feat(spots): make reports act, add a publish floor, stop junk looking endorsed

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.
This commit is contained in:
SkateSpots 2026-09-28 19:32:01 +07:00
parent 563ce33276
commit ee97a1c5ca
6 changed files with 126 additions and 18 deletions

View file

@ -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**

View file

@ -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<string>();
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),
};
}

View file

@ -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<string, SpotReportCounts>();
const notASpotReporters = new Map<string, Set<string>>();
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<string>();
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 },
};
}

View file

@ -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 ?? [])

View file

@ -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]);

View file

@ -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() {
))}
</div>
{/* Reported as not a skate spot — visible to everyone, not just admins. */}
{notASpotReporters > 0 && (
<div className="mt-3 flex items-start gap-2.5 rounded-xl border border-red-500/40 bg-red-500/10 p-3.5">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-red-400" />
<p className="text-xs leading-relaxed text-red-200">
<strong className="text-red-300">Reported as not a skate spot</strong>
{notASpotReporters > 1 ? ` by ${notASpotReporters} skaters` : ''} — under review. If
this is a real spot, add a photo or a status report to confirm it.
</p>
</div>
)}
{/* 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() {
</div>
</div>
{/* 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) ? (
<div className="p-3 rounded-lg" style={{ backgroundColor: `${weatherData.skateability.color}20`, border: `2px solid ${weatherData.skateability.color}` }}>
<div className="flex items-center justify-between mb-2">
<span className="font-semibold" style={{ color: weatherData.skateability.color }}>
@ -966,6 +985,12 @@ export default function SpotDetailPage() {
{weatherData.skateability.reasons.join(' • ')}
</div>
</div>
) : (
<div className="rounded-lg border border-white/10 bg-white/[0.03] p-3 text-xs text-muted-foreground">
Weather only. Nobody has confirmed this is a skate spot yet, so there&apos;s no
conditions score — add a photo or a status report to confirm it.
</div>
)}
{/* Weather Details */}
<div className="grid grid-cols-2 gap-3 text-sm">