forked from Keelrleek/SoulStream
Importing a shared playlist was a one-time snapshot: the owner's next
revision could only arrive as a whole second copy. A copy now remembers
WHICH revision it was made from, and the app reads the pointer for it
every ten minutes — no new contract, the same pointer and revision reads
the share card already does.
The ledger gains `following`, keyed `owner:uuid` — the one key a database
cannot hand out twice. A follow records the local playlist, the synced
revision (hash + seq) and what the last check saw: the current revision,
the entries it has that the synced one never had (in the owner's order),
and how many synced entries are gone. The diff is by occurrence id, the
proposal board's rule: a reorder is not news, a removal is counted, a
duplicate stays its own row. `import` stages the fetched revision so
`follow` records exactly what the copy was made from — a pointer read of
its own could already name a newer one.
Update appends the new rows through the same resolve path an import uses
(library first, Soulseek stream otherwise) and never removes or reorders:
the copy is the follower's to edit, and a track the owner dropped is
reported, not taken away. It runs under the ledger lock end to end, so a
check landing meanwhile merges against the new synced revision or not at
all. Checks read the network WITHOUT the lock — a pointer read is a ring
search — and merge each result under it afterwards, dropped if the follow
moved on.
Deleting the local copy ends the follow, in `forget_playlist` and the
startup reconcile both: the row id goes back to SQLite, and the next
playlist created must not start taking someone else's updates. Seen live:
the re-import after a delete took row id 1 again and followed cleanly.
Surfaces: an Inbox card when a check finds news ("2 new tracks since you
imported it · 1 removed", Add 2 tracks / stop following), and a Following
row in the playlist's Share sheet (up to date / N new / withdrawn by the
owner, Check now, Stop following). The poll rides the inbox watch: its own
ten-minute interval, plus one pass when the adapter connects.
Live, two instances on this machine: import + follow 582 ms; the owner's
revision 2 (one dropped, two added) read back as exactly "2 new, 1 removed"
in 2.7 s; Add appended two stream rows in 46 ms and left the dropped track
in place; a withdraw read as withdrawn in 57 ms; delete took the follow
with it; re-follow and unfollow both left the copy alone. One pointer read
after a republish timed out at 120 s and arrived a second later — the same
first-read-after-a-change lag the board has.
Tests: the diff (order, removal, a duplicate occurrence) and the ledger
reading files written before follows existed. App 152 · 6 ignored.
814 lines
32 KiB
JavaScript
814 lines
32 KiB
JavaScript
// 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";
|
|
import { announceProposal } from "./state.svelte.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 = ["search", "transfers", "browse", "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,
|
|
/** username → native Soulseek card ({...UserInfoDto} | {error}) for
|
|
* the session; fetched only when a profile is surfaced (the drawer). */
|
|
userInfo: {},
|
|
/** username → {likes, hates} | {error}, same lifetime. */
|
|
interests: {},
|
|
/** The server's similar-users answer, or null before it was asked. */
|
|
similar: null,
|
|
/** What this device has declared on Soulseek — the shelf's only input. */
|
|
interests: null,
|
|
/** slsk_link_status: our own verified link and everyone we verified. */
|
|
links: null,
|
|
/** username → the last failed ask's reason. */
|
|
linkErrors: {},
|
|
/** friends_list: every relationship this device holds, newest first. */
|
|
friends: [],
|
|
/** shares_inbox: playlist references people sent us, newest first. */
|
|
shares: [],
|
|
/** card id → fetched ShareView | {pending} | {error} (the Verify button). */
|
|
sharePreviews: {},
|
|
/** shared_playlists_mine: what this device has published. */
|
|
published: [],
|
|
/** shared_playlists_following: imported shared playlists this device
|
|
* keeps an eye on, each with what the last check found. */
|
|
following: [],
|
|
/** inbox_status: the encrypted inbox's state on this device. */
|
|
inbox: null,
|
|
/** What the last check found ({new_items, quarantined, pruned}). */
|
|
inboxLast: null,
|
|
/** spaces_list: the Spaces this device owns or was invited to. */
|
|
spaces: [],
|
|
/** The open Space (space_open), or null. */
|
|
space: null,
|
|
spaceError: "",
|
|
/** space_board: the proposal board beside the open Space's pinned playlist, or null. */
|
|
board: null,
|
|
boardError: "",
|
|
/** overlap_list: library comparisons computed on this device, best first. */
|
|
overlap: [],
|
|
/** persona key → ProfileView | {none} | {error} | {pending}: other
|
|
* people's signed profiles, read once per session when surfaced. */
|
|
remoteProfiles: {},
|
|
/** 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 wired = false;
|
|
/** The backend's community events, wired once per session. Both the app-wide
|
|
* inbox watch and the Community page need them; whoever arrives first wires. */
|
|
function wireEvents() {
|
|
if (!wired) {
|
|
wired = 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();
|
|
// The node usually comes up after the watch started, so the
|
|
// first follow check rides the connection rather than the
|
|
// interval.
|
|
void pollFollows();
|
|
} else if (community.persona) community.persona.available = false;
|
|
});
|
|
api.onEvent("community:friends", () => void refreshFriends());
|
|
api.onEvent("community:spaces", () => {
|
|
void refreshSpaces();
|
|
if (community.space) void openSpace(community.space.owner, community.space.space_id);
|
|
});
|
|
api.onEvent("community:shares", () => {
|
|
void refreshShares();
|
|
void refreshPublished();
|
|
void refreshFollowing();
|
|
});
|
|
api.onEvent("community:overlap", () => void refreshOverlap());
|
|
api.onEvent("community:proposal", (p) => {
|
|
announceProposal(p.from, p.playlist);
|
|
// If the board is already on screen, show what it is talking about.
|
|
if (community.board && community.space) void openBoard(community.space.owner, community.space.space_id);
|
|
});
|
|
api.onEvent("soulseek:user-state", (p) => {
|
|
if (p.status === 1 || p.status === 2) noteSeen(p.username);
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Idempotent: pulls the state the Community page draws. */
|
|
export async function initCommunity() {
|
|
wireEvents();
|
|
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshFollowing(), refreshInbox(), refreshSpaces(), refreshOverlap()]);
|
|
await refreshPersona();
|
|
}
|
|
|
|
// ---- the inbox watch ---------------------------------------------------------------
|
|
/** Everything sealed — friend requests, shares, proposal pings — arrives on one
|
|
* inbox check, and that check used to live in Community.svelte's effect. So
|
|
* nothing arrived unless that page happened to be open: a friend request sat
|
|
* unseen while you were in the Library, and the proposal toast could only fire
|
|
* somewhere it was least needed. The watch belongs to the app.
|
|
*
|
|
* App.svelte starts it once at boot. The gate is re-read every tick, so a node
|
|
* that comes up later — or an inbox published later — simply starts working
|
|
* without anything having to restart the timer. */
|
|
const INBOX_POLL_MS = 90_000;
|
|
/** Followed playlists are read far less often: every pointer read is a ring
|
|
* search that can take seconds, and an owner's next revision is a matter
|
|
* of days, not minutes. */
|
|
const FOLLOW_POLL_MS = 10 * 60_000;
|
|
let inboxWatch = 0;
|
|
let followWatch = 0;
|
|
let polling = false;
|
|
let checkingFollows = false;
|
|
|
|
export async function startInboxWatch() {
|
|
if (inboxWatch) return;
|
|
wireEvents();
|
|
inboxWatch = setInterval(() => void pollInbox(), INBOX_POLL_MS);
|
|
followWatch = setInterval(() => void pollFollows(), FOLLOW_POLL_MS);
|
|
await refreshFreenet();
|
|
await pollInbox();
|
|
// Already connected (a reload with the node up): check now rather than
|
|
// wait out the first interval. Otherwise the status event does it.
|
|
void pollFollows();
|
|
}
|
|
|
|
/** One pass over the followed playlists, quiet like the inbox poll: the
|
|
* backend logs what it found, and a card appears only when there is news. */
|
|
async function pollFollows() {
|
|
if (checkingFollows || community.busy["followcheck"]) return;
|
|
checkingFollows = true;
|
|
try {
|
|
if (!adapterConnected(community.freenet?.status)) return;
|
|
await refreshFollowing();
|
|
if (!community.following.length) return;
|
|
await api.sharedPlaylistsCheck();
|
|
await refreshFollowing();
|
|
} catch {
|
|
// Never surfaces; the Check now button is the one that reports.
|
|
} finally {
|
|
checkingFollows = false;
|
|
}
|
|
}
|
|
|
|
/** One tick, and quiet by design: a node that is down or an inbox that was
|
|
* never published is the resting state of this app, not an error to show. */
|
|
async function pollInbox() {
|
|
if (polling) return;
|
|
polling = true;
|
|
try {
|
|
// If the first read failed we have no status to gate on, and the
|
|
// status event only speaks when something changes — so try again
|
|
// rather than sit inert for the session.
|
|
if (!community.freenet) await refreshFreenet();
|
|
await refreshInbox();
|
|
if (!adapterConnected(community.freenet?.status)) return;
|
|
if (!community.inbox?.published) return;
|
|
await readInbox();
|
|
} catch {
|
|
// A background poll never surfaces; the manual button is the one that
|
|
// reports. The backend logs the reason either way.
|
|
} finally {
|
|
polling = false;
|
|
}
|
|
}
|
|
|
|
// ---- native Soulseek cards ---------------------------------------------------------
|
|
/** Fetch a person's native card and interests once per session, both in
|
|
* flight together; errors are kept per person so the drawer can say so. */
|
|
export async function loadUserInfo(username) {
|
|
if (!username) return;
|
|
if (!community.userInfo[username]) {
|
|
community.userInfo[username] = { pending: true };
|
|
api.slskUserInfo(username)
|
|
.then((v) => (community.userInfo[username] = v))
|
|
.catch((e) => (community.userInfo[username] = { error: String(e) }));
|
|
}
|
|
if (!community.interests[username]) {
|
|
community.interests[username] = { pending: true };
|
|
api.slskUserInterests(username)
|
|
.then((v) => (community.interests[username] = v))
|
|
.catch((e) => (community.interests[username] = { error: String(e) }));
|
|
}
|
|
}
|
|
/** What we have told the server we like. The shelf ranks on exactly this. */
|
|
export async function refreshInterests() {
|
|
try {
|
|
community.interests = await api.slskInterestsMine();
|
|
} catch {
|
|
// not connected; the panel says so
|
|
}
|
|
}
|
|
|
|
/** Declare an interest, or take it back, then ask the server again — the
|
|
* ranking only moves when its input does. */
|
|
export const setInterest = (thing, like, on) =>
|
|
run("interest", async () => {
|
|
community.interests = await api.slskInterestSet(thing, like, on);
|
|
community.similar = null;
|
|
await loadSimilar(true);
|
|
});
|
|
|
|
export async function loadSimilar(force = false) {
|
|
if (community.similar && !force) return;
|
|
community.busy.similar = true;
|
|
try {
|
|
community.similar = { users: await api.slskSimilarUsers() };
|
|
} catch (e) {
|
|
community.similar = { users: [], error: String(e) };
|
|
} finally {
|
|
delete community.busy.similar;
|
|
}
|
|
}
|
|
|
|
// ---- the Soulseek link proof ----------------------------------------------------
|
|
export async function refreshLinks() {
|
|
try {
|
|
community.links = await api.slskLinkStatus();
|
|
} catch {
|
|
// stays null: the UI treats it as "not linked"
|
|
}
|
|
}
|
|
export const linkSelf = () =>
|
|
run("linkself", async () => {
|
|
community.links = await api.slskLinkSelf();
|
|
});
|
|
export const unlinkSelf = () =>
|
|
run("linkself", async () => {
|
|
community.links = await api.slskLinkUnlink();
|
|
});
|
|
/** Ask one person over Soulseek; success is remembered on the Rust side. */
|
|
export async function checkLink(username) {
|
|
community.busy["link:" + username] = true;
|
|
delete community.linkErrors[username];
|
|
try {
|
|
const k = await api.slskLinkCheck(username);
|
|
await refreshLinks();
|
|
return k;
|
|
} catch (e) {
|
|
community.linkErrors[username] = String(e?.message ?? e);
|
|
return null;
|
|
} finally {
|
|
delete community.busy["link:" + username];
|
|
}
|
|
}
|
|
export const linkFor = (username) => {
|
|
const n = (username ?? "").trim().toLowerCase();
|
|
return community.links?.others?.find((l) => l.username_normalized === n) ?? null;
|
|
};
|
|
export async function loadRemoteProfile(key, force = false) {
|
|
if (!key) return;
|
|
if (community.remoteProfiles[key] && !force) return;
|
|
community.remoteProfiles[key] = { pending: true };
|
|
try {
|
|
community.remoteProfiles[key] = (await api.profileGet(key)) ?? { none: true };
|
|
} catch (e) {
|
|
community.remoteProfiles[key] = { error: String(e?.message ?? e) };
|
|
}
|
|
}
|
|
|
|
|
|
// ---- friends ------------------------------------------------------------------------
|
|
export async function refreshFriends() {
|
|
try {
|
|
community.friends = await api.friendsList();
|
|
} catch {
|
|
// stays as it was
|
|
}
|
|
}
|
|
const friendAction = (key, fn) => run(key, async () => { await fn(); await refreshFriends(); });
|
|
export const friendRequest = (username) => friendAction("friend:" + username, () => api.friendRequest(username));
|
|
export const friendAccept = (id) => friendAction("friend:" + id, () => api.friendAccept(id));
|
|
export const friendDecline = (id) => friendAction("friend:" + id, () => api.friendDecline(id));
|
|
export const friendRemove = (id) => friendAction("friend:" + id, () => api.friendRemove(id));
|
|
export const friendForget = (id) => friendAction("friend:" + id, () => api.friendForget(id));
|
|
/** The live relationship with a username, if any: friends first, then the
|
|
* most recently touched one. */
|
|
export const friendshipFor = (username) => {
|
|
const n = (username ?? "").trim().toLowerCase();
|
|
const mine = community.friends.filter((f) => f.username_normalized === n);
|
|
return mine.find((f) => f.state === "friends") ?? mine[0] ?? null;
|
|
};
|
|
export const friendCount = () => community.friends.filter((f) => f.state === "friends").length;
|
|
export const pendingIn = () => community.friends.filter((f) => f.state === "pending-in");
|
|
|
|
|
|
// ---- shared playlists ---------------------------------------------------------------
|
|
export async function refreshShares() {
|
|
try {
|
|
community.shares = await api.sharesInbox();
|
|
} catch {
|
|
// stays as it was
|
|
}
|
|
}
|
|
export async function refreshPublished() {
|
|
try {
|
|
community.published = await api.sharedPlaylistsMine();
|
|
} catch {
|
|
// stays as it was
|
|
}
|
|
}
|
|
export const publishedFor = (playlistId) => community.published.find((p) => p.playlist_id === playlistId) ?? null;
|
|
export const sharedPublish = (playlistId) =>
|
|
run("publish:" + playlistId, async () => {
|
|
const r = await api.sharedPlaylistPublish(playlistId);
|
|
await refreshPublished();
|
|
return r;
|
|
});
|
|
export const sharedWithdraw = (playlistId) =>
|
|
run("publish:" + playlistId, async () => {
|
|
await api.sharedPlaylistWithdraw(playlistId);
|
|
await refreshPublished();
|
|
});
|
|
export const shareSend = (username, playlistId) => run("share:" + playlistId, () => api.shareSend(username, playlistId));
|
|
export const shareDismiss = (id) =>
|
|
run("share:" + id, async () => {
|
|
await api.shareDismiss(id);
|
|
await refreshShares();
|
|
});
|
|
/** Fetch and verify a shared playlist through our node — nothing imported. */
|
|
export async function sharePreview(card) {
|
|
community.sharePreviews[card.id] = { pending: true };
|
|
try {
|
|
community.sharePreviews[card.id] = await api.sharedPlaylistFetch(card.owner_key, card.playlist_uuid);
|
|
} catch (e) {
|
|
community.sharePreviews[card.id] = { error: String(e?.message ?? e) };
|
|
}
|
|
}
|
|
/** Fetch, verify, stage through the app's import path, commit — the new
|
|
* playlist's id comes back; the card is dismissed. The copy then follows
|
|
* the shared playlist, so the owner's next revision reads as "3 new tracks
|
|
* since you imported" instead of arriving as a second copy. */
|
|
export const shareImport = (card) =>
|
|
run("import:" + card.id, async () => {
|
|
const draft = await api.sharedPlaylistImport(card.owner_key, card.playlist_uuid);
|
|
const id = await api.playlistImportCommit(
|
|
draft.name ?? card.name,
|
|
draft.description ?? null,
|
|
draft.kind ?? "manual",
|
|
draft.entries,
|
|
draft.cover_url ?? null,
|
|
);
|
|
try {
|
|
await api.sharedPlaylistFollow(card.owner_key, card.playlist_uuid, id);
|
|
} catch (e) {
|
|
// The playlist is already real; a follow that could not be
|
|
// recorded is not worth failing the import over.
|
|
console.warn("could not follow the shared playlist", e);
|
|
}
|
|
await api.shareDismiss(card.id);
|
|
await Promise.all([refreshShares(), refreshFollowing()]);
|
|
return id;
|
|
});
|
|
|
|
// ---- followed playlists -------------------------------------------------------------
|
|
export async function refreshFollowing() {
|
|
try {
|
|
community.following = await api.sharedPlaylistsFollowing();
|
|
} catch {
|
|
// stays as it was
|
|
}
|
|
}
|
|
/** The follow behind a local playlist, when it was imported from a share. */
|
|
export const followFor = (playlistId) => community.following.find((f) => f.playlist_id === playlistId) ?? null;
|
|
/** Follows with something to act on: tracks the owner added, or dropped. A
|
|
* withdrawal is a state, not news — the playlist page says so. */
|
|
export const followNews = () => community.following.filter((f) => !f.withdrawn && (f.added.length || f.removed));
|
|
async function checkFollowsNow() {
|
|
const r = await api.sharedPlaylistsCheck();
|
|
await refreshFollowing();
|
|
return r;
|
|
}
|
|
/** The Check now button: busy state, and a failure the page can show. */
|
|
export const checkFollows = () => run("followcheck", checkFollowsNow);
|
|
/** Append what the owner added since the copy was last synced. Rows resolve
|
|
* the way an import does; nothing is removed from the copy. */
|
|
export const followUpdate = (f) =>
|
|
run("follow:" + f.playlist_uuid, async () => {
|
|
const added = await api.sharedPlaylistFollowUpdate(f.owner_key, f.playlist_uuid);
|
|
await refreshFollowing();
|
|
return added;
|
|
});
|
|
export const unfollow = (f) =>
|
|
run("follow:" + f.playlist_uuid, async () => {
|
|
await api.sharedPlaylistUnfollow(f.owner_key, f.playlist_uuid);
|
|
await refreshFollowing();
|
|
});
|
|
|
|
|
|
// ---- the encrypted inbox --------------------------------------------------------------
|
|
export async function refreshInbox() {
|
|
try {
|
|
community.inbox = await api.inboxStatus();
|
|
} catch {
|
|
// stays as it was
|
|
}
|
|
}
|
|
export const publishInbox = () =>
|
|
run("inbox", async () => {
|
|
community.inbox = await api.inboxPublish();
|
|
});
|
|
/** Read our inbox through the node, act on sealed cargo, prune it. */
|
|
async function readInbox() {
|
|
community.inboxLast = await api.inboxCheck();
|
|
await Promise.all([refreshInbox(), refreshFriends(), refreshShares()]);
|
|
return community.inboxLast;
|
|
}
|
|
/** The Check now button: busy state, and a failure the page can show. The
|
|
* background watch below deliberately does NOT go through this — a poll that
|
|
* fails must not stamp the shared error slot. */
|
|
export const checkInbox = () => run("inboxcheck", readInbox);
|
|
export const friendRequestByKey = (personaKey) => friendAction("friend:" + personaKey, () => api.friendRequestByKey(personaKey));
|
|
export const friendshipForKey = (key) => {
|
|
const mine = community.friends.filter((f) => f.persona_key === key);
|
|
return mine.find((f) => f.state === "friends") ?? mine[0] ?? null;
|
|
};
|
|
|
|
|
|
// ---- spaces -------------------------------------------------------------------------
|
|
export async function refreshSpaces() {
|
|
try {
|
|
community.spaces = await api.spacesList();
|
|
} catch {
|
|
// stays as it was
|
|
}
|
|
}
|
|
/** Read a Space through the node; the view is what the checkpoint says. */
|
|
export async function openSpace(owner, space) {
|
|
community.spaceError = "";
|
|
community.busy["space:" + space] = true;
|
|
try {
|
|
community.space = await api.spaceOpen(owner, space);
|
|
} catch (e) {
|
|
community.spaceError = String(e?.message ?? e);
|
|
} finally {
|
|
delete community.busy["space:" + space];
|
|
}
|
|
}
|
|
export const createSpace = (name, description, isPrivate) =>
|
|
run("space:create", async () => {
|
|
community.space = await api.spaceCreate(name, description, isPrivate);
|
|
await refreshSpaces();
|
|
});
|
|
export const postToSpace = (owner, space, text) =>
|
|
run("space:post", async () => {
|
|
await api.spacePost(owner, space, text);
|
|
await openSpace(owner, space);
|
|
});
|
|
export const inviteToSpace = (owner, space, personaKey) =>
|
|
run("space:invite", async () => {
|
|
community.space = await api.spaceInvite(owner, space, personaKey);
|
|
});
|
|
export const removeFromSpace = (owner, space, personaKey) =>
|
|
run("space:remove", async () => {
|
|
community.space = await api.spaceRemoveMember(owner, space, personaKey);
|
|
});
|
|
export const pinToSpace = (owner, space, playlistOwner, playlistUuid) =>
|
|
run("space:pin", async () => {
|
|
community.space = await api.spaceSetPinned(owner, space, playlistOwner, playlistUuid);
|
|
});
|
|
export const setShareListens = (owner, space, on) =>
|
|
run("space:listens", async () => {
|
|
community.space = await api.spaceSetShareListens(owner, space, on);
|
|
});
|
|
export const leaveSpace = (owner, space) =>
|
|
run("space:leave", async () => {
|
|
await api.spaceLeave(owner, space);
|
|
community.space = null;
|
|
community.board = null;
|
|
await refreshSpaces();
|
|
});
|
|
|
|
// ---- library overlap ------------------------------------------------------------
|
|
export async function refreshOverlap() {
|
|
try {
|
|
community.overlap = await api.overlapList();
|
|
} catch {
|
|
// stays as it was
|
|
}
|
|
}
|
|
export const overlapFor = (username) => {
|
|
const k = (username ?? "").trim().toLowerCase();
|
|
return community.overlap.find((o) => o.username.toLowerCase() === k) ?? null;
|
|
};
|
|
/** Browse them on purpose and compare — nothing is uploaded to do it. */
|
|
export const computeOverlap = (username) =>
|
|
run("overlap:" + username, async () => {
|
|
await api.overlapCompute(username);
|
|
await refreshOverlap();
|
|
});
|
|
export const forgetOverlap = (username) =>
|
|
run("overlap:" + username, async () => {
|
|
await api.overlapForget(username);
|
|
await refreshOverlap();
|
|
});
|
|
|
|
// ---- the proposal board ---------------------------------------------------------
|
|
/** Read the board beside the Space's pinned playlist; the playlist's owner also
|
|
* brings its roster in line with the members here. */
|
|
export async function openBoard(owner, space) {
|
|
community.boardError = "";
|
|
community.busy["board:" + space] = true;
|
|
try {
|
|
community.board = await api.spaceBoard(owner, space);
|
|
} catch (e) {
|
|
community.board = null;
|
|
community.boardError = String(e?.message ?? e);
|
|
} finally {
|
|
delete community.busy["board:" + space];
|
|
}
|
|
}
|
|
async function boardRun(key, fn) {
|
|
community.busy[key] = true;
|
|
community.boardError = "";
|
|
try {
|
|
return await fn();
|
|
} catch (e) {
|
|
community.boardError = String(e?.message ?? e);
|
|
throw e;
|
|
} finally {
|
|
delete community.busy[key];
|
|
}
|
|
}
|
|
export const proposeToBoard = (owner, space, base, ops, note) =>
|
|
boardRun("board:propose", async () => {
|
|
community.board = await api.spacePropose(owner, space, base, ops, note);
|
|
});
|
|
export const decideProposal = (owner, space, proposal, accept, reason) =>
|
|
boardRun("board:decide", async () => {
|
|
community.board = await api.spaceDecide(owner, space, proposal, accept, reason);
|
|
});
|
|
|
|
|
|
// Dev handle beside `__soulstream` (state.svelte.js): probes reach the store
|
|
// through this, never through a bare import (which would be a second copy).
|
|
if (import.meta.env.DEV) globalThis.__community = community;
|
|
if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, refreshFollowing, followFor, followNews, checkFollows, followUpdate, unfollow, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, setShareListens, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap, refreshInterests, setInterest };
|