From 6c65ec3078ab9480a8d379a106a05eec1cde6665 Mon Sep 17 00:00:00 2001 From: Carl Date: Wed, 23 Sep 2026 23:54:02 -0700 Subject: [PATCH 1/3] feat(channels): mute conversations and mark them read or unread Extend the existing persistent sidebar context menu with icon-bearing mute/read actions. Preserve New session, placement cleanup, and focus handoff while confirming encrypted mute writes through the live socket and durable unread owner. Signed-off-by: Carl --- dev/relay-broker.mjs | 149 ++++++++ dev/sidebar-mutes-broker.test.mjs | 218 +++++++++++ dev/sidebar-mutes.mjs | 90 +++++ dev/sidebar-mutes.test.mjs | 207 +++++++++++ dev/sidebar-preferences.mjs | 3 +- docs/channels.md | 35 +- docs/notifications.md | 10 + docs/unread.md | 25 ++ .../channels/ChannelReadMenuItem.test.tsx | 141 +++++++ src/bundled/channels/ChannelReadMenuItem.tsx | 59 +++ src/bundled/channels/sidebar-sections.test.ts | 1 + .../channels/useChannelRowMenu.test.tsx | 1 + .../channels/useOptimisticMute.test.tsx | 140 +++++++ src/bundled/channels/useOptimisticMute.ts | 72 ++++ .../channel-navigation/ChannelSidebar.tsx | 128 ++++++- src/features/notifications/messages.test.ts | 144 ++++++++ src/features/notifications/messages.ts | 22 ++ src/features/relay/live-restriction.test.ts | 1 + src/features/relay/read-state.ts | 19 +- src/features/relay/session.ts | 14 + .../relay/sidebar-preferences-store.test.ts | 176 ++++++++- .../relay/sidebar-preferences-store.ts | 75 +++- .../relay/sidebar-preferences.test.ts | 8 + src/features/relay/sidebar-preferences.ts | 27 ++ src/features/relay/transport.ts | 29 +- 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 + src/shared/design-system/icons/index.ts | 9 + tests/browser/fixture.mjs | 34 ++ tests/browser/navigation-mute-read.spec.mjs | 347 ++++++++++++++++++ .../browser/navigation-session-menu.spec.mjs | 1 + tests/browser/policy-relay.mjs | 5 +- 34 files changed, 2352 insertions(+), 14 deletions(-) create mode 100644 dev/sidebar-mutes-broker.test.mjs create mode 100644 dev/sidebar-mutes.mjs create mode 100644 dev/sidebar-mutes.test.mjs create mode 100644 src/bundled/channels/ChannelReadMenuItem.test.tsx create mode 100644 src/bundled/channels/ChannelReadMenuItem.tsx create mode 100644 src/bundled/channels/useOptimisticMute.test.tsx create mode 100644 src/bundled/channels/useOptimisticMute.ts create mode 100644 tests/browser/navigation-mute-read.spec.mjs diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index 768ee6eb9..d3eae36e0 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -1,5 +1,9 @@ import { memoryFilter, decodeAgentMemory } from "./agent-memory.mjs"; import { memoryResponseText } from "../src/features/agents/memory.ts"; +import { + assertSidebarMuteIntent, + mutateSidebarMute, +} from "./sidebar-mutes.mjs"; import { prepareMedia } from "./media-preparation.mjs"; import { readProjectGit } from "./project-git.mjs"; import { parseGitRead } from "../src/features/projects/git.ts"; @@ -86,6 +90,7 @@ import { schnorr } from "@noble/curves/secp256k1.js"; const MAX_FILTERS = 4, MAX_LIMIT = 500, MAX_INFLIGHT = 6, + SIDEBAR_HEAD_BYTES = SIDEBAR_REQUEST_BYTES + 4096, UPSTREAM_TIMEOUT_MS = 20000, KEEPALIVE_MS = 60000; @@ -423,6 +428,28 @@ export function relayBrokerPlugin({ const upstream = createUpstream(); // Injected fixtures bypass the pool; the live relay always uses the warm agent. const fetchUpstream = upstreamFetch ?? upstream.fetch; + const readSidebarHead = async (response, label = "preference") => { + if (!response.body) + throw new Error(`Sidebar ${label} response missing`); + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let bytes = 0, + text = ""; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) return JSON.parse(text + decoder.decode()); + bytes += value.byteLength; + if (bytes > SIDEBAR_HEAD_BYTES) + throw new Error(`Sidebar ${label} response exceeds capacity`); + text += decoder.decode(value, { stream: true }); + } + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } + }; + // Discovery is lazy and independent for each community; unavailable relays never block startup. const registered = new Map(Object.entries(aliases)); const authorities = new Map(); @@ -468,6 +495,7 @@ export function relayBrokerPlugin({ let sidebarUploads = 0; let attachmentUploads = 0; let libraryRead; + const sidebarMutations = new Map(); const streams = new Map(); const admissions = createHostAdmission(); const builderlab = createBuilderlab({ @@ -710,6 +738,126 @@ export function relayBrokerPlugin({ sidebarUploads--; } } + if (route === "/api/relay/sidebar-mute" && req.method === "POST") { + let raw = ""; + for await (const part of req) { + raw += part; + if (Buffer.byteLength(raw) > 2048) + return json(res, 413, { + error: `Sidebar preference intent is too large`, + }); + } + let intent; + try { + intent = JSON.parse(raw); + assertSidebarMuteIntent(intent); + } catch { + return json(res, 400, { + error: `Invalid sidebar preference intent`, + }); + } + const stream = streams.get(req.headers["x-buzz-live-id"]); + if (!stream || stream.relay !== relay) + return json(res, 503, { + error: "Publication socket unavailable", + sent: false, + }); + const request = new AbortController(); + const close = () => request.abort(); + res.once("close", close); + const previous = sidebarMutations.get(relay) ?? Promise.resolve(); + const mutation = previous + .catch(() => {}) + .then(async () => { + request.signal.throwIfAborted(); + const filter = [ + { + kinds: [30078], + authors: [viewer], + "#d": ["channel-mutes"], + limit: 1, + }, + ]; + const lane = admissions(relay, viewer).api; + const requestSignal = AbortSignal.any([ + request.signal, + AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + ]); + const dispatch = (path, body) => + admittedApiRequest( + lane, + () => { + requestSignal.throwIfAborted(); + const value = JSON.stringify(body); + const auth = finalizeEvent( + { + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + content: "", + tags: [ + ["u", `${relay}${path}`], + ["method", "POST"], + [ + "payload", + createHash("sha256").update(value).digest("hex"), + ], + ["nonce", randomBytes(16).toString("hex")], + ], + }, + key, + ); + return fetchUpstream(`${relay}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: + "Nostr " + + Buffer.from(JSON.stringify(auth)).toString( + "base64", + ), + }, + body: value, + redirect: "error", + signal: requestSignal, + }); + }, + requestSignal, + ); + const readHead = async () => { + const response = await dispatch("/query", filter); + if (!response.ok) + throw new Error( + `Sidebar preference query failed (${response.status})`, + ); + return readSidebarHead(response); + }; + const publishEvent = (event) => + stream.traffic.publish(event, requestSignal); + return mutateSidebarMute(intent, key, readHead, publishEvent); + }); + sidebarMutations.set(relay, mutation); + try { + return json(res, 200, await mutation); + } catch (error) { + if (error instanceof ApiPaused) + return json(res, 429, { + error: error.message, + sent: false, + paused: true, + retryAfterMs: error.retryAfterMs, + }); + return json(res, 502, { + error: + error instanceof Error + ? error.message + : `Sidebar preference failed`, + }); + } finally { + res.off("close", close); + if (sidebarMutations.get(relay) === mutation) + sidebarMutations.delete(relay); + } + } if (route === "/api/relay/agent-library" && req.method === "GET") { try { // Share concurrent reads, never retain the local snapshot after completion. @@ -746,6 +894,7 @@ export function relayBrokerPlugin({ projectGit: true, attachmentUploads: true, sidebarPreferences: true, + sidebarMuteWrites: true, channelKit: true, readState: true, agentLibrary: true, diff --git a/dev/sidebar-mutes-broker.test.mjs b/dev/sidebar-mutes-broker.test.mjs new file mode 100644 index 000000000..f5d1b3f22 --- /dev/null +++ b/dev/sidebar-mutes-broker.test.mjs @@ -0,0 +1,218 @@ +import { createServer } from "node:http"; +import { createHash } from "node:crypto"; +import { afterEach, expect, it } from "vitest"; +import { brokerSocket, openBrokerSocket } from "../tests/broker-socket.mjs"; +import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools"; +import { relayBrokerPlugin } from "./relay-broker.mjs"; +import { prepareSidebarMute } from "./sidebar-mutes.mjs"; +import { connectBrokerTransport } from "../src/features/relay/transport.ts"; +import { fixtureRelayUrl, fixtureAliases } from "../tests/relay-config.ts"; + +const disposals = []; +afterEach(async () => { + for (const dispose of disposals.splice(0)) await dispose(); +}); +async function harness({ connected = true } = {}) { + const key = generateSecretKey(), + viewer = getPublicKey(key); + let handler, queryFailure, publicationFailure, live; + let activeSocket; + let conflict = false; + const heads = new Map(), + calls = []; + const socket = brokerSocket((event) => { + expect(verifyEvent(event)).toBe(true); + expect(event.pubkey).toBe(viewer); + calls.push({ url: "socket:EVENT", body: event }); + if (publicationFailure === "disconnect") activeSocket.close(); + if (!publicationFailure && !conflict) + heads.set(event.tags.find(([name]) => name === "d")[1], event); + return ""; + }); + const server = createServer((req, res) => { + req.headers.origin ??= `http://${req.headers.host}`; + handler(req, res); + }); + await relayBrokerPlugin({ + relayUrl: fixtureRelayUrl, + communityAliases: fixtureAliases, + identity: () => key, + authority: async () => ({ relayAuthor: viewer }), + socketFactory: () => { + activeSocket = socket.factory(); + const rawSend = activeSocket.send; + activeSocket.send = (text) => { + const [kind, event] = JSON.parse(text); + if (kind === "EVENT" && publicationFailure === "rejection") { + calls.push({ url: "socket:EVENT", body: event }); + queueMicrotask(() => + activeSocket.onmessage?.({ + data: JSON.stringify(["OK", event.id, false, "blocked: fixture"]), + }), + ); + } else rawSend(text); + }; + return activeSocket; + }, + upstreamFetch: async (url, init) => { + if (!init?.body) return Response.json({ self: viewer }); + const body = JSON.parse(init.body); + const auth = JSON.parse( + Buffer.from(init.headers.Authorization.slice(6), "base64").toString(), + ); + expect(verifyEvent(auth)).toBe(true); + expect(auth.pubkey).toBe(viewer); + expect(auth.tags).toContainEqual(["u", String(url)]); + expect(auth.tags).toContainEqual(["method", "POST"]); + expect(auth.tags).toContainEqual([ + "payload", + createHash("sha256").update(init.body).digest("hex"), + ]); + expect(init.redirect).toBe("error"); + calls.push({ url: String(url), body }); + expect(new URL(url).pathname).toBe("/query"); + if (queryFailure) return queryFailure; + const head = heads.get(body[0]["#d"][0]); + return Response.json(head ? [head] : []); + }, + }).configureServer({ + httpServer: server, + config: { logger: { info() {}, error() {} } }, + middlewares: { + use(callback) { + handler = callback; + }, + }, + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + disposals.push(async () => { + live?.dispose(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }); + const base = `http://127.0.0.1:${server.address().port}`; + const transport = await connectBrokerTransport(base); + if (connected) live = await openBrokerSocket(transport); + return { + disconnect() { + live.dispose(); + }, + liveId: () => live?.identity(), + key, + viewer, + transport, + calls, + heads, + failQuery(value) { + queryFailure = value; + }, + failPublication(value) { + publicationFailure = value; + }, + conflict() { + conflict = true; + }, + post(value, origin, route = "sidebar-mute", liveId = live?.identity()) { + return fetch(`${base}/api/relay/${route}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(liveId ? { "X-Buzz-Live-ID": liveId } : {}), + ...(origin ? { Origin: origin } : {}), + }, + body: JSON.stringify(value), + }); + }, + }; +} +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", + "EVENT", + "/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 === "socket:EVENT")).toHaveLength(2); + await h.transport.writeSidebarMute( + { channelId: "alpha", muted: false }, + signal, + ); + expect(h.calls.filter((call) => call.url === "socket:EVENT")).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", "rejection", "disconnect", "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 (["rejection", "disconnect"].includes(failure)) + h.failPublication(failure); + 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 === "socket:EVENT")).toEqual([]); + }, +); + +it("refuses missing, stale and cross-community live owners without reads or writes", async () => { + const h = await harness({ connected: false }); + const intent = { channelId: "alpha", muted: true }; + await expect( + h.transport.writeSidebarMute(intent, new AbortController().signal), + ).rejects.toThrow(); + expect(h.calls).toEqual([]); + const ready = await harness(); + for (const [route, id] of [ + ["sidebar-mute", "f".repeat(32)], + ["secondary/sidebar-mute", ready.liveId()], + ]) { + const response = await ready.post(intent, undefined, route, id); + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ sent: false }); + } + ready.disconnect(); + await expect( + ready.transport.writeSidebarMute(intent, new AbortController().signal), + ).rejects.toThrow(); + expect(ready.calls).toEqual([]); +}); diff --git a/dev/sidebar-mutes.mjs b/dev/sidebar-mutes.mjs new file mode 100644 index 000000000..a31492219 --- /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 000000000..149f0bd71 --- /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-preferences.mjs b/dev/sidebar-preferences.mjs index 86e334bf1..483118a43 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 5a4f58df9..240e4bd93 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -70,7 +70,7 @@ connection generation. This resets their session-owned state on switching or reconnecting, not unrelated page drafts; 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 @@ -79,6 +79,39 @@ 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 one narrow **Mute/Unmute** command. It +re-reads the viewer's signed encrypted `channel-mutes` coordinate, changes only the +requested entry, publishes through existing relay admission, and confirms via +readback. Publication uses the existing authenticated live socket, scoped to the +requesting session/community; a missing or disconnected owner fails without HTTP +fallback or automatic replay. Unrelated fields and explicit unmute tombstones +survive. Invalid, unreadable, or over-budget heads fail closed; only a successful absent-head read +can seed a record. Same-host writes serialize per relay. This is confirmed +whole-record replacement, not atomic cross-device merging or a durable outbox; +simultaneous writers on different hosts can still race. Failure requires explicit +retry. No group/star mutation, sorting, or alternate menu implementation is included. + +Rows expose mute/read actions through right-click/long-press, Shift+F10, or the +Context Menu key. They extend the persistent sidebar’s existing menu after +**New session**, separated from session entry; DM removal stays separate. +Mute closes immediately and optimistically changes the next menu action, not +unread truth or notification policy before confirmation. Failure rolls back to +confirmed state and shows an app notification with Retry (same intent) and Dismiss. +Newer clicks supersede older completion UI; session-owned writes and sidebar +pending/error presentation survive page switches. Session replacement discards +that presentation. Cache clear/disposal abort +pending work but cannot retract an accepted relay publication. + +Mark as Read delegates to the [durable unread owner](unread.md), without selecting +the row, and closes after the local transaction commits. Observed unread or a +manual mark offers **Mark as Read**; otherwise the menu offers **Mark as Unread** +with its device-only tooltip. An open menu subscribes to the shared projection, +without fetching history or inventing exact counts. Read errors remain in-menu +for explicit retry. Focus resolves the current row by identity even if saved +preferences relocated it during the transaction. Read actions require +`frontier-sync`; hosts lacking mute writes keep read-only preference projection. +Packaged hosts gain no speculative native preference writer. + Collapsed section keys and sidebar scroll remain separate, scoped view intent. They survive page switches in the same mounted sidebar, are saved when that sidebar exits its session, and restore before paint when the roster and groups diff --git a/docs/notifications.md b/docs/notifications.md index c1afd1ddb..c862dc443 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -41,6 +41,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 5a38baf8c..eacf7eed1 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`. @@ -113,6 +121,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/ChannelReadMenuItem.test.tsx b/src/bundled/channels/ChannelReadMenuItem.test.tsx new file mode 100644 index 000000000..78fbf2af4 --- /dev/null +++ b/src/bundled/channels/ChannelReadMenuItem.test.tsx @@ -0,0 +1,141 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { StrictMode } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import type { + UnreadCapability, + UnreadSnapshot, +} from "../../features/relay/unread"; +import { + MenuRoot, + MenuPopup, + MenuTrigger, +} from "../../shared/design-system/ui/Menu"; +import { ChannelReadMenuItem } from "./ChannelReadMenuItem"; + +afterEach(cleanup); +function setup() { + const listeners = new Map void>>(); + const snapshots = new Map(); + const set = (channelId: string, patch: Partial = {}) => { + snapshots.set(channelId, { + target: { kind: "channel", channelId }, + observedCount: 0, + attentionCount: 0, + manual: "none", + coverage: "observed", + freshness: "observed", + ...patch, + }); + for (const listener of listeners.get(channelId) ?? []) listener(); + }; + set("room"); + set("other", { observedCount: 2 }); + const saved = { + operationId: "saved", + durability: "saved", + sync: "local-only", + } as const; + const unread = { + snapshot: (target) => { + const snapshot = snapshots.get(target.channelId); + if (!snapshot) throw new Error("Missing test snapshot"); + return snapshot; + }, + subscribe(target, listener) { + const owned = listeners.get(target.channelId) ?? new Set(); + listeners.set(target.channelId, owned); + owned.add(listener); + return () => { + owned.delete(listener); + }; + }, + markChannelRead: vi.fn(async () => saved), + markUnreadLocal: vi.fn(async () => saved), + } satisfies Pick< + UnreadCapability, + "snapshot" | "subscribe" | "markChannelRead" | "markUnreadLocal" + >; + const run = vi.fn(async (action: () => Promise) => { + await action(); + }); + const menu = (channelId = "room", pending = false, open = true) => ( + + + Channel actions + + + + + + ); + return { unread, run, menu, set, listeners }; +} + +it.each([ + { observedCount: 3, manual: "none", name: "Mark as Read" }, + { observedCount: 0, manual: "local-only", name: "Mark as Read" }, + { observedCount: 0, manual: "remote", name: "Mark as Read" }, + { observedCount: 0, manual: "none", name: "Mark as Unread" }, + { observedCount: null, manual: "none", name: "Mark as Unread" }, +] as const)( + "offers only $name for count=$observedCount/manual=$manual", + async ({ name, ...snapshot }) => { + const h = setup(); + h.set("room", snapshot); + render(h.menu()); + const item = await screen.findByRole("menuitem", { name }); + expect(screen.getAllByRole("menuitem")).toHaveLength(1); + expect(item.querySelector(".buzz-menu-icon")).toHaveAttribute( + "aria-hidden", + "true", + ); + expect(item.querySelector("svg")).toHaveAttribute("aria-hidden", "true"); + await userEvent.click(item); + if (name === "Mark as Read") { + expect(h.unread.markChannelRead).toHaveBeenCalledExactlyOnceWith("room"); + expect(h.unread.markUnreadLocal).not.toHaveBeenCalled(); + } else { + expect(item).toHaveAttribute("title", "Mark unread on this device only"); + expect(h.unread.markUnreadLocal).toHaveBeenCalledExactlyOnceWith({ + kind: "channel", + channelId: "room", + }); + expect(h.unread.markChannelRead).not.toHaveBeenCalled(); + } + }, +); + +it("updates an open menu from the domain snapshot, disables pending work and releases retargeted subscriptions", async () => { + const h = setup(); + const view = render(h.menu()); + const initial = await screen.findByRole("menuitem", { + name: "Mark as Unread", + }); + await waitFor(() => expect(initial).toBeVisible()); + act(() => h.set("room", { observedCount: 1 })); + expect(screen.getByRole("menuitem", { name: "Mark as Read" })).toBeVisible(); + act(() => h.set("room")); + expect( + screen.getByRole("menuitem", { name: "Mark as Unread" }), + ).toBeVisible(); + view.rerender(h.menu("room", true)); + const disabled = screen.getByRole("menuitem", { name: "Mark as Unread" }); + expect(disabled).toHaveAttribute("aria-disabled", "true"); + await userEvent.click(disabled); + expect(h.run).not.toHaveBeenCalled(); + view.rerender(h.menu("other")); + expect(h.listeners.get("room")?.size).toBe(0); + await userEvent.click(screen.getByRole("menuitem", { name: "Mark as Read" })); + expect(h.unread.markChannelRead).toHaveBeenCalledExactlyOnceWith("other"); + view.rerender(h.menu("other", false, false)); + await waitFor(() => expect(h.listeners.get("other")?.size).toBe(0)); + view.unmount(); +}); diff --git a/src/bundled/channels/ChannelReadMenuItem.tsx b/src/bundled/channels/ChannelReadMenuItem.tsx new file mode 100644 index 000000000..0a5597bb6 --- /dev/null +++ b/src/bundled/channels/ChannelReadMenuItem.tsx @@ -0,0 +1,59 @@ +import { useCallback, useMemo, useSyncExternalStore } from "react"; +import type { UnreadCapability } from "../../features/relay/unread"; +import { + EnvelopeIcon, + EnvelopeOpenIcon, +} from "../../shared/design-system/icons"; +import { MenuIcon, MenuItem } from "../../shared/design-system/ui/Menu"; + +/** The menu portal mounts this subscription only while its popup is mounted. */ +export function ChannelReadMenuItem({ + unread, + channelId, + pending, + run, +}: { + unread: Pick< + UnreadCapability, + "snapshot" | "subscribe" | "markChannelRead" | "markUnreadLocal" + >; + channelId: string; + pending: boolean; + run: (action: () => Promise) => Promise; +}) { + const target = useMemo( + () => ({ kind: "channel" as const, channelId }), + [channelId], + ); + const subscribe = useCallback( + (listener: () => void) => unread.subscribe(target, listener), + [unread, target], + ); + const get = useCallback(() => unread.snapshot(target), [unread, target]); + const snapshot = useSyncExternalStore(subscribe, get, get); + const hasUnread = + snapshot.manual !== "none" || (snapshot.observedCount ?? 0) > 0; + return ( + + void run(() => + hasUnread + ? unread.markChannelRead(channelId) + : unread.markUnreadLocal(target), + ) + } + > + + {hasUnread ? ( + + ) : ( + + )} + + {hasUnread ? "Mark as Read" : "Mark as Unread"} + + ); +} diff --git a/src/bundled/channels/sidebar-sections.test.ts b/src/bundled/channels/sidebar-sections.test.ts index 62fe1c6ab..b233751ef 100644 --- a/src/bundled/channels/sidebar-sections.test.ts +++ b/src/bundled/channels/sidebar-sections.test.ts @@ -34,6 +34,7 @@ it("intersects groups/stars with active authorized streams, keeping forums and D "group-dm": "channels", other: "missing", }, + muted: [], starred: [ "star", "archived", diff --git a/src/bundled/channels/useChannelRowMenu.test.tsx b/src/bundled/channels/useChannelRowMenu.test.tsx index a105acd3f..711f4f15b 100644 --- a/src/bundled/channels/useChannelRowMenu.test.tsx +++ b/src/bundled/channels/useChannelRowMenu.test.tsx @@ -27,6 +27,7 @@ const sections = ( sections: groups, assignments: placement === "group:work" ? { alpha: "work" } : {}, starred: placement === "starred" ? ["alpha"] : [], + muted: [], }); const initial = { sections: sections("group:work"), diff --git a/src/bundled/channels/useOptimisticMute.test.tsx b/src/bundled/channels/useOptimisticMute.test.tsx new file mode 100644 index 000000000..7f29de67f --- /dev/null +++ b/src/bundled/channels/useOptimisticMute.test.tsx @@ -0,0 +1,140 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from "@testing-library/react"; +import { StrictMode, type ReactNode } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import { useOptimisticMute } from "./useOptimisticMute"; + +afterEach(cleanup); +function deferred() { + let resolve!: (value: readonly string[]) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +it("projects immediately, writes once in StrictMode and drops the overlay after confirmation", async () => { + const save = deferred(); + const write = vi.fn(() => save.promise); + const { result } = renderHook(() => useOptimisticMute(write), { wrapper }); + act(() => result.current.change("room", "Room", true)); + expect(result.current.intents.get("room")).toMatchObject({ + muted: true, + pending: true, + }); + expect(write).toHaveBeenCalledExactlyOnceWith("room", true); + await act(async () => { + save.resolve(["room"]); + }); + expect(result.current.intents.size).toBe(0); +}); + +it("rolls back a failed unmute and retries the explicit intent rather than toggling confirmed state", async () => { + const failed = deferred(); + const retry = deferred(); + const write = vi + .fn() + .mockReturnValueOnce(failed.promise) + .mockReturnValueOnce(retry.promise); + const { result } = renderHook(() => useOptimisticMute(write), { wrapper }); + act(() => result.current.change("room", "Room", false)); + await act(async () => { + failed.reject(new Error("offline")); + }); + expect(result.current.intents.get("room")).toMatchObject({ + muted: false, + pending: false, + error: "offline", + }); + act(() => result.current.change("room", "Room", false)); + expect(result.current.intents.get("room")).toMatchObject({ + muted: false, + pending: true, + }); + expect(write).toHaveBeenLastCalledWith("room", false); + await act(async () => { + retry.resolve([]); + }); + expect(result.current.intents.size).toBe(0); +}); + +it.each(["success", "failure"])( + "ignores an older %s during a rapid reversal and retains other channels", + async (outcome) => { + const first = deferred(); + const latest = deferred(); + const other = deferred(); + const write = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(latest.promise) + .mockReturnValueOnce(other.promise); + const { result } = renderHook(() => useOptimisticMute(write), { wrapper }); + act(() => { + result.current.change("room", "Room", true); + result.current.change("room", "Room", false); + result.current.change("other", "Other", true); + }); + await act(async () => { + if (outcome === "success") first.resolve(["room"]); + else first.reject(new Error("older failure")); + }); + expect(result.current.intents.get("room")).toMatchObject({ + muted: false, + pending: true, + }); + await act(async () => { + latest.reject(new Error("latest failure")); + }); + act(() => result.current.dismiss("room")); + expect(result.current.intents.has("room")).toBe(false); + expect(result.current.intents.get("other")?.pending).toBe(true); + await act(async () => { + other.resolve(["other"]); + }); + expect(result.current.intents.size).toBe(0); + }, +); + +it("leaves saving with the session and ignores a retired view’s late result", async () => { + const oldSave = deferred(); + const newSave = deferred(); + const oldWrite = vi.fn(() => oldSave.promise); + const newWrite = vi.fn(() => newSave.promise); + const { result, rerender, unmount } = renderHook( + ({ write }) => useOptimisticMute(write), + { initialProps: { write: oldWrite }, wrapper }, + ); + act(() => result.current.change("room", "Room", true)); + rerender({ write: newWrite }); + expect(result.current.intents.size).toBe(0); + act(() => result.current.change("room", "Room", false)); + await act(async () => { + oldSave.reject(new Error("retired")); + }); + expect(result.current.intents.get("room")).toMatchObject({ + muted: false, + pending: true, + }); + unmount(); + await act(async () => { + newSave.resolve([]); + }); +}); + +it("retains a synchronous host failure for retry", () => { + const write = vi.fn(() => { + throw new Error("unavailable"); + }); + const { result } = renderHook(() => useOptimisticMute(write), { wrapper }); + act(() => result.current.change("room", "Room", true)); + expect(result.current.intents.get("room")).toMatchObject({ + pending: false, + error: "unavailable", + }); +}); diff --git a/src/bundled/channels/useOptimisticMute.ts b/src/bundled/channels/useOptimisticMute.ts new file mode 100644 index 000000000..e05371099 --- /dev/null +++ b/src/bundled/channels/useOptimisticMute.ts @@ -0,0 +1,72 @@ +import { useEffect, useRef, useState } from "react"; +import type { RelaySession } from "../../features/relay/session"; + +type MuteIntent = Readonly<{ + channelId: string; + name: string; + muted: boolean; + pending: boolean; + error?: string; +}>; + +/** Presentation only: notifications continue to use confirmed session preferences. */ +export function useOptimisticMute( + write: RelaySession["sidebarPreferences"]["setMute"], +) { + const [intents, setIntents] = useState>( + new Map(), + ); + const lifetime = useRef<{ + live: boolean; + write: typeof write; + } | null>(null); + useEffect(() => { + const view = { live: true, write }; + lifetime.current = view; + setIntents(new Map()); + return () => { + view.live = false; + lifetime.current = null; + }; + }, [write]); + + function change(channelId: string, name: string, muted: boolean) { + const active = lifetime.current; + if (!active) return; + const intent: MuteIntent = { channelId, name, muted, pending: true }; + setIntents((current) => new Map(current).set(channelId, intent)); + const finish = (error?: string) => { + if (!active.live) return; + setIntents((current) => { + // An older completion must not remove or roll back a newer click. + if (current.get(channelId) !== intent) return current; + const next = new Map(current); + if (error !== undefined) + next.set(channelId, { ...intent, pending: false, error }); + else next.delete(channelId); + return next; + }); + }; + void (async () => { + try { + // The session owns saving; leaving Messages must not cancel the intent. + await active.write(channelId, muted); + finish(); + } catch (error) { + finish(error instanceof Error ? error.message : String(error)); + } + })(); + } + return { + intents, + change, + dismiss(channelId: string) { + setIntents((current) => { + if (current.get(channelId)?.pending) return current; + const next = new Map(current); + next.delete(channelId); + return next; + }); + }, + }; +} diff --git a/src/features/channel-navigation/ChannelSidebar.tsx b/src/features/channel-navigation/ChannelSidebar.tsx index 968c64604..7b8224557 100644 --- a/src/features/channel-navigation/ChannelSidebar.tsx +++ b/src/features/channel-navigation/ChannelSidebar.tsx @@ -2,6 +2,7 @@ import { Component, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -20,16 +21,22 @@ import { Button } from "../../shared/design-system/ui/Button"; import { ContextMenuRoot, MenuItem, + MenuIcon, MenuPopup, + MenuSeparator, } from "../../shared/design-system/ui/Menu"; import type { ChannelSummary } from "../relay/contracts"; import { useChannelRowMenu } from "../../bundled/channels/useChannelRowMenu"; import { IconButton } from "../../shared/design-system/ui/IconButton"; import { ToastNotice } from "../../shared/design-system/ui/Toast"; import { + BellIcon, + BellSlashIcon, CaretRightIcon, PlusIcon, } from "../../shared/design-system/icons/index"; +import { ChannelReadMenuItem } from "../../bundled/channels/ChannelReadMenuItem"; +import { useOptimisticMute } from "../../bundled/channels/useOptimisticMute"; import { ChannelSidebarItem } from "../../bundled/channels/ChannelSidebarItem"; import { SidebarUnread } from "../../bundled/channels/SidebarUnread"; import { SidebarSectionIcon } from "../../bundled/channels/SidebarSectionIcon"; @@ -137,6 +144,13 @@ function ReadySidebar({ }) { const list = useChannelList(queries.channels); const preferences = useSidebarPreferences(queries.sidebarPreferences); + const mute = useOptimisticMute(queries.sidebarPreferences.setMute); + const rowMenuGeneration = useRef(0); + const [readWrite, setReadWrite] = useState<{ + pending: boolean; + error?: string; + }>(); + const [rowFocus, setRowFocus] = useState(); const kitState = useSyncExternalStore( queries.channelKit.subscribe, queries.channelKit.snapshot, @@ -315,6 +329,7 @@ function ReadySidebar({ })), assignments: groups.assignments, starred: preferences.data?.starred ?? [], + muted: preferences.data?.muted ?? [], } : preferences.data, hiddenDms.hiddenIds, @@ -341,20 +356,95 @@ function ReadySidebar({ , ); } + const muteable = + queries.sidebarPreferences.muteWritable && !!preferences.data; + const readable = queries.unread.sync().capability === "frontier-sync"; + if (actions.length && (muteable || readable)) + actions.push(); + if (muteable) { + const intent = mute.intents.get(channel.id); + const muted = intent?.pending + ? intent.muted + : (preferences.data?.muted.includes(channel.id) ?? false); + actions.push( + changeMute(channel.id, channel.name, !muted)} + > + + {muted ? : } + + {muted ? "Unmute" : "Mute"} + , + ); + } + if (readable) + actions.push( + runReadAction(channel.id, action)} + />, + ); return actions; }; const { rowMenu, open: openMenu, - close: closeRowMenu, + close: closeMenu, } = useChannelRowMenu(sections, rowActions); const openRowMenu = useCallback( (channel: ChannelSummary, sectionKey: string, anchor?: HTMLElement) => { startingSession.current = false; + rowMenuGeneration.current++; + setReadWrite(undefined); openMenu(channel, sectionKey, anchor); }, [openMenu], ); + const closeRowMenu = useCallback(() => { + rowMenuGeneration.current++; + setReadWrite(undefined); + closeMenu(); + }, [closeMenu]); + useLayoutEffect(() => { + if (!rowFocus) return; + sidebar.list.current + ?.querySelector( + `[data-channel-id="${CSS.escape(rowFocus)}"]`, + ) + ?.focus({ preventScroll: true }); + setRowFocus(undefined); + }, [rowFocus, sidebar.list]); + const runReadAction = async ( + channelId: string, + action: () => Promise, + ) => { + const generation = rowMenuGeneration.current; + setReadWrite({ pending: true }); + try { + await action(); + if (!mounted.current || generation !== rowMenuGeneration.current) return; + // Startup can move the row; resolve its current owner after the commit. + setRowFocus(channelId); + closeRowMenu(); + } catch (error) { + if (!mounted.current || generation !== rowMenuGeneration.current) return; + setReadWrite({ + pending: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + const changeMute = (channelId: string, name: string, muted: boolean) => { + mute.change(channelId, name, muted); + setRowFocus(channelId); + closeRowMenu(); + }; return ( <>
@@ -577,6 +667,10 @@ function ReadySidebar({ } > {actions} + {readWrite?.pending &&

Saving…

} + {readWrite?.error && ( +

{readWrite.error}

+ )} ); @@ -596,9 +690,39 @@ function ReadySidebar({

No channels yet.

)} + {[...mute.intents.values()] + .filter((intent) => !intent.pending) + .map((intent) => ( + + + + + ))} {preferences.status === "error" ? ( diff --git a/src/features/notifications/messages.test.ts b/src/features/notifications/messages.test.ts index cf8619411..f46f949ff 100644 --- a/src/features/notifications/messages.test.ts +++ b/src/features/notifications/messages.test.ts @@ -4,6 +4,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"; @@ -42,6 +46,7 @@ async function setup( channelsMounted?: boolean; deferRoster?: boolean; }, + sidebar?: { decode: SidebarDecoder; write?: SidebarMuteMutator }, ) { const viewer = keypair(), peer = keypair(), @@ -108,6 +113,12 @@ async function setup( : {}), } : {}), + ...(sidebar + ? { + decodeSidebarPreferences: sidebar.decode, + ...(sidebar.write ? { writeSidebarMute: sidebar.write } : {}), + } + : {}), media: () => undefined, subscribe(value) { callbacks = value; @@ -756,3 +767,136 @@ it("scopes notification author collisions to the message channel", async () => { await vi.waitFor(() => expect(h.show).toHaveBeenCalledTimes(2)); expect(h.show.mock.calls[1]?.[0].title).toContain("Pinky · "); }); + +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 2f3c0ddb2..bce8a2a1a 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 && @@ -158,6 +172,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. @@ -183,5 +204,6 @@ export function bindMessageNotifications( stopIncoming(); stopAccess(); stopSync(); + stopPreferences(); }; } diff --git a/src/features/relay/live-restriction.test.ts b/src/features/relay/live-restriction.test.ts index 9ae29a8f0..2d863637a 100644 --- a/src/features/relay/live-restriction.test.ts +++ b/src/features/relay/live-restriction.test.ts @@ -75,6 +75,7 @@ async function setup() { const rows = () => sidebarSections(owner.session.channels.list().channels, { starred: ["a"], + muted: [], sections: [], assignments: {}, }).flatMap((section) => section.rows.map((channel) => channel.id)); diff --git a/src/features/relay/read-state.ts b/src/features/relay/read-state.ts index 90596e643..036d61153 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 ac1aa9ec1..b49a91773 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -843,6 +843,20 @@ export function createRelaySession( }, !!transport?.decodeSidebarPreferences, notify, + (() => { + const write = transport?.writeSidebarMute; + return write + ? (intent, signal) => + write( + intent, + AbortSignal.any([ + lifetime.signal, + AbortSignal.timeout(20_000), + signal, + ]), + ) + : undefined; + })(), ); const workSessions = createWorkSessions( writes?.outbox, diff --git a/src/features/relay/sidebar-preferences-store.test.ts b/src/features/relay/sidebar-preferences-store.test.ts index 0aedbb893..267773e1b 100644 --- a/src/features/relay/sidebar-preferences-store.test.ts +++ b/src/features/relay/sidebar-preferences-store.test.ts @@ -1,18 +1,26 @@ import { expect, it, vi } from "vitest"; import { createRelaySession } from "./session"; import { flush, keypair, scriptedTransport } from "./testing"; -import type { SidebarPreferences } from "./sidebar-preferences"; +import type { + SidebarPreferences, + SidebarMuteMutator, +} from "./sidebar-preferences"; const data: SidebarPreferences = { sections: [{ id: "work", name: "Work", order: 0 }], assignments: { alpha: "work" }, starred: ["beta"], + muted: [], }; -function setup(decode = vi.fn(async (): Promise => data)) { +function setup( + decode = vi.fn(async (): Promise => data), + writeSidebarMute?: SidebarMuteMutator, +) { const wire = scriptedTransport(keypair().pubkey, keypair().pubkey); const owner = createRelaySession({ ...wire.transport, decodeSidebarPreferences: decode, + ...(writeSidebarMute ? { writeSidebarMute } : {}), }); return { wire, owner, preferences: owner.session.sidebarPreferences, decode }; } @@ -164,3 +172,167 @@ it.each(["clearCache", "dispose"] as const)( } }, ); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +it("serializes mute writes without changing saved groups or stars", async () => { + const gate = deferred(); + const started = deferred(); + const mute = vi + .fn() + .mockImplementationOnce(async () => { + started.resolve(); + return gate.promise; + }) + .mockResolvedValueOnce(["alpha", "beta"]); + const { wire, owner, preferences } = setup(undefined, mute); + try { + const initial = preferences.ensure(); + await flush(); + wire.next().respond([]); + await initial; + const first = preferences.setMute("alpha", true); + await started.promise; + const second = preferences.setMute("beta", true); + expect(mute).toHaveBeenCalledOnce(); + expect(preferences.snapshot().data).toEqual(data); + gate.resolve(["alpha"]); + await Promise.all([first, second]); + expect(preferences.snapshot()).toEqual({ + status: "ready", + data: { ...data, muted: ["alpha", "beta"] }, + }); + expect(Object.isFrozen(preferences.snapshot().data?.muted)).toBe(true); + } 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, 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, 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, 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 { owner, preferences } = setup(undefined, mute); + try { + await expect(preferences.setMute("alpha", true)).rejects.toThrow( + "unavailable", + ); + expect(mute).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 5f4aad644..6058d062e 100644 --- a/src/features/relay/sidebar-preferences-store.ts +++ b/src/features/relay/sidebar-preferences-store.ts @@ -1,4 +1,7 @@ -import type { SidebarPreferences } from "./sidebar-preferences"; +import type { + SidebarPreferences, + SidebarMuteMutator, +} from "./sidebar-preferences"; type Snapshot = Readonly<{ status: "idle" | "loading" | "ready" | "error" | "unsupported"; @@ -11,6 +14,7 @@ export function createSidebarPreferencesStore( read: (signal?: AbortSignal) => Promise, available: boolean, notify = (listener: () => void) => listener(), + writeMute?: SidebarMuteMutator, ) { const listeners = new Set<() => void>(); const empty = (): Snapshot => @@ -20,6 +24,10 @@ export function createSidebarPreferencesStore( let active: | { controller: AbortController; promise: Promise } | undefined; + let writeQueue = Promise.resolve(); + let writeLifetime = new AbortController(); + let mutation = 0; + let generation = 0; const publish = (next: Snapshot) => { snapshot = Object.freeze(next); for (const listener of listeners) notify(listener); @@ -28,13 +36,20 @@ export function createSidebarPreferencesStore( if (closed || !available) return Promise.resolve(); if (active) return active.promise; const controller = new AbortController(); + const refreshMutation = mutation; const job = { controller, promise: Promise.resolve() }; active = job; job.promise = Promise.resolve().then(async () => { if (closed || controller.signal.aborted) return; try { const data = await read(controller.signal); - if (closed || controller.signal.aborted || active !== job) return; + if ( + closed || + controller.signal.aborted || + active !== job || + mutation !== refreshMutation + ) + return; publish({ status: "ready", data: Object.freeze({ @@ -43,10 +58,16 @@ export function createSidebarPreferencesStore( ), assignments: Object.freeze({ ...data.assignments }), starred: Object.freeze([...data.starred]), + muted: Object.freeze([...data.muted]), }), }); } catch (error) { - if (!closed && !controller.signal.aborted && active === job) + if ( + !closed && + !controller.signal.aborted && + active === job && + mutation === refreshMutation + ) publish({ ...snapshot, status: "error", @@ -65,6 +86,46 @@ export function createSidebarPreferencesStore( return { queries: Object.freeze({ available, + 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: Object.freeze({ + ...current, + muted: Object.freeze([...mutes]), + }), + }); + return mutes; + }); + writeQueue = run.then( + () => undefined, + () => undefined, + ); + return run; + }, + // Keep explicit one-shot reads compatible; views use the retained snapshot. read, snapshot: () => snapshot, @@ -83,12 +144,20 @@ export function createSidebarPreferencesStore( }), clear() { if (closed) return; + generation++; + mutation++; + writeLifetime.abort(); + writeLifetime = new AbortController(); active?.controller.abort(); active = undefined; publish(empty()); }, dispose() { closed = true; + generation++; + mutation++; + writeLifetime.abort(); + writeLifetime = new AbortController(); active?.controller.abort(); active = undefined; snapshot = empty(); diff --git a/src/features/relay/sidebar-preferences.test.ts b/src/features/relay/sidebar-preferences.test.ts index dec5f2c13..92089ea72 100644 --- a/src/features/relay/sidebar-preferences.test.ts +++ b/src/features/relay/sidebar-preferences.test.ts @@ -35,6 +35,7 @@ const expected = { sections: groups.sections, assignments: { general: "work" }, starred: ["general"], + muted: [], }; it("reads legacy preferences through the production session, transport, and bounded broker decoder without publishing", async () => { @@ -70,6 +71,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); }); @@ -215,6 +222,7 @@ it("reads legacy preferences through the production session, transport, and boun sections: [], assignments: {}, starred: [], + muted: [], }); upstream.mockImplementationOnce(async () => Response.json({ error: "unavailable" }, { status: 503 }), diff --git a/src/features/relay/sidebar-preferences.ts b/src/features/relay/sidebar-preferences.ts index 1214d652d..7d3ee7f54 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 SidebarPreferences = Readonly<{ sections: readonly Readonly<{ @@ -14,7 +15,12 @@ export type SidebarPreferences = Readonly<{ }>[]; assignments: Readonly>; starred: readonly string[]; + muted: readonly string[]; }>; +export type SidebarMuteMutator = ( + intent: Readonly<{ channelId: string; muted: boolean }>, + signal: AbortSignal, +) => Promise; export type SidebarDecoder = ( events: readonly RelayEvent[], signal: AbortSignal, @@ -34,15 +40,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); @@ -99,6 +108,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 9fe70cdff..e7e8c6209 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -17,7 +17,12 @@ import { readSnapshotText, } from "./read-state-snapshot"; import type { AgentLibraryReader } from "../agents/library"; -import type { SidebarDecoder, SidebarPreferences } from "./sidebar-preferences"; +import { + projectSidebarPreferences, + type SidebarMuteMutator, + type SidebarDecoder, + type SidebarPreferences, +} from "./sidebar-preferences"; import { createHostAdmission } from "./host-admission"; import { relayOrigin } from "../communities/destination"; import { @@ -89,6 +94,7 @@ export interface ReadTransport { string, "online" | "away" | "offline" | "unknown" > | null>; + readonly writeSidebarMute?: SidebarMuteMutator; readonly profiling?: RelayProfiler; /** Verified incoming traffic. The session owns this subscription and fences late delivery. */ subscribe?(callbacks: LiveCallbacks): LiveSubscription; @@ -247,6 +253,7 @@ export async function connectBrokerTransport( live?: boolean; presence?: boolean; sidebarPreferences?: boolean; + sidebarMuteWrites?: boolean; channelKit?: boolean; agentLibrary?: boolean; agentMemories?: boolean; @@ -563,6 +570,26 @@ export async function connectBrokerTransport( }, } : {}), + ...(session.sidebarMuteWrites + ? { + async writeSidebarMute(intent, signal) { + const result = await fetch(`${endpoint}/sidebar-mute`, { + method: "POST", + credentials: "same-origin", + headers: publicationHeaders(), + 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 a51472f68..9833223bb 100644 --- a/src/features/relay/unread.test.ts +++ b/src/features/relay/unread.test.ts @@ -33,8 +33,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; @@ -97,6 +103,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) => { @@ -1077,3 +1086,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 dea574c17..75a60337a 100644 --- a/src/features/relay/unread.ts +++ b/src/features/relay/unread.ts @@ -77,6 +77,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; } @@ -835,6 +837,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 c9acfa36f..03c319249 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 bead9f7d9..24f1eccc7 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/src/shared/design-system/icons/index.ts b/src/shared/design-system/icons/index.ts index 546515b7d..948a7bcd1 100644 --- a/src/shared/design-system/icons/index.ts +++ b/src/shared/design-system/icons/index.ts @@ -35,6 +35,8 @@ import { AtIcon as PhosphorAtIcon } from "@phosphor-icons/react/dist/csr/At"; export const AtIcon = defineIcon("phosphor", PhosphorAtIcon); import { BellIcon as PhosphorBellIcon } from "@phosphor-icons/react/dist/csr/Bell"; export const BellIcon = defineIcon("phosphor", PhosphorBellIcon); +import { BellSlashIcon as PhosphorBellSlashIcon } from "@phosphor-icons/react/dist/csr/BellSlash"; +export const BellSlashIcon = defineIcon("phosphor", PhosphorBellSlashIcon); import { CaretDownIcon as PhosphorCaretDownIcon } from "@phosphor-icons/react/dist/csr/CaretDown"; export const CaretDownIcon = defineIcon("phosphor", PhosphorCaretDownIcon); import { CaretLeftIcon as PhosphorCaretLeftIcon } from "@phosphor-icons/react/dist/csr/CaretLeft"; @@ -62,6 +64,13 @@ import { DownloadIcon as PhosphorDownloadIcon } from "@phosphor-icons/react/dist export const DownloadIcon = defineIcon("phosphor", PhosphorDownloadIcon); import { DropboxLogoIcon as PhosphorDropboxLogoIcon } from "@phosphor-icons/react/dist/csr/DropboxLogo"; export const DropboxLogoIcon = defineIcon("phosphor", PhosphorDropboxLogoIcon); +import { EnvelopeIcon as PhosphorEnvelopeIcon } from "@phosphor-icons/react/dist/csr/Envelope"; +export const EnvelopeIcon = defineIcon("phosphor", PhosphorEnvelopeIcon); +import { EnvelopeOpenIcon as PhosphorEnvelopeOpenIcon } from "@phosphor-icons/react/dist/csr/EnvelopeOpen"; +export const EnvelopeOpenIcon = defineIcon( + "phosphor", + PhosphorEnvelopeOpenIcon, +); import { FigmaLogoIcon as PhosphorFigmaLogoIcon } from "@phosphor-icons/react/dist/csr/FigmaLogo"; export const FigmaLogoIcon = defineIcon("phosphor", PhosphorFigmaLogoIcon); import { PaperclipIcon as PhosphorPaperclipIcon } from "@phosphor-icons/react/dist/csr/Paperclip"; diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 6307b1277..761042ca3 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -802,6 +802,25 @@ export const test = base.extend({ return; } expect(event.kind).toBe(30078); + const sidebarCoordinate = event.tags.find(([name]) => name === "d")?.[1]; + if (sidebarCoordinate === "channel-mutes") { + expect(event.tags).toContainEqual(["t", sidebarCoordinate]); + const blob = JSON.parse( + nip44.v2.decrypt( + event.content, + nip44.v2.utils.getConversationKey(userKey, viewer), + ), + ); + readEvents.get(community).set(sidebarCoordinate, event); + report.sidebarPublications ??= []; + report.sidebarPublications.push({ + community, + coordinate: sidebarCoordinate, + event, + blob, + }); + return; + } expect(event.tags).toContainEqual(["t", "read-state"]); const blob = JSON.parse( nip44.v2.decrypt( @@ -1330,10 +1349,25 @@ export const test = base.extend({ observerFailures.splice(match, 1); return true; }; + // Consume each deliberately injected mute failure by exact request URL. + const muteFailures = [...(report.sidebarMuteFailures ?? [])]; + const injectedMuteFailure = (message, index) => { + if ( + !/^Failed to load resource: the server responded with a status of 502/.test( + message, + ) + ) + return false; + const match = muteFailures.indexOf(consoleLocations.get(index)); + if (match < 0) return false; + muteFailures.splice(match, 1); + return true; + }; expect( report.consoleErrors.filter( (message, index) => !retiredConsole(message, index) && + !injectedMuteFailure(message, index) && !( expectedPageFailure && message.includes("Fixture page render failure") diff --git a/tests/browser/navigation-mute-read.spec.mjs b/tests/browser/navigation-mute-read.spec.mjs new file mode 100644 index 000000000..925f996d7 --- /dev/null +++ b/tests/browser/navigation-mute-read.spec.mjs @@ -0,0 +1,347 @@ +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); + // Context menus make the rest of the page aria-hidden while open. + const sidebar = page.getByRole("navigation", { + name: "Subscribed channels", + includeHidden: true, + }); + 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 expect(menu.getByRole("menuitem")).toHaveText([ + "New session", + "Mute", + "Mark as Read", + ]); + await expect(menu.getByRole("separator")).toHaveCount(1); + for (const name of ["Mute", "Mark as Read"]) { + await expect( + menu + .getByRole("menuitem", { name, exact: true }) + .locator(".buzz-menu-icon"), + ).toHaveAttribute("aria-hidden", "true"); + } + await expect( + sidebar.getByRole("button", { name: /More options/ }), + ).toHaveCount(0); + await captureActions(page, menu, 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).toHaveCount(0); + await expect(beta).toBeFocused(); + await expect(beta.getByLabel(/^(Muting;|Muted;)/)).toHaveCount(0); + await expect(alpha).toHaveAttribute("aria-current", "page"); + await expect(badge(beta)).toHaveAttribute( + "aria-label", + /^6 observed unread messages/, + ); + // Reopening is usable while the relay is still held; no frozen Saving menu. + await page.keyboard.press("Shift+F10"); + await expect( + menu.getByRole("menuitem", { name: "Unmute", exact: true }), + ).toBeEnabled(); + await expect( + menu.getByRole("menuitem", { name: "Mark as Read" }), + ).toBeEnabled(); + await page.keyboard.press("Escape"); + } finally { + release(); + } + const failure = page.getByRole("dialog", { name: "Couldn’t mute Beta" }); + await expect(failure).toContainText("Relay request failed (502)"); + await beta.focus(); + await page.keyboard.press("Shift+F10"); + await expect( + menu.getByRole("menuitem", { name: "Mute", exact: true }), + ).toBeEnabled(); + await page.keyboard.press("Escape"); + await page.unroute("**/sidebar-mute"); + // The persistent sidebar keeps retry UI and actions usable outside Messages. + await page.getByRole("button", { name: "Projects", exact: true }).click(); + await expect( + page.getByRole("heading", { name: "Projects", exact: true }), + ).toBeVisible(); + await expect(failure).toBeVisible(); + await failure.getByRole("button", { name: "Retry", exact: true }).click(); + await expect(failure).toHaveCount(0); + await expect(menu).toHaveCount(0); + await expect(beta).toBeFocused(); + await expect(badge(beta)).toHaveAttribute( + "aria-label", + /^6 observed unread messages/, + ); + await expect + .poll(() => 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).not.toHaveAttribute("aria-current", "page"); + await expect( + page.getByRole("heading", { name: "Projects", exact: true }), + ).toBeVisible(); + 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 beta.click({ button: "right" }); + await expect( + menu.getByRole("menuitem", { name: "Unmute", exact: true }), + ).toBeEnabled(); + await page.keyboard.press("Escape"); + await expect(beta).toBeFocused(); + await expect(beta.getByLabel(/^(Muting;|Muted;)/)).toHaveCount(0); + await expect(badge(alpha)).toHaveAttribute( + "aria-label", + /^8 observed unread messages/, + ); + await expect(badge(beta)).toHaveCount(0); + await beta.click({ button: "right" }); + const markUnread = menu.getByRole("menuitem", { + name: "Mark as Unread", + exact: true, + }); + await expect(markUnread).toBeVisible(); + await expect( + menu.getByRole("menuitem", { name: "Mark as Read", exact: true }), + ).toHaveCount(0); + await captureActions(page, menu, testInfo.outputPath("mark-unread-menu.png")); + await markUnread.click(); + await expect(menu).toHaveCount(0); + await expect(beta).toBeFocused(); + const localMark = beta.getByRole("img", { + name: "Marked unread on this device only", + }); + await expect(localMark).toBeVisible(); + await expect(alpha).toHaveAttribute("aria-current", "page"); + // Cold preferences can relocate the row while a read-state transaction waits + // on startup. Control both responses so focus restoration crosses that remount. + const preferences = holdResponse(page, "**/sidebar-preferences"); + const reads = holdResponse(page, "**/read-state-decode"); + await Promise.all([preferences.ready, reads.ready]); + try { + await page.reload(); + await page + .getByRole("button", { name: "Messages", exact: true }) + .first() + .click(); + await Promise.all([preferences.started, reads.started]); + await expect(localMark).toBeVisible(); + await expect(beta).toContainText("Beta"); + await beta.focus(); + await page.keyboard.press("Shift+F10"); + await expect(markUnread).toHaveCount(0); + await menu + .getByRole("menuitem", { name: "Mark as Read", exact: true }) + .click(); + await expect(menu.getByRole("status")).toHaveText("Saving…"); + preferences.release(); + await expect( + sidebar + .locator("details") + .filter({ has: page.locator("summary", { hasText: "Work" }) }) + .locator('[data-channel-id="beta"]'), + ).toBeVisible(); + reads.release(); + await expect(menu).toHaveCount(0); + await expect(beta).toBeFocused(); + } finally { + preferences.release(); + reads.release(); + await page.unroute("**/sidebar-preferences"); + await page.unroute("**/read-state-decode"); + } + await expect(localMark).toHaveCount(0); + await expect(badge(beta)).toHaveCount(0); + await expect(badge(alpha)).toHaveAttribute( + "aria-label", + /^8 observed unread messages/, + ); + await page.keyboard.press("Shift+F10"); + await expect(markUnread).toBeVisible(); + const unmute = holdResponse(page, "**/sidebar-mute"); + await unmute.ready; + try { + await menu.getByRole("menuitem", { name: "Unmute", exact: true }).click(); + await unmute.started; + await expect(menu).toHaveCount(0); + await expect(beta).toBeFocused(); + await page.keyboard.press("Shift+F10"); + await expect( + menu.getByRole("menuitem", { name: "Mute", exact: true }), + ).toBeEnabled(); + await page.keyboard.press("Escape"); + } finally { + unmute.release(); + } + await expect + .poll(() => app.report.sidebarPublications.at(-1)) + .toMatchObject({ + coordinate: "channel-mutes", + blob: { channels: { beta: { muted: false } } }, + }); + await page.reload(); + await page + .getByRole("button", { name: "Messages", exact: true }) + .first() + .click(); + await beta.click({ button: "right" }); + await expect( + menu.getByRole("menuitem", { name: "Mute", exact: true }), + ).toBeVisible(); + await page.keyboard.press("Escape"); + // Removing session entry must not disable sibling actions or leave a divider. + const toggleSessions = async (enabled) => { + await page + .getByRole("button", { name: "Your profile", exact: true }) + .click(); + await page.getByRole("menuitem", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: "Plugins", exact: true }).click(); + const toggle = page + .getByRole("region", { name: "Plugins", exact: true }) + .getByRole("article") + .filter({ + has: page.getByRole("heading", { name: "Sessions", exact: true }), + }) + .getByRole("switch", { name: "Enable Sessions" }); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-checked", String(enabled)); + }; + await toggleSessions(false); + await beta.click({ button: "right" }); + await expect(menu.getByRole("menuitem")).toHaveText([ + "Mute", + "Mark as Unread", + ]); + await expect(menu.getByRole("separator")).toHaveCount(0); + await page.keyboard.press("Escape"); + await expect(menu).toHaveCount(0); + await expect(beta).toBeFocused(); + await toggleSessions(true); + // The production broker must expose a valid empty agent library when this + // retained row menu opens the session composer (not just in the local-only host). + await beta.click({ button: "right" }); + await menu + .getByRole("menuitem", { name: "New session", exact: true }) + .click(); + await expect( + page.getByRole("textbox", { name: "Message this session", exact: true }), + ).toBeFocused(); + expect(app.report.unexpected).toEqual([]); +}); + +function holdResponse(page, pattern) { + let release, started; + const gate = new Promise((resolve) => { + release = resolve; + }); + const requested = new Promise((resolve) => { + started = resolve; + }); + const ready = page.route(pattern, async (route) => { + started(); + await gate; + await route.continue(); + }); + return { ready, started: requested, release: () => release() }; +} + +// Capture the real built app menu, without unrelated conversation content. +async function captureActions(page, menu, path) { + // Page screenshots do not wait for the menu's entrance transition to finish. + await menu.evaluate(async (element) => { + await Promise.all( + element + .getAnimations({ subtree: true }) + .map((animation) => animation.finished), + ); + }); + const first = await menu.getByRole("menuitem").first().boundingBox(); + const last = await menu + .getByRole("menuitem", { name: /^Mark as (Read|Unread)$/ }) + .boundingBox(); + if (!first || !last) throw new Error("Read/mute actions are not laid out"); + await page.screenshot({ + path, + clip: { + x: first.x - 4, + y: first.y - 4, + width: first.width + 8, + height: last.y + last.height - first.y + 8, + }, + }); +} diff --git a/tests/browser/navigation-session-menu.spec.mjs b/tests/browser/navigation-session-menu.spec.mjs index 4bb848d58..7f71e0395 100644 --- a/tests/browser/navigation-session-menu.spec.mjs +++ b/tests/browser/navigation-session-menu.spec.mjs @@ -137,6 +137,7 @@ test.describe("menu placement lifetime", () => { sections: [{ id: "work", name: "Work", order: 0 }], assignments: { beta: "work" }, starred: stars, + muted: [], }, }); }); diff --git a/tests/browser/policy-relay.mjs b/tests/browser/policy-relay.mjs index c6592650d..d56b8ea52 100644 --- a/tests/browser/policy-relay.mjs +++ b/tests/browser/policy-relay.mjs @@ -195,9 +195,10 @@ export function policyRelay({ ); } if (filters.length !== 1) { - // The read-only sidebar projection reads the two exact coordinates. - expect(filters).toHaveLength(2); + // Sidebar preferences read only these three exact own-author coordinates. + expect(filters).toHaveLength(3); expect(filters.map((filter) => filter["#d"]?.[0]).sort()).toEqual([ + "channel-mutes", "channel-sections", "channel-stars", ]); From 8267c4a0704381094ab4f3ca93f77cb1356ae500 Mon Sep 17 00:00:00 2001 From: Carl Date: Thu, 24 Sep 2026 15:33:33 -0700 Subject: [PATCH 2/3] test(channels): locate dm controls by sidebar row Find the existing row container rather than assuming two wrapper levels. Preserve hide/restore, badge geometry and tab-order assertions when mute/read enables the shared context menu for DMs. Signed-off-by: Carl --- tests/browser/sidebar-unread.spec.mjs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/browser/sidebar-unread.spec.mjs b/tests/browser/sidebar-unread.spec.mjs index dda76ff16..11295e09c 100644 --- a/tests/browser/sidebar-unread.spec.mjs +++ b/tests/browser/sidebar-unread.spec.mjs @@ -66,15 +66,10 @@ test("DM hide control removes a row and a new message restores it", async ({ return { x: marker.x - row.x, y: marker.y - row.y }; }); const before = await badgePosition(); - const container = dm.locator("..").locator(".."); + const container = dm.locator("xpath=ancestor::*[@data-channel-sidebar-row]"); await container.screenshot({ path: info.outputPath("dm-row-default.png") }); await dm.hover(); - const hide = dm - .locator("..") - .locator("..") - .getByRole("button", { - name: /Remove .* from DMs/, - }); + const hide = container.getByRole("button", { name: /Remove .* from DMs/ }); await expect(hide).toBeVisible(); await expect .poll(() => @@ -298,8 +293,7 @@ test("edge pills follow scroll and reveal the nearest unread without selection o if (info.project.name === "chromium") { await page.keyboard.press("Tab"); const remove = row(page, "dm-030") - .locator("..") - .locator("..") + .locator("xpath=ancestor::*[@data-channel-sidebar-row]") .getByRole("button", { name: /Remove .* from DMs/ }); await expect(remove).toBeFocused(); await page.keyboard.press("Tab"); From 5db272b12afa2352981d07fd81121e42f3219e4e Mon Sep 17 00:00:00 2001 From: Carl Date: Thu, 24 Sep 2026 15:57:51 -0700 Subject: [PATCH 3/3] test(settings): wait for wheel scrolling to finish Observe scrollend before measuring the next plugin row position. A changed scrollTop only proves wheel input started; queued WebKit scrolling could clip the switch after the full-visibility assertion passed. Preserve main's geometry, navigation, focus and keyboard assertions. Signed-off-by: Carl --- tests/browser/settings.spec.mjs | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/tests/browser/settings.spec.mjs b/tests/browser/settings.spec.mjs index 97b6e8456..e6b5d0c1f 100644 --- a/tests/browser/settings.spec.mjs +++ b/tests/browser/settings.spec.mjs @@ -30,6 +30,29 @@ test("short narrow Settings keeps full plugin rows usable at 200% text size", as await expect(page.locator("html")).toHaveCSS("--buzz-text-scale", "2"); const bounds = await frame.boundingBox(); const scroller = frame.locator(":scope > div"); + const wheel = async (deltaY) => { + // A changed scrollTop is only the start of WebKit's animated wheel input. + // Arm the completion observer before input, then measure the settled row. + const completion = await scroller.evaluateHandle((element) => { + const state = { done: false }; + element.addEventListener( + "scrollend", + () => { + state.done = true; + }, + { once: true }, + ); + return state; + }); + try { + await page.mouse.wheel(0, deltaY); + await expect + .poll(() => completion.evaluate((state) => state.done)) + .toBe(true); + } finally { + await completion.dispose(); + } + }; await page.mouse.move( bounds.x + bounds.width / 2, bounds.y + bounds.height / 2, @@ -49,14 +72,7 @@ test("short narrow Settings keeps full plugin rows usable at 200% text size", as target.y < visibleTop + 8 ? target.y - visibleTop - 8 : target.y + target.height - visibleBottom + 8; - const before = await scroller.evaluate((element) => element.scrollTop); - await page.mouse.wheel( - 0, - Math.sign(distance) * Math.max(Math.abs(distance), 24), - ); - await expect - .poll(() => scroller.evaluate((element) => element.scrollTop)) - .not.toBe(before); + await wheel(Math.sign(distance) * Math.max(Math.abs(distance), 24)); } await expect(row).toBeInViewport({ ratio: 1 }); await expect(toggle).toBeInViewport({ ratio: 1 });