- {names.join(", ")}
- {others > 0 ? ` and ${others} others` : ""}
- {matching.length === 1 ? " is typing…" : " are typing…"}
+
+ {matching.length > 0 && (
+
+ {names.join(", ")}
+ {others > 0 ? ` and ${others} others` : ""}
+ {matching.length === 1 ? " is typing…" : " are typing…"}
+
+ )}
);
}
diff --git a/src/features/relay/typing.test.ts b/src/features/relay/typing.test.ts
index 0e12a8e8..7a69abb0 100644
--- a/src/features/relay/typing.test.ts
+++ b/src/features/relay/typing.test.ts
@@ -122,6 +122,39 @@ it("messages win a batch, suppress late pulses for two seconds and retain timest
vi.advanceTimersByTime(8000);
expect(snapshot()).toEqual([]);
});
+for (const kind of [9, 40002]) {
+ it(`kind ${kind} quiet suppression remembers replayed pulses without deferring activity or extending quiet`, () => {
+ const { owner, snapshot } = setup();
+ owner.accept([
+ signed(agent, {
+ kind,
+ tags: [["h", "a"]],
+ content: "complete",
+ created_at: epoch,
+ }),
+ ]);
+ vi.advanceTimersByTime(1000);
+ const suppressed = pulse(undefined, epoch + 1);
+ owner.accept([suppressed], true);
+ expect(snapshot()).toEqual([]);
+ vi.advanceTimersByTime(1000);
+ expect(snapshot()).toEqual([]); // Quiet ending never reveals a dropped pulse.
+ owner.accept([suppressed], true);
+ expect(snapshot()).toEqual([]);
+ // Suppression did not move the original two-second quiet deadline.
+ owner.accept([pulse(undefined, epoch + 2)], true);
+ expect(snapshot()).toHaveLength(1);
+ vi.advanceTimersByTime(1000);
+ owner.accept([suppressed], true);
+ expect(snapshot()).toHaveLength(1);
+ vi.advanceTimersByTime(6999);
+ expect(snapshot()).toHaveLength(1);
+ vi.advanceTimersByTime(1);
+ expect(snapshot()).toEqual([]);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+}
+
it("message suppression is signer/thread scoped and clears only older activity", () => {
const { owner, snapshot } = setup();
const tags = [
diff --git a/src/features/relay/typing.ts b/src/features/relay/typing.ts
index ad9683f5..0c96c7e9 100644
--- a/src/features/relay/typing.ts
+++ b/src/features/relay/typing.ts
@@ -70,8 +70,9 @@ export function createTyping(
now: number,
) {
if (at <= record.lastActivityAt || at <= record.lastMessageAt) return;
- if (now < record.quietUntil) return;
+ // Remember suppressed pulses too: quiet ending must not admit their replays.
record.lastActivityAt = at;
+ if (now < record.quietUntil) return;
record.visibleUntil = at + ACTIVITY_LIFETIME_MS;
}
function recordCompletion(
diff --git a/tests/browser/typing.spec.mjs b/tests/browser/typing.spec.mjs
index 4645ff5f..aea07584 100644
--- a/tests/browser/typing.spec.mjs
+++ b/tests/browser/typing.spec.mjs
@@ -1,5 +1,5 @@
import { test, expect } from "./fixture.mjs";
-import { open } from "./timeline.mjs";
+import { open, end } from "./timeline.mjs";
test.use({ productionBroker: true, readState: true, threadUnread: true });
test("Messages receives scoped typing through authenticated live traffic and expires it without publishing", async ({
@@ -52,3 +52,82 @@ test("Messages receives scoped typing through authenticated live traffic and exp
await expect(indicator).toHaveCount(0, { timeout: 10000 });
expect(app.report.publications).toEqual([]);
});
+
+for (const scope of ["channel", "thread"]) {
+ test(`${scope} typing preserves viewport bounds and the visible bottom through completion and expiry`, async ({
+ page,
+ app,
+ }) => {
+ await open(page, app);
+ await expect
+ .poll(() =>
+ app.report.liveRequests.some((r) =>
+ r.filter?.["#h"]?.includes("alpha"),
+ ),
+ )
+ .toBe(true);
+ const root = app.histories
+ .get("primary/alpha")
+ .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);
+ await page
+ .locator(`[data-channel-timeline] [data-message-id="${root.id}"]`)
+ .getByRole("button", { name: /^View thread:/ })
+ .click();
+ await expect(
+ page.getByText("28 replies shown", { exact: true }),
+ ).toBeVisible();
+ } else {
+ await end(page);
+ }
+ const history = page.getByRole("region", {
+ name: scope === "thread" ? "Thread messages" : "Channel message history",
+ exact: true,
+ });
+ const composer = page.getByRole("form", {
+ name: scope === "thread" ? "Reply to thread" : "Send a message to Alpha",
+ exact: true,
+ });
+ const indicator = composer.getByRole("status", { name: "Typing activity" });
+ const gap = () =>
+ history.evaluate(
+ (el) => el.scrollHeight - el.clientHeight - el.scrollTop,
+ );
+ await expect.poll(gap).toBeLessThan(2);
+ expect(
+ await history.evaluate((el) => el.scrollHeight - el.clientHeight),
+ ).toBeGreaterThan(100);
+ const idle = await history.boundingBox();
+ const idleComposer = await composer.boundingBox();
+ const stable = async () => {
+ expect(await history.boundingBox()).toEqual(idle);
+ expect(await composer.boundingBox()).toEqual(idleComposer);
+ await expect.poll(gap).toBeLessThan(2);
+ const tail = await history
+ .locator("[data-message-id]")
+ .last()
+ .boundingBox();
+ expect(tail.y).toBeGreaterThanOrEqual(idle.y - 2);
+ expect(tail.y + tail.height).toBeLessThanOrEqual(
+ idle.y + idle.height + 2,
+ );
+ };
+ await expect(indicator).toHaveCount(0);
+ const target = scope === "thread" ? { root: root.id } : {};
+ app.activity(target);
+ await expect(indicator).toContainText("is typing…");
+ await stable();
+ app.activity({ ...target, kind: 9 });
+ await expect(indicator).toHaveCount(0);
+ await stable();
+ // A different signer is outside the first signer's quiet period.
+ app.activity({ ...target, author: 1 });
+ await expect(indicator).toContainText("is typing…");
+ await stable();
+ await expect(indicator).toHaveCount(0, { timeout: 10000 });
+ await stable();
+ expect(app.report.publications).toEqual([]);
+ });
+}
From 0680d3baacc04b007fe61b212b0e4381d17e6dba Mon Sep 17 00:00:00 2001
From: Fizz
<400e8babadcee6a7f420103f10a2849d84c4a9c71d5bd04f3948c814216648a3@buzz.block.builderlab.xyz>
Date: Sat, 12 Sep 2026 15:51:28 -0700
Subject: [PATCH 5/7] test: capture clipped reading anchors in tall timelines
Signed-off-by: Fizz <400e8babadcee6a7f420103f10a2849d84c4a9c71d5bd04f3948c814216648a3@buzz.block.builderlab.xyz>
---
docs/browser-testing.md | 6 +++-
tests/browser/timeline-setup.spec.mjs | 45 ++++++++++++++++++++++++++-
tests/browser/timeline.mjs | 17 +++++++---
3 files changed, 61 insertions(+), 7 deletions(-)
diff --git a/docs/browser-testing.md b/docs/browser-testing.md
index 0ad69e29..1ab38d13 100644
--- a/docs/browser-testing.md
+++ b/docs/browser-testing.md
@@ -266,7 +266,11 @@ cursor responses for explicit paging tests; accidentally entering that path is n
valid resize setup. `upper()` establishes above-bottom reading with at most four
real wheel gestures, requiring progress and settled distance >400px. It does not
measure exact wheel displacement. Partial-input and blocked-input controls guard
-that setup; same-ID/Y <4px and bottom <4px assertions remain unchanged. No retries
+that setup; same-ID/Y <4px and bottom <4px assertions remain unchanged. Anchor
+capture prefers a whole paragraph, falling back to the first intersecting row
+when tall messages leave only clipped paragraphs. A deterministic helper control
+covers that geometry, whole-paragraph preference, offscreen rejection, and rejection
+of an actual anchor displacement. No retries
or additional WebKit exclusions are used. The underlying Linux WebKit single-wheel
shortfall remains unattributed; this setup change does not fix or explain it.
diff --git a/tests/browser/timeline-setup.spec.mjs b/tests/browser/timeline-setup.spec.mjs
index c5a60772..520a45c9 100644
--- a/tests/browser/timeline-setup.spec.mjs
+++ b/tests/browser/timeline-setup.spec.mjs
@@ -1,5 +1,5 @@
import { test, expect } from "./fixture.mjs";
-import { open, upper, expectAnchor } from "./timeline.mjs";
+import { open, upper, anchor, expectAnchor } from "./timeline.mjs";
// Reading setup must not accidentally exercise older-page loading.
test.use({ tallMessages: true });
@@ -35,6 +35,49 @@ test("reading setup handles partial wheel progress without weakening the anchor"
}
});
+test("reading anchor handles clipped paragraphs and still detects displacement", async ({
+ page,
+}) => {
+ // Deterministic geometry from the CI failure: both visible paragraphs are
+ // clipped, with no whole paragraph to select. This is a helper control, not
+ // a replacement for the production resize journeys.
+ await page.setContent(`
+
+ `);
+ const saved = await anchor(page);
+ expect(saved).toEqual({ id: "clipped", y: -30 });
+ await expectAnchor(page, saved);
+ await page.locator('[data-message-id="clipped"]').evaluate((row) => {
+ row.style.top = "-10px";
+ });
+ expect(await anchor(page)).toEqual({ id: saved.id, y: saved.y + 20 });
+ // The unchanged oracle must reject a real jump, not merely find the same ID.
+ await expect(expectAnchor(page, saved)).rejects.toThrow(
+ "same visible message clipped at same viewport Y",
+ );
+ await page.locator('[data-message-id="next"] p').evaluate((p) => {
+ p.style.height = "40px";
+ });
+ expect(await anchor(page)).toEqual({ id: "next", y: 130 });
+ await history(page)
+ .locator("[data-message-id]")
+ .evaluateAll((rows) => {
+ for (const row of rows) row.style.top = "300px";
+ });
+ await expect(anchor(page)).rejects.toThrow("No visible message anchor");
+});
+
test("reading setup rejects an immobile timeline instead of accepting a bottom anchor", async ({
page,
app,
diff --git a/tests/browser/timeline.mjs b/tests/browser/timeline.mjs
index 7ed822e6..e56b9871 100644
--- a/tests/browser/timeline.mjs
+++ b/tests/browser/timeline.mjs
@@ -34,13 +34,20 @@ export async function settle(page) {
export async function anchor(page) {
return history(page).evaluate((element) => {
const bounds = element.getBoundingClientRect();
- const row = Array.from(element.querySelectorAll("[data-message-id]")).find(
- (row) => {
+ const rows = Array.from(element.querySelectorAll("[data-message-id]"));
+ // Prefer a whole paragraph, but tall messages can leave only clipped rows.
+ // Track the first intersecting row in that case, as the reader does. The
+ // same-ID/Y assertion below still detects displacement after a resize.
+ const row =
+ rows.find((row) => {
const rect = row.querySelector("p").getBoundingClientRect();
return rect.top >= bounds.top && rect.bottom <= bounds.bottom;
- },
- );
- if (!row) throw new Error("No fully visible message anchor");
+ }) ??
+ rows.find((row) => {
+ const rect = row.getBoundingClientRect();
+ return rect.bottom > bounds.top && rect.top < bounds.bottom;
+ });
+ if (!row) throw new Error("No visible message anchor");
return {
id: row.dataset.messageId,
y: row.querySelector("p").getBoundingClientRect().top - bounds.top,
From a2c1c82a5d2fa3607b404f575480dd947eedd275 Mon Sep 17 00:00:00 2001
From: Fizz
<400e8babadcee6a7f420103f10a2849d84c4a9c71d5bd04f3948c814216648a3@buzz.block.builderlab.xyz>
Date: Mon, 14 Sep 2026 14:38:28 +0200
Subject: [PATCH 6/7] test: cover typing and incoming notifications after
rebase
Signed-off-by: Fizz <400e8babadcee6a7f420103f10a2849d84c4a9c71d5bd04f3948c814216648a3@buzz.block.builderlab.xyz>
---
src/features/relay/live.test.ts | 5 +-
src/features/relay/session.ts | 8 ++-
src/features/relay/typing.integration.test.ts | 66 +++++++++++++++++++
3 files changed, 77 insertions(+), 2 deletions(-)
diff --git a/src/features/relay/live.test.ts b/src/features/relay/live.test.ts
index 5de753d6..1a57032b 100644
--- a/src/features/relay/live.test.ts
+++ b/src/features/relay/live.test.ts
@@ -717,7 +717,10 @@ it("admits signed typing only on its authenticated channel route, without extra
await h.first.receive(["EVENT", requests[3]?.[1], event]);
expect(h.callbacks.receive).not.toHaveBeenCalled();
await h.first.receive(["EVENT", route[1], event]);
- expect(h.callbacks.receive).toHaveBeenCalledExactlyOnceWith([event]);
+ expect(h.callbacks.receive).toHaveBeenCalledExactlyOnceWith([event], {
+ channelId: "a",
+ phase: "replay",
+ });
await h.first.receive([
"EVENT",
route[1],
diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts
index c54d3fc4..f6a1cc23 100644
--- a/src/features/relay/session.ts
+++ b/src/features/relay/session.ts
@@ -993,7 +993,13 @@ export function createRelaySession(
events.filter((event) => event.kind === 20002),
true,
);
- if (closed || epoch !== accessEpoch || !candidates.size || !provenance?.channelId) return;
+ if (
+ closed ||
+ epoch !== accessEpoch ||
+ !candidates.size ||
+ !provenance?.channelId
+ )
+ return;
const delivered = new Set
();
const incoming: readonly IncomingMessage[] = Object.freeze(
visible.flatMap((event) => {
diff --git a/src/features/relay/typing.integration.test.ts b/src/features/relay/typing.integration.test.ts
index 11be6144..b8f5cefa 100644
--- a/src/features/relay/typing.integration.test.ts
+++ b/src/features/relay/typing.integration.test.ts
@@ -207,3 +207,69 @@ it("refreshing a finite kind-20002 view neither retains nor activates typing", a
owner.dispose();
}
});
+
+it.each(["none", "cache", "access", "dispose"] as const)(
+ "preserves incoming notifications alongside typing and fences reentrant %s",
+ async (transition) => {
+ vi.useFakeTimers();
+ vi.setSystemTime(1_800_000_000_000);
+ const viewer = keypair(),
+ relay = keypair(),
+ agent = keypair(),
+ peer = keypair();
+ const wire = scriptedTransport(viewer.pubkey, relay.pubkey);
+ let live!: LiveCallbacks;
+ const owner = createRelaySession({
+ ...wire.transport,
+ subscribe(callbacks) {
+ live = callbacks;
+ return { update() {}, retry() {}, dispose() {} };
+ },
+ });
+ let clearing: Promise | undefined;
+ try {
+ live.state({ status: "connected", routes: [] });
+ live.receive([roster(relay, "a", [viewer.pubkey])]);
+ const incoming = vi.fn();
+ owner.session.subscribeIncoming(incoming);
+ owner.session.typing.subscribe(() => {
+ if (!owner.session.typing.snapshot().length) return;
+ if (transition === "cache") clearing = owner.clearCache();
+ else if (transition === "access")
+ live.receive([roster(relay, "a", [], 1_800_000_001)]);
+ else if (transition === "dispose") owner.dispose();
+ });
+ const message = signed(peer, {
+ kind: 9,
+ content: "fixture",
+ created_at: 1_800_000_000,
+ tags: [["h", "a"]],
+ });
+ const pulse = signed(agent, {
+ kind: 20002,
+ content: "",
+ created_at: 1_800_000_000,
+ tags: [["h", "a"]],
+ });
+ live.receive([message, pulse], { phase: "live", channelId: "a" });
+ await clearing;
+ if (transition === "none") {
+ expect(owner.session.typing.snapshot()).toHaveLength(1);
+ expect(incoming).toHaveBeenCalledExactlyOnceWith([
+ expect.objectContaining({
+ messageId: message.id,
+ authorId: peer.pubkey,
+ }),
+ ]);
+ live.receive([pulse], { phase: "live", channelId: "a" });
+ expect(incoming).toHaveBeenCalledTimes(1);
+ } else {
+ expect(owner.session.typing.snapshot()).toEqual([]);
+ expect(incoming).not.toHaveBeenCalled();
+ }
+ } finally {
+ owner.dispose();
+ }
+ expect(vi.getTimerCount()).toBe(0);
+ },
+);
From f26cfbfbd7614ac8ca0ab971a33170068c35f2f6 Mon Sep 17 00:00:00 2001
From: pic-worker-aa352576c7
<6838876f3610dc213d19edeb1cdf5a8d0a0b568dc9b40db674ade16725074cce@buzz.block.builderlab.xyz>
Date: Tue, 15 Sep 2026 10:02:40 +0200
Subject: [PATCH 7/7] test: establish thread bottom before typing assertions
Signed-off-by: pic-worker-aa352576c7 <6838876f3610dc213d19edeb1cdf5a8d0a0b568dc9b40db674ade16725074cce@buzz.block.builderlab.xyz>
---
tests/browser/typing.spec.mjs | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/tests/browser/typing.spec.mjs b/tests/browser/typing.spec.mjs
index aea07584..ca282767 100644
--- a/tests/browser/typing.spec.mjs
+++ b/tests/browser/typing.spec.mjs
@@ -95,6 +95,13 @@ for (const scope of ["channel", "thread"]) {
history.evaluate(
(el) => el.scrollHeight - el.clientHeight - el.scrollTop,
);
+ if (scope === "thread") {
+ // Opening can race the final signed fixture replies under parallel load.
+ // Establish the bottom-reading precondition with real browser input before
+ // capturing geometry; the assertions below verify typing keeps it there.
+ await history.hover();
+ await page.mouse.wheel(0, Math.max(1, await gap()));
+ }
await expect.poll(gap).toBeLessThan(2);
expect(
await history.evaluate((el) => el.scrollHeight - el.clientHeight),