Introduce Quick Preview (#6298)

This commit is contained in:
Alexander Zinchuk 2025-10-08 12:33:19 +02:00
parent 729ed3dd7d
commit 2b7fcbb240
25 changed files with 639 additions and 74 deletions

View File

@ -2281,3 +2281,4 @@
"GiftValueForSaleOnFragment" = "for sale on Fragment"; "GiftValueForSaleOnFragment" = "for sale on Fragment";
"GiftValueForSaleOnTelegram" = "for sale on Telegram"; "GiftValueForSaleOnTelegram" = "for sale on Telegram";
"EmbeddedMessageNoCaption" = "Caption removed"; "EmbeddedMessageNoCaption" = "Caption removed";
"QuickPreview" = "Quick Preview";

View File

@ -104,3 +104,4 @@ export { default as OneTimeMediaModal } from '../components/modals/oneTimeMedia/
export { default as WebAppsCloseConfirmationModal } from '../components/main/WebAppsCloseConfirmationModal'; export { default as WebAppsCloseConfirmationModal } from '../components/main/WebAppsCloseConfirmationModal';
export { default as FrozenAccountModal } from '../components/modals/frozenAccount/FrozenAccountModal'; export { default as FrozenAccountModal } from '../components/modals/frozenAccount/FrozenAccountModal';
export { default as ProfileRatingModal } from '../components/modals/profileRating/ProfileRatingModal'; export { default as ProfileRatingModal } from '../components/modals/profileRating/ProfileRatingModal';
export { default as QuickPreviewModal } from '../components/modals/quickPreview/QuickPreviewModal';

View File

@ -186,6 +186,7 @@ const Chat: FC<OwnProps & StateProps> = ({
reportMessages, reportMessages,
openFrozenAccountModal, openFrozenAccountModal,
updateChatMutedState, updateChatMutedState,
openQuickPreview,
} = getActions(); } = getActions();
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
@ -239,7 +240,13 @@ const Chat: FC<OwnProps & StateProps> = ({
const getIsForumPanelClosed = useSelectorSignal(selectIsForumPanelClosed); const getIsForumPanelClosed = useSelectorSignal(selectIsForumPanelClosed);
const handleClick = useLastCallback(() => { const handleClick = useLastCallback((e: React.MouseEvent) => {
if (e.altKey && !isSavedDialog && !isForum && !isPreview) {
e.preventDefault();
openQuickPreview({ id: chatId });
return;
}
const noForumTopicPanel = isMobile && isForumAsMessages; const noForumTopicPanel = isMobile && isForumAsMessages;
if (isMobile) { if (isMobile) {

View File

@ -103,6 +103,7 @@ const Topic: FC<OwnProps & StateProps> = ({
focusLastMessage, focusLastMessage,
setViewForumAsMessages, setViewForumAsMessages,
updateTopicMutedState, updateTopicMutedState,
openQuickPreview,
} = getActions(); } = getActions();
const lang = useOldLang(); const lang = useOldLang();
@ -153,7 +154,13 @@ const Topic: FC<OwnProps & StateProps> = ({
orderDiff, orderDiff,
}); });
const handleOpenTopic = useLastCallback(() => { const handleOpenTopic = useLastCallback((e: React.MouseEvent) => {
if (e.altKey) {
e.preventDefault();
openQuickPreview({ id: chatId, threadId: topic.id });
return;
}
openThread({ chatId, threadId: topic.id, shouldReplaceHistory: true }); openThread({ chatId, threadId: topic.id, shouldReplaceHistory: true });
setViewForumAsMessages({ chatId, isEnabled: false }); setViewForumAsMessages({ chatId, isEnabled: false });

View File

@ -8,6 +8,7 @@ import { getCanManageTopic, getHasAdminRight } from '../../../../global/helpers'
import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../../../../util/browser/windowEnvironment'; import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../../../../util/browser/windowEnvironment';
import { compact } from '../../../../util/iteratees'; import { compact } from '../../../../util/iteratees';
import useLang from '../../../../hooks/useLang';
import useOldLang from '../../../../hooks/useOldLang'; import useOldLang from '../../../../hooks/useOldLang';
export default function useTopicContextActions({ export default function useTopicContextActions({
@ -29,7 +30,8 @@ export default function useTopicContextActions({
handleMute?: NoneToVoidFunction; handleMute?: NoneToVoidFunction;
handleUnmute?: NoneToVoidFunction; handleUnmute?: NoneToVoidFunction;
}) { }) {
const lang = useOldLang(); const lang = useLang();
const oldLang = useOldLang();
return useMemo(() => { return useMemo(() => {
const { const {
@ -43,6 +45,7 @@ export default function useTopicContextActions({
toggleTopicPinned, toggleTopicPinned,
markTopicRead, markTopicRead,
openChatInNewTab, openChatInNewTab,
openQuickPreview,
} = getActions(); } = getActions();
const canToggleClosed = getCanManageTopic(chat, topic); const canToggleClosed = getCanManageTopic(chat, topic);
@ -56,9 +59,17 @@ export default function useTopicContextActions({
}, },
}; };
const actionQuickPreview = {
title: lang('QuickPreview'),
icon: 'eye-outline',
handler: () => {
openQuickPreview({ id: chatId, threadId: topicId });
},
};
const actionUnreadMark = topic.unreadCount || !wasOpened const actionUnreadMark = topic.unreadCount || !wasOpened
? { ? {
title: lang('MarkAsRead'), title: oldLang('MarkAsRead'),
icon: 'readchats', icon: 'readchats',
handler: () => { handler: () => {
markTopicRead({ chatId, topicId }); markTopicRead({ chatId, topicId });
@ -68,42 +79,42 @@ export default function useTopicContextActions({
const actionPin = canTogglePinned ? (isPinned const actionPin = canTogglePinned ? (isPinned
? { ? {
title: lang('UnpinFromTop'), title: oldLang('UnpinFromTop'),
icon: 'unpin', icon: 'unpin',
handler: () => toggleTopicPinned({ chatId, topicId, isPinned: false }), handler: () => toggleTopicPinned({ chatId, topicId, isPinned: false }),
} }
: { : {
title: lang('PinToTop'), title: oldLang('PinToTop'),
icon: 'pin', icon: 'pin',
handler: () => toggleTopicPinned({ chatId, topicId, isPinned: true }), handler: () => toggleTopicPinned({ chatId, topicId, isPinned: true }),
}) : undefined; }) : undefined;
const actionMute = ((isChatMuted && notifySettings.mutedUntil === undefined) || notifySettings.mutedUntil) const actionMute = ((isChatMuted && notifySettings.mutedUntil === undefined) || notifySettings.mutedUntil)
? { ? {
title: lang('ChatList.Unmute'), title: oldLang('ChatList.Unmute'),
icon: 'unmute', icon: 'unmute',
handler: handleUnmute, handler: handleUnmute,
} }
: { : {
title: `${lang('ChatList.Mute')}...`, title: `${oldLang('ChatList.Mute')}...`,
icon: 'mute', icon: 'mute',
handler: handleMute, handler: handleMute,
}; };
const actionCloseTopic = canToggleClosed ? (isClosed const actionCloseTopic = canToggleClosed ? (isClosed
? { ? {
title: lang('lng_forum_topic_reopen'), title: oldLang('lng_forum_topic_reopen'),
icon: 'reopen-topic', icon: 'reopen-topic',
handler: () => editTopic({ chatId, topicId, isClosed: false }), handler: () => editTopic({ chatId, topicId, isClosed: false }),
} }
: { : {
title: lang('lng_forum_topic_close'), title: oldLang('lng_forum_topic_close'),
icon: 'close-topic', icon: 'close-topic',
handler: () => editTopic({ chatId, topicId, isClosed: true }), handler: () => editTopic({ chatId, topicId, isClosed: true }),
}) : undefined; }) : undefined;
const actionDelete = canDelete ? { const actionDelete = canDelete ? {
title: lang('lng_forum_topic_delete'), title: oldLang('lng_forum_topic_delete'),
icon: 'delete', icon: 'delete',
destructive: true, destructive: true,
handler: handleDelete, handler: handleDelete,
@ -111,11 +122,12 @@ export default function useTopicContextActions({
return compact([ return compact([
actionOpenInNewTab, actionOpenInNewTab,
actionQuickPreview,
actionPin, actionPin,
actionUnreadMark, actionUnreadMark,
actionMute, actionMute,
actionCloseTopic, actionCloseTopic,
actionDelete, actionDelete,
]) as MenuItemContextAction[]; ]) as MenuItemContextAction[];
}, [topic, chat, isChatMuted, wasOpened, lang, canDelete, handleDelete, handleMute, handleUnmute]); }, [topic, chat, isChatMuted, wasOpened, lang, oldLang, canDelete, handleDelete, handleMute, handleUnmute]);
} }

View File

@ -52,7 +52,7 @@ const LeftSearchResultChat: FC<OwnProps & StateProps> = ({
withOpenAppButton, withOpenAppButton,
onClick, onClick,
}) => { }) => {
const { requestMainWebView, updateChatMutedState } = getActions(); const { requestMainWebView, updateChatMutedState, openQuickPreview } = getActions();
const oldLang = useOldLang(); const oldLang = useOldLang();
const [isMuteModalOpen, openMuteModal, closeMuteModal] = useFlag(); const [isMuteModalOpen, openMuteModal, closeMuteModal] = useFlag();
@ -85,7 +85,12 @@ const LeftSearchResultChat: FC<OwnProps & StateProps> = ({
handleChatFolderChange, handleChatFolderChange,
}, true); }, true);
const handleClick = useLastCallback(() => { const handleClick = useLastCallback((e: React.MouseEvent) => {
if (e.altKey && chat && !chat.isForum) {
e.preventDefault();
openQuickPreview({ id: chatId });
return;
}
onClick(chatId); onClick(chatId);
}); });
@ -100,7 +105,9 @@ const LeftSearchResultChat: FC<OwnProps & StateProps> = ({
}); });
}); });
const buttonRef = useSelectWithEnter(handleClick); const buttonRef = useSelectWithEnter(() => {
onClick(chatId);
});
return ( return (
<ListItem <ListItem

View File

@ -1,6 +1,6 @@
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import { memo, useCallback } from '../../../lib/teact/teact'; import { memo, useCallback } from '../../../lib/teact/teact';
import { withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { ApiTopic } from '../../../api/types'; import type { ApiTopic } from '../../../api/types';
@ -26,15 +26,23 @@ type StateProps = {
const TOPIC_ICON_SIZE = 2 * REM; const TOPIC_ICON_SIZE = 2 * REM;
const LeftSearchResultTopic: FC<OwnProps & StateProps> = ({ const LeftSearchResultTopic: FC<OwnProps & StateProps> = ({
chatId,
topicId, topicId,
topic, topic,
onClick, onClick,
}) => { }) => {
const handleClick = useCallback(() => { const { openQuickPreview } = getActions();
onClick(topicId);
}, [topicId, onClick]);
const buttonRef = useSelectWithEnter(handleClick); const handleClick = useCallback((e: React.MouseEvent) => {
if (e.altKey) {
e.preventDefault();
openQuickPreview({ id: chatId, threadId: topicId });
return;
}
onClick(topicId);
}, [chatId, topicId, onClick, openQuickPreview]);
const buttonRef = useSelectWithEnter(() => onClick(topicId));
if (!topic) { if (!topic) {
return undefined; return undefined;

View File

@ -96,9 +96,10 @@ type OwnProps = {
withDefaultBg: boolean; withDefaultBg: boolean;
isContactRequirePremium?: boolean; isContactRequirePremium?: boolean;
paidMessagesStars?: number; paidMessagesStars?: number;
onScrollDownToggle: BooleanToVoidFunction; isQuickPreview?: boolean;
onNotchToggle: BooleanToVoidFunction; onScrollDownToggle?: BooleanToVoidFunction;
onIntersectPinnedMessage: OnIntersectPinnedMessage; onNotchToggle?: AnyToVoidFunction;
onIntersectPinnedMessage?: OnIntersectPinnedMessage;
}; };
type StateProps = { type StateProps = {
@ -224,6 +225,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
canTranslate, canTranslate,
translationLanguage, translationLanguage,
shouldAutoTranslate, shouldAutoTranslate,
isQuickPreview,
onIntersectPinnedMessage, onIntersectPinnedMessage,
onScrollDownToggle, onScrollDownToggle,
onNotchToggle, onNotchToggle,
@ -443,7 +445,12 @@ const MessageList: FC<OwnProps & StateProps> = ({
return undefined; return undefined;
} }
return debounce(() => loadViewportMessages({ direction: LoadMoreDirection.Around }), 1000, true, false); return debounce(
() => loadViewportMessages({ direction: LoadMoreDirection.Around, chatId, threadId }),
1000,
true,
false,
);
// eslint-disable-next-line react-hooks-static-deps/exhaustive-deps // eslint-disable-next-line react-hooks-static-deps/exhaustive-deps
}, [loadViewportMessages, messageIds]); }, [loadViewportMessages, messageIds]);
@ -469,7 +476,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
const isFocusing = Boolean(selectTabState(global).focusedMessage?.chatId); const isFocusing = Boolean(selectTabState(global).focusedMessage?.chatId);
if (!isFocusing) { if (!isFocusing) {
onIntersectPinnedMessage({ shouldCancelWaiting: true }); onIntersectPinnedMessage?.({ shouldCancelWaiting: true });
} }
if (!container.parentElement) { if (!container.parentElement) {
@ -478,7 +485,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
scrollOffsetRef.current = container.scrollHeight - container.scrollTop; scrollOffsetRef.current = container.scrollHeight - container.scrollTop;
if (type === 'thread') { if (type === 'thread' && !isQuickPreview) {
setScrollOffset({ chatId, threadId, scrollOffset: scrollOffsetRef.current }); setScrollOffset({ chatId, threadId, scrollOffset: scrollOffsetRef.current });
} }
}); });
@ -717,7 +724,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
useEffect(() => { useEffect(() => {
if (hasMessages) return; if (hasMessages) return;
onScrollDownToggle(false); onScrollDownToggle?.(false);
}, [hasMessages, onScrollDownToggle]); }, [hasMessages, onScrollDownToggle]);
const activeKey = isRestricted ? ( const activeKey = isRestricted ? (
@ -790,6 +797,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
nameChangeDate={nameChangeDate} nameChangeDate={nameChangeDate}
photoChangeDate={photoChangeDate} photoChangeDate={photoChangeDate}
noAppearanceAnimation={!messageGroups || !shouldAnimateAppearanceRef.current} noAppearanceAnimation={!messageGroups || !shouldAnimateAppearanceRef.current}
isQuickPreview={isQuickPreview}
onScrollDownToggle={onScrollDownToggle} onScrollDownToggle={onScrollDownToggle}
onNotchToggle={onNotchToggle} onNotchToggle={onNotchToggle}
onIntersectPinnedMessage={onIntersectPinnedMessage} onIntersectPinnedMessage={onIntersectPinnedMessage}

View File

@ -74,9 +74,10 @@ interface OwnProps {
photoChangeDate?: number; photoChangeDate?: number;
noAppearanceAnimation: boolean; noAppearanceAnimation: boolean;
isSavedDialog?: boolean; isSavedDialog?: boolean;
onScrollDownToggle: BooleanToVoidFunction; isQuickPreview?: boolean;
onNotchToggle: AnyToVoidFunction; onScrollDownToggle?: BooleanToVoidFunction;
onIntersectPinnedMessage: OnIntersectPinnedMessage; onNotchToggle?: AnyToVoidFunction;
onIntersectPinnedMessage?: OnIntersectPinnedMessage;
canPost?: boolean; canPost?: boolean;
} }
@ -110,6 +111,7 @@ const MessageListContent: FC<OwnProps> = ({
photoChangeDate, photoChangeDate,
noAppearanceAnimation, noAppearanceAnimation,
isSavedDialog, isSavedDialog,
isQuickPreview,
onScrollDownToggle, onScrollDownToggle,
onNotchToggle, onNotchToggle,
onIntersectPinnedMessage, onIntersectPinnedMessage,
@ -126,7 +128,7 @@ const MessageListContent: FC<OwnProps> = ({
observeIntersectionForReading, observeIntersectionForReading,
observeIntersectionForLoading, observeIntersectionForLoading,
observeIntersectionForPlaying, observeIntersectionForPlaying,
} = useMessageObservers(type, containerRef, memoFirstUnreadIdRef, onIntersectPinnedMessage, chatId); } = useMessageObservers(type, containerRef, memoFirstUnreadIdRef, onIntersectPinnedMessage, chatId, isQuickPreview);
const { const {
withHistoryTriggers, withHistoryTriggers,

View File

@ -102,7 +102,7 @@ import ReactorListModal from './ReactorListModal.async';
import MiddleSearch from './search/MiddleSearch.async'; import MiddleSearch from './search/MiddleSearch.async';
import './MiddleColumn.scss'; import './MiddleColumn.scss';
import styles from './MiddleColumn.module.scss'; import backgroundStyles from '../../styles/_patternBackground.module.scss';
interface OwnProps { interface OwnProps {
leftColumnRef: ElementRef<HTMLDivElement>; leftColumnRef: ElementRef<HTMLDivElement>;
@ -439,12 +439,12 @@ function MiddleColumn({
); );
const bgClassName = buildClassName( const bgClassName = buildClassName(
styles.background, backgroundStyles.background,
withRightColumnAnimation && styles.withTransition, withRightColumnAnimation && backgroundStyles.withTransition,
customBackground && styles.customBgImage, customBackground && backgroundStyles.customBgImage,
backgroundColor && styles.customBgColor, backgroundColor && backgroundStyles.customBgColor,
customBackground && isBackgroundBlurred && styles.blurred, customBackground && isBackgroundBlurred && backgroundStyles.blurred,
isRightColumnShown && styles.withRightColumn, isRightColumnShown && backgroundStyles.withRightColumn,
); );
const messagingDisabledClassName = buildClassName( const messagingDisabledClassName = buildClassName(
@ -578,7 +578,7 @@ function MiddleColumn({
paidMessagesStars={paidMessagesStars} paidMessagesStars={paidMessagesStars}
withBottomShift={withMessageListBottomShift} withBottomShift={withMessageListBottomShift}
withDefaultBg={Boolean(!customBackground && !backgroundColor)} withDefaultBg={Boolean(!customBackground && !backgroundColor)}
onIntersectPinnedMessage={renderingHandleIntersectPinnedMessage!} onIntersectPinnedMessage={renderingHandleIntersectPinnedMessage}
/> />
<div className={footerClassName}> <div className={footerClassName}>
<FloatingActionButtons <FloatingActionButtons

View File

@ -17,8 +17,9 @@ export default function useMessageObservers(
type: MessageListType, type: MessageListType,
containerRef: ElementRef<HTMLDivElement>, containerRef: ElementRef<HTMLDivElement>,
memoFirstUnreadIdRef: { current: number | undefined }, memoFirstUnreadIdRef: { current: number | undefined },
onIntersectPinnedMessage: OnIntersectPinnedMessage, onIntersectPinnedMessage: OnIntersectPinnedMessage | undefined,
chatId: string, chatId: string,
isQuickPreview?: boolean,
) { ) {
const { const {
markMessageListRead, markMentionsRead, animateUnreadReaction, markMessageListRead, markMentionsRead, animateUnreadReaction,
@ -81,12 +82,18 @@ export default function useMessageObservers(
} }
}); });
if (memoFirstUnreadIdRef.current && maxId >= memoFirstUnreadIdRef.current) { if (!isQuickPreview) {
markMessageListRead({ maxId }); if (memoFirstUnreadIdRef.current && maxId >= memoFirstUnreadIdRef.current) {
} markMessageListRead({ maxId });
}
if (mentionIds.length) { if (mentionIds.length) {
markMentionsRead({ chatId, messageIds: mentionIds }); markMentionsRead({ chatId, messageIds: mentionIds });
}
if (scheduledToUpdateViews.length) {
scheduleForViewsIncrement({ chatId, ids: scheduledToUpdateViews });
}
} }
if (reactionIds.length) { if (reactionIds.length) {
@ -94,11 +101,7 @@ export default function useMessageObservers(
} }
if (viewportPinnedIdsToAdd.length || viewportPinnedIdsToRemove.length) { if (viewportPinnedIdsToAdd.length || viewportPinnedIdsToRemove.length) {
onIntersectPinnedMessage({ viewportPinnedIdsToAdd, viewportPinnedIdsToRemove }); onIntersectPinnedMessage?.({ viewportPinnedIdsToAdd, viewportPinnedIdsToRemove });
}
if (scheduledToUpdateViews.length) {
scheduleForViewsIncrement({ chatId, ids: scheduledToUpdateViews });
} }
}); });

View File

@ -28,8 +28,8 @@ export default function useScrollHooks(
getContainerHeight: Signal<number | undefined>, getContainerHeight: Signal<number | undefined>,
isViewportNewest: boolean, isViewportNewest: boolean,
isUnread: boolean, isUnread: boolean,
onScrollDownToggle: BooleanToVoidFunction, onScrollDownToggle: BooleanToVoidFunction | undefined,
onNotchToggle: BooleanToVoidFunction, onNotchToggle: AnyToVoidFunction | undefined,
isReady: boolean, isReady: boolean,
) { ) {
const { loadViewportMessages } = getActions(); const { loadViewportMessages } = getActions();
@ -51,14 +51,16 @@ export default function useScrollHooks(
if (!isReady) return; if (!isReady) return;
if (!messageIds?.length) { if (!messageIds?.length) {
onScrollDownToggle(false); onScrollDownToggle?.(false);
onNotchToggle(false); onNotchToggle?.(false);
return; return;
} }
if (!isViewportNewest) { if (!isViewportNewest) {
onScrollDownToggle(true); onScrollDownToggle?.(true);
onNotchToggle(true); onNotchToggle?.(true);
return; return;
} }
@ -74,8 +76,8 @@ export default function useScrollHooks(
if (scrollHeight === 0) return; if (scrollHeight === 0) return;
onScrollDownToggle(isUnread ? !isAtBottom : !isNearBottom); onScrollDownToggle?.(isUnread ? !isAtBottom : !isNearBottom);
onNotchToggle(!isAtBottom); onNotchToggle?.(!isAtBottom);
}); });
const { const {

View File

@ -1,4 +1,4 @@
import type React from '../../lib/teact/teact'; import type { FC } from '../../lib/teact/teact';
import { memo } from '../../lib/teact/teact'; import { memo } from '../../lib/teact/teact';
import { withGlobal } from '../../global'; import { withGlobal } from '../../global';
@ -37,6 +37,7 @@ import PaidReactionModal from './paidReaction/PaidReactionModal.async';
import PreparedMessageModal from './preparedMessage/PreparedMessageModal.async'; import PreparedMessageModal from './preparedMessage/PreparedMessageModal.async';
import PriceConfirmModal from './priceConfirm/PriceConfirmModal.async'; import PriceConfirmModal from './priceConfirm/PriceConfirmModal.async';
import ProfileRatingModal from './profileRating/ProfileRatingModal.async'; import ProfileRatingModal from './profileRating/ProfileRatingModal.async';
import QuickPreviewModal from './quickPreview/QuickPreviewModal.async';
import ReportAdModal from './reportAd/ReportAdModal.async'; import ReportAdModal from './reportAd/ReportAdModal.async';
import ReportModal from './reportModal/ReportModal.async'; import ReportModal from './reportModal/ReportModal.async';
import SharePreparedMessageModal from './sharePreparedMessage/SharePreparedMessageModal.async'; import SharePreparedMessageModal from './sharePreparedMessage/SharePreparedMessageModal.async';
@ -97,14 +98,15 @@ type ModalKey = keyof Pick<TabState,
'isFrozenAccountModalOpen' | 'isFrozenAccountModalOpen' |
'deleteAccountModal' | 'deleteAccountModal' |
'isAgeVerificationModalOpen' | 'isAgeVerificationModalOpen' |
'profileRatingModal' 'profileRatingModal' |
'quickPreview'
>; >;
type StateProps = { type StateProps = {
[K in ModalKey]?: TabState[K]; [K in ModalKey]?: TabState[K];
}; };
type ModalRegistry = { type ModalRegistry = {
[K in ModalKey]: React.FC<{ [K in ModalKey]: FC<{
modal: TabState[K]; modal: TabState[K];
}>; }>;
}; };
@ -157,6 +159,7 @@ const MODALS: ModalRegistry = {
deleteAccountModal: DeleteAccountModal, deleteAccountModal: DeleteAccountModal,
isAgeVerificationModalOpen: AgeVerificationModal, isAgeVerificationModalOpen: AgeVerificationModal,
profileRatingModal: ProfileRatingModal, profileRatingModal: ProfileRatingModal,
quickPreview: QuickPreviewModal,
}; };
const MODAL_KEYS = Object.keys(MODALS) as ModalKey[]; const MODAL_KEYS = Object.keys(MODALS) as ModalKey[];
const MODAL_ENTRIES = Object.entries(MODALS) as Entries<ModalRegistry>; const MODAL_ENTRIES = Object.entries(MODALS) as Entries<ModalRegistry>;

View File

@ -0,0 +1,18 @@
import type { FC } from '../../../lib/teact/teact';
import { memo } from '../../../lib/teact/teact';
import type { OwnProps } from './QuickPreviewModal';
import { Bundles } from '../../../util/moduleLoader';
import useModuleLoader from '../../../hooks/useModuleLoader';
const QuickPreviewModalAsync: FC<OwnProps> = memo((props) => {
const { modal } = props;
const QuickPreviewModal = useModuleLoader(Bundles.Extra, 'QuickPreviewModal', !modal);
return QuickPreviewModal ? <QuickPreviewModal {...props} /> : undefined;
});
export default QuickPreviewModalAsync;

View File

@ -0,0 +1,55 @@
.root {
:global(.modal-dialog) {
overflow: hidden;
height: 40rem;
max-height: 90vh;
}
}
.content {
overflow: hidden;
display: flex;
flex-direction: column;
height: 100%;
margin: 0 !important;
padding: 0 !important;
:global(.messages-container) {
pointer-events: none;
// Prevent right column width calculation from applying
width: 100% !important;
}
:global(.sticky-date span) {
pointer-events: none !important;
}
}
// Analogue of `#MiddleColumn` from MiddleColumn.module.scss
.column {
cursor: pointer;
position: relative;
z-index: 1;
display: flex;
justify-content: center;
min-width: 0;
height: 100%;
}
// Analogue of `.messages-layout` from MiddleColumn.module.scss
.messagesLayout {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
}

View File

@ -0,0 +1,133 @@
import type { FC } from '@teact';
import { memo, useEffect } from '@teact';
import { getActions, withGlobal } from '../../../global';
import type { TabState } from '../../../global/types';
import type { ThemeKey } from '../../../types';
import { MAIN_THREAD_ID } from '../../../api/types';
import { selectTheme, selectThemeValues } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle';
import captureEscKeyListener from '../../../util/captureEscKeyListener';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useCustomBackground from '../../../hooks/useCustomBackground';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useLastCallback from '../../../hooks/useLastCallback';
import MessageList from '../../middle/MessageList';
import Modal from '../../ui/Modal';
import QuickPreviewModalHeader from './QuickPreviewModalHeader';
import backgroundStyles from '../../../styles/_patternBackground.module.scss';
import styles from './QuickPreviewModal.module.scss';
export type OwnProps = {
modal: TabState['quickPreview'];
};
type StateProps = {
theme: ThemeKey;
customBackground?: string;
backgroundColor?: string;
patternColor?: string;
isBackgroundBlurred?: boolean;
};
const QuickPreviewModal: FC<OwnProps & StateProps> = ({
modal,
theme,
customBackground,
backgroundColor,
patternColor,
isBackgroundBlurred,
}) => {
const { closeQuickPreview, openChat, openThread } = getActions();
const chatId = modal?.chatId;
const threadId = modal?.threadId;
const isOpen = Boolean(chatId);
const customBackgroundValue = useCustomBackground(theme, customBackground);
const handleClose = useLastCallback(() => {
closeQuickPreview();
});
const handleContentClick = useLastCallback(() => {
if (chatId) {
if (threadId) {
openThread({ chatId, threadId, shouldReplaceHistory: true });
} else {
openChat({ id: chatId, shouldReplaceHistory: true });
}
closeQuickPreview();
}
});
useEffect(() => isOpen ? captureEscKeyListener(handleClose) : undefined, [isOpen, handleClose]);
useHistoryBack({
isActive: isOpen,
onBack: handleClose,
});
const { chatId: renderingChatId, threadId: renderingThreadId } = useCurrentOrPrev(modal, true)!;
const bgClassName = buildClassName(
backgroundStyles.background,
customBackground && backgroundStyles.customBgImage,
backgroundColor && backgroundStyles.customBgColor,
customBackground && isBackgroundBlurred && backgroundStyles.blurred,
);
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
header={<QuickPreviewModalHeader chatId={renderingChatId} threadId={renderingThreadId} onClose={handleClose} />}
className={styles.root}
contentClassName={styles.content}
>
<div
className={styles.column}
style={buildStyle(
`--pattern-color: ${patternColor}`,
backgroundColor && `--theme-background-color: ${backgroundColor}`,
)}
onClick={handleContentClick}
>
<div
className={bgClassName}
style={customBackgroundValue ? `--custom-background: ${customBackgroundValue}` : undefined}
/>
<div className={styles.messagesLayout}>
<MessageList
chatId={renderingChatId}
threadId={renderingThreadId || MAIN_THREAD_ID}
type="thread"
canPost={false}
isReady
withDefaultBg={Boolean(!customBackground && !backgroundColor)}
isQuickPreview
/>
</div>
</div>
</Modal>
);
};
export default memo(withGlobal<OwnProps>((global, { modal: chatId }): Complete<StateProps> => {
const theme = selectTheme(global);
const {
isBlurred: isBackgroundBlurred, background: customBackground, backgroundColor, patternColor,
} = selectThemeValues(global, theme) || {};
return {
theme,
customBackground,
backgroundColor,
patternColor,
isBackgroundBlurred,
};
})(QuickPreviewModal));

View File

@ -0,0 +1,98 @@
.root {
position: relative;
min-height: 3rem;
padding: 0.5rem 0.625rem 0.625rem 0.75rem !important;
border-bottom: 1px solid var(--color-borders);
:global(.modal-title) {
margin: 0 !important;
font-size: inherit;
font-weight: inherit;
}
}
.closeButton, .markAsReadButton {
z-index: 1;
}
.closeButton {
position: absolute !important;
top: 0.5rem;
right: 0.5rem;
}
.markAsReadButton {
position: absolute !important;
top: 0.5rem;
right: 3rem;
}
.chatInfoOverride {
// Mimic left column chat list styling
:global(.ChatInfo) {
display: flex;
flex: 1;
gap: 0.625rem;
align-items: center;
}
:global(.Avatar) {
flex-shrink: 0;
width: 2.625rem;
height: 2.625rem;
}
:global(.info) {
overflow: hidden;
display: flex;
flex: 1;
flex-direction: column;
gap: 0.125rem;
text-align: left;
}
:global(.title),
:global(.fullName) {
overflow: hidden;
margin-bottom: 0;
font-size: 1rem;
font-weight: var(--font-weight-semibold);
line-height: 1.25rem;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.status) {
font-size: 0.875rem;
line-height: 1.125rem;
color: var(--color-text-secondary);
}
:global(.topic-header-icon) {
--custom-emoji-size: 2.25rem;
width: 2.5rem !important;
height: 2.5rem !important;
font-size: 2.25rem;
:global(.emoji-small) {
width: 1.25rem;
height: 1.25rem;
}
:global(.topic-icon-letter) {
font-size: 1.25rem;
}
&:global(.general-forum-icon) {
color: var(--color-text-secondary);
}
}
:global(.Transition.message-count-transition) {
height: 1.125rem;
}
}

View File

@ -0,0 +1,157 @@
import type { FC } from '@teact';
import { memo } from '@teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiChat, ApiTypingStatus, ApiUpdateConnectionStateType } from '../../../api/types';
import type { ThreadId } from '../../../types';
import { MAIN_THREAD_ID } from '../../../api/types';
import { getIsSavedDialog } from '../../../global/helpers';
import { selectChat, selectThreadParam, selectTopic } from '../../../global/selectors';
import { isUserId } from '../../../util/entities/ids';
import useConnectionStatus from '../../../hooks/useConnectionStatus';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import GroupChatInfo from '../../common/GroupChatInfo';
import Icon from '../../common/icons/Icon';
import PrivateChatInfo from '../../common/PrivateChatInfo';
import Button from '../../ui/Button';
import styles from './QuickPreviewModalHeader.module.scss';
type OwnProps = {
chatId: string;
threadId?: ThreadId;
onClose: VoidFunction;
};
type StateProps = {
chat?: ApiChat;
connectionState?: ApiUpdateConnectionStateType;
isSyncing?: boolean;
isFetchingDifference?: boolean;
typingStatus?: ApiTypingStatus;
isSavedDialog?: boolean;
unreadCount?: number;
hasUnreadMark?: boolean;
};
const EMOJI_STATUS_SIZE = 20;
const QuickPreviewModalHeader: FC<OwnProps & StateProps> = ({
chatId,
threadId,
chat,
connectionState,
isSyncing,
isFetchingDifference,
typingStatus,
isSavedDialog,
unreadCount,
hasUnreadMark,
onClose,
}) => {
const lang = useLang();
const oldLang = useOldLang();
const { markChatMessagesRead } = getActions();
const {
connectionStatusText,
} = useConnectionStatus(oldLang, connectionState, isSyncing || isFetchingDifference, true);
const handleMarkAsRead = useLastCallback(() => {
markChatMessagesRead({ id: chatId });
});
const savedMessagesStatus = isSavedDialog ? lang('SavedMessages') : undefined;
const realChatId = isSavedDialog ? String(MAIN_THREAD_ID) : chatId;
const displayChatId = chat?.isMonoforum ? chat.linkedMonoforumId! : realChatId;
return (
<div className={styles.root}>
{Boolean(unreadCount || hasUnreadMark) && (
<Button
round
color="translucent"
size="smaller"
ariaLabel={lang('ChatListContextMaskAsRead')}
onClick={handleMarkAsRead}
className={styles.markAsReadButton}
>
<Icon name="readchats" />
</Button>
)}
<Button
round
color="translucent"
size="smaller"
ariaLabel={lang('Close')}
onClick={onClose}
className={styles.closeButton}
>
<Icon name="close" />
</Button>
<div className="modal-title">
<div className={styles.chatInfoOverride}>
{isUserId(displayChatId) ? (
<PrivateChatInfo
key={displayChatId}
userId={displayChatId}
typingStatus={typingStatus}
status={connectionStatusText || savedMessagesStatus}
withDots={Boolean(connectionStatusText)}
withFullInfo={false}
withMediaViewer={false}
withStory={false}
withUpdatingStatus
isSavedDialog={isSavedDialog}
emojiStatusSize={EMOJI_STATUS_SIZE}
noRtl
/>
) : (
<GroupChatInfo
key={displayChatId}
chatId={displayChatId}
threadId={!isSavedDialog ? threadId : undefined}
typingStatus={typingStatus}
withMonoforumStatus={chat?.isMonoforum}
status={connectionStatusText || savedMessagesStatus}
withDots={Boolean(connectionStatusText)}
withMediaViewer={false}
withFullInfo={false}
withUpdatingStatus
withStory={false}
isSavedDialog={isSavedDialog}
emojiStatusSize={EMOJI_STATUS_SIZE}
noRtl
/>
)}
</div>
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId, threadId }): Complete<StateProps> => {
const chat = selectChat(global, chatId);
const typingStatus = selectThreadParam(global, chatId, threadId || MAIN_THREAD_ID, 'typingStatus');
const isSavedDialog = getIsSavedDialog(chatId, threadId || MAIN_THREAD_ID, global.currentUserId);
const unreadCount = chat?.isForum && threadId
? selectTopic(global, chatId, threadId)?.unreadCount
: chat?.unreadCount;
return {
chat,
connectionState: global.connectionState,
isSyncing: global.isSyncing,
isFetchingDifference: global.isFetchingDifference,
typingStatus,
isSavedDialog,
unreadCount,
hasUnreadMark: chat?.hasUnreadMark,
};
},
)(QuickPreviewModalHeader));

View File

@ -1180,3 +1180,19 @@ addActionHandler('updateSharePreparedMessageModalSendArgs', async (global, actio
}, tabId); }, tabId);
setGlobal(global); setGlobal(global);
}); });
addActionHandler('openQuickPreview', (global, actions, payload): ActionReturnType => {
const { id: chatId, threadId, tabId = getCurrentTabId() } = payload;
return updateTabState(global, {
quickPreview: { chatId, threadId },
}, tabId);
});
addActionHandler('closeQuickPreview', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
quickPreview: undefined,
}, tabId);
});

View File

@ -2509,6 +2509,12 @@ export interface ActionPayloads {
} & WithTabId; } & WithTabId;
closeSuggestedPostApprovalModal: WithTabId | undefined; closeSuggestedPostApprovalModal: WithTabId | undefined;
openQuickPreview: {
id: string;
threadId?: ThreadId;
} & WithTabId;
closeQuickPreview: WithTabId | undefined;
openDeleteMessageModal: ({ openDeleteMessageModal: ({
chatId: string; chatId: string;
messageIds: number[]; messageIds: number[];

View File

@ -875,6 +875,11 @@ export type TabState = {
errorKey?: RegularLangFnParameters; errorKey?: RegularLangFnParameters;
}; };
quickPreview?: {
chatId: string;
threadId?: ThreadId;
};
isWaitingForStarGiftUpgrade?: true; isWaitingForStarGiftUpgrade?: true;
isWaitingForStarGiftTransfer?: true; isWaitingForStarGiftTransfer?: true;
insertingPeerIdMention?: string; insertingPeerIdMention?: string;

View File

@ -5,9 +5,7 @@ import type { ApiChat, ApiTopic, ApiUser } from '../api/types';
import type { MenuItemContextAction } from '../components/ui/ListItem'; import type { MenuItemContextAction } from '../components/ui/ListItem';
import { SERVICE_NOTIFICATIONS_USER_ID } from '../config'; import { SERVICE_NOTIFICATIONS_USER_ID } from '../config';
import { import { getCanDeleteChat, isChatArchived, isChatChannel, isChatGroup } from '../global/helpers';
getCanDeleteChat, isChatArchived, isChatChannel, isChatGroup,
} from '../global/helpers';
import { IS_TAURI } from '../util/browser/globalEnvironment'; import { IS_TAURI } from '../util/browser/globalEnvironment';
import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../util/browser/windowEnvironment'; import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../util/browser/windowEnvironment';
import { isUserId } from '../util/entities/ids'; import { isUserId } from '../util/entities/ids';
@ -86,6 +84,7 @@ const useChatContextActions = ({
markChatMessagesRead, markChatMessagesRead,
markChatUnread, markChatUnread,
openChatInNewTab, openChatInNewTab,
openQuickPreview,
} = getActions(); } = getActions();
const actionOpenInNewTab = IS_OPEN_IN_NEW_TAB_SUPPORTED && { const actionOpenInNewTab = IS_OPEN_IN_NEW_TAB_SUPPORTED && {
@ -100,6 +99,16 @@ const useChatContextActions = ({
}, },
}; };
const actionQuickPreview = !isSavedDialog && !chat.isForum && {
title: lang('QuickPreview'),
icon: 'eye-outline',
handler: () => {
openQuickPreview({
id: chat.id,
});
},
};
const togglePinned = () => { const togglePinned = () => {
if (isSavedDialog) { if (isSavedDialog) {
toggleSavedDialogPinned({ id: chat.id }); toggleSavedDialogPinned({ id: chat.id });
@ -128,7 +137,7 @@ const useChatContextActions = ({
}; };
if (isSavedDialog) { if (isSavedDialog) {
return compact([actionOpenInNewTab, actionPin, actionDelete]) as MenuItemContextAction[]; return compact([actionOpenInNewTab, actionQuickPreview, actionPin, actionDelete]) as MenuItemContextAction[];
} }
const actionAddToFolder = canChangeFolder ? { const actionAddToFolder = canChangeFolder ? {
@ -150,12 +159,15 @@ const useChatContextActions = ({
}; };
if (isInSearch) { if (isInSearch) {
return compact([actionOpenInNewTab, actionPin, actionAddToFolder, actionMute]) as MenuItemContextAction[]; return compact([
actionOpenInNewTab, actionQuickPreview, actionPin, actionAddToFolder, actionMute,
]) as MenuItemContextAction[];
} }
const actionMaskAsRead = ( const actionMaskAsRead = (
chat.unreadCount || chat.hasUnreadMark || Object.values(topics || {}).some(({ unreadCount }) => unreadCount) chat.unreadCount || chat.hasUnreadMark || Object.values(topics || {}).some(({ unreadCount }) => unreadCount)
) ? { )
? {
title: lang('ChatListContextMaskAsRead'), title: lang('ChatListContextMaskAsRead'),
icon: 'readchats', icon: 'readchats',
handler: () => markChatMessagesRead({ id: chat.id }), handler: () => markChatMessagesRead({ id: chat.id }),
@ -177,6 +189,7 @@ const useChatContextActions = ({
return compact([ return compact([
actionOpenInNewTab, actionOpenInNewTab,
actionQuickPreview,
actionAddToFolder, actionAddToFolder,
actionMaskAsRead, actionMaskAsRead,
actionMarkAsUnread, actionMarkAsUnread,

View File

@ -1,24 +1,26 @@
import { useCallback, useEffect, useRef } from '../lib/teact/teact'; import { useEffect, useRef } from '../lib/teact/teact';
import useLastCallback from './useLastCallback.ts';
const useSendWithEnter = ( const useSendWithEnter = (
onSelect: NoneToVoidFunction, onSelect: NoneToVoidFunction,
) => { ) => {
const buttonRef = useRef<HTMLDivElement>(); const buttonRef = useRef<HTMLDivElement>();
const handleKeyDown = useCallback((e: KeyboardEvent) => { const handleKeyDown = useLastCallback((e: KeyboardEvent) => {
if (e.key !== 'Enter') return; if (e.key !== 'Enter') return;
const isFocused = buttonRef.current === document.activeElement; const isFocused = buttonRef.current === document.activeElement;
if (isFocused) { if (isFocused) {
onSelect(); onSelect();
} }
}, [onSelect]); });
useEffect(() => { useEffect(() => {
window.addEventListener('keydown', handleKeyDown, false); window.addEventListener('keydown', handleKeyDown, false);
return () => window.removeEventListener('keydown', handleKeyDown, false); return () => window.removeEventListener('keydown', handleKeyDown, false);
}, [handleKeyDown]); }, []);
return buttonRef; return buttonRef;
}; };

View File

@ -25,7 +25,7 @@
} }
:global(html.theme-light) &:not(.customBgImage)::before { :global(html.theme-light) &:not(.customBgImage)::before {
background-image: url('../../assets/chat-bg-br.png'); background-image: url('../assets/chat-bg-br.png');
} }
&:not(.customBgImage).customBgColor::before { &:not(.customBgImage).customBgColor::before {
@ -77,14 +77,14 @@
bottom: 0; bottom: 0;
left: 0; left: 0;
background-image: url('../../assets/chat-bg-pattern-light.png'); background-image: url('../assets/chat-bg-pattern-light.png');
background-repeat: repeat; background-repeat: repeat;
background-position: top right; background-position: top right;
background-size: 510px auto; background-size: 510px auto;
mix-blend-mode: overlay; mix-blend-mode: overlay;
:global(html.theme-dark) & { :global(html.theme-dark) & {
background-image: url('../../assets/chat-bg-pattern-dark.png'); background-image: url('../assets/chat-bg-pattern-dark.png');
mix-blend-mode: unset; mix-blend-mode: unset;
} }
} }

View File

@ -1705,6 +1705,7 @@ export interface LangPair {
'GiftValueForSaleOnFragment': undefined; 'GiftValueForSaleOnFragment': undefined;
'GiftValueForSaleOnTelegram': undefined; 'GiftValueForSaleOnTelegram': undefined;
'EmbeddedMessageNoCaption': undefined; 'EmbeddedMessageNoCaption': undefined;
'QuickPreview': undefined;
} }
export interface LangPairWithVariables<V = LangVariable> { export interface LangPairWithVariables<V = LangVariable> {