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:
parent
563ce33276
commit
ee97a1c5ca
6 changed files with 126 additions and 18 deletions
13
AGENTS.md
13
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**
|
||||
|
|
|
|||
41
src/hooks/useAutoHiddenSpots.ts
Normal file
41
src/hooks/useAutoHiddenSpots.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ?? [])
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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'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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue