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:
parent
ff5cd8f6eb
commit
e66888128f
9 changed files with 205 additions and 9 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
39
src/hooks/useMuteList.ts
Normal 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
67
src/hooks/useMuteUser.ts
Normal 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',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
41
src/lib/localMutes.ts
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue