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;
|
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`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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',
|
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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue