Message List: Simplify pinned navigation check (#6649)

This commit is contained in:
zubiden 2026-02-22 23:43:11 +01:00 committed by Alexander Zinchuk
parent 0142b6ac55
commit 6908e40e0f
6 changed files with 95 additions and 87 deletions

View File

@ -135,7 +135,16 @@ const MessageListContent = ({
observeIntersectionForReading, observeIntersectionForReading,
observeIntersectionForLoading, observeIntersectionForLoading,
observeIntersectionForPlaying, observeIntersectionForPlaying,
} = useMessageObservers(type, containerRef, memoFirstUnreadIdRef, onIntersectPinnedMessage, chatId, isQuickPreview); onMessageUnmount,
} = useMessageObservers({
type,
containerRef,
memoFirstUnreadIdRef,
chatId,
threadId,
isQuickPreview,
onIntersectPinnedMessage,
});
const { const {
withHistoryTriggers, withHistoryTriggers,
@ -304,7 +313,7 @@ const MessageListContent = ({
isJustAdded={isLastInList && isNewMessage} isJustAdded={isLastInList && isNewMessage}
isLastInList={isLastInList} isLastInList={isLastInList}
getIsMessageListReady={getIsReady} getIsMessageListReady={getIsReady}
onIntersectPinnedMessage={onIntersectPinnedMessage} onMessageUnmount={onMessageUnmount}
/>, />,
]); ]);
} }
@ -375,8 +384,8 @@ const MessageListContent = ({
isLastInDocumentGroup={position.isLastInDocumentGroup} isLastInDocumentGroup={position.isLastInDocumentGroup}
isLastInList={position.isLastInList} isLastInList={position.isLastInList}
memoFirstUnreadIdRef={memoFirstUnreadIdRef} memoFirstUnreadIdRef={memoFirstUnreadIdRef}
onIntersectPinnedMessage={onIntersectPinnedMessage}
getIsMessageListReady={getIsReady} getIsMessageListReady={getIsReady}
onMessageUnmount={onMessageUnmount}
/>, />,
]); ]);
}).flat(); }).flat();

View File

