import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useNostrLogin } from '@nostrify/react/login'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useSyncedAppData, type MergeStrategy } from '@/hooks/useSyncedAppData'; import { isInteractiveSigner } from '@/lib/signerCapabilities'; /** * 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; /** Crew chat last-seen, keyed by crew coordinate. */ crewChats: Record; } 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 = (remote, patch) => { const dms: Record = { ...remote.dms }; for (const [k, v] of Object.entries(patch.dms)) dms[k] = maxTs(dms[k], v); const crewChats: Record = { ...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 { logins } = useNostrLogin(); const lsKey = user ? `skatespots:read-state:${user.pubkey}` : 'skatespots:read-state:anon'; const [local, setLocal] = useLocalStorage(lsKey, EMPTY); // Amber (NIP-55) prompts on every nip44 encrypt AND every sign. Marking the // bell (or a DM) read is not a signing action, so for an interactive signer we // keep the marker LOCAL-ONLY and never publish — no surprise popups just for // glancing at notifications. Non-interactive signers (nsec, extension) still // mirror the markers to the encrypted kind-30078 event for cross-device sync. const interactive = useMemo(() => isInteractiveSigner(user, logins), [user, logins]); const synced = useSyncedAppData({ 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]); // Hold the synced engine + interactive flag in refs so `apply` can be a // STABLE callback. Depending on them directly made `apply` (and every mark* // derived from it) change identity on every render, which made effects such as // NewsletterPage's / the notification bell's run after every render and — // because marking read set a new state object — loop until React aborted with // "Maximum update depth exceeded". const syncedRef = useRef(synced); syncedRef.current = synced; const interactiveRef = useRef(interactive); interactiveRef.current = interactive; /** Apply a patch to BOTH the instant local blob and the synced mirror. */ const apply = useCallback( (producePatch: (current: ReadState) => ReadState) => { setLocal((prev) => producePatch(prev)); // Interactive signer: local-only (see `interactive` above) — publishing // here would pop Amber just for reading. Otherwise mirror to the synced // event (no-op for anon; debounced + merged inside). if (interactiveRef.current) return; void syncedRef.current.patch((current) => producePatch(current)); }, [setLocal], ); 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); // Idempotent: return the SAME object when nothing actually moves, so React // bails out instead of re-rendering (and re-running any effect that calls // this). A fresh object every call was the other half of the render loop. apply((s) => s.notifications >= now && s.notificationsSeen >= now ? 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); // Idempotent (see markAllRead) — bail with the same object if already marked. apply((s) => (s.newsletter >= now ? 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 }; }