SkateSpots/src/hooks/useVouches.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

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'] });
},
});
}