mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user