From b00608f3b342cdac043749eceb157fa8cd467d8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 29 Jul 2026 23:36:37 +0000 Subject: [PATCH] soulseek: peer menu with browse and favorites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit slskd has no favorites or buddy-list concept — 0.26.0's UsersController exposes only endpoint/browse/directory/info/status — so Officer owns that data itself: a soulseek_favorites table served by the slskd sidecar under a /_officer/* namespace, which can never collide with slskd's /api/v0/*. The main server gains exactly one line, injecting X-Officer-User on the proxy hop, so it stays a thin auth proxy and grows no Soulseek logic. The route is handled before the upstream check, so favorites keep working with slskd down. Usernames in search results and downloads become a dropdown (browse shares, toggle favorite). Browsing publishes to a nonce-stamped, consumed-once channel so the Users section looks the peer up without re-running the expensive browse on every remount, and favorites get their own section at the top of that panel, which doubles as its landing content. CardHeader had to split its toggle row to host the dropdown, since a trigger can't live inside the collapse button. The schema file is deliberately self-contained so it can move wholesale into the sidecar directory when sidecars start owning their own schema. Its DDL was applied by hand, matching drizzle's constraint naming, rather than running a whole-schema push. Co-Authored-By: Claude Opus 4.8 --- src/databases/officer_db/src/index.ts | 1 + .../officer_db/src/queries/soulseek.ts | 25 ++ src/databases/officer_db/src/schema/index.ts | 1 + .../officer_db/src/schema/soulseek.ts | 24 ++ src/servers/api/slskd/router.ts | 10 +- src/servers/sidecar/slskd/index.ts | 17 + src/servers/sidecar/slskd/officer.ts | 59 ++++ .../officerdev/src/apps/Soulseek/Cards.tsx | 60 +++- .../src/apps/Soulseek/SearchResults.tsx | 134 ++++---- .../src/apps/Soulseek/SoulseekTransfers.tsx | 163 ++++++---- .../src/apps/Soulseek/SoulseekUsers.tsx | 301 ++++++++++++------ .../officerdev/src/apps/Soulseek/UserMenu.tsx | 68 ++++ .../officerdev/src/apps/Soulseek/shared.ts | 22 +- .../src/apps/Soulseek/useSoulseekFavorites.ts | 47 +++ 14 files changed, 676 insertions(+), 256 deletions(-) create mode 100644 src/databases/officer_db/src/queries/soulseek.ts create mode 100644 src/databases/officer_db/src/schema/soulseek.ts create mode 100644 src/servers/sidecar/slskd/officer.ts create mode 100644 src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx create mode 100644 src/workspaces/officerdev/src/apps/Soulseek/useSoulseekFavorites.ts diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index a71729a4..281b6f94 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -102,6 +102,7 @@ export type { PlaylistSummary, Playlist, } from './queries/music'; +export { getSoulseekFavorites, addSoulseekFavorite, removeSoulseekFavorite } from './queries/soulseek'; export { getVaultTokens, setVaultTokens, diff --git a/src/databases/officer_db/src/queries/soulseek.ts b/src/databases/officer_db/src/queries/soulseek.ts new file mode 100644 index 00000000..0170d50b --- /dev/null +++ b/src/databases/officer_db/src/queries/soulseek.ts @@ -0,0 +1,25 @@ +import { eq, and, asc } from 'drizzle-orm'; +import { db } from '../db'; +import { soulseekFavorites } from '../schema'; + +/** A user's favourited Soulseek peers, alphabetical (the order the UI lists them in). */ +export async function getSoulseekFavorites(userId: number): Promise { + const rows = await db + .select({ username: soulseekFavorites.username }) + .from(soulseekFavorites) + .where(eq(soulseekFavorites.userId, userId)) + .orderBy(asc(soulseekFavorites.username)); + return rows.map((r) => r.username); +} + +/** Add a favourite (idempotent — a repeat add is a no-op via the unique constraint). */ +export async function addSoulseekFavorite(userId: number, username: string): Promise { + await db.insert(soulseekFavorites).values({ userId, username }).onConflictDoNothing(); +} + +/** Remove a favourite (no-op if it wasn't set). */ +export async function removeSoulseekFavorite(userId: number, username: string): Promise { + await db + .delete(soulseekFavorites) + .where(and(eq(soulseekFavorites.userId, userId), eq(soulseekFavorites.username, username))); +} diff --git a/src/databases/officer_db/src/schema/index.ts b/src/databases/officer_db/src/schema/index.ts index 8950d94d..5c6c2704 100644 --- a/src/databases/officer_db/src/schema/index.ts +++ b/src/databases/officer_db/src/schema/index.ts @@ -7,4 +7,5 @@ export * from './email'; export * from './pipeline-jobs'; export * from './chat-events'; export * from './music'; +export * from './soulseek'; export * from './vault'; diff --git a/src/databases/officer_db/src/schema/soulseek.ts b/src/databases/officer_db/src/schema/soulseek.ts new file mode 100644 index 00000000..79a4dc10 --- /dev/null +++ b/src/databases/officer_db/src/schema/soulseek.ts @@ -0,0 +1,24 @@ +import { pgTable, serial, integer, text, timestamp, unique } from 'drizzle-orm/pg-core'; +import { users } from './auth'; + +// Soulseek state that Officer owns because slskd has none. slskd exposes no favourites/buddy-list API +// (verified against 0.26.0's UsersController: only endpoint/browse/directory/info/status), so the peers +// the owner marks live here instead of in a forked daemon. +// +// Every table here is `soulseek_`-prefixed, and this file deliberately holds nothing else: when sidecars +// start owning their own schema, it moves wholesale into src/servers/sidecar/slskd/ with no untangling. +// Only the officer-slskd sidecar reads or writes these tables. + +export const soulseekFavorites = pgTable( + 'soulseek_favorites', + { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + username: text('username').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + // Also the lookup index — (user_id, username) covers "all of a user's favourites" by leftmost prefix. + (t) => [unique('uq_soulseek_favorites_user_username').on(t.userId, t.username)], +); diff --git a/src/servers/api/slskd/router.ts b/src/servers/api/slskd/router.ts index 7bbcdf6d..2a5efde1 100644 --- a/src/servers/api/slskd/router.ts +++ b/src/servers/api/slskd/router.ts @@ -6,9 +6,10 @@ import { getSlskdServerUrl } from './sidecar-server'; // the subpath + query + body to the officer-slskd sidecar, which OWNS the slskd contract and injects the // slskd API key. The platform holds no slskd credentials. // -// This is a catch-all with no routes of its own: the full /api/slskd/* contract (slskd's own API, e.g. -// /api/v0/searches, /api/v0/transfers) is documented at the top of the sidecar's fetch handler — -// src/servers/sidecar/slskd/index.ts. +// This is a catch-all with no routes of its own: the full /api/slskd/* contract is documented at the top +// of the sidecar's fetch handler (src/servers/sidecar/slskd/index.ts). That contract covers both slskd's +// own API (/api/v0/searches, /api/v0/transfers, …) and the sidecar's Officer-owned routes (/_officer/*, +// features slskd has no concept of). Both are opaque from here — this file never grows Soulseek logic. export const slskdRouter = createRouter(); @@ -28,6 +29,9 @@ slskdRouter.all('/*', async (ctx) => { if (contentType) headers['Content-Type'] = contentType; const range = ctx.req.header('range'); if (range) headers['Range'] = range; + // Forward the authenticated user id so the sidecar can serve its own Officer-owned routes (/_officer/*, + // e.g. favourite peers) against Postgres. The sidecar binds loopback only, so this header is trusted. + headers['X-Officer-User'] = String(ctx.get('user').id); const hasBody = method !== 'GET' && method !== 'HEAD'; diff --git a/src/servers/sidecar/slskd/index.ts b/src/servers/sidecar/slskd/index.ts index e7efa0e4..2c0e063d 100644 --- a/src/servers/sidecar/slskd/index.ts +++ b/src/servers/sidecar/slskd/index.ts @@ -1,6 +1,7 @@ import type { SidecarCommand, SidecarEvent } from '../protocol'; import { createSidecarConnector } from '../connect'; import { getSlskdBase, getSlskdApiKey, stripHopByHop } from './upstream'; +import { handleOfficerRoute } from './officer'; // The officer-slskd sidecar. Same philosophy as officer-vault / officer-music: a singleton process that // registers with the API server and OWNS a contract — here, a reverse-proxy to a self-hosted slskd @@ -14,6 +15,10 @@ import { getSlskdBase, getSlskdApiKey, stripHopByHop } from './upstream'; // `X-API-Key` and pass method, path, query, headers, status and BOTH body streams through verbatim. // `GET /_health` is ours (probes slskd's /health), not part of the slskd contract. The server listens on // a random loopback port, reported to the API on connect so it can route here. +// +// Paths under `/_officer/` are also ours and are NOT forwarded: they're the features slskd has no concept +// of (favourite peers, …), served straight from Postgres. See officer.ts for that contract. Keeping them +// here rather than in the platform API is what lets the main server stay a pure proxy forever. // ───────────────────────────────────────────────────────────────────────────────────────────────── // // TODO (next iteration): slskd streams live search results + transfer progress over SignalR hubs at @@ -55,6 +60,18 @@ const server = Bun.serve({ } } + // Officer-owned routes — answered locally, never proxied (so they work even with slskd down). + if (url.pathname.startsWith('/_officer/')) { + try { + const res = await handleOfficerRoute(req, url); + if (res) return res; + return new Response('not found', { status: 404 }); + } catch (err) { + console.error(`[slskd] ${req.method} ${url.pathname} failed`, err); + return Response.json({ error: 'internal error' }, { status: 500 }); + } + } + if (!base) return new Response('slskd upstream not configured', { status: 503 }); const target = `${base}${url.pathname}${url.search}`; diff --git a/src/servers/sidecar/slskd/officer.ts b/src/servers/sidecar/slskd/officer.ts new file mode 100644 index 00000000..cd173491 --- /dev/null +++ b/src/servers/sidecar/slskd/officer.ts @@ -0,0 +1,59 @@ +import { getSoulseekFavorites, addSoulseekFavorite, removeSoulseekFavorite } from 'officerdb'; + +// Officer-owned Soulseek routes — everything slskd itself has no concept of. These are served HERE, by +// the sidecar, not forwarded upstream: the platform API stays a pure auth-and-forward proxy forever, and +// slskd stays unforked. They live under the `/_officer/` prefix, mirroring `/_health`, so they can never +// collide with slskd's own surface (all of which is under `/api/v0/`). +// +// ───────────────────────────────────────────────────────────────────────────────────────────────── +// CONTRACT (reached from the browser as /api/slskd/_officer/…) +// GET /_officer/favorites → string[] favourited peer usernames, alphabetical +// POST /_officer/favorites { username } → { ok: true } add (idempotent) +// DELETE /_officer/favorites?username= → { ok: true } remove (no-op if absent) +// ───────────────────────────────────────────────────────────────────────────────────────────────── + +// The authenticated user id arrives in X-Officer-User, injected by the platform proxy after auth. We +// bind loopback only, so it's trusted — same arrangement as the music sidecar's per-user state routes. +const userIdOf = (req: Request): number | null => { + const n = Number(req.headers.get('x-officer-user')); + return Number.isInteger(n) && n > 0 ? n : null; +}; + +// Soulseek usernames are opaque to us and may contain spaces, so the only shapes we reject are ones +// no nick can have: empty, absurdly long, or containing control characters. +const USERNAME_MAX = 255; +const CONTROL_CHARS = /[\u0000-\u001f\u007f]/; +const cleanUsername = (v: unknown): string | null => { + if (typeof v !== 'string') return null; + const u = v.trim(); + if (!u || u.length > USERNAME_MAX) return null; + return CONTROL_CHARS.test(u) ? null : u; +}; + +/** Handles a `/_officer/*` request, or returns null if the path isn't one of ours. */ +export async function handleOfficerRoute(req: Request, url: URL): Promise { + const path = url.pathname; + if (path !== '/_officer/favorites') return null; + + const userId = userIdOf(req); + if (userId === null) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 }); + + if (req.method === 'GET') return Response.json(await getSoulseekFavorites(userId)); + + if (req.method === 'POST') { + const body = (await req.json().catch(() => null)) as { username?: unknown } | null; + const username = cleanUsername(body?.username); + if (!username) return Response.json({ error: 'username required' }, { status: 400 }); + await addSoulseekFavorite(userId, username); + return Response.json({ ok: true }); + } + + if (req.method === 'DELETE') { + const username = cleanUsername(url.searchParams.get('username')); + if (!username) return Response.json({ error: 'username required' }, { status: 400 }); + await removeSoulseekFavorite(userId, username); + return Response.json({ ok: true }); + } + + return new Response('method not allowed', { status: 405 }); +} diff --git a/src/workspaces/officerdev/src/apps/Soulseek/Cards.tsx b/src/workspaces/officerdev/src/apps/Soulseek/Cards.tsx index e78708e8..5104a109 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/Cards.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/Cards.tsx @@ -9,18 +9,52 @@ export const Card = ({ children }: { children: ReactNode }) => (
{children}
); -type CardHeaderProps = { open: boolean; onToggle: () => void; title: ReactNode; meta?: ReactNode }; -export const CardHeader = ({ open, onToggle, title, meta }: CardHeaderProps) => ( - -); + ); + const metaBlock = meta &&
{meta}
; + + if (!titleMenu) + return ( + + ); + + return ( +
+ + {titleMenu} +
+ ); +}; export const CardBody = ({ children }: { children: ReactNode }) => (
{children}
@@ -45,9 +79,7 @@ export const SubCardHeader = ({ icon, label, title, meta, action, open, onToggle const inner = ( <> {onToggle && ( - + )} {icon} diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx index a50695d5..71ce4422 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SearchResults.tsx @@ -4,6 +4,7 @@ import { usePanelChannel } from 'hooks/usePanelChannel'; import { toast } from 'sonner'; import { ArrowLeft, Download, Lock, Loader2, Folder, Zap, Users, Search, X, RefreshCw } from 'lucide-react'; import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList, Pill } from './Cards'; +import { UserMenu } from './UserMenu'; import { SLSKD_REFRESH_CHANNEL, basename, @@ -58,7 +59,10 @@ const filtersActive = (f: Filters) => const applyFilters = (users: ResultUser[], f: Filters): ResultUser[] => { const terms = f.text.toLowerCase().split(/\s+/).filter(Boolean); const pos = terms.filter((t) => !t.startsWith('-')); - const neg = terms.filter((t) => t.startsWith('-')).map((t) => t.slice(1)).filter(Boolean); + const neg = terms + .filter((t) => t.startsWith('-')) + .map((t) => t.slice(1)) + .filter(Boolean); const fileOk = (file: ResultFile) => { if (f.hideLocked && file.isLocked) return false; if (f.exts.size && !f.exts.has(file.extension)) return false; @@ -373,11 +377,17 @@ const UserCard = ({ user, open, onToggle, queued, onDownload }: UserCardProps) = ); return ( - + } meta={meta} /> {open && ( {user.folders.map((folder) => ( - + ))} {allFiles.length > 1 && ( - ) : null - } - /> - {open && ( - <> - - {folder.files.map((file) => { - const isQueued = queued.has(fileKey(username, file.filename)); - const duration = formatDuration(file.length); - return ( -
- - {file.isLocked && } - {file.name} - -
- {file.bitRate ? {file.bitRate} kbps : null} - {duration && {duration}} - {formatSize(file.size)} -
+ + setOpen((v) => !v)} + icon={} + label={folder.label} + title={folder.path} + meta={`${folder.files.length} · ${formatSize(folder.size)}`} + action={ + folder.files.length > 1 ? ( -
- ); - })} -
- {unlocked > 1 && ( - - )} - - )} - + ) : null + } + /> + {open && ( + <> + + {folder.files.map((file) => { + const isQueued = queued.has(fileKey(username, file.filename)); + const duration = formatDuration(file.length); + return ( +
+ + {file.isLocked && } + {file.name} + +
+ {file.bitRate ? {file.bitRate} kbps : null} + {duration && {duration}} + {formatSize(file.size)} +
+ +
+ ); + })} +
+ {unlocked > 1 && ( + + )} + + )} + ); }; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx index 4b0e70c6..ea21fcf9 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekTransfers.tsx @@ -5,6 +5,7 @@ import { useClient } from 'hooks/useClient'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { RefreshCw, X, Radio, Folder, RotateCcw, Ban, Trash2 } from 'lucide-react'; import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList } from './Cards'; +import { UserMenu } from './UserMenu'; import { SLSKD_REFRESH_CHANNEL, basename, @@ -54,7 +55,8 @@ const group = (users: SlskdDownloadUser[], keep: (phase: TransferPhase) => boole .filter((f) => keep(f.phase)); if (files.length === 0) continue; files.sort( - (a, b) => PHASE_ORDER[a.phase] - PHASE_ORDER[b.phase] || basename(a.filename).localeCompare(basename(b.filename)), + (a, b) => + PHASE_ORDER[a.phase] - PHASE_ORDER[b.phase] || basename(a.filename).localeCompare(basename(b.filename)), ); dirs.push({ directory: dir.directory, @@ -71,7 +73,10 @@ const group = (users: SlskdDownloadUser[], keep: (phase: TransferPhase) => boole out.push({ username: user.username, dirs, counts, total }); } // Active users first, then alphabetical. - out.sort((a, b) => Number(b.counts.downloading > 0) - Number(a.counts.downloading > 0) || a.username.localeCompare(b.username)); + out.sort( + (a, b) => + Number(b.counts.downloading > 0) - Number(a.counts.downloading > 0) || a.username.localeCompare(b.username), + ); return out; }; @@ -96,7 +101,8 @@ export const SoulseekTransfers = () => { // three actions target disjoint sets: retry → failed, cancel → in-flight, remove → done. const totals = useMemo(() => { const t: Record = { downloading: 0, queued: 0, failed: 0, done: 0 }; - for (const u of data) for (const d of u.directories ?? []) for (const f of d.files ?? []) t[transferPhase(f.state)]++; + for (const u of data) + for (const d of u.directories ?? []) for (const f of d.files ?? []) t[transferPhase(f.state)]++; return t; }, [data]); const inFlight = totals.downloading + totals.queued; @@ -147,7 +153,10 @@ export const SoulseekTransfers = () => { const dropIds = (ids: Set) => setData((prev) => - prev.map((u) => ({ ...u, directories: (u.directories ?? []).map((d) => ({ ...d, files: (d.files ?? []).filter((f) => !ids.has(f.id)) })) })), + prev.map((u) => ({ + ...u, + directories: (u.directories ?? []).map((d) => ({ ...d, files: (d.files ?? []).filter((f) => !ids.has(f.id)) })), + })), ); // For an active transfer this cancels it; for a finished/failed one it clears it from the queue. @@ -175,7 +184,9 @@ export const SoulseekTransfers = () => { }; const clearAllCompleted = async () => { - const finished = completed.flatMap((u) => u.dirs.flatMap((d) => d.files.map((f) => ({ username: u.username, id: f.id })))); + const finished = completed.flatMap((u) => + u.dirs.flatMap((d) => d.files.map((f) => ({ username: u.username, id: f.id }))), + ); if (finished.length === 0) return; await Promise.allSettled( finished.map((f) => @@ -264,10 +275,14 @@ export const SoulseekTransfers = () => { slskd unreachable: {statusError} ) : ( <> - + {server?.state ?? 'unknown'} {server?.address && · {server.address}} - {app?.version?.current && · v{app.version.current}} + {app?.version?.current && ( + · v{app.version.current} + )} )} @@ -283,9 +298,19 @@ export const SoulseekTransfers = () => { {(active.length > 0 || completed.length > 0) && (
- } label="Retry errored" count={totals.failed} onClick={retryErrored} /> + } + label="Retry errored" + count={totals.failed} + onClick={retryErrored} + /> } label="Cancel all" count={inFlight} onClick={cancelAll} /> - } label="Remove completed" count={totals.done} onClick={clearAllCompleted} /> + } + label="Remove completed" + count={totals.done} + onClick={clearAllCompleted} + />
)} @@ -380,7 +405,7 @@ const UserCard = ({ user, open, onToggle, onRemove, onPosition, onClearCompleted )); return ( - + } meta={meta} /> {open && ( {user.dirs.map((dir) => ( @@ -406,64 +431,64 @@ type FolderBlockProps = { dir: DlDir; onRemove: (row: Row) => void; onPosition: const FolderBlock = ({ dir, onRemove, onPosition }: FolderBlockProps) => { const [open, setOpen] = useState(true); return ( - - setOpen((v) => !v)} - icon={} - label={dir.label} - title={dir.directory} - meta={`${dir.files.length} · ${formatSize(dir.size)}`} - /> - {open && ( - - {dir.files.map((row) => { - const style = phaseStyle[row.phase]; - const pct = row.phase === 'done' ? 100 : Math.max(0, Math.min(100, Math.round(row.percentComplete))); - const speed = row.phase === 'downloading' ? formatSpeed(row.averageSpeed) : ''; - return ( -
-
- - {basename(row.filename)} - -
-
-
-
-
- {row.phase === 'queued' ? ( - - ) : ( - {style.label} - )} - {row.size > 0 && · {formatSize(row.size)}} - {speed && · {speed}} -
-
- ); - })} - - )} - + + setOpen((v) => !v)} + icon={} + label={dir.label} + title={dir.directory} + meta={`${dir.files.length} · ${formatSize(dir.size)}`} + /> + {open && ( + + {dir.files.map((row) => { + const style = phaseStyle[row.phase]; + const pct = row.phase === 'done' ? 100 : Math.max(0, Math.min(100, Math.round(row.percentComplete))); + const speed = row.phase === 'downloading' ? formatSpeed(row.averageSpeed) : ''; + return ( +
+
+ + {basename(row.filename)} + +
+
+
+
+
+ {row.phase === 'queued' ? ( + + ) : ( + {style.label} + )} + {row.size > 0 && · {formatSize(row.size)}} + {speed && · {speed}} +
+
+ ); + })} + + )} + ); }; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx index 355000e6..976ae9de 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx @@ -1,13 +1,25 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useClient } from 'hooks/useClient'; +import { usePanelChannel } from 'hooks/usePanelChannel'; import { toast } from 'sonner'; -import { Users, Search, FolderOpen, CircleCheck, CircleSlash, Clock, Loader2 } from 'lucide-react'; -import type { SlskdBrowseDirectory, SlskdUserInfo, SlskdUserStatus } from './shared'; +import { Users, Search, FolderOpen, CircleCheck, CircleSlash, Clock, Loader2, Star, X } from 'lucide-react'; +import { useSoulseekFavorites } from './useSoulseekFavorites'; +import { + SOULSEEK_USER_CHANNEL, + type SlskdBrowseDirectory, + type SlskdUserInfo, + type SlskdUserStatus, + type SoulseekUserRequest, +} from './shared'; // Users panel — look up a peer: their presence (online/away/offline), profile info (description, upload // slots, queue), and optionally browse their shared folders. GET /users/{u}/status + /info fetch the -// header; GET /users/{u}/browse pulls the share tree (a flat directory list). Basic version: a lookup -// box, a profile card, and a lazily-loaded, collapsed folder list. +// header; GET /users/{u}/browse pulls the share tree (a flat directory list). +// +// This is also where the workspace's peer actions land: the username dropdown in search results and +// downloads publishes to 'soulseek:user', which we consume once (clearing it) to look the peer up and +// auto-browse. Favourites — Officer's own data, since slskd has no such concept — get their own section +// at the top, which also serves as this panel's landing content before any lookup. type Loaded = { username: string; @@ -33,35 +45,15 @@ export const SoulseekUsers = () => { const [peer, setPeer] = useState(null); const [dirs, setDirs] = useState(null); const [browsing, setBrowsing] = useState(false); + const [request, setRequest] = usePanelChannel(SOULSEEK_USER_CHANNEL, null); + const { favorites, isFavorite, toggle } = useSoulseekFavorites(); - const lookup = async () => { - const username = query.trim(); - if (!username || loading) return; - setLoading(true); - setPeer(null); - setDirs(null); - const [status, info] = await Promise.allSettled([ - client.get(`/slskd/api/v0/users/${encodeURIComponent(username)}/status`), - client.get(`/slskd/api/v0/users/${encodeURIComponent(username)}/info`), - ]); - if (status.status === 'rejected' && info.status === 'rejected') { - toast.error(`Couldn't reach ${username} — they may be offline.`); - setLoading(false); - return; - } - setPeer({ - username, - status: status.status === 'fulfilled' ? status.value : null, - info: info.status === 'fulfilled' ? info.value : null, - }); - setLoading(false); - }; - - const browse = async () => { - if (!peer || browsing) return; + const browse = async (username: string) => { setBrowsing(true); try { - const tree = await client.get(`/slskd/api/v0/users/${encodeURIComponent(peer.username)}/browse`); + const tree = await client.get( + `/slskd/api/v0/users/${encodeURIComponent(username)}/browse`, + ); setDirs([...tree].sort((a, b) => a.name.localeCompare(b.name))); } catch (err) { toast.error(`Browse failed: ${err instanceof Error ? err.message : String(err)}`); @@ -70,6 +62,40 @@ export const SoulseekUsers = () => { } }; + const lookup = async (name: string, autoBrowse = false) => { + const username = name.trim(); + if (!username || loading) return; + setQuery(username); + setLoading(true); + setPeer(null); + setDirs(null); + const [status, info] = await Promise.allSettled([ + client.get(`/slskd/api/v0/users/${encodeURIComponent(username)}/status`), + client.get(`/slskd/api/v0/users/${encodeURIComponent(username)}/info`), + ]); + setLoading(false); + if (status.status === 'rejected' && info.status === 'rejected') { + toast.error(`Couldn't reach ${username} — they may be offline.`); + return; + } + setPeer({ + username, + status: status.status === 'fulfilled' ? status.value : null, + info: info.status === 'fulfilled' ? info.value : null, + }); + if (autoBrowse) browse(username); + }; + + // Consume a peer request from the username dropdown. Cleared as it's handled, so switching away and + // back doesn't re-run the (expensive) browse. + useEffect(() => { + if (!request) return; + setRequest(null); + lookup(request.username, request.browse); + // lookup is recreated each render; the effect deliberately runs only when a request arrives. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [request]); + const pres = presenceStyle(peer?.status?.presence); const PresIcon = pres.icon; @@ -80,7 +106,7 @@ export const SoulseekUsers = () => {
{ ev.preventDefault(); - lookup(); + lookup(query); }} className="flex flex-1 items-center gap-2" > @@ -102,88 +128,151 @@ export const SoulseekUsers = () => {
- {!peer ? ( -
- -

Enter a Soulseek username to see their profile and shares.

-
- ) : ( -
- {/* Profile */} -
-
-
- {peer.username.charAt(0).toUpperCase()} -
-
-
{peer.username}
-
- - {pres.label} - {peer.status?.isPrivileged && · privileged} -
-
-
- - {peer.info && ( - <> -
- - - -
- {peer.info.description && ( -

- {peer.info.description} -

- )} - - )} +
+ {/* Favorites — Officer's own data (slskd has no such concept). Always present, so the section + doubles as the panel's landing content before any lookup. */} +
+
+ + Favorites + {favorites.length > 0 && · {favorites.length}}
+ {favorites.length === 0 ? ( +

+ No favorites yet — add one from a peer's profile below, or from the username menu in search results and + downloads. +

+ ) : ( +
+ {favorites.map((u) => { + const current = peer?.username === u; + return ( +
+ + +
+ ); + })} +
+ )} +
- {/* Shares */} -
-
- - Shared folders - {dirs && · {dirs.length}} - {!dirs && ( + {!peer ? ( +
+ +

Enter a Soulseek username to see their profile and shares.

+
+ ) : ( + <> + {/* Profile */} +
+
+
+ {peer.username.charAt(0).toUpperCase()} +
+
+
{peer.username}
+
+ + {pres.label} + {peer.status?.isPrivileged && · privileged} +
+
+
+ + {peer.info && ( + <> +
+ + + +
+ {peer.info.description && ( +

+ {peer.info.description} +

+ )} + )}
- {dirs && - (dirs.length === 0 ? ( -

No shared folders.

- ) : ( -
- {dirs.map((d) => ( -
- - - {d.name} - - - {d.fileCount} file{d.fileCount === 1 ? '' : 's'} - -
- ))} -
- ))} -
-
- )} + + {/* Shares */} +
+
+ + Shared folders + {dirs && · {dirs.length}} + {!dirs && ( + + )} +
+ {dirs && + (dirs.length === 0 ? ( +

No shared folders.

+ ) : ( +
+ {dirs.map((d) => ( +
+ + + {d.name} + + + {d.fileCount} file{d.fileCount === 1 ? '' : 's'} + +
+ ))} +
+ ))} +
+ + )} +
); diff --git a/src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx b/src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx new file mode 100644 index 00000000..7e86076b --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx @@ -0,0 +1,68 @@ +import { usePanelChannel } from 'hooks/usePanelChannel'; +import { toast } from 'sonner'; +import { FolderOpen, Star, StarOff } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, +} from '@/components/ui/dropdown-menu'; +import { useSoulseekFavorites } from './useSoulseekFavorites'; +import { + SOULSEEK_SECTION_CHANNEL, + SOULSEEK_USER_CHANNEL, + type SoulseekSectionId, + type SoulseekUserRequest, +} from './shared'; + +// The peer dropdown, shared by search results and downloads: click a username anywhere in the workspace +// and act on that peer. "Browse user files" hands the username to the Users section over a panel channel +// (which owns the lookup + browse); favouriting goes to the sidecar's Officer-owned favourites route. + +type UserMenuProps = { username: string }; + +export const UserMenu = ({ username }: UserMenuProps) => { + const [, setSection] = usePanelChannel(SOULSEEK_SECTION_CHANNEL, 'dashboard'); + const [, setRequest] = usePanelChannel(SOULSEEK_USER_CHANNEL, null); + const { isFavorite, toggle } = useSoulseekFavorites(); + const favorited = isFavorite(username); + + const browse = () => { + setRequest({ username, browse: true, nonce: Date.now() }); + setSection('users'); + }; + + const toggleFavorite = () => { + toggle(username); + toast.success(favorited ? `Removed ${username} from favorites` : `Added ${username} to favorites`); + }; + + return ( + + + + + + {username} + + + + Browse user files + + + {favorited ? : } + {favorited ? 'Remove from favorites' : 'Add user to favorites'} + + + + ); +}; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts index 017925df..6208d4d7 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts +++ b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts @@ -180,7 +180,13 @@ export type SlskdServerState = { address?: string; state?: string; isConnected?: // Chat rooms. GET /rooms/joined/{name} inlines users + messages; GET /rooms/available lists the rest. export type SlskdRoomUser = { username: string }; -export type SlskdRoomMessage = { timestamp: string; username: string; message: string; roomName?: string; self?: boolean }; +export type SlskdRoomMessage = { + timestamp: string; + username: string; + message: string; + roomName?: string; + self?: boolean; +}; export type SlskdRoom = { name: string; isPrivate?: boolean; users?: SlskdRoomUser[]; messages?: SlskdRoomMessage[] }; export type SlskdRoomInfo = { name: string; userCount: number; isPrivate?: boolean }; @@ -207,6 +213,12 @@ export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [ { id: 'system', label: 'System' }, ]; +// Published by the username dropdown (search results / downloads) to jump straight to a peer in the Users +// section. Nonce-stamped and consumed-once (the Users panel clears it) so revisiting the section doesn't +// re-run an expensive browse, while two requests for the SAME username still each trigger a fresh lookup. +export const SOULSEEK_USER_CHANNEL = 'soulseek:user'; +export type SoulseekUserRequest = { username: string; browse: boolean; nonce: number }; + // A past search, as listed by GET /searches (no responses inlined). export type SlskdSearchSummary = { id: string; @@ -277,7 +289,13 @@ export const transferPhase = (state: string): TransferPhase => { const s = state.toLowerCase(); if (s.includes('inprogress') || s.includes('initializing')) return 'downloading'; if (s.includes('succeeded')) return 'done'; - if (s.includes('errored') || s.includes('cancelled') || s.includes('rejected') || s.includes('timedout') || s.includes('aborted')) + if ( + s.includes('errored') || + s.includes('cancelled') || + s.includes('rejected') || + s.includes('timedout') || + s.includes('aborted') + ) return 'failed'; return 'queued'; // Requested / Queued / anything not yet resolved }; diff --git a/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekFavorites.ts b/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekFavorites.ts new file mode 100644 index 00000000..00971e08 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekFavorites.ts @@ -0,0 +1,47 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useClient } from 'hooks/useClient'; + +const KEY = ['soulseek', 'favorites'] as const; +const EMPTY: string[] = []; + +/** + * The owner's favourited Soulseek peers, backed by the officer-slskd sidecar's own `/_officer/favorites` + * route (slskd has no favourites API — Officer owns that data). One shared react-query cache, so every + * star in the workspace reflects the same state; toggling is optimistic and rolls back on failure. + */ +export function useSoulseekFavorites() { + const { get, post, delete: del } = useClient(); + const qc = useQueryClient(); + + const { data } = useQuery({ + queryKey: KEY, + queryFn: () => get('/slskd/_officer/favorites'), + staleTime: 60_000, + }); + + const mutation = useMutation({ + mutationFn: ({ on, username }: { on: boolean; username: string }) => + on + ? post('/slskd/_officer/favorites', { username }) + : del(`/slskd/_officer/favorites?username=${encodeURIComponent(username)}`), + onMutate: async ({ on, username }) => { + await qc.cancelQueries({ queryKey: KEY }); + const prev = qc.getQueryData(KEY) ?? EMPTY; + const next = on ? [...prev, username].sort((a, b) => a.localeCompare(b)) : prev.filter((u) => u !== username); + qc.setQueryData(KEY, next); + return { prev }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev) qc.setQueryData(KEY, ctx.prev); + }, + }); + + const favorites = data ?? EMPTY; + const isFavorite = (username: string) => favorites.includes(username); + const toggle = (username: string) => { + if (!username) return; + mutation.mutate({ on: !isFavorite(username), username }); + }; + + return { favorites, isFavorite, toggle }; +}