Root cause: Layout is NOT a router layout — ~62 pages wrap themselves in <Layout>, so its chrome remounts on every route change. The header's NotificationBell -> useNotificationsRead -> useReadState -> useSyncedAppData re-decrypts an encrypted kind-30078 event on mount; with Amber (NIP-55) every nip44.decrypt opens the signer app, so it popped on each menu click. Fix: for interactive signers, decrypt once per session and serve remounts from cache (staleTime/gcTime Infinity, refetchOnMount/refetchOnReconnect false) in useSyncedAppData and useStash. Non-interactive signers refetch as before. Follow-up (noted in AGENTS.md): hoist Layout into the router so the chrome mounts once — removes the whole remount class (and SasquatchGuide state resets).
226 lines
9.3 KiB
TypeScript
226 lines
9.3 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useNostr } from '@nostrify/react';
|
|
import { useNostrLogin } from '@nostrify/react/login';
|
|
import { useCurrentUser } from './useCurrentUser';
|
|
import { useNostrPublish } from './useNostrPublish';
|
|
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
|
|
|
|
/**
|
|
* useSyncedAppData — a small engine for storing a blob of app state as ONE
|
|
* encrypted, addressable NIP-78 (`kind-30078`) event, keyed by a `d` tag, and
|
|
* synced across the logged-in user's devices.
|
|
*
|
|
* It exists so features like read-state and preferences don't each re-implement
|
|
* the encrypt-to-self + replaceable-event + clobber-guard dance that `useStash`
|
|
* pioneered. The design mirrors `useStash` deliberately (same NIP-44 posture,
|
|
* same "re-fetch fresh before write" safety), with two additions the Stash
|
|
* doesn't need:
|
|
*
|
|
* 1. **Deep field-merge on write.** These blobs are edited from MULTIPLE
|
|
* devices (phone marks the bell read; laptop marks a DM read). A blind
|
|
* overwrite by last-write-wins would let one device wipe the other's
|
|
* fields. So before publishing we re-fetch the freshest remote copy and
|
|
* merge the caller's patch INTO it (see `mergeStrategy`), rather than
|
|
* replacing it wholesale.
|
|
*
|
|
* 2. **Debounced publish.** Read-state changes fire often (every glance at the
|
|
* bell). We coalesce rapid writes into at most one relay publish per
|
|
* `debounceMs`, and flush on unload, so we never spam relays.
|
|
*
|
|
* ANON / NO-NIP44 USERS: this engine simply reports `data: null` and its
|
|
* `patch()` becomes a no-op. Callers layer a localStorage fallback on top so
|
|
* logged-out (or non-encrypting) users keep the exact behavior they had before.
|
|
*/
|
|
|
|
/** How to combine a locally-produced patch with the freshest remote object. */
|
|
export type MergeStrategy<T> = (remote: T, patch: T) => T;
|
|
|
|
interface UseSyncedAppDataOptions<T> {
|
|
/** Stable `d` tag, e.g. `"skatespots:read-state"`. One event per tag. */
|
|
dTag: string;
|
|
/** Human-readable NIP-31 alt describing the event. */
|
|
alt: string;
|
|
/** Value returned before anything loads / when unavailable. */
|
|
empty: T;
|
|
/** Combine the freshest remote copy with the caller's patch. */
|
|
merge: MergeStrategy<T>;
|
|
/**
|
|
* When false, the event is NOT fetched/decrypted. Useful when the hook is
|
|
* mounted only for its writer API. Defaults to true.
|
|
*/
|
|
enabled?: boolean;
|
|
/** Coalesce writes into at most one publish per this many ms (default 4000). */
|
|
debounceMs?: number;
|
|
}
|
|
|
|
const APP_DATA_KIND = 30078; // NIP-78 application-specific data
|
|
|
|
export function useSyncedAppData<T>(options: UseSyncedAppDataOptions<T>) {
|
|
const { dTag, alt, empty, merge, enabled = true, debounceMs = 4000 } = options;
|
|
|
|
const { nostr } = useNostr();
|
|
const { user } = useCurrentUser();
|
|
const { logins } = useNostrLogin();
|
|
const queryClient = useQueryClient();
|
|
const { mutateAsync: createEvent } = useNostrPublish();
|
|
|
|
const hasNip44 = useMemo(() => checkNip44(user, logins), [user, logins]);
|
|
const canSync = !!user?.pubkey && hasNip44;
|
|
|
|
// Amber (NIP-55) opens the signer app on EVERY nip44 decrypt. This engine is
|
|
// used by chrome that is mounted per page (e.g. the notification bell's read
|
|
// state), so re-decrypting on every navigation spams Amber popups. For an
|
|
// interactive signer, decrypt once per session and keep it cached — remounts
|
|
// read the cache instead of prompting. (Non-interactive signers are cheap, so
|
|
// they keep normal refetching.)
|
|
const interactive = isInteractiveSigner(user, logins);
|
|
|
|
const queryKey = useMemo(
|
|
() => ['synced-app-data', dTag, user?.pubkey] as const,
|
|
[dTag, user?.pubkey],
|
|
);
|
|
|
|
// ── Fetch + decrypt the single event ──────────────────────────────────────
|
|
const { data, isLoading } = useQuery({
|
|
queryKey,
|
|
enabled: enabled && canSync,
|
|
// For interactive signers: never consider it stale and never evict it from
|
|
// memory during the session → one decrypt, then cache hits.
|
|
staleTime: interactive ? Infinity : 60_000,
|
|
gcTime: interactive ? Infinity : undefined,
|
|
refetchOnMount: !interactive,
|
|
refetchOnWindowFocus: false,
|
|
refetchOnReconnect: !interactive,
|
|
queryFn: async ({ signal }): Promise<T> => {
|
|
if (!user?.pubkey || !hasNip44) return empty;
|
|
|
|
const combined = AbortSignal.any([signal, AbortSignal.timeout(10_000)]);
|
|
const events = await nostr.query(
|
|
[{ kinds: [APP_DATA_KIND], authors: [user.pubkey], '#d': [dTag], limit: 1 }],
|
|
{ signal: combined },
|
|
);
|
|
if (events.length === 0) return empty;
|
|
|
|
try {
|
|
let dec: typeof user.signer.nip44;
|
|
try { dec = user.signer?.nip44; } catch { return empty; }
|
|
if (!dec) return empty;
|
|
const plaintext = await dec.decrypt(user.pubkey, events[0].content);
|
|
return JSON.parse(plaintext) as T;
|
|
} catch (err) {
|
|
console.error(`[SyncedAppData:${dTag}] decrypt failed:`, err);
|
|
return empty;
|
|
}
|
|
},
|
|
});
|
|
|
|
// ── Fresh re-fetch right before a write (clobber-guard, mirrors useStash) ──
|
|
const fetchLatest = useCallback(async (): Promise<T> => {
|
|
if (!user?.pubkey || !hasNip44) return empty;
|
|
|
|
let dec: typeof user.signer.nip44;
|
|
try { dec = user.signer?.nip44; } catch { dec = undefined; }
|
|
if (!dec) return queryClient.getQueryData<T>(queryKey) ?? data ?? empty;
|
|
|
|
try {
|
|
const events = await nostr.query(
|
|
[{ kinds: [APP_DATA_KIND], authors: [user.pubkey], '#d': [dTag], limit: 1 }],
|
|
{ signal: AbortSignal.timeout(10_000) },
|
|
);
|
|
if (events.length === 0) {
|
|
return queryClient.getQueryData<T>(queryKey) ?? data ?? empty;
|
|
}
|
|
const plaintext = await dec.decrypt(user.pubkey, events[0].content);
|
|
return JSON.parse(plaintext) as T;
|
|
} catch (err) {
|
|
// Never wipe on failure — prefer cached over empty.
|
|
console.error(`[SyncedAppData:${dTag}] refetch-before-write failed:`, err);
|
|
return queryClient.getQueryData<T>(queryKey) ?? data ?? empty;
|
|
}
|
|
}, [user, hasNip44, nostr, queryClient, queryKey, data, empty, dTag]);
|
|
|
|
// ── Debounced publisher ────────────────────────────────────────────────────
|
|
// We keep the newest desired object in a ref and flush it after a quiet
|
|
// period. Multiple rapid patches collapse into one publish.
|
|
const pendingRef = useRef<T | null>(null);
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const publishNow = useCallback(async () => {
|
|
if (timerRef.current) {
|
|
clearTimeout(timerRef.current);
|
|
timerRef.current = null;
|
|
}
|
|
const toPublish = pendingRef.current;
|
|
pendingRef.current = null;
|
|
if (toPublish == null || !user?.pubkey || !hasNip44) return;
|
|
|
|
let enc: typeof user.signer.nip44;
|
|
try { enc = user.signer?.nip44; } catch { return; }
|
|
if (!enc) return;
|
|
|
|
try {
|
|
const ciphertext = await enc.encrypt(user.pubkey, JSON.stringify(toPublish));
|
|
await createEvent({
|
|
kind: APP_DATA_KIND,
|
|
content: ciphertext,
|
|
tags: [['d', dTag], ['alt', alt]],
|
|
});
|
|
} catch (err) {
|
|
console.error(`[SyncedAppData:${dTag}] publish failed:`, err);
|
|
}
|
|
}, [user, hasNip44, createEvent, dTag, alt]);
|
|
|
|
/**
|
|
* Apply a patch: merge it into the freshest remote copy, update the local
|
|
* cache immediately (so the UI is instant), and schedule a debounced publish.
|
|
* No-op for anon / non-encrypting users.
|
|
*/
|
|
const patch = useCallback(
|
|
async (producePatch: T | ((current: T) => T)) => {
|
|
if (!canSync) return;
|
|
|
|
const latest = await fetchLatest();
|
|
const asPatch =
|
|
typeof producePatch === 'function'
|
|
? (producePatch as (c: T) => T)(latest)
|
|
: producePatch;
|
|
const merged = merge(latest, asPatch);
|
|
|
|
// Optimistic local cache — UI reflects it without waiting for the relay.
|
|
queryClient.setQueryData<T>(queryKey, merged);
|
|
|
|
// Queue the debounced publish.
|
|
pendingRef.current = merged;
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
timerRef.current = setTimeout(() => { void publishNow(); }, debounceMs);
|
|
},
|
|
[canSync, fetchLatest, merge, queryClient, queryKey, publishNow, debounceMs],
|
|
);
|
|
|
|
// Flush any pending write when the tab is hidden/closed so a quick glance +
|
|
// navigation away still persists.
|
|
useEffect(() => {
|
|
const flush = () => { if (pendingRef.current != null) void publishNow(); };
|
|
window.addEventListener('pagehide', flush);
|
|
window.addEventListener('beforeunload', flush);
|
|
return () => {
|
|
window.removeEventListener('pagehide', flush);
|
|
window.removeEventListener('beforeunload', flush);
|
|
// Flush on unmount too.
|
|
if (pendingRef.current != null) void publishNow();
|
|
};
|
|
}, [publishNow]);
|
|
|
|
return {
|
|
/** Decrypted synced object, or `null` while loading / when unavailable. */
|
|
data: (data ?? null) as T | null,
|
|
isLoading,
|
|
/** True when the user can actually sync (logged in + NIP-44). */
|
|
canSync,
|
|
/** Merge a patch in and schedule a debounced, clobber-safe publish. */
|
|
patch,
|
|
/** Force an immediate publish of any queued write. */
|
|
flush: publishNow,
|
|
};
|
|
}
|