From 26f9f02ba20fc4873411d3bdd5f850b03cc647d9 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 20 Aug 2026 11:50:40 -0400 Subject: [PATCH] fix(desktop): keep Inbox view preferences local Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- .../home/inboxViewPreference.test.mjs | 17 +- .../home/inboxViewPreferenceSync.test.mjs | 56 ----- .../features/home/inboxViewPreferenceSync.ts | 228 ------------------ .../features/home/useInboxViewPreference.ts | 122 +--------- .../sidebar/lib/sidebarSyncWatermark.ts | 6 +- desktop/src/shared/constants/kinds.ts | 3 +- 6 files changed, 31 insertions(+), 401 deletions(-) delete mode 100644 desktop/src/features/home/inboxViewPreferenceSync.test.mjs delete mode 100644 desktop/src/features/home/inboxViewPreferenceSync.ts diff --git a/desktop/src/features/home/inboxViewPreference.test.mjs b/desktop/src/features/home/inboxViewPreference.test.mjs index 7e2c095810..25fb7d277d 100644 --- a/desktop/src/features/home/inboxViewPreference.test.mjs +++ b/desktop/src/features/home/inboxViewPreference.test.mjs @@ -54,9 +54,19 @@ test("filter and unread-only restore from the per-user, per-relay cache", async const originalFetchEvents = relayClient.fetchEvents; const originalSubscribeLive = relayClient.subscribeLive; const originalSubscribeToReconnects = relayClient.subscribeToReconnects; - relayClient.fetchEvents = async () => []; - relayClient.subscribeLive = async () => async () => {}; - relayClient.subscribeToReconnects = () => () => {}; + let relayOperations = 0; + relayClient.fetchEvents = async () => { + relayOperations += 1; + return []; + }; + relayClient.subscribeLive = async () => { + relayOperations += 1; + return async () => {}; + }; + relayClient.subscribeToReconnects = () => { + relayOperations += 1; + return () => {}; + }; const pubkey = "pk-inbox-view"; const relayUrl = "wss://relay.example"; @@ -79,6 +89,7 @@ test("filter and unread-only restore from the per-user, per-relay cache", async assert.equal(restored.result.current.filter, "mention"); assert.equal(restored.result.current.unreadOnly, true); restored.unmount(); + assert.equal(relayOperations, 0); } finally { cleanup(); relayClient.fetchEvents = originalFetchEvents; diff --git a/desktop/src/features/home/inboxViewPreferenceSync.test.mjs b/desktop/src/features/home/inboxViewPreferenceSync.test.mjs deleted file mode 100644 index dec630b292..0000000000 --- a/desktop/src/features/home/inboxViewPreferenceSync.test.mjs +++ /dev/null @@ -1,56 +0,0 @@ -import assert from "node:assert/strict"; -import test, { mock } from "node:test"; - -import { relayClient } from "@/shared/api/relayClient"; -import { KIND_INBOX_VIEW } from "@/shared/constants/kinds"; -import { - installFakeWindow, - installTauriMock, - makeFakeWindow, -} from "@/features/sidebar/lib/sidebarSyncTestHelpers.mjs"; -import { InboxViewPreferenceSyncManager } from "./inboxViewPreferenceSync.ts"; - -test("publishes Inbox view settings as the encrypted inbox-view NIP-78 blob", async () => { - mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); - const published = []; - mock.method(relayClient, "publishEvent", (event) => { - published.push(event); - return Promise.resolve(); - }); - const fakeWindow = makeFakeWindow(); - const restoreWindow = installFakeWindow(fakeWindow); - const tauri = installTauriMock( - JSON.stringify({ version: 1, filter: "all", unreadOnly: false }), - ); - - try { - const manager = new InboxViewPreferenceSyncManager( - "pk-lww", - "wss://relay.example", - ); - manager.publishPreference({ - version: 1, - filter: "mention", - unreadOnly: true, - }); - fakeWindow._fireTimer(); - await new Promise((resolve) => setTimeout(resolve, 0)); - - assert.deepEqual(JSON.parse(tauri.capturedPlaintext()), { - version: 1, - filter: "mention", - unreadOnly: true, - }); - assert.equal(published.length, 1); - assert.equal(published[0].kind, KIND_INBOX_VIEW); - assert.deepEqual(published[0].tags, [ - ["d", "inbox-view"], - ["t", "inbox-view"], - ]); - manager.destroy(); - } finally { - tauri.restore(); - restoreWindow(); - mock.reset(); - } -}); diff --git a/desktop/src/features/home/inboxViewPreferenceSync.ts b/desktop/src/features/home/inboxViewPreferenceSync.ts deleted file mode 100644 index 4d5280d04e..0000000000 --- a/desktop/src/features/home/inboxViewPreferenceSync.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { relayClient } from "@/shared/api/relayClient"; -import { - nip44DecryptFromSelf, - nip44EncryptToSelf, - signRelayEvent, -} from "@/shared/api/tauri"; -import type { RelayEvent } from "@/shared/api/types"; -import { KIND_INBOX_VIEW } from "@/shared/constants/kinds"; -import { - advanceWatermark, - readWatermark, - runBootstrap, - type FetchResult, -} from "@/features/sidebar/lib/sidebarSyncWatermark"; -import { - parseInboxViewPreference, - type InboxViewPreference, -} from "./inboxViewPreference"; - -const D_TAG = "inbox-view"; -const BLOB_TYPE = D_TAG; -const DEBOUNCE_MS = 2_000; - -/** A decrypted Inbox view preference and its relay ordering metadata. */ -export type RemoteInboxViewPreference = { - preference: InboxViewPreference; - createdAt: number; - eventId: string; -}; - -async function decryptAndParse( - event: RelayEvent, -): Promise { - try { - const plaintext = await nip44DecryptFromSelf(event.content); - const preference = parseInboxViewPreference(JSON.parse(plaintext)); - if (!preference) return null; - return { preference, createdAt: event.created_at, eventId: event.id }; - } catch { - return null; - } -} - -/** - * Syncs Inbox filter settings through encrypted NIP-78 app data, matching the - * channel-sort preference's debounced, whole-blob last-write-wins behavior. - */ -export class InboxViewPreferenceSyncManager { - private debounceTimer: number | null = null; - private destroyed = false; - private lastPublishedPreference: InboxViewPreference | null = null; - private lastRemoteCreatedAt: number; - private pendingPreference: InboxViewPreference | null = null; - private pubkey: string; - private relayUrl: string; - - constructor(pubkey: string, relayUrl: string) { - this.pubkey = pubkey; - this.relayUrl = relayUrl; - this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); - } - - async fetchRemotePreference(): Promise< - FetchResult - > { - try { - const events = await relayClient.fetchEvents({ - kinds: [KIND_INBOX_VIEW], - authors: [this.pubkey], - "#d": [D_TAG], - limit: 1, - }); - 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 { - status: "found", - data: result, - createdAt: result.createdAt, - eventId: result.eventId, - }; - } catch { - 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); - this.debounceTimer = null; - } - } - - getPendingPreference(): InboxViewPreference | null { - return this.pendingPreference; - } - - publishPreference(preference: InboxViewPreference): void { - this.pendingPreference = preference; - if (this.debounceTimer !== null) { - window.clearTimeout(this.debounceTimer); - } - this.debounceTimer = window.setTimeout(() => { - this.debounceTimer = null; - void this.doPublish(preference); - }, DEBOUNCE_MS); - } - - private async fetchOwnBlobBeforePublish( - preference: InboxViewPreference, - ): Promise { - try { - const events = await relayClient.fetchEvents({ - kinds: [KIND_INBOX_VIEW], - authors: [this.pubkey], - "#d": [D_TAG], - limit: 1, - }); - if (events.length === 0 || events[0].pubkey !== this.pubkey) { - return preference; - } - const event = events[0]; - const headBeforeFetch = this.lastRemoteCreatedAt; - this.recordRemoteHead(event.created_at); - const remote = await decryptAndParse(event); - if (!remote) return preference; - return remote.createdAt > headBeforeFetch - ? remote.preference - : preference; - } catch { - return preference; - } - } - - private isIdenticalToLastPublished(preference: InboxViewPreference): boolean { - return ( - this.lastPublishedPreference?.filter === preference.filter && - this.lastPublishedPreference.unreadOnly === preference.unreadOnly - ); - } - - private async doPublish(preference: InboxViewPreference): Promise { - try { - const merged = await this.fetchOwnBlobBeforePublish(preference); - if (this.destroyed) return; - if (this.isIdenticalToLastPublished(merged)) { - this.pendingPreference = null; - return; - } - const ciphertext = await nip44EncryptToSelf(JSON.stringify(merged)); - const createdAt = Math.max( - Math.floor(Date.now() / 1_000), - this.lastRemoteCreatedAt + 1, - ); - const event = await signRelayEvent({ - kind: KIND_INBOX_VIEW, - content: ciphertext, - createdAt, - tags: [ - ["d", D_TAG], - ["t", D_TAG], - ], - }); - if (this.destroyed) return; - await relayClient.publishEvent( - event, - "Timed out publishing Inbox view preferences.", - "Failed to publish Inbox view preferences.", - ); - this.recordRemoteHead(event.created_at); - this.lastPublishedPreference = merged; - this.pendingPreference = null; - } catch (error) { - console.warn("[inboxViewPreferenceSync] publish failed:", error); - } - } - - async subscribe( - onUpdate: (remote: RemoteInboxViewPreference) => void, - ): Promise<() => Promise> { - return relayClient.subscribeLive( - { - kinds: [KIND_INBOX_VIEW], - authors: [this.pubkey], - "#d": [D_TAG], - limit: 0, - }, - (event: RelayEvent) => { - if (event.pubkey !== this.pubkey) return; - this.recordRemoteHead(event.created_at); - void decryptAndParse(event).then((result) => { - if (result) onUpdate(result); - }); - }, - ); - } - - async bootstrap(localPreference: InboxViewPreference) { - const fetchResult = await this.fetchRemotePreference(); - return runBootstrap({ - fetchResult, - lastHead: this.lastRemoteCreatedAt, - localStore: localPreference, - isLocalNonEmpty: (preference) => - preference.filter !== "all" || preference.unreadOnly, - publishFn: (preference) => this.publishPreference(preference), - }); - } - - destroy(): void { - this.destroyed = true; - this.cancelPendingPublish(); - this.pendingPreference = null; - } -} diff --git a/desktop/src/features/home/useInboxViewPreference.ts b/desktop/src/features/home/useInboxViewPreference.ts index 5872f14ad0..17b1c8b7e2 100644 --- a/desktop/src/features/home/useInboxViewPreference.ts +++ b/desktop/src/features/home/useInboxViewPreference.ts @@ -1,7 +1,6 @@ import * as React from "react"; import type { InboxFilter } from "@/features/home/lib/inbox"; -import { relayClient } from "@/shared/api/relayClient"; import { DEFAULT_INBOX_VIEW_PREFERENCE, inboxViewPreferenceStorageKey, @@ -9,10 +8,6 @@ import { writeInboxViewPreference, type InboxViewPreference, } from "./inboxViewPreference"; -import { - InboxViewPreferenceSyncManager, - type RemoteInboxViewPreference, -} from "./inboxViewPreferenceSync"; /** State and mutations exposed to the mounted Inbox surface. */ export type InboxViewPreferenceController = { @@ -23,8 +18,8 @@ export type InboxViewPreferenceController = { }; /** - * Persistent Inbox view preferences scoped by pubkey and relay. localStorage - * is the instant/offline cache; encrypted NIP-78 is the cross-client source. + * Device-local Inbox view preferences, scoped by signed-in user and relay. + * They survive navigation and restart without being sent to the relay. */ export function useInboxViewPreference( pubkey: string | undefined, @@ -35,126 +30,35 @@ export function useInboxViewPreference( ? readInboxViewPreference(pubkey, relayUrl) : DEFAULT_INBOX_VIEW_PREFERENCE, ); - const managerRef = React.useRef(null); - const lastAppliedRemoteTs = React.useRef(0); - const lastAppliedEventId = React.useRef(""); React.useEffect(() => { - if (!pubkey || !relayUrl) { - setPreference(DEFAULT_INBOX_VIEW_PREFERENCE); - lastAppliedRemoteTs.current = 0; - lastAppliedEventId.current = ""; - return; - } - setPreference(readInboxViewPreference(pubkey, relayUrl)); - lastAppliedRemoteTs.current = 0; - lastAppliedEventId.current = ""; - managerRef.current = new InboxViewPreferenceSyncManager(pubkey, relayUrl); - return () => { - managerRef.current?.destroy(); - managerRef.current = null; - }; + setPreference( + pubkey + ? readInboxViewPreference(pubkey, relayUrl) + : DEFAULT_INBOX_VIEW_PREFERENCE, + ); }, [pubkey, relayUrl]); React.useEffect(() => { if (!pubkey) return; const key = inboxViewPreferenceStorageKey(pubkey, relayUrl); const handler = (event: StorageEvent) => { - if (event.key !== key) return; - setPreference(readInboxViewPreference(pubkey, relayUrl)); + if (event.key === key) { + setPreference(readInboxViewPreference(pubkey, relayUrl)); + } }; window.addEventListener("storage", handler); return () => window.removeEventListener("storage", handler); }, [pubkey, relayUrl]); - const applyRemote = React.useCallback( - ( - remote: RemoteInboxViewPreference, - ): ((previous: InboxViewPreference) => InboxViewPreference) => { - return (previous) => { - if (!pubkey) return previous; - if (remote.createdAt < lastAppliedRemoteTs.current) return previous; - if ( - remote.createdAt === lastAppliedRemoteTs.current && - remote.eventId <= lastAppliedEventId.current - ) { - return previous; - } - lastAppliedRemoteTs.current = remote.createdAt; - lastAppliedEventId.current = remote.eventId; - managerRef.current?.cancelPendingPublish(); - if (!writeInboxViewPreference(pubkey, remote.preference, relayUrl)) { - return previous; - } - return remote.preference; - }; - }, - [pubkey, relayUrl], - ); - - React.useEffect(() => { - if (!pubkey || !relayUrl) return; - let cancelled = false; - const local = readInboxViewPreference(pubkey, relayUrl); - void managerRef.current?.bootstrap(local).then((result) => { - if (cancelled) return; - if (result.action === "apply-remote") { - setPreference(applyRemote(result.data)); - } - }); - return () => { - cancelled = true; - }; - }, [pubkey, relayUrl, applyRemote]); - - React.useEffect(() => { - if (!pubkey) return; - let unsubscribe: (() => Promise) | null = null; - let cancelled = false; - void managerRef.current - ?.subscribe((remote) => { - if (!cancelled) setPreference(applyRemote(remote)); - }) - .then((dispose) => { - if (cancelled) { - void dispose(); - } else { - unsubscribe = dispose; - } - }); - return () => { - cancelled = true; - if (unsubscribe) void unsubscribe(); - }; - }, [pubkey, applyRemote]); - - React.useEffect(() => { - if (!pubkey) return; - let cancelled = false; - const unsubscribe = relayClient.subscribeToReconnects(() => { - void managerRef.current?.fetchRemotePreference().then((result) => { - if (cancelled) return; - if (result.status === "found") { - setPreference(applyRemote(result.data)); - } - const pending = managerRef.current?.getPendingPreference(); - if (pending) managerRef.current?.publishPreference(pending); - }); - }); - return () => { - cancelled = true; - unsubscribe(); - }; - }, [pubkey, applyRemote]); - const updatePreference = React.useCallback( (patch: Partial>) => { if (!pubkey) return; setPreference((previous) => { const next = { ...previous, ...patch }; - if (!writeInboxViewPreference(pubkey, next, relayUrl)) return previous; - managerRef.current?.publishPreference(next); - return next; + return writeInboxViewPreference(pubkey, next, relayUrl) + ? next + : previous; }); }, [pubkey, relayUrl], diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts index 4d1eeb2ec3..8f4cb321bd 100644 --- a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts +++ b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts @@ -1,7 +1,7 @@ /** - * Persisted remote-head watermark for durable-preference sync managers. + * Persisted remote-head watermark for sidebar-preference sync managers. * - * Each manager (sections, sort, stars, mutes, project membership, Inbox view) persists the highest + * Each manager (sections, sort, stars, mutes, project membership) 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 @@ -96,7 +96,7 @@ export type BootstrapResult = | { action: "hold" }; /** - * Shared boot policy for durable-preference sync managers. + * Shared boot policy for all sidebar-preference sync managers. * * Each manager calls this from its `bootstrap()` method, supplying its * surface-specific fetch, publish, and local-store accessors. The full diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index a3e27acbc0..4f8b7afe2b 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -43,14 +43,13 @@ export const KIND_HUDDLE_PARTICIPANT_LEFT = 48102; export const KIND_HUDDLE_ENDED = 48103; // NIP-78 application-specific data. All use kind 30078; the relay // differentiates them by d-tag ("read-state:", "channel-sections", -// "channel-mutes", "channel-stars", "channel-sort", "project-sidebar-membership", "inbox-view"). +// "channel-mutes", "channel-stars", "channel-sort", "project-sidebar-membership"). export const KIND_READ_STATE = 30078; export const KIND_CHANNEL_SECTIONS = 30078; export const KIND_CHANNEL_MUTES = 30078; export const KIND_CHANNEL_STARS = 30078; export const KIND_CHANNEL_SORT = 30078; export const KIND_PROJECT_SIDEBAR_MEMBERSHIP = 30078; -export const KIND_INBOX_VIEW = 30078; export const KIND_COMMUNITY_THEME = 30078; // NIP-33 persona/team/managed-agent projection events (d-tag keyed). Published // backend-side as secrets-stripped snapshots; the inbound sync hook subscribes