community: one area for the people on both networks, and the hub grows search tabs, a folder column and a queue column
This commit is contained in:
1 parent
6dce4e2ac7
commit
8c25f93afe
14 files changed
+2368
-68
No files matched your search
@@ -488,3 +488,27 @@ export function onEvent(name, handler) {
|
||||
const un = listen(name, (e) => handler(e.payload)).catch(() => () => {});
|
||||
return () => un.then((f) => f());
|
||||
}
|
||||
|
||||
// ---- the Community area: Freenet layer, persona, profile, local contacts ----
|
||||
export const freenetStatus = () => invoke("freenet_status");
|
||||
export const freenetSetEnabled = (enabled) => invoke("freenet_set_enabled", { enabled });
|
||||
export const freenetDetect = () => invoke("freenet_detect");
|
||||
export const freenetSetLoadRemoteImages = (on) => invoke("freenet_set_load_remote_images", { on });
|
||||
export const freenetNodeInfo = () => invoke("freenet_node_info");
|
||||
export const freenetNodeStart = () => invoke("freenet_node_start");
|
||||
export const freenetNodeStop = () => invoke("freenet_node_stop");
|
||||
export const personaStatus = () => invoke("persona_status");
|
||||
export const personaCreate = () => invoke("persona_create");
|
||||
export const personaExport = (passphrase, path) => invoke("persona_export", { passphrase, path });
|
||||
export const personaImport = (passphrase, path, force = false) =>
|
||||
invoke("persona_import", { passphrase, path, force });
|
||||
export const personaRotate = () => invoke("persona_rotate");
|
||||
export const personaRemove = () => invoke("persona_remove");
|
||||
export const profileGet = (persona = null, force = false) => invoke("profile_get", { persona, force });
|
||||
export const profilePublish = (displayName, bio) => invoke("profile_publish", { displayName, bio });
|
||||
export const profileSetAvatar = (imagePath) => invoke("profile_set_avatar", { imagePath });
|
||||
export const profileClearAvatar = () => invoke("profile_clear_avatar");
|
||||
export const contactsMetaList = () => invoke("contacts_meta_list");
|
||||
export const contactMetaSet = (meta) => invoke("contact_meta_set", { meta });
|
||||
/** kind: "image" | "recovery-open" | "recovery-save" -> a path, or null. */
|
||||
export const communityPickFile = (kind) => invoke("community_pick_file", { kind });
|
||||
@@ -0,0 +1,301 @@
|
||||
// The Community area's own store: the Freenet layer's status, the persona,
|
||||
// the published profile, the local contact metadata and the two device-local
|
||||
// memories (last seen, favourite rooms). Everything Soulseek-live — chats,
|
||||
// rooms, buddies — stays in state.svelte.js's `slsk` and is only READ here.
|
||||
//
|
||||
// Two words the labels below are careful about, from the design's truthful
|
||||
// list (FREENET-SUPPLEMENT-DESIGN §10.3): a successful fetch is "synced as of
|
||||
// <time>", never "online"; and the adapter's states are named for what they
|
||||
// are (node stopped, incompatible, connecting), never softened.
|
||||
import * as api from "./api.js";
|
||||
|
||||
const LS_SEEN = "community.lastSeen";
|
||||
const LS_FAVS = "community.roomFavs";
|
||||
const LS_SECTION = "community.section";
|
||||
|
||||
function readJson(key, fallback) {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(key) ?? "null");
|
||||
return v ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
function writeJson(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// storage full or unavailable — a lost memory, never a failure
|
||||
}
|
||||
}
|
||||
|
||||
export const SECTIONS = ["inbox", "rooms", "people", "discover", "spaces", "you"];
|
||||
|
||||
export const community = $state({
|
||||
section: SECTIONS.includes(readJson(LS_SECTION, "")) ? readJson(LS_SECTION, "") : "inbox",
|
||||
/** freenet_status — null until first asked. */
|
||||
freenet: null,
|
||||
/** freenet_node_info — the installed peer, if any, and whether we run it. */
|
||||
nodeInfo: null,
|
||||
/** persona_status — null until first asked. */
|
||||
persona: null,
|
||||
/** profile_get for our own persona — null when none published. */
|
||||
profile: null,
|
||||
/** ms of the last successful profile read; what "synced as of" shows. */
|
||||
profileAt: null,
|
||||
profileError: "",
|
||||
/** username → ContactMeta (petname, group, note, trusted, favorite). */
|
||||
contacts: {},
|
||||
/** username → ms this device last saw them online or away. */
|
||||
lastSeen: readJson(LS_SEEN, {}),
|
||||
/** Favourite room names, device-local. */
|
||||
roomFavs: readJson(LS_FAVS, []),
|
||||
/** Which person the drawer shows, or null. */
|
||||
drawer: null,
|
||||
/** key → true while an action runs (buttons disable on it). */
|
||||
busy: {},
|
||||
/** The last action's error, shown where it happened. */
|
||||
error: "",
|
||||
});
|
||||
|
||||
export function setSection(s) {
|
||||
if (!SECTIONS.includes(s)) return;
|
||||
community.section = s;
|
||||
writeJson(LS_SECTION, s);
|
||||
}
|
||||
|
||||
/** The adapter's status on the wire: `{kind: "connected"}` — serde's
|
||||
* internally tagged, kebab-case form (detail-carrying variants add
|
||||
* `detail`). Older shapes (a bare string) are folded in for safety. */
|
||||
export function statusKind(status) {
|
||||
if (!status) return "";
|
||||
if (typeof status === "string") return status.toLowerCase();
|
||||
return String(status.kind ?? Object.keys(status)[0] ?? "").toLowerCase();
|
||||
}
|
||||
/** Human words for an AdapterStatus, from the design's truthful list. */
|
||||
export function adapterLabel(status) {
|
||||
switch (statusKind(status)) {
|
||||
case "disabled":
|
||||
return { text: "local only", tone: "off" };
|
||||
case "detecting":
|
||||
return { text: "connecting", tone: "acc" };
|
||||
case "nodeabsent":
|
||||
case "node-absent":
|
||||
return { text: "node not installed or stopped", tone: "warn" };
|
||||
case "nodeoccupied":
|
||||
case "node-occupied":
|
||||
return { text: "port in use by something else", tone: "warn" };
|
||||
case "nodeincompatible":
|
||||
case "node-incompatible":
|
||||
return { text: "incompatible node", tone: "err" };
|
||||
case "connected":
|
||||
return { text: "connected", tone: "ok" };
|
||||
case "lost":
|
||||
return { text: "connection lost · retrying", tone: "warn" };
|
||||
default:
|
||||
return { text: "unknown", tone: "off" };
|
||||
}
|
||||
}
|
||||
export const adapterConnected = (status) => statusKind(status) === "connected";
|
||||
|
||||
async function run(key, fn) {
|
||||
community.busy[key] = true;
|
||||
community.error = "";
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
community.error = String(e?.message ?? e);
|
||||
throw e;
|
||||
} finally {
|
||||
delete community.busy[key];
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the Freenet layer ----------------------------------------------------
|
||||
export async function refreshFreenet() {
|
||||
try {
|
||||
community.freenet = await api.freenetStatus();
|
||||
} catch (e) {
|
||||
community.error = String(e);
|
||||
}
|
||||
await refreshNodeInfo();
|
||||
}
|
||||
export async function refreshNodeInfo() {
|
||||
try {
|
||||
community.nodeInfo = await api.freenetNodeInfo();
|
||||
} catch {
|
||||
// no node info is not an error state — the card shows what it has
|
||||
}
|
||||
}
|
||||
export const startNode = () =>
|
||||
run("node", async () => {
|
||||
community.nodeInfo = await api.freenetNodeStart();
|
||||
});
|
||||
export const stopNode = () =>
|
||||
run("node", async () => {
|
||||
community.nodeInfo = await api.freenetNodeStop();
|
||||
});
|
||||
export const setFreenetEnabled = (on) =>
|
||||
run("freenet", async () => {
|
||||
community.freenet = await api.freenetSetEnabled(on);
|
||||
if (!on) {
|
||||
community.persona = null;
|
||||
community.profile = null;
|
||||
community.profileAt = null;
|
||||
}
|
||||
});
|
||||
export const setLoadRemoteImages = (on) =>
|
||||
run("images", async () => {
|
||||
community.freenet = await api.freenetSetLoadRemoteImages(on);
|
||||
});
|
||||
export const detectNode = () =>
|
||||
run("detect", async () => {
|
||||
const status = await api.freenetDetect();
|
||||
if (community.freenet) community.freenet.status = status;
|
||||
return status;
|
||||
});
|
||||
|
||||
// ---- persona ----------------------------------------------------------------
|
||||
export async function refreshPersona() {
|
||||
try {
|
||||
community.persona = await api.personaStatus();
|
||||
} catch (e) {
|
||||
community.error = String(e);
|
||||
return;
|
||||
}
|
||||
if (community.persona?.persona) await loadProfile();
|
||||
else {
|
||||
community.profile = null;
|
||||
community.profileAt = null;
|
||||
}
|
||||
}
|
||||
export const personaKey = () => community.persona?.persona?.public_key ?? null;
|
||||
|
||||
export const createPersona = () =>
|
||||
run("persona", async () => {
|
||||
await api.personaCreate();
|
||||
await refreshPersona();
|
||||
});
|
||||
export const exportPersona = (passphrase) =>
|
||||
run("export", async () => {
|
||||
const path = await api.communityPickFile("recovery-save");
|
||||
if (!path) return null;
|
||||
const bytes = await api.personaExport(passphrase, path);
|
||||
return { path, bytes };
|
||||
});
|
||||
export const importPersona = (passphrase, force = false) =>
|
||||
run("import", async () => {
|
||||
const path = await api.communityPickFile("recovery-open");
|
||||
if (!path) return null;
|
||||
const info = await api.personaImport(passphrase, path, force);
|
||||
await refreshPersona();
|
||||
return info;
|
||||
});
|
||||
export const rotatePersona = () =>
|
||||
run("rotate", async () => {
|
||||
const info = await api.personaRotate();
|
||||
await refreshPersona();
|
||||
return info;
|
||||
});
|
||||
export const removePersona = () =>
|
||||
run("remove", async () => {
|
||||
await api.personaRemove();
|
||||
await refreshPersona();
|
||||
});
|
||||
|
||||
// ---- profile ----------------------------------------------------------------
|
||||
/** Our own profile: instant from the device's ledger; `force` asks the
|
||||
* network, which can take a minute when nothing is published. */
|
||||
export async function loadProfile(force = false) {
|
||||
community.profileError = "";
|
||||
community.busy.profile = true;
|
||||
try {
|
||||
community.profile = await api.profileGet(null, force);
|
||||
community.profileAt = Date.now();
|
||||
} catch (e) {
|
||||
community.profileError = String(e);
|
||||
} finally {
|
||||
delete community.busy.profile;
|
||||
}
|
||||
}
|
||||
export const publishProfile = (displayName, bio) =>
|
||||
run("publish", async () => {
|
||||
community.profile = await api.profilePublish(displayName, bio);
|
||||
community.profileAt = Date.now();
|
||||
});
|
||||
export const pickAndSetAvatar = () =>
|
||||
run("avatar", async () => {
|
||||
const path = await api.communityPickFile("image");
|
||||
if (!path) return false;
|
||||
community.profile = await api.profileSetAvatar(path);
|
||||
community.profileAt = Date.now();
|
||||
return true;
|
||||
});
|
||||
export const clearAvatar = () =>
|
||||
run("avatar", async () => {
|
||||
community.profile = await api.profileClearAvatar();
|
||||
community.profileAt = Date.now();
|
||||
});
|
||||
|
||||
// ---- local contact metadata --------------------------------------------------
|
||||
function indexContacts(list) {
|
||||
community.contacts = Object.fromEntries(list.map((c) => [c.username, c]));
|
||||
}
|
||||
export async function loadContacts() {
|
||||
try {
|
||||
indexContacts(await api.contactsMetaList());
|
||||
} catch (e) {
|
||||
community.error = String(e);
|
||||
}
|
||||
}
|
||||
export const metaOf = (username) =>
|
||||
community.contacts[username] ?? {
|
||||
username,
|
||||
petname: null,
|
||||
group: null,
|
||||
note: null,
|
||||
trusted: false,
|
||||
favorite: false,
|
||||
};
|
||||
export const setContactMeta = (meta) =>
|
||||
run("meta:" + meta.username, async () => {
|
||||
indexContacts(await api.contactMetaSet(meta));
|
||||
});
|
||||
|
||||
// ---- device-local memories ------------------------------------------------------
|
||||
let seenFlush = 0;
|
||||
export function noteSeen(username) {
|
||||
community.lastSeen[username] = Date.now();
|
||||
clearTimeout(seenFlush);
|
||||
seenFlush = setTimeout(() => writeJson(LS_SEEN, community.lastSeen), 1500);
|
||||
}
|
||||
export function toggleRoomFav(name) {
|
||||
const i = community.roomFavs.indexOf(name);
|
||||
if (i >= 0) community.roomFavs.splice(i, 1);
|
||||
else community.roomFavs.push(name);
|
||||
writeJson(LS_FAVS, community.roomFavs);
|
||||
}
|
||||
|
||||
// ---- boot -------------------------------------------------------------------------
|
||||
let booted = false;
|
||||
/** Idempotent: the first Community mount wires the listeners and pulls the
|
||||
* initial state; later mounts only refresh. */
|
||||
export async function initCommunity() {
|
||||
if (!booted) {
|
||||
booted = true;
|
||||
api.onEvent("freenet:status", (status) => {
|
||||
if (community.freenet) community.freenet.status = status;
|
||||
else void refreshFreenet();
|
||||
void refreshNodeInfo();
|
||||
// A fresh connection may carry a persona we have not read yet;
|
||||
// a lost one invalidates what we had.
|
||||
if (adapterConnected(status)) void refreshPersona();
|
||||
else if (community.persona) community.persona.available = false;
|
||||
});
|
||||
api.onEvent("soulseek:user-state", (p) => {
|
||||
if (p.status === 1 || p.status === 2) noteSeen(p.username);
|
||||
});
|
||||
}
|
||||
await Promise.all([refreshFreenet(), loadContacts()]);
|
||||
await refreshPersona();
|
||||
}
|
||||
@@ -55,6 +55,7 @@ function entryKey(e) {
|
||||
* back to — "Search" as a bare destination is a box you have already left. */
|
||||
const ROOTS = new Set([
|
||||
"home",
|
||||
"community",
|
||||
"search",
|
||||
"library",
|
||||
"downloads",
|
||||
|
||||
+175
-49
@@ -1722,6 +1722,7 @@ export function goToView(view, opts = {}) {
|
||||
const VIEW_LABELS = {
|
||||
home: "Home",
|
||||
soulseek: "Soulseek",
|
||||
community: "Community",
|
||||
search: "Search",
|
||||
library: "Your Library",
|
||||
downloads: "Downloads",
|
||||
@@ -1733,6 +1734,7 @@ const VIEW_LABELS = {
|
||||
const VIEW_GLYPHS = {
|
||||
home: "home",
|
||||
soulseek: "globe",
|
||||
community: "users",
|
||||
search: "search",
|
||||
library: "library",
|
||||
downloads: "download",
|
||||
@@ -5447,27 +5449,68 @@ export async function saveCatalogAlbum(album) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export const search = $state({
|
||||
token: null,
|
||||
query: "",
|
||||
searching: false,
|
||||
responses: 0,
|
||||
// key: `${username}|${folder}` -> group summary (scalars only — the file
|
||||
// lists live outside reactive state, see searchFiles below).
|
||||
groups: {},
|
||||
/* --------------------------------------------------------------- searching
|
||||
* Qt keeps every search alive in its own tab, and so do we: `searchTabs` is
|
||||
* the strip, each entry a self-contained session (its own token, groups and
|
||||
* raw file map). `search` below is the ACTIVE session's face — the whole UI
|
||||
* reads through it, so a tab switch is a one-line assignment here rather than
|
||||
* a rewrite there. */
|
||||
export const SEARCH_TABS_MAX = 8;
|
||||
|
||||
export const searchTabs = $state({
|
||||
/** Newest last. Each: {id, token, query, searching, responses, groups} */
|
||||
list: [],
|
||||
activeId: null,
|
||||
/** What searchNetworkFor is about to look for, shown by the box in the
|
||||
* gap before the first tab exists (and if login interrupts). */
|
||||
seeded: "",
|
||||
});
|
||||
|
||||
// Raw per-group file lists. Deliberately NOT reactive: a broad search stores
|
||||
// up to 300 × 500 file objects, and wrapping each in a $state proxy is what
|
||||
// used to freeze the webview (GC storm at ~1GB+). Components re-render off
|
||||
// the group's reactive fileCount and read the list through filesForGroup().
|
||||
const searchFiles = new Map();
|
||||
let searchStopTimer = null;
|
||||
let searchRequest = 0;
|
||||
let searchSeq = 0;
|
||||
// Raw per-group file lists, per session. Deliberately NOT reactive: a broad
|
||||
// search stores up to 300 × 500 file objects, and wrapping each in a $state
|
||||
// proxy is what used to freeze the webview (GC storm at ~1GB+). Components
|
||||
// re-render off the group's reactive fileCount and read the list through
|
||||
// filesForGroup(). Keyed by session id so closing a tab frees the whole map.
|
||||
const searchFilesById = new Map();
|
||||
// Session id -> its 45s routing-close timer.
|
||||
const searchStopTimers = new Map();
|
||||
const EMPTY_GROUPS = Object.freeze({});
|
||||
|
||||
function trimHiddenSearchFiles() {
|
||||
function sessionById(id) {
|
||||
return searchTabs.list.find((s) => s.id === id) ?? null;
|
||||
}
|
||||
function activeSession() {
|
||||
return sessionById(searchTabs.activeId);
|
||||
}
|
||||
function filesOf(id) {
|
||||
let m = searchFilesById.get(id);
|
||||
if (!m) searchFilesById.set(id, (m = new Map()));
|
||||
return m;
|
||||
}
|
||||
|
||||
/** The active session, as the single object every existing reader expects. */
|
||||
export const search = {
|
||||
get token() { return activeSession()?.token ?? null; },
|
||||
set token(v) { const s = activeSession(); if (s) s.token = v; },
|
||||
get query() { return activeSession()?.query ?? searchTabs.seeded; },
|
||||
// Seed ONLY. searchNetworkFor assigns this before it calls runSearch, and
|
||||
// a write-through would rename the live tab — which runSearch would then
|
||||
// find by query and re-run in place, wiping the results the user was
|
||||
// looking at. A tab's query is its identity; only runSearch sets it.
|
||||
set query(v) { searchTabs.seeded = v; },
|
||||
get searching() { return activeSession()?.searching ?? false; },
|
||||
set searching(v) { const s = activeSession(); if (s) s.searching = v; },
|
||||
get responses() { return activeSession()?.responses ?? 0; },
|
||||
set responses(v) { const s = activeSession(); if (s) s.responses = v; },
|
||||
get groups() { return activeSession()?.groups ?? EMPTY_GROUPS; },
|
||||
set groups(v) { const s = activeSession(); if (s) s.groups = v; },
|
||||
};
|
||||
|
||||
function trimHiddenSearchFiles(session) {
|
||||
if (!session) return;
|
||||
const visibleKeys = new Set(
|
||||
Object.values(search.groups)
|
||||
Object.values(session.groups)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(b.hasFreeSlot - a.hasFreeSlot)
|
||||
@@ -5476,13 +5519,15 @@ function trimHiddenSearchFiles() {
|
||||
.slice(0, 120)
|
||||
.map((group) => group.key),
|
||||
);
|
||||
for (const key of searchFiles.keys()) {
|
||||
if (!visibleKeys.has(key)) searchFiles.delete(key);
|
||||
const files = filesOf(session.id);
|
||||
for (const key of files.keys()) {
|
||||
if (!visibleKeys.has(key)) files.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function filesForGroup(key) {
|
||||
return searchFiles.get(key)?.files ?? [];
|
||||
// Read-only: never creates a map (a null activeId would leave one behind).
|
||||
return searchFilesById.get(searchTabs.activeId)?.get(key)?.files ?? [];
|
||||
}
|
||||
|
||||
// Mirrors crates/audio-formats: everything recognised as audio, playable or
|
||||
@@ -5530,6 +5575,9 @@ export const slsk = $state({
|
||||
buddies: [],
|
||||
/** username → { status?, avg_speed?, file_count?, dir_count?, exists? }. */
|
||||
buddyState: {},
|
||||
/** A username another area (Community) wants browsed in the hub's Browse
|
||||
* tab; the hub consumes and clears it on arrival. */
|
||||
browseRequest: null,
|
||||
});
|
||||
|
||||
const SLSK_FEED_MAX = 500;
|
||||
@@ -6243,20 +6291,25 @@ export function initEvents() {
|
||||
// The backend batches search responses (~150ms ticks) so a broad search
|
||||
// can't flood the IPC bridge or trigger a reactive flush per response.
|
||||
api.onEvent("soulseek:search-results", (batch) => {
|
||||
let matched = 0;
|
||||
for (const r of batch) {
|
||||
if (!search.searching || r.token !== search.token) continue;
|
||||
matched += 1;
|
||||
// Every tab is live at once; the token says whose results these
|
||||
// are. A background tab fills in exactly like the visible one.
|
||||
const sess = searchTabs.list.find(
|
||||
(s) => s.searching && s.token != null && s.token === r.token,
|
||||
);
|
||||
if (!sess) continue;
|
||||
sess.responses += 1;
|
||||
const searchFiles = filesOf(sess.id);
|
||||
for (const f of r.files) {
|
||||
const key = `${r.username}|${f.folder}`;
|
||||
let g = search.groups[key];
|
||||
let g = sess.groups[key];
|
||||
let raw = searchFiles.get(key);
|
||||
if (!g) {
|
||||
// Cap group count so a popular search can't flood the DOM.
|
||||
if (searchFiles.size >= 300) continue;
|
||||
raw = { files: [], exts: new Map() };
|
||||
searchFiles.set(key, raw);
|
||||
g = search.groups[key] = {
|
||||
g = sess.groups[key] = {
|
||||
key,
|
||||
username: r.username,
|
||||
folder: f.folder,
|
||||
@@ -6290,7 +6343,6 @@ export function initEvents() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matched > 0) search.responses += matched;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7315,39 +7367,111 @@ if (import.meta.env.DEV && typeof window !== "undefined") {
|
||||
};
|
||||
}
|
||||
|
||||
export async function runSearch(query) {
|
||||
const request = ++searchRequest;
|
||||
clearTimeout(searchStopTimer);
|
||||
const previousToken = search.token;
|
||||
// Invalidate routing before awaiting the new backend token, otherwise
|
||||
// late batches from the previous search can populate the freshly-cleared
|
||||
// result set during this gap.
|
||||
search.token = null;
|
||||
if (previousToken != null) {
|
||||
api.stopSearch(previousToken).catch(() => {});
|
||||
/** Bring a tab forward. The whole Search UI reads through `search`, so this
|
||||
* assignment IS the switch. */
|
||||
export function selectSearchTab(id) {
|
||||
if (sessionById(id)) searchTabs.activeId = id;
|
||||
}
|
||||
|
||||
/** Close one tab: stop its search on the wire and free its file map. */
|
||||
export function closeSearchTab(id) {
|
||||
const i = searchTabs.list.findIndex((s) => s.id === id);
|
||||
if (i < 0) return;
|
||||
const [sess] = searchTabs.list.splice(i, 1);
|
||||
clearTimeout(searchStopTimers.get(id));
|
||||
searchStopTimers.delete(id);
|
||||
searchFilesById.delete(id);
|
||||
if (sess.token != null) api.stopSearch(sess.token).catch(() => {});
|
||||
if (searchTabs.activeId === id) {
|
||||
// Prefer the neighbour to the left — Qt's behaviour, and it keeps a
|
||||
// rapid close-close-close from jumping across the strip.
|
||||
const next = searchTabs.list[Math.max(0, i - 1)];
|
||||
searchTabs.activeId = next ? next.id : null;
|
||||
}
|
||||
search.query = query;
|
||||
search.groups = {};
|
||||
searchFiles.clear();
|
||||
search.responses = 0;
|
||||
search.searching = true;
|
||||
}
|
||||
|
||||
/** Retire the oldest tab that is no longer live, so the strip stays bounded
|
||||
* without ever killing a search that is still trickling in. */
|
||||
function evictOldestSearchTab() {
|
||||
if (searchTabs.list.length < SEARCH_TABS_MAX) return;
|
||||
const victim =
|
||||
searchTabs.list.find((s) => !s.searching && s.id !== searchTabs.activeId)
|
||||
?? searchTabs.list.find((s) => s.id !== searchTabs.activeId)
|
||||
?? searchTabs.list[0];
|
||||
if (victim) closeSearchTab(victim.id);
|
||||
}
|
||||
|
||||
export async function runSearch(query) {
|
||||
// Re-running a query that already owns a tab focuses it instead of
|
||||
// opening a duplicate — Qt does the same, and it keeps the strip honest.
|
||||
const existing = searchTabs.list.find((s) => s.query === query);
|
||||
if (existing) {
|
||||
searchTabs.activeId = existing.id;
|
||||
if (existing.searching || existing.token != null) return;
|
||||
// A closed-out tab re-runs in place, keeping its position.
|
||||
existing.groups = {};
|
||||
filesOf(existing.id).clear();
|
||||
existing.responses = 0;
|
||||
existing.searching = true;
|
||||
try {
|
||||
await armSearch(existing, query);
|
||||
} catch (err) {
|
||||
// Keep the tab — it still holds the previous run's results.
|
||||
existing.searching = false;
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
evictOldestSearchTab();
|
||||
searchTabs.list.push({
|
||||
id: ++searchSeq,
|
||||
token: null,
|
||||
query,
|
||||
searching: true,
|
||||
responses: 0,
|
||||
// key: `${username}|${folder}` -> group summary (scalars only — the
|
||||
// file lists live outside reactive state, see searchFilesById).
|
||||
groups: {},
|
||||
});
|
||||
// Read the entry back out: THIS is the reactive proxy the store holds, and
|
||||
// the one every later write (token, searching) has to land on.
|
||||
const sess = searchTabs.list[searchTabs.list.length - 1];
|
||||
searchTabs.activeId = sess.id;
|
||||
searchTabs.seeded = "";
|
||||
try {
|
||||
await armSearch(sess, query);
|
||||
} catch (err) {
|
||||
// The wire refused (offline, mid-reconnect). A brand-new tab holds
|
||||
// nothing worth keeping, and a tab stuck "live" forever is a lie.
|
||||
closeSearchTab(sess.id);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask the wire for a token and hold this session's 45s routing window. */
|
||||
async function armSearch(sess, query) {
|
||||
const token = await api.startSearch(query);
|
||||
if (request !== searchRequest) {
|
||||
if (!sessionById(sess.id)) {
|
||||
// Closed while we awaited the token — nothing left to route into.
|
||||
api.stopSearch(token).catch(() => {});
|
||||
return;
|
||||
}
|
||||
search.token = token;
|
||||
sess.token = token;
|
||||
// Soulseek searches trickle in; stop routing after 45s.
|
||||
searchStopTimer = setTimeout(() => {
|
||||
if (search.token === token) {
|
||||
search.searching = false;
|
||||
clearTimeout(searchStopTimers.get(sess.id));
|
||||
searchStopTimers.set(
|
||||
sess.id,
|
||||
setTimeout(() => {
|
||||
searchStopTimers.delete(sess.id);
|
||||
if (sess.token !== token) return;
|
||||
sess.searching = false;
|
||||
// The UI exposes only the top 120 groups. Keep their full file
|
||||
// lists and discard invisible raw payloads after routing closes;
|
||||
// scalar summaries remain available for the result count.
|
||||
trimHiddenSearchFiles();
|
||||
trimHiddenSearchFiles(sess);
|
||||
api.stopSearch(token).catch(() => {});
|
||||
}
|
||||
}, 45_000);
|
||||
}, 45_000),
|
||||
);
|
||||
}
|
||||
|
||||
// Dev-only: expose the LIVE module instance for the CDP verification loop.
|
||||
@@ -7655,5 +7779,7 @@ if (import.meta.env.DEV) {
|
||||
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
|
||||
playlistStore, refreshPlaylists, goToView, peekNextIndices,
|
||||
slsk, search, runSearch, uploadToast, announceUpload,
|
||||
searchTabs, selectSearchTab, closeSearchTab, filesForGroup,
|
||||
downloads, network, sendDm, openChat,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user