diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index f765b843b3b..147ab573817 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -173,9 +173,11 @@ export function AppShell() { const identityQuery = useIdentityQuery(); const { mutedChannelIds, muteChannel, unmuteChannel } = useChannelMutes( identityQuery.data?.pubkey, + communitiesHook.activeCommunity?.relayUrl, ); const { starredChannelIds, starChannel, unstarChannel } = useChannelStars( identityQuery.data?.pubkey, + communitiesHook.activeCommunity?.relayUrl, ); usePersonaSync( identityQuery.data?.pubkey, diff --git a/desktop/src/features/profile/lib/selfProfileStorage.ts b/desktop/src/features/profile/lib/selfProfileStorage.ts index dbc4f887609..02e083ae1d4 100644 --- a/desktop/src/features/profile/lib/selfProfileStorage.ts +++ b/desktop/src/features/profile/lib/selfProfileStorage.ts @@ -11,16 +11,10 @@ * prevents one community's cached identity from bleeding into another. */ -const STORAGE_KEY_PREFIX = "buzz-self-profile.v1"; +export { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; -/** - * Normalizes a relay URL for use in storage keys. - * Trim, strip trailing slashes, lowercase — ensures equivalent URLs map to - * the same key regardless of formatting differences. - */ -export function normalizeRelayUrl(relayUrl: string): string { - return relayUrl.trim().replace(/\/+$/, "").toLowerCase(); -} +const STORAGE_KEY_PREFIX = "buzz-self-profile.v1"; /** * Dispatched on window after a successful writeSelfProfileCache so that any diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs new file mode 100644 index 00000000000..845e5a5accc --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelMutesSync.test.mjs @@ -0,0 +1,198 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { ChannelMuteSyncManager } from "./channelMutesSync.ts"; +import { + makeFakeWindow, + installFakeWindow, +} from "./sidebarSyncTestHelpers.mjs"; + +const RELAY = "wss://r.test"; +const RELAY_KEY = encodeURIComponent(RELAY); + +function makeStore(channels = {}) { + return { version: 1, channels }; +} + +// ─── destroy() must cancel pending publish, not flush ───────────────────────── + +// Regression guard for the community-switch cross-relay publish vector: +// mute a channel in relay A → destroy() called (relayUrl dep change) → +// no publish should fire. +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(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-test", RELAY); + manager.publishMutes(makeStore({ ch1: { muted: true, updatedAt: 100 } })); + manager.destroy(); + assert.equal(publishCalls.length, 0); + assert.equal(manager.getPendingMuteStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { + let releaseFetch = null; + const publishCalls = []; + mock.method( + relayClient, + "fetchEvents", + () => + new Promise((res) => { + releaseFetch = () => res([]); + }), + ); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-race", RELAY); + manager.publishMutes(makeStore({ ch1: { muted: true, updatedAt: 100 } })); + fw._fireTimer(); + manager.destroy(); + releaseFetch(); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(publishCalls.length, 0); + } finally { + restore(); + mock.reset(); + } +}); + +test("destroy: is safe to call with no pending publish", () => { + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-no-pending", RELAY); + assert.doesNotThrow(() => manager.destroy()); + } finally { + restore(); + } +}); + +// ─── Boot seed-publish guard (the revert-fix regression suite) ───────────────── + +// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) +test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-fail", RELAY); + const result = await manager.bootstrap( + makeStore({ ch1: { muted: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingMuteStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued) +test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-mutes:pk-stale:${RELAY_KEY}`, + "1700000000", + ); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-stale", RELAY); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-mutes:pk-stale:${RELAY_KEY}`, + ) ?? "0", + ) > 0, + ); + const result = await manager.bootstrap( + makeStore({ ch1: { muted: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingMuteStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null) +test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelMuteSyncManager("pk-fresh", RELAY); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-mutes:pk-fresh:${RELAY_KEY}`, + ), + null, + ); + const result = await manager.bootstrap( + makeStore({ ch1: { muted: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.ok(manager.getPendingMuteStore() !== null); + } finally { + restore(); + mock.reset(); + } +}); + +// 4. relay-A / relay-B watermark isolation +// Mutation: using pubkey-only key (no relay) makes relay A's head suppress relay B's first-sync. +test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B", async () => { + const relayA = "wss://a.relay.test"; + const relayB = "wss://b.relay.test"; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-mutes:pk-iso:${encodeURIComponent(relayA)}`, + "1700000100", + ); + const restore = installFakeWindow(fw); + try { + const managerB = new ChannelMuteSyncManager("pk-iso", relayB); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-mutes:pk-iso:${encodeURIComponent(relayB)}`, + ), + null, + "relay B watermark must be independent of relay A head", + ); + const result = await managerB.bootstrap( + makeStore({ ch1: { muted: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.ok( + managerB.getPendingMuteStore() !== null, + "first-sync seed on relay B must not be blocked by relay A watermark", + ); + } finally { + restore(); + mock.reset(); + } +}); diff --git a/desktop/src/features/sidebar/lib/channelMutesSync.ts b/desktop/src/features/sidebar/lib/channelMutesSync.ts index 0a0d2bb9f63..5e8a17e74d9 100644 --- a/desktop/src/features/sidebar/lib/channelMutesSync.ts +++ b/desktop/src/features/sidebar/lib/channelMutesSync.ts @@ -11,8 +11,15 @@ import { parseMutePayload, type ChannelMuteStore, } from "./channelMutesStorage"; +import { + advanceWatermark, + readWatermark, + runBootstrap, + type FetchResult, +} from "./sidebarSyncWatermark"; const D_TAG = "channel-mutes"; +const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; export type RemoteMutes = { @@ -34,16 +41,20 @@ async function decryptAndParse(event: RelayEvent): Promise { export class ChannelMuteSyncManager { private pubkey: string; + private relayUrl: string; private debounceTimer: number | null = null; - private lastRemoteCreatedAt = 0; + private lastRemoteCreatedAt: number; private pendingStore: ChannelMuteStore | null = null; private lastPublishedStore: ChannelMuteStore | null = null; + private destroyed = false; - constructor(pubkey: string) { + constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; + this.relayUrl = relayUrl; + this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } - async fetchRemoteMutes(): Promise { + async fetchRemoteMutes(): Promise> { try { const events = await relayClient.fetchEvents({ kinds: [KIND_CHANNEL_MUTES], @@ -51,19 +62,31 @@ export class ChannelMuteSyncManager { "#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, - ); + if (events.length === 0 || events[0].pubkey !== this.pubkey) { + return { status: "absent" }; + } + const event = events[0]; + this.recordRemoteHead(event.created_at); + const result = await decryptAndParse(event); + if (!result) { + return { status: "failed", createdAt: event.created_at }; } - return result; + return { + status: "found", + data: result, + createdAt: result.createdAt, + eventId: result.eventId, + }; } catch { - return null; + return { status: "failed" }; + } + } + + private recordRemoteHead(createdAt: number): void { + if (createdAt > this.lastRemoteCreatedAt) { + this.lastRemoteCreatedAt = createdAt; } + advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); } cancelPendingMutePublish(): void { @@ -99,12 +122,11 @@ export class ChannelMuteSyncManager { limit: 1, }); if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const remote = await decryptAndParse(events[0]); + const event = events[0]; + // Record the raw head before decrypt on the pre-publish path too. + this.recordRemoteHead(event.created_at); + const remote = await decryptAndParse(event); if (!remote) return store; - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - remote.createdAt, - ); return mergeStores(store, remote.store); } catch { return store; @@ -132,6 +154,10 @@ export class ChannelMuteSyncManager { private async doPublish(store: ChannelMuteStore): Promise { try { const merged = await this.fetchOwnBlobBeforePublish(store); + // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish + // was awaited (community switch during in-flight fetch). If so, abort + // before touching the relay. + if (this.destroyed) return; if (this.isIdenticalToLastPublished(merged)) { this.pendingStore = null; return; @@ -154,15 +180,13 @@ export class ChannelMuteSyncManager { ["t", D_TAG], // relay discoverability; not used in our filters ], }); + if (this.destroyed) return; await relayClient.publishEvent( event, "Timed out publishing channel mutes.", "Failed to publish channel mutes.", ); - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - event.created_at, - ); + this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; this.pendingStore = null; } catch (error) { @@ -182,12 +206,11 @@ export class ChannelMuteSyncManager { }, (event: RelayEvent) => { if (event.pubkey !== this.pubkey) return; + // Record the raw head before decrypt so an undecryptable live event + // still advances the watermark and blocks future seed-publish. + this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); onUpdate(result); } }); @@ -195,14 +218,30 @@ export class ChannelMuteSyncManager { ); } + /** + * Fetches the remote blob on first mount, records the remote head, and + * delegates the seed/hold/apply-remote decision to `runBootstrap`. + */ + async bootstrap(localStore: ChannelMuteStore) { + const fetchResult = await this.fetchRemoteMutes(); + return runBootstrap({ + fetchResult, + lastHead: this.lastRemoteCreatedAt, + localStore, + isLocalNonEmpty: (s) => Object.keys(s.channels).length > 0, + publishFn: (s) => this.publishMutes(s), + }); + } + destroy(): void { - if (this.debounceTimer !== null && this.pendingStore !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - void this.doPublish(this.pendingStore); - } else if (this.debounceTimer !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - } + // Cancel any pending publish and mark this manager as destroyed so any + // in-flight doPublish() calls abort before reaching relayClient. + // Pending debounce-window changes are intentionally dropped: flushing + // could publish relay A's state to relay B via the shared relayClient + // singleton. Local entries survive because the apply/publish paths merge + // per-entry via mergeStores, so no local work is permanently lost. + this.destroyed = true; + this.cancelPendingMutePublish(); + this.pendingStore = null; } } diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts index 0d6b5768b6d..3900c40c184 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts @@ -1,4 +1,4 @@ -import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; const STORAGE_KEY_PREFIX = "buzz-channel-sections.v1"; diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs index 5dad6c86735..904ac1f3f24 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs @@ -3,6 +3,11 @@ import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; import { ChannelSectionSyncManager } from "./channelSectionsSync.ts"; +import { + makeFakeWindow, + installFakeWindow, + installTauriMock, +} from "./sidebarSyncTestHelpers.mjs"; function makeStore(overrides = {}) { return { @@ -13,198 +18,265 @@ function makeStore(overrides = {}) { }; } +function makeSectionsStore(sections = []) { + return { version: 1, sections, assignments: {} }; +} + +const RELAY = "wss://r.test"; +const RELAY_KEY = encodeURIComponent(RELAY); + // ─── destroy() must cancel pending publish, not flush ───────────────────────── // Regression guard for the community-switch cross-relay publish vector: // edit sections 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. +// no publish should fire. test("destroy: cancels pending publish without flushing to the relay", () => { - const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + const publishCalls = []; mock.method(relayClient, "publishEvent", (...args) => { publishCalls.push(args); return Promise.resolve(); }); - - // Simulate the timer scheduler with a manual clock so we can advance it. - let timerCallback = null; - const originalSetTimeout = globalThis.window?.setTimeout; - const originalClearTimeout = globalThis.window?.clearTimeout; - - // Inject a fake window.setTimeout/clearTimeout if needed. - const fakeTimers = []; - let nextId = 1; - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; - } - 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; - } - }; - + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); try { - const manager = new ChannelSectionSyncManager("pk-test"); - const store = makeStore({ - sections: [{ id: "s1", name: "Work", order: 0 }], - }); - - // Queue a publish — this sets the debounce timer. - manager.publishSections(store); - assert.ok(timerCallback !== null, "debounce timer should be set"); - - // Destroy before the debounce fires — simulates community switch. - manager.destroy(); - - // Timer must be cleared and no publish should fire now. - assert.ok( - timerCallback === null, - "debounce timer should be cleared on destroy", - ); - - // Advance time by invoking the callback that was cleared — it shouldn't exist. - // If clearTimeout didn't work, try firing whatever was captured before destroy. - // (There's nothing to fire after a correct destroy.) - assert.equal( - publishCalls.length, - 0, - "no publish event should have been sent after destroy", + const manager = new ChannelSectionSyncManager("pk-test", RELAY); + manager.publishSections( + makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }] }), ); + assert.ok(fw._hasTimer(), "debounce timer should be set"); + manager.destroy(); + assert.ok(!fw._hasTimer(), "debounce timer should be cleared on destroy"); + assert.equal(publishCalls.length, 0); + assert.equal(manager.getPendingStore(), null); } finally { - // Restore timer functions. - if (originalSetTimeout !== undefined) { - globalThis.window.setTimeout = originalSetTimeout; - } - if (originalClearTimeout !== undefined) { - globalThis.window.clearTimeout = originalClearTimeout; - } + restore(); 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. +// Regression guard for the timer-fired race: debounce fires → doPublish awaits +// fetchOwnBlobBeforePublish → destroy() called → publishEvent must not fire. test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { - // fetchEvents is held until we release it — simulates the latency window. let releaseFetch = null; const publishCalls = []; - - mock.method(relayClient, "fetchEvents", () => { - return new Promise((resolve) => { - // resolve with empty so fetchOwnBlobBeforePublish returns the local store - releaseFetch = () => resolve([]); - }); - }); + mock.method( + relayClient, + "fetchEvents", + () => + new Promise((res) => { + releaseFetch = () => res([]); + }), + ); 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; - }; - + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); try { - const manager = new ChannelSectionSyncManager("pk-race"); - const store = makeStore({ - sections: [{ id: "s1", name: "Work", order: 0 }], - }); - - // Queue the publish — captures the debounce callback. - manager.publishSections(store); - assert.ok(capturedCallback !== null, "debounce timer should be set"); - - // Fire the debounce manually — this starts doPublish() and nulls - // debounceTimer inside publishSections' callback, leaving the async - // doPublish running and awaiting fetchOwnBlobBeforePublish. - const timerFn = capturedCallback; - capturedCallback = null; // timer cleared itself inside the callback - timerFn(); - - // Now destroy() — debounceTimer is already null (timer fired), so only - // the destroyed flag can stop doPublish. + const manager = new ChannelSectionSyncManager("pk-race", RELAY); + manager.publishSections( + makeStore({ sections: [{ id: "s1", name: "Work", order: 0 }] }), + ); + fw._fireTimer(); // starts doPublish, which is now awaiting fetchOwnBlobBeforePublish manager.destroy(); - - // Release the held fetchEvents — fetchOwnBlobBeforePublish resolves with - // the local store, then doPublish should check destroyed and abort. releaseFetch(); - - // Drain microtasks so doPublish fully runs through to its abort point. - await new Promise((resolve) => setTimeout(resolve, 0)); - + await new Promise((r) => setTimeout(r, 0)); assert.equal( publishCalls.length, 0, - "publishEvent must not be called after destroy() even when timer already fired", + "publishEvent must not fire after destroy", ); } finally { - globalThis.window.setTimeout = origSetTimeout; - globalThis.window.clearTimeout = origClearTimeout; + restore(); mock.reset(); } }); test("destroy: is safe to call with no pending publish", () => { - const manager = new ChannelSectionSyncManager("pk-no-pending"); - // Should not throw even with nothing queued. - assert.doesNotThrow(() => manager.destroy()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSectionSyncManager("pk-no-pending", RELAY); + assert.doesNotThrow(() => manager.destroy()); + } finally { + restore(); + } +}); + +// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── +// Wiring tests 1-3 drive the production bootstrap() path; policy tested once +// in sidebarSyncWatermark.test.mjs. + +// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) +test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSectionSyncManager("pk-fail", RELAY); + const result = await manager.bootstrap( + makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), + ); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStore(), null); + } finally { + restore(); + mock.reset(); + } }); -test("destroy: cancelPendingPublish clears pendingStore", () => { - let timerCallback = null; - let nextId = 1; - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; +// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued) +test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`, + "1700000000", + ); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSectionSyncManager("pk-stale", RELAY); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sections:pk-stale:${RELAY_KEY}`, + ) ?? "0", + ) > 0, + ); + const result = await manager.bootstrap( + makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), + ); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStore(), null); + } finally { + restore(); + mock.reset(); } - 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; - }; +}); +// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null) +test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); try { - const manager = new ChannelSectionSyncManager("pk-pending-null"); - const store = makeStore({ - sections: [{ id: "s1", name: "Test", order: 0 }], - }); - manager.publishSections(store); - assert.deepEqual(manager.getPendingStore(), store); + const manager = new ChannelSectionSyncManager("pk-fresh", RELAY); + const result = await manager.bootstrap( + makeSectionsStore([{ id: "s1", name: "Work", order: 0 }]), + ); + assert.equal(result.action, "hold"); + assert.ok(manager.getPendingStore() !== null); + } finally { + restore(); + mock.reset(); + } +}); - manager.destroy(); +// 4. LWW baseline: newer decryptable pre-publish event still wins after an +// undecryptable head was recorded. +// Mutation test: headBeforeFetch → this.lastRemoteCreatedAt makes comparison +// 200>200=false → local wins instead of remote → wrong content encrypted. +test("revert-fix: sections LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { + const REMOTE_ID = "remote-section-from-relay"; + let callCount = 0; + mock.method(relayClient, "fetchEvents", () => { + callCount++; + return Promise.resolve([ + { + pubkey: "pk-lww", + content: callCount === 1 ? "bad-cipher" : "good-cipher", + created_at: callCount === 1 ? 100 : 200, + id: `evt-${callCount}`, + }, + ]); + }); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock( + JSON.stringify({ + version: 1, + sections: [{ id: REMOTE_ID, name: "Remote", order: 0 }], + assignments: {}, + }), + ); + try { + const manager = new ChannelSectionSyncManager("pk-lww", RELAY); + await manager.fetchRemoteSections(); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sections:pk-lww:${RELAY_KEY}`, + ) ?? "0", + ) >= 100, + ); + manager.publishSections( + makeSectionsStore([{ id: "local-s", name: "Local", order: 0 }]), + ); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + const pt = tauri.capturedPlaintext(); + assert.ok(pt !== null, "nip44EncryptToSelf must have been called"); + assert.ok( + JSON.parse(pt).sections?.some((s) => s.id === REMOTE_ID), + `remote sections must win LWW merge — got: ${pt}`, + ); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// 5. live-sub: undecryptable event on live path records head before decrypt +// Mutation test: removing recordRemoteHead before decrypt in the live callback +// leaves watermark at 0 after a live event. +test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => { + let liveCallback = null; + mock.method(relayClient, "subscribeLive", (_filter, onEvent) => { + liveCallback = onEvent; + return Promise.resolve(async () => {}); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSectionSyncManager("pk-live", RELAY); assert.equal( - manager.getPendingStore(), + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sections:pk-live:${RELAY_KEY}`, + ), null, - "pendingStore must be null after destroy", + "watermark starts absent", + ); + await manager.subscribeToSections(() => {}); + assert.ok( + liveCallback !== null, + "subscribeLive must have captured the callback", + ); + liveCallback({ + pubkey: "pk-live", + content: "!bad-cipher!", + created_at: 1700005555, + id: "live-evt-1", + }); + await new Promise((r) => setTimeout(r, 0)); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sections:pk-live:${RELAY_KEY}`, + ) ?? "0", + ) >= 1700005555, + "live undecryptable event must advance the watermark before decrypt is attempted", ); - assert.ok(timerCallback === null, "timer must be cleared after destroy"); } finally { - globalThis.window.setTimeout = orig; - globalThis.window.clearTimeout = origClear; + restore(); + mock.reset(); } }); diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts index 70930c26f6c..858b62430f3 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -11,8 +11,15 @@ import { type ChannelSection, type ChannelSectionStore, } from "./channelSectionsStorage"; +import { + advanceWatermark, + readWatermark, + runBootstrap, + type FetchResult, +} from "./sidebarSyncWatermark"; const D_TAG = "channel-sections"; +const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; export type RemoteSections = { @@ -36,17 +43,22 @@ async function decryptAndParse( export class ChannelSectionSyncManager { private pubkey: string; + private relayUrl: string; private debounceTimer: number | null = null; - private lastRemoteCreatedAt = 0; + private lastRemoteCreatedAt: number; private pendingStore: ChannelSectionStore | null = null; private lastPublishedStore: ChannelSectionStore | null = null; private destroyed = false; - constructor(pubkey: string) { + constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; + this.relayUrl = relayUrl; + // Hydrate from localStorage so we never seed-publish if a remote blob has + // been seen in a prior session. + this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } - async fetchRemoteSections(): Promise { + async fetchRemoteSections(): Promise> { try { const events = await relayClient.fetchEvents({ kinds: [KIND_CHANNEL_SECTIONS], @@ -54,21 +66,37 @@ export class ChannelSectionSyncManager { "#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, - ); + if (events.length === 0 || events[0].pubkey !== this.pubkey) { + return { status: "absent" }; + } + const event = events[0]; + // An event exists — record its created_at regardless of whether we can + // decrypt it, so seed-publish is blocked even when the payload is + // unreadable (e.g. wrong key). + this.recordRemoteHead(event.created_at); + const result = await decryptAndParse(event); + if (!result) { + return { status: "failed", createdAt: event.created_at }; } - return result; + return { + status: "found", + data: result, + createdAt: result.createdAt, + eventId: result.eventId, + }; } catch { - return null; + return { status: "failed" }; } } + /** Update in-memory + persisted watermark. */ + private recordRemoteHead(createdAt: number): void { + if (createdAt > this.lastRemoteCreatedAt) { + this.lastRemoteCreatedAt = createdAt; + } + advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); + } + cancelPendingPublish(): void { if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); @@ -102,11 +130,17 @@ export class ChannelSectionSyncManager { limit: 1, }); if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const remote = await decryptAndParse(events[0]); + const event = events[0]; + // Snapshot the watermark before advancing it: after recordRemoteHead + // runs, lastRemoteCreatedAt equals event.created_at, so the LWW + // comparison remote.createdAt > lastRemoteCreatedAt would always be + // false and silently suppress the merge. + const headBeforeFetch = this.lastRemoteCreatedAt; + this.recordRemoteHead(event.created_at); + const remote = await decryptAndParse(event); if (!remote) return store; // Sections use whole-blob LWW: take whichever is newer - if (remote.createdAt > this.lastRemoteCreatedAt) { - this.lastRemoteCreatedAt = remote.createdAt; + if (remote.createdAt > headBeforeFetch) { return remote.store; } return store; @@ -181,10 +215,7 @@ export class ChannelSectionSyncManager { "Timed out publishing channel sections.", "Failed to publish channel sections.", ); - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - event.created_at, - ); + this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; this.pendingStore = null; } catch (error) { @@ -204,12 +235,11 @@ export class ChannelSectionSyncManager { }, (event: RelayEvent) => { if (event.pubkey !== this.pubkey) return; + // Record the raw head before decrypt so an undecryptable live event + // still advances the watermark and blocks future seed-publish. + this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); onUpdate(result); } }); @@ -217,14 +247,28 @@ export class ChannelSectionSyncManager { ); } + /** + * Fetches the remote blob on first mount, records the remote head, and + * delegates the seed/hold/apply-remote decision to `runBootstrap`. + */ + async bootstrap(localStore: ChannelSectionStore) { + const fetchResult = await this.fetchRemoteSections(); + return runBootstrap({ + fetchResult, + lastHead: this.lastRemoteCreatedAt, + localStore, + isLocalNonEmpty: (s) => s.sections.length > 0, + publishFn: (s) => this.publishSections(s), + }); + } + 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 community switching and could - // publish relay A's sections to relay B via the shared relayClient - // singleton. + // in-flight doPublish() calls abort before reaching relayClient. + // Pending debounce-window changes are intentionally dropped: flushing + // could publish relay A's sections to relay B via the shared relayClient + // singleton. On return, bootstrap's found path whole-blob-replaces from + // remote, so any dropped pending edit is lost. this.destroyed = true; this.cancelPendingPublish(); this.pendingStore = null; diff --git a/desktop/src/features/sidebar/lib/channelSortPreference.ts b/desktop/src/features/sidebar/lib/channelSortPreference.ts index 6bd9b48d7b4..aa67ca3fb16 100644 --- a/desktop/src/features/sidebar/lib/channelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/channelSortPreference.ts @@ -1,4 +1,4 @@ -import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; import type { Channel } from "@/shared/api/types"; const STORAGE_KEY_PREFIX = "buzz-channel-sort.v1"; diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs index 76bf57b6c5e..28159eedd34 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs @@ -3,174 +3,260 @@ import test, { mock } from "node:test"; import { relayClient } from "@/shared/api/relayClient"; import { ChannelSortSyncManager } from "./channelSortSync.ts"; +import { + makeFakeWindow, + installFakeWindow, + installTauriMock, +} from "./sidebarSyncTestHelpers.mjs"; function makeStore(groups = {}) { return { version: 1, groups }; } -// ─── destroy() must cancel pending publish, not flush ───────────────────────── +const RELAY = "wss://r.test"; +const RELAY_KEY = encodeURIComponent(RELAY); -// Regression guard for the community-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. +// ─── destroy() must cancel pending publish, not flush ───────────────────────── test("destroy: cancels pending publish without flushing to the relay", () => { - const publishCalls = []; mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + const publishCalls = []; 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; - } - }; - + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); try { - const manager = new ChannelSortSyncManager("pk-test"); - const store = makeStore({ channels: "recent" }); - - manager.publishSortPrefs(store); - assert.ok(timerCallback !== null, "debounce timer should be set"); - + const manager = new ChannelSortSyncManager("pk-test", RELAY); + manager.publishSortPrefs(makeStore({ channels: "recent" })); + assert.ok(fw._hasTimer(), "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", - ); + assert.ok(!fw._hasTimer(), "debounce timer should be cleared on destroy"); + assert.equal(publishCalls.length, 0); + assert.equal(manager.getPendingStore(), null); } finally { - if (originalSetTimeout !== undefined) { - globalThis.window.setTimeout = originalSetTimeout; - } - if (originalClearTimeout !== undefined) { - globalThis.window.clearTimeout = originalClearTimeout; - } + restore(); 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. +// Regression guard for the timer-fired race: debounce fires → doPublish awaits +// fetchOwnBlobBeforePublish → destroy() called → publishEvent must not fire. 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, + "fetchEvents", + () => + new Promise((res) => { + releaseFetch = () => res([]); + }), + ); 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; - }; - + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); 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(); - + const manager = new ChannelSortSyncManager("pk-race", RELAY); + manager.publishSortPrefs(makeStore({ dms: "recent" })); + fw._fireTimer(); // starts doPublish, which is now awaiting fetchOwnBlobBeforePublish manager.destroy(); - releaseFetch(); - - await new Promise((resolve) => setTimeout(resolve, 0)); - + await new Promise((r) => setTimeout(r, 0)); assert.equal( publishCalls.length, 0, - "publishEvent must not be called after destroy() even when timer already fired", + "publishEvent must not fire after destroy", ); } finally { - globalThis.window.setTimeout = origSetTimeout; - globalThis.window.clearTimeout = origClearTimeout; + restore(); mock.reset(); } }); test("destroy: is safe to call with no pending publish", () => { - const manager = new ChannelSortSyncManager("pk-no-pending"); - assert.doesNotThrow(() => manager.destroy()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSortSyncManager("pk-no-pending", RELAY); + assert.doesNotThrow(() => manager.destroy()); + } finally { + restore(); + } }); -test("destroy: cancelPendingPublish clears pendingStore", () => { - let timerCallback = null; - let nextId = 1; - if (typeof globalThis.window === "undefined") { - globalThis.window = {}; +// ─── Boot seed-publish guard (the revert-fix regression suite) ──────────────── +// Wiring tests 1-3 drive the production bootstrap() path; policy tested once +// in sidebarSyncWatermark.test.mjs. + +// 1. fetch failed (error/timeout) + local non-empty → hold, zero publish calls +// Mutation: removing the failed guard causes bootstrap to call publishSortPrefs → pendingStore set. +test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSortSyncManager("pk-fail", RELAY); + const result = await manager.bootstrap(makeStore({ channels: "recent" })); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStore(), null); + } finally { + restore(); + mock.reset(); } - 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; - }; +}); +// 2. absent + persisted head > 0 → hold, zero publish calls (the dev-build stale-copy case) +// Mutation: setting watermark to 0 in localStorage causes bootstrap to seed. +test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`, + "1700000000", + ); + const restore = installFakeWindow(fw); try { - const manager = new ChannelSortSyncManager("pk-pending-null"); - const store = makeStore({ starred: "recent" }); - manager.publishSortPrefs(store); - assert.deepEqual(manager.getPendingStore(), store); + const manager = new ChannelSortSyncManager("pk-stale", RELAY); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-stale:${RELAY_KEY}`, + ) ?? "0", + ) > 0, + ); + const result = await manager.bootstrap(makeStore({ channels: "recent" })); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStore(), null); + } finally { + restore(); + mock.reset(); + } +}); - manager.destroy(); +// 3. absent + head 0 + local non-empty → seed-publish queued (first-sync preserved) +// Mutation: removing the absent+head-0 seed call leaves pendingStore null. +test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSortSyncManager("pk-fresh", RELAY); assert.equal( - manager.getPendingStore(), + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-fresh:${RELAY_KEY}`, + ), null, - "pendingStore must be null after destroy", ); - assert.ok(timerCallback === null, "timer must be cleared after destroy"); + const result = await manager.bootstrap(makeStore({ channels: "recent" })); + assert.equal(result.action, "hold"); + assert.ok(manager.getPendingStore() !== null); } finally { - globalThis.window.setTimeout = orig; - globalThis.window.clearTimeout = origClear; + restore(); + mock.reset(); + } +}); + +// 4. LWW baseline: newer decryptable pre-publish event still wins after an +// undecryptable head was recorded. +// Mutation test: headBeforeFetch → this.lastRemoteCreatedAt makes comparison +// 200>200=false → local wins instead of remote → wrong content encrypted. +test("revert-fix: sort LWW — newer decryptable pre-publish event selected after undecryptable head recorded", async () => { + const REMOTE_KEY = "remote-group-from-relay"; + let callCount = 0; + mock.method(relayClient, "fetchEvents", () => { + callCount++; + return Promise.resolve([ + { + pubkey: "pk-lww", + content: callCount === 1 ? "bad-cipher" : "good-cipher", + created_at: callCount === 1 ? 100 : 200, + id: `evt-${callCount}`, + }, + ]); + }); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock( + JSON.stringify({ version: 1, groups: { [REMOTE_KEY]: "recent" } }), + ); + try { + const manager = new ChannelSortSyncManager("pk-lww", RELAY); + await manager.fetchRemoteSortPrefs(); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-lww:${RELAY_KEY}`, + ) ?? "0", + ) >= 100, + ); + manager.publishSortPrefs(makeStore({ "local-group": "recent" })); + fw._fireTimer(); + await new Promise((r) => setTimeout(r, 20)); + const pt = tauri.capturedPlaintext(); + assert.ok(pt !== null, "nip44EncryptToSelf must have been called"); + assert.ok( + JSON.parse(pt).groups && REMOTE_KEY in JSON.parse(pt).groups, + `remote groups must win LWW merge — got: ${pt}`, + ); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); + +// 5. live-sub: undecryptable event on live path records head before decrypt +// Mutation test: removing recordRemoteHead before decrypt in the live callback +// leaves watermark at 0 after a live event. +test("revert-fix: undecryptable live event advances watermark before decrypt attempt", async () => { + let liveCallback = null; + mock.method(relayClient, "subscribeLive", (_filter, onEvent) => { + liveCallback = onEvent; + return Promise.resolve(async () => {}); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelSortSyncManager("pk-live", RELAY); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-live:${RELAY_KEY}`, + ), + null, + "watermark starts absent", + ); + await manager.subscribeToSortPrefs(() => {}); + assert.ok( + liveCallback !== null, + "subscribeLive must have captured the callback", + ); + liveCallback({ + pubkey: "pk-live", + content: "!bad-cipher!", + created_at: 1700005555, + id: "live-evt-1", + }); + await new Promise((r) => setTimeout(r, 0)); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-sort:pk-live:${RELAY_KEY}`, + ) ?? "0", + ) >= 1700005555, + "live undecryptable event must advance the watermark before decrypt is attempted", + ); + } finally { + restore(); + mock.reset(); } }); diff --git a/desktop/src/features/sidebar/lib/channelSortSync.ts b/desktop/src/features/sidebar/lib/channelSortSync.ts index e23387368d1..fe71fe62dfa 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.ts +++ b/desktop/src/features/sidebar/lib/channelSortSync.ts @@ -10,8 +10,15 @@ import { parseChannelSortPayload, type ChannelSortStore, } from "./channelSortPreference"; +import { + advanceWatermark, + readWatermark, + runBootstrap, + type FetchResult, +} from "./sidebarSyncWatermark"; const D_TAG = "channel-sort"; +const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; export type RemoteSortPrefs = { @@ -44,17 +51,20 @@ async function decryptAndParse( */ export class ChannelSortSyncManager { private pubkey: string; + private relayUrl: string; private debounceTimer: number | null = null; - private lastRemoteCreatedAt = 0; + private lastRemoteCreatedAt: number; private pendingStore: ChannelSortStore | null = null; private lastPublishedStore: ChannelSortStore | null = null; private destroyed = false; - constructor(pubkey: string) { + constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; + this.relayUrl = relayUrl; + this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } - async fetchRemoteSortPrefs(): Promise { + async fetchRemoteSortPrefs(): Promise> { try { const events = await relayClient.fetchEvents({ kinds: [KIND_CHANNEL_SORT], @@ -62,21 +72,33 @@ export class ChannelSortSyncManager { "#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, - ); + if (events.length === 0 || events[0].pubkey !== this.pubkey) { + return { status: "absent" }; + } + const event = events[0]; + this.recordRemoteHead(event.created_at); + const result = await decryptAndParse(event); + if (!result) { + return { status: "failed", createdAt: event.created_at }; } - return result; + return { + status: "found", + data: result, + createdAt: result.createdAt, + eventId: result.eventId, + }; } catch { - return null; + return { status: "failed" }; } } + private recordRemoteHead(createdAt: number): void { + if (createdAt > this.lastRemoteCreatedAt) { + this.lastRemoteCreatedAt = createdAt; + } + advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); + } + cancelPendingPublish(): void { if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); @@ -110,11 +132,17 @@ export class ChannelSortSyncManager { limit: 1, }); if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const remote = await decryptAndParse(events[0]); + const event = events[0]; + // Snapshot the watermark before advancing it: after recordRemoteHead + // runs, lastRemoteCreatedAt equals event.created_at, so the LWW + // comparison remote.createdAt > lastRemoteCreatedAt would always be + // false and silently suppress the merge. + const headBeforeFetch = this.lastRemoteCreatedAt; + this.recordRemoteHead(event.created_at); + const remote = await decryptAndParse(event); if (!remote) return store; // Sort prefs use whole-blob LWW: take whichever is newer - if (remote.createdAt > this.lastRemoteCreatedAt) { - this.lastRemoteCreatedAt = remote.createdAt; + if (remote.createdAt > headBeforeFetch) { return remote.store; } return store; @@ -174,10 +202,7 @@ export class ChannelSortSyncManager { "Timed out publishing channel sort preferences.", "Failed to publish channel sort preferences.", ); - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - event.created_at, - ); + this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; this.pendingStore = null; } catch (error) { @@ -197,12 +222,11 @@ export class ChannelSortSyncManager { }, (event: RelayEvent) => { if (event.pubkey !== this.pubkey) return; + // Record the raw head before decrypt so an undecryptable live event + // still advances the watermark and blocks future seed-publish. + this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); onUpdate(result); } }); @@ -210,14 +234,28 @@ export class ChannelSortSyncManager { ); } + /** + * Fetches the remote blob on first mount, records the remote head, and + * delegates the seed/hold/apply-remote decision to `runBootstrap`. + */ + async bootstrap(localStore: ChannelSortStore) { + const fetchResult = await this.fetchRemoteSortPrefs(); + return runBootstrap({ + fetchResult, + lastHead: this.lastRemoteCreatedAt, + localStore, + isLocalNonEmpty: (s) => Object.keys(s.groups).length > 0, + publishFn: (s) => this.publishSortPrefs(s), + }); + } + 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 community switching and could - // publish relay A's sort prefs to relay B via the shared relayClient - // singleton. + // in-flight doPublish() calls abort before reaching relayClient. + // Pending debounce-window changes are intentionally dropped: flushing + // could publish relay A's sort prefs to relay B via the shared relayClient + // singleton. On return, bootstrap's found path whole-blob-replaces from + // remote, so any dropped pending edit is lost. this.destroyed = true; this.cancelPendingPublish(); this.pendingStore = null; diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs new file mode 100644 index 00000000000..b0235744672 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelStarsSync.test.mjs @@ -0,0 +1,202 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { ChannelStarSyncManager } from "./channelStarsSync.ts"; +import { + makeFakeWindow, + installFakeWindow, +} from "./sidebarSyncTestHelpers.mjs"; + +const RELAY = "wss://r.test"; +const RELAY_KEY = encodeURIComponent(RELAY); + +function makeStore(channels = {}) { + return { version: 1, channels }; +} + +// ─── destroy() must cancel pending publish, not flush ───────────────────────── + +// Regression guard for the community-switch cross-relay publish vector: +// star a channel in relay A → destroy() called (relayUrl dep change) → +// no publish should fire. +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(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-test", RELAY); + manager.publishStars(makeStore({ ch1: { starred: true, updatedAt: 100 } })); + manager.destroy(); + assert.equal(publishCalls.length, 0, "no publish after destroy"); + assert.equal(manager.getPendingStarStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => { + let releaseFetch = null; + const publishCalls = []; + mock.method( + relayClient, + "fetchEvents", + () => + new Promise((res) => { + releaseFetch = () => res([]); + }), + ); + mock.method(relayClient, "publishEvent", (...args) => { + publishCalls.push(args); + return Promise.resolve(); + }); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-race", RELAY); + manager.publishStars(makeStore({ ch1: { starred: true, updatedAt: 100 } })); + fw._fireTimer(); + manager.destroy(); + releaseFetch(); + await new Promise((r) => setTimeout(r, 0)); + assert.equal( + publishCalls.length, + 0, + "publishEvent must not be called after destroy", + ); + } finally { + restore(); + mock.reset(); + } +}); + +test("destroy: is safe to call with no pending publish", () => { + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-no-pending", RELAY); + assert.doesNotThrow(() => manager.destroy()); + } finally { + restore(); + } +}); + +// ─── Boot seed-publish guard (the revert-fix regression suite) ───────────────── + +// 1. fetch failed → hold, pendingStore null (mutation: remove failed guard → seed queued) +test("revert-fix: fetch failed (error) does not trigger seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay timeout")), + ); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-fail", RELAY); + const result = await manager.bootstrap( + makeStore({ ch1: { starred: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStarStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +// 2. absent + prior watermark → hold, pendingStore null (mutation: clear watermark → seed queued) +test("revert-fix: absent fetch with prior watermark blocks seed-publish via bootstrap", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-stars:pk-stale:${RELAY_KEY}`, + "1700000000", + ); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-stale", RELAY); + assert.ok( + Number( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-stars:pk-stale:${RELAY_KEY}`, + ) ?? "0", + ) > 0, + ); + const result = await manager.bootstrap( + makeStore({ ch1: { starred: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.equal(manager.getPendingStarStore(), null); + } finally { + restore(); + mock.reset(); + } +}); + +// 3. absent + zero watermark + non-empty → seed queued (mutation: remove seed call → pendingStore null) +test("revert-fix: absent fetch with zero watermark seeds via bootstrap (first-sync preserved)", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + try { + const manager = new ChannelStarSyncManager("pk-fresh", RELAY); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-stars:pk-fresh:${RELAY_KEY}`, + ), + null, + ); + const result = await manager.bootstrap( + makeStore({ ch1: { starred: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.ok(manager.getPendingStarStore() !== null); + } finally { + restore(); + mock.reset(); + } +}); + +// 4. relay-A / relay-B watermark isolation +// Mutation: using pubkey-only key (no relay) makes relay A's head suppress relay B's first-sync. +test("revert-fix: relay-A watermark does not suppress first-sync seed on relay-B", async () => { + const relayA = "wss://a.relay.test"; + const relayB = "wss://b.relay.test"; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + fw.localStorage.setItem( + `buzz-sync-watermark.v1:channel-stars:pk-iso:${encodeURIComponent(relayA)}`, + "1700000100", + ); + const restore = installFakeWindow(fw); + try { + const managerB = new ChannelStarSyncManager("pk-iso", relayB); + assert.equal( + fw.localStorage.getItem( + `buzz-sync-watermark.v1:channel-stars:pk-iso:${encodeURIComponent(relayB)}`, + ), + null, + "relay B watermark must be independent of relay A head", + ); + const result = await managerB.bootstrap( + makeStore({ ch1: { starred: true, updatedAt: 1 } }), + ); + assert.equal(result.action, "hold"); + assert.ok( + managerB.getPendingStarStore() !== null, + "first-sync seed on relay B must not be blocked by relay A watermark", + ); + } finally { + restore(); + mock.reset(); + } +}); diff --git a/desktop/src/features/sidebar/lib/channelStarsSync.ts b/desktop/src/features/sidebar/lib/channelStarsSync.ts index 6681030d47f..a5abec03fba 100644 --- a/desktop/src/features/sidebar/lib/channelStarsSync.ts +++ b/desktop/src/features/sidebar/lib/channelStarsSync.ts @@ -11,8 +11,15 @@ import { parseStarPayload, type ChannelStarStore, } from "./channelStarsStorage"; +import { + advanceWatermark, + readWatermark, + runBootstrap, + type FetchResult, +} from "./sidebarSyncWatermark"; const D_TAG = "channel-stars"; +const BLOB_TYPE = D_TAG; const DEBOUNCE_MS = 2_000; export type RemoteStars = { @@ -34,16 +41,20 @@ async function decryptAndParse(event: RelayEvent): Promise { export class ChannelStarSyncManager { private pubkey: string; + private relayUrl: string; private debounceTimer: number | null = null; - private lastRemoteCreatedAt = 0; + private lastRemoteCreatedAt: number; private pendingStore: ChannelStarStore | null = null; private lastPublishedStore: ChannelStarStore | null = null; + private destroyed = false; - constructor(pubkey: string) { + constructor(pubkey: string, relayUrl: string) { this.pubkey = pubkey; + this.relayUrl = relayUrl; + this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } - async fetchRemoteStars(): Promise { + async fetchRemoteStars(): Promise> { try { const events = await relayClient.fetchEvents({ kinds: [KIND_CHANNEL_STARS], @@ -51,19 +62,31 @@ export class ChannelStarSyncManager { "#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, - ); + if (events.length === 0 || events[0].pubkey !== this.pubkey) { + return { status: "absent" }; + } + const event = events[0]; + this.recordRemoteHead(event.created_at); + const result = await decryptAndParse(event); + if (!result) { + return { status: "failed", createdAt: event.created_at }; } - return result; + return { + status: "found", + data: result, + createdAt: result.createdAt, + eventId: result.eventId, + }; } catch { - return null; + return { status: "failed" }; + } + } + + private recordRemoteHead(createdAt: number): void { + if (createdAt > this.lastRemoteCreatedAt) { + this.lastRemoteCreatedAt = createdAt; } + advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt); } cancelPendingStarPublish(): void { @@ -99,12 +122,11 @@ export class ChannelStarSyncManager { limit: 1, }); if (events.length === 0 || events[0].pubkey !== this.pubkey) return store; - const remote = await decryptAndParse(events[0]); + const event = events[0]; + // Record the raw head before decrypt on the pre-publish path too. + this.recordRemoteHead(event.created_at); + const remote = await decryptAndParse(event); if (!remote) return store; - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - remote.createdAt, - ); return mergeStores(store, remote.store); } catch { return store; @@ -132,6 +154,10 @@ export class ChannelStarSyncManager { private async doPublish(store: ChannelStarStore): Promise { try { const merged = await this.fetchOwnBlobBeforePublish(store); + // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish + // was awaited (community switch during in-flight fetch). If so, abort + // before touching the relay. + if (this.destroyed) return; if (this.isIdenticalToLastPublished(merged)) { this.pendingStore = null; return; @@ -154,15 +180,13 @@ export class ChannelStarSyncManager { ["t", D_TAG], // relay discoverability; not used in our filters ], }); + if (this.destroyed) return; await relayClient.publishEvent( event, "Timed out publishing channel stars.", "Failed to publish channel stars.", ); - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - event.created_at, - ); + this.recordRemoteHead(event.created_at); this.lastPublishedStore = merged; this.pendingStore = null; } catch (error) { @@ -182,12 +206,11 @@ export class ChannelStarSyncManager { }, (event: RelayEvent) => { if (event.pubkey !== this.pubkey) return; + // Record the raw head before decrypt so an undecryptable live event + // still advances the watermark and blocks future seed-publish. + this.recordRemoteHead(event.created_at); void decryptAndParse(event).then((result) => { if (result) { - this.lastRemoteCreatedAt = Math.max( - this.lastRemoteCreatedAt, - result.createdAt, - ); onUpdate(result); } }); @@ -195,14 +218,30 @@ export class ChannelStarSyncManager { ); } + /** + * Fetches the remote blob on first mount, records the remote head, and + * delegates the seed/hold/apply-remote decision to `runBootstrap`. + */ + async bootstrap(localStore: ChannelStarStore) { + const fetchResult = await this.fetchRemoteStars(); + return runBootstrap({ + fetchResult, + lastHead: this.lastRemoteCreatedAt, + localStore, + isLocalNonEmpty: (s) => Object.keys(s.channels).length > 0, + publishFn: (s) => this.publishStars(s), + }); + } + destroy(): void { - if (this.debounceTimer !== null && this.pendingStore !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - void this.doPublish(this.pendingStore); - } else if (this.debounceTimer !== null) { - window.clearTimeout(this.debounceTimer); - this.debounceTimer = null; - } + // Cancel any pending publish and mark this manager as destroyed so any + // in-flight doPublish() calls abort before reaching relayClient. + // Pending debounce-window changes are intentionally dropped: flushing + // could publish relay A's state to relay B via the shared relayClient + // singleton. Local entries survive because the apply/publish paths merge + // per-entry via mergeStores, so no local work is permanently lost. + this.destroyed = true; + this.cancelPendingStarPublish(); + this.pendingStore = null; } } diff --git a/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs b/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs new file mode 100644 index 00000000000..c94d76db705 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarSyncTestHelpers.mjs @@ -0,0 +1,85 @@ +// Shared helpers for sidebar sync manager tests. + +export function makeFakeWindow() { + const storage = new Map(); + const ls = { + getItem: (k) => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, v), + removeItem: (k) => storage.delete(k), + clear: () => storage.clear(), + }; + let timerCallback = null; + let nextTimerId = 100; + return { + localStorage: ls, + setTimeout: (fn, _ms) => { + timerCallback = fn; + return nextTimerId++; + }, + clearTimeout: (_id) => { + timerCallback = null; + }, + _fireTimer: () => { + if (timerCallback) { + const fn = timerCallback; + timerCallback = null; + fn(); + } + }, + _hasTimer: () => timerCallback !== null, + }; +} + +export function installFakeWindow(fw) { + if (typeof globalThis.window === "undefined") globalThis.window = {}; + const origLs = globalThis.window.localStorage; + const origSt = globalThis.window.setTimeout; + const origCt = globalThis.window.clearTimeout; + globalThis.window.localStorage = fw.localStorage; + globalThis.window.setTimeout = fw.setTimeout; + globalThis.window.clearTimeout = fw.clearTimeout; + return () => { + if (origLs !== undefined) globalThis.window.localStorage = origLs; + if (origSt !== undefined) globalThis.window.setTimeout = origSt; + if (origCt !== undefined) globalThis.window.clearTimeout = origCt; + }; +} + +export function installTauriMock(goodCipherPayload) { + const orig = globalThis.window?.__TAURI_INTERNALS__; + if (typeof globalThis.window === "undefined") globalThis.window = {}; + let captured = null; + globalThis.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + if (cmd === "nip44_decrypt_from_self") { + if (args?.ciphertext === "bad-cipher") + return Promise.reject(new Error("decrypt failed")); + return Promise.resolve(goodCipherPayload); + } + if (cmd === "nip44_encrypt_to_self") { + captured = args?.plaintext ?? null; + return Promise.resolve("ct"); + } + if (cmd === "sign_event") + return Promise.resolve( + JSON.stringify({ + id: "eid", + pubkey: "pk-lww", + content: "ct", + created_at: args?.createdAt ?? 0, + kind: args?.kind ?? 0, + tags: args?.tags ?? [], + sig: "s", + }), + ); + return Promise.reject(new Error(`unmocked: ${cmd}`)); + }, + }; + return { + restore: () => { + if (orig !== undefined) globalThis.window.__TAURI_INTERNALS__ = orig; + else delete globalThis.window.__TAURI_INTERNALS__; + }, + capturedPlaintext: () => captured, + }; +} diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs new file mode 100644 index 00000000000..0e8cb373c16 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.test.mjs @@ -0,0 +1,253 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// We need a minimal localStorage stub since we're running in Node. +function withFreshStorage(fn) { + const store = new Map(); + const ls = { + getItem: (k) => store.get(k) ?? null, + setItem: (k, v) => store.set(k, v), + removeItem: (k) => store.delete(k), + clear: () => store.clear(), + }; + const orig = globalThis.window?.localStorage; + if (typeof globalThis.window === "undefined") globalThis.window = {}; + globalThis.window.localStorage = ls; + try { + fn(ls); + } finally { + if (orig !== undefined) globalThis.window.localStorage = orig; + else delete globalThis.window.localStorage; + } +} + +const { readWatermark, advanceWatermark, runBootstrap } = await import( + "./sidebarSyncWatermark.ts" +); + +// Relay URLs are normalised (trimmed, lowercase, trailing slash stripped) +// so the same relay written two ways produces the same key. +const RELAY = "wss://relay.example.com"; +const RELAY_ENCODED = encodeURIComponent("wss://relay.example.com"); + +// ── readWatermark ──────────────────────────────────────────────────────────── + +test("readWatermark: returns 0 when no key exists", () => { + withFreshStorage(() => { + assert.equal(readWatermark("pk", "sections", RELAY), 0); + }); +}); + +test("readWatermark: returns 0 when stored value is 0", () => { + withFreshStorage((ls) => { + ls.setItem(`buzz-sync-watermark.v1:sections:pk:${RELAY_ENCODED}`, "0"); + assert.equal(readWatermark("pk", "sections", RELAY), 0); + }); +}); + +test("readWatermark: returns stored positive integer", () => { + withFreshStorage((ls) => { + ls.setItem( + `buzz-sync-watermark.v1:sections:pk:${RELAY_ENCODED}`, + "1700000000", + ); + assert.equal(readWatermark("pk", "sections", RELAY), 1700000000); + }); +}); + +test("readWatermark: scopes by blobType", () => { + withFreshStorage((ls) => { + ls.setItem(`buzz-sync-watermark.v1:sections:pk:${RELAY_ENCODED}`, "100"); + ls.setItem(`buzz-sync-watermark.v1:sort:pk:${RELAY_ENCODED}`, "200"); + assert.equal(readWatermark("pk", "sections", RELAY), 100); + assert.equal(readWatermark("pk", "sort", RELAY), 200); + }); +}); + +test("readWatermark: normalises relay URL (trailing slash, case)", () => { + withFreshStorage(() => { + // Write with one form, read with another — must produce the same value. + advanceWatermark("pk", "sections", "WSS://Relay.Example.Com/", 999); + assert.equal( + readWatermark("pk", "sections", "wss://relay.example.com"), + 999, + ); + assert.equal( + readWatermark("pk", "sections", "WSS://Relay.Example.Com/"), + 999, + ); + }); +}); + +// ── advanceWatermark ───────────────────────────────────────────────────────── + +test("advanceWatermark: writes when no prior value exists", () => { + withFreshStorage(() => { + advanceWatermark("pk", "sections", RELAY, 1700000000); + assert.equal(readWatermark("pk", "sections", RELAY), 1700000000); + }); +}); + +test("advanceWatermark: advances when next > current", () => { + withFreshStorage(() => { + advanceWatermark("pk", "sections", RELAY, 100); + advanceWatermark("pk", "sections", RELAY, 200); + assert.equal(readWatermark("pk", "sections", RELAY), 200); + }); +}); + +test("advanceWatermark: does not regress when next <= current (monotonic)", () => { + withFreshStorage(() => { + advanceWatermark("pk", "sections", RELAY, 500); + advanceWatermark("pk", "sections", RELAY, 400); // older — must not overwrite + advanceWatermark("pk", "sections", RELAY, 500); // equal — must not overwrite + assert.equal(readWatermark("pk", "sections", RELAY), 500); + }); +}); + +test("advanceWatermark: round-trips across separate reads (simulated restart)", () => { + withFreshStorage(() => { + // Session A writes watermark. + advanceWatermark("pk", "sections", RELAY, 1700000042); + // Session B reads it back. + assert.equal(readWatermark("pk", "sections", RELAY), 1700000042); + }); +}); + +// ── Relay-A / Relay-B isolation ────────────────────────────────────────────── + +test("relay-A watermark does not suppress first-sync on relay-B", () => { + withFreshStorage(() => { + const relayA = "wss://a.relay.test"; + const relayB = "wss://b.relay.test"; + advanceWatermark("pk", "sections", relayA, 1700000100); + assert.equal( + readWatermark("pk", "sections", relayB), + 0, + "relay B watermark must be independent of relay A", + ); + }); +}); + +test("relay-A watermark is preserved after relay-B session", () => { + withFreshStorage(() => { + const relayA = "wss://a.relay.test"; + const relayB = "wss://b.relay.test"; + advanceWatermark("pk", "sections", relayA, 1700000100); + advanceWatermark("pk", "sections", relayB, 1700000200); + assert.equal( + readWatermark("pk", "sections", relayA), + 1700000100, + "relay A head must not be clobbered by relay B activity", + ); + }); +}); + +// ── runBootstrap policy — tested once; mutations to any branch fail here ───── + +function makeBootstrapArgs({ fetchResult, lastHead, localNonEmpty }) { + let n = 0; + return { + args: { + fetchResult, + lastHead, + localStore: { items: localNonEmpty ? ["x"] : [] }, + isLocalNonEmpty: (s) => s.items.length > 0, + publishFn: () => { + n++; + }, + }, + publishCount: () => n, + }; +} + +// Guard: fetch failed → hold, zero publishes. +// Mutation: removing the failed branch causes a seed on first-sync case. +test("runBootstrap: fetch failed returns hold and never calls publishFn", () => { + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { status: "failed" }, + lastHead: 0, + localNonEmpty: true, + }); + const result = runBootstrap(args); + assert.equal(result.action, "hold"); + assert.equal( + publishCount(), + 0, + "publishFn must not be called on failed fetch", + ); +}); + +// Guard: fetch absent + prior head > 0 → hold, zero publishes (stale-dev-build case). +// Mutation: setting lastHead to 0 causes a seed. +test("runBootstrap: fetch absent with prior head returns hold and never calls publishFn", () => { + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { status: "absent" }, + lastHead: 1700000000, + localNonEmpty: true, + }); + const result = runBootstrap(args); + assert.equal(result.action, "hold"); + assert.equal( + publishCount(), + 0, + "publishFn must not be called when prior head exists", + ); +}); + +// Guard: fetch absent + head 0 + local non-empty → publishFn called exactly once, hold returned. +// Mutation: removing the absent+head-0 seed call leaves publishCount at 0. +test("runBootstrap: first-sync (absent + zero head + non-empty local) calls publishFn and returns hold", () => { + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { status: "absent" }, + lastHead: 0, + localNonEmpty: true, + }); + const result = runBootstrap(args); + assert.equal(result.action, "hold"); + assert.equal( + publishCount(), + 1, + "publishFn must be called exactly once on first-sync", + ); +}); + +// Guard: fetch absent + head 0 + empty local → no publish, hold returned. +test("runBootstrap: first-sync with empty local store does not call publishFn", () => { + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { status: "absent" }, + lastHead: 0, + localNonEmpty: false, + }); + const result = runBootstrap(args); + assert.equal(result.action, "hold"); + assert.equal(publishCount(), 0, "empty local store must not trigger seed"); +}); + +// Guard: fetch found → apply-remote returned, no publish. +// Mutation: removing the found branch drops the remote data. +test("runBootstrap: fetch found returns apply-remote with data and never calls publishFn", () => { + const remoteData = { + store: { version: 1, items: [] }, + createdAt: 100, + eventId: "e1", + }; + const { args, publishCount } = makeBootstrapArgs({ + fetchResult: { + status: "found", + data: remoteData, + createdAt: 100, + eventId: "e1", + }, + lastHead: 0, + localNonEmpty: true, + }); + const result = runBootstrap(args); + assert.equal(result.action, "apply-remote"); + assert.deepEqual(result.data, remoteData); + assert.equal( + publishCount(), + 0, + "publishFn must not be called when remote was found", + ); +}); diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts new file mode 100644 index 00000000000..d81b188ad67 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts @@ -0,0 +1,135 @@ +/** + * Persisted remote-head watermark for sidebar-preference sync managers. + * + * Each manager (sections, sort, stars, mutes) persists the highest + * `created_at` it has ever observed from the relay under a key scoped to + * pubkey + relay + blob type. On the next boot the manager reads this value + * back: if it is > 0 a remote blob has existed before and seed-publishing + * must be skipped even when the fetch comes back empty (error, timeout, or + * auth-race). + * + * Keys live in localStorage alongside the payload blobs. They are tiny + * (one integer string per key) and scoped so they never bleed across + * identities, communities, or blob types. + * + * `relayUrl` is always required — a pubkey-only fallback is not safe because + * a head seen on relay A would suppress legitimate first-time seeding on + * relay B. The URL is normalised (trimmed, trailing slash stripped, + * lower-cased) before being embedded in the key so the same relay written + * two ways never produces two different keys. + */ + +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; + +const PREFIX = "buzz-sync-watermark.v1"; + +/** + * Tri-state result returned by every `fetchRemote*()` method. + * + * - `found` — the relay returned an event that decrypted and parsed cleanly. + * - `absent` — the relay was successfully queried and returned zero events + * (genuine first-time use on this relay). + * - `failed` — the fetch threw (timeout, relay error, auth-race), or an event + * existed but could not be decrypted/parsed. In the `failed` + * case, `createdAt` may be set when the event itself was readable + * even though its payload was not — the manager records the head + * so seed-publish is still blocked. + */ +export type FetchResult = + | { status: "found"; data: T; createdAt: number; eventId: string } + | { status: "absent" } + | { status: "failed"; createdAt?: number }; + +function watermarkKey( + pubkey: string, + blobType: string, + relayUrl: string, +): string { + return `${PREFIX}:${blobType}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} + +/** Read the persisted watermark (0 when absent or on read error). */ +export function readWatermark( + pubkey: string, + blobType: string, + relayUrl: string, +): number { + try { + const raw = window.localStorage.getItem( + watermarkKey(pubkey, blobType, relayUrl), + ); + if (raw === null) return 0; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : 0; + } catch { + return 0; + } +} + +/** + * Persist a new watermark if it is strictly greater than the current value. + * Absence or error never lowers the watermark (monotonic). + */ +export function advanceWatermark( + pubkey: string, + blobType: string, + relayUrl: string, + next: number, +): void { + try { + const current = readWatermark(pubkey, blobType, relayUrl); + if (next <= current) return; + window.localStorage.setItem( + watermarkKey(pubkey, blobType, relayUrl), + String(next), + ); + } catch { + // Ignore write failures — the in-memory lastRemoteCreatedAt still guards + // seed-publish within this session; the watermark is belt-and-suspenders + // across sessions. + } +} + +/** Result returned by `bootstrap()` — the hook acts on this without publishing. */ +export type BootstrapResult = + | { action: "apply-remote"; data: T } + | { action: "hold" }; + +/** + * Shared boot policy for all four sidebar-preference sync managers. + * + * Each manager calls this from its `bootstrap()` method, supplying its + * surface-specific fetch, publish, and local-store accessors. The full + * decision lives here once so that a mutation to any one surface cannot + * escape via a per-manager copy. + * + * Policy: + * - `found` → return `apply-remote`; hook applies data. + * - `failed` → hold; seed-publish blocked (error or unreadable event). + * - `absent` + `lastHead > 0` → hold; relay blob seen before, absence may be transient. + * - `absent` + `lastHead === 0` + non-empty local → call `publishFn(local)`; return `hold`. + * - `absent` + `lastHead === 0` + empty local → hold; nothing to seed. + */ +export function runBootstrap({ + fetchResult, + lastHead, + localStore, + isLocalNonEmpty, + publishFn, +}: { + fetchResult: FetchResult; + lastHead: number; + localStore: TLocal; + isLocalNonEmpty: (store: TLocal) => boolean; + publishFn: (store: TLocal) => void; +}): BootstrapResult { + if (fetchResult.status === "found") { + return { action: "apply-remote", data: fetchResult.data }; + } + if (fetchResult.status === "absent" && lastHead === 0) { + if (isLocalNonEmpty(localStore)) { + publishFn(localStore); + } + } + return { action: "hold" }; +} diff --git a/desktop/src/features/sidebar/lib/useChannelMutes.ts b/desktop/src/features/sidebar/lib/useChannelMutes.ts index 1fe92b60a31..cab913834dc 100644 --- a/desktop/src/features/sidebar/lib/useChannelMutes.ts +++ b/desktop/src/features/sidebar/lib/useChannelMutes.ts @@ -14,7 +14,10 @@ import { import { ChannelMuteSyncManager } from "./channelMutesSync"; import type { RemoteMutes } from "./channelMutesSync"; -export function useChannelMutes(pubkey: string | undefined): { +export function useChannelMutes( + pubkey: string | undefined, + relayUrl?: string, +): { mutedChannelIds: Set; muteChannel: (channelId: string) => void; unmuteChannel: (channelId: string) => void; @@ -31,7 +34,7 @@ export function useChannelMutes(pubkey: string | undefined): { const lastAppliedEventId = React.useRef(""); React.useEffect(() => { - if (!pubkey) { + if (!pubkey || !relayUrl) { setStore(DEFAULT_STORE); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; @@ -40,12 +43,12 @@ export function useChannelMutes(pubkey: string | undefined): { setStore(readChannelMutesStore(pubkey)); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; - managerRef.current = new ChannelMuteSyncManager(pubkey); + managerRef.current = new ChannelMuteSyncManager(pubkey, relayUrl); return () => { managerRef.current?.destroy(); managerRef.current = null; }; - }, [pubkey]); + }, [pubkey, relayUrl]); React.useEffect(() => { if (!pubkey) { @@ -86,24 +89,22 @@ export function useChannelMutes(pubkey: string | undefined): { ); React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; - void managerRef.current?.fetchRemoteMutes().then((remote) => { + const local = readChannelMutesStore(pubkey); + void managerRef.current?.bootstrap(local).then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); - } else { - const local = readChannelMutesStore(pubkey); - if (Object.keys(local.channels).length > 0) { - managerRef.current?.publishMutes(local); - } + if (result.action === "apply-remote") { + setStore(applyRemote(result.data)); } + // "hold": seed already performed by bootstrap (if first-sync), or blocked. }); return () => { cancelled = true; }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); + // biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds subscription when the active relay changes even though it is not used inside the effect body directly (the manager via managerRef.current carries it) React.useEffect(() => { if (!pubkey) return; let unsub: (() => Promise) | null = null; @@ -124,16 +125,17 @@ export function useChannelMutes(pubkey: string | undefined): { cancelled = true; if (unsub) void unsub(); }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); + // biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds reconnect listener when the active relay changes (community switch) even though it is not referenced directly inside the effect body React.useEffect(() => { if (!pubkey) return; let cancelled = false; const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteMutes().then((remote) => { + void managerRef.current?.fetchRemoteMutes().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); + if (result.status === "found") { + setStore(applyRemote(result.data)); } const pending = managerRef.current?.getPendingMuteStore(); if (pending) { @@ -145,7 +147,7 @@ export function useChannelMutes(pubkey: string | undefined): { cancelled = true; unsub(); }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); // biome-ignore lint/correctness/useExhaustiveDependencies: store.channels is the relevant dep — the outer store identity can change without channels changing (e.g., on reconnect writes) const mutedChannelIds = React.useMemo( diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index 2ba659a4841..3d8aa736086 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -45,7 +45,7 @@ export function useChannelSections( const lastAppliedEventId = React.useRef(""); React.useEffect(() => { - if (!pubkey) { + if (!pubkey || !relayUrl) { setStore(DEFAULT_STORE); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; @@ -54,7 +54,7 @@ export function useChannelSections( setStore(readChannelSectionsStore(pubkey, relayUrl)); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; - managerRef.current = new ChannelSectionSyncManager(pubkey); + managerRef.current = new ChannelSectionSyncManager(pubkey, relayUrl); return () => { managerRef.current?.destroy(); managerRef.current = null; @@ -102,18 +102,16 @@ export function useChannelSections( ); React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; - void managerRef.current?.fetchRemoteSections().then((remote) => { + const local = readChannelSectionsStore(pubkey, relayUrl); + void managerRef.current?.bootstrap(local).then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); - } else { - const local = readChannelSectionsStore(pubkey, relayUrl); - if (local.sections.length > 0) { - managerRef.current?.publishSections(local); - } + if (result.action === "apply-remote") { + setStore(applyRemote(result.data)); } + // "hold": seed already performed by bootstrap (if first-sync), or + // blocked (failed fetch / prior watermark). Hook does nothing. }); return () => { cancelled = true; @@ -146,10 +144,10 @@ export function useChannelSections( if (!pubkey) return; let cancelled = false; const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteSections().then((remote) => { + void managerRef.current?.fetchRemoteSections().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); + if (result.status === "found") { + setStore(applyRemote(result.data)); } const pending = managerRef.current?.getPendingStore(); if (pending) { diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts index e347d41a9d8..a7963a11e4f 100644 --- a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts @@ -49,7 +49,7 @@ export function useChannelSortPreference( const lastAppliedEventId = React.useRef(""); React.useEffect(() => { - if (!pubkey) { + if (!pubkey || !relayUrl) { setStore(DEFAULT_STORE); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; @@ -58,7 +58,7 @@ export function useChannelSortPreference( setStore(readChannelSortStore(pubkey, relayUrl)); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; - managerRef.current = new ChannelSortSyncManager(pubkey); + managerRef.current = new ChannelSortSyncManager(pubkey, relayUrl); return () => { managerRef.current?.destroy(); managerRef.current = null; @@ -101,18 +101,15 @@ export function useChannelSortPreference( ); React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; - void managerRef.current?.fetchRemoteSortPrefs().then((remote) => { + const local = readChannelSortStore(pubkey, relayUrl); + void managerRef.current?.bootstrap(local).then((result) => { 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); - } + if (result.action === "apply-remote") { + setStore(applyRemote(result.data)); } + // "hold": seed already performed by bootstrap (if first-sync), or blocked. }); return () => { cancelled = true; @@ -145,10 +142,10 @@ export function useChannelSortPreference( if (!pubkey) return; let cancelled = false; const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteSortPrefs().then((remote) => { + void managerRef.current?.fetchRemoteSortPrefs().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); + if (result.status === "found") { + setStore(applyRemote(result.data)); } const pending = managerRef.current?.getPendingStore(); if (pending) { diff --git a/desktop/src/features/sidebar/lib/useChannelStars.ts b/desktop/src/features/sidebar/lib/useChannelStars.ts index 777bf52cfd8..b19b18a8643 100644 --- a/desktop/src/features/sidebar/lib/useChannelStars.ts +++ b/desktop/src/features/sidebar/lib/useChannelStars.ts @@ -14,7 +14,10 @@ import { import { ChannelStarSyncManager } from "./channelStarsSync"; import type { RemoteStars } from "./channelStarsSync"; -export function useChannelStars(pubkey: string | undefined): { +export function useChannelStars( + pubkey: string | undefined, + relayUrl?: string, +): { starredChannelIds: Set; starChannel: (channelId: string) => void; unstarChannel: (channelId: string) => void; @@ -31,7 +34,7 @@ export function useChannelStars(pubkey: string | undefined): { const lastAppliedEventId = React.useRef(""); React.useEffect(() => { - if (!pubkey) { + if (!pubkey || !relayUrl) { setStore(DEFAULT_STORE); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; @@ -40,12 +43,12 @@ export function useChannelStars(pubkey: string | undefined): { setStore(readChannelStarsStore(pubkey)); lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; - managerRef.current = new ChannelStarSyncManager(pubkey); + managerRef.current = new ChannelStarSyncManager(pubkey, relayUrl); return () => { managerRef.current?.destroy(); managerRef.current = null; }; - }, [pubkey]); + }, [pubkey, relayUrl]); React.useEffect(() => { if (!pubkey) { @@ -86,24 +89,22 @@ export function useChannelStars(pubkey: string | undefined): { ); React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; - void managerRef.current?.fetchRemoteStars().then((remote) => { + const local = readChannelStarsStore(pubkey); + void managerRef.current?.bootstrap(local).then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); - } else { - const local = readChannelStarsStore(pubkey); - if (Object.keys(local.channels).length > 0) { - managerRef.current?.publishStars(local); - } + if (result.action === "apply-remote") { + setStore(applyRemote(result.data)); } + // "hold": seed already performed by bootstrap (if first-sync), or blocked. }); return () => { cancelled = true; }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); + // biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds subscription when the active relay changes even though it is not used inside the effect body directly (the manager via managerRef.current carries it) React.useEffect(() => { if (!pubkey) return; let unsub: (() => Promise) | null = null; @@ -124,16 +125,17 @@ export function useChannelStars(pubkey: string | undefined): { cancelled = true; if (unsub) void unsub(); }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); + // biome-ignore lint/correctness/useExhaustiveDependencies: relayUrl is intentional — rebinds reconnect listener when the active relay changes (community switch) even though it is not referenced directly inside the effect body React.useEffect(() => { if (!pubkey) return; let cancelled = false; const unsub = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemoteStars().then((remote) => { + void managerRef.current?.fetchRemoteStars().then((result) => { if (cancelled) return; - if (remote) { - setStore(applyRemote(remote)); + if (result.status === "found") { + setStore(applyRemote(result.data)); } const pending = managerRef.current?.getPendingStarStore(); if (pending) { @@ -145,7 +147,7 @@ export function useChannelStars(pubkey: string | undefined): { cancelled = true; unsub(); }; - }, [pubkey, applyRemote]); + }, [pubkey, relayUrl, applyRemote]); // biome-ignore lint/correctness/useExhaustiveDependencies: store.channels is the relevant dep — the outer store identity can change without channels changing (e.g., on reconnect writes) const starredChannelIds = React.useMemo( diff --git a/desktop/src/shared/lib/normalizeRelayUrl.ts b/desktop/src/shared/lib/normalizeRelayUrl.ts new file mode 100644 index 00000000000..7222b1fe255 --- /dev/null +++ b/desktop/src/shared/lib/normalizeRelayUrl.ts @@ -0,0 +1,8 @@ +/** + * Normalizes a relay URL for use in storage keys. + * Trim, strip trailing slashes, lowercase — ensures equivalent URLs map to + * the same key regardless of formatting differences. + */ +export function normalizeRelayUrl(relayUrl: string): string { + return relayUrl.trim().replace(/\/+$/, "").toLowerCase(); +}