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:
parent
fd9316331b
commit
3b06e90d15
6 changed files with 229 additions and 22 deletions
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
74
src/hooks/useSetTrustProvider.ts
Normal file
74
src/hooks/useSetTrustProvider.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue