- Report INBOX: /review now defaults to a Reports tab. useRecentReports fetches recent NIP-56 (1984) and resolves the referenced event by id (one extra ids: query), so ANY report is actionable — content that isn't flagged, comments, profiles, anything. reportTargetMeta maps kind -> label + route for the link. Actions: Hide / Dismiss report (verify) / Undo / Look at it. - Bulk undo: when a tab holds >1 verdict, a toolbar offers 'Undo all N' with an AlertDialog that warns each undo is its own label publish (one Amber approval each). Built for the 13 old hidden labels from Sep 3. - Crews + Lab tabs: added includeHidden to useCrews/useLabProfiles. Hiding these removes a community / a person's listing, so their Hide goes through a confirmation (confirmHide/confirmHideNote on QueueList). Verified in the browser: 8 tabs render; the inbox shows and resolves a real report (target + reason); the Spots tab shows the bulk-undo toolbar. tsc/eslint clean, 101 tests, smoke PASS.
120 lines
4 KiB
TypeScript
120 lines
4 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
|
import type { NostrEvent } from '@nostrify/nostrify';
|
|
|
|
/** NIP-56 report kind. */
|
|
export const REPORT_KIND = 1984;
|
|
|
|
export interface ReportTarget {
|
|
id: string;
|
|
kind: number;
|
|
pubkey: string;
|
|
identifier?: string;
|
|
/** `kind:pubkey:d` when the target is addressable (what the labels use). */
|
|
coord?: string;
|
|
title?: string;
|
|
}
|
|
|
|
export interface RecentReport {
|
|
id: string;
|
|
reporter: string;
|
|
createdAt: number;
|
|
/** NIP-56 reason from the `e` tag: spam | illegal | impersonation | other … */
|
|
reason: string;
|
|
content: string;
|
|
/** The reported event, when we could resolve it. */
|
|
target?: ReportTarget;
|
|
}
|
|
|
|
/**
|
|
* The report inbox: recent NIP-56 reports, with their targets RESOLVED.
|
|
*
|
|
* WHY: reports were only ever visible for items that already happened to be in
|
|
* the review queue (and only as a count). A report about a comment, a profile, a
|
|
* crew or anything not currently flagged had nowhere to land. This reads recent
|
|
* reports and resolves the referenced event so a human can act on any of them —
|
|
* no heuristics involved.
|
|
*/
|
|
export function useRecentReports(limit = 200) {
|
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
|
|
|
return useQuery({
|
|
queryKey: ['recent-reports', limit, getQueryKeySuffix()],
|
|
staleTime: 60_000,
|
|
queryFn: async (c): Promise<RecentReport[]> => {
|
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(9000)]);
|
|
|
|
const reports = await querySkateContent([{ kinds: [REPORT_KIND], limit }], { signal });
|
|
if (reports.length === 0) return [];
|
|
|
|
// Resolve the reported events: one extra query for every referenced id.
|
|
const ids = [
|
|
...new Set(
|
|
reports
|
|
.flatMap((r) => r.tags.filter(([n]) => n === 'e').map(([, v]) => v))
|
|
.filter(Boolean),
|
|
),
|
|
].slice(0, 200);
|
|
|
|
const targets = new Map<string, NostrEvent>();
|
|
if (ids.length > 0) {
|
|
try {
|
|
const events = await querySkateContent([{ ids, limit: ids.length }], { signal });
|
|
for (const ev of events) targets.set(ev.id, ev);
|
|
} catch {
|
|
// Resolution is best-effort — an unresolvable report is still shown.
|
|
}
|
|
}
|
|
|
|
return reports
|
|
.map((r): RecentReport => {
|
|
const eTag = r.tags.find(([n]) => n === 'e');
|
|
const targetEvent = eTag?.[1] ? targets.get(eTag[1]) : undefined;
|
|
const d = targetEvent?.tags.find(([n]) => n === 'd')?.[1];
|
|
const title = targetEvent?.tags.find(([n]) => n === 'title' || n === 'name')?.[1];
|
|
return {
|
|
id: r.id,
|
|
reporter: r.pubkey,
|
|
createdAt: r.created_at,
|
|
reason: eTag?.[2] || 'other',
|
|
content: r.content,
|
|
target: targetEvent
|
|
? {
|
|
id: targetEvent.id,
|
|
kind: targetEvent.kind,
|
|
pubkey: targetEvent.pubkey,
|
|
identifier: d,
|
|
coord: d ? `${targetEvent.kind}:${targetEvent.pubkey}:${d}` : undefined,
|
|
title,
|
|
}
|
|
: undefined,
|
|
};
|
|
})
|
|
.sort((a, b) => b.createdAt - a.createdAt);
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Human label + route for a reported kind. */
|
|
export function reportTargetMeta(kind: number): { label: string; path?: string } {
|
|
switch (kind) {
|
|
case 30818:
|
|
return { label: 'spot', path: '/spot' };
|
|
case 30017:
|
|
return { label: 'shop', path: '/shop' };
|
|
case 31923:
|
|
case 31925:
|
|
return { label: 'event', path: '/event' };
|
|
case 30823:
|
|
return { label: 'tour', path: '/tour' };
|
|
case 30402:
|
|
return { label: 'listing', path: '/listing' };
|
|
case 35006:
|
|
return { label: 'crew', path: '/crew' };
|
|
case 35000:
|
|
return { label: 'skater profile' };
|
|
default:
|
|
if (kind >= 35001 && kind <= 35014) return { label: 'Lab profile' };
|
|
return { label: `kind ${kind}` };
|
|
}
|
|
}
|