diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx index 3e4383a7c..34d3d6460 100644 --- a/src/components/middle/MessageListContent.tsx +++ b/src/components/middle/MessageListContent.tsx @@ -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(); diff --git a/src/components/middle/hooks/useMessageObservers.ts b/src/components/middle/hooks/useMessageObservers.ts index 57dbc0285..d210a0de0 100644 --- a/src/components/middle/hooks/useMessageObservers.ts +++ b/src/components/middle/hooks/useMessageObservers.ts @@ -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, - memoFirstUnreadIdRef: { current: number | undefined }, - onIntersectPinnedMessage: OnIntersectPinnedMessage | undefined, - chatId: string, - isQuickPreview?: boolean, -) { +export default function useMessageObservers({ + type, + containerRef, + memoFirstUnreadIdRef, + chatId, + threadId, + isQuickPreview, + onIntersectPinnedMessage, +}: { + containerRef: ElementRef; + 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([]); + 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(); + 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, }; } diff --git a/src/components/middle/hooks/usePinnedMessage.ts b/src/components/middle/hooks/usePinnedMessage.ts index be7410aa7..69fa5a01d 100644 --- a/src/components/middle/hooks/usePinnedMessage.ts +++ b/src/components/middle/hooks/usePinnedMessage.ts @@ -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) diff --git a/src/components/middle/message/ActionMessage.tsx b/src/components/middle/message/ActionMessage.tsx index 69e79052f..0a7a396b6 100644 --- a/src/components/middle/message/ActionMessage.tsx +++ b/src/components/middle/message/ActionMessage.tsx @@ -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; - 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; diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 51760aa8d..a59be28cd 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -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; diff --git a/src/components/ui/Button.scss b/src/components/ui/Button.scss index 930b94a6c..f96a7adcd 100644 --- a/src/components/ui/Button.scss +++ b/src/components/ui/Button.scss @@ -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;