From ee97a1c5ca62641d49d2825d98c5416a2521f153 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 28 Sep 2026 19:32:01 +0700 Subject: [PATCH] feat(spots): make reports act, add a publish floor, stop junk looking endorsed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'test of secret island airport' case: a no-photo, one-line, reported pin sat on the map looking legit. Four fixes: - A) Report has a visible effect: a 'Reported as not a skate spot by N — under review' banner on the spot page, for everyone. - B) Publish floor: a spot needs a photo OR a non-empty description. Photo stays optional (per the earlier decision); title + type alone no longer publishes. - C) Auto-hide from the map when >=3 DISTINCT accounts report it AND it has no community signals (no community photo, no status report). The 'no signals' clause is the anti-brigade guard; reversible. useSpotReports now exposes distinct . - D) The flattering 'Conditions ... NN/100' block is hidden until the spot is confirmed (community or staff) — on an unconfirmed pin it read as the app endorsing the spot itself. --- AGENTS.md | 13 +++++++++++ src/hooks/useAutoHiddenSpots.ts | 41 +++++++++++++++++++++++++++++++++ src/hooks/useSpotReports.ts | 24 +++++++++++++------ src/pages/CreateSpotPage.tsx | 18 +++++++++++---- src/pages/MapPage.tsx | 15 ++++++++++-- src/pages/SpotDetailPage.tsx | 33 ++++++++++++++++++++++---- 6 files changed, 126 insertions(+), 18 deletions(-) create mode 100644 src/hooks/useAutoHiddenSpots.ts diff --git a/AGENTS.md b/AGENTS.md index 31e486c..6a4bc93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -493,6 +493,19 @@ session with **no other memory of this project**. So: (`useBatchTrustScores`) are kind-agnostic; status/photo signals are spot-only. Status/go-lab/crew queues are still out — profiles are less spam-prone and hiding a crew hides a community. +- **Report → action + a publish floor (2026-09-28):** the one-tap "not a skate + spot" report used to only land in the admin queue (invisible effect). It now + **shows on the spot page to everyone** ("Reported as not a skate spot by N — + under review"), and **`useAutoHiddenSpots`** hides a spot from the map when + **≥3 DISTINCT** accounts reported it **and** it has **no community signals** + (no community photo, no status report). The "no signals" clause is the + anti-brigade guard; it is reversible (a photo/status, or an admin + `cleared`/`verified` verdict, brings it back). `useSpotReports` now exposes + `reporters` (distinct accounts). **Publish floor:** a spot now needs a **photo + OR a non-empty description** (title + type alone no longer publishes) — photo + stays optional, per the earlier decision. And the flattering **"Conditions … + NN/100"** block is **hidden until the spot is confirmed** (community or staff); + on an unconfirmed pin it read as endorsement of the *spot*. - **Photo is recommended, not required (2026-09-28):** the hard block in `CreateSpotPage` was removed — it punished an honest mapper with no photo on hand. Publishing with no photo is allowed; the spot stays **Unverified** diff --git a/src/hooks/useAutoHiddenSpots.ts b/src/hooks/useAutoHiddenSpots.ts new file mode 100644 index 0000000..562710a --- /dev/null +++ b/src/hooks/useAutoHiddenSpots.ts @@ -0,0 +1,41 @@ +import { useMemo } from 'react'; +import { useSpotReports } from '@/hooks/useSpotReports'; +import { useBatchSpotPhotos } from '@/hooks/useSpotContributions'; +import { useBatchSpotStatus } from '@/hooks/useSpotStatus'; + +/** + * A spot is auto-hidden pending human review when this many DISTINCT accounts + * have reported it "not a skate spot" AND it has NO community signals at all + * (no community photo, no status report). + * + * The "no community signals" clause is the anti-brigade guard: any real spot + * that a skater has engaged with is left to a human instead. It is fully + * reversible — a community photo/status (or an admin `cleared`/`verified` + * verdict) brings it straight back. + */ +export const AUTO_HIDE_REPORT_THRESHOLD = 3; + +export function useAutoHiddenSpots( + spots: Array<{ id: string; pubkey: string; identifier: string }>, +) { + const reports = useSpotReports(spots); + const photos = useBatchSpotPhotos(spots); + const status = useBatchSpotStatus(spots); + + const autoHiddenIds = useMemo(() => { + const set = new Set(); + for (const s of spots) { + if (reports.getCounts(s.id).reporters < AUTO_HIDE_REPORT_THRESHOLD) continue; + const hasPhoto = photos.getCommunityPhotoCount(s.pubkey, s.identifier) > 0; + const hasStatus = (status.getStatus(s.pubkey, s.identifier)?.reporterCount ?? 0) > 0; + if (hasPhoto || hasStatus) continue; + set.add(s.id); + } + return set; + }, [spots, reports, photos, status]); + + return { + autoHiddenIds, + isAutoHidden: (id: string) => autoHiddenIds.has(id), + }; +} diff --git a/src/hooks/useSpotReports.ts b/src/hooks/useSpotReports.ts index 960042c..1c31c93 100644 --- a/src/hooks/useSpotReports.ts +++ b/src/hooks/useSpotReports.ts @@ -10,6 +10,8 @@ export interface SpotReportCounts { total: number; /** Reports specifically tagged `not-a-skate-spot` (see useNotASpotReport). */ notASpot: number; + /** DISTINCT accounts that filed a `not-a-skate-spot` report (anti-brigade). */ + reporters: number; } /** @@ -44,17 +46,24 @@ export function useSpotReports(spots: Array<{ id: string }>) { const counts = useMemo(() => { const map = new Map(); + const notASpotReporters = new Map>(); for (const ev of query.data ?? []) { + // 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'); // 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 }; + const entry = map.get(value) ?? { total: 0, notASpot: 0, reporters: 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; + if (isNotASpot) { + entry.notASpot += 1; + const set = notASpotReporters.get(value) ?? new Set(); + set.add(ev.pubkey); + notASpotReporters.set(value, set); + entry.reporters = set.size; + } map.set(value, entry); } } @@ -63,6 +72,7 @@ export function useSpotReports(spots: Array<{ id: string }>) { return { isLoading: query.isLoading, - getCounts: (id: string): SpotReportCounts => counts.get(id) ?? { total: 0, notASpot: 0 }, + getCounts: (id: string): SpotReportCounts => + counts.get(id) ?? { total: 0, notASpot: 0, reporters: 0 }, }; } diff --git a/src/pages/CreateSpotPage.tsx b/src/pages/CreateSpotPage.tsx index 8f1c03c..357d00e 100644 --- a/src/pages/CreateSpotPage.tsx +++ b/src/pages/CreateSpotPage.tsx @@ -260,11 +260,6 @@ export default function CreateSpotPage() { return; } - // A photo is strongly recommended but NOT required: blocking an honest - // mapper who has no picture on hand is worse than accepting a photo-less - // pin. Spots without one simply stay "Unverified" (see the spot page badge) - // and the review queue flags them, so a human can look later. - // 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) { @@ -275,6 +270,19 @@ export default function CreateSpotPage() { return; } + // A photo is still NOT required — but a spot needs *something* beyond a + // title + type. Require a photo OR a description so the empty husk (a "test" + // pin, an airport with one word) can't publish, without blocking an honest + // mapper who has no picture on hand but can describe the place. + if (images.length === 0 && !description.trim()) { + toast({ + title: 'Add a photo or a description', + description: 'Give skaters something to go on — a picture, or a line about the place.', + variant: 'destructive', + }); + return; + } + // Look for an existing spot within 60 m and ask before creating a clone. if (!dupConfirmedRef.current) { const nearby = (existingSpots ?? []) diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index ea0f533..f348507 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -25,6 +25,7 @@ import { LoginArea } from '@/components/auth/LoginArea'; import { useSkateSpots } from '@/hooks/useSkateSpots'; import { useRelayStatus } from '@/hooks/useRelayStatus'; +import { useAutoHiddenSpots } from '@/hooks/useAutoHiddenSpots'; import { useOnlineStatus } from '@/hooks/useOfflineCache'; import { useShops } from '@/hooks/useShops'; import { useMarketplaceItems } from '@/hooks/useMarketplace'; @@ -547,6 +548,14 @@ export default function MapPage() { const relay = useRelayStatus(SKATE_RELAY); const relayUnreachable = !online || relay.status === 'offline'; + // Spots reported "not a skate spot" by ≥3 distinct accounts with NO community + // signals are hidden from the map pending a human (see useAutoHiddenSpots). + const autoHideInput = useMemo( + () => (spots ?? []).map((s) => ({ id: s.id, pubkey: s.pubkey, identifier: s.identifier })), + [spots], + ); + const { autoHiddenIds } = useAutoHiddenSpots(autoHideInput); + // Tell the cold-start splash that real content has arrived, so it can reveal // the map already populated (rather than dismissing on bare connection). We // signal readiness when the spots query has genuinely resolved — either it @@ -822,7 +831,9 @@ export default function MapPage() { // Combine spots and shops into unified list for map display const allLocations = useMemo(() => { - const spotList = (spots || []).map(spot => ({ ...spot, isShop: false })); + const spotList = (spots || []) + .filter((spot) => !autoHiddenIds.has(spot.id)) + .map(spot => ({ ...spot, isShop: false })); const shopList = (shops || []).map(shop => ({ ...shop, title: shop.name, @@ -835,7 +846,7 @@ export default function MapPage() { contactInfo: undefined, })); return [...spotList, ...shopList]; - }, [spots, shops]); + }, [spots, shops, autoHiddenIds]); // Fetch trust scores for all creators const locationPubkeys = useMemo(() => allLocations.map((l) => l.pubkey), [allLocations]); diff --git a/src/pages/SpotDetailPage.tsx b/src/pages/SpotDetailPage.tsx index e8839f9..4f4239f 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 { useSpotReports } from '@/hooks/useSpotReports'; import { useAdminModeration } from '@/hooks/useAdminModeration'; import { isAdminPubkey } from '@/lib/admins'; import { cn } from '@/lib/utils'; @@ -176,6 +177,11 @@ export default function SpotDetailPage() { (communityPhotos?.length ?? 0) + (spotStatus?.reporterCount ?? 0); const communityVerified = communitySignalCount > 0; + // "Not a skate spot" reports — shown to EVERYONE, so the one-tap report has a + // visible effect instead of only creating an entry in the admin queue. + const { getCounts: getSpotReportCounts } = useSpotReports(spot ? [{ id: spot.id }] : []); + const notASpotReporters = spot ? getSpotReportCounts(spot.id).reporters : 0; + // Staff verdict (NIP-32 `verified` label) — separate from the community // signal: a human actually looked at this one. const moderation = useAdminModeration(); @@ -552,6 +558,18 @@ export default function SpotDetailPage() { ))} + {/* Reported as not a skate spot — visible to everyone, not just admins. */} + {notASpotReporters > 0 && ( +
+ +

+ Reported as not a skate spot + {notASpotReporters > 1 ? ` by ${notASpotReporters} skaters` : ''} — under review. If + this is a real spot, add a photo or a status report to confirm it. +

+
+ )} + {/* Author-facing moderation notice. The verdicts are public labels, so there is nothing secret to hide — and without this a contributor whose spot was hidden just sees it vanish. */} @@ -943,10 +961,11 @@ export default function SpotDetailPage() { - {/* 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. */} + {/* Conditions score — WEATHER ONLY, and only once the place is + confirmed (community or staff). On an unconfirmed pin a big + green "PERFECT 95/100" reads as endorsement of the *spot*, + which is exactly what we don't want. */} + {(communityVerified || staffVerified) ? (
@@ -966,6 +985,12 @@ export default function SpotDetailPage() { {weatherData.skateability.reasons.join(' • ')}
+ ) : ( +
+ Weather only. Nobody has confirmed this is a skate spot yet, so there's no + conditions score — add a photo or a status report to confirm it. +
+ )} {/* Weather Details */}