From fd923bb9be198c33e9af12d2ea1b0ef61d0f500f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 7 Aug 2026 11:45:33 +0000 Subject: [PATCH] give the soulseek workspace a url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section is /soulseek/:section — nav entries are NavLinks, the view panel reads the same URL instead of being told, and the dashboard's tiles and recent searches are real links (a recent search now opens that search, not the search screen's front page). The peer went in `?user=` rather than the /soulseek/users/:name the audit sketched: a second path segment would need a nested route just to keep the nav highlight, and `?search=` had already set the convention there. That deletes the `soulseek:user` channel and with it a `{username, nonce}` request the Users panel consumed-once and cleared — the nonce existed so asking for the same peer twice counted twice. A link is idempotent, so there is nothing to consume and nothing to disambiguate. `soulseek:refresh` stays: it is a signal, which is what channels are for. --- docs/navigation-audit.md | 19 ++++---- docs/workspace-panel-todo.md | 12 ++++- docs/workspace-panels.md | 9 ++-- src/apps/officer-web/App.tsx | 1 + .../Dashboard/Soulseek/SoulseekScreen.tsx | 13 +++-- .../src/apps/Soulseek/SoulseekDashboard.tsx | 39 +++++++-------- .../src/apps/Soulseek/SoulseekNav.tsx | 47 ++++++++++--------- .../src/apps/Soulseek/SoulseekUsers.tsx | 34 +++++++------- .../src/apps/Soulseek/SoulseekView.tsx | 7 ++- .../officerdev/src/apps/Soulseek/UserMenu.tsx | 29 ++++-------- .../officerdev/src/apps/Soulseek/shared.ts | 44 ++++++++++++----- src/workspaces/officerdev/src/index.ts | 9 ++++ 12 files changed, 149 insertions(+), 114 deletions(-) diff --git a/docs/navigation-audit.md b/docs/navigation-audit.md index af3c352d..82d18ca0 100644 --- a/docs/navigation-audit.md +++ b/docs/navigation-audit.md @@ -36,7 +36,7 @@ That fix is the template for the HIGH items below. **Caveat:** the fix only did ## Route map (from `App.tsx`) **Existing entity routes:** `/chat/:sessionId`, `/jobs/:id`, `/dashboards/:id`, `/projects/:id`, `/email/:emailId`. -**Flat screens:** `/` `/files` `/music` `/soulseek` `/tasks` `/skills` `/processes` `/activity` +**Flat screens:** `/` `/files` `/music` `/tasks` `/skills` `/processes` `/activity` `/system-monitor` `/task-logs` `/plans` `/terminal` `/desktop` `/browser` `/code-editor`. The five settings pages are route **pairs** now, not flat screens — `/settings/{profile,ai,system,integrations,user-management}` plus a `:section` each (M6). There has never been a `/settings/apps`; that entry was wrong when this list @@ -87,12 +87,13 @@ is **one design decision** that cascades across many files: - **Music** — `Music/{MusicBrowser:182,209 · MusicDetail:138,174,234 · FavoritesView:85,107}` hold the entire library location in `usePanelChannel('music:cwd')`; every drill-in is `setCwd(path)`. No URL, no deep-link to an album. → back `music:cwd` with `/music?path=`; rows become ``s. -- **Soulseek** — an entire workspace with **no URL at all**: `soulseek:section` nav (`SoulseekNav:40`, - `SoulseekDashboard:88/96/110/121`), `soulseek:user` peer channel (`UserMenu:32`, `SoulseekUsers:42`), and - per-panel `useState` for past-search (`SearchView:160`, a textbook `role="button"` div with `s.id` in the - closure), rooms (`SoulseekRooms:153`), conversations (`SoulseekChat:135`). → decide a nested tree - `/soulseek/:section` + `/soulseek/{users/:name, search/:id, rooms/:name, chat/:name}`. **S1 (the section nav) - decides S2–S7.** +- ~~**Soulseek**~~ — **mostly done.** The section is `/soulseek/:section` (nav entries are `NavLink`s, the + view panel reads the same URL), the peer is `?user=`, and the past-search was already `?search=`. + The `soulseek:section` and `soulseek:user` channels are deleted; `soulseek:refresh` stays, which is a + signal and not selection. The peer went in the query string rather than `/soulseek/users/:name` as + sketched here, because the section nav has to stay a one-segment `NavLink` — a second path segment would + need a nested route just to keep the highlight, and the query string is what `?search=` already uses. + Still on `useState`: rooms (`SoulseekRooms`) and conversations (`SoulseekChat`). ### 🟡 LOW / borderline @@ -120,8 +121,6 @@ This is the primary surface to convert to URL-driven selection. | `SELECTED_PROJECT` (`useGlobal`) | selected project | `/projects/:id` | `ProjectListApp.tsx:45`, `ProjectPreview.tsx:386` (H3) | | `EMAIL_SELECTED` | open email | `/email/:emailId` | `EmailList.tsx:336`, `EmailScreen.tsx` sync (H5) | | `music:cwd` | library location (album/artist/folder) | `/music?path=` | `MusicBrowser/MusicDetail/FavoritesView` | -| `soulseek:section` | active Soulseek section | `/soulseek/:section` | `SoulseekNav/View/Dashboard/UserMenu` | -| `soulseek:user` | selected peer | `/soulseek/users/:name` | `UserMenu.tsx:28`, `SoulseekUsers.tsx:34` | | local `useState` selections | search / room / conversation / peer / open file / preview slug | respective nested routes | `SearchView`, `SoulseekRooms`, `SoulseekChat`, `SoulseekUsers`, `CodeEditor/useEditorState`, `PreviewProvider` | **Excluded — event-bus / refresh signals, NOT selection:** `files:refresh-signal`, @@ -201,7 +200,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri ### Phase 3 — Whole-workspace routing decisions (needs a design call first) - [ ] **Music** — decide `/music?path=` (or nested); back `music:cwd` with the URL; rows → `` (M-music). -- [ ] **Soulseek** — decide the nested tree `/soulseek/:section` + `/soulseek/{users,search,rooms,chat}/:x`; **S1 decides S2–S7** (M-slsk). +- [x] **Soulseek** — `/soulseek/:section` with the peer in `?user=` and the search already in `?search=`; the two selection channels are deleted. Rooms and conversations are still `useState`. **Needs runtime test.** - [x] **M10** SystemMonitor scope → `/system-monitor/:scope`; `monitor:scope` channel deleted. **Needs runtime test.** - [ ] **M9** Plans → `/plans/:name` (`Plans/index.tsx:36`). diff --git a/docs/workspace-panel-todo.md b/docs/workspace-panel-todo.md index 49a7dd67..249e9de2 100644 --- a/docs/workspace-panel-todo.md +++ b/docs/workspace-panel-todo.md @@ -646,8 +646,16 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin key now, so there is nothing left to disagree with. **SystemMonitor is done too** (audit M10) — `/system-monitor/:scope`, scope list as `NavLink`s, `monitor:scope` deleted. Smallest of the seven and the same shape as `/photos`. - Remaining here: Music, Soulseek, Email, Browser. **Chat detail (`chat:selected-session`) is - chat-owned — leave it; it is written up in `COMMS/`.** + **Soulseek is done** — `/soulseek/:section`, and the peer moved to `?user=` rather than the + `/soulseek/users/:name` the audit sketched: a second path segment would have needed a nested route + just to keep the nav's `NavLink` highlight, and `?search=` was already establishing the convention + for that workspace. Deleting `soulseek:user` also deleted the nonce it carried — a request stamped + `{username, nonce}`, consumed-once by the Users panel so that returning to the section did not + re-run the lookup, and nonce-stamped so that asking for the *same* peer twice still counted twice. + A link needs none of that: it is idempotent, so there is nothing to consume and nothing to + disambiguate. That is the shape of most of this section's remaining work. + Remaining here: Music, Email, Browser. **Chat detail (`chat:selected-session`) is chat-owned — + leave it; it is written up in `COMMS/`.** ### 5.9 The context has grown an app-config section — _(found 2026-08-07)_ diff --git a/docs/workspace-panels.md b/docs/workspace-panels.md index 04e421c0..b236faa5 100644 --- a/docs/workspace-panels.md +++ b/docs/workspace-panels.md @@ -126,10 +126,11 @@ connection status and the header renders it. Panel-scoped by construction, which ### The thirteen channels, honestly -13 channels, 26 call sites. Six carry selection that should be in the URL — `music:cwd` (the entire -library location, so no album is linkable), `soulseek:section` and `soulseek:user` (the whole Soulseek -workspace has no URL), `chat:selected-session`, `monitor:scope`. Two are view toggles where a channel is -genuinely fine (`music:favorites`, `music:lyrics`). +Was 13 channels, 26 call sites. Six carried selection that belongs in the URL, and three of those are +gone: `soulseek:section` and `soulseek:user` (the whole Soulseek workspace now has one) and +`monitor:scope`. Still to move: `music:cwd` — the entire library location, so no album is linkable — and +`chat:selected-session`, which is the chat's to move, not this document's. Two are view toggles where a +channel is genuinely fine (`music:favorites`, `music:lyrics`). Two are wired to nobody: `preview:refresh` and `chat:active-session` have publishers and no subscribers. A third, `file-viewer:`, had a subscriber and no publisher — it is gone, along with the diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 5b7c5f1c..4daf55c8 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -59,6 +59,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx index 22748f88..3b193752 100644 --- a/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx @@ -1,17 +1,22 @@ +import { Navigate, useParams } from 'react-router'; import type { LayoutNode } from 'officerdev'; -import { WorkspaceView } from 'officerdev'; +import { WorkspaceView, DEFAULT_SOULSEEK_SECTION, soulseekSectionPath, isSoulseekSection } from 'officerdev'; import { useDashboardState } from 'state/useDashboardState'; import { defaultLayout } from './defaultLayout'; -// /soulseek uses the Workspace/Panel system (like /chat and /music): a section nav (soulseek-nav) on -// the left and a section view (soulseek-view) on the right, coordinating via the 'soulseek:section' -// channel. Both talk to slskd through the /api/slskd auth proxy. +// /soulseek uses the Workspace/Panel system (like /photos): a section nav (soulseek-nav) on the left and +// a section view (soulseek-view) on the right, both reading `:section` from the URL. Deeper selection — +// which search, which peer — is the query string. Both talk to slskd through the /api/slskd auth proxy. export const SoulseekScreen = () => { + const { section } = useParams(); // v2: nav + view (replaced the earlier search + transfers split) — new key so the old persisted // layout doesn't resurrect as two mismatched panels. const workspace = useDashboardState('screens/soulseek-v2', defaultLayout); + // After the hooks — it returns early. + if (!isSoulseekSection(section)) return ; + return (
{ export const SoulseekDashboard = () => { const client = useClient(); - const [, setSection] = usePanelChannel(SOULSEEK_SECTION_CHANNEL, 'dashboard'); const [app, setApp] = useState(null); const [downloads, setDownloads] = useState(emptyTally); const [uploads, setUploads] = useState(emptyTally); @@ -85,7 +85,7 @@ export const SoulseekDashboard = () => {
{/* Downloads */} -
} onClick={() => setSection('downloads')}> +
} to={soulseekSectionPath('downloads')}> @@ -93,7 +93,7 @@ export const SoulseekDashboard = () => {
{/* Uploads */} -
} onClick={() => setSection('uploads')}> +
} to={soulseekSectionPath('uploads')}> @@ -105,29 +105,27 @@ export const SoulseekDashboard = () => {
Recent searches - +
{recent.length === 0 ? (

No searches yet.

) : (
{recent.map((s) => ( - + ))}
)} @@ -137,19 +135,18 @@ export const SoulseekDashboard = () => { ); }; -type SectionProps = { title: string; icon: React.ReactNode; onClick: () => void; children: React.ReactNode }; +type SectionProps = { title: string; icon: React.ReactNode; to: string; children: React.ReactNode }; -const Section = ({ title, icon, onClick, children }: SectionProps) => ( +const Section = ({ title, icon, to, children }: SectionProps) => (
- +
{children}
); diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekNav.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekNav.tsx index 1fb04af4..5b2ba11f 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekNav.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekNav.tsx @@ -1,5 +1,5 @@ import type { LucideIcon } from 'lucide-react'; -import { usePanelChannel } from 'hooks/usePanelChannel'; +import { NavLink } from 'react-router'; import { LayoutGrid, Search, @@ -11,10 +11,11 @@ import { Server, Plug, } from 'lucide-react'; -import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, type SoulseekSectionId } from './shared'; +import { SOULSEEK_SECTIONS, soulseekSectionPath, type SoulseekSectionId } from './shared'; -// Left panel of the /soulseek workspace — a vertical section menu mirroring slskd's top nav. Publishes -// the active section to the 'soulseek:section' channel; SoulseekView (right) renders the matching UI. +// Left panel of the /soulseek workspace — a vertical section menu mirroring slskd's top nav. Entries are +// react-router NavLinks to /soulseek/
, so active state comes from the router and SoulseekView +// (right) learns which section is open by reading the same URL rather than by being told. const ICONS: Record = { dashboard: LayoutGrid, @@ -29,8 +30,6 @@ const ICONS: Record = { }; export const SoulseekNav = () => { - const [section, setSection] = usePanelChannel(SOULSEEK_SECTION_CHANNEL, 'dashboard'); - return (
@@ -46,26 +45,30 @@ export const SoulseekNav = () => { diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx index 77b4cffc..5ae02393 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx @@ -1,19 +1,20 @@ import { useState, useEffect } from 'react'; -import { usePanelChannel } from 'hooks/usePanelChannel'; +import { useSearchParams } from 'react-router'; import { toast } from 'sonner'; import { Users, Search, CircleCheck, CircleSlash, Clock, Loader2, Star, X, Download } from 'lucide-react'; import { useSoulseekFavorites } from './useSoulseekFavorites'; import { useSoulseekUser } from './useSoulseekUser'; import { useSoulseekBrowseSnapshots } from './useSoulseekBrowse'; import { SharesBrowser, BrowseStateChip } from './SharesBrowser'; -import { SOULSEEK_USER_CHANNEL, type SoulseekUserRequest } from './shared'; +import { USER_PARAM } from './shared'; // Users panel — look up a peer: their presence (online/away/offline) and profile info (description, // upload slots, queue), both cheap live calls (GET /users/{u}/status + /info). Their shared folders come // from Officer's own cache instead, via — see that file for why it isn't browsed live. // // 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. +// downloads links here with `?user=`, which is the whole mechanism — a peer profile is now +// linkable and survives a reload. // 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. @@ -29,9 +30,9 @@ const presenceStyle = (p?: string): { label: string; dot: string; icon: typeof C }; export const SoulseekUsers = () => { - const [query, setQuery] = useState(''); - const [selected, setSelected] = useState(''); - const [request, setRequest] = usePanelChannel(SOULSEEK_USER_CHANNEL, null); + const [params, setParams] = useSearchParams(); + const selected = params.get(USER_PARAM)?.trim() ?? ''; + const [query, setQuery] = useState(selected); const { favorites, isFavorite, toggle } = useSoulseekFavorites(); const { snapshotOf, fetchShares } = useSoulseekBrowseSnapshots(); @@ -39,23 +40,22 @@ export const SoulseekUsers = () => { const user = useSoulseekUser(selected); const peer = user.data ?? null; + // Navigating, not setting state: the peer is `?user=`. Shares are still NOT fetched automatically — + // that's a multi-minute server-side job, so it stays an explicit click in the shares card. const select = (name: string) => { const username = name.trim(); if (!username) return; - setQuery(username); - setSelected(username); + setParams((prev) => { + const next = new URLSearchParams(prev); + next.set(USER_PARAM, username); + return next; + }); }; - // Consume a peer request from the username dropdown. Cleared as it's handled, so switching away and - // back doesn't re-run the lookup. Shares are NOT fetched automatically — that's a multi-minute - // server-side job, so it stays an explicit click in the shares card. + // Arriving by link (or Back) should show the name in the box that produced it. useEffect(() => { - if (!request) return; - setRequest(null); - select(request.username); - // select is recreated each render; the effect deliberately runs only when a request arrives. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [request]); + if (selected) setQuery(selected); + }, [selected]); useEffect(() => { if (user.error) toast.error(user.error.message); diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx index 4badd9fe..b32716e9 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekView.tsx @@ -1,6 +1,5 @@ import { Construction } from 'lucide-react'; -import { usePanelChannel } from 'hooks/usePanelChannel'; -import { SOULSEEK_SECTION_CHANNEL, SOULSEEK_SECTIONS, type SoulseekSectionId } from './shared'; +import { SOULSEEK_SECTIONS, useSoulseekSection, type SoulseekSectionId } from './shared'; import { SearchView } from './SearchView'; import { SoulseekTransfers } from './SoulseekTransfers'; import { SoulseekUploads } from './SoulseekUploads'; @@ -12,7 +11,7 @@ import { SoulseekSystem } from './SoulseekSystem'; import { SoulseekConnection } from './SoulseekConnection'; import { useServiceConnection } from '../../hooks/useServiceConnection'; -// Right panel of the /soulseek workspace — renders the UI for the section the nav selected. Content +// Right panel of the /soulseek workspace — renders the UI for the section the URL names. Content // zoom used to live here; it is now a framework-level per-panel control in PanelSlot. const Placeholder = ({ id }: { id: SoulseekSectionId }) => { @@ -56,7 +55,7 @@ const sectionView = (section: SoulseekSectionId) => { }; export const SoulseekView = () => { - const [section] = usePanelChannel(SOULSEEK_SECTION_CHANNEL, 'dashboard'); + const section = useSoulseekSection(); const { data: connection, isLoading } = useServiceConnection('slskd'); // Nothing connected yet: the setup form takes over every section, because none of them can do anything diff --git a/src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx b/src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx index 7939cd71..f92661bf 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/UserMenu.tsx @@ -1,4 +1,4 @@ -import { usePanelChannel } from 'hooks/usePanelChannel'; +import { Link } from 'react-router'; import { toast } from 'sonner'; import { FolderOpen, Star, StarOff } from 'lucide-react'; import { @@ -10,30 +10,19 @@ import { DropdownMenuSeparator, } from '@/components/ui/dropdown-menu'; import { useSoulseekFavorites } from './useSoulseekFavorites'; -import { - SOULSEEK_SECTION_CHANNEL, - SOULSEEK_USER_CHANNEL, - type SoulseekSectionId, - type SoulseekUserRequest, -} from './shared'; +import { soulseekUserPath } from './shared'; // The peer dropdown, shared by search results and downloads: click a username anywhere in the workspace -// and act on that peer. Opening a peer hands the username to the Users section over a panel channel -// (which owns the lookup and the cached share tree); favouriting goes to the sidecar's favourites route. +// and act on that peer. Opening a peer is a real link to /soulseek/users?user= — the Users section +// still owns the lookup and the cached share tree, it just reads who from the URL. Favouriting is a +// mutation, so it stays a menu item that acts rather than navigates. 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 openPeer = () => { - setRequest({ username, nonce: Date.now() }); - setSection('users'); - }; - const toggleFavorite = () => { toggle(username); toast.success(favorited ? `Removed ${username} from favorites` : `Added ${username} to favorites`); @@ -54,9 +43,11 @@ export const UserMenu = ({ username }: UserMenuProps) => { {username} - - - View profile & shares + + + + View profile & shares + {favorited ? : } diff --git a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts index f4aa558c..9ee73ac8 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts +++ b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts @@ -1,5 +1,8 @@ -// Shared types/helpers for the /soulseek workspace panels (SoulseekSearch + SoulseekTransfers), which -// talk to slskd through the /api/slskd auth proxy and coordinate over a single panel channel. +// Shared types/helpers for the /soulseek workspace panels, which talk to slskd through the /api/slskd +// auth proxy. What is open — the section, the peer, the search — is the URL; the one channel left here +// is a refresh signal, which is what channels are for. + +import { useParams } from 'react-router'; // Bumped (to a fresh nonce) whenever a download is enqueued from the search panel, so the transfers // panel refetches immediately instead of waiting for its next poll tick. @@ -235,9 +238,8 @@ export type SlskdRoomMessage = { export type SlskdRoom = { name: string; isPrivate?: boolean; users?: SlskdRoomUser[]; messages?: SlskdRoomMessage[] }; export type SlskdRoomInfo = { name: string; userCount: number; isPrivate?: boolean }; -// The nav panel (SoulseekNav) publishes the active section here; the view panel (SoulseekView) reads -// it and renders the matching UI. Mirrors slskd's own top-menu sections. -export const SOULSEEK_SECTION_CHANNEL = 'soulseek:section'; +// Which section is open is `/soulseek/:section` — the nav panel and the view panel both read the URL and +// neither publishes to the other. Mirrors slskd's own top-menu sections. export type SoulseekSectionId = | 'dashboard' | 'search' @@ -260,14 +262,34 @@ export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [ { id: 'connection', label: 'Connection' }, ]; -// 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 the lookup, while two requests for the SAME username still each trigger a fresh one. -export const SOULSEEK_USER_CHANNEL = 'soulseek:user'; -export type SoulseekUserRequest = { username: string; nonce: number }; +/** Where /soulseek lands, and where an unrecognised section redirects to. */ +export const DEFAULT_SOULSEEK_SECTION: SoulseekSectionId = 'dashboard'; + +export const isSoulseekSection = (value: string | undefined): value is SoulseekSectionId => + SOULSEEK_SECTIONS.some((s) => s.id === value); + +export const soulseekSectionPath = (section: SoulseekSectionId) => `/soulseek/${section}`; + +// SoulseekScreen redirects anything unrecognised, so the fallback here only covers the instant before +// that lands. +export const useSoulseekSection = (): SoulseekSectionId => { + const { section } = useParams(); + return isSoulseekSection(section) ? section : DEFAULT_SOULSEEK_SECTION; +}; + +// Which peer the Users section is showing — `/soulseek/users?user=`. +// +// This was a `soulseek:user` channel carrying `{username, nonce}`: the username dropdown published a +// request, the Users panel consumed it once and cleared it, and the nonce existed so that asking for the +// SAME peer twice still counted as two requests. All of that machinery was a workaround for the peer not +// being in the URL — a link is idempotent, so there is nothing to consume and no nonce to disambiguate. +export const USER_PARAM = 'user'; + +export const soulseekUserPath = (username: string) => + `${soulseekSectionPath('users')}?${USER_PARAM}=${encodeURIComponent(username)}`; // A past search, as listed by GET /searches (no responses inlined). -// Which search's results are open — `/soulseek?search=`, read from the URL rather than held in a +// Which search's results are open — `/soulseek/search?search=`, read from the URL rather than held in a // panel channel or local state. See docs/navigation-audit.md. export const SEARCH_PARAM = 'search'; diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index a8dd2596..d0f404ef 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -43,6 +43,15 @@ export type { PhotosSectionId } from './apps/Photos/shared'; export { DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from './apps/Jellyfin/shared'; export type { JellyfinSectionId } from './apps/Jellyfin/shared'; +// Same for /soulseek, which also puts the open peer in `?user=`. +export { + DEFAULT_SOULSEEK_SECTION, + soulseekSectionPath, + soulseekUserPath, + isSoulseekSection, +} from './apps/Soulseek/shared'; +export type { SoulseekSectionId } from './apps/Soulseek/shared'; + // Same for /system-monitor, where the param is the scope rather than a section. export { DEFAULT_MONITOR_SCOPE, monitorScopePath, isMonitorScope } from './apps/SystemMonitor/shared'; export type { MonitorScope } from './apps/SystemMonitor/shared';