diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index 317c681d6..e9058baa1 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -1,6 +1,11 @@ import { uploadAttachment, UploadError } from "./attachment-upload.mjs"; import { validChannelCommand } from "./session-commands.mjs"; import { SocketRequestError } from "../src/features/relay/socket-requests.ts"; +import { assertSidebarSortIntent, mutateSidebarSort } from "./sidebar-sort.mjs"; +import { + assertSidebarStarIntent, + mutateSidebarStar, +} from "./sidebar-stars.mjs"; import { validateWorkflowEvent, WORKFLOW_KINDS, @@ -25,6 +30,8 @@ import { import { readAgentLibrary } from "./agent-library.mjs"; import { decodeSidebarPreferences, + assertSidebarAssignmentIntent, + mutateSidebarAssignment, SIDEBAR_REQUEST_BYTES, SIDEBAR_UPLOAD_MS, SIDEBAR_UPLOAD_SLOTS, @@ -65,6 +72,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; @@ -305,6 +313,29 @@ export function validAgentEnrollment(event) { ) ); } +export function validChannelActivityFilters(filters) { + return ( + Array.isArray(filters) && + filters.length >= 1 && + filters.length <= 128 && + filters.every( + (filter) => + filter && + typeof filter === "object" && + filter.limit === 1 && + Array.isArray(filter.kinds) && + filter.kinds.length === 4 && + [9, 40002, 45001, 45003].every((kind) => filter.kinds.includes(kind)) && + Array.isArray(filter["#h"]) && + filter["#h"].length === 1 && + typeof filter["#h"][0] === "string" && + /^[a-zA-Z0-9_-]{1,128}$/.test(filter["#h"][0]) && + Object.keys(filter).every((key) => + ["kinds", "#h", "limit"].includes(key), + ), + ) + ); +} export function validFilters(filters) { return ( Array.isArray(filters) && @@ -366,6 +397,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(); @@ -410,6 +462,7 @@ export function relayBrokerPlugin({ let sidebarUploads = 0; let attachmentUploads = 0; let libraryRead; + const sidebarMutations = new Map(); const streams = new Map(); const admissions = createHostAdmission(); server.httpServer?.once("close", () => { @@ -596,6 +649,167 @@ export function relayBrokerPlugin({ sidebarUploads--; } } + if ( + [ + "/api/relay/sidebar-assignment", + "/api/relay/sidebar-star", + "/api/relay/sidebar-sort", + ].includes(route) && + req.method === "POST" + ) { + const sorting = route === "/api/relay/sidebar-sort"; + const starring = route === "/api/relay/sidebar-star"; + let raw = ""; + for await (const part of req) { + raw += part; + if (Buffer.byteLength(raw) > (sorting ? 32 * 1024 : 2048)) + return json(res, 413, { + error: `Sidebar preference intent is too large`, + }); + } + let intent; + try { + intent = JSON.parse(raw); + if (sorting) assertSidebarSortIntent(intent); + else 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": [ + sorting + ? "channel-sort" + : 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", + ); + }; + if (sorting) + return { + groups: await mutateSidebarSort( + intent, + key, + readHead, + publishEvent, + ), + }; + 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. @@ -628,6 +842,10 @@ export function relayBrokerPlugin({ attachmentUploads: true, sidebarPreferences: true, readState: true, + sidebarPreferenceWrites: true, + sidebarStarWrites: true, + sidebarSortWrites: true, + channelActivity: true, agentLibrary: true, live: true, presence: true, @@ -1036,6 +1254,7 @@ export function relayBrokerPlugin({ ![ "/api/relay/query", "/api/relay/presence-snapshot", + "/api/relay/channel-activity", "/api/relay/sign", "/api/relay/publish", "/api/relay/read-state-sign", @@ -1098,6 +1317,9 @@ export function relayBrokerPlugin({ }); } } + const channelActivity = route === "/api/relay/channel-activity"; + if (channelActivity && !validChannelActivityFilters(filters)) + return json(res, 400, { error: "Activity filter rejected" }); const profile = route === "/api/relay/profile"; const claim = route === "/api/relay/claim"; const policy = route === "/api/relay/accept-policy"; @@ -1252,6 +1474,7 @@ export function relayBrokerPlugin({ !workflowPath && !readPublishing && !snapshot && + !channelActivity && !validFilters(filters) ) return json(res, 400, { error: "Read filter rejected" }); @@ -1382,8 +1605,9 @@ export function relayBrokerPlugin({ lane, request, requestSignal, - route === "/api/relay/query" && - req.headers["x-buzz-read-priority"] === "background" + channelActivity || + (route === "/api/relay/query" && + req.headers["x-buzz-read-priority"] === "background") ? "background" : "foreground", ); diff --git a/dev/sidebar-create-section.test.mjs b/dev/sidebar-create-section.test.mjs new file mode 100644 index 000000000..175ab9fa3 --- /dev/null +++ b/dev/sidebar-create-section.test.mjs @@ -0,0 +1,136 @@ +import { expect, it } from "vitest"; +import { + finalizeEvent, + generateSecretKey, + getPublicKey, + nip44, +} from "nostr-tools"; +import { + assertSidebarAssignmentIntent, + prepareSidebarAssignment, + mutateSidebarAssignment, +} from "./sidebar-preferences.mjs"; + +const secret = generateSecretKey(); +const id = "12345678-1234-1234-1234-123456789abc"; +const intent = { channelId: "alpha", createSection: { id, name: " Launch " } }; +function event(blob) { + const key = nip44.v2.utils.getConversationKey(secret, getPublicKey(secret)); + try { + return finalizeEvent( + { + kind: 30078, + created_at: 5, + tags: [["d", "channel-sections"]], + content: nip44.v2.encrypt(JSON.stringify(blob), key), + }, + secret, + ); + } finally { + key.fill(0); + } +} +function decode(record) { + const key = nip44.v2.utils.getConversationKey(secret, getPublicKey(secret)); + try { + return JSON.parse(nip44.v2.decrypt(record.content, key)); + } finally { + key.fill(0); + } +} +it("creates and assigns together, preserving raw unrelated fields and an idempotent retry", () => { + const blob = { + version: 1, + future: { enabled: true }, + sections: [{ id: "work", name: "Work", order: 3, future: "keep" }], + assignments: { beta: "work", hidden: "missing" }, + }; + const created = prepareSidebarAssignment([event(blob)], intent, secret); + expect(decode(created.event)).toEqual({ + ...blob, + sections: [...blob.sections, { id, name: "Launch", order: 4 }], + assignments: { ...blob.assignments, alpha: id }, + }); + expect( + prepareSidebarAssignment([created.event], intent, secret).event, + ).toBeUndefined(); + expect(() => + prepareSidebarAssignment( + [created.event], + { ...intent, createSection: { id, name: "Different" } }, + secret, + ), + ).toThrow("changed"); +}); +it.each([ + { ...intent, sectionId: "work" }, + { ...intent, createSection: { id, name: " " } }, + { ...intent, createSection: { id, name: "a".repeat(257) } }, + { ...intent, createSection: { id: "__proto__", name: "Launch" } }, + { ...intent, createSection: { id, name: "Launch", icon: "unexpected" } }, + { ...intent, createSection: [] }, + { ...intent, createSection: null }, +])("rejects malformed create intent before writing: %j", (value) => { + expect(() => assertSidebarAssignmentIntent(value)).toThrow("Invalid"); +}); +it("enforces section limits and refuses an invalid or unreadable current head", () => { + const full = { + version: 1, + sections: Array.from({ length: 100 }, (_, i) => ({ + id: String(i), + name: String(i), + order: i, + })), + assignments: {}, + }; + expect(() => prepareSidebarAssignment([event(full)], intent, secret)).toThrow( + "Unsupported", + ); + expect(() => + prepareSidebarAssignment([event({ ...full, version: 2 })], intent, secret), + ).toThrow("Unsupported"); + const unreadable = event({ version: 1, sections: [], assignments: {} }); + expect(() => + prepareSidebarAssignment( + [{ ...JSON.parse(JSON.stringify(unreadable)), content: "broken" }], + intent, + secret, + ), + ).toThrow("Invalid"); +}); +it("failed confirmation may follow publication; retry confirms the same section without another write", async () => { + let head; + let reads = 0; + let publications = 0; + const read = async () => { + if (++reads === 2) throw new Error("confirmation offline"); + return head ? [head] : []; + }; + const publish = async (value) => { + publications++; + head = value; + }; + await expect( + mutateSidebarAssignment(intent, secret, read, publish), + ).rejects.toThrow("confirmation offline"); + const groups = await mutateSidebarAssignment(intent, secret, read, publish); + expect(groups.sections).toEqual([{ id, name: "Launch", order: 0 }]); + expect(groups.assignments).toEqual({ alpha: id }); + expect(publications).toBe(1); +}); +it("a failed initial read never seeds or publishes", async () => { + let publications = 0; + await expect( + mutateSidebarAssignment( + intent, + secret, + async () => { + throw new Error("offline"); + }, + async () => { + publications++; + }, + ), + ).rejects.toThrow("offline"); + expect(publications).toBe(0); +}); diff --git a/dev/sidebar-group-moves.test.mjs b/dev/sidebar-group-moves.test.mjs new file mode 100644 index 000000000..8360c38d2 --- /dev/null +++ b/dev/sidebar-group-moves.test.mjs @@ -0,0 +1,310 @@ +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", "channels"]); + } 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().data).toEqual(before.data); + expect(h.prefs.snapshot().moves).toEqual([ + expect.objectContaining({ pending: false, error: expect.any(String) }), + ]); + 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().data).toEqual(before.data); + expect(h.prefs.snapshot().moves).toEqual([ + expect.objectContaining({ pending: false, error: expect.any(String) }), + ]); + 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 optimistic = h.prefs.snapshot(); + expect(optimistic.data.assignments).toEqual({ beta: "work" }); + expect(optimistic.data.starred).toEqual([]); + const refresh = h.prefs.refresh(); + expect(h.prefs.snapshot()).toBe(optimistic); + expect(before.data.starred).toEqual(["alpha"]); + 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: "ready", + moves: [ + expect.objectContaining({ pending: false, 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(); + } +}); + +it("create-and-move from Starred retries one durable section after partial failure", async () => { + const h = await setup(); + const section = { + id: "12345678-1234-1234-1234-123456789abc", + name: "Launch", + }; + try { + const before = h.prefs.snapshot(); + h.star.mockRejectedValueOnce(new Error("star rejected")); + await expect(h.prefs.createAndAssign("alpha", section)).rejects.toThrow( + "star rejected", + ); + expect(h.prefs.snapshot().data).toEqual(before.data); + expect(h.prefs.snapshot().moves).toEqual([ + expect.objectContaining({ pending: false, error: expect.any(String) }), + ]); + expect(h.publications).toEqual(["channel-sections"]); + await h.prefs.createAndAssign("alpha", section); + expect(h.publications).toEqual(["channel-sections", "channel-stars"]); + const restored = await h.read(); + expect(restored.sections).toHaveLength(2); + expect(restored.assignments).toEqual({ alpha: section.id, beta: "work" }); + expect(restored.starred).toEqual([]); + expect(h.prefs.snapshot().data).toEqual(restored); + } 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 000000000..0d04baed3 --- /dev/null +++ b/dev/sidebar-preference-writes.test.mjs @@ -0,0 +1,322 @@ +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 { prepareSidebarSort } from "./sidebar-sort.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; + let activityEvents = []; + 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; + if (body[0]["#h"]) return Response.json(activityEvents); + 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, + setActivity(events) { + activityEvents = events; + }, + transport, + calls, + heads, + failQuery(value) { + queryFailure = value; + }, + failPublication(value) { + publicationFailure = value; + }, + conflict() { + conflict = true; + }, + post(value, origin, route = "sidebar-star") { + return fetch(`${base}/api/relay/${route}`, { + 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( + [], + ); + }, +); + +it("real broker sorting preserves saved-section keys in transport confirmation and removes A–Z overrides", async () => { + const h = await harness(); + const signal = new AbortController().signal; + h.heads.set( + "channel-sort", + prepareSidebarSort( + [], + { group: "forums", mode: "recent", sectionIds: [] }, + h.key, + ).event, + ); + expect( + await h.transport.writeSidebarSort( + "section:work", + "recent", + ["work"], + signal, + ), + ).toEqual({ forums: "recent", "section:work": "recent" }); + 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-sort"], limit: 1 }, + ]); + expect( + await h.transport.writeSidebarSort( + "section:work", + "alpha", + ["work"], + signal, + ), + ).toEqual({ forums: "recent" }); +}); + +it.each(["query", "oversized", "publication", "receipt", "conflict"])( + "does not confirm sidebar sorting 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.writeSidebarSort( + "channels", + "recent", + [], + new AbortController().signal, + ), + ).rejects.toThrow(); + if (["query", "oversized"].includes(failure)) + expect(h.calls.filter((call) => call.url.endsWith("/events"))).toEqual( + [], + ); + }, +); + +it("activity uses the purpose-bound 128-channel broker route without widening generic query admission", async () => { + const h = await harness(); + const ids = Array.from({ length: 128 }, (_, i) => `room-${i}`); + expect( + await h.transport.channelActivity(ids, new AbortController().signal), + ).toEqual([]); + const filters = h.calls[0].body; + expect(filters).toEqual( + ids.map((id) => ({ + kinds: [9, 40002, 45001, 45003], + "#h": [id], + limit: 1, + })), + ); + const before = h.calls.length; + for (const [route, body] of [ + ["query", filters], + ["channel-activity", [...filters, filters[0]]], + ["channel-activity", [{ ...filters[0], limit: 2 }]], + ["channel-activity", [{ ...filters[0], kinds: [0] }]], + ["channel-activity", [{ ...filters[0], authors: [h.viewer] }]], + ]) + expect((await h.post(body, undefined, route)).status).toBe(400); + expect(h.calls).toHaveLength(before); + h.setActivity([{ kind: 9, content: "unsigned" }]); + await expect( + h.transport.channelActivity(["room-0"], new AbortController().signal), + ).rejects.toThrow(); +}); + +it("real broker creates and assigns a section through the signed, confirmed narrow command", async () => { + const h = await harness(); + const intent = { + channelId: "alpha", + createSection: { + id: "12345678-1234-1234-1234-123456789abc", + name: "Launch", + }, + }; + const result = await h.transport.writeSidebarAssignment( + intent, + new AbortController().signal, + ); + expect(result).toEqual({ + sections: [{ ...intent.createSection, order: 0 }], + assignments: { alpha: intent.createSection.id }, + }); + expect(h.calls.map(({ url }) => new URL(url).pathname)).toEqual([ + "/query", + "/events", + "/query", + ]); + expect( + await h.transport.writeSidebarAssignment( + intent, + new AbortController().signal, + ), + ).toEqual(result); + expect(h.calls.filter(({ url }) => url.endsWith("/events"))).toHaveLength(1); + const before = h.calls.length; + expect( + ( + await h.post( + { ...intent, sectionId: "work" }, + undefined, + "sidebar-assignment", + ) + ).status, + ).toBe(400); + expect(h.calls).toHaveLength(before); +}); diff --git a/dev/sidebar-preferences.d.mts b/dev/sidebar-preferences.d.mts new file mode 100644 index 000000000..05988dcad --- /dev/null +++ b/dev/sidebar-preferences.d.mts @@ -0,0 +1,28 @@ +import type { + SidebarAssignmentIntent, + 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 SidebarAssignmentIntent; +export function prepareSidebarAssignment( + events: readonly RelayEvent[], + intent: SidebarAssignmentIntent, + secret: Uint8Array, + now?: number, +): { groups: SidebarGroups; event?: RelayEvent }; +export function mutateSidebarAssignment( + intent: SidebarAssignmentIntent, + 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 86e334bf1..0d4db8c26 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, @@ -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,8 +49,176 @@ export function decodeSidebarPreferences(events, secret) { return projectSidebarPreferences( decoded.get("channel-sections"), decoded.get("channel-stars"), + decoded.get("channel-sort"), ); } finally { 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)) && + (intent.createSection === undefined || + (intent.sectionId === undefined && + intent.createSection && + typeof intent.createSection === "object" && + !Array.isArray(intent.createSection) && + typeof intent.createSection.id === "string" && + /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test( + intent.createSection.id, + ) && + typeof intent.createSection.name === "string" && + intent.createSection.name.trim().length > 0 && + intent.createSection.name.length <= 256 && + Object.keys(intent.createSection).every((key) => + ["id", "name"].includes(key), + ))) && + Object.keys(intent).every((key) => + ["channelId", "sectionId", "createSection"].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); + const sectionId = intent.createSection?.id ?? intent.sectionId; + let sections = current.blob.sections; + let created = false; + if (intent.createSection) { + const name = intent.createSection.name.trim(); + const existing = sections.find((section) => section.id === sectionId); + // The dialog retains one ID across retries, including an unknown publish + // outcome. Never duplicate or silently rename a section on retry. + if (existing && existing.name !== name) + throw new Error("The new section changed; reload and try again"); + if (!existing) { + const order = + Math.max(-1, ...sections.map((section) => section.order)) + 1; + sections = [...sections, { id: sectionId, name, order }]; + created = true; + } + } + if ( + sectionId !== undefined && + !sections.some((section) => section.id === sectionId) + ) + throw new Error("Sidebar group no longer exists"); + const assignments = { + ...current.blob.assignments, + ...(sectionId === undefined ? {} : { [intent.channelId]: sectionId }), + }; + if (sectionId === undefined) delete assignments[intent.channelId]; + const blob = { ...current.blob, sections, assignments }; + const groups = projectSidebarPreferences(blob, undefined); + if (Buffer.byteLength(JSON.stringify(blob)) > 128 * 1024) + throw new Error("Sidebar plaintext budget exceeded"); + const previous = Object.hasOwn(current.blob.assignments, intent.channelId) + ? current.blob.assignments[intent.channelId] + : undefined; + if (!created && previous === 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-sort.mjs b/dev/sidebar-sort.mjs new file mode 100644 index 000000000..1ccb15541 --- /dev/null +++ b/dev/sidebar-sort.mjs @@ -0,0 +1,125 @@ +import { finalizeEvent, getPublicKey, nip44, verifyEvent } from "nostr-tools"; +import { projectSidebarPreferences } from "../src/features/relay/sidebar-preferences.ts"; +import { SIDEBAR_REQUEST_BYTES } from "./sidebar-preferences.mjs"; + +const SORT_COORDINATE = "channel-sort"; +const SORT_KEYS = new Set(["starred", "channels", "forums", "dms"]); +function validSortGroup(group, sectionIds) { + return ( + SORT_KEYS.has(group) || + (group.startsWith("section:") && sectionIds.includes(group.slice(8))) + ); +} +export function assertSidebarSortIntent(intent) { + if ( + !intent || + typeof intent !== "object" || + Array.isArray(intent) || + typeof intent.group !== "string" || + intent.group.length > 264 || + !["alpha", "recent"].includes(intent.mode) || + !Array.isArray(intent.sectionIds) || + intent.sectionIds.length > 100 || + intent.sectionIds.some( + (id) => typeof id !== "string" || !id.trim() || id.length > 256, + ) || + !validSortGroup(intent.group, intent.sectionIds) || + Object.keys(intent).some( + (key) => !["group", "mode", "sectionIds"].includes(key), + ) + ) + throw new Error("Invalid sidebar sort intent"); +} +function parseSortEvent(events, secret, sectionIds) { + if ( + !Array.isArray(events) || + events.length > 1 || + Buffer.byteLength(JSON.stringify(events)) > SIDEBAR_REQUEST_BYTES + ) + throw new Error("Invalid sidebar sort head"); + if (!events.length) return { blob: { version: 1, groups: {} }, 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] !== SORT_COORDINATE || + typeof event.content !== "string" || + !verifyEvent(event) + ) + throw new Error("Invalid sidebar sort 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(undefined, undefined, blob, sectionIds); + return { blob, createdAt: event.created_at }; + } finally { + key.fill(0); + } +} +export function prepareSidebarSort(events, intent, secret, now = Date.now()) { + assertSidebarSortIntent(intent); + const viewer = getPublicKey(secret); + const current = parseSortEvent(events, secret, intent.sectionIds); + const groups = { ...current.blob.groups }; + if (intent.mode === "alpha") delete groups[intent.group]; + else groups[intent.group] = intent.mode; + const projected = + projectSidebarPreferences( + undefined, + undefined, + { ...current.blob, groups }, + intent.sectionIds, + ).sort ?? {}; + if ( + Object.keys(groups).length === Object.keys(current.blob.groups).length && + Object.entries(groups).every( + ([group, mode]) => current.blob.groups[group] === mode, + ) + ) + return { groups: projected }; + const key = nip44.v2.utils.getConversationKey(secret, viewer); + let content; + try { + content = nip44.v2.encrypt( + JSON.stringify({ ...current.blob, groups }), + key, + ); + } finally { + key.fill(0); + } + return { + groups: projected, + event: finalizeEvent( + { + kind: 30078, + content, + created_at: Math.max(Math.floor(now / 1000), current.createdAt + 1), + tags: [ + ["d", SORT_COORDINATE], + ["t", SORT_COORDINATE], + ], + }, + secret, + ), + }; +} +export async function mutateSidebarSort(intent, secret, readHead, publish) { + assertSidebarSortIntent(intent); + const draft = prepareSidebarSort(await readHead(), intent, secret); + if (!draft.event) return draft.groups; + await publish(draft.event); + const confirmation = prepareSidebarSort(await readHead(), intent, secret); + if (confirmation.event) + throw new Error( + "Sidebar sort changed on another device; reload and try again", + ); + return confirmation.groups; +} diff --git a/dev/sidebar-sort.test.mjs b/dev/sidebar-sort.test.mjs new file mode 100644 index 000000000..082df4b9f --- /dev/null +++ b/dev/sidebar-sort.test.mjs @@ -0,0 +1,167 @@ +import { expect, it, vi } from "vitest"; +import { + finalizeEvent, + generateSecretKey, + getPublicKey, + nip44, + verifyEvent, +} from "nostr-tools"; +import { prepareSidebarSort, mutateSidebarSort } from "./sidebar-sort.mjs"; +import { projectSidebarPreferences } from "../src/features/relay/sidebar-preferences.ts"; +function harness() { + const secret = generateSecretKey(), + viewer = getPublicKey(secret); + const key = nip44.v2.utils.getConversationKey(secret, viewer); + return { + secret, + viewer, + encrypt(blob, overrides = {}) { + return finalizeEvent( + { + kind: 30078, + created_at: 100, + tags: [["d", "channel-sort"]], + content: nip44.v2.encrypt(JSON.stringify(blob), key), + ...overrides, + }, + secret, + ); + }, + decode(event) { + return JSON.parse(nip44.v2.decrypt(event.content, key)); + }, + }; +} +const intent = { group: "section:work", mode: "recent", sectionIds: ["work"] }; +it("mutates only the selected override, preserves unprojected data, and encodes alpha as absence", () => { + const h = harness(); + const blob = { + version: 1, + future: { x: 1 }, + groups: { + channels: "recent", + "section:elsewhere": "recent", + future: "next-mode", + }, + }; + const draft = prepareSidebarSort([h.encrypt(blob)], intent, h.secret, 50_000); + expect(verifyEvent(draft.event)).toBe(true); + expect(draft.event.created_at).toBe(101); + expect(h.decode(draft.event)).toEqual({ + ...blob, + groups: { ...blob.groups, "section:work": "recent" }, + }); + expect(draft.groups).toEqual({ + channels: "recent", + "section:work": "recent", + }); + const alpha = prepareSidebarSort( + [draft.event], + { ...intent, mode: "alpha" }, + h.secret, + 50_000, + ); + expect(h.decode(alpha.event)).toEqual(blob); + expect(alpha.event.created_at).toBe(102); + expect( + prepareSidebarSort([alpha.event], { ...intent, mode: "alpha" }, h.secret) + .event, + ).toBeUndefined(); +}); +it("rejects invalid intent before reading, and ambiguous/untrusted/invalid heads before publishing", async () => { + const h = harness(), + other = harness(); + const read = vi.fn(), + publish = vi.fn(); + for (const invalid of [ + null, + {}, + { ...intent, group: "group:work" }, + { ...intent, mode: "unknown" }, + { ...intent, sectionIds: [" "] }, + { ...intent, extra: 1 }, + ]) { + await expect( + mutateSidebarSort(invalid, h.secret, read, publish), + ).rejects.toThrow(); + } + expect(read).not.toHaveBeenCalled(); + const valid = h.encrypt({ version: 1, groups: {} }); + for (const heads of [ + null, + [valid, valid], + [other.encrypt({ version: 1, groups: {} })], + [{ ...JSON.parse(JSON.stringify(valid)), sig: "0".repeat(128) }], + [h.encrypt({ version: 2, groups: {} })], + [h.encrypt({ version: 1, groups: [] })], + [h.encrypt({ version: 1, groups: {} }, { tags: [["d", "channel-stars"]] })], + [h.encrypt({ version: 1, groups: {} }, { content: "not encrypted" })], + ]) { + await expect( + mutateSidebarSort(intent, h.secret, async () => heads, publish), + ).rejects.toThrow(); + } + expect(publish).not.toHaveBeenCalled(); +}); +it("confirms newer unrelated choices, rejects a replaced intent, and never seeds after a failed read", async () => { + const h = harness(); + let heads = []; + const read = vi.fn(async () => heads); + const publish = vi.fn(async () => { + heads = [ + h.encrypt({ + version: 1, + groups: { "section:work": "recent", forums: "recent" }, + }), + ]; + }); + expect(await mutateSidebarSort(intent, h.secret, read, publish)).toEqual({ + "section:work": "recent", + forums: "recent", + }); + expect(read).toHaveBeenCalledTimes(2); + await mutateSidebarSort(intent, h.secret, read, publish); + expect(publish).toHaveBeenCalledOnce(); + heads = []; + await expect( + mutateSidebarSort(intent, h.secret, read, async () => {}), + ).rejects.toThrow("changed on another device"); + await expect( + mutateSidebarSort( + intent, + h.secret, + async () => { + throw new Error("offline"); + }, + publish, + ), + ).rejects.toThrow("offline"); + expect(publish).toHaveBeenCalledOnce(); +}); +it("projection accepts full-length section keys, rejects over-budget data, and ignores unknown keys/modes", () => { + const id = "x".repeat(256); + expect( + projectSidebarPreferences( + undefined, + undefined, + { + version: 1, + groups: { + [`section:${id}`]: "recent", + channels: "recent", + future: "recent", + dms: "unknown", + }, + }, + [id], + ).sort, + ).toEqual({ [`section:${id}`]: "recent", channels: "recent" }); + expect(() => + projectSidebarPreferences(undefined, undefined, { + version: 1, + groups: Object.fromEntries( + Array.from({ length: 105 }, (_, i) => [`section:${i}`, "recent"]), + ), + }), + ).toThrow("budget exceeded"); +}); diff --git a/dev/sidebar-stars.mjs b/dev/sidebar-stars.mjs new file mode 100644 index 000000000..9e8b25f79 --- /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 000000000..e4e48572c --- /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 ebcff2343..239b0b2e2 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -73,9 +73,91 @@ 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. Failed moves roll back to the last fully +confirmed placement while preserving newer pending choices, and offer explicit +retry outside the closed menu. 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. **Move channel ▸** contains Starred, saved groups, +**Create new…**, and removal to Channels. Choosing a destination immediately +closes the menu, moves the row, expands its destination and restores focus by +channel identity. The root menu alone restores focus; nested popup cleanup must +not refocus a retired trigger. Saving runs silently in the background; only failed +saves expose Retry / Dismiss below the sidebar. Starred is pinned first; placement is exclusive. "Remove from Starred" +and "Remove from [group]" return to Channels, never to a remembered group. Choosing +the already-selected Starred or saved group performs that same removal. 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. +Pending intents overlay the last fully confirmed placement. Only a complete move +advances that baseline, and refreshes cannot expose an intermediate write. If the +second write fails, the channel rolls back unless a newer move superseded it; +explicit retry finishes the failed intent. 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. Create new names a section and assigns this channel in one encrypted +section-record write before clearing Star; retries reuse that section ID. It does +not create a channel. Forums/DMs assignment and general group CRUD/reorder remain +outside these actions. +Hosts without the write capabilities retain the read-only projection. + +Each section (Starred, saved groups, Channels, Forums and DMs) defaults to A–Z +and has an independent Recent choice in its shared **More actions → Sort** menu. +The session owns verified activity, not the page: Recent starts purpose-bound +background reads in batches of at most 128 channels for kinds 9, 40002, 45001 and +45003. Failed reads retain the last good projection; authoritative empty results +clear unchanged values, while newer verified live activity cannot roll back. +Unknown activity sorts last, with name/ID ties. Cache/access clearing, disconnect +and disposal fence pending results. These reads do not grant channel access or +populate timeline history, and no roster activity read starts for A–Z alone. +Forum kinds share the existing live channel route and its capacity limits; no +extra subscriptions are created. Restart an already-running development broker +to load that expanded filter. Channels beyond live capacity refresh recency when +the roster demand changes or the session reconnects, not via a polling loop. + +The encrypted `channel-sort` coordinate uses built-in keys or `section:`; +A–Z removes an override. The host verifies/decrypts the current head, preserves +unrelated raw entries, publishes only a changed intent, and re-reads to confirm. +The session applies sort choices optimistically and serializes writes with the +other preference commands. Failure rolls back only that intent, preserving newer +pending choices and unrelated preferences. Choosing a sort closes the menu immediately; +it can be reopened and changed again while saving. Only failures show Retry / Dismiss +below the sidebar, retained by the session across page exits. A newer choice for the +same section supersedes an older failure. +The same whole-record/multi-device limitations above apply. Hosts without the +sort writer retain read-only ordering. Native-adapter parity is not added here. + +Cold Messages entry uses one page-owned sidebar reveal boundary. After the roster +becomes available, it waits for saved preferences, roster metadata, the initial +unread repair and (only for Recent) activity settlement, for at most **1.5 seconds**. +Conversation opening and access checks do not wait for this presentation latch. +Failures count as settled; the existing owners retain their errors and last-good +state. At the deadline usable rows are revealed with an **Updating sidebar details…** +notice while outstanding work completes. Names, badges or order may still change +in this slow-dependency fallback; it is not a claim of complete unread history. +Warm page returns retain reveal and initial unread-settlement flags by session, +not copies of rows or access data. Community/viewer/connection replacement mounts +a fresh workspace; revoked rows disappear from the current roster immediately. +Optional participant-profile enrichment stays independent of sidebar readiness. + 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. Search lives in the top-bar +They are saved on page exit and restored before paint at the sidebar reveal +boundary; navigation history does not own them. Search lives in the top-bar palette; legacy sidebar filters are ignored. 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. diff --git a/src/bundled/channels/ChannelSidebarItem.tsx b/src/bundled/channels/ChannelSidebarItem.tsx index 7f39d002a..7525866cb 100644 --- a/src/bundled/channels/ChannelSidebarItem.tsx +++ b/src/bundled/channels/ChannelSidebarItem.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { memo, type ReactElement } from "react"; import type { ChannelSummary } from "../../features/relay/contracts"; import type { RelaySession } from "../../features/relay/session"; import { @@ -30,6 +30,7 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({ onNewSession, onOpenThread, onHideDm, + wrapSelect, }: { channel: ChannelSummary; session: RelaySession; @@ -45,6 +46,7 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({ onNewSession: (id: string) => void; onOpenThread: (channelId: string, rootId: string) => void; onHideDm?: (id: string) => void; + wrapSelect?: (trigger: ReactElement) => ReactElement; }) { const Icon = channel.channelType === "dm" @@ -52,6 +54,15 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({ ? UsersIcon : ChatCircleIcon : channelIcon(channel); + const activityTrigger = (trigger: ReactElement) => ( + onOpenThread(item.channelId, item.rootId)} + trigger={trigger} + /> + ); return ( } - wrapSelect={(trigger) => ( - onOpenThread(item.channelId, item.rootId)} - trigger={trigger} - /> - )} + wrapSelect={(trigger) => { + const activity = activityTrigger(trigger); + return wrapSelect ? wrapSelect(activity) : activity; + }} selected={selected} sessionsEnabled={sessionsEnabled} collapsed={collapsed} diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css index 06b5dd5ec..5e0a4bfb5 100644 --- a/src/bundled/channels/Channels.module.css +++ b/src/bundled/channels/Channels.module.css @@ -566,3 +566,8 @@ html[data-keyboard-navigation] border-radius: 50%; background: var(--affordance-accent-prominent); } + +.sectionSortAction { + flex-shrink: 0; + margin-block: calc(-1 * var(--space-1)); +} diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index badf98ab3..a18f28b9e 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -3,6 +3,8 @@ import { Panel } from "../../shared/design-system/ui/Panel"; import { PanelHeader } from "../../shared/design-system/ui/PanelHeader"; import { Button } from "../../shared/design-system/ui/Button"; import { IconButton } from "../../shared/design-system/ui/IconButton"; +import { CreateSidebarSection } from "./CreateSidebarSection"; +import { SidebarGroupIcon } from "./SidebarGroupIcon"; import { useChannelPanels } from "./useChannelPanels"; import { ChannelSettingsPanel } from "./ChannelSettingsPanel"; import type { PageNavigation } from "../../features/navigation/service"; @@ -33,16 +35,19 @@ import { useSyncExternalStore, type CSSProperties, type ReactNode, + type ReactElement, } from "react"; import { CaretRightIcon, DotsThreeIcon, + ArrowsDownUpIcon, PlugIcon, ChatCircleIcon, PlusIcon, } from "../../shared/design-system/icons/index"; import { channelIcon } from "../../features/channels/channel-icon"; import type { RelayData } from "../../features/relay/service"; +import type { ChannelSummary } from "../../features/relay/contracts"; import type { RelaySession } from "../../features/relay/session"; import { useChannelList, @@ -66,7 +71,6 @@ import { useChannelLabels } from "./useChannelLabels"; import { useSidebarPreferences } from "./useSidebarPreferences"; import { isChannelSectionKey, sidebarSections } from "./sidebar-sections"; import { useHiddenDms } from "./useHiddenDms"; -import { SidebarSectionIcon } from "./SidebarSectionIcon"; import { CreateChannelDialog, type CreateChannelInput, @@ -77,6 +81,24 @@ import { CHANNEL_SIDEBAR_MIN_WIDTH, useSidebarView, } from "./useSidebarView"; +import { useSidebarStartup } from "./useSidebarStartup"; +import { + ContextMenuRoot, + ContextMenuTrigger, + MenuGroup, + MenuGroupLabel, + MenuIcon, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuRoot, + MenuSubmenu, + MenuSubmenuPopup, + MenuSubmenuTrigger, + MenuTrigger, + MenuSeparator, +} from "../../shared/design-system/ui/Menu"; import styles from "./Channels.module.css"; export function ChannelsPage({ @@ -204,9 +226,7 @@ function ChannelWorkspace({ useEffect(() => { void queries.emoji.ensure(); }, [queries]); - useEffect(() => { - if (list.status === "ready") void queries.unread.ensure(); - }, [queries, list.status]); + const startup = useSidebarStartup(queries, list, preferences); const available = useSyncExternalStore( panels.subscribe, panels.snapshot, @@ -270,11 +290,20 @@ function ChannelWorkspace({ [navigate], ); const threadTrigger = useRef(null); + const [sectionMenu, setSectionMenu] = useState<{ key: string }>(); + const [rowMenu, setRowMenu] = useState<{ + channel: ChannelSummary; + sectionId?: string; + anchor?: HTMLElement; + }>(); + const pendingCreate = useRef(undefined); + const [creatingFor, setCreatingFor] = useState(); + 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 sidebar = useSidebarView(scope, startup.ready); const channels = useChannelLabels( list.channels, queries.profiles, @@ -800,11 +829,77 @@ function ChannelWorkspace({ const drawer = useChannelPanels(panels, drawerContext, () => setSettings(undefined), ); - const sections = sidebarSections( - channels, - preferences.data, - hiddenDms.hiddenIds, + const sections = startup.ready + ? sidebarSections(channels, preferences.data, hiddenDms.hiddenIds) + : []; + const closeSectionMenu = useCallback(() => setSectionMenu(undefined), []); + const setSectionSort = (key: string, mode: "alpha" | "recent") => { + // The session applies the choice immediately and owns rollback/retry state. + void preferences + .setSort( + key.startsWith("group:") ? `section:${key.slice(6)}` : key, + mode, + preferences.data?.sections.map((section) => section.id) ?? [], + ) + .catch(() => {}); + closeSectionMenu(); + }; + const closeRowMenu = useCallback(() => { + setRowMenu(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) => { + setRowMenu({ + channel, + ...(sectionId ? { sectionId } : {}), + ...(anchor ? { anchor } : {}), + }); + }, + [], ); + const focusChannelPlacement = (channelId: string) => { + const data = queries.sidebarPreferences.snapshot().data; + const sectionId = data?.assignments[channelId]; + const sectionKey = data?.starred.includes(channelId) + ? "starred" + : sectionId + ? `group:${sectionId}` + : "channels"; + sidebar.toggle(sectionKey, true); + setRowFocus({ channelId, sectionKey }); + }; + const moveChannel = ( + channelId: string, + operation: () => Promise, + ) => { + const saving = operation(); // Publishes optimistic placement synchronously. + closeRowMenu(); + focusChannelPlacement(channelId); + void saving.catch(() => { + // The session exposes retry even after page/menu unmount. Restore a row + // focus lost to rollback, but never steal focus from another control. + if ( + sidebar.list.current?.isConnected && + document.activeElement === document.body + ) + focusChannelPlacement(channelId); + }); + }; + const assignGroup = (channelId: string, sectionId?: string) => + moveChannel(channelId, () => preferences.assign(channelId, sectionId)); + const setChannelStar = (channelId: string, starred: boolean) => + moveChannel(channelId, () => preferences.setStar(channelId, starred)); return (
+ {creatingFor && ( + { + moveChannel(creatingFor.id, () => + preferences.createAndAssign(creatingFor.id, section), + ); + setCreatingFor(undefined); + }} + close={() => { + focusChannelPlacement(creatingFor.id); + setCreatingFor(undefined); + }} + /> + )}
@@ -822,6 +932,7 @@ function ChannelWorkspace({ return (
@@ -841,10 +952,7 @@ function ChannelWorkspace({ aria-hidden="true" /> {section.icon && ( - + )} {section.title} {showsCreateChannel && ( @@ -870,6 +978,85 @@ function ChannelWorkspace({ /> )} + {preferences.sortWritable && ( + { + if (open) { + closeSectionMenu(); + setSectionMenu({ key: section.key }); + } else if (sectionMenu?.key === section.key) + closeSectionMenu(); + }} + > + ( + + + )} + /> + + + + + + + Sort + + + + void setSectionSort( + section.key, + mode as "alpha" | "recent", + ) + } + > + + Recent + + + A–Z + + + + + + + )} {section.rows.map((channel) => { const sessions = childrenByParent.get(channel.id); @@ -878,7 +1065,21 @@ function ChannelWorkspace({ sessions?.some((child) => child.id === current?.id) ? current?.id : undefined; - return ( + const currentSectionId = section.key.startsWith("group:") + ? section.key.slice("group:".length) + : undefined; + const movable = + preferences.writable && + preferences.starWritable && + !!preferences.data && + channel.channelType !== "dm" && + channel.channelType !== "forum"; + const starred = section.key === "starred"; + const menuOpen = + movable && + rowMenu?.channel.id === channel.id && + rowMenu.sectionId === currentSectionId; + const channelButton = ( ( + { + if ( + event.key === "ContextMenu" || + (event.shiftKey && event.key === "F10") + ) { + event.preventDefault(); + openRowMenu( + channel, + currentSectionId, + event.currentTarget, + ); + } + }} + render={ +
+ } + > + {trigger} + + ), + } + : {})} /> ); + if (!movable) { + return channelButton; + } + return ( + { + if ( + !open && + pendingCreate.current?.id === channel.id + ) { + setCreatingFor(pendingCreate.current); + pendingCreate.current = undefined; + } + }} + onOpenChange={(open) => { + if (open) openRowMenu(channel, currentSectionId); + else if (menuOpen) closeRowMenu(); + }} + > + {channelButton} + + pendingCreate.current + ? false + : (sidebar.list.current?.querySelector( + `[data-channel-id="${CSS.escape(channel.id)}"]`, + ) ?? false) + } + > + + + Move channel + + + + Move to… + + { + if (destination === "starred") + void setChannelStar(channel.id, !starred); + else { + const groupId = destination.slice( + "group:".length, + ); + void assignGroup( + channel.id, + groupId === currentSectionId + ? undefined + : groupId, + ); + } + }} + > + + ★ + Starred + + {preferences.data?.sections.map((group) => ( + + {group.icon && ( + + + + )} + {group.name} + + ))} + + + { + pendingCreate.current = channel; + }} + > + +Create new… + + {(starred || currentSectionId) && ( + { + if (starred) + void setChannelStar(channel.id, false); + else void assignGroup(channel.id); + }} + > + Remove from {section.title} + + )} + + + + + ); })}
); })} - {list.status === "loading" && !list.channels.length && ( -

Loading your channels…

+ {!startup.ready && ( +

+ Loading your sidebar… +

)} {list.status === "error" && (

{list.error}

)} - {list.status === "ready" && !channels.length && ( + {startup.ready && list.status === "ready" && !channels.length && (

No channels yet.

)}
- {preferences.status === "error" ? ( + {startup.updating && ( +

+ Updating sidebar details… +

+ )} + {preferences.sortErrors?.map(({ group, mode, error }) => ( +
+

+ Couldn’t save the sort order for{" "} + {sections.find( + ({ key }) => + key === + (group.startsWith("section:") + ? `group:${group.slice(8)}` + : group), + )?.title ?? "this section"} + . {error} +

+ + +
+ ))} + {preferences.moves + ?.filter((move) => !move.pending) + .map((move) => ( +
+

+ Couldn’t save the move for{" "} + {channels.find(({ id }) => id === move.channelId)?.name ?? + "this channel"} + . {move.error} +

+

+ The previous placement is shown. A partial save may already + exist on the relay. +

+ + +
+ ))} + {startup.ready && preferences.status === "error" ? ( - ) : preferences.status !== "ready" ? ( + ) : startup.ready && preferences.status === "unsupported" ? (

- {preferences.status === "loading" - ? "Loading saved groups and stars…" - : "Saved groups and stars aren’t supported by this host yet."} + Saved groups and stars aren’t supported by this host yet.

) : null}
diff --git a/src/bundled/channels/CreateSidebarSection.module.css b/src/bundled/channels/CreateSidebarSection.module.css new file mode 100644 index 000000000..f6cb823ae --- /dev/null +++ b/src/bundled/channels/CreateSidebarSection.module.css @@ -0,0 +1,11 @@ +.description { + display: flex; + gap: var(--space-1); + min-width: 0; + white-space: nowrap; +} +.description > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/bundled/channels/CreateSidebarSection.tsx b/src/bundled/channels/CreateSidebarSection.tsx new file mode 100644 index 000000000..6319f5a3c --- /dev/null +++ b/src/bundled/channels/CreateSidebarSection.tsx @@ -0,0 +1,71 @@ +import { useId, useRef, useState } from "react"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Dialog } from "../../shared/design-system/ui/Dialog"; +import { Input } from "../../shared/design-system/ui/Input"; +import styles from "./CreateSidebarSection.module.css"; + +/** Mounted after the launching menu closes, so focus has one modal owner. */ +export function CreateSidebarSection({ + channelName, + create, + close, +}: { + channelName: string; + create: (section: { id: string; name: string }) => void; + close: () => void; +}) { + const formId = useId(); + const input = useRef(null); + const [name, setName] = useState(""); + const submitted = useRef(false); + return ( + { + if (!open) close(); + }} + title="Create new section" + description={ + + Move {channelName} into a new + section. + + } + initialFocus={input} + finalFocus={false} + actions={ + <> + + + + } + > +
{ + event.preventDefault(); + if (submitted.current || !name.trim()) return; + submitted.current = true; + create({ id: crypto.randomUUID(), name: name.trim() }); + }} + > + setName(event.target.value)} + /> +
+
+ ); +} diff --git a/src/bundled/channels/SidebarGroupIcon.module.css b/src/bundled/channels/SidebarGroupIcon.module.css new file mode 100644 index 000000000..b1a2bd45a --- /dev/null +++ b/src/bundled/channels/SidebarGroupIcon.module.css @@ -0,0 +1,24 @@ +.icon { + position: relative; + display: inline-block; + width: 1rem; + height: 1rem; + flex: 0 0 auto; + vertical-align: middle; + line-height: 1; + overflow: hidden; +} +.placeholder { + position: absolute; + inset: 0; + background: var(--border-standard); +} +.image { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} +.image[data-loading] { + visibility: hidden; +} diff --git a/src/bundled/channels/SidebarGroupIcon.test.tsx b/src/bundled/channels/SidebarGroupIcon.test.tsx new file mode 100644 index 000000000..b38b34b80 --- /dev/null +++ b/src/bundled/channels/SidebarGroupIcon.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, assert, expect, it, vi } from "vitest"; +import { createEmojiDirectory } from "../../features/relay/emoji-directory"; +import type { RelayReader } from "../../features/relay/reader"; +import type { RelayEvent } from "../../features/relay/events"; +import { keypair, signed } from "../../features/relay/testing"; +import { SidebarGroupIcon } from "./SidebarGroupIcon"; + +const member = keypair(); +const entry = (url = "https://relay.test/media/stamp.png", time = 1) => + signed(member, { + kind: 30030, + created_at: time, + content: "", + tags: [ + ["d", "buzz:custom-emoji"], + ["emoji", "stamp", url], + ], + }); +const owners: ReturnType[] = []; +afterEach(() => { + cleanup(); + for (const owner of owners.splice(0)) owner.dispose(); +}); +function fixture(read = vi.fn(async () => [entry()])) { + const owner = createEmojiDirectory({ read }); + owners.push(owner); + const media = vi.fn( + (url: string): string | undefined => `/safe?url=${encodeURIComponent(url)}`, + ); + return { owner, read, session: { emoji: owner.queries, media } }; +} + +it("renders Unicode without starting an emoji read", () => { + const h = fixture(); + const view = render(); + expect(view.container).toHaveTextContent("📚"); + expect(h.read).not.toHaveBeenCalled(); +}); + +it("shares a background catalog read across icons, resolves case, and renders live replacements", async () => { + let release!: (events: readonly RelayEvent[]) => void; + const h = fixture( + vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }), + ), + ); + const view = render( + <> + + + , + ); + expect(h.read).toHaveBeenCalledOnce(); + expect(h.read).toHaveBeenCalledWith( + [{ kinds: [30030], "#d": ["buzz:custom-emoji"], limit: 500 }], + expect.objectContaining({ priority: "background" }), + ); + expect(view.container.querySelectorAll("img")).toHaveLength(0); + expect(view.container.querySelectorAll("span[data-loading]")).toHaveLength(2); + expect(view.container).not.toHaveTextContent(":"); + expect(view.container.querySelector("[title]")).toBeNull(); + await act(async () => { + release([entry()]); + }); + expect(view.container.querySelectorAll("img")).toHaveLength(2); + expect(view.container.querySelector("img")).toHaveAttribute( + "src", + "/safe?url=https%3A%2F%2Frelay.test%2Fmedia%2Fstamp.png", + ); + expect(view.container.querySelectorAll("span[data-loading]")).toHaveLength(2); + for (const image of view.container.querySelectorAll("img")) + fireEvent.load(image); + expect(view.container.querySelectorAll("[data-loading]")).toHaveLength(0); + act(() => h.owner.accept([entry("https://relay.test/media/new.png", 2)])); + expect(view.container.querySelectorAll("span[data-loading]")).toHaveLength(2); + expect(view.container.querySelector("img")).toHaveAttribute( + "src", + "/safe?url=https%3A%2F%2Frelay.test%2Fmedia%2Fnew.png", + ); + act(() => + h.owner.accept([ + signed(member, { + kind: 30030, + created_at: 3, + content: "", + tags: [["d", "buzz:custom-emoji"]], + }), + ]), + ); + expect(view.container.querySelectorAll("img")).toHaveLength(0); + expect(view.container).not.toHaveTextContent(":"); + expect(view.container.querySelector("[data-loading]")).toBeNull(); +}); + +it("hides missing, blocked and failed media without ever showing shortcode text", async () => { + const h = fixture(); + h.session.media.mockReturnValue(undefined); + const view = render(); + await act(async () => { + await h.session.emoji.ensure(); + }); + expect(view.container).not.toHaveTextContent(":"); + expect(view.container.querySelector("[data-loading]")).toBeNull(); + expect(view.container.querySelector("img")).toBeNull(); + h.session.media.mockReturnValue("/safe/stamp.png"); + view.rerender(); + const image = view.container.querySelector("img"); + assert.exists(image); + expect(image).toHaveAttribute("alt", ""); + expect(view.container.querySelector("span[data-loading]")).not.toBeNull(); + fireEvent.error(image); + expect(view.container.querySelector("img")).toBeNull(); + expect( + view.container.querySelector("[data-sidebar-group-icon]"), + ).toHaveAttribute("aria-hidden", "true"); + expect(view.container.querySelector("[data-loading]")).toBeNull(); + view.rerender(); + expect(view.container).not.toHaveTextContent(":"); + expect(view.container.querySelector("[data-loading]")).toBeNull(); + view.rerender(); + expect(view.container).not.toHaveTextContent(":"); + expect(view.container.querySelector("[data-loading]")).toBeNull(); +}); + +it("does not retain another community's image while its catalog loads", async () => { + const a = fixture(); + const view = render(); + await act(async () => { + await a.session.emoji.ensure(); + }); + expect(view.container.querySelector("img")).not.toBeNull(); + const b = fixture(vi.fn(async () => [])); + await act(async () => { + view.rerender(); + }); + expect(view.container.querySelector("img")).toBeNull(); + expect(b.session.media).not.toHaveBeenCalled(); +}); + +it("keeps catalog failure settled and reflects explicit recovery", async () => { + const h = fixture( + vi + .fn() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValue([entry()]), + ); + const view = render(); + await act(async () => { + await h.session.emoji.ensure(); + }); + expect(h.session.emoji.snapshot().status).toBe("error"); + view.rerender(); + expect(h.read).toHaveBeenCalledOnce(); + expect(view.container).not.toHaveTextContent(":"); + expect(view.container.querySelector("[data-loading]")).toBeNull(); + await act(async () => { + await h.session.emoji.refresh(); + }); + expect(view.container.querySelector("img")).not.toBeNull(); +}); diff --git a/src/bundled/channels/SidebarGroupIcon.tsx b/src/bundled/channels/SidebarGroupIcon.tsx new file mode 100644 index 000000000..f2a4f7e49 --- /dev/null +++ b/src/bundled/channels/SidebarGroupIcon.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import type { RelaySession } from "../../features/relay/session"; +import { normalizeShortcode } from "../../features/relay/emoji"; +import styles from "./SidebarGroupIcon.module.css"; + +type Session = Pick; + +/** Decorative group icons share the community catalog, never a new read owner. */ +export function SidebarGroupIcon({ + icon, + session, +}: { + icon: string; + session: Session; +}) { + const custom = icon.startsWith(":") && icon.endsWith(":"); + const shortcode = custom ? normalizeShortcode(icon) : undefined; + return ( + + ); +} + +function Placeholder() { + return ; +} + +function CommunityIcon({ + shortcode, + session, +}: { + shortcode: string; + session: Session; +}) { + const catalog = useSyncExternalStore( + session.emoji.subscribe, + session.emoji.snapshot, + session.emoji.snapshot, + ); + useEffect(() => { + void session.emoji.ensure(); + }, [session.emoji]); + const emoji = catalog.entries.find((entry) => entry.shortcode === shortcode); + if (!emoji) + return catalog.status === "idle" || catalog.status === "loading" ? ( + + ) : null; + const src = session.media(emoji.url); + return src ? : null; +} + +/** Unlike message emoji, decorative icons never fall back to shortcode text. */ +function IconImage({ src }: { src: string }) { + const [status, setStatus] = useState<"loading" | "ready" | "error">( + "loading", + ); + if (status === "error") return null; + return ( + <> + {status === "loading" && } + setStatus("ready")} + onError={() => setStatus("error")} + /> + + ); +} diff --git a/src/bundled/channels/sidebar-sections.test.ts b/src/bundled/channels/sidebar-sections.test.ts index 79068193a..584ba65bc 100644 --- a/src/bundled/channels/sidebar-sections.test.ts +++ b/src/bundled/channels/sidebar-sections.test.ts @@ -72,5 +72,113 @@ it("intersects groups/stars with active authorized streams, keeping forums and D sidebarSections(roster).flatMap((section) => section.rows.map((channel) => channel.id), ), - ).toEqual(["star", "work", "other", "forum", "dm", "group-dm"]); + ).toEqual(["other", "star", "work", "forum", "dm", "group-dm"]); }); + +it("sorts every section independently with deterministic inactive and tie fallbacks", () => { + const roster = [ + row("z-id", { name: "same", lastActivityAt: 20 }), + row("a-id", { name: "Same", lastActivityAt: 20 }), + row("new", { name: "Zulu", lastActivityAt: 30 }), + row("quiet-b", { name: "beta" }), + row("quiet-a", { name: "Alpha" }), + row("forum-old", { + name: "Forum old", + channelType: "forum", + lastActivityAt: 5, + }), + row("forum-new", { + name: "Forum new", + channelType: "forum", + lastActivityAt: 10, + }), + ]; + const preferences = { + sections: [{ id: "work", name: "Work", order: 0 }], + assignments: { + "z-id": "work", + "a-id": "work", + new: "work", + "quiet-b": "work", + "quiet-a": "work", + }, + starred: [], + sort: { "section:work": "recent" as const, forums: "alpha" as const }, + }; + expect( + sidebarSections(roster, preferences).map((section) => [ + section.key, + section.rows.map((channel) => channel.id), + ]), + ).toEqual([ + ["group:work", ["new", "a-id", "z-id", "quiet-a", "quiet-b"]], + ["channels", []], + ["forums", ["forum-new", "forum-old"]], + ]); + expect(sidebarSections(roster)[0]?.rows.map((channel) => channel.id)).toEqual( + ["quiet-a", "quiet-b", "a-id", "z-id", "new"], + ); +}); +it("Star placement is exclusive and Unstar restores the saved assignment", () => { + const channels = [row("alpha"), row("beta")]; + const saved = { + sections: [{ id: "work", name: "Work", order: 0 }], + assignments: { beta: "work" }, + starred: ["alpha", "beta"], + }; + const placements = (starred: string[]) => + sidebarSections(channels, { ...saved, starred }).map((section) => [ + section.key, + section.rows.map((channel) => channel.id), + ]); + expect(placements(saved.starred)).toEqual([ + ["starred", ["alpha", "beta"]], + ["channels", []], + ]); + expect(placements(["alpha"])).toEqual([ + ["starred", ["alpha"]], + ["group:work", ["beta"]], + ["channels", []], + ]); + expect(saved.assignments).toEqual({ beta: "work" }); +}); + +it.each(["starred", "section:work", "channels", "forums", "dms"])( + "%s Recent affects only its own section", + (key) => { + const keys = ["starred", "section:work", "channels", "forums", "dms"]; + const channels = keys.flatMap((section, i) => [ + row(`a-${i}`, { + name: "Alpha", + lastActivityAt: 10, + ...(section === "forums" + ? { channelType: "forum" as const } + : section === "dms" + ? { channelType: "dm" as const } + : {}), + }), + row(`z-${i}`, { + name: "Zulu", + lastActivityAt: 20, + ...(section === "forums" + ? { channelType: "forum" as const } + : section === "dms" + ? { channelType: "dm" as const } + : {}), + }), + ]); + const sections = sidebarSections(channels, { + sections: [{ id: "work", name: "Work", order: 0 }], + assignments: { "a-1": "work", "z-1": "work" }, + starred: ["a-0", "z-0"], + sort: { [key]: "recent" }, + }); + expect( + sections.map((section) => section.rows.map((row) => row.name)), + ).toEqual( + keys.map((section) => + section === key ? ["Zulu", "Alpha"] : ["Alpha", "Zulu"], + ), + ); + }, +); diff --git a/src/bundled/channels/sidebar-sections.ts b/src/bundled/channels/sidebar-sections.ts index 370c682e8..c0e3407b8 100644 --- a/src/bundled/channels/sidebar-sections.ts +++ b/src/bundled/channels/sidebar-sections.ts @@ -25,43 +25,81 @@ export function sidebarSections( const groups = preferences?.sections ?? []; const ids = new Set(groups.map((group) => group.id)); const assignment = (id: string) => preferences?.assignments[id]; + const compareName = (a: ChannelSummary, b: ChannelSummary) => + a.name.toLowerCase() < b.name.toLowerCase() + ? -1 + : a.name.toLowerCase() > b.name.toLowerCase() + ? 1 + : a.id.localeCompare(b.id); + const sort = (rows: readonly ChannelSummary[], key: string) => + [...rows].sort((a, b) => { + if (preferences?.sort?.[key] === "recent") { + if ( + a.lastActivityAt !== undefined && + b.lastActivityAt !== undefined && + a.lastActivityAt !== b.lastActivityAt + ) + return b.lastActivityAt - a.lastActivityAt; + if (a.lastActivityAt !== undefined && b.lastActivityAt === undefined) + return -1; + if (a.lastActivityAt === undefined && b.lastActivityAt !== undefined) + return 1; + } + return compareName(a, b); + }); return [ { key: "starred", title: "Starred", icon: "★", - rows: streams.filter((channel) => stars.has(channel.id)), + rows: sort( + streams.filter((channel) => stars.has(channel.id)), + "starred", + ), }, ...groups.map((group) => ({ key: `group:${group.id}`, title: group.name, icon: group.icon, - rows: streams.filter( - (channel) => - !stars.has(channel.id) && assignment(channel.id) === group.id, + rows: sort( + streams.filter( + (channel) => + !stars.has(channel.id) && assignment(channel.id) === group.id, + ), + `section:${group.id}`, ), })), { key: "channels", title: "Channels", icon: undefined, - rows: streams.filter( - (channel) => - !stars.has(channel.id) && !ids.has(assignment(channel.id) ?? ""), + rows: sort( + streams.filter( + (channel) => + !stars.has(channel.id) && !ids.has(assignment(channel.id) ?? ""), + ), + "channels", ), }, { key: "forums", title: "Forums", icon: undefined, - rows: active.filter((channel) => channel.channelType === "forum"), + rows: sort( + active.filter((channel) => channel.channelType === "forum"), + "forums", + ), }, { key: "dms", title: "DMs", icon: undefined, - rows: active.filter( - (channel) => channel.channelType === "dm" && !hiddenDms.has(channel.id), + rows: sort( + active.filter( + (channel) => + channel.channelType === "dm" && !hiddenDms.has(channel.id), + ), + "dms", ), }, ].filter((section) => section.key === "channels" || section.rows.length); diff --git a/src/bundled/channels/useSidebarPreferences.ts b/src/bundled/channels/useSidebarPreferences.ts index 495771064..356fba19d 100644 --- a/src/bundled/channels/useSidebarPreferences.ts +++ b/src/bundled/channels/useSidebarPreferences.ts @@ -16,6 +16,16 @@ export function useSidebarPreferences( return { ...snapshot, status: snapshot.status === "idle" ? ("loading" as const) : snapshot.status, + writable: queries.writable, + assign: queries.assign, + createAndAssign: queries.createAndAssign, + retryMove: queries.retryMove, + dismissMoveError: queries.dismissMoveError, + starWritable: queries.starWritable, + sortWritable: queries.sortWritable, + setSort: queries.setSort, + dismissSortError: queries.dismissSortError, + setStar: queries.setStar, reload: queries.refresh, }; } diff --git a/src/bundled/channels/useSidebarStartup.test.tsx b/src/bundled/channels/useSidebarStartup.test.tsx new file mode 100644 index 000000000..af31ab86d --- /dev/null +++ b/src/bundled/channels/useSidebarStartup.test.tsx @@ -0,0 +1,225 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { StrictMode } from "react"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import type { ChannelList } from "../../features/relay/contracts"; +import { + SIDEBAR_REVEAL_BUDGET_MS, + useSidebarStartup, + type SidebarStartupSession, +} from "./useSidebarStartup"; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); +function fixture() { + let resolve!: () => void; + const unread = new Promise((done) => { + resolve = done; + }); + let live: ReturnType = { + status: "unavailable", + routes: [], + roster: { state: "pending" }, + heads: [], + }; + const listeners = new Set<() => void>(); + const session: SidebarStartupSession = { + live: { + snapshot: () => live, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }, + unread: { ensure: vi.fn(() => unread) }, + }; + return { + session, + resolve, + names(state: "verified" | "error" = "verified") { + live = { ...live, roster: { state } }; + for (const listener of listeners) listener(); + }, + listeners, + }; +} +const list: ChannelList = { + status: "ready", + channels: [{ id: "one", name: "One" }], + activityStatus: "loading", +}; +const preferences = { + status: "ready", + data: { + sections: [], + assignments: {}, + starred: [], + sort: { channels: "recent" }, + }, +} as const; +function View({ + session, + value = list, + prefs = preferences, +}: { + session: SidebarStartupSession; + value?: ChannelList; + prefs?: Parameters[2]; +}) { + const state = useSidebarStartup(session, value, prefs); + return ( +
+ {state.ready + ? value.channels.map(({ id, name }) => ( + + )) + : "Loading"} + {String(state.updating)} +
+ ); +} + +it("reveals once after names, saved preferences, unread and Recent activity settle in any order", async () => { + const h = fixture(); + const view = render( + + + , + ); + expect(screen.queryByRole("button")).toBeNull(); + await act(async () => { + h.resolve(); + }); + act(() => h.names()); + view.rerender( + + + , + ); + expect(screen.queryByRole("button")).toBeNull(); + view.rerender( + + + , + ); + expect(screen.getByRole("button", { name: "One" })).toBeVisible(); + expect(screen.getByTestId("updating")).toHaveTextContent("false"); + // A later refresh never blanks navigation, nor retains revoked rows. + view.rerender( + + + , + ); + expect(screen.queryByText("Loading", { exact: true })).toBeNull(); + expect(screen.queryByRole("button")).toBeNull(); +}); + +it("caps cold presentation waiting after authorization without needing optional reads to finish", async () => { + vi.useFakeTimers(); + const h = fixture(); + const view = render( + , + ); + await act(async () => { + vi.advanceTimersByTime(5000); + }); + expect(h.session.unread.ensure).not.toHaveBeenCalled(); + expect(screen.queryByRole("button")).toBeNull(); + view.rerender(); + await act(async () => { + vi.advanceTimersByTime(SIDEBAR_REVEAL_BUDGET_MS - 1); + }); + expect(screen.queryByRole("button")).toBeNull(); + await act(async () => { + vi.advanceTimersByTime(1); + }); + expect(screen.getByRole("button")).toBeVisible(); + expect(screen.getByTestId("updating")).toHaveTextContent("true"); +}); + +it("does not wait for activity in A–Z or treat failed preferences as an endless load", async () => { + const h = fixture(); + h.names(); + h.resolve(); + await act(async () => { + render( + , + ); + }); + expect(screen.getByRole("button")).toBeVisible(); +}); + +it("retains warm presentation but not another session's rows or readiness; unmount removes subscribers", async () => { + const h = fixture(); + h.names(); + h.resolve(); + const view = render( + , + ); + await act(async () => {}); + expect(screen.getByRole("button")).toBeVisible(); + view.unmount(); + expect(h.listeners.size).toBe(0); + const warm = render( + , + ); + expect(screen.getByRole("button")).toBeVisible(); + expect(screen.getByTestId("updating")).toHaveTextContent("false"); + warm.unmount(); + const replacement = fixture(); + render(); + expect(screen.queryByRole("button")).toBeNull(); +}); + +it("settles failed optional reads without an endless loading notice", async () => { + const h = fixture(); + h.names("error"); + vi.mocked(h.session.unread.ensure).mockRejectedValue(new Error("offline")); + await act(async () => { + render( + , + ); + }); + expect(screen.getByRole("button")).toBeVisible(); + expect(screen.getByTestId("updating")).toHaveTextContent("false"); +}); + +it("retains unread settlement after page exit without revealing a replacement session", async () => { + vi.useFakeTimers(); + const h = fixture(); + h.names(); + const view = render( + , + ); + await act(async () => vi.advanceTimersByTime(SIDEBAR_REVEAL_BUDGET_MS)); + expect(screen.getByTestId("updating")).toHaveTextContent("true"); + view.unmount(); + const replacement = fixture(); + const other = render(); + await act(async () => h.resolve()); + expect(screen.queryByRole("button")).toBeNull(); + other.unmount(); + render( + , + ); + expect(screen.getByRole("button")).toBeVisible(); + expect(screen.getByTestId("updating")).toHaveTextContent("false"); +}); diff --git a/src/bundled/channels/useSidebarStartup.ts b/src/bundled/channels/useSidebarStartup.ts new file mode 100644 index 000000000..6ad23d5c0 --- /dev/null +++ b/src/bundled/channels/useSidebarStartup.ts @@ -0,0 +1,86 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import type { RelaySession } from "../../features/relay/session"; +import type { ChannelList } from "../../features/relay/contracts"; + +// Presentation latch only, never roster/access data. Warm page remounts reveal +// immediately, but new relay/viewer sessions start independently. +const startupSessions = new WeakMap< + SidebarStartupSession, + { revealed: boolean; unreadDone: boolean } +>(); +export const SIDEBAR_REVEAL_BUDGET_MS = 1500; +// Only page-owned presentation depends on these signals. No new relay work. +export type SidebarStartupSession = { + live: Pick; + unread: Pick; +}; + +export function useSidebarStartup( + session: SidebarStartupSession, + list: ChannelList, + preferences: ReturnType, +) { + // ChannelWorkspace is keyed by scope and connection generation; the hook is + // remounted for a replacement session, while page-only remounts reuse latches. + const [retained] = useState(() => { + const state = startupSessions.get(session) ?? { + revealed: false, + unreadDone: false, + }; + startupSessions.set(session, state); + return state; + }); + const [revealed, setRevealed] = useState(retained.revealed); + const [unreadDone, setUnreadDone] = useState(retained.unreadDone); + const [expired, setExpired] = useState(false); + const live = useSyncExternalStore( + session.live.subscribe, + session.live.snapshot, + session.live.snapshot, + ); + const rosterAvailable = list.status === "ready" || list.status === "error"; + useEffect(() => { + if (!rosterAvailable) return; + let mounted = true; + void session.unread + .ensure() + .catch(() => {}) + .then(() => { + // Only the UI subscription ends on page exit; the session still owns + // this shared read. Preserve settlement if it finishes while away. + retained.unreadDone = true; + if (mounted) setUnreadDone(true); + }); + return () => { + mounted = false; + }; + }, [session, rosterAvailable, retained]); + useEffect(() => { + if (revealed || !rosterAvailable) return; + const timer = setTimeout(() => setExpired(true), SIDEBAR_REVEAL_BUDGET_MS); + return () => clearTimeout(timer); + }, [revealed, rosterAvailable]); + const preferencesReady = + !!preferences.data || + preferences.status === "error" || + preferences.status === "unsupported"; + const namesReady = + live.roster.state !== "pending" && live.roster.state !== "idle"; + const recent = Object.values(preferences.data?.sort ?? {}).includes("recent"); + const activityReady = + !recent || + list.activityStatus === "ready" || + list.activityStatus === "error" || + list.activityStatus === "unavailable"; + const settled = preferencesReady && namesReady && activityReady && unreadDone; + const ready = revealed || (rosterAvailable && (expired || settled)); + useEffect(() => { + if (!ready || revealed) return; + retained.revealed = true; + setRevealed(true); + }, [ready, revealed, retained]); + return { + ready, + updating: ready && !settled, + }; +} diff --git a/src/features/relay/channel-activity-session.test.ts b/src/features/relay/channel-activity-session.test.ts new file mode 100644 index 000000000..16917f3e8 --- /dev/null +++ b/src/features/relay/channel-activity-session.test.ts @@ -0,0 +1,152 @@ +import { assert, expect, it, vi } from "vitest"; +import { createRelaySession } from "./session"; +import { + flush, + keypair, + message, + metadata, + roster, + scriptedTransport, + signed, +} from "./testing"; +import type { LiveCallbacks } from "./live"; +import type { RelayEvent } from "./events"; +function setup() { + const viewer = keypair(), + relay = keypair(), + peer = keypair(); + const wire = scriptedTransport(viewer.pubkey, relay.pubkey); + let live!: LiveCallbacks; + const pending: { + ids: readonly string[]; + signal: AbortSignal; + resolve(events: RelayEvent[]): void; + reject(error: Error): void; + }[] = []; + const activity = vi.fn( + (ids: readonly string[], signal: AbortSignal) => + new Promise((resolve, reject) => + pending.push({ ids, signal, resolve, reject }), + ), + ); + const owner = createRelaySession({ + ...wire.transport, + channelActivity: activity, + decodeSidebarPreferences: async () => ({ + sections: [], + assignments: {}, + starred: [], + sort: {}, + }), + writeSidebarSort: async (group, mode) => + mode === "recent" ? { [group]: "recent" } : {}, + subscribe(callbacks) { + live = callbacks; + return { update() {}, dispose() {}, retry() {} }; + }, + }); + const channels = owner.session.channels; + const preferences = owner.session.sidebarPreferences; + async function initial() { + channels.ensureList(); + wire + .next() + .respond([ + roster(relay, "alpha", [viewer.pubkey]), + roster(relay, "beta", [viewer.pubkey]), + metadata(relay, "alpha", "Alpha"), + metadata(relay, "beta", "Beta"), + ]); + await flush(); + const reading = preferences.ensure(); + await flush(); + wire.next().respond([]); + await reading; + live.state({ status: "connected", routes: [] }); + } + return { + owner, + channels, + preferences, + pending, + activity, + viewer, + relay, + peer, + live: () => live, + initial, + }; +} +it("reads no roster activity for A–Z, deduplicates Recent demand, and projects verified live activity monotonically", async () => { + const h = setup(); + try { + await h.initial(); + expect(h.activity).not.toHaveBeenCalled(); + await h.preferences.setSort("channels", "recent", []); + expect(h.activity).toHaveBeenCalledOnce(); + expect(h.channels.list().activityStatus).toBe("loading"); + expect(h.pending[0]?.ids).toEqual(["alpha", "beta"]); + take(h.pending).resolve([message(h.peer, "alpha", "history", 50)]); + await flush(); + const first = h.channels.list(); + expect(first.activityStatus).toBe("ready"); + expect(first.channels.find((c) => c.id === "alpha")?.lastActivityAt).toBe( + 50, + ); + expect(h.channels.list()).toBe(first); + await h.preferences.setSort("dms", "recent", []); + expect(h.activity).toHaveBeenCalledOnce(); + h.live().receive([message(h.peer, "alpha", "new", 90)]); + h.live().receive([ + message(h.peer, "alpha", "older", 70), + signed(h.peer, { + kind: 39000, + created_at: 200, + tags: [["d", "beta"]], + content: "metadata is not activity", + }), + ]); + expect( + h.channels.list().channels.find((c) => c.id === "alpha")?.lastActivityAt, + ).toBe(90); + expect( + h.channels.list().channels.find((c) => c.id === "beta")?.lastActivityAt, + ).toBeUndefined(); + } finally { + h.owner.dispose(); + } +}); +it.each(["clear", "dispose", "revoke", "disconnect"] as const)( + "%s fences activity reads and cannot leak late data into a new lifetime", + async (action) => { + const h = setup(); + try { + await h.initial(); + await h.preferences.setSort("channels", "recent", []); + const request = take(h.pending); + if (action === "clear") await h.owner.clearCache(); + if (action === "dispose") h.owner.dispose(); + if (action === "revoke") + h.live().receive([ + roster(h.relay, "alpha", [], Math.floor(Date.now() / 1000) + 1), + ]); + if (action === "disconnect") + h.live().state({ status: "retrying", routes: [] }); + expect(request.signal.aborted).toBe(true); + request.resolve([message(h.peer, "alpha", "retired", 100)]); + await flush(); + expect( + h.channels.list().channels.find((c) => c.id === "alpha") + ?.lastActivityAt, + ).toBeUndefined(); + } finally { + h.owner.dispose(); + } + }, +); + +function take(pending: T[]): T { + const next = pending.shift(); + assert.exists(next, "Expected a pending operation"); + return next; +} diff --git a/src/features/relay/channel-activity.test.ts b/src/features/relay/channel-activity.test.ts new file mode 100644 index 000000000..3457482c1 --- /dev/null +++ b/src/features/relay/channel-activity.test.ts @@ -0,0 +1,206 @@ +import { assert, expect, it, vi } from "vitest"; +import { + createChannelActivity, + CHANNEL_ACTIVITY_KINDS, +} from "./channel-activity"; +import { flush, keypair, message, signed } from "./testing"; + +const forumActivity = ( + key: ReturnType, + channelId: string, + kind: 45001 | 45003, + created_at: number, +) => + signed(key, { kind, created_at, content: "forum", tags: [["h", channelId]] }); + +function deferredReader() { + const pending: { + ids: readonly string[]; + signal: AbortSignal; + resolve(events: readonly import("./events").RelayEvent[]): void; + reject(error: unknown): void; + }[] = []; + return { + pending, + read(ids: readonly string[], signal: AbortSignal) { + return new Promise( + (resolve, reject) => pending.push({ ids, signal, resolve, reject }), + ); + }, + }; +} + +it("reads authoritative activity in 128-channel batches across message and forum kinds", async () => { + const peer = keypair(); + const wire = deferredReader(); + const changed = vi.fn(); + const activity = createChannelActivity(wire.read); + activity.subscribe(changed); + const ids = Array.from({ length: 257 }, (_, index) => `room-${index}`); + const refresh = activity.refresh(ids); + await flush(); + for (const [index, size] of [128, 128, 1].entries()) { + const request = take(wire.pending); + expect(request.ids).toEqual(ids.slice(index * 128, index * 128 + size)); + expect(CHANNEL_ACTIVITY_KINDS).toEqual([9, 40002, 45001, 45003]); + const channelId = ids[index * 128]; + assert.exists(channelId); + request.resolve([forumActivity(peer, channelId, 45003, 100 + index)]); + await flush(); + } + await refresh; + expect(activity.last(ids[0] ?? "")).toBe(100); + expect(activity.last(ids[128] ?? "")).toBe(101); + expect(activity.last(ids[256] ?? "")).toBe(102); + expect(changed).toHaveBeenCalledTimes(2); + expect(activity.status()).toBe("ready"); +}); + +it("keeps the last good projection on failure and never rolls back newer live activity", async () => { + const peer = keypair(); + const wire = deferredReader(); + const activity = createChannelActivity(wire.read, vi.fn()); + activity.accept([message(peer, "alpha", "seed", 50)]); + + const failed = activity.refresh(["alpha"]); + await flush(); + take(wire.pending).reject(new Error("offline")); + await expect(failed).rejects.toThrow("offline"); + expect(activity.last("alpha")).toBe(50); + + const refresh = activity.refresh(["alpha", "quiet"]); + await flush(); + activity.accept([message(peer, "alpha", "live", 90)]); + take(wire.pending).resolve([message(peer, "alpha", "stale query", 60)]); + await refresh; + expect(activity.last("alpha")).toBe(90); + expect(activity.last("quiet")).toBeUndefined(); +}); + +it("authoritative absence clears unchanged recency but cache clear rejects late settlement", async () => { + const peer = keypair(); + const wire = deferredReader(); + const activity = createChannelActivity(wire.read, vi.fn()); + activity.accept([forumActivity(peer, "forum", 45001, 70)]); + const absent = activity.refresh(["forum"]); + await flush(); + take(wire.pending).resolve([]); + await absent; + expect(activity.last("forum")).toBeUndefined(); + + activity.accept([message(peer, "forum", "again", 80)]); + const stale = activity.refresh(["forum"]); + await flush(); + activity.clear(); + take(wire.pending).resolve([message(peer, "forum", "late", 100)]); + await stale; + expect(activity.last("forum")).toBeUndefined(); +}); + +it("retires an older roster refresh before it can erase newer results", async () => { + const peer = keypair(); + const wire = deferredReader(); + const activity = createChannelActivity(wire.read); + const older = activity.refresh(["old"]); + const first = take(wire.pending); + const newer = activity.refresh(["new"]); + const second = take(wire.pending); + second.resolve([message(peer, "new", "new roster", 90)]); + await newer; + first.resolve([message(peer, "old", "stale roster", 100)]); + await older; + expect(activity.last("new")).toBe(90); + expect(activity.last("old")).toBeUndefined(); + expect(first.signal.aborted).toBe(true); + activity.dispose(); +}); + +it("cache clear aborts active batches and disposal ignores late live input", async () => { + const peer = keypair(); + const wire = deferredReader(); + const activity = createChannelActivity(wire.read); + const pending = activity.refresh(["alpha"]); + const first = take(wire.pending); + activity.clear(); + first.resolve([message(peer, "alpha", "late", 90)]); + await pending; + expect(first.signal.aborted).toBe(true); + expect(activity.last("alpha")).toBeUndefined(); + activity.dispose(); + activity.accept([message(peer, "alpha", "retired", 100)]); + expect(activity.last("alpha")).toBeUndefined(); +}); + +it("a later batch failure cannot partially replace the last good projection", async () => { + const peer = keypair(); + const wire = deferredReader(); + const activity = createChannelActivity(wire.read); + activity.accept([message(peer, "room-0", "seed", 50)]); + const ids = Array.from({ length: 129 }, (_, i) => `room-${i}`); + const pending = activity.refresh(ids); + const failed = expect(pending).rejects.toThrow("offline"); + take(wire.pending).resolve([message(peer, "room-0", "partial", 90)]); + await flush(); + take(wire.pending).reject(new Error("offline")); + await failed; + expect(activity.last("room-0")).toBe(50); + activity.dispose(); +}); + +function take(pending: T[]): T { + const next = pending.shift(); + assert.exists(next, "Expected a pending operation"); + return next; +} + +it("ignores unsupported kinds, ambiguous scopes and out-of-batch activity", async () => { + const peer = keypair(); + const valid = message(peer, "alpha", "visible", 40); + const ambiguous = signed(peer, { + kind: 9, + created_at: 100, + content: "ambiguous", + tags: [ + ["h", "alpha"], + ["h", "beta"], + ], + }); + const unsupported = signed(peer, { + kind: 7, + created_at: 200, + content: "reaction", + tags: [["h", "alpha"]], + }); + const other = message(peer, "beta", "unrequested", 300); + const owner = createChannelActivity(async () => [ + valid, + ambiguous, + unsupported, + other, + ]); + await owner.refresh(["alpha"]); + expect(owner.last("alpha")).toBe(40); + expect(owner.last("beta")).toBeUndefined(); + owner.accept([ambiguous, unsupported]); + expect(owner.last("alpha")).toBe(40); + owner.dispose(); +}); + +it("publishes settled readiness even for empty activity and failure without changing values", async () => { + const wire = deferredReader(); + const activity = createChannelActivity(wire.read); + expect(activity.status()).toBe("idle"); + const empty = activity.refresh(["quiet"]); + expect(activity.status()).toBe("loading"); + take(wire.pending).resolve([]); + await empty; + expect(activity.status()).toBe("ready"); + const failure = activity.refresh(["quiet"]); + const rejected = expect(failure).rejects.toThrow("offline"); + take(wire.pending).reject(new Error("offline")); + await rejected; + expect(activity.status()).toBe("error"); + activity.clear(); + expect(activity.status()).toBe("idle"); + activity.dispose(); +}); diff --git a/src/features/relay/channel-activity.ts b/src/features/relay/channel-activity.ts new file mode 100644 index 000000000..0c4fdded1 --- /dev/null +++ b/src/features/relay/channel-activity.ts @@ -0,0 +1,145 @@ +import type { RelayEvent } from "./events"; + +export const CHANNEL_ACTIVITY_KINDS = [9, 40002, 45001, 45003] as const; +const CHANNEL_BATCH = 128; +export type ChannelActivityReader = ( + channelIds: readonly string[], + signal: AbortSignal, +) => Promise; + +const channelOf = (event: RelayEvent) => { + const channels = event.tags.filter(([name]) => name === "h"); + return channels.length === 1 ? channels[0]?.[1] : undefined; +}; + +/** One roster-scoped activity projection. Authoritative reads may clear unchanged + * values; verified live activity can only advance them. */ +export function createChannelActivity( + read: ChannelActivityReader | undefined, + notify = (listener: () => void) => listener(), +) { + let values = new Map(); + let generation = 0; + let revision = 0; + let closed = false; + let status: "idle" | "loading" | "ready" | "error" | "unavailable" = read + ? "idle" + : "unavailable"; + let active: AbortController | undefined; + const listeners = new Set<() => void>(); + const publish = () => { + revision++; + for (const listener of listeners) notify(listener); + }; + const mergeLive = (events: readonly RelayEvent[]) => { + if (closed) return; + let changed = false; + for (const event of events) { + if (!CHANNEL_ACTIVITY_KINDS.includes(event.kind as never)) continue; + const channelId = channelOf(event); + if (!channelId || event.created_at <= (values.get(channelId) ?? -1)) + continue; + values.set(channelId, event.created_at); + changed = true; + } + if (changed) publish(); + }; + return { + status: () => status, + last: (channelId: string) => values.get(channelId), + revision: () => revision, + subscribe(listener: () => void) { + if (closed) return () => {}; + listeners.add(listener); + return () => listeners.delete(listener); + }, + accept: mergeLive, + async refresh(input: readonly string[]) { + if (closed || !read) return; + const ids = [...new Set(input)]; + const atStart = new Map(values); + const epoch = ++generation; + active?.abort(); + const controller = new AbortController(); + active = controller; + status = "loading"; + publish(); + const refreshed = new Map(); + try { + for (let offset = 0; offset < ids.length; offset += CHANNEL_BATCH) { + const batch = ids.slice(offset, offset + CHANNEL_BATCH); + const events = await read( + batch, + AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]), + ); + if (closed || epoch !== generation) return; + for (const event of events) { + const channelId = channelOf(event); + if ( + channelId && + batch.includes(channelId) && + CHANNEL_ACTIVITY_KINDS.includes(event.kind as never) && + event.created_at > (refreshed.get(channelId) ?? -1) + ) + refreshed.set(channelId, event.created_at); + } + } + } catch (error) { + if (closed || controller.signal.aborted || epoch !== generation) return; + status = "error"; + publish(); + throw error; + } finally { + if (active === controller) active = undefined; + } + if (closed || epoch !== generation) return; + const wanted = new Set(ids); + const next = new Map(values); + for (const id of ids) { + const displayed = values.get(id); + const started = atStart.get(id); + const result = refreshed.get(id); + const changedDuringRead = displayed !== started; + if ( + displayed !== undefined && + (result !== undefined ? displayed > result : changedDuringRead) + ) + continue; + if (result === undefined) next.delete(id); + else next.set(id, result); + } + for (const id of next.keys()) if (!wanted.has(id)) next.delete(id); + status = "ready"; + values = next; + publish(); + }, + cancel() { + generation++; + active?.abort(); + active = undefined; + if (status === "loading") { + status = "idle"; + publish(); + } + }, + clear() { + if (closed) return; + generation++; + active?.abort(); + active = undefined; + const emptyStatus = read ? "idle" : "unavailable"; + if (!values.size && status === emptyStatus) return; + status = emptyStatus; + values = new Map(); + publish(); + }, + dispose() { + closed = true; + generation++; + active?.abort(); + active = undefined; + values.clear(); + listeners.clear(); + }, + }; +} diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts index 55cdc8c4d..57e64595c 100644 --- a/src/features/relay/contracts.ts +++ b/src/features/relay/contracts.ts @@ -11,6 +11,8 @@ export type ChannelSummary = Readonly<{ preview?: string | undefined; /** Readable public nonmember channel; not part of the joined roster. */ readOnly?: true; + /** Newest verified user-visible activity for sidebar ordering, in Unix seconds. */ + lastActivityAt?: number | undefined; /** Members-only channel omitted from directories (NIP-29 `hidden`), such as a DM. */ hidden?: true; /** Relay-authored metadata; absent while metadata is unavailable. */ @@ -90,6 +92,8 @@ export type ChannelList = Readonly<{ /** Set when the viewer's roster read hit its cap; omitted channels are then not evidence of removal. */ coverage?: "partial"; asOf?: number; + /** Initial Recent ordering observation, independent of roster/access readiness. */ + activityStatus?: "idle" | "loading" | "ready" | "error" | "unavailable"; channels: readonly ChannelSummary[]; error?: string; }>; diff --git a/src/features/relay/live.test.ts b/src/features/relay/live.test.ts index b974db691..5bdf58501 100644 --- a/src/features/relay/live.test.ts +++ b/src/features/relay/live.test.ts @@ -1178,3 +1178,36 @@ it("presence and ordinary publications correlate independently and share only re h.owner.dispose(); } }); + +it("requests forum posts and replies on existing channel routes for live recency", async () => { + vi.useFakeTimers(); + const h = setup(["forum"]); + try { + await h.first.auth(); + await vi.advanceTimersByTimeAsync(750); + const request = h.first + .requests() + .find((entry) => entry[2]["#h"]?.[0] === "forum"); + assert.exists(request); + expect(request[2].kinds).toEqual( + expect.arrayContaining([9, 40002, 45001, 45003]), + ); + expect(h.first.requests()).toHaveLength(3); + await h.first.receive(["EOSE", request[1]]); + for (const kind of [45001, 45003]) { + const event = signed(h.key, { + kind, + tags: [["h", "forum"]], + content: "forum activity", + created_at: 1700000000, + }); + await h.first.receive(["EVENT", request[1], event]); + expect(h.callbacks.receive).toHaveBeenLastCalledWith([event], { + phase: "live", + channelId: "forum", + }); + } + } finally { + h.owner.dispose(); + } +}); diff --git a/src/features/relay/live.ts b/src/features/relay/live.ts index fc4b247ae..bdb89ecdd 100644 --- a/src/features/relay/live.ts +++ b/src/features/relay/live.ts @@ -140,7 +140,7 @@ type Route = { deadline?: ReturnType; }; const CHANNEL_KINDS = [ - 9, 40002, 40099, 40003, 5, 9005, 7, 39000, 39002, 39005, 20002, + 9, 40002, 45001, 45003, 40099, 40003, 5, 9005, 7, 39000, 39002, 39005, 20002, ]; /** One authenticated socket, independently established channel routes and two explicit globals. * Recent replay is opportunistic: finite reads own catch-up and history bounds. */ diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 2666038c4..a3d2eb110 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -27,8 +27,10 @@ import { import { createTyping } from "./typing"; import { createUnread } from "./unread"; import type { IncomingListener, IncomingMessage } from "./incoming"; +import type { ChannelList } from "./contracts"; import { objectBody } from "./body"; import { readSidebarPreferences } from "./sidebar-preferences"; +import { createChannelActivity } from "./channel-activity"; import { createSidebarPreferencesStore } from "./sidebar-preferences-store"; import { createEmojiDirectory } from "./emoji-directory"; import { createProfileDirectory } from "./profile-directory"; @@ -318,6 +320,8 @@ export function createRelaySession( emoji.clear(); activity.clear(); presence.clear(); + channelActivity.clear(); + activityRosterKey = undefined; archives.clear(); workflows.clear(); for (const purge of views.values()) purge(); @@ -502,6 +506,13 @@ export function createRelaySession( transport?.archiveAuthority, notify, ); + const channelActivity = createChannelActivity( + transport?.channelActivity + ? (ids, signal) => + transport.channelActivity?.(ids, signal) ?? Promise.resolve([]) + : undefined, + notify, + ); const channels = createChannelStore( transport ? { @@ -535,6 +546,50 @@ export function createRelaySession( canAccess: (channelId) => channels.canParticipate(channelId), notify, }); + let sourceChannelList = channels.queries.list(); + let activityChannelList: ChannelList = Object.freeze({ + ...sourceChannelList, + activityStatus: channelActivity.status(), + }); + let channelActivityRevision = channelActivity.revision(); + const channelQueries = Object.freeze({ + ...channels.queries, + list() { + const snapshot = channels.queries.list(); + const activityRevision = channelActivity.revision(); + if ( + snapshot === sourceChannelList && + activityRevision === channelActivityRevision + ) + return activityChannelList; + sourceChannelList = snapshot; + channelActivityRevision = activityRevision; + const projected = snapshot.channels.map((channel) => { + const lastActivityAt = channelActivity.last(channel.id); + return lastActivityAt === undefined + ? channel + : Object.freeze({ ...channel, lastActivityAt }); + }); + activityChannelList = Object.freeze({ + ...snapshot, + activityStatus: channelActivity.status(), + channels: projected.every( + (channel, index) => channel === snapshot.channels[index], + ) + ? snapshot.channels + : Object.freeze(projected), + }); + return activityChannelList; + }, + subscribeList(listener: () => void) { + const offChannels = channels.queries.subscribeList(listener); + const offActivity = channelActivity.subscribe(listener); + return () => { + offChannels(); + offActivity(); + }; + }, + }); const readScope = `${transport?.scope ?? transport?.relayAuthor ?? "offline"}:${transport?.viewer ?? ""}`; const reads = createReadState({ viewer: transport?.viewer ?? "", @@ -551,7 +606,7 @@ export function createRelaySession( }); const unread = createUnread({ reads, - channels: channels.queries, + channels: channelQueries, // Repair owns evidence only, not timeline/history ingestion. The shared // scheduler and verified transport stay shared; unread fences access epochs. reader: requests.reader, @@ -791,6 +846,50 @@ export function createRelaySession( ); }, !!transport?.decodeSidebarPreferences, + (() => { + const write = transport?.writeSidebarAssignment; + return write + ? (intent, signal) => + write( + intent, + AbortSignal.any([ + lifetime.signal, + AbortSignal.timeout(20_000), + signal, + ]), + ) + : undefined; + })(), + (() => { + const write = transport?.writeSidebarStar; + return write + ? (intent, signal) => + write( + intent, + AbortSignal.any([ + lifetime.signal, + AbortSignal.timeout(20_000), + signal, + ]), + ) + : undefined; + })(), + (() => { + const write = transport?.writeSidebarSort; + return write + ? (group, mode, sectionIds, signal) => + write( + group, + mode, + sectionIds, + AbortSignal.any([ + lifetime.signal, + AbortSignal.timeout(20_000), + signal, + ]), + ) + : undefined; + })(), notify, ); const workSessions = createWorkSessions( @@ -1012,7 +1111,7 @@ export function createRelaySession( views.set(dispose, thread.purge); return { ...thread.view, dispose }; }, - channels: channels.queries, + channels: channelQueries, names: identityNames, profiles: profiles.queries, emoji: emoji.queries, @@ -1370,6 +1469,13 @@ export function createRelaySession( activity.channelEvents( events.filter((event) => event.pubkey !== transport.viewer), ); + if ( + !closed && + epoch === accessEpoch && + generation === liveGeneration && + liveSnapshot.status === "connected" + ) + channelActivity.accept(visible); if ( closed || epoch !== accessEpoch || @@ -1413,6 +1519,8 @@ export function createRelaySession( liveSnapshot.status === "connected" ) { liveGeneration++; + channelActivity.cancel(); + activityRosterKey = undefined; catchups.clear(); catchupQueue.clear(); requests.invalidate(); @@ -1463,6 +1571,8 @@ export function createRelaySession( timers.delete(timer); if (!closed) { agentLibrary.reconnect(); + activityRosterKey = undefined; + refreshChannelActivity(); emoji.reconnect(); unread.reconnect(); for (const refresh of refreshers) void refresh(); @@ -1492,8 +1602,35 @@ export function createRelaySession( if (!closed) channels.denyChannel(channelId, new Error(reason)); }, }); + let activityRosterKey: string | undefined; + const refreshChannelActivity = () => { + if ( + closed || + !transport?.channelActivity || + !Object.values( + sidebarPreferences.queries.snapshot().data?.sort ?? {}, + ).includes("recent") + ) + return; + const roster = channels.queries.list(); + if (roster.status !== "ready") return; + const ids = roster.channels.map((channel) => channel.id).sort(); + const key = ids.join("\0"); + if (key === activityRosterKey) return; + activityRosterKey = key; + void channelActivity.refresh(ids).catch(() => { + if (activityRosterKey === key) activityRosterKey = undefined; + }); + }; + const stopActivityRoster = channels.queries.subscribeList( + refreshChannelActivity, + ); + const stopActivityPreferences = sidebarPreferences.queries.subscribe( + refreshChannelActivity, + ); const stopInterests = channels.queries.subscribeList(updateInterests); updateInterests(); + refreshChannelActivity(); return { session, @@ -1503,6 +1640,8 @@ export function createRelaySession( cacheClearEpoch++; activity.clear(); presence.clear(); + channelActivity.clear(); + activityRosterKey = undefined; typing.clear(); sidebarPreferences.clear(); // New windows must not yield to or receive errors from retired owners. @@ -1526,6 +1665,9 @@ export function createRelaySession( lifetime.abort(); activity.dispose(); presence.dispose(); + channelActivity.dispose(); + stopActivityRoster(); + stopActivityPreferences(); sidebarPreferences.dispose(); stopInterests(); stopWarmPreferences(); diff --git a/src/features/relay/sidebar-moves-store.test.ts b/src/features/relay/sidebar-moves-store.test.ts new file mode 100644 index 000000000..1477f56e6 --- /dev/null +++ b/src/features/relay/sidebar-moves-store.test.ts @@ -0,0 +1,180 @@ +import { expect, it, vi } from "vitest"; +import { createSidebarPreferencesStore } from "./sidebar-preferences-store"; +import type { + SidebarAssignmentIntent, + SidebarPreferences, +} from "./sidebar-preferences"; +import { flush } from "./testing"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +async function setup() { + let data: SidebarPreferences = { + sections: [ + { id: "work", name: "Work", order: 0 }, + { id: "later", name: "Later", order: 1 }, + ], + assignments: { alpha: "work", beta: "work" }, + starred: ["alpha"], + sort: { channels: "recent" }, + }; + const read = vi.fn(async () => data); + const assignment = vi.fn(async (intent: SidebarAssignmentIntent) => { + const id = intent.createSection?.id ?? intent.sectionId; + const assignments = { ...data.assignments }; + if (id) assignments[intent.channelId] = id; + else delete assignments[intent.channelId]; + const sections = + intent.createSection && + !data.sections.some(({ id }) => id === intent.createSection?.id) + ? [...data.sections, { ...intent.createSection, order: 2 }] + : data.sections; + data = { ...data, sections, assignments }; + return { sections, assignments }; + }); + const star = vi.fn( + async (intent: { channelId: string; starred: boolean }) => { + const stars = new Set(data.starred); + if (intent.starred) stars.add(intent.channelId); + else stars.delete(intent.channelId); + data = { ...data, starred: [...stars] }; + return data.starred; + }, + ); + const sort = vi.fn(async (key: string) => ({ [key]: "recent" as const })); + const owner = createSidebarPreferencesStore( + read, + true, + assignment, + star, + sort, + ); + await owner.queries.ensure(); + return { owner, prefs: owner.queries, assignment, star, sort, read }; +} + +it("moves immediately, keeps later intent through older failure, and serializes both records before sorting", async () => { + const h = await setup(); + const gate = deferred(); + h.star.mockImplementationOnce(() => gate.promise); + try { + const first = h.prefs.assign("alpha", "later"); + const failed = expect(first).rejects.toThrow("offline"); + expect(h.prefs.snapshot().data?.assignments.alpha).toBe("later"); + expect(h.prefs.snapshot().data?.starred).toEqual([]); + await flush(); // assignment confirmed, Star clear held + expect(h.assignment).toHaveBeenCalledOnce(); + const second = h.prefs.setStar("alpha", true); + const other = h.prefs.assign("beta", "later"); + const sorting = h.prefs.setSort("forums", "recent", []); + expect(h.prefs.snapshot().data).toMatchObject({ + assignments: { alpha: "later", beta: "later" }, + starred: ["alpha"], + sort: { channels: "recent", forums: "recent" }, + }); + expect(h.sort).not.toHaveBeenCalled(); + gate.reject(new Error("offline")); + await failed; + await Promise.all([second, other, sorting]); + expect(h.prefs.snapshot().moves).toBeUndefined(); // stale failure offers no stale retry + expect(h.prefs.snapshot().data).toMatchObject({ + assignments: { beta: "later" }, + starred: ["alpha"], + sort: { forums: "recent" }, + }); + } finally { + gate.resolve([]); + h.owner.dispose(); + } +}); + +it("failure rolls back just its channel, persists a retry across refresh, and clears retry after success", async () => { + const h = await setup(); + h.star.mockRejectedValueOnce(new Error("offline")); + try { + const first = h.prefs.assign("alpha", "later"); + const failed = expect(first).rejects.toThrow("offline"); + const other = h.prefs.assign("beta", "later"); + await failed; + await other; + expect(h.prefs.snapshot().data?.starred).toEqual(["alpha"]); + expect(h.prefs.snapshot().data?.assignments.beta).toBe("later"); + expect(h.prefs.snapshot().moves).toEqual([ + expect.objectContaining({ + channelId: "alpha", + pending: false, + error: "offline", + }), + ]); + await h.prefs.refresh(); + expect(h.prefs.snapshot().moves).toHaveLength(1); + const retry = h.prefs.retryMove("alpha"); + expect(h.prefs.snapshot().data?.starred).toEqual([]); + expect(h.prefs.snapshot().moves?.[0]?.pending).toBe(true); + await retry; + expect(h.prefs.snapshot().moves).toBeUndefined(); + expect(h.prefs.snapshot().data?.assignments).toEqual({ + alpha: "later", + beta: "later", + }); + } finally { + h.owner.dispose(); + } +}); + +it("create-and-move appears before the first write, rolls back on failure, and reuses its ID on retry", async () => { + const h = await setup(); + const gate = deferred<{ + sections: SidebarPreferences["sections"]; + assignments: SidebarPreferences["assignments"]; + }>(); + h.assignment.mockImplementationOnce(() => gate.promise); + const section = { + id: "12345678-1234-1234-1234-123456789abc", + name: "Launch", + }; + try { + const creating = h.prefs.createAndAssign("alpha", section); + const failed = expect(creating).rejects.toThrow("offline"); + expect(h.prefs.snapshot().data?.sections.at(-1)?.id).toBe(section.id); + expect(h.prefs.snapshot().data?.assignments.alpha).toBe(section.id); + expect(h.prefs.snapshot().data?.starred).toEqual([]); + await flush(); + gate.reject(new Error("offline")); + await failed; + expect(h.prefs.snapshot().data?.sections).toHaveLength(2); + expect(h.prefs.snapshot().data?.starred).toEqual(["alpha"]); + await h.prefs.retryMove("alpha"); + expect( + h.assignment.mock.calls.map(([intent]) => intent.createSection?.id), + ).toEqual([section.id, section.id]); + expect(h.prefs.snapshot().data?.sections).toHaveLength(3); + } finally { + h.owner.dispose(); + } +}); + +it.each(["clear", "dispose"] as const)( + "%s clears optimistic placement/errors and rejects late repopulation", + async (action) => { + const h = await setup(); + const gate = deferred(); + h.star.mockImplementationOnce(() => gate.promise); + const moving = h.prefs.assign("alpha", "later"); + const failed = expect(moving).rejects.toThrow("unavailable"); + await flush(); + h.owner[action](); + gate.resolve([]); + await failed; + expect(h.prefs.snapshot().data).toBeUndefined(); + expect(h.prefs.snapshot().moves).toBeUndefined(); + h.owner.dispose(); + }, +); diff --git a/src/features/relay/sidebar-preferences-store.test.ts b/src/features/relay/sidebar-preferences-store.test.ts index 0aedbb893..9ee4690b2 100644 --- a/src/features/relay/sidebar-preferences-store.test.ts +++ b/src/features/relay/sidebar-preferences-store.test.ts @@ -1,22 +1,185 @@ import { expect, it, vi } from "vitest"; import { createRelaySession } from "./session"; import { flush, keypair, scriptedTransport } from "./testing"; -import type { SidebarPreferences } from "./sidebar-preferences"; +import type { + SidebarAssignmentMutator, + SidebarStarMutator, + SidebarPreferences, +} from "./sidebar-preferences"; + +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 }; +} const data: SidebarPreferences = { sections: [{ id: "work", name: "Work", order: 0 }], assignments: { alpha: "work" }, starred: ["beta"], }; -function setup(decode = vi.fn(async (): Promise => data)) { +function setup( + decode = vi.fn(async (): Promise => data), + write?: SidebarAssignmentMutator, + writeStar?: SidebarStarMutator, +) { const wire = scriptedTransport(keypair().pubkey, keypair().pubkey); const owner = createRelaySession({ ...wire.transport, decodeSidebarPreferences: decode, + ...(write || writeStar + ? { + writeSidebarAssignment: + write ?? + (async ({ channelId, sectionId }) => { + const assignments = { ...data.assignments }; + if (sectionId) assignments[channelId] = sectionId; + else delete assignments[channelId]; + return { sections: data.sections, assignments }; + }), + writeSidebarStar: + writeStar ?? + (async ({ channelId, starred }) => [ + ...data.starred.filter((id) => id !== channelId), + ...(starred ? [channelId] : []), + ]), + } + : {}), }); return { wire, owner, preferences: owner.session.sidebarPreferences, decode }; } +it("applies a confirmed assignment to the retained session snapshot", async () => { + const write = vi.fn(async () => ({ + sections: [ + { id: "work", name: "Work", order: 0 }, + { id: "later", name: "Later", order: 1 }, + ], + assignments: { alpha: "later" }, + })); + const { wire, owner, preferences } = setup(undefined, write); + try { + const initial = preferences.ensure(); + await flush(); + wire.next().respond([]); + await initial; + const listener = vi.fn(); + preferences.subscribe(listener); + await expect(preferences.assign("alpha", "later")).resolves.toMatchObject({ + assignments: { alpha: "later" }, + }); + expect(write).toHaveBeenCalledWith( + { channelId: "alpha", sectionId: "later" }, + expect.any(AbortSignal), + ); + expect(preferences.snapshot()).toEqual({ + status: "ready", + data: { + sections: [ + { id: "work", name: "Work", order: 0 }, + { id: "later", name: "Later", order: 1 }, + ], + assignments: { alpha: "later" }, + starred: ["beta"], + }, + }); + expect(Object.isFrozen(preferences.snapshot().data?.assignments)).toBe( + true, + ); + expect(listener).toHaveBeenCalledTimes(2); // optimistic placement, then confirmation + } finally { + owner.dispose(); + } +}); + +it("keeps the last confirmed snapshot when an assignment fails", async () => { + const write = vi.fn(async () => { + throw new Error("relay rejected write"); + }); + const { wire, owner, preferences } = setup(undefined, write); + try { + const initial = preferences.ensure(); + await flush(); + wire.next().respond([]); + await initial; + const retained = preferences.snapshot(); + await expect(preferences.assign("alpha")).rejects.toThrow( + "relay rejected write", + ); + expect(preferences.snapshot().data).toEqual(retained.data); + expect(preferences.snapshot().moves).toEqual([ + expect.objectContaining({ + pending: false, + error: "relay rejected write", + }), + ]); + } finally { + owner.dispose(); + } +}); + +it("does not let an older refresh overwrite a confirmed assignment", async () => { + let resolveDecode!: (value: SidebarPreferences) => void; + const decode = vi.fn( + async () => + new Promise((resolve) => { + resolveDecode = resolve; + }), + ); + const write = vi.fn(async () => ({ + sections: [{ id: "work", name: "Work", order: 0 }], + assignments: { alpha: "work" }, + })); + const { wire, owner, preferences } = setup(decode, write); + try { + decode.mockResolvedValueOnce(data); + const initial = preferences.ensure(); + await flush(); + wire.next().respond([]); + await initial; + const refresh = preferences.refresh(); + await flush(); + wire.next().respond([]); + await flush(); + await preferences.assign("alpha", "work"); + resolveDecode({ ...data, assignments: {} }); + await refresh; + expect(preferences.snapshot().data?.assignments).toEqual({ alpha: "work" }); + } finally { + owner.dispose(); + } +}); + +it("rejects queued assignment results after session cache clear", async () => { + let resolveWrite!: ( + value: Awaited>, + ) => void; + const write = vi.fn( + async () => + new Promise((resolve) => { + resolveWrite = resolve; + }), + ); + const { wire, owner, preferences } = setup(undefined, write); + const initial = preferences.ensure(); + await flush(); + wire.next().respond([]); + await initial; + const pending = preferences.assign("alpha", "work"); + await flush(); + await owner.clearCache(); + resolveWrite({ + sections: [{ id: "work", name: "Work", order: 0 }], + assignments: { alpha: "work" }, + }); + await expect(pending).rejects.toThrow("unavailable"); + expect(preferences.snapshot()).toEqual({ status: "idle" }); + owner.dispose(); +}); it("one session retains groups across observers and deduplicates initial reads", async () => { const { wire, owner, preferences, decode } = setup(); try { @@ -164,3 +327,178 @@ it.each(["clearCache", "dispose"] as const)( } }, ); + +it("serializes confirmed assignment and star writes without losing either projection", async () => { + const gate = deferred(); + const started = deferred(); + const star = vi.fn(async ({ starred }) => { + if (!starred) return ["alpha"]; + started.resolve(); + return gate.promise; + }); + const assign = vi.fn(async () => ({ + sections: data.sections, + assignments: { alpha: "work", beta: "work" }, + })); + const { wire, owner, preferences } = setup(undefined, assign, star); + try { + const initial = preferences.ensure(); + await flush(); + wire.next().respond([]); + await initial; + const pending = preferences.setStar("alpha", true); + await started.promise; + const queued = preferences.assign("beta", "work"); + expect(preferences.snapshot().data).toEqual({ + ...data, + assignments: { alpha: "work", beta: "work" }, + starred: ["alpha"], + }); + expect(assign).not.toHaveBeenCalled(); + gate.resolve(["alpha", "beta"]); + await Promise.all([pending, queued]); + expect(preferences.snapshot()).toEqual({ + status: "ready", + data: { + ...data, + assignments: { alpha: "work", beta: "work" }, + starred: ["alpha"], + }, + }); + expect(Object.isFrozen(preferences.snapshot().data?.starred)).toBe(true); + expect(star).toHaveBeenCalledWith( + { channelId: "alpha", starred: true }, + expect.any(AbortSignal), + ); + } finally { + gate.resolve([]); + owner.dispose(); + } +}); + +it("failed Star retains the confirmed snapshot and a retry can unstar", async () => { + const star = vi + .fn() + .mockRejectedValueOnce(new Error("publish rejected")) + .mockResolvedValueOnce([]); + const { wire, owner, preferences } = setup(undefined, undefined, star); + try { + const initial = preferences.ensure(); + await flush(); + wire.next().respond([]); + await initial; + const retained = preferences.snapshot(); + await expect(preferences.setStar("beta", false)).rejects.toThrow( + "publish rejected", + ); + expect(preferences.snapshot().data).toEqual(retained.data); + expect(preferences.snapshot().moves).toEqual([ + expect.objectContaining({ pending: false, error: "publish rejected" }), + ]); + await preferences.retryMove("beta"); + expect(preferences.snapshot().data).toEqual({ ...data, starred: [] }); + } finally { + owner.dispose(); + } +}); + +it.each(["success", "failure"])( + "a stale refresh %s cannot overwrite confirmed Star", + async (outcome) => { + const gate = deferred(); + const started = deferred(); + const decode = vi + .fn(async () => data) + .mockImplementationOnce(async () => data); + const { wire, owner, preferences } = setup(decode, undefined, async () => [ + "alpha", + "beta", + ]); + try { + const initial = preferences.ensure(); + await flush(); + wire.next().respond([]); + await initial; + decode.mockImplementationOnce(() => { + started.resolve(); + return gate.promise; + }); + const refresh = preferences.refresh(); + await flush(); + wire.next().respond([]); + await started.promise; + await preferences.setStar("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?.starred).toEqual(["alpha", "beta"]); + } finally { + gate.resolve(data); + owner.dispose(); + } + }, +); + +it.each(["clearCache", "dispose", "cancel"] as const)( + "%s aborts Star and fences active and queued writes", + async (action) => { + const gate = deferred(); + const started = deferred(); + const star = vi.fn(async (_intent, signal) => { + started.resolve(signal); + return gate.promise; + }); + const { wire, owner, preferences } = setup(undefined, undefined, star); + const caller = new AbortController(); + try { + const initial = preferences.ensure(); + await flush(); + wire.next().respond([]); + await initial; + const pending = preferences.setStar("alpha", true, caller.signal); + const activeSignal = await started.promise; + const queued = preferences.setStar("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(star).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 star = vi.fn(async () => []); + const assign = vi.fn(async () => data); + const { owner, preferences } = setup(undefined, assign, star); + try { + await expect(preferences.setStar("alpha", true)).rejects.toThrow( + "unavailable", + ); + await expect(preferences.assign("alpha", "work")).rejects.toThrow(); + expect(star).not.toHaveBeenCalled(); + expect(assign).not.toHaveBeenCalled(); + } finally { + owner.dispose(); + } + const readonly = setup(); + try { + expect(readonly.preferences.starWritable).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..8e64fe928 100644 --- a/src/features/relay/sidebar-preferences-store.ts +++ b/src/features/relay/sidebar-preferences-store.ts @@ -1,15 +1,41 @@ -import type { SidebarPreferences } from "./sidebar-preferences"; +import type { + SidebarAssignmentMutator, + SidebarAssignmentIntent, + SidebarStarMutator, + SidebarSortMode, + SidebarSortMutator, + SidebarPreferences, +} from "./sidebar-preferences"; +type MoveDestination = + | { starred: true } + | Omit; +type MoveIntent = Readonly<{ + id: number; + channelId: string; + destination: MoveDestination; +}>; +type MoveFailure = MoveIntent & Readonly<{ error: string }>; +type SortFailure = Readonly<{ + group: string; + mode: SidebarSortMode; + error: string; +}>; type Snapshot = Readonly<{ status: "idle" | "loading" | "ready" | "error" | "unsupported"; data?: SidebarPreferences; error?: string; + moves?: readonly (MoveIntent & { pending: boolean; error?: string })[]; + sortErrors?: readonly SortFailure[]; }>; /** One bounded account-preference projection per relay session, not per page. */ export function createSidebarPreferencesStore( read: (signal?: AbortSignal) => Promise, available: boolean, + write?: SidebarAssignmentMutator, + writeStar?: SidebarStarMutator, + writeSort?: SidebarSortMutator, notify = (listener: () => void) => listener(), ) { const listeners = new Set<() => void>(); @@ -20,33 +46,134 @@ export function createSidebarPreferencesStore( let active: | { controller: AbortController; promise: Promise } | undefined; + let writeQueue = Promise.resolve(); + let writeLifetime = new AbortController(); + let mutation = 0; + let confirmedPlacement: SidebarPreferences | undefined; + let nextMove = 0; + const pendingMoves = new Map(); + const failedMoves = new Map(); + const latestMove = new Map(); + const withPendingMoves = (data: SidebarPreferences): SidebarPreferences => { + let sections = data.sections; + const assignments = { ...data.assignments }; + const starred = new Set(data.starred); + for (const { channelId, destination } of pendingMoves.values()) { + if ("starred" in destination) starred.add(channelId); + else { + starred.delete(channelId); + const created = destination.createSection; + if (created && !sections.some(({ id }) => id === created.id)) + sections = [ + ...sections, + { + ...created, + name: created.name.trim(), + order: Math.max(-1, ...sections.map(({ order }) => order)) + 1, + }, + ]; + const target = created?.id ?? destination.sectionId; + if (target) assignments[channelId] = target; + else delete assignments[channelId]; + } + } + return { ...data, sections, assignments, starred: [...starred] }; + }; + let generation = 0; + let nextSortMutation = 0; + const latestSort = new Map(); + const failedSorts = new Map(); + const pendingSorts = new Map< + number, + { group: string; mode: SidebarSortMode } + >(); + let confirmedSort: Readonly> = {}; + const withSort = ( + sort: Readonly>, + group: string, + mode: SidebarSortMode, + ) => { + const next = { ...sort }; + if (mode === "alpha") delete next[group]; + else next[group] = mode; + return next; + }; + const withPendingSorts = ( + sort: Readonly>, + ) => { + let next = sort; + for (const pending of pendingSorts.values()) + next = withSort(next, pending.group, pending.mode); + return next; + }; + const retained = (data: SidebarPreferences): SidebarPreferences => + Object.freeze({ + sections: Object.freeze( + data.sections.map((section) => Object.freeze({ ...section })), + ), + assignments: Object.freeze({ ...data.assignments }), + starred: Object.freeze([...data.starred]), + ...(data.sort ? { sort: Object.freeze({ ...data.sort }) } : {}), + }); const publish = (next: Snapshot) => { - snapshot = Object.freeze(next); + const { moves: _moves, sortErrors: _sortErrors, ...state } = next; + const moves = [ + ...Array.from(pendingMoves.values(), (intent) => ({ + ...intent, + pending: true, + })), + ...Array.from(failedMoves.values(), (intent) => ({ + ...intent, + pending: false, + })), + ]; + snapshot = Object.freeze({ + ...state, + ...(failedSorts.size + ? { sortErrors: Object.freeze([...failedSorts.values()]) } + : {}), + ...(moves.length ? { moves: Object.freeze(moves) } : {}), + }); for (const listener of listeners) notify(listener); }; function refresh(): Promise { if (closed || !available) return Promise.resolve(); + if (pendingMoves.size) return writeQueue; 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; + confirmedSort = { ...(data.sort ?? {}) }; + confirmedPlacement = data; publish({ status: "ready", - data: Object.freeze({ - sections: Object.freeze( - data.sections.map((section) => Object.freeze({ ...section })), + data: retained( + withPendingMoves( + pendingSorts.size + ? { ...data, sort: withPendingSorts(confirmedSort) } + : data, ), - assignments: Object.freeze({ ...data.assignments }), - starred: Object.freeze([...data.starred]), - }), + ), }); } catch (error) { - if (!closed && !controller.signal.aborted && active === job) + if ( + !closed && + !controller.signal.aborted && + active === job && + mutation === refreshMutation + ) publish({ ...snapshot, status: "error", @@ -62,9 +189,225 @@ export function createSidebarPreferencesStore( }); return job.promise; } + // Optimistic placement is a projection over confirmed data. Persistence keeps + // assignment/create before Star removal, in the same queue as sorting. A failed + // older intent cannot undo a later move or replace its retry state. + function move( + channelId: string, + destination: MoveDestination, + signal?: AbortSignal, + ): Promise { + if (closed || !snapshot.data || !confirmedPlacement || !writeStar || !write) + return Promise.reject( + new Error("Sidebar group moves are unavailable in this host"), + ); + const writeGeneration = generation; + const writeSignal = AbortSignal.any([ + writeLifetime.signal, + ...(signal ? [signal] : []), + ]); + if (writeSignal.aborted) return Promise.reject(writeSignal.reason); + const intent: MoveIntent = { id: ++nextMove, channelId, destination }; + pendingMoves.set(intent.id, intent); + latestMove.set(channelId, intent.id); + failedMoves.delete(channelId); + mutation++; + const project = () => { + if (!confirmedPlacement) return; + const sort = snapshot.data?.sort; + publish({ + status: "ready", + data: retained( + withPendingMoves({ + ...confirmedPlacement, + ...(sort ? { sort } : {}), + }), + ), + }); + }; + const check = () => { + if (closed || generation !== writeGeneration) + throw new Error("Sidebar group moves are unavailable"); + writeSignal.throwIfAborted(); + }; + const run = writeQueue + .catch(() => {}) + .then(async () => { + try { + check(); + const starring = "starred" in destination; + // Always check the fresh assignment head on removal, even if cached + // placement has no assignment. Another device may have changed it. + const groups = starring + ? undefined + : await write({ channelId, ...destination }, writeSignal); + check(); + const stars = await writeStar( + { channelId, starred: starring }, + writeSignal, + ); + check(); + if (!confirmedPlacement) + throw new Error("Sidebar group moves are unavailable"); + confirmedPlacement = { + ...confirmedPlacement, + ...(groups + ? { sections: groups.sections, assignments: groups.assignments } + : {}), + starred: stars, + }; + return retained(confirmedPlacement); + } catch (error) { + if ( + !closed && + generation === writeGeneration && + !writeSignal.aborted && + latestMove.get(channelId) === intent.id + ) + failedMoves.set(channelId, { + ...intent, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } finally { + if (!closed && generation === writeGeneration) { + mutation++; + pendingMoves.delete(intent.id); + project(); + } + } + }); + writeQueue = run.then( + () => undefined, + () => undefined, + ); + project(); + return run; + } return { queries: Object.freeze({ available, + retryMove(channelId: string) { + const failed = failedMoves.get(channelId); + return failed + ? move(channelId, failed.destination) + : Promise.reject(new Error("No failed move to retry")); + }, + dismissMoveError(channelId: string) { + failedMoves.delete(channelId); + publish(snapshot); + }, + writable: !!write && !!writeStar, + assign(channelId: string, sectionId?: string, signal?: AbortSignal) { + return move(channelId, sectionId ? { sectionId } : {}, signal); + }, + createAndAssign( + channelId: string, + section: { id: string; name: string }, + signal?: AbortSignal, + ) { + return move(channelId, { createSection: section }, signal); + }, + starWritable: !!write && !!writeStar, + async setStar(channelId: string, starred: boolean, signal?: AbortSignal) { + const data = await move( + channelId, + starred ? { starred: true } : {}, + signal, + ); + return data.starred; + }, + dismissSortError(group: string) { + failedSorts.delete(group); + publish(snapshot); + }, + sortWritable: !!writeSort, + setSort( + group: string, + mode: SidebarSortMode, + sectionIds: readonly string[], + signal?: AbortSignal, + ) { + if (closed || !writeSort || !snapshot.data) + return Promise.reject( + new Error("Sidebar sorting is read-only in this host"), + ); + const writeGeneration = generation; + const writeSignal = AbortSignal.any([ + writeLifetime.signal, + ...(signal ? [signal] : []), + ]); + if (writeSignal.aborted) return Promise.reject(writeSignal.reason); + const id = ++nextSortMutation; + pendingSorts.set(id, { group, mode }); + latestSort.set(group, id); + failedSorts.delete(group); + mutation++; + const current = snapshot.data ?? { + sections: [], + assignments: {}, + starred: [], + }; + publish({ + status: "ready", + data: retained({ + ...current, + sort: withSort(current.sort ?? confirmedSort, group, mode), + }), + }); + const settle = (sort?: Readonly>) => { + if (closed || generation !== writeGeneration) return; + if (sort) confirmedSort = { ...sort }; + pendingSorts.delete(id); + const latest = snapshot.data ?? current; + publish({ + status: "ready", + data: retained({ + ...latest, + sort: withPendingSorts(confirmedSort), + }), + }); + }; + const run = writeQueue + .catch(() => {}) + .then(async () => { + if (closed || generation !== writeGeneration) + throw new Error("Sidebar sorting is unavailable"); + try { + writeSignal.throwIfAborted(); + const sort = await writeSort( + group, + mode, + sectionIds, + writeSignal, + ); + if (closed || generation !== writeGeneration) + throw new Error("Sidebar sorting is unavailable"); + writeSignal.throwIfAborted(); + mutation++; + settle(sort); + return sort; + } catch (error) { + if (!closed && generation === writeGeneration) { + mutation++; + if (!writeSignal.aborted && latestSort.get(group) === id) + failedSorts.set(group, { + group, + mode, + error: + error instanceof Error ? error.message : String(error), + }); + } + settle(); + throw error; + } + }); + writeQueue = run.then( + () => undefined, + () => undefined, + ); + return run; + }, // Keep explicit one-shot reads compatible; views use the retained snapshot. read, snapshot: () => snapshot, @@ -83,12 +426,35 @@ export function createSidebarPreferencesStore( }), clear() { if (closed) return; + generation++; + confirmedPlacement = undefined; + pendingMoves.clear(); + failedMoves.clear(); + latestMove.clear(); + mutation++; + pendingSorts.clear(); + latestSort.clear(); + failedSorts.clear(); + confirmedSort = {}; + writeLifetime.abort(); + writeLifetime = new AbortController(); active?.controller.abort(); active = undefined; publish(empty()); }, dispose() { closed = true; + pendingSorts.clear(); + latestSort.clear(); + failedSorts.clear(); + confirmedSort = {}; + writeLifetime.abort(); + generation++; + confirmedPlacement = undefined; + pendingMoves.clear(); + failedMoves.clear(); + latestMove.clear(); + mutation++; 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..5199ea689 100644 --- a/src/features/relay/sidebar-preferences.test.ts +++ b/src/features/relay/sidebar-preferences.test.ts @@ -15,6 +15,13 @@ import { projectSidebarPreferences, readSidebarPreferences, } from "./sidebar-preferences"; +import { + assertSidebarAssignmentIntent, + decodeSidebarPreferences, + mutateSidebarAssignment, + prepareSidebarAssignment, + SIDEBAR_REQUEST_BYTES, +} from "../../../dev/sidebar-preferences.mjs"; import { keypair, signed, scriptedTransport, flush, roster } from "./testing"; import type { LiveCallbacks } from "./live"; import { ReadError } from "./errors"; @@ -70,6 +77,12 @@ it("reads legacy preferences through the production session, transport, and boun "#d": ["channel-stars"], limit: 1, }, + { + kinds: [30078], + authors: [viewer.pubkey], + "#d": ["channel-sort"], + limit: 1, + }, ]); return Response.json(result); }); @@ -578,3 +591,170 @@ it.each([ } }, ); + +it("rejects invalid assignment intents before any relay work", () => { + for (const intent of [ + null, + [], + {}, + { channelId: "" }, + { channelId: "general", sectionId: "" }, + { channelId: "general", extra: true }, + ]) + expect(() => assertSidebarAssignmentIntent(intent)).toThrow( + "Invalid sidebar assignment intent", + ); +}); + +it("prepares one host-owned assignment without replacing unrelated groups", () => { + const viewer = keypair(); + const encrypt = (value: unknown, created_at = 100) => + signed(viewer, { + kind: 30078, + created_at, + tags: [["d", "channel-sections"]], + content: nip44.v2.encrypt( + JSON.stringify(value), + nip44.v2.utils.getConversationKey(viewer.secret, viewer.pubkey), + ), + }); + const head = encrypt({ + version: 1, + sections: [ + { id: "work", name: "Work", order: 0 }, + { id: "later", name: "Later", order: 1 }, + ], + assignments: { general: "work", random: "later" }, + }); + const moved = prepareSidebarAssignment( + [head], + { channelId: "general", sectionId: "later" }, + viewer.secret, + 50_000, + ); + expect(moved.groups.assignments).toEqual({ + general: "later", + random: "later", + }); + expect(moved.event).toBeDefined(); + if (!moved.event) throw new Error("Missing sidebar assignment event"); + expect(moved.event).toMatchObject({ + kind: 30078, + pubkey: viewer.pubkey, + created_at: 101, + tags: [ + ["d", "channel-sections"], + ["t", "channel-sections"], + ], + }); + expect(decodeSidebarPreferences([moved.event], viewer.secret)).toMatchObject({ + sections: [ + { id: "work", name: "Work", order: 0 }, + { id: "later", name: "Later", order: 1 }, + ], + assignments: { general: "later", random: "later" }, + }); + const removed = prepareSidebarAssignment( + [moved.event], + { channelId: "general" }, + viewer.secret, + 50_000, + ); + expect(removed.groups.assignments).toEqual({ random: "later" }); + expect(() => + prepareSidebarAssignment( + [head], + { channelId: "general", sectionId: "gone" }, + viewer.secret, + ), + ).toThrow("no longer exists"); + const same = prepareSidebarAssignment( + [head], + { channelId: "general", sectionId: "work" }, + viewer.secret, + ); + expect(same.event).toBeUndefined(); +}); + +it("applies decoder-parity bounds to the untrusted sidebar group head", () => { + const viewer = keypair(); + const event = signed(viewer, { + kind: 30078, + tags: [["d", "channel-sections"]], + content: "x".repeat(SIDEBAR_REQUEST_BYTES), + }); + expect(Buffer.byteLength(JSON.stringify([event]))).toBeGreaterThan( + SIDEBAR_REQUEST_BYTES, + ); + expect(() => + prepareSidebarAssignment([event], { channelId: "general" }, viewer.secret), + ).toThrow("Invalid sidebar group head"); +}); + +it("confirms the requested assignment while preserving newer unrelated assignments", async () => { + const viewer = keypair(); + const encrypt = (assignments: Record) => + signed(viewer, { + kind: 30078, + tags: [["d", "channel-sections"]], + content: nip44.v2.encrypt( + JSON.stringify({ + version: 1, + sections: [{ id: "work", name: "Work", order: 0 }], + assignments, + }), + nip44.v2.utils.getConversationKey(viewer.secret, viewer.pubkey), + ), + }); + const initial = encrypt({}); + let confirmation = [initial]; + let publishedAssignments: Readonly> = {}; + const result = await mutateSidebarAssignment( + { channelId: "general", sectionId: "work" }, + viewer.secret, + async () => confirmation, + async (event) => { + publishedAssignments = decodeSidebarPreferences( + [event], + viewer.secret, + ).assignments; + confirmation = [encrypt({ ...publishedAssignments, random: "work" })]; + }, + ); + expect(publishedAssignments).toEqual({ general: "work" }); + expect(result.assignments).toEqual({ general: "work", random: "work" }); +}); + +it("assignment writes retain unrelated raw fields and dangling assignments omitted from the view", () => { + const viewer = keypair(); + const key = nip44.v2.utils.getConversationKey(viewer.secret, viewer.pubkey); + try { + const blob = { + version: 1, + extra: "preserve", + sections: [{ id: "work", name: "Work", order: 0, color: "blue" }], + assignments: { old: "missing", random: "work" }, + }; + const head = signed(viewer, { + kind: 30078, + tags: [["d", "channel-sections"]], + content: nip44.v2.encrypt(JSON.stringify(blob), key), + }); + const result = prepareSidebarAssignment( + [head], + { channelId: "__proto__", sectionId: "work" }, + viewer.secret, + ); + if (!result.event) throw new Error("Expected assignment publication"); + expect(JSON.parse(nip44.v2.decrypt(result.event.content, key))).toEqual({ + ...blob, + assignments: { ...blob.assignments, ["__proto__"]: "work" }, + }); + expect(result.groups.assignments).toEqual({ + random: "work", + ["__proto__"]: "work", + }); + } finally { + key.fill(0); + } +}); diff --git a/src/features/relay/sidebar-preferences.ts b/src/features/relay/sidebar-preferences.ts index 1214d652d..cd2ad3de1 100644 --- a/src/features/relay/sidebar-preferences.ts +++ b/src/features/relay/sidebar-preferences.ts @@ -4,8 +4,9 @@ import type { RelayReader } from "./reader.ts"; export const SIDEBAR_COORDINATES = [ "channel-sections", "channel-stars", + "channel-sort", ] as const; -export type SidebarPreferences = Readonly<{ +export type SidebarGroups = Readonly<{ sections: readonly Readonly<{ id: string; name: string; @@ -13,13 +14,36 @@ export type SidebarPreferences = Readonly<{ order: number; }>[]; assignments: Readonly>; - starred: readonly string[]; }>; +export type SidebarSortMode = "alpha" | "recent"; +export type SidebarPreferences = SidebarGroups & + Readonly<{ + starred: readonly string[]; + sort?: Readonly>; + }>; +export type SidebarAssignmentIntent = Readonly<{ + channelId: string; + sectionId?: string; + createSection?: Readonly<{ id: string; name: string }>; +}>; +export type SidebarSortMutator = ( + group: string, + mode: SidebarSortMode, + sectionIds: readonly string[], + signal: AbortSignal, +) => Promise>>; +export type SidebarAssignmentMutator = ( + intent: SidebarAssignmentIntent, + signal: AbortSignal, +) => Promise; +export type SidebarStarMutator = ( + intent: Readonly<{ channelId: string; starred: boolean }>, + signal: AbortSignal, +) => Promise; export type SidebarDecoder = ( events: readonly RelayEvent[], signal: AbortSignal, ) => Promise; - function object(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid sidebar preferences"); @@ -34,11 +58,14 @@ function text(value: unknown, max = 256): string { export function projectSidebarPreferences( sections: unknown, stars: unknown, + sort?: unknown, + sortSectionIds?: readonly string[], ): SidebarPreferences { const result: { sections: { id: string; name: string; icon?: string; order: number }[]; assignments: Record; starred: string[]; + sort?: Record; } = { sections: [], assignments: {}, @@ -99,6 +126,27 @@ export function projectSidebarPreferences( if (entry.starred) result.starred.push(id); } } + if (sort !== undefined) { + const data = object(sort); + if (data.version !== 1) throw new Error("Unsupported sidebar sort"); + const entries = Object.entries(object(data.groups)); + if (entries.length > 104) throw new Error("Sidebar sort budget exceeded"); + const fixed = new Set(["starred", "channels", "forums", "dms"]); + const liveSections = new Set( + (sortSectionIds ?? result.sections.map((section) => section.id)).map( + (id) => `section:${id}`, + ), + ); + result.sort = Object.fromEntries( + entries + .filter( + (entry): entry is [string, SidebarSortMode] => + entry[1] === "alpha" || entry[1] === "recent", + ) + .map(([key, mode]) => [text(key, 264), mode] as const) + .filter(([key]) => fixed.has(key) || liveSections.has(key)), + ); + } return result; } diff --git a/src/features/relay/sidebar-sorting-store.test.ts b/src/features/relay/sidebar-sorting-store.test.ts new file mode 100644 index 000000000..adf1f4f7e --- /dev/null +++ b/src/features/relay/sidebar-sorting-store.test.ts @@ -0,0 +1,277 @@ +import { assert, expect, it, vi } from "vitest"; +import { createSidebarPreferencesStore } from "./sidebar-preferences-store"; +import type { + SidebarPreferences, + SidebarSortMode, +} from "./sidebar-preferences"; +import { flush } from "./testing"; + +type Sort = Readonly>; +const data: SidebarPreferences = { + sections: [{ id: "work", name: "Work", order: 0 }], + assignments: { alpha: "work" }, + starred: ["beta"], +}; +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +function setup() { + const pending: ReturnType>[] = []; + const read = vi.fn(async (): Promise => data); + const write = vi.fn(async () => ({ + ...data, + assignments: { alpha: "later" }, + })); + const star = vi.fn(async () => ["alpha", "beta"]); + const sort = vi.fn(() => { + const job = deferred(); + pending.push(job); + return job.promise; + }); + const owner = createSidebarPreferencesStore(read, true, write, star, sort); + return { owner, preferences: owner.queries, pending, read, sort }; +} + +it("independent optimistic choices survive older success and failure in queue order", async () => { + const { owner, preferences, pending, sort } = setup(); + try { + await preferences.ensure(); + const first = preferences.setSort("channels", "recent", ["work"]); + const firstFailed = expect(first).rejects.toThrow("offline"); + const second = preferences.setSort("section:work", "recent", ["work"]); + expect(preferences.snapshot().data).toEqual({ + ...data, + sort: { channels: "recent", "section:work": "recent" }, + }); + await flush(); + expect(sort).toHaveBeenCalledTimes(1); + take(pending).reject(new Error("offline")); + await firstFailed; + await flush(); + expect(preferences.snapshot().data?.sort).toEqual({ + "section:work": "recent", + }); + expect(preferences.snapshot().sortErrors).toEqual([ + { group: "channels", mode: "recent", error: "offline" }, + ]); + take(pending).resolve({ "section:work": "recent" }); + await second; + expect(preferences.snapshot().data).toEqual({ + ...data, + sort: { "section:work": "recent" }, + }); + } finally { + owner.dispose(); + } +}); + +it("an older confirmation never overwrites a newer A–Z intent for the same section", async () => { + const { owner, preferences, pending } = setup(); + try { + await preferences.ensure(); + const first = preferences.setSort("channels", "recent", []); + const second = preferences.setSort("channels", "alpha", []); + expect(preferences.snapshot().data?.sort).toEqual({}); + await flush(); + take(pending).resolve({ channels: "recent" }); + await first; + await flush(); + expect(preferences.snapshot().data?.sort).toEqual({}); + take(pending).resolve({}); + await second; + expect(preferences.snapshot().data?.sort).toEqual({}); + } finally { + owner.dispose(); + } +}); + +it("refresh during a pending intent updates unrelated data without hiding that intent", async () => { + const { owner, preferences, pending, read } = setup(); + try { + await preferences.ensure(); + const writing = preferences.setSort("channels", "recent", []); + const failed = expect(writing).rejects.toThrow("offline"); + await flush(); + read.mockResolvedValueOnce({ + ...data, + starred: ["alpha"], + sort: { forums: "recent" }, + }); + await preferences.refresh(); + expect(preferences.snapshot().data).toEqual({ + ...data, + starred: ["alpha"], + sort: { channels: "recent", forums: "recent" }, + }); + take(pending).reject(new Error("offline")); + await failed; + expect(preferences.snapshot().data).toEqual({ + ...data, + starred: ["alpha"], + sort: { forums: "recent" }, + }); + } finally { + owner.dispose(); + } +}); + +it("group and Star confirmations retain a queued optimistic sort and vice versa", async () => { + const { owner, preferences, pending } = setup(); + try { + await preferences.ensure(); + const assigning = preferences.assign("alpha", "later"); + const starring = preferences.setStar("alpha", true); + const sorting = preferences.setSort("channels", "recent", ["work"]); + await assigning; + await starring; + expect(preferences.snapshot().data).toEqual({ + ...data, + assignments: { alpha: "later" }, + starred: ["alpha", "beta"], + sort: { channels: "recent" }, + }); + await flush(); + take(pending).resolve({ channels: "recent" }); + await sorting; + expect(preferences.snapshot().data).toEqual({ + ...data, + assignments: { alpha: "later" }, + starred: ["alpha", "beta"], + sort: { channels: "recent" }, + }); + } finally { + owner.dispose(); + } +}); + +it.each(["clear", "dispose"] as const)( + "%s aborts active sorting and prevents queued writes/late repopulation", + async (action) => { + const { owner, preferences, pending, sort } = setup(); + await preferences.ensure(); + const first = preferences.setSort("channels", "recent", []); + const second = preferences.setSort("forums", "recent", []); + const failed = [ + expect(first).rejects.toThrow("unavailable"), + expect(second).rejects.toThrow("unavailable"), + ]; + await flush(); + owner[action](); + take(pending).resolve({ channels: "recent" }); + await Promise.all(failed); + expect(sort).toHaveBeenCalledTimes(1); + expect( + ( + sort.mock.calls[0] as unknown as [string, string, string[], AbortSignal] + )[3].aborted, + ).toBe(true); + expect(preferences.snapshot().data).toBeUndefined(); + expect(preferences.snapshot().sortErrors).toBeUndefined(); + owner.dispose(); + }, +); + +it("caller cancellation rolls back only its own intent and skips its queued publication", async () => { + const { owner, preferences, pending, sort } = setup(); + try { + await preferences.ensure(); + const first = preferences.setSort("channels", "recent", []); + const controller = new AbortController(); + const second = preferences.setSort( + "forums", + "recent", + [], + controller.signal, + ); + const failed = expect(second).rejects.toThrow(); + controller.abort(); + await flush(); + take(pending).resolve({ channels: "recent" }); + await first; + await failed; + expect(sort).toHaveBeenCalledTimes(1); + expect(preferences.snapshot().data?.sort).toEqual({ channels: "recent" }); + expect(preferences.snapshot().sortErrors).toBeUndefined(); + } finally { + owner.dispose(); + } +}); + +it("a superseded failure cannot expose retry or roll back a newer choice", async () => { + const { owner, preferences, pending } = setup(); + try { + await preferences.ensure(); + const first = preferences.setSort("channels", "recent", []); + const failed = expect(first).rejects.toThrow("offline"); + const second = preferences.setSort("channels", "alpha", []); + await flush(); + take(pending).reject(new Error("offline")); + await failed; + expect(preferences.snapshot().sortErrors).toBeUndefined(); + expect(preferences.snapshot().data?.sort).toEqual({}); + await flush(); + take(pending).resolve({}); + await second; + expect(preferences.snapshot().sortErrors).toBeUndefined(); + } finally { + owner.dispose(); + } +}); + +it("sort failure survives observer removal, supports retry, and dismisses per section", async () => { + const { owner, preferences, pending } = setup(); + try { + await preferences.ensure(); + const off = preferences.subscribe(vi.fn()); + const first = preferences.setSort("channels", "recent", []); + const failed = expect(first).rejects.toThrow("offline"); + off(); + await flush(); + take(pending).reject(new Error("offline")); + await failed; + const again = preferences.subscribe(vi.fn()); + await preferences.ensure(); + const failure = preferences.snapshot().sortErrors?.[0]; + expect(failure).toEqual({ + group: "channels", + mode: "recent", + error: "offline", + }); + assert.exists(failure); + const retry = preferences.setSort(failure.group, failure.mode, []); + expect(preferences.snapshot().sortErrors).toBeUndefined(); + expect(preferences.snapshot().data?.sort).toEqual({ channels: "recent" }); + await flush(); + take(pending).resolve({ channels: "recent" }); + await retry; + for (const group of ["channels", "forums"]) { + const rejected = expect( + preferences.setSort(group, "alpha", []), + ).rejects.toThrow("offline"); + await flush(); + take(pending).reject(new Error("offline")); + await rejected; + } + preferences.dismissSortError("channels"); + expect(preferences.snapshot().sortErrors).toEqual([ + { group: "forums", mode: "alpha", error: "offline" }, + ]); + again(); + owner.clear(); + expect(preferences.snapshot().sortErrors).toBeUndefined(); + } finally { + owner.dispose(); + } +}); + +function take(pending: T[]): T { + const next = pending.shift(); + assert.exists(next, "Expected a pending operation"); + return next; +} diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index d03706ef1..61428e5c6 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -9,7 +9,14 @@ import { readSnapshotText, } from "./read-state-snapshot"; import type { AgentLibraryReader } from "../agents/library"; -import type { SidebarDecoder, SidebarPreferences } from "./sidebar-preferences"; +import { + projectSidebarPreferences, + type SidebarAssignmentMutator, + type SidebarStarMutator, + type SidebarSortMutator, + type SidebarDecoder, + type SidebarPreferences, +} from "./sidebar-preferences"; import { createHostAdmission } from "./host-admission"; import { relayOrigin } from "../communities/destination"; import { @@ -56,7 +63,7 @@ export interface ReadTransport { /** Explicit relay-advertised session command support. */ /** Host-projected local library; display only, never relay authority. */ readonly readAgentLibrary?: AgentLibraryReader; - /** Host-only decoder of the viewer's two signed sidebar preference coordinates. */ + /** Host-only decoder of the viewer's signed sidebar preference coordinates. */ readonly decodeSidebarPreferences?: SidebarDecoder; readonly readState?: ReadStateHost; /** Strictly validated atomic writer snapshot; never an ordinary event-array query. */ @@ -73,6 +80,10 @@ export interface ReadTransport { string, "online" | "away" | "offline" | "unknown" > | null>; + /** Host-only, relay-scoped mutation of one existing sidebar group assignment. */ + readonly writeSidebarAssignment?: SidebarAssignmentMutator; + readonly writeSidebarStar?: SidebarStarMutator; + readonly writeSidebarSort?: SidebarSortMutator; readonly profiling?: RelayProfiler; /** Verified incoming traffic. The session owns this subscription and fences late delivery. */ subscribe?(callbacks: LiveCallbacks): LiveSubscription; @@ -86,6 +97,11 @@ export interface ReadTransport { readonly relayAuthor: string; /** Explicit NIP-11 self from this community, never a contact-key fallback. */ readonly archiveAuthority?: string; + /** Purpose-bound authoritative recency, verified and max 128 channel IDs. */ + channelActivity?( + channelIds: readonly string[], + signal: AbortSignal, + ): Promise; query( filters: readonly ReadFilter[], signal?: AbortSignal, @@ -229,6 +245,10 @@ export async function connectBrokerTransport( live?: boolean; presence?: boolean; sidebarPreferences?: boolean; + sidebarPreferenceWrites?: boolean; + sidebarStarWrites?: boolean; + sidebarSortWrites?: boolean; + channelActivity?: boolean; agentLibrary?: boolean; agentActivity?: boolean; readState?: boolean; @@ -437,6 +457,82 @@ export async function connectBrokerTransport( }, } : {}), + ...(session.sidebarPreferenceWrites + ? { + async writeSidebarAssignment(intent, signal) { + const result = await fetch(`${endpoint}/sidebar-assignment`, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(intent), + signal, + }); + if (!result.ok) { + const failure = await readApiFailure(result); + throw new Error(failure.error); + } + const value = (await result.json()) as SidebarPreferences; + const groups = projectSidebarPreferences( + { + version: 1, + sections: value.sections, + assignments: value.assignments, + }, + undefined, + ); + return { + sections: groups.sections, + assignments: groups.assignments, + }; + }, + } + : {}), + ...(session.sidebarStarWrites + ? { + async writeSidebarStar(intent, signal) { + const result = await fetch(`${endpoint}/sidebar-star`, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(intent), + signal, + }); + if (!result.ok) + throw new Error((await readApiFailure(result)).error); + return projectSidebarPreferences(undefined, await result.json()) + .starred; + }, + } + : {}), + ...(session.sidebarSortWrites + ? { + async writeSidebarSort(group, mode, sectionIds, signal) { + const result = await fetch(`${endpoint}/sidebar-sort`, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ group, mode, sectionIds }), + signal, + }); + if (!result.ok) { + const failure = await readApiFailure(result); + throw new Error(failure.error); + } + const value = (await result.json()) as { groups?: unknown }; + return ( + projectSidebarPreferences( + undefined, + undefined, + { + version: 1, + groups: value.groups, + }, + sectionIds, + ).sort ?? {} + ); + }, + } + : {}), ...(session.writeKinds ? { writer: { @@ -469,6 +565,30 @@ export async function connectBrokerTransport( }, } : {}), + ...(session.channelActivity + ? { + async channelActivity(channelIds, signal) { + const result = await fetch(`${endpoint}/channel-activity`, { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "X-Buzz-Read-Priority": "background", + }, + body: JSON.stringify( + channelIds.map((channelId) => ({ + kinds: [9, 40002, 45001, 45003], + "#h": [channelId], + limit: 1, + })), + ), + signal, + }); + if (!result.ok) throw httpReadError(result.status); + return parseEvents(await result.json(), verify, signal); + }, + } + : {}), media: (url, size) => mediaUrl( url, diff --git a/src/shared/design-system/icons/index.ts b/src/shared/design-system/icons/index.ts index 89e0aed8c..aa35fa650 100644 --- a/src/shared/design-system/icons/index.ts +++ b/src/shared/design-system/icons/index.ts @@ -27,6 +27,11 @@ export const ArrowsClockwiseIcon = defineIcon( "phosphor", PhosphorArrowsClockwiseIcon, ); +import { ArrowsDownUpIcon as PhosphorArrowsDownUpIcon } from "@phosphor-icons/react/dist/csr/ArrowsDownUp"; +export const ArrowsDownUpIcon = defineIcon( + "phosphor", + PhosphorArrowsDownUpIcon, +); import { ArrowsInIcon as PhosphorArrowsInIcon } from "@phosphor-icons/react/dist/csr/ArrowsIn"; export const ArrowsInIcon = defineIcon("phosphor", PhosphorArrowsInIcon); import { ArrowsOutIcon as PhosphorArrowsOutIcon } from "@phosphor-icons/react/dist/csr/ArrowsOut"; diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 010761d89..478574bc4 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -38,6 +38,10 @@ export const test = base.extend({ sessionParents: [{}, { option: true }], sidebarUnread: [false, { option: true }], savedSidebar: [false, { option: true }], + sidebarIcons: [false, { option: true }], + channelNames: [{}, { option: true }], + sortingSidebar: [false, { option: true }], + initialSidebarSort: [{}, { option: true }], expectedPageFailure: [false, { option: true }], largeSidebar: [false, { option: true }], dmLabels: [false, { option: true }], @@ -66,6 +70,10 @@ export const test = base.extend({ sessionParents, sidebarUnread, savedSidebar, + sidebarIcons, + channelNames, + sortingSidebar, + initialSidebarSort, expectedPageFailure, largeSidebar, dmLabels, @@ -143,8 +151,14 @@ export const test = base.extend({ : dmLabels ? ["dm-peer"] : []; + const sortingIds = sortingSidebar ? ["cedar", "maple", "willow"] : []; const rosterIds = [ - ...new Set([...channels, ...dmIds, ...Object.values(sessionParents)]), + ...new Set([ + ...channels, + ...dmIds, + ...sortingIds, + ...Object.values(sessionParents), + ]), ]; if (savedSidebar) { const key = nip44.v2.utils.getConversationKey(userKey, viewer); @@ -155,7 +169,24 @@ export const test = base.extend({ "channel-sections", { version: 1, - sections: [{ id: "work", name: "Work", order: 0 }], + sections: [ + { + id: "work", + name: "Work", + order: 0, + ...(sidebarIcons ? { icon: ":stamp:" } : {}), + }, + ...(sidebarIcons + ? [ + { + id: "missing", + name: "Unavailable", + order: 1, + icon: ":unavailable_icon:", + }, + ] + : []), + ], assignments: { beta: "work" }, }, ], @@ -166,6 +197,7 @@ export const test = base.extend({ channels: { alpha: { starred: true, updatedAt: 1 } }, }, ], + ["channel-sort", { version: 1, groups: initialSidebarSort }], ]) { records.set( coordinate, @@ -206,6 +238,17 @@ export const test = base.extend({ const historyDurationMs = performance.now() - historyStarted; for (const community of ["primary", "secondary"]) for (const id of dmIds) histories.set(`${community}/${id}`, []); + for (const community of ["primary", "secondary"]) + for (const [index, id] of sortingIds.entries()) + histories.set(`${community}/${id}`, [ + sign( + 9, + [["h", id]], + `Activity in ${id}`, + userKey, + 1700000200 + index, + ), + ]); const targetEvents = []; let searchTarget; if (openSearch) { @@ -504,7 +547,11 @@ export const test = base.extend({ .map((id) => sign(39000, [ ["d", id], - ["name", id === "alpha" ? "Alpha" : id === "beta" ? "Beta" : id], + [ + "name", + channelNames[id] ?? + (id === "alpha" ? "Alpha" : id === "beta" ? "Beta" : id), + ], ...(id === "open" ? [["public"], ["t", "stream"]] : []), ...(dmIds.includes(id) ? [["t", "dm"], ["hidden"]] : []), ...(sessionChannels.includes(id) @@ -543,7 +590,23 @@ export const test = base.extend({ "#d": ["buzz:custom-emoji"], limit: 500, }); - return []; + return sidebarIcons + ? [ + sign( + 30030, + [ + ["d", "buzz:custom-emoji"], + [ + "emoji", + "stamp", + `https://${community}.example/media/stamp.png`, + ], + ], + "", + userKey, + ), + ] + : []; } if (filter.kinds?.includes(0)) return [ @@ -807,18 +870,54 @@ export const test = base.extend({ }, } : {}), - ...(readState + ...(readState || savedSidebar ? { - discovery: (community) => ({ - self: getPublicKey(relayKey), - read_state_snapshot: { - version: 1, - community_id: communityIds[community], - max_events: 4096, - max_bytes: 8388608, - }, - }), - acceptPublication: acceptReadPublication, + ...(readState + ? { + discovery: (community) => ({ + self: getPublicKey(relayKey), + read_state_snapshot: { + version: 1, + community_id: communityIds[community], + max_events: 4096, + max_bytes: 8388608, + }, + }), + } + : {}), + acceptPublication: (community, event) => { + expect(verifyEvent(event)).toBe(true); + expect(event.pubkey).toBe(viewer); + expect(event.kind).toBe(30078); + const coordinate = event.tags.find( + ([key]) => key === "d", + )?.[1]; + if ( + [ + "channel-sections", + "channel-stars", + "channel-sort", + ].includes(coordinate) + ) { + expect(event.tags).toContainEqual(["t", coordinate]); + const blob = JSON.parse( + nip44.v2.decrypt( + event.content, + nip44.v2.utils.getConversationKey(userKey, viewer), + ), + ); + readEvents.get(community).set(coordinate, event); + report.sidebarPublications ??= []; + report.sidebarPublications.push({ + community, + coordinate, + event, + blob, + }); + return; + } + acceptReadPublication(community, event); + }, } : {}), }) @@ -932,7 +1031,7 @@ export const test = base.extend({ `Unexpected fixture request: ${request.method} ${request.url}`, ); expect(body.length).toBeGreaterThan(0); - expect(body.length).toBeLessThanOrEqual(2); + expect(body.length).toBeLessThanOrEqual(3); const filter = body[0]; const result = [ ...new Map( @@ -1239,10 +1338,30 @@ export const test = base.extend({ observerFailures.splice(match, 1); return true; }; + // Preference retry journeys inject specific failed host requests. Match + // each exact URL once, not every 502 or every console error in the test. + const starFailures = [ + ...(report.sidebarStarFailures ?? []), + ...(report.sidebarSortFailures ?? []), + ...(report.sidebarAssignmentFailures ?? []), + ]; + const injectedStarFailure = (message, index) => { + if ( + !/^Failed to load resource: the server responded with a status of 502/.test( + message, + ) + ) + return false; + const match = starFailures.indexOf(consoleLocations.get(index)); + if (match < 0) return false; + starFailures.splice(match, 1); + return true; + }; expect( report.consoleErrors.filter( (message, index) => !retiredConsole(message, index) && + !injectedStarFailure(message, index) && !( expectedPageFailure && message.includes("Fixture page render failure") diff --git a/tests/browser/navigation-group-icons.spec.mjs b/tests/browser/navigation-group-icons.spec.mjs new file mode 100644 index 000000000..83490354a --- /dev/null +++ b/tests/browser/navigation-group-icons.spec.mjs @@ -0,0 +1,156 @@ +import { test, expect } from "./fixture.mjs"; +import { open } from "./timeline.mjs"; + +const channelName = "buzz-tiptap-view-dom-crash"; +test.use({ + productionBroker: true, + savedSidebar: true, + sidebarIcons: true, + channelNames: { beta: channelName }, +}); + +// Real catalog → session media URL → sidebar/menu image; browser layout owns +// loading placeholders and single-line dialog truncation, not a DOM emulator. +test("group icons resolve custom media without overlapping labels, and the create dialog stays one line", async ({ + page, + app, +}, testInfo) => { + await page.addInitScript(() => + localStorage.setItem("buzz-appearance.v1", "dark"), + ); + let release, started; + const held = new Promise((resolve) => { + release = resolve; + }); + const requested = new Promise((resolve) => { + started = resolve; + }); + await page.route("**/media?*", async (route) => { + started(); + await held; + const url = new URL(route.request().url()); + expect(url.origin).toBe(app.origin); + expect(url.searchParams.get("url")).toBe( + "https://primary.example/media/stamp.png", + ); + await route.fulfill({ + contentType: "image/svg+xml", + body: '', + }); + }); + const section = page.locator('[data-sidebar-section="group:work"]'); + const headerIcon = section.locator("summary img"); + try { + await open(page, app); + await requested; + const placeholder = section.locator("summary span[data-loading]"); + await expect(placeholder).toBeVisible(); + await expect(placeholder).toHaveCSS("width", "16px"); + await expect(placeholder).toHaveCSS("height", "16px"); + await expect(placeholder).not.toHaveCSS( + "background-color", + "rgba(0, 0, 0, 0)", + ); + await expect(headerIcon).toBeHidden(); + await expect(section.locator("summary")).not.toContainText(":stamp:"); + await section + .locator("summary") + .screenshot({ path: testInfo.outputPath("group-icon-loading.png") }); + } finally { + release(); + } + await expect(headerIcon).toBeVisible(); + await expect + .poll(() => + headerIcon.evaluate((image) => image.complete && image.naturalWidth > 0), + ) + .toBe(true); + await expect(section.locator("summary [data-loading]")).toHaveCount(0); + const row = section.locator('[data-channel-id="beta"]'); + await row.focus(); + await page.keyboard.press("Shift+F10"); + await page + .getByRole("menuitem", { name: "Move channel", exact: true }) + .focus(); + await page.keyboard.press("ArrowRight"); + const menu = page.getByRole("menu", { name: "Move channel", exact: true }); + const work = menu.getByRole("menuitemradio", { name: "Work", exact: true }); + await expect(work.locator("img")).toBeVisible(); + await expect + .poll(() => + work + .locator("img") + .evaluate((image) => image.complete && image.naturalWidth > 0), + ) + .toBe(true); + const fallback = menu + .getByRole("menuitemradio", { name: "Unavailable", exact: true }) + .locator("[data-sidebar-group-icon]"); + await expect(fallback).toBeEmpty(); + await expect(fallback.locator("[data-loading]")).toHaveCount(0); + await expect(menu).not.toContainText(":stamp:"); + await expect(menu).not.toContainText(":unavailable_icon:"); + await expect(menu.locator("[title]")).toHaveCount(0); + for (const item of [ + work, + menu.getByRole("menuitemradio", { name: "Unavailable", exact: true }), + ]) { + const bounds = await item.evaluate((element) => { + const icon = element.querySelector("[data-sidebar-group-icon]"); + const label = element.querySelector(".buzz-menu-choice-label").lastChild; + const range = document.createRange(); + range.selectNodeContents(label); + return { + iconRight: icon.getBoundingClientRect().right, + labelLeft: range.getBoundingClientRect().left, + }; + }); + expect(bounds.iconRight).toBeLessThanOrEqual(bounds.labelLeft); + } + await page.screenshot({ + path: testInfo.outputPath("custom-group-menu.png"), + clip: { x: 0, y: 0, width: 600, height: 520 }, + }); + await menu + .getByRole("menuitem", { name: "Create new…", exact: true }) + .click(); + const dialog = page.getByRole("dialog", { name: "Create new section" }); + const field = dialog.getByRole("textbox", { + name: "Section name", + exact: true, + }); + await expect(field).toBeFocused(); + await expect(dialog.locator("label")).toHaveCount(0); + await expect(dialog.locator("p")).toHaveText( + `Move ${channelName} into a new section.`, + ); + for (const width of [1440, 360]) { + await page.setViewportSize({ width, height: 950 }); + const layout = await dialog.locator("p").evaluate((element) => { + const name = element.querySelector("span[title]"); + const range = document.createRange(); + range.selectNodeContents(name); + const box = element.getBoundingClientRect(); + return { + height: box.height, + nameHeight: range.getBoundingClientRect().height, + width: element.clientWidth, + scrollWidth: element.scrollWidth, + right: box.right, + viewport: window.innerWidth, + }; + }); + expect(layout.height).toBeLessThan(layout.nameHeight * 2); + expect(layout.scrollWidth).toBeLessThanOrEqual(layout.width); + expect(layout.right).toBeLessThanOrEqual(layout.viewport); + } + await dialog.screenshot({ + path: testInfo.outputPath("create-section-narrow.png"), + }); + await page.setViewportSize({ width: 1440, height: 950 }); + await dialog.screenshot({ + path: testInfo.outputPath("create-section-single-line.png"), + }); + await page.keyboard.press("Escape"); + await expect(row).toBeFocused(); +}); diff --git a/tests/browser/navigation-groups.spec.mjs b/tests/browser/navigation-groups.spec.mjs index 0b4c28a2b..b52987ca6 100644 --- a/tests/browser/navigation-groups.spec.mjs +++ b/tests/browser/navigation-groups.spec.mjs @@ -1,6 +1,477 @@ import { test, expect } from "./fixture.mjs"; import { open } from "./timeline.mjs"; +test.use({ productionBroker: true, savedSidebar: true }); + +async function openMove(page, row) { + await row.focus(); + await page.keyboard.press("Shift+F10"); + const parent = page.getByRole("menuitem", { + name: "Move channel", + exact: true, + }); + await expect(parent).toBeVisible(); + await parent.focus(); + await page.keyboard.press("ArrowRight"); + const menu = page.getByRole("menu", { name: "Move channel", exact: true }); + await expect(menu).toBeVisible(); + return menu; +} +const rowIn = (page, section) => + page.locator(`[data-sidebar-section="${section}"] [data-channel-id="beta"]`); +const sidebar = (page) => + page.getByRole("navigation", { name: "Subscribed channels" }); +// No saving UI is a completion signal: wait for the final confirmed Star command +// of each move (including no-op Star writes), not merely relay publication. +const confirmations = new WeakMap(); +test.beforeEach(async ({ page }) => { + const state = { completed: 0, expected: 0 }; + confirmations.set(page, state); + page.on("requestfinished", async (request) => { + if ( + new URL(request.url()).pathname.endsWith("/sidebar-star") && + (await request.response())?.ok() + ) + state.completed++; + }); +}); +async function saved(page, app, publications, moves = 1) { + const state = confirmations.get(page); + state.expected += moves; + await expect.poll(() => state.completed).toBe(state.expected); + await expect + .poll(() => app.report.sidebarPublications?.length ?? 0) + .toBe(publications); + await expect( + page.getByText("Saving sidebar changes…", { exact: true }), + ).toHaveCount(0); +} +function gate() { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +// Browser-owned keyboard nesting, portal geometry, and exclusive row/focus relocation. +// Record ordering, concurrency, bounds and cancellation matrices stay in Vitest. +test("row menu moves and removes a channel optimistically, retaining keyboard navigation and confirmed reload", async ({ + page, + app, +}) => { + await open(page, app); + const beta = rowIn(page, "group:work"); + await expect(beta).toBeVisible(); + await beta.click({ button: "right" }); + await page + .getByRole("menuitem", { name: "Move channel", exact: true }) + .focus(); + await page.keyboard.press("ArrowRight"); + const menu = page.getByRole("menu", { name: "Move channel", exact: true }); + await expect(menu).toBeVisible(); + await page.keyboard.press("ArrowLeft"); + await expect( + page.getByRole("menuitem", { name: "Move channel", exact: true }), + ).toBeFocused(); + await expect(menu).toHaveCount(0); + await page.keyboard.press("ArrowRight"); + await expect( + menu.getByRole("menuitemradio", { name: "Work" }), + ).toHaveAttribute("aria-checked", "true"); + // Checked state renders before Base UI transfers focus into the reopened + // submenu. Establish keyboard ownership before sending its next command. + await expect( + menu.getByRole("menuitemradio", { name: "Starred", exact: true }), + ).toBeFocused(); + await page.keyboard.press("End"); + await expect( + menu.getByRole("menuitem", { name: "Remove from Work" }), + ).toBeFocused(); + await page.keyboard.press("Home"); + await expect( + menu.getByRole("menuitemradio", { name: "Starred", exact: true }), + ).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect(menu.getByRole("menuitemradio", { name: "Work" })).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect( + menu.getByRole("menuitem", { name: "Create new…" }), + ).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("Enter"); + await expect(rowIn(page, "channels")).toBeFocused(); + await expect(beta).toHaveCount(0); + await saved(page, app, 1); + expect(app.report.sidebarPublications[0].blob.assignments).toEqual({}); + await openMove(page, rowIn(page, "channels")); + await menu.getByRole("menuitemradio", { name: "Work" }).click(); + await expect(beta).toBeFocused(); + await expect(sidebar(page).locator('[data-channel-id="beta"]')).toHaveCount( + 1, + ); + await saved(page, app, 2); + expect(app.report.sidebarPublications[1].blob.assignments).toEqual({ + beta: "work", + }); + await page.reload(); + await expect(beta).toBeVisible(); + + // Clicking the checked group is the same remove intent, including rollback. + await openMove(page, beta); + const selected = menu.getByRole("menuitemradio", { + name: "Work", + exact: true, + }); + await expect(selected).toHaveAttribute("aria-checked", "true"); + const held = gate(), + started = gate(); + await page.route("**/sidebar-assignment", async (route) => { + started.resolve(); + await held.promise; + app.report.sidebarAssignmentFailures ??= []; + app.report.sidebarAssignmentFailures.push(route.request().url()); + await route.fulfill({ + status: 502, + contentType: "application/json", + body: JSON.stringify({ error: "Group removal failed; retry" }), + }); + }); + try { + await selected.click(); + await expect(rowIn(page, "channels")).toBeFocused(); + await started.promise; + await expect(menu).toHaveCount(0); + await expect(beta).toHaveCount(0); + await expect(sidebar(page).locator('[data-channel-id="beta"]')).toHaveCount( + 1, + ); + await expect(page.getByText(/Saving(?: sidebar changes)?…/)).toHaveCount(0); + } finally { + held.resolve(); + } + const error = page + .getByRole("alert") + .filter({ hasText: "Couldn’t save the move for Beta" }); + await expect(error).toContainText("Relay request failed (502)"); + await expect(beta).toBeVisible(); + await expect(rowIn(page, "channels")).toHaveCount(0); + await page.unroute("**/sidebar-assignment"); + await error.getByRole("button", { name: "Retry move" }).click(); + await expect(rowIn(page, "channels")).toBeFocused(); + await expect(error).toHaveCount(0); + await saved(page, app, 3); + expect(app.report.sidebarPublications.at(-1).blob.assignments).toEqual({}); + await page.reload(); + await expect(rowIn(page, "channels")).toBeVisible(); + await expect(beta).toHaveCount(0); +}); + +test("optimistic Star moves close the menu before the write, roll back with visible retry, and remove to Channels after reload", async ({ + page, + app, +}, testInfo) => { + await page.addInitScript(() => + localStorage.setItem("buzz-appearance.v1", "dark"), + ); + await open(page, app); + const beta = rowIn(page, "group:work"); + const starred = rowIn(page, "starred"); + await expect(beta).toBeVisible(); + await page.locator('[data-sidebar-section="starred"] summary').click(); + const menu = await openMove(page, beta); + await expect(menu).toHaveCSS("opacity", "1"); + await expect( + page.getByRole("menu", { name: "Actions for Beta", exact: true }), + ).toHaveCSS("opacity", "1"); + const bounds = await menu.boundingBox(); + const viewport = page.viewportSize(); + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.y).toBeGreaterThanOrEqual(0); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width); + expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height); + await page.screenshot({ + path: testInfo.outputPath("move-channel-menu.png"), + clip: { x: 10, y: 50, width: 470, height: 470 }, + }); + await page.keyboard.press("ArrowLeft"); + await expect( + page.getByRole("menuitem", { name: "Move channel", exact: true }), + ).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(beta).toBeFocused(); + await page.keyboard.press("ContextMenu"); + await page + .getByRole("menuitem", { name: "Move channel", exact: true }) + .focus(); + await page.keyboard.press("ArrowRight"); + const held = gate(), + started = gate(); + await page.route("**/sidebar-star", async (route) => { + started.resolve(); + await held.promise; + app.report.sidebarStarFailures ??= []; + app.report.sidebarStarFailures.push(route.request().url()); + await route.fulfill({ + status: 502, + contentType: "application/json", + body: JSON.stringify({ error: "Star save failed; retry" }), + }); + }); + try { + await menu + .getByRole("menuitemradio", { name: "Starred", exact: true }) + .click(); + await started.promise; + await expect(menu).toHaveCount(0); + await expect(starred).toBeFocused(); + await expect(beta).toHaveCount(0); + await expect(page.getByText(/Saving(?: sidebar changes)?…/)).toHaveCount(0); + expect(app.report.sidebarPublications ?? []).toHaveLength(0); + } finally { + held.resolve(); + } + const error = page + .getByRole("alert") + .filter({ hasText: "Couldn’t save the move for Beta" }); + await expect(error).toContainText("Relay request failed (502)"); + await expect(beta).toBeVisible(); + await expect(starred).toHaveCount(0); + await page.unroute("**/sidebar-star"); + // Failures live in the session, not in a menu or mounted Messages page. + await page.getByRole("button", { name: "Home", exact: true }).first().click(); + await page + .getByRole("button", { name: "Messages", exact: true }) + .first() + .click(); + await error.getByRole("button", { name: "Retry move" }).click(); + await expect(starred).toBeFocused(); + await expect(error).toHaveCount(0); + await saved(page, app, 1); + await openMove(page, starred); + await menu + .getByRole("menuitem", { name: "Remove from Starred", exact: true }) + .click(); + await expect(rowIn(page, "channels")).toBeFocused(); + await saved(page, app, 3); + expect( + app.report.sidebarPublications.slice(1).map(({ coordinate }) => coordinate), + ).toEqual(["channel-sections", "channel-stars"]); + await page.reload(); + await expect( + page.locator('[data-sidebar-section="starred"] [data-channel-id="alpha"]'), + ).toBeVisible(); + await expect(rowIn(page, "channels")).toBeVisible(); + await expect(beta).toHaveCount(0); + await openMove(page, rowIn(page, "channels")); + await menu + .getByRole("menuitemradio", { name: "Starred", exact: true }) + .click(); + await expect(starred).toBeFocused(); + await saved(page, app, 4); + await openMove(page, starred); + await menu.getByRole("menuitemradio", { name: "Work", exact: true }).click(); + await expect(beta).toBeFocused(); + await saved(page, app, 6); + expect(app.report.sidebarPublications.at(-2)).toMatchObject({ + coordinate: "channel-sections", + blob: { assignments: { beta: "work" } }, + }); + expect(app.report.sidebarPublications.at(-1)).toMatchObject({ + coordinate: "channel-stars", + blob: { channels: { beta: { starred: false } } }, + }); + await openMove(page, beta); + await menu + .getByRole("menuitemradio", { name: "Starred", exact: true }) + .click(); + await expect(starred).toBeFocused(); + await saved(page, app, 7); + await openMove(page, starred); + const selected = menu.getByRole("menuitemradio", { + name: "Starred", + exact: true, + }); + await expect(selected).toHaveAttribute("aria-checked", "true"); + await selected.focus(); + await page.keyboard.press("Enter"); + await expect(rowIn(page, "channels")).toBeFocused(); + await expect(menu).toHaveCount(0); + await expect(starred).toHaveCount(0); + await saved(page, app, 9); + expect(app.report.sidebarPublications.at(-2)).toMatchObject({ + coordinate: "channel-sections", + blob: { assignments: {} }, + }); + expect(app.report.sidebarPublications.at(-1)).toMatchObject({ + coordinate: "channel-stars", + blob: { channels: { beta: { starred: false } } }, + }); + await page.reload(); + await expect(rowIn(page, "channels")).toBeVisible(); + await expect(sidebar(page).locator('[data-channel-id="beta"]')).toHaveCount( + 1, + ); + await expect(beta).toHaveCount(0); // Never restore the remembered Work assignment. +}); + +// Menu → native dialog → optimistic new-section focus must be checked in browsers. +test("Create new supports cancel, moves before publication, and retries the same section after partial failure", async ({ + page, + app, +}, testInfo) => { + await page.addInitScript(() => + localStorage.setItem("buzz-appearance.v1", "dark"), + ); + await open(page, app); + const beta = rowIn(page, "group:work"); + await expect(beta).toBeVisible(); + await openMove(page, beta); + await page.getByRole("menuitem", { name: "Create new…" }).click(); + const dialog = page.getByRole("dialog", { name: "Create new section" }); + const field = dialog.getByRole("textbox", { name: "Section name" }); + await expect(dialog.locator("p")).toHaveText("Move Beta into a new section."); + await expect(dialog.locator("label")).toHaveCount(0); + await expect(field).toBeFocused(); + await expect(page.getByRole("menu")).toHaveCount(0); + await expect( + dialog.getByRole("button", { name: "Create and move" }), + ).toBeDisabled(); + await field.fill("Cancelled"); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(beta).toBeFocused(); + expect(app.report.sidebarPublications ?? []).toHaveLength(0); + await openMove(page, beta); + await page + .getByRole("menuitemradio", { name: "Starred", exact: true }) + .click(); + await expect(rowIn(page, "starred")).toBeFocused(); + await saved(page, app, 1); + await openMove(page, rowIn(page, "starred")); + await page.getByRole("menuitem", { name: "Create new…" }).click(); + await expect(field).toBeFocused(); + await field.fill("Launch"); + await dialog.screenshot({ path: testInfo.outputPath("create-section.png") }); + const held = gate(), + started = gate(); + await page.route("**/sidebar-assignment", async (route) => { + started.resolve(); + await held.promise; + await route.continue(); + }); + await page.route("**/sidebar-star", async (route) => { + app.report.sidebarStarFailures ??= []; + app.report.sidebarStarFailures.push(route.request().url()); + await route.fulfill({ + status: 502, + contentType: "application/json", + body: JSON.stringify({ error: "Star save failed; retry" }), + }); + }); + let sectionId; + try { + await dialog.getByRole("button", { name: "Create and move" }).click(); + await started.promise; + await expect(dialog).toHaveCount(0); + const createdGroup = sidebar(page) + .locator("details") + .filter({ has: page.locator("summary", { hasText: /^Launch$/ }) }); + await expect( + createdGroup.locator('[data-channel-id="beta"]'), + ).toBeFocused(); + sectionId = (await createdGroup.getAttribute("data-sidebar-section")).slice( + "group:".length, + ); + await expect(page.getByText(/Saving(?: sidebar changes)?…/)).toHaveCount(0); + expect(app.report.sidebarPublications).toHaveLength(1); // No section write yet. + await expect(rowIn(page, "starred")).toHaveCount(0); + } finally { + held.resolve(); + } + const error = page + .getByRole("alert") + .filter({ hasText: "Couldn’t save the move for Beta" }); + await expect(error).toContainText("Relay request failed (502)"); + await expect(rowIn(page, "starred")).toBeVisible(); + await expect(rowIn(page, `group:${sectionId}`)).toHaveCount(0); + await page.unroute("**/sidebar-star"); + await page.unroute("**/sidebar-assignment"); + await error.getByRole("button", { name: "Retry move" }).click(); + const destination = rowIn(page, `group:${sectionId}`); + await expect(destination).toBeFocused(); + await saved(page, app, 3); + expect( + app.report.sidebarPublications.filter( + ({ coordinate }) => coordinate === "channel-sections", + ), + ).toHaveLength(1); + await page.reload(); + await expect(destination).toBeVisible(); + await expect(sidebar(page).locator('[data-channel-id="beta"]')).toHaveCount( + 1, + ); + await sidebar(page).screenshot({ + path: testInfo.outputPath("created-section-reloaded.png"), + }); + + // A saved-group row can create another group directly; while that save is + // held, the moved row can immediately move again to a different saved group. + const nextHeld = gate(), + nextStarted = gate(); + await page.route("**/sidebar-assignment", async (route) => { + nextStarted.resolve(); + await nextHeld.promise; + await route.continue(); + }); + let followup; + try { + await openMove(page, destination); + await page.getByRole("menuitem", { name: "Create new…" }).click(); + await field.fill("Follow-up"); + await dialog.getByRole("button", { name: "Create and move" }).click(); + await nextStarted.promise; + const nextGroup = sidebar(page) + .locator("details") + .filter({ + has: page.locator("summary", { hasText: /^Follow-up$/ }), + }); + followup = rowIn( + page, + await nextGroup.getAttribute("data-sidebar-section"), + ); + await expect(followup).toBeFocused(); + await expect(destination).toHaveCount(0); + await expect(page.getByText(/Saving(?: sidebar changes)?…/)).toHaveCount(0); + const moving = await openMove(page, followup); + await moving + .getByRole("menuitemradio", { name: "Work", exact: true }) + .click(); + await expect(beta).toBeFocused(); + await expect(followup).toHaveCount(0); + await expect(sidebar(page).locator('[data-channel-id="beta"]')).toHaveCount( + 1, + ); + expect(app.report.sidebarPublications).toHaveLength(3); + } finally { + nextHeld.resolve(); + } + await saved(page, app, 5, 2); + await page.unroute("**/sidebar-assignment"); + await page.reload(); + await expect(beta).toBeVisible(); + // Existing group → another existing group needs no intermediate removal. + const moving = await openMove(page, beta); + await moving + .getByRole("menuitemradio", { name: "Follow-up", exact: true }) + .click(); + await expect(followup).toBeFocused(); + await saved(page, app, 6); + await page.reload(); + await expect(followup).toBeVisible(); + await expect(beta).toHaveCount(0); +}); + test.use({ productionBroker: true, savedSidebar: true, diff --git a/tests/browser/navigation-sorting.spec.mjs b/tests/browser/navigation-sorting.spec.mjs new file mode 100644 index 000000000..a229ec70c --- /dev/null +++ b/tests/browser/navigation-sorting.spec.mjs @@ -0,0 +1,268 @@ +import { test, expect } from "./fixture.mjs"; +import { open } from "./timeline.mjs"; + +test.use({ productionBroker: true, savedSidebar: true, sortingSidebar: true }); +// Native details/menu nesting, submenu focus/viewport placement and row reorder +// through the built app + real broker are browser contracts. Matrices live below. +test("section sort applies immediately, rolls back on failure, and persists retry independently", async ({ + page, + app, +}, testInfo) => { + await page.addInitScript(() => + localStorage.setItem("buzz-appearance.v1", "dark"), + ); + await open(page, app); + const sidebar = page.getByRole("navigation", { name: "Subscribed channels" }); + const channels = sidebar.locator('[data-sidebar-section="channels"]'); + const work = sidebar.locator('[data-sidebar-section="group:work"]'); + const trigger = channels.getByRole("button", { + name: "More actions for Channels", + }); + const rows = channels.locator("[data-channel-id]"); + const order = () => + rows.evaluateAll((elements) => elements.map((el) => el.dataset.channelId)); + await expect( + work.getByRole("button", { name: "Beta", exact: true }), + ).toBeVisible(); + await expect(rows.first()).toBeVisible(); + const alphaOrder = await order(); + expect(alphaOrder.length).toBeGreaterThan(2); + const activity = app.report.queries.filter((q) => + q.filter.kinds?.includes(45003), + ); + expect(activity).toEqual([]); + async function openSort() { + await trigger.click(); + await expect(channels).toHaveAttribute("open", ""); + await page + .getByRole("menu", { name: "More actions for Channels", exact: true }) + .getByRole("menuitem", { name: "Sort", exact: true }) + .focus(); + await page.keyboard.press("ArrowRight"); + await expect( + page.getByRole("menu", { name: "Sort", exact: true }), + ).toBeVisible(); + } + await page.screenshot({ + path: testInfo.outputPath("sidebar-sort-alpha.png"), + clip: { x: 0, y: 0, width: 540, height: 460 }, + }); + await openSort(); + const menu = page.getByRole("menu", { name: "Sort", exact: true }); + await expect( + menu.getByRole("menuitemradio", { name: "A–Z" }), + ).toHaveAttribute("aria-checked", "true"); + let release, started; + const held = new Promise((resolve) => { + release = resolve; + }); + const requestStarted = new Promise((resolve) => { + started = resolve; + }); + await page.route("**/sidebar-sort", async (route) => { + started(); + await held; + app.report.sidebarSortFailures ??= []; + app.report.sidebarSortFailures.push(route.request().url()); + await route.fulfill({ + status: 502, + contentType: "application/json", + body: JSON.stringify({ error: "sort save failed" }), + }); + }); + try { + await menu.getByRole("menuitemradio", { name: "Recent" }).click(); + await requestStarted; + await expect(menu).not.toBeVisible(); + await expect(trigger).toBeFocused(); + await expect(page.getByText(/Saving(?: sidebar changes)?…/)).toHaveCount(0); + await openSort(); + await expect( + menu.getByRole("menuitemradio", { name: "A–Z" }), + ).toBeEnabled(); + await expect( + menu.getByRole("menuitemradio", { name: "Recent" }), + ).toHaveAttribute("aria-checked", "true"); + await expect.poll(order).not.toEqual(alphaOrder); + await page.keyboard.press("Escape"); + await page.keyboard.press("Escape"); + } finally { + release(); + } + const error = page + .getByRole("alert") + .filter({ hasText: "Couldn’t save the sort order for Channels" }); + await expect(error).toContainText("Relay request failed (502)"); + await expect.poll(order).toEqual(alphaOrder); + await page.unroute("**/sidebar-sort"); + // Error ownership is session-scoped, not coupled to the dismissed menu/page. + await page.getByRole("button", { name: "Home", exact: true }).first().click(); + await page + .getByRole("button", { name: "Messages", exact: true }) + .first() + .click(); + await expect(error).toBeVisible(); + async function confirmedSort(action) { + const response = page.waitForResponse("**/sidebar-sort"); + await action(); + expect((await response).ok()).toBe(true); + await (await response).finished(); + } + await confirmedSort(() => + error.getByRole("button", { name: "Retry sort" }).click(), + ); + await expect(error).toHaveCount(0); + await expect.poll(order).not.toEqual(alphaOrder); + expect(app.report.sidebarPublications.at(-1)).toMatchObject({ + coordinate: "channel-sort", + blob: { groups: { channels: "recent" } }, + }); + const recentOrder = await order(); + await openSort(); + await expect( + menu.getByRole("menuitemradio", { name: "Recent" }), + ).toHaveAttribute("aria-checked", "true"); + await expect(menu).toHaveCSS("opacity", "1"); + await expect( + page.getByRole("menu", { name: "More actions for Channels", exact: true }), + ).toHaveCSS("opacity", "1"); + await page.screenshot({ + path: testInfo.outputPath("sidebar-sort-menu.png"), + clip: { x: 0, y: 0, width: 540, height: 460 }, + }); + const box = await menu.boundingBox(), + viewport = page.viewportSize(); + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.y).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(viewport.width); + expect(box.y + box.height).toBeLessThanOrEqual(viewport.height); + await page.keyboard.press("Escape"); + await expect(menu).not.toBeVisible(); + await page.keyboard.press("Escape"); + await expect( + page.getByRole("menu", { name: "More actions for Channels", exact: true }), + ).not.toBeVisible(); + await work.getByRole("button", { name: "More actions for Work" }).click(); + await page + .getByRole("menu", { name: "More actions for Work", exact: true }) + .getByRole("menuitem", { name: "Sort", exact: true }) + .focus(); + await page.keyboard.press("ArrowRight"); + const workMenu = page.getByRole("menu", { name: "Sort", exact: true }); + await expect( + workMenu.getByRole("menuitemradio", { name: "A–Z" }), + ).toHaveAttribute("aria-checked", "true"); + await confirmedSort(() => + workMenu.getByRole("menuitemradio", { name: "Recent" }).click(), + ); + await expect(workMenu).not.toBeVisible(); + expect(app.report.sidebarPublications.at(-1).blob.groups).toEqual({ + channels: "recent", + "section:work": "recent", + }); + await expect.poll(order).toEqual(recentOrder); + await openSort(); + await confirmedSort(() => + menu.getByRole("menuitemradio", { name: "A–Z" }).click(), + ); + await expect(menu).not.toBeVisible(); + await expect.poll(order).toEqual(alphaOrder); + expect(app.report.sidebarPublications.at(-1).blob.groups).toEqual({ + "section:work": "recent", + }); + expect(app.report.unexpected).toEqual([]); +}); + +// Cold-start paint is a composition contract: real saved sort, activity response, +// and independent conversation opening. Timer/order permutations live in RTL. +test.describe("cold sidebar presentation", () => { + test.use({ initialSidebarSort: { channels: "recent" } }); + test("reveals saved placement and Recent together without blocking the conversation", async ({ + page, + app, + }, testInfo) => { + let releasePreferences, releaseActivity; + const preferences = new Promise((resolve) => { + releasePreferences = resolve; + }); + const activity = new Promise((resolve) => { + releaseActivity = resolve; + }); + let preferencesStarted, activityStarted; + const decoding = new Promise((resolve) => { + preferencesStarted = resolve; + }); + const ordering = new Promise((resolve) => { + activityStarted = resolve; + }); + await page.clock.install(); + await page.clock.pauseAt(new Date()); + await page.addInitScript(() => + localStorage.setItem("buzz-appearance.v1", "dark"), + ); + await page.route("**/sidebar-preferences", async (route) => { + const response = await route.fetch(); + preferencesStarted(); + await preferences; + await route.fulfill({ response }); + }); + await page.route("**/channel-activity", async (route) => { + const response = await route.fetch(); + activityStarted(); + await activity; + await route.fulfill({ response }); + }); + const sidebar = page.getByRole("navigation", { + name: "Subscribed channels", + }); + try { + await page.goto(app.origin); + await page + .getByRole("button", { name: "Messages", exact: true }) + .first() + .click(); + await decoding; + await expect(sidebar.getByRole("status")).toHaveText( + "Loading your sidebar…", + ); + await expect(sidebar.locator("[data-channel-id]")).toHaveCount(0); + await expect( + page.getByRole("textbox", { name: "Message #Alpha", exact: true }), + ).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath("sidebar-cold-loading.png"), + clip: { x: 0, y: 0, width: 540, height: 460 }, + }); + releasePreferences(); + await ordering; + await expect(sidebar.locator("[data-channel-id]")).toHaveCount(0); + releaseActivity(); + const rows = sidebar.locator( + '[data-sidebar-section="channels"] [data-channel-id]', + ); + await expect(rows.first()).toBeVisible(); + expect( + await rows.evaluateAll((elements) => + elements.slice(0, 3).map((el) => el.dataset.channelId), + ), + ).toEqual(["willow", "maple", "cedar"]); + await expect( + sidebar.locator( + '[data-sidebar-section="group:work"] [data-channel-id="beta"]', + ), + ).toBeVisible(); + await expect(sidebar.getByRole("status")).toHaveCount(0); + await page.screenshot({ + path: testInfo.outputPath("sidebar-cold-revealed.png"), + clip: { x: 0, y: 0, width: 540, height: 460 }, + }); + } finally { + releasePreferences(); + releaseActivity(); + // Remove the gates before resumed timers can start a fresh intercepted read. + await page.unroute("**/sidebar-preferences"); + await page.unroute("**/channel-activity"); + await page.clock.resume(); + } + }); +}); diff --git a/tests/browser/policy-relay.mjs b/tests/browser/policy-relay.mjs index f0d359f07..b5d6de5ea 100644 --- a/tests/browser/policy-relay.mjs +++ b/tests/browser/policy-relay.mjs @@ -188,11 +188,33 @@ export function policyRelay({ ), ); } + if ( + filters.length <= 128 && + filters.every( + (filter) => + filter.limit === 1 && + filter["#h"]?.length === 1 && + [9, 40002, 45001, 45003].every((kind) => + filter.kinds?.includes(kind), + ), + ) + ) { + for (const filter of filters) + report.queries.push({ + community: communityOf(url), + filter, + at: performance.now(), + }); + return Response.json( + filters.flatMap((filter) => answer(communityOf(url), filter)), + ); + } if (filters.length !== 1) { - // The read-only sidebar projection reads the two exact coordinates. - expect(filters).toHaveLength(2); + // The read-only sidebar projection reads the three exact coordinates. + expect(filters).toHaveLength(3); expect(filters.map((filter) => filter["#d"]?.[0]).sort()).toEqual([ "channel-sections", + "channel-sort", "channel-stars", ]); for (const filter of filters) { diff --git a/tests/browser/sidebar-unread.spec.mjs b/tests/browser/sidebar-unread.spec.mjs index 0c8e0cf8a..a289124e6 100644 --- a/tests/browser/sidebar-unread.spec.mjs +++ b/tests/browser/sidebar-unread.spec.mjs @@ -395,15 +395,12 @@ test("session changes discard the previous sidebar targets and manual unread sti const held = new Promise((resolve) => { release = resolve; }); - let requested = false; - await page.route( - "**/api/relay/secondary/sidebar-preferences", - async (route) => { - requested = true; - await held; - await route.continue(); - }, - ); + const pendingRoutes = []; + await page.route("**/api/relay/secondary/sidebar-preferences", (route) => { + const pending = held.then(() => route.continue()); + pendingRoutes.push(pending); + return pending; + }); try { await open(page, app); await expect(cue(page, "below")).toBeVisible(); @@ -427,13 +424,19 @@ test("session changes discard the previous sidebar targets and manual unread sti await page .getByRole("button", { name: "Channel settings", exact: true }) .click(); - await expect.poll(() => requested).toBe(true); + await expect.poll(() => pendingRoutes.length).toBeGreaterThan(0); + // A new session reveals its roster after the bounded startup wait even if + // preferences are still blocked. Scrolling the empty loading view is a no-op. + await expect(row(page, "alpha")).toBeVisible(); + await expect( + page.getByText("Updating sidebar details…", { exact: true }), + ).toBeVisible(); await scroll(page, 1800); await expect(cue(page, "above")).toBeVisible(); // Completing delayed preferences must not replace the user's newer viewport. release(); await expect( - page.getByText("Loading saved groups and stars…", { exact: true }), + page.getByText("Updating sidebar details…", { exact: true }), ).toBeHidden(); expect(await list(page).evaluate((element) => element.scrollTop)).toBe( 1800, @@ -447,6 +450,7 @@ test("session changes discard the previous sidebar targets and manual unread sti ).toBeAttached(); } finally { release(); + await Promise.all(pendingRoutes); await page.unroute("**/api/relay/secondary/sidebar-preferences"); } });