From b497a3c0ea6787743b2a041cb1823a44d3994899 Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Thu, 6 Nov 2025 11:36:38 +0100 Subject: [PATCH] Message List: Fix scroll to bottom ignoring local messages (#6418) --- src/components/left/main/Chat.tsx | 4 +- src/components/left/main/Topic.tsx | 4 +- .../middle/FloatingActionButtons.tsx | 21 +--- src/components/middle/MessageList.scss | 5 + src/components/middle/MessageList.tsx | 25 +++- .../middle/MessageListBottomMarker.tsx | 28 +++++ src/components/middle/MessageListContent.tsx | 30 +++-- src/components/middle/hooks/useScrollHooks.ts | 45 ++++++-- .../middle/message/ActionMessage.tsx | 5 +- src/components/middle/message/Message.tsx | 20 +++- ...ssage.ts => useFocusMessageListElement.ts} | 18 +-- src/global/actions/api/messages.ts | 15 ++- src/global/actions/ui/messages.ts | 107 ++++++++---------- src/global/reducers/messages.ts | 50 +------- src/global/selectors/chats.ts | 6 +- src/global/types/actions.ts | 3 +- src/hooks/useResizeMessageObserver.ts | 4 +- 17 files changed, 211 insertions(+), 179 deletions(-) create mode 100644 src/components/middle/MessageListBottomMarker.tsx rename src/components/middle/message/hooks/{useFocusMessage.ts => useFocusMessageListElement.ts} (82%) diff --git a/src/components/left/main/Chat.tsx b/src/components/left/main/Chat.tsx index 7c499e343..e1ce015ce 100644 --- a/src/components/left/main/Chat.tsx +++ b/src/components/left/main/Chat.tsx @@ -181,7 +181,6 @@ const Chat: FC = ({ openChat, openSavedDialog, toggleChatInfo, - focusLastMessage, focusMessage, loadTopics, openForumPanel, @@ -191,6 +190,7 @@ const Chat: FC = ({ openFrozenAccountModal, updateChatMutedState, openQuickPreview, + scrollMessageListToBottom, } = getActions(); const { isMobile } = useAppLayout(); @@ -293,7 +293,7 @@ const Chat: FC = ({ openChat({ id: chatId, noForumTopicPanel, shouldReplaceHistory: true }, { forceOnHeavyAnimation: true }); if (isSelected && canScrollDown) { - focusLastMessage(); + scrollMessageListToBottom(); } }); diff --git a/src/components/left/main/Topic.tsx b/src/components/left/main/Topic.tsx index b673d05a9..03199afc1 100644 --- a/src/components/left/main/Topic.tsx +++ b/src/components/left/main/Topic.tsx @@ -102,7 +102,7 @@ const Topic: FC = ({ const { openThread, deleteTopic, - focusLastMessage, + scrollMessageListToBottom, setViewForumAsMessages, updateTopicMutedState, openQuickPreview, @@ -168,7 +168,7 @@ const Topic: FC = ({ setViewForumAsMessages({ chatId, isEnabled: false }); if (canScrollDown) { - focusLastMessage(); + scrollMessageListToBottom(); } }); diff --git a/src/components/middle/FloatingActionButtons.tsx b/src/components/middle/FloatingActionButtons.tsx index 9d83f5d0b..2c15a2d2d 100644 --- a/src/components/middle/FloatingActionButtons.tsx +++ b/src/components/middle/FloatingActionButtons.tsx @@ -6,7 +6,6 @@ import type { MessageListType, ThreadId } from '../../types'; import { MAIN_THREAD_ID } from '../../api/types'; import { selectChat, selectCurrentMessageList, selectCurrentMiddleSearch } from '../../global/selectors'; -import animateScroll from '../../util/animateScroll'; import buildClassName from '../../util/buildClassName'; import useLastCallback from '../../hooks/useLastCallback'; @@ -32,8 +31,6 @@ type StateProps = { mentionsCount?: number; }; -const FOCUS_MARGIN = 20; - const FloatingActionButtons: FC = ({ withScrollDown, canPost, @@ -49,7 +46,7 @@ const FloatingActionButtons: FC = ({ }) => { const { focusNextReply, focusNextReaction, focusNextMention, fetchUnreadReactions, - readAllMentions, readAllReactions, fetchUnreadMentions, + readAllMentions, readAllReactions, fetchUnreadMentions, scrollMessageListToBottom, } = getActions(); const elementRef = useRef(); @@ -99,21 +96,7 @@ const FloatingActionButtons: FC = ({ if (messageListType === 'thread') { focusNextReply(); } else { - const messagesContainer = elementRef.current!.parentElement!.querySelector( - '.Transition_slide-active > .MessageList', - )!; - const messageElements = messagesContainer.querySelectorAll('.message-list-item'); - const lastMessageElement = messageElements[messageElements.length - 1]; - if (!lastMessageElement) { - return; - } - - animateScroll({ - container: messagesContainer, - element: lastMessageElement, - position: 'end', - margin: FOCUS_MARGIN, - }); + scrollMessageListToBottom(); } }); diff --git a/src/components/middle/MessageList.scss b/src/components/middle/MessageList.scss index c767c7730..c28d090f5 100644 --- a/src/components/middle/MessageList.scss +++ b/src/components/middle/MessageList.scss @@ -321,6 +321,11 @@ } } + .list-bottom-marker.with-sponsored { + position: relative; + top: -1rem; // Prevent overlapping with the sponsored message + } + @media (pointer: coarse) { user-select: none; diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index 998e8ef9e..2486fb36a 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -61,6 +61,7 @@ import { isLocalMessageId } from '../../util/keys/messageKey'; import resetScroll from '../../util/resetScroll'; import { debounce, onTickEnd } from '../../util/schedulers'; import getOffsetToContainer from '../../util/visibility/getOffsetToContainer'; +import { REM } from '../common/helpers/mediaDimensions'; import { groupMessages } from './helpers/groupMessages'; import { preventMessageInputBlur } from './helpers/preventMessageInputBlur'; @@ -76,7 +77,7 @@ import useContainerHeight from './hooks/useContainerHeight'; import useStickyDates from './hooks/useStickyDates'; import Loading from '../ui/Loading'; -import Transition from '../ui/Transition.tsx'; +import Transition from '../ui/Transition'; import ContactGreeting from './ContactGreeting'; import MessageListAccountInfo from './MessageListAccountInfo'; import MessageListContent from './MessageListContent'; @@ -144,6 +145,7 @@ type StateProps = { translationLanguage?: string; shouldAutoTranslate?: boolean; isActive?: boolean; + shouldScrollToBottom?: boolean; }; enum Content { @@ -168,7 +170,7 @@ const BOTTOM_THRESHOLD = 50; const UNREAD_DIVIDER_TOP = 10; const SCROLL_DEBOUNCE = 200; const MESSAGE_ANIMATION_DURATION = 500; -const BOTTOM_FOCUS_MARGIN = 20; +const BOTTOM_FOCUS_MARGIN = 0.5 * REM; const SELECT_MODE_ANIMATION_DURATION = 200; const UNREAD_DIVIDER_CLASS = 'unread-divider'; @@ -186,6 +188,7 @@ const MessageList: FC = ({ canPost, isSynced, isActive, + shouldScrollToBottom, // eslint-disable-next-line @typescript-eslint/no-shadow isChatMonoforum, isReady, @@ -622,11 +625,11 @@ const MessageList: FC = ({ if (wasMessageAdded && isAtBottom && !isAlreadyFocusing) { // Break out of `forceLayout` requestMeasure(() => { - const shouldScrollToBottom = !isBackgroundModeActive() || !firstUnreadElement; + const isScrollToBottom = !isBackgroundModeActive() || !firstUnreadElement; animateScroll({ container, - element: shouldScrollToBottom ? lastItemElement : firstUnreadElement, - position: shouldScrollToBottom ? 'end' : 'start', + element: isScrollToBottom ? lastItemElement : firstUnreadElement, + position: isScrollToBottom ? 'end' : 'start', margin: BOTTOM_FOCUS_MARGIN, forceDuration: noMessageSendingAnimation ? 0 : undefined, }); @@ -800,10 +803,11 @@ const MessageList: FC = ({ photoChangeDate={photoChangeDate} noAppearanceAnimation={!messageGroups || !shouldAnimateAppearanceRef.current} isQuickPreview={isQuickPreview} + canPost={canPost} + shouldScrollToBottom={shouldScrollToBottom} onScrollDownToggle={onScrollDownToggle} onNotchToggle={onNotchToggle} onIntersectPinnedMessage={onIntersectPinnedMessage} - canPost={canPost} /> ) : ( @@ -827,6 +831,7 @@ const MessageList: FC = ({ export default memo(withGlobal( (global, { chatId, threadId, type }): Complete => { + const tabState = selectTabState(global); const currentUserId = global.currentUserId!; const chat = selectChat(global, chatId); const userFullInfo = selectUserFullInfo(global, chatId); @@ -883,6 +888,13 @@ export default memo(withGlobal( const isActive = currentMessageList && currentMessageList.chatId === chatId && currentMessageList.threadId === threadId && currentMessageList.type === type; + const { + chatId: focusedChatId, + threadId: focusedThreadId, + messageId: focusedMessageId, + } = tabState.focusedMessage || {}; + const shouldScrollToBottom = focusedChatId === chatId && focusedThreadId === threadId && !focusedMessageId; + return { isActive, areAdsEnabled, @@ -925,6 +937,7 @@ export default memo(withGlobal( canTranslate, translationLanguage, shouldAutoTranslate, + shouldScrollToBottom, }; }, )(MessageList)); diff --git a/src/components/middle/MessageListBottomMarker.tsx b/src/components/middle/MessageListBottomMarker.tsx new file mode 100644 index 000000000..143f63648 --- /dev/null +++ b/src/components/middle/MessageListBottomMarker.tsx @@ -0,0 +1,28 @@ +import { memo, useRef } from '@teact'; + +import buildClassName from '../../util/buildClassName'; + +import useFocusMessageListElement from './message/hooks/useFocusMessageListElement'; + +type OwnProps = { + isJustAdded?: boolean; + isFocused?: boolean; + className?: string; +}; + +const MessageListBottomMarker = ({ isJustAdded, isFocused, className }: OwnProps) => { + const ref = useRef(); + + useFocusMessageListElement({ + elementRef: ref, + isJustAdded, + isFocused, + noFocusHighlight: true, + }); + + return ( +
+ ); +}; + +export default memo(MessageListBottomMarker); diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx index 1e48441cb..20e9259e8 100644 --- a/src/components/middle/MessageListContent.tsx +++ b/src/components/middle/MessageListContent.tsx @@ -1,4 +1,4 @@ -import type { ElementRef, FC } from '../../lib/teact/teact'; +import type { ElementRef } from '../../lib/teact/teact'; import { getIsHeavyAnimating, memo } from '../../lib/teact/teact'; import { getActions, getGlobal } from '../../global'; @@ -43,6 +43,7 @@ import Message from './message/Message'; import SenderGroupContainer from './message/SenderGroupContainer'; import SponsoredMessage from './message/SponsoredMessage'; import MessageListAccountInfo from './MessageListAccountInfo'; +import MessageListBottomMarker from './MessageListBottomMarker'; import actionMessageStyles from './message/ActionMessage.module.scss'; @@ -75,15 +76,16 @@ interface OwnProps { noAppearanceAnimation: boolean; isSavedDialog?: boolean; isQuickPreview?: boolean; + canPost?: boolean; + shouldScrollToBottom?: boolean; onScrollDownToggle?: BooleanToVoidFunction; onNotchToggle?: AnyToVoidFunction; onIntersectPinnedMessage?: OnIntersectPinnedMessage; - canPost?: boolean; } const UNREAD_DIVIDER_CLASS = 'unread-divider'; -const MessageListContent: FC = ({ +const MessageListContent = ({ canShowAds, chatId, threadId, @@ -112,17 +114,19 @@ const MessageListContent: FC = ({ noAppearanceAnimation, isSavedDialog, isQuickPreview, + shouldScrollToBottom, + canPost, onScrollDownToggle, onNotchToggle, onIntersectPinnedMessage, - canPost, -}) => { +}: OwnProps) => { const { openHistoryCalendar } = getActions(); const getIsHeavyAnimating2 = getIsHeavyAnimating; const getIsReady = useDerivedSignal(() => isReady && !getIsHeavyAnimating2(), [isReady, getIsHeavyAnimating2]); const areDatesClickable = !isSavedDialog && !isSchedule; + const shouldRenderSponsoredMessage = canShowAds && isViewportNewest; const { observeIntersectionForReading, @@ -135,17 +139,17 @@ const MessageListContent: FC = ({ backwardsTriggerRef, forwardsTriggerRef, fabTriggerRef, - } = useScrollHooks( + } = useScrollHooks({ type, containerRef, messageIds, getContainerHeight, isViewportNewest, isUnread, + isReady, onScrollDownToggle, onNotchToggle, - isReady, - ); + }); const oldLang = useOldLang(); const lang = useLang(); @@ -457,7 +461,15 @@ const MessageListContent: FC = ({ key="fab-trigger" className="fab-trigger" /> - {canShowAds && isViewportNewest && ( + {isViewportNewest && ( + + )} + {shouldRenderSponsoredMessage && ( , - messageIds: number[], - getContainerHeight: Signal, - isViewportNewest: boolean, - isUnread: boolean, - onScrollDownToggle: BooleanToVoidFunction | undefined, - onNotchToggle: AnyToVoidFunction | undefined, - isReady: boolean, -) { +export default function useScrollHooks({ + type, + containerRef, + messageIds, + getContainerHeight, + isViewportNewest, + isUnread, + isReady, + onScrollDownToggle, + onNotchToggle, +}: { + type: MessageListType; + containerRef: ElementRef; + messageIds: number[]; + getContainerHeight: Signal; + isViewportNewest: boolean; + isUnread: boolean; + isReady: boolean; + onScrollDownToggle: BooleanToVoidFunction | undefined; + onNotchToggle: AnyToVoidFunction | undefined; +}) { const { loadViewportMessages } = getActions(); const [loadMoreBackwards, loadMoreForwards] = useMemo( @@ -136,7 +146,18 @@ export default function useScrollHooks( if (isReady) { toggleScrollTools(); } - }, [isReady, toggleScrollTools]); + }, [isReady]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + container.addEventListener('scrollend', toggleScrollTools); + + return () => { + container.removeEventListener('scrollend', toggleScrollTools); + }; + }, [containerRef]); const freezeShortly = useLastCallback(() => { freezeForFab(); diff --git a/src/components/middle/message/ActionMessage.tsx b/src/components/middle/message/ActionMessage.tsx index 3cd8972c7..32366f584 100644 --- a/src/components/middle/message/ActionMessage.tsx +++ b/src/components/middle/message/ActionMessage.tsx @@ -44,7 +44,7 @@ import useMessageResizeObserver from '../../../hooks/useResizeMessageObserver'; import useShowTransition from '../../../hooks/useShowTransition'; import { type OnIntersectPinnedMessage } from '../hooks/usePinnedMessage'; import useFluidBackgroundFilter from './hooks/useFluidBackgroundFilter'; -import useFocusMessage from './hooks/useFocusMessage'; +import useFocusMessageListElement from './hooks/useFocusMessageListElement'; import ActionMessageText from './ActionMessageText'; import ChannelPhoto from './actions/ChannelPhoto'; @@ -176,9 +176,8 @@ const ActionMessage = ({ replyMessage, id, ); - useFocusMessage({ + useFocusMessageListElement({ elementRef: ref, - chatId, isFocused, focusDirection, noFocusHighlight, diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 3301ebc76..3325eb6be 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -154,7 +154,7 @@ import useMessageResizeObserver from '../../../hooks/useResizeMessageObserver'; import useShowTransition from '../../../hooks/useShowTransition'; import useTextLanguage from '../../../hooks/useTextLanguage'; import useDetectChatLanguage from './hooks/useDetectChatLanguage'; -import useFocusMessage from './hooks/useFocusMessage'; +import useFocusMessageListElement from './hooks/useFocusMessageListElement'; import useInnerHandlers from './hooks/useInnerHandlers'; import useMessageTranslation from './hooks/useMessageTranslation'; import useOuterHandlers from './hooks/useOuterHandlers'; @@ -462,7 +462,7 @@ const Message = ({ openSuggestedPostApprovalModal, disableContextMenuHint, animateUnreadReaction, - focusLastMessage, + focusMessage, markMentionsRead, } = getActions(); @@ -698,15 +698,24 @@ const Message = ({ requestEffect(); }); + const handleFocusSelf = useLastCallback(() => { + focusMessage({ + chatId, + threadId, + messageId, + noHighlight: true, + }); + }); + useEffect(() => { if (!isLastInList) { return; } if (withVoiceTranscription && transcribedText) { - focusLastMessage(); + handleFocusSelf(); } - }, [focusLastMessage, isLastInList, transcribedText, withVoiceTranscription]); + }, [isLastInList, transcribedText, withVoiceTranscription]); useEffect(() => { const element = ref.current; @@ -882,9 +891,8 @@ const Message = ({ replyStory, ); - useFocusMessage({ + useFocusMessageListElement({ elementRef: ref, - chatId, isFocused, focusDirection, noFocusHighlight, diff --git a/src/components/middle/message/hooks/useFocusMessage.ts b/src/components/middle/message/hooks/useFocusMessageListElement.ts similarity index 82% rename from src/components/middle/message/hooks/useFocusMessage.ts rename to src/components/middle/message/hooks/useFocusMessageListElement.ts index b6ce33074..b2d12171d 100644 --- a/src/components/middle/message/hooks/useFocusMessage.ts +++ b/src/components/middle/message/hooks/useFocusMessageListElement.ts @@ -9,15 +9,16 @@ import { requestForcedReflow, requestMeasure, requestMutation, } from '../../../../lib/fasterdom/fasterdom'; import animateScroll from '../../../../util/animateScroll'; +import { REM } from '../../../common/helpers/mediaDimensions'; // This is used when the viewport was replaced. const BOTTOM_FOCUS_OFFSET = 500; const RELOCATED_FOCUS_OFFSET = SCROLL_MAX_DISTANCE; -const FOCUS_MARGIN = 20; +const FOCUS_MARGIN = 1.25 * REM; +const BOTTOM_FOCUS_MARGIN = 0.5 * REM; -export default function useFocusMessage({ +export default function useFocusMessageListElement({ elementRef, - chatId, isFocused, focusDirection, noFocusHighlight, @@ -27,7 +28,6 @@ export default function useFocusMessage({ scrollTargetPosition, }: { elementRef: ElementRef; - chatId: string; isFocused?: boolean; focusDirection?: FocusDirection; noFocusHighlight?: boolean; @@ -43,10 +43,12 @@ export default function useFocusMessage({ isRelocatedRef.current = false; if (isFocused && elementRef.current) { - const messagesContainer = elementRef.current.closest('.MessageList')!; + const messagesContainer = elementRef.current.closest('.MessageList'); + if (!messagesContainer) return; + // `noFocusHighlight` is always called with “scroll-to-bottom” buttons const isToBottom = noFocusHighlight; - const scrollPosition = scrollTargetPosition || isToBottom ? 'end' : 'centerOrTop'; + const scrollPosition = scrollTargetPosition || (isToBottom ? 'end' : 'centerOrTop'); const exec = () => { const maxDistance = focusDirection !== undefined @@ -56,7 +58,7 @@ export default function useFocusMessage({ container: messagesContainer, element: elementRef.current!, position: scrollPosition, - margin: FOCUS_MARGIN, + margin: isToBottom ? BOTTOM_FOCUS_MARGIN : FOCUS_MARGIN, maxDistance, forceDirection: focusDirection, forceNormalContainerHeight: isResizingContainer, @@ -85,6 +87,6 @@ export default function useFocusMessage({ } } }, [ - elementRef, chatId, isFocused, focusDirection, noFocusHighlight, isResizingContainer, isQuote, scrollTargetPosition, + elementRef, isFocused, focusDirection, noFocusHighlight, isResizingContainer, isQuote, scrollTargetPosition, ]); } diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index 57ccaa918..88476907d 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -169,6 +169,7 @@ addActionHandler('loadViewportMessages', (global, actions, payload): ActionRetur direction = LoadMoreDirection.Around, isBudgetPreload = false, shouldForceRender = false, + forceLastSlice = false, onLoaded, onError, tabId = getCurrentTabId(), @@ -199,7 +200,9 @@ addActionHandler('loadViewportMessages', (global, actions, payload): ActionRetur const listedIds = selectListedIds(global, chatId, threadId); if (!viewportIds || !viewportIds.length || direction === LoadMoreDirection.Around) { - const offsetId = selectFocusedMessageId(global, chatId, tabId) || selectRealLastReadId(global, chatId, threadId); + const offsetId = !forceLastSlice ? ( + selectFocusedMessageId(global, chatId, tabId) || selectRealLastReadId(global, chatId, threadId) + ) : undefined; const isOutlying = Boolean(offsetId && listedIds && !listedIds.includes(offsetId)); const historyIds = (isOutlying ? selectOutlyingListByMessageId(global, chatId, threadId, offsetId!) @@ -222,17 +225,19 @@ addActionHandler('loadViewportMessages', (global, actions, payload): ActionRetur onLoaded?.(); } } else { - const offsetId = direction === LoadMoreDirection.Backwards ? viewportIds[0] : viewportIds[viewportIds.length - 1]; + const offsetId = !forceLastSlice ? ( + direction === LoadMoreDirection.Backwards ? viewportIds[0] : viewportIds[viewportIds.length - 1] + ) : undefined; // Prevent requests with local offsets - if (isLocalMessageId(offsetId)) return; + if (offsetId && isLocalMessageId(offsetId)) return; // Prevent unnecessary requests in threads if (offsetId === threadId && direction === LoadMoreDirection.Backwards) return; - const isOutlying = Boolean(listedIds && !listedIds.includes(offsetId)); + const isOutlying = Boolean(listedIds && offsetId && !listedIds.includes(offsetId)); const historyIds = (isOutlying - ? selectOutlyingListByMessageId(global, chatId, threadId, offsetId) : listedIds)!; + ? selectOutlyingListByMessageId(global, chatId, threadId, offsetId!) : listedIds)!; if (historyIds?.length) { const { newViewportIds, areSomeLocal, areAllLocal, diff --git a/src/global/actions/ui/messages.ts b/src/global/actions/ui/messages.ts index 3c51957c6..1538af130 100644 --- a/src/global/actions/ui/messages.ts +++ b/src/global/actions/ui/messages.ts @@ -21,7 +21,6 @@ import parseHtmlAsFormattedText from '../../../util/parseHtmlAsFormattedText'; import { getServerTime } from '../../../util/serverTime'; import versionNotification from '../../../versionNotification.txt'; import { - getIsSavedDialog, getMediaFilename, getMediaFormat, getMediaHash, @@ -41,7 +40,6 @@ import { replaceTabThreadParam, replaceThreadParam, toggleMessageSelection, - updateFocusDirection, updateFocusedMessage, } from '../../reducers'; import { updateTabState } from '../../reducers/tabs'; @@ -60,7 +58,6 @@ import { selectIsRightColumnShown, selectIsViewportNewest, selectMessageIdsByGroupId, - selectPinnedIds, selectReplyStack, selectRequestedChatTranslationLanguage, selectRequestedMessageTranslationLanguage, @@ -323,52 +320,6 @@ addActionHandler('closePollResults', (global, actions, payload): ActionReturnTyp }, tabId); }); -addActionHandler('focusLastMessage', (global, actions, payload): ActionReturnType => { - const { tabId = getCurrentTabId() } = payload || {}; - const currentMessageList = selectCurrentMessageList(global, tabId); - if (!currentMessageList) { - return; - } - - const { chatId, threadId, type } = currentMessageList; - - const isSavedDialog = getIsSavedDialog(chatId, threadId, global.currentUserId); - - let lastMessageId: number | undefined; - if (threadId === MAIN_THREAD_ID) { - if (type === 'pinned') { - const pinnedMessageIds = selectPinnedIds(global, chatId, MAIN_THREAD_ID); - if (!pinnedMessageIds?.length) { - return; - } - - lastMessageId = pinnedMessageIds[pinnedMessageIds.length - 1]; - } else { - lastMessageId = selectChatLastMessageId(global, chatId); - } - } else if (isSavedDialog) { - lastMessageId = selectChatLastMessageId(global, String(threadId), 'saved'); - } else { - const threadInfo = selectThreadInfo(global, chatId, threadId); - - lastMessageId = threadInfo?.lastMessageId; - } - - if (!lastMessageId) { - return; - } - - actions.focusMessage({ - chatId, - threadId, - messageListType: type, - messageId: lastMessageId, - noHighlight: true, - noForumTopicPanel: true, - tabId, - }); -}); - addActionHandler('focusNextReply', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload || {}; const currentMessageList = selectCurrentMessageList(global, tabId); @@ -381,7 +332,7 @@ addActionHandler('focusNextReply', (global, actions, payload): ActionReturnType const replyStack = selectReplyStack(global, chatId, threadId, tabId); if (!replyStack || replyStack.length === 0) { - actions.focusLastMessage({ tabId }); + actions.scrollMessageListToBottom({ tabId }); } else { const messageId = replyStack.pop(); @@ -441,13 +392,11 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType => } blurTimeout = window.setTimeout(() => { global = getGlobal(); - global = updateFocusedMessage({ global }, tabId); - global = updateFocusDirection(global, undefined, tabId); + global = updateFocusedMessage(global, undefined, tabId); setGlobal(global); }, noHighlight ? FOCUS_NO_HIGHLIGHT_DURATION : FOCUS_DURATION); - global = updateFocusedMessage({ - global, + global = updateFocusedMessage(global, { chatId, messageId, threadId, @@ -456,8 +405,8 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType => quote, quoteOffset, scrollTargetPosition, + direction: undefined, }, tabId); - global = updateFocusDirection(global, undefined, tabId); if (replyMessageId) { const replyStack = selectReplyStack(global, chatId, threadId, tabId) || []; @@ -465,7 +414,7 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType => } if (shouldSwitchChat) { - global = updateFocusDirection(global, FocusDirection.Static, tabId); + global = updateFocusedMessage(global, { direction: FocusDirection.Static }, tabId); } const viewportIds = selectViewportIds(global, chatId, threadId, tabId); @@ -489,7 +438,7 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType => if (viewportIds && !shouldSwitchChat) { const direction = messageId > viewportIds[0] ? FocusDirection.Down : FocusDirection.Up; - global = updateFocusDirection(global, direction, tabId); + global = updateFocusedMessage(global, { direction }, tabId); } if (isAnimatingScroll()) { @@ -516,6 +465,50 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType => return undefined; }); +addActionHandler('scrollMessageListToBottom', (global, actions, payload): ActionReturnType => { + const { tabId = getCurrentTabId() } = payload || {}; + const currentMessageList = selectCurrentMessageList(global, tabId); + if (!currentMessageList) { + return; + } + + const { chatId, threadId } = currentMessageList; + + global = updateFocusedMessage(global, { + chatId, + threadId, + messageId: undefined, + scrollTargetPosition: 'end', + direction: FocusDirection.Down, + noHighlight: true, + }, tabId); + + setGlobal(global, { forceOnHeavyAnimation: true }); + + // Reuse part of `focusMessage` + if (blurTimeout) { + clearTimeout(blurTimeout); + blurTimeout = undefined; + } + blurTimeout = window.setTimeout(() => { + global = getGlobal(); + global = updateFocusedMessage(global, undefined, tabId); + setGlobal(global); + }, FOCUS_NO_HIGHLIGHT_DURATION); + + if (isAnimatingScroll()) { + cancelScrollBlockingAnimation(); + } + + actions.loadViewportMessages({ + chatId, + threadId, + tabId, + shouldForceRender: true, + forceLastSlice: true, + }); +}); + addActionHandler('setShouldPreventComposerAnimation', (global, actions, payload): ActionReturnType => { const { shouldPreventComposerAnimation, tabId = getCurrentTabId() } = payload; return updateTabState(global, { diff --git a/src/global/reducers/messages.ts b/src/global/reducers/messages.ts index cc5b69654..b1d276fdc 100644 --- a/src/global/reducers/messages.ts +++ b/src/global/reducers/messages.ts @@ -4,10 +4,8 @@ import type { ApiWebPageFull, } from '../../api/types'; import type { - FocusDirection, MessageList, MessageListType, - ScrollTargetPosition, TabThread, Thread, ThreadId, @@ -719,40 +717,16 @@ export function updateQuickReplyMessages( } export function updateFocusedMessage( - { - global, - chatId, - messageId, - threadId = MAIN_THREAD_ID, - noHighlight = false, - isResizingContainer = false, - quote, - quoteOffset, - scrollTargetPosition, - }: { - global: T; - chatId?: string; - messageId?: number; - threadId?: ThreadId; - noHighlight?: boolean; - isResizingContainer?: boolean; - quote?: string; - quoteOffset?: number; - scrollTargetPosition?: ScrollTargetPosition; - }, - ...[tabId = getCurrentTabId()]: TabArgs + global: T, update: Partial | undefined, ...[tabId = getCurrentTabId()]: TabArgs ): T { + if (!update) { + return updateTabState(global, { focusedMessage: undefined }, tabId); + } + return updateTabState(global, { focusedMessage: { ...selectTabState(global, tabId).focusedMessage, - chatId, - threadId, - messageId, - noHighlight, - isResizingContainer, - quote, - quoteOffset, - scrollTargetPosition, + ...update, }, }, tabId); } @@ -789,18 +763,6 @@ export function deleteSponsoredMessage( }; } -export function updateFocusDirection( - global: T, direction?: FocusDirection, - ...[tabId = getCurrentTabId()]: TabArgs -): T { - return updateTabState(global, { - focusedMessage: { - ...selectTabState(global, tabId).focusedMessage, - direction, - }, - }, tabId); -} - export function enterMessageSelectMode( global: T, chatId: string, diff --git a/src/global/selectors/chats.ts b/src/global/selectors/chats.ts index 85252eaa8..7cbd0c9bf 100644 --- a/src/global/selectors/chats.ts +++ b/src/global/selectors/chats.ts @@ -1,8 +1,8 @@ -import type { - ApiChat, ApiChatFullInfo, ApiChatType, -} from '../../api/types'; import type { ChatListType } from '../../types'; import type { GlobalState, TabArgs } from '../types'; +import { + type ApiChat, type ApiChatFullInfo, type ApiChatType, +} from '../../api/types'; import { ALL_FOLDER_ID, ARCHIVED_FOLDER_ID, MEMBERS_LOAD_SLICE, SAVED_FOLDER_ID, SERVICE_NOTIFICATIONS_USER_ID, diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index a90e648af..173291bd9 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -469,6 +469,7 @@ export interface ActionPayloads { chatId?: string; threadId?: ThreadId; shouldForceRender?: boolean; + forceLastSlice?: boolean; onLoaded?: NoneToVoidFunction; onError?: NoneToVoidFunction; } & WithTabId; @@ -1024,8 +1025,8 @@ export interface ActionPayloads { scrollTargetPosition?: ScrollTargetPosition; timestamp?: number; } & WithTabId; + scrollMessageListToBottom: WithTabId | undefined; - focusLastMessage: WithTabId | undefined; updateDraftReplyInfo: Partial & WithTabId; resetDraftReplyInfo: WithTabId | undefined; updateDraftSuggestedPostInfo: Partial & WithTabId; diff --git a/src/hooks/useResizeMessageObserver.ts b/src/hooks/useResizeMessageObserver.ts index 83c920c9d..044ed697f 100644 --- a/src/hooks/useResizeMessageObserver.ts +++ b/src/hooks/useResizeMessageObserver.ts @@ -16,7 +16,7 @@ function useMessageResizeObserver( shouldFocusOnResize = false, ) { const { - focusLastMessage, + scrollMessageListToBottom, } = getActions(); const messageHeightRef = useRef(0); @@ -40,7 +40,7 @@ function useMessageResizeObserver( const previousScrollBottom = currentScrollBottom - resizeDiff; if (previousScrollBottom <= BOTTOM_FOCUS_SCROLL_THRESHOLD) { - focusLastMessage(); + scrollMessageListToBottom(); } }, );