fix(desktop): keep Inbox view preferences local

Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz>
Signed-off-by: loganj <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-08-20 11:50:40 -04:00
committed by loganj
parent 2bd3c670a9
commit 26f9f02ba2
6 changed files with 31 additions and 401 deletions

View File

@@ -54,9 +54,19 @@ test("filter and unread-only restore from the per-user, per-relay cache", async
const originalFetchEvents = relayClient.fetchEvents;
const originalSubscribeLive = relayClient.subscribeLive;
const originalSubscribeToReconnects = relayClient.subscribeToReconnects;
relayClient.fetchEvents = async () => [];
relayClient.subscribeLive = async () => async () => {};
relayClient.subscribeToReconnects = () => () => {};
let relayOperations = 0;
relayClient.fetchEvents = async () => {
relayOperations += 1;
return [];
};
relayClient.subscribeLive = async () => {
relayOperations += 1;
return async () => {};
};
relayClient.subscribeToReconnects = () => {
relayOperations += 1;
return () => {};
};
const pubkey = "pk-inbox-view";
const relayUrl = "wss://relay.example";
@@ -79,6 +89,7 @@ test("filter and unread-only restore from the per-user, per-relay cache", async
assert.equal(restored.result.current.filter, "mention");
assert.equal(restored.result.current.unreadOnly, true);
restored.unmount();
assert.equal(relayOperations, 0);
} finally {
cleanup();
relayClient.fetchEvents = originalFetchEvents;

View File

@@ -1,56 +0,0 @@
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();
}
});

View File

@@ -1,228 +0,0 @@
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;
}
}

View File

@@ -1,7 +1,6 @@
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,
@@ -9,10 +8,6 @@ import {
writeInboxViewPreference,
type InboxViewPreference,
} from "./inboxViewPreference";
import {
InboxViewPreferenceSyncManager,
type RemoteInboxViewPreference,
} from "./inboxViewPreferenceSync";
/** State and mutations exposed to the mounted Inbox surface. */
export type InboxViewPreferenceController = {
@@ -23,8 +18,8 @@ export type InboxViewPreferenceController = {
};
/**
* Persistent Inbox view preferences scoped by pubkey and relay. localStorage
* is the instant/offline cache; encrypted NIP-78 is the cross-client source.
* Device-local Inbox view preferences, scoped by signed-in user and relay.
* They survive navigation and restart without being sent to the relay.
*/
export function useInboxViewPreference(
pubkey: string | undefined,
@@ -35,126 +30,35 @@ export function useInboxViewPreference(
? 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;
};
setPreference(
pubkey
? readInboxViewPreference(pubkey, relayUrl)
: DEFAULT_INBOX_VIEW_PREFERENCE,
);
}, [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));
if (event.key === key) {
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;
return writeInboxViewPreference(pubkey, next, relayUrl)
? next
: previous;
});
},
[pubkey, relayUrl],

View File

@@ -1,7 +1,7 @@
/**
* Persisted remote-head watermark for durable-preference sync managers.
* Persisted remote-head watermark for sidebar-preference sync managers.
*
* Each manager (sections, sort, stars, mutes, project membership, Inbox view) persists the highest
* Each manager (sections, sort, stars, mutes, project membership) 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 durable-preference sync managers.
* Shared boot policy for all sidebar-preference sync managers.
*
* Each manager calls this from its `bootstrap()` method, supplying its
* surface-specific fetch, publish, and local-store accessors. The full

View File

@@ -43,14 +43,13 @@ 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", "project-sidebar-membership", "inbox-view").
// "channel-mutes", "channel-stars", "channel-sort", "project-sidebar-membership").
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_PROJECT_SIDEBAR_MEMBERSHIP = 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