From 13cf21beeb7e9dc912cc00573298093829a0dc9d Mon Sep 17 00:00:00 2001 From: pic-worker-aa352576c7 <6838876f3610dc213d19edeb1cdf5a8d0a0b568dc9b40db674ade16725074cce@buzz.block.builderlab.xyz> Date: Fri, 11 Sep 2026 21:59:00 -0700 Subject: [PATCH 1/7] Add shared receive-only channel and thread typing Signed-off-by: pic-worker-aa352576c7 <6838876f3610dc213d19edeb1cdf5a8d0a0b568dc9b40db674ade16725074cce@buzz.block.builderlab.xyz> --- docs/relay-queries.md | 52 ++++++ src/features/messages/MessageComposer.tsx | 13 ++ src/features/messages/Messages.module.css | 8 + src/features/messages/TypingIndicator.tsx | 40 +++++ src/features/relay/live.test.ts | 30 ++++ src/features/relay/live.ts | 13 +- src/features/relay/session.ts | 26 ++- src/features/relay/typing.integration.test.ts | 106 +++++++++++ src/features/relay/typing.test.ts | 156 ++++++++++++++++ src/features/relay/typing.ts | 170 ++++++++++++++++++ tests/browser/fixture.mjs | 21 +++ tests/browser/typing.spec.mjs | 54 ++++++ 12 files changed, 687 insertions(+), 2 deletions(-) create mode 100644 src/features/messages/TypingIndicator.tsx create mode 100644 src/features/relay/typing.integration.test.ts create mode 100644 src/features/relay/typing.test.ts create mode 100644 src/features/relay/typing.ts create mode 100644 tests/browser/typing.spec.mjs diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 552d85e0..21f0529f 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -453,3 +453,55 @@ Exhausted attempts, unsupported pauses, other route/connection failures and unfinished finite roster/head failures still show a warning and recovery action. This presentation policy does not increase quotas or guarantee that another client sharing the account cannot cause a refusal. + +## Receive-only typing + +`session.typing.snapshot()` / `.subscribe()` expose immutable active entries +`{ channelId, threadRootId?, pubkey }`. The session owns one ephemeral projection; +shared `features/messages/TypingIndicator` renders it inside channel/thread +composers, including read-only connections. Page plugins consume the same UI and +session. Mounting consumers starts no reads, profile enrichment or subscriptions. +Names reuse the shared profile snapshot, with a public-key fragment fallback. +This reports signed typing activity (including agents), **not** inferred agent +execution, online presence, or a promise that an answer is coming. + +Kind 20002 joins the existing authenticated channel route, with the same signature +verification and socket/generation fencing. Typing must match that exact route, +name exactly one bounded `h`, belong to the current visible roster and pass the +existing channel access policy. Identity is the event signer, matching message +folding; arbitrary `p`/actor tags and profile display names do not confer identity. +A channel pulse has no `e`; a thread pulse has one marked canonical `reply`, with +at most one marked `root` for nested replies. Thread scope uses the existing +canonical root convention; it is not an extra target lookup or an access grant. +Malformed/ambiguous references are dropped rather than displayed at channel level. + +Only live pulses can activate typing. No typing payload enters finite views, +recent history, channel caches, unread counts, the outbox or persistent storage. +Expiry is eight seconds from the signed timestamp. Already-expired and future +activity is rejected (no clock-skew allowance). Duplicates/older pulses cannot +extend expiry. Signed content messages (9/40002) suppress their signer's same +channel/thread activity; timestamp watermarks reject older pulses and a two-second +post-message quiet period covers late activity. Old history does not clear newer +activity or repeatedly extend suppression. This does not reinterpret edits as +new messages or resolve relay-proxied author envelopes beyond current host rules. + +At most 1,024 active/suppression records and one timeout exist per session. At +capacity new identities/scopes are dropped until expiry; suppression evidence is +never evicted to admit a stale pulse. Access loss clears all typing conservatively, +before batched access notifications. Disconnect, cache clear, session disposal and +account/community replacement clear it too. Session disposal fences late callbacks; +plugins unloading only remove their UI subscriptions. There is no typing publisher, +new signer, transport, polling, durable cache, or plugin-owned ephemeral owner. + +Protocol reference: `block/buzz`'s +`crates/buzz-acp/src/relay.rs::build_typing_event` and +`desktop/src/features/messages/useChannelTyping.ts` (8-second TTL and 2-second +post-message suppression). Regression owners are `typing*.test.ts`, `live.test.ts` +and `tests/browser/typing.spec.mjs`; browser simulations use ephemeral fixture +keys through the production authenticated broker, never the deployed relay. + +**Review-sensitive:** `session.ts` (FOUNDATION admission/lifecycle and synchronous +subscriber reentrancy), `live.ts` (existing route filter/verification boundary), +`typing.ts` (timestamp, scope, bounds and suppression), and shared composer placement. +An existing development broker imports live routing at startup and needs one +coordinated restart to receive kind 20002; frontend hot reload alone is insufficient. diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 9639d419..a9aab60f 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -1,3 +1,4 @@ +import { TypingIndicator } from "./TypingIndicator"; import { ArrowUp, X } from "lucide-react"; import { useEffect, @@ -297,6 +298,11 @@ function Composer({ if (!outbox?.supports(9)) return ( ); @@ -311,6 +317,13 @@ function Composer({ send(); }} > + {!disabled && ( + + )} diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index d8ac4cd1..df8bdd2a 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -661,3 +661,11 @@ button.avatar:focus-visible, outline: 2px solid var(--focus); outline-offset: 2px; } + +.typing { + color: var(--text-muted); + font-size: calc(12px * var(--buzz-text-scale, 1)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/features/messages/TypingIndicator.tsx b/src/features/messages/TypingIndicator.tsx new file mode 100644 index 00000000..59d05f51 --- /dev/null +++ b/src/features/messages/TypingIndicator.tsx @@ -0,0 +1,40 @@ +import { useSyncExternalStore } from "react"; +import type { RelaySession } from "../relay/session"; +import styles from "./Messages.module.css"; + +/** Shared presentation only. Mounting more consumers creates no relay work. */ +export function TypingIndicator({ + session, + channelId, + threadRootId, +}: { + session: RelaySession; + channelId: string; + threadRootId?: string | undefined; +}) { + const entries = useSyncExternalStore( + session.typing.subscribe, + session.typing.snapshot, + ); + const profiles = useSyncExternalStore( + session.profiles.subscribe, + session.profiles.snapshot, + ); + const matching = entries.filter( + (entry) => + entry.channelId === channelId && entry.threadRootId === threadRootId, + ); + if (!matching.length) return null; + // Reuse already available names; optional typing must not trigger profile reads. + const names = matching + .slice(0, 3) + .map(({ pubkey }) => profiles.get(pubkey)?.name ?? pubkey.slice(0, 10)); + const others = matching.length - names.length; + return ( +
+ {names.join(", ")} + {others > 0 ? ` and ${others} others` : ""} + {matching.length === 1 ? " is typing…" : " are typing…"} +
+ ); +} diff --git a/src/features/relay/live.test.ts b/src/features/relay/live.test.ts index ff1520df..636d4b29 100644 --- a/src/features/relay/live.test.ts +++ b/src/features/relay/live.test.ts @@ -574,3 +574,33 @@ it("requests community emoji on the existing profile route and delivers verified expect(h.callbacks.receive).toHaveBeenCalledWith([event]); h.owner.dispose(); }); + +it("admits signed typing only on its authenticated channel route, without extra subscriptions", async () => { + vi.useFakeTimers(); + const h = setup(); + await h.first.auth(); + await vi.advanceTimersByTimeAsync(750); + const requests = h.first.requests(); + expect(requests).toHaveLength(4); + const route = requests[2]; + assert.exists(route); + expect(route[2].kinds).toContain(20002); + const event = signed(keypair(), { + kind: 20002, + content: "", + tags: [["h", "a"]], + }); + await h.first.receive(["EVENT", requests[0]?.[1], event]); + 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]); + await h.first.receive([ + "EVENT", + route[1], + { ...event, sig: "0".repeat(128) }, + ]); + expect(h.callbacks.receive).toHaveBeenCalledTimes(1); + h.owner.dispose(); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/src/features/relay/live.ts b/src/features/relay/live.ts index e4a6c2ab..fa6d3630 100644 --- a/src/features/relay/live.ts +++ b/src/features/relay/live.ts @@ -78,7 +78,7 @@ type Route = { quotaRetries: number; deadline?: ReturnType; }; -const CHANNEL_KINDS = [9, 40002, 40003, 5, 9005, 7, 39000, 39002, 39005]; +const CHANNEL_KINDS = [9, 40002, 40003, 5, 9005, 7, 39000, 39002, 39005, 20002]; /** One authenticated socket, independently established channel routes and two explicit globals. * Recent replay is opportunistic: finite reads own catch-up and history bounds. */ export function subscribeRelayTraffic( @@ -415,6 +415,17 @@ export function subscribeRelayTraffic( fail(route, "Relay supplied invalid live traffic"); return; } + // Ephemeral channel activity must arrive on that exact authenticated + // channel route; a global or another channel is not an access grant. + if ( + incoming.kind === 20002 && + (!route.channelId || + incoming.tags.filter(([name]) => name === "h").length !== 1 || + !incoming.tags.some( + ([name, value]) => name === "h" && value === route.channelId, + )) + ) + return; if (route.status === "pending") route.count++; callbacks.receive([incoming]); } else if (data[0] === "EOSE" && route.status === "pending") { diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index abbac089..80fe4c44 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -15,6 +15,7 @@ import { browserReadStateStorage, type ReadStateStorage, } from "./read-state-storage"; +import { createTyping } from "./typing"; import { createUnread } from "./unread"; import { readSidebarPreferences } from "./sidebar-preferences"; import { createSidebarPreferencesStore } from "./sidebar-preferences-store"; @@ -80,6 +81,14 @@ export function createRelaySession( else listener(); }; let canAccess: (id: string) => boolean = () => true; + const typing = createTyping( + transport?.viewer ?? "", + (id) => + !closed && + canAccess(id) && + channels.queries.list().channels.some((channel) => channel.id === id), + notify, + ); const recent = new ByteLru<{ event: RelayEvent; revision: number }>( 4096, 8 * 1024 * 1024, @@ -154,6 +163,7 @@ export function createRelaySession( revoking++; try { accessEpoch++; + typing.clear(); // Filters cannot tell us ownership of broad/ID/reference reads. Infrequent // authoritative access loss cancels them all, not merely explicit #h reads. requests.invalidate(); @@ -234,8 +244,13 @@ export function createRelaySession( events.some((event) => [39000, 39002].includes(event.kind)) ) channels.acceptDiscovery(events); - const visible = events.filter(visibility(events)); + // Ephemeral typing never enters finite views, history, caches or persistence. + const visible = events + .filter((event) => event.kind !== 20002) + .filter(visibility(events)); const epoch = accessEpoch; + typing.accept(visible); + if (closed || epoch !== accessEpoch) return []; profiling.measure( "events.reconcile", events[0]?.id ?? "empty", @@ -567,6 +582,7 @@ export function createRelaySession( notify, ); const session = Object.freeze({ + typing: typing.capability, unread: unread.capability, sidebarPreferences: sidebarPreferences.queries, live, @@ -882,9 +898,15 @@ export function createRelaySession( ) refreshRoster(); accept(events); + if (liveSnapshot.status === "connected") + typing.accept( + events.filter((event) => event.kind === 20002), + true, + ); }, state(snapshot) { if (closed) return; + if (snapshot.status !== "connected") typing.clear(); if ( snapshot.status !== "connected" && liveSnapshot.status === "connected" @@ -947,6 +969,7 @@ export function createRelaySession( async clearCache() { accessEpoch++; cacheClearEpoch++; + typing.clear(); sidebarPreferences.clear(); // New windows must not yield to or receive errors from retired owners. catchups.clear(); @@ -964,6 +987,7 @@ export function createRelaySession( }, dispose() { closed = true; + typing.dispose(); lifetime.abort(); sidebarPreferences.dispose(); stopInterests(); diff --git a/src/features/relay/typing.integration.test.ts b/src/features/relay/typing.integration.test.ts new file mode 100644 index 00000000..90eb0b8b --- /dev/null +++ b/src/features/relay/typing.integration.test.ts @@ -0,0 +1,106 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { createRelaySession } from "./session"; +import type { LiveCallbacks } from "./live"; +import { keypair, roster, signed, scriptedTransport } from "./testing"; + +afterEach(() => vi.useRealTimers()); +it("owns one ephemeral projection across views; purges on access, disconnect, cache and disposal", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_800_000_000_000); + const viewer = keypair(), + relay = keypair(), + agent = keypair(); + const wire = scriptedTransport(viewer.pubkey, relay.pubkey); + let live!: LiveCallbacks; + const dispose = vi.fn(), + subscribe = vi.fn((callbacks: LiveCallbacks) => { + live = callbacks; + return { update() {}, retry() {}, dispose }; + }); + const owner = createRelaySession({ ...wire.transport, subscribe }); + const pulse = signed(agent, { + kind: 20002, + content: "", + tags: [["h", "a"]], + created_at: 1_800_000_000, + }); + const view = owner.session.observe([ + { kinds: [20002], "#h": ["a"], limit: 10 }, + ]); + const stops = [1, 2].map(() => owner.session.typing.subscribe(() => {})); + live.state({ status: "connected", routes: [] }); + const snapshot = owner.session.typing.snapshot; + // No roster means no access, even for a signed event. + live.receive([pulse]); + expect(snapshot()).toEqual([]); + live.receive([roster(relay, "a", [viewer.pubkey])]); + live.receive([pulse]); + expect(snapshot()).toHaveLength(1); + expect(view.snapshot().events).toEqual([]); + expect(subscribe).toHaveBeenCalledTimes(1); + const callback = vi.fn(() => expect(snapshot()).toEqual([])); + const stop = owner.session.typing.subscribe(callback); + live.receive([roster(relay, "a", [], 1_800_000_001), pulse]); + expect(snapshot()).toEqual([]); + expect(callback).toHaveBeenCalled(); + stop(); + live.receive([roster(relay, "a", [viewer.pubkey], 1_800_000_002), pulse]); + expect(snapshot()).toHaveLength(1); + live.state({ status: "retrying", routes: [] }); + expect(snapshot()).toEqual([]); + live.receive([pulse]); + expect(snapshot()).toEqual([]); + live.state({ status: "connected", routes: [] }); + live.receive([pulse]); + expect(snapshot()).toHaveLength(1); + await owner.clearCache(); + expect(snapshot()).toEqual([]); + live.receive([roster(relay, "a", [viewer.pubkey], 1_800_000_003), pulse]); + owner.dispose(); + live.receive([pulse]); + expect(snapshot()).toEqual([]); + expect(dispose).toHaveBeenCalledTimes(1); + for (const stop of stops) stop(); + view.dispose(); + expect(vi.getTimerCount()).toBe(0); + const other = createRelaySession(null); + expect(other.session.typing.snapshot()).toEqual([]); + other.dispose(); +}); + +it("a synchronous typing listener cannot reseed retained views after disposal", () => { + const viewer = keypair(), + relay = keypair(), + agent = keypair(); + const wire = scriptedTransport(viewer.pubkey, relay.pubkey); + let live!: LiveCallbacks; + const owner = createRelaySession({ + ...wire.transport, + subscribe(callbacks) { + live = callbacks; + return { update() {}, retry() {}, dispose() {} }; + }, + }); + live.state({ status: "connected", routes: [] }); + live.receive([roster(relay, "a", [viewer.pubkey])]); + const at = Math.floor(Date.now() / 1000); + const pulse = signed(agent, { + kind: 20002, + content: "", + created_at: at, + tags: [["h", "a"]], + }); + live.receive([pulse]); + const view = owner.session.observe([{ kinds: [9], "#h": ["a"], limit: 10 }]); + owner.session.typing.subscribe(() => owner.dispose()); + live.receive([ + signed(agent, { + kind: 9, + content: "fixture", + created_at: at, + tags: [["h", "a"]], + }), + ]); + expect(view.snapshot().events).toEqual([]); + expect(owner.session.typing.snapshot()).toEqual([]); +}); diff --git a/src/features/relay/typing.test.ts b/src/features/relay/typing.test.ts new file mode 100644 index 00000000..76796501 --- /dev/null +++ b/src/features/relay/typing.test.ts @@ -0,0 +1,156 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { createTyping } from "./typing"; +import { keypair, message, signed } from "./testing"; + +const agent = keypair(), + viewer = keypair(); +const epoch = 1_800_000_000; +function setup() { + vi.useFakeTimers(); + vi.setSystemTime(epoch * 1000); + const owner = createTyping( + viewer.pubkey, + (id) => id === "a", + (fn) => fn(), + ); + return { owner, snapshot: owner.capability.snapshot }; +} +function pulse(tags = [["h", "a"]], at = epoch, key = agent) { + return signed(key, { kind: 20002, content: "", tags, created_at: at }); +} +afterEach(() => vi.useRealTimers()); +it("expires at signed time, ignores duplicates/out-of-order pulses and uses one timer", () => { + const { owner, snapshot } = setup(); + owner.accept([pulse()], true); + expect(snapshot()).toHaveLength(1); + expect(vi.getTimerCount()).toBe(1); + vi.advanceTimersByTime(3000); + owner.accept([pulse(), pulse(undefined, epoch - 1)], true); + vi.advanceTimersByTime(4999); + expect(snapshot()).toHaveLength(1); + vi.advanceTimersByTime(1); + expect(snapshot()).toEqual([]); + expect(vi.getTimerCount()).toBe(0); +}); +it("rejects finite, expired, future, self, denied and malformed channel/thread scope", () => { + const { owner, snapshot } = setup(); + owner.accept([pulse()]); + owner.accept( + [ + pulse(undefined, epoch - 8), + pulse(undefined, epoch + 1), + pulse(undefined, epoch, viewer), + pulse([]), + pulse([["h", "denied"]]), + pulse([ + ["h", "a"], + ["h", "a"], + ]), + pulse([ + ["h", "a"], + ["e", "bad", "", "reply"], + ]), + pulse([ + ["h", "a"], + ["e", "a".repeat(64), "", "root"], + ]), + pulse([ + ["h", "a"], + ["e", "a".repeat(64)], + ]), + ], + true, + ); + expect(snapshot()).toEqual([]); + expect(vi.getTimerCount()).toBe(0); +}); +it("separates channel, canonical threads, nested roots and multiple signers", () => { + const { owner, snapshot } = setup(); + const root = "a".repeat(64), + parent = "b".repeat(64); + owner.accept( + [ + pulse(), + pulse([ + ["h", "a"], + ["e", root, "", "reply"], + ]), + pulse(undefined, epoch, keypair()), + ], + true, + ); + expect(snapshot()).toHaveLength(3); + owner.accept( + [ + pulse([ + ["h", "a"], + ["e", root, "", "root"], + ["e", parent, "", "reply"], + ]), + ], + true, + ); + expect(snapshot()).toHaveLength(3); + expect(snapshot().filter((e) => e.threadRootId === root)).toHaveLength(1); + owner.dispose(); + expect(snapshot()).toEqual([]); + expect(vi.getTimerCount()).toBe(0); +}); +it("messages win a batch, suppress late pulses for two seconds and retain timestamp watermarks", () => { + const { owner, snapshot } = setup(); + owner.accept([pulse(), message(agent, "a", "fixture", epoch)], true); + expect(snapshot()).toEqual([]); + vi.advanceTimersByTime(1000); + owner.accept([pulse(undefined, epoch + 1)], true); + expect(snapshot()).toEqual([]); + vi.advanceTimersByTime(1000); + owner.accept([pulse(), pulse(undefined, epoch + 2)], true); + expect(snapshot()).toHaveLength(1); + // Duplicate history cannot extend suppression or remove newer activity. + owner.accept([message(agent, "a", "old", epoch)]); + expect(snapshot()).toHaveLength(1); + vi.advanceTimersByTime(8000); + expect(snapshot()).toEqual([]); +}); +it("message suppression is signer/thread scoped and clears only older activity", () => { + const { owner, snapshot } = setup(); + const tags = [ + ["h", "a"], + ["e", "a".repeat(64), "", "reply"], + ]; + owner.accept([pulse(), pulse(tags)], true); + owner.accept([message(agent, "a", "channel", epoch)]); + expect(snapshot()).toHaveLength(1); + expect(snapshot()[0]?.threadRootId).toBe("a".repeat(64)); + owner.accept([ + signed(agent, { kind: 40002, tags, content: "{}", created_at: epoch }), + ]); + expect(snapshot()).toEqual([]); +}); +it("bounds active and suppression records without eviction; teardown fences retained callbacks", () => { + const { owner, snapshot } = setup(); + // Distinct roots avoid generating 1025 signing keys. + owner.accept( + Array.from({ length: 1025 }, (_, i) => + pulse([ + ["h", "a"], + ["e", i.toString(16).padStart(64, "0"), "", "reply"], + ]), + ), + true, + ); + expect(snapshot()).toHaveLength(1024); + expect(vi.getTimerCount()).toBe(1); + const listener = vi.fn(); + const stop = owner.capability.subscribe(listener); + owner.clear(); + expect(snapshot()).toEqual([]); + expect(listener).toHaveBeenCalledTimes(1); + stop(); + owner.accept([pulse()], true); + expect(listener).toHaveBeenCalledTimes(1); + owner.dispose(); + owner.accept([pulse()], true); + expect(snapshot()).toEqual([]); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/src/features/relay/typing.ts b/src/features/relay/typing.ts new file mode 100644 index 00000000..415ca268 --- /dev/null +++ b/src/features/relay/typing.ts @@ -0,0 +1,170 @@ +import type { RelayEvent } from "./events"; +import { threadReference } from "./thread-reference"; + +const TTL = 8_000; +const SUPPRESS = 2_000; +const CAPACITY = 1024; +export type TypingEntry = Readonly<{ + channelId: string; + threadRootId?: string; + pubkey: string; +}>; +type Record = { + entry: TypingEntry; + typingAt: number; + messageAt: number; + expires: number; + suppress: number; + retire: number; +}; + +/** Receive-only ephemeral projection. Input is verified by the existing host + * transport; identity is the signer, never a display name or an untrusted p tag. + * No retained event payloads, reads, persistence, or per-consumer timers. */ +export function createTyping( + viewer: string, + canAccess: (channelId: string) => boolean, + notify: (listener: () => void) => void, +) { + let closed = false; + let timer: ReturnType | undefined; + const records = new Map(); + const listeners = new Set<() => void>(); + let snapshot: readonly TypingEntry[] = Object.freeze([]); + function publish() { + clearTimeout(timer); + timer = undefined; + const now = Date.now(); + let nextWake = Infinity; + const next: TypingEntry[] = []; + for (const [key, record] of records) { + if (record.retire <= now) { + records.delete(key); + continue; + } + nextWake = Math.min(nextWake, record.retire); + if (record.expires > now) { + next.push(record.entry); + nextWake = Math.min(nextWake, record.expires); + } + } + if (!closed && nextWake < Infinity) + timer = setTimeout(publish, nextWake - now); + if ( + next.length === snapshot.length && + next.every((e, i) => e === snapshot[i]) + ) + return; + snapshot = Object.freeze(next); + for (const listener of listeners) notify(listener); + } + function accept(events: readonly RelayEvent[], live = false) { + if (closed) return; + const now = Date.now(); + // Prune before admission; at capacity drop new keys, never evict suppression + // evidence to admit an older pulse. All records retire within a bounded TTL. + for (const [key, record] of records) + if (record.retire <= now) records.delete(key); + // Completion wins independent of batch ordering (including equal seconds). + for (const event of [...events].sort( + (a, b) => Number(a.kind === 20002) - Number(b.kind === 20002), + )) { + const typing = event.kind === 20002; + if ((!typing && ![9, 40002].includes(event.kind)) || (typing && !live)) + continue; + if (event.pubkey === viewer || !/^[0-9a-f]{64}$/.test(event.pubkey)) + continue; + const at = event.created_at * 1000; + if ( + !Number.isSafeInteger(event.created_at) || + at > now || + at + TTL <= now + ) + continue; + const channels = event.tags.filter(([name]) => name === "h"); + const channelId = channels[0]?.[1]; + if ( + channels.length !== 1 || + !channelId || + !/^[a-zA-Z0-9_-]{1,128}$/.test(channelId) || + !canAccess(channelId) + ) + continue; + const refs = event.tags.filter(([name]) => name === "e"); + // Canonical reply, optionally with one marked root for nested replies. + if ( + refs.length && + (refs.length > 2 || + refs.some( + (tag) => + !/^[0-9a-f]{64}$/i.test(tag[1] ?? "") || + !["root", "reply"].includes(tag[3] ?? ""), + ) || + refs.filter((tag) => tag[3] === "reply").length !== 1 || + refs.filter((tag) => tag[3] === "root").length > 1) + ) + continue; + const threadRootId = threadReference(event)?.rootId; + const key = `${channelId}:${threadRootId ?? ""}:${event.pubkey}`; + let record = records.get(key); + if (!record) { + if (records.size >= CAPACITY) continue; + record = { + entry: Object.freeze({ + channelId, + ...(threadRootId ? { threadRootId } : {}), + pubkey: event.pubkey, + }), + typingAt: -1, + messageAt: -1, + expires: 0, + suppress: 0, + retire: 0, + }; + records.set(key, record); + } + if (typing) { + if ( + at <= record.typingAt || + at <= record.messageAt || + record.suppress > now + ) + continue; + record.typingAt = at; + record.expires = at + TTL; + } else { + if (at <= record.messageAt) continue; + record.messageAt = at; + if (at >= record.typingAt) { + record.expires = 0; + record.suppress = now + SUPPRESS; + } + } + record.retire = Math.max(record.retire, at + TTL, record.suppress); + } + publish(); + } + function clear() { + records.clear(); + publish(); + } + return { + capability: Object.freeze({ + snapshot: () => snapshot, + subscribe(listener: () => void) { + if (closed) return () => {}; + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }), + accept, + clear, + dispose() { + closed = true; + clear(); + listeners.clear(); + }, + }; +} diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 309ae0dc..064f27a2 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -59,6 +59,7 @@ export const test = base.extend({ testInfo, ) => { const relayKey = generateSecretKey(); + const typingKeys = [generateSecretKey(), generateSecretKey()]; const userKey = generateSecretKey(); const viewer = getPublicKey(userKey); const peerKey = dmLabels || readState ? generateSecretKey() : undefined; @@ -645,6 +646,26 @@ export const test = base.extend({ expect(rosterIds).toContain(id); rosterIds.splice(rosterIds.indexOf(id), 1); }, + // Signed upstream-only simulations: never a browser publication or live relay. + activity({ + channel = "alpha", + root, + author = 0, + kind = 20002, + age = 0, + } = {}) { + if (!relay) + throw new Error("Typing fixture requires production broker"); + const event = sign( + kind, + [["h", channel], ...(root ? [["e", root, "", "reply"]] : [])], + kind === 20002 ? "" : "Fixture completion", + typingKeys[author], + Math.floor(Date.now() / 1000) - age, + ); + relay.publish("primary", event); + return event; + }, edit(community, channel, target, content) { const event = sign( 40003, diff --git a/tests/browser/typing.spec.mjs b/tests/browser/typing.spec.mjs new file mode 100644 index 00000000..4645ff5f --- /dev/null +++ b/tests/browser/typing.spec.mjs @@ -0,0 +1,54 @@ +import { test, expect } from "./fixture.mjs"; +import { open } 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 ({ + page, + app, +}, testInfo) => { + await open(page, app); + await expect + .poll(() => + app.report.liveRequests.some((r) => r.filter?.["#h"]?.includes("alpha")), + ) + .toBe(true); + const indicator = page.getByRole("status", { name: "Typing activity" }); + app.activity({ age: 9 }); + await expect(indicator).toHaveCount(0); + app.activity(); + await expect(indicator).toContainText("is typing…"); + app.activity({ author: 1 }); + await expect(indicator).toContainText("are typing…"); + await page.screenshot({ path: testInfo.outputPath("messages-typing.png") }); + app.activity({ kind: 9 }); + await expect(indicator).toContainText("is typing…"); + app.activity({ kind: 9, author: 1 }); + await expect(indicator).toHaveCount(0); + app.activity(); // same-second late pulse cannot resurrect completion + await expect(indicator).toHaveCount(0); + const root = app.histories + .get("primary/alpha") + .find((e) => e.content === "Thread root 0"); + await page + .locator(`[data-channel-timeline] [data-message-id="${root.id}"]`) + .getByRole("button", { name: /^View thread:/ }) + .click(); + const thread = page.getByRole("complementary", { + name: "Thread", + exact: true, + }); + await expect( + thread.getByRole("textbox", { name: "Reply to thread", exact: true }), + ).toBeVisible(); + app.activity({ root: root.id }); + await expect( + thread.getByRole("status", { name: "Typing activity" }), + ).toContainText("is typing…"); + await expect(indicator).toHaveCount(1); + await page.screenshot({ + path: testInfo.outputPath("messages-thread-typing.png"), + }); + // Real browser timer, signed timestamp TTL, no polling transport or fixture cleanup. + await expect(indicator).toHaveCount(0, { timeout: 10000 }); + expect(app.report.publications).toEqual([]); +}); From e899641095d9732007fdedacfb19326caf4c09b3 Mon Sep 17 00:00:00 2001 From: pic-worker-aa352576c7 <6838876f3610dc213d19edeb1cdf5a8d0a0b568dc9b40db674ade16725074cce@buzz.block.builderlab.xyz> Date: Fri, 11 Sep 2026 22:20:09 -0700 Subject: [PATCH 2/7] Fix typing completion scope and reentrant live admission Signed-off-by: pic-worker-aa352576c7 <6838876f3610dc213d19edeb1cdf5a8d0a0b568dc9b40db674ade16725074cce@buzz.block.builderlab.xyz> --- src/features/relay/session.ts | 11 +- src/features/relay/typing.integration.test.ts | 103 ++++++++++++++++++ src/features/relay/typing.test.ts | 57 ++++++++++ src/features/relay/typing.ts | 4 +- 4 files changed, 173 insertions(+), 2 deletions(-) diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 80fe4c44..57c19e48 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -897,8 +897,17 @@ export function createRelaySession( ) ) refreshRoster(); + const epoch = accessEpoch; + const generation = liveGeneration; accept(events); - if (liveSnapshot.status === "connected") + // Completion subscribers can synchronously clear, revoke or retire this + // live delivery. Do not admit its remaining pulses into the new lifetime. + if ( + !closed && + epoch === accessEpoch && + generation === liveGeneration && + liveSnapshot.status === "connected" + ) typing.accept( events.filter((event) => event.kind === 20002), true, diff --git a/src/features/relay/typing.integration.test.ts b/src/features/relay/typing.integration.test.ts index 90eb0b8b..11be6144 100644 --- a/src/features/relay/typing.integration.test.ts +++ b/src/features/relay/typing.integration.test.ts @@ -104,3 +104,106 @@ it("a synchronous typing listener cannot reseed retained views after disposal", expect(view.snapshot().events).toEqual([]); expect(owner.session.typing.snapshot()).toEqual([]); }); + +for (const transition of ["cache", "dispose", "access", "reconnect"] as const) { + it(`fences the rest of a live callback batch after reentrant ${transition}`, async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_800_000_000_000); + const viewer = keypair(), + relay = keypair(), + agent = keypair(); + const wire = scriptedTransport(viewer.pubkey, relay.pubkey); + let live!: LiveCallbacks; + const owner = createRelaySession({ + ...wire.transport, + subscribe(callbacks) { + live = callbacks; + return { update() {}, retry() {}, dispose() {} }; + }, + }); + live.state({ status: "connected", routes: [] }); + live.receive([ + roster(relay, "a", [viewer.pubkey]), + roster(relay, "b", [viewer.pubkey]), + ]); + const pulse = signed(agent, { + kind: 20002, + content: "", + created_at: 1_800_000_000, + tags: [["h", "a"]], + }); + live.receive([pulse]); + expect(owner.session.typing.snapshot()).toHaveLength(1); + let clearing: Promise | undefined; + const listener = vi.fn(() => { + expect(owner.session.typing.snapshot()).toEqual([]); + if (transition === "cache") clearing = owner.clearCache(); + else if (transition === "dispose") owner.dispose(); + // Revoking another channel still invalidates the in-flight access epoch. + else if (transition === "access") + live.receive([roster(relay, "b", [], 1_800_000_001)]); + else { + live.state({ status: "retrying", routes: [] }); + live.state({ status: "connected", routes: [] }); + } + }); + owner.session.typing.subscribe(listener); + // Supported LiveCallbacks batch boundary; WS/SSE currently deliver singletons. + live.receive([ + signed(agent, { + kind: 9, + content: "fixture", + created_at: 1_800_000_000, + tags: [["h", "a"]], + }), + pulse, + ]); + await clearing; + expect(listener).toHaveBeenCalledTimes(1); + expect(owner.session.typing.snapshot()).toEqual([]); + owner.dispose(); + expect(vi.getTimerCount()).toBe(0); + }); +} + +it("refreshing a finite kind-20002 view neither retains nor activates typing", async () => { + const viewer = keypair(), + relay = keypair(), + agent = keypair(); + const wire = scriptedTransport(viewer.pubkey, relay.pubkey); + let live!: LiveCallbacks; + const owner = createRelaySession({ + ...wire.transport, + subscribe(callbacks) { + live = callbacks; + return { update() {}, retry() {}, dispose() {} }; + }, + }); + try { + live.state({ status: "connected", routes: [] }); + live.receive([roster(relay, "a", [viewer.pubkey])]); + const filters = [{ kinds: [20002], "#h": ["a"], limit: 10 }]; + const view = owner.session.observe(filters); + const pulse = signed(agent, { + kind: 20002, + content: "", + created_at: Math.floor(Date.now() / 1000), + tags: [["h", "a"]], + }); + const refresh = view.refresh(); + const read = wire.next(); + expect(read.filters).toEqual(filters); + read.respond([pulse]); + await refresh; + expect(view.snapshot()).toMatchObject({ status: "ready", events: [] }); + expect(owner.session.typing.snapshot()).toEqual([]); + // The same signed event is valid live, but still cannot enter finite views. + live.receive([pulse]); + expect(owner.session.typing.snapshot()).toHaveLength(1); + expect(view.snapshot().events).toEqual([]); + const later = owner.session.observe(filters); + expect(later.snapshot().events).toEqual([]); + } finally { + owner.dispose(); + } +}); diff --git a/src/features/relay/typing.test.ts b/src/features/relay/typing.test.ts index 76796501..e63db962 100644 --- a/src/features/relay/typing.test.ts +++ b/src/features/relay/typing.test.ts @@ -58,6 +58,16 @@ it("rejects finite, expired, future, self, denied and malformed channel/thread s ["h", "a"], ["e", "a".repeat(64)], ]), + pulse([ + ["h", "a"], + ["e", "a".repeat(64), "", "mention"], + ]), + pulse([ + ["h", "a"], + ["e", "a".repeat(64), "", "root"], + ["e", "b".repeat(64), "", "reply"], + ["e", "c".repeat(64), "", "mention"], + ]), ], true, ); @@ -154,3 +164,50 @@ it("bounds active and suppression records without eviction; teardown fences reta expect(snapshot()).toEqual([]); expect(vi.getTimerCount()).toBe(0); }); + +for (const kind of [9, 40002]) { + for (const scope of ["mention", "quote", "reply", "nested"] as const) { + it(`kind ${kind} ${scope} content clears and suppresses its authoritative scope`, () => { + const { owner, snapshot } = setup(); + const root = "a".repeat(64); + const threadTags = [ + ["h", "a"], + ["e", root, "", "reply"], + ]; + const references = + scope === "mention" + ? [["e", "b".repeat(64), "", "mention"]] + : scope === "quote" + ? [["e", "b".repeat(64)]] + : [ + ...(scope === "nested" ? [["e", root, "", "root"]] : []), + ["e", scope === "nested" ? "c".repeat(64) : root, "", "reply"], + ["e", "b".repeat(64), "", "mention"], + ["e", "d".repeat(64)], + ]; + const threaded = scope === "reply" || scope === "nested"; + const target = pulse(threaded ? threadTags : undefined); + const other = pulse(threaded ? undefined : threadTags); + owner.accept([target, other], true); + owner.accept([ + signed(agent, { + kind, + content: "fixture", + created_at: epoch, + tags: [["h", "a"], ...references], + }), + ]); + expect(snapshot()).toHaveLength(1); + expect(snapshot()[0]?.threadRootId).toBe(threaded ? undefined : root); + // Same-second replay and a newer pulse during the quiet period both lose. + owner.accept([target], true); + vi.advanceTimersByTime(1000); + owner.accept([pulse(threaded ? threadTags : undefined, epoch + 1)], true); + expect(snapshot()).toHaveLength(1); + vi.advanceTimersByTime(1000); + owner.accept([pulse(threaded ? threadTags : undefined, epoch + 2)], true); + expect(snapshot()).toHaveLength(2); + owner.dispose(); + }); + } +} diff --git a/src/features/relay/typing.ts b/src/features/relay/typing.ts index 415ca268..dbec2792 100644 --- a/src/features/relay/typing.ts +++ b/src/features/relay/typing.ts @@ -91,8 +91,10 @@ export function createTyping( ) continue; const refs = event.tags.filter(([name]) => name === "e"); - // Canonical reply, optionally with one marked root for nested replies. + // Pulses require an unambiguous canonical scope. Content uses the same + // threadReference semantics as folding, including non-thread references. if ( + typing && refs.length && (refs.length > 2 || refs.some( From fa065ddb560d9e82d57f5ac0fa4691d23858dee7 Mon Sep 17 00:00:00 2001 From: Fizz <400e8babadcee6a7f420103f10a2849d84c4a9c71d5bd04f3948c814216648a3@buzz.block.builderlab.xyz> Date: Thu, 10 Sep 2026 17:16:02 -0700 Subject: [PATCH 3/7] feat(pulse): checkpoint source page experiment Signed-off-by: Fizz <400e8babadcee6a7f420103f10a2849d84c4a9c71d5bd04f3948c814216648a3@buzz.block.builderlab.xyz> --- crates/plugin-manager/src/lib.rs | 2 + crates/plugin-manager/tests/management.rs | 1 + src/app/pages.integration.test.mjs | 20 +- src/bundled/index.ts | 3 + src/bundled/pulse/Pulse.module.css | 333 ++++++++++++++++++++ src/bundled/pulse/PulseConversation.tsx | 92 ++++++ src/bundled/pulse/PulsePage.tsx | 354 ++++++++++++++++++++++ src/bundled/pulse/README.md | 83 +++++ src/bundled/pulse/feed.test.ts | 116 +++++++ src/bundled/pulse/feed.ts | 83 +++++ src/bundled/pulse/index.tsx | 16 + src/bundled/pulse/manifest.json | 1 + src/bundled/pulse/usePulseFeed.ts | 105 +++++++ tests/browser/pulse-lifecycle.spec.mjs | 81 +++++ tests/browser/pulse.spec.mjs | 79 +++++ 15 files changed, 1367 insertions(+), 2 deletions(-) create mode 100644 src/bundled/pulse/Pulse.module.css create mode 100644 src/bundled/pulse/PulseConversation.tsx create mode 100644 src/bundled/pulse/PulsePage.tsx create mode 100644 src/bundled/pulse/README.md create mode 100644 src/bundled/pulse/feed.test.ts create mode 100644 src/bundled/pulse/feed.ts create mode 100644 src/bundled/pulse/index.tsx create mode 100644 src/bundled/pulse/manifest.json create mode 100644 src/bundled/pulse/usePulseFeed.ts create mode 100644 tests/browser/pulse-lifecycle.spec.mjs create mode 100644 tests/browser/pulse.spec.mjs diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 4cc98d0a..c1cbb57a 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -65,6 +65,8 @@ pub fn bundled_manifests() -> Vec { .expect("projects manifest"), serde_json::from_str(include_str!("../../../src/bundled/agents/manifest.json")) .expect("agents manifest"), + serde_json::from_str(include_str!("../../../src/bundled/pulse/manifest.json")) + .expect("pulse manifest"), ] } fn is_bundled(id: &str) -> bool { diff --git a/crates/plugin-manager/tests/management.rs b/crates/plugin-manager/tests/management.rs index 678b7f06..b1902388 100644 --- a/crates/plugin-manager/tests/management.rs +++ b/crates/plugin-manager/tests/management.rs @@ -278,6 +278,7 @@ fn bundled_plugins_have_independent_flags_and_all_ids_are_reserved() { "buzz.agents", "buzz.emoji", "buzz.mentions", + "buzz.pulse", ] { assert!( manager diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index ed4fa6db..359cbaa4 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -29,7 +29,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", services = createServices(); assert.deepEqual(services.pages.snapshot(), []); await settle(); - assert.equal(services.pages.snapshot().length, 3); + assert.equal(services.pages.snapshot().length, 4); await vi.waitFor(() => assert.equal(services.conversation.tools.snapshot().length, 2), ); @@ -85,7 +85,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", .some((panel) => panel.pluginId === "buzz.bestie"), false, ); - assert.equal(services.pages.snapshot().length, 3); + assert.equal(services.pages.snapshot().length, 4); await services.plugins.change("enable", "buzz.bestie"); // Management completion is not activation completion; Cordis still owns import/disposal barriers. await vi.waitFor(() => @@ -100,6 +100,22 @@ test("the app runtime exposes ready bundled pages and removes them on disable", .find((panel) => panel.pluginId === "buzz.bestie"); assert.notEqual(secondBestie, firstBestie); assert.equal(secondBestie.revision, firstBestie.revision); + const pulse = services.pages + .snapshot() + .find((page) => page.pluginId === "buzz.pulse"); + assert.equal(pulse.title, "Pulse"); + assert.equal(pulse.companion, true); + assert.match( + renderToStaticMarkup(createElement(pulse.component)), + /A little closer to what matters/, + ); + const pulseSession = services.relay.snapshot().session; + await services.plugins.change("disable", "buzz.pulse"); + assert.equal( + services.pages.snapshot().some((page) => page.pluginId === "buzz.pulse"), + false, + ); + assert.equal(services.relay.snapshot().session, pulseSession); const page = services.pages.snapshot()[0]; assert.match( renderToStaticMarkup(createElement(page.component)), diff --git a/src/bundled/index.ts b/src/bundled/index.ts index 78cf15c0..18e9d76e 100644 --- a/src/bundled/index.ts +++ b/src/bundled/index.ts @@ -2,6 +2,8 @@ import terminalManifest from "./terminal/manifest.json"; import * as terminal from "./terminal"; import profilesManifest from "./profiles/manifest.json"; import * as profiles from "./profiles"; +import pulseManifest from "./pulse/manifest.json"; +import * as pulse from "./pulse"; import mentionsManifest from "./mentions/manifest.json"; import * as mentions from "./mentions"; import emojiManifest from "./emoji/manifest.json"; @@ -28,4 +30,5 @@ export const bundledPlugins: readonly BundledPlugin[] = [ { manifest: { ...bestieManifest, apiVersion: 1 }, module: bestie }, { manifest: { ...projectsManifest, apiVersion: 1 }, module: projects }, { manifest: { ...agentsManifest, apiVersion: 1 }, module: agents }, + { manifest: { ...pulseManifest, apiVersion: 1 }, module: pulse }, ]; diff --git a/src/bundled/pulse/Pulse.module.css b/src/bundled/pulse/Pulse.module.css new file mode 100644 index 00000000..78d177d2 --- /dev/null +++ b/src/bundled/pulse/Pulse.module.css @@ -0,0 +1,333 @@ +/* Page-owned selector trees are disjoint; no host or shared message overrides. */ +/* biome-ignore-all lint/style/noDescendingSpecificity: Selectors belong to disjoint Pulse regions. */ +.root { + height: 100%; + min-height: 0; +} +.canvas { + display: flex; + height: 100%; + min-height: 0; + max-width: 960px; + margin: 0 auto; + overflow: hidden; + border-radius: var(--radius-card); + background: var(--surface); + box-shadow: var(--elevation-card); + color: var(--text); +} +.sidebar { + width: 220px; + flex: 0 0 220px; + display: flex; + flex-direction: column; + min-height: 0; + padding: 24px 12px 12px; + border-right: 1px solid var(--border); +} +.brand { + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px 24px; + font-size: 18px; +} +.sidebar nav button { + border: 0; + box-shadow: none; + width: 100%; + display: flex; + align-items: center; + text-align: left; + gap: 10px; + background: transparent; + padding: 10px 12px; + font-size: 13px; + border-radius: var(--radius-control); +} +.sidebar nav button:hover { + background: var(--surface-hover); +} +.sidebar nav button[aria-current="page"] { + background: var(--selected); + color: var(--on-selected); +} +.navIcon { + display: grid; + place-items: center; + border-radius: 50%; + width: 28px; + height: 28px; + background: var(--surface-control); + color: var(--text); +} +.sidebarLabel { + margin: 28px 12px 12px; + color: var(--text-muted); + font-size: 10px; + letter-spacing: 0.08em; + font-weight: 600; +} +.channelSearch { + min-width: 0; + width: 100%; + font-size: 12px; + margin-bottom: 8px; +} +.channelList { + min-height: 0; + flex: 1; + overflow: auto; +} +.channelList button span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.channelList svg { + flex-shrink: 0; +} +.sidebarFoot { + font-size: 11px; + line-height: 1.6; + color: var(--text-muted); + padding: 12px; + margin: 0; +} +.sidebarError { + font-size: 12px; + overflow-wrap: anywhere; +} +.main { + min-width: 0; + min-height: 0; + flex: 1; + display: flex; + flex-direction: column; +} +.feedHeading { + display: flex; + justify-content: space-between; + align-items: center; + padding: 36px 32px 8px; + gap: 12px; +} +.feedHeading button { + border: 0; + background: transparent; + padding: 8px; +} +.feedHeading h1 { + margin: 8px 0; + font-size: 30px; + font-weight: 500; + letter-spacing: -0.04em; +} +.eyebrow { + color: var(--text-muted); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; +} +.intro { + padding: 0 32px 24px; + color: var(--text-muted); + font-size: 13px; + line-height: 1.6; +} +.search { + display: flex; + align-items: center; + gap: 8px; + margin: 0 32px 16px; + background: var(--surface-input); + border: 1px solid var(--border-input); + border-radius: var(--radius-control); + padding: 0 12px; +} +.search input { + width: 100%; + min-width: 0; + border: 0; + background: transparent; +} +.feed { + flex: 1; + min-height: 0; + overflow: auto; + overflow-anchor: auto; +} +.card { + padding: 20px 16px 16px; + border-top: 1px solid var(--border); +} +.source { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: space-between; + width: 100%; + text-align: left; + border: 0; + background: transparent; + color: var(--text-muted); + font-size: 11px; + padding: 0 12px 8px; +} +.source span:last-child { + font-size: 10px; +} +.reply { + display: flex; + gap: 8px; + align-items: center; + border: 0; + background: var(--surface-control); + color: var(--text-muted); + font-size: 11px; + border-radius: 24px; + margin: 12px 0 0 56px; + padding: 7px 12px; +} +.empty, +.connect { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 48px 24px; + color: var(--text-muted); +} +.empty h2 { + font-size: 18px; + color: var(--text); + font-weight: 500; +} +.empty p, +.connect p { + max-width: 360px; + font-size: 13px; + line-height: 1.6; +} +.connect { + height: 100%; + border-radius: var(--radius-card); + background: var(--surface); +} +.connect h1 { + font-size: 30px; + color: var(--text); + font-weight: 500; +} +.footer { + text-align: center; + padding: 28px; + font-size: 11px; + line-height: 1.8; + color: var(--text-muted); +} +.notice { + margin: 16px; + padding: 16px; + color: var(--danger); + border: 1px solid var(--border); + border-radius: var(--radius-control); + font-size: 13px; +} +.detail { + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; +} +.heading { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + border-bottom: 1px solid var(--border); +} +.heading strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; +} +.heading button { + display: grid; + place-items: center; + border: 0; + background: transparent; +} +@media (max-width: 700px) { + .sidebar { + width: 160px; + flex-basis: 160px; + padding: 16px 8px 8px; + } + .feedHeading { + padding: 24px 20px 8px; + } + .intro { + padding: 0 20px 20px; + } + .search { + margin: 0 20px 16px; + } +} +@media (max-width: 520px) { + .canvas { + flex-direction: column; + } + .sidebar { + width: auto; + flex: 0 0 auto; + border-right: 0; + border-bottom: 1px solid var(--border); + padding: 8px; + } + .sidebar nav:first-of-type { + display: flex; + } + .sidebar nav:first-of-type button { + justify-content: center; + gap: 4px; + font-size: 12px; + padding: 6px; + } + .brand, + .sidebarLabel, + .sidebarFoot, + .channelSearch { + display: none; + } + .channelList { + display: flex; + max-height: 44px; + } + .channelList button { + flex: 0 0 auto; + max-width: 160px; + } + .reading .sidebar { + display: none; + } + .feedHeading { + padding-top: 20px; + } + .eyebrow { + font-size: 9px; + } + .card { + padding: 16px 8px; + } +} + +.threadHost { + display: grid; + grid-template-rows: minmax(0, 1fr); + flex: 1; + min-height: 0; +} diff --git a/src/bundled/pulse/PulseConversation.tsx b/src/bundled/pulse/PulseConversation.tsx new file mode 100644 index 00000000..4cc31c3e --- /dev/null +++ b/src/bundled/pulse/PulseConversation.tsx @@ -0,0 +1,92 @@ +import { useState } from "react"; +import { ArrowLeft } from "lucide-react"; +import type { RelaySession } from "../../features/relay/session"; +import type { ConversationExtensions } from "../../features/conversation/contracts"; +import { useChannelWindow } from "../../features/relay/react"; +import { ChannelTimeline } from "../../features/messages/ChannelTimeline"; +import { MessageComposer } from "../../features/messages/MessageComposer"; +import { ThreadPanel } from "../../features/messages/ThreadPanel"; +import styles from "./Pulse.module.css"; +const openLink = () => false; +export function PulseConversation({ + session, + scope, + channelId, + name, + extensions, + back, + initialThread, +}: { + session: RelaySession; + scope: string; + channelId: string; + name: string; + extensions?: ConversationExtensions | undefined; + back(): void; + initialThread?: string | undefined; +}) { + const window = useChannelWindow(session.channels, channelId); + const [thread, setThread] = useState(initialThread); + const [sent, setSent] = useState(); + return ( +
+
+ + {name} +
+ {thread ? ( +
+ setThread(undefined)} + onOpenLink={openLink} + /> +
+ ) : ( + <> + {window.status === "error" && !window.rows.length ? ( +
+

{window.error}

+ +
+ ) : window.status !== "ready" && !window.rows.length ? ( +

+ Loading messages… +

+ ) : ( + + )} + + + )} +
+ ); +} diff --git a/src/bundled/pulse/PulsePage.tsx b/src/bundled/pulse/PulsePage.tsx new file mode 100644 index 00000000..8632fdbb --- /dev/null +++ b/src/bundled/pulse/PulsePage.tsx @@ -0,0 +1,354 @@ +import { useMemo, useRef, useState, type ReactNode } from "react"; +import { + Hash, + Inbox, + MessageCircle, + Search, + Sparkles, + RefreshCw, +} from "lucide-react"; +import type { RelayData } from "../../features/relay/service"; +import type { RelaySession } from "../../features/relay/session"; +import type { ConversationExtensions } from "../../features/conversation/contracts"; +import { useChannelList, useRelayConnection } from "../../features/relay/react"; +import { PanelFrame } from "../../features/panels/PanelFrame"; +import { MessageRow } from "../../features/messages/MessageRow"; +import { readView, writeView } from "../../shared/view-state"; +import { channelLabel, filterRows, visibleChannels } from "./feed"; +import { usePulseFeed } from "./usePulseFeed"; +import { PulseConversation } from "./PulseConversation"; +import styles from "./Pulse.module.css"; + +type View = "for-you" | "all" | "search"; +type Selection = { channelId: string; thread?: string | undefined }; +const views = [ + { id: "search", title: "Search", icon: Search }, + { id: "for-you", title: "For you", icon: Sparkles }, + { id: "all", title: "All messages", icon: MessageCircle }, +] as const; +export function PulsePage({ + relay, + extensions, + companion, +}: { + relay: RelayData; + extensions?: ConversationExtensions | undefined; + companion?: ReactNode; +}) { + const connection = useRelayConnection(relay); + return ( +
+ + {connection.status === "ready" ? ( + + ) : ( +
+
+ )} +
+
+ ); +} +function PulseWorkspace({ + session, + scope, + viewer, + extensions, +}: { + session: RelaySession; + scope: string; + viewer?: string | undefined; + extensions?: ConversationExtensions | undefined; +}) { + const list = useChannelList(session.channels); + const feed = usePulseFeed(session, list.channels, list.status === "ready"); + const channels = visibleChannels(list.channels); + const [view, setView] = useState(() => { + const saved = readView(scope, "pulse-view", "all"); + return saved === "for-you" || saved === "search" ? saved : "all"; + }); + const [selected, setSelected] = useState(() => { + const id = readView(scope, "pulse-channel", undefined); + return typeof id === "string" ? { channelId: id } : undefined; + }); + const [search, setSearch] = useState(""); + const [channelSearch, setChannelSearch] = useState(""); + const trigger = useRef(null); + const current = channels.find( + (channel) => channel.id === selected?.channelId, + ); + const open = (channelId: string, thread?: string) => { + trigger.current = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + setSelected({ channelId, thread }); + writeView(scope, "pulse-channel", channelId); + }; + const back = () => { + setSelected(undefined); + writeView(scope, "pulse-channel", null); + requestAnimationFrame(() => { + if (trigger.current?.isConnected) trigger.current.focus(); + }); + }; + const switchView = (next: View) => { + setView(next); + writeView(scope, "pulse-view", next); + back(); + }; + const rows = useMemo( + () => + filterRows(feed.rows, list.channels, feed.profiles, view, search, viewer), + [feed.rows, list.channels, feed.profiles, view, search, viewer], + ); + const title = views.find((item) => item.id === view)?.title; + const loading = + list.status === "loading" || + (list.status === "ready" && + channels.length > 0 && + !feed.error && + feed.status === "loading"); + return ( +
+ +
+ {current ? ( + + ) : ( + <> +
+
+ + YOUR COMMUNITY, AT A GLANCE + +

{title}

+
+ +
+
+ {view === "for-you" + ? "Your direct conversations and messages that mention you. Original excerpts, not AI summaries." + : view === "search" + ? "Find something in the recent activity loaded here." + : "A quieter way to catch up. Open a conversation and pick up where it left off."} +
+ {view === "search" && ( + + )} +
+ {(feed.error || list.status === "error") && ( +
+

+ Some activity couldn’t be loaded. This feed may be out of + date. +

+ +
+ )} + {loading && !rows.length ? ( +

+ Gathering recent conversations… +

+ ) : rows.length ? ( + rows.map((row) => { + const channel = channels.find( + (item) => item.id === row.channelId, + ); + if (!channel) return null; + return ( +
+ + false} + /> + +
+ ); + }) + ) : ( +
+
+ )} +
+ Recent activity, not complete history + {list.coverage === "partial" ? " · Partial channel list" : ""}. +
+ Replies stay in their original conversation. No messages are + sent to an AI provider. +
+
+ + )} +
+
+ ); +} diff --git a/src/bundled/pulse/README.md b/src/bundled/pulse/README.md new file mode 100644 index 00000000..bead257e --- /dev/null +++ b/src/bundled/pulse/README.md @@ -0,0 +1,83 @@ +# Pulse experiment + +A bundled **source page plugin**, independently toggleable in Settings → Plugins. +This is not a self-contained external API-v1 install artifact. Run this branch of +buzz-app and choose **Pulse** in the top navigation. Messages is unchanged. + +## Design reference + +Inspired by [`block/buzz`'s `am-pulse-proto`](https://github.com/block/buzz/tree/2d620055e574f41f466ae13b88cfb0aa77ede068/desktop/src/features/pulse), +inspected against merge base `cec5c8fd9280d30f56effac701e1e19d5cfe6fea` with main. +The relevant additions are `UnifiedPulseView`, `PulseConversationSplitView`, +`PulseBriefing`, and the feature README—not the branch's unrelated mobile, +identity, agent-recovery, or broader shell changes. + +Carried over: +- A centered 960px rounded canvas, quiet separators, compact 220px conversation + navigation, and Search / For you / All messages destinations. +- Author-led activity with source-conversation links and in-place thread drill-in. +- Conversation selection independent of feed order; shared, separately scoped + channel/thread drafts; responsive navigation and light/dark semantic tokens. + +Adapted to buzz-app: +- The host keeps its own navigation, canvas, appearance and companion launcher. +- A page owns its React tree and CSS module; no host or `FOUNDATION` file changes. +- `relay` supplies one existing session. The page creates one bounded observed + activity view after the roster is available, disposed on unmount/roster change. + No new socket, cache, signer, outbox, polling loop, or model-provider connection. +- Reuses `ChannelTimeline`, `MessageRow`, `MessageComposer`, `ThreadPanel` and + `PanelFrame`. Emoji/Mentions remain optional conversation contributions. + Shared message styling is retained rather than copied or overridden with + prototype bubble styles. Links keep ordinary external navigation in this pass; + only the host companion dock is embedded, not a second local object dock. +- Session-owned state remounts by scope **and** generation. Navigation and drafts + persist under stable scope, not generation. The full roster's membership key + participates in missing DM-profile recovery; names never gate conversation reads. + +## Honest boundaries + +**For you is not an AI briefing.** It filters recent original top-level posts to +DMs and exact `p`-tag mentions of the viewer. It does not infer requests from prose, +agent runtime status or unread state. No provider receives messages. buzz-app does +not currently expose a shared summarization or read-marker capability. + +The feed requests 200 recent events across the visible joined roster and shows at +most 30 conversations, one newest matching root each. Host retained-view limits +still apply. Busy channels may dominate the window; search covers retained activity, +not full history. The channel rail remains roster-ordered, not a fabricated activity +ranking. A partial roster is labeled. Hidden non-DM and archived channels are omitted. + +Author edits/deletes use the shared fold. Delivery uncertainty remains visible. +Feed rows deliberately do not interpret relay thread summaries: the session does +not expose the signing identity needed to validate those summaries in this page. +Opening a thread uses the authoritative shared reader and its existing bounded, +oldest-first history behavior. Selected channel state survives reload; thread +selection and feed search do not become new URL routes. + +## Trying it + +From this worktree: + +```sh +bin/just web +``` + +For actual community data, use the repository's existing opt-in live setup in +[README](../../../../README.md#relay-channels), then `BUZZ_LIVE=1 bin/just web`. +No identity configuration is added by this plugin. Packaged login and native +acceptance remain the host's existing limitations. + +## Checks + +- `feed.test.ts`: visible sources, deterministic grouping, author edits/deletes, + failed-edit rollback, exact mentions, bounded search and DM names. +- App composition test: contribution registration, disable and shared-session lifetime. +- Native catalog test: independent enable/disable flag and reserved bundled identity. +- `tests/browser/pulse*.spec.mjs`: Chromium/WebKit search, conversation opening, + separate retained drafts, thread reader, live source edits, access revocation, + plugin unload/re-enable, companion placement, theme and narrow viewport. + +Browser fixtures use synthetic signed data; they do not publish to a real relay. +The lifecycle fixture exercises reply draft composition, not successful publication. +A full `just scan`, attended live use, final design approval and packaged native +acceptance are required before calling this ready for integration. diff --git a/src/bundled/pulse/feed.test.ts b/src/bundled/pulse/feed.test.ts new file mode 100644 index 00000000..57609e4f --- /dev/null +++ b/src/bundled/pulse/feed.test.ts @@ -0,0 +1,116 @@ +import { expect, it } from "vitest"; +import { keypair, message, signed } from "../../features/relay/testing"; +import { channelLabel, filterRows, pulseRows, visibleChannels } from "./feed"; +import type { ChannelSummary } from "../../features/relay/contracts"; +const user = keypair(); +const channels: ChannelSummary[] = [ + { id: "a", name: "Design" }, + { id: "b", name: "B" }, + { + id: "dm", + name: "DM", + channelType: "dm", + hidden: true, + participants: [user.pubkey], + }, + { id: "hidden", name: "Hidden", hidden: true }, + { id: "archived", name: "Archived", archived: true }, +]; +it("uses only visible joined channels and folded roots, with stable chronological ordering", () => { + const a = message(user, "a", "First", 1); + const b = message(user, "b", "Second", 2); + const events = [ + a, + b, + message(user, "a", "Reply", 3, [["e", a.id, "", "reply"]]), + message(user, "hidden", "Secret", 4), + message(user, "archived", "Old", 5), + message(user, "unknown", "Unknown", 6), + ]; + expect(visibleChannels(channels).map((c) => c.id)).toEqual(["a", "b", "dm"]); + expect(pulseRows(events, channels).map((row) => row.id)).toEqual([ + b.id, + a.id, + ]); + expect(pulseRows([...events].reverse(), channels)).toEqual( + pulseRows(events, channels), + ); +}); +it("uses shared edit/delete semantics and preserves delivery uncertainty", () => { + const root = message(user, "a", "Before", 1); + const edit = signed(user, { + kind: 40003, + content: "After", + created_at: 2, + tags: [ + ["h", "a"], + ["e", root.id], + ], + }); + expect(pulseRows([root, edit], channels)[0]?.content).toBe("After"); + expect( + pulseRows([root, { ...edit, delivery: "failed" }], channels)[0]?.content, + ).toBe("Before"); + expect( + pulseRows( + [{ ...root, delivery: "unknown", error: "Timed out" }], + channels, + )[0], + ).toMatchObject({ delivery: "unknown", deliveryError: "Timed out" }); + const deletion = signed(user, { + kind: 5, + content: "", + tags: [ + ["h", "a"], + ["e", root.id], + ], + }); + expect(pulseRows([root, edit, deletion], channels)).toEqual([]); + expect( + pulseRows( + [root], + channels.filter((c) => c.id !== "a"), + ), + ).toEqual([]); +}); +it("For you uses exact notification identity or DM membership, not prose or invented unread status", () => { + const prose = message(user, "a", "@Reader please review", 3); + const mention = message(user, "b", "A request", 2, [["p", user.pubkey]]); + const dm = message(user, "dm", "Hello", 1); + const rows = pulseRows([prose, mention, dm], channels); + expect( + filterRows(rows, channels, new Map(), "for-you", "", user.pubkey).map( + (row) => row.id, + ), + ).toEqual([mention.id, dm.id]); + expect( + filterRows(rows, channels, new Map(), "search", "design").map( + (row) => row.id, + ), + ).toEqual([prose.id]); +}); +it("groups one newest matching source per channel and searches current profile labels", () => { + const events = [ + message(user, "a", "one", 1), + message(user, "a", "two", 2), + message(user, "dm", "hello", 3), + ]; + const profiles = new Map([[user.pubkey, { name: "Alice" }]]); + const rows = pulseRows(events, channels); + expect(filterRows(rows, channels, profiles, "all", "")).toHaveLength(2); + expect( + filterRows(rows, channels, profiles, "search", "one")[0]?.content, + ).toBe("one"); + expect( + channelLabel( + { id: "dm", name: "DM", channelType: "dm", participants: [user.pubkey] }, + profiles, + ), + ).toBe("Alice"); + expect( + channelLabel( + { id: "self", name: "Self", channelType: "dm", participants: [] }, + profiles, + ), + ).toBe("Notes to self"); +}); diff --git a/src/bundled/pulse/feed.ts b/src/bundled/pulse/feed.ts new file mode 100644 index 00000000..9c2aa7da --- /dev/null +++ b/src/bundled/pulse/feed.ts @@ -0,0 +1,83 @@ +import type { + ChannelSummary, + ChannelMessage, + Profile, +} from "../../features/relay/contracts"; +import type { VisibleEvent } from "../../features/relay/projection"; +import { foldMessages } from "../../features/relay/fold"; + +export function visibleChannels(channels: readonly ChannelSummary[]) { + return channels.filter( + (channel) => + !channel.archived && (!channel.hidden || channel.channelType === "dm"), + ); +} +export function channelLabel( + channel: ChannelSummary, + profiles: ReadonlyMap, +) { + if (channel.channelType !== "dm" || !channel.participants) + return channel.name; + return channel.participants.length + ? channel.participants + .map((id) => profiles.get(id)?.name ?? id.slice(0, 10)) + .join(", ") + : "Notes to self"; +} +/** Presentation over an owned host view. No independent retained cache or authority. */ +export function pulseRows( + events: readonly VisibleEvent[], + channels: readonly ChannelSummary[], +) { + const usable = events.filter( + (event) => + !([40003, 7].includes(event.kind) && event.delivery === "failed"), + ); + const byId = new Map(events.map((event) => [event.id, event])); + return visibleChannels(channels) + .flatMap((channel) => { + // The session does not expose the relay signing identity. Do not infer it or + // trust an arbitrary 39005 author; the shared thread reader resolves replies. + const rows = foldMessages(channel.id, "", usable); + return rows.map((row) => ({ + ...row, + delivery: byId.get(row.id)?.delivery, + deliveryError: byId.get(row.id)?.error, + })); + }) + .sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id)); +} +export function filterRows( + rows: readonly ChannelMessage[], + channels: readonly ChannelSummary[], + profiles: ReadonlyMap, + view: string, + search: string, + viewer?: string, +) { + const byId = new Map(channels.map((channel) => [channel.id, channel])); + const seen = new Set(); + return rows + .filter((row) => { + const channel = byId.get(row.channelId); + if (!channel) return false; + if ( + view === "for-you" && + !( + channel.channelType === "dm" || + (viewer && row.mentions.includes(viewer)) + ) + ) + return false; + const text = `${row.content} ${channelLabel(channel, profiles)} ${profiles.get(row.authorId)?.name ?? ""}`; + if ( + view === "search" && + !text.toLowerCase().includes(search.trim().toLowerCase()) + ) + return false; + if (seen.has(row.channelId)) return false; + seen.add(row.channelId); + return true; + }) + .slice(0, 30); +} diff --git a/src/bundled/pulse/index.tsx b/src/bundled/pulse/index.tsx new file mode 100644 index 00000000..19f20d50 --- /dev/null +++ b/src/bundled/pulse/index.tsx @@ -0,0 +1,16 @@ +import type { PluginModule } from "../../plugins/api"; +import { PulsePage } from "./PulsePage"; +export const inject = ["pages", "relay", "conversation"]; +export const apply: PluginModule["apply"] = (ctx) => { + const relay = ctx.relay; + const extensions = ctx.conversation; + ctx.pages.register({ + id: "pulse", + title: "Pulse", + layout: "workspace", + companion: true, + component: ({ companion }) => ( + + ), + }); +}; diff --git a/src/bundled/pulse/manifest.json b/src/bundled/pulse/manifest.json new file mode 100644 index 00000000..3ec7b5d0 --- /dev/null +++ b/src/bundled/pulse/manifest.json @@ -0,0 +1 @@ +{ "id": "buzz.pulse", "name": "Pulse", "apiVersion": 1 } diff --git a/src/bundled/pulse/usePulseFeed.ts b/src/bundled/pulse/usePulseFeed.ts new file mode 100644 index 00000000..35fe9d0d --- /dev/null +++ b/src/bundled/pulse/usePulseFeed.ts @@ -0,0 +1,105 @@ +import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import type { + RelaySession, + EventViewSnapshot, +} from "../../features/relay/session"; +import type { ChannelSummary } from "../../features/relay/contracts"; +import { selectProfiles } from "../../features/relay/profile-selection"; +import { pulseRows, visibleChannels } from "./feed"; +const empty: EventViewSnapshot = { status: "idle", events: [] }; +const noop = () => () => {}; + +export function usePulseFeed( + session: RelaySession, + roster: readonly ChannelSummary[], + ready: boolean, +) { + const key = visibleChannels(roster) + .map((channel) => channel.id) + .sort() + .join("\n"); + const [owned, setOwned] = useState<{ + key: string; + view: ReturnType; + }>(); + const [error, setError] = useState(); + const [attempt, setAttempt] = useState(0); + // Allocate only after roster authority and in an effect: StrictMode/unload cannot leak handles. + // biome-ignore lint/correctness/useExhaustiveDependencies: attempt retries allocation failures. + useEffect(() => { + if (!ready || !key) return; + try { + const view = session.observe([ + { + kinds: [9, 40002, 40003, 5, 9005, 7], + "#h": key.split("\n"), + limit: 200, + top_level: true, + include_aux: true, + }, + ]); + setError(undefined); + setOwned({ key, view }); + void view.refresh(); + return () => view.dispose(); + } catch (cause) { + setError(String(cause)); + } + }, [session, key, ready, attempt]); + const view = ready && owned?.key === key ? owned.view : undefined; + const snapshot = useSyncExternalStore( + view?.subscribe ?? noop, + view?.snapshot ?? (() => empty), + view?.snapshot ?? (() => empty), + ); + const rows = useMemo( + () => pulseRows(snapshot.events, roster), + [snapshot.events, roster], + ); + const profileKey = [ + ...new Set([ + ...rows.slice(0, 200).map((row) => row.authorId), + ...visibleChannels(roster).flatMap( + (channel) => channel.participants ?? [], + ), + ]), + ] + .sort() + .slice(0, 1024) + .join(":"); + const selection = useMemo( + () => + selectProfiles(session.profiles, profileKey ? profileKey.split(":") : []), + [session, profileKey], + ); + const profiles = useSyncExternalStore( + selection.subscribe, + selection.snapshot, + selection.snapshot, + ); + const missing = profileKey + .split(":") + .filter((id) => id && !profiles.has(id)) + .join(":"); + // Include the whole roster identity: even hidden-channel revocation can purge shared profiles. + const membership = roster + .map((channel) => channel.id) + .sort() + .join("\n"); + useEffect(() => { + if (membership && missing) + void session.profiles + .ensure(missing.split(":"), "background") + .catch(() => {}); + }, [session, missing, membership]); + return { + rows, + profiles, + status: snapshot.status, + error: error ?? snapshot.error, + refresh: () => { + if (view) void view.refresh(); + else setAttempt((value) => value + 1); + }, + }; +} diff --git a/tests/browser/pulse-lifecycle.spec.mjs b/tests/browser/pulse-lifecycle.spec.mjs new file mode 100644 index 00000000..3a694458 --- /dev/null +++ b/tests/browser/pulse-lifecycle.spec.mjs @@ -0,0 +1,81 @@ +import { test, expect } from "./fixture.mjs"; +const pulse = (page) => + page + .getByRole("navigation", { name: "Pages", exact: true }) + .getByRole("button", { name: "Pulse", exact: true }); + +test("Pulse opens shared threads, follows edits, purges revoked sources and unloads independently", async ({ + page, + app, +}, testInfo) => { + // Only model the upstream root/traversal response; the thread owner, composer, + // verification and application remain real. Publication is not exercised here. + await page.route("**/api/relay/primary/query", async (route) => { + const filters = route.request().postDataJSON(); + if ( + filters.some((filter) => filter.ids || filter.depth_limit !== undefined) + ) { + const root = app.histories.get("primary/alpha").at(-1); + const events = filters.some((filter) => filter.ids?.includes(root.id)) + ? [root] + : []; + return route.fulfill({ json: events }); + } + return route.continue(); + }); + await page.goto(app.origin); + await pulse(page).click(); + await expect( + page.getByRole("article", { name: "Activity in Alpha" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Open thread / reply" }).click(); + const thread = page.getByRole("complementary", { + name: "Thread", + exact: true, + }); + await expect(thread).toBeVisible(); + const reply = thread.getByRole("textbox", { + name: "Reply to thread", + exact: true, + }); + await reply.fill("A separate reply draft"); + await page.screenshot({ path: testInfo.outputPath("pulse-thread.png") }); + await page.getByRole("button", { name: "Close thread", exact: true }).click(); + await expect( + page.getByRole("textbox", { name: "Message #Alpha" }), + ).toHaveValue(""); + await page.getByRole("button", { name: "Back to Pulse" }).click(); + const root = app.histories.get("primary/alpha").at(-1); + app.edit("primary", "alpha", root, "An updated Pulse source"); + await expect( + page.getByRole("article", { name: "Activity in Alpha" }), + ).toContainText("An updated Pulse source"); + await page.getByRole("button", { name: "Open thread / reply" }).click(); + await expect(reply).toHaveValue("A separate reply draft"); + await page.getByRole("button", { name: "Back to Pulse" }).click(); + app.omitChannel("alpha"); + await page.getByRole("button", { name: "Refresh Pulse" }).click(); + await expect( + page + .getByRole("navigation", { name: "Pulse conversations" }) + .getByRole("button", { name: "Alpha" }), + ).toHaveCount(0); + await expect( + page.getByRole("article", { name: "Activity in Alpha" }), + ).toHaveCount(0); + await page.getByRole("button", { name: "Your profile" }).click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: "Plugins", exact: true }).click(); + await page.getByRole("switch", { name: "Enable Pulse", exact: true }).click(); + await expect(pulse(page)).toHaveCount(0); + await page.getByRole("switch", { name: "Enable Pulse", exact: true }).click(); + await pulse(page).click(); + await expect( + page.getByRole("article", { name: "Activity in Alpha" }), + ).toHaveCount(0); + await expect( + page + .getByRole("navigation", { name: "Pulse conversations" }) + .getByRole("button", { name: "Beta" }), + ).toBeVisible(); +}); diff --git a/tests/browser/pulse.spec.mjs b/tests/browser/pulse.spec.mjs new file mode 100644 index 00000000..3f104ce5 --- /dev/null +++ b/tests/browser/pulse.spec.mjs @@ -0,0 +1,79 @@ +import { test, expect } from "./fixture.mjs"; +const pulse = (page) => + page + .getByRole("navigation", { name: "Pages", exact: true }) + .getByRole("button", { name: "Pulse", exact: true }); +const view = (page, name) => + page + .getByRole("navigation", { name: "Pulse views" }) + .getByRole("button", { name, exact: true }); + +test("Pulse reads shared activity, searches, opens a conversation and retains scoped drafts", async ({ + page, + app, +}, testInfo) => { + await page.goto(app.origin); + await pulse(page).click(); + await expect( + page.getByRole("article", { name: "Activity in Alpha" }), + ).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("pulse-light.png") }); + await view(page, "Search").click(); + const search = page.getByRole("searchbox", { + name: "Search recent Pulse activity", + }); + await search.fill("nothing-matches-this"); + await expect( + page.getByText("No recent conversations match that search."), + ).toBeVisible(); + await search.fill("message 639"); + await expect(page.getByRole("article")).toHaveCount(1); + const before = performance.now(); + await page.getByRole("button", { name: /Open conversation ↗/ }).click(); + await expect( + page.getByRole("region", { name: "Channel message history" }), + ).toBeVisible(); + app.report.measurements.push({ + name: "pulse-cold-open", + durationMs: performance.now() - before, + }); + const draft = page.getByRole("textbox", { name: "Message #Alpha" }); + await draft.fill("Keep this Pulse draft"); + await page.getByRole("button", { name: "Back to Pulse" }).click(); + const warm = performance.now(); + await page.getByRole("button", { name: /Open conversation ↗/ }).click(); + await expect(draft).toHaveValue("Keep this Pulse draft"); + app.report.measurements.push({ + name: "pulse-warm-open", + durationMs: performance.now() - warm, + }); + await page.screenshot({ + path: testInfo.outputPath("pulse-conversation.png"), + }); + await page.getByRole("button", { name: "Back to Pulse" }).click(); + await view(page, "All messages").click(); + await page.getByRole("button", { name: "Bestie", exact: true }).click(); + await expect( + page.getByRole("complementary", { name: "Bestie", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Close Bestie panel" }).click(); + await page.getByRole("button", { name: "Your profile" }).click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: "Appearance", exact: true }).click(); + await page.getByRole("radio", { name: "Dark", exact: true }).check(); + await pulse(page).click(); + await page.screenshot({ path: testInfo.outputPath("pulse-dark.png") }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ path: testInfo.outputPath("pulse-mobile.png") }); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true); + await view(page, "For you").click(); + await expect( + page.getByText( + "No direct conversations or mentions in this recent window.", + ), + ).toBeVisible(); +}); From 2498ec8de8700a6f1d2663cc8234cbe3de3dd399 Mon Sep 17 00:00:00 2001 From: pic-worker-aa352576c7 <6838876f3610dc213d19edeb1cdf5a8d0a0b568dc9b40db674ade16725074cce@buzz.block.builderlab.xyz> Date: Fri, 11 Sep 2026 18:27:36 -0700 Subject: [PATCH 4/7] Refine Pulse presentation and preserve current shared messaging APIs Recover the existing design pass, retain the single-channel relay window contract, and combine bubble presentation with main Markdown, profiles, and reading behavior. Co-authored-by: Fizz <400e8babadcee6a7f420103f10a2849d84c4a9c71d5bd04f3948c814216648a3@buzz.block.builderlab.xyz> Signed-off-by: pic-worker-aa352576c7 <6838876f3610dc213d19edeb1cdf5a8d0a0b568dc9b40db674ade16725074cce@buzz.block.builderlab.xyz> --- src/bundled/pulse/Pulse.module.css | 191 ++++----- src/bundled/pulse/PulseConversation.tsx | 127 +++--- src/bundled/pulse/PulsePage.tsx | 388 ++++++++++-------- src/bundled/pulse/README.md | 34 +- src/bundled/pulse/usePulseFeed.ts | 11 +- src/features/messages/ChannelTimeline.tsx | 13 +- .../messages/MessageRow.presentation.test.tsx | 56 +++ src/features/messages/MessageRow.tsx | 96 +++-- src/features/messages/Messages.module.css | 71 ++++ src/features/messages/ThreadPanel.tsx | 18 +- tests/browser/layout.spec.mjs | 22 +- tests/browser/pulse-design.spec.mjs | 141 +++++++ tests/browser/pulse.spec.mjs | 16 + 13 files changed, 808 insertions(+), 376 deletions(-) create mode 100644 src/features/messages/MessageRow.presentation.test.tsx create mode 100644 tests/browser/pulse-design.spec.mjs diff --git a/src/bundled/pulse/Pulse.module.css b/src/bundled/pulse/Pulse.module.css index 78d177d2..2e428a42 100644 --- a/src/bundled/pulse/Pulse.module.css +++ b/src/bundled/pulse/Pulse.module.css @@ -1,4 +1,4 @@ -/* Page-owned selector trees are disjoint; no host or shared message overrides. */ +/* Pulse owns layout only; bubble rendering belongs to the shared MessageRow. */ /* biome-ignore-all lint/style/noDescendingSpecificity: Selectors belong to disjoint Pulse regions. */ .root { height: 100%; @@ -13,7 +13,6 @@ overflow: hidden; border-radius: var(--radius-card); background: var(--surface); - box-shadow: var(--elevation-card); color: var(--text); } .sidebar { @@ -22,16 +21,9 @@ display: flex; flex-direction: column; min-height: 0; - padding: 24px 12px 12px; + padding: 8px; border-right: 1px solid var(--border); } -.brand { - display: flex; - align-items: center; - gap: 8px; - padding: 0 12px 24px; - font-size: 18px; -} .sidebar nav button { border: 0; box-shadow: none; @@ -39,18 +31,19 @@ display: flex; align-items: center; text-align: left; - gap: 10px; + gap: 12px; background: transparent; - padding: 10px 12px; - font-size: 13px; + padding: 12px; + margin-bottom: 4px; + font-size: 14px; border-radius: var(--radius-control); } .sidebar nav button:hover { background: var(--surface-hover); } .sidebar nav button[aria-current="page"] { - background: var(--selected); - color: var(--on-selected); + background: var(--surface-control); + font-weight: 600; } .navIcon { display: grid; @@ -58,58 +51,60 @@ border-radius: 50%; width: 28px; height: 28px; + flex-shrink: 0; background: var(--surface-control); - color: var(--text); -} -.sidebarLabel { - margin: 28px 12px 12px; color: var(--text-muted); font-size: 10px; - letter-spacing: 0.08em; - font-weight: 600; +} +.sidebarDivider { + margin: 8px 0; + border-top: 1px solid var(--border); } .channelSearch { min-width: 0; width: 100%; font-size: 12px; margin-bottom: 8px; + padding: 8px 12px; + border: 0; + background: var(--surface-input); } .channelList { min-height: 0; flex: 1; overflow: auto; + scrollbar-width: thin; } -.channelList button span { +.channelList button > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.channelList svg { - flex-shrink: 0; -} -.sidebarFoot { - font-size: 11px; - line-height: 1.6; +.sidebarFoot, +.sidebarError { + font-size: 12px; + line-height: 1.5; color: var(--text-muted); padding: 12px; margin: 0; -} -.sidebarError { - font-size: 12px; overflow-wrap: anywhere; } -.main { +.main, +.feedContent { min-width: 0; min-height: 0; flex: 1; display: flex; flex-direction: column; } +.feedContent[hidden] { + display: none; +} .feedHeading { display: flex; justify-content: space-between; align-items: center; - padding: 36px 32px 8px; + padding: 8px 28px; gap: 12px; } .feedHeading button { @@ -118,30 +113,23 @@ padding: 8px; } .feedHeading h1 { - margin: 8px 0; - font-size: 30px; + margin: 0; + font-size: 14px; font-weight: 500; - letter-spacing: -0.04em; -} -.eyebrow { - color: var(--text-muted); - font-size: 10px; - font-weight: 600; - letter-spacing: 0.08em; } .intro { - padding: 0 32px 24px; + padding: 0 28px 16px; color: var(--text-muted); - font-size: 13px; - line-height: 1.6; + font-size: 12px; + line-height: 1.5; + border-bottom: 1px solid var(--border); } .search { display: flex; align-items: center; gap: 8px; - margin: 0 32px 16px; - background: var(--surface-input); - border: 1px solid var(--border-input); + margin: 20px 28px 8px; + background: var(--surface-control); border-radius: var(--radius-control); padding: 0 12px; } @@ -156,38 +144,58 @@ min-height: 0; overflow: auto; overflow-anchor: auto; + scrollbar-width: thin; +} +.latest { + position: sticky; + top: 8px; + display: block; + margin: 8px auto; + border-radius: 24px; + font-size: 12px; + background: var(--surface-elevated); + box-shadow: var(--elevation-card); + z-index: 1; } .card { - padding: 20px 16px 16px; - border-top: 1px solid var(--border); + padding: 0 28px; + border-bottom: 1px solid var(--border); } .source { - display: flex; - flex-wrap: wrap; - gap: 8px; - justify-content: space-between; - width: 100%; - text-align: left; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; border: 0; background: transparent; color: var(--text-muted); - font-size: 11px; - padding: 0 12px 8px; + font-size: 12px; + padding: 0; } -.source span:last-child { - font-size: 10px; +.cardActions { + display: flex; + justify-content: space-between; + gap: 20px; + margin-top: 8px; } .reply { - display: flex; + display: inline-flex; gap: 8px; align-items: center; + justify-content: center; border: 0; - background: var(--surface-control); + background: transparent; color: var(--text-muted); - font-size: 11px; + font-size: 12px; border-radius: 24px; - margin: 12px 0 0 56px; - padding: 7px 12px; + min-width: 32px; + min-height: 32px; + padding: 4px 8px; +} +.reply:hover, +.source:hover { + background: var(--surface-hover); + color: var(--text); } .empty, .connect { @@ -200,7 +208,7 @@ color: var(--text-muted); } .empty h2 { - font-size: 18px; + font-size: 16px; color: var(--text); font-weight: 500; } @@ -216,7 +224,7 @@ background: var(--surface); } .connect h1 { - font-size: 30px; + font-size: 24px; color: var(--text); font-weight: 500; } @@ -254,6 +262,7 @@ text-overflow: ellipsis; white-space: nowrap; font-size: 14px; + font-weight: 500; } .heading button { display: grid; @@ -261,20 +270,28 @@ border: 0; background: transparent; } +.threadHost { + display: grid; + grid-template-rows: minmax(0, 1fr); + flex: 1; + min-height: 0; +} @media (max-width: 700px) { .sidebar { - width: 160px; - flex-basis: 160px; - padding: 16px 8px 8px; + width: 180px; + flex-basis: 180px; } .feedHeading { - padding: 24px 20px 8px; + padding: 16px 20px 0; } .intro { - padding: 0 20px 20px; + padding: 0 20px 16px; } .search { - margin: 0 20px 16px; + margin: 16px 20px 8px; + } + .card { + padding: 0 20px; } } @media (max-width: 520px) { @@ -293,41 +310,27 @@ } .sidebar nav:first-of-type button { justify-content: center; - gap: 4px; + gap: 6px; font-size: 12px; - padding: 6px; + padding: 8px 4px; } - .brand, - .sidebarLabel, - .sidebarFoot, - .channelSearch { + .sidebarDivider, + .sidebarFoot { display: none; } .channelList { display: flex; - max-height: 44px; + max-height: 48px; } .channelList button { flex: 0 0 auto; max-width: 160px; + padding: 8px 12px; } .reading .sidebar { display: none; } - .feedHeading { - padding-top: 20px; - } - .eyebrow { - font-size: 9px; - } .card { - padding: 16px 8px; + padding: 0 16px; } } - -.threadHost { - display: grid; - grid-template-rows: minmax(0, 1fr); - flex: 1; - min-height: 0; -} diff --git a/src/bundled/pulse/PulseConversation.tsx b/src/bundled/pulse/PulseConversation.tsx index 4cc31c3e..09b78e95 100644 --- a/src/bundled/pulse/PulseConversation.tsx +++ b/src/bundled/pulse/PulseConversation.tsx @@ -8,26 +8,28 @@ import { MessageComposer } from "../../features/messages/MessageComposer"; import { ThreadPanel } from "../../features/messages/ThreadPanel"; import styles from "./Pulse.module.css"; const openLink = () => false; +type ConversationProps = { + session: RelaySession; + viewer?: string | undefined; + scope: string; + channelId: string; + name: string; + extensions?: ConversationExtensions | undefined; + back(): void; + initialThread?: string | undefined; +}; + export function PulseConversation({ session, + viewer, scope, channelId, name, extensions, back, initialThread, -}: { - session: RelaySession; - scope: string; - channelId: string; - name: string; - extensions?: ConversationExtensions | undefined; - back(): void; - initialThread?: string | undefined; -}) { - const window = useChannelWindow(session.channels, channelId); +}: ConversationProps) { const [thread, setThread] = useState(initialThread); - const [sent, setSent] = useState(); return (
@@ -39,6 +41,8 @@ export function PulseConversation({ {thread ? (
) : ( - <> - {window.status === "error" && !window.rows.length ? ( -
-

{window.error}

- -
- ) : window.status !== "ready" && !window.rows.length ? ( -

- Loading messages… -

- ) : ( - - )} - - + )}
); } + +// Direct thread entry owns only the thread reader; do not also open a channel window. +function PulseChannelMessages({ + session, + viewer, + scope, + channelId, + name, + extensions, + onOpenThread, +}: Omit & { + onOpenThread(id: string): void; +}) { + const window = useChannelWindow(session.channels, channelId); + const [sent, setSent] = useState(); + return ( + <> + {window.status === "error" && !window.rows.length ? ( +
+

