diff --git a/NIP.md b/NIP.md index 4955ac6..cb5eb45 100644 --- a/NIP.md +++ b/NIP.md @@ -3008,3 +3008,65 @@ A reader opts in to the in-app newsletter mailbox by publishing a per-user - Subscription is opt-in for notifications only: everyone can read issues on the `/newsletter` page regardless. Subscribing routes new issues into the notification bell's "News" tab and unread badge. + +## Cross-device app data (kind 30078, encrypted) + +Beyond the plaintext newsletter-subscription marker above, SkateSpots stores two +blobs of personal app state as **NIP-78 application-specific data** events +(kind 30078, addressable), each keyed by its own `d` tag and **NIP-44-encrypted +to self** so it syncs across the user's devices without being readable by relays +or anyone else. The engine behind both is `src/hooks/useSyncedAppData.ts`; +`localStorage` remains the instant, offline-first fast path (and the only store +for logged-out users), with the encrypted event as the cross-device mirror. + +| `d` tag | Hook | Content (decrypted JSON) | Merge policy | +|---------|------|--------------------------|--------------| +| `skatespots:read-state` | `useReadState` | `{ notifications, notificationsSeen, newsletter, dms: {pubkey: ts}, crewChats: {coord: ts} }` — all unix seconds | **Field-wise max** (markers only move forward, so two devices merge by taking the larger timestamp per field — commutative, never clobbers) | +| `skatespots:preferences` | `usePreferences` | `{ navMiddleIds?, desktopPrimaryIds?, language?, updatedAt }` | **Last-write-wins by `updatedAt`** (a preference is a genuine replacement, not a monotonic marker) | + +### Read-state event + +```json +{ + "kind": 30078, + "content": "", + "tags": [ + ["d", "skatespots:read-state"], + ["alt", "SkateSpots read-state (encrypted)"] + ] +} +``` + +Drives the notification bell badge, DM unread indicators, the crew-chat "new +messages" divider, and the newsletter unread badge — so clearing them on one +device clears them on the others. + +### Preferences event + +```json +{ + "kind": 30078, + "content": "", + "tags": [ + ["d", "skatespots:preferences"], + ["alt", "SkateSpots preferences (encrypted)"] + ] +} +``` + +Carries the customizable bottom-nav / desktop-nav layout (and, going forward, +other personal UI settings) so a skater's setup follows their npub between phone +and laptop. + +### Safety notes + +- **Clobber-guard.** Both blobs are one replaceable event whose content is the + *whole* object. Before every publish the engine re-fetches and re-decrypts the + freshest remote copy and merges the local patch into it (per the policy above), + so a stale write can never wipe a field another device just set — the same + data-safety discipline the private Stash (`kind 30001`) uses. +- **Debounced writes.** Read-state changes fire often (every glance at the bell); + the engine coalesces rapid changes into at most one relay publish per few + seconds and flushes on `pagehide`/`beforeunload`, so relays are never spammed. +- **Anon users.** With no signer (or no NIP-44), sync is a no-op and behavior is + identical to the previous localStorage-only implementation. diff --git a/src/hooks/useCrewChatRead.ts b/src/hooks/useCrewChatRead.ts index 3968508..e7567b2 100644 --- a/src/hooks/useCrewChatRead.ts +++ b/src/hooks/useCrewChatRead.ts @@ -1,29 +1,9 @@ -import { useCallback } from 'react'; -import { useLocalStorage } from '@/hooks/useLocalStorage'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; - /** - * Per-crew "last seen the chat" timestamp (unix seconds), stored per-pubkey + - * per-crew in localStorage. Used only to draw a subtle "new messages" divider - * when a member returns to a chat — no network cost, works for every transport. + * Per-crew chat "last seen" state. * - * We deliberately keep this local-only: crew chat is casual and the divider is a - * convenience, not a synced read-receipt. Nothing here leaves the device. + * Implementation moved to `useReadState` (cross-device sync via an encrypted + * `kind-30078` event) while preserving the same instant localStorage fast-path + * and the same public API (`{ lastSeen, markSeen }`). Re-exported here so + * existing imports keep working unchanged. */ -export function useCrewChatRead(crewCoord: string | undefined) { - const { user } = useCurrentUser(); - const key = - user && crewCoord - ? `crew-chat-last-seen:${user.pubkey}:${crewCoord}` - : 'crew-chat-last-seen:anon'; - const [lastSeen, setLastSeen] = useLocalStorage(key, 0); - - const markSeen = useCallback( - (ts?: number) => { - setLastSeen(ts ?? Math.floor(Date.now() / 1000)); - }, - [setLastSeen], - ); - - return { lastSeen, markSeen }; -} +export { useCrewChatRead } from '@/hooks/useReadState'; diff --git a/src/hooks/useDMRead.ts b/src/hooks/useDMRead.ts index 96b2ed5..989c972 100644 --- a/src/hooks/useDMRead.ts +++ b/src/hooks/useDMRead.ts @@ -1,43 +1,9 @@ -import { useCallback } from 'react'; -import { useLocalStorage } from '@/hooks/useLocalStorage'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; - /** - * Per-conversation "last read" timestamps (unix seconds) for direct messages, - * stored per-pubkey in localStorage. A conversation counts as unread when its - * latest message (from the OTHER person) is newer than this timestamp. + * Direct-message per-conversation read state. * - * Local-only by design: this is a lightweight "have I seen it" convenience, not - * a synced read-receipt broadcast to anyone. Nothing here leaves the device. - * - * NIP-17 note: gift-wrapped DMs are decrypted client-side, so we compare against - * the message's own `created_at`. (Gift-wrap timestamps can be slightly fuzzed, - * but the inner rumor's `created_at` — what we store on DirectMessage — is the - * sender's real time, so this comparison is reliable for read state.) + * Implementation moved to `useReadState` (cross-device sync via an encrypted + * `kind-30078` event) while preserving the same instant localStorage fast-path + * and the same public API (`{ lastReadMap, markRead, getLastRead }`). + * Re-exported here so existing imports keep working unchanged. */ -export function useDMRead() { - const { user } = useCurrentUser(); - const key = user ? `dm-last-read:${user.pubkey}` : 'dm-last-read:anon'; - const [map, setMap] = useLocalStorage>(key, {}); - - /** Mark a conversation (keyed by the other party's pubkey) read as of `ts`. */ - const markRead = useCallback( - (conversationWith: string, ts?: number) => { - const at = ts ?? Math.floor(Date.now() / 1000); - setMap((prev) => { - const key = conversationWith.toLowerCase(); - if ((prev[key] ?? 0) >= at) return prev; // never move backwards - return { ...prev, [key]: at }; - }); - }, - [setMap], - ); - - /** Last-read unix seconds for a conversation, or 0 if never opened. */ - const getLastRead = useCallback( - (conversationWith: string) => map[conversationWith.toLowerCase()] ?? 0, - [map], - ); - - return { lastReadMap: map, markRead, getLastRead }; -} +export { useDMRead } from '@/hooks/useReadState'; diff --git a/src/hooks/useNavPreferences.ts b/src/hooks/useNavPreferences.ts index 99734ec..2c03ae5 100644 --- a/src/hooks/useNavPreferences.ts +++ b/src/hooks/useNavPreferences.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { MapPin, GraduationCap, @@ -12,6 +12,7 @@ import { type LucideIcon, } from 'lucide-react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; +import { usePreferences } from '@/hooks/usePreferences'; /** * Bottom-nav customization (mobile). @@ -127,6 +128,16 @@ function normalizeIds(ids: string[]): string[] { export function useNavPreferences() { const [storedIds, setStoredIds] = useLocalStorage(STORAGE_KEY, DEFAULT_TAB_IDS); + const { preferences, setPreferences } = usePreferences(); + + // Cross-device: when synced preferences carry a nav layout, adopt it locally. + const syncedMiddle = preferences.navMiddleIds; + useEffect(() => { + if (!syncedMiddle) return; + setStoredIds((prev) => + JSON.stringify(prev) === JSON.stringify(syncedMiddle) ? prev : syncedMiddle, + ); + }, [syncedMiddle, setStoredIds]); const middleIds = useMemo(() => normalizeIds(storedIds), [storedIds]); @@ -137,11 +148,18 @@ export function useNavPreferences() { ); const setMiddleIds = useCallback( - (ids: string[]) => setStoredIds(normalizeIds(ids)), - [setStoredIds], + (ids: string[]) => { + const clean = normalizeIds(ids); + setStoredIds(clean); + setPreferences({ navMiddleIds: clean }); + }, + [setStoredIds, setPreferences], ); - const reset = useCallback(() => setStoredIds(DEFAULT_TAB_IDS), [setStoredIds]); + const reset = useCallback(() => { + setStoredIds(DEFAULT_TAB_IDS); + setPreferences({ navMiddleIds: DEFAULT_TAB_IDS }); + }, [setStoredIds, setPreferences]); return { /** The 3 chosen middle tab ids. */ @@ -169,6 +187,16 @@ export function useDesktopNavPreferences() { DESKTOP_STORAGE_KEY, DEFAULT_DESKTOP_IDS, ); + const { preferences, setPreferences } = usePreferences(); + + // Cross-device: adopt a synced desktop layout when present. + const syncedPrimary = preferences.desktopPrimaryIds; + useEffect(() => { + if (!syncedPrimary) return; + setStoredIds((prev) => + JSON.stringify(prev) === JSON.stringify(syncedPrimary) ? prev : syncedPrimary, + ); + }, [syncedPrimary, setStoredIds]); const primaryIds = useMemo(() => normalizeDesktopIds(storedIds), [storedIds]); @@ -184,14 +212,18 @@ export function useDesktopNavPreferences() { ); const setPrimaryIds = useCallback( - (ids: string[]) => setStoredIds(normalizeDesktopIds(ids)), - [setStoredIds], + (ids: string[]) => { + const clean = normalizeDesktopIds(ids); + setStoredIds(clean); + setPreferences({ desktopPrimaryIds: clean }); + }, + [setStoredIds, setPreferences], ); - const reset = useCallback( - () => setStoredIds(DEFAULT_DESKTOP_IDS), - [setStoredIds], - ); + const reset = useCallback(() => { + setStoredIds(DEFAULT_DESKTOP_IDS); + setPreferences({ desktopPrimaryIds: DEFAULT_DESKTOP_IDS }); + }, [setStoredIds, setPreferences]); return { /** Ordered ids shown as primary desktop tabs. */ diff --git a/src/hooks/useNewsletterRead.ts b/src/hooks/useNewsletterRead.ts index 6c4fb06..af2d68a 100644 --- a/src/hooks/useNewsletterRead.ts +++ b/src/hooks/useNewsletterRead.ts @@ -1,20 +1,9 @@ -import { useCallback } from 'react'; -import { useLocalStorage } from '@/hooks/useLocalStorage'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; - /** - * Tracks the timestamp (unix seconds) at which the user last read the newsletter - * mailbox. Any issue published after this is "unread" and contributes to the - * News badge. Stored per-pubkey so switching accounts keeps independent state. + * Newsletter mailbox read state. + * + * Implementation moved to `useReadState` (cross-device sync via an encrypted + * `kind-30078` event) while preserving the same instant localStorage fast-path + * and the same public API (`{ lastRead, markNewsletterRead }`). Re-exported + * here so existing imports keep working unchanged. */ -export function useNewsletterRead() { - const { user } = useCurrentUser(); - const key = user ? `newsletter-last-read:${user.pubkey}` : 'newsletter-last-read:anon'; - const [lastRead, setLastRead] = useLocalStorage(key, 0); - - const markNewsletterRead = useCallback(() => { - setLastRead(Math.floor(Date.now() / 1000)); - }, [setLastRead]); - - return { lastRead, markNewsletterRead }; -} +export { useNewsletterRead } from '@/hooks/useReadState'; diff --git a/src/hooks/useNotificationsRead.ts b/src/hooks/useNotificationsRead.ts index df57155..8e87990 100644 --- a/src/hooks/useNotificationsRead.ts +++ b/src/hooks/useNotificationsRead.ts @@ -1,31 +1,10 @@ -import { useCallback } from 'react'; -import { useLocalStorage } from '@/hooks/useLocalStorage'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; - /** - * Tracks the timestamp (unix seconds) at which the logged-in user last opened - * their notification center. Anything newer than this is considered "unread" - * and contributes to the bell's badge count. Stored per-pubkey in localStorage - * so switching accounts keeps independent read state. + * Notification-center read state. + * + * Implementation moved to `useReadState`, which mirrors the marker into an + * encrypted `kind-30078` event so it syncs across the user's devices while + * keeping the same instant localStorage fast-path and the same public API + * (`{ lastRead, seenAt, markAllRead }`). Re-exported here so existing imports + * keep working unchanged. */ -export function useNotificationsRead() { - const { user } = useCurrentUser(); - const key = user ? `notifications-last-read:${user.pubkey}` : 'notifications-last-read:anon'; - const [lastRead, setLastRead] = useLocalStorage(key, 0); - - // Separately track when the user last dismissed the BADGE for actionable - // items (crew invites, join requests, pending confirmations). Those items - // stay visible in their tab until acted upon, but "Mark read" should still - // clear the red badge — otherwise the badge sticks forever and "Mark read" - // appears broken. We compare item timestamps against this to decide badging. - const seenKey = user ? `notifications-seen-at:${user.pubkey}` : 'notifications-seen-at:anon'; - const [seenAt, setSeenAt] = useLocalStorage(seenKey, 0); - - const markAllRead = useCallback(() => { - const now = Math.floor(Date.now() / 1000); - setLastRead(now); - setSeenAt(now); - }, [setLastRead, setSeenAt]); - - return { lastRead, seenAt, markAllRead }; -} +export { useNotificationsRead } from '@/hooks/useReadState'; diff --git a/src/hooks/usePreferences.ts b/src/hooks/usePreferences.ts new file mode 100644 index 0000000..324c96d --- /dev/null +++ b/src/hooks/usePreferences.ts @@ -0,0 +1,72 @@ +import { useCallback, useEffect } from 'react'; +import { useLocalStorage } from '@/hooks/useLocalStorage'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useSyncedAppData, type MergeStrategy } from '@/hooks/useSyncedAppData'; + +/** + * usePreferences — cross-device user preferences. + * + * Non-secret-but-personal UI settings (bottom-nav layout, desktop primary tabs, + * language) that should follow a skater's npub between phone and laptop instead + * of being stranded per-browser in localStorage. + * + * Same shape as `useReadState`: localStorage is the instant, offline-first, + * anon-friendly fast path, and one encrypted `kind-30078` event is the + * cross-device mirror. The difference is the MERGE policy — a preference is a + * genuine replacement, not a monotonic marker, so we use LAST-WRITE-WINS keyed + * by a per-blob `updatedAt`: the object with the newer `updatedAt` wins whole. + */ + +export interface Preferences { + /** Mobile bottom-nav: chosen middle tab ids. */ + navMiddleIds?: string[]; + /** Desktop header: chosen primary tab ids (order preserved). */ + desktopPrimaryIds?: string[]; + /** UI language code (e.g. "en", "th"). */ + language?: string; + /** Unix seconds of the last change — drives last-write-wins merge. */ + updatedAt: number; +} + +const EMPTY: Preferences = { updatedAt: 0 }; + +const D_TAG = 'skatespots:preferences'; + +/** Whole-object last-write-wins by `updatedAt` (newer replaces older). */ +const mergePreferences: MergeStrategy = (remote, patch) => + patch.updatedAt >= remote.updatedAt ? patch : remote; + +export function usePreferences() { + const { user } = useCurrentUser(); + const lsKey = user ? `skatespots:preferences:${user.pubkey}` : 'skatespots:preferences:anon'; + const [local, setLocal] = useLocalStorage(lsKey, EMPTY); + + const synced = useSyncedAppData({ + dTag: D_TAG, + alt: 'SkateSpots preferences (encrypted)', + empty: EMPTY, + merge: mergePreferences, + }); + + // When the relay copy arrives and it's newer, adopt it locally. + const remote = synced.data; + useEffect(() => { + if (!remote) return; + setLocal((prev) => (remote.updatedAt > prev.updatedAt ? remote : prev)); + }, [remote, setLocal]); + + /** Patch one or more preference fields (bumps `updatedAt`, mirrors + syncs). */ + const setPreferences = useCallback( + (patch: Partial>) => { + const now = Math.floor(Date.now() / 1000); + setLocal((prev) => { + const next: Preferences = { ...prev, ...patch, updatedAt: now }; + void synced.patch(next); + return next; + }); + }, + [setLocal, synced], + ); + + return { preferences: local, setPreferences }; +} diff --git a/src/hooks/useReadState.ts b/src/hooks/useReadState.ts new file mode 100644 index 0000000..cc739ed --- /dev/null +++ b/src/hooks/useReadState.ts @@ -0,0 +1,197 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useLocalStorage } from '@/hooks/useLocalStorage'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useSyncedAppData, type MergeStrategy } from '@/hooks/useSyncedAppData'; + +/** + * useReadState — cross-device "what have I already seen" markers. + * + * SkateSpots tracks read state for four surfaces (the notification bell, DMs, + * crew chats, the newsletter). Historically each lived in localStorage keyed by + * pubkey, so clearing the bell on your phone left the laptop's badge lit. This + * hook keeps that instant, offline-first localStorage behavior AND mirrors the + * markers into one encrypted `kind-30078` event so they follow your npub across + * devices. + * + * DESIGN + * ────── + * - **localStorage stays the fast path / source of truth for the UI.** Reads are + * synchronous and work while logged out. The individual read hooks + * (`useNotificationsRead`, `useDMRead`, …) keep their exact public API and + * simply call this to stay in sync — no consumer component changes. + * - **The synced copy is the cross-device layer.** On login we hydrate from the + * relay (merging in anything newer) and every `mark*` also patches the synced + * event (debounced). + * - **Merge = MAX per field.** Read markers only ever move forward in time, so + * merging two devices is "take the larger timestamp" — commutative and safe, + * never clobbers. + */ + +/** The whole synced read-state blob. All values are unix seconds. */ +export interface ReadState { + /** Notification center last-opened. */ + notifications: number; + /** Badge-dismiss marker for actionable items (invites, join requests). */ + notificationsSeen: number; + /** Newsletter mailbox last-read. */ + newsletter: number; + /** DM last-read, keyed by the other party's lowercased pubkey. */ + dms: Record; + /** Crew chat last-seen, keyed by crew coordinate. */ + crewChats: Record; +} + +const EMPTY: ReadState = { + notifications: 0, + notificationsSeen: 0, + newsletter: 0, + dms: {}, + crewChats: {}, +}; + +const D_TAG = 'skatespots:read-state'; + +/** Take the larger of two unix-second markers (0 = never). */ +const maxTs = (a = 0, b = 0) => (a > b ? a : b); + +/** Field-wise max-merge: never moves a marker backwards, never drops a key. */ +const mergeReadState: MergeStrategy = (remote, patch) => { + const dms: Record = { ...remote.dms }; + for (const [k, v] of Object.entries(patch.dms)) dms[k] = maxTs(dms[k], v); + + const crewChats: Record = { ...remote.crewChats }; + for (const [k, v] of Object.entries(patch.crewChats)) { + crewChats[k] = maxTs(crewChats[k], v); + } + + return { + notifications: maxTs(remote.notifications, patch.notifications), + notificationsSeen: maxTs(remote.notificationsSeen, patch.notificationsSeen), + newsletter: maxTs(remote.newsletter, patch.newsletter), + dms, + crewChats, + }; +}; + +/** Merge a synced blob into the local one (used when the relay copy arrives). */ +function mergeIntoLocal(local: ReadState, incoming: ReadState): ReadState { + return mergeReadState(local, incoming); +} + +/** + * Shared read-state store. Prefer the specific hooks below in components; this + * is the single place that owns the localStorage blob + the synced mirror. + */ +export function useReadState() { + const { user } = useCurrentUser(); + const lsKey = user ? `skatespots:read-state:${user.pubkey}` : 'skatespots:read-state:anon'; + const [local, setLocal] = useLocalStorage(lsKey, EMPTY); + + const synced = useSyncedAppData({ + dTag: D_TAG, + alt: 'SkateSpots read-state (encrypted)', + empty: EMPTY, + merge: mergeReadState, + }); + + // When the relay copy arrives (or changes), fold anything newer into local so + // a device that was behind catches up. Max-merge means this can only advance + // markers, never undo a local "mark read". + const remote = synced.data; + useEffect(() => { + if (!remote) return; + setLocal((prev) => { + const merged = mergeIntoLocal(prev, remote); + // Avoid a needless state churn if nothing actually changed. + return JSON.stringify(merged) === JSON.stringify(prev) ? prev : merged; + }); + }, [remote, setLocal]); + + /** Apply a patch to BOTH the instant local blob and the synced mirror. */ + const apply = useCallback( + (producePatch: (current: ReadState) => ReadState) => { + setLocal((prev) => producePatch(prev)); + // Mirror to the synced event (no-op for anon; debounced + merged inside). + void synced.patch((current) => producePatch(current)); + }, + [setLocal, synced], + ); + + return { state: local, apply }; +} + +// ─── Backwards-compatible, drop-in replacements for the old hooks ──────────── +// These preserve the EXACT signatures the components already consume, so no +// call site changes — only the storage underneath gains cross-device sync. + +/** @see useNotificationsRead (legacy name kept) */ +export function useNotificationsRead() { + const { state, apply } = useReadState(); + + const markAllRead = useCallback(() => { + const now = Math.floor(Date.now() / 1000); + apply((s) => ({ ...s, notifications: now, notificationsSeen: now })); + }, [apply]); + + return { lastRead: state.notifications, seenAt: state.notificationsSeen, markAllRead }; +} + +/** @see useNewsletterRead */ +export function useNewsletterRead() { + const { state, apply } = useReadState(); + + const markNewsletterRead = useCallback(() => { + const now = Math.floor(Date.now() / 1000); + apply((s) => ({ ...s, newsletter: now })); + }, [apply]); + + return { lastRead: state.newsletter, markNewsletterRead }; +} + +/** @see useDMRead */ +export function useDMRead() { + const { state, apply } = useReadState(); + + const markRead = useCallback( + (conversationWith: string, ts?: number) => { + const at = ts ?? Math.floor(Date.now() / 1000); + const key = conversationWith.toLowerCase(); + apply((s) => { + if ((s.dms[key] ?? 0) >= at) return s; // never backwards + return { ...s, dms: { ...s.dms, [key]: at } }; + }); + }, + [apply], + ); + + const getLastRead = useCallback( + (conversationWith: string) => state.dms[conversationWith.toLowerCase()] ?? 0, + [state.dms], + ); + + return { lastReadMap: state.dms, markRead, getLastRead }; +} + +/** @see useCrewChatRead */ +export function useCrewChatRead(crewCoord: string | undefined) { + const { state, apply } = useReadState(); + + const lastSeen = useMemo( + () => (crewCoord ? state.crewChats[crewCoord] ?? 0 : 0), + [state.crewChats, crewCoord], + ); + + const markSeen = useCallback( + (ts?: number) => { + if (!crewCoord) return; + const at = ts ?? Math.floor(Date.now() / 1000); + apply((s) => { + if ((s.crewChats[crewCoord] ?? 0) >= at) return s; + return { ...s, crewChats: { ...s.crewChats, [crewCoord]: at } }; + }); + }, + [apply, crewCoord], + ); + + return { lastSeen, markSeen }; +} diff --git a/src/hooks/useSyncedAppData.ts b/src/hooks/useSyncedAppData.ts new file mode 100644 index 0000000..5d948d0 --- /dev/null +++ b/src/hooks/useSyncedAppData.ts @@ -0,0 +1,213 @@ +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 } 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; +} + +const APP_DATA_KIND = 30078; // NIP-78 application-specific data + +export function useSyncedAppData(options: UseSyncedAppDataOptions) { + 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; + + 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, + staleTime: 60_000, + refetchOnWindowFocus: false, + 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)) => { + 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(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, + }; +}