soulseek, extracted from the platform as a plugin
Seventh plugin out, and the first that was already dead. Soulseek had been switched off since 2026-08-13: hono.ts had the router import and its mount commented out, so createSidecarProxy never ran, registerProxiedPrefix was never called, the slskd:server announcement fired into a void, and officer-slskd was not in the ecosystem at all. The permission entry had been reduced to api: [], which is why assertPermissionTotality was satisfied and the server booted clean. The UI rendered perfectly and every request 404'd. So this is a resurrection as well as a move. The mount is now /api/soulseek, derived via mountPrefix from the appName — all 47 frontend call sites repointed from /slskd/. The credential key in service_connections stays 'slskd': it names the daemon, not the route. Those two strings differ on purpose and conflating them reads the wrong row. The connection stays in core service_connections, as transmission does, so 'slskd' remains in a platform union this plugin cannot extend. Recorded in PLUGIN.md. Unlike transmission this plugin does own a schema: favourites and the two halves of the cached browse tree, which slskd has no concept of. Those three tables had been dropped from the drizzle barrel, so bun db:push would have deleted them on any server holding data. 313 palette classes converted to the semantic tone set, including a shared PHASE_TONE the three transfer views previously each copied.
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
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<string[]>([]);
|
||||
const [available, setAvailable] = useState<SlskdRoomInfo[]>([]);
|
||||
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('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadJoined = useCallback(async () => {
|
||||
try {
|
||||
setJoined(await client.get<string[]>('/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<SlskdRoomInfo[]>('/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<SlskdRoom>(`/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 (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Room rail */}
|
||||
<div className="flex w-56 shrink-0 flex-col border-r bg-muted/20">
|
||||
<div className="flex items-center justify-between px-3 py-2.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span>Joined</span>
|
||||
<button type="button" onClick={loadJoined} title="Refresh" className="rounded p-1 hover:bg-muted">
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 px-2">
|
||||
{joined.length === 0 && <p className="px-2 py-1 text-xs text-muted-foreground">No rooms joined yet.</p>}
|
||||
{joined.map((name) => {
|
||||
const active = name === selected;
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className={`group flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm transition-colors ${
|
||||
active ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{/* 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>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => leave(name)}
|
||||
title={`Leave ${name}`}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground opacity-0 transition group-hover:opacity-100 hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<LogOut className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Join a room */}
|
||||
<div className="mt-auto border-t p-2">
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
join(joinName);
|
||||
}}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<input
|
||||
value={joinName}
|
||||
onChange={(ev) => 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"
|
||||
/>
|
||||
<datalist id="slskd-available-rooms">
|
||||
{notJoined.map((r) => (
|
||||
<option key={r.name} value={r.name}>
|
||||
{r.userCount} users
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!joinName.trim() || busy}
|
||||
title="Join"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transcript + composer */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{!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">
|
||||
{joined.length === 0 ? 'Join a room to start chatting.' : 'Pick a room to read it.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2.5">
|
||||
<Hash className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-medium">{selected}</span>
|
||||
<span className="ml-auto inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Users className="h-3.5 w-3.5" /> {room?.users?.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No messages yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{messages.map((m, i) => (
|
||||
<div key={`${m.timestamp}-${i}`} className="flex gap-2 text-sm">
|
||||
<span className="shrink-0 pt-0.5 text-xs tabular-nums text-muted-foreground">{formatClock(m.timestamp)}</span>
|
||||
<span className={`shrink-0 font-medium ${m.self ? 'text-primary' : 'text-foreground'}`}>{m.username}</span>
|
||||
<span className="min-w-0 break-words text-foreground/90">{m.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
send();
|
||||
}}
|
||||
className="flex shrink-0 items-center gap-2 border-t p-3"
|
||||
>
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(ev) => 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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!draft.trim()}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground transition hover:bg-primary/90 disabled:opacity-40"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user