From b00f531fee664727356f72c100a5dc0c7be5ff94 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 29 May 2026 14:22:36 -0400 Subject: [PATCH 1/3] feat(desktop): sync channel sections across devices via Nostr Channel sections were localStorage-only, meaning they didn't sync across devices. Adds a NIP-78 (kind 30078) event-based sync layer so sections created, renamed, deleted, or reordered on any device propagate to all others. localStorage is retained as a local cache for instant rendering on startup. --- .../sidebar/lib/channelSectionsSync.ts | 151 ++++++++++++++++++ .../sidebar/lib/useChannelSections.ts | 85 ++++++++++ desktop/src/shared/constants/kinds.ts | 1 + 3 files changed, 237 insertions(+) create mode 100644 desktop/src/features/sidebar/lib/channelSectionsSync.ts diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts new file mode 100644 index 000000000..f4769fcc2 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -0,0 +1,151 @@ +import { relayClient } from "@/shared/api/relayClient"; +import { + nip44DecryptFromSelf, + nip44EncryptToSelf, + signRelayEvent, +} from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_CHANNEL_SECTIONS } from "@/shared/constants/kinds"; +import type { ChannelSectionStore } from "./channelSectionsStorage"; + +const D_TAG = "channel-sections"; +const DEBOUNCE_MS = 2_000; + +export type RemoteSections = { + store: ChannelSectionStore; + createdAt: number; +}; + +let debounceTimer: number | null = null; +let lastRemoteCreatedAt = 0; + +function parsePayload(json: unknown): ChannelSectionStore | null { + if (typeof json !== "object" || json === null) return null; + const obj = json as Record; + const sections = Array.isArray(obj.sections) + ? obj.sections.filter( + (e: unknown): e is { id: string; name: string; order: number } => + typeof e === "object" && + e !== null && + typeof (e as Record).id === "string" && + typeof (e as Record).name === "string" && + typeof (e as Record).order === "number", + ) + : []; + const assignments = + typeof obj.assignments === "object" && + obj.assignments !== null && + !Array.isArray(obj.assignments) + ? Object.fromEntries( + Object.entries(obj.assignments as Record).filter( + (e): e is [string, string] => typeof e[1] === "string", + ), + ) + : {}; + return { version: 1, sections, assignments }; +} + +async function decryptAndParse( + event: RelayEvent, +): Promise { + try { + const plaintext = await nip44DecryptFromSelf(event.content); + const store = parsePayload(JSON.parse(plaintext)); + if (!store) return null; + return { store, createdAt: event.created_at }; + } catch { + return null; + } +} + +export async function fetchRemoteSections( + pubkey: string, +): Promise { + try { + const events = await relayClient.fetchEvents({ + kinds: [KIND_CHANNEL_SECTIONS], + authors: [pubkey], + "#d": [D_TAG], + limit: 1, + }); + if (events.length === 0) return null; + const result = await decryptAndParse(events[0]); + if (result) { + lastRemoteCreatedAt = Math.max(lastRemoteCreatedAt, result.createdAt); + } + return result; + } catch { + return null; + } +} + +export function publishSections(store: ChannelSectionStore): void { + if (debounceTimer !== null) { + window.clearTimeout(debounceTimer); + } + debounceTimer = window.setTimeout(() => { + debounceTimer = null; + void doPublish(store); + }, DEBOUNCE_MS); +} + +async function doPublish(store: ChannelSectionStore): Promise { + try { + const payload = { + sections: store.sections, + assignments: store.assignments, + }; + const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload)); + const createdAt = Math.max( + Math.floor(Date.now() / 1_000), + lastRemoteCreatedAt + 1, + ); + const event = await signRelayEvent({ + kind: KIND_CHANNEL_SECTIONS, + content: ciphertext, + createdAt, + tags: [ + ["d", D_TAG], + ["t", D_TAG], + ], + }); + await relayClient.publishEvent( + event, + "Timed out publishing channel sections.", + "Failed to publish channel sections.", + ); + lastRemoteCreatedAt = Math.max(lastRemoteCreatedAt, event.created_at); + } catch { + // Non-fatal: next mutation or reconnect will retry + } +} + +export async function subscribeToSections( + pubkey: string, + onUpdate: (remote: RemoteSections) => void, +): Promise<() => Promise> { + return relayClient.subscribeLive( + { + kinds: [KIND_CHANNEL_SECTIONS], + authors: [pubkey], + "#d": [D_TAG], + limit: 0, + }, + (event: RelayEvent) => { + void decryptAndParse(event).then((result) => { + if (result) { + lastRemoteCreatedAt = Math.max(lastRemoteCreatedAt, result.createdAt); + onUpdate(result); + } + }); + }, + ); +} + +export function resetSyncState(): void { + if (debounceTimer !== null) { + window.clearTimeout(debounceTimer); + debounceTimer = null; + } + lastRemoteCreatedAt = 0; +} diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index 2052299b0..a75796728 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -1,11 +1,18 @@ import * as React from "react"; +import { relayClient } from "@/shared/api/relayClient"; import { DEFAULT_STORE, readChannelSectionsStore, storageKey, writeChannelSectionsStore, } from "./channelSectionsStorage"; +import { + fetchRemoteSections, + publishSections, + resetSyncState, + subscribeToSections, +} from "./channelSectionsSync"; export type { ChannelSection } from "./channelSectionsStorage"; @@ -33,12 +40,19 @@ export function useChannelSections(pubkey: string | undefined): { return readChannelSectionsStore(pubkey); }); + const lastAppliedRemoteTs = React.useRef(0); + React.useEffect(() => { if (!pubkey) { setStore(DEFAULT_STORE); + lastAppliedRemoteTs.current = 0; return; } setStore(readChannelSectionsStore(pubkey)); + lastAppliedRemoteTs.current = 0; + return () => { + resetSyncState(); + }; }, [pubkey]); React.useEffect(() => { @@ -58,6 +72,70 @@ export function useChannelSections(pubkey: string | undefined): { }; }, [pubkey]); + React.useEffect(() => { + if (!pubkey) return; + let cancelled = false; + void fetchRemoteSections(pubkey).then((remote) => { + if (cancelled) return; + if (remote) { + setStore((prev) => { + if (remote.createdAt <= lastAppliedRemoteTs.current) return prev; + lastAppliedRemoteTs.current = remote.createdAt; + if (!writeChannelSectionsStore(pubkey, remote.store)) return prev; + return remote.store; + }); + } else { + const local = readChannelSectionsStore(pubkey); + if (local.sections.length > 0) { + publishSections(local); + } + } + }); + return () => { + cancelled = true; + }; + }, [pubkey]); + + React.useEffect(() => { + if (!pubkey) return; + let unsub: (() => Promise) | null = null; + let cancelled = false; + void subscribeToSections(pubkey, (remote) => { + if (cancelled) return; + setStore((prev) => { + if (remote.createdAt <= lastAppliedRemoteTs.current) return prev; + lastAppliedRemoteTs.current = remote.createdAt; + if (!writeChannelSectionsStore(pubkey, remote.store)) return prev; + return remote.store; + }); + }).then((dispose) => { + if (cancelled) { + void dispose(); + } else { + unsub = dispose; + } + }); + return () => { + cancelled = true; + if (unsub) void unsub(); + }; + }, [pubkey]); + + React.useEffect(() => { + if (!pubkey) return; + return relayClient.subscribeToReconnects(() => { + void fetchRemoteSections(pubkey).then((remote) => { + if (!remote) return; + setStore((prev) => { + if (remote.createdAt <= lastAppliedRemoteTs.current) return prev; + lastAppliedRemoteTs.current = remote.createdAt; + if (!writeChannelSectionsStore(pubkey, remote.store)) return prev; + return remote.store; + }); + }); + }); + }, [pubkey]); + const sections = React.useMemo( () => store.sections.slice().sort((a, b) => a.order - b.order), [store.sections], @@ -87,6 +165,7 @@ export function useChannelSections(pubkey: string | undefined): { return prev; } created = section; + publishSections(next); return next; }); return created; @@ -109,6 +188,7 @@ export function useChannelSections(pubkey: string | undefined): { if (!writeChannelSectionsStore(pubkey, next)) { return prev; } + publishSections(next); return next; }); }, @@ -135,6 +215,7 @@ export function useChannelSections(pubkey: string | undefined): { if (!writeChannelSectionsStore(pubkey, next)) { return prev; } + publishSections(next); return next; }); }, @@ -170,6 +251,7 @@ export function useChannelSections(pubkey: string | undefined): { if (!writeChannelSectionsStore(pubkey, next)) { return prev; } + publishSections(next); return next; }); }, @@ -205,6 +287,7 @@ export function useChannelSections(pubkey: string | undefined): { if (!writeChannelSectionsStore(pubkey, next)) { return prev; } + publishSections(next); return next; }); }, @@ -240,6 +323,7 @@ export function useChannelSections(pubkey: string | undefined): { if (!writeChannelSectionsStore(pubkey, next)) { return prev; } + publishSections(next); return next; }); }, @@ -258,6 +342,7 @@ export function useChannelSections(pubkey: string | undefined): { if (!writeChannelSectionsStore(pubkey, next)) { return prev; } + publishSections(next); return next; }); }, diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 73028596f..2f0b264dc 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -17,6 +17,7 @@ export const KIND_FORUM_COMMENT = 45003; export const KIND_APPROVAL_REQUEST = 46010; export const KIND_TYPING_INDICATOR = 20002; export const KIND_READ_STATE = 30078; +export const KIND_CHANNEL_SECTIONS = 30078; export const KIND_USER_STATUS = 30315; export const KIND_AGENT_OBSERVER_FRAME = 24200; export const KIND_REPO_ANNOUNCEMENT = 30617; From 06dba28172dcda0bf2dd1b509b78866e31f98802 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 29 May 2026 16:01:02 -0400 Subject: [PATCH 2/3] fix(desktop): harden channel sections sync from review findings Cancel stale debounce when applying remote updates to prevent publishing overwritten state. Track pending publishes for retry on reconnect. Add pubkey validation before decrypting relay events. Extract applyRemote reconcile helper and swapSectionOrder to eliminate duplication. Fix createSection to construct section before setStore so return value is deterministic. Add NIP-01 event ID tie-breaking. Wire resetSyncState into resetWorkspaceState. --- .../sidebar/lib/channelSectionsSync.ts | 59 +++--- .../sidebar/lib/useChannelSections.ts | 184 +++++++++--------- .../features/workspaces/useWorkspaceInit.ts | 2 + desktop/src/shared/constants/kinds.ts | 2 + 4 files changed, 119 insertions(+), 128 deletions(-) diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts index f4769fcc2..52008f77d 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -6,7 +6,10 @@ import { } from "@/shared/api/tauri"; import type { RelayEvent } from "@/shared/api/types"; import { KIND_CHANNEL_SECTIONS } from "@/shared/constants/kinds"; -import type { ChannelSectionStore } from "./channelSectionsStorage"; +import { + parseChannelSectionPayload, + type ChannelSectionStore, +} from "./channelSectionsStorage"; const D_TAG = "channel-sections"; const DEBOUNCE_MS = 2_000; @@ -14,45 +17,21 @@ const DEBOUNCE_MS = 2_000; export type RemoteSections = { store: ChannelSectionStore; createdAt: number; + eventId: string; }; let debounceTimer: number | null = null; let lastRemoteCreatedAt = 0; - -function parsePayload(json: unknown): ChannelSectionStore | null { - if (typeof json !== "object" || json === null) return null; - const obj = json as Record; - const sections = Array.isArray(obj.sections) - ? obj.sections.filter( - (e: unknown): e is { id: string; name: string; order: number } => - typeof e === "object" && - e !== null && - typeof (e as Record).id === "string" && - typeof (e as Record).name === "string" && - typeof (e as Record).order === "number", - ) - : []; - const assignments = - typeof obj.assignments === "object" && - obj.assignments !== null && - !Array.isArray(obj.assignments) - ? Object.fromEntries( - Object.entries(obj.assignments as Record).filter( - (e): e is [string, string] => typeof e[1] === "string", - ), - ) - : {}; - return { version: 1, sections, assignments }; -} +let pendingStore: ChannelSectionStore | null = null; async function decryptAndParse( event: RelayEvent, ): Promise { try { const plaintext = await nip44DecryptFromSelf(event.content); - const store = parsePayload(JSON.parse(plaintext)); + const store = parseChannelSectionPayload(JSON.parse(plaintext)); if (!store) return null; - return { store, createdAt: event.created_at }; + return { store, createdAt: event.created_at, eventId: event.id }; } catch { return null; } @@ -69,6 +48,7 @@ export async function fetchRemoteSections( limit: 1, }); if (events.length === 0) return null; + if (events[0].pubkey !== pubkey) return null; const result = await decryptAndParse(events[0]); if (result) { lastRemoteCreatedAt = Math.max(lastRemoteCreatedAt, result.createdAt); @@ -79,7 +59,19 @@ export async function fetchRemoteSections( } } +export function cancelPendingPublish(): void { + if (debounceTimer !== null) { + window.clearTimeout(debounceTimer); + debounceTimer = null; + } +} + +export function getPendingStore(): ChannelSectionStore | null { + return pendingStore; +} + export function publishSections(store: ChannelSectionStore): void { + pendingStore = store; if (debounceTimer !== null) { window.clearTimeout(debounceTimer); } @@ -106,7 +98,7 @@ async function doPublish(store: ChannelSectionStore): Promise { createdAt, tags: [ ["d", D_TAG], - ["t", D_TAG], + ["t", D_TAG], // relay discoverability; not used in our filters ], }); await relayClient.publishEvent( @@ -115,8 +107,9 @@ async function doPublish(store: ChannelSectionStore): Promise { "Failed to publish channel sections.", ); lastRemoteCreatedAt = Math.max(lastRemoteCreatedAt, event.created_at); - } catch { - // Non-fatal: next mutation or reconnect will retry + pendingStore = null; + } catch (error) { + console.warn("[channelSectionsSync] publish failed:", error); } } @@ -132,6 +125,7 @@ export async function subscribeToSections( limit: 0, }, (event: RelayEvent) => { + if (event.pubkey !== pubkey) return; void decryptAndParse(event).then((result) => { if (result) { lastRemoteCreatedAt = Math.max(lastRemoteCreatedAt, result.createdAt); @@ -148,4 +142,5 @@ export function resetSyncState(): void { debounceTimer = null; } lastRemoteCreatedAt = 0; + pendingStore = null; } diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index a75796728..ecad246c8 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -8,11 +8,14 @@ import { writeChannelSectionsStore, } from "./channelSectionsStorage"; import { + cancelPendingPublish, fetchRemoteSections, + getPendingStore, publishSections, resetSyncState, subscribeToSections, } from "./channelSectionsSync"; +import type { RemoteSections } from "./channelSectionsSync"; export type { ChannelSection } from "./channelSectionsStorage"; @@ -21,6 +24,26 @@ import type { ChannelSectionStore, } from "./channelSectionsStorage"; +function swapSectionOrder( + prev: ChannelSectionStore, + sectionId: string, + direction: "up" | "down", +): ChannelSectionStore | null { + const target = prev.sections.find((s) => s.id === sectionId); + if (!target) return null; + const sorted = prev.sections.slice().sort((a, b) => a.order - b.order); + const idx = sorted.findIndex((s) => s.id === sectionId); + const neighborIdx = direction === "up" ? idx - 1 : idx + 1; + if (neighborIdx < 0 || neighborIdx >= sorted.length) return null; + const neighbor = sorted[neighborIdx]; + const sections = prev.sections.map((s) => { + if (s.id === target.id) return { ...s, order: neighbor.order }; + if (s.id === neighbor.id) return { ...s, order: target.order }; + return s; + }); + return { ...prev, sections }; +} + export function useChannelSections(pubkey: string | undefined): { sections: ChannelSection[]; assignments: Record; @@ -41,15 +64,18 @@ export function useChannelSections(pubkey: string | undefined): { }); const lastAppliedRemoteTs = React.useRef(0); + const lastAppliedEventId = React.useRef(""); React.useEffect(() => { if (!pubkey) { setStore(DEFAULT_STORE); lastAppliedRemoteTs.current = 0; + lastAppliedEventId.current = ""; return; } setStore(readChannelSectionsStore(pubkey)); lastAppliedRemoteTs.current = 0; + lastAppliedEventId.current = ""; return () => { resetSyncState(); }; @@ -72,18 +98,34 @@ export function useChannelSections(pubkey: string | undefined): { }; }, [pubkey]); + const applyRemote = React.useCallback( + ( + remote: RemoteSections, + ): ((prev: ChannelSectionStore) => ChannelSectionStore) => { + return (prev) => { + if (remote.createdAt < lastAppliedRemoteTs.current) return prev; + if ( + remote.createdAt === lastAppliedRemoteTs.current && + remote.eventId >= lastAppliedEventId.current + ) + return prev; + lastAppliedRemoteTs.current = remote.createdAt; + lastAppliedEventId.current = remote.eventId; + cancelPendingPublish(); + if (!writeChannelSectionsStore(pubkey!, remote.store)) return prev; + return remote.store; + }; + }, + [pubkey], + ); + React.useEffect(() => { if (!pubkey) return; let cancelled = false; void fetchRemoteSections(pubkey).then((remote) => { if (cancelled) return; if (remote) { - setStore((prev) => { - if (remote.createdAt <= lastAppliedRemoteTs.current) return prev; - lastAppliedRemoteTs.current = remote.createdAt; - if (!writeChannelSectionsStore(pubkey, remote.store)) return prev; - return remote.store; - }); + setStore(applyRemote(remote)); } else { const local = readChannelSectionsStore(pubkey); if (local.sections.length > 0) { @@ -94,7 +136,7 @@ export function useChannelSections(pubkey: string | undefined): { return () => { cancelled = true; }; - }, [pubkey]); + }, [pubkey, applyRemote]); React.useEffect(() => { if (!pubkey) return; @@ -102,12 +144,7 @@ export function useChannelSections(pubkey: string | undefined): { let cancelled = false; void subscribeToSections(pubkey, (remote) => { if (cancelled) return; - setStore((prev) => { - if (remote.createdAt <= lastAppliedRemoteTs.current) return prev; - lastAppliedRemoteTs.current = remote.createdAt; - if (!writeChannelSectionsStore(pubkey, remote.store)) return prev; - return remote.store; - }); + setStore(applyRemote(remote)); }).then((dispose) => { if (cancelled) { void dispose(); @@ -119,22 +156,28 @@ export function useChannelSections(pubkey: string | undefined): { cancelled = true; if (unsub) void unsub(); }; - }, [pubkey]); + }, [pubkey, applyRemote]); React.useEffect(() => { if (!pubkey) return; - return relayClient.subscribeToReconnects(() => { + let cancelled = false; + const unsub = relayClient.subscribeToReconnects(() => { void fetchRemoteSections(pubkey).then((remote) => { - if (!remote) return; - setStore((prev) => { - if (remote.createdAt <= lastAppliedRemoteTs.current) return prev; - lastAppliedRemoteTs.current = remote.createdAt; - if (!writeChannelSectionsStore(pubkey, remote.store)) return prev; - return remote.store; - }); + if (cancelled) return; + if (remote) { + setStore(applyRemote(remote)); + } + const pending = getPendingStore(); + if (pending) { + publishSections(pending); + } }); }); - }, [pubkey]); + return () => { + cancelled = true; + unsub(); + }; + }, [pubkey, applyRemote]); const sections = React.useMemo( () => store.sections.slice().sort((a, b) => a.order - b.order), @@ -143,32 +186,27 @@ export function useChannelSections(pubkey: string | undefined): { const createSection = React.useCallback( (name: string): ChannelSection | null => { - if (!pubkey) { - return null; - } - let created: ChannelSection | null = null; - setStore((prev) => { - const maxOrder = - prev.sections.length > 0 - ? Math.max(...prev.sections.map((s) => s.order)) - : -1; - const section: ChannelSection = { - id: crypto.randomUUID(), - name, - order: maxOrder + 1, - }; + if (!pubkey) return null; + const prev = readChannelSectionsStore(pubkey); + const maxOrder = + prev.sections.length > 0 + ? Math.max(...prev.sections.map((s) => s.order)) + : -1; + const section: ChannelSection = { + id: crypto.randomUUID(), + name, + order: maxOrder + 1, + }; + setStore((current) => { const next: ChannelSectionStore = { - ...prev, - sections: [...prev.sections, section], + ...current, + sections: [...current.sections, section], }; - if (!writeChannelSectionsStore(pubkey, next)) { - return prev; - } - created = section; + if (!writeChannelSectionsStore(pubkey, next)) return current; publishSections(next); return next; }); - return created; + return section; }, [pubkey], ); @@ -224,33 +262,10 @@ export function useChannelSections(pubkey: string | undefined): { const moveSectionUp = React.useCallback( (sectionId: string) => { - if (!pubkey) { - return; - } + if (!pubkey) return; setStore((prev) => { - const target = prev.sections.find((s) => s.id === sectionId); - if (!target) { - return prev; - } - const sorted = prev.sections.slice().sort((a, b) => a.order - b.order); - const idx = sorted.findIndex((s) => s.id === sectionId); - if (idx <= 0) { - return prev; - } - const neighbor = sorted[idx - 1]; - const sections = prev.sections.map((s) => { - if (s.id === target.id) { - return { ...s, order: neighbor.order }; - } - if (s.id === neighbor.id) { - return { ...s, order: target.order }; - } - return s; - }); - const next: ChannelSectionStore = { ...prev, sections }; - if (!writeChannelSectionsStore(pubkey, next)) { - return prev; - } + const next = swapSectionOrder(prev, sectionId, "up"); + if (!next || !writeChannelSectionsStore(pubkey, next)) return prev; publishSections(next); return next; }); @@ -260,33 +275,10 @@ export function useChannelSections(pubkey: string | undefined): { const moveSectionDown = React.useCallback( (sectionId: string) => { - if (!pubkey) { - return; - } + if (!pubkey) return; setStore((prev) => { - const target = prev.sections.find((s) => s.id === sectionId); - if (!target) { - return prev; - } - const sorted = prev.sections.slice().sort((a, b) => a.order - b.order); - const idx = sorted.findIndex((s) => s.id === sectionId); - if (idx < 0 || idx >= sorted.length - 1) { - return prev; - } - const neighbor = sorted[idx + 1]; - const sections = prev.sections.map((s) => { - if (s.id === target.id) { - return { ...s, order: neighbor.order }; - } - if (s.id === neighbor.id) { - return { ...s, order: target.order }; - } - return s; - }); - const next: ChannelSectionStore = { ...prev, sections }; - if (!writeChannelSectionsStore(pubkey, next)) { - return prev; - } + const next = swapSectionOrder(prev, sectionId, "down"); + if (!next || !writeChannelSectionsStore(pubkey, next)) return prev; publishSections(next); return next; }); diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts index 8da3e949d..335ed8147 100644 --- a/desktop/src/features/workspaces/useWorkspaceInit.ts +++ b/desktop/src/features/workspaces/useWorkspaceInit.ts @@ -10,6 +10,7 @@ import { resetMediaCaches } from "@/shared/lib/mediaUrl"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; import { clearAllDrafts } from "@/features/messages/lib/useDrafts"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; +import { resetSyncState } from "@/features/sidebar/lib/channelSectionsSync"; import { initFirstWorkspace } from "./workspaceStorage"; import type { Workspace } from "./types"; @@ -26,6 +27,7 @@ function resetWorkspaceState(): void { resetMediaCaches(); clearSearchHitEventCache(); clearAllDrafts(); + resetSyncState(); } type WorkspaceInitResult = diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 2f0b264dc..bed88adad 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -16,6 +16,8 @@ export const KIND_FORUM_POST = 45001; export const KIND_FORUM_COMMENT = 45003; export const KIND_APPROVAL_REQUEST = 46010; export const KIND_TYPING_INDICATOR = 20002; +// NIP-78 application-specific data. Both use kind 30078; the relay +// differentiates them by d-tag ("read-state:" vs "channel-sections"). export const KIND_READ_STATE = 30078; export const KIND_CHANNEL_SECTIONS = 30078; export const KIND_USER_STATUS = 30315; From 2b5e69eaf7c49d361ede3f51eabe86ffee1f9ef6 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 29 May 2026 16:23:08 -0400 Subject: [PATCH 3/3] test(desktop): add unit tests for channel sections storage and helpers Extract swapSectionOrder to channelSectionsHelpers.ts for testability. 27 tests covering parseChannelSectionPayload validation, stripOrphanedAssignments identity semantics, localStorage round-trip, and swapSectionOrder boundary conditions. --- .../lib/channelSectionsHelpers.test.mjs | 79 +++++++ .../sidebar/lib/channelSectionsHelpers.ts | 21 ++ .../lib/channelSectionsStorage.test.mjs | 204 ++++++++++++++++++ .../sidebar/lib/useChannelSections.ts | 21 +- 4 files changed, 305 insertions(+), 20 deletions(-) create mode 100644 desktop/src/features/sidebar/lib/channelSectionsHelpers.test.mjs create mode 100644 desktop/src/features/sidebar/lib/channelSectionsHelpers.ts create mode 100644 desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs diff --git a/desktop/src/features/sidebar/lib/channelSectionsHelpers.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsHelpers.test.mjs new file mode 100644 index 000000000..97362adf2 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSectionsHelpers.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { swapSectionOrder } from "./channelSectionsHelpers.ts"; + +function makeStore(sections, assignments = {}) { + return { version: 1, sections, assignments }; +} + +function makeSection(id, name, order) { + return { id, name, order }; +} + +test("move up succeeds: middle section swaps order with the one above", () => { + const store = makeStore([ + makeSection("a", "A", 0), + makeSection("b", "B", 1), + makeSection("c", "C", 2), + ]); + const result = swapSectionOrder(store, "b", "up"); + assert.notEqual(result, null); + const byId = Object.fromEntries(result.sections.map((s) => [s.id, s.order])); + assert.equal(byId["b"], 0); + assert.equal(byId["a"], 1); + assert.equal(byId["c"], 2); +}); + +test("move down succeeds: middle section swaps order with the one below", () => { + const store = makeStore([ + makeSection("a", "A", 0), + makeSection("b", "B", 1), + makeSection("c", "C", 2), + ]); + const result = swapSectionOrder(store, "b", "down"); + assert.notEqual(result, null); + const byId = Object.fromEntries(result.sections.map((s) => [s.id, s.order])); + assert.equal(byId["b"], 2); + assert.equal(byId["c"], 1); + assert.equal(byId["a"], 0); +}); + +test("move up at top boundary returns null", () => { + const store = makeStore([makeSection("a", "A", 0), makeSection("b", "B", 1)]); + assert.equal(swapSectionOrder(store, "a", "up"), null); +}); + +test("move down at bottom boundary returns null", () => { + const store = makeStore([makeSection("a", "A", 0), makeSection("b", "B", 1)]); + assert.equal(swapSectionOrder(store, "b", "down"), null); +}); + +test("non-existent section returns null", () => { + const store = makeStore([makeSection("a", "A", 0)]); + assert.equal(swapSectionOrder(store, "z", "up"), null); +}); + +test("single section move up returns null", () => { + const store = makeStore([makeSection("a", "A", 0)]); + assert.equal(swapSectionOrder(store, "a", "up"), null); +}); + +test("single section move down returns null", () => { + const store = makeStore([makeSection("a", "A", 0)]); + assert.equal(swapSectionOrder(store, "a", "down"), null); +}); + +test("non-contiguous orders: swap uses actual order values not indices", () => { + const store = makeStore([ + makeSection("a", "A", 0), + makeSection("b", "B", 5), + makeSection("c", "C", 10), + ]); + const result = swapSectionOrder(store, "b", "up"); + assert.notEqual(result, null); + const byId = Object.fromEntries(result.sections.map((s) => [s.id, s.order])); + assert.equal(byId["b"], 0); + assert.equal(byId["a"], 5); + assert.equal(byId["c"], 10); +}); diff --git a/desktop/src/features/sidebar/lib/channelSectionsHelpers.ts b/desktop/src/features/sidebar/lib/channelSectionsHelpers.ts new file mode 100644 index 000000000..fe41cfee6 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSectionsHelpers.ts @@ -0,0 +1,21 @@ +import type { ChannelSectionStore } from "./channelSectionsStorage"; + +export function swapSectionOrder( + prev: ChannelSectionStore, + sectionId: string, + direction: "up" | "down", +): ChannelSectionStore | null { + const target = prev.sections.find((s) => s.id === sectionId); + if (!target) return null; + const sorted = prev.sections.slice().sort((a, b) => a.order - b.order); + const idx = sorted.findIndex((s) => s.id === sectionId); + const neighborIdx = direction === "up" ? idx - 1 : idx + 1; + if (neighborIdx < 0 || neighborIdx >= sorted.length) return null; + const neighbor = sorted[neighborIdx]; + const sections = prev.sections.map((s) => { + if (s.id === target.id) return { ...s, order: neighbor.order }; + if (s.id === neighbor.id) return { ...s, order: target.order }; + return s; + }); + return { ...prev, sections }; +} diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs new file mode 100644 index 000000000..390e255d4 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs @@ -0,0 +1,204 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_STORE, + parseChannelSectionPayload, + readChannelSectionsStore, + storageKey, + stripOrphanedAssignments, + writeChannelSectionsStore, +} from "./channelSectionsStorage.ts"; + +if (typeof globalThis.window === "undefined") { + const storage = new Map(); + globalThis.window = { + localStorage: { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + removeItem: (key) => storage.delete(key), + }, + }; +} + +function makeStore(overrides = {}) { + return { + version: 1, + sections: overrides.sections ?? [{ id: "s1", name: "Test", order: 0 }], + assignments: overrides.assignments ?? {}, + ...overrides, + }; +} + +function makeSection(overrides = {}) { + return { id: "s1", name: "Test", order: 0, ...overrides }; +} + +test("parseChannelSectionPayload: valid complete payload returns correct store", () => { + const payload = { + version: 1, + sections: [{ id: "s1", name: "Work", order: 0 }], + assignments: { chan1: "s1" }, + }; + const result = parseChannelSectionPayload(payload); + assert.deepEqual(result, { + version: 1, + sections: [{ id: "s1", name: "Work", order: 0 }], + assignments: { chan1: "s1" }, + }); +}); + +test("parseChannelSectionPayload: null input returns null", () => { + assert.equal(parseChannelSectionPayload(null), null); +}); + +test("parseChannelSectionPayload: non-object input returns null", () => { + assert.equal(parseChannelSectionPayload("string"), null); + assert.equal(parseChannelSectionPayload(42), null); + assert.equal(parseChannelSectionPayload(true), null); +}); + +test("parseChannelSectionPayload: missing sections returns empty sections array", () => { + const result = parseChannelSectionPayload({ assignments: {} }); + assert.deepEqual(result?.sections, []); +}); + +test("parseChannelSectionPayload: malformed section entries are filtered out", () => { + const payload = { + sections: [ + { id: 123, name: "Bad ID", order: 0 }, + { id: "s1", name: 456, order: 0 }, + { id: "s2", name: "Good", order: "not-a-number" }, + null, + "string-entry", + ], + assignments: {}, + }; + const result = parseChannelSectionPayload(payload); + assert.deepEqual(result?.sections, []); +}); + +test("parseChannelSectionPayload: valid sections with some invalid ones filters correctly", () => { + const payload = { + sections: [ + { id: "s1", name: "Valid", order: 0 }, + { id: 99, name: "Bad ID", order: 1 }, + { id: "s2", name: "Also Valid", order: 2 }, + ], + assignments: {}, + }; + const result = parseChannelSectionPayload(payload); + assert.deepEqual(result?.sections, [ + { id: "s1", name: "Valid", order: 0 }, + { id: "s2", name: "Also Valid", order: 2 }, + ]); +}); + +test("parseChannelSectionPayload: missing assignments returns empty assignments object", () => { + const result = parseChannelSectionPayload({ sections: [] }); + assert.deepEqual(result?.assignments, {}); +}); + +test("parseChannelSectionPayload: assignments with non-string values are filtered out", () => { + const payload = { + sections: [{ id: "s1", name: "Test", order: 0 }], + assignments: { chan1: "s1", chan2: 42, chan3: null, chan4: true }, + }; + const result = parseChannelSectionPayload(payload); + assert.deepEqual(result?.assignments, { chan1: "s1" }); +}); + +test("parseChannelSectionPayload: orphaned assignments are stripped", () => { + const payload = { + sections: [{ id: "s1", name: "Exists", order: 0 }], + assignments: { chan1: "s1", chan2: "missing-section" }, + }; + const result = parseChannelSectionPayload(payload); + assert.deepEqual(result?.assignments, { chan1: "s1" }); +}); + +test("stripOrphanedAssignments: store with no orphans returns same reference", () => { + const store = makeStore({ + sections: [makeSection({ id: "s1" })], + assignments: { chan1: "s1" }, + }); + assert.equal(stripOrphanedAssignments(store), store); +}); + +test("stripOrphanedAssignments: store with orphaned assignments returns new object without them", () => { + const store = makeStore({ + sections: [makeSection({ id: "s1" })], + assignments: { chan1: "s1", chan2: "ghost" }, + }); + const result = stripOrphanedAssignments(store); + assert.notEqual(result, store); + assert.deepEqual(result.assignments, { chan1: "s1" }); +}); + +test("stripOrphanedAssignments: store with all valid assignments returns same reference", () => { + const store = makeStore({ + sections: [ + makeSection({ id: "s1" }), + makeSection({ id: "s2", name: "B", order: 1 }), + ], + assignments: { chan1: "s1", chan2: "s2" }, + }); + assert.equal(stripOrphanedAssignments(store), store); +}); + +test("stripOrphanedAssignments: empty store returns same reference", () => { + const store = makeStore({ sections: [], assignments: {} }); + assert.equal(stripOrphanedAssignments(store), store); +}); + +test("writeChannelSectionsStore + readChannelSectionsStore: write then read returns same data", () => { + const pubkey = "pk-roundtrip"; + const store = makeStore({ + sections: [makeSection({ id: "s1", name: "Work", order: 0 })], + assignments: { chan1: "s1" }, + }); + const written = writeChannelSectionsStore(pubkey, store); + assert.equal(written, true); + const result = readChannelSectionsStore(pubkey); + assert.deepEqual(result, store); +}); + +test("readChannelSectionsStore: non-existent key returns DEFAULT_STORE", () => { + const result = readChannelSectionsStore("pk-does-not-exist-xyz"); + assert.deepEqual(result, DEFAULT_STORE); +}); + +test("readChannelSectionsStore: corrupt JSON returns DEFAULT_STORE", () => { + const pubkey = "pk-corrupt"; + window.localStorage.setItem(storageKey(pubkey), "not-valid-json{{{"); + const result = readChannelSectionsStore(pubkey); + assert.deepEqual(result, DEFAULT_STORE); +}); + +test("readChannelSectionsStore: object with wrong version returns DEFAULT_STORE", () => { + const pubkey = "pk-wrong-version"; + window.localStorage.setItem( + storageKey(pubkey), + JSON.stringify({ version: 2, sections: [], assignments: {} }), + ); + const result = readChannelSectionsStore(pubkey); + assert.deepEqual(result, DEFAULT_STORE); +}); + +test("writeChannelSectionsStore: returns false when setItem throws", () => { + const pubkey = "pk-throws"; + const original = window.localStorage.setItem; + window.localStorage.setItem = () => { + throw new Error("storage full"); + }; + try { + const result = writeChannelSectionsStore(pubkey, makeStore()); + assert.equal(result, false); + } finally { + window.localStorage.setItem = original; + } +}); + +test("storageKey: returns expected format with pubkey", () => { + assert.equal(storageKey("abc123"), "sprout-channel-sections.v1:abc123"); +}); diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index ecad246c8..090b7e285 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -16,6 +16,7 @@ import { subscribeToSections, } from "./channelSectionsSync"; import type { RemoteSections } from "./channelSectionsSync"; +import { swapSectionOrder } from "./channelSectionsHelpers"; export type { ChannelSection } from "./channelSectionsStorage"; @@ -24,26 +25,6 @@ import type { ChannelSectionStore, } from "./channelSectionsStorage"; -function swapSectionOrder( - prev: ChannelSectionStore, - sectionId: string, - direction: "up" | "down", -): ChannelSectionStore | null { - const target = prev.sections.find((s) => s.id === sectionId); - if (!target) return null; - const sorted = prev.sections.slice().sort((a, b) => a.order - b.order); - const idx = sorted.findIndex((s) => s.id === sectionId); - const neighborIdx = direction === "up" ? idx - 1 : idx + 1; - if (neighborIdx < 0 || neighborIdx >= sorted.length) return null; - const neighbor = sorted[neighborIdx]; - const sections = prev.sections.map((s) => { - if (s.id === target.id) return { ...s, order: neighbor.order }; - if (s.id === neighbor.id) return { ...s, order: target.order }; - return s; - }); - return { ...prev, sections }; -} - export function useChannelSections(pubkey: string | undefined): { sections: ChannelSection[]; assignments: Record;