soulseek: cache each peer's profile instead of refetching it per click

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 02:24:52 +00:00
co-authored by Claude Opus 4.8
parent fc00d3861c
commit 1534bc2ea7
2 changed files with 90 additions and 50 deletions
@@ -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<Loaded | null>(null);
const [selected, setSelected] = useState('');
const [request, setRequest] = usePanelChannel<SoulseekUserRequest | null>(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<SlskdUserStatus>(`/slskd/api/v0/users/${encodeURIComponent(username)}/status`),
client.get<SlskdUserInfo>(`/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 (
<div className="flex h-full flex-col overflow-hidden">
@@ -86,7 +73,7 @@ export const SoulseekUsers = () => {
<form
onSubmit={(ev) => {
ev.preventDefault();
lookup(query);
select(query);
}}
className="flex flex-1 items-center gap-2"
>
@@ -98,10 +85,10 @@ export const SoulseekUsers = () => {
/>
<button
type="submit"
disabled={!query.trim() || loading}
disabled={!query.trim() || user.isFetching}
className="flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3 text-sm text-primary-foreground transition hover:bg-primary/90 disabled:opacity-40"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
{user.isFetching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
Look up
</button>
</form>
@@ -125,7 +112,7 @@ export const SoulseekUsers = () => {
) : (
<div className="max-h-64 divide-y divide-white/5 overflow-y-auto">
{favorites.map((u) => {
const current = peer?.username === u;
const current = selected === u;
return (
<div
key={u}
@@ -135,7 +122,7 @@ export const SoulseekUsers = () => {
>
<button
type="button"
onClick={() => lookup(u)}
onClick={() => select(u)}
title={`Look up ${u}`}
className={`min-w-0 flex-1 truncate text-left transition ${
current ? 'font-medium text-primary' : 'text-zinc-200 hover:text-zinc-100'
@@ -169,38 +156,46 @@ export const SoulseekUsers = () => {
)}
</div>
{!peer ? (
{!selected ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center text-muted-foreground">
<Users className="h-8 w-8" />
<p className="text-sm">Enter a Soulseek username to see their profile and shares.</p>
</div>
) : (
<>
{/* 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. */}
<div className="rounded-xl border border-white/10 bg-zinc-950 p-4">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-white/5 text-lg font-semibold text-zinc-300">
{peer.username.charAt(0).toUpperCase()}
{selected.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-zinc-100">{peer.username}</div>
<div className={`flex items-center gap-1.5 text-xs ${pres.dot}`}>
<PresIcon className="h-3.5 w-3.5" />
{pres.label}
{peer.status?.isPrivileged && <span className="text-amber-400">· privileged</span>}
</div>
<div className="truncate text-sm font-semibold text-zinc-100">{selected}</div>
{pending ? (
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Checking…
</div>
) : (
<div className={`flex items-center gap-1.5 text-xs ${pres.dot}`}>
<PresIcon className="h-3.5 w-3.5" />
{pres.label}
{peer?.status?.isPrivileged && <span className="text-amber-400">· privileged</span>}
</div>
)}
</div>
<button
type="button"
onClick={() => toggle(peer.username)}
title={isFavorite(peer.username) ? 'Remove from favorites' : 'Add to favorites'}
onClick={() => toggle(selected)}
title={isFavorite(selected) ? 'Remove from favorites' : 'Add to favorites'}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-zinc-500 transition hover:bg-white/5 hover:text-zinc-200"
>
<Star className={`h-4 w-4 ${isFavorite(peer.username) ? 'fill-amber-400 text-amber-400' : ''}`} />
<Star className={`h-4 w-4 ${isFavorite(selected) ? 'fill-amber-400 text-amber-400' : ''}`} />
</button>
</div>
{peer.info && (
{peer?.info && (
<>
<div className="mt-4 grid grid-cols-3 gap-2">
<Stat label="Upload slots" value={peer.info.uploadSlots ?? 0} />
@@ -220,7 +215,7 @@ export const SoulseekUsers = () => {
)}
</div>
<SharesBrowser username={peer.username} />
<SharesBrowser username={selected} />
</>
)}
</div>
@@ -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<SoulseekUser> => {
const u = encodeURIComponent(username);
const [status, info] = await Promise.allSettled([
get<SlskdUserStatus>(`/slskd/api/v0/users/${u}/status`),
get<SlskdUserInfo>(`/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,
});
}