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:
parent
042e4af8ed
commit
0f2215f46c
4 changed files with 227 additions and 65 deletions
38
NIP.md
38
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": "<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
|
||||
|
||||
|
|
|
|||
153
src/hooks/useCrewDismissals.ts
Normal file
153
src/hooks/useCrewDismissals.ts
Normal 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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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'] });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue