From 528ab11e3bcd43c5b7c7768e83d456b51935fb0e Mon Sep 17 00:00:00 2001 From: Carl Date: Thu, 17 Sep 2026 08:53:25 -1000 Subject: [PATCH] feat(channels): mute and mark channels read from the sidebar Use confirmed host-owned encrypted mute preferences in message notification policy, preserving mentions and unread evidence. Advance channel read state through verified evidence and clear only owned local unread marks in one durable transaction. Expose both actions through the shared row menu with persistence, cancellation, failure recovery and browser coverage. Signed-off-by: Carl --- dev/relay-broker.mjs | 29 ++- dev/sidebar-mutes.mjs | 90 ++++++++ dev/sidebar-mutes.test.mjs | 207 ++++++++++++++++++ dev/sidebar-preference-writes.test.mjs | 82 ++++++- dev/sidebar-preferences.mjs | 3 +- docs/channels.md | 25 ++- docs/notifications.md | 10 + docs/unread.md | 25 +++ src/bundled/channels/ChannelsPage.tsx | 70 +++++- src/bundled/channels/sidebar-sections.test.ts | 2 + src/bundled/channels/useSidebarPreferences.ts | 2 + src/features/notifications/messages.test.ts | 144 ++++++++++++ src/features/notifications/messages.ts | 22 ++ src/features/relay/read-state.ts | 19 +- src/features/relay/session.ts | 14 ++ .../relay/sidebar-preferences-store.test.ts | 190 ++++++++++++++++ .../relay/sidebar-preferences-store.ts | 40 ++++ .../relay/sidebar-preferences.test.ts | 8 + src/features/relay/sidebar-preferences.ts | 27 +++ src/features/relay/transport.ts | 23 ++ src/features/relay/unread.test.ts | 146 ++++++++++++ src/features/relay/unread.ts | 28 +++ src/features/relay/warm-lifecycle.test.ts | 1 + src/features/relay/warm.test.ts | 1 + tests/browser/fixture.mjs | 21 +- tests/browser/navigation-groups.spec.mjs | 2 +- tests/browser/navigation-mute-read.spec.mjs | 133 +++++++++++ tests/browser/policy-relay.mjs | 5 +- 28 files changed, 1336 insertions(+), 33 deletions(-) create mode 100644 dev/sidebar-mutes.mjs create mode 100644 dev/sidebar-mutes.test.mjs create mode 100644 tests/browser/navigation-mute-read.spec.mjs diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index 7d18a87f..c0a228dc 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -1,3 +1,7 @@ +import { + assertSidebarMuteIntent, + mutateSidebarMute, +} from "./sidebar-mutes.mjs"; import { assertSidebarStarIntent, mutateSidebarStar, @@ -561,10 +565,12 @@ export function relayBrokerPlugin({ [ "/api/relay/sidebar-assignment", "/api/relay/sidebar-star", + "/api/relay/sidebar-mute", ].includes(route) && req.method === "POST" ) { const starring = route === "/api/relay/sidebar-star"; + const muting = route === "/api/relay/sidebar-mute"; let raw = ""; for await (const part of req) { raw += part; @@ -577,6 +583,7 @@ export function relayBrokerPlugin({ try { intent = JSON.parse(raw); if (starring) assertSidebarStarIntent(intent); + else if (muting) assertSidebarMuteIntent(intent); else assertSidebarAssignmentIntent(intent); } catch { return json(res, 400, { @@ -595,7 +602,13 @@ export function relayBrokerPlugin({ { kinds: [30078], authors: [viewer], - "#d": [starring ? "channel-stars" : "channel-sections"], + "#d": [ + starring + ? "channel-stars" + : muting + ? "channel-mutes" + : "channel-sections", + ], limit: 1, }, ]; @@ -670,12 +683,13 @@ export function relayBrokerPlugin({ "Sidebar preference publication was not accepted", ); }; - return (starring ? mutateSidebarStar : mutateSidebarAssignment)( - intent, - key, - readHead, - publishEvent, - ); + return ( + starring + ? mutateSidebarStar + : muting + ? mutateSidebarMute + : mutateSidebarAssignment + )(intent, key, readHead, publishEvent); }); sidebarMutations.set(relay, mutation); try { @@ -727,6 +741,7 @@ export function relayBrokerPlugin({ readState: true, sidebarPreferenceWrites: true, sidebarStarWrites: true, + sidebarMuteWrites: true, agentLibrary: true, live: true, agentActivity: true, diff --git a/dev/sidebar-mutes.mjs b/dev/sidebar-mutes.mjs new file mode 100644 index 00000000..a3149221 --- /dev/null +++ b/dev/sidebar-mutes.mjs @@ -0,0 +1,90 @@ +import { finalizeEvent, getPublicKey, nip44 } from "nostr-tools"; +import { decodeSidebarPreferences } from "./sidebar-preferences.mjs"; + +const COORDINATE = "channel-mutes"; +export function assertSidebarMuteIntent(intent) { + if ( + !intent || + typeof intent !== "object" || + Array.isArray(intent) || + typeof intent.channelId !== "string" || + !intent.channelId.trim() || + intent.channelId.length > 256 || + typeof intent.muted !== "boolean" || + Object.keys(intent).some((key) => !["channelId", "muted"].includes(key)) + ) + throw new Error("Invalid sidebar mute intent"); +} + +/** One explicit mute intent against a fresh signed head; keep unmute tombstones. */ +export function prepareSidebarMute(events, intent, secret, now = Date.now()) { + assertSidebarMuteIntent(intent); + // The shared bounded decoder verifies signature, own author, schema and budgets. + decodeSidebarPreferences(events, secret); + if ( + events.length > 1 || + events.some( + (event) => + !event.tags.some( + ([name, value]) => name === "d" && value === COORDINATE, + ), + ) + ) + throw new Error("Invalid sidebar mute head"); + const viewer = getPublicKey(secret); + const key = nip44.v2.utils.getConversationKey(secret, viewer); + try { + const head = events[0]; + const current = head + ? JSON.parse(nip44.v2.decrypt(head.content, key)) + : { version: 1, channels: {} }; + const previous = Object.hasOwn(current.channels, intent.channelId) + ? current.channels[intent.channelId] + : undefined; + if (previous?.muted === intent.muted) return { mutes: current }; + const mutes = { + ...current, + channels: { + ...current.channels, + [intent.channelId]: { + ...previous, + muted: intent.muted, + updatedAt: Math.max(now, (previous?.updatedAt ?? 0) + 1), + }, + }, + }; + const event = finalizeEvent( + { + kind: 30078, + content: nip44.v2.encrypt(JSON.stringify(mutes), key), + created_at: Math.max( + Math.floor(now / 1000), + (head?.created_at ?? 0) + 1, + ), + tags: [ + ["d", COORDINATE], + ["t", COORDINATE], + ], + }, + secret, + ); + // Refuse over-budget changes rather than silently trimming other channels. + decodeSidebarPreferences([event], secret); + return { mutes, event }; + } finally { + key.fill(0); + } +} + +export async function mutateSidebarMute(intent, secret, readHead, publish) { + assertSidebarMuteIntent(intent); + const draft = prepareSidebarMute(await readHead(), intent, secret); + if (!draft.event) return draft.mutes; + await publish(draft.event); + const confirmation = prepareSidebarMute(await readHead(), intent, secret); + if (confirmation.event) + throw new Error( + "Sidebar mutes changed on another device; reload and try again", + ); + return confirmation.mutes; +} diff --git a/dev/sidebar-mutes.test.mjs b/dev/sidebar-mutes.test.mjs new file mode 100644 index 00000000..149f0bd7 --- /dev/null +++ b/dev/sidebar-mutes.test.mjs @@ -0,0 +1,207 @@ +import { expect, it, vi } from "vitest"; +import { + finalizeEvent, + generateSecretKey, + getPublicKey, + nip44, + verifyEvent, +} from "nostr-tools"; +import { + assertSidebarMuteIntent, + prepareSidebarMute, + mutateSidebarMute, +} from "./sidebar-mutes.mjs"; +import { + decodeSidebarPreferences, + SIDEBAR_REQUEST_BYTES, +} from "./sidebar-preferences.mjs"; + +function harness() { + const secret = generateSecretKey(); + const viewer = getPublicKey(secret); + return { + secret, + viewer, + encrypt(channels, overrides = {}) { + const key = nip44.v2.utils.getConversationKey(secret, viewer); + try { + return finalizeEvent( + { + kind: 30078, + created_at: 100, + tags: [["d", "channel-mutes"]], + content: nip44.v2.encrypt( + JSON.stringify({ version: 1, channels }), + key, + ), + ...overrides, + }, + secret, + ); + } finally { + key.fill(0); + } + }, + }; +} +it("rejects invalid intent shapes before relay reads", async () => { + const h = harness(); + for (const intent of [ + null, + [], + {}, + { channelId: "", muted: true }, + { channelId: "a" }, + { channelId: "a", muted: 1 }, + { channelId: "x".repeat(257), muted: true }, + { channelId: "a", muted: true, extra: 1 }, + ]) + expect(() => assertSidebarMuteIntent(intent)).toThrow( + "Invalid sidebar mute intent", + ); + const read = vi.fn(); + await expect(mutateSidebarMute({}, h.secret, read, vi.fn())).rejects.toThrow( + "Invalid sidebar mute intent", + ); + expect(read).not.toHaveBeenCalled(); +}); +it("encrypts explicit Mute/Unmute with monotonic timestamps and preserves unrelated tombstones", () => { + const h = harness(); + const channels = { + alpha: { muted: false, updatedAt: 60000 }, + beta: { muted: true, updatedAt: 2 }, + gone: { muted: false, updatedAt: 3 }, + }; + const added = prepareSidebarMute( + [h.encrypt(channels)], + { channelId: "alpha", muted: true }, + h.secret, + 50000, + ); + expect(verifyEvent(added.event)).toBe(true); + expect(added.event).toMatchObject({ + pubkey: h.viewer, + kind: 30078, + created_at: 101, + tags: [ + ["d", "channel-mutes"], + ["t", "channel-mutes"], + ], + }); + expect(added.event.content).not.toContain("alpha"); + expect(added.mutes.channels).toEqual({ + ...channels, + alpha: { muted: true, updatedAt: 60001 }, + }); + expect(decodeSidebarPreferences([added.event], h.secret).muted).toEqual([ + "alpha", + "beta", + ]); + const removed = prepareSidebarMute( + [added.event], + { channelId: "alpha", muted: false }, + h.secret, + 50000, + ); + expect(removed.mutes.channels).toEqual({ + ...channels, + alpha: { muted: false, updatedAt: 60002 }, + }); + expect(decodeSidebarPreferences([removed.event], h.secret).muted).toEqual([ + "beta", + ]); + expect( + prepareSidebarMute( + [removed.event], + { channelId: "alpha", muted: false }, + h.secret, + ).event, + ).toBeUndefined(); + expect( + prepareSidebarMute([], { channelId: "new", muted: false }, h.secret, 50000) + .mutes.channels, + ).toEqual({ new: { muted: false, updatedAt: 50000 } }); +}); +it("refuses untrusted, ambiguous, malformed and over-budget heads rather than seeding", () => { + const h = harness(), + other = harness(); + const intent = { channelId: "alpha", muted: true }; + const valid = h.encrypt({}); + for (const events of [ + null, + [other.encrypt({})], + [valid, valid], + [{ ...JSON.parse(JSON.stringify(valid)), sig: "0".repeat(128) }], + [h.encrypt({}, { tags: [["d", "channel-sections"]] })], + [ + h.encrypt( + {}, + { + tags: [ + ["d", "channel-mutes"], + ["d", "channel-mutes"], + ], + }, + ), + ], + [h.encrypt({ alpha: { muted: true, updatedAt: -1 } })], + [h.encrypt({}, { content: "x".repeat(SIDEBAR_REQUEST_BYTES) })], + ]) + expect(() => prepareSidebarMute(events, intent, h.secret)).toThrow(); + const full = Object.fromEntries( + Array.from({ length: 500 }, (_, i) => [ + `id-${i}`, + { muted: false, updatedAt: 1 }, + ]), + ); + expect(() => prepareSidebarMute([h.encrypt(full)], intent, h.secret)).toThrow( + "budget exceeded", + ); +}); +it("confirms fresh retained state, including newer unrelated entries, and does not publish no-ops", async () => { + const h = harness(); + let heads = []; + const read = vi.fn(async () => heads); + const publish = vi.fn(async () => { + heads = [ + h.encrypt({ + alpha: { muted: true, updatedAt: 1 }, + beta: { muted: true, updatedAt: 2 }, + }), + ]; + }); + const intent = { channelId: "alpha", muted: true }; + expect( + (await mutateSidebarMute(intent, h.secret, read, publish)).channels, + ).toHaveProperty("beta"); + expect(read).toHaveBeenCalledTimes(2); + expect(publish).toHaveBeenCalledOnce(); + await mutateSidebarMute(intent, h.secret, read, publish); + expect(publish).toHaveBeenCalledOnce(); +}); +it("does not report success on read/publish failures or conflicting confirmation", async () => { + const h = harness(), + intent = { channelId: "alpha", muted: true }; + const publish = vi.fn(); + await expect( + mutateSidebarMute( + intent, + h.secret, + async () => { + throw new Error("read failed"); + }, + publish, + ), + ).rejects.toThrow("read failed"); + expect(publish).not.toHaveBeenCalled(); + const read = vi.fn(async () => []); + await expect( + mutateSidebarMute(intent, h.secret, read, async () => { + throw new Error("publish failed"); + }), + ).rejects.toThrow("publish failed"); + expect(read).toHaveBeenCalledOnce(); + await expect( + mutateSidebarMute(intent, h.secret, read, publish), + ).rejects.toThrow("changed on another device"); +}); diff --git a/dev/sidebar-preference-writes.test.mjs b/dev/sidebar-preference-writes.test.mjs index 232ecc2f..3c81d756 100644 --- a/dev/sidebar-preference-writes.test.mjs +++ b/dev/sidebar-preference-writes.test.mjs @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { afterEach, expect, it } from "vitest"; import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools"; import { relayBrokerPlugin } from "./relay-broker.mjs"; +import { prepareSidebarMute } from "./sidebar-mutes.mjs"; import { prepareSidebarStar } from "./sidebar-stars.mjs"; import { connectBrokerTransport } from "../src/features/relay/transport.ts"; import { fixtureRelayUrl, fixtureAliases } from "../tests/relay-config.ts"; @@ -84,8 +85,8 @@ async function harness() { conflict() { conflict = true; }, - post(value, origin) { - return fetch(`${base}/api/relay/sidebar-star`, { + post(value, origin, route = "sidebar-star") { + return fetch(`${base}/api/relay/${route}`, { method: "POST", headers: { "Content-Type": "application/json", @@ -177,3 +178,80 @@ it.each(["query", "oversized", "publication", "receipt", "conflict"])( ); }, ); + +it("real broker Mute roundtrip signs scoped requests and confirms before projecting", async () => { + const h = await harness(), + signal = new AbortController().signal; + h.heads.set( + "channel-mutes", + prepareSidebarMute([], { channelId: "other", muted: true }, h.key).event, + ); + expect( + await h.transport.writeSidebarMute( + { channelId: "alpha", muted: true }, + signal, + ), + ).toEqual(["other", "alpha"]); + expect(h.calls.map((call) => new URL(call.url).pathname)).toEqual([ + "/query", + "/events", + "/query", + ]); + expect(h.calls[0].body).toEqual([ + { kinds: [30078], authors: [h.viewer], "#d": ["channel-mutes"], limit: 1 }, + ]); + expect( + await h.transport.writeSidebarMute( + { channelId: "alpha", muted: false }, + signal, + ), + ).toEqual(["other"]); + expect(h.calls.filter((call) => call.url.endsWith("/events"))).toHaveLength( + 2, + ); + await h.transport.writeSidebarMute( + { channelId: "alpha", muted: false }, + signal, + ); + expect(h.calls.filter((call) => call.url.endsWith("/events"))).toHaveLength( + 2, + ); +}); +it("refuses invalid intent and foreign origins without upstream requests", async () => { + const h = await harness(); + const post = (value, origin) => h.post(value, origin, "sidebar-mute"); + expect((await post({ channelId: "alpha", muted: "true" })).status).toBe(400); + expect( + (await post({ channelId: "alpha", muted: true }, "https://foreign.invalid")) + .status, + ).toBe(403); + expect( + (await post({ channelId: "x".repeat(2100), muted: true })).status, + ).toBe(413); + expect(h.calls).toEqual([]); +}); +it.each(["query", "oversized", "publication", "receipt", "conflict"])( + "does not claim a saved Mute after %s failure", + async (failure) => { + const h = await harness(); + if (failure === "query") + h.failQuery(new Response("failed", { status: 503 })); + if (failure === "oversized") + h.failQuery(new Response(`[${" ".repeat(270000)}]`)); + if (failure === "publication") + h.failPublication(new Response("failed", { status: 503 })); + if (failure === "receipt") + h.failPublication(Response.json({ accepted: false, event_id: "wrong" })); + if (failure === "conflict") h.conflict(); + await expect( + h.transport.writeSidebarMute( + { channelId: "alpha", muted: true }, + new AbortController().signal, + ), + ).rejects.toThrow(); + if (["query", "oversized"].includes(failure)) + expect(h.calls.filter((call) => call.url.endsWith("/events"))).toEqual( + [], + ); + }, +); diff --git a/dev/sidebar-preferences.mjs b/dev/sidebar-preferences.mjs index dfe6b9e9..046c0b42 100644 --- a/dev/sidebar-preferences.mjs +++ b/dev/sidebar-preferences.mjs @@ -11,7 +11,7 @@ export const SIDEBAR_UPLOAD_MS = 10_000; export function decodeSidebarPreferences(events, secret) { if ( !Array.isArray(events) || - events.length > 2 || + events.length > SIDEBAR_COORDINATES.length || Buffer.byteLength(JSON.stringify(events)) > SIDEBAR_REQUEST_BYTES ) throw new Error("Invalid sidebar records"); @@ -49,6 +49,7 @@ export function decodeSidebarPreferences(events, secret) { return projectSidebarPreferences( decoded.get("channel-sections"), decoded.get("channel-stars"), + decoded.get("channel-mutes"), ); } finally { key.fill(0); diff --git a/docs/channels.md b/docs/channels.md index 574f538e..6b5bfde9 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -63,7 +63,7 @@ The workspace React key includes community/viewer scope **and** connection generation. This resets session-owned component state on switching or reconnecting; drafts, channel selection and reading geometry retain their stable scope keys. -Saved sidebar groups, ordering, assignments and stars live in the session's +Saved sidebar groups, ordering, assignments, stars and mutes live in the session's `sidebarPreferences` snapshot, not in the mounted Messages page. `ensure()` shares one initial read; `refresh()` explicitly reloads/retries while retaining the last good snapshot through loading/errors. Page exits neither restart nor cancel that @@ -72,12 +72,13 @@ late completion cannot repopulate a retired snapshot. These are account-owned preferences, not channel access grants: sidebar sections still intersect the authorized roster. There is no new disk cache or automatic cross-device sync. -The browser/development host exposes narrow **assign/remove group** and -**Star/Unstar** commands. Each re-reads the viewer's signed encrypted coordinate, -changes only the requested entry, publishes through the shared relay admission -lane, then re-reads to confirm the requested state. Unrelated fields and explicit -unstar tombstones are retained. Invalid/unreadable/over-budget heads fail closed; -only a successful absent-head read can seed a coordinate. Same-host writes are +The browser/development host exposes narrow **assign/remove group**, +**Star/Unstar**, and **Mute/Unmute** commands. Each re-reads the viewer's signed +encrypted coordinate, changes only the requested entry, publishes through the shared +relay admission lane, then re-reads to confirm the requested state. Unrelated fields +and explicit unstar/unmute tombstones are retained. Invalid/unreadable/over-budget +heads fail closed; only a successful absent-head read can seed a coordinate. +Same-host writes are serialized per relay. This is confirmed whole-record replacement, not atomic cross-device merging, a durable pending outbox, or automatic retry: simultaneous writers on different hosts can still race. Failure leaves the last confirmed UI @@ -92,8 +93,14 @@ Stream rows expose these actions by right-click/long-press, Shift+F10 or the Context Menu key. Menus remain open during saving and failed-save retry; confirmed relocation expands the destination and restores focus by channel identity. Starred placement is exclusive, retaining the saved assignment so Unstar restores it. -Forums/DMs, group CRUD/reorder and independent sorting are outside this slice. -Hosts without the write capabilities retain the read-only projection. +Group assignment and stars remain stream-only; group CRUD/reorder and independent +sorting are outside this slice. Mute and Mark as Read also apply to forum/DM rows +when the host supports them. Mute changes [notification eligibility](notifications.md), +not unread truth; its row indicator says “Muted; mentions still notify”. Mark as +Read delegates to the [durable unread owner](unread.md) without selecting the row, +and closes only after the local transaction commits. These actions share the same +saving/error/retry and focus-return behavior. Hosts without the write capabilities +retain the read-only projection; Mark as Read requires `frontier-sync`. Search, collapsed section keys and sidebar scroll remain separate, scoped view intent. They are saved on page exit and restored before paint when the roster and diff --git a/docs/notifications.md b/docs/notifications.md index 3b1e4971..6f971d41 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -35,6 +35,16 @@ checks, not that an OS banner was displayed or read. complete snapshot); local-only hosts wait only for local storage. Failed or cancelled observation does not release alerts. Visibility is checked after UI presentation, without publishing read intent. +- Channel Mute/Unmute uses the session's confirmed, encrypted `channel-mutes` + preference, independently of whether Channels is mounted. Muted channels suppress + DM and participating-thread alerts; explicit mentions still pass the channel-mute + gate, not global off/category/read/access/permission gates. This does not introduce + a broadcast notification category or change unread badges. Unknown or failed + preference reads hold non-mention candidates until explicit retry; a confirmed + mute cancels pending candidates, including an in-flight permission check. Unmute + does not replay cancelled alerts. Existing shown OS banners are not withdrawn. + Hosts without preference decoding retain existing notification behavior; this + slice adds no native preference adapter or automatic cross-device synchronization. - Permission is requested explicitly from Settings where a browser needs a user gesture. A fresh pending candidate is reconsidered after Allow; a newer off choice still wins. Observable API errors are reported, never auto-retried. diff --git a/docs/unread.md b/docs/unread.md index 68623fcd..9da9f299 100644 --- a/docs/unread.md +++ b/docs/unread.md @@ -65,6 +65,14 @@ could hide unseen siblings. Oversized rows that never fit fully are not auto-rea - `markThrough(target, messageId)` is explicit prefix intent through verified evidence. It can mark unloaded earlier messages read; do not use it for viewport observation. A channel prefix requires a top-level message, not a reply. +- `markChannelRead(channelId)` snapshots the newest retained verified message + (including replies) when invoked, then atomically advances the channel frontier + and clears the channel's owned local manual-unread marks. It does not fetch + history, select the row, or substitute the wall clock for message evidence. + Arrivals beyond that timestamp remain unread; like other timestamp prefixes, + this also covers messages at or before the cut that arrive later. + With no message evidence, it clears only the channel's local mark and invents + no frontier. Success means local durability; publication may still be pending. - `markUnreadLocal(target)` is durable **on this browser profile/device only**. Automatic reading does not clear it. An explicit mark-through clears that target's local mark. `syncedManualUnread` is `false`. @@ -99,6 +107,23 @@ and row projection (case-insensitive hex, last valid marker wins). Resolution st requires bounded, retained same-channel message evidence; references alone do not grant access or trigger a read. +## Explicit clearing matrix + +| Intent | Durable frontier | Local manual-unread clears | +| --- | --- | --- | +| Automatic visible dwell | Individual verified message | None | +| `markThrough(target, messageId)` | Explicit verified target prefix | That target only | +| `markChannelRead(channelId)` | Channel through newest retained verified message, including replies | Channel, retained messages, verified same-channel reply roots, and threads whose top-level root is retained | +| Channel read with no evidence | None | Channel only | +| Mute/Unmute | None | None | + +Channel read does not clear other channels, unproven ancestry, or remote manual +unread overrides. Bounded evidence cannot establish ownership of every historical +local mark. The channel frontier and owned local clears commit in one transaction; +storage failure changes neither, and disposal/cache clear or access revoke/regrant +invalidates queued intent. Automatic dwell retains its existing cancellation rule +for newer manual-unread intent. + ## Durable sync and privacy The journal is separate from disposable message caches in `buzz-read-state-v1`, diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 1bfb9b82..d75dedb2 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -22,6 +22,7 @@ import { import { Hash, Search, + BellOff, PlugZap, MessageCircle, MoreHorizontal, @@ -549,6 +550,24 @@ function ChannelWorkspace({ }); } }; + const runChannelAction = async ( + channelId: string, + action: () => Promise, + ) => { + const generation = rowMenuGeneration.current; + setGroupWrite({ channelId, pending: true }); + try { + await action(); + if (generation === rowMenuGeneration.current) closeRowMenu(); + } catch (error) { + if (generation !== rowMenuGeneration.current) return; + setGroupWrite({ + channelId, + pending: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }; const setChannelStar = async (channelId: string, starred: boolean) => { const generation = rowMenuGeneration.current; setGroupWrite({ channelId, pending: true }); @@ -628,8 +647,14 @@ function ChannelWorkspace({ channel.channelType !== "dm" && channel.channelType !== "forum"; const starred = section.key === "starred"; + const muteable = preferences.muteWritable && !!preferences.data; + const muted = + preferences.data?.muted.includes(channel.id) ?? false; + const readable = + queries.unread.sync().capability === "frontier-sync"; + const actionable = movable || starrable || muteable || readable; const menuOpen = - (movable || starrable) && + actionable && rowMenu?.channel.id === channel.id && rowMenu.sectionId === currentSectionId; const channelButton = ( @@ -649,10 +674,16 @@ function ChannelWorkspace({ > {channel.name} + {muted && ( + + )} ); - if (!movable && !starrable) { + if (!actionable) { return (
{channelButton} @@ -760,6 +791,41 @@ function ChannelWorkspace({ )} )} + {(muteable || readable) && (starrable || movable) && ( + + )} + {muteable && ( + + void runChannelAction(channel.id, () => + preferences.setMute(channel.id, !muted), + ) + } + > + {muted ? "Unmute" : "Mute"} + + )} + {readable && ( + + void runChannelAction(channel.id, () => + queries.unread.markChannelRead(channel.id), + ) + } + > + Mark as Read + + )} {groupWrite?.channelId === channel.id && groupWrite.pending &&

Saving…

} {groupWrite?.channelId === channel.id && diff --git a/src/bundled/channels/sidebar-sections.test.ts b/src/bundled/channels/sidebar-sections.test.ts index b2a92927..fcf7c157 100644 --- a/src/bundled/channels/sidebar-sections.test.ts +++ b/src/bundled/channels/sidebar-sections.test.ts @@ -26,6 +26,7 @@ it("intersects groups/stars with active authorized streams, keeping forums and D "group-dm": "channels", other: "missing", }, + muted: [], starred: ["star", "archived", "hidden", "revoked", "dm", "forum"], }; const project = (channels: readonly ChannelSummary[]) => @@ -54,6 +55,7 @@ it("Star placement is exclusive and Unstar restores the saved assignment", () => const saved = { sections: [{ id: "work", name: "Work", order: 0 }], assignments: { beta: "work" }, + muted: [], starred: ["alpha", "beta"], }; const placements = (starred: string[]) => diff --git a/src/bundled/channels/useSidebarPreferences.ts b/src/bundled/channels/useSidebarPreferences.ts index 011463d6..689d50e4 100644 --- a/src/bundled/channels/useSidebarPreferences.ts +++ b/src/bundled/channels/useSidebarPreferences.ts @@ -20,6 +20,8 @@ export function useSidebarPreferences( assign: queries.assign, starWritable: queries.starWritable, setStar: queries.setStar, + muteWritable: queries.muteWritable, + setMute: queries.setMute, reload: queries.refresh, }; } diff --git a/src/features/notifications/messages.test.ts b/src/features/notifications/messages.test.ts index 55dce04f..701393e6 100644 --- a/src/features/notifications/messages.test.ts +++ b/src/features/notifications/messages.test.ts @@ -2,6 +2,10 @@ import { Context } from "@deepseek-ai/cordis"; import { PluginRuntime } from "../../plugins/runtime"; import { afterEach, expect, it, vi } from "vitest"; import { createRelaySession } from "../relay/session"; +import type { + SidebarDecoder, + SidebarMuteMutator, +} from "../relay/sidebar-preferences"; import type { LiveCallbacks } from "../relay/live"; import type { ReadFilter } from "../relay/events"; import type { Communities } from "../communities/service"; @@ -40,6 +44,7 @@ async function setup( channelsMounted?: boolean; deferRoster?: boolean; }, + sidebar?: { decode: SidebarDecoder; write?: SidebarMuteMutator }, ) { const viewer = keypair(), peer = keypair(), @@ -106,6 +111,12 @@ async function setup( : {}), } : {}), + ...(sidebar + ? { + decodeSidebarPreferences: sidebar.decode, + ...(sidebar.write ? { writeSidebarMute: sidebar.write } : {}), + } + : {}), media: () => undefined, subscribe(value) { callbacks = value; @@ -722,3 +733,136 @@ it("notification startup waits for the roster without consuming the shared evide expect(h.markerQuery).toHaveBeenCalledOnce(); expect(evidence()).toHaveLength(1); }); + +it.each(["direct", "thread"] as const)( + "confirmed mute suppresses %s alerts but preserves unread and explicit mentions", + async (category) => { + vi.spyOn(Date, "now").mockReturnValue(1_780_000_000_000); + const write = vi.fn(async ({ muted }) => + muted ? ["room"] : [], + ); + const h = await setup(Promise.resolve(), undefined, undefined, { + decode: async () => ({ + sections: [], + assignments: {}, + starred: [], + muted: [], + }), + write, + }); + await h.owner.session.sidebarPreferences.ensure(); + const root = message(h.viewer, "room", "root", 1_779_999_999); + if (category === "direct") + h.emit([ + signed(h.relay, { + kind: 39000, + created_at: 1_780_000_000, + content: JSON.stringify({ name: "Room", channel_type: "dm" }), + tags: [ + ["d", "room"], + ["name", "Room"], + ["t", "dm"], + ], + }), + ]); + else h.emit([root], "replay"); + const make = (text: string) => + message( + h.peer, + "room", + text, + 1_780_000_000, + category === "thread" ? [["e", root.id, "", "reply"]] : [], + ); + await h.owner.session.sidebarPreferences.setMute("room", true); + const quiet = make("quiet"); + h.emit([quiet], "live"); + // Mention is an observable presentation barrier behind the muted candidate. + h.emit([h.make("mention")], "live"); + await vi.waitFor(() => expect(h.show).toHaveBeenCalledOnce()); + expect(h.show.mock.calls[0]?.[0].body).toBe("mention"); + expect(h.owner.session.unread.attention("room", quiet.id).unread).toBe( + true, + ); + await h.owner.session.sidebarPreferences.setMute("room", false); + h.emit([make("audible")], "live"); + await vi.waitFor(() => expect(h.show).toHaveBeenCalledTimes(2)); + expect(h.show.mock.calls[1]?.[0].body).toBe("audible"); + }, +); + +it("a confirmed mute cancels an alert waiting on permission, even after unmute", async () => { + vi.spyOn(Date, "now").mockReturnValue(1_780_000_000_000); + const h = await setup(Promise.resolve(), undefined, undefined, { + decode: async () => ({ + sections: [], + assignments: {}, + starred: [], + muted: [], + }), + write: async ({ muted }) => (muted ? ["room"] : []), + }); + await h.owner.session.sidebarPreferences.ensure(); + let release!: (permission: "granted") => void; + h.permission.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const root = message(h.viewer, "room", "root", 1_779_999_999); + const reply = message(h.peer, "room", "cancelled reply", 1_780_000_000, [ + ["e", root.id, "", "reply"], + ]); + h.emit([root], "replay"); + h.emit([reply], "live"); + await vi.waitFor(() => expect(release).toBeTypeOf("function")); + try { + await h.owner.session.sidebarPreferences.setMute("room", true); + await h.owner.session.sidebarPreferences.setMute("room", false); + } finally { + release("granted"); + } + // A fresh candidate drains the presentation turn after permission resolves. + h.emit([h.make("fresh mention")], "live"); + await vi.waitFor(() => expect(h.show).toHaveBeenCalledOnce()); + expect(h.show.mock.calls[0]?.[0].body).toBe("fresh mention"); + expect(h.owner.session.unread.attention("room", reply.id).unread).toBe(true); +}); + +it.each([true, false])( + "initial mute read failure holds ordinary alerts until explicit retry (muted=%s), without Channels mounted", + async (muted) => { + vi.spyOn(Date, "now").mockReturnValue(1_780_000_000_000); + const decode = vi + .fn() + .mockRejectedValueOnce(new Error("preferences unavailable")) + .mockResolvedValue({ + sections: [], + assignments: {}, + starred: [], + muted: muted ? ["room"] : [], + }); + const h = await setup(Promise.resolve(), undefined, undefined, { decode }); + await h.owner.session.sidebarPreferences.ensure(); + expect(h.owner.session.sidebarPreferences.snapshot().status).toBe("error"); + const root = message(h.viewer, "room", "root", 1_779_999_999); + h.emit([root], "replay"); + const reply = message(h.peer, "room", "waiting", 1_780_000_000, [ + ["e", root.id, "", "reply"], + ]); + h.emit([reply], "live"); + // A mention bypasses only mute readiness, not existing read/permission policy. + h.emit([h.make("mention")], "live"); + await vi.waitFor(() => expect(h.show).toHaveBeenCalledOnce()); + expect(h.show.mock.calls[0]?.[0].body).toBe("mention"); + await h.owner.session.sidebarPreferences.refresh(); + if (!muted) await vi.waitFor(() => expect(h.show).toHaveBeenCalledTimes(2)); + else { + h.emit([h.make("second mention")], "live"); + await vi.waitFor(() => expect(h.show).toHaveBeenCalledTimes(2)); + expect(h.show.mock.calls[1]?.[0].body).toBe("second mention"); + } + expect(decode).toHaveBeenCalledTimes(2); + }, +); diff --git a/src/features/notifications/messages.ts b/src/features/notifications/messages.ts index 2b86743b..d38b1eef 100644 --- a/src/features/notifications/messages.ts +++ b/src/features/notifications/messages.ts @@ -54,6 +54,7 @@ export function bindMessageNotifications( let stopIncoming = () => {}; let stopAccess = () => {}; let stopSync = () => {}; + let stopPreferences = () => {}; const update = () => { const client = communities.snapshot(); void notifications.selectViewer(client.viewer); @@ -67,6 +68,7 @@ export function bindMessageNotifications( stopIncoming(); stopAccess(); stopSync(); + stopPreferences(); notifications.revalidate(); session = relay.session; identity = next; @@ -119,6 +121,18 @@ export function bindMessageNotifications( message.messageId, ); const sync = owned.unread.sync(); + // Mentions bypass channel mute, as in the legacy policy. Unknown + // preferences must not briefly release ordinary alerts at startup. + if (attention.category !== "mention") { + const preferences = owned.sidebarPreferences.snapshot(); + if (preferences.data?.muted.includes(message.channelId)) + return false; + if ( + preferences.status !== "ready" && + preferences.status !== "unsupported" + ) + return "wait"; + } if ( attention.status === "ineligible" || (!notifications.snapshot().preferences.notifyWhileViewing && @@ -150,6 +164,13 @@ export function bindMessageNotifications( stopIncoming = owned.subscribeIncoming(receive); // Only reconsider retained live candidates; readiness is not an event source. stopSync = owned.unread.subscribeSync(() => notifications.revalidate()); + const preferencesChanged = () => { + notifications.revalidate(); + if (owned.sidebarPreferences.snapshot().status === "idle") + void owned.sidebarPreferences.ensure(); + }; + stopPreferences = owned.sidebarPreferences.subscribe(preferencesChanged); + preferencesChanged(); // App-global ownership: Channels may not be mounted. Start its shared // observation only after discovery, so an empty startup roster cannot // consume the unread owner's one-shot evidence repair. @@ -175,5 +196,6 @@ export function bindMessageNotifications( stopIncoming(); stopAccess(); stopSync(); + stopPreferences(); }; } diff --git a/src/features/relay/read-state.ts b/src/features/relay/read-state.ts index 90596e64..036d6115 100644 --- a/src/features/relay/read-state.ts +++ b/src/features/relay/read-state.ts @@ -340,6 +340,7 @@ export function createReadState({ timestamp: number | undefined, unread: boolean | undefined, valid: () => boolean, + clearLocalKeys: readonly string[] = [key], ): Promise { return queue(async () => { await ready; @@ -373,7 +374,8 @@ export function createReadState({ ); const localUnread = { ...current.localUnread }; // Automatic observations do not clear explicit local manual-unread intent. - if (unread === false) delete localUnread[key]; + if (unread === false) + for (const clearKey of clearLocalKeys) delete localUnread[clearKey]; if (unread === true) localUnread[key] = revision; return { ...current, @@ -576,7 +578,20 @@ export function createReadState({ timestamp: number, valid: () => boolean, explicit = false, - ) => mutate(key, timestamp, explicit ? false : undefined, valid), + clearLocalKeys?: readonly string[], + ) => + mutate( + key, + timestamp, + explicit ? false : undefined, + valid, + clearLocalKeys, + ), + clearLocalUnread: ( + key: string, + keys: readonly string[], + valid: () => boolean, + ) => mutate(key, undefined, false, valid, keys), markLocalUnread: (key: string, valid: () => boolean) => mutate(key, undefined, true, valid), accept(events: readonly RelayEvent[]) { diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 1ce1c884..e85eabb7 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -654,6 +654,20 @@ export function createRelaySession( ) : undefined; })(), + (() => { + const write = transport?.writeSidebarMute; + return write + ? (intent, signal) => + write( + intent, + AbortSignal.any([ + lifetime.signal, + AbortSignal.timeout(20_000), + signal, + ]), + ) + : undefined; + })(), notify, ); const session = Object.freeze({ diff --git a/src/features/relay/sidebar-preferences-store.test.ts b/src/features/relay/sidebar-preferences-store.test.ts index b827ba4b..5c65cf86 100644 --- a/src/features/relay/sidebar-preferences-store.test.ts +++ b/src/features/relay/sidebar-preferences-store.test.ts @@ -4,6 +4,7 @@ import { flush, keypair, scriptedTransport } from "./testing"; import type { SidebarAssignmentMutator, SidebarStarMutator, + SidebarMuteMutator, SidebarPreferences, } from "./sidebar-preferences"; @@ -20,12 +21,14 @@ function deferred() { const data: SidebarPreferences = { sections: [{ id: "work", name: "Work", order: 0 }], assignments: { alpha: "work" }, + muted: [], starred: ["beta"], }; function setup( decode = vi.fn(async (): Promise => data), write?: SidebarAssignmentMutator, writeStar?: SidebarStarMutator, + writeMute?: SidebarMuteMutator, ) { const wire = scriptedTransport(keypair().pubkey, keypair().pubkey); const owner = createRelaySession({ @@ -33,6 +36,7 @@ function setup( decodeSidebarPreferences: decode, ...(write ? { writeSidebarAssignment: write } : {}), ...(writeStar ? { writeSidebarStar: writeStar } : {}), + ...(writeMute ? { writeSidebarMute: writeMute } : {}), }); return { wire, owner, preferences: owner.session.sidebarPreferences, decode }; } @@ -68,6 +72,7 @@ it("applies a confirmed assignment to the retained session snapshot", async () = { id: "later", name: "Later", order: 1 }, ], assignments: { alpha: "later" }, + muted: [], starred: ["beta"], }, }); @@ -335,6 +340,7 @@ it("serializes confirmed assignment and star writes without losing either projec data: { ...data, assignments: { alpha: "work", beta: "work" }, + muted: [], starred: ["alpha", "beta"], }, }); @@ -472,3 +478,187 @@ it("does not mutate before a successful initial preference read or without host readonly.owner.dispose(); } }); + +it("serializes confirmed assignment and mute writes without losing either projection", async () => { + const gate = deferred(); + const started = deferred(); + const mute = vi.fn(async () => { + started.resolve(); + return gate.promise; + }); + const assign = vi.fn(async () => ({ + sections: data.sections, + assignments: { alpha: "work", beta: "work" }, + })); + const { wire, owner, preferences } = setup( + undefined, + assign, + undefined, + mute, + ); + try { + const initial = preferences.ensure(); + await flush(); + wire.next().respond([]); + await initial; + const pending = preferences.setMute("alpha", true); + await started.promise; + const queued = preferences.assign("beta", "work"); + expect(preferences.snapshot().data).toEqual(data); + expect(assign).not.toHaveBeenCalled(); + gate.resolve(["alpha", "beta"]); + await Promise.all([pending, queued]); + expect(preferences.snapshot()).toEqual({ + status: "ready", + data: { + ...data, + assignments: { alpha: "work", beta: "work" }, + muted: ["alpha", "beta"], + }, + }); + expect(Object.isFrozen(preferences.snapshot().data?.muted)).toBe(true); + expect(mute).toHaveBeenCalledWith( + { channelId: "alpha", muted: true }, + expect.any(AbortSignal), + ); + } finally { + gate.resolve([]); + owner.dispose(); + } +}); + +it("failed Mute retains the confirmed snapshot and a retry can unmute", async () => { + const mute = vi + .fn() + .mockRejectedValueOnce(new Error("publish rejected")) + .mockResolvedValueOnce([]); + const { wire, owner, preferences } = setup( + undefined, + undefined, + undefined, + mute, + ); + try { + const initial = preferences.ensure(); + await flush(); + wire.next().respond([]); + await initial; + const retained = preferences.snapshot(); + await expect(preferences.setMute("beta", false)).rejects.toThrow( + "publish rejected", + ); + expect(preferences.snapshot()).toBe(retained); + await preferences.setMute("beta", false); + expect(preferences.snapshot().data).toEqual({ ...data, muted: [] }); + } finally { + owner.dispose(); + } +}); + +it.each(["success", "failure"])( + "a stale refresh %s cannot overwrite confirmed Mute", + async (outcome) => { + const gate = deferred(); + const started = deferred(); + const decode = vi + .fn(async () => data) + .mockImplementationOnce(async () => data); + const { wire, owner, preferences } = setup( + decode, + undefined, + undefined, + async () => ["alpha", "beta"], + ); + try { + const initial = preferences.ensure(); + await flush(); + wire.next().respond([]); + await initial; + decode.mockImplementationOnce(() => { + started.resolve(); + return gate.promise; + }); + const refresh = preferences.refresh(); + await flush(); + wire.next().respond([]); + await started.promise; + await preferences.setMute("alpha", true); + const retained = preferences.snapshot(); + if (outcome === "success") gate.resolve(data); + else gate.reject(new Error("old read failed")); + await refresh; + expect(preferences.snapshot()).toBe(retained); + expect(retained.status).toBe("ready"); + expect(retained.data?.muted).toEqual(["alpha", "beta"]); + } finally { + gate.resolve(data); + owner.dispose(); + } + }, +); + +it.each(["clearCache", "dispose", "cancel"] as const)( + "%s aborts Mute and fences active and queued writes", + async (action) => { + const gate = deferred(); + const started = deferred(); + const mute = vi.fn(async (_intent, signal) => { + started.resolve(signal); + return gate.promise; + }); + const { wire, owner, preferences } = setup( + undefined, + undefined, + undefined, + mute, + ); + const caller = new AbortController(); + try { + const initial = preferences.ensure(); + await flush(); + wire.next().respond([]); + await initial; + const pending = preferences.setMute("alpha", true, caller.signal); + const activeSignal = await started.promise; + const queued = preferences.setMute("beta", false, caller.signal); + const result = Promise.allSettled([pending, queued]); + if (action === "cancel") caller.abort(); + else await owner[action](); + expect(activeSignal.aborted).toBe(true); + gate.resolve(["alpha", "beta"]); + expect((await result).map((entry) => entry.status)).toEqual([ + "rejected", + "rejected", + ]); + expect(mute).toHaveBeenCalledOnce(); + expect(preferences.snapshot().data).toEqual( + action === "cancel" ? data : undefined, + ); + } finally { + gate.resolve([]); + owner.dispose(); + } + }, +); + +it("does not mutate before a successful initial preference read or without host capability", async () => { + const mute = vi.fn(async () => []); + const assign = vi.fn(async () => data); + const { owner, preferences } = setup(undefined, assign, undefined, mute); + try { + await expect(preferences.setMute("alpha", true)).rejects.toThrow( + "unavailable", + ); + await expect(preferences.assign("alpha", "work")).rejects.toThrow(); + expect(mute).not.toHaveBeenCalled(); + expect(assign).not.toHaveBeenCalled(); + } finally { + owner.dispose(); + } + const readonly = setup(); + try { + expect(readonly.preferences.muteWritable).toBe(false); + } finally { + readonly.owner.dispose(); + } +}); diff --git a/src/features/relay/sidebar-preferences-store.ts b/src/features/relay/sidebar-preferences-store.ts index b6fe42c1..56e5870e 100644 --- a/src/features/relay/sidebar-preferences-store.ts +++ b/src/features/relay/sidebar-preferences-store.ts @@ -1,6 +1,7 @@ import type { SidebarAssignmentMutator, SidebarStarMutator, + SidebarMuteMutator, SidebarPreferences, } from "./sidebar-preferences"; @@ -16,6 +17,7 @@ export function createSidebarPreferencesStore( available: boolean, write?: SidebarAssignmentMutator, writeStar?: SidebarStarMutator, + writeMute?: SidebarMuteMutator, notify = (listener: () => void) => listener(), ) { const listeners = new Set<() => void>(); @@ -37,6 +39,7 @@ export function createSidebarPreferencesStore( ), assignments: Object.freeze({ ...data.assignments }), starred: Object.freeze([...data.starred]), + muted: Object.freeze([...data.muted]), }); const publish = (next: Snapshot) => { snapshot = Object.freeze(next); @@ -118,6 +121,7 @@ export function createSidebarPreferencesStore( sections: groups.sections, assignments: groups.assignments, starred: current?.starred ?? [], + muted: current?.muted ?? [], }), }); return groups; @@ -164,6 +168,42 @@ export function createSidebarPreferencesStore( ); return run; }, + muteWritable: !!writeMute, + setMute(channelId: string, muted: boolean, signal?: AbortSignal) { + if (closed || !writeMute || !snapshot.data) + return Promise.reject( + new Error("Sidebar mutes are unavailable in this host"), + ); + const writeGeneration = generation; + const writeSignal = AbortSignal.any([ + writeLifetime.signal, + ...(signal ? [signal] : []), + ]); + const run = writeQueue + .catch(() => {}) + .then(async () => { + if (closed || generation !== writeGeneration) + throw new Error("Sidebar mutes are unavailable"); + writeSignal.throwIfAborted(); + const mutes = await writeMute({ channelId, muted }, writeSignal); + if (closed || generation !== writeGeneration) + throw new Error("Sidebar mutes are unavailable"); + writeSignal.throwIfAborted(); + const current = snapshot.data; + if (!current) throw new Error("Sidebar mutes are unavailable"); + mutation++; + publish({ + status: "ready", + data: retained({ ...current, muted: mutes }), + }); + return mutes; + }); + writeQueue = run.then( + () => undefined, + () => undefined, + ); + return run; + }, // Keep explicit one-shot reads compatible; views use the retained snapshot. read, snapshot: () => snapshot, diff --git a/src/features/relay/sidebar-preferences.test.ts b/src/features/relay/sidebar-preferences.test.ts index 96719f7e..4adc54d9 100644 --- a/src/features/relay/sidebar-preferences.test.ts +++ b/src/features/relay/sidebar-preferences.test.ts @@ -41,6 +41,7 @@ const stars = { const expected = { sections: groups.sections, assignments: { general: "work" }, + muted: [], starred: ["general"], }; @@ -77,6 +78,12 @@ it("reads legacy preferences through the production session, transport, and boun "#d": ["channel-stars"], limit: 1, }, + { + kinds: [30078], + authors: [viewer.pubkey], + "#d": ["channel-mutes"], + limit: 1, + }, ]); return Response.json(result); }); @@ -221,6 +228,7 @@ it("reads legacy preferences through the production session, transport, and boun expect(await owner.session.sidebarPreferences.read()).toEqual({ sections: [], assignments: {}, + muted: [], starred: [], }); upstream.mockImplementationOnce(async () => diff --git a/src/features/relay/sidebar-preferences.ts b/src/features/relay/sidebar-preferences.ts index da1ec1f9..207db7b2 100644 --- a/src/features/relay/sidebar-preferences.ts +++ b/src/features/relay/sidebar-preferences.ts @@ -4,6 +4,7 @@ import type { RelayReader } from "./reader.ts"; export const SIDEBAR_COORDINATES = [ "channel-sections", "channel-stars", + "channel-mutes", ] as const; export type SidebarGroups = Readonly<{ sections: readonly Readonly<{ @@ -17,6 +18,7 @@ export type SidebarGroups = Readonly<{ export type SidebarPreferences = SidebarGroups & Readonly<{ starred: readonly string[]; + muted: readonly string[]; }>; export type SidebarAssignmentIntent = Readonly<{ channelId: string; @@ -30,6 +32,10 @@ export type SidebarStarMutator = ( intent: Readonly<{ channelId: string; starred: boolean }>, signal: AbortSignal, ) => Promise; +export type SidebarMuteMutator = ( + intent: Readonly<{ channelId: string; muted: boolean }>, + signal: AbortSignal, +) => Promise; export type SidebarDecoder = ( events: readonly RelayEvent[], signal: AbortSignal, @@ -48,15 +54,18 @@ function text(value: unknown, max = 256): string { export function projectSidebarPreferences( sections: unknown, stars: unknown, + mutes?: unknown, ): SidebarPreferences { const result: { sections: { id: string; name: string; icon?: string; order: number }[]; assignments: Record; starred: string[]; + muted: string[]; } = { sections: [], assignments: {}, starred: [], + muted: [], }; if (sections !== undefined) { const data = object(sections); @@ -113,6 +122,24 @@ export function projectSidebarPreferences( if (entry.starred) result.starred.push(id); } } + if (mutes !== undefined) { + const data = object(mutes); + if (data.version !== 1) throw new Error("Unsupported channel mutes"); + const entries = Object.entries(object(data.channels)); + if (entries.length > 500) throw new Error("Channel mute budget exceeded"); + for (const [id, raw] of entries) { + text(id); + const entry = object(raw); + if ( + typeof entry.muted !== "boolean" || + typeof entry.updatedAt !== "number" || + !Number.isFinite(entry.updatedAt) || + entry.updatedAt < 0 + ) + throw new Error("Invalid channel mute"); + if (entry.muted) result.muted.push(id); + } + } return result; } diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index d2736bb6..47a694b6 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -12,6 +12,7 @@ import { projectSidebarPreferences, type SidebarAssignmentMutator, type SidebarStarMutator, + type SidebarMuteMutator, type SidebarDecoder, type SidebarPreferences, } from "./sidebar-preferences"; @@ -64,6 +65,7 @@ export interface ReadTransport { /** Host-only, relay-scoped mutation of one existing sidebar group assignment. */ readonly writeSidebarAssignment?: SidebarAssignmentMutator; readonly writeSidebarStar?: SidebarStarMutator; + readonly writeSidebarMute?: SidebarMuteMutator; readonly profiling?: RelayProfiler; /** Verified incoming traffic. The session owns this subscription and fences late delivery. */ subscribe?(callbacks: LiveCallbacks): LiveSubscription; @@ -173,6 +175,7 @@ export async function connectBrokerTransport( sidebarPreferences?: boolean; sidebarPreferenceWrites?: boolean; sidebarStarWrites?: boolean; + sidebarMuteWrites?: boolean; agentLibrary?: boolean; agentActivity?: boolean; readState?: boolean; @@ -376,6 +379,26 @@ export async function connectBrokerTransport( }, } : {}), + ...(session.sidebarMuteWrites + ? { + async writeSidebarMute(intent, signal) { + const result = await fetch(`${endpoint}/sidebar-mute`, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(intent), + signal, + }); + if (!result.ok) + throw new Error((await readApiFailure(result)).error); + return projectSidebarPreferences( + undefined, + undefined, + await result.json(), + ).muted; + }, + } + : {}), ...(session.writeKinds ? { writer: { diff --git a/src/features/relay/unread.test.ts b/src/features/relay/unread.test.ts index 826aa5ea..64d36b0c 100644 --- a/src/features/relay/unread.test.ts +++ b/src/features/relay/unread.test.ts @@ -32,8 +32,14 @@ function setup(options: ChannelStoreOptions = {}) { alice = keypair(); let journal: ReadJournal | undefined; let hold: Promise | undefined; + let failure: Error | undefined; const storage: ReadStateStorage = { async update(change) { + if (failure) { + const error = failure; + failure = undefined; + throw error; + } if (hold) { const wait = hold; hold = undefined; @@ -96,6 +102,9 @@ function setup(options: ChannelStoreOptions = {}) { target, snapshot: () => owner.session.unread.snapshot(target), journal: () => journal, + failSave() { + failure = new Error("disk full"); + }, holdSave() { let release = () => {}; hold = new Promise((resolve) => { @@ -774,3 +783,140 @@ it("attention fails closed after deletion or access loss, and viewing cannot sur h.emit([row]); expect(h.session.unread.attention("room", row.id).viewing).toBe(false); }); + +it("channel read atomically clears owned marks through the latest reply, preserving other channels and later arrivals", async () => { + const h = setup(); + h.grant("room"); + h.grant("other"); + const root = message(h.alice, "room", "root", 11); + const reply = message(h.alice, "room", "reply", 20, [ + ["e", root.id, "", "reply"], + ["p", h.viewer.pubkey], + ]); + const other = message(h.alice, "other", "other", 12); + h.emit([root, reply, other]); + const unread = h.session.unread; + const thread = { + kind: "thread" as const, + channelId: "room", + rootId: root.id, + }; + const msg = { + kind: "message" as const, + channelId: "room", + messageId: reply.id, + }; + await unread.markUnreadLocal(h.target); + await unread.markUnreadLocal(thread); + await unread.markUnreadLocal(msg); + await unread.markUnreadLocal({ kind: "channel", channelId: "other" }); + const before = h.journal(); + const release = h.holdSave(); + const pending = unread.markChannelRead("room"); + // Arrival is beyond the captured frontier while its durable transaction waits. + const later = message(h.alice, "room", "later", 21); + h.emit([later]); + expect(h.snapshot().manual).toBe("local-only"); + release(); + expect(await pending).toMatchObject({ durability: "saved", sync: "pending" }); + expect(h.journal()?.revision).toBe((before?.revision ?? 0) + 1); + expect(h.journal()?.state.frontiers).toEqual({ room: 20 }); + expect(h.journal()?.localUnread).toEqual({ + other: before?.localUnread.other, + }); + expect(unread.snapshot(thread)).toMatchObject({ + manual: "none", + observedCount: 0, + }); + expect(unread.snapshot(msg)).toMatchObject({ + manual: "none", + observedCount: 0, + }); + expect(h.snapshot()).toMatchObject({ + observedCount: 1, + attentionCount: 0, + manual: "none", + }); + expect( + unread.snapshot({ kind: "channel", channelId: "other" }), + ).toMatchObject({ observedCount: 1, manual: "local-only" }); + expect(h.session.channels.window("room").rows).toHaveLength(0); +}); + +it("channel read clears local intent without fabricating a frontier when no messages are known", async () => { + const h = setup(); + h.grant("room"); + await h.session.unread.markUnreadLocal(h.target); + await h.session.unread.markChannelRead("room"); + expect(h.journal()?.state.frontiers).toEqual({}); + expect(h.snapshot()).toMatchObject({ observedCount: null, manual: "none" }); + expect(h.host.sign).not.toHaveBeenCalled(); +}); + +it.each(["clearCache", "dispose", "revoke-regrant"] as const)( + "channel read rejects delayed intent after %s without clearing saved marks", + async (action) => { + const h = setup(); + h.grant("room"); + h.emit([message(h.alice, "room", "root", 11)]); + await h.session.unread.markUnreadLocal(h.target); + const before = h.journal(); + const release = h.holdSave(); + const result = h.session.unread.markChannelRead("room"); + const rejected = expect(result).rejects.toThrow(); + if (action === "revoke-regrant") { + h.emit([roster(h.relay, "room", [], 20)]); + h.grant("room", 21); + } else await h[action](); + release(); + await rejected; + expect(h.journal()?.state.frontiers).toEqual(before?.state.frontiers); + expect(h.journal()?.localUnread).toEqual(before?.localUnread); + }, +); + +it("channel read cannot use another channel, deleted content or auxiliary events as its frontier", async () => { + const h = setup(); + h.grant("room"); + h.grant("other"); + const root = message(h.alice, "room", "root", 11); + const removed = message(h.alice, "room", "deleted", 20); + h.emit([ + root, + removed, + message(h.alice, "other", "other", 30), + signed(h.alice, { + kind: 5, + created_at: 40, + tags: [ + ["h", "room"], + ["e", removed.id], + ], + content: "", + }), + ]); + await h.session.unread.markChannelRead("room"); + expect(h.journal()?.state.frontiers).toEqual({ room: 11 }); + await expect(h.session.unread.markChannelRead("denied")).rejects.toThrow( + "unavailable", + ); +}); + +it("failed channel read saves neither frontier nor clears, and an explicit retry succeeds", async () => { + const h = setup(); + h.grant("room"); + h.emit([message(h.alice, "room", "root", 11)]); + await h.session.unread.markUnreadLocal(h.target); + const before = h.journal(); + h.failSave(); + await expect(h.session.unread.markChannelRead("room")).rejects.toThrow( + "disk full", + ); + expect(h.journal()).toEqual(before); + expect(h.snapshot()).toMatchObject({ + observedCount: 1, + manual: "local-only", + }); + await h.session.unread.markChannelRead("room"); + expect(h.snapshot()).toMatchObject({ observedCount: 0, manual: "none" }); +}); diff --git a/src/features/relay/unread.ts b/src/features/relay/unread.ts index 9378a47d..bfe0bd90 100644 --- a/src/features/relay/unread.ts +++ b/src/features/relay/unread.ts @@ -55,6 +55,8 @@ export interface UnreadCapability { target: ReadTarget, messageId: string, ): Promise; + /** Explicit channel prefix through retained verified evidence, including replies. */ + markChannelRead(channelId: string): Promise; markUnreadLocal(target: ReadTarget): Promise; readonly syncedManualUnread: false; } @@ -637,6 +639,32 @@ export function createUnread({ true, ); }, + async markChannelRead(channelId) { + if (closed || !allowed(channelId)) + throw new Error("Read target unavailable"); + indexEvidence(); + const rows = byChannel.get(channelId) ?? []; + // Snapshot the cut at invocation, not after a queued storage write. Do not + // substitute wall time or a preview timestamp for verified domain evidence. + const latest = rows.reduce( + (head, { event }) => + !head || event.created_at > head.created_at ? event : head, + undefined, + ); + const keys = new Set([channelId]); + for (const { event, rootId } of rows) { + keys.add(`msg:${event.id}`); + if (rootId) keys.add(`thread:${rootId}`); + // A retained top-level message establishes its thread's channel even + // when that thread's replies are outside our bounded evidence window. + if (!threadReference(event)) keys.add(`thread:${event.id}`); + } + const generation = epoch; + const valid = () => !closed && generation === epoch && allowed(channelId); + return latest + ? reads.read(channelId, latest.created_at, valid, true, [...keys]) + : reads.clearLocalUnread(channelId, [channelId], valid); + }, async markUnreadLocal(target) { const key = targetKey(target); const generation = epoch; diff --git a/src/features/relay/warm-lifecycle.test.ts b/src/features/relay/warm-lifecycle.test.ts index c9acfa36..03c31924 100644 --- a/src/features/relay/warm-lifecycle.test.ts +++ b/src/features/relay/warm-lifecycle.test.ts @@ -46,6 +46,7 @@ async function setup( sections: [], assignments: {}, starred, + muted: [], }), query: vi.fn((...args: Parameters) => args[0].some((filter) => filter.kinds?.includes(0)) diff --git a/src/features/relay/warm.test.ts b/src/features/relay/warm.test.ts index bead9f7d..24f1eccc 100644 --- a/src/features/relay/warm.test.ts +++ b/src/features/relay/warm.test.ts @@ -41,6 +41,7 @@ function setup( decodeSidebarPreferences: async () => ({ sections: [], assignments: {}, + muted: [], starred: Object.freeze([...starred]), }), }; diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index a5f0dbf6..e0f6c954 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -614,7 +614,11 @@ export const test = base.extend({ ([key]) => key === "d", )?.[1]; if ( - ["channel-sections", "channel-stars"].includes(coordinate) + [ + "channel-sections", + "channel-stars", + "channel-mutes", + ].includes(coordinate) ) { expect(event.tags).toContainEqual(["t", coordinate]); const blob = JSON.parse( @@ -1022,26 +1026,29 @@ export const test = base.extend({ observerFailures.splice(match, 1); return true; }; - // The Star retry journey injects one specific failed host request. Match + // The Star/Mute retry journeys inject specific failed host requests. Match // that exact URL once, not every 502 or every console error in the test. - const starFailures = [...(report.sidebarStarFailures ?? [])]; - const injectedStarFailure = (message, index) => { + const preferenceFailures = [ + ...(report.sidebarStarFailures ?? []), + ...(report.sidebarMuteFailures ?? []), + ]; + const injectedPreferenceFailure = (message, index) => { if ( !/^Failed to load resource: the server responded with a status of 502/.test( message, ) ) return false; - const match = starFailures.indexOf(consoleLocations.get(index)); + const match = preferenceFailures.indexOf(consoleLocations.get(index)); if (match < 0) return false; - starFailures.splice(match, 1); + preferenceFailures.splice(match, 1); return true; }; expect( report.consoleErrors.filter( (message, index) => !retiredConsole(message, index) && - !injectedStarFailure(message, index) && + !injectedPreferenceFailure(message, index) && !( expectedPageFailure && message.includes("Fixture page render failure") diff --git a/tests/browser/navigation-groups.spec.mjs b/tests/browser/navigation-groups.spec.mjs index 6655de42..510ab279 100644 --- a/tests/browser/navigation-groups.spec.mjs +++ b/tests/browser/navigation-groups.spec.mjs @@ -31,7 +31,7 @@ test("row menu moves and removes a channel through the confirmed saved-group wri ).toHaveAttribute("aria-checked", "true"); await page.keyboard.press("End"); await expect( - page.getByRole("menuitem", { name: "Remove from group" }), + page.getByRole("menuitem", { name: "Mark as Read", exact: true }), ).toBeFocused(); await page.keyboard.press("Home"); await expect( diff --git a/tests/browser/navigation-mute-read.spec.mjs b/tests/browser/navigation-mute-read.spec.mjs new file mode 100644 index 00000000..fe10d49e --- /dev/null +++ b/tests/browser/navigation-mute-read.spec.mjs @@ -0,0 +1,133 @@ +import { test, expect } from "./fixture.mjs"; +import { open } from "./timeline.mjs"; + +// Browser-only boundary: real shared-menu focus/dismissal, production broker, +// IndexedDB reload and app-global preference startup. Policy matrices live in Vitest. +test.use({ + productionBroker: true, + readState: true, + savedSidebar: true, + historyCounts: { alpha: 8, beta: 6 }, +}); + +test("channel menu mute/read persist without selecting the row; failed mute remains retryable", async ({ + page, + app, +}, testInfo) => { + await page.addInitScript(() => + localStorage.setItem("buzz-appearance.v1", "dark"), + ); + await open(page, app); + const sidebar = page.getByRole("navigation", { name: "Subscribed channels" }); + const beta = sidebar.locator('[data-channel-id="beta"]'); + const alpha = sidebar.locator('[data-channel-id="alpha"]'); + const menu = page.getByRole("menu", { name: "Actions for Beta" }); + const badge = (row) => + row.getByRole("img", { name: /observed unread messages/ }); + await expect(badge(beta)).toHaveAttribute( + "aria-label", + /^6 observed unread messages/, + ); + await expect(badge(alpha)).toHaveAttribute( + "aria-label", + /^8 observed unread messages/, + ); + await beta.focus(); + await page.keyboard.press("Shift+F10"); + await expect(menu).toBeVisible(); + await menu.screenshot({ path: testInfo.outputPath("mute-read-menu.png") }); + let release, started; + const gate = new Promise((resolve) => { + release = resolve; + }); + const requested = new Promise((resolve) => { + started = resolve; + }); + await page.route("**/sidebar-mute", async (route) => { + started(); + await gate; + app.report.sidebarMuteFailures ??= []; + app.report.sidebarMuteFailures.push(route.request().url()); + await route.fulfill({ + status: 502, + contentType: "application/json", + body: JSON.stringify({ error: "Fixture mute failure" }), + }); + }); + try { + await menu.getByRole("menuitem", { name: "Mute", exact: true }).click(); + await requested; + await expect(menu.getByRole("status")).toHaveText("Saving…"); + await expect( + menu.getByRole("menuitem", { name: "Mark as Read" }), + ).toBeDisabled(); + await expect(beta.getByLabel("Muted; mentions still notify")).toHaveCount( + 0, + ); + } finally { + release(); + } + await expect(menu.getByRole("alert")).toHaveText( + "Relay request failed (502)", + ); + await page.unroute("**/sidebar-mute"); + await menu.getByRole("menuitem", { name: "Mute", exact: true }).click(); + await expect(menu).toHaveCount(0); + await expect(beta).toBeFocused(); + await expect(beta.getByLabel("Muted; mentions still notify")).toBeVisible(); + await expect(badge(beta)).toHaveAttribute( + "aria-label", + /^6 observed unread messages/, + ); + expect(app.report.sidebarPublications.at(-1)).toMatchObject({ + coordinate: "channel-mutes", + blob: { channels: { beta: { muted: true } } }, + }); + await page.keyboard.press("ContextMenu"); + await expect( + menu.getByRole("menuitem", { name: "Unmute", exact: true }), + ).toBeVisible(); + await menu.getByRole("menuitem", { name: "Mark as Read" }).click(); + await expect(menu).toHaveCount(0); + await expect(beta).toBeFocused(); + await expect(badge(beta)).toHaveCount(0); + await expect(badge(alpha)).toHaveAttribute( + "aria-label", + /^8 observed unread messages/, + ); + await expect(alpha).toHaveAttribute("aria-current", "page"); + await expect + .poll( + () => + app.report.readPublications.some( + ({ blob }) => + blob.contexts.beta === + app.histories.get("primary/beta").at(-1).created_at, + ), + { timeout: 12000 }, + ) + .toBe(true); + await page.reload(); + await page + .getByRole("button", { name: "Messages", exact: true }) + .first() + .click(); + await expect(beta.getByLabel("Muted; mentions still notify")).toBeVisible(); + await expect(badge(alpha)).toHaveAttribute( + "aria-label", + /^8 observed unread messages/, + ); + await expect(badge(beta)).toHaveCount(0); + await beta.click({ button: "right" }); + await menu.getByRole("menuitem", { name: "Unmute", exact: true }).click(); + // A modal menu temporarily hides its background from accessible queries. + // Dismissal establishes confirmed completion, not the icon's hidden interval. + await expect(menu).toHaveCount(0); + await expect(beta).toBeFocused(); + await expect(beta.getByLabel("Muted; mentions still notify")).toHaveCount(0); + expect(app.report.sidebarPublications.at(-1)).toMatchObject({ + coordinate: "channel-mutes", + blob: { channels: { beta: { muted: false } } }, + }); + expect(app.report.unexpected).toEqual([]); +}); diff --git a/tests/browser/policy-relay.mjs b/tests/browser/policy-relay.mjs index 537f25b6..3d8e0408 100644 --- a/tests/browser/policy-relay.mjs +++ b/tests/browser/policy-relay.mjs @@ -140,9 +140,10 @@ export function policyRelay({ ); } if (filters.length !== 1) { - // The read-only sidebar projection reads the two exact coordinates. - expect(filters).toHaveLength(2); + // The sidebar projection reads only these exact account coordinates. + expect(filters).toHaveLength(3); expect(filters.map((filter) => filter["#d"]?.[0]).sort()).toEqual([ + "channel-mutes", "channel-sections", "channel-stars", ]);