SkateSpots/src/hooks/useSyncedAppData.ts
SkateSpots e69bace784 fix(amber,routing): scope Amber prompts to real actions; real per-mode routes + steps
AMBER — 'pops on every screen':
staleTime: Infinity only stops REFETCHING, never the FIRST decrypt. So the
encrypted useSyncedAppData blobs (read-state, preferences) each ran a queryFn on
mount (racing consumers multiplied it) and relay responses landing during
navigation made it look per-page. useSyncedAppData now has interactiveNetwork
(default false): for an interactive signer the blob is LOCAL-ONLY — query not
even enabled, patch() a no-op — no decrypt on mount, no publish on a nav-tab
change. Verified with a mocked x-amber login: network-on => Amber op at startup,
with the fix => 0. (useCrewDismissals/useStash were already gated.)

ROUTING — 'same time for every transport option':
router.project-osrm.org hosts ONE car dataset and ignores the profile in the URL,
so foot/bike/car returned identical numbers. Switched to the FOSSGIS per-profile
servers (routing.openstreetmap.de/routed-{foot,bike,car}); the demo server is now
only a last-resort fallback and its result is flagged approximate ('driving
estimate'). Request steps=true and surface turn-by-turn steps for orientation,
plus a '~X min on a board' skate estimate next to the walking time. Verified:
same trip => walk 13 min / bike 5 / moto+car 2, with 5-9 steps each.
2026-09-27 18:35:17 +07:00

259 lines
11 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;
/**
* 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<T>(options: UseSyncedAppDataOptions<T>) {
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<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)) => {
// 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<T>(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],
);
}