import { useNostr } from '@nostrify/react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useCallback } from 'react'; import type { NostrEvent } from '@nostrify/nostrify'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { SPOT_CHAT_KIND } from '@/lib/types'; /** * Spot Chat — ephemeral, geohash-anchored local chat channels. * * Inspired by BitChat's location-channel concept, reframed for skaters and * kept fully SkateSpots-native (our own kind + tag conventions, not interop). * * Each message is an ephemeral event (kind 24899) tagged with a geohash `g`. * Anyone viewing the same geohash channel sees the same live conversation. * Because the events are ephemeral, relays are not expected to store them * long-term — matching the "here and now" vibe of a spot's side-groupchat. */ /** A single parsed chat message. */ export interface SpotChatMessage { id: string; pubkey: string; content: string; createdAt: number; geohash: string; /** IRC-style action message (`/me does a kickflip`). */ isAction: boolean; } /** Geohash precision presets, mirroring BitChat's Block / Neighborhood / City. */ export type ChatPrecision = 'block' | 'neighborhood' | 'city'; export const CHAT_PRECISION_LENGTH: Record = { block: 8, // ~40m neighborhood: 6, // ~1.2km city: 5, // ~5km }; export const CHAT_PRECISION_LABEL: Record = { block: 'Block', neighborhood: 'Neighborhood', city: 'City', }; /** * Normalize a geohash to a given precision (channel key). * Lower precision = larger area = more people in the channel. */ export function channelGeohash(geohash: string, precision: ChatPrecision): string { return geohash.slice(0, CHAT_PRECISION_LENGTH[precision]).toLowerCase(); } /** Parse a raw event into a chat message, or null if invalid. */ function parseSpotChatMessage(event: NostrEvent, channel: string): SpotChatMessage | null { if (event.kind !== SPOT_CHAT_KIND) return null; const g = event.tags.find(([name]) => name === 'g')?.[1]?.toLowerCase(); if (!g) return null; // The message must belong to this channel (its geohash starts with the // channel key, so a block-level message shows up in the neighborhood channel). if (!g.startsWith(channel)) return null; const content = event.content.trim(); if (!content || content.length > 500) return null; const isAction = content.startsWith('/me '); return { id: event.id, pubkey: event.pubkey, content: isAction ? content.slice(4).trim() : content, createdAt: event.created_at, geohash: g, isAction, }; } /** * Subscribe to (poll) messages for a geohash channel. * Polls every few seconds so the channel feels live without a persistent socket. */ export function useSpotChat(geohash: string | undefined, precision: ChatPrecision = 'block') { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const channel = geohash ? channelGeohash(geohash, precision) : ''; return useQuery({ queryKey: ['spot-chat', channel, getQueryKeySuffix()], queryFn: async (c) => { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); // Only keep messages from roughly the last 24h — this is "here and now" chat. const since = Math.floor(Date.now() / 1000) - 24 * 60 * 60; const events = await querySkateContent( [ { kinds: [SPOT_CHAT_KIND], '#g': [channel], since, limit: 200, }, ], { signal }, ); const messages: SpotChatMessage[] = []; const seen = new Set(); for (const event of events) { if (seen.has(event.id)) continue; seen.add(event.id); const msg = parseSpotChatMessage(event, channel); if (msg) messages.push(msg); } // Oldest first for chat display. return messages.sort((a, b) => a.createdAt - b.createdAt); }, enabled: !!channel, staleTime: 5000, refetchInterval: 8000, // Poll for new messages }); } /** * Send a message to a geohash channel. * Publishes to the community relay + the user's write relays so both * SkateSpots users and the sender's own network see it. */ export function useSendSpotChat() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { getWriteRelays } = useSkateRelays(); const queryClient = useQueryClient(); const send = useCallback( async (geohash: string, precision: ChatPrecision, content: string) => { if (!user) throw new Error('You must be logged in to chat'); const channel = channelGeohash(geohash, precision); const trimmed = content.trim().slice(0, 500); if (!trimmed) return; const event = await user.signer.signEvent({ kind: SPOT_CHAT_KIND, content: trimmed, created_at: Math.floor(Date.now() / 1000), tags: [ ['g', channel], ['n', 'skate'], // SkateSpots-native namespace marker ['alt', `Spot Chat message in geohash ${channel}`], ], }); const relays = getWriteRelays(); await Promise.allSettled( relays.map((url) => nostr.relay(url).event(event, { signal: AbortSignal.timeout(8000) })), ); return event; }, [user, nostr, getWriteRelays], ); return useMutation({ mutationFn: async ({ geohash, precision, content, }: { geohash: string; precision: ChatPrecision; content: string; }) => { return send(geohash, precision, content); }, onSuccess: (_data, variables) => { const channel = channelGeohash(variables.geohash, variables.precision); queryClient.invalidateQueries({ queryKey: ['spot-chat', channel] }); }, }); }