feat(moderation): reversible verdicts (undo) + community photos as a signal
Undo (latest verdict wins):
- MODERATION_LABEL_CLEARED = 'cleared' — the neutral undo verdict.
- useAdminModeration no longer UNIONS labels (which made a hide permanent in the
UI); it keeps the NEWEST verdict per target (created_at, with device-local
verdicts overlaid by timestamp), so 'cleared' supersedes an earlier
'hidden'/'verified'. useAdminClear() publishes it.
- Local mirror is now a verdict map (skatespots_local_admin_verdicts_v1: key ->
{verdict, at}) with migration from the legacy hidden/confirmed arrays.
- useSkateSpots(limit, { includeHidden: true }) — ADMIN TOOLING ONLY: without it
a hidden spot leaves the list, so a verdict could never be undone. The review
queue uses it, keeps actioned rows (Hidden/Confirmed badge + Undo), and sorts
un-actioned first.
Community photos as a signal:
- useBatchSpotPhotos: ONE #a query for every spot (the single-spot hook already
queried by #a, so batching is free), counting only photos from people OTHER
than the mapper. Folded into the 'no community signals' flag.
Verified by seeding local verdicts: none -> flags + Confirm/Hide; hidden ->
Undo; verified -> Undo; cleared -> back to neutral. tsc/eslint clean, 101 tests,
smoke PASS.
This commit is contained in:
parent
0419c18810
commit
e44f8b81a7
9 changed files with 358 additions and 117 deletions
15
AGENTS.md
15
AGENTS.md
|
|
@ -403,7 +403,20 @@ session with **no other memory of this project**. So:
|
||||||
The flag logic is shared in **`src/lib/spotQuality.ts`** (used by both the page
|
The flag logic is shared in **`src/lib/spotQuality.ts`** (used by both the page
|
||||||
and `AdminReviewCard`, so they can't drift) and is deliberately heuristic —
|
and `AdminReviewCard`, so they can't drift) and is deliberately heuristic —
|
||||||
'no community signals' only fires alongside another flag, so a brand-new real
|
'no community signals' only fires alongside another flag, so a brand-new real
|
||||||
spot isn't punished for being new.
|
spot isn't punished for being new. Community signals now include **other
|
||||||
|
skaters' photos**: `useBatchSpotPhotos` (`useSpotContributions.ts`) does ONE
|
||||||
|
`#a` query for every spot and ignores the mapper's own photos.
|
||||||
|
- **Verdicts are reversible — latest wins (2026-09-28):** `MODERATION_LABEL_CLEARED`
|
||||||
|
('cleared') is the neutral undo verdict. `useAdminModeration` no longer unions
|
||||||
|
labels; it keeps the **newest verdict per target** (by `created_at`, with
|
||||||
|
device-local verdicts overlaid by timestamp), so publishing `cleared`
|
||||||
|
supersedes an earlier `hidden`/`verified`. `useAdminClear()` publishes it.
|
||||||
|
The local mirror is now a **verdict map** (`skatespots_local_admin_verdicts_v1`,
|
||||||
|
key = coord or `e:<id>`, value `{verdict, at}`), migrating the legacy
|
||||||
|
hidden/confirmed arrays on read. `useSkateSpots(limit, { includeHidden: true })`
|
||||||
|
is **admin-tooling only** — without it a hidden spot leaves the list and can
|
||||||
|
never be un-hidden. The review queue keeps actioned rows (badge + **Undo**)
|
||||||
|
so both verbiage and verdicts are reversible in the UI.
|
||||||
- **Mints directory**: `/mints` page lists Cashu mints from the community
|
- **Mints directory**: `/mints` page lists Cashu mints from the community
|
||||||
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
||||||
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
|
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,11 @@ v1.5.4
|
||||||
verdict on whether a place is a real spot.
|
verdict on whether a place is a real spot.
|
||||||
- One-tap "Not a skate spot" for off-topic pins (test entries, airports, malls,
|
- One-tap "Not a skate spot" for off-topic pins (test entries, airports, malls,
|
||||||
private land), plus an admin review queue that surfaces them for a human.
|
private land), plus an admin review queue that surfaces them for a human.
|
||||||
- The queue also weighs mapper trust and whether any skater has actually
|
- The queue also weighs mapper trust, whether any skater has reported on the
|
||||||
reported on the spot, and a review can now **Confirm** a spot as well as hide
|
spot, and whether anyone has added a community photo — and a review can now
|
||||||
it — a staff-vouched spot gets a badge and drops off the queue.
|
**Confirm** a spot as well as hide it (a staff-vouched spot gets a badge).
|
||||||
|
- Moderation is **reversible**: Hide and Confirm can both be undone, returning the
|
||||||
|
spot to neutral without hiding or vouching for it.
|
||||||
|
|
||||||
🛍 App Shop — a new section (empty on purpose, with free printables)
|
🛍 App Shop — a new section (empty on purpose, with free printables)
|
||||||
- Free to print, no sats needed: a 55 × 86 mm "DO YOU EVEN SKATE BRO?" sticker
|
- Free to print, no sats needed: a 55 × 86 mm "DO YOU EVEN SKATE BRO?" sticker
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { isAdminPubkey } from '@/lib/admins';
|
import { isAdminPubkey } from '@/lib/admins';
|
||||||
import { addLocalHidden, addLocalConfirmed } from '@/lib/localModeration';
|
import { setLocalVerdict } from '@/lib/localModeration';
|
||||||
import {
|
import {
|
||||||
MODERATION_LABEL_NAMESPACE,
|
MODERATION_LABEL_NAMESPACE,
|
||||||
MODERATION_LABEL_HIDDEN,
|
MODERATION_LABEL_HIDDEN,
|
||||||
MODERATION_LABEL_VERIFIED,
|
MODERATION_LABEL_VERIFIED,
|
||||||
|
MODERATION_LABEL_CLEARED,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -18,7 +19,8 @@ import {
|
||||||
*
|
*
|
||||||
* Two symmetric verdicts share one code path:
|
* Two symmetric verdicts share one code path:
|
||||||
* • `hidden` — take it out of the app (soft, reversible);
|
* • `hidden` — take it out of the app (soft, reversible);
|
||||||
* • `verified` — vouch for it, which also clears it from the review queue.
|
* • `verified` — vouch for it, which also clears it from the review queue;
|
||||||
|
* • `cleared` — UNDO: retract a verdict, back to neutral (newest wins).
|
||||||
*
|
*
|
||||||
* The label is public and authored by an admin key, so every SkateSpots client
|
* The label is public and authored by an admin key, so every SkateSpots client
|
||||||
* filters/boosts accordingly. Nothing is ever deleted from relays — that is the
|
* filters/boosts accordingly. Nothing is ever deleted from relays — that is the
|
||||||
|
|
@ -58,9 +60,14 @@ function usePublishModerationLabel(label: string) {
|
||||||
|
|
||||||
await publish({ kind: 1985, content: reason ?? '', tags });
|
await publish({ kind: 1985, content: reason ?? '', tags });
|
||||||
|
|
||||||
// Optimistic, this device.
|
// Optimistic, this device — newest verdict wins on merge.
|
||||||
if (label === MODERATION_LABEL_HIDDEN) addLocalHidden({ coord, id });
|
const verdict =
|
||||||
else addLocalConfirmed({ coord, id });
|
label === MODERATION_LABEL_HIDDEN
|
||||||
|
? 'hidden'
|
||||||
|
: label === MODERATION_LABEL_VERIFIED
|
||||||
|
? 'verified'
|
||||||
|
: 'cleared';
|
||||||
|
setLocalVerdict({ coord, id }, verdict);
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-moderation'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-moderation'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -79,3 +86,13 @@ export function useAdminVerify() {
|
||||||
const { run, isPending } = usePublishModerationLabel(MODERATION_LABEL_VERIFIED);
|
const { run, isPending } = usePublishModerationLabel(MODERATION_LABEL_VERIFIED);
|
||||||
return { confirmContent: run, isPending };
|
return { confirmContent: run, isPending };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UNDO: retract whatever verdict was on the target so it is merely neutral
|
||||||
|
* again — not hidden, not vouched. The spot stays exactly where it was; only the
|
||||||
|
* verdict goes away.
|
||||||
|
*/
|
||||||
|
export function useAdminClear() {
|
||||||
|
const { run, isPending } = usePublishModerationLabel(MODERATION_LABEL_CLEARED);
|
||||||
|
return { clearVerdict: run, isPending };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ import {
|
||||||
MODERATION_LABEL_NAMESPACE,
|
MODERATION_LABEL_NAMESPACE,
|
||||||
MODERATION_LABEL_HIDDEN,
|
MODERATION_LABEL_HIDDEN,
|
||||||
MODERATION_LABEL_VERIFIED,
|
MODERATION_LABEL_VERIFIED,
|
||||||
|
MODERATION_LABEL_CLEARED,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { getLocalHidden, getLocalConfirmed } from '@/lib/localModeration';
|
import { getLocalVerdicts, type LocalVerdict } from '@/lib/localModeration';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide admin moderation layer.
|
* App-wide admin moderation layer.
|
||||||
|
|
@ -107,10 +108,12 @@ export function useAdminModeration() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const adminSet = new Set(ADMIN_PUBKEYS.map((p) => p.toLowerCase()));
|
const adminSet = new Set(ADMIN_PUBKEYS.map((p) => p.toLowerCase()));
|
||||||
const coords = new Set<string>();
|
|
||||||
const ids = new Set<string>();
|
// NEWEST VERDICT PER TARGET WINS. Kind 1985 is a regular event, so there
|
||||||
const verifiedCoords = new Set<string>();
|
// can be several labels for one target; the latest one is the current
|
||||||
const verifiedIds = new Set<string>();
|
// truth. That is what makes a verdict reversible: an undo publishes
|
||||||
|
// `cleared`, which supersedes an earlier `hidden`/`verified`.
|
||||||
|
const verdicts = new Map<string, { verdict: LocalVerdict; at: number }>();
|
||||||
|
|
||||||
for (const ev of events) {
|
for (const ev of events) {
|
||||||
// Defense in depth: re-verify author + namespace + label client-side.
|
// Defense in depth: re-verify author + namespace + label client-side.
|
||||||
|
|
@ -119,28 +122,47 @@ export function useAdminModeration() {
|
||||||
([n, v]) => n === 'L' && v === MODERATION_LABEL_NAMESPACE,
|
([n, v]) => n === 'L' && v === MODERATION_LABEL_NAMESPACE,
|
||||||
);
|
);
|
||||||
if (!hasNamespace) continue;
|
if (!hasNamespace) continue;
|
||||||
const isHidden = ev.tags.some(
|
|
||||||
([n, v]) => n === 'l' && v === MODERATION_LABEL_HIDDEN,
|
const labelOf = (want: string) =>
|
||||||
);
|
ev.tags.some(([n, v]) => n === 'l' && v === want);
|
||||||
const isVerified = ev.tags.some(
|
const verdict: LocalVerdict | null = labelOf(MODERATION_LABEL_HIDDEN)
|
||||||
([n, v]) => n === 'l' && v === MODERATION_LABEL_VERIFIED,
|
? 'hidden'
|
||||||
);
|
: labelOf(MODERATION_LABEL_VERIFIED)
|
||||||
if (!isHidden && !isVerified) continue;
|
? 'verified'
|
||||||
|
: labelOf(MODERATION_LABEL_CLEARED)
|
||||||
|
? 'cleared'
|
||||||
|
: null;
|
||||||
|
if (!verdict) continue;
|
||||||
|
|
||||||
for (const [n, v] of ev.tags) {
|
for (const [n, v] of ev.tags) {
|
||||||
if (n === 'a' && v) (isHidden ? coords : verifiedCoords).add(v);
|
const key = n === 'a' ? v : n === 'e' ? `e:${v}` : null;
|
||||||
if (n === 'e' && v) (isHidden ? ids : verifiedIds).add(v);
|
if (!key) continue;
|
||||||
|
const current = verdicts.get(key);
|
||||||
|
if (!current || ev.created_at > current.at) {
|
||||||
|
verdicts.set(key, { verdict, at: ev.created_at });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge in this device's OPTIMISTIC local hides so admin-removed content
|
// Overlay this device's OPTIMISTIC verdicts (just-made ones win until the
|
||||||
// disappears instantly, before the background kind:1985 label propagates.
|
// relay catches up, and an undo written locally replaces the old verdict).
|
||||||
const local = getLocalHidden();
|
for (const [key, entry] of getLocalVerdicts()) {
|
||||||
for (const c of local.coords) coords.add(c);
|
const current = verdicts.get(key);
|
||||||
for (const i of local.ids) ids.add(i);
|
if (!current || entry.at >= current.at) verdicts.set(key, entry);
|
||||||
const localConfirmed = getLocalConfirmed();
|
}
|
||||||
for (const c of localConfirmed.coords) verifiedCoords.add(c);
|
|
||||||
for (const i of localConfirmed.ids) verifiedIds.add(i);
|
const coords = new Set<string>();
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const verifiedCoords = new Set<string>();
|
||||||
|
const verifiedIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const [key, { verdict }] of verdicts) {
|
||||||
|
const isId = key.startsWith('e:');
|
||||||
|
const value = isId ? key.slice(2) : key;
|
||||||
|
if (verdict === 'hidden') (isId ? ids : coords).add(value);
|
||||||
|
else if (verdict === 'verified') (isId ? verifiedIds : verifiedCoords).add(value);
|
||||||
|
// `cleared` adds nothing — the target is simply neutral again.
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
coords,
|
coords,
|
||||||
|
|
@ -161,26 +183,31 @@ export function useAdminModeration() {
|
||||||
// Even if the relay query is still loading/empty, honor local optimistic
|
// Even if the relay query is still loading/empty, honor local optimistic
|
||||||
// hides so the takedown is reflected immediately on this device.
|
// hides so the takedown is reflected immediately on this device.
|
||||||
if (!query.data) {
|
if (!query.data) {
|
||||||
const local = getLocalHidden();
|
const coords = new Set<string>();
|
||||||
const localConfirmed = getLocalConfirmed();
|
const ids = new Set<string>();
|
||||||
if (
|
const verifiedCoords = new Set<string>();
|
||||||
local.coords.size === 0 &&
|
const verifiedIds = new Set<string>();
|
||||||
local.ids.size === 0 &&
|
|
||||||
localConfirmed.coords.size === 0 &&
|
for (const [key, entry] of getLocalVerdicts()) {
|
||||||
localConfirmed.ids.size === 0
|
const isId = key.startsWith('e:');
|
||||||
) {
|
const value = isId ? key.slice(2) : key;
|
||||||
|
if (entry.verdict === 'hidden') (isId ? ids : coords).add(value);
|
||||||
|
else if (entry.verdict === 'verified') (isId ? verifiedIds : verifiedCoords).add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coords.size === 0 && ids.size === 0 && verifiedCoords.size === 0 && verifiedIds.size === 0) {
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
coords: local.coords,
|
coords,
|
||||||
ids: local.ids,
|
ids,
|
||||||
has: ({ coord, id }) =>
|
has: ({ coord, id }) =>
|
||||||
(!!coord && local.coords.has(coord)) || (!!id && local.ids.has(id)),
|
(!!coord && coords.has(coord)) || (!!id && ids.has(id)),
|
||||||
verifiedCoords: localConfirmed.coords,
|
verifiedCoords,
|
||||||
verifiedIds: localConfirmed.ids,
|
verifiedIds,
|
||||||
isVerified: ({ coord, id }) =>
|
isVerified: ({ coord, id }) =>
|
||||||
(!!coord && localConfirmed.coords.has(coord)) ||
|
(!!coord && verifiedCoords.has(coord)) || (!!id && verifiedIds.has(id)),
|
||||||
(!!id && localConfirmed.ids.has(id)),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,22 @@ const SPOTS_STALE_TIME = 5 * 60 * 1000; // 5 min
|
||||||
const SPOTS_GC_TIME = 30 * 60 * 1000; // 30 min
|
const SPOTS_GC_TIME = 30 * 60 * 1000; // 30 min
|
||||||
const QUERY_TIMEOUT = 15000; // 15s — mobile cold-start needs more time
|
const QUERY_TIMEOUT = 15000; // 15s — mobile cold-start needs more time
|
||||||
|
|
||||||
export function useSkateSpots(limit = 100) {
|
/**
|
||||||
|
* Spots, newest first.
|
||||||
|
*
|
||||||
|
* `includeHidden` is for ADMIN TOOLING ONLY (the review queue): without it a spot
|
||||||
|
* you just hid vanishes from the list, so there is no way to undo the verdict.
|
||||||
|
* Normal callers must leave it off so hidden spots stay hidden.
|
||||||
|
*/
|
||||||
|
export function useSkateSpots(limit = 100, options: { includeHidden?: boolean } = {}) {
|
||||||
|
const { includeHidden = false } = options;
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
const hidden = useAdminModeration();
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['skate-spots', limit, getQueryKeySuffix()],
|
queryKey: ['skate-spots', limit, includeHidden, getQueryKeySuffix()],
|
||||||
select: (spots: SkateSpot[]) => filterAdminHidden(spots, SKATE_SPOT_KIND, hidden),
|
select: (spots: SkateSpot[]) =>
|
||||||
|
includeHidden ? spots : filterAdminHidden(spots, SKATE_SPOT_KIND, hidden),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
// If offline, return cached spots
|
// If offline, return cached spots
|
||||||
if (isOffline()) {
|
if (isOffline()) {
|
||||||
|
|
|
||||||
|
|
@ -173,3 +173,64 @@ export function useAggregatedSpotTags(spotPubkey: string, spotIdentifier: string
|
||||||
.sort((a, b) => b.count - a.count);
|
.sort((a, b) => b.count - a.count);
|
||||||
}, [tagContributions]);
|
}, [tagContributions]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Community photo counts for MANY spots in one query.
|
||||||
|
*
|
||||||
|
* `useSpotPhotos` already queries by the `a` tag, so the batch is the same query
|
||||||
|
* with every ref at once (relays index `a`, so this is one request) — that is
|
||||||
|
* what makes it cheap enough for the review queue, where a per-spot query would
|
||||||
|
* mean hundreds of round-trips.
|
||||||
|
*
|
||||||
|
* Only photos from people OTHER than the mapper count: the mapper supplying their
|
||||||
|
* own picture is expected, not a community signal.
|
||||||
|
*/
|
||||||
|
export function useBatchSpotPhotos(spots: Array<{ pubkey: string; identifier: string }>) {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
|
||||||
|
const refs = useMemo(
|
||||||
|
() => spots.map((s) => `${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`),
|
||||||
|
[spots],
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['batch-spot-photos', refs.slice().sort().join(',')],
|
||||||
|
enabled: refs.length > 0,
|
||||||
|
staleTime: 60_000,
|
||||||
|
queryFn: async (c) => {
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
|
||||||
|
const events = await nostr.query(
|
||||||
|
[{ kinds: [PHOTO_CONTRIBUTION_KIND], '#a': refs, limit: 1000 }],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
return events
|
||||||
|
.map(parsePhotoContribution)
|
||||||
|
.filter((p): p is PhotoContribution => p !== null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapperByRef = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const s of spots) map.set(`${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`, s.pubkey);
|
||||||
|
return map;
|
||||||
|
}, [spots]);
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const photo of query.data ?? []) {
|
||||||
|
// The contribution event carries the spot's `a` coordinate.
|
||||||
|
const ref = photo.event.tags.find(([n]) => n === 'a')?.[1];
|
||||||
|
if (!ref) continue;
|
||||||
|
const mapper = mapperByRef.get(ref);
|
||||||
|
if (mapper && photo.pubkey === mapper) continue; // mapper's own photo
|
||||||
|
map.set(ref, (map.get(ref) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [query.data, mapperByRef]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
getCommunityPhotoCount: (pubkey: string, identifier: string) =>
|
||||||
|
counts.get(`${SKATE_SPOT_KIND}:${pubkey}:${identifier}`) ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,82 +3,109 @@
|
||||||
*
|
*
|
||||||
* WHY THIS EXISTS
|
* WHY THIS EXISTS
|
||||||
* ---------------
|
* ---------------
|
||||||
* An admin takedown publishes a kind:1985 `hidden` label so the content is
|
* An admin verdict publishes a kind:1985 label so it applies across devices (see
|
||||||
* hidden across devices (see useAdminModeration). But publishing that label
|
* `useAdminModeration`). But publishing requires an Amber approval, which can be
|
||||||
* requires a SECOND Amber approval, which on mobile can be slow or require a
|
* slow. We must not make the admin UI wait on it, so every verdict is ALSO
|
||||||
* second app switch. We must NOT make the delete UI wait on it.
|
* mirrored here and merged in, so it takes effect on this device instantly.
|
||||||
*
|
*
|
||||||
* So the moment the required kind:5 deletion succeeds, we record the target in
|
* VERDICTS (latest wins)
|
||||||
* this local store. useAdminModeration merges it, so the content disappears
|
* ----------------------
|
||||||
* from the app immediately on THIS device. The kind:1985 label still publishes
|
* A target has ONE current verdict, and the newest one wins — both here and on
|
||||||
* in the background for cross-device / other-user persistence.
|
* the relay side. That is what makes moderation reversible:
|
||||||
|
* • `hidden` — out of the app;
|
||||||
|
* • `verified` — admin-vouched (badge, and cleared from the review queue);
|
||||||
|
* • `cleared` — UNDO: back to neutral, as if nobody had said anything.
|
||||||
*
|
*
|
||||||
* Persisted to localStorage so an optimistic hide survives a reload even if the
|
* Entries carry a timestamp so a device-local verdict (just now) can be compared
|
||||||
* background label publish hasn't propagated yet.
|
* against a relay label (created_at) instead of blindly winning forever.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const STORAGE_KEY = 'skatespots_local_admin_hidden_v1';
|
/** A moderation verdict about one target. */
|
||||||
/** Same idea, for the POSITIVE action: an admin vouching for a spot. */
|
export type LocalVerdict = 'hidden' | 'verified' | 'cleared';
|
||||||
const CONFIRMED_KEY = 'skatespots_local_admin_confirmed_v1';
|
|
||||||
|
|
||||||
interface LocalHidden {
|
export interface LocalVerdictEntry {
|
||||||
coords: string[];
|
verdict: LocalVerdict;
|
||||||
ids: string[];
|
/** Unix seconds. */
|
||||||
|
at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readAt(key: string): LocalHidden {
|
const STORAGE_KEY = 'skatespots_local_admin_verdicts_v1';
|
||||||
|
// Older versions stored hidden and confirmed as two separate arrays.
|
||||||
|
const LEGACY_HIDDEN_KEY = 'skatespots_local_admin_hidden_v1';
|
||||||
|
const LEGACY_CONFIRMED_KEY = 'skatespots_local_admin_confirmed_v1';
|
||||||
|
|
||||||
|
type Store = Record<string, LocalVerdictEntry>;
|
||||||
|
|
||||||
|
/** One key per target: the addressable coord, or `e:<id>` for a concrete event. */
|
||||||
|
export function targetKey(opts: { coord?: string; id?: string }): string | null {
|
||||||
|
if (opts.coord) return opts.coord;
|
||||||
|
if (opts.id) return `e:${opts.id}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLegacy(key: string): string[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(key);
|
const raw = localStorage.getItem(key);
|
||||||
if (!raw) return { coords: [], ids: [] };
|
if (!raw) return [];
|
||||||
const parsed = JSON.parse(raw) as Partial<LocalHidden>;
|
const parsed = JSON.parse(raw) as { coords?: unknown; ids?: unknown };
|
||||||
return {
|
const coords = Array.isArray(parsed.coords) ? (parsed.coords as string[]) : [];
|
||||||
coords: Array.isArray(parsed.coords) ? parsed.coords : [],
|
const ids = Array.isArray(parsed.ids) ? (parsed.ids as string[]) : [];
|
||||||
ids: Array.isArray(parsed.ids) ? parsed.ids : [],
|
return [...coords, ...ids.map((id) => `e:${id}`)];
|
||||||
};
|
|
||||||
} catch {
|
} catch {
|
||||||
return { coords: [], ids: [] };
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeAt(key: string, data: LocalHidden): void {
|
function read(): Store {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(key, JSON.stringify(data));
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) return JSON.parse(raw) as Store;
|
||||||
|
// Migrate the legacy two-array format. `at: 0` means "no idea when", so any
|
||||||
|
// real relay label supersedes it — but it still applies while the label
|
||||||
|
// hasn't propagated.
|
||||||
|
const migrated: Store = {};
|
||||||
|
for (const key of readLegacy(LEGACY_HIDDEN_KEY)) migrated[key] = { verdict: 'hidden', at: 0 };
|
||||||
|
for (const key of readLegacy(LEGACY_CONFIRMED_KEY)) migrated[key] = { verdict: 'verified', at: 0 };
|
||||||
|
if (Object.keys(migrated).length > 0) write(migrated);
|
||||||
|
return migrated;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(store: Store): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||||
} catch {
|
} catch {
|
||||||
/* storage full / unavailable — the optimistic mirror is best-effort */
|
/* storage full / unavailable — the optimistic mirror is best-effort */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const read = () => readAt(STORAGE_KEY);
|
/** Record the admin's verdict for a target (device-local, newest wins). */
|
||||||
const write = (data: LocalHidden) => writeAt(STORAGE_KEY, data);
|
export function setLocalVerdict(
|
||||||
|
opts: { coord?: string; id?: string },
|
||||||
/**
|
verdict: LocalVerdict,
|
||||||
* The admin's POSITIVE action: they looked at a spot and vouched for it. Stored
|
): void {
|
||||||
* locally so the review queue stops nagging about it instantly, and mirrored by
|
const key = targetKey(opts);
|
||||||
* a public `verified` label (see useAdminVerify).
|
if (!key) return;
|
||||||
*/
|
const store = read();
|
||||||
export function addLocalConfirmed(opts: { coord?: string; id?: string }): void {
|
store[key] = { verdict, at: Math.floor(Date.now() / 1000) };
|
||||||
const data = readAt(CONFIRMED_KEY);
|
write(store);
|
||||||
if (opts.coord && !data.coords.includes(opts.coord)) data.coords.push(opts.coord);
|
|
||||||
if (opts.id && !data.ids.includes(opts.id)) data.ids.push(opts.id);
|
|
||||||
writeAt(CONFIRMED_KEY, data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read the current local confirmed sets. */
|
/** Read every locally-recorded verdict, keyed by `targetKey`. */
|
||||||
export function getLocalConfirmed(): { coords: Set<string>; ids: Set<string> } {
|
export function getLocalVerdicts(): Map<string, LocalVerdictEntry> {
|
||||||
const data = readAt(CONFIRMED_KEY);
|
return new Map(Object.entries(read()));
|
||||||
return { coords: new Set(data.coords), ids: new Set(data.ids) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Record a coordinate + event id as locally hidden by the admin. */
|
// ─── Thin wrappers, kept for existing callers ────────────────────────────────
|
||||||
|
|
||||||
|
/** @see setLocalVerdict */
|
||||||
export function addLocalHidden(opts: { coord?: string; id?: string }): void {
|
export function addLocalHidden(opts: { coord?: string; id?: string }): void {
|
||||||
const data = read();
|
setLocalVerdict(opts, 'hidden');
|
||||||
if (opts.coord && !data.coords.includes(opts.coord)) data.coords.push(opts.coord);
|
|
||||||
if (opts.id && !data.ids.includes(opts.id)) data.ids.push(opts.id);
|
|
||||||
write(data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read the current local hidden sets. */
|
/** @see setLocalVerdict */
|
||||||
export function getLocalHidden(): { coords: Set<string>; ids: Set<string> } {
|
export function addLocalConfirmed(opts: { coord?: string; id?: string }): void {
|
||||||
const data = read();
|
setLocalVerdict(opts, 'verified');
|
||||||
return { coords: new Set(data.coords), ids: new Set(data.ids) };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,12 @@ export const MODERATION_LABEL_NAMESPACE = 'org.skatespots.moderation';
|
||||||
export const MODERATION_LABEL_HIDDEN = 'hidden';
|
export const MODERATION_LABEL_HIDDEN = 'hidden';
|
||||||
/** Admin VOUCHES for the target (the positive counterpart to `hidden`). */
|
/** Admin VOUCHES for the target (the positive counterpart to `hidden`). */
|
||||||
export const MODERATION_LABEL_VERIFIED = 'verified';
|
export const MODERATION_LABEL_VERIFIED = 'verified';
|
||||||
|
/**
|
||||||
|
* UNDO: the admin retracted a previous verdict. Nothing is hidden or vouched —
|
||||||
|
* the target goes back to neutral. Newest verdict wins, so this supersedes an
|
||||||
|
* earlier `hidden`/`verified` label.
|
||||||
|
*/
|
||||||
|
export const MODERATION_LABEL_CLEARED = 'cleared';
|
||||||
|
|
||||||
/** Extract member pubkeys (hex) claimed by a Lab profile via `p` tags. */
|
/** Extract member pubkeys (hex) claimed by a Lab profile via `p` tags. */
|
||||||
export function parseMemberPubkeys(event: NostrEvent): string[] {
|
export function parseMemberPubkeys(event: NostrEvent): string[] {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
Loader2,
|
Loader2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
|
Undo2,
|
||||||
Users,
|
Users,
|
||||||
ShieldQuestion,
|
ShieldQuestion,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
@ -20,7 +21,8 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||||
import { useBatchSpotStatus } from '@/hooks/useSpotStatus';
|
import { useBatchSpotStatus } from '@/hooks/useSpotStatus';
|
||||||
import { useAdminHide, useAdminVerify } from '@/hooks/useAdminHide';
|
import { useBatchSpotPhotos } from '@/hooks/useSpotContributions';
|
||||||
|
import { useAdminHide, useAdminVerify, useAdminClear } from '@/hooks/useAdminHide';
|
||||||
import { useAdminModeration } from '@/hooks/useAdminModeration';
|
import { useAdminModeration } from '@/hooks/useAdminModeration';
|
||||||
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
|
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
|
@ -44,9 +46,12 @@ import NotFound from '@/pages/NotFound';
|
||||||
*/
|
*/
|
||||||
export default function AdminReviewPage() {
|
export default function AdminReviewPage() {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { data: spots, isLoading } = useSkateSpots(300);
|
// includeHidden: a spot you just hid must stay listed here, or there is no
|
||||||
|
// way to undo the verdict.
|
||||||
|
const { data: spots, isLoading } = useSkateSpots(300, { includeHidden: true });
|
||||||
const { hideContent, isPending: hiding } = useAdminHide();
|
const { hideContent, isPending: hiding } = useAdminHide();
|
||||||
const { confirmContent, isPending: confirming } = useAdminVerify();
|
const { confirmContent, isPending: confirming } = useAdminVerify();
|
||||||
|
const { clearVerdict, isPending: clearing } = useAdminClear();
|
||||||
const moderation = useAdminModeration();
|
const moderation = useAdminModeration();
|
||||||
const { reportNotASpot } = useNotASpotReport();
|
const { reportNotASpot } = useNotASpotReport();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
@ -63,6 +68,7 @@ export default function AdminReviewPage() {
|
||||||
[spots],
|
[spots],
|
||||||
);
|
);
|
||||||
const { getStatus } = useBatchSpotStatus(statusInput);
|
const { getStatus } = useBatchSpotStatus(statusInput);
|
||||||
|
const { getCommunityPhotoCount } = useBatchSpotPhotos(statusInput);
|
||||||
|
|
||||||
const rows = useMemo(
|
const rows = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -70,23 +76,33 @@ export default function AdminReviewPage() {
|
||||||
.map((spot) => {
|
.map((spot) => {
|
||||||
const coord = `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`;
|
const coord = `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`;
|
||||||
const status = getStatus(spot.pubkey, spot.identifier);
|
const status = getStatus(spot.pubkey, spot.identifier);
|
||||||
|
const hidden = moderation.has({ coord, id: spot.id });
|
||||||
|
const verified = moderation.isVerified({ coord, id: spot.id });
|
||||||
|
const photos = getCommunityPhotoCount(spot.pubkey, spot.identifier);
|
||||||
|
const reporters = status?.reporterCount ?? 0;
|
||||||
return {
|
return {
|
||||||
spot,
|
spot,
|
||||||
coord,
|
coord,
|
||||||
verified: moderation.isVerified({ coord, id: spot.id }),
|
hidden,
|
||||||
|
verified,
|
||||||
|
// An already-actioned row is kept so the admin can UNDO it.
|
||||||
|
actioned: hidden || verified,
|
||||||
trust: trustScores?.get(spot.pubkey),
|
trust: trustScores?.get(spot.pubkey),
|
||||||
reporters: status?.reporterCount ?? 0,
|
reporters,
|
||||||
|
photos,
|
||||||
flags: spotFlags(spot, {
|
flags: spotFlags(spot, {
|
||||||
mapperTrust: trustScores?.get(spot.pubkey),
|
mapperTrust: trustScores?.get(spot.pubkey),
|
||||||
communityStatusReports: status?.reporterCount ?? 0,
|
communityStatusReports: reporters,
|
||||||
|
communityPhotos: photos,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
// Admin-vouched spots are done — and confirming one should visibly
|
.filter((row) => row.actioned || row.flags.length > 0)
|
||||||
// remove it from the pile.
|
// Un-actioned rows first (most flags first); actioned rows sink below.
|
||||||
.filter((row) => !row.verified && row.flags.length > 0)
|
.sort((a, b) =>
|
||||||
.sort((a, b) => b.flags.length - a.flags.length),
|
a.actioned === b.actioned ? b.flags.length - a.flags.length : a.actioned ? 1 : -1,
|
||||||
[spots, moderation, trustScores, getStatus],
|
),
|
||||||
|
[spots, moderation, trustScores, getStatus, getCommunityPhotoCount],
|
||||||
);
|
);
|
||||||
|
|
||||||
useSeoMeta({ title: 'Review queue — SkateSpots' });
|
useSeoMeta({ title: 'Review queue — SkateSpots' });
|
||||||
|
|
@ -110,6 +126,22 @@ export default function AdminReviewPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUndo = async (coord: string, id: string) => {
|
||||||
|
try {
|
||||||
|
await clearVerdict({ coord, id, reason: 'verdict retracted' });
|
||||||
|
toast({
|
||||||
|
title: 'Undone',
|
||||||
|
description: 'Back to neutral — not hidden, not vouched.',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: 'Could not undo',
|
||||||
|
description: e instanceof Error ? e.message : 'Try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleConfirm = async (coord: string, id: string) => {
|
const handleConfirm = async (coord: string, id: string) => {
|
||||||
try {
|
try {
|
||||||
await confirmContent({ coord, id, reason: 'looks legit after review' });
|
await confirmContent({ coord, id, reason: 'looks legit after review' });
|
||||||
|
|
@ -151,8 +183,17 @@ export default function AdminReviewPage() {
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{rows.map(({ spot, coord, flags, trust, reporters }) => (
|
{rows.map(({ spot, coord, flags, trust, reporters, photos, hidden, verified }) => (
|
||||||
<Card key={spot.id} className="border-2 border-white/10 bg-white/[0.03]">
|
<Card
|
||||||
|
key={spot.id}
|
||||||
|
className={
|
||||||
|
hidden
|
||||||
|
? 'border-2 border-red-500/25 bg-red-500/[0.04]'
|
||||||
|
: verified
|
||||||
|
? 'border-2 border-emerald-500/25 bg-emerald-500/[0.04]'
|
||||||
|
: 'border-2 border-white/10 bg-white/[0.03]'
|
||||||
|
}
|
||||||
|
>
|
||||||
<CardContent className="space-y-3 p-4">
|
<CardContent className="space-y-3 p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{spot.images?.[0] ? (
|
{spot.images?.[0] ? (
|
||||||
|
|
@ -167,12 +208,26 @@ export default function AdminReviewPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate font-bold text-white">{spot.title || '(untitled)'}</p>
|
<p className="flex items-center gap-2 truncate font-bold text-white">
|
||||||
|
{hidden && (
|
||||||
|
<Badge className="border border-red-500/50 bg-red-500/20 text-[10px] font-black uppercase tracking-wide text-red-300">
|
||||||
|
<EyeOff className="mr-1 h-3 w-3" />
|
||||||
|
Hidden
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{verified && (
|
||||||
|
<Badge className="border border-emerald-500/50 bg-emerald-500/20 text-[10px] font-black uppercase tracking-wide text-emerald-300">
|
||||||
|
<BadgeCheck className="mr-1 h-3 w-3" />
|
||||||
|
Confirmed
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="truncate">{spot.title || '(untitled)'}</span>
|
||||||
|
</p>
|
||||||
<p className="truncate text-xs text-gray-500">
|
<p className="truncate text-xs text-gray-500">
|
||||||
{spot.description?.trim() || 'no description'}
|
{spot.description?.trim() || 'no description'}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{flags.map((f) => (
|
{!hidden && !verified && flags.map((f) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={f}
|
key={f}
|
||||||
className="border border-amber-500/40 bg-amber-500/15 text-[10px] font-bold uppercase tracking-wide text-amber-300"
|
className="border border-amber-500/40 bg-amber-500/15 text-[10px] font-bold uppercase tracking-wide text-amber-300"
|
||||||
|
|
@ -192,8 +247,13 @@ export default function AdminReviewPage() {
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Users className="h-3 w-3" />
|
<Users className="h-3 w-3" />
|
||||||
{reporters > 0
|
{reporters > 0 || photos > 0
|
||||||
? `${reporters} status report${reporters === 1 ? '' : 's'}`
|
? [
|
||||||
|
reporters > 0 ? `${reporters} status report${reporters === 1 ? '' : 's'}` : null,
|
||||||
|
photos > 0 ? `${photos} community photo${photos === 1 ? '' : 's'}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
: 'no community signals'}
|
: 'no community signals'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -201,6 +261,23 @@ export default function AdminReviewPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(hidden || verified) ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1.5 border-white/20 text-gray-200 hover:bg-white/10"
|
||||||
|
disabled={clearing}
|
||||||
|
onClick={() => void handleUndo(coord, spot.id)}
|
||||||
|
>
|
||||||
|
{clearing ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Undo2 className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Undo
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-400"
|
className="gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-400"
|
||||||
|
|
@ -243,6 +320,8 @@ export default function AdminReviewPage() {
|
||||||
<Flag className="h-3.5 w-3.5" />
|
<Flag className="h-3.5 w-3.5" />
|
||||||
Not a skate spot
|
Not a skate spot
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Button asChild size="sm" variant="ghost" className="gap-1.5 text-gray-400">
|
<Button asChild size="sm" variant="ghost" className="gap-1.5 text-gray-400">
|
||||||
<Link to={`/spot/${spot.pubkey}/${spot.identifier}`}>
|
<Link to={`/spot/${spot.pubkey}/${spot.identifier}`}>
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue