From 1534bc2ea7fadb84d107e7582f0a14bd04930450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 30 Jul 2026 02:24:52 +0000 Subject: [PATCH] soulseek: cache each peer's profile instead of refetching it per click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presence and profile lived in component state, and selecting a peer began by clearing it — so every click blanked the panel back to "enter a username", then rebuilt it from two network calls, even for a peer looked at seconds earlier. The panel is mostly used by bouncing between the same handful of favourites, which made that the common path. A query keyed by username makes the second visit free and the first one non-destructive: the card now renders from the selected name, so it appears immediately with the presence line filling in, and the shares browser below it stays mounted rather than unmounting and losing its expanded tree. Co-Authored-By: Claude Opus 4.8 --- .../src/apps/Soulseek/SoulseekUsers.tsx | 95 +++++++++---------- .../src/apps/Soulseek/useSoulseekUser.ts | 45 +++++++++ 2 files changed, 90 insertions(+), 50 deletions(-) create mode 100644 src/workspaces/officerdev/src/apps/Soulseek/useSoulseekUser.ts diff --git a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx index 31664158..77b4cffc 100644 --- a/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx +++ b/src/workspaces/officerdev/src/apps/Soulseek/SoulseekUsers.tsx @@ -1,12 +1,12 @@ import { useState, useEffect } from 'react'; -import { useClient } from 'hooks/useClient'; import { usePanelChannel } from 'hooks/usePanelChannel'; 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 SlskdUserInfo, type SlskdUserStatus, type SoulseekUserRequest } from './shared'; +import { SOULSEEK_USER_CHANNEL, type SoulseekUserRequest } 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 @@ -17,12 +17,6 @@ import { SOULSEEK_USER_CHANNEL, type SlskdUserInfo, type SlskdUserStatus, type S // 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; - status: SlskdUserStatus | null; - info: SlskdUserInfo | null; -}; - const presenceStyle = (p?: string): { label: string; dot: string; icon: typeof CircleCheck } => { switch ((p ?? '').toLowerCase()) { case 'online': @@ -35,34 +29,21 @@ const presenceStyle = (p?: string): { label: string; dot: string; icon: typeof C }; export const SoulseekUsers = () => { - const client = useClient(); const [query, setQuery] = useState(''); - const [loading, setLoading] = useState(false); - const [peer, setPeer] = useState(null); + const [selected, setSelected] = useState(''); const [request, setRequest] = usePanelChannel(SOULSEEK_USER_CHANNEL, null); const { favorites, isFavorite, toggle } = useSoulseekFavorites(); const { snapshotOf, fetchShares } = useSoulseekBrowseSnapshots(); - const lookup = async (name: string) => { + // Selecting a peer is now just naming one — the query owns the fetching, and its cache owns the answer. + const user = useSoulseekUser(selected); + const peer = user.data ?? null; + + const select = (name: string) => { const username = name.trim(); - if (!username || loading) return; + if (!username) return; setQuery(username); - setLoading(true); - setPeer(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, - }); + setSelected(username); }; // Consume a peer request from the username dropdown. Cleared as it's handled, so switching away and @@ -71,13 +52,19 @@ export const SoulseekUsers = () => { useEffect(() => { if (!request) return; setRequest(null); - lookup(request.username); - // lookup is recreated each render; the effect deliberately runs only when a request arrives. + 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]); + useEffect(() => { + if (user.error) toast.error(user.error.message); + }, [user.error]); + const pres = presenceStyle(peer?.status?.presence); const PresIcon = pres.icon; + // Only a peer we have nothing cached for is worth showing a pending state for. + const pending = !!selected && user.isPending; return (
@@ -86,7 +73,7 @@ export const SoulseekUsers = () => {
{ ev.preventDefault(); - lookup(query); + select(query); }} className="flex flex-1 items-center gap-2" > @@ -98,10 +85,10 @@ export const SoulseekUsers = () => { />
@@ -125,7 +112,7 @@ export const SoulseekUsers = () => { ) : (
{favorites.map((u) => { - const current = peer?.username === u; + const current = selected === u; return (
{ >
- {!peer ? ( + {!selected ? (

Enter a Soulseek username to see their profile and shares.

) : ( <> - {/* Profile */} + {/* Profile. Keyed off the selected name, not the loaded peer, so a lookup already in cache + paints instantly and a fresh one fills in around a card that's already on screen. */}
- {peer.username.charAt(0).toUpperCase()} + {selected.charAt(0).toUpperCase()}
-
{peer.username}
-
- - {pres.label} - {peer.status?.isPrivileged && · privileged} -
+
{selected}
+ {pending ? ( +
+ + Checking… +
+ ) : ( +
+ + {pres.label} + {peer?.status?.isPrivileged && · privileged} +
+ )}
- {peer.info && ( + {peer?.info && ( <>
@@ -220,7 +215,7 @@ export const SoulseekUsers = () => { )}
- + )}
diff --git a/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekUser.ts b/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekUser.ts new file mode 100644 index 00000000..116dc71d --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Soulseek/useSoulseekUser.ts @@ -0,0 +1,45 @@ +import { useQuery } from '@tanstack/react-query'; +import { useClient } from 'hooks/useClient'; +import type { SlskdUserInfo, SlskdUserStatus } from './shared'; + +/** A peer's presence and profile, as far as either could be reached. */ +export type SoulseekUser = { + username: string; + status: SlskdUserStatus | null; + info: SlskdUserInfo | null; +}; + +/** + * One peer's presence and profile, cached per username. Selecting a peer you've already looked at paints + * from cache instead of blanking the panel back to its empty state and re-asking the network for an + * answer it just had — which is what local state did, once per click. + */ +export function useSoulseekUser(username: string) { + const { get } = useClient(); + + return useQuery({ + queryKey: ['soulseek', 'user', username], + queryFn: async (): Promise => { + const u = encodeURIComponent(username); + const [status, info] = await Promise.allSettled([ + get(`/slskd/api/v0/users/${u}/status`), + get(`/slskd/api/v0/users/${u}/info`), + ]); + // Half an answer is still worth showing — a reachable peer with a private profile answers only one + // of the two. Both failing is what actually means "couldn't reach them". + if (status.status === 'rejected' && info.status === 'rejected') { + throw new Error(`Couldn't reach ${username} — they may be offline.`); + } + return { + username, + status: status.status === 'fulfilled' ? status.value : null, + info: info.status === 'fulfilled' ? info.value : null, + }; + }, + enabled: !!username, + // Presence drifts, but not fast enough to be worth a round trip every time the panel remounts. + staleTime: 60_000, + // An unreachable peer IS the answer, not a fault to retry: retrying only delays saying so. + retry: false, + }); +}