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**.
|
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
|
||||||
|
|
||||||
|
|
|
||||||
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 { 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'] });
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue