SkateSpots/src/hooks/useSpotContributions.ts
SkateSpots e44f8b81a7 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.
2026-09-28 01:06:04 +07:00

236 lines
7 KiB
TypeScript

import { useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { SKATE_SPOT_KIND } from '@/lib/types';
// Kind 1063 = File Metadata (NIP-94) for community photos
export const PHOTO_CONTRIBUTION_KIND = 1063;
export interface PhotoContribution {
id: string;
pubkey: string;
url: string;
mimeType?: string;
caption: string;
createdAt: number;
event: NostrEvent;
}
export interface TagContribution {
id: string;
pubkey: string;
tags: string[];
createdAt: number;
event: NostrEvent;
}
/**
* Parse a kind 1063 file metadata event into a PhotoContribution
*/
function parsePhotoContribution(event: NostrEvent): PhotoContribution | null {
if (event.kind !== PHOTO_CONTRIBUTION_KIND) return null;
const url = event.tags.find(([name]) => name === 'url')?.[1];
if (!url) return null;
const mimeType = event.tags.find(([name]) => name === 'm')?.[1];
return {
id: event.id,
pubkey: event.pubkey,
url,
mimeType,
caption: event.content,
createdAt: event.created_at,
event,
};
}
/**
* Parse a kind 1111 comment event that contains tag suggestions
*/
function parseTagContribution(event: NostrEvent): TagContribution | null {
if (event.kind !== 1111) return null;
// Check if this is a tag contribution (has #skatespot-tags marker)
const isTagContribution = event.tags.some(
([name, value]) => name === 't' && value === 'skatespot-tags'
);
if (!isTagContribution) return null;
// Extract suggested tags (all t tags except the marker)
const tags = event.tags
.filter(([name, value]) => name === 't' && value !== 'skatespot-tags')
.map(([, value]) => value)
.filter(Boolean) as string[];
if (tags.length === 0) return null;
return {
id: event.id,
pubkey: event.pubkey,
tags,
createdAt: event.created_at,
event,
};
}
/**
* Hook to fetch community photo contributions for a spot
*/
export function useSpotPhotos(spotPubkey: string, spotIdentifier: string) {
const { nostr } = useNostr();
const spotRef = `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`;
return useQuery({
queryKey: ['spot-photos', spotPubkey, spotIdentifier],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
// Query kind 1063 events that reference this spot via 'a' tag
const events = await nostr.query([
{
kinds: [PHOTO_CONTRIBUTION_KIND],
'#a': [spotRef],
limit: 50,
},
], { signal });
return events
.map(parsePhotoContribution)
.filter((p): p is PhotoContribution => p !== null)
.sort((a, b) => b.createdAt - a.createdAt);
},
enabled: !!spotPubkey && !!spotIdentifier,
});
}
/**
* Hook to fetch community tag suggestions for a spot
*/
export function useSpotTagSuggestions(spotPubkey: string, spotIdentifier: string) {
const { nostr } = useNostr();
const spotRef = `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`;
return useQuery({
queryKey: ['spot-tag-suggestions', spotPubkey, spotIdentifier],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
// Query kind 1111 comments that are tag contributions
const events = await nostr.query([
{
kinds: [1111],
'#a': [spotRef],
'#t': ['skatespot-tags'],
limit: 100,
},
], { signal });
return events
.map(parseTagContribution)
.filter((t): t is TagContribution => t !== null)
.sort((a, b) => b.createdAt - a.createdAt);
},
enabled: !!spotPubkey && !!spotIdentifier,
});
}
/**
* Hook to get aggregated community tags with vote counts
*/
export function useAggregatedSpotTags(spotPubkey: string, spotIdentifier: string) {
const { data: tagContributions } = useSpotTagSuggestions(spotPubkey, spotIdentifier);
return useMemo(() => {
if (!tagContributions) return [];
// Count how many times each tag was suggested
const tagCounts = new Map<string, { count: number; contributors: Set<string> }>();
for (const contribution of tagContributions) {
for (const tag of contribution.tags) {
const existing = tagCounts.get(tag) || { count: 0, contributors: new Set() };
// Only count each contributor once per tag
if (!existing.contributors.has(contribution.pubkey)) {
existing.count++;
existing.contributors.add(contribution.pubkey);
}
tagCounts.set(tag, existing);
}
}
// Convert to array and sort by count
return Array.from(tagCounts.entries())
.map(([tag, { count, contributors }]) => ({
tag,
count,
contributorCount: contributors.size,
}))
.sort((a, b) => b.count - a.count);
}, [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,
};
}