diff --git a/AGENTS.md b/AGENTS.md index 7baaa42..17f60bb 100644 --- a/AGENTS.md +++ b/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/* → diff --git a/release-notes.txt b/release-notes.txt index 0732359..8491db8 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -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 diff --git a/src/components/AdminReviewCard.tsx b/src/components/AdminReviewCard.tsx index 1609e76..b95ff73 100644 --- a/src/components/AdminReviewCard.tsx +++ b/src/components/AdminReviewCard.tsx @@ -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; diff --git a/src/hooks/useAdminHide.ts b/src/hooks/useAdminHide.ts index b5c0ef5..11fa342 100644 --- a/src/hooks/useAdminHide.ts +++ b/src/hooks/useAdminHide.ts @@ -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 }; } diff --git a/src/hooks/useAdminModeration.ts b/src/hooks/useAdminModeration.ts index 330f7d1..cbcbc80 100644 --- a/src/hooks/useAdminModeration.ts +++ b/src/hooks/useAdminModeration.ts @@ -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; /** 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; + verifiedIds: Set; + 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(); const ids = new Set(); + const verifiedCoords = new Set(); + const verifiedIds = new Set(); 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)), }; } diff --git a/src/lib/localModeration.ts b/src/lib/localModeration.ts index 78a6c85..9c45991 100644 --- a/src/lib/localModeration.ts +++ b/src/lib/localModeration.ts @@ -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; 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; ids: Set } { + 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(); diff --git a/src/lib/spotQuality.ts b/src/lib/spotQuality.ts new file mode 100644 index 0000000..68b6226 --- /dev/null +++ b/src/lib/spotQuality.ts @@ -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; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 261e2a8..79abaf8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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[] { diff --git a/src/pages/AdminReviewPage.tsx b/src/pages/AdminReviewPage.tsx index 609c3d5..1475752 100644 --- a/src/pages/AdminReviewPage.tsx +++ b/src/pages/AdminReviewPage.tsx @@ -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 ; - 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() { - ) : flagged.length === 0 ? ( + ) : rows.length === 0 ? ( @@ -114,7 +151,7 @@ export default function AdminReviewPage() { ) : (
- {flagged.map(({ spot, flags }) => ( + {rows.map(({ spot, coord, flags, trust, reporters }) => (
@@ -144,16 +181,45 @@ export default function AdminReviewPage() { ))}
+
+ + {typeof trust === 'number' && trust > 0 ? ( + + ) : ( + + )} + mapper trust {typeof trust === 'number' ? trust : '—'} + + + + {reporters > 0 + ? `${reporters} status report${reporters === 1 ? '' : 's'}` + : 'no community signals'} + +
+
); diff --git a/src/pages/SpotDetailPage.tsx b/src/pages/SpotDetailPage.tsx index 1a632d5..a07c4d4 100644 --- a/src/pages/SpotDetailPage.tsx +++ b/src/pages/SpotDetailPage.tsx @@ -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} )} + {/* Staff verdict wins if present: a human reviewed it. */} + {staffVerified && ( + + + Verified by SkateSpots + + )} {/* Community confidence — NOT a quality/weather score. */} + {!staffVerified && ( )} + )} {spot.types.map((type) => ( {SPOT_TYPE_LABELS[type] || type}