SkateSpots/src/hooks/useCollectiveMembers.ts
shakespeare.diy 926470a86e Crews: opt-in member visibility + Lab vouch/web-of-trust anti-fraud + CrewDetail redesign
Step 3 — member visibility (best-of-both, honest):
- MemberVisibility ('public' | 'crew') stored on the member's OWN kind-1985
  attestation via a `visibility` tag, so a public crew can contain a private
  member. Confirmed crew-mates + founder see them; outsiders see anonymised
  "hidden member" slots. Framed honestly in-UI (app-level discovery privacy).
- VisibilityPicker in ConfirmMembershipCard (choose on join) +
  MemberVisibilityControl for confirmed members to change it anytime.
- useCollectiveMembers parses visibility; CollectiveMembers hides crew-only
  members from non-members.

Step 4 — anti-fraud vouch layer:
- useVouches/useVouchFor: NIP-32 vouch labels, trust-WEIGHTED via web-of-trust
  so fake-account vouch rings carry no weight. VouchCard with new/vouched/
  scene-verified tiers, wired into every Lab profile detail page.

Step 4 — CrewDetail UX redesign:
- Activity consolidated into clean Feed/Events/Chat tabs (no more giant scroll)
- Sidebar reordered: Members → Support → Band → Connections → Founder
- Member visibility control surfaced; tighter hero

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-08-10 21:01:57 +07:00

126 lines
4.4 KiB
TypeScript

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<CollectiveMember[]> => {
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<string, NostrEvent>();
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,
});
}