mirror of
https://github.com/block/buzz.git
synced 2026-11-13 04:41:12 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user