import { useNostr } from '@nostrify/react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useCurrentUser } from './useCurrentUser'; import { useNostrPublish } from './useNostrPublish'; import type { NostrEvent } from '@nostrify/nostrify'; // NIP-38 User Status Kind export const USER_STATUS_KIND = 30315; // Skating status presets with emojis export const SKATING_STATUSES = [ { id: 'skating', label: 'Skating', emoji: '🛹', description: 'Currently skating' }, { id: 'filming', label: 'Filming', emoji: '🎬', description: 'Filming tricks' }, { id: 'looking-for-sesh', label: 'Looking for Sesh', emoji: '👀', description: 'Want to skate with others' }, { id: 'coaching', label: 'Coaching', emoji: '🎓', description: 'Teaching/coaching' }, { id: 'chilling', label: 'Chilling', emoji: '😎', description: 'Hanging out at the spot' }, { id: 'driving-to-spot', label: 'On My Way', emoji: '🚗', description: 'Heading to a spot' }, { id: 'taking-a-break', label: 'Taking a Break', emoji: '🧃', description: 'Resting/hydrating' }, { id: 'done-for-today', label: 'Done for Today', emoji: '🌙', description: 'Finished skating' }, ] as const; export type SkatingStatusId = typeof SKATING_STATUSES[number]['id']; export interface UserStatus { pubkey: string; status: string; statusId?: SkatingStatusId; emoji?: string; linkedSpot?: { pubkey: string; identifier: string; title?: string; }; expiration?: number; createdAt: number; } // Parse a NIP-38 status event into our format function parseStatusEvent(event: NostrEvent): UserStatus | null { const dTag = event.tags.find(([name]) => name === 'd')?.[1]; if (dTag !== 'general') return null; // Only handle general statuses for now const status = event.content; if (!status) return null; // Find linked spot (a tag) const aTag = event.tags.find(([name]) => name === 'a')?.[1]; let linkedSpot: UserStatus['linkedSpot'] | undefined; if (aTag) { const parts = aTag.split(':'); if (parts.length >= 3 && parts[0] === '30818') { linkedSpot = { pubkey: parts[1], identifier: parts[2], }; } } // Find expiration const expirationTag = event.tags.find(([name]) => name === 'expiration')?.[1]; const expiration = expirationTag ? parseInt(expirationTag, 10) : undefined; // Check if expired if (expiration && expiration < Math.floor(Date.now() / 1000)) { return null; } // Try to match to a skating status preset const matchedStatus = SKATING_STATUSES.find(s => status.toLowerCase().includes(s.label.toLowerCase()) || status.includes(s.emoji) ); return { pubkey: event.pubkey, status, statusId: matchedStatus?.id, emoji: matchedStatus?.emoji, linkedSpot, expiration, createdAt: event.created_at, }; } // Hook to get a user's current status export function useUserStatus(pubkey: string | undefined) { const { nostr } = useNostr(); return useQuery({ queryKey: ['user-status', pubkey], queryFn: async (c) => { if (!pubkey) return null; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]); const events = await nostr.query([ { kinds: [USER_STATUS_KIND], authors: [pubkey], '#d': ['general'], limit: 1, }, ], { signal }); if (events.length === 0) return null; return parseStatusEvent(events[0]); }, enabled: !!pubkey, staleTime: 1000 * 60, // 1 minute refetchInterval: 1000 * 60 * 2, // Refetch every 2 minutes }); } // Hook to get statuses for multiple users export function useBatchUserStatuses(pubkeys: string[]) { const { nostr } = useNostr(); return useQuery({ queryKey: ['batch-user-statuses', pubkeys.join(',')], queryFn: async (c) => { if (pubkeys.length === 0) return new Map(); const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); const events = await nostr.query([ { kinds: [USER_STATUS_KIND], authors: pubkeys, '#d': ['general'], }, ], { signal }); const statusMap = new Map(); // Group by pubkey and get most recent const latestByPubkey = new Map(); for (const event of events) { const existing = latestByPubkey.get(event.pubkey); if (!existing || event.created_at > existing.created_at) { latestByPubkey.set(event.pubkey, event); } } for (const event of latestByPubkey.values()) { const status = parseStatusEvent(event); if (status) { statusMap.set(event.pubkey, status); } } return statusMap; }, enabled: pubkeys.length > 0, staleTime: 1000 * 60, refetchInterval: 1000 * 60 * 2, }); } // Hook to get users skating at a specific spot export function useWhosSkatingAtSpot(spotPubkey: string, spotIdentifier: string) { const { nostr } = useNostr(); return useQuery({ queryKey: ['whos-skating', spotPubkey, spotIdentifier], queryFn: async (c) => { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); const spotRef = `30818:${spotPubkey}:${spotIdentifier}`; // Query statuses that reference this spot const events = await nostr.query([ { kinds: [USER_STATUS_KIND], '#a': [spotRef], '#d': ['general'], limit: 50, }, ], { signal }); const now = Math.floor(Date.now() / 1000); const statuses: UserStatus[] = []; const seenPubkeys = new Set(); // Sort by created_at descending to get most recent first const sortedEvents = events.sort((a, b) => b.created_at - a.created_at); for (const event of sortedEvents) { if (seenPubkeys.has(event.pubkey)) continue; const status = parseStatusEvent(event); if (status) { // Only include if not expired and created within last 4 hours if (!status.expiration || status.expiration > now) { if (now - status.createdAt < 4 * 60 * 60) { statuses.push(status); seenPubkeys.add(event.pubkey); } } } } return statuses; }, staleTime: 1000 * 30, // 30 seconds refetchInterval: 1000 * 60, // 1 minute }); } // Hook to set current user's status export function useSetUserStatus() { const { user } = useCurrentUser(); const { mutateAsync: publishEvent } = useNostrPublish(); const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ statusId, customStatus, linkedSpot, expirationMinutes, }: { statusId?: SkatingStatusId; customStatus?: string; linkedSpot?: { pubkey: string; identifier: string }; expirationMinutes?: number; }) => { if (!user) throw new Error('Must be logged in'); // Build status content let content = ''; if (statusId) { const preset = SKATING_STATUSES.find(s => s.id === statusId); if (preset) { content = `${preset.emoji} ${preset.label}`; } } if (customStatus) { content = customStatus; } // Build tags const tags: string[][] = [ ['d', 'general'], // Required for NIP-38 ]; // Add spot reference if provided if (linkedSpot) { tags.push(['a', `30818:${linkedSpot.pubkey}:${linkedSpot.identifier}`]); } // Add expiration if provided if (expirationMinutes && expirationMinutes > 0) { const expiration = Math.floor(Date.now() / 1000) + expirationMinutes * 60; tags.push(['expiration', expiration.toString()]); } await publishEvent({ kind: USER_STATUS_KIND, content, tags, }); return { content, linkedSpot }; }, onSuccess: () => { // Invalidate status queries queryClient.invalidateQueries({ queryKey: ['user-status'] }); queryClient.invalidateQueries({ queryKey: ['whos-skating'] }); }, }); } // Hook to clear current user's status export function useClearUserStatus() { const { user } = useCurrentUser(); const { mutateAsync: publishEvent } = useNostrPublish(); const queryClient = useQueryClient(); return useMutation({ mutationFn: async () => { if (!user) throw new Error('Must be logged in'); // Publish empty status to clear await publishEvent({ kind: USER_STATUS_KIND, content: '', tags: [['d', 'general']], }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['user-status'] }); queryClient.invalidateQueries({ queryKey: ['whos-skating'] }); }, }); }