Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion desktop/src/features/home/ui/HomeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -107,7 +108,10 @@ export function HomeView({
homeInboxWidthPx > 0 &&
homeInboxWidthPx < INBOX_SINGLE_COLUMN_BREAKPOINT_PX;
const [filter, setFilter] = React.useState<InboxFilter>("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 —
Expand Down
80 changes: 80 additions & 0 deletions desktop/src/features/home/usePersistentBoolean.test.mjs
Original file line number Diff line number Diff line change
@@ -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",
);
});
40 changes: 40 additions & 0 deletions desktop/src/features/home/usePersistentBoolean.ts
Original file line number Diff line number Diff line change
@@ -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<React.SetStateAction<boolean>>] {
const [value, setValue] = React.useState<boolean>(() => {
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];
}