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**.
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": "<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

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 { 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<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
}
}
// ─── 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<string, CrewInvite>();
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<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
}
}
// 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<string, CrewJoinRequest>();
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'] });

View file

@ -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,