diff --git a/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx b/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx index 2434690798..0b4e4dd0d5 100644 --- a/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx +++ b/ts/components/conversation/pinned-messages/PinnedMessagesBar.dom.tsx @@ -185,7 +185,7 @@ function TabsList(props: { return ( - {props.pins.toReversed().map((pin, pinIndex) => { + {props.pins.map((pin, pinIndex) => { return ( , + pinnedMessageId: PinnedMessageId +): PinnedMessageId | null { + let prev: Pin | null = null; + for (const pin of pins) { + if (pin.id === pinnedMessageId) { + break; + } + prev = pin; + } + return prev?.id ?? null; +} + +function getNextPinId( + pins: ReadonlyArray, + pinnedMessageId: PinnedMessageId +): PinnedMessageId | null { + let found = false; + for (const pin of pins) { + if (found) { + return pin.id; + } + if (pin.id === pinnedMessageId) { + found = true; + } + } + return null; +} + const selectPins: StateSelector> = createSelector( getPinnedMessages, getMessagePropsSelector, (pinnedMessages, messagePropsSelector) => { - return pinnedMessages.map((pinnedMessageRenderData): Pin => { + const sorted = orderBy( + pinnedMessages, + ['message.received_at', 'message.sent_at'], + ['ASC', 'ASC'] + ); + + return sorted.map((pinnedMessageRenderData): Pin => { const { pinnedMessage, message } = pinnedMessageRenderData; + const messageProps = messagePropsSelector(message); return { @@ -130,6 +175,168 @@ const selectPins: StateSelector> = createSelector( } ); +function isHTMLElement(node: Node): node is HTMLElement { + return node instanceof HTMLElement; +} + +function getNodeDataMessageId(node: Node): string | null { + if (isHTMLElement(node)) { + return node.dataset.messageId ?? null; + } + return null; +} + +function useTimelineIntersectionObserver( + pins: ReadonlyArray, + onCurrentChange: (current: PinnedMessageId) => void +) { + const onCurrentChangeRef = useRef(onCurrentChange); + useEffect(() => { + onCurrentChangeRef.current = onCurrentChange; + }, [onCurrentChange]); + + useEffect(() => { + // We only need to track anything if there are multiple pins + if (pins.length <= 1) { + return; + } + + const scroller = document.querySelector( + '.module-timeline__messages__container' + ); + strictAssert(scroller != null, 'Missing timeline scroller element'); + const messagesList = document.querySelector('.module-timeline__messages'); + strictAssert( + messagesList != null, + 'Missing timeline messages list element' + ); + + const pinnedMessageIdsByMessageIds = new Map(); + for (const pin of pins) { + pinnedMessageIdsByMessageIds.set(pin.message.id, pin.id); + } + + const pinnedMessageIdVisibility = new Map(); + + const intersectionObserver = new IntersectionObserver( + entries => { + const changesByPinnedMessageId = new Map< + PinnedMessageId, + IntersectionObserverEntry + >(); + + const sortedEntries = entries.toSorted((a, b) => { + return b.boundingClientRect.bottom - a.boundingClientRect.bottom; + }); + + for (const entry of sortedEntries) { + const messageId = getNodeDataMessageId(entry.target); + strictAssert(messageId != null, 'Missing node messageId'); + const pinnedMessageId = pinnedMessageIdsByMessageIds.get(messageId); + strictAssert(pinnedMessageId != null, 'Message is not pinned'); + + const prevVisible = pinnedMessageIdVisibility.get(pinnedMessageId); + const isVisible = entry.isIntersecting; + + if (prevVisible != null && prevVisible !== isVisible) { + changesByPinnedMessageId.set(pinnedMessageId, entry); + } + + pinnedMessageIdVisibility.set(pinnedMessageId, isVisible); + } + + let currentPinId: PinnedMessageId | null = null; + + for (const [pinnedMessageId, entry] of changesByPinnedMessageId) { + strictAssert(entry.rootBounds != null, 'Missing rootBounds'); + const { top, bottom } = entry.boundingClientRect; + + if (top > entry.rootBounds.bottom) { + // entry is below scroll area, show prev pin + currentPinId = getPrevPinId(pins, pinnedMessageId); + break; // don't check lower pins + } + + if (bottom < entry.rootBounds.top) { + // entry is above scroll area, show next pin if visible + const nextPinId = getNextPinId(pins, pinnedMessageId); + if (nextPinId != null && pinnedMessageIdVisibility.get(nextPinId)) { + currentPinId = nextPinId; + } + continue; + } + + // entry is intersecting with scroll area, show it + currentPinId = pinnedMessageId; + break; // don't show further pins + } + + if (currentPinId != null) { + onCurrentChangeRef.current(currentPinId); + } + }, + { root: scroller } + ); + + function added(node: Node, messageId: string | null) { + if (messageId == null || !isHTMLElement(node)) { + return; + } + const pinnedMessageId = pinnedMessageIdsByMessageIds.get(messageId); + if (pinnedMessageId == null) { + return; + } + + intersectionObserver.observe(node); + } + + function removed(node: Node, messageId: string | null) { + if (messageId == null || !isHTMLElement(node)) { + return; + } + const pinnedMessageId = pinnedMessageIdsByMessageIds.get(messageId); + if (pinnedMessageId == null) { + return; + } + + pinnedMessageIdVisibility.delete(pinnedMessageId); + intersectionObserver.unobserve(node); + } + + const mutationObserver = new MutationObserver(mutations => { + for (const mutation of mutations) { + if (mutation.type === 'attributes') { + removed(mutation.target, mutation.oldValue ?? ''); + added(mutation.target, getNodeDataMessageId(mutation.target)); + } else if (mutation.type === 'childList') { + for (const removedNode of mutation.removedNodes) { + removed(removedNode, getNodeDataMessageId(removedNode)); + } + for (const addedNode of mutation.addedNodes) { + added(addedNode, getNodeDataMessageId(addedNode)); + } + } + } + }); + + mutationObserver.observe(messagesList, { + childList: true, + attributes: true, + attributeOldValue: true, + attributeFilter: ['data-message-id'], + }); + + for (const child of messagesList.children) { + added(child, getNodeDataMessageId(child)); + } + + return () => { + mutationObserver.disconnect(); + intersectionObserver.disconnect(); + }; + }, [pins]); +} + export const SmartPinnedMessagesBar = memo(function SmartPinnedMessagesBar() { const i18n = useSelector(getIntl); const conversationId = useSelector(getSelectedConversationId); @@ -150,7 +357,7 @@ export const SmartPinnedMessagesBar = memo(function SmartPinnedMessagesBar() { const { onPinnedMessageRemove } = usePinnedMessagesActions(); const [current, setCurrent] = useState(() => { - return pins.at(0)?.id ?? null; + return pins.at(-1)?.id ?? null; }); const isCurrentOutOfDate = useMemo(() => { @@ -169,7 +376,7 @@ export const SmartPinnedMessagesBar = memo(function SmartPinnedMessagesBar() { }, [current, pins]); if (isCurrentOutOfDate) { - setCurrent(pins.at(0)?.id ?? null); + setCurrent(pins.at(-1)?.id ?? null); } const handleCurrentChange = useCallback( @@ -182,8 +389,15 @@ export const SmartPinnedMessagesBar = memo(function SmartPinnedMessagesBar() { const handlePinGoTo = useCallback( (messageId: string) => { scrollToMessage(conversationId, messageId); + if (current == null) { + return; + } + const prevPinId = getPrevPinId(pins, current); + if (prevPinId != null) { + setCurrent(prevPinId); + } }, - [scrollToMessage, conversationId] + [scrollToMessage, conversationId, pins, current] ); const handlePinRemove = useCallback( @@ -199,6 +413,10 @@ export const SmartPinnedMessagesBar = memo(function SmartPinnedMessagesBar() { }); }, [pushPanelForConversation]); + useTimelineIntersectionObserver(pins, nextCurrent => { + setCurrent(nextCurrent); + }); + if (current == null) { return; } diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 3e457b6591..efe54e67f5 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -2322,6 +2322,13 @@ "reasonCategory": "usageTrusted", "updated": "2023-08-20T22:14:52.008Z" }, + { + "rule": "React-useRef", + "path": "ts/state/smart/PinnedMessagesBar.preload.tsx", + "line": " const onCurrentChangeRef = useRef(onCurrentChange);", + "reasonCategory": "usageTrusted", + "updated": "2025-12-15T18:22:14.286Z" + }, { "rule": "DOM-innerHTML", "path": "ts/windows/loading/start.dom.ts",