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,
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`.

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
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.

View file

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

View file

@ -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;

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: {
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',

View file

@ -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',

View file

@ -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')}
/>
<MenuRow
icon={Shield}
label={t('settingsUi.settingsPage.trust')}
sublabel={t('settingsUi.settingsPage.trustSub')}
iconColor="text-[#13d2e5]"
iconBg="bg-[#13d2e5]/15"
onClick={() => onNavigate('trust')}
/>
<MenuRow
icon={Wrench}
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
// blank screen with no way back.
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 initialSection =
@ -956,6 +966,12 @@ export default function SettingsPage() {
</div>
)}
{section === 'advanced' && <AdvancedScreen onBack={handleBack} />}
{section === 'trust' && (
<div>
<BackHeader title={t('settingsUi.settingsPage.trust')} onBack={handleBack} />
<TrustSettingsCard />
</div>
)}
{/* Main menu */}
{!section && <SettingsMenu onNavigate={handleNavigate} />}