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;
|
||||
`FLAGGED_REPORTERS_THRESHOLD` 2), `src/hooks/useTrustBucket.ts`, and
|
||||
`components/TrustBadge.tsx` now renders the three buckets (Unknown hidden by
|
||||
default, Flagged always shown). `TrustBadge` is used by the map/tour/event cards,
|
||||
marketplace card, listing detail and coach detail, so those surfaces get it for
|
||||
free. Still open: batch the bucket at list level (currently one provider query
|
||||
per card), a "who reported" breakdown, a provider-choice setting, alerts, and
|
||||
the confidence-gated scoring model. Check `TRUST_SCORE.md` before touching
|
||||
`useTrustScore` / `useRelatrScore` / `trustFilter`.
|
||||
default, Flagged always shown, and Verified suppressed for accounts you already
|
||||
follow). `TrustBadge` is used by the map/tour/event cards, marketplace card,
|
||||
listing detail and coach detail, so those surfaces get it for free. The provider
|
||||
read is **batched** (one query per viewer for the union of on-screen subjects,
|
||||
in `useTrustAssertions.ts`), and `useUserReports` + `components/TrustFlaggedNotice.tsx`
|
||||
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
|
||||
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 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
|
||||
the `NosFabrica/protocols` repository, plus a public reference
|
||||
|
|
|
|||
|
|
@ -50,9 +50,12 @@ export function TrustBadge({
|
|||
showUnknown = false,
|
||||
}: TrustBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { bucket, assertion, isLoading } = useTrustBucket(pubkey);
|
||||
const { bucket, assertion, directFollow, isLoading } = useTrustBucket(pubkey);
|
||||
|
||||
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;
|
||||
|
||||
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 type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
|
@ -15,37 +16,107 @@ import {
|
|||
type UserTrustAssertion,
|
||||
} 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
|
||||
* configured house provider. Signatures are verified and the event author must
|
||||
* 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
|
||||
* 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[]) {
|
||||
const { nostr } = useNostr();
|
||||
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({
|
||||
queryKey: ['trust-assertions', targets.join(','), user?.pubkey],
|
||||
const grew = registerTargets(viewer, targets);
|
||||
|
||||
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>> => {
|
||||
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)]);
|
||||
|
||||
// 1. Resolve the provider: the viewer's own designation, else the house.
|
||||
let provider: TrustProvider = HOUSE_TRUST_PROVIDER;
|
||||
try {
|
||||
if (user?.pubkey) {
|
||||
if (viewer) {
|
||||
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 },
|
||||
));
|
||||
const designation = parseProviderDesignation(designationEvents[0]);
|
||||
|
|
@ -60,12 +131,12 @@ export function useTrustAssertions(pubkeys: string[]) {
|
|||
// 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 = {
|
||||
kinds: [NIP85_USER_ASSERTION_KIND],
|
||||
authors: [provider.pubkey],
|
||||
'#d': targets,
|
||||
limit: targets.length,
|
||||
'#d': all,
|
||||
limit: all.length,
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -90,4 +161,18 @@ export function useTrustAssertions(pubkeys: string[]) {
|
|||
gcTime: 24 * 60 * 60 * 1000,
|
||||
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;
|
||||
/** Local web-of-trust score (0-100), when available. */
|
||||
localScore?: number;
|
||||
/** The viewer directly follows this account (or it is the viewer). */
|
||||
directFollow: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -25,11 +27,13 @@ export function useTrustBucket(pubkey: string | undefined): TrustBucketResult {
|
|||
|
||||
const assertion = pubkey ? assertions?.get(pubkey) : undefined;
|
||||
const bucket = assertion ? bucketFromAssertion(assertion) : bucketFromLocalScore(localScore);
|
||||
const directFollow = localScore !== undefined && localScore >= 90;
|
||||
|
||||
return {
|
||||
bucket,
|
||||
assertion,
|
||||
localScore,
|
||||
directFollow,
|
||||
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',
|
||||
},
|
||||
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',
|
||||
bucketUnknown: 'Unknown',
|
||||
bucketFlagged: 'Flagged',
|
||||
|
|
|
|||
|
|
@ -6143,6 +6143,13 @@ walletUi: {
|
|||
top10Hint: 'Entra en el top 10 de patrocinadores',
|
||||
},
|
||||
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',
|
||||
bucketUnknown: 'Desconocido',
|
||||
bucketFlagged: 'Marcado',
|
||||
|
|
|
|||
|
|
@ -35,13 +35,14 @@ export function bucketFromAssertion(assertion: UserTrustAssertion): TrustBucket
|
|||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* someone in the network already vouches. NIP-05-only (30) and unknown (0)
|
||||
* stay unknown. The local ladder has no negative signal, so it never flags.
|
||||
* Bucket from the local WoT ladder (used when no provider assertion exists).
|
||||
* Only a friend-of-friend (60) counts as a network vouch — someone you did not
|
||||
* pick vouches for them. A direct follow (90) or yourself (100) is neutral: a
|
||||
* "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 {
|
||||
if (score !== undefined && score >= 60) return 'verified';
|
||||
if (score === 60) return 'verified';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
|||
import { VouchCard } from '@/components/VouchCard';
|
||||
import { LinkedCrewCard } from '@/components/CrossLinkCards';
|
||||
import { TrustBadge } from '@/components/TrustBadge';
|
||||
import { TrustFlaggedNotice } from '@/components/TrustFlaggedNotice';
|
||||
import { useLabProfile } from '@/hooks/useLabProfiles';
|
||||
import { useGigs } from '@/hooks/useGigs';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
|
|
@ -422,6 +423,8 @@ export default function CoachDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<TrustFlaggedNotice pubkey={profile.pubkey} targetTitle={profile.title} className="mb-6" />
|
||||
|
||||
{/* Bio */}
|
||||
{profile.bio && (
|
||||
<div className="mb-6">
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
||||
import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox';
|
||||
import { TrustBadge } from '@/components/TrustBadge';
|
||||
import { TrustFlaggedNotice } from '@/components/TrustFlaggedNotice';
|
||||
import { TradeReputationCard } from '@/components/TradeReputationCard';
|
||||
import { ListingTradeActions } from '@/components/ListingTradeActions';
|
||||
import { ListingOffersPanel } from '@/components/ListingOffersPanel';
|
||||
|
|
@ -288,6 +289,7 @@ export default function ListingDetailPage() {
|
|||
</div>
|
||||
<TrustBadge pubkey={item.pubkey} size="sm" showLabel={false} className="ml-auto" />
|
||||
</Link>
|
||||
<TrustFlaggedNotice pubkey={item.pubkey} targetTitle={displayName} />
|
||||
{sellerInfo && sellerInfo.listingCount > 0 && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{sellerInfo.listingCount === 1
|
||||
|
|
|
|||
Loading…
Reference in a new issue