From b8773299f351aad376c8355a7b5c85f7e017f9d6 Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Mon, 28 Sep 2026 16:31:31 -0400 Subject: [PATCH 01/11] Polish message and thread spacing and typography Signed-off-by: Arjun Mahanti --- src/features/messages/ChannelTimeline.tsx | 6 ++ .../messages/MessageTimestamp.test.tsx | 51 ++++++++------- src/features/messages/MessageTimestamp.tsx | 17 +---- src/features/messages/Messages.module.css | 63 ++++++++++--------- .../messages/ThreadPanel.nesting.test.tsx | 7 ++- src/features/messages/ThreadPanel.tsx | 7 +-- .../messages/message-grouping.test.ts | 59 +++++++++++++++++ src/features/messages/message-grouping.ts | 18 ++++++ 8 files changed, 156 insertions(+), 72 deletions(-) create mode 100644 src/features/messages/message-grouping.test.ts create mode 100644 src/features/messages/message-grouping.ts diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index 87571ae93..aaf8d8dfc 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -8,6 +8,7 @@ import type { RelaySession } from "../relay/session"; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Virtualizer, type VirtualizerHandle } from "virtua"; import { MessageRow } from "./MessageRow"; +import { continuesMessageGroup } from "./message-grouping"; import type { Attachment, ChannelWindow } from "../relay/contracts"; import { useRowProfiles } from "../relay/react"; import { geometryFor, geometrySignature } from "./geometry"; @@ -545,6 +546,11 @@ function Timeline({ /> ) : ( { vi.useRealTimers(); }); it.each([ - [new Date(2026, 8, 24, 9, 5), "Today at"], - [new Date(2026, 8, 23, 9, 5), "Yesterday at"], - [new Date(2026, 8, 17, 9, 5), "Sep 17 at"], - [new Date(2025, 8, 17, 9, 5), "Sep 17, 2025 at"], -])("gives %s day context and a full accessible date", (date, label) => { - const { container } = render( - , - ); - expect(container.querySelector("time")).toHaveAttribute( - "datetime", - date.toISOString(), - ); - expect(container.querySelector('[aria-hidden="true"]')).toHaveTextContent( - label, - ); - expect( - screen.getByText( - date.toLocaleString(undefined, { dateStyle: "full", timeStyle: "long" }), - ), - ).toHaveClass("sr-only"); -}); + [new Date(2026, 8, 24, 9, 5)], + [new Date(2026, 8, 23, 9, 5)], + [new Date(2026, 8, 17, 9, 5)], + [new Date(2025, 8, 17, 9, 5)], +])( + "shows only the clock for %s while retaining the full accessible date", + (date) => { + const { container } = render( + , + ); + expect(container.querySelector("time")).toHaveAttribute( + "datetime", + date.toISOString(), + ); + expect(container.querySelector('[aria-hidden="true"]')).toHaveTextContent( + new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + }).format(date), + ); + expect( + screen.getByText( + date.toLocaleString(undefined, { + dateStyle: "full", + timeStyle: "long", + }), + ), + ).toHaveClass("sr-only"); + }, +); it("keeps the continuation clock compact without dropping its accessible date", () => { const date = new Date(2026, 8, 24, 9, 5); const { container } = render( diff --git a/src/features/messages/MessageTimestamp.tsx b/src/features/messages/MessageTimestamp.tsx index 90a0ed5e7..d61a9d604 100644 --- a/src/features/messages/MessageTimestamp.tsx +++ b/src/features/messages/MessageTimestamp.tsx @@ -10,21 +10,6 @@ export function MessageTimestamp({ compact?: boolean; }) { const date = new Date(createdAt * 1000); - const now = new Date(); - const yesterday = new Date(now); - yesterday.setDate(now.getDate() - 1); - const day = - date.toDateString() === now.toDateString() - ? "Today" - : date.toDateString() === yesterday.toDateString() - ? "Yesterday" - : date.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - ...(date.getFullYear() !== now.getFullYear() - ? { year: "numeric" as const } - : {}), - }); const clock = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit", @@ -36,7 +21,7 @@ export function MessageTimestamp({ .map((part) => part.value) .join("") .trim() - : `${day} at ${clock.format(date)}`; + : clock.format(date); const fullDate = date.toLocaleString(undefined, { dateStyle: "full", timeStyle: "long", diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index de222c31e..8882456fb 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -65,7 +65,7 @@ font-size: var(--text-caption); line-height: var(--text-caption--line-height); letter-spacing: var(--text-caption--letter-spacing); - margin: var(--space-2) 0 var(--space-8); + margin: var(--space-3) 0; } .day::before, .day::after { @@ -79,13 +79,18 @@ container: message-row / inline-size; display: flex; gap: var(--space-3); - padding: var(--space-2) 0 var(--space-section-gap); + padding: var(--space-2) 0; } .message[data-layout="thread"], .message[data-layout="continuation"] { gap: var(--space-2); - padding-block: var(--space-1); - min-block-size: var(--size-control); +} +.message[data-layout="continuation"] { + padding-block: var(--space-half); +} +/* Timeline continuations keep the full-size avatar column's text alignment. */ +.feed .message[data-layout="continuation"] { + gap: var(--space-3); } .messageGutter { flex: 0 0 var(--size-control-sm); @@ -99,9 +104,12 @@ flex-wrap: wrap; align-items: baseline; gap: var(--space-2); - font-size: var(--text-label-sm); - line-height: var(--text-label-sm--line-height); - letter-spacing: var(--text-label-sm--letter-spacing); + font-size: var(--text-body-sm); + line-height: var(--text-body-sm--line-height); + letter-spacing: var(--text-body-sm--letter-spacing); +} +.author strong { + font-weight: var(--type-weight-medium); } .byline time { color: var(--text-subtle); @@ -365,7 +373,7 @@ width: 360px; max-width: 100%; aspect-ratio: 360 / 320; - margin-top: var(--space-2); + margin-block: var(--space-1); overflow: hidden; border-radius: var(--media-preview-radius); clip-path: var(--media-corner-clip, none); @@ -477,8 +485,6 @@ margin-top: var(--space-1h); } .day span { - border: 1px solid var(--border-standard); - border-radius: var(--radius-pill); padding: var(--space-1h) var(--space-3); } .threadUnread { @@ -489,6 +495,10 @@ background: var(--text-standard); } .messageBody [data-thread-summary] { + font-size: var(--text-caption); + line-height: var(--text-caption--line-height); + letter-spacing: var(--text-caption--letter-spacing); + font-weight: var(--type-weight-regular); border-radius: var(--radius-pill); margin-top: var(--space-1); padding: var(--space-1); @@ -744,6 +754,7 @@ html[data-keyboard-navigation] .audioSeek:focus-visible { max-width: 100%; aspect-ratio: var(--media-ratio, 16 / 9); overflow: hidden; + margin-block: var(--space-1); padding: 0; border: 0; border-radius: var(--media-preview-radius); @@ -1186,6 +1197,10 @@ html[data-keyboard-navigation] .imageReviewToolbar :focus-visible { .plainText { white-space: pre-wrap; } +.text :where(p, li), +.plainText { + text-wrap-style: pretty; +} .text p { line-height: inherit; } @@ -1480,23 +1495,6 @@ html[data-keyboard-navigation] .imageReviewToolbar :focus-visible { opacity: 0; pointer-events: none; } - /* Reserve the controls' natural height in threads even while hidden, so - showing actions neither covers text nor moves neighboring click targets. */ - .message:is([data-layout="thread"], [data-layout="continuation"]) - .messageBody { - display: flex; - flex-direction: column; - } - .message:is([data-layout="thread"], [data-layout="continuation"]) - .messageActions { - position: static; - order: 1; - flex-wrap: nowrap; - max-width: none; - flex-shrink: 0; - align-self: flex-end; - margin-inline-start: 0; - } .message:hover .messageActions, .message:focus-within .messageActions, .messageActions[data-open], @@ -1644,7 +1642,7 @@ html[data-keyboard-navigation] .replyBranchRail:focus-visible { /* Continuations retain a readable clock without repeating the author header. */ .messageGutter { display: flex; - justify-content: flex-end; + justify-content: center; align-items: flex-start; } .continuationTime { @@ -1653,7 +1651,7 @@ html[data-keyboard-navigation] .replyBranchRail:focus-visible { background: var(--surface-panel); color: var(--text-subtle); font-size: var(--text-caption); - line-height: var(--text-caption--line-height); + line-height: calc(var(--text-body-sm) * var(--text-body-sm--line-height)); letter-spacing: var(--text-caption--letter-spacing); font-variant-numeric: tabular-nums; white-space: nowrap; @@ -2163,3 +2161,10 @@ html[data-keyboard-navigation] .rawDiff:focus-visible { .mediaReviewViewer:focus { outline: none; } + +.feed .message[data-layout="continuation"] .messageGutter { + flex-basis: var(--size-control); +} +.text pre { + text-wrap-style: auto; +} diff --git a/src/features/messages/ThreadPanel.nesting.test.tsx b/src/features/messages/ThreadPanel.nesting.test.tsx index 579376284..510f0dc57 100644 --- a/src/features/messages/ThreadPanel.nesting.test.tsx +++ b/src/features/messages/ThreadPanel.nesting.test.tsx @@ -208,7 +208,12 @@ it("groups the first same-author reply with the root but respects the time windo "data-layout", "continuation", ); - h.update([{ ...row("parent", "root"), createdAt: 602 }]); + h.update([{ ...row("parent", "root"), createdAt: 301 }]); + expect(screen.getByText("parent").closest("article")).toHaveAttribute( + "data-layout", + "continuation", + ); + h.update([{ ...row("parent", "root"), createdAt: 302 }]); expect(screen.getByText("parent").closest("article")).toHaveAttribute( "data-layout", "thread", diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx index 4884d7b64..e417c8d0a 100644 --- a/src/features/messages/ThreadPanel.tsx +++ b/src/features/messages/ThreadPanel.tsx @@ -24,6 +24,7 @@ import type { RelaySession } from "../relay/session"; import type { ThreadView } from "../relay/threads"; import { useRowProfiles } from "../relay/react"; import { MessageRow } from "./MessageRow"; +import { continuesMessageGroup } from "./message-grouping"; import { MessageComposer } from "./MessageComposer"; import styles from "./Messages.module.css"; import { rejectUnhandledFileDrop } from "./use-file-drop"; @@ -522,11 +523,7 @@ function ThreadMessages({ function renderReplies(parent: string | undefined, depth = 0): ReactNode { return (tree.children.get(parent) ?? []).map((row) => { const children = tree.children.get(row.id); - const continuation = - previousReply?.authorId === row.authorId && - row.createdAt >= previousReply.createdAt && - row.createdAt - previousReply.createdAt <= 10 * 60 && - !row.membership; + const continuation = continuesMessageGroup(previousReply, row); previousReply = children?.length && !expanded.has(row.id) ? undefined : row; const descendants = branchReplies.get(row.id) ?? []; diff --git a/src/features/messages/message-grouping.test.ts b/src/features/messages/message-grouping.test.ts new file mode 100644 index 000000000..d7e179342 --- /dev/null +++ b/src/features/messages/message-grouping.test.ts @@ -0,0 +1,59 @@ +import { expect, it } from "vitest"; +import type { ChannelMessage } from "../relay/contracts"; +import { continuesMessageGroup } from "./message-grouping"; + +const first: ChannelMessage = { + id: "first", + channelId: "channel", + authorId: "author", + content: "Hello", + createdAt: new Date(2026, 8, 28, 12).getTime() / 1000, + mentions: [], + participants: [], + attachments: [], + reactions: [], + replyCount: 0, +}; + +it("groups adjacent same-author messages through the five-minute boundary", () => { + for (const seconds of [0, 1, 299, 300]) { + expect( + continuesMessageGroup(first, { + ...first, + id: "next", + createdAt: first.createdAt + seconds, + }), + ).toBe(true); + } +}); +it("breaks groups at missing history, author/channel changes, time gaps and reversed time", () => { + expect(continuesMessageGroup(undefined, first)).toBe(false); + for (const change of [ + { authorId: "other" }, + { channelId: "other" }, + { createdAt: first.createdAt + 301 }, + { createdAt: first.createdAt - 1 }, + ]) { + expect(continuesMessageGroup(first, { ...first, ...change })).toBe(false); + } +}); +it("breaks at local midnight even within five minutes", () => { + const before = { + ...first, + createdAt: new Date(2026, 8, 28, 23, 59).getTime() / 1000, + }; + expect( + continuesMessageGroup(before, { + ...first, + createdAt: before.createdAt + 120, + }), + ).toBe(false); +}); +it("keeps membership events separate on either side", () => { + const membership = { + ...first, + membership: {} as NonNullable, + }; + expect(continuesMessageGroup(first, membership)).toBe(false); + expect(continuesMessageGroup(membership, first)).toBe(false); +}); diff --git a/src/features/messages/message-grouping.ts b/src/features/messages/message-grouping.ts new file mode 100644 index 000000000..70988a265 --- /dev/null +++ b/src/features/messages/message-grouping.ts @@ -0,0 +1,18 @@ +import type { ChannelMessage } from "../relay/contracts"; + +export function continuesMessageGroup( + previous: ChannelMessage | undefined, + current: ChannelMessage, +): boolean { + return ( + !!previous && + !previous.membership && + !current.membership && + previous.channelId === current.channelId && + previous.authorId === current.authorId && + current.createdAt >= previous.createdAt && + current.createdAt - previous.createdAt <= 5 * 60 && + new Date(previous.createdAt * 1000).toDateString() === + new Date(current.createdAt * 1000).toDateString() + ); +} From bae2dca777805af5b457c4afd63251798cfe2f5f Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Mon, 28 Sep 2026 17:00:33 -0400 Subject: [PATCH 02/11] Preserve scroll anchors and adapt compact message fixtures Signed-off-by: Arjun Mahanti --- src/features/messages/Messages.module.css | 3 +- tests/browser/fixture.mjs | 2 +- tests/browser/history-loading.spec.mjs | 54 +++++++++++----------- tests/browser/layout.spec.mjs | 9 +++- tests/browser/profiles-appearance.spec.mjs | 2 +- tests/browser/typing.spec.mjs | 6 +-- 6 files changed, 43 insertions(+), 33 deletions(-) diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index 8882456fb..0ad6695ed 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -79,7 +79,8 @@ container: message-row / inline-size; display: flex; gap: var(--space-3); - padding: var(--space-2) 0; + /* Match continuation bottom padding so regrouping preserves reading anchors. */ + padding: calc(var(--space-3) + var(--space-half)) 0 var(--space-half); } .message[data-layout="thread"], .message[data-layout="continuation"] { diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index c75098f8b..ce7665bc7 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -314,7 +314,7 @@ export const test = base.extend({ sign( 9, [["h", channel]], - `${community} ${channel} message ${i}\n${"Mixed height message content. ".repeat((1 + (i % 7) * 3) * (tallMessages ? 4 : 1))}`, + `${community} ${channel} message ${i}\n${"Mixed height message content. ".repeat((1 + (i % 7) * 3) * (tallMessages ? 5 : 1))}`, readState ? peerKey : userKey, 1700000100 + i, ), diff --git a/tests/browser/history-loading.spec.mjs b/tests/browser/history-loading.spec.mjs index 4262018a4..c898b169e 100644 --- a/tests/browser/history-loading.spec.mjs +++ b/tests/browser/history-loading.spec.mjs @@ -9,32 +9,34 @@ test.use({ const history = (page) => page.getByRole("region", { name: "Channel message history" }); -test("automatic history starts before the top in a production broker session", async ({ - page, - app, -}) => { - await open(page, app); - await expect( - page.getByRole("button", { name: "Retry live updates", exact: true }), - ).toHaveCount(0); - await history(page).hover(); - for (let i = 0; i < 100 && !app.pending.length; i++) { - await page.mouse.wheel(0, -450); - await page.waitForTimeout(40); - if ( - await history(page) - .getByRole("button", { name: "Loading older…", exact: true }) - .count() - ) - break; - } - await expect.poll(() => app.pending.length).toBe(1); - const top = await history(page).evaluate((e) => e.scrollTop); - app.report.measurements.push({ automaticRequestTop: top }); - expect(top).toBeGreaterThan(1000); - app.pending.shift().release(); - await settle(page); -}); +const tall = test.extend({ tallMessages: true }); + +tall( + "automatic history starts before the top in a production broker session", + async ({ page, app }) => { + await open(page, app); + await expect( + page.getByRole("button", { name: "Retry live updates", exact: true }), + ).toHaveCount(0); + await history(page).hover(); + for (let i = 0; i < 100 && !app.pending.length; i++) { + await page.mouse.wheel(0, -450); + await page.waitForTimeout(40); + if ( + await history(page) + .getByRole("button", { name: "Loading older…", exact: true }) + .count() + ) + break; + } + await expect.poll(() => app.pending.length).toBe(1); + const top = await history(page).evaluate((e) => e.scrollTop); + app.report.measurements.push({ automaticRequestTop: top }); + expect(top).toBeGreaterThan(1000); + app.pending.shift().release(); + await settle(page); + }, +); test("returning to the top continues history loading on wheel without a button", async ({ page, diff --git a/tests/browser/layout.spec.mjs b/tests/browser/layout.spec.mjs index 1c10e2d3d..8a7a531f4 100644 --- a/tests/browser/layout.spec.mjs +++ b/tests/browser/layout.spec.mjs @@ -417,7 +417,11 @@ readingTest( await expectBottom(); // Reopen by keyboard without browser click-to-scroll changing the saved position. const target = "https://github.com/block/buzz/pull/4"; - const saved = await upper(page); + // Keep the offscreen opener within the virtualizer's mounted buffer after + // focus moves to the panel; this tests restoration to a mounted trigger. + await history.hover(); + await wheel(page, -200); + const saved = await anchor(page); await expectNonPaging(page, app); await page .getByRole("link", { name: target, exact: true }) @@ -440,6 +444,9 @@ readingTest( }; }); await button(page, "Close channel panel").focus(); + await expect( + page.getByRole("link", { name: target, exact: true }), + ).not.toBeInViewport(); await button(page, "Close channel panel").click(); const trigger = page.getByRole("link", { name: target, exact: true }); await settle(page); diff --git a/tests/browser/profiles-appearance.spec.mjs b/tests/browser/profiles-appearance.spec.mjs index f3c926eca..0b677db3c 100644 --- a/tests/browser/profiles-appearance.spec.mjs +++ b/tests/browser/profiles-appearance.spec.mjs @@ -1,7 +1,7 @@ import { test, expect } from "./fixture.mjs"; import { open } from "./timeline.mjs"; -test.use({ historyCounts: { alpha: 20, beta: 0 } }); +test.use({ historyCounts: { alpha: 1, beta: 0 } }); for (const mode of ["light", "dark"]) { test(`Profiles uses shared styles and host keyboard focus in ${mode} mode`, async ({ page, diff --git a/tests/browser/typing.spec.mjs b/tests/browser/typing.spec.mjs index d3b71c5a7..369aa11eb 100644 --- a/tests/browser/typing.spec.mjs +++ b/tests/browser/typing.spec.mjs @@ -83,17 +83,17 @@ for (const scope of ["channel", "thread"]) { .find((e) => e.content === "Thread root 0"); if (scope === "thread") { // Seed enough signed upstream replies to exercise a genuinely scrolling thread. - for (let i = 0; i < 25; i++) app.reply(root.id); + for (let i = 0; i < 30; i++) app.reply(root.id); await page .locator(`[data-channel-timeline] [data-message-id="${root.id}"]`) .getByRole("button", { name: /^View thread:/ }) .click(); - // One nested descendant is collapsed; the root plus 27 direct replies mount. + // One nested descendant is collapsed; the root plus 32 direct replies mount. await expect( page .getByRole("region", { name: "Thread messages", exact: true }) .locator("[data-message-id]"), - ).toHaveCount(28); + ).toHaveCount(33); } else { await end(page); } From dc51d90198e0b1ca39a7904f6a35a59ccc5aa0c6 Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Mon, 28 Sep 2026 17:11:22 -0400 Subject: [PATCH 03/11] Batch virtualizer resize delivery outside observer callbacks Signed-off-by: Arjun Mahanti --- patches/virtua@0.51.0.patch | 92 +++++++++++++++++-- pnpm-lock.yaml | 6 +- .../messages/virtua-compensation.test.mjs | 84 +++++++++++++++++ 3 files changed, 171 insertions(+), 11 deletions(-) diff --git a/patches/virtua@0.51.0.patch b/patches/virtua@0.51.0.patch index 2a45a14fd..788b659b8 100644 --- a/patches/virtua@0.51.0.patch +++ b/patches/virtua@0.51.0.patch @@ -1,14 +1,64 @@ diff --git a/lib/index.js b/lib/index.js -index a183cc7..a892a1c 100644 +index a183cc7b..44e2d963 100644 --- a/lib/index.js +++ b/lib/index.js -@@ -7,1 +7,5 @@ +@@ -6,6 +6,10 @@ import { jsx as c } from "react/jsx-runtime"; +- ++ import { flushSync as f } from "react-dom"; +- + +// WebKit can restore a stale native scroll offset after an instant correction. +// Interrupt only the corrected axis; observer-inferred idle is not native idle. +const isMacWebKit = () => typeof navigator !== "undefined" && !b() && navigator.platform === "MacIntel" && navigator.vendor === "Apple Computer, Inc."; -@@ -252 +256,19 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v ++ + var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v = e => [ ...e ].sort((e, t) => e - t), p = setTimeout, S = clearTimeout, m = "function" == typeof queueMicrotask ? queueMicrotask : e => { + Promise.resolve().then(e); + }, $ = () => { +@@ -173,15 +177,37 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v + q: () => [ l.slice(), o ] + }; + }, C = e => { +- let t; ++ let t, view, frame; ++ const observed = new Set, pending = new Map; + return { + A(n) { +- (t || (t = new (z(_(n)).ResizeObserver)(e))).observe(n); ++ observed.add(n); ++ if (!t) { ++ view = z(_(n)); ++ // Rendering from native resize delivery can mount same-depth ++ // items and trigger WebKit's skipped-notification loop. ++ t = new view.ResizeObserver(entries => { ++ for (const entry of entries) if (observed.has(entry.target)) pending.set(entry.target, entry); ++ if (pending.size && frame === undefined) frame = view.requestAnimationFrame(() => { ++ frame = undefined; ++ const batch = [...pending.values()]; ++ pending.clear(); ++ if (batch.length) e(batch); ++ }); ++ }); ++ } ++ t.observe(n); + }, + B(e) { ++ observed.delete(e); ++ pending.delete(e); + t.unobserve(e); + }, + X() { ++ if (frame !== undefined) view.cancelAnimationFrame(frame); ++ frame = undefined; ++ pending.clear(); ++ observed.clear(); + t && t.disconnect(); + } + }; +@@ -249,10 +275,28 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v + o && o(); + } ]; + }, E = (e, t) => { - let n, o, r = $(), i = !1; + let n, o, r = $(), i = !1, restoreOverflow; + // Scroll APIs can truncate fractional offsets. Reach the whole edge without @@ -29,20 +79,46 @@ index a183cc7..a892a1c 100644 + }, timer = p(restore); + restoreOverflow = restore; + }; -@@ -255 +277 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v + const s = t ? "scrollLeft" : "scrollTop", l = t ? "left" : "top", c = t ? "overflowX" : "overflowY", [f, a] = O(e, () => r[0], (e, t) => { + n.scrollTo({ - [l]: T(e, i), + [l]: T(edgeOffset(e), i), -@@ -269 +291,2 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v + behavior: t ? "smooth" : "instant" + }); + }), u = t ? "width" : "height", d = /* @__PURE__ */ new WeakMap, h = C(t => { +@@ -266,7 +310,8 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v + return { + D(f, u = f.parentElement) { + h.A(n = u), t && (i = "rtl" === getComputedStyle(u).direction), o = M(e, u, t, () => T(u[s], i), (t, n, o) => { - if (o) { + if (isMacWebKit()) interruptMomentum(); + else if (o) { -@@ -277 +300 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v + const e = u.style, t = e[c]; + e[c] = "hidden", p(() => { + e[c] = t; +@@ -274,7 +319,7 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v + } + const r = e.T() + t; + r <= 0 || r >= e.O() + e.t() - e.o() ? u.scrollTo({ - [l]: T(r, i), + [l]: T(edgeOffset(r), i), -@@ -286 +309 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v + behavior: "instant" + }) : u.scrollBy({ + [l]: T(t, i), +@@ -283,7 +328,7 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v + }), r[1](!0); + }, + _() { - h.X(), o && o.X(), r[1](!1), r = $(); + restoreOverflow && restoreOverflow(), h.X(), o && o.X(), r[1](!1), r = $(); -@@ -799,2 +822 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v + }, + P: (e, t) => (d.set(e, t), h.A(e), () => { + d.delete(e), h.B(e); +@@ -796,5 +841,4 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v + }); + }); +- ++ -export { G as VList, j as Virtualizer, U as WindowVirtualizer, N as experimental_VGrid }; -//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16a6d73df..c29ca1a4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: patchedDependencies: '@base-ui/react@1.8.0': 7fa81e73a79e25bc6cddeeb432a4609fbc8f1795141fbd0cfebacbfbaa02757f - virtua@0.51.0: 8ce61fc4aaeefa37261c28b45657b9c53c03a364ea9f64fb29e974d9c44cff6a + virtua@0.51.0: c363436cd08be09757bc2d557a70df2a0d37072b835acb98079a9287a2f41f67 importers: @@ -131,7 +131,7 @@ importers: version: 4.0.1 virtua: specifier: 0.51.0 - version: 0.51.0(patch_hash=8ce61fc4aaeefa37261c28b45657b9c53c03a364ea9f64fb29e974d9c44cff6a)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 0.51.0(patch_hash=c363436cd08be09757bc2d557a70df2a0d37072b835acb98079a9287a2f41f67)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) yaml: specifier: 2.9.1 version: 2.9.1 @@ -3729,7 +3729,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - virtua@0.51.0(patch_hash=8ce61fc4aaeefa37261c28b45657b9c53c03a364ea9f64fb29e974d9c44cff6a)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + virtua@0.51.0(patch_hash=c363436cd08be09757bc2d557a70df2a0d37072b835acb98079a9287a2f41f67)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): optionalDependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) diff --git a/src/features/messages/virtua-compensation.test.mjs b/src/features/messages/virtua-compensation.test.mjs index 2295ca702..7c0cfad59 100644 --- a/src/features/messages/virtua-compensation.test.mjs +++ b/src/features/messages/virtua-compensation.test.mjs @@ -293,3 +293,87 @@ for (const [name, config, deferred] of [ c.driver._(); }); } + +function resizeHarness() { + let notify; + let nextFrame = 0; + const frames = new Map(); + const view = { + ResizeObserver: class { + constructor(callback) { + notify = callback; + } + observe() {} + unobserve() {} + disconnect() {} + }, + requestAnimationFrame(callback) { + const id = nextFrame++; + frames.set(id, callback); + return id; + }, + cancelAnimationFrame(id) { + frames.delete(id); + }, + }; + const received = []; + const observer = new Function(`${core};return C;`)()((entries) => + received.push(entries), + ); + const node = () => ({ ownerDocument: { defaultView: view } }); + return { + observer, + received, + frames, + node, + notify(entries) { + notify(entries); + }, + flush() { + const pending = [...frames.values()]; + frames.clear(); + for (const callback of pending) callback(); + }, + }; +} + +it("delivers the latest resize per target outside native observer delivery", () => { + const h = resizeHarness(); + const first = h.node(), + second = h.node(); + h.observer.A(first); + h.observer.A(second); + h.notify([{ target: first, contentRect: { height: 40 } }]); + const latest = { target: first, contentRect: { height: 60 } }; + const other = { target: second, contentRect: { height: 80 } }; + h.notify([latest, other]); + expect(h.received).toEqual([]); + expect(h.frames.size).toBe(1); + h.flush(); + expect(h.received).toEqual([[latest, other]]); + expect(h.frames.size).toBe(0); +}); + +it("drops retired targets, cancels pending delivery on disposal, and can remount", () => { + const h = resizeHarness(); + const first = h.node(), + second = h.node(); + h.observer.A(first); + h.observer.A(second); + const retained = { target: second, contentRect: { height: 80 } }; + h.notify([{ target: first }, retained]); + h.observer.B(first); + h.flush(); + expect(h.received).toEqual([[retained]]); + h.notify([retained]); + h.observer.X(); + expect(h.frames.size).toBe(0); + h.notify([retained]); // A native callback already queued at disposal is stale. + h.flush(); + expect(h.received).toEqual([[retained]]); + h.observer.A(first); + const remounted = { target: first, contentRect: { height: 100 } }; + h.notify([remounted]); + h.flush(); + expect(h.received).toEqual([[retained], [remounted]]); +}); From be46501deec54a61290227afbb499475bf4d4aca Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Mon, 28 Sep 2026 17:43:43 -0400 Subject: [PATCH 04/11] Keep thread actions clear of text and handle completed cache transactions Signed-off-by: Arjun Mahanti --- src/features/messages/Messages.module.css | 15 ++++++ src/features/relay/persistence.test.ts | 60 +++++++++++++++++++++++ src/features/relay/persistence.ts | 14 +++++- 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 src/features/relay/persistence.test.ts diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index 0ad6695ed..a6e5228a0 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -1496,6 +1496,21 @@ html[data-keyboard-navigation] .imageReviewToolbar :focus-visible { opacity: 0; pointer-events: none; } + /* Thread controls keep their own row so hover never obscures reply text + or shifts neighboring branch controls. */ + .threadHistory .messageBody { + display: flex; + flex-direction: column; + } + .threadHistory .messageActions { + position: static; + order: 1; + flex-wrap: nowrap; + max-width: none; + flex-shrink: 0; + align-self: flex-end; + margin-inline-start: 0; + } .message:hover .messageActions, .message:focus-within .messageActions, .messageActions[data-open], diff --git a/src/features/relay/persistence.test.ts b/src/features/relay/persistence.test.ts new file mode 100644 index 000000000..cddaaf2d7 --- /dev/null +++ b/src/features/relay/persistence.test.ts @@ -0,0 +1,60 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { createHeadPersistence } from "./persistence"; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +function transaction(abort: () => void) { + const tx = { + abort: vi.fn(abort), + error: null, + oncomplete: () => {}, + onabort: () => {}, + onerror: () => {}, + objectStore: () => ({ delete: vi.fn() }), + }; + const request = { + onsuccess: () => {}, + result: { transaction: () => tx, close: vi.fn() }, + }; + vi.stubGlobal("indexedDB", { open: () => request }); + const cache = createHeadPersistence("viewer", "relay"); + const result = cache.remove("channel"); + request.onsuccess(); + return { tx, result }; +} + +it("waits for completion when a timeout races an already-finished transaction", async () => { + vi.useFakeTimers(); + const { tx, result } = transaction(() => { + throw new DOMException("The transaction has finished", "InvalidStateError"); + }); + await vi.advanceTimersByTimeAsync(2000); + expect(tx.abort).toHaveBeenCalledOnce(); + tx.oncomplete(); + await expect(result).resolves.toBeUndefined(); + expect(vi.getTimerCount()).toBe(0); +}); + +it("still aborts and rejects a genuinely stalled transaction", async () => { + vi.useFakeTimers(); + const { tx, result } = transaction(() => tx.onabort()); + const rejected = expect(result).rejects.toThrow("Cache transaction aborted"); + await vi.advanceTimersByTimeAsync(2000); + await rejected; + expect(tx.abort).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); +}); + +it("rejects unexpected abort errors instead of leaving the operation pending", async () => { + vi.useFakeTimers(); + const failure = new Error("Unexpected abort failure"); + const { result } = transaction(() => { + throw failure; + }); + const rejected = expect(result).rejects.toBe(failure); + await vi.advanceTimersByTimeAsync(2000); + await rejected; +}); diff --git a/src/features/relay/persistence.ts b/src/features/relay/persistence.ts index 492f73f89..cd19452ec 100644 --- a/src/features/relay/persistence.ts +++ b/src/features/relay/persistence.ts @@ -91,7 +91,19 @@ export function createHeadPersistence( const tx = db.transaction(name, mode); let result: T; const timeout = setTimeout(() => { - tx.abort(); + try { + tx.abort(); + } catch (error) { + // Native completion can precede its queued event. Let that event + // settle the operation when the transaction can no longer abort. + if ( + !( + error instanceof DOMException && + error.name === "InvalidStateError" + ) + ) + reject(error); + } }, 2000); tx.oncomplete = () => { clearTimeout(timeout); From 8ddb5667d22dd9d7e07426bc5c03913418c99a5b Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Mon, 28 Sep 2026 19:25:57 -0400 Subject: [PATCH 05/11] Keep message actions clear of text and restore visible date context Signed-off-by: Arjun Mahanti --- src/features/messages/ChannelTimeline.tsx | 3 +- src/features/messages/MediaReviewViewer.tsx | 9 +- src/features/messages/MembershipRow.tsx | 1 + src/features/messages/MessageRow.tsx | 1 + src/features/messages/Messages.module.css | 18 ++-- src/features/messages/ThreadPanel.tsx | 8 +- tests/browser/message-actions.spec.mjs | 96 ++++++++++++++++++++- tests/browser/messages.spec.mjs | 11 +++ 8 files changed, 128 insertions(+), 19 deletions(-) diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index aaf8d8dfc..98e309784 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -526,8 +526,7 @@ function Timeline({ {rows.map((row, index) => { const day = index === 0 - ? queries.channels.get?.(channelId)?.channelType !== "dm" || - window.hasMore + ? true : new Date( (rows[index - 1]?.createdAt ?? 0) * 1000, ).toDateString() !== diff --git a/src/features/messages/MediaReviewViewer.tsx b/src/features/messages/MediaReviewViewer.tsx index ef7e89244..58b12fffb 100644 --- a/src/features/messages/MediaReviewViewer.tsx +++ b/src/features/messages/MediaReviewViewer.tsx @@ -568,7 +568,7 @@ function ReviewComments({ {replies.length} - {replies.map((row) => ( + {replies.map((row, index) => (
false} - day={false} + day={ + index === 0 || + new Date( + (replies[index - 1]?.createdAt ?? 0) * 1000, + ).toDateString() !== new Date(row.createdAt * 1000).toDateString() + } retry={session.messages.retry} onOpenMediaReview={(_rowId, attachment, seconds) => selectAttachment(attachment, seconds) diff --git a/src/features/messages/MembershipRow.tsx b/src/features/messages/MembershipRow.tsx index b06789622..14506ba8f 100644 --- a/src/features/messages/MembershipRow.tsx +++ b/src/features/messages/MembershipRow.tsx @@ -40,6 +40,7 @@ export const MembershipRow = memo(function MembershipRow({
{new Date(row.createdAt * 1000).toLocaleDateString(undefined, { + year: "numeric", weekday: "long", month: "long", day: "numeric", diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index 120191928..136978afa 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -240,6 +240,7 @@ export const MessageRow = memo(function MessageRow({
{new Date(row.createdAt * 1000).toLocaleDateString(undefined, { + year: "numeric", weekday: "long", month: "long", day: "numeric", diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index a6e5228a0..fea65805b 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -1489,21 +1489,15 @@ html[data-keyboard-navigation] .imageReviewToolbar :focus-visible { } } @media (hover: hover) and (pointer: fine) and (min-width: 640px) { - .messageActions { - position: absolute; - inset-inline-end: 0; - top: 0; - opacity: 0; - pointer-events: none; - } - /* Thread controls keep their own row so hover never obscures reply text - or shifts neighboring branch controls. */ - .threadHistory .messageBody { + /* Keep actions clear of prose without hover shifts. Every layout reserves + the same footer so regrouping on prepend preserves the reading anchor. */ + .messageBody { display: flex; flex-direction: column; } - .threadHistory .messageActions { - position: static; + .messageActions { + opacity: 0; + pointer-events: none; order: 1; flex-wrap: nowrap; max-width: none; diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx index 6c73a64a4..b286b3466 100644 --- a/src/features/messages/ThreadPanel.tsx +++ b/src/features/messages/ThreadPanel.tsx @@ -525,6 +525,10 @@ function ThreadMessages({ return (tree.children.get(parent) ?? []).map((row) => { const children = tree.children.get(row.id); const continuation = continuesMessageGroup(previousReply, row); + const day = + !previousReply || + new Date(previousReply.createdAt * 1000).toDateString() !== + new Date(row.createdAt * 1000).toDateString(); previousReply = children?.length && !expanded.has(row.id) ? undefined : row; const descendants = branchReplies.get(row.id) ?? []; @@ -548,7 +552,7 @@ function ThreadMessages({ media={session.media} onOpenLink={onOpenLink} canOpenLink={canOpenLink} - day={false} + day={day} layout={continuation ? "continuation" : "thread"} retry={session.messages.retry} {...(canSeekVideo ? { onMediaTime: handleMediaTime } : {})} @@ -661,7 +665,7 @@ function ThreadMessages({ media={session.media} onOpenLink={onOpenLink} canOpenLink={canOpenLink} - day={false} + day={true} layout="thread" retry={session.messages.retry} mediaMode="thread" diff --git a/tests/browser/message-actions.spec.mjs b/tests/browser/message-actions.spec.mjs index f66b3dae5..3e3b24a81 100644 --- a/tests/browser/message-actions.spec.mjs +++ b/tests/browser/message-actions.spec.mjs @@ -1,5 +1,5 @@ import { test, expect } from "./fixture.mjs"; -import { open } from "./timeline.mjs"; +import { open, settle } from "./timeline.mjs"; test.use({ productionBroker: true, @@ -295,3 +295,97 @@ test.describe("touch", () => { ).toBeVisible(); }); }); + +// Native geometry and hit testing protect prose/link access while controls reveal. +test("narrow timeline continuation actions never cover prose or move adjacent rows", async ({ + page, + app, +}) => { + await page.setViewportSize({ width: 900, height: 950 }); + await open(page, app); + app.append("primary", "alpha", "Start a compact group"); + const event = app.append( + "primary", + "alpha", + "Read [this reference](https://example.com/reference)", + ); + const next = app.append("primary", "alpha", "Next message"); + const row = page.locator( + `[data-channel-timeline] [data-message-id="${event.id}"]`, + ); + const following = page.locator( + `[data-channel-timeline] [data-message-id="${next.id}"]`, + ); + await expect(row.locator('[data-layout="continuation"]')).toBeVisible(); + await expect(following).toBeVisible(); + const actions = row.getByRole("group", { name: "Message actions" }); + const link = row.getByRole("link", { name: /this reference/ }); + await page.mouse.move(0, 0); + await expect(actions).toHaveCSS("opacity", "0"); + await settle(page); + const baseline = await following.boundingBox(); + for (const mode of ["hover", "focus"]) { + if (mode === "hover") await row.hover(); + else { + await page.mouse.move(0, 0); + await row.getByRole("button", { name: "More message actions" }).focus(); + } + await expect(actions).toHaveCSS("opacity", "1"); + const prose = await row.locator("p").first().boundingBox(); + const toolbar = await actions.boundingBox(); + expect(toolbar.y).toBeGreaterThanOrEqual(prose.y + prose.height); + expect((await following.boundingBox()).y).toBe(baseline.y); + await expect + .poll(() => + link.evaluate((node) => { + const rect = node.getBoundingClientRect(); + return node.contains( + document.elementFromPoint( + rect.x + rect.width / 2, + rect.y + rect.height / 2, + ), + ); + }), + ) + .toBe(true); + } +}); + +test("historical single-day DMs and their threads expose dates without hover", async ({ + page, + app, +}) => { + await open(page, app); + await page + .getByRole("navigation", { name: "Subscribed channels" }) + .getByRole("button", { name: "Alice Fixture", exact: true }) + .click(); + const timeline = page.locator("[data-channel-timeline]"); + const channel = await timeline.getAttribute("data-channel-timeline"); + const event = app.append("primary", channel, "Historical message"); + const row = timeline.locator(`[data-message-id="${event.id}"]`); + await expect(row).toBeVisible(); + const date = await page.evaluate( + (seconds) => + new Date(seconds * 1000).toLocaleDateString(undefined, { + year: "numeric", + weekday: "long", + month: "long", + day: "numeric", + }), + event.created_at, + ); + await page.mouse.move(0, 0); + await expect(timeline.getByText(date, { exact: true })).toBeVisible(); + await expect( + timeline.getByRole("button", { name: "Load older messages" }), + ).toHaveCount(0); + const reply = row.getByRole("button", { name: "Reply", exact: true }); + await reply.focus(); + await reply.press("Enter"); + const thread = page.getByRole("complementary", { + name: "Thread", + exact: true, + }); + await expect(thread.getByText(date, { exact: true })).toBeVisible(); +}); diff --git a/tests/browser/messages.spec.mjs b/tests/browser/messages.spec.mjs index d375d506a..a12db850b 100644 --- a/tests/browser/messages.spec.mjs +++ b/tests/browser/messages.spec.mjs @@ -686,6 +686,17 @@ test("exact reply media keeps its selected attachment and canonical thread", asy () => window.messagesFixture.report.exactReplyId, ); const reply = comments.locator(`[data-message-id="${exactReplyId}"]`); + const commentDate = await reply.locator("time").evaluate((time) => + new Date(time.dateTime).toLocaleDateString(undefined, { + year: "numeric", + weekday: "long", + month: "long", + day: "numeric", + }), + ); + await expect( + comments.getByText(commentDate, { exact: true }).first(), + ).toBeVisible(); await reply.hover(); const addReaction = reply.getByTestId("reaction-row").getByRole("button", { name: "Add reaction", From 8478e5323fba5671ea02c678ca45a23e4a020b8b Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Mon, 28 Sep 2026 19:44:56 -0400 Subject: [PATCH 06/11] Align date and presence journeys with compact grouped messages Signed-off-by: Arjun Mahanti --- tests/browser/new-message.spec.mjs | 10 +++++++++- tests/browser/presence.spec.mjs | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/browser/new-message.spec.mjs b/tests/browser/new-message.spec.mjs index 9690e1312..4fc35f6c7 100644 --- a/tests/browser/new-message.spec.mjs +++ b/tests/browser/new-message.spec.mjs @@ -609,7 +609,15 @@ test("empty compose, keyboard selection, pagination, removal effects, retry, the await expect(message).toHaveCount(1); await expect(message).toBeVisible(); await expect(message.locator("time")).toBeVisible(); - await expect(page.locator('[class*="_day_"]')).toHaveCount(0); + const visibleDate = await message.locator("time").evaluate((time) => + new Date(time.dateTime).toLocaleDateString(undefined, { + year: "numeric", + weekday: "long", + month: "long", + day: "numeric", + }), + ); + await expect(message.getByText(visibleDate, { exact: true })).toBeVisible(); await expect( page.getByRole("textbox", { name: "Message #Avery Chen" }), ).toBeVisible(); diff --git a/tests/browser/presence.spec.mjs b/tests/browser/presence.spec.mjs index a4a72d9c2..857647a17 100644 --- a/tests/browser/presence.spec.mjs +++ b/tests/browser/presence.spec.mjs @@ -213,7 +213,8 @@ test("foreground send and cold channel entry remain available during a profile s }); test.describe("human message bylines show known presence", () => { - test.use({ threadUnread: true, historyCounts: { alpha: 20, beta: 20 } }); + // Presence demand needs a mounted author header, not a virtualized history. + test.use({ threadUnread: true, historyCounts: { alpha: 2, beta: 1 } }); test("timeline and thread bylines demand presence alongside an explicit profile", async ({ page, app, From ed0ebdecf05e0384a2833ed9efa3b1defc0d8b31 Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Mon, 28 Sep 2026 20:03:22 -0400 Subject: [PATCH 07/11] Preserve bottom intent across a measured shrink followed by an append Signed-off-by: Arjun Mahanti --- .../messages/ChannelTimeline.test.tsx | 19 +++++++++++++++++++ src/features/messages/ChannelTimeline.tsx | 9 +++++++++ 2 files changed, 28 insertions(+) diff --git a/src/features/messages/ChannelTimeline.test.tsx b/src/features/messages/ChannelTimeline.test.tsx index c6bd838b9..a71df38e1 100644 --- a/src/features/messages/ChannelTimeline.test.tsx +++ b/src/features/messages/ChannelTimeline.test.tsx @@ -1464,3 +1464,22 @@ it("estimated list shrinkage can leave an intermediate gap without becoming read h.unmount(); expect(h.saved().bottom).toBe(true); }); + +it("records a measured shrink before an append hides its queued native scroll clamp", () => { + const h = setup(); + h.element.scrollTop = 3038; + h.scroll(); + h.append(); + h.element.scrollHeight -= 200; + h.element.scrollTop -= 200; + h.measureRows(false); // Mutation delivery precedes the queued native scroll. + h.element.scrollHeight += 300; // A new event arrives before that scroll. + h.dispatchScroll(); + h.handle.scrollToIndex.mockClear(); + h.flush(); + expect(h.handle.scrollToIndex).toHaveBeenCalledExactlyOnceWith(2, { + align: "end", + }); + h.unmount(); + expect(h.saved().bottom).toBe(true); +}); diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index 98e309784..dacd2e1a4 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -350,6 +350,15 @@ function Timeline({ observer = new MutationObserver(() => { if (list.style.height === height) return; height = list.style.height; + // Capture a native clamp while its shrink is still observable. + // Another append can grow the list before the queued scroll event. + if ( + !restore && + measuredPosition.current && + intent.current === scheduledIntent && + scroller.current + ) + recordPosition(scroller.current); cancelAnimationFrame(frame); correctionPending = true; frame = requestAnimationFrame(restorePosition); From 189c757aba58d24536163b2d85e3cadace509900 Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Mon, 28 Sep 2026 20:42:34 -0400 Subject: [PATCH 08/11] Retain cold reading restoration across pre-measurement refreshes Signed-off-by: Arjun Mahanti --- .../messages/ChannelTimeline.test.tsx | 31 ++++++++++++++++++- src/features/messages/ChannelTimeline.tsx | 23 +++++++------- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/features/messages/ChannelTimeline.test.tsx b/src/features/messages/ChannelTimeline.test.tsx index a71df38e1..8cb434b39 100644 --- a/src/features/messages/ChannelTimeline.test.tsx +++ b/src/features/messages/ChannelTimeline.test.tsx @@ -1146,7 +1146,10 @@ it("restores an anchor inside a membership group after history joins across a pa membershipRow("anchor", 2), membershipRow("newer", 3), ]); - expect(h.handle.scrollToIndex).not.toHaveBeenCalled(); + expect(h.handle.scrollToIndex).toHaveBeenCalledExactlyOnceWith(0, { + align: "start", + offset: -42, + }); h.unmount(); }); it("live group growth follows the displayed group index rather than a hidden raw row", () => { @@ -1483,3 +1486,29 @@ it("records a measured shrink before an append hides its queued native scroll cl h.unmount(); expect(h.saved().bottom).toBe(true); }); + +it.each([false, true])( + "a row refresh before the first measured height preserves cold restoration unless the reader intervenes=%s", + (gesture) => { + const mountedRow = { id: "last", y: 42 }; + const mounted = [mountedRow]; + const h = setup({ + initial: { offset: 80851, bottom: false, anchor: { id: "last", y: 42 } }, + mounted, + }); + mountedRow.y = 142; // Native cold layout can report the right ID at the wrong Y. + h.dispatchScroll(); + if (gesture) h.gesture(); + h.handle.scrollToIndex.mockClear(); + h.edit(); // Refresh cancels the original observer before any height delivery. + h.handle.scrollToIndex.mockClear(); + h.measureRows(); + if (gesture) expect(h.handle.scrollToIndex).not.toHaveBeenCalled(); + else + expect(h.handle.scrollToIndex).toHaveBeenCalledExactlyOnceWith(1, { + align: "start", + offset: -42, + }); + h.unmount(); + }, +); diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index dacd2e1a4..6ad14b76d 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -303,9 +303,7 @@ function Timeline({ ? savedPosition.current : null; let observer: MutationObserver | undefined; - let correctionPending = false; const restorePosition = () => { - correctionPending = false; if (intent.current !== scheduledIntent || !handle.current) return; if (restore) { const anchor = restore.anchor; @@ -360,7 +358,6 @@ function Timeline({ ) recordPosition(scroller.current); cancelAnimationFrame(frame); - correctionPending = true; frame = requestAnimationFrame(restorePosition); }); observer.observe(list, { @@ -374,15 +371,17 @@ function Timeline({ return () => { cancelAnimationFrame(frame); observer?.disconnect(); - // A row refresh can cancel the late measurement correction. Carry the - // original restoration across it; only newer reader input may retire it. - if ( - correctionPending && - restore && - !follow.current && - intent.current === scheduledIntent - ) { - savedPosition.current = restore; + // A row refresh can arrive before the first deferred measurement or + // cancel its correction. Keep restoration until newer reader input. + if (restore && !follow.current && intent.current === scheduledIntent) { + // Carry the original geometry with the resolved membership identity. + savedPosition.current = + restore.anchor && restoredAnchor.current + ? { + ...restore, + anchor: { ...restore.anchor, id: restoredAnchor.current }, + } + : restore; settled.current = false; } }; From 506e94391ba875d941e67b17fafae005160dcb29 Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Mon, 28 Sep 2026 21:10:53 -0400 Subject: [PATCH 09/11] Float message actions without layout gaps and simplify the menu Signed-off-by: Arjun Mahanti --- .../messages/ChannelTimeline.report.test.tsx | 4 +-- src/features/messages/MessageActionBar.tsx | 13 +------- src/features/messages/MessageManagement.tsx | 10 +++--- src/features/messages/MessageRow.tsx | 31 ++++------------- src/features/messages/Messages.module.css | 33 ++++++++++--------- .../messages/ReportMessageDialog.test.tsx | 12 ++----- tests/browser/message-actions.spec.mjs | 14 +++++--- 7 files changed, 45 insertions(+), 72 deletions(-) diff --git a/src/features/messages/ChannelTimeline.report.test.tsx b/src/features/messages/ChannelTimeline.report.test.tsx index 865801f72..e6ec3e999 100644 --- a/src/features/messages/ChannelTimeline.report.test.tsx +++ b/src/features/messages/ChannelTimeline.report.test.tsx @@ -146,9 +146,7 @@ async function openReport(row: HTMLElement) { name: "More message actions", }); await user.click(trigger); - await user.click( - await screen.findByRole("menuitem", { name: "Report message" }), - ); + await user.click(await screen.findByRole("menuitem", { name: "Report" })); await screen.findByRole("dialog", { name: "Report message" }); return { user, trigger }; } diff --git a/src/features/messages/MessageActionBar.tsx b/src/features/messages/MessageActionBar.tsx index 08830b82a..745b0d6e0 100644 --- a/src/features/messages/MessageActionBar.tsx +++ b/src/features/messages/MessageActionBar.tsx @@ -99,7 +99,7 @@ export function MessageActionBar({ }} /> )} - + Copy message - { - if (link) void copy(() => link, "Link"); - }} - > - - - - Copy link - {overflowItems} diff --git a/src/features/messages/MessageManagement.tsx b/src/features/messages/MessageManagement.tsx index 88f090e27..f87630522 100644 --- a/src/features/messages/MessageManagement.tsx +++ b/src/features/messages/MessageManagement.tsx @@ -1,3 +1,7 @@ +import { + EnvelopeIcon, + EnvelopeOpenIcon, +} from "../../shared/design-system/icons"; import { createContext, useContext, @@ -8,7 +12,7 @@ import { } from "react"; import { Button } from "../../shared/design-system/ui/Button"; import { AlertDialog } from "../../shared/design-system/ui/AlertDialog"; -import { MenuItem, MenuSeparator } from "../../shared/design-system/ui/Menu"; +import { MenuItem, MenuIcon } from "../../shared/design-system/ui/Menu"; import type { ChannelMessage } from "../relay/contracts"; import type { RelaySession } from "../relay/session"; import type { OutgoingEvent } from "../relay/outbox"; @@ -161,11 +165,9 @@ export function MessageManagement({ export function MessageManagementItems({ row, session, - separated = false, }: { row: ChannelMessage; session: RelaySession; - separated?: boolean; }) { const management = useContext(Management); const editor = useMessageEditScope(); @@ -209,7 +211,6 @@ export function MessageManagementItems({ afterClose ? afterClose(action) : action(); return ( <> - {separated && } {canEdit && ( + {unread ? : } {unread ? "Mark read" : "Mark unread"} diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index da2794d8d..2687621af 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -40,11 +40,7 @@ import { MessageReactionControls, MessageReactions } from "./MessageReactions"; import { MessageManagementItems } from "./MessageManagement"; import { MessageActionBar } from "./MessageActionBar"; import { FlagIcon } from "../../shared/design-system/icons"; -import { - MenuIcon, - MenuItem, - MenuSeparator, -} from "../../shared/design-system/ui/Menu"; +import { MenuIcon, MenuItem } from "../../shared/design-system/ui/Menu"; import { ToastNotice } from "../../shared/design-system/ui/Toast"; import { ReportMessageDialog } from "./ReportMessageDialog"; import { messageCopyLink, messageCopyText } from "./message-copy"; @@ -195,7 +191,7 @@ export const MessageRow = memo(function MessageRow({ - Report message + Report ); // Keep mixed attachments in sender order; only adjacent images share a strip. @@ -361,24 +357,11 @@ export const MessageRow = memo(function MessageRow({ } overflowItems={ <> - {overflowItems != null ? ( - <> - - {overflowItems} - - ) : session ? ( - - ) : undefined} - {reportItem && ( - <> - - {reportItem} - - )} + {overflowItems ?? + (session ? ( + + ) : undefined)} + {reportItem} } /> diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index 2cf97dfd9..1b2ebc613 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -97,6 +97,8 @@ flex: 0 0 var(--size-control-sm); } .messageBody { + display: flex; + flex-direction: column; flex: 1; min-width: 0; } @@ -1520,6 +1522,9 @@ html[data-keyboard-navigation] .imageReviewToolbar :focus-visible { .messageActions { @apply popover-surface; + order: 1; + flex-shrink: 0; + align-self: flex-end; border-radius: var(--radius-pill); display: flex; align-items: center; @@ -1548,23 +1553,25 @@ html[data-keyboard-navigation] .imageReviewToolbar :focus-visible { z-index: 1; } } -@media (hover: hover) and (pointer: fine) and (min-width: 640px) { - /* Keep actions clear of prose without hover shifts. Every layout reserves - the same footer so regrouping on prepend preserves the reading anchor. */ +@media (hover: hover) and (pointer: fine) { + /* Float at the byline center without reserving message height. */ .messageBody { - display: flex; - flex-direction: column; + position: relative; } .messageActions { + position: absolute; + inset-inline-end: 0; + bottom: 100%; + z-index: 2; opacity: 0; pointer-events: none; - order: 1; - flex-wrap: nowrap; - max-width: none; - flex-shrink: 0; - align-self: flex-end; margin-inline-start: 0; } + .message:not([data-layout="continuation"]) .messageActions { + top: calc(var(--text-body-sm) * var(--text-body-sm--line-height) / 2); + bottom: auto; + transform: translateY(-50%); + } .message:hover .messageActions, .message:focus-within .messageActions, .messageActions[data-open], @@ -1804,12 +1811,6 @@ html[data-keyboard-navigation] .replyBranchRail:focus-visible { --reply-guide: var(--border-prominent); } -@container message-row (max-width: 260px) { - .copyLinkShortcut { - display: none; - } -} - .spoiler { display: inline-block; position: relative; diff --git a/src/features/messages/ReportMessageDialog.test.tsx b/src/features/messages/ReportMessageDialog.test.tsx index 24c2040ed..2d92dbdc4 100644 --- a/src/features/messages/ReportMessageDialog.test.tsx +++ b/src/features/messages/ReportMessageDialog.test.tsx @@ -82,9 +82,7 @@ it("reports from the message menu, keeps input after failure and confirms succes mount(publish); const trigger = screen.getByRole("button", { name: "More message actions" }); await user.click(trigger); - await user.click( - await screen.findByRole("menuitem", { name: "Report message" }), - ); + await user.click(await screen.findByRole("menuitem", { name: "Report" })); const dialog = await screen.findByRole("dialog", { name: "Report message" }); // A virtualized list must not evict the row owning the draft and outcome. expect(keepMounted.mock.calls).toEqual([[root.id]]); @@ -142,9 +140,7 @@ it("starts each report with an empty form", async () => { mount(async () => {}); const trigger = screen.getByRole("button", { name: "More message actions" }); await user.click(trigger); - await user.click( - await screen.findByRole("menuitem", { name: "Report message" }), - ); + await user.click(await screen.findByRole("menuitem", { name: "Report" })); await user.click(await screen.findByRole("radio", { name: "Other" })); expect(keepMounted.mock.calls).toEqual([[root.id]]); await user.click(screen.getByRole("button", { name: "Cancel" })); @@ -155,9 +151,7 @@ it("starts each report with an empty form", async () => { await waitFor(() => expect(release).toHaveBeenCalledOnce()); expect(focusedAtRelease).toBe(trigger); await user.click(trigger); - await user.click( - await screen.findByRole("menuitem", { name: "Report message" }), - ); + await user.click(await screen.findByRole("menuitem", { name: "Report" })); expect( ( (await screen.findByRole("radio", { name: "Other" })) as HTMLElement diff --git a/tests/browser/message-actions.spec.mjs b/tests/browser/message-actions.spec.mjs index 3e3b24a81..681370c15 100644 --- a/tests/browser/message-actions.spec.mjs +++ b/tests/browser/message-actions.spec.mjs @@ -151,12 +151,17 @@ test("message actions reveal, copy, restore focus and reply across responsive la panel.locator(`[data-message-id="${broadcast.id}"]`), ).toBeFocused(); await page.getByRole("button", { name: "Close thread", exact: true }).click(); - for (const width of [900, 390]) { + for (const width of [900, 603, 390]) { await page.setViewportSize({ width, height: 850 }); await row.scrollIntoViewIfNeeded(); - if (width === 900) await row.hover(); - else await page.mouse.move(0, 0); + await page.mouse.move(0, 0); + await page.mouse.click(0, 0); + await expect(actions).toHaveCSS("opacity", "0"); + await row.hover(); await expect(actions).toHaveCSS("opacity", "1"); + await expect( + row.getByRole("button", { name: "Copy link", exact: true }), + ).toBeVisible(); await trigger.click(); const menu = page.getByRole("menu"); await expect(menu).toBeVisible(); @@ -333,7 +338,8 @@ test("narrow timeline continuation actions never cover prose or move adjacent ro await expect(actions).toHaveCSS("opacity", "1"); const prose = await row.locator("p").first().boundingBox(); const toolbar = await actions.boundingBox(); - expect(toolbar.y).toBeGreaterThanOrEqual(prose.y + prose.height); + expect(toolbar.y + toolbar.height).toBeLessThanOrEqual(prose.y); + await expect(actions).toHaveCSS("position", "absolute"); expect((await following.boundingBox()).y).toBe(baseline.y); await expect .poll(() => From 3f13854865eacbfefed13ecbcc359159221c3bc2 Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Mon, 28 Sep 2026 21:16:08 -0400 Subject: [PATCH 10/11] Align pending message menu expectations with simplified actions Signed-off-by: Arjun Mahanti --- src/features/messages/MessageRow.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/messages/MessageRow.test.tsx b/src/features/messages/MessageRow.test.tsx index 3e9ee7f76..094696dfe 100644 --- a/src/features/messages/MessageRow.test.tsx +++ b/src/features/messages/MessageRow.test.tsx @@ -896,7 +896,9 @@ it.each(["sending", "failed"] as const)( screen.getByRole("button", { name: "More message actions" }), ); await screen.findByRole("menu"); - expect(screen.getAllByRole("menuitem")).toHaveLength(2); + expect( + screen.getAllByRole("menuitem").map((item) => item.textContent), + ).toEqual(["Copy message"]); expect(screen.queryByRole("separator")).toBeNull(); } finally { cleanup(); From e9e81bb2afef6ac237173082300e534f7514f666 Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Mon, 28 Sep 2026 21:46:08 -0400 Subject: [PATCH 11/11] Keep floating thread controls clear of prose and repair scroll setup Signed-off-by: Arjun Mahanti --- src/features/messages/Messages.module.css | 7 ++-- tests/browser/image-scroll.spec.mjs | 45 ++++++++++++++++------- tests/browser/nested-replies.spec.mjs | 2 +- 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index 1b2ebc613..f10e69418 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -1554,7 +1554,7 @@ html[data-keyboard-navigation] .imageReviewToolbar :focus-visible { } } @media (hover: hover) and (pointer: fine) { - /* Float at the byline center without reserving message height. */ + /* Float above prose without reserving message height. */ .messageBody { position: relative; } @@ -1568,9 +1568,8 @@ html[data-keyboard-navigation] .imageReviewToolbar :focus-visible { margin-inline-start: 0; } .message:not([data-layout="continuation"]) .messageActions { - top: calc(var(--text-body-sm) * var(--text-body-sm--line-height) / 2); - bottom: auto; - transform: translateY(-50%); + /* Bound the lower edge even when a narrow toolbar wraps. */ + bottom: calc(100% - var(--text-body-sm) * var(--text-body-sm--line-height)); } .message:hover .messageActions, .message:focus-within .messageActions, diff --git a/tests/browser/image-scroll.spec.mjs b/tests/browser/image-scroll.spec.mjs index 692c5755f..1099d67ff 100644 --- a/tests/browser/image-scroll.spec.mjs +++ b/tests/browser/image-scroll.spec.mjs @@ -8,27 +8,33 @@ import { settle, wheel, anchor, expectAnchor } from "./timeline.mjs"; // release them and assert stability without any corrective scrolling. async function navigate(page, direction) { const feed = page.getByRole("region", { name: "Channel message history" }); - const gap = () => - feed.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop); - const reached = (distance) => - direction < 0 ? distance > 5000 : distance < 4; + const remaining = () => + feed.evaluate( + (el, direction) => + direction < 0 + ? el.scrollTop - (el.scrollHeight - el.clientHeight) / 3 + : el.scrollHeight - el.clientHeight - el.scrollTop, + direction, + ); await feed.hover(); + // Read inside the current scroll extent, clear of both clamped edges. // Traverse to the setup condition, not a fixed wheel-count budget. WebKit and // virtualized remeasurement can apply only part of a requested displacement. // The existing test deadline bounds traversal; every gesture must make settled // progress. This runs only while image responses are held, never during the // preservation assertions that follow their release. while (true) { - const before = await gap(); - if (reached(before)) break; - const remaining = direction < 0 ? 6000 - before : before; - await wheel(page, direction * Math.min(2000, remaining)); + const before = await remaining(); + if (before < 4) break; + await wheel(page, direction * Math.min(2000, before)); expect( - direction * (before - (await gap())), + before - (await remaining()), "image navigation retains progress after settling", ).toBeGreaterThan(0); } - expect(reached(await gap()), "image navigation reaches its setup").toBe(true); + expect(await remaining(), "image navigation reaches its setup").toBeLessThan( + 4, + ); } async function fixtureServer() { @@ -118,6 +124,10 @@ test("delayed and failed images preserve bottom and reading anchors across remou await navigate(page, -1); await expect.poll(() => pending.size).toBeGreaterThan(0); await settle(page); + expect(await feed.evaluate((el) => el.scrollTop)).toBeGreaterThan(0); + expect(await gap()).toBeGreaterThan( + await feed.evaluate((el) => el.clientHeight), + ); const reading = await anchor(page); await release(); await loaded(); @@ -193,6 +203,17 @@ test("image navigation handles partial gestures and rejects blocked input", asyn
`); + // Install the fixture's input policy before hover commits WebKit's wheel + // event regions; adding the first listener immediately before input can lose it. + await page.getByRole("region").evaluate((element) => { + element.addEventListener( + "wheel", + (event) => { + if (element.hasAttribute("data-block-wheel")) event.preventDefault(); + }, + { passive: false }, + ); + }); const wheel = page.mouse.wheel.bind(page.mouse); let gestures = 0; page.mouse.wheel = (x, y) => { @@ -206,9 +227,7 @@ test("image navigation handles partial gestures and rejects blocked input", asyn await navigate(page, -1); expect(gestures).toBeGreaterThan(8); await page.getByRole("region").evaluate((element) => { - element.addEventListener("wheel", (event) => event.preventDefault(), { - passive: false, - }); + element.setAttribute("data-block-wheel", ""); }); gestures = 0; await expect(navigate(page, 1)).rejects.toThrow( diff --git a/tests/browser/nested-replies.spec.mjs b/tests/browser/nested-replies.spec.mjs index a49804d22..41e371a76 100644 --- a/tests/browser/nested-replies.spec.mjs +++ b/tests/browser/nested-replies.spec.mjs @@ -624,7 +624,7 @@ for (const width of [1492, 1280, 1024, 390]) panel .locator(`[data-message-id="${id}"]`) .getByRole("group", { name: "Message actions" }), - ).toHaveCSS("opacity", width < 640 ? "1" : "0"); + ).toHaveCSS("opacity", "0"); } await panel.screenshot({ path: test.info().outputPath(`crowded-${width}.png`),