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
|
`wss://` relay, or clear to fall back to the community provider). The local
|
||||||
(non-provider) ladder is computed **once per viewer** by `useViewerGraph` and
|
(non-provider) ladder is computed **once per viewer** by `useViewerGraph` and
|
||||||
shared, so `TrustBadge` no longer fires a per-card `useTrustScore` graph query.
|
shared, so `TrustBadge` no longer fires a per-card `useTrustScore` graph query.
|
||||||
Still open: a
|
**Mute landed:** `useMuteList` + `useMuteUser` (NIP-51 kind-10000, optimistic via
|
||||||
mute action (no NIP-51 kind-10000 hook exists yet), counterparty alerts, and the
|
`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
|
confidence-gated scoring model. Check
|
||||||
`TRUST_SCORE.md` before touching `useTrustScore` / `useRelatrScore` /
|
`TRUST_SCORE.md` before touching `useTrustScore` / `useRelatrScore` /
|
||||||
`trustFilter`.
|
`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
|
community provider — a signed action, so it prompts the signer. Link to
|
||||||
`/trust`.
|
`/trust`.
|
||||||
|
|
||||||
Still open: a mute action (there is no NIP-51 kind-10000 hook yet — Dismiss is
|
## Fourth pass — mute (landed)
|
||||||
the local equivalent for now), counterparty alerts, and the confidence-gated
|
|
||||||
scoring model.
|
- **`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 { useTrustBucket } from '@/hooks/useTrustBucket';
|
||||||
import { useUserReports } from '@/hooks/useUserReports';
|
import { useUserReports } from '@/hooks/useUserReports';
|
||||||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||||
|
import { useMuteUser } from '@/hooks/useMuteUser';
|
||||||
import { FLAGGED_REPORTERS_THRESHOLD } from '@/lib/trustLadder';
|
import { FLAGGED_REPORTERS_THRESHOLD } from '@/lib/trustLadder';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -30,6 +31,7 @@ export function TrustFlaggedNotice({ pubkey, targetTitle, className }: TrustFlag
|
||||||
const { data: scores } = useBatchTrustScores(reporterPubkeys);
|
const { data: scores } = useBatchTrustScores(reporterPubkeys);
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const [dismissed, setDismissed] = useState(false);
|
||||||
const [reportOpen, setReportOpen] = useState(false);
|
const [reportOpen, setReportOpen] = useState(false);
|
||||||
|
const muteUser = useMuteUser();
|
||||||
|
|
||||||
const flagged = bucket === 'flagged' || reporterCount >= FLAGGED_REPORTERS_THRESHOLD;
|
const flagged = bucket === 'flagged' || reporterCount >= FLAGGED_REPORTERS_THRESHOLD;
|
||||||
if (!flagged || dismissed) return null;
|
if (!flagged || dismissed) return null;
|
||||||
|
|
@ -87,6 +89,15 @@ export function TrustFlaggedNotice({ pubkey, targetTitle, className }: TrustFlag
|
||||||
<Flag className="h-3.5 w-3.5" />
|
<Flag className="h-3.5 w-3.5" />
|
||||||
{t('walletUi.trust.flaggedReport')}
|
{t('walletUi.trust.flaggedReport')}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { getLocalVerdicts, type LocalVerdict } from '@/lib/localModeration';
|
import { getLocalVerdicts, type LocalVerdict } from '@/lib/localModeration';
|
||||||
import { getLocalDeletions } from '@/lib/localDeletions';
|
import { getLocalDeletions } from '@/lib/localDeletions';
|
||||||
|
import { useMuteList } from '@/hooks/useMuteList';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide admin moderation layer.
|
* App-wide admin moderation layer.
|
||||||
|
|
@ -38,8 +39,12 @@ export interface AdminHiddenSet {
|
||||||
verifiedCoords: Set<string>;
|
verifiedCoords: Set<string>;
|
||||||
verifiedIds: Set<string>;
|
verifiedIds: Set<string>;
|
||||||
isVerified: (opts: { coord?: string; id?: string }) => boolean;
|
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 = {
|
const EMPTY: AdminHiddenSet = {
|
||||||
coords: new Set(),
|
coords: new Set(),
|
||||||
ids: new Set(),
|
ids: new Set(),
|
||||||
|
|
@ -47,6 +52,7 @@ const EMPTY: AdminHiddenSet = {
|
||||||
verifiedCoords: new Set(),
|
verifiedCoords: new Set(),
|
||||||
verifiedIds: new Set(),
|
verifiedIds: new Set(),
|
||||||
isVerified: () => false,
|
isVerified: () => false,
|
||||||
|
mutedPubkeys: EMPTY_PUBKEYS,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -58,8 +64,10 @@ export function filterAdminHidden<
|
||||||
T extends { id: string; pubkey: string; identifier: string },
|
T extends { id: string; pubkey: string; identifier: string },
|
||||||
>(items: T[], kind: number, hidden: AdminHiddenSet): T[] {
|
>(items: T[], kind: number, hidden: AdminHiddenSet): T[] {
|
||||||
const deleted = getLocalDeletions();
|
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) => {
|
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}`;
|
const coord = `${kind}:${it.pubkey}:${it.identifier}`;
|
||||||
if (hidden.has({ coord, id: it.id })) return false;
|
if (hidden.has({ coord, id: it.id })) return false;
|
||||||
// Locally-deleted (NIP-09 pending): drop immediately so a deleted item can
|
// Locally-deleted (NIP-09 pending): drop immediately so a deleted item can
|
||||||
|
|
@ -83,8 +91,9 @@ export function filterAdminHiddenMixed<
|
||||||
},
|
},
|
||||||
>(items: T[], hidden: AdminHiddenSet): T[] {
|
>(items: T[], hidden: AdminHiddenSet): T[] {
|
||||||
const deleted = getLocalDeletions();
|
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) => {
|
return items.filter((it) => {
|
||||||
|
if (hidden.mutedPubkeys.has(it.pubkey)) return false;
|
||||||
const coord = `${it.event.kind}:${it.pubkey}:${it.identifier}`;
|
const coord = `${it.event.kind}:${it.pubkey}:${it.identifier}`;
|
||||||
if (hidden.has({ coord, id: it.id })) return false;
|
if (hidden.has({ coord, id: it.id })) return false;
|
||||||
if (deleted.has(coord) || deleted.has(`e:${it.id}`)) return false;
|
if (deleted.has(coord) || deleted.has(`e:${it.id}`)) return false;
|
||||||
|
|
@ -181,6 +190,7 @@ export function useAdminModeration() {
|
||||||
verifiedIds,
|
verifiedIds,
|
||||||
isVerified: ({ coord, id }) =>
|
isVerified: ({ coord, id }) =>
|
||||||
(!!coord && verifiedCoords.has(coord)) || (!!id && verifiedIds.has(id)),
|
(!!coord && verifiedCoords.has(coord)) || (!!id && verifiedIds.has(id)),
|
||||||
|
mutedPubkeys: EMPTY_PUBKEYS,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
|
|
@ -188,6 +198,9 @@ export function useAdminModeration() {
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mutes = useMuteList();
|
||||||
|
const mutedPubkeys = mutes.data ?? EMPTY_PUBKEYS;
|
||||||
|
|
||||||
// Even if the relay query is still loading/empty, honor local optimistic
|
// Even if the relay query is still loading/empty, honor local optimistic
|
||||||
// hides so the takedown is reflected immediately on this device.
|
// hides so the takedown is reflected immediately on this device.
|
||||||
if (!query.data) {
|
if (!query.data) {
|
||||||
|
|
@ -204,7 +217,7 @@ export function useAdminModeration() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coords.size === 0 && ids.size === 0 && verifiedCoords.size === 0 && verifiedIds.size === 0) {
|
if (coords.size === 0 && ids.size === 0 && verifiedCoords.size === 0 && verifiedIds.size === 0) {
|
||||||
return EMPTY;
|
return { ...EMPTY, mutedPubkeys };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -216,8 +229,9 @@ export function useAdminModeration() {
|
||||||
verifiedIds,
|
verifiedIds,
|
||||||
isVerified: ({ coord, id }) =>
|
isVerified: ({ coord, id }) =>
|
||||||
(!!coord && verifiedCoords.has(coord)) || (!!id && verifiedIds.has(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',
|
top10Hint: 'Reach the top 10 supporters',
|
||||||
},
|
},
|
||||||
trust: {
|
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',
|
attributedNetwork: 'From people in your network',
|
||||||
attributedProviderHouse: 'From the community trust provider',
|
attributedProviderHouse: 'From the community trust provider',
|
||||||
attributedProviderCustom: 'From the provider you chose',
|
attributedProviderCustom: 'From the provider you chose',
|
||||||
|
|
|
||||||
|
|
@ -6145,6 +6145,11 @@ walletUi: {
|
||||||
top10Hint: 'Entra en el top 10 de patrocinadores',
|
top10Hint: 'Entra en el top 10 de patrocinadores',
|
||||||
},
|
},
|
||||||
trust: {
|
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',
|
attributedNetwork: 'De personas de tu red',
|
||||||
attributedProviderHouse: 'Del proveedor de confianza de la comunidad',
|
attributedProviderHouse: 'Del proveedor de confianza de la comunidad',
|
||||||
attributedProviderCustom: 'Del proveedor que elegiste',
|
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