SkateSpots/src/hooks/useUserReports.ts
SkateSpots 2d1144bb7e feat(trust): batch provider reads, neutral direct follows, flagged notice
Second pass of TRUST_SCORE.md.

- useTrustAssertions now batches: every TrustBadge registers its subject into
  a shared per-viewer union and ONE query fetches the union (no per-card
  query), with a debounced refetch when a list grows. Same hook API.
- A direct follow (or self) is no longer shown as Verified - only a
  friend-of-friend or a provider assertion counts; Flagged is never
  suppressed.
- useUserReports reads public NIP-56 reports for an account, and
  TrustFlaggedNotice shows a flagged counterparty with the reporters grouped
  by the viewer's network plus Report / Dismiss. Wired into the listing
  seller card and the coach detail page.
- New walletUi.trust.* keys (en/es).
2026-10-09 17:17:11 +07:00

70 lines
2.1 KiB
TypeScript

import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { onlyVerified } from '@/lib/verifyEvents';
import type { NostrEvent } from '@nostrify/nostrify';
/** NIP-56 report kind. */
export const REPORT_KIND = 1984;
export interface UserReport {
id: string;
/** The reporter. */
pubkey: string;
createdAt: number;
content: string;
/** NIP-56 reason from the `p` tag, when present. */
reason?: string;
}
/**
* Public user-level reports (NIP-56) that name this pubkey in a `p` tag, read
* from the community relay + the viewer's relays. This is the auditable side of
* a "flagged" account: the provider asserts a count, but the raw reports are
* public, so we can show who said it in aggregate.
*/
export function useUserReports(pubkey: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const query = useQuery({
queryKey: ['user-reports', pubkey, getQueryKeySuffix()],
enabled: !!pubkey,
staleTime: 60_000,
queryFn: async (c): Promise<UserReport[]> => {
if (!pubkey) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
const events = onlyVerified(await querySkateContent(
[{ kinds: [REPORT_KIND], '#p': [pubkey], limit: 200 }],
{ signal },
));
return events.map(toUserReport).filter((r): r is UserReport => r !== null);
},
});
const reporters = useMemo(() => {
const set = new Set<string>();
for (const report of query.data ?? []) set.add(report.pubkey);
return [...set];
}, [query.data]);
return {
reports: query.data ?? [],
reporterPubkeys: reporters,
/** Distinct reporters — the anti-brigade unit, same as content reports. */
reporterCount: reporters.length,
isLoading: query.isLoading,
};
}
function toUserReport(event: NostrEvent): UserReport | null {
const pTag = event.tags.find(([name]) => name === 'p');
if (!pTag) return null;
return {
id: event.id,
pubkey: event.pubkey,
createdAt: event.created_at,
content: event.content,
reason: pTag[2],
};
}