SkateSpots/src/hooks/usePreferences.ts

72 lines
2.8 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);
// Compute OUTSIDE the updater: a state updater must be pure (React may run
// it twice), and `synced.patch` publishes an encrypted event.
const next: Preferences = { ...local, ...patch, updatedAt: now };
setLocal(next);
void synced.patch(next);
},
[local, setLocal, synced],
);
return { preferences: local, setPreferences };
}