From c4bc3b04901c57c7ae67a4c99e38b720c5887ba9 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Mon, 6 Jul 2026 14:36:07 -0700 Subject: [PATCH] feat(sidebar): sync channel sort preferences across clients via relay The per-group Recent/A-Z sort preference was desktop-only localStorage, so it silently diverged between devices. It now syncs as encrypted NIP-78 app data (kind 30078, d-tag "channel-sort") following the same pattern as channel sections: NIP-44 encrypted-to-self content, debounced publishes, read-before-write, and whole-blob last-write-wins. localStorage stays as the instant-paint/offline cache; when no remote blob exists yet, non-default local prefs seed-publish. The existing orphaned-section pruning still applies on write, and user-facing sort behavior is unchanged. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../sidebar/lib/channelSortSync.test.mjs | 176 ++++++++++++++ .../features/sidebar/lib/channelSortSync.ts | 225 ++++++++++++++++++ .../sidebar/lib/useChannelSortPreference.ts | 106 +++++++++ desktop/src/shared/constants/kinds.ts | 3 +- 4 files changed, 509 insertions(+), 1 deletion(-) create mode 100644 desktop/src/features/sidebar/lib/channelSortSync.test.mjs create mode 100644 desktop/src/features/sidebar/lib/channelSortSync.ts diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs new file mode 100644 index 000000000..903245a9f --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { ChannelSortSyncManager } from "./channelSortSync.ts"; + +function makeStore(groups = {}) { + return { version: 1, groups }; +} + +// ─── destroy() must cancel pending publish, not flush ───────────────────────── + +// Regression guard for the workspace-switch cross-relay publish vector: +// change a sort mode in relay A → destroy() is called (relayUrl dep change) → +// no publish should fire. The scoped localStorage write is durable; when the +// user returns to relay A the seed-publish path handles it. +test("destroy: cancels pending publish without flushing to the relay", () => { + const publishCalls = []; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + + let timerCallback = null; + const fakeTimers = []; + let nextId = 1; + if (typeof globalThis.window === "undefined") { + globalThis.window = {}; + } + const originalSetTimeout = globalThis.window.setTimeout; + const originalClearTimeout = globalThis.window.clearTimeout; + globalThis.window.setTimeout = (fn, _ms) => { + const id = nextId++; + fakeTimers.push({ id, fn }); + timerCallback = fn; + return id; + }; + globalThis.window.clearTimeout = (id) => { + const idx = fakeTimers.findIndex((t) => t.id === id); + if (idx !== -1) { + fakeTimers.splice(idx, 1); + timerCallback = null; + } + }; + + try { + const manager = new ChannelSortSyncManager("pk-test"); + const store = makeStore({ channels: "recent" }); + + manager.publishSortPrefs(store); + assert.ok(timerCallback !== null, "debounce timer should be set"); + + manager.destroy(); + + assert.ok( + timerCallback === null, + "debounce timer should be cleared on destroy", + ); + assert.equal( + publishCalls.length, + 0, + "no publish event should have been sent after destroy", + ); + } finally { + if (originalSetTimeout !== undefined) { + globalThis.window.setTimeout = originalSetTimeout; + } + if (originalClearTimeout !== undefined) { + globalThis.window.clearTimeout = originalClearTimeout; + } + mock.reset(); + } +}); + +// Regression guard for the timer-fired race: debounce fires → doPublish starts +// awaiting fetchOwnBlobBeforePublish → destroy() is called (relayUrl dep +// change) → publishEvent must never be called even though the timer already +// fired and cleared itself before destroy() ran. +test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { + let releaseFetch = null; + const publishCalls = []; + + mock.method(relayClient, "fetchEvents", () => { + return new Promise((resolve) => { + releaseFetch = () => resolve([]); + }); + }); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + + if (typeof globalThis.window === "undefined") { + globalThis.window = {}; + } + let capturedCallback = null; + let nextId = 1; + const origSetTimeout = globalThis.window.setTimeout; + const origClearTimeout = globalThis.window.clearTimeout; + globalThis.window.setTimeout = (fn, _ms) => { + capturedCallback = fn; + return nextId++; + }; + globalThis.window.clearTimeout = (_id) => { + capturedCallback = null; + }; + + try { + const manager = new ChannelSortSyncManager("pk-race"); + const store = makeStore({ dms: "recent" }); + + manager.publishSortPrefs(store); + assert.ok(capturedCallback !== null, "debounce timer should be set"); + + const timerFn = capturedCallback; + capturedCallback = null; // timer cleared itself inside the callback + timerFn(); + + manager.destroy(); + + releaseFetch(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal( + publishCalls.length, + 0, + "publishEvent must not be called after destroy() even when timer already fired", + ); + } finally { + globalThis.window.setTimeout = origSetTimeout; + globalThis.window.clearTimeout = origClearTimeout; + mock.reset(); + } +}); + +test("destroy: is safe to call with no pending publish", () => { + const manager = new ChannelSortSyncManager("pk-no-pending"); + assert.doesNotThrow(() => manager.destroy()); +}); + +test("destroy: cancelPendingPublish clears pendingStore", () => { + let timerCallback = null; + let nextId = 1; + if (typeof globalThis.window === "undefined") { + globalThis.window = {}; + } + const orig = globalThis.window.setTimeout; + const origClear = globalThis.window.clearTimeout; + globalThis.window.setTimeout = (fn, _ms) => { + timerCallback = fn; + return nextId++; + }; + globalThis.window.clearTimeout = (_id) => { + timerCallback = null; + }; + + try { + const manager = new ChannelSortSyncManager("pk-pending-null"); + const store = makeStore({ starred: "recent" }); + manager.publishSortPrefs(store); + assert.deepEqual(manager.getPendingStore(), store); + + manager.destroy(); + assert.equal( + manager.getPendingStore(), + null, + "pendingStore must be null after destroy", + ); + assert.ok(timerCallback === null, "timer must be cleared after destroy"); + } finally { + globalThis.window.setTimeout = orig; + globalThis.window.clearTimeout = origClear; + } +}); diff --git a/desktop/src/features/sidebar/lib/channelSortSync.ts b/desktop/src/features/sidebar/lib/channelSortSync.ts new file mode 100644 index 000000000..55e326d66 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSortSync.ts @@ -0,0 +1,225 @@ +import { relayClient } from "@/shared/api/relayClient"; +import { + nip44DecryptFromSelf, + nip44EncryptToSelf, + signRelayEvent, +} from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_CHANNEL_SORT } from "@/shared/constants/kinds"; +import { + parseChannelSortPayload, + type ChannelSortStore, +} from "./channelSortPreference"; + +const D_TAG = "channel-sort"; +const DEBOUNCE_MS = 2_000; + +export type RemoteSortPrefs = { + store: ChannelSortStore; + createdAt: number; + eventId: string; +}; + +async function decryptAndParse( + event: RelayEvent, +): Promise { + try { + const plaintext = await nip44DecryptFromSelf(event.content); + const store = parseChannelSortPayload(JSON.parse(plaintext)); + if (!store) return null; + return { store, createdAt: event.created_at, eventId: event.id }; + } catch { + return null; + } +} + +/** + * Syncs the per-group sidebar sort preferences across clients via encrypted + * NIP-78 app data (kind 30078, d-tag `channel-sort`), following the same + * pattern as channel sections: NIP-44 encrypted-to-self content, debounced + * writes, and whole-blob last-write-wins. The sort map is a compact, + * low-frequency preference blob, so whole-blob LWW (like sections) is + * sufficient — per-key merge (like stars/mutes) would be unnecessary + * complexity here. + */ +export class ChannelSortSyncManager { + private pubkey: string; + private debounceTimer: number | null = null; + private lastRemoteCreatedAt = 0; + private pendingStore: ChannelSortStore | null = null; + private lastPublishedStore: ChannelSortStore | null = null; + private destroyed = false; + + constructor(pubkey: string) { + this.pubkey = pubkey; + } + + async fetchRemoteSortPrefs(): Promise { + try { + const events = await relayClient.fetchEvents({ + kinds: [KIND_CHANNEL_SORT], + authors: [this.pubkey], + "#d": [D_TAG], + limit: 1, + }); + if (events.length === 0) return null; + if (events[0].pubkey !== this.pubkey) return null; + const result = await decryptAndParse(events[0]); + if (result) { + this.lastRemoteCreatedAt = Math.max( + this.lastRemoteCreatedAt, + result.createdAt, + ); + } + return result; + } catch { + return null; + } + } + + cancelPendingPublish(): void { + if (this.debounceTimer !== null) { + window.clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + } + + getPendingStore(): ChannelSortStore | null { + return this.pendingStore; + } + + publishSortPrefs(store: ChannelSortStore): void { + this.pendingStore = store; + if (this.debounceTimer !== null) { + window.clearTimeout(this.debounceTimer); + } + this.debounceTimer = window.setTimeout(() => { + this.debounceTimer = null; + void this.doPublish(store); + }, DEBOUNCE_MS); + } + + private async fetchOwnBlobBeforePublish( + store: ChannelSortStore, + ): Promise { + try { + const events = await relayClient.fetchEvents({ + kinds: [KIND_CHANNEL_SORT], + authors: [this.pubkey], + "#d": [D_TAG], + limit: 1, + }); + if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; + const remote = await decryptAndParse(events[0]); + if (!remote) return store; + // Sort prefs use whole-blob LWW: take whichever is newer + if (remote.createdAt > this.lastRemoteCreatedAt) { + this.lastRemoteCreatedAt = remote.createdAt; + return remote.store; + } + return store; + } catch { + return store; + } + } + + private isIdenticalToLastPublished(store: ChannelSortStore): boolean { + if (!this.lastPublishedStore) return false; + const lastGroups = this.lastPublishedStore.groups; + const currentGroups = store.groups; + const lastKeys = Object.keys(lastGroups); + const currentKeys = Object.keys(currentGroups); + if (lastKeys.length !== currentKeys.length) return false; + for (const key of currentKeys) { + if (lastGroups[key] !== currentGroups[key]) return false; + } + return true; + } + + private async doPublish(store: ChannelSortStore): Promise { + try { + const merged = await this.fetchOwnBlobBeforePublish(store); + // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish + // was awaited (workspace switch during in-flight fetch). If so, abort + // before touching the relay. + if (this.destroyed) return; + if (this.isIdenticalToLastPublished(merged)) { + this.pendingStore = null; + return; + } + const payload = { + version: 1, + groups: merged.groups, + }; + const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload)); + const createdAt = Math.max( + Math.floor(Date.now() / 1_000), + this.lastRemoteCreatedAt + 1, + ); + const event = await signRelayEvent({ + kind: KIND_CHANNEL_SORT, + content: ciphertext, + createdAt, + tags: [ + ["d", D_TAG], + ["t", D_TAG], // relay discoverability; not used in our filters + ], + }); + // Final guard immediately before the network call — sign/encrypt are + // synchronous-ish but cheap; the relay socket may have moved to a + // different workspace by the time we reach this point. + if (this.destroyed) return; + await relayClient.publishEvent( + event, + "Timed out publishing channel sort preferences.", + "Failed to publish channel sort preferences.", + ); + this.lastRemoteCreatedAt = Math.max( + this.lastRemoteCreatedAt, + event.created_at, + ); + this.lastPublishedStore = merged; + this.pendingStore = null; + } catch (error) { + console.warn("[channelSortSync] publish failed:", error); + } + } + + async subscribeToSortPrefs( + onUpdate: (remote: RemoteSortPrefs) => void, + ): Promise<() => Promise> { + return relayClient.subscribeLive( + { + kinds: [KIND_CHANNEL_SORT], + authors: [this.pubkey], + "#d": [D_TAG], + limit: 0, + }, + (event: RelayEvent) => { + if (event.pubkey !== this.pubkey) return; + void decryptAndParse(event).then((result) => { + if (result) { + this.lastRemoteCreatedAt = Math.max( + this.lastRemoteCreatedAt, + result.createdAt, + ); + onUpdate(result); + } + }); + }, + ); + } + + destroy(): void { + // Cancel any pending publish and mark this manager as destroyed so any + // in-flight doPublish() calls abort before reaching relayClient. The + // scoped localStorage write is already durable; when the user returns to + // this relay the existing seed-publish guard will re-publish from local + // state. Flushing here would race against workspace switching and could + // publish relay A's sort prefs to relay B via the shared relayClient + // singleton. + this.destroyed = true; + this.cancelPendingPublish(); + this.pendingStore = null; + } +} diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts index 6b23c1b3b..3e9f26041 100644 --- a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts @@ -1,5 +1,6 @@ import * as React from "react"; +import { relayClient } from "@/shared/api/relayClient"; import { DEFAULT_STORE, readChannelSortStore, @@ -11,6 +12,8 @@ import { type ChannelSortMode, type ChannelSortStore, } from "./channelSortPreference"; +import { ChannelSortSyncManager } from "./channelSortSync"; +import type { RemoteSortPrefs } from "./channelSortSync"; /** * Persistent per-group sidebar sort preferences, scoped by pubkey + relay so @@ -19,6 +22,11 @@ import { * custom section) carries its own saved Recent/A–Z mode; unset groups default * to A–Z. Mirrors changes made in other windows via the storage event. * + * Preferences sync across clients via encrypted NIP-78 app data (kind 30078, + * d-tag `channel-sort`), following the channel-sections pattern: localStorage + * stays the instant/offline cache, the relay blob is the cross-client source + * of truth, and conflicts resolve with whole-blob last-write-wins. + * * When `liveSectionIds` is provided, writes also prune `section:` entries * whose custom section no longer exists, so deleted sections don't leave * stale keys in localStorage. @@ -36,12 +44,25 @@ export function useChannelSortPreference( return readChannelSortStore(pubkey, relayUrl); }); + const managerRef = React.useRef(null); + const lastAppliedRemoteTs = React.useRef(0); + const lastAppliedEventId = React.useRef(""); + React.useEffect(() => { if (!pubkey) { setStore(DEFAULT_STORE); + lastAppliedRemoteTs.current = 0; + lastAppliedEventId.current = ""; return; } setStore(readChannelSortStore(pubkey, relayUrl)); + lastAppliedRemoteTs.current = 0; + lastAppliedEventId.current = ""; + managerRef.current = new ChannelSortSyncManager(pubkey); + return () => { + managerRef.current?.destroy(); + managerRef.current = null; + }; }, [pubkey, relayUrl]); React.useEffect(() => { @@ -57,6 +78,90 @@ export function useChannelSortPreference( }; }, [pubkey, relayUrl]); + const applyRemote = React.useCallback( + ( + remote: RemoteSortPrefs, + ): ((prev: ChannelSortStore) => ChannelSortStore) => { + return (prev) => { + if (!pubkey) 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; + managerRef.current?.cancelPendingPublish(); + if (!writeChannelSortStore(pubkey, remote.store, relayUrl)) return prev; + return remote.store; + }; + }, + [pubkey, relayUrl], + ); + + React.useEffect(() => { + if (!pubkey) return; + let cancelled = false; + void managerRef.current?.fetchRemoteSortPrefs().then((remote) => { + if (cancelled) return; + if (remote) { + setStore(applyRemote(remote)); + } else { + const local = readChannelSortStore(pubkey, relayUrl); + if (Object.keys(local.groups).length > 0) { + managerRef.current?.publishSortPrefs(local); + } + } + }); + return () => { + cancelled = true; + }; + }, [pubkey, relayUrl, applyRemote]); + + React.useEffect(() => { + if (!pubkey) return; + let unsub: (() => Promise) | null = null; + let cancelled = false; + void managerRef.current + ?.subscribeToSortPrefs((remote) => { + if (cancelled) return; + setStore(applyRemote(remote)); + }) + .then((dispose) => { + if (cancelled) { + void dispose(); + } else { + unsub = dispose; + } + }); + return () => { + cancelled = true; + if (unsub) void unsub(); + }; + }, [pubkey, applyRemote]); + + React.useEffect(() => { + if (!pubkey) return; + let cancelled = false; + const unsub = relayClient.subscribeToReconnects(() => { + void managerRef.current?.fetchRemoteSortPrefs().then((remote) => { + if (cancelled) return; + if (remote) { + setStore(applyRemote(remote)); + } + const pending = managerRef.current?.getPendingStore(); + if (pending) { + managerRef.current?.publishSortPrefs(pending); + } + }); + }); + return () => { + cancelled = true; + unsub(); + }; + }, [pubkey, applyRemote]); + const sortModeFor = React.useCallback( (group: ChannelSortGroupKey) => sortModeForGroup(store, group), [store], @@ -76,6 +181,7 @@ export function useChannelSortPreference( ? stripOrphanedSectionModes(withUpdate, liveSectionIds) : withUpdate; if (!writeChannelSortStore(pubkey, next, relayUrl)) return prev; + managerRef.current?.publishSortPrefs(next); return next; }); }, diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index c16fcb471..8b6c8dca6 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -30,11 +30,12 @@ export const KIND_HUDDLE_PARTICIPANT_JOINED = 48101; export const KIND_HUDDLE_PARTICIPANT_LEFT = 48102; export const KIND_HUDDLE_ENDED = 48103; // NIP-78 application-specific data. All use kind 30078; the relay -// differentiates them by d-tag ("read-state:", "channel-sections", "channel-mutes", "channel-stars"). +// differentiates them by d-tag ("read-state:", "channel-sections", "channel-mutes", "channel-stars", "channel-sort"). export const KIND_READ_STATE = 30078; export const KIND_CHANNEL_SECTIONS = 30078; export const KIND_CHANNEL_MUTES = 30078; export const KIND_CHANNEL_STARS = 30078; +export const KIND_CHANNEL_SORT = 30078; // NIP-33 persona/team/managed-agent projection events (d-tag keyed). Published // backend-side as secrets-stripped snapshots; the inbound sync hook subscribes // to all three to patch local records. Mirror of buzz-core's KIND_PERSONA etc.