import { useEffect, useMemo, useRef, useState } from 'react'; import { formatDistanceToNowStrict } from '@/lib/dateFnsI18n'; 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 { useTranslation } from '@/contexts/language'; import { useAuthor } from '@/hooks/useAuthor'; import { useToast } from '@/hooks/useToast'; import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { genUserName } from '@/lib/genUserName'; import { TrustFilter } from '@/components/TrustFilter'; import { filterByTrustLevel, type TrustFilterLevel, } from '@/lib/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 { t } = useTranslation(); 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 === 1 ? t('spotUi.skatersActiveOne', { count: recent.size }) : t('spotUi.skatersActiveOther', { count: recent.size }), description: recent.size > 0 ? t('spotUi.skatersActiveDesc') : t('spotUi.beFirstToSay'), }); setDraft(''); return; } try { await sendMessage({ geohash, precision, content: text }); setDraft(''); } catch (err) { toast({ title: t('spotUi.messageFailed'), description: err instanceof Error ? err.message : t('spotUi.couldNotSendMessage'), variant: 'destructive', }); } }; return (
{t('spotUi.spotChat')} {t('spotUi.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 && (
{t('spotUi.loadingChat')}
)} {!isLoading && (!messages || messages.length === 0) && (
{hiddenCount > 0 ? (

{hiddenCount === 1 ? t('spotUi.messagesHiddenOne', { count: hiddenCount }) : t('spotUi.messagesHiddenOther', { count: hiddenCount })}

) : (

{t('spotUi.noMessagesYet')}

)}
)} {messages?.map((m) => ( ))} {!isLoading && messages && messages.length > 0 && hiddenCount > 0 && (
{hiddenCount === 1 ? t('spotUi.messagesHiddenShortOne', { count: hiddenCount }) : t('spotUi.messagesHiddenShortOther', { count: hiddenCount })}
)}
{user ? ( <>
setDraft(e.target.value)} placeholder={t('spotUi.messagePlaceholder')} maxLength={500} className="flex-1" />

{t('spotUi.chatPublicNote')}

) : (

{t('spotUi.logInToChat')}

)}
); }