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:
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user