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
|
- Subscription is opt-in for notifications only: everyone can read issues on
|
||||||
the `/newsletter` page regardless. Subscribing routes new issues into the
|
the `/newsletter` page regardless. Subscribing routes new issues into the
|
||||||
notification bell's "News" tab and unread badge.
|
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 chat "last seen" state.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* We deliberately keep this local-only: crew chat is casual and the divider is a
|
* Implementation moved to `useReadState` (cross-device sync via an encrypted
|
||||||
* convenience, not a synced read-receipt. Nothing here leaves the device.
|
* `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) {
|
export { useCrewChatRead } from '@/hooks/useReadState';
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
* Direct-message per-conversation read state.
|
||||||
* stored per-pubkey in localStorage. A conversation counts as unread when its
|
|
||||||
* latest message (from the OTHER person) is newer than this timestamp.
|
|
||||||
*
|
*
|
||||||
* Local-only by design: this is a lightweight "have I seen it" convenience, not
|
* Implementation moved to `useReadState` (cross-device sync via an encrypted
|
||||||
* a synced read-receipt broadcast to anyone. Nothing here leaves the device.
|
* `kind-30078` event) while preserving the same instant localStorage fast-path
|
||||||
*
|
* and the same public API (`{ lastReadMap, markRead, getLastRead }`).
|
||||||
* NIP-17 note: gift-wrapped DMs are decrypted client-side, so we compare against
|
* Re-exported here so existing imports keep working unchanged.
|
||||||
* 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.)
|
|
||||||
*/
|
*/
|
||||||
export function useDMRead() {
|
export { useDMRead } from '@/hooks/useReadState';
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
MapPin,
|
MapPin,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
|
import { usePreferences } from '@/hooks/usePreferences';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bottom-nav customization (mobile).
|
* Bottom-nav customization (mobile).
|
||||||
|
|
@ -127,6 +128,16 @@ function normalizeIds(ids: string[]): string[] {
|
||||||
|
|
||||||
export function useNavPreferences() {
|
export function useNavPreferences() {
|
||||||
const [storedIds, setStoredIds] = useLocalStorage<string[]>(STORAGE_KEY, DEFAULT_TAB_IDS);
|
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]);
|
const middleIds = useMemo(() => normalizeIds(storedIds), [storedIds]);
|
||||||
|
|
||||||
|
|
@ -137,11 +148,18 @@ export function useNavPreferences() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const setMiddleIds = useCallback(
|
const setMiddleIds = useCallback(
|
||||||
(ids: string[]) => setStoredIds(normalizeIds(ids)),
|
(ids: string[]) => {
|
||||||
[setStoredIds],
|
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 {
|
return {
|
||||||
/** The 3 chosen middle tab ids. */
|
/** The 3 chosen middle tab ids. */
|
||||||
|
|
@ -169,6 +187,16 @@ export function useDesktopNavPreferences() {
|
||||||
DESKTOP_STORAGE_KEY,
|
DESKTOP_STORAGE_KEY,
|
||||||
DEFAULT_DESKTOP_IDS,
|
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]);
|
const primaryIds = useMemo(() => normalizeDesktopIds(storedIds), [storedIds]);
|
||||||
|
|
||||||
|
|
@ -184,14 +212,18 @@ export function useDesktopNavPreferences() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const setPrimaryIds = useCallback(
|
const setPrimaryIds = useCallback(
|
||||||
(ids: string[]) => setStoredIds(normalizeDesktopIds(ids)),
|
(ids: string[]) => {
|
||||||
[setStoredIds],
|
const clean = normalizeDesktopIds(ids);
|
||||||
|
setStoredIds(clean);
|
||||||
|
setPreferences({ desktopPrimaryIds: clean });
|
||||||
|
},
|
||||||
|
[setStoredIds, setPreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const reset = useCallback(
|
const reset = useCallback(() => {
|
||||||
() => setStoredIds(DEFAULT_DESKTOP_IDS),
|
setStoredIds(DEFAULT_DESKTOP_IDS);
|
||||||
[setStoredIds],
|
setPreferences({ desktopPrimaryIds: DEFAULT_DESKTOP_IDS });
|
||||||
);
|
}, [setStoredIds, setPreferences]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/** Ordered ids shown as primary desktop tabs. */
|
/** 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
|
* Newsletter mailbox read state.
|
||||||
* mailbox. Any issue published after this is "unread" and contributes to the
|
*
|
||||||
* News badge. Stored per-pubkey so switching accounts keeps independent 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() {
|
export { useNewsletterRead } from '@/hooks/useReadState';
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
* Notification-center read state.
|
||||||
* their notification center. Anything newer than this is considered "unread"
|
*
|
||||||
* and contributes to the bell's badge count. Stored per-pubkey in localStorage
|
* Implementation moved to `useReadState`, which mirrors the marker into an
|
||||||
* so switching accounts keeps independent read state.
|
* 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() {
|
export { useNotificationsRead } from '@/hooks/useReadState';
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
|
||||||
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