diff --git a/desktop/src/features/messages/ui/anchoredScrollPolicy.ts b/desktop/src/features/messages/ui/anchoredScrollPolicy.ts index 5dfec80b836..95b7bdd4a6a 100644 --- a/desktop/src/features/messages/ui/anchoredScrollPolicy.ts +++ b/desktop/src/features/messages/ui/anchoredScrollPolicy.ts @@ -45,11 +45,13 @@ export function settleProgrammaticBottomPin( export function shouldSettleForSplitPanel({ isAtBottom, splitPanelOpen, + targetHeld, }: { isAtBottom: boolean; splitPanelOpen: boolean; + targetHeld: boolean; }): boolean { - return isAtBottom && splitPanelOpen; + return isAtBottom && splitPanelOpen && !targetHeld; } export function shouldSettleVirtualizedBottom({ diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs index 044efae607a..dd0f4160d7a 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs +++ b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs @@ -93,6 +93,40 @@ function installDOMShim() { this === node || this.children.some((child) => child.contains(node)) ); } + + setAttribute(name, value) { + this.attrs ??= new Map(); + this.attrs.set(name, String(value)); + } + + getAttribute(name) { + return this.attrs?.get(name) ?? null; + } + + // Supports `tag`, `[attr]` and `[attr='value']` compounds in a comma list, + // and throws on anything else rather than silently not matching. + matches(selectors) { + const attribute = /\[([\w-]+)(?:='([^']*)')?\]/g; + return selectors.split(",").some((selector) => { + const [, tag, attributes] = /^\s*([\w-]*)(.*?)\s*$/.exec(selector); + if (attributes.replace(attribute, "") !== "") { + throw new Error(`unsupported selector: ${selector}`); + } + if (tag && tag !== this.tagName) return false; + return [...attributes.matchAll(attribute)].every(([, name, value]) => + value === undefined + ? this.getAttribute(name) !== null + : this.getAttribute(name) === value, + ); + }); + } + + closest(selectors) { + for (let node = this; node instanceof NodeShim; node = node.parentNode) { + if (node.matches(selectors)) return node; + } + return null; + } } class DocumentShim extends EventTargetShim { @@ -694,3 +728,360 @@ test("target arriving before the second mount frame supersedes the bottom pin", globalThis.cancelAnimationFrame = previousCancelRaf; } }); + +function TargetHoldHarness({ fake, onState, ...props }) { + onState( + useAnchoredScroll({ + channelId: "conversation", + contentRef: fake.content, + isLoading: false, + messages: fake.messages, + scrollContainerRef: fake.scroller, + virtualizerOwnsPrependAnchoring: true, + virtualScrollToBottom: fake.scrollToBottom, + virtualScrollToMessage: fake.scrollToMessage, + virtualSettleAtBottom: fake.settleAtBottom, + ...props, + }), + ); + return null; +} + +// Accepts a virtual route target, then replays the layout that carried it +// away in CI: the route clears the target, the thread panel opens on it, and a +// later layout pass reports the virtualizer at its floor. Each reflow +// re-centres the held target rather than settling at the bottom. +async function renderHeldTarget() { + const resizeObservers = []; + globalThis.ResizeObserver = class { + constructor(callback) { + this.callback = callback; + this.targets = []; + resizeObservers.push(this); + } + disconnect() {} + observe(target) { + this.targets.push(target); + } + }; + const scroller = document.createElement("div"); + scroller.querySelector = () => null; + const settles = []; + const targetScrolls = []; + const fake = { + content: { current: null }, + // MessageTimeline rows carry their timestamp (Unix seconds). + messages: [ + { createdAt: 100, id: "selected" }, + { createdAt: 500, id: "latest" }, + ], + scrollToBottom: () => settles.push("jump"), + scrollToMessage: (messageId) => { + targetScrolls.push(messageId); + return true; + }, + scroller: { current: scroller }, + settleAtBottom: () => settles.push("settle"), + }; + let state = null; + let props = { splitPanelOpen: false, targetMessageId: "selected" }; + const root = createRoot(document.createElement("div")); + const render = async (nextProps) => { + props = { ...props, ...nextProps }; + await act(async () => + root.render( + React.createElement(TargetHoldHarness, { + fake, + onState: (next) => { + state = next; + }, + ...props, + }), + ), + ); + }; + const resizeViewport = async () => { + await act(async () => { + for (const observer of resizeObservers) { + if (observer.targets.includes(scroller)) observer.callback(); + } + }); + }; + + await render({}); + assert.equal(state.isAtBottom, false, "the target is accepted"); + assert.deepEqual(targetScrolls, ["selected"]); + await render({ splitPanelOpen: true, targetMessageId: null }); + assert.equal(targetScrolls.length, 2, "the panel opening re-centres it"); + await act(async () => state.onVirtualizerAtBottomStateChange(true)); + await resizeViewport(); + assert.deepEqual(settles, [], "layout cannot settle a held target"); + assert.deepEqual( + targetScrolls, + ["selected", "selected", "selected"], + "viewport reflow re-centres the held target", + ); + return { + fake, + render, + resizeViewport, + root, + scroller, + settles, + state: () => state, + targetScrolls, + }; +} + +test("an accepted virtual target holds through the thread panel opening and floor reports", async () => { + const { + fake, + render, + resizeViewport, + root, + scroller, + settles, + state, + targetScrolls, + } = await renderHeldTarget(); + + // The floor report still drives the affordances. + assert.equal(state().isAtBottom, true); + fake.messages = [...fake.messages, { createdAt: 600, id: "live" }]; + await render({}); + assert.deepEqual(settles, [], "a live append cannot settle a held target"); + // The append lands below the held row without a scroll event, so the hook + // itself must bring back the Jump to latest / new-message pill. + assert.equal(state().isAtBottom, false, "the pill shows again"); + assert.equal(state().newMessageCount, 1); + // The reader is no longer at the floor, so taking the viewport back must + // not snap past the new row on the next reflow. + await act(async () => + scroller.dispatchEvent({ target: scroller, type: "pointerdown" }), + ); + await resizeViewport(); + assert.deepEqual(settles, [], "release after the append stays put"); + assert.equal(targetScrolls.length, 3, "and is not re-centred"); + await act(async () => root.unmount()); +}); + +// Mounts `tagName` inside a row in the scroller, as a focus or event target. +function mountInRow(scroller, tagName, attributes = {}) { + const row = document.createElement("div"); + const element = document.createElement(tagName); + for (const [name, value] of Object.entries(attributes)) { + element.setAttribute(name, value); + } + row.appendChild(element); + scroller.appendChild(row); + return element; +} + +function pressKey(key, target) { + globalThis.dispatchEvent({ + altKey: false, + ctrlKey: false, + key, + metaKey: false, + target, + type: "keydown", + }); +} + +const RELEASES = [ + ["wheel", ({ scroller }) => scroller.dispatchEvent({ type: "wheel" })], + [ + "touch drag", + ({ scroller }) => scroller.dispatchEvent({ type: "touchmove" }), + ], + [ + "scrollbar pointerdown", + ({ scroller }) => + scroller.dispatchEvent({ target: scroller, type: "pointerdown" }), + ], + ["PageUp on the scroller", ({ scroller }) => pressKey("PageUp", scroller)], + ["End on the scroller", ({ scroller }) => pressKey("End", scroller)], + [ + "Space on row text", + ({ scroller }) => pressKey(" ", mountInRow(scroller, "p")), + ], + // The browser scrolls the scroller for these, not the focused control. + [ + "PageDown on a focused row action", + ({ scroller }) => pressKey("PageDown", mountInRow(scroller, "button")), + ], + [ + "Space on a focused link", + ({ scroller }) => pressKey(" ", mountInRow(scroller, "a", { href: "#" })), + ], + ["Jump to latest", ({ state }) => state().scrollToBottom("auto")], + [ + "own send", + async ({ fake, render, state }) => { + state().scrollToBottomOnNextUpdate(); + fake.messages = [...fake.messages, { createdAt: 600, id: "sent" }]; + await render({}); + }, + ], + [ + "channel change", + ({ render }) => render({ channelId: "other", isLoading: true }), + ], + // Find-in-channel and the oldest-unread pill jump through the public + // scrollToMessage. Neither holds, so the next floor report follows again. + [ + "find jump", + ({ state }) => { + state().scrollToMessage("selected", { + behavior: "smooth", + highlight: true, + }); + state().onVirtualizerAtBottomStateChange(true); + }, + ], +]; + +for (const [name, release] of RELEASES) { + test(`${name} releases a held virtual target`, async () => { + const held = await renderHeldTarget(); + await act(async () => release(held)); + const before = held.settles.filter((kind) => kind === "settle").length; + const targetScrollsBefore = held.targetScrolls.length; + await held.resizeViewport(); + + assert.equal( + held.settles.filter((kind) => kind === "settle").length, + before + 1, + "the floor follows viewport reflow again", + ); + assert.equal( + held.targetScrolls.length, + targetScrollsBefore, + "a released target is not re-centred", + ); + await act(async () => held.root.unmount()); + }); +} + +// Row interaction, not reader scroll. Space activates these controls. +const KEEPS = [ + [ + "a descendant pointerdown", + ({ scroller }) => + scroller.dispatchEvent({ + target: mountInRow(scroller, "div"), + type: "pointerdown", + }), + ], + [ + "Space on a focused row action", + ({ scroller }) => pressKey(" ", mountInRow(scroller, "button")), + ], + [ + "Space on a focused menu item", + ({ scroller }) => + pressKey(" ", mountInRow(scroller, "div", { role: "menuitem" })), + ], +]; + +for (const [name, interact] of KEEPS) { + test(`${name} keeps a virtual target held`, async () => { + const held = await renderHeldTarget(); + await act(async () => interact(held)); + await held.resizeViewport(); + assert.deepEqual(held.settles, []); + assert.equal(held.targetScrolls.length, 4, "the target is re-centred"); + + held.fake.messages = [ + ...held.fake.messages, + { createdAt: 600, id: "live" }, + ]; + await held.render({}); + assert.deepEqual(held.settles, [], "a live append cannot settle it"); + assert.equal(held.state().newMessageCount, 1); + await act(async () => held.root.unmount()); + }); +} + +test("a same-second live row under a held target raises the pill", async () => { + const held = await renderHeldTarget(); + // The floor report left isAtBottom true. A row stamped in the newest row's + // second with a larger id sorts ahead of it, so the last id is unchanged. + held.fake.messages = [ + { createdAt: 100, id: "selected" }, + { createdAt: 500, id: "same-second" }, + { createdAt: 500, id: "latest" }, + ]; + await held.render({}); + assert.equal(held.state().isAtBottom, false, "the pill shows"); + assert.equal(held.state().newMessageCount, 1); + + // The previous newest row is deleted in the commit a live row lands in. + held.fake.messages = [ + { createdAt: 100, id: "selected" }, + { createdAt: 500, id: "same-second" }, + { createdAt: 600, id: "live" }, + ]; + await held.render({}); + assert.equal(held.state().newMessageCount, 2); + + // A send that doesn't arm the bottom follow (a thread reply also sent to + // the channel) counts once. Its relay ack changes the id, not the row key. + const optimistic = { createdAt: 601, id: "local", renderKey: "local" }; + held.fake.messages = [...held.fake.messages, optimistic]; + await held.render({}); + held.fake.messages = [ + ...held.fake.messages.slice(0, -1), + { ...optimistic, id: "acked" }, + ]; + await held.render({}); + assert.equal(held.state().newMessageCount, 3); + assert.deepEqual(held.settles, []); + await act(async () => held.root.unmount()); +}); + +test("history filling the gap under a held target is not counted as new", async () => { + const held = await renderHeldTarget(); + // A cold target sits older than the head window. The page that fills the + // gap between them keeps both ends of the list, so nothing arrived below. + held.fake.messages = [ + { createdAt: 100, id: "selected" }, + { createdAt: 200, id: "gap-2" }, + { createdAt: 300, id: "gap-3" }, + { createdAt: 500, id: "latest" }, + ]; + await held.render({}); + assert.equal(held.state().isAtBottom, true, "the floor report stands"); + assert.equal(held.state().newMessageCount, 0); + + // Deleting the newest row changes the last id, but nothing is new. + held.fake.messages = held.fake.messages.slice(0, -1); + await held.render({}); + assert.equal(held.state().isAtBottom, true, "a deletion raises no pill"); + assert.equal(held.state().newMessageCount, 0); + + // More history and a live row in one commit: only the live row is new. + held.fake.messages = [ + { createdAt: 100, id: "selected" }, + { createdAt: 150, id: "gap-1" }, + { createdAt: 200, id: "gap-2" }, + { createdAt: 300, id: "gap-3" }, + { createdAt: 600, id: "live" }, + ]; + await held.render({}); + assert.equal(held.state().isAtBottom, false); + assert.equal(held.state().newMessageCount, 1); + assert.deepEqual(held.settles, []); + await act(async () => held.root.unmount()); +}); + +test("reader input at the floor resumes following live messages", async () => { + const held = await renderHeldTarget(); + // A wheel at the floor moves nothing, so no new floor report follows it. + await act(async () => held.scroller.dispatchEvent({ type: "wheel" })); + held.fake.messages = [...held.fake.messages, { createdAt: 600, id: "live" }]; + await held.render({}); + + assert.deepEqual(held.settles, ["settle"]); + await act(async () => held.root.unmount()); +}); diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.test.mjs b/desktop/src/features/messages/ui/useAnchoredScroll.test.mjs index 28273f08bb8..aa67eca44dd 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.test.mjs +++ b/desktop/src/features/messages/ui/useAnchoredScroll.test.mjs @@ -24,18 +24,19 @@ function fakeContainer({ clientHeight, scrollHeight, scrollTop }) { } test("split panel settles only an already-bottomed timeline", () => { - assert.equal( - shouldSettleForSplitPanel({ isAtBottom: true, splitPanelOpen: true }), - true, - ); - assert.equal( - shouldSettleForSplitPanel({ isAtBottom: false, splitPanelOpen: true }), - false, - ); - assert.equal( - shouldSettleForSplitPanel({ isAtBottom: true, splitPanelOpen: false }), - false, - ); + for (const [isAtBottom, splitPanelOpen, targetHeld, expected] of [ + [true, true, false, true], + [false, true, false, false], + [true, false, false, false], + // A held navigation target keeps the viewport even at the floor. + [true, true, true, false], + ]) { + assert.equal( + shouldSettleForSplitPanel({ isAtBottom, splitPanelOpen, targetHeld }), + expected, + JSON.stringify({ isAtBottom, splitPanelOpen, targetHeld }), + ); + } }); test("virtualized bottom settle arms for pinned appends and replacements", () => { diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 41e0b407863..63e67ce763a 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -8,6 +8,7 @@ import { shouldSettleForSplitPanel, shouldSettleVirtualizedBottom, } from "./anchoredScrollPolicy"; +import { listenForReaderScroll } from "./useVirtualizedBottomSettle"; import { useVirtualizedViewportResize } from "./useVirtualizedViewportResize"; /** @@ -23,6 +24,14 @@ type AnchorState = | { kind: "message"; messageId: string; topOffset: number } | { kind: "pinned-center"; messageId: string; contentTop: number }; +type AnchoredMessage = { + id: string; + /** Unix seconds. Lets a held route target count live arrivals. */ + createdAt?: number; + /** Identity that survives an optimistic send's relay ack. */ + renderKey?: string; +}; + type UseAnchoredScrollOptions = { /** Scroll container. Owned by the parent so external refs still compose. */ scrollContainerRef: React.RefObject; @@ -35,7 +44,7 @@ type UseAnchoredScrollOptions = { isLoading: boolean; /** Source of truth for the rendered list. Used to detect new-at-bottom * arrivals and to seed/refresh the anchor pre-render. */ - messages: Array<{ id: string }>; + messages: AnchoredMessage[]; splitPanelOpen?: boolean; /** When set, scroll to this message on mount and on change. */ @@ -153,6 +162,31 @@ function computeAnchor( return { kind: "at-bottom" }; } +/** + * Counts the rows in `current` that arrived live since `previous`: new rows + * stamped in the previous newest row's second or later. A same-second arrival + * can sort ahead of that row and leave the last id unchanged, so position + * can't tell. History only adds older rows, including a page that fills the + * gap between a cold target and the head window. Rows without `createdAt` + * never count, and a live row stamped earlier (sender clock skew) doesn't + * either. + */ +function countLiveArrivals( + previous: readonly AnchoredMessage[], + current: readonly AnchoredMessage[], +) { + const newest = previous[previous.length - 1]?.createdAt; + if (newest === undefined) return 0; + const rowKey = (message: AnchoredMessage) => message.renderKey ?? message.id; + const previousKeys = new Set(previous.map(rowKey)); + return current.filter( + (message) => + message.createdAt !== undefined && + message.createdAt >= newest && + !previousKeys.has(rowKey(message)), + ).length; +} + export function useAnchoredScroll({ scrollContainerRef, contentRef, @@ -178,9 +212,21 @@ export function useAnchoredScroll({ // restoration). useState would force re-renders we don't want. const anchorRef = React.useRef({ kind: "at-bottom" }); const virtualizerAtBottomRef = React.useRef(true); + // An accepted virtual route target (targetMessageId) owns the viewport until + // the reader scrolls, sends, jumps to latest, or changes channel. Layout + // passes in between (history prepends, row measurement, the thread panel + // opening) can report the floor; none of them may turn that report into a + // bottom settle. The unread pill and find jumps don't hold. + const targetHoldRef = React.useRef(false); const [isAtBottom, setIsAtBottom] = React.useState(true); React.useLayoutEffect(() => { - if (shouldSettleForSplitPanel({ isAtBottom, splitPanelOpen })) { + if ( + shouldSettleForSplitPanel({ + isAtBottom, + splitPanelOpen, + targetHeld: targetHoldRef.current, + }) + ) { virtualSettleAtBottom?.(); } }, [isAtBottom, splitPanelOpen, virtualSettleAtBottom]); @@ -193,7 +239,7 @@ export function useAnchoredScroll({ const prevLastMessageIdRef = React.useRef(undefined); const prevFirstMessageIdRef = React.useRef(undefined); const prevMessageCountRef = React.useRef(0); - const prevMessagesRef = React.useRef>([]); + const prevMessagesRef = React.useRef([]); const handledTargetIdRef = React.useRef(null); const highlightTimeoutRef = React.useRef(null); // Tracks a pending rAF queued by pinToBottomOnMount so it can be cancelled @@ -229,6 +275,7 @@ export function useAnchoredScroll({ React.useLayoutEffect(() => { anchorRef.current = { kind: "at-bottom" }; virtualizerAtBottomRef.current = true; + targetHoldRef.current = false; setIsAtBottom(true); setNewMessageCount(0); setHighlightedMessageId(null); @@ -368,6 +415,7 @@ export function useAnchoredScroll({ const container = scrollContainerRef.current; if (!container) return; anchorRef.current = { kind: "at-bottom" }; + targetHoldRef.current = false; // A programmatic jump-to-bottom is not atomic, even for `behavior: "auto"`: // the browser can emit `scroll` while the list is still settling row // measurements. During that window `computeAnchor` may read the transient @@ -443,7 +491,12 @@ export function useAnchoredScroll({ const scrollToMessageImperative = React.useCallback( ( messageId: string, - options: { highlight?: boolean; behavior?: ScrollBehavior } = {}, + options: { + highlight?: boolean; + behavior?: ScrollBehavior; + /** Route targets only: hold the viewport against bottom settles. */ + holdTarget?: boolean; + } = {}, ): boolean => { const container = scrollContainerRef.current; if (!container) return false; @@ -468,9 +521,12 @@ export function useAnchoredScroll({ // A target that is not loaded yet leaves the bottom pin alone. return false; } - // An accepted target supersedes every pending bottom pin. + // An accepted target supersedes every pending bottom pin. A route + // target also holds the viewport until the reader takes it back; any + // other jump replaces an earlier hold. virtualCancelBottomIntent?.(); cancelMountBottomPin(); + targetHoldRef.current = options.holdTarget === true; anchorRef.current = { kind: "message", messageId, topOffset: 0 }; virtualizerAtBottomRef.current = false; setIsAtBottom(false); @@ -640,6 +696,7 @@ export function useAnchoredScroll({ if ( scrollToMessageImperative(targetMessageId, { highlight: highlightTargetMessage, + holdTarget: true, }) ) { handledTargetIdRef.current = targetMessageId; @@ -684,6 +741,7 @@ export function useAnchoredScroll({ if (newLatestArrived && forceBottomOnNextAppendRef.current) { forceBottomOnNextAppendRef.current = false; anchorRef.current = { kind: "at-bottom" }; + targetHoldRef.current = false; settlingRef.current = true; if (virtualizerOwnsPrependAnchoring && virtualScrollToBottom) { virtualScrollToBottom("auto"); @@ -757,6 +815,18 @@ export function useAnchoredScroll({ if (!isPrepend) { setNewMessageCount((current) => current + messagesArrived); } + } else if (targetHoldRef.current && messages !== prevMessages) { + // A held target keeps its message anchor through floor reports, so + // isAtBottom can still read true when a live row lands below it. Virtua + // reports bottom state only from scroll events, and an append emits + // none, so raise the new-message pill here. A same-second arrival can + // land without changing the last id, so count arrivals by timestamp. + const rowsBelow = countLiveArrivals(prevMessages, messages); + if (rowsBelow > 0) { + virtualizerAtBottomRef.current = false; + setIsAtBottom(false); + setNewMessageCount((current) => current + rowsBelow); + } } prevLastMessageIdRef.current = lastMessage?.id; @@ -819,12 +889,44 @@ export function useAnchoredScroll({ virtualizerOwnsPrependAnchoring, ]); + // Viewport reflow (the thread panel opening or closing, a window resize) + // rewraps rows around a held target without a scroll event, so neither the + // target's place nor the bottom state would follow. Re-centre the target: + // the scroll that follows refreshes the bottom state too. + const reanchorHeldTarget = React.useCallback(() => { + const anchor = anchorRef.current; + if (!targetHoldRef.current || anchor.kind !== "message") return; + virtualScrollToMessage?.(anchor.messageId); + }, [virtualScrollToMessage]); + const splitPanelOpenRef = React.useRef(splitPanelOpen); + React.useLayoutEffect(() => { + if (splitPanelOpenRef.current === splitPanelOpen) return; + splitPanelOpenRef.current = splitPanelOpen; + reanchorHeldTarget(); + }, [reanchorHeldTarget, splitPanelOpen]); useVirtualizedViewportResize( scrollContainerRef, virtualizerAtBottomRef, + targetHoldRef, virtualizerOwnsPrependAnchoring ? virtualSettleAtBottom : undefined, + reanchorHeldTarget, ); + // Reader scroll input releases a held target. The floor reports that + // arrived while it was held then take effect, so a reader already at the + // bottom follows new messages again. + React.useLayoutEffect(() => { + const container = scrollContainerRef.current; + if (!virtualizerOwnsPrependAnchoring || !container) return; + return listenForReaderScroll(container, () => { + if (!targetHoldRef.current) return; + targetHoldRef.current = false; + if (virtualizerAtBottomRef.current) { + anchorRef.current = { kind: "at-bottom" }; + } + }); + }, [scrollContainerRef, virtualizerOwnsPrependAnchoring]); + // Pinned centers survive our own corrections but release as soon as the // reader deliberately takes control of the scroll position or the caller // retires the temporary target after layout settlement. @@ -898,6 +1000,7 @@ export function useAnchoredScroll({ if ( !scrollToMessageImperative(targetMessageId, { highlight: highlightTargetMessage, + holdTarget: true, }) ) { // Not loaded yet. A cold deep-link target is fetched by id and spliced @@ -937,7 +1040,10 @@ export function useAnchoredScroll({ if (!virtualizerOwnsPrependAnchoring) return; virtualizerAtBottomRef.current = atBottom; if (atBottom) { - anchorRef.current = { kind: "at-bottom" }; + // A held target keeps its anchor. The report still updates the + // affordances, but must not become bottom-follow ownership. A live + // append under the hold raises them again (restoration effect). + if (!targetHoldRef.current) anchorRef.current = { kind: "at-bottom" }; setNewMessageCount(0); } setIsAtBottom(atBottom); diff --git a/desktop/src/features/messages/ui/useVirtualizedBottomSettle.ts b/desktop/src/features/messages/ui/useVirtualizedBottomSettle.ts index 8c0535779f1..926e47704fb 100644 --- a/desktop/src/features/messages/ui/useVirtualizedBottomSettle.ts +++ b/desktop/src/features/messages/ui/useVirtualizedBottomSettle.ts @@ -11,12 +11,80 @@ const SCROLL_INTENT_KEYS = new Set([ " ", ]); -function isEditableKeyboardTarget(target: EventTarget | null) { +// Space activates these controls instead of scrolling. A focused link is not +// one: links activate on Enter, and Space scrolls past them. +const SPACE_ACTIVATED_CONTROLS = [ + "button", + "summary", + "[role='button']", + "[role='checkbox']", + "[role='menuitem']", + "[role='menuitemcheckbox']", + "[role='menuitemradio']", + "[role='option']", + "[role='radio']", + "[role='switch']", + "[role='tab']", +].join(", "); + +/** True when the focused control consumes `key` rather than scrolling. */ +function isControlKeyboardTarget(target: EventTarget | null, key: string) { if (!(target instanceof HTMLElement)) return false; if (target.isContentEditable) return true; - return ( + if ( target.closest("input, textarea, select, [contenteditable='true']") !== null - ); + ) { + return true; + } + return key === " " && target.closest(SPACE_ACTIVATED_CONTROLS) !== null; +} + +/** + * Calls `onReaderScroll` when the reader takes scroll ownership of + * `scroller`: a wheel, a touch drag, a pointerdown on the scroller itself + * (scrollbar or background), or a scroll key aimed at it or at row content. + * Space on a row control (Reply, a reaction) activates it and doesn't count. + * Returns the unsubscribe function. + */ +export function listenForReaderScroll( + scroller: HTMLElement, + onReaderScroll: () => void, +): () => void { + const onTouchMove = () => onReaderScroll(); + const onPointerDown = (event: PointerEvent) => { + // A descendant pointerdown is ordinary row interaction (link, reaction, + // thread action), not evidence that the reader took scroll ownership. + // Direct scroller hits cover scrollbar/background drag initiation. + if (event.target === scroller) onReaderScroll(); + }; + const onWheel = (event: WheelEvent) => { + // Ctrl+wheel is browser zoom, not reader navigation. The resulting + // viewport/content reflow must still settle at the new physical floor. + if (!event.ctrlKey) onReaderScroll(); + }; + const onKeyDown = (event: KeyboardEvent) => { + if ( + !event.altKey && + !event.ctrlKey && + !event.metaKey && + event.target instanceof Node && + scroller.contains(event.target) && + SCROLL_INTENT_KEYS.has(event.key) && + !isControlKeyboardTarget(event.target, event.key) + ) { + onReaderScroll(); + } + }; + scroller.addEventListener("pointerdown", onPointerDown, { passive: true }); + scroller.addEventListener("touchmove", onTouchMove, { passive: true }); + scroller.addEventListener("wheel", onWheel, { passive: true }); + window.addEventListener("keydown", onKeyDown, true); + return () => { + scroller.removeEventListener("pointerdown", onPointerDown); + scroller.removeEventListener("touchmove", onTouchMove); + scroller.removeEventListener("wheel", onWheel); + window.removeEventListener("keydown", onKeyDown, true); + }; } export function useVirtualizedBottomSettle( @@ -58,44 +126,7 @@ export function useVirtualizedBottomSettle( React.useLayoutEffect(() => { const scroller = hostRef.current?.firstElementChild; if (!(scroller instanceof HTMLDivElement)) return; - const retire = () => cancel(); - const retireForPointer = (event: PointerEvent) => { - // A descendant pointerdown is ordinary row interaction (link, reaction, - // thread action), not evidence that the reader took scroll ownership. - // Direct scroller hits cover scrollbar/background drag initiation. - if (event.target === scroller) cancel(); - }; - const retireForWheel = (event: WheelEvent) => { - // Ctrl+wheel is browser zoom, not reader navigation. Keep bottom intent - // armed so the resulting viewport/content reflow can settle at the new - // physical floor. - if (!event.ctrlKey) cancel(); - }; - const retireForScrollKey = (event: KeyboardEvent) => { - if ( - !event.altKey && - !event.ctrlKey && - !event.metaKey && - event.target instanceof Node && - scroller.contains(event.target) && - !isEditableKeyboardTarget(event.target) && - SCROLL_INTENT_KEYS.has(event.key) - ) { - cancel(); - } - }; - scroller.addEventListener("pointerdown", retireForPointer, { - passive: true, - }); - scroller.addEventListener("touchmove", retire, { passive: true }); - scroller.addEventListener("wheel", retireForWheel, { passive: true }); - window.addEventListener("keydown", retireForScrollKey, true); - return () => { - scroller.removeEventListener("pointerdown", retireForPointer); - scroller.removeEventListener("touchmove", retire); - scroller.removeEventListener("wheel", retireForWheel); - window.removeEventListener("keydown", retireForScrollKey, true); - }; + return listenForReaderScroll(scroller, cancel); }, [cancel, hostRef]); React.useLayoutEffect(() => { diff --git a/desktop/src/features/messages/ui/useVirtualizedViewportResize.test.mjs b/desktop/src/features/messages/ui/useVirtualizedViewportResize.test.mjs index e284460c172..cabc5dccb55 100644 --- a/desktop/src/features/messages/ui/useVirtualizedViewportResize.test.mjs +++ b/desktop/src/features/messages/ui/useVirtualizedViewportResize.test.mjs @@ -4,12 +4,19 @@ import test from "node:test"; import { shouldSettleVirtualizedViewportResize } from "./useVirtualizedViewportResize.ts"; test("viewport resize follows the virtualizer's explicit bottom state", () => { - assert.equal( - shouldSettleVirtualizedViewportResize({ virtualizerAtBottom: true }), - true, - ); - assert.equal( - shouldSettleVirtualizedViewportResize({ virtualizerAtBottom: false }), - false, - ); + for (const [virtualizerAtBottom, targetHeld, expected] of [ + [true, false, true], + [false, false, false], + // A held navigation target keeps the viewport even at the floor. + [true, true, false], + ]) { + assert.equal( + shouldSettleVirtualizedViewportResize({ + targetHeld, + virtualizerAtBottom, + }), + expected, + JSON.stringify({ targetHeld, virtualizerAtBottom }), + ); + } }); diff --git a/desktop/src/features/messages/ui/useVirtualizedViewportResize.ts b/desktop/src/features/messages/ui/useVirtualizedViewportResize.ts index d3273a09b62..e8d63c55343 100644 --- a/desktop/src/features/messages/ui/useVirtualizedViewportResize.ts +++ b/desktop/src/features/messages/ui/useVirtualizedViewportResize.ts @@ -1,18 +1,24 @@ import * as React from "react"; export function shouldSettleVirtualizedViewportResize({ + targetHeld, virtualizerAtBottom, }: { + targetHeld: boolean; virtualizerAtBottom: boolean; }): boolean { - return virtualizerAtBottom; + return virtualizerAtBottom && !targetHeld; } -/** Re-settles a bottom-pinned virtualized timeline after viewport reflow. */ +/** Re-settles a bottom-pinned virtualized timeline after viewport reflow. + * While an accepted navigation target holds the viewport, it re-anchors + * that target instead. */ export function useVirtualizedViewportResize( scrollContainerRef: React.RefObject, virtualizerAtBottomRef: React.RefObject, + targetHoldRef: React.RefObject, settleAtBottom?: () => void, + reanchorHeldTarget?: () => void, ) { React.useEffect(() => { const container = scrollContainerRef.current; @@ -27,13 +33,22 @@ export function useVirtualizedViewportResize( const observer = new ResizeObserver(() => { if ( shouldSettleVirtualizedViewportResize({ + targetHeld: targetHoldRef.current, virtualizerAtBottom: virtualizerAtBottomRef.current, }) ) { settleAtBottom(); + } else if (targetHoldRef.current) { + reanchorHeldTarget?.(); } }); observer.observe(container); return () => observer.disconnect(); - }, [scrollContainerRef, settleAtBottom, virtualizerAtBottomRef]); + }, [ + reanchorHeldTarget, + scrollContainerRef, + settleAtBottom, + targetHoldRef, + virtualizerAtBottomRef, + ]); }