diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekChat.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekChat.tsx index 692e9568..10b030f4 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekChat.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekChat.tsx @@ -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([]); - const [selected, setSelected] = useState(null); const [messages, setMessages] = useState([]); 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('/slskd/api/v0/conversations'); - setConversations(list); - setSelected((cur) => cur ?? list[0]?.username ?? null); + setConversations(await client.get('/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' }`} > - + {unread > 0 && !active && ( {unread} diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekRooms.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekRooms.tsx index 40c36285..f82373f4 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekRooms.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekRooms.tsx @@ -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([]); const [available, setAvailable] = useState([]); - const [selected, setSelected] = useState(null); + 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(''); @@ -26,9 +28,7 @@ export const SoulseekRooms = () => { const loadJoined = useCallback(async () => { try { - const names = await client.get('/slskd/api/v0/rooms/joined'); - setJoined(names); - setSelected((cur) => cur ?? names[0] ?? null); + setJoined(await client.get('/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' }`} > - +