commit 691dce96f6ef52194dc75749457b58fcc7335167
parent 5267fcf9780e1b9268adfdc7fdf5a4c56f7b2533
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date: Wed, 18 Feb 2026 10:56:13 -0700
radio plugin, dice plugin, plugin rack, and connectifier
made some cool nifty plugins and a plugin rack to hold em, finally solved the issue where we kept disconnecting
Diffstat:
17 files changed, 2008 insertions(+), 62 deletions(-)
diff --git a/CLEAN_INSTALL/public/app.js b/CLEAN_INSTALL/public/app.js
@@ -27,6 +27,7 @@ const toggleRightRackEl = document.getElementById("toggleRightRack");
const layoutPresetEl = document.getElementById("layoutPreset");
const uiScaleEl = document.getElementById("uiScale");
const deviceLayoutEl = document.getElementById("deviceLayout");
+const stayConnectedEl = document.getElementById("stayConnected");
const dockHotbarEl = document.getElementById("dockHotbar");
const showSideRackBtn = document.getElementById("showSideRack");
const showRightRackBtn = document.getElementById("showRightRack");
@@ -667,6 +668,173 @@ registerCorePanel({ id: "moderation", title: "Moderation", icon: "🛡️", role
registerCorePanel({ id: "profile", title: "Profile", icon: "👤", role: "transient", defaultRack: "main", element: profileViewPanel });
registerCorePanel({ id: "composer", title: "New Hive", icon: "✍️", role: "aux", defaultRack: "main", element: pollinatePanel });
+let pluginRackPanelEl = null;
+let pluginRackWidgetsRackEl = null;
+let pluginRackAddMenuEl = null;
+
+function closePluginRackAddMenu() {
+ if (!pluginRackAddMenuEl) return;
+ try {
+ pluginRackAddMenuEl.remove();
+ } catch {
+ // ignore
+ }
+ pluginRackAddMenuEl = null;
+}
+
+function panelIsPluginOwned(panelId) {
+ const id = String(panelId || "").trim();
+ if (!id) return false;
+ if (id.startsWith("chat:")) return false;
+ const entry = panelRegistry.get(id);
+ const src = typeof entry?.source === "string" ? entry.source : "";
+ return src.startsWith("plugin:");
+}
+
+function panelIsHostableInPluginRack(panelId) {
+ const id = String(panelId || "").trim();
+ if (!id) return false;
+ if (id === "pluginRack") return false;
+ if (!panelIsPluginOwned(id)) return false;
+ // Widgets should be small, stackable tools (not full workspace surfaces like Maps).
+ if (panelRole(id) === "primary") return false;
+ return true;
+}
+
+function ensurePluginRackPanel() {
+ if (pluginRackPanelEl instanceof HTMLElement && pluginRackPanelEl.isConnected) return pluginRackPanelEl;
+
+ if (!(pluginRackPanelEl instanceof HTMLElement)) {
+ const shell = document.createElement("section");
+ shell.className = "panel panelFill pluginRackPanel rackPanel";
+ shell.dataset.panelId = "pluginRack";
+ shell.innerHTML = `
+ <div class="panelHeader">
+ <div class="panelTitle">${escapeHtml("Plugin Rack")}</div>
+ <div class="row"></div>
+ </div>
+ <div class="panelBody pluginRackBody">
+ <div class="pluginRackToolbar">
+ <button type="button" class="ghost smallBtn" data-pluginrackadd="1">+ Add widget</button>
+ <div class="small muted pluginRackHint">Drop plugin panels here to stack them.</div>
+ </div>
+ <div id="pluginRackWidgetsRack" class="pluginRackWidgets" aria-label="Plugin widgets"></div>
+ </div>
+ `;
+ pluginRackPanelEl = shell;
+ pluginRackWidgetsRackEl = shell.querySelector("#pluginRackWidgetsRack");
+
+ shell.querySelector("[data-pluginrackadd]")?.addEventListener("click", (e) => {
+ const anchor = e.currentTarget;
+ if (pluginRackAddMenuEl) closePluginRackAddMenu();
+ else openPluginRackAddMenu(anchor);
+ });
+ }
+
+ // Ensure it's registered as a core panel for docking + layout state.
+ registerCorePanel({ id: "pluginRack", title: "Plugin Rack", icon: "🧰", role: "aux", defaultRack: "main", element: pluginRackPanelEl });
+
+ // Append into the DOM so it can be docked/restored. (It will typically live in the hotbar.)
+ const side = ensureMainSideRack();
+ if (side && pluginRackPanelEl.parentElement !== side) side.appendChild(pluginRackPanelEl);
+
+ return pluginRackPanelEl;
+}
+
+function ensurePluginRackWidgetsRack() {
+ ensurePluginRackPanel();
+ return pluginRackWidgetsRackEl instanceof HTMLElement ? pluginRackWidgetsRackEl : null;
+}
+
+function readPluginRackWidgetsOrder() {
+ const rack = ensurePluginRackWidgetsRack();
+ return rack ? readRackOrder(rack) : [];
+}
+
+function removePanelFromPluginRack(panelId) {
+ const id = String(panelId || "").trim();
+ if (!id) return;
+ rackLayoutState.pluginRackWidgets = Array.isArray(rackLayoutState.pluginRackWidgets)
+ ? rackLayoutState.pluginRackWidgets.filter((x) => x !== id)
+ : [];
+ const el = getPanelElement(id);
+ if (el) el.classList.remove("pluginRackWidget");
+ const rack = ensurePluginRackWidgetsRack();
+ if (rack && el && el.parentElement === rack) rack.removeChild(el);
+ const side = ensureMainSideRack();
+ if (side && el && !el.parentElement) side.appendChild(el);
+}
+
+function hostPanelInPluginRack(panelId) {
+ const id = String(panelId || "").trim();
+ if (!id) return;
+ if (!rackLayoutEnabled) return;
+ if (!panelIsHostableInPluginRack(id)) {
+ toast("Can't add widget", `${panelTitle(id)} can't be hosted in Plugin Rack.`);
+ return;
+ }
+
+ const rack = ensurePluginRackWidgetsRack();
+ const el = getPanelElement(id);
+ if (!rack || !el) return;
+
+ // Hosting implies it should be visible in the rack, not docked.
+ if (isDocked(id)) undockPanel(id);
+
+ const lastRack = rackIdForPanelElement(el);
+ if (lastRack) rememberPanelLastRack(id, lastRack);
+
+ el.classList.add("pluginRackWidget");
+ if (el.parentElement !== rack) rack.appendChild(el);
+
+ const next = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []);
+ next.add(id);
+ rackLayoutState.pluginRackWidgets = Array.from(next);
+ saveRackLayoutState();
+ syncRackStateFromDom();
+ enforceWorkspaceRules();
+}
+
+function openPluginRackAddMenu(anchorEl) {
+ closePluginRackAddMenu();
+ if (!(anchorEl instanceof HTMLElement)) return;
+ if (!rackLayoutEnabled) return;
+
+ const hosted = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []);
+ const candidates = Array.from(panelRegistry.keys())
+ .filter((id) => panelIsHostableInPluginRack(id) && !hosted.has(id))
+ .sort((a, b) => panelTitle(a).localeCompare(panelTitle(b)));
+
+ const items = candidates
+ .map((id) => `<button type="button" class="ghost smallBtn" data-pluginrackhost="${escapeHtml(id)}">${escapeHtml(panelTitle(id))}</button>`)
+ .join("");
+
+ const menu = document.createElement("div");
+ menu.className = "hotbarAddMenu pluginRackAddMenu";
+ menu.innerHTML = `
+ <div class="small muted" style="padding:6px 8px 4px;">Add widget</div>
+ <div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No plugin widgets available.</div>`}</div>
+ `;
+
+ const rect = anchorEl.getBoundingClientRect();
+ const left = Math.max(12, Math.min(window.innerWidth - 260, rect.left));
+ const top = Math.max(12, Math.min(window.innerHeight - 320, rect.bottom + 8));
+ menu.style.left = `${left}px`;
+ menu.style.top = `${top}px`;
+
+ menu.addEventListener("click", (e) => {
+ const btn = e.target.closest?.("[data-pluginrackhost]");
+ if (!btn) return;
+ const id = String(btn.getAttribute("data-pluginrackhost") || "").trim();
+ if (!id) return;
+ hostPanelInPluginRack(id);
+ closePluginRackAddMenu();
+ });
+
+ document.body.appendChild(menu);
+ pluginRackAddMenuEl = menu;
+}
+
// Rack mode: Profile should behave like a normal dockable panel (not a flow that replaces Hives).
// Override the role after the initial core registration (Map#set will replace the previous entry).
panelRegistry.set("profile", { ...(panelRegistry.get("profile") || { id: "profile", source: "core" }), role: "aux" });
@@ -686,7 +854,7 @@ const PRESET_DEFS = {
sideOrder: ["profile", "composer"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["maps", "library"],
+ dockBottom: ["pluginRack", "maps", "library"],
},
chatFocus: {
presetId: "chatFocus",
@@ -698,7 +866,7 @@ const PRESET_DEFS = {
sideOrder: ["profile"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["hives", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "hives", "composer", "maps", "library"],
},
browse: {
presetId: "browse",
@@ -710,7 +878,7 @@ const PRESET_DEFS = {
sideOrder: ["chat"],
sideCollapsed: true,
rightOrder: ["profile"],
- dockBottom: ["people", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "people", "composer", "maps", "library"],
},
creator: {
presetId: "creator",
@@ -722,7 +890,7 @@ const PRESET_DEFS = {
sideOrder: ["people"],
sideCollapsed: true,
rightOrder: ["profile"],
- dockBottom: ["chat", "maps", "library"],
+ dockBottom: ["pluginRack", "chat", "maps", "library"],
},
mapsSession: {
presetId: "mapsSession",
@@ -733,7 +901,7 @@ const PRESET_DEFS = {
sideOrder: ["hives"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["profile", "composer", "library"],
+ dockBottom: ["pluginRack", "profile", "composer", "library"],
},
quiet: {
presetId: "quiet",
@@ -745,7 +913,7 @@ const PRESET_DEFS = {
sideCollapsed: true,
rightOrder: [],
rightCollapsed: true,
- dockBottom: ["chat", "people", "maps", "library"],
+ dockBottom: ["pluginRack", "chat", "people", "maps", "library"],
},
ops: {
presetId: "ops",
@@ -757,7 +925,7 @@ const PRESET_DEFS = {
sideOrder: ["hives"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["profile", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "profile", "composer", "maps", "library"],
},
reportsFocus: {
presetId: "reportsFocus",
@@ -770,7 +938,7 @@ const PRESET_DEFS = {
sideOrder: ["people"],
sideCollapsed: true,
rightOrder: ["chat"],
- dockBottom: ["hives", "profile", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "hives", "profile", "composer", "maps", "library"],
},
communityWatch: {
presetId: "communityWatch",
@@ -782,7 +950,7 @@ const PRESET_DEFS = {
sideOrder: ["chat"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["profile", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "profile", "composer", "maps", "library"],
},
serverAdmin: {
presetId: "serverAdmin",
@@ -794,7 +962,7 @@ const PRESET_DEFS = {
sideOrder: ["chat"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["profile", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "profile", "composer", "maps", "library"],
},
};
@@ -885,6 +1053,7 @@ function loadRackLayoutState() {
presetId: "discordLike",
docked: { bottom: [] },
racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
+ pluginRackWidgets: [],
lastRackByPanelId: {},
};
const parsed = JSON.parse(raw);
@@ -894,9 +1063,13 @@ function loadRackLayoutState() {
presetId: "discordLike",
docked: { bottom: [] },
racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
+ pluginRackWidgets: [],
lastRackByPanelId: {},
};
const bottom = Array.isArray(parsed?.docked?.bottom) ? parsed.docked.bottom.map((x) => String(x || "")).filter(Boolean) : [];
+ const pluginRackWidgets = Array.isArray(parsed?.pluginRackWidgets)
+ ? parsed.pluginRackWidgets.map((x) => String(x || "")).filter(Boolean)
+ : [];
const presetId = typeof parsed?.presetId === "string" ? parsed.presetId : "discordLike";
const workspaceLeft = Array.isArray(parsed?.racks?.workspaceLeft) ? parsed.racks.workspaceLeft.map((x) => String(x || "")).filter(Boolean) : [];
const workspaceRight = Array.isArray(parsed?.racks?.workspaceRight) ? parsed.racks.workspaceRight.map((x) => String(x || "")).filter(Boolean) : [];
@@ -910,13 +1083,14 @@ function loadRackLayoutState() {
if (!id || !rackId) continue;
lastRackByPanelId[id] = rackId;
}
- return { version: 2, presetId, docked: { bottom }, racks: { workspaceLeft, workspaceRight, side, right }, lastRackByPanelId };
+ return { version: 2, presetId, docked: { bottom }, racks: { workspaceLeft, workspaceRight, side, right }, pluginRackWidgets, lastRackByPanelId };
} catch {
return {
version: 2,
presetId: "discordLike",
docked: { bottom: [] },
racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
+ pluginRackWidgets: [],
lastRackByPanelId: {},
};
}
@@ -1000,12 +1174,12 @@ function panelCanExpand(panelId) {
if (id.startsWith("chat:")) return true;
if (panelRole(id) === "primary") return true;
// Allow a few core panels to take over the workspace even though they aren't "primary" by default.
- return id === "moderation" || id === "composer";
+ return id === "moderation" || id === "composer" || id === "pluginRack";
}
// Panels that are allowed to live in "skinny" columns (side rack / right rack).
// These panels should be able to render in a narrow width without breaking layout.
-const SKINNY_CAPABLE_PANELS = new Set(["people", "profile", "composer", "hives", "chat"]);
+const SKINNY_CAPABLE_PANELS = new Set(["people", "profile", "composer", "hives", "chat", "dice"]);
function panelIsSkinnyCapable(panelId) {
const id = String(panelId || "").trim();
@@ -1046,6 +1220,8 @@ function rememberPanelLastRack(panelId, rackId) {
function dockPanel(panelId) {
const id = String(panelId || "").trim();
if (!id) return;
+ // Docking a hosted widget should implicitly un-host it.
+ removePanelFromPluginRack(id);
const el = getPanelElement(id);
const lastRack = rackIdForPanelElement(el);
if (lastRack) rememberPanelLastRack(id, lastRack);
@@ -1265,6 +1441,8 @@ function readRackOrder(rackEl) {
function applyRackStateToDom() {
if (!rackLayoutEnabled) return;
+ // Ensure core "virtual" panels exist before we try to place them.
+ ensurePluginRackPanel();
const left = ensureWorkspaceLeftRack();
const rightWorkspace = ensureWorkspaceRightRack();
const side = ensureMainSideRack();
@@ -1291,6 +1469,18 @@ function applyRackStateToDom() {
const el = getPanelElement(panelId);
if (el) right.appendChild(el);
}
+
+ // Hosted plugin widgets live inside Plugin Rack, not a top-level rack.
+ const widgetsOrder = Array.isArray(rackLayoutState?.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : [];
+ const widgetsRack = ensurePluginRackWidgetsRack();
+ if (widgetsRack) {
+ for (const panelId of widgetsOrder) {
+ const el = getPanelElement(panelId);
+ if (!el) continue;
+ el.classList.add("pluginRackWidget");
+ widgetsRack.appendChild(el);
+ }
+ }
}
function readWorkspaceActivePrimary() {
@@ -1448,6 +1638,14 @@ function syncRackStateFromDom() {
side: readRackOrder(side),
right: readRackOrder(right),
};
+ rackLayoutState.pluginRackWidgets = readPluginRackWidgetsOrder();
+ const hosted = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []);
+ for (const [id, entry] of panelRegistry.entries()) {
+ const el = entry?.element;
+ if (!(el instanceof HTMLElement)) continue;
+ if (!el.classList.contains("pluginRackWidget") && hosted.has(id)) el.classList.add("pluginRackWidget");
+ if (el.classList.contains("pluginRackWidget") && !hosted.has(id)) el.classList.remove("pluginRackWidget");
+ }
saveRackLayoutState();
}
@@ -1598,6 +1796,11 @@ function applyPreset(presetId) {
return;
}
+ // Presets are hard-applied: clear any hosted widgets so placement remains deterministic.
+ closePluginRackAddMenu();
+ for (const id of readPluginRackWidgetsOrder()) removePanelFromPluginRack(id);
+ rackLayoutState.pluginRackWidgets = [];
+
rackLayoutState.presetId = def.presetId || key;
const workspaceLeftOrder = Array.isArray(def.workspaceLeftOrder) ? def.workspaceLeftOrder.map((x) => String(x || "")).filter(Boolean) : [];
@@ -1744,6 +1947,8 @@ function installPanelMinimizeButtons() {
addMinBtn(hivesPanelEl?.querySelector(".panelHeader"), "hives");
addMinBtn(profileViewPanel?.querySelector(".panelHeader"), "profile");
addMinBtn(pollinatePanel?.querySelector(".panelHeader"), "composer");
+ ensurePluginRackPanel();
+ addMinBtn(pluginRackPanelEl?.querySelector(".panelHeader"), "pluginRack");
}
function ensurePluginPanelShell(panelId, title, icon, defaultRack, role) {
@@ -2147,7 +2352,8 @@ function enableRackDnD() {
const rightWorkspace = ensureWorkspaceRightRack();
const side = ensureMainSideRack();
if (!right || !left || !rightWorkspace || !side) return;
- const racks = [left, rightWorkspace, side, right];
+ const pluginWidgets = ensurePluginRackWidgetsRack();
+ const racks = [left, rightWorkspace, side, right, pluginWidgets].filter((x) => x instanceof HTMLElement);
// Guard against double-install if initRackLayout is called more than once.
if (appRoot?.dataset?.rackDnd === "1") return;
@@ -2245,9 +2451,22 @@ function enableRackDnD() {
const isWorkspaceSlot = targetRack.id === "workspaceLeftSlot" || targetRack.id === "workspaceRightSlot";
const isRightRackSlot = targetRack.id === "rightRack";
const isSideRackSlot = targetRack.id === "mainSideRack";
+ const isPluginRackWidgets = targetRack.id === "pluginRackWidgetsRack";
const isSkinnyRackSlot = isRightRackSlot || isSideRackSlot;
const skinnyOk = panelIsSkinnyCapable(draggingPanelId);
+ if (isPluginRackWidgets && !panelIsHostableInPluginRack(draggingPanelId)) {
+ toast("Can't place there", `${panelTitle(draggingPanelId)} can't be hosted in Plugin Rack.`);
+ if (originRack) {
+ if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(draggingEl, originBefore);
+ else originRack.appendChild(draggingEl);
+ }
+ cleanup();
+ syncRackStateFromDom();
+ enforceWorkspaceRules();
+ return;
+ }
+
// Only skinny-capable panels may live in skinny columns (side / right racks).
if (isSkinnyRackSlot && !skinnyOk) {
toast("Can't place there", `${panelTitle(draggingPanelId)} can't be placed in a skinny rack.`);
@@ -2272,6 +2491,7 @@ function enableRackDnD() {
} else {
targetRack.insertBefore(draggingEl, placeholderEl);
}
+ if (isPluginRackWidgets) draggingEl.classList.add("pluginRackWidget");
}
const shouldDock = Boolean(dockHotbarEl && e.clientY > window.innerHeight - 90);
const dockId = draggingPanelId;
@@ -2393,6 +2613,17 @@ function initRackLayout() {
enableRackLayoutDom();
+ // Ensure Plugin Rack exists and is accessible (defaults to hotbar unless explicitly placed).
+ ensurePluginRackPanel();
+ const pluginRackPlaced =
+ isDocked("pluginRack") ||
+ ["workspaceLeft", "workspaceRight", "side", "right"].some((k) => Array.isArray(rackLayoutState?.racks?.[k]) && rackLayoutState.racks[k].includes("pluginRack"));
+ if (!pluginRackPlaced) {
+ rackLayoutState.docked.bottom = Array.isArray(rackLayoutState?.docked?.bottom) ? rackLayoutState.docked.bottom : [];
+ if (!rackLayoutState.docked.bottom.includes("pluginRack")) rackLayoutState.docked.bottom.push("pluginRack");
+ saveRackLayoutState();
+ }
+
// Side racks behave like summonable hotbars: hide/show without changing panel layout state.
toggleSideRackEl && (toggleSideRackEl.disabled = false);
toggleRightRackEl && (toggleRightRackEl.disabled = false);
@@ -2447,10 +2678,15 @@ function initRackLayout() {
if (appRoot && appRoot.dataset.hotbarPlusClose !== "1") {
appRoot.dataset.hotbarPlusClose = "1";
document.addEventListener("pointerdown", (e) => {
- if (!hotbarPlusMenuEl) return;
+ if (!hotbarPlusMenuEl && !pluginRackAddMenuEl) return;
const t = e.target;
- if (t && (hotbarPlusMenuEl.contains(t) || dockHotbarEl?.contains(t))) return;
+ if (t) {
+ if (hotbarPlusMenuEl && hotbarPlusMenuEl.contains(t)) return;
+ if (pluginRackAddMenuEl && pluginRackAddMenuEl.contains(t)) return;
+ if (dockHotbarEl && dockHotbarEl.contains(t)) return;
+ }
closeHotbarPlusMenu();
+ closePluginRackAddMenu();
});
}
@@ -2482,6 +2718,14 @@ function initRackLayout() {
const resolveOrbDropRack = (panelId, rackEl) => {
const id = String(panelId || "").trim();
if (!id) return rackEl;
+ if (rackEl && rackEl.id === "pluginRackWidgetsRack") {
+ if (panelIsHostableInPluginRack(id)) return rackEl;
+ const left = ensureWorkspaceLeftRack();
+ const right = ensureWorkspaceRightRack();
+ const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
+ const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
+ return leftEmpty ? left : rightEmpty ? right : left;
+ }
// Skinny racks (side/right) only allow skinny-capable panels.
if (rackEl && (rackEl.id === "mainSideRack" || rackEl.id === "rightRack")) {
if (panelIsSkinnyCapable(id)) return rackEl;
@@ -2520,7 +2764,8 @@ function initRackLayout() {
const rightWorkspaceRack = ensureWorkspaceRightRack();
const sideRack = ensureMainSideRack();
const rightRack = ensureRightRack();
- return [leftRack, rightWorkspaceRack, sideRack, rightRack].filter((x) => x instanceof HTMLElement);
+ const pluginWidgetsRack = ensurePluginRackWidgetsRack();
+ return [leftRack, rightWorkspaceRack, sideRack, rightRack, pluginWidgetsRack].filter((x) => x instanceof HTMLElement);
};
const rackAtPoint = (x, y) => {
@@ -2570,6 +2815,7 @@ function initRackLayout() {
if (insertBefore) rack.insertBefore(panelEl, insertBefore);
else rack.appendChild(panelEl);
}
+ if (rack.id === "pluginRackWidgetsRack") panelEl.classList.add("pluginRackWidget");
rememberPanelLastRack(id, rack.id);
saveRackLayoutState();
syncRackStateFromDom();
@@ -2688,6 +2934,14 @@ const PEOPLE_WIDTH_DEFAULT = 360;
let editContext = null;
let mentionState = { open: false, query: "", selected: 0, items: [], anchorRect: null };
+const STAY_CONNECTED_KEY = "bzl_stayConnected";
+function readStayConnectedPref() {
+ return readBoolPref(STAY_CONNECTED_KEY, false);
+}
+function writeStayConnectedPref(on) {
+ writeBoolPref(STAY_CONNECTED_KEY, Boolean(on));
+}
+
let instanceBranding = { title: "Bzl", subtitle: "Ephemeral hives + chat", allowMemberPermanentPosts: false, appearance: {} };
let serverInfo = null;
let serverHealth = null;
@@ -6372,6 +6626,26 @@ function openChat(postId) {
// Rack mode: hive chats live in dedicated chat panels (instances). Don't also open the legacy main chat panel.
if (rackLayoutEnabled) {
+ const mainChatPanelIsIdle = Boolean(
+ chatPanelEl &&
+ typeof isDocked === "function" &&
+ !isDocked("chat") &&
+ !activeDmThreadId &&
+ !activeChatPostId &&
+ !isMapChatActive()
+ );
+ if (mainChatPanelIsIdle) {
+ activeChatPostId = postId;
+ markRead(postId);
+ renderFeed();
+ ws.send(JSON.stringify({ type: "getChat", postId }));
+ renderChatPanel(true);
+ renderTypingIndicator();
+ if (isMobileSwipeMode()) setMobilePanel("chat");
+ chatEditor.focus();
+ return;
+ }
+
markRead(postId);
renderFeed();
ws.send(JSON.stringify({ type: "getChat", postId }));
@@ -8280,18 +8554,100 @@ playSfx("open", { volume: 0.34 }).then((ok) => {
if (ok) pendingOpenSfx = false;
});
-setConn("connecting");
-const ws = new WebSocket(wsUrl());
-window.__bzlWs = ws;
-ws.addEventListener("open", () => {
- setConn("open");
- const token = getSessionToken();
- if (token) ws.send(JSON.stringify({ type: "resumeSession", token }));
-});
-ws.addEventListener("close", () => setConn("closed"));
-ws.addEventListener("error", () => setConn("closed"));
+let ws = null;
+let wsKeepaliveTimer = null;
+let wsReconnectTimer = null;
+let wsReconnectAttempt = 0;
+
+function clearWsKeepalive() {
+ if (!wsKeepaliveTimer) return;
+ try {
+ clearInterval(wsKeepaliveTimer);
+ } catch {
+ // ignore
+ }
+ wsKeepaliveTimer = null;
+}
+
+function clearWsReconnect() {
+ if (!wsReconnectTimer) return;
+ try {
+ clearTimeout(wsReconnectTimer);
+ } catch {
+ // ignore
+ }
+ wsReconnectTimer = null;
+}
-ws.addEventListener("message", (evt) => {
+function startWsKeepalive(sock) {
+ clearWsKeepalive();
+ if (!readStayConnectedPref()) return;
+ wsKeepaliveTimer = setInterval(() => {
+ if (!sock || sock !== ws) return;
+ if (sock.readyState !== WebSocket.OPEN) return;
+ try {
+ sock.send(JSON.stringify({ type: "ping" }));
+ } catch {
+ // ignore
+ }
+ }, 25_000);
+}
+
+function scheduleWsReconnect() {
+ clearWsReconnect();
+ if (!readStayConnectedPref()) return;
+ const attempt = Math.min(6, Math.max(0, wsReconnectAttempt));
+ const base = 1000 * Math.pow(2, attempt);
+ const jitter = Math.floor(Math.random() * 250);
+ const delay = Math.min(15_000, base) + jitter;
+ wsReconnectAttempt += 1;
+ setConn("connecting");
+ wsReconnectTimer = setTimeout(() => {
+ wsReconnectTimer = null;
+ connectWs();
+ }, delay);
+}
+
+function connectWs() {
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
+ clearWsKeepalive();
+ setConn("connecting");
+ const sock = new WebSocket(wsUrl());
+ ws = sock;
+ window.__bzlWs = sock;
+
+ sock.addEventListener("open", () => {
+ if (sock !== ws) return;
+ setConn("open");
+ wsReconnectAttempt = 0;
+ clearWsReconnect();
+ startWsKeepalive(sock);
+ const token = getSessionToken();
+ if (token) {
+ try {
+ sock.send(JSON.stringify({ type: "resumeSession", token }));
+ } catch {
+ // ignore
+ }
+ }
+ });
+
+ sock.addEventListener("close", () => {
+ if (sock !== ws) return;
+ setConn("closed");
+ clearWsKeepalive();
+ scheduleWsReconnect();
+ });
+
+ sock.addEventListener("error", () => {
+ if (sock !== ws) return;
+ setConn("closed");
+ });
+
+ sock.addEventListener("message", onWsMessage);
+}
+
+function onWsMessage(evt) {
let msg;
try {
msg = JSON.parse(evt.data);
@@ -9000,10 +9356,27 @@ ws.addEventListener("message", (evt) => {
}
renderChatInstancesForPost(msg.postId);
}
-});
+}
+
+setConn("connecting");
+connectWs();
renderLanHint();
initDisplayPrefsUi();
+if (stayConnectedEl) {
+ stayConnectedEl.checked = readStayConnectedPref();
+ stayConnectedEl.addEventListener("change", () => {
+ const on = Boolean(stayConnectedEl.checked);
+ writeStayConnectedPref(on);
+ if (on) {
+ if (!ws || ws.readyState === WebSocket.CLOSED) connectWs();
+ startWsKeepalive(ws);
+ } else {
+ clearWsReconnect();
+ clearWsKeepalive();
+ }
+ });
+}
renderPeoplePanel();
setPeopleOpen(getPeopleOpen());
composerOpen = getComposerOpen();
diff --git a/CLEAN_INSTALL/public/index.html b/CLEAN_INSTALL/public/index.html
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bzl - Hives</title>
- <link rel="stylesheet" href="/styles.css?v=103" />
+ <link rel="stylesheet" href="/styles.css?v=104" />
</head>
<body>
<div class="app">
@@ -57,6 +57,10 @@
<span>Show reactions bar</span>
<input id="toggleReactions" type="checkbox" />
</label>
+ <label class="checkRow" style="margin-top:8px;">
+ <span>Stay connected</span>
+ <input id="stayConnected" type="checkbox" />
+ </label>
<details style="margin-top:10px;">
<summary class="small muted" style="cursor:pointer;user-select:none;">Advanced display</summary>
@@ -530,6 +534,6 @@
</div>
<div id="dockHotbar" class="dockHotbar hidden" aria-label="Docked panels"></div>
- <script src="/app.js?v=116"></script>
+ <script src="/app.js?v=120"></script>
</body>
</html>
diff --git a/CLEAN_INSTALL/public/styles.css b/CLEAN_INSTALL/public/styles.css
@@ -361,6 +361,35 @@ body {
min-height: 0;
}
+.pluginRackPanel .pluginRackBody {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.pluginRackToolbar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ border-bottom: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
+}
+
+.pluginRackWidgets {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--app-gap);
+ padding: 10px;
+}
+
+.pluginRackWidgets > .rackPanel {
+ flex: 0 0 auto;
+ min-height: 0;
+}
+
.app.rackMode.hasMod {
/* In rack mode, mod is just another panel inside the right rack. */
grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px 1fr 10px minmax(var(--people-min), var(--people-width));
diff --git a/CLEAN_INSTALL/server.js b/CLEAN_INSTALL/server.js
@@ -4128,6 +4128,15 @@ wss.on("connection", (ws, req) => {
if (!msg || typeof msg !== "object") return;
+ if (msg.type === "ping") {
+ try {
+ ws.send(JSON.stringify({ type: "pong", serverTime: now() }));
+ } catch {
+ // ignore
+ }
+ return;
+ }
+
const msgType = typeof msg.type === "string" ? msg.type : "";
const pluginMatch = msgType.match(/^plugin:([a-z0-9_.-]{1,32}):([a-zA-Z0-9_.-]{1,64})$/);
if (pluginMatch) {
diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md
@@ -24,6 +24,12 @@ This repo includes starter plugins and zip builders:
- Library: `plugins_dev/library/`
- Build: `node scripts/build-library-plugin.js`
- Upload: `dist/plugins/library.zip`
+- Radio: `plugins_dev/radio/`
+ - Build: `node scripts/build-radio-plugin.js`
+ - Upload: `dist/plugins/radio.zip`
+- Dice: `plugins_dev/dice/`
+ - Build: `node scripts/build-dice-plugin.js`
+ - Upload: `dist/plugins/dice.zip`
- Directory Server (draft): `plugins_dev/directory-server/`
- Build: `node scripts/build-directory-server-plugin.js`
- Upload: `dist/plugins/directory-server.zip`
diff --git a/plugins_dev/dice/client.js b/plugins_dev/dice/client.js
@@ -0,0 +1,173 @@
+(() => {
+ if (!window?.BzlPluginHost?.register) return;
+
+ const PLUGIN_ID = "dice";
+
+ function esc(s) {
+ return String(s ?? "")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/\"/g, """)
+ .replace(/'/g, "'");
+ }
+
+ function safeJsonParse(str) {
+ try {
+ return JSON.parse(str);
+ } catch {
+ return null;
+ }
+ }
+
+ function fmtTime(t) {
+ try {
+ return new Date(Number(t || Date.now())).toLocaleTimeString();
+ } catch {
+ return "";
+ }
+ }
+
+ window.BzlPluginHost.register(PLUGIN_ID, (ctx) => {
+ const ws = window.__bzlWs;
+ let mountEl = null;
+ let inputEl = null;
+ let rollBtnEl = null;
+ let listEl = null;
+ let errEl = null;
+ let history = [];
+
+ function setError(msg) {
+ if (!errEl) return;
+ errEl.textContent = String(msg || "");
+ errEl.classList.toggle("hidden", !errEl.textContent);
+ }
+
+ function renderList() {
+ if (!listEl) return;
+ if (!history.length) {
+ listEl.innerHTML = `<div class="small muted" style="padding:10px">No rolls yet.</div>`;
+ return;
+ }
+ const rows = history
+ .slice()
+ .reverse()
+ .slice(0, 60)
+ .map((r) => {
+ const rolls = Array.isArray(r.rolls) ? r.rolls.join(", ") : "";
+ return `<div class="diceRow">
+ <div class="diceTop">
+ <span class="diceUser">@${esc(r.user || "unknown")}</span>
+ <span class="muted">•</span>
+ <span class="diceExpr">${esc(r.expr || "")}</span>
+ <span class="muted">•</span>
+ <span class="muted">${esc(fmtTime(r.createdAt))}</span>
+ </div>
+ <div class="diceBottom">
+ <span class="muted">rolls:</span> <span>${esc(rolls)}</span>
+ <span class="muted">→</span>
+ <span class="diceTotal">total: ${esc(String(r.total))}</span>
+ </div>
+ </div>`;
+ })
+ .join("");
+ listEl.innerHTML = rows;
+ }
+
+ function doRoll() {
+ const expr = String(inputEl?.value || "").trim();
+ if (!expr) return;
+ setError("");
+ ctx.send("roll", { expr });
+ }
+
+ function ensureUi() {
+ if (!mountEl) return;
+ mountEl.innerHTML = `
+ <style>
+ .diceWrap { display:flex; flex-direction:column; gap:10px; }
+ .diceControls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
+ .diceControls input { flex: 1; min-width: 140px; }
+ .diceErr { color: color-mix(in srgb, var(--bad) 80%, var(--text)); }
+ .diceList { max-height: 340px; overflow:auto; border:1px solid rgba(246,240,255,0.10); border-radius:12px; }
+ .diceRow { padding:10px 12px; border-bottom:1px solid rgba(246,240,255,0.06); }
+ .diceRow:last-child { border-bottom:0; }
+ .diceTop { display:flex; gap:8px; flex-wrap:wrap; align-items:baseline; }
+ .diceBottom { display:flex; gap:8px; flex-wrap:wrap; margin-top:4px; }
+ .diceUser { font-weight: 900; }
+ .diceExpr { font-weight: 900; letter-spacing: 0.2px; }
+ .diceTotal { font-weight: 900; color: color-mix(in srgb, var(--warn) 70%, var(--text)); }
+ </style>
+ <div class="diceWrap">
+ <div class="diceControls">
+ <input type="text" maxlength="40" placeholder="XdY+Z (e.g. 2d6+1, d20, 4d8-2)" data-diceexpr="1" />
+ <button type="button" class="primary smallBtn" data-diceroll="1">Roll</button>
+ </div>
+ <div class="small muted">Rolls are server-generated and broadcast to everyone.</div>
+ <div class="small diceErr hidden" data-diceerr="1"></div>
+ <div class="diceList" data-dicelist="1"></div>
+ </div>
+ `;
+ inputEl = mountEl.querySelector("[data-diceexpr='1']");
+ rollBtnEl = mountEl.querySelector("[data-diceroll='1']");
+ listEl = mountEl.querySelector("[data-dicelist='1']");
+ errEl = mountEl.querySelector("[data-diceerr='1']");
+
+ if (rollBtnEl) rollBtnEl.addEventListener("click", doRoll);
+ if (inputEl) {
+ inputEl.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ doRoll();
+ }
+ });
+ }
+ renderList();
+ }
+
+ function onWsMessage(evt) {
+ const msg = safeJsonParse(evt.data);
+ if (!msg || typeof msg !== "object") return;
+ const type = String(msg.type || "");
+ if (type === "plugin:dice:history") {
+ history = Array.isArray(msg.history) ? msg.history : [];
+ renderList();
+ return;
+ }
+ if (type === "plugin:dice:rolled") {
+ if (msg.roll && typeof msg.roll === "object") {
+ history = [...history, msg.roll].slice(-120);
+ renderList();
+ }
+ return;
+ }
+ if (type === "plugin:dice:error") {
+ setError(String(msg.message || "Error."));
+ }
+ }
+
+ ctx.ui?.registerPanel?.({
+ id: "dice",
+ title: "Dice",
+ icon: "🎲",
+ defaultRack: "right",
+ role: "utility",
+ presetHints: {
+ defaultSocial: { place: "docked.bottom" },
+ ops: { place: "docked.bottom" }
+ },
+ render(mount) {
+ mountEl = mount;
+ history = [];
+ ensureUi();
+ if (ws && ws.addEventListener) ws.addEventListener("message", onWsMessage);
+ ctx.send("stateReq", {});
+ return () => {
+ if (ws && ws.removeEventListener) ws.removeEventListener("message", onWsMessage);
+ mountEl = null;
+ };
+ }
+ });
+ });
+})();
+
diff --git a/plugins_dev/dice/plugin.json b/plugins_dev/dice/plugin.json
@@ -0,0 +1,10 @@
+{
+ "id": "dice",
+ "name": "Dice",
+ "version": "0.1.0",
+ "description": "Shared dice roller (XdY+Z) that broadcasts results to everyone.",
+ "entryClient": "client.js",
+ "entryServer": "server.js",
+ "permissions": ["ui", "ws"]
+}
+
diff --git a/plugins_dev/dice/server.js b/plugins_dev/dice/server.js
@@ -0,0 +1,164 @@
+const crypto = require("crypto");
+const fs = require("fs");
+const path = require("path");
+
+const HISTORY_MAX = Number(process.env.DICE_HISTORY_MAX || 120);
+const MAX_DICE = Number(process.env.DICE_MAX_DICE || 100);
+const MAX_SIDES = Number(process.env.DICE_MAX_SIDES || 1000);
+const MAX_MOD_ABS = Number(process.env.DICE_MAX_MOD_ABS || 10000);
+
+function safeJsonParse(str) {
+ try {
+ return JSON.parse(str);
+ } catch {
+ return null;
+ }
+}
+
+function readJsonOrNull(filePath) {
+ try {
+ const raw = fs.readFileSync(filePath, "utf8");
+ return safeJsonParse(raw);
+ } catch {
+ return null;
+ }
+}
+
+function writeFileAtomic(filePath, content) {
+ const dir = path.dirname(filePath);
+ fs.mkdirSync(dir, { recursive: true });
+ const tmp = `${filePath}.tmp.${crypto.randomBytes(6).toString("hex")}`;
+ fs.writeFileSync(tmp, content, "utf8");
+ fs.renameSync(tmp, filePath);
+}
+
+function nowMs() {
+ return Date.now();
+}
+
+function toId(bytes = 6) {
+ return crypto.randomBytes(Math.max(3, Math.min(16, bytes))).toString("hex");
+}
+
+function normUser(u) {
+ return String(u || "").trim().toLowerCase();
+}
+
+function parseExpr(raw) {
+ const s = String(raw || "").trim();
+ if (!s) return { ok: false, error: "Empty roll." };
+ const m = s.match(/^\s*(\d*)\s*d\s*(\d+)\s*([+-]\s*\d+)?\s*$/i);
+ if (!m) return { ok: false, error: "Invalid format. Use XdY+Z (example: 2d6+1)." };
+ const countRaw = m[1];
+ const sidesRaw = m[2];
+ const modRaw = m[3] || "";
+ const count = countRaw ? Number(countRaw) : 1;
+ const sides = Number(sidesRaw);
+ const mod = modRaw ? Number(String(modRaw).replace(/\s+/g, "")) : 0;
+ if (!Number.isFinite(count) || !Number.isFinite(sides) || !Number.isFinite(mod)) {
+ return { ok: false, error: "Invalid numbers in roll." };
+ }
+ const dice = Math.floor(count);
+ const sds = Math.floor(sides);
+ const md = Math.trunc(mod);
+ if (dice <= 0) return { ok: false, error: "Dice count must be >= 1." };
+ if (dice > MAX_DICE) return { ok: false, error: `Too many dice (max ${MAX_DICE}).` };
+ if (sds < 2) return { ok: false, error: "Sides must be >= 2." };
+ if (sds > MAX_SIDES) return { ok: false, error: `Too many sides (max ${MAX_SIDES}).` };
+ if (Math.abs(md) > MAX_MOD_ABS) return { ok: false, error: `Modifier too large (max ±${MAX_MOD_ABS}).` };
+ return { ok: true, dice, sides: sds, mod: md, expr: `${dice}d${sds}${md === 0 ? "" : md > 0 ? `+${md}` : `${md}`}` };
+}
+
+function rollOnce(sides) {
+ // inclusive 1..sides
+ return crypto.randomInt(1, sides + 1);
+}
+
+module.exports = function init(api) {
+ const dataFile = path.join(__dirname, "dice.json");
+ let history = [];
+
+ function loadHistory() {
+ const parsed = readJsonOrNull(dataFile);
+ const arr = Array.isArray(parsed?.history) ? parsed.history : [];
+ history = arr
+ .map((r) => {
+ const id = String(r?.id || "").trim().toLowerCase();
+ const user = normUser(r?.user);
+ const expr = String(r?.expr || "").trim().slice(0, 40);
+ const dice = Number(r?.dice || 0) || 0;
+ const sides = Number(r?.sides || 0) || 0;
+ const mod = Number(r?.mod || 0) || 0;
+ const rolls = Array.isArray(r?.rolls) ? r.rolls.map((n) => Math.max(1, Math.floor(Number(n || 0) || 0))) : [];
+ const total = Number(r?.total || 0) || 0;
+ const createdAt = Number(r?.createdAt || 0) || 0;
+ if (!id || !user || !expr || !createdAt) return null;
+ return { id, user, expr, dice, sides, mod, rolls: rolls.slice(0, MAX_DICE), total, createdAt };
+ })
+ .filter(Boolean)
+ .slice(-HISTORY_MAX);
+ }
+
+ function saveHistory() {
+ writeFileAtomic(dataFile, JSON.stringify({ version: 1, savedAt: nowMs(), history }, null, 2) + "\n");
+ }
+
+ function broadcastRoll(entry) {
+ api.broadcast({ type: "plugin:dice:rolled", roll: entry });
+ }
+
+ function send(ws, msg) {
+ try {
+ ws.send(JSON.stringify(msg));
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ function sendError(ws, message) {
+ send(ws, { type: "plugin:dice:error", message: String(message || "Error.") });
+ }
+
+ loadHistory();
+
+ api.registerWs("stateReq", (ws) => {
+ send(ws, { type: "plugin:dice:history", history });
+ });
+
+ api.registerWs("roll", (ws, msg) => {
+ const user = normUser(ws?.user?.username);
+ if (!user) {
+ sendError(ws, "Sign in required to roll dice.");
+ return;
+ }
+ const parsed = parseExpr(msg?.expr);
+ if (!parsed.ok) {
+ sendError(ws, parsed.error);
+ return;
+ }
+ const rolls = [];
+ let sum = 0;
+ for (let i = 0; i < parsed.dice; i++) {
+ const r = rollOnce(parsed.sides);
+ rolls.push(r);
+ sum += r;
+ }
+ const total = sum + parsed.mod;
+ const entry = {
+ id: toId(6),
+ user,
+ expr: parsed.expr,
+ dice: parsed.dice,
+ sides: parsed.sides,
+ mod: parsed.mod,
+ rolls,
+ total,
+ createdAt: nowMs()
+ };
+ history = [...history, entry].slice(-HISTORY_MAX);
+ saveHistory();
+ broadcastRoll(entry);
+ });
+};
+
diff --git a/plugins_dev/radio/client.js b/plugins_dev/radio/client.js
@@ -0,0 +1,486 @@
+(() => {
+ if (!window?.BzlPluginHost?.register) return;
+
+ const PLUGIN_ID = "radio";
+ const LS_ON = "bzl_radio_on";
+ const LS_TUNED = "bzl_radio_stationId";
+
+ function esc(s) {
+ return String(s ?? "")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/\"/g, """)
+ .replace(/'/g, "'");
+ }
+
+ function safeJsonParse(str) {
+ try {
+ return JSON.parse(str);
+ } catch {
+ return null;
+ }
+ }
+
+ function getSessionTokenSafe() {
+ try {
+ // Core stores the session token in localStorage.
+ return localStorage.getItem("bzl_session_token") || "";
+ } catch {
+ return "";
+ }
+ }
+
+ async function uploadMp3(file, toast) {
+ if (!file) return "";
+ const lowered = String(file.name || "").toLowerCase();
+ const isMp3 = lowered.endsWith(".mp3") || String(file.type || "").toLowerCase() === "audio/mpeg";
+ if (!isMp3) {
+ toast("Radio", "Only MP3 files are allowed.");
+ return "";
+ }
+ const token = getSessionTokenSafe();
+ if (!token) {
+ toast("Radio", "Sign in required to upload MP3s.");
+ return "";
+ }
+ try {
+ const res = await fetch("/api/upload?kind=audio", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "audio/mpeg"
+ },
+ body: file
+ });
+ const payload = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ toast("Radio", payload?.error || "Upload failed.");
+ return "";
+ }
+ const url = String(payload?.url || "");
+ if (!url) {
+ toast("Radio", "Upload failed (no URL).");
+ return "";
+ }
+ if (!/\.mp3(\?|#|$)/i.test(url)) {
+ toast("Radio", "Upload rejected (not an MP3 URL).");
+ return "";
+ }
+ return url;
+ } catch {
+ toast("Radio", "Upload failed (network error).");
+ return "";
+ }
+ }
+
+ window.BzlPluginHost.register(PLUGIN_ID, (ctx) => {
+ const ws = window.__bzlWs;
+ let mountEl = null;
+ let audioEl = null;
+ let fileInputEl = null;
+
+ let stations = [];
+ let isOn = false;
+ let tunedStationId = "";
+ let playingStationId = "";
+ let lastPlayedTrackUrl = "";
+ let lastPlayedTrackSrc = "";
+ let needsManualPlay = false;
+
+ function readPrefs() {
+ try {
+ isOn = localStorage.getItem(LS_ON) === "1";
+ tunedStationId = String(localStorage.getItem(LS_TUNED) || "").trim().toLowerCase();
+ } catch {
+ isOn = false;
+ tunedStationId = "";
+ }
+ }
+
+ function writeOn(next) {
+ isOn = Boolean(next);
+ try {
+ localStorage.setItem(LS_ON, isOn ? "1" : "0");
+ } catch {}
+ }
+
+ function writeTuned(id) {
+ tunedStationId = String(id || "").trim().toLowerCase();
+ try {
+ localStorage.setItem(LS_TUNED, tunedStationId);
+ } catch {}
+ }
+
+ function stationById(id) {
+ const sid = String(id || "").trim().toLowerCase();
+ return stations.find((s) => String(s?.id || "") === sid) || null;
+ }
+
+ function ensureTunedExists() {
+ if (!stations.length) {
+ tunedStationId = "";
+ return;
+ }
+ if (tunedStationId && stationById(tunedStationId)) return;
+ tunedStationId = String(stations[0]?.id || "");
+ }
+
+ function pickRandomTrack(station) {
+ const tracks = Array.isArray(station?.tracks) ? station.tracks : [];
+ if (!tracks.length) return null;
+ if (tracks.length === 1) return tracks[0];
+ for (let i = 0; i < 6; i++) {
+ const t = tracks[Math.floor(Math.random() * tracks.length)];
+ if (t && String(t.url || "") !== lastPlayedTrackUrl) return t;
+ }
+ return tracks[Math.floor(Math.random() * tracks.length)];
+ }
+
+ function stopPlayback() {
+ playingStationId = "";
+ lastPlayedTrackUrl = "";
+ lastPlayedTrackSrc = "";
+ needsManualPlay = false;
+ if (!audioEl) return;
+ try {
+ audioEl.pause();
+ audioEl.removeAttribute("src");
+ audioEl.load();
+ } catch {}
+ updateNowEl();
+ }
+
+ async function startPlaybackFor(stationId) {
+ if (!audioEl) return;
+ const station = stationById(stationId);
+ if (!station) return;
+ const track = pickRandomTrack(station);
+ if (!track || !track.url) {
+ stopPlayback();
+ return;
+ }
+ playingStationId = String(station.id || "");
+ lastPlayedTrackUrl = String(track.url || "");
+ needsManualPlay = false;
+ try {
+ lastPlayedTrackSrc = new URL(String(track.url || ""), window.location.origin).href;
+ } catch {
+ lastPlayedTrackSrc = String(track.url || "");
+ }
+ audioEl.src = lastPlayedTrackSrc;
+ try {
+ audioEl.load();
+ } catch {}
+ try {
+ await audioEl.play();
+ } catch {
+ // Autoplay can be blocked; keep controls visible and prompt user to press play.
+ needsManualPlay = true;
+ }
+ updateNowEl();
+ }
+
+ function updateNowEl() {
+ if (!mountEl) return;
+ const nowWrap = mountEl.querySelector(".radioNow");
+ if (!nowWrap) return;
+ ensureTunedExists();
+ const tuned = tunedStationId ? stationById(tunedStationId) : null;
+ const trackCount = tuned ? Number(tuned.trackCount || (tuned.tracks || []).length || 0) : 0;
+ const isPlaying = Boolean(isOn && playingStationId && playingStationId === tunedStationId);
+ const nowTrack = isPlaying ? (tuned?.tracks || []).find((t) => String(t.url || "") === lastPlayedTrackUrl) : null;
+
+ nowWrap.innerHTML = `
+ ${
+ !tuned
+ ? `<span class="muted">No stations available.</span>`
+ : !isOn
+ ? `<span class="muted">Radio is off. Tune around, then toggle it on to listen.</span>`
+ : trackCount === 0
+ ? `<span class="muted">This station has no tracks yet. Upload some MP3s.</span>`
+ : nowTrack
+ ? `Now playing: <b>${esc(nowTrack.title || "Track")}</b> <span class="muted">(${esc(
+ nowTrack.addedBy ? `@${nowTrack.addedBy}` : "unknown"
+ )})</span>`
+ : needsManualPlay
+ ? `<span class="muted">Press play to start listening (browser blocked autoplay).</span>`
+ : `<span class="muted">Ready.</span>`
+ }
+ `;
+ }
+
+ function stepTune(dir) {
+ if (!stations.length) return;
+ const currentIdx = Math.max(
+ 0,
+ stations.findIndex((s) => String(s?.id || "") === tunedStationId)
+ );
+ const nextIdx = (currentIdx + (dir < 0 ? -1 : 1) + stations.length) % stations.length;
+ const nextId = String(stations[nextIdx]?.id || "");
+ writeTuned(nextId);
+ render();
+ if (isOn) {
+ stopPlayback();
+ startPlaybackFor(nextId);
+ }
+ }
+
+ function render() {
+ if (!mountEl) return;
+ ensureTunedExists();
+
+ const tuned = tunedStationId ? stationById(tunedStationId) : null;
+ const canUpload = Boolean(ctx.getUser && ctx.getUser());
+ const tunedName = tuned ? tuned.name : "No stations yet";
+ const tunedAuthor = tuned ? tuned.author : "";
+ const trackCount = tuned ? Number(tuned.trackCount || (tuned.tracks || []).length || 0) : 0;
+ const isPlaying = Boolean(isOn && playingStationId && playingStationId === tunedStationId);
+ const nowTrack = isPlaying ? (tuned?.tracks || []).find((t) => String(t.url || "") === lastPlayedTrackUrl) : null;
+
+ mountEl.innerHTML = `
+ <style>
+ .radioWrap { display:flex; flex-direction:column; gap:10px; }
+ .radioTop { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
+ .radioRow { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
+ .radioStation { display:flex; flex-direction:column; gap:2px; padding:10px 12px; border:1px solid rgba(246,240,255,0.12); background:rgba(255,255,255,0.02); border-radius:14px; }
+ .radioStationName { font-weight:900; letter-spacing:0.2px; }
+ .radioMeta { opacity:0.75; }
+ .radioNow { padding:8px 10px; border:1px dashed rgba(246,240,255,0.16); border-radius:12px; background:rgba(0,0,0,0.12); }
+ .radioBtns button { padding:7px 10px; border-radius:10px; }
+ .radioArrow { font-size:16px; line-height:1; }
+ .radioSmall { font-size: 12px; opacity: 0.8; }
+ .radioList { max-height: 220px; overflow:auto; border:1px solid rgba(246,240,255,0.10); border-radius:12px; }
+ .radioStationItem { padding:8px 10px; display:flex; justify-content:space-between; gap:10px; border-bottom:1px solid rgba(246,240,255,0.06); cursor:pointer; }
+ .radioStationItem:last-child { border-bottom:0; }
+ .radioStationItem.active { background: rgba(255, 184, 77, 0.08); }
+ </style>
+ <div class="radioWrap">
+ <div class="radioTop">
+ <div class="radioRow">
+ <label class="row" style="gap:8px;align-items:center">
+ <input type="checkbox" data-radioon="1" ${isOn ? "checked" : ""} />
+ <span style="font-weight:900">Radio</span>
+ </label>
+ <div class="radioBtns radioRow">
+ <button type="button" class="ghost smallBtn radioArrow" data-tune="-1" title="Tune left">←</button>
+ <button type="button" class="ghost smallBtn radioArrow" data-tune="1" title="Tune right">→</button>
+ </div>
+ </div>
+ <div class="radioRow">
+ <button type="button" class="ghost smallBtn" data-newstation="1">New station</button>
+ <button type="button" class="ghost smallBtn" data-upload="1" ${!tuned ? "disabled" : ""}>Upload MP3s</button>
+ <input type="file" accept=\".mp3,audio/mpeg\" multiple style="display:none" data-filepick="1" />
+ </div>
+ </div>
+
+ <div class="radioStation">
+ <div class="radioStationName">${esc(tunedName)}</div>
+ <div class="radioMeta small muted">${tunedAuthor ? `by @${esc(tunedAuthor)} • ${trackCount} track${trackCount === 1 ? "" : "s"}` : "Create a station to get started."}</div>
+ </div>
+
+ <div class="radioNow small">
+ ${
+ !tuned
+ ? `<span class="muted">No stations available.</span>`
+ : !isOn
+ ? `<span class="muted">Radio is off. Tune around, then toggle it on to listen.</span>`
+ : trackCount === 0
+ ? `<span class="muted">This station has no tracks yet. Upload some MP3s.</span>`
+ : nowTrack
+ ? `Now playing: <b>${esc(nowTrack.title || "Track")}</b> <span class="muted">(${esc(nowTrack.addedBy ? `@${nowTrack.addedBy}` : "unknown")})</span>`
+ : needsManualPlay
+ ? `<span class="muted">Press play to start listening (browser blocked autoplay).</span>`
+ : `<span class="muted">Ready.</span>`
+ }
+ </div>
+
+ <audio controls preload="none" style="width:100%"></audio>
+
+ <div class="radioSmall muted">Stations</div>
+ <div class="radioList">
+ ${
+ stations.length
+ ? stations
+ .map((s) => {
+ const active = String(s.id || "") === tunedStationId;
+ const count = Number(s.trackCount || (s.tracks || []).length || 0);
+ return `<div class="radioStationItem ${active ? "active" : ""}" data-station="${esc(s.id)}">
+ <span><b>${esc(s.name || "Station")}</b> <span class="muted">by @${esc(s.author || "")}</span></span>
+ <span class="muted">${count}</span>
+ </div>`;
+ })
+ .join("")
+ : `<div class="small muted" style="padding:10px">No stations yet.</div>`
+ }
+ </div>
+ </div>
+ `;
+
+ audioEl = mountEl.querySelector("audio");
+ fileInputEl = mountEl.querySelector('[data-filepick="1"]');
+
+ if (audioEl) {
+ // This plugin re-renders via innerHTML. Preserve the last selected track across renders so
+ // the native audio controls don't end up disabled (no src).
+ if (lastPlayedTrackSrc) {
+ const current = String(audioEl.getAttribute("src") || "");
+ if (current !== lastPlayedTrackSrc) {
+ audioEl.src = lastPlayedTrackSrc;
+ try {
+ audioEl.load();
+ } catch {}
+ }
+ }
+ }
+
+ const onEl = mountEl.querySelector('[data-radioon="1"]');
+ const uploadBtn = mountEl.querySelector('[data-upload="1"]');
+
+ if (onEl) {
+ onEl.addEventListener("change", () => {
+ writeOn(Boolean(onEl.checked));
+ render();
+ if (!isOn) stopPlayback();
+ else if (tunedStationId) startPlaybackFor(tunedStationId);
+ });
+ }
+
+ mountEl.querySelectorAll("[data-tune]").forEach((btn) => {
+ btn.addEventListener("click", () => stepTune(Number(btn.getAttribute("data-tune") || "0")));
+ });
+
+ mountEl.querySelectorAll("[data-station]").forEach((row) => {
+ row.addEventListener("click", () => {
+ const id = String(row.getAttribute("data-station") || "").trim().toLowerCase();
+ if (!id) return;
+ writeTuned(id);
+ render();
+ if (isOn) {
+ stopPlayback();
+ startPlaybackFor(id);
+ }
+ });
+ });
+
+ const newBtn = mountEl.querySelector('[data-newstation="1"]');
+ if (newBtn) {
+ newBtn.addEventListener("click", () => {
+ const name = prompt("Station name:");
+ if (!name) return;
+ ctx.send("createStation", { name: String(name).trim() });
+ });
+ }
+
+ if (uploadBtn && fileInputEl) {
+ uploadBtn.addEventListener("click", () => {
+ if (!canUpload) {
+ ctx.toast("Radio", "Sign in required to upload MP3s.");
+ return;
+ }
+ if (!tunedStationId) return;
+ fileInputEl.value = "";
+ fileInputEl.click();
+ });
+ fileInputEl.addEventListener("change", async () => {
+ const files = Array.from(fileInputEl.files || []);
+ if (!files.length || !tunedStationId) return;
+ const uploaded = [];
+ for (const f of files) {
+ const url = await uploadMp3(f, ctx.toast);
+ if (!url) continue;
+ uploaded.push({ url, title: String(f.name || "track").replace(/\.mp3$/i, "") });
+ }
+ if (!uploaded.length) return;
+ ctx.send("addTracks", { stationId: tunedStationId, tracks: uploaded });
+ });
+ }
+
+ if (audioEl) {
+ audioEl.addEventListener("ended", () => {
+ if (!isOn) return;
+ if (!tunedStationId) return;
+ startPlaybackFor(tunedStationId);
+ });
+ audioEl.addEventListener("error", () => {
+ if (!isOn) return;
+ stopPlayback();
+ updateNowEl();
+ });
+ audioEl.addEventListener("play", () => {
+ needsManualPlay = false;
+ updateNowEl();
+ });
+ }
+
+ // Keep audio element in sync with current state.
+ if (!isOn) stopPlayback();
+ else if (tunedStationId && (!playingStationId || playingStationId !== tunedStationId) && !needsManualPlay) {
+ stopPlayback();
+ startPlaybackFor(tunedStationId);
+ }
+ }
+
+ function onWsMessage(evt) {
+ let msg;
+ try {
+ msg = safeJsonParse(evt.data);
+ } catch {
+ return;
+ }
+ if (!msg || typeof msg !== "object") return;
+ const type = String(msg.type || "");
+ if (type === "plugin:radio:stations") {
+ stations = Array.isArray(msg.stations) ? msg.stations : [];
+ stations.sort((a, b) => Number(a.createdAt || 0) - Number(b.createdAt || 0));
+ ensureTunedExists();
+ render();
+ return;
+ }
+ if (type === "plugin:radio:createOk") {
+ const id = String(msg.stationId || "").trim().toLowerCase();
+ if (id) {
+ writeTuned(id);
+ render();
+ }
+ return;
+ }
+ if (type === "plugin:radio:addOk") {
+ ctx.toast("Radio", `Added ${Number(msg.added || 0)} track(s).`);
+ return;
+ }
+ if (type === "plugin:radio:error") {
+ ctx.toast("Radio", String(msg.message || "Radio error."));
+ }
+ }
+
+ readPrefs();
+
+ ctx.ui?.registerPanel?.({
+ id: "radio",
+ title: "Radio",
+ icon: "📻",
+ defaultRack: "right",
+ role: "aux",
+ presetHints: {
+ defaultSocial: { place: "docked.bottom" },
+ browse: { place: "docked.bottom" },
+ mapsSession: { place: "right" }
+ },
+ render(mount) {
+ mountEl = mount;
+ stations = [];
+ playingStationId = "";
+ lastPlayedTrackUrl = "";
+ if (ws && ws.addEventListener) ws.addEventListener("message", onWsMessage);
+ ctx.send("stateReq", {});
+ render();
+ return () => {
+ if (ws && ws.removeEventListener) ws.removeEventListener("message", onWsMessage);
+ stopPlayback();
+ mountEl = null;
+ };
+ }
+ });
+ });
+})();
diff --git a/plugins_dev/radio/plugin.json b/plugins_dev/radio/plugin.json
@@ -0,0 +1,9 @@
+{
+ "id": "radio",
+ "name": "Radio",
+ "version": "0.1.6",
+ "description": "A playful community radio: create stations, upload MP3 tracks, and tune in.",
+ "entryClient": "client.js",
+ "entryServer": "server.js",
+ "permissions": ["ui", "ws", "uploads"]
+}
diff --git a/plugins_dev/radio/server.js b/plugins_dev/radio/server.js
@@ -0,0 +1,222 @@
+const crypto = require("crypto");
+const fs = require("fs");
+const path = require("path");
+
+const MAX_STATIONS = Number(process.env.RADIO_MAX_STATIONS || 500);
+const MAX_TRACKS_PER_STATION = Number(process.env.RADIO_MAX_TRACKS_PER_STATION || 500);
+
+function safeJsonParse(str) {
+ try {
+ return JSON.parse(str);
+ } catch {
+ return null;
+ }
+}
+
+function readJsonOrNull(filePath) {
+ try {
+ const raw = fs.readFileSync(filePath, "utf8");
+ return safeJsonParse(raw);
+ } catch {
+ return null;
+ }
+}
+
+function writeFileAtomic(filePath, content) {
+ const dir = path.dirname(filePath);
+ fs.mkdirSync(dir, { recursive: true });
+ const tmp = `${filePath}.tmp.${crypto.randomBytes(6).toString("hex")}`;
+ fs.writeFileSync(tmp, content, "utf8");
+ fs.renameSync(tmp, filePath);
+}
+
+function nowMs() {
+ return Date.now();
+}
+
+function toId(bytes = 6) {
+ return crypto.randomBytes(Math.max(3, Math.min(16, bytes))).toString("hex");
+}
+
+function normUser(u) {
+ return String(u || "").trim().toLowerCase();
+}
+
+function normTitle(t, fallback) {
+ const s = String(t || "").replace(/\s+/g, " ").trim().slice(0, 40);
+ return s || fallback || "Untitled";
+}
+
+function normUrl(u) {
+ const s = String(u || "").trim();
+ if (!s) return "";
+ if (!s.startsWith("/uploads/")) return "";
+ if (!/\.mp3(\?|#|$)/i.test(s)) return "";
+ if (s.length > 280) return "";
+ return s;
+}
+
+module.exports = function init(api) {
+ const dataFile = path.join(__dirname, "radio.json");
+
+ function loadState() {
+ const parsed = readJsonOrNull(dataFile);
+ const stations = Array.isArray(parsed?.stations) ? parsed.stations : [];
+ const cleaned = stations
+ .map((s) => {
+ const id = String(s?.id || "").trim().toLowerCase();
+ const name = normTitle(s?.name, "Station");
+ const author = normUser(s?.author);
+ const createdAt = Number(s?.createdAt || 0) || 0;
+ const tracks = Array.isArray(s?.tracks) ? s.tracks : [];
+ const cleanTracks = tracks
+ .map((t) => {
+ const tid = String(t?.id || "").trim().toLowerCase();
+ const title = normTitle(t?.title, "Track");
+ const url = normUrl(t?.url);
+ const addedBy = normUser(t?.addedBy);
+ const addedAt = Number(t?.addedAt || 0) || 0;
+ if (!tid || !url) return null;
+ return { id: tid, title, url, addedBy, addedAt };
+ })
+ .filter(Boolean)
+ .slice(0, MAX_TRACKS_PER_STATION);
+ if (!id || !author) return null;
+ return { id, name, author, createdAt, tracks: cleanTracks };
+ })
+ .filter(Boolean)
+ .slice(0, MAX_STATIONS);
+ cleaned.sort((a, b) => Number(a.createdAt || 0) - Number(b.createdAt || 0));
+ return { stations: cleaned };
+ }
+
+ function saveState(state) {
+ const stations = Array.isArray(state?.stations) ? state.stations : [];
+ writeFileAtomic(dataFile, JSON.stringify({ version: 1, savedAt: nowMs(), stations }, null, 2) + "\n");
+ }
+
+ function listForClient() {
+ const state = loadState();
+ return state.stations.map((s) => ({
+ id: s.id,
+ name: s.name,
+ author: s.author,
+ createdAt: s.createdAt,
+ trackCount: Array.isArray(s.tracks) ? s.tracks.length : 0,
+ tracks: (Array.isArray(s.tracks) ? s.tracks : []).map((t) => ({
+ id: t.id,
+ title: t.title,
+ url: t.url,
+ addedBy: t.addedBy,
+ addedAt: t.addedAt
+ }))
+ }));
+ }
+
+ function send(ws, msg) {
+ try {
+ ws.send(JSON.stringify(msg));
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ function sendError(ws, message, data) {
+ send(ws, { type: "plugin:radio:error", message: String(message || "Error."), data: data || null });
+ }
+
+ function broadcastStations() {
+ api.broadcast({ type: "plugin:radio:stations", stations: listForClient(), at: api.now() });
+ }
+
+ api.registerWs("stateReq", (ws) => {
+ send(ws, { type: "plugin:radio:stations", stations: listForClient(), at: api.now() });
+ });
+
+ api.registerWs("createStation", (ws, msg) => {
+ const user = normUser(ws?.user?.username);
+ if (!user) {
+ sendError(ws, "Sign in required to create a station.");
+ return;
+ }
+ const name = normTitle(msg?.name, "");
+ if (!name) {
+ sendError(ws, "Station name required.");
+ return;
+ }
+ const state = loadState();
+ if (state.stations.length >= MAX_STATIONS) {
+ sendError(ws, "Station limit reached.");
+ return;
+ }
+ const station = {
+ id: toId(6),
+ name,
+ author: user,
+ createdAt: nowMs(),
+ tracks: []
+ };
+ state.stations.push(station);
+ saveState(state);
+ broadcastStations();
+ send(ws, { type: "plugin:radio:createOk", stationId: station.id });
+ });
+
+ api.registerWs("addTracks", (ws, msg) => {
+ const user = normUser(ws?.user?.username);
+ if (!user) {
+ sendError(ws, "Sign in required to upload tracks.");
+ return;
+ }
+ const stationId = String(msg?.stationId || "").trim().toLowerCase();
+ if (!stationId) {
+ sendError(ws, "Missing stationId.");
+ return;
+ }
+ const incoming = Array.isArray(msg?.tracks) ? msg.tracks : [];
+ if (!incoming.length) {
+ sendError(ws, "No tracks provided.");
+ return;
+ }
+ const state = loadState();
+ const idx = state.stations.findIndex((s) => String(s?.id || "") === stationId);
+ if (idx < 0) {
+ sendError(ws, "Station not found.");
+ return;
+ }
+ const station = state.stations[idx];
+ const existingUrls = new Set((station.tracks || []).map((t) => String(t.url || "")));
+ const space = Math.max(0, MAX_TRACKS_PER_STATION - (Array.isArray(station.tracks) ? station.tracks.length : 0));
+ if (space <= 0) {
+ sendError(ws, "Track limit reached for this station.");
+ return;
+ }
+
+ const clean = [];
+ for (const raw of incoming) {
+ if (clean.length >= space) break;
+ const url = normUrl(raw?.url);
+ if (!url || existingUrls.has(url)) continue;
+ const title = normTitle(raw?.title, "Track");
+ clean.push({
+ id: toId(6),
+ title,
+ url,
+ addedBy: user,
+ addedAt: nowMs()
+ });
+ existingUrls.add(url);
+ }
+ if (!clean.length) {
+ sendError(ws, "No valid MP3 tracks to add.");
+ return;
+ }
+ station.tracks = [...(Array.isArray(station.tracks) ? station.tracks : []), ...clean].slice(0, MAX_TRACKS_PER_STATION);
+ state.stations[idx] = station;
+ saveState(state);
+ broadcastStations();
+ send(ws, { type: "plugin:radio:addOk", stationId, added: clean.length });
+ });
+};
+
diff --git a/public/app.js b/public/app.js
@@ -27,6 +27,7 @@ const toggleRightRackEl = document.getElementById("toggleRightRack");
const layoutPresetEl = document.getElementById("layoutPreset");
const uiScaleEl = document.getElementById("uiScale");
const deviceLayoutEl = document.getElementById("deviceLayout");
+const stayConnectedEl = document.getElementById("stayConnected");
const dockHotbarEl = document.getElementById("dockHotbar");
const showSideRackBtn = document.getElementById("showSideRack");
const showRightRackBtn = document.getElementById("showRightRack");
@@ -667,6 +668,173 @@ registerCorePanel({ id: "moderation", title: "Moderation", icon: "🛡️", role
registerCorePanel({ id: "profile", title: "Profile", icon: "👤", role: "transient", defaultRack: "main", element: profileViewPanel });
registerCorePanel({ id: "composer", title: "New Hive", icon: "✍️", role: "aux", defaultRack: "main", element: pollinatePanel });
+let pluginRackPanelEl = null;
+let pluginRackWidgetsRackEl = null;
+let pluginRackAddMenuEl = null;
+
+function closePluginRackAddMenu() {
+ if (!pluginRackAddMenuEl) return;
+ try {
+ pluginRackAddMenuEl.remove();
+ } catch {
+ // ignore
+ }
+ pluginRackAddMenuEl = null;
+}
+
+function panelIsPluginOwned(panelId) {
+ const id = String(panelId || "").trim();
+ if (!id) return false;
+ if (id.startsWith("chat:")) return false;
+ const entry = panelRegistry.get(id);
+ const src = typeof entry?.source === "string" ? entry.source : "";
+ return src.startsWith("plugin:");
+}
+
+function panelIsHostableInPluginRack(panelId) {
+ const id = String(panelId || "").trim();
+ if (!id) return false;
+ if (id === "pluginRack") return false;
+ if (!panelIsPluginOwned(id)) return false;
+ // Widgets should be small, stackable tools (not full workspace surfaces like Maps).
+ if (panelRole(id) === "primary") return false;
+ return true;
+}
+
+function ensurePluginRackPanel() {
+ if (pluginRackPanelEl instanceof HTMLElement && pluginRackPanelEl.isConnected) return pluginRackPanelEl;
+
+ if (!(pluginRackPanelEl instanceof HTMLElement)) {
+ const shell = document.createElement("section");
+ shell.className = "panel panelFill pluginRackPanel rackPanel";
+ shell.dataset.panelId = "pluginRack";
+ shell.innerHTML = `
+ <div class="panelHeader">
+ <div class="panelTitle">${escapeHtml("Plugin Rack")}</div>
+ <div class="row"></div>
+ </div>
+ <div class="panelBody pluginRackBody">
+ <div class="pluginRackToolbar">
+ <button type="button" class="ghost smallBtn" data-pluginrackadd="1">+ Add widget</button>
+ <div class="small muted pluginRackHint">Drop plugin panels here to stack them.</div>
+ </div>
+ <div id="pluginRackWidgetsRack" class="pluginRackWidgets" aria-label="Plugin widgets"></div>
+ </div>
+ `;
+ pluginRackPanelEl = shell;
+ pluginRackWidgetsRackEl = shell.querySelector("#pluginRackWidgetsRack");
+
+ shell.querySelector("[data-pluginrackadd]")?.addEventListener("click", (e) => {
+ const anchor = e.currentTarget;
+ if (pluginRackAddMenuEl) closePluginRackAddMenu();
+ else openPluginRackAddMenu(anchor);
+ });
+ }
+
+ // Ensure it's registered as a core panel for docking + layout state.
+ registerCorePanel({ id: "pluginRack", title: "Plugin Rack", icon: "🧰", role: "aux", defaultRack: "main", element: pluginRackPanelEl });
+
+ // Append into the DOM so it can be docked/restored. (It will typically live in the hotbar.)
+ const side = ensureMainSideRack();
+ if (side && pluginRackPanelEl.parentElement !== side) side.appendChild(pluginRackPanelEl);
+
+ return pluginRackPanelEl;
+}
+
+function ensurePluginRackWidgetsRack() {
+ ensurePluginRackPanel();
+ return pluginRackWidgetsRackEl instanceof HTMLElement ? pluginRackWidgetsRackEl : null;
+}
+
+function readPluginRackWidgetsOrder() {
+ const rack = ensurePluginRackWidgetsRack();
+ return rack ? readRackOrder(rack) : [];
+}
+
+function removePanelFromPluginRack(panelId) {
+ const id = String(panelId || "").trim();
+ if (!id) return;
+ rackLayoutState.pluginRackWidgets = Array.isArray(rackLayoutState.pluginRackWidgets)
+ ? rackLayoutState.pluginRackWidgets.filter((x) => x !== id)
+ : [];
+ const el = getPanelElement(id);
+ if (el) el.classList.remove("pluginRackWidget");
+ const rack = ensurePluginRackWidgetsRack();
+ if (rack && el && el.parentElement === rack) rack.removeChild(el);
+ const side = ensureMainSideRack();
+ if (side && el && !el.parentElement) side.appendChild(el);
+}
+
+function hostPanelInPluginRack(panelId) {
+ const id = String(panelId || "").trim();
+ if (!id) return;
+ if (!rackLayoutEnabled) return;
+ if (!panelIsHostableInPluginRack(id)) {
+ toast("Can't add widget", `${panelTitle(id)} can't be hosted in Plugin Rack.`);
+ return;
+ }
+
+ const rack = ensurePluginRackWidgetsRack();
+ const el = getPanelElement(id);
+ if (!rack || !el) return;
+
+ // Hosting implies it should be visible in the rack, not docked.
+ if (isDocked(id)) undockPanel(id);
+
+ const lastRack = rackIdForPanelElement(el);
+ if (lastRack) rememberPanelLastRack(id, lastRack);
+
+ el.classList.add("pluginRackWidget");
+ if (el.parentElement !== rack) rack.appendChild(el);
+
+ const next = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []);
+ next.add(id);
+ rackLayoutState.pluginRackWidgets = Array.from(next);
+ saveRackLayoutState();
+ syncRackStateFromDom();
+ enforceWorkspaceRules();
+}
+
+function openPluginRackAddMenu(anchorEl) {
+ closePluginRackAddMenu();
+ if (!(anchorEl instanceof HTMLElement)) return;
+ if (!rackLayoutEnabled) return;
+
+ const hosted = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []);
+ const candidates = Array.from(panelRegistry.keys())
+ .filter((id) => panelIsHostableInPluginRack(id) && !hosted.has(id))
+ .sort((a, b) => panelTitle(a).localeCompare(panelTitle(b)));
+
+ const items = candidates
+ .map((id) => `<button type="button" class="ghost smallBtn" data-pluginrackhost="${escapeHtml(id)}">${escapeHtml(panelTitle(id))}</button>`)
+ .join("");
+
+ const menu = document.createElement("div");
+ menu.className = "hotbarAddMenu pluginRackAddMenu";
+ menu.innerHTML = `
+ <div class="small muted" style="padding:6px 8px 4px;">Add widget</div>
+ <div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No plugin widgets available.</div>`}</div>
+ `;
+
+ const rect = anchorEl.getBoundingClientRect();
+ const left = Math.max(12, Math.min(window.innerWidth - 260, rect.left));
+ const top = Math.max(12, Math.min(window.innerHeight - 320, rect.bottom + 8));
+ menu.style.left = `${left}px`;
+ menu.style.top = `${top}px`;
+
+ menu.addEventListener("click", (e) => {
+ const btn = e.target.closest?.("[data-pluginrackhost]");
+ if (!btn) return;
+ const id = String(btn.getAttribute("data-pluginrackhost") || "").trim();
+ if (!id) return;
+ hostPanelInPluginRack(id);
+ closePluginRackAddMenu();
+ });
+
+ document.body.appendChild(menu);
+ pluginRackAddMenuEl = menu;
+}
+
// Rack mode: Profile should behave like a normal dockable panel (not a flow that replaces Hives).
// Override the role after the initial core registration (Map#set will replace the previous entry).
panelRegistry.set("profile", { ...(panelRegistry.get("profile") || { id: "profile", source: "core" }), role: "aux" });
@@ -686,7 +854,7 @@ const PRESET_DEFS = {
sideOrder: ["profile", "composer"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["maps", "library"],
+ dockBottom: ["pluginRack", "maps", "library"],
},
chatFocus: {
presetId: "chatFocus",
@@ -698,7 +866,7 @@ const PRESET_DEFS = {
sideOrder: ["profile"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["hives", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "hives", "composer", "maps", "library"],
},
browse: {
presetId: "browse",
@@ -710,7 +878,7 @@ const PRESET_DEFS = {
sideOrder: ["chat"],
sideCollapsed: true,
rightOrder: ["profile"],
- dockBottom: ["people", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "people", "composer", "maps", "library"],
},
creator: {
presetId: "creator",
@@ -722,7 +890,7 @@ const PRESET_DEFS = {
sideOrder: ["people"],
sideCollapsed: true,
rightOrder: ["profile"],
- dockBottom: ["chat", "maps", "library"],
+ dockBottom: ["pluginRack", "chat", "maps", "library"],
},
mapsSession: {
presetId: "mapsSession",
@@ -733,7 +901,7 @@ const PRESET_DEFS = {
sideOrder: ["hives"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["profile", "composer", "library"],
+ dockBottom: ["pluginRack", "profile", "composer", "library"],
},
quiet: {
presetId: "quiet",
@@ -745,7 +913,7 @@ const PRESET_DEFS = {
sideCollapsed: true,
rightOrder: [],
rightCollapsed: true,
- dockBottom: ["chat", "people", "maps", "library"],
+ dockBottom: ["pluginRack", "chat", "people", "maps", "library"],
},
ops: {
presetId: "ops",
@@ -757,7 +925,7 @@ const PRESET_DEFS = {
sideOrder: ["hives"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["profile", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "profile", "composer", "maps", "library"],
},
reportsFocus: {
presetId: "reportsFocus",
@@ -770,7 +938,7 @@ const PRESET_DEFS = {
sideOrder: ["people"],
sideCollapsed: true,
rightOrder: ["chat"],
- dockBottom: ["hives", "profile", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "hives", "profile", "composer", "maps", "library"],
},
communityWatch: {
presetId: "communityWatch",
@@ -782,7 +950,7 @@ const PRESET_DEFS = {
sideOrder: ["chat"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["profile", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "profile", "composer", "maps", "library"],
},
serverAdmin: {
presetId: "serverAdmin",
@@ -794,7 +962,7 @@ const PRESET_DEFS = {
sideOrder: ["chat"],
sideCollapsed: true,
rightOrder: ["people"],
- dockBottom: ["profile", "composer", "maps", "library"],
+ dockBottom: ["pluginRack", "profile", "composer", "maps", "library"],
},
};
@@ -885,6 +1053,7 @@ function loadRackLayoutState() {
presetId: "discordLike",
docked: { bottom: [] },
racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
+ pluginRackWidgets: [],
lastRackByPanelId: {},
};
const parsed = JSON.parse(raw);
@@ -894,9 +1063,13 @@ function loadRackLayoutState() {
presetId: "discordLike",
docked: { bottom: [] },
racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
+ pluginRackWidgets: [],
lastRackByPanelId: {},
};
const bottom = Array.isArray(parsed?.docked?.bottom) ? parsed.docked.bottom.map((x) => String(x || "")).filter(Boolean) : [];
+ const pluginRackWidgets = Array.isArray(parsed?.pluginRackWidgets)
+ ? parsed.pluginRackWidgets.map((x) => String(x || "")).filter(Boolean)
+ : [];
const presetId = typeof parsed?.presetId === "string" ? parsed.presetId : "discordLike";
const workspaceLeft = Array.isArray(parsed?.racks?.workspaceLeft) ? parsed.racks.workspaceLeft.map((x) => String(x || "")).filter(Boolean) : [];
const workspaceRight = Array.isArray(parsed?.racks?.workspaceRight) ? parsed.racks.workspaceRight.map((x) => String(x || "")).filter(Boolean) : [];
@@ -910,13 +1083,14 @@ function loadRackLayoutState() {
if (!id || !rackId) continue;
lastRackByPanelId[id] = rackId;
}
- return { version: 2, presetId, docked: { bottom }, racks: { workspaceLeft, workspaceRight, side, right }, lastRackByPanelId };
+ return { version: 2, presetId, docked: { bottom }, racks: { workspaceLeft, workspaceRight, side, right }, pluginRackWidgets, lastRackByPanelId };
} catch {
return {
version: 2,
presetId: "discordLike",
docked: { bottom: [] },
racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
+ pluginRackWidgets: [],
lastRackByPanelId: {},
};
}
@@ -1000,12 +1174,12 @@ function panelCanExpand(panelId) {
if (id.startsWith("chat:")) return true;
if (panelRole(id) === "primary") return true;
// Allow a few core panels to take over the workspace even though they aren't "primary" by default.
- return id === "moderation" || id === "composer";
+ return id === "moderation" || id === "composer" || id === "pluginRack";
}
// Panels that are allowed to live in "skinny" columns (side rack / right rack).
// These panels should be able to render in a narrow width without breaking layout.
-const SKINNY_CAPABLE_PANELS = new Set(["people", "profile", "composer", "hives", "chat"]);
+const SKINNY_CAPABLE_PANELS = new Set(["people", "profile", "composer", "hives", "chat", "dice"]);
function panelIsSkinnyCapable(panelId) {
const id = String(panelId || "").trim();
@@ -1046,6 +1220,8 @@ function rememberPanelLastRack(panelId, rackId) {
function dockPanel(panelId) {
const id = String(panelId || "").trim();
if (!id) return;
+ // Docking a hosted widget should implicitly un-host it.
+ removePanelFromPluginRack(id);
const el = getPanelElement(id);
const lastRack = rackIdForPanelElement(el);
if (lastRack) rememberPanelLastRack(id, lastRack);
@@ -1265,6 +1441,8 @@ function readRackOrder(rackEl) {
function applyRackStateToDom() {
if (!rackLayoutEnabled) return;
+ // Ensure core "virtual" panels exist before we try to place them.
+ ensurePluginRackPanel();
const left = ensureWorkspaceLeftRack();
const rightWorkspace = ensureWorkspaceRightRack();
const side = ensureMainSideRack();
@@ -1291,6 +1469,18 @@ function applyRackStateToDom() {
const el = getPanelElement(panelId);
if (el) right.appendChild(el);
}
+
+ // Hosted plugin widgets live inside Plugin Rack, not a top-level rack.
+ const widgetsOrder = Array.isArray(rackLayoutState?.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : [];
+ const widgetsRack = ensurePluginRackWidgetsRack();
+ if (widgetsRack) {
+ for (const panelId of widgetsOrder) {
+ const el = getPanelElement(panelId);
+ if (!el) continue;
+ el.classList.add("pluginRackWidget");
+ widgetsRack.appendChild(el);
+ }
+ }
}
function readWorkspaceActivePrimary() {
@@ -1448,6 +1638,14 @@ function syncRackStateFromDom() {
side: readRackOrder(side),
right: readRackOrder(right),
};
+ rackLayoutState.pluginRackWidgets = readPluginRackWidgetsOrder();
+ const hosted = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []);
+ for (const [id, entry] of panelRegistry.entries()) {
+ const el = entry?.element;
+ if (!(el instanceof HTMLElement)) continue;
+ if (!el.classList.contains("pluginRackWidget") && hosted.has(id)) el.classList.add("pluginRackWidget");
+ if (el.classList.contains("pluginRackWidget") && !hosted.has(id)) el.classList.remove("pluginRackWidget");
+ }
saveRackLayoutState();
}
@@ -1598,6 +1796,11 @@ function applyPreset(presetId) {
return;
}
+ // Presets are hard-applied: clear any hosted widgets so placement remains deterministic.
+ closePluginRackAddMenu();
+ for (const id of readPluginRackWidgetsOrder()) removePanelFromPluginRack(id);
+ rackLayoutState.pluginRackWidgets = [];
+
rackLayoutState.presetId = def.presetId || key;
const workspaceLeftOrder = Array.isArray(def.workspaceLeftOrder) ? def.workspaceLeftOrder.map((x) => String(x || "")).filter(Boolean) : [];
@@ -1744,6 +1947,8 @@ function installPanelMinimizeButtons() {
addMinBtn(hivesPanelEl?.querySelector(".panelHeader"), "hives");
addMinBtn(profileViewPanel?.querySelector(".panelHeader"), "profile");
addMinBtn(pollinatePanel?.querySelector(".panelHeader"), "composer");
+ ensurePluginRackPanel();
+ addMinBtn(pluginRackPanelEl?.querySelector(".panelHeader"), "pluginRack");
}
function ensurePluginPanelShell(panelId, title, icon, defaultRack, role) {
@@ -2147,7 +2352,8 @@ function enableRackDnD() {
const rightWorkspace = ensureWorkspaceRightRack();
const side = ensureMainSideRack();
if (!right || !left || !rightWorkspace || !side) return;
- const racks = [left, rightWorkspace, side, right];
+ const pluginWidgets = ensurePluginRackWidgetsRack();
+ const racks = [left, rightWorkspace, side, right, pluginWidgets].filter((x) => x instanceof HTMLElement);
// Guard against double-install if initRackLayout is called more than once.
if (appRoot?.dataset?.rackDnd === "1") return;
@@ -2245,9 +2451,22 @@ function enableRackDnD() {
const isWorkspaceSlot = targetRack.id === "workspaceLeftSlot" || targetRack.id === "workspaceRightSlot";
const isRightRackSlot = targetRack.id === "rightRack";
const isSideRackSlot = targetRack.id === "mainSideRack";
+ const isPluginRackWidgets = targetRack.id === "pluginRackWidgetsRack";
const isSkinnyRackSlot = isRightRackSlot || isSideRackSlot;
const skinnyOk = panelIsSkinnyCapable(draggingPanelId);
+ if (isPluginRackWidgets && !panelIsHostableInPluginRack(draggingPanelId)) {
+ toast("Can't place there", `${panelTitle(draggingPanelId)} can't be hosted in Plugin Rack.`);
+ if (originRack) {
+ if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(draggingEl, originBefore);
+ else originRack.appendChild(draggingEl);
+ }
+ cleanup();
+ syncRackStateFromDom();
+ enforceWorkspaceRules();
+ return;
+ }
+
// Only skinny-capable panels may live in skinny columns (side / right racks).
if (isSkinnyRackSlot && !skinnyOk) {
toast("Can't place there", `${panelTitle(draggingPanelId)} can't be placed in a skinny rack.`);
@@ -2272,6 +2491,7 @@ function enableRackDnD() {
} else {
targetRack.insertBefore(draggingEl, placeholderEl);
}
+ if (isPluginRackWidgets) draggingEl.classList.add("pluginRackWidget");
}
const shouldDock = Boolean(dockHotbarEl && e.clientY > window.innerHeight - 90);
const dockId = draggingPanelId;
@@ -2393,6 +2613,17 @@ function initRackLayout() {
enableRackLayoutDom();
+ // Ensure Plugin Rack exists and is accessible (defaults to hotbar unless explicitly placed).
+ ensurePluginRackPanel();
+ const pluginRackPlaced =
+ isDocked("pluginRack") ||
+ ["workspaceLeft", "workspaceRight", "side", "right"].some((k) => Array.isArray(rackLayoutState?.racks?.[k]) && rackLayoutState.racks[k].includes("pluginRack"));
+ if (!pluginRackPlaced) {
+ rackLayoutState.docked.bottom = Array.isArray(rackLayoutState?.docked?.bottom) ? rackLayoutState.docked.bottom : [];
+ if (!rackLayoutState.docked.bottom.includes("pluginRack")) rackLayoutState.docked.bottom.push("pluginRack");
+ saveRackLayoutState();
+ }
+
// Side racks behave like summonable hotbars: hide/show without changing panel layout state.
toggleSideRackEl && (toggleSideRackEl.disabled = false);
toggleRightRackEl && (toggleRightRackEl.disabled = false);
@@ -2447,10 +2678,15 @@ function initRackLayout() {
if (appRoot && appRoot.dataset.hotbarPlusClose !== "1") {
appRoot.dataset.hotbarPlusClose = "1";
document.addEventListener("pointerdown", (e) => {
- if (!hotbarPlusMenuEl) return;
+ if (!hotbarPlusMenuEl && !pluginRackAddMenuEl) return;
const t = e.target;
- if (t && (hotbarPlusMenuEl.contains(t) || dockHotbarEl?.contains(t))) return;
+ if (t) {
+ if (hotbarPlusMenuEl && hotbarPlusMenuEl.contains(t)) return;
+ if (pluginRackAddMenuEl && pluginRackAddMenuEl.contains(t)) return;
+ if (dockHotbarEl && dockHotbarEl.contains(t)) return;
+ }
closeHotbarPlusMenu();
+ closePluginRackAddMenu();
});
}
@@ -2482,6 +2718,14 @@ function initRackLayout() {
const resolveOrbDropRack = (panelId, rackEl) => {
const id = String(panelId || "").trim();
if (!id) return rackEl;
+ if (rackEl && rackEl.id === "pluginRackWidgetsRack") {
+ if (panelIsHostableInPluginRack(id)) return rackEl;
+ const left = ensureWorkspaceLeftRack();
+ const right = ensureWorkspaceRightRack();
+ const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
+ const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false;
+ return leftEmpty ? left : rightEmpty ? right : left;
+ }
// Skinny racks (side/right) only allow skinny-capable panels.
if (rackEl && (rackEl.id === "mainSideRack" || rackEl.id === "rightRack")) {
if (panelIsSkinnyCapable(id)) return rackEl;
@@ -2520,7 +2764,8 @@ function initRackLayout() {
const rightWorkspaceRack = ensureWorkspaceRightRack();
const sideRack = ensureMainSideRack();
const rightRack = ensureRightRack();
- return [leftRack, rightWorkspaceRack, sideRack, rightRack].filter((x) => x instanceof HTMLElement);
+ const pluginWidgetsRack = ensurePluginRackWidgetsRack();
+ return [leftRack, rightWorkspaceRack, sideRack, rightRack, pluginWidgetsRack].filter((x) => x instanceof HTMLElement);
};
const rackAtPoint = (x, y) => {
@@ -2570,6 +2815,7 @@ function initRackLayout() {
if (insertBefore) rack.insertBefore(panelEl, insertBefore);
else rack.appendChild(panelEl);
}
+ if (rack.id === "pluginRackWidgetsRack") panelEl.classList.add("pluginRackWidget");
rememberPanelLastRack(id, rack.id);
saveRackLayoutState();
syncRackStateFromDom();
@@ -2688,6 +2934,14 @@ const PEOPLE_WIDTH_DEFAULT = 360;
let editContext = null;
let mentionState = { open: false, query: "", selected: 0, items: [], anchorRect: null };
+const STAY_CONNECTED_KEY = "bzl_stayConnected";
+function readStayConnectedPref() {
+ return readBoolPref(STAY_CONNECTED_KEY, false);
+}
+function writeStayConnectedPref(on) {
+ writeBoolPref(STAY_CONNECTED_KEY, Boolean(on));
+}
+
let instanceBranding = { title: "Bzl", subtitle: "Ephemeral hives + chat", allowMemberPermanentPosts: false, appearance: {} };
let serverInfo = null;
let serverHealth = null;
@@ -6372,6 +6626,26 @@ function openChat(postId) {
// Rack mode: hive chats live in dedicated chat panels (instances). Don't also open the legacy main chat panel.
if (rackLayoutEnabled) {
+ const mainChatPanelIsIdle = Boolean(
+ chatPanelEl &&
+ typeof isDocked === "function" &&
+ !isDocked("chat") &&
+ !activeDmThreadId &&
+ !activeChatPostId &&
+ !isMapChatActive()
+ );
+ if (mainChatPanelIsIdle) {
+ activeChatPostId = postId;
+ markRead(postId);
+ renderFeed();
+ ws.send(JSON.stringify({ type: "getChat", postId }));
+ renderChatPanel(true);
+ renderTypingIndicator();
+ if (isMobileSwipeMode()) setMobilePanel("chat");
+ chatEditor.focus();
+ return;
+ }
+
markRead(postId);
renderFeed();
ws.send(JSON.stringify({ type: "getChat", postId }));
@@ -8280,18 +8554,100 @@ playSfx("open", { volume: 0.34 }).then((ok) => {
if (ok) pendingOpenSfx = false;
});
-setConn("connecting");
-const ws = new WebSocket(wsUrl());
-window.__bzlWs = ws;
-ws.addEventListener("open", () => {
- setConn("open");
- const token = getSessionToken();
- if (token) ws.send(JSON.stringify({ type: "resumeSession", token }));
-});
-ws.addEventListener("close", () => setConn("closed"));
-ws.addEventListener("error", () => setConn("closed"));
+let ws = null;
+let wsKeepaliveTimer = null;
+let wsReconnectTimer = null;
+let wsReconnectAttempt = 0;
+
+function clearWsKeepalive() {
+ if (!wsKeepaliveTimer) return;
+ try {
+ clearInterval(wsKeepaliveTimer);
+ } catch {
+ // ignore
+ }
+ wsKeepaliveTimer = null;
+}
+
+function clearWsReconnect() {
+ if (!wsReconnectTimer) return;
+ try {
+ clearTimeout(wsReconnectTimer);
+ } catch {
+ // ignore
+ }
+ wsReconnectTimer = null;
+}
-ws.addEventListener("message", (evt) => {
+function startWsKeepalive(sock) {
+ clearWsKeepalive();
+ if (!readStayConnectedPref()) return;
+ wsKeepaliveTimer = setInterval(() => {
+ if (!sock || sock !== ws) return;
+ if (sock.readyState !== WebSocket.OPEN) return;
+ try {
+ sock.send(JSON.stringify({ type: "ping" }));
+ } catch {
+ // ignore
+ }
+ }, 25_000);
+}
+
+function scheduleWsReconnect() {
+ clearWsReconnect();
+ if (!readStayConnectedPref()) return;
+ const attempt = Math.min(6, Math.max(0, wsReconnectAttempt));
+ const base = 1000 * Math.pow(2, attempt);
+ const jitter = Math.floor(Math.random() * 250);
+ const delay = Math.min(15_000, base) + jitter;
+ wsReconnectAttempt += 1;
+ setConn("connecting");
+ wsReconnectTimer = setTimeout(() => {
+ wsReconnectTimer = null;
+ connectWs();
+ }, delay);
+}
+
+function connectWs() {
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
+ clearWsKeepalive();
+ setConn("connecting");
+ const sock = new WebSocket(wsUrl());
+ ws = sock;
+ window.__bzlWs = sock;
+
+ sock.addEventListener("open", () => {
+ if (sock !== ws) return;
+ setConn("open");
+ wsReconnectAttempt = 0;
+ clearWsReconnect();
+ startWsKeepalive(sock);
+ const token = getSessionToken();
+ if (token) {
+ try {
+ sock.send(JSON.stringify({ type: "resumeSession", token }));
+ } catch {
+ // ignore
+ }
+ }
+ });
+
+ sock.addEventListener("close", () => {
+ if (sock !== ws) return;
+ setConn("closed");
+ clearWsKeepalive();
+ scheduleWsReconnect();
+ });
+
+ sock.addEventListener("error", () => {
+ if (sock !== ws) return;
+ setConn("closed");
+ });
+
+ sock.addEventListener("message", onWsMessage);
+}
+
+function onWsMessage(evt) {
let msg;
try {
msg = JSON.parse(evt.data);
@@ -9000,10 +9356,27 @@ ws.addEventListener("message", (evt) => {
}
renderChatInstancesForPost(msg.postId);
}
-});
+}
+
+setConn("connecting");
+connectWs();
renderLanHint();
initDisplayPrefsUi();
+if (stayConnectedEl) {
+ stayConnectedEl.checked = readStayConnectedPref();
+ stayConnectedEl.addEventListener("change", () => {
+ const on = Boolean(stayConnectedEl.checked);
+ writeStayConnectedPref(on);
+ if (on) {
+ if (!ws || ws.readyState === WebSocket.CLOSED) connectWs();
+ startWsKeepalive(ws);
+ } else {
+ clearWsReconnect();
+ clearWsKeepalive();
+ }
+ });
+}
renderPeoplePanel();
setPeopleOpen(getPeopleOpen());
composerOpen = getComposerOpen();
diff --git a/public/index.html b/public/index.html
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bzl - Hives</title>
- <link rel="stylesheet" href="/styles.css?v=103" />
+ <link rel="stylesheet" href="/styles.css?v=104" />
</head>
<body>
<div class="app">
@@ -57,6 +57,10 @@
<span>Show reactions bar</span>
<input id="toggleReactions" type="checkbox" />
</label>
+ <label class="checkRow" style="margin-top:8px;">
+ <span>Stay connected</span>
+ <input id="stayConnected" type="checkbox" />
+ </label>
<details style="margin-top:10px;">
<summary class="small muted" style="cursor:pointer;user-select:none;">Advanced display</summary>
@@ -530,6 +534,6 @@
</div>
<div id="dockHotbar" class="dockHotbar hidden" aria-label="Docked panels"></div>
- <script src="/app.js?v=116"></script>
+ <script src="/app.js?v=120"></script>
</body>
</html>
diff --git a/public/styles.css b/public/styles.css
@@ -361,6 +361,35 @@ body {
min-height: 0;
}
+.pluginRackPanel .pluginRackBody {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.pluginRackToolbar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ border-bottom: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
+}
+
+.pluginRackWidgets {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--app-gap);
+ padding: 10px;
+}
+
+.pluginRackWidgets > .rackPanel {
+ flex: 0 0 auto;
+ min-height: 0;
+}
+
.app.rackMode.hasMod {
/* In rack mode, mod is just another panel inside the right rack. */
grid-template-columns: minmax(var(--sidebar-min), var(--sidebar-width)) 10px 1fr 10px minmax(var(--people-min), var(--people-width));
diff --git a/scripts/build-dice-plugin.js b/scripts/build-dice-plugin.js
@@ -0,0 +1,23 @@
+const fs = require("fs");
+const path = require("path");
+const AdmZip = require("adm-zip");
+
+const root = path.resolve(__dirname, "..");
+const pluginDir = path.join(root, "plugins_dev", "dice");
+const distDir = path.join(root, "dist", "plugins");
+const outZip = path.join(distDir, "dice.zip");
+
+function main() {
+ if (!fs.existsSync(pluginDir)) {
+ console.error("Missing plugin folder:", pluginDir);
+ process.exit(1);
+ }
+ fs.mkdirSync(distDir, { recursive: true });
+ const zip = new AdmZip();
+ zip.addLocalFolder(pluginDir, "");
+ zip.writeZip(outZip);
+ console.log("Built:", outZip);
+}
+
+main();
+
diff --git a/scripts/build-radio-plugin.js b/scripts/build-radio-plugin.js
@@ -0,0 +1,23 @@
+const fs = require("fs");
+const path = require("path");
+const AdmZip = require("adm-zip");
+
+const root = path.resolve(__dirname, "..");
+const pluginDir = path.join(root, "plugins_dev", "radio");
+const distDir = path.join(root, "dist", "plugins");
+const outZip = path.join(distDir, "radio.zip");
+
+function main() {
+ if (!fs.existsSync(pluginDir)) {
+ console.error("Missing plugin folder:", pluginDir);
+ process.exit(1);
+ }
+ fs.mkdirSync(distDir, { recursive: true });
+ const zip = new AdmZip();
+ zip.addLocalFolder(pluginDir, "");
+ zip.writeZip(outZip);
+ console.log("Built:", outZip);
+}
+
+main();
+
diff --git a/server.js b/server.js
@@ -4128,6 +4128,15 @@ wss.on("connection", (ws, req) => {
if (!msg || typeof msg !== "object") return;
+ if (msg.type === "ping") {
+ try {
+ ws.send(JSON.stringify({ type: "pong", serverTime: now() }));
+ } catch {
+ // ignore
+ }
+ return;
+ }
+
const msgType = typeof msg.type === "string" ? msg.type : "";
const pluginMatch = msgType.match(/^plugin:([a-z0-9_.-]{1,32}):([a-zA-Z0-9_.-]{1,64})$/);
if (pluginMatch) {