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>
72 lines
2.7 KiB
TypeScript
72 lines
2.7 KiB
TypeScript
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 };
|
|
}
|