Audit of the newsletter section found two real bugs.
1) /newsletter rendered in an infinite loop (100% CPU, React 'Maximum update
depth exceeded'). Chain: useSyncedAppData returned a fresh object each render
-> useReadState.apply (dep: synced) changed identity -> markNewsletterRead /
markAllRead changed every render -> the page effect (deps include it) re-ran
after every render -> the mark set a NEW state object -> loop. The
notification bell looped the same way while open.
- useLocalStorage: setter is now referentially stable (ref + functional set).
- useSyncedAppData: returned object is memoized.
- useReadState: apply reads synced/interactive from refs (stable); marks are
idempotent (same object when already marked) so React bails.
- NewsletterPage marks read once per visit (mount-once ref guard).
2) Issues + subscription markers were published via the default pool (the
author's own NIP-65 relays) but READ via useSkateRelays (community relay +
reader's relays; logged-out = community relay only), so an admin without
goskatespots in their list would publish drops nobody could see. Composer,
backfill panel and the subscribe hook now publish to getWriteRelays() and
fail loudly if every relay rejects (useSpotChat pattern). NIP.md untouched
(no schema change).
Also: guard the overlay pill/reminder against an empty archive, and drop a
redundant canPublish clause.
229 lines
8.7 KiB
TypeScript
229 lines
8.7 KiB
TypeScript
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<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 { logins } = useNostrLogin();
|
|
const lsKey = user ? `skatespots:read-state:${user.pubkey}` : 'skatespots:read-state:anon';
|
|
const [local, setLocal] = useLocalStorage<ReadState>(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<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]);
|
|
|
|
// 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 };
|
|
}
|