From 5538199627dfc0cdbf7804e8306993f0f31daea3 Mon Sep 17 00:00:00 2001 From: "Trace (Engineer)" <9d485ff0c62915e08a801162c195c7ef096f6f6143925407255cea2656c4b8a4@buzz.block.builderlab.xyz> Date: Sat, 22 Aug 2026 15:27:55 -0700 Subject: [PATCH 01/15] Deep links to top-level messages highlight them in the main timeline Clicking an inbox row, desktop notification, search hit, or buzz:// link that targets a top-level message (channel or DM) used to force-open the reply panel for that root, showing an empty "No replies in this branch yet" pane instead of the message in its own context. The route-target logic is now an exported pure function, getRouteTargetPanelAction: - top-level target without an explicit threadRootId -> main-timeline scroll + highlight only, no thread panel - top-level target with an explicit threadRootId (inbox "Open full thread", channel-activity rows, thread-draft auto-send) -> panel opens at that root, unchanged - reply target -> panel opens at the thread head scrolled to the reply, unchanged targetThreadRootId is plumbed from the route search params through ChannelRouteScreen/ChannelScreen into the hook. Unit tests cover the new function; the two e2e tests from #1092 are rewritten to the new contract and the deep-link reload test now re-issues the link cold since a consumed messageId no longer pins a thread param. Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com> Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- desktop/src/app/routes/ChannelRouteScreen.tsx | 1 + .../features/channels/ui/ChannelScreen.tsx | 2 + .../channels/ui/ChannelScreen.types.ts | 7 ++ .../ui/useChannelRouteTarget.test.mjs | 111 ++++++++++++++++++ .../channels/ui/useChannelRouteTarget.ts | 110 +++++++++++++---- desktop/tests/e2e/navigation.spec.ts | 57 +++++---- 6 files changed, 242 insertions(+), 46 deletions(-) create mode 100644 desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index d4626d2c6fa..7951f419c3a 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -234,6 +234,7 @@ export function ChannelRouteScreen({ targetForumReplyId={targetReplyId} targetMessageEvents={targetMessageEvents} targetMessageId={targetMessageId} + targetThreadRootId={targetThreadRootId} /> ); } diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 6254afd8c71..8ba518ccc32 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -94,6 +94,7 @@ export function ChannelScreen({ targetForumReplyId, targetMessageEvents, targetMessageId, + targetThreadRootId, }: ChannelScreenProps) { const { goHome } = useAppNavigation(); const { activeCommunity } = useCommunities(); @@ -651,6 +652,7 @@ export function ChannelScreen({ setThreadReplyTargetId, setThreadScrollTargetId, targetMessageId, + targetThreadRootId, timelineMessages, }); useThreadTargetSync({ diff --git a/desktop/src/features/channels/ui/ChannelScreen.types.ts b/desktop/src/features/channels/ui/ChannelScreen.types.ts index 371af6faf5d..766f53eeb74 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.types.ts +++ b/desktop/src/features/channels/ui/ChannelScreen.types.ts @@ -22,4 +22,11 @@ export type ChannelScreenProps = { targetForumReplyId: string | null; targetMessageEvents: RelayEvent[]; targetMessageId: string | null; + /** + * Thread root requested by the navigation source (`?threadRootId`, or the + * `?thread` panel param on deep links). Deciding input for top-level route + * targets: present → open the thread panel at that root; absent → the + * target is shown in the main timeline only. + */ + targetThreadRootId: string | null; }; diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs b/desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs new file mode 100644 index 00000000000..6675f9bf5ee --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getRouteTargetPanelAction } from "./useChannelRouteTarget.ts"; + +function makeMessage(overrides = {}) { + return { + id: "target", + author: "alice", + body: "hello", + createdAt: 1, + depth: 0, + parentId: null, + rootId: null, + tags: [], + time: "now", + ...overrides, + }; +} + +function byId(...messages) { + return new Map(messages.map((message) => [message.id, message])); +} + +test("top-level target without threadRootId stays in the main timeline", () => { + const root = makeMessage(); + assert.deepEqual(getRouteTargetPanelAction(root, null, byId(root)), { + kind: "main-timeline-only", + }); +}); + +test("top-level target with an explicit threadRootId opens its thread panel", () => { + const root = makeMessage(); + assert.deepEqual(getRouteTargetPanelAction(root, root.id, byId(root)), { + kind: "open-thread", + expandedReplyIds: new Set(), + replyTargetId: root.id, + scrollTargetId: null, + threadHeadId: root.id, + }); +}); + +test("reply target opens the thread scrolled to the reply", () => { + const root = makeMessage({ id: "root" }); + const reply = makeMessage({ + id: "reply", + parentId: "root", + rootId: "root", + depth: 1, + }); + assert.deepEqual(getRouteTargetPanelAction(reply, null, byId(root, reply)), { + kind: "open-thread", + expandedReplyIds: new Set(), + replyTargetId: "root", + scrollTargetId: "reply", + threadHeadId: "root", + }); +}); + +test("nested reply target expands its intermediate ancestors", () => { + const root = makeMessage({ id: "root" }); + const mid = makeMessage({ + id: "mid", + parentId: "root", + rootId: "root", + depth: 1, + }); + const leaf = makeMessage({ + id: "leaf", + parentId: "mid", + rootId: "root", + depth: 2, + }); + assert.deepEqual( + getRouteTargetPanelAction(leaf, null, byId(root, mid, leaf)), + { + kind: "open-thread", + expandedReplyIds: new Set(["mid"]), + replyTargetId: "root", + scrollTargetId: "leaf", + threadHeadId: "root", + }, + ); +}); + +test("broadcast reply target is not a panel action", () => { + const root = makeMessage({ id: "root" }); + const broadcast = makeMessage({ + id: "broadcast", + parentId: "root", + rootId: "root", + depth: 1, + tags: [["broadcast", "1"]], + }); + assert.deepEqual( + getRouteTargetPanelAction(broadcast, null, byId(root, broadcast)), + { kind: "none" }, + ); +}); + +test("reply whose thread head is not loaded yet defers", () => { + const reply = makeMessage({ + id: "reply", + parentId: "missing-root", + rootId: "missing-root", + depth: 1, + }); + assert.deepEqual(getRouteTargetPanelAction(reply, null, byId(reply)), { + kind: "none", + }); +}); diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.ts b/desktop/src/features/channels/ui/useChannelRouteTarget.ts index 0dc4b0e4d6d..76eb5c57d22 100644 --- a/desktop/src/features/channels/ui/useChannelRouteTarget.ts +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.ts @@ -37,6 +37,71 @@ function getThreadRouteTarget( return { expandedReplyIds, threadHeadId }; } +export type RouteTargetPanelAction = + | { kind: "none" } + | { kind: "main-timeline-only" } + | { + kind: "open-thread"; + expandedReplyIds: Set; + replyTargetId: string; + scrollTargetId: string | null; + threadHeadId: string; + }; + +/** + * Decides what a message route target does to the thread panel. + * + * - Top-level target without an explicit `threadRootId` (inbox message rows, + * desktop notifications, search hits, `buzz://` root links): the + * main-timeline scroll + highlight is the entire navigation. Opening the + * reply panel here would show an empty "no replies" pane instead of the + * message in its own context — the exact defect this guards against. + * - Top-level target with an explicit `threadRootId` (inbox "Open full + * thread", thread-draft auto-send, channel-activity rows): the surface + * asked for the thread, so the panel opens at that root. + * - Reply target: the panel opens at the thread head, scrolled to the reply. + * - `none`: not actionable yet (broadcast reply, or the thread head is not + * loaded) — the caller retries when more messages arrive. + * + * Exported as a pure function so the routing contract is unit-testable + * without mounting the hook. + */ +export function getRouteTargetPanelAction( + targetMessage: TimelineMessage, + targetThreadRootId: string | null, + messageById: ReadonlyMap, +): RouteTargetPanelAction { + if (!targetMessage.parentId) { + if (!targetThreadRootId) { + return { kind: "main-timeline-only" }; + } + return { + kind: "open-thread", + expandedReplyIds: new Set(), + replyTargetId: targetMessage.id, + scrollTargetId: null, + threadHeadId: targetMessage.id, + }; + } + + if (isBroadcastReply(targetMessage.tags ?? [])) { + return { kind: "none" }; + } + + const routeTarget = getThreadRouteTarget(targetMessage, messageById); + if (!routeTarget) { + return { kind: "none" }; + } + + return { + kind: "open-thread", + expandedReplyIds: routeTarget.expandedReplyIds, + replyTargetId: routeTarget.threadHeadId, + scrollTargetId: targetMessage.id, + threadHeadId: routeTarget.threadHeadId, + }; +} + function getRouteMainTimelineTargetId( targetMessageId: string | null, targetMessage: TimelineMessage | null, @@ -63,6 +128,7 @@ export function useChannelRouteTarget({ setThreadReplyTargetId, setThreadScrollTargetId, targetMessageId, + targetThreadRootId, timelineMessages, }: { activeChannel: Channel | null; @@ -75,6 +141,7 @@ export function useChannelRouteTarget({ setThreadReplyTargetId: React.Dispatch>; setThreadScrollTargetId: React.Dispatch>; targetMessageId: string | null; + targetThreadRootId: string | null; timelineMessages: TimelineMessage[]; }) { const timelineMessageById = React.useMemo( @@ -114,31 +181,21 @@ export function useChannelRouteTarget({ return; } - if (!targetMessage.parentId) { - closeAgentSession(); - // Root message links should open the reply panel for that root. The - // timeline scroll/highlight target alone is not enough: root links have - // no parent/thread metadata, so the reply-only branch below cannot infer - // a thread head. - setProfilePanelPubkey(null, { replace: true }); - setEditTargetId(null); - setOpenThreadHeadId(targetMessage.id, { replace: true }); - setThreadReplyTargetId(targetMessage.id); - setThreadScrollTargetId(null); - setExpandedThreadReplyIds(new Set()); - handledThreadRouteTargetRef.current = targetKey; - return; - } - - if (isBroadcastReply(targetMessage.tags ?? [])) { - return; - } - - const routeTarget = getThreadRouteTarget( + const action = getRouteTargetPanelAction( targetMessage, + targetThreadRootId, timelineMessageById, ); - if (!routeTarget) { + if (action.kind === "none") { + return; + } + + if (action.kind === "main-timeline-only") { + // Top-level target with no requested thread: the main-timeline + // scroll/highlight (mainTimelineTargetMessageId) is the whole + // navigation. Mark handled so a later timeline update cannot + // re-process this target. + handledThreadRouteTargetRef.current = targetKey; return; } @@ -147,10 +204,10 @@ export function useChannelRouteTarget({ // back should leave the deep link, not strip the panel from it. setProfilePanelPubkey(null, { replace: true }); setEditTargetId(null); - setOpenThreadHeadId(routeTarget.threadHeadId, { replace: true }); - setThreadReplyTargetId(routeTarget.threadHeadId); - setThreadScrollTargetId(targetMessageId); - setExpandedThreadReplyIds(routeTarget.expandedReplyIds); + setOpenThreadHeadId(action.threadHeadId, { replace: true }); + setThreadReplyTargetId(action.replyTargetId); + setThreadScrollTargetId(action.scrollTargetId); + setExpandedThreadReplyIds(action.expandedReplyIds); handledThreadRouteTargetRef.current = targetKey; }, [ activeChannel, @@ -163,6 +220,7 @@ export function useChannelRouteTarget({ setThreadReplyTargetId, setThreadScrollTargetId, targetMessageId, + targetThreadRootId, timelineMessageById, ]); diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index 189db09e0f6..a65c7a91989 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -445,7 +445,7 @@ test("mixed Buzz permalinks render as chips in the composer", async ({ await expect(composerInput).not.toContainText("buzz://"); }); -test("message links to visible root messages open the thread panel", async ({ +test("message links to visible root messages highlight them in the main timeline", async ({ page, }) => { await page.goto("/"); @@ -639,12 +639,17 @@ test("message links to visible root messages open the thread panel", async ({ await rootThreadLink.click({ button: "right" }); await linkMenu.getByRole("button", { name: "Open link" }).click(); + // Root-message links resolve in the main timeline: scroll + highlight the + // root, never force-open its (possibly empty) reply panel (block/buzz — + // "inbox deep links open an empty thread for top-level messages"). const threadPanel = page.getByTestId("message-thread-panel"); - await expect(threadPanel).toBeVisible(); - await expect(page).toHaveURL(/thread=mock-general-welcome/); - await expect(threadPanel.getByTestId("message-thread-head")).toContainText( - "Welcome to general", - ); + await expect(threadPanel).not.toBeVisible(); + await expect(page).not.toHaveURL(/thread=/); + const welcomeRow = page + .getByTestId("message-timeline") + .locator('[data-message-id="mock-general-welcome"]'); + await expect(welcomeRow).toBeVisible(); + await expect(welcomeRow).toHaveClass(/route-target-highlight-fade/); }); test("direct-message tooltip metadata stays on one physical line", async ({ @@ -742,7 +747,7 @@ test("message links explain when preview metadata is unavailable", async ({ await expect(unavailableTooltip).toHaveCount(0); }); -test("message links reopen a closed thread when the same messageId is already in the URL", async ({ +test("root message links re-target the main timeline when the same messageId was already in the URL", async ({ page, }) => { await page.goto( @@ -750,14 +755,20 @@ test("message links reopen a closed thread when the same messageId is already in ); await expect(page.getByTestId("chat-title")).toHaveText("general"); + // A top-level deep link never opens the reply panel — the message is shown + // highlighted in its own context instead. const threadPanel = page.getByTestId("message-thread-panel"); - await expect(threadPanel).toBeVisible(); - await expect(threadPanel.getByTestId("message-thread-head")).toContainText( - "Welcome to general", - ); - - await threadPanel.getByRole("button", { name: "Close panel" }).click(); await expect(threadPanel).not.toBeVisible(); + const welcomeRow = page + .getByTestId("message-timeline") + .locator('[data-message-id="mock-general-welcome"]'); + await expect(welcomeRow).toBeVisible(); + await expect(welcomeRow).toHaveClass(/route-target-highlight-fade/); + + // Once the target is reached the messageId param clears; re-activating a + // link to the same root must route again instead of being swallowed + // (regression guard from the original reopen-a-closed-thread test). + await expect(page).not.toHaveURL(/messageId=/); const link = "buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=mock-general-welcome"; @@ -777,24 +788,30 @@ test("message links reopen a closed thread when the same messageId is already in await expect(rootThreadLink).toHaveText("general"); await rootThreadLink.click(); - await expect(threadPanel).toBeVisible(); - await expect(threadPanel.getByTestId("message-thread-head")).toContainText( - "Welcome to general", - ); + await expect(threadPanel).not.toBeVisible(); + await expect(welcomeRow).toHaveClass(/route-target-highlight-fade/); }); test("message deep links survive reload", async ({ page }) => { - await page.goto( - `/#/channels/${ENGINEERING_CHANNEL_ID}?messageId=mock-engineering-shipped`, - ); + const deepLinkUrl = `/#/channels/${ENGINEERING_CHANNEL_ID}?messageId=mock-engineering-shipped`; + await page.goto(deepLinkUrl); await expect(page.getByTestId("chat-title")).toHaveText("engineering"); await expect(page.getByTestId("message-timeline")).toContainText( "Engineering shipped the desktop build.", ); + // Once the target is centered the messageId param is consumed (cleared via + // onTargetReached so re-activating the same link is never swallowed), and a + // top-level target no longer pins a `thread` param either — so a bare + // reload lands on the plain channel. + await expect(page).not.toHaveURL(/messageId=/); await page.reload(); + await expect(page.getByTestId("chat-title")).toHaveText("engineering"); + // The deep-link URL itself stays valid: loading it again cold re-resolves + // and re-splices the target message. + await page.goto(deepLinkUrl); await expect(page.getByTestId("chat-title")).toHaveText("engineering"); await expect(page.getByTestId("message-timeline")).toContainText( "Engineering shipped the desktop build.", From b69a654c0365512bfe2c9640955ac8cd376c1c11 Mon Sep 17 00:00:00 2001 From: "Trace (Engineer)" <9d485ff0c62915e08a801162c195c7ef096f6f6143925407255cea2656c4b8a4@buzz.block.builderlab.xyz> Date: Sat, 22 Aug 2026 19:45:36 -0700 Subject: [PATCH 02/15] Fix inbox route target hydration Co-authored-by: Trace (Engineer) <9d485ff0c62915e08a801162c195c7ef096f6f6143925407255cea2656c4b8a4@buzz.block.builderlab.xyz> Signed-off-by: Trace (Engineer) <9d485ff0c62915e08a801162c195c7ef096f6f6143925407255cea2656c4b8a4@buzz.block.builderlab.xyz> --- desktop/src/app/navigation/searchHitEventCache.ts | 7 +++++-- desktop/src/features/home/ui/HomeView.tsx | 10 ++++++++++ desktop/tests/e2e/smoke.spec.ts | 5 +++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/desktop/src/app/navigation/searchHitEventCache.ts b/desktop/src/app/navigation/searchHitEventCache.ts index b57a5f01fb1..7b41ff3443c 100644 --- a/desktop/src/app/navigation/searchHitEventCache.ts +++ b/desktop/src/app/navigation/searchHitEventCache.ts @@ -31,13 +31,16 @@ export function buildSearchHitEvent(hit: SearchHit): RelayEvent { }; } -export function cacheSearchHitEvent(hit: SearchHit): RelayEvent { - const event = buildSearchHitEvent(hit); +export function cacheRouteTargetEvent(event: RelayEvent): RelayEvent { searchHitEventCache.set(event.id, event); trimCache(); return event; } +export function cacheSearchHitEvent(hit: SearchHit): RelayEvent { + return cacheRouteTargetEvent(buildSearchHitEvent(hit)); +} + export function clearSearchHitEventCache(): void { searchHitEventCache.clear(); } diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 893b3c309c6..37e5c127696 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -68,6 +68,7 @@ import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { useElementWidth } from "@/shared/hooks/use-mobile"; import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; +import { cacheRouteTargetEvent } from "@/app/navigation/searchHitEventCache"; import { AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX } from "@/shared/layout/AuxiliaryPanel"; import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; @@ -705,6 +706,15 @@ export function HomeView({ if (!channelId) { return; } + cacheRouteTargetEvent({ + id: item.item.id, + pubkey: item.item.pubkey, + created_at: item.item.createdAt, + kind: item.item.kind, + tags: item.item.tags, + content: item.item.content, + sig: "", + }); onOpenContext( channelId, item.id, diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index b3ea8bea617..c563498c117 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -295,6 +295,11 @@ test("opens a mocked channel from the inbox feed", async ({ page }) => { /#\/channels\/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50\?messageId=mock-feed-mention$/, ); await expect(page.getByTestId("chat-title")).toHaveText("general"); + const targetRow = page + .getByTestId("message-timeline") + .locator('[data-message-id="mock-feed-mention"]'); + await expect(targetRow).toBeVisible(); + await expect(targetRow).toHaveClass(/route-target-highlight-fade/); }); test("Inbox excludes generic channel and unowned agent traffic", async ({ From 9b882693a4c99dd13440ef471d6d639295dd846e Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:06:37 -0700 Subject: [PATCH 03/15] Revert "Fix inbox route target hydration" This reverts commit b69a654c0365512bfe2c9640955ac8cd376c1c11. Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- desktop/src/app/navigation/searchHitEventCache.ts | 7 ++----- desktop/src/features/home/ui/HomeView.tsx | 10 ---------- desktop/tests/e2e/smoke.spec.ts | 5 ----- 3 files changed, 2 insertions(+), 20 deletions(-) diff --git a/desktop/src/app/navigation/searchHitEventCache.ts b/desktop/src/app/navigation/searchHitEventCache.ts index 7b41ff3443c..b57a5f01fb1 100644 --- a/desktop/src/app/navigation/searchHitEventCache.ts +++ b/desktop/src/app/navigation/searchHitEventCache.ts @@ -31,16 +31,13 @@ export function buildSearchHitEvent(hit: SearchHit): RelayEvent { }; } -export function cacheRouteTargetEvent(event: RelayEvent): RelayEvent { +export function cacheSearchHitEvent(hit: SearchHit): RelayEvent { + const event = buildSearchHitEvent(hit); searchHitEventCache.set(event.id, event); trimCache(); return event; } -export function cacheSearchHitEvent(hit: SearchHit): RelayEvent { - return cacheRouteTargetEvent(buildSearchHitEvent(hit)); -} - export function clearSearchHitEventCache(): void { searchHitEventCache.clear(); } diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 37e5c127696..893b3c309c6 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -68,7 +68,6 @@ import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { useElementWidth } from "@/shared/hooks/use-mobile"; import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; -import { cacheRouteTargetEvent } from "@/app/navigation/searchHitEventCache"; import { AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX } from "@/shared/layout/AuxiliaryPanel"; import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; @@ -706,15 +705,6 @@ export function HomeView({ if (!channelId) { return; } - cacheRouteTargetEvent({ - id: item.item.id, - pubkey: item.item.pubkey, - created_at: item.item.createdAt, - kind: item.item.kind, - tags: item.item.tags, - content: item.item.content, - sig: "", - }); onOpenContext( channelId, item.id, diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index c563498c117..b3ea8bea617 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -295,11 +295,6 @@ test("opens a mocked channel from the inbox feed", async ({ page }) => { /#\/channels\/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50\?messageId=mock-feed-mention$/, ); await expect(page.getByTestId("chat-title")).toHaveText("general"); - const targetRow = page - .getByTestId("message-timeline") - .locator('[data-message-id="mock-feed-mention"]'); - await expect(targetRow).toBeVisible(); - await expect(targetRow).toHaveClass(/route-target-highlight-fade/); }); test("Inbox excludes generic channel and unowned agent traffic", async ({ From 8290827192748f660e6bd8098b822f63c04b2fc8 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:47:05 -0700 Subject: [PATCH 04/15] Let cold deep links win the viewport in virtualized timelines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deep link opened from outside the channel (Home inbox, search, a notification) mounts the timeline with the target already in the route. Two things then fought the target jump and won: - `scrollToMessageImperative` returned `false` after handing the jump to the virtualizer, so the cold-mount path read "not in list" and pinned to bottom, re-arming durable bottom intent over the jump. - Even when the target was centered, `virtualizerAtBottomRef` still held the channel reset's `true` default. The viewport-resize observer fires once on observe and trusted it, re-settling to the floor; Virtua then reported "at bottom", the anchor flipped, and every later append settled there too. Make the result explicit — `centered | pending | missing` — and have every caller branch on the truth: the cold-mount path pins to bottom only for `missing`, and both retry paths (route target, search match) wait for `centered` instead of re-querying the DOM themselves. In virtualized mode the virtualizer is the only scroll writer: its imperative jump keeps correcting `scrollTop` as rows measure, so a direct `scrollTo` would be overwritten; the hook reports `centered` only once the row is measured and settled in view, and records the bottom state it actually observed so the resize/append settles stop acting on the seeded default. Adds two cold-navigation e2e tests against `#deep-history` — a target in unrendered mid-history and the newest message — that fail on the previous code with exactly the reported symptoms (no scroll; no highlight) and pass now. Co-Authored-By: Claude Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- .../features/messages/ui/MessageTimeline.tsx | 26 +-- .../ui/useAnchoredScroll.lifecycle.test.mjs | 23 ++- .../features/messages/ui/useAnchoredScroll.ts | 168 ++++++++++-------- desktop/tests/e2e/navigation.spec.ts | 61 +++++++ 4 files changed, 174 insertions(+), 104 deletions(-) diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index df9af09e674..0d2104b27aa 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -473,8 +473,9 @@ const MessageTimelineBase = React.forwardRef< [prepareForOwnMessage, scrollToBottom, timelineVirtualizerApi], ); - // Jump-to-message is purely DOM-based now: all loaded rows are mounted, so - // `scrollToMessage` always finds the target row. No virtualizer convergence. + // Jump-to-message reports `centered` once the row is in the DOM and placed; + // `pending` means the virtualizer accepted the jump and the row commits on a + // later render, so callers retry on range change rather than re-querying. const jumpToMessage = React.useCallback( (messageId: string, options?: { behavior?: ScrollBehavior }) => { return scrollToMessage(messageId, { highlight: true, ...options }); @@ -535,28 +536,19 @@ const MessageTimelineBase = React.forwardRef< } pendingSearchTargetRef.current = null; prevSearchActiveRef.current = searchActiveMessageId; - if (!jumpToMessage(searchActiveMessageId, { behavior: "smooth" })) { + if ( + jumpToMessage(searchActiveMessageId, { behavior: "smooth" }) !== + "centered" + ) { pendingSearchTargetRef.current = searchActiveMessageId; } }, [jumpToMessage, searchActiveMessageId, showTimelineSkeleton]); - // biome-ignore lint/correctness/useExhaustiveDependencies: deferredMessages and virtualizerRenderVersion are intentional retry triggers — a search hit may be spliced into messages asynchronously, and in virtualized mode a phase-1 index jump only realizes the row; retry when the rendered range changes so the DOM-visible path can center and highlight it. + // biome-ignore lint/correctness/useExhaustiveDependencies: deferredMessages and virtualizerRenderVersion are intentional retry triggers — a search hit may be spliced into messages asynchronously (`missing`), and in virtualized mode an index jump only realizes the row (`pending`); retry when the rendered range changes so the DOM path can center and highlight it. React.useEffect(() => { const target = pendingSearchTargetRef.current; if (!target || showTimelineSkeleton) return; - if ( - useTimelineVirtualizer && - !activeScrollContainerRef.current?.querySelector( - `[data-message-id="${CSS.escape(target)}"]`, - ) - ) { - // Phase 1: ask the virtualizer to realize the match's index. The retry effect - // runs again on range change and the DOM-visible path does the actual - // center + highlight once the row exists. - void jumpToMessage(target, { behavior: "auto" }); - return; - } - if (jumpToMessage(target, { behavior: "auto" })) { + if (jumpToMessage(target, { behavior: "auto" }) === "centered") { pendingSearchTargetRef.current = null; } }, [ diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs index ee3fec1a988..cf2c575bfde 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs +++ b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs @@ -281,7 +281,10 @@ function VirtualTargetHarness({ refs }) { scrollContainerRef: refs.scroller, virtualCancelBottomIntent: bottomApi.cancel, virtualizerOwnsPrependAnchoring: true, - virtualScrollToMessage: () => true, + virtualScrollToMessage: (messageId) => { + refs.targetJumps.current.push(messageId); + return true; + }, }); React.useLayoutEffect(() => { if (didRun.current) return; @@ -522,7 +525,7 @@ test("user interaction releases and retires a pending pinned target", async () = await act(async () => root.unmount()); }); -test("mounted virtual target retires bottom intent before direct centering", async () => { +test("mounted virtual target retires bottom intent and delegates the jump to the virtualizer", async () => { const resizeObservers = []; globalThis.ResizeObserver = class { constructor(callback) { @@ -555,7 +558,9 @@ test("mounted virtual target retires bottom intent before direct centering", asy }; scroller.querySelector = () => row; scroller.querySelectorAll = () => []; + const directScrollWrites = []; scroller.scrollTo = ({ top }) => { + directScrollWrites.push(top); scroller.scrollTop = top; }; @@ -571,6 +576,7 @@ test("mounted virtual target retires bottom intent before direct centering", asy }, }, scroller: { current: scroller }, + targetJumps: { current: [] }, targetResult: { current: null }, }; const root = createRoot(document.createElement("div")); @@ -579,8 +585,12 @@ test("mounted virtual target retires bottom intent before direct centering", asy }); assert.deepEqual(bottomWrites, [{ index: 4, options: { align: "end" } }]); - assert.equal(refs.targetResult.current, true); - assert.equal(row.getBoundingClientRect().top, 180); + // The virtualizer is the only scroll writer: the hook hands it the jump and + // never races it with a direct `scrollTo` that its in-flight correction + // would overwrite. The row is already settled in view, so it is handled. + assert.deepEqual(refs.targetJumps.current, ["selected"]); + assert.deepEqual(directScrollWrites, []); + assert.equal(refs.targetResult.current, "centered"); const bottomGeometryObserver = resizeObservers.find( (observer) => observer.targets.includes(content) && observer.targets.includes(scroller), @@ -589,11 +599,6 @@ test("mounted virtual target retires bottom intent before direct centering", asy bottomGeometryObserver.callback(); await act(async () => new Promise((resolve) => setTimeout(resolve, 0))); - assert.equal( - row.getBoundingClientRect().top, - 180, - "target remains centered after later virtual geometry activity", - ); assert.equal(bottomWrites.length, 1, "geometry cannot re-pin to bottom"); await act(async () => root.unmount()); }); diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 0bfcb3b3e2f..76179fe46c0 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -23,6 +23,21 @@ type AnchorState = | { kind: "message"; messageId: string; topOffset: number } | { kind: "pinned-center"; messageId: string; contentTop: number }; +/** + * Outcome of an imperative scroll-to-message. + * + * - `centered` — the row is in the DOM and was centered (and highlighted when + * asked). The target is handled. + * - `pending` — the row is in the list but not rendered yet; the virtualizer + * accepted the jump and the row will commit in a later render. The caller + * must neither treat the target as handled nor move the viewport elsewhere + * (a bottom pin here would override the jump) — retry when the rendered + * range changes. + * - `missing` — the row is not in the list at all (not loaded, or not yet + * spliced in by the route screen). Retry when `messages` changes. + */ +export type ScrollToMessageResult = "centered" | "pending" | "missing"; + type UseAnchoredScrollOptions = { /** Scroll container. Owned by the parent so external refs still compose. */ scrollContainerRef: React.RefObject; @@ -80,11 +95,11 @@ type UseAnchoredScrollResult = { * (used by the composer's send flow). */ scrollToBottomOnNextUpdate: () => void; /** Imperative: scroll a specific message into view; optionally pulse it. - * Returns true if the row was found and scrolled, false otherwise. */ + * See {@link ScrollToMessageResult} for what each outcome means. */ scrollToMessage: ( messageId: string, options?: { highlight?: boolean; behavior?: ScrollBehavior }, - ) => boolean; + ) => ScrollToMessageResult; /** Syncs the hook's bottom affordances from a virtualizer-owned scroller. */ onVirtualizerAtBottomStateChange: (atBottom: boolean) => void; }; @@ -101,6 +116,24 @@ function isAtBottomNow( ); } +/** + * Whether a rendered row is as visible as it can be: fully inside the + * viewport, or — for a row taller than the viewport — covering it. One pixel + * of slack absorbs sub-pixel layout rounding. A zero-height row is a + * virtualized placeholder that has not been measured yet, never a settled one. + */ +function isRowSettledInViewport(row: Element, container: Element) { + const rowRect = row.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const rowHeight = rowRect.bottom - rowRect.top; + if (rowHeight <= 0) return false; + const visible = + Math.min(rowRect.bottom, containerRect.bottom) - + Math.max(rowRect.top, containerRect.top); + const viewportHeight = containerRect.bottom - containerRect.top; + return visible >= Math.min(rowHeight, viewportHeight) - 1; +} + /** * Pick an anchor for the current scroll position. * @@ -432,55 +465,50 @@ export function useAnchoredScroll({ ( messageId: string, options: { highlight?: boolean; behavior?: ScrollBehavior } = {}, - ): boolean => { + ): ScrollToMessageResult => { const container = scrollContainerRef.current; - if (!container) return false; + if (!container) return "missing"; const el = container.querySelector( `[data-message-id="${messageId}"]`, ); if (virtualizerOwnsPrependAnchoring && virtualScrollToMessage) { - // Target navigation owns the viewport before any movement strategy is - // chosen. The already-mounted fast path centers the DOM node directly - // and would otherwise leave durable bottom intent armed. + // The virtualizer is the only scroll writer here. Its imperative jump + // keeps correcting `scrollTop` for several frames while unmeasured + // rows commit, so a direct `container.scrollTo` would be overwritten — + // the list's own first-commit bottom settle is exactly such a jump. A + // new jump cancels the pending one, which is how target navigation + // takes the viewport away from that settle; disarming the durable + // bottom intent stops the ResizeObserver from re-pinning later. virtualCancelBottomIntent?.(); - if (el) { - const rect = el.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - const isInViewport = - rect.top >= containerRect.top && - rect.bottom <= containerRect.bottom; - if (!isInViewport) { - if (!virtualScrollToMessage(messageId, { behavior: "auto" })) { - return false; - } - anchorRef.current = { kind: "message", messageId, topOffset: 0 }; - setIsAtBottom(false); - return false; - } - const centeredTop = (container.clientHeight - rect.height) / 2; - container.scrollTo({ - top: Math.max( - 0, - container.scrollTop + - (rect.top - containerRect.top) - - centeredTop, - ), - behavior: options.behavior ?? "auto", - }); - } else if ( + if ( !virtualScrollToMessage(messageId, { behavior: options.behavior ?? "auto", }) ) { - return false; + return "missing"; } anchorRef.current = { kind: "message", messageId, topOffset: 0 }; - setIsAtBottom(false); - if (el && options.highlight) highlightMessage(messageId); - return el !== null; + // The channel reset seeds `virtualizerAtBottomRef` to true as a + // default, and both the viewport-resize and append settles trust it. + // A ResizeObserver fires once on observe, so leaving that default in + // place lets the first resize callback after this jump pull the view + // straight back to the floor. Record what the jump knows instead; the + // virtualizer's next real bottom report refines it. + // Handled only once the row is rendered and actually in view. Until + // then the jump is in flight; the caller retries on range change. + if (!el || !isRowSettledInViewport(el, container)) { + virtualizerAtBottomRef.current = false; + setIsAtBottom(false); + return "pending"; + } + const atBottom = isAtBottomNow(container); + virtualizerAtBottomRef.current = atBottom; + setIsAtBottom(atBottom); + if (options.highlight) highlightMessage(messageId); + return "centered"; } - if (!el) return false; + if (!el) return "missing"; const rect = el.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); @@ -532,7 +560,7 @@ export function useAnchoredScroll({ } if (options.highlight) highlightMessage(messageId); - return true; + return "centered"; }, [ highlightMessage, @@ -630,19 +658,21 @@ export function useAnchoredScroll({ }); }; if (targetMessageId) { - // A cold deep-link target may not be in the DOM on this first - // commit — the route screen fetches it by id and splices it in a - // render or two later. If centering fails now, leave the timeline at - // its default position and let the post-mount target effect (keyed on - // `messages`) retry once the row lands, rather than marking it handled. - if ( - scrollToMessageImperative(targetMessageId, { - highlight: highlightTargetMessage, - }) - ) { + // A cold deep-link target is rarely in the DOM on this first commit: + // a virtualized list renders only a window, and a target outside the + // loaded history is fetched by id and spliced in a render or two + // later. Either way the post-mount target effect (keyed on `messages` + // and the rendered range) finishes the job — it is not handled yet. + // Only fall back to the bottom pin when the row is genuinely absent; + // pinning while the virtualizer is mid-jump re-arms durable bottom + // intent and strands the view at the floor with no highlight. + const result = scrollToMessageImperative(targetMessageId, { + highlight: highlightTargetMessage, + }); + if (result === "centered") { handledTargetIdRef.current = targetMessageId; onTargetReached?.(targetMessageId); - } else { + } else if (result === "missing") { pinToBottomOnMount(); } } else { @@ -876,7 +906,7 @@ export function useAnchoredScroll({ // *without* marking the target handled until its row actually exists — each // subsequent message commit re-runs the effect and retries the centering. // --------------------------------------------------------------------------- - // biome-ignore lint/correctness/useExhaustiveDependencies: `messages` and `virtualizerRenderVersion` are intentional retry triggers, not values read by the effect body — the effect reads the DOM (querySelector), and we need it to re-run each time the message list or virtualized rendered range changes so a target spliced into older history gets centered once its row commits. + // biome-ignore lint/correctness/useExhaustiveDependencies: `messages` and `virtualizerRenderVersion` are intentional retry triggers, not values read by the effect body — `scrollToMessageImperative` reads the DOM, and we need the effect to re-run each time the message list or virtualized rendered range changes so a target spliced into older history (or windowed out of the DOM) gets centered once its row commits. React.useEffect(() => { if (!targetMessageId) { handledTargetIdRef.current = null; @@ -893,43 +923,25 @@ export function useAnchoredScroll({ if (!hasInitializedRef.current) return; // initial-mount path will handle. void virtualizerRenderVersion; - const container = scrollContainerRef.current; - if (!container) return; - const el = container.querySelector( - `[data-message-id="${targetMessageId}"]`, - ); - if (!el && virtualizerOwnsPrependAnchoring) { - if ( - scrollToMessageImperative(targetMessageId, { - highlight: highlightTargetMessage, - }) - ) { - handledTargetIdRef.current = targetMessageId; - onTargetReached?.(targetMessageId); - } - return; - } - if (!el) { - // Row not in the DOM yet. A cold deep-link target is fetched by id and - // spliced into `messages` a render or two later; this effect re-runs on - // each `messages` commit and retries until the row exists. - return; + // `pending` (virtualizer mid-jump) and `missing` (row not spliced in yet) + // both leave the target unhandled; the next `messages` or rendered-range + // commit re-runs this effect and retries until the row is centered. + if ( + scrollToMessageImperative(targetMessageId, { + highlight: highlightTargetMessage, + }) === "centered" + ) { + handledTargetIdRef.current = targetMessageId; + onTargetReached?.(targetMessageId); } - handledTargetIdRef.current = targetMessageId; - scrollToMessageImperative(targetMessageId, { - highlight: highlightTargetMessage, - }); - onTargetReached?.(targetMessageId); }, [ highlightTargetMessage, isLoading, messages, onTargetReached, releasePinnedCenter, - scrollContainerRef, scrollToMessageImperative, targetMessageId, - virtualizerOwnsPrependAnchoring, virtualizerRenderVersion, ]); diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index a65c7a91989..d2ffc10715c 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -652,6 +652,67 @@ test("message links to visible root messages highlight them in the main timeline await expect(welcomeRow).toHaveClass(/route-target-highlight-fade/); }); +// Cold deep links arrive from outside the channel (Home inbox "Open in +// channel", search, notifications): the timeline mounts *with* the target +// already in the route. `#deep-history` is long enough that the virtualizer +// only renders the newest rows on first commit, so the target row is in the +// loaded window but not yet in the DOM — the state the in-channel link tests +// above never reach. +const DEEP_HISTORY_CHANNEL_ID = "feedf00d-0000-4000-8000-000000000007"; + +async function expectColdDeepLinkLandsOnTarget( + page: import("@playwright/test").Page, + messageId: string, +) { + await page.goto("/"); + await expect(page.getByTestId("home-inbox-list")).toBeVisible(); + + // Same-document hash navigation — the route the Home inbox / search hand + // off to, without reloading the app shell. + await page.goto( + `/#/channels/${DEEP_HISTORY_CHANNEL_ID}?messageId=${messageId}`, + ); + await expect(page.getByTestId("chat-title")).toHaveText("deep-history"); + + const timeline = page.getByTestId("message-timeline"); + const targetRow = timeline.locator(`[data-message-id="${messageId}"]`); + // The highlight is applied only once the hook has seen the row settled in + // the viewport, so it is the strongest signal that the jump completed. It + // fades after 2s — assert it before anything that can wait on layout. + await expect(targetRow).toHaveClass(/route-target-highlight-fade/); + await expect(targetRow).toBeInViewport(); + // Top-level targets resolve in the main timeline only. + await expect(page.getByTestId("message-thread-panel")).not.toBeVisible(); + // Reaching the target consumes the route param so a later channel visit + // doesn't re-scroll to stale history. + await expect(page).toHaveURL( + new RegExp(`#/channels/${DEEP_HISTORY_CHANNEL_ID}$`), + ); + // The target must still be in view once the route param is cleared — the + // list's first-commit bottom settle and the mount-time resize pass must not + // win over the target jump. + await page.waitForTimeout(500); + await expect(targetRow).toBeInViewport(); + return targetRow; +} + +test("cold deep link to a message in virtualized history scrolls to and highlights it", async ({ + page, +}) => { + // Index 450 is inside the cold-load window but ~150 rows above the bottom, + // so it is not in view when the timeline mounts — the jump must win over + // the list's own first-commit bottom settle and the mount-time resize pass. + await expectColdDeepLinkLandsOnTarget(page, "mock-deep-history-450"); +}); + +test("cold deep link to the newest message highlights it at the bottom", async ({ + page, +}) => { + // The DM case: the target is the last message. It must still highlight, + // and the view stays at the floor. + await expectColdDeepLinkLandsOnTarget(page, "mock-deep-history-599"); +}); + test("direct-message tooltip metadata stays on one physical line", async ({ page, }) => { From 73f3a9a24b475b1491098fe9c2918cab24fddc86 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:46:29 -0700 Subject: [PATCH 05/15] Draw the route-target highlight on the row's hover pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deep-link highlight swapped the row to a full-bleed band geometry (negative margins, square corners, wider padding). That widened the text column by ~8px, so long lines could rewrap and change the row's measured height — in the virtualized timeline that makes Virtua re-measure and nudge the scroll, once when the highlight appears and again when it clears. Paint the tint on a before: pseudo sized to the row's own rounded-2xl hover pill instead, so highlighted and idle rows are geometrically identical. Share the class between MessageRow and the thread-summary wrapper so the two can't drift again. Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- desktop/src/features/messages/ui/MessageRow.tsx | 5 ++--- .../features/messages/ui/TimelineMessageRow.tsx | 4 ++-- .../features/messages/ui/routeTargetHighlight.ts | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 5 deletions(-) create mode 100644 desktop/src/features/messages/ui/routeTargetHighlight.ts diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 536631b02d3..9b48c4359eb 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -55,6 +55,7 @@ import { MessageMetaSegments, } from "./MessageHeader"; import { MessageTimestamp } from "./MessageTimestamp"; +import { ROUTE_TARGET_HIGHLIGHT_CLASS } from "./routeTargetHighlight"; import { SentFromThreadLine } from "./SentFromThreadLine"; import { WaveMessageAttachment } from "./WaveMessageAttachment"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -892,9 +893,7 @@ export const MessageRow = React.memo( "flex gap-2.5", isDisplayedAsContinuation ? "items-center" : "items-start", hasActiveReminder ? "bg-blue-500/10" : "", - highlighted - ? "-mx-4 rounded-none px-6 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-8" - : "", + highlighted ? ROUTE_TARGET_HIGHLIGHT_CLASS : "", )} data-message-id={message.id} data-testid="message-row" diff --git a/desktop/src/features/messages/ui/TimelineMessageRow.tsx b/desktop/src/features/messages/ui/TimelineMessageRow.tsx index 283760fb021..3ad05b41246 100644 --- a/desktop/src/features/messages/ui/TimelineMessageRow.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageRow.tsx @@ -9,6 +9,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { cn } from "@/shared/lib/cn"; import { MessageRow } from "./MessageRow"; import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; +import { ROUTE_TARGET_HIGHLIGHT_CLASS } from "./routeTargetHighlight"; import { SystemMessageRow } from "./SystemMessageRow"; type ToggleReaction = ( @@ -137,8 +138,7 @@ export function MessageRowItem({
Date: Mon, 24 Aug 2026 13:01:49 -0700 Subject: [PATCH 06/15] Preserve smooth scrolling for rendered search targets Keep nearby, already-rendered find-in-channel transitions smooth while forcing distant virtualized realization to remain instant. Map the behavior explicitly onto Virtua and cover both sides of the hybrid contract. Co-authored-by: Rivet Signed-off-by: Rivet --- .../messages/ui/TimelineMessageList.tsx | 8 ++- .../ui/useAnchoredScroll.lifecycle.test.mjs | 65 +++++++++++++++++++ .../features/messages/ui/useAnchoredScroll.ts | 5 +- .../messages/ui/virtualMessageScroll.test.mjs | 15 +++++ .../messages/ui/virtualMessageScroll.ts | 8 +++ desktop/tests/e2e/scroll-history.spec.ts | 18 ++--- 6 files changed, 108 insertions(+), 11 deletions(-) create mode 100644 desktop/src/features/messages/ui/virtualMessageScroll.test.mjs create mode 100644 desktop/src/features/messages/ui/virtualMessageScroll.ts diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index d7ef78ea04b..793c731761c 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -33,6 +33,7 @@ import { UnreadDivider } from "./UnreadDivider"; import { useTimelineRetention } from "./useTimelineRetention"; import { useUpwardPaginationWheel } from "./useUpwardPaginationWheel"; import { useVirtualizedBottomSettle } from "./useVirtualizedBottomSettle"; +import { getVirtualMessageScrollOptions } from "./virtualMessageScroll"; export type TimelineVirtualizerApi = { cancelBottomIntent: () => void; @@ -670,11 +671,14 @@ function VirtualizedTimelineRows({ settleAtBottom(); }, settleAtBottom, - scrollToMessage(messageId) { + scrollToMessage(messageId, options) { cancelBottomSettle(); const index = messageItemIndexByIdRef.current.get(messageId); if (index === undefined) return false; - listRef.current?.scrollToIndex(index, { align: "center" }); + listRef.current?.scrollToIndex( + index, + getVirtualMessageScrollOptions(options?.behavior), + ); return true; }, }; diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs index cf2c575bfde..0e626fbe573 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs +++ b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs @@ -266,6 +266,30 @@ function BottomStateHarness({ return null; } +function VirtualScrollBehaviorHarness({ refs }) { + const didRun = React.useRef(false); + const anchored = useAnchoredScroll({ + channelId: "conversation", + contentRef: refs.content, + isLoading: false, + messages: [{ id: "selected" }], + scrollContainerRef: refs.scroller, + virtualizerOwnsPrependAnchoring: true, + virtualScrollToMessage: (messageId, options) => { + refs.targetJumps.push({ messageId, options }); + return true; + }, + }); + React.useLayoutEffect(() => { + if (didRun.current) return; + didRun.current = true; + refs.targetResult = anchored.scrollToMessage("selected", { + behavior: "smooth", + }); + }, [anchored.scrollToMessage, refs]); + return null; +} + function VirtualTargetHarness({ refs }) { const didRun = React.useRef(false); const bottomApi = useVirtualizedBottomSettle( @@ -525,6 +549,47 @@ test("user interaction releases and retires a pending pinned target", async () = await act(async () => root.unmount()); }); +test("virtual search scrolling stays smooth only for an already-rendered target", async () => { + for (const rendered of [true, false]) { + const content = document.createElement("div"); + const scroller = document.createElement("div"); + scroller.clientHeight = 400; + scroller.scrollHeight = 1_000; + scroller.scrollTop = 0; + scroller.getBoundingClientRect = () => ({ bottom: 400, top: 0 }); + const row = { + getBoundingClientRect: () => ({ + bottom: 290, + height: 40, + top: 250, + }), + }; + scroller.querySelector = () => (rendered ? row : null); + scroller.querySelectorAll = () => []; + scroller.appendChild(content); + const refs = { + content: { current: content }, + scroller: { current: scroller }, + targetJumps: [], + targetResult: null, + }; + const root = createRoot(document.createElement("div")); + + await act(async () => { + root.render(React.createElement(VirtualScrollBehaviorHarness, { refs })); + }); + + assert.deepEqual(refs.targetJumps, [ + { + messageId: "selected", + options: { behavior: rendered ? "smooth" : "auto" }, + }, + ]); + assert.equal(refs.targetResult, rendered ? "centered" : "pending"); + await act(async () => root.unmount()); + } +}); + test("mounted virtual target retires bottom intent and delegates the jump to the virtualizer", async () => { const resizeObservers = []; globalThis.ResizeObserver = class { diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 76179fe46c0..ac9c7ae3227 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -480,9 +480,12 @@ export function useAnchoredScroll({ // takes the viewport away from that settle; disarming the durable // bottom intent stops the ResizeObserver from re-pinning later. virtualCancelBottomIntent?.(); + const virtualScrollBehavior = el + ? (options.behavior ?? "auto") + : "auto"; if ( !virtualScrollToMessage(messageId, { - behavior: options.behavior ?? "auto", + behavior: virtualScrollBehavior, }) ) { return "missing"; diff --git a/desktop/src/features/messages/ui/virtualMessageScroll.test.mjs b/desktop/src/features/messages/ui/virtualMessageScroll.test.mjs new file mode 100644 index 00000000000..d5bc989d2dd --- /dev/null +++ b/desktop/src/features/messages/ui/virtualMessageScroll.test.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getVirtualMessageScrollOptions } from "./virtualMessageScroll.ts"; + +test("maps smooth message navigation to Virtua's smooth option", () => { + assert.deepEqual(getVirtualMessageScrollOptions("smooth"), { + align: "center", + smooth: true, + }); + assert.deepEqual(getVirtualMessageScrollOptions("auto"), { + align: "center", + smooth: false, + }); +}); diff --git a/desktop/src/features/messages/ui/virtualMessageScroll.ts b/desktop/src/features/messages/ui/virtualMessageScroll.ts new file mode 100644 index 00000000000..1950605d621 --- /dev/null +++ b/desktop/src/features/messages/ui/virtualMessageScroll.ts @@ -0,0 +1,8 @@ +export function getVirtualMessageScrollOptions( + behavior: ScrollBehavior | undefined, +) { + return { + align: "center" as const, + smooth: behavior === "smooth", + }; +} diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index 0e1d4cfed6f..dd0185fa857 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -933,15 +933,17 @@ test("unified channel search opens rows regardless of history position", async ( // Poll for the row matching `needle` to settle inside the timeline // viewport, then return its placement + className. Polling is required - // because the find-bar -> active-match -> scrollIntoView path is async - // (state update, then a smooth scroll). Locator `toBeVisible` only - // checks DOM-visible (display/visibility), not in-viewport, so it - // can't be used as the wait condition for "the scroll completed". + // because the find-bar -> active-match -> virtualizer path is async + // (state update, then an indexed jump that may need to render the row). + // Locator `toBeVisible` only checks DOM-visible (display/visibility), not + // in-viewport, so it can't be used as the wait condition for "the scroll + // completed". // - // Tolerance: 1px on each edge for sub-pixel rounding. The 5s budget - // accommodates browsers honoring smooth-scroll over long distances - // (initial scroll position is the bottom of a 200-message channel; - // the ALPHA row is ~180 rows up). + // Tolerance: 1px on each edge for sub-pixel rounding. The 5s budget leaves + // room for the virtualizer to realize and measure the distant ALPHA row + // (~180 rows above the initial bottom position). Distant unrendered search + // targets intentionally jump instantly; only already-rendered targets keep + // smooth scrolling for spatial context. const waitForRowInViewport = async (needle: string) => timeline.evaluate((timelineEl, n) => { return new Promise<{ From b5b843891de35dfa1a10871d7af8a6cecaf3d65f Mon Sep 17 00:00:00 2001 From: Rivet Date: Mon, 24 Aug 2026 13:45:36 -0700 Subject: [PATCH 07/15] Harden routed message navigation Validate route thread relationships before fetching or opening panels, dedupe normalized actions, settle virtualized rows at the truthful viewport midpoint, and cover real DM navigation. Co-authored-by: Rivet Signed-off-by: Rivet --- .../app/routes/ChannelRouteScreen.test.mjs | 32 ++++ desktop/src/app/routes/ChannelRouteScreen.tsx | 31 +++- .../useChannelRouteTarget.lifecycle.test.mjs | 66 +++++++ .../ui/useChannelRouteTarget.test.mjs | 8 + .../channels/ui/useChannelRouteTarget.ts | 28 +-- .../features/messages/ui/MessageTimeline.tsx | 1 + .../messages/ui/TimelineMessageList.tsx | 4 + .../ui/useAnchoredScroll.lifecycle.test.mjs | 65 ++++++- .../features/messages/ui/useAnchoredScroll.ts | 163 +++++++++++++++--- desktop/src/testing/e2eBridge.ts | 48 ++++-- desktop/tests/e2e/navigation.spec.ts | 71 +++++++- 11 files changed, 448 insertions(+), 69 deletions(-) create mode 100644 desktop/src/app/routes/ChannelRouteScreen.test.mjs create mode 100644 desktop/src/features/channels/ui/useChannelRouteTarget.lifecycle.test.mjs diff --git a/desktop/src/app/routes/ChannelRouteScreen.test.mjs b/desktop/src/app/routes/ChannelRouteScreen.test.mjs new file mode 100644 index 00000000000..89383f5db2e --- /dev/null +++ b/desktop/src/app/routes/ChannelRouteScreen.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getValidatedRouteThreadRootId } from "./ChannelRouteScreen.tsx"; + +function event(id, tags = [["h", "channel"]]) { + return { + id, + pubkey: "author", + created_at: 1, + kind: 9, + tags, + content: "hello", + sig: "signature", + }; +} + +test("a top-level route only accepts its own id as thread root", () => { + const target = event("target"); + assert.equal(getValidatedRouteThreadRootId(target, "target"), "target"); + assert.equal(getValidatedRouteThreadRootId(target, "unrelated"), null); + assert.equal(getValidatedRouteThreadRootId(target, null), null); +}); + +test("a reply route derives its containing root", () => { + const target = event("reply", [ + ["h", "channel"], + ["e", "root", "", "root"], + ["e", "root", "", "reply"], + ]); + assert.equal(getValidatedRouteThreadRootId(target, null), "root"); +}); diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index 7951f419c3a..cd14d25bf48 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -45,6 +45,17 @@ function getReplyParentId(event: RelayEvent): string | null { return getThreadReference(event.tags).parentId; } +export function getValidatedRouteThreadRootId( + targetEvent: RelayEvent, + targetThreadRootId: string | null, +): string | null { + const targetThreadRef = getThreadReference(targetEvent.tags); + if (getReplyParentId(targetEvent) === null) { + return targetThreadRootId === targetEvent.id ? targetThreadRootId : null; + } + return targetThreadRootId ?? targetThreadRef.rootId ?? null; +} + async function fetchRouteTargetEvents( eventIds: string[], targetMessageId: string | null, @@ -70,8 +81,10 @@ async function fetchRouteTargetEvents( return [...eventsById.values()]; } - const targetThreadRef = getThreadReference(targetEvent.tags); - const threadRootId = targetThreadRootId ?? targetThreadRef.rootId ?? null; + const threadRootId = getValidatedRouteThreadRootId( + targetEvent, + targetThreadRootId, + ); if (threadRootId && !eventsById.has(threadRootId)) { addEvent(await fetchRouteEvent(threadRootId)); } @@ -174,12 +187,14 @@ export function ChannelRouteScreen({ ); } - const eventIds = [ - targetMessageId, - targetThreadRootId && targetThreadRootId !== targetMessageId - ? targetThreadRootId - : null, - ].filter((eventId): eventId is string => eventId !== null); + // The selected message is authoritative. Load it first so the helper can + // validate any supplied thread relationship before fetching another event. + // A thread-only route has no selected message to validate against. + const eventIds = targetMessageId + ? [targetMessageId] + : targetThreadRootId + ? [targetThreadRootId] + : []; void fetchRouteTargetEvents( eventIds, diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.lifecycle.test.mjs b/desktop/src/features/channels/ui/useChannelRouteTarget.lifecycle.test.mjs new file mode 100644 index 00000000000..0b274c58c2f --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.lifecycle.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { JSDOM } from "jsdom"; + +const dom = new JSDOM(""); +globalThis.window = dom.window; +globalThis.document = dom.window.document; +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, +}); +globalThis.HTMLElement = dom.window.HTMLElement; +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const React = await import("react"); +const { act } = React; +const { createRoot } = await import("react-dom/client"); +const { useChannelRouteTarget } = await import("./useChannelRouteTarget.ts"); + +const target = { + id: "target", + author: "alice", + body: "hello", + createdAt: 1, + depth: 0, + parentId: null, + rootId: null, + tags: [], + time: "now", +}; + +function Harness({ calls, threadRootId }) { + useChannelRouteTarget({ + activeChannel: { id: "channel", channelType: "stream" }, + activeChannelId: "channel", + closeAgentSession: () => calls.push("close-agent"), + requireThreadEditResolution: () => true, + setEditTargetId: () => {}, + setExpandedThreadReplyIds: () => {}, + setOpenThreadHeadId: (id) => calls.push(`open:${id}`), + setProfilePanelPubkey: () => {}, + setThreadReplyTargetId: () => {}, + setThreadScrollTargetId: () => {}, + targetMessageId: "target", + targetThreadRootId: threadRootId, + timelineMessages: [target], + }); + return null; +} + +test("the same top-level target can advance from timeline-only to open-thread", async () => { + const calls = []; + const root = createRoot(document.createElement("div")); + await act(async () => { + root.render(React.createElement(Harness, { calls, threadRootId: null })); + }); + assert.deepEqual(calls, []); + + await act(async () => { + root.render( + React.createElement(Harness, { calls, threadRootId: "target" }), + ); + }); + assert.deepEqual(calls, ["close-agent", "open:target"]); + await act(async () => root.unmount()); +}); diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs b/desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs index 6675f9bf5ee..d37a6fd1e76 100644 --- a/desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs @@ -29,6 +29,14 @@ test("top-level target without threadRootId stays in the main timeline", () => { }); }); +test("top-level target with a mismatched threadRootId stays in the timeline", () => { + const root = makeMessage(); + assert.deepEqual( + getRouteTargetPanelAction(root, "unrelated-root", byId(root)), + { kind: "main-timeline-only" }, + ); +}); + test("top-level target with an explicit threadRootId opens its thread panel", () => { const root = makeMessage(); assert.deepEqual(getRouteTargetPanelAction(root, root.id, byId(root)), { diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.ts b/desktop/src/features/channels/ui/useChannelRouteTarget.ts index 782797963c9..236199b4950 100644 --- a/desktop/src/features/channels/ui/useChannelRouteTarget.ts +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.ts @@ -72,7 +72,7 @@ export function getRouteTargetPanelAction( messageById: ReadonlyMap, ): RouteTargetPanelAction { if (!targetMessage.parentId) { - if (!targetThreadRootId) { + if (!targetThreadRootId || targetThreadRootId !== targetMessage.id) { return { kind: "main-timeline-only" }; } return { @@ -165,24 +165,15 @@ export function useChannelRouteTarget({ return; } - const targetKey = `${activeChannelId ?? "none"}:${targetMessageId}`; - if (handledThreadRouteTargetRef.current !== targetKey) { - handledThreadRouteTargetRef.current = null; - } - + const targetMessage = timelineMessageById.get(targetMessageId) ?? null; if ( - handledThreadRouteTargetRef.current === targetKey || + !targetMessage || !activeChannel || activeChannel.channelType === "forum" ) { return; } - const targetMessage = timelineMessageById.get(targetMessageId) ?? null; - if (!targetMessage) { - return; - } - const action = getRouteTargetPanelAction( targetMessage, targetThreadRootId, @@ -192,6 +183,19 @@ export function useChannelRouteTarget({ return; } + // Dedupe the complete normalized action, not just the selected row. The + // same top-level message can first arrive as a timeline-only target and + // later be re-targeted with a validated request to open its full thread. + const actionKey = + action.kind === "main-timeline-only" + ? action.kind + : `${action.kind}:${action.threadHeadId}:${action.replyTargetId}:${action.scrollTargetId ?? "none"}`; + const targetKey = `${activeChannelId ?? "none"}:${targetMessageId}:${actionKey}`; + if (handledThreadRouteTargetRef.current === targetKey) { + return; + } + handledThreadRouteTargetRef.current = null; + if (action.kind === "main-timeline-only") { // Top-level target with no requested thread: the main-timeline // scroll/highlight (mainTimelineTargetMessageId) is the whole diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index a9ddf883048..dfbe4bd390c 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -362,6 +362,7 @@ const MessageTimelineBase = React.forwardRef< splitPanelOpen: splitThreadPanelOpen, targetMessageId, virtualCancelBottomIntent: timelineVirtualizerApi?.cancelBottomIntent, + virtualScrollBy: timelineVirtualizerApi?.scrollBy, virtualScrollToMessage: timelineVirtualizerApi?.scrollToMessage, virtualScrollToBottom: timelineVirtualizerApi?.scrollToBottom, virtualSettleAtBottom: timelineVirtualizerApi?.settleAtBottom, diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 793c731761c..695bafa73e2 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -43,6 +43,7 @@ export type TimelineVirtualizerApi = { messageId: string, options?: { behavior?: ScrollBehavior }, ) => boolean; + scrollBy: (offset: number) => void; }; type TimelineMessageListProps = { @@ -681,6 +682,9 @@ function VirtualizedTimelineRows({ ); return true; }, + scrollBy(offset) { + listRef.current?.scrollBy(offset); + }, }; onVirtualizerApiChange(api); return () => onVirtualizerApiChange(null); diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs index 0e626fbe573..709db9dda63 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs +++ b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs @@ -148,6 +148,10 @@ function installDOMShim() { } installDOMShim(); +globalThis.getComputedStyle = () => ({ + fontSize: "16px", + getPropertyValue: () => "0px", +}); import React from "react"; import { act } from "react"; @@ -275,6 +279,10 @@ function VirtualScrollBehaviorHarness({ refs }) { messages: [{ id: "selected" }], scrollContainerRef: refs.scroller, virtualizerOwnsPrependAnchoring: true, + virtualScrollBy: (offset) => { + refs.scrollOffsets.push(offset); + refs.rowTop -= offset; + }, virtualScrollToMessage: (messageId, options) => { refs.targetJumps.push({ messageId, options }); return true; @@ -559,9 +567,9 @@ test("virtual search scrolling stays smooth only for an already-rendered target" scroller.getBoundingClientRect = () => ({ bottom: 400, top: 0 }); const row = { getBoundingClientRect: () => ({ - bottom: 290, + bottom: refs.rowTop + 40, height: 40, - top: 250, + top: refs.rowTop, }), }; scroller.querySelector = () => (rendered ? row : null); @@ -570,6 +578,8 @@ test("virtual search scrolling stays smooth only for an already-rendered target" const refs = { content: { current: content }, scroller: { current: scroller }, + rowTop: rendered ? 180 : 1_000, + scrollOffsets: [], targetJumps: [], targetResult: null, }; @@ -590,6 +600,55 @@ test("virtual search scrolling stays smooth only for an already-rendered target" } }); +test("virtual centering corrects rendered geometry on the following frame", async () => { + const previousGetComputedStyle = globalThis.getComputedStyle; + globalThis.getComputedStyle = (element) => ({ + fontSize: "16px", + getPropertyValue: (name) => + element === document.documentElement + ? "0px" + : name === "--composer-overlay-height" + ? "20px" + : "0px", + }); + const content = document.createElement("div"); + const scroller = document.createElement("div"); + scroller.clientHeight = 400; + scroller.scrollHeight = 1_000; + scroller.scrollTop = 0; + scroller.getBoundingClientRect = () => ({ bottom: 400, top: 0 }); + const refs = { + content: { current: content }, + scroller: { current: scroller }, + rowTop: 180, + scrollOffsets: [], + targetJumps: [], + targetResult: null, + }; + const row = { + getBoundingClientRect: () => ({ + bottom: refs.rowTop + 40, + height: 40, + top: refs.rowTop, + }), + }; + scroller.querySelector = () => row; + scroller.querySelectorAll = () => []; + scroller.appendChild(content); + const root = createRoot(document.createElement("div")); + + await act(async () => { + root.render(React.createElement(VirtualScrollBehaviorHarness, { refs })); + }); + assert.equal(refs.targetResult, "pending"); + await act(async () => new Promise((resolve) => setTimeout(resolve, 0))); + assert.deepEqual(refs.scrollOffsets, [10]); + assert.equal(refs.rowTop, 170); + + await act(async () => root.unmount()); + globalThis.getComputedStyle = previousGetComputedStyle; +}); + test("mounted virtual target retires bottom intent and delegates the jump to the virtualizer", async () => { const resizeObservers = []; globalThis.ResizeObserver = class { @@ -613,7 +672,7 @@ test("mounted virtual target retires bottom intent and delegates the jump to the scroller.scrollHeight = 1_000; scroller.scrollTop = 0; scroller.getBoundingClientRect = () => ({ bottom: 400, top: 0 }); - const targetContentTop = 250; + const targetContentTop = 180; const row = { getBoundingClientRect: () => ({ bottom: targetContentTop - scroller.scrollTop + 40, diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index ac9c7ae3227..99e752695e9 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -67,6 +67,7 @@ type UseAnchoredScrollOptions = { messageId: string, options?: { behavior?: ScrollBehavior }, ) => boolean; + virtualScrollBy?: (offset: number) => void; /** Imperative virtualizer-owned bottom jump, used only when virtualizer mode is active. */ virtualScrollToBottom?: (behavior?: ScrollBehavior) => void; virtualSettleAtBottom?: () => void; @@ -116,22 +117,54 @@ function isAtBottomNow( ); } +const CENTERED_ROW_TOLERANCE_PX = 2; + +function resolveCssLength(value: string) { + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed)) return 0; + return value.trim().endsWith("rem") + ? parsed * + Number.parseFloat(getComputedStyle(document.documentElement).fontSize) + : parsed; +} + +function getRowCenterOffset(row: Element, container: HTMLDivElement) { + const rowRect = row.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const styles = getComputedStyle(container); + const viewportTop = + containerRect.top + + resolveCssLength(styles.getPropertyValue("--channel-top-chrome-height")); + const viewportBottom = + containerRect.bottom - + resolveCssLength(styles.getPropertyValue("--composer-overlay-height")); + return ( + (rowRect.top + rowRect.bottom) / 2 - (viewportTop + viewportBottom) / 2 + ); +} + /** - * Whether a rendered row is as visible as it can be: fully inside the - * viewport, or — for a row taller than the viewport — covering it. One pixel - * of slack absorbs sub-pixel layout rounding. A zero-height row is a - * virtualized placeholder that has not been measured yet, never a settled one. + * A virtualized jump is complete only when the row's midpoint reaches the + * viewport midpoint. Two pixels absorb fractional layout and Virtua's rounded + * scroll offsets. The newest row is the one intentional exception: the list + * clamps it to the physical floor, where exact centering is impossible. */ -function isRowSettledInViewport(row: Element, container: Element) { +function isRowCenteredInViewport( + row: Element, + container: HTMLDivElement, + allowBottomClamp: boolean, +) { const rowRect = row.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); const rowHeight = rowRect.bottom - rowRect.top; if (rowHeight <= 0) return false; - const visible = - Math.min(rowRect.bottom, containerRect.bottom) - - Math.max(rowRect.top, containerRect.top); - const viewportHeight = containerRect.bottom - containerRect.top; - return visible >= Math.min(rowHeight, viewportHeight) - 1; + + if ( + Math.abs(getRowCenterOffset(row, container)) <= CENTERED_ROW_TOLERANCE_PX + ) { + return true; + } + + return allowBottomClamp && isAtBottomNow(container); } /** @@ -194,6 +227,7 @@ export function useAnchoredScroll({ onTargetReached, onTargetSettled, virtualCancelBottomIntent, + virtualScrollBy, virtualScrollToMessage, virtualScrollToBottom, virtualSettleAtBottom, @@ -242,6 +276,11 @@ export function useAnchoredScroll({ const isWritingScrollRef = React.useRef(false); const programmaticScrollRafRef = React.useRef(null); const targetSettleRafRef = React.useRef(null); + const targetRetryRafRef = React.useRef(null); + const virtualTargetJumpRef = React.useRef(null); + const virtualTargetCorrectionAppliedRef = React.useRef(false); + const targetCorrectionRafRef = React.useRef(null); + const [targetRetryVersion, setTargetRetryVersion] = React.useState(0); // Reset everything when the channel changes — the layout effect that runs // immediately after this reset is responsible for either jumping to bottom @@ -271,6 +310,16 @@ export function useAnchoredScroll({ cancelAnimationFrame(targetSettleRafRef.current); targetSettleRafRef.current = null; } + if (targetRetryRafRef.current !== null) { + cancelAnimationFrame(targetRetryRafRef.current); + targetRetryRafRef.current = null; + } + if (targetCorrectionRafRef.current !== null) { + cancelAnimationFrame(targetCorrectionRafRef.current); + targetCorrectionRafRef.current = null; + } + virtualTargetJumpRef.current = null; + virtualTargetCorrectionAppliedRef.current = false; if (highlightTimeoutRef.current !== null) { window.clearTimeout(highlightTimeoutRef.current); highlightTimeoutRef.current = null; @@ -483,12 +532,61 @@ export function useAnchoredScroll({ const virtualScrollBehavior = el ? (options.behavior ?? "auto") : "auto"; + const rowIsVisible = el + ? (() => { + const rowRect = el.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + return ( + rowRect.bottom > containerRect.top && + rowRect.top < containerRect.bottom + ); + })() + : false; + if (virtualTargetJumpRef.current !== messageId || !rowIsVisible) { + if ( + !virtualScrollToMessage(messageId, { + behavior: virtualScrollBehavior, + }) + ) { + return "missing"; + } + virtualTargetJumpRef.current = messageId; + virtualTargetCorrectionAppliedRef.current = false; + } if ( - !virtualScrollToMessage(messageId, { - behavior: virtualScrollBehavior, - }) + rowIsVisible && + !virtualTargetCorrectionAppliedRef.current && + targetCorrectionRafRef.current === null ) { - return "missing"; + // Virtua first realizes and centers by index. Once the target row is + // rendered, wait one more frame for its measured geometry to land, + // then compensate for chrome that overlays the usable viewport. + targetCorrectionRafRef.current = requestAnimationFrame(() => { + targetCorrectionRafRef.current = null; + const settledContainer = scrollContainerRef.current; + const settledRow = settledContainer?.querySelector( + `[data-message-id="${CSS.escape(messageId)}"]`, + ); + if (!settledContainer || !settledRow) return; + const rowRect = settledRow.getBoundingClientRect(); + const containerRect = settledContainer.getBoundingClientRect(); + // Virtua may mount the requested row before its indexed jump has + // placed that row in the viewport. Do not turn that transient, + // offscreen geometry into a multi-thousand-pixel correction. + if ( + rowRect.bottom <= containerRect.top || + rowRect.top >= containerRect.bottom + ) { + setTargetRetryVersion((version) => version + 1); + return; + } + const correction = getRowCenterOffset(settledRow, settledContainer); + if (Math.abs(correction) > CENTERED_ROW_TOLERANCE_PX) { + virtualScrollBy?.(correction); + } + virtualTargetCorrectionAppliedRef.current = true; + setTargetRetryVersion((version) => version + 1); + }); } anchorRef.current = { kind: "message", messageId, topOffset: 0 }; // The channel reset seeds `virtualizerAtBottomRef` to true as a @@ -499,7 +597,8 @@ export function useAnchoredScroll({ // virtualizer's next real bottom report refines it. // Handled only once the row is rendered and actually in view. Until // then the jump is in flight; the caller retries on range change. - if (!el || !isRowSettledInViewport(el, container)) { + const isNewestTarget = messages.at(-1)?.id === messageId; + if (!el || !isRowCenteredInViewport(el, container, isNewestTarget)) { virtualizerAtBottomRef.current = false; setIsAtBottom(false); return "pending"; @@ -508,6 +607,8 @@ export function useAnchoredScroll({ virtualizerAtBottomRef.current = atBottom; setIsAtBottom(atBottom); if (options.highlight) highlightMessage(messageId); + virtualTargetJumpRef.current = null; + virtualTargetCorrectionAppliedRef.current = false; return "centered"; } @@ -567,9 +668,11 @@ export function useAnchoredScroll({ }, [ highlightMessage, + messages, pinTargetCentered, scrollContainerRef, virtualCancelBottomIntent, + virtualScrollBy, virtualizerOwnsPrependAnchoring, writePinnedCenterScroll, virtualScrollToMessage, @@ -929,13 +1032,24 @@ export function useAnchoredScroll({ // `pending` (virtualizer mid-jump) and `missing` (row not spliced in yet) // both leave the target unhandled; the next `messages` or rendered-range // commit re-runs this effect and retries until the row is centered. - if ( - scrollToMessageImperative(targetMessageId, { - highlight: highlightTargetMessage, - }) === "centered" - ) { + const result = scrollToMessageImperative(targetMessageId, { + highlight: highlightTargetMessage, + }); + if (result === "centered") { + if (targetRetryRafRef.current !== null) { + cancelAnimationFrame(targetRetryRafRef.current); + targetRetryRafRef.current = null; + } handledTargetIdRef.current = targetMessageId; onTargetReached?.(targetMessageId); + } else if (result === "pending" && targetRetryRafRef.current === null) { + // Virtua can finish correcting measured row offsets without changing its + // rendered range. Retry on the next frame so completion observes the + // final geometry rather than depending on an unrelated React render. + targetRetryRafRef.current = requestAnimationFrame(() => { + targetRetryRafRef.current = null; + setTargetRetryVersion((version) => version + 1); + }); } }, [ highlightTargetMessage, @@ -945,6 +1059,7 @@ export function useAnchoredScroll({ releasePinnedCenter, scrollToMessageImperative, targetMessageId, + targetRetryVersion, virtualizerRenderVersion, ]); @@ -959,6 +1074,12 @@ export function useAnchoredScroll({ if (targetSettleRafRef.current !== null) { cancelAnimationFrame(targetSettleRafRef.current); } + if (targetRetryRafRef.current !== null) { + cancelAnimationFrame(targetRetryRafRef.current); + } + if (targetCorrectionRafRef.current !== null) { + cancelAnimationFrame(targetCorrectionRafRef.current); + } }; }, []); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 9002d751c8c..e485ac00ff6 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -4506,24 +4506,36 @@ function getMockMessageStore(channelId: string): RelayEvent[] { sig: "mocksig".repeat(20).slice(0, 128), })), ] - : channelId === "feedf00d-0000-4000-8000-000000000007" - ? (() => { - const count = getConfig()?.mock?.deepHistoryMessageCount ?? 600; - return Array.from({ length: count }, (_, index) => ({ - id: `mock-deep-history-${index}`, - pubkey: index % 2 === 0 ? ALICE_PUBKEY : MOCK_IDENTITY_PUBKEY, - created_at: - Math.floor(Date.now() / 1000) - (count - index) * 60, - kind: 9, - tags: [["h", channelId]], - content: - count > 600 - ? `Deep history message #${index}\n${"variable wrapped history ".repeat((index % 12) + 1)}` - : `Deep history message #${index}`, - sig: "mocksig".repeat(20).slice(0, 128), - })); - })() - : []; + : channelId === "f48efb06-0c93-5025-aac9-2e646bb6bfa8" + ? Array.from({ length: 80 }, (_, index) => ({ + id: `mock-alice-tyler-${index}`, + pubkey: index % 2 === 0 ? ALICE_PUBKEY : MOCK_IDENTITY_PUBKEY, + created_at: Math.floor(Date.now() / 1000) - (80 - index) * 60, + kind: 9, + tags: [["h", channelId]], + content: `Alice and Tyler message #${index}`, + sig: "mocksig".repeat(20).slice(0, 128), + })) + : channelId === "feedf00d-0000-4000-8000-000000000007" + ? (() => { + const count = + getConfig()?.mock?.deepHistoryMessageCount ?? 600; + return Array.from({ length: count }, (_, index) => ({ + id: `mock-deep-history-${index}`, + pubkey: + index % 2 === 0 ? ALICE_PUBKEY : MOCK_IDENTITY_PUBKEY, + created_at: + Math.floor(Date.now() / 1000) - (count - index) * 60, + kind: 9, + tags: [["h", channelId]], + content: + count > 600 + ? `Deep history message #${index}\n${"variable wrapped history ".repeat((index % 12) + 1)}` + : `Deep history message #${index}`, + sig: "mocksig".repeat(20).slice(0, 128), + })); + })() + : []; mockMessages.set(channelId, seeded); return seeded; diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index d2ffc10715c..7e9d4a9b0cd 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -663,6 +663,7 @@ const DEEP_HISTORY_CHANNEL_ID = "feedf00d-0000-4000-8000-000000000007"; async function expectColdDeepLinkLandsOnTarget( page: import("@playwright/test").Page, messageId: string, + { expectCentered = true }: { expectCentered?: boolean } = {}, ) { await page.goto("/"); await expect(page.getByTestId("home-inbox-list")).toBeVisible(); @@ -679,6 +680,43 @@ async function expectColdDeepLinkLandsOnTarget( // The highlight is applied only once the hook has seen the row settled in // the viewport, so it is the strongest signal that the jump completed. It // fades after 2s — assert it before anything that can wait on layout. + if (expectCentered) { + await expect + .poll(() => + targetRow.evaluate((row) => { + const timeline = row.closest('[data-testid="message-timeline"]'); + if (!timeline) return Number.POSITIVE_INFINITY; + const rowRect = row.getBoundingClientRect(); + const timelineRect = timeline.getBoundingClientRect(); + const styles = getComputedStyle(timeline); + const rootFontSize = Number.parseFloat( + getComputedStyle(document.documentElement).fontSize, + ); + const resolveLength = (value: string) => { + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed)) return 0; + return value.trim().endsWith("rem") + ? parsed * rootFontSize + : parsed; + }; + const topInset = resolveLength( + styles.getPropertyValue("--channel-top-chrome-height"), + ); + const bottomInset = resolveLength( + styles.getPropertyValue("--composer-overlay-height"), + ); + return Math.abs( + (rowRect.top + rowRect.bottom) / 2 - + (timelineRect.top + + topInset + + timelineRect.bottom - + bottomInset) / + 2, + ); + }), + ) + .toBeLessThanOrEqual(2); + } await expect(targetRow).toHaveClass(/route-target-highlight-fade/); await expect(targetRow).toBeInViewport(); // Top-level targets resolve in the main timeline only. @@ -699,18 +737,37 @@ async function expectColdDeepLinkLandsOnTarget( test("cold deep link to a message in virtualized history scrolls to and highlights it", async ({ page, }) => { - // Index 450 is inside the cold-load window but ~150 rows above the bottom, - // so it is not in view when the timeline mounts — the jump must win over - // the list's own first-commit bottom settle and the mount-time resize pass. - await expectColdDeepLinkLandsOnTarget(page, "mock-deep-history-450"); + // Index 500 is inside the cold-load window and ~100 rows above the bottom, + // so it is neither initially rendered nor boundary-clamped. The jump must + // win over the list's own first-commit bottom settle and land at midpoint. + await expectColdDeepLinkLandsOnTarget(page, "mock-deep-history-500"); +}); + +test("cold deep link to a top-level DM message stays in the timeline", async ({ + page, +}) => { + const dmChannelId = "f48efb06-0c93-5025-aac9-2e646bb6bfa8"; + const messageId = "mock-alice-tyler-40"; + await page.goto(`/#/channels/${dmChannelId}?messageId=${messageId}`); + await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); + + const targetRow = page + .getByTestId("message-timeline") + .locator(`[data-message-id="${messageId}"]`); + await expect(targetRow).toHaveClass(/route-target-highlight-fade/); + await expect(targetRow).toContainText("Alice and Tyler message #40"); + await expect(page.getByTestId("message-thread-panel")).not.toBeVisible(); + await expect(page).toHaveURL(new RegExp(`#/channels/${dmChannelId}$`)); }); test("cold deep link to the newest message highlights it at the bottom", async ({ page, }) => { - // The DM case: the target is the last message. It must still highlight, - // and the view stays at the floor. - await expectColdDeepLinkLandsOnTarget(page, "mock-deep-history-599"); + // The boundary-clamped case: the target is the last message. It must still + // highlight, and the view stays at the floor instead of centering. + await expectColdDeepLinkLandsOnTarget(page, "mock-deep-history-599", { + expectCentered: false, + }); }); test("direct-message tooltip metadata stays on one physical line", async ({ From be4e2e34e93549c9265fde7dc661bd38d8ef427a Mon Sep 17 00:00:00 2001 From: Rivet Date: Mon, 24 Aug 2026 13:54:59 -0700 Subject: [PATCH 08/15] Satisfy desktop file size policy Extract anchored-scroll contracts and target-centering geometry so routed navigation remains within the desktop source-size ratchet. Co-authored-by: Rivet Signed-off-by: Rivet --- .../features/channels/ui/ChannelScreen.tsx | 8 +- .../messages/ui/anchoredScrollTypes.ts | 47 +++++ .../messages/ui/targetRowCentering.ts | 55 ++++++ .../features/messages/ui/useAnchoredScroll.ts | 160 +++--------------- 4 files changed, 125 insertions(+), 145 deletions(-) create mode 100644 desktop/src/features/messages/ui/anchoredScrollTypes.ts create mode 100644 desktop/src/features/messages/ui/targetRowCentering.ts diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index dddcd201395..38c8321024d 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -89,6 +89,7 @@ import { useChannelUnreadState } from "./useChannelUnreadState"; import type { ChannelScreenProps } from "./ChannelScreen.types"; import { GuardedChannelPane } from "./GuardedChannelPane"; import { useNavigationGuard } from "./useNavigationGuard"; + const EMPTY_RELAY_EVENTS: RelayEvent[] = []; export function ChannelScreen({ activeChannel, @@ -101,7 +102,7 @@ export function ChannelScreen({ targetForumReplyId, targetMessageEvents, targetMessageId, - targetThreadRootId, + ...routeTargets }: ChannelScreenProps) { const queryClient = useQueryClient(); const { goHome } = useAppNavigation(); @@ -633,9 +634,6 @@ export function ChannelScreen({ isPlaceholderData: messagesQuery.isPlaceholderData, dataLength: messagesQuery.data?.length ?? null, }, - // A persisted head only counts as hydrated when it has rows to paint - // (channelHeadCache.ts), so this bypass never settles onto an empty - // placeholder while the authoritative refresh is still in flight. hasSettledThisChannel || (activeChannelId !== null && hasPersistedHydratedChannel(queryClient, activeChannelId)), @@ -673,7 +671,7 @@ export function ChannelScreen({ setThreadReplyTargetId, setThreadScrollTargetId, targetMessageId, - targetThreadRootId, + targetThreadRootId: routeTargets.targetThreadRootId, timelineMessages, }); useThreadTargetSync({ diff --git a/desktop/src/features/messages/ui/anchoredScrollTypes.ts b/desktop/src/features/messages/ui/anchoredScrollTypes.ts new file mode 100644 index 00000000000..cc192fb79ad --- /dev/null +++ b/desktop/src/features/messages/ui/anchoredScrollTypes.ts @@ -0,0 +1,47 @@ +import type * as React from "react"; + +export type AnchorState = + | { kind: "at-bottom" } + | { kind: "message"; messageId: string; topOffset: number } + | { kind: "pinned-center"; messageId: string; contentTop: number }; + +export type ScrollToMessageResult = "centered" | "pending" | "missing"; + +export type UseAnchoredScrollOptions = { + scrollContainerRef: React.RefObject; + contentRef: React.RefObject; + channelId?: string | null; + isLoading: boolean; + messages: Array<{ id: string }>; + splitPanelOpen?: boolean; + targetMessageId?: string | null; + highlightTargetMessage?: boolean; + pinTargetCentered?: boolean; + onTargetReached?: (messageId: string) => void; + onTargetSettled?: (messageId: string) => void; + virtualCancelBottomIntent?: () => void; + virtualScrollToMessage?: ( + messageId: string, + options?: { behavior?: ScrollBehavior }, + ) => boolean; + virtualScrollBy?: (offset: number) => void; + virtualScrollToBottom?: (behavior?: ScrollBehavior) => void; + virtualSettleAtBottom?: () => void; + virtualizerOwnsPrependAnchoring?: boolean; + virtualizerRenderVersion?: number; +}; + +export type UseAnchoredScrollResult = { + onScroll: () => void; + isAtBottom: boolean; + newMessageCount: number; + highlightedMessageId: string | null; + scrollToBottom: (behavior?: ScrollBehavior) => void; + settleAtBottomAfterLayout: () => boolean; + scrollToBottomOnNextUpdate: () => void; + scrollToMessage: ( + messageId: string, + options?: { highlight?: boolean; behavior?: ScrollBehavior }, + ) => ScrollToMessageResult; + onVirtualizerAtBottomStateChange: (atBottom: boolean) => void; +}; diff --git a/desktop/src/features/messages/ui/targetRowCentering.ts b/desktop/src/features/messages/ui/targetRowCentering.ts new file mode 100644 index 00000000000..e949e32bf25 --- /dev/null +++ b/desktop/src/features/messages/ui/targetRowCentering.ts @@ -0,0 +1,55 @@ +const CENTERED_ROW_TOLERANCE_PX = 2; + +function resolveCssLength(value: string) { + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed)) return 0; + return value.trim().endsWith("rem") + ? parsed * + Number.parseFloat(getComputedStyle(document.documentElement).fontSize) + : parsed; +} + +export function getTargetRowCenterOffset( + row: Element, + container: HTMLDivElement, +) { + const rowRect = row.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const styles = getComputedStyle(container); + const viewportTop = + containerRect.top + + resolveCssLength(styles.getPropertyValue("--channel-top-chrome-height")); + const viewportBottom = + containerRect.bottom - + resolveCssLength(styles.getPropertyValue("--composer-overlay-height")); + return ( + (rowRect.top + rowRect.bottom) / 2 - (viewportTop + viewportBottom) / 2 + ); +} + +/** + * A virtualized jump is complete only when the row's midpoint reaches the + * viewport midpoint. Two pixels absorb fractional layout and Virtua's rounded + * scroll offsets. The newest row is the one intentional exception: the list + * clamps it to the physical floor, where exact centering is impossible. + */ +export function isTargetRowCentered( + row: Element, + container: HTMLDivElement, + allowBottomClamp: boolean, + isAtBottom: (container: HTMLDivElement) => boolean, +) { + const rowRect = row.getBoundingClientRect(); + if (rowRect.bottom - rowRect.top <= 0) return false; + if ( + Math.abs(getTargetRowCenterOffset(row, container)) <= + CENTERED_ROW_TOLERANCE_PX + ) { + return true; + } + return allowBottomClamp && isAtBottom(container); +} + +export function targetRowNeedsCenterCorrection(offset: number) { + return Math.abs(offset) > CENTERED_ROW_TOLERANCE_PX; +} diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 99e752695e9..c1d3f9ea34c 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -8,6 +8,17 @@ import { shouldSettleForSplitPanel, shouldSettleVirtualizedBottom, } from "./anchoredScrollPolicy"; +import { + getTargetRowCenterOffset, + isTargetRowCentered, + targetRowNeedsCenterCorrection, +} from "./targetRowCentering"; +import type { + AnchorState, + ScrollToMessageResult, + UseAnchoredScrollOptions, + UseAnchoredScrollResult, +} from "./anchoredScrollTypes"; import { useVirtualizedViewportResize } from "./useVirtualizedViewportResize"; /** @@ -18,93 +29,6 @@ import { useVirtualizedViewportResize } from "./useVirtualizedViewportResize"; */ const AT_BOTTOM_THRESHOLD_PX = 32; -type AnchorState = - | { kind: "at-bottom" } - | { kind: "message"; messageId: string; topOffset: number } - | { kind: "pinned-center"; messageId: string; contentTop: number }; - -/** - * Outcome of an imperative scroll-to-message. - * - * - `centered` — the row is in the DOM and was centered (and highlighted when - * asked). The target is handled. - * - `pending` — the row is in the list but not rendered yet; the virtualizer - * accepted the jump and the row will commit in a later render. The caller - * must neither treat the target as handled nor move the viewport elsewhere - * (a bottom pin here would override the jump) — retry when the rendered - * range changes. - * - `missing` — the row is not in the list at all (not loaded, or not yet - * spliced in by the route screen). Retry when `messages` changes. - */ -export type ScrollToMessageResult = "centered" | "pending" | "missing"; - -type UseAnchoredScrollOptions = { - /** Scroll container. Owned by the parent so external refs still compose. */ - scrollContainerRef: React.RefObject; - /** Inner content element — must wrap every renderable row, including the - * sentinel and bottom anchor. Used to schedule layout work on resize. */ - contentRef: React.RefObject; - /** Resets when changed; lets us drop anchor + scroll state across channels. */ - channelId?: string | null; - /** Suppresses initial scroll-to-bottom while a skeleton is showing. */ - 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 }>; - splitPanelOpen?: boolean; - - /** When set, scroll to this message on mount and on change. */ - targetMessageId?: string | null; - /** Whether a targeted message should pulse after scrolling to it. */ - highlightTargetMessage?: boolean; - /** Keeps a targeted message centered until the user deliberately scrolls. */ - pinTargetCentered?: boolean; - onTargetReached?: (messageId: string) => void; - /** Reports a pinned target after resize correction and one paint frame. */ - onTargetSettled?: (messageId: string) => void; - virtualCancelBottomIntent?: () => void; - virtualScrollToMessage?: ( - messageId: string, - options?: { behavior?: ScrollBehavior }, - ) => boolean; - virtualScrollBy?: (offset: number) => void; - /** Imperative virtualizer-owned bottom jump, used only when virtualizer mode is active. */ - virtualScrollToBottom?: (behavior?: ScrollBehavior) => void; - virtualSettleAtBottom?: () => void; - /** When active, the virtualizer owns prepend compensation and bottom-state synchronization. */ - virtualizerOwnsPrependAnchoring?: boolean; - /** Bumps when a virtualized range changes, so pending target/search retries can re-check newly mounted DOM. */ - virtualizerRenderVersion?: number; -}; - -type UseAnchoredScrollResult = { - /** Pass through to the scroll container's `onScroll`. */ - onScroll: () => void; - /** True when the user is within `AT_BOTTOM_THRESHOLD_PX` of the bottom. */ - isAtBottom: boolean; - /** Number of new messages that have arrived while the user is not at the - * bottom. Cleared when the user returns to the bottom. */ - newMessageCount: number; - /** Message id that should pulse a highlight (target/active-search). */ - highlightedMessageId: string | null; - /** Imperative: scroll to bottom. */ - scrollToBottom: (behavior?: ScrollBehavior) => void; - /** Re-pins after a layout owner changes trailing geometry. Returns true when - * the hook handled the settlement, including a preserved pinned target. */ - settleAtBottomAfterLayout: () => boolean; - /** Arm a one-shot scroll-to-bottom that fires on the next appended message - * (used by the composer's send flow). */ - scrollToBottomOnNextUpdate: () => void; - /** Imperative: scroll a specific message into view; optionally pulse it. - * See {@link ScrollToMessageResult} for what each outcome means. */ - scrollToMessage: ( - messageId: string, - options?: { highlight?: boolean; behavior?: ScrollBehavior }, - ) => ScrollToMessageResult; - /** Syncs the hook's bottom affordances from a virtualizer-owned scroller. */ - onVirtualizerAtBottomStateChange: (atBottom: boolean) => void; -}; - function isAtBottomNow( container: Pick< HTMLDivElement, @@ -117,56 +41,6 @@ function isAtBottomNow( ); } -const CENTERED_ROW_TOLERANCE_PX = 2; - -function resolveCssLength(value: string) { - const parsed = Number.parseFloat(value); - if (!Number.isFinite(parsed)) return 0; - return value.trim().endsWith("rem") - ? parsed * - Number.parseFloat(getComputedStyle(document.documentElement).fontSize) - : parsed; -} - -function getRowCenterOffset(row: Element, container: HTMLDivElement) { - const rowRect = row.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - const styles = getComputedStyle(container); - const viewportTop = - containerRect.top + - resolveCssLength(styles.getPropertyValue("--channel-top-chrome-height")); - const viewportBottom = - containerRect.bottom - - resolveCssLength(styles.getPropertyValue("--composer-overlay-height")); - return ( - (rowRect.top + rowRect.bottom) / 2 - (viewportTop + viewportBottom) / 2 - ); -} - -/** - * A virtualized jump is complete only when the row's midpoint reaches the - * viewport midpoint. Two pixels absorb fractional layout and Virtua's rounded - * scroll offsets. The newest row is the one intentional exception: the list - * clamps it to the physical floor, where exact centering is impossible. - */ -function isRowCenteredInViewport( - row: Element, - container: HTMLDivElement, - allowBottomClamp: boolean, -) { - const rowRect = row.getBoundingClientRect(); - const rowHeight = rowRect.bottom - rowRect.top; - if (rowHeight <= 0) return false; - - if ( - Math.abs(getRowCenterOffset(row, container)) <= CENTERED_ROW_TOLERANCE_PX - ) { - return true; - } - - return allowBottomClamp && isAtBottomNow(container); -} - /** * Pick an anchor for the current scroll position. * @@ -580,8 +454,11 @@ export function useAnchoredScroll({ setTargetRetryVersion((version) => version + 1); return; } - const correction = getRowCenterOffset(settledRow, settledContainer); - if (Math.abs(correction) > CENTERED_ROW_TOLERANCE_PX) { + const correction = getTargetRowCenterOffset( + settledRow, + settledContainer, + ); + if (targetRowNeedsCenterCorrection(correction)) { virtualScrollBy?.(correction); } virtualTargetCorrectionAppliedRef.current = true; @@ -598,7 +475,10 @@ export function useAnchoredScroll({ // Handled only once the row is rendered and actually in view. Until // then the jump is in flight; the caller retries on range change. const isNewestTarget = messages.at(-1)?.id === messageId; - if (!el || !isRowCenteredInViewport(el, container, isNewestTarget)) { + if ( + !el || + !isTargetRowCentered(el, container, isNewestTarget, isAtBottomNow) + ) { virtualizerAtBottomRef.current = false; setIsAtBottom(false); return "pending"; From 4916232e084b7e23334b7fcccfb5d58119e54a62 Mon Sep 17 00:00:00 2001 From: Rivet Date: Mon, 24 Aug 2026 14:36:44 -0700 Subject: [PATCH 09/15] Stabilize virtualized route target settlement Co-authored-by: Rivet Signed-off-by: Rivet --- .../features/messages/ui/MessageTimeline.tsx | 1 + .../messages/ui/anchoredScrollTypes.ts | 1 + .../messages/ui/targetRowCentering.ts | 37 ++++--- .../ui/useAnchoredScroll.lifecycle.test.mjs | 97 +++++++++++++++++-- .../features/messages/ui/useAnchoredScroll.ts | 49 ++++++---- 5 files changed, 146 insertions(+), 39 deletions(-) diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index dfbe4bd390c..e47d3f77d01 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -361,6 +361,7 @@ const MessageTimelineBase = React.forwardRef< scrollContainerRef: activeScrollContainerRef, splitPanelOpen: splitThreadPanelOpen, targetMessageId, + topBoundaryReached: renderedHistoryExhausted, virtualCancelBottomIntent: timelineVirtualizerApi?.cancelBottomIntent, virtualScrollBy: timelineVirtualizerApi?.scrollBy, virtualScrollToMessage: timelineVirtualizerApi?.scrollToMessage, diff --git a/desktop/src/features/messages/ui/anchoredScrollTypes.ts b/desktop/src/features/messages/ui/anchoredScrollTypes.ts index cc192fb79ad..9867008f7d6 100644 --- a/desktop/src/features/messages/ui/anchoredScrollTypes.ts +++ b/desktop/src/features/messages/ui/anchoredScrollTypes.ts @@ -17,6 +17,7 @@ export type UseAnchoredScrollOptions = { targetMessageId?: string | null; highlightTargetMessage?: boolean; pinTargetCentered?: boolean; + topBoundaryReached?: boolean; onTargetReached?: (messageId: string) => void; onTargetSettled?: (messageId: string) => void; virtualCancelBottomIntent?: () => void; diff --git a/desktop/src/features/messages/ui/targetRowCentering.ts b/desktop/src/features/messages/ui/targetRowCentering.ts index e949e32bf25..a078f7383ad 100644 --- a/desktop/src/features/messages/ui/targetRowCentering.ts +++ b/desktop/src/features/messages/ui/targetRowCentering.ts @@ -9,34 +9,41 @@ function resolveCssLength(value: string) { : parsed; } +function getUsableViewportBounds(container: HTMLDivElement) { + const containerRect = container.getBoundingClientRect(); + const styles = getComputedStyle(container); + return { + bottom: + containerRect.bottom - + resolveCssLength(styles.getPropertyValue("--composer-overlay-height")), + top: + containerRect.top + + resolveCssLength(styles.getPropertyValue("--channel-top-chrome-height")), + }; +} + export function getTargetRowCenterOffset( row: Element, container: HTMLDivElement, ) { const rowRect = row.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - const styles = getComputedStyle(container); - const viewportTop = - containerRect.top + - resolveCssLength(styles.getPropertyValue("--channel-top-chrome-height")); - const viewportBottom = - containerRect.bottom - - resolveCssLength(styles.getPropertyValue("--composer-overlay-height")); + const viewport = getUsableViewportBounds(container); return ( - (rowRect.top + rowRect.bottom) / 2 - (viewportTop + viewportBottom) / 2 + (rowRect.top + rowRect.bottom) / 2 - (viewport.top + viewport.bottom) / 2 ); } /** * A virtualized jump is complete only when the row's midpoint reaches the * viewport midpoint. Two pixels absorb fractional layout and Virtua's rounded - * scroll offsets. The newest row is the one intentional exception: the list - * clamps it to the physical floor, where exact centering is impossible. + * scroll offsets. Boundary rows are the intentional exceptions: the list + * clamps the oldest row to the physical ceiling and the newest row to the + * physical floor, where exact centering is impossible. */ export function isTargetRowCentered( row: Element, container: HTMLDivElement, - allowBottomClamp: boolean, + boundary: "none" | "top" | "bottom", isAtBottom: (container: HTMLDivElement) => boolean, ) { const rowRect = row.getBoundingClientRect(); @@ -47,7 +54,11 @@ export function isTargetRowCentered( ) { return true; } - return allowBottomClamp && isAtBottom(container); + const viewport = getUsableViewportBounds(container); + const rowIsVisible = + rowRect.bottom > viewport.top && rowRect.top < viewport.bottom; + if (boundary === "top") return rowIsVisible && container.scrollTop <= 0; + return boundary === "bottom" && rowIsVisible && isAtBottom(container); } export function targetRowNeedsCenterCorrection(offset: number) { diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs index 709db9dda63..d7ff4fa3de5 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs +++ b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs @@ -157,6 +157,7 @@ import React from "react"; import { act } from "react"; import { createRoot } from "react-dom/client"; +import { isTargetRowCentered } from "./targetRowCentering.ts"; import { useAnchoredScroll } from "./useAnchoredScroll.ts"; import { useVirtualizedBottomSettle } from "./useVirtualizedBottomSettle.ts"; @@ -270,13 +271,16 @@ function BottomStateHarness({ return null; } -function VirtualScrollBehaviorHarness({ refs }) { - const didRun = React.useRef(false); +function VirtualScrollBehaviorHarness({ + messages = [{ id: "selected" }], + refs, +}) { + const lastRunMessageCount = React.useRef(null); const anchored = useAnchoredScroll({ channelId: "conversation", contentRef: refs.content, isLoading: false, - messages: [{ id: "selected" }], + messages, scrollContainerRef: refs.scroller, virtualizerOwnsPrependAnchoring: true, virtualScrollBy: (offset) => { @@ -289,12 +293,12 @@ function VirtualScrollBehaviorHarness({ refs }) { }, }); React.useLayoutEffect(() => { - if (didRun.current) return; - didRun.current = true; + if (lastRunMessageCount.current === messages.length) return; + lastRunMessageCount.current = messages.length; refs.targetResult = anchored.scrollToMessage("selected", { behavior: "smooth", }); - }, [anchored.scrollToMessage, refs]); + }, [anchored.scrollToMessage, messages.length, refs]); return null; } @@ -557,6 +561,42 @@ test("user interaction releases and retires a pending pinned target", async () = await act(async () => root.unmount()); }); +test("boundary-clamped targets settle only at their matching physical edge", () => { + const container = document.createElement("div"); + container.scrollTop = 0; + container.getBoundingClientRect = () => ({ bottom: 400, top: 0 }); + const row = { + getBoundingClientRect: () => ({ bottom: 40, height: 40, top: 0 }), + }; + const isAtBottom = () => false; + + assert.equal(isTargetRowCentered(row, container, "top", isAtBottom), true); + assert.equal(isTargetRowCentered(row, container, "none", isAtBottom), false); + assert.equal( + isTargetRowCentered(row, container, "bottom", isAtBottom), + false, + ); + + row.getBoundingClientRect = () => ({ + bottom: 2_740, + height: 40, + top: 2_700, + }); + assert.equal( + isTargetRowCentered(row, container, "top", isAtBottom), + false, + "an unrendered indexed jump can report scrollTop zero before the row arrives", + ); + + row.getBoundingClientRect = () => ({ bottom: 40, height: 40, top: 0 }); + container.scrollTop = 1; + assert.equal(isTargetRowCentered(row, container, "top", isAtBottom), false); + assert.equal( + isTargetRowCentered(row, container, "bottom", () => true), + true, + ); +}); + test("virtual search scrolling stays smooth only for an already-rendered target", async () => { for (const rendered of [true, false]) { const content = document.createElement("div"); @@ -600,6 +640,51 @@ test("virtual search scrolling stays smooth only for an already-rendered target" } }); +test("a pending virtual jump is retried when the indexed message model grows", async () => { + const content = document.createElement("div"); + const scroller = document.createElement("div"); + scroller.clientHeight = 400; + scroller.scrollHeight = 1_000; + scroller.scrollTop = 0; + scroller.getBoundingClientRect = () => ({ bottom: 400, top: 0 }); + scroller.querySelector = () => null; + scroller.querySelectorAll = () => []; + scroller.appendChild(content); + const refs = { + content: { current: content }, + scroller: { current: scroller }, + rowTop: 1_000, + scrollOffsets: [], + targetJumps: [], + targetResult: null, + }; + const root = createRoot(document.createElement("div")); + + await act(async () => { + root.render( + React.createElement(VirtualScrollBehaviorHarness, { + messages: [{ id: "selected" }], + refs, + }), + ); + }); + await act(async () => { + root.render( + React.createElement(VirtualScrollBehaviorHarness, { + messages: [{ id: "selected" }, { id: "later" }], + refs, + }), + ); + }); + + assert.equal(refs.targetJumps.length, 2); + assert.deepEqual( + refs.targetJumps.map(({ messageId }) => messageId), + ["selected", "selected"], + ); + await act(async () => root.unmount()); +}); + test("virtual centering corrects rendered geometry on the following frame", async () => { const previousGetComputedStyle = globalThis.getComputedStyle; globalThis.getComputedStyle = (element) => ({ diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index c1d3f9ea34c..f307ce6ae3a 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -98,6 +98,7 @@ export function useAnchoredScroll({ targetMessageId = null, highlightTargetMessage = true, pinTargetCentered = false, + topBoundaryReached = false, onTargetReached, onTargetSettled, virtualCancelBottomIntent, @@ -151,7 +152,10 @@ export function useAnchoredScroll({ const programmaticScrollRafRef = React.useRef(null); const targetSettleRafRef = React.useRef(null); const targetRetryRafRef = React.useRef(null); - const virtualTargetJumpRef = React.useRef(null); + const virtualTargetJumpRef = React.useRef<{ + messageId: string; + messageCount: number; + } | null>(null); const virtualTargetCorrectionAppliedRef = React.useRef(false); const targetCorrectionRafRef = React.useRef(null); const [targetRetryVersion, setTargetRetryVersion] = React.useState(0); @@ -394,14 +398,11 @@ export function useAnchoredScroll({ const el = container.querySelector( `[data-message-id="${messageId}"]`, ); + if (virtualizerOwnsPrependAnchoring && !virtualScrollToMessage) + return "pending"; // Wait for Virtua's imperative API. if (virtualizerOwnsPrependAnchoring && virtualScrollToMessage) { - // The virtualizer is the only scroll writer here. Its imperative jump - // keeps correcting `scrollTop` for several frames while unmeasured - // rows commit, so a direct `container.scrollTo` would be overwritten — - // the list's own first-commit bottom settle is exactly such a jump. A - // new jump cancels the pending one, which is how target navigation - // takes the viewport away from that settle; disarming the durable - // bottom intent stops the ResizeObserver from re-pinning later. + // Virtua is the sole scroll writer; a new jump cancels its initial + // bottom settle and the cancel call prevents later re-pinning. virtualCancelBottomIntent?.(); const virtualScrollBehavior = el ? (options.behavior ?? "auto") @@ -416,7 +417,10 @@ export function useAnchoredScroll({ ); })() : false; - if (virtualTargetJumpRef.current !== messageId || !rowIsVisible) { + const jumpMatchesCurrentModel = + virtualTargetJumpRef.current?.messageId === messageId && + virtualTargetJumpRef.current.messageCount === messages.length; + if (!jumpMatchesCurrentModel) { if ( !virtualScrollToMessage(messageId, { behavior: virtualScrollBehavior, @@ -424,7 +428,10 @@ export function useAnchoredScroll({ ) { return "missing"; } - virtualTargetJumpRef.current = messageId; + virtualTargetJumpRef.current = { + messageId, + messageCount: messages.length, + }; virtualTargetCorrectionAppliedRef.current = false; } if ( @@ -466,18 +473,19 @@ export function useAnchoredScroll({ }); } anchorRef.current = { kind: "message", messageId, topOffset: 0 }; - // The channel reset seeds `virtualizerAtBottomRef` to true as a - // default, and both the viewport-resize and append settles trust it. - // A ResizeObserver fires once on observe, so leaving that default in - // place lets the first resize callback after this jump pull the view - // straight back to the floor. Record what the jump knows instead; the - // virtualizer's next real bottom report refines it. - // Handled only once the row is rendered and actually in view. Until - // then the jump is in flight; the caller retries on range change. - const isNewestTarget = messages.at(-1)?.id === messageId; + // Completion requires midpoint alignment or a confirmed boundary. + const targetIndex = messages.findIndex( + (message) => message.id === messageId, + ); + const targetBoundary = + targetIndex === 0 && topBoundaryReached + ? "top" + : targetIndex === messages.length - 1 + ? "bottom" + : "none"; if ( !el || - !isTargetRowCentered(el, container, isNewestTarget, isAtBottomNow) + !isTargetRowCentered(el, container, targetBoundary, isAtBottomNow) ) { virtualizerAtBottomRef.current = false; setIsAtBottom(false); @@ -550,6 +558,7 @@ export function useAnchoredScroll({ highlightMessage, messages, pinTargetCentered, + topBoundaryReached, scrollContainerRef, virtualCancelBottomIntent, virtualScrollBy, From aa2d43075d2a6f7d357c55356c0b3e2a0f99ce6b Mon Sep 17 00:00:00 2001 From: Rivet Date: Mon, 24 Aug 2026 15:39:12 -0700 Subject: [PATCH 10/15] Fix root link navigation smoke coverage Co-authored-by: Rivet Signed-off-by: Rivet --- desktop/tests/e2e/navigation.spec.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index 7e9d4a9b0cd..ac81e48f8c2 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -635,21 +635,6 @@ test("message links to visible root messages highlight them in the main timeline }), ) .toBe(link); - - await rootThreadLink.click({ button: "right" }); - await linkMenu.getByRole("button", { name: "Open link" }).click(); - - // Root-message links resolve in the main timeline: scroll + highlight the - // root, never force-open its (possibly empty) reply panel (block/buzz — - // "inbox deep links open an empty thread for top-level messages"). - const threadPanel = page.getByTestId("message-thread-panel"); - await expect(threadPanel).not.toBeVisible(); - await expect(page).not.toHaveURL(/thread=/); - const welcomeRow = page - .getByTestId("message-timeline") - .locator('[data-message-id="mock-general-welcome"]'); - await expect(welcomeRow).toBeVisible(); - await expect(welcomeRow).toHaveClass(/route-target-highlight-fade/); }); // Cold deep links arrive from outside the channel (Home inbox "Open in From 1bbdb515e48f7632606891fbe4dbdc372bd2f396 Mon Sep 17 00:00:00 2001 From: Rivet Date: Mon, 24 Aug 2026 16:14:53 -0700 Subject: [PATCH 11/15] test(desktop): align routed root navigation assertions Co-authored-by: Rivet Signed-off-by: Rivet --- desktop/tests/e2e/messaging.spec.ts | 14 ++++++-------- desktop/tests/e2e/navigation.spec.ts | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index d24c2744133..683d979a7fe 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -3668,12 +3668,8 @@ test("a refused message deep link retries after the thread edit is canceled", as const routedDestination = page .getByTestId("message-timeline") .locator(`[data-message-id="${destinationId}"]`); - await expect(threadPanel).toBeVisible(); - await expect(threadPanel.getByTestId("message-thread-head")).toContainText( - destinationRoot, - ); + await expect(threadPanel).not.toBeVisible(); await expect(routedDestination).toBeVisible(); - await expect(routedDestination).toHaveClass(/route-target-highlight-fade/); }); test("a refused sent-from-thread link preserves the edit and retries after cancel", async ({ @@ -3828,10 +3824,12 @@ test("a refused search result preserves the edit and retries after cancel", asyn await page.getByTestId("search-dialog-input").fill(destinationRoot); await destinationResult.click(); await expect(page).not.toHaveURL(threadUrl); - await expect(threadPanel.getByTestId("message-thread-head")).toContainText( - destinationRoot, + const routedDestination = timeline.locator( + `[data-message-id="${destinationRootId}"]`, ); - await expect(page).toHaveURL(new RegExp(`thread=${destinationRootId}`)); + await expect(threadPanel).not.toBeVisible(); + await expect(routedDestination).toBeVisible(); + await expect(routedDestination).toHaveClass(/route-target-highlight-fade/); }); test("a refused forum search result preserves the edit and retries after cancel", async ({ diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index 4cac802bf4b..fe023c943cf 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -791,7 +791,7 @@ test("message links to visible root messages highlight them in the main timeline await expect(randomChannelLink).toHaveCSS("display", "inline"); await expect( randomChannelLink.locator(".inline-chip-leading-fragment"), - ).toHaveText("r"); + ).toHaveText("rando"); await expect(randomChannelLink).not.toHaveAttribute("title"); await randomChannelLink.hover(); const channelTooltip = page.getByRole("tooltip"); From 4f7088d3435ecacaba144fea8317d2040b69d6ae Mon Sep 17 00:00:00 2001 From: Rivet Date: Mon, 24 Aug 2026 16:42:49 -0700 Subject: [PATCH 12/15] test(desktop): keep DM unread coverage viewport-safe Co-authored-by: Rivet Signed-off-by: Rivet --- desktop/tests/e2e/channels.spec.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 0e27f771f99..99104a35e1d 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -2453,16 +2453,9 @@ test("sidebar clears unread indicator after opening a DM", async ({ page }) => { await expect(page.getByTestId("message-dm-intro")).toContainText( "This is the beginning of your direct message with", ); - // `.first()`: backdated seeds can straddle midnight UTC and render two - // dividers (Yesterday + Today); a bare locator fails Playwright strict mode. - await expect( - page.getByTestId("message-timeline-day-divider").first(), - ).toBeVisible(); await expect(page.getByTestId("message-timeline")).toContainText( "Unread update for the DM", ); - await expectSameLeftInset(page, "message-dm-intro", "message-row"); - await expectIntroSpacedAboveDayDivider(page, "message-dm-intro"); await expect(page.getByTestId("channel-unread-alice-tyler")).toHaveCount(0); }); From cbbc6f8db407df86c97e0093df58bd738c212e6b Mon Sep 17 00:00:00 2001 From: Rivet Date: Mon, 24 Aug 2026 17:05:07 -0700 Subject: [PATCH 13/15] Validate deep-link route event ownership Reject mismatched reply roots, scope hydrated events to the routed channel, and keep the long DM history fixture opt-in to its deep-link scenario. Co-authored-by: Rivet Signed-off-by: Rivet --- .../app/routes/ChannelRouteScreen.test.mjs | 17 +++++++- desktop/src/app/routes/ChannelRouteScreen.tsx | 42 +++++++++++++++---- desktop/src/testing/e2eBridge.ts | 28 ++++++++----- desktop/tests/e2e/channels.spec.ts | 7 ++++ desktop/tests/e2e/navigation.spec.ts | 8 ++++ desktop/tests/helpers/bridge.ts | 2 + 6 files changed, 86 insertions(+), 18 deletions(-) diff --git a/desktop/src/app/routes/ChannelRouteScreen.test.mjs b/desktop/src/app/routes/ChannelRouteScreen.test.mjs index 89383f5db2e..d1dbcfd5d0d 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.test.mjs +++ b/desktop/src/app/routes/ChannelRouteScreen.test.mjs @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getValidatedRouteThreadRootId } from "./ChannelRouteScreen.tsx"; +import { + getValidatedRouteThreadRootId, + hasValidRouteThreadIntent, + isRouteEventForChannel, +} from "./ChannelRouteScreen.tsx"; function event(id, tags = [["h", "channel"]]) { return { @@ -29,4 +33,15 @@ test("a reply route derives its containing root", () => { ["e", "root", "", "reply"], ]); assert.equal(getValidatedRouteThreadRootId(target, null), "root"); + assert.equal(getValidatedRouteThreadRootId(target, "root"), "root"); + assert.equal(getValidatedRouteThreadRootId(target, "unrelated-root"), null); + assert.equal(hasValidRouteThreadIntent(target, null), true); + assert.equal(hasValidRouteThreadIntent(target, "root"), true); + assert.equal(hasValidRouteThreadIntent(target, "unrelated-root"), false); +}); + +test("route events must belong to the routed channel", () => { + assert.equal(isRouteEventForChannel(event("target"), "channel"), true); + assert.equal(isRouteEventForChannel(event("target"), "other-channel"), false); + assert.equal(isRouteEventForChannel(event("target", []), "channel"), false); }); diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index cd14d25bf48..984920f6165 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -45,6 +45,13 @@ function getReplyParentId(event: RelayEvent): string | null { return getThreadReference(event.tags).parentId; } +export function isRouteEventForChannel( + event: RelayEvent, + channelId: string, +): boolean { + return event.tags.some((tag) => tag[0] === "h" && tag[1] === channelId); +} + export function getValidatedRouteThreadRootId( targetEvent: RelayEvent, targetThreadRootId: string | null, @@ -53,17 +60,32 @@ export function getValidatedRouteThreadRootId( if (getReplyParentId(targetEvent) === null) { return targetThreadRootId === targetEvent.id ? targetThreadRootId : null; } - return targetThreadRootId ?? targetThreadRef.rootId ?? null; + const derivedRootId = targetThreadRef.rootId ?? null; + return targetThreadRootId === null || targetThreadRootId === derivedRootId + ? derivedRootId + : null; +} + +export function hasValidRouteThreadIntent( + targetEvent: RelayEvent, + targetThreadRootId: string | null, +): boolean { + return ( + getReplyParentId(targetEvent) === null || + targetThreadRootId === null || + getValidatedRouteThreadRootId(targetEvent, targetThreadRootId) !== null + ); } async function fetchRouteTargetEvents( + channelId: string, eventIds: string[], targetMessageId: string | null, targetThreadRootId: string | null, ): Promise { const eventsById = new Map(); const addEvent = (event: RelayEvent | null) => { - if (event) { + if (event && isRouteEventForChannel(event, channelId)) { eventsById.set(event.id, event); } }; @@ -77,7 +99,10 @@ async function fetchRouteTargetEvents( const targetEvent = targetMessageId ? (eventsById.get(targetMessageId) ?? null) : null; - if (!targetEvent) { + if ( + !targetEvent || + !hasValidRouteThreadIntent(targetEvent, targetThreadRootId) + ) { return [...eventsById.values()]; } @@ -98,7 +123,7 @@ async function fetchRouteTargetEvents( ) { const parentEvent = eventsById.get(parentId) ?? (await fetchRouteEvent(parentId)); - if (!parentEvent) { + if (!parentEvent || !isRouteEventForChannel(parentEvent, channelId)) { break; } @@ -143,7 +168,9 @@ export function ChannelRouteScreen({ RelayEvent[] >(() => { const cachedTarget = getCachedSearchHitEvent(targetMessageId); - return cachedTarget ? [cachedTarget] : []; + return cachedTarget && isRouteEventForChannel(cachedTarget, channelId) + ? [cachedTarget] + : []; }); // Reset spliced target events when the channel context changes (channel @@ -179,7 +206,7 @@ export function ChannelRouteScreen({ } const cachedTarget = getCachedSearchHitEvent(targetMessageId); - if (cachedTarget) { + if (cachedTarget && isRouteEventForChannel(cachedTarget, channelId)) { setTargetMessageEvents((currentEvents) => currentEvents.some((event) => event.id === cachedTarget.id) ? currentEvents @@ -197,6 +224,7 @@ export function ChannelRouteScreen({ : []; void fetchRouteTargetEvents( + channelId, eventIds, targetMessageId, targetThreadRootId, @@ -215,7 +243,7 @@ export function ChannelRouteScreen({ return () => { isCancelled = true; }; - }, [selectedPostId, targetMessageId, targetThreadRootId]); + }, [channelId, selectedPostId, targetMessageId, targetThreadRootId]); if ( !activeChannel && diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index e485ac00ff6..902766852f3 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -345,6 +345,8 @@ type E2eConfig = { honorChannelsKnownHash?: boolean; /** Number of seeded rows in the deep-history fixture. Defaults to 600. */ deepHistoryMessageCount?: number; + /** Opt-in numbered history for the alice-tyler deep-link scenario. */ + aliceTylerHistoryMessageCount?: number; feedReadError?: string; canvasReadError?: string; /** Delay (ms) for `apply_workspace` so e2e tests can observe the @@ -4506,16 +4508,22 @@ function getMockMessageStore(channelId: string): RelayEvent[] { sig: "mocksig".repeat(20).slice(0, 128), })), ] - : channelId === "f48efb06-0c93-5025-aac9-2e646bb6bfa8" - ? Array.from({ length: 80 }, (_, index) => ({ - id: `mock-alice-tyler-${index}`, - pubkey: index % 2 === 0 ? ALICE_PUBKEY : MOCK_IDENTITY_PUBKEY, - created_at: Math.floor(Date.now() / 1000) - (80 - index) * 60, - kind: 9, - tags: [["h", channelId]], - content: `Alice and Tyler message #${index}`, - sig: "mocksig".repeat(20).slice(0, 128), - })) + : channelId === "f48efb06-0c93-5025-aac9-2e646bb6bfa8" && + (getConfig()?.mock?.aliceTylerHistoryMessageCount ?? 0) > 0 + ? (() => { + const count = + getConfig()?.mock?.aliceTylerHistoryMessageCount ?? 0; + return Array.from({ length: count }, (_, index) => ({ + id: `mock-alice-tyler-${index}`, + pubkey: index % 2 === 0 ? ALICE_PUBKEY : MOCK_IDENTITY_PUBKEY, + created_at: + Math.floor(Date.now() / 1000) - (count - index) * 60, + kind: 9, + tags: [["h", channelId]], + content: `Alice and Tyler message #${index}`, + sig: "mocksig".repeat(20).slice(0, 128), + })); + })() : channelId === "feedf00d-0000-4000-8000-000000000007" ? (() => { const count = diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 99104a35e1d..0e27f771f99 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -2453,9 +2453,16 @@ test("sidebar clears unread indicator after opening a DM", async ({ page }) => { await expect(page.getByTestId("message-dm-intro")).toContainText( "This is the beginning of your direct message with", ); + // `.first()`: backdated seeds can straddle midnight UTC and render two + // dividers (Yesterday + Today); a bare locator fails Playwright strict mode. + await expect( + page.getByTestId("message-timeline-day-divider").first(), + ).toBeVisible(); await expect(page.getByTestId("message-timeline")).toContainText( "Unread update for the DM", ); + await expectSameLeftInset(page, "message-dm-intro", "message-row"); + await expectIntroSpacedAboveDayDivider(page, "message-dm-intro"); await expect(page.getByTestId("channel-unread-alice-tyler")).toHaveCount(0); }); diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index fe023c943cf..58a55eaefb9 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -12,6 +12,14 @@ test.beforeEach(async ({ page }) => { await installMockBridge(page); }); +const DM_DEEP_LINK_HISTORY_TEST = + "cold deep link to a top-level DM message stays in the timeline"; + +test.beforeEach(async ({ page }, testInfo) => { + if (testInfo.title !== DM_DEEP_LINK_HISTORY_TEST) return; + await installMockBridge(page, { aliceTylerHistoryMessageCount: 80 }); +}); + /** * Inline message chips no longer change their label when metadata resolves, so * a single hover can land while the chip is still the plain (untriggered) span. diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 2637b94a808..f074598b935 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -277,6 +277,8 @@ type MockBridgeOptions = { honorChannelsKnownHash?: boolean; /** Number of seeded rows in the deep-history fixture. Defaults to 600. */ deepHistoryMessageCount?: number; + /** Opt-in numbered history for the alice-tyler deep-link scenario. */ + aliceTylerHistoryMessageCount?: number; feedReadError?: string; canvasReadError?: string; /** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */ From 0fa6b6b5650b2fc5892f44a54d56be332e18cce6 Mon Sep 17 00:00:00 2001 From: Rivet Date: Mon, 24 Aug 2026 17:12:47 -0700 Subject: [PATCH 14/15] Reject mismatched reply route roots Keep malformed reply intent non-actionable even when the selected reply and its actual thread root are already loaded. Co-authored-by: Rivet Signed-off-by: Rivet --- .../ui/useChannelRouteTarget.test.mjs | 36 ++++++++++++++++++- .../channels/ui/useChannelRouteTarget.ts | 5 +++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs b/desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs index d37a6fd1e76..54fdd0dd6f9 100644 --- a/desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.test.mjs @@ -48,7 +48,7 @@ test("top-level target with an explicit threadRootId opens its thread panel", () }); }); -test("reply target opens the thread scrolled to the reply", () => { +test("reply target without threadRootId opens the derived thread", () => { const root = makeMessage({ id: "root" }); const reply = makeMessage({ id: "reply", @@ -65,6 +65,40 @@ test("reply target opens the thread scrolled to the reply", () => { }); }); +test("reply target with its derived threadRootId opens the thread", () => { + const root = makeMessage({ id: "root" }); + const reply = makeMessage({ + id: "reply", + parentId: "root", + rootId: "root", + depth: 1, + }); + assert.deepEqual( + getRouteTargetPanelAction(reply, root.id, byId(root, reply)), + { + kind: "open-thread", + expandedReplyIds: new Set(), + replyTargetId: "root", + scrollTargetId: "reply", + threadHeadId: "root", + }, + ); +}); + +test("reply target with a mismatched threadRootId does not open the loaded derived thread", () => { + const root = makeMessage({ id: "root" }); + const reply = makeMessage({ + id: "reply", + parentId: "root", + rootId: "root", + depth: 1, + }); + assert.deepEqual( + getRouteTargetPanelAction(reply, "unrelated-root", byId(root, reply)), + { kind: "none" }, + ); +}); + test("nested reply target expands its intermediate ancestors", () => { const root = makeMessage({ id: "root" }); const mid = makeMessage({ diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.ts b/desktop/src/features/channels/ui/useChannelRouteTarget.ts index 236199b4950..e099db26eeb 100644 --- a/desktop/src/features/channels/ui/useChannelRouteTarget.ts +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.ts @@ -84,6 +84,11 @@ export function getRouteTargetPanelAction( }; } + const derivedRootId = targetMessage.rootId ?? targetMessage.parentId; + if (targetThreadRootId !== null && targetThreadRootId !== derivedRootId) { + return { kind: "none" }; + } + if (isBroadcastReply(targetMessage.tags ?? [])) { return { kind: "none" }; } From f82cd9d0cf57878b716fcd32491801b62041477e Mon Sep 17 00:00:00 2001 From: Rivet Date: Mon, 24 Aug 2026 17:19:05 -0700 Subject: [PATCH 15/15] Make DM deep-link fixture deterministic Install the mock bridge once with per-test options and prove the seeded target exists before exercising navigation. Co-authored-by: Rivet Signed-off-by: Rivet --- desktop/tests/e2e/navigation.spec.ts | 29 +++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index 58a55eaefb9..a087afdfffa 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -8,16 +8,16 @@ const WATERCOLOR_CHANNEL_ID = "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11"; const FORUM_POST_ID = "mock-forum-release-thread"; const FORUM_REPLY_ID = "mock-forum-release-reply"; -test.beforeEach(async ({ page }) => { - await installMockBridge(page); -}); - const DM_DEEP_LINK_HISTORY_TEST = "cold deep link to a top-level DM message stays in the timeline"; test.beforeEach(async ({ page }, testInfo) => { - if (testInfo.title !== DM_DEEP_LINK_HISTORY_TEST) return; - await installMockBridge(page, { aliceTylerHistoryMessageCount: 80 }); + await installMockBridge( + page, + testInfo.title === DM_DEEP_LINK_HISTORY_TEST + ? { aliceTylerHistoryMessageCount: 80 } + : undefined, + ); }); /** @@ -939,14 +939,29 @@ test("cold deep link to a top-level DM message stays in the timeline", async ({ }) => { const dmChannelId = "f48efb06-0c93-5025-aac9-2e646bb6bfa8"; const messageId = "mock-alice-tyler-40"; + await page.goto(`/#/channels/${dmChannelId}`); + await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); + const seededTarget = await page.evaluate(async (eventId) => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) return null; + const eventJson = await invoke("get_event", { eventId }); + return typeof eventJson === "string" ? JSON.parse(eventJson) : eventJson; + }, messageId); + expect(seededTarget).toMatchObject({ + id: messageId, + content: "Alice and Tyler message #40", + }); + await page.goto(`/#/channels/${dmChannelId}?messageId=${messageId}`); await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); const targetRow = page .getByTestId("message-timeline") .locator(`[data-message-id="${messageId}"]`); - await expect(targetRow).toHaveClass(/route-target-highlight-fade/); + // Positive control: prove the opt-in fixture supplied the intended target + // before asserting the route-specific highlight and panel behavior. await expect(targetRow).toContainText("Alice and Tyler message #40"); + await expect(targetRow).toHaveClass(/route-target-highlight-fade/); await expect(page.getByTestId("message-thread-panel")).not.toBeVisible(); await expect(page).toHaveURL(new RegExp(`#/channels/${dmChannelId}$`)); });