Message List: Fix scroll to bottom ignoring local messages (#6418)

This commit is contained in:
zubiden 2025-11-06 11:36:38 +01:00 committed by Alexander Zinchuk
parent cb6f814c36
commit b497a3c0ea
17 changed files with 211 additions and 179 deletions

View File

@ -181,7 +181,6 @@ const Chat: FC<OwnProps & StateProps> = ({
openChat, openChat,
openSavedDialog, openSavedDialog,
toggleChatInfo, toggleChatInfo,
focusLastMessage,
focusMessage, focusMessage,
loadTopics, loadTopics,
openForumPanel, openForumPanel,
@ -191,6 +190,7 @@ const Chat: FC<OwnProps & StateProps> = ({
openFrozenAccountModal, openFrozenAccountModal,
updateChatMutedState, updateChatMutedState,
openQuickPreview, openQuickPreview,
scrollMessageListToBottom,
} = getActions(); } = getActions();
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
@ -293,7 +293,7 @@ const Chat: FC<OwnProps & StateProps> = ({
openChat({ id: chatId, noForumTopicPanel, shouldReplaceHistory: true }, { forceOnHeavyAnimation: true }); openChat({ id: chatId, noForumTopicPanel, shouldReplaceHistory: true }, { forceOnHeavyAnimation: true });
if (isSelected && canScrollDown) { if (isSelected && canScrollDown) {
focusLastMessage(); scrollMessageListToBottom();
} }
}); });

View File

@ -102,7 +102,7 @@ const Topic: FC<OwnProps & StateProps> = ({
const { const {
openThread, openThread,
deleteTopic, deleteTopic,
focusLastMessage, scrollMessageListToBottom,
setViewForumAsMessages, setViewForumAsMessages,
updateTopicMutedState, updateTopicMutedState,
openQuickPreview, openQuickPreview,
@ -168,7 +168,7 @@ const Topic: FC<OwnProps & StateProps> = ({
setViewForumAsMessages({ chatId, isEnabled: false }); setViewForumAsMessages({ chatId, isEnabled: false });
if (canScrollDown) { if (canScrollDown) {
focusLastMessage(); scrollMessageListToBottom();
} }
}); });

View File

@ -6,7 +6,6 @@ import type { MessageListType, ThreadId } from '../../types';
import { MAIN_THREAD_ID } from '../../api/types'; import { MAIN_THREAD_ID } from '../../api/types';
import { selectChat, selectCurrentMessageList, selectCurrentMiddleSearch } from '../../global/selectors'; import { selectChat, selectCurrentMessageList, selectCurrentMiddleSearch } from '../../global/selectors';
import animateScroll from '../../util/animateScroll';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
@ -32,8 +31,6 @@ type StateProps = {
mentionsCount?: number; mentionsCount?: number;
}; };
const FOCUS_MARGIN = 20;
const FloatingActionButtons: FC<OwnProps & StateProps> = ({ const FloatingActionButtons: FC<OwnProps & StateProps> = ({
withScrollDown, withScrollDown,
canPost, canPost,
@ -49,7 +46,7 @@ const FloatingActionButtons: FC<OwnProps & StateProps> = ({
}) => { }) => {
const { const {
focusNextReply, focusNextReaction, focusNextMention, fetchUnreadReactions, focusNextReply, focusNextReaction, focusNextMention, fetchUnreadReactions,
readAllMentions, readAllReactions, fetchUnreadMentions, readAllMentions, readAllReactions, fetchUnreadMentions, scrollMessageListToBottom,
} = getActions(); } = getActions();
const elementRef = useRef<HTMLDivElement>(); const elementRef = useRef<HTMLDivElement>();
@ -99,21 +96,7 @@ const FloatingActionButtons: FC<OwnProps & StateProps> = ({
if (messageListType === 'thread') { if (messageListType === 'thread') {
focusNextReply(); focusNextReply();
} else { } else {
const messagesContainer = elementRef.current!.parentElement!.querySelector<HTMLDivElement>( scrollMessageListToBottom();
'.Transition_slide-active > .MessageList',
)!;
const messageElements = messagesContainer.querySelectorAll<HTMLDivElement>('.message-list-item');
const lastMessageElement = messageElements[messageElements.length - 1];
if (!lastMessageElement) {
return;
}
animateScroll({
container: messagesContainer,
element: lastMessageElement,
position: 'end',
margin: FOCUS_MARGIN,
});
} }
}); });

View File

@ -321,6 +321,11 @@
} }
} }
.list-bottom-marker.with-sponsored {
position: relative;
top: -1rem; // Prevent overlapping with the sponsored message
}
@media (pointer: coarse) { @media (pointer: coarse) {
user-select: none; user-select: none;

View File

@ -61,6 +61,7 @@ import { isLocalMessageId } from '../../util/keys/messageKey';
import resetScroll from '../../util/resetScroll'; import resetScroll from '../../util/resetScroll';
import { debounce, onTickEnd } from '../../util/schedulers'; import { debounce, onTickEnd } from '../../util/schedulers';
import getOffsetToContainer from '../../util/visibility/getOffsetToContainer'; import getOffsetToContainer from '../../util/visibility/getOffsetToContainer';
import { REM } from '../common/helpers/mediaDimensions';
import { groupMessages } from './helpers/groupMessages'; import { groupMessages } from './helpers/groupMessages';
import { preventMessageInputBlur } from './helpers/preventMessageInputBlur'; import { preventMessageInputBlur } from './helpers/preventMessageInputBlur';
@ -76,7 +77,7 @@ import useContainerHeight from './hooks/useContainerHeight';
import useStickyDates from './hooks/useStickyDates'; import useStickyDates from './hooks/useStickyDates';
import Loading from '../ui/Loading'; import Loading from '../ui/Loading';
import Transition from '../ui/Transition.tsx'; import Transition from '../ui/Transition';
import ContactGreeting from './ContactGreeting'; import ContactGreeting from './ContactGreeting';
import MessageListAccountInfo from './MessageListAccountInfo'; import MessageListAccountInfo from './MessageListAccountInfo';
import MessageListContent from './MessageListContent'; import MessageListContent from './MessageListContent';
@ -144,6 +145,7 @@ type StateProps = {
translationLanguage?: string; translationLanguage?: string;
shouldAutoTranslate?: boolean; shouldAutoTranslate?: boolean;
isActive?: boolean; isActive?: boolean;
shouldScrollToBottom?: boolean;
}; };
enum Content { enum Content {
@ -168,7 +170,7 @@ const BOTTOM_THRESHOLD = 50;
const UNREAD_DIVIDER_TOP = 10; const UNREAD_DIVIDER_TOP = 10;
const SCROLL_DEBOUNCE = 200; const SCROLL_DEBOUNCE = 200;
const MESSAGE_ANIMATION_DURATION = 500; const MESSAGE_ANIMATION_DURATION = 500;
const BOTTOM_FOCUS_MARGIN = 20; const BOTTOM_FOCUS_MARGIN = 0.5 * REM;
const SELECT_MODE_ANIMATION_DURATION = 200; const SELECT_MODE_ANIMATION_DURATION = 200;
const UNREAD_DIVIDER_CLASS = 'unread-divider'; const UNREAD_DIVIDER_CLASS = 'unread-divider';
@ -186,6 +188,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
canPost, canPost,
isSynced, isSynced,
isActive, isActive,
shouldScrollToBottom,
// eslint-disable-next-line @typescript-eslint/no-shadow // eslint-disable-next-line @typescript-eslint/no-shadow
isChatMonoforum, isChatMonoforum,
isReady, isReady,
@ -622,11 +625,11 @@ const MessageList: FC<OwnProps & StateProps> = ({
if (wasMessageAdded && isAtBottom && !isAlreadyFocusing) { if (wasMessageAdded && isAtBottom && !isAlreadyFocusing) {
// Break out of `forceLayout` // Break out of `forceLayout`
requestMeasure(() => { requestMeasure(() => {
const shouldScrollToBottom = !isBackgroundModeActive() || !firstUnreadElement; const isScrollToBottom = !isBackgroundModeActive() || !firstUnreadElement;
animateScroll({ animateScroll({
container, container,
element: shouldScrollToBottom ? lastItemElement : firstUnreadElement, element: isScrollToBottom ? lastItemElement : firstUnreadElement,
position: shouldScrollToBottom ? 'end' : 'start', position: isScrollToBottom ? 'end' : 'start',
margin: BOTTOM_FOCUS_MARGIN, margin: BOTTOM_FOCUS_MARGIN,
forceDuration: noMessageSendingAnimation ? 0 : undefined, forceDuration: noMessageSendingAnimation ? 0 : undefined,
}); });
@ -800,10 +803,11 @@ const MessageList: FC<OwnProps & StateProps> = ({
photoChangeDate={photoChangeDate} photoChangeDate={photoChangeDate}
noAppearanceAnimation={!messageGroups || !shouldAnimateAppearanceRef.current} noAppearanceAnimation={!messageGroups || !shouldAnimateAppearanceRef.current}
isQuickPreview={isQuickPreview} isQuickPreview={isQuickPreview}
canPost={canPost}
shouldScrollToBottom={shouldScrollToBottom}
onScrollDownToggle={onScrollDownToggle} onScrollDownToggle={onScrollDownToggle}
onNotchToggle={onNotchToggle} onNotchToggle={onNotchToggle}
onIntersectPinnedMessage={onIntersectPinnedMessage} onIntersectPinnedMessage={onIntersectPinnedMessage}
canPost={canPost}
/> />
) : ( ) : (
<Loading color="white" backgroundColor="dark" /> <Loading color="white" backgroundColor="dark" />
@ -827,6 +831,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global, { chatId, threadId, type }): Complete<StateProps> => { (global, { chatId, threadId, type }): Complete<StateProps> => {
const tabState = selectTabState(global);
const currentUserId = global.currentUserId!; const currentUserId = global.currentUserId!;
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
const userFullInfo = selectUserFullInfo(global, chatId); const userFullInfo = selectUserFullInfo(global, chatId);
@ -883,6 +888,13 @@ export default memo(withGlobal<OwnProps>(
const isActive = currentMessageList && currentMessageList.chatId === chatId const isActive = currentMessageList && currentMessageList.chatId === chatId
&& currentMessageList.threadId === threadId && currentMessageList.type === type; && currentMessageList.threadId === threadId && currentMessageList.type === type;
const {
chatId: focusedChatId,
threadId: focusedThreadId,
messageId: focusedMessageId,
} = tabState.focusedMessage || {};
const shouldScrollToBottom = focusedChatId === chatId && focusedThreadId === threadId && !focusedMessageId;
return { return {
isActive, isActive,
areAdsEnabled, areAdsEnabled,
@ -925,6 +937,7 @@ export default memo(withGlobal<OwnProps>(
canTranslate, canTranslate,
translationLanguage, translationLanguage,
shouldAutoTranslate, shouldAutoTranslate,
shouldScrollToBottom,
}; };
}, },
)(MessageList)); )(MessageList));

View File

@ -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<HTMLDivElement>();
useFocusMessageListElement({
elementRef: ref,
isJustAdded,
isFocused,
noFocusHighlight: true,
});
return (
<div ref={ref} className={buildClassName('list-bottom-marker', className)} />
);
};
export default memo(MessageListBottomMarker);

View File

@ -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 { getIsHeavyAnimating, memo } from '../../lib/teact/teact';
import { getActions, getGlobal } from '../../global'; import { getActions, getGlobal } from '../../global';
@ -43,6 +43,7 @@ import Message from './message/Message';
import SenderGroupContainer from './message/SenderGroupContainer'; import SenderGroupContainer from './message/SenderGroupContainer';
import SponsoredMessage from './message/SponsoredMessage'; import SponsoredMessage from './message/SponsoredMessage';
import MessageListAccountInfo from './MessageListAccountInfo'; import MessageListAccountInfo from './MessageListAccountInfo';
import MessageListBottomMarker from './MessageListBottomMarker';
import actionMessageStyles from './message/ActionMessage.module.scss'; import actionMessageStyles from './message/ActionMessage.module.scss';
@ -75,15 +76,16 @@ interface OwnProps {
noAppearanceAnimation: boolean; noAppearanceAnimation: boolean;
isSavedDialog?: boolean; isSavedDialog?: boolean;
isQuickPreview?: boolean; isQuickPreview?: boolean;
canPost?: boolean;
shouldScrollToBottom?: boolean;
onScrollDownToggle?: BooleanToVoidFunction; onScrollDownToggle?: BooleanToVoidFunction;
onNotchToggle?: AnyToVoidFunction; onNotchToggle?: AnyToVoidFunction;
onIntersectPinnedMessage?: OnIntersectPinnedMessage; onIntersectPinnedMessage?: OnIntersectPinnedMessage;
canPost?: boolean;
} }
const UNREAD_DIVIDER_CLASS = 'unread-divider'; const UNREAD_DIVIDER_CLASS = 'unread-divider';
const MessageListContent: FC<OwnProps> = ({ const MessageListContent = ({
canShowAds, canShowAds,
chatId, chatId,
threadId, threadId,
@ -112,17 +114,19 @@ const MessageListContent: FC<OwnProps> = ({
noAppearanceAnimation, noAppearanceAnimation,
isSavedDialog, isSavedDialog,
isQuickPreview, isQuickPreview,
shouldScrollToBottom,
canPost,
onScrollDownToggle, onScrollDownToggle,
onNotchToggle, onNotchToggle,
onIntersectPinnedMessage, onIntersectPinnedMessage,
canPost, }: OwnProps) => {
}) => {
const { openHistoryCalendar } = getActions(); const { openHistoryCalendar } = getActions();
const getIsHeavyAnimating2 = getIsHeavyAnimating; const getIsHeavyAnimating2 = getIsHeavyAnimating;
const getIsReady = useDerivedSignal(() => isReady && !getIsHeavyAnimating2(), [isReady, getIsHeavyAnimating2]); const getIsReady = useDerivedSignal(() => isReady && !getIsHeavyAnimating2(), [isReady, getIsHeavyAnimating2]);
const areDatesClickable = !isSavedDialog && !isSchedule; const areDatesClickable = !isSavedDialog && !isSchedule;
const shouldRenderSponsoredMessage = canShowAds && isViewportNewest;
const { const {
observeIntersectionForReading, observeIntersectionForReading,
@ -135,17 +139,17 @@ const MessageListContent: FC<OwnProps> = ({
backwardsTriggerRef, backwardsTriggerRef,
forwardsTriggerRef, forwardsTriggerRef,
fabTriggerRef, fabTriggerRef,
} = useScrollHooks( } = useScrollHooks({
type, type,
containerRef, containerRef,
messageIds, messageIds,
getContainerHeight, getContainerHeight,
isViewportNewest, isViewportNewest,
isUnread, isUnread,
isReady,
onScrollDownToggle, onScrollDownToggle,
onNotchToggle, onNotchToggle,
isReady, });
);
const oldLang = useOldLang(); const oldLang = useOldLang();
const lang = useLang(); const lang = useLang();
@ -457,7 +461,15 @@ const MessageListContent: FC<OwnProps> = ({
key="fab-trigger" key="fab-trigger"
className="fab-trigger" className="fab-trigger"
/> />
{canShowAds && isViewportNewest && ( {isViewportNewest && (
<MessageListBottomMarker
key="bottom-marker"
isJustAdded={isNewMessage}
isFocused={shouldScrollToBottom}
className={shouldRenderSponsoredMessage ? 'with-sponsored' : undefined}
/>
)}
{shouldRenderSponsoredMessage && (
<SponsoredMessage <SponsoredMessage
key={chatId} key={chatId}
chatId={chatId} chatId={chatId}

View File

@ -21,17 +21,27 @@ const NOTCH_THRESHOLD = 1; // Notch has zero height so we at least need a 1px ma
const CONTAINER_HEIGHT_DEBOUNCE = 200; const CONTAINER_HEIGHT_DEBOUNCE = 200;
const TOOLS_FREEZE_TIMEOUT = 350; // Approximate message sending animation duration const TOOLS_FREEZE_TIMEOUT = 350; // Approximate message sending animation duration
export default function useScrollHooks( export default function useScrollHooks({
type: MessageListType, type,
containerRef: ElementRef<HTMLDivElement>, containerRef,
messageIds: number[], messageIds,
getContainerHeight: Signal<number | undefined>, getContainerHeight,
isViewportNewest: boolean, isViewportNewest,
isUnread: boolean, isUnread,
onScrollDownToggle: BooleanToVoidFunction | undefined, isReady,
onNotchToggle: AnyToVoidFunction | undefined, onScrollDownToggle,
isReady: boolean, onNotchToggle,
) { }: {
type: MessageListType;
containerRef: ElementRef<HTMLDivElement>;
messageIds: number[];
getContainerHeight: Signal<number | undefined>;
isViewportNewest: boolean;
isUnread: boolean;
isReady: boolean;
onScrollDownToggle: BooleanToVoidFunction | undefined;
onNotchToggle: AnyToVoidFunction | undefined;
}) {
const { loadViewportMessages } = getActions(); const { loadViewportMessages } = getActions();
const [loadMoreBackwards, loadMoreForwards] = useMemo( const [loadMoreBackwards, loadMoreForwards] = useMemo(
@ -136,7 +146,18 @@ export default function useScrollHooks(
if (isReady) { if (isReady) {
toggleScrollTools(); 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(() => { const freezeShortly = useLastCallback(() => {
freezeForFab(); freezeForFab();

View File

@ -44,7 +44,7 @@ import useMessageResizeObserver from '../../../hooks/useResizeMessageObserver';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
import { type OnIntersectPinnedMessage } from '../hooks/usePinnedMessage'; import { type OnIntersectPinnedMessage } from '../hooks/usePinnedMessage';
import useFluidBackgroundFilter from './hooks/useFluidBackgroundFilter'; import useFluidBackgroundFilter from './hooks/useFluidBackgroundFilter';
import useFocusMessage from './hooks/useFocusMessage'; import useFocusMessageListElement from './hooks/useFocusMessageListElement';
import ActionMessageText from './ActionMessageText'; import ActionMessageText from './ActionMessageText';
import ChannelPhoto from './actions/ChannelPhoto'; import ChannelPhoto from './actions/ChannelPhoto';
@ -176,9 +176,8 @@ const ActionMessage = ({
replyMessage, replyMessage,
id, id,
); );
useFocusMessage({ useFocusMessageListElement({
elementRef: ref, elementRef: ref,
chatId,
isFocused, isFocused,
focusDirection, focusDirection,
noFocusHighlight, noFocusHighlight,

View File

@ -154,7 +154,7 @@ import useMessageResizeObserver from '../../../hooks/useResizeMessageObserver';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
import useTextLanguage from '../../../hooks/useTextLanguage'; import useTextLanguage from '../../../hooks/useTextLanguage';
import useDetectChatLanguage from './hooks/useDetectChatLanguage'; import useDetectChatLanguage from './hooks/useDetectChatLanguage';
import useFocusMessage from './hooks/useFocusMessage'; import useFocusMessageListElement from './hooks/useFocusMessageListElement';
import useInnerHandlers from './hooks/useInnerHandlers'; import useInnerHandlers from './hooks/useInnerHandlers';
import useMessageTranslation from './hooks/useMessageTranslation'; import useMessageTranslation from './hooks/useMessageTranslation';
import useOuterHandlers from './hooks/useOuterHandlers'; import useOuterHandlers from './hooks/useOuterHandlers';
@ -462,7 +462,7 @@ const Message = ({
openSuggestedPostApprovalModal, openSuggestedPostApprovalModal,
disableContextMenuHint, disableContextMenuHint,
animateUnreadReaction, animateUnreadReaction,
focusLastMessage, focusMessage,
markMentionsRead, markMentionsRead,
} = getActions(); } = getActions();
@ -698,15 +698,24 @@ const Message = ({
requestEffect(); requestEffect();
}); });
const handleFocusSelf = useLastCallback(() => {
focusMessage({
chatId,
threadId,
messageId,
noHighlight: true,
});
});
useEffect(() => { useEffect(() => {
if (!isLastInList) { if (!isLastInList) {
return; return;
} }
if (withVoiceTranscription && transcribedText) { if (withVoiceTranscription && transcribedText) {
focusLastMessage(); handleFocusSelf();
} }
}, [focusLastMessage, isLastInList, transcribedText, withVoiceTranscription]); }, [isLastInList, transcribedText, withVoiceTranscription]);
useEffect(() => { useEffect(() => {
const element = ref.current; const element = ref.current;
@ -882,9 +891,8 @@ const Message = ({
replyStory, replyStory,
); );
useFocusMessage({ useFocusMessageListElement({
elementRef: ref, elementRef: ref,
chatId,
isFocused, isFocused,
focusDirection, focusDirection,
noFocusHighlight, noFocusHighlight,

View File

@ -9,15 +9,16 @@ import {
requestForcedReflow, requestMeasure, requestMutation, requestForcedReflow, requestMeasure, requestMutation,
} from '../../../../lib/fasterdom/fasterdom'; } from '../../../../lib/fasterdom/fasterdom';
import animateScroll from '../../../../util/animateScroll'; import animateScroll from '../../../../util/animateScroll';
import { REM } from '../../../common/helpers/mediaDimensions';
// This is used when the viewport was replaced. // This is used when the viewport was replaced.
const BOTTOM_FOCUS_OFFSET = 500; const BOTTOM_FOCUS_OFFSET = 500;
const RELOCATED_FOCUS_OFFSET = SCROLL_MAX_DISTANCE; 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, elementRef,
chatId,
isFocused, isFocused,
focusDirection, focusDirection,
noFocusHighlight, noFocusHighlight,
@ -27,7 +28,6 @@ export default function useFocusMessage({
scrollTargetPosition, scrollTargetPosition,
}: { }: {
elementRef: ElementRef<HTMLDivElement>; elementRef: ElementRef<HTMLDivElement>;
chatId: string;
isFocused?: boolean; isFocused?: boolean;
focusDirection?: FocusDirection; focusDirection?: FocusDirection;
noFocusHighlight?: boolean; noFocusHighlight?: boolean;
@ -43,10 +43,12 @@ export default function useFocusMessage({
isRelocatedRef.current = false; isRelocatedRef.current = false;
if (isFocused && elementRef.current) { if (isFocused && elementRef.current) {
const messagesContainer = elementRef.current.closest<HTMLDivElement>('.MessageList')!; const messagesContainer = elementRef.current.closest<HTMLDivElement>('.MessageList');
if (!messagesContainer) return;
// `noFocusHighlight` is always called with “scroll-to-bottom” buttons // `noFocusHighlight` is always called with “scroll-to-bottom” buttons
const isToBottom = noFocusHighlight; const isToBottom = noFocusHighlight;
const scrollPosition = scrollTargetPosition || isToBottom ? 'end' : 'centerOrTop'; const scrollPosition = scrollTargetPosition || (isToBottom ? 'end' : 'centerOrTop');
const exec = () => { const exec = () => {
const maxDistance = focusDirection !== undefined const maxDistance = focusDirection !== undefined
@ -56,7 +58,7 @@ export default function useFocusMessage({
container: messagesContainer, container: messagesContainer,
element: elementRef.current!, element: elementRef.current!,
position: scrollPosition, position: scrollPosition,
margin: FOCUS_MARGIN, margin: isToBottom ? BOTTOM_FOCUS_MARGIN : FOCUS_MARGIN,
maxDistance, maxDistance,
forceDirection: focusDirection, forceDirection: focusDirection,
forceNormalContainerHeight: isResizingContainer, 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,
]); ]);
} }

View File

@ -169,6 +169,7 @@ addActionHandler('loadViewportMessages', (global, actions, payload): ActionRetur
direction = LoadMoreDirection.Around, direction = LoadMoreDirection.Around,
isBudgetPreload = false, isBudgetPreload = false,
shouldForceRender = false, shouldForceRender = false,
forceLastSlice = false,
onLoaded, onLoaded,
onError, onError,
tabId = getCurrentTabId(), tabId = getCurrentTabId(),
@ -199,7 +200,9 @@ addActionHandler('loadViewportMessages', (global, actions, payload): ActionRetur
const listedIds = selectListedIds(global, chatId, threadId); const listedIds = selectListedIds(global, chatId, threadId);
if (!viewportIds || !viewportIds.length || direction === LoadMoreDirection.Around) { 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 isOutlying = Boolean(offsetId && listedIds && !listedIds.includes(offsetId));
const historyIds = (isOutlying const historyIds = (isOutlying
? selectOutlyingListByMessageId(global, chatId, threadId, offsetId!) ? selectOutlyingListByMessageId(global, chatId, threadId, offsetId!)
@ -222,17 +225,19 @@ addActionHandler('loadViewportMessages', (global, actions, payload): ActionRetur
onLoaded?.(); onLoaded?.();
} }
} else { } 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 // Prevent requests with local offsets
if (isLocalMessageId(offsetId)) return; if (offsetId && isLocalMessageId(offsetId)) return;
// Prevent unnecessary requests in threads // Prevent unnecessary requests in threads
if (offsetId === threadId && direction === LoadMoreDirection.Backwards) return; 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 const historyIds = (isOutlying
? selectOutlyingListByMessageId(global, chatId, threadId, offsetId) : listedIds)!; ? selectOutlyingListByMessageId(global, chatId, threadId, offsetId!) : listedIds)!;
if (historyIds?.length) { if (historyIds?.length) {
const { const {
newViewportIds, areSomeLocal, areAllLocal, newViewportIds, areSomeLocal, areAllLocal,

View File

@ -21,7 +21,6 @@ import parseHtmlAsFormattedText from '../../../util/parseHtmlAsFormattedText';
import { getServerTime } from '../../../util/serverTime'; import { getServerTime } from '../../../util/serverTime';
import versionNotification from '../../../versionNotification.txt'; import versionNotification from '../../../versionNotification.txt';
import { import {
getIsSavedDialog,
getMediaFilename, getMediaFilename,
getMediaFormat, getMediaFormat,
getMediaHash, getMediaHash,
@ -41,7 +40,6 @@ import {
replaceTabThreadParam, replaceTabThreadParam,
replaceThreadParam, replaceThreadParam,
toggleMessageSelection, toggleMessageSelection,
updateFocusDirection,
updateFocusedMessage, updateFocusedMessage,
} from '../../reducers'; } from '../../reducers';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
@ -60,7 +58,6 @@ import {
selectIsRightColumnShown, selectIsRightColumnShown,
selectIsViewportNewest, selectIsViewportNewest,
selectMessageIdsByGroupId, selectMessageIdsByGroupId,
selectPinnedIds,
selectReplyStack, selectReplyStack,
selectRequestedChatTranslationLanguage, selectRequestedChatTranslationLanguage,
selectRequestedMessageTranslationLanguage, selectRequestedMessageTranslationLanguage,
@ -323,52 +320,6 @@ addActionHandler('closePollResults', (global, actions, payload): ActionReturnTyp
}, tabId); }, 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 => { addActionHandler('focusNextReply', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {}; const { tabId = getCurrentTabId() } = payload || {};
const currentMessageList = selectCurrentMessageList(global, tabId); const currentMessageList = selectCurrentMessageList(global, tabId);
@ -381,7 +332,7 @@ addActionHandler('focusNextReply', (global, actions, payload): ActionReturnType
const replyStack = selectReplyStack(global, chatId, threadId, tabId); const replyStack = selectReplyStack(global, chatId, threadId, tabId);
if (!replyStack || replyStack.length === 0) { if (!replyStack || replyStack.length === 0) {
actions.focusLastMessage({ tabId }); actions.scrollMessageListToBottom({ tabId });
} else { } else {
const messageId = replyStack.pop(); const messageId = replyStack.pop();
@ -441,13 +392,11 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType =>
} }
blurTimeout = window.setTimeout(() => { blurTimeout = window.setTimeout(() => {
global = getGlobal(); global = getGlobal();
global = updateFocusedMessage({ global }, tabId); global = updateFocusedMessage(global, undefined, tabId);
global = updateFocusDirection(global, undefined, tabId);
setGlobal(global); setGlobal(global);
}, noHighlight ? FOCUS_NO_HIGHLIGHT_DURATION : FOCUS_DURATION); }, noHighlight ? FOCUS_NO_HIGHLIGHT_DURATION : FOCUS_DURATION);
global = updateFocusedMessage({ global = updateFocusedMessage(global, {
global,
chatId, chatId,
messageId, messageId,
threadId, threadId,
@ -456,8 +405,8 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType =>
quote, quote,
quoteOffset, quoteOffset,
scrollTargetPosition, scrollTargetPosition,
direction: undefined,
}, tabId); }, tabId);
global = updateFocusDirection(global, undefined, tabId);
if (replyMessageId) { if (replyMessageId) {
const replyStack = selectReplyStack(global, chatId, threadId, tabId) || []; const replyStack = selectReplyStack(global, chatId, threadId, tabId) || [];
@ -465,7 +414,7 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType =>
} }
if (shouldSwitchChat) { if (shouldSwitchChat) {
global = updateFocusDirection(global, FocusDirection.Static, tabId); global = updateFocusedMessage(global, { direction: FocusDirection.Static }, tabId);
} }
const viewportIds = selectViewportIds(global, chatId, threadId, tabId); const viewportIds = selectViewportIds(global, chatId, threadId, tabId);
@ -489,7 +438,7 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType =>
if (viewportIds && !shouldSwitchChat) { if (viewportIds && !shouldSwitchChat) {
const direction = messageId > viewportIds[0] ? FocusDirection.Down : FocusDirection.Up; const direction = messageId > viewportIds[0] ? FocusDirection.Down : FocusDirection.Up;
global = updateFocusDirection(global, direction, tabId); global = updateFocusedMessage(global, { direction }, tabId);
} }
if (isAnimatingScroll()) { if (isAnimatingScroll()) {
@ -516,6 +465,50 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType =>
return undefined; 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 => { addActionHandler('setShouldPreventComposerAnimation', (global, actions, payload): ActionReturnType => {
const { shouldPreventComposerAnimation, tabId = getCurrentTabId() } = payload; const { shouldPreventComposerAnimation, tabId = getCurrentTabId() } = payload;
return updateTabState(global, { return updateTabState(global, {

View File

@ -4,10 +4,8 @@ import type {
ApiWebPageFull, ApiWebPageFull,
} from '../../api/types'; } from '../../api/types';
import type { import type {
FocusDirection,
MessageList, MessageList,
MessageListType, MessageListType,
ScrollTargetPosition,
TabThread, TabThread,
Thread, Thread,
ThreadId, ThreadId,
@ -719,40 +717,16 @@ export function updateQuickReplyMessages<T extends GlobalState>(
} }
export function updateFocusedMessage<T extends GlobalState>( export function updateFocusedMessage<T extends GlobalState>(
{ global: T, update: Partial<TabState['focusedMessage']> | undefined, ...[tabId = getCurrentTabId()]: TabArgs<T>
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<T>
): T { ): T {
if (!update) {
return updateTabState(global, { focusedMessage: undefined }, tabId);
}
return updateTabState(global, { return updateTabState(global, {
focusedMessage: { focusedMessage: {
...selectTabState(global, tabId).focusedMessage, ...selectTabState(global, tabId).focusedMessage,
chatId, ...update,
threadId,
messageId,
noHighlight,
isResizingContainer,
quote,
quoteOffset,
scrollTargetPosition,
}, },
}, tabId); }, tabId);
} }
@ -789,18 +763,6 @@ export function deleteSponsoredMessage<T extends GlobalState>(
}; };
} }
export function updateFocusDirection<T extends GlobalState>(
global: T, direction?: FocusDirection,
...[tabId = getCurrentTabId()]: TabArgs<T>
): T {
return updateTabState(global, {
focusedMessage: {
...selectTabState(global, tabId).focusedMessage,
direction,
},
}, tabId);
}
export function enterMessageSelectMode<T extends GlobalState>( export function enterMessageSelectMode<T extends GlobalState>(
global: T, global: T,
chatId: string, chatId: string,

View File

@ -1,8 +1,8 @@
import type {
ApiChat, ApiChatFullInfo, ApiChatType,
} from '../../api/types';
import type { ChatListType } from '../../types'; import type { ChatListType } from '../../types';
import type { GlobalState, TabArgs } from '../types'; import type { GlobalState, TabArgs } from '../types';
import {
type ApiChat, type ApiChatFullInfo, type ApiChatType,
} from '../../api/types';
import { import {
ALL_FOLDER_ID, ARCHIVED_FOLDER_ID, MEMBERS_LOAD_SLICE, SAVED_FOLDER_ID, SERVICE_NOTIFICATIONS_USER_ID, ALL_FOLDER_ID, ARCHIVED_FOLDER_ID, MEMBERS_LOAD_SLICE, SAVED_FOLDER_ID, SERVICE_NOTIFICATIONS_USER_ID,

View File

@ -469,6 +469,7 @@ export interface ActionPayloads {
chatId?: string; chatId?: string;
threadId?: ThreadId; threadId?: ThreadId;
shouldForceRender?: boolean; shouldForceRender?: boolean;
forceLastSlice?: boolean;
onLoaded?: NoneToVoidFunction; onLoaded?: NoneToVoidFunction;
onError?: NoneToVoidFunction; onError?: NoneToVoidFunction;
} & WithTabId; } & WithTabId;
@ -1024,8 +1025,8 @@ export interface ActionPayloads {
scrollTargetPosition?: ScrollTargetPosition; scrollTargetPosition?: ScrollTargetPosition;
timestamp?: number; timestamp?: number;
} & WithTabId; } & WithTabId;
scrollMessageListToBottom: WithTabId | undefined;
focusLastMessage: WithTabId | undefined;
updateDraftReplyInfo: Partial<ApiInputMessageReplyInfo> & WithTabId; updateDraftReplyInfo: Partial<ApiInputMessageReplyInfo> & WithTabId;
resetDraftReplyInfo: WithTabId | undefined; resetDraftReplyInfo: WithTabId | undefined;
updateDraftSuggestedPostInfo: Partial<ApiInputSuggestedPostInfo> & WithTabId; updateDraftSuggestedPostInfo: Partial<ApiInputSuggestedPostInfo> & WithTabId;

View File

@ -16,7 +16,7 @@ function useMessageResizeObserver(
shouldFocusOnResize = false, shouldFocusOnResize = false,
) { ) {
const { const {
focusLastMessage, scrollMessageListToBottom,
} = getActions(); } = getActions();
const messageHeightRef = useRef(0); const messageHeightRef = useRef(0);
@ -40,7 +40,7 @@ function useMessageResizeObserver(
const previousScrollBottom = currentScrollBottom - resizeDiff; const previousScrollBottom = currentScrollBottom - resizeDiff;
if (previousScrollBottom <= BOTTOM_FOCUS_SCROLL_THRESHOLD) { if (previousScrollBottom <= BOTTOM_FOCUS_SCROLL_THRESHOLD) {
focusLastMessage(); scrollMessageListToBottom();
} }
}, },
); );