put the soulseek room and chat peer in the url

The last two selections in Soulseek still held in useState. `?room=` and
`?peer=` now own them, the rails are links, and the leave/close buttons stay
siblings of the anchor.

Both rails auto-selected the first entry on load, which is the reason the
selection was local: there was nowhere to put an answer the user had not given.
The bare section is a real state now — nothing open — and both panels already
had the empty pane to say so. Rooms' pane said "Join a room to start chatting"
unconditionally, which was wrong once you could be joined to rooms with none
open, so it now distinguishes the two.

Join and "message a user" stay buttons: each writes something and *then* opens
it, which a link cannot express.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 13:04:19 +00:00
co-authored by Claude Opus 5
parent 33492262b5
commit 5a2ef6a0a0
3 changed files with 81 additions and 29 deletions
@@ -1,8 +1,9 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { Link, useSearchParams } from 'react-router';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { MessageCircle, Send, Plus, RefreshCw, X } from 'lucide-react'; import { MessageCircle, Send, Plus, RefreshCw, X } from 'lucide-react';
import { formatClock, type SlskdConversation, type SlskdPrivateMessage } from './shared'; 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 // 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 // /conversations/{user}/messages returns a thread (polled while it's open). Sending POSTs a bare JSON
@@ -16,8 +17,9 @@ const isMine = (m: SlskdPrivateMessage) => (m.direction ?? '').toLowerCase() ===
export const SoulseekChat = () => { export const SoulseekChat = () => {
const client = useClient(); const client = useClient();
const [params, setParams] = useSearchParams();
const selected = params.get(PEER_PARAM)?.trim() || null;
const [conversations, setConversations] = useState<SlskdConversation[]>([]); const [conversations, setConversations] = useState<SlskdConversation[]>([]);
const [selected, setSelected] = useState<string | null>(null);
const [messages, setMessages] = useState<SlskdPrivateMessage[]>([]); const [messages, setMessages] = useState<SlskdPrivateMessage[]>([]);
const [draft, setDraft] = useState(''); const [draft, setDraft] = useState('');
const [peerName, setPeerName] = useState(''); const [peerName, setPeerName] = useState('');
@@ -25,9 +27,7 @@ export const SoulseekChat = () => {
const loadConversations = useCallback(async () => { const loadConversations = useCallback(async () => {
try { try {
const list = await client.get<SlskdConversation[]>('/slskd/api/v0/conversations'); setConversations(await client.get<SlskdConversation[]>('/slskd/api/v0/conversations'));
setConversations(list);
setSelected((cur) => cur ?? list[0]?.username ?? null);
} catch { } catch {
/* status panel surfaces connection errors */ /* status panel surfaces connection errors */
} }
@@ -73,12 +73,18 @@ export const SoulseekChat = () => {
if (el) el.scrollTop = el.scrollHeight; if (el) el.scrollTop = el.scrollHeight;
}, [messages.length, selected]); }, [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 openPeer = (name: string) => {
const target = name.trim(); const target = name.trim();
if (!target) return; if (!target) return;
setPeerName(''); setPeerName('');
setSelected(target);
setConversations((prev) => (prev.some((c) => c.username === target) ? prev : [...prev, { username: target }])); 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) => { const close = async (name: string) => {
@@ -87,11 +93,19 @@ export const SoulseekChat = () => {
} catch { } catch {
/* optimistic — drop it locally regardless */ /* optimistic — drop it locally regardless */
} }
setConversations((prev) => { setConversations((prev) => prev.filter((c) => c.username !== name));
const next = prev.filter((c) => c.username !== name); // Closing the open thread leaves nothing open. `replace`, because the thread you just deleted is not
setSelected((cur) => (cur === name ? next[0]?.username ?? null : cur)); // somewhere Back should return you to.
return next; if (name === selected) {
}); setParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete(PEER_PARAM);
return next;
},
{ replace: true },
);
}
}; };
const send = async () => { const send = async () => {
@@ -130,14 +144,15 @@ export const SoulseekChat = () => {
active ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground' active ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`} }`}
> >
<button {/* The close button is a sibling, never nested inside the anchor. */}
type="button" <Link
onClick={() => setSelected(c.username)} to={soulseekPeerPath(c.username)}
className="flex min-w-0 flex-1 items-center gap-1.5 text-left" className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
aria-current={active ? 'true' : undefined}
> >
<MessageCircle className="h-3.5 w-3.5 shrink-0" /> <MessageCircle className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{c.username}</span> <span className="truncate">{c.username}</span>
</button> </Link>
{unread > 0 && !active && ( {unread > 0 && !active && (
<span className="shrink-0 rounded-full bg-primary px-1.5 text-[10px] font-medium tabular-nums text-primary-foreground"> <span className="shrink-0 rounded-full bg-primary px-1.5 text-[10px] font-medium tabular-nums text-primary-foreground">
{unread} {unread}
@@ -1,8 +1,9 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { Link, useSearchParams } from 'react-router';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Hash, Users, LogOut, Send, Plus, RefreshCw } from 'lucide-react'; import { Hash, Users, LogOut, Send, Plus, RefreshCw } from 'lucide-react';
import { formatClock, type SlskdRoom, type SlskdRoomInfo } from './shared'; 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 // 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 // in; GET /rooms/joined/{name} inlines that room's users + messages (polled while it's open); GET
@@ -17,7 +18,8 @@ export const SoulseekRooms = () => {
const client = useClient(); const client = useClient();
const [joined, setJoined] = useState<string[]>([]); const [joined, setJoined] = useState<string[]>([]);
const [available, setAvailable] = useState<SlskdRoomInfo[]>([]); const [available, setAvailable] = useState<SlskdRoomInfo[]>([]);
const [selected, setSelected] = useState<string | null>(null); const [params, setParams] = useSearchParams();
const selected = params.get(ROOM_PARAM)?.trim() || null;
const [room, setRoom] = useState<SlskdRoom | null>(null); const [room, setRoom] = useState<SlskdRoom | null>(null);
const [draft, setDraft] = useState(''); const [draft, setDraft] = useState('');
const [joinName, setJoinName] = useState(''); const [joinName, setJoinName] = useState('');
@@ -26,9 +28,7 @@ export const SoulseekRooms = () => {
const loadJoined = useCallback(async () => { const loadJoined = useCallback(async () => {
try { try {
const names = await client.get<string[]>('/slskd/api/v0/rooms/joined'); setJoined(await client.get<string[]>('/slskd/api/v0/rooms/joined'));
setJoined(names);
setSelected((cur) => cur ?? names[0] ?? null);
} catch { } catch {
/* status panel surfaces connection errors */ /* status panel surfaces connection errors */
} }
@@ -95,7 +95,12 @@ export const SoulseekRooms = () => {
await client.post('/slskd/api/v0/rooms/joined', target); await client.post('/slskd/api/v0/rooms/joined', target);
setJoinName(''); setJoinName('');
setJoined((prev) => (prev.includes(target) ? prev : [...prev, target])); setJoined((prev) => (prev.includes(target) ? prev : [...prev, target]));
setSelected(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}`); toast.success(`Joined ${target}`);
} catch (err) { } catch (err) {
toast.error(`Couldn't join ${target}: ${err instanceof Error ? err.message : String(err)}`); toast.error(`Couldn't join ${target}: ${err instanceof Error ? err.message : String(err)}`);
@@ -107,11 +112,19 @@ export const SoulseekRooms = () => {
const leave = async (name: string) => { const leave = async (name: string) => {
try { try {
await client.delete(`/slskd/api/v0/rooms/joined/${encodeURIComponent(name)}`); await client.delete(`/slskd/api/v0/rooms/joined/${encodeURIComponent(name)}`);
setJoined((prev) => { setJoined((prev) => prev.filter((n) => n !== name));
const next = prev.filter((n) => n !== name); // Leaving the open room leaves nothing open. `replace`, because a room you are no longer in is not
setSelected((cur) => (cur === name ? next[0] ?? null : cur)); // somewhere Back should return you to.
return next; if (name === selected) {
}); setParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete(ROOM_PARAM);
return next;
},
{ replace: true },
);
}
} catch (err) { } catch (err) {
toast.error(`Couldn't leave ${name}: ${err instanceof Error ? err.message : String(err)}`); toast.error(`Couldn't leave ${name}: ${err instanceof Error ? err.message : String(err)}`);
} }
@@ -150,10 +163,15 @@ export const SoulseekRooms = () => {
active ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground' active ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`} }`}
> >
<button type="button" onClick={() => setSelected(name)} className="flex min-w-0 flex-1 items-center gap-1.5 text-left"> {/* The leave button is a sibling, never nested inside the anchor. */}
<Link
to={soulseekRoomPath(name)}
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
aria-current={active ? 'true' : undefined}
>
<Hash className="h-3.5 w-3.5 shrink-0" /> <Hash className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{name}</span> <span className="truncate">{name}</span>
</button> </Link>
<button <button
type="button" type="button"
onClick={() => leave(name)} onClick={() => leave(name)}
@@ -207,7 +225,9 @@ export const SoulseekRooms = () => {
{!selected ? ( {!selected ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground"> <div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
<Hash className="h-8 w-8" /> <Hash className="h-8 w-8" />
<p className="text-sm">Join a room to start chatting.</p> <p className="text-sm">
{joined.length === 0 ? 'Join a room to start chatting.' : 'Pick a room to read it.'}
</p>
</div> </div>
) : ( ) : (
<> <>
@@ -288,6 +288,23 @@ export const USER_PARAM = 'user';
export const soulseekUserPath = (username: string) => export const soulseekUserPath = (username: string) =>
`${soulseekSectionPath('users')}?${USER_PARAM}=${encodeURIComponent(username)}`; `${soulseekSectionPath('users')}?${USER_PARAM}=${encodeURIComponent(username)}`;
// Which room the Rooms section is showing — `/soulseek/rooms?room=<name>` — and which thread the Chat
// section is showing — `/soulseek/chat?peer=<name>`. Two params rather than one because they are two
// sections: they never appear together, and a shared name would make `?thing=` mean different kinds of
// thing depending on the path.
//
// Both rails used to auto-select the first entry on load, which is why they held the selection locally:
// there was nowhere else to put an answer the user had not given. The bare section is now a real state —
// "nothing open" — and both panels already had the empty pane to say so.
export const ROOM_PARAM = 'room';
export const PEER_PARAM = 'peer';
export const soulseekRoomPath = (name: string) =>
`${soulseekSectionPath('rooms')}?${ROOM_PARAM}=${encodeURIComponent(name)}`;
export const soulseekPeerPath = (username: string) =>
`${soulseekSectionPath('chat')}?${PEER_PARAM}=${encodeURIComponent(username)}`;
// A past search, as listed by GET /searches (no responses inlined). // A past search, as listed by GET /searches (no responses inlined).
// Which search's results are open — `/soulseek/search?search=<id>`, read from the URL rather than held in a // Which search's results are open — `/soulseek/search?search=<id>`, read from the URL rather than held in a
// panel channel or local state. See docs/navigation-audit.md. // panel channel or local state. See docs/navigation-audit.md.