feat(trust): let the viewer choose the trust provider (NIP-85 kind 10040)

Settings -> Trust is now functional, not read-only. useSetTrustProvider
publishes a kind-10040 designation for a custom provider (npub or 64-hex +
wss relay, the five 30382:<type> rows) and can clear it to fall back to the
community provider. The card validates input, prompts the signer, and
invalidates the provider + assertion queries so scores refresh. Docs
updated.
This commit is contained in:
SkateSpots 2026-10-09 17:55:00 +07:00
parent fd9316331b
commit 3b06e90d15
6 changed files with 229 additions and 22 deletions

View file

@ -2550,9 +2550,11 @@ pass:** the badge tooltip attributes the source (your network / community
provider / your provider) and links to a public **`/trust`** explainer;
**Settings → Trust** (`?section=trust`, `components/TrustSettingsCard.tsx`) has a
show/hide-marks toggle (`useTrustDisplay`, Flagged is never hidden) and shows the
active provider + relay (`useTrustProvider`). Still open: provider choice
(publishing a kind-10040 designation), a mute action (no NIP-51 kind-10000 hook
exists yet), counterparty alerts, and the confidence-gated scoring model. Check
active provider + relay (`useTrustProvider`), and lets the viewer **publish a
kind-10040 provider designation** (`useSetTrustProvider`: custom npub/hex +
`wss://` relay, or clear to fall back to the community provider). Still open: 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`.

View file

@ -21,10 +21,8 @@ marketplace, and moderation.
ListingDetailPage and CoachDetailPage, the mapping, Lab and marketplace all get
it for free.
Still open from this slice: precompute the bucket at list level and pass it down
(one provider query per list instead of per card); a "who reported" breakdown
(the assertion carries counts, not identities); and a Settings control to pick
the provider instead of only using the designation or house default.
All three follow-ups landed in the later passes below: the batched read, the
flagged notice's reporter breakdown, and Settings → Trust.
## Second pass (landed)
@ -53,13 +51,16 @@ the provider instead of only using the designation or house default.
point-of-view relativity. Added to the smoke-test route list.
- **Settings → Trust** (`?section=trust`, `components/TrustSettingsCard.tsx`): a
show/hide-marks toggle (`useTrustDisplay`, localStorage
`skatespots:trust:display`; **Flagged is never hidden**) and the active
provider + relay (`useTrustProvider`), with a link to `/trust`.
`skatespots:trust:display`; **Flagged is never hidden**), the active provider +
relay (`useTrustProvider`), and **provider choice**: `useSetTrustProvider`
publishes a NIP-85 **kind-10040** designation (custom npub/hex + `wss://`
relay, all five `30382:<type>` rows) or clears it to fall back to the
community provider — a signed action, so it prompts the signer. Link to
`/trust`.
Still open: provider *choice* (publishing a kind-10040 designation, i.e. an
in-app NIP-85 activation flow), 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.
Still open: 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.

View file

