feat: cross-device read-state + preference sync via encrypted NIP-78

Adopt PosterChan's "encrypted events are the datastore" idea for two
per-browser bits of state, so they follow the user's npub across devices.

New shared engine (src/hooks/useSyncedAppData.ts):
- Stores one encrypted kind-30078 (NIP-78) event per d-tag, NIP-44
  encrypt-to-self, modeled on the proven useStash pattern.
- Clobber-guard: re-fetch + re-decrypt the freshest remote copy and
  MERGE the local patch in before publishing (never overwrites a field
  another device just set).
- Debounced publisher (coalesces rapid writes, flushes on pagehide/
  beforeunload) so frequent read-marker changes don't spam relays.
- No-op for anon / non-NIP-44 signers.

Read-state sync (src/hooks/useReadState.ts), d-tag skatespots:read-state:
- Unifies the four previously localStorage-only markers (notification
  bell, DMs, crew chat, newsletter) into one synced blob.
- Merge = field-wise MAX (markers only move forward → commutative, safe).
- The four legacy hooks now re-export drop-in replacements with identical
  signatures, so NO consumer component changed. localStorage stays as the
  instant/offline/anon fast path; the relay copy hydrates and max-merges.
- Fixes the real bug where clearing a badge on one device left it lit on
  another.

Preference sync (src/hooks/usePreferences.ts), d-tag skatespots:preferences:
- Mobile + desktop nav layouts now sync (language field wired for later).
- Merge = last-write-wins by updatedAt (a pref replaces, not accumulates).
- useNavPreferences / useDesktopNavPreferences adopt a synced layout when
  present and mirror changes; public APIs unchanged, localStorage fallback
  intact.

Docs: NIP.md gains a "Cross-device app data (kind 30078, encrypted)"
section documenting both d-tags, their content shapes, merge policies, and
the clobber-guard/debounce/anon safety notes.

Build + typecheck pass.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-25 09:49:47 +07:00
parent 5c3c2f6589
commit 800d9492ab
9 changed files with 613 additions and 123 deletions

62
NIP.md
View file

@ -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": "<nip44(JSON.stringify({ notifications, notificationsSeen, newsletter, dms, crewChats }))>",
"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": "<nip44(JSON.stringify({ navMiddleIds, desktopPrimaryIds, language, updatedAt }))>",
"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.

View file

@ -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<number>(key, 0);
const markSeen = useCallback(
(ts?: number) => {
setLastSeen(ts ?? Math.floor(Date.now() / 1000));
},
[setLastSeen],
);
return { lastSeen, markSeen };
}
export { useCrewChatRead } from '@/hooks/useReadState';

View file

@ -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<Record<string, number>>(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';

View file

@ -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<string[]>(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. */

View file

@ -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<number>(key, 0);
const markNewsletterRead = useCallback(() => {
setLastRead(Math.floor(Date.now() / 1000));
}, [setLastRead]);
return { lastRead, markNewsletterRead };
}
export { useNewsletterRead } from '@/hooks/useReadState';

View file

@ -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<number>(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<number>(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';

View file

@ -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<Preferences> = (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<Preferences>(lsKey, EMPTY);
const synced = useSyncedAppData<Preferences>({
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<Omit<Preferences, 'updatedAt'>>) => {
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 };
}

197
src/hooks/useReadState.ts Normal file
View file

@ -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<string, number>;
/** Crew chat last-seen, keyed by crew coordinate. */
crewChats: Record<string, number>;
}
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<ReadState> = (remote, patch) => {
const dms: Record<string, number> = { ...remote.dms };
for (const [k, v] of Object.entries(patch.dms)) dms[k] = maxTs(dms[k], v);
const crewChats: Record<string, number> = { ...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<ReadState>(lsKey, EMPTY);
const synced = useSyncedAppData<ReadState>({
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 };
}

View file

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