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>
146 lines
4.9 KiB
TypeScript
146 lines
4.9 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 { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
|
import {
|
|
VOUCH_LABEL_NAMESPACE,
|
|
VOUCH_LABEL_VALUE,
|
|
} from '@/lib/types';
|
|
import type { NostrEvent } from '@nostrify/nostrify';
|
|
|
|
export interface VouchData {
|
|
/** All voucher pubkeys (deduped, latest label per author). */
|
|
vouchers: string[];
|
|
/** Total vouch count. */
|
|
count: number;
|
|
/**
|
|
* Trust-weighted score: sum of each voucher's web-of-trust score (0-100)
|
|
* relative to the viewer. A ring of untrusted fakes barely moves this.
|
|
*/
|
|
weightedScore: number;
|
|
/** How many vouchers are in the viewer's web of trust (score > 0). */
|
|
trustedVouchers: number;
|
|
/** Whether the current viewer has already vouched. */
|
|
viewerVouched: boolean;
|
|
}
|
|
|
|
/**
|
|
* Query vouches for a Lab profile coordinate (`kind:pubkey:d`) and compute a
|
|
* trust-weighted score using the viewer's web of trust.
|
|
*/
|
|
export function useVouches(coord: string | undefined) {
|
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
|
const { user } = useCurrentUser();
|
|
|
|
const query = useQuery({
|
|
queryKey: ['vouches', coord, getQueryKeySuffix()],
|
|
queryFn: async (): Promise<string[]> => {
|
|
if (!coord) return [];
|
|
const signal = AbortSignal.timeout(8000);
|
|
const events = await querySkateContent(
|
|
[
|
|
{
|
|
kinds: [1985],
|
|
'#a': [coord],
|
|
'#l': [VOUCH_LABEL_VALUE],
|
|
limit: 500,
|
|
},
|
|
],
|
|
{ signal }
|
|
);
|
|
|
|
// Latest valid vouch per author.
|
|
const latest = new Map<string, NostrEvent>();
|
|
for (const ev of events) {
|
|
const hasNamespace = ev.tags.some(([n, v]) => n === 'L' && v === VOUCH_LABEL_NAMESPACE);
|
|
const hasLabel = ev.tags.some(([n, v]) => n === 'l' && v === VOUCH_LABEL_VALUE);
|
|
const refs = ev.tags.some(([n, v]) => n === 'a' && v === coord);
|
|
if (!hasNamespace || !hasLabel || !refs) continue;
|
|
// A retraction is a label with `["l","vouch"]` removed — we simply treat
|
|
// the newest event; if its content is "retracted" we drop it.
|
|
const author = ev.pubkey.toLowerCase();
|
|
const existing = latest.get(author);
|
|
if (!existing || ev.created_at > existing.created_at) latest.set(author, ev);
|
|
}
|
|
|
|
// Drop retracted vouches (content marker) and self-vouches.
|
|
const [, providerPubkey] = coord.split(':');
|
|
const vouchers: string[] = [];
|
|
for (const [author, ev] of latest) {
|
|
if (ev.content === 'retracted') continue;
|
|
if (author === providerPubkey?.toLowerCase()) continue; // no self-vouch
|
|
vouchers.push(author);
|
|
}
|
|
return vouchers;
|
|
},
|
|
enabled: !!coord,
|
|
staleTime: 30000,
|
|
});
|
|
|
|
const vouchers = query.data ?? [];
|
|
const { data: trustScores } = useBatchTrustScores(vouchers);
|
|
|
|
let weightedScore = 0;
|
|
let trustedVouchers = 0;
|
|
for (const pk of vouchers) {
|
|
const score = trustScores?.get(pk) ?? 0;
|
|
weightedScore += score;
|
|
if (score > 0) trustedVouchers += 1;
|
|
}
|
|
|
|
const data: VouchData = {
|
|
vouchers,
|
|
count: vouchers.length,
|
|
weightedScore,
|
|
trustedVouchers,
|
|
viewerVouched: !!user && vouchers.includes(user.pubkey.toLowerCase()),
|
|
};
|
|
|
|
return { ...query, data };
|
|
}
|
|
|
|
interface VouchParams {
|
|
/** Provider profile coordinate `kind:pubkey:d`. */
|
|
coord: string;
|
|
/** Provider display name (for readability). */
|
|
providerName?: string;
|
|
/** Set true to retract a previous vouch. */
|
|
retract?: boolean;
|
|
}
|
|
|
|
/** Publish (or retract) a vouch for a Lab provider, signed with the viewer's key. */
|
|
export function useVouchFor() {
|
|
const { nostr } = useNostr();
|
|
const { user } = useCurrentUser();
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ coord, providerName, retract }: VouchParams) => {
|
|
if (!user) throw new Error('You must be logged in to vouch');
|
|
const [, providerPubkey] = coord.split(':');
|
|
if (providerPubkey?.toLowerCase() === user.pubkey.toLowerCase()) {
|
|
throw new Error("You can't vouch for your own profile");
|
|
}
|
|
|
|
const event = await user.signer.signEvent({
|
|
kind: 1985,
|
|
content: retract ? 'retracted' : `Vouches for ${providerName ?? 'this skater'}`,
|
|
tags: [
|
|
['L', VOUCH_LABEL_NAMESPACE],
|
|
['l', VOUCH_LABEL_VALUE, VOUCH_LABEL_NAMESPACE],
|
|
['a', coord],
|
|
['p', providerPubkey ?? ''],
|
|
['alt', retract ? 'Retracted a SkateSpots vouch' : `Vouches for a SkateSpots provider (${providerName ?? coord})`],
|
|
],
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
});
|
|
|
|
await nostr.event(event, { signal: AbortSignal.timeout(15000) });
|
|
return event;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['vouches'] });
|
|
},
|
|
});
|
|
}
|