import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { Link, useSearchParams } from 'react-router'; import { useClient } from 'hooks/useClient'; import { toast } from 'sonner'; import { Hash, Users, LogOut, Send, Plus, RefreshCw } from 'lucide-react'; import { formatClock, soulseekRoomPath, ROOM_PARAM, type SlskdRoom, type SlskdRoomInfo } from './shared'; // Rooms panel — join Soulseek chat rooms and talk in them. GET /rooms/joined lists the room names we're // in; GET /rooms/joined/{name} inlines that room's users + messages (polled while it's open); GET // /rooms/available lists everything else to join. Messages/joins POST a bare JSON string body, matching // slskd's [FromBody] string endpoints. Basic version: a room rail on the left, transcript + composer on // the right. const POLL_MS = 2500; const AVAILABLE_LIMIT = 250; export const SoulseekRooms = () => { const client = useClient(); const [joined, setJoined] = useState([]); const [available, setAvailable] = useState([]); const [params, setParams] = useSearchParams(); const selected = params.get(ROOM_PARAM)?.trim() || null; const [room, setRoom] = useState(null); const [draft, setDraft] = useState(''); const [joinName, setJoinName] = useState(''); const [busy, setBusy] = useState(false); const scrollRef = useRef(null); const loadJoined = useCallback(async () => { try { setJoined(await client.get('/soulseek/api/v0/rooms/joined')); } catch { /* status panel surfaces connection errors */ } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const loadAvailable = useCallback(async () => { try { setAvailable(await client.get('/soulseek/api/v0/rooms/available')); } catch { /* ignore */ } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { loadJoined(); loadAvailable(); }, [loadJoined, loadAvailable]); // Poll the selected room's transcript + user list while it's open. useEffect(() => { if (!selected) { setRoom(null); return; } let cancelled = false; const tick = () => client .get(`/soulseek/api/v0/rooms/joined/${encodeURIComponent(selected)}`) .then((r) => !cancelled && setRoom(r)) .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 room changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [selected]); const messages = room?.messages ?? []; // Stick to the bottom as new messages arrive / when switching rooms. useEffect(() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }, [messages.length, selected]); const notJoined = useMemo(() => { const have = new Set(joined); return available .filter((r) => !have.has(r.name)) .sort((a, b) => b.userCount - a.userCount) .slice(0, AVAILABLE_LIMIT); // eslint-disable-next-line react-hooks/exhaustive-deps }, [available, joined]); const join = async (name: string) => { const target = name.trim(); if (!target || busy) return; setBusy(true); try { await client.post('/soulseek/api/v0/rooms/joined', target); setJoinName(''); setJoined((prev) => (prev.includes(target) ? prev : [...prev, target])); // Joins the room *and then* opens it, so this stays a button — a link cannot express the "and then". setParams((prev) => { const next = new URLSearchParams(prev); next.set(ROOM_PARAM, target); return next; }); toast.success(`Joined ${target}`); } catch (err) { toast.error(`Couldn't join ${target}: ${err instanceof Error ? err.message : String(err)}`); } finally { setBusy(false); } }; const leave = async (name: string) => { try { await client.delete(`/soulseek/api/v0/rooms/joined/${encodeURIComponent(name)}`); setJoined((prev) => prev.filter((n) => n !== name)); // Leaving the open room leaves nothing open. `replace`, because a room you are no longer in is not // somewhere Back should return you to. if (name === selected) { setParams( (prev) => { const next = new URLSearchParams(prev); next.delete(ROOM_PARAM); return next; }, { replace: true }, ); } } catch (err) { toast.error(`Couldn't leave ${name}: ${err instanceof Error ? err.message : String(err)}`); } }; const send = async () => { const text = draft.trim(); if (!text || !selected) return; setDraft(''); try { await client.post(`/soulseek/api/v0/rooms/joined/${encodeURIComponent(selected)}/messages`, text); } catch (err) { setDraft(text); toast.error(`Send failed: ${err instanceof Error ? err.message : String(err)}`); } }; return (
{/* Room rail */}
Joined
{joined.length === 0 &&

No rooms joined yet.

} {joined.map((name) => { const active = name === selected; return (
{/* The leave button is a sibling, never nested inside the anchor. */} {name}
); })}
{/* Join a room */}
{ ev.preventDefault(); join(joinName); }} className="flex items-center gap-1.5" > setJoinName(ev.target.value)} list="slskd-available-rooms" placeholder="Join a room…" className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus:border-primary/50" /> {notJoined.map((r) => ( ))}
{/* Transcript + composer */}
{!selected ? (

{joined.length === 0 ? 'Join a room to start chatting.' : 'Pick a room to read it.'}

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

No messages yet.

) : (
{messages.map((m, i) => (
{formatClock(m.timestamp)} {m.username} {m.message}
))}
)}
{ 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" />
)}
); };