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";
"GiftValueForSaleOnTelegram" = "for sale on Telegram";
"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 FrozenAccountModal } from '../components/modals/frozenAccount/FrozenAccountModal';
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,
openFrozenAccountModal,
updateChatMutedState,
openQuickPreview,
} = getActions();
const { isMobile } = useAppLayout();
@ -239,7 +240,13 @@ const Chat: FC<OwnProps & StateProps> = ({
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;
if (isMobile) {

View File

@ -103,6 +103,7 @@ const Topic: FC<OwnProps & StateProps> = ({
focusLastMessage,
setViewForumAsMessages,
updateTopicMutedState,
openQuickPreview,
} = getActions();
const lang = useOldLang();
@ -153,7 +154,13 @@ const Topic: FC<OwnProps & StateProps> = ({
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 });
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 { compact } from '../../../../util/iteratees';
import useLang from '../../../../hooks/useLang';
import useOldLang from '../../../../hooks/useOldLang';
export default function useTopicContextActions({
@ -29,7 +30,8 @@ export default function useTopicContextActions({
handleMute?: NoneToVoidFunction;
handleUnmute?: NoneToVoidFunction;
}) {
const lang = useOldLang();
const lang = useLang();
const oldLang = useOldLang();
return useMemo(() => {
const {
@ -43,6 +45,7 @@ export default function useTopicContextActions({
toggleTopicPinned,
markTopicRead,
openChatInNewTab,
openQuickPreview,
} = getActions();
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
? {
title: lang('MarkAsRead'),
title: oldLang('MarkAsRead'),
icon: 'readchats',
handler: () => {
markTopicRead({ chatId, topicId });
@ -68,42 +79,42 @@ export default function useTopicContextActions({
const actionPin = canTogglePinned ? (isPinned
? {
title: lang('UnpinFromTop'),
title: oldLang('UnpinFromTop'),
icon: 'unpin',
handler: () => toggleTopicPinned({ chatId, topicId, isPinned: false }),
}
: {
title: lang('PinToTop'),
title: oldLang('PinToTop'),
icon: 'pin',
handler: () => toggleTopicPinned({ chatId, topicId, isPinned: true }),
}) : undefined;
const actionMute = ((isChatMuted && notifySettings.mutedUntil === undefined) || notifySettings.mutedUntil)
? {
title: lang('ChatList.Unmute'),
title: oldLang('ChatList.Unmute'),
icon: 'unmute',
handler: handleUnmute,
}
: {
title: `${lang('ChatList.Mute')}...`,
title: `${oldLang('ChatList.Mute')}...`,
icon: 'mute',
handler: handleMute,
};
const actionCloseTopic = canToggleClosed ? (isClosed
? {
title: lang('lng_forum_topic_reopen'),
title: oldLang('lng_forum_topic_reopen'),
icon: 'reopen-topic',
handler: () => editTopic({ chatId, topicId, isClosed: false }),
}
: {
title: lang('lng_forum_topic_close'),
title: oldLang('lng_forum_topic_close'),
icon: 'close-topic',
handler: () => editTopic({ chatId, topicId, isClosed: true }),
}) : undefined;
const actionDelete = canDelete ? {
title: lang('lng_forum_topic_delete'),
title: oldLang('lng_forum_topic_delete'),
icon: 'delete',
destructive: true,
handler: handleDelete,
@ -111,11 +122,12 @@ export default function useTopicContextActions({
return compact([
actionOpenInNewTab,
actionQuickPreview,
actionPin,
actionUnreadMark,
actionMute,
actionCloseTopic,
actionDelete,
]) 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,
onClick,
}) => {
const { requestMainWebView, updateChatMutedState } = getActions();
const { requestMainWebView, updateChatMutedState, openQuickPreview } = getActions();
const oldLang = useOldLang();
const [isMuteModalOpen, openMuteModal, closeMuteModal] = useFlag();
@ -85,7 +85,12 @@ const LeftSearchResultChat: FC<OwnProps & StateProps> = ({
handleChatFolderChange,
}, true);
const handleClick = useLastCallback(() => {
const handleClick = useLastCallback((e: React.MouseEvent) => {
if (e.altKey && chat && !chat.isForum) {
e.preventDefault();
openQuickPreview({ id: chatId });
return;
}
onClick(chatId);
});
@ -100,7 +105,9 @@ const LeftSearchResultChat: FC<OwnProps & StateProps> = ({
});
});
const buttonRef = useSelectWithEnter(handleClick);
const buttonRef = useSelectWithEnter(() => {
onClick(chatId);
});
return (
<ListItem

View File

@ -1,6 +1,6 @@
import type { FC } 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';
@ -26,15 +26,23 @@ type StateProps = {
const TOPIC_ICON_SIZE = 2 * REM;
const LeftSearchResultTopic: FC<OwnProps & StateProps> = ({
chatId,
topicId,
topic,
onClick,
}) => {
const handleClick = useCallback(() => {
onClick(topicId);
}, [topicId, onClick]);
const { openQuickPreview } = getActions();
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) {
return undefined;

View File

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

View File

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

View File

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

View File

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

View File

@ -28,8 +28,8 @@ export default function useScrollHooks(
getContainerHeight: Signal<number | undefined>,
isViewportNewest: boolean,
isUnread: boolean,
onScrollDownToggle: BooleanToVoidFunction,
onNotchToggle: BooleanToVoidFunction,
onScrollDownToggle: BooleanToVoidFunction | undefined,
onNotchToggle: AnyToVoidFunction | undefined,
isReady: boolean,
) {
const { loadViewportMessages } = getActions();
@ -51,14 +51,16 @@ export default function useScrollHooks(
if (!isReady) return;
if (!messageIds?.length) {
onScrollDownToggle(false);
onNotchToggle(false);
onScrollDownToggle?.(false);
onNotchToggle?.(false);
return;
}
if (!isViewportNewest) {
onScrollDownToggle(true);
onNotchToggle(true);
onScrollDownToggle?.(true);
onNotchToggle?.(true);
return;
}
@ -74,8 +76,8 @@ export default function useScrollHooks(
if (scrollHeight === 0) return;
onScrollDownToggle(isUnread ? !isAtBottom : !isNearBottom);
onNotchToggle(!isAtBottom);
onScrollDownToggle?.(isUnread ? !isAtBottom : !isNearBottom);
onNotchToggle?.(!isAtBottom);
});
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 { withGlobal } from '../../global';
@ -37,6 +37,7 @@ import PaidReactionModal from './paidReaction/PaidReactionModal.async';
import PreparedMessageModal from './preparedMessage/PreparedMessageModal.async';
import PriceConfirmModal from './priceConfirm/PriceConfirmModal.async';
import ProfileRatingModal from './profileRating/ProfileRatingModal.async';
import QuickPreviewModal from './quickPreview/QuickPreviewModal.async';
import ReportAdModal from './reportAd/ReportAdModal.async';
import ReportModal from './reportModal/ReportModal.async';
import SharePreparedMessageModal from './sharePreparedMessage/SharePreparedMessageModal.async';
@ -97,14 +98,15 @@ type ModalKey = keyof Pick<TabState,
'isFrozenAccountModalOpen' |
'deleteAccountModal' |
'isAgeVerificationModalOpen' |
'profileRatingModal'
'profileRatingModal' |
'quickPreview'
>;
type StateProps = {
[K in ModalKey]?: TabState[K];
};
type ModalRegistry = {
[K in ModalKey]: React.FC<{
[K in ModalKey]: FC<{
modal: TabState[K];
}>;
};
@ -157,6 +159,7 @@ const MODALS: ModalRegistry = {
deleteAccountModal: DeleteAccountModal,
isAgeVerificationModalOpen: AgeVerificationModal,
profileRatingModal: ProfileRatingModal,
quickPreview: QuickPreviewModal,
};
const MODAL_KEYS = Object.keys(MODALS) as ModalKey[];
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);
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;
closeSuggestedPostApprovalModal: WithTabId | undefined;
openQuickPreview: {
id: string;
threadId?: ThreadId;
} & WithTabId;
closeQuickPreview: WithTabId | undefined;
openDeleteMessageModal: ({
chatId: string;
messageIds: number[];

View File

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

View File

@ -5,9 +5,7 @@ import type { ApiChat, ApiTopic, ApiUser } from '../api/types';
import type { MenuItemContextAction } from '../components/ui/ListItem';
import { SERVICE_NOTIFICATIONS_USER_ID } from '../config';
import {
getCanDeleteChat, isChatArchived, isChatChannel, isChatGroup,
} from '../global/helpers';
import { getCanDeleteChat, isChatArchived, isChatChannel, isChatGroup } from '../global/helpers';
import { IS_TAURI } from '../util/browser/globalEnvironment';
import { IS_OPEN_IN_NEW_TAB_SUPPORTED } from '../util/browser/windowEnvironment';
import { isUserId } from '../util/entities/ids';
@ -86,6 +84,7 @@ const useChatContextActions = ({
markChatMessagesRead,
markChatUnread,
openChatInNewTab,
openQuickPreview,
} = getActions();
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 = () => {
if (isSavedDialog) {
toggleSavedDialogPinned({ id: chat.id });
@ -128,7 +137,7 @@ const useChatContextActions = ({
};
if (isSavedDialog) {
return compact([actionOpenInNewTab, actionPin, actionDelete]) as MenuItemContextAction[];
return compact([actionOpenInNewTab, actionQuickPreview, actionPin, actionDelete]) as MenuItemContextAction[];
}
const actionAddToFolder = canChangeFolder ? {
@ -150,12 +159,15 @@ const useChatContextActions = ({
};
if (isInSearch) {
return compact([actionOpenInNewTab, actionPin, actionAddToFolder, actionMute]) as MenuItemContextAction[];
return compact([
actionOpenInNewTab, actionQuickPreview, actionPin, actionAddToFolder, actionMute,
]) as MenuItemContextAction[];
}
const actionMaskAsRead = (
chat.unreadCount || chat.hasUnreadMark || Object.values(topics || {}).some(({ unreadCount }) => unreadCount)
) ? {
)
? {
title: lang('ChatListContextMaskAsRead'),
icon: 'readchats',
handler: () => markChatMessagesRead({ id: chat.id }),
@ -177,6 +189,7 @@ const useChatContextActions = ({
return compact([
actionOpenInNewTab,
actionQuickPreview,
actionAddToFolder,
actionMaskAsRead,
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 = (
onSelect: NoneToVoidFunction,
) => {
const buttonRef = useRef<HTMLDivElement>();
const handleKeyDown = useCallback((e: KeyboardEvent) => {
const handleKeyDown = useLastCallback((e: KeyboardEvent) => {
if (e.key !== 'Enter') return;
const isFocused = buttonRef.current === document.activeElement;
if (isFocused) {
onSelect();
}
}, [onSelect]);
});
useEffect(() => {
window.addEventListener('keydown', handleKeyDown, false);
return () => window.removeEventListener('keydown', handleKeyDown, false);
}, [handleKeyDown]);
}, []);
return buttonRef;
};

View File

@ -25,7 +25,7 @@
}
: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 {
@ -77,14 +77,14 @@
bottom: 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-position: top right;
background-size: 510px auto;
mix-blend-mode: overlay;
: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;
}
}

View File

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