diff --git a/dev/relay-broker-api.test.mjs b/dev/relay-broker-api.test.mjs index c6818119..46bb51d6 100644 --- a/dev/relay-broker-api.test.mjs +++ b/dev/relay-broker-api.test.mjs @@ -494,3 +494,79 @@ test("both real sign and publish routes admit direct replies but reject arbitrar await h.close(); } }); + +test("lifecycle uses dedicated shape-limited host routes, never the message writer", async () => { + const h = await harness((call) => + Response.json( + call.url.endsWith("/events") + ? { accepted: true, event_id: call.body.id } + : [], + ), + ); + try { + const transport = await connectBrokerTransport(h.base); + expect(transport.writer.kinds).not.toContain(9008); + const id = "11111111-1111-4111-8111-111111111111"; + const template = { + kind: 9008, + tags: [["h", id]], + content: "", + created_at: 1700000000, + }; + expect((await h.post("sign", template)).status).toBe(400); + const invalid = [ + { + ...template, + kind: 9002, + tags: [ + ["h", id], + ["name", "rename"], + ], + }, + { + ...template, + kind: 9022, + tags: [ + ["h", id], + ["p", transport.viewer], + ], + }, + { ...template, content: "extra" }, + { + ...template, + tags: [ + ["h", id], + ["h", id], + ], + }, + ]; + for (const event of invalid) { + expect((await h.post("channel-lifecycle-sign", event)).status).toBe(400); + expect((await h.post("channel-lifecycle-publish", event)).status).toBe( + 400, + ); + } + const signal = new AbortController().signal; + const signed = await transport.channelLifecycle.sign(template, signal); + expect(verifyEvent(signed)).toBe(true); + expect(signed).toMatchObject(template); + expect((await h.post("publish", signed)).status).toBe(400); + await transport.channelLifecycle.publish(signed, signal); + expect(h.calls.filter((call) => call.url.endsWith("/events"))).toHaveLength( + 1, + ); + const foreignKey = new Uint8Array(32).fill(5); + const foreign = finalizeEvent( + { ...template, tags: template.tags.map((tag) => [...tag]) }, + foreignKey, + ); + expect((await h.post("channel-lifecycle-publish", foreign)).status).toBe( + 400, + ); + expect(h.calls.filter((call) => call.url.endsWith("/events"))).toHaveLength( + 1, + ); + } finally { + await h.close(); + } +}); diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index b6e56d46..946652c7 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -1,3 +1,8 @@ +import { validateLifecycleTemplate } from "../src/features/relay/channel-lifecycle-protocol.ts"; +import { + assertSidebarStarIntent, + mutateSidebarStar, +} from "./sidebar-stars.mjs"; import { validateWorkflowEvent, WORKFLOW_KINDS, @@ -23,6 +28,8 @@ import { import { readAgentLibrary } from "./agent-library.mjs"; import { decodeSidebarPreferences, + assertSidebarAssignmentIntent, + mutateSidebarAssignment, SIDEBAR_REQUEST_BYTES, SIDEBAR_UPLOAD_MS, SIDEBAR_UPLOAD_SLOTS, @@ -59,6 +66,7 @@ const MAX_FILTERS = 4, MAX_LIMIT = 500, MAX_INFLIGHT = 6, MAX_MEDIA_BYTES = 20 * 1024 * 1024, + SIDEBAR_HEAD_BYTES = SIDEBAR_REQUEST_BYTES + 4096, UPSTREAM_TIMEOUT_MS = 20000, KEEPALIVE_MS = 60000; @@ -300,6 +308,27 @@ 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 = "group") => { + 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(); @@ -342,6 +371,7 @@ export function relayBrokerPlugin({ let inflight = 0; let sidebarUploads = 0; let libraryRead; + const sidebarMutations = new Map(); const streams = new Map(); const admissions = createHostAdmission(); server.httpServer?.once("close", () => { @@ -528,6 +558,149 @@ export function relayBrokerPlugin({ sidebarUploads--; } } + if ( + [ + "/api/relay/sidebar-assignment", + "/api/relay/sidebar-star", + ].includes(route) && + req.method === "POST" + ) { + const starring = route === "/api/relay/sidebar-star"; + 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); + if (starring) assertSidebarStarIntent(intent); + else assertSidebarAssignmentIntent(intent); + } catch { + return json(res, 400, { + error: `Invalid sidebar preference intent`, + }); + } + 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": [starring ? "channel-stars" : "channel-sections"], + 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 = async (event) => { + const response = await dispatch("/events", event); + if (!response.ok) + throw new Error( + `Sidebar preference publish failed (${response.status})`, + ); + const receipt = await readSidebarHead( + response, + "publication", + ); + if ( + receipt.event_id !== event.id || + receipt.accepted !== true + ) + throw new Error( + "Sidebar preference publication was not accepted", + ); + }; + return (starring ? mutateSidebarStar : mutateSidebarAssignment)( + 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. @@ -550,9 +723,12 @@ export function relayBrokerPlugin({ ...(await getAuthority(relay)), relayUrl: relay, writeKinds: [7, 9, ...WORKFLOW_KINDS], + channelLifecycle: true, workflowReads: true, sidebarPreferences: true, readState: true, + sidebarPreferenceWrites: true, + sidebarStarWrites: true, agentLibrary: true, live: true, agentActivity: true, @@ -758,6 +934,8 @@ export function relayBrokerPlugin({ ![ "/api/relay/query", "/api/relay/sign", + "/api/relay/channel-lifecycle-sign", + "/api/relay/channel-lifecycle-publish", "/api/relay/publish", "/api/relay/read-state-sign", "/api/relay/read-state-publish", @@ -888,10 +1066,26 @@ export function relayBrokerPlugin({ sent: false, }); const timings = []; - const signing = route === "/api/relay/sign"; - const publishing = route === "/api/relay/publish"; + const lifecycle = + route === "/api/relay/channel-lifecycle-sign" || + route === "/api/relay/channel-lifecycle-publish"; + const signing = + route === "/api/relay/sign" || + route === "/api/relay/channel-lifecycle-sign"; + const publishing = + route === "/api/relay/publish" || + route === "/api/relay/channel-lifecycle-publish"; if (signing || publishing) { - if (![7, 9].includes(filters?.kind)) { + if (lifecycle) { + try { + validateLifecycleTemplate(filters); + } catch { + return json(res, 400, { + error: "Invalid channel lifecycle command", + sent: false, + }); + } + } else if (![7, 9].includes(filters?.kind)) { try { validateWorkflowEvent( { ...filters, pubkey: signing ? viewer : filters.pubkey }, diff --git a/dev/sidebar-group-moves.test.mjs b/dev/sidebar-group-moves.test.mjs new file mode 100644 index 00000000..3c27fd87 --- /dev/null +++ b/dev/sidebar-group-moves.test.mjs @@ -0,0 +1,269 @@ +import { expect, it, vi } from "vitest"; +import { + finalizeEvent, + generateSecretKey, + getPublicKey, + nip44, +} from "nostr-tools"; +import { createSidebarPreferencesStore } from "../src/features/relay/sidebar-preferences-store.ts"; +import { sidebarSections } from "../src/bundled/channels/sidebar-sections.ts"; +import { + decodeSidebarPreferences, + mutateSidebarAssignment, +} from "./sidebar-preferences.mjs"; +import { mutateSidebarStar } from "./sidebar-stars.mjs"; + +async function setup({ cachedAssignment = true } = {}) { + const secret = generateSecretKey(); + const key = nip44.v2.utils.getConversationKey(secret, getPublicKey(secret)); + const heads = new Map(); + for (const [coordinate, blob] of [ + [ + "channel-sections", + { + version: 1, + sections: [{ id: "work", name: "Work", order: 0 }], + assignments: { alpha: "work", beta: "work" }, + }, + ], + [ + "channel-stars", + { version: 1, channels: { alpha: { starred: true, updatedAt: 1 } } }, + ], + ]) + heads.set( + coordinate, + finalizeEvent( + { + kind: 30078, + created_at: 1, + tags: [["d", coordinate]], + content: nip44.v2.encrypt(JSON.stringify(blob), key), + }, + secret, + ), + ); + key.fill(0); + const read = vi.fn(async () => + decodeSidebarPreferences([...heads.values()], secret), + ); + if (!cachedAssignment) + read.mockResolvedValueOnce({ + ...(await read()), + assignments: { beta: "work" }, + }); + const publications = []; + const publish = vi.fn(async (event) => { + const coordinate = event.tags.find(([tag]) => tag === "d")[1]; + heads.set(coordinate, event); + publications.push(coordinate); + }); + const assignment = vi.fn((intent, signal) => + mutateSidebarAssignment( + intent, + secret, + async () => { + signal.throwIfAborted(); + return [heads.get("channel-sections")]; + }, + publish, + ), + ); + const star = vi.fn(async (intent, signal) => { + const result = await mutateSidebarStar( + intent, + secret, + async () => { + signal.throwIfAborted(); + return [heads.get("channel-stars")]; + }, + publish, + ); + return Object.entries(result.channels) + .filter(([, value]) => value.starred) + .map(([id]) => id); + }); + const owner = createSidebarPreferencesStore(read, true, assignment, star); + await owner.queries.ensure(); + return { + owner, + prefs: owner.queries, + read, + publish, + publications, + assignment, + star, + }; +} + +it.each([true, false])( + "removal clears the durable previous group even when cached assignment is %s", + async (cachedAssignment) => { + const h = await setup({ cachedAssignment }); + try { + await h.prefs.setStar("alpha", false); + expect(h.publications).toEqual(["channel-sections", "channel-stars"]); + const restored = await h.read(); + expect(restored.assignments).toEqual({ beta: "work" }); + expect(restored.starred).toEqual([]); + expect(h.prefs.snapshot().data).toEqual(restored); + expect( + sidebarSections([{ id: "alpha", name: "Alpha" }], restored).map( + ({ key }) => key, + ), + ).toEqual(["channels"]); + } finally { + h.owner.dispose(); + } + }, +); + +it("moves directly from Starred into a saved group and retains other assignments", async () => { + const h = await setup(); + try { + await h.prefs.assign("alpha", "work"); + const restored = await h.read(); + expect(restored.assignments).toEqual({ alpha: "work", beta: "work" }); + expect(restored.starred).toEqual([]); + expect( + sidebarSections([{ id: "alpha", name: "Alpha" }], restored).map( + ({ key }) => key, + ), + ).toEqual(["group:work"]); + } finally { + h.owner.dispose(); + } +}); + +it("does not clear Star when assignment publication fails", async () => { + const h = await setup(); + try { + const before = h.prefs.snapshot(); + h.publish.mockRejectedValueOnce(new Error("assignment rejected")); + await expect(h.prefs.setStar("alpha", false)).rejects.toThrow( + "assignment rejected", + ); + expect(h.star).not.toHaveBeenCalled(); + expect(h.prefs.snapshot()).toBe(before); + expect((await h.read()).starred).toEqual(["alpha"]); + await h.prefs.setStar("alpha", false); + expect((await h.read()).assignments).toEqual({ beta: "work" }); + } finally { + h.owner.dispose(); + } +}); + +it("keeps Starred after a partial failure and retry cannot resurrect the old group", async () => { + const h = await setup(); + try { + const before = h.prefs.snapshot(); + h.star.mockRejectedValueOnce(new Error("star rejected")); + await expect(h.prefs.setStar("alpha", false)).rejects.toThrow( + "star rejected", + ); + expect(h.prefs.snapshot()).toBe(before); + const partial = await h.read(); + expect(partial.assignments).toEqual({ beta: "work" }); + expect(partial.starred).toEqual(["alpha"]); + await h.prefs.refresh(); + await h.prefs.setStar("alpha", false); + expect(h.prefs.snapshot().data).toEqual({ ...partial, starred: [] }); + expect(h.publications).toEqual(["channel-sections", "channel-stars"]); + } finally { + h.owner.dispose(); + } +}); + +it("a refresh during the two-write move cannot expose the intermediate assignment", async () => { + const h = await setup(); + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + let started; + const entered = new Promise((resolve) => { + started = resolve; + }); + const star = h.star.getMockImplementation(); + h.star.mockImplementationOnce(async (...args) => { + started(); + await held; + return star(...args); + }); + try { + const before = h.prefs.snapshot(); + const pending = h.prefs.setStar("alpha", false); + await entered; + const refresh = h.prefs.refresh(); + expect(h.prefs.snapshot()).toBe(before); + expect(h.read).toHaveBeenCalledOnce(); + release(); + await Promise.all([pending, refresh]); + expect(h.prefs.snapshot().data.assignments).toEqual({ beta: "work" }); + expect(h.prefs.snapshot().data.starred).toEqual([]); + } finally { + release(); + h.owner.dispose(); + } +}); + +it("failed move plus an older refresh cannot strand the preference status at loading", async () => { + const h = await setup(); + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + let started; + const entered = new Promise((resolve) => { + started = resolve; + }); + try { + const before = h.prefs.snapshot().data; + h.read.mockImplementationOnce(async () => { + started(); + return held; + }); + const refresh = h.prefs.refresh(); + await entered; + h.assignment.mockRejectedValueOnce(new Error("assignment failed")); + await expect(h.prefs.setStar("alpha", false)).rejects.toThrow( + "assignment failed", + ); + release(before); + await refresh; + expect(h.prefs.snapshot()).toEqual({ + status: "error", + error: "assignment failed", + data: before, + }); + await h.prefs.refresh(); + expect(h.prefs.snapshot().status).toBe("ready"); + } finally { + release(h.prefs.snapshot().data); + h.owner.dispose(); + } +}); + +it("cancellation between records prevents clearing Star and retry finishes from durable state", async () => { + const h = await setup(); + const caller = new AbortController(); + const assign = h.assignment.getMockImplementation(); + h.assignment.mockImplementationOnce(async (...args) => { + const result = await assign(...args); + caller.abort(); + return result; + }); + try { + await expect( + h.prefs.setStar("alpha", false, caller.signal), + ).rejects.toThrow(); + expect(h.star).not.toHaveBeenCalled(); + expect((await h.read()).starred).toEqual(["alpha"]); + await h.prefs.setStar("alpha", false); + const restored = await h.read(); + expect(restored.assignments).toEqual({ beta: "work" }); + expect(restored.starred).toEqual([]); + } finally { + h.owner.dispose(); + } +}); diff --git a/dev/sidebar-preference-writes.test.mjs b/dev/sidebar-preference-writes.test.mjs new file mode 100644 index 00000000..232ecc2f --- /dev/null +++ b/dev/sidebar-preference-writes.test.mjs @@ -0,0 +1,179 @@ +import { createServer } from "node:http"; +import { createHash } from "node:crypto"; +import { afterEach, expect, it } from "vitest"; +import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools"; +import { relayBrokerPlugin } from "./relay-broker.mjs"; +import { prepareSidebarStar } from "./sidebar-stars.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() { + const key = generateSecretKey(), + viewer = getPublicKey(key); + let handler, queryFailure, publicationFailure; + let conflict = false; + const heads = new Map(), + calls = []; + const server = createServer((req, res) => { + req.headers.origin ??= `http://${req.headers.host}`; + handler(req, res); + }); + await relayBrokerPlugin({ + relayUrl: fixtureRelayUrl, + communityAliases: fixtureAliases, + identity: () => key, + 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 }); + if (String(url).endsWith("/events")) { + expect(verifyEvent(body)).toBe(true); + if (publicationFailure) return publicationFailure; + if (!conflict) + heads.set(body.tags.find(([name]) => name === "d")[1], body); + return Response.json({ accepted: true, event_id: body.id }); + } + 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 () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }); + const base = `http://127.0.0.1:${server.address().port}`; + const transport = await connectBrokerTransport(base); + return { + key, + viewer, + transport, + calls, + heads, + failQuery(value) { + queryFailure = value; + }, + failPublication(value) { + publicationFailure = value; + }, + conflict() { + conflict = true; + }, + post(value, origin) { + return fetch(`${base}/api/relay/sidebar-star`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(origin ? { Origin: origin } : {}), + }, + body: JSON.stringify(value), + }); + }, + }; +} +it("real broker Star roundtrip signs scoped requests and confirms before projecting", async () => { + const h = await harness(), + signal = new AbortController().signal; + h.heads.set( + "channel-stars", + prepareSidebarStar([], { channelId: "other", starred: true }, h.key).event, + ); + expect( + await h.transport.writeSidebarStar( + { channelId: "alpha", starred: true }, + signal, + ), + ).toEqual(["other", "alpha"]); + expect(h.calls.map((call) => new URL(call.url).pathname)).toEqual([ + "/query", + "/events", + "/query", + ]); + expect(h.calls[0].body).toEqual([ + { kinds: [30078], authors: [h.viewer], "#d": ["channel-stars"], limit: 1 }, + ]); + expect( + await h.transport.writeSidebarStar( + { channelId: "alpha", starred: false }, + signal, + ), + ).toEqual(["other"]); + expect(h.calls.filter((call) => call.url.endsWith("/events"))).toHaveLength( + 2, + ); + await h.transport.writeSidebarStar( + { channelId: "alpha", starred: false }, + signal, + ); + expect(h.calls.filter((call) => call.url.endsWith("/events"))).toHaveLength( + 2, + ); +}); +it("refuses invalid intent and foreign origins without upstream requests", async () => { + const h = await harness(); + expect((await h.post({ channelId: "alpha", starred: "true" })).status).toBe( + 400, + ); + expect( + ( + await h.post( + { channelId: "alpha", starred: true }, + "https://foreign.invalid", + ) + ).status, + ).toBe(403); + expect( + (await h.post({ channelId: "x".repeat(2100), starred: true })).status, + ).toBe(413); + expect(h.calls).toEqual([]); +}); +it.each(["query", "oversized", "publication", "receipt", "conflict"])( + "does not claim a saved Star after %s failure", + async (failure) => { + const h = await harness(); + if (failure === "query") + h.failQuery(new Response("failed", { status: 503 })); + if (failure === "oversized") + h.failQuery(new Response(`[${" ".repeat(270000)}]`)); + if (failure === "publication") + h.failPublication(new Response("failed", { status: 503 })); + if (failure === "receipt") + h.failPublication(Response.json({ accepted: false, event_id: "wrong" })); + if (failure === "conflict") h.conflict(); + await expect( + h.transport.writeSidebarStar( + { channelId: "alpha", starred: true }, + new AbortController().signal, + ), + ).rejects.toThrow(); + if (["query", "oversized"].includes(failure)) + expect(h.calls.filter((call) => call.url.endsWith("/events"))).toEqual( + [], + ); + }, +); diff --git a/dev/sidebar-preferences.d.mts b/dev/sidebar-preferences.d.mts new file mode 100644 index 00000000..a17e3e0a --- /dev/null +++ b/dev/sidebar-preferences.d.mts @@ -0,0 +1,28 @@ +import type { SidebarGroups } from "../src/features/relay/sidebar-preferences"; +import type { RelayEvent } from "../src/features/relay/events"; + +export function decodeSidebarPreferences( + events: readonly RelayEvent[], + secret: Uint8Array, +): import("../src/features/relay/sidebar-preferences").SidebarPreferences; +export function assertSidebarAssignmentIntent( + intent: unknown, +): asserts intent is { + channelId: string; + sectionId?: string; +}; +export function prepareSidebarAssignment( + events: readonly RelayEvent[], + intent: { channelId: string; sectionId?: string }, + secret: Uint8Array, + now?: number, +): { groups: SidebarGroups; event?: RelayEvent }; +export function mutateSidebarAssignment( + intent: { channelId: string; sectionId?: string }, + secret: Uint8Array, + readHead: () => Promise, + publish: (event: RelayEvent) => Promise, +): Promise; +export const SIDEBAR_REQUEST_BYTES: number; +export const SIDEBAR_UPLOAD_SLOTS: number; +export const SIDEBAR_UPLOAD_MS: number; diff --git a/dev/sidebar-preferences.mjs b/dev/sidebar-preferences.mjs index 86e334bf..dfe6b9e9 100644 --- a/dev/sidebar-preferences.mjs +++ b/dev/sidebar-preferences.mjs @@ -1,4 +1,4 @@ -import { getPublicKey, nip44, verifyEvent } from "nostr-tools"; +import { finalizeEvent, getPublicKey, nip44, verifyEvent } from "nostr-tools"; import { projectSidebarPreferences, SIDEBAR_COORDINATES, @@ -54,3 +54,136 @@ export function decodeSidebarPreferences(events, secret) { key.fill(0); } } + +const SECTION_COORDINATE = "channel-sections"; +function validAssignmentIntent(intent) { + return ( + intent && + typeof intent === "object" && + !Array.isArray(intent) && + typeof intent.channelId === "string" && + intent.channelId.trim().length > 0 && + intent.channelId.length <= 256 && + (intent.sectionId === undefined || + (typeof intent.sectionId === "string" && + intent.sectionId.trim().length > 0 && + intent.sectionId.length <= 256)) && + Object.keys(intent).every((key) => ["channelId", "sectionId"].includes(key)) + ); +} +export function assertSidebarAssignmentIntent(intent) { + if (!validAssignmentIntent(intent)) + throw new Error("Invalid sidebar assignment intent"); +} +function parseSectionsEvent(events, secret) { + if ( + !Array.isArray(events) || + events.length > 1 || + Buffer.byteLength(JSON.stringify(events)) > SIDEBAR_REQUEST_BYTES + ) + throw new Error("Invalid sidebar group head"); + if (!events.length) + return { + blob: { version: 1, sections: [], assignments: {} }, + createdAt: 0, + }; + const [event] = events; + const viewer = getPublicKey(secret); + const tags = event?.tags?.filter?.( + (tag) => Array.isArray(tag) && tag[0] === "d", + ); + if ( + event?.kind !== 30078 || + event.pubkey !== viewer || + tags?.length !== 1 || + tags[0]?.[1] !== SECTION_COORDINATE || + typeof event.content !== "string" || + !verifyEvent(event) + ) + throw new Error("Invalid sidebar group head"); + const key = nip44.v2.utils.getConversationKey(secret, viewer); + try { + const plaintext = nip44.v2.decrypt(event.content, key); + if (Buffer.byteLength(plaintext) > 128 * 1024) + throw new Error("Sidebar plaintext budget exceeded"); + const blob = JSON.parse(plaintext); + projectSidebarPreferences(blob, undefined); + return { blob, createdAt: event.created_at }; + } finally { + key.fill(0); + } +} +/** Narrow host command: mutate one assignment against the latest encrypted head. */ +export function prepareSidebarAssignment( + events, + intent, + secret, + now = Date.now(), +) { + assertSidebarAssignmentIntent(intent); + const viewer = getPublicKey(secret); + const current = parseSectionsEvent(events, secret); + if ( + intent.sectionId !== undefined && + !current.blob.sections.some((section) => section.id === intent.sectionId) + ) + throw new Error("Sidebar group no longer exists"); + const assignments = { + ...current.blob.assignments, + ...(intent.sectionId === undefined + ? {} + : { [intent.channelId]: intent.sectionId }), + }; + if (intent.sectionId === undefined) delete assignments[intent.channelId]; + const blob = { ...current.blob, assignments }; + const groups = projectSidebarPreferences(blob, undefined); + const previous = Object.hasOwn(current.blob.assignments, intent.channelId) + ? current.blob.assignments[intent.channelId] + : undefined; + if (previous === intent.sectionId) return { groups }; + const key = nip44.v2.utils.getConversationKey(secret, viewer); + let content; + try { + content = nip44.v2.encrypt(JSON.stringify(blob), key); + } finally { + key.fill(0); + } + return { + groups, + event: finalizeEvent( + { + kind: 30078, + content, + created_at: Math.max(Math.floor(now / 1000), current.createdAt + 1), + tags: [ + ["d", SECTION_COORDINATE], + ["t", SECTION_COORDINATE], + ], + }, + secret, + ), + }; +} + +/** Publish one assignment, then re-read the coordinate before reporting saved state. */ +export async function mutateSidebarAssignment( + intent, + secret, + readHead, + publish, +) { + assertSidebarAssignmentIntent(intent); + const draft = prepareSidebarAssignment(await readHead(), intent, secret); + if (!draft.event) return draft.groups; + await publish(draft.event); + const confirmation = prepareSidebarAssignment( + await readHead(), + intent, + secret, + ); + if (confirmation.event) + throw new Error( + "Sidebar groups changed on another device; reload and try again", + ); + return confirmation.groups; +} diff --git a/dev/sidebar-stars.mjs b/dev/sidebar-stars.mjs new file mode 100644 index 00000000..9e8b25f7 --- /dev/null +++ b/dev/sidebar-stars.mjs @@ -0,0 +1,91 @@ +import { finalizeEvent, getPublicKey, nip44 } from "nostr-tools"; +import { decodeSidebarPreferences } from "./sidebar-preferences.mjs"; + +const COORDINATE = "channel-stars"; +export function assertSidebarStarIntent(intent) { + if ( + !intent || + typeof intent !== "object" || + Array.isArray(intent) || + typeof intent.channelId !== "string" || + !intent.channelId.trim() || + intent.channelId.length > 256 || + typeof intent.starred !== "boolean" || + Object.keys(intent).some((key) => !["channelId", "starred"].includes(key)) + ) + throw new Error("Invalid sidebar star intent"); +} + +/** One explicit star intent against a fresh signed head; keep unstar tombstones. */ +export function prepareSidebarStar(events, intent, secret, now = Date.now()) { + assertSidebarStarIntent(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 star 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?.starred === intent.starred || (!previous && !intent.starred)) + return { stars: current }; + const stars = { + ...current, + channels: { + ...current.channels, + [intent.channelId]: { + ...previous, + starred: intent.starred, + updatedAt: Math.max(now, (previous?.updatedAt ?? 0) + 1), + }, + }, + }; + const event = finalizeEvent( + { + kind: 30078, + content: nip44.v2.encrypt(JSON.stringify(stars), 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 { stars, event }; + } finally { + key.fill(0); + } +} + +export async function mutateSidebarStar(intent, secret, readHead, publish) { + assertSidebarStarIntent(intent); + const draft = prepareSidebarStar(await readHead(), intent, secret); + if (!draft.event) return draft.stars; + await publish(draft.event); + const confirmation = prepareSidebarStar(await readHead(), intent, secret); + if (confirmation.event) + throw new Error( + "Sidebar stars changed on another device; reload and try again", + ); + return confirmation.stars; +} diff --git a/dev/sidebar-stars.test.mjs b/dev/sidebar-stars.test.mjs new file mode 100644 index 00000000..e4e48572 --- /dev/null +++ b/dev/sidebar-stars.test.mjs @@ -0,0 +1,211 @@ +import { expect, it, vi } from "vitest"; +import { + finalizeEvent, + generateSecretKey, + getPublicKey, + nip44, + verifyEvent, +} from "nostr-tools"; +import { + assertSidebarStarIntent, + prepareSidebarStar, + mutateSidebarStar, +} from "./sidebar-stars.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-stars"]], + 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: "", starred: true }, + { channelId: "a" }, + { channelId: "a", starred: 1 }, + { channelId: "x".repeat(257), starred: true }, + { channelId: "a", starred: true, extra: 1 }, + ]) + expect(() => assertSidebarStarIntent(intent)).toThrow( + "Invalid sidebar star intent", + ); + const read = vi.fn(); + await expect(mutateSidebarStar({}, h.secret, read, vi.fn())).rejects.toThrow( + "Invalid sidebar star intent", + ); + expect(read).not.toHaveBeenCalled(); +}); +it("encrypts explicit Star/Unstar with monotonic timestamps and preserves unrelated tombstones", () => { + const h = harness(); + const channels = { + alpha: { starred: false, updatedAt: 60000 }, + beta: { starred: true, updatedAt: 2 }, + gone: { starred: false, updatedAt: 3 }, + }; + const added = prepareSidebarStar( + [h.encrypt(channels)], + { channelId: "alpha", starred: 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-stars"], + ["t", "channel-stars"], + ], + }); + expect(added.event.content).not.toContain("alpha"); + expect(added.stars.channels).toEqual({ + ...channels, + alpha: { starred: true, updatedAt: 60001 }, + }); + expect(decodeSidebarPreferences([added.event], h.secret).starred).toEqual([ + "alpha", + "beta", + ]); + const removed = prepareSidebarStar( + [added.event], + { channelId: "alpha", starred: false }, + h.secret, + 50000, + ); + expect(removed.stars.channels).toEqual({ + ...channels, + alpha: { starred: false, updatedAt: 60002 }, + }); + expect(decodeSidebarPreferences([removed.event], h.secret).starred).toEqual([ + "beta", + ]); + expect( + prepareSidebarStar( + [removed.event], + { channelId: "alpha", starred: false }, + h.secret, + ).event, + ).toBeUndefined(); + expect( + prepareSidebarStar( + [], + { channelId: "new", starred: false }, + h.secret, + 50000, + ).stars.channels, + ).toEqual({}); +}); +it("refuses untrusted, ambiguous, malformed and over-budget heads rather than seeding", () => { + const h = harness(), + other = harness(); + const intent = { channelId: "alpha", starred: 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-stars"], + ["d", "channel-stars"], + ], + }, + ), + ], + [h.encrypt({ alpha: { starred: true, updatedAt: -1 } })], + [h.encrypt({}, { content: "x".repeat(SIDEBAR_REQUEST_BYTES) })], + ]) + expect(() => prepareSidebarStar(events, intent, h.secret)).toThrow(); + const full = Object.fromEntries( + Array.from({ length: 500 }, (_, i) => [ + `id-${i}`, + { starred: false, updatedAt: 1 }, + ]), + ); + expect(() => prepareSidebarStar([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: { starred: true, updatedAt: 1 }, + beta: { starred: true, updatedAt: 2 }, + }), + ]; + }); + const intent = { channelId: "alpha", starred: true }; + expect( + (await mutateSidebarStar(intent, h.secret, read, publish)).channels, + ).toHaveProperty("beta"); + expect(read).toHaveBeenCalledTimes(2); + expect(publish).toHaveBeenCalledOnce(); + await mutateSidebarStar(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", starred: true }; + const publish = vi.fn(); + await expect( + mutateSidebarStar( + 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( + mutateSidebarStar(intent, h.secret, read, async () => { + throw new Error("publish failed"); + }), + ).rejects.toThrow("publish failed"); + expect(read).toHaveBeenCalledOnce(); + await expect( + mutateSidebarStar(intent, h.secret, read, publish), + ).rejects.toThrow("changed on another device"); +}); diff --git a/docs/channels.md b/docs/channels.md index c969f6cb..5b3e63ec 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -72,12 +72,88 @@ late completion cannot repopulate a retired snapshot. These are account-owned preferences, not channel access grants: sidebar sections still intersect the authorized roster. There is no new disk cache or automatic cross-device sync. +The browser/development host exposes narrow **assign/remove group** and +**Star/Unstar** commands. Each re-reads the viewer's signed encrypted coordinate, +changes only the requested entry, publishes through the shared relay admission +lane, then re-reads to confirm the requested state. Unrelated fields and explicit +unstar tombstones are retained. Invalid/unreadable/over-budget heads fail closed; +only a successful absent-head read can seed a coordinate. Same-host writes are +serialized per relay. This is confirmed whole-record replacement, not atomic +cross-device merging, a durable pending outbox, or automatic retry: simultaneous +writers on different hosts can still race. Failure retains the last fully confirmed UI +placement and offers an explicit retry; a failed confirmation may follow a +publication that reached the relay. + +The session preference owner serializes local commands and fences refreshes, +caller cancellation, cache clear and disposal. `session.ts` only composes host +capabilities with session lifetime and a bounded deadline. Cache clear cancels +pending work but cannot retract a publication already accepted by the relay. +Stream rows expose these actions by right-click/long-press, Shift+F10 or the +Context Menu key. Menus remain open during saving and failed-save retry; confirmed +relocation expands the destination and restores focus by channel identity. Starred +is a built-in group pinned first, offered alongside saved groups in one "Move to…" +chooser. Placement is exclusive. "Remove from Starred" and "Remove from [group]" +return to Channels, never to a remembered group. Moving out of Starred directly +into a saved group is supported. + +The legacy format still stores stars and assignments separately. The preference +owner confirms the requested assignment (or its removal) **before** clearing Star; +removal always checks the fresh assignment head, not just the cached projection. +Only the complete move updates local placement, and refreshes cannot expose an +intermediate write. If the second write fails, the channel remains Starred and an +explicit retry finishes the move; a reload reflects whatever reached the relay. +This is ordered two-record persistence, not an atomic multi-device move. A prior +assignment may remain stored while starred but is never used as an Unstar target. +Forums/DMs, group CRUD/reorder and independent sorting are outside this slice. +Hosts without the write capabilities retain the read-only projection. + Search, collapsed section keys and sidebar scroll remain separate, scoped view intent. They are saved on page exit and restored before paint when the roster and groups are available; navigation history does not own them. The saved-groups browser regression records every visible return frame and holds the redundant decode path, so eventual restoration cannot conceal a fallback-group/scroll jump. +## Channel lifecycle + +The row menu resolves fresh relay-authored metadata (`39000`), administrators +(`39001`) and membership (`39002`) at exact channel coordinates before offering +Archive/Delete/Leave or DM Hide. Archive requires a direct owner/admin role; +Delete requires a direct owner role; the last owner cannot Leave. The menu omits +Leave when it is forbidden, without an ownership-transfer explanation. Action +labels have no trailing ellipsis; confirmation dialogs are unchanged. DMs offer Hide +only. Delegated owner-agent authority and community-admin overrides are not +inferred or supported by this slice; the relay remains the final authority. +Membership accepts NIP-29 `p` tags with optional relay and role fields +(`["p", pubkey, relay_hint?, role?]`), including the relay's four-field roster. +These fields never substitute for the separate administrator record. Invalid +member keys and duplicate entries still fail closed. Failed menu permission reads +show "Channel actions unavailable" with retry, not raw protocol errors. Pending +permission reads show no loading row; actions appear only after verification. + +Each command has explicit confirmation; Delete additionally requires the channel +name. The lifecycle owner rechecks authority before signing and again before +publication, validates the returned command, and confirms relay-owned state before +removing a row. Archive retains membership; confirmed Delete/Leave use the existing +access-loss purge. Commands use narrow development-broker routes, never the message +outbox or automatic replay. Hosts without this capability display an unavailable +notice; native/direct-signer parity is deferred. + +DM Hide publishes `41012`, not Leave or Delete. The separate relay-authored `30622` +visibility snapshot (`d=viewer`, `p=viewer`, hidden DM `h` tags) only filters sidebar +rows; it does not deny access or prevent exact conversation navigation. Visibility +refreshes with the channel roster, preserves the last good set on failure and +rejects older snapshots. Live cross-device visibility updates and an in-app DM +reopen/unhide flow are deferred; opening a DM through another supported client's +`41010` flow and refreshing restores the row. + +A definitive rejection offers retry without optimistic removal. If publication or +confirmation has an uncertain outcome, the dialog warns that the command may have +taken effect, disables blind resubmission and asks the user to close and refresh +channels. Cancellation/cache clear/session replacement fence late results but cannot +retract a request already sent. Cancellation returns focus to the originating row; +confirmed removal moves an active conversation to another available destination +(or the neutral Messages page) with a sidebar/search focus fallback. + ## Performance and correctness carried from Astra The port retains the prepared-store implementation and its behavior tests: diff --git a/src/bundled/channels/ChannelLifecycleDialog.module.css b/src/bundled/channels/ChannelLifecycleDialog.module.css new file mode 100644 index 00000000..28f0dfe2 --- /dev/null +++ b/src/bundled/channels/ChannelLifecycleDialog.module.css @@ -0,0 +1,55 @@ +.dialog { + margin: auto; + color: var(--text-primary); + background: var(--bg-float); + border: 1px solid var(--border-primary); + border-radius: var(--radius-panel); + padding: var(--space-6); + width: min(480px, calc(100vw - 2 * var(--space-4))); + max-height: calc(100dvh - 2 * var(--space-4)); + overflow: auto; + box-shadow: var(--shadow-sm); + font-size: var(--text-body-sm); + line-height: var(--text-body-sm--line-height); + letter-spacing: var(--text-body-sm--letter-spacing); + font-weight: var(--type-weight-normal); +} +.dialog::backdrop { + background: var(--bg-scrim); +} +.dialog h2 { + font-size: var(--text-heading); + line-height: var(--text-heading--line-height); + letter-spacing: var(--text-heading--letter-spacing); + font-weight: var(--type-weight-medium); + margin: 0 0 var(--space-4); +} +.dialog p { + margin: var(--space-4) 0; +} +.dialog label { + display: grid; + gap: var(--space-2); +} +.dialog input { + width: 100%; + padding: var(--space-2) var(--space-3); + color: var(--text-primary); + background: var(--bg-inset); + border: 1px solid var(--border-primary); + border-radius: var(--radius-row); +} +.actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-2); + margin-top: var(--space-6); +} +.actions [data-destructive]:not([data-disabled]) { + color: var(--red-12); + background: var(--red-3); +} +.actions [data-destructive]:hover:not([data-disabled]) { + background: var(--red-4); +} diff --git a/src/bundled/channels/ChannelLifecycleDialog.tsx b/src/bundled/channels/ChannelLifecycleDialog.tsx new file mode 100644 index 00000000..bf5a6511 --- /dev/null +++ b/src/bundled/channels/ChannelLifecycleDialog.tsx @@ -0,0 +1,137 @@ +import { useEffect, useRef, useState } from "react"; +import { Button } from "../../shared/design-system/ui/Button"; +import { + ChannelLifecycleUnconfirmed, + type ChannelLifecycleCapability, +} from "../../features/relay/channel-lifecycle"; +import type { ChannelLifecycleAction } from "../../features/relay/channel-lifecycle-protocol"; +import styles from "./ChannelLifecycleDialog.module.css"; + +const copy = { + archive: { + title: "Archive channel", + detail: + "Archive this channel for everyone and remove it from the sidebar. Messages are retained. A channel administrator can unarchive it from another supported client.", + }, + delete: { + title: "Delete channel", + detail: + "Delete this channel for everyone. You cannot undo this action from Buzz.", + }, + leave: { + title: "Leave channel", + detail: + "Leave this channel and remove it from your sidebar. You may need an invitation to rejoin a private channel.", + }, + hide: { + title: "Hide conversation", + detail: + "Hide this conversation from your sidebar only. Messages and membership are kept; other participants are not removed.", + }, +} as const; + +export function ChannelLifecycleDialog({ + channelId, + channelName, + action, + lifecycle, + close, + completed, +}: { + channelId: string; + channelName: string; + action: ChannelLifecycleAction; + lifecycle: ChannelLifecycleCapability; + close(): void; + completed(): void; +}) { + const dialog = useRef(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [refreshRequired, setRefreshRequired] = useState(false); + const [confirmation, setConfirmation] = useState(""); + const operation = useRef(undefined); + useEffect(() => { + dialog.current?.showModal(); + return () => { + operation.current?.abort(); + }; + }, []); + const submit = async () => { + if ( + operation.current || + refreshRequired || + (action === "delete" && confirmation !== channelName) + ) + return; + const controller = new AbortController(); + operation.current = controller; + setBusy(true); + setError(""); + try { + await lifecycle.run(action, channelId, controller.signal); + if (!controller.signal.aborted) completed(); + } catch (error) { + if (!controller.signal.aborted) { + setError(error instanceof Error ? error.message : String(error)); + setRefreshRequired(error instanceof ChannelLifecycleUnconfirmed); + } + } finally { + operation.current = undefined; + if (!controller.signal.aborted) setBusy(false); + } + }; + return ( + { + event.preventDefault(); + if (!busy) close(); + }} + > +

+ {copy[action].title}: {channelName} +

+

{copy[action].detail}

+ {action === "delete" && ( + + )} + {error &&

{error}

} + {busy && ( +

+ Checking permissions and waiting for relay confirmation… +

+ )} +
+ + +
+
+ ); +} diff --git a/src/bundled/channels/ChannelLifecycleMenu.test.tsx b/src/bundled/channels/ChannelLifecycleMenu.test.tsx new file mode 100644 index 00000000..a77629d7 --- /dev/null +++ b/src/bundled/channels/ChannelLifecycleMenu.test.tsx @@ -0,0 +1,283 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ContextMenuRoot, MenuPopup } from "../../shared/design-system/ui/Menu"; +import { + ChannelLifecycleUnconfirmed, + type ChannelLifecycleCapability, +} from "../../features/relay/channel-lifecycle"; +import { ChannelLifecycleMenu } from "./ChannelLifecycleMenu"; +import { ChannelLifecycleDialog } from "./ChannelLifecycleDialog"; +import type { ChannelLifecycleSettings } from "../../features/relay/channel-lifecycle-protocol"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +const settings: ChannelLifecycleSettings = { + channelId: "id", + channelType: "stream", + canArchive: true, + canDelete: true, + canLeave: false, + canHide: false, + leaveReason: "Transfer ownership before leaving the channel.", +}; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +function capability() { + return { + available: true, + load: vi.fn(async () => settings), + run: vi.fn(async () => {}), + snapshot: () => ({ status: "ready", hidden: [] }), + subscribe: () => () => {}, + refreshVisibility: async () => {}, + } satisfies ChannelLifecycleCapability; +} +it("waits silently for fresh permissions and preserves the last-owner boundary", async () => { + const user = userEvent.setup(); + const lifecycle = capability(); + const choose = vi.fn(); + const gate = deferred(); + lifecycle.load.mockReturnValueOnce(gate.promise); + render( + + + + + , + ); + await waitFor(() => expect(lifecycle.load).toHaveBeenCalledOnce()); + expect(screen.queryByText("Checking channel permissions…")).toBeNull(); + expect(screen.queryAllByRole("menuitem")).toHaveLength(0); + expect(choose).not.toHaveBeenCalled(); + gate.resolve(settings); + const remove = await screen.findByRole("menuitem", { + name: "Delete channel", + }); + expect(screen.queryByRole("menuitem", { name: /^Leave channel/ })).toBeNull(); + expect( + screen.queryByText("Transfer ownership before leaving the channel."), + ).toBeNull(); + expect( + screen.getAllByRole("menuitem").map((item) => item.textContent), + ).toEqual(["Archive channel", "Delete channel"]); + await user.click(remove); + expect(choose).toHaveBeenCalledWith("delete"); +}); +it.each([ + { action: "leave", label: "Leave channel", channelType: "stream" }, + { action: "hide", label: "Hide conversation", channelType: "dm" }, +] as const)( + "offers $label without an ellipsis when permitted", + async ({ action, label, channelType }) => { + const user = userEvent.setup(); + const lifecycle = capability(); + lifecycle.load.mockResolvedValue({ + channelId: "id", + channelType, + canArchive: false, + canDelete: false, + canLeave: action === "leave", + canHide: action === "hide", + }); + const choose = vi.fn(); + render( + + + + + , + ); + const item = await screen.findByRole("menuitem", { + name: label, + }); + expect(screen.getAllByRole("menuitem")).toHaveLength(1); + await user.click(item); + expect(choose).toHaveBeenCalledWith(action); + }, +); +it("failed permission reads offer retry rather than stale destructive actions", async () => { + const user = userEvent.setup(); + const lifecycle = capability(); + lifecycle.load.mockRejectedValueOnce( + new Error("Malformed channel membership state"), + ); + render( + + + {}} + disabled={false} + /> + + , + ); + expect((await screen.findByRole("alert")).textContent).toBe( + "Channel actions unavailable", + ); + expect( + screen.queryByRole("menuitem", { name: "Archive channel" }), + ).toBeNull(); + const retry = deferred(); + lifecycle.load.mockReturnValueOnce(retry.promise); + await user.click( + screen.getByRole("menuitem", { name: "Retry channel permissions" }), + ); + await waitFor(() => expect(lifecycle.load).toHaveBeenCalledTimes(2)); + expect(screen.queryAllByRole("menuitem")).toHaveLength(0); + expect(screen.queryByRole("alert")).toBeNull(); + retry.resolve(settings); + expect( + await screen.findByRole("menuitem", { + name: "Archive channel", + }), + ).toBeDefined(); +}); +it("confirmation, pending lockout and failed-write recovery stay in the actual dialog", async () => { + // jsdom does not implement top-layer focus; that contract is covered in browsers. + HTMLDialogElement.prototype.showModal = vi.fn(function ( + this: HTMLDialogElement, + ) { + this.setAttribute("open", ""); + }); + const user = userEvent.setup(); + const lifecycle = capability(); + const completed = vi.fn(); + const close = vi.fn(); + const gate = deferred(); + lifecycle.run.mockImplementationOnce(() => + gate.promise.then(() => { + throw new Error("relay rejected"); + }), + ); + render( + , + ); + const confirm = screen.getByRole("button", { + name: "Delete channel", + }) as HTMLButtonElement; + expect(confirm.disabled).toBe(true); + await user.type( + screen.getByRole("textbox", { name: "Channel name confirmation" }), + "Fixture", + ); + await user.click(confirm); + await waitFor(() => expect(lifecycle.run).toHaveBeenCalledOnce()); + expect(confirm.disabled).toBe(true); + expect( + (screen.getByRole("button", { name: "Cancel" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + gate.resolve(); + expect((await screen.findByRole("alert")).textContent).toBe("relay rejected"); + expect(completed).not.toHaveBeenCalled(); + expect(confirm.disabled).toBe(false); + await user.click(confirm); + await waitFor(() => expect(completed).toHaveBeenCalledOnce()); +}); + +it.each(["leave", "hide", "archive"] as const)( + "%s requires confirmation and ignores completion after unmount", + async (action) => { + HTMLDialogElement.prototype.showModal = vi.fn(function ( + this: HTMLDialogElement, + ) { + this.setAttribute("open", ""); + }); + const lifecycle = capability(); + const gate = deferred(); + lifecycle.run.mockImplementationOnce(() => gate.promise); + const completed = vi.fn(); + const user = userEvent.setup(); + const view = render( + {}} + completed={completed} + />, + ); + expect(lifecycle.run).not.toHaveBeenCalled(); + const label = { + leave: "Leave channel", + hide: "Hide conversation", + archive: "Archive channel", + }[action]; + await user.click(screen.getByRole("button", { name: label })); + expect(lifecycle.run).toHaveBeenCalledWith( + action, + "id", + expect.any(AbortSignal), + ); + const signal = vi.mocked(lifecycle.run).mock.calls[0]?.[2]; + view.unmount(); + expect(signal?.aborted).toBe(true); + gate.resolve(); + await gate.promise; + expect(completed).not.toHaveBeenCalled(); + }, +); +it("uncertain delivery keeps the dialog recoverable without offering blind resubmission", async () => { + HTMLDialogElement.prototype.showModal = vi.fn(function ( + this: HTMLDialogElement, + ) { + this.setAttribute("open", ""); + }); + const lifecycle = capability(); + lifecycle.run.mockRejectedValueOnce( + new ChannelLifecycleUnconfirmed("connection lost"), + ); + const user = userEvent.setup(); + const close = vi.fn(); + render( + {}} + />, + ); + const confirm = screen.getByRole("button", { + name: "Leave channel", + }) as HTMLButtonElement; + await user.click(confirm); + expect((await screen.findByRole("alert")).textContent).toContain( + "may have taken effect", + ); + expect(confirm.disabled).toBe(true); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(close).toHaveBeenCalledOnce(); + expect(lifecycle.run).toHaveBeenCalledOnce(); +}); diff --git a/src/bundled/channels/ChannelLifecycleMenu.tsx b/src/bundled/channels/ChannelLifecycleMenu.tsx new file mode 100644 index 00000000..aa030a2e --- /dev/null +++ b/src/bundled/channels/ChannelLifecycleMenu.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState } from "react"; +import { MenuItem } from "../../shared/design-system/ui/Menu"; +import type { ChannelLifecycleCapability } from "../../features/relay/channel-lifecycle"; +import type { + ChannelLifecycleAction, + ChannelLifecycleSettings, +} from "../../features/relay/channel-lifecycle-protocol"; + +/** Mounted only while a menu is open: no per-row/background capability reads. */ +export function ChannelLifecycleMenu({ + channelId, + lifecycle, + choose, + disabled, +}: { + channelId: string; + lifecycle: ChannelLifecycleCapability; + choose(action: ChannelLifecycleAction): void; + disabled: boolean; +}) { + const [state, setState] = useState(); + const [failed, setFailed] = useState(false); + const [retry, setRetry] = useState(0); + // biome-ignore lint/correctness/useExhaustiveDependencies: explicit retry starts a fresh permission lookup. + useEffect(() => { + if (!lifecycle.available) return; + const controller = new AbortController(); + setState(undefined); + setFailed(false); + void lifecycle.load(channelId, controller.signal).then( + (settings) => { + if (!controller.signal.aborted) setState(settings); + }, + () => { + if (!controller.signal.aborted) setFailed(true); + }, + ); + return () => controller.abort(); + }, [channelId, lifecycle, retry]); + if (!lifecycle.available) + return ( + + Channel actions unavailable on this connection + + ); + if (failed) + return ( + <> +

Channel actions unavailable

+ setRetry((value) => value + 1)} + > + Retry channel permissions + + + ); + if (!state) return null; + return ( + <> + {state.canHide ? ( + choose("hide")}> + Hide conversation + + ) : ( + <> + {state.canArchive && ( + choose("archive")}> + Archive channel + + )} + {state.canDelete && ( + choose("delete")}> + Delete channel + + )} + {state.canLeave && ( + choose("leave")}> + Leave channel + + )} + + )} + + ); +} diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css index b36a01f7..b15d621e 100644 --- a/src/bundled/channels/Channels.module.css +++ b/src/bundled/channels/Channels.module.css @@ -152,6 +152,29 @@ color: var(--text); font-weight: var(--type-weight-medium); } +.channelRow { + position: relative; + display: flex; + align-items: center; + border-radius: var(--radius-row); +} +.channelRow .channelLink { + min-width: 0; + flex: 1; +} +.channelRow:has(.channelLink:hover), +.channelRow:has(.channelLink[aria-current="page"]), +.channelRow[data-menu-open="true"] { + background: var(--surface-hover); +} +.channelRow .channelLink:hover, +.channelRow .channelLink[aria-current="page"] { + background: transparent; +} +.channelRow:has(.channelLink[aria-current="page"]) { + background: var(--surface-accent); +} + .preferenceNotice { padding: var(--space-2); color: var(--text-muted); diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 97a6c53b..021e5a96 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1,3 +1,6 @@ +import { ChannelLifecycleMenu } from "./ChannelLifecycleMenu"; +import { ChannelLifecycleDialog } from "./ChannelLifecycleDialog"; +import type { ChannelLifecycleAction } from "../../features/relay/channel-lifecycle-protocol"; import { useChannelPanels } from "./useChannelPanels"; import type { PageNavigation } from "../../features/navigation/service"; import type { Navigation } from "../../features/navigation/controller"; @@ -22,13 +25,14 @@ import { import { Hash, Search, - MoreHorizontal, PlugZap, MessageCircle, + MoreHorizontal, Users, } from "lucide-react"; import type { RelayData } from "../../features/relay/service"; import type { RelaySession } from "../../features/relay/session"; +import type { ChannelSummary } from "../../features/relay/contracts"; import { useChannelList, useChannelWindow, @@ -48,6 +52,18 @@ import { useChannelLabels } from "./useChannelLabels"; import { useSidebarPreferences } from "./useSidebarPreferences"; import { useSidebarView } from "./useSidebarView"; import { sidebarSections } from "./sidebar-sections"; +import { + ContextMenuRoot, + ContextMenuTrigger, + MenuGroup, + MenuGroupLabel, + MenuIcon, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, +} from "../../shared/design-system/ui/Menu"; import styles from "./Channels.module.css"; export function ChannelsPage({ @@ -145,6 +161,21 @@ function ChannelWorkspace({ }) { const list = useChannelList(queries.channels); const preferences = useSidebarPreferences(queries.sidebarPreferences); + const lifecycle = queries.channelLifecycle; + const dmVisibility = useSyncExternalStore( + lifecycle.subscribe, + lifecycle.snapshot, + lifecycle.snapshot, + ); + // biome-ignore lint/correctness/useExhaustiveDependencies: a completed roster refresh also refreshes per-viewer visibility. + useEffect(() => { + if (list.status === "ready") void lifecycle.refreshVisibility(); + }, [lifecycle, list.asOf, list.status]); + const [lifecycleDialog, setLifecycleDialog] = useState<{ + channel: ChannelSummary; + action: ChannelLifecycleAction; + }>(); + const lifecycleFocus = useRef(undefined); useEffect(() => { if (list.status === "ready") void queries.unread.ensure(); }, [queries, list.status]); @@ -179,19 +210,63 @@ function ChannelWorkspace({ messageId: string; }>(); const threadTrigger = useRef(null); + const [rowMenu, setRowMenu] = useState<{ + channel: ChannelSummary; + sectionId?: string; + anchor?: HTMLElement; + }>(); + const rowMenuGeneration = useRef(0); + const [groupWrite, setGroupWrite] = useState<{ + channelId: string; + pending: boolean; + error?: string; + }>(); + const [rowFocus, setRowFocus] = useState<{ + channelId: string; + sectionKey: string; + }>(); const [sent, setSent] = useState<{ channelId: string; id: string }>(); const sidebar = useSidebarView( scope, list.status === "ready" && preferences.status !== "loading", ); const { search } = sidebar; - const channels = useChannelLabels(list.channels, queries.profiles); + const labelled = useChannelLabels(list.channels, queries.profiles); + const channels = useMemo( + () => + labelled.filter( + (channel) => + !channel.archived && + (channel.channelType !== "dm" || + !dmVisibility.hidden.includes(channel.id)), + ), + [labelled, dmVisibility.hidden], + ); + useLayoutEffect(() => { + if (!lifecycleFocus.current || lifecycleDialog) return; + const id = lifecycleFocus.current; + lifecycleFocus.current = undefined; + const origin = sidebar.list.current?.querySelector( + `[data-channel-id="${CSS.escape(id)}"]`, + ); + const fallback = + sidebar.list.current?.querySelector( + "[data-channel-id]", + ); + // Hidden/collapsed sections have no focusable row; leave an accessible fallback. + const target = origin?.getClientRects().length ? origin : fallback; + if (target?.getClientRects().length) target.focus({ preventScroll: true }); + else + sidebar.list.current?.closest("aside")?.querySelector("input")?.focus(); + }, [lifecycleDialog, sidebar.list]); const requestedChannel = navigation?.target.kind === "conversation" ? navigation.target.channelId : undefined; const current = requestedChannel - ? (channels.find((channel) => channel.id === requestedChannel) ?? + ? (labelled.find( + (channel) => channel.id === requestedChannel && !channel.archived, + ) ?? (list.coverage === "partial" ? { id: requestedChannel, name: "Conversation" } : undefined)) @@ -474,10 +549,113 @@ function ChannelWorkspace({ ), [channels, search], ); + const closeRowMenu = useCallback(() => { + rowMenuGeneration.current++; + setRowMenu(undefined); + setGroupWrite(undefined); + }, []); + useLayoutEffect(() => { + if (!rowFocus) return; + const destination = sidebar.list.current?.querySelector( + `[data-sidebar-section="${CSS.escape(rowFocus.sectionKey)}"]`, + ); + const link = destination?.querySelector( + `[data-channel-id="${CSS.escape(rowFocus.channelId)}"]`, + ); + link?.focus({ preventScroll: true }); + setRowFocus(undefined); + }, [rowFocus, sidebar.list]); + const openRowMenu = useCallback( + (channel: ChannelSummary, sectionId?: string, anchor?: HTMLElement) => { + rowMenuGeneration.current++; + setGroupWrite(undefined); + setRowMenu({ + channel, + ...(sectionId ? { sectionId } : {}), + ...(anchor ? { anchor } : {}), + }); + }, + [], + ); + const assignGroup = async (channelId: string, sectionId?: string) => { + const generation = rowMenuGeneration.current; + setGroupWrite({ channelId, pending: true }); + try { + await preferences.assign(channelId, sectionId); + if (generation !== rowMenuGeneration.current) return; + const sectionKey = sectionId ? `group:${sectionId}` : "channels"; + sidebar.toggle(sectionKey, true); + setRowFocus({ channelId, sectionKey }); + closeRowMenu(); + } catch (error) { + if (generation !== rowMenuGeneration.current) return; + setGroupWrite({ + channelId, + pending: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + const setChannelStar = async (channelId: string, starred: boolean) => { + const generation = rowMenuGeneration.current; + setGroupWrite({ channelId, pending: true }); + try { + await preferences.setStar(channelId, starred); + if (generation !== rowMenuGeneration.current) return; + const sectionKey = starred ? "starred" : "channels"; + sidebar.toggle(sectionKey, true); + setRowFocus({ channelId, sectionKey }); + closeRowMenu(); + } catch (error) { + if (generation !== rowMenuGeneration.current) return; + setGroupWrite({ + channelId, + pending: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }; return (
+ {lifecycleDialog && ( + { + lifecycleFocus.current = lifecycleDialog.channel.id; + setLifecycleDialog(undefined); + }} + completed={() => { + lifecycleFocus.current = lifecycleDialog.channel.id; + const id = lifecycleDialog.channel.id; + setLifecycleDialog(undefined); + if ( + current?.id === id || + requestedChannel === id || + selected === id + ) { + const next = channels.find( + (channel) => channel.id !== id && !channel.archived, + ); + if (next) select(next.id); + else { + setSelected(undefined); + writeView(scope, "selected-channel", undefined); + void navigator?.open({ + version: 1, + kind: "page", + pluginId: "buzz.channels", + pageId: "channels", + }); + } + } + }} + /> + )}