diff --git a/AGENTS.md b/AGENTS.md index 88c4a7e..7baaa42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -367,6 +367,29 @@ session with **no other memory of this project**. So: SVGs are self-contained: brand fonts + logo embedded as data URIs and a real vector QR (verified decoding to https://skate-spots.org — if you regenerate, re-verify the QR scans with the logo in the middle; error-correction level H). +- **Spot data quality (2026-09-27):** three cheap defences against junk pins + (test entries, airports, malls), because "the map looks endorsed" is the real + risk. (1) `CreateSpotPage` **requires ≥1 photo** (guard in `handleSubmit` + + the submit button is disabled) — a typo rarely has a photo. (2) The spot page + shows a **Community verified / Unverified** badge computed from signals only + *other* skaters can produce: community photos + `useSpotStatus` `reporterCount` + (kind 2259). The weather score was renamed **"Conditions · weather only"** + (in `SpotDetailPage` and the `SpotMap` popup) so it can't read as spot quality. + (3) `useNotASpotReport` = one-tap **NIP-56 (1984)** report tagged + `not-a-skate-spot` (NIP-56 `other` reason + our `L`/`l`/`t` taxonomy), used + from the spot's "More" menu and the shared `ReportDialog`. +- **Admin hide + review queue (2026-09-27):** `useAdminModeration` always READ + NIP-32 kind-1985 `hidden` labels, but **nothing published one** — the only + working hide was device-local (`useDeleteEntity` → `addLocalHidden`). + `useAdminHide` is the missing write side: kind 1985 + `['L', + 'org.skatespots.moderation']` + `['l','hidden',NS]` + `['a',coord]`/`['e',id]`, + plus an optimistic local hide. `/review` (`AdminReviewPage`, **admin-only, + 404 otherwise**) lists spots that trip cheap signals (suspicious title, name + ≤3 chars, no photo, no description, no geohash) sorted by flag count, with + one-tap **Hide** or **Not a skate spot**; entry point is the admin-only + `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. - **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 74ae84a..0732359 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -1,5 +1,16 @@ v1.5.4 +🧹 Keeping the map real +- Every new spot now needs at least one photo — the cheapest defence against + test entries and off-topic pins ("test of secret island airport"). +- Spots only say "Community verified" when another skater has contributed (a + photo or a status report). Until then they are labelled "Unverified" instead + of looking endorsed. +- The weather score is now labelled "Conditions · weather only". It was never a + 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. + 🛍 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 and an A3 poster, both print-ready vector files with a working QR. diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index cf9ac8c..78f2c44 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -141,6 +141,7 @@ const ROUTES = [ '/stats', '/mints', '/app-shop', + '/review', '/messages/group/abc123', ]; const problems = []; diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 174e560..95fa1bf 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -18,6 +18,7 @@ const LabPage = lazyWithRetry(() => import("./pages/LabPage")); const WalletPage = lazyWithRetry(() => import("./pages/WalletPage")); const MintsPage = lazyWithRetry(() => import("./pages/MintsPage")); const AppShopPage = lazyWithRetry(() => import("./pages/AppShopPage")); +const AdminReviewPage = lazyWithRetry(() => import("./pages/AdminReviewPage")); const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage")); const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage")); const CreateShopPage = lazyWithRetry(() => import("./pages/CreateShopPage")); @@ -136,6 +137,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/AdminReviewCard.tsx b/src/components/AdminReviewCard.tsx new file mode 100644 index 0000000..1609e76 --- /dev/null +++ b/src/components/AdminReviewCard.tsx @@ -0,0 +1,59 @@ +import { Link } from 'react-router-dom'; +import { useMemo } from 'react'; +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 + ); +} + +/** + * Admin-only entry point for the review queue. Renders nothing for everyone + * else, so it can sit in Settings → Advanced beside the other owner cards. + */ +export function AdminReviewCard() { + const { user } = useCurrentUser(); + const isAdmin = isAdminPubkey(user?.pubkey); + const { data: spots } = useSkateSpots(300); + + const flaggedCount = useMemo( + () => (spots ?? []).filter(isFlagged).length, + [spots], + ); + + if (!isAdmin) return null; + + return ( + +
+ +
+

Review queue

+

+ {flaggedCount > 0 + ? `${flaggedCount} spot${flaggedCount === 1 ? '' : 's'} trip the junk signals — hide or dismiss.` + : 'Spots that look like test entries or off-topic pins.'} +

+
+ +
+ + ); +} diff --git a/src/components/ReportDialog.tsx b/src/components/ReportDialog.tsx index 07b379a..1e20a3d 100644 --- a/src/components/ReportDialog.tsx +++ b/src/components/ReportDialog.tsx @@ -18,6 +18,13 @@ import { cn } from '@/lib/utils'; // NIP-56 Report types with skate-specific labels const REPORT_REASONS = [ + { + id: 'other', + label: 'Not a skate spot', + description: 'A test entry, or a place that is not for skating at all', + icon: '🛹', + subtype: 'not-a-skate-spot', + }, { id: 'spam', label: 'Fake/Non-existent spot', diff --git a/src/components/SpotMap.tsx b/src/components/SpotMap.tsx index ea549b7..244ad46 100644 --- a/src/components/SpotMap.tsx +++ b/src/components/SpotMap.tsx @@ -580,9 +580,9 @@ function createPopupContent(
- ${escapeHtml(weatherData.skateability.label)} + Conditions · ${escapeHtml(weatherData.skateability.label)}
-
${escapeHtml(weatherData.skateability.score)}/100
+
${escapeHtml(weatherData.skateability.score)}/100 weather
`; weatherSection.appendChild(currentWeather); diff --git a/src/hooks/useAdminHide.ts b/src/hooks/useAdminHide.ts new file mode 100644 index 0000000..b5c0ef5 --- /dev/null +++ b/src/hooks/useAdminHide.ts @@ -0,0 +1,61 @@ +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'; + +/** + * Publish an admin "hidden" moderation label (NIP-32 kind 1985). + * + * 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. + * + * Also writes the OPTIMISTIC local hide so the content disappears instantly on + * this device instead of waiting for the relay round-trip. + */ +export function useAdminHide() { + const { user } = useCurrentUser(); + const { mutateAsync: publish, isPending: publishing } = useNostrPublish(); + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: async ({ + coord, + id, + reason, + }: { + /** Addressable coordinate, e.g. `30818::`. */ + coord?: string; + /** Event id (for regular/replaceable events). */ + id?: string; + /** Why it was hidden — 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.'); + + const tags: string[][] = [ + ['L', MODERATION_LABEL_NAMESPACE], + ['l', MODERATION_LABEL_HIDDEN, MODERATION_LABEL_NAMESPACE], + ['alt', 'SkateSpots: moderation label — hidden'], + ]; + 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 }); + queryClient.invalidateQueries({ queryKey: ['admin-moderation'] }); + }, + }); + + return { hideContent: mutation.mutateAsync, isPending: mutation.isPending || publishing }; +} diff --git a/src/hooks/useNotASpotReport.ts b/src/hooks/useNotASpotReport.ts new file mode 100644 index 0000000..0e4e0e6 --- /dev/null +++ b/src/hooks/useNotASpotReport.ts @@ -0,0 +1,72 @@ +import { useMutation } from '@tanstack/react-query'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useToast } from '@/hooks/useToast'; +import { MODERATION_LABEL_NAMESPACE } from '@/lib/types'; + +/** + * One-tap "this is not a skate spot" report. + * + * WHY NIP-56 AND NOT A NEW KIND: kind 1984 is the ecosystem's report event and + * `other` is its catch-all reason, so the report is useful to *any* Nostr client + * (and visible on relays) instead of being a SkateSpots-only artifact. The extra + * `L`/`l`/`t` tags give us a stable taxonomy (`not-a-skate-spot`) that our own + * admin tooling can filter on without breaking anyone else's interpretation. + * + * This is for OFF-TOPIC places (test entries, an airport, a mall, private land) — + * not for disagreeing about whether a spot is good. + */ +export function useNotASpotReport() { + const { user } = useCurrentUser(); + const { mutateAsync: publish, isPending: publishing } = useNostrPublish(); + const { toast } = useToast(); + + const mutation = useMutation({ + mutationFn: async ({ + eventId, + pubkey, + title, + }: { + /** The spot's kind-30818 event id. */ + eventId: string; + /** The spot's author. */ + pubkey: string; + title?: string; + }) => { + if (!user) throw new Error('Log in to report a spot.'); + + await publish({ + kind: 1984, // NIP-56 report + content: title ? `Not a skate spot: ${title}` : 'Not a skate spot', + tags: [ + ['e', eventId, 'other'], + ['p', pubkey], + ['L', MODERATION_LABEL_NAMESPACE], + ['l', 'not-a-skate-spot', MODERATION_LABEL_NAMESPACE], + ['t', 'not-a-skate-spot'], + ['alt', 'SkateSpots: report — not a skate spot'], + ], + }); + }, + onSuccess: () => { + toast({ + title: 'Thanks — sent for review', + description: + 'No hard feelings: this flags the pin as off-topic so people can decide for themselves.', + }); + }, + onError: (error) => { + toast({ + title: 'Could not send the report', + description: error instanceof Error ? error.message : 'Please try again.', + variant: 'destructive', + }); + }, + }); + + return { + reportNotASpot: mutation.mutateAsync, + isPending: mutation.isPending || publishing, + alreadyReported: mutation.isSuccess, + }; +} diff --git a/src/pages/AdminReviewPage.tsx b/src/pages/AdminReviewPage.tsx new file mode 100644 index 0000000..609c3d5 --- /dev/null +++ b/src/pages/AdminReviewPage.tsx @@ -0,0 +1,194 @@ +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 { 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 { 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 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. + */ + +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 { reportNotASpot } = useNotASpotReport(); + const { toast } = useToast(); + + const flagged = useMemo( + () => + (spots ?? []) + .map((spot) => ({ spot, flags: flagsFor(spot) })) + .filter((row) => row.flags.length > 0) + .sort((a, b) => b.flags.length - a.flags.length), + [spots], + ); + + useSeoMeta({ title: 'Review queue — SkateSpots' }); + + // 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[]) => { + try { + await hideContent({ + coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`, + id: spot.id, + reason: `auto-flagged: ${flags.join(', ')}`, + }); + toast({ + title: 'Hidden', + description: 'Removed from the app via a public moderation label. Reversible.', + }); + } catch (e) { + toast({ + title: 'Could not hide', + description: e instanceof Error ? e.message : 'Try again.', + variant: 'destructive', + }); + } + }; + + const handleNotASpot = (spot: SkateSpot) => { + void reportNotASpot({ eventId: spot.id, pubkey: spot.pubkey, title: spot.title }); + }; + + return ( +
+ + + {isLoading ? ( +
+ + +
+ ) : flagged.length === 0 ? ( + + + +

+ Nothing to review — no spot currently trips the junk signals. +

+
+
+ ) : ( +
+ {flagged.map(({ spot, flags }) => ( + + +
+ {spot.images?.[0] ? ( + + ) : ( +
+ 🛹 +
+ )} +
+

{spot.title || '(untitled)'}

+

+ {spot.description?.trim() || 'no description'} +

+
+ {flags.map((f) => ( + + {f} + + ))} +
+
+
+ +
+ + + +
+
+
+ ))} +
+ )} + +

+ Hiding publishes a public NIP-32 label (org.skatespots.moderation / + hidden) 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. +

+
+ ); +} diff --git a/src/pages/CreateSpotPage.tsx b/src/pages/CreateSpotPage.tsx index 6082468..b438ba9 100644 --- a/src/pages/CreateSpotPage.tsx +++ b/src/pages/CreateSpotPage.tsx @@ -251,6 +251,20 @@ export default function CreateSpotPage() { return; } + // A photo is REQUIRED. It is the single cheapest defence against the map + // filling up with test entries and off-topic pins ("test of secret island + // airport"): a real spot has a photo, a typo doesn't. It also gives every + // other skater something to verify against. + if (images.length === 0) { + toast({ + title: 'Add a photo first', + description: + 'Every spot needs at least one photo — of the spot, not the map. It keeps the map real.', + variant: 'destructive', + }); + return; + } + // Don't publish while the Quick Snap photo is still uploading — otherwise the // spot would go out without the photo the user just took. if (isUploadingQuickPhoto) { @@ -682,9 +696,15 @@ export default function CreateSpotPage() { /> - {/* Images */} + {/* Images — required (see the guard in handleSubmit). */}
- +
{images.map((url) => (
@@ -749,7 +769,7 @@ export default function CreateSpotPage() {
- {/* Skateability Score */} + {/* Conditions score — WEATHER ONLY. Deliberately not called + "Skateability": this is about the sky, not whether the + place is a real, rideable spot. The community signal lives + in the badge next to the spot's type tags. */}
- Skateability + Conditions + + weather only + {weatherData.skateability.score}/100