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 }>(); 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(); 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(); 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, }; }