SkateSpots/src/hooks/useCrewInvites.ts
shakespeare.diy f4bca7b736 Admins can endorse join-requests; founder ratifies (kind 9087)
Closes the admin/founder asymmetry without breaking the trust model: only the
founder authors the crew event, so only they add members — but an admin can now
publicly *vouch* for a join-request, and the founder ratifies with one click.

Protocol (SkateSpots-native kind 9087, regular event):
- e-tags the join-request, a-tags the crew, p-tags the requester (role
  "requester") and the founder (so it hits the founder's bell).
- Clients keep only endorsements authored by a real admin of the endorsed crew
  and dedupe one-per-admin-per-request (no credibility inflation).
- New parseCrewEndorsement + CrewEndorsement type; documented in NIP.md.

Hooks:
- useEndorseJoinRequest (admin publishes an endorsement, admin-gated).
- useJoinRequestEndorsements: batch-fetch endorsements for all visible requests
  in ONE #e query, grouped per request, admin-verified per crew.
- useCrewNotifications attaches endorsements to each request entry.

UX (JoinRequestCard):
- Founder: primary becomes "Approve & add" when vouched, with a vouch strip
  (stacked admin avatars + "Vouched by Rider +1 more") as social proof.
- Admin: the old dead-end "Only the founder can add members" becomes a real
  "Vouch for them" action, then a "Vouched — waiting on the founder" state,
  plus a one-line explainer so the path isn't a mystery.
- Endorsements enrich the existing request card (no duplicate notification).

Build clean, no console errors. Not deployed.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 01:40:04 +07:00

445 lines
17 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
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,
CREW_ENDORSEMENT_KIND,
SKATE_CREW_KIND,
crewInviteD,
crewJoinRequestD,
parseCrewInvite,
parseCrewJoinRequest,
parseCrewEndorsement,
parseAddrCoord,
type CrewInvite,
type CrewJoinRequest,
type CrewEndorsement,
type SkateCrew,
type MemberVisibility,
} from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
// ─── 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 ──────────────────────────────────────────────────────
interface SendInviteParams {
crew: SkateCrew;
/** Invitee pubkey (hex). */
invitee: string;
role?: 'member' | 'admin';
note?: string;
/** Whether the current user is the crew founder (can also add the p-tag). */
isFounder: boolean;
}
/**
* Send a structured crew invite to a specific pubkey.
*
* The invite is a public, addressable event (kind 36229) that `p`-tags the
* invitee so they can query "invites addressed to me". It carries nothing
* sensitive — real membership stays unforgeable (founder `p` tag + invitee
* attestation).
*
* When the sender is the FOUNDER, we optimistically add the invitee to the
* crew's member (`p`) list at invite time, so the moment they accept (attest)
* they show as a confirmed member with no extra founder step. When an ADMIN
* invites, they can't edit the founder's event, so the invite alone is sent and
* the founder confirms the add on accept.
*/
export function useSendCrewInvite() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ crew, invitee, role = 'member', note = '', isFounder }: SendInviteParams) => {
if (!user) throw new Error('You must be logged in to invite.');
const crewCoord = `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`;
const inviteePk = invitee.toLowerCase();
if (inviteePk === user.pubkey.toLowerCase()) {
throw new Error("You can't invite yourself.");
}
// 1. Publish the invite event.
const inviteEvent = await user.signer.signEvent({
kind: CREW_INVITE_KIND,
content: note.slice(0, 500),
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', crewInviteD(crewCoord, inviteePk)],
['p', inviteePk],
['a', crewCoord],
['crew_name', crew.name],
['role', role],
['alt', `Crew invite: join ${crew.name} on SkateSpots`],
],
});
await nostr.event(inviteEvent, { signal: AbortSignal.timeout(15000) });
// 2. Founder optimistically pre-lists the invitee so accept = instant confirm.
if (isFounder && user.pubkey.toLowerCase() === crew.pubkey.toLowerCase()) {
const already = crew.members.some((m) => m.toLowerCase() === inviteePk);
if (!already) {
const updated = republishCrewWithMember(crew.event, inviteePk, role);
const signed = await user.signer.signEvent(updated);
await nostr.event(signed, { signal: AbortSignal.timeout(15000) });
}
}
return inviteEvent;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['skate-crews'] });
queryClient.invalidateQueries({ queryKey: ['skate-crew'] });
queryClient.invalidateQueries({ queryKey: ['crew-invites'] });
},
});
}
/**
* Rebuild a crew event's tags with an added member `p` tag, preserving all
* other tags. Returns an unsigned event template ready to sign.
*/
function republishCrewWithMember(
crewEvent: NostrEvent,
memberPubkey: string,
role: 'member' | 'admin',
): { kind: number; content: string; created_at: number; tags: string[][] } {
const tags = crewEvent.tags.filter(
([n, v]) => !(n === 'p' && v?.toLowerCase() === memberPubkey.toLowerCase()),
);
if (role === 'admin') tags.push(['p', memberPubkey, '', 'admin']);
else tags.push(['p', memberPubkey]);
return {
kind: crewEvent.kind,
content: crewEvent.content,
created_at: Math.floor(Date.now() / 1000),
tags,
};
}
// ─── Reading invites addressed to me ─────────────────────────────────────────
/**
* 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();
return useQuery({
queryKey: ['crew-invites', 'incoming', pubkey, getQueryKeySuffix()],
enabled: !!pubkey,
staleTime: 30_000,
refetchOnMount: 'always',
queryFn: async (): Promise<CrewInvite[]> => {
if (!pubkey) return [];
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[{ kinds: [CREW_INVITE_KIND], '#p': [pubkey], limit: 100 }],
{ signal },
);
const seen = new Map<string, CrewInvite>();
for (const ev of events) {
const inv = parseCrewInvite(ev);
if (!inv || inv.invitee.toLowerCase() !== pubkey.toLowerCase()) 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);
}
return [...seen.values()].sort((a, b) => b.createdAt - a.createdAt);
},
});
}
// ─── Accept / decline ─────────────────────────────────────────────────────────
/**
* Accept a crew invite: publish the invitee's own NIP-32 membership attestation
* (kind 1985). Once the founder has listed them (founders pre-list on invite),
* they show as a confirmed member. The invite is dismissed locally.
*/
export function useAcceptCrewInvite() {
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const { mutateAsync: confirm } = useConfirmMembership();
const { mutateAsync: dismiss } = useDismissCrewNotification();
return useMutation({
mutationFn: async ({
invite,
visibility = 'public',
}: {
invite: CrewInvite;
visibility?: MemberVisibility;
}) => {
if (!user) throw new Error('You must be logged in.');
const coord = parseAddrCoord(invite.crewCoord);
if (!coord) throw new Error('Invalid crew reference.');
await confirm({
kind: coord.kind,
founderPubkey: coord.pubkey,
identifier: coord.identifier,
collectiveName: invite.crewName,
visibility,
});
await dismiss(invite.id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-invites'] });
queryClient.invalidateQueries({ queryKey: ['collective-members'] });
queryClient.invalidateQueries({ queryKey: ['skate-crews'] });
},
});
}
/** 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 }) => {
await dismiss(invite.id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-invites'] });
},
});
}
// ─── Join requests (notifiable) ───────────────────────────────────────────────
// 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 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() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
crew,
note = '',
}: {
crew: SkateCrew;
note?: string;
}) => {
if (!user) throw new Error('You must be logged in to request to join.');
const crewCoord = `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`;
const recipients = Array.from(new Set([crew.pubkey, ...crew.admins])).filter(
(pk) => pk.toLowerCase() !== user.pubkey.toLowerCase(),
);
const event = await user.signer.signEvent({
kind: CREW_JOIN_REQUEST_KIND,
content: note.slice(0, 500),
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', crewJoinRequestD(crewCoord, user.pubkey.toLowerCase())],
['a', crewCoord],
['crew_name', crew.name],
...recipients.map((pk) => ['p', pk]),
['alt', `Crew join request: ${crew.name} on SkateSpots`],
],
});
await nostr.event(event, { signal: AbortSignal.timeout(15000) });
return event;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-join-requests'] });
},
});
}
/** Incoming join-requests addressed to the given moderator (founder/admin). */
export function useIncomingJoinRequests(pubkey: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['crew-join-requests', 'incoming', pubkey, getQueryKeySuffix()],
enabled: !!pubkey,
staleTime: 30_000,
refetchOnMount: 'always',
queryFn: async (): Promise<CrewJoinRequest[]> => {
if (!pubkey) return [];
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[{ kinds: [CREW_JOIN_REQUEST_KIND], '#p': [pubkey], limit: 100 }],
{ signal },
);
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;
// Dedup by (crew + requester) — newest wins.
const key = `${req.crewCoord}:${req.requester}`;
const prev = seen.get(key);
if (!prev || req.createdAt > prev.createdAt) seen.set(key, req);
}
return [...seen.values()].sort((a, b) => b.createdAt - a.createdAt);
},
});
}
/**
* Approve a join-request: the FOUNDER adds the requester to the crew's member
* (`p`) list. The requester then confirms with their own key (standard
* attestation). Only the founder can do this (they author the crew event); an
* admin dismisses/handles out-of-band.
*/
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 }) => {
if (!user) throw new Error('You must be logged in.');
if (user.pubkey.toLowerCase() !== crew.pubkey.toLowerCase()) {
throw new Error('Only the crew founder can add members.');
}
const alreadyMember = crew.members.some(
(m) => m.toLowerCase() === request.requester.toLowerCase(),
);
if (!alreadyMember) {
const updated = republishCrewWithMember(crew.event, request.requester, 'member');
const signed = await user.signer.signEvent(updated);
await nostr.event(signed, { signal: AbortSignal.timeout(15000) });
}
await dismiss(request.id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-join-requests'] });
queryClient.invalidateQueries({ queryKey: ['skate-crews'] });
queryClient.invalidateQueries({ queryKey: ['skate-crew'] });
},
});
}
/** 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 }) => {
await dismiss(request.id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-join-requests'] });
},
});
}
// ─── Admin endorsements (admin proposes → founder confirms) ────────────────────
// Only the founder can edit the roster, so an admin can't add a member. Instead
// an admin publicly *vouches* for a request (kind 9087, p-tagging the founder).
// The founder sees the endorsement(s) on the request card and ratifies with one
// click — closing the admin/founder asymmetry without breaking the trust model.
/** An admin endorses ("vouches for") a join-request so the founder can ratify. */
export function useEndorseJoinRequest() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
request,
crew,
note = '',
}: {
request: CrewJoinRequest;
crew: SkateCrew;
note?: string;
}) => {
if (!user) throw new Error('You must be logged in.');
const isAdmin = crew.admins.some((a) => a.toLowerCase() === user.pubkey.toLowerCase());
if (!isAdmin) throw new Error('Only a crew admin can endorse a request.');
const event = await user.signer.signEvent({
kind: CREW_ENDORSEMENT_KIND,
content: note.slice(0, 300),
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', request.id], // the request being endorsed
['a', request.crewCoord], // the crew
['p', request.requester, '', 'requester'], // who's being vouched for
['p', crew.pubkey], // surface to the founder's bell
['alt', `Crew endorsement: vouching for a new member of ${crew.name}`],
],
});
await nostr.event(event, { signal: AbortSignal.timeout(15000) });
return event;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-endorsements'] });
},
});
}
/**
* Fetch endorsements for a set of join-request ids in ONE query, grouped by
* request id. Only endorsements from actual crew admins are kept (an outsider
* can't inflate a request's credibility).
*/
export function useJoinRequestEndorsements(
requestIds: string[],
adminsByCrewCoord: Map<string, Set<string>>,
) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const ids = [...requestIds].sort();
return useQuery({
queryKey: ['crew-endorsements', ids, getQueryKeySuffix()],
enabled: ids.length > 0,
staleTime: 30_000,
queryFn: async (): Promise<Map<string, CrewEndorsement[]>> => {
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[{ kinds: [CREW_ENDORSEMENT_KIND], '#e': ids, limit: 200 }],
{ signal },
);
const byRequest = new Map<string, CrewEndorsement[]>();
const seen = new Set<string>(); // dedupe one endorser per request
for (const ev of events) {
const end = parseCrewEndorsement(ev);
if (!end || !ids.includes(end.requestId)) continue;
// Endorser must be an admin of the endorsed crew.
const admins = adminsByCrewCoord.get(end.crewCoord);
if (!admins || !admins.has(end.endorser.toLowerCase())) continue;
const key = `${end.requestId}:${end.endorser.toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
const arr = byRequest.get(end.requestId) ?? [];
arr.push(end);
byRequest.set(end.requestId, arr);
}
return byRequest;
},
});
}