bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

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:
MCLEAN_INSTALL/public/app.js | 1301++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
MCLEAN_INSTALL/public/index.html | 46++++++++++++++++++++++++++++++++++++++++++++--
MCLEAN_INSTALL/public/styles.css | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mdocs/COMMUNITY_FEATURES_ROADMAP.md | 4+++-
Adocs/ONBOARDING_AND_MOD_MESSAGE_IMPLEMENTATION_SPEC.md | 244+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpublic/app.js | 1301++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mpublic/index.html | 46++++++++++++++++++++++++++++++++++++++++++++--
Mpublic/styles.css | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mserver.js | 462+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
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) {