{window.error}

+ +
+ ) : window.status !== "ready" && !window.rows.length ? ( +

+ Loading messages… +

+ ) : ( + + )} + + + ); +} diff --git a/src/bundled/pulse/PulsePage.tsx b/src/bundled/pulse/PulsePage.tsx index 8632fdbb..26698369 100644 --- a/src/bundled/pulse/PulsePage.tsx +++ b/src/bundled/pulse/PulsePage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Hash, Inbox, @@ -6,6 +6,8 @@ import { Search, Sparkles, RefreshCw, + ArrowUpRight, + Layers, } from "lucide-react"; import type { RelayData } from "../../features/relay/service"; import type { RelaySession } from "../../features/relay/session"; @@ -23,8 +25,8 @@ type View = "for-you" | "all" | "search"; type Selection = { channelId: string; thread?: string | undefined }; const views = [ { id: "search", title: "Search", icon: Search }, - { id: "for-you", title: "For you", icon: Sparkles }, - { id: "all", title: "All messages", icon: MessageCircle }, + { id: "for-you", title: "For you", icon: Inbox }, + { id: "all", title: "All messages", icon: Layers }, ] as const; export function PulsePage({ relay, @@ -98,11 +100,12 @@ function PulseWorkspace({ const current = channels.find( (channel) => channel.id === selected?.channelId, ); - const open = (channelId: string, thread?: string) => { + const open = (channelId: string, thread?: string, source?: HTMLElement) => { trigger.current = - document.activeElement instanceof HTMLElement + source ?? + (document.activeElement instanceof HTMLElement ? document.activeElement - : null; + : null); setSelected({ channelId, thread }); writeView(scope, "pulse-channel", channelId); }; @@ -116,13 +119,37 @@ function PulseWorkspace({ const switchView = (next: View) => { setView(next); writeView(scope, "pulse-view", next); - back(); + setSelected(undefined); + writeView(scope, "pulse-channel", null); }; const rows = useMemo( () => filterRows(feed.rows, list.channels, feed.profiles, view, search, viewer), [feed.rows, list.channels, feed.profiles, view, search, viewer], ); + const scroll = useRef(null); + // Freeze channel order while reading; content edits and revoked sources stay live. + const [order, setOrder] = useState([]); + const rowChannels = rows.map((row) => row.channelId).join("\n"); + useEffect(() => { + if (!scroll.current || scroll.current.scrollTop <= 24) + setOrder(rowChannels ? rowChannels.split("\n") : []); + }, [rowChannels]); + const ranked = new Map(order.map((id, index) => [id, index])); + const orderedRows = [...rows].sort( + (a, b) => + (ranked.get(a.channelId) ?? 999) - (ranked.get(b.channelId) ?? 999), + ); + const pendingOrder = order.join("\n") !== rowChannels; + const acceptLatest = () => { + setOrder(rows.map((row) => row.channelId)); + scroll.current?.scrollTo({ top: 0 }); + }; + const matchingChannels = channels.filter((channel) => + channelLabel(channel, feed.profiles) + .toLowerCase() + .includes(channelSearch.toLowerCase()), + ); const title = views.find((item) => item.id === view)?.title; const loading = list.status === "loading" || @@ -133,10 +160,6 @@ function PulseWorkspace({ return (
- {current ? ( + {current && ( - ) : ( - <> -
-
- - YOUR COMMUNITY, AT A GLANCE - -

{title}

-
+ )} +
-
- {view === "for-you" - ? "Your direct conversations and messages that mention you. Original excerpts, not AI summaries." - : view === "search" - ? "Find something in the recent activity loaded here." - : "A quieter way to catch up. Open a conversation and pick up where it left off."} -
- {view === "search" && ( - )} -
- {(feed.error || list.status === "error") && ( -
-

- Some activity couldn’t be loaded. This feed may be out of - date. -

- -
- )} - {loading && !rows.length ? ( -

- Gathering recent conversations… + {(feed.error || list.status === "error") && ( +

+

+ Some activity couldn’t be loaded. This feed may be out of + date.

- ) : rows.length ? ( - rows.map((row) => { - const channel = channels.find( - (item) => item.id === row.channelId, - ); - if (!channel) return null; - return ( -
- +
+ )} + {loading && !rows.length ? ( +

+ Gathering recent conversations… +

+ ) : rows.length ? ( + orderedRows.map((row) => { + const channel = channels.find( + (item) => item.id === row.channelId, + ); + if (!channel) return null; + return ( +
+ + open(channel.id, undefined, event.currentTarget) + } + > {channel.channelType === "dm" - ? "Direct conversation" - : `# ${channelLabel(channel, feed.profiles)}`} - - Open conversation ↗ - - false} - /> - -
- ); - }) - ) : ( -
-
- )} -
- Recent activity, not complete history - {list.coverage === "partial" ? " · Partial channel list" : ""}. -
- Replies stay in their original conversation. No messages are - sent to an AI provider. -
-
- - )} + ? `DM · ${channelLabel(channel, feed.profiles)}` + : `#${channelLabel(channel, feed.profiles)}`} + + } + footer={ +
+ + +
+ } + profile={feed.profiles.get(row.authorId)} + participantProfiles={feed.profiles} + media={session.media} + extensions={extensions} + day={false} + retry={ + session.outbox ? session.messages.retry : undefined + } + onOpenLink={() => false} + /> + + ); + }) + ) : ( +
+
+ )} +
+ Recent activity, not complete history + {list.coverage === "partial" ? " · Partial channel list" : ""}. +
+ Replies go to their original thread. +
+
+ ); diff --git a/src/bundled/pulse/README.md b/src/bundled/pulse/README.md index bead257e..c1dc7734 100644 --- a/src/bundled/pulse/README.md +++ b/src/bundled/pulse/README.md @@ -2,7 +2,7 @@ A bundled **source page plugin**, independently toggleable in Settings → Plugins. This is not a self-contained external API-v1 install artifact. Run this branch of -buzz-app and choose **Pulse** in the top navigation. Messages is unchanged. +buzz-app and choose **Pulse** in the top navigation. Messages keeps its default row presentation. ## Design reference @@ -27,8 +27,10 @@ Adapted to buzz-app: No new socket, cache, signer, outbox, polling loop, or model-provider connection. - Reuses `ChannelTimeline`, `MessageRow`, `MessageComposer`, `ThreadPanel` and `PanelFrame`. Emoji/Mentions remain optional conversation contributions. - Shared message styling is retained rather than copied or overridden with - prototype bubble styles. Links keep ordinary external navigation in this pass; + An optional shared `presentation="bubbles"` prop carries the prototype's + directional bubbles and bottom-aligned avatars without a second message renderer. + Main's Markdown, safe attachments, profile-link props, and authoritative + timeline/thread reading hooks remain intact. Links keep ordinary external navigation in this pass; only the host companion dock is embedded, not a second local object dock. - Session-owned state remounts by scope **and** generation. Navigation and drafts persist under stable scope, not generation. The full roster's membership key @@ -39,13 +41,20 @@ Adapted to buzz-app: **For you is not an AI briefing.** It filters recent original top-level posts to DMs and exact `p`-tag mentions of the viewer. It does not infer requests from prose, agent runtime status or unread state. No provider receives messages. buzz-app does -not currently expose a shared summarization or read-marker capability. +not currently expose a shared summarization capability. Pulse does not invent +unread badges; the shared conversation readers retain main's real reading behavior. The feed requests 200 recent events across the visible joined roster and shows at -most 30 conversations, one newest matching root each. Host retained-view limits -still apply. Busy channels may dominate the window; search covers retained activity, +most 30 conversations, one newest matching root each. This is an ordinary multi-channel +query: replies and auxiliary events consume that window, and roots are selected locally +by the shared fold. It does not use the relay's single-channel `top_level` / `include_aux` +window extension. Edits/deletes outside retained activity may be absent from excerpts; +open the conversation for its authoritative channel-window read. The 200-event limit +bounds the request, not the lifetime retained view: the host bounds retained remote +evidence to 2,000 events / 8 MiB (`features/relay/projection.ts`). Busy channels may dominate the window; search covers retained activity, not full history. The channel rail remains roster-ordered, not a fabricated activity -ranking. A partial roster is labeled. Hidden non-DM and archived channels are omitted. +ranking. It renders the authorized roster directly (not a virtualized rail); feed +rendering is capped at 30 groups and conversation history uses shared virtualization. A partial roster is labeled. Hidden non-DM and archived channels are omitted. Author edits/deletes use the shared fold. Delivery uncertainty remains visible. Feed rows deliberately do not interpret relay thread summaries: the session does @@ -59,16 +68,21 @@ selection and feed search do not become new URL routes. From this worktree: ```sh -bin/just web +bin/pnpm install --frozen-lockfile +bin/pnpm dev --host 127.0.0.1 --port 1432 --strictPort ``` -For actual community data, use the repository's existing opt-in live setup in -[README](../../../../README.md#relay-channels), then `BUZZ_LIVE=1 bin/just web`. +Check that 1432 is free first; leave existing 1430/1431 servers alone. For actual +community data, use the repository's public `BUZZ_DEV_VIEWER` pin setup in +[README](../../../../README.md#relay-channels). Main now enables the development +broker from that pin; `BUZZ_LIVE=1` is no longer required. No identity configuration is added by this plugin. Packaged login and native acceptance remain the host's existing limitations. ## Checks +- `MessageRow.presentation.test.tsx`: both row/bubble content and actions, explicit + viewer direction; upstream MessageRow tests remain separate and unchanged. - `feed.test.ts`: visible sources, deterministic grouping, author edits/deletes, failed-edit rollback, exact mentions, bounded search and DM names. - App composition test: contribution registration, disable and shared-session lifetime. diff --git a/src/bundled/pulse/usePulseFeed.ts b/src/bundled/pulse/usePulseFeed.ts index 35fe9d0d..96d323f6 100644 --- a/src/bundled/pulse/usePulseFeed.ts +++ b/src/bundled/pulse/usePulseFeed.ts @@ -33,9 +33,10 @@ export function usePulseFeed( { kinds: [9, 40002, 40003, 5, 9005, 7], "#h": key.split("\n"), + // Multi-channel activity uses ordinary filters. The relay's top_level / + // include_aux window extension requires exactly one channel; the shared + // fold selects roots and applies any retained author edits/deletes here. limit: 200, - top_level: true, - include_aux: true, }, ]); setError(undefined); @@ -59,9 +60,9 @@ export function usePulseFeed( const profileKey = [ ...new Set([ ...rows.slice(0, 200).map((row) => row.authorId), - ...visibleChannels(roster).flatMap( - (channel) => channel.participants ?? [], - ), + ...visibleChannels(roster) + .filter((channel) => channel.channelType === "dm") + .flatMap((channel) => channel.participants ?? []), ]), ] .sort() diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index 5f6497fe..c168e4b8 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -3,7 +3,7 @@ import type { ConversationExtensions } from "../conversation/contracts"; 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 { MessageRow, type MessagePresentation } from "./MessageRow"; import type { ChannelWindow } from "../relay/contracts"; import { useRowProfiles } from "../relay/react"; import { geometryFor, geometrySignature } from "./geometry"; @@ -60,7 +60,7 @@ function positionAt( : {}), }; } -export type ChannelTimelineProps = { +export type ChannelTimelineProps = MessagePresentation & { extensions?: ConversationExtensions | undefined; channelId: string; scope: string; @@ -83,6 +83,8 @@ export function ChannelTimeline(props: ChannelTimelineProps) { } function Timeline({ channelId, + presentation, + viewer, extensions, scope, queries, @@ -101,8 +103,9 @@ function Timeline({ const profiles = useRowProfiles(queries.profiles, rows); const geometry = useMemo(() => geometryFor(queries.channels), [queries]); const signature = useMemo( - () => geometrySignature(rows, profiles), - [rows, profiles], + () => + `${presentation ?? "rows"}:${viewer ?? ""}:${geometrySignature(rows, profiles)}`, + [rows, profiles, presentation, viewer], ); const scroller = useRef(null); const handle = useRef(null); @@ -358,6 +361,8 @@ function Timeline({ key={row.id} row={row} unread={queries.unread} + presentation={presentation} + viewer={viewer} extensions={extensions} profile={profiles.get(row.authorId)} participantProfiles={profiles} diff --git a/src/features/messages/MessageRow.presentation.test.tsx b/src/features/messages/MessageRow.presentation.test.tsx new file mode 100644 index 00000000..b46eeb44 --- /dev/null +++ b/src/features/messages/MessageRow.presentation.test.tsx @@ -0,0 +1,56 @@ +import { expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MessageRow, type MessagePresentation } from "./MessageRow"; +import type { ChannelMessage } from "../relay/contracts"; + +const row: ChannelMessage = { + id: "root", + channelId: "alpha", + authorId: "alice", + createdAt: 1700000000, + content: "Original excerpt https://example.com", + mentions: [], + attachments: [{ url: "https://example.com/image.png", video: false }], + reactions: [{ content: "👍" }], + replyCount: 2, + participants: [], +}; +function render(presentation: MessagePresentation = {}) { + return renderToStaticMarkup( + url} + day={false} + onOpenLink={() => false} + onOpenThread={() => {}} + retry={undefined} + context={#alpha} + footer={} + />, + ); +} +it("keeps standard rows opt-out and original content/actions in both presentations", () => { + for (const html of [render(), render({ presentation: "bubbles" })]) { + expect(html).toContain("Original excerpt"); + expect(html).toContain('href="https://example.com/"'); + expect(html).toContain("Open image attachment"); + expect(html).toContain("👍"); + expect(html).toContain("View thread: 2 replies"); + expect(html).toContain("Open conversation"); + expect(html).toContain("#alpha"); + } + expect(render()).not.toContain("data-direction"); +}); +it("uses explicit viewer identity for directional bubbles, never names or missing identity", () => { + expect(render({ presentation: "bubbles" })).toContain( + 'data-direction="incoming"', + ); + expect(render({ presentation: "bubbles", viewer: "Alice" })).toContain( + 'data-direction="incoming"', + ); + expect(render({ presentation: "bubbles", viewer: "alice" })).toContain( + 'data-direction="outgoing"', + ); +}); diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index 7e5e2e53..67eb7c19 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useSyncExternalStore } from "react"; +import { memo, useCallback, useSyncExternalStore, type ReactNode } from "react"; import type { UnreadCapability } from "../relay/unread"; import { profileTarget } from "../profiles/target"; import { InlineText } from "../conversation/InlineText"; @@ -10,7 +10,14 @@ import { safeMessageUrl } from "../relay/message-content"; import styles from "./Messages.module.css"; import { usesLargeEmojiPresentation } from "./emoji-size"; -export type MessageRowProps = { +export type MessagePresentation = { + presentation?: "bubbles" | undefined; + viewer?: string | undefined; +}; + +export type MessageRowProps = MessagePresentation & { + context?: ReactNode; + footer?: ReactNode; row: ChannelMessage; unread?: UnreadCapability | undefined; extensions?: ConversationExtensions | undefined; @@ -27,6 +34,10 @@ export type MessageRowProps = { export const MessageRow = memo(function MessageRow({ row, unread, + presentation, + viewer, + context, + footer, extensions, profile, media, @@ -56,6 +67,40 @@ export const MessageRow = memo(function MessageRow({ const clickable = target && canOpenLink?.(target); const AvatarTag = clickable ? "button" : "div"; const emojiOnly = usesLargeEmojiPresentation(row.content, row.emoji); + const avatar = ( + ) => { + event.currentTarget.focus(); + onOpenLink(target); + }, + } + : {})} + > + {picture ? ( + + ) : ( + name.slice(0, 2).toUpperCase() + )} + + ); + const text = ( + + ); + const bubbles = presentation === "bubbles"; + const outgoing = bubbles && viewer === row.authorId; return (
{day && ( @@ -69,29 +114,18 @@ export const MessageRow = memo(function MessageRow({
)} -
- ) => { - event.currentTarget.focus(); - onOpenLink(target); - }, - } - : {})} - > - {picture ? ( - - ) : ( - name.slice(0, 2).toUpperCase() - )} - +
+ {!bubbles && avatar}
{name} + {context}
- + {bubbles ? ( +
+ {text} + {!outgoing && avatar} +
+ ) : ( + text + )} {row.attachments.map((attachment) => { const url = safeMessageUrl(attachment.url); @@ -224,6 +257,7 @@ export const MessageRow = memo(function MessageRow({ )} )} + {footer}
diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index df8bdd2a..b110e90b 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -669,3 +669,74 @@ button.avatar:focus-visible, text-overflow: ellipsis; white-space: nowrap; } + +/* Optional shared presentation. Default Messages layout is unchanged. */ +.bubbleMessage { + gap: 10px; + padding: 12px 0; + align-items: flex-end; +} +.bubbleMessage .avatar { + width: 28px; + height: 28px; + font-size: 10px; +} +.bubbleMessage .messageBody { + margin-left: 38px; + flex: 0 1 auto; + max-width: 82%; +} +.bubbleMessage .byline { + padding: 0 4px; + margin-bottom: 6px; + gap: 6px; + font-size: 12px; + line-height: 18px; +} +.bubbleMessage .text { + width: fit-content; + max-width: 100%; + margin: 0; + padding: 8px 14px; + border-radius: 20px; + background: var(--surface-control); +} +.bubbleAnchor { + position: relative; + width: fit-content; + max-width: 100%; +} +.bubbleAnchor .avatar { + position: absolute; + left: -38px; + bottom: 0; +} +.outgoing { + justify-content: flex-end; +} +.outgoing .messageBody { + margin-left: 0; +} +.outgoing .byline { + justify-content: flex-end; +} +.outgoing .bubbleAnchor { + margin-left: auto; +} +.outgoing .text { + margin-left: auto; + background: var(--primary); + color: var(--on-primary); +} +.outgoing .text :where(a, h1, h2, h3, h4, h5, h6, blockquote) { + color: inherit; +} +.outgoing .text :where(code, pre, table) { + color: var(--text); + background: var(--surface-control); +} +.bubbleThread { + border: 0; + border-radius: 0; + box-shadow: none; +} diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx index 80a01d32..17dc2f9c 100644 --- a/src/features/messages/ThreadPanel.tsx +++ b/src/features/messages/ThreadPanel.tsx @@ -12,13 +12,13 @@ import type { ConversationExtensions } from "../conversation/contracts"; import type { RelaySession } from "../relay/session"; import type { ThreadView } from "../relay/threads"; import { useRowProfiles } from "../relay/react"; -import { MessageRow } from "./MessageRow"; +import { MessageRow, type MessagePresentation } from "./MessageRow"; import { MessageComposer } from "./MessageComposer"; import styles from "./Messages.module.css"; import { useReading } from "./use-reading"; import { messageViewKey } from "./view-key"; -export type ThreadPanelProps = { +export type ThreadPanelProps = MessagePresentation & { extensions?: ConversationExtensions | undefined; session: RelaySession; scope: string; @@ -46,6 +46,8 @@ export function ThreadPanel(props: ThreadPanelProps) { } function OwnedThreadPanel({ session, + presentation, + viewer, extensions, scope, channelName, @@ -77,7 +79,7 @@ function OwnedThreadPanel({ }, [session, channelId, messageId, attempt]); return (