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.
|
(`useBatchTrustScores`) are kind-agnostic; status/photo signals are spot-only.
|
||||||
Status/go-lab/crew queues are still out — profiles are less spam-prone and
|
Status/go-lab/crew queues are still out — profiles are less spam-prone and
|
||||||
hiding a crew hides a community.
|
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
|
- **Photo is recommended, not required (2026-09-28):** the hard block in
|
||||||
`CreateSpotPage` was removed — it punished an honest mapper with no photo on
|
`CreateSpotPage` was removed — it punished an honest mapper with no photo on
|
||||||
hand. Publishing with no photo is allowed; the spot stays **Unverified**
|
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;
|
total: number;
|
||||||
/** Reports specifically tagged `not-a-skate-spot` (see useNotASpotReport). */
|
/** Reports specifically tagged `not-a-skate-spot` (see useNotASpotReport). */
|
||||||
notASpot: number;
|
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 counts = useMemo(() => {
|
||||||
const map = new Map<string, SpotReportCounts>();
|
const map = new Map<string, SpotReportCounts>();
|
||||||
|
const notASpotReporters = new Map<string, Set<string>>();
|
||||||
for (const ev of query.data ?? []) {
|
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.
|
// A report can reference several targets; count each.
|
||||||
for (const [n, value] of ev.tags) {
|
for (const [n, value] of ev.tags) {
|
||||||
if (n !== 'e' || !value) continue;
|
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;
|
entry.total += 1;
|
||||||
// Our own taxonomy marker, or the NIP-56 'other' reason from the dialog.
|
if (isNotASpot) {
|
||||||
const isNotASpot =
|
entry.notASpot += 1;
|
||||||
ev.tags.some(([t, v]) => t === 't' && v === 'not-a-skate-spot') ||
|
const set = notASpotReporters.get(value) ?? new Set<string>();
|
||||||
ev.tags.some(([t, v]) => t === 'l' && v === 'not-a-skate-spot');
|
set.add(ev.pubkey);
|
||||||
if (isNotASpot) entry.notASpot += 1;
|
notASpotReporters.set(value, set);
|
||||||
|
entry.reporters = set.size;
|
||||||
|
}
|
||||||
map.set(value, entry);
|
map.set(value, entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -63,6 +72,7 @@ export function useSpotReports(spots: Array<{ id: string }>) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoading: query.isLoading,
|
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;
|
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
|
// Don't publish while the Quick Snap photo is still uploading — otherwise the
|
||||||
// spot would go out without the photo the user just took.
|
// spot would go out without the photo the user just took.
|
||||||
if (isUploadingQuickPhoto) {
|
if (isUploadingQuickPhoto) {
|
||||||
|
|
@ -275,6 +270,19 @@ export default function CreateSpotPage() {
|
||||||
return;
|
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.
|
// Look for an existing spot within 60 m and ask before creating a clone.
|
||||||
if (!dupConfirmedRef.current) {
|
if (!dupConfirmedRef.current) {
|
||||||
const nearby = (existingSpots ?? [])
|
const nearby = (existingSpots ?? [])
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
|
|
||||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
import { useRelayStatus } from '@/hooks/useRelayStatus';
|
import { useRelayStatus } from '@/hooks/useRelayStatus';
|
||||||
|
import { useAutoHiddenSpots } from '@/hooks/useAutoHiddenSpots';
|
||||||
import { useOnlineStatus } from '@/hooks/useOfflineCache';
|
import { useOnlineStatus } from '@/hooks/useOfflineCache';
|
||||||
import { useShops } from '@/hooks/useShops';
|
import { useShops } from '@/hooks/useShops';
|
||||||
import { useMarketplaceItems } from '@/hooks/useMarketplace';
|
import { useMarketplaceItems } from '@/hooks/useMarketplace';
|
||||||
|
|
@ -547,6 +548,14 @@ export default function MapPage() {
|
||||||
const relay = useRelayStatus(SKATE_RELAY);
|
const relay = useRelayStatus(SKATE_RELAY);
|
||||||
const relayUnreachable = !online || relay.status === 'offline';
|
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
|
// 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
|
// the map already populated (rather than dismissing on bare connection). We
|
||||||
// signal readiness when the spots query has genuinely resolved — either it
|
// 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
|
// Combine spots and shops into unified list for map display
|
||||||
const allLocations = useMemo(() => {
|
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 => ({
|
const shopList = (shops || []).map(shop => ({
|
||||||
...shop,
|
...shop,
|
||||||
title: shop.name,
|
title: shop.name,
|
||||||
|
|
@ -835,7 +846,7 @@ export default function MapPage() {
|
||||||
contactInfo: undefined,
|
contactInfo: undefined,
|
||||||
}));
|
}));
|
||||||
return [...spotList, ...shopList];
|
return [...spotList, ...shopList];
|
||||||
}, [spots, shops]);
|
}, [spots, shops, autoHiddenIds]);
|
||||||
|
|
||||||
// Fetch trust scores for all creators
|
// Fetch trust scores for all creators
|
||||||
const locationPubkeys = useMemo(() => allLocations.map((l) => l.pubkey), [allLocations]);
|
const locationPubkeys = useMemo(() => allLocations.map((l) => l.pubkey), [allLocations]);
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { useSkateSpot } from '@/hooks/useSkateSpots';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
|
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
|
||||||
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
|
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
|
||||||
|
import { useSpotReports } from '@/hooks/useSpotReports';
|
||||||
import { useAdminModeration } from '@/hooks/useAdminModeration';
|
import { useAdminModeration } from '@/hooks/useAdminModeration';
|
||||||
import { isAdminPubkey } from '@/lib/admins';
|
import { isAdminPubkey } from '@/lib/admins';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -176,6 +177,11 @@ export default function SpotDetailPage() {
|
||||||
(communityPhotos?.length ?? 0) + (spotStatus?.reporterCount ?? 0);
|
(communityPhotos?.length ?? 0) + (spotStatus?.reporterCount ?? 0);
|
||||||
const communityVerified = communitySignalCount > 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
|
// Staff verdict (NIP-32 `verified` label) — separate from the community
|
||||||
// signal: a human actually looked at this one.
|
// signal: a human actually looked at this one.
|
||||||
const moderation = useAdminModeration();
|
const moderation = useAdminModeration();
|
||||||
|
|
@ -552,6 +558,18 @@ export default function SpotDetailPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</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
|
{/* Author-facing moderation notice. The verdicts are public
|
||||||
labels, so there is nothing secret to hide — and without this
|
labels, so there is nothing secret to hide — and without this
|
||||||
a contributor whose spot was hidden just sees it vanish. */}
|
a contributor whose spot was hidden just sees it vanish. */}
|
||||||
|
|
@ -943,10 +961,11 @@ export default function SpotDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conditions score — WEATHER ONLY. Deliberately not called
|
{/* Conditions score — WEATHER ONLY, and only once the place is
|
||||||
"Skateability": this is about the sky, not whether the
|
confirmed (community or staff). On an unconfirmed pin a big
|
||||||
place is a real, rideable spot. The community signal lives
|
green "PERFECT 95/100" reads as endorsement of the *spot*,
|
||||||
in the badge next to the spot's type tags. */}
|
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="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">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="font-semibold" style={{ color: weatherData.skateability.color }}>
|
<span className="font-semibold" style={{ color: weatherData.skateability.color }}>
|
||||||
|
|
@ -966,6 +985,12 @@ export default function SpotDetailPage() {
|
||||||
{weatherData.skateability.reasons.join(' • ')}
|
{weatherData.skateability.reasons.join(' • ')}
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Weather Details */}
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue