feat(moderation): positive 'verified' verdict + trust/community signals in the review queue
- MODERATION_LABEL_VERIFIED: the positive counterpart to 'hidden'. Both labels are fetched in ONE query; useAdminModeration now exposes verifiedCoords/ verifiedIds/isVerified() with a local mirror (addLocalConfirmed/ getLocalConfirmed). useAdminVerify() publishes it (shares the publish path with useAdminHide). - Review queue: a human review can now CONFIRM a spot, not just hide it. A confirmed spot clears from the queue and gets a 'Verified by SkateSpots' badge on the spot page (staff verdict, shown separately from the community badge). - Two more signals: mapper trust (useBatchTrustScores) and whether any skater has filed a status report (useBatchSpotStatus reporterCount) -> 'mapper not in your web of trust' / 'no community signals'. The latter only fires alongside another flag so a brand-new real spot isn't punished for being new. - Flag logic moved to src/lib/spotQuality.ts so the page and AdminReviewCard share it. Verified: the queue lists the airport entry with suspicious name + no photo + no community signals, shows mapper trust, and offers Confirm/Hide/Not a skate spot/Look at it.
This commit is contained in:
parent
a43ff47b28
commit
0419c18810
10 changed files with 338 additions and 100 deletions
14
AGENTS.md
14
AGENTS.md
|
|
@ -390,6 +390,20 @@ session with **no other memory of this project**. So:
|
|||
`AdminReviewCard` in Settings → Advanced. Hiding is **soft and reversible** —
|
||||
the event stays on relays (honest for a censorship-resistant network); nothing
|
||||
is deleted. `/review` is in the smoke-test route list.
|
||||
- **Symmetric verdicts + richer signals (2026-09-27):** `MODERATION_LABEL_VERIFIED`
|
||||
is the positive counterpart to `hidden`. `useAdminModeration` fetches both
|
||||
labels in ONE query and exposes `verifiedCoords`/`verifiedIds`/`isVerified()`
|
||||
(plus a local mirror via `addLocalConfirmed`/`getLocalConfirmed`), and
|
||||
`useAdminVerify()` (same file as `useAdminHide`) publishes it. Using it, the
|
||||
review queue can **Confirm** a spot — which clears it from the queue and shows
|
||||
a "Verified by SkateSpots" badge on the spot page — instead of only ever
|
||||
hiding things. Queue flags now also include **mapper trust** (`useBatchTrustScores`)
|
||||
and **whether any skater has filed a status report** (`useBatchSpotStatus`
|
||||
`reporterCount`) → 'mapper not in your web of trust' / 'no community signals'.
|
||||
The flag logic is shared in **`src/lib/spotQuality.ts`** (used by both the page
|
||||
and `AdminReviewCard`, so they can't drift) and is deliberately heuristic —
|
||||
'no community signals' only fires alongside another flag, so a brand-new real
|
||||
spot isn't punished for being new.
|
||||
- **Mints directory**: `/mints` page lists Cashu mints from the community
|
||||
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
||||
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ v1.5.4
|
|||
verdict on whether a place is a real spot.
|
||||
- One-tap "Not a skate spot" for off-topic pins (test entries, airports, malls,
|
||||
private land), plus an admin review queue that surfaces them for a human.
|
||||
- The queue also weighs mapper trust and whether any skater has actually
|
||||
reported on the spot, and a review can now **Confirm** a spot as well as hide
|
||||
it — a staff-vouched spot gets a badge and drops off the queue.
|
||||
|
||||
🛍 App Shop — a new section (empty on purpose, with free printables)
|
||||
- Free to print, no sats needed: a 55 × 86 mm "DO YOU EVEN SKATE BRO?" sticker
|
||||
|
|
|
|||
|
|
@ -4,22 +4,7 @@ import { ShieldAlert, ArrowRight } from 'lucide-react';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import type { SkateSpot } from '@/lib/types';
|
||||
|
||||
/** Same cheap signals the review page uses (kept in sync deliberately). */
|
||||
const SUSPICIOUS_TITLE =
|
||||
/(^|\b)(test|testing|asdf|asd|qwerty|foo|bar|untitled|demo|sample|example|secret|airport|my (house|home|flat|apartment))(\b|$)/i;
|
||||
|
||||
function isFlagged(spot: SkateSpot): boolean {
|
||||
const title = (spot.title ?? '').trim();
|
||||
const description = (spot.description ?? '').trim();
|
||||
return (
|
||||
SUSPICIOUS_TITLE.test(title) ||
|
||||
title.length <= 3 ||
|
||||
!spot.images?.length ||
|
||||
description.length < 10
|
||||
);
|
||||
}
|
||||
import { isFlagged } from '@/lib/spotQuality';
|
||||
|
||||
/**
|
||||
* Admin-only entry point for the review queue. Renders nothing for everyone
|
||||
|
|
@ -30,10 +15,9 @@ export function AdminReviewCard() {
|
|||
const isAdmin = isAdminPubkey(user?.pubkey);
|
||||
const { data: spots } = useSkateSpots(300);
|
||||
|
||||
const flaggedCount = useMemo(
|
||||
() => (spots ?? []).filter(isFlagged).length,
|
||||
[spots],
|
||||
);
|
||||
// Cheap signals only here (no per-spot trust/status queries just to draw a
|
||||
// badge) — the review page does the deeper pass.
|
||||
const flaggedCount = useMemo(() => (spots ?? []).filter(isFlagged).length, [spots]);
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,25 +2,32 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { addLocalHidden } from '@/lib/localModeration';
|
||||
import { MODERATION_LABEL_NAMESPACE, MODERATION_LABEL_HIDDEN } from '@/lib/types';
|
||||
import { addLocalHidden, addLocalConfirmed } from '@/lib/localModeration';
|
||||
import {
|
||||
MODERATION_LABEL_NAMESPACE,
|
||||
MODERATION_LABEL_HIDDEN,
|
||||
MODERATION_LABEL_VERIFIED,
|
||||
} from '@/lib/types';
|
||||
|
||||
/**
|
||||
* Publish an admin "hidden" moderation label (NIP-32 kind 1985).
|
||||
* Publish an admin moderation label (NIP-32 kind 1985) for a target.
|
||||
*
|
||||
* THE MISSING HALF: `useAdminModeration` has always READ these labels and
|
||||
* filtered hidden content out of every list — but nothing ever PUBLISHED one, so
|
||||
* the only working hide was the local, this-device-only one in
|
||||
* `useDeleteEntity`. This hook is the write side: the label is public, authored
|
||||
* by an admin pubkey, and any SkateSpots client (and any other client that
|
||||
* honours the namespace) will filter the target. It is a **soft, reversible
|
||||
* hide** — the underlying event stays on relays, which is the honest behaviour
|
||||
* on a censorship-resistant network.
|
||||
* filtered on them, but nothing ever PUBLISHED one — the only working hide was
|
||||
* the local, this-device-only one in `useDeleteEntity`. This is the write side.
|
||||
*
|
||||
* Also writes the OPTIMISTIC local hide so the content disappears instantly on
|
||||
* this device instead of waiting for the relay round-trip.
|
||||
* Two symmetric verdicts share one code path:
|
||||
* • `hidden` — take it out of the app (soft, reversible);
|
||||
* • `verified` — vouch for it, which also clears it from the review queue.
|
||||
*
|
||||
* The label is public and authored by an admin key, so every SkateSpots client
|
||||
* filters/boosts accordingly. Nothing is ever deleted from relays — that is the
|
||||
* honest behaviour on a censorship-resistant network.
|
||||
*
|
||||
* A local optimistic mirror is written too, so the effect is instant on this
|
||||
* device instead of waiting for the relay round-trip.
|
||||
*/
|
||||
export function useAdminHide() {
|
||||
function usePublishModerationLabel(label: string) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publish, isPending: publishing } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -35,27 +42,40 @@ export function useAdminHide() {
|
|||
coord?: string;
|
||||
/** Event id (for regular/replaceable events). */
|
||||
id?: string;
|
||||
/** Why it was hidden — stored in the label's content for transparency. */
|
||||
/** Why — stored in the label's content for transparency. */
|
||||
reason?: string;
|
||||
}) => {
|
||||
if (!isAdminPubkey(user?.pubkey)) throw new Error('Admins only.');
|
||||
if (!coord && !id) throw new Error('Nothing to hide — need a coordinate or an event id.');
|
||||
if (!coord && !id) throw new Error('Need a coordinate or an event id.');
|
||||
|
||||
const tags: string[][] = [
|
||||
['L', MODERATION_LABEL_NAMESPACE],
|
||||
['l', MODERATION_LABEL_HIDDEN, MODERATION_LABEL_NAMESPACE],
|
||||
['alt', 'SkateSpots: moderation label — hidden'],
|
||||
['l', label, MODERATION_LABEL_NAMESPACE],
|
||||
['alt', `SkateSpots: moderation label — ${label}`],
|
||||
];
|
||||
if (coord) tags.push(['a', coord]);
|
||||
if (id) tags.push(['e', id]);
|
||||
|
||||
await publish({ kind: 1985, content: reason ?? '', tags });
|
||||
|
||||
// Optimistic: hide on this device immediately.
|
||||
addLocalHidden({ coord, id });
|
||||
// Optimistic, this device.
|
||||
if (label === MODERATION_LABEL_HIDDEN) addLocalHidden({ coord, id });
|
||||
else addLocalConfirmed({ coord, id });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-moderation'] });
|
||||
},
|
||||
});
|
||||
|
||||
return { hideContent: mutation.mutateAsync, isPending: mutation.isPending || publishing };
|
||||
return { run: mutation.mutateAsync, isPending: mutation.isPending || publishing };
|
||||
}
|
||||
|
||||
/** Hide a target across the app (soft + reversible). */
|
||||
export function useAdminHide() {
|
||||
const { run, isPending } = usePublishModerationLabel(MODERATION_LABEL_HIDDEN);
|
||||
return { hideContent: run, isPending };
|
||||
}
|
||||
|
||||
/** Vouch for a target — the positive counterpart, which clears the review queue. */
|
||||
export function useAdminVerify() {
|
||||
const { run, isPending } = usePublishModerationLabel(MODERATION_LABEL_VERIFIED);
|
||||
return { confirmContent: run, isPending };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import { ADMIN_PUBKEYS } from '@/lib/admins';
|
|||
import {
|
||||
MODERATION_LABEL_NAMESPACE,
|
||||
MODERATION_LABEL_HIDDEN,
|
||||
MODERATION_LABEL_VERIFIED,
|
||||
} from '@/lib/types';
|
||||
import { getLocalHidden } from '@/lib/localModeration';
|
||||
import { getLocalHidden, getLocalConfirmed } from '@/lib/localModeration';
|
||||
|
||||
/**
|
||||
* App-wide admin moderation layer.
|
||||
|
|
@ -28,12 +29,22 @@ export interface AdminHiddenSet {
|
|||
ids: Set<string>;
|
||||
/** True if either set contains anything (cheap guard for callers). */
|
||||
has: (opts: { coord?: string; id?: string }) => boolean;
|
||||
/**
|
||||
* Admin-VOUCHED targets (the positive label). Used to promote a spot out of
|
||||
* the review queue and to show a "Verified" badge.
|
||||
*/
|
||||
verifiedCoords: Set<string>;
|
||||
verifiedIds: Set<string>;
|
||||
isVerified: (opts: { coord?: string; id?: string }) => boolean;
|
||||
}
|
||||
|
||||
const EMPTY: AdminHiddenSet = {
|
||||
coords: new Set(),
|
||||
ids: new Set(),
|
||||
has: () => false,
|
||||
verifiedCoords: new Set(),
|
||||
verifiedIds: new Set(),
|
||||
isVerified: () => false,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -87,7 +98,8 @@ export function useAdminModeration() {
|
|||
{
|
||||
kinds: [1985],
|
||||
authors: ADMIN_PUBKEYS,
|
||||
'#l': [MODERATION_LABEL_HIDDEN],
|
||||
// One request for BOTH admin verdicts: hidden and verified.
|
||||
'#l': [MODERATION_LABEL_HIDDEN, MODERATION_LABEL_VERIFIED],
|
||||
limit: 1000,
|
||||
},
|
||||
],
|
||||
|
|
@ -97,6 +109,8 @@ export function useAdminModeration() {
|
|||
const adminSet = new Set(ADMIN_PUBKEYS.map((p) => p.toLowerCase()));
|
||||
const coords = new Set<string>();
|
||||
const ids = new Set<string>();
|
||||
const verifiedCoords = new Set<string>();
|
||||
const verifiedIds = new Set<string>();
|
||||
|
||||
for (const ev of events) {
|
||||
// Defense in depth: re-verify author + namespace + label client-side.
|
||||
|
|
@ -104,14 +118,18 @@ export function useAdminModeration() {
|
|||
const hasNamespace = ev.tags.some(
|
||||
([n, v]) => n === 'L' && v === MODERATION_LABEL_NAMESPACE,
|
||||
);
|
||||
const hasLabel = ev.tags.some(
|
||||
if (!hasNamespace) continue;
|
||||
const isHidden = ev.tags.some(
|
||||
([n, v]) => n === 'l' && v === MODERATION_LABEL_HIDDEN,
|
||||
);
|
||||
if (!hasNamespace || !hasLabel) continue;
|
||||
const isVerified = ev.tags.some(
|
||||
([n, v]) => n === 'l' && v === MODERATION_LABEL_VERIFIED,
|
||||
);
|
||||
if (!isHidden && !isVerified) continue;
|
||||
|
||||
for (const [n, v] of ev.tags) {
|
||||
if (n === 'a' && v) coords.add(v);
|
||||
if (n === 'e' && v) ids.add(v);
|
||||
if (n === 'a' && v) (isHidden ? coords : verifiedCoords).add(v);
|
||||
if (n === 'e' && v) (isHidden ? ids : verifiedIds).add(v);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,12 +138,19 @@ export function useAdminModeration() {
|
|||
const local = getLocalHidden();
|
||||
for (const c of local.coords) coords.add(c);
|
||||
for (const i of local.ids) ids.add(i);
|
||||
const localConfirmed = getLocalConfirmed();
|
||||
for (const c of localConfirmed.coords) verifiedCoords.add(c);
|
||||
for (const i of localConfirmed.ids) verifiedIds.add(i);
|
||||
|
||||
return {
|
||||
coords,
|
||||
ids,
|
||||
has: ({ coord, id }) =>
|
||||
(!!coord && coords.has(coord)) || (!!id && ids.has(id)),
|
||||
verifiedCoords,
|
||||
verifiedIds,
|
||||
isVerified: ({ coord, id }) =>
|
||||
(!!coord && verifiedCoords.has(coord)) || (!!id && verifiedIds.has(id)),
|
||||
};
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
|
|
@ -137,12 +162,25 @@ export function useAdminModeration() {
|
|||
// 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;
|
||||
const localConfirmed = getLocalConfirmed();
|
||||
if (
|
||||
local.coords.size === 0 &&
|
||||
local.ids.size === 0 &&
|
||||
localConfirmed.coords.size === 0 &&
|
||||
localConfirmed.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)),
|
||||
verifiedCoords: localConfirmed.coords,
|
||||
verifiedIds: localConfirmed.ids,
|
||||
isVerified: ({ coord, id }) =>
|
||||
(!!coord && localConfirmed.coords.has(coord)) ||
|
||||
(!!id && localConfirmed.ids.has(id)),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,15 +18,17 @@
|
|||
*/
|
||||
|
||||
const STORAGE_KEY = 'skatespots_local_admin_hidden_v1';
|
||||
/** Same idea, for the POSITIVE action: an admin vouching for a spot. */
|
||||
const CONFIRMED_KEY = 'skatespots_local_admin_confirmed_v1';
|
||||
|
||||
interface LocalHidden {
|
||||
coords: string[];
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
function read(): LocalHidden {
|
||||
function readAt(key: string): LocalHidden {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return { coords: [], ids: [] };
|
||||
const parsed = JSON.parse(raw) as Partial<LocalHidden>;
|
||||
return {
|
||||
|
|
@ -38,14 +40,35 @@ function read(): LocalHidden {
|
|||
}
|
||||
}
|
||||
|
||||
function write(data: LocalHidden): void {
|
||||
function writeAt(key: string, data: LocalHidden): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
} catch {
|
||||
/* storage full / unavailable — optimistic hide is best-effort */
|
||||
/* storage full / unavailable — the optimistic mirror is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
const read = () => readAt(STORAGE_KEY);
|
||||
const write = (data: LocalHidden) => writeAt(STORAGE_KEY, data);
|
||||
|
||||
/**
|
||||
* The admin's POSITIVE action: they looked at a spot and vouched for it. Stored
|
||||
* locally so the review queue stops nagging about it instantly, and mirrored by
|
||||
* a public `verified` label (see useAdminVerify).
|
||||
*/
|
||||
export function addLocalConfirmed(opts: { coord?: string; id?: string }): void {
|
||||
const data = readAt(CONFIRMED_KEY);
|
||||
if (opts.coord && !data.coords.includes(opts.coord)) data.coords.push(opts.coord);
|
||||
if (opts.id && !data.ids.includes(opts.id)) data.ids.push(opts.id);
|
||||
writeAt(CONFIRMED_KEY, data);
|
||||
}
|
||||
|
||||
/** Read the current local confirmed sets. */
|
||||
export function getLocalConfirmed(): { coords: Set<string>; ids: Set<string> } {
|
||||
const data = readAt(CONFIRMED_KEY);
|
||||
return { coords: new Set(data.coords), ids: new Set(data.ids) };
|
||||
}
|
||||
|
||||
/** Record a coordinate + event id as locally hidden by the admin. */
|
||||
export function addLocalHidden(opts: { coord?: string; id?: string }): void {
|
||||
const data = read();
|
||||
|
|
|
|||
53
src/lib/spotQuality.ts
Normal file
53
src/lib/spotQuality.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Cheap, explainable "is this pin junk?" signals.
|
||||
*
|
||||
* Shared by the admin review queue and its Settings card so the two never drift.
|
||||
* These are HEURISTICS, not verdicts: every one of them is something a human can
|
||||
* check in about two seconds, and the action they drive (a reversible NIP-32
|
||||
* label) is a suggestion-taking, never a deletion.
|
||||
*/
|
||||
import type { SkateSpot } from '@/lib/types';
|
||||
|
||||
/** Names that almost always mean a test entry or an off-topic pin. */
|
||||
const SUSPICIOUS_TITLE =
|
||||
/(^|\b)(test|testing|asdf|asd|qwerty|foo|bar|untitled|demo|sample|example|secret|airport|my (house|home|flat|apartment))(\b|$)/i;
|
||||
|
||||
export interface SpotQualitySignals {
|
||||
/** Mapper's web-of-trust score (0–100), when known. */
|
||||
mapperTrust?: number;
|
||||
/** Status reports (kind 2259) from skaters — a real "someone was here" signal. */
|
||||
communityStatusReports?: number;
|
||||
/** Community photos contributed by people other than the mapper. */
|
||||
communityPhotos?: number;
|
||||
}
|
||||
|
||||
/** Every flag that applies, as short human-readable labels. */
|
||||
export function spotFlags(spot: SkateSpot, extra: SpotQualitySignals = {}): string[] {
|
||||
const flags: string[] = [];
|
||||
const title = (spot.title ?? '').trim();
|
||||
const description = (spot.description ?? '').trim();
|
||||
|
||||
if (SUSPICIOUS_TITLE.test(title)) flags.push('suspicious name');
|
||||
if (title.length <= 3) flags.push('name too short');
|
||||
if (!spot.images || spot.images.length === 0) flags.push('no photo');
|
||||
if (description.length < 10) flags.push('no description');
|
||||
if (!spot.geohash) flags.push('no location precision');
|
||||
|
||||
// Signals that need extra data (passed in by the review queue).
|
||||
if (typeof extra.mapperTrust === 'number' && extra.mapperTrust <= 0) {
|
||||
flags.push('mapper not in your web of trust');
|
||||
}
|
||||
const communitySignals =
|
||||
(extra.communityStatusReports ?? 0) + (extra.communityPhotos ?? 0);
|
||||
if (communitySignals === 0 && flags.length > 0) {
|
||||
// Only meaningful alongside another flag: an unconfirmed spot with a real
|
||||
// name and a photo is just new, not junk.
|
||||
flags.push('no community signals');
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
export function isFlagged(spot: SkateSpot): boolean {
|
||||
return spotFlags(spot).length > 0;
|
||||
}
|
||||
|
|
@ -228,6 +228,8 @@ export const VOUCH_LABEL_VALUE = 'vouch';
|
|||
*/
|
||||
export const MODERATION_LABEL_NAMESPACE = 'org.skatespots.moderation';
|
||||
export const MODERATION_LABEL_HIDDEN = 'hidden';
|
||||
/** Admin VOUCHES for the target (the positive counterpart to `hidden`). */
|
||||
export const MODERATION_LABEL_VERIFIED = 'verified';
|
||||
|
||||
/** Extract member pubkeys (hex) claimed by a Lab profile via `p` tags. */
|
||||
export function parseMemberPubkeys(event: NostrEvent): string[] {
|
||||
|
|
|
|||
|
|
@ -1,63 +1,92 @@
|
|||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ShieldAlert, Flag, EyeOff, ExternalLink, Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import {
|
||||
ShieldAlert,
|
||||
Flag,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
BadgeCheck,
|
||||
Users,
|
||||
ShieldQuestion,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||
import { useAdminHide } from '@/hooks/useAdminHide';
|
||||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||
import { useBatchSpotStatus } from '@/hooks/useSpotStatus';
|
||||
import { useAdminHide, useAdminVerify } from '@/hooks/useAdminHide';
|
||||
import { useAdminModeration } from '@/hooks/useAdminModeration';
|
||||
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types';
|
||||
import { spotFlags } from '@/lib/spotQuality';
|
||||
import { SKATE_SPOT_KIND } from '@/lib/types';
|
||||
import NotFound from '@/pages/NotFound';
|
||||
|
||||
/**
|
||||
* Admin review queue.
|
||||
*
|
||||
* Surfaces spots that LOOK like junk using cheap, explainable signals — the kind
|
||||
* of thing a test entry or an off-topic pin trips instantly ("test of secret
|
||||
* island airport": suspicious name + no photo). It does not delete anything:
|
||||
* every action is a soft, reversible NIP-32 `hidden` label (see `useAdminHide`)
|
||||
* or a NIP-56 report. Flags are suggestions, not verdicts — a human decides.
|
||||
* Surfaces spots that LOOK like junk using cheap, explainable signals — a test
|
||||
* entry or an off-topic pin trips several at once ("test of secret island
|
||||
* airport": suspicious name + no photo + no description + no community signal).
|
||||
*
|
||||
* Nothing here deletes: **Hide** publishes a public, reversible NIP-32 label,
|
||||
* **Confirm** publishes the opposite (`verified`) so a human review can IMPROVE
|
||||
* a spot's standing and clear it from this queue, and the report sends NIP-56.
|
||||
* Flags are heuristics — a human decides.
|
||||
*/
|
||||
|
||||
const SUSPICIOUS_TITLE =
|
||||
/(^|\b)(test|testing|asdf|asd|qwerty|foo|bar|untitled|demo|sample|example|secret|airport|my (house|home|flat|apartment))(\b|$)/i;
|
||||
|
||||
/** Cheap signals. Each one is something a human can check in two seconds. */
|
||||
function flagsFor(spot: SkateSpot): string[] {
|
||||
const flags: string[] = [];
|
||||
const title = (spot.title ?? '').trim();
|
||||
const description = (spot.description ?? '').trim();
|
||||
|
||||
if (SUSPICIOUS_TITLE.test(title)) flags.push('suspicious name');
|
||||
if (title.length <= 3) flags.push('name too short');
|
||||
if (!spot.images || spot.images.length === 0) flags.push('no photo');
|
||||
if (description.length < 10) flags.push('no description');
|
||||
if (!spot.geohash) flags.push('no location precision');
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
export default function AdminReviewPage() {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: spots, isLoading } = useSkateSpots(300);
|
||||
const { hideContent, isPending: hiding } = useAdminHide();
|
||||
const { confirmContent, isPending: confirming } = useAdminVerify();
|
||||
const moderation = useAdminModeration();
|
||||
const { reportNotASpot } = useNotASpotReport();
|
||||
const { toast } = useToast();
|
||||
|
||||
const flagged = useMemo(
|
||||
// Signals that need batch data.
|
||||
const mappers = useMemo(
|
||||
() => [...new Set((spots ?? []).map((s) => s.pubkey))],
|
||||
[spots],
|
||||
);
|
||||
const { data: trustScores } = useBatchTrustScores(mappers);
|
||||
|
||||
const statusInput = useMemo(
|
||||
() => (spots ?? []).map((s) => ({ pubkey: s.pubkey, identifier: s.identifier })),
|
||||
[spots],
|
||||
);
|
||||
const { getStatus } = useBatchSpotStatus(statusInput);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
(spots ?? [])
|
||||
.map((spot) => ({ spot, flags: flagsFor(spot) }))
|
||||
.filter((row) => row.flags.length > 0)
|
||||
.map((spot) => {
|
||||
const coord = `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`;
|
||||
const status = getStatus(spot.pubkey, spot.identifier);
|
||||
return {
|
||||
spot,
|
||||
coord,
|
||||
verified: moderation.isVerified({ coord, id: spot.id }),
|
||||
trust: trustScores?.get(spot.pubkey),
|
||||
reporters: status?.reporterCount ?? 0,
|
||||
flags: spotFlags(spot, {
|
||||
mapperTrust: trustScores?.get(spot.pubkey),
|
||||
communityStatusReports: status?.reporterCount ?? 0,
|
||||
}),
|
||||
};
|
||||
})
|
||||
// Admin-vouched spots are done — and confirming one should visibly
|
||||
// remove it from the pile.
|
||||
.filter((row) => !row.verified && row.flags.length > 0)
|
||||
.sort((a, b) => b.flags.length - a.flags.length),
|
||||
[spots],
|
||||
[spots, moderation, trustScores, getStatus],
|
||||
);
|
||||
|
||||
useSeoMeta({ title: 'Review queue — SkateSpots' });
|
||||
|
|
@ -65,13 +94,9 @@ export default function AdminReviewPage() {
|
|||
// Admins only. Everyone else sees a 404 rather than a hint that this exists.
|
||||
if (!isAdminPubkey(user?.pubkey)) return <NotFound />;
|
||||
|
||||
const handleHide = async (spot: SkateSpot, flags: string[]) => {
|
||||
const handleHide = async (coord: string, id: string, flags: string[]) => {
|
||||
try {
|
||||
await hideContent({
|
||||
coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`,
|
||||
id: spot.id,
|
||||
reason: `auto-flagged: ${flags.join(', ')}`,
|
||||
});
|
||||
await hideContent({ coord, id, reason: `auto-flagged: ${flags.join(', ')}` });
|
||||
toast({
|
||||
title: 'Hidden',
|
||||
description: 'Removed from the app via a public moderation label. Reversible.',
|
||||
|
|
@ -85,8 +110,20 @@ export default function AdminReviewPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleNotASpot = (spot: SkateSpot) => {
|
||||
void reportNotASpot({ eventId: spot.id, pubkey: spot.pubkey, title: spot.title });
|
||||
const handleConfirm = async (coord: string, id: string) => {
|
||||
try {
|
||||
await confirmContent({ coord, id, reason: 'looks legit after review' });
|
||||
toast({
|
||||
title: 'Confirmed',
|
||||
description: 'Vouched for — it drops off this queue and shows as verified.',
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not confirm',
|
||||
description: e instanceof Error ? e.message : 'Try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -95,7 +132,7 @@ export default function AdminReviewPage() {
|
|||
icon={ShieldAlert}
|
||||
title="Review queue"
|
||||
accent="#ff6600"
|
||||
subtitle="Spots that trip the cheap junk signals. Hide is a soft, reversible label — nothing is deleted."
|
||||
subtitle="Spots that trip the cheap junk signals. Hide is a soft, reversible label; Confirm vouches for it. Nothing is deleted."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
|
|
@ -103,7 +140,7 @@ export default function AdminReviewPage() {
|
|||
<Skeleton className="h-24 w-full rounded-2xl" />
|
||||
<Skeleton className="h-24 w-full rounded-2xl" />
|
||||
</div>
|
||||
) : flagged.length === 0 ? (
|
||||
) : rows.length === 0 ? (
|
||||
<Card className="border-dashed border-white/10">
|
||||
<CardContent className="space-y-3 py-12 text-center">
|
||||
<CheckCircle2 className="mx-auto h-8 w-8 text-emerald-500" />
|
||||
|
|
@ -114,7 +151,7 @@ export default function AdminReviewPage() {
|
|||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{flagged.map(({ spot, flags }) => (
|
||||
{rows.map(({ spot, coord, flags, trust, reporters }) => (
|
||||
<Card key={spot.id} className="border-2 border-white/10 bg-white/[0.03]">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
|
|
@ -144,16 +181,45 @@ export default function AdminReviewPage() {
|
|||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
{typeof trust === 'number' && trust > 0 ? (
|
||||
<BadgeCheck className="h-3 w-3 text-emerald-500" />
|
||||
) : (
|
||||
<ShieldQuestion className="h-3 w-3 text-gray-600" />
|
||||
)}
|
||||
mapper trust {typeof trust === 'number' ? trust : '—'}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{reporters > 0
|
||||
? `${reporters} status report${reporters === 1 ? '' : 's'}`
|
||||
: 'no community signals'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-400"
|
||||
disabled={confirming}
|
||||
onClick={() => void handleConfirm(coord, spot.id)}
|
||||
>
|
||||
{confirming ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<BadgeCheck className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5 border-red-500/40 text-red-400 hover:bg-red-500/10"
|
||||
disabled={hiding}
|
||||
onClick={() => void handleHide(spot, flags)}
|
||||
onClick={() => void handleHide(coord, spot.id, flags)}
|
||||
>
|
||||
{hiding ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
|
|
@ -166,7 +232,13 @@ export default function AdminReviewPage() {
|
|||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5 border-amber-500/40 text-amber-400 hover:bg-amber-500/10"
|
||||
onClick={() => handleNotASpot(spot)}
|
||||
onClick={() =>
|
||||
void reportNotASpot({
|
||||
eventId: spot.id,
|
||||
pubkey: spot.pubkey,
|
||||
title: spot.title,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Flag className="h-3.5 w-3.5" />
|
||||
Not a skate spot
|
||||
|
|
@ -185,9 +257,11 @@ export default function AdminReviewPage() {
|
|||
)}
|
||||
|
||||
<p className="mt-6 text-[11px] leading-relaxed text-gray-600">
|
||||
Hiding publishes a public NIP-32 label (<code>org.skatespots.moderation</code> /
|
||||
<code> hidden</code>) that every SkateSpots client filters out; the spot itself stays on
|
||||
relays, so it can be un-hidden later. Flags are heuristics — check before you hide.
|
||||
<strong className="text-gray-500">Hide</strong> publishes{' '}
|
||||
<code>org.skatespots.moderation / hidden</code>;{' '}
|
||||
<strong className="text-gray-500">Confirm</strong> publishes{' '}
|
||||
<code>… / verified</code>, which clears the spot from this queue and badges it as
|
||||
staff-vouched. Both are public, reversible labels — the spot itself stays on relays.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { useSkateSpot } from '@/hooks/useSkateSpots';
|
|||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
|
||||
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
|
||||
import { useAdminModeration } from '@/hooks/useAdminModeration';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
|
@ -29,7 +30,12 @@ import { useMarketplaceAtSpot } from '@/hooks/useMarketplace';
|
|||
import { useSpotServices } from '@/hooks/useSpotServices';
|
||||
import { MarketplaceItemCard } from '@/components/MarketplaceItemCard';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { SPOT_TYPE_LABELS, ACCESS_TYPE_INFO, SHELTER_INFO } from '@/lib/types';
|
||||
import {
|
||||
SPOT_TYPE_LABELS,
|
||||
ACCESS_TYPE_INFO,
|
||||
SHELTER_INFO,
|
||||
SKATE_SPOT_KIND,
|
||||
} from '@/lib/types';
|
||||
import NotFound from './NotFound';
|
||||
import {
|
||||
MapPin,
|
||||
|
|
@ -56,6 +62,7 @@ import {
|
|||
MessageCircle,
|
||||
ShieldCheck,
|
||||
ShieldQuestion,
|
||||
BadgeCheck,
|
||||
} from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { ReportDialog } from '@/components/ReportDialog';
|
||||
|
|
@ -168,6 +175,14 @@ export default function SpotDetailPage() {
|
|||
(communityPhotos?.length ?? 0) + (spotStatus?.reporterCount ?? 0);
|
||||
const communityVerified = communitySignalCount > 0;
|
||||
|
||||
// Staff verdict (NIP-32 `verified` label) — separate from the community
|
||||
// signal: a human actually looked at this one.
|
||||
const moderation = useAdminModeration();
|
||||
const staffVerified = !!spot && moderation.isVerified({
|
||||
coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`,
|
||||
id: spot.id,
|
||||
});
|
||||
|
||||
useSeoMeta({
|
||||
title: spot ? `${spot.title} - SkateSpots` : 'Skate Spot - SkateSpots',
|
||||
description: spot?.description || 'View this skateboarding spot on SkateSpots.',
|
||||
|
|
@ -474,7 +489,18 @@ export default function SpotDetailPage() {
|
|||
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_INFO[spot.shelter].label}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Staff verdict wins if present: a human reviewed it. */}
|
||||
{staffVerified && (
|
||||
<Badge
|
||||
className="border border-[#ccff00]/60 bg-[#ccff00]/20 font-bold uppercase text-[#ccff00]"
|
||||
title="A SkateSpots admin reviewed and vouched for this spot."
|
||||
>
|
||||
<BadgeCheck className="mr-1 h-3 w-3" />
|
||||
Verified by SkateSpots
|
||||
</Badge>
|
||||
)}
|
||||
{/* Community confidence — NOT a quality/weather score. */}
|
||||
{!staffVerified && (
|
||||
<Badge
|
||||
className={cn(
|
||||
'border font-bold uppercase',
|
||||
|
|
@ -501,6 +527,7 @@ export default function SpotDetailPage() {
|
|||
</>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
{spot.types.map((type) => (
|
||||
<Badge key={type} className="bg-[#ccff00]/20 text-[#ccff00] border border-[#ccff00]/50 font-bold uppercase">
|
||||
{SPOT_TYPE_LABELS[type] || type}
|
||||
|
|
|
|||
Loading…
Reference in a new issue