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:
KeelrLeek committed 2026-09-02 13:43:16 -05:00
1 parent 6dce4e2ac7
commit 8c25f93afe
14 files changed
+2368 -68

No files matched your search

+301
View File
@@ -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();
}