From 0f2215f46c4839da12cef38fb511b67859001dd3 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 3 Sep 2026 01:32:04 +0700 Subject: [PATCH] Sync crew-notification dismissals across devices (private NIP-78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- NIP.md | 38 +++++++- src/hooks/useCrewDismissals.ts | 153 ++++++++++++++++++++++++++++++ src/hooks/useCrewInvites.ts | 84 +++++----------- src/hooks/useCrewNotifications.ts | 17 +++- 4 files changed, 227 insertions(+), 65 deletions(-) create mode 100644 src/hooks/useCrewDismissals.ts diff --git a/NIP.md b/NIP.md index 0c41e98..758eba2 100644 --- a/NIP.md +++ b/NIP.md @@ -2239,8 +2239,9 @@ unforgeable (founder `p` tag + invitee attestation). notification with **Accept** / **Decline**. 3. **Accept.** The invitee publishes their own NIP-32 membership attestation (kind `1985`, as above). Since the invitee can't delete an invite they didn't - author, the invite is cleared from *their* notifications via a local dismissal. -4. **Decline.** Local dismissal only — no event required. + author, the invite is cleared from *their* notifications via the synced + dismissal set (below). +4. **Decline.** Dismissal only — no public event required. Founders also retain a shareable **link/message** invite for people not yet on Nostr, and a shortcut to add a member key directly in crew settings. @@ -2289,8 +2290,37 @@ admin. It carries nothing sensitive: just an optional note. attestation, exactly as with an invite. Admins can surface and dismiss but cannot add. 4. **Dismiss.** Because a moderator can't delete an event they didn't author, - handled/ignored requests are cleared from *their* bell via a local dismissal - set. + handled/ignored requests are cleared from *their* bell via the synced + dismissal set (below). + +#### Dismissed notifications (kind `30078`) — private, cross-device + +Neither an invitee nor a moderator authored the invite / join-request they're +acting on, so they can't delete it — they can only **stop it nagging them**. +That "dismissed" set is kept as a single private, addressable **NIP-78** +application-data event (kind `30078`, `d = skatespots:crew-notif-dismissals`) +whose `content` is a **NIP-44 payload encrypted to the user's own key**. One +list covers both invites and join-requests (both keyed by event id). + +```json +{ + "kind": 30078, + "content": "", …] }))>", + "tags": [ + ["d", "skatespots:crew-notif-dismissals"], + ["alt", "Private: dismissed SkateSpots crew notifications"] + ] +} +``` + +- **Cross-device.** Because it's a published event (not `localStorage`), the + dismissal follows the user to every device they log in from — handle a request + on your phone and it's gone on your laptop too. +- **Private.** The `ids` never leave the encrypted `content`, so relays learn + nothing about which invites/requests a user ignored. +- **Graceful degradation.** The client always resolves the **union** of this + synced list and a local mirror, so dismissals still work instantly, offline, + and on signers without NIP-44 (they simply don't sync). #### Private Crew Chat (Cordn / MLS) — the `coordinator` tag diff --git a/src/hooks/useCrewDismissals.ts b/src/hooks/useCrewDismissals.ts new file mode 100644 index 0000000..d3d096f --- /dev/null +++ b/src/hooks/useCrewDismissals.ts @@ -0,0 +1,153 @@ +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['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> => { + 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>(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) }); + }, + }); +} diff --git a/src/hooks/useCrewInvites.ts b/src/hooks/useCrewInvites.ts index 7247ee4..e36287e 100644 --- a/src/hooks/useCrewInvites.ts +++ b/src/hooks/useCrewInvites.ts @@ -3,6 +3,7 @@ import { useNostr } from '@nostrify/react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useConfirmMembership } from '@/hooks/useMembershipAttestation'; +import { useDismissCrewNotification } from '@/hooks/useCrewDismissals'; import { CREW_INVITE_KIND, CREW_JOIN_REQUEST_KIND, @@ -19,31 +20,12 @@ import { } from '@/lib/types'; import type { NostrEvent } from '@nostrify/nostrify'; -// ─── Local dismissals ───────────────────────────────────────────────────────── -// The invitee can't delete an invite they didn't author, so accepted/declined -// invites are cleared from *their* notifications via a local dismissal set. -// (The public invite event still exists on relays; it just stops nagging them.) - -const DISMISS_KEY = 'skatespots:dismissed-crew-invites'; - -function readDismissed(): Set { - try { - const raw = localStorage.getItem(DISMISS_KEY); - return new Set(raw ? (JSON.parse(raw) as string[]) : []); - } catch { - return new Set(); - } -} - -function dismissInvite(inviteId: string) { - try { - const set = readDismissed(); - set.add(inviteId); - localStorage.setItem(DISMISS_KEY, JSON.stringify([...set])); - } catch { - // ignore storage errors - } -} +// ─── Dismissals ─────────────────────────────────────────────────────────────── +// The invitee/moderator can't delete an event they didn't author, so handled +// invites & join-requests are cleared from *their* notifications via a +// dismissal set. That set is a private, cross-device-synced NIP-78 list — +// see `useCrewDismissals`. The public event still exists on relays; it just +// stops nagging them, everywhere they log in. // ─── Sending an invite ────────────────────────────────────────────────────── @@ -147,7 +129,11 @@ function republishCrewWithMember( // ─── Reading invites addressed to me ───────────────────────────────────────── -/** Live crew invites where the given pubkey is the invitee (excludes locally dismissed). */ +/** + * Live crew invites where the given pubkey is the invitee. Dismissal filtering + * is applied downstream in `useCrewNotifications` (via the synced dismissal + * set), so this returns every live invite addressed to the user. + */ export function useMyCrewInvites(pubkey: string | undefined) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); @@ -164,12 +150,10 @@ export function useMyCrewInvites(pubkey: string | undefined) { { signal }, ); - const dismissed = readDismissed(); const seen = new Map(); for (const ev of events) { const inv = parseCrewInvite(ev); if (!inv || inv.invitee.toLowerCase() !== pubkey.toLowerCase()) continue; - if (dismissed.has(inv.id)) continue; // Dedup by crew coord (addressable — newest wins). const prev = seen.get(inv.crewCoord); if (!prev || inv.createdAt > prev.createdAt) seen.set(inv.crewCoord, inv); @@ -190,6 +174,7 @@ export function useAcceptCrewInvite() { const { user } = useCurrentUser(); const queryClient = useQueryClient(); const { mutateAsync: confirm } = useConfirmMembership(); + const { mutateAsync: dismiss } = useDismissCrewNotification(); return useMutation({ mutationFn: async ({ @@ -211,7 +196,7 @@ export function useAcceptCrewInvite() { visibility, }); - dismissInvite(invite.id); + await dismiss(invite.id); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['crew-invites'] }); @@ -221,13 +206,14 @@ export function useAcceptCrewInvite() { }); } -/** Decline a crew invite — dismiss it locally so it clears from notifications. */ +/** Decline a crew invite — dismiss it so it clears from notifications (synced). */ export function useDeclineCrewInvite() { const queryClient = useQueryClient(); + const { mutateAsync: dismiss } = useDismissCrewNotification(); return useMutation({ mutationFn: async ({ invite }: { invite: CrewInvite }) => { - dismissInvite(invite.id); + await dismiss(invite.id); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['crew-invites'] }); @@ -239,29 +225,9 @@ export function useDeclineCrewInvite() { // A skater asks to join a crew. We publish a structured, addressable event // (kind 31969) p-tagged to the founder + admins so it shows in THEIR notification // bell with a one-click "Add to crew" (founder) — parallel to invites, so the -// two directions are symmetric. Handled requests are cleared via a local -// dismissal set on the moderator's device. - -const JOIN_DISMISS_KEY = 'skatespots:dismissed-join-requests'; - -function readJoinDismissed(): Set { - try { - const raw = localStorage.getItem(JOIN_DISMISS_KEY); - return new Set(raw ? (JSON.parse(raw) as string[]) : []); - } catch { - return new Set(); - } -} - -function dismissJoinRequest(id: string) { - try { - const set = readJoinDismissed(); - set.add(id); - localStorage.setItem(JOIN_DISMISS_KEY, JSON.stringify([...set])); - } catch { - // ignore - } -} +// two directions are symmetric. Handled requests are cleared via the shared, +// cross-device-synced dismissal set (`useCrewDismissals`); filtering happens +// downstream in `useCrewNotifications`. /** Publish a structured join-request to a crew's founder + admins. */ export function useSendJoinRequest() { @@ -320,13 +286,11 @@ export function useIncomingJoinRequests(pubkey: string | undefined) { [{ kinds: [CREW_JOIN_REQUEST_KIND], '#p': [pubkey], limit: 100 }], { signal }, ); - const dismissed = readJoinDismissed(); const seen = new Map(); for (const ev of events) { const req = parseCrewJoinRequest(ev); if (!req) continue; if (!req.recipients.includes(pubkey.toLowerCase())) continue; - if (dismissed.has(req.id)) continue; // Dedup by (crew + requester) — newest wins. const key = `${req.crewCoord}:${req.requester}`; const prev = seen.get(key); @@ -347,6 +311,7 @@ export function useApproveJoinRequest() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const queryClient = useQueryClient(); + const { mutateAsync: dismiss } = useDismissCrewNotification(); return useMutation({ mutationFn: async ({ request, crew }: { request: CrewJoinRequest; crew: SkateCrew }) => { @@ -362,7 +327,7 @@ export function useApproveJoinRequest() { const signed = await user.signer.signEvent(updated); await nostr.event(signed, { signal: AbortSignal.timeout(15000) }); } - dismissJoinRequest(request.id); + await dismiss(request.id); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['crew-join-requests'] }); @@ -372,12 +337,13 @@ export function useApproveJoinRequest() { }); } -/** Dismiss a join-request locally (moderator handled/ignored it). */ +/** Dismiss a join-request (moderator handled/ignored it) — synced across devices. */ export function useDismissJoinRequest() { const queryClient = useQueryClient(); + const { mutateAsync: dismiss } = useDismissCrewNotification(); return useMutation({ mutationFn: async ({ request }: { request: CrewJoinRequest }) => { - dismissJoinRequest(request.id); + await dismiss(request.id); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['crew-join-requests'] }); diff --git a/src/hooks/useCrewNotifications.ts b/src/hooks/useCrewNotifications.ts index 9b5a73a..959758e 100644 --- a/src/hooks/useCrewNotifications.ts +++ b/src/hooks/useCrewNotifications.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useMyCrewInvites, useIncomingJoinRequests } from '@/hooks/useCrewInvites'; +import { useCrewDismissals } from '@/hooks/useCrewDismissals'; import { useCrews } from '@/hooks/useCrews'; import { useCollectiveMembers } from '@/hooks/useCollectiveMembers'; import { SKATE_CREW_KIND, type CrewInvite, type CrewJoinRequest, type SkateCrew } from '@/lib/types'; @@ -30,6 +31,16 @@ export function useCrewNotifications() { const { data: invites, isLoading: invitesLoading } = useMyCrewInvites(user?.pubkey); const { data: joinRequests, isLoading: joinLoading } = useIncomingJoinRequests(user?.pubkey); const { data: crews, isLoading: crewsLoading } = useCrews(200); + // Cross-device-synced set of dismissed invite/request ids. + const { data: dismissed } = useCrewDismissals(); + + // Invites addressed to me, minus anything I've dismissed (accepted/declined), + // synced across all my devices. + const visibleInvites = useMemo(() => { + const list = invites ?? []; + if (!dismissed || dismissed.size === 0) return list; + return list.filter((inv) => !dismissed.has(inv.id)); + }, [invites, dismissed]); // Crews that list me as a member (candidates for "pending confirmation"). const listedCrews = useMemo(() => { @@ -45,6 +56,8 @@ export function useCrewNotifications() { const requestsWithCrew = useMemo(() => { if (!user || !joinRequests?.length || !crews) return []; return joinRequests + // Drop dismissed requests (synced across the moderator's devices). + .filter((req) => !dismissed?.has(req.id)) .map((req) => { const crew = crews.find( (c) => `${SKATE_CREW_KIND}:${c.pubkey}:${c.identifier}` === req.crewCoord, @@ -58,11 +71,11 @@ export function useCrewNotifications() { crew.pubkey.toLowerCase() === user.pubkey.toLowerCase() || crew.admins.some((a) => a.toLowerCase() === user.pubkey.toLowerCase()), ); - }, [user, joinRequests, crews]); + }, [user, joinRequests, crews, dismissed]); return { user, - invites: invites ?? [], + invites: visibleInvites, listedCrews, joinRequests: requestsWithCrew, isLoading: invitesLoading || joinLoading || crewsLoading,