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; provider / your provider) and links to a public **`/trust`** explainer;
**Settings → Trust** (`?section=trust`, `components/TrustSettingsCard.tsx`) has a **Settings → Trust** (`?section=trust`, `components/TrustSettingsCard.tsx`) has a
show/hide-marks toggle (`useTrustDisplay`, Flagged is never hidden) and shows the show/hide-marks toggle (`useTrustDisplay`, Flagged is never hidden) and shows the
active provider + relay (`useTrustProvider`). Still open: provider choice active provider + relay (`useTrustProvider`), and lets the viewer **publish a
(publishing a kind-10040 designation), a mute action (no NIP-51 kind-10000 hook kind-10040 provider designation** (`useSetTrustProvider`: custom npub/hex +
exists yet), counterparty alerts, and the confidence-gated scoring model. Check `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` / `TRUST_SCORE.md` before touching `useTrustScore` / `useRelatrScore` /
`trustFilter`. `trustFilter`.

View file

@ -21,10 +21,8 @@ marketplace, and moderation.
ListingDetailPage and CoachDetailPage, the mapping, Lab and marketplace all get ListingDetailPage and CoachDetailPage, the mapping, Lab and marketplace all get
it for free. it for free.
Still open from this slice: precompute the bucket at list level and pass it down All three follow-ups landed in the later passes below: the batched read, the
(one provider query per list instead of per card); a "who reported" breakdown flagged notice's reporter breakdown, and Settings → Trust.
(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) ## 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. point-of-view relativity. Added to the smoke-test route list.
- **Settings → Trust** (`?section=trust`, `components/TrustSettingsCard.tsx`): a - **Settings → Trust** (`?section=trust`, `components/TrustSettingsCard.tsx`): a
show/hide-marks toggle (`useTrustDisplay`, localStorage show/hide-marks toggle (`useTrustDisplay`, localStorage
`skatespots:trust:display`; **Flagged is never hidden**) and the active `skatespots:trust:display`; **Flagged is never hidden**), the active provider +
provider + relay (`useTrustProvider`), with a link to `/trust`. 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 Still open: a mute action (there is no NIP-51 kind-10000 hook yet — Dismiss is
in-app NIP-85 activation flow), a mute action (there is no NIP-51 kind-10000 hook the local equivalent for now), counterparty alerts, and the confidence-gated
yet — Dismiss is the local equivalent for now), counterparty alerts, and the scoring model.
confidence-gated scoring model.

View file

@ -1,25 +1,60 @@
import { useState } from 'react';
import { Link } from 'react-router-dom'; 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 { Switch } from '@/components/ui/switch';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTrustDisplay } from '@/hooks/useTrustDisplay'; import { useTrustDisplay } from '@/hooks/useTrustDisplay';
import { useTrustProvider } from '@/hooks/useTrustProvider'; import { useTrustProvider } from '@/hooks/useTrustProvider';
import {
normalizeProviderPubkey,
normalizeProviderRelay,
useSetTrustProvider,
} from '@/hooks/useSetTrustProvider';
import { HOUSE_TRUST_PROVIDER } from '@/lib/nip85'; import { HOUSE_TRUST_PROVIDER } from '@/lib/nip85';
/** /**
* Settings → Trust. How trust is displayed, and which provider is in effect. * 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 * Flagged always shows, so hiding marks is a display choice, not a way to
* silence a safety signal. * silence a safety signal. Choosing a provider publishes a NIP-85 kind-10040
* designation signed by the user.
*/ */
export function TrustSettingsCard() { export function TrustSettingsCard() {
const { t } = useTranslation(); const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { enabled, setEnabled } = useTrustDisplay(); const { enabled, setEnabled } = useTrustDisplay();
const { data: provider } = useTrustProvider(); 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 isHouse = !provider || provider.pubkey === HOUSE_TRUST_PROVIDER.pubkey;
const source = isHouse ? t('trustUi.sourceHouse') : t('trustUi.sourceCustom'); 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-2xl border border-white/10 bg-white/3 p-4"> <div className="rounded-2xl border border-white/10 bg-white/3 p-4">
@ -32,13 +67,88 @@ export function TrustSettingsCard() {
</div> </div>
</div> </div>
<div className="space-y-2 rounded-2xl border border-white/10 bg-white/3 p-4"> <div className="space-y-3 rounded-2xl border border-white/10 bg-white/3 p-4">
<p className="text-sm font-bold text-white">{t('trustUi.sourceLabel')}</p> <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> <p className="text-xs text-muted-foreground">{source}</p>
{user && provider?.relay && ( {user && provider?.relay && (
<p className="break-all text-xs text-muted-foreground">{provider.relay}</p> <p className="break-all text-xs text-muted-foreground">{provider.relay}</p>
)} )}
{!user && <p className="text-xs text-muted-foreground">{t('trustUi.loggedOut')}</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>
)}
{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"> <Link to="/trust" className="inline-block text-xs text-[#ccff00] underline">
{t('walletUi.trust.howItWorks')} {t('walletUi.trust.howItWorks')}
</Link> </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', sellerHint: 'This seller’s venue issues access passes',
}, },
trustUi: { 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', showMarks: 'Show trust marks',
showMarksDesc: 'Verified and unknown marks. Reports always show.', showMarksDesc: 'Verified and unknown marks. Reports always show.',
sourceLabel: 'Source', sourceLabel: 'Source',

View file

@ -7389,6 +7389,16 @@ walletUi: {
sellerHint: 'El local de este vendedor emite pases de acceso', sellerHint: 'El local de este vendedor emite pases de acceso',
}, },
trustUi: { 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', showMarks: 'Mostrar marcas de confianza',
showMarksDesc: 'Marcas de verificado y desconocido. Los reportes siempre se muestran.', showMarksDesc: 'Marcas de verificado y desconocido. Los reportes siempre se muestran.',
sourceLabel: 'Fuente', sourceLabel: 'Fuente',