diff --git a/AGENTS.md b/AGENTS.md index 9939c6a..ddb9edb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2568,7 +2568,10 @@ shared, so `TrustBadge` no longer fires a per-card `useTrustScore` graph query. **Mute landed:** `useMuteList` + `useMuteUser` (NIP-51 kind-10000, optimistic via `lib/localMutes.ts`) feed `useAdminModeration`'s new `mutedPubkeys`, so the shared `filterAdminHidden` drops muted authors across every list; the flagged -notice has a Mute action. Still open: counterparty alerts and the +notice has a Mute action. **Alerts landed:** `useNetworkAlerts` + `/alerts` +(`pages/NetworkAlertsPage.tsx`) list accounts you follow that trusted people +reported/muted, with Ignore/Unfollow/Mute/Report; Settings → Trust links to it +with a count (ignored set in `skatespots:trust:alerts-ignored`). Still open: 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 bf82c40..5f93771 100644 --- a/TRUST_SCORE.md +++ b/TRUST_SCORE.md @@ -72,7 +72,19 @@ flagged notice's reporter breakdown, and Settings → Trust. content" layer; `includeHidden` admin tooling still bypasses it). - The flagged notice gains a **Mute** action (Report + Mute + Dismiss). -Still open: counterparty alerts and the confidence-gated scoring model. +## Fifth pass — network alerts (landed) + +- **`useNetworkAlerts`** takes the viewer's follows, reads their NIP-85 + assertions (batched), and surfaces the accounts that trusted people have + reported or muted (`reporters`/`muters` at/above the flagged threshold). + Ignored accounts are remembered device-locally + (`skatespots:trust:alerts-ignored`). +- **`/alerts`** (`pages/NetworkAlertsPage.tsx`) lists them with + **Ignore / Unfollow / Mute / Report** actions (reusing `useFollowActions`, + `useMuteUser`, `ReportDialog`) — inform, never auto-act. +- Settings → Trust links to it with a live count. + +Still open: the confidence-gated scoring model. diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 8b11864..f36a776 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -145,6 +145,7 @@ const ROUTES = [ '/collections', '/stats', '/trust', + '/alerts', '/mints', '/app-shop', '/diy', diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 741e436..6af5077 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -68,6 +68,7 @@ const NIP19Page = lazyWithRetry(() => import("./pages/NIP19Page").then(m => ({ d const LicensePage = lazyWithRetry(() => import("./pages/LicensePage")); const PrivacyPage = lazyWithRetry(() => import("./pages/PrivacyPage")); const TrustPage = lazyWithRetry(() => import("./pages/TrustPage")); +const NetworkAlertsPage = lazyWithRetry(() => import("./pages/NetworkAlertsPage")); const SupportersPage = lazyWithRetry(() => import("./pages/SupportersPage")); const DonatePage = lazyWithRetry(() => import("./pages/DonatePage")); const RiderCardPage = lazyWithRetry(() => import("./pages/RiderCardPage")); @@ -142,6 +143,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/TrustSettingsCard.tsx b/src/components/TrustSettingsCard.tsx index 7bf3eb7..0fd6a61 100644 --- a/src/components/TrustSettingsCard.tsx +++ b/src/components/TrustSettingsCard.tsx @@ -9,6 +9,7 @@ import { useTranslation } from '@/contexts/language'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useTrustDisplay } from '@/hooks/useTrustDisplay'; import { useTrustProvider } from '@/hooks/useTrustProvider'; +import { useNetworkAlerts } from '@/hooks/useNetworkAlerts'; import { normalizeProviderPubkey, normalizeProviderRelay, @@ -27,6 +28,7 @@ export function TrustSettingsCard() { const { user } = useCurrentUser(); const { enabled, setEnabled } = useTrustDisplay(); const { data: provider } = useTrustProvider(); + const { alertCount } = useNetworkAlerts(); const setProvider = useSetTrustProvider(); const [formOpen, setFormOpen] = useState(false); @@ -153,6 +155,18 @@ export function TrustSettingsCard() { {t('walletUi.trust.howItWorks')} + +
+ + {t('trustUi.alertsTitle')} + {user && alertCount > 0 && ( + + {alertCount} + + )} + +

{t('trustUi.alertsDesc')}

