import { useState, useEffect, useRef, useCallback } from 'react'; import { Link, useSearchParams } from 'react-router'; import { useClient } from 'hooks/useClient'; import { toast } from 'sonner'; import { MessageCircle, Send, Plus, RefreshCw, X } from 'lucide-react'; import { formatClock, soulseekPeerPath, PEER_PARAM, type SlskdConversation, type SlskdPrivateMessage } from './shared'; // Chat panel — private (1:1) messaging. GET /conversations lists the peers we have threads with; GET // /conversations/{user}/messages returns a thread (polled while it's open). Sending POSTs a bare JSON // string body to /conversations/{user}, matching slskd's [FromBody] string endpoint; viewing a thread // PUTs /conversations/{user} to acknowledge (clear the unread badge). Basic version: a peer rail on the // left, transcript + composer on the right. const POLL_MS = 2500; const isMine = (m: SlskdPrivateMessage) => (m.direction ?? '').toLowerCase() === 'out'; export const SoulseekChat = () => { const client = useClient(); const [params, setParams] = useSearchParams(); const selected = params.get(PEER_PARAM)?.trim() || null; const [conversations, setConversations] = useState([]); const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(''); const [peerName, setPeerName] = useState(''); const scrollRef = useRef(null); const loadConversations = useCallback(async () => { try { setConversations(await client.get('/soulseek/api/v0/conversations')); } catch { /* status panel surfaces connection errors */ } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { loadConversations(); }, [loadConversations]); // Poll the selected thread's messages while it's open, and acknowledge to clear its unread badge. useEffect(() => { if (!selected) { setMessages([]); return; } let cancelled = false; const tick = () => client .get(`/soulseek/api/v0/conversations/${encodeURIComponent(selected)}/messages`) .then((m) => { if (cancelled) return; setMessages(m); setConversations((prev) => prev.map((c) => (c.username === selected ? { ...c, unAcknowledgedMessageCount: 0 } : c)), ); client.put(`/soulseek/api/v0/conversations/${encodeURIComponent(selected)}`).catch(() => {}); }) .catch(() => {}); tick(); const timer = setInterval(tick, POLL_MS); return () => { cancelled = true; clearInterval(timer); }; // useClient() is a fresh object each render — re-poll only when the selected peer changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [selected]); // Stick to the bottom as new messages arrive / when switching threads. useEffect(() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }, [messages.length, selected]); // Starts a thread that isn't in the list yet, so it stays a form submit rather than a link: it adds the // peer locally *and then* opens it, and a link cannot express the "and then". const openPeer = (name: string) => { const target = name.trim(); if (!target) return; setPeerName(''); setConversations((prev) => (prev.some((c) => c.username === target) ? prev : [...prev, { username: target }])); setParams((prev) => { const next = new URLSearchParams(prev); next.set(PEER_PARAM, target); return next; }); }; const close = async (name: string) => { try { await client.delete(`/soulseek/api/v0/conversations/${encodeURIComponent(name)}`); } catch { /* optimistic — drop it locally regardless */ } setConversations((prev) => prev.filter((c) => c.username !== name)); // Closing the open thread leaves nothing open. `replace`, because the thread you just deleted is not // somewhere Back should return you to. if (name === selected) { setParams( (prev) => { const next = new URLSearchParams(prev); next.delete(PEER_PARAM); return next; }, { replace: true }, ); } }; const send = async () => { const text = draft.trim(); if (!text || !selected) return; setDraft(''); try { await client.post(`/soulseek/api/v0/conversations/${encodeURIComponent(selected)}`, text); } catch (err) { setDraft(text); toast.error(`Send failed: ${err instanceof Error ? err.message : String(err)}`); } }; return (
{/* Peer rail */}
Conversations
{conversations.length === 0 && (

No conversations yet.

)} {conversations.map((c) => { const active = c.username === selected; const unread = c.unAcknowledgedMessageCount ?? 0; return (
{/* The close button is a sibling, never nested inside the anchor. */} {c.username} {unread > 0 && !active && ( {unread} )}
); })}
{/* Start a conversation */}
{ ev.preventDefault(); openPeer(peerName); }} className="flex items-center gap-1.5" > setPeerName(ev.target.value)} placeholder="Message a user…" className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus:border-primary/50" />
{/* Transcript + composer */}
{!selected ? (

Pick a conversation, or message a user to start one.

) : ( <>
{selected}
{messages.length === 0 ? (

No messages yet. Say hello.

) : (
{messages.map((m, i) => { const mine = isMine(m); return (
{m.message}
{formatClock(m.timestamp)}
); })}
)}
{ ev.preventDefault(); send(); }} className="flex shrink-0 items-center gap-2 border-t p-3" > setDraft(ev.target.value)} placeholder={`Message ${selected}…`} className="h-9 min-w-0 flex-1 rounded-lg border bg-background px-3 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20" />
)}
); };