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.
268 lines
11 KiB
TypeScript
268 lines
11 KiB
TypeScript
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, 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
|
|
// string body to /conversations/{user}, matching slskd's [FromBody] string endpoint; viewing a thread
|
|
// PUTs /conversations/{user} to acknowledge (clear the unread badge). Basic version: a peer rail on the
|
|
// left, transcript + composer on the right.
|
|
|
|
const POLL_MS = 2500;
|
|
|
|
const isMine = (m: SlskdPrivateMessage) => (m.direction ?? '').toLowerCase() === 'out';
|
|
|
|
export const SoulseekChat = () => {
|
|
const client = useClient();
|
|
const [params, setParams] = useSearchParams();
|
|
const selected = params.get(PEER_PARAM)?.trim() || null;
|
|
const [conversations, setConversations] = useState<SlskdConversation[]>([]);
|
|
const [messages, setMessages] = useState<SlskdPrivateMessage[]>([]);
|
|
const [draft, setDraft] = useState('');
|
|
const [peerName, setPeerName] = useState('');
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
const loadConversations = useCallback(async () => {
|
|
try {
|
|
setConversations(await client.get<SlskdConversation[]>('/soulseek/api/v0/conversations'));
|
|
} catch {
|
|
/* status panel surfaces connection errors */
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadConversations();
|
|
}, [loadConversations]);
|
|
|
|
// Poll the selected thread's messages while it's open, and acknowledge to clear its unread badge.
|
|
useEffect(() => {
|
|
if (!selected) {
|
|
setMessages([]);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
const tick = () =>
|
|
client
|
|
.get<SlskdPrivateMessage[]>(`/soulseek/api/v0/conversations/${encodeURIComponent(selected)}/messages`)
|
|
.then((m) => {
|
|
if (cancelled) return;
|
|
setMessages(m);
|
|
setConversations((prev) =>
|
|
prev.map((c) => (c.username === selected ? { ...c, unAcknowledgedMessageCount: 0 } : c)),
|
|
);
|
|
client.put(`/soulseek/api/v0/conversations/${encodeURIComponent(selected)}`).catch(() => {});
|
|
})
|
|
.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 peer changes.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selected]);
|
|
|
|
// Stick to the bottom as new messages arrive / when switching threads.
|
|
useEffect(() => {
|
|
const el = scrollRef.current;
|
|
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('');
|
|
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) => {
|
|
try {
|
|
await client.delete(`/soulseek/api/v0/conversations/${encodeURIComponent(name)}`);
|
|
} catch {
|
|
/* optimistic — drop it locally regardless */
|
|
}
|
|
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 () => {
|
|
const text = draft.trim();
|
|
if (!text || !selected) return;
|
|
setDraft('');
|
|
try {
|
|
await client.post(`/soulseek/api/v0/conversations/${encodeURIComponent(selected)}`, text);
|
|
} catch (err) {
|
|
setDraft(text);
|
|
toast.error(`Send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-full overflow-hidden">
|
|
{/* Peer 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>Conversations</span>
|
|
<button type="button" onClick={loadConversations} 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">
|
|
{conversations.length === 0 && (
|
|
<p className="px-2 py-1 text-xs text-muted-foreground">No conversations yet.</p>
|
|
)}
|
|
{conversations.map((c) => {
|
|
const active = c.username === selected;
|
|
const unread = c.unAcknowledgedMessageCount ?? 0;
|
|
return (
|
|
<div
|
|
key={c.username}
|
|
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 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>
|
|
</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}
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => close(c.username)}
|
|
title={`Close ${c.username}`}
|
|
className="shrink-0 rounded p-0.5 text-muted-foreground opacity-0 transition group-hover:opacity-100 hover:bg-muted hover:text-foreground"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Start a conversation */}
|
|
<div className="mt-auto border-t p-2">
|
|
<form
|
|
onSubmit={(ev) => {
|
|
ev.preventDefault();
|
|
openPeer(peerName);
|
|
}}
|
|
className="flex items-center gap-1.5"
|
|
>
|
|
<input
|
|
value={peerName}
|
|
onChange={(ev) => setPeerName(ev.target.value)}
|
|
placeholder="Message a user…"
|
|
className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus:border-primary/50"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={!peerName.trim()}
|
|
title="Open conversation"
|
|
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">
|
|
<MessageCircle className="h-8 w-8" />
|
|
<p className="text-sm">Pick a conversation, or message a user to start one.</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2.5">
|
|
<MessageCircle className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<span className="truncate font-medium">{selected}</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. Say hello.</p>
|
|
) : (
|
|
<div className="flex flex-col gap-1.5">
|
|
{messages.map((m, i) => {
|
|
const mine = isMine(m);
|
|
return (
|
|
<div key={m.id || `${m.timestamp}-${i}`} className={`flex ${mine ? 'justify-end' : 'justify-start'}`}>
|
|
<div
|
|
className={`max-w-[80%] rounded-2xl px-3 py-1.5 text-sm ${
|
|
mine ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground'
|
|
}`}
|
|
>
|
|
<div className="break-words">{m.message}</div>
|
|
<div className={`mt-0.5 text-right text-[10px] tabular-nums ${mine ? 'text-primary-foreground/70' : 'text-muted-foreground'}`}>
|
|
{formatClock(m.timestamp)}
|
|
</div>
|
|
</div>
|
|
</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>
|
|
);
|
|
};
|