@ -1,26 +1,37 @@
import type { ElementRef } from '../../../lib/teact/teact'; import { type ElementRef, useEffect, useRef } from '../../../lib/teact/teact';
import { getActions } from '../../../global'; import { getActions } from '../../../global';
import type { MessageListType } from '../../../types'; import type { MessageListType, ThreadId } from '../../../types';
import type { OnIntersectPinnedMessage } from './usePinnedMessage'; import type { OnIntersectPinnedMessage } from './usePinnedMessage';
import { IS_ANDROID } from '../../../util/browser/windowEnvironment'; import { IS_ANDROID } from '../../../util/browser/windowEnvironment';
import { unique } from '../../../util/iteratees';
import useAppLayout from '../../../hooks/useAppLayout'; import useAppLayout from '../../../hooks/useAppLayout';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import useLastCallback from '../../../hooks/useLastCallback';
import useBackgroundMode, { isBackgroundModeActive } from '../../../hooks/window/useBackgroundMode'; import useBackgroundMode, { isBackgroundModeActive } from '../../../hooks/window/useBackgroundMode';
const INTERSECTION_THROTTLE_FOR_READING = 150; const INTERSECTION_THROTTLE_FOR_READING = 150;
const INTERSECTION_THROTTLE_FOR_MEDIA = IS_ANDROID ? 1000 : 350; const INTERSECTION_THROTTLE_FOR_MEDIA = IS_ANDROID ? 1000 : 350;
export default function useMessageObservers( export default function useMessageObservers({
type: MessageListType, type,
containerRef: ElementRef<HTMLDivElement>, containerRef,
memoFirstUnreadIdRef: { current: number | undefined }, memoFirstUnreadIdRef,
onIntersectPinnedMessage: OnIntersectPinnedMessage | undefined, chatId,
chatId: string, threadId,
isQuickPreview?: boolean, isQuickPreview,
) { onIntersectPinnedMessage,
}: {
containerRef: ElementRef<HTMLDivElement>;
memoFirstUnreadIdRef: { current: number | undefined };
chatId: string;
threadId: ThreadId;
type: MessageListType;
isQuickPreview?: boolean;
onIntersectPinnedMessage: OnIntersectPinnedMessage | undefined;
}) {
const { const {
markMessageListRead, markMentionsRead, animateUnreadReaction, markMessageListRead, markMentionsRead, animateUnreadReaction,
scheduleForViewsIncrement, scheduleForViewsIncrement,
@ -29,11 +40,18 @@ export default function useMessageObservers(
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
const INTERSECTION_MARGIN_FOR_LOADING = isMobile ? 300 : 500; const INTERSECTION_MARGIN_FOR_LOADING = isMobile ? 300 : 500;
const visibleViewportIdsRef = useRef<number[]>([]);
useEffect(() => {
visibleViewportIdsRef.current = [];
}, [threadId, chatId, type]);
// Note: Targets bottom marker, not the message itself
const { const {
observe: observeIntersectionForReading, freeze: freezeForReading, unfreeze: unfreezeForReading, observe: observeIntersectionForReading, freeze: freezeForReading, unfreeze: unfreezeForReading,
} = useIntersectionObserver({ } = useIntersectionObserver({
rootRef: containerRef, rootRef: containerRef,
throttleMs: INTERSECTION_THROTTLE_FOR_READING, throttleMs: INTERSECTION_THROTTLE_FOR_READING,
threshold: 0,
// `memoFirstUnreadIdRef` is set after the first render, firing callback before that can skip some entries, like the last message // `memoFirstUnreadIdRef` is set after the first render, firing callback before that can skip some entries, like the last message
shouldSkipFirst: true, shouldSkipFirst: true,
}, (entries) => { }, (entries) => {
@ -44,10 +62,12 @@ export default function useMessageObservers(
let maxId = 0; let maxId = 0;
const mentionIds: number[] = []; const mentionIds: number[] = [];
const reactionIds: number[] = []; const reactionIds: number[] = [];
const viewportPinnedIdsToAdd: number[] = [];
const viewportPinnedIdsToRemove: number[] = [];
const scheduledToUpdateViews: number[] = []; const scheduledToUpdateViews: number[] = [];
const currentVisibleViewportIds = visibleViewportIdsRef.current;
const hiddenViewportIds = new Set<number>();
const newVisibleViewportIds: number[] = [];
entries.forEach((entry) => { entries.forEach((entry) => {
const { isIntersecting, target } = entry; const { isIntersecting, target } = entry;
@ -57,12 +77,12 @@ export default function useMessageObservers(
const albumMainId = dataset.albumMainId ? Number(dataset.albumMainId) : undefined; const albumMainId = dataset.albumMainId ? Number(dataset.albumMainId) : undefined;
if (!isIntersecting) { if (!isIntersecting) {
if (dataset.isPinned) { hiddenViewportIds.add(messageId);
viewportPinnedIdsToRemove.push(albumMainId || messageId);
}
return; return;
} }
newVisibleViewportIds.push(messageId);
if (messageId > maxId) { if (messageId > maxId) {
maxId = messageId; maxId = messageId;
} }
@ -75,15 +95,15 @@ export default function useMessageObservers(
reactionIds.push(messageId); reactionIds.push(messageId);
} }
if (dataset.isPinned) {
viewportPinnedIdsToAdd.push(albumMainId || messageId);
}
if (shouldUpdateViews) { if (shouldUpdateViews) {
scheduledToUpdateViews.push(albumMainId || messageId); scheduledToUpdateViews.push(albumMainId || messageId);
} }
}); });
visibleViewportIdsRef.current = unique(currentVisibleViewportIds.concat(newVisibleViewportIds))
.filter((id) => !hiddenViewportIds.has(id))
.sort((a, b) => a - b);
if (!isQuickPreview) { if (!isQuickPreview) {
if (memoFirstUnreadIdRef.current && maxId && maxId >= memoFirstUnreadIdRef.current) { if (memoFirstUnreadIdRef.current && maxId && maxId >= memoFirstUnreadIdRef.current) {
markMessageListRead({ maxId }); markMessageListRead({ maxId });
@ -102,8 +122,8 @@ export default function useMessageObservers(
animateUnreadReaction({ chatId, messageIds: reactionIds }); animateUnreadReaction({ chatId, messageIds: reactionIds });
} }
if (viewportPinnedIdsToAdd.length || viewportPinnedIdsToRemove.length) { if (visibleViewportIdsRef.current.length) {
onIntersectPinnedMessage?.({ viewportPinnedIdsToAdd, viewportPinnedIdsToRemove }); onIntersectPinnedMessage?.({ firstViewportId: visibleViewportIdsRef.current[0] });
} }
}); });
@ -122,9 +142,14 @@ export default function useMessageObservers(
throttleMs: INTERSECTION_THROTTLE_FOR_MEDIA, throttleMs: INTERSECTION_THROTTLE_FOR_MEDIA,
}); });
const onMessageUnmount = useLastCallback((messageId: number) => {
visibleViewportIdsRef.current = visibleViewportIdsRef.current.filter((id) => id !== messageId);
});
return { return {
observeIntersectionForReading, observeIntersectionForReading,
observeIntersectionForLoading, observeIntersectionForLoading,
observeIntersectionForPlaying, observeIntersectionForPlaying,
onMessageUnmount,
}; };
} }

View File

@ -3,22 +3,17 @@ import { getGlobal } from '../../../global';
import type { ThreadId } from '../../../types'; import type { ThreadId } from '../../../types';
import { selectFocusedMessageId, selectListedIds, selectOutlyingListByMessageId } from '../../../global/selectors'; import { selectListedIds, selectOutlyingListByMessageId } from '../../../global/selectors';
import cycleRestrict from '../../../util/cycleRestrict'; import cycleRestrict from '../../../util/cycleRestrict';
import { unique } from '../../../util/iteratees';
import useDerivedSignal from '../../../hooks/useDerivedSignal'; import useDerivedSignal from '../../../hooks/useDerivedSignal';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
export type OnIntersectPinnedMessage = (params: { export type OnIntersectPinnedMessage = (params: {
viewportPinnedIdsToAdd?: number[]; firstViewportId?: number;
viewportPinnedIdsToRemove?: number[];
shouldCancelWaiting?: boolean; shouldCancelWaiting?: boolean;
}) => void; }) => void;
let viewportPinnedIds: number[] | undefined;
let lastFocusedId: number | undefined;
export default function usePinnedMessage( export default function usePinnedMessage(
chatId?: string, threadId?: ThreadId, pinnedIds?: number[], chatId?: string, threadId?: ThreadId, pinnedIds?: number[],
) { ) {
@ -32,10 +27,9 @@ export default function usePinnedMessage(
// Reset when switching chat // Reset when switching chat
useEffect(() => { useEffect(() => {
viewportPinnedIds = undefined;
setLoadingPinnedId(undefined); setLoadingPinnedId(undefined);
}, [ }, [
chatId, setPinnedIndexByKey, setLoadingPinnedId, threadId, chatId, threadId, setPinnedIndexByKey, setLoadingPinnedId,
]); ]);
useEffect(() => { useEffect(() => {
@ -51,14 +45,12 @@ export default function usePinnedMessage(
}, [getPinnedIndexByKey, key, pinnedIds?.length, setPinnedIndexByKey]); }, [getPinnedIndexByKey, key, pinnedIds?.length, setPinnedIndexByKey]);
const handleIntersectPinnedMessage: OnIntersectPinnedMessage = useLastCallback(({ const handleIntersectPinnedMessage: OnIntersectPinnedMessage = useLastCallback(({
viewportPinnedIdsToAdd = [], firstViewportId,
viewportPinnedIdsToRemove = [],
shouldCancelWaiting, shouldCancelWaiting,
}) => { }) => {
if (!chatId || !threadId || !key || !pinnedIds?.length) return; if (!chatId || !threadId || !key || !pinnedIds?.length) return;
if (shouldCancelWaiting) { if (shouldCancelWaiting) {
lastFocusedId = undefined;
setLoadingPinnedId(undefined); setLoadingPinnedId(undefined);
return; return;
} }
@ -71,36 +63,22 @@ export default function usePinnedMessage(
[key]: clampIndex(newPinnedIndex), [key]: clampIndex(newPinnedIndex),
}); });
setLoadingPinnedId(undefined); setLoadingPinnedId(undefined);
// We're still scrolling, prevent updating the index
if (loadingPinnedId < (firstViewportId || 0)) {
return;
}
} }
viewportPinnedIds = unique( let newIndex = pinnedIds.findIndex((id) => id < (firstViewportId || 0));
(viewportPinnedIds?.filter((id) => !viewportPinnedIdsToRemove.includes(id)) ?? []) if (newIndex === -1) {
.concat(viewportPinnedIdsToAdd), newIndex = 0; // Pinned are sorted from newest to oldest
);
// Sometimes this callback is called after focus has been reset in global, so we leverage `lastFocusedId`
const focusedMessageId = selectFocusedMessageId(getGlobal(), chatId) || lastFocusedId;
if (lastFocusedId && viewportPinnedIds.includes(lastFocusedId)) {
lastFocusedId = undefined;
} }
if (focusedMessageId) { setPinnedIndexByKey({
const pinnedIndexAboveFocused = pinnedIds.findIndex((id) => id < focusedMessageId); ...getPinnedIndexByKey(),
[key]: clampIndex(newIndex),
setPinnedIndexByKey({ });
...getPinnedIndexByKey(),
[key]: clampIndex(pinnedIndexAboveFocused),
});
} else if (viewportPinnedIds.length) {
const maxViewportPinnedId = Math.max(...viewportPinnedIds);
const newIndex = pinnedIds.indexOf(maxViewportPinnedId);
setPinnedIndexByKey({
...getPinnedIndexByKey(),
[key]: clampIndex(newIndex),
});
}
}); });
const handleFocusPinnedMessage = useLastCallback((messageId: number) => { const handleFocusPinnedMessage = useLastCallback((messageId: number) => {
@ -109,8 +87,6 @@ export default function usePinnedMessage(
return; return;
} }
lastFocusedId = messageId;
const global = getGlobal(); const global = getGlobal();
const listedIds = selectListedIds(global, chatId, threadId); const listedIds = selectListedIds(global, chatId, threadId);
const isMessageLoaded = listedIds?.includes(messageId) const isMessageLoaded = listedIds?.includes(messageId)

View File

@ -1,5 +1,6 @@
import { import {
memo, useEffect, useMemo, useRef, useUnmountCleanup, memo, useEffect, useMemo, useRef,
useUnmountCleanup,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
@ -51,7 +52,6 @@ import { type ObserveFn, useOnIntersect } from '../../../hooks/useIntersectionOb
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
import { type OnIntersectPinnedMessage } from '../hooks/usePinnedMessage';
import useFluidBackgroundFilter from './hooks/useFluidBackgroundFilter'; import useFluidBackgroundFilter from './hooks/useFluidBackgroundFilter';
import useFocusMessageListElement from './hooks/useFocusMessageListElement'; import useFocusMessageListElement from './hooks/useFocusMessageListElement';
@ -82,10 +82,10 @@ type OwnProps = {
isLastInList?: boolean; isLastInList?: boolean;
memoFirstUnreadIdRef?: { current: number | undefined }; memoFirstUnreadIdRef?: { current: number | undefined };
getIsMessageListReady?: Signal<boolean>; getIsMessageListReady?: Signal<boolean>;
onIntersectPinnedMessage?: OnIntersectPinnedMessage;
observeIntersectionForBottom?: ObserveFn; observeIntersectionForBottom?: ObserveFn;
observeIntersectionForLoading?: ObserveFn; observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn; observeIntersectionForPlaying?: ObserveFn;
onMessageUnmount?: (messageId: number) => void;
}; };
type StateProps = { type StateProps = {
@ -138,10 +138,10 @@ const ActionMessage = ({
isResizingContainer, isResizingContainer,
scrollTargetPosition, scrollTargetPosition,
isAccountFrozen, isAccountFrozen,
onIntersectPinnedMessage,
observeIntersectionForBottom, observeIntersectionForBottom,
observeIntersectionForLoading, observeIntersectionForLoading,
observeIntersectionForPlaying, observeIntersectionForPlaying,
onMessageUnmount,
}: OwnProps & StateProps) => { }: OwnProps & StateProps) => {
const { const {
requestConfetti, requestConfetti,
@ -249,12 +249,6 @@ const ActionMessage = ({
scrollTargetPosition, scrollTargetPosition,
}); });
useUnmountCleanup(() => {
if (message.isPinned) {
onIntersectPinnedMessage?.({ viewportPinnedIdsToRemove: [message.id] });
}
});
const { const {
isContextMenuOpen, contextMenuAnchor, isContextMenuOpen, contextMenuAnchor,
handleBeforeContextMenu, handleContextMenu, handleBeforeContextMenu, handleContextMenu,
@ -291,6 +285,10 @@ const ActionMessage = ({
ref, ref,
}); });
useUnmountCleanup(() => {
onMessageUnmount?.(id);
});
useEffect(() => { useEffect(() => {
const bottomMarker = ref.current; const bottomMarker = ref.current;
if (!bottomMarker || !isElementInViewport(bottomMarker)) return; if (!bottomMarker || !isElementInViewport(bottomMarker)) return;

View File

@ -42,7 +42,6 @@ import type {
ThreadReadState, ThreadReadState,
} from '../../../types'; } from '../../../types';
import type { Signal } from '../../../util/signals'; import type { Signal } from '../../../util/signals';
import type { OnIntersectPinnedMessage } from '../hooks/usePinnedMessage';
import { MAIN_THREAD_ID } from '../../../api/types'; import { MAIN_THREAD_ID } from '../../../api/types';
import { AudioOrigin } from '../../../types'; import { AudioOrigin } from '../../../types';
@ -238,7 +237,7 @@ type OwnProps = {
observeIntersectionForBottom?: ObserveFn; observeIntersectionForBottom?: ObserveFn;
observeIntersectionForLoading?: ObserveFn; observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn; observeIntersectionForPlaying?: ObserveFn;
onIntersectPinnedMessage?: OnIntersectPinnedMessage; onMessageUnmount?: (messageId: number) => void;
} & MessagePositionProperties; } & MessagePositionProperties;
type StateProps = { type StateProps = {
@ -353,9 +352,6 @@ const MAX_REASON_LENGTH = 200;
const Message = ({ const Message = ({
message, message,
observeIntersectionForBottom,
observeIntersectionForLoading,
observeIntersectionForPlaying,
album, album,
noAvatars, noAvatars,
withAvatar, withAvatar,
@ -462,7 +458,10 @@ const Message = ({
minFutureTime, minFutureTime,
webPage, webPage,
summary, summary,
onIntersectPinnedMessage, observeIntersectionForBottom,
observeIntersectionForLoading,
observeIntersectionForPlaying,
onMessageUnmount,
}: OwnProps & StateProps) => { }: OwnProps & StateProps) => {
const { const {
toggleMessageSelection, toggleMessageSelection,
@ -536,19 +535,16 @@ const Message = ({
className: false, className: false,
}); });
useUnmountCleanup(() => {
onMessageUnmount?.(messageId);
});
const { const {
id: messageId, chatId, forwardInfo, viaBotId, isTranscriptionError, factCheck, id: messageId, chatId, forwardInfo, viaBotId, isTranscriptionError, factCheck,
isTypingDraft, isTypingDraft,
} = message; } = message;
const hasSummary = Boolean(message.summaryLanguageCode); const hasSummary = Boolean(message.summaryLanguageCode);
useUnmountCleanup(() => {
if (message.isPinned) {
const id = album ? album.mainMessage.id : messageId;
onIntersectPinnedMessage?.({ viewportPinnedIdsToRemove: [id] });
}
});
const isLocal = isMessageLocal(message); const isLocal = isMessageLocal(message);
const isOwn = isOwnMessage(message); const isOwn = isOwnMessage(message);
const isScheduled = messageListType === 'scheduled' || message.isScheduled; const isScheduled = messageListType === 'scheduled' || message.isScheduled;

View File

@ -364,6 +364,8 @@
} }
&.tiny { &.tiny {
--emoji-size: 1rem;
height: 2.25rem; height: 2.25rem;
padding: 0.4375rem; padding: 0.4375rem;
border-radius: var(--border-radius-button-tiny); border-radius: var(--border-radius-button-tiny);
@ -380,6 +382,8 @@
} }
&.pill { &.pill {
--emoji-size: 1.25rem;
height: 1.75rem; height: 1.75rem;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-radius: 1rem; border-radius: 1rem;