Repository navigation
feat(activity): show reactions to your messages in Activity - #2638
Conversation
joahg
left a comment
There was a problem hiding this comment.
🤖 Read inbox-reactions.ts and the p-tag wiring in messages.ts, tools.ts, and session.ts. Ownership, channel-membership, and deletion fail-closed checks look sound, and the read budgets are bounded. Worth confirming that adding a p tag to kind 7 doesn't cause the relay or agent wake logic to treat reactions as mentions that wake agents.
|
🤖 Thanks @joahg, I checked the agent wake question at
So reactions still reach an agent only through an Attention watch that asks for kind 7. That's unchanged, because watches already heard untagged reactions in their channels. The one thing the tag changes there: a classifier watch's I couldn't verify the relay's own push or wake handling from this repo. If it fans out on |
6ae6125 to
655417f
Compare
d8c80fb to
2de6600
Compare
Add a Reactions tab to Activity, list reactions in the sidebar hover card, and briefly show a live reaction's emoji on the Activity bell. New reactions carry a NIP-25 `p` tag for the reacted message's author (app and MCP react), so Activity reads `#p: [viewer]` kind 7. Shared emoji get a shared row per message; each person's other emoji fold into one row. Reactions have no unread state or badge, and opening one shows the message in its own conversation or thread. Co-authored-by: Eugene <11b65c7022d0662a7e3c969ca511980925a21d138ec0d25214a4997e7b598240@buzz.block.builderlab.xyz> Co-authored-by: BasilBot <399581b6165ab01343e3a1324774b1024d76942aea563468f93c975e5ee5193c@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: tulsi <[email protected]>
2de6600 to
47a3b96
Compare
Bring in reactions in Activity (#2638) and the plugin, terminal and profile changes before it. Activity and Members browser tests now find the reaction emoji on the dock tile, name the Manage members button by its conversation, and leave room for the dock beside the Activity list. Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen <[email protected]>
…-service * origin/main: fix(sidebar): highlight collapsed sections with unread channels (#2539) perf(threads): isolate thread rows inside the scroller (#2462) perf(messages): skip the glass on closed message action bars (#2461) perf(threads): measure long-thread scrolling in browsers and the macOS app (#2460) docs(threads): set scrolling requirements and targets for long threads (#2459) fix(composer): seed required recipients in saved briefs (#2667) fix: preserve agent activity ownership and remove Solo composer gap (#2613) feat(agents): open agent card links in Create agent (#2662) Align Bestie messaging with the shared conversation workspace (#2614) feat(messages): add a compact message density (#2547) fix(dialog): limit footer click-through to the gradient gutter (#2545) fix(channels): put the huddle launcher after the other header tools (#2660) feat(drafts-sent): bundle Drafts & sent for everyone (#2644) feat(identity): one Builderlab sign-in, the native NIP-FI session (#2576) fix(agents): let people dismiss connection setup cards (#2631) AIDA-2425: Preserve links beside pasted images (#2520) feat(activity): show reactions to your messages in Activity (#2638) # Conflicts: # docs/identity.md # src-tauri/src/federated_identity/agent/mod.rs # src-tauri/src/federated_identity/mod.rs # src-tauri/src/federated_identity/tests.rs # src/bundled/codex/runtime.ts
App navigation moves to a left dock, and the header keeps only history, Search and window controls. ### What changes - **Dock.** Bestie, Activity, Messages, Reminders, Agents, Workflows and any plugin page placed in the topbar or toolbar are icon tabs in a left column, Bestie first. Labels show at once, as a documented dock label hover bucket, and on keyboard focus; inactive Activity keeps its hover preview and shows the latest reaction emoji. With a plugin shell backdrop, the dock keeps the same solid floor as header controls. The community picker and profile sit at the bottom of the dock, and their menus open to the right. - **Header.** Back/forward, a centered Search capsule with its shortcut (⌘K by default, follows rebinding), and window controls. Search fills the space at smaller widths and drops to its own row when narrow. The old header page tabs and "More pages" overflow are removed. - **Sidebar only on Messages.** Pages without their own sidebar (Activity, Agents, Bestie, Usage, Settings…) use their main view. Sidebar-placed pages (Usage, Drafts & sent) list in the Messages sidebar. New and ⌘N follow the creation sidebar. - **Conversation headers.** Member avatars and the conversation name form one button that opens the existing access dialog, named after what that dialog offers: "Manage members of …", or "Share access to …", "Chat access for …" or "Channel access for …" for Solo chats, visitors and guests. Solo chats show their agents. A conversation with one tab no longer shows a tab strip. - **Design system.** `Tabs` gets a `dock` variant and `orientation`, `Tooltip` gets `hint="dock"`, and `PanelHeader`'s divider changes for every panel header. - **Also:** macOS traffic lights move to 16,24; message history fades at its top and bottom edges (profiled: no frame cost in WebKit or Chromium, ~18% more scroll CPU in headless Chromium); Activity row avatars change size. ### Since the first draft - Rebased once onto main, then merged main up to `f6b0c2c62` (Activity rename #2543, reactions in Activity #2638, Drafts & sent #2644, Builderlab sign-in #2576). - Restored the Search shortcut hint. `PageNavigation` takes `placement="dock"` and labels its tablist "Apps". `ChannelHeaderIdentity` owns the conversation avatars, label and hint. Header measurement uses refs. Removed dead attributes, keys and a duplicate check. - Browser tests from main updated for the dock: the Activity reaction emoji is on the dock tile, Manage members is named by its conversation, and Activity toolbar widths leave room for the dock. - Merged main again, up to `fd6296a5c`. Then review fixes: dock labels show on keyboard focus, and the dock label hover bucket is documented. The header access names match their dialog. The sidebar grip's backdrop halo is gone, since the grip now sits on the opaque joined seam. The Search shortcut chip no longer makes the topbar 48px tall. Test fixes for the shell backdrop, wrapped link drag and Drafts & sent placement checks. ### Validation (on `92fb0abe6`) - `pnpm check` and pre-push hooks pass. Full vitest: 18,294 pass, 1 fails: `ChannelMembersDialog.test.tsx` "drains a gated action … after cancellation", which fails the same way on a clean main checkout on this Mac and passes in isolation. - 248 browser tests across the layout, Activity, page placement, backdrop, channel settings, sidebar, Solo, lifecycle, permissions and profile specs, plus the wrapped-link and zoom tests, pass in Chromium and WebKit. Integration tests (261) passed before the last two merges from main. The full browser suite runs in CI. - One local failure, `layout.spec.mjs` "Alt+ArrowUp from a message link preserves native scroll ownership", fails the same way on a clean main checkout on this Mac. - Native rendering, traffic lights, window dragging and real sign-in were checked by hand in the desktop app. --------- Signed-off-by: Arjun Mahanti <[email protected]> Signed-off-by: Bradley Axen <[email protected]> Co-authored-by: Codex <[email protected]> Co-authored-by: bmo <a7b504d9a2bfe905589e9ac237d7d8d023b8d10547060ec9618e6487b55d756c@buzz.block.builderlab.xyz> Co-authored-by: Codex <[email protected]> Co-authored-by: Bradley Axen <[email protected]> Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Clicking an Inbox row for a reply inside a DM thread (e.g. "kalvin · DM ·
kalvin — might not be exactly realted", a reply in a 9-reply thread in the
kalvin DM) opened the DM's main conversation with the thread collapsed as
"9 replies", instead of opening the thread at the reply. Channel thread
rows already open their thread.
Root cause: the unread owner projects each DM as one Inbox row whose read
target is always the DM channel ({ kind: "channel" }), because a DM is
read as a whole channel. The row still records where opening resumes:
`messageId` and, when that message is a reply, `rootId`. But the Inbox
page's `tabTarget` decided "open whole" from the read target alone
(`item.target.kind === "channel"`), so every DM row opened the whole DM,
even when it resumed at a thread reply. The read target had been reused
as the open-shape decision. The Inbox hover preview already passed
`threadRootId` for these rows, so only the page's tab path dropped the
thread.
Fix: a DM row opens whole only when its resume message is top-level
(`item.rootId` unset). When it resumes at a thread reply, it opens that
thread at the reply, the same tab shape a channel thread row uses. Read
behaviour is unchanged: the DM's read target is still its channel. Rows
whose resume message is a top-level DM message still open the whole DM,
and Solo chat rows are unchanged.
Tests: a new InboxPage test covers a DM whose only incoming message is a
reply under the viewer's DM message. It fails before the fix (side tab
"Alice", the whole DM) and passes after ("Thread · Alice", with the reply
in "Thread messages"). The existing "opens a DM row's whole conversation
in a tab" test still covers top-level DM rows. DM reaction rows carry no
read target and still open the whole DM, as #2638 intends.
docs/inbox.md documents the behaviour.
Manual before/after in the running app:
1. In a DM with someone, send a top-level message, and have them reply to
it in its thread (no newer top-level message from them afterwards).
2. Open Inbox. The DM row ("<name> · DM · <name>") previews their reply.
3. Click the row.
Before: the side tab is titled with the person's name and shows the
whole DM timeline; the thread is collapsed under your message as
"N replies".
After: the side tab is "Thread · <name>" and shows the thread, headed
by your original message, with their reply revealed.
4. For contrast, a DM row whose resume message is top-level (e.g. a new
top-level message from them) still opens the whole DM.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
… unread Follow-up to eb0e1600d, addressing its review. eb0e1600d opened a DM row as a thread whenever its resume message was a reply. But reading any of a DM reads all of it. Take a mixed DM: Alice replies in a DM thread, then posts two top-level messages. Its row resumed at the reply, so it opened "Thread · Alice". The row's read then marked both top-level messages read, including the row's own preview, without showing them. Re-clicking after the read could not reach them either. The representative had moved to the newest top-level message, so `tabTarget` now opened whole, but the captured tab was the thread, and the shape-mismatch reselect kept showing that thread. Fix: - The unread owner's Inbox projection records `threadOnly` where it picks the representative. It is set when every unread message (or, with none unread, the representative) is a reply in the representative's thread. `rootId` keeps its meaning for archive, `targetOf` and the hover preview. - A DM row opens as a thread only when `threadOnly` is set. A DM mixing unread top-level and thread messages opens whole, where threads show their reply counts, so the row's whole-DM read never covers a message its tab hides. With nothing unread, the row follows its representative as before. So the reported case, where the latest incoming DM message is a thread reply, still opens the thread. - Re-clicking the selected row still keeps a captured whole DM or session tab when the row would now open as a thread. For example, a DM with an older top-level message and a newer reply opens whole, and after its read it no longer flips to the thread. A captured thread tab now follows the row instead of being kept. A DM thread whose row has since moved to a top-level message opens the whole DM rather than staying on a thread that hides that message. The documented Solo chat case (a mention that regrouped into a thread keeps its timeline tab) is unchanged. Channel and Solo chat rows open as before: channel rows always open at a thread, and a Solo chat's shape does not depend on `threadOnly`. DM reaction rows carry no `threadOnly`, so they still open the whole DM, as #2638 intends. DM read behaviour is unchanged. Tests: - unread.test.ts: a new projection test covers one DM through four states: thread-only unread, an older reply with a newer top-level message, the same after reading (follows the newest, top-level message), and an older top-level message with a newer reply, before and after reading. - InboxPage.test.tsx keeps the thread-only DM test and adds three: - Older unread reply with newer top-level messages: the DM opens whole, and stays whole on re-click after the read. - Older unread top-level message with a newer reply: the DM opens whole. After the read the row would open the thread, but re-click keeps the whole DM. - A DM opened as a thread, then a new top-level message arrives: re-click opens the whole DM. The tests share a small `seedDm` and tab-shape helper. jsdom does not lay out the virtualised channel timeline, so whole-DM tabs are checked by tab title and the "Channel message history" region, as the existing whole-DM test does. - Fail-before: restoring only the old `tabTarget` (`!item.rootId`) fails the older-reply/newer-top-level test, which opens "Thread · Alice". Restoring only the old shape-mismatch reselect fails the thread-then- top-level re-click test, which stays on "Thread · Alice". docs/inbox.md no longer claims parity with channel thread rows. It says that reading a DM, even in a thread, reads all of it, when a DM row opens as a thread, and how re-clicking treats whole and thread tabs. Validation (local, on a heavily loaded machine; load average 100-270): - Vitest: InboxPage.test.tsx 155/155, DraftsView 39/39, inbox-feed 45/45, unread-startup 50/50, and the other inbox/unread files 240/240, each passing when run alone. In parallel runs, a different set of about 11 tests timed out each time. HEAD's production code showed the same timeouts in A/B runs (3/3 archived-DM InboxPage tests, 6/50 unread-startup tests). - Playwright inbox, inbox-archive, inbox-reading and inbox-tabs: Chromium 19/19. WebKit 18/19. inbox-archive:658 lost its fill()'d draft, a known WebKit flake under load. It passed twice when rerun alone at lower load, and it opens a channel row, not a DM. - tsc --noEmit and pnpm lint pass. - Deferred: no manual run in the desktop app yet. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Matt Toohey <[email protected]>
Clicking an Inbox row for a reply inside a DM thread (e.g. "kalvin · DM ·
kalvin — might not be exactly realted", a reply in a 9-reply thread in the
kalvin DM) opened the DM's main conversation with the thread collapsed as
"9 replies", instead of opening the thread at the reply. Channel thread
rows already open their thread.
Root cause: the unread owner projects each DM as one Inbox row whose read
target is always the DM channel ({ kind: "channel" }), because a DM is
read as a whole channel. The row still records where opening resumes:
`messageId` and, when that message is a reply, `rootId`. But the Inbox
page's `tabTarget` decided "open whole" from the read target alone
(`item.target.kind === "channel"`), so every DM row opened the whole DM,
even when it resumed at a thread reply. The read target had been reused
as the open-shape decision. The Inbox hover preview already passed
`threadRootId` for these rows, so only the page's tab path dropped the
thread.
Fix: a DM row opens whole only when its resume message is top-level
(`item.rootId` unset). When it resumes at a thread reply, it opens that
thread at the reply, the same tab shape a channel thread row uses. Read
behaviour is unchanged: the DM's read target is still its channel. Rows
whose resume message is a top-level DM message still open the whole DM,
and Solo chat rows are unchanged.
Tests: a new InboxPage test covers a DM whose only incoming message is a
reply under the viewer's DM message. It fails before the fix (side tab
"Alice", the whole DM) and passes after ("Thread · Alice", with the reply
in "Thread messages"). The existing "opens a DM row's whole conversation
in a tab" test still covers top-level DM rows. DM reaction rows carry no
read target and still open the whole DM, as #2638 intends.
docs/inbox.md documents the behaviour.
Manual before/after in the running app:
1. In a DM with someone, send a top-level message, and have them reply to
it in its thread (no newer top-level message from them afterwards).
2. Open Inbox. The DM row ("<name> · DM · <name>") previews their reply.
3. Click the row.
Before: the side tab is titled with the person's name and shows the
whole DM timeline; the thread is collapsed under your message as
"N replies".
After: the side tab is "Thread · <name>" and shows the thread, headed
by your original message, with their reply revealed.
4. For contrast, a DM row whose resume message is top-level (e.g. a new
top-level message from them) still opens the whole DM.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
Clicking an Activity (Inbox) row for a reply in a DM thread opened the whole DM, with the thread collapsed under "N replies". The reply stayed hidden until you opened the thread yourself, while channel thread rows already open their thread. With this change, a DM row opens its thread too, but only when that thread holds all of the row's unread messages. A DM thread's reads now stop before any unread DM message the thread does not show, so a top-level message that arrives while the thread is open stays unread. Rebased onto `main` at `2552bf65f`. Conflicts were in `InboxPage.tsx`, `InboxPage.test.tsx`, `docs/inbox.md` and `tests/browser/inbox-archive.spec.mjs`. The one that affected behaviour was `tabTarget` (`src/bundled/inbox/InboxPage.tsx`). #2638 gave reaction rows no read target, so it made `target` optional and opened every DM row whole by channel type (`channel?.channelType === "dm"`). This branch had opened a DM row whole when `item.target.kind === "channel" && !item.threadOnly`. The resolution applies `!threadOnly` to both DM tests: `(item.target?.kind === "channel" || channel?.channelType === "dm") && !item.threadOnly`. Reaction rows carry no `threadOnly`, so DM reaction rows still open the whole DM, as #2638 intends. #2543 deleted `tests/browser/inbox-archive.spec.mjs`, so this branch's one-line comment fix in that file is gone and the branch no longer changes any browser spec. ## Before / after Left: `main`. Right: this branch. Both run the same seeded data in the browser test harness (headless Chromium, fixture names only). https://github.com/user-attachments/assets/3cea7338-611b-4427-bac6-25fc4a6ef8ae  | Case | `main` | This branch | |---|---|---| | Thread-only: Alice sends two unread replies in a thread under your message | Whole DM, thread collapsed as "2 replies" | "Thread · Alice Fixture": the root and both replies | | Mixed: one unread reply, then a newer unread top-level message | Whole DM | Whole DM (unchanged on purpose) | The recording predates the rebase and the read cutoff below. Neither changes the tab that either case opens; `main` now calls the page Activity (#2543). ## Root cause The unread owner gives each DM one Inbox row. Its read target is the whole DM channel, because a DM is read as a whole. The row also records where opening resumes: `messageId`, plus `rootId` when that message is a reply. But `tabTarget` in `src/bundled/inbox/InboxPage.tsx` picked the tab shape from the read target alone (`item.target.kind === "channel"`). So every DM row opened the whole DM, even when it resumed at a thread reply. The hover preview already passed the thread root; only the tab path dropped it. ## Fix ### Opening a DM row - `src/features/relay/unread.ts` sets a new `threadOnly` flag on a DM row at the point where it picks the row's representative message. The flag is set when every unread message is a reply in the representative's thread. With nothing unread, it is set when the representative is a reply. Only DM rows carry the flag. - `tabTarget` opens a DM row as that thread only when `threadOnly` is set. Otherwise the row opens the whole DM, as before. DM reaction rows have no `threadOnly`, so they still open whole. - Why not open the thread whenever the row resumes at a reply? Take a DM with an unread reply followed by newer unread top-level messages. Opening the thread would hide the row's own preview message. So a mixed DM still opens whole, where each thread shows its reply count. - Re-clicking the selected row: - A whole-DM or session tab stays whole, even if the row would now open as a thread (for example, after its read). - A thread tab follows the row. If the row no longer opens as a thread (for example, a new top-level message arrived), re-clicking opens the whole DM instead of staying on a thread that hides it. - A Solo chat mention that regrouped into a thread still keeps its timeline tab. - Channel rows and Solo chat rows open as before. ### Reading a DM thread (review follow-up) Before this, any read in a DM advanced the DM's one channel frontier through its newest retained message. That includes a read earned inside one of its threads, so a top-level message the thread never showed was marked read. - A reading lease can now name the thread its reader shows: `session.unread.reading(channelId, rootId)`. `useReading` passes its `rootId`. `ThreadPanel` passes the thread it opened even while the root is still unresolved (`snapshot.root?.id ?? messageId`), so a reply shown under an unavailable root cannot read the whole DM. - In a DM, a thread read covers retained messages, but stops one second before the earliest message outside that thread that the frontier still leaves unread: a top-level message, or a reply in another thread. A new helper, `dmCut`, computes this cut. - The thread's own root and replies never stop it. A reply belongs to the thread its marked root names (`row.rootId ?? row.threadRootId`), even when that root is not retained. - The viewer's own messages never stop it. - A message kept unread by Mark unread or a device-local mark does not stop it either. Advancing the frontier leaves that message unread anyway. - With nothing unread outside the thread, a thread read still covers the whole DM, as before. - The row's click reads the same way when it opens a thread. `prepareChannelRead(channelId, rootId)` takes the thread the opened tab shows, and the Inbox page passes `tabTarget(...).threadId` for the open click only. The cut then comes from retained messages, not the click time. This also covers a row that renders late: live updates are coalesced for up to 200 ms, so a row can still show `threadOnly` and open the thread after a top-level message has reached the owner. - One frontier cannot skip a message. So once a message outside the thread stays unread, the thread's own later replies stay unread too, until a whole-DM read covers them. That is deliberately conservative. The row then holds unread messages outside the thread, so it is no longer `threadOnly`, and it opens the whole DM. - Surfaces: the Activity DM thread tab (including one restored on returning to Activity), the Channels DM thread pane and embedded DM threads. In Channels, the DM timeline beside the thread pane still reads the whole DM when it has focus. Only reads earned in the thread pane alone are bounded. - Unchanged: whole-DM reads, ✓ and Mark as read (an explicit whole-DM catch-up through the click time), a whole DM opened from its row, and all channel and Solo reads. Docs: `docs/inbox.md` says when a DM row opens as a thread, how re-clicking treats whole and thread tabs, and how a DM thread's reads are bounded. `docs/unread.md` describes the thread lease, its cut and the bounded click. `docs/buzz-help.md` says when a DM row opens its thread, and that DM messages outside it stay unread until the whole DM opens. ## Tests Opening: - `src/features/relay/unread.test.ts`: a new projection test takes one DM through several states. It covers thread-only unread, then an older reply with a newer top-level message, then an older top-level message with a newer reply. The last two are checked before and after reading. - `src/bundled/inbox/InboxPage.test.tsx`, four new tests: - A DM whose only unread message is a reply opens "Thread · Alice" with the reply shown. - An older unread reply with newer top-level messages opens the whole DM, and stays whole on re-click after the read. - An older unread top-level message with a newer reply opens the whole DM. After the read, re-click keeps it whole. - A DM opened as a thread receives a new top-level message, and re-click opens the whole DM. Reading: - `src/features/relay/unread.test.ts`, through a DM thread lease (`catchUp` and `observe`): - With nothing unread outside the thread, it reads the whole DM. - A reply in another thread stops it just before that reply. That reply and the thread's later reply stay unread, and a whole lease then reads everything. - The viewer's own top-level message does not stop it, but Alice's does. The row then resumes at that message, unread and without `threadOnly`, and a further thread dwell still cannot pass it. - A thread whose root is never retained reads both its replies, and a later top-level message still stops it. - An older top-level message marked unread, by Mark unread or by a device-local mark, does not stop the thread's read. The marked message stays unread. - `prepareChannelRead("dm", root)` cuts at the newest retained message, not the click time, and leaves a retained top-level message unread. A whole-DM read then covers it through the click time. - `src/bundled/inbox/InboxPage.test.tsx`, three new tests. Each checks the read frontier as well as the tab shape: - With a thread-only DM open as "Thread · Alice", Alice replies in the thread and posts at the top level. A focus dwell in the thread reads the reply but stops one second before the top-level message. The row stays unread at that message, and re-clicking opens the whole DM. - The same, with the messages arriving while Activity is unmounted and the thread tab restored on return. - Alice's top-level message reaches the owner and the row is clicked in one `act` batch, before the row re-renders. It still opens "Thread · Alice", and the click's read stops one second before the message. - The dwell helper advances fake time in 50 ms rounds and fails after 2 s of fake time. It replaces `vi.waitFor`, where how far fake time got depended on runner speed. - `src/features/messages/ThreadPanel.test.tsx`: with the original unavailable, every reading lease names the thread the panel opened. - `src/features/messages/use-reading.test.ts`: leases carry `(channelId, rootId)`, and a thread list's leases name its thread. Fail-before: - The thread-only opening test fails on `main`, where the side tab is "Alice" and shows the whole DM. - Restoring only the old `tabTarget` fails the mixed test, which then opens "Thread · Alice". - Restoring only the old re-click rule fails the thread-then-top-level test, which then stays on the thread. - Reverting only the thread cut in `unread.ts` fails both owner cases (frontier 31, expected 29) and both InboxPage dwell cases. - Reverting only `use-reading.ts` (leases name no thread) fails both InboxPage dwell cases. Reverting only `ThreadPanel.tsx` fails its lease test. - Matching thread membership by `row.rootId` alone fails the unretained-root case (21, not 23). - Bounding the cut by `isUnread` fails both mark cases (21, not 30). - An owner click read that ignores the thread reads through the click time. That fails the owner case (expected 21) and the InboxPage stale-row case (expected 24). An Inbox page that passes no thread fails the stale-row case the same way. No browser journeys were added or removed (see the rebase note above). Validation (local, on the rebased branch): - `tsc --noEmit` and `pnpm lint` pass. - Vitest: all 26 affected unread, Inbox, notifications, `ThreadPanel`, `EmbeddedThread`, conversation-tab, `ChannelsPage.tabs` and `use-reading` files pass, each run alone. `InboxPage.test.tsx` passed 150/150 in 5 full-file runs. - Playwright Chromium: `inbox-reading`, `inbox`, `inbox-tabs`, `thread-unread`, `sidebar-unread` and `typing-dm` all pass (41 tests). - Not run on this head: WebKit journeys, and the desktop app. Agent checks ran in jsdom and the browser harness only. - This push skipped the local pre-push hooks, because this machine times out tests under load. Hosted CI runs the full suite. ## Remaining risks The unseen-arrival risk raised in review ([P2](#2587 (comment))) is addressed. A DM thread's dwells and its row click no longer read a top-level message, or a reply in another thread, that this device received before the read. What remains: - **Late delivery.** A message outside the thread can reach this device only after a thread read (dwell or click). If it is dated at or before that read's cut, it is read without being shown. This happens with sender clock skew (the relay accepts ±900 s), a top-level message sent in the same second as a thread reply, or an outbox retry that keeps its original timestamp. A single timestamp frontier cannot close this. - **Thread replies after an outside message stay unread while shown.** This is deliberate (see Fix). The row then opens the whole DM, which reads them. - **Unresolved root at an exact reply.** Activity opens a thread at an exact reply. If the thread view cannot resolve the root either, `ThreadPanel` names that reply as the lease's thread instead of the root. The thread's other replies then count as outside it and stay unread while shown. This fails safe: nothing is read unseen. - **Unchanged from `main`.** ✓, Mark as read and a whole DM opened from its row read through the click time, as a channel's explicit read does. So a message created by then but delivered after the click is read without being shown. ## Product sign-off needed #1279 made Inbox DM rows open the whole DM on purpose. This PR reverses that for DM rows whose unread messages are all in one thread, so it needs an explicit OK from the Inbox owner before merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Matt Toohey <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
) ## Problem Three costs add up while a busy channel is open and new messages arrive. - **Reading observation restarts on every message.** `useReading` lists `latestMessageId` as an effect dependency, so each new latest message tears down the effect and runs it again. That disposes the reading lease, the observers and the listeners, then measures rows synchronously in the passive-effect phase (`getClientRects` / `getBoundingClientRect`). The result is a forced layout per message, and a dwell that new messages arriving faster than 300 ms never let complete. - **Activity reactions feed pruning.** #2638 added this feed. On every channel-list notification, which live messages trigger, it prunes up to 100 items with `allowed(item.channelId)`. Each call rescans the list and runs `members.includes(viewer)`, even though the callback has just built the same allowed set. - **Own-reaction ring.** #2709 draws the ring with an inset `box-shadow`, which adds paint-bound long-animation-frame blocking while the pointer rests on a message. ## Change This PR combines three changes: - `useReading` reads the latest message id through `useEffectEvent` at measurement time, and it is no longer an effect dependency. A new latest message already mutates the list, and the existing mutation observer remeasures it in the next frame. - The reactions prune filter uses `next.has(item.channelId)`, the set built a few lines above from the same list snapshot and predicate. - The own-reaction ring is drawn with `outline: 1px solid` and `outline-offset: -1px` instead of the inset shadow. It uses the same color variables, and in forced-colors mode only `outline-color: Highlight` is set. ## No behavior changes - **Reading.** - The latest id is read when the bottom row is measured, so catch-up still targets the newest rendered row. `catchUp` itself still skips when a newer row exists and revalidates its target. - If the latest id changes with no DOM mutation, the bottom check finds no matching shown row, the same outcome as the old restart. - `rootId`, `channelId`, `session` and `enabled` still restart the effect. - A new test checks that a new latest id neither restarts observation nor takes a lease, and that catch-up reaches the new row after it is measured. The test fails without this change. - **Reactions.** `next` holds exactly the ids `allowed` would accept, built from the same snapshot with no `await` before the filter. `load` and `eventVisibility` still call `allowed`. - **Ring.** It has the same width, color and position, inside the pill's border box, and follows the capsule radius. An outline paints above the pill's content and an inset shadow below it. The pill's button is transparent at rest and the hover glyph is transformed, so the ring looks the same. Outlines follow `border-radius` from Safari 16.4 / WebKitGTK 2.40. ## Evidence Main `8c0cb0c1e` and this PR were interleaved for 6 rounds on 4 Linux and 5 macOS workstations. Each workstation's median is taken first, and the table shows the mean of those medians. - **Browser rows.** Production build, Chromium at 4× CPU throttling. Traffic appends a message every 200 ms to the open channel and to another one. - **pointer on message:** the pointer rests on a message, which reveals its action bar, for 4.5 s. - **no pointer:** the same run with the pointer away. - **at rest:** the channel stays open for 3 s with no interaction. - **Unit row.** Vitest micro-benchmark with 400 channels, one of them with 5,001 members and the viewer at the end of its list, and 100 reaction items in that channel. It delivers 50 channel-list notifications. It ran on 2 Linux and 2 macOS workstations. | metric | Linux: main | Linux: this PR | Linux Δ % | macOS: main | macOS: this PR | macOS Δ % | |---|---|---|---|---|---|---| | pointer on message: blocking | 156.2 ms | 35.3 ms | −77.4% | 32.3 ms | 0.0 ms | −100% | | pointer on message: task | 2,017.7 ms | 1,997.4 ms | −1.0% | 1,458.1 ms | 1,461.7 ms | +0.2% (noise) | | pointer on message: script | 1,028.8 ms | 1,010.5 ms | −1.8% | 644.5 ms | 640.2 ms | −0.7% (noise) | | no pointer: blocking | 133.9 ms | 13.7 ms | −89.8% | 25.6 ms | 0.0 ms | −100% | | no pointer: task | 1,843.9 ms | 1,864.5 ms | +1.1% (noise) | 1,365.2 ms | 1,353.7 ms | −0.8% | | at rest: task | 318.0 ms | 324.4 ms | +2.0% (noise, one box +11%) | 235.1 ms | 236.0 ms | +0.4% (noise) | | reactions list callback ×50 (unit) | 464.4 ms | 6.9 ms | −98.5% | 309.0 ms | 4.0 ms | −98.7% | Lower is better. Blocking is long-animation-frame blocking time. Every workstation moved the same way on both blocking rows (per box: Linux −77.0% to −77.9% and −88.1% to −91.9%; macOS −100% on all five). Neither variant blocks in the at-rest run. Checks: - The `messages` vitest suites pass (96 files, 2,835 tests). - `inbox-reactions` passes (10 tests). - `tsc`, `biome` and `check-contrast` pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
## Problem #2638 added reaction rows to the Activity inbox. `InboxRow.tsx` imports `reactionName` statically to build each row's accessible label. That one import put `features/messages/reaction-name` and its emoji-mart name table (`@emoji-mart/data/sets/15/native.json`, a 413 KB chunk) into the first-paint closure. Every launch now downloads, parses and evaluates the table and builds its `Map`, whether or not a reaction row is ever shown. `MessageReactions` and `StatusEmoji` already load the same module with `import()`. ## Change - A new `useReactionName` hook (`features/messages/use-reaction-name.ts`) loads `reaction-name` with `import()` once, caches it at module level and rerenders when the table arrives. This is the load-once code `useEmojiName` in `StatusEmoji` already had, moved so it can be shared. - `ReactionRowButton` and `useEmojiName` both use the hook, so one table load serves both. ## Behavior - The visible row doesn't change. - Until the table loads, a Unicode reaction's `aria-label` uses the emoji character, for example "reacted with ❤️". It switches to the name, "reacted with heart", once the table loads. The first reaction row to mount fetches the chunk. - Custom-emoji reactions don't use the table and are unaffected. ## Evidence Cold start of a production build in Chromium at 4× CPU throttling, from navigation to the first frame with the app shell rendered. Main `8c0cb0c1e` and this PR ran as 12 interleaved samples each on one Linux and one macOS workstation. The table shows medians. | metric | Linux: main | Linux: this PR | Linux Δ % | macOS: main | macOS: this PR | macOS Δ % | |---|---|---|---|---|---|---| | script | 392.9 ms | 374.4 ms | −4.7% | 273.6 ms | 240.0 ms | −12.3% | | module evaluation | 160.5 ms | 131.0 ms | −18.4% | 102.6 ms | 79.7 ms | −22.3% | | time to shell | 1,302.1 ms | 1,214.2 ms | −6.8% | 862.5 ms | 808.5 ms | −6.3% | | eager JS (Vite manifest) | 6,296,425 B | 5,884,309 B | −6.5% | same | same | −6.5% | Lower is better. - The eager JS row is the static-import closure from `index.html`. The `native` chunk (412,954 B) leaves it, and `main` grows by 273 B for the dynamic import. - On macOS, every sample of this PR reached the shell faster than every sample of main. Checks: - The `src/bundled/inbox` and `src/features/user-status` vitest suites pass (239 tests). - `tsc` and `biome` pass. - A production build has no remaining static importer of `reaction-name`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Kalvin Chau <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
## Why NIP-25 says a reaction should carry a `p` tag naming the author of the reacted-to event. buzz-app's Activity → Reactions tab (block/buzz-app#2638, merged) finds reactions to your messages with a `#p` read. Reactions sent from mobile have no `p` tag, so they never appear there. #9121 makes the same change for `buzz reactions add`. ## What - Every reaction sent from mobile now carries `["p", <message author>]`. This covers the reaction pill, the quick-reaction row, the emoji picker, and reactions on system rows. - Removing a reaction is unchanged. - Agent status reactions (buzz-acp) and the desktop app are unchanged by decision. Agent status reactions stay out of Activity. ## Testing - New unit test: `ChannelActions.addReaction` publishes exactly `["e", target]` and `["p", author]`. It fails without the change (actual tags: `[['e', 'target-event']]`). - The message-actions widget test now checks that a quick reaction passes the message's author. - `just mobile-check`: format and `flutter analyze` are clean (no issues). - `just mobile-test`: 4,507 passed, plus the push-unconfigured and secp256k1 suites. All pass. - No screenshots: the change is one tag on the published event, and nothing on screen changes. - Not yet: a run on a device, and the human test. ## How a human can test 1. On mobile, as user B, react 👍 to a message from user A. 2. As user A, open Activity → Reactions in a current buzz-app build. 3. It works if a "B reacted 👍" row appears. Before this change, no row appears. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Matthew Boston <[email protected]> Co-authored-by: mboston Implementer <932d3b6ebea6cc83e091551ae46d1c7e7f8f7d0fa8e9cef5e6facc8544af413b@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <[email protected]>
Why
In Slack, a reaction like ✅ or ❤️ on your message is a quiet but useful signal: someone saw it, agreed, or finished the thing. Buzz drops these today, so people have to go back to each message to check. This PR adds reactions to your messages to Activity (renamed in #2543) and briefly shows the emoji on the Activity bell when a live reaction comes in.
Stacked on #2543 (
basil/inbox-filter-clear). Retarget tomainonce that merges.What
How reactions are found (please review, wesb)
Kind-7 reactions only carry the reacted message's id (
e), so "reactions to my messages" can't be queried directly. This PR adds a NIP-25 author tag (p) to new reactions, then reads#p: [me]:src/features/relay/messages.ts:react()adds["p", <message author>].src/buzz-mcp/tools.ts: the MCPreacttool does the same, so agents' reactions show up too.src/features/relay/session.ts(FOUNDATION): 10 lines of wiring.relayAuthorandsigningAuthorityintocreateInboxFeed.inboxFeed.reactions.receivebehind the sameclosed/ epoch / generation guards aschannelActivity.unread.tsare untouched.src/features/relay/inbox-reactions.ts(new) owns the projection:Forward-only: reactions sent before this change, or from older clients, have no
ptag and won't appear. We aren't backfilling them; that was a product decision.Screenshots
Test fixture data; names are made up.
Reactions tab: a shared ❤️ row, one person's other emoji folded together, and a single reaction.
All tab: reactions mixed in with messages; reactions never show an unread dot.
Sidebar hover card: the same rows.
Bell: briefly shows a live reaction's emoji.
Dark mode: Reactions tab and hover card.
Not in this PR
Testing
Rebased onto #2543's current head
655417f50(which picked up main's Activity scroll restoration) and squashed to one commit,2de660049. On that head:pnpm checkpasses, andvitest runpasses (972 files) apart fromcodex-sh.test.ts, which failed once under load and passes alone. Theinbox,inbox-mark-read,inbox-tabsandinbox-previewjourneys pass in Chromium and WebKit (50 tests). The runs below were on the previous head:pnpm checkpasses (biome, tsc, design typecheck, design checks), and so doesgit diff --check.vitest runpasses: 960 files, 17,885 tests.node --test tests/integration/*.test.mjspasses.inbox-reactions.test.ts(ownership and channel checks, deletion settlement, failed-settlement retry, unarchive, archived-room isolation, replay vs live),reaction-rows.test.ts(regrouping after removal and filtering, custom emoji identity),ActivityNavigation.test.tsx(3s hold, bursts, replay, hidden window, custom-image preload, single-grapheme swap) andInboxPage.test.tsxcases.cargo test --lib relay::passes, 216 tests. It includes a new check that packaged signing accepts theptag.inbox,inbox-mark-read,inbox-tabsandinbox-previewpass in both engines. New journeys ininbox-tabs.spec.mjscover tab geometry, the settled indicator and row layout;inbox-preview.spec.mjscovers the hover card.pnpm test:browserrun reached 1,804 of 2,176 before its time limit. Its failures were either the two pre-fix toolbar journeys, since fixed, the same failure on feat(inbox): rename to Activity and simplify the filter bar #2543's head without this change (layout.spec.mjs:1405,typing.spec.mjs:290, WebKitpage-placement.spec.mjs:259), or a load-only failure that passes when rerun alone (route-teardown.spec.mjs:33, WebKittimeline-setup.spec.mjs:251).Known gap: the unchanged native test
window_state::tests::onboarding_snapshot_is_kept_until_next_launch_restorecrashed in an earlier full native run. This PR doesn't touch it.🤖 Generated with Claude Code