commit 879a6d6a95a9ecd824bc3662432671a7fbdf1979
parent 2f8bddd647459cd0451e2b515b3495bcf61b03de
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date: Thu, 19 Feb 2026 16:17:37 -0700
Hints, better UX, shortcuts!
Mobile UX refactor and rack-mode stabilization: improved screen routing, fixed blank/half-rendered panels, adjusted mobile nav behavior, and reduced mobile controls clutter.
Added Enable hints user setting (persisted via localStorage) and rolled out contextual hints across Account/Hives/Chat/People/Moderation/Onboarding/Profile.
Added keyboard shortcuts:
[ / ] cycles layout presets (ignored while typing).
- / = cycles Hives collections/views when Hives is active.
- / = cycles chat contexts/list entries when Chat is active.
Implemented chat context selector improvements:
Organized DMs + Hive chats.
Includes active chats, recent chats, and open chat-panel chats.
Fixed switching logic and list state handling.
Changed chat-open behavior in rack mode:
Clicking Chat on a hive reuses the nearest visible chat panel instead of spawning new panels.
Removed auto-focus on chat editor when switching chats/DMs.
Rack/layout behavior constraints:
Hives no longer skinny-capable.
Plugin Rack now skinny-capable and right-rack-allowed.
Side rack now capped to max 2 visible stacked panels.
Cleaned skinny-button lifecycle for non-skinny panels.
UI polish/fixes:
Moved sidebar Show button to bottom-left to avoid overlap.
Made dropdown options readable with explicit high-contrast option styling.
Stabilized small control buttons (nowrap) and replaced fragile glyphs with robust symbols.
Mojibake/encoding cleanup across UI strings/icons (panel icons, status text, arrows/ellipsis/bullets, toolbar glyphs, Plugin Rack icon, etc.).
Onboarding work (from earlier pass) integrated:
Onboarding promoted as first-class panel.
Dedicated moderation Onboarding tab.
Structured rules editor + rules migration + &X rule reference behavior.
Release packaging + sync:
Synced updated public/* into CLEAN_INSTALL/public/*.
Rebuilt clean install zip artifact:
Bzl-CLEAN_INSTALL-v0.1.1.zip
Cache/version bumps in index.html (and clean install copy) through latest:
styles.css?v=126
app.js?v=146
Diffstat:
9 files changed, 3466 insertions(+), 228 deletions(-)
diff --git a/CLEAN_INSTALL/public/app.js b/CLEAN_INSTALL/public/app.js
@@ -1,4 +1,4 @@
-const connBadge = document.getElementById("connBadge");
+const connBadge = document.getElementById("connBadge");
const lanHint = document.getElementById("lanHint");
const appRoot = document.querySelector(".app");
@@ -33,6 +33,7 @@ const layoutPresetEl = document.getElementById("layoutPreset");
const uiScaleEl = document.getElementById("uiScale");
const deviceLayoutEl = document.getElementById("deviceLayout");
const stayConnectedEl = document.getElementById("stayConnected");
+const enableHintsEl = document.getElementById("enableHints");
const dockHotbarEl = document.getElementById("dockHotbar");
const showSideRackBtn = document.getElementById("showSideRack");
const showRightRackBtn = document.getElementById("showRightRack");
@@ -40,6 +41,10 @@ const chatModToggleWrapEl = document.getElementById("chatModToggleWrap");
const chatModToggleEl = document.getElementById("chatModToggle");
const authHint = document.getElementById("authHint");
+const onboardingCard = document.getElementById("onboardingCard");
+const onboardingBody = document.getElementById("onboardingBody");
+const onboardingAcceptBtn = document.getElementById("onboardingAccept");
+const onboardingRefreshBtn = document.getElementById("onboardingRefresh");
const userLabel = document.getElementById("userLabel");
const authForm = document.getElementById("authForm");
const authUser = document.getElementById("authUser");
@@ -55,7 +60,7 @@ const removeProfileImageBtn = document.getElementById("removeProfileImage");
const nameColorInput = document.getElementById("nameColor");
const saveProfileBtn = document.getElementById("saveProfile");
const profileStatus = document.getElementById("profileStatus");
-// Instance + plugin admin UI lives in Moderation → Server tab (rendered dynamically).
+// Instance + plugin admin UI lives in Moderation -> Server tab (rendered dynamically).
const modPanelEl = document.getElementById("modPanel");
const modBodyEl = document.getElementById("modBody");
const modRefreshBtn = document.getElementById("modRefresh");
@@ -102,6 +107,10 @@ const mobileSortCycleBtn = document.getElementById("mobileSortCycle");
const clearFilterBtn = document.getElementById("clearFilter");
const feedEl = document.getElementById("feed");
const hiveTabsEl = document.getElementById("hiveTabs");
+const onboardingPanelEl = document.getElementById("onboardingPanel");
+const onboardingPanelBodyEl = document.getElementById("onboardingPanelBody");
+const onboardingPanelAcceptBtn = document.getElementById("onboardingPanelAccept");
+const onboardingPanelRefreshBtn = document.getElementById("onboardingPanelRefresh");
const profileViewPanel = document.getElementById("profileViewPanel");
const profileViewTitle = document.getElementById("profileViewTitle");
const profileViewMeta = document.getElementById("profileViewMeta");
@@ -126,6 +135,7 @@ const profileCancelBtn = document.getElementById("profileCancelBtn");
const chatTitle = document.getElementById("chatTitle");
const chatMeta = document.getElementById("chatMeta");
+const chatContextSelectEl = document.getElementById("chatContextSelect");
const chatBackToListBtn = document.getElementById("chatBackToList");
const chatMessagesEl = document.getElementById("chatMessages");
const typingIndicator = document.getElementById("typingIndicator");
@@ -215,6 +225,19 @@ let windowFocused = true;
let typingStopTimer = null;
let lastTypingSentAt = 0;
let modTab = "reports";
+let onboardingViewerTab = "about";
+let onboardingAdminTab = "about";
+let onboardingAdminDraft = {
+ enabled: true,
+ aboutContent: "",
+ requireAcceptance: false,
+ blockReadUntilAccepted: false,
+ roleSelectEnabled: true,
+ selfAssignableRoleIds: [],
+ rules: [],
+};
+let onboardingAdminDraftStamp = "";
+const onboardingAdminExpandedRuleIds = new Set();
let modReports = [];
let modUsers = [];
let modLog = [];
@@ -260,6 +283,10 @@ let dmThreadsById = new Map();
const dmMessagesByThreadId = new Map();
let activeDmThreadId = null;
let pendingOpenDmThreadId = "";
+const CHAT_RECENTS_LIMIT = 24;
+let recentHiveChatIds = [];
+let recentDmChatThreadIds = [];
+let syncingChatContextSelect = false;
let walkieRecording = false;
let walkieStartAt = 0;
let walkieRecorder = null;
@@ -309,7 +336,7 @@ const WORKSPACE_EXPANDED_DISPLACED_KEY = "bzl_workspace_expandedDisplaced";
/** @type {RackLayoutState} */
let rackLayoutState = {
version: 2,
- presetId: "discordLike",
+ presetId: "onboardingDefault",
docked: { bottom: [] },
racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
};
@@ -458,7 +485,7 @@ function normalizeDeviceLayout(raw) {
function detectViewportSize() {
const w = Math.max(1, Number(window.innerWidth) || 1);
const h = Math.max(1, Number(window.innerHeight) || 1);
- // Keep this intentionally simple: we mostly care about “can we fit columns sanely?”
+ // Keep this intentionally simple: we mostly care about "can we fit columns sanely?"
// Consider both width and height so low-res (ex: 1280x720) can auto-compact.
if (w <= 1100 || h <= 720) return "xs";
if (w <= 1400 || h <= 820) return "sm";
@@ -678,6 +705,7 @@ function togglePanelSkinny(panelId) {
registerCorePanel({ id: "chat", title: "Chat", icon: "💬", role: "primary", defaultRack: "main", element: chatPanelEl });
registerCorePanel({ id: "hives", title: "Hives", icon: "🐝", role: "primary", defaultRack: "main", element: hivesPanelEl });
+registerCorePanel({ id: "onboarding", title: "Onboarding", icon: "🧭", role: "primary", defaultRack: "main", element: onboardingPanelEl });
registerCorePanel({ id: "people", title: "People", icon: "👥", role: "aux", defaultRack: "right", element: peopleDrawerEl });
registerCorePanel({ id: "moderation", title: "Moderation", icon: "🛡️", role: "aux", defaultRack: "right", element: modPanelEl });
registerCorePanel({ id: "profile", title: "Profile", icon: "👤", role: "transient", defaultRack: "main", element: profileViewPanel });
@@ -747,7 +775,7 @@ function ensurePluginRackPanel() {
}
// 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 });
+ 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();
@@ -860,6 +888,17 @@ window.__bzlPanels = { panelRegistry };
const PRESET_DEFS = {
// Presets are hard-applied (exact placement). Anything not explicitly placed starts in the hotbar.
// Workspace uses two full-height primary slots (left + right). No vertical splits.
+ onboardingDefault: {
+ presetId: "onboardingDefault",
+ label: "Onboarding (Default)",
+ group: "user",
+ workspaceLeftOrder: ["onboarding"],
+ workspaceRightOrder: ["hives"],
+ sideOrder: ["chat", "profile", "composer"],
+ sideCollapsed: false,
+ rightOrder: ["people"],
+ dockBottom: ["pluginRack", "maps", "library"],
+ },
social: {
presetId: "social",
label: "Default (Social)",
@@ -984,6 +1023,7 @@ const PRESET_DEFS = {
const PRESET_ALIASES = {
// Back-compat for older preset ids.
discordLike: "social",
+ onboarding: "onboardingDefault",
chat: "chatFocus",
browsing: "browse",
maps: "mapsSession",
@@ -995,12 +1035,12 @@ const PRESET_ALIASES = {
function resolvePresetKey(presetId) {
const raw = String(presetId || "").trim();
const mapped = Object.prototype.hasOwnProperty.call(PRESET_ALIASES, raw) ? PRESET_ALIASES[raw] : raw;
- return Object.prototype.hasOwnProperty.call(PRESET_DEFS, mapped) ? mapped : "social";
+ return Object.prototype.hasOwnProperty.call(PRESET_DEFS, mapped) ? mapped : "onboardingDefault";
}
function updateLayoutPresetOptions() {
if (!layoutPresetEl) return;
- const current = resolvePresetKey(rackLayoutState?.presetId || layoutPresetEl.value || "social");
+ const current = resolvePresetKey(rackLayoutState?.presetId || layoutPresetEl.value || "onboardingDefault");
const defs = Object.values(PRESET_DEFS).filter((d) => d && typeof d === "object");
const userDefs = defs.filter((d) => d.group === "user");
@@ -1027,8 +1067,8 @@ function updateLayoutPresetOptions() {
layoutPresetEl.appendChild(modGroup);
}
- const nextValue = canModerate ? current : (PRESET_DEFS[current]?.modOnly ? "social" : current);
- layoutPresetEl.value = Object.prototype.hasOwnProperty.call(PRESET_DEFS, nextValue) ? nextValue : "social";
+ const nextValue = canModerate ? current : (PRESET_DEFS[current]?.modOnly ? "onboardingDefault" : current);
+ layoutPresetEl.value = Object.prototype.hasOwnProperty.call(PRESET_DEFS, nextValue) ? nextValue : "onboardingDefault";
}
function readRackLayoutEnabled() {
@@ -1065,7 +1105,7 @@ function loadRackLayoutState() {
if (!raw)
return {
version: 2,
- presetId: "discordLike",
+ presetId: "onboardingDefault",
docked: { bottom: [] },
racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
pluginRackWidgets: [],
@@ -1075,7 +1115,7 @@ function loadRackLayoutState() {
if (!parsed || parsed.version !== 2)
return {
version: 2,
- presetId: "discordLike",
+ presetId: "onboardingDefault",
docked: { bottom: [] },
racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
pluginRackWidgets: [],
@@ -1085,7 +1125,7 @@ function loadRackLayoutState() {
const pluginRackWidgets = Array.isArray(parsed?.pluginRackWidgets)
? parsed.pluginRackWidgets.map((x) => String(x || "")).filter(Boolean)
: [];
- const presetId = typeof parsed?.presetId === "string" ? parsed.presetId : "discordLike";
+ const presetId = typeof parsed?.presetId === "string" ? parsed.presetId : "onboardingDefault";
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) : [];
const side = Array.isArray(parsed?.racks?.side) ? parsed.racks.side.map((x) => String(x || "")).filter(Boolean) : [];
@@ -1102,7 +1142,7 @@ function loadRackLayoutState() {
} catch {
return {
version: 2,
- presetId: "discordLike",
+ presetId: "onboardingDefault",
docked: { bottom: [] },
racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
pluginRackWidgets: [],
@@ -1194,7 +1234,7 @@ function panelCanExpand(panelId) {
// 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", "dice"]);
+const SKINNY_CAPABLE_PANELS = new Set(["people", "profile", "composer", "chat", "pluginRack", "dice"]);
function panelIsSkinnyCapable(panelId) {
const id = String(panelId || "").trim();
@@ -1359,7 +1399,7 @@ function renderHotbar() {
const plusHtml = includePlus
? `
<button type="button" class="dockOrb dockOrbPlus" data-hotbarplus="1" title="Add panel">
- <span class="dockOrbIcon" aria-hidden="true">+</span>
+ <span class="dockOrbIcon" aria-hidden="true">+</span>
<span>Add</span>
</button>
`
@@ -1401,7 +1441,7 @@ function openHotbarPlusMenu(anchorEl) {
const menu = document.createElement("div");
menu.className = "hotbarAddMenu";
menu.innerHTML = `
- <div class="small muted" style="padding:6px 8px 4px;">New chat panel for…</div>
+ <div class="small muted" style="padding:6px 8px 4px;">New chat panel for...</div>
<div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No hives yet.</div>`}</div>
`;
@@ -1546,6 +1586,15 @@ function enforceWorkspaceRules() {
enforceSkinny(side);
enforceSkinny(rightRack);
+ // Side rack can stack, but keep it compact: at most 2 visible panels.
+ const sideKids = Array.from(side.querySelectorAll(":scope > .rackPanel:not(.hidden)"));
+ if (sideKids.length > 2) {
+ for (const extra of sideKids.slice(2)) {
+ const id = String(extra?.dataset?.panelId || "").trim();
+ if (id) dockPanel(id);
+ }
+ }
+
// Right rack is single-slot: keep at most one visible panel.
const rightKids = Array.from(rightRack.querySelectorAll(":scope > .rackPanel:not(.hidden)"));
if (rightKids.length > 1) {
@@ -1764,6 +1813,11 @@ function enableRackLayoutDom() {
// Mark center panels as rack panels too (they already live in mainRack in normal DOM).
if (main) {
+ if (onboardingPanelEl) {
+ mark(onboardingPanelEl, "onboarding");
+ if (left && onboardingPanelEl.parentElement !== left) left.appendChild(onboardingPanelEl);
+ onboardingPanelEl.classList.remove("hidden");
+ }
if (hivesPanelEl) {
mark(hivesPanelEl, "hives");
if (left && hivesPanelEl.parentElement !== left) left.appendChild(hivesPanelEl);
@@ -1807,7 +1861,7 @@ function applyPreset(presetId) {
const def = PRESET_DEFS[key];
if (!def) return;
if (def.modOnly && !canModerate) {
- applyPreset("social");
+ applyPreset("onboardingDefault");
return;
}
@@ -1916,7 +1970,7 @@ function installPanelMinimizeButtons() {
const drag = document.createElement("button");
drag.type = "button";
drag.className = "ghost smallBtn rackDragHandle";
- drag.textContent = "☰";
+ drag.textContent = "≡";
drag.title = "Drag to reorder";
drag.setAttribute("data-rackdrag", panelId);
row.appendChild(drag);
@@ -1926,18 +1980,21 @@ function installPanelMinimizeButtons() {
const skinny = document.createElement("button");
skinny.type = "button";
skinny.className = "ghost smallBtn";
- skinny.textContent = "][";
+ skinny.textContent = "↔";
skinny.title = "Toggle skinny/full";
skinny.setAttribute("data-skinny", panelId);
skinny.onclick = () => togglePanelSkinny(panelId);
row.appendChild(skinny);
}
+ if (!panelIsSkinnyCapable(panelId)) {
+ headerEl.querySelector(`[data-skinny="${cssEscape(panelId)}"]`)?.remove();
+ }
if (panelCanExpand(panelId) && !headerEl.querySelector(`[data-expand="${panelId}"]`)) {
const expand = document.createElement("button");
expand.type = "button";
expand.className = "ghost smallBtn";
- expand.textContent = "[]";
+ expand.textContent = "□";
expand.title = "Expand workspace";
expand.setAttribute("data-expand", panelId);
expand.onclick = () => togglePrimaryExpand(panelId);
@@ -1948,7 +2005,7 @@ function installPanelMinimizeButtons() {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "ghost smallBtn";
- btn.textContent = "—";
+ btn.textContent = "-";
btn.title = "Minimize to hotbar";
btn.setAttribute("data-minimize", panelId);
btn.onclick = () => dockPanel(panelId);
@@ -1999,8 +2056,8 @@ function ensurePluginPanelShell(panelId, title, icon, defaultRack, role) {
<div class="panelHeader">
<div class="panelTitle">${escapeHtml(title || panelId)}</div>
<div class="row">
- <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">☰</button>
- <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">—</button>
+ <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">≡</button>
+ <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">-</button>
</div>
</div>
<div class="panelBody" data-pluginmount="1"></div>
@@ -2013,7 +2070,7 @@ function ensurePluginPanelShell(panelId, title, icon, defaultRack, role) {
const expand = document.createElement("button");
expand.type = "button";
expand.className = "ghost smallBtn";
- expand.textContent = "[]";
+ expand.textContent = "□";
expand.title = "Expand workspace";
expand.setAttribute("data-expand", panelId);
expand.addEventListener("click", () => togglePrimaryExpand(panelId));
@@ -2048,10 +2105,10 @@ function ensureChatPostPanelInstance(postId, opts) {
<div class="small muted chatMeta"></div>
</div>
<div class="row">
- <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">☰</button>
- <button type="button" class="ghost smallBtn" data-skinny="${escapeHtml(panelId)}" title="Toggle skinny/full">][</button>
- <button type="button" class="ghost smallBtn" data-expand="${escapeHtml(panelId)}" title="Expand workspace">[]</button>
- <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">—</button>
+ <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">≡</button>
+ <button type="button" class="ghost smallBtn" data-skinny="${escapeHtml(panelId)}" title="Toggle skinny/full">↔</button>
+ <button type="button" class="ghost smallBtn" data-expand="${escapeHtml(panelId)}" title="Expand workspace">□</button>
+ <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">-</button>
</div>
</div>
<div class="chatMessages"></div>
@@ -2196,7 +2253,7 @@ function renderTypingIndicatorForPost(postId, targetEl) {
}
const names = Array.from(set.values()).slice(0, 3);
const more = set.size > names.length ? ` +${set.size - names.length}` : "";
- targetEl.textContent = `${names.map((u) => `@${u}`).join(", ")}${more} typing…`;
+ targetEl.textContent = `${names.map((u) => `@${u}`).join(", ")}${more} typing...`;
}
function renderChatPostPanelInstance(panelId, forceScroll) {
@@ -2341,6 +2398,48 @@ function renderChatInstancesForPost(postId) {
}
}
+function setChatInstancePanelPost(panelId, postId, forceScroll = true) {
+ const pid = String(postId || "").trim();
+ const id = String(panelId || "").trim();
+ if (!pid || !id) return false;
+ const inst = chatPanelInstances.get(id);
+ if (!inst) return false;
+ const post = posts.get(pid);
+ if (!post) return false;
+ inst.postId = pid;
+ chatPanelInstances.set(id, inst);
+ const root = getPanelElement(id);
+ const titleEl = root?.querySelector?.(".panelTitle");
+ if (titleEl) titleEl.textContent = post?.title ? `Chat: ${String(post.title).slice(0, 32)}` : "Chat";
+ renderChatPostPanelInstance(id, forceScroll);
+ return true;
+}
+
+function nearestVisibleChatInstancePanelId(sourceEl) {
+ const anchor = sourceEl instanceof HTMLElement ? sourceEl : null;
+ if (!anchor) return "";
+ const anchorRect = anchor.getBoundingClientRect();
+ const ax = anchorRect.left + anchorRect.width / 2;
+ const ay = anchorRect.top + anchorRect.height / 2;
+ let bestId = "";
+ let bestDist = Number.POSITIVE_INFINITY;
+ for (const [panelId] of chatPanelInstances.entries()) {
+ const root = getPanelElement(panelId);
+ if (!(root instanceof HTMLElement)) continue;
+ if (root.classList.contains("hidden")) continue;
+ const rect = root.getBoundingClientRect();
+ if (rect.width <= 1 || rect.height <= 1) continue;
+ const cx = rect.left + rect.width / 2;
+ const cy = rect.top + rect.height / 2;
+ const dist = Math.hypot(cx - ax, cy - ay);
+ if (dist < bestDist) {
+ bestDist = dist;
+ bestId = panelId;
+ }
+ }
+ return bestId;
+}
+
function applyPluginPresetHint(panelDef) {
if (!rackLayoutEnabled) return;
const id = String(panelDef?.id || "").trim();
@@ -2605,11 +2704,11 @@ function initRackLayout() {
if (layoutPresetEl) {
updateLayoutPresetOptions();
- layoutPresetEl.value = resolvePresetKey(rackLayoutState.presetId || "social");
+ layoutPresetEl.value = resolvePresetKey(rackLayoutState.presetId || "onboardingDefault");
layoutPresetEl.disabled = !rackLayoutEnabled;
layoutPresetEl.onchange = () => {
if (!rackLayoutEnabled) return;
- const next = String(layoutPresetEl.value || "social");
+ const next = String(layoutPresetEl.value || "onboardingDefault");
applyPreset(next);
};
}
@@ -2669,6 +2768,15 @@ function initRackLayout() {
setRightCollapsed(readBoolPref(RACK_RIGHT_COLLAPSED_KEY, false), { persist: false });
applyRackStateToDom();
+ const hasOnboardingPlacement =
+ (Array.isArray(rackLayoutState?.racks?.workspaceLeft) && rackLayoutState.racks.workspaceLeft.includes("onboarding")) ||
+ (Array.isArray(rackLayoutState?.racks?.workspaceRight) && rackLayoutState.racks.workspaceRight.includes("onboarding")) ||
+ (Array.isArray(rackLayoutState?.racks?.side) && rackLayoutState.racks.side.includes("onboarding")) ||
+ (Array.isArray(rackLayoutState?.racks?.right) && rackLayoutState.racks.right.includes("onboarding")) ||
+ (Array.isArray(rackLayoutState?.docked?.bottom) && rackLayoutState.docked.bottom.includes("onboarding"));
+ if ((rackLayoutState?.presetId || "") === "onboardingDefault" && !hasOnboardingPlacement) {
+ applyPreset("onboardingDefault");
+ }
installPanelMinimizeButtons();
enableRackDnD();
installWorkspaceInteractions();
@@ -2914,7 +3022,7 @@ function initRackLayout() {
// First enable: seed state from the selected preset so users immediately get a sensible layout.
if (!hadState) {
- const preset = resolvePresetKey(rackLayoutState.presetId || (layoutPresetEl ? String(layoutPresetEl.value || "") : "") || "social");
+ const preset = resolvePresetKey(rackLayoutState.presetId || (layoutPresetEl ? String(layoutPresetEl.value || "") : "") || "onboardingDefault");
applyPreset(preset);
}
@@ -2956,8 +3064,31 @@ function readStayConnectedPref() {
function writeStayConnectedPref(on) {
writeBoolPref(STAY_CONNECTED_KEY, Boolean(on));
}
+const ENABLE_HINTS_KEY = "bzl_enableHints";
+function readHintsEnabledPref() {
+ const raw = localStorage.getItem(ENABLE_HINTS_KEY);
+ if (raw == null) return true;
+ return raw !== "0";
+}
+function writeHintsEnabledPref(on) {
+ const enabled = Boolean(on);
+ localStorage.setItem(ENABLE_HINTS_KEY, enabled ? "1" : "0");
+ appRoot?.classList.toggle("hintsEnabled", enabled);
+}
let instanceBranding = { title: "Bzl", subtitle: "Ephemeral hives + chat", allowMemberPermanentPosts: false, appearance: {} };
+let onboardingState = {
+ enabled: true,
+ rulesVersion: 1,
+ requireAcceptance: false,
+ blockReadUntilAccepted: false,
+ acceptedRulesVersion: 0,
+ acceptedAt: 0,
+ tutorialVersion: 1,
+ tutorialCompletedVersion: 0,
+ selectedRoleIds: [],
+ needsAcceptance: false,
+};
let serverInfo = null;
let serverHealth = null;
let serverInfoStatus = { loading: false, at: 0, error: "" };
@@ -3155,14 +3286,79 @@ function normalizeInstanceBranding(raw) {
const mutedPct = clampPct(appearanceRaw.mutedPct, 65);
const linePct = clampPct(appearanceRaw.linePct, 10);
const panel2Pct = clampPct(appearanceRaw.panel2Pct, 2);
+ const onboardingRaw = raw?.onboarding && typeof raw.onboarding === "object" ? raw.onboarding : {};
+ const aboutRaw = onboardingRaw.about && typeof onboardingRaw.about === "object" ? onboardingRaw.about : {};
+ const rulesRaw = onboardingRaw.rules && typeof onboardingRaw.rules === "object" ? onboardingRaw.rules : {};
+ const roleSelectRaw = onboardingRaw.roleSelect && typeof onboardingRaw.roleSelect === "object" ? onboardingRaw.roleSelect : {};
+ const tutorialRaw = onboardingRaw.tutorial && typeof onboardingRaw.tutorial === "object" ? onboardingRaw.tutorial : {};
+ const ruleItems = Array.isArray(rulesRaw.items)
+ ? rulesRaw.items
+ .map((r, idx) => ({
+ id: String(r?.id || `r${idx + 1}`).trim().slice(0, 40),
+ order: Number.isFinite(Number(r?.order)) ? Math.max(1, Math.floor(Number(r.order))) : idx + 1,
+ name: String(r?.name || "").trim().slice(0, 60),
+ shortDescription: String(r?.shortDescription || "").trim().slice(0, 180),
+ description: typeof r?.description === "string" ? r.description : "",
+ severity: ["info", "warn", "critical"].includes(String(r?.severity || "").trim().toLowerCase())
+ ? String(r.severity).trim().toLowerCase()
+ : "info",
+ }))
+ .filter((r) => r.id)
+ .slice(0, 200)
+ .sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || String(a.id || "").localeCompare(String(b.id || "")))
+ : [];
return {
title: title || "Bzl",
subtitle: subtitle || "Ephemeral hives + chat",
allowMemberPermanentPosts,
+ onboarding: {
+ enabled: Object.prototype.hasOwnProperty.call(onboardingRaw, "enabled") ? Boolean(onboardingRaw.enabled) : true,
+ about: {
+ content: typeof aboutRaw.content === "string" ? aboutRaw.content : "",
+ updatedAt: Number(aboutRaw.updatedAt || 0) || 0,
+ updatedBy: String(aboutRaw.updatedBy || "").trim().toLowerCase(),
+ },
+ rules: {
+ version: Math.max(1, Math.floor(Number(rulesRaw.version || 1))),
+ requireAcceptance: Boolean(rulesRaw.requireAcceptance),
+ blockReadUntilAccepted: Boolean(rulesRaw.blockReadUntilAccepted),
+ items: ruleItems,
+ },
+ roleSelect: {
+ enabled: Object.prototype.hasOwnProperty.call(roleSelectRaw, "enabled") ? Boolean(roleSelectRaw.enabled) : true,
+ selfAssignableRoleIds: Array.isArray(roleSelectRaw.selfAssignableRoleIds)
+ ? roleSelectRaw.selfAssignableRoleIds.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean).slice(0, 64)
+ : [],
+ },
+ tutorial: {
+ enabled: Object.prototype.hasOwnProperty.call(tutorialRaw, "enabled") ? Boolean(tutorialRaw.enabled) : true,
+ version: Math.max(1, Math.floor(Number(tutorialRaw.version || 1))),
+ },
+ },
appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct },
};
}
+function normalizeOnboardingState(raw) {
+ const src = raw && typeof raw === "object" ? raw : {};
+ return {
+ enabled: Object.prototype.hasOwnProperty.call(src, "enabled") ? Boolean(src.enabled) : Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.enabled),
+ rulesVersion: Math.max(1, Math.floor(Number(src.rulesVersion || normalizeInstanceBranding(instanceBranding).onboarding?.rules?.version || 1))),
+ requireAcceptance: Object.prototype.hasOwnProperty.call(src, "requireAcceptance")
+ ? Boolean(src.requireAcceptance)
+ : Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.rules?.requireAcceptance),
+ blockReadUntilAccepted: Object.prototype.hasOwnProperty.call(src, "blockReadUntilAccepted")
+ ? Boolean(src.blockReadUntilAccepted)
+ : Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.rules?.blockReadUntilAccepted),
+ acceptedRulesVersion: Math.max(0, Math.floor(Number(src.acceptedRulesVersion || 0))),
+ acceptedAt: Number(src.acceptedAt || 0) || 0,
+ tutorialVersion: Math.max(1, Math.floor(Number(src.tutorialVersion || normalizeInstanceBranding(instanceBranding).onboarding?.tutorial?.version || 1))),
+ tutorialCompletedVersion: Math.max(0, Math.floor(Number(src.tutorialCompletedVersion || 0))),
+ selectedRoleIds: Array.isArray(src.selectedRoleIds) ? src.selectedRoleIds.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean).slice(0, 64) : [],
+ needsAcceptance: Boolean(src.needsAcceptance),
+ };
+}
+
function applyInstanceAppearance(appearanceOverride = null) {
const b = normalizeInstanceBranding(appearanceOverride ? { ...instanceBranding, appearance: appearanceOverride } : instanceBranding);
const a = b.appearance || {};
@@ -3250,6 +3446,7 @@ function normalizeDmMessage(raw) {
return {
id,
fromUser: String(raw.fromUser || raw.from || "").trim().toLowerCase(),
+ asMod: Boolean(raw.asMod) || String(raw.fromUser || raw.from || "").trim().toLowerCase() === "mod",
createdAt: Number(raw.createdAt || 0),
text: typeof raw.text === "string" ? raw.text : "",
html: typeof raw.html === "string" ? raw.html : "",
@@ -3261,6 +3458,91 @@ function dmActivityAt(thread) {
return Math.max(Number(thread.lastMessageAt || 0), Number(thread.updatedAt || 0), Number(thread.createdAt || 0));
}
+function pushRecentUnique(list, id, limit = CHAT_RECENTS_LIMIT) {
+ const value = String(id || "").trim();
+ if (!value) return list;
+ const next = [value, ...list.filter((x) => x !== value)];
+ if (next.length > limit) next.length = limit;
+ return next;
+}
+
+function touchRecentHiveChat(postId) {
+ const id = String(postId || "").trim();
+ if (!id) return;
+ recentHiveChatIds = pushRecentUnique(recentHiveChatIds, id);
+}
+
+function touchRecentDmChat(threadId) {
+ const id = String(threadId || "").trim();
+ if (!id) return;
+ recentDmChatThreadIds = pushRecentUnique(recentDmChatThreadIds, id);
+}
+
+function activeDmThreadsSorted() {
+ return dmThreads
+ .filter((t) => t && String(t.status || "") === "active")
+ .sort((a, b) => dmActivityAt(b) - dmActivityAt(a));
+}
+
+function renderChatContextSelect() {
+ if (!(chatContextSelectEl instanceof HTMLSelectElement)) return;
+ const dmThreadsActive = activeDmThreadsSorted();
+ const dmById = new Map(dmThreadsActive.map((t) => [t.id, t]));
+ recentDmChatThreadIds = recentDmChatThreadIds.filter((id) => dmById.has(id));
+ const dmRecent = [activeDmThreadId, ...recentDmChatThreadIds]
+ .map((id) => dmById.get(String(id || "")))
+ .filter(Boolean)
+ .filter((t, i, arr) => arr.findIndex((x) => x.id === t.id) === i);
+
+ const postsById = new Map(Array.from(posts.values()).map((p) => [String(p.id), p]));
+ const openPanelPostIds = Array.from(chatPanelInstances.values())
+ .map((inst) => String(inst?.postId || "").trim())
+ .filter(Boolean);
+ recentHiveChatIds = recentHiveChatIds.filter((id) => {
+ const p = postsById.get(String(id));
+ return Boolean(p && !p.deleted);
+ });
+ const postRecent = [activeChatPostId, ...openPanelPostIds, ...recentHiveChatIds]
+ .map((id) => postsById.get(String(id || "")))
+ .filter((p) => p && !p.deleted)
+ .filter((p, i, arr) => arr.findIndex((x) => String(x.id) === String(p.id)) === i);
+
+ const hasAny = Boolean(dmRecent.length || postRecent.length || activeDmThreadId || activeChatPostId);
+ if (!hasAny) {
+ chatContextSelectEl.classList.add("hidden");
+ chatContextSelectEl.innerHTML = "";
+ return;
+ }
+
+ const activeDmValue = activeDmThreadId ? `dm:${activeDmThreadId}` : "";
+ const activePostValue = activeChatPostId ? `post:${activeChatPostId}` : "";
+ const selected = activeDmValue || activePostValue || "";
+
+ const dmOptions = dmRecent
+ .map((t) => {
+ const other = `@${escapeHtml(t.other || "unknown")}`;
+ return `<option value="dm:${escapeHtml(t.id)}">${other}</option>`;
+ })
+ .join("");
+
+ const postOptions = postRecent
+ .map((p) => {
+ const label = `${escapeHtml(postTitle(p))}${p.author ? ` - @${escapeHtml(String(p.author || ""))}` : ""}`;
+ return `<option value="post:${escapeHtml(String(p.id))}">${label}</option>`;
+ })
+ .join("");
+
+ const topPlaceholder = `<option value="">Open chats...</option>`;
+ const dmGroup = dmOptions ? `<optgroup label="DMs">${dmOptions}</optgroup>` : "";
+ const postGroup = postOptions ? `<optgroup label="Hive Chats">${postOptions}</optgroup>` : "";
+
+ syncingChatContextSelect = true;
+ chatContextSelectEl.classList.remove("hidden");
+ chatContextSelectEl.innerHTML = `${topPlaceholder}${dmGroup}${postGroup}`;
+ chatContextSelectEl.value = selected && chatContextSelectEl.querySelector(`option[value="${cssEscape(selected)}"]`) ? selected : "";
+ syncingChatContextSelect = false;
+}
+
function setDmThreads(list) {
dmThreads = Array.isArray(list) ? list.map(normalizeDmThread).filter(Boolean) : [];
dmThreadsById = new Map(dmThreads.map((t) => [t.id, t]));
@@ -4133,7 +4415,9 @@ function isMobileScreenMode() {
function loadMobileLayout() {
const defaults = () => {
const pinned = ["account", "hives", "chat", "people", "profile"];
- return { version: 1, pinned, active: pinned[0] || "account", history: [], tools: { composerOpen: false, profileOpen: false, pluginRackOpen: false } };
+ const onboardingEnabled = Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.enabled);
+ const active = onboardingEnabled ? "onboarding" : pinned[0] || "account";
+ return { version: 1, pinned, active, history: [], tools: { composerOpen: false, profileOpen: false, pluginRackOpen: false } };
};
const sanitizeId = (id) => {
const raw = String(id || "")
@@ -4144,7 +4428,7 @@ function loadMobileLayout() {
if (raw === "mod") return canModerate ? "moderation" : "";
if (raw === "sidebar") return "account";
if (raw === "main" || raw === "workspace") return "hives";
- if (raw === "account" || raw === "hives" || raw === "chat" || raw === "people" || raw === "profile") return raw;
+ if (raw === "account" || raw === "hives" || raw === "chat" || raw === "people" || raw === "profile" || raw === "onboarding") return raw;
if (raw === "moderation") return canModerate ? "moderation" : "";
if (panelRegistry.has(raw)) return raw;
return "";
@@ -4177,6 +4461,7 @@ function saveMobileLayout(layout) {
function availableMobileScreens() {
const out = [];
out.push({ id: "account", title: "Account", core: true });
+ if (Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.enabled)) out.push({ id: "onboarding", title: "Onboarding", core: true });
out.push({ id: "hives", title: "Hives", core: true });
out.push({ id: "chat", title: "Chat", core: true });
out.push({ id: "people", title: "People", core: true });
@@ -4212,8 +4497,9 @@ function mobileScreenFromLegacyPanel(next) {
if (raw === "chat") return "chat";
if (raw === "people") return "people";
if (raw === "profile") return "profile";
+ if (raw === "onboarding") return "onboarding";
if (raw === "moderation" || raw === "mod") return canModerate ? "moderation" : "hives";
- if (raw === "hives" || raw === "account" || raw === "people" || raw === "profile" || raw === "moderation") return raw;
+ if (raw === "hives" || raw === "account" || raw === "people" || raw === "profile" || raw === "onboarding" || raw === "moderation") return raw;
// Plugin panel id can be treated as a screen.
if (panelRegistry.has(raw)) return raw;
return "hives";
@@ -4394,6 +4680,10 @@ function hostHivesInMobileScreen() {
function setMobileScreen(screenId, { pushHistory = true } = {}) {
if (!appRoot) return;
const screen = mobileScreenFromLegacyPanel(screenId);
+ if (onboardingNeedsAcceptanceNow() && screen !== "onboarding" && screen !== "account") {
+ setMobileScreen("onboarding", { pushHistory: false });
+ return;
+ }
const nextIsMore = screen === "more";
if (nextIsMore) {
setMobileMoreOpen(true);
@@ -4447,6 +4737,12 @@ function setMobileScreen(screenId, { pushHistory = true } = {}) {
return;
}
+ if (screen === "onboarding") {
+ const hosted = hostPanelInMobileScreen("onboarding");
+ appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives");
+ return;
+ }
+
if (screen === "hives") {
const hosted = hostHivesInMobileScreen();
appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives");
@@ -4496,7 +4792,7 @@ function applyMobileMode() {
const current = String(appRoot.getAttribute("data-mobile-screen") || "").trim();
if (!wasMobile || !current) {
const layout = loadMobileLayout();
- const desired = mobileScreenFromLegacyPanel(layout.active || "hives");
+ const desired = onboardingNeedsAcceptanceNow() ? "onboarding" : mobileScreenFromLegacyPanel(layout.active || "hives");
setMobileScreen(desired, { pushHistory: false });
}
renderMobileNav();
@@ -4514,8 +4810,8 @@ function applyMobileMode() {
function shiftMobilePanel(delta) {
if (!isMobileScreenMode()) return;
const order = canModerate
- ? ["account", "hives", "chat", "people", "profile", "moderation"]
- : ["account", "hives", "chat", "people", "profile"];
+ ? ["account", "onboarding", "hives", "chat", "people", "profile", "moderation"]
+ : ["account", "onboarding", "hives", "chat", "people", "profile"];
const current = mobileScreenFromLegacyPanel(appRoot?.getAttribute("data-mobile-screen") || "hives");
const idx = order.indexOf(current);
const at = idx >= 0 ? idx : 0;
@@ -5227,7 +5523,7 @@ function isOwnerUser() {
function renderPluginsAdminHtml() {
if (!isOwnerUser()) return `<div class="muted small">Owner only.</div>`;
const status = pluginAdminStatus ? `<div class="small muted">${escapeHtml(pluginAdminStatus)}</div>` : "";
- const busyLine = pluginAdminBusy ? `<div class="small muted">Working…</div>` : "";
+ const busyLine = pluginAdminBusy ? `<div class="small muted">Working...</div>` : "";
const listHtml = !plugins.length
? `<div class="muted small">No plugins installed yet.</div>`
: plugins
@@ -5688,7 +5984,7 @@ function renderFeed() {
);
if (list.length === 0) {
- feedEl.innerHTML = `<div class="small muted">No active posts in this view/filter.</div>`;
+ feedEl.innerHTML = `<div class="small muted">No active posts in this view/filter.</div><div class="uiHint">Tap <b>New Hive</b> to create one, or clear filters to widen results.</div>`;
return;
}
@@ -5802,24 +6098,265 @@ function isMobileChatScreenActive() {
}
function renderMobileChatListHtml() {
- const list = sortPosts(Array.from(posts.values()))
- .filter((p) => p && !p.deleted)
+ const dmActive = activeDmThreadsSorted().slice(0, 30);
+ const recentPostIds = recentHiveChatIds.slice(0, 24);
+ const recentPosts = recentPostIds.map((id) => posts.get(id)).filter((p) => p && !p.deleted);
+ const recentPostIdSet = new Set(recentPosts.map((p) => String(p.id)));
+ const availablePosts = sortPosts(Array.from(posts.values()))
+ .filter((p) => p && !p.deleted && !recentPostIdSet.has(String(p.id)))
.slice(0, 60);
- if (!list.length) {
- return `<div class="small muted">No active hives available for chat.</div>`;
+
+ if (!dmActive.length && !recentPosts.length && !availablePosts.length) {
+ return `<div class="small muted">No active hives available for chat.</div><div class="uiHint">Create a hive in Hives first, then return here to chat.</div>`;
}
- return `<div class="mobileChatList">${list
- .map((p) => {
- const title = escapeHtml(postTitle(p));
- const author = p.author ? `@${escapeHtml(String(p.author || ""))}` : "anon";
- const exp = formatCountdown(p.expiresAt);
- const lock = p.locked ? " · locked" : "";
- return `<button type="button" class="ghost mobileChatListItem" data-mobilechatopen="${escapeHtml(p.id)}">
- <span class="mobileChatListTop">${title}</span>
- <span class="mobileChatListMeta">${author} · ${escapeHtml(exp)}${lock}</span>
- </button>`;
- })
- .join("")}</div>`;
+
+ const dmSection = dmActive.length
+ ? `<div class="mobileChatSection">
+ <div class="small muted">DMs</div>
+ ${dmActive
+ .map((t) => {
+ const who = `@${escapeHtml(String(t.other || "unknown"))}`;
+ const when = dmActivityAt(t) ? new Date(dmActivityAt(t)).toLocaleTimeString() : "active";
+ return `<button type="button" class="ghost mobileChatListItem" data-dmopen="${escapeHtml(t.id)}">
+ <span class="mobileChatListTop">${who}</span>
+ <span class="mobileChatListMeta">private chat · ${escapeHtml(when)}</span>
+ </button>`;
+ })
+ .join("")}
+ </div>`
+ : "";
+
+ const postItem = (p) => {
+ const title = escapeHtml(postTitle(p));
+ const author = p.author ? `@${escapeHtml(String(p.author || ""))}` : "anon";
+ const exp = formatCountdown(p.expiresAt);
+ const lock = p.locked ? " · locked" : "";
+ return `<button type="button" class="ghost mobileChatListItem" data-mobilechatopen="${escapeHtml(p.id)}">
+ <span class="mobileChatListTop">${title}</span>
+ <span class="mobileChatListMeta">${author} · ${escapeHtml(exp)}${lock}</span>
+ </button>`;
+ };
+
+ const recentSection = recentPosts.length
+ ? `<div class="mobileChatSection">
+ <div class="small muted">Recent Hive Chats</div>
+ ${recentPosts.map(postItem).join("")}
+ </div>`
+ : "";
+
+ const hivesSection = availablePosts.length
+ ? `<div class="mobileChatSection">
+ <div class="small muted">Available Hives</div>
+ ${availablePosts.map(postItem).join("")}
+ </div>`
+ : "";
+
+ return `<div class="mobileChatList">${dmSection}${recentSection}${hivesSection}</div>`;
+}
+
+function onboardingRequiresAcceptance() {
+ return Boolean(onboardingState.enabled && onboardingState.requireAcceptance);
+}
+
+function onboardingNeedsAcceptanceNow() {
+ if (!onboardingRequiresAcceptance()) return false;
+ return Boolean(onboardingState.needsAcceptance || Number(onboardingState.acceptedRulesVersion || 0) < Number(onboardingState.rulesVersion || 1));
+}
+
+function onboardingSeverityLabel(severity) {
+ const s = String(severity || "").toLowerCase();
+ if (s === "critical") return "Critical";
+ if (s === "warn") return "Warn";
+ return "Info";
+}
+
+function onboardingSeverityBadge(severity) {
+ const s = String(severity || "info").toLowerCase();
+ const cls = s === "critical" ? "onbSeverityCritical" : s === "warn" ? "onbSeverityWarn" : "onbSeverityInfo";
+ return `<span class="tag ${cls}">${escapeHtml(onboardingSeverityLabel(s))}</span>`;
+}
+
+function onboardingRuleListFromConfig(cfg) {
+ const list = Array.isArray(cfg?.rules?.items) ? cfg.rules.items : [];
+ return list
+ .map((r, index) => ({
+ id: String(r?.id || `r${index + 1}`).trim().slice(0, 40) || `r${index + 1}`,
+ order: Number.isFinite(Number(r?.order)) ? Math.max(1, Math.floor(Number(r.order))) : index + 1,
+ name: String(r?.name || "").trim().slice(0, 60) || `Rule ${index + 1}`,
+ shortDescription: String(r?.shortDescription || "").trim().slice(0, 180),
+ description: String(r?.description || "").slice(0, 6000),
+ severity: ["info", "warn", "critical"].includes(String(r?.severity || "").toLowerCase())
+ ? String(r.severity).toLowerCase()
+ : "info",
+ }))
+ .sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || String(a.id || "").localeCompare(String(b.id || "")));
+}
+
+function onboardingDraftStampFromConfig(cfg) {
+ return JSON.stringify({
+ enabled: Boolean(cfg?.enabled),
+ aboutUpdatedAt: Number(cfg?.about?.updatedAt || 0),
+ rulesVersion: Number(cfg?.rules?.version || 1),
+ itemCount: Array.isArray(cfg?.rules?.items) ? cfg.rules.items.length : 0,
+ roleSelectEnabled: Boolean(cfg?.roleSelect?.enabled),
+ selfAssignableCount: Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds) ? cfg.roleSelect.selfAssignableRoleIds.length : 0,
+ });
+}
+
+function syncOnboardingAdminDraft(force = false) {
+ const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {};
+ const stamp = onboardingDraftStampFromConfig(cfg);
+ if (!force && stamp === onboardingAdminDraftStamp) return;
+ onboardingAdminDraft = {
+ enabled: Boolean(cfg?.enabled),
+ aboutContent: String(cfg?.about?.content || ""),
+ requireAcceptance: Boolean(cfg?.rules?.requireAcceptance),
+ blockReadUntilAccepted: Boolean(cfg?.rules?.blockReadUntilAccepted),
+ roleSelectEnabled: Boolean(cfg?.roleSelect?.enabled),
+ selfAssignableRoleIds: Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds)
+ ? cfg.roleSelect.selfAssignableRoleIds.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean)
+ : [],
+ rules: onboardingRuleListFromConfig(cfg),
+ };
+ onboardingAdminDraftStamp = stamp;
+ onboardingAdminExpandedRuleIds.clear();
+ if (onboardingAdminDraft.rules[0]?.id) onboardingAdminExpandedRuleIds.add(onboardingAdminDraft.rules[0].id);
+}
+
+function normalizeOnboardingDraftRules() {
+ onboardingAdminDraft.rules = (Array.isArray(onboardingAdminDraft.rules) ? onboardingAdminDraft.rules : [])
+ .map((r, index) => ({
+ id: String(r?.id || `r${index + 1}`).trim().slice(0, 40) || `r${index + 1}`,
+ order: index + 1,
+ name: String(r?.name || "").trim().slice(0, 60) || `Rule ${index + 1}`,
+ shortDescription: String(r?.shortDescription || "").trim().slice(0, 180),
+ description: String(r?.description || "").slice(0, 6000),
+ severity: ["info", "warn", "critical"].includes(String(r?.severity || "").toLowerCase())
+ ? String(r.severity).toLowerCase()
+ : "info",
+ }))
+ .slice(0, 200);
+}
+
+function renderOnboardingPanel() {
+ if (!(onboardingPanelEl instanceof HTMLElement) || !(onboardingPanelBodyEl instanceof HTMLElement)) return;
+ const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {};
+ if (!cfg.enabled) {
+ onboardingPanelEl.classList.add("hidden");
+ onboardingPanelBodyEl.innerHTML = `<div class="small muted">Onboarding is disabled for this server.</div>`;
+ if (onboardingPanelAcceptBtn instanceof HTMLButtonElement) onboardingPanelAcceptBtn.classList.add("hidden");
+ return;
+ }
+
+ onboardingPanelEl.classList.remove("hidden");
+ const needs = onboardingNeedsAcceptanceNow();
+ const rules = onboardingRuleListFromConfig(cfg);
+ const about = typeof cfg?.about?.content === "string" ? cfg.about.content.trim() : "";
+ const roleIds = Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds) ? cfg.roleSelect.selfAssignableRoleIds : [];
+ const roleItems = roleIds
+ .map((key) => customRoles.find((r) => String(r?.key || "") === String(key)))
+ .filter(Boolean)
+ .map((r) => `<span class="tag">${escapeHtml(String(r.label || r.key || ""))}</span>`)
+ .join(" ");
+
+ onboardingPanelBodyEl.innerHTML = `
+ <div class="onbTabs">
+ <button type="button" class="${onboardingViewerTab === "about" ? "primary" : "ghost"} smallBtn" data-onbtab="about">About</button>
+ <button type="button" class="${onboardingViewerTab === "rules" ? "primary" : "ghost"} smallBtn" data-onbtab="rules">Rules</button>
+ <button type="button" class="${onboardingViewerTab === "roles" ? "primary" : "ghost"} smallBtn" data-onbtab="roles">Roles</button>
+ </div>
+ ${
+ onboardingViewerTab === "about"
+ ? about
+ ? `<div class="onboardingAbout">${about}</div>`
+ : `<div class="small muted">No About content published yet.</div>`
+ : onboardingViewerTab === "rules"
+ ? rules.length
+ ? `<div class="onbRuleList">${rules
+ .map(
+ (r) => `<article class="onbRuleViewerCard">
+ <div class="row" style="justify-content:space-between;align-items:center;">
+ <b>${escapeHtml(r.name || "Rule")}</b>
+ ${onboardingSeverityBadge(r.severity)}
+ </div>
+ ${r.shortDescription ? `<div class="small muted">${escapeHtml(r.shortDescription)}</div>` : ""}
+ ${r.description ? `<div class="small">${r.description}</div>` : ""}
+ </article>`
+ )
+ .join("")}</div>`
+ : `<div class="small muted">No rules configured.</div>`
+ : cfg?.roleSelect?.enabled
+ ? roleItems
+ ? `<div class="row" style="flex-wrap:wrap;gap:8px;">${roleItems}</div>`
+ : `<div class="small muted">No self-assignable roles configured.</div>`
+ : `<div class="small muted">Role select is disabled.</div>`
+ }
+ <div class="small ${needs ? "badText" : "goodText"}" style="margin-top:10px;">
+ ${
+ onboardingRequiresAcceptance()
+ ? needs
+ ? "Rules acceptance required before posting/chat."
+ : `Rules accepted${onboardingState.acceptedAt ? ` at ${escapeHtml(formatLocalTime(onboardingState.acceptedAt))}` : "."}`
+ : "Rules acceptance is optional on this server."
+ }
+ </div>`;
+
+ if (onboardingPanelAcceptBtn instanceof HTMLButtonElement) {
+ onboardingPanelAcceptBtn.classList.toggle("hidden", !onboardingRequiresAcceptance());
+ onboardingPanelAcceptBtn.disabled = !loggedInUser || !needs;
+ onboardingPanelAcceptBtn.textContent = needs ? "Accept and continue" : "Accepted";
+ }
+}
+
+function renderOnboardingCard() {
+ if (!(onboardingCard instanceof HTMLElement) || !(onboardingBody instanceof HTMLElement)) return;
+ // Onboarding now lives as a first-class workspace panel; keep the old account card hidden.
+ onboardingCard.classList.add("hidden");
+ onboardingBody.innerHTML = "";
+ if (onboardingAcceptBtn instanceof HTMLButtonElement) {
+ onboardingAcceptBtn.classList.add("hidden");
+ onboardingAcceptBtn.disabled = true;
+ }
+ renderOnboardingPanel();
+ return;
+
+ const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {};
+ if (!cfg.enabled) {
+ onboardingCard.classList.add("hidden");
+ onboardingBody.innerHTML = "";
+ return;
+ }
+ onboardingCard.classList.remove("hidden");
+ const needs = onboardingNeedsAcceptanceNow();
+ const rules = onboardingRuleListFromConfig(cfg).slice(0, 6);
+ const about = typeof cfg?.about?.content === "string" ? cfg.about.content.trim() : "";
+ const aboutBlock = about ? `<div class="onboardingAbout">${about}</div>` : `<div class="small muted">No About text set yet.</div>`;
+ const rulesBlock = rules.length
+ ? `<ol class="onboardingRules">${rules
+ .map(
+ (r) =>
+ `<li><b>${escapeHtml(r.name || "Rule")}</b>${r.shortDescription ? `<div class="small muted">${escapeHtml(r.shortDescription)}</div>` : ""}</li>`
+ )
+ .join("")}</ol>`
+ : `<div class="small muted">No rules published yet.</div>`;
+ onboardingBody.innerHTML = `
+ ${aboutBlock}
+ <div class="small" style="margin-top:10px;"><b>Rules</b></div>
+ ${rulesBlock}
+ ${
+ onboardingRequiresAcceptance()
+ ? `<div class="small ${needs ? "badText" : "goodText"}" style="margin-top:10px;">
+ ${needs ? "Rules acceptance required before posting/chat." : `Rules accepted${onboardingState.acceptedAt ? ` at ${escapeHtml(formatLocalTime(onboardingState.acceptedAt))}` : "."}`}
+ </div>`
+ : `<div class="small muted" style="margin-top:10px;">Rules acceptance is optional on this server.</div>`
+ }
+ `;
+ if (onboardingAcceptBtn instanceof HTMLButtonElement) {
+ onboardingAcceptBtn.classList.toggle("hidden", !onboardingRequiresAcceptance());
+ onboardingAcceptBtn.disabled = !loggedInUser || !needs;
+ onboardingAcceptBtn.textContent = needs ? "Accept and continue" : "Accepted";
+ }
+ renderOnboardingPanel();
}
function setAuthUi() {
@@ -5827,7 +6364,9 @@ function setAuthUi() {
userLabel.innerHTML = renderUserPill(loggedInUser);
logoutBtn.classList.remove("hidden");
const roleText = loggedInRole && loggedInRole !== "member" ? ` (${loggedInRole})` : "";
- authHint.textContent = `Signed in${roleText}. You can post, chat, and boost others.`;
+ authHint.textContent = onboardingNeedsAcceptanceNow()
+ ? `Signed in${roleText}. Accept server rules to unlock posting/chat.`
+ : `Signed in${roleText}. You can post, chat, and boost others.`;
} else {
userLabel.textContent = "Signed out";
logoutBtn.classList.add("hidden");
@@ -5849,6 +6388,7 @@ function setAuthUi() {
codeRow.classList.toggle("hidden", !registrationEnabled);
registerBtn.classList.toggle("hidden", !(registrationEnabled || canRegisterFirstUser));
+ renderOnboardingCard();
renderModPanel();
}
@@ -5879,7 +6419,7 @@ function renderPeoplePanel() {
if (!membersTabOn) {
if (!peopleDmsViewEl) return;
if (!loggedInUser) {
- peopleDmsViewEl.innerHTML = `<div class="muted">Sign in to use DMs.</div>`;
+ peopleDmsViewEl.innerHTML = `<div class="muted">Sign in to use DMs.</div><div class="uiHint">After signing in, open a DM request and accept it to start chatting.</div>`;
return;
}
@@ -5895,7 +6435,7 @@ function renderPeoplePanel() {
eligibleMembers.length > 0
? `<div class="dmNewRow">
<select class="dmToSelect" data-dmto="1">
- <option value="">New DM…</option>
+ <option value="">New DM...</option>
${eligibleMembers.map((u) => `<option value="${escapeHtml(u)}">@${escapeHtml(u)}</option>`).join("")}
</select>
<button type="button" class="primary" data-dmrequestfromselect="1">Request</button>
@@ -5934,7 +6474,7 @@ function renderPeoplePanel() {
? `<button type="button" class="primary smallBtn" data-dmopen="${escapeHtml(t.id)}">Open</button>`
: status === "declined"
? `<button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(other)}">Request again</button>`
- : `<span class="muted small">Waiting…</span>`;
+ : `<span class="muted small">Waiting...</span>`;
if (isBlocked) {
actions =
@@ -5974,7 +6514,7 @@ function renderPeoplePanel() {
.sort((a, b) => Number(Boolean(b.online)) - Number(Boolean(a.online)) || String(a.username).localeCompare(String(b.username)));
if (!list.length) {
- peopleListEl.innerHTML = `<div class="muted">No members found.</div>`;
+ peopleListEl.innerHTML = `<div class="muted">No members found.</div><div class="uiHint">Try clearing the search filter or check back when more members are online.</div>`;
return;
}
peopleListEl.innerHTML = list
@@ -6103,7 +6643,7 @@ function renderModPanel() {
const updatedAt = serverInfoStatus.at ? formatLocalTime(serverInfoStatus.at) : "";
const statusLine = loading
- ? `<span class="muted">Loading…</span>`
+ ? `<span class="muted">Loading...</span>`
: err
? `<span class="bad">${escapeHtml(err)}</span>`
: updatedAt
@@ -6142,7 +6682,7 @@ function renderModPanel() {
<label style="flex:1">
<span>Theme preset</span>
<select data-theme-preset>
- <option value="">(choose…)</option>
+ <option value="">(choose...)</option>
${THEME_PRESETS.map((p) => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)}</option>`).join("")}
</select>
</label>
@@ -6289,6 +6829,128 @@ function renderModPanel() {
return;
}
+ if (modTab === "onboarding") {
+ const isOwner = loggedInRole === "owner";
+ const canEdit = loggedInRole === "owner" || loggedInRole === "moderator";
+ syncOnboardingAdminDraft(false);
+ normalizeOnboardingDraftRules();
+ const roleOptions = customRoles
+ .map(
+ (r) =>
+ `<label class="checkRow">
+ <span>${escapeHtml(String(r.label || r.key || ""))}</span>
+ <input type="checkbox" data-onboarding-rolecheck="${escapeHtml(String(r.key || ""))}" ${
+ onboardingAdminDraft.selfAssignableRoleIds.includes(String(r.key || "")) ? "checked" : ""
+ } />
+ </label>`
+ )
+ .join("");
+ const rulesCards = onboardingAdminDraft.rules.length
+ ? onboardingAdminDraft.rules
+ .map((r, idx) => {
+ const expanded = onboardingAdminExpandedRuleIds.has(r.id);
+ return `<article class="onbRuleEditorCard" data-onb-ruleid="${escapeHtml(r.id)}">
+ <div class="row" style="justify-content:space-between;align-items:center;">
+ <button type="button" class="ghost smallBtn" data-onb-ruletoggle="${escapeHtml(r.id)}">${expanded ? "▾" : "▸"} Rule ${idx + 1}</button>
+ <div class="row" style="gap:6px;">
+ <button type="button" class="ghost smallBtn" data-onb-ruleup="${escapeHtml(r.id)}" ${idx <= 0 ? "disabled" : ""}>↑</button>
+ <button type="button" class="ghost smallBtn" data-onb-ruledown="${escapeHtml(r.id)}" ${
+ idx >= onboardingAdminDraft.rules.length - 1 ? "disabled" : ""
+ }>↓</button>
+ <button type="button" class="ghost smallBtn" data-onb-ruledelete="${escapeHtml(r.id)}">Delete</button>
+ </div>
+ </div>
+ ${
+ expanded
+ ? `<div class="onbRuleEditorBody">
+ <label><span>Name</span><input data-onb-rulefield="name" data-onb-ruleid="${escapeHtml(r.id)}" value="${escapeHtml(
+ r.name
+ )}" maxlength="60" /></label>
+ <label><span>Short description</span><input data-onb-rulefield="shortDescription" data-onb-ruleid="${escapeHtml(
+ r.id
+ )}" value="${escapeHtml(r.shortDescription)}" maxlength="180" /></label>
+ <label><span>Full description</span><textarea data-onb-rulefield="description" data-onb-ruleid="${escapeHtml(
+ r.id
+ )}" rows="4">${escapeHtml(r.description)}</textarea></label>
+ <label><span>Severity</span>
+ <select data-onb-rulefield="severity" data-onb-ruleid="${escapeHtml(r.id)}">
+ <option value="info" ${r.severity === "info" ? "selected" : ""}>Info</option>
+ <option value="warn" ${r.severity === "warn" ? "selected" : ""}>Warn</option>
+ <option value="critical" ${r.severity === "critical" ? "selected" : ""}>Critical</option>
+ </select>
+ </label>
+ </div>`
+ : ""
+ }
+ </article>`;
+ })
+ .join("")
+ : `<div class="small muted">No rules yet. Add your first rule.</div>`;
+
+ modBodyEl.innerHTML = `
+ <div class="modCard">
+ <div class="modRowTop"><div><b>Onboarding</b></div></div>
+ <div class="small muted">Configure About, Rules, and Role Select.</div>
+ <div class="onbTabs" style="margin-top:8px;">
+ <button type="button" class="${onboardingAdminTab === "about" ? "primary" : "ghost"} smallBtn" data-onb-admin-tab="about">About</button>
+ <button type="button" class="${onboardingAdminTab === "rules" ? "primary" : "ghost"} smallBtn" data-onb-admin-tab="rules">Rules</button>
+ <button type="button" class="${onboardingAdminTab === "roles" ? "primary" : "ghost"} smallBtn" data-onb-admin-tab="roles">Roles</button>
+ </div>
+ </div>
+ <div class="modCard">
+ ${
+ onboardingAdminTab === "about"
+ ? `<label class="checkRow">
+ <span>Enable onboarding panel</span>
+ <input type="checkbox" data-onboarding-enabled ${onboardingAdminDraft.enabled ? "checked" : ""} ${canEdit ? "" : "disabled"} />
+ </label>
+ <label>
+ <span>About (rich text allowed)</span>
+ <textarea data-onboarding-about rows="10" ${canEdit ? "" : "disabled"}>${escapeHtml(onboardingAdminDraft.aboutContent)}</textarea>
+ </label>
+ <div class="small muted">Updated by: ${escapeHtml(String(normalizeInstanceBranding(instanceBranding).onboarding?.about?.updatedBy || "n/a"))}</div>
+ <div class="small muted">Updated at: ${escapeHtml(
+ formatLocalTime(normalizeInstanceBranding(instanceBranding).onboarding?.about?.updatedAt || 0) || "n/a"
+ )}</div>`
+ : onboardingAdminTab === "rules"
+ ? `<label class="checkRow">
+ <span>Require rules acceptance before posting/chat</span>
+ <input type="checkbox" data-onboarding-require ${onboardingAdminDraft.requireAcceptance ? "checked" : ""} ${
+ canEdit ? "" : "disabled"
+ } />
+ </label>
+ <label class="checkRow">
+ <span>Block reading hives until accepted ${isOwner ? "" : "(owner only)"}</span>
+ <input type="checkbox" data-onboarding-blockread ${onboardingAdminDraft.blockReadUntilAccepted ? "checked" : ""} ${
+ canEdit && isOwner ? "" : "disabled"
+ } />
+ </label>
+ <div class="row" style="justify-content:space-between;align-items:center;margin:8px 0;">
+ <div><b>Rules</b></div>
+ <button type="button" class="primary smallBtn" data-onb-ruleadd="1" ${canEdit ? "" : "disabled"}>+ Add Rule</button>
+ </div>
+ <div class="onbRuleEditorList">${rulesCards}</div>`
+ : `<label class="checkRow">
+ <span>Enable custom role select in onboarding</span>
+ <input type="checkbox" data-onboarding-roleenabled ${onboardingAdminDraft.roleSelectEnabled ? "checked" : ""} ${
+ canEdit ? "" : "disabled"
+ } />
+ </label>
+ <div class="small muted">Choose self-assignable roles:</div>
+ <div class="onbRoleGrid">${roleOptions || `<div class="small muted">No custom roles defined.</div>`}</div>`
+ }
+ </div>
+ <div class="modCard">
+ <div class="row" style="gap:8px;">
+ <button type="button" class="primary" data-onboarding-save="1" ${canEdit ? "" : "disabled"}>Save</button>
+ <button type="button" class="ghost" data-onboarding-publish="1" ${canEdit ? "" : "disabled"}>Publish</button>
+ <button type="button" class="ghost" data-onboarding-refresh="1">Reload</button>
+ </div>
+ </div>
+ `;
+ return;
+ }
+
if (modTab === "users") {
const roleList = customRoles.length
? customRoles
@@ -6408,7 +7070,7 @@ function renderModPanel() {
: "public";
return `<span class="tag">/${escapeHtml(c.name)}</span>${
c.id !== "general"
- ? `<button type="button" data-collectiongate="${escapeHtml(c.id)}">Gate…</button>
+ ? `<button type="button" data-collectiongate="${escapeHtml(c.id)}">Gate...</button>
<button type="button" data-collectionpublic="${escapeHtml(c.id)}">Make public</button>`
: ""
}
@@ -6456,7 +7118,7 @@ function renderModPanel() {
)}" data-ttl="1440">TTL 1d</button>
<button type="button" data-modaction="post_ttl_set" data-targettype="post" data-targetid="${escapeHtml(
p.id
- )}" data-ttlprompt="1">Set TTL…</button>
+ )}" data-ttlprompt="1">Set TTL...</button>
${
p.readOnly
? `<button type="button" data-modaction="post_readonly_set" data-targettype="post" data-targetid="${escapeHtml(
@@ -6473,10 +7135,10 @@ function renderModPanel() {
)}" data-unprotect="1">Unprotect</button>
<button type="button" data-modaction="post_protection_set" data-targettype="post" data-targetid="${escapeHtml(
p.id
- )}" data-protect="1">Change password…</button>`
+ )}" data-protect="1">Change password...</button>`
: `<button type="button" data-modaction="post_protection_set" data-targettype="post" data-targetid="${escapeHtml(
p.id
- )}" data-protect="1">Protect…</button>`
+ )}" data-protect="1">Protect...</button>`
}
<button type="button" data-modaction="message_purge_recent" data-targettype="post" data-targetid="${escapeHtml(
p.id
@@ -6677,6 +7339,7 @@ function pushMapChatMessage(mapId, scope, message) {
function renderChatPanel(forceScroll = false) {
updateChatModToggleVisibility();
+ renderChatContextSelect();
const mobileChatScreen = isMobileChatScreenActive();
const mediaState = captureMediaState(chatMessagesEl);
if (activeDmThreadId) {
@@ -6709,7 +7372,7 @@ function renderChatPanel(forceScroll = false) {
</div>`
: status === "declined"
? `<button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(thread.other)}">Request again</button>`
- : `<div class="muted">Waiting for @${escapeHtml(thread.other)}…</div>`;
+ : `<div class="muted">Waiting for @${escapeHtml(thread.other)}...</div>`;
chatMessagesEl.innerHTML = `<div class="small muted">${promptHtml}</div>`;
restoreMediaState(chatMessagesEl, mediaState);
setReplyToMessage(null);
@@ -6720,14 +7383,15 @@ function renderChatPanel(forceScroll = false) {
.map((m, index) => {
const from = m.fromUser || "";
const isYou = loggedInUser && from && from === loggedInUser;
+ const isModMsg = Boolean(m?.asMod) || String(from || "").toLowerCase() === "mod";
const rail = chatRailClass({
fromUser: from,
- isModMessage: Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod"
+ isModMessage: isModMsg
});
const prev = index > 0 ? messages[index - 1] : null;
const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from);
- const who = renderUserPill(from || "");
- const youTag = isYou ? `<span class="muted">(you)</span>` : "";
+ const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || "");
+ const youTag = isModMsg ? "" : isYou ? `<span class="muted">(you)</span>` : "";
const time = new Date(m.createdAt).toLocaleTimeString();
const tint = tintStylesFromHex(getProfile(from).color);
const html = typeof m.html === "string" && m.html.trim() ? m.html : "";
@@ -6830,7 +7494,7 @@ function renderChatPanel(forceScroll = false) {
if (chatPanelEl) chatPanelEl.classList.remove("walkie");
if (walkieBarEl) walkieBarEl.classList.add("hidden");
if (chatForm) chatForm.classList.remove("hidden");
- chatMessagesEl.innerHTML = `<div class="small muted">No chat selected.</div>`;
+ chatMessagesEl.innerHTML = `<div class="small muted">No chat selected.</div><div class="uiHint">Open a hive and press <b>Chat</b>, or use People -> DMs to open a private thread.</div>`;
restoreMediaState(chatMessagesEl, mediaState);
setReplyToMessage(null);
return;
@@ -7167,6 +7831,7 @@ function openDmThread(threadId) {
if (activeChatPostId) ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
activeChatPostId = null;
activeDmThreadId = id;
+ touchRecentDmChat(id);
setReplyToMessage(null);
ws.send(JSON.stringify({ type: "dmHistory", threadId: id }));
renderChatPanel(true);
@@ -7174,16 +7839,18 @@ function openDmThread(threadId) {
setMobileScreen("chat");
renderMobileNav();
}
- chatEditor?.focus();
}
-function openChat(postId) {
+function openChat(postId, opts = null) {
activeDmThreadId = null;
stopWalkieRecording();
+ const options = opts && typeof opts === "object" ? opts : {};
+ const sourceEl = options.sourceEl instanceof HTMLElement ? options.sourceEl : null;
const post = posts.get(postId);
if (!post) return;
if (post.deleted) {
activeChatPostId = postId;
+ touchRecentHiveChat(postId);
renderChatPanel(true);
if (isMobileSwipeMode()) setMobilePanel("chat");
return;
@@ -7193,47 +7860,42 @@ function openChat(postId) {
return;
}
- // Rack mode: hive chats live in dedicated chat panels (instances). Don't also open the legacy main chat panel.
+ // Rack mode: switch the nearest visible chat panel when possible; otherwise use main chat.
if (rackLayoutEnabled) {
- const mainChatPanelIsIdle = Boolean(
- chatPanelEl &&
- typeof isDocked === "function" &&
- !isDocked("chat") &&
- !activeDmThreadId &&
- !activeChatPostId &&
- !isMapChatActive()
- );
- if (mainChatPanelIsIdle) {
+ const nearestInstanceId = nearestVisibleChatInstancePanelId(sourceEl);
+ if (nearestInstanceId) {
+ touchRecentHiveChat(postId);
+ markRead(postId);
+ renderFeed();
+ ws.send(JSON.stringify({ type: "getChat", postId }));
+ setChatInstancePanelPost(nearestInstanceId, postId, true);
+ renderChatContextSelect();
+ return;
+ }
+ if (chatPanelEl && typeof isDocked === "function" && !isDocked("chat")) {
activeChatPostId = postId;
+ touchRecentHiveChat(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 }));
- ensureChatPostPanelInstance(postId, { docked: false });
- return;
}
if (activeChatPostId && activeChatPostId !== postId) {
ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
setReplyToMessage(null);
}
activeChatPostId = postId;
+ touchRecentHiveChat(postId);
markRead(postId);
renderFeed();
ws.send(JSON.stringify({ type: "getChat", postId }));
renderChatPanel(true);
renderTypingIndicator();
if (isMobileSwipeMode()) setMobilePanel("chat");
- chatEditor.focus();
-
}
let pendingOpenChatAfterUnlock = null;
@@ -7513,6 +8175,114 @@ function shouldHandleWalkieHotkey(evt) {
return true;
}
+function isTextEntryFocused() {
+ const el = document.activeElement;
+ if (!el) return false;
+ const tag = String(el.tagName || "").toLowerCase();
+ if (tag === "textarea") return true;
+ if (tag === "input") {
+ const type = String(el.getAttribute?.("type") || "text").toLowerCase();
+ return !["button", "checkbox", "color", "file", "hidden", "radio", "range", "reset", "submit"].includes(type);
+ }
+ return Boolean(el.isContentEditable);
+}
+
+function cycleLayoutPresetBy(step) {
+ if (!layoutPresetEl || !rackLayoutEnabled || layoutPresetEl.disabled) return;
+ const options = Array.from(layoutPresetEl.options || [])
+ .map((opt) => String(opt.value || "").trim())
+ .filter((v) => v);
+ if (!options.length) return;
+ const current = resolvePresetKey(String(layoutPresetEl.value || rackLayoutState?.presetId || "onboardingDefault"));
+ let idx = options.indexOf(current);
+ if (idx < 0) idx = 0;
+ const len = options.length;
+ const next = options[(idx + step + len) % len];
+ if (!next) return;
+ layoutPresetEl.value = next;
+ applyPreset(next);
+}
+
+let hotkeyPanelContext = "";
+function updateHotkeyPanelContextFromTarget(target) {
+ const el = target instanceof HTMLElement ? target : null;
+ if (!el) return;
+ if (el.closest("#hivesPanel")) {
+ hotkeyPanelContext = "hives";
+ return;
+ }
+ if (el.closest("aside.chat") || el.closest(".chatInstance") || el.closest("[data-panel-id^='chat:post:']")) {
+ hotkeyPanelContext = "chat";
+ }
+}
+
+function activePanelContextForHotkeys() {
+ if (isMobileScreenMode() && appRoot) {
+ const mobile = String(appRoot.getAttribute("data-mobile-screen") || "").trim();
+ if (mobile === "hives") return "hives";
+ if (mobile === "chat" || (mobile === "host" && mobileHostPanelId === "chat")) return "chat";
+ }
+ const ae = document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ if (ae) {
+ if (ae.closest("#hivesPanel")) return "hives";
+ if (ae.closest("aside.chat") || ae.closest(".chatInstance") || ae.closest("[data-panel-id^='chat:post:']")) return "chat";
+ }
+ return hotkeyPanelContext || "";
+}
+
+function cycleHiveViewBy(step) {
+ if (!hiveTabsEl) return false;
+ const views = Array.from(hiveTabsEl.querySelectorAll("button[data-hiveview]:not([disabled])"))
+ .map((b) => String(b.getAttribute("data-hiveview") || "").trim())
+ .filter(Boolean);
+ if (!views.length) return false;
+ let idx = views.indexOf(String(activeHiveView || "all"));
+ if (idx < 0) idx = 0;
+ const len = views.length;
+ const next = views[(idx + step + len) % len];
+ if (!next || next === activeHiveView) return false;
+ activeHiveView = next;
+ renderFeed();
+ return true;
+}
+
+function cycleChatContextBy(step) {
+ renderChatContextSelect();
+ if (!(chatContextSelectEl instanceof HTMLSelectElement)) return false;
+ const items = [
+ "__list__",
+ ...Array.from(chatContextSelectEl.options || [])
+ .map((o) => String(o.value || "").trim())
+ .filter((v) => v && (v.startsWith("dm:") || v.startsWith("post:"))),
+ ];
+ if (items.length <= 1) return false;
+ const current = activeDmThreadId ? `dm:${activeDmThreadId}` : activeChatPostId ? `post:${activeChatPostId}` : "__list__";
+ let idx = items.indexOf(current);
+ if (idx < 0) idx = 0;
+ const len = items.length;
+ const next = items[(idx + step + len) % len];
+ if (!next || next === current) return false;
+ if (next === "__list__") {
+ if (activeChatPostId && ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
+ activeChatPostId = null;
+ activeDmThreadId = null;
+ activeMapsRoomId = "";
+ activeMapsRoomTitle = "";
+ setReplyToMessage(null);
+ renderChatPanel(true);
+ return true;
+ }
+ if (next.startsWith("dm:")) {
+ openDmThread(next.slice(3));
+ return true;
+ }
+ if (next.startsWith("post:")) {
+ openChat(next.slice(5));
+ return true;
+ }
+ return false;
+}
+
function canWalkieTalkNow() {
if (!loggedInUser || !ws || ws.readyState !== WebSocket.OPEN) return false;
if (!activeChatPostId) return false;
@@ -7525,7 +8295,7 @@ async function startWalkieRecording() {
if (walkieRecording) return;
if (!canWalkieTalkNow()) return;
try {
- if (walkieStatusEl) walkieStatusEl.textContent = "Requesting microphone…";
+ if (walkieStatusEl) walkieStatusEl.textContent = "Requesting microphone...";
const { ctx, mix, dest } = await ensureWalkieGraph();
if (ctx.state === "suspended") await ctx.resume();
@@ -7549,7 +8319,7 @@ async function startWalkieRecording() {
walkieStartAt = Date.now();
walkieRecording = true;
if (walkieBarEl) walkieBarEl.classList.add("isRecording");
- if (walkieStatusEl) walkieStatusEl.textContent = "Recording… release to send.";
+ if (walkieStatusEl) walkieStatusEl.textContent = "Recording... release to send.";
const dispatch = await ensureWalkieDispatchBuffer();
if (dispatch) {
@@ -7571,7 +8341,7 @@ async function startWalkieRecording() {
const tookMs = Date.now() - walkieStartAt;
walkieRecording = false;
if (walkieBarEl) walkieBarEl.classList.remove("isRecording");
- if (walkieStatusEl) walkieStatusEl.textContent = "Processing…";
+ if (walkieStatusEl) walkieStatusEl.textContent = "Processing...";
// Give some browsers a tick to deliver the final dataavailable.
await new Promise((r) => window.setTimeout(r, 0));
@@ -7585,7 +8355,7 @@ async function startWalkieRecording() {
const ext = (rec.mimeType || "").includes("ogg") ? "ogg" : "webm";
const file = new File([blob], `walkie-${Date.now()}.${ext}`, { type: rec.mimeType || blob.type || "audio/webm" });
- if (walkieStatusEl) walkieStatusEl.textContent = "Uploading…";
+ if (walkieStatusEl) walkieStatusEl.textContent = "Uploading...";
const url = await uploadMediaFile(file, "audio");
if (!url) {
if (walkieStatusEl) walkieStatusEl.textContent = "";
@@ -8007,6 +8777,10 @@ profileSaveBtn?.addEventListener("click", () => {
newPostForm.addEventListener("submit", (e) => {
e.preventDefault();
+ if (onboardingNeedsAcceptanceNow()) {
+ toast("Onboarding", "Accept server rules in Account before creating hives.");
+ return;
+ }
const title = String(postTitleInput?.value || "")
.replace(/\s+/g, " ")
.trim()
@@ -8073,6 +8847,10 @@ toggleComposerBtn?.addEventListener("click", () => {
toggleComposerInlineBtn?.addEventListener("click", () => setComposerOpen(false));
function submitChat() {
+ if (onboardingNeedsAcceptanceNow()) {
+ toast("Onboarding", "Accept server rules in Account before chatting.");
+ return;
+ }
const html = chatEditor.innerHTML.trim();
const text = chatEditor.innerText.trim();
const hasImg = Boolean(chatEditor.querySelector("img"));
@@ -8244,7 +9022,7 @@ feedEl.addEventListener("click", (e) => {
const postId = chatBtn.getAttribute("data-chat");
const post = postId ? posts.get(postId) : null;
if (post?.locked) unlockPostFlow(postId, true);
- else openChat(postId);
+ else openChat(postId, { sourceEl: chatBtn });
return;
}
@@ -8351,6 +9129,43 @@ window.addEventListener("keydown", (e) => {
openPostMenuId = "";
});
+window.addEventListener("keydown", (e) => {
+ if (e.defaultPrevented) return;
+ if (e.repeat) return;
+ if (e.altKey || e.ctrlKey || e.metaKey) return;
+ if (isTextEntryFocused()) return;
+ const ctx = activePanelContextForHotkeys();
+ const plus = e.key === "=" || e.code === "NumpadAdd";
+ const minus = e.key === "-" || e.code === "NumpadSubtract";
+ if (ctx === "hives" && (plus || minus)) {
+ e.preventDefault();
+ cycleHiveViewBy(plus ? 1 : -1);
+ return;
+ }
+ if (ctx === "chat" && (plus || minus)) {
+ e.preventDefault();
+ cycleChatContextBy(plus ? 1 : -1);
+ return;
+ }
+ if (e.key === "[") {
+ e.preventDefault();
+ cycleLayoutPresetBy(-1);
+ return;
+ }
+ if (e.key === "]") {
+ e.preventDefault();
+ cycleLayoutPresetBy(1);
+ }
+});
+
+window.addEventListener(
+ "pointerdown",
+ (e) => {
+ updateHotkeyPanelContextFromTarget(e.target);
+ },
+ true
+);
+
window.addEventListener("click", (e) => {
if (!openPostMenuId) return;
const esc = cssEscape(openPostMenuId);
@@ -8490,11 +9305,27 @@ chatBackToListBtn?.addEventListener("click", () => {
renderChatPanel(true);
});
+chatContextSelectEl?.addEventListener("change", () => {
+ if (syncingChatContextSelect) return;
+ const raw = String(chatContextSelectEl.value || "").trim();
+ if (!raw) return;
+ if (raw.startsWith("dm:")) {
+ const id = raw.slice(3);
+ if (id) openDmThread(id);
+ return;
+ }
+ if (raw.startsWith("post:")) {
+ const id = raw.slice(5);
+ if (id) openChat(id);
+ }
+});
+
modPanelEl?.addEventListener("click", (e) => {
const tabBtn = e.target.closest("[data-modtab]");
if (tabBtn) {
modTab = tabBtn.getAttribute("data-modtab") || "reports";
if (modTab === "server") requestServerInfo();
+ if (modTab === "onboarding") syncOnboardingAdminDraft(true);
renderModPanel();
return;
}
@@ -8503,6 +9334,11 @@ modPanelEl?.addEventListener("click", (e) => {
modRefreshBtn?.addEventListener("click", () => {
if (!canModerate) return;
if (modTab === "server") requestServerInfo();
+ else if (modTab === "onboarding") {
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
+ syncOnboardingAdminDraft(true);
+ renderModPanel();
+ }
else requestModData();
});
modReportStatusEl?.addEventListener("change", () => {
@@ -8623,6 +9459,120 @@ modBodyEl?.addEventListener("click", (e) => {
const serverRefreshBtn = e.target.closest("button[data-server-refresh]");
if (serverRefreshBtn) {
requestServerInfo();
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
+ return;
+ }
+
+ const onboardingRefreshBtn = e.target.closest("button[data-onboarding-refresh]");
+ if (onboardingRefreshBtn) {
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
+ syncOnboardingAdminDraft(true);
+ renderModPanel();
+ return;
+ }
+
+ const onbAdminTabBtn = e.target.closest("button[data-onb-admin-tab]");
+ if (onbAdminTabBtn) {
+ const tab = String(onbAdminTabBtn.getAttribute("data-onb-admin-tab") || "about").trim();
+ if (!["about", "rules", "roles"].includes(tab)) return;
+ onboardingAdminTab = tab;
+ renderModPanel();
+ return;
+ }
+
+ const onbRuleAddBtn = e.target.closest("button[data-onb-ruleadd]");
+ if (onbRuleAddBtn) {
+ if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
+ normalizeOnboardingDraftRules();
+ const nextIndex = onboardingAdminDraft.rules.length + 1;
+ const id = `r${Date.now()}_${nextIndex}`;
+ onboardingAdminDraft.rules.push({
+ id,
+ order: nextIndex,
+ name: `Rule ${nextIndex}`,
+ shortDescription: "",
+ description: "",
+ severity: "info",
+ });
+ normalizeOnboardingDraftRules();
+ onboardingAdminExpandedRuleIds.add(id);
+ onboardingAdminTab = "rules";
+ renderModPanel();
+ return;
+ }
+
+ const onbRuleToggleBtn = e.target.closest("button[data-onb-ruletoggle]");
+ if (onbRuleToggleBtn) {
+ const id = String(onbRuleToggleBtn.getAttribute("data-onb-ruletoggle") || "").trim();
+ if (!id) return;
+ if (onboardingAdminExpandedRuleIds.has(id)) onboardingAdminExpandedRuleIds.delete(id);
+ else onboardingAdminExpandedRuleIds.add(id);
+ renderModPanel();
+ return;
+ }
+
+ const onbRuleDeleteBtn = e.target.closest("button[data-onb-ruledelete]");
+ if (onbRuleDeleteBtn) {
+ if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
+ const id = String(onbRuleDeleteBtn.getAttribute("data-onb-ruledelete") || "").trim();
+ onboardingAdminDraft.rules = onboardingAdminDraft.rules.filter((r) => r.id !== id);
+ onboardingAdminExpandedRuleIds.delete(id);
+ normalizeOnboardingDraftRules();
+ renderModPanel();
+ return;
+ }
+
+ const onbRuleUpBtn = e.target.closest("button[data-onb-ruleup]");
+ if (onbRuleUpBtn) {
+ if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
+ const id = String(onbRuleUpBtn.getAttribute("data-onb-ruleup") || "").trim();
+ const idx = onboardingAdminDraft.rules.findIndex((r) => r.id === id);
+ if (idx <= 0) return;
+ const tmp = onboardingAdminDraft.rules[idx - 1];
+ onboardingAdminDraft.rules[idx - 1] = onboardingAdminDraft.rules[idx];
+ onboardingAdminDraft.rules[idx] = tmp;
+ normalizeOnboardingDraftRules();
+ renderModPanel();
+ return;
+ }
+
+ const onbRuleDownBtn = e.target.closest("button[data-onb-ruledown]");
+ if (onbRuleDownBtn) {
+ if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
+ const id = String(onbRuleDownBtn.getAttribute("data-onb-ruledown") || "").trim();
+ const idx = onboardingAdminDraft.rules.findIndex((r) => r.id === id);
+ if (idx < 0 || idx >= onboardingAdminDraft.rules.length - 1) return;
+ const tmp = onboardingAdminDraft.rules[idx + 1];
+ onboardingAdminDraft.rules[idx + 1] = onboardingAdminDraft.rules[idx];
+ onboardingAdminDraft.rules[idx] = tmp;
+ normalizeOnboardingDraftRules();
+ renderModPanel();
+ return;
+ }
+
+ const onboardingSaveBtn = e.target.closest("button[data-onboarding-save],button[data-onboarding-publish]");
+ if (onboardingSaveBtn) {
+ if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
+ const publish = onboardingSaveBtn.hasAttribute("data-onboarding-publish");
+ normalizeOnboardingDraftRules();
+ ws.send(
+ JSON.stringify({
+ type: "instanceSetOnboarding",
+ publish,
+ enabled: Boolean(onboardingAdminDraft.enabled),
+ about: { content: String(onboardingAdminDraft.aboutContent || "") },
+ rules: {
+ requireAcceptance: Boolean(onboardingAdminDraft.requireAcceptance),
+ blockReadUntilAccepted: Boolean(onboardingAdminDraft.blockReadUntilAccepted),
+ items: onboardingAdminDraft.rules,
+ },
+ roleSelect: {
+ enabled: Boolean(onboardingAdminDraft.roleSelectEnabled),
+ selfAssignableRoleIds: onboardingAdminDraft.selfAssignableRoleIds,
+ }
+ })
+ );
+ toast("Onboarding", publish ? "Publishing..." : "Saving...");
return;
}
@@ -8657,7 +9607,7 @@ modBodyEl?.addEventListener("click", (e) => {
appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct }
})
);
- toast("Instance", "Saving…");
+ toast("Instance", "Saving...");
return;
}
@@ -8682,7 +9632,7 @@ modBodyEl?.addEventListener("click", (e) => {
appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct }
})
);
- toast("Theme", "Saving…");
+ toast("Theme", "Saving...");
return;
}
@@ -8699,7 +9649,7 @@ modBodyEl?.addEventListener("click", (e) => {
if (pluginReloadBtn) {
if (!isOwnerUser()) return;
pluginAdminBusy = true;
- pluginAdminStatus = "Reloading plugins…";
+ pluginAdminStatus = "Reloading plugins...";
renderModPanel();
ws.send(JSON.stringify({ type: "pluginReload" }));
return;
@@ -8713,7 +9663,7 @@ modBodyEl?.addEventListener("click", (e) => {
const ok = confirm(`Uninstall "${id}"? This deletes the plugin files from this server.`);
if (!ok) return;
pluginAdminBusy = true;
- pluginAdminStatus = `Uninstalling "${id}"…`;
+ pluginAdminStatus = `Uninstalling "${id}"...`;
renderModPanel();
ws.send(JSON.stringify({ type: "pluginUninstall", id }));
return;
@@ -8736,7 +9686,7 @@ modBodyEl?.addEventListener("click", (e) => {
return;
}
pluginAdminBusy = true;
- pluginAdminStatus = "Uploading plugin…";
+ pluginAdminStatus = "Uploading plugin...";
renderModPanel();
(async () => {
try {
@@ -8779,7 +9729,7 @@ modBodyEl?.addEventListener("click", (e) => {
const ok = confirm("NUKE the board? This clears all hives, reports, moderation log, and hive media uploads.");
if (!ok) return;
ws.send(JSON.stringify({ type: "nukeBoard", confirm: true, confirmText: "ARE YOU SURE?" }));
- toast("NUKE", "Working…");
+ toast("NUKE", "Working...");
return;
}
@@ -8937,6 +9887,53 @@ modBodyEl?.addEventListener("click", (e) => {
});
modBodyEl?.addEventListener("change", (e) => {
+ const onbEnabled = e.target?.closest?.("input[data-onboarding-enabled]");
+ if (onbEnabled) {
+ onboardingAdminDraft.enabled = Boolean(onbEnabled.checked);
+ return;
+ }
+ const onbRequire = e.target?.closest?.("input[data-onboarding-require]");
+ if (onbRequire) {
+ onboardingAdminDraft.requireAcceptance = Boolean(onbRequire.checked);
+ return;
+ }
+ const onbBlockRead = e.target?.closest?.("input[data-onboarding-blockread]");
+ if (onbBlockRead) {
+ onboardingAdminDraft.blockReadUntilAccepted = Boolean(onbBlockRead.checked);
+ return;
+ }
+ const onbRoleEnabled = e.target?.closest?.("input[data-onboarding-roleenabled]");
+ if (onbRoleEnabled) {
+ onboardingAdminDraft.roleSelectEnabled = Boolean(onbRoleEnabled.checked);
+ return;
+ }
+ const onbRoleCheck = e.target?.closest?.("input[data-onboarding-rolecheck]");
+ if (onbRoleCheck) {
+ const key = String(onbRoleCheck.getAttribute("data-onboarding-rolecheck") || "").trim().toLowerCase();
+ if (!key) return;
+ const set = new Set(onboardingAdminDraft.selfAssignableRoleIds || []);
+ if (onbRoleCheck.checked) set.add(key);
+ else set.delete(key);
+ onboardingAdminDraft.selfAssignableRoleIds = Array.from(set);
+ return;
+ }
+ const onbRuleField = e.target?.closest?.("[data-onb-rulefield]");
+ if (onbRuleField) {
+ const id = String(onbRuleField.getAttribute("data-onb-ruleid") || "").trim();
+ const field = String(onbRuleField.getAttribute("data-onb-rulefield") || "").trim();
+ if (!id || !field) return;
+ const rule = onboardingAdminDraft.rules.find((r) => r.id === id);
+ if (!rule) return;
+ if (field === "severity") {
+ rule.severity = ["info", "warn", "critical"].includes(String(onbRuleField.value || "").toLowerCase())
+ ? String(onbRuleField.value || "").toLowerCase()
+ : "info";
+ return;
+ }
+ rule[field] = String(onbRuleField.value || "");
+ return;
+ }
+
const presetSelect = e.target?.closest?.("select[data-theme-preset]");
if (presetSelect) {
if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
@@ -8984,13 +9981,29 @@ modBodyEl?.addEventListener("change", (e) => {
for (const p of plugins) {
if (p && String(p.id || "").toLowerCase() === id) p.enabled = enabled;
}
- pluginAdminStatus = enabled ? "Enabling…" : "Disabling…";
+ pluginAdminStatus = enabled ? "Enabling..." : "Disabling...";
renderModPanel();
wsRef.send(JSON.stringify({ type: "pluginSetEnabled", id, enabled }));
return;
}
});
+modBodyEl?.addEventListener("input", (e) => {
+ const aboutEl = e.target?.closest?.("textarea[data-onboarding-about]");
+ if (aboutEl) {
+ onboardingAdminDraft.aboutContent = String(aboutEl.value || "");
+ return;
+ }
+ const onbRuleField = e.target?.closest?.("input[data-onb-rulefield],textarea[data-onb-rulefield]");
+ if (!onbRuleField) return;
+ const id = String(onbRuleField.getAttribute("data-onb-ruleid") || "").trim();
+ const field = String(onbRuleField.getAttribute("data-onb-rulefield") || "").trim();
+ if (!id || !field) return;
+ const rule = onboardingAdminDraft.rules.find((r) => r.id === id);
+ if (!rule) return;
+ rule[field] = String(onbRuleField.value || "");
+});
+
modBodyEl?.addEventListener("change", (e) => {
const toggle = e.target?.closest?.("input[data-nukeconfirm]");
if (!toggle) return;
@@ -9312,6 +10325,7 @@ function onWsMessage(evt) {
devLog = [];
profiles = msg.profiles && typeof msg.profiles === "object" ? msg.profiles : {};
instanceBranding = normalizeInstanceBranding(msg.instance || {});
+ onboardingState = normalizeOnboardingState(msg.auth?.onboarding || {});
renderInstanceBranding();
collections = normalizeCollections(msg.collections);
customRoles = normalizeRoleDefs(msg.roles?.custom);
@@ -9334,6 +10348,7 @@ function onWsMessage(evt) {
renderLanHint();
renderPeoplePanel();
renderCenterPanels();
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
return;
}
@@ -9437,6 +10452,8 @@ function onWsMessage(evt) {
if (msg.type === "instanceUpdated" && msg.instance && typeof msg.instance === "object") {
instanceBranding = normalizeInstanceBranding(msg.instance);
+ onboardingState = normalizeOnboardingState(onboardingState);
+ if (modTab === "onboarding") syncOnboardingAdminDraft(true);
renderInstanceBranding();
applyInstanceAppearance();
setAuthUi();
@@ -9445,6 +10462,8 @@ function onWsMessage(evt) {
if (msg.type === "instanceOk" && msg.instance && typeof msg.instance === "object") {
instanceBranding = normalizeInstanceBranding(msg.instance);
+ onboardingState = normalizeOnboardingState(onboardingState);
+ if (modTab === "onboarding") syncOnboardingAdminDraft(true);
renderInstanceBranding();
applyInstanceAppearance();
setAuthUi();
@@ -9590,6 +10609,7 @@ function onWsMessage(evt) {
loggedInUser = msg.username || null;
loggedInRole = typeof msg.role === "string" ? msg.role : "member";
canModerate = Boolean(msg.canModerate);
+ onboardingState = normalizeOnboardingState(msg.onboarding || onboardingState);
if (typeof msg.sessionToken === "string" && msg.sessionToken) setSessionToken(msg.sessionToken);
const profile = msg.profile || {};
pendingProfileImage = typeof profile.image === "string" ? profile.image : "";
@@ -9615,6 +10635,7 @@ function onWsMessage(evt) {
if (canModerate) requestModData();
if (rackLayoutEnabled) applyDockState();
updateLayoutPresetOptions();
+ renderOnboardingCard();
return;
}
@@ -9623,6 +10644,7 @@ function onWsMessage(evt) {
loggedInUser = null;
loggedInRole = "member";
canModerate = false;
+ onboardingState = normalizeOnboardingState({ acceptedRulesVersion: 0, acceptedAt: 0, needsAcceptance: false });
dmThreads = [];
dmThreadsById = new Map();
dmMessagesByThreadId.clear();
@@ -9642,6 +10664,7 @@ function onWsMessage(evt) {
renderCenterPanels();
if (rackLayoutEnabled) applyDockState();
updateLayoutPresetOptions();
+ renderOnboardingCard();
return;
}
@@ -9649,6 +10672,7 @@ function onWsMessage(evt) {
if (!loggedInUser || msg.username !== loggedInUser) return;
loggedInRole = typeof msg.role === "string" ? msg.role : loggedInRole;
canModerate = Boolean(msg.canModerate);
+ onboardingState = normalizeOnboardingState(msg.onboarding || onboardingState);
if (!canModerate) lanUrls = [];
if (msg.prefs && typeof msg.prefs === "object") setUserPrefs(msg.prefs);
setAuthUi();
@@ -9657,6 +10681,14 @@ function onWsMessage(evt) {
renderPeoplePanel();
if (canModerate) requestModData();
updateLayoutPresetOptions();
+ renderOnboardingCard();
+ return;
+ }
+
+ if (msg.type === "onboardingState" && msg.onboarding && typeof msg.onboarding === "object") {
+ onboardingState = normalizeOnboardingState(msg.onboarding);
+ setAuthUi();
+ renderOnboardingCard();
return;
}
@@ -9752,6 +10784,25 @@ function onWsMessage(evt) {
return;
}
+ if (msg.type === "dmModMessageReceived") {
+ const threadId = String(msg.threadId || "").trim();
+ if (!threadId) return;
+ if (!dmThreadsById.has(threadId) && ws?.readyState === WebSocket.OPEN) {
+ pendingOpenDmThreadId = threadId;
+ ws.send(JSON.stringify({ type: "dmList" }));
+ }
+ if (isMobileScreenMode()) {
+ const layout = loadMobileLayout();
+ layout.active = "chat";
+ saveMobileLayout(layout);
+ setMobileScreen("chat");
+ renderMobileNav();
+ }
+ if (dmThreadsById.has(threadId)) openDmThread(threadId);
+ toast("Moderator message", "Opened priority moderator DM.");
+ return;
+ }
+
if (msg.type === "lanInfo") {
lanUrls = Array.isArray(msg.lanUrls) ? msg.lanUrls : [];
renderLanHint();
@@ -10033,6 +11084,7 @@ setConn("connecting");
connectWs();
renderLanHint();
+writeHintsEnabledPref(readHintsEnabledPref());
initDisplayPrefsUi();
if (stayConnectedEl) {
stayConnectedEl.checked = readStayConnectedPref();
@@ -10048,6 +11100,12 @@ if (stayConnectedEl) {
}
});
}
+if (enableHintsEl) {
+ enableHintsEl.checked = readHintsEnabledPref();
+ enableHintsEl.addEventListener("change", () => {
+ writeHintsEnabledPref(Boolean(enableHintsEl.checked));
+ });
+}
renderPeoplePanel();
setPeopleOpen(getPeopleOpen());
composerOpen = getComposerOpen();
@@ -10258,6 +11316,39 @@ peopleDmsViewEl?.addEventListener("click", (e) => {
}
});
+onboardingAcceptBtn?.addEventListener("click", () => {
+ if (!loggedInUser) {
+ toast("Sign in required", "Sign in to accept server rules.");
+ return;
+ }
+ ws.send(JSON.stringify({ type: "onboardingAcceptRules" }));
+});
+
+onboardingRefreshBtn?.addEventListener("click", () => {
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
+});
+
+onboardingPanelAcceptBtn?.addEventListener("click", () => {
+ if (!loggedInUser) {
+ toast("Sign in required", "Sign in to accept server rules.");
+ return;
+ }
+ ws.send(JSON.stringify({ type: "onboardingAcceptRules" }));
+});
+
+onboardingPanelRefreshBtn?.addEventListener("click", () => {
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
+});
+
+onboardingPanelBodyEl?.addEventListener("click", (e) => {
+ const tabBtn = e.target.closest?.("button[data-onbtab]");
+ if (!tabBtn) return;
+ const tab = String(tabBtn.getAttribute("data-onbtab") || "about").trim();
+ if (!["about", "rules", "roles"].includes(tab)) return;
+ onboardingViewerTab = tab;
+ renderOnboardingPanel();
+});
+
profileCard?.addEventListener("click", (e) => {
const dmBtn = e.target.closest("button[data-dmrequest]");
if (!dmBtn) return;
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=119" />
+ <link rel="stylesheet" href="/styles.css?v=126" />
</head>
<body>
<div class="app">
@@ -28,6 +28,7 @@
<section id="viewPanel" class="panel">
<div class="panelTitle">View</div>
+ <div class="uiHint">Use layout presets for quick panel setups. Shortcuts: <b>[</b>/<b>]</b> cycle presets, <b>-</b>/<b>=</b> cycle hives/chats in the active panel.</div>
<label class="checkRow" style="margin-top:8px;">
<span>Rack layout (experimental)</span>
<input id="toggleRackLayout" type="checkbox" />
@@ -61,6 +62,10 @@
<span>Stay connected</span>
<input id="stayConnected" type="checkbox" />
</label>
+ <label class="checkRow" style="margin-top:8px;">
+ <span>Enable hints</span>
+ <input id="enableHints" type="checkbox" />
+ </label>
<details style="margin-top:10px;">
<summary class="small muted" style="cursor:pointer;user-select:none;">Advanced display</summary>
@@ -93,6 +98,7 @@
<section id="accountPanel" class="panel">
<div class="panelTitle">Account</div>
<div class="small muted" id="authHint">Sign in to post, chat, and boost.</div>
+ <div class="uiHint">New here: create an account, then open a hive and tap <b>Chat</b> to join a conversation.</div>
<div class="small muted">Signed in as</div>
<div id="userLabel" class="userLine">Signed out</div>
@@ -119,11 +125,20 @@
Note: this is a prototype; don't reuse important passwords.
</div>
</form>
+ <div id="onboardingCard" class="onboardingCard hidden">
+ <div class="panelTitle">Onboarding</div>
+ <div id="onboardingBody" class="small muted"></div>
+ <div class="row" style="margin-top:8px;">
+ <button id="onboardingAccept" class="primary grow hidden" type="button">Accept and continue</button>
+ <button id="onboardingRefresh" class="ghost" type="button">Refresh</button>
+ </div>
+ </div>
</section>
<section id="profilePanel" class="panel">
<div class="panelTitle">Profile</div>
<div class="small muted">Set how your name appears.</div>
+ <div class="uiHint">Your profile card appears in People and when someone opens your profile from a post or chat.</div>
<label>
<span>Profile picture</span>
<input id="profileImage" type="file" accept="image/*" />
@@ -181,6 +196,7 @@
<button id="toggleComposer" class="mobileComposerToggle" type="button">New Hive</button>
</div>
</div>
+ <div class="uiHint">Use filters to narrow posts, then tap <b>Chat</b> on a hive card. Shortcut in Hives: <b>-</b>/<b>=</b> cycles collections/views.</div>
<div class="hiveTabs" id="hiveTabs">
<button type="button" data-hiveview="all" class="primary">All</button>
<button type="button" data-hiveview="starred" class="ghost">Starred</button>
@@ -189,6 +205,23 @@
<div id="feed" class="feed"></div>
</section>
+ <section id="onboardingPanel" class="panel panelFill hidden">
+ <div class="panelHeader">
+ <div>
+ <div class="panelTitle">Onboarding</div>
+ <div class="small muted">About, rules, and first steps.</div>
+ </div>
+ </div>
+ <div class="panelBody onboardingPanelBody">
+ <div class="uiHint">Read About and Rules first. If required, accept rules to unlock posting and chat.</div>
+ <div id="onboardingPanelBody" class="small muted"></div>
+ <div class="row" style="margin-top:10px;">
+ <button id="onboardingPanelAccept" class="primary grow hidden" type="button">Accept and continue</button>
+ <button id="onboardingPanelRefresh" class="ghost" type="button">Refresh</button>
+ </div>
+ </div>
+ </section>
+
<section id="profileViewPanel" class="panel panelFill hidden">
<div class="panelHeader">
<div>
@@ -200,6 +233,7 @@
<button id="profileEditToggleBtn" class="ghost smallBtn hidden" type="button">Edit profile</button>
</div>
</div>
+ <div class="uiHint">Tip: open someone from People, a hive card, or a chat message to view their profile here.</div>
<div id="profileViewBody" class="profileViewBody">
<div id="profileCard" class="profileCard"></div>
</div>
@@ -254,6 +288,7 @@
<div class="panelTitle">Create Hive</div>
<button id="toggleComposerInline" class="ghost smallBtn" type="button">Hide</button>
</div>
+ <div class="uiHint">Keep titles short and clear. Add keywords so others can find your hive faster.</div>
<form id="newPostForm" class="form">
<label>
<span>Title (max 96 chars)</span>
@@ -325,9 +360,11 @@
<div id="chatMeta" class="small muted">Select a post to chat.</div>
</div>
<div class="row chatHeaderActions">
+ <select id="chatContextSelect" class="chatContextSelect" aria-label="Open chats"></select>
<button id="chatBackToList" class="ghost smallBtn hidden" type="button">Back</button>
</div>
</div>
+ <div class="uiHint">Select a hive chat or DM first, then type your message and press Send. Shortcut in Chat: <b>-</b>/<b>=</b> cycles chat list entries.</div>
<div id="chatMessages" class="chatMessages"></div>
<div id="typingIndicator" class="typingIndicator small muted"></div>
<div id="walkieBar" class="walkieBar hidden" aria-label="Walkie talkie controls">
@@ -369,6 +406,7 @@
<input id="chatImage" class="hidden" type="file" accept="image/*" />
<input id="chatAudio" class="hidden" type="file" accept="audio/*,.mp3,.wav,.ogg,.m4a,.webm" />
</div>
+ <div class="uiHint">Use Link, GIF/Image, and Audio to attach media quickly.</div>
<button class="primary" type="submit">Send</button>
</form>
</aside>
@@ -379,11 +417,13 @@
<div class="panelHeader">
<div class="panelTitle">Moderation</div>
</div>
+ <div class="uiHint">Use tabs to review reports, manage users/hives, configure server settings, and publish onboarding content.</div>
<div class="modTabs">
<button type="button" class="ghost" data-modtab="reports">Reports</button>
<button type="button" class="ghost" data-modtab="users">Users</button>
<button type="button" class="ghost" data-modtab="hives">Hives</button>
<button type="button" class="ghost" data-modtab="server">Server</button>
+ <button type="button" class="ghost" data-modtab="onboarding">Onboarding</button>
<button type="button" class="ghost" data-modtab="log">Log</button>
</div>
<div class="modFilters">
@@ -411,12 +451,14 @@
<button id="peopleDmsTab" class="ghost" type="button">DMs</button>
</div>
<div id="peopleMembersView">
+ <div class="uiHint">Members list lets you open profiles and start DMs. Search filters by username.</div>
<div class="peopleFilters">
<input id="peopleSearch" placeholder="Search members" />
</div>
<div id="peopleList" class="peopleList small"></div>
</div>
<div id="peopleDmsView" class="peopleDms small hidden">
+ <div class="uiHint">DM requests must be accepted before chat opens. Active threads show an <b>Open</b> button.</div>
DMs coming soon.
</div>
</aside>
@@ -558,6 +600,6 @@
</div>
<div id="dockHotbar" class="dockHotbar hidden" aria-label="Docked panels"></div>
- <script src="/app.js?v=134"></script>
+ <script src="/app.js?v=146"></script>
</body>
</html>
diff --git a/CLEAN_INSTALL/public/styles.css b/CLEAN_INSTALL/public/styles.css
@@ -936,6 +936,8 @@ body {
.smallBtn {
padding: 6px 10px;
border-radius: 999px;
+ white-space: nowrap;
+ line-height: 1;
}
.mapChatToggle {
@@ -946,9 +948,10 @@ body {
}
.sidebarToggle {
- position: absolute;
- top: 12px;
+ position: fixed;
+ bottom: calc(12px + env(safe-area-inset-bottom, 0px));
left: 12px;
+ top: auto;
z-index: 20;
}
@@ -1490,6 +1493,22 @@ body {
font-weight: 800;
}
+.uiHint {
+ margin-top: 8px;
+ margin-bottom: 8px;
+ padding: 8px 10px;
+ border-radius: 10px;
+ border: 1px solid rgba(246, 240, 255, 0.14);
+ background: rgba(120, 50, 190, 0.12);
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.1rem;
+}
+
+.app:not(.hintsEnabled) .uiHint {
+ display: none !important;
+}
+
.panelTitleSub {
font-weight: 800;
margin-top: 2px;
@@ -1577,6 +1596,94 @@ body {
flex: 1;
}
+.onboardingCard {
+ margin-top: 10px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 10px;
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.onboardingAbout {
+ max-height: 180px;
+ overflow: auto;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 8px;
+ background: rgba(255, 255, 255, 0.01);
+}
+
+.onboardingRules {
+ margin: 8px 0 0 18px;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.onboardingPanelBody {
+ padding: 10px;
+ overflow: auto;
+}
+
+.onbTabs {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.onbRuleList,
+.onbRuleEditorList {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.onbRuleViewerCard,
+.onbRuleEditorCard {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 10px;
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.onbRuleEditorBody {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.onbRoleGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.onbSeverityInfo {
+ border-color: rgba(61, 220, 151, 0.55);
+ color: #9beac7;
+}
+
+.onbSeverityWarn {
+ border-color: rgba(255, 166, 0, 0.55);
+ color: #ffd28d;
+}
+
+.onbSeverityCritical {
+ border-color: rgba(255, 77, 138, 0.65);
+ color: #ffb3cb;
+}
+
+.goodText {
+ color: var(--good);
+}
+
+.badText {
+ color: var(--bad);
+}
+
label span {
display: block;
color: var(--muted);
@@ -1596,6 +1703,22 @@ select {
outline: none;
}
+select option,
+select optgroup {
+ color: #11131a;
+ background: #f4f6fb;
+}
+
+select option:checked {
+ color: #0f1724;
+ background: #8eb8e6;
+}
+
+select optgroup {
+ font-weight: 700;
+ background: #e7ebf3;
+}
+
input:focus,
textarea:focus,
select:focus,
@@ -3205,12 +3328,28 @@ button:disabled {
margin-left: auto;
}
-.app.mobileScreens .mobileChatList {
+.chatContextSelect {
+ min-width: 180px;
+ max-width: 320px;
+}
+
+.mobileChatSection {
display: flex;
flex-direction: column;
gap: 8px;
}
+.app.mobileScreens .mobileChatList {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.app.mobileScreens .chatContextSelect {
+ min-width: 132px;
+ max-width: 180px;
+}
+
.app.mobileScreens .mobileChatListItem {
width: 100%;
text-align: left;
diff --git a/docs/COMMUNITY_FEATURES_ROADMAP.md b/docs/COMMUNITY_FEATURES_ROADMAP.md
@@ -3,6 +3,9 @@
## Purpose
Capture the next major product features requested for community growth, and define a practical build order.
+## Related Specs
+- `docs/ONBOARDING_AND_MOD_MESSAGE_IMPLEMENTATION_SPEC.md` (onboarding panes, mod messages, tutorial overlays, onboarding-first preset)
+
## Scope
This roadmap covers:
- Discovery and organization of hives (`Collections`, sorting, search)
@@ -174,4 +177,3 @@ Acceptance:
- Should custom role tags be visible publicly or owner/mod-only?
- For Most Popular, should score weight reactions, chat volume, and recency equally?
- Should blocked users be hidden in people list and typing indicators too?
-
diff --git a/docs/ONBOARDING_AND_MOD_MESSAGE_IMPLEMENTATION_SPEC.md b/docs/ONBOARDING_AND_MOD_MESSAGE_IMPLEMENTATION_SPEC.md
@@ -0,0 +1,244 @@
+# Onboarding + Mod Message Implementation Spec (v1)
+
+## Purpose
+Define a shippable implementation for:
+- Mod-to-user priority messaging (`MOD` sender identity in client UI)
+- Server-configurable onboarding panes (`About`, `Rules`, `Role Select`)
+- Rule reference shortcuts in chat (`&X`)
+- Guided tutorial overlays
+- A default layout preset that prioritizes onboarding UX on first run
+
+This spec is implementation-focused and aligned with current panel/rack + mobile screen architecture.
+
+## Product goals
+- New users understand the community quickly before posting.
+- Moderators can communicate urgent guidance without DM friction.
+- UX remains usable on small mobile screens first, then scales up.
+- Onboarding is the default first-run layout preset on all devices.
+
+## Non-goals (v1)
+- Full learning management / quizzes.
+- Rich workflow automation for moderation.
+- End-to-end plugin onboarding integration.
+- Cross-instance federated onboarding sync.
+
+## Feature scope
+
+### 1) Mod Message
+**Behavior**
+- Moderators/owner can send a direct message as `MOD`.
+- Message bypasses DM request acceptance.
+- Recipient gets a high-priority notification.
+- On open clients, UI focuses the mod thread in the primary view.
+
+**Identity model**
+- Client renders sender as `MOD`.
+- Server stores `senderUserId` for audit.
+- Non-mod recipients never see the underlying mod username.
+
+**Controls**
+- Rate limit per moderator (anti-spam).
+- Recipient can mute future mod messages from the same instance for a time window (except critical notices flag).
+- Full moderation log entries are mandatory.
+
+### 2) Onboarding Panes
+Three admin-configured panes:
+
+1. **About**
+ - Rich text content (sanitized HTML subset / markdown-rendered).
+ - Optional media blocks.
+
+2. **Rules**
+ - Ordered list of rules:
+ - `name`
+ - `shortDescription`
+ - `description`
+ - Optional `requireAcceptance` gate.
+ - If gate is enabled, users cannot post/chat until accepted.
+ - Optional read-gate for viewing posts (configurable).
+
+3. **Custom Role Select**
+ - Displays selectable self-assignable custom roles.
+ - Hidden when no self-assignable roles exist.
+
+### 3) Rule shortcut in chat (`&X`)
+- Typing `&3` expands to a highlighted rule reference chip/card.
+- Expansion occurs client-side preview + server-side validation on send.
+- If rule index invalid, send is rejected with actionable error.
+- Hover/tap on rendered reference opens Rules pane anchored to that rule.
+
+### 4) Tutorial overlays
+- Entry point: Account panel button (`Show tutorial`).
+- Device-specific walkthrough variants:
+ - Mobile: bottom-nav + screen switching + compose/chat flow.
+ - Desktop/tablet: panel layout, rack visibility, profile/people/chat flow.
+- Supports replay anytime.
+- Tracks completion per user/device.
+
+### 5) Default layout preset: `onboardingDefault`
+- New default preset for first-run sessions.
+- Prioritizes onboarding surfaces before advanced layout customization.
+- Applies once per user/device, then user customizations persist.
+
+## UX per device
+
+### Mobile (primary target)
+- Bottom nav order: `Account`, `Hives`, `Chat`, `People`, `Profile`, `More`.
+- `Maps` hidden by default on mobile.
+- Onboarding appears as top-level flow when acceptance required.
+- Rules gate CTA stays sticky at bottom: `Accept and continue`.
+- Chat composer:
+ - Hide advanced formatting/emoji reaction controls by default.
+ - Keep quick actions: `Link`, `GIF/Image`, `Audio`.
+ - `Send` button full-width and never obscured by nav/safe-area.
+- People screen must always render a visible list state:
+ - loading, empty, or populated.
+
+### Tablet
+- Same onboarding flow as mobile, with split-pane where width allows:
+ - left: rules/about list
+ - right: selected detail.
+- Chat list + active chat can coexist in landscape.
+
+### Desktop
+- Onboarding is a guided preset, not a hard gate unless `requireAcceptance`.
+- If side rack is hidden, remaining panels reflow without orphan floating panes.
+- Mod message interrupt focuses chat panel but does not destroy current layout.
+
+## Data model changes
+
+### `instanceConfig.onboarding`
+```json
+{
+ "enabled": true,
+ "about": {
+ "content": "sanitized-rich-text",
+ "updatedAt": 0,
+ "updatedBy": ""
+ },
+ "rules": {
+ "requireAcceptance": false,
+ "blockReadUntilAccepted": false,
+ "items": [
+ { "id": "r1", "order": 1, "name": "", "shortDescription": "", "description": "" }
+ ]
+ },
+ "roleSelect": {
+ "enabled": true,
+ "selfAssignableRoleIds": []
+ },
+ "tutorial": {
+ "enabled": true,
+ "version": 1
+ }
+}
+```
+
+### `users.onboardingState[instanceId]`
+```json
+{
+ "acceptedRulesVersion": 1,
+ "acceptedAt": 0,
+ "tutorialCompletedVersion": 1,
+ "selectedRoleIds": []
+}
+```
+
+### `messages` additions
+```json
+{
+ "isModMessage": true,
+ "senderLabel": "MOD",
+ "auditSenderUserId": "u123",
+ "priority": "high"
+}
+```
+
+## API / WebSocket changes
+
+### Admin/Mod config
+- `onboarding:get`
+- `onboarding:updateAbout`
+- `onboarding:updateRules`
+- `onboarding:updateRoleSelect`
+- `onboarding:publish` (increments rules/tutorial version where needed)
+
+### User actions
+- `onboarding:getState`
+- `onboarding:acceptRules`
+- `onboarding:selectRoles`
+- `tutorial:markComplete`
+
+### Mod messaging
+- `dm:sendModMessage`
+- `dm:modMessageReceived` (high-priority client event)
+- `dm:openThread` should accept mod thread ids identically to regular DMs
+
+### Rule references
+- `chat:resolveRuleRef` (optional helper)
+- Server validates final message payload contains valid rule IDs.
+
+## Permission rules
+- Owner/moderator can manage onboarding content and send mod messages.
+- Only owner can toggle `blockReadUntilAccepted`.
+- Rule acceptance is per user per instance.
+- Server enforces acceptance gate for read/post endpoints and WS events.
+
+## Layout preset definition
+
+Add preset to layout catalog:
+
+```json
+{
+ "id": "onboardingDefault",
+ "label": "Onboarding",
+ "deviceProfiles": {
+ "mobile": { "pinned": ["account", "hives", "chat", "people", "profile"] },
+ "tablet": { "racks": { "left": ["onboarding"], "main": ["hives", "chat"], "right": ["people", "profile"] } },
+ "desktop": { "racks": { "left": ["onboarding", "hives"], "main": ["chat"], "right": ["people", "profile"] } }
+ },
+ "firstRunOnly": true
+}
+```
+
+Preset application rules:
+- Applied when no prior user layout exists.
+- If onboarding disabled, preset gracefully degrades to existing default.
+- User edits override preset immediately and persist.
+
+## Rollout plan
+
+### Phase 1: Data + gating foundation
+- Add onboarding config/state schemas.
+- Implement rules acceptance gate checks server-side.
+- Add migration defaults for existing instances/users.
+
+### Phase 2: Mobile-first onboarding UX
+- Build About/Rules/Role Select screens.
+- Implement `onboardingDefault` first-run preset.
+- Ensure safe-area/nav spacing for chat composer and send button.
+
+### Phase 3: Mod message + rule refs
+- Add mod message send/receive path and audit logging.
+- Add `&X` parsing, validation, and rendering.
+- Add notification + focus behavior.
+
+### Phase 4: Tutorial overlays + polish
+- Implement per-device tutorial flows.
+- Add replay entry point in Account.
+- Add analytics counters (started/completed/skipped).
+
+## Acceptance criteria
+- People pane always renders on mobile (no blank screen state).
+- DM/mod thread open action always routes to a visible chat context.
+- Send button never overlaps bottom nav on supported mobile sizes.
+- With rules gate enabled, blocked actions return clear errors and CTA to rules.
+- `&X` references resolve correctly and render consistently in chat history.
+- First-run users see onboarding preset by default on each device class.
+
+## UX ideas for later polish
+- Rule chips in chat show color-coded severity (info/warn/critical).
+- “Why this rule exists” collapsible rationale to reduce moderation friction.
+- Progressive disclosure in composer: reveal rich tools only after tapping `+`.
+- Smart onboarding resume: return user to last incomplete pane.
+- Contextual tutorial nudges when user fails an action repeatedly.
diff --git a/public/app.js b/public/app.js
@@ -1,4 +1,4 @@
-const connBadge = document.getElementById("connBadge");
+const connBadge = document.getElementById("connBadge");
const lanHint = document.getElementById("lanHint");
const appRoot = document.querySelector(".app");
@@ -33,6 +33,7 @@ const layoutPresetEl = document.getElementById("layoutPreset");
const uiScaleEl = document.getElementById("uiScale");
const deviceLayoutEl = document.getElementById("deviceLayout");
const stayConnectedEl = document.getElementById("stayConnected");
+const enableHintsEl = document.getElementById("enableHints");
const dockHotbarEl = document.getElementById("dockHotbar");
const showSideRackBtn = document.getElementById("showSideRack");
const showRightRackBtn = document.getElementById("showRightRack");
@@ -40,6 +41,10 @@ const chatModToggleWrapEl = document.getElementById("chatModToggleWrap");
const chatModToggleEl = document.getElementById("chatModToggle");
const authHint = document.getElementById("authHint");
+const onboardingCard = document.getElementById("onboardingCard");
+const onboardingBody = document.getElementById("onboardingBody");
+const onboardingAcceptBtn = document.getElementById("onboardingAccept");
+const onboardingRefreshBtn = document.getElementById("onboardingRefresh");
const userLabel = document.getElementById("userLabel");
const authForm = document.getElementById("authForm");
const authUser = document.getElementById("authUser");
@@ -55,7 +60,7 @@ const removeProfileImageBtn = document.getElementById("removeProfileImage");
const nameColorInput = document.getElementById("nameColor");
const saveProfileBtn = document.getElementById("saveProfile");
const profileStatus = document.getElementById("profileStatus");
-// Instance + plugin admin UI lives in Moderation → Server tab (rendered dynamically).
+// Instance + plugin admin UI lives in Moderation -> Server tab (rendered dynamically).
const modPanelEl = document.getElementById("modPanel");
const modBodyEl = document.getElementById("modBody");
const modRefreshBtn = document.getElementById("modRefresh");
@@ -102,6 +107,10 @@ const mobileSortCycleBtn = document.getElementById("mobileSortCycle");
const clearFilterBtn = document.getElementById("clearFilter");
const feedEl = document.getElementById("feed");
const hiveTabsEl = document.getElementById("hiveTabs");
+const onboardingPanelEl = document.getElementById("onboardingPanel");
+const onboardingPanelBodyEl = document.getElementById("onboardingPanelBody");
+const onboardingPanelAcceptBtn = document.getElementById("onboardingPanelAccept");
+const onboardingPanelRefreshBtn = document.getElementById("onboardingPanelRefresh");
const profileViewPanel = document.getElementById("profileViewPanel");
const profileViewTitle = document.getElementById("profileViewTitle");
const profileViewMeta = document.getElementById("profileViewMeta");
@@ -126,6 +135,7 @@ const profileCancelBtn = document.getElementById("profileCancelBtn");
const chatTitle = document.getElementById("chatTitle");
const chatMeta = document.getElementById("chatMeta");
+const chatContextSelectEl = document.getElementById("chatContextSelect");
const chatBackToListBtn = document.getElementById("chatBackToList");
const chatMessagesEl = document.getElementById("chatMessages");
const typingIndicator = document.getElementById("typingIndicator");
@@ -215,6 +225,19 @@ let windowFocused = true;
let typingStopTimer = null;
let lastTypingSentAt = 0;
let modTab = "reports";
+let onboardingViewerTab = "about";
+let onboardingAdminTab = "about";
+let onboardingAdminDraft = {
+ enabled: true,
+ aboutContent: "",
+ requireAcceptance: false,
+ blockReadUntilAccepted: false,
+ roleSelectEnabled: true,
+ selfAssignableRoleIds: [],
+ rules: [],
+};
+let onboardingAdminDraftStamp = "";
+const onboardingAdminExpandedRuleIds = new Set();
let modReports = [];
let modUsers = [];
let modLog = [];
@@ -260,6 +283,10 @@ let dmThreadsById = new Map();
const dmMessagesByThreadId = new Map();
let activeDmThreadId = null;
let pendingOpenDmThreadId = "";
+const CHAT_RECENTS_LIMIT = 24;
+let recentHiveChatIds = [];
+let recentDmChatThreadIds = [];
+let syncingChatContextSelect = false;
let walkieRecording = false;
let walkieStartAt = 0;
let walkieRecorder = null;
@@ -309,7 +336,7 @@ const WORKSPACE_EXPANDED_DISPLACED_KEY = "bzl_workspace_expandedDisplaced";
/** @type {RackLayoutState} */
let rackLayoutState = {
version: 2,
- presetId: "discordLike",
+ presetId: "onboardingDefault",
docked: { bottom: [] },
racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
};
@@ -458,7 +485,7 @@ function normalizeDeviceLayout(raw) {
function detectViewportSize() {
const w = Math.max(1, Number(window.innerWidth) || 1);
const h = Math.max(1, Number(window.innerHeight) || 1);
- // Keep this intentionally simple: we mostly care about “can we fit columns sanely?”
+ // Keep this intentionally simple: we mostly care about "can we fit columns sanely?"
// Consider both width and height so low-res (ex: 1280x720) can auto-compact.
if (w <= 1100 || h <= 720) return "xs";
if (w <= 1400 || h <= 820) return "sm";
@@ -678,6 +705,7 @@ function togglePanelSkinny(panelId) {
registerCorePanel({ id: "chat", title: "Chat", icon: "💬", role: "primary", defaultRack: "main", element: chatPanelEl });
registerCorePanel({ id: "hives", title: "Hives", icon: "🐝", role: "primary", defaultRack: "main", element: hivesPanelEl });
+registerCorePanel({ id: "onboarding", title: "Onboarding", icon: "🧭", role: "primary", defaultRack: "main", element: onboardingPanelEl });
registerCorePanel({ id: "people", title: "People", icon: "👥", role: "aux", defaultRack: "right", element: peopleDrawerEl });
registerCorePanel({ id: "moderation", title: "Moderation", icon: "🛡️", role: "aux", defaultRack: "right", element: modPanelEl });
registerCorePanel({ id: "profile", title: "Profile", icon: "👤", role: "transient", defaultRack: "main", element: profileViewPanel });
@@ -747,7 +775,7 @@ function ensurePluginRackPanel() {
}
// 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 });
+ 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();
@@ -860,6 +888,17 @@ window.__bzlPanels = { panelRegistry };
const PRESET_DEFS = {
// Presets are hard-applied (exact placement). Anything not explicitly placed starts in the hotbar.
// Workspace uses two full-height primary slots (left + right). No vertical splits.
+ onboardingDefault: {
+ presetId: "onboardingDefault",
+ label: "Onboarding (Default)",
+ group: "user",
+ workspaceLeftOrder: ["onboarding"],
+ workspaceRightOrder: ["hives"],
+ sideOrder: ["chat", "profile", "composer"],
+ sideCollapsed: false,
+ rightOrder: ["people"],
+ dockBottom: ["pluginRack", "maps", "library"],
+ },
social: {
presetId: "social",
label: "Default (Social)",
@@ -984,6 +1023,7 @@ const PRESET_DEFS = {
const PRESET_ALIASES = {
// Back-compat for older preset ids.
discordLike: "social",
+ onboarding: "onboardingDefault",
chat: "chatFocus",
browsing: "browse",
maps: "mapsSession",
@@ -995,12 +1035,12 @@ const PRESET_ALIASES = {
function resolvePresetKey(presetId) {
const raw = String(presetId || "").trim();
const mapped = Object.prototype.hasOwnProperty.call(PRESET_ALIASES, raw) ? PRESET_ALIASES[raw] : raw;
- return Object.prototype.hasOwnProperty.call(PRESET_DEFS, mapped) ? mapped : "social";
+ return Object.prototype.hasOwnProperty.call(PRESET_DEFS, mapped) ? mapped : "onboardingDefault";
}
function updateLayoutPresetOptions() {
if (!layoutPresetEl) return;
- const current = resolvePresetKey(rackLayoutState?.presetId || layoutPresetEl.value || "social");
+ const current = resolvePresetKey(rackLayoutState?.presetId || layoutPresetEl.value || "onboardingDefault");
const defs = Object.values(PRESET_DEFS).filter((d) => d && typeof d === "object");
const userDefs = defs.filter((d) => d.group === "user");
@@ -1027,8 +1067,8 @@ function updateLayoutPresetOptions() {
layoutPresetEl.appendChild(modGroup);
}
- const nextValue = canModerate ? current : (PRESET_DEFS[current]?.modOnly ? "social" : current);
- layoutPresetEl.value = Object.prototype.hasOwnProperty.call(PRESET_DEFS, nextValue) ? nextValue : "social";
+ const nextValue = canModerate ? current : (PRESET_DEFS[current]?.modOnly ? "onboardingDefault" : current);
+ layoutPresetEl.value = Object.prototype.hasOwnProperty.call(PRESET_DEFS, nextValue) ? nextValue : "onboardingDefault";
}
function readRackLayoutEnabled() {
@@ -1065,7 +1105,7 @@ function loadRackLayoutState() {
if (!raw)
return {
version: 2,
- presetId: "discordLike",
+ presetId: "onboardingDefault",
docked: { bottom: [] },
racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
pluginRackWidgets: [],
@@ -1075,7 +1115,7 @@ function loadRackLayoutState() {
if (!parsed || parsed.version !== 2)
return {
version: 2,
- presetId: "discordLike",
+ presetId: "onboardingDefault",
docked: { bottom: [] },
racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
pluginRackWidgets: [],
@@ -1085,7 +1125,7 @@ function loadRackLayoutState() {
const pluginRackWidgets = Array.isArray(parsed?.pluginRackWidgets)
? parsed.pluginRackWidgets.map((x) => String(x || "")).filter(Boolean)
: [];
- const presetId = typeof parsed?.presetId === "string" ? parsed.presetId : "discordLike";
+ const presetId = typeof parsed?.presetId === "string" ? parsed.presetId : "onboardingDefault";
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) : [];
const side = Array.isArray(parsed?.racks?.side) ? parsed.racks.side.map((x) => String(x || "")).filter(Boolean) : [];
@@ -1102,7 +1142,7 @@ function loadRackLayoutState() {
} catch {
return {
version: 2,
- presetId: "discordLike",
+ presetId: "onboardingDefault",
docked: { bottom: [] },
racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] },
pluginRackWidgets: [],
@@ -1194,7 +1234,7 @@ function panelCanExpand(panelId) {
// 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", "dice"]);
+const SKINNY_CAPABLE_PANELS = new Set(["people", "profile", "composer", "chat", "pluginRack", "dice"]);
function panelIsSkinnyCapable(panelId) {
const id = String(panelId || "").trim();
@@ -1359,7 +1399,7 @@ function renderHotbar() {
const plusHtml = includePlus
? `
<button type="button" class="dockOrb dockOrbPlus" data-hotbarplus="1" title="Add panel">
- <span class="dockOrbIcon" aria-hidden="true">+</span>
+ <span class="dockOrbIcon" aria-hidden="true">+</span>
<span>Add</span>
</button>
`
@@ -1401,7 +1441,7 @@ function openHotbarPlusMenu(anchorEl) {
const menu = document.createElement("div");
menu.className = "hotbarAddMenu";
menu.innerHTML = `
- <div class="small muted" style="padding:6px 8px 4px;">New chat panel for…</div>
+ <div class="small muted" style="padding:6px 8px 4px;">New chat panel for...</div>
<div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No hives yet.</div>`}</div>
`;
@@ -1546,6 +1586,15 @@ function enforceWorkspaceRules() {
enforceSkinny(side);
enforceSkinny(rightRack);
+ // Side rack can stack, but keep it compact: at most 2 visible panels.
+ const sideKids = Array.from(side.querySelectorAll(":scope > .rackPanel:not(.hidden)"));
+ if (sideKids.length > 2) {
+ for (const extra of sideKids.slice(2)) {
+ const id = String(extra?.dataset?.panelId || "").trim();
+ if (id) dockPanel(id);
+ }
+ }
+
// Right rack is single-slot: keep at most one visible panel.
const rightKids = Array.from(rightRack.querySelectorAll(":scope > .rackPanel:not(.hidden)"));
if (rightKids.length > 1) {
@@ -1764,6 +1813,11 @@ function enableRackLayoutDom() {
// Mark center panels as rack panels too (they already live in mainRack in normal DOM).
if (main) {
+ if (onboardingPanelEl) {
+ mark(onboardingPanelEl, "onboarding");
+ if (left && onboardingPanelEl.parentElement !== left) left.appendChild(onboardingPanelEl);
+ onboardingPanelEl.classList.remove("hidden");
+ }
if (hivesPanelEl) {
mark(hivesPanelEl, "hives");
if (left && hivesPanelEl.parentElement !== left) left.appendChild(hivesPanelEl);
@@ -1807,7 +1861,7 @@ function applyPreset(presetId) {
const def = PRESET_DEFS[key];
if (!def) return;
if (def.modOnly && !canModerate) {
- applyPreset("social");
+ applyPreset("onboardingDefault");
return;
}
@@ -1916,7 +1970,7 @@ function installPanelMinimizeButtons() {
const drag = document.createElement("button");
drag.type = "button";
drag.className = "ghost smallBtn rackDragHandle";
- drag.textContent = "☰";
+ drag.textContent = "≡";
drag.title = "Drag to reorder";
drag.setAttribute("data-rackdrag", panelId);
row.appendChild(drag);
@@ -1926,18 +1980,21 @@ function installPanelMinimizeButtons() {
const skinny = document.createElement("button");
skinny.type = "button";
skinny.className = "ghost smallBtn";
- skinny.textContent = "][";
+ skinny.textContent = "↔";
skinny.title = "Toggle skinny/full";
skinny.setAttribute("data-skinny", panelId);
skinny.onclick = () => togglePanelSkinny(panelId);
row.appendChild(skinny);
}
+ if (!panelIsSkinnyCapable(panelId)) {
+ headerEl.querySelector(`[data-skinny="${cssEscape(panelId)}"]`)?.remove();
+ }
if (panelCanExpand(panelId) && !headerEl.querySelector(`[data-expand="${panelId}"]`)) {
const expand = document.createElement("button");
expand.type = "button";
expand.className = "ghost smallBtn";
- expand.textContent = "[]";
+ expand.textContent = "□";
expand.title = "Expand workspace";
expand.setAttribute("data-expand", panelId);
expand.onclick = () => togglePrimaryExpand(panelId);
@@ -1948,7 +2005,7 @@ function installPanelMinimizeButtons() {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "ghost smallBtn";
- btn.textContent = "—";
+ btn.textContent = "-";
btn.title = "Minimize to hotbar";
btn.setAttribute("data-minimize", panelId);
btn.onclick = () => dockPanel(panelId);
@@ -1999,8 +2056,8 @@ function ensurePluginPanelShell(panelId, title, icon, defaultRack, role) {
<div class="panelHeader">
<div class="panelTitle">${escapeHtml(title || panelId)}</div>
<div class="row">
- <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">☰</button>
- <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">—</button>
+ <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">≡</button>
+ <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">-</button>
</div>
</div>
<div class="panelBody" data-pluginmount="1"></div>
@@ -2013,7 +2070,7 @@ function ensurePluginPanelShell(panelId, title, icon, defaultRack, role) {
const expand = document.createElement("button");
expand.type = "button";
expand.className = "ghost smallBtn";
- expand.textContent = "[]";
+ expand.textContent = "□";
expand.title = "Expand workspace";
expand.setAttribute("data-expand", panelId);
expand.addEventListener("click", () => togglePrimaryExpand(panelId));
@@ -2048,10 +2105,10 @@ function ensureChatPostPanelInstance(postId, opts) {
<div class="small muted chatMeta"></div>
</div>
<div class="row">
- <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">☰</button>
- <button type="button" class="ghost smallBtn" data-skinny="${escapeHtml(panelId)}" title="Toggle skinny/full">][</button>
- <button type="button" class="ghost smallBtn" data-expand="${escapeHtml(panelId)}" title="Expand workspace">[]</button>
- <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">—</button>
+ <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">≡</button>
+ <button type="button" class="ghost smallBtn" data-skinny="${escapeHtml(panelId)}" title="Toggle skinny/full">↔</button>
+ <button type="button" class="ghost smallBtn" data-expand="${escapeHtml(panelId)}" title="Expand workspace">□</button>
+ <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">-</button>
</div>
</div>
<div class="chatMessages"></div>
@@ -2196,7 +2253,7 @@ function renderTypingIndicatorForPost(postId, targetEl) {
}
const names = Array.from(set.values()).slice(0, 3);
const more = set.size > names.length ? ` +${set.size - names.length}` : "";
- targetEl.textContent = `${names.map((u) => `@${u}`).join(", ")}${more} typing…`;
+ targetEl.textContent = `${names.map((u) => `@${u}`).join(", ")}${more} typing...`;
}
function renderChatPostPanelInstance(panelId, forceScroll) {
@@ -2341,6 +2398,48 @@ function renderChatInstancesForPost(postId) {
}
}
+function setChatInstancePanelPost(panelId, postId, forceScroll = true) {
+ const pid = String(postId || "").trim();
+ const id = String(panelId || "").trim();
+ if (!pid || !id) return false;
+ const inst = chatPanelInstances.get(id);
+ if (!inst) return false;
+ const post = posts.get(pid);
+ if (!post) return false;
+ inst.postId = pid;
+ chatPanelInstances.set(id, inst);
+ const root = getPanelElement(id);
+ const titleEl = root?.querySelector?.(".panelTitle");
+ if (titleEl) titleEl.textContent = post?.title ? `Chat: ${String(post.title).slice(0, 32)}` : "Chat";
+ renderChatPostPanelInstance(id, forceScroll);
+ return true;
+}
+
+function nearestVisibleChatInstancePanelId(sourceEl) {
+ const anchor = sourceEl instanceof HTMLElement ? sourceEl : null;
+ if (!anchor) return "";
+ const anchorRect = anchor.getBoundingClientRect();
+ const ax = anchorRect.left + anchorRect.width / 2;
+ const ay = anchorRect.top + anchorRect.height / 2;
+ let bestId = "";
+ let bestDist = Number.POSITIVE_INFINITY;
+ for (const [panelId] of chatPanelInstances.entries()) {
+ const root = getPanelElement(panelId);
+ if (!(root instanceof HTMLElement)) continue;
+ if (root.classList.contains("hidden")) continue;
+ const rect = root.getBoundingClientRect();
+ if (rect.width <= 1 || rect.height <= 1) continue;
+ const cx = rect.left + rect.width / 2;
+ const cy = rect.top + rect.height / 2;
+ const dist = Math.hypot(cx - ax, cy - ay);
+ if (dist < bestDist) {
+ bestDist = dist;
+ bestId = panelId;
+ }
+ }
+ return bestId;
+}
+
function applyPluginPresetHint(panelDef) {
if (!rackLayoutEnabled) return;
const id = String(panelDef?.id || "").trim();
@@ -2605,11 +2704,11 @@ function initRackLayout() {
if (layoutPresetEl) {
updateLayoutPresetOptions();
- layoutPresetEl.value = resolvePresetKey(rackLayoutState.presetId || "social");
+ layoutPresetEl.value = resolvePresetKey(rackLayoutState.presetId || "onboardingDefault");
layoutPresetEl.disabled = !rackLayoutEnabled;
layoutPresetEl.onchange = () => {
if (!rackLayoutEnabled) return;
- const next = String(layoutPresetEl.value || "social");
+ const next = String(layoutPresetEl.value || "onboardingDefault");
applyPreset(next);
};
}
@@ -2669,6 +2768,15 @@ function initRackLayout() {
setRightCollapsed(readBoolPref(RACK_RIGHT_COLLAPSED_KEY, false), { persist: false });
applyRackStateToDom();
+ const hasOnboardingPlacement =
+ (Array.isArray(rackLayoutState?.racks?.workspaceLeft) && rackLayoutState.racks.workspaceLeft.includes("onboarding")) ||
+ (Array.isArray(rackLayoutState?.racks?.workspaceRight) && rackLayoutState.racks.workspaceRight.includes("onboarding")) ||
+ (Array.isArray(rackLayoutState?.racks?.side) && rackLayoutState.racks.side.includes("onboarding")) ||
+ (Array.isArray(rackLayoutState?.racks?.right) && rackLayoutState.racks.right.includes("onboarding")) ||
+ (Array.isArray(rackLayoutState?.docked?.bottom) && rackLayoutState.docked.bottom.includes("onboarding"));
+ if ((rackLayoutState?.presetId || "") === "onboardingDefault" && !hasOnboardingPlacement) {
+ applyPreset("onboardingDefault");
+ }
installPanelMinimizeButtons();
enableRackDnD();
installWorkspaceInteractions();
@@ -2914,7 +3022,7 @@ function initRackLayout() {
// First enable: seed state from the selected preset so users immediately get a sensible layout.
if (!hadState) {
- const preset = resolvePresetKey(rackLayoutState.presetId || (layoutPresetEl ? String(layoutPresetEl.value || "") : "") || "social");
+ const preset = resolvePresetKey(rackLayoutState.presetId || (layoutPresetEl ? String(layoutPresetEl.value || "") : "") || "onboardingDefault");
applyPreset(preset);
}
@@ -2956,8 +3064,31 @@ function readStayConnectedPref() {
function writeStayConnectedPref(on) {
writeBoolPref(STAY_CONNECTED_KEY, Boolean(on));
}
+const ENABLE_HINTS_KEY = "bzl_enableHints";
+function readHintsEnabledPref() {
+ const raw = localStorage.getItem(ENABLE_HINTS_KEY);
+ if (raw == null) return true;
+ return raw !== "0";
+}
+function writeHintsEnabledPref(on) {
+ const enabled = Boolean(on);
+ localStorage.setItem(ENABLE_HINTS_KEY, enabled ? "1" : "0");
+ appRoot?.classList.toggle("hintsEnabled", enabled);
+}
let instanceBranding = { title: "Bzl", subtitle: "Ephemeral hives + chat", allowMemberPermanentPosts: false, appearance: {} };
+let onboardingState = {
+ enabled: true,
+ rulesVersion: 1,
+ requireAcceptance: false,
+ blockReadUntilAccepted: false,
+ acceptedRulesVersion: 0,
+ acceptedAt: 0,
+ tutorialVersion: 1,
+ tutorialCompletedVersion: 0,
+ selectedRoleIds: [],
+ needsAcceptance: false,
+};
let serverInfo = null;
let serverHealth = null;
let serverInfoStatus = { loading: false, at: 0, error: "" };
@@ -3155,14 +3286,79 @@ function normalizeInstanceBranding(raw) {
const mutedPct = clampPct(appearanceRaw.mutedPct, 65);
const linePct = clampPct(appearanceRaw.linePct, 10);
const panel2Pct = clampPct(appearanceRaw.panel2Pct, 2);
+ const onboardingRaw = raw?.onboarding && typeof raw.onboarding === "object" ? raw.onboarding : {};
+ const aboutRaw = onboardingRaw.about && typeof onboardingRaw.about === "object" ? onboardingRaw.about : {};
+ const rulesRaw = onboardingRaw.rules && typeof onboardingRaw.rules === "object" ? onboardingRaw.rules : {};
+ const roleSelectRaw = onboardingRaw.roleSelect && typeof onboardingRaw.roleSelect === "object" ? onboardingRaw.roleSelect : {};
+ const tutorialRaw = onboardingRaw.tutorial && typeof onboardingRaw.tutorial === "object" ? onboardingRaw.tutorial : {};
+ const ruleItems = Array.isArray(rulesRaw.items)
+ ? rulesRaw.items
+ .map((r, idx) => ({
+ id: String(r?.id || `r${idx + 1}`).trim().slice(0, 40),
+ order: Number.isFinite(Number(r?.order)) ? Math.max(1, Math.floor(Number(r.order))) : idx + 1,
+ name: String(r?.name || "").trim().slice(0, 60),
+ shortDescription: String(r?.shortDescription || "").trim().slice(0, 180),
+ description: typeof r?.description === "string" ? r.description : "",
+ severity: ["info", "warn", "critical"].includes(String(r?.severity || "").trim().toLowerCase())
+ ? String(r.severity).trim().toLowerCase()
+ : "info",
+ }))
+ .filter((r) => r.id)
+ .slice(0, 200)
+ .sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || String(a.id || "").localeCompare(String(b.id || "")))
+ : [];
return {
title: title || "Bzl",
subtitle: subtitle || "Ephemeral hives + chat",
allowMemberPermanentPosts,
+ onboarding: {
+ enabled: Object.prototype.hasOwnProperty.call(onboardingRaw, "enabled") ? Boolean(onboardingRaw.enabled) : true,
+ about: {
+ content: typeof aboutRaw.content === "string" ? aboutRaw.content : "",
+ updatedAt: Number(aboutRaw.updatedAt || 0) || 0,
+ updatedBy: String(aboutRaw.updatedBy || "").trim().toLowerCase(),
+ },
+ rules: {
+ version: Math.max(1, Math.floor(Number(rulesRaw.version || 1))),
+ requireAcceptance: Boolean(rulesRaw.requireAcceptance),
+ blockReadUntilAccepted: Boolean(rulesRaw.blockReadUntilAccepted),
+ items: ruleItems,
+ },
+ roleSelect: {
+ enabled: Object.prototype.hasOwnProperty.call(roleSelectRaw, "enabled") ? Boolean(roleSelectRaw.enabled) : true,
+ selfAssignableRoleIds: Array.isArray(roleSelectRaw.selfAssignableRoleIds)
+ ? roleSelectRaw.selfAssignableRoleIds.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean).slice(0, 64)
+ : [],
+ },
+ tutorial: {
+ enabled: Object.prototype.hasOwnProperty.call(tutorialRaw, "enabled") ? Boolean(tutorialRaw.enabled) : true,
+ version: Math.max(1, Math.floor(Number(tutorialRaw.version || 1))),
+ },
+ },
appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct },
};
}
+function normalizeOnboardingState(raw) {
+ const src = raw && typeof raw === "object" ? raw : {};
+ return {
+ enabled: Object.prototype.hasOwnProperty.call(src, "enabled") ? Boolean(src.enabled) : Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.enabled),
+ rulesVersion: Math.max(1, Math.floor(Number(src.rulesVersion || normalizeInstanceBranding(instanceBranding).onboarding?.rules?.version || 1))),
+ requireAcceptance: Object.prototype.hasOwnProperty.call(src, "requireAcceptance")
+ ? Boolean(src.requireAcceptance)
+ : Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.rules?.requireAcceptance),
+ blockReadUntilAccepted: Object.prototype.hasOwnProperty.call(src, "blockReadUntilAccepted")
+ ? Boolean(src.blockReadUntilAccepted)
+ : Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.rules?.blockReadUntilAccepted),
+ acceptedRulesVersion: Math.max(0, Math.floor(Number(src.acceptedRulesVersion || 0))),
+ acceptedAt: Number(src.acceptedAt || 0) || 0,
+ tutorialVersion: Math.max(1, Math.floor(Number(src.tutorialVersion || normalizeInstanceBranding(instanceBranding).onboarding?.tutorial?.version || 1))),
+ tutorialCompletedVersion: Math.max(0, Math.floor(Number(src.tutorialCompletedVersion || 0))),
+ selectedRoleIds: Array.isArray(src.selectedRoleIds) ? src.selectedRoleIds.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean).slice(0, 64) : [],
+ needsAcceptance: Boolean(src.needsAcceptance),
+ };
+}
+
function applyInstanceAppearance(appearanceOverride = null) {
const b = normalizeInstanceBranding(appearanceOverride ? { ...instanceBranding, appearance: appearanceOverride } : instanceBranding);
const a = b.appearance || {};
@@ -3250,6 +3446,7 @@ function normalizeDmMessage(raw) {
return {
id,
fromUser: String(raw.fromUser || raw.from || "").trim().toLowerCase(),
+ asMod: Boolean(raw.asMod) || String(raw.fromUser || raw.from || "").trim().toLowerCase() === "mod",
createdAt: Number(raw.createdAt || 0),
text: typeof raw.text === "string" ? raw.text : "",
html: typeof raw.html === "string" ? raw.html : "",
@@ -3261,6 +3458,91 @@ function dmActivityAt(thread) {
return Math.max(Number(thread.lastMessageAt || 0), Number(thread.updatedAt || 0), Number(thread.createdAt || 0));
}
+function pushRecentUnique(list, id, limit = CHAT_RECENTS_LIMIT) {
+ const value = String(id || "").trim();
+ if (!value) return list;
+ const next = [value, ...list.filter((x) => x !== value)];
+ if (next.length > limit) next.length = limit;
+ return next;
+}
+
+function touchRecentHiveChat(postId) {
+ const id = String(postId || "").trim();
+ if (!id) return;
+ recentHiveChatIds = pushRecentUnique(recentHiveChatIds, id);
+}
+
+function touchRecentDmChat(threadId) {
+ const id = String(threadId || "").trim();
+ if (!id) return;
+ recentDmChatThreadIds = pushRecentUnique(recentDmChatThreadIds, id);
+}
+
+function activeDmThreadsSorted() {
+ return dmThreads
+ .filter((t) => t && String(t.status || "") === "active")
+ .sort((a, b) => dmActivityAt(b) - dmActivityAt(a));
+}
+
+function renderChatContextSelect() {
+ if (!(chatContextSelectEl instanceof HTMLSelectElement)) return;
+ const dmThreadsActive = activeDmThreadsSorted();
+ const dmById = new Map(dmThreadsActive.map((t) => [t.id, t]));
+ recentDmChatThreadIds = recentDmChatThreadIds.filter((id) => dmById.has(id));
+ const dmRecent = [activeDmThreadId, ...recentDmChatThreadIds]
+ .map((id) => dmById.get(String(id || "")))
+ .filter(Boolean)
+ .filter((t, i, arr) => arr.findIndex((x) => x.id === t.id) === i);
+
+ const postsById = new Map(Array.from(posts.values()).map((p) => [String(p.id), p]));
+ const openPanelPostIds = Array.from(chatPanelInstances.values())
+ .map((inst) => String(inst?.postId || "").trim())
+ .filter(Boolean);
+ recentHiveChatIds = recentHiveChatIds.filter((id) => {
+ const p = postsById.get(String(id));
+ return Boolean(p && !p.deleted);
+ });
+ const postRecent = [activeChatPostId, ...openPanelPostIds, ...recentHiveChatIds]
+ .map((id) => postsById.get(String(id || "")))
+ .filter((p) => p && !p.deleted)
+ .filter((p, i, arr) => arr.findIndex((x) => String(x.id) === String(p.id)) === i);
+
+ const hasAny = Boolean(dmRecent.length || postRecent.length || activeDmThreadId || activeChatPostId);
+ if (!hasAny) {
+ chatContextSelectEl.classList.add("hidden");
+ chatContextSelectEl.innerHTML = "";
+ return;
+ }
+
+ const activeDmValue = activeDmThreadId ? `dm:${activeDmThreadId}` : "";
+ const activePostValue = activeChatPostId ? `post:${activeChatPostId}` : "";
+ const selected = activeDmValue || activePostValue || "";
+
+ const dmOptions = dmRecent
+ .map((t) => {
+ const other = `@${escapeHtml(t.other || "unknown")}`;
+ return `<option value="dm:${escapeHtml(t.id)}">${other}</option>`;
+ })
+ .join("");
+
+ const postOptions = postRecent
+ .map((p) => {
+ const label = `${escapeHtml(postTitle(p))}${p.author ? ` - @${escapeHtml(String(p.author || ""))}` : ""}`;
+ return `<option value="post:${escapeHtml(String(p.id))}">${label}</option>`;
+ })
+ .join("");
+
+ const topPlaceholder = `<option value="">Open chats...</option>`;
+ const dmGroup = dmOptions ? `<optgroup label="DMs">${dmOptions}</optgroup>` : "";
+ const postGroup = postOptions ? `<optgroup label="Hive Chats">${postOptions}</optgroup>` : "";
+
+ syncingChatContextSelect = true;
+ chatContextSelectEl.classList.remove("hidden");
+ chatContextSelectEl.innerHTML = `${topPlaceholder}${dmGroup}${postGroup}`;
+ chatContextSelectEl.value = selected && chatContextSelectEl.querySelector(`option[value="${cssEscape(selected)}"]`) ? selected : "";
+ syncingChatContextSelect = false;
+}
+
function setDmThreads(list) {
dmThreads = Array.isArray(list) ? list.map(normalizeDmThread).filter(Boolean) : [];
dmThreadsById = new Map(dmThreads.map((t) => [t.id, t]));
@@ -4133,7 +4415,9 @@ function isMobileScreenMode() {
function loadMobileLayout() {
const defaults = () => {
const pinned = ["account", "hives", "chat", "people", "profile"];
- return { version: 1, pinned, active: pinned[0] || "account", history: [], tools: { composerOpen: false, profileOpen: false, pluginRackOpen: false } };
+ const onboardingEnabled = Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.enabled);
+ const active = onboardingEnabled ? "onboarding" : pinned[0] || "account";
+ return { version: 1, pinned, active, history: [], tools: { composerOpen: false, profileOpen: false, pluginRackOpen: false } };
};
const sanitizeId = (id) => {
const raw = String(id || "")
@@ -4144,7 +4428,7 @@ function loadMobileLayout() {
if (raw === "mod") return canModerate ? "moderation" : "";
if (raw === "sidebar") return "account";
if (raw === "main" || raw === "workspace") return "hives";
- if (raw === "account" || raw === "hives" || raw === "chat" || raw === "people" || raw === "profile") return raw;
+ if (raw === "account" || raw === "hives" || raw === "chat" || raw === "people" || raw === "profile" || raw === "onboarding") return raw;
if (raw === "moderation") return canModerate ? "moderation" : "";
if (panelRegistry.has(raw)) return raw;
return "";
@@ -4177,6 +4461,7 @@ function saveMobileLayout(layout) {
function availableMobileScreens() {
const out = [];
out.push({ id: "account", title: "Account", core: true });
+ if (Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.enabled)) out.push({ id: "onboarding", title: "Onboarding", core: true });
out.push({ id: "hives", title: "Hives", core: true });
out.push({ id: "chat", title: "Chat", core: true });
out.push({ id: "people", title: "People", core: true });
@@ -4212,8 +4497,9 @@ function mobileScreenFromLegacyPanel(next) {
if (raw === "chat") return "chat";
if (raw === "people") return "people";
if (raw === "profile") return "profile";
+ if (raw === "onboarding") return "onboarding";
if (raw === "moderation" || raw === "mod") return canModerate ? "moderation" : "hives";
- if (raw === "hives" || raw === "account" || raw === "people" || raw === "profile" || raw === "moderation") return raw;
+ if (raw === "hives" || raw === "account" || raw === "people" || raw === "profile" || raw === "onboarding" || raw === "moderation") return raw;
// Plugin panel id can be treated as a screen.
if (panelRegistry.has(raw)) return raw;
return "hives";
@@ -4394,6 +4680,10 @@ function hostHivesInMobileScreen() {
function setMobileScreen(screenId, { pushHistory = true } = {}) {
if (!appRoot) return;
const screen = mobileScreenFromLegacyPanel(screenId);
+ if (onboardingNeedsAcceptanceNow() && screen !== "onboarding" && screen !== "account") {
+ setMobileScreen("onboarding", { pushHistory: false });
+ return;
+ }
const nextIsMore = screen === "more";
if (nextIsMore) {
setMobileMoreOpen(true);
@@ -4447,6 +4737,12 @@ function setMobileScreen(screenId, { pushHistory = true } = {}) {
return;
}
+ if (screen === "onboarding") {
+ const hosted = hostPanelInMobileScreen("onboarding");
+ appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives");
+ return;
+ }
+
if (screen === "hives") {
const hosted = hostHivesInMobileScreen();
appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives");
@@ -4496,7 +4792,7 @@ function applyMobileMode() {
const current = String(appRoot.getAttribute("data-mobile-screen") || "").trim();
if (!wasMobile || !current) {
const layout = loadMobileLayout();
- const desired = mobileScreenFromLegacyPanel(layout.active || "hives");
+ const desired = onboardingNeedsAcceptanceNow() ? "onboarding" : mobileScreenFromLegacyPanel(layout.active || "hives");
setMobileScreen(desired, { pushHistory: false });
}
renderMobileNav();
@@ -4514,8 +4810,8 @@ function applyMobileMode() {
function shiftMobilePanel(delta) {
if (!isMobileScreenMode()) return;
const order = canModerate
- ? ["account", "hives", "chat", "people", "profile", "moderation"]
- : ["account", "hives", "chat", "people", "profile"];
+ ? ["account", "onboarding", "hives", "chat", "people", "profile", "moderation"]
+ : ["account", "onboarding", "hives", "chat", "people", "profile"];
const current = mobileScreenFromLegacyPanel(appRoot?.getAttribute("data-mobile-screen") || "hives");
const idx = order.indexOf(current);
const at = idx >= 0 ? idx : 0;
@@ -5227,7 +5523,7 @@ function isOwnerUser() {
function renderPluginsAdminHtml() {
if (!isOwnerUser()) return `<div class="muted small">Owner only.</div>`;
const status = pluginAdminStatus ? `<div class="small muted">${escapeHtml(pluginAdminStatus)}</div>` : "";
- const busyLine = pluginAdminBusy ? `<div class="small muted">Working…</div>` : "";
+ const busyLine = pluginAdminBusy ? `<div class="small muted">Working...</div>` : "";
const listHtml = !plugins.length
? `<div class="muted small">No plugins installed yet.</div>`
: plugins
@@ -5688,7 +5984,7 @@ function renderFeed() {
);
if (list.length === 0) {
- feedEl.innerHTML = `<div class="small muted">No active posts in this view/filter.</div>`;
+ feedEl.innerHTML = `<div class="small muted">No active posts in this view/filter.</div><div class="uiHint">Tap <b>New Hive</b> to create one, or clear filters to widen results.</div>`;
return;
}
@@ -5802,24 +6098,265 @@ function isMobileChatScreenActive() {
}
function renderMobileChatListHtml() {
- const list = sortPosts(Array.from(posts.values()))
- .filter((p) => p && !p.deleted)
+ const dmActive = activeDmThreadsSorted().slice(0, 30);
+ const recentPostIds = recentHiveChatIds.slice(0, 24);
+ const recentPosts = recentPostIds.map((id) => posts.get(id)).filter((p) => p && !p.deleted);
+ const recentPostIdSet = new Set(recentPosts.map((p) => String(p.id)));
+ const availablePosts = sortPosts(Array.from(posts.values()))
+ .filter((p) => p && !p.deleted && !recentPostIdSet.has(String(p.id)))
.slice(0, 60);
- if (!list.length) {
- return `<div class="small muted">No active hives available for chat.</div>`;
+
+ if (!dmActive.length && !recentPosts.length && !availablePosts.length) {
+ return `<div class="small muted">No active hives available for chat.</div><div class="uiHint">Create a hive in Hives first, then return here to chat.</div>`;
}
- return `<div class="mobileChatList">${list
- .map((p) => {
- const title = escapeHtml(postTitle(p));
- const author = p.author ? `@${escapeHtml(String(p.author || ""))}` : "anon";
- const exp = formatCountdown(p.expiresAt);
- const lock = p.locked ? " · locked" : "";
- return `<button type="button" class="ghost mobileChatListItem" data-mobilechatopen="${escapeHtml(p.id)}">
- <span class="mobileChatListTop">${title}</span>
- <span class="mobileChatListMeta">${author} · ${escapeHtml(exp)}${lock}</span>
- </button>`;
- })
- .join("")}</div>`;
+
+ const dmSection = dmActive.length
+ ? `<div class="mobileChatSection">
+ <div class="small muted">DMs</div>
+ ${dmActive
+ .map((t) => {
+ const who = `@${escapeHtml(String(t.other || "unknown"))}`;
+ const when = dmActivityAt(t) ? new Date(dmActivityAt(t)).toLocaleTimeString() : "active";
+ return `<button type="button" class="ghost mobileChatListItem" data-dmopen="${escapeHtml(t.id)}">
+ <span class="mobileChatListTop">${who}</span>
+ <span class="mobileChatListMeta">private chat · ${escapeHtml(when)}</span>
+ </button>`;
+ })
+ .join("")}
+ </div>`
+ : "";
+
+ const postItem = (p) => {
+ const title = escapeHtml(postTitle(p));
+ const author = p.author ? `@${escapeHtml(String(p.author || ""))}` : "anon";
+ const exp = formatCountdown(p.expiresAt);
+ const lock = p.locked ? " · locked" : "";
+ return `<button type="button" class="ghost mobileChatListItem" data-mobilechatopen="${escapeHtml(p.id)}">
+ <span class="mobileChatListTop">${title}</span>
+ <span class="mobileChatListMeta">${author} · ${escapeHtml(exp)}${lock}</span>
+ </button>`;
+ };
+
+ const recentSection = recentPosts.length
+ ? `<div class="mobileChatSection">
+ <div class="small muted">Recent Hive Chats</div>
+ ${recentPosts.map(postItem).join("")}
+ </div>`
+ : "";
+
+ const hivesSection = availablePosts.length
+ ? `<div class="mobileChatSection">
+ <div class="small muted">Available Hives</div>
+ ${availablePosts.map(postItem).join("")}
+ </div>`
+ : "";
+
+ return `<div class="mobileChatList">${dmSection}${recentSection}${hivesSection}</div>`;
+}
+
+function onboardingRequiresAcceptance() {
+ return Boolean(onboardingState.enabled && onboardingState.requireAcceptance);
+}
+
+function onboardingNeedsAcceptanceNow() {
+ if (!onboardingRequiresAcceptance()) return false;
+ return Boolean(onboardingState.needsAcceptance || Number(onboardingState.acceptedRulesVersion || 0) < Number(onboardingState.rulesVersion || 1));
+}
+
+function onboardingSeverityLabel(severity) {
+ const s = String(severity || "").toLowerCase();
+ if (s === "critical") return "Critical";
+ if (s === "warn") return "Warn";
+ return "Info";
+}
+
+function onboardingSeverityBadge(severity) {
+ const s = String(severity || "info").toLowerCase();
+ const cls = s === "critical" ? "onbSeverityCritical" : s === "warn" ? "onbSeverityWarn" : "onbSeverityInfo";
+ return `<span class="tag ${cls}">${escapeHtml(onboardingSeverityLabel(s))}</span>`;
+}
+
+function onboardingRuleListFromConfig(cfg) {
+ const list = Array.isArray(cfg?.rules?.items) ? cfg.rules.items : [];
+ return list
+ .map((r, index) => ({
+ id: String(r?.id || `r${index + 1}`).trim().slice(0, 40) || `r${index + 1}`,
+ order: Number.isFinite(Number(r?.order)) ? Math.max(1, Math.floor(Number(r.order))) : index + 1,
+ name: String(r?.name || "").trim().slice(0, 60) || `Rule ${index + 1}`,
+ shortDescription: String(r?.shortDescription || "").trim().slice(0, 180),
+ description: String(r?.description || "").slice(0, 6000),
+ severity: ["info", "warn", "critical"].includes(String(r?.severity || "").toLowerCase())
+ ? String(r.severity).toLowerCase()
+ : "info",
+ }))
+ .sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || String(a.id || "").localeCompare(String(b.id || "")));
+}
+
+function onboardingDraftStampFromConfig(cfg) {
+ return JSON.stringify({
+ enabled: Boolean(cfg?.enabled),
+ aboutUpdatedAt: Number(cfg?.about?.updatedAt || 0),
+ rulesVersion: Number(cfg?.rules?.version || 1),
+ itemCount: Array.isArray(cfg?.rules?.items) ? cfg.rules.items.length : 0,
+ roleSelectEnabled: Boolean(cfg?.roleSelect?.enabled),
+ selfAssignableCount: Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds) ? cfg.roleSelect.selfAssignableRoleIds.length : 0,
+ });
+}
+
+function syncOnboardingAdminDraft(force = false) {
+ const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {};
+ const stamp = onboardingDraftStampFromConfig(cfg);
+ if (!force && stamp === onboardingAdminDraftStamp) return;
+ onboardingAdminDraft = {
+ enabled: Boolean(cfg?.enabled),
+ aboutContent: String(cfg?.about?.content || ""),
+ requireAcceptance: Boolean(cfg?.rules?.requireAcceptance),
+ blockReadUntilAccepted: Boolean(cfg?.rules?.blockReadUntilAccepted),
+ roleSelectEnabled: Boolean(cfg?.roleSelect?.enabled),
+ selfAssignableRoleIds: Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds)
+ ? cfg.roleSelect.selfAssignableRoleIds.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean)
+ : [],
+ rules: onboardingRuleListFromConfig(cfg),
+ };
+ onboardingAdminDraftStamp = stamp;
+ onboardingAdminExpandedRuleIds.clear();
+ if (onboardingAdminDraft.rules[0]?.id) onboardingAdminExpandedRuleIds.add(onboardingAdminDraft.rules[0].id);
+}
+
+function normalizeOnboardingDraftRules() {
+ onboardingAdminDraft.rules = (Array.isArray(onboardingAdminDraft.rules) ? onboardingAdminDraft.rules : [])
+ .map((r, index) => ({
+ id: String(r?.id || `r${index + 1}`).trim().slice(0, 40) || `r${index + 1}`,
+ order: index + 1,
+ name: String(r?.name || "").trim().slice(0, 60) || `Rule ${index + 1}`,
+ shortDescription: String(r?.shortDescription || "").trim().slice(0, 180),
+ description: String(r?.description || "").slice(0, 6000),
+ severity: ["info", "warn", "critical"].includes(String(r?.severity || "").toLowerCase())
+ ? String(r.severity).toLowerCase()
+ : "info",
+ }))
+ .slice(0, 200);
+}
+
+function renderOnboardingPanel() {
+ if (!(onboardingPanelEl instanceof HTMLElement) || !(onboardingPanelBodyEl instanceof HTMLElement)) return;
+ const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {};
+ if (!cfg.enabled) {
+ onboardingPanelEl.classList.add("hidden");
+ onboardingPanelBodyEl.innerHTML = `<div class="small muted">Onboarding is disabled for this server.</div>`;
+ if (onboardingPanelAcceptBtn instanceof HTMLButtonElement) onboardingPanelAcceptBtn.classList.add("hidden");
+ return;
+ }
+
+ onboardingPanelEl.classList.remove("hidden");
+ const needs = onboardingNeedsAcceptanceNow();
+ const rules = onboardingRuleListFromConfig(cfg);
+ const about = typeof cfg?.about?.content === "string" ? cfg.about.content.trim() : "";
+ const roleIds = Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds) ? cfg.roleSelect.selfAssignableRoleIds : [];
+ const roleItems = roleIds
+ .map((key) => customRoles.find((r) => String(r?.key || "") === String(key)))
+ .filter(Boolean)
+ .map((r) => `<span class="tag">${escapeHtml(String(r.label || r.key || ""))}</span>`)
+ .join(" ");
+
+ onboardingPanelBodyEl.innerHTML = `
+ <div class="onbTabs">
+ <button type="button" class="${onboardingViewerTab === "about" ? "primary" : "ghost"} smallBtn" data-onbtab="about">About</button>
+ <button type="button" class="${onboardingViewerTab === "rules" ? "primary" : "ghost"} smallBtn" data-onbtab="rules">Rules</button>
+ <button type="button" class="${onboardingViewerTab === "roles" ? "primary" : "ghost"} smallBtn" data-onbtab="roles">Roles</button>
+ </div>
+ ${
+ onboardingViewerTab === "about"
+ ? about
+ ? `<div class="onboardingAbout">${about}</div>`
+ : `<div class="small muted">No About content published yet.</div>`
+ : onboardingViewerTab === "rules"
+ ? rules.length
+ ? `<div class="onbRuleList">${rules
+ .map(
+ (r) => `<article class="onbRuleViewerCard">
+ <div class="row" style="justify-content:space-between;align-items:center;">
+ <b>${escapeHtml(r.name || "Rule")}</b>
+ ${onboardingSeverityBadge(r.severity)}
+ </div>
+ ${r.shortDescription ? `<div class="small muted">${escapeHtml(r.shortDescription)}</div>` : ""}
+ ${r.description ? `<div class="small">${r.description}</div>` : ""}
+ </article>`
+ )
+ .join("")}</div>`
+ : `<div class="small muted">No rules configured.</div>`
+ : cfg?.roleSelect?.enabled
+ ? roleItems
+ ? `<div class="row" style="flex-wrap:wrap;gap:8px;">${roleItems}</div>`
+ : `<div class="small muted">No self-assignable roles configured.</div>`
+ : `<div class="small muted">Role select is disabled.</div>`
+ }
+ <div class="small ${needs ? "badText" : "goodText"}" style="margin-top:10px;">
+ ${
+ onboardingRequiresAcceptance()
+ ? needs
+ ? "Rules acceptance required before posting/chat."
+ : `Rules accepted${onboardingState.acceptedAt ? ` at ${escapeHtml(formatLocalTime(onboardingState.acceptedAt))}` : "."}`
+ : "Rules acceptance is optional on this server."
+ }
+ </div>`;
+
+ if (onboardingPanelAcceptBtn instanceof HTMLButtonElement) {
+ onboardingPanelAcceptBtn.classList.toggle("hidden", !onboardingRequiresAcceptance());
+ onboardingPanelAcceptBtn.disabled = !loggedInUser || !needs;
+ onboardingPanelAcceptBtn.textContent = needs ? "Accept and continue" : "Accepted";
+ }
+}
+
+function renderOnboardingCard() {
+ if (!(onboardingCard instanceof HTMLElement) || !(onboardingBody instanceof HTMLElement)) return;
+ // Onboarding now lives as a first-class workspace panel; keep the old account card hidden.
+ onboardingCard.classList.add("hidden");
+ onboardingBody.innerHTML = "";
+ if (onboardingAcceptBtn instanceof HTMLButtonElement) {
+ onboardingAcceptBtn.classList.add("hidden");
+ onboardingAcceptBtn.disabled = true;
+ }
+ renderOnboardingPanel();
+ return;
+
+ const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {};
+ if (!cfg.enabled) {
+ onboardingCard.classList.add("hidden");
+ onboardingBody.innerHTML = "";
+ return;
+ }
+ onboardingCard.classList.remove("hidden");
+ const needs = onboardingNeedsAcceptanceNow();
+ const rules = onboardingRuleListFromConfig(cfg).slice(0, 6);
+ const about = typeof cfg?.about?.content === "string" ? cfg.about.content.trim() : "";
+ const aboutBlock = about ? `<div class="onboardingAbout">${about}</div>` : `<div class="small muted">No About text set yet.</div>`;
+ const rulesBlock = rules.length
+ ? `<ol class="onboardingRules">${rules
+ .map(
+ (r) =>
+ `<li><b>${escapeHtml(r.name || "Rule")}</b>${r.shortDescription ? `<div class="small muted">${escapeHtml(r.shortDescription)}</div>` : ""}</li>`
+ )
+ .join("")}</ol>`
+ : `<div class="small muted">No rules published yet.</div>`;
+ onboardingBody.innerHTML = `
+ ${aboutBlock}
+ <div class="small" style="margin-top:10px;"><b>Rules</b></div>
+ ${rulesBlock}
+ ${
+ onboardingRequiresAcceptance()
+ ? `<div class="small ${needs ? "badText" : "goodText"}" style="margin-top:10px;">
+ ${needs ? "Rules acceptance required before posting/chat." : `Rules accepted${onboardingState.acceptedAt ? ` at ${escapeHtml(formatLocalTime(onboardingState.acceptedAt))}` : "."}`}
+ </div>`
+ : `<div class="small muted" style="margin-top:10px;">Rules acceptance is optional on this server.</div>`
+ }
+ `;
+ if (onboardingAcceptBtn instanceof HTMLButtonElement) {
+ onboardingAcceptBtn.classList.toggle("hidden", !onboardingRequiresAcceptance());
+ onboardingAcceptBtn.disabled = !loggedInUser || !needs;
+ onboardingAcceptBtn.textContent = needs ? "Accept and continue" : "Accepted";
+ }
+ renderOnboardingPanel();
}
function setAuthUi() {
@@ -5827,7 +6364,9 @@ function setAuthUi() {
userLabel.innerHTML = renderUserPill(loggedInUser);
logoutBtn.classList.remove("hidden");
const roleText = loggedInRole && loggedInRole !== "member" ? ` (${loggedInRole})` : "";
- authHint.textContent = `Signed in${roleText}. You can post, chat, and boost others.`;
+ authHint.textContent = onboardingNeedsAcceptanceNow()
+ ? `Signed in${roleText}. Accept server rules to unlock posting/chat.`
+ : `Signed in${roleText}. You can post, chat, and boost others.`;
} else {
userLabel.textContent = "Signed out";
logoutBtn.classList.add("hidden");
@@ -5849,6 +6388,7 @@ function setAuthUi() {
codeRow.classList.toggle("hidden", !registrationEnabled);
registerBtn.classList.toggle("hidden", !(registrationEnabled || canRegisterFirstUser));
+ renderOnboardingCard();
renderModPanel();
}
@@ -5879,7 +6419,7 @@ function renderPeoplePanel() {
if (!membersTabOn) {
if (!peopleDmsViewEl) return;
if (!loggedInUser) {
- peopleDmsViewEl.innerHTML = `<div class="muted">Sign in to use DMs.</div>`;
+ peopleDmsViewEl.innerHTML = `<div class="muted">Sign in to use DMs.</div><div class="uiHint">After signing in, open a DM request and accept it to start chatting.</div>`;
return;
}
@@ -5895,7 +6435,7 @@ function renderPeoplePanel() {
eligibleMembers.length > 0
? `<div class="dmNewRow">
<select class="dmToSelect" data-dmto="1">
- <option value="">New DM…</option>
+ <option value="">New DM...</option>
${eligibleMembers.map((u) => `<option value="${escapeHtml(u)}">@${escapeHtml(u)}</option>`).join("")}
</select>
<button type="button" class="primary" data-dmrequestfromselect="1">Request</button>
@@ -5934,7 +6474,7 @@ function renderPeoplePanel() {
? `<button type="button" class="primary smallBtn" data-dmopen="${escapeHtml(t.id)}">Open</button>`
: status === "declined"
? `<button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(other)}">Request again</button>`
- : `<span class="muted small">Waiting…</span>`;
+ : `<span class="muted small">Waiting...</span>`;
if (isBlocked) {
actions =
@@ -5974,7 +6514,7 @@ function renderPeoplePanel() {
.sort((a, b) => Number(Boolean(b.online)) - Number(Boolean(a.online)) || String(a.username).localeCompare(String(b.username)));
if (!list.length) {
- peopleListEl.innerHTML = `<div class="muted">No members found.</div>`;
+ peopleListEl.innerHTML = `<div class="muted">No members found.</div><div class="uiHint">Try clearing the search filter or check back when more members are online.</div>`;
return;
}
peopleListEl.innerHTML = list
@@ -6103,7 +6643,7 @@ function renderModPanel() {
const updatedAt = serverInfoStatus.at ? formatLocalTime(serverInfoStatus.at) : "";
const statusLine = loading
- ? `<span class="muted">Loading…</span>`
+ ? `<span class="muted">Loading...</span>`
: err
? `<span class="bad">${escapeHtml(err)}</span>`
: updatedAt
@@ -6142,7 +6682,7 @@ function renderModPanel() {
<label style="flex:1">
<span>Theme preset</span>
<select data-theme-preset>
- <option value="">(choose…)</option>
+ <option value="">(choose...)</option>
${THEME_PRESETS.map((p) => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)}</option>`).join("")}
</select>
</label>
@@ -6289,6 +6829,128 @@ function renderModPanel() {
return;
}
+ if (modTab === "onboarding") {
+ const isOwner = loggedInRole === "owner";
+ const canEdit = loggedInRole === "owner" || loggedInRole === "moderator";
+ syncOnboardingAdminDraft(false);
+ normalizeOnboardingDraftRules();
+ const roleOptions = customRoles
+ .map(
+ (r) =>
+ `<label class="checkRow">
+ <span>${escapeHtml(String(r.label || r.key || ""))}</span>
+ <input type="checkbox" data-onboarding-rolecheck="${escapeHtml(String(r.key || ""))}" ${
+ onboardingAdminDraft.selfAssignableRoleIds.includes(String(r.key || "")) ? "checked" : ""
+ } />
+ </label>`
+ )
+ .join("");
+ const rulesCards = onboardingAdminDraft.rules.length
+ ? onboardingAdminDraft.rules
+ .map((r, idx) => {
+ const expanded = onboardingAdminExpandedRuleIds.has(r.id);
+ return `<article class="onbRuleEditorCard" data-onb-ruleid="${escapeHtml(r.id)}">
+ <div class="row" style="justify-content:space-between;align-items:center;">
+ <button type="button" class="ghost smallBtn" data-onb-ruletoggle="${escapeHtml(r.id)}">${expanded ? "▾" : "▸"} Rule ${idx + 1}</button>
+ <div class="row" style="gap:6px;">
+ <button type="button" class="ghost smallBtn" data-onb-ruleup="${escapeHtml(r.id)}" ${idx <= 0 ? "disabled" : ""}>↑</button>
+ <button type="button" class="ghost smallBtn" data-onb-ruledown="${escapeHtml(r.id)}" ${
+ idx >= onboardingAdminDraft.rules.length - 1 ? "disabled" : ""
+ }>↓</button>
+ <button type="button" class="ghost smallBtn" data-onb-ruledelete="${escapeHtml(r.id)}">Delete</button>
+ </div>
+ </div>
+ ${
+ expanded
+ ? `<div class="onbRuleEditorBody">
+ <label><span>Name</span><input data-onb-rulefield="name" data-onb-ruleid="${escapeHtml(r.id)}" value="${escapeHtml(
+ r.name
+ )}" maxlength="60" /></label>
+ <label><span>Short description</span><input data-onb-rulefield="shortDescription" data-onb-ruleid="${escapeHtml(
+ r.id
+ )}" value="${escapeHtml(r.shortDescription)}" maxlength="180" /></label>
+ <label><span>Full description</span><textarea data-onb-rulefield="description" data-onb-ruleid="${escapeHtml(
+ r.id
+ )}" rows="4">${escapeHtml(r.description)}</textarea></label>
+ <label><span>Severity</span>
+ <select data-onb-rulefield="severity" data-onb-ruleid="${escapeHtml(r.id)}">
+ <option value="info" ${r.severity === "info" ? "selected" : ""}>Info</option>
+ <option value="warn" ${r.severity === "warn" ? "selected" : ""}>Warn</option>
+ <option value="critical" ${r.severity === "critical" ? "selected" : ""}>Critical</option>
+ </select>
+ </label>
+ </div>`
+ : ""
+ }
+ </article>`;
+ })
+ .join("")
+ : `<div class="small muted">No rules yet. Add your first rule.</div>`;
+
+ modBodyEl.innerHTML = `
+ <div class="modCard">
+ <div class="modRowTop"><div><b>Onboarding</b></div></div>
+ <div class="small muted">Configure About, Rules, and Role Select.</div>
+ <div class="onbTabs" style="margin-top:8px;">
+ <button type="button" class="${onboardingAdminTab === "about" ? "primary" : "ghost"} smallBtn" data-onb-admin-tab="about">About</button>
+ <button type="button" class="${onboardingAdminTab === "rules" ? "primary" : "ghost"} smallBtn" data-onb-admin-tab="rules">Rules</button>
+ <button type="button" class="${onboardingAdminTab === "roles" ? "primary" : "ghost"} smallBtn" data-onb-admin-tab="roles">Roles</button>
+ </div>
+ </div>
+ <div class="modCard">
+ ${
+ onboardingAdminTab === "about"
+ ? `<label class="checkRow">
+ <span>Enable onboarding panel</span>
+ <input type="checkbox" data-onboarding-enabled ${onboardingAdminDraft.enabled ? "checked" : ""} ${canEdit ? "" : "disabled"} />
+ </label>
+ <label>
+ <span>About (rich text allowed)</span>
+ <textarea data-onboarding-about rows="10" ${canEdit ? "" : "disabled"}>${escapeHtml(onboardingAdminDraft.aboutContent)}</textarea>
+ </label>
+ <div class="small muted">Updated by: ${escapeHtml(String(normalizeInstanceBranding(instanceBranding).onboarding?.about?.updatedBy || "n/a"))}</div>
+ <div class="small muted">Updated at: ${escapeHtml(
+ formatLocalTime(normalizeInstanceBranding(instanceBranding).onboarding?.about?.updatedAt || 0) || "n/a"
+ )}</div>`
+ : onboardingAdminTab === "rules"
+ ? `<label class="checkRow">
+ <span>Require rules acceptance before posting/chat</span>
+ <input type="checkbox" data-onboarding-require ${onboardingAdminDraft.requireAcceptance ? "checked" : ""} ${
+ canEdit ? "" : "disabled"
+ } />
+ </label>
+ <label class="checkRow">
+ <span>Block reading hives until accepted ${isOwner ? "" : "(owner only)"}</span>
+ <input type="checkbox" data-onboarding-blockread ${onboardingAdminDraft.blockReadUntilAccepted ? "checked" : ""} ${
+ canEdit && isOwner ? "" : "disabled"
+ } />
+ </label>
+ <div class="row" style="justify-content:space-between;align-items:center;margin:8px 0;">
+ <div><b>Rules</b></div>
+ <button type="button" class="primary smallBtn" data-onb-ruleadd="1" ${canEdit ? "" : "disabled"}>+ Add Rule</button>
+ </div>
+ <div class="onbRuleEditorList">${rulesCards}</div>`
+ : `<label class="checkRow">
+ <span>Enable custom role select in onboarding</span>
+ <input type="checkbox" data-onboarding-roleenabled ${onboardingAdminDraft.roleSelectEnabled ? "checked" : ""} ${
+ canEdit ? "" : "disabled"
+ } />
+ </label>
+ <div class="small muted">Choose self-assignable roles:</div>
+ <div class="onbRoleGrid">${roleOptions || `<div class="small muted">No custom roles defined.</div>`}</div>`
+ }
+ </div>
+ <div class="modCard">
+ <div class="row" style="gap:8px;">
+ <button type="button" class="primary" data-onboarding-save="1" ${canEdit ? "" : "disabled"}>Save</button>
+ <button type="button" class="ghost" data-onboarding-publish="1" ${canEdit ? "" : "disabled"}>Publish</button>
+ <button type="button" class="ghost" data-onboarding-refresh="1">Reload</button>
+ </div>
+ </div>
+ `;
+ return;
+ }
+
if (modTab === "users") {
const roleList = customRoles.length
? customRoles
@@ -6408,7 +7070,7 @@ function renderModPanel() {
: "public";
return `<span class="tag">/${escapeHtml(c.name)}</span>${
c.id !== "general"
- ? `<button type="button" data-collectiongate="${escapeHtml(c.id)}">Gate…</button>
+ ? `<button type="button" data-collectiongate="${escapeHtml(c.id)}">Gate...</button>
<button type="button" data-collectionpublic="${escapeHtml(c.id)}">Make public</button>`
: ""
}
@@ -6456,7 +7118,7 @@ function renderModPanel() {
)}" data-ttl="1440">TTL 1d</button>
<button type="button" data-modaction="post_ttl_set" data-targettype="post" data-targetid="${escapeHtml(
p.id
- )}" data-ttlprompt="1">Set TTL…</button>
+ )}" data-ttlprompt="1">Set TTL...</button>
${
p.readOnly
? `<button type="button" data-modaction="post_readonly_set" data-targettype="post" data-targetid="${escapeHtml(
@@ -6473,10 +7135,10 @@ function renderModPanel() {
)}" data-unprotect="1">Unprotect</button>
<button type="button" data-modaction="post_protection_set" data-targettype="post" data-targetid="${escapeHtml(
p.id
- )}" data-protect="1">Change password…</button>`
+ )}" data-protect="1">Change password...</button>`
: `<button type="button" data-modaction="post_protection_set" data-targettype="post" data-targetid="${escapeHtml(
p.id
- )}" data-protect="1">Protect…</button>`
+ )}" data-protect="1">Protect...</button>`
}
<button type="button" data-modaction="message_purge_recent" data-targettype="post" data-targetid="${escapeHtml(
p.id
@@ -6677,6 +7339,7 @@ function pushMapChatMessage(mapId, scope, message) {
function renderChatPanel(forceScroll = false) {
updateChatModToggleVisibility();
+ renderChatContextSelect();
const mobileChatScreen = isMobileChatScreenActive();
const mediaState = captureMediaState(chatMessagesEl);
if (activeDmThreadId) {
@@ -6709,7 +7372,7 @@ function renderChatPanel(forceScroll = false) {
</div>`
: status === "declined"
? `<button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(thread.other)}">Request again</button>`
- : `<div class="muted">Waiting for @${escapeHtml(thread.other)}…</div>`;
+ : `<div class="muted">Waiting for @${escapeHtml(thread.other)}...</div>`;
chatMessagesEl.innerHTML = `<div class="small muted">${promptHtml}</div>`;
restoreMediaState(chatMessagesEl, mediaState);
setReplyToMessage(null);
@@ -6720,14 +7383,15 @@ function renderChatPanel(forceScroll = false) {
.map((m, index) => {
const from = m.fromUser || "";
const isYou = loggedInUser && from && from === loggedInUser;
+ const isModMsg = Boolean(m?.asMod) || String(from || "").toLowerCase() === "mod";
const rail = chatRailClass({
fromUser: from,
- isModMessage: Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod"
+ isModMessage: isModMsg
});
const prev = index > 0 ? messages[index - 1] : null;
const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from);
- const who = renderUserPill(from || "");
- const youTag = isYou ? `<span class="muted">(you)</span>` : "";
+ const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || "");
+ const youTag = isModMsg ? "" : isYou ? `<span class="muted">(you)</span>` : "";
const time = new Date(m.createdAt).toLocaleTimeString();
const tint = tintStylesFromHex(getProfile(from).color);
const html = typeof m.html === "string" && m.html.trim() ? m.html : "";
@@ -6830,7 +7494,7 @@ function renderChatPanel(forceScroll = false) {
if (chatPanelEl) chatPanelEl.classList.remove("walkie");
if (walkieBarEl) walkieBarEl.classList.add("hidden");
if (chatForm) chatForm.classList.remove("hidden");
- chatMessagesEl.innerHTML = `<div class="small muted">No chat selected.</div>`;
+ chatMessagesEl.innerHTML = `<div class="small muted">No chat selected.</div><div class="uiHint">Open a hive and press <b>Chat</b>, or use People -> DMs to open a private thread.</div>`;
restoreMediaState(chatMessagesEl, mediaState);
setReplyToMessage(null);
return;
@@ -7167,6 +7831,7 @@ function openDmThread(threadId) {
if (activeChatPostId) ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
activeChatPostId = null;
activeDmThreadId = id;
+ touchRecentDmChat(id);
setReplyToMessage(null);
ws.send(JSON.stringify({ type: "dmHistory", threadId: id }));
renderChatPanel(true);
@@ -7174,16 +7839,18 @@ function openDmThread(threadId) {
setMobileScreen("chat");
renderMobileNav();
}
- chatEditor?.focus();
}
-function openChat(postId) {
+function openChat(postId, opts = null) {
activeDmThreadId = null;
stopWalkieRecording();
+ const options = opts && typeof opts === "object" ? opts : {};
+ const sourceEl = options.sourceEl instanceof HTMLElement ? options.sourceEl : null;
const post = posts.get(postId);
if (!post) return;
if (post.deleted) {
activeChatPostId = postId;
+ touchRecentHiveChat(postId);
renderChatPanel(true);
if (isMobileSwipeMode()) setMobilePanel("chat");
return;
@@ -7193,47 +7860,42 @@ function openChat(postId) {
return;
}
- // Rack mode: hive chats live in dedicated chat panels (instances). Don't also open the legacy main chat panel.
+ // Rack mode: switch the nearest visible chat panel when possible; otherwise use main chat.
if (rackLayoutEnabled) {
- const mainChatPanelIsIdle = Boolean(
- chatPanelEl &&
- typeof isDocked === "function" &&
- !isDocked("chat") &&
- !activeDmThreadId &&
- !activeChatPostId &&
- !isMapChatActive()
- );
- if (mainChatPanelIsIdle) {
+ const nearestInstanceId = nearestVisibleChatInstancePanelId(sourceEl);
+ if (nearestInstanceId) {
+ touchRecentHiveChat(postId);
+ markRead(postId);
+ renderFeed();
+ ws.send(JSON.stringify({ type: "getChat", postId }));
+ setChatInstancePanelPost(nearestInstanceId, postId, true);
+ renderChatContextSelect();
+ return;
+ }
+ if (chatPanelEl && typeof isDocked === "function" && !isDocked("chat")) {
activeChatPostId = postId;
+ touchRecentHiveChat(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 }));
- ensureChatPostPanelInstance(postId, { docked: false });
- return;
}
if (activeChatPostId && activeChatPostId !== postId) {
ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
setReplyToMessage(null);
}
activeChatPostId = postId;
+ touchRecentHiveChat(postId);
markRead(postId);
renderFeed();
ws.send(JSON.stringify({ type: "getChat", postId }));
renderChatPanel(true);
renderTypingIndicator();
if (isMobileSwipeMode()) setMobilePanel("chat");
- chatEditor.focus();
-
}
let pendingOpenChatAfterUnlock = null;
@@ -7513,6 +8175,114 @@ function shouldHandleWalkieHotkey(evt) {
return true;
}
+function isTextEntryFocused() {
+ const el = document.activeElement;
+ if (!el) return false;
+ const tag = String(el.tagName || "").toLowerCase();
+ if (tag === "textarea") return true;
+ if (tag === "input") {
+ const type = String(el.getAttribute?.("type") || "text").toLowerCase();
+ return !["button", "checkbox", "color", "file", "hidden", "radio", "range", "reset", "submit"].includes(type);
+ }
+ return Boolean(el.isContentEditable);
+}
+
+function cycleLayoutPresetBy(step) {
+ if (!layoutPresetEl || !rackLayoutEnabled || layoutPresetEl.disabled) return;
+ const options = Array.from(layoutPresetEl.options || [])
+ .map((opt) => String(opt.value || "").trim())
+ .filter((v) => v);
+ if (!options.length) return;
+ const current = resolvePresetKey(String(layoutPresetEl.value || rackLayoutState?.presetId || "onboardingDefault"));
+ let idx = options.indexOf(current);
+ if (idx < 0) idx = 0;
+ const len = options.length;
+ const next = options[(idx + step + len) % len];
+ if (!next) return;
+ layoutPresetEl.value = next;
+ applyPreset(next);
+}
+
+let hotkeyPanelContext = "";
+function updateHotkeyPanelContextFromTarget(target) {
+ const el = target instanceof HTMLElement ? target : null;
+ if (!el) return;
+ if (el.closest("#hivesPanel")) {
+ hotkeyPanelContext = "hives";
+ return;
+ }
+ if (el.closest("aside.chat") || el.closest(".chatInstance") || el.closest("[data-panel-id^='chat:post:']")) {
+ hotkeyPanelContext = "chat";
+ }
+}
+
+function activePanelContextForHotkeys() {
+ if (isMobileScreenMode() && appRoot) {
+ const mobile = String(appRoot.getAttribute("data-mobile-screen") || "").trim();
+ if (mobile === "hives") return "hives";
+ if (mobile === "chat" || (mobile === "host" && mobileHostPanelId === "chat")) return "chat";
+ }
+ const ae = document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ if (ae) {
+ if (ae.closest("#hivesPanel")) return "hives";
+ if (ae.closest("aside.chat") || ae.closest(".chatInstance") || ae.closest("[data-panel-id^='chat:post:']")) return "chat";
+ }
+ return hotkeyPanelContext || "";
+}
+
+function cycleHiveViewBy(step) {
+ if (!hiveTabsEl) return false;
+ const views = Array.from(hiveTabsEl.querySelectorAll("button[data-hiveview]:not([disabled])"))
+ .map((b) => String(b.getAttribute("data-hiveview") || "").trim())
+ .filter(Boolean);
+ if (!views.length) return false;
+ let idx = views.indexOf(String(activeHiveView || "all"));
+ if (idx < 0) idx = 0;
+ const len = views.length;
+ const next = views[(idx + step + len) % len];
+ if (!next || next === activeHiveView) return false;
+ activeHiveView = next;
+ renderFeed();
+ return true;
+}
+
+function cycleChatContextBy(step) {
+ renderChatContextSelect();
+ if (!(chatContextSelectEl instanceof HTMLSelectElement)) return false;
+ const items = [
+ "__list__",
+ ...Array.from(chatContextSelectEl.options || [])
+ .map((o) => String(o.value || "").trim())
+ .filter((v) => v && (v.startsWith("dm:") || v.startsWith("post:"))),
+ ];
+ if (items.length <= 1) return false;
+ const current = activeDmThreadId ? `dm:${activeDmThreadId}` : activeChatPostId ? `post:${activeChatPostId}` : "__list__";
+ let idx = items.indexOf(current);
+ if (idx < 0) idx = 0;
+ const len = items.length;
+ const next = items[(idx + step + len) % len];
+ if (!next || next === current) return false;
+ if (next === "__list__") {
+ if (activeChatPostId && ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false }));
+ activeChatPostId = null;
+ activeDmThreadId = null;
+ activeMapsRoomId = "";
+ activeMapsRoomTitle = "";
+ setReplyToMessage(null);
+ renderChatPanel(true);
+ return true;
+ }
+ if (next.startsWith("dm:")) {
+ openDmThread(next.slice(3));
+ return true;
+ }
+ if (next.startsWith("post:")) {
+ openChat(next.slice(5));
+ return true;
+ }
+ return false;
+}
+
function canWalkieTalkNow() {
if (!loggedInUser || !ws || ws.readyState !== WebSocket.OPEN) return false;
if (!activeChatPostId) return false;
@@ -7525,7 +8295,7 @@ async function startWalkieRecording() {
if (walkieRecording) return;
if (!canWalkieTalkNow()) return;
try {
- if (walkieStatusEl) walkieStatusEl.textContent = "Requesting microphone…";
+ if (walkieStatusEl) walkieStatusEl.textContent = "Requesting microphone...";
const { ctx, mix, dest } = await ensureWalkieGraph();
if (ctx.state === "suspended") await ctx.resume();
@@ -7549,7 +8319,7 @@ async function startWalkieRecording() {
walkieStartAt = Date.now();
walkieRecording = true;
if (walkieBarEl) walkieBarEl.classList.add("isRecording");
- if (walkieStatusEl) walkieStatusEl.textContent = "Recording… release to send.";
+ if (walkieStatusEl) walkieStatusEl.textContent = "Recording... release to send.";
const dispatch = await ensureWalkieDispatchBuffer();
if (dispatch) {
@@ -7571,7 +8341,7 @@ async function startWalkieRecording() {
const tookMs = Date.now() - walkieStartAt;
walkieRecording = false;
if (walkieBarEl) walkieBarEl.classList.remove("isRecording");
- if (walkieStatusEl) walkieStatusEl.textContent = "Processing…";
+ if (walkieStatusEl) walkieStatusEl.textContent = "Processing...";
// Give some browsers a tick to deliver the final dataavailable.
await new Promise((r) => window.setTimeout(r, 0));
@@ -7585,7 +8355,7 @@ async function startWalkieRecording() {
const ext = (rec.mimeType || "").includes("ogg") ? "ogg" : "webm";
const file = new File([blob], `walkie-${Date.now()}.${ext}`, { type: rec.mimeType || blob.type || "audio/webm" });
- if (walkieStatusEl) walkieStatusEl.textContent = "Uploading…";
+ if (walkieStatusEl) walkieStatusEl.textContent = "Uploading...";
const url = await uploadMediaFile(file, "audio");
if (!url) {
if (walkieStatusEl) walkieStatusEl.textContent = "";
@@ -8007,6 +8777,10 @@ profileSaveBtn?.addEventListener("click", () => {
newPostForm.addEventListener("submit", (e) => {
e.preventDefault();
+ if (onboardingNeedsAcceptanceNow()) {
+ toast("Onboarding", "Accept server rules in Account before creating hives.");
+ return;
+ }
const title = String(postTitleInput?.value || "")
.replace(/\s+/g, " ")
.trim()
@@ -8073,6 +8847,10 @@ toggleComposerBtn?.addEventListener("click", () => {
toggleComposerInlineBtn?.addEventListener("click", () => setComposerOpen(false));
function submitChat() {
+ if (onboardingNeedsAcceptanceNow()) {
+ toast("Onboarding", "Accept server rules in Account before chatting.");
+ return;
+ }
const html = chatEditor.innerHTML.trim();
const text = chatEditor.innerText.trim();
const hasImg = Boolean(chatEditor.querySelector("img"));
@@ -8244,7 +9022,7 @@ feedEl.addEventListener("click", (e) => {
const postId = chatBtn.getAttribute("data-chat");
const post = postId ? posts.get(postId) : null;
if (post?.locked) unlockPostFlow(postId, true);
- else openChat(postId);
+ else openChat(postId, { sourceEl: chatBtn });
return;
}
@@ -8351,6 +9129,43 @@ window.addEventListener("keydown", (e) => {
openPostMenuId = "";
});
+window.addEventListener("keydown", (e) => {
+ if (e.defaultPrevented) return;
+ if (e.repeat) return;
+ if (e.altKey || e.ctrlKey || e.metaKey) return;
+ if (isTextEntryFocused()) return;
+ const ctx = activePanelContextForHotkeys();
+ const plus = e.key === "=" || e.code === "NumpadAdd";
+ const minus = e.key === "-" || e.code === "NumpadSubtract";
+ if (ctx === "hives" && (plus || minus)) {
+ e.preventDefault();
+ cycleHiveViewBy(plus ? 1 : -1);
+ return;
+ }
+ if (ctx === "chat" && (plus || minus)) {
+ e.preventDefault();
+ cycleChatContextBy(plus ? 1 : -1);
+ return;
+ }
+ if (e.key === "[") {
+ e.preventDefault();
+ cycleLayoutPresetBy(-1);
+ return;
+ }
+ if (e.key === "]") {
+ e.preventDefault();
+ cycleLayoutPresetBy(1);
+ }
+});
+
+window.addEventListener(
+ "pointerdown",
+ (e) => {
+ updateHotkeyPanelContextFromTarget(e.target);
+ },
+ true
+);
+
window.addEventListener("click", (e) => {
if (!openPostMenuId) return;
const esc = cssEscape(openPostMenuId);
@@ -8490,11 +9305,27 @@ chatBackToListBtn?.addEventListener("click", () => {
renderChatPanel(true);
});
+chatContextSelectEl?.addEventListener("change", () => {
+ if (syncingChatContextSelect) return;
+ const raw = String(chatContextSelectEl.value || "").trim();
+ if (!raw) return;
+ if (raw.startsWith("dm:")) {
+ const id = raw.slice(3);
+ if (id) openDmThread(id);
+ return;
+ }
+ if (raw.startsWith("post:")) {
+ const id = raw.slice(5);
+ if (id) openChat(id);
+ }
+});
+
modPanelEl?.addEventListener("click", (e) => {
const tabBtn = e.target.closest("[data-modtab]");
if (tabBtn) {
modTab = tabBtn.getAttribute("data-modtab") || "reports";
if (modTab === "server") requestServerInfo();
+ if (modTab === "onboarding") syncOnboardingAdminDraft(true);
renderModPanel();
return;
}
@@ -8503,6 +9334,11 @@ modPanelEl?.addEventListener("click", (e) => {
modRefreshBtn?.addEventListener("click", () => {
if (!canModerate) return;
if (modTab === "server") requestServerInfo();
+ else if (modTab === "onboarding") {
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
+ syncOnboardingAdminDraft(true);
+ renderModPanel();
+ }
else requestModData();
});
modReportStatusEl?.addEventListener("change", () => {
@@ -8623,6 +9459,120 @@ modBodyEl?.addEventListener("click", (e) => {
const serverRefreshBtn = e.target.closest("button[data-server-refresh]");
if (serverRefreshBtn) {
requestServerInfo();
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
+ return;
+ }
+
+ const onboardingRefreshBtn = e.target.closest("button[data-onboarding-refresh]");
+ if (onboardingRefreshBtn) {
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
+ syncOnboardingAdminDraft(true);
+ renderModPanel();
+ return;
+ }
+
+ const onbAdminTabBtn = e.target.closest("button[data-onb-admin-tab]");
+ if (onbAdminTabBtn) {
+ const tab = String(onbAdminTabBtn.getAttribute("data-onb-admin-tab") || "about").trim();
+ if (!["about", "rules", "roles"].includes(tab)) return;
+ onboardingAdminTab = tab;
+ renderModPanel();
+ return;
+ }
+
+ const onbRuleAddBtn = e.target.closest("button[data-onb-ruleadd]");
+ if (onbRuleAddBtn) {
+ if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
+ normalizeOnboardingDraftRules();
+ const nextIndex = onboardingAdminDraft.rules.length + 1;
+ const id = `r${Date.now()}_${nextIndex}`;
+ onboardingAdminDraft.rules.push({
+ id,
+ order: nextIndex,
+ name: `Rule ${nextIndex}`,
+ shortDescription: "",
+ description: "",
+ severity: "info",
+ });
+ normalizeOnboardingDraftRules();
+ onboardingAdminExpandedRuleIds.add(id);
+ onboardingAdminTab = "rules";
+ renderModPanel();
+ return;
+ }
+
+ const onbRuleToggleBtn = e.target.closest("button[data-onb-ruletoggle]");
+ if (onbRuleToggleBtn) {
+ const id = String(onbRuleToggleBtn.getAttribute("data-onb-ruletoggle") || "").trim();
+ if (!id) return;
+ if (onboardingAdminExpandedRuleIds.has(id)) onboardingAdminExpandedRuleIds.delete(id);
+ else onboardingAdminExpandedRuleIds.add(id);
+ renderModPanel();
+ return;
+ }
+
+ const onbRuleDeleteBtn = e.target.closest("button[data-onb-ruledelete]");
+ if (onbRuleDeleteBtn) {
+ if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
+ const id = String(onbRuleDeleteBtn.getAttribute("data-onb-ruledelete") || "").trim();
+ onboardingAdminDraft.rules = onboardingAdminDraft.rules.filter((r) => r.id !== id);
+ onboardingAdminExpandedRuleIds.delete(id);
+ normalizeOnboardingDraftRules();
+ renderModPanel();
+ return;
+ }
+
+ const onbRuleUpBtn = e.target.closest("button[data-onb-ruleup]");
+ if (onbRuleUpBtn) {
+ if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
+ const id = String(onbRuleUpBtn.getAttribute("data-onb-ruleup") || "").trim();
+ const idx = onboardingAdminDraft.rules.findIndex((r) => r.id === id);
+ if (idx <= 0) return;
+ const tmp = onboardingAdminDraft.rules[idx - 1];
+ onboardingAdminDraft.rules[idx - 1] = onboardingAdminDraft.rules[idx];
+ onboardingAdminDraft.rules[idx] = tmp;
+ normalizeOnboardingDraftRules();
+ renderModPanel();
+ return;
+ }
+
+ const onbRuleDownBtn = e.target.closest("button[data-onb-ruledown]");
+ if (onbRuleDownBtn) {
+ if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
+ const id = String(onbRuleDownBtn.getAttribute("data-onb-ruledown") || "").trim();
+ const idx = onboardingAdminDraft.rules.findIndex((r) => r.id === id);
+ if (idx < 0 || idx >= onboardingAdminDraft.rules.length - 1) return;
+ const tmp = onboardingAdminDraft.rules[idx + 1];
+ onboardingAdminDraft.rules[idx + 1] = onboardingAdminDraft.rules[idx];
+ onboardingAdminDraft.rules[idx] = tmp;
+ normalizeOnboardingDraftRules();
+ renderModPanel();
+ return;
+ }
+
+ const onboardingSaveBtn = e.target.closest("button[data-onboarding-save],button[data-onboarding-publish]");
+ if (onboardingSaveBtn) {
+ if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
+ const publish = onboardingSaveBtn.hasAttribute("data-onboarding-publish");
+ normalizeOnboardingDraftRules();
+ ws.send(
+ JSON.stringify({
+ type: "instanceSetOnboarding",
+ publish,
+ enabled: Boolean(onboardingAdminDraft.enabled),
+ about: { content: String(onboardingAdminDraft.aboutContent || "") },
+ rules: {
+ requireAcceptance: Boolean(onboardingAdminDraft.requireAcceptance),
+ blockReadUntilAccepted: Boolean(onboardingAdminDraft.blockReadUntilAccepted),
+ items: onboardingAdminDraft.rules,
+ },
+ roleSelect: {
+ enabled: Boolean(onboardingAdminDraft.roleSelectEnabled),
+ selfAssignableRoleIds: onboardingAdminDraft.selfAssignableRoleIds,
+ }
+ })
+ );
+ toast("Onboarding", publish ? "Publishing..." : "Saving...");
return;
}
@@ -8657,7 +9607,7 @@ modBodyEl?.addEventListener("click", (e) => {
appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct }
})
);
- toast("Instance", "Saving…");
+ toast("Instance", "Saving...");
return;
}
@@ -8682,7 +9632,7 @@ modBodyEl?.addEventListener("click", (e) => {
appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct }
})
);
- toast("Theme", "Saving…");
+ toast("Theme", "Saving...");
return;
}
@@ -8699,7 +9649,7 @@ modBodyEl?.addEventListener("click", (e) => {
if (pluginReloadBtn) {
if (!isOwnerUser()) return;
pluginAdminBusy = true;
- pluginAdminStatus = "Reloading plugins…";
+ pluginAdminStatus = "Reloading plugins...";
renderModPanel();
ws.send(JSON.stringify({ type: "pluginReload" }));
return;
@@ -8713,7 +9663,7 @@ modBodyEl?.addEventListener("click", (e) => {
const ok = confirm(`Uninstall "${id}"? This deletes the plugin files from this server.`);
if (!ok) return;
pluginAdminBusy = true;
- pluginAdminStatus = `Uninstalling "${id}"…`;
+ pluginAdminStatus = `Uninstalling "${id}"...`;
renderModPanel();
ws.send(JSON.stringify({ type: "pluginUninstall", id }));
return;
@@ -8736,7 +9686,7 @@ modBodyEl?.addEventListener("click", (e) => {
return;
}
pluginAdminBusy = true;
- pluginAdminStatus = "Uploading plugin…";
+ pluginAdminStatus = "Uploading plugin...";
renderModPanel();
(async () => {
try {
@@ -8779,7 +9729,7 @@ modBodyEl?.addEventListener("click", (e) => {
const ok = confirm("NUKE the board? This clears all hives, reports, moderation log, and hive media uploads.");
if (!ok) return;
ws.send(JSON.stringify({ type: "nukeBoard", confirm: true, confirmText: "ARE YOU SURE?" }));
- toast("NUKE", "Working…");
+ toast("NUKE", "Working...");
return;
}
@@ -8937,6 +9887,53 @@ modBodyEl?.addEventListener("click", (e) => {
});
modBodyEl?.addEventListener("change", (e) => {
+ const onbEnabled = e.target?.closest?.("input[data-onboarding-enabled]");
+ if (onbEnabled) {
+ onboardingAdminDraft.enabled = Boolean(onbEnabled.checked);
+ return;
+ }
+ const onbRequire = e.target?.closest?.("input[data-onboarding-require]");
+ if (onbRequire) {
+ onboardingAdminDraft.requireAcceptance = Boolean(onbRequire.checked);
+ return;
+ }
+ const onbBlockRead = e.target?.closest?.("input[data-onboarding-blockread]");
+ if (onbBlockRead) {
+ onboardingAdminDraft.blockReadUntilAccepted = Boolean(onbBlockRead.checked);
+ return;
+ }
+ const onbRoleEnabled = e.target?.closest?.("input[data-onboarding-roleenabled]");
+ if (onbRoleEnabled) {
+ onboardingAdminDraft.roleSelectEnabled = Boolean(onbRoleEnabled.checked);
+ return;
+ }
+ const onbRoleCheck = e.target?.closest?.("input[data-onboarding-rolecheck]");
+ if (onbRoleCheck) {
+ const key = String(onbRoleCheck.getAttribute("data-onboarding-rolecheck") || "").trim().toLowerCase();
+ if (!key) return;
+ const set = new Set(onboardingAdminDraft.selfAssignableRoleIds || []);
+ if (onbRoleCheck.checked) set.add(key);
+ else set.delete(key);
+ onboardingAdminDraft.selfAssignableRoleIds = Array.from(set);
+ return;
+ }
+ const onbRuleField = e.target?.closest?.("[data-onb-rulefield]");
+ if (onbRuleField) {
+ const id = String(onbRuleField.getAttribute("data-onb-ruleid") || "").trim();
+ const field = String(onbRuleField.getAttribute("data-onb-rulefield") || "").trim();
+ if (!id || !field) return;
+ const rule = onboardingAdminDraft.rules.find((r) => r.id === id);
+ if (!rule) return;
+ if (field === "severity") {
+ rule.severity = ["info", "warn", "critical"].includes(String(onbRuleField.value || "").toLowerCase())
+ ? String(onbRuleField.value || "").toLowerCase()
+ : "info";
+ return;
+ }
+ rule[field] = String(onbRuleField.value || "");
+ return;
+ }
+
const presetSelect = e.target?.closest?.("select[data-theme-preset]");
if (presetSelect) {
if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return;
@@ -8984,13 +9981,29 @@ modBodyEl?.addEventListener("change", (e) => {
for (const p of plugins) {
if (p && String(p.id || "").toLowerCase() === id) p.enabled = enabled;
}
- pluginAdminStatus = enabled ? "Enabling…" : "Disabling…";
+ pluginAdminStatus = enabled ? "Enabling..." : "Disabling...";
renderModPanel();
wsRef.send(JSON.stringify({ type: "pluginSetEnabled", id, enabled }));
return;
}
});
+modBodyEl?.addEventListener("input", (e) => {
+ const aboutEl = e.target?.closest?.("textarea[data-onboarding-about]");
+ if (aboutEl) {
+ onboardingAdminDraft.aboutContent = String(aboutEl.value || "");
+ return;
+ }
+ const onbRuleField = e.target?.closest?.("input[data-onb-rulefield],textarea[data-onb-rulefield]");
+ if (!onbRuleField) return;
+ const id = String(onbRuleField.getAttribute("data-onb-ruleid") || "").trim();
+ const field = String(onbRuleField.getAttribute("data-onb-rulefield") || "").trim();
+ if (!id || !field) return;
+ const rule = onboardingAdminDraft.rules.find((r) => r.id === id);
+ if (!rule) return;
+ rule[field] = String(onbRuleField.value || "");
+});
+
modBodyEl?.addEventListener("change", (e) => {
const toggle = e.target?.closest?.("input[data-nukeconfirm]");
if (!toggle) return;
@@ -9312,6 +10325,7 @@ function onWsMessage(evt) {
devLog = [];
profiles = msg.profiles && typeof msg.profiles === "object" ? msg.profiles : {};
instanceBranding = normalizeInstanceBranding(msg.instance || {});
+ onboardingState = normalizeOnboardingState(msg.auth?.onboarding || {});
renderInstanceBranding();
collections = normalizeCollections(msg.collections);
customRoles = normalizeRoleDefs(msg.roles?.custom);
@@ -9334,6 +10348,7 @@ function onWsMessage(evt) {
renderLanHint();
renderPeoplePanel();
renderCenterPanels();
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
return;
}
@@ -9437,6 +10452,8 @@ function onWsMessage(evt) {
if (msg.type === "instanceUpdated" && msg.instance && typeof msg.instance === "object") {
instanceBranding = normalizeInstanceBranding(msg.instance);
+ onboardingState = normalizeOnboardingState(onboardingState);
+ if (modTab === "onboarding") syncOnboardingAdminDraft(true);
renderInstanceBranding();
applyInstanceAppearance();
setAuthUi();
@@ -9445,6 +10462,8 @@ function onWsMessage(evt) {
if (msg.type === "instanceOk" && msg.instance && typeof msg.instance === "object") {
instanceBranding = normalizeInstanceBranding(msg.instance);
+ onboardingState = normalizeOnboardingState(onboardingState);
+ if (modTab === "onboarding") syncOnboardingAdminDraft(true);
renderInstanceBranding();
applyInstanceAppearance();
setAuthUi();
@@ -9590,6 +10609,7 @@ function onWsMessage(evt) {
loggedInUser = msg.username || null;
loggedInRole = typeof msg.role === "string" ? msg.role : "member";
canModerate = Boolean(msg.canModerate);
+ onboardingState = normalizeOnboardingState(msg.onboarding || onboardingState);
if (typeof msg.sessionToken === "string" && msg.sessionToken) setSessionToken(msg.sessionToken);
const profile = msg.profile || {};
pendingProfileImage = typeof profile.image === "string" ? profile.image : "";
@@ -9615,6 +10635,7 @@ function onWsMessage(evt) {
if (canModerate) requestModData();
if (rackLayoutEnabled) applyDockState();
updateLayoutPresetOptions();
+ renderOnboardingCard();
return;
}
@@ -9623,6 +10644,7 @@ function onWsMessage(evt) {
loggedInUser = null;
loggedInRole = "member";
canModerate = false;
+ onboardingState = normalizeOnboardingState({ acceptedRulesVersion: 0, acceptedAt: 0, needsAcceptance: false });
dmThreads = [];
dmThreadsById = new Map();
dmMessagesByThreadId.clear();
@@ -9642,6 +10664,7 @@ function onWsMessage(evt) {
renderCenterPanels();
if (rackLayoutEnabled) applyDockState();
updateLayoutPresetOptions();
+ renderOnboardingCard();
return;
}
@@ -9649,6 +10672,7 @@ function onWsMessage(evt) {
if (!loggedInUser || msg.username !== loggedInUser) return;
loggedInRole = typeof msg.role === "string" ? msg.role : loggedInRole;
canModerate = Boolean(msg.canModerate);
+ onboardingState = normalizeOnboardingState(msg.onboarding || onboardingState);
if (!canModerate) lanUrls = [];
if (msg.prefs && typeof msg.prefs === "object") setUserPrefs(msg.prefs);
setAuthUi();
@@ -9657,6 +10681,14 @@ function onWsMessage(evt) {
renderPeoplePanel();
if (canModerate) requestModData();
updateLayoutPresetOptions();
+ renderOnboardingCard();
+ return;
+ }
+
+ if (msg.type === "onboardingState" && msg.onboarding && typeof msg.onboarding === "object") {
+ onboardingState = normalizeOnboardingState(msg.onboarding);
+ setAuthUi();
+ renderOnboardingCard();
return;
}
@@ -9752,6 +10784,25 @@ function onWsMessage(evt) {
return;
}
+ if (msg.type === "dmModMessageReceived") {
+ const threadId = String(msg.threadId || "").trim();
+ if (!threadId) return;
+ if (!dmThreadsById.has(threadId) && ws?.readyState === WebSocket.OPEN) {
+ pendingOpenDmThreadId = threadId;
+ ws.send(JSON.stringify({ type: "dmList" }));
+ }
+ if (isMobileScreenMode()) {
+ const layout = loadMobileLayout();
+ layout.active = "chat";
+ saveMobileLayout(layout);
+ setMobileScreen("chat");
+ renderMobileNav();
+ }
+ if (dmThreadsById.has(threadId)) openDmThread(threadId);
+ toast("Moderator message", "Opened priority moderator DM.");
+ return;
+ }
+
if (msg.type === "lanInfo") {
lanUrls = Array.isArray(msg.lanUrls) ? msg.lanUrls : [];
renderLanHint();
@@ -10033,6 +11084,7 @@ setConn("connecting");
connectWs();
renderLanHint();
+writeHintsEnabledPref(readHintsEnabledPref());
initDisplayPrefsUi();
if (stayConnectedEl) {
stayConnectedEl.checked = readStayConnectedPref();
@@ -10048,6 +11100,12 @@ if (stayConnectedEl) {
}
});
}
+if (enableHintsEl) {
+ enableHintsEl.checked = readHintsEnabledPref();
+ enableHintsEl.addEventListener("change", () => {
+ writeHintsEnabledPref(Boolean(enableHintsEl.checked));
+ });
+}
renderPeoplePanel();
setPeopleOpen(getPeopleOpen());
composerOpen = getComposerOpen();
@@ -10258,6 +11316,39 @@ peopleDmsViewEl?.addEventListener("click", (e) => {
}
});
+onboardingAcceptBtn?.addEventListener("click", () => {
+ if (!loggedInUser) {
+ toast("Sign in required", "Sign in to accept server rules.");
+ return;
+ }
+ ws.send(JSON.stringify({ type: "onboardingAcceptRules" }));
+});
+
+onboardingRefreshBtn?.addEventListener("click", () => {
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
+});
+
+onboardingPanelAcceptBtn?.addEventListener("click", () => {
+ if (!loggedInUser) {
+ toast("Sign in required", "Sign in to accept server rules.");
+ return;
+ }
+ ws.send(JSON.stringify({ type: "onboardingAcceptRules" }));
+});
+
+onboardingPanelRefreshBtn?.addEventListener("click", () => {
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" }));
+});
+
+onboardingPanelBodyEl?.addEventListener("click", (e) => {
+ const tabBtn = e.target.closest?.("button[data-onbtab]");
+ if (!tabBtn) return;
+ const tab = String(tabBtn.getAttribute("data-onbtab") || "about").trim();
+ if (!["about", "rules", "roles"].includes(tab)) return;
+ onboardingViewerTab = tab;
+ renderOnboardingPanel();
+});
+
profileCard?.addEventListener("click", (e) => {
const dmBtn = e.target.closest("button[data-dmrequest]");
if (!dmBtn) return;
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=119" />
+ <link rel="stylesheet" href="/styles.css?v=126" />
</head>
<body>
<div class="app">
@@ -28,6 +28,7 @@
<section id="viewPanel" class="panel">
<div class="panelTitle">View</div>
+ <div class="uiHint">Use layout presets for quick panel setups. Shortcuts: <b>[</b>/<b>]</b> cycle presets, <b>-</b>/<b>=</b> cycle hives/chats in the active panel.</div>
<label class="checkRow" style="margin-top:8px;">
<span>Rack layout (experimental)</span>
<input id="toggleRackLayout" type="checkbox" />
@@ -61,6 +62,10 @@
<span>Stay connected</span>
<input id="stayConnected" type="checkbox" />
</label>
+ <label class="checkRow" style="margin-top:8px;">
+ <span>Enable hints</span>
+ <input id="enableHints" type="checkbox" />
+ </label>
<details style="margin-top:10px;">
<summary class="small muted" style="cursor:pointer;user-select:none;">Advanced display</summary>
@@ -93,6 +98,7 @@
<section id="accountPanel" class="panel">
<div class="panelTitle">Account</div>
<div class="small muted" id="authHint">Sign in to post, chat, and boost.</div>
+ <div class="uiHint">New here: create an account, then open a hive and tap <b>Chat</b> to join a conversation.</div>
<div class="small muted">Signed in as</div>
<div id="userLabel" class="userLine">Signed out</div>
@@ -119,11 +125,20 @@
Note: this is a prototype; don't reuse important passwords.
</div>
</form>
+ <div id="onboardingCard" class="onboardingCard hidden">
+ <div class="panelTitle">Onboarding</div>
+ <div id="onboardingBody" class="small muted"></div>
+ <div class="row" style="margin-top:8px;">
+ <button id="onboardingAccept" class="primary grow hidden" type="button">Accept and continue</button>
+ <button id="onboardingRefresh" class="ghost" type="button">Refresh</button>
+ </div>
+ </div>
</section>
<section id="profilePanel" class="panel">
<div class="panelTitle">Profile</div>
<div class="small muted">Set how your name appears.</div>
+ <div class="uiHint">Your profile card appears in People and when someone opens your profile from a post or chat.</div>
<label>
<span>Profile picture</span>
<input id="profileImage" type="file" accept="image/*" />
@@ -181,6 +196,7 @@
<button id="toggleComposer" class="mobileComposerToggle" type="button">New Hive</button>
</div>
</div>
+ <div class="uiHint">Use filters to narrow posts, then tap <b>Chat</b> on a hive card. Shortcut in Hives: <b>-</b>/<b>=</b> cycles collections/views.</div>
<div class="hiveTabs" id="hiveTabs">
<button type="button" data-hiveview="all" class="primary">All</button>
<button type="button" data-hiveview="starred" class="ghost">Starred</button>
@@ -189,6 +205,23 @@
<div id="feed" class="feed"></div>
</section>
+ <section id="onboardingPanel" class="panel panelFill hidden">
+ <div class="panelHeader">
+ <div>
+ <div class="panelTitle">Onboarding</div>
+ <div class="small muted">About, rules, and first steps.</div>
+ </div>
+ </div>
+ <div class="panelBody onboardingPanelBody">
+ <div class="uiHint">Read About and Rules first. If required, accept rules to unlock posting and chat.</div>
+ <div id="onboardingPanelBody" class="small muted"></div>
+ <div class="row" style="margin-top:10px;">
+ <button id="onboardingPanelAccept" class="primary grow hidden" type="button">Accept and continue</button>
+ <button id="onboardingPanelRefresh" class="ghost" type="button">Refresh</button>
+ </div>
+ </div>
+ </section>
+
<section id="profileViewPanel" class="panel panelFill hidden">
<div class="panelHeader">
<div>
@@ -200,6 +233,7 @@
<button id="profileEditToggleBtn" class="ghost smallBtn hidden" type="button">Edit profile</button>
</div>
</div>
+ <div class="uiHint">Tip: open someone from People, a hive card, or a chat message to view their profile here.</div>
<div id="profileViewBody" class="profileViewBody">
<div id="profileCard" class="profileCard"></div>
</div>
@@ -254,6 +288,7 @@
<div class="panelTitle">Create Hive</div>
<button id="toggleComposerInline" class="ghost smallBtn" type="button">Hide</button>
</div>
+ <div class="uiHint">Keep titles short and clear. Add keywords so others can find your hive faster.</div>
<form id="newPostForm" class="form">
<label>
<span>Title (max 96 chars)</span>
@@ -325,9 +360,11 @@
<div id="chatMeta" class="small muted">Select a post to chat.</div>
</div>
<div class="row chatHeaderActions">
+ <select id="chatContextSelect" class="chatContextSelect" aria-label="Open chats"></select>
<button id="chatBackToList" class="ghost smallBtn hidden" type="button">Back</button>
</div>
</div>
+ <div class="uiHint">Select a hive chat or DM first, then type your message and press Send. Shortcut in Chat: <b>-</b>/<b>=</b> cycles chat list entries.</div>
<div id="chatMessages" class="chatMessages"></div>
<div id="typingIndicator" class="typingIndicator small muted"></div>
<div id="walkieBar" class="walkieBar hidden" aria-label="Walkie talkie controls">
@@ -369,6 +406,7 @@
<input id="chatImage" class="hidden" type="file" accept="image/*" />
<input id="chatAudio" class="hidden" type="file" accept="audio/*,.mp3,.wav,.ogg,.m4a,.webm" />
</div>
+ <div class="uiHint">Use Link, GIF/Image, and Audio to attach media quickly.</div>
<button class="primary" type="submit">Send</button>
</form>
</aside>
@@ -379,11 +417,13 @@
<div class="panelHeader">
<div class="panelTitle">Moderation</div>
</div>
+ <div class="uiHint">Use tabs to review reports, manage users/hives, configure server settings, and publish onboarding content.</div>
<div class="modTabs">
<button type="button" class="ghost" data-modtab="reports">Reports</button>
<button type="button" class="ghost" data-modtab="users">Users</button>
<button type="button" class="ghost" data-modtab="hives">Hives</button>
<button type="button" class="ghost" data-modtab="server">Server</button>
+ <button type="button" class="ghost" data-modtab="onboarding">Onboarding</button>
<button type="button" class="ghost" data-modtab="log">Log</button>
</div>
<div class="modFilters">
@@ -411,12 +451,14 @@
<button id="peopleDmsTab" class="ghost" type="button">DMs</button>
</div>
<div id="peopleMembersView">
+ <div class="uiHint">Members list lets you open profiles and start DMs. Search filters by username.</div>
<div class="peopleFilters">
<input id="peopleSearch" placeholder="Search members" />
</div>
<div id="peopleList" class="peopleList small"></div>
</div>
<div id="peopleDmsView" class="peopleDms small hidden">
+ <div class="uiHint">DM requests must be accepted before chat opens. Active threads show an <b>Open</b> button.</div>
DMs coming soon.
</div>
</aside>
@@ -558,6 +600,6 @@
</div>
<div id="dockHotbar" class="dockHotbar hidden" aria-label="Docked panels"></div>
- <script src="/app.js?v=134"></script>
+ <script src="/app.js?v=146"></script>
</body>
</html>
diff --git a/public/styles.css b/public/styles.css
@@ -936,6 +936,8 @@ body {
.smallBtn {
padding: 6px 10px;
border-radius: 999px;
+ white-space: nowrap;
+ line-height: 1;
}
.mapChatToggle {
@@ -946,9 +948,10 @@ body {
}
.sidebarToggle {
- position: absolute;
- top: 12px;
+ position: fixed;
+ bottom: calc(12px + env(safe-area-inset-bottom, 0px));
left: 12px;
+ top: auto;
z-index: 20;
}
@@ -1490,6 +1493,22 @@ body {
font-weight: 800;
}
+.uiHint {
+ margin-top: 8px;
+ margin-bottom: 8px;
+ padding: 8px 10px;
+ border-radius: 10px;
+ border: 1px solid rgba(246, 240, 255, 0.14);
+ background: rgba(120, 50, 190, 0.12);
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.1rem;
+}
+
+.app:not(.hintsEnabled) .uiHint {
+ display: none !important;
+}
+
.panelTitleSub {
font-weight: 800;
margin-top: 2px;
@@ -1577,6 +1596,94 @@ body {
flex: 1;
}
+.onboardingCard {
+ margin-top: 10px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 10px;
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.onboardingAbout {
+ max-height: 180px;
+ overflow: auto;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 8px;
+ background: rgba(255, 255, 255, 0.01);
+}
+
+.onboardingRules {
+ margin: 8px 0 0 18px;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.onboardingPanelBody {
+ padding: 10px;
+ overflow: auto;
+}
+
+.onbTabs {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.onbRuleList,
+.onbRuleEditorList {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.onbRuleViewerCard,
+.onbRuleEditorCard {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 10px;
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.onbRuleEditorBody {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.onbRoleGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.onbSeverityInfo {
+ border-color: rgba(61, 220, 151, 0.55);
+ color: #9beac7;
+}
+
+.onbSeverityWarn {
+ border-color: rgba(255, 166, 0, 0.55);
+ color: #ffd28d;
+}
+
+.onbSeverityCritical {
+ border-color: rgba(255, 77, 138, 0.65);
+ color: #ffb3cb;
+}
+
+.goodText {
+ color: var(--good);
+}
+
+.badText {
+ color: var(--bad);
+}
+
label span {
display: block;
color: var(--muted);
@@ -1596,6 +1703,22 @@ select {
outline: none;
}
+select option,
+select optgroup {
+ color: #11131a;
+ background: #f4f6fb;
+}
+
+select option:checked {
+ color: #0f1724;
+ background: #8eb8e6;
+}
+
+select optgroup {
+ font-weight: 700;
+ background: #e7ebf3;
+}
+
input:focus,
textarea:focus,
select:focus,
@@ -3205,12 +3328,28 @@ button:disabled {
margin-left: auto;
}
-.app.mobileScreens .mobileChatList {
+.chatContextSelect {
+ min-width: 180px;
+ max-width: 320px;
+}
+
+.mobileChatSection {
display: flex;
flex-direction: column;
gap: 8px;
}
+.app.mobileScreens .mobileChatList {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.app.mobileScreens .chatContextSelect {
+ min-width: 132px;
+ max-width: 180px;
+}
+
.app.mobileScreens .mobileChatListItem {
width: 100%;
text-align: left;
diff --git a/server.js b/server.js
@@ -194,6 +194,7 @@ let instanceBranding = {
title: "Bzl",
subtitle: "Ephemeral hives + chat",
allowMemberPermanentPosts: false,
+ onboarding: defaultOnboardingConfig(),
appearance: {
bg: "#060611",
panel: "#0c0c18",
@@ -539,10 +540,116 @@ function sanitizeInstanceText(text, maxLen) {
return clean.replace(/\s+/g, " ").trim().slice(0, maxLen);
}
+function defaultOnboardingConfig() {
+ return {
+ enabled: true,
+ about: { content: "", updatedAt: 0, updatedBy: "" },
+ rules: { version: 1, requireAcceptance: false, blockReadUntilAccepted: false, items: [] },
+ roleSelect: { enabled: true, selfAssignableRoleIds: [] },
+ tutorial: { enabled: true, version: 1 }
+ };
+}
+
+function parseLegacyRulesTextToItems(text) {
+ const raw = typeof text === "string" ? text : "";
+ const clean = sanitizeRichHtml(raw).trim();
+ if (!clean) return [];
+ const plain = sanitizeHtml(clean, { allowedTags: [], allowedAttributes: {} }).replace(/\s+/g, " ").trim();
+ if (!plain) return [];
+ return [
+ {
+ id: "r1",
+ order: 1,
+ name: "Server rules",
+ shortDescription: plain.slice(0, 180),
+ description: clean,
+ severity: "info"
+ }
+ ];
+}
+
+function sanitizeOnboardingRuleItem(raw, index = 0) {
+ if (!raw || typeof raw !== "object") return null;
+ const id = typeof raw.id === "string" && raw.id.trim() ? raw.id.trim().slice(0, 40) : `r${index + 1}`;
+ const name = sanitizeInstanceText(raw.name || "", 60);
+ const shortDescription = sanitizeInstanceText(raw.shortDescription || "", 180);
+ const descriptionRaw = typeof raw.description === "string" ? raw.description : "";
+ const description = sanitizeRichHtml(descriptionRaw).slice(0, 12_000);
+ const severityRaw = String(raw.severity || "").trim().toLowerCase();
+ const severity = severityRaw === "warn" || severityRaw === "critical" ? severityRaw : "info";
+ if (!name && !shortDescription && !description) return null;
+ const order = Number.isFinite(Number(raw.order)) ? Math.max(1, Math.floor(Number(raw.order))) : index + 1;
+ return { id, order, name: name || `Rule ${order}`, shortDescription, description, severity };
+}
+
+function sanitizeOnboardingConfig(raw) {
+ const fallback = defaultOnboardingConfig();
+ const src = raw && typeof raw === "object" ? raw : {};
+ const aboutRaw = src.about && typeof src.about === "object" ? src.about : {};
+ const rulesRaw = src.rules && typeof src.rules === "object" ? src.rules : {};
+ const roleSelectRaw = src.roleSelect && typeof src.roleSelect === "object" ? src.roleSelect : {};
+ const tutorialRaw = src.tutorial && typeof src.tutorial === "object" ? src.tutorial : {};
+ const aboutContent = sanitizeRichHtml(typeof aboutRaw.content === "string" ? aboutRaw.content : "").slice(0, 30_000);
+ const legacyRulesText =
+ typeof src.rulesText === "string"
+ ? src.rulesText
+ : typeof rulesRaw.text === "string"
+ ? rulesRaw.text
+ : typeof rulesRaw.rulesText === "string"
+ ? rulesRaw.rulesText
+ : "";
+ const itemsRaw = Array.isArray(rulesRaw.items) ? rulesRaw.items : parseLegacyRulesTextToItems(legacyRulesText);
+ const items = [];
+ const seen = new Set();
+ for (let i = 0; i < itemsRaw.length && items.length < 200; i += 1) {
+ const clean = sanitizeOnboardingRuleItem(itemsRaw[i], i);
+ if (!clean) continue;
+ if (seen.has(clean.id)) continue;
+ seen.add(clean.id);
+ items.push(clean);
+ }
+ items.sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || String(a.id || "").localeCompare(String(b.id || "")));
+ const rulesVersion = Math.max(1, Math.floor(Number(rulesRaw.version || fallback.rules.version)));
+ const tutorialVersion = Math.max(1, Math.floor(Number(tutorialRaw.version || fallback.tutorial.version)));
+ return {
+ enabled: Object.prototype.hasOwnProperty.call(src, "enabled") ? Boolean(src.enabled) : fallback.enabled,
+ about: {
+ content: aboutContent,
+ updatedAt: Number(aboutRaw.updatedAt || 0) || 0,
+ updatedBy: normalizeUsername(aboutRaw.updatedBy || "")
+ },
+ rules: {
+ version: rulesVersion,
+ requireAcceptance: Boolean(rulesRaw.requireAcceptance),
+ blockReadUntilAccepted: Boolean(rulesRaw.blockReadUntilAccepted),
+ items
+ },
+ roleSelect: {
+ enabled: Object.prototype.hasOwnProperty.call(roleSelectRaw, "enabled") ? Boolean(roleSelectRaw.enabled) : fallback.roleSelect.enabled,
+ selfAssignableRoleIds: sanitizeCustomRoleKeys(roleSelectRaw.selfAssignableRoleIds)
+ },
+ tutorial: {
+ enabled: Object.prototype.hasOwnProperty.call(tutorialRaw, "enabled") ? Boolean(tutorialRaw.enabled) : fallback.tutorial.enabled,
+ version: tutorialVersion
+ }
+ };
+}
+
+function sanitizeOnboardingState(raw) {
+ const src = raw && typeof raw === "object" ? raw : {};
+ return {
+ acceptedRulesVersion: Math.max(0, Math.floor(Number(src.acceptedRulesVersion || 0))),
+ acceptedAt: Number(src.acceptedAt || 0) || 0,
+ tutorialCompletedVersion: Math.max(0, Math.floor(Number(src.tutorialCompletedVersion || 0))),
+ selectedRoleIds: sanitizeCustomRoleKeys(src.selectedRoleIds)
+ };
+}
+
function sanitizeInstanceBranding(raw) {
const title = sanitizeInstanceText(raw?.title || "", INSTANCE_TITLE_MAX_LEN) || "Bzl";
const subtitle = sanitizeInstanceText(raw?.subtitle || "", INSTANCE_SUBTITLE_MAX_LEN) || "Ephemeral hives + chat";
const allowMemberPermanentPosts = Boolean(raw?.allowMemberPermanentPosts);
+ const onboarding = sanitizeOnboardingConfig(raw?.onboarding);
const appearanceRaw = raw?.appearance && typeof raw.appearance === "object" ? raw.appearance : {};
const bg = sanitizeColorHex(appearanceRaw.bg) || "#060611";
const panel = sanitizeColorHex(appearanceRaw.panel) || "#0c0c18";
@@ -557,7 +664,7 @@ function sanitizeInstanceBranding(raw) {
const linePct = sanitizePercentInt(appearanceRaw.linePct, 10);
const panel2Pct = sanitizePercentInt(appearanceRaw.panel2Pct, 2);
const appearance = { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct };
- return { title, subtitle, allowMemberPermanentPosts, appearance };
+ return { title, subtitle, allowMemberPermanentPosts, onboarding, appearance };
}
function sanitizeAvatar(avatar) {
@@ -807,6 +914,81 @@ function userState(username) {
};
}
+function userOnboardingState(username) {
+ const normalized = normalizeUsername(username || "");
+ if (!normalized) return sanitizeOnboardingState(null);
+ const user = usersByName.get(normalized);
+ return sanitizeOnboardingState(user?.onboardingState);
+}
+
+function userNeedsOnboardingAcceptance(username) {
+ const normalized = normalizeUsername(username || "");
+ if (!normalized) return false;
+ if (hasRole(normalized, ROLE_MODERATOR)) return false;
+ const onboarding = sanitizeOnboardingConfig(instanceBranding?.onboarding);
+ if (!onboarding.enabled) return false;
+ if (!onboarding.rules.requireAcceptance) return false;
+ const state = userOnboardingState(normalized);
+ const requiredVersion = Math.max(1, Number(onboarding.rules.version || 1));
+ return Number(state.acceptedRulesVersion || 0) < requiredVersion;
+}
+
+function userAllowedToReadContent(username) {
+ const onboarding = sanitizeOnboardingConfig(instanceBranding?.onboarding);
+ if (!onboarding.enabled) return true;
+ if (!onboarding.rules.requireAcceptance) return true;
+ if (!onboarding.rules.blockReadUntilAccepted) return true;
+ const normalized = normalizeUsername(username || "");
+ if (!normalized) return false;
+ if (hasRole(normalized, ROLE_MODERATOR)) return true;
+ return !userNeedsOnboardingAcceptance(normalized);
+}
+
+function onboardingPayloadForUser(username) {
+ const cfg = sanitizeOnboardingConfig(instanceBranding?.onboarding);
+ const normalized = normalizeUsername(username || "");
+ const state = userOnboardingState(normalized);
+ const needsAcceptance = normalized ? userNeedsOnboardingAcceptance(normalized) : Boolean(cfg.rules.requireAcceptance && cfg.rules.blockReadUntilAccepted);
+ return {
+ enabled: cfg.enabled,
+ rulesVersion: Number(cfg.rules.version || 1),
+ requireAcceptance: Boolean(cfg.rules.requireAcceptance),
+ blockReadUntilAccepted: Boolean(cfg.rules.blockReadUntilAccepted),
+ acceptedRulesVersion: Number(state.acceptedRulesVersion || 0),
+ acceptedAt: Number(state.acceptedAt || 0),
+ tutorialVersion: Number(cfg.tutorial.version || 1),
+ tutorialCompletedVersion: Number(state.tutorialCompletedVersion || 0),
+ selectedRoleIds: sanitizeCustomRoleKeys(state.selectedRoleIds),
+ needsAcceptance
+ };
+}
+
+function onboardingRulesOrdered() {
+ const cfg = sanitizeOnboardingConfig(instanceBranding?.onboarding);
+ const items = Array.isArray(cfg?.rules?.items) ? cfg.rules.items.slice() : [];
+ items.sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || String(a.id || "").localeCompare(String(b.id || "")));
+ return items;
+}
+
+function resolveOnboardingRuleByOrdinal(ordinal) {
+ const n = Math.max(1, Math.floor(Number(ordinal || 0)));
+ if (!Number.isFinite(n)) return null;
+ const ordered = onboardingRulesOrdered();
+ return ordered[n - 1] || null;
+}
+
+function expandRuleRefsInPlainText(text) {
+ const input = String(text || "");
+ if (!input) return input;
+ const re = /(^|[\s(])&(\d{1,3})(?=$|[\s).,!?;:])/g;
+ return input.replace(re, (match, lead, num) => {
+ const rule = resolveOnboardingRuleByOrdinal(Number(num));
+ if (!rule) return match;
+ const short = String(rule.shortDescription || rule.name || `Rule ${num}`).replace(/\s+/g, " ").trim();
+ return `${lead}[Rule ${num}: ${short}]`;
+ });
+}
+
function authPayloadForUser(username) {
const state = userState(username);
const user = usersByName.get(normalizeUsername(username));
@@ -814,6 +996,7 @@ function authPayloadForUser(username) {
username,
role: state.role,
customRoles: sanitizeCustomRoleKeys(user?.customRoles),
+ onboarding: onboardingPayloadForUser(username),
mutedUntil: state.mutedUntil,
suspendedUntil: state.suspendedUntil,
banned: state.banned
@@ -1040,7 +1223,8 @@ function loadUsersFromDisk() {
suspendedUntil: parseUntil(u?.suspendedUntil),
banned: Boolean(u?.banned),
starredPostIds: sanitizePostIdList(u?.starredPostIds),
- hiddenPostIds: sanitizePostIdList(u?.hiddenPostIds)
+ hiddenPostIds: sanitizePostIdList(u?.hiddenPostIds),
+ onboardingState: sanitizeOnboardingState(u?.onboardingState)
});
}
sorted.sort((a, b) => Number(a.createdAt || 0) - Number(b.createdAt || 0));
@@ -1388,6 +1572,7 @@ function collectionForPost(post) {
}
function canUserSeePostByCollection(username, post) {
+ if (!userAllowedToReadContent(username)) return false;
return hasCollectionAccessForUser(username, collectionForPost(post));
}
@@ -2007,6 +2192,19 @@ function loadInstanceFromDisk() {
? data.allowMemberPermanentPosts
: data?.instance?.allowMemberPermanentPosts
),
+ onboarding:
+ data?.onboarding && typeof data.onboarding === "object"
+ ? data.onboarding
+ : data?.instance?.onboarding && typeof data.instance.onboarding === "object"
+ ? data.instance.onboarding
+ : {
+ rulesText:
+ typeof data?.rulesText === "string"
+ ? data.rulesText
+ : typeof data?.instance?.rulesText === "string"
+ ? data.instance.rulesText
+ : ""
+ },
appearance
});
lastInstanceBroadcastHash = JSON.stringify(instanceBranding);
@@ -2151,9 +2349,12 @@ function serializeDmMessageForWs(message) {
}
const text = typeof parsed?.text === "string" ? parsed.text : "";
const html = typeof parsed?.html === "string" ? parsed.html : "";
+ const asMod = Boolean(parsed?.asMod) || String(message?.from || "").trim().toLowerCase() === "mod";
+ const fromUser = asMod ? "mod" : normalizeUsername(message?.from || "");
return {
id: String(message?.id || ""),
- fromUser: String(message?.from || ""),
+ fromUser,
+ asMod,
createdAt: Number(message?.createdAt || 0),
text,
html
@@ -3613,6 +3814,7 @@ function sendLoginOk(ws, username, sessionToken) {
mutedUntil: state.mutedUntil,
suspendedUntil: state.suspendedUntil,
banned: state.banned,
+ onboarding: onboardingPayloadForUser(username),
canModerate: hasRole(username, ROLE_MODERATOR),
sessionToken: sessionToken || "",
profile: getPublicProfile(username),
@@ -3648,6 +3850,9 @@ function enforceUserState(ws, mode) {
if (mode === "chat" && state.suspended) return { ok: false, message: "Your account is suspended." };
if (mode === "chat" && state.muted) return { ok: false, message: "You are muted right now." };
if (mode === "write" && state.suspended) return { ok: false, message: "Your account is suspended." };
+ if ((mode === "chat" || mode === "write") && userNeedsOnboardingAcceptance(username)) {
+ return { ok: false, message: "Please accept the server rules in Account before posting or chatting." };
+ }
return { ok: true, state };
}
@@ -4160,6 +4365,7 @@ wss.on("connection", (ws, req) => {
suspendedUntil: 0,
banned: false,
canModerate: false,
+ onboarding: onboardingPayloadForUser(""),
canRegisterFirstUser: canRegisterFirstUser(ws),
registrationEnabled: registrationEnabled()
}
@@ -4363,6 +4569,7 @@ wss.on("connection", (ws, req) => {
links: [],
starredPostIds: [],
hiddenPostIds: [],
+ onboardingState: sanitizeOnboardingState(null),
createdAt: now()
};
try {
@@ -4822,9 +5029,10 @@ wss.on("connection", (ws, req) => {
.replace(/\s+/g, " ")
.trim()
.slice(0, CHAT_MAX_LEN);
+ const expandedText = expandRuleRefsInPlainText(safeText).slice(0, CHAT_MAX_LEN);
const hasMedia = /<(img|audio)\b/i.test(safeHtml);
- if (!postId || (!safeText && !hasMedia)) return;
+ if (!postId || (!expandedText && !hasMedia)) return;
const entry = posts.get(postId);
if (!entry) {
@@ -4868,14 +5076,14 @@ wss.on("connection", (ws, req) => {
createdAt: Number(replyTarget.createdAt || now())
}
: null;
- const mentions = extractMentionUsernames(safeText);
+ const mentions = extractMentionUsernames(expandedText);
const wantsMod = Boolean(msg.asMod);
const asMod = wantsMod && hasRole(ws.user.username, ROLE_MODERATOR);
const message = {
id: toId(),
postId,
- text: safeText || "[media]",
+ text: expandedText || "[media]",
html: safeHtml,
asMod,
mentions: sanitizePostMode(entry.post?.mode) === "walkie" ? [] : mentions,
@@ -5487,7 +5695,7 @@ wss.on("connection", (ws, req) => {
fontBody: msg.fontBody,
fontMono: msg.fontMono
};
- instanceBranding = sanitizeInstanceBranding({ title, subtitle, allowMemberPermanentPosts, appearance });
+ instanceBranding = sanitizeInstanceBranding({ ...instanceBranding, title, subtitle, allowMemberPermanentPosts, appearance });
try {
persistInstanceToDisk();
} catch (e) {
@@ -5555,6 +5763,138 @@ wss.on("connection", (ws, req) => {
return;
}
+ if (msg.type === "instanceSetOnboarding") {
+ const actor = ws?.user?.username;
+ if (!actor || !hasRole(actor, ROLE_MODERATOR)) {
+ ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
+ return;
+ }
+ const current = sanitizeOnboardingConfig(instanceBranding?.onboarding);
+ const next = { ...current };
+ const publish = Boolean(msg.publish);
+
+ if (Object.prototype.hasOwnProperty.call(msg, "enabled")) next.enabled = Boolean(msg.enabled);
+
+ const about = msg.about && typeof msg.about === "object" ? msg.about : null;
+ if (about) {
+ if (Object.prototype.hasOwnProperty.call(about, "content")) {
+ next.about = {
+ ...next.about,
+ content: sanitizeRichHtml(typeof about.content === "string" ? about.content : "").slice(0, 30_000),
+ updatedAt: now(),
+ updatedBy: normalizeUsername(actor)
+ };
+ }
+ }
+
+ const rules = msg.rules && typeof msg.rules === "object" ? msg.rules : null;
+ if (rules) {
+ if (Object.prototype.hasOwnProperty.call(rules, "requireAcceptance")) {
+ next.rules.requireAcceptance = Boolean(rules.requireAcceptance);
+ }
+ if (Object.prototype.hasOwnProperty.call(rules, "blockReadUntilAccepted")) {
+ if (!hasRole(actor, ROLE_OWNER)) {
+ ws.send(JSON.stringify({ type: "permissionDenied", message: "Owner access required to block read access." }));
+ return;
+ }
+ next.rules.blockReadUntilAccepted = Boolean(rules.blockReadUntilAccepted);
+ }
+ if (Array.isArray(rules.items)) {
+ const sanitizedItems = [];
+ const seenIds = new Set();
+ for (let i = 0; i < rules.items.length && sanitizedItems.length < 200; i += 1) {
+ const item = sanitizeOnboardingRuleItem(rules.items[i], i);
+ if (!item) continue;
+ if (seenIds.has(item.id)) continue;
+ seenIds.add(item.id);
+ sanitizedItems.push(item);
+ }
+ next.rules.items = sanitizedItems;
+ }
+ if (publish) next.rules.version = Math.max(1, Number(next.rules.version || 1) + 1);
+ }
+
+ const roleSelect = msg.roleSelect && typeof msg.roleSelect === "object" ? msg.roleSelect : null;
+ if (roleSelect) {
+ if (Object.prototype.hasOwnProperty.call(roleSelect, "enabled")) next.roleSelect.enabled = Boolean(roleSelect.enabled);
+ if (Object.prototype.hasOwnProperty.call(roleSelect, "selfAssignableRoleIds")) {
+ next.roleSelect.selfAssignableRoleIds = sanitizeCustomRoleKeys(roleSelect.selfAssignableRoleIds);
+ }
+ }
+
+ const tutorial = msg.tutorial && typeof msg.tutorial === "object" ? msg.tutorial : null;
+ if (publish && !rules) next.rules.version = Math.max(1, Number(next.rules.version || 1) + 1);
+ if (tutorial) {
+ if (Object.prototype.hasOwnProperty.call(tutorial, "enabled")) next.tutorial.enabled = Boolean(tutorial.enabled);
+ if (Boolean(tutorial.bumpVersion) || publish) next.tutorial.version = Math.max(1, Number(next.tutorial.version || 1) + 1);
+ }
+
+ instanceBranding = sanitizeInstanceBranding({ ...instanceBranding, onboarding: next });
+ try {
+ persistInstanceToDisk();
+ } catch (e) {
+ ws.send(JSON.stringify({ type: "error", message: e?.message || "Failed to save onboarding settings." }));
+ return;
+ }
+ broadcastInstanceUpdated(true);
+ ws.send(JSON.stringify({ type: "instanceOk", instance: instanceBranding }));
+ ws.send(JSON.stringify({ type: "onboardingState", onboarding: onboardingPayloadForUser(actor) }));
+ appendModLog({
+ actionType: "instance_onboarding_set",
+ actor,
+ targetType: "system",
+ targetId: "instance",
+ reason: "Updated onboarding settings",
+ metadata: {
+ enabled: instanceBranding?.onboarding?.enabled,
+ rulesVersion: Number(instanceBranding?.onboarding?.rules?.version || 1),
+ requireAcceptance: Boolean(instanceBranding?.onboarding?.rules?.requireAcceptance),
+ blockReadUntilAccepted: Boolean(instanceBranding?.onboarding?.rules?.blockReadUntilAccepted)
+ }
+ });
+ return;
+ }
+
+ if (msg.type === "onboardingGet") {
+ const actor = ws?.user?.username || "";
+ ws.send(JSON.stringify({ type: "onboardingState", onboarding: onboardingPayloadForUser(actor) }));
+ return;
+ }
+
+ if (msg.type === "onboardingAcceptRules") {
+ const actor = ws?.user?.username;
+ if (!actor) {
+ ws.send(JSON.stringify({ type: "error", message: "Please sign in first." }));
+ return;
+ }
+ const cfg = sanitizeOnboardingConfig(instanceBranding?.onboarding);
+ if (!cfg.enabled || !cfg.rules.requireAcceptance) {
+ ws.send(JSON.stringify({ type: "onboardingState", onboarding: onboardingPayloadForUser(actor) }));
+ return;
+ }
+ const requiredVersion = Math.max(1, Number(cfg.rules.version || 1));
+ const write = writeUserPatch(actor, (u) => {
+ const prior = sanitizeOnboardingState(u?.onboardingState);
+ return {
+ ...u,
+ onboardingState: {
+ ...prior,
+ acceptedRulesVersion: requiredVersion,
+ acceptedAt: now()
+ }
+ };
+ });
+ if (!write.ok) {
+ ws.send(JSON.stringify({ type: "error", message: write.message || "Failed to accept rules." }));
+ return;
+ }
+ ws.send(JSON.stringify({ type: "onboardingState", onboarding: onboardingPayloadForUser(actor) }));
+ ws.send(JSON.stringify({ type: "authState", ...authPayloadForUser(actor), canModerate: hasRole(actor, ROLE_MODERATOR), prefs: getUserPrefs(actor) }));
+ sendCollectionsForWs(ws);
+ sendPostsSnapshot(ws);
+ return;
+ }
+
if (msg.type === "collectionCreate") {
if (!modViewAllowed(ws)) {
ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
@@ -6239,6 +6579,114 @@ wss.on("connection", (ws, req) => {
return;
}
+ if (msg.type === "dmSendMod") {
+ const actor = ws.user?.username;
+ if (!actor) {
+ ws.send(JSON.stringify({ type: "error", message: "Please sign in first." }));
+ return;
+ }
+ if (!hasRole(actor, ROLE_MODERATOR)) {
+ ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
+ return;
+ }
+ const toUser = normalizeUsername(msg.to || "");
+ if (!toUser || toUser === normalizeUsername(actor)) {
+ ws.send(JSON.stringify({ type: "error", message: "Pick a valid user." }));
+ return;
+ }
+ if (!usersByName.has(toUser)) {
+ ws.send(JSON.stringify({ type: "error", message: "User not found." }));
+ return;
+ }
+
+ let thread = null;
+ for (const t of dmThreadsById.values()) {
+ if (!t?.users) continue;
+ const users = t.users.map((u) => normalizeUsername(u));
+ if (users.includes(normalizeUsername(actor)) && users.includes(toUser)) {
+ thread = t;
+ break;
+ }
+ }
+ if (!thread) {
+ thread = {
+ id: toId(),
+ users: [normalizeUsername(actor), toUser],
+ requestedBy: normalizeUsername(actor),
+ pendingFor: "",
+ state: "active",
+ createdAt: now(),
+ updatedAt: now(),
+ lastMessageAt: 0,
+ messages: []
+ };
+ } else {
+ thread.requestedBy = normalizeUsername(actor);
+ thread.pendingFor = "";
+ thread.state = "active";
+ thread.updatedAt = now();
+ }
+
+ const rawText = typeof msg.text === "string" ? msg.text : "";
+ const rawHtml = typeof msg.html === "string" ? msg.html : "";
+ const hasHtml = rawHtml && rawHtml.trim().length > 0;
+ const safeHtml = hasHtml ? sanitizeRichHtml(rawHtml) : "";
+ const safeText = (hasHtml ? sanitizeHtml(safeHtml, { allowedTags: [], allowedAttributes: {} }) : rawText)
+ .replace(/\s+/g, " ")
+ .trim()
+ .slice(0, CHAT_MAX_LEN);
+ if (!safeText && !safeHtml) {
+ ws.send(JSON.stringify({ type: "error", message: "Message is empty." }));
+ return;
+ }
+
+ const payload = JSON.stringify({ text: safeText, html: safeHtml, asMod: true });
+ const enc = dmEncryptUtf8(payload);
+ if (!enc) {
+ ws.send(JSON.stringify({ type: "error", message: "Failed to store DM message." }));
+ return;
+ }
+ const message = { id: toId(), from: "mod", createdAt: now(), enc };
+ thread.messages = Array.isArray(thread.messages) ? thread.messages : [];
+ thread.messages.push(message);
+ if (thread.messages.length > 500) thread.messages.splice(0, thread.messages.length - 500);
+ thread.lastMessageAt = message.createdAt;
+ thread.updatedAt = now();
+ dmThreadsById.set(thread.id, thread);
+ persistDmsToDisk();
+
+ appendModLog({
+ actionType: "dm_mod_message",
+ actor,
+ targetType: "user",
+ targetId: toUser,
+ reason: "Sent moderator DM message",
+ metadata: { threadId: thread.id }
+ });
+
+ const wsMsg = { type: "dmMessage", threadId: thread.id, message: serializeDmMessageForWs(message) };
+ sendToSockets(
+ (client) => {
+ const name = client?.user?.username;
+ if (!name) return false;
+ const n = normalizeUsername(name);
+ return thread.users.includes(n);
+ },
+ wsMsg
+ );
+ sendToSockets(
+ (client) => normalizeUsername(client?.user?.username || "") === toUser,
+ {
+ type: "dmModMessageReceived",
+ threadId: thread.id,
+ fromUser: "mod",
+ preview: safeText.slice(0, 160)
+ }
+ );
+ broadcastDmThread(thread);
+ return;
+ }
+
if (msg.type === "dmSend") {
const fromUser = ws.user?.username;
if (!fromUser) {