+
); } diff --git a/src/hooks/useNetworkAlerts.ts b/src/hooks/useNetworkAlerts.ts new file mode 100644 index 0000000..7042a5c --- /dev/null +++ b/src/hooks/useNetworkAlerts.ts @@ -0,0 +1,63 @@ +import { useMemo } from 'react'; + +import { useFollowing } from './useFollowing'; +import { useTrustAssertions } from './useTrustAssertions'; +import { useLocalStorage } from './useLocalStorage'; +import { FLAGGED_REPORTERS_THRESHOLD } from '@/lib/trustLadder'; + +/** A muted-by-trusted-people count at/above this also raises an alert. */ +const MUTERS_THRESHOLD = FLAGGED_REPORTERS_THRESHOLD; + +export interface NetworkAlert { + pubkey: string; + reporters: number; + muters: number; + followers?: number; + hops?: number; +} + +/** + * Accounts the viewer follows that trusted people have reported or muted, + * according to the active NIP-85 provider. + * + * This is a safety notice, not an automatic action: the page offers + * ignore / unfollow / mute / report, and the user decides. Dismissed accounts + * are remembered device-locally. + */ +export function useNetworkAlerts() { + const { data: following } = useFollowing(); + const { data: assertions, isLoading } = useTrustAssertions(following ?? []); + const [ignored, setIgnored] = useLocalStorage('skatespots:trust:alerts-ignored', []); + + const alerts = useMemo(() => { + const out: NetworkAlert[] = []; + for (const pubkey of following ?? []) { + if (ignored.includes(pubkey)) continue; + const assertion = assertions?.get(pubkey); + if (!assertion) continue; + + const reporters = assertion.reporters ?? 0; + const muters = assertion.muters ?? 0; + if (reporters >= FLAGGED_REPORTERS_THRESHOLD || muters >= MUTERS_THRESHOLD) { + out.push({ + pubkey, + reporters, + muters, + followers: assertion.followers, + hops: assertion.hops, + }); + } + } + // Strongest negative signal first. + out.sort((a, b) => b.reporters + b.muters - (a.reporters + a.muters)); + return out; + }, [following, assertions, ignored]); + + return { + alerts, + alertCount: alerts.length, + isLoading, + ignore: (pubkey: string) => + setIgnored((prev) => (prev.includes(pubkey) ? prev : [...prev, pubkey])), + }; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index dca65e0..ce17dd9 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -7394,6 +7394,14 @@ walletUi: { sellerHint: 'This seller’s venue issues access passes', }, trustUi: { + alertsTitle: 'Network alerts', + alertsDesc: 'Accounts you follow that trusted people have reported or muted.', + alertsEmpty: 'No alerts. We check the accounts you follow for trusted reports.', + alertsReported: 'Reported by {{count}} trusted people', + alertsMuted: 'Muted by {{count}} trusted people', + alertsIgnore: 'Ignore', + alertsUnfollow: 'Unfollow', + alertsLogin: 'Log in to see alerts for the accounts you follow.', providerTitle: 'Provider', providerDesc: 'Scores come from a provider you choose. The community provider is the default.', useCommunity: 'Use community provider', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 80a4c4a..9dc1505 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -7394,6 +7394,14 @@ walletUi: { sellerHint: 'El local de este vendedor emite pases de acceso', }, trustUi: { + alertsTitle: 'Avisos de la red', + alertsDesc: 'Cuentas que sigues que personas de confianza han reportado o silenciado.', + alertsEmpty: 'Sin avisos. Revisamos las cuentas que sigues por reportes de confianza.', + alertsReported: 'Reportada por {{count}} personas de confianza', + alertsMuted: 'Silenciada por {{count}} personas de confianza', + alertsIgnore: 'Ignorar', + alertsUnfollow: 'Dejar de seguir', + alertsLogin: 'Inicia sesion para ver avisos de las cuentas que sigues.', 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', diff --git a/src/pages/NetworkAlertsPage.tsx b/src/pages/NetworkAlertsPage.tsx new file mode 100644 index 0000000..cc2b3f5 --- /dev/null +++ b/src/pages/NetworkAlertsPage.tsx @@ -0,0 +1,156 @@ +import { useState } from 'react'; +import { useSeoMeta } from '@unhead/react'; +import { Link } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; +import { ShieldAlert, Flag, UserMinus, X } from 'lucide-react'; + +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { ReportDialog } from '@/components/ReportDialog'; +import { useTranslation } from '@/contexts/language'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useNetworkAlerts } from '@/hooks/useNetworkAlerts'; +import { useFollowActions } from '@/hooks/useFollowing'; +import { useMuteUser } from '@/hooks/useMuteUser'; +import { genUserName } from '@/lib/genUserName'; + +/** + * Accounts you follow that trusted people have reported or muted. A safety + * notice with actions the user chooses — ignore, unfollow, mute, report. + */ +export default function NetworkAlertsPage() { + const { t } = useTranslation(); + useSeoMeta({ + title: t('trustUi.alertsTitle'), + description: t('trustUi.alertsDesc'), + }); + + const { user } = useCurrentUser(); + const { alerts, isLoading, ignore } = useNetworkAlerts(); + + return ( +
+
+

+ + {t('trustUi.alertsTitle')} +

+

{t('trustUi.alertsDesc')}

+
+ + {!user ? ( +

{t('trustUi.alertsLogin')}

+ ) : isLoading ? ( +

{t('common.loading')}

+ ) : alerts.length === 0 ? ( +
+ {t('trustUi.alertsEmpty')} +
+ ) : ( +
+ {alerts.map((alert) => ( + ignore(alert.pubkey)} + /> + ))} +
+ )} +
+ ); +} + +function AlertRow({ + pubkey, + reporters, + muters, + onIgnore, +}: { + pubkey: string; + reporters: number; + muters: number; + onIgnore: () => void; +}) { + const { t } = useTranslation(); + const { data: author } = useAuthor(pubkey); + const metadata = author?.metadata; + const name = metadata?.name || metadata?.display_name || genUserName(pubkey); + const { unfollow, isUnfollowing } = useFollowActions(); + const muteUser = useMuteUser(); + const [reportOpen, setReportOpen] = useState(false); + const profilePath = `/${nip19.npubEncode(pubkey)}`; + + return ( +
+
+ + {metadata?.picture ? : null} + {name.slice(0, 2).toUpperCase()} + + +
+ + {name} + +

+ {reporters > 0 + ? t('trustUi.alertsReported', { count: reporters }) + : t('trustUi.alertsMuted', { count: muters })} +

+ +
+ + + + +
+
+
+ + +
+ ); +}