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 }; +}