652 lines
25 KiB
JavaScript
652 lines
25 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";
|
|
|
|
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,
|
|
/** 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: [],
|
|
/** 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 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("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();
|
|
});
|
|
api.onEvent("community:overlap", () => void refreshOverlap());
|
|
api.onEvent("soulseek:user-state", (p) => {
|
|
if (p.status === 1 || p.status === 2) noteSeen(p.username);
|
|
});
|
|
}
|
|
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshInbox(), refreshSpaces(), refreshOverlap()]);
|
|
await refreshPersona();
|
|
}
|
|
|
|
// ---- 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) }));
|
|
}
|
|
}
|
|
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. */
|
|
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,
|
|
);
|
|
await api.shareDismiss(card.id);
|
|
await refreshShares();
|
|
return id;
|
|
});
|
|
|
|
|
|
// ---- 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. */
|
|
export const checkInbox = () =>
|
|
run("inboxcheck", async () => {
|
|
community.inboxLast = await api.inboxCheck();
|
|
await Promise.all([refreshInbox(), refreshFriends(), refreshShares()]);
|
|
return community.inboxLast;
|
|
});
|
|
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 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, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap };
|