SkateSpots/src/hooks/useSyncedAppData.ts
SkateSpots 55b4dd2e86 fix(amber): stop re-decrypting on every navigation
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).
2026-09-27 13:28:36 +07:00

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,
};
}