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>
445 lines
17 KiB
TypeScript
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;
|
|
},
|
|
});
|
|
}
|