app.js (472681B)
1 ο»Ώconst connBadge = document.getElementById("connBadge"); 2 const lanHint = document.getElementById("lanHint"); 3 4 const appRoot = document.querySelector(".app"); 5 const toggleSidebarBtn = document.getElementById("toggleSidebar"); 6 const showSidebarBtn = document.getElementById("showSidebar"); 7 const togglePeopleBtn = document.getElementById("togglePeople"); 8 const peopleDrawerEl = document.getElementById("peopleDrawer"); 9 const closePeopleBtn = document.getElementById("closePeople"); 10 const instanceTitleEl = document.getElementById("instanceTitle"); 11 const instanceSubtitleEl = document.getElementById("instanceSubtitle"); 12 const peopleMembersTabBtn = document.getElementById("peopleMembersTab"); 13 const peopleDmsTabBtn = document.getElementById("peopleDmsTab"); 14 const peopleMembersViewEl = document.getElementById("peopleMembersView"); 15 const peopleDmsViewEl = document.getElementById("peopleDmsView"); 16 const peopleSearchEl = document.getElementById("peopleSearch"); 17 const peopleListEl = document.getElementById("peopleList"); 18 const mobileNavEl = document.getElementById("mobileNav"); 19 const mobileFourthBtn = document.getElementById("mobileFourthBtn"); 20 const mobileMoreSheetEl = document.getElementById("mobileMoreSheet"); 21 const mobileMoreCloseBtn = document.getElementById("mobileMoreClose"); 22 const mobileMoreSearchEl = document.getElementById("mobileMoreSearch"); 23 const mobileMoreListEl = document.getElementById("mobileMoreList"); 24 const mobileScreenHostEl = document.getElementById("mobileScreenHost"); 25 const enableNotifsBtn = document.getElementById("enableNotifs"); 26 const notifStatus = document.getElementById("notifStatus"); 27 const toggleReactionsEl = document.getElementById("toggleReactions"); 28 const hivesViewModeEl = document.getElementById("hivesViewMode"); 29 const toggleRackLayoutEl = document.getElementById("toggleRackLayout"); 30 const toggleSideRackEl = document.getElementById("toggleSideRack"); 31 const toggleRightRackEl = document.getElementById("toggleRightRack"); 32 const layoutPresetEl = document.getElementById("layoutPreset"); 33 const uiScaleEl = document.getElementById("uiScale"); 34 const deviceLayoutEl = document.getElementById("deviceLayout"); 35 const stayConnectedEl = document.getElementById("stayConnected"); 36 const enableHintsEl = document.getElementById("enableHints"); 37 const chatEnterModeEl = document.getElementById("chatEnterMode"); 38 const openShortcutHelpBtn = document.getElementById("openShortcutHelp"); 39 const resetCurrentLayoutBtn = document.getElementById("resetCurrentLayout"); 40 const dockHotbarEl = document.getElementById("dockHotbar"); 41 const showSideRackBtn = document.getElementById("showSideRack"); 42 const showRightRackBtn = document.getElementById("showRightRack"); 43 const chatModToggleWrapEl = document.getElementById("chatModToggleWrap"); 44 const chatModToggleEl = document.getElementById("chatModToggle"); 45 46 const authHint = document.getElementById("authHint"); 47 const onboardingCard = document.getElementById("onboardingCard"); 48 const onboardingBody = document.getElementById("onboardingBody"); 49 const onboardingAcceptBtn = document.getElementById("onboardingAccept"); 50 const onboardingRefreshBtn = document.getElementById("onboardingRefresh"); 51 const userLabel = document.getElementById("userLabel"); 52 const authForm = document.getElementById("authForm"); 53 const authUser = document.getElementById("authUser"); 54 const authPass = document.getElementById("authPass"); 55 const codeRow = document.getElementById("codeRow"); 56 const authCode = document.getElementById("authCode"); 57 const registerBtn = document.getElementById("registerBtn"); 58 const logoutBtn = document.getElementById("logoutBtn"); 59 60 const profileImageInput = document.getElementById("profileImage"); 61 const profilePreview = document.getElementById("profilePreview"); 62 const removeProfileImageBtn = document.getElementById("removeProfileImage"); 63 const nameColorInput = document.getElementById("nameColor"); 64 const saveProfileBtn = document.getElementById("saveProfile"); 65 const profileStatus = document.getElementById("profileStatus"); 66 // Instance + plugin admin UI lives in Moderation -> Server tab (rendered dynamically). 67 const modPanelEl = document.getElementById("modPanel"); 68 const modBodyEl = document.getElementById("modBody"); 69 const modRefreshBtn = document.getElementById("modRefresh"); 70 const modReportStatusEl = document.getElementById("modReportStatus"); 71 const modModal = document.getElementById("modModal"); 72 const modModalTitle = document.getElementById("modModalTitle"); 73 const modModalBody = document.getElementById("modModalBody"); 74 const modModalPrimary = document.getElementById("modModalPrimary"); 75 const modModalCancel = document.getElementById("modModalCancel"); 76 const modModalClose = document.getElementById("modModalClose"); 77 const modModalStatus = document.getElementById("modModalStatus"); 78 const mediaModal = document.getElementById("mediaModal"); 79 const mediaModalTitle = document.getElementById("mediaModalTitle"); 80 const mediaModalImg = document.getElementById("mediaModalImg"); 81 const mediaModalOpenLink = document.getElementById("mediaModalOpenLink"); 82 const mediaModalCopyLink = document.getElementById("mediaModalCopyLink"); 83 const mediaModalClose = document.getElementById("mediaModalClose"); 84 const mediaModalStatus = document.getElementById("mediaModalStatus"); 85 const shortcutHelpModal = document.getElementById("shortcutHelpModal"); 86 const shortcutHelpCloseBtn = document.getElementById("shortcutHelpClose"); 87 88 const newPostForm = document.getElementById("newPostForm"); 89 const pollinatePanel = document.getElementById("pollinatePanel"); 90 const toggleComposerBtn = document.getElementById("toggleComposer"); 91 const toggleComposerInlineBtn = document.getElementById("toggleComposerInline"); 92 const mainRackEl = document.getElementById("mainRack"); 93 const mainWorkspaceRackEl = document.getElementById("mainWorkspaceRack"); 94 const mainSideRackEl = document.getElementById("mainSideRack"); 95 const hivesPanelEl = document.getElementById("hivesPanel"); 96 const postTitleInput = document.getElementById("postTitle"); 97 const postImageInput = document.getElementById("postImage"); 98 const postAudioInput = document.getElementById("postAudio"); 99 const editor = document.getElementById("editor"); 100 const postCollectionEl = document.getElementById("postCollection"); 101 const keywordsEl = document.getElementById("keywords"); 102 const ttlMinutesEl = document.getElementById("ttlMinutes"); 103 const isProtectedEl = document.getElementById("isProtected"); 104 const isWalkieEl = document.getElementById("isWalkie"); 105 const postPasswordEl = document.getElementById("postPassword"); 106 107 const filterKeywordsEl = document.getElementById("filterKeywords"); 108 const filterAuthorEl = document.getElementById("filterAuthor"); 109 const sortByEl = document.getElementById("sortBy"); 110 const mobileHiveSearchBtn = document.getElementById("mobileHiveSearch"); 111 const mobileSortCycleBtn = document.getElementById("mobileSortCycle"); 112 const clearFilterBtn = document.getElementById("clearFilter"); 113 const feedEl = document.getElementById("feed"); 114 const hiveTabsEl = document.getElementById("hiveTabs"); 115 const onboardingPanelEl = document.getElementById("onboardingPanel"); 116 const onboardingPanelBodyEl = document.getElementById("onboardingPanelBody"); 117 const onboardingPanelAcceptBtn = document.getElementById("onboardingPanelAccept"); 118 const onboardingPanelRefreshBtn = document.getElementById("onboardingPanelRefresh"); 119 const profileViewPanel = document.getElementById("profileViewPanel"); 120 const profileViewTitle = document.getElementById("profileViewTitle"); 121 const profileViewMeta = document.getElementById("profileViewMeta"); 122 const profileCard = document.getElementById("profileCard"); 123 const profileBackBtn = document.getElementById("profileBackBtn"); 124 const profileEditToggleBtn = document.getElementById("profileEditToggleBtn"); 125 const profileEditPanel = document.getElementById("profileEditPanel"); 126 const profilePronounsInput = document.getElementById("profilePronouns"); 127 const profileThemeSongUrlInput = document.getElementById("profileThemeSongUrl"); 128 const profileThemeSongUploadBtn = document.getElementById("profileThemeSongUploadBtn"); 129 const profileThemeSongClearBtn = document.getElementById("profileThemeSongClearBtn"); 130 const profileThemeSongFileInput = document.getElementById("profileThemeSongFile"); 131 const profileThemeSongPreview = document.getElementById("profileThemeSongPreview"); 132 const profileBioToolbar = document.getElementById("profileBioToolbar"); 133 const profileBioEditor = document.getElementById("profileBioEditor"); 134 const profileBioImageFileInput = document.getElementById("profileBioImageFile"); 135 const profileBioAudioFileInput = document.getElementById("profileBioAudioFile"); 136 const profileAddLinkBtn = document.getElementById("profileAddLinkBtn"); 137 const profileLinksEditor = document.getElementById("profileLinksEditor"); 138 const profileSaveBtn = document.getElementById("profileSaveBtn"); 139 const profileCancelBtn = document.getElementById("profileCancelBtn"); 140 141 const chatTitle = document.getElementById("chatTitle"); 142 const chatMeta = document.getElementById("chatMeta"); 143 const chatContextSelectEl = document.getElementById("chatContextSelect"); 144 const chatBackToListBtn = document.getElementById("chatBackToList"); 145 const chatMessagesEl = document.getElementById("chatMessages"); 146 const typingIndicator = document.getElementById("typingIndicator"); 147 const chatForm = document.getElementById("chatForm"); 148 const chatReplyBanner = document.getElementById("chatReplyBanner"); 149 const chatReplyWho = document.getElementById("chatReplyWho"); 150 const chatReplyText = document.getElementById("chatReplyText"); 151 const chatReplyCancelBtn = document.getElementById("chatReplyCancel"); 152 const chatEditor = document.getElementById("chatEditor"); 153 const mentionMenuEl = document.getElementById("mentionMenu"); 154 const chatImageInput = document.getElementById("chatImage"); 155 const chatAudioInput = document.getElementById("chatAudio"); 156 157 // When selecting images/audio for chat, route the insertion to the most-recently focused rich editor 158 // (main chat panel or a chat instance panel). 159 let chatUploadTargetEditor = chatEditor; 160 const walkieBarEl = document.getElementById("walkieBar"); 161 const walkieRecordBtn = document.getElementById("walkieRecordBtn"); 162 const walkieStatusEl = document.getElementById("walkieStatus"); 163 const sidebarPanelEl = document.querySelector(".sidebar"); 164 const chatResizeHandle = document.getElementById("chatResizeHandle"); 165 const sidebarResizeHandle = document.getElementById("sidebarResizeHandle"); 166 const mainResizeHandle = document.getElementById("mainResizeHandle"); 167 const chatPanelEl = document.querySelector(".chat"); 168 const peopleResizeHandle = document.getElementById("peopleResizeHandle"); 169 const chatHeaderEl = chatPanelEl ? chatPanelEl.querySelector(".panelHeader") : null; 170 const editModal = document.getElementById("editModal"); 171 const editModalTitle = document.getElementById("editModalTitle"); 172 const editModalCloseBtn = document.getElementById("editModalClose"); 173 const editModalCancelBtn = document.getElementById("editModalCancel"); 174 const editModalSaveBtn = document.getElementById("editModalSave"); 175 const editModalStatus = document.getElementById("editModalStatus"); 176 const editModalPostTitleRow = document.getElementById("editModalPostTitleRow"); 177 const editModalPostTitleInput = document.getElementById("editModalPostTitle"); 178 const editModalPostMeta = document.getElementById("editModalPostMeta"); 179 const editModalKeywordsInput = document.getElementById("editModalKeywords"); 180 const editModalCollectionSelect = document.getElementById("editModalCollection"); 181 const editModalProtectedToggle = document.getElementById("editModalProtected"); 182 const editModalWalkieToggle = document.getElementById("editModalWalkie"); 183 const editModalPasswordRow = document.getElementById("editModalPasswordRow"); 184 const editModalPasswordInput = document.getElementById("editModalPassword"); 185 const editModalToolbar = document.getElementById("editModalToolbar"); 186 const editModalEditor = document.getElementById("editModalEditor"); 187 const editModalImageInput = document.getElementById("editModalImage"); 188 const editModalAudioInput = document.getElementById("editModalAudio"); 189 190 // Temporarily force rack mode on (hide toggle) while the feature stabilizes. 191 const FORCE_RACK_MODE = true; 192 193 // Display prefs (device layout + text scale) 194 const UI_SCALE_KEY = "bzl_uiScale"; // "auto" | "xs" | "sm" | "md" | "lg" 195 const DEVICE_LAYOUT_KEY = "bzl_deviceLayout"; // "auto" | "widescreen" | "fourThree" | "threeTwo" | "ultrawide" | "portrait" 196 197 /** @type {Map<string, any>} */ 198 const posts = new Map(); 199 /** @type {Record<string, {image?: string, color?: string}>} */ 200 let profiles = {}; 201 202 /** @type {Map<string, any[]>} */ 203 const chatByPost = new Map(); 204 /** @type {Map<string, number>} */ 205 const unreadByPostId = new Map(); 206 /** @type {Map<string, Set<string>>} */ 207 const typingUsersByPostId = new Map(); 208 /** @type {Set<string>} */ 209 const myReacts = new Set(); 210 /** @type {Map<string, number>} */ 211 const reactPulseByKey = new Map(); 212 let allowedReactions = ["π", "β€οΈ", "π‘", "π", "π₯Ί", "π"]; 213 214 let clientId = null; 215 let loggedInUser = null; 216 let loggedInRole = "member"; 217 let canModerate = false; 218 let canRegisterFirstUser = false; 219 let registrationEnabled = false; 220 let activeChatPostId = null; 221 let activeMapsRoomId = ""; 222 let activeMapsRoomTitle = ""; 223 let activeMapsChatScope = "local"; // "local" | "global" 224 /** @type {Map<string, any[]>} */ 225 const mapsChatGlobalByMapId = new Map(); 226 /** @type {Map<string, any[]>} */ 227 const mapsChatLocalByMapId = new Map(); 228 let pendingProfileImage = ""; 229 let windowFocused = true; 230 let typingStopTimer = null; 231 let lastTypingSentAt = 0; 232 let modTab = "reports"; 233 let onboardingViewerTab = "about"; 234 let onboardingAdminTab = "about"; 235 let onboardingAdminDraft = { 236 enabled: true, 237 aboutContent: "", 238 requireAcceptance: false, 239 blockReadUntilAccepted: false, 240 roleSelectEnabled: true, 241 selfAssignableRoleIds: [], 242 rules: [], 243 }; 244 let onboardingAdminDraftStamp = ""; 245 const onboardingAdminExpandedRuleIds = new Set(); 246 let modReports = []; 247 let modUsers = []; 248 let modLog = []; 249 let devLog = []; 250 let modLogView = localStorage.getItem("bzl_modLogView") || "dev"; // "dev" | "moderation" 251 let devLogAutoScroll = localStorage.getItem("bzl_devLogAutoScroll") !== "0"; 252 let modModalContext = null; 253 let lanUrls = []; 254 const MOBILE_LAYOUT_KEY = "bzl_mobile_layout_v1"; 255 let mobilePanel = "hives"; // Back-compat: used by older call sites (maps to mobile "screen" now). 256 let mobileMoreOpen = false; 257 let mobileHostPanelId = ""; 258 const mobileHostRestoreParentByPanelId = new Map(); 259 const mobileHostedPanelIds = new Set(); 260 const mobileHostEphemeralPanelIds = new Set(); 261 let composerOpen = false; 262 let touchStartX = 0; 263 let touchStartY = 0; 264 let touchTracking = false; 265 let peopleOpen = false; 266 let peopleTab = "members"; 267 let peopleMembers = []; 268 let openPostMenuId = ""; 269 270 // Multi-instance chat panels (MVP: per-hive/post chat panels). 271 /** @type {Map<string, {postId:string}>} */ 272 const chatPanelInstances = new Map(); 273 274 function isChatInstancePanelId(panelId) { 275 const id = String(panelId || ""); 276 return id.startsWith("chat:post:"); 277 } 278 279 function chatInstancePanelIdForPost(postId) { 280 const pid = String(postId || "").trim(); 281 if (!pid) return ""; 282 return `chat:post:${pid}`; 283 } 284 let dmThreads = []; 285 /** @type {Map<string, any>} */ 286 let dmThreadsById = new Map(); 287 /** @type {Map<string, any[]>} */ 288 const dmMessagesByThreadId = new Map(); 289 let activeDmThreadId = null; 290 let pendingOpenDmThreadId = ""; 291 const CHAT_RECENTS_LIMIT = 24; 292 let recentHiveChatIds = []; 293 let recentDmChatThreadIds = []; 294 let syncingChatContextSelect = false; 295 let walkieRecording = false; 296 let walkieStartAt = 0; 297 let walkieRecorder = null; 298 let walkieChunks = []; 299 let walkieCtx = null; 300 let walkieMicStream = null; 301 let walkieMixNode = null; 302 let walkieDestNode = null; 303 let walkieDispatchBuffer = null; 304 const SESSION_TOKEN_KEY = "bzl_session_token"; 305 const CLIENT_IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024; 306 const CLIENT_AUDIO_UPLOAD_MAX_BYTES = 150 * 1024 * 1024; 307 let allowedPostReactions = ["π", "β€οΈ", "π‘", "π", "π₯Ί", "π", "β"]; 308 let allowedChatReactions = ["π", "β€οΈ", "π‘", "π", "π₯Ί", "π"]; 309 let userPrefs = { starredPostIds: [], hiddenPostIds: [], ignoredUsers: [], blockedUsers: [] }; 310 let showReactions = localStorage.getItem("bzl_showReactions") !== "0"; 311 let chatDock = localStorage.getItem("bzl_chatDock") === "right" ? "right" : "left"; 312 let activeHiveView = "all"; 313 let collections = []; 314 let customRoles = []; 315 let plugins = []; 316 const loadedPluginClientVersionById = new Map(); // pluginId -> version string 317 let centerView = "hives"; 318 const HIVES_VIEW_MODE_KEY = "bzl_hivesViewMode"; 319 const HIVES_LIST_AUTO_THRESHOLD_PX = 520; 320 let lastHivesWidthPx = 0; 321 let hivesResizeObserver = null; 322 323 // --- Rack layout (experimental) ------------------------------------------------ 324 325 const RACK_LAYOUT_ENABLED_KEY = "bzl_rackLayout_enabled"; 326 const RACK_LAYOUT_STATE_KEY = "bzl_rackLayout_state_v2"; 327 const RACK_SIDE_COLLAPSED_KEY = "bzl_rackLayout_sideCollapsed"; 328 const RACK_RIGHT_COLLAPSED_KEY = "bzl_rackLayout_rightCollapsed"; 329 const WORKSPACE_EXPANDED_PRIMARY_KEY = "bzl_workspace_expandedPrimary"; 330 const WORKSPACE_EXPANDED_DISPLACED_KEY = "bzl_workspace_expandedDisplaced"; 331 332 /** 333 * @typedef {{ 334 * version: 2, 335 * presetId: string, 336 * docked: { bottom: string[] }, 337 * racks?: { workspaceLeft?: string[], workspaceRight?: string[], side?: string[], right?: string[] }, 338 * }} RackLayoutState 339 */ 340 341 /** @type {RackLayoutState} */ 342 let rackLayoutState = { 343 version: 2, 344 presetId: "onboardingDefault", 345 docked: { bottom: [] }, 346 racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, 347 }; 348 let rackLayoutEnabled = false; 349 let rightRackEl = null; 350 let mainRack = null; 351 let mainSideRack = null; 352 const WORKSPACE_ACTIVE_PRIMARY_KEY = "bzl_workspace_activePrimary"; 353 354 function readBoolPref(key, fallback = false) { 355 try { 356 const raw = localStorage.getItem(key); 357 if (raw == null) return fallback; 358 return raw === "1" || raw === "true"; 359 } catch { 360 return fallback; 361 } 362 } 363 364 function writeBoolPref(key, value) { 365 try { 366 localStorage.setItem(key, value ? "1" : "0"); 367 } catch { 368 // ignore 369 } 370 } 371 372 function readWorkspaceExpandedPrimary() { 373 return readStringPref(WORKSPACE_EXPANDED_PRIMARY_KEY, "").trim(); 374 } 375 376 function writeWorkspaceExpandedPrimary(panelId) { 377 writeStringPref(WORKSPACE_EXPANDED_PRIMARY_KEY, String(panelId || "").trim()); 378 } 379 380 function readWorkspaceExpandedDisplaced() { 381 return readStringPref(WORKSPACE_EXPANDED_DISPLACED_KEY, "").trim(); 382 } 383 384 function writeWorkspaceExpandedDisplaced(panelId) { 385 writeStringPref(WORKSPACE_EXPANDED_DISPLACED_KEY, String(panelId || "").trim()); 386 } 387 388 function clearWorkspaceExpandedState() { 389 writeWorkspaceExpandedPrimary(""); 390 writeWorkspaceExpandedDisplaced(""); 391 } 392 393 function togglePrimaryExpand(panelId) { 394 if (!rackLayoutEnabled) return; 395 const id = String(panelId || "").trim(); 396 if (!id) return; 397 if (!panelCanExpand(id)) return; 398 399 const current = readWorkspaceExpandedPrimary(); 400 const left = ensureWorkspaceLeftRack(); 401 const right = ensureWorkspaceRightRack(); 402 if (!left || !right) return; 403 404 // If the panel isn't in a workspace slot, pull it into the workspace first. 405 const panelEl = getPanelElement(id); 406 if (panelEl) { 407 const inWorkspace = panelEl.parentElement === left || panelEl.parentElement === right; 408 if (!inWorkspace) { 409 const leftExisting = left.querySelector?.(":scope > .rackPanel:not(.hidden)"); 410 const rightExisting = right.querySelector?.(":scope > .rackPanel:not(.hidden)"); 411 const leftEmpty = !leftExisting; 412 const rightEmpty = !rightExisting; 413 // Prefer the right slot for "aux" expandables like Moderation/Composer. 414 const target = rightEmpty ? right : leftEmpty ? left : right; 415 const existing = target === left ? leftExisting : rightExisting; 416 if (existing instanceof HTMLElement && existing !== panelEl) { 417 const existingId = String(existing.dataset?.panelId || "").trim(); 418 if (existingId) dockPanel(existingId); 419 } 420 target.appendChild(panelEl); 421 syncRackStateFromDom(); 422 enforceWorkspaceRules(); 423 } 424 } 425 426 const leftPanel = left.querySelector?.(":scope > .rackPanel"); 427 const rightPanel = right.querySelector?.(":scope > .rackPanel"); 428 const leftId = String(leftPanel?.dataset?.panelId || "").trim(); 429 const rightId = String(rightPanel?.dataset?.panelId || "").trim(); 430 431 if (current && current === id) { 432 // Collapse: try to restore the displaced panel (if any) back into the now-visible other slot. 433 const displaced = readWorkspaceExpandedDisplaced(); 434 clearWorkspaceExpandedState(); 435 if (displaced && isDocked(displaced)) { 436 undockPanel(displaced); 437 const el = getPanelElement(displaced); 438 if (el) { 439 if (leftId === id && !rightId) right.appendChild(el); 440 else if (rightId === id && !leftId) left.appendChild(el); 441 } 442 } 443 enforceWorkspaceRules(); 444 return; 445 } 446 447 // Expand: if the other slot is occupied, dock it so it stays accessible via hotbar. 448 writeWorkspaceExpandedPrimary(id); 449 let displaced = ""; 450 if (leftId === id && rightId) displaced = rightId; 451 if (rightId === id && leftId) displaced = leftId; 452 if (displaced && displaced !== id) { 453 writeWorkspaceExpandedDisplaced(displaced); 454 dockPanel(displaced); 455 } else { 456 writeWorkspaceExpandedDisplaced(""); 457 } 458 enforceWorkspaceRules(); 459 } 460 461 function readStringPref(key, fallback = "") { 462 try { 463 const raw = localStorage.getItem(key); 464 if (raw == null) return fallback; 465 return String(raw); 466 } catch { 467 return fallback; 468 } 469 } 470 471 function normalizeUiScale(raw) { 472 const v = String(raw || "").trim().toLowerCase(); 473 if (v === "auto") return "auto"; 474 if (v === "xs" || v === "compact") return "xs"; 475 if (v === "sm" || v === "small") return "sm"; 476 if (v === "lg" || v === "large") return "lg"; 477 return "md"; 478 } 479 480 function normalizeDeviceLayout(raw) { 481 const v = String(raw || "").trim().toLowerCase(); 482 if (v === "widescreen") return "widescreen"; 483 if (v === "fourthree" || v === "fourThree".toLowerCase() || v === "4:3" || v === "4x3") return "fourThree"; 484 if (v === "threetwo" || v === "threeTwo".toLowerCase() || v === "3:2" || v === "3x2") return "threeTwo"; 485 if (v === "ultrawide") return "ultrawide"; 486 if (v === "portrait") return "portrait"; 487 return "auto"; 488 } 489 490 function detectViewportSize() { 491 const w = Math.max(1, Number(window.innerWidth) || 1); 492 const h = Math.max(1, Number(window.innerHeight) || 1); 493 // Keep this intentionally simple: we mostly care about "can we fit columns sanely?" 494 // Consider both width and height so low-res (ex: 1280x720) can auto-compact. 495 if (w <= 1100 || h <= 720) return "xs"; 496 if (w <= 1400 || h <= 820) return "sm"; 497 if (w <= 1800) return "md"; 498 return "lg"; 499 } 500 501 function detectAspectLayout() { 502 const w = Math.max(1, Number(window.innerWidth) || 1); 503 const h = Math.max(1, Number(window.innerHeight) || 1); 504 const ratio = w / h; 505 // Heuristics: 506 // - Portrait: <= ~1.25 507 // - 4:3-ish: 1.25..1.38 508 // - 3:2-ish: 1.38..1.62 (covers 3:2 and nearby) 509 // - Widescreen: 1.62..1.95 (16:10..~2:1) 510 // - Ultrawide: >= 1.95 511 if (ratio <= 1.25) return "portrait"; 512 if (ratio < 1.38) return "fourThree"; 513 if (ratio >= 1.38 && ratio < 1.62) return "threeTwo"; 514 if (ratio >= 1.95) return "ultrawide"; 515 return "widescreen"; 516 } 517 518 function applyDisplayPrefs() { 519 const root = document.documentElement; 520 if (!root) return; 521 const scalePref = normalizeUiScale(readStringPref(UI_SCALE_KEY, "auto")); 522 const layoutPref = normalizeDeviceLayout(readStringPref(DEVICE_LAYOUT_KEY, "auto")); 523 const layout = layoutPref === "auto" ? detectAspectLayout() : layoutPref; 524 const viewport = detectViewportSize(); 525 const scale = 526 scalePref === "auto" ? (viewport === "xs" ? "xs" : viewport === "sm" ? "sm" : "md") : scalePref; 527 528 root.dataset.uiScale = scale; 529 root.dataset.uiScalePref = scalePref; 530 root.dataset.deviceLayout = layoutPref; 531 root.dataset.aspect = layout; 532 root.dataset.viewport = viewport; 533 534 if (uiScaleEl) uiScaleEl.value = scalePref; 535 if (deviceLayoutEl) deviceLayoutEl.value = layoutPref; 536 } 537 538 function initDisplayPrefsUi() { 539 applyDisplayPrefs(); 540 if (uiScaleEl) { 541 uiScaleEl.value = normalizeUiScale(readStringPref(UI_SCALE_KEY, "auto")); 542 uiScaleEl.addEventListener("change", () => { 543 const next = normalizeUiScale(uiScaleEl.value); 544 try { 545 localStorage.setItem(UI_SCALE_KEY, next); 546 } catch { 547 // ignore 548 } 549 applyDisplayPrefs(); 550 }); 551 } 552 if (deviceLayoutEl) { 553 deviceLayoutEl.value = normalizeDeviceLayout(readStringPref(DEVICE_LAYOUT_KEY, "auto")); 554 deviceLayoutEl.addEventListener("change", () => { 555 const next = normalizeDeviceLayout(deviceLayoutEl.value); 556 try { 557 localStorage.setItem(DEVICE_LAYOUT_KEY, next); 558 } catch { 559 // ignore 560 } 561 applyDisplayPrefs(); 562 }); 563 } 564 565 let resizeTimer = null; 566 window.addEventListener("resize", () => { 567 if (resizeTimer) window.clearTimeout(resizeTimer); 568 resizeTimer = window.setTimeout(() => { 569 resizeTimer = null; 570 // Always re-apply (viewport changes matter even when layout is manually pinned). 571 applyDisplayPrefs(); 572 }, 90); 573 }); 574 } 575 576 function writeStringPref(key, value) { 577 try { 578 localStorage.setItem(key, String(value)); 579 } catch { 580 // ignore 581 } 582 } 583 584 function resolveHivesViewMode() { 585 const pref = readStringPref(HIVES_VIEW_MODE_KEY, "list"); 586 const normalized = String(pref || "auto").toLowerCase(); 587 if (normalized === "list") return "list"; 588 if (normalized === "cards") return "cards"; 589 // auto (currently treated as list by default; we can reintroduce responsive modes later) 590 return "list"; 591 } 592 593 function applyHivesViewMode() { 594 const mode = resolveHivesViewMode(); 595 const list = mode === "list"; 596 feedEl?.classList.toggle("hivesListView", list); 597 hivesPanelEl?.classList.toggle("hivesListView", list); 598 } 599 600 function installHivesAutoViewMode() { 601 if (!hivesPanelEl) return; 602 if (typeof ResizeObserver === "undefined") { 603 window.addEventListener("resize", () => applyHivesViewMode()); 604 return; 605 } 606 if (hivesResizeObserver) return; 607 hivesResizeObserver = new ResizeObserver((entries) => { 608 const entry = entries && entries[0]; 609 const w = Number(entry?.contentRect?.width || 0); 610 if (!w) return; 611 const rounded = Math.round(w); 612 if (rounded === lastHivesWidthPx) return; 613 lastHivesWidthPx = rounded; 614 applyHivesViewMode(); 615 }); 616 try { 617 hivesResizeObserver.observe(hivesPanelEl); 618 } catch { 619 // ignore 620 } 621 } 622 623 function setSideCollapsed(collapsed, opts) { 624 const options = opts && typeof opts === "object" ? opts : {}; 625 const persist = options.persist !== false; 626 const updateControls = options.updateControls !== false; 627 if (!appRoot) return; 628 appRoot.classList.toggle("sideCollapsed", Boolean(collapsed)); 629 if (persist) writeBoolPref(RACK_SIDE_COLLAPSED_KEY, Boolean(collapsed)); 630 if (updateControls && toggleSideRackEl) toggleSideRackEl.checked = !Boolean(collapsed); 631 updateSideRackEmptyState(); 632 } 633 634 function setRightCollapsed(collapsed, opts) { 635 const options = opts && typeof opts === "object" ? opts : {}; 636 const persist = options.persist !== false; 637 const updateControls = options.updateControls !== false; 638 if (!appRoot) return; 639 appRoot.classList.toggle("rightCollapsed", Boolean(collapsed)); 640 if (persist) writeBoolPref(RACK_RIGHT_COLLAPSED_KEY, Boolean(collapsed)); 641 if (updateControls && toggleRightRackEl) toggleRightRackEl.checked = !Boolean(collapsed); 642 } 643 644 function updateSideRackEmptyState() { 645 if (!appRoot) return; 646 const side = mainSideRackEl || mainSideRack || document.getElementById("mainSideRack"); 647 if (!(side instanceof HTMLElement)) return; 648 const hasVisible = Boolean(side.querySelector?.(".rackPanel:not(.hidden)")); 649 appRoot.classList.toggle("sideRackEmpty", !hasVisible); 650 } 651 652 // Panel registry (skeleton): this will become the primary way core + plugins register UI panels. 653 // For now, it powers rack mode (docking + ordering + workspace rules) and plugin panel shells. 654 /** @type {Map<string, {id:string,title:string,icon?:string,source:string,role:string,defaultRack:string,element?:HTMLElement|null}>} */ 655 const panelRegistry = new Map(); 656 657 function registerCorePanel(def) { 658 const id = String(def?.id || "").trim(); 659 if (!id) return; 660 const title = String(def?.title || id).trim(); 661 const icon = typeof def?.icon === "string" ? def.icon : ""; 662 const role = typeof def?.role === "string" ? def.role : "aux"; 663 const defaultRack = typeof def?.defaultRack === "string" ? def.defaultRack : "right"; 664 const element = def?.element instanceof HTMLElement ? def.element : null; 665 panelRegistry.set(id, { id, title, icon, source: "core", role, defaultRack, element }); 666 } 667 668 function togglePanelSkinny(panelId) { 669 if (!rackLayoutEnabled) return; 670 const id = String(panelId || "").trim(); 671 if (!id) return; 672 if (!panelIsSkinnyCapable(id)) return; 673 const panelEl = getPanelElement(id); 674 if (!panelEl) return; 675 676 const left = ensureWorkspaceLeftRack(); 677 const right = ensureWorkspaceRightRack(); 678 const side = ensureMainSideRack(); 679 if (!left || !right || !side) return; 680 681 const parentId = rackIdForPanelElement(panelEl); 682 const inSkinny = parentId === "mainSideRack" || parentId === "rightRack"; 683 684 if (inSkinny) { 685 // Move to workspace (prefer an empty slot; otherwise prefer right). 686 const leftExisting = left.querySelector?.(":scope > .rackPanel:not(.hidden)"); 687 const rightExisting = right.querySelector?.(":scope > .rackPanel:not(.hidden)"); 688 const target = !rightExisting ? right : !leftExisting ? left : right; 689 const existing = target === left ? leftExisting : rightExisting; 690 if (existing instanceof HTMLElement && existing !== panelEl) { 691 const existingId = String(existing.dataset?.panelId || "").trim(); 692 if (existingId) dockPanel(existingId); 693 } 694 target.appendChild(panelEl); 695 rememberPanelLastRack(id, target.id); 696 saveRackLayoutState(); 697 syncRackStateFromDom(); 698 enforceWorkspaceRules(); 699 return; 700 } 701 702 // Move to side rack (skinny). 703 setSideCollapsed(false); 704 side.prepend(panelEl); 705 rememberPanelLastRack(id, side.id); 706 saveRackLayoutState(); 707 syncRackStateFromDom(); 708 enforceWorkspaceRules(); 709 } 710 711 registerCorePanel({ id: "chat", title: "Chat", icon: "π¬", role: "primary", defaultRack: "main", element: chatPanelEl }); 712 registerCorePanel({ id: "hives", title: "Hives", icon: "π", role: "primary", defaultRack: "main", element: hivesPanelEl }); 713 registerCorePanel({ id: "onboarding", title: "Onboarding", icon: "π§", role: "primary", defaultRack: "main", element: onboardingPanelEl }); 714 registerCorePanel({ id: "people", title: "People", icon: "π₯", role: "aux", defaultRack: "right", element: peopleDrawerEl }); 715 registerCorePanel({ id: "moderation", title: "Moderation", icon: "π‘οΈ", role: "aux", defaultRack: "right", element: modPanelEl }); 716 registerCorePanel({ id: "profile", title: "Profile", icon: "π€", role: "transient", defaultRack: "main", element: profileViewPanel }); 717 registerCorePanel({ id: "composer", title: "New Hive", icon: "βοΈ", role: "aux", defaultRack: "main", element: pollinatePanel }); 718 719 let pluginRackPanelEl = null; 720 let pluginRackWidgetsRackEl = null; 721 let pluginRackAddMenuEl = null; 722 723 function closePluginRackAddMenu() { 724 if (!pluginRackAddMenuEl) return; 725 try { 726 pluginRackAddMenuEl.remove(); 727 } catch { 728 // ignore 729 } 730 pluginRackAddMenuEl = null; 731 } 732 733 function panelIsPluginOwned(panelId) { 734 const id = String(panelId || "").trim(); 735 if (!id) return false; 736 if (id.startsWith("chat:")) return false; 737 const entry = panelRegistry.get(id); 738 const src = typeof entry?.source === "string" ? entry.source : ""; 739 return src.startsWith("plugin:"); 740 } 741 742 function panelIsHostableInPluginRack(panelId) { 743 const id = String(panelId || "").trim(); 744 if (!id) return false; 745 if (id === "pluginRack") return false; 746 if (!panelIsPluginOwned(id)) return false; 747 // Widgets should be small, stackable tools (not full workspace surfaces like Maps). 748 if (panelRole(id) === "primary") return false; 749 return true; 750 } 751 752 function ensurePluginRackPanel() { 753 if (pluginRackPanelEl instanceof HTMLElement && pluginRackPanelEl.isConnected) return pluginRackPanelEl; 754 755 if (!(pluginRackPanelEl instanceof HTMLElement)) { 756 const shell = document.createElement("section"); 757 shell.className = "panel panelFill pluginRackPanel rackPanel"; 758 shell.dataset.panelId = "pluginRack"; 759 shell.innerHTML = ` 760 <div class="panelHeader"> 761 <div class="panelTitle">${escapeHtml("Plugin Rack")}</div> 762 <div class="row"></div> 763 </div> 764 <div class="panelBody pluginRackBody"> 765 <div class="pluginRackToolbar"> 766 <button type="button" class="ghost smallBtn" data-pluginrackadd="1">+ Add widget</button> 767 <div class="small muted pluginRackHint">Drop plugin panels here to stack them.</div> 768 </div> 769 <div id="pluginRackWidgetsRack" class="pluginRackWidgets" aria-label="Plugin widgets"></div> 770 </div> 771 `; 772 pluginRackPanelEl = shell; 773 pluginRackWidgetsRackEl = shell.querySelector("#pluginRackWidgetsRack"); 774 775 shell.querySelector("[data-pluginrackadd]")?.addEventListener("click", (e) => { 776 const anchor = e.currentTarget; 777 if (pluginRackAddMenuEl) closePluginRackAddMenu(); 778 else openPluginRackAddMenu(anchor); 779 }); 780 } 781 782 // Ensure it's registered as a core panel for docking + layout state. 783 registerCorePanel({ id: "pluginRack", title: "Plugin Rack", icon: "π§©", role: "aux", defaultRack: "main", element: pluginRackPanelEl }); 784 785 // Append into the DOM so it can be docked/restored. (It will typically live in the hotbar.) 786 const side = ensureMainSideRack(); 787 if (side && pluginRackPanelEl.parentElement !== side) side.appendChild(pluginRackPanelEl); 788 789 return pluginRackPanelEl; 790 } 791 792 function ensurePluginRackWidgetsRack() { 793 ensurePluginRackPanel(); 794 return pluginRackWidgetsRackEl instanceof HTMLElement ? pluginRackWidgetsRackEl : null; 795 } 796 797 function readPluginRackWidgetsOrder() { 798 const rack = ensurePluginRackWidgetsRack(); 799 return rack ? readRackOrder(rack) : []; 800 } 801 802 function removePanelFromPluginRack(panelId) { 803 const id = String(panelId || "").trim(); 804 if (!id) return; 805 rackLayoutState.pluginRackWidgets = Array.isArray(rackLayoutState.pluginRackWidgets) 806 ? rackLayoutState.pluginRackWidgets.filter((x) => x !== id) 807 : []; 808 const el = getPanelElement(id); 809 if (el) el.classList.remove("pluginRackWidget"); 810 const rack = ensurePluginRackWidgetsRack(); 811 if (rack && el && el.parentElement === rack) rack.removeChild(el); 812 const side = ensureMainSideRack(); 813 if (side && el && !el.parentElement) side.appendChild(el); 814 } 815 816 function hostPanelInPluginRack(panelId) { 817 const id = String(panelId || "").trim(); 818 if (!id) return; 819 if (!rackLayoutEnabled) return; 820 if (!panelIsHostableInPluginRack(id)) { 821 toast("Can't add widget", `${panelTitle(id)} can't be hosted in Plugin Rack.`); 822 return; 823 } 824 825 const rack = ensurePluginRackWidgetsRack(); 826 const el = getPanelElement(id); 827 if (!rack || !el) return; 828 829 // Hosting implies it should be visible in the rack, not docked. 830 if (isDocked(id)) undockPanel(id); 831 832 const lastRack = rackIdForPanelElement(el); 833 if (lastRack) rememberPanelLastRack(id, lastRack); 834 835 el.classList.add("pluginRackWidget"); 836 if (el.parentElement !== rack) rack.appendChild(el); 837 838 const next = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []); 839 next.add(id); 840 rackLayoutState.pluginRackWidgets = Array.from(next); 841 saveRackLayoutState(); 842 syncRackStateFromDom(); 843 enforceWorkspaceRules(); 844 } 845 846 function openPluginRackAddMenu(anchorEl) { 847 closePluginRackAddMenu(); 848 if (!(anchorEl instanceof HTMLElement)) return; 849 if (!rackLayoutEnabled) return; 850 851 const hosted = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []); 852 const candidates = Array.from(panelRegistry.keys()) 853 .filter((id) => panelIsHostableInPluginRack(id) && !hosted.has(id)) 854 .sort((a, b) => panelTitle(a).localeCompare(panelTitle(b))); 855 856 const items = candidates 857 .map((id) => `<button type="button" class="ghost smallBtn" data-pluginrackhost="${escapeHtml(id)}">${escapeHtml(panelTitle(id))}</button>`) 858 .join(""); 859 860 const menu = document.createElement("div"); 861 menu.className = "hotbarAddMenu pluginRackAddMenu"; 862 menu.innerHTML = ` 863 <div class="small muted" style="padding:6px 8px 4px;">Add widget</div> 864 <div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No plugin widgets available.</div>`}</div> 865 `; 866 867 const rect = anchorEl.getBoundingClientRect(); 868 const left = Math.max(12, Math.min(window.innerWidth - 260, rect.left)); 869 const top = Math.max(12, Math.min(window.innerHeight - 320, rect.bottom + 8)); 870 menu.style.left = `${left}px`; 871 menu.style.top = `${top}px`; 872 873 menu.addEventListener("click", (e) => { 874 const btn = e.target.closest?.("[data-pluginrackhost]"); 875 if (!btn) return; 876 const id = String(btn.getAttribute("data-pluginrackhost") || "").trim(); 877 if (!id) return; 878 hostPanelInPluginRack(id); 879 closePluginRackAddMenu(); 880 }); 881 882 document.body.appendChild(menu); 883 pluginRackAddMenuEl = menu; 884 } 885 886 // Rack mode: Profile should behave like a normal dockable panel (not a flow that replaces Hives). 887 // Override the role after the initial core registration (Map#set will replace the previous entry). 888 panelRegistry.set("profile", { ...(panelRegistry.get("profile") || { id: "profile", source: "core" }), role: "aux" }); 889 890 // Expose for quick inspection in the browser console while iterating. 891 window.__bzlPanels = { panelRegistry }; 892 893 const PRESET_DEFS = { 894 // Presets are hard-applied (exact placement). Anything not explicitly placed starts in the hotbar. 895 // Workspace uses two full-height primary slots (left + right). No vertical splits. 896 onboardingDefault: { 897 presetId: "onboardingDefault", 898 label: "Onboarding (Default)", 899 group: "user", 900 workspaceLeftOrder: ["onboarding"], 901 workspaceRightOrder: ["hives"], 902 sideOrder: ["chat", "profile", "composer"], 903 sideCollapsed: false, 904 rightOrder: ["people"], 905 dockBottom: ["pluginRack", "maps", "library-browser", "library-shelf", "library-reader"], 906 }, 907 social: { 908 presetId: "social", 909 label: "Default (Social)", 910 group: "user", 911 workspaceLeftOrder: ["hives"], 912 workspaceRightOrder: ["chat"], 913 sideOrder: ["profile", "composer"], 914 sideCollapsed: true, 915 rightOrder: ["people"], 916 dockBottom: ["pluginRack", "maps", "library-browser", "library-shelf", "library-reader"], 917 }, 918 chatFocus: { 919 presetId: "chatFocus", 920 label: "Chat Focus", 921 group: "user", 922 workspaceLeftOrder: ["chat"], 923 workspaceRightOrder: [], 924 expandedPrimary: "chat", 925 sideOrder: ["profile"], 926 sideCollapsed: true, 927 rightOrder: ["people"], 928 dockBottom: ["pluginRack", "hives", "composer", "maps", "library-browser", "library-shelf", "library-reader"], 929 }, 930 browse: { 931 presetId: "browse", 932 label: "Browse", 933 group: "user", 934 workspaceLeftOrder: ["hives"], 935 workspaceRightOrder: [], 936 expandedPrimary: "hives", 937 sideOrder: ["chat"], 938 sideCollapsed: true, 939 rightOrder: ["profile"], 940 dockBottom: ["pluginRack", "people", "composer", "maps", "library-browser", "library-shelf", "library-reader"], 941 }, 942 creator: { 943 presetId: "creator", 944 label: "Creator", 945 group: "user", 946 workspaceLeftOrder: ["hives"], 947 workspaceRightOrder: ["composer"], 948 composerOpen: true, 949 sideOrder: ["people"], 950 sideCollapsed: true, 951 rightOrder: ["profile"], 952 dockBottom: ["pluginRack", "chat", "maps", "library-browser", "library-shelf", "library-reader"], 953 }, 954 mapsSession: { 955 presetId: "mapsSession", 956 label: "Maps Session", 957 group: "user", 958 workspaceLeftOrder: ["maps"], // if installed 959 workspaceRightOrder: ["chat"], 960 sideOrder: ["hives"], 961 sideCollapsed: true, 962 rightOrder: ["people"], 963 dockBottom: ["pluginRack", "profile", "composer", "library-browser", "library-shelf", "library-reader"], 964 }, 965 quiet: { 966 presetId: "quiet", 967 label: "Quiet (No People)", 968 group: "user", 969 workspaceLeftOrder: ["hives"], 970 workspaceRightOrder: ["profile"], 971 sideOrder: ["composer"], 972 sideCollapsed: true, 973 rightOrder: [], 974 rightCollapsed: true, 975 dockBottom: ["pluginRack", "chat", "people", "maps", "library-browser", "library-shelf", "library-reader"], 976 }, 977 readingNook: { 978 presetId: "readingNook", 979 label: "Reading Nook", 980 group: "user", 981 workspaceLeftOrder: ["library-reader"], 982 workspaceRightOrder: ["library-shelf"], 983 sideOrder: ["profile"], 984 sideCollapsed: true, 985 rightOrder: ["people"], 986 dockBottom: ["pluginRack", "hives", "chat", "composer", "maps", "library-browser"], 987 }, 988 libraryCurator: { 989 presetId: "libraryCurator", 990 label: "Library Curator", 991 group: "user", 992 workspaceLeftOrder: ["library-browser"], 993 workspaceRightOrder: ["library-shelf"], 994 sideOrder: ["profile"], 995 sideCollapsed: true, 996 rightOrder: ["people"], 997 dockBottom: ["pluginRack", "hives", "chat", "composer", "maps", "library-reader"], 998 }, 999 ops: { 1000 presetId: "ops", 1001 label: "Ops", 1002 group: "mod", 1003 modOnly: true, 1004 workspaceLeftOrder: ["moderation"], 1005 workspaceRightOrder: ["chat"], 1006 sideOrder: ["hives"], 1007 sideCollapsed: true, 1008 rightOrder: ["people"], 1009 dockBottom: ["pluginRack", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"], 1010 }, 1011 reportsFocus: { 1012 presetId: "reportsFocus", 1013 label: "Reports Focus", 1014 group: "mod", 1015 modOnly: true, 1016 workspaceLeftOrder: ["moderation"], 1017 workspaceRightOrder: [], 1018 expandedPrimary: "moderation", 1019 sideOrder: ["people"], 1020 sideCollapsed: true, 1021 rightOrder: ["chat"], 1022 dockBottom: ["pluginRack", "hives", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"], 1023 }, 1024 communityWatch: { 1025 presetId: "communityWatch", 1026 label: "Community Watch", 1027 group: "mod", 1028 modOnly: true, 1029 workspaceLeftOrder: ["hives"], 1030 workspaceRightOrder: ["moderation"], 1031 sideOrder: ["chat"], 1032 sideCollapsed: true, 1033 rightOrder: ["people"], 1034 dockBottom: ["pluginRack", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"], 1035 }, 1036 serverAdmin: { 1037 presetId: "serverAdmin", 1038 label: "Server Admin", 1039 group: "mod", 1040 modOnly: true, 1041 workspaceLeftOrder: ["moderation"], 1042 workspaceRightOrder: ["hives"], 1043 sideOrder: ["chat"], 1044 sideCollapsed: true, 1045 rightOrder: ["people"], 1046 dockBottom: ["pluginRack", "profile", "composer", "maps", "library-browser", "library-shelf", "library-reader"], 1047 }, 1048 }; 1049 1050 const PRESET_ALIASES = { 1051 // Back-compat for older preset ids. 1052 discordLike: "social", 1053 onboarding: "onboardingDefault", 1054 chat: "chatFocus", 1055 browsing: "browse", 1056 maps: "mapsSession", 1057 focus: "quiet", 1058 clean: "social", 1059 moderation: "ops", 1060 reading: "readingNook", 1061 library: "libraryCurator", 1062 }; 1063 1064 function resolvePresetKey(presetId) { 1065 const raw = String(presetId || "").trim(); 1066 const mapped = Object.prototype.hasOwnProperty.call(PRESET_ALIASES, raw) ? PRESET_ALIASES[raw] : raw; 1067 return Object.prototype.hasOwnProperty.call(PRESET_DEFS, mapped) ? mapped : "onboardingDefault"; 1068 } 1069 1070 function updateLayoutPresetOptions() { 1071 if (!layoutPresetEl) return; 1072 const current = resolvePresetKey(rackLayoutState?.presetId || layoutPresetEl.value || "onboardingDefault"); 1073 1074 const defs = Object.values(PRESET_DEFS).filter((d) => d && typeof d === "object"); 1075 const userDefs = defs.filter((d) => d.group === "user"); 1076 const modDefs = defs.filter((d) => d.group === "mod"); 1077 1078 const makeOpt = (def) => { 1079 const opt = document.createElement("option"); 1080 opt.value = String(def.presetId || ""); 1081 opt.textContent = String(def.label || def.presetId || "Preset"); 1082 return opt; 1083 }; 1084 1085 layoutPresetEl.innerHTML = ""; 1086 1087 const userGroup = document.createElement("optgroup"); 1088 userGroup.label = "Presets"; 1089 for (const def of userDefs) userGroup.appendChild(makeOpt(def)); 1090 layoutPresetEl.appendChild(userGroup); 1091 1092 if (canModerate) { 1093 const modGroup = document.createElement("optgroup"); 1094 modGroup.label = "Moderation (mods)"; 1095 for (const def of modDefs) modGroup.appendChild(makeOpt(def)); 1096 layoutPresetEl.appendChild(modGroup); 1097 } 1098 1099 const nextValue = canModerate ? current : (PRESET_DEFS[current]?.modOnly ? "onboardingDefault" : current); 1100 layoutPresetEl.value = Object.prototype.hasOwnProperty.call(PRESET_DEFS, nextValue) ? nextValue : "onboardingDefault"; 1101 } 1102 1103 function readRackLayoutEnabled() { 1104 if (FORCE_RACK_MODE) return true; 1105 try { 1106 return localStorage.getItem(RACK_LAYOUT_ENABLED_KEY) === "1"; 1107 } catch { 1108 return false; 1109 } 1110 } 1111 1112 function writeRackLayoutEnabled(enabled) { 1113 if (FORCE_RACK_MODE) { 1114 rackLayoutEnabled = true; 1115 try { 1116 localStorage.setItem(RACK_LAYOUT_ENABLED_KEY, "1"); 1117 } catch { 1118 // ignore 1119 } 1120 return; 1121 } 1122 rackLayoutEnabled = Boolean(enabled); 1123 try { 1124 localStorage.setItem(RACK_LAYOUT_ENABLED_KEY, rackLayoutEnabled ? "1" : "0"); 1125 } catch { 1126 // ignore 1127 } 1128 } 1129 1130 /** @returns {RackLayoutState} */ 1131 function loadRackLayoutState() { 1132 try { 1133 const raw = localStorage.getItem(RACK_LAYOUT_STATE_KEY); 1134 if (!raw) 1135 return { 1136 version: 2, 1137 presetId: "onboardingDefault", 1138 docked: { bottom: [] }, 1139 racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, 1140 pluginRackWidgets: [], 1141 lastRackByPanelId: {}, 1142 }; 1143 const parsed = JSON.parse(raw); 1144 if (!parsed || parsed.version !== 2) 1145 return { 1146 version: 2, 1147 presetId: "onboardingDefault", 1148 docked: { bottom: [] }, 1149 racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, 1150 pluginRackWidgets: [], 1151 lastRackByPanelId: {}, 1152 }; 1153 const bottom = Array.isArray(parsed?.docked?.bottom) ? parsed.docked.bottom.map((x) => String(x || "")).filter(Boolean) : []; 1154 const pluginRackWidgets = Array.isArray(parsed?.pluginRackWidgets) 1155 ? parsed.pluginRackWidgets.map((x) => String(x || "")).filter(Boolean) 1156 : []; 1157 const presetId = typeof parsed?.presetId === "string" ? parsed.presetId : "onboardingDefault"; 1158 const workspaceLeft = Array.isArray(parsed?.racks?.workspaceLeft) ? parsed.racks.workspaceLeft.map((x) => String(x || "")).filter(Boolean) : []; 1159 const workspaceRight = Array.isArray(parsed?.racks?.workspaceRight) ? parsed.racks.workspaceRight.map((x) => String(x || "")).filter(Boolean) : []; 1160 const side = Array.isArray(parsed?.racks?.side) ? parsed.racks.side.map((x) => String(x || "")).filter(Boolean) : []; 1161 const right = Array.isArray(parsed?.racks?.right) ? parsed.racks.right.map((x) => String(x || "")).filter(Boolean) : []; 1162 const lastRackByPanelIdRaw = parsed?.lastRackByPanelId && typeof parsed.lastRackByPanelId === "object" ? parsed.lastRackByPanelId : {}; 1163 const lastRackByPanelId = {}; 1164 for (const [k, v] of Object.entries(lastRackByPanelIdRaw)) { 1165 const id = String(k || "").trim(); 1166 const rackId = typeof v === "string" ? v.trim() : ""; 1167 if (!id || !rackId) continue; 1168 lastRackByPanelId[id] = rackId; 1169 } 1170 return { version: 2, presetId, docked: { bottom }, racks: { workspaceLeft, workspaceRight, side, right }, pluginRackWidgets, lastRackByPanelId }; 1171 } catch { 1172 return { 1173 version: 2, 1174 presetId: "onboardingDefault", 1175 docked: { bottom: [] }, 1176 racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, 1177 pluginRackWidgets: [], 1178 lastRackByPanelId: {}, 1179 }; 1180 } 1181 } 1182 1183 function saveRackLayoutState() { 1184 try { 1185 localStorage.setItem(RACK_LAYOUT_STATE_KEY, JSON.stringify(rackLayoutState)); 1186 } catch { 1187 // ignore 1188 } 1189 } 1190 1191 function ensureWorkspaceSlots() { 1192 const workspace = mainWorkspaceRackEl || document.getElementById("mainWorkspaceRack"); 1193 if (!workspace) return { left: null, right: null }; 1194 1195 let left = workspace.querySelector?.("#workspaceLeftSlot"); 1196 let right = workspace.querySelector?.("#workspaceRightSlot"); 1197 1198 if (!left) { 1199 left = document.createElement("div"); 1200 left.id = "workspaceLeftSlot"; 1201 left.className = "workspaceSlot workspaceSlotLeft"; 1202 left.setAttribute("aria-label", "Workspace left"); 1203 workspace.prepend(left); 1204 } 1205 if (!right) { 1206 right = document.createElement("div"); 1207 right.id = "workspaceRightSlot"; 1208 right.className = "workspaceSlot workspaceSlotRight"; 1209 right.setAttribute("aria-label", "Workspace right"); 1210 const afterLeft = workspace.querySelector?.("#workspaceLeftSlot"); 1211 if (afterLeft && afterLeft.nextSibling) workspace.insertBefore(right, afterLeft.nextSibling); 1212 else workspace.appendChild(right); 1213 } 1214 return { left, right }; 1215 } 1216 1217 function panelTitle(panelId) { 1218 const entry = panelRegistry.get(panelId); 1219 if (entry?.title) return entry.title; 1220 if (panelId === "maps") return "Maps"; 1221 if (panelId === "library") return "Library"; 1222 return String(panelId || ""); 1223 } 1224 1225 function chatRailClass({ fromUser, isModMessage }) { 1226 const from = String(fromUser || "").trim(); 1227 const isSystem = !from || from.toLowerCase() === "system"; 1228 const isModMsg = Boolean(isModMessage); 1229 const isYou = Boolean(loggedInUser && from && from === loggedInUser); 1230 if (isSystem || isModMsg) return "railLeft"; 1231 if (isYou) return "railRight"; 1232 return "railCenter"; 1233 } 1234 1235 function updateChatModToggleVisibility() { 1236 if (!chatModToggleWrapEl) return; 1237 const canUse = Boolean(canModerate && activeChatPostId && !activeDmThreadId && !isMapChatActive()); 1238 chatModToggleWrapEl.classList.toggle("hidden", !canUse); 1239 if (!canUse && chatModToggleEl) chatModToggleEl.checked = false; 1240 } 1241 1242 function panelIcon(panelId) { 1243 const entry = panelRegistry.get(panelId); 1244 if (entry?.icon) return entry.icon; 1245 if (panelId === "maps") return "πΊοΈ"; 1246 if (panelId === "library") return "π"; 1247 return "β’"; 1248 } 1249 1250 function panelRole(panelId) { 1251 const entry = panelRegistry.get(panelId); 1252 return typeof entry?.role === "string" ? entry.role : "aux"; 1253 } 1254 1255 function panelCanExpand(panelId) { 1256 const id = String(panelId || "").trim(); 1257 if (!id) return false; 1258 if (id.startsWith("chat:")) return true; 1259 if (panelRole(id) === "primary") return true; 1260 // Allow a few core panels to take over the workspace even though they aren't "primary" by default. 1261 return id === "moderation" || id === "composer" || id === "pluginRack"; 1262 } 1263 1264 // Panels that are allowed to live in "skinny" columns (side rack / right rack). 1265 // These panels should be able to render in a narrow width without breaking layout. 1266 const SKINNY_CAPABLE_PANELS = new Set(["people", "profile", "composer", "chat", "pluginRack", "dice"]); 1267 1268 function panelIsSkinnyCapable(panelId) { 1269 const id = String(panelId || "").trim(); 1270 if (!id) return false; 1271 if (id.startsWith("chat:")) return true; 1272 return SKINNY_CAPABLE_PANELS.has(id); 1273 } 1274 1275 function isDocked(panelId) { 1276 return rackLayoutState.docked.bottom.includes(panelId); 1277 } 1278 1279 function getPanelElement(panelId) { 1280 const id = String(panelId || "").trim(); 1281 if (!id) return null; 1282 const entry = panelRegistry.get(id); 1283 const el = entry?.element; 1284 return el instanceof HTMLElement ? el : null; 1285 } 1286 1287 function rackIdForPanelElement(panelEl) { 1288 const el = panelEl instanceof HTMLElement ? panelEl : null; 1289 if (!el) return ""; 1290 const parent = el.parentElement; 1291 const id = parent && typeof parent.id === "string" ? parent.id : ""; 1292 if (id === "workspaceLeftSlot" || id === "workspaceRightSlot" || id === "mainSideRack" || id === "rightRack") return id; 1293 return ""; 1294 } 1295 1296 function updateSkinnyChatPanels() { 1297 const applySkinnyState = (panelEl) => { 1298 if (!(panelEl instanceof HTMLElement)) return; 1299 const rackId = rackIdForPanelElement(panelEl); 1300 const inSkinnyRack = rackId === "mainSideRack" || rackId === "rightRack"; 1301 panelEl.classList.toggle("isSkinnyChat", Boolean(rackLayoutEnabled && inSkinnyRack)); 1302 }; 1303 1304 applySkinnyState(chatPanelEl); 1305 for (const panelId of chatPanelInstances.keys()) { 1306 applySkinnyState(getPanelElement(panelId)); 1307 } 1308 } 1309 1310 function rememberPanelLastRack(panelId, rackId) { 1311 const id = String(panelId || "").trim(); 1312 const rack = String(rackId || "").trim(); 1313 if (!id || !rack) return; 1314 if (!rackLayoutState.lastRackByPanelId || typeof rackLayoutState.lastRackByPanelId !== "object") rackLayoutState.lastRackByPanelId = {}; 1315 rackLayoutState.lastRackByPanelId[id] = rack; 1316 } 1317 1318 function dockPanel(panelId) { 1319 const id = String(panelId || "").trim(); 1320 if (!id) return; 1321 // Docking a hosted widget should implicitly un-host it. 1322 removePanelFromPluginRack(id); 1323 const el = getPanelElement(id); 1324 const lastRack = rackIdForPanelElement(el); 1325 if (lastRack) rememberPanelLastRack(id, lastRack); 1326 if (!isDocked(id)) rackLayoutState.docked.bottom.push(id); 1327 saveRackLayoutState(); 1328 applyDockState(); 1329 } 1330 1331 function undockPanel(panelId) { 1332 const id = String(panelId || "").trim(); 1333 if (!id) return; 1334 rackLayoutState.docked.bottom = rackLayoutState.docked.bottom.filter((x) => x !== id); 1335 saveRackLayoutState(); 1336 applyDockState(); 1337 } 1338 1339 function restorePanelFromHotbar(panelId) { 1340 const id = String(panelId || "").trim(); 1341 if (!id) return; 1342 if (!rackLayoutEnabled) return; 1343 1344 const panelEl = getPanelElement(id); 1345 if (!panelEl) return; 1346 1347 // Decide where to restore the panel. 1348 const lastRackId = 1349 rackLayoutState?.lastRackByPanelId && typeof rackLayoutState.lastRackByPanelId === "object" 1350 ? String(rackLayoutState.lastRackByPanelId[id] || "") 1351 : ""; 1352 const lastRack = lastRackId ? document.getElementById(lastRackId) : null; 1353 1354 const leftSlot = ensureWorkspaceLeftRack(); 1355 const rightSlot = ensureWorkspaceRightRack(); 1356 const sideRack = ensureMainSideRack(); 1357 const rightRack = ensureRightRack(); 1358 1359 const pickWorkspaceSlot = () => { 1360 const leftEmpty = leftSlot ? leftSlot.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; 1361 const rightEmpty = rightSlot ? rightSlot.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; 1362 return leftEmpty ? leftSlot : rightEmpty ? rightSlot : leftSlot; 1363 }; 1364 1365 let targetRack = null; 1366 if (lastRack instanceof HTMLElement) { 1367 targetRack = lastRack; 1368 } else if (panelIsSkinnyCapable(id)) { 1369 // Heuristic: aux-like panels default to side rack; "right" defaults to the right rack. 1370 const defRack = String(panelRegistry.get(id)?.defaultRack || ""); 1371 targetRack = defRack === "right" ? rightRack : sideRack; 1372 } else { 1373 targetRack = pickWorkspaceSlot(); 1374 } 1375 1376 // If restoring into a collapsed rack, uncollapse it (hotbar acts like a summonable launcher). 1377 if (targetRack && targetRack.id === "mainSideRack") setSideCollapsed(false); 1378 if (targetRack && targetRack.id === "rightRack") setRightCollapsed(false); 1379 1380 // If the panel already lives in a rack, keep its place and just reveal it. 1381 const currentRackId = rackIdForPanelElement(panelEl); 1382 const currentRack = currentRackId ? document.getElementById(currentRackId) : null; 1383 1384 undockPanel(id); 1385 1386 if (!(currentRack instanceof HTMLElement)) { 1387 const rack = targetRack instanceof HTMLElement ? targetRack : null; 1388 if (rack) { 1389 // Right rack + workspace slots are single-slot: docking the existing occupant is the least surprising behavior. 1390 const isWorkspaceSlot = rack.id === "workspaceLeftSlot" || rack.id === "workspaceRightSlot"; 1391 const isRightRackSlot = rack.id === "rightRack"; 1392 if (isWorkspaceSlot || isRightRackSlot) { 1393 const existing = rack.querySelector?.(":scope > .rackPanel:not(.hidden)"); 1394 if (existing instanceof HTMLElement && existing !== panelEl) { 1395 const existingId = String(existing.dataset.panelId || "").trim(); 1396 if (existingId) dockPanel(existingId); 1397 } 1398 } 1399 rack.appendChild(panelEl); 1400 rememberPanelLastRack(id, rack.id); 1401 saveRackLayoutState(); 1402 } 1403 } else { 1404 // Ensure the rack is visible if we restored into it. 1405 if (currentRack.id === "mainSideRack") setSideCollapsed(false); 1406 if (currentRack.id === "rightRack") setRightCollapsed(false); 1407 } 1408 1409 syncRackStateFromDom(); 1410 enforceWorkspaceRules(); 1411 } 1412 1413 function showHotbar(show) { 1414 if (!dockHotbarEl) return; 1415 if (!show && dockHotbarEl.dataset.lockVisible === "1") return; 1416 dockHotbarEl.classList.toggle("hidden", !show); 1417 dockHotbarEl.classList.toggle("show", Boolean(show)); 1418 if (appRoot) appRoot.classList.toggle("hotbarVisible", Boolean(show)); 1419 } 1420 1421 function renderHotbar() { 1422 if (!dockHotbarEl) return; 1423 const items = rackLayoutState.docked.bottom.slice().filter((id) => getPanelElement(id)); 1424 const includePlus = Boolean(rackLayoutEnabled); 1425 if (!items.length && !includePlus) { 1426 dockHotbarEl.classList.add("hidden"); 1427 dockHotbarEl.classList.remove("show"); 1428 dockHotbarEl.innerHTML = ""; 1429 if (appRoot) appRoot.classList.remove("hotbarVisible"); 1430 return; 1431 } 1432 1433 const orbsHtml = items 1434 .map( 1435 (id) => ` 1436 <button type="button" class="dockOrb" data-undock="${escapeHtml(id)}" title="Restore ${escapeHtml(panelTitle(id))}"> 1437 <span class="dockOrbIcon" aria-hidden="true">${escapeHtml(panelIcon(id))}</span> 1438 <span>${escapeHtml(panelTitle(id))}</span> 1439 </button> 1440 ` 1441 ) 1442 .join(""); 1443 1444 const plusHtml = includePlus 1445 ? ` 1446 <button type="button" class="dockOrb dockOrbPlus" data-hotbarplus="1" title="Add panel"> 1447 <span class="dockOrbIcon" aria-hidden="true">+</span> 1448 <span>Add</span> 1449 </button> 1450 ` 1451 : ""; 1452 1453 dockHotbarEl.innerHTML = `${orbsHtml}${plusHtml}`; 1454 dockHotbarEl.classList.remove("hidden"); 1455 requestAnimationFrame(() => showHotbar(true)); 1456 } 1457 1458 let hotbarPlusMenuEl = null; 1459 let workspaceAddMenuEl = null; 1460 1461 function closeHotbarPlusMenu() { 1462 if (!hotbarPlusMenuEl) return; 1463 try { 1464 hotbarPlusMenuEl.remove(); 1465 } catch { 1466 // ignore 1467 } 1468 hotbarPlusMenuEl = null; 1469 } 1470 1471 function closeWorkspaceAddMenu() { 1472 if (!workspaceAddMenuEl) return; 1473 try { 1474 workspaceAddMenuEl.remove(); 1475 } catch { 1476 // ignore 1477 } 1478 workspaceAddMenuEl = null; 1479 } 1480 1481 function workspaceAddCandidates() { 1482 return Array.from(panelRegistry.keys()) 1483 .filter((id) => Boolean(getPanelElement(id))) 1484 .filter((id) => !id.startsWith("chat:post:")) 1485 .filter((id) => id !== "profile") 1486 .filter((id) => !(id === "moderation" && !canModerate)) 1487 .map((id) => ({ 1488 id, 1489 title: panelTitle(id), 1490 icon: panelIcon(id), 1491 docked: isDocked(id), 1492 })) 1493 .sort((a, b) => a.title.localeCompare(b.title)); 1494 } 1495 1496 function restorePanelToWorkspaceSlot(panelId, slotId) { 1497 const id = String(panelId || "").trim(); 1498 const slot = String(slotId || "").trim(); 1499 if (!id || !slot) return; 1500 const target = slot === "workspaceRightSlot" ? ensureWorkspaceRightRack() : ensureWorkspaceLeftRack(); 1501 if (!(target instanceof HTMLElement)) return; 1502 const panelEl = getPanelElement(id); 1503 if (!(panelEl instanceof HTMLElement)) return; 1504 if (isDocked(id)) undockPanel(id); 1505 const existing = target.querySelector?.(":scope > .rackPanel:not(.hidden)"); 1506 if (existing instanceof HTMLElement && existing !== panelEl) { 1507 const existingId = String(existing.dataset.panelId || "").trim(); 1508 if (existingId) dockPanel(existingId); 1509 } 1510 target.appendChild(panelEl); 1511 rememberPanelLastRack(id, target.id); 1512 saveRackLayoutState(); 1513 applyDockState(); 1514 syncRackStateFromDom(); 1515 enforceWorkspaceRules(); 1516 } 1517 1518 function openWorkspaceAddMenu(anchorEl, slotId) { 1519 closeWorkspaceAddMenu(); 1520 if (!(anchorEl instanceof HTMLElement)) return; 1521 const slot = String(slotId || "").trim(); 1522 if (!slot) return; 1523 const items = workspaceAddCandidates() 1524 .map( 1525 (p) => `<button type="button" class="ghost smallBtn" data-workspaceaddpanel="${escapeHtml(p.id)}" data-workspaceaddslot="${escapeHtml(slot)}"> 1526 ${escapeHtml(p.icon)} ${escapeHtml(p.title)}${p.docked ? " (docked)" : ""} 1527 </button>` 1528 ) 1529 .join(""); 1530 const menu = document.createElement("div"); 1531 menu.className = "hotbarAddMenu"; 1532 menu.innerHTML = `<div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No panels available.</div>`}</div>`; 1533 const rect = anchorEl.getBoundingClientRect(); 1534 menu.style.left = `${Math.max(12, Math.min(window.innerWidth - 272, rect.left - 10))}px`; 1535 menu.style.top = `${Math.max(12, rect.bottom + 8)}px`; 1536 menu.addEventListener("click", (e) => { 1537 const btn = e.target.closest?.("[data-workspaceaddpanel][data-workspaceaddslot]"); 1538 if (!btn) return; 1539 const id = String(btn.getAttribute("data-workspaceaddpanel") || "").trim(); 1540 const slotIdNext = String(btn.getAttribute("data-workspaceaddslot") || "").trim(); 1541 if (!id || !slotIdNext) return; 1542 restorePanelToWorkspaceSlot(id, slotIdNext); 1543 closeWorkspaceAddMenu(); 1544 }); 1545 document.body.appendChild(menu); 1546 workspaceAddMenuEl = menu; 1547 } 1548 1549 function openHotbarPlusMenu(anchorEl) { 1550 closeHotbarPlusMenu(); 1551 if (!dockHotbarEl) return; 1552 if (!(anchorEl instanceof HTMLElement)) return; 1553 1554 const list = sortPosts(Array.from(posts.values())).slice(0, 8); 1555 const items = list 1556 .map((p) => { 1557 const id = String(p?.id || "").trim(); 1558 if (!id) return ""; 1559 const title = postTitle(p); 1560 return `<button type="button" class="ghost smallBtn" data-addchatpost="${escapeHtml(id)}">${escapeHtml(title)}</button>`; 1561 }) 1562 .filter(Boolean) 1563 .join(""); 1564 1565 const menu = document.createElement("div"); 1566 menu.className = "hotbarAddMenu"; 1567 menu.innerHTML = ` 1568 <div class="small muted" style="padding:6px 8px 4px;">New chat panel for...</div> 1569 <div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No hives yet.</div>`}</div> 1570 `; 1571 1572 const rect = anchorEl.getBoundingClientRect(); 1573 const left = Math.max(12, Math.min(window.innerWidth - 260, rect.left - 200)); 1574 const top = Math.max(12, rect.top - 260); 1575 menu.style.left = `${left}px`; 1576 menu.style.top = `${top}px`; 1577 1578 menu.addEventListener("click", (e) => { 1579 const btn = e.target.closest?.("[data-addchatpost]"); 1580 if (!btn) return; 1581 const postId = String(btn.getAttribute("data-addchatpost") || "").trim(); 1582 if (!postId) return; 1583 ensureChatPostPanelInstance(postId, { docked: true }); 1584 try { 1585 ws.send(JSON.stringify({ type: "getChat", postId })); 1586 } catch { 1587 // ignore 1588 } 1589 closeHotbarPlusMenu(); 1590 renderHotbar(); 1591 }); 1592 1593 document.body.appendChild(menu); 1594 hotbarPlusMenuEl = menu; 1595 } 1596 1597 function applyDockState() { 1598 // For the first implementation phase, we support docking any registered panel that has a DOM element. 1599 for (const [id, p] of panelRegistry.entries()) { 1600 const el = p?.element; 1601 if (!(el instanceof HTMLElement)) continue; 1602 if (id === "moderation" && !canModerate) { 1603 el.classList.add("hidden"); 1604 continue; 1605 } 1606 el.classList.toggle("hidden", isDocked(id)); 1607 } 1608 1609 renderHotbar(); 1610 updateSideRackEmptyState(); 1611 updateSkinnyChatPanels(); 1612 renderWorkspaceSlotAffordances(); 1613 } 1614 1615 function renderWorkspaceSlotAffordances() { 1616 if (!rackLayoutEnabled) return; 1617 const left = ensureWorkspaceLeftRack(); 1618 const right = ensureWorkspaceRightRack(); 1619 for (const slot of [left, right]) { 1620 if (!(slot instanceof HTMLElement)) continue; 1621 const hasVisible = Boolean(slot.querySelector?.(":scope > .rackPanel:not(.hidden)")); 1622 slot.classList.toggle("workspaceSlotEmpty", !hasVisible); 1623 const existing = slot.querySelector?.(":scope > .workspaceEmptyAdd"); 1624 if (hasVisible) { 1625 if (existing) existing.remove(); 1626 continue; 1627 } 1628 if (existing) continue; 1629 const btn = document.createElement("button"); 1630 btn.type = "button"; 1631 btn.className = "workspaceEmptyAdd ghost"; 1632 btn.setAttribute("data-workspaceadd", slot.id || ""); 1633 btn.innerHTML = `<span class="workspaceEmptyAddPlus">+</span><span>Add panel</span>`; 1634 slot.appendChild(btn); 1635 } 1636 } 1637 1638 function readRackOrder(rackEl) { 1639 if (!(rackEl instanceof HTMLElement)) return []; 1640 return Array.from(rackEl.querySelectorAll(".rackPanel")) 1641 .filter((el) => el instanceof HTMLElement && !el.classList.contains("hidden")) 1642 .map((el) => String(el?.dataset?.panelId || "").trim()) 1643 .filter(Boolean); 1644 } 1645 1646 function applyRackStateToDom() { 1647 if (!rackLayoutEnabled) return; 1648 // Ensure core "virtual" panels exist before we try to place them. 1649 ensurePluginRackPanel(); 1650 const left = ensureWorkspaceLeftRack(); 1651 const rightWorkspace = ensureWorkspaceRightRack(); 1652 const side = ensureMainSideRack(); 1653 const right = ensureRightRack(); 1654 if (!left || !rightWorkspace || !side || !right) return; 1655 const leftOrder = Array.isArray(rackLayoutState?.racks?.workspaceLeft) ? rackLayoutState.racks.workspaceLeft : []; 1656 const rightOrderW = Array.isArray(rackLayoutState?.racks?.workspaceRight) ? rackLayoutState.racks.workspaceRight : []; 1657 const sideOrder = Array.isArray(rackLayoutState?.racks?.side) ? rackLayoutState.racks.side : []; 1658 const rightOrder = Array.isArray(rackLayoutState?.racks?.right) ? rackLayoutState.racks.right : []; 1659 1660 for (const panelId of leftOrder) { 1661 const el = getPanelElement(panelId); 1662 if (el) left.appendChild(el); 1663 } 1664 for (const panelId of rightOrderW) { 1665 const el = getPanelElement(panelId); 1666 if (el) rightWorkspace.appendChild(el); 1667 } 1668 for (const panelId of sideOrder) { 1669 const el = getPanelElement(panelId); 1670 if (el) side.appendChild(el); 1671 } 1672 for (const panelId of rightOrder) { 1673 const el = getPanelElement(panelId); 1674 if (el) right.appendChild(el); 1675 } 1676 1677 // Hosted plugin widgets live inside Plugin Rack, not a top-level rack. 1678 const widgetsOrder = Array.isArray(rackLayoutState?.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []; 1679 const widgetsRack = ensurePluginRackWidgetsRack(); 1680 if (widgetsRack) { 1681 for (const panelId of widgetsOrder) { 1682 const el = getPanelElement(panelId); 1683 if (!el) continue; 1684 el.classList.add("pluginRackWidget"); 1685 widgetsRack.appendChild(el); 1686 } 1687 } 1688 } 1689 1690 function readWorkspaceActivePrimary() { 1691 try { 1692 const raw = localStorage.getItem(WORKSPACE_ACTIVE_PRIMARY_KEY); 1693 return raw ? String(raw) : ""; 1694 } catch { 1695 return ""; 1696 } 1697 } 1698 1699 function writeWorkspaceActivePrimary(panelId) { 1700 const id = String(panelId || "").trim(); 1701 if (!id) return; 1702 try { 1703 localStorage.setItem(WORKSPACE_ACTIVE_PRIMARY_KEY, id); 1704 } catch { 1705 // ignore 1706 } 1707 } 1708 1709 function enforceWorkspaceRules() { 1710 if (!rackLayoutEnabled) return; 1711 const left = ensureWorkspaceLeftRack(); 1712 const rightWorkspace = ensureWorkspaceRightRack(); 1713 const side = ensureMainSideRack(); 1714 const rightRack = ensureRightRack(); 1715 if (!left || !rightWorkspace || !side || !rightRack) return; 1716 1717 // Primary panels: allow up to 2 visible (one per workspace slot). Enforce max 1 per slot. 1718 const cleanupSlot = (slotEl) => { 1719 const kids = Array.from(slotEl.querySelectorAll(":scope > .rackPanel:not(.hidden)")); 1720 if (kids.length <= 1) return; 1721 for (const extra of kids.slice(1)) side.appendChild(extra); 1722 }; 1723 cleanupSlot(left); 1724 cleanupSlot(rightWorkspace); 1725 1726 // Side rack and right rack are "skinny columns": only allow skinny-capable panels. 1727 const enforceSkinny = (rackEl) => { 1728 const kids = Array.from(rackEl.querySelectorAll(":scope > .rackPanel:not(.hidden)")); 1729 for (const kid of kids) { 1730 const id = String(kid?.dataset?.panelId || "").trim(); 1731 if (!id) continue; 1732 if (!panelIsSkinnyCapable(id)) dockPanel(id); 1733 } 1734 }; 1735 enforceSkinny(side); 1736 enforceSkinny(rightRack); 1737 1738 // Side rack can stack, but keep it compact: at most 2 visible panels. 1739 const sideKids = Array.from(side.querySelectorAll(":scope > .rackPanel:not(.hidden)")); 1740 if (sideKids.length > 2) { 1741 for (const extra of sideKids.slice(2)) { 1742 const id = String(extra?.dataset?.panelId || "").trim(); 1743 if (id) dockPanel(id); 1744 } 1745 } 1746 1747 // Right rack is single-slot: keep at most one visible panel. 1748 const rightKids = Array.from(rightRack.querySelectorAll(":scope > .rackPanel:not(.hidden)")); 1749 if (rightKids.length > 1) { 1750 for (const extra of rightKids.slice(1)) { 1751 const id = String(extra?.dataset?.panelId || "").trim(); 1752 if (id) dockPanel(id); 1753 } 1754 } 1755 1756 // Panels that live in the workspace slots should be "full" by default (especially primaries). 1757 for (const slot of [left, rightWorkspace]) { 1758 const panel = slot.querySelector?.(":scope > .rackPanel:not(.hidden)"); 1759 if (!(panel instanceof HTMLElement)) continue; 1760 const id = String(panel.dataset.panelId || "").trim(); 1761 if (!id) continue; 1762 panel.classList.remove("panelCollapsed"); 1763 panel.dataset.panelDisplay = "full"; 1764 } 1765 1766 // If only one workspace slot is occupied, allow it to expand to full width to avoid blank space. 1767 // (We temporarily disable this during drag so the empty slot remains a visible drop target.) 1768 const leftPanel = left.querySelector?.(":scope > .rackPanel:not(.hidden)"); 1769 const rightPanel = rightWorkspace.querySelector?.(":scope > .rackPanel:not(.hidden)"); 1770 const leftId = String(leftPanel?.dataset?.panelId || "").trim(); 1771 const rightId = String(rightPanel?.dataset?.panelId || "").trim(); 1772 1773 // Workspace expansion (explicit maximize for primaries). 1774 const expandedId = readWorkspaceExpandedPrimary(); 1775 const expandedInLeft = Boolean(expandedId && expandedId === leftId); 1776 const expandedInRight = Boolean(expandedId && expandedId === rightId); 1777 const expandedValid = expandedInLeft || expandedInRight; 1778 if (appRoot) { 1779 appRoot.classList.toggle("workspaceExpandedLeft", expandedInLeft); 1780 appRoot.classList.toggle("workspaceExpandedRight", expandedInRight); 1781 if (!expandedValid) appRoot.classList.remove("workspaceExpandedLeft", "workspaceExpandedRight"); 1782 } 1783 if (expandedId && !expandedValid) clearWorkspaceExpandedState(); 1784 1785 // If expanded and the other slot is occupied, keep it accessible via hotbar. 1786 if (expandedInLeft && rightId && rightId !== expandedId) { 1787 if (!readWorkspaceExpandedDisplaced()) writeWorkspaceExpandedDisplaced(rightId); 1788 dockPanel(rightId); 1789 } 1790 if (expandedInRight && leftId && leftId !== expandedId) { 1791 if (!readWorkspaceExpandedDisplaced()) writeWorkspaceExpandedDisplaced(leftId); 1792 dockPanel(leftId); 1793 } 1794 1795 // Auto-expand single-primary only when not explicitly expanded. 1796 if (appRoot && !appRoot.classList.contains("rackIsDragging") && !expandedValid) { 1797 const leftOnly = Boolean(leftPanel && !rightPanel); 1798 const rightOnly = Boolean(!leftPanel && rightPanel); 1799 appRoot.classList.toggle("workspaceSingleLeft", leftOnly); 1800 appRoot.classList.toggle("workspaceSingleRight", rightOnly); 1801 } else if (appRoot) { 1802 appRoot.classList.remove("workspaceSingleLeft", "workspaceSingleRight"); 1803 } 1804 1805 // Transient panels should live in the side column and be collapsed by default. 1806 for (const el of Array.from(appRoot.querySelectorAll("#mainWorkspaceRack .rackPanel, #mainSideRack .rackPanel"))) { 1807 const id = String(el?.dataset?.panelId || "").trim(); 1808 if (!id) continue; 1809 if (panelRole(id) !== "transient") continue; 1810 if (el.parentElement !== side) side.appendChild(el); 1811 el.classList.add("panelCollapsed"); 1812 el.dataset.panelDisplay = "collapsed"; 1813 } 1814 1815 updateSkinnyChatPanels(); 1816 renderWorkspaceSlotAffordances(); 1817 syncRackStateFromDom(); 1818 } 1819 1820 function installWorkspaceInteractions() { 1821 if (!rackLayoutEnabled) return; 1822 if (!appRoot) return; 1823 if (appRoot.dataset.workspaceClicks === "1") return; 1824 appRoot.dataset.workspaceClicks = "1"; 1825 1826 appRoot.addEventListener("click", (e) => { 1827 if (!rackLayoutEnabled) return; 1828 const target = e.target; 1829 const addBtn = target?.closest?.("[data-workspaceadd]"); 1830 if (addBtn instanceof HTMLElement) { 1831 const slotId = String(addBtn.getAttribute("data-workspaceadd") || "").trim(); 1832 if (!slotId) return; 1833 if (workspaceAddMenuEl) closeWorkspaceAddMenu(); 1834 else openWorkspaceAddMenu(addBtn, slotId); 1835 return; 1836 } 1837 const interactive = target?.closest?.("button,a,input,select,textarea,label"); 1838 if (interactive) return; 1839 const panel = target?.closest?.(".rackPanel"); 1840 if (!panel) return; 1841 if (!(panel instanceof HTMLElement)) return; 1842 if (!panel.closest?.("#mainRack")) return; 1843 const panelId = String(panel.dataset.panelId || "").trim(); 1844 if (!panelId) return; 1845 if (panelRole(panelId) !== "primary") return; 1846 writeWorkspaceActivePrimary(panelId); 1847 enforceWorkspaceRules(); 1848 }); 1849 } 1850 1851 function syncRackStateFromDom() { 1852 if (!rackLayoutEnabled) return; 1853 const left = ensureWorkspaceLeftRack(); 1854 const rightWorkspace = ensureWorkspaceRightRack(); 1855 const side = ensureMainSideRack(); 1856 const right = ensureRightRack(); 1857 if (!left || !rightWorkspace || !side || !right) return; 1858 rackLayoutState.racks = { 1859 workspaceLeft: readRackOrder(left), 1860 workspaceRight: readRackOrder(rightWorkspace), 1861 side: readRackOrder(side), 1862 right: readRackOrder(right), 1863 }; 1864 rackLayoutState.pluginRackWidgets = readPluginRackWidgetsOrder(); 1865 const hosted = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []); 1866 for (const [id, entry] of panelRegistry.entries()) { 1867 const el = entry?.element; 1868 if (!(el instanceof HTMLElement)) continue; 1869 if (!el.classList.contains("pluginRackWidget") && hosted.has(id)) el.classList.add("pluginRackWidget"); 1870 if (el.classList.contains("pluginRackWidget") && !hosted.has(id)) el.classList.remove("pluginRackWidget"); 1871 } 1872 saveRackLayoutState(); 1873 } 1874 1875 function ensureRightRack() { 1876 if (!appRoot) return null; 1877 if (rightRackEl && rightRackEl.isConnected) return rightRackEl; 1878 const el = document.createElement("aside"); 1879 el.id = "rightRack"; 1880 el.className = "rightRack"; 1881 appRoot.appendChild(el); 1882 rightRackEl = el; 1883 return el; 1884 } 1885 1886 function ensureMainRack() { 1887 // In rack mode, "main rack" is the workspace column inside #mainRack. 1888 if (mainRack && mainRack.isConnected) return mainRack; 1889 if (mainWorkspaceRackEl) { 1890 mainRack = mainWorkspaceRackEl; 1891 return mainRack; 1892 } 1893 1894 const wrapper = mainRackEl || document.querySelector("#mainRack") || document.querySelector("main.main"); 1895 if (!wrapper) return null; 1896 1897 let workspace = wrapper.querySelector?.("#mainWorkspaceRack"); 1898 let side = wrapper.querySelector?.("#mainSideRack"); 1899 if (!workspace) { 1900 const w = document.createElement("div"); 1901 w.id = "mainWorkspaceRack"; 1902 w.className = "workspaceRack"; 1903 w.setAttribute("aria-label", "Workspace"); 1904 wrapper.appendChild(w); 1905 workspace = w; 1906 } 1907 if (!side) { 1908 const s = document.createElement("div"); 1909 s.id = "mainSideRack"; 1910 s.className = "sideRack"; 1911 s.setAttribute("aria-label", "Side panels"); 1912 wrapper.appendChild(s); 1913 side = s; 1914 } 1915 mainSideRack = side; 1916 mainRack = workspace; 1917 return mainRack; 1918 } 1919 1920 function ensureMainSideRack() { 1921 if (mainSideRack && mainSideRack.isConnected) return mainSideRack; 1922 if (mainSideRackEl) { 1923 mainSideRack = mainSideRackEl; 1924 return mainSideRack; 1925 } 1926 // Ensure the workspace rack exists too (creates both columns if missing). 1927 ensureMainRack(); 1928 return mainSideRack instanceof HTMLElement ? mainSideRack : null; 1929 } 1930 1931 function ensureWorkspaceLeftRack() { 1932 const { left } = ensureWorkspaceSlots(); 1933 return left instanceof HTMLElement ? left : null; 1934 } 1935 1936 function ensureWorkspaceRightRack() { 1937 const { right } = ensureWorkspaceSlots(); 1938 return right instanceof HTMLElement ? right : null; 1939 } 1940 1941 function enableRackLayoutDom() { 1942 if (!appRoot) return; 1943 appRoot.classList.add("rackMode"); 1944 const rack = ensureRightRack(); 1945 if (!rack) return; 1946 const main = ensureMainRack(); 1947 const left = ensureWorkspaceLeftRack(); 1948 const rightWorkspace = ensureWorkspaceRightRack(); 1949 const side = ensureMainSideRack(); 1950 1951 const mark = (el, panelId) => { 1952 if (!el) return; 1953 el.classList.add("rackPanel"); 1954 el.dataset.panelId = panelId; 1955 }; 1956 1957 // Move right-side panels into the rack so they become stackable. 1958 // (This is a stepping stone toward full dockable panels.) 1959 if (chatPanelEl) { 1960 mark(chatPanelEl, "chat"); 1961 // Chat is a workspace primary in rack mode by default; enforceWorkspaceRules will manage if moved. 1962 if (rightWorkspace && chatPanelEl.parentElement !== rightWorkspace) rightWorkspace.appendChild(chatPanelEl); 1963 } 1964 if (peopleDrawerEl) { 1965 mark(peopleDrawerEl, "people"); 1966 if (peopleDrawerEl.parentElement !== rack) rack.appendChild(peopleDrawerEl); 1967 } 1968 if (modPanelEl) { 1969 mark(modPanelEl, "moderation"); 1970 if (modPanelEl.parentElement !== rack) rack.appendChild(modPanelEl); 1971 } 1972 1973 // Mark center panels as rack panels too (they already live in mainRack in normal DOM). 1974 if (main) { 1975 if (onboardingPanelEl) { 1976 mark(onboardingPanelEl, "onboarding"); 1977 if (left && onboardingPanelEl.parentElement !== left) left.appendChild(onboardingPanelEl); 1978 onboardingPanelEl.classList.remove("hidden"); 1979 } 1980 if (hivesPanelEl) { 1981 mark(hivesPanelEl, "hives"); 1982 if (left && hivesPanelEl.parentElement !== left) left.appendChild(hivesPanelEl); 1983 } 1984 if (profileViewPanel) { 1985 mark(profileViewPanel, "profile"); 1986 if (side && profileViewPanel.parentElement !== side) side.appendChild(profileViewPanel); 1987 // In rack mode, profile is its own panel; don't keep it hidden behind the legacy center-view toggle. 1988 profileViewPanel.classList.remove("hidden"); 1989 } 1990 if (pollinatePanel) { 1991 mark(pollinatePanel, "composer"); 1992 if (side && pollinatePanel.parentElement !== side) side.appendChild(pollinatePanel); 1993 } 1994 } 1995 1996 // Hide old resizers in rack mode (we'll replace with rack-aware resizing later). 1997 chatResizeHandle?.classList.add("hidden"); 1998 peopleResizeHandle?.classList.add("hidden"); 1999 2000 // People drawer chrome: hide the close button (panel is now a rack item). 2001 closePeopleBtn?.classList.add("hidden"); 2002 // People drawer toggle button is obsolete in rack mode. 2003 togglePeopleBtn?.classList.add("hidden"); 2004 // Ensure people panel isn't hidden by legacy state. 2005 peopleDrawerEl?.classList.remove("hidden"); 2006 peopleOpen = true; 2007 2008 // Profile panel no longer "replaces" the feed in rack mode, so the back button is confusing. 2009 profileBackBtn?.classList.add("hidden"); 2010 } 2011 2012 function disableRackLayoutDom() { 2013 if (!appRoot) return; 2014 appRoot.classList.remove("rackMode"); 2015 // No attempt to move elements back (yet). Disable is meant for page reload use. 2016 } 2017 2018 function applyPreset(presetId) { 2019 const key = resolvePresetKey(presetId); 2020 const def = PRESET_DEFS[key]; 2021 if (!def) return; 2022 if (def.modOnly && !canModerate) { 2023 applyPreset("onboardingDefault"); 2024 return; 2025 } 2026 2027 // Presets are hard-applied: clear any hosted widgets so placement remains deterministic. 2028 closePluginRackAddMenu(); 2029 for (const id of readPluginRackWidgetsOrder()) removePanelFromPluginRack(id); 2030 rackLayoutState.pluginRackWidgets = []; 2031 2032 rackLayoutState.presetId = def.presetId || key; 2033 2034 const workspaceLeftOrder = Array.isArray(def.workspaceLeftOrder) ? def.workspaceLeftOrder.map((x) => String(x || "")).filter(Boolean) : []; 2035 const workspaceRightOrder = Array.isArray(def.workspaceRightOrder) ? def.workspaceRightOrder.map((x) => String(x || "")).filter(Boolean) : []; 2036 const sideOrder = Array.isArray(def.sideOrder) ? def.sideOrder.map((x) => String(x || "")).filter(Boolean) : []; 2037 const rightOrderRaw = Array.isArray(def.rightOrder) ? def.rightOrder.map((x) => String(x || "")).filter(Boolean) : []; 2038 // Right rack is a single skinny-capable panel. 2039 const rightOrder = rightOrderRaw.length ? [rightOrderRaw[0]] : []; 2040 2041 // Applying a preset should be deterministic even after the user has rearranged panels. 2042 clearWorkspaceExpandedState(); 2043 const expandedPrimary = typeof def.expandedPrimary === "string" ? def.expandedPrimary.trim() : ""; 2044 if (expandedPrimary) writeWorkspaceExpandedPrimary(expandedPrimary); 2045 2046 if (typeof def.composerOpen === "boolean") setComposerOpen(def.composerOpen); 2047 setSideCollapsed(Boolean(def.sideCollapsed), { persist: true }); 2048 setRightCollapsed(Boolean(def.rightCollapsed), { persist: true }); 2049 2050 const leftRack = ensureWorkspaceLeftRack(); 2051 const rightWorkspaceRack = ensureWorkspaceRightRack(); 2052 const sideRack = ensureMainSideRack(); 2053 const rightRack = ensureRightRack(); 2054 if (!leftRack || !rightWorkspaceRack || !sideRack || !rightRack) return; 2055 2056 const placed = new Set([...workspaceLeftOrder, ...workspaceRightOrder, ...sideOrder, ...rightOrder]); 2057 const docked = new Set(Array.isArray(def.dockBottom) ? def.dockBottom.map((x) => String(x || "")).filter(Boolean) : []); 2058 for (const id of placed) docked.delete(id); 2059 2060 // Default: anything not explicitly placed by the preset goes to the hotbar. 2061 for (const id of Array.from(panelRegistry.keys())) { 2062 if (!placed.has(id)) docked.add(id); 2063 } 2064 2065 // Moderation panel should not be forced visible for non-mods. 2066 if (!canModerate) { 2067 docked.add("moderation"); 2068 // Also ensure moderation isn't placed anywhere. 2069 workspaceLeftOrder.splice(0, workspaceLeftOrder.length, ...workspaceLeftOrder.filter((x) => x !== "moderation")); 2070 workspaceRightOrder.splice(0, workspaceRightOrder.length, ...workspaceRightOrder.filter((x) => x !== "moderation")); 2071 sideOrder.splice(0, sideOrder.length, ...sideOrder.filter((x) => x !== "moderation")); 2072 } 2073 2074 rackLayoutState.docked.bottom = Array.from(docked); 2075 2076 saveRackLayoutState(); 2077 applyDockState(); 2078 2079 // Detach all known panels before re-placing, so we don't end up with "stale" panels sticking in old racks. 2080 const elsById = new Map(); 2081 for (const id of Array.from(panelRegistry.keys())) { 2082 const el = getPanelElement(id); 2083 if (el) elsById.set(id, el); 2084 } 2085 for (const el of elsById.values()) { 2086 if (el.parentElement) el.parentElement.removeChild(el); 2087 } 2088 2089 if (leftRack) { 2090 for (const panelId of workspaceLeftOrder) { 2091 if (docked.has(panelId)) continue; 2092 const el = elsById.get(panelId) || getPanelElement(panelId); 2093 if (el) leftRack.appendChild(el); 2094 } 2095 } 2096 if (rightWorkspaceRack) { 2097 for (const panelId of workspaceRightOrder) { 2098 if (docked.has(panelId)) continue; 2099 const el = elsById.get(panelId) || getPanelElement(panelId); 2100 if (el) rightWorkspaceRack.appendChild(el); 2101 } 2102 } 2103 if (sideRack) { 2104 for (const panelId of sideOrder) { 2105 if (docked.has(panelId)) continue; 2106 const el = elsById.get(panelId) || getPanelElement(panelId); 2107 if (el) sideRack.appendChild(el); 2108 } 2109 } 2110 if (rightRack) { 2111 for (const panelId of rightOrder) { 2112 if (docked.has(panelId)) continue; 2113 const el = elsById.get(panelId) || getPanelElement(panelId); 2114 if (el) rightRack.appendChild(el); 2115 } 2116 } 2117 2118 syncRackStateFromDom(); 2119 enforceWorkspaceRules(); 2120 updateLayoutPresetOptions(); 2121 } 2122 2123 function installPanelMinimizeButtons() { 2124 const addMinBtn = (headerEl, panelId) => { 2125 if (!headerEl) return; 2126 const row = headerEl.querySelector(".row") || headerEl.querySelector(".filters") || headerEl; 2127 2128 if (!headerEl.querySelector(`[data-rackdrag="${panelId}"]`)) { 2129 const drag = document.createElement("button"); 2130 drag.type = "button"; 2131 drag.className = "ghost smallBtn rackDragHandle"; 2132 drag.textContent = "β‘"; 2133 drag.title = "Drag to reorder"; 2134 drag.setAttribute("data-rackdrag", panelId); 2135 row.appendChild(drag); 2136 } 2137 2138 if (panelIsSkinnyCapable(panelId) && !headerEl.querySelector(`[data-skinny="${panelId}"]`)) { 2139 const skinny = document.createElement("button"); 2140 skinny.type = "button"; 2141 skinny.className = "ghost smallBtn"; 2142 skinny.textContent = "β"; 2143 skinny.title = "Toggle skinny/full"; 2144 skinny.setAttribute("data-skinny", panelId); 2145 skinny.onclick = () => togglePanelSkinny(panelId); 2146 row.appendChild(skinny); 2147 } 2148 if (!panelIsSkinnyCapable(panelId)) { 2149 headerEl.querySelector(`[data-skinny="${cssEscape(panelId)}"]`)?.remove(); 2150 } 2151 2152 if (panelCanExpand(panelId) && !headerEl.querySelector(`[data-expand="${panelId}"]`)) { 2153 const expand = document.createElement("button"); 2154 expand.type = "button"; 2155 expand.className = "ghost smallBtn"; 2156 expand.textContent = "β‘"; 2157 expand.title = "Expand workspace"; 2158 expand.setAttribute("data-expand", panelId); 2159 expand.onclick = () => togglePrimaryExpand(panelId); 2160 row.appendChild(expand); 2161 } 2162 2163 if (!headerEl.querySelector(`[data-minimize="${panelId}"]`)) { 2164 const btn = document.createElement("button"); 2165 btn.type = "button"; 2166 btn.className = "ghost smallBtn"; 2167 btn.textContent = "-"; 2168 btn.title = "Minimize to hotbar"; 2169 btn.setAttribute("data-minimize", panelId); 2170 btn.onclick = () => dockPanel(panelId); 2171 row.appendChild(btn); 2172 } 2173 }; 2174 2175 addMinBtn(chatHeaderEl, "chat"); 2176 addMinBtn(modPanelEl?.querySelector(".panelHeader"), "moderation"); 2177 addMinBtn(peopleDrawerEl?.querySelector(".panelHeader"), "people"); 2178 addMinBtn(hivesPanelEl?.querySelector(".panelHeader"), "hives"); 2179 addMinBtn(profileViewPanel?.querySelector(".panelHeader"), "profile"); 2180 addMinBtn(pollinatePanel?.querySelector(".panelHeader"), "composer"); 2181 ensurePluginRackPanel(); 2182 addMinBtn(pluginRackPanelEl?.querySelector(".panelHeader"), "pluginRack"); 2183 } 2184 2185 function ensurePluginPanelShell(panelId, title, icon, defaultRack, role) { 2186 const wantsMain = String(defaultRack || "").toLowerCase() === "main"; 2187 const isPrimary = String(role || "").toLowerCase() === "primary"; 2188 let preferred = null; 2189 if (wantsMain && isPrimary) { 2190 // Primary panels should live inside a workspace slot, not as loose items in the workspace grid. 2191 const left = ensureWorkspaceLeftRack(); 2192 const right = ensureWorkspaceRightRack(); 2193 const side = ensureMainSideRack(); 2194 const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel").length === 0 : false; 2195 const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel").length === 0 : false; 2196 preferred = leftEmpty ? left : rightEmpty ? right : side; 2197 } else if (wantsMain) { 2198 preferred = ensureMainSideRack(); 2199 } else { 2200 preferred = ensureRightRack(); 2201 } 2202 const rack = preferred || ensureRightRack() || ensureMainSideRack() || ensureWorkspaceLeftRack() || ensureWorkspaceRightRack() || ensureMainRack(); 2203 if (!rack) return null; 2204 2205 const existing = document.querySelector?.(`.panel.pluginPanel[data-panel-id="${CSS.escape(panelId)}"]`); 2206 if (existing instanceof HTMLElement) { 2207 if (existing.parentElement !== rack) rack.appendChild(existing); 2208 return existing; 2209 } 2210 2211 const shell = document.createElement("section"); 2212 shell.className = "panel panelFill pluginPanel rackPanel"; 2213 shell.dataset.panelId = panelId; 2214 shell.innerHTML = ` 2215 <div class="panelHeader"> 2216 <div class="panelTitle">${escapeHtml(title || panelId)}</div> 2217 <div class="row"> 2218 <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">β‘</button> 2219 <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">-</button> 2220 </div> 2221 </div> 2222 <div class="panelBody" data-pluginmount="1"></div> 2223 `; 2224 2225 const minBtn = shell.querySelector(`[data-minimize="${panelId}"]`); 2226 if (isPrimary || panelCanExpand(panelId)) { 2227 const headerRow = shell.querySelector(".panelHeader .row"); 2228 if (headerRow && !headerRow.querySelector(`[data-expand="${panelId}"]`)) { 2229 const expand = document.createElement("button"); 2230 expand.type = "button"; 2231 expand.className = "ghost smallBtn"; 2232 expand.textContent = "β‘"; 2233 expand.title = "Expand workspace"; 2234 expand.setAttribute("data-expand", panelId); 2235 expand.addEventListener("click", () => togglePrimaryExpand(panelId)); 2236 if (minBtn && minBtn.parentElement === headerRow) headerRow.insertBefore(expand, minBtn); 2237 else headerRow.appendChild(expand); 2238 } 2239 } 2240 if (minBtn) minBtn.addEventListener("click", () => dockPanel(panelId)); 2241 2242 rack.appendChild(shell); 2243 return shell; 2244 } 2245 2246 function ensureChatPostPanelInstance(postId, opts) { 2247 if (!rackLayoutEnabled) return ""; 2248 const pid = String(postId || "").trim(); 2249 if (!pid) return ""; 2250 const post = posts.get(pid) || null; 2251 const panelId = chatInstancePanelIdForPost(pid); 2252 if (!panelId) return ""; 2253 2254 if (panelRegistry.has(panelId)) return panelId; 2255 2256 const title = post?.title ? `Chat: ${String(post.title).slice(0, 32)}` : "Chat"; 2257 const shell = document.createElement("section"); 2258 shell.className = "panel panelFill rackPanel chat chatInstance"; 2259 shell.dataset.panelId = panelId; 2260 shell.innerHTML = ` 2261 <div class="panelHeader"> 2262 <div> 2263 <div class="panelTitle">${escapeHtml(title)}</div> 2264 <div class="small muted chatMeta"></div> 2265 </div> 2266 <div class="row"> 2267 <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">β‘</button> 2268 <button type="button" class="ghost smallBtn" data-skinny="${escapeHtml(panelId)}" title="Toggle skinny/full">β</button> 2269 <button type="button" class="ghost smallBtn" data-expand="${escapeHtml(panelId)}" title="Expand workspace">β‘</button> 2270 <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">-</button> 2271 </div> 2272 </div> 2273 <div class="chatMessages"></div> 2274 <div class="typingIndicator small muted"></div> 2275 <form class="chatForm"> 2276 <div class="chatComposer"> 2277 <div class="toolbar" role="toolbar" aria-label="Chat formatting"> 2278 <button type="button" data-chatcmd="bold"><b>B</b></button> 2279 <button type="button" data-chatcmd="italic"><i>I</i></button> 2280 <button type="button" data-chatcmd="underline"><u>U</u></button> 2281 <button type="button" data-chatcmd="strikeThrough"><s>S</s></button> 2282 <span class="sep"></span> 2283 <button type="button" data-chatcmd="insertUnorderedList">List</button> 2284 <button type="button" data-chatcmd="insertOrderedList">1. List</button> 2285 <button type="button" data-chatlink="1">Link</button> 2286 <button type="button" data-chatimg="1">GIF/Image</button> 2287 <button type="button" data-chataudio="1">Audio</button> 2288 <button type="button" data-chatemoji="1">Emoji</button> 2289 <button type="button" data-chatcmd="removeFormat">Clear</button> 2290 </div> 2291 <div class="chatInstanceTools"> 2292 <label class="checkRow chatModToggle chatInstModToggle hidden" title="Send as moderator/system message (left rail)"> 2293 <span>Mod</span> 2294 <input class="chatInstModToggleInput" type="checkbox" /> 2295 </label> 2296 </div> 2297 <div class="editor chatEditor" contenteditable="true" aria-label="Chat editor"></div> 2298 </div> 2299 <button class="primary" type="submit">Send</button> 2300 </form> 2301 `; 2302 2303 const metaEl = shell.querySelector(".chatMeta"); 2304 const messagesEl = shell.querySelector(".chatMessages"); 2305 const typingEl = shell.querySelector(".typingIndicator"); 2306 const formEl = shell.querySelector("form.chatForm"); 2307 const editorEl = shell.querySelector(".chatEditor"); 2308 const modToggleWrapEl = shell.querySelector(".chatInstModToggle"); 2309 const modToggleEl = shell.querySelector(".chatInstModToggleInput"); 2310 2311 shell.querySelector(`[data-minimize="${cssEscape(panelId)}"]`)?.addEventListener("click", () => dockPanel(panelId)); 2312 shell.querySelector(`[data-expand="${cssEscape(panelId)}"]`)?.addEventListener("click", () => togglePrimaryExpand(panelId)); 2313 shell.querySelector(`[data-skinny="${cssEscape(panelId)}"]`)?.addEventListener("click", () => togglePanelSkinny(panelId)); 2314 2315 if (formEl && editorEl) { 2316 formEl.addEventListener("submit", (e) => { 2317 e.preventDefault(); 2318 const html = String(editorEl.innerHTML || "").trim(); 2319 const text = String(editorEl.innerText || "").trim(); 2320 const hasImg = Boolean(editorEl.querySelector("img")); 2321 const hasAudio = Boolean(editorEl.querySelector("audio")); 2322 if (!text && !hasImg && !hasAudio) return; 2323 if (!loggedInUser) { 2324 toast("Sign in required", "Sign in to chat."); 2325 return; 2326 } 2327 const currentPost = posts.get(pid) || null; 2328 if (currentPost && String(currentPost.mode || currentPost.chatMode || "").toLowerCase() === "walkie") { 2329 toast("Walkie Talkie", "This hive is walkie-only. Hold ~ to talk."); 2330 return; 2331 } 2332 if (currentPost?.readOnly && !(loggedInRole === "owner" || loggedInRole === "moderator")) { 2333 toast("Read-only", "This hive is read-only."); 2334 return; 2335 } 2336 if (currentPost?.deleted) { 2337 toast("Unavailable", "This post was deleted."); 2338 return; 2339 } 2340 const wantsMod = Boolean(canModerate && modToggleEl instanceof HTMLInputElement && modToggleEl.checked); 2341 ws.send(JSON.stringify({ type: "typing", postId: pid, isTyping: false })); 2342 ws.send(JSON.stringify({ type: "chatMessage", postId: pid, text, html, replyToId: "", asMod: wantsMod })); 2343 editorEl.innerHTML = ""; 2344 // Leave global reply-to state alone; this instance panel is independent (MVP). 2345 }); 2346 2347 editorEl.addEventListener("focus", () => { 2348 chatUploadTargetEditor = editorEl; 2349 }); 2350 2351 editorEl.addEventListener("keydown", (e) => { 2352 if (!shouldSubmitChatOnEnter(e)) return; 2353 e.preventDefault(); 2354 formEl.requestSubmit(); 2355 }); 2356 2357 // Allow drag/drop uploads in instance chats too. 2358 try { 2359 installDropUpload(editorEl, { allowImages: true, allowAudio: true }); 2360 } catch { 2361 // ignore 2362 } 2363 } 2364 2365 if (modToggleWrapEl) modToggleWrapEl.classList.toggle("hidden", !canModerate); 2366 2367 // Register + insert. 2368 panelRegistry.set(panelId, { 2369 id: panelId, 2370 title, 2371 icon: "π¬", 2372 source: "core", 2373 role: "aux", 2374 defaultRack: "main", 2375 element: shell, 2376 }); 2377 chatPanelInstances.set(panelId, { postId: pid }); 2378 2379 const options = opts && typeof opts === "object" ? opts : {}; 2380 const docked = Boolean(options.docked); 2381 const sideRack = ensureMainSideRack(); 2382 if (docked) { 2383 // Keep it out of layout; show as orb. 2384 if (sideRack) sideRack.appendChild(shell); 2385 dockPanel(panelId); 2386 } else { 2387 setSideCollapsed(false); 2388 if (sideRack) sideRack.prepend(shell); 2389 rememberPanelLastRack(panelId, "mainSideRack"); 2390 saveRackLayoutState(); 2391 applyDockState(); 2392 syncRackStateFromDom(); 2393 enforceWorkspaceRules(); 2394 } 2395 2396 renderChatPostPanelInstance(panelId, true); 2397 return panelId; 2398 } 2399 2400 function renderTypingIndicatorForPost(postId, targetEl) { 2401 if (!(targetEl instanceof HTMLElement)) return; 2402 const id = String(postId || "").trim(); 2403 if (!id) { 2404 targetEl.textContent = ""; 2405 return; 2406 } 2407 const set = typingUsersByPostId.get(id); 2408 if (!set || set.size === 0) { 2409 targetEl.textContent = ""; 2410 return; 2411 } 2412 const names = Array.from(set.values()).slice(0, 3); 2413 const more = set.size > names.length ? ` +${set.size - names.length}` : ""; 2414 targetEl.textContent = `${names.map((u) => `@${u}`).join(", ")}${more} typing...`; 2415 } 2416 2417 function renderChatPostPanelInstance(panelId, forceScroll) { 2418 const id = String(panelId || "").trim(); 2419 if (!id) return; 2420 const inst = chatPanelInstances.get(id); 2421 if (!inst) return; 2422 const postId = String(inst.postId || "").trim(); 2423 const post = postId ? posts.get(postId) : null; 2424 const root = getPanelElement(id); 2425 if (!(root instanceof HTMLElement)) return; 2426 const metaEl = root.querySelector(".chatMeta"); 2427 const messagesEl = root.querySelector(".chatMessages"); 2428 const typingEl = root.querySelector(".typingIndicator"); 2429 const editorEl = root.querySelector(".chatEditor"); 2430 const sendBtn = root.querySelector("form.chatForm button[type='submit']"); 2431 2432 if (metaEl) { 2433 if (!post) metaEl.textContent = "Hive not found."; 2434 else { 2435 const tags = (post.keywords || []).map((k) => `#${k}`).join(" "); 2436 const author = post.author ? `by @${post.author}` : ""; 2437 const exp = formatCountdown(post.expiresAt); 2438 const ro = post.readOnly ? " | read-only" : ""; 2439 metaEl.textContent = `${author}${ro} | ${exp === "permanent" ? "permanent" : `expires in ${exp}`} | ${tags}`.trim(); 2440 } 2441 } 2442 2443 if (!(messagesEl instanceof HTMLElement)) return; 2444 const atBottomBefore = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 24; 2445 2446 if (!post) { 2447 messagesEl.innerHTML = `<div class="small muted">Hive not found.</div>`; 2448 if (typingEl) typingEl.textContent = ""; 2449 return; 2450 } 2451 if (post.deleted) { 2452 messagesEl.innerHTML = `<div class="small muted">Post was deleted.</div>`; 2453 if (typingEl) typingEl.textContent = ""; 2454 return; 2455 } 2456 2457 const isWalkie = String(post.mode || post.chatMode || "").toLowerCase() === "walkie"; 2458 const canChatWrite = Boolean(loggedInRole === "owner" || loggedInRole === "moderator" || !post.readOnly); 2459 if (editorEl) editorEl.contentEditable = String(Boolean(canChatWrite && !isWalkie)); 2460 if (sendBtn instanceof HTMLButtonElement) sendBtn.disabled = !(loggedInUser && canChatWrite && !isWalkie); 2461 2462 const modToggleWrapEl = root.querySelector(".chatInstModToggle"); 2463 const modToggleEl = root.querySelector(".chatInstModToggleInput"); 2464 if (modToggleWrapEl) modToggleWrapEl.classList.toggle("hidden", !canModerate); 2465 if (!canModerate && modToggleEl instanceof HTMLInputElement) modToggleEl.checked = false; 2466 2467 const messages = chatByPost.get(post.id) || []; 2468 const ignoreUserSet = new Set( 2469 [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase()) 2470 ); 2471 const selfLower = String(loggedInUser || "").toLowerCase(); 2472 const visibleMessages = messages.filter((m) => { 2473 const fromLower = String(m?.fromUser || "").toLowerCase(); 2474 if (!fromLower || fromLower === selfLower) return true; 2475 return !ignoreUserSet.has(fromLower); 2476 }); 2477 2478 messagesEl.innerHTML = visibleMessages 2479 .map((m, index) => { 2480 const isModMsg = Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod"; 2481 const from = isModMsg ? "MOD" : m.fromUser || ""; 2482 const isYou = loggedInUser && from && from === loggedInUser; 2483 const rail = chatRailClass({ fromUser: from, isModMessage: isModMsg }); 2484 const prev = index > 0 ? visibleMessages[index - 1] : null; 2485 const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); 2486 const mentions = Array.isArray(m.mentions) ? m.mentions.map((u) => String(u || "").toLowerCase()) : []; 2487 const mentionMe = Boolean(loggedInUser && mentions.includes(loggedInUser)); 2488 const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || ""); 2489 const youTag = !isModMsg && isYou ? `<span class="muted">(you)</span>` : ""; 2490 const time = new Date(m.createdAt).toLocaleTimeString(); 2491 const tint = isModMsg ? "" : tintStylesFromHex(getProfile(from).color); 2492 const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; 2493 const content = html ? html : highlightMentionsInText(m.text || ""); 2494 const replyMeta = m.replyTo && typeof m.replyTo === "object" ? m.replyTo : null; 2495 const replyBlock = replyMeta 2496 ? `<div class="chatReplyRef"><span class="small muted">@${escapeHtml(replyMeta.fromUser || "unknown")}</span><div class="small">${escapeHtml( 2497 String(replyMeta.text || "[media]").slice(0, 120) 2498 )}</div></div>` 2499 : ""; 2500 const reacts = renderReactionButtons({ kind: "chat", id: m.id, reactions: m.reactions || {}, postId: post.id }); 2501 const deletedLine = m.deleted 2502 ? `<div class="small muted">message deleted${ 2503 m.deletedBy ? ` by @${escapeHtml(m.deletedBy)}` : "" 2504 } at ${escapeHtml(new Date(Number(m.deletedAt || m.createdAt || Date.now())).toLocaleString())}</div>` 2505 : ""; 2506 const editedLine = 2507 !m.deleted && Number(m.editCount || 0) > 0 2508 ? `<div class="small muted">edited (${Number(m.editCount || 0)}) at ${escapeHtml( 2509 new Date(Number(m.editedAt || m.createdAt || Date.now())).toLocaleTimeString() 2510 )}</div>` 2511 : ""; 2512 const reportAction = 2513 loggedInUser && !m.deleted 2514 ? `<button type="button" class="ghost smallBtn" data-reportchat="${escapeHtml(m.id)}" data-postid="${escapeHtml( 2515 post.id 2516 )}">Report</button>` 2517 : ""; 2518 const deleteAction = 2519 loggedInUser && !m.deleted && (loggedInRole === "owner" || loggedInRole === "moderator" || from === loggedInUser) 2520 ? `<button type="button" class="ghost smallBtn" data-delchat="${escapeHtml(m.id)}" data-postid="${escapeHtml( 2521 post.id 2522 )}">Delete</button>` 2523 : ""; 2524 const actions = 2525 reportAction || deleteAction 2526 ? `<div class="chatTools">${reportAction}${deleteAction}</div>` 2527 : ""; 2528 return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""} ${rail} ${isModMsg ? "isModMsg" : ""}" data-msgid="${escapeHtml( 2529 m.id 2530 )}" ${tint}> 2531 <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> 2532 ${replyBlock} 2533 <div class="content">${content}</div> 2534 ${deletedLine}${editedLine} 2535 <div class="chatActionsRow">${reacts}${actions}</div> 2536 </div>`; 2537 }) 2538 .join(""); 2539 2540 for (const contentEl of messagesEl.querySelectorAll(".chatMsg .content")) { 2541 decorateMentionNodesInElement(contentEl); 2542 decorateYouTubeEmbedsInElement(contentEl); 2543 } 2544 2545 renderTypingIndicatorForPost(post.id, typingEl); 2546 2547 if (forceScroll || atBottomBefore) messagesEl.scrollTop = messagesEl.scrollHeight; 2548 } 2549 2550 function renderChatInstancesForPost(postId) { 2551 const pid = String(postId || "").trim(); 2552 if (!pid) return; 2553 for (const [panelId, inst] of chatPanelInstances.entries()) { 2554 if (String(inst?.postId || "") !== pid) continue; 2555 renderChatPostPanelInstance(panelId); 2556 } 2557 } 2558 2559 function setChatInstancePanelPost(panelId, postId, forceScroll = true) { 2560 const pid = String(postId || "").trim(); 2561 const id = String(panelId || "").trim(); 2562 if (!pid || !id) return false; 2563 const inst = chatPanelInstances.get(id); 2564 if (!inst) return false; 2565 const post = posts.get(pid); 2566 if (!post) return false; 2567 inst.postId = pid; 2568 chatPanelInstances.set(id, inst); 2569 const root = getPanelElement(id); 2570 const titleEl = root?.querySelector?.(".panelTitle"); 2571 if (titleEl) titleEl.textContent = post?.title ? `Chat: ${String(post.title).slice(0, 32)}` : "Chat"; 2572 renderChatPostPanelInstance(id, forceScroll); 2573 return true; 2574 } 2575 2576 function nearestVisibleChatInstancePanelId(sourceEl) { 2577 const anchor = sourceEl instanceof HTMLElement ? sourceEl : null; 2578 if (!anchor) return ""; 2579 const anchorRect = anchor.getBoundingClientRect(); 2580 const ax = anchorRect.left + anchorRect.width / 2; 2581 const ay = anchorRect.top + anchorRect.height / 2; 2582 let bestId = ""; 2583 let bestDist = Number.POSITIVE_INFINITY; 2584 for (const [panelId] of chatPanelInstances.entries()) { 2585 const root = getPanelElement(panelId); 2586 if (!(root instanceof HTMLElement)) continue; 2587 if (root.classList.contains("hidden")) continue; 2588 const rect = root.getBoundingClientRect(); 2589 if (rect.width <= 1 || rect.height <= 1) continue; 2590 const cx = rect.left + rect.width / 2; 2591 const cy = rect.top + rect.height / 2; 2592 const dist = Math.hypot(cx - ax, cy - ay); 2593 if (dist < bestDist) { 2594 bestDist = dist; 2595 bestId = panelId; 2596 } 2597 } 2598 return bestId; 2599 } 2600 2601 function applyPluginPresetHint(panelDef) { 2602 if (!rackLayoutEnabled) return; 2603 const id = String(panelDef?.id || "").trim(); 2604 if (!id) return; 2605 if (isDocked(id)) return; 2606 const presetId = rackLayoutState?.presetId || ""; 2607 const hint = panelDef?.presetHints && typeof panelDef.presetHints === "object" ? panelDef.presetHints[presetId] : null; 2608 const place = hint && typeof hint === "object" ? String(hint.place || "") : ""; 2609 if (place === "docked.bottom") { 2610 dockPanel(id); 2611 return; 2612 } 2613 if (place === "main" || place === "right") { 2614 const rack = place === "main" ? ensureMainSideRack() : ensureRightRack(); 2615 const el = getPanelElement(id); 2616 if (rack && el) rack.appendChild(el); 2617 } 2618 } 2619 2620 function enableRackDnD() { 2621 if (!rackLayoutEnabled) return; 2622 const right = ensureRightRack(); 2623 const left = ensureWorkspaceLeftRack(); 2624 const rightWorkspace = ensureWorkspaceRightRack(); 2625 const side = ensureMainSideRack(); 2626 if (!right || !left || !rightWorkspace || !side) return; 2627 const pluginWidgets = ensurePluginRackWidgetsRack(); 2628 const racks = [left, rightWorkspace, side, right, pluginWidgets].filter((x) => x instanceof HTMLElement); 2629 2630 // Guard against double-install if initRackLayout is called more than once. 2631 if (appRoot?.dataset?.rackDnd === "1") return; 2632 if (appRoot) appRoot.dataset.rackDnd = "1"; 2633 2634 let draggingEl = null; 2635 let placeholderEl = null; 2636 let pointerId = null; 2637 let dragOffset = { x: 0, y: 0 }; 2638 let draggingPanelId = ""; 2639 let activeRack = null; 2640 let originRack = null; 2641 let originBefore = null; 2642 2643 const cancelDrag = () => { 2644 if (!draggingEl) return; 2645 cleanup(); 2646 enforceWorkspaceRules(); 2647 }; 2648 2649 const cleanup = () => { 2650 if (appRoot) appRoot.classList.remove("rackIsDragging"); 2651 if (draggingEl) { 2652 draggingEl.classList.remove("rackDragging"); 2653 draggingEl.style.position = ""; 2654 draggingEl.style.left = ""; 2655 draggingEl.style.top = ""; 2656 draggingEl.style.width = ""; 2657 draggingEl.style.zIndex = ""; 2658 draggingEl.style.pointerEvents = ""; 2659 } 2660 if (dockHotbarEl) dockHotbarEl.classList.remove("dockTarget"); 2661 if (placeholderEl && placeholderEl.parentElement) placeholderEl.parentElement.removeChild(placeholderEl); 2662 draggingEl = null; 2663 placeholderEl = null; 2664 pointerId = null; 2665 draggingPanelId = ""; 2666 activeRack = null; 2667 originRack = null; 2668 originBefore = null; 2669 }; 2670 2671 const siblings = (rack) => Array.from(rack.querySelectorAll(".rackPanel")).filter((el) => el !== draggingEl && el !== placeholderEl); 2672 2673 const insertPlaceholderAt = (rack, y) => { 2674 const items = siblings(rack); 2675 for (const el of items) { 2676 const r = el.getBoundingClientRect(); 2677 const mid = r.top + r.height / 2; 2678 if (y < mid) { 2679 rack.insertBefore(placeholderEl, el); 2680 return; 2681 } 2682 } 2683 rack.appendChild(placeholderEl); 2684 }; 2685 2686 const rackAtPoint = (x, y) => { 2687 for (const r of racks) { 2688 const rect = r.getBoundingClientRect(); 2689 if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return r; 2690 } 2691 return null; 2692 }; 2693 2694 const onMove = (e) => { 2695 if (!draggingEl || e.pointerId !== pointerId) return; 2696 e.preventDefault(); 2697 const x = e.clientX - dragOffset.x; 2698 const y = e.clientY - dragOffset.y; 2699 draggingEl.style.left = `${x}px`; 2700 draggingEl.style.top = `${y}px`; 2701 2702 const targetRack = rackAtPoint(e.clientX, e.clientY) || activeRack; 2703 if (targetRack && placeholderEl && placeholderEl.parentElement !== targetRack) { 2704 targetRack.appendChild(placeholderEl); 2705 } 2706 if (targetRack) { 2707 activeRack = targetRack; 2708 insertPlaceholderAt(targetRack, e.clientY); 2709 } 2710 2711 if (dockHotbarEl) { 2712 const nearBottom = e.clientY > window.innerHeight - 90; 2713 dockHotbarEl.classList.toggle("dockTarget", Boolean(nearBottom)); 2714 if (nearBottom) showHotbar(true); 2715 } 2716 }; 2717 2718 const onUp = (e) => { 2719 if (!draggingEl || e.pointerId !== pointerId) return; 2720 e.preventDefault(); 2721 const targetRack = placeholderEl?.parentElement || activeRack; 2722 if (targetRack && placeholderEl && placeholderEl.parentElement === targetRack) { 2723 const isWorkspaceSlot = targetRack.id === "workspaceLeftSlot" || targetRack.id === "workspaceRightSlot"; 2724 const isRightRackSlot = targetRack.id === "rightRack"; 2725 const isSideRackSlot = targetRack.id === "mainSideRack"; 2726 const isPluginRackWidgets = targetRack.id === "pluginRackWidgetsRack"; 2727 const isSkinnyRackSlot = isRightRackSlot || isSideRackSlot; 2728 const skinnyOk = panelIsSkinnyCapable(draggingPanelId); 2729 2730 if (isPluginRackWidgets && !panelIsHostableInPluginRack(draggingPanelId)) { 2731 toast("Can't place there", `${panelTitle(draggingPanelId)} can't be hosted in Plugin Rack.`); 2732 if (originRack) { 2733 if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(draggingEl, originBefore); 2734 else originRack.appendChild(draggingEl); 2735 } 2736 cleanup(); 2737 syncRackStateFromDom(); 2738 enforceWorkspaceRules(); 2739 return; 2740 } 2741 2742 // Only skinny-capable panels may live in skinny columns (side / right racks). 2743 if (isSkinnyRackSlot && !skinnyOk) { 2744 toast("Can't place there", `${panelTitle(draggingPanelId)} can't be placed in a skinny rack.`); 2745 if (originRack) { 2746 if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(draggingEl, originBefore); 2747 else originRack.appendChild(draggingEl); 2748 } 2749 cleanup(); 2750 syncRackStateFromDom(); 2751 enforceWorkspaceRules(); 2752 return; 2753 } 2754 2755 if (isWorkspaceSlot || isRightRackSlot) { 2756 const existing = Array.from(targetRack.querySelectorAll(":scope > .rackPanel")).find((x) => x !== draggingEl); 2757 targetRack.insertBefore(draggingEl, placeholderEl); 2758 // Swap if occupied: send the previous occupant back to the origin rack position. 2759 if (existing && originRack) { 2760 if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(existing, originBefore); 2761 else originRack.appendChild(existing); 2762 } 2763 } else { 2764 targetRack.insertBefore(draggingEl, placeholderEl); 2765 } 2766 if (isPluginRackWidgets) draggingEl.classList.add("pluginRackWidget"); 2767 } 2768 const shouldDock = Boolean(dockHotbarEl && e.clientY > window.innerHeight - 90); 2769 const dockId = draggingPanelId; 2770 cleanup(); 2771 if (shouldDock && dockId) dockPanel(dockId); 2772 syncRackStateFromDom(); 2773 enforceWorkspaceRules(); 2774 }; 2775 2776 // Use window-level listeners so cross-rack dragging stays responsive even when the cursor passes over gaps/resizers. 2777 window.addEventListener("pointermove", onMove); 2778 window.addEventListener("pointerup", onUp); 2779 window.addEventListener("pointercancel", onUp); 2780 // Extra safety: pointer events can fail to deliver pointerup if the mouse is released outside the window. 2781 window.addEventListener("blur", cancelDrag); 2782 window.addEventListener("mouseup", cancelDrag); 2783 window.addEventListener("touchend", cancelDrag, { passive: true }); 2784 document.addEventListener("visibilitychange", () => { 2785 if (document.visibilityState !== "visible") cancelDrag(); 2786 }); 2787 window.addEventListener("keydown", (e) => { 2788 if (e.key === "Escape") cancelDrag(); 2789 }); 2790 2791 const onDown = (e) => { 2792 const btn = e.target.closest?.("[data-rackdrag]"); 2793 if (!btn) return; 2794 const el = btn.closest?.(".rackPanel"); 2795 if (!(el instanceof HTMLElement)) return; 2796 if (el.classList.contains("hidden")) return; 2797 2798 e.preventDefault(); 2799 // If a drag somehow got stuck, start clean. 2800 cleanup(); 2801 if (appRoot) appRoot.classList.add("rackIsDragging"); 2802 draggingEl = el; 2803 draggingPanelId = String(el.dataset.panelId || ""); 2804 pointerId = e.pointerId; 2805 draggingEl.setPointerCapture?.(pointerId); 2806 2807 activeRack = el.parentElement; 2808 originRack = activeRack; 2809 originBefore = draggingEl.nextSibling; 2810 const rect = draggingEl.getBoundingClientRect(); 2811 dragOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; 2812 2813 placeholderEl = document.createElement("div"); 2814 placeholderEl.className = "rackPlaceholder"; 2815 placeholderEl.style.height = `${Math.max(40, Math.round(rect.height))}px`; 2816 2817 (activeRack || main).insertBefore(placeholderEl, draggingEl.nextSibling); 2818 2819 draggingEl.classList.add("rackDragging"); 2820 draggingEl.style.position = "fixed"; 2821 draggingEl.style.left = `${rect.left}px`; 2822 draggingEl.style.top = `${rect.top}px`; 2823 draggingEl.style.width = `${rect.width}px`; 2824 draggingEl.style.zIndex = "80"; 2825 draggingEl.style.pointerEvents = "none"; 2826 }; 2827 2828 // Delegate to the app root so panels can be dragged regardless of which rack they're currently in. 2829 (appRoot || document).addEventListener("pointerdown", onDown); 2830 } 2831 2832 function initRackLayout() { 2833 rackLayoutEnabled = readRackLayoutEnabled(); 2834 let hadState = false; 2835 try { 2836 hadState = Boolean(localStorage.getItem(RACK_LAYOUT_STATE_KEY)); 2837 } catch { 2838 hadState = false; 2839 } 2840 rackLayoutState = loadRackLayoutState(); 2841 // Normalize older preset ids in persisted state. 2842 rackLayoutState.presetId = resolvePresetKey(rackLayoutState.presetId); 2843 2844 if (toggleRackLayoutEl) { 2845 toggleRackLayoutEl.checked = rackLayoutEnabled; 2846 // Hide/disable the toggle while rack mode is forced on. 2847 if (FORCE_RACK_MODE) { 2848 toggleRackLayoutEl.checked = true; 2849 toggleRackLayoutEl.disabled = true; 2850 const row = toggleRackLayoutEl.closest?.("label"); 2851 if (row) row.classList.add("hidden"); 2852 const toggleBtn = document.getElementById("toggleRackLayoutBtn"); 2853 if (toggleBtn) toggleBtn.classList.add("hidden"); 2854 } else { 2855 toggleRackLayoutEl.onchange = () => { 2856 writeRackLayoutEnabled(Boolean(toggleRackLayoutEl.checked)); 2857 // Reload is the simplest safe path while the feature is in flux. 2858 location.reload(); 2859 }; 2860 } 2861 } 2862 2863 if (layoutPresetEl) { 2864 updateLayoutPresetOptions(); 2865 layoutPresetEl.value = resolvePresetKey(rackLayoutState.presetId || "onboardingDefault"); 2866 layoutPresetEl.disabled = !rackLayoutEnabled; 2867 layoutPresetEl.onchange = () => { 2868 if (!rackLayoutEnabled) return; 2869 const next = String(layoutPresetEl.value || "onboardingDefault"); 2870 applyPreset(next); 2871 }; 2872 } 2873 2874 if (!rackLayoutEnabled) { 2875 disableRackLayoutDom(); 2876 setSideCollapsed(false, { persist: false, updateControls: false }); 2877 setRightCollapsed(false, { persist: false, updateControls: false }); 2878 toggleSideRackEl && (toggleSideRackEl.disabled = true); 2879 toggleRightRackEl && (toggleRightRackEl.disabled = true); 2880 showSideRackBtn?.classList.add("hidden"); 2881 showRightRackBtn?.classList.add("hidden"); 2882 showHotbar(false); 2883 return; 2884 } 2885 2886 enableRackLayoutDom(); 2887 2888 // Ensure Plugin Rack exists and is accessible (defaults to hotbar unless explicitly placed). 2889 ensurePluginRackPanel(); 2890 const pluginRackPlaced = 2891 isDocked("pluginRack") || 2892 ["workspaceLeft", "workspaceRight", "side", "right"].some((k) => Array.isArray(rackLayoutState?.racks?.[k]) && rackLayoutState.racks[k].includes("pluginRack")); 2893 if (!pluginRackPlaced) { 2894 rackLayoutState.docked.bottom = Array.isArray(rackLayoutState?.docked?.bottom) ? rackLayoutState.docked.bottom : []; 2895 if (!rackLayoutState.docked.bottom.includes("pluginRack")) rackLayoutState.docked.bottom.push("pluginRack"); 2896 saveRackLayoutState(); 2897 } 2898 2899 // Side racks behave like summonable hotbars: hide/show without changing panel layout state. 2900 toggleSideRackEl && (toggleSideRackEl.disabled = false); 2901 toggleRightRackEl && (toggleRightRackEl.disabled = false); 2902 2903 if (showSideRackBtn) { 2904 showSideRackBtn.classList.remove("hidden"); 2905 showSideRackBtn.onclick = () => setSideCollapsed(false); 2906 } 2907 if (showRightRackBtn) { 2908 showRightRackBtn.classList.remove("hidden"); 2909 showRightRackBtn.onclick = () => setRightCollapsed(false); 2910 } 2911 2912 if (toggleSideRackEl) { 2913 toggleSideRackEl.onchange = () => { 2914 if (!rackLayoutEnabled) return; 2915 setSideCollapsed(!Boolean(toggleSideRackEl.checked)); 2916 }; 2917 } 2918 if (toggleRightRackEl) { 2919 toggleRightRackEl.onchange = () => { 2920 if (!rackLayoutEnabled) return; 2921 setRightCollapsed(!Boolean(toggleRightRackEl.checked)); 2922 }; 2923 } 2924 2925 setSideCollapsed(readBoolPref(RACK_SIDE_COLLAPSED_KEY, false), { persist: false }); 2926 setRightCollapsed(readBoolPref(RACK_RIGHT_COLLAPSED_KEY, false), { persist: false }); 2927 2928 applyRackStateToDom(); 2929 const hasOnboardingPlacement = 2930 (Array.isArray(rackLayoutState?.racks?.workspaceLeft) && rackLayoutState.racks.workspaceLeft.includes("onboarding")) || 2931 (Array.isArray(rackLayoutState?.racks?.workspaceRight) && rackLayoutState.racks.workspaceRight.includes("onboarding")) || 2932 (Array.isArray(rackLayoutState?.racks?.side) && rackLayoutState.racks.side.includes("onboarding")) || 2933 (Array.isArray(rackLayoutState?.racks?.right) && rackLayoutState.racks.right.includes("onboarding")) || 2934 (Array.isArray(rackLayoutState?.docked?.bottom) && rackLayoutState.docked.bottom.includes("onboarding")); 2935 if ((rackLayoutState?.presetId || "") === "onboardingDefault" && !hasOnboardingPlacement) { 2936 applyPreset("onboardingDefault"); 2937 } 2938 installPanelMinimizeButtons(); 2939 enableRackDnD(); 2940 installWorkspaceInteractions(); 2941 enforceWorkspaceRules(); 2942 renderProfilePanel(); 2943 2944 // Hotbar interactions 2945 if (dockHotbarEl) { 2946 dockHotbarEl.onmouseenter = () => showHotbar(true); 2947 dockHotbarEl.onmouseleave = () => showHotbar(false); 2948 // Docked items must be restored via drag-and-drop (click does nothing), but the "+" orb is clickable. 2949 dockHotbarEl.onclick = (e) => { 2950 if (dockHotbarEl.dataset.dragging === "1") return; 2951 const plus = e.target.closest?.("[data-hotbarplus]"); 2952 if (!plus) return; 2953 if (hotbarPlusMenuEl) closeHotbarPlusMenu(); 2954 else openHotbarPlusMenu(plus); 2955 }; 2956 } 2957 2958 // Close the "+" menu when clicking elsewhere. 2959 if (appRoot && appRoot.dataset.hotbarPlusClose !== "1") { 2960 appRoot.dataset.hotbarPlusClose = "1"; 2961 document.addEventListener("pointerdown", (e) => { 2962 if (!hotbarPlusMenuEl && !pluginRackAddMenuEl && !workspaceAddMenuEl) return; 2963 const t = e.target; 2964 if (t) { 2965 if (hotbarPlusMenuEl && hotbarPlusMenuEl.contains(t)) return; 2966 if (pluginRackAddMenuEl && pluginRackAddMenuEl.contains(t)) return; 2967 if (workspaceAddMenuEl && workspaceAddMenuEl.contains(t)) return; 2968 if (dockHotbarEl && dockHotbarEl.contains(t)) return; 2969 if (t.closest?.("[data-workspaceadd]")) return; 2970 } 2971 closeHotbarPlusMenu(); 2972 closePluginRackAddMenu(); 2973 closeWorkspaceAddMenu(); 2974 }); 2975 } 2976 2977 // Drag orbs back into the rack to restore (MVP: restore to end of rack). 2978 if (dockHotbarEl) { 2979 let orbDragId = ""; 2980 let orbPointer = null; 2981 let orbStart = null; 2982 let orbMoved = false; 2983 let orbPlaceholder = null; 2984 let orbActiveRack = null; 2985 2986 const lockHotbarVisible = (lock) => { 2987 dockHotbarEl.dataset.lockVisible = lock ? "1" : "0"; 2988 dockHotbarEl.dataset.dragging = lock ? "1" : "0"; 2989 // While dragging an orb, keep both workspace slots visible as drop targets. 2990 if (appRoot) { 2991 if (lock) { 2992 appRoot.classList.add("rackIsDragging"); 2993 appRoot.dataset.orbDragging = "1"; 2994 } else if (appRoot.dataset.orbDragging === "1") { 2995 delete appRoot.dataset.orbDragging; 2996 appRoot.classList.remove("rackIsDragging"); 2997 } 2998 } 2999 if (lock) showHotbar(true); 3000 }; 3001 3002 const resolveOrbDropRack = (panelId, rackEl) => { 3003 const id = String(panelId || "").trim(); 3004 if (!id) return rackEl; 3005 if (rackEl && rackEl.id === "pluginRackWidgetsRack") { 3006 if (panelIsHostableInPluginRack(id)) return rackEl; 3007 const left = ensureWorkspaceLeftRack(); 3008 const right = ensureWorkspaceRightRack(); 3009 const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; 3010 const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; 3011 return leftEmpty ? left : rightEmpty ? right : left; 3012 } 3013 // Skinny racks (side/right) only allow skinny-capable panels. 3014 if (rackEl && (rackEl.id === "mainSideRack" || rackEl.id === "rightRack")) { 3015 if (panelIsSkinnyCapable(id)) return rackEl; 3016 const left = ensureWorkspaceLeftRack(); 3017 const right = ensureWorkspaceRightRack(); 3018 const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; 3019 const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; 3020 return leftEmpty ? left : rightEmpty ? right : left; 3021 } 3022 if (panelRole(id) !== "primary") return rackEl; 3023 const isWorkspaceSlot = rackEl && (rackEl.id === "workspaceLeftSlot" || rackEl.id === "workspaceRightSlot"); 3024 if (isWorkspaceSlot) return rackEl; 3025 const left = ensureWorkspaceLeftRack(); 3026 const right = ensureWorkspaceRightRack(); 3027 const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; 3028 const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; 3029 return leftEmpty ? left : rightEmpty ? right : left; 3030 }; 3031 3032 const insertOrbPlaceholderAt = (rack, y) => { 3033 if (!(rack instanceof HTMLElement) || !(orbPlaceholder instanceof HTMLElement)) return; 3034 const items = Array.from(rack.querySelectorAll(":scope > .rackPanel")).filter((el) => el !== orbPlaceholder); 3035 for (const el of items) { 3036 const r = el.getBoundingClientRect(); 3037 const mid = r.top + r.height / 2; 3038 if (y < mid) { 3039 rack.insertBefore(orbPlaceholder, el); 3040 return; 3041 } 3042 } 3043 rack.appendChild(orbPlaceholder); 3044 }; 3045 3046 const orbRacks = () => { 3047 const leftRack = ensureWorkspaceLeftRack(); 3048 const rightWorkspaceRack = ensureWorkspaceRightRack(); 3049 const sideRack = ensureMainSideRack(); 3050 const rightRack = ensureRightRack(); 3051 const pluginWidgetsRack = ensurePluginRackWidgetsRack(); 3052 return [leftRack, rightWorkspaceRack, sideRack, rightRack, pluginWidgetsRack].filter((x) => x instanceof HTMLElement); 3053 }; 3054 3055 const rackAtPoint = (x, y) => { 3056 for (const r of orbRacks()) { 3057 const rect = r.getBoundingClientRect(); 3058 if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return r; 3059 } 3060 return null; 3061 }; 3062 3063 const dropOrbIntoRack = (panelId, targetRack, beforeEl) => { 3064 const id = String(panelId || "").trim(); 3065 if (!id) return; 3066 const rack = resolveOrbDropRack(id, targetRack); 3067 if (!(rack instanceof HTMLElement)) return; 3068 const panelEl = getPanelElement(id); 3069 if (!panelEl) return; 3070 3071 // Restoring into a collapsed rack should uncollapse it (hotbar is a summonable launcher). 3072 if (rack.id === "mainSideRack") setSideCollapsed(false); 3073 if (rack.id === "rightRack") setRightCollapsed(false); 3074 3075 undockPanel(id); 3076 3077 const isWorkspaceSlot = rack.id === "workspaceLeftSlot" || rack.id === "workspaceRightSlot"; 3078 const isRightRackSlot = rack.id === "rightRack"; 3079 if (isWorkspaceSlot) { 3080 const existing = rack.querySelector?.(":scope > .rackPanel:not(.hidden)"); 3081 if (existing instanceof HTMLElement && existing !== panelEl) { 3082 const existingId = String(existing.dataset.panelId || "").trim(); 3083 if (existingId) dockPanel(existingId); 3084 } 3085 } 3086 if (isRightRackSlot) { 3087 const existing = rack.querySelector?.(":scope > .rackPanel:not(.hidden)"); 3088 if (existing instanceof HTMLElement && existing !== panelEl) { 3089 const existingId = String(existing.dataset.panelId || "").trim(); 3090 if (existingId) dockPanel(existingId); 3091 } 3092 } 3093 3094 const insertBefore = 3095 beforeEl instanceof HTMLElement && beforeEl.parentElement === rack && beforeEl.classList.contains("rackPanel") 3096 ? beforeEl 3097 : null; 3098 if (panelEl.parentElement !== rack) { 3099 if (insertBefore) rack.insertBefore(panelEl, insertBefore); 3100 else rack.appendChild(panelEl); 3101 } 3102 if (rack.id === "pluginRackWidgetsRack") panelEl.classList.add("pluginRackWidget"); 3103 rememberPanelLastRack(id, rack.id); 3104 saveRackLayoutState(); 3105 syncRackStateFromDom(); 3106 enforceWorkspaceRules(); 3107 }; 3108 3109 dockHotbarEl.addEventListener("pointerdown", (e) => { 3110 const orb = e.target.closest?.("[data-undock]"); 3111 if (!orb) return; 3112 orbDragId = String(orb.getAttribute("data-undock") || ""); 3113 if (!orbDragId) return; 3114 orbPointer = e.pointerId; 3115 orbStart = { x: e.clientX, y: e.clientY }; 3116 orbMoved = false; 3117 orbActiveRack = null; 3118 orb.classList.add("dragging"); 3119 orb.setPointerCapture?.(orbPointer); 3120 lockHotbarVisible(true); 3121 e.preventDefault(); 3122 3123 // Placeholder shows drop position while dragging. 3124 orbPlaceholder = document.createElement("div"); 3125 orbPlaceholder.className = "rackPlaceholder"; 3126 orbPlaceholder.style.height = "52px"; 3127 }); 3128 window.addEventListener("pointermove", (e) => { 3129 if (!orbDragId || e.pointerId !== orbPointer) return; 3130 if (!orbStart) return; 3131 const dx = Math.abs(e.clientX - orbStart.x); 3132 const dy = Math.abs(e.clientY - orbStart.y); 3133 if (dx + dy > 6) orbMoved = true; 3134 3135 if (orbMoved && orbPlaceholder) { 3136 const r = rackAtPoint(e.clientX, e.clientY) || orbActiveRack; 3137 if (r && orbPlaceholder.parentElement !== r) r.appendChild(orbPlaceholder); 3138 if (r) { 3139 orbActiveRack = r; 3140 insertOrbPlaceholderAt(r, e.clientY); 3141 } 3142 } 3143 }); 3144 dockHotbarEl.addEventListener("pointerup", (e) => { 3145 if (!orbDragId || e.pointerId !== orbPointer) return; 3146 const orb = dockHotbarEl.querySelector(`[data-undock="${CSS.escape(orbDragId)}"]`); 3147 if (orb) orb.classList.remove("dragging"); 3148 const targetRack = orbMoved ? (rackAtPoint(e.clientX, e.clientY) || orbActiveRack) : null; 3149 const beforeEl = 3150 orbMoved && orbPlaceholder && targetRack instanceof HTMLElement && orbPlaceholder.parentElement === targetRack 3151 ? orbPlaceholder.nextSibling 3152 : null; 3153 if (orbMoved && targetRack) dropOrbIntoRack(orbDragId, targetRack, beforeEl); 3154 orbDragId = ""; 3155 orbPointer = null; 3156 orbStart = null; 3157 orbMoved = false; 3158 orbActiveRack = null; 3159 if (orbPlaceholder && orbPlaceholder.parentElement) orbPlaceholder.parentElement.removeChild(orbPlaceholder); 3160 orbPlaceholder = null; 3161 lockHotbarVisible(false); 3162 }); 3163 dockHotbarEl.addEventListener("pointercancel", () => { 3164 orbDragId = ""; 3165 orbPointer = null; 3166 orbStart = null; 3167 orbMoved = false; 3168 orbActiveRack = null; 3169 if (orbPlaceholder && orbPlaceholder.parentElement) orbPlaceholder.parentElement.removeChild(orbPlaceholder); 3170 orbPlaceholder = null; 3171 lockHotbarVisible(false); 3172 dockHotbarEl.querySelectorAll(".dockOrb.dragging").forEach((x) => x.classList.remove("dragging")); 3173 }); 3174 } 3175 3176 // Reveal hotbar when cursor is near bottom if there are docked items. 3177 window.addEventListener("mousemove", (e) => { 3178 if (!dockHotbarEl) return; 3179 if (!rackLayoutEnabled) return; 3180 const nearBottom = e.clientY > window.innerHeight - 80; 3181 showHotbar(Boolean(nearBottom)); 3182 }); 3183 3184 // First enable: seed state from the selected preset so users immediately get a sensible layout. 3185 if (!hadState) { 3186 const preset = resolvePresetKey(rackLayoutState.presetId || (layoutPresetEl ? String(layoutPresetEl.value || "") : "") || "onboardingDefault"); 3187 applyPreset(preset); 3188 } 3189 3190 applyDockState(); 3191 enforceWorkspaceRules(); 3192 } 3193 let activeProfileUsername = ""; 3194 let activeProfile = null; 3195 let lastRequestedProfileUsername = ""; 3196 let isEditingProfile = false; 3197 let replyToMessage = null; 3198 let chatResizeDragging = false; 3199 let chatResizeStartX = 0; 3200 let chatResizeStartWidth = 0; 3201 const CHAT_WIDTH_KEY = "bzl_chatWidth"; 3202 const CHAT_WIDTH_DEFAULT = 640; 3203 let sidebarResizeDragging = false; 3204 let sidebarResizeStartX = 0; 3205 let sidebarResizeStartWidth = 0; 3206 const SIDEBAR_WIDTH_KEY = "bzl_sidebarWidth"; 3207 const SIDEBAR_WIDTH_DEFAULT = 320; 3208 let modResizeDragging = false; 3209 let modResizeStartX = 0; 3210 let modResizeStartWidth = 0; 3211 const MOD_WIDTH_KEY = "bzl_modWidth"; 3212 const MOD_WIDTH_DEFAULT = 360; 3213 let peopleResizeDragging = false; 3214 let peopleResizeStartX = 0; 3215 let peopleResizeStartWidth = 0; 3216 const PEOPLE_WIDTH_KEY = "bzl_peopleWidth"; 3217 const PEOPLE_WIDTH_DEFAULT = 360; 3218 let editContext = null; 3219 let mentionState = { open: false, query: "", selected: 0, items: [], anchorRect: null }; 3220 3221 const STAY_CONNECTED_KEY = "bzl_stayConnected"; 3222 function readStayConnectedPref() { 3223 return readBoolPref(STAY_CONNECTED_KEY, false); 3224 } 3225 function writeStayConnectedPref(on) { 3226 writeBoolPref(STAY_CONNECTED_KEY, Boolean(on)); 3227 } 3228 const ENABLE_HINTS_KEY = "bzl_enableHints"; 3229 const CHAT_ENTER_MODE_KEY = "bzl_chatEnterMode"; // "ctrlEnter" | "enter" 3230 function readHintsEnabledPref() { 3231 const raw = localStorage.getItem(ENABLE_HINTS_KEY); 3232 if (raw == null) return true; 3233 return raw !== "0"; 3234 } 3235 function writeHintsEnabledPref(on) { 3236 const enabled = Boolean(on); 3237 localStorage.setItem(ENABLE_HINTS_KEY, enabled ? "1" : "0"); 3238 appRoot?.classList.toggle("hintsEnabled", enabled); 3239 } 3240 3241 function readChatEnterModePref() { 3242 const raw = readStringPref(CHAT_ENTER_MODE_KEY, "ctrlEnter"); 3243 return raw === "enter" ? "enter" : "ctrlEnter"; 3244 } 3245 3246 function writeChatEnterModePref(mode) { 3247 const next = String(mode || "").trim().toLowerCase(); 3248 writeStringPref(CHAT_ENTER_MODE_KEY, next === "enter" ? "enter" : "ctrlEnter"); 3249 } 3250 3251 let instanceBranding = { title: "Bzl", subtitle: "Ephemeral hives + chat", allowMemberPermanentPosts: false, appearance: {} }; 3252 let onboardingState = { 3253 enabled: true, 3254 rulesVersion: 1, 3255 requireAcceptance: false, 3256 blockReadUntilAccepted: false, 3257 acceptedRulesVersion: 0, 3258 acceptedAt: 0, 3259 tutorialVersion: 1, 3260 tutorialCompletedVersion: 0, 3261 selectedRoleIds: [], 3262 needsAcceptance: false, 3263 }; 3264 let serverInfo = null; 3265 let serverHealth = null; 3266 let serverInfoStatus = { loading: false, at: 0, error: "" }; 3267 let pluginAdminStatus = ""; 3268 let pluginAdminBusy = false; 3269 const pluginEnableInFlight = new Set(); 3270 3271 const THEME_PRESETS = [ 3272 { 3273 id: "bzl_original", 3274 name: "Bzl (Original)", 3275 appearance: { 3276 bg: "#060611", 3277 panel: "#0c0c18", 3278 text: "#f6f0ff", 3279 accent: "#ff3ea5", 3280 accent2: "#b84bff", 3281 good: "#3ddc97", 3282 bad: "#ff4d8a", 3283 fontBody: "system", 3284 fontMono: "mono", 3285 mutedPct: 65, 3286 linePct: 10, 3287 panel2Pct: 2 3288 } 3289 }, 3290 { 3291 id: "midnight_cyan", 3292 name: "Midnight Cyan", 3293 appearance: { 3294 bg: "#060a12", 3295 panel: "#0a1220", 3296 text: "#eaf4ff", 3297 accent: "#2bf5d6", 3298 accent2: "#4aa0ff", 3299 good: "#2bf5d6", 3300 bad: "#ff4d8a", 3301 fontBody: "system", 3302 fontMono: "mono", 3303 mutedPct: 64, 3304 linePct: 10, 3305 panel2Pct: 2 3306 } 3307 }, 3308 { 3309 id: "warm_amber", 3310 name: "Warm Amber", 3311 appearance: { 3312 bg: "#0b0706", 3313 panel: "#17100e", 3314 text: "#fff2ea", 3315 accent: "#ffb020", 3316 accent2: "#ff3ea5", 3317 good: "#3ddc97", 3318 bad: "#ff4d8a", 3319 fontBody: "serif", 3320 fontMono: "mono", 3321 mutedPct: 66, 3322 linePct: 11, 3323 panel2Pct: 3 3324 } 3325 }, 3326 { 3327 id: "slate_violet", 3328 name: "Slate Violet", 3329 appearance: { 3330 bg: "#080a10", 3331 panel: "#101522", 3332 text: "#eef0ff", 3333 accent: "#9b8cff", 3334 accent2: "#ff3ea5", 3335 good: "#3ddc97", 3336 bad: "#ff4d8a", 3337 fontBody: "system", 3338 fontMono: "mono", 3339 mutedPct: 62, 3340 linePct: 9, 3341 panel2Pct: 2 3342 } 3343 }, 3344 { 3345 id: "terminal_green", 3346 name: "Terminal Green", 3347 appearance: { 3348 bg: "#040805", 3349 panel: "#070f08", 3350 text: "#d7ffe6", 3351 accent: "#2bff88", 3352 accent2: "#20d3ff", 3353 good: "#2bff88", 3354 bad: "#ff4d8a", 3355 fontBody: "mono", 3356 fontMono: "mono", 3357 mutedPct: 58, 3358 linePct: 12, 3359 panel2Pct: 2 3360 } 3361 }, 3362 { 3363 id: "high_contrast", 3364 name: "High Contrast", 3365 appearance: { 3366 bg: "#000000", 3367 panel: "#0a0a0a", 3368 text: "#ffffff", 3369 accent: "#ffd300", 3370 accent2: "#00d3ff", 3371 good: "#00ff85", 3372 bad: "#ff2d55", 3373 fontBody: "system", 3374 fontMono: "mono", 3375 mutedPct: 70, 3376 linePct: 16, 3377 panel2Pct: 3 3378 } 3379 }, 3380 { 3381 id: "lavender_mist", 3382 name: "Lavender Mist", 3383 appearance: { 3384 bg: "#070611", 3385 panel: "#120c1b", 3386 text: "#f7f3ff", 3387 accent: "#c9a3ff", 3388 accent2: "#ff79c6", 3389 good: "#3ddc97", 3390 bad: "#ff4d8a", 3391 fontBody: "system", 3392 fontMono: "mono", 3393 mutedPct: 68, 3394 linePct: 10, 3395 panel2Pct: 3 3396 } 3397 } 3398 ]; 3399 3400 const SFX = { 3401 open: "/assets/sfx/Select_B7.wav", 3402 post: "/assets/sfx/Select_B7.wav", 3403 ping: "/assets/sfx/Select_C3.wav", 3404 }; 3405 const sfxCache = new Map(); 3406 let pendingOpenSfx = true; 3407 let lastSfxAt = 0; 3408 3409 function getSfx(url) { 3410 const key = String(url || ""); 3411 if (!key) return null; 3412 if (sfxCache.has(key)) return sfxCache.get(key); 3413 const a = new Audio(key); 3414 a.preload = "auto"; 3415 sfxCache.set(key, a); 3416 return a; 3417 } 3418 3419 async function playSfx(name, { volume = 0.32 } = {}) { 3420 const url = SFX[name]; 3421 if (!url) return false; 3422 const now = Date.now(); 3423 if (now - lastSfxAt < 120) return false; 3424 lastSfxAt = now; 3425 3426 const a = getSfx(url); 3427 if (!a) return false; 3428 try { 3429 a.pause(); 3430 a.currentTime = 0; 3431 a.volume = Math.max(0, Math.min(1, Number(volume) || 0.32)); 3432 await a.play(); 3433 return true; 3434 } catch { 3435 return false; 3436 } 3437 } 3438 3439 function normalizeInstanceBranding(raw) { 3440 const title = String(raw?.title || "").replace(/\s+/g, " ").trim().slice(0, 32); 3441 const subtitle = String(raw?.subtitle || "").replace(/\s+/g, " ").trim().slice(0, 80); 3442 const allowMemberPermanentPosts = Boolean(raw?.allowMemberPermanentPosts); 3443 const appearanceRaw = raw?.appearance && typeof raw.appearance === "object" ? raw.appearance : {}; 3444 const bg = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.bg || "")) ? String(appearanceRaw.bg).toLowerCase() : "#060611"; 3445 const panel = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.panel || "")) ? String(appearanceRaw.panel).toLowerCase() : "#0c0c18"; 3446 const text = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.text || "")) ? String(appearanceRaw.text).toLowerCase() : "#f6f0ff"; 3447 const accent = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.accent || "")) ? String(appearanceRaw.accent).toLowerCase() : "#ff3ea5"; 3448 const accent2 = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.accent2 || "")) ? String(appearanceRaw.accent2).toLowerCase() : "#b84bff"; 3449 const good = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.good || "")) ? String(appearanceRaw.good).toLowerCase() : "#3ddc97"; 3450 const bad = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.bad || "")) ? String(appearanceRaw.bad).toLowerCase() : "#ff4d8a"; 3451 const fontBody = ["system", "serif", "mono"].includes(String(appearanceRaw.fontBody || "")) ? String(appearanceRaw.fontBody) : "system"; 3452 const fontMono = ["mono", "system"].includes(String(appearanceRaw.fontMono || "")) ? String(appearanceRaw.fontMono) : "mono"; 3453 const clampPct = (n, fallback) => { 3454 const v = Math.floor(Number(n)); 3455 if (!Number.isFinite(v)) return fallback; 3456 return Math.max(0, Math.min(100, v)); 3457 }; 3458 const mutedPct = clampPct(appearanceRaw.mutedPct, 65); 3459 const linePct = clampPct(appearanceRaw.linePct, 10); 3460 const panel2Pct = clampPct(appearanceRaw.panel2Pct, 2); 3461 const onboardingRaw = raw?.onboarding && typeof raw.onboarding === "object" ? raw.onboarding : {}; 3462 const aboutRaw = onboardingRaw.about && typeof onboardingRaw.about === "object" ? onboardingRaw.about : {}; 3463 const rulesRaw = onboardingRaw.rules && typeof onboardingRaw.rules === "object" ? onboardingRaw.rules : {}; 3464 const roleSelectRaw = onboardingRaw.roleSelect && typeof onboardingRaw.roleSelect === "object" ? onboardingRaw.roleSelect : {}; 3465 const tutorialRaw = onboardingRaw.tutorial && typeof onboardingRaw.tutorial === "object" ? onboardingRaw.tutorial : {}; 3466 const ruleItems = Array.isArray(rulesRaw.items) 3467 ? rulesRaw.items 3468 .map((r, idx) => ({ 3469 id: String(r?.id || `r${idx + 1}`).trim().slice(0, 40), 3470 order: Number.isFinite(Number(r?.order)) ? Math.max(1, Math.floor(Number(r.order))) : idx + 1, 3471 name: String(r?.name || "").trim().slice(0, 60), 3472 shortDescription: String(r?.shortDescription || "").trim().slice(0, 180), 3473 description: typeof r?.description === "string" ? r.description : "", 3474 severity: ["info", "warn", "critical"].includes(String(r?.severity || "").trim().toLowerCase()) 3475 ? String(r.severity).trim().toLowerCase() 3476 : "info", 3477 })) 3478 .filter((r) => r.id) 3479 .slice(0, 200) 3480 .sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || String(a.id || "").localeCompare(String(b.id || ""))) 3481 : []; 3482 return { 3483 title: title || "Bzl", 3484 subtitle: subtitle || "Ephemeral hives + chat", 3485 allowMemberPermanentPosts, 3486 onboarding: { 3487 enabled: Object.prototype.hasOwnProperty.call(onboardingRaw, "enabled") ? Boolean(onboardingRaw.enabled) : true, 3488 about: { 3489 content: typeof aboutRaw.content === "string" ? aboutRaw.content : "", 3490 updatedAt: Number(aboutRaw.updatedAt || 0) || 0, 3491 updatedBy: String(aboutRaw.updatedBy || "").trim().toLowerCase(), 3492 }, 3493 rules: { 3494 version: Math.max(1, Math.floor(Number(rulesRaw.version || 1))), 3495 requireAcceptance: Boolean(rulesRaw.requireAcceptance), 3496 blockReadUntilAccepted: Boolean(rulesRaw.blockReadUntilAccepted), 3497 items: ruleItems, 3498 }, 3499 roleSelect: { 3500 enabled: Object.prototype.hasOwnProperty.call(roleSelectRaw, "enabled") ? Boolean(roleSelectRaw.enabled) : true, 3501 selfAssignableRoleIds: Array.isArray(roleSelectRaw.selfAssignableRoleIds) 3502 ? roleSelectRaw.selfAssignableRoleIds.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean).slice(0, 64) 3503 : [], 3504 }, 3505 tutorial: { 3506 enabled: Object.prototype.hasOwnProperty.call(tutorialRaw, "enabled") ? Boolean(tutorialRaw.enabled) : true, 3507 version: Math.max(1, Math.floor(Number(tutorialRaw.version || 1))), 3508 }, 3509 }, 3510 appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct }, 3511 }; 3512 } 3513 3514 function normalizeOnboardingState(raw) { 3515 const src = raw && typeof raw === "object" ? raw : {}; 3516 return { 3517 enabled: Object.prototype.hasOwnProperty.call(src, "enabled") ? Boolean(src.enabled) : Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.enabled), 3518 rulesVersion: Math.max(1, Math.floor(Number(src.rulesVersion || normalizeInstanceBranding(instanceBranding).onboarding?.rules?.version || 1))), 3519 requireAcceptance: Object.prototype.hasOwnProperty.call(src, "requireAcceptance") 3520 ? Boolean(src.requireAcceptance) 3521 : Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.rules?.requireAcceptance), 3522 blockReadUntilAccepted: Object.prototype.hasOwnProperty.call(src, "blockReadUntilAccepted") 3523 ? Boolean(src.blockReadUntilAccepted) 3524 : Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.rules?.blockReadUntilAccepted), 3525 acceptedRulesVersion: Math.max(0, Math.floor(Number(src.acceptedRulesVersion || 0))), 3526 acceptedAt: Number(src.acceptedAt || 0) || 0, 3527 tutorialVersion: Math.max(1, Math.floor(Number(src.tutorialVersion || normalizeInstanceBranding(instanceBranding).onboarding?.tutorial?.version || 1))), 3528 tutorialCompletedVersion: Math.max(0, Math.floor(Number(src.tutorialCompletedVersion || 0))), 3529 selectedRoleIds: Array.isArray(src.selectedRoleIds) ? src.selectedRoleIds.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean).slice(0, 64) : [], 3530 needsAcceptance: Boolean(src.needsAcceptance), 3531 }; 3532 } 3533 3534 function applyInstanceAppearance(appearanceOverride = null) { 3535 const b = normalizeInstanceBranding(appearanceOverride ? { ...instanceBranding, appearance: appearanceOverride } : instanceBranding); 3536 const a = b.appearance || {}; 3537 const fontStacks = { 3538 system: 3539 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"', 3540 serif: 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif', 3541 mono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', 3542 }; 3543 const fontBodyStack = fontStacks[a.fontBody] || fontStacks.system; 3544 const fontMonoStack = fontStacks[a.fontMono] || fontStacks.mono; 3545 document.documentElement.style.setProperty("--bg", a.bg || "#060611"); 3546 document.documentElement.style.setProperty("--panel", a.panel || "#0c0c18"); 3547 document.documentElement.style.setProperty("--text", a.text || "#f6f0ff"); 3548 document.documentElement.style.setProperty("--accent", a.accent || "#ff3ea5"); 3549 document.documentElement.style.setProperty("--accent2", a.accent2 || "#b84bff"); 3550 document.documentElement.style.setProperty("--good", a.good || "#3ddc97"); 3551 document.documentElement.style.setProperty("--bad", a.bad || "#ff4d8a"); 3552 document.documentElement.style.setProperty("--font-body", fontBodyStack); 3553 document.documentElement.style.setProperty("--font-mono", fontMonoStack); 3554 document.documentElement.style.setProperty("--muted-pct", String(Number(a.mutedPct ?? 65))); 3555 document.documentElement.style.setProperty("--line-pct", String(Number(a.linePct ?? 10))); 3556 document.documentElement.style.setProperty("--panel2-pct", String(Number(a.panel2Pct ?? 2))); 3557 } 3558 3559 function renderInstanceBranding() { 3560 const b = normalizeInstanceBranding(instanceBranding); 3561 if (instanceTitleEl) instanceTitleEl.textContent = b.title; 3562 if (instanceSubtitleEl) instanceSubtitleEl.textContent = b.subtitle; 3563 } 3564 3565 function formatLocalTime(ts) { 3566 const n = Number(ts || 0); 3567 if (!n) return ""; 3568 try { 3569 return new Date(n).toLocaleString(); 3570 } catch { 3571 return ""; 3572 } 3573 } 3574 3575 async function requestServerInfo() { 3576 if (serverInfoStatus.loading) return; 3577 serverInfoStatus = { loading: true, at: Date.now(), error: "" }; 3578 renderModPanel(); 3579 try { 3580 const [infoRes, healthRes] = await Promise.all([ 3581 fetch("/api/info", { cache: "no-store" }), 3582 fetch("/api/health", { cache: "no-store" }) 3583 ]); 3584 if (!infoRes.ok) throw new Error(`Failed to load /api/info (${infoRes.status})`); 3585 if (!healthRes.ok) throw new Error(`Failed to load /api/health (${healthRes.status})`); 3586 serverInfo = await infoRes.json(); 3587 serverHealth = await healthRes.json(); 3588 serverInfoStatus = { loading: false, at: Date.now(), error: "" }; 3589 renderModPanel(); 3590 } catch (e) { 3591 serverInfoStatus = { loading: false, at: Date.now(), error: e?.message || "Failed to load server info." }; 3592 renderModPanel(); 3593 } 3594 } 3595 3596 function normalizeDmThread(raw) { 3597 if (!raw || typeof raw !== "object") return null; 3598 const id = String(raw.id || "").trim(); 3599 const other = String(raw.other || "").trim().toLowerCase(); 3600 const status = String(raw.status || "").trim(); 3601 if (!id || !other) return null; 3602 return { 3603 id, 3604 other, 3605 status: status || "unknown", 3606 requestedBy: String(raw.requestedBy || ""), 3607 pendingFor: String(raw.pendingFor || ""), 3608 createdAt: Number(raw.createdAt || 0), 3609 updatedAt: Number(raw.updatedAt || 0), 3610 lastMessageAt: Number(raw.lastMessageAt || 0), 3611 }; 3612 } 3613 3614 function normalizeDmMessage(raw) { 3615 if (!raw || typeof raw !== "object") return null; 3616 const id = String(raw.id || "").trim(); 3617 if (!id) return null; 3618 return { 3619 id, 3620 fromUser: String(raw.fromUser || raw.from || "").trim().toLowerCase(), 3621 asMod: Boolean(raw.asMod) || String(raw.fromUser || raw.from || "").trim().toLowerCase() === "mod", 3622 createdAt: Number(raw.createdAt || 0), 3623 text: typeof raw.text === "string" ? raw.text : "", 3624 html: typeof raw.html === "string" ? raw.html : "", 3625 }; 3626 } 3627 3628 function dmActivityAt(thread) { 3629 if (!thread) return 0; 3630 return Math.max(Number(thread.lastMessageAt || 0), Number(thread.updatedAt || 0), Number(thread.createdAt || 0)); 3631 } 3632 3633 function shortTimeAgo(ts) { 3634 const t = Number(ts || 0); 3635 if (!Number.isFinite(t) || t <= 0) return ""; 3636 const deltaMs = Math.max(0, Date.now() - t); 3637 const mins = Math.floor(deltaMs / 60000); 3638 if (mins < 1) return "now"; 3639 if (mins < 60) return `${mins}m`; 3640 const hours = Math.floor(mins / 60); 3641 if (hours < 24) return `${hours}h`; 3642 const days = Math.floor(hours / 24); 3643 return `${days}d`; 3644 } 3645 3646 function postChatActivityAt(postId, post) { 3647 const id = String(postId || "").trim(); 3648 const list = id ? chatByPost.get(id) : null; 3649 const lastChatAt = 3650 Array.isArray(list) && list.length 3651 ? Math.max( 3652 ...list.map((m) => Math.max(Number(m?.createdAt || 0), Number(m?.editedAt || 0), Number(m?.deletedAt || 0))) 3653 ) 3654 : 0; 3655 return Math.max(lastChatAt, Number(post?.createdAt || 0), Number(post?.updatedAt || 0)); 3656 } 3657 3658 function pushRecentUnique(list, id, limit = CHAT_RECENTS_LIMIT) { 3659 const value = String(id || "").trim(); 3660 if (!value) return list; 3661 const next = [value, ...list.filter((x) => x !== value)]; 3662 if (next.length > limit) next.length = limit; 3663 return next; 3664 } 3665 3666 function touchRecentHiveChat(postId) { 3667 const id = String(postId || "").trim(); 3668 if (!id) return; 3669 recentHiveChatIds = pushRecentUnique(recentHiveChatIds, id); 3670 } 3671 3672 function touchRecentDmChat(threadId) { 3673 const id = String(threadId || "").trim(); 3674 if (!id) return; 3675 recentDmChatThreadIds = pushRecentUnique(recentDmChatThreadIds, id); 3676 } 3677 3678 function activeDmThreadsSorted() { 3679 return dmThreads 3680 .filter((t) => t && String(t.status || "") === "active") 3681 .sort((a, b) => dmActivityAt(b) - dmActivityAt(a)); 3682 } 3683 3684 function blurFocusedChatComposer() { 3685 const activeEl = document.activeElement; 3686 if (!(activeEl instanceof HTMLElement)) return; 3687 if (activeEl === chatEditor || activeEl.closest?.(".chatEditor")) activeEl.blur(); 3688 } 3689 3690 function openChatContextValue(rawValue, opts = null) { 3691 const raw = String(rawValue || "").trim(); 3692 if (!raw) return false; 3693 const options = opts && typeof opts === "object" ? opts : {}; 3694 const preserveFocus = Boolean(options.preserveFocus); 3695 if (raw.startsWith("dm:")) { 3696 const id = raw.slice(3); 3697 if (!id) return false; 3698 openDmThread(id, { preserveFocus }); 3699 return true; 3700 } 3701 if (raw.startsWith("post:")) { 3702 const id = raw.slice(5); 3703 if (!id) return false; 3704 openChat(id, { preserveFocus }); 3705 return true; 3706 } 3707 return false; 3708 } 3709 3710 function renderChatContextSelect() { 3711 if (!(chatContextSelectEl instanceof HTMLSelectElement)) return; 3712 const prevValue = String(chatContextSelectEl.value || "").trim(); 3713 const dmThreadsActive = activeDmThreadsSorted(); 3714 const dmById = new Map(dmThreadsActive.map((t) => [t.id, t])); 3715 recentDmChatThreadIds = recentDmChatThreadIds.filter((id) => dmById.has(id)); 3716 const dmRecent = [activeDmThreadId, ...recentDmChatThreadIds] 3717 .map((id) => dmById.get(String(id || ""))) 3718 .filter(Boolean) 3719 .filter((t, i, arr) => arr.findIndex((x) => x.id === t.id) === i); 3720 3721 const postsById = new Map(Array.from(posts.values()).map((p) => [String(p.id), p])); 3722 const openPanelPostIds = Array.from(chatPanelInstances.values()) 3723 .map((inst) => String(inst?.postId || "").trim()) 3724 .filter(Boolean); 3725 recentHiveChatIds = recentHiveChatIds.filter((id) => { 3726 const p = postsById.get(String(id)); 3727 return Boolean(p && !p.deleted); 3728 }); 3729 const knownChatPostIds = Array.from(chatByPost.keys()).map((id) => String(id || "").trim()).filter(Boolean); 3730 const postRecent = [activeChatPostId, ...openPanelPostIds, ...recentHiveChatIds, ...knownChatPostIds] 3731 .map((id) => postsById.get(String(id || ""))) 3732 .filter((p) => p && !p.deleted) 3733 .filter((p, i, arr) => arr.findIndex((x) => String(x.id) === String(p.id)) === i); 3734 3735 const hasAny = Boolean(dmRecent.length || postRecent.length || activeDmThreadId || activeChatPostId); 3736 if (!hasAny) { 3737 chatContextSelectEl.classList.add("hidden"); 3738 chatContextSelectEl.innerHTML = ""; 3739 return; 3740 } 3741 3742 const activeDmValue = activeDmThreadId ? `dm:${activeDmThreadId}` : ""; 3743 const activePostValue = activeChatPostId ? `post:${activeChatPostId}` : ""; 3744 const selected = activeDmValue || activePostValue || prevValue; 3745 3746 syncingChatContextSelect = true; 3747 chatContextSelectEl.classList.remove("hidden"); 3748 chatContextSelectEl.replaceChildren(); 3749 const topPlaceholder = document.createElement("option"); 3750 topPlaceholder.value = ""; 3751 topPlaceholder.textContent = "Open chats..."; 3752 chatContextSelectEl.appendChild(topPlaceholder); 3753 3754 if (dmRecent.length) { 3755 const dmGroup = document.createElement("optgroup"); 3756 dmGroup.label = "DMs"; 3757 for (const thread of dmRecent) { 3758 const opt = document.createElement("option"); 3759 opt.value = `dm:${String(thread.id || "").trim()}`; 3760 const when = shortTimeAgo(dmActivityAt(thread)); 3761 opt.textContent = `@${String(thread.other || "unknown")}${when ? ` β’ ${when}` : ""}`; 3762 dmGroup.appendChild(opt); 3763 } 3764 chatContextSelectEl.appendChild(dmGroup); 3765 } 3766 3767 if (postRecent.length) { 3768 const postGroup = document.createElement("optgroup"); 3769 postGroup.label = "Hive Chats"; 3770 for (const post of postRecent) { 3771 const postId = String(post.id || "").trim(); 3772 if (!postId) continue; 3773 const opt = document.createElement("option"); 3774 opt.value = `post:${postId}`; 3775 const unread = Number(unreadByPostId.get(postId) || 0); 3776 const unreadLabel = unread > 0 ? ` (${unread})` : ""; 3777 const when = shortTimeAgo(postChatActivityAt(postId, post)); 3778 opt.textContent = `${postTitle(post)}${unreadLabel}${when ? ` β’ ${when}` : ""}${post.author ? ` - @${String(post.author || "")}` : ""}`; 3779 postGroup.appendChild(opt); 3780 } 3781 chatContextSelectEl.appendChild(postGroup); 3782 } 3783 3784 chatContextSelectEl.value = 3785 selected && chatContextSelectEl.querySelector(`option[value="${cssEscape(selected)}"]`) ? selected : ""; 3786 syncingChatContextSelect = false; 3787 } 3788 3789 function setDmThreads(list) { 3790 dmThreads = Array.isArray(list) ? list.map(normalizeDmThread).filter(Boolean) : []; 3791 dmThreadsById = new Map(dmThreads.map((t) => [t.id, t])); 3792 if (pendingOpenDmThreadId) { 3793 const pending = dmThreadsById.get(pendingOpenDmThreadId) || null; 3794 if (pending && String(pending.status || "") === "active") { 3795 openDmThread(pending.id); 3796 } 3797 } 3798 if (activeDmThreadId && !dmThreadsById.has(activeDmThreadId)) { 3799 activeDmThreadId = null; 3800 } 3801 renderPeoplePanel(); 3802 renderChatPanel(); 3803 } 3804 3805 function applyChatDock() { 3806 if (!appRoot) return; 3807 appRoot.classList.toggle("chatRight", chatDock === "right"); 3808 } 3809 3810 function upsertDmThread(rawThread) { 3811 const t = normalizeDmThread(rawThread); 3812 if (!t) return; 3813 dmThreadsById.set(t.id, t); 3814 dmThreads = dmThreads.filter((x) => x.id !== t.id); 3815 dmThreads.push(t); 3816 dmThreads.sort((a, b) => dmActivityAt(b) - dmActivityAt(a)); 3817 renderPeoplePanel(); 3818 renderChatPanel(); 3819 } 3820 3821 function setModModalOpen(open) { 3822 if (!modModal) return; 3823 modModal.classList.toggle("hidden", !open); 3824 if (!open) { 3825 modModalContext = null; 3826 if (modModalBody) modModalBody.innerHTML = ""; 3827 if (modModalStatus) modModalStatus.textContent = ""; 3828 if (modModalPrimary) modModalPrimary.classList.remove("hidden"); 3829 } 3830 } 3831 3832 function setMediaModalOpen(open) { 3833 if (!mediaModal) return; 3834 mediaModal.classList.toggle("hidden", !open); 3835 if (!open) { 3836 if (mediaModalImg) mediaModalImg.src = ""; 3837 if (mediaModalOpenLink) mediaModalOpenLink.href = "#"; 3838 if (mediaModalStatus) mediaModalStatus.textContent = ""; 3839 if (mediaModalTitle) mediaModalTitle.textContent = "Media"; 3840 } 3841 } 3842 3843 function setShortcutHelpOpen(open) { 3844 if (!shortcutHelpModal) return; 3845 shortcutHelpModal.classList.toggle("hidden", !open); 3846 } 3847 3848 function openMediaModal(url) { 3849 const src = String(url || "").trim(); 3850 if (!src) return; 3851 if (!mediaModalImg) return; 3852 mediaModalImg.src = src; 3853 if (mediaModalOpenLink) mediaModalOpenLink.href = src; 3854 if (mediaModalStatus) mediaModalStatus.textContent = ""; 3855 setMediaModalOpen(true); 3856 } 3857 3858 function gateTokenLabel(token) { 3859 const t = String(token || "").trim().toLowerCase(); 3860 if (!t) return { label: "", color: "" }; 3861 if (t === "owner" || t === "moderator" || t === "member") return { label: t, color: "" }; 3862 if (t.startsWith("role:")) { 3863 const key = t.slice("role:".length); 3864 const def = roleDefByKey(key); 3865 if (def) return { label: def.label || `role:${key}`, color: def.color || "" }; 3866 return { label: `role:${key}`, color: "" }; 3867 } 3868 return { label: t, color: "" }; 3869 } 3870 3871 function openCollectionGateModal(collectionId) { 3872 if (!canModerate) return; 3873 const id = String(collectionId || ""); 3874 const col = collections.find((c) => c.id === id); 3875 if (!col) { 3876 toast("Collections", "Collection not found."); 3877 return; 3878 } 3879 modModalContext = { kind: "collectionGate", collectionId: id }; 3880 if (modModalTitle) modModalTitle.textContent = `Gate /${col.name || col.id}`; 3881 if (modModalStatus) modModalStatus.textContent = ""; 3882 if (modModalPrimary) modModalPrimary.textContent = "Save"; 3883 3884 const visibility = col.visibility === "gated" ? "gated" : "public"; 3885 const allowed = new Set(Array.isArray(col.allowedRoles) ? col.allowedRoles : []); 3886 const tokens = availableGateTokens(); 3887 3888 const optionsHtml = tokens 3889 .map((token) => { 3890 const meta = gateTokenLabel(token); 3891 const swatch = meta.color ? `<span class="roleSwatch" style="background:${escapeHtml(meta.color)}"></span>` : ""; 3892 const checked = allowed.has(token) ? "checked" : ""; 3893 return `<label class="gateOption"> 3894 <span class="gateOptionLeft">${swatch}<span>${escapeHtml(meta.label)}</span></span> 3895 <input type="checkbox" data-gatetoken="${escapeHtml(token)}" ${checked} /> 3896 </label>`; 3897 }) 3898 .join(""); 3899 3900 if (modModalBody) { 3901 modModalBody.innerHTML = ` 3902 <div class="row" style="gap:12px;align-items:center"> 3903 <label class="gateOption" style="flex:1"> 3904 <span>Public</span> 3905 <input type="radio" name="gateVisibility" value="public" ${visibility === "public" ? "checked" : ""} /> 3906 </label> 3907 <label class="gateOption" style="flex:1"> 3908 <span>Gated</span> 3909 <input type="radio" name="gateVisibility" value="gated" ${visibility === "gated" ? "checked" : ""} /> 3910 </label> 3911 </div> 3912 <div class="small muted">If gated, pick one or more roles that can view this collection.</div> 3913 <div class="gateList" id="gateListWrap">${optionsHtml || `<div class="muted">No roles available.</div>`}</div> 3914 `; 3915 } 3916 setModModalOpen(true); 3917 updateGateModalVisibility(); 3918 } 3919 3920 function openUserRolesModal(username) { 3921 if (!canModerate) return; 3922 const target = String(username || "").toLowerCase(); 3923 if (!target) return; 3924 const member = (peopleMembers || []).find((m) => m && m.username === target); 3925 const assigned = new Set(Array.isArray(member?.customRoles) ? member.customRoles : []); 3926 modModalContext = { kind: "userRoles", username: target }; 3927 if (modModalTitle) modModalTitle.textContent = `Custom roles for @${target}`; 3928 if (modModalPrimary) modModalPrimary.classList.add("hidden"); 3929 if (modModalStatus) modModalStatus.textContent = "Toggles apply immediately."; 3930 3931 const rows = customRoles.length 3932 ? customRoles 3933 .map((r) => { 3934 const checked = assigned.has(r.key) ? "checked" : ""; 3935 const swatch = r.color ? `<span class="roleSwatch" style="background:${escapeHtml(r.color)}"></span>` : ""; 3936 return `<label class="gateOption"> 3937 <span class="gateOptionLeft">${swatch}<span>${escapeHtml(r.label)}</span> <span class="roleKey">${escapeHtml( 3938 r.key 3939 )}</span></span> 3940 <input type="checkbox" data-userrolekey="${escapeHtml(r.key)}" ${checked} /> 3941 </label>`; 3942 }) 3943 .join("") 3944 : `<div class="muted">No custom roles created yet.</div>`; 3945 3946 if (modModalBody) modModalBody.innerHTML = `<div class="gateList">${rows}</div>`; 3947 setModModalOpen(true); 3948 } 3949 3950 function openCollectionCreateModal() { 3951 if (!canModerate) return; 3952 modModalContext = { kind: "collectionCreate" }; 3953 if (modModalTitle) modModalTitle.textContent = "Create collection"; 3954 if (modModalPrimary) modModalPrimary.textContent = "Create"; 3955 if (modModalStatus) modModalStatus.textContent = ""; 3956 if (modModalBody) { 3957 modModalBody.innerHTML = ` 3958 <label> 3959 <span>Name</span> 3960 <input id="modModalCollectionName" maxlength="40" placeholder="Example: music" /> 3961 </label> 3962 <div class="small muted">Collections appear as tabs and can be gated.</div> 3963 `; 3964 } 3965 setModModalOpen(true); 3966 setTimeout(() => document.getElementById("modModalCollectionName")?.focus(), 0); 3967 } 3968 3969 function updateGateModalVisibility() { 3970 const listWrap = document.getElementById("gateListWrap"); 3971 if (!listWrap) return; 3972 const v = String(modModalBody?.querySelector("input[name='gateVisibility']:checked")?.value || "public"); 3973 listWrap.classList.toggle("hidden", v !== "gated"); 3974 } 3975 3976 function getSessionToken() { 3977 try { 3978 return localStorage.getItem(SESSION_TOKEN_KEY) || ""; 3979 } catch { 3980 return ""; 3981 } 3982 } 3983 3984 function setSessionToken(token) { 3985 try { 3986 if (!token) localStorage.removeItem(SESSION_TOKEN_KEY); 3987 else localStorage.setItem(SESSION_TOKEN_KEY, token); 3988 } catch { 3989 // ignore 3990 } 3991 } 3992 3993 function fallbackPeopleFromProfiles() { 3994 const out = []; 3995 for (const [username, p] of Object.entries(profiles || {})) { 3996 if (!username) continue; 3997 out.push({ 3998 username, 3999 image: typeof p?.image === "string" ? p.image : "", 4000 color: typeof p?.color === "string" ? p.color : "", 4001 role: "member", 4002 online: false, 4003 status: "offline" 4004 }); 4005 } 4006 if (loggedInUser && !out.some((m) => m.username === loggedInUser)) { 4007 const me = getProfile(loggedInUser); 4008 out.push({ 4009 username: loggedInUser, 4010 image: me.image || "", 4011 color: me.color || "", 4012 role: loggedInRole || "member", 4013 online: true, 4014 status: "online" 4015 }); 4016 } 4017 out.sort((a, b) => a.username.localeCompare(b.username)); 4018 return out; 4019 } 4020 4021 function ensurePeopleFallback() { 4022 if (Array.isArray(peopleMembers) && peopleMembers.length > 0) return; 4023 peopleMembers = fallbackPeopleFromProfiles(); 4024 } 4025 4026 const toastHost = (() => { 4027 const el = document.createElement("div"); 4028 el.className = "toastHost"; 4029 document.body.appendChild(el); 4030 return el; 4031 })(); 4032 4033 /** @type {Set<string>} */ 4034 const newPostAnimIds = new Set(); 4035 /** @type {Map<string, number>} */ 4036 const buzzTimers = new Map(); 4037 4038 function syncProtectedUi() { 4039 if (!isProtectedEl || !postPasswordEl) return; 4040 const on = Boolean(isProtectedEl.checked); 4041 postPasswordEl.disabled = !on; 4042 if (!on) postPasswordEl.value = ""; 4043 } 4044 4045 syncProtectedUi(); 4046 isProtectedEl?.addEventListener("change", () => { 4047 syncProtectedUi(); 4048 if (isProtectedEl?.checked) postPasswordEl?.focus(); 4049 }); 4050 4051 function setSidebarHidden(hidden) { 4052 if (!appRoot) return; 4053 appRoot.classList.toggle("sidebarHidden", hidden); 4054 if (toggleSidebarBtn) { 4055 toggleSidebarBtn.textContent = "Hide"; 4056 toggleSidebarBtn.title = "Hide sidebar"; 4057 } 4058 if (showSidebarBtn) { 4059 showSidebarBtn.classList.toggle("hidden", !hidden); 4060 showSidebarBtn.textContent = "Show"; 4061 showSidebarBtn.title = "Show sidebar"; 4062 } 4063 try { 4064 localStorage.setItem("bzl_sidebarHidden", hidden ? "1" : "0"); 4065 } catch { 4066 // ignore 4067 } 4068 } 4069 4070 function getSidebarHidden() { 4071 try { 4072 return localStorage.getItem("bzl_sidebarHidden") === "1"; 4073 } catch { 4074 return false; 4075 } 4076 } 4077 4078 function setPeopleOpen(open) { 4079 const inRackMode = Boolean(appRoot?.classList.contains("rackMode")); 4080 peopleOpen = inRackMode ? true : Boolean(open); 4081 if (!peopleDrawerEl) return; 4082 // In rack mode, "People" is a normal dockable panel; don't hide it behind a special toggle. 4083 peopleDrawerEl.classList.toggle("hidden", !peopleOpen && !inRackMode); 4084 if (togglePeopleBtn) { 4085 if (inRackMode) { 4086 togglePeopleBtn.classList.add("hidden"); 4087 } else { 4088 togglePeopleBtn.classList.remove("hidden"); 4089 togglePeopleBtn.textContent = peopleOpen ? "Hide people" : "People"; 4090 togglePeopleBtn.title = peopleOpen ? "Hide people" : "Show people"; 4091 } 4092 } 4093 if (peopleOpen && ws.readyState === WebSocket.OPEN) { 4094 ws.send(JSON.stringify({ type: "peopleList" })); 4095 } 4096 if (inRackMode) return; 4097 try { 4098 localStorage.setItem("bzl_peopleOpen", peopleOpen ? "1" : "0"); 4099 } catch { 4100 // ignore 4101 } 4102 } 4103 4104 function getPeopleOpen() { 4105 try { 4106 return localStorage.getItem("bzl_peopleOpen") === "1"; 4107 } catch { 4108 return false; 4109 } 4110 } 4111 4112 function setComposerOpen(open) { 4113 composerOpen = Boolean(open); 4114 if (pollinatePanel) pollinatePanel.classList.toggle("composerCollapsed", !composerOpen); 4115 if (toggleComposerBtn) { 4116 toggleComposerBtn.textContent = composerOpen ? "Hide Creator" : "New Hive"; 4117 toggleComposerBtn.title = composerOpen ? "Hide hive creator" : "Open hive creator"; 4118 } 4119 renderCenterPanels(); 4120 updateSideRackEmptyState(); 4121 try { 4122 localStorage.setItem("bzl_composerOpen", composerOpen ? "1" : "0"); 4123 } catch { 4124 // ignore 4125 } 4126 } 4127 4128 function getComposerOpen() { 4129 try { 4130 return localStorage.getItem("bzl_composerOpen") === "1"; 4131 } catch { 4132 return false; 4133 } 4134 } 4135 4136 function readStoredChatWidth() { 4137 try { 4138 const raw = Number(localStorage.getItem(CHAT_WIDTH_KEY) || 0); 4139 return Number.isFinite(raw) && raw > 0 ? raw : CHAT_WIDTH_DEFAULT; 4140 } catch { 4141 return CHAT_WIDTH_DEFAULT; 4142 } 4143 } 4144 4145 function readStoredSidebarWidth() { 4146 try { 4147 const raw = Number(localStorage.getItem(SIDEBAR_WIDTH_KEY) || 0); 4148 return Number.isFinite(raw) && raw > 0 ? raw : SIDEBAR_WIDTH_DEFAULT; 4149 } catch { 4150 return SIDEBAR_WIDTH_DEFAULT; 4151 } 4152 } 4153 4154 function readStoredModWidth() { 4155 try { 4156 const raw = Number(localStorage.getItem(MOD_WIDTH_KEY) || 0); 4157 return Number.isFinite(raw) && raw > 0 ? raw : MOD_WIDTH_DEFAULT; 4158 } catch { 4159 return MOD_WIDTH_DEFAULT; 4160 } 4161 } 4162 4163 function readStoredPeopleWidth() { 4164 try { 4165 const raw = Number(localStorage.getItem(PEOPLE_WIDTH_KEY) || 0); 4166 return Number.isFinite(raw) && raw > 0 ? raw : PEOPLE_WIDTH_DEFAULT; 4167 } catch { 4168 return PEOPLE_WIDTH_DEFAULT; 4169 } 4170 } 4171 4172 function clampChatWidth(px) { 4173 const maxByViewport = Math.floor(window.innerWidth * 0.72); 4174 return Math.max(380, Math.min(maxByViewport, Math.floor(Number(px || CHAT_WIDTH_DEFAULT)))); 4175 } 4176 4177 function clampSidebarWidth(px) { 4178 const maxByViewport = Math.floor(window.innerWidth * 0.42); 4179 return Math.max(240, Math.min(maxByViewport, Math.floor(Number(px || SIDEBAR_WIDTH_DEFAULT)))); 4180 } 4181 4182 function clampModWidth(px) { 4183 const maxByViewport = Math.floor(window.innerWidth * 0.44); 4184 return Math.max(280, Math.min(maxByViewport, Math.floor(Number(px || MOD_WIDTH_DEFAULT)))); 4185 } 4186 4187 function clampPeopleWidth(px) { 4188 const maxByViewport = Math.floor(window.innerWidth * 0.62); 4189 return Math.max(320, Math.min(maxByViewport, Math.floor(Number(px || PEOPLE_WIDTH_DEFAULT)))); 4190 } 4191 4192 function applyChatWidth(px, persist = true) { 4193 if (!appRoot) return; 4194 const next = clampChatWidth(px); 4195 appRoot.style.setProperty("--chat-width", `${next}px`); 4196 if (persist) { 4197 try { 4198 localStorage.setItem(CHAT_WIDTH_KEY, String(next)); 4199 } catch { 4200 // ignore 4201 } 4202 } 4203 } 4204 4205 function applySidebarWidth(px, persist = true) { 4206 if (!appRoot) return; 4207 const next = clampSidebarWidth(px); 4208 appRoot.style.setProperty("--sidebar-width", `${next}px`); 4209 if (persist) { 4210 try { 4211 localStorage.setItem(SIDEBAR_WIDTH_KEY, String(next)); 4212 } catch { 4213 // ignore 4214 } 4215 } 4216 } 4217 4218 function applyModWidth(px, persist = true) { 4219 if (!appRoot) return; 4220 const next = clampModWidth(px); 4221 appRoot.style.setProperty("--mod-width", `${next}px`); 4222 if (persist) { 4223 try { 4224 localStorage.setItem(MOD_WIDTH_KEY, String(next)); 4225 } catch { 4226 // ignore 4227 } 4228 } 4229 } 4230 4231 function applyPeopleWidth(px, persist = true) { 4232 const next = clampPeopleWidth(px); 4233 document.documentElement.style.setProperty("--people-width", `${next}px`); 4234 if (persist) { 4235 try { 4236 localStorage.setItem(PEOPLE_WIDTH_KEY, String(next)); 4237 } catch { 4238 // ignore 4239 } 4240 } 4241 } 4242 4243 function canResizeChatNow() { 4244 return !isMobileSwipeMode(); 4245 } 4246 4247 function canResizeSidebarNow() { 4248 return !isMobileSwipeMode(); 4249 } 4250 4251 function canResizeModNow() { 4252 return !isMobileSwipeMode() && canModerate; 4253 } 4254 4255 function canResizePeopleNow() { 4256 return !isMobileSwipeMode(); 4257 } 4258 4259 function stopAnyPanelResize() { 4260 if (!chatResizeDragging && !sidebarResizeDragging && !modResizeDragging && !peopleResizeDragging) return; 4261 chatResizeDragging = false; 4262 sidebarResizeDragging = false; 4263 modResizeDragging = false; 4264 peopleResizeDragging = false; 4265 appRoot?.classList.remove("isResizing"); 4266 } 4267 4268 function startChatResize(clientX) { 4269 if (!canResizeChatNow() || !chatPanelEl) return false; 4270 chatResizeDragging = true; 4271 chatResizeStartX = clientX; 4272 chatResizeStartWidth = chatPanelEl.getBoundingClientRect().width || readStoredChatWidth(); 4273 appRoot?.classList.add("isResizing"); 4274 return true; 4275 } 4276 4277 function startSidebarResize(clientX) { 4278 if (!canResizeSidebarNow() || !sidebarPanelEl || appRoot?.classList.contains("sidebarHidden")) return false; 4279 sidebarResizeDragging = true; 4280 sidebarResizeStartX = clientX; 4281 sidebarResizeStartWidth = sidebarPanelEl.getBoundingClientRect().width || readStoredSidebarWidth(); 4282 appRoot?.classList.add("isResizing"); 4283 return true; 4284 } 4285 4286 function startModResize(clientX) { 4287 if (!canResizeModNow() || !modPanelEl || modPanelEl.classList.contains("hidden")) return false; 4288 modResizeDragging = true; 4289 modResizeStartX = clientX; 4290 modResizeStartWidth = modPanelEl.getBoundingClientRect().width || readStoredModWidth(); 4291 appRoot?.classList.add("isResizing"); 4292 return true; 4293 } 4294 4295 function startPeopleResize(clientX) { 4296 if (!canResizePeopleNow() || !peopleDrawerEl || peopleDrawerEl.classList.contains("hidden")) return false; 4297 peopleResizeDragging = true; 4298 peopleResizeStartX = clientX; 4299 peopleResizeStartWidth = peopleDrawerEl.getBoundingClientRect().width || readStoredPeopleWidth(); 4300 appRoot?.classList.add("isResizing"); 4301 return true; 4302 } 4303 4304 function setEditModalOpen(open) { 4305 if (!editModal) return; 4306 editModal.classList.toggle("hidden", !open); 4307 if (editModalStatus) editModalStatus.textContent = ""; 4308 if (!open) { 4309 editContext = null; 4310 if (editModalEditor) editModalEditor.innerHTML = ""; 4311 if (editModalPostTitleInput) editModalPostTitleInput.value = ""; 4312 if (editModalPostMeta) editModalPostMeta.classList.add("hidden"); 4313 if (editModalKeywordsInput) editModalKeywordsInput.value = ""; 4314 if (editModalCollectionSelect) editModalCollectionSelect.innerHTML = ""; 4315 if (editModalProtectedToggle) editModalProtectedToggle.checked = false; 4316 if (editModalWalkieToggle) editModalWalkieToggle.checked = false; 4317 if (editModalPasswordInput) editModalPasswordInput.value = ""; 4318 if (editModalPasswordRow) editModalPasswordRow.classList.add("hidden"); 4319 } 4320 } 4321 4322 function parseKeywordsInput(value) { 4323 const raw = String(value || "") 4324 .split(",") 4325 .map((x) => x.trim().toLowerCase()) 4326 .filter(Boolean); 4327 const out = []; 4328 for (const k of raw) { 4329 const cleaned = k.replace(/[^a-z0-9_-]/g, "").slice(0, 20); 4330 if (!cleaned) continue; 4331 if (!out.includes(cleaned)) out.push(cleaned); 4332 if (out.length >= 6) break; 4333 } 4334 return out; 4335 } 4336 4337 function fillCollectionSelect(selectEl, currentId) { 4338 if (!selectEl) return; 4339 const active = activeCollections(); 4340 const current = String(currentId || "") || "general"; 4341 const list = active.length ? active : [{ id: "general", name: "General" }]; 4342 const hasCurrent = list.some((c) => c.id === current); 4343 selectEl.innerHTML = 4344 (hasCurrent ? "" : `<option value="${escapeHtml(current)}">${escapeHtml(current)}</option>`) + 4345 list.map((c) => `<option value="${escapeHtml(c.id)}">${escapeHtml(c.name || c.id)}</option>`).join(""); 4346 selectEl.value = current; 4347 } 4348 4349 function openEditModalForPost(post) { 4350 if (!post || post.deleted || post.locked) return; 4351 if (!loggedInUser || post.author !== loggedInUser) return; 4352 editContext = { kind: "post", postId: post.id }; 4353 if (editModalTitle) editModalTitle.textContent = "Edit post"; 4354 if (editModalPostTitleRow) editModalPostTitleRow.classList.remove("hidden"); 4355 if (editModalPostMeta) editModalPostMeta.classList.remove("hidden"); 4356 if (editModalPostTitleInput) editModalPostTitleInput.value = String(post.title || "").slice(0, 96); 4357 if (editModalKeywordsInput) editModalKeywordsInput.value = (post.keywords || []).join(", "); 4358 fillCollectionSelect(editModalCollectionSelect, String(post.collectionId || "general")); 4359 if (editModalProtectedToggle) editModalProtectedToggle.checked = Boolean(post.protected); 4360 if (editModalWalkieToggle) editModalWalkieToggle.checked = String(post.mode || post.chatMode || "").toLowerCase() === "walkie"; 4361 if (editModalPasswordRow) editModalPasswordRow.classList.toggle("hidden", !Boolean(post.protected)); 4362 if (editModalPasswordInput) editModalPasswordInput.value = ""; 4363 if (editModalEditor) editModalEditor.innerHTML = String(post.contentHtml || "").trim() || escapeHtml(post.content || ""); 4364 setEditModalOpen(true); 4365 setTimeout(() => editModalEditor?.focus(), 0); 4366 } 4367 4368 function openEditModalForChatMessage(message, postId) { 4369 if (!message || message.deleted) return; 4370 if (!loggedInUser || message.fromUser !== loggedInUser) return; 4371 editContext = { kind: "chat", messageId: message.id, postId }; 4372 if (editModalTitle) editModalTitle.textContent = "Edit message"; 4373 if (editModalPostTitleRow) editModalPostTitleRow.classList.add("hidden"); 4374 if (editModalPostTitleInput) editModalPostTitleInput.value = ""; 4375 if (editModalPostMeta) editModalPostMeta.classList.add("hidden"); 4376 if (editModalKeywordsInput) editModalKeywordsInput.value = ""; 4377 if (editModalCollectionSelect) editModalCollectionSelect.innerHTML = ""; 4378 if (editModalProtectedToggle) editModalProtectedToggle.checked = false; 4379 if (editModalWalkieToggle) editModalWalkieToggle.checked = false; 4380 if (editModalPasswordInput) editModalPasswordInput.value = ""; 4381 if (editModalPasswordRow) editModalPasswordRow.classList.add("hidden"); 4382 if (editModalEditor) editModalEditor.innerHTML = String(message.html || "").trim() || escapeHtml(message.text || ""); 4383 setEditModalOpen(true); 4384 setTimeout(() => editModalEditor?.focus(), 0); 4385 } 4386 4387 editModalProtectedToggle?.addEventListener("change", () => { 4388 const on = Boolean(editModalProtectedToggle?.checked); 4389 if (editModalPasswordRow) editModalPasswordRow.classList.toggle("hidden", !on); 4390 if (!on && editModalPasswordInput) editModalPasswordInput.value = ""; 4391 }); 4392 4393 function collectEditorPayload(targetEditor) { 4394 const html = String(targetEditor?.innerHTML || "").trim(); 4395 const text = String(targetEditor?.innerText || "") 4396 .replace(/\s+/g, " ") 4397 .trim(); 4398 const hasImg = Boolean(targetEditor?.querySelector?.("img")); 4399 const hasAudio = Boolean(targetEditor?.querySelector?.("audio")); 4400 return { html, text, hasImg, hasAudio }; 4401 } 4402 4403 function syncProfileSongPreview(url) { 4404 if (!profileThemeSongPreview || !profileThemeSongUrlInput) return; 4405 const safe = asProfileLink(url) || (String(url || "").startsWith("/uploads/") ? String(url || "") : ""); 4406 if (!safe) { 4407 profileThemeSongPreview.classList.add("hidden"); 4408 profileThemeSongPreview.removeAttribute("src"); 4409 profileThemeSongUrlInput.value = ""; 4410 return; 4411 } 4412 profileThemeSongPreview.classList.remove("hidden"); 4413 profileThemeSongPreview.src = safe; 4414 profileThemeSongPreview.load(); 4415 profileThemeSongUrlInput.value = safe; 4416 } 4417 4418 function renderProfileLinksEditor(links) { 4419 if (!profileLinksEditor) return; 4420 const list = normalizeProfileLinks(links); 4421 if (!list.length) { 4422 profileLinksEditor.innerHTML = `<div class="small muted">No links yet.</div>`; 4423 return; 4424 } 4425 profileLinksEditor.innerHTML = list 4426 .map( 4427 (entry, index) => `<div class="profileLinkEditRow"> 4428 <input data-linklabel="${index}" value="${escapeHtml(entry.label)}" maxlength="40" placeholder="Label" /> 4429 <input data-linkurl="${index}" value="${escapeHtml(entry.url)}" maxlength="280" placeholder="https://..." /> 4430 <button type="button" class="ghost smallBtn" data-linkremove="${index}">Remove</button> 4431 </div>` 4432 ) 4433 .join(""); 4434 } 4435 4436 function profileLinksFromEditor() { 4437 if (!profileLinksEditor) return []; 4438 const rows = Array.from(profileLinksEditor.querySelectorAll(".profileLinkEditRow")); 4439 if (!rows.length) return []; 4440 const out = []; 4441 for (const row of rows) { 4442 const label = String(row.querySelector("[data-linklabel]")?.value || "") 4443 .replace(/\s+/g, " ") 4444 .trim() 4445 .slice(0, 40); 4446 const url = asProfileLink(row.querySelector("[data-linkurl]")?.value || ""); 4447 if (!url) continue; 4448 out.push({ label: label || "Link", url }); 4449 if (out.length >= 8) break; 4450 } 4451 return out; 4452 } 4453 4454 function renderProfileCard() { 4455 if (!profileCard) return; 4456 if (!activeProfile || !activeProfile.username) { 4457 profileCard.innerHTML = `<div class="small muted">Profile unavailable.</div>`; 4458 return; 4459 } 4460 const p = normalizeProfileData(activeProfile); 4461 const headerStyle = p.color ? ` style="--profile-accent:${escapeHtml(p.color)}"` : ""; 4462 const pronouns = p.pronouns ? `<div class="small muted pronouns">${escapeHtml(p.pronouns)}</div>` : ""; 4463 const usernameLower = String(p.username || "").toLowerCase(); 4464 const selfLower = String(loggedInUser || "").toLowerCase(); 4465 const canDm = Boolean(loggedInUser && usernameLower && usernameLower !== selfLower); 4466 const ignored = prefSet("ignoredUsers").has(usernameLower); 4467 const blocked = prefSet("blockedUsers").has(usernameLower); 4468 const dmBtn = canDm 4469 ? `<button type="button" class="primary smallBtn" data-dmrequest="${escapeHtml(p.username)}" ${blocked ? "disabled" : ""}>DM</button>` 4470 : ""; 4471 const modDmBtn = canModerate && canDm 4472 ? `<button type="button" class="ghost smallBtn" data-moddm="${escapeHtml(p.username)}">Mod DM</button>` 4473 : ""; 4474 const member = peopleMembers.find((m) => String(m.username || "").toLowerCase() === usernameLower) || null; 4475 const role = roleLabel(member?.role); 4476 const isStaff = role === "owner" || role === "moderator"; 4477 const canMuteUser = Boolean(loggedInUser && usernameLower && usernameLower !== selfLower && !isStaff); 4478 const ignoreBtn = canMuteUser 4479 ? ignored 4480 ? `<button type="button" class="ghost smallBtn" data-unignoreuser="${escapeHtml(p.username)}">Unignore</button>` 4481 : `<button type="button" class="ghost smallBtn" data-ignoreuser="${escapeHtml(p.username)}">Ignore</button>` 4482 : ""; 4483 const blockBtn = canMuteUser 4484 ? blocked 4485 ? `<button type="button" class="ghost smallBtn" data-unblockuser="${escapeHtml(p.username)}">Unblock</button>` 4486 : `<button type="button" class="ghost smallBtn" data-blockuser="${escapeHtml(p.username)}">Block</button>` 4487 : ""; 4488 const blockNote = canDm && blocked ? `<div class="small muted">Blocked: DMs + content hidden.</div>` : ""; 4489 const bio = p.bioHtml ? `<div class="profileBio">${p.bioHtml}</div>` : `<div class="small muted">No bio yet.</div>`; 4490 const theme = p.themeSongUrl ? `<audio controls preload="none" src="${escapeHtml(p.themeSongUrl)}"></audio>` : `<div class="small muted">No theme song set.</div>`; 4491 const links = p.links.length 4492 ? p.links 4493 .map( 4494 (entry) => 4495 `<a class="tag profileLinkTag" href="${escapeHtml(entry.url)}" target="_blank" rel="noopener noreferrer nofollow">${escapeHtml(entry.label)}</a>` 4496 ) 4497 .join("") 4498 : `<div class="small muted">No links yet.</div>`; 4499 profileCard.innerHTML = `<div class="profileHeader"${headerStyle}> 4500 <span class="pfp profileHeroPfp">${p.image ? `<img alt="" src="${escapeHtml(p.image)}" />` : ""}</span> 4501 <div class="profileIdentity"> 4502 <div class="profileHandle" ${p.color ? `style="color:${escapeHtml(safeTextColorFromHex(p.color))}"` : ""}>@${escapeHtml(p.username)}</div> 4503 ${pronouns} 4504 </div> 4505 ${dmBtn || modDmBtn || ignoreBtn || blockBtn ? `<div class="profileActions">${dmBtn}${modDmBtn}${ignoreBtn}${blockBtn}</div>` : ""} 4506 </div> 4507 ${blockNote} 4508 <div class="profileSection"> 4509 <div class="small muted">Bio</div> 4510 ${bio} 4511 </div> 4512 <div class="profileSection"> 4513 <div class="small muted">Theme song</div> 4514 ${theme} 4515 </div> 4516 <div class="profileSection"> 4517 <div class="small muted">Links</div> 4518 <div class="profileLinksWrap">${links}</div> 4519 </div>`; 4520 const bioEl = profileCard.querySelector(".profileBio"); 4521 if (bioEl) decorateYouTubeEmbedsInElement(bioEl); 4522 } 4523 4524 function renderProfileEditor() { 4525 const canEdit = Boolean(loggedInUser && activeProfile && activeProfile.username === loggedInUser); 4526 if (profileEditToggleBtn) profileEditToggleBtn.classList.toggle("hidden", !canEdit); 4527 if (!profileEditPanel || !profilePronounsInput || !profileBioEditor) return; 4528 profileEditPanel.classList.toggle("hidden", !(canEdit && isEditingProfile)); 4529 if (!canEdit || !activeProfile) return; 4530 profilePronounsInput.value = String(activeProfile.pronouns || ""); 4531 profileBioEditor.innerHTML = String(activeProfile.bioHtml || ""); 4532 renderProfileLinksEditor(activeProfile.links); 4533 syncProfileSongPreview(activeProfile.themeSongUrl || ""); 4534 } 4535 4536 function renderCenterPanels() { 4537 // In rack mode, panels are independent. Profile shouldn't "replace" the Hives panel. 4538 if (rackLayoutEnabled) { 4539 if (pollinatePanel) { 4540 pollinatePanel.classList.remove("hidden"); 4541 pollinatePanel.classList.toggle("panelCollapsed", !composerOpen); 4542 pollinatePanel.dataset.panelDisplay = composerOpen ? "full" : "collapsed"; 4543 } 4544 renderProfilePanel(); 4545 updateSideRackEmptyState(); 4546 return; 4547 } 4548 4549 const profileMode = centerView === "profile"; 4550 if (profileViewPanel) profileViewPanel.classList.toggle("hidden", !profileMode); 4551 if (feedEl?.closest("section")) feedEl.closest("section").classList.toggle("hidden", profileMode); 4552 if (pollinatePanel) { 4553 if (profileMode) pollinatePanel.classList.add("hidden"); 4554 else pollinatePanel.classList.toggle("hidden", !composerOpen); 4555 } 4556 if (!profileMode) return; 4557 renderProfilePanel(); 4558 } 4559 4560 function renderProfilePanel() { 4561 if (!profileViewPanel) return; 4562 if (!activeProfileUsername && !activeProfile && loggedInUser) { 4563 activeProfileUsername = String(loggedInUser || "").trim().toLowerCase(); 4564 } 4565 4566 const username = String(activeProfile?.username || activeProfileUsername || "") 4567 .trim() 4568 .toLowerCase(); 4569 4570 if (username) { 4571 // Ensure we always have *some* profile data to show immediately. 4572 if (!activeProfile || String(activeProfile.username || "").toLowerCase() !== username) { 4573 const basic = getProfile(username); 4574 activeProfile = normalizeProfileData({ username, image: basic.image || "", color: basic.color || "" }); 4575 } 4576 4577 // Pull the full profile from the server (bio/links/song) once per username selection. 4578 try { 4579 if (ws?.readyState === WebSocket.OPEN && lastRequestedProfileUsername !== username) { 4580 lastRequestedProfileUsername = username; 4581 ws.send(JSON.stringify({ type: "getUserProfile", username })); 4582 } 4583 } catch { 4584 // ignore 4585 } 4586 } 4587 4588 if (profileViewTitle) profileViewTitle.textContent = username ? `@${username}` : "Profile"; 4589 if (profileViewMeta) profileViewMeta.textContent = username === loggedInUser ? "Your profile" : "Community profile"; 4590 renderProfileCard(); 4591 renderProfileEditor(); 4592 } 4593 4594 function setCenterView(next, username = "") { 4595 if (rackLayoutEnabled) { 4596 // Keep the legacy centerView on "hives" in rack mode; just update profile context. 4597 const wantsProfile = next === "profile"; 4598 if (wantsProfile) { 4599 activeProfileUsername = String(username || activeProfileUsername || "") 4600 .trim() 4601 .toLowerCase(); 4602 isEditingProfile = false; 4603 if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile"; 4604 4605 // Make sure the profile panel is actually visible as its own panel. 4606 undockPanel("profile"); 4607 profileViewPanel.classList.remove("panelCollapsed"); 4608 profileViewPanel.dataset.panelDisplay = "full"; 4609 enforceWorkspaceRules(); 4610 renderProfilePanel(); 4611 } else { 4612 activeProfileUsername = ""; 4613 activeProfile = null; 4614 isEditingProfile = false; 4615 if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile"; 4616 renderProfilePanel(); 4617 } 4618 return; 4619 } 4620 4621 centerView = next === "profile" ? "profile" : "hives"; 4622 if (centerView === "hives") { 4623 activeProfileUsername = ""; 4624 activeProfile = null; 4625 isEditingProfile = false; 4626 if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile"; 4627 } else { 4628 activeProfileUsername = String(username || activeProfileUsername || "") 4629 .trim() 4630 .toLowerCase(); 4631 isEditingProfile = false; 4632 if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile"; 4633 } 4634 renderCenterPanels(); 4635 } 4636 4637 function openUserProfile(username) { 4638 const normalized = String(username || "") 4639 .trim() 4640 .toLowerCase(); 4641 if (!normalized) return; 4642 const basic = getProfile(normalized); 4643 activeProfile = normalizeProfileData({ username: normalized, image: basic.image || "", color: basic.color || "" }); 4644 setCenterView("profile", normalized); 4645 ws.send(JSON.stringify({ type: "getUserProfile", username: normalized })); 4646 if (isMobileSwipeMode()) setMobileScreen("profile"); 4647 } 4648 4649 function isMobileSwipeMode() { 4650 // Mobile UX should kick in for touch-first devices, including landscape phones. 4651 // (Many phones exceed 760px in landscape, so max-width alone is not sufficient.) 4652 const mqNarrow = "(max-width: 760px)"; 4653 const mqPortrait = "(hover: none) and (pointer: coarse) and (max-width: 900px)"; 4654 const mqLandscape = "(hover: none) and (pointer: coarse) and (max-height: 520px)"; 4655 return window.matchMedia(mqNarrow).matches || window.matchMedia(mqPortrait).matches || window.matchMedia(mqLandscape).matches; 4656 } 4657 4658 function isMobileScreenMode() { 4659 // Keep this consistent with CSS mobile screen media queries. 4660 const mqNarrow = "(max-width: 760px)"; 4661 const mqPortrait = "(hover: none) and (pointer: coarse) and (max-width: 900px)"; 4662 const mqLandscape = "(hover: none) and (pointer: coarse) and (max-height: 520px)"; 4663 return window.matchMedia(mqNarrow).matches || window.matchMedia(mqPortrait).matches || window.matchMedia(mqLandscape).matches; 4664 } 4665 4666 function loadMobileLayout() { 4667 const defaults = () => { 4668 const pinned = ["account", "hives", "chat", "people", "profile"]; 4669 const onboardingEnabled = Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.enabled); 4670 const active = onboardingEnabled ? "onboarding" : pinned[0] || "account"; 4671 return { version: 1, pinned, active, history: [], tools: { composerOpen: false, profileOpen: false, pluginRackOpen: false } }; 4672 }; 4673 const sanitizeId = (id) => { 4674 const raw = String(id || "") 4675 .trim() 4676 .toLowerCase(); 4677 if (!raw) return ""; 4678 if (raw === "maps" || raw === "library") return ""; 4679 if (raw === "mod") return canModerate ? "moderation" : ""; 4680 if (raw === "sidebar") return "account"; 4681 if (raw === "main" || raw === "workspace") return "hives"; 4682 if (raw === "account" || raw === "hives" || raw === "chat" || raw === "people" || raw === "profile" || raw === "onboarding") return raw; 4683 if (raw === "moderation") return canModerate ? "moderation" : ""; 4684 if (panelRegistry.has(raw)) return raw; 4685 return ""; 4686 }; 4687 try { 4688 const raw = localStorage.getItem(MOBILE_LAYOUT_KEY); 4689 if (!raw) return defaults(); 4690 const parsed = JSON.parse(raw); 4691 const pinned = Array.isArray(parsed?.pinned) ? parsed.pinned.map((x) => sanitizeId(x)).filter(Boolean) : null; 4692 const active = sanitizeId(parsed?.active); 4693 const history = Array.isArray(parsed?.history) ? parsed.history.map((x) => sanitizeId(x)).filter(Boolean) : []; 4694 const base = defaults(); 4695 if (pinned && pinned.length) base.pinned = pinned.slice(0, 5); 4696 if (active) base.active = active; 4697 base.history = history.slice(0, 12); 4698 return base; 4699 } catch { 4700 return defaults(); 4701 } 4702 } 4703 4704 function saveMobileLayout(layout) { 4705 try { 4706 localStorage.setItem(MOBILE_LAYOUT_KEY, JSON.stringify(layout)); 4707 } catch { 4708 // ignore 4709 } 4710 } 4711 4712 function availableMobileScreens() { 4713 const out = []; 4714 out.push({ id: "account", title: "Account", core: true }); 4715 if (Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.enabled)) out.push({ id: "onboarding", title: "Onboarding", core: true }); 4716 out.push({ id: "hives", title: "Hives", core: true }); 4717 out.push({ id: "chat", title: "Chat", core: true }); 4718 out.push({ id: "people", title: "People", core: true }); 4719 out.push({ id: "profile", title: "Profile", core: true }); 4720 if (canModerate) out.push({ id: "moderation", title: "Moderation", core: true }); 4721 4722 // Plugin screens: include primary-ish panels that exist. 4723 for (const [id, entry] of panelRegistry.entries()) { 4724 if (!id || typeof id !== "string") continue; 4725 if (id === "maps" || id === "library") continue; 4726 if (id === "hives" || id === "chat" || id === "people" || id === "moderation" || id === "profile" || id === "composer" || id === "pluginRack") continue; 4727 const role = typeof entry?.role === "string" ? entry.role : ""; 4728 if (role && role !== "primary") continue; 4729 const hasElement = entry?.element instanceof HTMLElement; 4730 const canRender = typeof pluginPanelDefsByPanelId.get(id)?.render === "function"; 4731 if (!hasElement && !canRender) continue; 4732 out.push({ id, title: panelTitle(id), core: false }); 4733 } 4734 4735 // Prefer stable ordering. 4736 const byTitle = (a, b) => String(a.title || "").localeCompare(String(b.title || "")); 4737 const core = out.filter((x) => x.core).sort(byTitle); 4738 const plugins = out.filter((x) => !x.core).sort(byTitle); 4739 return { core, plugins }; 4740 } 4741 4742 function mobileScreenFromLegacyPanel(next) { 4743 const raw = String(next || "").trim(); 4744 if (!raw) return "hives"; 4745 if (raw === "maps" || raw === "library") return "hives"; 4746 if (raw === "sidebar") return "account"; 4747 if (raw === "main" || raw === "workspace") return "hives"; 4748 if (raw === "chat") return "chat"; 4749 if (raw === "people") return "people"; 4750 if (raw === "profile") return "profile"; 4751 if (raw === "onboarding") return "onboarding"; 4752 if (raw === "moderation" || raw === "mod") return canModerate ? "moderation" : "hives"; 4753 if (raw === "hives" || raw === "account" || raw === "people" || raw === "profile" || raw === "onboarding" || raw === "moderation") return raw; 4754 // Plugin panel id can be treated as a screen. 4755 if (panelRegistry.has(raw)) return raw; 4756 return "hives"; 4757 } 4758 4759 function setMobileMoreOpen(open) { 4760 mobileMoreOpen = Boolean(open); 4761 if (mobileMoreSheetEl) mobileMoreSheetEl.classList.toggle("hidden", !mobileMoreOpen); 4762 if (mobileNavEl) { 4763 const moreBtn = mobileNavEl.querySelector?.('[data-mobilescreen="more"]'); 4764 if (moreBtn instanceof HTMLElement) { 4765 moreBtn.classList.toggle("primary", mobileMoreOpen); 4766 moreBtn.classList.toggle("ghost", !mobileMoreOpen); 4767 } 4768 } 4769 } 4770 4771 function restoreHostedPanelIfAny() { 4772 const ids = Array.from(mobileHostedPanelIds); 4773 if (mobileHostPanelId && !ids.includes(mobileHostPanelId)) ids.push(mobileHostPanelId); 4774 if (!ids.length) return; 4775 mobileHostedPanelIds.clear(); 4776 mobileHostPanelId = ""; 4777 for (const id of ids) { 4778 const el = getPanelElement(id); 4779 const parent = mobileHostRestoreParentByPanelId.get(id) || null; 4780 mobileHostRestoreParentByPanelId.delete(id); 4781 if (!(el instanceof HTMLElement)) continue; 4782 if (!parent && mobileHostEphemeralPanelIds.has(id)) { 4783 mobileHostEphemeralPanelIds.delete(id); 4784 try { 4785 el.remove(); 4786 } catch { 4787 // ignore 4788 } 4789 const prev = panelRegistry.get(id); 4790 if (prev) panelRegistry.set(id, { ...prev, element: null }); 4791 continue; 4792 } 4793 if (parent instanceof HTMLElement && parent.isConnected) { 4794 parent.appendChild(el); 4795 continue; 4796 } 4797 const def = panelRegistry.get(id); 4798 const wantsMain = String(def?.defaultRack || "").toLowerCase() === "main"; 4799 const rack = wantsMain ? ensureMainSideRack() : ensureRightRack(); 4800 if (rack) rack.appendChild(el); 4801 } 4802 } 4803 4804 function ensureMobileHostedPluginPanel(panelId) { 4805 const id = String(panelId || "").trim(); 4806 if (!id) return null; 4807 const existing = getPanelElement(id); 4808 if (existing instanceof HTMLElement) return existing; 4809 const entry = panelRegistry.get(id); 4810 const src = typeof entry?.source === "string" ? entry.source : ""; 4811 if (!src.startsWith("plugin:")) return null; 4812 const def = pluginPanelDefsByPanelId.get(id); 4813 const render = def?.render; 4814 if (typeof render !== "function") return null; 4815 4816 const shell = document.createElement("section"); 4817 shell.className = "panel panelFill pluginPanel mobileHostedPluginPanel"; 4818 shell.dataset.panelId = id; 4819 shell.innerHTML = ` 4820 <div class="panelHeader"> 4821 <div class="panelTitle">${escapeHtml(def?.title || id)}</div> 4822 <div class="row"></div> 4823 </div> 4824 <div class="panelBody" data-pluginmount="1"></div> 4825 `; 4826 4827 const mount = shell.querySelector("[data-pluginmount]"); 4828 if (mount instanceof HTMLElement) { 4829 const pluginId = String(def?.pluginId || "").trim(); 4830 const api = { 4831 toast, 4832 send: (eventName, payload) => { 4833 const ev = String(eventName || "").trim(); 4834 if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false; 4835 const wsRef = window.__bzlWs; 4836 if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false; 4837 const msg = payload && typeof payload === "object" ? payload : {}; 4838 wsRef.send(JSON.stringify({ ...msg, type: `plugin:${pluginId}:${ev}` })); 4839 return true; 4840 }, 4841 getUser: () => loggedInUser, 4842 getRole: () => loggedInRole, 4843 storage: { 4844 get(key) { 4845 try { 4846 return localStorage.getItem(`bzl_panel_${id}_${String(key || "")}`); 4847 } catch { 4848 return null; 4849 } 4850 }, 4851 set(key, value) { 4852 try { 4853 localStorage.setItem(`bzl_panel_${id}_${String(key || "")}`, String(value ?? "")); 4854 return true; 4855 } catch { 4856 return false; 4857 } 4858 } 4859 } 4860 }; 4861 try { 4862 const cleanup = render(mount, api); 4863 if (typeof cleanup === "function") shell.__panelCleanup = cleanup; 4864 } catch (e) { 4865 console.warn(`Plugin ${pluginId} panel render failed:`, e?.message || e); 4866 mount.textContent = `Failed to render panel "${id}".`; 4867 } 4868 } 4869 4870 panelRegistry.set(id, { 4871 ...(entry || { id, title: def?.title || id, icon: def?.icon || "", source: `plugin:${def?.pluginId || ""}`, role: def?.role || "aux", defaultRack: def?.defaultRack || "right" }), 4872 title: def?.title || (entry?.title || id), 4873 icon: def?.icon || (entry?.icon || ""), 4874 role: def?.role || (entry?.role || "aux"), 4875 defaultRack: def?.defaultRack || (entry?.defaultRack || "right"), 4876 element: shell 4877 }); 4878 mobileHostEphemeralPanelIds.add(id); 4879 return shell; 4880 } 4881 4882 function hostPanelInMobileScreen(panelId) { 4883 const id = String(panelId || "").trim(); 4884 if (!id) return false; 4885 if (!(mobileScreenHostEl instanceof HTMLElement)) return false; 4886 if (rackLayoutEnabled && isDocked(id)) { 4887 undockPanel(id); 4888 applyDockState(); 4889 } 4890 let el = getPanelElement(id); 4891 if (!(el instanceof HTMLElement)) el = ensureMobileHostedPluginPanel(id); 4892 if (!(el instanceof HTMLElement)) return false; 4893 el.classList.remove("hidden"); 4894 4895 restoreHostedPanelIfAny(); 4896 const parent = el.parentElement; 4897 if (parent instanceof HTMLElement) mobileHostRestoreParentByPanelId.set(id, parent); 4898 mobileHostPanelId = id; 4899 mobileHostedPanelIds.clear(); 4900 mobileHostedPanelIds.add(id); 4901 mobileScreenHostEl.innerHTML = ""; 4902 mobileScreenHostEl.appendChild(el); 4903 return true; 4904 } 4905 4906 function hostHivesInMobileScreen() { 4907 if (!(mobileScreenHostEl instanceof HTMLElement)) return false; 4908 if (rackLayoutEnabled) { 4909 if (isDocked("hives")) undockPanel("hives"); 4910 applyDockState(); 4911 } 4912 const hivesEl = getPanelElement("hives"); 4913 if (!(hivesEl instanceof HTMLElement)) return false; 4914 4915 restoreHostedPanelIfAny(); 4916 4917 const hivesParent = hivesEl.parentElement; 4918 if (hivesParent instanceof HTMLElement) mobileHostRestoreParentByPanelId.set("hives", hivesParent); 4919 4920 mobileScreenHostEl.innerHTML = ""; 4921 hivesEl.classList.remove("hidden"); 4922 mobileScreenHostEl.appendChild(hivesEl); 4923 4924 mobileHostedPanelIds.clear(); 4925 mobileHostedPanelIds.add("hives"); 4926 mobileHostPanelId = "hives"; 4927 4928 return true; 4929 } 4930 4931 function setMobileScreen(screenId, { pushHistory = true } = {}) { 4932 if (!appRoot) return; 4933 const screen = mobileScreenFromLegacyPanel(screenId); 4934 if (onboardingNeedsAcceptanceNow() && screen !== "onboarding" && screen !== "account") { 4935 setMobileScreen("onboarding", { pushHistory: false }); 4936 return; 4937 } 4938 const nextIsMore = screen === "more"; 4939 if (nextIsMore) { 4940 setMobileMoreOpen(true); 4941 return; 4942 } 4943 4944 if (pushHistory) { 4945 const current = String(appRoot.getAttribute("data-mobile-screen") || "").trim(); 4946 if (current && current !== "more" && current !== screen) { 4947 const layout = loadMobileLayout(); 4948 layout.history = [current, ...(layout.history || [])].filter((x, idx, arr) => x && arr.indexOf(x) === idx).slice(0, 12); 4949 saveMobileLayout(layout); 4950 } 4951 } 4952 4953 setMobileMoreOpen(false); 4954 4955 // Core screens map directly. 4956 if (screen === "people") { 4957 setPeopleOpen(true); 4958 peopleDrawerEl?.classList.remove("hidden"); 4959 renderPeoplePanel(); 4960 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "peopleList" })); 4961 } else { 4962 setPeopleOpen(false); 4963 } 4964 4965 if (screen === "moderation" && !canModerate) { 4966 appRoot.setAttribute("data-mobile-screen", "hives"); 4967 return; 4968 } 4969 4970 if (screen === "account") { 4971 restoreHostedPanelIfAny(); 4972 appRoot.setAttribute("data-mobile-screen", screen); 4973 return; 4974 } 4975 4976 if (screen === "people") { 4977 const hosted = hostPanelInMobileScreen("people"); 4978 appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "people"); 4979 return; 4980 } 4981 4982 if (screen === "profile") { 4983 const target = String(activeProfileUsername || loggedInUser || "").trim().toLowerCase(); 4984 if (target) setCenterView("profile", target); 4985 else renderProfilePanel(); 4986 const hosted = hostPanelInMobileScreen("profile"); 4987 appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives"); 4988 return; 4989 } 4990 4991 if (screen === "onboarding") { 4992 const hosted = hostPanelInMobileScreen("onboarding"); 4993 appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives"); 4994 return; 4995 } 4996 4997 if (screen === "hives") { 4998 const hosted = hostHivesInMobileScreen(); 4999 appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives"); 5000 return; 5001 } 5002 5003 const hostableCorePanelId = screen === "chat" ? "chat" : screen === "moderation" ? "moderation" : ""; 5004 if (hostableCorePanelId) { 5005 const hosted = hostPanelInMobileScreen(hostableCorePanelId); 5006 appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives"); 5007 return; 5008 } 5009 5010 // Plugin screen: host it. 5011 const hosted = hostPanelInMobileScreen(screen); 5012 appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives"); 5013 } 5014 5015 function setMobilePanel(next) { 5016 if (!appRoot) return; 5017 // Back-compat shim: old callers still call setMobilePanel("chat"/"main"/etc). 5018 if (!isMobileScreenMode()) return; 5019 mobilePanel = mobileScreenFromLegacyPanel(next); 5020 setMobileScreen(mobilePanel, { pushHistory: true }); 5021 } 5022 5023 function applyMobileMode() { 5024 if (!appRoot) return; 5025 const wasMobile = appRoot.classList.contains("mobileScreens"); 5026 const mobile = isMobileScreenMode(); 5027 appRoot.classList.toggle("mobileScreens", mobile); 5028 if (mobileNavEl) mobileNavEl.classList.toggle("hidden", !mobile); 5029 if (mobile) stopAnyPanelResize(); 5030 5031 if (!mobile) { 5032 setMobileMoreOpen(false); 5033 restoreHostedPanelIfAny(); 5034 return; 5035 } 5036 5037 if (mobileFourthBtn instanceof HTMLElement) { 5038 mobileFourthBtn.textContent = "People"; 5039 mobileFourthBtn.setAttribute("data-mobilescreen", "people"); 5040 } 5041 5042 // Apply persisted layout only when entering mobile mode (avoid resetting state on keyboard/URL-bar resizes). 5043 const current = String(appRoot.getAttribute("data-mobile-screen") || "").trim(); 5044 if (!wasMobile || !current) { 5045 const layout = loadMobileLayout(); 5046 const desired = onboardingNeedsAcceptanceNow() ? "onboarding" : mobileScreenFromLegacyPanel(layout.active || "hives"); 5047 setMobileScreen(desired, { pushHistory: false }); 5048 } 5049 renderMobileNav(); 5050 if (mobileMoreOpen) renderMobileMoreList(); 5051 5052 if (!wasMobile) { 5053 if (canResizeSidebarNow()) applySidebarWidth(readStoredSidebarWidth(), false); 5054 if (canResizeChatNow()) applyChatWidth(readStoredChatWidth(), false); 5055 if (canResizeModNow()) applyModWidth(readStoredModWidth(), false); 5056 if (canResizePeopleNow()) applyPeopleWidth(readStoredPeopleWidth(), false); 5057 setComposerOpen(composerOpen); 5058 } 5059 } 5060 5061 function shiftMobilePanel(delta) { 5062 if (!isMobileScreenMode()) return; 5063 const order = canModerate 5064 ? ["account", "onboarding", "hives", "chat", "people", "profile", "moderation"] 5065 : ["account", "onboarding", "hives", "chat", "people", "profile"]; 5066 const current = mobileScreenFromLegacyPanel(appRoot?.getAttribute("data-mobile-screen") || "hives"); 5067 const idx = order.indexOf(current); 5068 const at = idx >= 0 ? idx : 0; 5069 const nextIdx = Math.max(0, Math.min(order.length - 1, at + delta)); 5070 setMobileScreen(order[nextIdx]); 5071 const layout = loadMobileLayout(); 5072 layout.active = order[nextIdx]; 5073 saveMobileLayout(layout); 5074 renderMobileNav(); 5075 } 5076 5077 function renderMobileNav() { 5078 if (!(mobileNavEl instanceof HTMLElement)) return; 5079 if (!appRoot) return; 5080 const active = String(appRoot.getAttribute("data-mobile-screen") || "hives").trim(); 5081 const buttons = Array.from(mobileNavEl.querySelectorAll("[data-mobilescreen]")); 5082 for (const btn of buttons) { 5083 const id = String(btn.getAttribute("data-mobilescreen") || "").trim(); 5084 const on = id !== "more" && (active === id || (active === "host" && id === mobileHostPanelId)); 5085 btn.classList.toggle("primary", on); 5086 btn.classList.toggle("ghost", !on); 5087 } 5088 } 5089 5090 function toast(title, body, timeoutMs = 2800) { 5091 const el = document.createElement("div"); 5092 el.className = "toast"; 5093 el.innerHTML = `<div class="toastTitle">${escapeHtml(title)}</div><div class="toastBody">${escapeHtml(body)}</div>`; 5094 toastHost.appendChild(el); 5095 setTimeout(() => el.remove(), timeoutMs); 5096 } 5097 5098 function sendDevLog(level, scope, message, data) { 5099 try { 5100 if (!canModerate) return false; 5101 const wsRef = window.__bzlWs; 5102 if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false; 5103 wsRef.send(JSON.stringify({ type: "devLogClient", level, scope, message, data })); 5104 return true; 5105 } catch { 5106 return false; 5107 } 5108 } 5109 5110 window.bzlDevLog = sendDevLog; 5111 5112 // Plugin event handlers: pluginId -> eventName -> Set<fn(msg)> 5113 const pluginClientHandlers = new Map(); 5114 // Moderation plugin tabs: fullTabId -> { title, ownerOnly, render(mount, api), pluginId } 5115 const modPluginTabs = new Map(); 5116 // Plugin panels by panelId (so mobile can render plugin screens even when rack layout is off). 5117 const pluginPanelDefsByPanelId = new Map(); 5118 5119 // Minimal plugin host (client-side). Plugins are trusted by the owner who installs them. 5120 // Plugin scripts can call `window.BzlPluginHost.register("pluginId", (ctx) => { ... })`. 5121 if (!window.BzlPluginHost) { 5122 const pluginInits = new Map(); 5123 window.BzlPluginHost = { 5124 apiVersion: 3, 5125 register(pluginId, initFn) { 5126 const id = String(pluginId || "").trim().toLowerCase(); 5127 if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(id)) throw new Error("Invalid plugin id"); 5128 if (typeof initFn !== "function") throw new Error("init must be a function"); 5129 if (pluginInits.has(id)) return false; 5130 pluginInits.set(id, initFn); 5131 try { 5132 initFn({ 5133 id, 5134 toast, 5135 getUser: () => loggedInUser, 5136 getRole: () => loggedInRole, 5137 on(eventName, handler) { 5138 const ev = String(eventName || "").trim(); 5139 if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) throw new Error("Invalid event name"); 5140 if (typeof handler !== "function") throw new Error("handler must be a function"); 5141 let byEvent = pluginClientHandlers.get(id); 5142 if (!byEvent) { 5143 byEvent = new Map(); 5144 pluginClientHandlers.set(id, byEvent); 5145 } 5146 let set = byEvent.get(ev); 5147 if (!set) { 5148 set = new Set(); 5149 byEvent.set(ev, set); 5150 } 5151 set.add(handler); 5152 return () => { 5153 try { 5154 set.delete(handler); 5155 } catch { 5156 // ignore 5157 } 5158 }; 5159 }, 5160 ui: { 5161 registerModTab(tabDef) { 5162 const tabId = String(tabDef?.id || id).trim().toLowerCase(); 5163 if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(tabId)) throw new Error("Invalid tab id"); 5164 const title = typeof tabDef?.title === "string" ? tabDef.title.trim().slice(0, 22) : tabId; 5165 const ownerOnly = Boolean(tabDef?.ownerOnly); 5166 const render = tabDef?.render; 5167 if (typeof render !== "function") throw new Error("render must be a function"); 5168 5169 const fullId = `plugin:${id}:${tabId}`; 5170 modPluginTabs.set(fullId, { title, ownerOnly, render, pluginId: id }); 5171 5172 const tabsEl = modPanelEl?.querySelector?.(".modTabs"); 5173 if (tabsEl && !tabsEl.querySelector(`[data-modtab="${CSS.escape(fullId)}"]`)) { 5174 const btn = document.createElement("button"); 5175 btn.type = "button"; 5176 btn.className = "ghost"; 5177 btn.textContent = title; 5178 btn.setAttribute("data-modtab", fullId); 5179 btn.dataset.ownerOnly = ownerOnly ? "1" : "0"; 5180 tabsEl.appendChild(btn); 5181 } 5182 5183 // If the tab isn't visible for this user, don't allow it to become active. 5184 if (ownerOnly && loggedInRole !== "owner" && modTab === fullId) { 5185 modTab = "server"; 5186 renderModPanel(); 5187 } 5188 return true; 5189 }, 5190 registerPanel(panelDef) { 5191 const panelId = String(panelDef?.id || id).trim().toLowerCase(); 5192 if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(panelId)) throw new Error("Invalid panel id"); 5193 const title = typeof panelDef?.title === "string" ? panelDef.title.trim().slice(0, 40) : panelId; 5194 const icon = typeof panelDef?.icon === "string" ? panelDef.icon.trim().slice(0, 10) : ""; 5195 const defaultRack = 5196 typeof panelDef?.defaultRack === "string" && /^(main|right)$/i.test(panelDef.defaultRack) 5197 ? panelDef.defaultRack.toLowerCase() 5198 : "right"; 5199 const role = 5200 typeof panelDef?.role === "string" && /^(primary|aux|transient|utility)$/i.test(panelDef.role) 5201 ? panelDef.role.toLowerCase() 5202 : "aux"; 5203 const source = `plugin:${id}`; 5204 const render = typeof panelDef?.render === "function" ? panelDef.render : null; 5205 5206 pluginPanelDefsByPanelId.set(panelId, { pluginId: id, panelId, title, icon, defaultRack, role, render }); 5207 5208 // Create a visible shell only when rack layout is enabled (for now). 5209 // Otherwise, plugins should continue using their existing DOM hooks. 5210 let element = null; 5211 if (rackLayoutEnabled) { 5212 const shell = ensurePluginPanelShell(panelId, title, icon, defaultRack, role); 5213 element = shell; 5214 const mount = shell ? shell.querySelector("[data-pluginmount]") : null; 5215 if (mount) { 5216 mount.innerHTML = ""; 5217 const api = { 5218 toast, 5219 send: (eventName, payload) => { 5220 const ev = String(eventName || "").trim(); 5221 if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false; 5222 const wsRef = window.__bzlWs; 5223 if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false; 5224 const msg = payload && typeof payload === "object" ? payload : {}; 5225 wsRef.send(JSON.stringify({ ...msg, type: `plugin:${id}:${ev}` })); 5226 return true; 5227 }, 5228 getUser: () => loggedInUser, 5229 getRole: () => loggedInRole, 5230 storage: { 5231 get(key) { 5232 try { 5233 return localStorage.getItem(`bzl_panel_${panelId}_${String(key || "")}`); 5234 } catch { 5235 return null; 5236 } 5237 }, 5238 set(key, value) { 5239 try { 5240 localStorage.setItem(`bzl_panel_${panelId}_${String(key || "")}`, String(value ?? "")); 5241 return true; 5242 } catch { 5243 return false; 5244 } 5245 }, 5246 }, 5247 }; 5248 try { 5249 const cleanup = render ? render(mount, api) : null; 5250 if (typeof cleanup === "function") { 5251 // Store cleanup on the shell so future hot-reload / uninstall can call it. 5252 shell.__panelCleanup = cleanup; 5253 } 5254 } catch (e) { 5255 console.warn(`Plugin ${id} panel render failed:`, e?.message || e); 5256 mount.textContent = `Failed to render panel "${panelId}".`; 5257 } 5258 } 5259 5260 enableRackDnD(); 5261 } 5262 5263 panelRegistry.set(panelId, { id: panelId, title, icon, source, role, defaultRack, element }); 5264 applyPluginPresetHint(panelDef); 5265 applyDockState(); 5266 syncRackStateFromDom(); 5267 return true; 5268 }, 5269 }, 5270 devLog: (level, message, data) => sendDevLog(level, `plugin:${id}`, message, data), 5271 send(eventName, payload) { 5272 const ev = String(eventName || "").trim(); 5273 if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false; 5274 const wsRef = window.__bzlWs; 5275 if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false; 5276 const msg = payload && typeof payload === "object" ? payload : {}; 5277 wsRef.send(JSON.stringify({ ...msg, type: `plugin:${id}:${ev}` })); 5278 return true; 5279 }, 5280 }); 5281 } catch (e) { 5282 console.warn(`Plugin ${id} init failed:`, e?.message || e); 5283 toast("Plugin error", `Failed to init "${id}".`); 5284 } 5285 return true; 5286 }, 5287 }; 5288 } 5289 5290 function renderTypingIndicator() { 5291 if (!typingIndicator) return; 5292 if (!activeChatPostId) { 5293 typingIndicator.textContent = ""; 5294 return; 5295 } 5296 const set = typingUsersByPostId.get(activeChatPostId); 5297 if (!set || set.size === 0) { 5298 typingIndicator.textContent = ""; 5299 return; 5300 } 5301 const names = Array.from(set.values()); 5302 let text = ""; 5303 if (names.length === 1) text = `@${names[0]} is typing`; 5304 else if (names.length === 2) text = `@${names[0]} and @${names[1]} are typing`; 5305 else text = `@${names[0]}, @${names[1]} and ${names.length - 2} others are typing`; 5306 typingIndicator.innerHTML = `${escapeHtml(text)} <span class="typingDots"><span>.</span><span>.</span><span>.</span></span>`; 5307 } 5308 5309 function highlightMentionsInText(text) { 5310 const escaped = escapeHtml(text || ""); 5311 if (!escaped) return ""; 5312 return escaped.replace(/(^|[\s(>])@([a-z0-9][a-z0-9_.-]{0,31})/gi, (full, lead, name) => { 5313 const normalized = String(name || "").toLowerCase(); 5314 const mine = loggedInUser && normalized === loggedInUser ? " mentionTokenMe" : ""; 5315 return `${lead}<span class="mentionToken${mine}">@${escapeHtml(name)}</span>`; 5316 }); 5317 } 5318 5319 function decorateMentionNodesInElement(rootEl) { 5320 if (!rootEl) return; 5321 const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT); 5322 const targets = []; 5323 for (let node = walker.nextNode(); node; node = walker.nextNode()) { 5324 const parent = node.parentElement; 5325 if (!parent) continue; 5326 if (parent.closest(".mentionToken")) continue; 5327 if (parent.closest("a")) continue; 5328 const text = String(node.nodeValue || ""); 5329 if (!/@[a-z0-9_][a-z0-9_.-]{0,31}/i.test(text)) continue; 5330 targets.push(node); 5331 } 5332 for (const node of targets) { 5333 const text = String(node.nodeValue || ""); 5334 const re = /(^|[\s(>])@([a-z0-9_][a-z0-9_.-]{0,31})/gi; 5335 let match; 5336 let last = 0; 5337 const frag = document.createDocumentFragment(); 5338 let changed = false; 5339 while ((match = re.exec(text))) { 5340 const start = match.index; 5341 const lead = match[1] || ""; 5342 const rawName = match[2] || ""; 5343 const mentionStart = start + lead.length; 5344 if (mentionStart > last) { 5345 frag.appendChild(document.createTextNode(text.slice(last, mentionStart))); 5346 } 5347 const normalized = String(rawName).toLowerCase(); 5348 const span = document.createElement("span"); 5349 span.className = `mentionToken${loggedInUser && normalized === loggedInUser ? " mentionTokenMe" : ""}`; 5350 span.textContent = `@${rawName}`; 5351 frag.appendChild(span); 5352 last = mentionStart + 1 + rawName.length; 5353 changed = true; 5354 } 5355 if (!changed) continue; 5356 if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last))); 5357 node.parentNode?.replaceChild(frag, node); 5358 } 5359 } 5360 5361 function youtubeVideoIdFromUrl(rawUrl) { 5362 const raw = String(rawUrl || "").trim(); 5363 if (!raw) return ""; 5364 const urlText = /^https?:\/\//i.test(raw) ? raw : `https://${raw.replace(/^\/+/, "")}`; 5365 let url; 5366 try { 5367 url = new URL(urlText); 5368 } catch { 5369 return ""; 5370 } 5371 5372 const host = String(url.hostname || "").toLowerCase(); 5373 const path = String(url.pathname || ""); 5374 const isYouTube = 5375 host === "youtu.be" || 5376 host.endsWith(".youtu.be") || 5377 host === "youtube.com" || 5378 host.endsWith(".youtube.com") || 5379 host === "youtube-nocookie.com" || 5380 host.endsWith(".youtube-nocookie.com"); 5381 if (!isYouTube) return ""; 5382 5383 let id = ""; 5384 if (host.includes("youtu.be")) { 5385 id = path.split("/").filter(Boolean)[0] || ""; 5386 } else { 5387 const v = url.searchParams.get("v"); 5388 if (v) id = v; 5389 if (!id) { 5390 const parts = path.split("/").filter(Boolean); 5391 if (parts[0] === "shorts") id = parts[1] || ""; 5392 if (!id && parts[0] === "embed") id = parts[1] || ""; 5393 } 5394 } 5395 5396 id = String(id || "").trim(); 5397 if (!/^[a-zA-Z0-9_-]{11}$/.test(id)) return ""; 5398 return id; 5399 } 5400 5401 function buildYouTubeEmbedEl(videoId) { 5402 const id = String(videoId || "").trim(); 5403 if (!/^[a-zA-Z0-9_-]{11}$/.test(id)) return null; 5404 const wrap = document.createElement("div"); 5405 wrap.className = "ytEmbed"; 5406 const iframe = document.createElement("iframe"); 5407 iframe.setAttribute("title", "YouTube video"); 5408 iframe.setAttribute("loading", "lazy"); 5409 iframe.setAttribute("allowfullscreen", "true"); 5410 iframe.setAttribute( 5411 "allow", 5412 "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 5413 ); 5414 iframe.setAttribute("referrerpolicy", "strict-origin-when-cross-origin"); 5415 iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-presentation allow-popups"); 5416 iframe.src = `https://www.youtube-nocookie.com/embed/${id}`; 5417 wrap.appendChild(iframe); 5418 return wrap; 5419 } 5420 5421 function decorateYouTubeEmbedsInElement(rootEl) { 5422 if (!rootEl) return; 5423 const existing = rootEl.querySelectorAll(".ytEmbed iframe[src*=\"youtube-nocookie.com/embed/\"]"); 5424 if (existing && existing.length) return; 5425 5426 const anchors = Array.from(rootEl.querySelectorAll("a[href]")); 5427 for (const a of anchors) { 5428 const href = a.getAttribute("href") || ""; 5429 const id = youtubeVideoIdFromUrl(href); 5430 if (!id) continue; 5431 const next = a.nextElementSibling; 5432 if (next && next.classList.contains("ytEmbed")) continue; 5433 const embed = buildYouTubeEmbedEl(id); 5434 if (!embed) continue; 5435 a.insertAdjacentElement("afterend", embed); 5436 } 5437 5438 const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT); 5439 const nodes = []; 5440 for (let node = walker.nextNode(); node; node = walker.nextNode()) { 5441 const parent = node.parentElement; 5442 if (!parent) continue; 5443 if (parent.closest("a")) continue; 5444 if (parent.closest(".ytEmbed")) continue; 5445 const text = String(node.nodeValue || ""); 5446 if (!/(youtu\.be\/|youtube\.com\/|youtube-nocookie\.com\/)/i.test(text)) continue; 5447 nodes.push(node); 5448 } 5449 5450 for (const node of nodes) { 5451 const text = String(node.nodeValue || ""); 5452 const re = /(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi; 5453 let match; 5454 let last = 0; 5455 const frag = document.createDocumentFragment(); 5456 let changed = false; 5457 while ((match = re.exec(text))) { 5458 const urlToken = String(match[0] || ""); 5459 const start = match.index; 5460 if (start > last) frag.appendChild(document.createTextNode(text.slice(last, start))); 5461 const id = youtubeVideoIdFromUrl(urlToken); 5462 if (!id) { 5463 frag.appendChild(document.createTextNode(urlToken)); 5464 last = start + urlToken.length; 5465 continue; 5466 } 5467 changed = true; 5468 const a = document.createElement("a"); 5469 const href = /^https?:\/\//i.test(urlToken) ? urlToken : `https://${urlToken}`; 5470 a.href = href; 5471 a.target = "_blank"; 5472 a.rel = "noopener noreferrer nofollow"; 5473 a.textContent = urlToken; 5474 frag.appendChild(a); 5475 frag.appendChild(buildYouTubeEmbedEl(id)); 5476 last = start + urlToken.length; 5477 } 5478 if (!changed) continue; 5479 if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last))); 5480 node.parentNode?.replaceChild(frag, node); 5481 } 5482 } 5483 5484 function findChatMessage(postId, messageId) { 5485 const list = chatByPost.get(postId) || []; 5486 return list.find((m) => m && m.id === messageId) || null; 5487 } 5488 5489 function setReplyToMessage(message) { 5490 replyToMessage = message || null; 5491 if (!chatReplyBanner || !chatReplyWho || !chatReplyText) return; 5492 if (!replyToMessage) { 5493 chatReplyBanner.classList.add("hidden"); 5494 chatReplyWho.textContent = ""; 5495 chatReplyText.textContent = ""; 5496 return; 5497 } 5498 chatReplyBanner.classList.remove("hidden"); 5499 const who = replyToMessage.fromUser ? `@${replyToMessage.fromUser}` : "unknown"; 5500 chatReplyWho.textContent = who; 5501 const text = String(replyToMessage.text || "").replace(/\s+/g, " ").trim(); 5502 chatReplyText.textContent = text ? `- ${text.slice(0, 96)}` : "- [media]"; 5503 } 5504 5505 function listMentionCandidates(query) { 5506 const q = String(query || "") 5507 .trim() 5508 .toLowerCase() 5509 .replace(/^@+/, ""); 5510 const list = Array.isArray(peopleMembers) && peopleMembers.length ? peopleMembers : fallbackPeopleFromProfiles(); 5511 const filtered = list 5512 .map((m) => String(m.username || "").toLowerCase()) 5513 .filter(Boolean) 5514 .filter((u) => (q ? u.includes(q) : true)) 5515 .slice(0, 8); 5516 return Array.from(new Set(filtered)); 5517 } 5518 5519 function getCaretRect() { 5520 const sel = window.getSelection(); 5521 if (!sel || sel.rangeCount === 0) return null; 5522 const range = sel.getRangeAt(0).cloneRange(); 5523 range.collapse(true); 5524 const rects = range.getClientRects(); 5525 if (rects && rects.length) return rects[0]; 5526 const node = range.startContainer && range.startContainer.parentElement ? range.startContainer.parentElement : null; 5527 return node ? node.getBoundingClientRect() : null; 5528 } 5529 5530 function renderMentionMenu() { 5531 if (!mentionMenuEl) return; 5532 const open = Boolean(mentionState.open && mentionState.items.length); 5533 mentionMenuEl.classList.toggle("hidden", !open); 5534 if (!open) { 5535 mentionMenuEl.innerHTML = ""; 5536 return; 5537 } 5538 const rect = mentionState.anchorRect || getCaretRect(); 5539 if (rect) { 5540 const top = Math.min(window.innerHeight - 180, rect.bottom + 6); 5541 const left = Math.min(window.innerWidth - 220, rect.left); 5542 mentionMenuEl.style.top = `${Math.max(10, top)}px`; 5543 mentionMenuEl.style.left = `${Math.max(10, left)}px`; 5544 } 5545 mentionMenuEl.innerHTML = mentionState.items 5546 .map((u, idx) => { 5547 const on = idx === mentionState.selected; 5548 return `<div class="mentionItem ${on ? "isOn" : ""}" role="option" data-mentionpick="${escapeHtml(u)}">@${escapeHtml(u)}</div>`; 5549 }) 5550 .join(""); 5551 } 5552 5553 mentionMenuEl?.addEventListener("mousedown", (e) => { 5554 const item = e.target.closest("[data-mentionpick]"); 5555 if (!item) return; 5556 e.preventDefault(); // keep focus in editor 5557 const picked = item.getAttribute("data-mentionpick") || ""; 5558 if (!picked) return; 5559 replaceCurrentMentionToken(picked); 5560 closeMentionMenu(); 5561 chatEditor?.focus(); 5562 }); 5563 5564 function closeMentionMenu() { 5565 mentionState = { open: false, query: "", selected: 0, items: [], anchorRect: null }; 5566 renderMentionMenu(); 5567 } 5568 5569 function replaceCurrentMentionToken(username) { 5570 const sel = window.getSelection(); 5571 if (!sel || sel.rangeCount === 0) return; 5572 const range = sel.getRangeAt(0); 5573 if (!range.collapsed) return; 5574 const node = range.startContainer; 5575 if (!node || node.nodeType !== Node.TEXT_NODE) return; 5576 const text = String(node.nodeValue || ""); 5577 const caret = range.startOffset; 5578 const before = text.slice(0, caret); 5579 const after = text.slice(caret); 5580 const atIndex = before.lastIndexOf("@"); 5581 if (atIndex < 0) return; 5582 const prefix = before.slice(0, atIndex); 5583 const next = `${prefix}@${username} ${after}`; 5584 node.nodeValue = next; 5585 const newOffset = (prefix + `@${username} `).length; 5586 const newRange = document.createRange(); 5587 newRange.setStart(node, Math.min(newOffset, node.nodeValue.length)); 5588 newRange.collapse(true); 5589 sel.removeAllRanges(); 5590 sel.addRange(newRange); 5591 } 5592 5593 function wsUrl() { 5594 const isHttps = location.protocol === "https:"; 5595 const proto = isHttps ? "wss:" : "ws:"; 5596 return `${proto}//${location.host}/ws`; 5597 } 5598 5599 function setConn(state) { 5600 if (state === "open") { 5601 connBadge.textContent = "Connected"; 5602 connBadge.className = "badge badge-good"; 5603 } else if (state === "closed") { 5604 connBadge.textContent = "Disconnected"; 5605 connBadge.className = "badge badge-bad"; 5606 } else { 5607 connBadge.textContent = "Connecting..."; 5608 connBadge.className = "badge badge-warn"; 5609 } 5610 } 5611 5612 function escapeHtml(str) { 5613 return String(str) 5614 .replaceAll("&", "&") 5615 .replaceAll("<", "<") 5616 .replaceAll(">", ">") 5617 .replaceAll('"', """) 5618 .replaceAll("'", "'"); 5619 } 5620 5621 function cssEscape(str) { 5622 const raw = String(str ?? ""); 5623 if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(raw); 5624 return raw.replace(/[^a-zA-Z0-9_-]/g, (m) => `\\${m}`); 5625 } 5626 5627 function parseKeywords(str) { 5628 if (!str) return []; 5629 const parts = str 5630 .split(",") 5631 .map((s) => s.trim().toLowerCase()) 5632 .filter(Boolean); 5633 return Array.from(new Set(parts)).slice(0, 6); 5634 } 5635 5636 function formatCountdown(expiresAt) { 5637 if (!Number(expiresAt || 0) || Number(expiresAt) <= 0) return "permanent"; 5638 const ms = expiresAt - Date.now(); 5639 if (ms <= 0) return "expired"; 5640 const totalSeconds = Math.floor(ms / 1000); 5641 const seconds = totalSeconds % 60; 5642 const totalMinutes = Math.floor(totalSeconds / 60); 5643 const minutes = totalMinutes % 60; 5644 const hours = Math.floor(totalMinutes / 60); 5645 if (hours > 0) return `${hours}h ${minutes}m`; 5646 if (minutes > 0) return `${minutes}m ${seconds}s`; 5647 return `${seconds}s`; 5648 } 5649 5650 function formatBoostRemaining(boostUntil) { 5651 const ms = boostUntil - Date.now(); 5652 if (ms <= 0) return ""; 5653 const totalSeconds = Math.floor(ms / 1000); 5654 const seconds = totalSeconds % 60; 5655 const totalMinutes = Math.floor(totalSeconds / 60); 5656 const minutes = totalMinutes % 60; 5657 const hours = Math.floor(totalMinutes / 60); 5658 if (hours > 0) return `${hours}h ${minutes}m`; 5659 if (minutes > 0) return `${minutes}m ${seconds}s`; 5660 return `${seconds}s`; 5661 } 5662 5663 function rankTime(post) { 5664 return Math.max(Number(post.lastActivityAt || post.createdAt || 0), Number(post.boostUntil || 0)); 5665 } 5666 5667 function normalizePrefs(raw) { 5668 const starred = Array.isArray(raw?.starredPostIds) ? raw.starredPostIds.filter((x) => typeof x === "string" && x) : []; 5669 const hidden = Array.isArray(raw?.hiddenPostIds) ? raw.hiddenPostIds.filter((x) => typeof x === "string" && x) : []; 5670 const ignored = Array.isArray(raw?.ignoredUsers) ? raw.ignoredUsers.filter((x) => typeof x === "string" && x) : []; 5671 const blocked = Array.isArray(raw?.blockedUsers) ? raw.blockedUsers.filter((x) => typeof x === "string" && x) : []; 5672 const cleanUsers = (list) => 5673 [...new Set(list.map((u) => String(u).trim().toLowerCase().replace(/^@+/, "")).filter(Boolean))].slice(0, 400); 5674 return { 5675 starredPostIds: [...new Set(starred)], 5676 hiddenPostIds: [...new Set(hidden)], 5677 ignoredUsers: cleanUsers(ignored), 5678 blockedUsers: cleanUsers(blocked), 5679 }; 5680 } 5681 5682 function setUserPrefs(raw) { 5683 userPrefs = normalizePrefs(raw || {}); 5684 if (!loggedInUser && activeHiveView !== "all") activeHiveView = "all"; 5685 } 5686 5687 function normalizeCollections(rawList) { 5688 const list = Array.isArray(rawList) ? rawList : []; 5689 const out = []; 5690 for (const item of list) { 5691 if (!item || typeof item !== "object") continue; 5692 const id = String(item.id || "").trim(); 5693 const name = String(item.name || "").trim(); 5694 if (!id || !name) continue; 5695 out.push({ 5696 id, 5697 name, 5698 order: Number(item.order || 0) || 0, 5699 archived: Boolean(item.archived), 5700 visibility: item.visibility === "gated" ? "gated" : "public", 5701 allowedRoles: Array.isArray(item.allowedRoles) ? item.allowedRoles.map((x) => String(x || "").toLowerCase()).filter(Boolean) : [] 5702 }); 5703 } 5704 out.sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || a.name.localeCompare(b.name)); 5705 return out; 5706 } 5707 5708 function activeCollections() { 5709 return collections.filter((c) => !c.archived); 5710 } 5711 5712 function ensureActiveCollectionView() { 5713 if (!String(activeHiveView).startsWith("collection:")) return; 5714 const id = String(activeHiveView).slice("collection:".length); 5715 if (!activeCollections().some((c) => c.id === id)) activeHiveView = "all"; 5716 } 5717 5718 function renderCollectionSelect() { 5719 if (!postCollectionEl) return; 5720 const list = activeCollections(); 5721 const opts = list.map((c) => `<option value="${escapeHtml(c.id)}">${escapeHtml(c.name)}</option>`).join(""); 5722 postCollectionEl.innerHTML = opts; 5723 if (!postCollectionEl.value && list.length) postCollectionEl.value = list[0].id; 5724 } 5725 5726 function normalizeRoleDefs(rawList) { 5727 const list = Array.isArray(rawList) ? rawList : []; 5728 const out = []; 5729 for (const item of list) { 5730 if (!item || typeof item !== "object") continue; 5731 const key = String(item.key || "").trim().toLowerCase(); 5732 const label = String(item.label || "").trim(); 5733 if (!key || !label) continue; 5734 out.push({ 5735 key, 5736 label, 5737 color: /^#[0-9a-f]{6}$/i.test(String(item.color || "")) ? String(item.color).toLowerCase() : "", 5738 order: Number(item.order || 0) || 0 5739 }); 5740 } 5741 out.sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || a.label.localeCompare(b.label)); 5742 return out; 5743 } 5744 5745 function normalizePlugins(rawList) { 5746 const list = Array.isArray(rawList) ? rawList : []; 5747 const out = []; 5748 for (const item of list) { 5749 if (!item || typeof item !== "object") continue; 5750 const id = String(item.id || "").trim().toLowerCase(); 5751 if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(id)) continue; 5752 out.push({ 5753 id, 5754 name: String(item.name || id).trim().slice(0, 64) || id, 5755 version: String(item.version || "0.0.0").trim().slice(0, 32), 5756 description: String(item.description || "").trim().slice(0, 240), 5757 enabled: Boolean(item.enabled), 5758 entryClient: String(item.entryClient || "").trim(), 5759 entryServer: String(item.entryServer || "").trim(), 5760 permissions: Array.isArray(item.permissions) 5761 ? item.permissions.filter((p) => typeof p === "string" && p.trim()).map((p) => p.trim().slice(0, 64)).slice(0, 24) 5762 : [], 5763 error: String(item.error || "").trim().slice(0, 280), 5764 }); 5765 } 5766 out.sort((a, b) => a.name.localeCompare(b.name)); 5767 return out; 5768 } 5769 5770 function isOwnerUser() { 5771 return Boolean(loggedInUser && loggedInRole === "owner"); 5772 } 5773 5774 function canManagePlugins() { 5775 return Boolean(loggedInUser && (loggedInRole === "owner" || loggedInRole === "moderator")); 5776 } 5777 5778 function renderPluginsAdminHtml() { 5779 if (!canManagePlugins()) return `<div class="muted small">Moderator/owner only.</div>`; 5780 const status = pluginAdminStatus ? `<div class="small muted">${escapeHtml(pluginAdminStatus)}</div>` : ""; 5781 const busyLine = pluginAdminBusy ? `<div class="small muted">Working...</div>` : ""; 5782 const listHtml = !plugins.length 5783 ? `<div class="muted small">No plugins installed yet.</div>` 5784 : plugins 5785 .map((p) => { 5786 const badges = []; 5787 if (p.entryClient) badges.push(`<span class="pluginBadge">client</span>`); 5788 if (p.entryServer) badges.push(`<span class="pluginBadge">server</span>`); 5789 for (const perm of p.permissions || []) badges.push(`<span class="pluginBadge">${escapeHtml(perm)}</span>`); 5790 const err = p.error ? `<div class="pluginError">${escapeHtml(p.error)}</div>` : ""; 5791 return `<div class="pluginRow"> 5792 <div class="pluginLeft"> 5793 <div class="pluginName">${escapeHtml(p.name)} <span class="muted small">v${escapeHtml(p.version)}</span></div> 5794 ${p.description ? `<div class="pluginDesc">${escapeHtml(p.description)}</div>` : ""} 5795 ${badges.length ? `<div class="pluginBadges">${badges.join("")}</div>` : ""} 5796 ${err} 5797 </div> 5798 <div class="pluginRight"> 5799 <label class="checkRow" style="justify-content:flex-end; gap:10px"> 5800 <span>Enabled</span> 5801 <input type="checkbox" data-pluginenable="${escapeHtml(p.id)}" ${p.enabled ? "checked" : ""} ${ 5802 pluginEnableInFlight.has(p.id) || pluginAdminBusy ? "disabled" : "" 5803 } /> 5804 </label> 5805 <button type="button" class="danger smallBtn" data-pluginuninstall="${escapeHtml(p.id)}">Uninstall</button> 5806 </div> 5807 </div>`; 5808 }) 5809 .join(""); 5810 return ` 5811 <div class="small muted">Moderator/owner only. Install optional plugins to extend your instance.</div> 5812 <div class="pluginInstallRow" style="margin-top:10px"> 5813 <input data-pluginzip="1" type="file" accept=".zip,application/zip" /> 5814 <button data-plugininstall="1" class="ghost" type="button">Install</button> 5815 <button data-pluginreload="1" class="ghost" type="button">Reload</button> 5816 </div> 5817 ${busyLine} 5818 ${status} 5819 <div class="pluginsList">${listHtml}</div> 5820 `; 5821 } 5822 5823 function ensureEnabledPluginClientScripts() { 5824 if (!Array.isArray(plugins) || !plugins.length) return; 5825 for (const p of plugins) { 5826 if (!p || !p.enabled) continue; 5827 if (!p.entryClient) continue; 5828 const wantVersion = String(p.version || "0"); 5829 const loadedVersion = loadedPluginClientVersionById.get(p.id) || ""; 5830 if (loadedVersion && loadedVersion === wantVersion) continue; 5831 const src = `/plugins/${encodeURIComponent(p.id)}/${p.entryClient}?v=${encodeURIComponent(p.version || "0")}`; 5832 const script = document.createElement("script"); 5833 script.src = src; 5834 script.defer = true; 5835 script.onload = () => { 5836 loadedPluginClientVersionById.set(p.id, wantVersion); 5837 }; 5838 script.onerror = () => { 5839 pluginAdminStatus = `Failed to load plugin "${p.id}".`; 5840 toast("Plugins", pluginAdminStatus); 5841 renderModPanel(); 5842 }; 5843 document.head.appendChild(script); 5844 } 5845 } 5846 5847 function setPlugins(rawList) { 5848 plugins = normalizePlugins(rawList); 5849 ensureEnabledPluginClientScripts(); 5850 if (canModerate && modTab === "server") renderModPanel(); 5851 } 5852 5853 function roleDefByKey(key) { 5854 return customRoles.find((r) => r.key === key) || null; 5855 } 5856 5857 function roleTokenLabel(token) { 5858 const t = String(token || ""); 5859 if (t === "owner" || t === "moderator" || t === "member") return t; 5860 if (t.startsWith("role:")) { 5861 const key = t.slice("role:".length); 5862 const found = roleDefByKey(key); 5863 return found ? found.label : key; 5864 } 5865 return t; 5866 } 5867 5868 function userCustomRoleKeys(username) { 5869 const member = (peopleMembers || []).find((m) => m && m.username === username); 5870 const keys = Array.isArray(member?.customRoles) ? member.customRoles : []; 5871 return keys.filter((x) => typeof x === "string" && x); 5872 } 5873 5874 function renderCustomRoleBadges(username) { 5875 const keys = userCustomRoleKeys(username); 5876 if (!keys.length) return ""; 5877 const parts = keys 5878 .map((key) => { 5879 const def = roleDefByKey(key); 5880 if (!def) return `<span class="modStatus">${escapeHtml(key)}</span>`; 5881 const style = def.color ? ` style="border-color:${escapeHtml(def.color)}66;color:${escapeHtml(def.color)}"` : ""; 5882 return `<span class="modStatus"${style}>${escapeHtml(def.label)}</span>`; 5883 }) 5884 .join(" "); 5885 return `<span class="customRoleRow">${parts}</span>`; 5886 } 5887 5888 function availableGateTokens() { 5889 const base = ["member", "moderator", "owner"]; 5890 const custom = customRoles.map((r) => `role:${r.key}`); 5891 return [...base, ...custom]; 5892 } 5893 5894 function prefSet(key) { 5895 return new Set(Array.isArray(userPrefs?.[key]) ? userPrefs[key] : []); 5896 } 5897 5898 function totalReactions(post) { 5899 const reactions = post?.reactions && typeof post.reactions === "object" ? post.reactions : {}; 5900 let total = 0; 5901 for (const count of Object.values(reactions)) total += Number(count || 0); 5902 return total; 5903 } 5904 5905 function sortPosts(list) { 5906 const mode = String(sortByEl?.value || "activity"); 5907 if (mode === "popular") { 5908 return list.sort((a, b) => totalReactions(b) - totalReactions(a) || rankTime(b) - rankTime(a) || b.createdAt - a.createdAt); 5909 } 5910 if (mode === "expiring") { 5911 const exp = (p) => { 5912 const t = Number(p?.expiresAt || 0) || 0; 5913 return t > 0 ? t : Number.MAX_SAFE_INTEGER; 5914 }; 5915 return list.sort((a, b) => exp(a) - exp(b) || rankTime(b) - rankTime(a)); 5916 } 5917 return list.sort((a, b) => rankTime(b) - rankTime(a) || b.createdAt - a.createdAt); 5918 } 5919 5920 function currentSortMode() { 5921 return String(sortByEl?.value || "activity"); 5922 } 5923 5924 function updateMobileSortCycleLabel() { 5925 if (!(mobileSortCycleBtn instanceof HTMLElement)) return; 5926 const mode = currentSortMode(); 5927 const label = mode === "popular" ? "Popular" : mode === "expiring" ? "Ending" : "Recent"; 5928 mobileSortCycleBtn.textContent = label; 5929 } 5930 5931 function getProfile(username) { 5932 if (!username) return { image: "", color: "" }; 5933 const p = profiles[username] || {}; 5934 return { image: p.image || "", color: p.color || "" }; 5935 } 5936 5937 function normalizeProfileLinks(list) { 5938 if (!Array.isArray(list)) return []; 5939 const out = []; 5940 for (const item of list) { 5941 if (!item || typeof item !== "object") continue; 5942 const label = String(item.label || "") 5943 .replace(/\s+/g, " ") 5944 .trim() 5945 .slice(0, 40); 5946 const url = String(item.url || "").trim().slice(0, 280); 5947 if (!/^https?:\/\//i.test(url)) continue; 5948 out.push({ label: label || "Link", url }); 5949 if (out.length >= 8) break; 5950 } 5951 return out; 5952 } 5953 5954 function normalizeProfileData(raw, fallbackUsername = "") { 5955 const username = String(raw?.username || fallbackUsername || "") 5956 .trim() 5957 .toLowerCase(); 5958 const image = typeof raw?.image === "string" ? raw.image : getProfile(username).image || ""; 5959 const colorRaw = typeof raw?.color === "string" ? raw.color : getProfile(username).color || ""; 5960 const color = /^#[0-9a-f]{6}$/i.test(colorRaw) ? colorRaw.toLowerCase() : ""; 5961 const pronouns = String(raw?.pronouns || "") 5962 .replace(/\s+/g, " ") 5963 .trim() 5964 .slice(0, 40); 5965 const bioHtml = typeof raw?.bioHtml === "string" ? raw.bioHtml : ""; 5966 const themeSongUrl = typeof raw?.themeSongUrl === "string" ? raw.themeSongUrl.trim() : ""; 5967 const links = normalizeProfileLinks(raw?.links); 5968 return { username, image, color, pronouns, bioHtml, themeSongUrl, links }; 5969 } 5970 5971 function asProfileLink(url) { 5972 const value = String(url || "").trim(); 5973 if (!/^https?:\/\//i.test(value)) return ""; 5974 return value; 5975 } 5976 5977 function renderUserPill(username) { 5978 if (!username) return `<span class="muted small">anon</span>`; 5979 const p = getProfile(username); 5980 const image = typeof p.image === "string" ? p.image : ""; 5981 const color = p.color && /^#[0-9a-f]{6}$/i.test(p.color) ? p.color : ""; 5982 const safeTextColor = color ? safeTextColorFromHex(color) : ""; 5983 const style = safeTextColor ? `style="color:${escapeHtml(safeTextColor)}"` : ""; 5984 const img = image ? `<img alt="" src="${escapeHtml(image)}" />` : ""; 5985 const extra = renderCustomRoleBadges(username); 5986 const normalized = String(username || "").trim().toLowerCase(); 5987 return `<button type="button" class="userPill userPillLink" data-viewprofile="${escapeHtml( 5988 normalized 5989 )}" title="View profile"><span class="pfp">${img}</span><span ${style}>@${escapeHtml(username)}</span>${extra}</button>`; 5990 } 5991 5992 function hexToRgb(hex) { 5993 const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex || ""); 5994 if (!m) return null; 5995 return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) }; 5996 } 5997 5998 function srgbToLinear(x) { 5999 const c = x / 255; 6000 if (c <= 0.04045) return c / 12.92; 6001 return Math.pow((c + 0.055) / 1.055, 2.4); 6002 } 6003 6004 function relativeLuminanceFromRgb(rgb) { 6005 if (!rgb) return 1; 6006 const r = srgbToLinear(rgb.r); 6007 const g = srgbToLinear(rgb.g); 6008 const b = srgbToLinear(rgb.b); 6009 return 0.2126 * r + 0.7152 * g + 0.0722 * b; 6010 } 6011 6012 function rgbToHex(rgb) { 6013 const clamp = (n) => Math.max(0, Math.min(255, Math.round(n))); 6014 const to2 = (n) => clamp(n).toString(16).padStart(2, "0"); 6015 return `#${to2(rgb.r)}${to2(rgb.g)}${to2(rgb.b)}`; 6016 } 6017 6018 function mixRgb(a, b, t) { 6019 const k = Math.max(0, Math.min(1, Number(t) || 0)); 6020 return { 6021 r: a.r + (b.r - a.r) * k, 6022 g: a.g + (b.g - a.g) * k, 6023 b: a.b + (b.b - a.b) * k, 6024 }; 6025 } 6026 6027 function safeTextColorFromHex(hex) { 6028 const rgb = hexToRgb(hex); 6029 if (!rgb) return ""; 6030 const baseLum = relativeLuminanceFromRgb(rgb); 6031 if (baseLum >= 0.38) return rgbToHex(rgb); 6032 const white = { r: 255, g: 255, b: 255 }; 6033 let best = rgb; 6034 for (let t = 0.10; t <= 0.85; t += 0.08) { 6035 const mixed = mixRgb(rgb, white, t); 6036 if (relativeLuminanceFromRgb(mixed) >= 0.42) { 6037 best = mixed; 6038 break; 6039 } 6040 best = mixed; 6041 } 6042 return rgbToHex(best); 6043 } 6044 6045 function tintStylesFromHex(hex) { 6046 const rgb = hexToRgb(hex); 6047 if (!rgb) return ""; 6048 const bg = `rgba(${rgb.r},${rgb.g},${rgb.b},0.10)`; 6049 const border = `rgba(${rgb.r},${rgb.g},${rgb.b},0.22)`; 6050 return `style="background:${bg};border-color:${border}"`; 6051 } 6052 6053 function cardTintStylesFromHex(hex) { 6054 const rgb = hexToRgb(hex); 6055 if (!rgb) return ""; 6056 const bg = `linear-gradient(180deg, rgba(${rgb.r},${rgb.g},${rgb.b},0.11), rgba(${rgb.r},${rgb.g},${rgb.b},0.03) 48%), var(--panel2)`; 6057 const border = `rgba(${rgb.r},${rgb.g},${rgb.b},0.34)`; 6058 const glow = `0 10px 24px rgba(${rgb.r},${rgb.g},${rgb.b},0.14)`; 6059 return `style="background:${bg};border-color:${border};box-shadow:${glow}"`; 6060 } 6061 6062 function matchesFilter(post, filterSet, authorQuery, hiddenSet, starredSet, ignoreUserSet, visibleCollectionIds) { 6063 if (visibleCollectionIds && !visibleCollectionIds.has(String(post.collectionId || ""))) return false; 6064 if (hiddenSet.has(post.id) && activeHiveView !== "hidden") return false; 6065 if (activeHiveView === "starred" && !starredSet.has(post.id)) return false; 6066 if (activeHiveView === "hidden" && !hiddenSet.has(post.id)) return false; 6067 const author = String(post.author || "").toLowerCase(); 6068 if (author && ignoreUserSet && ignoreUserSet.has(author) && (!loggedInUser || author !== String(loggedInUser).toLowerCase())) return false; 6069 if (String(activeHiveView).startsWith("collection:")) { 6070 const collectionId = String(activeHiveView).slice("collection:".length); 6071 if ((post.collectionId || "") !== collectionId) return false; 6072 } 6073 if (filterSet.size > 0) { 6074 let matched = false; 6075 for (const kw of post.keywords || []) { 6076 if (filterSet.has(kw)) { 6077 matched = true; 6078 break; 6079 } 6080 } 6081 if (!matched) return false; 6082 } 6083 if (authorQuery && !author.includes(authorQuery)) return false; 6084 return true; 6085 } 6086 6087 function postTitle(post) { 6088 if (post.locked) return "Protected post"; 6089 const text = String(post.title || post.content || "").replace(/\s+/g, " ").trim(); 6090 if (!text) return "(untitled)"; 6091 return text.length > 96 ? `${text.slice(0, 96)}...` : text; 6092 } 6093 6094 function myReactKey(kind, id, emoji) { 6095 return `${kind}:${id}:${emoji}`; 6096 } 6097 6098 function toggleMyReact(kind, id, emoji) { 6099 const key = myReactKey(kind, id, emoji); 6100 if (myReacts.has(key)) myReacts.delete(key); 6101 else myReacts.add(key); 6102 } 6103 6104 function markReactPulse(kind, id, emoji) { 6105 const key = myReactKey(kind, id, emoji); 6106 reactPulseByKey.set(key, Date.now()); 6107 } 6108 6109 function renderReactionButtons({ kind, id, reactions, postId }) { 6110 if (!showReactions) return ""; 6111 const r = reactions && typeof reactions === "object" ? reactions : {}; 6112 const emojis = kind === "post" ? allowedPostReactions : allowedChatReactions; 6113 return `<div class="reactionsRow"> 6114 ${emojis 6115 .map((emoji) => { 6116 const count = Number(r[emoji] || 0); 6117 const key = myReactKey(kind, id, emoji); 6118 const isOn = myReacts.has(key); 6119 const pulseAt = reactPulseByKey.get(key) || 0; 6120 const pulse = pulseAt && Date.now() - pulseAt < 650; 6121 if (pulseAt && !pulse) reactPulseByKey.delete(key); 6122 const cls = `${isOn ? "reactBtn isOn" : "reactBtn"}${pulse ? " pulse" : ""}`; 6123 const attrs = 6124 kind === "post" 6125 ? `data-react="1" data-kind="post" data-postid="${escapeHtml(id)}" data-emoji="${escapeHtml(emoji)}"` 6126 : `data-react="1" data-kind="chat" data-postid="${escapeHtml(postId || "")}" data-msgid="${escapeHtml( 6127 id 6128 )}" data-emoji="${escapeHtml(emoji)}"`; 6129 return `<span class="${cls}" ${attrs}>${escapeHtml(emoji)} <span class="count">${count || ""}</span></span>`; 6130 }) 6131 .join("")} 6132 </div>`; 6133 } 6134 6135 function markRead(postId) { 6136 if (!postId) return; 6137 unreadByPostId.delete(postId); 6138 } 6139 6140 function bumpUnread(postId) { 6141 const current = unreadByPostId.get(postId) || 0; 6142 unreadByPostId.set(postId, Math.min(99, current + 1)); 6143 } 6144 6145 function notifSupported() { 6146 return typeof window.Notification !== "undefined"; 6147 } 6148 6149 function notifState() { 6150 if (!notifSupported()) return "unsupported"; 6151 return Notification.permission; // default | denied | granted 6152 } 6153 6154 function updateNotifUi() { 6155 if (!enableNotifsBtn || !notifStatus) return; 6156 const state = notifState(); 6157 const secure = location.protocol === "https:"; 6158 const hint = secure ? "" : " (requires HTTPS: use tunnel)"; 6159 6160 if (state === "unsupported") { 6161 enableNotifsBtn.classList.add("hidden"); 6162 notifStatus.textContent = "Notifications not supported in this browser."; 6163 return; 6164 } 6165 6166 enableNotifsBtn.classList.remove("hidden"); 6167 if (!secure) { 6168 enableNotifsBtn.disabled = true; 6169 notifStatus.textContent = `Notifications disabled on HTTP${hint}.`; 6170 return; 6171 } 6172 6173 enableNotifsBtn.disabled = state === "granted"; 6174 enableNotifsBtn.textContent = state === "granted" ? "Notifications enabled" : "Enable notifications"; 6175 notifStatus.textContent = 6176 state === "granted" ? "You'll get pings when activity happens." : state === "denied" ? "Blocked in browser settings." : ""; 6177 } 6178 6179 function maybeNotify(title, body, data) { 6180 if (notifState() !== "granted") return; 6181 if (windowFocused && !document.hidden) return; 6182 try { 6183 const n = new Notification(title, { body, data }); 6184 n.onclick = () => { 6185 window.focus(); 6186 if (data?.postId) openChat(data.postId); 6187 if (data?.threadId) openDmThread(data.threadId); 6188 n.close(); 6189 }; 6190 } catch { 6191 // ignore 6192 } 6193 } 6194 6195 function renderLanHint() { 6196 if (!lanHint) return; 6197 if (!canModerate || !Array.isArray(lanUrls) || lanUrls.length === 0) { 6198 lanHint.textContent = ""; 6199 return; 6200 } 6201 lanHint.innerHTML = `LAN: <span class="muted">${lanUrls.map(escapeHtml).join(" | ")}</span>`; 6202 } 6203 6204 function renderFeed() { 6205 const filter = parseKeywords(filterKeywordsEl.value); 6206 const filterSet = new Set(filter); 6207 const authorQuery = String(filterAuthorEl?.value || "") 6208 .trim() 6209 .replace(/^@+/, "") 6210 .toLowerCase(); 6211 ensureActiveCollectionView(); 6212 const hiddenSet = prefSet("hiddenPostIds"); 6213 const starredSet = prefSet("starredPostIds"); 6214 const ignoreUserSet = new Set([...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase())); 6215 const visibleCollectionIds = new Set(activeCollections().map((c) => c.id)); 6216 if (!loggedInUser && activeHiveView !== "all") activeHiveView = "all"; 6217 if (hiveTabsEl) { 6218 const collectionTabs = activeCollections() 6219 .map((c) => { 6220 const view = `collection:${c.id}`; 6221 const on = view === activeHiveView; 6222 const cls = on ? "primary" : "ghost"; 6223 return `<button type="button" data-hiveview="${escapeHtml(view)}" class="${cls}">${escapeHtml(c.name)}</button>`; 6224 }) 6225 .join(""); 6226 const allOn = activeHiveView === "all"; 6227 const starredOn = activeHiveView === "starred"; 6228 const hiddenOn = activeHiveView === "hidden"; 6229 hiveTabsEl.innerHTML = ` 6230 <button type="button" data-hiveview="all" class="${allOn ? "primary" : "ghost"}">All</button> 6231 ${collectionTabs} 6232 <button type="button" data-hiveview="starred" class="${starredOn ? "primary" : "ghost"}" ${loggedInUser ? "" : "disabled"}>Starred</button> 6233 <button type="button" data-hiveview="hidden" class="${hiddenOn ? "primary" : "ghost"}" ${loggedInUser ? "" : "disabled"}>Hidden</button> 6234 `; 6235 } 6236 6237 const list = sortPosts(Array.from(posts.values())).filter((p) => 6238 matchesFilter(p, filterSet, authorQuery, hiddenSet, starredSet, ignoreUserSet, visibleCollectionIds) 6239 ); 6240 6241 if (list.length === 0) { 6242 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>`; 6243 return; 6244 } 6245 6246 feedEl.innerHTML = list 6247 .map((p) => { 6248 const tags = (p.keywords || []).map((k) => `<span class="tag">#${escapeHtml(k)}</span>`).join(""); 6249 const collectionName = activeCollections().find((c) => c.id === p.collectionId)?.name || "General"; 6250 const collectionTag = `<span class="tag">/${escapeHtml(collectionName)}</span>`; 6251 const postedLine = `<div class="small muted">posted ${escapeHtml(new Date(p.createdAt).toLocaleString())}</div>`; 6252 const editedLine = 6253 Number(p.editCount || 0) > 0 6254 ? `<div class="small muted">edited (${Number(p.editCount || 0)}) at ${escapeHtml( 6255 new Date(Number(p.editedAt || p.createdAt)).toLocaleString() 6256 )}</div>` 6257 : ""; 6258 const deletedLine = p.deleted 6259 ? `<div class="small muted">Post was deleted${ 6260 p.deletedBy ? ` by @${escapeHtml(p.deletedBy)}` : "" 6261 } at ${escapeHtml(new Date(Number(p.deletedAt || Date.now())).toLocaleString())}${ 6262 p.deleteReason ? ` (${escapeHtml(p.deleteReason)})` : "" 6263 }</div>` 6264 : ""; 6265 const authorLine = p.author ? `<div class="small postAuthor">${renderUserPill(p.author)}</div>` : ""; 6266 const boostText = formatBoostRemaining(Number(p.boostUntil || 0)); 6267 const boostLine = boostText ? `<div class="countdown boost" data-boost="${p.id}">boost ${boostText}</div>` : ""; 6268 6269 const canBoost = Boolean(loggedInUser && !p.locked && !p.deleted && p.author && loggedInUser !== p.author); 6270 const canManageOwnPost = Boolean(loggedInUser && !p.locked && !p.deleted && p.author && loggedInUser === p.author); 6271 const boostControls = canBoost 6272 ? `<div class="boostRow"> 6273 <select data-boostsel="${p.id}"> 6274 <option value="300000">+5m</option> 6275 <option value="900000">+15m</option> 6276 <option value="1800000">+30m</option> 6277 <option value="3600000" selected>+1h</option> 6278 <option value="7200000">+2h</option> 6279 </select> 6280 <button type="button" data-boostbtn="${p.id}">Boost</button> 6281 </div>` 6282 : ""; 6283 6284 const reactionsHtml = p.locked || p.deleted ? "" : renderReactionButtons({ kind: "post", id: p.id, reactions: p.reactions || {} }); 6285 const isHidden = hiddenSet.has(p.id); 6286 const menuItems = ` 6287 ${canManageOwnPost ? `<button type="button" class="ghost" data-editpost="${p.id}">Edit</button>` : ""} 6288 ${canManageOwnPost ? `<button type="button" class="ghost danger" data-deletepost="${p.id}">Delete</button>` : ""} 6289 ${loggedInUser ? `<button type="button" class="ghost" data-hidepost="${p.id}">${isHidden ? "Unhide" : "Hide"}</button>` : ""} 6290 ${loggedInUser && !p.deleted ? `<button type="button" class="ghost" data-reportpost="${p.id}">Report</button>` : ""} 6291 `.trim(); 6292 const hasMenu = Boolean(menuItems); 6293 const kebabBtn = hasMenu 6294 ? `<button type="button" class="ghost smallBtn kebabBtn" data-postmenu="${p.id}" aria-haspopup="menu" aria-expanded="false" title="More">⋮</button>` 6295 : ""; 6296 const postMenu = hasMenu 6297 ? `<div class="postMenu hidden" role="menu" data-postmenu-panel="${p.id}">${menuItems}</div>` 6298 : ""; 6299 6300 const unread = unreadByPostId.get(p.id) || 0; 6301 const unreadDot = unread ? `<span class="badgeDot" title="${unread} unread"></span>` : ""; 6302 const unreadClass = unread ? " isUnread" : ""; 6303 const newClass = newPostAnimIds.has(p.id) ? " isNew" : ""; 6304 const buzzClass = buzzTimers.has(p.id) ? " isBuzz" : ""; 6305 const lockLine = p.locked ? `<div class="small muted">π password protected</div>` : ""; 6306 const cardTint = p.author ? cardTintStylesFromHex(getProfile(p.author).color) : ""; 6307 const contentHtml = typeof p.contentHtml === "string" && p.contentHtml.trim() ? p.contentHtml : ""; 6308 const contentText = typeof p.content === "string" && p.content.trim() ? escapeHtml(p.content) : ""; 6309 const content = contentHtml ? contentHtml : contentText ? `<div class="muted">${contentText}</div>` : ""; 6310 const contentBlock = content ? `<div class="postContent">${content}</div>` : ""; 6311 const lastChat = (chatByPost.get(p.id) || []).filter((m) => !m?.deleted).slice(-1)[0] || null; 6312 const lastChatFrom = lastChat ? String(lastChat.fromUser || "").trim() : ""; 6313 const lastChatText = lastChat ? String(lastChat.text || "").replace(/\s+/g, " ").trim().slice(0, 92) : ""; 6314 const lastChatWho = lastChat 6315 ? (lastChatFrom && lastChatFrom.toLowerCase() === "mod" ? "MOD" : `@${escapeHtml(lastChatFrom || "unknown")}`) 6316 : ""; 6317 const lastChatLine = lastChat 6318 ? `<div class="small muted postLastChat">Last chat: ${lastChatWho}${lastChatText ? ` β ${escapeHtml(lastChatText)}` : ""}</div>` 6319 : ""; 6320 const typersSet = typingUsersByPostId.get(p.id); 6321 const typingUsers = typersSet ? Array.from(typersSet.values()).slice(0, 2) : []; 6322 const typingMore = typersSet && typersSet.size > typingUsers.length ? ` +${typersSet.size - typingUsers.length}` : ""; 6323 const typingLine = typingUsers.length 6324 ? `<div class="small muted postTypingLine">${typingUsers.map((u) => `@${escapeHtml(u)}`).join(", ")}${typingMore} typing...</div>` 6325 : ""; 6326 6327 return ` 6328 <article class="post${unreadClass}${newClass}${buzzClass}" data-id="${p.id}" ${cardTint}> 6329 <div class="postTop"> 6330 <div class="postTitleRow"> 6331 <div class="postTitle">${escapeHtml(postTitle(p))}</div> 6332 ${postedLine} 6333 ${authorLine} 6334 ${lockLine} 6335 </div> 6336 <div class="rightCol"> 6337 ${unreadDot} 6338 <div class="countdown" data-countdown="${p.id}">${formatCountdown(p.expiresAt)}</div> 6339 ${boostLine} 6340 ${boostControls} 6341 <div class="postActionsRow"> 6342 <button type="button" data-chat="${p.id}">${p.locked ? "Unlock" : p.deleted ? "View" : "Chat"}</button> 6343 ${kebabBtn} 6344 ${postMenu} 6345 </div> 6346 </div> 6347 </div> 6348 ${deletedLine} 6349 ${editedLine} 6350 ${contentBlock} 6351 ${typingLine} 6352 ${lastChatLine} 6353 <div class="postMeta">${collectionTag}${tags ? ` ${tags}` : ""}</div> 6354 ${reactionsHtml} 6355 </article>`; 6356 }) 6357 .join(""); 6358 6359 try { 6360 feedEl.querySelectorAll?.(".postContent").forEach((el) => decorateYouTubeEmbedsInElement(el)); 6361 } catch { 6362 // ignore 6363 } 6364 } 6365 6366 function isMobileChatScreenActive() { 6367 if (!isMobileScreenMode() || !appRoot) return false; 6368 const screen = String(appRoot.getAttribute("data-mobile-screen") || "").trim(); 6369 return screen === "chat" || (screen === "host" && mobileHostPanelId === "chat"); 6370 } 6371 6372 function renderMobileChatListHtml() { 6373 const dmActive = activeDmThreadsSorted().slice(0, 30); 6374 const recentPostIds = recentHiveChatIds.slice(0, 24); 6375 const recentPosts = recentPostIds.map((id) => posts.get(id)).filter((p) => p && !p.deleted); 6376 const recentPostIdSet = new Set(recentPosts.map((p) => String(p.id))); 6377 const availablePosts = sortPosts(Array.from(posts.values())) 6378 .filter((p) => p && !p.deleted && !recentPostIdSet.has(String(p.id))) 6379 .slice(0, 60); 6380 6381 if (!dmActive.length && !recentPosts.length && !availablePosts.length) { 6382 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>`; 6383 } 6384 6385 const dmSection = dmActive.length 6386 ? `<div class="mobileChatSection"> 6387 <div class="small muted">DMs</div> 6388 ${dmActive 6389 .map((t) => { 6390 const who = `@${escapeHtml(String(t.other || "unknown"))}`; 6391 const when = dmActivityAt(t) ? new Date(dmActivityAt(t)).toLocaleTimeString() : "active"; 6392 return `<button type="button" class="ghost mobileChatListItem" data-dmopen="${escapeHtml(t.id)}"> 6393 <span class="mobileChatListTop">${who}</span> 6394 <span class="mobileChatListMeta">private chat Β· ${escapeHtml(when)}</span> 6395 </button>`; 6396 }) 6397 .join("")} 6398 </div>` 6399 : ""; 6400 6401 const postItem = (p) => { 6402 const title = escapeHtml(postTitle(p)); 6403 const author = p.author ? `@${escapeHtml(String(p.author || ""))}` : "anon"; 6404 const exp = formatCountdown(p.expiresAt); 6405 const lock = p.locked ? " Β· locked" : ""; 6406 return `<button type="button" class="ghost mobileChatListItem" data-mobilechatopen="${escapeHtml(p.id)}"> 6407 <span class="mobileChatListTop">${title}</span> 6408 <span class="mobileChatListMeta">${author} Β· ${escapeHtml(exp)}${lock}</span> 6409 </button>`; 6410 }; 6411 6412 const recentSection = recentPosts.length 6413 ? `<div class="mobileChatSection"> 6414 <div class="small muted">Recent Hive Chats</div> 6415 ${recentPosts.map(postItem).join("")} 6416 </div>` 6417 : ""; 6418 6419 const hivesSection = availablePosts.length 6420 ? `<div class="mobileChatSection"> 6421 <div class="small muted">Available Hives</div> 6422 ${availablePosts.map(postItem).join("")} 6423 </div>` 6424 : ""; 6425 6426 return `<div class="mobileChatList">${dmSection}${recentSection}${hivesSection}</div>`; 6427 } 6428 6429 function onboardingRequiresAcceptance() { 6430 return Boolean(onboardingState.enabled && onboardingState.requireAcceptance); 6431 } 6432 6433 function onboardingNeedsAcceptanceNow() { 6434 if (!onboardingRequiresAcceptance()) return false; 6435 return Boolean(onboardingState.needsAcceptance || Number(onboardingState.acceptedRulesVersion || 0) < Number(onboardingState.rulesVersion || 1)); 6436 } 6437 6438 function onboardingSeverityLabel(severity) { 6439 const s = String(severity || "").toLowerCase(); 6440 if (s === "critical") return "Critical"; 6441 if (s === "warn") return "Warn"; 6442 return "Info"; 6443 } 6444 6445 function onboardingSeverityBadge(severity) { 6446 const s = String(severity || "info").toLowerCase(); 6447 const cls = s === "critical" ? "onbSeverityCritical" : s === "warn" ? "onbSeverityWarn" : "onbSeverityInfo"; 6448 return `<span class="tag ${cls}">${escapeHtml(onboardingSeverityLabel(s))}</span>`; 6449 } 6450 6451 function onboardingRuleListFromConfig(cfg) { 6452 const list = Array.isArray(cfg?.rules?.items) ? cfg.rules.items : []; 6453 return list 6454 .map((r, index) => ({ 6455 id: String(r?.id || `r${index + 1}`).trim().slice(0, 40) || `r${index + 1}`, 6456 order: Number.isFinite(Number(r?.order)) ? Math.max(1, Math.floor(Number(r.order))) : index + 1, 6457 name: String(r?.name || "").trim().slice(0, 60) || `Rule ${index + 1}`, 6458 shortDescription: String(r?.shortDescription || "").trim().slice(0, 180), 6459 description: String(r?.description || "").slice(0, 6000), 6460 severity: ["info", "warn", "critical"].includes(String(r?.severity || "").toLowerCase()) 6461 ? String(r.severity).toLowerCase() 6462 : "info", 6463 })) 6464 .sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || String(a.id || "").localeCompare(String(b.id || ""))); 6465 } 6466 6467 function onboardingDraftStampFromConfig(cfg) { 6468 return JSON.stringify({ 6469 enabled: Boolean(cfg?.enabled), 6470 aboutUpdatedAt: Number(cfg?.about?.updatedAt || 0), 6471 rulesVersion: Number(cfg?.rules?.version || 1), 6472 itemCount: Array.isArray(cfg?.rules?.items) ? cfg.rules.items.length : 0, 6473 roleSelectEnabled: Boolean(cfg?.roleSelect?.enabled), 6474 selfAssignableCount: Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds) ? cfg.roleSelect.selfAssignableRoleIds.length : 0, 6475 }); 6476 } 6477 6478 function syncOnboardingAdminDraft(force = false) { 6479 const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {}; 6480 const stamp = onboardingDraftStampFromConfig(cfg); 6481 if (!force && stamp === onboardingAdminDraftStamp) return; 6482 onboardingAdminDraft = { 6483 enabled: Boolean(cfg?.enabled), 6484 aboutContent: String(cfg?.about?.content || ""), 6485 requireAcceptance: Boolean(cfg?.rules?.requireAcceptance), 6486 blockReadUntilAccepted: Boolean(cfg?.rules?.blockReadUntilAccepted), 6487 roleSelectEnabled: Boolean(cfg?.roleSelect?.enabled), 6488 selfAssignableRoleIds: Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds) 6489 ? cfg.roleSelect.selfAssignableRoleIds.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean) 6490 : [], 6491 rules: onboardingRuleListFromConfig(cfg), 6492 }; 6493 onboardingAdminDraftStamp = stamp; 6494 onboardingAdminExpandedRuleIds.clear(); 6495 if (onboardingAdminDraft.rules[0]?.id) onboardingAdminExpandedRuleIds.add(onboardingAdminDraft.rules[0].id); 6496 } 6497 6498 function normalizeOnboardingDraftRules() { 6499 onboardingAdminDraft.rules = (Array.isArray(onboardingAdminDraft.rules) ? onboardingAdminDraft.rules : []) 6500 .map((r, index) => ({ 6501 id: String(r?.id || `r${index + 1}`).trim().slice(0, 40) || `r${index + 1}`, 6502 order: index + 1, 6503 name: String(r?.name || "").trim().slice(0, 60) || `Rule ${index + 1}`, 6504 shortDescription: String(r?.shortDescription || "").trim().slice(0, 180), 6505 description: String(r?.description || "").slice(0, 6000), 6506 severity: ["info", "warn", "critical"].includes(String(r?.severity || "").toLowerCase()) 6507 ? String(r.severity).toLowerCase() 6508 : "info", 6509 })) 6510 .slice(0, 200); 6511 } 6512 6513 function renderOnboardingPanel() { 6514 if (!(onboardingPanelEl instanceof HTMLElement) || !(onboardingPanelBodyEl instanceof HTMLElement)) return; 6515 const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {}; 6516 if (!cfg.enabled) { 6517 onboardingPanelEl.classList.add("hidden"); 6518 onboardingPanelBodyEl.innerHTML = `<div class="small muted">Onboarding is disabled for this server.</div>`; 6519 if (onboardingPanelAcceptBtn instanceof HTMLButtonElement) onboardingPanelAcceptBtn.classList.add("hidden"); 6520 return; 6521 } 6522 6523 onboardingPanelEl.classList.remove("hidden"); 6524 const needs = onboardingNeedsAcceptanceNow(); 6525 const rules = onboardingRuleListFromConfig(cfg); 6526 const about = typeof cfg?.about?.content === "string" ? cfg.about.content.trim() : ""; 6527 const roleIds = Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds) ? cfg.roleSelect.selfAssignableRoleIds : []; 6528 const roleItems = roleIds 6529 .map((key) => customRoles.find((r) => String(r?.key || "") === String(key))) 6530 .filter(Boolean) 6531 .map((r) => `<span class="tag">${escapeHtml(String(r.label || r.key || ""))}</span>`) 6532 .join(" "); 6533 6534 onboardingPanelBodyEl.innerHTML = ` 6535 <div class="onbTabs"> 6536 <button type="button" class="${onboardingViewerTab === "about" ? "primary" : "ghost"} smallBtn" data-onbtab="about">About</button> 6537 <button type="button" class="${onboardingViewerTab === "rules" ? "primary" : "ghost"} smallBtn" data-onbtab="rules">Rules</button> 6538 <button type="button" class="${onboardingViewerTab === "roles" ? "primary" : "ghost"} smallBtn" data-onbtab="roles">Roles</button> 6539 </div> 6540 ${ 6541 onboardingViewerTab === "about" 6542 ? about 6543 ? `<div class="onboardingAbout">${about}</div>` 6544 : `<div class="small muted">No About content published yet.</div>` 6545 : onboardingViewerTab === "rules" 6546 ? rules.length 6547 ? `<div class="onbRuleList">${rules 6548 .map( 6549 (r) => `<article class="onbRuleViewerCard"> 6550 <div class="row" style="justify-content:space-between;align-items:center;"> 6551 <b>${escapeHtml(r.name || "Rule")}</b> 6552 ${onboardingSeverityBadge(r.severity)} 6553 </div> 6554 ${r.shortDescription ? `<div class="small muted">${escapeHtml(r.shortDescription)}</div>` : ""} 6555 ${r.description ? `<div class="small">${r.description}</div>` : ""} 6556 </article>` 6557 ) 6558 .join("")}</div>` 6559 : `<div class="small muted">No rules configured.</div>` 6560 : cfg?.roleSelect?.enabled 6561 ? roleItems 6562 ? `<div class="row" style="flex-wrap:wrap;gap:8px;">${roleItems}</div>` 6563 : `<div class="small muted">No self-assignable roles configured.</div>` 6564 : `<div class="small muted">Role select is disabled.</div>` 6565 } 6566 <div class="small ${needs ? "badText" : "goodText"}" style="margin-top:10px;"> 6567 ${ 6568 onboardingRequiresAcceptance() 6569 ? needs 6570 ? "Rules acceptance required before posting/chat." 6571 : `Rules accepted${onboardingState.acceptedAt ? ` at ${escapeHtml(formatLocalTime(onboardingState.acceptedAt))}` : "."}` 6572 : "Rules acceptance is optional on this server." 6573 } 6574 </div>`; 6575 6576 if (onboardingPanelAcceptBtn instanceof HTMLButtonElement) { 6577 onboardingPanelAcceptBtn.classList.toggle("hidden", !onboardingRequiresAcceptance()); 6578 onboardingPanelAcceptBtn.disabled = !loggedInUser || !needs; 6579 onboardingPanelAcceptBtn.textContent = needs ? "Accept and continue" : "Accepted"; 6580 } 6581 } 6582 6583 function renderOnboardingCard() { 6584 if (!(onboardingCard instanceof HTMLElement) || !(onboardingBody instanceof HTMLElement)) return; 6585 // Onboarding now lives as a first-class workspace panel; keep the old account card hidden. 6586 onboardingCard.classList.add("hidden"); 6587 onboardingBody.innerHTML = ""; 6588 if (onboardingAcceptBtn instanceof HTMLButtonElement) { 6589 onboardingAcceptBtn.classList.add("hidden"); 6590 onboardingAcceptBtn.disabled = true; 6591 } 6592 renderOnboardingPanel(); 6593 return; 6594 6595 const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {}; 6596 if (!cfg.enabled) { 6597 onboardingCard.classList.add("hidden"); 6598 onboardingBody.innerHTML = ""; 6599 return; 6600 } 6601 onboardingCard.classList.remove("hidden"); 6602 const needs = onboardingNeedsAcceptanceNow(); 6603 const rules = onboardingRuleListFromConfig(cfg).slice(0, 6); 6604 const about = typeof cfg?.about?.content === "string" ? cfg.about.content.trim() : ""; 6605 const aboutBlock = about ? `<div class="onboardingAbout">${about}</div>` : `<div class="small muted">No About text set yet.</div>`; 6606 const rulesBlock = rules.length 6607 ? `<ol class="onboardingRules">${rules 6608 .map( 6609 (r) => 6610 `<li><b>${escapeHtml(r.name || "Rule")}</b>${r.shortDescription ? `<div class="small muted">${escapeHtml(r.shortDescription)}</div>` : ""}</li>` 6611 ) 6612 .join("")}</ol>` 6613 : `<div class="small muted">No rules published yet.</div>`; 6614 onboardingBody.innerHTML = ` 6615 ${aboutBlock} 6616 <div class="small" style="margin-top:10px;"><b>Rules</b></div> 6617 ${rulesBlock} 6618 ${ 6619 onboardingRequiresAcceptance() 6620 ? `<div class="small ${needs ? "badText" : "goodText"}" style="margin-top:10px;"> 6621 ${needs ? "Rules acceptance required before posting/chat." : `Rules accepted${onboardingState.acceptedAt ? ` at ${escapeHtml(formatLocalTime(onboardingState.acceptedAt))}` : "."}`} 6622 </div>` 6623 : `<div class="small muted" style="margin-top:10px;">Rules acceptance is optional on this server.</div>` 6624 } 6625 `; 6626 if (onboardingAcceptBtn instanceof HTMLButtonElement) { 6627 onboardingAcceptBtn.classList.toggle("hidden", !onboardingRequiresAcceptance()); 6628 onboardingAcceptBtn.disabled = !loggedInUser || !needs; 6629 onboardingAcceptBtn.textContent = needs ? "Accept and continue" : "Accepted"; 6630 } 6631 renderOnboardingPanel(); 6632 } 6633 6634 function setAuthUi() { 6635 if (loggedInUser) { 6636 userLabel.innerHTML = renderUserPill(loggedInUser); 6637 logoutBtn.classList.remove("hidden"); 6638 const roleText = loggedInRole && loggedInRole !== "member" ? ` (${loggedInRole})` : ""; 6639 authHint.textContent = onboardingNeedsAcceptanceNow() 6640 ? `Signed in${roleText}. Accept server rules to unlock posting/chat.` 6641 : `Signed in${roleText}. You can post, chat, and boost others.`; 6642 } else { 6643 userLabel.textContent = "Signed out"; 6644 logoutBtn.classList.add("hidden"); 6645 authHint.textContent = registrationEnabled 6646 ? "Sign in or create an account with the registration code." 6647 : canRegisterFirstUser 6648 ? "No users exist yet. Create the first user from this computer." 6649 : "Sign in to post, chat, and boost."; 6650 } 6651 applyInstanceAppearance(); 6652 6653 const canMakePermanent = 6654 Boolean(loggedInUser) && 6655 (loggedInRole === "owner" || loggedInRole === "moderator" || Boolean(normalizeInstanceBranding(instanceBranding).allowMemberPermanentPosts)); 6656 if (ttlMinutesEl) { 6657 ttlMinutesEl.min = canMakePermanent ? "0" : "1"; 6658 if (!canMakePermanent && Number(ttlMinutesEl.value || 0) <= 0) ttlMinutesEl.value = "60"; 6659 } 6660 6661 codeRow.classList.toggle("hidden", !registrationEnabled); 6662 registerBtn.classList.toggle("hidden", !(registrationEnabled || canRegisterFirstUser)); 6663 renderOnboardingCard(); 6664 renderModPanel(); 6665 } 6666 6667 function roleLabel(role) { 6668 const r = String(role || "member"); 6669 return r === "owner" || r === "moderator" ? r : "member"; 6670 } 6671 6672 function peopleOnlineCardStyle(member) { 6673 if (!member?.online) return ""; 6674 const rgb = hexToRgb(member.color || ""); 6675 if (!rgb) { 6676 return `style="border-color:rgba(255,62,165,0.35);box-shadow:0 10px 24px rgba(255,62,165,0.12);"`; 6677 } 6678 return `style="border-color:rgba(${rgb.r},${rgb.g},${rgb.b},0.45);background:linear-gradient(180deg, rgba(${rgb.r},${rgb.g},${rgb.b},0.13), rgba(${rgb.r},${rgb.g},${rgb.b},0.04) 55%), rgba(255,255,255,0.02);box-shadow:0 10px 24px rgba(${rgb.r},${rgb.g},${rgb.b},0.17);"`; 6679 } 6680 6681 function renderPeoplePanel() { 6682 if (!peopleDrawerEl || !peopleListEl) return; 6683 ensurePeopleFallback(); 6684 const membersTabOn = peopleTab === "members"; 6685 peopleMembersTabBtn?.classList.toggle("primary", membersTabOn); 6686 peopleMembersTabBtn?.classList.toggle("ghost", !membersTabOn); 6687 peopleDmsTabBtn?.classList.toggle("primary", !membersTabOn); 6688 peopleDmsTabBtn?.classList.toggle("ghost", membersTabOn); 6689 peopleMembersViewEl?.classList.toggle("hidden", !membersTabOn); 6690 peopleDmsViewEl?.classList.toggle("hidden", membersTabOn); 6691 if (!membersTabOn) { 6692 if (!peopleDmsViewEl) return; 6693 if (!loggedInUser) { 6694 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>`; 6695 return; 6696 } 6697 6698 const blockedSet = prefSet("blockedUsers"); 6699 const eligibleMembers = peopleMembers 6700 .filter((m) => m?.username && String(m.username).toLowerCase() !== String(loggedInUser).toLowerCase()) 6701 .filter((m) => !blockedSet.has(String(m.username || "").toLowerCase())) 6702 .map((m) => String(m.username)) 6703 .sort((a, b) => a.localeCompare(b)) 6704 .slice(0, 250); 6705 6706 const picker = 6707 eligibleMembers.length > 0 6708 ? `<div class="dmNewRow"> 6709 <select class="dmToSelect" data-dmto="1"> 6710 <option value="">New DM...</option> 6711 ${eligibleMembers.map((u) => `<option value="${escapeHtml(u)}">@${escapeHtml(u)}</option>`).join("")} 6712 </select> 6713 <button type="button" class="primary" data-dmrequestfromselect="1">Request</button> 6714 </div>` 6715 : `<div class="muted">No other members yet.</div>`; 6716 6717 const threads = Array.isArray(dmThreads) ? [...dmThreads].sort((a, b) => dmActivityAt(b) - dmActivityAt(a)) : []; 6718 const listHtml = threads.length 6719 ? threads 6720 .map((t) => { 6721 const other = String(t.other || ""); 6722 const isBlocked = blockedSet.has(other.toLowerCase()); 6723 const status = String(t.status || "unknown"); 6724 const when = dmActivityAt(t); 6725 const whenTxt = when ? new Date(when).toLocaleString() : ""; 6726 const statusBadge = 6727 status === "incoming" 6728 ? `<span class="tag dmTag dmTagIncoming">request</span>` 6729 : status === "outgoing" 6730 ? `<span class="tag dmTag dmTagPending">pending</span>` 6731 : status === "active" 6732 ? `<span class="tag dmTag dmTagActive">active</span>` 6733 : status === "declined" 6734 ? `<span class="tag dmTag dmTagDeclined">declined</span>` 6735 : `<span class="tag dmTag">unknown</span>`; 6736 6737 const blockedBadge = isBlocked ? `<span class="tag dmTag dmTagDeclined">blocked</span>` : ""; 6738 6739 let actions = 6740 status === "incoming" 6741 ? `<div class="row" style="gap:8px;justify-content:flex-end"> 6742 <button type="button" class="primary smallBtn" data-dmaccept="${escapeHtml(t.id)}">Accept</button> 6743 <button type="button" class="ghost smallBtn" data-dmdecline="${escapeHtml(t.id)}">Decline</button> 6744 </div>` 6745 : status === "active" 6746 ? `<button type="button" class="primary smallBtn" data-dmopen="${escapeHtml(t.id)}">Open</button>` 6747 : status === "declined" 6748 ? `<button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(other)}">Request again</button>` 6749 : `<span class="muted small">Waiting...</span>`; 6750 6751 if (isBlocked) { 6752 actions = 6753 status === "active" 6754 ? `<button type="button" class="primary smallBtn" data-dmopen="${escapeHtml(t.id)}" disabled>Open</button>` 6755 : `<span class="muted small">Blocked</span>`; 6756 } 6757 if (canModerate && other) { 6758 actions += ` <button type="button" class="ghost smallBtn" data-moddm="${escapeHtml(other)}">Mod DM</button>`; 6759 } 6760 6761 return `<div class="dmThreadCard"> 6762 <div class="dmThreadTop"> 6763 <div class="dmThreadLeft"> 6764 ${renderUserPill(other)} 6765 ${statusBadge} 6766 ${blockedBadge} 6767 </div> 6768 <div class="dmThreadRight">${actions}</div> 6769 </div> 6770 <div class="small muted">${whenTxt ? `Last activity: ${escapeHtml(whenTxt)}` : "No messages yet."} <span class="muted">β’</span> DMs purge daily.</div> 6771 </div>`; 6772 }) 6773 .join("") 6774 : `<div class="muted">No DMs yet. Start one from the Members tab or a profile.</div>`; 6775 6776 peopleDmsViewEl.innerHTML = ` 6777 <div class="dmHeader"> 6778 <div class="small muted">Private 1:1 chats (encrypted at rest). Incoming requests must be accepted.</div> 6779 ${picker} 6780 </div> 6781 <div class="dmThreadList">${listHtml}</div> 6782 `; 6783 return; 6784 } 6785 6786 const q = (peopleSearchEl?.value || "").trim().toLowerCase(); 6787 const list = peopleMembers 6788 .filter((m) => (q ? String(m.username || "").toLowerCase().includes(q) : true)) 6789 .sort((a, b) => Number(Boolean(b.online)) - Number(Boolean(a.online)) || String(a.username).localeCompare(String(b.username))); 6790 6791 if (!list.length) { 6792 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>`; 6793 return; 6794 } 6795 peopleListEl.innerHTML = list 6796 .map((m) => { 6797 const username = String(m.username || ""); 6798 const status = String(m.status || (m.online ? "online" : "offline")); 6799 const role = roleLabel(m.role); 6800 const statusText = `${status}${m.online ? "" : ""}`; 6801 const cardStyle = peopleOnlineCardStyle(m); 6802 const canDm = Boolean(loggedInUser && username && String(username).toLowerCase() !== String(loggedInUser).toLowerCase()); 6803 const canModDm = Boolean(canModerate && username && String(username).toLowerCase() !== String(loggedInUser || "").toLowerCase()); 6804 return `<div class="peopleCard" data-viewprofile="${escapeHtml(username)}" ${cardStyle}> 6805 <div class="peopleCardTop"> 6806 <div>${renderUserPill(username)} <span class="modStatus">${escapeHtml(role)}</span></div> 6807 <div class="peopleStatus">${escapeHtml(statusText)}</div> 6808 </div> 6809 <div class="peopleCardActions"> 6810 <button type="button" class="ghost smallBtn" data-viewprofile="${escapeHtml(username)}">Profile</button> 6811 <button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(username)}" ${canDm ? "" : "disabled"}>DM</button> 6812 ${canModDm ? `<button type="button" class="ghost smallBtn" data-moddm="${escapeHtml(username)}">Mod DM</button>` : ""} 6813 </div> 6814 </div>`; 6815 }) 6816 .join(""); 6817 } 6818 6819 function statusBadge(status) { 6820 const s = String(status || ""); 6821 if (!s) return `<span class="muted">-</span>`; 6822 return `<span class="modStatus">${escapeHtml(s)}</span>`; 6823 } 6824 6825 function userStateText(user) { 6826 const t = Date.now(); 6827 if (user.banned) return "banned"; 6828 if (Number(user.suspendedUntil || 0) > t) return `suspended until ${new Date(user.suspendedUntil).toLocaleString()}`; 6829 if (Number(user.mutedUntil || 0) > t) return `muted until ${new Date(user.mutedUntil).toLocaleString()}`; 6830 return "active"; 6831 } 6832 6833 function promptReason(actionLabel) { 6834 const value = prompt(`Reason for ${actionLabel}:`); 6835 if (!value) return ""; 6836 return value.trim(); 6837 } 6838 6839 function requestModData() { 6840 if (!canModerate) return; 6841 ws.send(JSON.stringify({ type: "modListUsers", limit: 200 })); 6842 ws.send(JSON.stringify({ type: "modListLog", limit: 200 })); 6843 ws.send(JSON.stringify({ type: "devLogList", limit: 300 })); 6844 const status = modReportStatusEl ? modReportStatusEl.value : "open"; 6845 ws.send(JSON.stringify({ type: "modListReports", status, limit: 200 })); 6846 } 6847 6848 function renderModPanel() { 6849 if (!modPanelEl || !modBodyEl) return; 6850 modPanelEl.classList.toggle("hidden", !canModerate); 6851 if (appRoot) appRoot.classList.toggle("hasMod", canModerate); 6852 if (!canModerate) { 6853 modBodyEl.innerHTML = ""; 6854 if (isMobileScreenMode() && appRoot?.getAttribute("data-mobile-screen") === "moderation") setMobileScreen("hives", { pushHistory: false }); 6855 return; 6856 } 6857 if (modReportStatusEl) modReportStatusEl.classList.toggle("hidden", modTab !== "reports"); 6858 6859 const tabs = Array.from(modPanelEl.querySelectorAll("[data-modtab]")); 6860 for (const btn of tabs) { 6861 const on = btn.getAttribute("data-modtab") === modTab; 6862 btn.classList.toggle("primary", on); 6863 btn.classList.toggle("ghost", !on); 6864 // Owner-only plugin tabs should not show for non-owners. 6865 const ownerOnly = btn.dataset.ownerOnly === "1"; 6866 btn.classList.toggle("hidden", Boolean(ownerOnly && loggedInRole !== "owner")); 6867 } 6868 6869 // Plugin-provided moderation tabs (render into modBody). 6870 if (modPluginTabs.has(modTab)) { 6871 const def = modPluginTabs.get(modTab); 6872 if (def?.ownerOnly && loggedInRole !== "owner") { 6873 modTab = "server"; 6874 renderModPanel(); 6875 return; 6876 } 6877 modBodyEl.innerHTML = ` 6878 <div class="modCard"> 6879 <div class="modRowTop"><div><b>${escapeHtml(def?.title || "Plugin")}</b></div></div> 6880 <div id="modPluginMount" class="modActions"></div> 6881 </div> 6882 `; 6883 const mount = modBodyEl.querySelector("#modPluginMount"); 6884 if (mount) { 6885 const api = { 6886 toast, 6887 send: (eventName, payload) => { 6888 const ev = String(eventName || "").trim(); 6889 if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false; 6890 const wsRef = window.__bzlWs; 6891 if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false; 6892 const msg = payload && typeof payload === "object" ? payload : {}; 6893 wsRef.send(JSON.stringify({ ...msg, type: `plugin:${def.pluginId}:${ev}` })); 6894 return true; 6895 }, 6896 getUser: () => loggedInUser, 6897 getRole: () => loggedInRole, 6898 }; 6899 try { 6900 def.render(mount, api); 6901 } catch (e) { 6902 mount.textContent = "Failed to render plugin tab."; 6903 console.warn(`Plugin tab render failed (${modTab}):`, e?.message || e); 6904 } 6905 } 6906 return; 6907 } 6908 6909 if (modTab === "server") { 6910 const isOwner = loggedInRole === "owner"; 6911 const canEditAppearance = loggedInRole === "owner" || loggedInRole === "moderator"; 6912 const b = normalizeInstanceBranding(instanceBranding); 6913 const a = b.appearance || {}; 6914 const loading = Boolean(serverInfoStatus.loading); 6915 const err = String(serverInfoStatus.error || ""); 6916 const info = serverInfo && typeof serverInfo === "object" ? serverInfo : null; 6917 const health = serverHealth && typeof serverHealth === "object" ? serverHealth : null; 6918 const stats = health?.stats && typeof health.stats === "object" ? health.stats : null; 6919 const rl = info?.config?.rateLimits && typeof info.config.rateLimits === "object" ? info.config.rateLimits : null; 6920 const updatedAt = serverInfoStatus.at ? formatLocalTime(serverInfoStatus.at) : ""; 6921 6922 const statusLine = loading 6923 ? `<span class="muted">Loading...</span>` 6924 : err 6925 ? `<span class="bad">${escapeHtml(err)}</span>` 6926 : updatedAt 6927 ? `<span class="muted">Updated: ${escapeHtml(updatedAt)}</span>` 6928 : `<span class="muted">Not loaded yet.</span>`; 6929 6930 const fontBodyOptions = [ 6931 { value: "system", label: "System (sans)" }, 6932 { value: "serif", label: "Serif" }, 6933 { value: "mono", label: "Monospace" }, 6934 ] 6935 .map((o) => `<option value="${o.value}" ${a.fontBody === o.value ? "selected" : ""}>${escapeHtml(o.label)}</option>`) 6936 .join(""); 6937 const fontMonoOptions = [ 6938 { value: "mono", label: "Monospace" }, 6939 { value: "system", label: "System" }, 6940 ] 6941 .map((o) => `<option value="${o.value}" ${a.fontMono === o.value ? "selected" : ""}>${escapeHtml(o.label)}</option>`) 6942 .join(""); 6943 6944 const instanceOwnerControls = `<label> 6945 <span>Title</span> 6946 <input data-instance-title maxlength="32" value="${escapeHtml(b.title)}" /> 6947 </label> 6948 <label> 6949 <span>Subtitle</span> 6950 <input data-instance-subtitle maxlength="80" value="${escapeHtml(b.subtitle)}" /> 6951 </label> 6952 <label class="row" style="gap:10px; align-items:center"> 6953 <input data-instance-allowpermanent type="checkbox" ${b.allowMemberPermanentPosts ? "checked" : ""} /> 6954 <span>Allow members to create permanent hives</span> 6955 </label>`; 6956 6957 const themePresetRow = ` 6958 <div class="row" style="gap:10px"> 6959 <label style="flex:1"> 6960 <span>Theme preset</span> 6961 <select data-theme-preset> 6962 <option value="">(choose...)</option> 6963 ${THEME_PRESETS.map((p) => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)}</option>`).join("")} 6964 </select> 6965 </label> 6966 <div class="row" style="align-items:flex-end"> 6967 <button type="button" class="ghost" data-theme-reset="1">Reset</button> 6968 </div> 6969 </div> 6970 `; 6971 6972 const appearanceControls = ` 6973 ${themePresetRow} 6974 <div class="row" style="gap:10px"> 6975 <label style="flex:1"> 6976 <span>Background</span> 6977 <input data-instance-bg type="color" value="${escapeHtml(a.bg || "#060611")}" /> 6978 </label> 6979 <label style="flex:1"> 6980 <span>Panel</span> 6981 <input data-instance-panel type="color" value="${escapeHtml(a.panel || "#0c0c18")}" /> 6982 </label> 6983 </div> 6984 <div class="row" style="gap:10px"> 6985 <label style="flex:1"> 6986 <span>Text</span> 6987 <input data-instance-text type="color" value="${escapeHtml(a.text || "#f6f0ff")}" /> 6988 </label> 6989 <label style="flex:1"> 6990 <span>Success / Danger</span> 6991 <div class="row" style="gap:10px"> 6992 <input data-instance-good type="color" value="${escapeHtml(a.good || "#3ddc97")}" /> 6993 <input data-instance-bad type="color" value="${escapeHtml(a.bad || "#ff4d8a")}" /> 6994 </div> 6995 </label> 6996 </div> 6997 <div class="row" style="gap:10px"> 6998 <label style="flex:1"> 6999 <span>Accent</span> 7000 <input data-instance-accent type="color" value="${escapeHtml(a.accent || "#ff3ea5")}" /> 7001 </label> 7002 <label style="flex:1"> 7003 <span>Accent 2</span> 7004 <input data-instance-accent2 type="color" value="${escapeHtml(a.accent2 || "#b84bff")}" /> 7005 </label> 7006 </div> 7007 <div class="row" style="gap:10px"> 7008 <label style="flex:1"> 7009 <span>Muted %</span> 7010 <input data-instance-mutedpct type="number" min="0" max="100" value="${escapeHtml(String(a.mutedPct ?? 65))}" /> 7011 </label> 7012 <label style="flex:1"> 7013 <span>Divider %</span> 7014 <input data-instance-linepct type="number" min="0" max="100" value="${escapeHtml(String(a.linePct ?? 10))}" /> 7015 </label> 7016 <label style="flex:1"> 7017 <span>Panel tint %</span> 7018 <input data-instance-panel2pct type="number" min="0" max="100" value="${escapeHtml(String(a.panel2Pct ?? 2))}" /> 7019 </label> 7020 </div> 7021 <div class="row" style="gap:10px"> 7022 <label style="flex:1"> 7023 <span>Body font</span> 7024 <select data-instance-fontbody>${fontBodyOptions}</select> 7025 </label> 7026 <label style="flex:1"> 7027 <span>Mono font</span> 7028 <select data-instance-fontmono>${fontMonoOptions}</select> 7029 </label> 7030 </div> 7031 `; 7032 7033 const instanceControls = isOwner 7034 ? `${instanceOwnerControls} 7035 ${appearanceControls} 7036 <div class="row" style="gap:8px"> 7037 <button type="button" class="primary" data-instance-save="1">Save</button> 7038 <button type="button" class="ghost" data-server-refresh="1">Refresh server</button> 7039 </div>` 7040 : canEditAppearance 7041 ? `<div class="small muted">Owner-only: title/subtitle and permanent-hive setting.</div> 7042 <div class="small">Title: <b>${escapeHtml(b.title)}</b></div> 7043 <div class="small">Subtitle: <b>${escapeHtml(b.subtitle)}</b></div> 7044 <div class="small">Members can create permanent hives: <b>${b.allowMemberPermanentPosts ? "yes" : "no"}</b></div> 7045 <div class="panelDivider"></div> 7046 ${appearanceControls} 7047 <div class="row" style="gap:8px"> 7048 <button type="button" class="primary" data-instance-saveappearance="1">Save theme</button> 7049 <button type="button" class="ghost" data-server-refresh="1">Refresh server</button> 7050 </div>` 7051 : `<div class="small muted">Only moderators can edit appearance. Only the owner can edit core instance settings.</div> 7052 <div class="small">Title: <b>${escapeHtml(b.title)}</b></div> 7053 <div class="small">Subtitle: <b>${escapeHtml(b.subtitle)}</b></div> 7054 <div class="small">Members can create permanent hives: <b>${b.allowMemberPermanentPosts ? "yes" : "no"}</b></div> 7055 <div class="row" style="gap:8px; margin-top:8px"> 7056 <button type="button" class="ghost" data-server-refresh="1">Refresh server</button> 7057 </div>`; 7058 7059 const serverLines = [ 7060 info?.port ? `Port: ${Number(info.port)}` : "", 7061 typeof info?.registrationEnabled === "boolean" ? `Registration enabled: ${info.registrationEnabled ? "yes" : "no"}` : "", 7062 typeof health?.uptimeSec === "number" ? `Uptime: ${Math.floor(health.uptimeSec)}s` : "", 7063 typeof stats?.sockets === "number" ? `Sockets: ${Math.floor(stats.sockets)}` : "", 7064 typeof stats?.activePosts === "number" ? `Active hives: ${Math.floor(stats.activePosts)}` : "", 7065 typeof stats?.users === "number" ? `Users: ${Math.floor(stats.users)}` : "", 7066 typeof stats?.activeRateLimitBuckets === "number" ? `Active rate limit buckets: ${Math.floor(stats.activeRateLimitBuckets)}` : "", 7067 ].filter(Boolean); 7068 7069 const rlLines = rl 7070 ? [ 7071 `Mod actions: ${rl.mod?.max ?? "?"} / ${rl.mod?.windowMs ?? "?"}ms`, 7072 `Login: ${rl.login?.max ?? "?"} / ${rl.login?.windowMs ?? "?"}ms`, 7073 `Register: ${rl.register?.max ?? "?"} / ${rl.register?.windowMs ?? "?"}ms`, 7074 `Resume: ${rl.resume?.max ?? "?"} / ${rl.resume?.windowMs ?? "?"}ms`, 7075 `Reports: ${rl.report?.max ?? "?"} / ${rl.report?.windowMs ?? "?"}ms`, 7076 ] 7077 : []; 7078 7079 modBodyEl.innerHTML = ` 7080 <div class="modCard"> 7081 <div class="modRowTop"> 7082 <div><b>Server</b></div> 7083 <div class="small">${statusLine}</div> 7084 </div> 7085 <div class="small muted">Server status, appearance, and plugins.</div> 7086 </div> 7087 <div class="modCard"> 7088 <div class="modRowTop"><div><b>Instance settings</b></div></div> 7089 <div class="modActions">${instanceControls}</div> 7090 </div> 7091 <div class="modCard"> 7092 <div class="modRowTop"><div><b>Plugins</b></div></div> 7093 <div class="modActions">${renderPluginsAdminHtml()}</div> 7094 </div> 7095 <div class="modCard"> 7096 <div class="modRowTop"><div><b>Runtime</b></div></div> 7097 <div class="small">${serverLines.length ? serverLines.map((x) => `<div>${escapeHtml(x)}</div>`).join("") : `<div class="muted">No data yet.</div>`}</div> 7098 ${ 7099 rlLines.length 7100 ? `<div class="small muted" style="margin-top:10px">Rate limits</div> 7101 <div class="small">${rlLines.map((x) => `<div>${escapeHtml(x)}</div>`).join("")}</div>` 7102 : "" 7103 } 7104 </div> 7105 `; 7106 return; 7107 } 7108 7109 if (modTab === "onboarding") { 7110 const isOwner = loggedInRole === "owner"; 7111 const canEdit = loggedInRole === "owner" || loggedInRole === "moderator"; 7112 syncOnboardingAdminDraft(false); 7113 normalizeOnboardingDraftRules(); 7114 const roleOptions = customRoles 7115 .map( 7116 (r) => 7117 `<label class="checkRow"> 7118 <span>${escapeHtml(String(r.label || r.key || ""))}</span> 7119 <input type="checkbox" data-onboarding-rolecheck="${escapeHtml(String(r.key || ""))}" ${ 7120 onboardingAdminDraft.selfAssignableRoleIds.includes(String(r.key || "")) ? "checked" : "" 7121 } /> 7122 </label>` 7123 ) 7124 .join(""); 7125 const rulesCards = onboardingAdminDraft.rules.length 7126 ? onboardingAdminDraft.rules 7127 .map((r, idx) => { 7128 const expanded = onboardingAdminExpandedRuleIds.has(r.id); 7129 return `<article class="onbRuleEditorCard" data-onb-ruleid="${escapeHtml(r.id)}"> 7130 <div class="row" style="justify-content:space-between;align-items:center;"> 7131 <button type="button" class="ghost smallBtn" data-onb-ruletoggle="${escapeHtml(r.id)}">${expanded ? "βΎ" : "βΈ"} Rule ${idx + 1}</button> 7132 <div class="row" style="gap:6px;"> 7133 <button type="button" class="ghost smallBtn" data-onb-ruleup="${escapeHtml(r.id)}" ${idx <= 0 ? "disabled" : ""}>β</button> 7134 <button type="button" class="ghost smallBtn" data-onb-ruledown="${escapeHtml(r.id)}" ${ 7135 idx >= onboardingAdminDraft.rules.length - 1 ? "disabled" : "" 7136 }>β</button> 7137 <button type="button" class="ghost smallBtn" data-onb-ruledelete="${escapeHtml(r.id)}">Delete</button> 7138 </div> 7139 </div> 7140 ${ 7141 expanded 7142 ? `<div class="onbRuleEditorBody"> 7143 <label><span>Name</span><input data-onb-rulefield="name" data-onb-ruleid="${escapeHtml(r.id)}" value="${escapeHtml( 7144 r.name 7145 )}" maxlength="60" /></label> 7146 <label><span>Short description</span><input data-onb-rulefield="shortDescription" data-onb-ruleid="${escapeHtml( 7147 r.id 7148 )}" value="${escapeHtml(r.shortDescription)}" maxlength="180" /></label> 7149 <label><span>Full description</span><textarea data-onb-rulefield="description" data-onb-ruleid="${escapeHtml( 7150 r.id 7151 )}" rows="4">${escapeHtml(r.description)}</textarea></label> 7152 <label><span>Severity</span> 7153 <select data-onb-rulefield="severity" data-onb-ruleid="${escapeHtml(r.id)}"> 7154 <option value="info" ${r.severity === "info" ? "selected" : ""}>Info</option> 7155 <option value="warn" ${r.severity === "warn" ? "selected" : ""}>Warn</option> 7156 <option value="critical" ${r.severity === "critical" ? "selected" : ""}>Critical</option> 7157 </select> 7158 </label> 7159 </div>` 7160 : "" 7161 } 7162 </article>`; 7163 }) 7164 .join("") 7165 : `<div class="small muted">No rules yet. Add your first rule.</div>`; 7166 7167 modBodyEl.innerHTML = ` 7168 <div class="modCard"> 7169 <div class="modRowTop"><div><b>Onboarding</b></div></div> 7170 <div class="small muted">Configure About, Rules, and Role Select.</div> 7171 <div class="onbTabs" style="margin-top:8px;"> 7172 <button type="button" class="${onboardingAdminTab === "about" ? "primary" : "ghost"} smallBtn" data-onb-admin-tab="about">About</button> 7173 <button type="button" class="${onboardingAdminTab === "rules" ? "primary" : "ghost"} smallBtn" data-onb-admin-tab="rules">Rules</button> 7174 <button type="button" class="${onboardingAdminTab === "roles" ? "primary" : "ghost"} smallBtn" data-onb-admin-tab="roles">Roles</button> 7175 </div> 7176 </div> 7177 <div class="modCard"> 7178 ${ 7179 onboardingAdminTab === "about" 7180 ? `<label class="checkRow"> 7181 <span>Enable onboarding panel</span> 7182 <input type="checkbox" data-onboarding-enabled ${onboardingAdminDraft.enabled ? "checked" : ""} ${canEdit ? "" : "disabled"} /> 7183 </label> 7184 <label> 7185 <span>About (rich text allowed)</span> 7186 <textarea data-onboarding-about rows="10" ${canEdit ? "" : "disabled"}>${escapeHtml(onboardingAdminDraft.aboutContent)}</textarea> 7187 </label> 7188 <div class="small muted">Updated by: ${escapeHtml(String(normalizeInstanceBranding(instanceBranding).onboarding?.about?.updatedBy || "n/a"))}</div> 7189 <div class="small muted">Updated at: ${escapeHtml( 7190 formatLocalTime(normalizeInstanceBranding(instanceBranding).onboarding?.about?.updatedAt || 0) || "n/a" 7191 )}</div>` 7192 : onboardingAdminTab === "rules" 7193 ? `<label class="checkRow"> 7194 <span>Require rules acceptance before posting/chat</span> 7195 <input type="checkbox" data-onboarding-require ${onboardingAdminDraft.requireAcceptance ? "checked" : ""} ${ 7196 canEdit ? "" : "disabled" 7197 } /> 7198 </label> 7199 <label class="checkRow"> 7200 <span>Block reading hives until accepted ${isOwner ? "" : "(owner only)"}</span> 7201 <input type="checkbox" data-onboarding-blockread ${onboardingAdminDraft.blockReadUntilAccepted ? "checked" : ""} ${ 7202 canEdit && isOwner ? "" : "disabled" 7203 } /> 7204 </label> 7205 <div class="row" style="justify-content:space-between;align-items:center;margin:8px 0;"> 7206 <div><b>Rules</b></div> 7207 <button type="button" class="primary smallBtn" data-onb-ruleadd="1" ${canEdit ? "" : "disabled"}>+ Add Rule</button> 7208 </div> 7209 <div class="onbRuleEditorList">${rulesCards}</div>` 7210 : `<label class="checkRow"> 7211 <span>Enable custom role select in onboarding</span> 7212 <input type="checkbox" data-onboarding-roleenabled ${onboardingAdminDraft.roleSelectEnabled ? "checked" : ""} ${ 7213 canEdit ? "" : "disabled" 7214 } /> 7215 </label> 7216 <div class="small muted">Choose self-assignable roles:</div> 7217 <div class="onbRoleGrid">${roleOptions || `<div class="small muted">No custom roles defined.</div>`}</div>` 7218 } 7219 </div> 7220 <div class="modCard"> 7221 <div class="row" style="gap:8px;"> 7222 <button type="button" class="primary" data-onboarding-save="1" ${canEdit ? "" : "disabled"}>Save</button> 7223 <button type="button" class="ghost" data-onboarding-publish="1" ${canEdit ? "" : "disabled"}>Publish</button> 7224 <button type="button" class="ghost" data-onboarding-refresh="1">Reload</button> 7225 </div> 7226 </div> 7227 `; 7228 return; 7229 } 7230 7231 if (modTab === "users") { 7232 const roleList = customRoles.length 7233 ? customRoles 7234 .map((r) => { 7235 const swatch = r.color ? `<span class="roleSwatch" style="background:${escapeHtml(r.color)}"></span>` : ""; 7236 return `<div class="roleRow"> 7237 <div class="roleRowLeft"> 7238 ${swatch} 7239 <div class="roleMeta"> 7240 <div><b>${escapeHtml(r.label)}</b></div> 7241 <div class="roleKey">${escapeHtml(r.key)}</div> 7242 </div> 7243 </div> 7244 <div class="row" style="gap:8px"> 7245 <button type="button" class="ghost smallBtn" data-rolearchive="${escapeHtml(r.key)}">Archive</button> 7246 </div> 7247 </div>`; 7248 }) 7249 .join("") 7250 : `<div class="muted">No custom roles yet.</div>`; 7251 7252 const roleAdminCard = `<div class="modCard"> 7253 <div class="modRowTop"> 7254 <div><b>Custom roles</b></div> 7255 </div> 7256 <div class="roleCreateRow" style="margin-bottom:10px"> 7257 <label> 7258 <span>Key</span> 7259 <input data-rolekey maxlength="18" placeholder="vip" /> 7260 </label> 7261 <label> 7262 <span>Label</span> 7263 <input data-rolelabel maxlength="24" placeholder="VIP" /> 7264 </label> 7265 <label> 7266 <span>Color</span> 7267 <input data-rolecolor type="color" value="#ff3ea5" /> 7268 </label> 7269 <button type="button" data-rolecreate="1">Create</button> 7270 </div> 7271 <div class="small muted" style="margin-bottom:8px">Tip: gate collections with <span class="tag">member</span>, <span class="tag">moderator</span>, <span class="tag">owner</span>, or <span class="tag">role:yourkey</span>.</div> 7272 <div class="gateList">${roleList}</div> 7273 </div>`; 7274 if (!modUsers.length) { 7275 modBodyEl.innerHTML = `${roleAdminCard}<div class="muted">No users found.</div>`; 7276 return; 7277 } 7278 modBodyEl.innerHTML = 7279 roleAdminCard + 7280 modUsers 7281 .map((u) => { 7282 const role = u.role || "member"; 7283 const status = userStateText(u); 7284 const canPromote = loggedInRole === "owner" && u.username !== loggedInUser; 7285 const canManageCustomRoles = canModerate && u.username !== loggedInUser; 7286 const canResetPassword = 7287 canModerate && 7288 u.username !== loggedInUser && 7289 role !== "owner" && 7290 (role !== "moderator" || loggedInRole === "owner"); 7291 const customBadges = renderCustomRoleBadges(u.username); 7292 return `<div class="modCard"> 7293 <div class="modRowTop"> 7294 <div><b>@${escapeHtml(u.username)}</b> ${statusBadge(role)}</div> 7295 <div class="muted">${escapeHtml(status)}</div> 7296 </div> 7297 <div class="small muted">custom roles: ${customBadges || `<span class="muted">(none)</span>`}</div> 7298 <div class="modActions"> 7299 <button type="button" data-modaction="user_mute" data-targettype="user" data-targetid="${escapeHtml(u.username)}" data-minutes="30">Mute 30m</button> 7300 <button type="button" data-modaction="user_unmute" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Unmute</button> 7301 <button type="button" data-modaction="user_suspend" data-targettype="user" data-targetid="${escapeHtml(u.username)}" data-minutes="120">Suspend 2h</button> 7302 <button type="button" data-modaction="user_unsuspend" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Unsuspend</button> 7303 <button type="button" data-modaction="user_ban" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Ban</button> 7304 <button type="button" data-modaction="user_unban" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Unban</button> 7305 ${canResetPassword ? `<button type="button" data-modaction="user_password_reset" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Reset password</button>` : ""} 7306 ${ 7307 canPromote && role === "member" 7308 ? `<button type="button" data-modaction="user_role_set" data-targettype="user" data-targetid="${escapeHtml( 7309 u.username 7310 )}" data-role="moderator">Make mod</button>` 7311 : "" 7312 } 7313 ${ 7314 canPromote && role === "moderator" 7315 ? `<button type="button" class="danger" data-modaction="user_role_set" data-targettype="user" data-targetid="${escapeHtml( 7316 u.username 7317 )}" data-role="member">Remove mod</button>` 7318 : "" 7319 } 7320 ${ 7321 canManageCustomRoles 7322 ? `<button type="button" data-usermanageroles="${escapeHtml(u.username)}">Manage custom roles</button>` 7323 : "" 7324 } 7325 </div> 7326 </div>`; 7327 }) 7328 .join(""); 7329 return; 7330 } 7331 7332 if (modTab === "hives") { 7333 const hives = Array.from(posts.values()).sort((a, b) => rankTime(b) - rankTime(a) || b.createdAt - a.createdAt); 7334 const collectionControls = canModerate 7335 ? `<div class="modCard"> 7336 <div class="modRowTop"> 7337 <div><b>Collections</b></div> 7338 <button type="button" data-createcollection="1">Create collection</button> 7339 </div> 7340 <div class="modActions"> 7341 ${activeCollections() 7342 .map((c) => { 7343 const canArchive = c.id !== "general"; 7344 const gateLabel = 7345 c.visibility === "gated" 7346 ? `gated: ${(c.allowedRoles || []).map((t) => roleTokenLabel(t)).join(", ") || "(none)"}` 7347 : "public"; 7348 return `<span class="tag">/${escapeHtml(c.name)}</span>${ 7349 c.id !== "general" 7350 ? `<button type="button" data-collectiongate="${escapeHtml(c.id)}">Gate...</button> 7351 <button type="button" data-collectionpublic="${escapeHtml(c.id)}">Make public</button>` 7352 : "" 7353 } 7354 <span class="small muted">${escapeHtml(gateLabel)}</span>${ 7355 canArchive 7356 ? `<button type="button" data-archivecollection="${escapeHtml(c.id)}">Archive ${escapeHtml(c.name)}</button>` 7357 : "" 7358 }`; 7359 }) 7360 .join(" ")} 7361 </div> 7362 </div>` 7363 : ""; 7364 if (!hives.length) { 7365 modBodyEl.innerHTML = `${collectionControls}<div class="muted">No active hives.</div>`; 7366 return; 7367 } 7368 modBodyEl.innerHTML = 7369 collectionControls + 7370 hives 7371 .map((p) => { 7372 const title = postTitle(p); 7373 const author = p.author ? `@${p.author}` : "unknown"; 7374 const collection = activeCollections().find((c) => c.id === p.collectionId)?.name || "General"; 7375 const openReports = modReports.filter( 7376 (r) => r && r.status === "open" && (r.postId === p.id || (r.targetType === "post" && r.targetId === p.id)) 7377 ).length; 7378 return `<div class="modCard"> 7379 <div class="modRowTop"> 7380 <div><b>${escapeHtml(title)}</b></div> 7381 <div class="muted">${formatCountdown(p.expiresAt)}</div> 7382 </div> 7383 <div class="small">author: ${escapeHtml(author)} | collection: /${escapeHtml(collection)} | id: ${escapeHtml(p.id)}</div> 7384 <div class="small muted">open reports: ${openReports}</div> 7385 <div class="modActions"> 7386 <button type="button" data-chat="${p.id}">Open chat</button> 7387 <button type="button" data-modaction="post_ttl_set" data-targettype="post" data-targetid="${escapeHtml( 7388 p.id 7389 )}" data-ttl="0">Permanent</button> 7390 <button type="button" data-modaction="post_ttl_set" data-targettype="post" data-targetid="${escapeHtml( 7391 p.id 7392 )}" data-ttl="60">TTL 1h</button> 7393 <button type="button" data-modaction="post_ttl_set" data-targettype="post" data-targetid="${escapeHtml( 7394 p.id 7395 )}" data-ttl="1440">TTL 1d</button> 7396 <button type="button" data-modaction="post_ttl_set" data-targettype="post" data-targetid="${escapeHtml( 7397 p.id 7398 )}" data-ttlprompt="1">Set TTL...</button> 7399 ${ 7400 p.readOnly 7401 ? `<button type="button" data-modaction="post_readonly_set" data-targettype="post" data-targetid="${escapeHtml( 7402 p.id 7403 )}" data-readonly="0">Make writable</button>` 7404 : `<button type="button" data-modaction="post_readonly_set" data-targettype="post" data-targetid="${escapeHtml( 7405 p.id 7406 )}" data-readonly="1">Read-only</button>` 7407 } 7408 ${ 7409 p.protected 7410 ? `<button type="button" data-modaction="post_protection_set" data-targettype="post" data-targetid="${escapeHtml( 7411 p.id 7412 )}" data-unprotect="1">Unprotect</button> 7413 <button type="button" data-modaction="post_protection_set" data-targettype="post" data-targetid="${escapeHtml( 7414 p.id 7415 )}" data-protect="1">Change password...</button>` 7416 : `<button type="button" data-modaction="post_protection_set" data-targettype="post" data-targetid="${escapeHtml( 7417 p.id 7418 )}" data-protect="1">Protect...</button>` 7419 } 7420 <button type="button" data-modaction="message_purge_recent" data-targettype="post" data-targetid="${escapeHtml( 7421 p.id 7422 )}" data-count="25">Purge 25 msgs</button> 7423 <button type="button" data-modaction="message_purge_recent" data-targettype="post" data-targetid="${escapeHtml( 7424 p.id 7425 )}" data-count="50">Purge 50 msgs</button> 7426 ${ 7427 p.deleted 7428 ? `<button type="button" data-modaction="post_restore" data-targettype="post" data-targetid="${escapeHtml( 7429 p.id 7430 )}" ${p.restoreAvailable ? "" : "disabled"}>${p.restoreAvailable ? "Restore hive" : "No restore snapshot"}</button>` 7431 : `<button type="button" data-modaction="post_delete" data-targettype="post" data-targetid="${escapeHtml( 7432 p.id 7433 )}">Delete hive</button>` 7434 } 7435 <button type="button" class="danger" data-modaction="post_erase" data-targettype="post" data-targetid="${escapeHtml( 7436 p.id 7437 )}">Erase</button> 7438 </div> 7439 </div>`; 7440 }) 7441 .join(""); 7442 return; 7443 } 7444 7445 if (modTab === "log") { 7446 const isOwner = loggedInRole === "owner"; 7447 const viewTabs = ` 7448 <div class="row" style="gap:10px; flex-wrap:wrap; margin-bottom:10px;"> 7449 <button type="button" class="${modLogView === "dev" ? "primary" : "ghost"} smallBtn" data-modlogview="dev">Server dev log</button> 7450 <button type="button" class="${modLogView === "moderation" ? "primary" : "ghost"} smallBtn" data-modlogview="moderation">Moderation log</button> 7451 </div> 7452 `; 7453 const nukeCard = isOwner 7454 ? `<div class="modCard"> 7455 <div class="modRowTop"> 7456 <div><b>NUKE</b></div> 7457 <button type="button" class="danger" data-nuke="1" disabled>NUKE</button> 7458 </div> 7459 <div class="small muted" style="margin-bottom:10px">Clears all hives, reports, moderation log, and hive media uploads. Keeps users + profiles.</div> 7460 <label class="row small" style="gap:10px;align-items:center;justify-content:flex-start"> 7461 <input type="checkbox" data-nukeconfirm="1" /> 7462 <span>ARE YOU SURE?</span> 7463 </label> 7464 </div>` 7465 : ""; 7466 7467 if (modLogView === "dev") { 7468 const lines = devLog 7469 .slice(0, 300) 7470 .reverse() 7471 .map((e) => { 7472 const ts = e?.createdAt ? new Date(e.createdAt).toLocaleString() : ""; 7473 const lvl = String(e?.level || "info").toUpperCase(); 7474 const scope = String(e?.scope || "server"); 7475 const msg = String(e?.message || ""); 7476 const data = String(e?.data || ""); 7477 const extra = data ? ` ${data}` : ""; 7478 return `[${ts}] ${lvl} ${scope}: ${msg}${extra}`; 7479 }) 7480 .join("\n"); 7481 7482 modBodyEl.innerHTML = ` 7483 ${viewTabs} 7484 <div class="modCard"> 7485 <div class="modRowTop"> 7486 <div><b>Dev log</b></div> 7487 <div class="row" style="gap:10px; flex-wrap:wrap; justify-content:flex-end"> 7488 <button type="button" class="ghost smallBtn" data-devlogrefresh="1">Refresh</button> 7489 <button type="button" class="ghost smallBtn" data-devlogcopy="1">Copy</button> 7490 ${isOwner ? `<button type="button" class="danger smallBtn" data-devlogclear="1">Clear</button>` : ""} 7491 </div> 7492 </div> 7493 <label class="row small muted" style="gap:10px; align-items:center; justify-content:flex-start; margin-bottom:10px;"> 7494 <input type="checkbox" data-devlogautoscroll="1" ${devLogAutoScroll ? "checked" : ""} /> 7495 <span>Auto-scroll</span> 7496 <button type="button" class="ghost smallBtn" data-devlogtest="1" style="margin-left:auto;">Test log</button> 7497 </label> 7498 <pre class="devLogPre" id="devLogPre">${escapeHtml(lines || "(empty)")}</pre> 7499 </div> 7500 `; 7501 7502 const pre = document.getElementById("devLogPre"); 7503 if (pre && devLogAutoScroll) pre.scrollTop = pre.scrollHeight; 7504 return; 7505 } 7506 7507 if (!modLog.length) { 7508 modBodyEl.innerHTML = `${viewTabs}${nukeCard}<div class="muted">No moderation log entries yet.</div>`; 7509 return; 7510 } 7511 modBodyEl.innerHTML = 7512 viewTabs + 7513 nukeCard + 7514 modLog 7515 .map( 7516 (entry) => `<div class="modCard"> 7517 <div class="modRowTop"> 7518 <div><b>${escapeHtml(entry.actionType || "action")}</b> ${statusBadge(entry.targetType || "")}</div> 7519 <div class="muted">${new Date(entry.createdAt).toLocaleString()}</div> 7520 </div> 7521 <div class="small">by @${escapeHtml(entry.actor || "unknown")} on ${escapeHtml(entry.targetId || "(none)")}</div> 7522 <div class="small muted">${escapeHtml(entry.reason || "")}</div> 7523 ${ 7524 entry?.metadata?.beforePreview || entry?.metadata?.beforeText 7525 ? `<div class="small muted">content: ${escapeHtml(entry.metadata.beforePreview || entry.metadata.beforeText || "")}</div>` 7526 : "" 7527 } 7528 ${ 7529 entry?.metadata?.editCount 7530 ? `<div class="small muted">edits: ${escapeHtml(String(entry.metadata.editCount))}</div>` 7531 : "" 7532 } 7533 ${ 7534 entry?.targetType === "post" && (entry?.actionType === "post_delete" || entry?.actionType === "self_post_delete") 7535 ? `<div class="modActions"> 7536 <button type="button" data-modaction="post_restore" data-targettype="post" data-targetid="${escapeHtml( 7537 entry.targetId || "" 7538 )}">Restore hive</button> 7539 </div>` 7540 : "" 7541 } 7542 ${ 7543 entry?.targetType === "chat" && 7544 (entry?.actionType === "message_delete" || entry?.actionType === "self_message_delete") 7545 ? `<div class="modActions"> 7546 <button type="button" data-modaction="message_restore" data-targettype="chat" data-targetid="${escapeHtml( 7547 entry.targetId || "" 7548 )}">Restore message</button> 7549 </div>` 7550 : "" 7551 } 7552 </div>` 7553 ) 7554 .join(""); 7555 return; 7556 } 7557 7558 if (!modReports.length) { 7559 modBodyEl.innerHTML = `<div class="muted">No reports for this filter.</div>`; 7560 return; 7561 } 7562 modBodyEl.innerHTML = modReports 7563 .map((r) => { 7564 const status = r.status || "open"; 7565 const canAct = status === "open"; 7566 return `<div class="modCard"> 7567 <div class="modRowTop"> 7568 <div><b>${escapeHtml(r.targetType || "target")}</b> ${statusBadge(status)}</div> 7569 <div class="muted">${new Date(r.createdAt).toLocaleString()}</div> 7570 </div> 7571 <div class="small">target: ${escapeHtml(r.targetId || "")}</div> 7572 <div class="small">reporter: @${escapeHtml(r.reporter || "")}</div> 7573 <div class="small muted">${escapeHtml(r.reason || "")}</div> 7574 ${ 7575 canAct 7576 ? `<div class="modActions"> 7577 <button type="button" data-modaction="report_resolve" data-targettype="report" data-targetid="${escapeHtml(r.id)}">Resolve</button> 7578 <button type="button" data-modaction="report_dismiss" data-targettype="report" data-targetid="${escapeHtml(r.id)}">Dismiss</button> 7579 </div>` 7580 : "" 7581 } 7582 </div>`; 7583 }) 7584 .join(""); 7585 } 7586 7587 function isMapChatActive() { 7588 return Boolean(!activeDmThreadId && !activeChatPostId && activeMapsRoomId); 7589 } 7590 7591 function normalizeMapChatScope(scope) { 7592 const s = String(scope || "").trim().toLowerCase(); 7593 return s === "global" ? "global" : "local"; 7594 } 7595 7596 function mapChatListFor(mapId, scope) { 7597 const mid = String(mapId || "").trim().toLowerCase(); 7598 if (!mid) return []; 7599 const sc = normalizeMapChatScope(scope); 7600 const store = sc === "global" ? mapsChatGlobalByMapId : mapsChatLocalByMapId; 7601 const arr = store.get(mid); 7602 return Array.isArray(arr) ? arr : []; 7603 } 7604 7605 function pushMapChatMessage(mapId, scope, message) { 7606 const mid = String(mapId || "").trim().toLowerCase(); 7607 if (!mid) return; 7608 const sc = normalizeMapChatScope(scope); 7609 const store = sc === "global" ? mapsChatGlobalByMapId : mapsChatLocalByMapId; 7610 const prev = store.get(mid); 7611 const arr = Array.isArray(prev) ? prev.slice() : []; 7612 arr.push(message); 7613 if (arr.length > 240) arr.splice(0, arr.length - 240); 7614 store.set(mid, arr); 7615 } 7616 7617 function renderChatPanel(forceScroll = false) { 7618 updateChatModToggleVisibility(); 7619 renderChatContextSelect(); 7620 const mobileChatScreen = isMobileChatScreenActive(); 7621 const mediaState = captureMediaState(chatMessagesEl); 7622 if (activeDmThreadId) { 7623 const thread = dmThreadsById.get(activeDmThreadId) || null; 7624 if (!thread) { 7625 activeDmThreadId = null; 7626 } else { 7627 const atBottomBefore = 7628 chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24; 7629 chatTitle.textContent = `@${thread.other}`; 7630 if (chatBackToListBtn) chatBackToListBtn.classList.toggle("hidden", !mobileChatScreen); 7631 const status = String(thread.status || "unknown"); 7632 const statusTxt = 7633 status === "incoming" 7634 ? "DM request (accept to chat)" 7635 : status === "outgoing" 7636 ? "DM request pending" 7637 : status === "declined" 7638 ? "DM request declined" 7639 : "Private chat"; 7640 chatMeta.textContent = `with @${thread.other} | ${statusTxt} | purged daily`; 7641 7642 const messages = dmMessagesByThreadId.get(activeDmThreadId) || []; 7643 if (status !== "active" && messages.length === 0) { 7644 const promptHtml = 7645 status === "incoming" 7646 ? `<div class="row" style="gap:8px;justify-content:flex-start"> 7647 <button type="button" class="primary smallBtn" data-dmaccept="${escapeHtml(thread.id)}">Accept</button> 7648 <button type="button" class="ghost smallBtn" data-dmdecline="${escapeHtml(thread.id)}">Decline</button> 7649 </div>` 7650 : status === "declined" 7651 ? `<button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(thread.other)}">Request again</button>` 7652 : `<div class="muted">Waiting for @${escapeHtml(thread.other)}...</div>`; 7653 chatMessagesEl.innerHTML = `<div class="small muted">${promptHtml}</div>`; 7654 restoreMediaState(chatMessagesEl, mediaState); 7655 setReplyToMessage(null); 7656 return; 7657 } 7658 7659 chatMessagesEl.innerHTML = messages 7660 .map((m, index) => { 7661 const from = m.fromUser || ""; 7662 const isYou = loggedInUser && from && from === loggedInUser; 7663 const isModMsg = Boolean(m?.asMod) || String(from || "").toLowerCase() === "mod"; 7664 const rail = chatRailClass({ 7665 fromUser: from, 7666 isModMessage: isModMsg 7667 }); 7668 const prev = index > 0 ? messages[index - 1] : null; 7669 const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); 7670 const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || ""); 7671 const youTag = isModMsg ? "" : isYou ? `<span class="muted">(you)</span>` : ""; 7672 const time = new Date(m.createdAt).toLocaleTimeString(); 7673 const tint = tintStylesFromHex(getProfile(from).color); 7674 const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; 7675 const content = html ? html : highlightMentionsInText(m.text || ""); 7676 return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${rail}" data-msgid="${escapeHtml(m.id)}" ${tint}> 7677 <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> 7678 <div class="content">${content}</div> 7679 </div>`; 7680 }) 7681 .join(""); 7682 for (const contentEl of chatMessagesEl.querySelectorAll(".chatMsg .content")) { 7683 decorateMentionNodesInElement(contentEl); 7684 decorateYouTubeEmbedsInElement(contentEl); 7685 } 7686 restoreMediaState(chatMessagesEl, mediaState); 7687 if (forceScroll || atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; 7688 return; 7689 } 7690 } 7691 7692 const post = activeChatPostId ? posts.get(activeChatPostId) : null; 7693 if (!post) { 7694 if (isMapChatActive()) { 7695 const mapId = String(activeMapsRoomId || "").trim().toLowerCase(); 7696 const scope = normalizeMapChatScope(activeMapsChatScope); 7697 const atBottomBefore = 7698 chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24; 7699 7700 const title = activeMapsRoomTitle ? `Map: ${activeMapsRoomTitle}` : `Map: ${mapId}`; 7701 chatTitle.textContent = activeMapsRoomTitle ? `Map: ${activeMapsRoomTitle}` : "Map chat"; 7702 if (chatBackToListBtn) chatBackToListBtn.classList.toggle("hidden", !mobileChatScreen); 7703 chatMeta.innerHTML = ` 7704 <span class="muted">${escapeHtml(title)}</span> 7705 <span class="muted">|</span> 7706 <span class="mapChatToggle"> 7707 <button type="button" class="${scope === "local" ? "primary" : "ghost"} smallBtn" data-mapchatscope="local" title="Local chat (nearby)">Local</button> 7708 <button type="button" class="${scope === "global" ? "primary" : "ghost"} smallBtn" data-mapchatscope="global" title="Global chat (entire map)">Global</button> 7709 </span> 7710 `; 7711 7712 if (chatPanelEl) chatPanelEl.classList.remove("walkie"); 7713 if (walkieBarEl) walkieBarEl.classList.add("hidden"); 7714 if (chatForm) chatForm.classList.remove("hidden"); 7715 7716 const messages = mapChatListFor(mapId, scope); 7717 if (!messages.length) { 7718 chatMessagesEl.innerHTML = `<div class="small muted">${ 7719 scope === "local" ? "Local chat is proximity-based. Say something nearby." : "No messages yet. Say hello!" 7720 }</div>`; 7721 restoreMediaState(chatMessagesEl, mediaState); 7722 setReplyToMessage(null); 7723 return; 7724 } 7725 7726 chatMessagesEl.innerHTML = messages 7727 .map((m, index) => { 7728 const from = String(m.fromUser || ""); 7729 const isYou = loggedInUser && from && from === loggedInUser; 7730 const rail = chatRailClass({ 7731 fromUser: from, 7732 isModMessage: Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod" 7733 }); 7734 const prev = index > 0 ? messages[index - 1] : null; 7735 const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); 7736 const who = renderUserPill(from || ""); 7737 const youTag = isYou ? `<span class="muted">(you)</span>` : ""; 7738 const time = new Date(Number(m.createdAt || 0) || Date.now()).toLocaleTimeString(); 7739 const tint = tintStylesFromHex(getProfile(from).color); 7740 const content = highlightMentionsInText(String(m.text || "")); 7741 return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${rail}" data-msgid="${escapeHtml(String(m.id || ""))}" ${tint}> 7742 <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> 7743 <div class="content">${content}</div> 7744 </div>`; 7745 }) 7746 .join(""); 7747 for (const contentEl of chatMessagesEl.querySelectorAll(".chatMsg .content")) { 7748 decorateMentionNodesInElement(contentEl); 7749 decorateYouTubeEmbedsInElement(contentEl); 7750 } 7751 restoreMediaState(chatMessagesEl, mediaState); 7752 if (forceScroll || atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; 7753 setReplyToMessage(null); 7754 return; 7755 } 7756 7757 if (chatBackToListBtn) chatBackToListBtn.classList.add("hidden"); 7758 if (mobileChatScreen) { 7759 chatTitle.textContent = "Chats"; 7760 chatMeta.textContent = "Select a hive chat."; 7761 if (chatPanelEl) chatPanelEl.classList.remove("walkie"); 7762 if (walkieBarEl) walkieBarEl.classList.add("hidden"); 7763 if (chatForm) chatForm.classList.add("hidden"); 7764 chatMessagesEl.innerHTML = renderMobileChatListHtml(); 7765 restoreMediaState(chatMessagesEl, mediaState); 7766 setReplyToMessage(null); 7767 return; 7768 } 7769 chatTitle.textContent = "Chat"; 7770 chatMeta.textContent = "Select a post to chat."; 7771 if (chatPanelEl) chatPanelEl.classList.remove("walkie"); 7772 if (walkieBarEl) walkieBarEl.classList.add("hidden"); 7773 if (chatForm) chatForm.classList.remove("hidden"); 7774 chatMessagesEl.innerHTML = `<div class="small muted">No chat selected.</div> 7775 <div class="uiHint">Open a hive and press <b>Chat</b>, or use People -> DMs to open a private thread.</div> 7776 <div class="row" style="gap:8px;justify-content:flex-start;margin-top:8px;"> 7777 <button type="button" class="ghost smallBtn" data-chatemptyopen="hives">Open Hives</button> 7778 <button type="button" class="ghost smallBtn" data-chatemptyopen="people">Open People</button> 7779 </div>`; 7780 restoreMediaState(chatMessagesEl, mediaState); 7781 setReplyToMessage(null); 7782 return; 7783 } 7784 7785 updateChatModToggleVisibility(); 7786 const isWalkie = String(post.mode || post.chatMode || "").toLowerCase() === "walkie"; 7787 if (chatPanelEl) chatPanelEl.classList.toggle("walkie", isWalkie); 7788 if (walkieBarEl) walkieBarEl.classList.toggle("hidden", !isWalkie); 7789 if (chatForm) chatForm.classList.toggle("hidden", isWalkie); 7790 if (walkieRecordBtn) walkieRecordBtn.disabled = !(isWalkie && loggedInUser); 7791 if (isWalkie && walkieStatusEl && !loggedInUser) walkieStatusEl.textContent = "Sign in to talk."; 7792 if (!isWalkie && walkieStatusEl) walkieStatusEl.textContent = ""; 7793 7794 const atBottomBefore = 7795 chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24; 7796 chatTitle.textContent = postTitle(post); 7797 if (chatBackToListBtn) chatBackToListBtn.classList.toggle("hidden", !mobileChatScreen); 7798 const tags = (post.keywords || []).map((k) => `#${k}`).join(" "); 7799 const author = post.author ? `by @${post.author}` : ""; 7800 const exp = formatCountdown(post.expiresAt); 7801 const ro = post.readOnly ? " | read-only" : ""; 7802 chatMeta.textContent = `${author}${isWalkie ? " | walkie talkie" : ""}${ro} | ${exp === "permanent" ? "permanent" : `expires in ${exp}`} | ${tags}`.trim(); 7803 const canChatWrite = Boolean(loggedInRole === "owner" || loggedInRole === "moderator" || !post.readOnly); 7804 if (chatEditor) chatEditor.contentEditable = String(Boolean(canChatWrite && !isWalkie)); 7805 const chatSendBtn = chatForm?.querySelector?.("button[type='submit']") || null; 7806 if (chatSendBtn) chatSendBtn.disabled = !(loggedInUser && canChatWrite && !isWalkie); 7807 if (post.deleted) { 7808 chatMessagesEl.innerHTML = `<div class="small muted">Post was deleted.</div>`; 7809 restoreMediaState(chatMessagesEl, mediaState); 7810 setReplyToMessage(null); 7811 return; 7812 } 7813 7814 const messages = chatByPost.get(post.id) || []; 7815 const ignoreUserSet = new Set( 7816 [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase()) 7817 ); 7818 const selfLower = String(loggedInUser || "").toLowerCase(); 7819 const visibleMessages = messages.filter((m) => { 7820 const fromLower = String(m?.fromUser || "").toLowerCase(); 7821 if (!fromLower || fromLower === selfLower) return true; 7822 return !ignoreUserSet.has(fromLower); 7823 }); 7824 7825 chatMessagesEl.innerHTML = visibleMessages 7826 .map((m, index) => { 7827 const isModMsg = Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod"; 7828 const from = isModMsg ? "MOD" : m.fromUser || ""; 7829 const rail = chatRailClass({ fromUser: from, isModMessage: isModMsg }); 7830 const prev = index > 0 ? visibleMessages[index - 1] : null; 7831 const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); 7832 const mentions = Array.isArray(m.mentions) ? m.mentions.map((u) => String(u || "").toLowerCase()) : []; 7833 const mentionMe = Boolean(loggedInUser && mentions.includes(loggedInUser)); 7834 const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || ""); 7835 const youTag = !isModMsg && loggedInUser && from && from === loggedInUser ? `<span class="muted">(you)</span>` : ""; 7836 const time = new Date(m.createdAt).toLocaleTimeString(); 7837 const tint = isModMsg ? "" : tintStylesFromHex(getProfile(from).color); 7838 const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; 7839 const content = html ? html : highlightMentionsInText(m.text || ""); 7840 const replyMeta = m.replyTo && typeof m.replyTo === "object" ? m.replyTo : null; 7841 const replyBlock = replyMeta 7842 ? `<div class="chatReplyRef"><span class="small muted">@${escapeHtml(replyMeta.fromUser || "unknown")}</span><div class="small">${escapeHtml( 7843 String(replyMeta.text || "[media]").slice(0, 120) 7844 )}</div></div>` 7845 : ""; 7846 const reacts = renderReactionButtons({ kind: "chat", id: m.id, reactions: m.reactions || {}, postId: post.id }); 7847 const deletedLine = m.deleted 7848 ? `<div class="small muted">message deleted${ 7849 m.deletedBy ? ` by @${escapeHtml(m.deletedBy)}` : "" 7850 } at ${escapeHtml(new Date(Number(m.deletedAt || m.createdAt || Date.now())).toLocaleString())}</div>` 7851 : ""; 7852 const editedLine = 7853 !m.deleted && Number(m.editCount || 0) > 0 7854 ? `<div class="small muted">edited (${Number(m.editCount || 0)}) at ${escapeHtml( 7855 new Date(Number(m.editedAt || m.createdAt || Date.now())).toLocaleTimeString() 7856 )}</div>` 7857 : ""; 7858 const reportAction = loggedInUser && !m.deleted 7859 ? `<button type="button" class="ghost smallBtn" data-reportchat="${escapeHtml(m.id)}" data-postid="${escapeHtml( 7860 post.id 7861 )}">Report</button>` 7862 : ""; 7863 const canManageOwnMessage = Boolean(loggedInUser && m.fromUser && m.fromUser === loggedInUser && !m.deleted); 7864 const replyAction = loggedInUser && !m.deleted 7865 ? `<button type="button" class="ghost smallBtn" data-replymsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(post.id)}">Reply</button>` 7866 : ""; 7867 const ownEditAction = canManageOwnMessage 7868 ? `<button type="button" class="ghost smallBtn" data-editmsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(post.id)}">Edit</button>` 7869 : ""; 7870 const ownDeleteAction = canManageOwnMessage 7871 ? `<button type="button" class="ghost smallBtn" data-deletemsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(post.id)}">Delete</button>` 7872 : ""; 7873 return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""} ${rail} ${isModMsg ? "isModMsg" : ""}" data-msgid="${escapeHtml(m.id)}" ${tint}> 7874 <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> 7875 ${replyBlock} 7876 ${deletedLine} 7877 ${editedLine} 7878 <div class="content">${content}</div> 7879 <div class="chatActionsRow"> 7880 <div class="chatReactions">${m.deleted ? "" : reacts}</div> 7881 <div class="chatTools">${replyAction}${ownEditAction}${ownDeleteAction}${reportAction}</div> 7882 </div> 7883 </div>`; 7884 }) 7885 .join(""); 7886 for (const contentEl of chatMessagesEl.querySelectorAll(".chatMsg .content")) { 7887 decorateMentionNodesInElement(contentEl); 7888 decorateYouTubeEmbedsInElement(contentEl); 7889 } 7890 restoreMediaState(chatMessagesEl, mediaState); 7891 if (forceScroll || atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; 7892 } 7893 7894 function captureMediaState(containerEl) { 7895 if (!containerEl) return []; 7896 const list = []; 7897 for (const el of containerEl.querySelectorAll("audio, video")) { 7898 try { 7899 const src = el.currentSrc || el.getAttribute("src") || ""; 7900 if (!src) continue; 7901 list.push({ 7902 src, 7903 currentTime: Number(el.currentTime || 0), 7904 paused: Boolean(el.paused), 7905 volume: Number.isFinite(el.volume) ? el.volume : 1, 7906 playbackRate: Number.isFinite(el.playbackRate) ? el.playbackRate : 1 7907 }); 7908 } catch { 7909 // ignore 7910 } 7911 } 7912 return list; 7913 } 7914 7915 function restoreMediaState(containerEl, mediaState) { 7916 if (!containerEl || !Array.isArray(mediaState) || mediaState.length === 0) return; 7917 const els = Array.from(containerEl.querySelectorAll("audio, video")); 7918 for (const s of mediaState) { 7919 const src = String(s?.src || ""); 7920 if (!src) continue; 7921 const el = els.find((x) => (x.currentSrc || x.getAttribute("src") || "") === src); 7922 if (!el) continue; 7923 try { 7924 if (Number.isFinite(s.volume)) el.volume = s.volume; 7925 if (Number.isFinite(s.playbackRate)) el.playbackRate = s.playbackRate; 7926 if (Number.isFinite(s.currentTime)) el.currentTime = s.currentTime; 7927 if (!s.paused) el.play().catch(() => {}); 7928 } catch { 7929 // ignore 7930 } 7931 } 7932 } 7933 7934 function appendChatHtmlAndDecorate(html, atBottomBefore) { 7935 if (!chatMessagesEl) return null; 7936 chatMessagesEl.insertAdjacentHTML("beforeend", html); 7937 const last = chatMessagesEl.lastElementChild; 7938 if (last && last.classList && last.classList.contains("chatMsg")) { 7939 const contentEl = last.querySelector(".content"); 7940 if (contentEl) { 7941 decorateMentionNodesInElement(contentEl); 7942 decorateYouTubeEmbedsInElement(contentEl); 7943 } 7944 } 7945 if (atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; 7946 return last; 7947 } 7948 7949 function appendPostChatMessageToDom(postId, message) { 7950 if (!chatMessagesEl) return false; 7951 const post = postId ? posts.get(postId) : null; 7952 if (!post || post.deleted) return false; 7953 if (!activeChatPostId || activeChatPostId !== postId) return false; 7954 if (activeDmThreadId) return false; 7955 if (!chatMessagesEl.querySelector(".chatMsg")) return false; 7956 7957 const atBottomBefore = 7958 chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24; 7959 7960 const ignoreUserSet = new Set( 7961 [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase()) 7962 ); 7963 const selfLower = String(loggedInUser || "").toLowerCase(); 7964 7965 const messages = chatByPost.get(postId) || []; 7966 let prevVisible = null; 7967 for (let i = messages.length - 2; i >= 0; i -= 1) { 7968 const pm = messages[i]; 7969 const fromLower = String(pm?.fromUser || "").toLowerCase(); 7970 if (!fromLower || fromLower === selfLower || !ignoreUserSet.has(fromLower)) { 7971 prevVisible = pm; 7972 break; 7973 } 7974 } 7975 7976 const m = message; 7977 const isModMsg = Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod"; 7978 const from = isModMsg ? "MOD" : m?.fromUser || ""; 7979 const isYou = loggedInUser && from && from === loggedInUser; 7980 const rail = chatRailClass({ fromUser: from, isModMessage: isModMsg }); 7981 const sameAuthorAsPrev = Boolean(prevVisible && String(prevVisible.fromUser || "") === from); 7982 const mentions = Array.isArray(m?.mentions) ? m.mentions.map((u) => String(u || "").toLowerCase()) : []; 7983 const mentionMe = Boolean(loggedInUser && mentions.includes(loggedInUser)); 7984 const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || ""); 7985 const youTag = !isModMsg && isYou ? `<span class="muted">(you)</span>` : ""; 7986 const time = new Date(m.createdAt).toLocaleTimeString(); 7987 const tint = isModMsg ? "" : tintStylesFromHex(getProfile(from).color); 7988 const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; 7989 const content = html ? html : highlightMentionsInText(m.text || ""); 7990 const replyMeta = m.replyTo && typeof m.replyTo === "object" ? m.replyTo : null; 7991 const replyBlock = replyMeta 7992 ? `<div class="chatReplyRef"><span class="small muted">@${escapeHtml(replyMeta.fromUser || "unknown")}</span><div class="small">${escapeHtml( 7993 String(replyMeta.text || "[media]").slice(0, 120) 7994 )}</div></div>` 7995 : ""; 7996 const reacts = renderReactionButtons({ kind: "chat", id: m.id, reactions: m.reactions || {}, postId }); 7997 const deletedLine = m.deleted 7998 ? `<div class="small muted">message deleted${m.deletedBy ? ` by @${escapeHtml(m.deletedBy)}` : ""} at ${escapeHtml( 7999 new Date(Number(m.deletedAt || m.createdAt || Date.now())).toLocaleString() 8000 )}</div>` 8001 : ""; 8002 const editedLine = 8003 !m.deleted && Number(m.editCount || 0) > 0 8004 ? `<div class="small muted">edited (${Number(m.editCount || 0)}) at ${escapeHtml( 8005 new Date(Number(m.editedAt || m.createdAt || Date.now())).toLocaleTimeString() 8006 )}</div>` 8007 : ""; 8008 const reportAction = 8009 loggedInUser && !m.deleted 8010 ? `<button type="button" class="ghost smallBtn" data-reportchat="${escapeHtml(m.id)}" data-postid="${escapeHtml(postId)}">Report</button>` 8011 : ""; 8012 const canManageOwnMessage = Boolean(loggedInUser && m.fromUser && m.fromUser === loggedInUser && !m.deleted); 8013 const replyAction = 8014 loggedInUser && !m.deleted 8015 ? `<button type="button" class="ghost smallBtn" data-replymsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(postId)}">Reply</button>` 8016 : ""; 8017 const ownEditAction = canManageOwnMessage 8018 ? `<button type="button" class="ghost smallBtn" data-editmsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(postId)}">Edit</button>` 8019 : ""; 8020 const ownDeleteAction = canManageOwnMessage 8021 ? `<button type="button" class="ghost smallBtn" data-deletemsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(postId)}">Delete</button>` 8022 : ""; 8023 8024 const msgHtml = `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""} ${rail} ${isModMsg ? "isModMsg" : ""}" data-msgid="${escapeHtml( 8025 m.id 8026 )}" ${tint}> 8027 <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> 8028 ${replyBlock} 8029 ${deletedLine} 8030 ${editedLine} 8031 <div class="content">${content}</div> 8032 <div class="chatActionsRow"> 8033 <div class="chatReactions">${m.deleted ? "" : reacts}</div> 8034 <div class="chatTools">${replyAction}${ownEditAction}${ownDeleteAction}${reportAction}</div> 8035 </div> 8036 </div>`; 8037 8038 appendChatHtmlAndDecorate(msgHtml, atBottomBefore); 8039 return true; 8040 } 8041 8042 function appendDmMessageToDom(threadId, message) { 8043 if (!chatMessagesEl) return false; 8044 if (!activeDmThreadId || activeDmThreadId !== threadId) return false; 8045 if (!chatMessagesEl.querySelector(".chatMsg")) return false; 8046 const thread = dmThreadsById.get(threadId) || null; 8047 if (!thread || String(thread.status || "unknown") !== "active") return false; 8048 8049 const atBottomBefore = 8050 chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24; 8051 8052 const messages = dmMessagesByThreadId.get(threadId) || []; 8053 const prev = messages.length >= 2 ? messages[messages.length - 2] : null; 8054 8055 const m = message; 8056 const from = m.fromUser || ""; 8057 const isYou = loggedInUser && from && from === loggedInUser; 8058 const rail = chatRailClass({ fromUser: from, isModMessage: false }); 8059 const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); 8060 const who = renderUserPill(from || ""); 8061 const youTag = isYou ? `<span class="muted">(you)</span>` : ""; 8062 const time = new Date(m.createdAt).toLocaleTimeString(); 8063 const tint = tintStylesFromHex(getProfile(from).color); 8064 const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; 8065 const content = html ? html : highlightMentionsInText(m.text || ""); 8066 8067 const msgHtml = `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${rail}" data-msgid="${escapeHtml(m.id)}" ${tint}> 8068 <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> 8069 <div class="content">${content}</div> 8070 </div>`; 8071 8072 appendChatHtmlAndDecorate(msgHtml, atBottomBefore); 8073 return true; 8074 } 8075 8076 function pulseChatMessage(messageId) { 8077 if (!chatMessagesEl) return; 8078 const id = String(messageId || ""); 8079 if (!id) return; 8080 const el = chatMessagesEl.querySelector(`[data-msgid="${cssEscape(id)}"]`); 8081 if (!el) return; 8082 el.classList.add("isNewMsg"); 8083 window.setTimeout(() => el.classList.remove("isNewMsg"), 720); 8084 } 8085 8086 function updateActiveChatMeta() { 8087 if (activeDmThreadId) return; 8088 const post = activeChatPostId ? posts.get(activeChatPostId) : null; 8089 if (!post) return; 8090 const tags = (post.keywords || []).map((k) => `#${k}`).join(" "); 8091 const author = post.author ? `by @${post.author}` : ""; 8092 const exp = formatCountdown(post.expiresAt); 8093 chatMeta.textContent = `${author} | ${exp === "permanent" ? "permanent" : `expires in ${exp}`} | ${tags}`.trim(); 8094 } 8095 8096 function openDmThread(threadId, opts = null) { 8097 const id = String(threadId || "").trim(); 8098 if (!id) return; 8099 const options = opts && typeof opts === "object" ? opts : {}; 8100 if (!options.preserveFocus) blurFocusedChatComposer(); 8101 const thread = dmThreadsById.get(id) || null; 8102 if (!thread) { 8103 pendingOpenDmThreadId = id; 8104 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "dmList" })); 8105 toast("DMs", "Thread not found yet. Refreshing DM list."); 8106 return; 8107 } 8108 if (String(thread.status || "") !== "active") { 8109 pendingOpenDmThreadId = id; 8110 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "dmList" })); 8111 toast("DMs", "DM is not active yet."); 8112 return; 8113 } 8114 pendingOpenDmThreadId = ""; 8115 if (activeChatPostId) ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); 8116 activeChatPostId = null; 8117 activeDmThreadId = id; 8118 touchRecentDmChat(id); 8119 setReplyToMessage(null); 8120 ws.send(JSON.stringify({ type: "dmHistory", threadId: id })); 8121 renderChatPanel(true); 8122 if (isMobileSwipeMode()) { 8123 setMobileScreen("chat"); 8124 renderMobileNav(); 8125 } 8126 } 8127 8128 function sendModDmPrompt(rawUsername) { 8129 const to = String(rawUsername || "") 8130 .trim() 8131 .replace(/^@+/, "") 8132 .toLowerCase(); 8133 if (!to) return; 8134 if (!loggedInUser) { 8135 toast("Sign in required", "Sign in to send moderator DMs."); 8136 return; 8137 } 8138 if (!canModerate) { 8139 toast("Moderator only", "You need moderator permissions."); 8140 return; 8141 } 8142 if (to === String(loggedInUser).toLowerCase()) { 8143 toast("Unavailable", "Can't send a moderator DM to yourself."); 8144 return; 8145 } 8146 const text = String(prompt(`Send moderator DM to @${to}:`) || "").trim(); 8147 if (!text) return; 8148 ws.send(JSON.stringify({ type: "dmSendMod", to, text })); 8149 toast("Moderator DM", `Sent to @${to}.`); 8150 } 8151 8152 function openChat(postId, opts = null) { 8153 activeDmThreadId = null; 8154 stopWalkieRecording(); 8155 const options = opts && typeof opts === "object" ? opts : {}; 8156 if (!options.preserveFocus) blurFocusedChatComposer(); 8157 const sourceEl = options.sourceEl instanceof HTMLElement ? options.sourceEl : null; 8158 const post = posts.get(postId); 8159 if (!post) return; 8160 if (post.deleted) { 8161 activeChatPostId = postId; 8162 touchRecentHiveChat(postId); 8163 renderChatPanel(true); 8164 if (isMobileSwipeMode()) setMobilePanel("chat"); 8165 return; 8166 } 8167 if (post.locked) { 8168 unlockPostFlow(postId, true); 8169 return; 8170 } 8171 8172 // Rack mode: switch the nearest visible chat panel when possible; otherwise use main chat. 8173 if (rackLayoutEnabled) { 8174 const nearestInstanceId = nearestVisibleChatInstancePanelId(sourceEl); 8175 if (nearestInstanceId) { 8176 touchRecentHiveChat(postId); 8177 markRead(postId); 8178 renderFeed(); 8179 ws.send(JSON.stringify({ type: "getChat", postId })); 8180 setChatInstancePanelPost(nearestInstanceId, postId, true); 8181 renderChatContextSelect(); 8182 return; 8183 } 8184 if (chatPanelEl && typeof isDocked === "function" && !isDocked("chat")) { 8185 activeChatPostId = postId; 8186 touchRecentHiveChat(postId); 8187 markRead(postId); 8188 renderFeed(); 8189 ws.send(JSON.stringify({ type: "getChat", postId })); 8190 renderChatPanel(true); 8191 renderTypingIndicator(); 8192 if (isMobileSwipeMode()) setMobilePanel("chat"); 8193 return; 8194 } 8195 } 8196 if (activeChatPostId && activeChatPostId !== postId) { 8197 ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); 8198 setReplyToMessage(null); 8199 } 8200 activeChatPostId = postId; 8201 touchRecentHiveChat(postId); 8202 markRead(postId); 8203 renderFeed(); 8204 ws.send(JSON.stringify({ type: "getChat", postId })); 8205 renderChatPanel(true); 8206 renderTypingIndicator(); 8207 if (isMobileSwipeMode()) setMobilePanel("chat"); 8208 } 8209 8210 let pendingOpenChatAfterUnlock = null; 8211 function unlockPostFlow(postId, openChatAfter) { 8212 const pw = prompt("Password for this post:"); 8213 if (!pw) return; 8214 pendingOpenChatAfterUnlock = openChatAfter ? postId : null; 8215 ws.send(JSON.stringify({ type: "unlockPost", postId, password: pw })); 8216 } 8217 8218 function runCmd(target, cmd) { 8219 target.focus(); 8220 document.execCommand(cmd); 8221 } 8222 8223 function runLink(target) { 8224 target.focus(); 8225 const url = prompt("Link URL (https://...)"); 8226 if (!url) return; 8227 document.execCommand("createLink", false, url); 8228 } 8229 8230 function runEmoji(target) { 8231 target.focus(); 8232 const raw = prompt("Emoji to insert (example: ππ₯π)"); 8233 const emoji = String(raw || "").trim(); 8234 if (!emoji) return; 8235 document.execCommand("insertText", false, emoji); 8236 } 8237 8238 function readFileAsDataUrl(file) { 8239 return new Promise((resolve, reject) => { 8240 const reader = new FileReader(); 8241 reader.onload = () => resolve(String(reader.result || "")); 8242 reader.onerror = () => reject(new Error("Failed to read file")); 8243 reader.readAsDataURL(file); 8244 }); 8245 } 8246 8247 async function resizeImageToSquareDataUrl(file, sizePx) { 8248 const dataUrl = await readFileAsDataUrl(file); 8249 const img = new Image(); 8250 img.src = dataUrl; 8251 await img.decode(); 8252 const canvas = document.createElement("canvas"); 8253 canvas.width = sizePx; 8254 canvas.height = sizePx; 8255 const ctx = canvas.getContext("2d"); 8256 if (!ctx) return ""; 8257 const side = Math.min(img.width, img.height); 8258 const sx = Math.floor((img.width - side) / 2); 8259 const sy = Math.floor((img.height - side) / 2); 8260 ctx.drawImage(img, sx, sy, side, side, 0, 0, sizePx, sizePx); 8261 // Preserve transparency for avatars (JPEG strips alpha). 8262 const webp = canvas.toDataURL("image/webp", 0.9); 8263 if (typeof webp === "string" && webp.startsWith("data:image/webp")) return webp; 8264 return canvas.toDataURL("image/png"); 8265 } 8266 8267 async function uploadMediaFile(file, kind) { 8268 if (!file) return ""; 8269 const maxBytes = kind === "audio" ? CLIENT_AUDIO_UPLOAD_MAX_BYTES : CLIENT_IMAGE_UPLOAD_MAX_BYTES; 8270 if (file.size > maxBytes) { 8271 toast("File too large", `${kind === "audio" ? "Audio" : "Image"} is too large for this server.`); 8272 return ""; 8273 } 8274 const token = getSessionToken(); 8275 if (!token) { 8276 toast("Sign in required", "Please sign in before uploading files."); 8277 return ""; 8278 } 8279 const loweredName = String(file.name || "").toLowerCase(); 8280 let contentType = (file.type || "").toLowerCase(); 8281 if (!contentType) { 8282 if (kind === "image") { 8283 if (loweredName.endsWith(".gif")) contentType = "image/gif"; 8284 else if (loweredName.endsWith(".png")) contentType = "image/png"; 8285 else if (loweredName.endsWith(".webp")) contentType = "image/webp"; 8286 else if (loweredName.endsWith(".jpg") || loweredName.endsWith(".jpeg")) contentType = "image/jpeg"; 8287 } else if (kind === "audio") { 8288 if (loweredName.endsWith(".mp3")) contentType = "audio/mpeg"; 8289 else if (loweredName.endsWith(".wav")) contentType = "audio/wav"; 8290 else if (loweredName.endsWith(".ogg")) contentType = "audio/ogg"; 8291 else if (loweredName.endsWith(".webm")) contentType = "audio/webm"; 8292 else if (loweredName.endsWith(".aac")) contentType = "audio/aac"; 8293 else if (loweredName.endsWith(".m4a") || loweredName.endsWith(".mp4")) contentType = "audio/mp4"; 8294 } 8295 } 8296 const headers = { 8297 Authorization: `Bearer ${token}`, 8298 "Content-Type": contentType || "application/octet-stream" 8299 }; 8300 try { 8301 const res = await fetch(`/api/upload?kind=${encodeURIComponent(kind)}`, { 8302 method: "POST", 8303 headers, 8304 body: file 8305 }); 8306 const payload = await res.json().catch(() => ({})); 8307 if (!res.ok) { 8308 toast("Upload failed", payload?.error || "Upload failed."); 8309 return ""; 8310 } 8311 if (!payload?.url) { 8312 toast("Upload failed", "Server did not return a media URL."); 8313 return ""; 8314 } 8315 return String(payload.url); 8316 } catch { 8317 toast("Upload failed", "Network error while uploading file."); 8318 return ""; 8319 } 8320 } 8321 8322 async function ensureWalkieContext() { 8323 if (walkieCtx) return walkieCtx; 8324 const ctx = new (window.AudioContext || window.webkitAudioContext)(); 8325 walkieCtx = ctx; 8326 return ctx; 8327 } 8328 8329 async function ensureWalkieDispatchBuffer() { 8330 if (walkieDispatchBuffer) return walkieDispatchBuffer; 8331 const ctx = await ensureWalkieContext(); 8332 try { 8333 const res = await fetch("/assets/walkie/dispatch.mp3"); 8334 const arr = await res.arrayBuffer(); 8335 walkieDispatchBuffer = await ctx.decodeAudioData(arr); 8336 return walkieDispatchBuffer; 8337 } catch { 8338 walkieDispatchBuffer = null; 8339 return null; 8340 } 8341 } 8342 8343 async function ensureWalkieGraph() { 8344 const ctx = await ensureWalkieContext(); 8345 if (walkieMixNode && walkieDestNode) return { ctx, mix: walkieMixNode, dest: walkieDestNode }; 8346 8347 if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") { 8348 throw new Error("Microphone is not supported in this browser."); 8349 } 8350 const host = String(location.hostname || "").toLowerCase(); 8351 const isLocal = 8352 host === "localhost" || 8353 host === "127.0.0.1" || 8354 host === "::1" || 8355 host.startsWith("192.168.") || 8356 host.startsWith("10.") || 8357 host.startsWith("172.16.") || 8358 host.startsWith("172.17.") || 8359 host.startsWith("172.18.") || 8360 host.startsWith("172.19.") || 8361 host.startsWith("172.20.") || 8362 host.startsWith("172.21.") || 8363 host.startsWith("172.22.") || 8364 host.startsWith("172.23.") || 8365 host.startsWith("172.24.") || 8366 host.startsWith("172.25.") || 8367 host.startsWith("172.26.") || 8368 host.startsWith("172.27.") || 8369 host.startsWith("172.28.") || 8370 host.startsWith("172.29.") || 8371 host.startsWith("172.30.") || 8372 host.startsWith("172.31."); 8373 if (!window.isSecureContext && !isLocal) { 8374 throw new Error("Microphone requires HTTPS (or localhost). Use your Cloudflare tunnel URL."); 8375 } 8376 8377 if (!walkieMicStream) { 8378 walkieMicStream = await navigator.mediaDevices.getUserMedia({ 8379 audio: { 8380 echoCancellation: true, 8381 noiseSuppression: true, 8382 autoGainControl: true, 8383 }, 8384 }); 8385 } 8386 8387 const micSource = new MediaStreamAudioSourceNode(ctx, { mediaStream: walkieMicStream }); 8388 const mix = new GainNode(ctx, { gain: 1 }); 8389 micSource.connect(mix); 8390 8391 let head = mix; 8392 let tail = null; 8393 let usedWorklet = false; 8394 8395 if (ctx.audioWorklet) { 8396 try { 8397 await ctx.audioWorklet.addModule("/assets/walkie/transmission-processor.js"); 8398 const pre = new AudioWorkletNode(ctx, "transmission-sat", { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1] }); 8399 const hp1 = new BiquadFilterNode(ctx, { type: "highpass", Q: 0.9, frequency: 420 }); 8400 const hp2 = new BiquadFilterNode(ctx, { type: "highpass", Q: 0.9, frequency: 420 }); 8401 const lp1 = new BiquadFilterNode(ctx, { type: "lowpass", Q: 0.9, frequency: 4200 }); 8402 const lp2 = new BiquadFilterNode(ctx, { type: "lowpass", Q: 0.9, frequency: 4200 }); 8403 const dip = new BiquadFilterNode(ctx, { type: "peaking", frequency: 680, Q: 0.8, gain: -1.1 }); 8404 const mid = new BiquadFilterNode(ctx, { type: "peaking", frequency: 1550, Q: 1.25, gain: 2.0 }); 8405 const post = new AudioWorkletNode(ctx, "transmission-post", { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1] }); 8406 8407 head.connect(pre); 8408 pre.connect(hp1); 8409 hp1.connect(hp2); 8410 hp2.connect(lp1); 8411 lp1.connect(lp2); 8412 lp2.connect(dip); 8413 dip.connect(mid); 8414 mid.connect(post); 8415 tail = post; 8416 8417 pre.parameters.get("drive")?.setValueAtTime(0.32, ctx.currentTime); 8418 pre.parameters.get("asym")?.setValueAtTime(0.12, ctx.currentTime); 8419 pre.parameters.get("mix")?.setValueAtTime(1, ctx.currentTime); 8420 8421 post.parameters.get("drive")?.setValueAtTime(0.42, ctx.currentTime); 8422 post.parameters.get("asym")?.setValueAtTime(0.12, ctx.currentTime); 8423 post.parameters.get("comp")?.setValueAtTime(0.38, ctx.currentTime); 8424 post.parameters.get("crush")?.setValueAtTime(0.04, ctx.currentTime); 8425 post.parameters.get("badAmount")?.setValueAtTime(0.22, ctx.currentTime); 8426 post.parameters.get("wowDepth")?.setValueAtTime(0.18, ctx.currentTime); 8427 post.parameters.get("dropRate")?.setValueAtTime(0.18, ctx.currentTime); 8428 post.parameters.get("dropDepth")?.setValueAtTime(0.25, ctx.currentTime); 8429 post.parameters.get("crackle")?.setValueAtTime(0.22, ctx.currentTime); 8430 post.parameters.get("lfoRate")?.setValueAtTime(0.75, ctx.currentTime); 8431 post.parameters.get("noise")?.setValueAtTime(0.18, ctx.currentTime); 8432 post.parameters.get("hiss")?.setValueAtTime(0.16, ctx.currentTime); 8433 post.parameters.get("noiseColor")?.setValueAtTime(0.15, ctx.currentTime); 8434 post.parameters.get("outGain")?.setValueAtTime(0.92, ctx.currentTime); 8435 8436 usedWorklet = true; 8437 } catch { 8438 usedWorklet = false; 8439 } 8440 } 8441 8442 if (!usedWorklet) { 8443 const hp = new BiquadFilterNode(ctx, { type: "highpass", Q: 0.85, frequency: 420 }); 8444 const lp = new BiquadFilterNode(ctx, { type: "lowpass", Q: 0.85, frequency: 4200 }); 8445 const comp = new DynamicsCompressorNode(ctx, { threshold: -22, knee: 28, ratio: 5.2, attack: 0.004, release: 0.18 }); 8446 const shaper = new WaveShaperNode(ctx, { 8447 curve: (() => { 8448 const n = 512; 8449 const c = new Float32Array(n); 8450 for (let i = 0; i < n; i++) { 8451 const x = (i / (n - 1)) * 2 - 1; 8452 c[i] = Math.tanh(x * 2.4); 8453 } 8454 return c; 8455 })(), 8456 oversample: "2x", 8457 }); 8458 head.connect(hp); 8459 hp.connect(lp); 8460 lp.connect(comp); 8461 comp.connect(shaper); 8462 tail = shaper; 8463 } 8464 8465 const dest = new MediaStreamAudioDestinationNode(ctx); 8466 tail.connect(dest); 8467 8468 walkieMixNode = mix; 8469 walkieDestNode = dest; 8470 return { ctx, mix, dest }; 8471 } 8472 8473 function shouldHandleWalkieHotkey(evt) { 8474 if (!evt) return false; 8475 if (evt.repeat) return false; 8476 if (evt.code !== "Backquote") return false; 8477 const tag = String(document.activeElement?.tagName || "").toLowerCase(); 8478 if (tag === "input" || tag === "textarea") return false; 8479 if (document.activeElement?.isContentEditable) { 8480 const el = document.activeElement; 8481 if (el && el === chatEditor && canWalkieTalkNow()) return true; 8482 return false; 8483 } 8484 return true; 8485 } 8486 8487 function isTextEntryFocused() { 8488 const el = document.activeElement; 8489 if (!el) return false; 8490 const tag = String(el.tagName || "").toLowerCase(); 8491 if (tag === "textarea") return true; 8492 if (tag === "input") { 8493 const type = String(el.getAttribute?.("type") || "text").toLowerCase(); 8494 return !["button", "checkbox", "color", "file", "hidden", "radio", "range", "reset", "submit"].includes(type); 8495 } 8496 return Boolean(el.isContentEditable); 8497 } 8498 8499 function shouldSubmitChatOnEnter(evt) { 8500 if (!evt || evt.key !== "Enter") return false; 8501 const mode = readChatEnterModePref(); 8502 if (mode === "enter") return !(evt.shiftKey || evt.altKey || evt.ctrlKey || evt.metaKey); 8503 return Boolean(evt.ctrlKey || evt.metaKey); 8504 } 8505 8506 function cycleLayoutPresetBy(step) { 8507 if (!layoutPresetEl || !rackLayoutEnabled || layoutPresetEl.disabled) return; 8508 const options = Array.from(layoutPresetEl.options || []) 8509 .map((opt) => String(opt.value || "").trim()) 8510 .filter((v) => v); 8511 if (!options.length) return; 8512 const current = resolvePresetKey(String(layoutPresetEl.value || rackLayoutState?.presetId || "onboardingDefault")); 8513 let idx = options.indexOf(current); 8514 if (idx < 0) idx = 0; 8515 const len = options.length; 8516 const next = options[(idx + step + len) % len]; 8517 if (!next) return; 8518 layoutPresetEl.value = next; 8519 applyPreset(next); 8520 } 8521 8522 let hotkeyPanelContext = ""; 8523 function updateHotkeyPanelContextFromTarget(target) { 8524 const el = target instanceof HTMLElement ? target : null; 8525 if (!el) return; 8526 if (el.closest("#hivesPanel")) { 8527 hotkeyPanelContext = "hives"; 8528 return; 8529 } 8530 if (el.closest("aside.chat") || el.closest(".chatInstance") || el.closest("[data-panel-id^='chat:post:']")) { 8531 hotkeyPanelContext = "chat"; 8532 } 8533 } 8534 8535 function activePanelContextForHotkeys() { 8536 if (isMobileScreenMode() && appRoot) { 8537 const mobile = String(appRoot.getAttribute("data-mobile-screen") || "").trim(); 8538 if (mobile === "hives") return "hives"; 8539 if (mobile === "chat" || (mobile === "host" && mobileHostPanelId === "chat")) return "chat"; 8540 } 8541 const ae = document.activeElement instanceof HTMLElement ? document.activeElement : null; 8542 if (ae) { 8543 if (ae.closest("#hivesPanel")) return "hives"; 8544 if (ae.closest("aside.chat") || ae.closest(".chatInstance") || ae.closest("[data-panel-id^='chat:post:']")) return "chat"; 8545 } 8546 return hotkeyPanelContext || ""; 8547 } 8548 8549 function cycleHiveViewBy(step) { 8550 if (!hiveTabsEl) return false; 8551 const views = Array.from(hiveTabsEl.querySelectorAll("button[data-hiveview]:not([disabled])")) 8552 .map((b) => String(b.getAttribute("data-hiveview") || "").trim()) 8553 .filter(Boolean); 8554 if (!views.length) return false; 8555 let idx = views.indexOf(String(activeHiveView || "all")); 8556 if (idx < 0) idx = 0; 8557 const len = views.length; 8558 const next = views[(idx + step + len) % len]; 8559 if (!next || next === activeHiveView) return false; 8560 activeHiveView = next; 8561 renderFeed(); 8562 return true; 8563 } 8564 8565 function cycleChatContextBy(step) { 8566 renderChatContextSelect(); 8567 if (!(chatContextSelectEl instanceof HTMLSelectElement)) return false; 8568 const items = [ 8569 "__list__", 8570 ...Array.from(chatContextSelectEl.options || []) 8571 .map((o) => String(o.value || "").trim()) 8572 .filter((v) => v && (v.startsWith("dm:") || v.startsWith("post:"))), 8573 ]; 8574 if (items.length <= 1) return false; 8575 const current = activeDmThreadId ? `dm:${activeDmThreadId}` : activeChatPostId ? `post:${activeChatPostId}` : "__list__"; 8576 let idx = items.indexOf(current); 8577 if (idx < 0) idx = 0; 8578 const len = items.length; 8579 const next = items[(idx + step + len) % len]; 8580 if (!next || next === current) return false; 8581 if (next === "__list__") { 8582 if (activeChatPostId && ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); 8583 activeChatPostId = null; 8584 activeDmThreadId = null; 8585 activeMapsRoomId = ""; 8586 activeMapsRoomTitle = ""; 8587 setReplyToMessage(null); 8588 renderChatPanel(true); 8589 return true; 8590 } 8591 if (next.startsWith("dm:")) { 8592 return openChatContextValue(next, { preserveFocus: false }); 8593 } 8594 if (next.startsWith("post:")) { 8595 return openChatContextValue(next, { preserveFocus: false }); 8596 } 8597 return false; 8598 } 8599 8600 function canWalkieTalkNow() { 8601 if (!loggedInUser || !ws || ws.readyState !== WebSocket.OPEN) return false; 8602 if (!activeChatPostId) return false; 8603 const post = posts.get(activeChatPostId); 8604 if (!post || post.deleted) return false; 8605 return String(post.mode || post.chatMode || "").toLowerCase() === "walkie"; 8606 } 8607 8608 async function startWalkieRecording() { 8609 if (walkieRecording) return; 8610 if (!canWalkieTalkNow()) return; 8611 try { 8612 if (walkieStatusEl) walkieStatusEl.textContent = "Requesting microphone..."; 8613 const { ctx, mix, dest } = await ensureWalkieGraph(); 8614 if (ctx.state === "suspended") await ctx.resume(); 8615 8616 walkieChunks = []; 8617 const stream = dest.stream; 8618 const preferred = [ 8619 "audio/webm;codecs=opus", 8620 "audio/ogg;codecs=opus", 8621 "audio/webm", 8622 "audio/ogg", 8623 ]; 8624 let mimeType = ""; 8625 for (const t of preferred) { 8626 if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(t)) { 8627 mimeType = t; 8628 break; 8629 } 8630 } 8631 const rec = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); 8632 walkieRecorder = rec; 8633 walkieStartAt = Date.now(); 8634 walkieRecording = true; 8635 if (walkieBarEl) walkieBarEl.classList.add("isRecording"); 8636 if (walkieStatusEl) walkieStatusEl.textContent = "Recording... release to send."; 8637 8638 const dispatch = await ensureWalkieDispatchBuffer(); 8639 if (dispatch) { 8640 const src = new AudioBufferSourceNode(ctx, { buffer: dispatch }); 8641 const g = new GainNode(ctx, { gain: 0.75 }); 8642 src.connect(g); 8643 g.connect(mix); 8644 src.start(); 8645 // Local feedback so user hears the click (quiet). 8646 const mon = new GainNode(ctx, { gain: 0.10 }); 8647 g.connect(mon); 8648 mon.connect(ctx.destination); 8649 } 8650 8651 rec.addEventListener("dataavailable", (e) => { 8652 if (e.data && e.data.size > 0) walkieChunks.push(e.data); 8653 }); 8654 rec.addEventListener("stop", async () => { 8655 const tookMs = Date.now() - walkieStartAt; 8656 walkieRecording = false; 8657 if (walkieBarEl) walkieBarEl.classList.remove("isRecording"); 8658 if (walkieStatusEl) walkieStatusEl.textContent = "Processing..."; 8659 8660 // Give some browsers a tick to deliver the final dataavailable. 8661 await new Promise((r) => window.setTimeout(r, 0)); 8662 const blob = new Blob(walkieChunks, { type: rec.mimeType || "audio/webm" }); 8663 walkieChunks = []; 8664 if (!blob || blob.size < 800 || tookMs < 160) { 8665 if (walkieStatusEl) walkieStatusEl.textContent = ""; 8666 toast("Walkie Talkie", "No audio captured. Check mic permissions/input and try again."); 8667 return; 8668 } 8669 8670 const ext = (rec.mimeType || "").includes("ogg") ? "ogg" : "webm"; 8671 const file = new File([blob], `walkie-${Date.now()}.${ext}`, { type: rec.mimeType || blob.type || "audio/webm" }); 8672 if (walkieStatusEl) walkieStatusEl.textContent = "Uploading..."; 8673 const url = await uploadMediaFile(file, "audio"); 8674 if (!url) { 8675 if (walkieStatusEl) walkieStatusEl.textContent = ""; 8676 return; 8677 } 8678 const post = posts.get(activeChatPostId); 8679 if (!post || post.deleted) { 8680 if (walkieStatusEl) walkieStatusEl.textContent = ""; 8681 return; 8682 } 8683 ws.send(JSON.stringify({ type: "chatMessage", postId: activeChatPostId, text: "", html: `<audio controls preload=\"none\" src=\"${escapeHtml(url)}\"></audio>` })); 8684 if (walkieStatusEl) walkieStatusEl.textContent = "Sent."; 8685 window.setTimeout(() => { 8686 if (walkieStatusEl && walkieStatusEl.textContent === "Sent.") walkieStatusEl.textContent = ""; 8687 }, 900); 8688 playSfx("ping", { volume: 0.22 }); 8689 }); 8690 8691 // Timeslice helps avoid empty blobs in some browsers. 8692 rec.start(250); 8693 } catch (e) { 8694 walkieRecording = false; 8695 if (walkieBarEl) walkieBarEl.classList.remove("isRecording"); 8696 const name = String(e?.name || ""); 8697 const msg = String(e?.message || ""); 8698 const pretty = 8699 name === "NotAllowedError" 8700 ? "Microphone permission denied. Allow mic access in your browser settings." 8701 : name === "NotFoundError" 8702 ? "No microphone device found." 8703 : name === "NotReadableError" 8704 ? "Microphone is in use by another app." 8705 : msg || "Microphone recording failed."; 8706 if (walkieStatusEl) walkieStatusEl.textContent = ""; 8707 toast("Walkie Talkie", pretty); 8708 } 8709 } 8710 8711 async function stopWalkieRecording() { 8712 if (!walkieRecorder || !walkieRecording) return; 8713 try { 8714 const { ctx, mix } = await ensureWalkieGraph(); 8715 const dispatch = await ensureWalkieDispatchBuffer(); 8716 if (dispatch) { 8717 const src = new AudioBufferSourceNode(ctx, { buffer: dispatch }); 8718 const g = new GainNode(ctx, { gain: 0.55 }); 8719 src.connect(g); 8720 g.connect(mix); 8721 src.start(); 8722 const mon = new GainNode(ctx, { gain: 0.08 }); 8723 g.connect(mon); 8724 mon.connect(ctx.destination); 8725 window.setTimeout(() => { 8726 try { 8727 if (walkieRecorder && walkieRecorder.state !== "inactive") walkieRecorder.stop(); 8728 } catch { 8729 // ignore 8730 } 8731 walkieRecorder = null; 8732 }, 160); 8733 return; 8734 } 8735 } catch { 8736 // ignore 8737 } 8738 try { 8739 if (walkieRecorder && walkieRecorder.state !== "inactive") walkieRecorder.stop(); 8740 } catch { 8741 // ignore 8742 } 8743 walkieRecorder = null; 8744 } 8745 8746 function insertAudioTag(target, srcUrl) { 8747 if (!srcUrl) return; 8748 target.focus(); 8749 const safe = escapeHtml(srcUrl); 8750 document.execCommand("insertHTML", false, `<audio controls preload="none" src="${safe}"></audio>`); 8751 } 8752 8753 function installDropUpload(targetEl, { allowImages = true, allowAudio = true } = {}) { 8754 if (!targetEl) return; 8755 const setActive = (on) => { 8756 try { 8757 targetEl.classList.toggle("isDropActive", Boolean(on)); 8758 } catch { 8759 // ignore 8760 } 8761 }; 8762 targetEl.addEventListener("dragover", (e) => { 8763 if (!e.dataTransfer) return; 8764 if (!e.dataTransfer.types || !Array.from(e.dataTransfer.types).includes("Files")) return; 8765 e.preventDefault(); 8766 setActive(true); 8767 }); 8768 targetEl.addEventListener("dragleave", () => setActive(false)); 8769 targetEl.addEventListener("drop", async (e) => { 8770 setActive(false); 8771 const files = e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : []; 8772 if (!files.length) return; 8773 e.preventDefault(); 8774 e.stopPropagation(); 8775 8776 for (const file of files.slice(0, 4)) { 8777 const type = String(file.type || "").toLowerCase(); 8778 const name = String(file.name || "").toLowerCase(); 8779 const isImg = type.startsWith("image/") || /\.(gif|png|jpe?g|webp)$/.test(name); 8780 const isAud = type.startsWith("audio/") || /\.(mp3|wav|ogg|m4a|aac|webm)$/.test(name); 8781 if (isImg && allowImages) { 8782 const url = await uploadMediaFile(file, "image"); 8783 if (!url) continue; 8784 targetEl.focus(); 8785 document.execCommand("insertImage", false, url); 8786 } else if (isAud && allowAudio) { 8787 const url = await uploadMediaFile(file, "audio"); 8788 if (!url) continue; 8789 insertAudioTag(targetEl, url); 8790 } 8791 } 8792 }); 8793 } 8794 8795 document.querySelector(".editorShell .toolbar")?.addEventListener("click", (e) => { 8796 const btn = e.target.closest("button"); 8797 if (!btn) return; 8798 const cmd = btn.getAttribute("data-cmd"); 8799 if (cmd) { 8800 runCmd(editor, cmd); 8801 return; 8802 } 8803 if (btn.getAttribute("data-link")) { 8804 runLink(editor); 8805 return; 8806 } 8807 if (btn.getAttribute("data-postimg")) { 8808 postImageInput?.click(); 8809 return; 8810 } 8811 if (btn.getAttribute("data-postaudio")) { 8812 postAudioInput?.click(); 8813 return; 8814 } 8815 if (btn.getAttribute("data-postemoji")) runEmoji(editor); 8816 }); 8817 8818 document.addEventListener("click", (e) => { 8819 const btn = e.target.closest?.("button"); 8820 if (!btn) return; 8821 const toolbar = btn.closest?.(".chatComposer .toolbar"); 8822 if (!toolbar) return; 8823 const composer = toolbar.closest?.(".chatComposer"); 8824 if (!composer) return; 8825 const targetEditor = composer.querySelector?.(".chatEditor") || chatEditor; 8826 if (!(targetEditor instanceof HTMLElement)) return; 8827 chatUploadTargetEditor = targetEditor; 8828 8829 const cmd = btn.getAttribute("data-chatcmd"); 8830 if (cmd) { 8831 runCmd(targetEditor, cmd); 8832 return; 8833 } 8834 if (btn.getAttribute("data-chatlink")) { 8835 runLink(targetEditor); 8836 return; 8837 } 8838 if (btn.getAttribute("data-chatimg")) { 8839 chatImageInput?.click(); 8840 return; 8841 } 8842 if (btn.getAttribute("data-chataudio")) { 8843 chatAudioInput?.click(); 8844 return; 8845 } 8846 if (btn.getAttribute("data-chatemoji")) runEmoji(targetEditor); 8847 }); 8848 8849 profileBioToolbar?.addEventListener("click", (e) => { 8850 const btn = e.target.closest("button"); 8851 if (!btn) return; 8852 const cmd = btn.getAttribute("data-profilecmd"); 8853 if (cmd) { 8854 runCmd(profileBioEditor, cmd); 8855 return; 8856 } 8857 if (btn.getAttribute("data-profilelink")) { 8858 runLink(profileBioEditor); 8859 return; 8860 } 8861 if (btn.getAttribute("data-profileimg")) { 8862 profileBioImageFileInput?.click(); 8863 return; 8864 } 8865 if (btn.getAttribute("data-profileaudio")) { 8866 profileBioAudioFileInput?.click(); 8867 return; 8868 } 8869 if (btn.getAttribute("data-profileemoji")) runEmoji(profileBioEditor); 8870 }); 8871 8872 editModalToolbar?.addEventListener("click", (e) => { 8873 const btn = e.target.closest("button"); 8874 if (!btn) return; 8875 const cmd = btn.getAttribute("data-editcmd"); 8876 if (cmd) { 8877 runCmd(editModalEditor, cmd); 8878 return; 8879 } 8880 if (btn.getAttribute("data-editlink")) { 8881 runLink(editModalEditor); 8882 return; 8883 } 8884 if (btn.getAttribute("data-editimg")) { 8885 editModalImageInput?.click(); 8886 return; 8887 } 8888 if (btn.getAttribute("data-editaudio")) { 8889 editModalAudioInput?.click(); 8890 return; 8891 } 8892 if (btn.getAttribute("data-editemoji")) runEmoji(editModalEditor); 8893 }); 8894 8895 editModalImageInput?.addEventListener("change", async () => { 8896 const file = editModalImageInput.files && editModalImageInput.files[0] ? editModalImageInput.files[0] : null; 8897 editModalImageInput.value = ""; 8898 if (!file) return; 8899 const url = await uploadMediaFile(file, "image"); 8900 if (!url) return; 8901 editModalEditor?.focus(); 8902 document.execCommand("insertImage", false, url); 8903 }); 8904 8905 editModalAudioInput?.addEventListener("change", async () => { 8906 const file = editModalAudioInput.files && editModalAudioInput.files[0] ? editModalAudioInput.files[0] : null; 8907 editModalAudioInput.value = ""; 8908 if (!file) return; 8909 const url = await uploadMediaFile(file, "audio"); 8910 if (!url) return; 8911 insertAudioTag(editModalEditor, url); 8912 }); 8913 8914 editModal?.addEventListener("click", (e) => { 8915 if (e.target?.getAttribute?.("data-modalclose")) setEditModalOpen(false); 8916 }); 8917 8918 editModalCloseBtn?.addEventListener("click", () => setEditModalOpen(false)); 8919 editModalCancelBtn?.addEventListener("click", () => setEditModalOpen(false)); 8920 8921 editModalSaveBtn?.addEventListener("click", () => { 8922 if (!editContext) return; 8923 if (!editModalEditor) return; 8924 const { html, text, hasImg, hasAudio } = collectEditorPayload(editModalEditor); 8925 if (!text && !hasImg && !hasAudio) { 8926 if (editModalStatus) editModalStatus.textContent = "Please add text, an image, or audio."; 8927 editModalEditor.focus(); 8928 return; 8929 } 8930 if (editContext.kind === "post") { 8931 const title = String(editModalPostTitleInput?.value || "") 8932 .replace(/\s+/g, " ") 8933 .trim() 8934 .slice(0, 96); 8935 if (!title) { 8936 if (editModalStatus) editModalStatus.textContent = "Title is required."; 8937 editModalPostTitleInput?.focus(); 8938 return; 8939 } 8940 const post = posts.get(editContext.postId); 8941 const wasProtected = Boolean(post?.protected); 8942 const wantsProtected = Boolean(editModalProtectedToggle?.checked); 8943 const password = String(editModalPasswordInput?.value || ""); 8944 if (wantsProtected && !wasProtected && password.trim().length < 4) { 8945 if (editModalStatus) editModalStatus.textContent = "Set a password (min 4 chars) to protect this post."; 8946 editModalPasswordInput?.focus(); 8947 return; 8948 } 8949 const keywords = parseKeywordsInput(editModalKeywordsInput?.value || ""); 8950 const collectionId = String(editModalCollectionSelect?.value || post?.collectionId || "general"); 8951 const mode = Boolean(editModalWalkieToggle?.checked) ? "walkie" : "text"; 8952 ws.send( 8953 JSON.stringify({ 8954 type: "editPost", 8955 postId: editContext.postId, 8956 title, 8957 content: text, 8958 contentHtml: html, 8959 keywords, 8960 collectionId, 8961 protected: wantsProtected, 8962 password: password.trim(), 8963 mode 8964 }) 8965 ); 8966 setEditModalOpen(false); 8967 return; 8968 } 8969 if (editContext.kind === "chat") { 8970 ws.send(JSON.stringify({ type: "editChatMessage", postId: editContext.postId, messageId: editContext.messageId, text, html })); 8971 setEditModalOpen(false); 8972 } 8973 }); 8974 8975 authForm.addEventListener("submit", (e) => { 8976 e.preventDefault(); 8977 const username = authUser.value.trim(); 8978 const password = authPass.value; 8979 ws.send(JSON.stringify({ type: "login", username, password })); 8980 }); 8981 8982 registerBtn.addEventListener("click", () => { 8983 const username = authUser.value.trim(); 8984 const password = authPass.value; 8985 const code = authCode.value.trim(); 8986 ws.send(JSON.stringify({ type: "register", username, password, code })); 8987 }); 8988 8989 logoutBtn.addEventListener("click", () => ws.send(JSON.stringify({ type: "logout" }))); 8990 8991 profileImageInput.addEventListener("change", async () => { 8992 profileStatus.textContent = ""; 8993 const file = profileImageInput.files && profileImageInput.files[0] ? profileImageInput.files[0] : null; 8994 if (!file) return; 8995 try { 8996 pendingProfileImage = await resizeImageToSquareDataUrl(file, 96); 8997 if (pendingProfileImage) { 8998 profilePreview.src = pendingProfileImage; 8999 profilePreview.classList.add("hasImg"); 9000 } 9001 } catch { 9002 profileStatus.textContent = "Failed to load image."; 9003 } 9004 }); 9005 9006 removeProfileImageBtn.addEventListener("click", () => { 9007 pendingProfileImage = ""; 9008 profilePreview.removeAttribute("src"); 9009 profilePreview.classList.remove("hasImg"); 9010 }); 9011 9012 saveProfileBtn.addEventListener("click", () => { 9013 profileStatus.textContent = ""; 9014 const color = nameColorInput.value; 9015 ws.send(JSON.stringify({ type: "updateProfile", image: pendingProfileImage, color })); 9016 }); 9017 9018 profileBackBtn?.addEventListener("click", () => setCenterView("hives")); 9019 9020 profileEditToggleBtn?.addEventListener("click", () => { 9021 isEditingProfile = !isEditingProfile; 9022 if (profileEditToggleBtn) profileEditToggleBtn.textContent = isEditingProfile ? "Close editor" : "Edit profile"; 9023 renderCenterPanels(); 9024 }); 9025 9026 profileCancelBtn?.addEventListener("click", () => { 9027 isEditingProfile = false; 9028 if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile"; 9029 renderCenterPanels(); 9030 }); 9031 9032 profileAddLinkBtn?.addEventListener("click", () => { 9033 const links = profileLinksFromEditor(); 9034 links.push({ label: "Link", url: "https://" }); 9035 renderProfileLinksEditor(links); 9036 }); 9037 9038 profileLinksEditor?.addEventListener("click", (e) => { 9039 const btn = e.target.closest("[data-linkremove]"); 9040 if (!btn) return; 9041 const idx = Number(btn.getAttribute("data-linkremove") || -1); 9042 const links = profileLinksFromEditor(); 9043 if (idx < 0 || idx >= links.length) return; 9044 links.splice(idx, 1); 9045 renderProfileLinksEditor(links); 9046 }); 9047 9048 profileThemeSongUploadBtn?.addEventListener("click", () => profileThemeSongFileInput?.click()); 9049 9050 profileThemeSongClearBtn?.addEventListener("click", () => syncProfileSongPreview("")); 9051 9052 profileThemeSongFileInput?.addEventListener("change", async () => { 9053 const file = profileThemeSongFileInput.files && profileThemeSongFileInput.files[0] ? profileThemeSongFileInput.files[0] : null; 9054 profileThemeSongFileInput.value = ""; 9055 if (!file) return; 9056 const url = await uploadMediaFile(file, "audio"); 9057 if (!url) return; 9058 syncProfileSongPreview(url); 9059 }); 9060 9061 profileBioImageFileInput?.addEventListener("change", async () => { 9062 const file = profileBioImageFileInput.files && profileBioImageFileInput.files[0] ? profileBioImageFileInput.files[0] : null; 9063 profileBioImageFileInput.value = ""; 9064 if (!file) return; 9065 const url = await uploadMediaFile(file, "image"); 9066 if (!url) return; 9067 profileBioEditor?.focus(); 9068 document.execCommand("insertImage", false, url); 9069 }); 9070 9071 profileBioAudioFileInput?.addEventListener("change", async () => { 9072 const file = profileBioAudioFileInput.files && profileBioAudioFileInput.files[0] ? profileBioAudioFileInput.files[0] : null; 9073 profileBioAudioFileInput.value = ""; 9074 if (!file) return; 9075 const url = await uploadMediaFile(file, "audio"); 9076 if (!url) return; 9077 insertAudioTag(profileBioEditor, url); 9078 }); 9079 9080 profileSaveBtn?.addEventListener("click", () => { 9081 if (!loggedInUser || !activeProfile || activeProfile.username !== loggedInUser) return; 9082 const pronouns = String(profilePronounsInput?.value || "") 9083 .replace(/\s+/g, " ") 9084 .trim() 9085 .slice(0, 40); 9086 const bioHtml = String(profileBioEditor?.innerHTML || ""); 9087 const themeSongUrl = String(profileThemeSongUrlInput?.value || "").trim(); 9088 const links = profileLinksFromEditor(); 9089 ws.send(JSON.stringify({ type: "updateProfile", pronouns, bioHtml, themeSongUrl, links })); 9090 }); 9091 9092 newPostForm.addEventListener("submit", (e) => { 9093 e.preventDefault(); 9094 if (onboardingNeedsAcceptanceNow()) { 9095 toast("Onboarding", "Accept server rules in Account before creating hives."); 9096 return; 9097 } 9098 const title = String(postTitleInput?.value || "") 9099 .replace(/\s+/g, " ") 9100 .trim() 9101 .slice(0, 96); 9102 if (!title) { 9103 toast("Post title", "Please add a short title."); 9104 postTitleInput?.focus(); 9105 return; 9106 } 9107 const html = editor.innerHTML.trim(); 9108 const text = editor.innerText.trim(); 9109 const hasImg = Boolean(editor.querySelector("img")); 9110 const hasAudio = Boolean(editor.querySelector("audio")); 9111 if (!text && !hasImg && !hasAudio) { 9112 toast("Post body", "Please add body text, image, or audio."); 9113 editor.focus(); 9114 return; 9115 } 9116 9117 const keywords = parseKeywords(keywordsEl.value); 9118 const collectionId = String(postCollectionEl?.value || "").trim(); 9119 if (!collectionId) { 9120 toast("Collection", "Please choose a collection."); 9121 return; 9122 } 9123 const ttlMinutes = Number(ttlMinutesEl.value || 60); 9124 const canMakePermanent = 9125 loggedInRole === "owner" || loggedInRole === "moderator" || Boolean(normalizeInstanceBranding(instanceBranding).allowMemberPermanentPosts); 9126 const minMinutes = canMakePermanent ? 0 : 1; 9127 const ttl = Math.max(minMinutes, Math.min(2880, Math.floor(ttlMinutes))) * 60_000; 9128 9129 const isProtected = Boolean(isProtectedEl?.checked); 9130 const password = typeof postPasswordEl?.value === "string" ? postPasswordEl.value : ""; 9131 if (isProtected && password.trim().length < 4) { 9132 toast("Protected post", "Password must be at least 4 characters."); 9133 return; 9134 } 9135 const mode = Boolean(isWalkieEl?.checked) ? "walkie" : "text"; 9136 ws.send( 9137 JSON.stringify({ type: "newPost", title, collectionId, contentHtml: html, content: text, keywords, ttl, protected: isProtected, password, mode }) 9138 ); 9139 if (postTitleInput) postTitleInput.value = ""; 9140 editor.innerHTML = ""; 9141 if (postPasswordEl) postPasswordEl.value = ""; 9142 if (isProtectedEl) isProtectedEl.checked = false; 9143 if (isWalkieEl) isWalkieEl.checked = false; 9144 if (isMobileSwipeMode()) setComposerOpen(false); 9145 }); 9146 9147 toggleComposerBtn?.addEventListener("click", () => { 9148 if (isMobileScreenMode()) { 9149 setComposerOpen(true); 9150 const layout = loadMobileLayout(); 9151 layout.active = "composer"; 9152 saveMobileLayout(layout); 9153 setMobileScreen("composer"); 9154 renderMobileNav(); 9155 if (composerOpen) (postTitleInput || editor)?.focus(); 9156 return; 9157 } 9158 setComposerOpen(!composerOpen); 9159 if (composerOpen) (postTitleInput || editor)?.focus(); 9160 }); 9161 toggleComposerInlineBtn?.addEventListener("click", () => setComposerOpen(false)); 9162 9163 function submitChat() { 9164 if (onboardingNeedsAcceptanceNow()) { 9165 toast("Onboarding", "Accept server rules in Account before chatting."); 9166 return; 9167 } 9168 const html = chatEditor.innerHTML.trim(); 9169 const text = chatEditor.innerText.trim(); 9170 const hasImg = Boolean(chatEditor.querySelector("img")); 9171 const hasAudio = Boolean(chatEditor.querySelector("audio")); 9172 if (activeDmThreadId) { 9173 if (!text && !hasImg && !hasAudio) return; 9174 if (!loggedInUser) { 9175 toast("Sign in required", "Sign in to send DMs."); 9176 return; 9177 } 9178 const thread = dmThreadsById.get(activeDmThreadId) || null; 9179 if (!thread) { 9180 toast("DMs", "This DM thread is unavailable."); 9181 return; 9182 } 9183 if (String(thread.status || "") !== "active") { 9184 toast("DMs", "You can only send messages after the DM is accepted."); 9185 return; 9186 } 9187 ws.send(JSON.stringify({ type: "dmSend", threadId: activeDmThreadId, text, html })); 9188 chatEditor.innerHTML = ""; 9189 return; 9190 } 9191 9192 if (isMapChatActive()) { 9193 if (!text && !hasImg && !hasAudio) return; 9194 if (hasImg || hasAudio) { 9195 toast("Maps chat", "Maps chat is text-only for now."); 9196 return; 9197 } 9198 if (!loggedInUser) { 9199 toast("Sign in required", "Sign in to chat in maps."); 9200 return; 9201 } 9202 try { 9203 ws.send(JSON.stringify({ type: "plugin:maps:chatSend", mapId: activeMapsRoomId, scope: normalizeMapChatScope(activeMapsChatScope), text })); 9204 // Optimistic add so it feels instant (server will also echo back). 9205 pushMapChatMessage(activeMapsRoomId, activeMapsChatScope, { 9206 id: `local_${Date.now()}_${Math.random().toString(16).slice(2)}`, 9207 fromUser: loggedInUser, 9208 text, 9209 createdAt: Date.now(), 9210 }); 9211 } catch { 9212 // ignore 9213 } 9214 chatEditor.innerHTML = ""; 9215 setReplyToMessage(null); 9216 renderChatPanel(true); 9217 return; 9218 } 9219 9220 if (!activeChatPostId || (!text && !hasImg && !hasAudio)) return; 9221 const post = posts.get(activeChatPostId); 9222 if (post && String(post.mode || post.chatMode || "").toLowerCase() === "walkie") { 9223 toast("Walkie Talkie", "This hive is walkie-only. Hold ~ to talk."); 9224 return; 9225 } 9226 if (post?.readOnly && !(loggedInRole === "owner" || loggedInRole === "moderator")) { 9227 toast("Read-only", "This hive is read-only."); 9228 return; 9229 } 9230 if (post?.deleted) { 9231 toast("Unavailable", "This post was deleted."); 9232 return; 9233 } 9234 const replyToId = replyToMessage?.id ? String(replyToMessage.id) : ""; 9235 const wantsMod = Boolean(canModerate && chatModToggleEl instanceof HTMLInputElement && chatModToggleEl.checked); 9236 ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); 9237 ws.send(JSON.stringify({ type: "chatMessage", postId: activeChatPostId, text, html, replyToId, asMod: wantsMod })); 9238 chatEditor.innerHTML = ""; 9239 setReplyToMessage(null); 9240 } 9241 9242 filterKeywordsEl.addEventListener("input", () => renderFeed()); 9243 filterAuthorEl?.addEventListener("input", () => renderFeed()); 9244 sortByEl?.addEventListener("change", () => { 9245 updateMobileSortCycleLabel(); 9246 renderFeed(); 9247 }); 9248 hiveTabsEl?.addEventListener("click", (e) => { 9249 const btn = e.target.closest("[data-hiveview]"); 9250 if (!btn) return; 9251 const next = btn.getAttribute("data-hiveview") || "all"; 9252 if (!loggedInUser && next !== "all") { 9253 toast("Sign in required", "Sign in to use Starred and Hidden views."); 9254 return; 9255 } 9256 activeHiveView = next; 9257 renderFeed(); 9258 }); 9259 clearFilterBtn.addEventListener("click", () => { 9260 filterKeywordsEl.value = ""; 9261 if (filterAuthorEl) filterAuthorEl.value = ""; 9262 if (sortByEl) sortByEl.value = "activity"; 9263 updateMobileSortCycleLabel(); 9264 activeHiveView = "all"; 9265 renderFeed(); 9266 }); 9267 9268 mobileHiveSearchBtn?.addEventListener("click", () => { 9269 const initial = String(filterAuthorEl?.value || "").trim() 9270 ? `@${String(filterAuthorEl?.value || "").trim()}` 9271 : String(filterKeywordsEl?.value || "").trim(); 9272 const raw = prompt("Search hives by @author or keywords:", initial); 9273 if (raw === null) return; 9274 const q = String(raw || "").trim(); 9275 if (!q) { 9276 if (filterAuthorEl) filterAuthorEl.value = ""; 9277 if (filterKeywordsEl) filterKeywordsEl.value = ""; 9278 renderFeed(); 9279 return; 9280 } 9281 const parts = q.split(/\s+/).filter(Boolean); 9282 const authorPart = parts.find((part) => part.startsWith("@")) || (q.startsWith("@") ? q : ""); 9283 const author = authorPart.replace(/^@+/, "").trim(); 9284 const keywordParts = authorPart ? parts.filter((part) => part !== authorPart) : parts; 9285 if (filterAuthorEl) filterAuthorEl.value = author || ""; 9286 if (filterKeywordsEl) filterKeywordsEl.value = keywordParts.join(", "); 9287 renderFeed(); 9288 }); 9289 9290 mobileSortCycleBtn?.addEventListener("click", () => { 9291 if (!sortByEl) return; 9292 const order = ["activity", "popular", "expiring"]; 9293 const current = String(sortByEl.value || "activity"); 9294 const at = Math.max(0, order.indexOf(current)); 9295 const next = order[(at + 1) % order.length]; 9296 sortByEl.value = next; 9297 updateMobileSortCycleLabel(); 9298 renderFeed(); 9299 }); 9300 9301 feedEl.addEventListener("click", (e) => { 9302 const profileLink = e.target.closest("[data-viewprofile]"); 9303 if (profileLink) { 9304 const username = profileLink.getAttribute("data-viewprofile") || ""; 9305 if (username) openUserProfile(username); 9306 return; 9307 } 9308 9309 const menuBtn = e.target.closest("button[data-postmenu]"); 9310 if (menuBtn) { 9311 const postId = menuBtn.getAttribute("data-postmenu") || ""; 9312 if (!postId) return; 9313 const wasOpen = openPostMenuId === postId; 9314 9315 for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden"); 9316 for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false"); 9317 9318 if (!wasOpen) { 9319 const panel = feedEl.querySelector(`[data-postmenu-panel="${cssEscape(postId)}"]`); 9320 if (panel) panel.classList.remove("hidden"); 9321 menuBtn.setAttribute("aria-expanded", "true"); 9322 openPostMenuId = postId; 9323 } else { 9324 openPostMenuId = ""; 9325 } 9326 return; 9327 } 9328 9329 const chatBtn = e.target.closest("button[data-chat]"); 9330 if (chatBtn) { 9331 if (openPostMenuId) { 9332 for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden"); 9333 for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false"); 9334 openPostMenuId = ""; 9335 } 9336 const postId = chatBtn.getAttribute("data-chat"); 9337 const post = postId ? posts.get(postId) : null; 9338 if (post?.locked) unlockPostFlow(postId, true); 9339 else openChat(postId, { sourceEl: chatBtn }); 9340 return; 9341 } 9342 9343 const boostBtn = e.target.closest("button[data-boostbtn]"); 9344 if (boostBtn) { 9345 const postId = boostBtn.getAttribute("data-boostbtn"); 9346 const card = boostBtn.closest(".post"); 9347 const sel = card ? card.querySelector("select[data-boostsel]") : null; 9348 const boostMs = sel ? Number(sel.value) : 3_600_000; 9349 ws.send(JSON.stringify({ type: "boostPost", postId, boostMs })); 9350 return; 9351 } 9352 9353 const reportPostBtn = e.target.closest("button[data-reportpost]"); 9354 if (reportPostBtn) { 9355 if (openPostMenuId) { 9356 for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden"); 9357 for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false"); 9358 openPostMenuId = ""; 9359 } 9360 const postId = reportPostBtn.getAttribute("data-reportpost") || ""; 9361 if (!postId) return; 9362 const post = posts.get(postId); 9363 if (post?.deleted) { 9364 toast("Unavailable", "This post was deleted."); 9365 return; 9366 } 9367 const reason = promptReason("post report"); 9368 if (!reason) return; 9369 ws.send(JSON.stringify({ type: "reportCreate", targetType: "post", targetId: postId, postId, reason })); 9370 return; 9371 } 9372 9373 const hideBtn = e.target.closest("button[data-hidepost]"); 9374 if (hideBtn) { 9375 if (openPostMenuId) { 9376 for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden"); 9377 for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false"); 9378 openPostMenuId = ""; 9379 } 9380 const postId = hideBtn.getAttribute("data-hidepost") || ""; 9381 if (!postId) return; 9382 const hidden = prefSet("hiddenPostIds").has(postId); 9383 ws.send(JSON.stringify({ type: hidden ? "unhidePost" : "hidePost", postId })); 9384 return; 9385 } 9386 9387 const react = e.target.closest("[data-react]"); 9388 if (react && react.getAttribute("data-kind") === "post") { 9389 if (openPostMenuId) { 9390 for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden"); 9391 for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false"); 9392 openPostMenuId = ""; 9393 } 9394 const postId = react.getAttribute("data-postid") || ""; 9395 const emoji = react.getAttribute("data-emoji") || ""; 9396 if (!postId || !emoji) return; 9397 const post = posts.get(postId); 9398 if (post?.deleted) { 9399 toast("Unavailable", "This post was deleted."); 9400 return; 9401 } 9402 markReactPulse("post", postId, emoji); 9403 toggleMyReact("post", postId, emoji); 9404 ws.send(JSON.stringify({ type: "react", targetType: "post", postId, emoji })); 9405 renderFeed(); 9406 return; 9407 } 9408 9409 const editPostBtn = e.target.closest("button[data-editpost]"); 9410 if (editPostBtn) { 9411 if (openPostMenuId) { 9412 for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden"); 9413 for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false"); 9414 openPostMenuId = ""; 9415 } 9416 const postId = editPostBtn.getAttribute("data-editpost") || ""; 9417 const post = postId ? posts.get(postId) : null; 9418 if (!post || post.deleted || post.locked) return; 9419 openEditModalForPost(post); 9420 return; 9421 } 9422 9423 const deletePostBtn = e.target.closest("button[data-deletepost]"); 9424 if (deletePostBtn) { 9425 if (openPostMenuId) { 9426 for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden"); 9427 for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false"); 9428 openPostMenuId = ""; 9429 } 9430 const postId = deletePostBtn.getAttribute("data-deletepost") || ""; 9431 if (!postId) return; 9432 const ok = confirm("Delete this post? It will show as deleted."); 9433 if (!ok) return; 9434 ws.send(JSON.stringify({ type: "deletePostSelf", postId })); 9435 } 9436 }); 9437 9438 window.addEventListener("keydown", (e) => { 9439 if (e.key !== "Escape") return; 9440 if (!openPostMenuId) return; 9441 for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden"); 9442 for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false"); 9443 openPostMenuId = ""; 9444 }); 9445 9446 window.addEventListener("keydown", (e) => { 9447 if (e.defaultPrevented) return; 9448 if (e.repeat) return; 9449 if (e.key === "?" && !isTextEntryFocused()) { 9450 e.preventDefault(); 9451 setShortcutHelpOpen(true); 9452 return; 9453 } 9454 if (e.altKey || e.ctrlKey || e.metaKey) return; 9455 if (isTextEntryFocused()) return; 9456 const ctx = activePanelContextForHotkeys(); 9457 const plus = e.key === "=" || e.code === "NumpadAdd"; 9458 const minus = e.key === "-" || e.code === "NumpadSubtract"; 9459 if (ctx === "hives" && (plus || minus)) { 9460 e.preventDefault(); 9461 cycleHiveViewBy(plus ? 1 : -1); 9462 return; 9463 } 9464 if (ctx === "chat" && (plus || minus)) { 9465 e.preventDefault(); 9466 cycleChatContextBy(plus ? 1 : -1); 9467 return; 9468 } 9469 if (e.key === "[") { 9470 e.preventDefault(); 9471 cycleLayoutPresetBy(-1); 9472 return; 9473 } 9474 if (e.key === "]") { 9475 e.preventDefault(); 9476 cycleLayoutPresetBy(1); 9477 } 9478 }); 9479 9480 window.addEventListener( 9481 "pointerdown", 9482 (e) => { 9483 updateHotkeyPanelContextFromTarget(e.target); 9484 }, 9485 true 9486 ); 9487 9488 window.addEventListener("click", (e) => { 9489 if (!openPostMenuId) return; 9490 const esc = cssEscape(openPostMenuId); 9491 const inside = e.target?.closest?.(`[data-postmenu-panel="${esc}"], button[data-postmenu="${esc}"]`); 9492 if (inside) return; 9493 for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden"); 9494 for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false"); 9495 openPostMenuId = ""; 9496 }); 9497 9498 chatMessagesEl.addEventListener("click", (e) => { 9499 const emptyActionBtn = e.target.closest("button[data-chatemptyopen]"); 9500 if (emptyActionBtn) { 9501 const target = String(emptyActionBtn.getAttribute("data-chatemptyopen") || "").trim().toLowerCase(); 9502 if (target === "hives") { 9503 if (isMobileSwipeMode()) { 9504 setMobilePanel("hives"); 9505 } else { 9506 const hivesHeader = hivesPanelEl?.querySelector?.(".panelHeader"); 9507 hivesHeader?.scrollIntoView?.({ block: "nearest", behavior: "smooth" }); 9508 } 9509 return; 9510 } 9511 if (target === "people") { 9512 const peopleEl = getPanelElement("people") || peopleDrawerEl; 9513 if (peopleEl && typeof undockPanel === "function" && isDocked("people")) undockPanel("people"); 9514 peopleEl?.scrollIntoView?.({ block: "nearest", behavior: "smooth" }); 9515 return; 9516 } 9517 } 9518 9519 const mobileChatOpenBtn = e.target.closest("button[data-mobilechatopen]"); 9520 if (mobileChatOpenBtn) { 9521 const postId = mobileChatOpenBtn.getAttribute("data-mobilechatopen") || ""; 9522 if (postId) openChat(postId); 9523 return; 9524 } 9525 9526 const dmAcceptBtn = e.target.closest("button[data-dmaccept]"); 9527 if (dmAcceptBtn) { 9528 const threadId = dmAcceptBtn.getAttribute("data-dmaccept") || ""; 9529 if (threadId) { 9530 pendingOpenDmThreadId = threadId; 9531 ws.send(JSON.stringify({ type: "dmRequestRespond", threadId, accept: true })); 9532 } 9533 return; 9534 } 9535 const dmDeclineBtn = e.target.closest("button[data-dmdecline]"); 9536 if (dmDeclineBtn) { 9537 const threadId = dmDeclineBtn.getAttribute("data-dmdecline") || ""; 9538 if (threadId) ws.send(JSON.stringify({ type: "dmRequestRespond", threadId, accept: false })); 9539 return; 9540 } 9541 const dmOpenBtn = e.target.closest("button[data-dmopen]"); 9542 if (dmOpenBtn) { 9543 const threadId = dmOpenBtn.getAttribute("data-dmopen") || ""; 9544 if (threadId) openDmThread(threadId); 9545 return; 9546 } 9547 const dmRequestBtn = e.target.closest("button[data-dmrequest]"); 9548 if (dmRequestBtn && activeDmThreadId) { 9549 const to = String(dmRequestBtn.getAttribute("data-dmrequest") || "") 9550 .trim() 9551 .replace(/^@+/, "") 9552 .toLowerCase(); 9553 if (to) ws.send(JSON.stringify({ type: "dmRequestCreate", to })); 9554 return; 9555 } 9556 9557 const profileLink = e.target.closest("[data-viewprofile]"); 9558 if (profileLink) { 9559 const username = profileLink.getAttribute("data-viewprofile") || ""; 9560 if (username) openUserProfile(username); 9561 return; 9562 } 9563 9564 const mention = e.target.closest(".mentionToken"); 9565 if (mention) { 9566 const raw = String(mention.textContent || "").trim(); 9567 const username = raw.replace(/^@+/, "").toLowerCase(); 9568 if (username) openUserProfile(username); 9569 return; 9570 } 9571 9572 const editBtn = e.target.closest("button[data-editmsg]"); 9573 if (editBtn) { 9574 const messageId = editBtn.getAttribute("data-editmsg") || ""; 9575 const postId = editBtn.getAttribute("data-postid") || activeChatPostId || ""; 9576 if (!messageId || !postId) return; 9577 const message = findChatMessage(postId, messageId); 9578 if (!message || message.deleted) return; 9579 openEditModalForChatMessage(message, postId); 9580 return; 9581 } 9582 9583 const deleteBtn = e.target.closest("button[data-deletemsg]"); 9584 if (deleteBtn) { 9585 const messageId = deleteBtn.getAttribute("data-deletemsg") || ""; 9586 if (!messageId) return; 9587 const ok = confirm("Delete this message?"); 9588 if (!ok) return; 9589 ws.send(JSON.stringify({ type: "deleteChatMessageSelf", messageId })); 9590 return; 9591 } 9592 9593 const replyBtn = e.target.closest("button[data-replymsg]"); 9594 if (replyBtn) { 9595 const messageId = replyBtn.getAttribute("data-replymsg") || ""; 9596 const postId = replyBtn.getAttribute("data-postid") || activeChatPostId || ""; 9597 if (!messageId || !postId) return; 9598 const message = findChatMessage(postId, messageId); 9599 if (!message) return; 9600 setReplyToMessage(message); 9601 chatEditor?.focus(); 9602 return; 9603 } 9604 9605 const reportChatBtn = e.target.closest("button[data-reportchat]"); 9606 if (reportChatBtn) { 9607 const messageId = reportChatBtn.getAttribute("data-reportchat") || ""; 9608 const postId = reportChatBtn.getAttribute("data-postid") || activeChatPostId || ""; 9609 if (!messageId || !postId) return; 9610 const message = findChatMessage(postId, messageId); 9611 if (!message || message.deleted) { 9612 toast("Unavailable", "That message was deleted."); 9613 return; 9614 } 9615 const reason = promptReason("message report"); 9616 if (!reason) return; 9617 ws.send(JSON.stringify({ type: "reportCreate", targetType: "chat", targetId: messageId, postId, reason })); 9618 return; 9619 } 9620 9621 const react = e.target.closest("[data-react]"); 9622 if (!react || react.getAttribute("data-kind") !== "chat") return; 9623 const postId = react.getAttribute("data-postid") || ""; 9624 const messageId = react.getAttribute("data-msgid") || ""; 9625 const emoji = react.getAttribute("data-emoji") || ""; 9626 if (!postId || !messageId || !emoji) return; 9627 markReactPulse("chat", messageId, emoji); 9628 toggleMyReact("chat", messageId, emoji); 9629 ws.send(JSON.stringify({ type: "react", targetType: "chat", postId, messageId, emoji })); 9630 renderChatPanel(); 9631 }); 9632 9633 chatReplyCancelBtn?.addEventListener("click", () => setReplyToMessage(null)); 9634 9635 chatBackToListBtn?.addEventListener("click", () => { 9636 if (activeChatPostId && ws?.readyState === WebSocket.OPEN) { 9637 ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); 9638 } 9639 activeChatPostId = null; 9640 activeDmThreadId = null; 9641 activeMapsRoomId = ""; 9642 activeMapsRoomTitle = ""; 9643 setReplyToMessage(null); 9644 renderChatPanel(true); 9645 }); 9646 9647 chatContextSelectEl?.addEventListener("change", () => { 9648 if (syncingChatContextSelect) return; 9649 const raw = String(chatContextSelectEl.value || "").trim(); 9650 if (!raw) return; 9651 openChatContextValue(raw, { preserveFocus: false }); 9652 }); 9653 9654 modPanelEl?.addEventListener("click", (e) => { 9655 const tabBtn = e.target.closest("[data-modtab]"); 9656 if (tabBtn) { 9657 modTab = tabBtn.getAttribute("data-modtab") || "reports"; 9658 if (modTab === "server") requestServerInfo(); 9659 if (modTab === "onboarding") syncOnboardingAdminDraft(true); 9660 renderModPanel(); 9661 return; 9662 } 9663 }); 9664 9665 modRefreshBtn?.addEventListener("click", () => { 9666 if (!canModerate) return; 9667 if (modTab === "server") requestServerInfo(); 9668 else if (modTab === "onboarding") { 9669 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" })); 9670 syncOnboardingAdminDraft(true); 9671 renderModPanel(); 9672 } 9673 else requestModData(); 9674 }); 9675 modReportStatusEl?.addEventListener("change", () => { 9676 if (!canModerate) return; 9677 ws.send(JSON.stringify({ type: "modListReports", status: modReportStatusEl.value || "open", limit: 200 })); 9678 }); 9679 9680 modModal?.addEventListener("click", (e) => { 9681 if (e.target?.getAttribute?.("data-modmodalclose")) setModModalOpen(false); 9682 }); 9683 modModalClose?.addEventListener("click", () => setModModalOpen(false)); 9684 modModalCancel?.addEventListener("click", () => setModModalOpen(false)); 9685 9686 modModalBody?.addEventListener("change", (e) => { 9687 if (!modModalContext) return; 9688 if (modModalContext.kind === "collectionGate") { 9689 if (e.target?.name === "gateVisibility") updateGateModalVisibility(); 9690 return; 9691 } 9692 if (modModalContext.kind !== "userRoles") return; 9693 const checkbox = e.target?.closest?.("input[type='checkbox'][data-userrolekey]"); 9694 if (!checkbox) return; 9695 const key = checkbox.getAttribute("data-userrolekey") || ""; 9696 const enabled = Boolean(checkbox.checked); 9697 if (!key) return; 9698 ws.send(JSON.stringify({ type: "userCustomRoleSet", targetId: modModalContext.username, key, enabled })); 9699 }); 9700 9701 modModalPrimary?.addEventListener("click", () => { 9702 if (!modModalContext) return; 9703 if (modModalStatus) modModalStatus.textContent = ""; 9704 if (modModalContext.kind === "collectionCreate") { 9705 const name = String(document.getElementById("modModalCollectionName")?.value || "").trim(); 9706 if (!name) { 9707 if (modModalStatus) modModalStatus.textContent = "Name is required."; 9708 return; 9709 } 9710 ws.send(JSON.stringify({ type: "collectionCreate", name })); 9711 setModModalOpen(false); 9712 return; 9713 } 9714 if (modModalContext.kind === "collectionGate") { 9715 const collectionId = String(modModalContext.collectionId || ""); 9716 const visibility = String(modModalBody?.querySelector("input[name='gateVisibility']:checked")?.value || "public"); 9717 if (visibility !== "gated") { 9718 ws.send(JSON.stringify({ type: "collectionSetGate", collectionId, visibility: "public", allowedRoles: [] })); 9719 setModModalOpen(false); 9720 return; 9721 } 9722 const allowedRoles = Array.from(modModalBody?.querySelectorAll("input[data-gatetoken]:checked") || []).map((el) => 9723 String(el.getAttribute("data-gatetoken") || "") 9724 ); 9725 if (!allowedRoles.length) { 9726 if (modModalStatus) modModalStatus.textContent = "Pick at least one allowed role for gated collections."; 9727 return; 9728 } 9729 ws.send(JSON.stringify({ type: "collectionSetGate", collectionId, visibility: "gated", allowedRoles })); 9730 setModModalOpen(false); 9731 } 9732 }); 9733 9734 modBodyEl?.addEventListener("click", (e) => { 9735 const modLogViewBtn = e.target.closest("button[data-modlogview]"); 9736 if (modLogViewBtn) { 9737 const next = String(modLogViewBtn.getAttribute("data-modlogview") || "dev"); 9738 modLogView = next === "moderation" ? "moderation" : "dev"; 9739 localStorage.setItem("bzl_modLogView", modLogView); 9740 if (modLogView === "dev" && ws.readyState === WebSocket.OPEN) { 9741 ws.send(JSON.stringify({ type: "devLogList", limit: 300 })); 9742 } 9743 renderModPanel(); 9744 return; 9745 } 9746 9747 const devLogRefreshBtn = e.target.closest("button[data-devlogrefresh]"); 9748 if (devLogRefreshBtn) { 9749 if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "devLogList", limit: 300 })); 9750 return; 9751 } 9752 9753 const devLogCopyBtn = e.target.closest("button[data-devlogcopy]"); 9754 if (devLogCopyBtn) { 9755 const text = String(document.getElementById("devLogPre")?.textContent || "").trim(); 9756 if (!text) { 9757 toast("Dev log", "Nothing to copy."); 9758 return; 9759 } 9760 navigator.clipboard 9761 .writeText(text) 9762 .then(() => toast("Dev log", "Copied.")) 9763 .catch(() => toast("Dev log", "Copy failed.")); 9764 return; 9765 } 9766 9767 const devLogClearBtn = e.target.closest("button[data-devlogclear]"); 9768 if (devLogClearBtn) { 9769 if (!(canModerate && loggedInRole === "owner")) return; 9770 const ok = confirm("Clear the server dev log?"); 9771 if (!ok) return; 9772 ws.send(JSON.stringify({ type: "devLogClear" })); 9773 return; 9774 } 9775 9776 const devLogTestBtn = e.target.closest("button[data-devlogtest]"); 9777 if (devLogTestBtn) { 9778 sendDevLog("info", "ui", "Dev log test", { at: Date.now() }); 9779 return; 9780 } 9781 9782 const devLogAutoScrollToggle = e.target.closest("input[data-devlogautoscroll]"); 9783 if (devLogAutoScrollToggle) { 9784 devLogAutoScroll = Boolean(devLogAutoScrollToggle.checked); 9785 localStorage.setItem("bzl_devLogAutoScroll", devLogAutoScroll ? "1" : "0"); 9786 renderModPanel(); 9787 return; 9788 } 9789 9790 const serverRefreshBtn = e.target.closest("button[data-server-refresh]"); 9791 if (serverRefreshBtn) { 9792 requestServerInfo(); 9793 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" })); 9794 return; 9795 } 9796 9797 const onboardingRefreshBtn = e.target.closest("button[data-onboarding-refresh]"); 9798 if (onboardingRefreshBtn) { 9799 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" })); 9800 syncOnboardingAdminDraft(true); 9801 renderModPanel(); 9802 return; 9803 } 9804 9805 const onbAdminTabBtn = e.target.closest("button[data-onb-admin-tab]"); 9806 if (onbAdminTabBtn) { 9807 const tab = String(onbAdminTabBtn.getAttribute("data-onb-admin-tab") || "about").trim(); 9808 if (!["about", "rules", "roles"].includes(tab)) return; 9809 onboardingAdminTab = tab; 9810 renderModPanel(); 9811 return; 9812 } 9813 9814 const onbRuleAddBtn = e.target.closest("button[data-onb-ruleadd]"); 9815 if (onbRuleAddBtn) { 9816 if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return; 9817 normalizeOnboardingDraftRules(); 9818 const nextIndex = onboardingAdminDraft.rules.length + 1; 9819 const id = `r${Date.now()}_${nextIndex}`; 9820 onboardingAdminDraft.rules.push({ 9821 id, 9822 order: nextIndex, 9823 name: `Rule ${nextIndex}`, 9824 shortDescription: "", 9825 description: "", 9826 severity: "info", 9827 }); 9828 normalizeOnboardingDraftRules(); 9829 onboardingAdminExpandedRuleIds.add(id); 9830 onboardingAdminTab = "rules"; 9831 renderModPanel(); 9832 return; 9833 } 9834 9835 const onbRuleToggleBtn = e.target.closest("button[data-onb-ruletoggle]"); 9836 if (onbRuleToggleBtn) { 9837 const id = String(onbRuleToggleBtn.getAttribute("data-onb-ruletoggle") || "").trim(); 9838 if (!id) return; 9839 if (onboardingAdminExpandedRuleIds.has(id)) onboardingAdminExpandedRuleIds.delete(id); 9840 else onboardingAdminExpandedRuleIds.add(id); 9841 renderModPanel(); 9842 return; 9843 } 9844 9845 const onbRuleDeleteBtn = e.target.closest("button[data-onb-ruledelete]"); 9846 if (onbRuleDeleteBtn) { 9847 if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return; 9848 const id = String(onbRuleDeleteBtn.getAttribute("data-onb-ruledelete") || "").trim(); 9849 onboardingAdminDraft.rules = onboardingAdminDraft.rules.filter((r) => r.id !== id); 9850 onboardingAdminExpandedRuleIds.delete(id); 9851 normalizeOnboardingDraftRules(); 9852 renderModPanel(); 9853 return; 9854 } 9855 9856 const onbRuleUpBtn = e.target.closest("button[data-onb-ruleup]"); 9857 if (onbRuleUpBtn) { 9858 if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return; 9859 const id = String(onbRuleUpBtn.getAttribute("data-onb-ruleup") || "").trim(); 9860 const idx = onboardingAdminDraft.rules.findIndex((r) => r.id === id); 9861 if (idx <= 0) return; 9862 const tmp = onboardingAdminDraft.rules[idx - 1]; 9863 onboardingAdminDraft.rules[idx - 1] = onboardingAdminDraft.rules[idx]; 9864 onboardingAdminDraft.rules[idx] = tmp; 9865 normalizeOnboardingDraftRules(); 9866 renderModPanel(); 9867 return; 9868 } 9869 9870 const onbRuleDownBtn = e.target.closest("button[data-onb-ruledown]"); 9871 if (onbRuleDownBtn) { 9872 if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return; 9873 const id = String(onbRuleDownBtn.getAttribute("data-onb-ruledown") || "").trim(); 9874 const idx = onboardingAdminDraft.rules.findIndex((r) => r.id === id); 9875 if (idx < 0 || idx >= onboardingAdminDraft.rules.length - 1) return; 9876 const tmp = onboardingAdminDraft.rules[idx + 1]; 9877 onboardingAdminDraft.rules[idx + 1] = onboardingAdminDraft.rules[idx]; 9878 onboardingAdminDraft.rules[idx] = tmp; 9879 normalizeOnboardingDraftRules(); 9880 renderModPanel(); 9881 return; 9882 } 9883 9884 const onboardingSaveBtn = e.target.closest("button[data-onboarding-save],button[data-onboarding-publish]"); 9885 if (onboardingSaveBtn) { 9886 if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return; 9887 const publish = onboardingSaveBtn.hasAttribute("data-onboarding-publish"); 9888 normalizeOnboardingDraftRules(); 9889 ws.send( 9890 JSON.stringify({ 9891 type: "instanceSetOnboarding", 9892 publish, 9893 enabled: Boolean(onboardingAdminDraft.enabled), 9894 about: { content: String(onboardingAdminDraft.aboutContent || "") }, 9895 rules: { 9896 requireAcceptance: Boolean(onboardingAdminDraft.requireAcceptance), 9897 blockReadUntilAccepted: Boolean(onboardingAdminDraft.blockReadUntilAccepted), 9898 items: onboardingAdminDraft.rules, 9899 }, 9900 roleSelect: { 9901 enabled: Boolean(onboardingAdminDraft.roleSelectEnabled), 9902 selfAssignableRoleIds: onboardingAdminDraft.selfAssignableRoleIds, 9903 } 9904 }) 9905 ); 9906 toast("Onboarding", publish ? "Publishing..." : "Saving..."); 9907 return; 9908 } 9909 9910 const instanceSaveBtn = e.target.closest("button[data-instance-save]"); 9911 if (instanceSaveBtn) { 9912 if (!(canModerate && loggedInRole === "owner")) return; 9913 const title = String(modBodyEl.querySelector("input[data-instance-title]")?.value || "").replace(/\s+/g, " ").trim().slice(0, 32); 9914 const subtitle = String(modBodyEl.querySelector("input[data-instance-subtitle]")?.value || "").replace(/\s+/g, " ").trim().slice(0, 80); 9915 const allowMemberPermanentPosts = Boolean(modBodyEl.querySelector("input[data-instance-allowpermanent]")?.checked); 9916 const bg = String(modBodyEl.querySelector("input[data-instance-bg]")?.value || "").trim(); 9917 const panel = String(modBodyEl.querySelector("input[data-instance-panel]")?.value || "").trim(); 9918 const text = String(modBodyEl.querySelector("input[data-instance-text]")?.value || "").trim(); 9919 const good = String(modBodyEl.querySelector("input[data-instance-good]")?.value || "").trim(); 9920 const bad = String(modBodyEl.querySelector("input[data-instance-bad]")?.value || "").trim(); 9921 const accent = String(modBodyEl.querySelector("input[data-instance-accent]")?.value || "").trim(); 9922 const accent2 = String(modBodyEl.querySelector("input[data-instance-accent2]")?.value || "").trim(); 9923 const fontBody = String(modBodyEl.querySelector("select[data-instance-fontbody]")?.value || "").trim(); 9924 const fontMono = String(modBodyEl.querySelector("select[data-instance-fontmono]")?.value || "").trim(); 9925 const mutedPct = String(modBodyEl.querySelector("input[data-instance-mutedpct]")?.value || "").trim(); 9926 const linePct = String(modBodyEl.querySelector("input[data-instance-linepct]")?.value || "").trim(); 9927 const panel2Pct = String(modBodyEl.querySelector("input[data-instance-panel2pct]")?.value || "").trim(); 9928 if (!title) { 9929 toast("Instance", "Title is required."); 9930 return; 9931 } 9932 ws.send( 9933 JSON.stringify({ 9934 type: "instanceSetBranding", 9935 title, 9936 subtitle, 9937 allowMemberPermanentPosts, 9938 appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct } 9939 }) 9940 ); 9941 toast("Instance", "Saving..."); 9942 return; 9943 } 9944 9945 const instanceSaveAppearanceBtn = e.target.closest("button[data-instance-saveappearance]"); 9946 if (instanceSaveAppearanceBtn) { 9947 if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return; 9948 const bg = String(modBodyEl.querySelector("input[data-instance-bg]")?.value || "").trim(); 9949 const panel = String(modBodyEl.querySelector("input[data-instance-panel]")?.value || "").trim(); 9950 const text = String(modBodyEl.querySelector("input[data-instance-text]")?.value || "").trim(); 9951 const good = String(modBodyEl.querySelector("input[data-instance-good]")?.value || "").trim(); 9952 const bad = String(modBodyEl.querySelector("input[data-instance-bad]")?.value || "").trim(); 9953 const accent = String(modBodyEl.querySelector("input[data-instance-accent]")?.value || "").trim(); 9954 const accent2 = String(modBodyEl.querySelector("input[data-instance-accent2]")?.value || "").trim(); 9955 const fontBody = String(modBodyEl.querySelector("select[data-instance-fontbody]")?.value || "").trim(); 9956 const fontMono = String(modBodyEl.querySelector("select[data-instance-fontmono]")?.value || "").trim(); 9957 const mutedPct = String(modBodyEl.querySelector("input[data-instance-mutedpct]")?.value || "").trim(); 9958 const linePct = String(modBodyEl.querySelector("input[data-instance-linepct]")?.value || "").trim(); 9959 const panel2Pct = String(modBodyEl.querySelector("input[data-instance-panel2pct]")?.value || "").trim(); 9960 ws.send( 9961 JSON.stringify({ 9962 type: "instanceSetAppearance", 9963 appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct } 9964 }) 9965 ); 9966 toast("Theme", "Saving..."); 9967 return; 9968 } 9969 9970 const themeResetBtn = e.target.closest("button[data-theme-reset]"); 9971 if (themeResetBtn) { 9972 if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return; 9973 applyInstanceAppearance(); 9974 renderModPanel(); 9975 toast("Theme", "Reset to saved theme."); 9976 return; 9977 } 9978 9979 const pluginReloadBtn = e.target.closest("button[data-pluginreload]"); 9980 if (pluginReloadBtn) { 9981 if (!canManagePlugins()) return; 9982 pluginAdminBusy = true; 9983 pluginAdminStatus = "Reloading plugins..."; 9984 renderModPanel(); 9985 ws.send(JSON.stringify({ type: "pluginReload" })); 9986 return; 9987 } 9988 9989 const pluginUninstallBtn = e.target.closest("button[data-pluginuninstall]"); 9990 if (pluginUninstallBtn) { 9991 if (!canManagePlugins()) return; 9992 const id = String(pluginUninstallBtn.getAttribute("data-pluginuninstall") || "").trim().toLowerCase(); 9993 if (!id) return; 9994 const ok = confirm(`Uninstall "${id}"? This deletes the plugin files from this server.`); 9995 if (!ok) return; 9996 pluginAdminBusy = true; 9997 pluginAdminStatus = `Uninstalling "${id}"...`; 9998 renderModPanel(); 9999 ws.send(JSON.stringify({ type: "pluginUninstall", id })); 10000 return; 10001 } 10002 10003 const pluginInstallBtn = e.target.closest("button[data-plugininstall]"); 10004 if (pluginInstallBtn) { 10005 if (!canManagePlugins()) return; 10006 const input = modBodyEl.querySelector("input[type='file'][data-pluginzip]") || null; 10007 const file = input?.files && input.files[0] ? input.files[0] : null; 10008 if (!file) { 10009 pluginAdminStatus = "Choose a .zip file first."; 10010 renderModPanel(); 10011 return; 10012 } 10013 const token = getSessionToken(); 10014 if (!token) { 10015 pluginAdminStatus = "Session missing. Please sign out/in and try again."; 10016 renderModPanel(); 10017 return; 10018 } 10019 pluginAdminBusy = true; 10020 pluginAdminStatus = "Uploading plugin..."; 10021 renderModPanel(); 10022 (async () => { 10023 try { 10024 const res = await fetch("/api/plugin-install", { 10025 method: "POST", 10026 headers: { "Content-Type": "application/zip", Authorization: `Bearer ${token}` }, 10027 body: file, 10028 credentials: "same-origin", 10029 }); 10030 const json = await res.json().catch(() => null); 10031 if (!res.ok || !json || !json.ok) { 10032 pluginAdminBusy = false; 10033 pluginAdminStatus = String(json?.error || `Install failed (${res.status}).`); 10034 renderModPanel(); 10035 return; 10036 } 10037 if (input) input.value = ""; 10038 pluginAdminBusy = false; 10039 pluginAdminStatus = `Installed "${json.plugin?.id || "plugin"}". Enable it below.`; 10040 toast("Plugins", "Installed. Enable it to activate."); 10041 renderModPanel(); 10042 } catch (err) { 10043 pluginAdminBusy = false; 10044 pluginAdminStatus = "Install failed."; 10045 renderModPanel(); 10046 } 10047 })(); 10048 return; 10049 } 10050 10051 const nukeBtn = e.target.closest("button[data-nuke]"); 10052 if (nukeBtn) { 10053 if (!(canModerate && loggedInRole === "owner")) return; 10054 const confirmEl = modBodyEl.querySelector("input[data-nukeconfirm]"); 10055 const okToggle = Boolean(confirmEl?.checked); 10056 if (!okToggle) { 10057 toast("NUKE", "Toggle ARE YOU SURE? first."); 10058 return; 10059 } 10060 const ok = confirm("NUKE the board? This clears all hives, reports, moderation log, and hive media uploads."); 10061 if (!ok) return; 10062 ws.send(JSON.stringify({ type: "nukeBoard", confirm: true, confirmText: "ARE YOU SURE?" })); 10063 toast("NUKE", "Working..."); 10064 return; 10065 } 10066 10067 const openChatBtn = e.target.closest("button[data-chat]"); 10068 if (openChatBtn) { 10069 const postId = openChatBtn.getAttribute("data-chat") || ""; 10070 if (postId) openChat(postId); 10071 return; 10072 } 10073 10074 const createCollectionBtn = e.target.closest("button[data-createcollection]"); 10075 if (createCollectionBtn) { 10076 openCollectionCreateModal(); 10077 return; 10078 } 10079 10080 const archiveCollectionBtn = e.target.closest("button[data-archivecollection]"); 10081 if (archiveCollectionBtn) { 10082 const collectionId = archiveCollectionBtn.getAttribute("data-archivecollection") || ""; 10083 if (!collectionId) return; 10084 const ok = confirm("Archive this collection? Existing hives stay visible in All."); 10085 if (!ok) return; 10086 ws.send(JSON.stringify({ type: "collectionArchive", collectionId })); 10087 return; 10088 } 10089 10090 const collectionGateBtn = e.target.closest("button[data-collectiongate]"); 10091 if (collectionGateBtn) { 10092 const collectionId = collectionGateBtn.getAttribute("data-collectiongate") || ""; 10093 if (!collectionId) return; 10094 openCollectionGateModal(collectionId); 10095 return; 10096 } 10097 10098 const collectionPublicBtn = e.target.closest("button[data-collectionpublic]"); 10099 if (collectionPublicBtn) { 10100 const collectionId = collectionPublicBtn.getAttribute("data-collectionpublic") || ""; 10101 if (!collectionId) return; 10102 ws.send(JSON.stringify({ type: "collectionSetGate", collectionId, visibility: "public", allowedRoles: [] })); 10103 return; 10104 } 10105 10106 const roleCreateBtn = e.target.closest("button[data-rolecreate]"); 10107 if (roleCreateBtn) { 10108 const card = roleCreateBtn.closest(".modCard"); 10109 const label = String(card?.querySelector("input[data-rolelabel]")?.value || "").trim(); 10110 let key = String(card?.querySelector("input[data-rolekey]")?.value || "") 10111 .trim() 10112 .toLowerCase(); 10113 if (!key && label) { 10114 key = label 10115 .toLowerCase() 10116 .replace(/[^a-z0-9]+/g, "_") 10117 .replace(/^_+|_+$/g, "") 10118 .slice(0, 18); 10119 const keyEl = card?.querySelector("input[data-rolekey]"); 10120 if (keyEl && key) keyEl.value = key; 10121 } 10122 const color = String(card?.querySelector("input[data-rolecolor]")?.value || "#ff3ea5").trim(); 10123 if (!key || !label) { 10124 toast("Roles", "Key and label are required."); 10125 return; 10126 } 10127 ws.send(JSON.stringify({ type: "roleCreate", key, label, color })); 10128 return; 10129 } 10130 10131 const roleArchiveBtn = e.target.closest("button[data-rolearchive]"); 10132 if (roleArchiveBtn) { 10133 const key = roleArchiveBtn.getAttribute("data-rolearchive") || ""; 10134 if (!key) return; 10135 const ok = confirm(`Archive role "${key}"?`); 10136 if (!ok) return; 10137 ws.send(JSON.stringify({ type: "roleArchive", key })); 10138 return; 10139 } 10140 10141 const userManageRolesBtn = e.target.closest("button[data-usermanageroles]"); 10142 if (userManageRolesBtn) { 10143 const targetId = userManageRolesBtn.getAttribute("data-usermanageroles") || ""; 10144 if (!targetId) return; 10145 openUserRolesModal(targetId); 10146 return; 10147 } 10148 10149 const actionBtn = e.target.closest("button[data-modaction]"); 10150 if (!actionBtn) return; 10151 const actionType = actionBtn.getAttribute("data-modaction") || ""; 10152 const targetType = actionBtn.getAttribute("data-targettype") || ""; 10153 const targetId = actionBtn.getAttribute("data-targetid") || ""; 10154 if (!actionType || !targetType || !targetId) return; 10155 10156 const metadata = {}; 10157 10158 if (actionType === "user_password_reset") { 10159 const pw = prompt("Set a new password (min 4 chars):"); 10160 if (pw === null) return; 10161 const next = String(pw || ""); 10162 if (next.length < 4) { 10163 toast("Password reset", "Password must be at least 4 characters."); 10164 return; 10165 } 10166 const ok = confirm("Reset this user's password to the value you entered?"); 10167 if (!ok) return; 10168 metadata.newPassword = next; 10169 } 10170 10171 if (actionType === "post_erase") { 10172 const ok = confirm("Erase this hive permanently? This cannot be restored."); 10173 if (!ok) return; 10174 } 10175 10176 if (actionType === "post_readonly_set") { 10177 metadata.readOnly = actionBtn.getAttribute("data-readonly") === "1"; 10178 } 10179 10180 if (actionType === "post_protection_set") { 10181 if (actionBtn.hasAttribute("data-unprotect")) { 10182 metadata.enabled = false; 10183 } else { 10184 const pw = prompt("Set post password (min 4 chars):"); 10185 if (pw === null) return; 10186 const next = String(pw || ""); 10187 if (next.length < 4) { 10188 toast("Protected post", "Password must be at least 4 characters."); 10189 return; 10190 } 10191 metadata.enabled = true; 10192 metadata.password = next; 10193 } 10194 } 10195 10196 const reason = promptReason(actionType); 10197 if (!reason) return; 10198 const minutesAttr = actionBtn.getAttribute("data-minutes"); 10199 const roleAttr = actionBtn.getAttribute("data-role"); 10200 const countAttr = actionBtn.getAttribute("data-count"); 10201 const ttlAttr = actionBtn.getAttribute("data-ttl"); 10202 const ttlPrompt = actionBtn.hasAttribute("data-ttlprompt"); 10203 if (minutesAttr) metadata.minutes = Number(minutesAttr); 10204 if (roleAttr) metadata.role = roleAttr; 10205 if (countAttr) metadata.count = Number(countAttr); 10206 if (ttlAttr) metadata.ttlMinutes = Number(ttlAttr); 10207 if (ttlPrompt && actionType === "post_ttl_set") { 10208 const raw = prompt("Set TTL minutes (0 = permanent):", "60"); 10209 if (raw === null) return; 10210 const n = Math.max(0, Math.min(2880, Math.floor(Number(raw)))); 10211 if (!Number.isFinite(n)) { 10212 toast("TTL", "Enter a valid number."); 10213 return; 10214 } 10215 metadata.ttlMinutes = n; 10216 } 10217 ws.send(JSON.stringify({ type: "modAction", actionType, targetType, targetId, reason, metadata })); 10218 }); 10219 10220 modBodyEl?.addEventListener("change", (e) => { 10221 const onbEnabled = e.target?.closest?.("input[data-onboarding-enabled]"); 10222 if (onbEnabled) { 10223 onboardingAdminDraft.enabled = Boolean(onbEnabled.checked); 10224 return; 10225 } 10226 const onbRequire = e.target?.closest?.("input[data-onboarding-require]"); 10227 if (onbRequire) { 10228 onboardingAdminDraft.requireAcceptance = Boolean(onbRequire.checked); 10229 return; 10230 } 10231 const onbBlockRead = e.target?.closest?.("input[data-onboarding-blockread]"); 10232 if (onbBlockRead) { 10233 onboardingAdminDraft.blockReadUntilAccepted = Boolean(onbBlockRead.checked); 10234 return; 10235 } 10236 const onbRoleEnabled = e.target?.closest?.("input[data-onboarding-roleenabled]"); 10237 if (onbRoleEnabled) { 10238 onboardingAdminDraft.roleSelectEnabled = Boolean(onbRoleEnabled.checked); 10239 return; 10240 } 10241 const onbRoleCheck = e.target?.closest?.("input[data-onboarding-rolecheck]"); 10242 if (onbRoleCheck) { 10243 const key = String(onbRoleCheck.getAttribute("data-onboarding-rolecheck") || "").trim().toLowerCase(); 10244 if (!key) return; 10245 const set = new Set(onboardingAdminDraft.selfAssignableRoleIds || []); 10246 if (onbRoleCheck.checked) set.add(key); 10247 else set.delete(key); 10248 onboardingAdminDraft.selfAssignableRoleIds = Array.from(set); 10249 return; 10250 } 10251 const onbRuleField = e.target?.closest?.("[data-onb-rulefield]"); 10252 if (onbRuleField) { 10253 const id = String(onbRuleField.getAttribute("data-onb-ruleid") || "").trim(); 10254 const field = String(onbRuleField.getAttribute("data-onb-rulefield") || "").trim(); 10255 if (!id || !field) return; 10256 const rule = onboardingAdminDraft.rules.find((r) => r.id === id); 10257 if (!rule) return; 10258 if (field === "severity") { 10259 rule.severity = ["info", "warn", "critical"].includes(String(onbRuleField.value || "").toLowerCase()) 10260 ? String(onbRuleField.value || "").toLowerCase() 10261 : "info"; 10262 return; 10263 } 10264 rule[field] = String(onbRuleField.value || ""); 10265 return; 10266 } 10267 10268 const presetSelect = e.target?.closest?.("select[data-theme-preset]"); 10269 if (presetSelect) { 10270 if (!(canModerate && (loggedInRole === "owner" || loggedInRole === "moderator"))) return; 10271 const id = String(presetSelect.value || "").trim(); 10272 if (!id) return; 10273 const preset = THEME_PRESETS.find((p) => p.id === id) || null; 10274 if (!preset) return; 10275 const a = preset.appearance || {}; 10276 const setValue = (selector, value) => { 10277 const el = modBodyEl.querySelector(selector); 10278 if (!el) return; 10279 el.value = String(value ?? ""); 10280 }; 10281 setValue("input[data-instance-bg]", a.bg); 10282 setValue("input[data-instance-panel]", a.panel); 10283 setValue("input[data-instance-text]", a.text); 10284 setValue("input[data-instance-good]", a.good); 10285 setValue("input[data-instance-bad]", a.bad); 10286 setValue("input[data-instance-accent]", a.accent); 10287 setValue("input[data-instance-accent2]", a.accent2); 10288 setValue("input[data-instance-mutedpct]", a.mutedPct); 10289 setValue("input[data-instance-linepct]", a.linePct); 10290 setValue("input[data-instance-panel2pct]", a.panel2Pct); 10291 setValue("select[data-instance-fontbody]", a.fontBody); 10292 setValue("select[data-instance-fontmono]", a.fontMono); 10293 applyInstanceAppearance(a); 10294 toast("Theme", `Preset "${preset.name}" applied (preview). Click Save to persist.`); 10295 return; 10296 } 10297 10298 const toggle = e.target?.closest?.("input[type='checkbox'][data-pluginenable]"); 10299 if (toggle) { 10300 if (!canManagePlugins()) return; 10301 const id = String(toggle.getAttribute("data-pluginenable") || "").trim().toLowerCase(); 10302 if (!id) return; 10303 const enabled = Boolean(toggle.checked); 10304 if (pluginEnableInFlight.has(id)) return; 10305 const wsRef = window.__bzlWs; 10306 if (!wsRef || wsRef.readyState !== WebSocket.OPEN) { 10307 toast("Plugins", "Not connected."); 10308 return; 10309 } 10310 pluginEnableInFlight.add(id); 10311 // Optimistic UI update to avoid flicker/repeated toggles. 10312 for (const p of plugins) { 10313 if (p && String(p.id || "").toLowerCase() === id) p.enabled = enabled; 10314 } 10315 pluginAdminStatus = enabled ? "Enabling..." : "Disabling..."; 10316 renderModPanel(); 10317 wsRef.send(JSON.stringify({ type: "pluginSetEnabled", id, enabled })); 10318 return; 10319 } 10320 }); 10321 10322 modBodyEl?.addEventListener("input", (e) => { 10323 const aboutEl = e.target?.closest?.("textarea[data-onboarding-about]"); 10324 if (aboutEl) { 10325 onboardingAdminDraft.aboutContent = String(aboutEl.value || ""); 10326 return; 10327 } 10328 const onbRuleField = e.target?.closest?.("input[data-onb-rulefield],textarea[data-onb-rulefield]"); 10329 if (!onbRuleField) return; 10330 const id = String(onbRuleField.getAttribute("data-onb-ruleid") || "").trim(); 10331 const field = String(onbRuleField.getAttribute("data-onb-rulefield") || "").trim(); 10332 if (!id || !field) return; 10333 const rule = onboardingAdminDraft.rules.find((r) => r.id === id); 10334 if (!rule) return; 10335 rule[field] = String(onbRuleField.value || ""); 10336 }); 10337 10338 modBodyEl?.addEventListener("change", (e) => { 10339 const toggle = e.target?.closest?.("input[data-nukeconfirm]"); 10340 if (!toggle) return; 10341 const btn = modBodyEl.querySelector("button[data-nuke]"); 10342 if (!btn) return; 10343 btn.disabled = !Boolean(toggle.checked); 10344 }); 10345 10346 chatForm.addEventListener("submit", (e) => { 10347 e.preventDefault(); 10348 submitChat(); 10349 }); 10350 10351 chatMeta?.addEventListener("click", (e) => { 10352 const btn = e.target?.closest?.("button[data-mapchatscope]"); 10353 if (!btn) return; 10354 const scope = normalizeMapChatScope(btn.getAttribute("data-mapchatscope") || "local"); 10355 activeMapsChatScope = scope; 10356 // Fetch global history on-demand when switching to global. 10357 if (scope === "global" && activeMapsRoomId) { 10358 try { 10359 const wsRef = window.__bzlWs; 10360 if (wsRef && wsRef.readyState === WebSocket.OPEN) { 10361 wsRef.send(JSON.stringify({ type: "plugin:maps:chatHistoryReq", mapId: activeMapsRoomId })); 10362 } 10363 } catch { 10364 // ignore 10365 } 10366 } 10367 renderChatPanel(true); 10368 }); 10369 10370 chatEditor.addEventListener("keydown", (e) => { 10371 if (mentionState.open) { 10372 if (e.key === "ArrowDown") { 10373 e.preventDefault(); 10374 mentionState.selected = Math.min(mentionState.items.length - 1, mentionState.selected + 1); 10375 renderMentionMenu(); 10376 return; 10377 } 10378 if (e.key === "ArrowUp") { 10379 e.preventDefault(); 10380 mentionState.selected = Math.max(0, mentionState.selected - 1); 10381 renderMentionMenu(); 10382 return; 10383 } 10384 if (e.key === "Enter" || e.key === "Tab") { 10385 e.preventDefault(); 10386 const picked = mentionState.items[mentionState.selected]; 10387 if (picked) replaceCurrentMentionToken(picked); 10388 closeMentionMenu(); 10389 return; 10390 } 10391 if (e.key === "Escape") { 10392 e.preventDefault(); 10393 closeMentionMenu(); 10394 return; 10395 } 10396 } 10397 if (e.key !== "Enter") return; 10398 if (!shouldSubmitChatOnEnter(e)) return; 10399 e.preventDefault(); 10400 submitChat(); 10401 }); 10402 10403 chatEditor.addEventListener("input", () => { 10404 if (!activeChatPostId || !loggedInUser) return; 10405 const textTail = String(chatEditor.innerText || "").slice(-80); 10406 const m = /@([a-z0-9_.-]{0,31})$/i.exec(textTail); 10407 if (m) { 10408 const query = String(m[1] || ""); 10409 mentionState.open = true; 10410 mentionState.query = query; 10411 mentionState.items = listMentionCandidates(query); 10412 mentionState.selected = 0; 10413 mentionState.anchorRect = getCaretRect(); 10414 renderMentionMenu(); 10415 } else { 10416 closeMentionMenu(); 10417 } 10418 10419 const t = Date.now(); 10420 if (t - lastTypingSentAt > 900) { 10421 ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: true })); 10422 lastTypingSentAt = t; 10423 } 10424 if (typingStopTimer) clearTimeout(typingStopTimer); 10425 typingStopTimer = setTimeout(() => { 10426 if (!activeChatPostId) return; 10427 ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); 10428 }, 1800); 10429 }); 10430 10431 chatEditor.addEventListener("focus", () => { 10432 chatUploadTargetEditor = chatEditor; 10433 }); 10434 10435 chatEditor.addEventListener("blur", () => { 10436 if (!activeChatPostId || !loggedInUser) return; 10437 ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); 10438 setTimeout(() => closeMentionMenu(), 0); 10439 }); 10440 10441 editor.addEventListener("keydown", (e) => { 10442 if (e.key !== "Enter") return; 10443 if (!(e.ctrlKey || e.metaKey)) return; 10444 e.preventDefault(); 10445 newPostForm.requestSubmit(); 10446 }); 10447 10448 chatImageInput.addEventListener("change", async () => { 10449 const file = chatImageInput.files && chatImageInput.files[0] ? chatImageInput.files[0] : null; 10450 chatImageInput.value = ""; 10451 if (!file) return; 10452 try { 10453 const url = await uploadMediaFile(file, "image"); 10454 if (!url) return; 10455 const target = chatUploadTargetEditor instanceof HTMLElement ? chatUploadTargetEditor : chatEditor; 10456 target.focus(); 10457 document.execCommand("insertImage", false, url); 10458 } catch { 10459 // ignore 10460 } 10461 }); 10462 10463 postImageInput?.addEventListener("change", async () => { 10464 const file = postImageInput.files && postImageInput.files[0] ? postImageInput.files[0] : null; 10465 postImageInput.value = ""; 10466 if (!file) return; 10467 try { 10468 const url = await uploadMediaFile(file, "image"); 10469 if (!url) return; 10470 editor.focus(); 10471 document.execCommand("insertImage", false, url); 10472 } catch { 10473 // ignore 10474 } 10475 }); 10476 10477 chatAudioInput?.addEventListener("change", async () => { 10478 const file = chatAudioInput.files && chatAudioInput.files[0] ? chatAudioInput.files[0] : null; 10479 chatAudioInput.value = ""; 10480 if (!file) return; 10481 try { 10482 const url = await uploadMediaFile(file, "audio"); 10483 if (!url) return; 10484 const target = chatUploadTargetEditor instanceof HTMLElement ? chatUploadTargetEditor : chatEditor; 10485 insertAudioTag(target, url); 10486 } catch { 10487 // ignore 10488 } 10489 }); 10490 10491 postAudioInput?.addEventListener("change", async () => { 10492 const file = postAudioInput.files && postAudioInput.files[0] ? postAudioInput.files[0] : null; 10493 postAudioInput.value = ""; 10494 if (!file) return; 10495 try { 10496 const url = await uploadMediaFile(file, "audio"); 10497 if (!url) return; 10498 insertAudioTag(editor, url); 10499 } catch { 10500 // ignore 10501 } 10502 }); 10503 10504 setInterval(() => { 10505 for (const el of document.querySelectorAll("[data-countdown]")) { 10506 const id = el.getAttribute("data-countdown"); 10507 const post = posts.get(id); 10508 if (!post) continue; 10509 el.textContent = formatCountdown(post.expiresAt); 10510 } 10511 for (const el of document.querySelectorAll("[data-boost]")) { 10512 const id = el.getAttribute("data-boost"); 10513 const post = posts.get(id); 10514 if (!post) continue; 10515 const txt = formatBoostRemaining(Number(post.boostUntil || 0)); 10516 if (!txt) { 10517 el.remove(); 10518 continue; 10519 } 10520 el.textContent = `boost ${txt}`; 10521 } 10522 if (activeChatPostId) updateActiveChatMeta(); 10523 }, 1000); 10524 10525 function unlockSfxOnce() { 10526 if (!pendingOpenSfx) return; 10527 playSfx("open", { volume: 0.34 }).then((ok) => { 10528 if (ok) pendingOpenSfx = false; 10529 }); 10530 } 10531 10532 window.addEventListener("pointerdown", unlockSfxOnce, { once: true, capture: true }); 10533 window.addEventListener("keydown", unlockSfxOnce, { once: true, capture: true }); 10534 10535 playSfx("open", { volume: 0.34 }).then((ok) => { 10536 if (ok) pendingOpenSfx = false; 10537 }); 10538 10539 let ws = null; 10540 let wsKeepaliveTimer = null; 10541 let wsReconnectTimer = null; 10542 let wsReconnectAttempt = 0; 10543 10544 function clearWsKeepalive() { 10545 if (!wsKeepaliveTimer) return; 10546 try { 10547 clearInterval(wsKeepaliveTimer); 10548 } catch { 10549 // ignore 10550 } 10551 wsKeepaliveTimer = null; 10552 } 10553 10554 function clearWsReconnect() { 10555 if (!wsReconnectTimer) return; 10556 try { 10557 clearTimeout(wsReconnectTimer); 10558 } catch { 10559 // ignore 10560 } 10561 wsReconnectTimer = null; 10562 } 10563 10564 function startWsKeepalive(sock) { 10565 clearWsKeepalive(); 10566 if (!readStayConnectedPref()) return; 10567 wsKeepaliveTimer = setInterval(() => { 10568 if (!sock || sock !== ws) return; 10569 if (sock.readyState !== WebSocket.OPEN) return; 10570 try { 10571 sock.send(JSON.stringify({ type: "ping" })); 10572 } catch { 10573 // ignore 10574 } 10575 }, 25_000); 10576 } 10577 10578 function scheduleWsReconnect() { 10579 clearWsReconnect(); 10580 if (!readStayConnectedPref()) return; 10581 const attempt = Math.min(6, Math.max(0, wsReconnectAttempt)); 10582 const base = 1000 * Math.pow(2, attempt); 10583 const jitter = Math.floor(Math.random() * 250); 10584 const delay = Math.min(15_000, base) + jitter; 10585 wsReconnectAttempt += 1; 10586 setConn("connecting"); 10587 wsReconnectTimer = setTimeout(() => { 10588 wsReconnectTimer = null; 10589 connectWs(); 10590 }, delay); 10591 } 10592 10593 function connectWs() { 10594 if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; 10595 clearWsKeepalive(); 10596 setConn("connecting"); 10597 const sock = new WebSocket(wsUrl()); 10598 ws = sock; 10599 window.__bzlWs = sock; 10600 10601 sock.addEventListener("open", () => { 10602 if (sock !== ws) return; 10603 setConn("open"); 10604 wsReconnectAttempt = 0; 10605 clearWsReconnect(); 10606 startWsKeepalive(sock); 10607 const token = getSessionToken(); 10608 if (token) { 10609 try { 10610 sock.send(JSON.stringify({ type: "resumeSession", token })); 10611 } catch { 10612 // ignore 10613 } 10614 } 10615 }); 10616 10617 sock.addEventListener("close", () => { 10618 if (sock !== ws) return; 10619 setConn("closed"); 10620 clearWsKeepalive(); 10621 scheduleWsReconnect(); 10622 }); 10623 10624 sock.addEventListener("error", () => { 10625 if (sock !== ws) return; 10626 setConn("closed"); 10627 }); 10628 10629 sock.addEventListener("message", onWsMessage); 10630 } 10631 10632 function onWsMessage(evt) { 10633 let msg; 10634 try { 10635 msg = JSON.parse(evt.data); 10636 } catch { 10637 return; 10638 } 10639 if (!msg || typeof msg !== "object") return; 10640 10641 if (msg.type === "init") { 10642 clientId = msg.clientId || null; 10643 canRegisterFirstUser = Boolean(msg.auth?.canRegisterFirstUser); 10644 registrationEnabled = Boolean(msg.auth?.registrationEnabled); 10645 loggedInRole = "member"; 10646 canModerate = false; 10647 dmThreads = []; 10648 dmThreadsById = new Map(); 10649 dmMessagesByThreadId.clear(); 10650 activeDmThreadId = null; 10651 pendingOpenDmThreadId = ""; 10652 lanUrls = []; 10653 modReports = []; 10654 modUsers = []; 10655 modLog = []; 10656 devLog = []; 10657 profiles = msg.profiles && typeof msg.profiles === "object" ? msg.profiles : {}; 10658 instanceBranding = normalizeInstanceBranding(msg.instance || {}); 10659 onboardingState = normalizeOnboardingState(msg.auth?.onboarding || {}); 10660 renderInstanceBranding(); 10661 collections = normalizeCollections(msg.collections); 10662 customRoles = normalizeRoleDefs(msg.roles?.custom); 10663 setPlugins(msg.plugins); 10664 renderCollectionSelect(); 10665 peopleMembers = Array.isArray(msg.people?.members) ? msg.people.members : []; 10666 if (!peopleMembers.length && ws.readyState === WebSocket.OPEN) { 10667 ws.send(JSON.stringify({ type: "peopleList" })); 10668 } 10669 if (msg.reactions?.allowed && Array.isArray(msg.reactions.allowed)) allowedReactions = msg.reactions.allowed; 10670 if (msg.reactions?.allowedPost && Array.isArray(msg.reactions.allowedPost)) allowedPostReactions = msg.reactions.allowedPost; 10671 if (msg.reactions?.allowedChat && Array.isArray(msg.reactions.allowedChat)) allowedChatReactions = msg.reactions.allowedChat; 10672 setUserPrefs({ starredPostIds: [], hiddenPostIds: [] }); 10673 unreadByPostId.clear(); 10674 posts.clear(); 10675 for (const p of msg.posts || []) posts.set(p.id, p); 10676 setAuthUi(); 10677 renderFeed(); 10678 renderChatPanel(); 10679 renderLanHint(); 10680 renderPeoplePanel(); 10681 renderCenterPanels(); 10682 if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" })); 10683 return; 10684 } 10685 10686 // Generic plugin event dispatch: `plugin:<pluginId>:<eventName>` 10687 // (Maps has some core-handled messages below; for other plugins, dispatch + stop.) 10688 if (typeof msg.type === "string") { 10689 const m = msg.type.match(/^plugin:([a-z0-9][a-z0-9_.-]{0,31}):([a-zA-Z0-9][a-zA-Z0-9_.-]{0,63})$/); 10690 if (m) { 10691 const pluginId = String(m[1] || "").toLowerCase(); 10692 const ev = String(m[2] || ""); 10693 const byEvent = pluginClientHandlers.get(pluginId); 10694 const set = byEvent ? byEvent.get(ev) : null; 10695 if (set && set.size) { 10696 for (const fn of Array.from(set)) { 10697 try { 10698 fn(msg); 10699 } catch (e) { 10700 console.warn(`Plugin handler failed (${pluginId}:${ev}):`, e?.message || e); 10701 } 10702 } 10703 } 10704 if (pluginId !== "maps") return; 10705 } 10706 } 10707 10708 if (msg.type === "plugin:maps:joinOk") { 10709 const map = msg.map && typeof msg.map === "object" ? msg.map : null; 10710 const mapId = map && typeof map.id === "string" ? map.id.trim().toLowerCase() : ""; 10711 if (mapId) { 10712 activeMapsRoomId = mapId; 10713 activeMapsRoomTitle = map && typeof map.title === "string" ? map.title.trim().slice(0, 64) : mapId; 10714 activeMapsChatScope = "local"; 10715 try { 10716 if (ws.readyState === WebSocket.OPEN) { 10717 ws.send(JSON.stringify({ type: "plugin:maps:chatHistoryReq", mapId })); 10718 } 10719 } catch { 10720 // ignore 10721 } 10722 if (isMapChatActive()) renderChatPanel(true); 10723 } 10724 return; 10725 } 10726 10727 if (msg.type === "plugin:maps:left") { 10728 const wasActive = Boolean(activeMapsRoomId); 10729 activeMapsRoomId = ""; 10730 activeMapsRoomTitle = ""; 10731 activeMapsChatScope = "local"; 10732 if (wasActive && !activeDmThreadId && !activeChatPostId) renderChatPanel(true); 10733 return; 10734 } 10735 10736 if (msg.type === "plugin:maps:chatHistory") { 10737 const mapId = typeof msg.mapId === "string" ? msg.mapId.trim().toLowerCase() : ""; 10738 const scope = normalizeMapChatScope(msg.scope || "global"); 10739 const messages = Array.isArray(msg.messages) ? msg.messages : []; 10740 if (mapId && scope === "global") { 10741 mapsChatGlobalByMapId.set( 10742 mapId, 10743 messages 10744 .map((m) => ({ 10745 id: String(m?.id || ""), 10746 fromUser: String(m?.fromUser || m?.username || ""), 10747 text: String(m?.text || ""), 10748 createdAt: Number(m?.createdAt || 0) || Date.now(), 10749 })) 10750 .filter((m) => m.id && m.fromUser && m.text) 10751 .slice(-240) 10752 ); 10753 if (isMapChatActive()) renderChatPanel(false); 10754 } 10755 return; 10756 } 10757 10758 if (msg.type === "plugin:maps:chatMessage") { 10759 const mapId = typeof msg.mapId === "string" ? msg.mapId.trim().toLowerCase() : ""; 10760 const scope = normalizeMapChatScope(msg.scope || "local"); 10761 const m = msg.message && typeof msg.message === "object" ? msg.message : null; 10762 if (mapId && m) { 10763 pushMapChatMessage(mapId, scope, { 10764 id: String(m.id || ""), 10765 fromUser: String(m.fromUser || m.username || ""), 10766 text: String(m.text || ""), 10767 createdAt: Number(m.createdAt || 0) || Date.now(), 10768 }); 10769 if (isMapChatActive()) renderChatPanel(false); 10770 } 10771 return; 10772 } 10773 10774 if (msg.type === "collectionsUpdated") { 10775 const prevView = activeHiveView; 10776 collections = normalizeCollections(msg.collections); 10777 renderCollectionSelect(); 10778 ensureActiveCollectionView(); 10779 if (activeHiveView !== prevView) renderFeed(); 10780 renderModPanel(); 10781 return; 10782 } 10783 10784 if (msg.type === "instanceUpdated" && msg.instance && typeof msg.instance === "object") { 10785 instanceBranding = normalizeInstanceBranding(msg.instance); 10786 onboardingState = normalizeOnboardingState(onboardingState); 10787 if (modTab === "onboarding") syncOnboardingAdminDraft(true); 10788 renderInstanceBranding(); 10789 applyInstanceAppearance(); 10790 setAuthUi(); 10791 return; 10792 } 10793 10794 if (msg.type === "instanceOk" && msg.instance && typeof msg.instance === "object") { 10795 instanceBranding = normalizeInstanceBranding(msg.instance); 10796 onboardingState = normalizeOnboardingState(onboardingState); 10797 if (modTab === "onboarding") syncOnboardingAdminDraft(true); 10798 renderInstanceBranding(); 10799 applyInstanceAppearance(); 10800 setAuthUi(); 10801 toast("Instance", "Saved."); 10802 return; 10803 } 10804 10805 if (msg.type === "postsSnapshot") { 10806 posts.clear(); 10807 for (const post of Array.isArray(msg.posts) ? msg.posts : []) posts.set(post.id, post); 10808 if (activeChatPostId && !posts.has(activeChatPostId)) { 10809 activeChatPostId = null; 10810 } 10811 renderFeed(); 10812 renderChatPanel(); 10813 return; 10814 } 10815 10816 if (msg.type === "boardReset") { 10817 posts.clear(); 10818 chatByPost.clear(); 10819 unreadByPostId.clear(); 10820 typingUsersByPostId.clear(); 10821 newPostAnimIds.clear(); 10822 if (buzzTimers.size) { 10823 for (const t of buzzTimers.values()) clearTimeout(t); 10824 buzzTimers.clear(); 10825 } 10826 activeChatPostId = null; 10827 renderFeed(); 10828 renderChatPanel(true); 10829 renderTypingIndicator(); 10830 renderModPanel(); 10831 if (canModerate) requestModData(); 10832 toast("Board reset", "All hives, reports, and logs were cleared."); 10833 return; 10834 } 10835 10836 if (msg.type === "rolesUpdated") { 10837 customRoles = normalizeRoleDefs(msg.roles); 10838 renderPeoplePanel(); 10839 renderModPanel(); 10840 return; 10841 } 10842 10843 if (msg.type === "pluginsUpdated") { 10844 setPlugins(msg.plugins); 10845 return; 10846 } 10847 10848 if (msg.type === "profilesUpdated" && msg.profiles && typeof msg.profiles === "object") { 10849 const nextProfiles = msg.profiles; 10850 const nextKeys = Object.keys(nextProfiles); 10851 const currentKeys = Object.keys(profiles || {}); 10852 if (nextKeys.length === 0 && currentKeys.length > 0) { 10853 return; 10854 } 10855 profiles = nextProfiles; 10856 setAuthUi(); 10857 renderFeed(); 10858 renderChatPanel(); 10859 renderPeoplePanel(); 10860 if (centerView === "profile") renderCenterPanels(); 10861 return; 10862 } 10863 10864 if (msg.type === "userProfile" && msg.profile) { 10865 const profile = normalizeProfileData(msg.profile); 10866 if (!profile.username) return; 10867 if (activeProfileUsername && profile.username !== activeProfileUsername) return; 10868 activeProfile = profile; 10869 setCenterView("profile", profile.username); 10870 return; 10871 } 10872 10873 if (msg.type === "userProfileUpdated" && msg.profile) { 10874 const profile = normalizeProfileData(msg.profile); 10875 if (!profile.username) return; 10876 if (centerView === "profile" && activeProfileUsername === profile.username) { 10877 activeProfile = profile; 10878 renderCenterPanels(); 10879 } 10880 return; 10881 } 10882 10883 if (msg.type === "newPost" && msg.post) { 10884 const isNewId = !posts.has(msg.post.id); 10885 posts.set(msg.post.id, msg.post); 10886 renderFeed(); 10887 if (isNewId) { 10888 newPostAnimIds.add(msg.post.id); 10889 setTimeout(() => { 10890 newPostAnimIds.delete(msg.post.id); 10891 renderFeed(); 10892 }, 950); 10893 } 10894 const author = msg.post.author || ""; 10895 const title = postTitle(msg.post); 10896 const authorLower = String(author || "").toLowerCase(); 10897 const selfLower = String(loggedInUser || "").toLowerCase(); 10898 const ignoreUserSet = new Set( 10899 [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase()) 10900 ); 10901 if (author && loggedInUser && author === loggedInUser) { 10902 playSfx("post", { volume: 0.36 }); 10903 } 10904 if (author && author !== loggedInUser && !(authorLower && authorLower !== selfLower && ignoreUserSet.has(authorLower))) { 10905 if (!windowFocused || document.hidden) { 10906 maybeNotify(`Bzl: ${title}`, `New post by @${author}`, { postId: msg.post.id }); 10907 } else { 10908 toast("New post", `${author ? `@${author}: ` : ""}${title}`); 10909 } 10910 } 10911 return; 10912 } 10913 10914 if (msg.type === "postUpdated" && msg.post) { 10915 posts.set(msg.post.id, msg.post); 10916 renderFeed(); 10917 renderChatPanel(); 10918 return; 10919 } 10920 10921 if (msg.type === "deletePost") { 10922 if (userPrefs?.starredPostIds) userPrefs.starredPostIds = userPrefs.starredPostIds.filter((id) => id !== msg.id); 10923 if (userPrefs?.hiddenPostIds) userPrefs.hiddenPostIds = userPrefs.hiddenPostIds.filter((id) => id !== msg.id); 10924 posts.delete(msg.id); 10925 chatByPost.delete(msg.id); 10926 unreadByPostId.delete(msg.id); 10927 typingUsersByPostId.delete(msg.id); 10928 if (buzzTimers.has(msg.id)) { 10929 clearTimeout(buzzTimers.get(msg.id)); 10930 buzzTimers.delete(msg.id); 10931 } 10932 if (activeChatPostId === msg.id) activeChatPostId = null; 10933 renderFeed(); 10934 renderChatPanel(); 10935 renderTypingIndicator(); 10936 return; 10937 } 10938 10939 if (msg.type === "loginOk") { 10940 loggedInUser = msg.username || null; 10941 loggedInRole = typeof msg.role === "string" ? msg.role : "member"; 10942 canModerate = Boolean(msg.canModerate); 10943 onboardingState = normalizeOnboardingState(msg.onboarding || onboardingState); 10944 if (typeof msg.sessionToken === "string" && msg.sessionToken) setSessionToken(msg.sessionToken); 10945 const profile = msg.profile || {}; 10946 pendingProfileImage = typeof profile.image === "string" ? profile.image : ""; 10947 if (pendingProfileImage) { 10948 profilePreview.src = pendingProfileImage; 10949 profilePreview.classList.add("hasImg"); 10950 } else { 10951 profilePreview.removeAttribute("src"); 10952 profilePreview.classList.remove("hasImg"); 10953 } 10954 if (profile.color) nameColorInput.value = profile.color; 10955 setUserPrefs(msg.prefs || {}); 10956 authPass.value = ""; 10957 profileStatus.textContent = ""; 10958 setAuthUi(); 10959 renderFeed(); 10960 renderLanHint(); 10961 if (centerView === "profile" && activeProfileUsername === loggedInUser) { 10962 ws.send(JSON.stringify({ type: "getUserProfile", username: loggedInUser })); 10963 } else { 10964 renderCenterPanels(); 10965 } 10966 if (canModerate) requestModData(); 10967 if (rackLayoutEnabled) applyDockState(); 10968 updateLayoutPresetOptions(); 10969 renderOnboardingCard(); 10970 return; 10971 } 10972 10973 if (msg.type === "logoutOk") { 10974 setSessionToken(""); 10975 loggedInUser = null; 10976 loggedInRole = "member"; 10977 canModerate = false; 10978 onboardingState = normalizeOnboardingState({ acceptedRulesVersion: 0, acceptedAt: 0, needsAcceptance: false }); 10979 dmThreads = []; 10980 dmThreadsById = new Map(); 10981 dmMessagesByThreadId.clear(); 10982 activeDmThreadId = null; 10983 pendingOpenDmThreadId = ""; 10984 stopWalkieRecording(); 10985 lanUrls = []; 10986 modReports = []; 10987 modUsers = []; 10988 modLog = []; 10989 setUserPrefs({ starredPostIds: [], hiddenPostIds: [] }); 10990 activeHiveView = "all"; 10991 setAuthUi(); 10992 renderFeed(); 10993 renderLanHint(); 10994 renderPeoplePanel(); 10995 renderCenterPanels(); 10996 if (rackLayoutEnabled) applyDockState(); 10997 updateLayoutPresetOptions(); 10998 renderOnboardingCard(); 10999 return; 11000 } 11001 11002 if (msg.type === "authState") { 11003 if (!loggedInUser || msg.username !== loggedInUser) return; 11004 loggedInRole = typeof msg.role === "string" ? msg.role : loggedInRole; 11005 canModerate = Boolean(msg.canModerate); 11006 onboardingState = normalizeOnboardingState(msg.onboarding || onboardingState); 11007 if (!canModerate) lanUrls = []; 11008 if (msg.prefs && typeof msg.prefs === "object") setUserPrefs(msg.prefs); 11009 setAuthUi(); 11010 renderLanHint(); 11011 if (rackLayoutEnabled) applyDockState(); 11012 renderPeoplePanel(); 11013 if (canModerate) requestModData(); 11014 updateLayoutPresetOptions(); 11015 renderOnboardingCard(); 11016 return; 11017 } 11018 11019 if (msg.type === "onboardingState" && msg.onboarding && typeof msg.onboarding === "object") { 11020 onboardingState = normalizeOnboardingState(msg.onboarding); 11021 setAuthUi(); 11022 renderOnboardingCard(); 11023 return; 11024 } 11025 11026 if (msg.type === "sessionInvalid") { 11027 setSessionToken(""); 11028 setUserPrefs({ starredPostIds: [], hiddenPostIds: [] }); 11029 dmThreads = []; 11030 dmThreadsById = new Map(); 11031 dmMessagesByThreadId.clear(); 11032 activeDmThreadId = null; 11033 pendingOpenDmThreadId = ""; 11034 return; 11035 } 11036 11037 if (msg.type === "userPrefs") { 11038 setUserPrefs(msg.prefs || {}); 11039 renderFeed(); 11040 return; 11041 } 11042 11043 if (msg.type === "peopleSnapshot") { 11044 peopleMembers = Array.isArray(msg.members) ? msg.members : []; 11045 renderPeoplePanel(); 11046 return; 11047 } 11048 11049 if (msg.type === "dmSnapshot") { 11050 setDmThreads(Array.isArray(msg.threads) ? msg.threads : []); 11051 return; 11052 } 11053 11054 if (msg.type === "dmThreadOk" && msg.thread) { 11055 const t = normalizeDmThread(msg.thread); 11056 if (!t) return; 11057 upsertDmThread(t); 11058 if (pendingOpenDmThreadId && pendingOpenDmThreadId === t.id && String(t.status || "") === "active") { 11059 openDmThread(t.id); 11060 } 11061 return; 11062 } 11063 11064 if (msg.type === "dmThreadUpdated" && msg.thread) { 11065 const me = String(loggedInUser || "").trim().toLowerCase(); 11066 const a = msg.thread?.a ? normalizeDmThread(msg.thread.a) : null; 11067 const b = msg.thread?.b ? normalizeDmThread(msg.thread.b) : null; 11068 const mine = me ? [a, b].find((t) => t && String(t.other || "").toLowerCase() !== me) : a || b; 11069 if (mine) { 11070 upsertDmThread(mine); 11071 if (pendingOpenDmThreadId && pendingOpenDmThreadId === mine.id && String(mine.status || "") === "active") { 11072 openDmThread(mine.id); 11073 } 11074 if (activeDmThreadId && mine.id === activeDmThreadId) { 11075 const current = dmMessagesByThreadId.get(activeDmThreadId) || null; 11076 if (!current || current.length === 0) ws.send(JSON.stringify({ type: "dmHistory", threadId: activeDmThreadId })); 11077 } 11078 } 11079 return; 11080 } 11081 11082 if (msg.type === "dmHistory") { 11083 const threadId = String(msg.threadId || "").trim(); 11084 if (!threadId) return; 11085 const messages = Array.isArray(msg.messages) ? msg.messages.map(normalizeDmMessage).filter(Boolean) : []; 11086 dmMessagesByThreadId.set(threadId, messages); 11087 if (activeDmThreadId === threadId) renderChatPanel(true); 11088 return; 11089 } 11090 11091 if (msg.type === "dmMessage" && msg.threadId && msg.message) { 11092 const threadId = String(msg.threadId || "").trim(); 11093 const message = normalizeDmMessage(msg.message); 11094 if (!threadId || !message) return; 11095 const existing = dmMessagesByThreadId.get(threadId) || []; 11096 if (!existing.some((m) => m.id === message.id)) { 11097 existing.push(message); 11098 dmMessagesByThreadId.set(threadId, existing); 11099 } 11100 const sender = String(message.fromUser || ""); 11101 const isFromYou = Boolean(sender && loggedInUser && sender === loggedInUser); 11102 if (activeDmThreadId === threadId && windowFocused && !document.hidden) { 11103 if (!appendDmMessageToDom(threadId, message)) renderChatPanel(); 11104 pulseChatMessage(message.id); 11105 } else { 11106 if (!isFromYou) { 11107 const title = `DM from @${sender || "unknown"}`; 11108 const body = String(message.text || "").slice(0, 160) || "New message"; 11109 if (!windowFocused || document.hidden) maybeNotify(`Bzl: ${title}`, body, { threadId }); 11110 else toast("DM", `${sender ? `@${sender}: ` : ""}${body}`); 11111 playSfx("ping", { volume: 0.38 }); 11112 } 11113 renderPeoplePanel(); 11114 } 11115 return; 11116 } 11117 11118 if (msg.type === "dmModMessageReceived") { 11119 const threadId = String(msg.threadId || "").trim(); 11120 if (!threadId) return; 11121 if (!dmThreadsById.has(threadId) && ws?.readyState === WebSocket.OPEN) { 11122 pendingOpenDmThreadId = threadId; 11123 ws.send(JSON.stringify({ type: "dmList" })); 11124 } 11125 if (isMobileScreenMode()) { 11126 const layout = loadMobileLayout(); 11127 layout.active = "chat"; 11128 saveMobileLayout(layout); 11129 setMobileScreen("chat"); 11130 renderMobileNav(); 11131 } 11132 if (dmThreadsById.has(threadId)) openDmThread(threadId); 11133 toast("Moderator message", "Opened priority moderator DM."); 11134 return; 11135 } 11136 11137 if (msg.type === "lanInfo") { 11138 lanUrls = Array.isArray(msg.lanUrls) ? msg.lanUrls : []; 11139 renderLanHint(); 11140 return; 11141 } 11142 11143 if (msg.type === "loginError") { 11144 authHint.textContent = msg.message || "Login failed."; 11145 return; 11146 } 11147 11148 if (msg.type === "profileOk") { 11149 const profile = msg.profile || {}; 11150 pendingProfileImage = typeof profile.image === "string" ? profile.image : pendingProfileImage; 11151 if (pendingProfileImage) { 11152 profilePreview.src = pendingProfileImage; 11153 profilePreview.classList.add("hasImg"); 11154 } else { 11155 profilePreview.removeAttribute("src"); 11156 profilePreview.classList.remove("hasImg"); 11157 } 11158 if (profile.color) nameColorInput.value = profile.color; 11159 profileStatus.textContent = "Saved."; 11160 const normalized = normalizeProfileData(profile, loggedInUser || ""); 11161 if (loggedInUser && normalized.username === loggedInUser) { 11162 activeProfile = normalized; 11163 activeProfileUsername = loggedInUser; 11164 if (centerView === "profile") { 11165 isEditingProfile = false; 11166 if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile"; 11167 renderCenterPanels(); 11168 } 11169 } 11170 return; 11171 } 11172 11173 if (msg.type === "error") { 11174 const m = msg.message || "Error"; 11175 authHint.textContent = m; 11176 profileStatus.textContent = m; 11177 toast("Error", m); 11178 return; 11179 } 11180 11181 if (msg.type === "rateLimited") { 11182 const m = msg.message || "Too many requests. Please wait and try again."; 11183 toast("Rate limit", m); 11184 return; 11185 } 11186 11187 if (msg.type === "permissionDenied") { 11188 const m = msg.message || "Permission denied."; 11189 if (/(owner|moderator) access required/i.test(m)) { 11190 pluginAdminStatus = m; 11191 pluginAdminBusy = false; 11192 pluginEnableInFlight.clear(); 11193 renderModPanel(); 11194 } 11195 toast("Moderation", m); 11196 return; 11197 } 11198 11199 if (msg.type === "collectionOk") { 11200 toast("Collections", "Collection created."); 11201 return; 11202 } 11203 11204 if (msg.type === "roleOk") { 11205 toast("Roles", "Role created."); 11206 return; 11207 } 11208 11209 if (msg.type === "pluginOk") { 11210 if (msg.uninstalled) pluginAdminStatus = "Plugin uninstalled."; 11211 else if (typeof msg.enabled === "boolean") pluginAdminStatus = msg.enabled ? "Plugin enabled." : "Plugin disabled."; 11212 else if (msg.reloaded) pluginAdminStatus = "Plugins reloaded."; 11213 else pluginAdminStatus = "Plugin updated."; 11214 pluginAdminBusy = false; 11215 if (msg.id) pluginEnableInFlight.delete(String(msg.id || "").trim().toLowerCase()); 11216 if (modTab === "server") renderModPanel(); 11217 return; 11218 } 11219 11220 if (msg.type === "postUnlocked") { 11221 const postId = msg.postId || ""; 11222 if (!postId || !msg.post) return; 11223 posts.set(postId, msg.post); 11224 if (Array.isArray(msg.messages)) chatByPost.set(postId, msg.messages); 11225 renderFeed(); 11226 renderChatPanel(); 11227 renderTypingIndicator(); 11228 if (pendingOpenChatAfterUnlock === postId) { 11229 pendingOpenChatAfterUnlock = null; 11230 openChat(postId); 11231 } else { 11232 toast("Unlocked", "You can view and chat in this post."); 11233 } 11234 return; 11235 } 11236 11237 if (msg.type === "chatHistory") { 11238 chatByPost.set(msg.postId, Array.isArray(msg.messages) ? msg.messages : []); 11239 markRead(msg.postId); 11240 renderChatPanel(true); 11241 renderTypingIndicator(); 11242 renderChatInstancesForPost(msg.postId); 11243 return; 11244 } 11245 11246 if (msg.type === "modSnapshot") { 11247 if (Array.isArray(msg.reports)) modReports = msg.reports; 11248 if (Array.isArray(msg.users)) modUsers = msg.users; 11249 if (Array.isArray(msg.log)) modLog = msg.log; 11250 renderModPanel(); 11251 return; 11252 } 11253 11254 if (msg.type === "devLogSnapshot") { 11255 if (Array.isArray(msg.log)) devLog = msg.log; 11256 if (canModerate && modTab === "log" && modLogView === "dev") renderModPanel(); 11257 return; 11258 } 11259 11260 if (msg.type === "devLogAppended" && msg.entry) { 11261 devLog.unshift(msg.entry); 11262 if (devLog.length > 300) devLog.splice(300); 11263 if (canModerate && modTab === "log" && modLogView === "dev") renderModPanel(); 11264 return; 11265 } 11266 11267 if (msg.type === "modLogAppended" && msg.entry) { 11268 modLog.unshift(msg.entry); 11269 if (modLog.length > 200) modLog.splice(200); 11270 renderModPanel(); 11271 return; 11272 } 11273 11274 if (msg.type === "modActionApplied") { 11275 requestModData(); 11276 renderFeed(); 11277 renderChatPanel(); 11278 renderModPanel(); 11279 return; 11280 } 11281 11282 if (msg.type === "nukeOk") { 11283 toast( 11284 "NUKE complete", 11285 `Cleared ${Number(msg.deletedPosts || 0)} hives and deleted ${Number(msg.deletedUploads || 0)} uploads (kept ${Number(msg.keptUploads || 0)} profile files).` 11286 ); 11287 return; 11288 } 11289 11290 if (msg.type === "reportCreated" && msg.report) { 11291 if (canModerate) { 11292 const idx = modReports.findIndex((r) => r.id === msg.report.id); 11293 if (idx >= 0) modReports[idx] = msg.report; 11294 else modReports.unshift(msg.report); 11295 if (modReports.length > 200) modReports.splice(200); 11296 renderModPanel(); 11297 } else if (msg.report.reporter === loggedInUser) { 11298 toast("Report submitted", "Thanks. A moderator will review it."); 11299 } 11300 return; 11301 } 11302 11303 if (msg.type === "reportUpdated" && msg.report) { 11304 const idx = modReports.findIndex((r) => r.id === msg.report.id); 11305 if (idx >= 0) modReports[idx] = msg.report; 11306 else modReports.unshift(msg.report); 11307 if (modReports.length > 200) modReports.splice(200); 11308 renderModPanel(); 11309 return; 11310 } 11311 11312 if (msg.type === "reactionUpdated" && msg.targetType === "chat") { 11313 const postId = msg.postId || ""; 11314 const messageId = msg.messageId || ""; 11315 const reactions = msg.reactions && typeof msg.reactions === "object" ? msg.reactions : {}; 11316 const arr = chatByPost.get(postId) || []; 11317 const m = arr.find((x) => x && x.id === messageId); 11318 if (m) m.reactions = reactions; 11319 if (activeChatPostId === postId) renderChatPanel(); 11320 renderChatInstancesForPost(postId); 11321 return; 11322 } 11323 11324 if (msg.type === "typing") { 11325 const postId = msg.postId || ""; 11326 const username = msg.username || ""; 11327 if (!postId || !username) return; 11328 if (loggedInUser && username === loggedInUser) return; 11329 const ignoreUserSet = new Set( 11330 [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase()) 11331 ); 11332 const usernameLower = String(username || "").toLowerCase(); 11333 const selfLower = String(loggedInUser || "").toLowerCase(); 11334 if (usernameLower && usernameLower !== selfLower && ignoreUserSet.has(usernameLower)) return; 11335 const isTyping = Boolean(msg.isTyping); 11336 const set = typingUsersByPostId.get(postId) || new Set(); 11337 if (isTyping) set.add(username); 11338 else set.delete(username); 11339 if (set.size === 0) typingUsersByPostId.delete(postId); 11340 else typingUsersByPostId.set(postId, set); 11341 if (activeChatPostId === postId) renderTypingIndicator(); 11342 renderChatInstancesForPost(postId); 11343 return; 11344 } 11345 11346 if (msg.type === "chatMessage") { 11347 const arr = chatByPost.get(msg.postId) || []; 11348 arr.push(msg.message); 11349 if (arr.length > 200) arr.splice(0, arr.length - 200); 11350 chatByPost.set(msg.postId, arr); 11351 const sender = msg.message?.fromUser || ""; 11352 if (sender) { 11353 const set = typingUsersByPostId.get(msg.postId); 11354 if (set && set.has(sender)) { 11355 set.delete(sender); 11356 if (set.size === 0) typingUsersByPostId.delete(msg.postId); 11357 } 11358 } 11359 const isFromYou = Boolean(sender && loggedInUser && sender === loggedInUser); 11360 const senderLower = String(sender || "").toLowerCase(); 11361 const selfLower = String(loggedInUser || "").toLowerCase(); 11362 const ignoreUserSet = new Set( 11363 [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase()) 11364 ); 11365 if (!isFromYou && senderLower && senderLower !== selfLower && ignoreUserSet.has(senderLower)) { 11366 if (activeChatPostId === msg.postId) renderChatPanel(); 11367 renderChatInstancesForPost(msg.postId); 11368 return; 11369 } 11370 const mentions = Array.isArray(msg.message?.mentions) ? msg.message.mentions.map((u) => String(u || "").toLowerCase()) : []; 11371 const mentionsYou = Boolean(loggedInUser && mentions.includes(loggedInUser) && !isFromYou); 11372 if (mentionsYou) playSfx("ping", { volume: 0.42 }); 11373 if (activeChatPostId === msg.postId && windowFocused && !document.hidden) { 11374 markRead(msg.postId); 11375 if (!appendPostChatMessageToDom(msg.postId, msg.message)) renderChatPanel(); 11376 pulseChatMessage(msg.message?.id); 11377 renderTypingIndicator(); 11378 if (mentionsYou) toast("Mentioned", `@${sender} mentioned you.`); 11379 } else { 11380 if (!buzzTimers.has(msg.postId)) { 11381 const t = window.setTimeout(() => { 11382 buzzTimers.delete(msg.postId); 11383 renderFeed(); 11384 }, 750); 11385 buzzTimers.set(msg.postId, t); 11386 } else { 11387 clearTimeout(buzzTimers.get(msg.postId)); 11388 const t = window.setTimeout(() => { 11389 buzzTimers.delete(msg.postId); 11390 renderFeed(); 11391 }, 750); 11392 buzzTimers.set(msg.postId, t); 11393 } 11394 bumpUnread(msg.postId); 11395 renderFeed(); 11396 const p = posts.get(msg.postId); 11397 const title = p ? postTitle(p) : "Chat"; 11398 const body = sender ? `@${sender}: ${msg.message?.text || ""}` : msg.message?.text || ""; 11399 if (!isFromYou) { 11400 if (!windowFocused || document.hidden) { 11401 const notifyTitle = mentionsYou ? `Bzl: Mention in ${title}` : `Bzl: ${title}`; 11402 maybeNotify(notifyTitle, body.slice(0, 160), { postId: msg.postId }); 11403 } else { 11404 const toastTitle = mentionsYou ? "Mentioned" : title; 11405 const toastBody = mentionsYou ? `@${sender} mentioned you` : body.slice(0, 120); 11406 toast(toastTitle, toastBody); 11407 } 11408 } 11409 } 11410 renderChatInstancesForPost(msg.postId); 11411 } 11412 } 11413 11414 setConn("connecting"); 11415 connectWs(); 11416 11417 renderLanHint(); 11418 writeHintsEnabledPref(readHintsEnabledPref()); 11419 initDisplayPrefsUi(); 11420 if (stayConnectedEl) { 11421 stayConnectedEl.checked = readStayConnectedPref(); 11422 stayConnectedEl.addEventListener("change", () => { 11423 const on = Boolean(stayConnectedEl.checked); 11424 writeStayConnectedPref(on); 11425 if (on) { 11426 if (!ws || ws.readyState === WebSocket.CLOSED) connectWs(); 11427 startWsKeepalive(ws); 11428 } else { 11429 clearWsReconnect(); 11430 clearWsKeepalive(); 11431 } 11432 }); 11433 } 11434 if (enableHintsEl) { 11435 enableHintsEl.checked = readHintsEnabledPref(); 11436 enableHintsEl.addEventListener("change", () => { 11437 writeHintsEnabledPref(Boolean(enableHintsEl.checked)); 11438 }); 11439 } 11440 if (chatEnterModeEl) { 11441 chatEnterModeEl.value = readChatEnterModePref(); 11442 chatEnterModeEl.addEventListener("change", () => { 11443 writeChatEnterModePref(chatEnterModeEl.value); 11444 }); 11445 } 11446 if (resetCurrentLayoutBtn) { 11447 resetCurrentLayoutBtn.addEventListener("click", () => { 11448 if (!rackLayoutEnabled) return; 11449 const currentPreset = String(rackLayoutState?.presetId || layoutPresetEl?.value || "defaultSocial"); 11450 applyPreset(currentPreset); 11451 toast("Layout", "Current preset layout reset."); 11452 }); 11453 } 11454 renderPeoplePanel(); 11455 setPeopleOpen(getPeopleOpen()); 11456 composerOpen = getComposerOpen(); 11457 setComposerOpen(composerOpen); 11458 applySidebarWidth(readStoredSidebarWidth(), false); 11459 applyChatWidth(readStoredChatWidth(), false); 11460 applyModWidth(readStoredModWidth(), false); 11461 applyPeopleWidth(readStoredPeopleWidth(), false); 11462 applyChatDock(); 11463 11464 if (toggleReactionsEl) { 11465 toggleReactionsEl.checked = showReactions; 11466 toggleReactionsEl.addEventListener("change", () => { 11467 showReactions = Boolean(toggleReactionsEl.checked); 11468 localStorage.setItem("bzl_showReactions", showReactions ? "1" : "0"); 11469 renderFeed(); 11470 renderChatPanel(); 11471 }); 11472 } 11473 11474 if (hivesViewModeEl) { 11475 const pref = readStringPref(HIVES_VIEW_MODE_KEY, "auto"); 11476 hivesViewModeEl.value = pref === "cards" || pref === "list" ? pref : "auto"; 11477 hivesViewModeEl.addEventListener("change", () => { 11478 const next = String(hivesViewModeEl.value || "auto").toLowerCase(); 11479 writeStringPref(HIVES_VIEW_MODE_KEY, next === "cards" || next === "list" ? next : "auto"); 11480 applyHivesViewMode(); 11481 }); 11482 } 11483 installHivesAutoViewMode(); 11484 applyHivesViewMode(); 11485 updateMobileSortCycleLabel(); 11486 11487 if (chatHeaderEl && appRoot) { 11488 chatHeaderEl.setAttribute("draggable", "true"); 11489 chatHeaderEl.title = "Drag left/right to dock chat"; 11490 chatHeaderEl.addEventListener("dragstart", (e) => { 11491 try { 11492 e.dataTransfer.effectAllowed = "move"; 11493 e.dataTransfer.setData("text/plain", "bzl:dock:chat"); 11494 } catch { 11495 // ignore 11496 } 11497 appRoot.classList.add("isDocking"); 11498 }); 11499 chatHeaderEl.addEventListener("dragend", () => { 11500 appRoot.classList.remove("isDocking"); 11501 }); 11502 appRoot.addEventListener("dragover", (e) => { 11503 if (!appRoot.classList.contains("isDocking")) return; 11504 e.preventDefault(); 11505 try { 11506 e.dataTransfer.dropEffect = "move"; 11507 } catch { 11508 // ignore 11509 } 11510 }); 11511 appRoot.addEventListener("drop", (e) => { 11512 if (!appRoot.classList.contains("isDocking")) return; 11513 e.preventDefault(); 11514 appRoot.classList.remove("isDocking"); 11515 const next = e.clientX > window.innerWidth * 0.58 ? "right" : "left"; 11516 if (next === chatDock) return; 11517 chatDock = next; 11518 localStorage.setItem("bzl_chatDock", chatDock); 11519 applyChatDock(); 11520 }); 11521 } 11522 11523 installDropUpload(editor, { allowImages: true, allowAudio: true }); 11524 installDropUpload(chatEditor, { allowImages: true, allowAudio: true }); 11525 installDropUpload(profileBioEditor, { allowImages: true, allowAudio: true }); 11526 installDropUpload(editModalEditor, { allowImages: true, allowAudio: true }); 11527 11528 mediaModal?.addEventListener("click", (e) => { 11529 if (e.target?.getAttribute?.("data-mediamodalclose")) setMediaModalOpen(false); 11530 }); 11531 mediaModalClose?.addEventListener("click", () => setMediaModalOpen(false)); 11532 mediaModalCopyLink?.addEventListener("click", async () => { 11533 const url = String(mediaModalOpenLink?.href || "").trim(); 11534 if (!url || url === "#") return; 11535 try { 11536 await navigator.clipboard.writeText(url); 11537 if (mediaModalStatus) mediaModalStatus.textContent = "Copied."; 11538 } catch { 11539 if (mediaModalStatus) mediaModalStatus.textContent = "Copy failed (clipboard blocked)."; 11540 } 11541 }); 11542 shortcutHelpModal?.addEventListener("click", (e) => { 11543 if (e.target?.getAttribute?.("data-shortcutclose")) setShortcutHelpOpen(false); 11544 }); 11545 shortcutHelpCloseBtn?.addEventListener("click", () => setShortcutHelpOpen(false)); 11546 openShortcutHelpBtn?.addEventListener("click", () => setShortcutHelpOpen(true)); 11547 document.addEventListener("keydown", (e) => { 11548 if (e.key !== "Escape") return; 11549 if (mediaModal && !mediaModal.classList.contains("hidden")) { 11550 setMediaModalOpen(false); 11551 return; 11552 } 11553 if (shortcutHelpModal && !shortcutHelpModal.classList.contains("hidden")) setShortcutHelpOpen(false); 11554 }); 11555 document.body.addEventListener("click", (e) => { 11556 const img = e.target?.closest?.("img"); 11557 if (!img) return; 11558 if (img.id === "profilePreview") return; 11559 if (img.closest("#mediaModal")) return; 11560 const inAllowed = 11561 img.closest(".chatMsg .content") || 11562 img.closest(".profileBio") || 11563 img.closest(".profileCard") || 11564 img.closest(".editor") || 11565 img.closest("#editModalEditor"); 11566 if (!inAllowed) return; 11567 const src = img.getAttribute("src") || ""; 11568 if (!src) return; 11569 openMediaModal(src); 11570 }); 11571 11572 setSidebarHidden(getSidebarHidden()); 11573 toggleSidebarBtn?.addEventListener("click", () => setSidebarHidden(true)); 11574 showSidebarBtn?.addEventListener("click", () => setSidebarHidden(false)); 11575 togglePeopleBtn?.addEventListener("click", () => setPeopleOpen(!peopleOpen)); 11576 closePeopleBtn?.addEventListener("click", () => setPeopleOpen(false)); 11577 peopleMembersTabBtn?.addEventListener("click", () => { 11578 peopleTab = "members"; 11579 renderPeoplePanel(); 11580 }); 11581 peopleDmsTabBtn?.addEventListener("click", () => { 11582 peopleTab = "dms"; 11583 renderPeoplePanel(); 11584 }); 11585 peopleSearchEl?.addEventListener("input", () => renderPeoplePanel()); 11586 peopleListEl?.addEventListener("click", (e) => { 11587 const modDmBtn = e.target.closest("button[data-moddm]"); 11588 if (modDmBtn) { 11589 sendModDmPrompt(modDmBtn.getAttribute("data-moddm") || ""); 11590 return; 11591 } 11592 const dmBtn = e.target.closest("button[data-dmrequest]"); 11593 if (dmBtn) { 11594 const to = String(dmBtn.getAttribute("data-dmrequest") || "") 11595 .trim() 11596 .replace(/^@+/, "") 11597 .toLowerCase(); 11598 if (!to) return; 11599 if (!loggedInUser) { 11600 toast("Sign in required", "Sign in to start a DM."); 11601 return; 11602 } 11603 if (to === String(loggedInUser).toLowerCase()) return; 11604 ws.send(JSON.stringify({ type: "dmRequestCreate", to })); 11605 peopleTab = "dms"; 11606 renderPeoplePanel(); 11607 return; 11608 } 11609 const btn = e.target.closest("[data-viewprofile]"); 11610 if (!btn) return; 11611 const username = btn.getAttribute("data-viewprofile") || ""; 11612 openUserProfile(username); 11613 }); 11614 11615 peopleDmsViewEl?.addEventListener("click", (e) => { 11616 const modDmBtn = e.target.closest("button[data-moddm]"); 11617 if (modDmBtn) { 11618 sendModDmPrompt(modDmBtn.getAttribute("data-moddm") || ""); 11619 return; 11620 } 11621 const profileLink = e.target.closest("[data-viewprofile]"); 11622 if (profileLink) { 11623 const username = profileLink.getAttribute("data-viewprofile") || ""; 11624 if (username) openUserProfile(username); 11625 return; 11626 } 11627 11628 const openBtn = e.target.closest("button[data-dmopen]"); 11629 if (openBtn) { 11630 const threadId = openBtn.getAttribute("data-dmopen") || ""; 11631 if (!threadId) return; 11632 openDmThread(threadId); 11633 return; 11634 } 11635 11636 const acceptBtn = e.target.closest("button[data-dmaccept]"); 11637 if (acceptBtn) { 11638 const threadId = acceptBtn.getAttribute("data-dmaccept") || ""; 11639 if (!threadId) return; 11640 pendingOpenDmThreadId = threadId; 11641 ws.send(JSON.stringify({ type: "dmRequestRespond", threadId, accept: true })); 11642 return; 11643 } 11644 11645 const declineBtn = e.target.closest("button[data-dmdecline]"); 11646 if (declineBtn) { 11647 const threadId = declineBtn.getAttribute("data-dmdecline") || ""; 11648 if (!threadId) return; 11649 ws.send(JSON.stringify({ type: "dmRequestRespond", threadId, accept: false })); 11650 return; 11651 } 11652 11653 const requestAgainBtn = e.target.closest("button[data-dmrequest]"); 11654 if (requestAgainBtn) { 11655 const to = String(requestAgainBtn.getAttribute("data-dmrequest") || "") 11656 .trim() 11657 .replace(/^@+/, "") 11658 .toLowerCase(); 11659 if (!to || !loggedInUser) return; 11660 if (to === String(loggedInUser).toLowerCase()) return; 11661 ws.send(JSON.stringify({ type: "dmRequestCreate", to })); 11662 return; 11663 } 11664 11665 const requestFromSelectBtn = e.target.closest("button[data-dmrequestfromselect]"); 11666 if (requestFromSelectBtn) { 11667 const sel = peopleDmsViewEl.querySelector("select[data-dmto]"); 11668 const to = String(sel?.value || "") 11669 .trim() 11670 .replace(/^@+/, "") 11671 .toLowerCase(); 11672 if (!to) return; 11673 if (!loggedInUser) { 11674 toast("Sign in required", "Sign in to start a DM."); 11675 return; 11676 } 11677 if (to === String(loggedInUser).toLowerCase()) return; 11678 ws.send(JSON.stringify({ type: "dmRequestCreate", to })); 11679 if (sel) sel.value = ""; 11680 return; 11681 } 11682 }); 11683 11684 onboardingAcceptBtn?.addEventListener("click", () => { 11685 if (!loggedInUser) { 11686 toast("Sign in required", "Sign in to accept server rules."); 11687 return; 11688 } 11689 ws.send(JSON.stringify({ type: "onboardingAcceptRules" })); 11690 }); 11691 11692 onboardingRefreshBtn?.addEventListener("click", () => { 11693 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" })); 11694 }); 11695 11696 onboardingPanelAcceptBtn?.addEventListener("click", () => { 11697 if (!loggedInUser) { 11698 toast("Sign in required", "Sign in to accept server rules."); 11699 return; 11700 } 11701 ws.send(JSON.stringify({ type: "onboardingAcceptRules" })); 11702 }); 11703 11704 onboardingPanelRefreshBtn?.addEventListener("click", () => { 11705 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" })); 11706 }); 11707 11708 onboardingPanelBodyEl?.addEventListener("click", (e) => { 11709 const tabBtn = e.target.closest?.("button[data-onbtab]"); 11710 if (!tabBtn) return; 11711 const tab = String(tabBtn.getAttribute("data-onbtab") || "about").trim(); 11712 if (!["about", "rules", "roles"].includes(tab)) return; 11713 onboardingViewerTab = tab; 11714 renderOnboardingPanel(); 11715 }); 11716 11717 profileCard?.addEventListener("click", (e) => { 11718 const modDmBtn = e.target.closest("button[data-moddm]"); 11719 if (modDmBtn) { 11720 sendModDmPrompt(modDmBtn.getAttribute("data-moddm") || ""); 11721 return; 11722 } 11723 const dmBtn = e.target.closest("button[data-dmrequest]"); 11724 if (!dmBtn) return; 11725 const to = String(dmBtn.getAttribute("data-dmrequest") || "") 11726 .trim() 11727 .replace(/^@+/, "") 11728 .toLowerCase(); 11729 if (!to) return; 11730 if (!loggedInUser) { 11731 toast("Sign in required", "Sign in to start a DM."); 11732 return; 11733 } 11734 if (to === String(loggedInUser).toLowerCase()) return; 11735 ws.send(JSON.stringify({ type: "dmRequestCreate", to })); 11736 peopleTab = "dms"; 11737 setPeopleOpen(true); 11738 renderPeoplePanel(); 11739 }); 11740 profileCard?.addEventListener("click", (e) => { 11741 const ignoreBtn = e.target.closest("button[data-ignoreuser],button[data-unignoreuser],button[data-blockuser],button[data-unblockuser]"); 11742 if (!ignoreBtn) return; 11743 const raw = 11744 ignoreBtn.getAttribute("data-ignoreuser") || 11745 ignoreBtn.getAttribute("data-unignoreuser") || 11746 ignoreBtn.getAttribute("data-blockuser") || 11747 ignoreBtn.getAttribute("data-unblockuser") || 11748 ""; 11749 const username = String(raw).trim().replace(/^@+/, "").toLowerCase(); 11750 if (!username || !loggedInUser) return; 11751 if (username === String(loggedInUser).toLowerCase()) return; 11752 if (ignoreBtn.hasAttribute("data-ignoreuser")) ws.send(JSON.stringify({ type: "ignoreUser", username })); 11753 else if (ignoreBtn.hasAttribute("data-unignoreuser")) ws.send(JSON.stringify({ type: "unignoreUser", username })); 11754 else if (ignoreBtn.hasAttribute("data-blockuser")) ws.send(JSON.stringify({ type: "blockUser", username })); 11755 else if (ignoreBtn.hasAttribute("data-unblockuser")) ws.send(JSON.stringify({ type: "unblockUser", username })); 11756 }); 11757 chatResizeHandle?.addEventListener("mousedown", (e) => { 11758 e.preventDefault(); 11759 startChatResize(e.clientX); 11760 }); 11761 chatResizeHandle?.addEventListener("dblclick", () => applyChatWidth(CHAT_WIDTH_DEFAULT)); 11762 sidebarResizeHandle?.addEventListener("mousedown", (e) => { 11763 e.preventDefault(); 11764 startSidebarResize(e.clientX); 11765 }); 11766 sidebarResizeHandle?.addEventListener("dblclick", () => applySidebarWidth(SIDEBAR_WIDTH_DEFAULT)); 11767 mainResizeHandle?.addEventListener("mousedown", (e) => { 11768 e.preventDefault(); 11769 startModResize(e.clientX); 11770 }); 11771 mainResizeHandle?.addEventListener("dblclick", () => applyModWidth(MOD_WIDTH_DEFAULT)); 11772 peopleResizeHandle?.addEventListener("mousedown", (e) => { 11773 e.preventDefault(); 11774 startPeopleResize(e.clientX); 11775 }); 11776 peopleResizeHandle?.addEventListener("dblclick", () => applyPeopleWidth(PEOPLE_WIDTH_DEFAULT)); 11777 sidebarPanelEl?.addEventListener("mousedown", (e) => { 11778 if (e.button !== 0 || isMobileSwipeMode()) return; 11779 const rect = sidebarPanelEl.getBoundingClientRect(); 11780 if (Math.abs(e.clientX - rect.right) > 12) return; 11781 e.preventDefault(); 11782 startSidebarResize(e.clientX); 11783 }); 11784 chatPanelEl?.addEventListener("mousedown", (e) => { 11785 if (e.button !== 0 || isMobileSwipeMode()) return; 11786 const rect = chatPanelEl.getBoundingClientRect(); 11787 if (Math.abs(e.clientX - rect.right) > 12) return; 11788 e.preventDefault(); 11789 startChatResize(e.clientX); 11790 }); 11791 modPanelEl?.addEventListener("mousedown", (e) => { 11792 if (e.button !== 0 || isMobileSwipeMode() || modPanelEl.classList.contains("hidden")) return; 11793 const rect = modPanelEl.getBoundingClientRect(); 11794 if (Math.abs(e.clientX - rect.left) > 12) return; 11795 e.preventDefault(); 11796 startModResize(e.clientX); 11797 }); 11798 peopleDrawerEl?.addEventListener("mousedown", (e) => { 11799 if (e.button !== 0 || isMobileSwipeMode() || peopleDrawerEl.classList.contains("hidden")) return; 11800 const rect = peopleDrawerEl.getBoundingClientRect(); 11801 if (Math.abs(e.clientX - rect.left) > 12) return; 11802 e.preventDefault(); 11803 startPeopleResize(e.clientX); 11804 }); 11805 mobileNavEl?.addEventListener("click", (e) => { 11806 const btn = e.target.closest("[data-mobilescreen]"); 11807 if (!btn) return; 11808 const id = String(btn.getAttribute("data-mobilescreen") || "").trim(); 11809 if (!id) return; 11810 if (id === "more") { 11811 renderMobileMoreList(); 11812 setMobileMoreOpen(true); 11813 return; 11814 } 11815 const layout = loadMobileLayout(); 11816 layout.active = id; 11817 saveMobileLayout(layout); 11818 setMobileScreen(id); 11819 renderMobileNav(); 11820 }); 11821 11822 function renderMobileMoreList() { 11823 if (!(mobileMoreListEl instanceof HTMLElement)) return; 11824 const q = String(mobileMoreSearchEl?.value || "").trim().toLowerCase(); 11825 const { core, plugins } = availableMobileScreens(); 11826 11827 const filter = (item) => { 11828 if (!q) return true; 11829 return String(item.title || "").toLowerCase().includes(q) || String(item.id || "").toLowerCase().includes(q); 11830 }; 11831 11832 const section = (title, items) => { 11833 const wrap = document.createElement("div"); 11834 const head = document.createElement("div"); 11835 head.className = "muted small"; 11836 head.textContent = title; 11837 head.style.margin = "6px 0 6px 2px"; 11838 wrap.appendChild(head); 11839 const list = document.createElement("div"); 11840 list.style.display = "flex"; 11841 list.style.flexDirection = "column"; 11842 list.style.gap = "10px"; 11843 for (const it of items.filter(filter)) { 11844 const row = document.createElement("button"); 11845 row.type = "button"; 11846 row.className = "mobileMoreItem"; 11847 row.innerHTML = `<span>${escapeHtml(it.title || it.id)}</span><span class="muted small">${escapeHtml(it.core ? "core" : "plugin")}</span>`; 11848 row.onclick = () => { 11849 const layout = loadMobileLayout(); 11850 layout.active = it.id; 11851 saveMobileLayout(layout); 11852 setMobileScreen(it.id); 11853 renderMobileNav(); 11854 setMobileMoreOpen(false); 11855 }; 11856 list.appendChild(row); 11857 } 11858 wrap.appendChild(list); 11859 return wrap; 11860 }; 11861 11862 mobileMoreListEl.innerHTML = ""; 11863 mobileMoreListEl.appendChild(section("Core", core)); 11864 if (plugins.length) mobileMoreListEl.appendChild(section("Plugins", plugins)); 11865 } 11866 11867 mobileMoreSearchEl?.addEventListener("input", () => { 11868 if (!mobileMoreOpen) return; 11869 renderMobileMoreList(); 11870 }); 11871 11872 mobileMoreCloseBtn?.addEventListener("click", () => setMobileMoreOpen(false)); 11873 mobileMoreSheetEl?.addEventListener("click", (e) => { 11874 const target = e.target; 11875 if (!target) return; 11876 if (target.closest?.("[data-mobilemoreclose]")) setMobileMoreOpen(false); 11877 }); 11878 11879 walkieRecordBtn?.addEventListener("pointerdown", (e) => { 11880 e.preventDefault(); 11881 startWalkieRecording(); 11882 }); 11883 walkieRecordBtn?.addEventListener("pointerup", (e) => { 11884 e.preventDefault(); 11885 stopWalkieRecording(); 11886 }); 11887 walkieRecordBtn?.addEventListener("pointerleave", () => stopWalkieRecording()); 11888 walkieRecordBtn?.addEventListener("mousedown", (e) => { 11889 e.preventDefault(); 11890 startWalkieRecording(); 11891 }); 11892 walkieRecordBtn?.addEventListener("mouseup", (e) => { 11893 e.preventDefault(); 11894 stopWalkieRecording(); 11895 }); 11896 walkieRecordBtn?.addEventListener( 11897 "touchstart", 11898 (e) => { 11899 e.preventDefault(); 11900 startWalkieRecording(); 11901 }, 11902 { passive: false } 11903 ); 11904 walkieRecordBtn?.addEventListener( 11905 "touchend", 11906 (e) => { 11907 e.preventDefault(); 11908 stopWalkieRecording(); 11909 }, 11910 { passive: false } 11911 ); 11912 11913 window.addEventListener("keydown", (e) => { 11914 if (!shouldHandleWalkieHotkey(e)) return; 11915 if (!canWalkieTalkNow()) return; 11916 e.preventDefault(); 11917 startWalkieRecording(); 11918 }); 11919 window.addEventListener("keyup", (e) => { 11920 if (!shouldHandleWalkieHotkey(e)) return; 11921 if (!canWalkieTalkNow()) return; 11922 e.preventDefault(); 11923 stopWalkieRecording(); 11924 }); 11925 window.addEventListener("pointerup", () => stopWalkieRecording()); 11926 window.addEventListener("mouseup", () => stopWalkieRecording()); 11927 window.addEventListener("mousemove", (e) => { 11928 if (chatResizeDragging) { 11929 const next = chatResizeStartWidth + (e.clientX - chatResizeStartX); 11930 applyChatWidth(next, false); 11931 return; 11932 } 11933 if (sidebarResizeDragging) { 11934 const next = sidebarResizeStartWidth + (e.clientX - sidebarResizeStartX); 11935 applySidebarWidth(next, false); 11936 return; 11937 } 11938 if (modResizeDragging) { 11939 const next = modResizeStartWidth - (e.clientX - modResizeStartX); 11940 applyModWidth(next, false); 11941 return; 11942 } 11943 if (peopleResizeDragging) { 11944 const next = peopleResizeStartWidth - (e.clientX - peopleResizeStartX); 11945 applyPeopleWidth(next, false); 11946 } 11947 }); 11948 window.addEventListener("mouseup", () => { 11949 if (chatResizeDragging && chatPanelEl) { 11950 applyChatWidth(chatPanelEl.getBoundingClientRect().width || readStoredChatWidth()); 11951 } 11952 if (sidebarResizeDragging && sidebarPanelEl) { 11953 applySidebarWidth(sidebarPanelEl.getBoundingClientRect().width || readStoredSidebarWidth()); 11954 } 11955 if (modResizeDragging && modPanelEl) { 11956 applyModWidth(modPanelEl.getBoundingClientRect().width || readStoredModWidth()); 11957 } 11958 if (peopleResizeDragging && peopleDrawerEl) { 11959 applyPeopleWidth(peopleDrawerEl.getBoundingClientRect().width || readStoredPeopleWidth()); 11960 } 11961 stopAnyPanelResize(); 11962 }); 11963 11964 appRoot?.addEventListener( 11965 "touchstart", 11966 (e) => { 11967 if (!isMobileSwipeMode()) return; 11968 if (!e.touches || e.touches.length !== 1) return; 11969 const t = e.touches[0]; 11970 touchStartX = t.clientX; 11971 touchStartY = t.clientY; 11972 touchTracking = true; 11973 }, 11974 { passive: true } 11975 ); 11976 11977 appRoot?.addEventListener( 11978 "touchend", 11979 (e) => { 11980 if (!isMobileSwipeMode() || !touchTracking) return; 11981 touchTracking = false; 11982 if (!e.changedTouches || e.changedTouches.length !== 1) return; 11983 const t = e.changedTouches[0]; 11984 const dx = t.clientX - touchStartX; 11985 const dy = t.clientY - touchStartY; 11986 if (Math.abs(dx) < 60) return; 11987 if (Math.abs(dx) < Math.abs(dy) * 1.2) return; 11988 if (dx < 0) shiftMobilePanel(1); 11989 else shiftMobilePanel(-1); 11990 }, 11991 { passive: true } 11992 ); 11993 11994 window.addEventListener("resize", applyMobileMode); 11995 applyMobileMode(); 11996 11997 // Initialize experimental rack layout (safe no-op when disabled). 11998 initRackLayout(); 11999 12000 window.addEventListener("focus", () => { 12001 windowFocused = true; 12002 updateNotifUi(); 12003 }); 12004 window.addEventListener("blur", () => { 12005 windowFocused = false; 12006 stopAnyPanelResize(); 12007 }); 12008 document.addEventListener("visibilitychange", () => updateNotifUi()); 12009 12010 enableNotifsBtn?.addEventListener("click", async () => { 12011 if (!notifSupported()) return; 12012 try { 12013 const res = await Notification.requestPermission(); 12014 if (res === "granted") toast("Notifications", "Enabled."); 12015 } catch { 12016 // ignore 12017 } 12018 updateNotifUi(); 12019 }); 12020 12021 updateNotifUi();