diff --git a/AGENTS.md b/AGENTS.md index aa95d15..01bb0ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2545,9 +2545,14 @@ listing detail and coach detail, so those surfaces get it for free. The provider read is **batched** (one query per viewer for the union of on-screen subjects, in `useTrustAssertions.ts`), and `useUserReports` + `components/TrustFlaggedNotice.tsx` add a flagged-counterparty notice (reporters grouped by the viewer's network, -Report/Dismiss) wired into the listing seller and coach detail. Still open: a -provider-choice setting, a mute action (no NIP-51 kind-10000 hook exists yet), -counterparty alerts, and the confidence-gated scoring model. Check +Report/Dismiss) wired into the listing seller and coach detail. **Legibility +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 `TRUST_SCORE.md` before touching `useTrustScore` / `useRelatrScore` / `trustFilter`. diff --git a/TRUST_SCORE.md b/TRUST_SCORE.md index fc32aa5..f615d31 100644 --- a/TRUST_SCORE.md +++ b/TRUST_SCORE.md @@ -44,9 +44,22 @@ the provider instead of only using the designation or house default. safety signal returns). Wired into the listing seller card and the coach detail page. -Still open: a Settings control to pick the trust provider; 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. +## Third pass — legibility (landed) + +- **Attribution.** The badge tooltip names the source of the claim (your network / + the community provider / the provider you chose) and links to an explainer. +- **Public `/trust` page.** Explains the three states, that absence is not + distrust, who computes trust, presence-is-not-endorsement, and + 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`. + +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. diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 99549a4..8b11864 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -139,6 +139,7 @@ const ROUTES = [ '/settings?section=profile', '/settings?section=coordinator', '/settings?section=pro', + '/settings?section=trust', '/npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx', '/stash', '/collections', diff --git a/src/components/TrustBadge.tsx b/src/components/TrustBadge.tsx index 2c33ce2..f9a1d0a 100644 --- a/src/components/TrustBadge.tsx +++ b/src/components/TrustBadge.tsx @@ -3,6 +3,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import { CheckCircle2, HelpCircle, Flag } from 'lucide-react'; import { Link } from 'react-router-dom'; import { useTrustBucket } from '@/hooks/useTrustBucket'; +import { useTrustDisplay } from '@/hooks/useTrustDisplay'; import { bucketDescKey, bucketLabelKey, type TrustBucket } from '@/lib/trustLadder'; import { HOUSE_TRUST_PROVIDER } from '@/lib/nip85'; import { cn } from '@/lib/utils'; @@ -53,6 +54,7 @@ export function TrustBadge({ }: TrustBadgeProps) { const { t } = useTranslation(); const { bucket, assertion, directFollow, isLoading } = useTrustBucket(pubkey); + const { enabled: displayEnabled } = useTrustDisplay(); // Always attribute the claim: a verdict with no source is not trustworthy. const sourceKey = assertion @@ -62,6 +64,9 @@ export function TrustBadge({ : 'walletUi.trust.attributedNetwork'; if (isLoading) return null; + // A viewer preference can hide the neutral/positive marks; the safety signal + // (Flagged) is never hidden by it. + if (!displayEnabled && bucket !== 'flagged') return null; // A direct follow (or yourself) is already known to the viewer — a "Verified" // mark there is redundant. Flagged is a safety signal and is never suppressed. if (bucket === 'verified' && directFollow) return null; diff --git a/src/components/TrustSettingsCard.tsx b/src/components/TrustSettingsCard.tsx new file mode 100644 index 0000000..5c4093e --- /dev/null +++ b/src/components/TrustSettingsCard.tsx @@ -0,0 +1,48 @@ +import { Link } from 'react-router-dom'; +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 { 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. + */ +export function TrustSettingsCard() { + const { t } = useTranslation(); + const { user } = useCurrentUser(); + const { enabled, setEnabled } = useTrustDisplay(); + const { data: provider } = useTrustProvider(); + + const isHouse = !provider || provider.pubkey === HOUSE_TRUST_PROVIDER.pubkey; + const source = isHouse ? t('trustUi.sourceHouse') : t('trustUi.sourceCustom'); + + return ( +
+
+
+
+

{t('trustUi.showMarks')}

+

{t('trustUi.showMarksDesc')}

+
+ +
+
+ +
+

{t('trustUi.sourceLabel')}

+

{source}

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

{provider.relay}

+ )} + {!user &&

{t('trustUi.loggedOut')}

} + + {t('walletUi.trust.howItWorks')} + +
+
+ ); +} diff --git a/src/hooks/useTrustDisplay.ts b/src/hooks/useTrustDisplay.ts new file mode 100644 index 0000000..3c47ccb --- /dev/null +++ b/src/hooks/useTrustDisplay.ts @@ -0,0 +1,11 @@ +import { useLocalStorage } from './useLocalStorage'; + +/** + * Viewer preference for whether trust marks are shown. Only the neutral/positive + * marks are affected — the safety signal (Flagged) is deliberately independent + * of this and always shows. + */ +export function useTrustDisplay() { + const [enabled, setEnabled] = useLocalStorage('skatespots:trust:display', true); + return { enabled, setEnabled }; +} diff --git a/src/hooks/useTrustProvider.ts b/src/hooks/useTrustProvider.ts new file mode 100644 index 0000000..317e6a4 --- /dev/null +++ b/src/hooks/useTrustProvider.ts @@ -0,0 +1,40 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; + +import { useCurrentUser } from './useCurrentUser'; +import { onlyVerified } from '@/lib/verifyEvents'; +import { + HOUSE_TRUST_PROVIDER, + NIP85_PROVIDER_LIST_KIND, + NIP85_USER_ASSERTION_KIND, + parseProviderDesignation, + resolveProvider, + type TrustProvider, +} from '@/lib/nip85'; + +/** + * The trust provider currently in effect for the viewer: the one named in their + * own kind-10040 designation, or the house provider when there is no + * designation. Read-only — publishing a designation is a separate step. + */ +export function useTrustProvider() { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const pubkey = user?.pubkey; + + return useQuery({ + queryKey: ['trust-provider', pubkey], + enabled: !!pubkey, + staleTime: 5 * 60 * 1000, + queryFn: async (c): Promise => { + if (!pubkey) return HOUSE_TRUST_PROVIDER; + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); + const events = onlyVerified(await nostr.query( + [{ kinds: [NIP85_PROVIDER_LIST_KIND], authors: [pubkey], limit: 1 }], + { signal }, + )); + const designation = parseProviderDesignation(events[0]); + return resolveProvider(designation, NIP85_USER_ASSERTION_KIND, 'rank', HOUSE_TRUST_PROVIDER); + }, + }); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 05d2db4..1cb15ed 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -2119,7 +2119,7 @@ labUi: { }, draft: { title: 'You have an unsaved draft', - body: 'Pick up where you left off — nothing is published until you press Publish.', + body: 'Pick up where you left off — nothing is published until you press Publish. Licensed under the GNU Affero General Public License v3.0: derivatives and hosted versions must stay open under the same terms, because network use counts as distribution.', resume: 'Resume draft', discard: 'Discard draft', }, @@ -2795,7 +2795,7 @@ labUi: { print: 'Print', copyId: 'Copy ID', cardType: 'Card Type', - licenseLabel: 'Skater License', + licenseLabel: 'License · AGPL-3.0', licenseSub: 'Your personal rider identity', parkLabel: 'Park Access', parkSub: 'Issue passes as a park owner', @@ -5229,6 +5229,8 @@ settingsUi: { myData: 'My Data', myDataSub: 'Export, import, manage', advancedSub: 'Relatr, Sasquatch, tools', + trust: 'Trust', + trustSub: 'Sources and display', notLoaded: 'Not loaded', notLoggedIn: 'Not logged in', }, @@ -7387,6 +7389,12 @@ walletUi: { sellerHint: 'This seller’s venue issues access passes', }, trustUi: { + showMarks: 'Show trust marks', + showMarksDesc: 'Verified and unknown marks. Reports always show.', + sourceLabel: 'Source', + sourceHouse: 'Community trust provider', + sourceCustom: 'Your provider', + loggedOut: 'Log in to choose your own trust provider.', seoTitle: 'How trust works', seoDescription: 'How SkateSpots shows trust: three states, where scores come from, and why absence is not distrust.', title: 'How trust works', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 17535d1..d257f27 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -2119,7 +2119,7 @@ labUi: { }, draft: { title: 'Tienes un borrador sin guardar', - body: 'Continúa donde lo dejaste — nada se publica hasta que pulses Publicar.', + body: 'Continúa donde lo dejaste — nada se publica hasta que pulses Publicar. Con licencia GNU Affero General Public License v3.0: las derivadas y las versiones alojadas deben seguir abiertas bajo los mismos terminos, porque el uso en red cuenta como distribucion.', resume: 'Reanudar borrador', discard: 'Descartar borrador', }, @@ -2795,7 +2795,7 @@ labUi: { print: 'Imprimir', copyId: 'Copiar ID', cardType: 'Tipo de carnet', - licenseLabel: 'Licencia de skater', + licenseLabel: 'Licencia · AGPL-3.0', licenseSub: 'Tu identidad personal de rider', parkLabel: 'Acceso al park', parkSub: 'Emite pases como propietario de un park', @@ -5229,6 +5229,8 @@ settingsUi: { myData: 'Mis datos', myDataSub: 'Exportar, importar, gestionar', advancedSub: 'Relatr, Sasquatch, herramientas', + trust: 'Confianza', + trustSub: 'Fuentes y visualizacion', notLoaded: 'Sin cargar', notLoggedIn: 'Sin sesión', }, @@ -7387,6 +7389,12 @@ walletUi: { sellerHint: 'El local de este vendedor emite pases de acceso', }, trustUi: { + showMarks: 'Mostrar marcas de confianza', + showMarksDesc: 'Marcas de verificado y desconocido. Los reportes siempre se muestran.', + sourceLabel: 'Fuente', + sourceHouse: 'Proveedor de confianza de la comunidad', + sourceCustom: 'Tu proveedor', + loggedOut: 'Inicia sesion para elegir tu propio proveedor de confianza.', seoTitle: 'Cómo funciona la confianza', seoDescription: 'Cómo muestra SkateSpots la confianza: tres estados, de dónde vienen las puntuaciones y por qué la ausencia no es desconfianza.', title: 'Cómo funciona la confianza', diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 2897be2..035c529 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -15,6 +15,7 @@ import { CVMHealthCheck } from '@/components/CVMHealthCheck'; import { SasquatchAiStatusCard } from '@/components/SasquatchAiStatusCard'; import { GrantProCard } from '@/components/GrantProCard'; import { ProSupportScreen } from '@/components/ProSupportScreen'; +import { TrustSettingsCard } from '@/components/TrustSettingsCard'; import { AdminReviewCard } from '@/components/AdminReviewCard'; import { RegionBackfillCard } from '@/components/RegionBackfillCard'; import { NotificationSettingsCard } from '@/components/NotificationSettingsCard'; @@ -55,7 +56,8 @@ type Section = | 'notifications' | 'coordinator' | 'advanced' - | 'pro'; + | 'pro' + | 'trust'; // ─── Back header ────────────────────────────────────────────────────────────── @@ -865,6 +867,14 @@ function SettingsMenu({ onNavigate }: { onNavigate: (s: Section) => void }) { iconBg="bg-green-500/15" onClick={() => onNavigate('mydata')} /> + onNavigate('trust')} + /> )} {section === 'advanced' && } + {section === 'trust' && ( +
+ + +
+ )} {/* Main menu */} {!section && }