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:
parent
5c3c2f6589
commit
800d9492ab
9 changed files with 613 additions and 123 deletions
62
NIP.md
62
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": "<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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
72
src/hooks/usePreferences.ts
Normal file
72
src/hooks/usePreferences.ts
Normal 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
197
src/hooks/useReadState.ts
Normal 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 };
|
||||
}
|
||||
213
src/hooks/useSyncedAppData.ts
Normal file
213
src/hooks/useSyncedAppData.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue