feat(trust): a real mute (NIP-51 kind 10000) that hides content

Completes the safety loop. useMuteList reads the viewer's kind-10000 mute
list plus optimistic local mutes (lib/localMutes.ts); useMuteUser
republishes the list with the target added, records it locally and
invalidates content queries. useAdminModeration carries a new
mutedPubkeys set, and the shared filterAdminHidden/filterAdminHiddenMixed
drop muted authors — so every list hook filters them with no per-hook
changes (admin tooling with includeHidden still sees them). The flagged
notice gains a Mute action.
This commit is contained in:
SkateSpots 2026-10-09 18:23:04 +07:00
parent ff5cd8f6eb
commit e66888128f
9 changed files with 205 additions and 9 deletions

View file

@ -2565,8 +2565,10 @@ kind-10040 provider designation** (`useSetTrustProvider`: custom npub/hex +
`wss://` relay, or clear to fall back to the community provider). The local
(non-provider) ladder is computed **once per viewer** by `useViewerGraph` and
shared, so `TrustBadge` no longer fires a per-card `useTrustScore` graph query.
Still open: a
mute action (no NIP-51 kind-10000 hook exists yet), counterparty alerts, and the
**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
confidence-gated scoring model. Check
`TRUST_SCORE.md` before touching `useTrustScore` / `useRelatrScore` /
`trustFilter`.

View file

@ -58,9 +58,21 @@ flagged notice's reporter breakdown, and Settings → Trust.
community provider — a signed action, so it prompts the signer. Link to
`/trust`.
Still open: 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.
## Fourth pass — mute (landed)
- **`useMuteList`** reads the viewer's NIP-51 **kind-10000** mute list plus any
optimistic local mutes (`lib/localMutes.ts`, `skatespots:local-mutes-v1`).
- **`useMuteUser`** republishes kind-10000 with the target added (preserving
existing entries), records it locally, and invalidates content queries.
- The shared **`filterAdminHidden` / `filterAdminHiddenMixed`** now also drop
authors in `hidden.mutedPubkeys`, so every list hook that already filters
hidden content (spots/shops/events/tours/crews/marketplace/skaters/coaches/lab)
hides muted accounts with **no per-hook changes**. `useAdminModeration` carries
the mute set alongside the admin verdicts (it is the app-wide "drop this
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.

View file

@ -6,6 +6,7 @@ import { ReportDialog } from '@/components/ReportDialog';
import { useTrustBucket } from '@/hooks/useTrustBucket';
import { useUserReports } from '@/hooks/useUserReports';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useMuteUser } from '@/hooks/useMuteUser';
import { FLAGGED_REPORTERS_THRESHOLD } from '@/lib/trustLadder';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
@ -30,6 +31,7 @@ export function TrustFlaggedNotice({ pubkey, targetTitle, className }: TrustFlag
const { data: scores } = useBatchTrustScores(reporterPubkeys);
const [dismissed, setDismissed] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const muteUser = useMuteUser();
const flagged = bucket === 'flagged' || reporterCount >= FLAGGED_REPORTERS_THRESHOLD;
if (!flagged || dismissed) return null;
@ -87,6 +89,15 @@ export function TrustFlaggedNotice({ pubkey, targetTitle, className }: TrustFlag
<Flag className="h-3.5 w-3.5" />
{t('walletUi.trust.flaggedReport')}
</Button>
<Button
size="sm"
variant="outline"
className="h-8 min-h-0"
disabled={muteUser.isPending}
onClick={() => muteUser.mutate(pubkey, { onSuccess: () => setDismissed(true) })}
>
{t('walletUi.trust.mute')}
</Button>
<Button
size="sm"
variant="ghost"

View file

@ -9,6 +9,7 @@ import {
} from '@/lib/types';
import { getLocalVerdicts, type LocalVerdict } from '@/lib/localModeration';
import { getLocalDeletions } from '@/lib/localDeletions';
import { useMuteList } from '@/hooks/useMuteList';
/**
* App-wide admin moderation layer.
@ -38,8 +39,12 @@ export interface AdminHiddenSet {
verifiedCoords: Set<string>;
verifiedIds: Set<string>;
isVerified: (opts: { coord?: string; id?: string }) => boolean;
/** Authors the viewer muted (NIP-51 kind 10000 + optimistic). */
mutedPubkeys: Set<string>;
}
const EMPTY_PUBKEYS = new Set<string>();
const EMPTY: AdminHiddenSet = {
coords: new Set(),
ids: new Set(),
@ -47,6 +52,7 @@ const EMPTY: AdminHiddenSet = {
verifiedCoords: new Set(),
verifiedIds: new Set(),
isVerified: () => false,
mutedPubkeys: EMPTY_PUBKEYS,
};
/**
@ -58,8 +64,10 @@ export function filterAdminHidden<
T extends { id: string; pubkey: string; identifier: string },
>(items: T[], kind: number, hidden: AdminHiddenSet): T[] {
const deleted = getLocalDeletions();
if (hidden.coords.size === 0 && hidden.ids.size === 0 && deleted.size === 0) return items;
if (hidden.coords.size === 0 && hidden.ids.size === 0 && deleted.size === 0 && hidden.mutedPubkeys.size === 0) return items;
return items.filter((it) => {
// Muted authors are dropped everywhere this filter is used (NIP-51 mute).
if (hidden.mutedPubkeys.has(it.pubkey)) return false;
const coord = `${kind}:${it.pubkey}:${it.identifier}`;
if (hidden.has({ coord, id: it.id })) return false;
// Locally-deleted (NIP-09 pending): drop immediately so a deleted item can
@ -83,8 +91,9 @@ export function filterAdminHiddenMixed<
},
>(items: T[], hidden: AdminHiddenSet): T[] {
const deleted = getLocalDeletions();
if (hidden.coords.size === 0 && hidden.ids.size === 0 && deleted.size === 0) return items;
if (hidden.coords.size === 0 && hidden.ids.size === 0 && deleted.size === 0 && hidden.mutedPubkeys.size === 0) return items;
return items.filter((it) => {
if (hidden.mutedPubkeys.has(it.pubkey)) return false;
const coord = `${it.event.kind}:${it.pubkey}:${it.identifier}`;
if (hidden.has({ coord, id: it.id })) return false;
if (deleted.has(coord) || deleted.has(`e:${it.id}`)) return false;
@ -181,6 +190,7 @@ export function useAdminModeration() {
verifiedIds,
isVerified: ({ coord, id }) =>
(!!coord && verifiedCoords.has(coord)) || (!!id && verifiedIds.has(id)),
mutedPubkeys: EMPTY_PUBKEYS,
};
},
staleTime: 60 * 1000,
@ -188,6 +198,9 @@ export function useAdminModeration() {
refetchOnMount: true,
});
const mutes = useMuteList();
const mutedPubkeys = mutes.data ?? EMPTY_PUBKEYS;
// Even if the relay query is still loading/empty, honor local optimistic
// hides so the takedown is reflected immediately on this device.
if (!query.data) {
@ -204,7 +217,7 @@ export function useAdminModeration() {
}
if (coords.size === 0 && ids.size === 0 && verifiedCoords.size === 0 && verifiedIds.size === 0) {
return EMPTY;
return { ...EMPTY, mutedPubkeys };
}
return {
@ -216,8 +229,9 @@ export function useAdminModeration() {
verifiedIds,
isVerified: ({ coord, id }) =>
(!!coord && verifiedCoords.has(coord)) || (!!id && verifiedIds.has(id)),
mutedPubkeys,
};
}
return query.data;
return { ...query.data, mutedPubkeys };
}

39
src/hooks/useMuteList.ts Normal file
View file

@ -0,0 +1,39 @@
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from './useCurrentUser';
import { onlyVerified } from '@/lib/verifyEvents';
import { getLocalMutes } from '@/lib/localMutes';
/** NIP-51 mute list. */
export const MUTE_LIST_KIND = 10000;
/**
* The viewer's muted pubkeys: their kind-10000 list plus any optimistic mutes
* made on this device. Returns an empty set when logged out.
*/
export function useMuteList() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const pubkey = user?.pubkey;
return useQuery({
queryKey: ['mute-list', pubkey],
enabled: !!pubkey,
staleTime: 5 * 60 * 1000,
queryFn: async (c): Promise<Set<string>> => {
const set = getLocalMutes();
if (!pubkey) return set;
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
const events = onlyVerified(await nostr.query(
[{ kinds: [MUTE_LIST_KIND], authors: [pubkey], limit: 1 }],
{ signal },
));
for (const tag of events[0]?.tags ?? []) {
if (tag[0] === 'p' && tag[1]) set.add(tag[1]);
}
return set;
},
});
}

67
src/hooks/useMuteUser.ts Normal file
View file

@ -0,0 +1,67 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { onlyVerified } from '@/lib/verifyEvents';
import { addLocalMute, getLocalMutes } from '@/lib/localMutes';
import { MUTE_LIST_KIND } from '@/hooks/useMuteList';
/**
* Mute a pubkey by republishing the viewer's NIP-51 kind-10000 mute list with
* the target added, preserving any entries already there. Optimistically records
* it locally and invalidates the content queries so the muted account drops out
* of the lists immediately.
*/
export function useMuteUser() {
const { nostr } = useNostr();
const { mutateAsync: publish } = useNostrPublish();
const { user } = useCurrentUser();
const { toast } = useToast();
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (target: string) => {
if (!user) throw new Error(t('walletUi.trust.loginToMute'));
let existing: string[] = [];
try {
const signal = AbortSignal.any([AbortSignal.timeout(8000)]);
const events = onlyVerified(await nostr.query(
[{ kinds: [MUTE_LIST_KIND], authors: [user.pubkey], limit: 1 }],
{ signal },
));
existing = (events[0]?.tags ?? [])
.filter(([name]) => name === 'p')
.map(([, value]) => value)
.filter(Boolean);
} catch {
// Relay unreachable — publish with what we know locally rather than fail.
}
const merged = new Set([...existing, ...getLocalMutes(), target]);
await publish({
kind: MUTE_LIST_KIND,
content: '',
tags: [...merged].map((pk) => ['p', pk]),
});
},
onSuccess: (_data, target) => {
addLocalMute(target);
queryClient.invalidateQueries({ queryKey: ['mute-list', user?.pubkey] });
// Refresh content so the muted account's items drop out of the lists.
queryClient.invalidateQueries();
toast({ title: t('walletUi.trust.muted'), description: t('walletUi.trust.mutedDesc') });
},
onError: (error) => {
toast({
title: t('walletUi.trust.muteFailed'),
description: error instanceof Error ? error.message : undefined,
variant: 'destructive',
});
},
});
}

View file

@ -6145,6 +6145,11 @@ walletUi: {
top10Hint: 'Reach the top 10 supporters',
},
trust: {
mute: 'Mute',
muted: 'Muted',
mutedDesc: 'You will not see their content.',
muteFailed: 'Could not mute. Try again.',
loginToMute: 'Log in to mute.',
attributedNetwork: 'From people in your network',
attributedProviderHouse: 'From the community trust provider',
attributedProviderCustom: 'From the provider you chose',

View file

@ -6145,6 +6145,11 @@ walletUi: {
top10Hint: 'Entra en el top 10 de patrocinadores',
},
trust: {
mute: 'Silenciar',
muted: 'Silenciado',
mutedDesc: 'No veras su contenido.',
muteFailed: 'No se pudo silenciar. Intentalo de nuevo.',
loginToMute: 'Inicia sesion para silenciar.',
attributedNetwork: 'De personas de tu red',
attributedProviderHouse: 'Del proveedor de confianza de la comunidad',
attributedProviderCustom: 'Del proveedor que elegiste',

41
src/lib/localMutes.ts Normal file
View file

@ -0,0 +1,41 @@
/**
* Device-local optimistic mutes.
*
* Publishing a NIP-51 kind-10000 mute list is a signed action, and the relay
* may not hand it back immediately. Keeping the just-muted pubkeys here means
* the mute takes effect on this device at once and survives a reload until the
* published list is read back. The relay list remains the source of truth; this
* is only an overlay (mirrors `localModeration` / `localDeletions`).
*/
const STORAGE_KEY = 'skatespots:local-mutes-v1';
function read(): string[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as string[]) : [];
} catch {
return [];
}
}
function write(list: string[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...new Set(list)]));
} catch {
/* storage full / unavailable — best-effort */
}
}
export function getLocalMutes(): Set<string> {
return new Set(read());
}
export function addLocalMute(pubkey: string): void {
if (!pubkey) return;
const list = read();
list.push(pubkey);
write(list);
}