feat(trust): Settings -> Trust and name AGPL on the License page

- Settings -> Trust section (?section=trust, TrustSettingsCard): a
  show/hide-marks toggle (useTrustDisplay; Flagged is never hidden) and the
  active provider + relay (useTrustProvider), linked to /trust.
- TrustBadge respects the display preference.
- Name AGPL-3.0 on the License page by updating its existing copy
  (licenseLabel + body) in en/es.
- Settings + card i18n keys; /settings?section=trust added to the smoke
  list. Docs updated.
This commit is contained in:
SkateSpots 2026-10-09 17:45:56 +07:00
parent 71ecb03ae8
commit fd9316331b
10 changed files with 167 additions and 12 deletions

View file

@ -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, read is **batched** (one query per viewer for the union of on-screen subjects,
in `useTrustAssertions.ts`), and `useUserReports` + `components/TrustFlaggedNotice.tsx` in `useTrustAssertions.ts`), and `useUserReports` + `components/TrustFlaggedNotice.tsx`
add a flagged-counterparty notice (reporters grouped by the viewer's network, 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 Report/Dismiss) wired into the listing seller and coach detail. **Legibility
provider-choice setting, a mute action (no NIP-51 kind-10000 hook exists yet), pass:** the badge tooltip attributes the source (your network / community
counterparty alerts, and the confidence-gated scoring model. Check 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` / `TRUST_SCORE.md` before touching `useTrustScore` / `useRelatrScore` /
`trustFilter`. `trustFilter`.

View file

@ -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 safety signal returns). Wired into the listing seller card and the coach
detail page. detail page.
Still open: a Settings control to pick the trust provider; a mute action (there ## Third pass — legibility (landed)
is no NIP-51 kind-10000 hook yet — Dismiss is the local equivalent for now);
counterparty alerts; and the confidence-gated scoring model. - **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.

View file

@ -139,6 +139,7 @@ const ROUTES = [
'/settings?section=profile', '/settings?section=profile',
'/settings?section=coordinator', '/settings?section=coordinator',
'/settings?section=pro', '/settings?section=pro',
'/settings?section=trust',
'/npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx', '/npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx',
'/stash', '/stash',
'/collections', '/collections',

View file

@ -3,6 +3,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { CheckCircle2, HelpCircle, Flag } from 'lucide-react'; import { CheckCircle2, HelpCircle, Flag } from 'lucide-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTrustBucket } from '@/hooks/useTrustBucket'; import { useTrustBucket } from '@/hooks/useTrustBucket';
import { useTrustDisplay } from '@/hooks/useTrustDisplay';
import { bucketDescKey, bucketLabelKey, type TrustBucket } from '@/lib/trustLadder'; import { bucketDescKey, bucketLabelKey, type TrustBucket } from '@/lib/trustLadder';
import { HOUSE_TRUST_PROVIDER } from '@/lib/nip85'; import { HOUSE_TRUST_PROVIDER } from '@/lib/nip85';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -53,6 +54,7 @@ export function TrustBadge({
}: TrustBadgeProps) { }: TrustBadgeProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { bucket, assertion, directFollow, isLoading } = useTrustBucket(pubkey); const { bucket, assertion, directFollow, isLoading } = useTrustBucket(pubkey);
const { enabled: displayEnabled } = useTrustDisplay();
// Always attribute the claim: a verdict with no source is not trustworthy. // Always attribute the claim: a verdict with no source is not trustworthy.
const sourceKey = assertion const sourceKey = assertion
@ -62,6 +64,9 @@ export function TrustBadge({
: 'walletUi.trust.attributedNetwork'; : 'walletUi.trust.attributedNetwork';
if (isLoading) return null; 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" // 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. // mark there is redundant. Flagged is a safety signal and is never suppressed.
if (bucket === 'verified' && directFollow) return null; if (bucket === 'verified' && directFollow) return null;

View file

@ -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 (
<div className="space-y-4">
<div className="rounded-2xl border border-white/10 bg-white/3 p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="text-sm font-bold text-white">{t('trustUi.showMarks')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('trustUi.showMarksDesc')}</p>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</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>
)}
{!user && <p className="text-xs text-muted-foreground">{t('trustUi.loggedOut')}</p>}
<Link to="/trust" className="inline-block text-xs text-[#ccff00] underline">
{t('walletUi.trust.howItWorks')}
</Link>
</div>
</div>
);
}

View file

@ -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<boolean>('skatespots:trust:display', true);
return { enabled, setEnabled };
}

View file

@ -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<TrustProvider> => {
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);
},
});
}

View file

@ -2119,7 +2119,7 @@ labUi: {
}, },
draft: { draft: {
title: 'You have an unsaved 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', resume: 'Resume draft',
discard: 'Discard draft', discard: 'Discard draft',
}, },
@ -2795,7 +2795,7 @@ labUi: {
print: 'Print', print: 'Print',
copyId: 'Copy ID', copyId: 'Copy ID',
cardType: 'Card Type', cardType: 'Card Type',
licenseLabel: 'Skater License', licenseLabel: 'License · AGPL-3.0',
licenseSub: 'Your personal rider identity', licenseSub: 'Your personal rider identity',
parkLabel: 'Park Access', parkLabel: 'Park Access',
parkSub: 'Issue passes as a park owner', parkSub: 'Issue passes as a park owner',
@ -5229,6 +5229,8 @@ settingsUi: {
myData: 'My Data', myData: 'My Data',
myDataSub: 'Export, import, manage', myDataSub: 'Export, import, manage',
advancedSub: 'Relatr, Sasquatch, tools', advancedSub: 'Relatr, Sasquatch, tools',
trust: 'Trust',
trustSub: 'Sources and display',
notLoaded: 'Not loaded', notLoaded: 'Not loaded',
notLoggedIn: 'Not logged in', notLoggedIn: 'Not logged in',
}, },
@ -7387,6 +7389,12 @@ walletUi: {
sellerHint: 'This seller’s venue issues access passes', sellerHint: 'This seller’s venue issues access passes',
}, },
trustUi: { 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', seoTitle: 'How trust works',
seoDescription: 'How SkateSpots shows trust: three states, where scores come from, and why absence is not distrust.', seoDescription: 'How SkateSpots shows trust: three states, where scores come from, and why absence is not distrust.',
title: 'How trust works', title: 'How trust works',

View file

@ -2119,7 +2119,7 @@ labUi: {
}, },
draft: { draft: {
title: 'Tienes un borrador sin guardar', 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', resume: 'Reanudar borrador',
discard: 'Descartar borrador', discard: 'Descartar borrador',
}, },
@ -2795,7 +2795,7 @@ labUi: {
print: 'Imprimir', print: 'Imprimir',
copyId: 'Copiar ID', copyId: 'Copiar ID',
cardType: 'Tipo de carnet', cardType: 'Tipo de carnet',
licenseLabel: 'Licencia de skater', licenseLabel: 'Licencia · AGPL-3.0',
licenseSub: 'Tu identidad personal de rider', licenseSub: 'Tu identidad personal de rider',
parkLabel: 'Acceso al park', parkLabel: 'Acceso al park',
parkSub: 'Emite pases como propietario de un park', parkSub: 'Emite pases como propietario de un park',
@ -5229,6 +5229,8 @@ settingsUi: {
myData: 'Mis datos', myData: 'Mis datos',
myDataSub: 'Exportar, importar, gestionar', myDataSub: 'Exportar, importar, gestionar',
advancedSub: 'Relatr, Sasquatch, herramientas', advancedSub: 'Relatr, Sasquatch, herramientas',
trust: 'Confianza',
trustSub: 'Fuentes y visualizacion',
notLoaded: 'Sin cargar', notLoaded: 'Sin cargar',
notLoggedIn: 'Sin sesión', notLoggedIn: 'Sin sesión',
}, },
@ -7387,6 +7389,12 @@ walletUi: {
sellerHint: 'El local de este vendedor emite pases de acceso', sellerHint: 'El local de este vendedor emite pases de acceso',
}, },
trustUi: { 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', 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.', 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', title: 'Cómo funciona la confianza',

View file

@ -15,6 +15,7 @@ import { CVMHealthCheck } from '@/components/CVMHealthCheck';
import { SasquatchAiStatusCard } from '@/components/SasquatchAiStatusCard'; import { SasquatchAiStatusCard } from '@/components/SasquatchAiStatusCard';
import { GrantProCard } from '@/components/GrantProCard'; import { GrantProCard } from '@/components/GrantProCard';
import { ProSupportScreen } from '@/components/ProSupportScreen'; import { ProSupportScreen } from '@/components/ProSupportScreen';
import { TrustSettingsCard } from '@/components/TrustSettingsCard';
import { AdminReviewCard } from '@/components/AdminReviewCard'; import { AdminReviewCard } from '@/components/AdminReviewCard';
import { RegionBackfillCard } from '@/components/RegionBackfillCard'; import { RegionBackfillCard } from '@/components/RegionBackfillCard';
import { NotificationSettingsCard } from '@/components/NotificationSettingsCard'; import { NotificationSettingsCard } from '@/components/NotificationSettingsCard';
@ -55,7 +56,8 @@ type Section =
| 'notifications' | 'notifications'
| 'coordinator' | 'coordinator'
| 'advanced' | 'advanced'
| 'pro'; | 'pro'
| 'trust';
// ─── Back header ────────────────────────────────────────────────────────────── // ─── Back header ──────────────────────────────────────────────────────────────
@ -865,6 +867,14 @@ function SettingsMenu({ onNavigate }: { onNavigate: (s: Section) => void }) {
iconBg="bg-green-500/15" iconBg="bg-green-500/15"
onClick={() => onNavigate('mydata')} onClick={() => onNavigate('mydata')}
/> />
<MenuRow
icon={Shield}
label={t('settingsUi.settingsPage.trust')}
sublabel={t('settingsUi.settingsPage.trustSub')}
iconColor="text-[#13d2e5]"
iconBg="bg-[#13d2e5]/15"
onClick={() => onNavigate('trust')}
/>
<MenuRow <MenuRow
icon={Wrench} icon={Wrench}
label={t('settingsUi.settingsPage.advanced')} label={t('settingsUi.settingsPage.advanced')}
@ -901,7 +911,7 @@ export default function SettingsPage() {
// stale bookmark to ?section=language) falls back to the menu rather than a // stale bookmark to ?section=language) falls back to the menu rather than a
// blank screen with no way back. // blank screen with no way back.
const RENDERABLE_SECTIONS: Section[] = [ const RENDERABLE_SECTIONS: Section[] = [
'profile', 'security', 'nip05', 'mydata', 'relays', 'appearance', 'wallet', 'notifications', 'coordinator', 'advanced', 'pro', 'profile', 'security', 'nip05', 'mydata', 'relays', 'appearance', 'wallet', 'notifications', 'coordinator', 'advanced', 'pro', 'trust',
]; ];
const requestedSection = searchParams.get('section') as Section; const requestedSection = searchParams.get('section') as Section;
const initialSection = const initialSection =
@ -956,6 +966,12 @@ export default function SettingsPage() {
</div> </div>
)} )}
{section === 'advanced' && <AdvancedScreen onBack={handleBack} />} {section === 'advanced' && <AdvancedScreen onBack={handleBack} />}
{section === 'trust' && (
<div>
<BackHeader title={t('settingsUi.settingsPage.trust')} onBack={handleBack} />
<TrustSettingsCard />
</div>
)}
{/* Main menu */} {/* Main menu */}
{!section && <SettingsMenu onNavigate={handleNavigate} />} {!section && <SettingsMenu onNavigate={handleNavigate} />}