diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 0f7c851643..80a24a018e 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -15,6 +15,7 @@ import { formatInboxFullTimestamp, getInboxItemConversationId, } from "@/features/home/lib/inbox"; +import { usePersistentBoolean } from "@/features/home/usePersistentBoolean"; import { useInboxSelectionAnchor } from "@/features/home/useInboxSelectionAnchor"; import { useOwnedAgentPubkeys } from "@/features/home/useOwnedAgentPubkeys"; import { @@ -107,7 +108,10 @@ export function HomeView({ homeInboxWidthPx > 0 && homeInboxWidthPx < INBOX_SINGLE_COLUMN_BREAKPOINT_PX; const [filter, setFilter] = React.useState("all"); - const [unreadOnly, setUnreadOnly] = React.useState(false); + const [unreadOnly, setUnreadOnly] = usePersistentBoolean( + "buzz-home-inbox-unread-only.v1", + false, + ); // 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 — diff --git a/desktop/src/features/home/usePersistentBoolean.test.mjs b/desktop/src/features/home/usePersistentBoolean.test.mjs new file mode 100644 index 0000000000..41b75fe4f7 --- /dev/null +++ b/desktop/src/features/home/usePersistentBoolean.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +/** + * Tests for the localStorage-backed unread-only preference. + * + * These tests verify the storage read/write logic that backs + * `usePersistentBoolean` — the hook powering the persisted + * "Show unread only" toggle in the Inbox. + */ + +const STORAGE_KEY = "buzz-home-inbox-unread-only.v1"; + +// Minimal mock localStorage for Node.js test environment +const store = new Map(); +const mockLocalStorage = { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, String(value)), + removeItem: (key) => store.delete(key), + clear: () => store.clear(), +}; + +// Inject mock into globalThis so the hook's storage access works +globalThis.localStorage = mockLocalStorage; + +function readStoredBoolean(key) { + const raw = globalThis.localStorage.getItem(key); + if (raw === null) return false; + return raw === "true"; +} + +function writeStoredBoolean(key, value) { + globalThis.localStorage.setItem(key, String(value)); +} + +test("default is false when no stored value exists", () => { + globalThis.localStorage.removeItem(STORAGE_KEY); + assert.equal(readStoredBoolean(STORAGE_KEY), false); +}); + +test("stored true is restored as true", () => { + writeStoredBoolean(STORAGE_KEY, true); + assert.equal(readStoredBoolean(STORAGE_KEY), true); +}); + +test("stored false is restored as false", () => { + writeStoredBoolean(STORAGE_KEY, false); + assert.equal(readStoredBoolean(STORAGE_KEY), false); +}); + +test("writing true then reading returns true", () => { + globalThis.localStorage.removeItem(STORAGE_KEY); + writeStoredBoolean(STORAGE_KEY, true); + assert.equal(readStoredBoolean(STORAGE_KEY), true); +}); + +test("writing false then reading returns false", () => { + globalThis.localStorage.removeItem(STORAGE_KEY); + writeStoredBoolean(STORAGE_KEY, true); + writeStoredBoolean(STORAGE_KEY, false); + assert.equal(readStoredBoolean(STORAGE_KEY), false); +}); + +test("toggling from true to false persists the new value", () => { + writeStoredBoolean(STORAGE_KEY, true); + assert.equal(readStoredBoolean(STORAGE_KEY), true); + writeStoredBoolean(STORAGE_KEY, false); + assert.equal(readStoredBoolean(STORAGE_KEY), false); +}); + +test("storage key uses versioned prefix matching existing buzz-home patterns", () => { + assert.ok( + STORAGE_KEY.startsWith("buzz-home-"), + "Storage key should follow the buzz-home-* prefix convention", + ); + assert.ok( + STORAGE_KEY.includes(".v1"), + "Storage key should include a version suffix for future schema changes", + ); +}); diff --git a/desktop/src/features/home/usePersistentBoolean.ts b/desktop/src/features/home/usePersistentBoolean.ts new file mode 100644 index 0000000000..8e689fbe05 --- /dev/null +++ b/desktop/src/features/home/usePersistentBoolean.ts @@ -0,0 +1,40 @@ +import * as React from "react"; + +/** + * A boolean state hook that persists its value to `localStorage`. + * + * - On first mount, the stored value is read and used as the initial state. + * If no stored value exists (or parsing fails), `defaultValue` is used. + * - Whenever the value changes, the new value is written back to storage. + * + * This follows the same localStorage pattern as `useFeedItemState` — a + * versioned key prefix, defensive `try/catch` around storage access, and + * SSR-safe guards (`typeof window`). + */ +export function usePersistentBoolean( + storageKey: string, + defaultValue: boolean, +): [boolean, React.Dispatch>] { + const [value, setValue] = React.useState(() => { + if (typeof window === "undefined") return defaultValue; + try { + const raw = window.localStorage.getItem(storageKey); + if (raw === null) return defaultValue; + return raw === "true"; + } catch { + return defaultValue; + } + }); + + React.useEffect(() => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(storageKey, String(value)); + } catch { + // Storage may be unavailable (private browsing, quota, etc.) — + // silently degrade to in-memory-only persistence. + } + }, [storageKey, value]); + + return [value, setValue]; +}