From a74848c36dac6ddb75078cd0d578cf379188f36c Mon Sep 17 00:00:00 2001 From: Carl Date: Thu, 17 Sep 2026 08:18:07 -1000 Subject: [PATCH 1/5] feat(channels): save sidebar groups and starred channels Expose group assignment and Star/Unstar through confirmed host-owned preference writes. Preserve unrelated encrypted data, fence stale reads and cancelled sessions, and restore keyboard focus after exclusive row relocation. Signed-off-by: Carl --- dev/relay-broker.mjs | 174 ++++++++++ dev/sidebar-preference-writes.test.mjs | 179 ++++++++++ dev/sidebar-preferences.d.mts | 28 ++ dev/sidebar-preferences.mjs | 135 +++++++- dev/sidebar-stars.mjs | 90 +++++ dev/sidebar-stars.test.mjs | 211 ++++++++++++ docs/channels.md | 23 ++ src/bundled/channels/ChannelsPage.tsx | 238 ++++++++++++- src/bundled/channels/sidebar-sections.test.ts | 19 ++ src/bundled/channels/useSidebarPreferences.ts | 4 + src/features/relay/session.ts | 28 ++ .../relay/sidebar-preferences-store.test.ts | 312 +++++++++++++++++- .../relay/sidebar-preferences-store.ts | 132 +++++++- .../relay/sidebar-preferences.test.ts | 174 ++++++++++ src/features/relay/sidebar-preferences.ts | 20 +- src/features/relay/transport.ts | 60 +++- tests/browser/fixture.mjs | 70 +++- tests/browser/navigation-groups.spec.mjs | 179 ++++++++++ 18 files changed, 2044 insertions(+), 32 deletions(-) create mode 100644 dev/sidebar-preference-writes.test.mjs create mode 100644 dev/sidebar-preferences.d.mts create mode 100644 dev/sidebar-stars.mjs create mode 100644 dev/sidebar-stars.test.mjs diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index 317c681d6..7a70175a4 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -1,6 +1,10 @@ import { uploadAttachment, UploadError } from "./attachment-upload.mjs"; import { validChannelCommand } from "./session-commands.mjs"; import { SocketRequestError } from "../src/features/relay/socket-requests.ts"; +import { + assertSidebarStarIntent, + mutateSidebarStar, +} from "./sidebar-stars.mjs"; import { validateWorkflowEvent, WORKFLOW_KINDS, @@ -25,6 +29,8 @@ import { import { readAgentLibrary } from "./agent-library.mjs"; import { decodeSidebarPreferences, + assertSidebarAssignmentIntent, + mutateSidebarAssignment, SIDEBAR_REQUEST_BYTES, SIDEBAR_UPLOAD_MS, SIDEBAR_UPLOAD_SLOTS, @@ -65,6 +71,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; @@ -366,6 +373,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 +438,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 +625,149 @@ export function relayBrokerPlugin({ sidebarUploads--; } } + if ( + [ + "/api/relay/sidebar-assignment", + "/api/relay/sidebar-star", + ].includes(route) && + req.method === "POST" + ) { + const starring = route === "/api/relay/sidebar-star"; + let raw = ""; + for await (const part of req) { + raw += part; + if (Buffer.byteLength(raw) > 2048) + return json(res, 413, { + error: `Sidebar preference intent is too large`, + }); + } + let intent; + try { + intent = JSON.parse(raw); + if (starring) assertSidebarStarIntent(intent); + else assertSidebarAssignmentIntent(intent); + } catch { + return json(res, 400, { + error: `Invalid sidebar preference intent`, + }); + } + const request = new AbortController(); + const close = () => request.abort(); + res.once("close", close); + const previous = sidebarMutations.get(relay) ?? Promise.resolve(); + const mutation = previous + .catch(() => {}) + .then(async () => { + request.signal.throwIfAborted(); + const filter = [ + { + kinds: [30078], + authors: [viewer], + "#d": [starring ? "channel-stars" : "channel-sections"], + limit: 1, + }, + ]; + const lane = admissions(relay, viewer).api; + const requestSignal = AbortSignal.any([ + request.signal, + AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + ]); + const dispatch = (path, body) => + admittedApiRequest( + lane, + () => { + requestSignal.throwIfAborted(); + const value = JSON.stringify(body); + const auth = finalizeEvent( + { + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + content: "", + tags: [ + ["u", `${relay}${path}`], + ["method", "POST"], + [ + "payload", + createHash("sha256").update(value).digest("hex"), + ], + ["nonce", randomBytes(16).toString("hex")], + ], + }, + key, + ); + return fetchUpstream(`${relay}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: + "Nostr " + + Buffer.from(JSON.stringify(auth)).toString( + "base64", + ), + }, + body: value, + redirect: "error", + signal: requestSignal, + }); + }, + requestSignal, + ); + const readHead = async () => { + const response = await dispatch("/query", filter); + if (!response.ok) + throw new Error( + `Sidebar preference query failed (${response.status})`, + ); + return readSidebarHead(response); + }; + const publishEvent = async (event) => { + const response = await dispatch("/events", event); + if (!response.ok) + throw new Error( + `Sidebar preference publish failed (${response.status})`, + ); + const receipt = await readSidebarHead( + response, + "publication", + ); + if ( + receipt.event_id !== event.id || + receipt.accepted !== true + ) + throw new Error( + "Sidebar preference publication was not accepted", + ); + }; + return (starring ? mutateSidebarStar : mutateSidebarAssignment)( + intent, + key, + readHead, + publishEvent, + ); + }); + sidebarMutations.set(relay, mutation); + try { + return json(res, 200, await mutation); + } catch (error) { + if (error instanceof ApiPaused) + return json(res, 429, { + error: error.message, + sent: false, + paused: true, + retryAfterMs: error.retryAfterMs, + }); + return json(res, 502, { + error: + error instanceof Error + ? error.message + : `Sidebar preference failed`, + }); + } finally { + res.off("close", close); + if (sidebarMutations.get(relay) === mutation) + sidebarMutations.delete(relay); + } + } if (route === "/api/relay/agent-library" && req.method === "GET") { try { // Share concurrent reads, never retain the local snapshot after completion. @@ -628,6 +800,8 @@ export function relayBrokerPlugin({ attachmentUploads: true, sidebarPreferences: true, readState: true, + sidebarPreferenceWrites: true, + sidebarStarWrites: true, agentLibrary: true, live: true, presence: true, diff --git a/dev/sidebar-preference-writes.test.mjs b/dev/sidebar-preference-writes.test.mjs new file mode 100644 index 000000000..232ecc2fe --- /dev/null +++ b/dev/sidebar-preference-writes.test.mjs @@ -0,0 +1,179 @@ +import { createServer } from "node:http"; +import { createHash } from "node:crypto"; +import { afterEach, expect, it } from "vitest"; +import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools"; +import { relayBrokerPlugin } from "./relay-broker.mjs"; +import { prepareSidebarStar } from "./sidebar-stars.mjs"; +import { connectBrokerTransport } from "../src/features/relay/transport.ts"; +import { fixtureRelayUrl, fixtureAliases } from "../tests/relay-config.ts"; + +const disposals = []; +afterEach(async () => { + for (const dispose of disposals.splice(0)) await dispose(); +}); +async function harness() { + const key = generateSecretKey(), + viewer = getPublicKey(key); + let handler, queryFailure, publicationFailure; + let conflict = false; + const heads = new Map(), + calls = []; + const server = createServer((req, res) => { + req.headers.origin ??= `http://${req.headers.host}`; + handler(req, res); + }); + await relayBrokerPlugin({ + relayUrl: fixtureRelayUrl, + communityAliases: fixtureAliases, + identity: () => key, + upstreamFetch: async (url, init) => { + if (!init?.body) return Response.json({ self: viewer }); + const body = JSON.parse(init.body); + const auth = JSON.parse( + Buffer.from(init.headers.Authorization.slice(6), "base64").toString(), + ); + expect(verifyEvent(auth)).toBe(true); + expect(auth.pubkey).toBe(viewer); + expect(auth.tags).toContainEqual(["u", String(url)]); + expect(auth.tags).toContainEqual(["method", "POST"]); + expect(auth.tags).toContainEqual([ + "payload", + createHash("sha256").update(init.body).digest("hex"), + ]); + expect(init.redirect).toBe("error"); + calls.push({ url: String(url), body }); + if (String(url).endsWith("/events")) { + expect(verifyEvent(body)).toBe(true); + if (publicationFailure) return publicationFailure; + if (!conflict) + heads.set(body.tags.find(([name]) => name === "d")[1], body); + return Response.json({ accepted: true, event_id: body.id }); + } + if (queryFailure) return queryFailure; + const head = heads.get(body[0]["#d"][0]); + return Response.json(head ? [head] : []); + }, + }).configureServer({ + httpServer: server, + config: { logger: { info() {}, error() {} } }, + middlewares: { + use(callback) { + handler = callback; + }, + }, + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + disposals.push(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }); + const base = `http://127.0.0.1:${server.address().port}`; + const transport = await connectBrokerTransport(base); + return { + key, + viewer, + transport, + calls, + heads, + failQuery(value) { + queryFailure = value; + }, + failPublication(value) { + publicationFailure = value; + }, + conflict() { + conflict = true; + }, + post(value, origin) { + return fetch(`${base}/api/relay/sidebar-star`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(origin ? { Origin: origin } : {}), + }, + body: JSON.stringify(value), + }); + }, + }; +} +it("real broker Star roundtrip signs scoped requests and confirms before projecting", async () => { + const h = await harness(), + signal = new AbortController().signal; + h.heads.set( + "channel-stars", + prepareSidebarStar([], { channelId: "other", starred: true }, h.key).event, + ); + expect( + await h.transport.writeSidebarStar( + { channelId: "alpha", starred: true }, + signal, + ), + ).toEqual(["other", "alpha"]); + expect(h.calls.map((call) => new URL(call.url).pathname)).toEqual([ + "/query", + "/events", + "/query", + ]); + expect(h.calls[0].body).toEqual([ + { kinds: [30078], authors: [h.viewer], "#d": ["channel-stars"], limit: 1 }, + ]); + expect( + await h.transport.writeSidebarStar( + { channelId: "alpha", starred: false }, + signal, + ), + ).toEqual(["other"]); + expect(h.calls.filter((call) => call.url.endsWith("/events"))).toHaveLength( + 2, + ); + await h.transport.writeSidebarStar( + { channelId: "alpha", starred: false }, + signal, + ); + expect(h.calls.filter((call) => call.url.endsWith("/events"))).toHaveLength( + 2, + ); +}); +it("refuses invalid intent and foreign origins without upstream requests", async () => { + const h = await harness(); + expect((await h.post({ channelId: "alpha", starred: "true" })).status).toBe( + 400, + ); + expect( + ( + await h.post( + { channelId: "alpha", starred: true }, + "https://foreign.invalid", + ) + ).status, + ).toBe(403); + expect( + (await h.post({ channelId: "x".repeat(2100), starred: true })).status, + ).toBe(413); + expect(h.calls).toEqual([]); +}); +it.each(["query", "oversized", "publication", "receipt", "conflict"])( + "does not claim a saved Star after %s failure", + async (failure) => { + const h = await harness(); + if (failure === "query") + h.failQuery(new Response("failed", { status: 503 })); + if (failure === "oversized") + h.failQuery(new Response(`[${" ".repeat(270000)}]`)); + if (failure === "publication") + h.failPublication(new Response("failed", { status: 503 })); + if (failure === "receipt") + h.failPublication(Response.json({ accepted: false, event_id: "wrong" })); + if (failure === "conflict") h.conflict(); + await expect( + h.transport.writeSidebarStar( + { channelId: "alpha", starred: true }, + new AbortController().signal, + ), + ).rejects.toThrow(); + if (["query", "oversized"].includes(failure)) + expect(h.calls.filter((call) => call.url.endsWith("/events"))).toEqual( + [], + ); + }, +); diff --git a/dev/sidebar-preferences.d.mts b/dev/sidebar-preferences.d.mts new file mode 100644 index 000000000..a17e3e0a2 --- /dev/null +++ b/dev/sidebar-preferences.d.mts @@ -0,0 +1,28 @@ +import type { SidebarGroups } from "../src/features/relay/sidebar-preferences"; +import type { RelayEvent } from "../src/features/relay/events"; + +export function decodeSidebarPreferences( + events: readonly RelayEvent[], + secret: Uint8Array, +): import("../src/features/relay/sidebar-preferences").SidebarPreferences; +export function assertSidebarAssignmentIntent( + intent: unknown, +): asserts intent is { + channelId: string; + sectionId?: string; +}; +export function prepareSidebarAssignment( + events: readonly RelayEvent[], + intent: { channelId: string; sectionId?: string }, + secret: Uint8Array, + now?: number, +): { groups: SidebarGroups; event?: RelayEvent }; +export function mutateSidebarAssignment( + intent: { channelId: string; sectionId?: string }, + secret: Uint8Array, + readHead: () => Promise, + publish: (event: RelayEvent) => Promise, +): Promise; +export const SIDEBAR_REQUEST_BYTES: number; +export const SIDEBAR_UPLOAD_SLOTS: number; +export const SIDEBAR_UPLOAD_MS: number; diff --git a/dev/sidebar-preferences.mjs b/dev/sidebar-preferences.mjs index 86e334bf1..dfe6b9e90 100644 --- a/dev/sidebar-preferences.mjs +++ b/dev/sidebar-preferences.mjs @@ -1,4 +1,4 @@ -import { getPublicKey, nip44, verifyEvent } from "nostr-tools"; +import { finalizeEvent, getPublicKey, nip44, verifyEvent } from "nostr-tools"; import { projectSidebarPreferences, SIDEBAR_COORDINATES, @@ -54,3 +54,136 @@ export function decodeSidebarPreferences(events, secret) { key.fill(0); } } + +const SECTION_COORDINATE = "channel-sections"; +function validAssignmentIntent(intent) { + return ( + intent && + typeof intent === "object" && + !Array.isArray(intent) && + typeof intent.channelId === "string" && + intent.channelId.trim().length > 0 && + intent.channelId.length <= 256 && + (intent.sectionId === undefined || + (typeof intent.sectionId === "string" && + intent.sectionId.trim().length > 0 && + intent.sectionId.length <= 256)) && + Object.keys(intent).every((key) => ["channelId", "sectionId"].includes(key)) + ); +} +export function assertSidebarAssignmentIntent(intent) { + if (!validAssignmentIntent(intent)) + throw new Error("Invalid sidebar assignment intent"); +} +function parseSectionsEvent(events, secret) { + if ( + !Array.isArray(events) || + events.length > 1 || + Buffer.byteLength(JSON.stringify(events)) > SIDEBAR_REQUEST_BYTES + ) + throw new Error("Invalid sidebar group head"); + if (!events.length) + return { + blob: { version: 1, sections: [], assignments: {} }, + createdAt: 0, + }; + const [event] = events; + const viewer = getPublicKey(secret); + const tags = event?.tags?.filter?.( + (tag) => Array.isArray(tag) && tag[0] === "d", + ); + if ( + event?.kind !== 30078 || + event.pubkey !== viewer || + tags?.length !== 1 || + tags[0]?.[1] !== SECTION_COORDINATE || + typeof event.content !== "string" || + !verifyEvent(event) + ) + throw new Error("Invalid sidebar group head"); + const key = nip44.v2.utils.getConversationKey(secret, viewer); + try { + const plaintext = nip44.v2.decrypt(event.content, key); + if (Buffer.byteLength(plaintext) > 128 * 1024) + throw new Error("Sidebar plaintext budget exceeded"); + const blob = JSON.parse(plaintext); + projectSidebarPreferences(blob, undefined); + return { blob, createdAt: event.created_at }; + } finally { + key.fill(0); + } +} +/** Narrow host command: mutate one assignment against the latest encrypted head. */ +export function prepareSidebarAssignment( + events, + intent, + secret, + now = Date.now(), +) { + assertSidebarAssignmentIntent(intent); + const viewer = getPublicKey(secret); + const current = parseSectionsEvent(events, secret); + if ( + intent.sectionId !== undefined && + !current.blob.sections.some((section) => section.id === intent.sectionId) + ) + throw new Error("Sidebar group no longer exists"); + const assignments = { + ...current.blob.assignments, + ...(intent.sectionId === undefined + ? {} + : { [intent.channelId]: intent.sectionId }), + }; + if (intent.sectionId === undefined) delete assignments[intent.channelId]; + const blob = { ...current.blob, assignments }; + const groups = projectSidebarPreferences(blob, undefined); + const previous = Object.hasOwn(current.blob.assignments, intent.channelId) + ? current.blob.assignments[intent.channelId] + : undefined; + if (previous === intent.sectionId) return { groups }; + const key = nip44.v2.utils.getConversationKey(secret, viewer); + let content; + try { + content = nip44.v2.encrypt(JSON.stringify(blob), key); + } finally { + key.fill(0); + } + return { + groups, + event: finalizeEvent( + { + kind: 30078, + content, + created_at: Math.max(Math.floor(now / 1000), current.createdAt + 1), + tags: [ + ["d", SECTION_COORDINATE], + ["t", SECTION_COORDINATE], + ], + }, + secret, + ), + }; +} + +/** Publish one assignment, then re-read the coordinate before reporting saved state. */ +export async function mutateSidebarAssignment( + intent, + secret, + readHead, + publish, +) { + assertSidebarAssignmentIntent(intent); + const draft = prepareSidebarAssignment(await readHead(), intent, secret); + if (!draft.event) return draft.groups; + await publish(draft.event); + const confirmation = prepareSidebarAssignment( + await readHead(), + intent, + secret, + ); + if (confirmation.event) + throw new Error( + "Sidebar groups changed on another device; reload and try again", + ); + return confirmation.groups; +} diff --git a/dev/sidebar-stars.mjs b/dev/sidebar-stars.mjs new file mode 100644 index 000000000..50be9b06e --- /dev/null +++ b/dev/sidebar-stars.mjs @@ -0,0 +1,90 @@ +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) 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..f97508f91 --- /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({ new: { starred: false, updatedAt: 50000 } }); +}); +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..0185562a2 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -73,6 +73,29 @@ late completion cannot repopulate a retired snapshot. These are account-owned preferences, not channel access grants: sidebar sections still intersect the authorized roster. There is no new disk cache or automatic cross-device sync. +The browser/development host exposes narrow **assign/remove group** and +**Star/Unstar** commands. Each re-reads the viewer's signed encrypted coordinate, +changes only the requested entry, publishes through the shared relay admission +lane, then re-reads to confirm the requested state. Unrelated fields and explicit +unstar tombstones are retained. Invalid/unreadable/over-budget heads fail closed; +only a successful absent-head read can seed a coordinate. Same-host writes are +serialized per relay. This is confirmed whole-record replacement, not atomic +cross-device merging, a durable pending outbox, or automatic retry: simultaneous +writers on different hosts can still race. Failure leaves the last confirmed UI +state and offers an explicit retry; a failed confirmation may follow a publication +that reached the relay. + +The session preference owner serializes local commands and fences refreshes, +caller cancellation, cache clear and disposal. `session.ts` only composes host +capabilities with session lifetime and a bounded deadline. Cache clear cancels +pending work but cannot retract a publication already accepted by the relay. +Stream rows expose these actions by right-click/long-press, Shift+F10 or the +Context Menu key. Menus remain open during saving and failed-save retry; confirmed +relocation expands the destination and restores focus by channel identity. Starred +placement is exclusive, retaining the saved assignment so Unstar restores it. +Forums/DMs, group CRUD/reorder and independent sorting are outside this slice. +Hosts without the write capabilities retain the read-only projection. + 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 diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index badf98ab3..016d592a9 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -43,6 +43,7 @@ import { } 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, @@ -77,6 +78,18 @@ import { CHANNEL_SIDEBAR_MIN_WIDTH, useSidebarView, } from "./useSidebarView"; +import { + ContextMenuRoot, + ContextMenuTrigger, + MenuGroup, + MenuGroupLabel, + MenuIcon, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, +} from "../../shared/design-system/ui/Menu"; import styles from "./Channels.module.css"; export function ChannelsPage({ @@ -270,6 +283,21 @@ function ChannelWorkspace({ [navigate], ); const threadTrigger = useRef(null); + const [rowMenu, setRowMenu] = useState<{ + channel: ChannelSummary; + sectionId?: string; + anchor?: HTMLElement; + }>(); + const rowMenuGeneration = useRef(0); + const [groupWrite, setGroupWrite] = useState<{ + channelId: string; + pending: boolean; + error?: string; + }>(); + const [rowFocus, setRowFocus] = useState<{ + channelId: string; + sectionKey: string; + }>(); const [sent, setSent] = useState<{ channelId: string; id: string }>(); const sidebar = useSidebarView( scope, @@ -805,6 +833,77 @@ function ChannelWorkspace({ preferences.data, hiddenDms.hiddenIds, ); + const closeRowMenu = useCallback(() => { + rowMenuGeneration.current++; + setRowMenu(undefined); + setGroupWrite(undefined); + }, []); + useLayoutEffect(() => { + if (!rowFocus) return; + const destination = sidebar.list.current?.querySelector( + `[data-sidebar-section="${CSS.escape(rowFocus.sectionKey)}"]`, + ); + const link = destination?.querySelector( + `[data-channel-id="${CSS.escape(rowFocus.channelId)}"]`, + ); + link?.focus({ preventScroll: true }); + setRowFocus(undefined); + }, [rowFocus, sidebar.list]); + const openRowMenu = useCallback( + (channel: ChannelSummary, sectionId?: string, anchor?: HTMLElement) => { + rowMenuGeneration.current++; + setGroupWrite(undefined); + setRowMenu({ + channel, + ...(sectionId ? { sectionId } : {}), + ...(anchor ? { anchor } : {}), + }); + }, + [], + ); + const assignGroup = async (channelId: string, sectionId?: string) => { + const generation = rowMenuGeneration.current; + setGroupWrite({ channelId, pending: true }); + try { + await preferences.assign(channelId, sectionId); + if (generation !== rowMenuGeneration.current) return; + const sectionKey = sectionId ? `group:${sectionId}` : "channels"; + sidebar.toggle(sectionKey, true); + setRowFocus({ channelId, sectionKey }); + closeRowMenu(); + } catch (error) { + if (generation !== rowMenuGeneration.current) return; + setGroupWrite({ + channelId, + pending: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + const setChannelStar = async (channelId: string, starred: boolean) => { + const generation = rowMenuGeneration.current; + setGroupWrite({ channelId, pending: true }); + try { + await preferences.setStar(channelId, starred); + if (generation !== rowMenuGeneration.current) return; + const sectionId = preferences.data?.assignments[channelId]; + const sectionKey = starred + ? "starred" + : preferences.data?.sections.some((group) => group.id === sectionId) + ? `group:${sectionId}` + : "channels"; + sidebar.toggle(sectionKey, true); + setRowFocus({ channelId, sectionKey }); + closeRowMenu(); + } catch (error) { + if (generation !== rowMenuGeneration.current) return; + setGroupWrite({ + channelId, + pending: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }; return (
@@ -878,7 +978,26 @@ 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 && + section.key !== "starred" && + channel.channelType !== "dm" && + channel.channelType !== "forum" && + !!preferences.data?.sections.length; + const starrable = + preferences.starWritable && + !!preferences.data && + channel.channelType !== "dm" && + channel.channelType !== "forum"; + const starred = section.key === "starred"; + const menuOpen = + (movable || starrable) && + rowMenu?.channel.id === channel.id && + rowMenu.sectionId === currentSectionId; + const channelButton = ( ); + if (!movable && !starrable) { + return ( +
+ {channelButton} +
+ ); + } + return ( + { + if (open) openRowMenu(channel, currentSectionId); + else if (menuOpen) closeRowMenu(); + }} + > + { + if ( + event.key === "ContextMenu" || + (event.shiftKey && event.key === "F10") + ) { + event.preventDefault(); + openRowMenu( + channel, + currentSectionId, + event.currentTarget, + ); + } + }} + render={ +
+ } + > + {channelButton} + + + sidebar.list.current?.querySelector( + `[data-channel-id="${CSS.escape(channel.id)}"]`, + ) ?? false + } + > + {starrable && ( + + void setChannelStar(channel.id, !starred) + } + > + {starred ? "Unstar" : "Star"} + + )} + {movable && ( + <> + {starrable && } + + Move to group + + + void assignGroup(channel.id, sectionId) + } + disabled={ + groupWrite?.channelId === channel.id && + groupWrite.pending + } + > + {preferences.data?.sections.map((group) => ( + + {group.icon && ( + {group.icon} + )} + {group.name} + + ))} + + {currentSectionId && ( + <> + + void assignGroup(channel.id)} + > + Remove from group + + + )} + + )} + {groupWrite?.channelId === channel.id && + groupWrite.pending &&

Saving…

} + {groupWrite?.channelId === channel.id && + groupWrite.error && ( +

{groupWrite.error}

+ )} +
+ + ); })} ); diff --git a/src/bundled/channels/sidebar-sections.test.ts b/src/bundled/channels/sidebar-sections.test.ts index 79068193a..addc78730 100644 --- a/src/bundled/channels/sidebar-sections.test.ts +++ b/src/bundled/channels/sidebar-sections.test.ts @@ -74,3 +74,22 @@ it("intersects groups/stars with active authorized streams, keeping forums and D ), ).toEqual(["star", "work", "other", "forum", "dm", "group-dm"]); }); +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"]]]); + expect(placements(["alpha"])).toEqual([ + ["starred", ["alpha"]], + ["group:work", ["beta"]], + ]); + expect(saved.assignments).toEqual({ beta: "work" }); +}); diff --git a/src/bundled/channels/useSidebarPreferences.ts b/src/bundled/channels/useSidebarPreferences.ts index 495771064..011463d6b 100644 --- a/src/bundled/channels/useSidebarPreferences.ts +++ b/src/bundled/channels/useSidebarPreferences.ts @@ -16,6 +16,10 @@ export function useSidebarPreferences( return { ...snapshot, status: snapshot.status === "idle" ? ("loading" as const) : snapshot.status, + writable: queries.writable, + assign: queries.assign, + starWritable: queries.starWritable, + setStar: queries.setStar, reload: queries.refresh, }; } diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 2666038c4..d56d2f0c1 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -791,6 +791,34 @@ 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; + })(), notify, ); const workSessions = createWorkSessions( diff --git a/src/features/relay/sidebar-preferences-store.test.ts b/src/features/relay/sidebar-preferences-store.test.ts index 0aedbb893..b827ba4bf 100644 --- a/src/features/relay/sidebar-preferences-store.test.ts +++ b/src/features/relay/sidebar-preferences-store.test.ts @@ -1,22 +1,163 @@ 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 ? { writeSidebarAssignment: write } : {}), + ...(writeStar ? { writeSidebarStar: writeStar } : {}), }); 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).toHaveBeenCalledOnce(); + } 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()).toBe(retained); + } 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 +305,170 @@ 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 () => { + 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); + 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", "beta"], + }, + }); + 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()).toBe(retained); + await preferences.setStar("beta", false); + 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..b6fe42c1c 100644 --- a/src/features/relay/sidebar-preferences-store.ts +++ b/src/features/relay/sidebar-preferences-store.ts @@ -1,4 +1,8 @@ -import type { SidebarPreferences } from "./sidebar-preferences"; +import type { + SidebarAssignmentMutator, + SidebarStarMutator, + SidebarPreferences, +} from "./sidebar-preferences"; type Snapshot = Readonly<{ status: "idle" | "loading" | "ready" | "error" | "unsupported"; @@ -10,6 +14,8 @@ type Snapshot = Readonly<{ export function createSidebarPreferencesStore( read: (signal?: AbortSignal) => Promise, available: boolean, + write?: SidebarAssignmentMutator, + writeStar?: SidebarStarMutator, notify = (listener: () => void) => listener(), ) { const listeners = new Set<() => void>(); @@ -20,6 +26,18 @@ export function createSidebarPreferencesStore( let active: | { controller: AbortController; promise: Promise } | undefined; + let writeQueue = Promise.resolve(); + let writeLifetime = new AbortController(); + let mutation = 0; + let generation = 0; + const 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]), + }); const publish = (next: Snapshot) => { snapshot = Object.freeze(next); for (const listener of listeners) notify(listener); @@ -28,25 +46,28 @@ export function createSidebarPreferencesStore( if (closed || !available) return Promise.resolve(); if (active) return active.promise; const controller = new AbortController(); + const refreshMutation = mutation; const job = { controller, promise: Promise.resolve() }; active = job; job.promise = Promise.resolve().then(async () => { if (closed || controller.signal.aborted) return; try { const data = await read(controller.signal); - if (closed || controller.signal.aborted || active !== job) return; - publish({ - status: "ready", - data: Object.freeze({ - sections: Object.freeze( - data.sections.map((section) => Object.freeze({ ...section })), - ), - assignments: Object.freeze({ ...data.assignments }), - starred: Object.freeze([...data.starred]), - }), - }); + if ( + closed || + controller.signal.aborted || + active !== job || + mutation !== refreshMutation + ) + return; + publish({ status: "ready", data: retained(data) }); } catch (error) { - if (!closed && !controller.signal.aborted && active === job) + if ( + !closed && + !controller.signal.aborted && + active === job && + mutation === refreshMutation + ) publish({ ...snapshot, status: "error", @@ -65,6 +86,84 @@ export function createSidebarPreferencesStore( return { queries: Object.freeze({ available, + writable: !!write, + assign(channelId: string, sectionId?: string, signal?: AbortSignal) { + if (closed || !write || !snapshot.data) + return Promise.reject( + new Error("Saved sidebar groups are read-only in this host"), + ); + const writeGeneration = generation; + const writeSignal = AbortSignal.any([ + writeLifetime.signal, + ...(signal ? [signal] : []), + ]); + const run = writeQueue + .catch(() => {}) + .then(async () => { + if (closed || generation !== writeGeneration) + throw new Error("Saved sidebar groups are unavailable"); + writeSignal.throwIfAborted(); + const groups = await write( + { channelId, ...(sectionId ? { sectionId } : {}) }, + writeSignal, + ); + if (closed || generation !== writeGeneration) + throw new Error("Saved sidebar groups are unavailable"); + writeSignal.throwIfAborted(); + mutation++; + const current = snapshot.data; + publish({ + status: "ready", + data: retained({ + sections: groups.sections, + assignments: groups.assignments, + starred: current?.starred ?? [], + }), + }); + return groups; + }); + writeQueue = run.then( + () => undefined, + () => undefined, + ); + return run; + }, + starWritable: !!writeStar, + setStar(channelId: string, starred: boolean, signal?: AbortSignal) { + if (closed || !writeStar || !snapshot.data) + return Promise.reject( + new Error("Sidebar stars are unavailable in this host"), + ); + const writeGeneration = generation; + const writeSignal = AbortSignal.any([ + writeLifetime.signal, + ...(signal ? [signal] : []), + ]); + const run = writeQueue + .catch(() => {}) + .then(async () => { + if (closed || generation !== writeGeneration) + throw new Error("Sidebar stars are unavailable"); + writeSignal.throwIfAborted(); + const stars = await writeStar({ channelId, starred }, writeSignal); + if (closed || generation !== writeGeneration) + throw new Error("Sidebar stars are unavailable"); + writeSignal.throwIfAborted(); + const current = snapshot.data; + if (!current) throw new Error("Sidebar stars are unavailable"); + mutation++; + publish({ + status: "ready", + data: retained({ ...current, starred: stars }), + }); + return stars; + }); + writeQueue = run.then( + () => undefined, + () => undefined, + ); + return run; + }, // Keep explicit one-shot reads compatible; views use the retained snapshot. read, snapshot: () => snapshot, @@ -83,12 +182,19 @@ export function createSidebarPreferencesStore( }), clear() { if (closed) return; + generation++; + mutation++; + writeLifetime.abort(); + writeLifetime = new AbortController(); active?.controller.abort(); active = undefined; publish(empty()); }, dispose() { closed = true; + writeLifetime.abort(); + generation++; + 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..96719f7e0 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"; @@ -578,3 +585,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..da1ec1f9a 100644 --- a/src/features/relay/sidebar-preferences.ts +++ b/src/features/relay/sidebar-preferences.ts @@ -5,7 +5,7 @@ export const SIDEBAR_COORDINATES = [ "channel-sections", "channel-stars", ] as const; -export type SidebarPreferences = Readonly<{ +export type SidebarGroups = Readonly<{ sections: readonly Readonly<{ id: string; name: string; @@ -13,13 +13,27 @@ export type SidebarPreferences = Readonly<{ order: number; }>[]; assignments: Readonly>; - starred: readonly string[]; }>; +export type SidebarPreferences = SidebarGroups & + Readonly<{ + starred: readonly string[]; + }>; +export type SidebarAssignmentIntent = Readonly<{ + channelId: string; + sectionId?: string; +}>; +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"); diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index d03706ef1..fa91aa1f8 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -9,7 +9,13 @@ 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 SidebarDecoder, + type SidebarPreferences, +} from "./sidebar-preferences"; import { createHostAdmission } from "./host-admission"; import { relayOrigin } from "../communities/destination"; import { @@ -73,6 +79,9 @@ 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 profiling?: RelayProfiler; /** Verified incoming traffic. The session owns this subscription and fences late delivery. */ subscribe?(callbacks: LiveCallbacks): LiveSubscription; @@ -229,6 +238,8 @@ export async function connectBrokerTransport( live?: boolean; presence?: boolean; sidebarPreferences?: boolean; + sidebarPreferenceWrites?: boolean; + sidebarStarWrites?: boolean; agentLibrary?: boolean; agentActivity?: boolean; readState?: boolean; @@ -437,6 +448,53 @@ 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.writeKinds ? { writer: { diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 010761d89..97b53afa6 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -807,18 +807,50 @@ 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"].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); + }, } : {}), }) @@ -1239,10 +1271,26 @@ export const test = base.extend({ observerFailures.splice(match, 1); return true; }; + // The Star retry journey injects one specific failed host request. Match + // that exact URL once, not every 502 or every console error in the test. + const starFailures = [...(report.sidebarStarFailures ?? [])]; + 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-groups.spec.mjs b/tests/browser/navigation-groups.spec.mjs index 0b4c28a2b..5c350fd16 100644 --- a/tests/browser/navigation-groups.spec.mjs +++ b/tests/browser/navigation-groups.spec.mjs @@ -1,6 +1,185 @@ import { test, expect } from "./fixture.mjs"; import { open } from "./timeline.mjs"; +test.use({ productionBroker: true, savedSidebar: true }); + +test("row menu moves and removes a channel through the confirmed saved-group writer", async ({ + page, + app, +}) => { + await open(page, app); + const sidebar = page.getByRole("navigation", { + name: "Subscribed channels", + }); + const work = sidebar + .locator("details") + .filter({ has: page.locator("summary", { hasText: /^Work$/ }) }); + const channels = sidebar + .locator("details") + .filter({ has: page.locator("summary", { hasText: /^Channels$/ }) }); + await expect( + work.getByRole("button", { name: "Beta", exact: true }), + ).toBeVisible(); + + await work + .getByRole("button", { name: "Beta", exact: true }) + .click({ button: "right" }); + const menu = page.getByRole("menu", { name: "Actions for Beta" }); + await expect(menu).toBeVisible(); + await expect( + menu.getByRole("menuitemradio", { name: "Work" }), + ).toHaveAttribute("aria-checked", "true"); + await page.keyboard.press("End"); + await expect( + page.getByRole("menuitem", { name: "Remove from group" }), + ).toBeFocused(); + await page.keyboard.press("Home"); + await expect( + menu.getByRole("menuitem", { name: "Star", exact: true }), + ).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect(menu.getByRole("menuitemradio", { name: "Work" })).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect( + page.getByRole("menuitem", { name: "Remove from group" }), + ).toBeFocused(); + await page.keyboard.press("Enter"); + await expect( + channels.getByRole("button", { name: "Beta", exact: true }), + ).toBeVisible(); + await expect( + work.getByRole("button", { name: "Beta", exact: true }), + ).toHaveCount(0); + await expect( + channels.getByRole("button", { name: "Beta", exact: true }), + ).toBeFocused(); + expect(app.report.sidebarPublications).toHaveLength(1); + expect(app.report.sidebarPublications[0].blob.assignments).toEqual({}); + + await channels + .getByRole("button", { name: "Beta", exact: true }) + .click({ button: "right" }); + await page.getByRole("menuitemradio", { name: "Work" }).click(); + await expect( + work.getByRole("button", { name: "Beta", exact: true }), + ).toBeVisible(); + await expect( + channels.getByRole("button", { name: "Beta", exact: true }), + ).toHaveCount(0); + await expect( + work.getByRole("button", { name: "Beta", exact: true }), + ).toBeFocused(); + expect(app.report.sidebarPublications).toHaveLength(2); + expect(app.report.sidebarPublications[1].blob.assignments).toEqual({ + beta: "work", + }); + expect(app.report.unexpected).toEqual([]); +}); + +// Real context-menu keyboard/focus, viewport placement and row relocation require +// a browser. Intent validation, concurrency and persistence matrices stay below. +test("Star and Unstar retain the assigned group, keep one row, and recover from a failed save", 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 work = sidebar.locator('[data-sidebar-section="group:work"]'); + const starred = sidebar.locator('[data-sidebar-section="starred"]'); + const beta = work.getByRole("button", { name: "Beta", exact: true }); + await expect(beta).toBeVisible(); + await starred.locator("summary").click(); + await expect(starred).not.toHaveAttribute("open", ""); + await beta.focus(); + await page.keyboard.press("Shift+F10"); + const menu = page.getByRole("menu", { name: "Actions for Beta" }); + await expect(menu).toBeVisible(); + await menu.screenshot({ path: testInfo.outputPath("group-star-menu.png") }); + await page.keyboard.press("Escape"); + await expect(beta).toBeFocused(); + await page.keyboard.press("ContextMenu"); + await expect(menu).toBeVisible(); + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + let started; + const requestStarted = new Promise((resolve) => { + started = resolve; + }); + await page.route("**/sidebar-star", async (route) => { + started(); + await held; + 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("menuitem", { name: "Star", exact: true }).click(); + await requestStarted; + await expect(menu.getByRole("status")).toHaveText("Saving…"); + await expect( + menu.getByRole("menuitem", { name: "Star", exact: true }), + ).toHaveAttribute("aria-disabled", "true"); + await expect( + page.locator( + '[data-sidebar-section="group:work"] [data-channel-id="beta"]', + ), + ).toBeVisible(); + } finally { + release(); + } + await expect(menu.getByRole("alert")).toHaveText( + "Relay request failed (502)", + ); + await expect( + menu.getByRole("menuitem", { name: "Star", exact: true }), + ).toBeEnabled(); + await page.unroute("**/sidebar-star"); + await page.keyboard.press("Home"); + await expect( + menu.getByRole("menuitem", { name: "Star", exact: true }), + ).toBeFocused(); + await page.keyboard.press("Enter"); + const relocated = starred.getByRole("button", { name: "Beta", exact: true }); + await expect(relocated).toBeVisible(); + await expect(relocated).toBeFocused(); + await sidebar.screenshot({ path: testInfo.outputPath("group-starred.png") }); + await expect(sidebar.locator('[data-channel-id="beta"]')).toHaveCount(1); + expect(app.report.sidebarPublications).toHaveLength(1); + expect(app.report.sidebarPublications[0]).toMatchObject({ + coordinate: "channel-stars", + blob: { channels: { alpha: { starred: true }, beta: { starred: true } } }, + }); + + // Work is absent while its only row is starred. Unstar must restore it. + await relocated.click({ button: "right" }); + await expect(menu).toBeVisible(); + 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 menu.getByRole("menuitem", { name: "Unstar", exact: true }).click(); + await expect(beta).toBeVisible(); + await expect(beta).toBeFocused(); + await expect(sidebar.locator('[data-channel-id="beta"]')).toHaveCount(1); + expect(app.report.sidebarPublications).toHaveLength(2); + expect(app.report.sidebarPublications[1]).toMatchObject({ + coordinate: "channel-stars", + blob: { channels: { alpha: { starred: true }, beta: { starred: false } } }, + }); + expect(app.report.unexpected).toEqual([]); +}); + test.use({ productionBroker: true, savedSidebar: true, From f66c29a286c4472a96da37992e79644303a0065c Mon Sep 17 00:00:00 2001 From: Carl Date: Thu, 17 Sep 2026 09:11:03 -1000 Subject: [PATCH 2/5] fix(channels): treat starred as a sidebar group Offer Starred and saved groups in one destination chooser. Confirm the destination assignment before clearing Star so removal returns to Channels without restoring a hidden group after reload. Retain confirmed local placement through partial failures, fence overlapping refreshes, and cover ordered writes, cancellation, retry and browser focus. Signed-off-by: Carl --- dev/sidebar-group-moves.test.mjs | 269 ++++++++++++++++++ dev/sidebar-stars.mjs | 3 +- dev/sidebar-stars.test.mjs | 2 +- docs/channels.md | 20 +- src/bundled/channels/ChannelsPage.tsx | 119 ++++---- .../relay/sidebar-preferences-store.test.ts | 25 +- .../relay/sidebar-preferences-store.ts | 169 ++++++----- tests/browser/navigation-groups.spec.mjs | 68 ++++- 8 files changed, 513 insertions(+), 162 deletions(-) create mode 100644 dev/sidebar-group-moves.test.mjs diff --git a/dev/sidebar-group-moves.test.mjs b/dev/sidebar-group-moves.test.mjs new file mode 100644 index 000000000..3c27fd875 --- /dev/null +++ b/dev/sidebar-group-moves.test.mjs @@ -0,0 +1,269 @@ +import { expect, it, vi } from "vitest"; +import { + finalizeEvent, + generateSecretKey, + getPublicKey, + nip44, +} from "nostr-tools"; +import { createSidebarPreferencesStore } from "../src/features/relay/sidebar-preferences-store.ts"; +import { sidebarSections } from "../src/bundled/channels/sidebar-sections.ts"; +import { + decodeSidebarPreferences, + mutateSidebarAssignment, +} from "./sidebar-preferences.mjs"; +import { mutateSidebarStar } from "./sidebar-stars.mjs"; + +async function setup({ cachedAssignment = true } = {}) { + const secret = generateSecretKey(); + const key = nip44.v2.utils.getConversationKey(secret, getPublicKey(secret)); + const heads = new Map(); + for (const [coordinate, blob] of [ + [ + "channel-sections", + { + version: 1, + sections: [{ id: "work", name: "Work", order: 0 }], + assignments: { alpha: "work", beta: "work" }, + }, + ], + [ + "channel-stars", + { version: 1, channels: { alpha: { starred: true, updatedAt: 1 } } }, + ], + ]) + heads.set( + coordinate, + finalizeEvent( + { + kind: 30078, + created_at: 1, + tags: [["d", coordinate]], + content: nip44.v2.encrypt(JSON.stringify(blob), key), + }, + secret, + ), + ); + key.fill(0); + const read = vi.fn(async () => + decodeSidebarPreferences([...heads.values()], secret), + ); + if (!cachedAssignment) + read.mockResolvedValueOnce({ + ...(await read()), + assignments: { beta: "work" }, + }); + const publications = []; + const publish = vi.fn(async (event) => { + const coordinate = event.tags.find(([tag]) => tag === "d")[1]; + heads.set(coordinate, event); + publications.push(coordinate); + }); + const assignment = vi.fn((intent, signal) => + mutateSidebarAssignment( + intent, + secret, + async () => { + signal.throwIfAborted(); + return [heads.get("channel-sections")]; + }, + publish, + ), + ); + const star = vi.fn(async (intent, signal) => { + const result = await mutateSidebarStar( + intent, + secret, + async () => { + signal.throwIfAborted(); + return [heads.get("channel-stars")]; + }, + publish, + ); + return Object.entries(result.channels) + .filter(([, value]) => value.starred) + .map(([id]) => id); + }); + const owner = createSidebarPreferencesStore(read, true, assignment, star); + await owner.queries.ensure(); + return { + owner, + prefs: owner.queries, + read, + publish, + publications, + assignment, + star, + }; +} + +it.each([true, false])( + "removal clears the durable previous group even when cached assignment is %s", + async (cachedAssignment) => { + const h = await setup({ cachedAssignment }); + try { + await h.prefs.setStar("alpha", false); + expect(h.publications).toEqual(["channel-sections", "channel-stars"]); + const restored = await h.read(); + expect(restored.assignments).toEqual({ beta: "work" }); + expect(restored.starred).toEqual([]); + expect(h.prefs.snapshot().data).toEqual(restored); + expect( + sidebarSections([{ id: "alpha", name: "Alpha" }], restored).map( + ({ key }) => key, + ), + ).toEqual(["channels"]); + } finally { + h.owner.dispose(); + } + }, +); + +it("moves directly from Starred into a saved group and retains other assignments", async () => { + const h = await setup(); + try { + await h.prefs.assign("alpha", "work"); + const restored = await h.read(); + expect(restored.assignments).toEqual({ alpha: "work", beta: "work" }); + expect(restored.starred).toEqual([]); + expect( + sidebarSections([{ id: "alpha", name: "Alpha" }], restored).map( + ({ key }) => key, + ), + ).toEqual(["group:work"]); + } finally { + h.owner.dispose(); + } +}); + +it("does not clear Star when assignment publication fails", async () => { + const h = await setup(); + try { + const before = h.prefs.snapshot(); + h.publish.mockRejectedValueOnce(new Error("assignment rejected")); + await expect(h.prefs.setStar("alpha", false)).rejects.toThrow( + "assignment rejected", + ); + expect(h.star).not.toHaveBeenCalled(); + expect(h.prefs.snapshot()).toBe(before); + expect((await h.read()).starred).toEqual(["alpha"]); + await h.prefs.setStar("alpha", false); + expect((await h.read()).assignments).toEqual({ beta: "work" }); + } finally { + h.owner.dispose(); + } +}); + +it("keeps Starred after a partial failure and retry cannot resurrect the old group", async () => { + const h = await setup(); + try { + const before = h.prefs.snapshot(); + h.star.mockRejectedValueOnce(new Error("star rejected")); + await expect(h.prefs.setStar("alpha", false)).rejects.toThrow( + "star rejected", + ); + expect(h.prefs.snapshot()).toBe(before); + const partial = await h.read(); + expect(partial.assignments).toEqual({ beta: "work" }); + expect(partial.starred).toEqual(["alpha"]); + await h.prefs.refresh(); + await h.prefs.setStar("alpha", false); + expect(h.prefs.snapshot().data).toEqual({ ...partial, starred: [] }); + expect(h.publications).toEqual(["channel-sections", "channel-stars"]); + } finally { + h.owner.dispose(); + } +}); + +it("a refresh during the two-write move cannot expose the intermediate assignment", async () => { + const h = await setup(); + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + let started; + const entered = new Promise((resolve) => { + started = resolve; + }); + const star = h.star.getMockImplementation(); + h.star.mockImplementationOnce(async (...args) => { + started(); + await held; + return star(...args); + }); + try { + const before = h.prefs.snapshot(); + const pending = h.prefs.setStar("alpha", false); + await entered; + const refresh = h.prefs.refresh(); + expect(h.prefs.snapshot()).toBe(before); + expect(h.read).toHaveBeenCalledOnce(); + release(); + await Promise.all([pending, refresh]); + expect(h.prefs.snapshot().data.assignments).toEqual({ beta: "work" }); + expect(h.prefs.snapshot().data.starred).toEqual([]); + } finally { + release(); + h.owner.dispose(); + } +}); + +it("failed move plus an older refresh cannot strand the preference status at loading", async () => { + const h = await setup(); + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + let started; + const entered = new Promise((resolve) => { + started = resolve; + }); + try { + const before = h.prefs.snapshot().data; + h.read.mockImplementationOnce(async () => { + started(); + return held; + }); + const refresh = h.prefs.refresh(); + await entered; + h.assignment.mockRejectedValueOnce(new Error("assignment failed")); + await expect(h.prefs.setStar("alpha", false)).rejects.toThrow( + "assignment failed", + ); + release(before); + await refresh; + expect(h.prefs.snapshot()).toEqual({ + status: "error", + error: "assignment failed", + data: before, + }); + await h.prefs.refresh(); + expect(h.prefs.snapshot().status).toBe("ready"); + } finally { + release(h.prefs.snapshot().data); + h.owner.dispose(); + } +}); + +it("cancellation between records prevents clearing Star and retry finishes from durable state", async () => { + const h = await setup(); + const caller = new AbortController(); + const assign = h.assignment.getMockImplementation(); + h.assignment.mockImplementationOnce(async (...args) => { + const result = await assign(...args); + caller.abort(); + return result; + }); + try { + await expect( + h.prefs.setStar("alpha", false, caller.signal), + ).rejects.toThrow(); + expect(h.star).not.toHaveBeenCalled(); + expect((await h.read()).starred).toEqual(["alpha"]); + await h.prefs.setStar("alpha", false); + const restored = await h.read(); + expect(restored.assignments).toEqual({ beta: "work" }); + expect(restored.starred).toEqual([]); + } finally { + h.owner.dispose(); + } +}); diff --git a/dev/sidebar-stars.mjs b/dev/sidebar-stars.mjs index 50be9b06e..9e8b25f79 100644 --- a/dev/sidebar-stars.mjs +++ b/dev/sidebar-stars.mjs @@ -41,7 +41,8 @@ export function prepareSidebarStar(events, intent, secret, now = Date.now()) { const previous = Object.hasOwn(current.channels, intent.channelId) ? current.channels[intent.channelId] : undefined; - if (previous?.starred === intent.starred) return { stars: current }; + if (previous?.starred === intent.starred || (!previous && !intent.starred)) + return { stars: current }; const stars = { ...current, channels: { diff --git a/dev/sidebar-stars.test.mjs b/dev/sidebar-stars.test.mjs index f97508f91..e4e48572c 100644 --- a/dev/sidebar-stars.test.mjs +++ b/dev/sidebar-stars.test.mjs @@ -124,7 +124,7 @@ it("encrypts explicit Star/Unstar with monotonic timestamps and preserves unrela h.secret, 50000, ).stars.channels, - ).toEqual({ new: { starred: false, updatedAt: 50000 } }); + ).toEqual({}); }); it("refuses untrusted, ambiguous, malformed and over-budget heads rather than seeding", () => { const h = harness(), diff --git a/docs/channels.md b/docs/channels.md index 0185562a2..362584672 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -81,9 +81,9 @@ unstar tombstones are retained. Invalid/unreadable/over-budget heads fail closed only a successful absent-head read can seed a coordinate. Same-host writes are serialized per relay. This is confirmed whole-record replacement, not atomic cross-device merging, a durable pending outbox, or automatic retry: simultaneous -writers on different hosts can still race. Failure leaves the last confirmed UI -state and offers an explicit retry; a failed confirmation may follow a publication -that reached the relay. +writers on different hosts can still race. Failure retains the last fully confirmed UI +placement and offers an explicit retry; a failed confirmation may follow a +publication that reached the relay. The session preference owner serializes local commands and fences refreshes, caller cancellation, cache clear and disposal. `session.ts` only composes host @@ -92,7 +92,19 @@ pending work but cannot retract a publication already accepted by the relay. Stream rows expose these actions by right-click/long-press, Shift+F10 or the Context Menu key. Menus remain open during saving and failed-save retry; confirmed relocation expands the destination and restores focus by channel identity. Starred -placement is exclusive, retaining the saved assignment so Unstar restores it. +is a built-in group pinned first, offered alongside saved groups in one "Move to…" +chooser. Placement is exclusive. "Remove from Starred" and "Remove from [group]" +return to Channels, never to a remembered group. Moving out of Starred directly +into a saved group is supported. + +The legacy format still stores stars and assignments separately. The preference +owner confirms the requested assignment (or its removal) **before** clearing Star; +removal always checks the fresh assignment head, not just the cached projection. +Only the complete move updates local placement, and refreshes cannot expose an +intermediate write. If the second write fails, the channel remains Starred and an +explicit retry finishes the move; a reload reflects whatever reached the relay. +This is ordered two-record persistence, not an atomic multi-device move. A prior +assignment may remain stored while starred but is never used as an Unstar target. Forums/DMs, group CRUD/reorder and independent sorting are outside this slice. Hosts without the write capabilities retain the read-only projection. diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 016d592a9..0510e1ac2 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -886,12 +886,7 @@ function ChannelWorkspace({ try { await preferences.setStar(channelId, starred); if (generation !== rowMenuGeneration.current) return; - const sectionId = preferences.data?.assignments[channelId]; - const sectionKey = starred - ? "starred" - : preferences.data?.sections.some((group) => group.id === sectionId) - ? `group:${sectionId}` - : "channels"; + const sectionKey = starred ? "starred" : "channels"; sidebar.toggle(sectionKey, true); setRowFocus({ channelId, sectionKey }); closeRowMenu(); @@ -983,18 +978,13 @@ function ChannelWorkspace({ : undefined; const movable = preferences.writable && - section.key !== "starred" && - channel.channelType !== "dm" && - channel.channelType !== "forum" && - !!preferences.data?.sections.length; - const starrable = preferences.starWritable && !!preferences.data && channel.channelType !== "dm" && channel.channelType !== "forum"; const starred = section.key === "starred"; const menuOpen = - (movable || starrable) && + movable && rowMenu?.channel.id === channel.id && rowMenu.sectionId === currentSectionId; const channelButton = ( @@ -1018,7 +1008,7 @@ function ChannelWorkspace({ onHideDm={hiddenDms.hide} /> ); - if (!movable && !starrable) { + if (!movable) { return (
{channelButton} @@ -1066,64 +1056,63 @@ function ChannelWorkspace({ ) ?? false } > - {starrable && ( - - void setChannelStar(channel.id, !starred) - } - > - {starred ? "Unstar" : "Star"} - - )} - {movable && ( + + Move to… + + { + if (destination === "starred") + void setChannelStar(channel.id, true); + else + void assignGroup( + channel.id, + destination.slice("group:".length), + ); + }} + disabled={ + groupWrite?.channelId === channel.id && + groupWrite.pending + } + > + + ★ + Starred + + {preferences.data?.sections.map((group) => ( + + {group.icon && {group.icon}} + {group.name} + + ))} + + {(starred || currentSectionId) && ( <> - {starrable && } - - Move to group - - - void assignGroup(channel.id, sectionId) - } + + { + if (starred) + void setChannelStar(channel.id, false); + else void assignGroup(channel.id); + }} > - {preferences.data?.sections.map((group) => ( - - {group.icon && ( - {group.icon} - )} - {group.name} - - ))} - - {currentSectionId && ( - <> - - void assignGroup(channel.id)} - > - Remove from group - - - )} + Remove from {section.title} + )} {groupWrite?.channelId === channel.id && diff --git a/src/features/relay/sidebar-preferences-store.test.ts b/src/features/relay/sidebar-preferences-store.test.ts index b827ba4bf..84fcfe633 100644 --- a/src/features/relay/sidebar-preferences-store.test.ts +++ b/src/features/relay/sidebar-preferences-store.test.ts @@ -31,8 +31,24 @@ function setup( const owner = createRelaySession({ ...wire.transport, decodeSidebarPreferences: decode, - ...(write ? { writeSidebarAssignment: write } : {}), - ...(writeStar ? { writeSidebarStar: writeStar } : {}), + ...(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 }; } @@ -309,7 +325,8 @@ 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 () => { + const star = vi.fn(async ({ starred }) => { + if (!starred) return ["alpha"]; started.resolve(); return gate.promise; }); @@ -335,7 +352,7 @@ it("serializes confirmed assignment and star writes without losing either projec data: { ...data, assignments: { alpha: "work", beta: "work" }, - starred: ["alpha", "beta"], + starred: ["alpha"], }, }); expect(Object.isFrozen(preferences.snapshot().data?.starred)).toBe(true); diff --git a/src/features/relay/sidebar-preferences-store.ts b/src/features/relay/sidebar-preferences-store.ts index b6fe42c1c..3036382d2 100644 --- a/src/features/relay/sidebar-preferences-store.ts +++ b/src/features/relay/sidebar-preferences-store.ts @@ -29,6 +29,7 @@ export function createSidebarPreferencesStore( let writeQueue = Promise.resolve(); let writeLifetime = new AbortController(); let mutation = 0; + let writing = false; let generation = 0; const retained = (data: SidebarPreferences): SidebarPreferences => Object.freeze({ @@ -44,6 +45,7 @@ export function createSidebarPreferencesStore( }; function refresh(): Promise { if (closed || !available) return Promise.resolve(); + if (writing) return writeQueue; if (active) return active.promise; const controller = new AbortController(); const refreshMutation = mutation; @@ -83,86 +85,103 @@ export function createSidebarPreferencesStore( }); return job.promise; } + // The legacy format uses two coordinates. Keep a move in one session queue, + // confirm the destination assignment before clearing Star, and expose the new + // placement only after both writes succeed. Failure is explicitly retryable; + // this is not an atomic cross-host transaction. + function move( + channelId: string, + destination: { starred: true } | { sectionId?: string }, + signal?: AbortSignal, + ): Promise { + const starring = "starred" in destination; + if (closed || !snapshot.data || !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] : []), + ]); + const check = () => { + if (closed || generation !== writeGeneration) + throw new Error("Sidebar group moves are unavailable"); + writeSignal.throwIfAborted(); + }; + const run = writeQueue + .catch(() => {}) + .then(async () => { + check(); + mutation++; + writing = true; + try { + // Always re-read/write the assignment on removal, even if the cached + // projection has no assignment (another client may have added one). + const groups = starring + ? undefined + : await write( + { + channelId, + ...(destination.sectionId + ? { sectionId: destination.sectionId } + : {}), + }, + writeSignal, + ); + check(); + const stars = await writeStar( + { channelId, starred: starring }, + writeSignal, + ); + check(); + const current = snapshot.data; + if (!current) throw new Error("Sidebar group moves are unavailable"); + const data = retained({ ...current, ...groups, starred: stars }); + publish({ status: "ready", data }); + return data; + } catch (error) { + // A refresh fenced by this move must not leave a permanent loading + // state if the move fails too. Retain its last confirmed placement. + if ( + generation === writeGeneration && + !closed && + snapshot.status === "loading" + ) + publish({ + ...snapshot, + status: "error", + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } finally { + if (generation === writeGeneration) { + mutation++; + writing = false; + } + } + }); + writeQueue = run.then( + () => undefined, + () => undefined, + ); + return run; + } return { queries: Object.freeze({ available, - writable: !!write, + writable: !!write && !!writeStar, assign(channelId: string, sectionId?: string, signal?: AbortSignal) { - if (closed || !write || !snapshot.data) - return Promise.reject( - new Error("Saved sidebar groups are read-only in this host"), - ); - const writeGeneration = generation; - const writeSignal = AbortSignal.any([ - writeLifetime.signal, - ...(signal ? [signal] : []), - ]); - const run = writeQueue - .catch(() => {}) - .then(async () => { - if (closed || generation !== writeGeneration) - throw new Error("Saved sidebar groups are unavailable"); - writeSignal.throwIfAborted(); - const groups = await write( - { channelId, ...(sectionId ? { sectionId } : {}) }, - writeSignal, - ); - if (closed || generation !== writeGeneration) - throw new Error("Saved sidebar groups are unavailable"); - writeSignal.throwIfAborted(); - mutation++; - const current = snapshot.data; - publish({ - status: "ready", - data: retained({ - sections: groups.sections, - assignments: groups.assignments, - starred: current?.starred ?? [], - }), - }); - return groups; - }); - writeQueue = run.then( - () => undefined, - () => undefined, - ); - return run; + return move(channelId, sectionId ? { sectionId } : {}, signal); }, - starWritable: !!writeStar, - setStar(channelId: string, starred: boolean, signal?: AbortSignal) { - if (closed || !writeStar || !snapshot.data) - return Promise.reject( - new Error("Sidebar stars are unavailable in this host"), - ); - const writeGeneration = generation; - const writeSignal = AbortSignal.any([ - writeLifetime.signal, - ...(signal ? [signal] : []), - ]); - const run = writeQueue - .catch(() => {}) - .then(async () => { - if (closed || generation !== writeGeneration) - throw new Error("Sidebar stars are unavailable"); - writeSignal.throwIfAborted(); - const stars = await writeStar({ channelId, starred }, writeSignal); - if (closed || generation !== writeGeneration) - throw new Error("Sidebar stars are unavailable"); - writeSignal.throwIfAborted(); - const current = snapshot.data; - if (!current) throw new Error("Sidebar stars are unavailable"); - mutation++; - publish({ - status: "ready", - data: retained({ ...current, starred: stars }), - }); - return stars; - }); - writeQueue = run.then( - () => undefined, - () => undefined, + starWritable: !!write && !!writeStar, + async setStar(channelId: string, starred: boolean, signal?: AbortSignal) { + const data = await move( + channelId, + starred ? { starred: true } : {}, + signal, ); - return run; + return data.starred; }, // Keep explicit one-shot reads compatible; views use the retained snapshot. read, @@ -183,6 +202,7 @@ export function createSidebarPreferencesStore( clear() { if (closed) return; generation++; + writing = false; mutation++; writeLifetime.abort(); writeLifetime = new AbortController(); @@ -194,6 +214,7 @@ export function createSidebarPreferencesStore( closed = true; writeLifetime.abort(); generation++; + writing = false; mutation++; active?.controller.abort(); active = undefined; diff --git a/tests/browser/navigation-groups.spec.mjs b/tests/browser/navigation-groups.spec.mjs index 5c350fd16..c03197191 100644 --- a/tests/browser/navigation-groups.spec.mjs +++ b/tests/browser/navigation-groups.spec.mjs @@ -31,17 +31,17 @@ test("row menu moves and removes a channel through the confirmed saved-group wri ).toHaveAttribute("aria-checked", "true"); await page.keyboard.press("End"); await expect( - page.getByRole("menuitem", { name: "Remove from group" }), + page.getByRole("menuitem", { name: "Remove from Work" }), ).toBeFocused(); await page.keyboard.press("Home"); await expect( - menu.getByRole("menuitem", { name: "Star", exact: true }), + 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( - page.getByRole("menuitem", { name: "Remove from group" }), + page.getByRole("menuitem", { name: "Remove from Work" }), ).toBeFocused(); await page.keyboard.press("Enter"); await expect( @@ -78,7 +78,7 @@ test("row menu moves and removes a channel through the confirmed saved-group wri // Real context-menu keyboard/focus, viewport placement and row relocation require // a browser. Intent validation, concurrency and persistence matrices stay below. -test("Star and Unstar retain the assigned group, keep one row, and recover from a failed save", async ({ +test("group moves include Starred, remove to Channels, and recover from a failed save", async ({ page, app, }, testInfo) => { @@ -122,11 +122,13 @@ test("Star and Unstar retain the assigned group, keep one row, and recover from }); }); try { - await menu.getByRole("menuitem", { name: "Star", exact: true }).click(); + await menu + .getByRole("menuitemradio", { name: "Starred", exact: true }) + .click(); await requestStarted; await expect(menu.getByRole("status")).toHaveText("Saving…"); await expect( - menu.getByRole("menuitem", { name: "Star", exact: true }), + menu.getByRole("menuitemradio", { name: "Starred", exact: true }), ).toHaveAttribute("aria-disabled", "true"); await expect( page.locator( @@ -140,12 +142,12 @@ test("Star and Unstar retain the assigned group, keep one row, and recover from "Relay request failed (502)", ); await expect( - menu.getByRole("menuitem", { name: "Star", exact: true }), + menu.getByRole("menuitemradio", { name: "Starred", exact: true }), ).toBeEnabled(); await page.unroute("**/sidebar-star"); await page.keyboard.press("Home"); await expect( - menu.getByRole("menuitem", { name: "Star", exact: true }), + menu.getByRole("menuitemradio", { name: "Starred", exact: true }), ).toBeFocused(); await page.keyboard.press("Enter"); const relocated = starred.getByRole("button", { name: "Beta", exact: true }); @@ -159,7 +161,7 @@ test("Star and Unstar retain the assigned group, keep one row, and recover from blob: { channels: { alpha: { starred: true }, beta: { starred: true } } }, }); - // Work is absent while its only row is starred. Unstar must restore it. + // Work is absent while its only row is starred. Removal returns to Channels. await relocated.click({ button: "right" }); await expect(menu).toBeVisible(); const bounds = await menu.boundingBox(); @@ -168,15 +170,55 @@ test("Star and Unstar retain the assigned group, keep one row, and recover from expect(bounds.y).toBeGreaterThanOrEqual(0); expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width); expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height); - await menu.getByRole("menuitem", { name: "Unstar", exact: true }).click(); - await expect(beta).toBeVisible(); - await expect(beta).toBeFocused(); + await expect( + menu.getByRole("menuitemradio", { name: "Starred", exact: true }), + ).toHaveAttribute("aria-checked", "true"); + await menu + .getByRole("menuitem", { name: "Remove from Starred", exact: true }) + .click(); + const ungrouped = sidebar.locator( + '[data-sidebar-section="channels"] [data-channel-id="beta"]', + ); + await expect(ungrouped).toBeVisible(); + await expect(ungrouped).toBeFocused(); + await expect(beta).toHaveCount(0); await expect(sidebar.locator('[data-channel-id="beta"]')).toHaveCount(1); - expect(app.report.sidebarPublications).toHaveLength(2); + expect(app.report.sidebarPublications).toHaveLength(3); expect(app.report.sidebarPublications[1]).toMatchObject({ + coordinate: "channel-sections", + blob: { assignments: {} }, + }); + expect(app.report.sidebarPublications[2]).toMatchObject({ coordinate: "channel-stars", blob: { channels: { alpha: { starred: true }, beta: { starred: false } } }, }); + await page.reload(); + // Roster rows can render before preference decode reattaches their menus. + await expect( + starred.getByRole("button", { name: "Alpha", exact: true }), + ).toBeVisible(); + await expect(ungrouped).toBeVisible(); + await expect(beta).toHaveCount(0); + + // Direct moves out of Starred use the same chooser, without a separate Unstar. + await ungrouped.click({ button: "right" }); + await menu + .getByRole("menuitemradio", { name: "Starred", exact: true }) + .click(); + await expect(relocated).toBeFocused(); + await relocated.click({ button: "right" }); + await menu.getByRole("menuitemradio", { name: "Work", exact: true }).click(); + await expect(beta).toBeVisible(); + await expect(beta).toBeFocused(); + await expect(sidebar.locator('[data-channel-id="beta"]')).toHaveCount(1); + 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 } } }, + }); expect(app.report.unexpected).toEqual([]); }); From e883957c34f5831e2a644d79ef1c4711cc1bc838 Mon Sep 17 00:00:00 2001 From: Carl Date: Thu, 17 Sep 2026 09:01:54 -1000 Subject: [PATCH 3/5] feat(channels): sort sidebar sections by recent activity or name Keep independent section choices in confirmed encrypted preferences with optimistic rollback. Project bounded verified activity in the session, reuse existing live routes, and preserve current grouping behavior as the explicit stack base. Signed-off-by: Carl --- dev/relay-broker.mjs | 60 ++++- dev/sidebar-preference-writes.test.mjs | 105 ++++++++- dev/sidebar-preferences.mjs | 3 +- dev/sidebar-sort.mjs | 125 +++++++++++ dev/sidebar-sort.test.mjs | 167 ++++++++++++++ docs/channels.md | 25 ++- src/bundled/channels/ChannelsPage.tsx | 108 +++++++++ src/bundled/channels/sidebar-sections.test.ts | 86 +++++++- src/bundled/channels/sidebar-sections.ts | 55 ++++- src/bundled/channels/useSidebarPreferences.ts | 2 + .../relay/channel-activity-session.test.ts | 150 +++++++++++++ src/features/relay/channel-activity.test.ts | 186 ++++++++++++++++ src/features/relay/channel-activity.ts | 135 ++++++++++++ src/features/relay/contracts.ts | 2 + src/features/relay/live.test.ts | 33 +++ src/features/relay/live.ts | 2 +- src/features/relay/session.ts | 110 +++++++++- .../relay/sidebar-preferences-store.ts | 118 +++++++++- .../relay/sidebar-preferences.test.ts | 6 + src/features/relay/sidebar-preferences.ts | 33 +++ .../relay/sidebar-sorting-store.test.ts | 205 ++++++++++++++++++ src/features/relay/transport.ts | 64 +++++- tests/browser/fixture.mjs | 30 ++- tests/browser/navigation-sorting.spec.mjs | 147 +++++++++++++ tests/browser/policy-relay.mjs | 26 ++- 25 files changed, 1952 insertions(+), 31 deletions(-) create mode 100644 dev/sidebar-sort.mjs create mode 100644 dev/sidebar-sort.test.mjs create mode 100644 src/features/relay/channel-activity-session.test.ts create mode 100644 src/features/relay/channel-activity.test.ts create mode 100644 src/features/relay/channel-activity.ts create mode 100644 src/features/relay/sidebar-sorting-store.test.ts create mode 100644 tests/browser/navigation-sorting.spec.mjs diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index 7a70175a4..e9058baa1 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -1,6 +1,7 @@ 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, @@ -312,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) && @@ -629,14 +653,16 @@ export function relayBrokerPlugin({ [ "/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) > 2048) + if (Buffer.byteLength(raw) > (sorting ? 32 * 1024 : 2048)) return json(res, 413, { error: `Sidebar preference intent is too large`, }); @@ -644,7 +670,8 @@ export function relayBrokerPlugin({ let intent; try { intent = JSON.parse(raw); - if (starring) assertSidebarStarIntent(intent); + if (sorting) assertSidebarSortIntent(intent); + else if (starring) assertSidebarStarIntent(intent); else assertSidebarAssignmentIntent(intent); } catch { return json(res, 400, { @@ -663,7 +690,13 @@ export function relayBrokerPlugin({ { kinds: [30078], authors: [viewer], - "#d": [starring ? "channel-stars" : "channel-sections"], + "#d": [ + sorting + ? "channel-sort" + : starring + ? "channel-stars" + : "channel-sections", + ], limit: 1, }, ]; @@ -738,6 +771,15 @@ export function relayBrokerPlugin({ "Sidebar preference publication was not accepted", ); }; + if (sorting) + return { + groups: await mutateSidebarSort( + intent, + key, + readHead, + publishEvent, + ), + }; return (starring ? mutateSidebarStar : mutateSidebarAssignment)( intent, key, @@ -802,6 +844,8 @@ export function relayBrokerPlugin({ readState: true, sidebarPreferenceWrites: true, sidebarStarWrites: true, + sidebarSortWrites: true, + channelActivity: true, agentLibrary: true, live: true, presence: true, @@ -1210,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", @@ -1272,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"; @@ -1426,6 +1474,7 @@ export function relayBrokerPlugin({ !workflowPath && !readPublishing && !snapshot && + !channelActivity && !validFilters(filters) ) return json(res, 400, { error: "Read filter rejected" }); @@ -1556,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-preference-writes.test.mjs b/dev/sidebar-preference-writes.test.mjs index 232ecc2fe..030ad8d80 100644 --- a/dev/sidebar-preference-writes.test.mjs +++ b/dev/sidebar-preference-writes.test.mjs @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { afterEach, expect, it } from "vitest"; import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools"; import { relayBrokerPlugin } from "./relay-broker.mjs"; +import { 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"; @@ -16,6 +17,7 @@ async function harness() { viewer = getPublicKey(key); let handler, queryFailure, publicationFailure; let conflict = false; + let activityEvents = []; const heads = new Map(), calls = []; const server = createServer((req, res) => { @@ -50,6 +52,7 @@ async function harness() { 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] : []); }, @@ -72,6 +75,9 @@ async function harness() { return { key, viewer, + setActivity(events) { + activityEvents = events; + }, transport, calls, heads, @@ -84,8 +90,8 @@ async function harness() { conflict() { conflict = true; }, - post(value, origin) { - return fetch(`${base}/api/relay/sidebar-star`, { + post(value, origin, route = "sidebar-star") { + return fetch(`${base}/api/relay/${route}`, { method: "POST", headers: { "Content-Type": "application/json", @@ -177,3 +183,98 @@ it.each(["query", "oversized", "publication", "receipt", "conflict"])( ); }, ); + +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(); +}); diff --git a/dev/sidebar-preferences.mjs b/dev/sidebar-preferences.mjs index dfe6b9e90..e01e5dc05 100644 --- a/dev/sidebar-preferences.mjs +++ b/dev/sidebar-preferences.mjs @@ -11,7 +11,7 @@ export const SIDEBAR_UPLOAD_MS = 10_000; export function decodeSidebarPreferences(events, secret) { if ( !Array.isArray(events) || - events.length > 2 || + events.length > SIDEBAR_COORDINATES.length || Buffer.byteLength(JSON.stringify(events)) > SIDEBAR_REQUEST_BYTES ) throw new Error("Invalid sidebar records"); @@ -49,6 +49,7 @@ export function decodeSidebarPreferences(events, secret) { return projectSidebarPreferences( decoded.get("channel-sections"), decoded.get("channel-stars"), + decoded.get("channel-sort"), ); } finally { key.fill(0); 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/docs/channels.md b/docs/channels.md index 362584672..fb54e82e2 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -105,9 +105,32 @@ intermediate write. If the second write fails, the channel remains Starred and a explicit retry finishes the move; a reload reflects whatever reached the relay. This is ordered two-record persistence, not an atomic multi-device move. A prior assignment may remain stored while starred but is never used as an Unstar target. -Forums/DMs, group CRUD/reorder and independent sorting are outside this slice. +Forums/DMs assignment and 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; the open menu offers explicit retry. +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. + 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 diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 0510e1ac2..faa4af7ac 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -37,6 +37,7 @@ import { import { CaretRightIcon, DotsThreeIcon, + ArrowsDownUpIcon, PlugIcon, ChatCircleIcon, PlusIcon, @@ -88,6 +89,11 @@ import { MenuPopup, MenuRadioGroup, MenuRadioItem, + MenuRoot, + MenuSubmenu, + MenuSubmenuPopup, + MenuSubmenuTrigger, + MenuTrigger, MenuSeparator, } from "../../shared/design-system/ui/Menu"; import styles from "./Channels.module.css"; @@ -283,6 +289,9 @@ function ChannelWorkspace({ [navigate], ); const threadTrigger = useRef(null); + const [sectionMenu, setSectionMenu] = useState<{ key: string }>(); + const [sortWrite, setSortWrite] = useState<{ key: string; error?: string }>(); + const sortIntent = useRef(0); const [rowMenu, setRowMenu] = useState<{ channel: ChannelSummary; sectionId?: string; @@ -833,6 +842,29 @@ function ChannelWorkspace({ preferences.data, hiddenDms.hiddenIds, ); + const closeSectionMenu = useCallback(() => { + sortIntent.current++; + setSectionMenu(undefined); + setSortWrite(undefined); + }, []); + const setSectionSort = async (key: string, mode: "alpha" | "recent") => { + const intent = ++sortIntent.current; + setSortWrite({ key }); + try { + await preferences.setSort( + key.startsWith("group:") ? `section:${key.slice(6)}` : key, + mode, + preferences.data?.sections.map((section) => section.id) ?? [], + ); + if (sortIntent.current === intent) closeSectionMenu(); + } catch (error) { + if (sortIntent.current !== intent) return; + setSortWrite({ + key, + error: error instanceof Error ? error.message : String(error), + }); + } + }; const closeRowMenu = useCallback(() => { rowMenuGeneration.current++; setRowMenu(undefined); @@ -965,6 +997,82 @@ 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", + ) + } + disabled={ + sortWrite?.key === section.key && !sortWrite.error + } + > + + Recent + + + A–Z + + + {sortWrite?.key === section.key && + !sortWrite.error &&

Saving…

} + {sortWrite?.key === section.key && + sortWrite.error && ( +

{sortWrite.error}

+ )} +
+
+
+
+ )} {section.rows.map((channel) => { const sessions = childrenByParent.get(channel.id); diff --git a/src/bundled/channels/sidebar-sections.test.ts b/src/bundled/channels/sidebar-sections.test.ts index addc78730..7f2b17707 100644 --- a/src/bundled/channels/sidebar-sections.test.ts +++ b/src/bundled/channels/sidebar-sections.test.ts @@ -72,7 +72,51 @@ 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"]], + ["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")]; @@ -93,3 +137,43 @@ it("Star placement is exclusive and Unstar restores the saved assignment", () => ]); 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..8e48b27d9 100644 --- a/src/bundled/channels/sidebar-sections.ts +++ b/src/bundled/channels/sidebar-sections.ts @@ -25,43 +25,78 @@ 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 011463d6b..69a58a370 100644 --- a/src/bundled/channels/useSidebarPreferences.ts +++ b/src/bundled/channels/useSidebarPreferences.ts @@ -19,6 +19,8 @@ export function useSidebarPreferences( writable: queries.writable, assign: queries.assign, starWritable: queries.starWritable, + sortWritable: queries.sortWritable, + setSort: queries.setSort, setStar: queries.setStar, reload: queries.refresh, }; 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..d3c5a370b --- /dev/null +++ b/src/features/relay/channel-activity-session.test.ts @@ -0,0 +1,150 @@ +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.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.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..f30ff0c16 --- /dev/null +++ b/src/features/relay/channel-activity.test.ts @@ -0,0 +1,186 @@ +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).toHaveBeenCalledOnce(); +}); + +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(); +}); diff --git a/src/features/relay/channel-activity.ts b/src/features/relay/channel-activity.ts new file mode 100644 index 000000000..9d44b64ec --- /dev/null +++ b/src/features/relay/channel-activity.ts @@ -0,0 +1,135 @@ +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 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 { + 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; + 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; + 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); + if ( + next.size === values.size && + [...next].every(([id, value]) => values.get(id) === value) + ) + return; + values = next; + publish(); + }, + cancel() { + generation++; + active?.abort(); + active = undefined; + }, + clear() { + if (closed) return; + generation++; + active?.abort(); + active = undefined; + if (!values.size) return; + 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..a63cf0c2b 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. */ 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 d56d2f0c1..3347ca330 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -29,6 +29,7 @@ import { createUnread } from "./unread"; import type { IncomingListener, IncomingMessage } from "./incoming"; 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 +319,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 +505,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 +545,43 @@ export function createRelaySession( canAccess: (channelId) => channels.canParticipate(channelId), notify, }); + let sourceChannelList = channels.queries.list(); + let activityChannelList = sourceChannelList; + 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 = projected.every( + (channel, index) => channel === snapshot.channels[index], + ) + ? snapshot + : Object.freeze({ ...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 +598,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, @@ -819,6 +866,22 @@ export function createRelaySession( ) : 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( @@ -1040,7 +1103,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, @@ -1398,6 +1461,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 || @@ -1441,6 +1511,8 @@ export function createRelaySession( liveSnapshot.status === "connected" ) { liveGeneration++; + channelActivity.cancel(); + activityRosterKey = undefined; catchups.clear(); catchupQueue.clear(); requests.invalidate(); @@ -1491,6 +1563,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(); @@ -1520,8 +1594,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, @@ -1531,6 +1632,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. @@ -1554,6 +1657,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-preferences-store.ts b/src/features/relay/sidebar-preferences-store.ts index 3036382d2..d77fe074e 100644 --- a/src/features/relay/sidebar-preferences-store.ts +++ b/src/features/relay/sidebar-preferences-store.ts @@ -1,6 +1,8 @@ import type { SidebarAssignmentMutator, SidebarStarMutator, + SidebarSortMode, + SidebarSortMutator, SidebarPreferences, } from "./sidebar-preferences"; @@ -16,6 +18,7 @@ export function createSidebarPreferencesStore( available: boolean, write?: SidebarAssignmentMutator, writeStar?: SidebarStarMutator, + writeSort?: SidebarSortMutator, notify = (listener: () => void) => listener(), ) { const listeners = new Set<() => void>(); @@ -31,6 +34,30 @@ export function createSidebarPreferencesStore( let mutation = 0; let writing = false; let generation = 0; + let nextSortMutation = 0; + 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( @@ -38,6 +65,7 @@ export function createSidebarPreferencesStore( ), 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); @@ -62,7 +90,15 @@ export function createSidebarPreferencesStore( mutation !== refreshMutation ) return; - publish({ status: "ready", data: retained(data) }); + confirmedSort = { ...(data.sort ?? {}) }; + publish({ + status: "ready", + data: retained( + pendingSorts.size + ? { ...data, sort: withPendingSorts(confirmedSort) } + : data, + ), + }); } catch (error) { if ( !closed && @@ -183,6 +219,82 @@ export function createSidebarPreferencesStore( ); return data.starred; }, + 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 }); + 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++; + settle(); + throw error; + } + }); + writeQueue = run.then( + () => undefined, + () => undefined, + ); + return run; + }, // Keep explicit one-shot reads compatible; views use the retained snapshot. read, snapshot: () => snapshot, @@ -204,6 +316,8 @@ export function createSidebarPreferencesStore( generation++; writing = false; mutation++; + pendingSorts.clear(); + confirmedSort = {}; writeLifetime.abort(); writeLifetime = new AbortController(); active?.controller.abort(); @@ -212,6 +326,8 @@ export function createSidebarPreferencesStore( }, dispose() { closed = true; + pendingSorts.clear(); + confirmedSort = {}; writeLifetime.abort(); generation++; writing = false; diff --git a/src/features/relay/sidebar-preferences.test.ts b/src/features/relay/sidebar-preferences.test.ts index 96719f7e0..5199ea689 100644 --- a/src/features/relay/sidebar-preferences.test.ts +++ b/src/features/relay/sidebar-preferences.test.ts @@ -77,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); }); diff --git a/src/features/relay/sidebar-preferences.ts b/src/features/relay/sidebar-preferences.ts index da1ec1f9a..b00470f32 100644 --- a/src/features/relay/sidebar-preferences.ts +++ b/src/features/relay/sidebar-preferences.ts @@ -4,6 +4,7 @@ import type { RelayReader } from "./reader.ts"; export const SIDEBAR_COORDINATES = [ "channel-sections", "channel-stars", + "channel-sort", ] as const; export type SidebarGroups = Readonly<{ sections: readonly Readonly<{ @@ -14,14 +15,22 @@ export type SidebarGroups = Readonly<{ }>[]; assignments: Readonly>; }>; +export type SidebarSortMode = "alpha" | "recent"; export type SidebarPreferences = SidebarGroups & Readonly<{ starred: readonly string[]; + sort?: Readonly>; }>; export type SidebarAssignmentIntent = Readonly<{ channelId: string; sectionId?: string; }>; +export type SidebarSortMutator = ( + group: string, + mode: SidebarSortMode, + sectionIds: readonly string[], + signal: AbortSignal, +) => Promise>>; export type SidebarAssignmentMutator = ( intent: SidebarAssignmentIntent, signal: AbortSignal, @@ -48,11 +57,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: {}, @@ -113,6 +125,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..081f44dc2 --- /dev/null +++ b/src/features/relay/sidebar-sorting-store.test.ts @@ -0,0 +1,205 @@ +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", + }); + 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(); + 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" }); + } 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 fa91aa1f8..415b49917 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -13,6 +13,7 @@ import { projectSidebarPreferences, type SidebarAssignmentMutator, type SidebarStarMutator, + type SidebarSortMutator, type SidebarDecoder, type SidebarPreferences, } from "./sidebar-preferences"; @@ -62,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. */ @@ -82,6 +83,7 @@ export interface ReadTransport { /** 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; @@ -95,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, @@ -240,6 +247,8 @@ export async function connectBrokerTransport( sidebarPreferences?: boolean; sidebarPreferenceWrites?: boolean; sidebarStarWrites?: boolean; + sidebarSortWrites?: boolean; + channelActivity?: boolean; agentLibrary?: boolean; agentActivity?: boolean; readState?: boolean; @@ -495,6 +504,35 @@ export async function connectBrokerTransport( }, } : {}), + ...(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: { @@ -527,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(), signal); + }, + } + : {}), media: (url, size) => mediaUrl( url, diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 97b53afa6..4a98f4502 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -38,6 +38,7 @@ export const test = base.extend({ sessionParents: [{}, { option: true }], sidebarUnread: [false, { option: true }], savedSidebar: [false, { option: true }], + sortingSidebar: [false, { option: true }], expectedPageFailure: [false, { option: true }], largeSidebar: [false, { option: true }], dmLabels: [false, { option: true }], @@ -66,6 +67,7 @@ export const test = base.extend({ sessionParents, sidebarUnread, savedSidebar, + sortingSidebar, expectedPageFailure, largeSidebar, dmLabels, @@ -143,8 +145,9 @@ 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); @@ -166,6 +169,7 @@ export const test = base.extend({ channels: { alpha: { starred: true, updatedAt: 1 } }, }, ], + ["channel-sort", { version: 1, groups: {} }], ]) { records.set( coordinate, @@ -206,6 +210,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) { @@ -830,7 +845,11 @@ export const test = base.extend({ ([key]) => key === "d", )?.[1]; if ( - ["channel-sections", "channel-stars"].includes(coordinate) + [ + "channel-sections", + "channel-stars", + "channel-sort", + ].includes(coordinate) ) { expect(event.tags).toContainEqual(["t", coordinate]); const blob = JSON.parse( @@ -964,7 +983,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( @@ -1273,7 +1292,10 @@ export const test = base.extend({ }; // The Star retry journey injects one specific failed host request. Match // that exact URL once, not every 502 or every console error in the test. - const starFailures = [...(report.sidebarStarFailures ?? [])]; + const starFailures = [ + ...(report.sidebarStarFailures ?? []), + ...(report.sidebarSortFailures ?? []), + ]; const injectedStarFailure = (message, index) => { if ( !/^Failed to load resource: the server responded with a status of 502/.test( diff --git a/tests/browser/navigation-sorting.spec.mjs b/tests/browser/navigation-sorting.spec.mjs new file mode 100644 index 000000000..19490cd18 --- /dev/null +++ b/tests/browser/navigation-sorting.spec.mjs @@ -0,0 +1,147 @@ +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.getByRole("status")).toHaveText("Saving…"); + await expect( + menu.getByRole("menuitemradio", { name: "Recent" }), + ).toHaveAttribute("aria-checked", "true"); + await expect.poll(order).not.toEqual(alphaOrder); + } finally { + release(); + } + await expect(menu.getByRole("alert")).toHaveText( + "Relay request failed (502)", + ); + await expect.poll(order).toEqual(alphaOrder); + await page.unroute("**/sidebar-sort"); + await menu.getByRole("menuitemradio", { name: "Recent" }).click(); + await expect(menu).not.toBeVisible(); + await expect(trigger).toBeFocused(); + 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 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 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([]); +}); 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) { From 46fc1b8b17d337a2c2cd455f402286fdd28ec2cb Mon Sep 17 00:00:00 2001 From: Carl Date: Tue, 22 Sep 2026 20:05:43 -0700 Subject: [PATCH 4/5] feat(channels): refine sidebar moves and custom group icons Add nested channel destinations and create-and-move with immediate placement, ordered preference writes, rollback, and explicit retry. Keep saving quiet and retain newer move and sort choices after older failures. Bound cold sidebar reveal while retaining warm presentation. Resolve custom group icons through the shared catalog with a subtle neutral-4 loading square and no shortcode fallback. Cover persistence, cancellation, menu focus, startup, and image lifecycles. Signed-off-by: Carl --- dev/sidebar-create-section.test.mjs | 136 +++++ dev/sidebar-group-moves.test.mjs | 51 +- dev/sidebar-preference-writes.test.mjs | 42 ++ dev/sidebar-preferences.d.mts | 14 +- dev/sidebar-preferences.mjs | 52 +- docs/channels.md | 60 +- src/bundled/channels/ChannelsPage.tsx | 370 +++++++----- .../channels/CreateSidebarSection.module.css | 47 ++ src/bundled/channels/CreateSidebarSection.tsx | 73 +++ .../channels/SidebarGroupIcon.module.css | 24 + .../channels/SidebarGroupIcon.test.tsx | 166 ++++++ src/bundled/channels/SidebarGroupIcon.tsx | 79 +++ src/bundled/channels/useSidebarPreferences.ts | 4 + .../channels/useSidebarStartup.test.tsx | 225 ++++++++ src/bundled/channels/useSidebarStartup.ts | 86 +++ .../relay/channel-activity-session.test.ts | 2 + src/features/relay/channel-activity.test.ts | 22 +- src/features/relay/channel-activity.ts | 22 +- src/features/relay/contracts.ts | 2 + src/features/relay/session.ts | 20 +- .../relay/sidebar-moves-store.test.ts | 180 ++++++ .../relay/sidebar-preferences-store.test.ts | 23 +- .../relay/sidebar-preferences-store.ts | 211 +++++-- src/features/relay/sidebar-preferences.ts | 1 + .../relay/sidebar-sorting-store.test.ts | 72 +++ tests/browser/fixture.mjs | 52 +- tests/browser/navigation-group-icons.spec.mjs | 156 +++++ tests/browser/navigation-groups.spec.mjs | 531 +++++++++++++----- tests/browser/navigation-sorting.spec.mjs | 139 ++++- 29 files changed, 2451 insertions(+), 411 deletions(-) create mode 100644 dev/sidebar-create-section.test.mjs create mode 100644 src/bundled/channels/CreateSidebarSection.module.css create mode 100644 src/bundled/channels/CreateSidebarSection.tsx create mode 100644 src/bundled/channels/SidebarGroupIcon.module.css create mode 100644 src/bundled/channels/SidebarGroupIcon.test.tsx create mode 100644 src/bundled/channels/SidebarGroupIcon.tsx create mode 100644 src/bundled/channels/useSidebarStartup.test.tsx create mode 100644 src/bundled/channels/useSidebarStartup.ts create mode 100644 src/features/relay/sidebar-moves-store.test.ts create mode 100644 tests/browser/navigation-group-icons.spec.mjs 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 index 3c27fd875..e4ce5659d 100644 --- a/dev/sidebar-group-moves.test.mjs +++ b/dev/sidebar-group-moves.test.mjs @@ -144,7 +144,10 @@ it("does not clear Star when assignment publication fails", async () => { "assignment rejected", ); expect(h.star).not.toHaveBeenCalled(); - expect(h.prefs.snapshot()).toBe(before); + 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" }); @@ -161,7 +164,10 @@ it("keeps Starred after a partial failure and retry cannot resurrect the old gro await expect(h.prefs.setStar("alpha", false)).rejects.toThrow( "star rejected", ); - expect(h.prefs.snapshot()).toBe(before); + 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"]); @@ -194,8 +200,12 @@ it("a refresh during the two-write move cannot expose the intermediate assignmen 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(before); + expect(h.prefs.snapshot()).toBe(optimistic); + expect(before.data.starred).toEqual(["alpha"]); expect(h.read).toHaveBeenCalledOnce(); release(); await Promise.all([pending, refresh]); @@ -232,8 +242,10 @@ it("failed move plus an older refresh cannot strand the preference status at loa release(before); await refresh; expect(h.prefs.snapshot()).toEqual({ - status: "error", - error: "assignment failed", + status: "ready", + moves: [ + expect.objectContaining({ pending: false, error: "assignment failed" }), + ], data: before, }); await h.prefs.refresh(); @@ -267,3 +279,32 @@ it("cancellation between records prevents clearing Star and retry finishes from 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 index 030ad8d80..0d04baed3 100644 --- a/dev/sidebar-preference-writes.test.mjs +++ b/dev/sidebar-preference-writes.test.mjs @@ -278,3 +278,45 @@ it("activity uses the purpose-bound 128-channel broker route without widening ge 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 index a17e3e0a2..05988dcad 100644 --- a/dev/sidebar-preferences.d.mts +++ b/dev/sidebar-preferences.d.mts @@ -1,4 +1,7 @@ -import type { SidebarGroups } from "../src/features/relay/sidebar-preferences"; +import type { + SidebarAssignmentIntent, + SidebarGroups, +} from "../src/features/relay/sidebar-preferences"; import type { RelayEvent } from "../src/features/relay/events"; export function decodeSidebarPreferences( @@ -7,18 +10,15 @@ export function decodeSidebarPreferences( ): import("../src/features/relay/sidebar-preferences").SidebarPreferences; export function assertSidebarAssignmentIntent( intent: unknown, -): asserts intent is { - channelId: string; - sectionId?: string; -}; +): asserts intent is SidebarAssignmentIntent; export function prepareSidebarAssignment( events: readonly RelayEvent[], - intent: { channelId: string; sectionId?: string }, + intent: SidebarAssignmentIntent, secret: Uint8Array, now?: number, ): { groups: SidebarGroups; event?: RelayEvent }; export function mutateSidebarAssignment( - intent: { channelId: string; sectionId?: string }, + intent: SidebarAssignmentIntent, secret: Uint8Array, readHead: () => Promise, publish: (event: RelayEvent) => Promise, diff --git a/dev/sidebar-preferences.mjs b/dev/sidebar-preferences.mjs index e01e5dc05..0d4db8c26 100644 --- a/dev/sidebar-preferences.mjs +++ b/dev/sidebar-preferences.mjs @@ -69,7 +69,24 @@ function validAssignmentIntent(intent) { (typeof intent.sectionId === "string" && intent.sectionId.trim().length > 0 && intent.sectionId.length <= 256)) && - Object.keys(intent).every((key) => ["channelId", "sectionId"].includes(key)) + (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) { @@ -124,24 +141,41 @@ export function prepareSidebarAssignment( 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 ( - intent.sectionId !== undefined && - !current.blob.sections.some((section) => section.id === intent.sectionId) + sectionId !== undefined && + !sections.some((section) => section.id === sectionId) ) throw new Error("Sidebar group no longer exists"); const assignments = { ...current.blob.assignments, - ...(intent.sectionId === undefined - ? {} - : { [intent.channelId]: intent.sectionId }), + ...(sectionId === undefined ? {} : { [intent.channelId]: sectionId }), }; - if (intent.sectionId === undefined) delete assignments[intent.channelId]; - const blob = { ...current.blob, assignments }; + 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 (previous === intent.sectionId) return { groups }; + if (!created && previous === sectionId) return { groups }; const key = nip44.v2.utils.getConversationKey(secret, viewer); let content; try { diff --git a/docs/channels.md b/docs/channels.md index fb54e82e2..239b0b2e2 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -81,31 +81,39 @@ unstar tombstones are retained. Invalid/unreadable/over-budget heads fail closed only a successful absent-head read can seed a coordinate. Same-host writes are serialized per relay. This is confirmed whole-record replacement, not atomic cross-device merging, a durable pending outbox, or automatic retry: simultaneous -writers on different hosts can still race. Failure retains the last fully confirmed UI -placement and offers an explicit retry; a failed confirmation may follow a -publication that reached the relay. +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. Menus remain open during saving and failed-save retry; confirmed -relocation expands the destination and restores focus by channel identity. Starred -is a built-in group pinned first, offered alongside saved groups in one "Move to…" -chooser. Placement is exclusive. "Remove from Starred" and "Remove from [group]" -return to Channels, never to a remembered group. Moving out of Starred directly -into a saved group is supported. +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. -Only the complete move updates local placement, and refreshes cannot expose an -intermediate write. If the second write fails, the channel remains Starred and an -explicit retry finishes the move; a reload reflects whatever reached the relay. -This is ordered two-record persistence, not an atomic multi-device move. A prior -assignment may remain stored while starred but is never used as an Unstar target. -Forums/DMs assignment and group CRUD/reorder remain outside these actions. +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 @@ -127,13 +135,29 @@ A–Z removes an override. The host verifies/decrypts the current head, preserve 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; the open menu offers explicit retry. +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/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index faa4af7ac..6b7d9ace1 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"; @@ -68,7 +70,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, @@ -79,6 +80,7 @@ import { CHANNEL_SIDEBAR_MIN_WIDTH, useSidebarView, } from "./useSidebarView"; +import { useSidebarStartup } from "./useSidebarStartup"; import { ContextMenuRoot, ContextMenuTrigger, @@ -223,9 +225,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, @@ -290,28 +290,19 @@ function ChannelWorkspace({ ); const threadTrigger = useRef(null); const [sectionMenu, setSectionMenu] = useState<{ key: string }>(); - const [sortWrite, setSortWrite] = useState<{ key: string; error?: string }>(); - const sortIntent = useRef(0); const [rowMenu, setRowMenu] = useState<{ channel: ChannelSummary; sectionId?: string; anchor?: HTMLElement; }>(); - const rowMenuGeneration = useRef(0); - const [groupWrite, setGroupWrite] = useState<{ - channelId: string; - pending: boolean; - error?: string; - }>(); + const 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, @@ -842,33 +833,24 @@ function ChannelWorkspace({ preferences.data, hiddenDms.hiddenIds, ); - const closeSectionMenu = useCallback(() => { - sortIntent.current++; - setSectionMenu(undefined); - setSortWrite(undefined); - }, []); - const setSectionSort = async (key: string, mode: "alpha" | "recent") => { - const intent = ++sortIntent.current; - setSortWrite({ key }); - try { - await preferences.setSort( + const sections = sidebarSections( + startup.ready ? visible : [], + preferences.data, + ); + 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) ?? [], - ); - if (sortIntent.current === intent) closeSectionMenu(); - } catch (error) { - if (sortIntent.current !== intent) return; - setSortWrite({ - key, - error: error instanceof Error ? error.message : String(error), - }); - } + ) + .catch(() => {}); + closeSectionMenu(); }; const closeRowMenu = useCallback(() => { - rowMenuGeneration.current++; setRowMenu(undefined); - setGroupWrite(undefined); }, []); useLayoutEffect(() => { if (!rowFocus) return; @@ -883,8 +865,6 @@ function ChannelWorkspace({ }, [rowFocus, sidebar.list]); const openRowMenu = useCallback( (channel: ChannelSummary, sectionId?: string, anchor?: HTMLElement) => { - rowMenuGeneration.current++; - setGroupWrite(undefined); setRowMenu({ channel, ...(sectionId ? { sectionId } : {}), @@ -893,44 +873,38 @@ function ChannelWorkspace({ }, [], ); - const assignGroup = async (channelId: string, sectionId?: string) => { - const generation = rowMenuGeneration.current; - setGroupWrite({ channelId, pending: true }); - try { - await preferences.assign(channelId, sectionId); - if (generation !== rowMenuGeneration.current) return; - const sectionKey = sectionId ? `group:${sectionId}` : "channels"; - sidebar.toggle(sectionKey, true); - setRowFocus({ channelId, sectionKey }); - closeRowMenu(); - } catch (error) { - if (generation !== rowMenuGeneration.current) return; - setGroupWrite({ - channelId, - pending: false, - error: error instanceof Error ? error.message : String(error), - }); - } + const 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 setChannelStar = async (channelId: string, starred: boolean) => { - const generation = rowMenuGeneration.current; - setGroupWrite({ channelId, pending: true }); - try { - await preferences.setStar(channelId, starred); - if (generation !== rowMenuGeneration.current) return; - const sectionKey = starred ? "starred" : "channels"; - sidebar.toggle(sectionKey, true); - setRowFocus({ channelId, sectionKey }); - closeRowMenu(); - } catch (error) { - if (generation !== rowMenuGeneration.current) return; - setGroupWrite({ - channelId, - pending: false, - error: error instanceof Error ? error.message : String(error), - }); - } + 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); + }} + /> + )}
@@ -968,7 +957,7 @@ function ChannelWorkspace({ aria-hidden="true" /> {section.icon && ( - @@ -1036,7 +1025,10 @@ function ChannelWorkspace({ Sort - + Recent @@ -1062,12 +1051,6 @@ function ChannelWorkspace({ A–Z - {sortWrite?.key === section.key && - !sortWrite.error &&

Saving…

} - {sortWrite?.key === section.key && - sortWrite.error && ( -

{sortWrite.error}

- )}
@@ -1127,6 +1110,12 @@ function ChannelWorkspace({ { + if (!open && pendingCreate.current?.id === channel.id) { + setCreatingFor(pendingCreate.current); + pendingCreate.current = undefined; + } + }} onOpenChange={(open) => { if (open) openRowMenu(channel, currentSectionId); else if (menuOpen) closeRowMenu(); @@ -1159,76 +1148,92 @@ function ChannelWorkspace({ aria-label={`Actions for ${channel.name}`} anchor={menuOpen ? rowMenu.anchor : undefined} finalFocus={() => - sidebar.list.current?.querySelector( - `[data-channel-id="${CSS.escape(channel.id)}"]`, - ) ?? false + pendingCreate.current + ? false + : (sidebar.list.current?.querySelector( + `[data-channel-id="${CSS.escape(channel.id)}"]`, + ) ?? false) } > - - Move to… - - { - if (destination === "starred") - void setChannelStar(channel.id, true); - else - void assignGroup( - channel.id, - destination.slice("group:".length), - ); - }} - disabled={ - groupWrite?.channelId === channel.id && - groupWrite.pending - } - > - - ★ - Starred - - {preferences.data?.sections.map((group) => ( - + 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, + ); + } + }} > - {group.icon && {group.icon}} - {group.name} - - ))} - - {(starred || currentSectionId) && ( - <> + + ★ + Starred + + {preferences.data?.sections.map((group) => ( + + {group.icon && ( + + + + )} + {group.name} + + ))} + { - if (starred) - void setChannelStar(channel.id, false); - else void assignGroup(channel.id); + pendingCreate.current = channel; }} > - Remove from {section.title} + +Create new… - - )} - {groupWrite?.channelId === channel.id && - groupWrite.pending &&

Saving…

} - {groupWrite?.channelId === channel.id && - groupWrite.error && ( -

{groupWrite.error}

- )} + {(starred || currentSectionId) && ( + { + if (starred) + void setChannelStar(channel.id, false); + else void assignGroup(channel.id); + }} + > + Remove from {section.title} + + )} +
+ ); @@ -1236,19 +1241,80 @@ function ChannelWorkspace({ ); })} - {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…" diff --git a/src/bundled/channels/CreateSidebarSection.module.css b/src/bundled/channels/CreateSidebarSection.module.css new file mode 100644 index 000000000..2d6c0d678 --- /dev/null +++ b/src/bundled/channels/CreateSidebarSection.module.css @@ -0,0 +1,47 @@ +.dialog { + margin: auto; + width: min(28rem, calc(100vw - 2 * var(--space-panel-inset))); + max-height: calc(100vh - 2 * var(--space-panel-inset)); + overflow: auto; + padding: var(--space-panel-inset); + border: 1px solid var(--border-primary); + border-radius: var(--radius-panel); + background: var(--bg-float); + color: var(--text-primary); +} +.dialog::backdrop { + background: var(--bg-scrim); +} +.dialog form { + display: grid; + gap: var(--space-3); +} +.description { + display: flex; + gap: var(--space-1); + min-width: 0; + white-space: nowrap; +} +.description > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.input { + width: 100%; + padding: var(--space-2) var(--space-control-inset); + border: 1px solid var(--border-control); + border-radius: var(--radius-control); + background: var(--bg-panel); + color: var(--text-primary); + font: inherit; +} +.input:focus-visible { + outline: 2px solid var(--text-primary); + outline-offset: 2px; +} +.actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); +} diff --git a/src/bundled/channels/CreateSidebarSection.tsx b/src/bundled/channels/CreateSidebarSection.tsx new file mode 100644 index 000000000..7e8b04950 --- /dev/null +++ b/src/bundled/channels/CreateSidebarSection.tsx @@ -0,0 +1,73 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { Button } from "../../shared/design-system/ui/Button"; +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 dialog = useRef(null); + const title = useId(); + const [name, setName] = useState(""); + const submitted = useRef(false); + useEffect(() => { + const element = dialog.current; + element?.showModal(); + return () => element?.close(); + }, []); + const dismiss = () => { + dialog.current?.close(); + close(); + }; + return ( +

{ + event.preventDefault(); + dismiss(); + }} + > +
{ + event.preventDefault(); + if (submitted.current || !name.trim()) return; + submitted.current = true; + dialog.current?.close(); + create({ id: crypto.randomUUID(), name: name.trim() }); + }} + > +

+ Create new section +

+

+ Move {channelName} into a new + section. +

+ 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..43494efb0 --- /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(--neutral-4); +} +.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/useSidebarPreferences.ts b/src/bundled/channels/useSidebarPreferences.ts index 69a58a370..356fba19d 100644 --- a/src/bundled/channels/useSidebarPreferences.ts +++ b/src/bundled/channels/useSidebarPreferences.ts @@ -18,9 +18,13 @@ export function useSidebarPreferences( 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 index d3c5a370b..16917f3e8 100644 --- a/src/features/relay/channel-activity-session.test.ts +++ b/src/features/relay/channel-activity-session.test.ts @@ -84,10 +84,12 @@ it("reads no roster activity for A–Z, deduplicates Recent demand, and projects 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, ); diff --git a/src/features/relay/channel-activity.test.ts b/src/features/relay/channel-activity.test.ts index f30ff0c16..3457482c1 100644 --- a/src/features/relay/channel-activity.test.ts +++ b/src/features/relay/channel-activity.test.ts @@ -52,7 +52,8 @@ it("reads authoritative activity in 128-channel batches across message and forum expect(activity.last(ids[0] ?? "")).toBe(100); expect(activity.last(ids[128] ?? "")).toBe(101); expect(activity.last(ids[256] ?? "")).toBe(102); - expect(changed).toHaveBeenCalledOnce(); + 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 () => { @@ -184,3 +185,22 @@ it("ignores unsupported kinds, ambiguous scopes and out-of-batch activity", asyn 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 index 9d44b64ec..0c4fdded1 100644 --- a/src/features/relay/channel-activity.ts +++ b/src/features/relay/channel-activity.ts @@ -22,6 +22,9 @@ export function createChannelActivity( 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 = () => { @@ -42,6 +45,7 @@ export function createChannelActivity( if (changed) publish(); }; return { + status: () => status, last: (channelId: string) => values.get(channelId), revision: () => revision, subscribe(listener: () => void) { @@ -58,6 +62,8 @@ export function createChannelActivity( 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) { @@ -80,6 +86,8 @@ export function createChannelActivity( } } catch (error) { if (closed || controller.signal.aborted || epoch !== generation) return; + status = "error"; + publish(); throw error; } finally { if (active === controller) active = undefined; @@ -101,11 +109,7 @@ export function createChannelActivity( else next.set(id, result); } for (const id of next.keys()) if (!wanted.has(id)) next.delete(id); - if ( - next.size === values.size && - [...next].every(([id, value]) => values.get(id) === value) - ) - return; + status = "ready"; values = next; publish(); }, @@ -113,13 +117,19 @@ export function createChannelActivity( generation++; active?.abort(); active = undefined; + if (status === "loading") { + status = "idle"; + publish(); + } }, clear() { if (closed) return; generation++; active?.abort(); active = undefined; - if (!values.size) return; + const emptyStatus = read ? "idle" : "unavailable"; + if (!values.size && status === emptyStatus) return; + status = emptyStatus; values = new Map(); publish(); }, diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts index a63cf0c2b..57e64595c 100644 --- a/src/features/relay/contracts.ts +++ b/src/features/relay/contracts.ts @@ -92,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/session.ts b/src/features/relay/session.ts index 3347ca330..a3d2eb110 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -27,6 +27,7 @@ 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"; @@ -546,7 +547,10 @@ export function createRelaySession( notify, }); let sourceChannelList = channels.queries.list(); - let activityChannelList = sourceChannelList; + let activityChannelList: ChannelList = Object.freeze({ + ...sourceChannelList, + activityStatus: channelActivity.status(), + }); let channelActivityRevision = channelActivity.revision(); const channelQueries = Object.freeze({ ...channels.queries, @@ -566,11 +570,15 @@ export function createRelaySession( ? channel : Object.freeze({ ...channel, lastActivityAt }); }); - activityChannelList = projected.every( - (channel, index) => channel === snapshot.channels[index], - ) - ? snapshot - : Object.freeze({ ...snapshot, channels: Object.freeze(projected) }); + 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) { 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 84fcfe633..9ee4690b2 100644 --- a/src/features/relay/sidebar-preferences-store.test.ts +++ b/src/features/relay/sidebar-preferences-store.test.ts @@ -90,7 +90,7 @@ it("applies a confirmed assignment to the retained session snapshot", async () = expect(Object.isFrozen(preferences.snapshot().data?.assignments)).toBe( true, ); - expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledTimes(2); // optimistic placement, then confirmation } finally { owner.dispose(); } @@ -110,7 +110,13 @@ it("keeps the last confirmed snapshot when an assignment fails", async () => { await expect(preferences.assign("alpha")).rejects.toThrow( "relay rejected write", ); - expect(preferences.snapshot()).toBe(retained); + expect(preferences.snapshot().data).toEqual(retained.data); + expect(preferences.snapshot().moves).toEqual([ + expect.objectContaining({ + pending: false, + error: "relay rejected write", + }), + ]); } finally { owner.dispose(); } @@ -343,7 +349,11 @@ it("serializes confirmed assignment and star writes without losing either projec const pending = preferences.setStar("alpha", true); await started.promise; const queued = preferences.assign("beta", "work"); - expect(preferences.snapshot().data).toEqual(data); + 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]); @@ -381,8 +391,11 @@ it("failed Star retains the confirmed snapshot and a retry can unstar", async () await expect(preferences.setStar("beta", false)).rejects.toThrow( "publish rejected", ); - expect(preferences.snapshot()).toBe(retained); - await preferences.setStar("beta", false); + 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(); diff --git a/src/features/relay/sidebar-preferences-store.ts b/src/features/relay/sidebar-preferences-store.ts index d77fe074e..8e64fe928 100644 --- a/src/features/relay/sidebar-preferences-store.ts +++ b/src/features/relay/sidebar-preferences-store.ts @@ -1,15 +1,32 @@ 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. */ @@ -32,9 +49,40 @@ export function createSidebarPreferencesStore( let writeQueue = Promise.resolve(); let writeLifetime = new AbortController(); let mutation = 0; - let writing = false; + 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 } @@ -68,12 +116,29 @@ export function createSidebarPreferencesStore( ...(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 (writing) return writeQueue; + if (pendingMoves.size) return writeQueue; if (active) return active.promise; const controller = new AbortController(); const refreshMutation = mutation; @@ -91,12 +156,15 @@ export function createSidebarPreferencesStore( ) return; confirmedSort = { ...(data.sort ?? {}) }; + confirmedPlacement = data; publish({ status: "ready", data: retained( - pendingSorts.size - ? { ...data, sort: withPendingSorts(confirmedSort) } - : data, + withPendingMoves( + pendingSorts.size + ? { ...data, sort: withPendingSorts(confirmedSort) } + : data, + ), ), }); } catch (error) { @@ -121,17 +189,15 @@ export function createSidebarPreferencesStore( }); return job.promise; } - // The legacy format uses two coordinates. Keep a move in one session queue, - // confirm the destination assignment before clearing Star, and expose the new - // placement only after both writes succeed. Failure is explicitly retryable; - // this is not an atomic cross-host transaction. + // 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: { starred: true } | { sectionId?: string }, + destination: MoveDestination, signal?: AbortSignal, ): Promise { - const starring = "starred" in destination; - if (closed || !snapshot.data || !writeStar || !write) + if (closed || !snapshot.data || !confirmedPlacement || !writeStar || !write) return Promise.reject( new Error("Sidebar group moves are unavailable in this host"), ); @@ -140,6 +206,25 @@ export function createSidebarPreferencesStore( 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"); @@ -148,52 +233,47 @@ export function createSidebarPreferencesStore( const run = writeQueue .catch(() => {}) .then(async () => { - check(); - mutation++; - writing = true; try { - // Always re-read/write the assignment on removal, even if the cached - // projection has no assignment (another client may have added one). + 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.sectionId - ? { sectionId: destination.sectionId } - : {}), - }, - writeSignal, - ); + : await write({ channelId, ...destination }, writeSignal); check(); const stars = await writeStar( { channelId, starred: starring }, writeSignal, ); check(); - const current = snapshot.data; - if (!current) throw new Error("Sidebar group moves are unavailable"); - const data = retained({ ...current, ...groups, starred: stars }); - publish({ status: "ready", data }); - return data; + 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) { - // A refresh fenced by this move must not leave a permanent loading - // state if the move fails too. Retain its last confirmed placement. if ( - generation === writeGeneration && !closed && - snapshot.status === "loading" + generation === writeGeneration && + !writeSignal.aborted && + latestMove.get(channelId) === intent.id ) - publish({ - ...snapshot, - status: "error", + failedMoves.set(channelId, { + ...intent, error: error instanceof Error ? error.message : String(error), }); throw error; } finally { - if (generation === writeGeneration) { + if (!closed && generation === writeGeneration) { mutation++; - writing = false; + pendingMoves.delete(intent.id); + project(); } } }); @@ -201,15 +281,33 @@ export function createSidebarPreferencesStore( () => 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( @@ -219,6 +317,10 @@ export function createSidebarPreferencesStore( ); return data.starred; }, + dismissSortError(group: string) { + failedSorts.delete(group); + publish(snapshot); + }, sortWritable: !!writeSort, setSort( group: string, @@ -238,6 +340,8 @@ export function createSidebarPreferencesStore( 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: [], @@ -284,7 +388,16 @@ export function createSidebarPreferencesStore( settle(sort); return sort; } catch (error) { - if (!closed && generation === writeGeneration) mutation++; + 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; } @@ -314,9 +427,14 @@ export function createSidebarPreferencesStore( clear() { if (closed) return; generation++; - writing = false; + confirmedPlacement = undefined; + pendingMoves.clear(); + failedMoves.clear(); + latestMove.clear(); mutation++; pendingSorts.clear(); + latestSort.clear(); + failedSorts.clear(); confirmedSort = {}; writeLifetime.abort(); writeLifetime = new AbortController(); @@ -327,10 +445,15 @@ export function createSidebarPreferencesStore( dispose() { closed = true; pendingSorts.clear(); + latestSort.clear(); + failedSorts.clear(); confirmedSort = {}; writeLifetime.abort(); generation++; - writing = false; + confirmedPlacement = undefined; + pendingMoves.clear(); + failedMoves.clear(); + latestMove.clear(); mutation++; active?.controller.abort(); active = undefined; diff --git a/src/features/relay/sidebar-preferences.ts b/src/features/relay/sidebar-preferences.ts index b00470f32..cd2ad3de1 100644 --- a/src/features/relay/sidebar-preferences.ts +++ b/src/features/relay/sidebar-preferences.ts @@ -24,6 +24,7 @@ export type SidebarPreferences = SidebarGroups & export type SidebarAssignmentIntent = Readonly<{ channelId: string; sectionId?: string; + createSection?: Readonly<{ id: string; name: string }>; }>; export type SidebarSortMutator = ( group: string, diff --git a/src/features/relay/sidebar-sorting-store.test.ts b/src/features/relay/sidebar-sorting-store.test.ts index 081f44dc2..adf1f4f7e 100644 --- a/src/features/relay/sidebar-sorting-store.test.ts +++ b/src/features/relay/sidebar-sorting-store.test.ts @@ -57,6 +57,9 @@ it("independent optimistic choices survive older success and failure in queue or 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({ @@ -169,6 +172,7 @@ it.each(["clear", "dispose"] as const)( )[3].aborted, ).toBe(true); expect(preferences.snapshot().data).toBeUndefined(); + expect(preferences.snapshot().sortErrors).toBeUndefined(); owner.dispose(); }, ); @@ -193,6 +197,74 @@ it("caller cancellation rolls back only its own intent and skips its queued publ 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(); } diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 4a98f4502..62dea6b6d 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -38,7 +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 }], @@ -67,7 +70,10 @@ export const test = base.extend({ sessionParents, sidebarUnread, savedSidebar, + sidebarIcons, + channelNames, sortingSidebar, + initialSidebarSort, expectedPageFailure, largeSidebar, dmLabels, @@ -158,7 +164,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" }, }, ], @@ -169,7 +192,7 @@ export const test = base.extend({ channels: { alpha: { starred: true, updatedAt: 1 } }, }, ], - ["channel-sort", { version: 1, groups: {} }], + ["channel-sort", { version: 1, groups: initialSidebarSort }], ]) { records.set( coordinate, @@ -519,7 +542,7 @@ 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) @@ -558,7 +581,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 [ @@ -1290,11 +1329,12 @@ export const test = base.extend({ observerFailures.splice(match, 1); return true; }; - // The Star retry journey injects one specific failed host request. Match - // that exact URL once, not every 502 or every console error in the test. + // 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 ( diff --git a/tests/browser/navigation-group-icons.spec.mjs b/tests/browser/navigation-group-icons.spec.mjs new file mode 100644 index 000000000..3379408eb --- /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"); + 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 c03197191..910d1b8aa 100644 --- a/tests/browser/navigation-groups.spec.mjs +++ b/tests/browser/navigation-groups.spec.mjs @@ -3,35 +3,85 @@ import { open } from "./timeline.mjs"; test.use({ productionBroker: true, savedSidebar: true }); -test("row menu moves and removes a channel through the confirmed saved-group writer", async ({ +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 sidebar = page.getByRole("navigation", { - name: "Subscribed channels", - }); - const work = sidebar - .locator("details") - .filter({ has: page.locator("summary", { hasText: /^Work$/ }) }); - const channels = sidebar - .locator("details") - .filter({ has: page.locator("summary", { hasText: /^Channels$/ }) }); - await expect( - work.getByRole("button", { name: "Beta", exact: true }), - ).toBeVisible(); - - await work - .getByRole("button", { name: "Beta", exact: true }) - .click({ button: "right" }); - const menu = page.getByRole("menu", { name: "Actions for Beta" }); + 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"); await page.keyboard.press("End"); await expect( - page.getByRole("menuitem", { name: "Remove from Work" }), + menu.getByRole("menuitem", { name: "Remove from Work" }), ).toBeFocused(); await page.keyboard.press("Home"); await expect( @@ -41,44 +91,78 @@ test("row menu moves and removes a channel through the confirmed saved-group wri await expect(menu.getByRole("menuitemradio", { name: "Work" })).toBeFocused(); await page.keyboard.press("ArrowDown"); await expect( - page.getByRole("menuitem", { name: "Remove from Work" }), + menu.getByRole("menuitem", { name: "Create new…" }), ).toBeFocused(); + await page.keyboard.press("ArrowDown"); await page.keyboard.press("Enter"); - await expect( - channels.getByRole("button", { name: "Beta", exact: true }), - ).toBeVisible(); - await expect( - work.getByRole("button", { name: "Beta", exact: true }), - ).toHaveCount(0); - await expect( - channels.getByRole("button", { name: "Beta", exact: true }), - ).toBeFocused(); - expect(app.report.sidebarPublications).toHaveLength(1); + 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 channels - .getByRole("button", { name: "Beta", exact: true }) - .click({ button: "right" }); - await page.getByRole("menuitemradio", { name: "Work" }).click(); - await expect( - work.getByRole("button", { name: "Beta", exact: true }), - ).toBeVisible(); - await expect( - channels.getByRole("button", { name: "Beta", exact: true }), - ).toHaveCount(0); - await expect( - work.getByRole("button", { name: "Beta", exact: true }), - ).toBeFocused(); - expect(app.report.sidebarPublications).toHaveLength(2); + 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", }); - expect(app.report.unexpected).toEqual([]); + 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); }); -// Real context-menu keyboard/focus, viewport placement and row relocation require -// a browser. Intent validation, concurrency and persistence matrices stay below. -test("group moves include Starred, remove to Channels, and recover from a failed save", async ({ +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) => { @@ -86,33 +170,41 @@ test("group moves include Starred, remove to Channels, and recover from a failed localStorage.setItem("buzz-appearance.v1", "dark"), ); await open(page, app); - const sidebar = page.getByRole("navigation", { name: "Subscribed channels" }); - const work = sidebar.locator('[data-sidebar-section="group:work"]'); - const starred = sidebar.locator('[data-sidebar-section="starred"]'); - const beta = work.getByRole("button", { name: "Beta", exact: true }); + const beta = rowIn(page, "group:work"); + const starred = rowIn(page, "starred"); await expect(beta).toBeVisible(); - await starred.locator("summary").click(); - await expect(starred).not.toHaveAttribute("open", ""); - await beta.focus(); - await page.keyboard.press("Shift+F10"); - const menu = page.getByRole("menu", { name: "Actions for Beta" }); - await expect(menu).toBeVisible(); - await menu.screenshot({ path: testInfo.outputPath("group-star-menu.png") }); + 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 expect(menu).toBeVisible(); - let release; - const held = new Promise((resolve) => { - release = resolve; - }); - let started; - const requestStarted = new Promise((resolve) => { - started = resolve; - }); + 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(); - await held; + started.resolve(); + await held.promise; app.report.sidebarStarFailures ??= []; app.report.sidebarStarFailures.push(route.request().url()); await route.fulfill({ @@ -125,92 +217,57 @@ test("group moves include Starred, remove to Channels, and recover from a failed await menu .getByRole("menuitemradio", { name: "Starred", exact: true }) .click(); - await requestStarted; - await expect(menu.getByRole("status")).toHaveText("Saving…"); - await expect( - menu.getByRole("menuitemradio", { name: "Starred", exact: true }), - ).toHaveAttribute("aria-disabled", "true"); - await expect( - page.locator( - '[data-sidebar-section="group:work"] [data-channel-id="beta"]', - ), - ).toBeVisible(); + 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 { - release(); + held.resolve(); } - await expect(menu.getByRole("alert")).toHaveText( - "Relay request failed (502)", - ); - await expect( - menu.getByRole("menuitemradio", { name: "Starred", exact: true }), - ).toBeEnabled(); + 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"); - await page.keyboard.press("Home"); - await expect( - menu.getByRole("menuitemradio", { name: "Starred", exact: true }), - ).toBeFocused(); - await page.keyboard.press("Enter"); - const relocated = starred.getByRole("button", { name: "Beta", exact: true }); - await expect(relocated).toBeVisible(); - await expect(relocated).toBeFocused(); - await sidebar.screenshot({ path: testInfo.outputPath("group-starred.png") }); - await expect(sidebar.locator('[data-channel-id="beta"]')).toHaveCount(1); - expect(app.report.sidebarPublications).toHaveLength(1); - expect(app.report.sidebarPublications[0]).toMatchObject({ - coordinate: "channel-stars", - blob: { channels: { alpha: { starred: true }, beta: { starred: true } } }, - }); - - // Work is absent while its only row is starred. Removal returns to Channels. - await relocated.click({ button: "right" }); - await expect(menu).toBeVisible(); - 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 expect( - menu.getByRole("menuitemradio", { name: "Starred", exact: true }), - ).toHaveAttribute("aria-checked", "true"); + // 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(); - const ungrouped = sidebar.locator( - '[data-sidebar-section="channels"] [data-channel-id="beta"]', - ); - await expect(ungrouped).toBeVisible(); - await expect(ungrouped).toBeFocused(); - await expect(beta).toHaveCount(0); - await expect(sidebar.locator('[data-channel-id="beta"]')).toHaveCount(1); - expect(app.report.sidebarPublications).toHaveLength(3); - expect(app.report.sidebarPublications[1]).toMatchObject({ - coordinate: "channel-sections", - blob: { assignments: {} }, - }); - expect(app.report.sidebarPublications[2]).toMatchObject({ - coordinate: "channel-stars", - blob: { channels: { alpha: { starred: true }, beta: { starred: false } } }, - }); + 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(); - // Roster rows can render before preference decode reattaches their menus. await expect( - starred.getByRole("button", { name: "Alpha", exact: true }), + page.locator('[data-sidebar-section="starred"] [data-channel-id="alpha"]'), ).toBeVisible(); - await expect(ungrouped).toBeVisible(); + await expect(rowIn(page, "channels")).toBeVisible(); await expect(beta).toHaveCount(0); - - // Direct moves out of Starred use the same chooser, without a separate Unstar. - await ungrouped.click({ button: "right" }); + await openMove(page, rowIn(page, "channels")); await menu .getByRole("menuitemradio", { name: "Starred", exact: true }) .click(); - await expect(relocated).toBeFocused(); - await relocated.click({ button: "right" }); + 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).toBeVisible(); await expect(beta).toBeFocused(); - await expect(sidebar.locator('[data-channel-id="beta"]')).toHaveCount(1); + await saved(page, app, 6); expect(app.report.sidebarPublications.at(-2)).toMatchObject({ coordinate: "channel-sections", blob: { assignments: { beta: "work" } }, @@ -219,7 +276,195 @@ test("group moves include Starred, remove to Channels, and recover from a failed coordinate: "channel-stars", blob: { channels: { beta: { starred: false } } }, }); - expect(app.report.unexpected).toEqual([]); + 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({ diff --git a/tests/browser/navigation-sorting.spec.mjs b/tests/browser/navigation-sorting.spec.mjs index 19490cd18..a229ec70c 100644 --- a/tests/browser/navigation-sorting.spec.mjs +++ b/tests/browser/navigation-sorting.spec.mjs @@ -73,22 +73,45 @@ test("section sort applies immediately, rolls back on failure, and persists retr try { await menu.getByRole("menuitemradio", { name: "Recent" }).click(); await requestStarted; - await expect(menu.getByRole("status")).toHaveText("Saving…"); + 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(); } - await expect(menu.getByRole("alert")).toHaveText( - "Relay request failed (502)", - ); + 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"); - await menu.getByRole("menuitemradio", { name: "Recent" }).click(); - await expect(menu).not.toBeVisible(); - await expect(trigger).toBeFocused(); + // 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", @@ -129,7 +152,9 @@ test("section sort applies immediately, rolls back on failure, and persists retr await expect( workMenu.getByRole("menuitemradio", { name: "A–Z" }), ).toHaveAttribute("aria-checked", "true"); - await workMenu.getByRole("menuitemradio", { name: "Recent" }).click(); + await confirmedSort(() => + workMenu.getByRole("menuitemradio", { name: "Recent" }).click(), + ); await expect(workMenu).not.toBeVisible(); expect(app.report.sidebarPublications.at(-1).blob.groups).toEqual({ channels: "recent", @@ -137,7 +162,9 @@ test("section sort applies immediately, rolls back on failure, and persists retr }); await expect.poll(order).toEqual(recentOrder); await openSort(); - await menu.getByRole("menuitemradio", { name: "A–Z" }).click(); + 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({ @@ -145,3 +172,97 @@ test("section sort applies immediately, rolls back on failure, and persists retr }); 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(); + } + }); +}); From 9164b3dbb182cb2108073d6c039e820344bfbca3 Mon Sep 17 00:00:00 2001 From: Carl Date: Wed, 23 Sep 2026 15:48:43 -0700 Subject: [PATCH 5/5] fix(channels): preserve current sidebar behavior after integration Compose grouping and sorting with main's sidebar rows, shared controls and verifier API. Keep empty Channels creation available, and synchronize browser assertions with roster reveal and submenu focus without weakening viewport or keyboard guarantees. Signed-off-by: Carl --- dev/sidebar-group-moves.test.mjs | 2 +- src/bundled/channels/ChannelSidebarItem.tsx | 26 +- src/bundled/channels/Channels.module.css | 5 + src/bundled/channels/ChannelsPage.tsx | 587 +++++++++--------- .../channels/CreateSidebarSection.module.css | 36 -- src/bundled/channels/CreateSidebarSection.tsx | 72 ++- .../channels/SidebarGroupIcon.module.css | 2 +- src/bundled/channels/sidebar-sections.test.ts | 7 +- src/bundled/channels/sidebar-sections.ts | 5 +- src/features/relay/transport.ts | 2 +- src/shared/design-system/icons/index.ts | 5 + tests/browser/fixture.mjs | 13 +- tests/browser/navigation-group-icons.spec.mjs | 2 +- tests/browser/navigation-groups.spec.mjs | 5 + tests/browser/sidebar-unread.spec.mjs | 26 +- 15 files changed, 407 insertions(+), 388 deletions(-) diff --git a/dev/sidebar-group-moves.test.mjs b/dev/sidebar-group-moves.test.mjs index e4ce5659d..8360c38d2 100644 --- a/dev/sidebar-group-moves.test.mjs +++ b/dev/sidebar-group-moves.test.mjs @@ -129,7 +129,7 @@ it("moves directly from Starred into a saved group and retains other assignments sidebarSections([{ id: "alpha", name: "Alpha" }], restored).map( ({ key }) => key, ), - ).toEqual(["group:work"]); + ).toEqual(["group:work", "channels"]); } finally { h.owner.dispose(); } 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 6b7d9ace1..a18f28b9e 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -35,6 +35,7 @@ import { useSyncExternalStore, type CSSProperties, type ReactNode, + type ReactElement, } from "react"; import { CaretRightIcon, @@ -828,15 +829,9 @@ function ChannelWorkspace({ const drawer = useChannelPanels(panels, drawerContext, () => setSettings(undefined), ); - const sections = sidebarSections( - channels, - preferences.data, - hiddenDms.hiddenIds, - ); - const sections = sidebarSections( - startup.ready ? visible : [], - preferences.data, - ); + 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. @@ -957,10 +952,7 @@ function ChannelWorkspace({ aria-hidden="true" /> {section.icon && ( - + )} {section.title} {showsCreateChannel && ( @@ -986,76 +978,85 @@ function ChannelWorkspace({ />
)} - {preferences.sortWritable && ( - { - if (open) { - closeSectionMenu(); - setSectionMenu({ key: section.key }); - } else if (sectionMenu?.key === section.key) - closeSectionMenu(); - }} - > - ( - - )} - /> - - - - - - - Sort - - { + if (open) { + closeSectionMenu(); + setSectionMenu({ key: section.key }); + } else if (sectionMenu?.key === section.key) + closeSectionMenu(); + }} + > + ( + + + )} + /> + - - void setSectionSort( - section.key, - mode as "alpha" | "recent", - ) - } - > - - Recent - - - A–Z - - - - - - - )} + + + + + + Sort + + + + void setSectionSort( + section.key, + mode as "alpha" | "recent", + ) + } + > + + Recent + + + A–Z + + + + + + + )} {section.rows.map((channel) => { const sessions = childrenByParent.get(channel.id); @@ -1064,20 +1065,20 @@ function ChannelWorkspace({ sessions?.some((child) => child.id === current?.id) ? current?.id : undefined; - 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 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(); - }} - > - { - if ( - event.key === "ContextMenu" || - (event.shiftKey && event.key === "F10") - ) { - event.preventDefault(); - openRowMenu( - channel, - currentSectionId, - event.currentTarget, - ); - } - }} - render={ -
- } - > - {channelButton} - - - pendingCreate.current - ? false - : (sidebar.list.current?.querySelector( - `[data-channel-id="${CSS.escape(channel.id)}"]`, - ) ?? false) - } - > - - Move channel - { + 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 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); - }} + + + Move channel + + - Remove from {section.title} - - )} - - - - - ); + + 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} + + )} + + + + + ); })} ); })} {!startup.ready && ( -

Loading your sidebar…

+

+ Loading your sidebar… +

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

@@ -1253,67 +1266,71 @@ function ChannelWorkspace({

No channels yet.

)} - {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} + {startup.updating && ( +

+ Updating sidebar details…

- - -
- ))} - {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} -

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

- The previous placement is shown. A partial save may already - exist on the relay. + 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" ? ( ) : 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 index 2d6c0d678..f6cb823ae 100644 --- a/src/bundled/channels/CreateSidebarSection.module.css +++ b/src/bundled/channels/CreateSidebarSection.module.css @@ -1,21 +1,3 @@ -.dialog { - margin: auto; - width: min(28rem, calc(100vw - 2 * var(--space-panel-inset))); - max-height: calc(100vh - 2 * var(--space-panel-inset)); - overflow: auto; - padding: var(--space-panel-inset); - border: 1px solid var(--border-primary); - border-radius: var(--radius-panel); - background: var(--bg-float); - color: var(--text-primary); -} -.dialog::backdrop { - background: var(--bg-scrim); -} -.dialog form { - display: grid; - gap: var(--space-3); -} .description { display: flex; gap: var(--space-1); @@ -27,21 +9,3 @@ overflow: hidden; text-overflow: ellipsis; } -.input { - width: 100%; - padding: var(--space-2) var(--space-control-inset); - border: 1px solid var(--border-control); - border-radius: var(--radius-control); - background: var(--bg-panel); - color: var(--text-primary); - font: inherit; -} -.input:focus-visible { - outline: 2px solid var(--text-primary); - outline-offset: 2px; -} -.actions { - display: flex; - justify-content: flex-end; - gap: var(--space-2); -} diff --git a/src/bundled/channels/CreateSidebarSection.tsx b/src/bundled/channels/CreateSidebarSection.tsx index 7e8b04950..6319f5a3c 100644 --- a/src/bundled/channels/CreateSidebarSection.tsx +++ b/src/bundled/channels/CreateSidebarSection.tsx @@ -1,5 +1,7 @@ -import { useEffect, useId, useRef, useState } from "react"; +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. */ @@ -12,62 +14,58 @@ export function CreateSidebarSection({ create: (section: { id: string; name: string }) => void; close: () => void; }) { - const dialog = useRef(null); - const title = useId(); + const formId = useId(); + const input = useRef(null); const [name, setName] = useState(""); const submitted = useRef(false); - useEffect(() => { - const element = dialog.current; - element?.showModal(); - return () => element?.close(); - }, []); - const dismiss = () => { - dialog.current?.close(); - close(); - }; return ( - { - event.preventDefault(); - dismiss(); + { + 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; - dialog.current?.close(); create({ id: crypto.randomUUID(), name: name.trim() }); }} > -

- Create new section -

-

- Move {channelName} into a new - section. -

- setName(event.target.value)} /> -
- - -
-
+
); } diff --git a/src/bundled/channels/SidebarGroupIcon.module.css b/src/bundled/channels/SidebarGroupIcon.module.css index 43494efb0..b1a2bd45a 100644 --- a/src/bundled/channels/SidebarGroupIcon.module.css +++ b/src/bundled/channels/SidebarGroupIcon.module.css @@ -11,7 +11,7 @@ .placeholder { position: absolute; inset: 0; - background: var(--neutral-4); + background: var(--border-standard); } .image { display: block; diff --git a/src/bundled/channels/sidebar-sections.test.ts b/src/bundled/channels/sidebar-sections.test.ts index 7f2b17707..584ba65bc 100644 --- a/src/bundled/channels/sidebar-sections.test.ts +++ b/src/bundled/channels/sidebar-sections.test.ts @@ -112,6 +112,7 @@ it("sorts every section independently with deterministic inactive and tie fallba ]), ).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( @@ -130,10 +131,14 @@ it("Star placement is exclusive and Unstar restores the saved assignment", () => section.key, section.rows.map((channel) => channel.id), ]); - expect(placements(saved.starred)).toEqual([["starred", ["alpha", "beta"]]]); + 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" }); }); diff --git a/src/bundled/channels/sidebar-sections.ts b/src/bundled/channels/sidebar-sections.ts index 8e48b27d9..c0e3407b8 100644 --- a/src/bundled/channels/sidebar-sections.ts +++ b/src/bundled/channels/sidebar-sections.ts @@ -95,7 +95,10 @@ export function sidebarSections( title: "DMs", icon: undefined, rows: sort( - active.filter((channel) => channel.channelType === "dm" && !hiddenDms.has(channel.id)), + active.filter( + (channel) => + channel.channelType === "dm" && !hiddenDms.has(channel.id), + ), "dms", ), }, diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index 415b49917..61428e5c6 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -585,7 +585,7 @@ export async function connectBrokerTransport( signal, }); if (!result.ok) throw httpReadError(result.status); - return parseEvents(await result.json(), signal); + return parseEvents(await result.json(), verify, signal); }, } : {}), 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 62dea6b6d..478574bc4 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -153,7 +153,12 @@ export const test = base.extend({ : []; const sortingIds = sortingSidebar ? ["cedar", "maple", "willow"] : []; const rosterIds = [ - ...new Set([...channels, ...dmIds, ...sortingIds, ...Object.values(sessionParents)]), + ...new Set([ + ...channels, + ...dmIds, + ...sortingIds, + ...Object.values(sessionParents), + ]), ]; if (savedSidebar) { const key = nip44.v2.utils.getConversationKey(userKey, viewer); @@ -542,7 +547,11 @@ export const test = base.extend({ .map((id) => sign(39000, [ ["d", id], - ["name", channelNames[id] ?? (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) diff --git a/tests/browser/navigation-group-icons.spec.mjs b/tests/browser/navigation-group-icons.spec.mjs index 3379408eb..83490354a 100644 --- a/tests/browser/navigation-group-icons.spec.mjs +++ b/tests/browser/navigation-group-icons.spec.mjs @@ -127,7 +127,7 @@ test("group icons resolve custom media without overlapping labels, and the creat 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"); + const name = element.querySelector("span[title]"); const range = document.createRange(); range.selectNodeContents(name); const box = element.getBoundingClientRect(); diff --git a/tests/browser/navigation-groups.spec.mjs b/tests/browser/navigation-groups.spec.mjs index 910d1b8aa..b52987ca6 100644 --- a/tests/browser/navigation-groups.spec.mjs +++ b/tests/browser/navigation-groups.spec.mjs @@ -79,6 +79,11 @@ test("row menu moves and removes a channel optimistically, retaining keyboard na 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" }), 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"); } });