From 3b06e90d15c8d62f63f4f31458523a3ea5703b41 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 9 Oct 2026 17:55:00 +0700 Subject: [PATCH] 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: 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. --- AGENTS.md | 8 +- TRUST_SCORE.md | 21 ++--- src/components/TrustSettingsCard.tsx | 128 +++++++++++++++++++++++++-- src/hooks/useSetTrustProvider.ts | 74 ++++++++++++++++ src/i18n/en.ts | 10 +++ src/i18n/es.ts | 10 +++ 6 files changed, 229 insertions(+), 22 deletions(-) create mode 100644 src/hooks/useSetTrustProvider.ts diff --git a/AGENTS.md b/AGENTS.md index 01bb0ba..f32073f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/TRUST_SCORE.md b/TRUST_SCORE.md index f615d31..b5c0454 100644 --- a/TRUST_SCORE.md +++ b/TRUST_SCORE.md @@ -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:` 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. diff --git a/src/components/TrustSettingsCard.tsx b/src/components/TrustSettingsCard.tsx index 5c4093e..7bf3eb7 100644 --- a/src/components/TrustSettingsCard.tsx +++ b/src/components/TrustSettingsCard.tsx @@ -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(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 (
@@ -32,13 +67,88 @@ export function TrustSettingsCard() {
-
-

{t('trustUi.sourceLabel')}

-

{source}

- {user && provider?.relay && ( -

{provider.relay}

+
+
+

{t('trustUi.providerTitle')}

+

{t('trustUi.providerDesc')}

+
+ +
+

{source}

+ {user && provider?.relay && ( +

{provider.relay}

+ )} +
+ + {!user &&

{t('trustUi.loggedOutProvider')}

} + + {user && ( +
+ {!isHouse && ( + + )} + +
)} - {!user &&

{t('trustUi.loggedOut')}

} + + {formOpen && user && ( +
+ setPubkeyInput(e.target.value)} + placeholder={t('trustUi.customPubkeyPlaceholder')} + className="h-9 text-sm" + /> + setRelayInput(e.target.value)} + placeholder={t('trustUi.customRelayPlaceholder')} + className="h-9 text-sm" + /> + {error &&

{error}

} +
+ + +
+
+ )} + {t('walletUi.trust.howItWorks')} diff --git a/src/hooks/useSetTrustProvider.ts b/src/hooks/useSetTrustProvider.ts new file mode 100644 index 0000000..d3e03c3 --- /dev/null +++ b/src/hooks/useSetTrustProvider.ts @@ -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; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 1cb15ed..7d1c2cf 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index d257f27..e26a890 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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',