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", ]);