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 { 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, 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
// /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 = () => {
const client = useClient();
const [params, setParams] = useSearchParams();
const selected = params.get(PEER_PARAM)?.trim() || null;
const [conversations, setConversations] = useState<SlskdConversation[]>([]);
const [selected, setSelected] = useState<string | null>(null);
const [messages, setMessages] = useState<SlskdPrivateMessage[]>([]);
const [draft, setDraft] = useState('');
const [peerName, setPeerName] = useState('');
@@ -25,9 +27,7 @@ export const SoulseekChat = () => {
const loadConversations = useCallback(async () => {
try {
const list = await client.get<SlskdConversation[]>('/slskd/api/v0/conversations');
setConversations(list);
setSelected((cur) => cur ?? list[0]?.username ?? null);
setConversations(await client.get<SlskdConversation[]>('/slskd/api/v0/conversations'));
} catch {
/* status panel surfaces connection errors */
}
@@ -73,12 +73,18 @@ export const SoulseekChat = () => {
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('');
setSelected(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) => {
@@ -87,11 +93,19 @@ export const SoulseekChat = () => {
} catch {
/* optimistic — drop it locally regardless */
}
setConversations((prev) => {
const next = prev.filter((c) => c.username !== name);
setSelected((cur) => (cur === name ? next[0]?.username ?? null : cur));
return next;
});
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 () => {
@@ -130,14 +144,15 @@ export const SoulseekChat = () => {
active ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<button
type="button"
onClick={() => setSelected(c.username)}
{/* The close button is a sibling, never nested inside the anchor. */}
<Link
to={soulseekPeerPath(c.username)}
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" />
<span className="truncate">{c.username}</span>
</button>
</Link>
{unread > 0 && !active && (
<span className="shrink-0 rounded-full bg-primary px-1.5 text-[10px] font-medium tabular-nums text-primary-foreground">
{unread}
@@ -1,8 +1,9 @@
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, 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
// 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 [joined, setJoined] = useState<string[]>([]);
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 [draft, setDraft] = useState('');
const [joinName, setJoinName] = useState('');
@@ -26,9 +28,7 @@ export const SoulseekRooms = () => {
const loadJoined = useCallback(async () => {
try {
const names = await client.get<string[]>('/slskd/api/v0/rooms/joined');
setJoined(names);
setSelected((cur) => cur ?? names[0] ?? null);
setJoined(await client.get<string[]>('/slskd/api/v0/rooms/joined'));
} catch {
/* status panel surfaces connection errors */
}
@@ -95,7 +95,12 @@ export const SoulseekRooms = () => {
await client.post('/slskd/api/v0/rooms/joined', target);
setJoinName('');
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}`);
} catch (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) => {
try {
await client.delete(`/slskd/api/v0/rooms/joined/${encodeURIComponent(name)}`);
setJoined((prev) => {
const next = prev.filter((n) => n !== name);
setSelected((cur) => (cur === name ? next[0] ?? null : cur));
return next;
});
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)}`);
}
@@ -150,10 +163,15 @@ export const SoulseekRooms = () => {
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" />
<span className="truncate">{name}</span>
</button>
</Link>
<button
type="button"
onClick={() => leave(name)}
@@ -207,7 +225,9 @@ export const SoulseekRooms = () => {
{!selected ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
<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>
) : (
<>
@@ -288,6 +288,23 @@ export const USER_PARAM = 'user';
export const soulseekUserPath = (username: string) =>
`${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).
// 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.