@ -1,25 +1,60 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { useTranslation } from '@/contexts/language';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTrustDisplay } from '@/hooks/useTrustDisplay';
import { useTrustProvider } from '@/hooks/useTrustProvider';
import {
normalizeProviderPubkey,
normalizeProviderRelay,
useSetTrustProvider,
} from '@/hooks/useSetTrustProvider';
import { HOUSE_TRUST_PROVIDER } from '@/lib/nip85';
/**
* Settings → Trust. How trust is displayed, and which provider is in effect.
* Flagged always shows, so turning marks off is a display choice, not a way to
* silence a safety signal.
* Settings → Trust: how trust is displayed, and which provider is in effect.
* Flagged always shows, so hiding marks is a display choice, not a way to
* silence a safety signal. Choosing a provider publishes a NIP-85 kind-10040
* designation signed by the user.
*/
export function TrustSettingsCard() {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { enabled, setEnabled } = useTrustDisplay();
const { data: provider } = useTrustProvider();
const setProvider = useSetTrustProvider();
const [formOpen, setFormOpen] = useState(false);
const [pubkeyInput, setPubkeyInput] = useState('');
const [relayInput, setRelayInput] = useState('');
const [error, setError] = useState<string | null>(null);
const isHouse = !provider || provider.pubkey === HOUSE_TRUST_PROVIDER.pubkey;
const source = isHouse ? t('trustUi.sourceHouse') : t('trustUi.sourceCustom');
const handleSave = async () => {
const hex = normalizeProviderPubkey(pubkeyInput);
const relay = normalizeProviderRelay(relayInput);
if (!hex || !relay) {
setError(t('trustUi.invalidProvider'));
return;
}
setError(null);
try {
await setProvider.mutateAsync({ pubkey: hex, relay });
setFormOpen(false);
setPubkeyInput('');
setRelayInput('');
} catch {
/* the hook surfaces the error toast */
}
};
return (
<div className="space-y-4">
<div className="rounded-2xl border border-white/10 bg-white/3 p-4">
@ -32,13 +67,88 @@ export function TrustSettingsCard() {
</div>
</div>
<div className="space-y-2 rounded-2xl border border-white/10 bg-white/3 p-4">
<p className="text-sm font-bold text-white">{t('trustUi.sourceLabel')}</p>
<p className="text-xs text-muted-foreground">{source}</p>
{user && provider?.relay && (
<p className="break-all text-xs text-muted-foreground">{provider.relay}</p>
<div className="space-y-3 rounded-2xl border border-white/10 bg-white/3 p-4">
<div>
<p className="text-sm font-bold text-white">{t('trustUi.providerTitle')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('trustUi.providerDesc')}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{source}</p>
{user && provider?.relay && (
<p className="break-all text-xs text-muted-foreground">{provider.relay}</p>
)}
</div>
{!user && <p className="text-xs text-muted-foreground">{t('trustUi.loggedOutProvider')}</p>}
{user && (
<div className="flex flex-wrap gap-2">
{!isHouse && (
<Button
size="sm"
variant="outline"
className="min-h-0 h-8"
disabled={setProvider.isPending}
onClick={() => setProvider.mutate(null)}
>
{t('trustUi.useCommunity')}
</Button>
)}
<Button
size="sm"
variant="outline"
className="min-h-0 h-8"
onClick={() => {
setFormOpen((open) => !open);
setError(null);
}}
>
{t('trustUi.setProvider')}
</Button>
</div>
)}
{!user && <p className="text-xs text-muted-foreground">{t('trustUi.loggedOut')}</p>}
{formOpen && user && (
<div className="space-y-2">
<Input
value={pubkeyInput}
onChange={(e) => setPubkeyInput(e.target.value)}
placeholder={t('trustUi.customPubkeyPlaceholder')}
className="h-9 text-sm"
/>
<Input
value={relayInput}
onChange={(e) => setRelayInput(e.target.value)}
placeholder={t('trustUi.customRelayPlaceholder')}
className="h-9 text-sm"
/>
{error && <p className="text-xs text-red-400">{error}</p>}
<div className="flex gap-2">
<Button
size="sm"
className="min-h-0 h-8 gap-1.5 bg-[#ccff00] text-black hover:bg-[#ccff00]/90"
disabled={setProvider.isPending}
onClick={handleSave}
>
{setProvider.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{t('common.save')}
</Button>
<Button
size="sm"
variant="ghost"
className="min-h-0 h-8"
onClick={() => {
setFormOpen(false);
setError(null);
}}
>
{t('common.cancel')}
</Button>
</div>
</div>
)}
<Link to="/trust" className="inline-block text-xs text-[#ccff00] underline">
{t('walletUi.trust.howItWorks')}
</Link>

View file

@ -0,0 +1,74 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { NIP85_PROVIDER_LIST_KIND, NIP85_USER_ASSERTION_KIND, type TrustProvider } from '@/lib/nip85';
/** The assertion types we designate (the normative kind-30382 set). */
const ASSERTION_TYPES = ['rank', 'followers', 'reporters', 'muters', 'hops'] as const;
/** Accept an npub or a 64-char hex pubkey; return hex or null. */
export function normalizeProviderPubkey(input: string): string | null {
const value = input.trim();
if (/^[0-9a-f]{64}$/i.test(value)) return value.toLowerCase();
if (value.startsWith('npub1')) {
try {
const decoded = nip19.decode(value);
if (decoded.type === 'npub') return decoded.data;
} catch {
return null;
}
}
return null;
}
/** A relay hint must be a websocket URL. */
export function normalizeProviderRelay(input: string): string | null {
const value = input.trim();
if (/^wss?:\/\/.+/i.test(value)) return value;
return null;
}
/**
* Publish (or clear) the viewer's NIP-85 kind-10040 provider designation.
* Passing `null` clears it, so the app falls back to the community provider.
* This is a signed action, so it prompts the signer like any other publish.
*/
export function useSetTrustProvider() {
const { mutateAsync: publish } = useNostrPublish();
const { user } = useCurrentUser();
const { toast } = useToast();
const { t } = useTranslation();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (provider: TrustProvider | null) => {
if (!user) throw new Error(t('trustUi.loggedOutProvider'));
const relay = provider?.relay;
const tags: string[][] =
provider && relay
? ASSERTION_TYPES.map((type) => [`${NIP85_USER_ASSERTION_KIND}:${type}`, provider.pubkey, relay])
: [];
await publish({ kind: NIP85_PROVIDER_LIST_KIND, content: '', tags });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trust-provider', user?.pubkey] });
queryClient.invalidateQueries({ queryKey: ['trust-assertions-batch', user?.pubkey ?? ''] });
toast({ title: t('trustUi.providerSaved') });
},
onError: (error) => {
toast({
title: t('trustUi.publishFailed'),
description: error instanceof Error ? error.message : undefined,
variant: 'destructive',
});
},
});
return mutation;
}

View file

@ -7389,6 +7389,16 @@ walletUi: {
sellerHint: 'This seller’s venue issues access passes',
},
trustUi: {
providerTitle: 'Provider',
providerDesc: 'Scores come from a provider you choose. The community provider is the default.',
useCommunity: 'Use community provider',
setProvider: 'Set a provider',
customPubkeyPlaceholder: 'Provider npub or hex pubkey',
customRelayPlaceholder: 'wss://relay.example.com',
invalidProvider: 'Enter a valid npub or 64-character hex pubkey, and a wss:// relay.',
publishFailed: 'Could not publish the provider. Try again.',
providerSaved: 'Provider updated.',
loggedOutProvider: 'Log in to choose a provider.',
showMarks: 'Show trust marks',
showMarksDesc: 'Verified and unknown marks. Reports always show.',
sourceLabel: 'Source',

View file

@ -7389,6 +7389,16 @@ walletUi: {
sellerHint: 'El local de este vendedor emite pases de acceso',
},
trustUi: {
providerTitle: 'Proveedor',
providerDesc: 'Las puntuaciones vienen de un proveedor que elijas. El proveedor de la comunidad es el predeterminado.',
useCommunity: 'Usar proveedor de la comunidad',
setProvider: 'Elegir un proveedor',
customPubkeyPlaceholder: 'npub o clave hex del proveedor',
customRelayPlaceholder: 'wss://relay.ejemplo.com',
invalidProvider: 'Introduce una npub o clave hex de 64 caracteres, y un relay wss://.',
publishFailed: 'No se pudo publicar el proveedor. Intentalo de nuevo.',
providerSaved: 'Proveedor actualizado.',
loggedOutProvider: 'Inicia sesion para elegir un proveedor.',
showMarks: 'Mostrar marcas de confianza',
showMarksDesc: 'Marcas de verificado y desconocido. Los reportes siempre se muestran.',
sourceLabel: 'Fuente',