- Tour sponsor URL/logo sanitized before href/src (SponsorSlot) — same XSS guard EventSponsors already uses. - Official SkateSpots badge only shows when the NIP-05 actually verifies (NIP19Page), not on a self-asserted handle. - Community stats reads are null-guarded on persisted-cache fields (monthly/cumulativeSpots/topMappers) — no more crash on an old cache. - Overpass response no longer assumes .elements (food layer). - user-listings query key is memoized (was Date.now() → refetch storm). - Pro grant requires the l=pro label (was accepted with none). - Verify card RSVP maps accepted/tentative (was going/interested → showed 'No RSVP' on a valid ticket). - Expired listings can't be bought via a stale/shared URL. - Batch spot photo/status hooks include the relay suffix and use querySkateContent (relay-policy parity with useSpotReports). - Offline queue requeues rows stuck in 'publishing' after a crash. - querySkateContent/queryCanonical dedupe replaceable events by author+kind (not id) so a stale profile/relay list can't win. - Route offline cache key includes the origin (was destination+mode only). - Internal links use <Link> (MarketplacePage); EventCountdown reacts to language changes.
248 lines
7.6 KiB
TypeScript
248 lines
7.6 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';
|
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
|
|
|
// 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. Read BOTH the
|
|
// NIP-22 uppercase root (`#A`, new writes) and the legacy lowercase `#a`
|
|
// so tag suggestions published before the fix still show up.
|
|
const events = await nostr.query([
|
|
{
|
|
kinds: [1111],
|
|
'#A': [spotRef],
|
|
'#t': ['skatespot-tags'],
|
|
limit: 100,
|
|
},
|
|
{
|
|
kinds: [1111],
|
|
'#a': [spotRef],
|
|
'#t': ['skatespot-tags'],
|
|
limit: 100,
|
|
},
|
|
], { signal });
|
|
|
|
// Dedupe (an event could match both filters).
|
|
const seen = new Set<string>();
|
|
return events
|
|
.filter((e) => (seen.has(e.id) ? false : (seen.add(e.id), true)))
|
|
.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 { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
|
|
|
const refs = useMemo(
|
|
() => spots.map((s) => `${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`),
|
|
[spots],
|
|
);
|
|
|
|
const query = useQuery({
|
|
queryKey: ['batch-spot-photos', getQueryKeySuffix(), 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 querySkateContent(
|
|
[{ 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,
|
|
};
|
|
}
|