import { useQuery } from '@tanstack/react-query'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { MEMBERSHIP_LABEL_NAMESPACE, MEMBERSHIP_LABEL_VALUE, parseMemberVisibility, type MemberVisibility } from '@/lib/types'; import type { NostrEvent } from '@nostrify/nostrify'; export type MembershipStatus = 'confirmed' | 'pending'; export interface CollectiveMember { pubkey: string; status: MembershipStatus; /** The confirming label event (kind 1985), if the member has attested. */ attestation?: NostrEvent; /** * Member's opt-in visibility from their own attestation. `public` (default) * or `crew` (hidden from non-members). Pending members are treated as * `public` until they attest with a choice. */ visibility: MemberVisibility; } interface UseCollectiveMembersParams { /** Kind of the collective profile (e.g. 35002). */ kind: number; /** Founder pubkey (author of the collective profile). */ founderPubkey: string; /** d-tag identifier of the collective profile. */ identifier: string; /** Member pubkeys (hex) the collective claims via `p` tags. */ claimedMembers: string[]; /** Whether the profile is actually a collective (skip query for individuals). */ enabled?: boolean; } /** * Cross-reference a collective's claimed members (`p` tags on the profile) * against NIP-32 membership attestations that each member publishes with * their OWN key. A member is: * - `confirmed`: claimed by the collective AND has published a matching * kind 1985 label referencing this collective's address. * - `pending`: claimed by the collective but has not (yet) attested. * * This makes membership unforgeable: the collective cannot fake a member's * confirmation, and a non-member's stray attestation is ignored unless the * collective also lists them. */ export function useCollectiveMembers({ kind, founderPubkey, identifier, claimedMembers, enabled = true, }: UseCollectiveMembersParams) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const address = `${kind}:${founderPubkey}:${identifier}`; return useQuery({ queryKey: ['collective-members', address, claimedMembers.join(','), getQueryKeySuffix()], queryFn: async (): Promise => { if (claimedMembers.length === 0) return []; const signal = AbortSignal.timeout(8000); // Query NIP-32 label events from the claimed members that reference // this collective's addressable coordinate. const events = await querySkateContent( [ { kinds: [1985], authors: claimedMembers, '#a': [address], '#l': [MEMBERSHIP_LABEL_VALUE], limit: claimedMembers.length * 2, }, ], { signal } ); // Build a map of pubkey -> confirming attestation (latest wins). const confirmed = new Map(); for (const ev of events) { // Validate it's a genuine membership label for THIS collective. const hasNamespace = ev.tags.some( ([n, v]) => n === 'L' && v === MEMBERSHIP_LABEL_NAMESPACE ); const hasLabel = ev.tags.some( ([n, v]) => n === 'l' && v === MEMBERSHIP_LABEL_VALUE ); const refsCollective = ev.tags.some(([n, v]) => n === 'a' && v === address); if (!hasNamespace || !hasLabel || !refsCollective) continue; const author = ev.pubkey.toLowerCase(); // Only count members the collective actually claims. if (!claimedMembers.includes(author)) continue; const existing = confirmed.get(author); if (!existing || ev.created_at > existing.created_at) { confirmed.set(author, ev); } } // Produce a status for every claimed member, confirmed first. const members: CollectiveMember[] = claimedMembers.map((pubkey) => { const attestation = confirmed.get(pubkey); const visibility = parseMemberVisibility( attestation?.tags.find(([n]) => n === 'visibility')?.[1] ); return { pubkey, status: attestation ? 'confirmed' : 'pending', attestation, visibility, }; }); members.sort((a, b) => { if (a.status === b.status) return 0; return a.status === 'confirmed' ? -1 : 1; }); return members; }, enabled: enabled && claimedMembers.length > 0, staleTime: 30000, }); }