SkateSpots/src/hooks/useCrewDismissals.ts
shakespeare.diy 0f2215f46c Sync crew-notification dismissals across devices (private NIP-78)
Fixes the caveat that dismissing a handled invite/join-request on one device
left it un-dismissed on another (it was per-device localStorage).

- New useCrewDismissals hook: stores the dismissed-id set as a single private,
  addressable NIP-78 event (kind 30078, d=skatespots:crew-notif-dismissals)
  with a NIP-44 payload encrypted to the user's own key. One list covers both
  invites and join-requests.
- useDismissCrewNotification: optimistic local update + best-effort encrypted
  publish, so dismissals feel instant and sync across every device the user
  logs in from.
- Graceful degradation: always resolves the UNION of the synced list and a
  local mirror, so it still works offline and on signers without NIP-44 (they
  just don't sync) — no regression from the old behavior.
- Rewired useCrewInvites (accept/decline/approve/dismiss) to the synced set;
  removed the two localStorage-only dismissal helpers. Filtering moved into
  useCrewNotifications so both invite + request lists share one source of truth.
- Documented the kind-30078 dismissal event in NIP.md.

Build clean, no console errors. Not deployed.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 01:32:04 +07:00

153 lines
5.3 KiB
TypeScript

import { useNostr } from '@nostrify/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
/**
* Cross-device "dismissed crew notifications" set.
*
* A crew invite / join-request lives on relays as a public event the recipient
* *doesn't* own, so they can't delete it — they can only stop it nagging them.
* Previously that was a per-device localStorage set, which meant dismissing on
* your phone left it un-dismissed on your laptop.
*
* This stores the set as a single private, addressable NIP-78 event (kind
* 30078) whose content is a NIP-44 payload encrypted to the user's own key —
* so it syncs across every device the user logs in from, and never leaks which
* invites/requests they ignored. One list covers both invites and join-requests
* (both keyed by event id).
*
* Graceful degradation: the resolved set is always the UNION of the synced
* remote list and a local mirror, so dismissals still work instantly, offline,
* and on signers without NIP-44 (they just don't sync).
*/
const DISMISS_KIND = 30078; // NIP-78 application-specific data (addressable)
export const DISMISS_D = 'skatespots:crew-notif-dismissals';
const LOCAL_KEY = 'skatespots:dismissed-crew-notifs';
// ─── Local mirror (offline + no-NIP-44 fallback) ──────────────────────────────
function readLocal(): string[] {
try {
const raw = localStorage.getItem(LOCAL_KEY);
return raw ? (JSON.parse(raw) as string[]) : [];
} catch {
return [];
}
}
function writeLocal(ids: string[]) {
try {
localStorage.setItem(LOCAL_KEY, JSON.stringify(ids));
} catch {
// ignore storage errors (private mode, quota, etc.)
}
}
/** Safely read the signer's nip44 without tripping a throwing getter. */
function getNip44(user: ReturnType<typeof useCurrentUser>['user']) {
if (!user?.signer) return undefined;
try {
return user.signer.nip44;
} catch {
return undefined;
}
}
export function dismissKey(pubkey: string | undefined) {
return ['crew-dismissals', pubkey] as const;
}
/**
* Resolve the set of dismissed notification ids for the current user — the
* union of the encrypted synced list and the local mirror.
*/
export function useCrewDismissals() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
return useQuery({
queryKey: dismissKey(user?.pubkey),
// Enabled even when logged out returns just the local mirror (harmless).
staleTime: 30_000,
queryFn: async (c): Promise<Set<string>> => {
const local = new Set(readLocal());
if (!user?.pubkey) return local;
const nip44 = getNip44(user);
// No NIP-44 → can't read the synced list; local mirror is all we have.
if (!nip44) return local;
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
try {
const events = await nostr.query(
[{ kinds: [DISMISS_KIND], authors: [user.pubkey], '#d': [DISMISS_D], limit: 1 }],
{ signal },
);
const ev = events[0];
if (ev?.content) {
const decrypted = await nip44.decrypt(user.pubkey, ev.content);
const parsed = JSON.parse(decrypted) as { ids?: unknown };
if (Array.isArray(parsed.ids)) {
for (const id of parsed.ids) {
if (typeof id === 'string') local.add(id);
}
}
}
} catch {
// Relay hiccup / undecryptable / offline — degrade to the local mirror.
}
// Keep the local mirror in sync with whatever we resolved.
writeLocal([...local]);
return local;
},
});
}
/**
* Dismiss a notification by its event id. Optimistically updates the cache +
* local mirror immediately, then (best-effort) publishes the encrypted synced
* list so other devices catch up.
*/
export function useDismissCrewNotification() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
// 1. Optimistic local update (works for everyone, instantly).
const key = dismissKey(user?.pubkey);
const current = queryClient.getQueryData<Set<string>>(key) ?? new Set(readLocal());
const next = new Set(current);
next.add(id);
queryClient.setQueryData(key, next);
writeLocal([...next]);
// 2. Best-effort sync via an encrypted NIP-78 event.
const nip44 = getNip44(user);
if (!user?.pubkey || !nip44) return; // no NIP-44: local-only, no throw
try {
const payload = JSON.stringify({ ids: [...next] });
const content = await nip44.encrypt(user.pubkey, payload);
const event = await user.signer.signEvent({
kind: DISMISS_KIND,
content,
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', DISMISS_D],
['alt', 'Private: dismissed SkateSpots crew notifications'],
],
});
await nostr.event(event, { signal: AbortSignal.timeout(10000) });
} catch {
// Publishing failed — the local dismissal still holds on this device.
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: dismissKey(user?.pubkey) });
},
});
}