diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 0b4cabe2b..ca8e5851e 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -2281,3 +2281,4 @@ "GiftValueForSaleOnFragment" = "for sale on Fragment"; "GiftValueForSaleOnTelegram" = "for sale on Telegram"; "EmbeddedMessageNoCaption" = "Caption removed"; +"QuickPreview" = "Quick Preview"; diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 098f4dfe7..8f78d6594 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -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'; diff --git a/src/components/left/main/Chat.tsx b/src/components/left/main/Chat.tsx index 67423c513..7c6270d8d 100644 --- a/src/components/left/main/Chat.tsx +++ b/src/components/left/main/Chat.tsx @@ -186,6 +186,7 @@ const Chat: FC = ({ reportMessages, openFrozenAccountModal, updateChatMutedState, + openQuickPreview, } = getActions(); const { isMobile } = useAppLayout(); @@ -239,7 +240,13 @@ const Chat: FC = ({ 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) { diff --git a/src/components/left/main/Topic.tsx b/src/components/left/main/Topic.tsx index 08d0ee425..3c841a9b4 100644 --- a/src/components/left/main/Topic.tsx +++ b/src/components/left/main/Topic.tsx @@ -103,6 +103,7 @@ const Topic: FC = ({ focusLastMessage, setViewForumAsMessages, updateTopicMutedState, + openQuickPreview, } = getActions(); const lang = useOldLang(); @@ -153,7 +154,13 @@ const Topic: FC = ({ 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 }); diff --git a/src/components/left/main/hooks/useTopicContextActions.ts b/src/components/left/main/hooks/useTopicContextActions.ts index 0e080a16d..114741137 100644 --- a/src/components/left/main/hooks/useTopicContextActions.ts +++ b/src/components/left/main/hooks/useTopicContextActions.ts @@ -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]); } diff --git a/src/components/left/search/LeftSearchResultChat.tsx b/src/components/left/search/LeftSearchResultChat.tsx index 24d6d7ab0..a581df91a 100644 --- a/src/components/left/search/LeftSearchResultChat.tsx +++ b/src/components/left/search/LeftSearchResultChat.tsx @@ -52,7 +52,7 @@ const LeftSearchResultChat: FC = ({ 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 = ({ 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 = ({ }); }); - const buttonRef = useSelectWithEnter(handleClick); + const buttonRef = useSelectWithEnter(() => { + onClick(chatId); + }); return ( = ({ + 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; diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index 195206404..6b5063d83 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -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 = ({ canTranslate, translationLanguage, shouldAutoTranslate, + isQuickPreview, onIntersectPinnedMessage, onScrollDownToggle, onNotchToggle, @@ -443,7 +445,12 @@ const MessageList: FC = ({ 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 = ({ 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 = ({ 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 = ({ useEffect(() => { if (hasMessages) return; - onScrollDownToggle(false); + onScrollDownToggle?.(false); }, [hasMessages, onScrollDownToggle]); const activeKey = isRestricted ? ( @@ -790,6 +797,7 @@ const MessageList: FC = ({ nameChangeDate={nameChangeDate} photoChangeDate={photoChangeDate} noAppearanceAnimation={!messageGroups || !shouldAnimateAppearanceRef.current} + isQuickPreview={isQuickPreview} onScrollDownToggle={onScrollDownToggle} onNotchToggle={onNotchToggle} onIntersectPinnedMessage={onIntersectPinnedMessage} diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx index e11fef91c..1e48441cb 100644 --- a/src/components/middle/MessageListContent.tsx +++ b/src/components/middle/MessageListContent.tsx @@ -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 = ({ photoChangeDate, noAppearanceAnimation, isSavedDialog, + isQuickPreview, onScrollDownToggle, onNotchToggle, onIntersectPinnedMessage, @@ -126,7 +128,7 @@ const MessageListContent: FC = ({ observeIntersectionForReading, observeIntersectionForLoading, observeIntersectionForPlaying, - } = useMessageObservers(type, containerRef, memoFirstUnreadIdRef, onIntersectPinnedMessage, chatId); + } = useMessageObservers(type, containerRef, memoFirstUnreadIdRef, onIntersectPinnedMessage, chatId, isQuickPreview); const { withHistoryTriggers, diff --git a/src/components/middle/MiddleColumn.tsx b/src/components/middle/MiddleColumn.tsx index 9d2bdd7bc..8073dac40 100644 --- a/src/components/middle/MiddleColumn.tsx +++ b/src/components/middle/MiddleColumn.tsx @@ -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; @@ -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} />
, 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 }); } }); diff --git a/src/components/middle/hooks/useScrollHooks.ts b/src/components/middle/hooks/useScrollHooks.ts index ad9f000b3..09bb22c70 100644 --- a/src/components/middle/hooks/useScrollHooks.ts +++ b/src/components/middle/hooks/useScrollHooks.ts @@ -28,8 +28,8 @@ export default function useScrollHooks( getContainerHeight: Signal, 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 { diff --git a/src/components/modals/ModalContainer.tsx b/src/components/modals/ModalContainer.tsx index b48c9d92e..1ca655a0c 100644 --- a/src/components/modals/ModalContainer.tsx +++ b/src/components/modals/ModalContainer.tsx @@ -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; 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; diff --git a/src/components/modals/quickPreview/QuickPreviewModal.async.tsx b/src/components/modals/quickPreview/QuickPreviewModal.async.tsx new file mode 100644 index 000000000..2dd7d54b3 --- /dev/null +++ b/src/components/modals/quickPreview/QuickPreviewModal.async.tsx @@ -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 = memo((props) => { + const { modal } = props; + + const QuickPreviewModal = useModuleLoader(Bundles.Extra, 'QuickPreviewModal', !modal); + + return QuickPreviewModal ? : undefined; +}); + +export default QuickPreviewModalAsync; diff --git a/src/components/modals/quickPreview/QuickPreviewModal.module.scss b/src/components/modals/quickPreview/QuickPreviewModal.module.scss new file mode 100644 index 000000000..a2e1d0068 --- /dev/null +++ b/src/components/modals/quickPreview/QuickPreviewModal.module.scss @@ -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%; +} diff --git a/src/components/modals/quickPreview/QuickPreviewModal.tsx b/src/components/modals/quickPreview/QuickPreviewModal.tsx new file mode 100644 index 000000000..e2da75b18 --- /dev/null +++ b/src/components/modals/quickPreview/QuickPreviewModal.tsx @@ -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 = ({ + 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 ( + } + className={styles.root} + contentClassName={styles.content} + > +
+
+
+ +
+
+ + ); +}; + +export default memo(withGlobal((global, { modal: chatId }): Complete => { + const theme = selectTheme(global); + const { + isBlurred: isBackgroundBlurred, background: customBackground, backgroundColor, patternColor, + } = selectThemeValues(global, theme) || {}; + + return { + theme, + customBackground, + backgroundColor, + patternColor, + isBackgroundBlurred, + }; +})(QuickPreviewModal)); diff --git a/src/components/modals/quickPreview/QuickPreviewModalHeader.module.scss b/src/components/modals/quickPreview/QuickPreviewModalHeader.module.scss new file mode 100644 index 000000000..adbf5a8a8 --- /dev/null +++ b/src/components/modals/quickPreview/QuickPreviewModalHeader.module.scss @@ -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; + } +} diff --git a/src/components/modals/quickPreview/QuickPreviewModalHeader.tsx b/src/components/modals/quickPreview/QuickPreviewModalHeader.tsx new file mode 100644 index 000000000..0746e80da --- /dev/null +++ b/src/components/modals/quickPreview/QuickPreviewModalHeader.tsx @@ -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 = ({ + 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 ( +
+ {Boolean(unreadCount || hasUnreadMark) && ( + + )} + +
+
+ {isUserId(displayChatId) ? ( + + ) : ( + + )} +
+
+
+ ); +}; + +export default memo(withGlobal( + (global, { chatId, threadId }): Complete => { + 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)); diff --git a/src/global/actions/ui/messages.ts b/src/global/actions/ui/messages.ts index 0f42bf6fd..75cbe860d 100644 --- a/src/global/actions/ui/messages.ts +++ b/src/global/actions/ui/messages.ts @@ -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); +}); diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index c080f230c..7f44fa8a9 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -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[]; diff --git a/src/global/types/tabState.ts b/src/global/types/tabState.ts index 158f2eb3f..58e487800 100644 --- a/src/global/types/tabState.ts +++ b/src/global/types/tabState.ts @@ -875,6 +875,11 @@ export type TabState = { errorKey?: RegularLangFnParameters; }; + quickPreview?: { + chatId: string; + threadId?: ThreadId; + }; + isWaitingForStarGiftUpgrade?: true; isWaitingForStarGiftTransfer?: true; insertingPeerIdMention?: string; diff --git a/src/hooks/useChatContextActions.ts b/src/hooks/useChatContextActions.ts index 0b66a757d..01dbd49dc 100644 --- a/src/hooks/useChatContextActions.ts +++ b/src/hooks/useChatContextActions.ts @@ -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, diff --git a/src/hooks/useSelectWithEnter.ts b/src/hooks/useSelectWithEnter.ts index 990c090d5..583463ba3 100644 --- a/src/hooks/useSelectWithEnter.ts +++ b/src/hooks/useSelectWithEnter.ts @@ -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(); - 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; }; diff --git a/src/components/middle/MiddleColumn.module.scss b/src/styles/_patternBackground.module.scss similarity index 91% rename from src/components/middle/MiddleColumn.module.scss rename to src/styles/_patternBackground.module.scss index 7fee47f47..841ea8953 100644 --- a/src/components/middle/MiddleColumn.module.scss +++ b/src/styles/_patternBackground.module.scss @@ -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; } } diff --git a/src/types/language.d.ts b/src/types/language.d.ts index ea3276479..18e7d5684 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -1705,6 +1705,7 @@ export interface LangPair { 'GiftValueForSaleOnFragment': undefined; 'GiftValueForSaleOnTelegram': undefined; 'EmbeddedMessageNoCaption': undefined; + 'QuickPreview': undefined; } export interface LangPairWithVariables {