/* The Soulseek hub on the phone: the connection, the rooms, the chats, the buddies. The desktop's `slsk` store (state.svelte.js), the same events, the same rules — the server echoes a room line back so it is not appended locally; a direct message is not echoed so our half is. The private layer (Freenet: Spaces, holdings, the sealed inbox) is not on the phone yet. */ import * as api from "../lib/api.js"; import { listen } from "../lib/events.js"; export const slsk = $state({ connection: "offline", // offline | connecting | online username: "", loginError: "", /** the saved username, for the sign-in card */ savedUser: "", hasPassword: false, /** [{ name, users }] — the server's public list */ rooms: [], /** room → { users: [{ username, … }], messages: [{ username, message, ts }] } */ joined: {}, /** username → { messages: [{ me, text, ts }], unread } */ chats: {}, buddies: [], buddyState: {}, /** the room or the person whose screen is open, so its unread stays 0 */ openChat: null, }); const FEED_MAX = 300; function trim(arr) { if (arr.length > FEED_MAX) arr.splice(0, arr.length - FEED_MAX); } export async function loadCredentials() { try { const c = await api.getCredentials(); slsk.savedUser = c?.username || ""; slsk.hasPassword = !!c?.has_password; } catch { /* fine */ } } /** Sign in. The phone always remembers: there is no credential manager to * ask, only the app's own sealed data directory. */ export async function connect(username, password) { slsk.loginError = ""; slsk.connection = "connecting"; try { await api.connectAndLogin(username.trim(), password, true); slsk.savedUser = username.trim(); slsk.hasPassword = true; } catch (e) { slsk.connection = "offline"; slsk.loginError = String(e).replace(/^Error:\s*/, ""); } } /** At launch: the saved account signs in on its own, quietly. */ export async function connectStored() { await loadCredentials(); if (!slsk.savedUser || !slsk.hasPassword) return; slsk.connection = "connecting"; try { await api.connectStored(); } catch (e) { slsk.connection = "offline"; slsk.loginError = String(e).replace(/^Error:\s*/, ""); } } export async function disconnect() { try { await api.disconnect(); } catch { /* fine */ } slsk.connection = "offline"; slsk.joined = {}; } export async function forgetAccount() { try { await api.forgetCredentials(); } catch { /* fine */ } slsk.savedUser = ""; slsk.hasPassword = false; } export function refreshRooms() { api.slskRoomList().catch(() => {}); } export async function joinRoom(name) { await api.slskJoinRoom(name); /* the roster arrives as soulseek:room-joined; nothing optimistic here */ } export async function leaveRoom(name) { await api.slskLeaveRoom(name); delete slsk.joined[name]; } /** The server echoes our line back to everyone, us included — send only. */ export async function sayRoom(room, text) { const t = text.trim(); if (t) await api.slskSayRoom(room, t); } export function openThread(username) { slsk.chats[username] ??= { messages: [], unread: 0 }; slsk.chats[username].unread = 0; } /** A direct message is not echoed back; our half is appended here. */ export async function sendDm(username, text) { const t = text.trim(); if (!t) return; await api.slskMessageUser(username, t); openThread(username); slsk.chats[username].messages.push({ me: true, text: t, ts: Date.now() }); trim(slsk.chats[username].messages); } export async function loadBuddies() { try { slsk.buddies = (await api.buddyList()) || []; } catch { /* offline */ } } export async function addBuddy(username) { slsk.buddies = (await api.buddyAdd(username)) || slsk.buddies; } export async function removeBuddy(username) { slsk.buddies = (await api.buddyRemove(username)) || slsk.buddies; delete slsk.buddyState[username]; } export function unreadTotal() { return Object.values(slsk.chats).reduce((n, c) => n + (c.unread || 0), 0); } let bound = false; /** Subscribe to the hub's events. Returns the unlisteners. */ export async function bindSlsk() { if (bound) return []; bound = true; const subs = []; const on = async (name, fn) => { try { subs.push(await listen(name, (ev) => fn(ev.payload))); } catch { /* not a phone */ } }; await on("soulseek:state", (ev) => { if (ev?.kind === "connected") { slsk.connection = "online"; slsk.username = ev.username || slsk.savedUser; slsk.loginError = ""; void loadBuddies(); refreshRooms(); } else if (ev?.kind === "disconnected") { slsk.connection = "offline"; } }); await on("soulseek:room-list", (rooms) => { slsk.rooms = (rooms || []).slice().sort((a, b) => (b.users || 0) - (a.users || 0)); }); await on("soulseek:room-joined", (p) => { const prev = slsk.joined[p.room]; slsk.joined[p.room] = { users: p.users || [], messages: prev?.messages ?? [] }; }); await on("soulseek:room-left", (p) => { delete slsk.joined[p.room]; }); await on("soulseek:room-user", (p) => { const room = slsk.joined[p.room]; if (!room) return; if (p.joined) { if (!room.users.some((u) => u.username === p.user?.username)) room.users.push(p.user); } else { room.users = room.users.filter((u) => u.username !== p.username); } }); await on("soulseek:room-message", (p) => { const room = slsk.joined[p.room]; if (!room) return; room.messages.push({ username: p.username, message: p.message, ts: p.ts ? p.ts * 1000 : Date.now() }); trim(room.messages); }); await on("soulseek:private-message", (p) => { const chat = (slsk.chats[p.username] ??= { messages: [], unread: 0 }); chat.messages.push({ me: false, text: p.message, ts: (p.timestamp || 0) * 1000 || Date.now() }); trim(chat.messages); if (slsk.openChat !== p.username) chat.unread += 1; }); await on("soulseek:user-state", (p) => { const s = (slsk.buddyState[p.username] ??= {}); Object.assign(s, p); }); return subs; }