From e549aeee3a6f6660386b7c9da5ef96fd5f464c21 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 28 Sep 2026 09:45:36 +0700 Subject: [PATCH] feat(spots): reports reach the admin, duplicates are caught, contributors are told MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Reports were a VOID: ReportDialog publishes NIP-56 (1984) but the only reader in the app was a count on marketplace cards, so an admin never saw one. Now useSpotReports batch-reads kind 1984 by '#e' (one query for every spot) and the review queue ranks reported spots ABOVE the heuristics with a red 'REPORTED: ...' chip. useNotASpotReport publishes to getWriteRelays() (community relay + the reporter's), not the default pool — the admin reads from the community relay, so reports written only to personal relays would never arrive. Verified end-to-end: published a report from Node, saw it surface in the queue. 2) Duplicate guard in CreateSpotPage: on submit, existing spots within 60 m are listed with distances + links, with 'It's a different spot — publish anyway'. Targets the clone-spam failure mode (five near-identical pins). 3) Contributor feedback: useSkateSpot no longer nulls a hidden spot for its AUTHOR (it used to silently 404 for the person who made it), and SpotDetailPage shows an author-only banner for hidden/verified. tsc/eslint clean, 101 tests, smoke PASS. --- AGENTS.md | 21 +++++++++ release-notes.txt | 6 +++ src/hooks/useNotASpotReport.ts | 23 +++++++-- src/hooks/useSkateSpots.ts | 16 +++++-- src/hooks/useSpotReports.ts | 68 +++++++++++++++++++++++++++ src/pages/AdminReviewPage.tsx | 39 +++++++++++----- src/pages/CreateSpotPage.tsx | 85 ++++++++++++++++++++++++++++++++-- src/pages/SpotDetailPage.tsx | 37 +++++++++++++++ 8 files changed, 273 insertions(+), 22 deletions(-) create mode 100644 src/hooks/useSpotReports.ts diff --git a/AGENTS.md b/AGENTS.md index cd5e1c3..9fb9c5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -417,6 +417,27 @@ session with **no other memory of this project**. So: is **admin-tooling only** — without it a hidden spot leaves the list and can never be un-hidden. The review queue keeps actioned rows (badge + **Undo**) so both verbiage and verdicts are reversible in the UI. +- **Reports reach the admin, duplicates are caught, contributors are told (2026-09-28):** + three gaps closed in one pass. + 1. **Reports were a void.** `ReportDialog` publishes NIP-56 (1984) for spots/ + events/tours/users/listings/comments, but the ONLY reader in the app was a + count on marketplace cards — an admin never saw a report. Now + **`useSpotReports`** batch-reads `kinds:[1984], '#e': [all spot ids]` in ONE + query (via `querySkateContent`), and the review queue ranks reported spots + ABOVE the heuristics (red `REPORTED: …` chip). `useNotASpotReport` now + publishes to **`getWriteRelays()`** (community relay + the reporter's) rather + than the default pool, because the admin reads from the community relay — the + same relay-parity rule as the newsletter and DMs. Verified end-to-end by + publishing a report from Node and seeing it surface in the queue. + 2. **Duplicate guard** in `CreateSpotPage`: on submit, any existing spot within + **60 m** (geohash-decoded, ≤3 candidates) shows an inline warning listing them + with distances and links, plus "It's a different spot — publish anyway" + (`dupConfirmedRef` bypass). Designed for the clone-spam failure mode. + 3. **Contributor feedback:** `useSkateSpot` no longer returns null for a hidden + spot when the viewer is its **author** — otherwise a moderated spot silently + 404s for the person who made it. `SpotDetailPage` shows an author-only banner + ("Hidden by a moderator" / "Verified by SkateSpots"). The verdicts are public + labels, so there is nothing secret being revealed. - **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 8df98c7..fd6849b 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -15,6 +15,12 @@ v1.5.4 **Confirm** a spot as well as hide it (a staff-vouched spot gets a badge). - Moderation is **reversible**: Hide and Confirm can both be undone, returning the spot to neutral without hiding or vouching for it. +- **Reports now reach a human**: the review queue surfaces reported spots at the + very top, above the automated signals — so "not a skate spot" actually lands. +- **Adding a spot warns you if one already exists** within 60 m, and links you + straight to it instead of letting you create a duplicate. +- **If a moderator hides or vouches for your spot, you're told** — right there on + the spot page (your own spots stay visible to you either way). 🛍 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/hooks/useNotASpotReport.ts b/src/hooks/useNotASpotReport.ts index 0e4e0e6..5479af7 100644 --- a/src/hooks/useNotASpotReport.ts +++ b/src/hooks/useNotASpotReport.ts @@ -1,5 +1,6 @@ import { useMutation } from '@tanstack/react-query'; -import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useNostr } from '@nostrify/react'; +import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useToast } from '@/hooks/useToast'; import { MODERATION_LABEL_NAMESPACE } from '@/lib/types'; @@ -18,7 +19,8 @@ import { MODERATION_LABEL_NAMESPACE } from '@/lib/types'; */ export function useNotASpotReport() { const { user } = useCurrentUser(); - const { mutateAsync: publish, isPending: publishing } = useNostrPublish(); + const { nostr } = useNostr(); + const { getWriteRelays } = useSkateRelays(); const { toast } = useToast(); const mutation = useMutation({ @@ -35,9 +37,10 @@ export function useNotASpotReport() { }) => { if (!user) throw new Error('Log in to report a spot.'); - await publish({ + const event = await user.signer.signEvent({ kind: 1984, // NIP-56 report content: title ? `Not a skate spot: ${title}` : 'Not a skate spot', + created_at: Math.floor(Date.now() / 1000), tags: [ ['e', eventId, 'other'], ['p', pubkey], @@ -47,6 +50,18 @@ export function useNotASpotReport() { ['alt', 'SkateSpots: report — not a skate spot'], ], }); + + // Publish to the COMMUNITY relay as well as the reporter's own relays. + // Admins read reports from the community relay, so a report written only + // to the reporter's personal relays would never reach them. + const results = await Promise.allSettled( + getWriteRelays().map((url) => + nostr.relay(url).event(event, { signal: AbortSignal.timeout(8000) }), + ), + ); + if (results.every((r) => r.status === 'rejected')) { + throw new Error('Could not reach any relay — check your connection and try again.'); + } }, onSuccess: () => { toast({ @@ -66,7 +81,7 @@ export function useNotASpotReport() { return { reportNotASpot: mutation.mutateAsync, - isPending: mutation.isPending || publishing, + isPending: mutation.isPending, alreadyReported: mutation.isSuccess, }; } diff --git a/src/hooks/useSkateSpots.ts b/src/hooks/useSkateSpots.ts index 3d45880..e52cf9a 100644 --- a/src/hooks/useSkateSpots.ts +++ b/src/hooks/useSkateSpots.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { SKATE_SPOT_KIND, parseSkateSpot, type SkateSpot } from '@/lib/types'; import { useSkateRelays } from '@/hooks/useSkateRelays'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration'; import { cacheSpots, getAllCachedSpots, isOffline } from '@/lib/offlineCache'; @@ -73,13 +74,20 @@ export function useSkateSpots(limit = 100, options: { includeHidden?: boolean } export function useSkateSpot(identifier: string, pubkey?: string) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const hidden = useAdminModeration(); + const { user } = useCurrentUser(); return useQuery({ queryKey: ['skate-spot', identifier, pubkey, getQueryKeySuffix()], - select: (spot: SkateSpot | null) => - spot && hidden.has({ coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`, id: spot.id }) - ? null - : spot, + select: (spot: SkateSpot | null) => { + if (!spot) return null; + if (!hidden.has({ coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`, id: spot.id })) { + return spot; + } + // A hidden spot stays reachable for its OWN AUTHOR — otherwise their spot + // silently 404s and they never learn why. Everyone else gets null (the map + // and lists filter it too). The detail page shows them what happened. + return user?.pubkey === spot.pubkey ? spot : null; + }, queryFn: async () => { // If offline, try to get from cache first if (isOffline() && pubkey) { diff --git a/src/hooks/useSpotReports.ts b/src/hooks/useSpotReports.ts new file mode 100644 index 0000000..960042c --- /dev/null +++ b/src/hooks/useSpotReports.ts @@ -0,0 +1,68 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useSkateRelays } from '@/hooks/useSkateRelays'; + +/** NIP-56 report kind. */ +export const REPORT_KIND = 1984; + +export interface SpotReportCounts { + /** Every NIP-56 report referencing the spot. */ + total: number; + /** Reports specifically tagged `not-a-skate-spot` (see useNotASpotReport). */ + notASpot: number; +} + +/** + * Batch-read NIP-56 reports for many spots in ONE query. + * + * Reports carry the target's event id in an `e` tag, so asking for + * `#e: [all spot ids]` is a single relay request — the same trick as the status + * and photo batch hooks. + * + * WHY THIS EXISTS: people could already report a spot, but nothing ever brought + * those reports to the admin — the only reader in the whole app was a count on + * marketplace cards. A report button whose output nobody sees is decoration, so + * the review queue now ranks reported spots to the top. + */ +export function useSpotReports(spots: Array<{ id: string }>) { + const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + + const ids = useMemo(() => spots.map((s) => s.id).filter(Boolean), [spots]); + + const query = useQuery({ + queryKey: ['spot-reports', ids.slice().sort().join(','), getQueryKeySuffix()], + enabled: ids.length > 0, + staleTime: 60_000, + queryFn: async (c) => { + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); + return querySkateContent( + [{ kinds: [REPORT_KIND], '#e': ids, limit: 1000 }], + { signal }, + ); + }, + }); + + const counts = useMemo(() => { + const map = new Map(); + for (const ev of query.data ?? []) { + // A report can reference several targets; count each. + for (const [n, value] of ev.tags) { + if (n !== 'e' || !value) continue; + const entry = map.get(value) ?? { total: 0, notASpot: 0 }; + entry.total += 1; + // Our own taxonomy marker, or the NIP-56 'other' reason from the dialog. + const isNotASpot = + ev.tags.some(([t, v]) => t === 't' && v === 'not-a-skate-spot') || + ev.tags.some(([t, v]) => t === 'l' && v === 'not-a-skate-spot'); + if (isNotASpot) entry.notASpot += 1; + map.set(value, entry); + } + } + return map; + }, [query.data]); + + return { + isLoading: query.isLoading, + getCounts: (id: string): SpotReportCounts => counts.get(id) ?? { total: 0, notASpot: 0 }, + }; +} diff --git a/src/pages/AdminReviewPage.tsx b/src/pages/AdminReviewPage.tsx index 8873e96..093af57 100644 --- a/src/pages/AdminReviewPage.tsx +++ b/src/pages/AdminReviewPage.tsx @@ -22,6 +22,7 @@ import { useSkateSpots } from '@/hooks/useSkateSpots'; import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { useBatchSpotStatus } from '@/hooks/useSpotStatus'; import { useBatchSpotPhotos } from '@/hooks/useSpotContributions'; +import { useSpotReports } from '@/hooks/useSpotReports'; import { useAdminHide, useAdminVerify, useAdminClear } from '@/hooks/useAdminHide'; import { useAdminModeration } from '@/hooks/useAdminModeration'; import { useNotASpotReport } from '@/hooks/useNotASpotReport'; @@ -69,6 +70,8 @@ export default function AdminReviewPage() { ); const { getStatus } = useBatchSpotStatus(statusInput); const { getCommunityPhotoCount } = useBatchSpotPhotos(statusInput); + // What people actually reported — the strongest signal we have. + const { getCounts: getReportCounts } = useSpotReports(spots ?? []); const rows = useMemo( () => @@ -80,6 +83,15 @@ export default function AdminReviewPage() { const verified = moderation.isVerified({ coord, id: spot.id }); const photos = getCommunityPhotoCount(spot.pubkey, spot.identifier); const reporters = status?.reporterCount ?? 0; + const reports = getReportCounts(spot.id); + const flags = spotFlags(spot, { + mapperTrust: trustScores?.get(spot.pubkey), + communityStatusReports: reporters, + communityPhotos: photos, + }); + // Human reports outrank heuristics: someone looked and objected. + if (reports.notASpot > 0) flags.unshift('reported: not a skate spot'); + else if (reports.total > 0) flags.unshift(`reported ×${reports.total}`); return { spot, coord, @@ -90,19 +102,20 @@ export default function AdminReviewPage() { trust: trustScores?.get(spot.pubkey), reporters, photos, - flags: spotFlags(spot, { - mapperTrust: trustScores?.get(spot.pubkey), - communityStatusReports: reporters, - communityPhotos: photos, - }), + reports, + flags, }; }) .filter((row) => row.actioned || row.flags.length > 0) - // Un-actioned rows first (most flags first); actioned rows sink below. - .sort((a, b) => - a.actioned === b.actioned ? b.flags.length - a.flags.length : a.actioned ? 1 : -1, - ), - [spots, moderation, trustScores, getStatus, getCommunityPhotoCount], + // Un-actioned first; within that, human reports before heuristics; + // actioned rows sink to the bottom (they are done, but undoable). + .sort((a, b) => { + if (a.actioned !== b.actioned) return a.actioned ? 1 : -1; + if (a.reports.notASpot !== b.reports.notASpot) return b.reports.notASpot - a.reports.notASpot; + if (a.reports.total !== b.reports.total) return b.reports.total - a.reports.total; + return b.flags.length - a.flags.length; + }), + [spots, moderation, trustScores, getStatus, getCommunityPhotoCount, getReportCounts], ); useSeoMeta({ title: 'Review queue — SkateSpots' }); @@ -230,7 +243,11 @@ export default function AdminReviewPage() { {!hidden && !verified && flags.map((f) => ( {f} diff --git a/src/pages/CreateSpotPage.tsx b/src/pages/CreateSpotPage.tsx index b438ba9..d2f34ca 100644 --- a/src/pages/CreateSpotPage.tsx +++ b/src/pages/CreateSpotPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import ngeohash from 'ngeohash'; @@ -10,6 +10,9 @@ import { Label } from '@/components/ui/label'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useUploadFile } from '@/hooks/useUploadFile'; +import { useSkateSpots } from '@/hooks/useSkateSpots'; +import { calculateDistance } from '@/lib/sortOptions'; +import type { SkateSpot } from '@/lib/types'; import { useToast } from '@/hooks/useToast'; import { useTranslation } from '@/contexts/language'; import { LoginArea } from '@/components/auth/LoginArea'; @@ -59,6 +62,12 @@ export default function CreateSpotPage() { const [shelter, setShelter] = useState(DEFAULT_SHELTER); const [contactInfo, setContactInfo] = useState(''); const [images, setImages] = useState([]); + // Duplicate guard: warn when a spot already exists right where you're pinning + // one. This is the cheapest fix for clone spam (five near-identical pins for + // the same spot) — and it points the contributor at the existing pin instead. + const { data: existingSpots } = useSkateSpots(500); + const [dupWarning, setDupWarning] = useState | null>(null); + const dupConfirmedRef = useRef(false); const [isGettingLocation, setIsGettingLocation] = useState(false); const [showCamera, setShowCamera] = useState(false); // True while the Quick Snap photo is uploading in the background after landing @@ -209,8 +218,8 @@ export default function CreateSpotPage() { setImages((prev) => prev.filter((img) => img !== url)); }; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const handleSubmit = async (e?: React.FormEvent) => { + e?.preventDefault(); if (!title.trim()) { toast({ @@ -275,6 +284,29 @@ export default function CreateSpotPage() { return; } + // Look for an existing spot within 60 m and ask before creating a clone. + if (!dupConfirmedRef.current) { + const nearby = (existingSpots ?? []) + .map((s) => { + if (!s.geohash) return null; + const { latitude, longitude } = ngeohash.decode(s.geohash); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; + return { spot: s, km: calculateDistance(lat, lng, latitude, longitude) }; + }) + .filter((x): x is { spot: SkateSpot; km: number } => !!x && x.km < 0.06) + .sort((a, b) => a.km - b.km) + .slice(0, 3); + + if (nearby.length > 0) { + setDupWarning(nearby); + toast({ + title: 'That spot may already be on the map', + description: 'Check the pin below before adding a duplicate.', + }); + return; + } + } + try { // Generate geohash from coordinates (7 chars = ~153m precision) const geohash = ngeohash.encode(lat, lng, 7); @@ -765,6 +797,53 @@ export default function CreateSpotPage() { hasSpotType={!!selectedCategory} /> + {dupWarning && ( +
+

+ This spot may already exist +

+

+ {dupWarning.length === 1 ? 'There is a spot' : 'There are spots'} within 60 m of + your pin. If it's the same place, add your photo or a status report there + instead of creating a duplicate. +

+
    + {dupWarning.map(({ spot, km }) => ( +
  • + + {spot.title || 'Untitled spot'} · {Math.round(km * 1000)} m away + +
  • + ))} +
+
+ + +
+
+ )} + {/* Submit */}