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 = (remote: T, patch: T) => T; interface UseSyncedAppDataOptions { /** 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; /** * 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; /** * Opt in to the encrypted relay copy while using an INTERACTIVE signer * (Amber/NIP-55). Off by default: for an interactive signer the blob is * **local-only** — no decrypt on mount and no publish on patch — because * every decrypt/encrypt/sign is a user-facing Amber approval screen. Only set * this for a feature that truly cannot work from the localStorage copy. */ interactiveNetwork?: boolean; } const APP_DATA_KIND = 30078; // NIP-78 application-specific data export function useSyncedAppData(options: UseSyncedAppDataOptions) { const { dTag, alt, empty, merge, enabled = true, debounceMs = 4000, interactiveNetwork = false, } = 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); // For an interactive signer the relay copy is OPT-IN. `staleTime: Infinity` // only stops *refetching* — it never stopped the FIRST decrypt, so the app // still popped Amber once per blob (twice: read-state + preferences, and // racing mounts could multiply that). The localStorage copy is the session // source of truth, so we skip the network entirely unless a caller opts in. const canUseNetwork = !interactive || interactiveNetwork; const queryKey = useMemo( () => ['synced-app-data', dTag, user?.pubkey] as const, [dTag, user?.pubkey], ); // ── Fetch + decrypt the single event ────────────────────────────────────── const { data, isLoading } = useQuery({ queryKey, // Interactive signer → no decrypt unless the caller opted in (see above). enabled: enabled && canSync && canUseNetwork, // 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 => { 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 => { 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(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(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(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(null); const timerRef = useRef | 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)) => { // Local-only for an interactive signer: publishing costs an Amber // encrypt + sign, and reading the remote copy first costs a decrypt. if (!canSync || !canUseNetwork) 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(queryKey, merged); // Queue the debounced publish. pendingRef.current = merged; if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => { void publishNow(); }, debounceMs); }, [canSync, canUseNetwork, 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]); // Memoize the returned object: it feeds `useCallback`/`useEffect` deps in // consumers (e.g. useReadState → mark* → page effects). A fresh object literal // every render made those identities churn, which turned "mark read on mount" // effects into an infinite render loop. return useMemo( () => ({ /** 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, }), [data, isLoading, canSync, patch, publishNow], ); }