import { useQuery, useQueryClient } from '@tanstack/react-query'; import { SPOT_COLLECTION_KIND, SKATE_SPOT_KIND, parseSpotCollection, type SpotCollection } from '@/lib/types'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useSkateRelays } from '@/hooks/useSkateRelays'; /** * Preserve tags this app doesn't own. Kind 30003 is a NIP-51 bookmark set and * re-publishing an addressable event replaces the WHOLE tag set, so foreign * `e`/`r`/`p`/extra `t` (or `a` to a non-spot coordinate) from other clients * must be carried forward or they are silently erased. */ function foreignCollectionTags(collection: SpotCollection): string[][] { const out: string[][] = []; for (const tag of collection.event.tags) { const [n, v] = tag; if (n === 'd' || n === 'title' || n === 'description' || n === 'image' || n === 'alt') continue; if (n === 't' && v === 'skatespots') continue; if (n === 'a' && v?.startsWith(`${SKATE_SPOT_KIND}:`)) continue; out.push(tag); } return out; } /** * Fetch all spot collections for a specific user */ export function useSpotCollections(pubkey?: string) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); return useQuery({ queryKey: ['spot-collections', pubkey, getQueryKeySuffix()], queryFn: async () => { if (!pubkey) return []; const signal = AbortSignal.timeout(10000); const events = await querySkateContent( [ { kinds: [SPOT_COLLECTION_KIND], authors: [pubkey], '#t': ['skatespots'], limit: 50, }, ], { signal } ); const collections: SpotCollection[] = []; for (const event of events) { const collection = parseSpotCollection(event); if (collection) collections.push(collection); } return collections.sort((a, b) => b.createdAt - a.createdAt); }, enabled: !!pubkey, staleTime: 30000, refetchOnMount: 'always', }); } /** * Fetch the current user's spot collections */ export function useMySpotCollections() { const { user } = useCurrentUser(); return useSpotCollections(user?.pubkey); } /** * Fetch a single spot collection by identifier */ export function useSpotCollection(identifier: string, pubkey?: string) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); return useQuery({ queryKey: ['spot-collection', identifier, pubkey, getQueryKeySuffix()], queryFn: async () => { const signal = AbortSignal.timeout(10000); const filter: { kinds: number[]; '#d': string[]; '#t': string[]; authors?: string[]; limit: number; } = { kinds: [SPOT_COLLECTION_KIND], '#d': [identifier], '#t': ['skatespots'], limit: 1, }; if (pubkey) { filter.authors = [pubkey]; } const events = await querySkateContent([filter], { signal }); if (events.length === 0) return null; return parseSpotCollection(events[0]); }, enabled: !!identifier, staleTime: 30000, refetchOnMount: 'always', }); } /** * Create a new spot collection */ export function useCreateSpotCollection() { const { mutateAsync: publishEvent, isPending } = useNostrPublish(); const queryClient = useQueryClient(); const { user } = useCurrentUser(); const createCollection = async ({ title, description, image, spotRefs = [], }: { title: string; description?: string; image?: string; spotRefs?: string[]; }) => { if (!user) throw new Error('Must be logged in to create a collection'); // Generate a unique identifier const identifier = `${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Date.now()}`; const tags: string[][] = [ ['d', identifier], ['title', title], ['t', 'skatespots'], ['alt', `Spot collection: ${title}`], ]; if (description) { tags.push(['description', description]); } if (image) { tags.push(['image', image]); } // Add spot references for (const ref of spotRefs) { tags.push(['a', ref]); } const event = await publishEvent({ kind: SPOT_COLLECTION_KIND, content: description || '', tags, }); // Invalidate collections cache queryClient.invalidateQueries({ queryKey: ['spot-collections', user.pubkey] }); return event; }; return { createCollection, isPending }; } /** * Add a spot to a collection */ export function useAddSpotToCollection() { const { mutateAsync: publishEvent, isPending } = useNostrPublish(); const queryClient = useQueryClient(); const { user } = useCurrentUser(); const addSpotToCollection = async ({ collection, spotPubkey, spotIdentifier, }: { collection: SpotCollection; spotPubkey: string; spotIdentifier: string; }) => { if (!user) throw new Error('Must be logged in to modify a collection'); if (collection.pubkey !== user.pubkey) throw new Error('You can only modify your own collections'); const spotRef = `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`; // Check if already in collection if (collection.spotRefs.includes(spotRef)) { throw new Error('Spot already in collection'); } // Build updated tags from original event const tags: string[][] = [ ['d', collection.identifier], ['title', collection.title], ['t', 'skatespots'], ['alt', `Spot collection: ${collection.title}`], ]; if (collection.description) { tags.push(['description', collection.description]); } if (collection.image) { tags.push(['image', collection.image]); } // Add existing spot refs for (const ref of collection.spotRefs) { tags.push(['a', ref]); } // Add new spot ref tags.push(['a', spotRef]); // Preserve foreign tags from the original event. tags.push(...foreignCollectionTags(collection)); const event = await publishEvent({ kind: SPOT_COLLECTION_KIND, content: collection.description || '', tags, }); // Invalidate caches queryClient.invalidateQueries({ queryKey: ['spot-collections', user.pubkey] }); queryClient.invalidateQueries({ queryKey: ['spot-collection', collection.identifier] }); return event; }; return { addSpotToCollection, isPending }; } /** * Remove a spot from a collection */ export function useRemoveSpotFromCollection() { const { mutateAsync: publishEvent, isPending } = useNostrPublish(); const queryClient = useQueryClient(); const { user } = useCurrentUser(); const removeSpotFromCollection = async ({ collection, spotPubkey, spotIdentifier, }: { collection: SpotCollection; spotPubkey: string; spotIdentifier: string; }) => { if (!user) throw new Error('Must be logged in to modify a collection'); if (collection.pubkey !== user.pubkey) throw new Error('You can only modify your own collections'); const spotRef = `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`; // Filter out the spot to remove const updatedSpotRefs = collection.spotRefs.filter(ref => ref !== spotRef); // Build updated tags const tags: string[][] = [ ['d', collection.identifier], ['title', collection.title], ['t', 'skatespots'], ['alt', `Spot collection: ${collection.title}`], ]; if (collection.description) { tags.push(['description', collection.description]); } if (collection.image) { tags.push(['image', collection.image]); } // Add remaining spot refs for (const ref of updatedSpotRefs) { tags.push(['a', ref]); } // Preserve foreign tags from the original event. tags.push(...foreignCollectionTags(collection)); const event = await publishEvent({ kind: SPOT_COLLECTION_KIND, content: collection.description || '', tags, }); // Invalidate caches queryClient.invalidateQueries({ queryKey: ['spot-collections', user.pubkey] }); queryClient.invalidateQueries({ queryKey: ['spot-collection', collection.identifier] }); return event; }; return { removeSpotFromCollection, isPending }; } /** * Delete a spot collection */ export function useDeleteSpotCollection() { const { mutateAsync: publishEvent, isPending } = useNostrPublish(); const queryClient = useQueryClient(); const { user } = useCurrentUser(); const deleteCollection = async (collection: SpotCollection) => { if (!user) throw new Error('Must be logged in to delete a collection'); if (collection.pubkey !== user.pubkey) throw new Error('You can only delete your own collections'); // Publish deletion event (kind 5) await publishEvent({ kind: 5, content: 'Deleted collection', tags: [ ['e', collection.id], ['a', `${SPOT_COLLECTION_KIND}:${collection.pubkey}:${collection.identifier}`], ], }); // Invalidate caches queryClient.invalidateQueries({ queryKey: ['spot-collections', user.pubkey] }); queryClient.invalidateQueries({ queryKey: ['spot-collection', collection.identifier] }); }; return { deleteCollection, isPending }; } /** * Update a spot collection (title, description, image) */ export function useUpdateSpotCollection() { const { mutateAsync: publishEvent, isPending } = useNostrPublish(); const queryClient = useQueryClient(); const { user } = useCurrentUser(); const updateCollection = async ({ collection, title, description, image, }: { collection: SpotCollection; title?: string; description?: string; image?: string; }) => { if (!user) throw new Error('Must be logged in to update a collection'); if (collection.pubkey !== user.pubkey) throw new Error('You can only update your own collections'); const newTitle = title ?? collection.title; const newDescription = description ?? collection.description; const newImage = image ?? collection.image; const tags: string[][] = [ ['d', collection.identifier], ['title', newTitle], ['t', 'skatespots'], ['alt', `Spot collection: ${newTitle}`], ]; if (newDescription) { tags.push(['description', newDescription]); } if (newImage) { tags.push(['image', newImage]); } // Keep existing spot refs for (const ref of collection.spotRefs) { tags.push(['a', ref]); } // Preserve foreign tags from the original event. tags.push(...foreignCollectionTags(collection)); const event = await publishEvent({ kind: SPOT_COLLECTION_KIND, content: newDescription || '', tags, }); // Invalidate caches queryClient.invalidateQueries({ queryKey: ['spot-collections', user.pubkey] }); queryClient.invalidateQueries({ queryKey: ['spot-collection', collection.identifier] }); return event; }; return { updateCollection, isPending }; }