SkateSpots/src/hooks/useSpotReports.ts
SkateSpots ee97a1c5ca 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.
2026-09-28 19:32:01 +07:00

78 lines
2.8 KiB
TypeScript

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;
/** DISTINCT accounts that filed a `not-a-skate-spot` report (anti-brigade). */
reporters: 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<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, reporters: 0 };
entry.total += 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);
}
}
return map;
}, [query.data]);
return {
isLoading: query.isLoading,
getCounts: (id: string): SpotReportCounts =>
counts.get(id) ?? { total: 0, notASpot: 0, reporters: 0 },
};
}