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.
259 lines
11 KiB
TypeScript
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],
|
|
);
|
|
}
|