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,
observeIntersectionForLoading,
observeIntersectionForPlaying,
} = useMessageObservers(type, containerRef, memoFirstUnreadIdRef, onIntersectPinnedMessage, chatId, isQuickPreview);
onMessageUnmount,
} = useMessageObservers({
type,
containerRef,
memoFirstUnreadIdRef,
chatId,
threadId,
isQuickPreview,
onIntersectPinnedMessage,
});
const {
withHistoryTriggers,
@ -304,7 +313,7 @@ const MessageListContent = ({
isJustAdded={isLastInList && isNewMessage}
isLastInList={isLastInList}
getIsMessageListReady={getIsReady}
onIntersectPinnedMessage={onIntersectPinnedMessage}
onMessageUnmount={onMessageUnmount}
/>,
]);
}
@ -375,8 +384,8 @@ const MessageListContent = ({
isLastInDocumentGroup={position.isLastInDocumentGroup}
isLastInList={position.isLastInList}
memoFirstUnreadIdRef={memoFirstUnreadIdRef}
onIntersectPinnedMessage={onIntersectPinnedMessage}
getIsMessageListReady={getIsReady}
onMessageUnmount={onMessageUnmount}
/>,
]);
}).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 type { MessageListType } from '../../../types';
import type { MessageListType, ThreadId } from '../../../types';
import type { OnIntersectPinnedMessage } from './usePinnedMessage';
import { IS_ANDROID } from '../../../util/browser/windowEnvironment';
import { unique } from '../../../util/iteratees';
import useAppLayout from '../../../hooks/useAppLayout';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import useLastCallback from '../../../hooks/useLastCallback';
import useBackgroundMode, { isBackgroundModeActive } from '../../../hooks/window/useBackgroundMode';
const INTERSECTION_THROTTLE_FOR_READING = 150;
const INTERSECTION_THROTTLE_FOR_MEDIA = IS_ANDROID ? 1000 : 350;
export default function useMessageObservers(
type: MessageListType,
containerRef: ElementRef<HTMLDivElement>,
memoFirstUnreadIdRef: { current: number | undefined },
onIntersectPinnedMessage: OnIntersectPinnedMessage | undefined,
chatId: string,
isQuickPreview?: boolean,
) {
export default function useMessageObservers({
type,
containerRef,
memoFirstUnreadIdRef,
chatId,
threadId,
isQuickPreview,
onIntersectPinnedMessage,
}: {
containerRef: ElementRef<HTMLDivElement>;
memoFirstUnreadIdRef: { current: number | undefined };
chatId: string;
threadId: ThreadId;
type: MessageListType;
isQuickPreview?: boolean;
onIntersectPinnedMessage: OnIntersectPinnedMessage | undefined;
}) {
const {
markMessageListRead, markMentionsRead, animateUnreadReaction,
scheduleForViewsIncrement,
@ -29,11 +40,18 @@ export default function useMessageObservers(
const { isMobile } = useAppLayout();
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 {
observe: observeIntersectionForReading, freeze: freezeForReading, unfreeze: unfreezeForReading,
} = useIntersectionObserver({
rootRef: containerRef,
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
shouldSkipFirst: true,
}, (entries) => {
@ -44,10 +62,12 @@ export default function useMessageObservers(
let maxId = 0;
const mentionIds: number[] = [];
const reactionIds: number[] = [];
const viewportPinnedIdsToAdd: number[] = [];
const viewportPinnedIdsToRemove: number[] = [];
const scheduledToUpdateViews: number[] = [];
const currentVisibleViewportIds = visibleViewportIdsRef.current;
const hiddenViewportIds = new Set<number>();
const newVisibleViewportIds: number[] = [];
entries.forEach((entry) => {
const { isIntersecting, target } = entry;
@ -57,12 +77,12 @@ export default function useMessageObservers(
const albumMainId = dataset.albumMainId ? Number(dataset.albumMainId) : undefined;
if (!isIntersecting) {
if (dataset.isPinned) {
viewportPinnedIdsToRemove.push(albumMainId || messageId);
}
hiddenViewportIds.add(messageId);
return;
}
newVisibleViewportIds.push(messageId);
if (messageId > maxId) {
maxId = messageId;
}
@ -75,15 +95,15 @@ export default function useMessageObservers(
reactionIds.push(messageId);
}
if (dataset.isPinned) {
viewportPinnedIdsToAdd.push(albumMainId || messageId);
}
if (shouldUpdateViews) {
scheduledToUpdateViews.push(albumMainId || messageId);
}
});
visibleViewportIdsRef.current = unique(currentVisibleViewportIds.concat(newVisibleViewportIds))
.filter((id) => !hiddenViewportIds.has(id))
.sort((a, b) => a - b);
if (!isQuickPreview) {
if (memoFirstUnreadIdRef.current && maxId && maxId >= memoFirstUnreadIdRef.current) {
markMessageListRead({ maxId });
@ -102,8 +122,8 @@ export default function useMessageObservers(
animateUnreadReaction({ chatId, messageIds: reactionIds });
}
if (viewportPinnedIdsToAdd.length || viewportPinnedIdsToRemove.length) {
onIntersectPinnedMessage?.({ viewportPinnedIdsToAdd, viewportPinnedIdsToRemove });
if (visibleViewportIdsRef.current.length) {
onIntersectPinnedMessage?.({ firstViewportId: visibleViewportIdsRef.current[0] });
}
});
@ -122,9 +142,14 @@ export default function useMessageObservers(
throttleMs: INTERSECTION_THROTTLE_FOR_MEDIA,
});
const onMessageUnmount = useLastCallback((messageId: number) => {
visibleViewportIdsRef.current = visibleViewportIdsRef.current.filter((id) => id !== messageId);
});
return {
observeIntersectionForReading,
observeIntersectionForLoading,
observeIntersectionForPlaying,
onMessageUnmount,
};
}

View File

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

View File

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

View File

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

View File

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