fix(desktop): persist Inbox view settings

Signed-off-by: loganj <loganj@squareup.com>
Signed-off-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz>
This commit is contained in:
Brother Darryl
2026-08-17 14:12:58 -04:00
parent 076081bfc6
commit 64d16a2ee0
15 changed files with 819 additions and 17 deletions
+1
View File
@@ -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",
+6
View File
@@ -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,
}}
>
+8
View File
@@ -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<AppShellContextValue>({
undoUnread: () => {},
unreadSet: EMPTY_SET,
},
inboxViewPreference: {
filter: "all",
setFilter: () => {},
setUnreadOnly: () => {},
unreadOnly: false,
},
onOpenSettings: null,
});
@@ -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("<!doctype html><html><body></body></html>", {
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"),
);
});
@@ -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<string> = 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<string, unknown>;
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;
}
}
@@ -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();
}
});
@@ -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<RemoteInboxViewPreference | null> {
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<RemoteInboxViewPreference>
> {
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<InboxViewPreference> {
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<void> {
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<void>> {
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;
}
}
@@ -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("<!doctype html><html><body></body></html>", {
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();
}
});
+4 -4
View File
@@ -109,11 +109,10 @@ export function HomeView({
const isNarrowHomeViewport =
homeInboxWidthPx > 0 &&
homeInboxWidthPx < INBOX_SINGLE_COLUMN_BREAKPOINT_PX;
const [filter, setFilter] = React.useState<InboxFilter>("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,
],
);
@@ -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<InboxViewPreference>(() =>
pubkey
? readInboxViewPreference(pubkey, relayUrl)
: DEFAULT_INBOX_VIEW_PREFERENCE,
);
const managerRef = React.useRef<InboxViewPreferenceSyncManager | null>(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<void>) | 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<Pick<InboxViewPreference, "filter" | "unreadOnly">>) => {
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,
};
}
@@ -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 {
@@ -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<T> =
| { 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
+2 -1
View File
@@ -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:<slotId>", "channel-sections", "channel-mutes", "channel-stars", "channel-sort").
// differentiates them by d-tag ("read-state:<slotId>", "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
@@ -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]);
@@ -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);
});