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).
This commit is contained in:
parent
fead9ca781
commit
2d1144bb7e
12 changed files with 354 additions and 25 deletions
17
AGENTS.md
17
AGENTS.md
|
|
@ -2519,12 +2519,17 @@ kind-30382 assertion parsing; absence = unknown, never distrust),
|
||||||
`src/lib/trustLadder.ts` (buckets Verified/Unknown/Flagged;
|
`src/lib/trustLadder.ts` (buckets Verified/Unknown/Flagged;
|
||||||
`FLAGGED_REPORTERS_THRESHOLD` 2), `src/hooks/useTrustBucket.ts`, and
|
`FLAGGED_REPORTERS_THRESHOLD` 2), `src/hooks/useTrustBucket.ts`, and
|
||||||
`components/TrustBadge.tsx` now renders the three buckets (Unknown hidden by
|
`components/TrustBadge.tsx` now renders the three buckets (Unknown hidden by
|
||||||
default, Flagged always shown). `TrustBadge` is used by the map/tour/event cards,
|
default, Flagged always shown, and Verified suppressed for accounts you already
|
||||||
marketplace card, listing detail and coach detail, so those surfaces get it for
|
follow). `TrustBadge` is used by the map/tour/event cards, marketplace card,
|
||||||
free. Still open: batch the bucket at list level (currently one provider query
|
listing detail and coach detail, so those surfaces get it for free. The provider
|
||||||
per card), a "who reported" breakdown, a provider-choice setting, alerts, and
|
read is **batched** (one query per viewer for the union of on-screen subjects,
|
||||||
the confidence-gated scoring model. Check `TRUST_SCORE.md` before touching
|
in `useTrustAssertions.ts`), and `useUserReports` + `components/TrustFlaggedNotice.tsx`
|
||||||
`useTrustScore` / `useRelatrScore` / `trustFilter`.
|
add a flagged-counterparty notice (reporters grouped by the viewer's network,
|
||||||
|
Report/Dismiss) wired into the listing seller and coach detail. Still open: a
|
||||||
|
provider-choice setting, a mute action (no NIP-51 kind-10000 hook exists yet),
|
||||||
|
counterparty alerts, and the confidence-gated scoring model. Check
|
||||||
|
`TRUST_SCORE.md` before touching `useTrustScore` / `useRelatrScore` /
|
||||||
|
`trustFilter`.
|
||||||
|
|
||||||
**Keep the door open (cheap):** admin keys and relay sets must stay *data you can
|
**Keep the door open (cheap):** admin keys and relay sets must stay *data you can
|
||||||
swap*, not constants. Hardcoded `ADMIN_PUBKEYS` is fine now — converting it later
|
swap*, not constants. Hardcoded `ADMIN_PUBKEYS` is fine now — converting it later
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,29 @@ Still open from this slice: precompute the bucket at list level and pass it down
|
||||||
(the assertion carries counts, not identities); and a Settings control to pick
|
(the assertion carries counts, not identities); and a Settings control to pick
|
||||||
the provider instead of only using the designation or house default.
|
the provider instead of only using the designation or house default.
|
||||||
|
|
||||||
|
## Second pass (landed)
|
||||||
|
|
||||||
|
- **Batched provider read.** Every `TrustBadge` registers its subject into a
|
||||||
|
shared per-viewer union and ONE query fetches the whole union (no per-card
|
||||||
|
query); a list that grows triggers a single debounced refetch. Contained to
|
||||||
|
`useTrustAssertions.ts` — no call site changed.
|
||||||
|
- **Direct follow is neutral.** `bucketFromLocalScore` now verifies only a
|
||||||
|
friend-of-friend (60); a direct follow (90) or self (100) shows nothing, since
|
||||||
|
the viewer already knows them. A provider assertion can still verify a
|
||||||
|
non-followed account, and `TrustBadge` suppresses verified when the viewer
|
||||||
|
directly follows. Flagged is never suppressed.
|
||||||
|
- **Flagged notice with a breakdown and actions.** `useUserReports` reads the
|
||||||
|
public NIP-56 reports naming an account; `components/TrustFlaggedNotice.tsx`
|
||||||
|
shows the reporters grouped as people you follow / in your network / other,
|
||||||
|
plus **Report** (reuses `ReportDialog`) and **Dismiss** (session-only, so the
|
||||||
|
safety signal returns). Wired into the listing seller card and the coach
|
||||||
|
detail page.
|
||||||
|
|
||||||
|
Still open: a Settings control to pick the trust provider; a mute action (there
|
||||||
|
is no NIP-51 kind-10000 hook yet — Dismiss is the local equivalent for now);
|
||||||
|
counterparty alerts; and the confidence-gated scoring model.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Source material: the **GrapeRank** and **Trusted Assertions** specifications in
|
Source material: the **GrapeRank** and **Trusted Assertions** specifications in
|
||||||
the `NosFabrica/protocols` repository, plus a public reference
|
the `NosFabrica/protocols` repository, plus a public reference
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,12 @@ export function TrustBadge({
|
||||||
showUnknown = false,
|
showUnknown = false,
|
||||||
}: TrustBadgeProps) {
|
}: TrustBadgeProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { bucket, assertion, isLoading } = useTrustBucket(pubkey);
|
const { bucket, assertion, directFollow, isLoading } = useTrustBucket(pubkey);
|
||||||
|
|
||||||
if (isLoading) return null;
|
if (isLoading) return null;
|
||||||
|
// A direct follow (or yourself) is already known to the viewer — a "Verified"
|
||||||
|
// mark there is redundant. Flagged is a safety signal and is never suppressed.
|
||||||
|
if (bucket === 'verified' && directFollow) return null;
|
||||||
if (bucket === 'unknown' && !showUnknown) return null;
|
if (bucket === 'unknown' && !showUnknown) return null;
|
||||||
|
|
||||||
const { className: bucketClass, Icon } = BUCKET_STYLE[bucket];
|
const { className: bucketClass, Icon } = BUCKET_STYLE[bucket];
|
||||||
|
|
|
||||||
119
src/components/TrustFlaggedNotice.tsx
Normal file
119
src/components/TrustFlaggedNotice.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { AlertTriangle, Flag } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ReportDialog } from '@/components/ReportDialog';
|
||||||
|
import { useTrustBucket } from '@/hooks/useTrustBucket';
|
||||||
|
import { useUserReports } from '@/hooks/useUserReports';
|
||||||
|
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||||
|
import { FLAGGED_REPORTERS_THRESHOLD } from '@/lib/trustLadder';
|
||||||
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TrustFlaggedNoticeProps {
|
||||||
|
pubkey: string;
|
||||||
|
targetTitle?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A safety notice for a flagged counterparty (seller, coach, organiser). It is
|
||||||
|
* informational, not an automatic takedown: it shows that trusted people
|
||||||
|
* reported the account, breaks the reporters down by the viewer's own network,
|
||||||
|
* and offers Report / Dismiss. Dismissal is session-only — a safety signal
|
||||||
|
* reappears on the next visit rather than staying hidden forever.
|
||||||
|
*/
|
||||||
|
export function TrustFlaggedNotice({ pubkey, targetTitle, className }: TrustFlaggedNoticeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { bucket, assertion } = useTrustBucket(pubkey);
|
||||||
|
const { reporterPubkeys, reporterCount } = useUserReports(pubkey);
|
||||||
|
const { data: scores } = useBatchTrustScores(reporterPubkeys);
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const [reportOpen, setReportOpen] = useState(false);
|
||||||
|
|
||||||
|
const flagged = bucket === 'flagged' || reporterCount >= FLAGGED_REPORTERS_THRESHOLD;
|
||||||
|
if (!flagged || dismissed) return null;
|
||||||
|
|
||||||
|
let follow = 0;
|
||||||
|
let network = 0;
|
||||||
|
let other = 0;
|
||||||
|
for (const pk of reporterPubkeys) {
|
||||||
|
const score = scores?.get(pk);
|
||||||
|
if (score !== undefined && score >= 90) follow += 1;
|
||||||
|
else if (score === 60) network += 1;
|
||||||
|
else other += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trustedCount = assertion?.reporters ?? reporterCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('rounded-2xl border-2 border-red-500/40 bg-red-500/10 p-4', className)}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-red-400" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-black uppercase tracking-wide text-red-300">
|
||||||
|
{t('walletUi.trust.flaggedNoticeTitle')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-gray-300">
|
||||||
|
{t('walletUi.trust.flaggedNoticeDesc')}
|
||||||
|
</p>
|
||||||
|
{trustedCount > 0 && (
|
||||||
|
<p className="mt-1 text-xs text-gray-400">
|
||||||
|
{t('walletUi.trust.reportedByTrusted', { count: trustedCount })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(follow > 0 || network > 0 || other > 0) && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{follow > 0 && (
|
||||||
|
<Chip label={t('walletUi.trust.flaggedReporterFollow', { count: follow })} />
|
||||||
|
)}
|
||||||
|
{network > 0 && (
|
||||||
|
<Chip label={t('walletUi.trust.flaggedReporterNetwork', { count: network })} />
|
||||||
|
)}
|
||||||
|
{other > 0 && (
|
||||||
|
<Chip label={t('walletUi.trust.flaggedReporterOther', { count: other })} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 min-h-0 gap-1.5 border-red-500/40 text-red-300 hover:bg-red-500/10"
|
||||||
|
onClick={() => setReportOpen(true)}
|
||||||
|
>
|
||||||
|
<Flag className="h-3.5 w-3.5" />
|
||||||
|
{t('walletUi.trust.flaggedReport')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 min-h-0 text-gray-400"
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
>
|
||||||
|
{t('walletUi.trust.flaggedDismiss')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReportDialog
|
||||||
|
open={reportOpen}
|
||||||
|
onOpenChange={setReportOpen}
|
||||||
|
targetType="user"
|
||||||
|
targetPubkey={pubkey}
|
||||||
|
targetTitle={targetTitle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chip({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<span className="rounded-full bg-red-500/15 px-2 py-0.5 text-[10px] font-medium text-red-200">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { useNostr } from '@nostrify/react';
|
import { useNostr } from '@nostrify/react';
|
||||||
import type { NostrEvent } from '@nostrify/nostrify';
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
@ -15,37 +16,107 @@ import {
|
||||||
type UserTrustAssertion,
|
type UserTrustAssertion,
|
||||||
} from '@/lib/nip85';
|
} from '@/lib/nip85';
|
||||||
|
|
||||||
const MAX_TARGETS = 500;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read kind-30382 Trusted Assertions (NIP-85) for a batch of subjects, from the
|
* Read kind-30382 Trusted Assertions (NIP-85) for a set of subjects, from the
|
||||||
* provider the viewer designates via their own kind-10040 — falling back to the
|
* provider the viewer designates via their own kind-10040 — falling back to the
|
||||||
* configured house provider. Signatures are verified and the event author must
|
* configured house provider. Signatures are verified and the event author must
|
||||||
* equal the resolved provider before a score is used.
|
* equal the resolved provider before a score is used.
|
||||||
*
|
*
|
||||||
|
* BATCHING
|
||||||
|
* --------
|
||||||
|
* TrustBadge is rendered once per card, so a naive hook would fire one relay
|
||||||
|
* query per author. Instead every consumer registers its targets into a shared
|
||||||
|
* per-viewer union, and ONE query fetches the whole union; each caller then
|
||||||
|
* reads only its own subjects out of the result. New targets arriving later
|
||||||
|
* (an infinite list) trigger a single debounced refetch.
|
||||||
|
*
|
||||||
* A missing assertion yields no entry: that means "nothing published", which is
|
* A missing assertion yields no entry: that means "nothing published", which is
|
||||||
* NOT the same as distrust. Callers must treat absence as `unknown`.
|
* NOT the same as distrust. Callers must treat absence as `unknown`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const MAX_UNION = 600;
|
||||||
|
const BATCH_WINDOW_MS = 100;
|
||||||
|
|
||||||
|
interface PendingBatch {
|
||||||
|
targets: Set<string>;
|
||||||
|
timer: ReturnType<typeof setTimeout> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batches = new Map<string, PendingBatch>();
|
||||||
|
|
||||||
|
function batchFor(viewer: string): PendingBatch {
|
||||||
|
let batch = batches.get(viewer);
|
||||||
|
if (!batch) {
|
||||||
|
batch = { targets: new Set(), timer: null };
|
||||||
|
batches.set(viewer, batch);
|
||||||
|
}
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the targets one consumer needs. Returns true when the union grew, so
|
||||||
|
* the caller can schedule a refetch. Idempotent, and deliberately called during
|
||||||
|
* render so a whole grid registers before the shared query's first pass.
|
||||||
|
*/
|
||||||
|
function registerTargets(viewer: string, targets: string[]): boolean {
|
||||||
|
const batch = batchFor(viewer);
|
||||||
|
let grew = false;
|
||||||
|
for (const target of targets) {
|
||||||
|
if (!batch.targets.has(target)) {
|
||||||
|
batch.targets.add(target);
|
||||||
|
grew = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cap the union (insertion order) so a long session cannot grow forever.
|
||||||
|
while (batch.targets.size > MAX_UNION) {
|
||||||
|
const oldest = batch.targets.values().next().value;
|
||||||
|
if (oldest === undefined) break;
|
||||||
|
batch.targets.delete(oldest);
|
||||||
|
}
|
||||||
|
return grew;
|
||||||
|
}
|
||||||
|
|
||||||
export function useTrustAssertions(pubkeys: string[]) {
|
export function useTrustAssertions(pubkeys: string[]) {
|
||||||
const { nostr } = useNostr();
|
const { nostr } = useNostr();
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const targets = [...pubkeys].sort().slice(0, MAX_TARGETS);
|
const viewer = user?.pubkey ?? '';
|
||||||
|
const key = useMemo(() => [...new Set(pubkeys)].sort().join(','), [pubkeys]);
|
||||||
|
const targets = useMemo(() => (key ? key.split(',') : []), [key]);
|
||||||
|
|
||||||
return useQuery({
|
const grew = registerTargets(viewer, targets);
|
||||||
queryKey: ['trust-assertions', targets.join(','), user?.pubkey],
|
|
||||||
|
const queryKey = useMemo(() => ['trust-assertions-batch', viewer], [viewer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!grew) return;
|
||||||
|
// The first fetch already sees the targets registered before it ran; only a
|
||||||
|
// later addition to an established batch needs a refetch.
|
||||||
|
if (queryClient.getQueryState(queryKey)?.data === undefined) return;
|
||||||
|
const batch = batchFor(viewer);
|
||||||
|
if (batch.timer) clearTimeout(batch.timer);
|
||||||
|
batch.timer = setTimeout(() => {
|
||||||
|
batch.timer = null;
|
||||||
|
queryClient.invalidateQueries({ queryKey });
|
||||||
|
}, BATCH_WINDOW_MS);
|
||||||
|
}, [grew, viewer, queryKey, queryClient]);
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey,
|
||||||
queryFn: async (c): Promise<Map<string, UserTrustAssertion>> => {
|
queryFn: async (c): Promise<Map<string, UserTrustAssertion>> => {
|
||||||
const out = new Map<string, UserTrustAssertion>();
|
const out = new Map<string, UserTrustAssertion>();
|
||||||
if (targets.length === 0) return out;
|
const all = [...batchFor(viewer).targets];
|
||||||
|
if (all.length === 0) return out;
|
||||||
|
|
||||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
|
||||||
|
|
||||||
// 1. Resolve the provider: the viewer's own designation, else the house.
|
// 1. Resolve the provider: the viewer's own designation, else the house.
|
||||||
let provider: TrustProvider = HOUSE_TRUST_PROVIDER;
|
let provider: TrustProvider = HOUSE_TRUST_PROVIDER;
|
||||||
try {
|
try {
|
||||||
if (user?.pubkey) {
|
if (viewer) {
|
||||||
const designationEvents = onlyVerified(await nostr.query(
|
const designationEvents = onlyVerified(await nostr.query(
|
||||||
[{ kinds: [NIP85_PROVIDER_LIST_KIND], authors: [user.pubkey], limit: 1 }],
|
[{ kinds: [NIP85_PROVIDER_LIST_KIND], authors: [viewer], limit: 1 }],
|
||||||
{ signal },
|
{ signal },
|
||||||
));
|
));
|
||||||
const designation = parseProviderDesignation(designationEvents[0]);
|
const designation = parseProviderDesignation(designationEvents[0]);
|
||||||
|
|
@ -60,12 +131,12 @@ export function useTrustAssertions(pubkeys: string[]) {
|
||||||
// Designation lookup failed — the house provider is the fallback.
|
// Designation lookup failed — the house provider is the fallback.
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch the assertions for these subjects from the resolved provider.
|
// 2. Fetch the assertions for the whole batch from the resolved provider.
|
||||||
const filter = {
|
const filter = {
|
||||||
kinds: [NIP85_USER_ASSERTION_KIND],
|
kinds: [NIP85_USER_ASSERTION_KIND],
|
||||||
authors: [provider.pubkey],
|
authors: [provider.pubkey],
|
||||||
'#d': targets,
|
'#d': all,
|
||||||
limit: targets.length,
|
limit: all.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -90,4 +161,18 @@ export function useTrustAssertions(pubkeys: string[]) {
|
||||||
gcTime: 24 * 60 * 60 * 1000,
|
gcTime: 24 * 60 * 60 * 1000,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hand each caller only its own subjects out of the shared batch result.
|
||||||
|
const data = useMemo(() => {
|
||||||
|
const full = query.data;
|
||||||
|
if (!full) return undefined;
|
||||||
|
const out = new Map<string, UserTrustAssertion>();
|
||||||
|
for (const target of targets) {
|
||||||
|
const assertion = full.get(target);
|
||||||
|
if (assertion) out.set(target, assertion);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [query.data, targets]);
|
||||||
|
|
||||||
|
return { data, isLoading: query.isLoading, isFetching: query.isFetching };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ export interface TrustBucketResult {
|
||||||
assertion?: UserTrustAssertion;
|
assertion?: UserTrustAssertion;
|
||||||
/** Local web-of-trust score (0-100), when available. */
|
/** Local web-of-trust score (0-100), when available. */
|
||||||
localScore?: number;
|
localScore?: number;
|
||||||
|
/** The viewer directly follows this account (or it is the viewer). */
|
||||||
|
directFollow: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,11 +27,13 @@ export function useTrustBucket(pubkey: string | undefined): TrustBucketResult {
|
||||||
|
|
||||||
const assertion = pubkey ? assertions?.get(pubkey) : undefined;
|
const assertion = pubkey ? assertions?.get(pubkey) : undefined;
|
||||||
const bucket = assertion ? bucketFromAssertion(assertion) : bucketFromLocalScore(localScore);
|
const bucket = assertion ? bucketFromAssertion(assertion) : bucketFromLocalScore(localScore);
|
||||||
|
const directFollow = localScore !== undefined && localScore >= 90;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bucket,
|
bucket,
|
||||||
assertion,
|
assertion,
|
||||||
localScore,
|
localScore,
|
||||||
|
directFollow,
|
||||||
isLoading: localLoading || assertionsLoading,
|
isLoading: localLoading || assertionsLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
70
src/hooks/useUserReports.ts
Normal file
70
src/hooks/useUserReports.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
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],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -6143,6 +6143,13 @@ walletUi: {
|
||||||
top10Hint: 'Reach the top 10 supporters',
|
top10Hint: 'Reach the top 10 supporters',
|
||||||
},
|
},
|
||||||
trust: {
|
trust: {
|
||||||
|
flaggedNoticeTitle: 'Reported by trusted skaters',
|
||||||
|
flaggedNoticeDesc: 'Take care. Trusted people in the network have reported this account.',
|
||||||
|
flaggedReporterFollow: '{{count}} you follow',
|
||||||
|
flaggedReporterNetwork: '{{count}} in your network',
|
||||||
|
flaggedReporterOther: '{{count}} other accounts',
|
||||||
|
flaggedReport: 'Report',
|
||||||
|
flaggedDismiss: 'Dismiss',
|
||||||
bucketVerified: 'Verified',
|
bucketVerified: 'Verified',
|
||||||
bucketUnknown: 'Unknown',
|
bucketUnknown: 'Unknown',
|
||||||
bucketFlagged: 'Flagged',
|
bucketFlagged: 'Flagged',
|
||||||
|
|
|
||||||
|
|
@ -6143,6 +6143,13 @@ walletUi: {
|
||||||
top10Hint: 'Entra en el top 10 de patrocinadores',
|
top10Hint: 'Entra en el top 10 de patrocinadores',
|
||||||
},
|
},
|
||||||
trust: {
|
trust: {
|
||||||
|
flaggedNoticeTitle: 'Reportada por patinadores de confianza',
|
||||||
|
flaggedNoticeDesc: 'Ten cuidado. Personas de confianza de la red han reportado esta cuenta.',
|
||||||
|
flaggedReporterFollow: '{{count}} que sigues',
|
||||||
|
flaggedReporterNetwork: '{{count}} de tu red',
|
||||||
|
flaggedReporterOther: '{{count}} otras cuentas',
|
||||||
|
flaggedReport: 'Reportar',
|
||||||
|
flaggedDismiss: 'Descartar',
|
||||||
bucketVerified: 'Verificado',
|
bucketVerified: 'Verificado',
|
||||||
bucketUnknown: 'Desconocido',
|
bucketUnknown: 'Desconocido',
|
||||||
bucketFlagged: 'Marcado',
|
bucketFlagged: 'Marcado',
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,14 @@ export function bucketFromAssertion(assertion: UserTrustAssertion): TrustBucket
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bucket from the local WoT ladder (used when no provider assertion exists):
|
* Bucket from the local WoT ladder (used when no provider assertion exists).
|
||||||
* `>= 60` means the viewer follows them or a friend-of-friend does — i.e.
|
* Only a friend-of-friend (60) counts as a network vouch — someone you did not
|
||||||
* someone in the network already vouches. NIP-05-only (30) and unknown (0)
|
* pick vouches for them. A direct follow (90) or yourself (100) is neutral: a
|
||||||
* stay unknown. The local ladder has no negative signal, so it never flags.
|
* "Verified" badge there tells the viewer something they already did, and it
|
||||||
|
* would dilute the signal. The ladder has no negative signal, so it never flags.
|
||||||
*/
|
*/
|
||||||
export function bucketFromLocalScore(score: number | undefined): TrustBucket {
|
export function bucketFromLocalScore(score: number | undefined): TrustBucket {
|
||||||
if (score !== undefined && score >= 60) return 'verified';
|
if (score === 60) return 'verified';
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
||||||
import { VouchCard } from '@/components/VouchCard';
|
import { VouchCard } from '@/components/VouchCard';
|
||||||
import { LinkedCrewCard } from '@/components/CrossLinkCards';
|
import { LinkedCrewCard } from '@/components/CrossLinkCards';
|
||||||
import { TrustBadge } from '@/components/TrustBadge';
|
import { TrustBadge } from '@/components/TrustBadge';
|
||||||
|
import { TrustFlaggedNotice } from '@/components/TrustFlaggedNotice';
|
||||||
import { useLabProfile } from '@/hooks/useLabProfiles';
|
import { useLabProfile } from '@/hooks/useLabProfiles';
|
||||||
import { useGigs } from '@/hooks/useGigs';
|
import { useGigs } from '@/hooks/useGigs';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
|
@ -422,6 +423,8 @@ export default function CoachDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TrustFlaggedNotice pubkey={profile.pubkey} targetTitle={profile.title} className="mb-6" />
|
||||||
|
|
||||||
{/* Bio */}
|
{/* Bio */}
|
||||||
{profile.bio && (
|
{profile.bio && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
||||||
import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox';
|
import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox';
|
||||||
import { TrustBadge } from '@/components/TrustBadge';
|
import { TrustBadge } from '@/components/TrustBadge';
|
||||||
|
import { TrustFlaggedNotice } from '@/components/TrustFlaggedNotice';
|
||||||
import { TradeReputationCard } from '@/components/TradeReputationCard';
|
import { TradeReputationCard } from '@/components/TradeReputationCard';
|
||||||
import { ListingTradeActions } from '@/components/ListingTradeActions';
|
import { ListingTradeActions } from '@/components/ListingTradeActions';
|
||||||
import { ListingOffersPanel } from '@/components/ListingOffersPanel';
|
import { ListingOffersPanel } from '@/components/ListingOffersPanel';
|
||||||
|
|
@ -288,6 +289,7 @@ export default function ListingDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
<TrustBadge pubkey={item.pubkey} size="sm" showLabel={false} className="ml-auto" />
|
<TrustBadge pubkey={item.pubkey} size="sm" showLabel={false} className="ml-auto" />
|
||||||
</Link>
|
</Link>
|
||||||
|
<TrustFlaggedNotice pubkey={item.pubkey} targetTitle={displayName} />
|
||||||
{sellerInfo && sellerInfo.listingCount > 0 && (
|
{sellerInfo && sellerInfo.listingCount > 0 && (
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{sellerInfo.listingCount === 1
|
{sellerInfo.listingCount === 1
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue