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