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 (
+
+
+
+ {!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 })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}