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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user