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>
This commit is contained in:
shakespeare.diy 2026-09-03 01:32:04 +07:00
parent 042e4af8ed
commit 0f2215f46c
4 changed files with 227 additions and 65 deletions

38
NIP.md
View file

@ -2239,8 +2239,9 @@ unforgeable (founder `p` tag + invitee attestation).
notification with **Accept** / **Decline**. notification with **Accept** / **Decline**.
3. **Accept.** The invitee publishes their own NIP-32 membership attestation 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 (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. author, the invite is cleared from *their* notifications via the synced
4. **Decline.** Local dismissal only — no event required. dismissal set (below).
4. **Decline.** Dismissal only — no public event required.
Founders also retain a shareable **link/message** invite for people not yet on 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. 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 attestation, exactly as with an invite. Admins can surface and dismiss but
cannot add. cannot add.
4. **Dismiss.** Because a moderator can't delete an event they didn't author, 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 handled/ignored requests are cleared from *their* bell via the synced
set. 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": "<nip44(JSON.stringify({ ids: ["<eventId>", …] }))>",
"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 #### Private Crew Chat (Cordn / MLS) — the `coordinator` tag

View file

@ -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<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) });
},
});
}

View file

@ -3,6 +3,7 @@ import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useConfirmMembership } from '@/hooks/useMembershipAttestation'; import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
import { useDismissCrewNotification } from '@/hooks/useCrewDismissals';
import { import {
CREW_INVITE_KIND, CREW_INVITE_KIND,
CREW_JOIN_REQUEST_KIND, CREW_JOIN_REQUEST_KIND,
@ -19,31 +20,12 @@ import {
} from '@/lib/types'; } from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
// ─── Local dismissals ───────────────────────────────────────────────────────── // ─── Dismissals ───────────────────────────────────────────────────────────────
// The invitee can't delete an invite they didn't author, so accepted/declined // The invitee/moderator can't delete an event they didn't author, so handled
// invites are cleared from *their* notifications via a local dismissal set. // invites & join-requests are cleared from *their* notifications via a
// (The public invite event still exists on relays; it just stops nagging them.) // dismissal set. That set is a private, cross-device-synced NIP-78 list —
// see `useCrewDismissals`. The public event still exists on relays; it just
const DISMISS_KEY = 'skatespots:dismissed-crew-invites'; // stops nagging them, everywhere they log in.
function readDismissed(): Set<string> {
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
}
}
// ─── Sending an invite ────────────────────────────────────────────────────── // ─── Sending an invite ──────────────────────────────────────────────────────
@ -147,7 +129,11 @@ function republishCrewWithMember(
// ─── Reading invites addressed to me ───────────────────────────────────────── // ─── 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) { export function useMyCrewInvites(pubkey: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
@ -164,12 +150,10 @@ export function useMyCrewInvites(pubkey: string | undefined) {
{ signal }, { signal },
); );
const dismissed = readDismissed();
const seen = new Map<string, CrewInvite>(); const seen = new Map<string, CrewInvite>();
for (const ev of events) { for (const ev of events) {
const inv = parseCrewInvite(ev); const inv = parseCrewInvite(ev);
if (!inv || inv.invitee.toLowerCase() !== pubkey.toLowerCase()) continue; if (!inv || inv.invitee.toLowerCase() !== pubkey.toLowerCase()) continue;
if (dismissed.has(inv.id)) continue;
// Dedup by crew coord (addressable — newest wins). // Dedup by crew coord (addressable — newest wins).
const prev = seen.get(inv.crewCoord); const prev = seen.get(inv.crewCoord);
if (!prev || inv.createdAt > prev.createdAt) seen.set(inv.crewCoord, inv); if (!prev || inv.createdAt > prev.createdAt) seen.set(inv.crewCoord, inv);
@ -190,6 +174,7 @@ export function useAcceptCrewInvite() {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { mutateAsync: confirm } = useConfirmMembership(); const { mutateAsync: confirm } = useConfirmMembership();
const { mutateAsync: dismiss } = useDismissCrewNotification();
return useMutation({ return useMutation({
mutationFn: async ({ mutationFn: async ({
@ -211,7 +196,7 @@ export function useAcceptCrewInvite() {
visibility, visibility,
}); });
dismissInvite(invite.id); await dismiss(invite.id);
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-invites'] }); 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() { export function useDeclineCrewInvite() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { mutateAsync: dismiss } = useDismissCrewNotification();
return useMutation({ return useMutation({
mutationFn: async ({ invite }: { invite: CrewInvite }) => { mutationFn: async ({ invite }: { invite: CrewInvite }) => {
dismissInvite(invite.id); await dismiss(invite.id);
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-invites'] }); 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 // 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 // (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 // 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 // two directions are symmetric. Handled requests are cleared via the shared,
// dismissal set on the moderator's device. // cross-device-synced dismissal set (`useCrewDismissals`); filtering happens
// downstream in `useCrewNotifications`.
const JOIN_DISMISS_KEY = 'skatespots:dismissed-join-requests';
function readJoinDismissed(): Set<string> {
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
}
}
/** Publish a structured join-request to a crew's founder + admins. */ /** Publish a structured join-request to a crew's founder + admins. */
export function useSendJoinRequest() { export function useSendJoinRequest() {
@ -320,13 +286,11 @@ export function useIncomingJoinRequests(pubkey: string | undefined) {
[{ kinds: [CREW_JOIN_REQUEST_KIND], '#p': [pubkey], limit: 100 }], [{ kinds: [CREW_JOIN_REQUEST_KIND], '#p': [pubkey], limit: 100 }],
{ signal }, { signal },
); );
const dismissed = readJoinDismissed();
const seen = new Map<string, CrewJoinRequest>(); const seen = new Map<string, CrewJoinRequest>();
for (const ev of events) { for (const ev of events) {
const req = parseCrewJoinRequest(ev); const req = parseCrewJoinRequest(ev);
if (!req) continue; if (!req) continue;
if (!req.recipients.includes(pubkey.toLowerCase())) continue; if (!req.recipients.includes(pubkey.toLowerCase())) continue;
if (dismissed.has(req.id)) continue;
// Dedup by (crew + requester) — newest wins. // Dedup by (crew + requester) — newest wins.
const key = `${req.crewCoord}:${req.requester}`; const key = `${req.crewCoord}:${req.requester}`;
const prev = seen.get(key); const prev = seen.get(key);
@ -347,6 +311,7 @@ export function useApproveJoinRequest() {
const { nostr } = useNostr(); const { nostr } = useNostr();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { mutateAsync: dismiss } = useDismissCrewNotification();
return useMutation({ return useMutation({
mutationFn: async ({ request, crew }: { request: CrewJoinRequest; crew: SkateCrew }) => { mutationFn: async ({ request, crew }: { request: CrewJoinRequest; crew: SkateCrew }) => {
@ -362,7 +327,7 @@ export function useApproveJoinRequest() {
const signed = await user.signer.signEvent(updated); const signed = await user.signer.signEvent(updated);
await nostr.event(signed, { signal: AbortSignal.timeout(15000) }); await nostr.event(signed, { signal: AbortSignal.timeout(15000) });
} }
dismissJoinRequest(request.id); await dismiss(request.id);
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-join-requests'] }); 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() { export function useDismissJoinRequest() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { mutateAsync: dismiss } = useDismissCrewNotification();
return useMutation({ return useMutation({
mutationFn: async ({ request }: { request: CrewJoinRequest }) => { mutationFn: async ({ request }: { request: CrewJoinRequest }) => {
dismissJoinRequest(request.id); await dismiss(request.id);
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-join-requests'] }); queryClient.invalidateQueries({ queryKey: ['crew-join-requests'] });

View file

@ -1,6 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useMyCrewInvites, useIncomingJoinRequests } from '@/hooks/useCrewInvites'; import { useMyCrewInvites, useIncomingJoinRequests } from '@/hooks/useCrewInvites';
import { useCrewDismissals } from '@/hooks/useCrewDismissals';
import { useCrews } from '@/hooks/useCrews'; import { useCrews } from '@/hooks/useCrews';
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers'; import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
import { SKATE_CREW_KIND, type CrewInvite, type CrewJoinRequest, type SkateCrew } from '@/lib/types'; 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: invites, isLoading: invitesLoading } = useMyCrewInvites(user?.pubkey);
const { data: joinRequests, isLoading: joinLoading } = useIncomingJoinRequests(user?.pubkey); const { data: joinRequests, isLoading: joinLoading } = useIncomingJoinRequests(user?.pubkey);
const { data: crews, isLoading: crewsLoading } = useCrews(200); 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"). // Crews that list me as a member (candidates for "pending confirmation").
const listedCrews = useMemo(() => { const listedCrews = useMemo(() => {
@ -45,6 +56,8 @@ export function useCrewNotifications() {
const requestsWithCrew = useMemo(() => { const requestsWithCrew = useMemo(() => {
if (!user || !joinRequests?.length || !crews) return []; if (!user || !joinRequests?.length || !crews) return [];
return joinRequests return joinRequests
// Drop dismissed requests (synced across the moderator's devices).
.filter((req) => !dismissed?.has(req.id))
.map((req) => { .map((req) => {
const crew = crews.find( const crew = crews.find(
(c) => `${SKATE_CREW_KIND}:${c.pubkey}:${c.identifier}` === req.crewCoord, (c) => `${SKATE_CREW_KIND}:${c.pubkey}:${c.identifier}` === req.crewCoord,
@ -58,11 +71,11 @@ export function useCrewNotifications() {
crew.pubkey.toLowerCase() === user.pubkey.toLowerCase() || crew.pubkey.toLowerCase() === user.pubkey.toLowerCase() ||
crew.admins.some((a) => a.toLowerCase() === user.pubkey.toLowerCase()), crew.admins.some((a) => a.toLowerCase() === user.pubkey.toLowerCase()),
); );
}, [user, joinRequests, crews]); }, [user, joinRequests, crews, dismissed]);
return { return {
user, user,
invites: invites ?? [], invites: visibleInvites,
listedCrews, listedCrews,
joinRequests: requestsWithCrew, joinRequests: requestsWithCrew,
isLoading: invitesLoading || joinLoading || crewsLoading, isLoading: invitesLoading || joinLoading || crewsLoading,