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>
126 lines
4.4 KiB
TypeScript
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,
|
|
});
|
|
}
|