diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7d06c4da9..b27efeaab 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -117,6 +117,7 @@ export default defineConfig({ "**/drafts-screenshots.spec.ts", "**/drafts-all-fix-screenshots.spec.ts", "**/inbox-refactor-screenshots.spec.ts", + "**/inbox-state-persistence.spec.ts", "**/buzz-theme-screenshots.spec.ts", "**/channel-sort.spec.ts", "**/identity-lost.spec.ts", diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 6257a75b7..2249fa68c 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -33,6 +33,7 @@ import { import { useUnreadChannels } from "@/features/channels/useUnreadChannels"; import { useMembershipNotifications } from "@/features/channels/useMembershipNotifications"; import { useFeedItemState } from "@/features/home/useFeedItemState"; +import { useInboxViewPreference } from "@/features/home/useInboxViewPreference"; import { useThreadFollows } from "@/features/messages/lib/useThreadFollows"; import { useHomeFeedNotifications, @@ -223,6 +224,10 @@ export function AppShell() { const { feedProfilesQuery, homeFeedQuery, notificationSettings } = useHomeFeedNotifications(identityQuery.data?.pubkey); const feedItemState = useFeedItemState(identityQuery.data?.pubkey); + const inboxViewPreference = useInboxViewPreference( + identityQuery.data?.pubkey, + communitiesHook.activeCommunity?.relayUrl, + ); const channelsQuery = useChannelsQuery(); const channels = channelsQuery.data ?? []; useReminderNotifications( @@ -716,6 +721,7 @@ export function AppShell() { topLevelUnreadChannelIds, hasSidebarUnreadProjections: true, feedItemState, + inboxViewPreference, onOpenSettings: handleOpenSettings, }} > diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index b90943617..7fde5fbfa 100644 --- a/desktop/src/app/AppShellContext.tsx +++ b/desktop/src/app/AppShellContext.tsx @@ -3,6 +3,7 @@ import type { ForcedUnreadSource } from "@/features/channels/forcedUnreadStore"; import type { ContextParentResolver } from "@/features/channels/readState/readStateManager"; import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels"; import type { FeedItemState } from "@/features/home/useFeedItemState"; +import type { InboxViewPreferenceController } from "@/features/home/useInboxViewPreference"; import type { FeedItem } from "@/shared/api/types"; import type { SettingsSection } from "@/features/settings/ui/SettingsPanels"; @@ -76,6 +77,7 @@ type AppShellContextValue = { // the mounted shell uses the split projections above. hasSidebarUnreadProjections: boolean; feedItemState: FeedItemState; + inboxViewPreference: InboxViewPreferenceController; // Open the Settings panel at the given section. Available on all surfaces // that render under AppShell (channel, home, projects, pulse, agents). // Used by config-nudge cards to deep-link to Settings → Agents. @@ -119,6 +121,12 @@ const AppShellContext = React.createContext({ undoUnread: () => {}, unreadSet: EMPTY_SET, }, + inboxViewPreference: { + filter: "all", + setFilter: () => {}, + setUnreadOnly: () => {}, + unreadOnly: false, + }, onOpenSettings: null, }); diff --git a/desktop/src/features/home/inboxViewPreference.test.mjs b/desktop/src/features/home/inboxViewPreference.test.mjs new file mode 100644 index 000000000..7e2c09581 --- /dev/null +++ b/desktop/src/features/home/inboxViewPreference.test.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +import { + DEFAULT_INBOX_VIEW_PREFERENCE, + inboxViewPreferenceStorageKey, + parseInboxViewPreference, +} from "./inboxViewPreference.ts"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +after(() => dom.window.close()); + +test("parses versioned Inbox view preferences with safe field defaults", () => { + assert.deepEqual( + parseInboxViewPreference({ + version: 1, + filter: "mention", + unreadOnly: true, + }), + { version: 1, filter: "mention", unreadOnly: true }, + ); + assert.deepEqual( + parseInboxViewPreference({ + version: 1, + filter: "unknown", + unreadOnly: "yes", + }), + DEFAULT_INBOX_VIEW_PREFERENCE, + ); + assert.equal(parseInboxViewPreference({ version: 2 }), null); +}); + +test("filter and unread-only restore from the per-user, per-relay cache", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { useInboxViewPreference } = await import( + "./useInboxViewPreference.ts" + ); + + const originalFetchEvents = relayClient.fetchEvents; + const originalSubscribeLive = relayClient.subscribeLive; + const originalSubscribeToReconnects = relayClient.subscribeToReconnects; + relayClient.fetchEvents = async () => []; + relayClient.subscribeLive = async () => async () => {}; + relayClient.subscribeToReconnects = () => () => {}; + + const pubkey = "pk-inbox-view"; + const relayUrl = "wss://relay.example"; + const key = inboxViewPreferenceStorageKey(pubkey, relayUrl); + window.localStorage.clear(); + + try { + const first = renderHook(() => useInboxViewPreference(pubkey, relayUrl)); + act(() => first.result.current.setFilter("mention")); + act(() => first.result.current.setUnreadOnly(true)); + + assert.deepEqual(JSON.parse(window.localStorage.getItem(key) ?? "null"), { + version: 1, + filter: "mention", + unreadOnly: true, + }); + first.unmount(); + + const restored = renderHook(() => useInboxViewPreference(pubkey, relayUrl)); + assert.equal(restored.result.current.filter, "mention"); + assert.equal(restored.result.current.unreadOnly, true); + restored.unmount(); + } finally { + cleanup(); + relayClient.fetchEvents = originalFetchEvents; + relayClient.subscribeLive = originalSubscribeLive; + relayClient.subscribeToReconnects = originalSubscribeToReconnects; + } +}); + +test("Inbox view cache does not bleed across relays", () => { + assert.notEqual( + inboxViewPreferenceStorageKey("pk", "wss://one.example"), + inboxViewPreferenceStorageKey("pk", "wss://two.example"), + ); +}); diff --git a/desktop/src/features/home/inboxViewPreference.ts b/desktop/src/features/home/inboxViewPreference.ts new file mode 100644 index 000000000..a82332bf0 --- /dev/null +++ b/desktop/src/features/home/inboxViewPreference.ts @@ -0,0 +1,95 @@ +import type { InboxFilter } from "@/features/home/lib/inbox"; +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; + +const STORAGE_KEY_PREFIX = "buzz-inbox-view.v1"; + +const INBOX_FILTERS: ReadonlySet = new Set([ + "all", + "project", + "mention", + "thread", + "needs_action", + "agent_activity", + "reminders", + "drafts", +]); + +/** Durable Inbox view settings scoped to one user and relay. */ +export type InboxViewPreference = { + version: 1; + filter: InboxFilter; + unreadOnly: boolean; +}; + +export const DEFAULT_INBOX_VIEW_PREFERENCE: InboxViewPreference = Object.freeze( + { + version: 1, + filter: "all", + unreadOnly: false, + }, +); + +/** Returns the local cache key for one user's Inbox view settings. */ +export function inboxViewPreferenceStorageKey( + pubkey: string, + relayUrl?: string, +): string { + if (!relayUrl) return `${STORAGE_KEY_PREFIX}:${pubkey}`; + return `${STORAGE_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} + +/** Parses the versioned local or NIP-78 Inbox preference payload. */ +export function parseInboxViewPreference( + json: unknown, +): InboxViewPreference | null { + if (typeof json !== "object" || json === null) return null; + const obj = json as Record; + if (obj.version !== 1) return null; + + return { + version: 1, + filter: + typeof obj.filter === "string" && INBOX_FILTERS.has(obj.filter) + ? (obj.filter as InboxFilter) + : DEFAULT_INBOX_VIEW_PREFERENCE.filter, + unreadOnly: + typeof obj.unreadOnly === "boolean" + ? obj.unreadOnly + : DEFAULT_INBOX_VIEW_PREFERENCE.unreadOnly, + }; +} + +/** Reads the instant/offline Inbox preference cache. */ +export function readInboxViewPreference( + pubkey: string, + relayUrl?: string, +): InboxViewPreference { + try { + const raw = window.localStorage.getItem( + inboxViewPreferenceStorageKey(pubkey, relayUrl), + ); + if (!raw) return DEFAULT_INBOX_VIEW_PREFERENCE; + return ( + parseInboxViewPreference(JSON.parse(raw)) ?? DEFAULT_INBOX_VIEW_PREFERENCE + ); + } catch { + return DEFAULT_INBOX_VIEW_PREFERENCE; + } +} + +/** Writes the instant/offline Inbox preference cache. */ +export function writeInboxViewPreference( + pubkey: string, + preference: InboxViewPreference, + relayUrl?: string, +): boolean { + try { + window.localStorage.setItem( + inboxViewPreferenceStorageKey(pubkey, relayUrl), + JSON.stringify(preference), + ); + return true; + } catch { + return false; + } +} diff --git a/desktop/src/features/home/inboxViewPreferenceSync.test.mjs b/desktop/src/features/home/inboxViewPreferenceSync.test.mjs new file mode 100644 index 000000000..dec630b29 --- /dev/null +++ b/desktop/src/features/home/inboxViewPreferenceSync.test.mjs @@ -0,0 +1,56 @@ +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 new file mode 100644 index 000000000..4d5280d04 --- /dev/null +++ b/desktop/src/features/home/inboxViewPreferenceSync.ts @@ -0,0 +1,228 @@ +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/inboxWidthPersistence.test.mjs b/desktop/src/features/home/inboxWidthPersistence.test.mjs new file mode 100644 index 000000000..885aebf9e --- /dev/null +++ b/desktop/src/features/home/inboxWidthPersistence.test.mjs @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +after(() => dom.window.close()); + +test("Inbox list and auxiliary-pane widths restore from localStorage", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { useResizableInboxListWidth } = await import( + "./useResizableInboxListWidth.ts" + ); + const { useThreadPanelWidth } = await import( + "@/shared/hooks/useThreadPanelWidth" + ); + + window.localStorage.clear(); + window.sessionStorage.clear(); + window.localStorage.setItem("buzz.desktop.home-inbox-list-width", "430"); + window.localStorage.setItem("buzz.desktop.thread-panel-width", "460"); + window.sessionStorage.setItem("buzz.desktop.home-inbox-list-width", "310"); + window.sessionStorage.setItem("buzz.desktop.thread-panel-width", "320"); + + try { + const inboxList = renderHook(() => useResizableInboxListWidth()); + const auxiliaryPane = renderHook(() => useThreadPanelWidth(1_200)); + + assert.equal(inboxList.result.current.inboxListWidthPx, 430); + assert.equal(auxiliaryPane.result.current.widthPx, 460); + + act(() => { + inboxList.result.current.handleInboxListResizeStart({ + clientX: 100, + preventDefault: () => {}, + }); + }); + const inboxMove = new window.Event("pointermove"); + Object.defineProperty(inboxMove, "clientX", { value: 150 }); + act(() => { + window.dispatchEvent(inboxMove); + window.dispatchEvent(new window.Event("pointerup")); + }); + act(() => { + auxiliaryPane.result.current.onResizeStart({ + clientX: 500, + preventDefault: () => {}, + }); + }); + const auxiliaryMove = new window.Event("pointermove"); + Object.defineProperty(auxiliaryMove, "clientX", { value: 450 }); + act(() => { + window.dispatchEvent(auxiliaryMove); + window.dispatchEvent(new window.Event("pointerup")); + }); + + assert.equal( + window.localStorage.getItem("buzz.desktop.home-inbox-list-width"), + "480", + ); + assert.equal( + window.localStorage.getItem("buzz.desktop.thread-panel-width"), + "510", + ); + inboxList.unmount(); + auxiliaryPane.unmount(); + + const restoredInboxList = renderHook(() => useResizableInboxListWidth()); + const restoredAuxiliaryPane = renderHook(() => useThreadPanelWidth(1_200)); + assert.equal(restoredInboxList.result.current.inboxListWidthPx, 480); + assert.equal(restoredAuxiliaryPane.result.current.widthPx, 510); + restoredInboxList.unmount(); + restoredAuxiliaryPane.unmount(); + } finally { + cleanup(); + } +}); diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 893b3c309..8ce83bc6b 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -109,11 +109,10 @@ export function HomeView({ const isNarrowHomeViewport = homeInboxWidthPx > 0 && homeInboxWidthPx < INBOX_SINGLE_COLUMN_BREAKPOINT_PX; - const [filter, setFilter] = React.useState("all"); - const [unreadOnly, setUnreadOnly] = React.useState(false); + const { filter, setFilter, setUnreadOnly, unreadOnly } = + useAppShell().inboxViewPreference; // Explicit selections are mirrored to the URL (`?item=`), so back/forward - // restores the detail pane each history entry was showing and reloads - // restore it from the URL. Default/automatic selection stays local-only — + // and reload restore them. Default/automatic selection stays local-only — // background data loads must never trigger navigations. const { applyPatch: applyInboxSearchPatch, values: inboxSearchValues } = useHistorySearchState(INBOX_SEARCH_KEYS); @@ -573,6 +572,7 @@ export function HomeView({ selectedConversationId, setSelectedDraftKey, setSelectedReminderId, + setFilter, unreadOnly, ], ); diff --git a/desktop/src/features/home/useInboxViewPreference.ts b/desktop/src/features/home/useInboxViewPreference.ts new file mode 100644 index 000000000..5872f14ad --- /dev/null +++ b/desktop/src/features/home/useInboxViewPreference.ts @@ -0,0 +1,178 @@ +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, + readInboxViewPreference, + writeInboxViewPreference, + type InboxViewPreference, +} from "./inboxViewPreference"; +import { + InboxViewPreferenceSyncManager, + type RemoteInboxViewPreference, +} from "./inboxViewPreferenceSync"; + +/** State and mutations exposed to the mounted Inbox surface. */ +export type InboxViewPreferenceController = { + filter: InboxFilter; + setFilter: (filter: InboxFilter) => void; + setUnreadOnly: (unreadOnly: boolean) => void; + unreadOnly: boolean; +}; + +/** + * Persistent Inbox view preferences scoped by pubkey and relay. localStorage + * is the instant/offline cache; encrypted NIP-78 is the cross-client source. + */ +export function useInboxViewPreference( + pubkey: string | undefined, + relayUrl?: string, +): InboxViewPreferenceController { + const [preference, setPreference] = React.useState(() => + pubkey + ? 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; + }; + }, [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)); + }; + 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; + }); + }, + [pubkey, relayUrl], + ); + + const setFilter = React.useCallback( + (filter: InboxFilter) => updatePreference({ filter }), + [updatePreference], + ); + const setUnreadOnly = React.useCallback( + (unreadOnly: boolean) => updatePreference({ unreadOnly }), + [updatePreference], + ); + + return { + filter: preference.filter, + setFilter, + setUnreadOnly, + unreadOnly: preference.unreadOnly, + }; +} diff --git a/desktop/src/features/home/useResizableInboxListWidth.ts b/desktop/src/features/home/useResizableInboxListWidth.ts index 2c896a091..1e0c9c715 100644 --- a/desktop/src/features/home/useResizableInboxListWidth.ts +++ b/desktop/src/features/home/useResizableInboxListWidth.ts @@ -4,7 +4,7 @@ const INBOX_LIST_DEFAULT_WIDTH_PX = 365; export const INBOX_COLUMN_MIN_WIDTH_PX = 300; export const INBOX_SINGLE_COLUMN_BREAKPOINT_PX = INBOX_COLUMN_MIN_WIDTH_PX * 2; const INBOX_LIST_MAX_WIDTH_PX = 520; -const INBOX_LIST_WIDTH_SESSION_KEY = "buzz.desktop.home-inbox-list-width"; +const INBOX_LIST_WIDTH_STORAGE_KEY = "buzz.desktop.home-inbox-list-width"; function clampInboxListWidth(width: number): number { return Math.max( @@ -19,7 +19,7 @@ function getInitialInboxListWidth(): number { } try { - const raw = window.sessionStorage.getItem(INBOX_LIST_WIDTH_SESSION_KEY); + const raw = window.localStorage.getItem(INBOX_LIST_WIDTH_STORAGE_KEY); if (!raw) { return INBOX_LIST_DEFAULT_WIDTH_PX; } @@ -46,8 +46,8 @@ export function useResizableInboxListWidth() { } try { - window.sessionStorage.setItem( - INBOX_LIST_WIDTH_SESSION_KEY, + window.localStorage.setItem( + INBOX_LIST_WIDTH_STORAGE_KEY, String(inboxListWidthPx), ); } catch { diff --git a/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts b/desktop/src/features/sidebar/lib/sidebarSyncWatermark.ts index d81b188ad..060edba46 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 sidebar-preference sync managers. + * Persisted remote-head watermark for durable-preference sync managers. * - * Each manager (sections, sort, stars, mutes) persists the highest + * Each manager (sections, sort, stars, mutes, Inbox view) 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 all four sidebar-preference sync managers. + * Shared boot policy for durable-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 b98ba37d7..08eac2452 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -42,12 +42,13 @@ export const KIND_HUDDLE_PARTICIPANT_JOINED = 48101; export const KIND_HUDDLE_PARTICIPANT_LEFT = 48102; export const KIND_HUDDLE_ENDED = 48103; // NIP-78 application-specific data. All use kind 30078; the relay -// differentiates them by d-tag ("read-state:", "channel-sections", "channel-mutes", "channel-stars", "channel-sort"). +// differentiates them by d-tag ("read-state:", "channel-sections", "channel-mutes", "channel-stars", "channel-sort", "inbox-view"). 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_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 diff --git a/desktop/src/shared/hooks/useThreadPanelWidth.ts b/desktop/src/shared/hooks/useThreadPanelWidth.ts index 20891b538..970cbed46 100644 --- a/desktop/src/shared/hooks/useThreadPanelWidth.ts +++ b/desktop/src/shared/hooks/useThreadPanelWidth.ts @@ -5,7 +5,7 @@ import { clampAuxiliaryPanelWidth, } from "@/shared/layout/AuxiliaryPanel"; -const THREAD_PANEL_WIDTH_SESSION_KEY = "buzz.desktop.thread-panel-width"; +const THREAD_PANEL_WIDTH_STORAGE_KEY = "buzz.desktop.thread-panel-width"; function getViewportWidth(): number { return typeof window === "undefined" ? 0 : window.innerWidth; @@ -29,7 +29,7 @@ function getInitialThreadPanelWidth(): number { } try { - const raw = window.sessionStorage.getItem(THREAD_PANEL_WIDTH_SESSION_KEY); + const raw = window.localStorage.getItem(THREAD_PANEL_WIDTH_STORAGE_KEY); if (!raw) { return AUXILIARY_PANEL_DEFAULT_WIDTH_PX; } @@ -60,12 +60,12 @@ export function useThreadPanelWidth(availableWidthPx?: number) { } try { - window.sessionStorage.setItem( - THREAD_PANEL_WIDTH_SESSION_KEY, + window.localStorage.setItem( + THREAD_PANEL_WIDTH_STORAGE_KEY, String(widthPx), ); } catch { - // Ignore storage failures and keep in-memory width for this session. + // Ignore storage failures and keep the chosen width in memory. } }, [widthPx]); diff --git a/desktop/tests/e2e/inbox-state-persistence.spec.ts b/desktop/tests/e2e/inbox-state-persistence.spec.ts new file mode 100644 index 000000000..7cb7bfdce --- /dev/null +++ b/desktop/tests/e2e/inbox-state-persistence.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +const MOCK_PUBKEY = "deadbeef".repeat(8); +const MOCK_RELAY_ENCODED = encodeURIComponent("ws://localhost:3000"); +const VIEW_STORAGE_KEY = `buzz-inbox-view.v1:${MOCK_PUBKEY}:${MOCK_RELAY_ENCODED}`; + +async function expectStickyInboxState(page: Page) { + await expect(page.getByTestId("inbox-filter-trigger")).toHaveText("Mentions"); + await page.getByTestId("inbox-options-trigger").click(); + await expect(page.getByTestId("inbox-unread-only-toggle")).toBeChecked(); +} + +test("Inbox filter and unread-only survive navigation and reload", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + + await page.getByTestId("inbox-filter-trigger").click(); + await page.getByRole("menuitemradio", { name: "Mentions" }).click(); + await page.getByTestId("inbox-options-trigger").click(); + await page.getByTestId("inbox-unread-only-toggle").click(); + await expect(page.getByTestId("inbox-unread-only-toggle")).toBeChecked(); + await page.keyboard.press("Escape"); + + await expect + .poll(() => + page.evaluate((key) => { + return JSON.parse(window.localStorage.getItem(key) ?? "null"); + }, VIEW_STORAGE_KEY), + ) + .toEqual({ version: 1, filter: "mention", unreadOnly: true }); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.getByRole("button", { name: "Inbox", exact: true }).click(); + await expectStickyInboxState(page); + await page.keyboard.press("Escape"); + + await page.reload(); + await expectStickyInboxState(page); +});