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.
78 lines
2.8 KiB
TypeScript
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 },
|
|
};
|
|
}
|