import { useEffect, useMemo, useRef, useState } from 'react'; import { formatDistanceToNowStrict } from 'date-fns'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { MessageCircle, Send, Radio, Info, Globe } from 'lucide-react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ToggleGroup, ToggleGroupItem, } from '@/components/ui/toggle-group'; import { LoginArea } from '@/components/auth/LoginArea'; import { NoteContent } from '@/components/NoteContent'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useToast } from '@/hooks/useToast'; import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { genUserName } from '@/lib/genUserName'; import { TrustFilter, filterByTrustLevel, type TrustFilterLevel, } from '@/components/TrustFilter'; import { useSpotChat, useSendSpotChat, channelGeohash, CHAT_PRECISION_LABEL, type ChatPrecision, type SpotChatMessage, } from '@/hooks/useSpotChat'; import { cn } from '@/lib/utils'; interface SpotChatProps { /** The geohash to anchor this chat to (from a spot or the map center). */ geohash: string; /** Whether to show the Block/Neighborhood/City precision selector. */ showPrecisionToggle?: boolean; /** Initial precision. */ defaultPrecision?: ChatPrecision; className?: string; } /** A single chat message row, resolving the author's profile. */ function ChatMessageRow({ message, isOwn, }: { message: SpotChatMessage; isOwn: boolean; }) { const author = useAuthor(message.pubkey); const metadata = author.data?.metadata; const name = metadata?.name ?? genUserName(message.pubkey); const npub = useMemo(() => nip19.npubEncode(message.pubkey), [message.pubkey]); if (message.isAction) { return (
✳ {name} {' '} {message.content}
); } return (
{name.slice(0, 2).toUpperCase()}
{name} {formatDistanceToNowStrict(message.createdAt * 1000, { addSuffix: true })}
); } export function SpotChat({ geohash, showPrecisionToggle = false, defaultPrecision = 'block', className, }: SpotChatProps) { const { user } = useCurrentUser(); const { toast } = useToast(); const [precision, setPrecision] = useState(defaultPrecision); const [trustLevel, setTrustLevel] = useState('all'); const [draft, setDraft] = useState(''); const scrollRef = useRef(null); const { data: rawMessages, isLoading } = useSpotChat(geohash, precision); const { mutateAsync: sendMessage, isPending } = useSendSpotChat(); const channel = channelGeohash(geohash, precision); // Trust scores for everyone who posted in this channel. const authorPubkeys = useMemo( () => Array.from(new Set((rawMessages ?? []).map((m) => m.pubkey))), [rawMessages], ); const { data: trustScores } = useBatchTrustScores( trustLevel === 'all' ? [] : authorPubkeys, ); // Apply the trust filter. The user's own messages are always kept so their // sent messages never vanish behind the filter. const messages = useMemo(() => { if (!rawMessages) return rawMessages; if (trustLevel === 'all') return rawMessages; const filtered = filterByTrustLevel( rawMessages, trustLevel, (pk) => trustScores?.get(pk), ); const kept = new Set(filtered.map((m) => m.id)); return rawMessages.filter((m) => kept.has(m.id) || m.pubkey === user?.pubkey); }, [rawMessages, trustLevel, trustScores, user?.pubkey]); const hiddenCount = (rawMessages?.length ?? 0) - (messages?.length ?? 0); // Auto-scroll to newest message when the list grows. const lastCount = useRef(0); useEffect(() => { const count = messages?.length ?? 0; if (count !== lastCount.current) { lastCount.current = count; scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); } }, [messages]); const handleSend = async (e: React.FormEvent) => { e.preventDefault(); const text = draft.trim(); if (!text) return; // IRC-style /who — list recent participants (easter-egg power-user command). if (text === '/who' || text.startsWith('/who ')) { const recent = new Set((rawMessages ?? []).map((m) => m.pubkey)); toast({ title: `${recent.size} skater${recent.size === 1 ? '' : 's'} active here`, description: recent.size > 0 ? 'People who posted in this channel in the last 24h.' : 'Be the first to say something.', }); setDraft(''); return; } try { await sendMessage({ geohash, precision, content: text }); setDraft(''); } catch (err) { toast({ title: 'Message failed', description: err instanceof Error ? err.message : 'Could not send message.', variant: 'destructive', }); } }; return (
Spot Chat Public
#{channel}
{showPrecisionToggle && (
v && setPrecision(v as ChatPrecision)} className="justify-start" size="sm" variant="outline" > {(Object.keys(CHAT_PRECISION_LABEL) as ChatPrecision[]).map((p) => ( {CHAT_PRECISION_LABEL[p]} ))}
)}
{isLoading && (
Loading chat…
)} {!isLoading && (!messages || messages.length === 0) && (
{hiddenCount > 0 ? (

{hiddenCount} message{hiddenCount === 1 ? '' : 's'} hidden by your trust filter. Lower the filter to see more.

) : (

No messages yet in this local channel. Say hi — "anyone skating tonight?"

)}
)} {messages?.map((m) => ( ))} {!isLoading && messages && messages.length > 0 && hiddenCount > 0 && (
{hiddenCount} message{hiddenCount === 1 ? '' : 's'} hidden by your trust filter
)}
{user ? ( <>
setDraft(e.target.value)} placeholder="Message this spot… (try /me or /who)" maxLength={500} className="flex-1" />

Anyone can read this — messages are public & ephemeral. For private chats, use Direct Messages.

) : (

Log in to join the local chat.

)}
); }