SkateSpots/src/hooks/useAdminModeration.ts
shakespeare.diy 79e8376f58 Make admin delete non-blocking + polish notification bell
DELETE HANG (real fix):
The admin takedown required TWO sequential Amber approvals (kind:5
deletion + kind:1985 hide label) and awaited both. On mobile the second
Amber prompt often doesn't resurface, so the button sat on "Deleting…"
until the 90s signer timeout. Now:
- Only the kind:5 deletion is awaited (the required action).
- The target is recorded in a local optimistic-hide store immediately, so
  the app hides it on this device the instant kind:5 succeeds — no waiting
  on a second signature.
- The cross-device kind:1985 label is fired in the BACKGROUND (not
  awaited); a second approval, if needed, happens after the dialog closes.
- The whole mutation is wrapped in a 40s hard timeout so the spinner can
  never hang indefinitely, whatever the signer/relay does.
- useAdminModeration merges the local optimistic set so hides are instant
  and survive reload.

NOTIFICATION BELL UI:
- Brand-lime (#ccff00) glowing badge with ring, replacing the flat teal dot.
- Bell wiggles + soft glow pulse when there are unread items (new `wiggle`
  keyframe).
- Gradient header with an icon chip, inline count, and a pill "Mark read"
  button / "All clear" state.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 22:59:16 +07:00

150 lines
4.8 KiB
TypeScript

import { useQuery } from '@tanstack/react-query';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { ADMIN_PUBKEYS } from '@/lib/admins';
import {
MODERATION_LABEL_NAMESPACE,
MODERATION_LABEL_HIDDEN,
} from '@/lib/types';
import { getLocalHidden } from '@/lib/localModeration';
/**
* App-wide admin moderation layer.
*
* SkateSpots admins (see src/lib/admins.ts) can take down ANY listing (spot,
* crew, tour, event, shop, Lab profile, …). Because a NIP-09 deletion request
* signed by a non-author is not honored by most relays, admin takedowns also
* publish a NIP-32 kind:1985 `hidden` label referencing the target. This hook
* fetches those admin labels so the client can hide the content everywhere,
* reliably, regardless of relay NIP-09 behavior.
*
* Security: only labels authored by a hardcoded ADMIN pubkey are honored, so a
* random user cannot hide someone else's listing. The label authorship is the
* trust boundary (see .agents/skills/nostr-security §3).
*/
export interface AdminHiddenSet {
/** Hidden addressable coordinates `kind:pubkey:d`. */
coords: Set<string>;
/** Hidden concrete event ids. */
ids: Set<string>;
/** True if either set contains anything (cheap guard for callers). */
has: (opts: { coord?: string; id?: string }) => boolean;
}
const EMPTY: AdminHiddenSet = {
coords: new Set(),
ids: new Set(),
has: () => false,
};
/**
* Drop admin-hidden entities from a parsed list. Works for any entity that
* carries `{ id, pubkey, identifier }` (spots, crews, tours, shops, events, …).
* `kind` is the entity's Nostr kind, used to build the `a` coordinate.
*/
export function filterAdminHidden<
T extends { id: string; pubkey: string; identifier: string },
>(items: T[], kind: number, hidden: AdminHiddenSet): T[] {
if (hidden.coords.size === 0 && hidden.ids.size === 0) return items;
return items.filter(
(it) =>
!hidden.has({ coord: `${kind}:${it.pubkey}:${it.identifier}`, id: it.id }),
);
}
/**
* Like `filterAdminHidden`, but for a mixed-kind list where each item carries
* its own source `event` (e.g. Lab profiles, which span 10 different kinds).
* The kind is read from `it.event.kind`.
*/
export function filterAdminHiddenMixed<
T extends {
id: string;
pubkey: string;
identifier: string;
event: { kind: number };
},
>(items: T[], hidden: AdminHiddenSet): T[] {
if (hidden.coords.size === 0 && hidden.ids.size === 0) return items;
return items.filter(
(it) =>
!hidden.has({
coord: `${it.event.kind}:${it.pubkey}:${it.identifier}`,
id: it.id,
}),
);
}
export function useAdminModeration() {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const query = useQuery({
queryKey: ['admin-moderation', getQueryKeySuffix()],
queryFn: async (): Promise<AdminHiddenSet> => {
const signal = AbortSignal.timeout(8000);
const events = await querySkateContent(
[
{
kinds: [1985],
authors: ADMIN_PUBKEYS,
'#l': [MODERATION_LABEL_HIDDEN],
limit: 1000,
},
],
{ signal },
);
const adminSet = new Set(ADMIN_PUBKEYS.map((p) => p.toLowerCase()));
const coords = new Set<string>();
const ids = new Set<string>();
for (const ev of events) {
// Defense in depth: re-verify author + namespace + label client-side.
if (!adminSet.has(ev.pubkey.toLowerCase())) continue;
const hasNamespace = ev.tags.some(
([n, v]) => n === 'L' && v === MODERATION_LABEL_NAMESPACE,
);
const hasLabel = ev.tags.some(
([n, v]) => n === 'l' && v === MODERATION_LABEL_HIDDEN,
);
if (!hasNamespace || !hasLabel) continue;
for (const [n, v] of ev.tags) {
if (n === 'a' && v) coords.add(v);
if (n === 'e' && v) ids.add(v);
}
}
// Merge in this device's OPTIMISTIC local hides so admin-removed content
// disappears instantly, before the background kind:1985 label propagates.
const local = getLocalHidden();
for (const c of local.coords) coords.add(c);
for (const i of local.ids) ids.add(i);
return {
coords,
ids,
has: ({ coord, id }) =>
(!!coord && coords.has(coord)) || (!!id && ids.has(id)),
};
},
staleTime: 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnMount: true,
});
// 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) {
const local = getLocalHidden();
if (local.coords.size === 0 && local.ids.size === 0) return EMPTY;
return {
coords: local.coords,
ids: local.ids,
has: ({ coord, id }) =>
(!!coord && local.coords.has(coord)) || (!!id && local.ids.has(id)),
};
}
return query.data;
}