app.js (595986B)
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 notifSoundToggleEl = document.getElementById("notifSoundToggle"); 28 const notifNewHiveToggleEl = document.getElementById("notifNewHiveToggle"); 29 const notifReplyPingToggleEl = document.getElementById("notifReplyPingToggle"); 30 const notifMyHiveChatsToggleEl = document.getElementById("notifMyHiveChatsToggle"); 31 const notifRecentHiveChatsToggleEl = document.getElementById("notifRecentHiveChatsToggle"); 32 const toggleReactionsEl = document.getElementById("toggleReactions"); 33 const hivesViewModeEl = document.getElementById("hivesViewMode"); 34 const toggleRackLayoutEl = document.getElementById("toggleRackLayout"); 35 const toggleSideRackEl = document.getElementById("toggleSideRack"); 36 const toggleRightRackEl = document.getElementById("toggleRightRack"); 37 const layoutPresetEl = document.getElementById("layoutPreset"); 38 const uiScaleEl = document.getElementById("uiScale"); 39 const deviceLayoutEl = document.getElementById("deviceLayout"); 40 const appearancePresetEl = document.getElementById("appearancePreset"); 41 const appearanceApplyPresetBtn = document.getElementById("appearanceApplyPreset"); 42 const appearanceResetPreviewBtn = document.getElementById("appearanceResetPreview"); 43 const appearanceBgEl = document.getElementById("appearanceBg"); 44 const appearancePanelEl = document.getElementById("appearancePanel"); 45 const appearanceTextEl = document.getElementById("appearanceText"); 46 const appearanceAccentEl = document.getElementById("appearanceAccent"); 47 const appearanceAccent2El = document.getElementById("appearanceAccent2"); 48 const appearanceGoodEl = document.getElementById("appearanceGood"); 49 const appearanceBadEl = document.getElementById("appearanceBad"); 50 const appearanceMutedPctEl = document.getElementById("appearanceMutedPct"); 51 const appearanceLinePctEl = document.getElementById("appearanceLinePct"); 52 const appearancePanel2PctEl = document.getElementById("appearancePanel2Pct"); 53 const appearanceFontBodyEl = document.getElementById("appearanceFontBody"); 54 const appearanceFontMonoEl = document.getElementById("appearanceFontMono"); 55 const appearanceSaveBtn = document.getElementById("appearanceSave"); 56 const appearanceClearBtn = document.getElementById("appearanceClear"); 57 const appearanceStatusEl = document.getElementById("appearanceStatus"); 58 const stayConnectedEl = document.getElementById("stayConnected"); 59 const enableHintsEl = document.getElementById("enableHints"); 60 const chatEnterModeEl = document.getElementById("chatEnterMode"); 61 const openShortcutHelpBtn = document.getElementById("openShortcutHelp"); 62 const resetCurrentLayoutBtn = document.getElementById("resetCurrentLayout"); 63 const dockHotbarEl = document.getElementById("dockHotbar"); 64 const showSideRackBtn = document.getElementById("showSideRack"); 65 const showRightRackBtn = document.getElementById("showRightRack"); 66 const chatModToggleWrapEl = document.getElementById("chatModToggleWrap"); 67 const chatModToggleEl = document.getElementById("chatModToggle"); 68 const poweredByVersionEl = document.getElementById("poweredByVersion"); 69 70 const authHint = document.getElementById("authHint"); 71 const accountPanel = document.getElementById("accountPanel"); 72 const onboardingCard = document.getElementById("onboardingCard"); 73 const onboardingBody = document.getElementById("onboardingBody"); 74 const onboardingAcceptBtn = document.getElementById("onboardingAccept"); 75 const onboardingRefreshBtn = document.getElementById("onboardingRefresh"); 76 const userLabel = document.getElementById("userLabel"); 77 const authForm = document.getElementById("authForm"); 78 const authUser = document.getElementById("authUser"); 79 const authPass = document.getElementById("authPass"); 80 const codeRow = document.getElementById("codeRow"); 81 const authCode = document.getElementById("authCode"); 82 const registerBtn = document.getElementById("registerBtn"); 83 const tourBtn = document.getElementById("tourBtn"); 84 const logoutBtn = document.getElementById("logoutBtn"); 85 86 const profileImageInput = document.getElementById("profileImage"); 87 const profilePreview = document.getElementById("profilePreview"); 88 const removeProfileImageBtn = document.getElementById("removeProfileImage"); 89 const nameColorInput = document.getElementById("nameColor"); 90 const saveProfileBtn = document.getElementById("saveProfile"); 91 const profileStatus = document.getElementById("profileStatus"); 92 // Instance + plugin admin UI lives in Moderation -> Server tab (rendered dynamically). 93 const modPanelEl = document.getElementById("modPanel"); 94 const modBodyEl = document.getElementById("modBody"); 95 const modRefreshBtn = document.getElementById("modRefresh"); 96 const modReportStatusEl = document.getElementById("modReportStatus"); 97 const modModal = document.getElementById("modModal"); 98 const modModalTitle = document.getElementById("modModalTitle"); 99 const modModalBody = document.getElementById("modModalBody"); 100 const modModalPrimary = document.getElementById("modModalPrimary"); 101 const modModalCancel = document.getElementById("modModalCancel"); 102 const modModalClose = document.getElementById("modModalClose"); 103 const modModalStatus = document.getElementById("modModalStatus"); 104 const mediaModal = document.getElementById("mediaModal"); 105 const mediaModalTitle = document.getElementById("mediaModalTitle"); 106 const mediaModalImg = document.getElementById("mediaModalImg"); 107 const mediaModalOpenLink = document.getElementById("mediaModalOpenLink"); 108 const mediaModalCopyLink = document.getElementById("mediaModalCopyLink"); 109 const mediaModalClose = document.getElementById("mediaModalClose"); 110 const mediaModalStatus = document.getElementById("mediaModalStatus"); 111 const shortcutHelpModal = document.getElementById("shortcutHelpModal"); 112 const shortcutHelpCloseBtn = document.getElementById("shortcutHelpClose"); 113 const sidebarScrollEl = document.querySelector(".sidebarScroll"); 114 115 const newPostForm = document.getElementById("newPostForm"); 116 const pollinatePanel = document.getElementById("pollinatePanel"); 117 const toggleComposerBtn = document.getElementById("toggleComposer"); 118 const toggleComposerInlineBtn = document.getElementById("toggleComposerInline"); 119 const mainRackEl = document.getElementById("mainRack"); 120 const mainWorkspaceRackEl = document.getElementById("mainWorkspaceRack"); 121 const mainSideRackEl = document.getElementById("mainSideRack"); 122 const hivesPanelEl = document.getElementById("hivesPanel"); 123 const postTitleInput = document.getElementById("postTitle"); 124 const postImageInput = document.getElementById("postImage"); 125 const postAudioInput = document.getElementById("postAudio"); 126 const editor = document.getElementById("editor"); 127 const postCollectionEl = document.getElementById("postCollection"); 128 const keywordsEl = document.getElementById("keywords"); 129 const ttlMinutesEl = document.getElementById("ttlMinutes"); 130 const ttlPresetEl = document.getElementById("ttlPreset"); 131 const ttlPermanentEl = document.getElementById("ttlPermanent"); 132 const isProtectedEl = document.getElementById("isProtected"); 133 const postModeEl = document.getElementById("postMode"); 134 const streamKindRowEl = document.getElementById("streamKindRow"); 135 const streamKindEl = document.getElementById("streamKind"); 136 const postPasswordEl = document.getElementById("postPassword"); 137 138 const filterKeywordsEl = document.getElementById("filterKeywords"); 139 const filterAuthorEl = document.getElementById("filterAuthor"); 140 const sortByEl = document.getElementById("sortBy"); 141 const mobileHiveSearchBtn = document.getElementById("mobileHiveSearch"); 142 const mobileSortCycleBtn = document.getElementById("mobileSortCycle"); 143 const clearFilterBtn = document.getElementById("clearFilter"); 144 const feedEl = document.getElementById("feed"); 145 const hiveTabsEl = document.getElementById("hiveTabs"); 146 const onboardingGateHintEl = document.getElementById("onboardingGateHint"); 147 const onboardingPanelEl = document.getElementById("onboardingPanel"); 148 const onboardingPanelBodyEl = document.getElementById("onboardingPanelBody"); 149 const onboardingPanelAcceptBtn = document.getElementById("onboardingPanelAccept"); 150 const onboardingPanelRefreshBtn = document.getElementById("onboardingPanelRefresh"); 151 const bzlSplashEl = document.getElementById("bzlSplash"); 152 const bzlSplashStartBtn = document.getElementById("bzlSplashStartBtn"); 153 const bzlSplashProgressFill = document.getElementById("bzlSplashProgressFill"); 154 const bzlSplashTipEl = document.getElementById("bzlSplashTip"); 155 const authGateEl = document.getElementById("authGate"); 156 const authGateHintEl = document.getElementById("authGateHint"); 157 const authGateFormEl = document.getElementById("authGateForm"); 158 const authGateUserEl = document.getElementById("authGateUser"); 159 const authGatePassEl = document.getElementById("authGatePass"); 160 const authGateCodeRowEl = document.getElementById("authGateCodeRow"); 161 const authGateCodeEl = document.getElementById("authGateCode"); 162 const authGateRegisterEl = document.getElementById("authGateRegister"); 163 const authGateOnboardingBodyEl = document.getElementById("authGateOnboardingBody"); 164 const authGateAcceptBtn = document.getElementById("authGateAccept"); 165 const authGateRefreshBtn = document.getElementById("authGateRefresh"); 166 const profileViewPanel = document.getElementById("profileViewPanel"); 167 const profileViewTitle = document.getElementById("profileViewTitle"); 168 const profileViewMeta = document.getElementById("profileViewMeta"); 169 const profileCard = document.getElementById("profileCard"); 170 const profileBackBtn = document.getElementById("profileBackBtn"); 171 const profileEditToggleBtn = document.getElementById("profileEditToggleBtn"); 172 const profileEditPanel = document.getElementById("profileEditPanel"); 173 const profilePronounsInput = document.getElementById("profilePronouns"); 174 const profileThemeSongUrlInput = document.getElementById("profileThemeSongUrl"); 175 const profileThemeSongUploadBtn = document.getElementById("profileThemeSongUploadBtn"); 176 const profileThemeSongClearBtn = document.getElementById("profileThemeSongClearBtn"); 177 const profileThemeSongFileInput = document.getElementById("profileThemeSongFile"); 178 const profileThemeSongPreview = document.getElementById("profileThemeSongPreview"); 179 const profileBioToolbar = document.getElementById("profileBioToolbar"); 180 const profileBioEditor = document.getElementById("profileBioEditor"); 181 const profileBioImageFileInput = document.getElementById("profileBioImageFile"); 182 const profileBioAudioFileInput = document.getElementById("profileBioAudioFile"); 183 const profileAddLinkBtn = document.getElementById("profileAddLinkBtn"); 184 const profileLinksEditor = document.getElementById("profileLinksEditor"); 185 const profileSaveBtn = document.getElementById("profileSaveBtn"); 186 const profileCancelBtn = document.getElementById("profileCancelBtn"); 187 188 const chatTitle = document.getElementById("chatTitle"); 189 const chatMeta = document.getElementById("chatMeta"); 190 const chatContextSelectEl = document.getElementById("chatContextSelect"); 191 const chatBackToListBtn = document.getElementById("chatBackToList"); 192 const streamStageEl = document.getElementById("streamStage"); 193 const streamStageTitleEl = document.getElementById("streamStageTitle"); 194 const streamStageStatusEl = document.getElementById("streamStageStatus"); 195 const streamStagePrimaryBtn = document.getElementById("streamStagePrimary"); 196 const streamStageVideoEl = document.getElementById("streamStageVideo"); 197 const streamStageAudioEl = document.getElementById("streamStageAudio"); 198 const streamStagePlaceholderEl = document.getElementById("streamStagePlaceholder"); 199 const streamVoiceControlsEl = document.getElementById("streamVoiceControls"); 200 const streamVoiceJoinToggleEl = document.getElementById("streamVoiceJoinToggle"); 201 const streamVoiceMuteBtn = document.getElementById("streamVoiceMuteBtn"); 202 const streamVoiceDeafenBtn = document.getElementById("streamVoiceDeafenBtn"); 203 const streamVoiceUsersEl = document.getElementById("streamVoiceUsers"); 204 const chatMessagesEl = document.getElementById("chatMessages"); 205 const typingIndicator = document.getElementById("typingIndicator"); 206 const chatForm = document.getElementById("chatForm"); 207 const chatReplyBanner = document.getElementById("chatReplyBanner"); 208 const chatReplyWho = document.getElementById("chatReplyWho"); 209 const chatReplyText = document.getElementById("chatReplyText"); 210 const chatReplyCancelBtn = document.getElementById("chatReplyCancel"); 211 const chatEditor = document.getElementById("chatEditor"); 212 const mentionMenuEl = document.getElementById("mentionMenu"); 213 const chatImageInput = document.getElementById("chatImage"); 214 const chatAudioInput = document.getElementById("chatAudio"); 215 216 // When selecting images/audio for chat, route the insertion to the most-recently focused rich editor 217 // (main chat panel or a chat instance panel). 218 let chatUploadTargetEditor = chatEditor; 219 const walkieBarEl = document.getElementById("walkieBar"); 220 const walkieRecordBtn = document.getElementById("walkieRecordBtn"); 221 const walkieStatusEl = document.getElementById("walkieStatus"); 222 const sidebarPanelEl = document.querySelector(".sidebar"); 223 const chatResizeHandle = document.getElementById("chatResizeHandle"); 224 const sidebarResizeHandle = document.getElementById("sidebarResizeHandle"); 225 const mainResizeHandle = document.getElementById("mainResizeHandle"); 226 const chatPanelEl = document.querySelector(".chat"); 227 const peopleResizeHandle = document.getElementById("peopleResizeHandle"); 228 const chatHeaderEl = chatPanelEl ? chatPanelEl.querySelector(".panelHeader") : null; 229 const editModal = document.getElementById("editModal"); 230 const editModalTitle = document.getElementById("editModalTitle"); 231 const editModalCloseBtn = document.getElementById("editModalClose"); 232 const editModalCancelBtn = document.getElementById("editModalCancel"); 233 const editModalSaveBtn = document.getElementById("editModalSave"); 234 const editModalStatus = document.getElementById("editModalStatus"); 235 const editModalPostTitleRow = document.getElementById("editModalPostTitleRow"); 236 const editModalPostTitleInput = document.getElementById("editModalPostTitle"); 237 const editModalPostMeta = document.getElementById("editModalPostMeta"); 238 const editModalKeywordsInput = document.getElementById("editModalKeywords"); 239 const editModalCollectionSelect = document.getElementById("editModalCollection"); 240 const editModalProtectedToggle = document.getElementById("editModalProtected"); 241 const editModalModeSelect = document.getElementById("editModalMode"); 242 const editModalStreamKindRow = document.getElementById("editModalStreamKindRow"); 243 const editModalStreamKindSelect = document.getElementById("editModalStreamKind"); 244 const editModalPasswordRow = document.getElementById("editModalPasswordRow"); 245 const editModalPasswordInput = document.getElementById("editModalPassword"); 246 const editModalToolbar = document.getElementById("editModalToolbar"); 247 const editModalEditor = document.getElementById("editModalEditor"); 248 const editModalImageInput = document.getElementById("editModalImage"); 249 const editModalAudioInput = document.getElementById("editModalAudio"); 250 251 // Temporarily force rack mode on (hide toggle) while the feature stabilizes. 252 const FORCE_RACK_MODE = true; 253 254 // Display prefs (device layout + text scale) 255 const UI_SCALE_KEY = "bzl_uiScale"; // "auto" | "xs" | "sm" | "md" | "lg" 256 const DEVICE_LAYOUT_KEY = "bzl_deviceLayout"; // "auto" | "widescreen" | "fourThree" | "threeTwo" | "ultrawide" | "portrait" 257 const USER_APPEARANCE_KEY = "bzl_userAppearance_v1"; 258 259 /** @type {Map<string, any>} */ 260 const posts = new Map(); 261 /** @type {Record<string, {image?: string, color?: string}>} */ 262 let profiles = {}; 263 264 /** @type {Map<string, any[]>} */ 265 const chatByPost = new Map(); 266 /** @type {Map<string, number>} */ 267 const unreadByPostId = new Map(); 268 /** @type {Map<string, Set<string>>} */ 269 const typingUsersByPostId = new Map(); 270 const ownRecentChatByPostId = new Map(); 271 /** @type {Set<string>} */ 272 const myReacts = new Set(); 273 /** @type {Map<string, number>} */ 274 const reactPulseByKey = new Map(); 275 let allowedReactions = ["π", "β€οΈ", "π‘", "π", "π₯Ί", "π"]; 276 277 let clientId = null; 278 let loggedInUser = null; 279 let loggedInRole = "member"; 280 let canModerate = false; 281 let canRegisterFirstUser = false; 282 let registrationEnabled = false; 283 let activeChatPostId = null; 284 let activeMapsRoomId = ""; 285 let activeMapsRoomTitle = ""; 286 let activeMapsChatScope = "local"; // "local" | "global" 287 /** @type {Map<string, any[]>} */ 288 const mapsChatGlobalByMapId = new Map(); 289 /** @type {Map<string, any[]>} */ 290 const mapsChatLocalByMapId = new Map(); 291 let pendingProfileImage = ""; 292 let windowFocused = true; 293 let typingStopTimer = null; 294 let lastTypingSentAt = 0; 295 let modTab = "reports"; 296 let onboardingViewerTab = "about"; 297 let authGateOnboardingTab = "about"; 298 let onboardingAdminTab = "about"; 299 let onboardingAdminDraft = { 300 enabled: true, 301 aboutContent: "", 302 requireAcceptance: false, 303 blockReadUntilAccepted: false, 304 roleSelectEnabled: true, 305 selfAssignableRoleIds: [], 306 rules: [], 307 }; 308 let onboardingAdminDraftStamp = ""; 309 const onboardingAdminExpandedRuleIds = new Set(); 310 let modReports = []; 311 let modUsers = []; 312 let modLog = []; 313 let devLog = []; 314 let modLogView = localStorage.getItem("bzl_modLogView") || "dev"; // "dev" | "moderation" 315 let devLogAutoScroll = localStorage.getItem("bzl_devLogAutoScroll") !== "0"; 316 let modModalContext = null; 317 let lanUrls = []; 318 const MOBILE_LAYOUT_KEY = "bzl_mobile_layout_v1"; 319 let mobilePanel = "hives"; // Back-compat: used by older call sites (maps to mobile "screen" now). 320 let mobileMoreOpen = false; 321 let mobileHostPanelId = ""; 322 const mobileHostRestoreParentByPanelId = new Map(); 323 const mobileHostedPanelIds = new Set(); 324 const mobileHostEphemeralPanelIds = new Set(); 325 let composerOpen = false; 326 let touchStartX = 0; 327 let touchStartY = 0; 328 let touchTracking = false; 329 let peopleOpen = false; 330 let peopleTab = "members"; 331 let peopleMembers = []; 332 let openPostMenuId = ""; 333 334 // Multi-instance chat panels (MVP: per-hive/post chat panels). 335 /** @type {Map<string, {postId:string}>} */ 336 const chatPanelInstances = new Map(); 337 338 function isChatInstancePanelId(panelId) { 339 const id = String(panelId || ""); 340 return id.startsWith("chat:post:"); 341 } 342 343 function chatInstancePanelIdForPost(postId) { 344 const pid = String(postId || "").trim(); 345 if (!pid) return ""; 346 return `chat:post:${pid}`; 347 } 348 let dmThreads = []; 349 /** @type {Map<string, any>} */ 350 let dmThreadsById = new Map(); 351 /** @type {Map<string, any[]>} */ 352 const dmMessagesByThreadId = new Map(); 353 let activeDmThreadId = null; 354 let pendingOpenDmThreadId = ""; 355 const CHAT_RECENTS_LIMIT = 24; 356 let recentHiveChatIds = []; 357 let recentDmChatThreadIds = []; 358 let syncingChatContextSelect = false; 359 let walkieRecording = false; 360 let walkieStartAt = 0; 361 let walkieRecorder = null; 362 let walkieChunks = []; 363 let walkieCtx = null; 364 let walkieMicStream = null; 365 let walkieMixNode = null; 366 let walkieDestNode = null; 367 let walkieDispatchBuffer = null; 368 let streamEnabled = false; 369 let streamIceServers = [{ urls: ["stun:stun.l.google.com:19302"] }]; 370 const streamLiveByPostId = new Map(); 371 let streamCurrentPostId = ""; 372 let streamCurrentRole = "idle"; // "idle" | "viewer" | "host" 373 let streamCurrentHostClientId = ""; 374 let streamRemoteHostClientId = ""; 375 let streamLocalMedia = null; 376 let streamRemoteMedia = null; 377 let streamRemoteKind = "webcam"; 378 const streamPeerByClientId = new Map(); 379 const streamRemoteMediaByClientId = new Map(); 380 const streamPeerUsernameByClientId = new Map(); 381 const streamRemoteAudioByClientId = new Map(); 382 const streamPeerVolumeByClientId = new Map(); 383 let streamVoiceMedia = null; 384 let streamVoiceJoined = false; 385 let streamVoiceMuted = false; 386 let streamVoiceDeafened = false; 387 const SESSION_TOKEN_KEY = "bzl_session_token"; 388 const TOUR_SEEN_VERSION = 2; 389 const TOUR_TASK_POLL_MS = 500; 390 const TOUR_AUTO_ADVANCE_MS = 1400; 391 const SPLASH_MIN_MS = 700; 392 const SPLASH_SFX_URL = "/assets/sfx/bzl_sound.mp3"; 393 const CLIENT_IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024; 394 const CLIENT_AUDIO_UPLOAD_MAX_BYTES = 150 * 1024 * 1024; 395 let allowedPostReactions = ["π", "β€οΈ", "π‘", "π", "π₯Ί", "π", "β"]; 396 let allowedChatReactions = ["π", "β€οΈ", "π‘", "π", "π₯Ί", "π"]; 397 let userPrefs = { starredPostIds: [], hiddenPostIds: [], ignoredUsers: [], blockedUsers: [] }; 398 let showReactions = localStorage.getItem("bzl_showReactions") !== "0"; 399 let chatDock = localStorage.getItem("bzl_chatDock") === "right" ? "right" : "left"; 400 let activeHiveView = "all"; 401 let collections = []; 402 let customRoles = []; 403 let plugins = []; 404 const loadedPluginClientVersionById = new Map(); // pluginId -> version string 405 let centerView = "hives"; 406 const HIVES_VIEW_MODE_KEY = "bzl_hivesViewMode"; 407 const HIVES_LIST_AUTO_THRESHOLD_PX = 520; 408 let lastHivesWidthPx = 0; 409 let hivesResizeObserver = null; 410 let guidedTourOverlayEl = null; 411 let guidedTourCardEl = null; 412 let guidedTourFocusEl = null; 413 let guidedTourStepEl = null; 414 let guidedTourTitleEl = null; 415 let guidedTourBodyEl = null; 416 let guidedTourTaskEl = null; 417 let guidedTourStatusEl = null; 418 let guidedTourDontShowEl = null; 419 let guidedTourPrevBtn = null; 420 let guidedTourNextBtn = null; 421 let guidedTourSkipBtn = null; 422 let guidedTourTargetEl = null; 423 let guidedTourTaskTimer = null; 424 let guidedTourAutoAdvanceTimer = null; 425 let guidedTourStepContext = {}; 426 let guidedTourAutoStartedForUser = ""; 427 let guidedTourState = { active: false, index: 0, steps: [], startedSignedIn: false }; 428 let guestAuthPanelRevealed = false; 429 let splashStartedAt = Date.now(); 430 let splashMinDone = false; 431 let splashAudioDone = false; 432 let splashVisible = true; 433 let splashNeedsGesture = false; 434 let splashProgressRaf = 0; 435 let splashAudio = null; 436 let splashTipTimer = 0; 437 let splashLastTip = ""; 438 439 const SPLASH_TIPS = [ 440 "Eyy, looking good!", 441 "We're about to crank it to 11.", 442 "bzl is the sound a digital bee makes.", 443 "1337 speak is overrated. I prefer Webdings.", 444 "bzl is free and open source!", 445 "The root trans means through or across.", 446 "What is the air-speed velocity of an unladen swallow?", 447 "What if the universe is one big atom?", 448 "Wonder what happened to Tom from Myspace?", 449 "They don't make 'em like this anymore.", 450 "What's future nostalgia mean?", 451 "Shortcut tip: R toggles the Members list rail.", 452 "Shortcut tip: [ and ] cycle layout presets.", 453 "Shortcut tip: Up/Left/Right/Down controls hovered panels.", 454 "Workflow tip: add from hotbar, then resize and reorder to taste.", 455 "Workflow tip: minimize panels you are not using to keep focus.", 456 "Profile tip: add a theme song in your profile editor.", 457 "Theme tip: try a preset first, then tweak accent and font.", 458 "Theme tip: mono fonts can make dense panels easier to scan.", 459 "Use Quick duration for common post expiry times.", 460 "Pin your core panels left-most for each workflow.", 461 "Livekit powers low-latency voice/video rooms in Bzl.", 462 "Livekit tip: one panel for stream, one for chat is a great combo.", 463 "Azakaela makes cool music.", 464 "A watched splash screen never boils.", 465 "Bees would absolutely use panel presets.", 466 "If this loads any faster, it becomes time travel.", 467 "Hydration check: sip something.", 468 "Your future self says this layout slaps.", 469 "This message is brought to you by electrons.", 470 "Somewhere, a rubber duck is nodding.", 471 "If in doubt, open one more panel.", 472 "This app contains 0% artificial buzzwords.", 473 "Do not feed the race conditions.", 474 "The hotbar is just a tiny panel hotel.", 475 "All bugs are features in chrysalis.", 476 "One does not simply ignore keyboard shortcuts.", 477 "Completely normal amount of glow here.", 478 "Please remain calm while we summon vibes.", 479 "Loading... but make it dramatic.", 480 "No panels were harmed in this boot.", 481 "Your layout called. It wants more symmetry.", 482 "Some assembly required. Mostly emotional.", 483 "This is the good timeline.", 484 "If found, return to nearest hive.", 485 "The bee movie quote budget was denied.", 486 "Yes, this is a real loading message.", 487 "You bring the ideas, we bring the panels.", 488 ]; 489 490 function isOwnerRole(role) { 491 return String(role || "").toLowerCase() === "owner"; 492 } 493 494 function isAdminRole(role) { 495 return String(role || "").toLowerCase() === "admin"; 496 } 497 498 function isModeratorRole(role) { 499 return String(role || "").toLowerCase() === "moderator"; 500 } 501 502 function isStaffRole(role) { 503 return isOwnerRole(role) || isAdminRole(role) || isModeratorRole(role); 504 } 505 506 function canManagePluginsRole(role) { 507 return isOwnerRole(role) || isAdminRole(role); 508 } 509 510 // --- Rack layout (experimental) ------------------------------------------------ 511 512 const RACK_LAYOUT_ENABLED_KEY = "bzl_rackLayout_enabled"; 513 const RACK_LAYOUT_STATE_KEY = "bzl_rackLayout_state_v2"; 514 const RACK_SIDE_COLLAPSED_KEY = "bzl_rackLayout_sideCollapsed"; 515 const RACK_RIGHT_COLLAPSED_KEY = "bzl_rackLayout_rightCollapsed"; 516 const WORKSPACE_EXPANDED_PRIMARY_KEY = "bzl_workspace_expandedPrimary"; 517 const WORKSPACE_EXPANDED_DISPLACED_KEY = "bzl_workspace_expandedDisplaced"; 518 const WORKSPACE_INFINITE_MODE = true; 519 const RIGHT_RACK_FIXED_PANEL_ID = "people"; 520 521 /** 522 * @typedef {{ 523 * version: 2, 524 * presetId: string, 525 * docked: { bottom: string[] }, 526 * racks?: { workspaceLeft?: string[], workspaceRight?: string[], side?: string[], right?: string[] }, 527 * }} RackLayoutState 528 */ 529 530 /** @type {RackLayoutState} */ 531 let rackLayoutState = { 532 version: 2, 533 presetId: "onboardingDefault", 534 docked: { bottom: [] }, 535 racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, 536 panelSizes: {}, 537 }; 538 let rackLayoutEnabled = false; 539 let rightRackEl = null; 540 let mainRack = null; 541 let mainSideRack = null; 542 const WORKSPACE_ACTIVE_PRIMARY_KEY = "bzl_workspace_activePrimary"; 543 544 function workspaceInfiniteMode() { 545 return Boolean(rackLayoutEnabled && WORKSPACE_INFINITE_MODE); 546 } 547 548 function isRightRackFixedPanel(panelId) { 549 return String(panelId || "").trim() === RIGHT_RACK_FIXED_PANEL_ID; 550 } 551 552 function readBoolPref(key, fallback = false) { 553 try { 554 const raw = localStorage.getItem(key); 555 if (raw == null) return fallback; 556 return raw === "1" || raw === "true"; 557 } catch { 558 return fallback; 559 } 560 } 561 562 function writeBoolPref(key, value) { 563 try { 564 localStorage.setItem(key, value ? "1" : "0"); 565 } catch { 566 // ignore 567 } 568 } 569 570 function readWorkspaceExpandedPrimary() { 571 return readStringPref(WORKSPACE_EXPANDED_PRIMARY_KEY, "").trim(); 572 } 573 574 function writeWorkspaceExpandedPrimary(panelId) { 575 writeStringPref(WORKSPACE_EXPANDED_PRIMARY_KEY, String(panelId || "").trim()); 576 } 577 578 function readWorkspaceExpandedDisplaced() { 579 return readStringPref(WORKSPACE_EXPANDED_DISPLACED_KEY, "").trim(); 580 } 581 582 function writeWorkspaceExpandedDisplaced(panelId) { 583 writeStringPref(WORKSPACE_EXPANDED_DISPLACED_KEY, String(panelId || "").trim()); 584 } 585 586 function clearWorkspaceExpandedState() { 587 writeWorkspaceExpandedPrimary(""); 588 writeWorkspaceExpandedDisplaced(""); 589 } 590 591 function togglePrimaryExpand(panelId) { 592 if (!rackLayoutEnabled) return; 593 const id = String(panelId || "").trim(); 594 if (!id) return; 595 if (!panelCanExpand(id)) return; 596 597 const current = readWorkspaceExpandedPrimary(); 598 const left = ensureWorkspaceLeftRack(); 599 const right = ensureWorkspaceRightRack(); 600 if (!left || !right) return; 601 602 // If the panel isn't in a workspace slot, pull it into the workspace first. 603 const panelEl = getPanelElement(id); 604 if (panelEl) { 605 const inWorkspace = panelEl.parentElement === left || panelEl.parentElement === right; 606 if (!inWorkspace) { 607 const leftExisting = left.querySelector?.(":scope > .rackPanel:not(.hidden)"); 608 const rightExisting = right.querySelector?.(":scope > .rackPanel:not(.hidden)"); 609 const leftEmpty = !leftExisting; 610 const rightEmpty = !rightExisting; 611 // Prefer the right slot for "aux" expandables like Moderation/Composer. 612 const target = rightEmpty ? right : leftEmpty ? left : right; 613 const existing = target === left ? leftExisting : rightExisting; 614 if (existing instanceof HTMLElement && existing !== panelEl) { 615 const existingId = String(existing.dataset?.panelId || "").trim(); 616 if (existingId) dockPanel(existingId); 617 } 618 target.appendChild(panelEl); 619 syncRackStateFromDom(); 620 enforceWorkspaceRules(); 621 } 622 } 623 624 const leftPanel = left.querySelector?.(":scope > .rackPanel"); 625 const rightPanel = right.querySelector?.(":scope > .rackPanel"); 626 const leftId = String(leftPanel?.dataset?.panelId || "").trim(); 627 const rightId = String(rightPanel?.dataset?.panelId || "").trim(); 628 629 if (current && current === id) { 630 // Collapse: try to restore the displaced panel (if any) back into the now-visible other slot. 631 const displaced = readWorkspaceExpandedDisplaced(); 632 clearWorkspaceExpandedState(); 633 if (displaced && isDocked(displaced)) { 634 undockPanel(displaced); 635 const el = getPanelElement(displaced); 636 if (el) { 637 if (leftId === id && !rightId) right.appendChild(el); 638 else if (rightId === id && !leftId) left.appendChild(el); 639 } 640 } 641 enforceWorkspaceRules(); 642 return; 643 } 644 645 // Expand: if the other slot is occupied, dock it so it stays accessible via hotbar. 646 writeWorkspaceExpandedPrimary(id); 647 let displaced = ""; 648 if (leftId === id && rightId) displaced = rightId; 649 if (rightId === id && leftId) displaced = leftId; 650 if (displaced && displaced !== id) { 651 writeWorkspaceExpandedDisplaced(displaced); 652 dockPanel(displaced); 653 } else { 654 writeWorkspaceExpandedDisplaced(""); 655 } 656 enforceWorkspaceRules(); 657 } 658 659 function readStringPref(key, fallback = "") { 660 try { 661 const raw = localStorage.getItem(key); 662 if (raw == null) return fallback; 663 return String(raw); 664 } catch { 665 return fallback; 666 } 667 } 668 669 function normalizeUiScale(raw) { 670 const v = String(raw || "").trim().toLowerCase(); 671 if (v === "auto") return "auto"; 672 if (v === "xs" || v === "compact") return "xs"; 673 if (v === "sm" || v === "small") return "sm"; 674 if (v === "lg" || v === "large") return "lg"; 675 return "md"; 676 } 677 678 function normalizeDeviceLayout(raw) { 679 const v = String(raw || "").trim().toLowerCase(); 680 if (v === "widescreen") return "widescreen"; 681 if (v === "fourthree" || v === "fourThree".toLowerCase() || v === "4:3" || v === "4x3") return "fourThree"; 682 if (v === "threetwo" || v === "threeTwo".toLowerCase() || v === "3:2" || v === "3x2") return "threeTwo"; 683 if (v === "ultrawide") return "ultrawide"; 684 if (v === "portrait") return "portrait"; 685 return "auto"; 686 } 687 688 function detectViewportSize() { 689 const w = Math.max(1, Number(window.innerWidth) || 1); 690 const h = Math.max(1, Number(window.innerHeight) || 1); 691 // Keep this intentionally simple: we mostly care about "can we fit columns sanely?" 692 // Consider both width and height so low-res (ex: 1280x720) can auto-compact. 693 if (w <= 1100 || h <= 720) return "xs"; 694 if (w <= 1400 || h <= 820) return "sm"; 695 if (w <= 1800) return "md"; 696 return "lg"; 697 } 698 699 function detectAspectLayout() { 700 const w = Math.max(1, Number(window.innerWidth) || 1); 701 const h = Math.max(1, Number(window.innerHeight) || 1); 702 const ratio = w / h; 703 // Heuristics: 704 // - Portrait: <= ~1.25 705 // - 4:3-ish: 1.25..1.38 706 // - 3:2-ish: 1.38..1.62 (covers 3:2 and nearby) 707 // - Widescreen: 1.62..1.95 (16:10..~2:1) 708 // - Ultrawide: >= 1.95 709 if (ratio <= 1.25) return "portrait"; 710 if (ratio < 1.38) return "fourThree"; 711 if (ratio >= 1.38 && ratio < 1.62) return "threeTwo"; 712 if (ratio >= 1.95) return "ultrawide"; 713 return "widescreen"; 714 } 715 716 function applyDisplayPrefs() { 717 const root = document.documentElement; 718 if (!root) return; 719 const scalePref = normalizeUiScale(readStringPref(UI_SCALE_KEY, "auto")); 720 const layoutPref = normalizeDeviceLayout(readStringPref(DEVICE_LAYOUT_KEY, "auto")); 721 const layout = layoutPref === "auto" ? detectAspectLayout() : layoutPref; 722 const viewport = detectViewportSize(); 723 const scale = 724 scalePref === "auto" ? (viewport === "xs" ? "xs" : viewport === "sm" ? "sm" : "md") : scalePref; 725 726 root.dataset.uiScale = scale; 727 root.dataset.uiScalePref = scalePref; 728 root.dataset.deviceLayout = layoutPref; 729 root.dataset.aspect = layout; 730 root.dataset.viewport = viewport; 731 732 if (uiScaleEl) uiScaleEl.value = scalePref; 733 if (deviceLayoutEl) deviceLayoutEl.value = layoutPref; 734 } 735 736 function initDisplayPrefsUi() { 737 applyDisplayPrefs(); 738 if (uiScaleEl) { 739 uiScaleEl.value = normalizeUiScale(readStringPref(UI_SCALE_KEY, "auto")); 740 uiScaleEl.addEventListener("change", () => { 741 const next = normalizeUiScale(uiScaleEl.value); 742 try { 743 localStorage.setItem(UI_SCALE_KEY, next); 744 } catch { 745 // ignore 746 } 747 applyDisplayPrefs(); 748 }); 749 } 750 if (deviceLayoutEl) { 751 deviceLayoutEl.value = normalizeDeviceLayout(readStringPref(DEVICE_LAYOUT_KEY, "auto")); 752 deviceLayoutEl.addEventListener("change", () => { 753 const next = normalizeDeviceLayout(deviceLayoutEl.value); 754 try { 755 localStorage.setItem(DEVICE_LAYOUT_KEY, next); 756 } catch { 757 // ignore 758 } 759 applyDisplayPrefs(); 760 }); 761 } 762 763 let resizeTimer = null; 764 window.addEventListener("resize", () => { 765 if (resizeTimer) window.clearTimeout(resizeTimer); 766 resizeTimer = window.setTimeout(() => { 767 resizeTimer = null; 768 // Always re-apply (viewport changes matter even when layout is manually pinned). 769 applyDisplayPrefs(); 770 }, 90); 771 }); 772 } 773 774 function writeStringPref(key, value) { 775 try { 776 localStorage.setItem(key, String(value)); 777 } catch { 778 // ignore 779 } 780 } 781 782 function resolveHivesViewMode() { 783 const pref = readStringPref(HIVES_VIEW_MODE_KEY, "list"); 784 const normalized = String(pref || "auto").toLowerCase(); 785 if (normalized === "list") return "list"; 786 if (normalized === "cards") return "cards"; 787 // auto (currently treated as list by default; we can reintroduce responsive modes later) 788 return "list"; 789 } 790 791 function applyHivesViewMode() { 792 const mode = resolveHivesViewMode(); 793 const list = mode === "list"; 794 feedEl?.classList.toggle("hivesListView", list); 795 hivesPanelEl?.classList.toggle("hivesListView", list); 796 } 797 798 function installHivesAutoViewMode() { 799 if (!hivesPanelEl) return; 800 if (typeof ResizeObserver === "undefined") { 801 window.addEventListener("resize", () => applyHivesViewMode()); 802 return; 803 } 804 if (hivesResizeObserver) return; 805 hivesResizeObserver = new ResizeObserver((entries) => { 806 const entry = entries && entries[0]; 807 const w = Number(entry?.contentRect?.width || 0); 808 if (!w) return; 809 const rounded = Math.round(w); 810 if (rounded === lastHivesWidthPx) return; 811 lastHivesWidthPx = rounded; 812 applyHivesViewMode(); 813 }); 814 try { 815 hivesResizeObserver.observe(hivesPanelEl); 816 } catch { 817 // ignore 818 } 819 } 820 821 function setSideCollapsed(collapsed, opts) { 822 const options = opts && typeof opts === "object" ? opts : {}; 823 const persist = options.persist !== false; 824 const updateControls = options.updateControls !== false; 825 if (!appRoot) return; 826 appRoot.classList.toggle("sideCollapsed", Boolean(collapsed)); 827 if (persist) writeBoolPref(RACK_SIDE_COLLAPSED_KEY, Boolean(collapsed)); 828 if (updateControls && toggleSideRackEl) toggleSideRackEl.checked = !Boolean(collapsed); 829 updateSideRackEmptyState(); 830 updateWorkspaceMinView(); 831 } 832 833 function setRightCollapsed(collapsed, opts) { 834 const options = opts && typeof opts === "object" ? opts : {}; 835 const persist = options.persist !== false; 836 const updateControls = options.updateControls !== false; 837 if (!appRoot) return; 838 appRoot.classList.toggle("rightCollapsed", Boolean(collapsed)); 839 if (persist) writeBoolPref(RACK_RIGHT_COLLAPSED_KEY, Boolean(collapsed)); 840 if (updateControls && toggleRightRackEl) toggleRightRackEl.checked = !Boolean(collapsed); 841 updateWorkspaceMinView(); 842 } 843 844 function updateSideRackEmptyState() { 845 if (!appRoot) return; 846 const side = mainSideRackEl || mainSideRack || document.getElementById("mainSideRack"); 847 if (!(side instanceof HTMLElement)) return; 848 const hasVisible = Boolean(side.querySelector?.(".rackPanel:not(.hidden)")); 849 appRoot.classList.toggle("sideRackEmpty", !hasVisible); 850 } 851 852 // Panel registry (skeleton): this will become the primary way core + plugins register UI panels. 853 // For now, it powers rack mode (docking + ordering + workspace rules) and plugin panel shells. 854 /** @type {Map<string, {id:string,title:string,icon?:string,source:string,role:string,defaultRack:string,element?:HTMLElement|null}>} */ 855 const panelRegistry = new Map(); 856 857 function registerCorePanel(def) { 858 const id = String(def?.id || "").trim(); 859 if (!id) return; 860 const title = String(def?.title || id).trim(); 861 const icon = typeof def?.icon === "string" ? def.icon : ""; 862 const role = typeof def?.role === "string" ? def.role : "aux"; 863 const defaultRack = typeof def?.defaultRack === "string" ? def.defaultRack : "right"; 864 const element = def?.element instanceof HTMLElement ? def.element : null; 865 panelRegistry.set(id, { id, title, icon, source: "core", role, defaultRack, element }); 866 } 867 868 function normalizeWorkspacePanelSize(size) { 869 const raw = String(size || "").trim().toLowerCase(); 870 if (raw === "skinny" || raw === "full") return raw; 871 return "half"; 872 } 873 874 function panelAllowsSkinnyWorkspaceSize(panelId) { 875 const id = String(panelId || "").trim(); 876 if (!id) return false; 877 if (id === "moderation") return false; 878 return true; 879 } 880 881 function panelWorkspaceSize(panelId) { 882 const id = String(panelId || "").trim(); 883 if (!id) return "half"; 884 const sizes = rackLayoutState?.panelSizes && typeof rackLayoutState.panelSizes === "object" ? rackLayoutState.panelSizes : {}; 885 const normalized = normalizeWorkspacePanelSize(sizes[id] || "half"); 886 if (normalized === "skinny" && !panelAllowsSkinnyWorkspaceSize(id)) return "half"; 887 return normalized; 888 } 889 890 function workspaceHalfPanelWidthPx() { 891 return Math.min(680, Math.max(340, Math.round(window.innerWidth * 0.74))); 892 } 893 894 function workspaceSkinnyPanelWidthPx() { 895 return Math.min(360, Math.max(220, Math.round(window.innerWidth * 0.42))); 896 } 897 898 function workspaceSidePanelsEnabled() { 899 if (!appRoot) return false; 900 return !(appRoot.classList.contains("sideCollapsed") && appRoot.classList.contains("rightCollapsed")); 901 } 902 903 function ensureWorkspaceMinSpacer() { 904 const workspace = ensureWorkspaceStripRack(); 905 if (!(workspace instanceof HTMLElement)) return null; 906 let spacer = workspace.querySelector?.(":scope > .workspaceMinSpacer"); 907 if (!(spacer instanceof HTMLElement)) { 908 spacer = document.createElement("div"); 909 spacer.className = "workspaceMinSpacer"; 910 workspace.appendChild(spacer); 911 } 912 if (workspace.lastElementChild !== spacer) workspace.appendChild(spacer); 913 return spacer; 914 } 915 916 function panelWorkspaceWidthPx(panelId) { 917 const size = panelWorkspaceSize(panelId); 918 const half = workspaceHalfPanelWidthPx(); 919 if (size === "full") return half * 2; 920 if (size === "skinny") return workspaceSkinnyPanelWidthPx(); 921 return half; 922 } 923 924 function updateWorkspaceMinView() { 925 if (!workspaceInfiniteMode()) return; 926 const workspace = ensureWorkspaceStripRack(); 927 if (!(workspace instanceof HTMLElement)) return; 928 const spacer = ensureWorkspaceMinSpacer(); 929 if (!(spacer instanceof HTMLElement)) return; 930 const panelIds = Array.from(workspace.querySelectorAll(":scope > .rackPanel:not(.hidden)")) 931 .map((el) => String(el?.dataset?.panelId || "").trim()) 932 .filter(Boolean); 933 const currentWidth = panelIds.reduce((sum, id) => sum + panelWorkspaceWidthPx(id), 0); 934 const half = workspaceHalfPanelWidthPx(); 935 const minRequired = workspaceSidePanelsEnabled() ? half * 2 + workspaceSkinnyPanelWidthPx() : half * 2; 936 const deficit = Math.max(0, Math.round(minRequired - currentWidth)); 937 spacer.style.width = `${deficit}px`; 938 } 939 940 function installWorkspaceScrollSnapStep() { 941 const workspace = ensureWorkspaceStripRack(); 942 if (!(workspace instanceof HTMLElement)) return; 943 if (workspace.dataset.snapStepInstalled === "1") return; 944 workspace.dataset.snapStepInstalled = "1"; 945 let snapTimer = null; 946 workspace.addEventListener("scroll", () => { 947 if (!workspaceInfiniteMode()) return; 948 if (appRoot?.classList.contains("rackIsDragging")) return; 949 if (snapTimer) clearTimeout(snapTimer); 950 snapTimer = setTimeout(() => { 951 const step = workspaceHalfPanelWidthPx(); 952 if (!step) return; 953 const current = workspace.scrollLeft; 954 const maxScroll = Math.max(0, workspace.scrollWidth - workspace.clientWidth); 955 let target = Math.round(current / step) * step; 956 target = Math.max(0, Math.min(maxScroll, target)); 957 const nearRightEdge = maxScroll > 0 && current >= maxScroll - Math.max(28, Math.round(step * 0.45)); 958 if (nearRightEdge) target = maxScroll; 959 if (Math.abs(target - current) < 4) return; 960 workspace.scrollTo({ left: target, behavior: "smooth" }); 961 }, 90); 962 }); 963 } 964 965 function refreshPanelSizeButtons(panelId) { 966 const id = String(panelId || "").trim(); 967 if (!id) return; 968 const size = panelWorkspaceSize(id); 969 const allowSkinny = panelAllowsSkinnyWorkspaceSize(id); 970 const root = getPanelElement(id); 971 if (!(root instanceof HTMLElement)) return; 972 for (const btn of root.querySelectorAll(`[data-panelsize][data-panelid="${cssEscape(id)}"]`)) { 973 const btnSize = normalizeWorkspacePanelSize(btn.getAttribute("data-panelsize") || ""); 974 if (btnSize === "skinny") btn.disabled = !allowSkinny; 975 btn.classList.toggle("isActive", btnSize === size); 976 } 977 } 978 979 function applyPanelWorkspaceSize(panelEl) { 980 const el = panelEl instanceof HTMLElement ? panelEl : null; 981 if (!el) return; 982 const panelId = String(el.dataset.panelId || "").trim(); 983 const inWorkspace = el.parentElement && String(el.parentElement.id || "") === "mainWorkspaceRack"; 984 el.classList.toggle("workspaceSizeSkinny", Boolean(inWorkspace && panelWorkspaceSize(panelId) === "skinny")); 985 el.classList.toggle("workspaceSizeHalf", Boolean(inWorkspace && panelWorkspaceSize(panelId) === "half")); 986 el.classList.toggle("workspaceSizeFull", Boolean(inWorkspace && panelWorkspaceSize(panelId) === "full")); 987 refreshPanelSizeButtons(panelId); 988 } 989 990 function applyAllWorkspacePanelSizes() { 991 const workspace = ensureWorkspaceStripRack(); 992 if (!(workspace instanceof HTMLElement)) return; 993 for (const panel of workspace.querySelectorAll(":scope > .rackPanel")) applyPanelWorkspaceSize(panel); 994 updateWorkspaceMinView(); 995 } 996 997 function setPanelWorkspaceSize(panelId, size) { 998 const id = String(panelId || "").trim(); 999 if (!id) return; 1000 let next = normalizeWorkspacePanelSize(size); 1001 if (next === "skinny" && !panelAllowsSkinnyWorkspaceSize(id)) next = "half"; 1002 if (!rackLayoutState.panelSizes || typeof rackLayoutState.panelSizes !== "object") rackLayoutState.panelSizes = {}; 1003 rackLayoutState.panelSizes[id] = next; 1004 saveRackLayoutState(); 1005 const panelEl = getPanelElement(id); 1006 if (panelEl) applyPanelWorkspaceSize(panelEl); 1007 } 1008 1009 function panelCanUseWorkspaceSizes(panelId) { 1010 const id = String(panelId || "").trim(); 1011 if (!id) return false; 1012 if (id.startsWith("chat:")) return true; 1013 return true; 1014 } 1015 1016 function installPanelSizeButtons(headerEl, panelId) { 1017 if (!(headerEl instanceof HTMLElement)) return; 1018 const id = String(panelId || "").trim(); 1019 if (!id) return; 1020 if (!panelCanUseWorkspaceSizes(id)) return; 1021 const row = headerEl.querySelector(".row") || headerEl; 1022 if (row.querySelector(`[data-panelsizerow="${cssEscape(id)}"]`)) return; 1023 const wrap = document.createElement("div"); 1024 wrap.className = "panelSizeControls"; 1025 wrap.setAttribute("data-panelsizerow", id); 1026 wrap.innerHTML = ` 1027 <button type="button" class="ghost smallBtn panelSizeBtn" data-panelsize="skinny" data-panelid="${escapeHtml(id)}" title="Skinny width">S</button> 1028 <button type="button" class="ghost smallBtn panelSizeBtn" data-panelsize="half" data-panelid="${escapeHtml(id)}" title="Half width">H</button> 1029 <button type="button" class="ghost smallBtn panelSizeBtn" data-panelsize="full" data-panelid="${escapeHtml(id)}" title="Full width">F</button> 1030 `; 1031 row.appendChild(wrap); 1032 refreshPanelSizeButtons(id); 1033 } 1034 1035 function togglePanelSkinny(panelId) { 1036 if (!rackLayoutEnabled) return; 1037 const id = String(panelId || "").trim(); 1038 if (!id) return; 1039 if (!panelIsSkinnyCapable(id)) return; 1040 const panelEl = getPanelElement(id); 1041 if (!panelEl) return; 1042 1043 const left = ensureWorkspaceLeftRack(); 1044 const right = ensureWorkspaceRightRack(); 1045 const side = ensureMainSideRack(); 1046 if (!left || !right || !side) return; 1047 1048 const parentId = rackIdForPanelElement(panelEl); 1049 const inSkinny = parentId === "mainSideRack" || parentId === "rightRack"; 1050 1051 if (inSkinny) { 1052 // Move to workspace (prefer an empty slot; otherwise prefer right). 1053 const leftExisting = left.querySelector?.(":scope > .rackPanel:not(.hidden)"); 1054 const rightExisting = right.querySelector?.(":scope > .rackPanel:not(.hidden)"); 1055 const target = !rightExisting ? right : !leftExisting ? left : right; 1056 const existing = target === left ? leftExisting : rightExisting; 1057 if (existing instanceof HTMLElement && existing !== panelEl) { 1058 const existingId = String(existing.dataset?.panelId || "").trim(); 1059 if (existingId) dockPanel(existingId); 1060 } 1061 target.appendChild(panelEl); 1062 rememberPanelLastRack(id, target.id); 1063 saveRackLayoutState(); 1064 syncRackStateFromDom(); 1065 enforceWorkspaceRules(); 1066 return; 1067 } 1068 1069 // Move to side rack (skinny). 1070 setSideCollapsed(false); 1071 side.prepend(panelEl); 1072 rememberPanelLastRack(id, side.id); 1073 saveRackLayoutState(); 1074 syncRackStateFromDom(); 1075 enforceWorkspaceRules(); 1076 } 1077 1078 registerCorePanel({ id: "chat", title: "Chat", icon: "π¬", role: "primary", defaultRack: "main", element: chatPanelEl }); 1079 registerCorePanel({ id: "hives", title: "Hives", icon: "π", role: "primary", defaultRack: "main", element: hivesPanelEl }); 1080 registerCorePanel({ id: "onboarding", title: "Onboarding", icon: "π§", role: "primary", defaultRack: "main", element: onboardingPanelEl }); 1081 registerCorePanel({ id: "people", title: "Members list", icon: "π₯", role: "aux", defaultRack: "right", element: peopleDrawerEl }); 1082 registerCorePanel({ id: "moderation", title: "Moderation", icon: "π‘οΈ", role: "aux", defaultRack: "right", element: modPanelEl }); 1083 registerCorePanel({ id: "profile", title: "Profile", icon: "π€", role: "transient", defaultRack: "main", element: profileViewPanel }); 1084 registerCorePanel({ id: "composer", title: "New Hive", icon: "βοΈ", role: "aux", defaultRack: "main", element: pollinatePanel }); 1085 1086 let pluginRackPanelEl = null; 1087 let pluginRackWidgetsRackEl = null; 1088 let pluginRackAddMenuEl = null; 1089 1090 function closePluginRackAddMenu() { 1091 if (!pluginRackAddMenuEl) return; 1092 try { 1093 pluginRackAddMenuEl.remove(); 1094 } catch { 1095 // ignore 1096 } 1097 pluginRackAddMenuEl = null; 1098 } 1099 1100 function panelIsPluginOwned(panelId) { 1101 const id = String(panelId || "").trim(); 1102 if (!id) return false; 1103 if (id.startsWith("chat:")) return false; 1104 const entry = panelRegistry.get(id); 1105 const src = typeof entry?.source === "string" ? entry.source : ""; 1106 return src.startsWith("plugin:"); 1107 } 1108 1109 function panelIsHostableInPluginRack(panelId) { 1110 const id = String(panelId || "").trim(); 1111 if (!id) return false; 1112 if (id === "pluginRack") return false; 1113 if (!panelIsPluginOwned(id)) return false; 1114 // Widgets should be small, stackable tools (not full workspace surfaces like Maps). 1115 if (panelRole(id) === "primary") return false; 1116 return true; 1117 } 1118 1119 function ensurePluginRackPanel() { 1120 if (pluginRackPanelEl instanceof HTMLElement && pluginRackPanelEl.isConnected) return pluginRackPanelEl; 1121 1122 if (!(pluginRackPanelEl instanceof HTMLElement)) { 1123 const shell = document.createElement("section"); 1124 shell.className = "panel panelFill pluginRackPanel rackPanel"; 1125 shell.dataset.panelId = "pluginRack"; 1126 shell.innerHTML = ` 1127 <div class="panelHeader"> 1128 <div class="panelTitle">${escapeHtml("Plugin Rack")}</div> 1129 <div class="row"></div> 1130 </div> 1131 <div class="panelBody pluginRackBody"> 1132 <div class="pluginRackToolbar"> 1133 <button type="button" class="ghost smallBtn" data-pluginrackadd="1">+ Add widget</button> 1134 <div class="small muted pluginRackHint">Drop plugin panels here to stack them.</div> 1135 </div> 1136 <div id="pluginRackWidgetsRack" class="pluginRackWidgets" aria-label="Plugin widgets"></div> 1137 </div> 1138 `; 1139 pluginRackPanelEl = shell; 1140 pluginRackWidgetsRackEl = shell.querySelector("#pluginRackWidgetsRack"); 1141 1142 shell.querySelector("[data-pluginrackadd]")?.addEventListener("click", (e) => { 1143 const anchor = e.currentTarget; 1144 if (pluginRackAddMenuEl) closePluginRackAddMenu(); 1145 else openPluginRackAddMenu(anchor); 1146 }); 1147 } 1148 1149 // Ensure it's registered as a core panel for docking + layout state. 1150 registerCorePanel({ id: "pluginRack", title: "Plugin Rack", icon: "π§©", role: "aux", defaultRack: "main", element: pluginRackPanelEl }); 1151 1152 // Append into the DOM so it can be docked/restored. (It will typically live in the hotbar.) 1153 const side = ensureMainSideRack(); 1154 if (side && pluginRackPanelEl.parentElement !== side) side.appendChild(pluginRackPanelEl); 1155 1156 return pluginRackPanelEl; 1157 } 1158 1159 function ensurePluginRackWidgetsRack() { 1160 ensurePluginRackPanel(); 1161 return pluginRackWidgetsRackEl instanceof HTMLElement ? pluginRackWidgetsRackEl : null; 1162 } 1163 1164 function readPluginRackWidgetsOrder() { 1165 const rack = ensurePluginRackWidgetsRack(); 1166 return rack ? readRackOrder(rack) : []; 1167 } 1168 1169 function removePanelFromPluginRack(panelId) { 1170 const id = String(panelId || "").trim(); 1171 if (!id) return; 1172 rackLayoutState.pluginRackWidgets = Array.isArray(rackLayoutState.pluginRackWidgets) 1173 ? rackLayoutState.pluginRackWidgets.filter((x) => x !== id) 1174 : []; 1175 const el = getPanelElement(id); 1176 if (el) el.classList.remove("pluginRackWidget"); 1177 const rack = ensurePluginRackWidgetsRack(); 1178 if (rack && el && el.parentElement === rack) rack.removeChild(el); 1179 const side = ensureMainSideRack(); 1180 if (side && el && !el.parentElement) side.appendChild(el); 1181 } 1182 1183 function hostPanelInPluginRack(panelId) { 1184 const id = String(panelId || "").trim(); 1185 if (!id) return; 1186 if (!rackLayoutEnabled) return; 1187 if (!panelIsHostableInPluginRack(id)) { 1188 toast("Can't add widget", `${panelTitle(id)} can't be hosted in Plugin Rack.`); 1189 return; 1190 } 1191 1192 const rack = ensurePluginRackWidgetsRack(); 1193 const el = getPanelElement(id); 1194 if (!rack || !el) return; 1195 1196 // Hosting implies it should be visible in the rack, not docked. 1197 if (isDocked(id)) undockPanel(id); 1198 1199 const lastRack = rackIdForPanelElement(el); 1200 if (lastRack) rememberPanelLastRack(id, lastRack); 1201 1202 el.classList.add("pluginRackWidget"); 1203 if (el.parentElement !== rack) rack.appendChild(el); 1204 1205 const next = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []); 1206 next.add(id); 1207 rackLayoutState.pluginRackWidgets = Array.from(next); 1208 saveRackLayoutState(); 1209 syncRackStateFromDom(); 1210 enforceWorkspaceRules(); 1211 } 1212 1213 function openPluginRackAddMenu(anchorEl) { 1214 closePluginRackAddMenu(); 1215 if (!(anchorEl instanceof HTMLElement)) return; 1216 if (!rackLayoutEnabled) return; 1217 1218 const hosted = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []); 1219 const candidates = Array.from(panelRegistry.keys()) 1220 .filter((id) => panelIsHostableInPluginRack(id) && !hosted.has(id)) 1221 .sort((a, b) => panelTitle(a).localeCompare(panelTitle(b))); 1222 1223 const items = candidates 1224 .map((id) => `<button type="button" class="ghost smallBtn" data-pluginrackhost="${escapeHtml(id)}">${escapeHtml(panelTitle(id))}</button>`) 1225 .join(""); 1226 1227 const menu = document.createElement("div"); 1228 menu.className = "hotbarAddMenu pluginRackAddMenu"; 1229 menu.innerHTML = ` 1230 <div class="small muted" style="padding:6px 8px 4px;">Add widget</div> 1231 <div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No plugin widgets available.</div>`}</div> 1232 `; 1233 1234 const rect = anchorEl.getBoundingClientRect(); 1235 const left = Math.max(12, Math.min(window.innerWidth - 260, rect.left)); 1236 const top = Math.max(12, Math.min(window.innerHeight - 320, rect.bottom + 8)); 1237 menu.style.left = `${left}px`; 1238 menu.style.top = `${top}px`; 1239 1240 menu.addEventListener("click", (e) => { 1241 const btn = e.target.closest?.("[data-pluginrackhost]"); 1242 if (!btn) return; 1243 const id = String(btn.getAttribute("data-pluginrackhost") || "").trim(); 1244 if (!id) return; 1245 hostPanelInPluginRack(id); 1246 closePluginRackAddMenu(); 1247 }); 1248 1249 document.body.appendChild(menu); 1250 pluginRackAddMenuEl = menu; 1251 } 1252 1253 // Rack mode: Profile should behave like a normal dockable panel (not a flow that replaces Hives). 1254 // Override the role after the initial core registration (Map#set will replace the previous entry). 1255 panelRegistry.set("profile", { ...(panelRegistry.get("profile") || { id: "profile", source: "core" }), role: "aux" }); 1256 1257 // Expose for quick inspection in the browser console while iterating. 1258 window.__bzlPanels = { panelRegistry }; 1259 1260 const PRESET_DEFS = { 1261 // Presets are hard-applied (exact placement). Anything not explicitly placed starts in the hotbar. 1262 // In workspace-infinite mode, these arrays are merged into one ordered strip. 1263 onboardingDefault: { 1264 presetId: "onboardingDefault", 1265 label: "Onboarding (Default)", 1266 group: "user", 1267 workspaceLeftOrder: ["onboarding", "hives"], 1268 workspaceRightOrder: ["chat", "people"], 1269 sideOrder: ["composer", "profile"], 1270 sideCollapsed: false, 1271 rightOrder: ["pluginRack"], 1272 dockBottom: ["pluginRack", "maps", "library-browser", "library-shelf", "library-reader"], 1273 }, 1274 tutorial: { 1275 presetId: "tutorial", 1276 label: "Tutorial", 1277 group: "user", 1278 workspaceLeftOrder: ["hives", "chat"], 1279 workspaceRightOrder: ["profile"], 1280 sideOrder: ["composer"], 1281 sideCollapsed: true, 1282 rightOrder: ["people"], 1283 dockBottom: ["onboarding", "composer", "moderation", "maps", "pluginRack", "library-browser", "library-shelf", "library-reader"], 1284 }, 1285 social: { 1286 presetId: "social", 1287 label: "Default (Social)", 1288 group: "user", 1289 workspaceLeftOrder: ["hives", "chat"], 1290 workspaceRightOrder: ["people", "profile"], 1291 sideOrder: ["composer", "pluginRack"], 1292 sideCollapsed: true, 1293 rightOrder: ["onboarding"], 1294 dockBottom: ["maps", "library-browser", "library-shelf", "library-reader"], 1295 }, 1296 chatFocus: { 1297 presetId: "chatFocus", 1298 label: "Chat Focus", 1299 group: "user", 1300 workspaceLeftOrder: ["chat", "hives"], 1301 workspaceRightOrder: ["people", "profile"], 1302 expandedPrimary: "chat", 1303 sideOrder: ["composer"], 1304 sideCollapsed: true, 1305 rightOrder: ["pluginRack"], 1306 dockBottom: ["onboarding", "maps", "library-browser", "library-shelf", "library-reader"], 1307 }, 1308 browse: { 1309 presetId: "browse", 1310 label: "Browse", 1311 group: "user", 1312 workspaceLeftOrder: ["hives", "chat"], 1313 workspaceRightOrder: ["profile", "people"], 1314 expandedPrimary: "hives", 1315 sideOrder: ["composer", "pluginRack"], 1316 sideCollapsed: true, 1317 rightOrder: ["onboarding"], 1318 dockBottom: ["maps", "library-browser", "library-shelf", "library-reader"], 1319 }, 1320 creator: { 1321 presetId: "creator", 1322 label: "Creator", 1323 group: "user", 1324 workspaceLeftOrder: ["hives", "composer"], 1325 workspaceRightOrder: ["chat", "people"], 1326 composerOpen: true, 1327 sideOrder: ["profile", "pluginRack"], 1328 sideCollapsed: true, 1329 rightOrder: ["onboarding"], 1330 dockBottom: ["maps", "library-browser", "library-shelf", "library-reader"], 1331 }, 1332 mapsSession: { 1333 presetId: "mapsSession", 1334 label: "Maps Session", 1335 group: "user", 1336 workspaceLeftOrder: ["maps", "chat"], // if installed 1337 workspaceRightOrder: ["hives", "people"], 1338 sideOrder: ["profile", "composer"], 1339 sideCollapsed: true, 1340 rightOrder: ["pluginRack"], 1341 dockBottom: ["onboarding", "library-browser", "library-shelf", "library-reader"], 1342 }, 1343 quiet: { 1344 presetId: "quiet", 1345 label: "Quiet (No People)", 1346 group: "user", 1347 workspaceLeftOrder: ["hives", "profile"], 1348 workspaceRightOrder: ["composer", "library-reader"], 1349 sideOrder: [], 1350 sideCollapsed: true, 1351 rightOrder: [], 1352 rightCollapsed: true, 1353 dockBottom: ["pluginRack", "chat", "people", "maps", "library-browser", "library-shelf", "onboarding"], 1354 }, 1355 readingNook: { 1356 presetId: "readingNook", 1357 label: "Reading Nook", 1358 group: "user", 1359 workspaceLeftOrder: ["library-reader", "library-shelf"], 1360 workspaceRightOrder: ["library-browser", "chat"], 1361 sideOrder: ["profile", "people"], 1362 sideCollapsed: true, 1363 rightOrder: ["pluginRack"], 1364 dockBottom: ["hives", "composer", "maps", "onboarding"], 1365 }, 1366 libraryCurator: { 1367 presetId: "libraryCurator", 1368 label: "Library Curator", 1369 group: "user", 1370 workspaceLeftOrder: ["library-browser", "library-shelf"], 1371 workspaceRightOrder: ["library-reader", "hives"], 1372 sideOrder: ["chat", "profile"], 1373 sideCollapsed: true, 1374 rightOrder: ["pluginRack"], 1375 dockBottom: ["people", "composer", "maps", "onboarding"], 1376 }, 1377 ops: { 1378 presetId: "ops", 1379 label: "Ops", 1380 group: "mod", 1381 modOnly: true, 1382 workspaceLeftOrder: ["moderation", "chat"], 1383 workspaceRightOrder: ["hives", "people"], 1384 sideOrder: ["profile", "composer"], 1385 sideCollapsed: true, 1386 rightOrder: ["pluginRack"], 1387 dockBottom: ["onboarding", "maps", "library-browser", "library-shelf", "library-reader"], 1388 }, 1389 reportsFocus: { 1390 presetId: "reportsFocus", 1391 label: "Reports Focus", 1392 group: "mod", 1393 modOnly: true, 1394 workspaceLeftOrder: ["moderation", "chat"], 1395 workspaceRightOrder: ["hives", "people"], 1396 expandedPrimary: "moderation", 1397 sideOrder: ["profile"], 1398 sideCollapsed: true, 1399 rightOrder: ["pluginRack"], 1400 dockBottom: ["onboarding", "composer", "maps", "library-browser", "library-shelf", "library-reader"], 1401 }, 1402 communityWatch: { 1403 presetId: "communityWatch", 1404 label: "Community Watch", 1405 group: "mod", 1406 modOnly: true, 1407 workspaceLeftOrder: ["hives", "moderation"], 1408 workspaceRightOrder: ["chat", "people"], 1409 sideOrder: ["profile", "composer"], 1410 sideCollapsed: true, 1411 rightOrder: ["pluginRack"], 1412 dockBottom: ["onboarding", "maps", "library-browser", "library-shelf", "library-reader"], 1413 }, 1414 serverAdmin: { 1415 presetId: "serverAdmin", 1416 label: "Server Admin", 1417 group: "mod", 1418 modOnly: true, 1419 workspaceLeftOrder: ["moderation", "hives"], 1420 workspaceRightOrder: ["chat", "people"], 1421 sideOrder: ["composer", "profile"], 1422 sideCollapsed: true, 1423 rightOrder: ["pluginRack"], 1424 dockBottom: ["onboarding", "maps", "library-browser", "library-shelf", "library-reader"], 1425 }, 1426 }; 1427 1428 const PRESET_ALIASES = { 1429 // Back-compat for older preset ids. 1430 discordLike: "social", 1431 onboarding: "onboardingDefault", 1432 chat: "chatFocus", 1433 browsing: "browse", 1434 maps: "mapsSession", 1435 focus: "quiet", 1436 clean: "social", 1437 moderation: "ops", 1438 reading: "readingNook", 1439 library: "libraryCurator", 1440 tour: "tutorial", 1441 }; 1442 1443 function resolvePresetKey(presetId) { 1444 const raw = String(presetId || "").trim(); 1445 const mapped = Object.prototype.hasOwnProperty.call(PRESET_ALIASES, raw) ? PRESET_ALIASES[raw] : raw; 1446 return Object.prototype.hasOwnProperty.call(PRESET_DEFS, mapped) ? mapped : "onboardingDefault"; 1447 } 1448 1449 function updateLayoutPresetOptions() { 1450 if (!layoutPresetEl) return; 1451 const current = resolvePresetKey(rackLayoutState?.presetId || layoutPresetEl.value || "onboardingDefault"); 1452 1453 const defs = Object.values(PRESET_DEFS).filter((d) => d && typeof d === "object"); 1454 const userDefs = defs.filter((d) => d.group === "user"); 1455 const modDefs = defs.filter((d) => d.group === "mod"); 1456 1457 const makeOpt = (def) => { 1458 const opt = document.createElement("option"); 1459 opt.value = String(def.presetId || ""); 1460 opt.textContent = String(def.label || def.presetId || "Preset"); 1461 return opt; 1462 }; 1463 1464 layoutPresetEl.innerHTML = ""; 1465 1466 const userGroup = document.createElement("optgroup"); 1467 userGroup.label = "Presets"; 1468 for (const def of userDefs) userGroup.appendChild(makeOpt(def)); 1469 layoutPresetEl.appendChild(userGroup); 1470 1471 if (canModerate) { 1472 const modGroup = document.createElement("optgroup"); 1473 modGroup.label = "Moderation (mods)"; 1474 for (const def of modDefs) modGroup.appendChild(makeOpt(def)); 1475 layoutPresetEl.appendChild(modGroup); 1476 } 1477 1478 const nextValue = canModerate ? current : (PRESET_DEFS[current]?.modOnly ? "onboardingDefault" : current); 1479 layoutPresetEl.value = Object.prototype.hasOwnProperty.call(PRESET_DEFS, nextValue) ? nextValue : "onboardingDefault"; 1480 } 1481 1482 function readRackLayoutEnabled() { 1483 if (FORCE_RACK_MODE) return true; 1484 try { 1485 return localStorage.getItem(RACK_LAYOUT_ENABLED_KEY) === "1"; 1486 } catch { 1487 return false; 1488 } 1489 } 1490 1491 function writeRackLayoutEnabled(enabled) { 1492 if (FORCE_RACK_MODE) { 1493 rackLayoutEnabled = true; 1494 try { 1495 localStorage.setItem(RACK_LAYOUT_ENABLED_KEY, "1"); 1496 } catch { 1497 // ignore 1498 } 1499 return; 1500 } 1501 rackLayoutEnabled = Boolean(enabled); 1502 try { 1503 localStorage.setItem(RACK_LAYOUT_ENABLED_KEY, rackLayoutEnabled ? "1" : "0"); 1504 } catch { 1505 // ignore 1506 } 1507 } 1508 1509 /** @returns {RackLayoutState} */ 1510 function loadRackLayoutState() { 1511 try { 1512 const raw = localStorage.getItem(RACK_LAYOUT_STATE_KEY); 1513 if (!raw) 1514 return { 1515 version: 2, 1516 presetId: "onboardingDefault", 1517 docked: { bottom: [] }, 1518 racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, 1519 pluginRackWidgets: [], 1520 lastRackByPanelId: {}, 1521 panelSizes: {}, 1522 }; 1523 const parsed = JSON.parse(raw); 1524 if (!parsed || parsed.version !== 2) 1525 return { 1526 version: 2, 1527 presetId: "onboardingDefault", 1528 docked: { bottom: [] }, 1529 racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, 1530 pluginRackWidgets: [], 1531 lastRackByPanelId: {}, 1532 panelSizes: {}, 1533 }; 1534 const bottom = Array.isArray(parsed?.docked?.bottom) ? parsed.docked.bottom.map((x) => String(x || "")).filter(Boolean) : []; 1535 const pluginRackWidgets = Array.isArray(parsed?.pluginRackWidgets) 1536 ? parsed.pluginRackWidgets.map((x) => String(x || "")).filter(Boolean) 1537 : []; 1538 const presetId = typeof parsed?.presetId === "string" ? parsed.presetId : "onboardingDefault"; 1539 const workspaceLeft = Array.isArray(parsed?.racks?.workspaceLeft) ? parsed.racks.workspaceLeft.map((x) => String(x || "")).filter(Boolean) : []; 1540 const workspaceRight = Array.isArray(parsed?.racks?.workspaceRight) ? parsed.racks.workspaceRight.map((x) => String(x || "")).filter(Boolean) : []; 1541 const side = Array.isArray(parsed?.racks?.side) ? parsed.racks.side.map((x) => String(x || "")).filter(Boolean) : []; 1542 const right = Array.isArray(parsed?.racks?.right) ? parsed.racks.right.map((x) => String(x || "")).filter(Boolean) : []; 1543 const lastRackByPanelIdRaw = parsed?.lastRackByPanelId && typeof parsed.lastRackByPanelId === "object" ? parsed.lastRackByPanelId : {}; 1544 const lastRackByPanelId = {}; 1545 for (const [k, v] of Object.entries(lastRackByPanelIdRaw)) { 1546 const id = String(k || "").trim(); 1547 const rackId = typeof v === "string" ? v.trim() : ""; 1548 if (!id || !rackId) continue; 1549 lastRackByPanelId[id] = rackId; 1550 } 1551 const panelSizesRaw = parsed?.panelSizes && typeof parsed.panelSizes === "object" ? parsed.panelSizes : {}; 1552 const panelSizes = {}; 1553 for (const [k, v] of Object.entries(panelSizesRaw)) { 1554 const id = String(k || "").trim(); 1555 const size = String(v || "").trim().toLowerCase(); 1556 if (!id) continue; 1557 if (size !== "skinny" && size !== "half" && size !== "full") continue; 1558 panelSizes[id] = size; 1559 } 1560 return { version: 2, presetId, docked: { bottom }, racks: { workspaceLeft, workspaceRight, side, right }, pluginRackWidgets, lastRackByPanelId, panelSizes }; 1561 } catch { 1562 return { 1563 version: 2, 1564 presetId: "onboardingDefault", 1565 docked: { bottom: [] }, 1566 racks: { workspaceLeft: [], workspaceRight: [], side: [], right: [] }, 1567 pluginRackWidgets: [], 1568 lastRackByPanelId: {}, 1569 panelSizes: {}, 1570 }; 1571 } 1572 } 1573 1574 function saveRackLayoutState() { 1575 try { 1576 localStorage.setItem(RACK_LAYOUT_STATE_KEY, JSON.stringify(rackLayoutState)); 1577 } catch { 1578 // ignore 1579 } 1580 } 1581 1582 function ensureWorkspaceSlots() { 1583 const workspace = mainWorkspaceRackEl || document.getElementById("mainWorkspaceRack"); 1584 if (!workspace) return { left: null, right: null }; 1585 if (workspaceInfiniteMode()) { 1586 const leftSlot = workspace.querySelector?.("#workspaceLeftSlot"); 1587 const rightSlot = workspace.querySelector?.("#workspaceRightSlot"); 1588 for (const slot of [leftSlot, rightSlot]) { 1589 if (!(slot instanceof HTMLElement)) continue; 1590 const kids = Array.from(slot.querySelectorAll(":scope > .rackPanel")); 1591 for (const kid of kids) workspace.appendChild(kid); 1592 try { 1593 slot.remove(); 1594 } catch { 1595 // ignore 1596 } 1597 } 1598 return { left: workspace, right: workspace }; 1599 } 1600 1601 let left = workspace.querySelector?.("#workspaceLeftSlot"); 1602 let right = workspace.querySelector?.("#workspaceRightSlot"); 1603 1604 if (!left) { 1605 left = document.createElement("div"); 1606 left.id = "workspaceLeftSlot"; 1607 left.className = "workspaceSlot workspaceSlotLeft"; 1608 left.setAttribute("aria-label", "Workspace left"); 1609 workspace.prepend(left); 1610 } 1611 if (!right) { 1612 right = document.createElement("div"); 1613 right.id = "workspaceRightSlot"; 1614 right.className = "workspaceSlot workspaceSlotRight"; 1615 right.setAttribute("aria-label", "Workspace right"); 1616 const afterLeft = workspace.querySelector?.("#workspaceLeftSlot"); 1617 if (afterLeft && afterLeft.nextSibling) workspace.insertBefore(right, afterLeft.nextSibling); 1618 else workspace.appendChild(right); 1619 } 1620 return { left, right }; 1621 } 1622 1623 function ensureWorkspaceStripRack() { 1624 if (!workspaceInfiniteMode()) return null; 1625 const workspace = ensureMainRack(); 1626 return workspace instanceof HTMLElement ? workspace : null; 1627 } 1628 1629 function panelTitle(panelId) { 1630 const entry = panelRegistry.get(panelId); 1631 if (entry?.title) return entry.title; 1632 if (panelId === "maps") return "Maps"; 1633 if (panelId === "library") return "Library"; 1634 return String(panelId || ""); 1635 } 1636 1637 function chatRailClass({ fromUser, isModMessage }) { 1638 const from = String(fromUser || "").trim(); 1639 const isSystem = !from || from.toLowerCase() === "system"; 1640 const isModMsg = Boolean(isModMessage); 1641 const isYou = Boolean(loggedInUser && from && from === loggedInUser); 1642 if (isSystem || isModMsg) return "railLeft"; 1643 if (isYou) return "railRight"; 1644 return "railCenter"; 1645 } 1646 1647 function updateChatModToggleVisibility() { 1648 if (!chatModToggleWrapEl) return; 1649 const canUse = Boolean(canModerate && activeChatPostId && !activeDmThreadId && !isMapChatActive()); 1650 chatModToggleWrapEl.classList.toggle("hidden", !canUse); 1651 if (!canUse && chatModToggleEl) chatModToggleEl.checked = false; 1652 } 1653 1654 function panelIcon(panelId) { 1655 const entry = panelRegistry.get(panelId); 1656 if (entry?.icon) return entry.icon; 1657 if (panelId === "maps") return "πΊοΈ"; 1658 if (panelId === "library") return "π"; 1659 return "β’"; 1660 } 1661 1662 function panelRole(panelId) { 1663 const entry = panelRegistry.get(panelId); 1664 return typeof entry?.role === "string" ? entry.role : "aux"; 1665 } 1666 1667 function panelCanExpand(panelId) { 1668 const id = String(panelId || "").trim(); 1669 if (!id) return false; 1670 if (id.startsWith("chat:")) return true; 1671 if (panelRole(id) === "primary") return true; 1672 // Allow a few core panels to take over the workspace even though they aren't "primary" by default. 1673 return id === "moderation" || id === "composer" || id === "pluginRack"; 1674 } 1675 1676 // Panels that are allowed to live in "skinny" columns (side rack / right rack). 1677 // These panels should be able to render in a narrow width without breaking layout. 1678 const SKINNY_CAPABLE_PANELS = new Set(["people", "profile", "composer", "chat", "pluginRack", "dice"]); 1679 1680 function panelIsSkinnyCapable(panelId) { 1681 const id = String(panelId || "").trim(); 1682 if (!id) return false; 1683 if (id.startsWith("chat:")) return true; 1684 return SKINNY_CAPABLE_PANELS.has(id); 1685 } 1686 1687 function isDocked(panelId) { 1688 return rackLayoutState.docked.bottom.includes(panelId); 1689 } 1690 1691 function getPanelElement(panelId) { 1692 const id = String(panelId || "").trim(); 1693 if (!id) return null; 1694 const entry = panelRegistry.get(id); 1695 const el = entry?.element; 1696 return el instanceof HTMLElement ? el : null; 1697 } 1698 1699 function rackIdForPanelElement(panelEl) { 1700 const el = panelEl instanceof HTMLElement ? panelEl : null; 1701 if (!el) return ""; 1702 const parent = el.parentElement; 1703 const id = parent && typeof parent.id === "string" ? parent.id : ""; 1704 if (id === "mainWorkspaceRack" || id === "workspaceLeftSlot" || id === "workspaceRightSlot" || id === "mainSideRack" || id === "rightRack") 1705 return id; 1706 return ""; 1707 } 1708 1709 function updateSkinnyChatPanels() { 1710 const applySkinnyState = (panelEl) => { 1711 if (!(panelEl instanceof HTMLElement)) return; 1712 const rackId = rackIdForPanelElement(panelEl); 1713 const inSkinnyRack = rackId === "mainSideRack" || rackId === "rightRack"; 1714 panelEl.classList.toggle("isSkinnyChat", Boolean(rackLayoutEnabled && inSkinnyRack)); 1715 }; 1716 1717 applySkinnyState(chatPanelEl); 1718 for (const panelId of chatPanelInstances.keys()) { 1719 applySkinnyState(getPanelElement(panelId)); 1720 } 1721 } 1722 1723 function rememberPanelLastRack(panelId, rackId) { 1724 const id = String(panelId || "").trim(); 1725 const rack = String(rackId || "").trim(); 1726 if (!id || !rack) return; 1727 if (!rackLayoutState.lastRackByPanelId || typeof rackLayoutState.lastRackByPanelId !== "object") rackLayoutState.lastRackByPanelId = {}; 1728 rackLayoutState.lastRackByPanelId[id] = rack; 1729 } 1730 1731 function dockPanel(panelId) { 1732 const id = String(panelId || "").trim(); 1733 if (!id) return; 1734 if (rackLayoutEnabled && isRightRackFixedPanel(id)) return; 1735 // Docking a hosted widget should implicitly un-host it. 1736 removePanelFromPluginRack(id); 1737 const el = getPanelElement(id); 1738 const lastRack = rackIdForPanelElement(el); 1739 if (lastRack) rememberPanelLastRack(id, lastRack); 1740 if (!isDocked(id)) rackLayoutState.docked.bottom.push(id); 1741 saveRackLayoutState(); 1742 applyDockState(); 1743 } 1744 1745 function undockPanel(panelId) { 1746 const id = String(panelId || "").trim(); 1747 if (!id) return; 1748 rackLayoutState.docked.bottom = rackLayoutState.docked.bottom.filter((x) => x !== id); 1749 saveRackLayoutState(); 1750 applyDockState(); 1751 } 1752 1753 function restorePanelFromHotbar(panelId, opts) { 1754 const id = String(panelId || "").trim(); 1755 if (!id) return; 1756 if (!rackLayoutEnabled) return; 1757 const options = opts && typeof opts === "object" ? opts : {}; 1758 const userAdded = options.userAdded === true; 1759 1760 const panelEl = getPanelElement(id); 1761 if (!panelEl) return; 1762 if (workspaceInfiniteMode() && isRightRackFixedPanel(id)) { 1763 const rightRack = ensureRightRack(); 1764 if (!(rightRack instanceof HTMLElement)) return; 1765 undockPanel(id); 1766 rightRack.appendChild(panelEl); 1767 rememberPanelLastRack(id, rightRack.id); 1768 setRightCollapsed(false); 1769 saveRackLayoutState(); 1770 syncRackStateFromDom(); 1771 enforceWorkspaceRules(); 1772 return; 1773 } 1774 if (workspaceInfiniteMode()) { 1775 const workspace = ensureWorkspaceStripRack(); 1776 if (!(workspace instanceof HTMLElement)) return; 1777 undockPanel(id); 1778 const spacer = ensureWorkspaceMinSpacer(); 1779 if (spacer instanceof HTMLElement && spacer.parentElement === workspace) workspace.insertBefore(panelEl, spacer); 1780 else workspace.appendChild(panelEl); 1781 rememberPanelLastRack(id, workspace.id); 1782 applyPanelWorkspaceSize(panelEl); 1783 saveRackLayoutState(); 1784 syncRackStateFromDom(); 1785 enforceWorkspaceRules(); 1786 if (userAdded) { 1787 requestAnimationFrame(() => { 1788 focusWorkspaceArrival(panelEl); 1789 }); 1790 } 1791 return; 1792 } 1793 1794 // Decide where to restore the panel. 1795 const lastRackId = 1796 rackLayoutState?.lastRackByPanelId && typeof rackLayoutState.lastRackByPanelId === "object" 1797 ? String(rackLayoutState.lastRackByPanelId[id] || "") 1798 : ""; 1799 const lastRack = lastRackId ? document.getElementById(lastRackId) : null; 1800 1801 const leftSlot = ensureWorkspaceLeftRack(); 1802 const rightSlot = ensureWorkspaceRightRack(); 1803 const sideRack = ensureMainSideRack(); 1804 const rightRack = ensureRightRack(); 1805 1806 const pickWorkspaceSlot = () => { 1807 const leftEmpty = leftSlot ? leftSlot.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; 1808 const rightEmpty = rightSlot ? rightSlot.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; 1809 return leftEmpty ? leftSlot : rightEmpty ? rightSlot : leftSlot; 1810 }; 1811 1812 let targetRack = null; 1813 if (lastRack instanceof HTMLElement) { 1814 targetRack = lastRack; 1815 } else if (panelIsSkinnyCapable(id)) { 1816 // Heuristic: aux-like panels default to side rack; "right" defaults to the right rack. 1817 const defRack = String(panelRegistry.get(id)?.defaultRack || ""); 1818 targetRack = defRack === "right" ? rightRack : sideRack; 1819 } else { 1820 targetRack = pickWorkspaceSlot(); 1821 } 1822 1823 // If restoring into a collapsed rack, uncollapse it (hotbar acts like a summonable launcher). 1824 if (targetRack && targetRack.id === "mainSideRack") setSideCollapsed(false); 1825 if (targetRack && targetRack.id === "rightRack") setRightCollapsed(false); 1826 1827 // If the panel already lives in a rack, keep its place and just reveal it. 1828 const currentRackId = rackIdForPanelElement(panelEl); 1829 const currentRack = currentRackId ? document.getElementById(currentRackId) : null; 1830 1831 undockPanel(id); 1832 1833 if (!(currentRack instanceof HTMLElement)) { 1834 const rack = targetRack instanceof HTMLElement ? targetRack : null; 1835 if (rack) { 1836 // Right rack + workspace slots are single-slot: docking the existing occupant is the least surprising behavior. 1837 const isWorkspaceSlot = rack.id === "workspaceLeftSlot" || rack.id === "workspaceRightSlot"; 1838 const isRightRackSlot = rack.id === "rightRack"; 1839 if (isWorkspaceSlot || isRightRackSlot) { 1840 const existing = rack.querySelector?.(":scope > .rackPanel:not(.hidden)"); 1841 if (existing instanceof HTMLElement && existing !== panelEl) { 1842 const existingId = String(existing.dataset.panelId || "").trim(); 1843 if (existingId) dockPanel(existingId); 1844 } 1845 } 1846 rack.appendChild(panelEl); 1847 rememberPanelLastRack(id, rack.id); 1848 saveRackLayoutState(); 1849 } 1850 } else { 1851 // Ensure the rack is visible if we restored into it. 1852 if (currentRack.id === "mainSideRack") setSideCollapsed(false); 1853 if (currentRack.id === "rightRack") setRightCollapsed(false); 1854 } 1855 1856 syncRackStateFromDom(); 1857 enforceWorkspaceRules(); 1858 if (userAdded) { 1859 requestAnimationFrame(() => { 1860 focusWorkspaceArrival(panelEl); 1861 }); 1862 } 1863 } 1864 1865 function showHotbar(show) { 1866 if (!dockHotbarEl) return; 1867 if (!show && dockHotbarEl.dataset.lockVisible === "1") return; 1868 dockHotbarEl.classList.toggle("hidden", !show); 1869 dockHotbarEl.classList.toggle("show", Boolean(show)); 1870 if (appRoot) appRoot.classList.toggle("hotbarVisible", Boolean(show)); 1871 } 1872 1873 function renderHotbar() { 1874 if (!dockHotbarEl) return; 1875 const items = rackLayoutState.docked.bottom.slice().filter((id) => getPanelElement(id)); 1876 const includePlus = Boolean(rackLayoutEnabled); 1877 if (!items.length && !includePlus) { 1878 dockHotbarEl.classList.add("hidden"); 1879 dockHotbarEl.classList.remove("show"); 1880 dockHotbarEl.innerHTML = ""; 1881 if (appRoot) appRoot.classList.remove("hotbarVisible"); 1882 return; 1883 } 1884 1885 const orbsHtml = items 1886 .map( 1887 (id) => ` 1888 <button type="button" class="dockOrb" data-undock="${escapeHtml(id)}" title="Click to restore ${escapeHtml(panelTitle(id))}. Drag to place in a rack."> 1889 <span class="dockOrbIcon" aria-hidden="true">${escapeHtml(panelIcon(id))}</span> 1890 <span>${escapeHtml(panelTitle(id))}</span> 1891 </button> 1892 ` 1893 ) 1894 .join(""); 1895 const hintHtml = items.length 1896 ? ` 1897 <div class="hotbarHint" aria-hidden="true"> 1898 <span class="hotbarHintGrip">β‘</span> 1899 <span>Drag to place</span> 1900 <span class="hotbarHintSep">β’</span> 1901 <span>Click to open</span> 1902 </div> 1903 ` 1904 : ""; 1905 1906 const plusHtml = includePlus 1907 ? ` 1908 <button type="button" class="dockOrb dockOrbPlus" data-hotbarplus="1" title="Add panel"> 1909 <span class="dockOrbIcon" aria-hidden="true">+</span> 1910 <span>Add</span> 1911 </button> 1912 ` 1913 : ""; 1914 1915 dockHotbarEl.innerHTML = `${hintHtml}${orbsHtml}${plusHtml}`; 1916 dockHotbarEl.classList.remove("hidden"); 1917 requestAnimationFrame(() => showHotbar(true)); 1918 } 1919 1920 let hotbarPlusMenuEl = null; 1921 let workspaceAddMenuEl = null; 1922 1923 function closeHotbarPlusMenu() { 1924 if (!hotbarPlusMenuEl) return; 1925 try { 1926 hotbarPlusMenuEl.remove(); 1927 } catch { 1928 // ignore 1929 } 1930 hotbarPlusMenuEl = null; 1931 } 1932 1933 function closeWorkspaceAddMenu() { 1934 if (!workspaceAddMenuEl) return; 1935 try { 1936 workspaceAddMenuEl.remove(); 1937 } catch { 1938 // ignore 1939 } 1940 workspaceAddMenuEl = null; 1941 } 1942 1943 function workspaceAddCandidates() { 1944 return Array.from(panelRegistry.keys()) 1945 .filter((id) => Boolean(getPanelElement(id))) 1946 .filter((id) => !id.startsWith("chat:post:")) 1947 .filter((id) => !isRightRackFixedPanel(id)) 1948 .filter((id) => id !== "profile") 1949 .filter((id) => !(id === "moderation" && !canModerate)) 1950 .map((id) => ({ 1951 id, 1952 title: panelTitle(id), 1953 icon: panelIcon(id), 1954 docked: isDocked(id), 1955 })) 1956 .sort((a, b) => a.title.localeCompare(b.title)); 1957 } 1958 1959 function restorePanelToWorkspaceSlot(panelId, slotId, opts) { 1960 const id = String(panelId || "").trim(); 1961 const slot = String(slotId || "").trim(); 1962 if (!id || !slot) return; 1963 const options = opts && typeof opts === "object" ? opts : {}; 1964 const userAdded = options.userAdded === true; 1965 if (workspaceInfiniteMode()) { 1966 restorePanelFromHotbar(id, { userAdded }); 1967 return; 1968 } 1969 const target = slot === "workspaceRightSlot" ? ensureWorkspaceRightRack() : ensureWorkspaceLeftRack(); 1970 if (!(target instanceof HTMLElement)) return; 1971 const panelEl = getPanelElement(id); 1972 if (!(panelEl instanceof HTMLElement)) return; 1973 if (isDocked(id)) undockPanel(id); 1974 const existing = target.querySelector?.(":scope > .rackPanel:not(.hidden)"); 1975 if (existing instanceof HTMLElement && existing !== panelEl) { 1976 const existingId = String(existing.dataset.panelId || "").trim(); 1977 if (existingId) dockPanel(existingId); 1978 } 1979 target.appendChild(panelEl); 1980 rememberPanelLastRack(id, target.id); 1981 saveRackLayoutState(); 1982 applyDockState(); 1983 syncRackStateFromDom(); 1984 enforceWorkspaceRules(); 1985 if (userAdded) { 1986 requestAnimationFrame(() => { 1987 focusWorkspaceArrival(panelEl); 1988 }); 1989 } 1990 } 1991 1992 function openWorkspaceAddMenu(anchorEl, slotId) { 1993 closeWorkspaceAddMenu(); 1994 if (!(anchorEl instanceof HTMLElement)) return; 1995 const slot = String(slotId || "").trim(); 1996 if (!slot) return; 1997 const items = workspaceAddCandidates() 1998 .map( 1999 (p) => `<button type="button" class="ghost smallBtn" data-workspaceaddpanel="${escapeHtml(p.id)}" data-workspaceaddslot="${escapeHtml(slot)}"> 2000 ${escapeHtml(p.icon)} ${escapeHtml(p.title)}${p.docked ? " (docked)" : ""} 2001 </button>` 2002 ) 2003 .join(""); 2004 const menu = document.createElement("div"); 2005 menu.className = "hotbarAddMenu"; 2006 menu.innerHTML = `<div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No panels available.</div>`}</div>`; 2007 const rect = anchorEl.getBoundingClientRect(); 2008 menu.style.left = `${Math.max(12, Math.min(window.innerWidth - 272, rect.left - 10))}px`; 2009 menu.style.top = `${Math.max(12, rect.bottom + 8)}px`; 2010 menu.addEventListener("click", (e) => { 2011 const btn = e.target.closest?.("[data-workspaceaddpanel][data-workspaceaddslot]"); 2012 if (!btn) return; 2013 const id = String(btn.getAttribute("data-workspaceaddpanel") || "").trim(); 2014 const slotIdNext = String(btn.getAttribute("data-workspaceaddslot") || "").trim(); 2015 if (!id || !slotIdNext) return; 2016 restorePanelToWorkspaceSlot(id, slotIdNext, { userAdded: true }); 2017 closeWorkspaceAddMenu(); 2018 }); 2019 document.body.appendChild(menu); 2020 workspaceAddMenuEl = menu; 2021 } 2022 2023 function openHotbarPlusMenu(anchorEl) { 2024 closeHotbarPlusMenu(); 2025 if (!dockHotbarEl) return; 2026 if (!(anchorEl instanceof HTMLElement)) return; 2027 2028 const list = sortPosts(Array.from(posts.values())).slice(0, 8); 2029 const items = list 2030 .map((p) => { 2031 const id = String(p?.id || "").trim(); 2032 if (!id) return ""; 2033 const title = postTitle(p); 2034 return `<button type="button" class="ghost smallBtn" data-addchatpost="${escapeHtml(id)}">${escapeHtml(title)}</button>`; 2035 }) 2036 .filter(Boolean) 2037 .join(""); 2038 2039 const menu = document.createElement("div"); 2040 menu.className = "hotbarAddMenu"; 2041 menu.innerHTML = ` 2042 <div class="small muted" style="padding:6px 8px 4px;">New chat panel for...</div> 2043 <div class="hotbarAddMenuList">${items || `<div class="small muted" style="padding:6px 8px;">No hives yet.</div>`}</div> 2044 `; 2045 2046 const rect = anchorEl.getBoundingClientRect(); 2047 const left = Math.max(12, Math.min(window.innerWidth - 260, rect.left - 200)); 2048 const top = Math.max(12, rect.top - 260); 2049 menu.style.left = `${left}px`; 2050 menu.style.top = `${top}px`; 2051 2052 menu.addEventListener("click", (e) => { 2053 const btn = e.target.closest?.("[data-addchatpost]"); 2054 if (!btn) return; 2055 const postId = String(btn.getAttribute("data-addchatpost") || "").trim(); 2056 if (!postId) return; 2057 ensureChatPostPanelInstance(postId, { docked: true }); 2058 try { 2059 ws.send(JSON.stringify({ type: "getChat", postId })); 2060 } catch { 2061 // ignore 2062 } 2063 closeHotbarPlusMenu(); 2064 renderHotbar(); 2065 }); 2066 2067 document.body.appendChild(menu); 2068 hotbarPlusMenuEl = menu; 2069 } 2070 2071 function applyDockState() { 2072 // For the first implementation phase, we support docking any registered panel that has a DOM element. 2073 for (const [id, p] of panelRegistry.entries()) { 2074 const el = p?.element; 2075 if (!(el instanceof HTMLElement)) continue; 2076 if (rackLayoutEnabled && isRightRackFixedPanel(id)) { 2077 el.classList.remove("hidden"); 2078 continue; 2079 } 2080 if (id === "moderation" && !canModerate) { 2081 el.classList.add("hidden"); 2082 continue; 2083 } 2084 el.classList.toggle("hidden", isDocked(id)); 2085 } 2086 2087 renderHotbar(); 2088 updateSideRackEmptyState(); 2089 updateSkinnyChatPanels(); 2090 renderWorkspaceSlotAffordances(); 2091 applyAllWorkspacePanelSizes(); 2092 } 2093 2094 function renderWorkspaceSlotAffordances() { 2095 if (!rackLayoutEnabled) return; 2096 if (workspaceInfiniteMode()) return; 2097 const left = ensureWorkspaceLeftRack(); 2098 const right = ensureWorkspaceRightRack(); 2099 for (const slot of [left, right]) { 2100 if (!(slot instanceof HTMLElement)) continue; 2101 const hasVisible = Boolean(slot.querySelector?.(":scope > .rackPanel:not(.hidden)")); 2102 slot.classList.toggle("workspaceSlotEmpty", !hasVisible); 2103 const existing = slot.querySelector?.(":scope > .workspaceEmptyAdd"); 2104 if (hasVisible) { 2105 if (existing) existing.remove(); 2106 continue; 2107 } 2108 if (existing) continue; 2109 const btn = document.createElement("button"); 2110 btn.type = "button"; 2111 btn.className = "workspaceEmptyAdd ghost"; 2112 btn.setAttribute("data-workspaceadd", slot.id || ""); 2113 btn.innerHTML = `<span class="workspaceEmptyAddPlus">+</span><span>Add panel</span>`; 2114 slot.appendChild(btn); 2115 } 2116 } 2117 2118 function readRackOrder(rackEl) { 2119 if (!(rackEl instanceof HTMLElement)) return []; 2120 return Array.from(rackEl.querySelectorAll(".rackPanel")) 2121 .filter((el) => el instanceof HTMLElement && !el.classList.contains("hidden")) 2122 .map((el) => String(el?.dataset?.panelId || "").trim()) 2123 .filter(Boolean); 2124 } 2125 2126 function applyRackStateToDom() { 2127 if (!rackLayoutEnabled) return; 2128 if (workspaceInfiniteMode()) { 2129 ensurePluginRackPanel(); 2130 const workspace = ensureWorkspaceStripRack(); 2131 const rightRack = ensureRightRack(); 2132 if (!(workspace instanceof HTMLElement)) return; 2133 const combinedOrder = [ 2134 ...(Array.isArray(rackLayoutState?.racks?.workspaceLeft) ? rackLayoutState.racks.workspaceLeft : []), 2135 ...(Array.isArray(rackLayoutState?.racks?.workspaceRight) ? rackLayoutState.racks.workspaceRight : []), 2136 ...(Array.isArray(rackLayoutState?.racks?.side) ? rackLayoutState.racks.side : []), 2137 ...(Array.isArray(rackLayoutState?.racks?.right) ? rackLayoutState.racks.right : []), 2138 ]; 2139 for (const panelId of combinedOrder) { 2140 if (isRightRackFixedPanel(panelId)) continue; 2141 const el = getPanelElement(panelId); 2142 if (!el) continue; 2143 if (isDocked(panelId)) continue; 2144 workspace.appendChild(el); 2145 applyPanelWorkspaceSize(el); 2146 } 2147 const rightPanel = getPanelElement(RIGHT_RACK_FIXED_PANEL_ID); 2148 if ( 2149 rightRack instanceof HTMLElement && 2150 rightPanel instanceof HTMLElement && 2151 !isDocked(RIGHT_RACK_FIXED_PANEL_ID) && 2152 rightPanel.parentElement !== rightRack 2153 ) { 2154 rightRack.appendChild(rightPanel); 2155 } 2156 return; 2157 } 2158 // Ensure core "virtual" panels exist before we try to place them. 2159 ensurePluginRackPanel(); 2160 const left = ensureWorkspaceLeftRack(); 2161 const rightWorkspace = ensureWorkspaceRightRack(); 2162 const side = ensureMainSideRack(); 2163 const right = ensureRightRack(); 2164 if (!left || !rightWorkspace || !side || !right) return; 2165 const leftOrder = Array.isArray(rackLayoutState?.racks?.workspaceLeft) ? rackLayoutState.racks.workspaceLeft : []; 2166 const rightOrderW = Array.isArray(rackLayoutState?.racks?.workspaceRight) ? rackLayoutState.racks.workspaceRight : []; 2167 const sideOrder = Array.isArray(rackLayoutState?.racks?.side) ? rackLayoutState.racks.side : []; 2168 const rightOrder = Array.isArray(rackLayoutState?.racks?.right) ? rackLayoutState.racks.right : []; 2169 2170 for (const panelId of leftOrder) { 2171 const el = getPanelElement(panelId); 2172 if (el) left.appendChild(el); 2173 } 2174 for (const panelId of rightOrderW) { 2175 const el = getPanelElement(panelId); 2176 if (el) rightWorkspace.appendChild(el); 2177 } 2178 for (const panelId of sideOrder) { 2179 const el = getPanelElement(panelId); 2180 if (el) side.appendChild(el); 2181 } 2182 for (const panelId of rightOrder) { 2183 const el = getPanelElement(panelId); 2184 if (el) right.appendChild(el); 2185 } 2186 2187 // Hosted plugin widgets live inside Plugin Rack, not a top-level rack. 2188 const widgetsOrder = Array.isArray(rackLayoutState?.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []; 2189 const widgetsRack = ensurePluginRackWidgetsRack(); 2190 if (widgetsRack) { 2191 for (const panelId of widgetsOrder) { 2192 const el = getPanelElement(panelId); 2193 if (!el) continue; 2194 el.classList.add("pluginRackWidget"); 2195 widgetsRack.appendChild(el); 2196 } 2197 } 2198 } 2199 2200 function readWorkspaceActivePrimary() { 2201 try { 2202 const raw = localStorage.getItem(WORKSPACE_ACTIVE_PRIMARY_KEY); 2203 return raw ? String(raw) : ""; 2204 } catch { 2205 return ""; 2206 } 2207 } 2208 2209 function writeWorkspaceActivePrimary(panelId) { 2210 const id = String(panelId || "").trim(); 2211 if (!id) return; 2212 try { 2213 localStorage.setItem(WORKSPACE_ACTIVE_PRIMARY_KEY, id); 2214 } catch { 2215 // ignore 2216 } 2217 } 2218 2219 function enforceWorkspaceRules() { 2220 if (!rackLayoutEnabled) return; 2221 if (workspaceInfiniteMode()) { 2222 const workspace = ensureWorkspaceStripRack(); 2223 const rightRack = ensureRightRack(); 2224 if (!(workspace instanceof HTMLElement)) return; 2225 if (!(rightRack instanceof HTMLElement)) return; 2226 const candidatePanels = Array.from( 2227 appRoot.querySelectorAll( 2228 "#mainWorkspaceRack > .rackPanel, #workspaceLeftSlot > .rackPanel, #workspaceRightSlot > .rackPanel, #mainSideRack > .rackPanel, #rightRack > .rackPanel" 2229 ) 2230 ); 2231 for (const panel of candidatePanels) { 2232 if (!(panel instanceof HTMLElement)) continue; 2233 const panelId = String(panel.dataset.panelId || "").trim(); 2234 if (!panelId) continue; 2235 if (isRightRackFixedPanel(panelId)) { 2236 if (rightRack instanceof HTMLElement && panel.parentElement !== rightRack) rightRack.appendChild(panel); 2237 panel.classList.remove("hidden"); 2238 continue; 2239 } 2240 if (isDocked(panelId)) continue; 2241 if (panel.parentElement !== workspace) workspace.appendChild(panel); 2242 applyPanelWorkspaceSize(panel); 2243 } 2244 if (rackLayoutState?.docked?.bottom?.includes?.(RIGHT_RACK_FIXED_PANEL_ID)) { 2245 rackLayoutState.docked.bottom = rackLayoutState.docked.bottom.filter((x) => x !== RIGHT_RACK_FIXED_PANEL_ID); 2246 } 2247 const peoplePanel = getPanelElement(RIGHT_RACK_FIXED_PANEL_ID); 2248 if (rightRack instanceof HTMLElement && peoplePanel instanceof HTMLElement && peoplePanel.parentElement !== rightRack) { 2249 rightRack.appendChild(peoplePanel); 2250 } 2251 if (appRoot) { 2252 appRoot.classList.remove("workspaceExpandedLeft", "workspaceExpandedRight", "workspaceSingleLeft", "workspaceSingleRight"); 2253 } 2254 syncRackStateFromDom(); 2255 updateWorkspaceMinView(); 2256 return; 2257 } 2258 const left = ensureWorkspaceLeftRack(); 2259 const rightWorkspace = ensureWorkspaceRightRack(); 2260 const side = ensureMainSideRack(); 2261 const rightRack = ensureRightRack(); 2262 if (!left || !rightWorkspace || !side || !rightRack) return; 2263 2264 // Primary panels: allow up to 2 visible (one per workspace slot). Enforce max 1 per slot. 2265 const cleanupSlot = (slotEl) => { 2266 const kids = Array.from(slotEl.querySelectorAll(":scope > .rackPanel:not(.hidden)")); 2267 if (kids.length <= 1) return; 2268 for (const extra of kids.slice(1)) side.appendChild(extra); 2269 }; 2270 cleanupSlot(left); 2271 cleanupSlot(rightWorkspace); 2272 2273 // Side rack and right rack are "skinny columns": only allow skinny-capable panels. 2274 const enforceSkinny = (rackEl) => { 2275 const kids = Array.from(rackEl.querySelectorAll(":scope > .rackPanel:not(.hidden)")); 2276 for (const kid of kids) { 2277 const id = String(kid?.dataset?.panelId || "").trim(); 2278 if (!id) continue; 2279 if (!panelIsSkinnyCapable(id)) dockPanel(id); 2280 } 2281 }; 2282 enforceSkinny(side); 2283 enforceSkinny(rightRack); 2284 2285 // Side rack can stack, but keep it compact: at most 2 visible panels. 2286 const sideKids = Array.from(side.querySelectorAll(":scope > .rackPanel:not(.hidden)")); 2287 if (sideKids.length > 2) { 2288 for (const extra of sideKids.slice(2)) { 2289 const id = String(extra?.dataset?.panelId || "").trim(); 2290 if (id) dockPanel(id); 2291 } 2292 } 2293 2294 // Right rack is single-slot: keep at most one visible panel. 2295 const rightKids = Array.from(rightRack.querySelectorAll(":scope > .rackPanel:not(.hidden)")); 2296 if (rightKids.length > 1) { 2297 for (const extra of rightKids.slice(1)) { 2298 const id = String(extra?.dataset?.panelId || "").trim(); 2299 if (id) dockPanel(id); 2300 } 2301 } 2302 2303 // Panels that live in the workspace slots should be "full" by default (especially primaries). 2304 for (const slot of [left, rightWorkspace]) { 2305 const panel = slot.querySelector?.(":scope > .rackPanel:not(.hidden)"); 2306 if (!(panel instanceof HTMLElement)) continue; 2307 const id = String(panel.dataset.panelId || "").trim(); 2308 if (!id) continue; 2309 panel.classList.remove("panelCollapsed"); 2310 panel.dataset.panelDisplay = "full"; 2311 } 2312 2313 // If only one workspace slot is occupied, allow it to expand to full width to avoid blank space. 2314 // (We temporarily disable this during drag so the empty slot remains a visible drop target.) 2315 const leftPanel = left.querySelector?.(":scope > .rackPanel:not(.hidden)"); 2316 const rightPanel = rightWorkspace.querySelector?.(":scope > .rackPanel:not(.hidden)"); 2317 const leftId = String(leftPanel?.dataset?.panelId || "").trim(); 2318 const rightId = String(rightPanel?.dataset?.panelId || "").trim(); 2319 2320 // Workspace expansion (explicit maximize for primaries). 2321 const expandedId = readWorkspaceExpandedPrimary(); 2322 const expandedInLeft = Boolean(expandedId && expandedId === leftId); 2323 const expandedInRight = Boolean(expandedId && expandedId === rightId); 2324 const expandedValid = expandedInLeft || expandedInRight; 2325 if (appRoot) { 2326 appRoot.classList.toggle("workspaceExpandedLeft", expandedInLeft); 2327 appRoot.classList.toggle("workspaceExpandedRight", expandedInRight); 2328 if (!expandedValid) appRoot.classList.remove("workspaceExpandedLeft", "workspaceExpandedRight"); 2329 } 2330 if (expandedId && !expandedValid) clearWorkspaceExpandedState(); 2331 2332 // If expanded and the other slot is occupied, keep it accessible via hotbar. 2333 if (expandedInLeft && rightId && rightId !== expandedId) { 2334 if (!readWorkspaceExpandedDisplaced()) writeWorkspaceExpandedDisplaced(rightId); 2335 dockPanel(rightId); 2336 } 2337 if (expandedInRight && leftId && leftId !== expandedId) { 2338 if (!readWorkspaceExpandedDisplaced()) writeWorkspaceExpandedDisplaced(leftId); 2339 dockPanel(leftId); 2340 } 2341 2342 // Auto-expand single-primary only when not explicitly expanded. 2343 if (appRoot && !appRoot.classList.contains("rackIsDragging") && !expandedValid) { 2344 const leftOnly = Boolean(leftPanel && !rightPanel); 2345 const rightOnly = Boolean(!leftPanel && rightPanel); 2346 appRoot.classList.toggle("workspaceSingleLeft", leftOnly); 2347 appRoot.classList.toggle("workspaceSingleRight", rightOnly); 2348 } else if (appRoot) { 2349 appRoot.classList.remove("workspaceSingleLeft", "workspaceSingleRight"); 2350 } 2351 2352 // Transient panels should live in the side column and be collapsed by default. 2353 for (const el of Array.from(appRoot.querySelectorAll("#mainWorkspaceRack .rackPanel, #mainSideRack .rackPanel"))) { 2354 const id = String(el?.dataset?.panelId || "").trim(); 2355 if (!id) continue; 2356 if (panelRole(id) !== "transient") continue; 2357 if (el.parentElement !== side) side.appendChild(el); 2358 el.classList.add("panelCollapsed"); 2359 el.dataset.panelDisplay = "collapsed"; 2360 } 2361 2362 updateSkinnyChatPanels(); 2363 renderWorkspaceSlotAffordances(); 2364 syncRackStateFromDom(); 2365 } 2366 2367 function installWorkspaceInteractions() { 2368 if (!rackLayoutEnabled) return; 2369 if (!appRoot) return; 2370 if (appRoot.dataset.workspaceClicks === "1") return; 2371 appRoot.dataset.workspaceClicks = "1"; 2372 2373 appRoot.addEventListener("click", (e) => { 2374 if (!rackLayoutEnabled) return; 2375 const target = e.target; 2376 const addBtn = target?.closest?.("[data-workspaceadd]"); 2377 if (addBtn instanceof HTMLElement) { 2378 const slotId = String(addBtn.getAttribute("data-workspaceadd") || "").trim(); 2379 if (!slotId) return; 2380 if (workspaceAddMenuEl) closeWorkspaceAddMenu(); 2381 else openWorkspaceAddMenu(addBtn, slotId); 2382 return; 2383 } 2384 const interactive = target?.closest?.("button,a,input,select,textarea,label"); 2385 if (interactive) return; 2386 const panel = target?.closest?.(".rackPanel"); 2387 if (!panel) return; 2388 if (!(panel instanceof HTMLElement)) return; 2389 if (!panel.closest?.("#mainRack")) return; 2390 const panelId = String(panel.dataset.panelId || "").trim(); 2391 if (!panelId) return; 2392 if (panelRole(panelId) !== "primary") return; 2393 writeWorkspaceActivePrimary(panelId); 2394 enforceWorkspaceRules(); 2395 }); 2396 } 2397 2398 function syncRackStateFromDom() { 2399 if (!rackLayoutEnabled) return; 2400 if (workspaceInfiniteMode()) { 2401 const workspace = ensureWorkspaceStripRack(); 2402 const right = ensureRightRack(); 2403 if (!(workspace instanceof HTMLElement)) return; 2404 const workspaceIds = readRackOrder(workspace).filter((id) => !isRightRackFixedPanel(id)); 2405 const rightIds = right instanceof HTMLElement ? readRackOrder(right).filter((id) => isRightRackFixedPanel(id)) : []; 2406 rackLayoutState.racks = { 2407 workspaceLeft: workspaceIds, 2408 workspaceRight: [], 2409 side: [], 2410 right: rightIds, 2411 }; 2412 rackLayoutState.pluginRackWidgets = readPluginRackWidgetsOrder(); 2413 saveRackLayoutState(); 2414 return; 2415 } 2416 const left = ensureWorkspaceLeftRack(); 2417 const rightWorkspace = ensureWorkspaceRightRack(); 2418 const side = ensureMainSideRack(); 2419 const right = ensureRightRack(); 2420 if (!left || !rightWorkspace || !side || !right) return; 2421 rackLayoutState.racks = { 2422 workspaceLeft: readRackOrder(left), 2423 workspaceRight: readRackOrder(rightWorkspace), 2424 side: readRackOrder(side), 2425 right: readRackOrder(right), 2426 }; 2427 rackLayoutState.pluginRackWidgets = readPluginRackWidgetsOrder(); 2428 const hosted = new Set(Array.isArray(rackLayoutState.pluginRackWidgets) ? rackLayoutState.pluginRackWidgets : []); 2429 for (const [id, entry] of panelRegistry.entries()) { 2430 const el = entry?.element; 2431 if (!(el instanceof HTMLElement)) continue; 2432 if (!el.classList.contains("pluginRackWidget") && hosted.has(id)) el.classList.add("pluginRackWidget"); 2433 if (el.classList.contains("pluginRackWidget") && !hosted.has(id)) el.classList.remove("pluginRackWidget"); 2434 } 2435 saveRackLayoutState(); 2436 } 2437 2438 function ensureRightRack() { 2439 if (!appRoot) return null; 2440 if (rightRackEl && rightRackEl.isConnected) return rightRackEl; 2441 const el = document.createElement("aside"); 2442 el.id = "rightRack"; 2443 el.className = "rightRack"; 2444 appRoot.appendChild(el); 2445 rightRackEl = el; 2446 return el; 2447 } 2448 2449 function ensureMainRack() { 2450 // In rack mode, "main rack" is the workspace column inside #mainRack. 2451 if (mainRack && mainRack.isConnected) return mainRack; 2452 if (mainWorkspaceRackEl) { 2453 mainRack = mainWorkspaceRackEl; 2454 return mainRack; 2455 } 2456 2457 const wrapper = mainRackEl || document.querySelector("#mainRack") || document.querySelector("main.main"); 2458 if (!wrapper) return null; 2459 2460 let workspace = wrapper.querySelector?.("#mainWorkspaceRack"); 2461 let side = wrapper.querySelector?.("#mainSideRack"); 2462 if (!workspace) { 2463 const w = document.createElement("div"); 2464 w.id = "mainWorkspaceRack"; 2465 w.className = "workspaceRack"; 2466 w.setAttribute("aria-label", "Workspace"); 2467 wrapper.appendChild(w); 2468 workspace = w; 2469 } 2470 if (!side) { 2471 const s = document.createElement("div"); 2472 s.id = "mainSideRack"; 2473 s.className = "sideRack"; 2474 s.setAttribute("aria-label", "Side panels"); 2475 wrapper.appendChild(s); 2476 side = s; 2477 } 2478 mainSideRack = side; 2479 mainRack = workspace; 2480 return mainRack; 2481 } 2482 2483 function ensureMainSideRack() { 2484 if (mainSideRack && mainSideRack.isConnected) return mainSideRack; 2485 if (mainSideRackEl) { 2486 mainSideRack = mainSideRackEl; 2487 return mainSideRack; 2488 } 2489 // Ensure the workspace rack exists too (creates both columns if missing). 2490 ensureMainRack(); 2491 return mainSideRack instanceof HTMLElement ? mainSideRack : null; 2492 } 2493 2494 function ensureWorkspaceLeftRack() { 2495 if (workspaceInfiniteMode()) return ensureWorkspaceStripRack(); 2496 const { left } = ensureWorkspaceSlots(); 2497 return left instanceof HTMLElement ? left : null; 2498 } 2499 2500 function ensureWorkspaceRightRack() { 2501 if (workspaceInfiniteMode()) return ensureWorkspaceStripRack(); 2502 const { right } = ensureWorkspaceSlots(); 2503 return right instanceof HTMLElement ? right : null; 2504 } 2505 2506 function enableRackLayoutDom() { 2507 if (!appRoot) return; 2508 appRoot.classList.add("rackMode"); 2509 appRoot.classList.toggle("workspaceInfinite", workspaceInfiniteMode()); 2510 const rack = ensureRightRack(); 2511 if (!rack) return; 2512 const main = ensureMainRack(); 2513 const left = ensureWorkspaceLeftRack(); 2514 const rightWorkspace = ensureWorkspaceRightRack(); 2515 const side = ensureMainSideRack(); 2516 const workspaceStrip = ensureWorkspaceStripRack(); 2517 2518 const mark = (el, panelId) => { 2519 if (!el) return; 2520 el.classList.add("rackPanel"); 2521 el.dataset.panelId = panelId; 2522 }; 2523 2524 // Move right-side panels into the rack so they become stackable. 2525 // (This is a stepping stone toward full dockable panels.) 2526 if (chatPanelEl) { 2527 mark(chatPanelEl, "chat"); 2528 if (workspaceInfiniteMode()) { 2529 if (workspaceStrip && chatPanelEl.parentElement !== workspaceStrip) workspaceStrip.appendChild(chatPanelEl); 2530 } else if (rightWorkspace && chatPanelEl.parentElement !== rightWorkspace) rightWorkspace.appendChild(chatPanelEl); 2531 } 2532 if (peopleDrawerEl) { 2533 mark(peopleDrawerEl, "people"); 2534 if (peopleDrawerEl.parentElement !== rack) rack.appendChild(peopleDrawerEl); 2535 } 2536 if (modPanelEl) { 2537 mark(modPanelEl, "moderation"); 2538 if (workspaceInfiniteMode()) { 2539 if (workspaceStrip && modPanelEl.parentElement !== workspaceStrip) workspaceStrip.appendChild(modPanelEl); 2540 } else if (modPanelEl.parentElement !== rack) rack.appendChild(modPanelEl); 2541 } 2542 2543 // Mark center panels as rack panels too (they already live in mainRack in normal DOM). 2544 if (main) { 2545 if (onboardingPanelEl) { 2546 mark(onboardingPanelEl, "onboarding"); 2547 if (workspaceInfiniteMode()) { 2548 if (workspaceStrip && onboardingPanelEl.parentElement !== workspaceStrip) workspaceStrip.appendChild(onboardingPanelEl); 2549 } else if (left && onboardingPanelEl.parentElement !== left) left.appendChild(onboardingPanelEl); 2550 onboardingPanelEl.classList.remove("hidden"); 2551 } 2552 if (hivesPanelEl) { 2553 mark(hivesPanelEl, "hives"); 2554 if (workspaceInfiniteMode()) { 2555 if (workspaceStrip && hivesPanelEl.parentElement !== workspaceStrip) workspaceStrip.appendChild(hivesPanelEl); 2556 } else if (left && hivesPanelEl.parentElement !== left) left.appendChild(hivesPanelEl); 2557 } 2558 if (profileViewPanel) { 2559 mark(profileViewPanel, "profile"); 2560 if (workspaceInfiniteMode()) { 2561 if (workspaceStrip && profileViewPanel.parentElement !== workspaceStrip) workspaceStrip.appendChild(profileViewPanel); 2562 } else if (side && profileViewPanel.parentElement !== side) side.appendChild(profileViewPanel); 2563 // In rack mode, profile is its own panel; don't keep it hidden behind the legacy center-view toggle. 2564 profileViewPanel.classList.remove("hidden"); 2565 } 2566 if (pollinatePanel) { 2567 mark(pollinatePanel, "composer"); 2568 if (workspaceInfiniteMode()) { 2569 if (workspaceStrip && pollinatePanel.parentElement !== workspaceStrip) workspaceStrip.appendChild(pollinatePanel); 2570 } else if (side && pollinatePanel.parentElement !== side) side.appendChild(pollinatePanel); 2571 } 2572 } 2573 2574 // Hide old resizers in rack mode (we'll replace with rack-aware resizing later). 2575 chatResizeHandle?.classList.add("hidden"); 2576 peopleResizeHandle?.classList.add("hidden"); 2577 2578 // People drawer chrome: hide the close button (panel is now a rack item). 2579 closePeopleBtn?.classList.add("hidden"); 2580 // People drawer toggle button is obsolete in rack mode. 2581 togglePeopleBtn?.classList.add("hidden"); 2582 // Ensure people panel isn't hidden by legacy state. 2583 peopleDrawerEl?.classList.remove("hidden"); 2584 peopleOpen = true; 2585 2586 // Profile panel no longer "replaces" the feed in rack mode, so the back button is confusing. 2587 profileBackBtn?.classList.add("hidden"); 2588 applyAllWorkspacePanelSizes(); 2589 installWorkspaceScrollSnapStep(); 2590 updateWorkspaceMinView(); 2591 } 2592 2593 function disableRackLayoutDom() { 2594 if (!appRoot) return; 2595 appRoot.classList.remove("rackMode"); 2596 appRoot.classList.remove("workspaceInfinite"); 2597 // No attempt to move elements back (yet). Disable is meant for page reload use. 2598 } 2599 2600 function applyPreset(presetId) { 2601 const key = resolvePresetKey(presetId); 2602 const def = PRESET_DEFS[key]; 2603 if (!def) return; 2604 if (def.modOnly && !canModerate) { 2605 applyPreset("onboardingDefault"); 2606 return; 2607 } 2608 2609 // Keep hosted widgets alive across preset switches so panel runtime state is preserved. 2610 closePluginRackAddMenu(); 2611 2612 rackLayoutState.presetId = def.presetId || key; 2613 2614 const workspaceLeftOrder = Array.isArray(def.workspaceLeftOrder) ? def.workspaceLeftOrder.map((x) => String(x || "")).filter(Boolean) : []; 2615 const workspaceRightOrder = Array.isArray(def.workspaceRightOrder) ? def.workspaceRightOrder.map((x) => String(x || "")).filter(Boolean) : []; 2616 const sideOrder = Array.isArray(def.sideOrder) ? def.sideOrder.map((x) => String(x || "")).filter(Boolean) : []; 2617 const rightOrderRaw = Array.isArray(def.rightOrder) ? def.rightOrder.map((x) => String(x || "")).filter(Boolean) : []; 2618 // Right rack is a single skinny-capable panel. 2619 const rightOrder = rightOrderRaw.length ? [rightOrderRaw[0]] : []; 2620 2621 // Applying a preset should be deterministic even after the user has rearranged panels. 2622 clearWorkspaceExpandedState(); 2623 const expandedPrimary = typeof def.expandedPrimary === "string" ? def.expandedPrimary.trim() : ""; 2624 if (expandedPrimary) writeWorkspaceExpandedPrimary(expandedPrimary); 2625 2626 if (typeof def.composerOpen === "boolean") setComposerOpen(def.composerOpen); 2627 setSideCollapsed(Boolean(def.sideCollapsed), { persist: true }); 2628 setRightCollapsed(Boolean(def.rightCollapsed), { persist: true }); 2629 2630 const leftRack = ensureWorkspaceLeftRack(); 2631 const rightWorkspaceRack = ensureWorkspaceRightRack(); 2632 const sideRack = ensureMainSideRack(); 2633 const rightRack = ensureRightRack(); 2634 if (!leftRack || !rightWorkspaceRack || !sideRack || !rightRack) return; 2635 2636 if (workspaceInfiniteMode()) { 2637 const workspace = ensureWorkspaceStripRack(); 2638 if (!(workspace instanceof HTMLElement)) return; 2639 const order = [...workspaceLeftOrder, ...workspaceRightOrder, ...sideOrder, ...rightOrder].filter((id) => !isRightRackFixedPanel(id)); 2640 const placed = new Set(order); 2641 const presetDocked = new Set( 2642 (Array.isArray(def.dockBottom) ? def.dockBottom.map((x) => String(x || "")).filter(Boolean) : []).filter((id) => !isRightRackFixedPanel(id)) 2643 ); 2644 const affectedByPreset = new Set([...placed, ...presetDocked]); 2645 const docked = new Set(Array.isArray(rackLayoutState?.docked?.bottom) ? rackLayoutState.docked.bottom : []); 2646 for (const id of affectedByPreset) docked.delete(id); 2647 for (const id of presetDocked) docked.add(id); 2648 for (const id of placed) docked.delete(id); 2649 // Core presets should not unexpectedly surface plugin panels unless explicitly placed. 2650 for (const [panelId] of panelRegistry.entries()) { 2651 if (!panelIsPluginOwned(panelId)) continue; 2652 if (placed.has(panelId) || presetDocked.has(panelId)) continue; 2653 docked.add(panelId); 2654 } 2655 if (!canModerate) docked.add("moderation"); 2656 rackLayoutState.docked.bottom = Array.from(docked); 2657 saveRackLayoutState(); 2658 applyDockState(); 2659 for (const panelId of order) { 2660 if (docked.has(panelId)) continue; 2661 const el = getPanelElement(panelId); 2662 if (!el) continue; 2663 workspace.appendChild(el); 2664 applyPanelWorkspaceSize(el); 2665 } 2666 syncRackStateFromDom(); 2667 enforceWorkspaceRules(); 2668 updateLayoutPresetOptions(); 2669 requestAnimationFrame(() => { 2670 try { 2671 workspace.scrollTo({ left: 0, behavior: "auto" }); 2672 } catch { 2673 // ignore 2674 } 2675 }); 2676 return; 2677 } 2678 2679 const placed = new Set([...workspaceLeftOrder, ...workspaceRightOrder, ...sideOrder, ...rightOrder]); 2680 const presetDocked = new Set( 2681 (Array.isArray(def.dockBottom) ? def.dockBottom.map((x) => String(x || "")).filter(Boolean) : []).filter((id) => !isRightRackFixedPanel(id)) 2682 ); 2683 const affectedByPreset = new Set([...placed, ...presetDocked]); 2684 // Apply preset intent only to panels explicitly named by the preset. 2685 // Dynamic panels (for example chat instances) keep their current dock/placement state. 2686 const docked = new Set(Array.isArray(rackLayoutState?.docked?.bottom) ? rackLayoutState.docked.bottom : []); 2687 for (const id of affectedByPreset) docked.delete(id); 2688 for (const id of presetDocked) docked.add(id); 2689 for (const id of placed) docked.delete(id); 2690 // Keep plugin-owned panels out of core layouts unless preset explicitly names them. 2691 for (const [panelId] of panelRegistry.entries()) { 2692 if (!panelIsPluginOwned(panelId)) continue; 2693 if (placed.has(panelId) || presetDocked.has(panelId)) continue; 2694 docked.add(panelId); 2695 } 2696 2697 // Moderation panel should not be forced visible for non-mods. 2698 if (!canModerate) { 2699 docked.add("moderation"); 2700 // Also ensure moderation isn't placed anywhere. 2701 workspaceLeftOrder.splice(0, workspaceLeftOrder.length, ...workspaceLeftOrder.filter((x) => x !== "moderation")); 2702 workspaceRightOrder.splice(0, workspaceRightOrder.length, ...workspaceRightOrder.filter((x) => x !== "moderation")); 2703 sideOrder.splice(0, sideOrder.length, ...sideOrder.filter((x) => x !== "moderation")); 2704 } 2705 2706 rackLayoutState.docked.bottom = Array.from(docked); 2707 2708 saveRackLayoutState(); 2709 applyDockState(); 2710 2711 if (leftRack) { 2712 for (const panelId of workspaceLeftOrder) { 2713 if (docked.has(panelId)) continue; 2714 const el = getPanelElement(panelId); 2715 if (el) leftRack.appendChild(el); 2716 } 2717 } 2718 if (rightWorkspaceRack) { 2719 for (const panelId of workspaceRightOrder) { 2720 if (docked.has(panelId)) continue; 2721 const el = getPanelElement(panelId); 2722 if (el) rightWorkspaceRack.appendChild(el); 2723 } 2724 } 2725 if (sideRack) { 2726 for (const panelId of sideOrder) { 2727 if (docked.has(panelId)) continue; 2728 const el = getPanelElement(panelId); 2729 if (el) sideRack.appendChild(el); 2730 } 2731 } 2732 if (rightRack) { 2733 for (const panelId of rightOrder) { 2734 if (docked.has(panelId)) continue; 2735 const el = getPanelElement(panelId); 2736 if (el) rightRack.appendChild(el); 2737 } 2738 } 2739 2740 syncRackStateFromDom(); 2741 enforceWorkspaceRules(); 2742 updateLayoutPresetOptions(); 2743 } 2744 2745 function installPanelMinimizeButtons() { 2746 const addMinBtn = (headerEl, panelId) => { 2747 if (!headerEl) return; 2748 const row = headerEl.querySelector(".row") || headerEl.querySelector(".filters") || headerEl; 2749 2750 if (!headerEl.querySelector(`[data-rackdrag="${panelId}"]`)) { 2751 const drag = document.createElement("button"); 2752 drag.type = "button"; 2753 drag.className = "ghost smallBtn rackDragHandle"; 2754 drag.textContent = "β‘"; 2755 drag.title = "Drag to reorder"; 2756 drag.setAttribute("data-rackdrag", panelId); 2757 row.appendChild(drag); 2758 } 2759 2760 installPanelSizeButtons(headerEl, panelId); 2761 2762 if (!headerEl.querySelector(`[data-minimize="${panelId}"]`)) { 2763 const btn = document.createElement("button"); 2764 btn.type = "button"; 2765 btn.className = "ghost smallBtn"; 2766 btn.textContent = "-"; 2767 btn.title = "Minimize to hotbar"; 2768 btn.setAttribute("data-minimize", panelId); 2769 btn.onclick = () => dockPanel(panelId); 2770 row.appendChild(btn); 2771 } 2772 }; 2773 2774 addMinBtn(chatHeaderEl, "chat"); 2775 addMinBtn(modPanelEl?.querySelector(".panelHeader"), "moderation"); 2776 addMinBtn(hivesPanelEl?.querySelector(".panelHeader"), "hives"); 2777 addMinBtn(profileViewPanel?.querySelector(".panelHeader"), "profile"); 2778 addMinBtn(pollinatePanel?.querySelector(".panelHeader"), "composer"); 2779 ensurePluginRackPanel(); 2780 addMinBtn(pluginRackPanelEl?.querySelector(".panelHeader"), "pluginRack"); 2781 } 2782 2783 function ensurePluginPanelShell(panelId, title, icon, defaultRack, role) { 2784 const wantsMain = String(defaultRack || "").toLowerCase() === "main"; 2785 const isPrimary = String(role || "").toLowerCase() === "primary"; 2786 let preferred = null; 2787 if (wantsMain && isPrimary) { 2788 // Primary panels should live inside a workspace slot, not as loose items in the workspace grid. 2789 const left = ensureWorkspaceLeftRack(); 2790 const right = ensureWorkspaceRightRack(); 2791 const side = ensureMainSideRack(); 2792 const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel").length === 0 : false; 2793 const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel").length === 0 : false; 2794 preferred = leftEmpty ? left : rightEmpty ? right : side; 2795 } else if (wantsMain) { 2796 preferred = ensureMainSideRack(); 2797 } else { 2798 preferred = ensureRightRack(); 2799 } 2800 const rack = preferred || ensureRightRack() || ensureMainSideRack() || ensureWorkspaceLeftRack() || ensureWorkspaceRightRack() || ensureMainRack(); 2801 if (!rack) return null; 2802 2803 const existing = document.querySelector?.(`.panel.pluginPanel[data-panel-id="${CSS.escape(panelId)}"]`); 2804 if (existing instanceof HTMLElement) { 2805 if (existing.parentElement !== rack) rack.appendChild(existing); 2806 return existing; 2807 } 2808 2809 const shell = document.createElement("section"); 2810 shell.className = "panel panelFill pluginPanel rackPanel"; 2811 shell.dataset.panelId = panelId; 2812 shell.innerHTML = ` 2813 <div class="panelHeader"> 2814 <div class="panelTitle">${escapeHtml(title || panelId)}</div> 2815 <div class="row"> 2816 <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">β‘</button> 2817 <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">-</button> 2818 </div> 2819 </div> 2820 <div class="panelBody" data-pluginmount="1"></div> 2821 `; 2822 installPanelSizeButtons(shell.querySelector(".panelHeader"), panelId); 2823 const minBtn = shell.querySelector(`[data-minimize="${panelId}"]`); 2824 if (minBtn) minBtn.addEventListener("click", () => dockPanel(panelId)); 2825 2826 rack.appendChild(shell); 2827 applyPanelWorkspaceSize(shell); 2828 return shell; 2829 } 2830 2831 function ensureChatPostPanelInstance(postId, opts) { 2832 if (!rackLayoutEnabled) return ""; 2833 const pid = String(postId || "").trim(); 2834 if (!pid) return ""; 2835 const post = posts.get(pid) || null; 2836 const panelId = chatInstancePanelIdForPost(pid); 2837 if (!panelId) return ""; 2838 2839 if (panelRegistry.has(panelId)) return panelId; 2840 2841 const title = post?.title ? `Chat: ${String(post.title).slice(0, 32)}` : "Chat"; 2842 const shell = document.createElement("section"); 2843 shell.className = "panel panelFill rackPanel chat chatInstance"; 2844 shell.dataset.panelId = panelId; 2845 shell.innerHTML = ` 2846 <div class="panelHeader"> 2847 <div> 2848 <div class="panelTitle">${escapeHtml(title)}</div> 2849 <div class="small muted chatMeta"></div> 2850 </div> 2851 <div class="row"> 2852 <button type="button" class="ghost smallBtn rackDragHandle" data-rackdrag="${escapeHtml(panelId)}" title="Drag to reorder">β‘</button> 2853 <div class="panelSizeControls" data-panelsizerow="${escapeHtml(panelId)}"> 2854 <button type="button" class="ghost smallBtn panelSizeBtn" data-panelsize="skinny" data-panelid="${escapeHtml(panelId)}" title="Skinny width">S</button> 2855 <button type="button" class="ghost smallBtn panelSizeBtn" data-panelsize="half" data-panelid="${escapeHtml(panelId)}" title="Half width">H</button> 2856 <button type="button" class="ghost smallBtn panelSizeBtn" data-panelsize="full" data-panelid="${escapeHtml(panelId)}" title="Full width">F</button> 2857 </div> 2858 <button type="button" class="ghost smallBtn" data-minimize="${escapeHtml(panelId)}" title="Minimize to hotbar">-</button> 2859 </div> 2860 </div> 2861 <div class="chatMessages"></div> 2862 <div class="typingIndicator small muted"></div> 2863 <form class="chatForm"> 2864 <div class="chatComposer"> 2865 <div class="toolbar" role="toolbar" aria-label="Chat formatting"> 2866 <button type="button" data-chatcmd="bold"><b>B</b></button> 2867 <button type="button" data-chatcmd="italic"><i>I</i></button> 2868 <button type="button" data-chatcmd="underline"><u>U</u></button> 2869 <button type="button" data-chatcmd="strikeThrough"><s>S</s></button> 2870 <span class="sep"></span> 2871 <button type="button" data-chatcmd="insertUnorderedList">List</button> 2872 <button type="button" data-chatcmd="insertOrderedList">1. List</button> 2873 <button type="button" data-chatlink="1">Link</button> 2874 <button type="button" data-chatimg="1">GIF/Image</button> 2875 <button type="button" data-chataudio="1">Audio</button> 2876 <button type="button" data-chatemoji="1">Emoji</button> 2877 <button type="button" data-chatcmd="removeFormat">Clear</button> 2878 </div> 2879 <div class="chatInstanceTools"> 2880 <label class="checkRow chatModToggle chatInstModToggle hidden" title="Send as moderator/system message (left rail)"> 2881 <span>Mod</span> 2882 <input class="chatInstModToggleInput" type="checkbox" /> 2883 </label> 2884 </div> 2885 <div class="editor chatEditor" contenteditable="true" aria-label="Chat editor"></div> 2886 </div> 2887 <button class="primary" type="submit">Send</button> 2888 </form> 2889 `; 2890 2891 const metaEl = shell.querySelector(".chatMeta"); 2892 const messagesEl = shell.querySelector(".chatMessages"); 2893 const typingEl = shell.querySelector(".typingIndicator"); 2894 const formEl = shell.querySelector("form.chatForm"); 2895 const editorEl = shell.querySelector(".chatEditor"); 2896 const modToggleWrapEl = shell.querySelector(".chatInstModToggle"); 2897 const modToggleEl = shell.querySelector(".chatInstModToggleInput"); 2898 2899 shell.querySelector(`[data-minimize="${cssEscape(panelId)}"]`)?.addEventListener("click", () => dockPanel(panelId)); 2900 refreshPanelSizeButtons(panelId); 2901 2902 if (formEl && editorEl) { 2903 formEl.addEventListener("submit", (e) => { 2904 e.preventDefault(); 2905 const html = String(editorEl.innerHTML || "").trim(); 2906 const text = String(editorEl.innerText || "").trim(); 2907 const hasImg = Boolean(editorEl.querySelector("img")); 2908 const hasAudio = Boolean(editorEl.querySelector("audio")); 2909 if (!text && !hasImg && !hasAudio) return; 2910 if (!loggedInUser) { 2911 toast("Sign in required", "Sign in to chat."); 2912 return; 2913 } 2914 const currentPost = posts.get(pid) || null; 2915 if (currentPost && String(currentPost.mode || currentPost.chatMode || "").toLowerCase() === "walkie") { 2916 toast("Walkie Talkie", "This hive is walkie-only. Hold ~ to talk."); 2917 return; 2918 } 2919 if (currentPost?.readOnly && !isStaffRole(loggedInRole)) { 2920 toast("Read-only", "This hive is read-only."); 2921 return; 2922 } 2923 if (currentPost?.deleted) { 2924 toast("Unavailable", "This post was deleted."); 2925 return; 2926 } 2927 const wantsMod = Boolean(canModerate && modToggleEl instanceof HTMLInputElement && modToggleEl.checked); 2928 ws.send(JSON.stringify({ type: "typing", postId: pid, isTyping: false })); 2929 ws.send(JSON.stringify({ type: "chatMessage", postId: pid, text, html, replyToId: "", asMod: wantsMod })); 2930 editorEl.innerHTML = ""; 2931 // Leave global reply-to state alone; this instance panel is independent (MVP). 2932 }); 2933 2934 editorEl.addEventListener("focus", () => { 2935 chatUploadTargetEditor = editorEl; 2936 }); 2937 2938 editorEl.addEventListener("keydown", (e) => { 2939 if (!shouldSubmitChatOnEnter(e)) return; 2940 e.preventDefault(); 2941 formEl.requestSubmit(); 2942 }); 2943 2944 // Allow drag/drop uploads in instance chats too. 2945 try { 2946 installDropUpload(editorEl, { allowImages: true, allowAudio: true }); 2947 } catch { 2948 // ignore 2949 } 2950 } 2951 2952 if (modToggleWrapEl) modToggleWrapEl.classList.toggle("hidden", !canModerate); 2953 2954 // Register + insert. 2955 panelRegistry.set(panelId, { 2956 id: panelId, 2957 title, 2958 icon: "π¬", 2959 source: "core", 2960 role: "aux", 2961 defaultRack: "main", 2962 element: shell, 2963 }); 2964 chatPanelInstances.set(panelId, { postId: pid }); 2965 2966 const options = opts && typeof opts === "object" ? opts : {}; 2967 const docked = Boolean(options.docked); 2968 const sideRack = ensureMainSideRack(); 2969 if (docked) { 2970 // Keep it out of layout; show as orb. 2971 if (sideRack) sideRack.appendChild(shell); 2972 dockPanel(panelId); 2973 } else { 2974 setSideCollapsed(false); 2975 if (sideRack) sideRack.prepend(shell); 2976 rememberPanelLastRack(panelId, "mainSideRack"); 2977 saveRackLayoutState(); 2978 applyDockState(); 2979 syncRackStateFromDom(); 2980 enforceWorkspaceRules(); 2981 } 2982 applyPanelWorkspaceSize(shell); 2983 2984 renderChatPostPanelInstance(panelId, true); 2985 return panelId; 2986 } 2987 2988 function renderTypingIndicatorForPost(postId, targetEl) { 2989 if (!(targetEl instanceof HTMLElement)) return; 2990 const id = String(postId || "").trim(); 2991 if (!id) { 2992 targetEl.textContent = ""; 2993 return; 2994 } 2995 const set = typingUsersByPostId.get(id); 2996 if (!set || set.size === 0) { 2997 targetEl.textContent = ""; 2998 return; 2999 } 3000 const names = Array.from(set.values()).slice(0, 3); 3001 const more = set.size > names.length ? ` +${set.size - names.length}` : ""; 3002 targetEl.textContent = `${names.map((u) => `@${u}`).join(", ")}${more} typing...`; 3003 } 3004 3005 function renderChatPostPanelInstance(panelId, forceScroll) { 3006 const id = String(panelId || "").trim(); 3007 if (!id) return; 3008 const inst = chatPanelInstances.get(id); 3009 if (!inst) return; 3010 const postId = String(inst.postId || "").trim(); 3011 const post = postId ? posts.get(postId) : null; 3012 const root = getPanelElement(id); 3013 if (!(root instanceof HTMLElement)) return; 3014 const metaEl = root.querySelector(".chatMeta"); 3015 const messagesEl = root.querySelector(".chatMessages"); 3016 const typingEl = root.querySelector(".typingIndicator"); 3017 const editorEl = root.querySelector(".chatEditor"); 3018 const sendBtn = root.querySelector("form.chatForm button[type='submit']"); 3019 3020 if (metaEl) { 3021 if (!post) metaEl.textContent = "Hive not found."; 3022 else { 3023 const tags = (post.keywords || []).map((k) => `#${k}`).join(" "); 3024 const author = post.author ? `by @${post.author}` : ""; 3025 const exp = formatCountdown(post.expiresAt); 3026 const ro = post.readOnly ? " | read-only" : ""; 3027 const mode = normalizePostMode(post.mode || post.chatMode || ""); 3028 const modeMeta = 3029 mode === "walkie" 3030 ? " | walkie talkie" 3031 : mode === "stream" 3032 ? ` | stream (${streamKindLabel(post.streamKind || "webcam")})` 3033 : ""; 3034 metaEl.textContent = `${author}${modeMeta}${ro} | ${exp === "permanent" ? "permanent" : `expires in ${exp}`} | ${tags}`.trim(); 3035 } 3036 } 3037 3038 if (!(messagesEl instanceof HTMLElement)) return; 3039 const atBottomBefore = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 24; 3040 3041 if (!post) { 3042 messagesEl.innerHTML = `<div class="small muted">Hive not found.</div>`; 3043 if (typingEl) typingEl.textContent = ""; 3044 return; 3045 } 3046 if (post.deleted) { 3047 messagesEl.innerHTML = `<div class="small muted">Post was deleted.</div>`; 3048 if (typingEl) typingEl.textContent = ""; 3049 return; 3050 } 3051 3052 const isWalkie = String(post.mode || post.chatMode || "").toLowerCase() === "walkie"; 3053 const canChatWrite = Boolean(isStaffRole(loggedInRole) || !post.readOnly); 3054 if (editorEl) editorEl.contentEditable = String(Boolean(canChatWrite && !isWalkie)); 3055 if (sendBtn instanceof HTMLButtonElement) sendBtn.disabled = !(loggedInUser && canChatWrite && !isWalkie); 3056 3057 const modToggleWrapEl = root.querySelector(".chatInstModToggle"); 3058 const modToggleEl = root.querySelector(".chatInstModToggleInput"); 3059 if (modToggleWrapEl) modToggleWrapEl.classList.toggle("hidden", !canModerate); 3060 if (!canModerate && modToggleEl instanceof HTMLInputElement) modToggleEl.checked = false; 3061 3062 const messages = chatByPost.get(post.id) || []; 3063 const ignoreUserSet = new Set( 3064 [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase()) 3065 ); 3066 const selfLower = String(loggedInUser || "").toLowerCase(); 3067 const visibleMessages = messages.filter((m) => { 3068 const fromLower = String(m?.fromUser || "").toLowerCase(); 3069 if (!fromLower || fromLower === selfLower) return true; 3070 return !ignoreUserSet.has(fromLower); 3071 }); 3072 3073 messagesEl.innerHTML = visibleMessages 3074 .map((m, index) => { 3075 const isModMsg = Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod"; 3076 const from = isModMsg ? "MOD" : m.fromUser || ""; 3077 const isYou = loggedInUser && from && from === loggedInUser; 3078 const rail = chatRailClass({ fromUser: from, isModMessage: isModMsg }); 3079 const prev = index > 0 ? visibleMessages[index - 1] : null; 3080 const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); 3081 const mentions = Array.isArray(m.mentions) ? m.mentions.map((u) => String(u || "").toLowerCase()) : []; 3082 const mentionMe = Boolean(loggedInUser && mentions.includes(loggedInUser)); 3083 const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || ""); 3084 const youTag = !isModMsg && isYou ? `<span class="muted">(you)</span>` : ""; 3085 const time = new Date(m.createdAt).toLocaleTimeString(); 3086 const tint = isModMsg ? "" : tintStylesFromHex(getProfile(from).color); 3087 const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; 3088 const content = html ? html : highlightMentionsInText(m.text || ""); 3089 const replyMeta = m.replyTo && typeof m.replyTo === "object" ? m.replyTo : null; 3090 const replyBlock = replyMeta 3091 ? `<div class="chatReplyRef"><span class="small muted">@${escapeHtml(replyMeta.fromUser || "unknown")}</span><div class="small">${escapeHtml( 3092 String(replyMeta.text || "[media]").slice(0, 120) 3093 )}</div></div>` 3094 : ""; 3095 const reacts = renderReactionButtons({ kind: "chat", id: m.id, reactions: m.reactions || {}, postId: post.id }); 3096 const deletedLine = m.deleted 3097 ? `<div class="small muted">message deleted${ 3098 m.deletedBy ? ` by @${escapeHtml(m.deletedBy)}` : "" 3099 } at ${escapeHtml(new Date(Number(m.deletedAt || m.createdAt || Date.now())).toLocaleString())}</div>` 3100 : ""; 3101 const editedLine = 3102 !m.deleted && Number(m.editCount || 0) > 0 3103 ? `<div class="small muted">edited (${Number(m.editCount || 0)}) at ${escapeHtml( 3104 new Date(Number(m.editedAt || m.createdAt || Date.now())).toLocaleTimeString() 3105 )}</div>` 3106 : ""; 3107 const reportAction = 3108 loggedInUser && !m.deleted 3109 ? `<button type="button" class="ghost smallBtn" data-reportchat="${escapeHtml(m.id)}" data-postid="${escapeHtml( 3110 post.id 3111 )}">Report</button>` 3112 : ""; 3113 const deleteAction = 3114 loggedInUser && !m.deleted && (isStaffRole(loggedInRole) || from === loggedInUser) 3115 ? `<button type="button" class="ghost smallBtn" data-delchat="${escapeHtml(m.id)}" data-postid="${escapeHtml( 3116 post.id 3117 )}">Delete</button>` 3118 : ""; 3119 const actions = 3120 reportAction || deleteAction 3121 ? `<div class="chatTools">${reportAction}${deleteAction}</div>` 3122 : ""; 3123 return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""} ${rail} ${isModMsg ? "isModMsg" : ""}" data-msgid="${escapeHtml( 3124 m.id 3125 )}" ${tint}> 3126 <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> 3127 ${replyBlock} 3128 <div class="content">${content}</div> 3129 ${deletedLine}${editedLine} 3130 <div class="chatActionsRow">${reacts}${actions}</div> 3131 </div>`; 3132 }) 3133 .join(""); 3134 3135 for (const contentEl of messagesEl.querySelectorAll(".chatMsg .content")) { 3136 decorateMentionNodesInElement(contentEl); 3137 decorateYouTubeEmbedsInElement(contentEl); 3138 } 3139 3140 renderTypingIndicatorForPost(post.id, typingEl); 3141 3142 if (forceScroll || atBottomBefore) messagesEl.scrollTop = messagesEl.scrollHeight; 3143 } 3144 3145 function renderChatInstancesForPost(postId) { 3146 const pid = String(postId || "").trim(); 3147 if (!pid) return; 3148 for (const [panelId, inst] of chatPanelInstances.entries()) { 3149 if (String(inst?.postId || "") !== pid) continue; 3150 renderChatPostPanelInstance(panelId); 3151 } 3152 } 3153 3154 function setChatInstancePanelPost(panelId, postId, forceScroll = true) { 3155 const pid = String(postId || "").trim(); 3156 const id = String(panelId || "").trim(); 3157 if (!pid || !id) return false; 3158 const inst = chatPanelInstances.get(id); 3159 if (!inst) return false; 3160 const post = posts.get(pid); 3161 if (!post) return false; 3162 inst.postId = pid; 3163 chatPanelInstances.set(id, inst); 3164 const root = getPanelElement(id); 3165 const titleEl = root?.querySelector?.(".panelTitle"); 3166 if (titleEl) titleEl.textContent = post?.title ? `Chat: ${String(post.title).slice(0, 32)}` : "Chat"; 3167 renderChatPostPanelInstance(id, forceScroll); 3168 return true; 3169 } 3170 3171 function nearestVisibleChatInstancePanelId(sourceEl) { 3172 const anchor = sourceEl instanceof HTMLElement ? sourceEl : null; 3173 if (!anchor) return ""; 3174 const anchorRect = anchor.getBoundingClientRect(); 3175 const ax = anchorRect.left + anchorRect.width / 2; 3176 const ay = anchorRect.top + anchorRect.height / 2; 3177 let bestId = ""; 3178 let bestDist = Number.POSITIVE_INFINITY; 3179 for (const [panelId] of chatPanelInstances.entries()) { 3180 const root = getPanelElement(panelId); 3181 if (!(root instanceof HTMLElement)) continue; 3182 if (root.classList.contains("hidden")) continue; 3183 const rect = root.getBoundingClientRect(); 3184 if (rect.width <= 1 || rect.height <= 1) continue; 3185 const cx = rect.left + rect.width / 2; 3186 const cy = rect.top + rect.height / 2; 3187 const dist = Math.hypot(cx - ax, cy - ay); 3188 if (dist < bestDist) { 3189 bestDist = dist; 3190 bestId = panelId; 3191 } 3192 } 3193 return bestId; 3194 } 3195 3196 function panelIdFromSourceElement(sourceEl) { 3197 const el = sourceEl instanceof HTMLElement ? sourceEl : null; 3198 if (!el) return ""; 3199 const panel = el.closest(".rackPanel"); 3200 if (!(panel instanceof HTMLElement)) return ""; 3201 return String(panel.dataset.panelId || "").trim(); 3202 } 3203 3204 function workspaceChatTargetCandidates() { 3205 if (!rackLayoutEnabled) return []; 3206 const left = ensureWorkspaceLeftRack(); 3207 const right = ensureWorkspaceRightRack(); 3208 if (!left || !right) return []; 3209 const slots = [left, right]; 3210 const out = []; 3211 for (const slot of slots) { 3212 const panel = slot.querySelector?.(":scope > .rackPanel:not(.hidden)"); 3213 if (!(panel instanceof HTMLElement)) continue; 3214 const panelId = String(panel.dataset.panelId || "").trim(); 3215 if (!panelId) continue; 3216 if (panelId === "chat") { 3217 out.push({ kind: "main", panelId, element: panel }); 3218 continue; 3219 } 3220 if (panelId.startsWith("chat:post:") && chatPanelInstances.has(panelId)) { 3221 out.push({ kind: "instance", panelId, element: panel }); 3222 } 3223 } 3224 return out; 3225 } 3226 3227 function chooseWorkspaceChatTarget(sourceEl) { 3228 const candidates = workspaceChatTargetCandidates(); 3229 if (!candidates.length) return null; 3230 const anchor = sourceEl instanceof HTMLElement ? sourceEl : null; 3231 if (!anchor) return candidates[0] || null; 3232 const anchorRect = anchor.getBoundingClientRect(); 3233 const ax = anchorRect.left + anchorRect.width / 2; 3234 const ay = anchorRect.top + anchorRect.height / 2; 3235 let best = null; 3236 let bestDist = Number.POSITIVE_INFINITY; 3237 for (const candidate of candidates) { 3238 const rect = candidate.element.getBoundingClientRect(); 3239 const cx = rect.left + rect.width / 2; 3240 const cy = rect.top + rect.height / 2; 3241 const dist = Math.hypot(cx - ax, cy - ay); 3242 if (dist < bestDist) { 3243 bestDist = dist; 3244 best = candidate; 3245 } 3246 } 3247 return best || candidates[0] || null; 3248 } 3249 3250 function chooseReferrerPanelForChatSplit(sourceEl) { 3251 const sourcePanelId = panelIdFromSourceElement(sourceEl); 3252 if (sourcePanelId && sourcePanelId !== "chat" && !sourcePanelId.startsWith("chat:")) return sourcePanelId; 3253 3254 const left = ensureWorkspaceLeftRack(); 3255 const right = ensureWorkspaceRightRack(); 3256 const inWorkspace = []; 3257 const leftPanel = left?.querySelector?.(":scope > .rackPanel:not(.hidden)"); 3258 const rightPanel = right?.querySelector?.(":scope > .rackPanel:not(.hidden)"); 3259 if (leftPanel instanceof HTMLElement) inWorkspace.push(String(leftPanel.dataset.panelId || "").trim()); 3260 if (rightPanel instanceof HTMLElement) inWorkspace.push(String(rightPanel.dataset.panelId || "").trim()); 3261 for (const id of inWorkspace) { 3262 if (!id || id === "chat" || id.startsWith("chat:")) continue; 3263 return id; 3264 } 3265 3266 const activePrimary = readWorkspaceActivePrimary(); 3267 if (activePrimary && activePrimary !== "chat" && !activePrimary.startsWith("chat:")) return activePrimary; 3268 3269 if (getPanelElement("hives")) return "hives"; 3270 return ""; 3271 } 3272 3273 function ensureChatWorkspaceSplit(sourceEl) { 3274 if (!rackLayoutEnabled || isMobileSwipeMode()) return false; 3275 if (!chatPanelEl) return false; 3276 const existingTarget = chooseWorkspaceChatTarget(sourceEl); 3277 if (existingTarget) return true; 3278 3279 const referrerPanelId = chooseReferrerPanelForChatSplit(sourceEl); 3280 if (referrerPanelId) { 3281 restorePanelToWorkspaceSlot(referrerPanelId, "workspaceLeftSlot"); 3282 writeWorkspaceActivePrimary(referrerPanelId); 3283 } 3284 if (isDocked("chat")) undockPanel("chat"); 3285 restorePanelToWorkspaceSlot("chat", "workspaceRightSlot"); 3286 writeWorkspaceActivePrimary(referrerPanelId || "chat"); 3287 return true; 3288 } 3289 3290 function applyPluginPresetHint(panelDef) { 3291 if (!rackLayoutEnabled) return; 3292 const id = String(panelDef?.id || "").trim(); 3293 if (!id) return; 3294 if (isDocked(id)) return; 3295 const presetId = rackLayoutState?.presetId || ""; 3296 const hint = panelDef?.presetHints && typeof panelDef.presetHints === "object" ? panelDef.presetHints[presetId] : null; 3297 const place = hint && typeof hint === "object" ? String(hint.place || "") : ""; 3298 if (place === "docked.bottom") { 3299 dockPanel(id); 3300 return; 3301 } 3302 if (place === "main" || place === "right") { 3303 const rack = place === "main" ? ensureMainSideRack() : ensureRightRack(); 3304 const el = getPanelElement(id); 3305 if (rack && el) rack.appendChild(el); 3306 } 3307 } 3308 3309 function enableRackDnD() { 3310 if (!rackLayoutEnabled) return; 3311 const pluginWidgets = ensurePluginRackWidgetsRack(); 3312 const workspaceRack = ensureWorkspaceStripRack(); 3313 const right = ensureRightRack(); 3314 const left = ensureWorkspaceLeftRack(); 3315 const rightWorkspace = ensureWorkspaceRightRack(); 3316 const side = ensureMainSideRack(); 3317 if (!workspaceInfiniteMode() && (!right || !left || !rightWorkspace || !side)) return; 3318 const racks = workspaceInfiniteMode() 3319 ? [workspaceRack, pluginWidgets].filter((x) => x instanceof HTMLElement) 3320 : [left, rightWorkspace, side, right, pluginWidgets].filter((x) => x instanceof HTMLElement); 3321 3322 // Guard against double-install if initRackLayout is called more than once. 3323 if (appRoot?.dataset?.rackDnd === "1") return; 3324 if (appRoot) appRoot.dataset.rackDnd = "1"; 3325 3326 let draggingEl = null; 3327 let placeholderEl = null; 3328 let pointerId = null; 3329 let dragOffset = { x: 0, y: 0 }; 3330 let draggingPanelId = ""; 3331 let activeRack = null; 3332 let originRack = null; 3333 let originBefore = null; 3334 3335 const cancelDrag = () => { 3336 if (!draggingEl) return; 3337 cleanup(); 3338 enforceWorkspaceRules(); 3339 }; 3340 3341 const cleanup = () => { 3342 if (appRoot) appRoot.classList.remove("rackIsDragging"); 3343 if (draggingEl) { 3344 draggingEl.classList.remove("rackDragging"); 3345 draggingEl.style.position = ""; 3346 draggingEl.style.left = ""; 3347 draggingEl.style.top = ""; 3348 draggingEl.style.width = ""; 3349 draggingEl.style.zIndex = ""; 3350 draggingEl.style.pointerEvents = ""; 3351 } 3352 if (dockHotbarEl) dockHotbarEl.classList.remove("dockTarget"); 3353 if (placeholderEl && placeholderEl.parentElement) placeholderEl.parentElement.removeChild(placeholderEl); 3354 draggingEl = null; 3355 placeholderEl = null; 3356 pointerId = null; 3357 draggingPanelId = ""; 3358 activeRack = null; 3359 originRack = null; 3360 originBefore = null; 3361 }; 3362 3363 const siblings = (rack) => Array.from(rack.querySelectorAll(".rackPanel")).filter((el) => el !== draggingEl && el !== placeholderEl); 3364 3365 const insertPlaceholderAt = (rack, y) => { 3366 const items = siblings(rack); 3367 for (const el of items) { 3368 const r = el.getBoundingClientRect(); 3369 const mid = r.top + r.height / 2; 3370 if (y < mid) { 3371 rack.insertBefore(placeholderEl, el); 3372 return; 3373 } 3374 } 3375 rack.appendChild(placeholderEl); 3376 }; 3377 3378 const rackAtPoint = (x, y) => { 3379 for (const r of racks) { 3380 const rect = r.getBoundingClientRect(); 3381 if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return r; 3382 } 3383 return null; 3384 }; 3385 3386 const onMove = (e) => { 3387 if (!draggingEl || e.pointerId !== pointerId) return; 3388 e.preventDefault(); 3389 const x = e.clientX - dragOffset.x; 3390 const y = e.clientY - dragOffset.y; 3391 draggingEl.style.left = `${x}px`; 3392 draggingEl.style.top = `${y}px`; 3393 3394 const targetRack = rackAtPoint(e.clientX, e.clientY) || activeRack; 3395 if (targetRack && placeholderEl && placeholderEl.parentElement !== targetRack) { 3396 targetRack.appendChild(placeholderEl); 3397 } 3398 if (targetRack) { 3399 activeRack = targetRack; 3400 insertPlaceholderAt(targetRack, e.clientY); 3401 } 3402 3403 if (dockHotbarEl) { 3404 const nearBottom = e.clientY > window.innerHeight - 90; 3405 dockHotbarEl.classList.toggle("dockTarget", Boolean(nearBottom)); 3406 if (nearBottom) showHotbar(true); 3407 } 3408 }; 3409 3410 const onUp = (e) => { 3411 if (!draggingEl || e.pointerId !== pointerId) return; 3412 e.preventDefault(); 3413 const targetRack = placeholderEl?.parentElement || activeRack; 3414 if (targetRack && placeholderEl && placeholderEl.parentElement === targetRack) { 3415 if (workspaceInfiniteMode()) { 3416 targetRack.insertBefore(draggingEl, placeholderEl); 3417 if (targetRack.id === "pluginRackWidgetsRack") draggingEl.classList.add("pluginRackWidget"); 3418 } else { 3419 const isWorkspaceSlot = targetRack.id === "workspaceLeftSlot" || targetRack.id === "workspaceRightSlot"; 3420 const isRightRackSlot = targetRack.id === "rightRack"; 3421 const isSideRackSlot = targetRack.id === "mainSideRack"; 3422 const isPluginRackWidgets = targetRack.id === "pluginRackWidgetsRack"; 3423 const isSkinnyRackSlot = isRightRackSlot || isSideRackSlot; 3424 const skinnyOk = panelIsSkinnyCapable(draggingPanelId); 3425 3426 if (isPluginRackWidgets && !panelIsHostableInPluginRack(draggingPanelId)) { 3427 toast("Can't place there", `${panelTitle(draggingPanelId)} can't be hosted in Plugin Rack.`); 3428 if (originRack) { 3429 if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(draggingEl, originBefore); 3430 else originRack.appendChild(draggingEl); 3431 } 3432 cleanup(); 3433 syncRackStateFromDom(); 3434 enforceWorkspaceRules(); 3435 return; 3436 } 3437 3438 // Only skinny-capable panels may live in skinny columns (side / right racks). 3439 if (isSkinnyRackSlot && !skinnyOk) { 3440 toast("Can't place there", `${panelTitle(draggingPanelId)} can't be placed in a skinny rack.`); 3441 if (originRack) { 3442 if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(draggingEl, originBefore); 3443 else originRack.appendChild(draggingEl); 3444 } 3445 cleanup(); 3446 syncRackStateFromDom(); 3447 enforceWorkspaceRules(); 3448 return; 3449 } 3450 3451 if (isWorkspaceSlot || isRightRackSlot) { 3452 const existing = Array.from(targetRack.querySelectorAll(":scope > .rackPanel")).find((x) => x !== draggingEl); 3453 targetRack.insertBefore(draggingEl, placeholderEl); 3454 // Swap if occupied: send the previous occupant back to the origin rack position. 3455 if (existing && originRack) { 3456 if (originBefore && originBefore.parentElement === originRack) originRack.insertBefore(existing, originBefore); 3457 else originRack.appendChild(existing); 3458 } 3459 } else { 3460 targetRack.insertBefore(draggingEl, placeholderEl); 3461 } 3462 if (isPluginRackWidgets) draggingEl.classList.add("pluginRackWidget"); 3463 } 3464 } 3465 const shouldDock = Boolean(dockHotbarEl && e.clientY > window.innerHeight - 90); 3466 const dockId = draggingPanelId; 3467 const droppedEl = draggingEl; 3468 const droppedRackId = String(targetRack?.id || ""); 3469 cleanup(); 3470 if (shouldDock && dockId) dockPanel(dockId); 3471 if (!shouldDock && droppedEl instanceof HTMLElement) applyPanelWorkspaceSize(droppedEl); 3472 syncRackStateFromDom(); 3473 enforceWorkspaceRules(); 3474 }; 3475 3476 // Use window-level listeners so cross-rack dragging stays responsive even when the cursor passes over gaps/resizers. 3477 window.addEventListener("pointermove", onMove); 3478 window.addEventListener("pointerup", onUp); 3479 window.addEventListener("pointercancel", onUp); 3480 // Extra safety: pointer events can fail to deliver pointerup if the mouse is released outside the window. 3481 window.addEventListener("blur", cancelDrag); 3482 window.addEventListener("mouseup", cancelDrag); 3483 window.addEventListener("touchend", cancelDrag, { passive: true }); 3484 document.addEventListener("visibilitychange", () => { 3485 if (document.visibilityState !== "visible") cancelDrag(); 3486 }); 3487 window.addEventListener("keydown", (e) => { 3488 if (e.key === "Escape") cancelDrag(); 3489 }); 3490 3491 const onDown = (e) => { 3492 const btn = e.target.closest?.("[data-rackdrag]"); 3493 if (!btn) return; 3494 const el = btn.closest?.(".rackPanel"); 3495 if (!(el instanceof HTMLElement)) return; 3496 if (el.classList.contains("hidden")) return; 3497 3498 e.preventDefault(); 3499 // If a drag somehow got stuck, start clean. 3500 cleanup(); 3501 if (appRoot) appRoot.classList.add("rackIsDragging"); 3502 draggingEl = el; 3503 draggingPanelId = String(el.dataset.panelId || ""); 3504 pointerId = e.pointerId; 3505 draggingEl.setPointerCapture?.(pointerId); 3506 3507 activeRack = el.parentElement; 3508 originRack = activeRack; 3509 originBefore = draggingEl.nextSibling; 3510 const rect = draggingEl.getBoundingClientRect(); 3511 dragOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; 3512 3513 placeholderEl = document.createElement("div"); 3514 placeholderEl.className = "rackPlaceholder"; 3515 placeholderEl.style.height = `${Math.max(40, Math.round(rect.height))}px`; 3516 3517 (activeRack || main).insertBefore(placeholderEl, draggingEl.nextSibling); 3518 3519 draggingEl.classList.add("rackDragging"); 3520 draggingEl.style.position = "fixed"; 3521 draggingEl.style.left = `${rect.left}px`; 3522 draggingEl.style.top = `${rect.top}px`; 3523 draggingEl.style.width = `${rect.width}px`; 3524 draggingEl.style.zIndex = "80"; 3525 draggingEl.style.pointerEvents = "none"; 3526 }; 3527 3528 // Delegate to the app root so panels can be dragged regardless of which rack they're currently in. 3529 (appRoot || document).addEventListener("pointerdown", onDown); 3530 } 3531 3532 function initRackLayout() { 3533 rackLayoutEnabled = readRackLayoutEnabled(); 3534 let hadState = false; 3535 try { 3536 hadState = Boolean(localStorage.getItem(RACK_LAYOUT_STATE_KEY)); 3537 } catch { 3538 hadState = false; 3539 } 3540 rackLayoutState = loadRackLayoutState(); 3541 // Normalize older preset ids in persisted state. 3542 rackLayoutState.presetId = resolvePresetKey(rackLayoutState.presetId); 3543 3544 if (toggleRackLayoutEl) { 3545 toggleRackLayoutEl.checked = rackLayoutEnabled; 3546 // Hide/disable the toggle while rack mode is forced on. 3547 if (FORCE_RACK_MODE) { 3548 toggleRackLayoutEl.checked = true; 3549 toggleRackLayoutEl.disabled = true; 3550 const row = toggleRackLayoutEl.closest?.("label"); 3551 if (row) row.classList.add("hidden"); 3552 const toggleBtn = document.getElementById("toggleRackLayoutBtn"); 3553 if (toggleBtn) toggleBtn.classList.add("hidden"); 3554 } else { 3555 toggleRackLayoutEl.onchange = () => { 3556 writeRackLayoutEnabled(Boolean(toggleRackLayoutEl.checked)); 3557 // Reload is the simplest safe path while the feature is in flux. 3558 location.reload(); 3559 }; 3560 } 3561 } 3562 3563 if (layoutPresetEl) { 3564 updateLayoutPresetOptions(); 3565 layoutPresetEl.value = resolvePresetKey(rackLayoutState.presetId || "onboardingDefault"); 3566 layoutPresetEl.disabled = !rackLayoutEnabled; 3567 layoutPresetEl.onchange = () => { 3568 if (!rackLayoutEnabled) return; 3569 const next = String(layoutPresetEl.value || "onboardingDefault"); 3570 applyPreset(next); 3571 }; 3572 } 3573 3574 if (!rackLayoutEnabled) { 3575 disableRackLayoutDom(); 3576 setSideCollapsed(false, { persist: false, updateControls: false }); 3577 setRightCollapsed(false, { persist: false, updateControls: false }); 3578 toggleSideRackEl && (toggleSideRackEl.disabled = true); 3579 toggleRightRackEl && (toggleRightRackEl.disabled = true); 3580 showSideRackBtn?.classList.add("hidden"); 3581 showRightRackBtn?.classList.add("hidden"); 3582 showHotbar(false); 3583 return; 3584 } 3585 3586 enableRackLayoutDom(); 3587 3588 // Ensure Plugin Rack exists and is accessible (defaults to hotbar unless explicitly placed). 3589 ensurePluginRackPanel(); 3590 const pluginRackPlaced = 3591 isDocked("pluginRack") || 3592 ["workspaceLeft", "workspaceRight", "side", "right"].some((k) => Array.isArray(rackLayoutState?.racks?.[k]) && rackLayoutState.racks[k].includes("pluginRack")); 3593 if (!pluginRackPlaced) { 3594 rackLayoutState.docked.bottom = Array.isArray(rackLayoutState?.docked?.bottom) ? rackLayoutState.docked.bottom : []; 3595 if (!rackLayoutState.docked.bottom.includes("pluginRack")) rackLayoutState.docked.bottom.push("pluginRack"); 3596 saveRackLayoutState(); 3597 } 3598 3599 // Side racks behave like summonable hotbars: hide/show without changing panel layout state. 3600 toggleSideRackEl && (toggleSideRackEl.disabled = false); 3601 toggleRightRackEl && (toggleRightRackEl.disabled = false); 3602 3603 if (showSideRackBtn) { 3604 showSideRackBtn.classList.remove("hidden"); 3605 showSideRackBtn.onclick = () => setSideCollapsed(false); 3606 } 3607 if (showRightRackBtn) { 3608 showRightRackBtn.classList.remove("hidden"); 3609 showRightRackBtn.onclick = () => setRightCollapsed(false); 3610 } 3611 3612 if (toggleSideRackEl) { 3613 toggleSideRackEl.onchange = () => { 3614 if (!rackLayoutEnabled) return; 3615 setSideCollapsed(!Boolean(toggleSideRackEl.checked)); 3616 }; 3617 } 3618 if (toggleRightRackEl) { 3619 toggleRightRackEl.onchange = () => { 3620 if (!rackLayoutEnabled) return; 3621 setRightCollapsed(!Boolean(toggleRightRackEl.checked)); 3622 }; 3623 } 3624 3625 setSideCollapsed(readBoolPref(RACK_SIDE_COLLAPSED_KEY, false), { persist: false }); 3626 setRightCollapsed(readBoolPref(RACK_RIGHT_COLLAPSED_KEY, false), { persist: false }); 3627 3628 applyRackStateToDom(); 3629 const hasOnboardingPlacement = 3630 (Array.isArray(rackLayoutState?.racks?.workspaceLeft) && rackLayoutState.racks.workspaceLeft.includes("onboarding")) || 3631 (Array.isArray(rackLayoutState?.racks?.workspaceRight) && rackLayoutState.racks.workspaceRight.includes("onboarding")) || 3632 (Array.isArray(rackLayoutState?.racks?.side) && rackLayoutState.racks.side.includes("onboarding")) || 3633 (Array.isArray(rackLayoutState?.racks?.right) && rackLayoutState.racks.right.includes("onboarding")) || 3634 (Array.isArray(rackLayoutState?.docked?.bottom) && rackLayoutState.docked.bottom.includes("onboarding")); 3635 if ((rackLayoutState?.presetId || "") === "onboardingDefault" && !hasOnboardingPlacement) { 3636 applyPreset("onboardingDefault"); 3637 } 3638 installPanelMinimizeButtons(); 3639 if (appRoot && appRoot.dataset.panelSizeControls !== "1") { 3640 appRoot.dataset.panelSizeControls = "1"; 3641 appRoot.addEventListener("click", (e) => { 3642 const btn = e.target?.closest?.("[data-panelsize][data-panelid]"); 3643 if (!(btn instanceof HTMLElement)) return; 3644 const panelId = String(btn.getAttribute("data-panelid") || "").trim(); 3645 const size = String(btn.getAttribute("data-panelsize") || "").trim().toLowerCase(); 3646 if (!panelId) return; 3647 setPanelWorkspaceSize(panelId, size); 3648 applyAllWorkspacePanelSizes(); 3649 }); 3650 } 3651 enableRackDnD(); 3652 installWorkspaceInteractions(); 3653 enforceWorkspaceRules(); 3654 renderProfilePanel(); 3655 3656 // Hotbar interactions 3657 if (dockHotbarEl) { 3658 dockHotbarEl.onmouseenter = null; 3659 dockHotbarEl.onmouseleave = null; 3660 // Click restores a panel to workspace; drag still supports precise placement. 3661 dockHotbarEl.onclick = (e) => { 3662 if (dockHotbarEl.dataset.dragging === "1") return; 3663 const restoreBtn = e.target.closest?.("[data-undock]"); 3664 if (restoreBtn) { 3665 const id = String(restoreBtn.getAttribute("data-undock") || "").trim(); 3666 if (id) restorePanelFromHotbar(id, { userAdded: true }); 3667 return; 3668 } 3669 const plus = e.target.closest?.("[data-hotbarplus]"); 3670 if (!plus) return; 3671 if (hotbarPlusMenuEl) closeHotbarPlusMenu(); 3672 else openHotbarPlusMenu(plus); 3673 }; 3674 } 3675 3676 // Close the "+" menu when clicking elsewhere. 3677 if (appRoot && appRoot.dataset.hotbarPlusClose !== "1") { 3678 appRoot.dataset.hotbarPlusClose = "1"; 3679 document.addEventListener("pointerdown", (e) => { 3680 if (!hotbarPlusMenuEl && !pluginRackAddMenuEl && !workspaceAddMenuEl) return; 3681 const t = e.target; 3682 if (t) { 3683 if (hotbarPlusMenuEl && hotbarPlusMenuEl.contains(t)) return; 3684 if (pluginRackAddMenuEl && pluginRackAddMenuEl.contains(t)) return; 3685 if (workspaceAddMenuEl && workspaceAddMenuEl.contains(t)) return; 3686 if (dockHotbarEl && dockHotbarEl.contains(t)) return; 3687 if (t.closest?.("[data-workspaceadd]")) return; 3688 } 3689 closeHotbarPlusMenu(); 3690 closePluginRackAddMenu(); 3691 closeWorkspaceAddMenu(); 3692 }); 3693 } 3694 3695 // Drag orbs back into the rack to restore (MVP: restore to end of rack). 3696 if (dockHotbarEl) { 3697 let orbDragId = ""; 3698 let orbPointer = null; 3699 let orbStart = null; 3700 let orbMoved = false; 3701 let orbPlaceholder = null; 3702 let orbActiveRack = null; 3703 3704 const lockHotbarVisible = (lock) => { 3705 dockHotbarEl.dataset.lockVisible = lock ? "1" : "0"; 3706 dockHotbarEl.dataset.dragging = lock ? "1" : "0"; 3707 // While dragging an orb, keep both workspace slots visible as drop targets. 3708 if (appRoot) { 3709 if (lock) { 3710 appRoot.classList.add("rackIsDragging"); 3711 appRoot.dataset.orbDragging = "1"; 3712 } else if (appRoot.dataset.orbDragging === "1") { 3713 delete appRoot.dataset.orbDragging; 3714 appRoot.classList.remove("rackIsDragging"); 3715 } 3716 } 3717 if (lock) showHotbar(true); 3718 }; 3719 3720 const resolveOrbDropRack = (panelId, rackEl) => { 3721 const id = String(panelId || "").trim(); 3722 if (!id) return rackEl; 3723 if (workspaceInfiniteMode()) { 3724 if (isRightRackFixedPanel(id)) return ensureRightRack() || rackEl; 3725 if (rackEl && rackEl.id === "pluginRackWidgetsRack" && panelIsHostableInPluginRack(id)) return rackEl; 3726 return ensureWorkspaceStripRack() || rackEl; 3727 } 3728 if (rackEl && rackEl.id === "pluginRackWidgetsRack") { 3729 if (panelIsHostableInPluginRack(id)) return rackEl; 3730 const left = ensureWorkspaceLeftRack(); 3731 const right = ensureWorkspaceRightRack(); 3732 const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; 3733 const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; 3734 return leftEmpty ? left : rightEmpty ? right : left; 3735 } 3736 // Skinny racks (side/right) only allow skinny-capable panels. 3737 if (rackEl && (rackEl.id === "mainSideRack" || rackEl.id === "rightRack")) { 3738 if (panelIsSkinnyCapable(id)) return rackEl; 3739 const left = ensureWorkspaceLeftRack(); 3740 const right = ensureWorkspaceRightRack(); 3741 const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; 3742 const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; 3743 return leftEmpty ? left : rightEmpty ? right : left; 3744 } 3745 if (panelRole(id) !== "primary") return rackEl; 3746 const isWorkspaceSlot = rackEl && (rackEl.id === "workspaceLeftSlot" || rackEl.id === "workspaceRightSlot"); 3747 if (isWorkspaceSlot) return rackEl; 3748 const left = ensureWorkspaceLeftRack(); 3749 const right = ensureWorkspaceRightRack(); 3750 const leftEmpty = left ? left.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; 3751 const rightEmpty = right ? right.querySelectorAll(":scope > .rackPanel:not(.hidden)").length === 0 : false; 3752 return leftEmpty ? left : rightEmpty ? right : left; 3753 }; 3754 3755 const insertOrbPlaceholderAt = (rack, y) => { 3756 if (!(rack instanceof HTMLElement) || !(orbPlaceholder instanceof HTMLElement)) return; 3757 const items = Array.from(rack.querySelectorAll(":scope > .rackPanel")).filter((el) => el !== orbPlaceholder); 3758 for (const el of items) { 3759 const r = el.getBoundingClientRect(); 3760 const mid = r.top + r.height / 2; 3761 if (y < mid) { 3762 rack.insertBefore(orbPlaceholder, el); 3763 return; 3764 } 3765 } 3766 rack.appendChild(orbPlaceholder); 3767 }; 3768 3769 const orbRacks = () => { 3770 if (workspaceInfiniteMode()) { 3771 const workspaceRack = ensureWorkspaceStripRack(); 3772 const pluginWidgetsRack = ensurePluginRackWidgetsRack(); 3773 return [workspaceRack, pluginWidgetsRack].filter((x) => x instanceof HTMLElement); 3774 } 3775 const leftRack = ensureWorkspaceLeftRack(); 3776 const rightWorkspaceRack = ensureWorkspaceRightRack(); 3777 const sideRack = ensureMainSideRack(); 3778 const rightRack = ensureRightRack(); 3779 const pluginWidgetsRack = ensurePluginRackWidgetsRack(); 3780 return [leftRack, rightWorkspaceRack, sideRack, rightRack, pluginWidgetsRack].filter((x) => x instanceof HTMLElement); 3781 }; 3782 3783 const rackAtPoint = (x, y) => { 3784 for (const r of orbRacks()) { 3785 const rect = r.getBoundingClientRect(); 3786 if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return r; 3787 } 3788 return null; 3789 }; 3790 3791 const dropOrbIntoRack = (panelId, targetRack, beforeEl) => { 3792 const id = String(panelId || "").trim(); 3793 if (!id) return; 3794 const rack = resolveOrbDropRack(id, targetRack); 3795 if (!(rack instanceof HTMLElement)) return; 3796 const panelEl = getPanelElement(id); 3797 if (!panelEl) return; 3798 3799 // Restoring into a collapsed rack should uncollapse it (hotbar is a summonable launcher). 3800 if (rack.id === "mainSideRack") setSideCollapsed(false); 3801 if (rack.id === "rightRack") setRightCollapsed(false); 3802 3803 undockPanel(id); 3804 if (!workspaceInfiniteMode()) { 3805 const isWorkspaceSlot = rack.id === "workspaceLeftSlot" || rack.id === "workspaceRightSlot"; 3806 const isRightRackSlot = rack.id === "rightRack"; 3807 if (isWorkspaceSlot) { 3808 const existing = rack.querySelector?.(":scope > .rackPanel:not(.hidden)"); 3809 if (existing instanceof HTMLElement && existing !== panelEl) { 3810 const existingId = String(existing.dataset.panelId || "").trim(); 3811 if (existingId) dockPanel(existingId); 3812 } 3813 } 3814 if (isRightRackSlot) { 3815 const existing = rack.querySelector?.(":scope > .rackPanel:not(.hidden)"); 3816 if (existing instanceof HTMLElement && existing !== panelEl) { 3817 const existingId = String(existing.dataset.panelId || "").trim(); 3818 if (existingId) dockPanel(existingId); 3819 } 3820 } 3821 } 3822 3823 const insertBefore = 3824 beforeEl instanceof HTMLElement && beforeEl.parentElement === rack && beforeEl.classList.contains("rackPanel") 3825 ? beforeEl 3826 : null; 3827 if (panelEl.parentElement !== rack) { 3828 if (insertBefore) rack.insertBefore(panelEl, insertBefore); 3829 else rack.appendChild(panelEl); 3830 } 3831 applyPanelWorkspaceSize(panelEl); 3832 if (rack.id === "pluginRackWidgetsRack") panelEl.classList.add("pluginRackWidget"); 3833 rememberPanelLastRack(id, rack.id); 3834 saveRackLayoutState(); 3835 syncRackStateFromDom(); 3836 enforceWorkspaceRules(); 3837 if (isWorkspaceRackId(rack.id)) { 3838 requestAnimationFrame(() => { 3839 focusWorkspaceArrival(panelEl); 3840 }); 3841 } 3842 }; 3843 3844 dockHotbarEl.addEventListener("pointerdown", (e) => { 3845 const orb = e.target.closest?.("[data-undock]"); 3846 if (!orb) return; 3847 orbDragId = String(orb.getAttribute("data-undock") || ""); 3848 if (!orbDragId) return; 3849 orbPointer = e.pointerId; 3850 orbStart = { x: e.clientX, y: e.clientY }; 3851 orbMoved = false; 3852 orbActiveRack = null; 3853 orb.classList.add("dragging"); 3854 orb.setPointerCapture?.(orbPointer); 3855 lockHotbarVisible(true); 3856 e.preventDefault(); 3857 3858 // Placeholder shows drop position while dragging. 3859 orbPlaceholder = document.createElement("div"); 3860 orbPlaceholder.className = "rackPlaceholder"; 3861 orbPlaceholder.style.height = "52px"; 3862 }); 3863 window.addEventListener("pointermove", (e) => { 3864 if (!orbDragId || e.pointerId !== orbPointer) return; 3865 if (!orbStart) return; 3866 const dx = Math.abs(e.clientX - orbStart.x); 3867 const dy = Math.abs(e.clientY - orbStart.y); 3868 if (dx + dy > 6) orbMoved = true; 3869 3870 if (orbMoved && orbPlaceholder) { 3871 const r = rackAtPoint(e.clientX, e.clientY) || orbActiveRack; 3872 if (r && orbPlaceholder.parentElement !== r) r.appendChild(orbPlaceholder); 3873 if (r) { 3874 orbActiveRack = r; 3875 insertOrbPlaceholderAt(r, e.clientY); 3876 } 3877 } 3878 }); 3879 dockHotbarEl.addEventListener("pointerup", (e) => { 3880 if (!orbDragId || e.pointerId !== orbPointer) return; 3881 const orb = dockHotbarEl.querySelector(`[data-undock="${CSS.escape(orbDragId)}"]`); 3882 if (orb) orb.classList.remove("dragging"); 3883 const targetRack = orbMoved ? (rackAtPoint(e.clientX, e.clientY) || orbActiveRack) : null; 3884 const beforeEl = 3885 orbMoved && orbPlaceholder && targetRack instanceof HTMLElement && orbPlaceholder.parentElement === targetRack 3886 ? orbPlaceholder.nextSibling 3887 : null; 3888 if (orbMoved && targetRack) dropOrbIntoRack(orbDragId, targetRack, beforeEl); 3889 orbDragId = ""; 3890 orbPointer = null; 3891 orbStart = null; 3892 orbMoved = false; 3893 orbActiveRack = null; 3894 if (orbPlaceholder && orbPlaceholder.parentElement) orbPlaceholder.parentElement.removeChild(orbPlaceholder); 3895 orbPlaceholder = null; 3896 lockHotbarVisible(false); 3897 }); 3898 dockHotbarEl.addEventListener("pointercancel", () => { 3899 orbDragId = ""; 3900 orbPointer = null; 3901 orbStart = null; 3902 orbMoved = false; 3903 orbActiveRack = null; 3904 if (orbPlaceholder && orbPlaceholder.parentElement) orbPlaceholder.parentElement.removeChild(orbPlaceholder); 3905 orbPlaceholder = null; 3906 lockHotbarVisible(false); 3907 dockHotbarEl.querySelectorAll(".dockOrb.dragging").forEach((x) => x.classList.remove("dragging")); 3908 }); 3909 } 3910 3911 // Keep hotbar visible in rack mode to avoid layout jank from auto-hide. 3912 if (rackLayoutEnabled) showHotbar(true); 3913 3914 // First enable: seed state from the selected preset so users immediately get a sensible layout. 3915 if (!hadState) { 3916 const preset = resolvePresetKey(rackLayoutState.presetId || (layoutPresetEl ? String(layoutPresetEl.value || "") : "") || "onboardingDefault"); 3917 applyPreset(preset); 3918 } 3919 3920 applyDockState(); 3921 enforceWorkspaceRules(); 3922 } 3923 let activeProfileUsername = ""; 3924 let activeProfile = null; 3925 let lastRequestedProfileUsername = ""; 3926 let isEditingProfile = false; 3927 let replyToMessage = null; 3928 let chatResizeDragging = false; 3929 let chatResizeStartX = 0; 3930 let chatResizeStartWidth = 0; 3931 const CHAT_WIDTH_KEY = "bzl_chatWidth"; 3932 const CHAT_WIDTH_DEFAULT = 640; 3933 let sidebarResizeDragging = false; 3934 let sidebarResizeStartX = 0; 3935 let sidebarResizeStartWidth = 0; 3936 const SIDEBAR_WIDTH_KEY = "bzl_sidebarWidth"; 3937 const SIDEBAR_WIDTH_DEFAULT = 320; 3938 let modResizeDragging = false; 3939 let modResizeStartX = 0; 3940 let modResizeStartWidth = 0; 3941 const MOD_WIDTH_KEY = "bzl_modWidth"; 3942 const MOD_WIDTH_DEFAULT = 360; 3943 let peopleResizeDragging = false; 3944 let peopleResizeStartX = 0; 3945 let peopleResizeStartWidth = 0; 3946 const PEOPLE_WIDTH_KEY = "bzl_peopleWidth"; 3947 const PEOPLE_WIDTH_DEFAULT = 360; 3948 let editContext = null; 3949 let mentionState = { open: false, query: "", selected: 0, items: [], anchorRect: null }; 3950 3951 const STAY_CONNECTED_KEY = "bzl_stayConnected"; 3952 function readStayConnectedPref() { 3953 return readBoolPref(STAY_CONNECTED_KEY, false); 3954 } 3955 function writeStayConnectedPref(on) { 3956 writeBoolPref(STAY_CONNECTED_KEY, Boolean(on)); 3957 } 3958 const NOTIF_SOUND_KEY = "bzl_notif_sound"; 3959 const NOTIF_NEW_HIVE_KEY = "bzl_notif_new_hive"; 3960 const NOTIF_REPLY_PING_KEY = "bzl_notif_reply_ping"; 3961 const NOTIF_MY_HIVE_CHAT_KEY = "bzl_notif_my_hive_chat"; 3962 const NOTIF_RECENT_HIVE_CHAT_KEY = "bzl_notif_recent_hive_chat"; 3963 const RECENT_HIVE_CHAT_WINDOW_MS = 6 * 60 * 60 * 1000; 3964 function readNotifSoundPref() { 3965 return readBoolPref(NOTIF_SOUND_KEY, true); 3966 } 3967 function writeNotifSoundPref(on) { 3968 writeBoolPref(NOTIF_SOUND_KEY, Boolean(on)); 3969 } 3970 function readNotifNewHivePref() { 3971 return readBoolPref(NOTIF_NEW_HIVE_KEY, true); 3972 } 3973 function writeNotifNewHivePref(on) { 3974 writeBoolPref(NOTIF_NEW_HIVE_KEY, Boolean(on)); 3975 } 3976 function readNotifReplyPingPref() { 3977 return readBoolPref(NOTIF_REPLY_PING_KEY, true); 3978 } 3979 function writeNotifReplyPingPref(on) { 3980 writeBoolPref(NOTIF_REPLY_PING_KEY, Boolean(on)); 3981 } 3982 function readNotifMyHiveChatPref() { 3983 return readBoolPref(NOTIF_MY_HIVE_CHAT_KEY, true); 3984 } 3985 function writeNotifMyHiveChatPref(on) { 3986 writeBoolPref(NOTIF_MY_HIVE_CHAT_KEY, Boolean(on)); 3987 } 3988 function readNotifRecentHiveChatPref() { 3989 return readBoolPref(NOTIF_RECENT_HIVE_CHAT_KEY, true); 3990 } 3991 function writeNotifRecentHiveChatPref(on) { 3992 writeBoolPref(NOTIF_RECENT_HIVE_CHAT_KEY, Boolean(on)); 3993 } 3994 function noteOwnRecentChat(postId, atMs) { 3995 const id = String(postId || "").trim(); 3996 if (!id) return; 3997 const at = Number(atMs || Date.now()); 3998 ownRecentChatByPostId.set(id, Number.isFinite(at) ? at : Date.now()); 3999 } 4000 function hasOwnRecentChat(postId) { 4001 const id = String(postId || "").trim(); 4002 if (!id) return false; 4003 const at = Number(ownRecentChatByPostId.get(id) || 0); 4004 if (!at) return false; 4005 if (Date.now() - at > RECENT_HIVE_CHAT_WINDOW_MS) { 4006 ownRecentChatByPostId.delete(id); 4007 return false; 4008 } 4009 return true; 4010 } 4011 function hydrateOwnRecentChatFromHistory(postId, messages) { 4012 const id = String(postId || "").trim(); 4013 if (!id || !Array.isArray(messages) || !messages.length) return; 4014 const selfLower = String(loggedInUser || "").toLowerCase(); 4015 if (!selfLower) return; 4016 for (let i = messages.length - 1; i >= 0; i -= 1) { 4017 const m = messages[i]; 4018 const fromLower = String(m?.fromUser || "").toLowerCase(); 4019 if (!fromLower || fromLower !== selfLower) continue; 4020 noteOwnRecentChat(id, m?.createdAt); 4021 return; 4022 } 4023 } 4024 const ENABLE_HINTS_KEY = "bzl_enableHints"; 4025 const CHAT_ENTER_MODE_KEY = "bzl_chatEnterMode"; // "ctrlEnter" | "enter" 4026 function readHintsEnabledPref() { 4027 const raw = localStorage.getItem(ENABLE_HINTS_KEY); 4028 if (raw == null) return true; 4029 return raw !== "0"; 4030 } 4031 function writeHintsEnabledPref(on) { 4032 const enabled = Boolean(on); 4033 localStorage.setItem(ENABLE_HINTS_KEY, enabled ? "1" : "0"); 4034 appRoot?.classList.toggle("hintsEnabled", enabled); 4035 } 4036 4037 function readChatEnterModePref() { 4038 const raw = readStringPref(CHAT_ENTER_MODE_KEY, "ctrlEnter"); 4039 return raw === "enter" ? "enter" : "ctrlEnter"; 4040 } 4041 4042 function writeChatEnterModePref(mode) { 4043 const next = String(mode || "").trim().toLowerCase(); 4044 writeStringPref(CHAT_ENTER_MODE_KEY, next === "enter" ? "enter" : "ctrlEnter"); 4045 } 4046 4047 let instanceBranding = { title: "Bzl", subtitle: "Ephemeral hives + chat", allowMemberPermanentPosts: false, appearance: {} }; 4048 let userAppearanceOverride = null; 4049 let onboardingState = { 4050 enabled: true, 4051 rulesVersion: 1, 4052 requireAcceptance: false, 4053 blockReadUntilAccepted: false, 4054 acceptedRulesVersion: 0, 4055 acceptedAt: 0, 4056 tutorialVersion: 1, 4057 tutorialCompletedVersion: 0, 4058 selectedRoleIds: [], 4059 needsAcceptance: false, 4060 }; 4061 let serverInfo = null; 4062 let serverHealth = null; 4063 let serverInfoStatus = { loading: false, at: 0, error: "" }; 4064 let pluginAdminStatus = ""; 4065 let pluginAdminBusy = false; 4066 const pluginEnableInFlight = new Set(); 4067 4068 const THEME_PRESETS = [ 4069 { 4070 id: "bzl_original", 4071 name: "Bzl (Original)", 4072 appearance: { 4073 bg: "#060611", 4074 panel: "#0c0c18", 4075 text: "#f6f0ff", 4076 accent: "#ff3ea5", 4077 accent2: "#b84bff", 4078 good: "#3ddc97", 4079 bad: "#ff4d8a", 4080 fontBody: "system", 4081 fontMono: "mono", 4082 mutedPct: 65, 4083 linePct: 10, 4084 panel2Pct: 2, 4085 }, 4086 }, 4087 { 4088 id: "midnight_cyan", 4089 name: "Midnight Cyan", 4090 appearance: { 4091 bg: "#060a12", 4092 panel: "#0a1220", 4093 text: "#eaf4ff", 4094 accent: "#2bf5d6", 4095 accent2: "#4aa0ff", 4096 good: "#2bf5d6", 4097 bad: "#ff5c8a", 4098 fontBody: "clean", 4099 fontMono: "mono", 4100 mutedPct: 64, 4101 linePct: 10, 4102 panel2Pct: 2, 4103 }, 4104 }, 4105 { 4106 id: "warm_amber", 4107 name: "Warm Amber", 4108 appearance: { 4109 bg: "#0b0706", 4110 panel: "#17100e", 4111 text: "#fff2ea", 4112 accent: "#ffb020", 4113 accent2: "#ff6b3d", 4114 good: "#56dba3", 4115 bad: "#ff5b6a", 4116 fontBody: "serif", 4117 fontMono: "mono", 4118 mutedPct: 66, 4119 linePct: 11, 4120 panel2Pct: 3, 4121 }, 4122 }, 4123 { 4124 id: "terminal_green", 4125 name: "Terminal Green", 4126 appearance: { 4127 bg: "#040805", 4128 panel: "#070f08", 4129 text: "#d7ffe6", 4130 accent: "#2bff88", 4131 accent2: "#20d3ff", 4132 good: "#2bff88", 4133 bad: "#ff4d8a", 4134 fontBody: "mono", 4135 fontMono: "mono", 4136 mutedPct: 58, 4137 linePct: 12, 4138 panel2Pct: 2, 4139 }, 4140 }, 4141 { 4142 id: "high_contrast", 4143 name: "High Contrast", 4144 appearance: { 4145 bg: "#000000", 4146 panel: "#0a0a0a", 4147 text: "#ffffff", 4148 accent: "#ffd300", 4149 accent2: "#00d3ff", 4150 good: "#00ff85", 4151 bad: "#ff2d55", 4152 fontBody: "condensed", 4153 fontMono: "mono", 4154 mutedPct: 70, 4155 linePct: 16, 4156 panel2Pct: 3, 4157 }, 4158 }, 4159 { 4160 id: "paper_ink", 4161 name: "Paper Ink", 4162 appearance: { 4163 bg: "#f2ecdf", 4164 panel: "#e7decd", 4165 text: "#2e251d", 4166 accent: "#1d5eff", 4167 accent2: "#b44f2b", 4168 good: "#2f9f63", 4169 bad: "#c84545", 4170 fontBody: "slab", 4171 fontMono: "mono", 4172 mutedPct: 46, 4173 linePct: 14, 4174 panel2Pct: 5, 4175 }, 4176 }, 4177 { 4178 id: "sunset_neon", 4179 name: "Sunset Neon", 4180 appearance: { 4181 bg: "#13070f", 4182 panel: "#1f0c18", 4183 text: "#ffeaf7", 4184 accent: "#ff7a00", 4185 accent2: "#ff2ea8", 4186 good: "#2be7b0", 4187 bad: "#ff4f70", 4188 fontBody: "rounded", 4189 fontMono: "mono", 4190 mutedPct: 62, 4191 linePct: 11, 4192 panel2Pct: 4, 4193 }, 4194 }, 4195 { 4196 id: "ocean_depth", 4197 name: "Ocean Depth", 4198 appearance: { 4199 bg: "#031119", 4200 panel: "#082130", 4201 text: "#d7f2ff", 4202 accent: "#28c7ff", 4203 accent2: "#3d78ff", 4204 good: "#28e6a1", 4205 bad: "#ff5e84", 4206 fontBody: "humanist", 4207 fontMono: "clean", 4208 mutedPct: 61, 4209 linePct: 10, 4210 panel2Pct: 3, 4211 }, 4212 }, 4213 { 4214 id: "retro_arcade", 4215 name: "Retro Arcade", 4216 appearance: { 4217 bg: "#09040f", 4218 panel: "#13081c", 4219 text: "#f7dcff", 4220 accent: "#2dff89", 4221 accent2: "#ffea00", 4222 good: "#32ff9f", 4223 bad: "#ff4f8b", 4224 fontBody: "condensed", 4225 fontMono: "mono", 4226 mutedPct: 60, 4227 linePct: 13, 4228 panel2Pct: 3, 4229 }, 4230 }, 4231 { 4232 id: "forest_moss", 4233 name: "Forest Moss", 4234 appearance: { 4235 bg: "#08120b", 4236 panel: "#102016", 4237 text: "#e8f7ea", 4238 accent: "#80c96a", 4239 accent2: "#2fae95", 4240 good: "#5fd48b", 4241 bad: "#e6626f", 4242 fontBody: "serif", 4243 fontMono: "humanist", 4244 mutedPct: 60, 4245 linePct: 9, 4246 panel2Pct: 4, 4247 }, 4248 }, 4249 { 4250 id: "noir_redline", 4251 name: "Noir Redline", 4252 appearance: { 4253 bg: "#0a090a", 4254 panel: "#151114", 4255 text: "#f8f4f6", 4256 accent: "#ff3d52", 4257 accent2: "#9aa0a6", 4258 good: "#4dd79c", 4259 bad: "#ff3d52", 4260 fontBody: "clean", 4261 fontMono: "mono", 4262 mutedPct: 67, 4263 linePct: 14, 4264 panel2Pct: 3, 4265 }, 4266 }, 4267 { 4268 id: "mist_ui", 4269 name: "Mist UI", 4270 appearance: { 4271 bg: "#f3f7fb", 4272 panel: "#e6edf5", 4273 text: "#1d2833", 4274 accent: "#2e76ff", 4275 accent2: "#34b3ff", 4276 good: "#21a56d", 4277 bad: "#cf4d66", 4278 fontBody: "clean", 4279 fontMono: "system", 4280 mutedPct: 48, 4281 linePct: 12, 4282 panel2Pct: 6, 4283 }, 4284 }, 4285 { 4286 id: "calculator_lcd", 4287 name: "Calculator LCD", 4288 appearance: { 4289 bg: "#0a110e", 4290 panel: "#121c18", 4291 text: "#d8ffe8", 4292 accent: "#98ff9a", 4293 accent2: "#62d2a2", 4294 good: "#98ff9a", 4295 bad: "#ff6b7a", 4296 fontBody: "lcd", 4297 fontMono: "lcd", 4298 mutedPct: 55, 4299 linePct: 12, 4300 panel2Pct: 3, 4301 }, 4302 }, 4303 { 4304 id: "digital_radio", 4305 name: "Digital Radio", 4306 appearance: { 4307 bg: "#041018", 4308 panel: "#0a1c26", 4309 text: "#dff8ff", 4310 accent: "#38e8ff", 4311 accent2: "#6bd0ff", 4312 good: "#43ffd0", 4313 bad: "#ff6d95", 4314 fontBody: "lcd", 4315 fontMono: "lcd", 4316 mutedPct: 57, 4317 linePct: 11, 4318 panel2Pct: 3, 4319 }, 4320 }, 4321 ]; 4322 4323 const THEME_PRESET_GROUP_ORDER = ["Dark", "Light", "Retro", "High-Contrast", "Reading"]; 4324 const THEME_PRESET_GROUP_BY_ID = { 4325 bzl_original: "Dark", 4326 midnight_cyan: "Dark", 4327 ocean_depth: "Dark", 4328 noir_redline: "Dark", 4329 forest_moss: "Dark", 4330 sunset_neon: "Dark", 4331 mist_ui: "Light", 4332 paper_ink: "Reading", 4333 terminal_green: "Retro", 4334 retro_arcade: "Retro", 4335 calculator_lcd: "Retro", 4336 digital_radio: "Retro", 4337 warm_amber: "Reading", 4338 high_contrast: "High-Contrast", 4339 }; 4340 4341 function groupedThemePresetOptionsHtml() { 4342 const groups = new Map(THEME_PRESET_GROUP_ORDER.map((label) => [label, []])); 4343 groups.set("Other", []); 4344 for (const preset of THEME_PRESETS) { 4345 const id = String(preset?.id || "").trim(); 4346 if (!id) continue; 4347 const group = THEME_PRESET_GROUP_BY_ID[id] || "Other"; 4348 if (!groups.has(group)) groups.set(group, []); 4349 groups.get(group).push(preset); 4350 } 4351 return [...groups.entries()] 4352 .map(([groupLabel, presets]) => { 4353 if (!Array.isArray(presets) || !presets.length) return ""; 4354 const opts = presets.map((p) => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)}</option>`).join(""); 4355 return `<optgroup label="${escapeHtml(groupLabel)}">${opts}</optgroup>`; 4356 }) 4357 .join(""); 4358 } 4359 4360 const SFX = { 4361 open: "/assets/sfx/Select_B7.wav", 4362 post: "/assets/sfx/Select_B7.wav", 4363 notif: "/assets/sfx/Select_B7.wav", 4364 ping: "/assets/sfx/Select_C3.wav", 4365 }; 4366 const sfxCache = new Map(); 4367 let pendingOpenSfx = true; 4368 let lastSfxAt = 0; 4369 4370 function getSfx(url) { 4371 const key = String(url || ""); 4372 if (!key) return null; 4373 if (sfxCache.has(key)) return sfxCache.get(key); 4374 const a = new Audio(key); 4375 a.preload = "auto"; 4376 sfxCache.set(key, a); 4377 return a; 4378 } 4379 4380 async function playSfx(name, { volume = 0.32 } = {}) { 4381 const url = SFX[name]; 4382 if (!url) return false; 4383 const now = Date.now(); 4384 if (now - lastSfxAt < 120) return false; 4385 lastSfxAt = now; 4386 4387 const a = getSfx(url); 4388 if (!a) return false; 4389 try { 4390 a.pause(); 4391 a.currentTime = 0; 4392 a.volume = Math.max(0, Math.min(1, Number(volume) || 0.32)); 4393 await a.play(); 4394 return true; 4395 } catch { 4396 return false; 4397 } 4398 } 4399 4400 function normalizeInstanceBranding(raw) { 4401 const title = String(raw?.title || "").replace(/\s+/g, " ").trim().slice(0, 32); 4402 const subtitle = String(raw?.subtitle || "").replace(/\s+/g, " ").trim().slice(0, 80); 4403 const allowMemberPermanentPosts = Boolean(raw?.allowMemberPermanentPosts); 4404 const appearanceRaw = raw?.appearance && typeof raw.appearance === "object" ? raw.appearance : {}; 4405 const bg = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.bg || "")) ? String(appearanceRaw.bg).toLowerCase() : "#060611"; 4406 const panel = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.panel || "")) ? String(appearanceRaw.panel).toLowerCase() : "#0c0c18"; 4407 const text = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.text || "")) ? String(appearanceRaw.text).toLowerCase() : "#f6f0ff"; 4408 const accent = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.accent || "")) ? String(appearanceRaw.accent).toLowerCase() : "#ff3ea5"; 4409 const accent2 = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.accent2 || "")) ? String(appearanceRaw.accent2).toLowerCase() : "#b84bff"; 4410 const good = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.good || "")) ? String(appearanceRaw.good).toLowerCase() : "#3ddc97"; 4411 const bad = /^#[0-9a-f]{6}$/i.test(String(appearanceRaw.bad || "")) ? String(appearanceRaw.bad).toLowerCase() : "#ff4d8a"; 4412 const fontBody = ["system", "serif", "mono", "humanist", "rounded", "condensed", "slab", "clean", "lcd"].includes( 4413 String(appearanceRaw.fontBody || "") 4414 ) 4415 ? String(appearanceRaw.fontBody) 4416 : "system"; 4417 const fontMono = ["mono", "system", "humanist", "rounded", "clean", "lcd"].includes(String(appearanceRaw.fontMono || "")) 4418 ? String(appearanceRaw.fontMono) 4419 : "mono"; 4420 const clampPct = (n, fallback) => { 4421 const v = Math.floor(Number(n)); 4422 if (!Number.isFinite(v)) return fallback; 4423 return Math.max(0, Math.min(100, v)); 4424 }; 4425 const mutedPct = clampPct(appearanceRaw.mutedPct, 65); 4426 const linePct = clampPct(appearanceRaw.linePct, 10); 4427 const panel2Pct = clampPct(appearanceRaw.panel2Pct, 2); 4428 const onboardingRaw = raw?.onboarding && typeof raw.onboarding === "object" ? raw.onboarding : {}; 4429 const aboutRaw = onboardingRaw.about && typeof onboardingRaw.about === "object" ? onboardingRaw.about : {}; 4430 const rulesRaw = onboardingRaw.rules && typeof onboardingRaw.rules === "object" ? onboardingRaw.rules : {}; 4431 const roleSelectRaw = onboardingRaw.roleSelect && typeof onboardingRaw.roleSelect === "object" ? onboardingRaw.roleSelect : {}; 4432 const tutorialRaw = onboardingRaw.tutorial && typeof onboardingRaw.tutorial === "object" ? onboardingRaw.tutorial : {}; 4433 const ruleItems = Array.isArray(rulesRaw.items) 4434 ? rulesRaw.items 4435 .map((r, idx) => ({ 4436 id: String(r?.id || `r${idx + 1}`).trim().slice(0, 40), 4437 order: Number.isFinite(Number(r?.order)) ? Math.max(1, Math.floor(Number(r.order))) : idx + 1, 4438 name: String(r?.name || "").trim().slice(0, 60), 4439 shortDescription: String(r?.shortDescription || "").trim().slice(0, 180), 4440 description: typeof r?.description === "string" ? r.description : "", 4441 severity: ["info", "warn", "critical"].includes(String(r?.severity || "").trim().toLowerCase()) 4442 ? String(r.severity).trim().toLowerCase() 4443 : "info", 4444 })) 4445 .filter((r) => r.id) 4446 .slice(0, 200) 4447 .sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || String(a.id || "").localeCompare(String(b.id || ""))) 4448 : []; 4449 return { 4450 title: title || "Bzl", 4451 subtitle: subtitle || "Ephemeral hives + chat", 4452 allowMemberPermanentPosts, 4453 onboarding: { 4454 enabled: Object.prototype.hasOwnProperty.call(onboardingRaw, "enabled") ? Boolean(onboardingRaw.enabled) : true, 4455 about: { 4456 content: typeof aboutRaw.content === "string" ? aboutRaw.content : "", 4457 updatedAt: Number(aboutRaw.updatedAt || 0) || 0, 4458 updatedBy: String(aboutRaw.updatedBy || "").trim().toLowerCase(), 4459 }, 4460 rules: { 4461 version: Math.max(1, Math.floor(Number(rulesRaw.version || 1))), 4462 requireAcceptance: Boolean(rulesRaw.requireAcceptance), 4463 blockReadUntilAccepted: Boolean(rulesRaw.blockReadUntilAccepted), 4464 items: ruleItems, 4465 }, 4466 roleSelect: { 4467 enabled: Object.prototype.hasOwnProperty.call(roleSelectRaw, "enabled") ? Boolean(roleSelectRaw.enabled) : true, 4468 selfAssignableRoleIds: Array.isArray(roleSelectRaw.selfAssignableRoleIds) 4469 ? roleSelectRaw.selfAssignableRoleIds.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean).slice(0, 64) 4470 : [], 4471 }, 4472 tutorial: { 4473 enabled: Object.prototype.hasOwnProperty.call(tutorialRaw, "enabled") ? Boolean(tutorialRaw.enabled) : true, 4474 version: Math.max(1, Math.floor(Number(tutorialRaw.version || 1))), 4475 }, 4476 }, 4477 appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct }, 4478 }; 4479 } 4480 4481 function normalizeOnboardingState(raw) { 4482 const src = raw && typeof raw === "object" ? raw : {}; 4483 return { 4484 enabled: Object.prototype.hasOwnProperty.call(src, "enabled") ? Boolean(src.enabled) : Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.enabled), 4485 rulesVersion: Math.max(1, Math.floor(Number(src.rulesVersion || normalizeInstanceBranding(instanceBranding).onboarding?.rules?.version || 1))), 4486 requireAcceptance: Object.prototype.hasOwnProperty.call(src, "requireAcceptance") 4487 ? Boolean(src.requireAcceptance) 4488 : Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.rules?.requireAcceptance), 4489 blockReadUntilAccepted: Object.prototype.hasOwnProperty.call(src, "blockReadUntilAccepted") 4490 ? Boolean(src.blockReadUntilAccepted) 4491 : Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.rules?.blockReadUntilAccepted), 4492 acceptedRulesVersion: Math.max(0, Math.floor(Number(src.acceptedRulesVersion || 0))), 4493 acceptedAt: Number(src.acceptedAt || 0) || 0, 4494 tutorialVersion: Math.max(1, Math.floor(Number(src.tutorialVersion || normalizeInstanceBranding(instanceBranding).onboarding?.tutorial?.version || 1))), 4495 tutorialCompletedVersion: Math.max(0, Math.floor(Number(src.tutorialCompletedVersion || 0))), 4496 selectedRoleIds: Array.isArray(src.selectedRoleIds) ? src.selectedRoleIds.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean).slice(0, 64) : [], 4497 needsAcceptance: Boolean(src.needsAcceptance), 4498 }; 4499 } 4500 4501 function loadUserAppearanceOverride() { 4502 try { 4503 const raw = localStorage.getItem(USER_APPEARANCE_KEY); 4504 if (!raw) return null; 4505 const parsed = JSON.parse(raw); 4506 if (!parsed || typeof parsed !== "object") return null; 4507 return normalizeInstanceBranding({ appearance: parsed }).appearance; 4508 } catch { 4509 return null; 4510 } 4511 } 4512 4513 function saveUserAppearanceOverride(appearance) { 4514 try { 4515 localStorage.setItem(USER_APPEARANCE_KEY, JSON.stringify(normalizeInstanceBranding({ appearance }).appearance)); 4516 } catch { 4517 // ignore 4518 } 4519 } 4520 4521 function clearUserAppearanceOverride() { 4522 try { 4523 localStorage.removeItem(USER_APPEARANCE_KEY); 4524 } catch { 4525 // ignore 4526 } 4527 } 4528 4529 function effectiveAppearanceForUi() { 4530 const base = normalizeInstanceBranding(instanceBranding).appearance || {}; 4531 if (!userAppearanceOverride || typeof userAppearanceOverride !== "object") return base; 4532 return normalizeInstanceBranding({ appearance: { ...base, ...userAppearanceOverride } }).appearance; 4533 } 4534 4535 function setAppearanceStatus(msg) { 4536 if (!(appearanceStatusEl instanceof HTMLElement)) return; 4537 appearanceStatusEl.textContent = String(msg || ""); 4538 } 4539 4540 function syncAppearanceControlsFromCurrent() { 4541 const a = effectiveAppearanceForUi(); 4542 if (appearanceBgEl) appearanceBgEl.value = a.bg || "#060611"; 4543 if (appearancePanelEl) appearancePanelEl.value = a.panel || "#0c0c18"; 4544 if (appearanceTextEl) appearanceTextEl.value = a.text || "#f6f0ff"; 4545 if (appearanceAccentEl) appearanceAccentEl.value = a.accent || "#ff3ea5"; 4546 if (appearanceAccent2El) appearanceAccent2El.value = a.accent2 || "#b84bff"; 4547 if (appearanceGoodEl) appearanceGoodEl.value = a.good || "#3ddc97"; 4548 if (appearanceBadEl) appearanceBadEl.value = a.bad || "#ff4d8a"; 4549 if (appearanceMutedPctEl) appearanceMutedPctEl.value = String(Number(a.mutedPct ?? 65)); 4550 if (appearanceLinePctEl) appearanceLinePctEl.value = String(Number(a.linePct ?? 10)); 4551 if (appearancePanel2PctEl) appearancePanel2PctEl.value = String(Number(a.panel2Pct ?? 2)); 4552 if (appearanceFontBodyEl) appearanceFontBodyEl.value = a.fontBody || "system"; 4553 if (appearanceFontMonoEl) appearanceFontMonoEl.value = a.fontMono || "mono"; 4554 } 4555 4556 function readAppearanceFromControls() { 4557 return normalizeInstanceBranding({ 4558 appearance: { 4559 bg: String(appearanceBgEl?.value || "").trim(), 4560 panel: String(appearancePanelEl?.value || "").trim(), 4561 text: String(appearanceTextEl?.value || "").trim(), 4562 accent: String(appearanceAccentEl?.value || "").trim(), 4563 accent2: String(appearanceAccent2El?.value || "").trim(), 4564 good: String(appearanceGoodEl?.value || "").trim(), 4565 bad: String(appearanceBadEl?.value || "").trim(), 4566 fontBody: String(appearanceFontBodyEl?.value || "system").trim(), 4567 fontMono: String(appearanceFontMonoEl?.value || "mono").trim(), 4568 mutedPct: String(appearanceMutedPctEl?.value || "").trim(), 4569 linePct: String(appearanceLinePctEl?.value || "").trim(), 4570 panel2Pct: String(appearancePanel2PctEl?.value || "").trim(), 4571 }, 4572 }).appearance; 4573 } 4574 4575 function initAppearanceControls() { 4576 if (!(appearancePresetEl instanceof HTMLSelectElement)) return; 4577 if (appearancePresetEl.dataset.ready === "1") return; 4578 appearancePresetEl.dataset.ready = "1"; 4579 appearancePresetEl.innerHTML = `<option value="">(choose...)</option>${groupedThemePresetOptionsHtml()}`; 4580 userAppearanceOverride = loadUserAppearanceOverride(); 4581 syncAppearanceControlsFromCurrent(); 4582 applyInstanceAppearance(); 4583 4584 appearanceApplyPresetBtn?.addEventListener("click", () => { 4585 const id = String(appearancePresetEl.value || "").trim(); 4586 const preset = THEME_PRESETS.find((p) => p.id === id) || null; 4587 if (!preset) return; 4588 const a = normalizeInstanceBranding({ appearance: preset.appearance || {} }).appearance; 4589 userAppearanceOverride = a; 4590 syncAppearanceControlsFromCurrent(); 4591 applyInstanceAppearance(a); 4592 setAppearanceStatus(`Preset "${preset.name}" applied (preview).`); 4593 }); 4594 4595 appearanceResetPreviewBtn?.addEventListener("click", () => { 4596 userAppearanceOverride = loadUserAppearanceOverride(); 4597 syncAppearanceControlsFromCurrent(); 4598 applyInstanceAppearance(); 4599 setAppearanceStatus("Reset to current saved look."); 4600 }); 4601 4602 appearanceSaveBtn?.addEventListener("click", () => { 4603 const a = readAppearanceFromControls(); 4604 userAppearanceOverride = a; 4605 saveUserAppearanceOverride(a); 4606 applyInstanceAppearance(); 4607 setAppearanceStatus("Saved personal look."); 4608 }); 4609 4610 appearanceClearBtn?.addEventListener("click", () => { 4611 userAppearanceOverride = null; 4612 clearUserAppearanceOverride(); 4613 syncAppearanceControlsFromCurrent(); 4614 applyInstanceAppearance(); 4615 setAppearanceStatus("Using server default look."); 4616 }); 4617 4618 const previewInputs = [ 4619 appearanceBgEl, 4620 appearancePanelEl, 4621 appearanceTextEl, 4622 appearanceAccentEl, 4623 appearanceAccent2El, 4624 appearanceGoodEl, 4625 appearanceBadEl, 4626 appearanceMutedPctEl, 4627 appearanceLinePctEl, 4628 appearancePanel2PctEl, 4629 appearanceFontBodyEl, 4630 appearanceFontMonoEl, 4631 ]; 4632 for (const input of previewInputs) { 4633 input?.addEventListener("input", () => { 4634 const a = readAppearanceFromControls(); 4635 userAppearanceOverride = a; 4636 applyInstanceAppearance(a); 4637 setAppearanceStatus("Previewing changes. Click Save look to keep."); 4638 }); 4639 input?.addEventListener("change", () => { 4640 const a = readAppearanceFromControls(); 4641 userAppearanceOverride = a; 4642 applyInstanceAppearance(a); 4643 setAppearanceStatus("Previewing changes. Click Save look to keep."); 4644 }); 4645 } 4646 } 4647 4648 function applyInstanceAppearance(appearanceOverride = null) { 4649 const override = 4650 appearanceOverride && typeof appearanceOverride === "object" 4651 ? appearanceOverride 4652 : userAppearanceOverride && typeof userAppearanceOverride === "object" 4653 ? userAppearanceOverride 4654 : null; 4655 const b = normalizeInstanceBranding(override ? { ...instanceBranding, appearance: { ...(instanceBranding?.appearance || {}), ...override } } : instanceBranding); 4656 const a = b.appearance || {}; 4657 const fontStacks = { 4658 system: 4659 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"', 4660 serif: 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif', 4661 mono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', 4662 humanist: '"Trebuchet MS", "Segoe UI", Tahoma, Verdana, sans-serif', 4663 rounded: '"Avenir Next Rounded", "Arial Rounded MT Bold", "Nunito", "Quicksand", "Trebuchet MS", sans-serif', 4664 condensed: '"Roboto Condensed", "Arial Narrow", "Liberation Sans Narrow", "Helvetica Neue Condensed", sans-serif', 4665 slab: '"Rockwell", "Roboto Slab", "Bitter", "Courier New", serif', 4666 clean: '"Inter", "Public Sans", "Noto Sans", "Segoe UI", sans-serif', 4667 lcd: '"Orbitron", "Eurostile", "Bank Gothic", "OCR A Std", "Consolas", "Courier New", monospace', 4668 }; 4669 const fontBodyStack = fontStacks[a.fontBody] || fontStacks.system; 4670 const fontMonoStack = fontStacks[a.fontMono] || fontStacks.mono; 4671 document.documentElement.style.setProperty("--bg", a.bg || "#060611"); 4672 document.documentElement.style.setProperty("--panel", a.panel || "#0c0c18"); 4673 document.documentElement.style.setProperty("--text", a.text || "#f6f0ff"); 4674 document.documentElement.style.setProperty("--accent", a.accent || "#ff3ea5"); 4675 document.documentElement.style.setProperty("--accent2", a.accent2 || "#b84bff"); 4676 document.documentElement.style.setProperty("--good", a.good || "#3ddc97"); 4677 document.documentElement.style.setProperty("--bad", a.bad || "#ff4d8a"); 4678 document.documentElement.style.setProperty("--font-body", fontBodyStack); 4679 document.documentElement.style.setProperty("--font-mono", fontMonoStack); 4680 document.documentElement.style.setProperty("--muted-pct", String(Number(a.mutedPct ?? 65))); 4681 document.documentElement.style.setProperty("--line-pct", String(Number(a.linePct ?? 10))); 4682 document.documentElement.style.setProperty("--panel2-pct", String(Number(a.panel2Pct ?? 2))); 4683 if (appearancePresetEl instanceof HTMLSelectElement) syncAppearanceControlsFromCurrent(); 4684 } 4685 4686 function renderInstanceBranding() { 4687 const b = normalizeInstanceBranding(instanceBranding); 4688 if (instanceTitleEl) instanceTitleEl.textContent = b.title; 4689 if (instanceSubtitleEl) instanceSubtitleEl.textContent = b.subtitle; 4690 } 4691 4692 function renderPoweredByVersion() { 4693 if (!poweredByVersionEl) return; 4694 const version = String(serverHealth?.version || "").trim(); 4695 poweredByVersionEl.textContent = version ? `v${version}` : ""; 4696 } 4697 4698 function formatLocalTime(ts) { 4699 const n = Number(ts || 0); 4700 if (!n) return ""; 4701 try { 4702 return new Date(n).toLocaleString(); 4703 } catch { 4704 return ""; 4705 } 4706 } 4707 4708 async function requestServerInfo() { 4709 if (serverInfoStatus.loading) return; 4710 serverInfoStatus = { loading: true, at: Date.now(), error: "" }; 4711 renderModPanel(); 4712 try { 4713 const [infoRes, healthRes] = await Promise.all([ 4714 fetch("/api/info", { cache: "no-store" }), 4715 fetch("/api/health", { cache: "no-store" }) 4716 ]); 4717 if (!infoRes.ok) throw new Error(`Failed to load /api/info (${infoRes.status})`); 4718 if (!healthRes.ok) throw new Error(`Failed to load /api/health (${healthRes.status})`); 4719 serverInfo = await infoRes.json(); 4720 serverHealth = await healthRes.json(); 4721 serverInfoStatus = { loading: false, at: Date.now(), error: "" }; 4722 renderPoweredByVersion(); 4723 renderModPanel(); 4724 } catch (e) { 4725 serverInfoStatus = { loading: false, at: Date.now(), error: e?.message || "Failed to load server info." }; 4726 renderPoweredByVersion(); 4727 renderModPanel(); 4728 } 4729 } 4730 4731 function normalizeDmThread(raw) { 4732 if (!raw || typeof raw !== "object") return null; 4733 const id = String(raw.id || "").trim(); 4734 const other = String(raw.other || "").trim().toLowerCase(); 4735 const status = String(raw.status || "").trim(); 4736 if (!id || !other) return null; 4737 return { 4738 id, 4739 other, 4740 status: status || "unknown", 4741 requestedBy: String(raw.requestedBy || ""), 4742 pendingFor: String(raw.pendingFor || ""), 4743 createdAt: Number(raw.createdAt || 0), 4744 updatedAt: Number(raw.updatedAt || 0), 4745 lastMessageAt: Number(raw.lastMessageAt || 0), 4746 }; 4747 } 4748 4749 function normalizeDmMessage(raw) { 4750 if (!raw || typeof raw !== "object") return null; 4751 const id = String(raw.id || "").trim(); 4752 if (!id) return null; 4753 return { 4754 id, 4755 fromUser: String(raw.fromUser || raw.from || "").trim().toLowerCase(), 4756 asMod: Boolean(raw.asMod) || String(raw.fromUser || raw.from || "").trim().toLowerCase() === "mod", 4757 createdAt: Number(raw.createdAt || 0), 4758 text: typeof raw.text === "string" ? raw.text : "", 4759 html: typeof raw.html === "string" ? raw.html : "", 4760 }; 4761 } 4762 4763 function dmActivityAt(thread) { 4764 if (!thread) return 0; 4765 return Math.max(Number(thread.lastMessageAt || 0), Number(thread.updatedAt || 0), Number(thread.createdAt || 0)); 4766 } 4767 4768 function shortTimeAgo(ts) { 4769 const t = Number(ts || 0); 4770 if (!Number.isFinite(t) || t <= 0) return ""; 4771 const deltaMs = Math.max(0, Date.now() - t); 4772 const mins = Math.floor(deltaMs / 60000); 4773 if (mins < 1) return "now"; 4774 if (mins < 60) return `${mins}m`; 4775 const hours = Math.floor(mins / 60); 4776 if (hours < 24) return `${hours}h`; 4777 const days = Math.floor(hours / 24); 4778 return `${days}d`; 4779 } 4780 4781 function postChatActivityAt(postId, post) { 4782 const id = String(postId || "").trim(); 4783 const list = id ? chatByPost.get(id) : null; 4784 const lastChatAt = 4785 Array.isArray(list) && list.length 4786 ? Math.max( 4787 ...list.map((m) => Math.max(Number(m?.createdAt || 0), Number(m?.editedAt || 0), Number(m?.deletedAt || 0))) 4788 ) 4789 : 0; 4790 return Math.max(lastChatAt, Number(post?.createdAt || 0), Number(post?.updatedAt || 0)); 4791 } 4792 4793 function pushRecentUnique(list, id, limit = CHAT_RECENTS_LIMIT) { 4794 const value = String(id || "").trim(); 4795 if (!value) return list; 4796 const next = [value, ...list.filter((x) => x !== value)]; 4797 if (next.length > limit) next.length = limit; 4798 return next; 4799 } 4800 4801 function touchRecentHiveChat(postId) { 4802 const id = String(postId || "").trim(); 4803 if (!id) return; 4804 recentHiveChatIds = pushRecentUnique(recentHiveChatIds, id); 4805 } 4806 4807 function touchRecentDmChat(threadId) { 4808 const id = String(threadId || "").trim(); 4809 if (!id) return; 4810 recentDmChatThreadIds = pushRecentUnique(recentDmChatThreadIds, id); 4811 } 4812 4813 function activeDmThreadsSorted() { 4814 return dmThreads 4815 .filter((t) => t && String(t.status || "") === "active") 4816 .sort((a, b) => dmActivityAt(b) - dmActivityAt(a)); 4817 } 4818 4819 function blurFocusedChatComposer() { 4820 const activeEl = document.activeElement; 4821 if (!(activeEl instanceof HTMLElement)) return; 4822 if (activeEl === chatEditor || activeEl.closest?.(".chatEditor")) activeEl.blur(); 4823 } 4824 4825 function openChatContextValue(rawValue, opts = null) { 4826 const raw = String(rawValue || "").trim(); 4827 if (!raw) return false; 4828 const options = opts && typeof opts === "object" ? opts : {}; 4829 const preserveFocus = Boolean(options.preserveFocus); 4830 if (raw.startsWith("dm:")) { 4831 const id = raw.slice(3); 4832 if (!id) return false; 4833 openDmThread(id, { preserveFocus }); 4834 return true; 4835 } 4836 if (raw.startsWith("post:")) { 4837 const id = raw.slice(5); 4838 if (!id) return false; 4839 openChat(id, { preserveFocus }); 4840 return true; 4841 } 4842 return false; 4843 } 4844 4845 function renderChatContextSelect() { 4846 if (!(chatContextSelectEl instanceof HTMLSelectElement)) return; 4847 const prevValue = String(chatContextSelectEl.value || "").trim(); 4848 const dmThreadsActive = activeDmThreadsSorted(); 4849 const dmById = new Map(dmThreadsActive.map((t) => [t.id, t])); 4850 recentDmChatThreadIds = recentDmChatThreadIds.filter((id) => dmById.has(id)); 4851 const dmRecent = [activeDmThreadId, ...recentDmChatThreadIds] 4852 .map((id) => dmById.get(String(id || ""))) 4853 .filter(Boolean) 4854 .filter((t, i, arr) => arr.findIndex((x) => x.id === t.id) === i); 4855 4856 const postsById = new Map(Array.from(posts.values()).map((p) => [String(p.id), p])); 4857 const openPanelPostIds = Array.from(chatPanelInstances.values()) 4858 .map((inst) => String(inst?.postId || "").trim()) 4859 .filter(Boolean); 4860 recentHiveChatIds = recentHiveChatIds.filter((id) => { 4861 const p = postsById.get(String(id)); 4862 return Boolean(p && !p.deleted); 4863 }); 4864 const knownChatPostIds = Array.from(chatByPost.keys()).map((id) => String(id || "").trim()).filter(Boolean); 4865 const postRecent = [activeChatPostId, ...openPanelPostIds, ...recentHiveChatIds, ...knownChatPostIds] 4866 .map((id) => postsById.get(String(id || ""))) 4867 .filter((p) => p && !p.deleted) 4868 .filter((p, i, arr) => arr.findIndex((x) => String(x.id) === String(p.id)) === i); 4869 4870 const hasAny = Boolean(dmRecent.length || postRecent.length || activeDmThreadId || activeChatPostId); 4871 if (!hasAny) { 4872 chatContextSelectEl.classList.add("hidden"); 4873 chatContextSelectEl.innerHTML = ""; 4874 return; 4875 } 4876 4877 const activeDmValue = activeDmThreadId ? `dm:${activeDmThreadId}` : ""; 4878 const activePostValue = activeChatPostId ? `post:${activeChatPostId}` : ""; 4879 const selected = activeDmValue || activePostValue || prevValue; 4880 4881 syncingChatContextSelect = true; 4882 chatContextSelectEl.classList.remove("hidden"); 4883 chatContextSelectEl.replaceChildren(); 4884 const topPlaceholder = document.createElement("option"); 4885 topPlaceholder.value = ""; 4886 topPlaceholder.textContent = "Open chats..."; 4887 chatContextSelectEl.appendChild(topPlaceholder); 4888 4889 if (dmRecent.length) { 4890 const dmGroup = document.createElement("optgroup"); 4891 dmGroup.label = "DMs"; 4892 for (const thread of dmRecent) { 4893 const opt = document.createElement("option"); 4894 opt.value = `dm:${String(thread.id || "").trim()}`; 4895 const when = shortTimeAgo(dmActivityAt(thread)); 4896 opt.textContent = `@${String(thread.other || "unknown")}${when ? ` β’ ${when}` : ""}`; 4897 dmGroup.appendChild(opt); 4898 } 4899 chatContextSelectEl.appendChild(dmGroup); 4900 } 4901 4902 if (postRecent.length) { 4903 const postGroup = document.createElement("optgroup"); 4904 postGroup.label = "Hive Chats"; 4905 for (const post of postRecent) { 4906 const postId = String(post.id || "").trim(); 4907 if (!postId) continue; 4908 const opt = document.createElement("option"); 4909 opt.value = `post:${postId}`; 4910 const unread = Number(unreadByPostId.get(postId) || 0); 4911 const unreadLabel = unread > 0 ? ` (${unread})` : ""; 4912 const when = shortTimeAgo(postChatActivityAt(postId, post)); 4913 const mode = normalizePostMode(post.mode || post.chatMode || ""); 4914 const streamLabel = mode === "stream" ? " [stream]" : ""; 4915 opt.textContent = `${postTitle(post)}${streamLabel}${unreadLabel}${when ? ` β’ ${when}` : ""}${post.author ? ` - @${String(post.author || "")}` : ""}`; 4916 postGroup.appendChild(opt); 4917 } 4918 chatContextSelectEl.appendChild(postGroup); 4919 } 4920 4921 chatContextSelectEl.value = 4922 selected && chatContextSelectEl.querySelector(`option[value="${cssEscape(selected)}"]`) ? selected : ""; 4923 syncingChatContextSelect = false; 4924 } 4925 4926 function setDmThreads(list) { 4927 dmThreads = Array.isArray(list) ? list.map(normalizeDmThread).filter(Boolean) : []; 4928 dmThreadsById = new Map(dmThreads.map((t) => [t.id, t])); 4929 if (pendingOpenDmThreadId) { 4930 const pending = dmThreadsById.get(pendingOpenDmThreadId) || null; 4931 if (pending && String(pending.status || "") === "active") { 4932 openDmThread(pending.id); 4933 } 4934 } 4935 if (activeDmThreadId && !dmThreadsById.has(activeDmThreadId)) { 4936 activeDmThreadId = null; 4937 } 4938 renderPeoplePanel(); 4939 renderChatPanel(); 4940 } 4941 4942 function applyChatDock() { 4943 if (!appRoot) return; 4944 appRoot.classList.toggle("chatRight", chatDock === "right"); 4945 } 4946 4947 function upsertDmThread(rawThread) { 4948 const t = normalizeDmThread(rawThread); 4949 if (!t) return; 4950 dmThreadsById.set(t.id, t); 4951 dmThreads = dmThreads.filter((x) => x.id !== t.id); 4952 dmThreads.push(t); 4953 dmThreads.sort((a, b) => dmActivityAt(b) - dmActivityAt(a)); 4954 renderPeoplePanel(); 4955 renderChatPanel(); 4956 } 4957 4958 function setModModalOpen(open) { 4959 if (!modModal) return; 4960 modModal.classList.toggle("hidden", !open); 4961 if (!open) { 4962 modModalContext = null; 4963 if (modModalBody) modModalBody.innerHTML = ""; 4964 if (modModalStatus) modModalStatus.textContent = ""; 4965 if (modModalPrimary) modModalPrimary.classList.remove("hidden"); 4966 } 4967 } 4968 4969 function setMediaModalOpen(open) { 4970 if (!mediaModal) return; 4971 mediaModal.classList.toggle("hidden", !open); 4972 if (!open) { 4973 if (mediaModalImg) mediaModalImg.src = ""; 4974 if (mediaModalOpenLink) mediaModalOpenLink.href = "#"; 4975 if (mediaModalStatus) mediaModalStatus.textContent = ""; 4976 if (mediaModalTitle) mediaModalTitle.textContent = "Media"; 4977 } 4978 } 4979 4980 function setShortcutHelpOpen(open) { 4981 if (!shortcutHelpModal) return; 4982 shortcutHelpModal.classList.toggle("hidden", !open); 4983 } 4984 4985 function openMediaModal(url) { 4986 const src = String(url || "").trim(); 4987 if (!src) return; 4988 if (!mediaModalImg) return; 4989 mediaModalImg.src = src; 4990 if (mediaModalOpenLink) mediaModalOpenLink.href = src; 4991 if (mediaModalStatus) mediaModalStatus.textContent = ""; 4992 setMediaModalOpen(true); 4993 } 4994 4995 function gateTokenLabel(token) { 4996 const t = String(token || "").trim().toLowerCase(); 4997 if (!t) return { label: "", color: "" }; 4998 if (t === "owner" || t === "moderator" || t === "member") return { label: t, color: "" }; 4999 if (t.startsWith("role:")) { 5000 const key = t.slice("role:".length); 5001 const def = roleDefByKey(key); 5002 if (def) return { label: def.label || `role:${key}`, color: def.color || "" }; 5003 return { label: `role:${key}`, color: "" }; 5004 } 5005 return { label: t, color: "" }; 5006 } 5007 5008 function openCollectionGateModal(collectionId) { 5009 if (!canModerate) return; 5010 const id = String(collectionId || ""); 5011 const col = collections.find((c) => c.id === id); 5012 if (!col) { 5013 toast("Collections", "Collection not found."); 5014 return; 5015 } 5016 modModalContext = { kind: "collectionGate", collectionId: id }; 5017 if (modModalTitle) modModalTitle.textContent = `Gate /${col.name || col.id}`; 5018 if (modModalStatus) modModalStatus.textContent = ""; 5019 if (modModalPrimary) modModalPrimary.textContent = "Save"; 5020 5021 const visibility = col.visibility === "gated" ? "gated" : "public"; 5022 const allowed = new Set(Array.isArray(col.allowedRoles) ? col.allowedRoles : []); 5023 const tokens = availableGateTokens(); 5024 5025 const optionsHtml = tokens 5026 .map((token) => { 5027 const meta = gateTokenLabel(token); 5028 const swatch = meta.color ? `<span class="roleSwatch" style="background:${escapeHtml(meta.color)}"></span>` : ""; 5029 const checked = allowed.has(token) ? "checked" : ""; 5030 return `<label class="gateOption"> 5031 <span class="gateOptionLeft">${swatch}<span>${escapeHtml(meta.label)}</span></span> 5032 <input type="checkbox" data-gatetoken="${escapeHtml(token)}" ${checked} /> 5033 </label>`; 5034 }) 5035 .join(""); 5036 5037 if (modModalBody) { 5038 modModalBody.innerHTML = ` 5039 <div class="row" style="gap:12px;align-items:center"> 5040 <label class="gateOption" style="flex:1"> 5041 <span>Public</span> 5042 <input type="radio" name="gateVisibility" value="public" ${visibility === "public" ? "checked" : ""} /> 5043 </label> 5044 <label class="gateOption" style="flex:1"> 5045 <span>Gated</span> 5046 <input type="radio" name="gateVisibility" value="gated" ${visibility === "gated" ? "checked" : ""} /> 5047 </label> 5048 </div> 5049 <div class="small muted">If gated, pick one or more roles that can view this collection.</div> 5050 <div class="gateList" id="gateListWrap">${optionsHtml || `<div class="muted">No roles available.</div>`}</div> 5051 `; 5052 } 5053 setModModalOpen(true); 5054 updateGateModalVisibility(); 5055 } 5056 5057 function openUserRolesModal(username) { 5058 if (!canModerate) return; 5059 const target = String(username || "").toLowerCase(); 5060 if (!target) return; 5061 const member = (peopleMembers || []).find((m) => m && m.username === target); 5062 const assigned = new Set(Array.isArray(member?.customRoles) ? member.customRoles : []); 5063 modModalContext = { kind: "userRoles", username: target }; 5064 if (modModalTitle) modModalTitle.textContent = `Custom roles for @${target}`; 5065 if (modModalPrimary) modModalPrimary.classList.add("hidden"); 5066 if (modModalStatus) modModalStatus.textContent = "Toggles apply immediately."; 5067 5068 const rows = customRoles.length 5069 ? customRoles 5070 .map((r) => { 5071 const checked = assigned.has(r.key) ? "checked" : ""; 5072 const swatch = r.color ? `<span class="roleSwatch" style="background:${escapeHtml(r.color)}"></span>` : ""; 5073 return `<label class="gateOption"> 5074 <span class="gateOptionLeft">${swatch}<span>${escapeHtml(r.label)}</span> <span class="roleKey">${escapeHtml( 5075 r.key 5076 )}</span></span> 5077 <input type="checkbox" data-userrolekey="${escapeHtml(r.key)}" ${checked} /> 5078 </label>`; 5079 }) 5080 .join("") 5081 : `<div class="muted">No custom roles created yet.</div>`; 5082 5083 if (modModalBody) modModalBody.innerHTML = `<div class="gateList">${rows}</div>`; 5084 setModModalOpen(true); 5085 } 5086 5087 function openCollectionCreateModal() { 5088 if (!canModerate) return; 5089 modModalContext = { kind: "collectionCreate" }; 5090 if (modModalTitle) modModalTitle.textContent = "Create collection"; 5091 if (modModalPrimary) modModalPrimary.textContent = "Create"; 5092 if (modModalStatus) modModalStatus.textContent = ""; 5093 if (modModalBody) { 5094 modModalBody.innerHTML = ` 5095 <label> 5096 <span>Name</span> 5097 <input id="modModalCollectionName" maxlength="40" placeholder="Example: music" /> 5098 </label> 5099 <div class="small muted">Collections appear as tabs and can be gated.</div> 5100 `; 5101 } 5102 setModModalOpen(true); 5103 setTimeout(() => document.getElementById("modModalCollectionName")?.focus(), 0); 5104 } 5105 5106 function updateGateModalVisibility() { 5107 const listWrap = document.getElementById("gateListWrap"); 5108 if (!listWrap) return; 5109 const v = String(modModalBody?.querySelector("input[name='gateVisibility']:checked")?.value || "public"); 5110 listWrap.classList.toggle("hidden", v !== "gated"); 5111 } 5112 5113 function getSessionToken() { 5114 try { 5115 return localStorage.getItem(SESSION_TOKEN_KEY) || ""; 5116 } catch { 5117 return ""; 5118 } 5119 } 5120 5121 function setSessionToken(token) { 5122 try { 5123 if (!token) localStorage.removeItem(SESSION_TOKEN_KEY); 5124 else localStorage.setItem(SESSION_TOKEN_KEY, token); 5125 } catch { 5126 // ignore 5127 } 5128 } 5129 5130 function fallbackPeopleFromProfiles() { 5131 const out = []; 5132 for (const [username, p] of Object.entries(profiles || {})) { 5133 if (!username) continue; 5134 out.push({ 5135 username, 5136 image: typeof p?.image === "string" ? p.image : "", 5137 color: typeof p?.color === "string" ? p.color : "", 5138 role: "member", 5139 online: false, 5140 status: "offline" 5141 }); 5142 } 5143 if (loggedInUser && !out.some((m) => m.username === loggedInUser)) { 5144 const me = getProfile(loggedInUser); 5145 out.push({ 5146 username: loggedInUser, 5147 image: me.image || "", 5148 color: me.color || "", 5149 role: loggedInRole || "member", 5150 online: true, 5151 status: "online" 5152 }); 5153 } 5154 out.sort((a, b) => a.username.localeCompare(b.username)); 5155 return out; 5156 } 5157 5158 function ensurePeopleFallback() { 5159 if (Array.isArray(peopleMembers) && peopleMembers.length > 0) return; 5160 peopleMembers = fallbackPeopleFromProfiles(); 5161 } 5162 5163 const toastHost = (() => { 5164 const el = document.createElement("div"); 5165 el.className = "toastHost"; 5166 document.body.appendChild(el); 5167 return el; 5168 })(); 5169 5170 /** @type {Set<string>} */ 5171 const newPostAnimIds = new Set(); 5172 /** @type {Map<string, number>} */ 5173 const buzzTimers = new Map(); 5174 const TTL_PRESET_VALUES = [5, 30, 60, 120, 720, 1440]; 5175 5176 function syncProtectedUi() { 5177 if (!isProtectedEl || !postPasswordEl) return; 5178 const on = Boolean(isProtectedEl.checked); 5179 postPasswordEl.disabled = !on; 5180 if (!on) postPasswordEl.value = ""; 5181 } 5182 5183 function syncComposerModeUi() { 5184 const mode = normalizePostMode(postModeEl?.value || "text"); 5185 if (postModeEl && postModeEl.value !== mode) postModeEl.value = mode; 5186 const showStreamKind = mode === "stream"; 5187 if (streamKindRowEl) streamKindRowEl.classList.toggle("hidden", !showStreamKind); 5188 if (streamKindEl) streamKindEl.value = normalizeStreamKind(streamKindEl.value || "webcam"); 5189 } 5190 5191 function userCanCreatePermanentHive() { 5192 return Boolean(loggedInUser) && (isStaffRole(loggedInRole) || Boolean(normalizeInstanceBranding(instanceBranding).allowMemberPermanentPosts)); 5193 } 5194 5195 function syncTtlUiFromMinutes() { 5196 const minutes = Number(ttlMinutesEl?.value || 60); 5197 const canPermanent = userCanCreatePermanentHive(); 5198 const isPermanent = Number.isFinite(minutes) && minutes <= 0 && canPermanent; 5199 if (ttlPermanentEl instanceof HTMLInputElement) ttlPermanentEl.checked = isPermanent; 5200 if (ttlPresetEl instanceof HTMLSelectElement) { 5201 const exact = TTL_PRESET_VALUES.find((v) => v === Math.floor(minutes)); 5202 if (exact) ttlPresetEl.value = String(exact); 5203 } 5204 } 5205 5206 syncProtectedUi(); 5207 isProtectedEl?.addEventListener("change", () => { 5208 syncProtectedUi(); 5209 if (isProtectedEl?.checked) postPasswordEl?.focus(); 5210 }); 5211 syncComposerModeUi(); 5212 postModeEl?.addEventListener("change", () => syncComposerModeUi()); 5213 ttlPresetEl?.addEventListener("change", () => { 5214 const next = Number(ttlPresetEl.value || 60); 5215 if (Number.isFinite(next) && ttlMinutesEl instanceof HTMLInputElement) ttlMinutesEl.value = String(Math.max(1, Math.min(2880, Math.floor(next)))); 5216 if (ttlPermanentEl instanceof HTMLInputElement) ttlPermanentEl.checked = false; 5217 }); 5218 ttlPermanentEl?.addEventListener("change", () => { 5219 if (!(ttlMinutesEl instanceof HTMLInputElement)) return; 5220 if (ttlPermanentEl.checked && userCanCreatePermanentHive()) { 5221 ttlMinutesEl.value = "0"; 5222 return; 5223 } 5224 if (Number(ttlMinutesEl.value || 0) <= 0) ttlMinutesEl.value = String(Number(ttlPresetEl?.value || 60) || 60); 5225 }); 5226 ttlMinutesEl?.addEventListener("input", () => syncTtlUiFromMinutes()); 5227 5228 function setSidebarHidden(hidden) { 5229 if (!appRoot) return; 5230 appRoot.classList.toggle("sidebarHidden", hidden); 5231 if (toggleSidebarBtn) { 5232 toggleSidebarBtn.textContent = "Hide"; 5233 toggleSidebarBtn.title = "Hide sidebar"; 5234 } 5235 if (showSidebarBtn) { 5236 showSidebarBtn.classList.toggle("hidden", !hidden); 5237 showSidebarBtn.textContent = "Show"; 5238 showSidebarBtn.title = "Show sidebar"; 5239 } 5240 try { 5241 localStorage.setItem("bzl_sidebarHidden", hidden ? "1" : "0"); 5242 } catch { 5243 // ignore 5244 } 5245 } 5246 5247 function getSidebarHidden() { 5248 try { 5249 return localStorage.getItem("bzl_sidebarHidden") === "1"; 5250 } catch { 5251 return false; 5252 } 5253 } 5254 5255 function setPeopleOpen(open) { 5256 const inRackMode = Boolean(appRoot?.classList.contains("rackMode")); 5257 peopleOpen = inRackMode ? true : Boolean(open); 5258 if (!peopleDrawerEl) return; 5259 // In rack mode, Members list is anchored to the right rail. 5260 peopleDrawerEl.classList.toggle("hidden", !peopleOpen && !inRackMode); 5261 if (togglePeopleBtn) { 5262 if (inRackMode) { 5263 togglePeopleBtn.classList.add("hidden"); 5264 } else { 5265 togglePeopleBtn.classList.remove("hidden"); 5266 togglePeopleBtn.textContent = peopleOpen ? "Hide members" : "Members"; 5267 togglePeopleBtn.title = peopleOpen ? "Hide members list" : "Show members list"; 5268 } 5269 } 5270 if (peopleOpen && ws.readyState === WebSocket.OPEN) { 5271 ws.send(JSON.stringify({ type: "peopleList" })); 5272 } 5273 if (inRackMode) return; 5274 try { 5275 localStorage.setItem("bzl_peopleOpen", peopleOpen ? "1" : "0"); 5276 } catch { 5277 // ignore 5278 } 5279 } 5280 5281 function getPeopleOpen() { 5282 try { 5283 return localStorage.getItem("bzl_peopleOpen") === "1"; 5284 } catch { 5285 return false; 5286 } 5287 } 5288 5289 function setComposerOpen(open) { 5290 composerOpen = Boolean(open); 5291 if (pollinatePanel) pollinatePanel.classList.toggle("composerCollapsed", !composerOpen); 5292 if (toggleComposerBtn) { 5293 toggleComposerBtn.textContent = composerOpen ? "Hide Creator" : "New Hive"; 5294 toggleComposerBtn.title = composerOpen ? "Hide hive creator" : "Open hive creator"; 5295 } 5296 renderCenterPanels(); 5297 updateSideRackEmptyState(); 5298 try { 5299 localStorage.setItem("bzl_composerOpen", composerOpen ? "1" : "0"); 5300 } catch { 5301 // ignore 5302 } 5303 } 5304 5305 function getComposerOpen() { 5306 try { 5307 return localStorage.getItem("bzl_composerOpen") === "1"; 5308 } catch { 5309 return false; 5310 } 5311 } 5312 5313 function readStoredChatWidth() { 5314 try { 5315 const raw = Number(localStorage.getItem(CHAT_WIDTH_KEY) || 0); 5316 return Number.isFinite(raw) && raw > 0 ? raw : CHAT_WIDTH_DEFAULT; 5317 } catch { 5318 return CHAT_WIDTH_DEFAULT; 5319 } 5320 } 5321 5322 function readStoredSidebarWidth() { 5323 try { 5324 const raw = Number(localStorage.getItem(SIDEBAR_WIDTH_KEY) || 0); 5325 return Number.isFinite(raw) && raw > 0 ? raw : SIDEBAR_WIDTH_DEFAULT; 5326 } catch { 5327 return SIDEBAR_WIDTH_DEFAULT; 5328 } 5329 } 5330 5331 function readStoredModWidth() { 5332 try { 5333 const raw = Number(localStorage.getItem(MOD_WIDTH_KEY) || 0); 5334 return Number.isFinite(raw) && raw > 0 ? raw : MOD_WIDTH_DEFAULT; 5335 } catch { 5336 return MOD_WIDTH_DEFAULT; 5337 } 5338 } 5339 5340 function readStoredPeopleWidth() { 5341 try { 5342 const raw = Number(localStorage.getItem(PEOPLE_WIDTH_KEY) || 0); 5343 return Number.isFinite(raw) && raw > 0 ? raw : PEOPLE_WIDTH_DEFAULT; 5344 } catch { 5345 return PEOPLE_WIDTH_DEFAULT; 5346 } 5347 } 5348 5349 function clampChatWidth(px) { 5350 const maxByViewport = Math.floor(window.innerWidth * 0.72); 5351 return Math.max(380, Math.min(maxByViewport, Math.floor(Number(px || CHAT_WIDTH_DEFAULT)))); 5352 } 5353 5354 function clampSidebarWidth(px) { 5355 const maxByViewport = Math.floor(window.innerWidth * 0.42); 5356 return Math.max(240, Math.min(maxByViewport, Math.floor(Number(px || SIDEBAR_WIDTH_DEFAULT)))); 5357 } 5358 5359 function clampModWidth(px) { 5360 const maxByViewport = Math.floor(window.innerWidth * 0.44); 5361 return Math.max(280, Math.min(maxByViewport, Math.floor(Number(px || MOD_WIDTH_DEFAULT)))); 5362 } 5363 5364 function clampPeopleWidth(px) { 5365 const maxByViewport = Math.floor(window.innerWidth * 0.62); 5366 return Math.max(320, Math.min(maxByViewport, Math.floor(Number(px || PEOPLE_WIDTH_DEFAULT)))); 5367 } 5368 5369 function applyChatWidth(px, persist = true) { 5370 if (!appRoot) return; 5371 const next = clampChatWidth(px); 5372 appRoot.style.setProperty("--chat-width", `${next}px`); 5373 if (persist) { 5374 try { 5375 localStorage.setItem(CHAT_WIDTH_KEY, String(next)); 5376 } catch { 5377 // ignore 5378 } 5379 } 5380 } 5381 5382 function applySidebarWidth(px, persist = true) { 5383 if (!appRoot) return; 5384 const next = clampSidebarWidth(px); 5385 appRoot.style.setProperty("--sidebar-width", `${next}px`); 5386 if (persist) { 5387 try { 5388 localStorage.setItem(SIDEBAR_WIDTH_KEY, String(next)); 5389 } catch { 5390 // ignore 5391 } 5392 } 5393 } 5394 5395 function applyModWidth(px, persist = true) { 5396 if (!appRoot) return; 5397 const next = clampModWidth(px); 5398 appRoot.style.setProperty("--mod-width", `${next}px`); 5399 if (persist) { 5400 try { 5401 localStorage.setItem(MOD_WIDTH_KEY, String(next)); 5402 } catch { 5403 // ignore 5404 } 5405 } 5406 } 5407 5408 function applyPeopleWidth(px, persist = true) { 5409 const next = clampPeopleWidth(px); 5410 document.documentElement.style.setProperty("--people-width", `${next}px`); 5411 if (persist) { 5412 try { 5413 localStorage.setItem(PEOPLE_WIDTH_KEY, String(next)); 5414 } catch { 5415 // ignore 5416 } 5417 } 5418 } 5419 5420 function canResizeChatNow() { 5421 return !isMobileSwipeMode(); 5422 } 5423 5424 function canResizeSidebarNow() { 5425 return !isMobileSwipeMode(); 5426 } 5427 5428 function canResizeModNow() { 5429 return !isMobileSwipeMode() && canModerate; 5430 } 5431 5432 function canResizePeopleNow() { 5433 return !isMobileSwipeMode(); 5434 } 5435 5436 function stopAnyPanelResize() { 5437 if (!chatResizeDragging && !sidebarResizeDragging && !modResizeDragging && !peopleResizeDragging) return; 5438 chatResizeDragging = false; 5439 sidebarResizeDragging = false; 5440 modResizeDragging = false; 5441 peopleResizeDragging = false; 5442 appRoot?.classList.remove("isResizing"); 5443 } 5444 5445 function startChatResize(clientX) { 5446 if (!canResizeChatNow() || !chatPanelEl) return false; 5447 chatResizeDragging = true; 5448 chatResizeStartX = clientX; 5449 chatResizeStartWidth = chatPanelEl.getBoundingClientRect().width || readStoredChatWidth(); 5450 appRoot?.classList.add("isResizing"); 5451 return true; 5452 } 5453 5454 function startSidebarResize(clientX) { 5455 if (!canResizeSidebarNow() || !sidebarPanelEl || appRoot?.classList.contains("sidebarHidden")) return false; 5456 sidebarResizeDragging = true; 5457 sidebarResizeStartX = clientX; 5458 sidebarResizeStartWidth = sidebarPanelEl.getBoundingClientRect().width || readStoredSidebarWidth(); 5459 appRoot?.classList.add("isResizing"); 5460 return true; 5461 } 5462 5463 function startModResize(clientX) { 5464 if (!canResizeModNow() || !modPanelEl || modPanelEl.classList.contains("hidden")) return false; 5465 modResizeDragging = true; 5466 modResizeStartX = clientX; 5467 modResizeStartWidth = modPanelEl.getBoundingClientRect().width || readStoredModWidth(); 5468 appRoot?.classList.add("isResizing"); 5469 return true; 5470 } 5471 5472 function startPeopleResize(clientX) { 5473 if (!canResizePeopleNow() || !peopleDrawerEl || peopleDrawerEl.classList.contains("hidden")) return false; 5474 peopleResizeDragging = true; 5475 peopleResizeStartX = clientX; 5476 peopleResizeStartWidth = peopleDrawerEl.getBoundingClientRect().width || readStoredPeopleWidth(); 5477 appRoot?.classList.add("isResizing"); 5478 return true; 5479 } 5480 5481 function setEditModalOpen(open) { 5482 if (!editModal) return; 5483 editModal.classList.toggle("hidden", !open); 5484 if (editModalStatus) editModalStatus.textContent = ""; 5485 if (!open) { 5486 editContext = null; 5487 if (editModalEditor) editModalEditor.innerHTML = ""; 5488 if (editModalPostTitleInput) editModalPostTitleInput.value = ""; 5489 if (editModalPostMeta) editModalPostMeta.classList.add("hidden"); 5490 if (editModalKeywordsInput) editModalKeywordsInput.value = ""; 5491 if (editModalCollectionSelect) editModalCollectionSelect.innerHTML = ""; 5492 if (editModalProtectedToggle) editModalProtectedToggle.checked = false; 5493 if (editModalModeSelect) editModalModeSelect.value = "text"; 5494 if (editModalStreamKindSelect) editModalStreamKindSelect.value = "webcam"; 5495 if (editModalStreamKindRow) editModalStreamKindRow.classList.add("hidden"); 5496 if (editModalPasswordInput) editModalPasswordInput.value = ""; 5497 if (editModalPasswordRow) editModalPasswordRow.classList.add("hidden"); 5498 } 5499 } 5500 5501 function parseKeywordsInput(value) { 5502 const raw = String(value || "") 5503 .split(",") 5504 .map((x) => x.trim().toLowerCase()) 5505 .filter(Boolean); 5506 const out = []; 5507 for (const k of raw) { 5508 const cleaned = k.replace(/[^a-z0-9_-]/g, "").slice(0, 20); 5509 if (!cleaned) continue; 5510 if (!out.includes(cleaned)) out.push(cleaned); 5511 if (out.length >= 6) break; 5512 } 5513 return out; 5514 } 5515 5516 function fillCollectionSelect(selectEl, currentId) { 5517 if (!selectEl) return; 5518 const active = activeCollections(); 5519 const current = String(currentId || "") || "general"; 5520 const list = active.length ? active : [{ id: "general", name: "General" }]; 5521 const hasCurrent = list.some((c) => c.id === current); 5522 selectEl.innerHTML = 5523 (hasCurrent ? "" : `<option value="${escapeHtml(current)}">${escapeHtml(current)}</option>`) + 5524 list.map((c) => `<option value="${escapeHtml(c.id)}">${escapeHtml(c.name || c.id)}</option>`).join(""); 5525 selectEl.value = current; 5526 } 5527 5528 function syncEditModalModeUi() { 5529 const mode = normalizePostMode(editModalModeSelect?.value || "text"); 5530 if (editModalModeSelect && editModalModeSelect.value !== mode) editModalModeSelect.value = mode; 5531 if (editModalStreamKindRow) editModalStreamKindRow.classList.toggle("hidden", mode !== "stream"); 5532 if (editModalStreamKindSelect) editModalStreamKindSelect.value = normalizeStreamKind(editModalStreamKindSelect.value || "webcam"); 5533 } 5534 5535 function openEditModalForPost(post) { 5536 if (!post || post.deleted || post.locked) return; 5537 if (!loggedInUser || post.author !== loggedInUser) return; 5538 editContext = { kind: "post", postId: post.id }; 5539 if (editModalTitle) editModalTitle.textContent = "Edit post"; 5540 if (editModalPostTitleRow) editModalPostTitleRow.classList.remove("hidden"); 5541 if (editModalPostMeta) editModalPostMeta.classList.remove("hidden"); 5542 if (editModalPostTitleInput) editModalPostTitleInput.value = String(post.title || "").slice(0, 96); 5543 if (editModalKeywordsInput) editModalKeywordsInput.value = (post.keywords || []).join(", "); 5544 fillCollectionSelect(editModalCollectionSelect, String(post.collectionId || "general")); 5545 if (editModalProtectedToggle) editModalProtectedToggle.checked = Boolean(post.protected); 5546 if (editModalModeSelect) editModalModeSelect.value = normalizePostMode(post.mode || post.chatMode || ""); 5547 if (editModalStreamKindSelect) editModalStreamKindSelect.value = normalizeStreamKind(post.streamKind || "webcam"); 5548 syncEditModalModeUi(); 5549 if (editModalPasswordRow) editModalPasswordRow.classList.toggle("hidden", !Boolean(post.protected)); 5550 if (editModalPasswordInput) editModalPasswordInput.value = ""; 5551 if (editModalEditor) editModalEditor.innerHTML = String(post.contentHtml || "").trim() || escapeHtml(post.content || ""); 5552 setEditModalOpen(true); 5553 setTimeout(() => editModalEditor?.focus(), 0); 5554 } 5555 5556 function openEditModalForChatMessage(message, postId) { 5557 if (!message || message.deleted) return; 5558 if (!loggedInUser || message.fromUser !== loggedInUser) return; 5559 editContext = { kind: "chat", messageId: message.id, postId }; 5560 if (editModalTitle) editModalTitle.textContent = "Edit message"; 5561 if (editModalPostTitleRow) editModalPostTitleRow.classList.add("hidden"); 5562 if (editModalPostTitleInput) editModalPostTitleInput.value = ""; 5563 if (editModalPostMeta) editModalPostMeta.classList.add("hidden"); 5564 if (editModalKeywordsInput) editModalKeywordsInput.value = ""; 5565 if (editModalCollectionSelect) editModalCollectionSelect.innerHTML = ""; 5566 if (editModalProtectedToggle) editModalProtectedToggle.checked = false; 5567 if (editModalModeSelect) editModalModeSelect.value = "text"; 5568 if (editModalStreamKindSelect) editModalStreamKindSelect.value = "webcam"; 5569 syncEditModalModeUi(); 5570 if (editModalPasswordInput) editModalPasswordInput.value = ""; 5571 if (editModalPasswordRow) editModalPasswordRow.classList.add("hidden"); 5572 if (editModalEditor) editModalEditor.innerHTML = String(message.html || "").trim() || escapeHtml(message.text || ""); 5573 setEditModalOpen(true); 5574 setTimeout(() => editModalEditor?.focus(), 0); 5575 } 5576 5577 editModalProtectedToggle?.addEventListener("change", () => { 5578 const on = Boolean(editModalProtectedToggle?.checked); 5579 if (editModalPasswordRow) editModalPasswordRow.classList.toggle("hidden", !on); 5580 if (!on && editModalPasswordInput) editModalPasswordInput.value = ""; 5581 }); 5582 editModalModeSelect?.addEventListener("change", () => syncEditModalModeUi()); 5583 5584 function collectEditorPayload(targetEditor) { 5585 const html = String(targetEditor?.innerHTML || "").trim(); 5586 const text = String(targetEditor?.innerText || "") 5587 .replace(/\s+/g, " ") 5588 .trim(); 5589 const hasImg = Boolean(targetEditor?.querySelector?.("img")); 5590 const hasAudio = Boolean(targetEditor?.querySelector?.("audio")); 5591 return { html, text, hasImg, hasAudio }; 5592 } 5593 5594 function syncProfileSongPreview(url) { 5595 if (!profileThemeSongPreview || !profileThemeSongUrlInput) return; 5596 const safe = asProfileLink(url) || (String(url || "").startsWith("/uploads/") ? String(url || "") : ""); 5597 if (!safe) { 5598 profileThemeSongPreview.classList.add("hidden"); 5599 profileThemeSongPreview.removeAttribute("src"); 5600 profileThemeSongUrlInput.value = ""; 5601 return; 5602 } 5603 profileThemeSongPreview.classList.remove("hidden"); 5604 profileThemeSongPreview.src = safe; 5605 profileThemeSongPreview.load(); 5606 profileThemeSongUrlInput.value = safe; 5607 } 5608 5609 function renderProfileLinksEditor(links) { 5610 if (!profileLinksEditor) return; 5611 const list = normalizeProfileLinks(links); 5612 if (!list.length) { 5613 profileLinksEditor.innerHTML = `<div class="small muted">No links yet.</div>`; 5614 return; 5615 } 5616 profileLinksEditor.innerHTML = list 5617 .map( 5618 (entry, index) => `<div class="profileLinkEditRow"> 5619 <input data-linklabel="${index}" value="${escapeHtml(entry.label)}" maxlength="40" placeholder="Label" /> 5620 <input data-linkurl="${index}" value="${escapeHtml(entry.url)}" maxlength="280" placeholder="https://..." /> 5621 <button type="button" class="ghost smallBtn" data-linkremove="${index}">Remove</button> 5622 </div>` 5623 ) 5624 .join(""); 5625 } 5626 5627 function profileLinksFromEditor() { 5628 if (!profileLinksEditor) return []; 5629 const rows = Array.from(profileLinksEditor.querySelectorAll(".profileLinkEditRow")); 5630 if (!rows.length) return []; 5631 const out = []; 5632 for (const row of rows) { 5633 const label = String(row.querySelector("[data-linklabel]")?.value || "") 5634 .replace(/\s+/g, " ") 5635 .trim() 5636 .slice(0, 40); 5637 const url = asProfileLink(row.querySelector("[data-linkurl]")?.value || ""); 5638 if (!url) continue; 5639 out.push({ label: label || "Link", url }); 5640 if (out.length >= 8) break; 5641 } 5642 return out; 5643 } 5644 5645 function renderProfileCard() { 5646 if (!profileCard) return; 5647 if (!activeProfile || !activeProfile.username) { 5648 profileCard.innerHTML = `<div class="small muted">Profile unavailable.</div>`; 5649 return; 5650 } 5651 const p = normalizeProfileData(activeProfile); 5652 const headerStyle = p.color ? ` style="--profile-accent:${escapeHtml(p.color)}"` : ""; 5653 const pronouns = p.pronouns ? `<div class="small muted pronouns">${escapeHtml(p.pronouns)}</div>` : ""; 5654 const usernameLower = String(p.username || "").toLowerCase(); 5655 const selfLower = String(loggedInUser || "").toLowerCase(); 5656 const canDm = Boolean(loggedInUser && usernameLower && usernameLower !== selfLower); 5657 const ignored = prefSet("ignoredUsers").has(usernameLower); 5658 const blocked = prefSet("blockedUsers").has(usernameLower); 5659 const dmBtn = canDm 5660 ? `<button type="button" class="primary smallBtn" data-dmrequest="${escapeHtml(p.username)}" ${blocked ? "disabled" : ""}>DM</button>` 5661 : ""; 5662 const modDmBtn = canModerate && canDm 5663 ? `<button type="button" class="ghost smallBtn" data-moddm="${escapeHtml(p.username)}">Mod DM</button>` 5664 : ""; 5665 const member = peopleMembers.find((m) => String(m.username || "").toLowerCase() === usernameLower) || null; 5666 const role = roleLabel(member?.role); 5667 const isStaff = role === "owner" || role === "moderator"; 5668 const canMuteUser = Boolean(loggedInUser && usernameLower && usernameLower !== selfLower && !isStaff); 5669 const ignoreBtn = canMuteUser 5670 ? ignored 5671 ? `<button type="button" class="ghost smallBtn" data-unignoreuser="${escapeHtml(p.username)}">Unignore</button>` 5672 : `<button type="button" class="ghost smallBtn" data-ignoreuser="${escapeHtml(p.username)}">Ignore</button>` 5673 : ""; 5674 const blockBtn = canMuteUser 5675 ? blocked 5676 ? `<button type="button" class="ghost smallBtn" data-unblockuser="${escapeHtml(p.username)}">Unblock</button>` 5677 : `<button type="button" class="ghost smallBtn" data-blockuser="${escapeHtml(p.username)}">Block</button>` 5678 : ""; 5679 const blockNote = canDm && blocked ? `<div class="small muted">Blocked: DMs + content hidden.</div>` : ""; 5680 const bio = p.bioHtml ? `<div class="profileBio">${p.bioHtml}</div>` : `<div class="small muted">No bio yet.</div>`; 5681 const theme = p.themeSongUrl ? `<audio controls preload="none" src="${escapeHtml(p.themeSongUrl)}"></audio>` : `<div class="small muted">No theme song set.</div>`; 5682 const links = p.links.length 5683 ? p.links 5684 .map( 5685 (entry) => 5686 `<a class="tag profileLinkTag" href="${escapeHtml(entry.url)}" target="_blank" rel="noopener noreferrer nofollow">${escapeHtml(entry.label)}</a>` 5687 ) 5688 .join("") 5689 : `<div class="small muted">No links yet.</div>`; 5690 profileCard.innerHTML = `<div class="profileHeader"${headerStyle}> 5691 <span class="pfp profileHeroPfp">${p.image ? `<img alt="" src="${escapeHtml(p.image)}" />` : ""}</span> 5692 <div class="profileIdentity"> 5693 <div class="profileHandle" ${p.color ? `style="color:${escapeHtml(safeTextColorFromHex(p.color))}"` : ""}>@${escapeHtml(p.username)}</div> 5694 ${pronouns} 5695 </div> 5696 ${dmBtn || modDmBtn || ignoreBtn || blockBtn ? `<div class="profileActions">${dmBtn}${modDmBtn}${ignoreBtn}${blockBtn}</div>` : ""} 5697 </div> 5698 ${blockNote} 5699 <div class="profileSection"> 5700 <div class="small muted">Bio</div> 5701 ${bio} 5702 </div> 5703 <div class="profileSection"> 5704 <div class="small muted">Theme song</div> 5705 ${theme} 5706 </div> 5707 <div class="profileSection"> 5708 <div class="small muted">Links</div> 5709 <div class="profileLinksWrap">${links}</div> 5710 </div>`; 5711 const bioEl = profileCard.querySelector(".profileBio"); 5712 if (bioEl) decorateYouTubeEmbedsInElement(bioEl); 5713 } 5714 5715 function renderProfileEditor() { 5716 const canEdit = Boolean(loggedInUser && activeProfile && activeProfile.username === loggedInUser); 5717 if (profileEditToggleBtn) profileEditToggleBtn.classList.toggle("hidden", !canEdit); 5718 if (!profileEditPanel || !profilePronounsInput || !profileBioEditor) return; 5719 profileEditPanel.classList.toggle("hidden", !(canEdit && isEditingProfile)); 5720 if (!canEdit || !activeProfile) return; 5721 profilePronounsInput.value = String(activeProfile.pronouns || ""); 5722 profileBioEditor.innerHTML = String(activeProfile.bioHtml || ""); 5723 renderProfileLinksEditor(activeProfile.links); 5724 syncProfileSongPreview(activeProfile.themeSongUrl || ""); 5725 } 5726 5727 function renderCenterPanels() { 5728 // In rack mode, panels are independent. Profile shouldn't "replace" the Hives panel. 5729 if (rackLayoutEnabled) { 5730 if (pollinatePanel) { 5731 pollinatePanel.classList.remove("hidden"); 5732 pollinatePanel.classList.toggle("panelCollapsed", !composerOpen); 5733 pollinatePanel.dataset.panelDisplay = composerOpen ? "full" : "collapsed"; 5734 } 5735 renderProfilePanel(); 5736 updateSideRackEmptyState(); 5737 return; 5738 } 5739 5740 const profileMode = centerView === "profile"; 5741 if (profileViewPanel) profileViewPanel.classList.toggle("hidden", !profileMode); 5742 if (feedEl?.closest("section")) feedEl.closest("section").classList.toggle("hidden", profileMode); 5743 if (pollinatePanel) { 5744 if (profileMode) pollinatePanel.classList.add("hidden"); 5745 else pollinatePanel.classList.toggle("hidden", !composerOpen); 5746 } 5747 if (!profileMode) return; 5748 renderProfilePanel(); 5749 } 5750 5751 function renderProfilePanel() { 5752 if (!profileViewPanel) return; 5753 if (!activeProfileUsername && !activeProfile && loggedInUser) { 5754 activeProfileUsername = String(loggedInUser || "").trim().toLowerCase(); 5755 } 5756 5757 const username = String(activeProfile?.username || activeProfileUsername || "") 5758 .trim() 5759 .toLowerCase(); 5760 5761 if (username) { 5762 // Ensure we always have *some* profile data to show immediately. 5763 if (!activeProfile || String(activeProfile.username || "").toLowerCase() !== username) { 5764 const basic = getProfile(username); 5765 activeProfile = normalizeProfileData({ username, image: basic.image || "", color: basic.color || "" }); 5766 } 5767 5768 // Pull the full profile from the server (bio/links/song) once per username selection. 5769 try { 5770 if (ws?.readyState === WebSocket.OPEN && lastRequestedProfileUsername !== username) { 5771 lastRequestedProfileUsername = username; 5772 ws.send(JSON.stringify({ type: "getUserProfile", username })); 5773 } 5774 } catch { 5775 // ignore 5776 } 5777 } 5778 5779 if (profileViewTitle) profileViewTitle.textContent = username ? `@${username}` : "Profile"; 5780 if (profileViewMeta) profileViewMeta.textContent = username === loggedInUser ? "Your profile" : "Community profile"; 5781 renderProfileCard(); 5782 renderProfileEditor(); 5783 } 5784 5785 function setCenterView(next, username = "") { 5786 if (rackLayoutEnabled) { 5787 // Keep the legacy centerView on "hives" in rack mode; just update profile context. 5788 const wantsProfile = next === "profile"; 5789 if (wantsProfile) { 5790 activeProfileUsername = String(username || activeProfileUsername || "") 5791 .trim() 5792 .toLowerCase(); 5793 isEditingProfile = false; 5794 if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile"; 5795 5796 // Make sure the profile panel is actually visible as its own panel. 5797 undockPanel("profile"); 5798 profileViewPanel.classList.remove("panelCollapsed"); 5799 profileViewPanel.dataset.panelDisplay = "full"; 5800 enforceWorkspaceRules(); 5801 renderProfilePanel(); 5802 } else { 5803 activeProfileUsername = ""; 5804 activeProfile = null; 5805 isEditingProfile = false; 5806 if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile"; 5807 renderProfilePanel(); 5808 } 5809 return; 5810 } 5811 5812 centerView = next === "profile" ? "profile" : "hives"; 5813 if (centerView === "hives") { 5814 activeProfileUsername = ""; 5815 activeProfile = null; 5816 isEditingProfile = false; 5817 if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile"; 5818 } else { 5819 activeProfileUsername = String(username || activeProfileUsername || "") 5820 .trim() 5821 .toLowerCase(); 5822 isEditingProfile = false; 5823 if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile"; 5824 } 5825 renderCenterPanels(); 5826 } 5827 5828 function openUserProfile(username) { 5829 const normalized = String(username || "") 5830 .trim() 5831 .toLowerCase(); 5832 if (!normalized) return; 5833 const basic = getProfile(normalized); 5834 activeProfile = normalizeProfileData({ username: normalized, image: basic.image || "", color: basic.color || "" }); 5835 setCenterView("profile", normalized); 5836 requestAnimationFrame(() => { 5837 const profilePanel = getPanelElement("profile"); 5838 if (profilePanel instanceof HTMLElement) focusWorkspaceArrival(profilePanel); 5839 }); 5840 ws.send(JSON.stringify({ type: "getUserProfile", username: normalized })); 5841 if (isMobileSwipeMode()) setMobileScreen("profile"); 5842 } 5843 5844 function isMobileSwipeMode() { 5845 // Mobile UX should kick in for touch-first devices, including landscape phones. 5846 // (Many phones exceed 760px in landscape, so max-width alone is not sufficient.) 5847 const mqNarrow = "(max-width: 760px)"; 5848 const mqPortrait = "(hover: none) and (pointer: coarse) and (max-width: 900px)"; 5849 const mqLandscape = "(hover: none) and (pointer: coarse) and (max-height: 520px)"; 5850 return window.matchMedia(mqNarrow).matches || window.matchMedia(mqPortrait).matches || window.matchMedia(mqLandscape).matches; 5851 } 5852 5853 function isMobileScreenMode() { 5854 // Keep this consistent with CSS mobile screen media queries. 5855 const mqNarrow = "(max-width: 760px)"; 5856 const mqPortrait = "(hover: none) and (pointer: coarse) and (max-width: 900px)"; 5857 const mqLandscape = "(hover: none) and (pointer: coarse) and (max-height: 520px)"; 5858 return window.matchMedia(mqNarrow).matches || window.matchMedia(mqPortrait).matches || window.matchMedia(mqLandscape).matches; 5859 } 5860 5861 function loadMobileLayout() { 5862 const defaults = () => { 5863 const pinned = ["account", "hives", "chat", "people", "profile"]; 5864 const onboardingEnabled = Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.enabled); 5865 const active = onboardingEnabled ? "onboarding" : pinned[0] || "account"; 5866 return { version: 1, pinned, active, history: [], tools: { composerOpen: false, profileOpen: false, pluginRackOpen: false } }; 5867 }; 5868 const sanitizeId = (id) => { 5869 const raw = String(id || "") 5870 .trim() 5871 .toLowerCase(); 5872 if (!raw) return ""; 5873 if (raw === "maps" || raw === "library") return ""; 5874 if (raw === "mod") return canModerate ? "moderation" : ""; 5875 if (raw === "sidebar") return "account"; 5876 if (raw === "main" || raw === "workspace") return "hives"; 5877 if (raw === "account" || raw === "hives" || raw === "chat" || raw === "people" || raw === "profile" || raw === "onboarding") return raw; 5878 if (raw === "moderation") return canModerate ? "moderation" : ""; 5879 if (panelRegistry.has(raw)) return raw; 5880 return ""; 5881 }; 5882 try { 5883 const raw = localStorage.getItem(MOBILE_LAYOUT_KEY); 5884 if (!raw) return defaults(); 5885 const parsed = JSON.parse(raw); 5886 const pinned = Array.isArray(parsed?.pinned) ? parsed.pinned.map((x) => sanitizeId(x)).filter(Boolean) : null; 5887 const active = sanitizeId(parsed?.active); 5888 const history = Array.isArray(parsed?.history) ? parsed.history.map((x) => sanitizeId(x)).filter(Boolean) : []; 5889 const base = defaults(); 5890 if (pinned && pinned.length) base.pinned = pinned.slice(0, 5); 5891 if (active) base.active = active; 5892 base.history = history.slice(0, 12); 5893 return base; 5894 } catch { 5895 return defaults(); 5896 } 5897 } 5898 5899 function saveMobileLayout(layout) { 5900 try { 5901 localStorage.setItem(MOBILE_LAYOUT_KEY, JSON.stringify(layout)); 5902 } catch { 5903 // ignore 5904 } 5905 } 5906 5907 function availableMobileScreens() { 5908 const out = []; 5909 out.push({ id: "account", title: "Account", core: true }); 5910 if (Boolean(normalizeInstanceBranding(instanceBranding).onboarding?.enabled)) out.push({ id: "onboarding", title: "Onboarding", core: true }); 5911 out.push({ id: "hives", title: "Hives", core: true }); 5912 out.push({ id: "chat", title: "Chat", core: true }); 5913 out.push({ id: "people", title: "Members list", core: true }); 5914 out.push({ id: "profile", title: "Profile", core: true }); 5915 if (canModerate) out.push({ id: "moderation", title: "Moderation", core: true }); 5916 5917 // Plugin screens: include primary-ish panels that exist. 5918 for (const [id, entry] of panelRegistry.entries()) { 5919 if (!id || typeof id !== "string") continue; 5920 if (id === "maps" || id === "library") continue; 5921 if (id === "hives" || id === "chat" || id === "people" || id === "moderation" || id === "profile" || id === "composer" || id === "pluginRack") continue; 5922 const role = typeof entry?.role === "string" ? entry.role : ""; 5923 if (role && role !== "primary") continue; 5924 const hasElement = entry?.element instanceof HTMLElement; 5925 const canRender = typeof pluginPanelDefsByPanelId.get(id)?.render === "function"; 5926 if (!hasElement && !canRender) continue; 5927 out.push({ id, title: panelTitle(id), core: false }); 5928 } 5929 5930 // Prefer stable ordering. 5931 const byTitle = (a, b) => String(a.title || "").localeCompare(String(b.title || "")); 5932 const core = out.filter((x) => x.core).sort(byTitle); 5933 const plugins = out.filter((x) => !x.core).sort(byTitle); 5934 return { core, plugins }; 5935 } 5936 5937 function mobileScreenFromLegacyPanel(next) { 5938 const raw = String(next || "").trim(); 5939 if (!raw) return "hives"; 5940 if (raw === "maps" || raw === "library") return "hives"; 5941 if (raw === "sidebar") return "account"; 5942 if (raw === "main" || raw === "workspace") return "hives"; 5943 if (raw === "chat") return "chat"; 5944 if (raw === "people") return "people"; 5945 if (raw === "profile") return "profile"; 5946 if (raw === "onboarding") return "onboarding"; 5947 if (raw === "moderation" || raw === "mod") return canModerate ? "moderation" : "hives"; 5948 if (raw === "hives" || raw === "account" || raw === "people" || raw === "profile" || raw === "onboarding" || raw === "moderation") return raw; 5949 // Plugin panel id can be treated as a screen. 5950 if (panelRegistry.has(raw)) return raw; 5951 return "hives"; 5952 } 5953 5954 function setMobileMoreOpen(open) { 5955 mobileMoreOpen = Boolean(open); 5956 if (mobileMoreSheetEl) mobileMoreSheetEl.classList.toggle("hidden", !mobileMoreOpen); 5957 if (mobileNavEl) { 5958 const moreBtn = mobileNavEl.querySelector?.('[data-mobilescreen="more"]'); 5959 if (moreBtn instanceof HTMLElement) { 5960 moreBtn.classList.toggle("primary", mobileMoreOpen); 5961 moreBtn.classList.toggle("ghost", !mobileMoreOpen); 5962 } 5963 } 5964 } 5965 5966 function restoreHostedPanelIfAny() { 5967 const ids = Array.from(mobileHostedPanelIds); 5968 if (mobileHostPanelId && !ids.includes(mobileHostPanelId)) ids.push(mobileHostPanelId); 5969 if (!ids.length) return; 5970 mobileHostedPanelIds.clear(); 5971 mobileHostPanelId = ""; 5972 for (const id of ids) { 5973 const el = getPanelElement(id); 5974 const parent = mobileHostRestoreParentByPanelId.get(id) || null; 5975 mobileHostRestoreParentByPanelId.delete(id); 5976 if (!(el instanceof HTMLElement)) continue; 5977 if (!parent && mobileHostEphemeralPanelIds.has(id)) { 5978 mobileHostEphemeralPanelIds.delete(id); 5979 try { 5980 el.remove(); 5981 } catch { 5982 // ignore 5983 } 5984 const prev = panelRegistry.get(id); 5985 if (prev) panelRegistry.set(id, { ...prev, element: null }); 5986 continue; 5987 } 5988 if (parent instanceof HTMLElement && parent.isConnected) { 5989 parent.appendChild(el); 5990 continue; 5991 } 5992 const def = panelRegistry.get(id); 5993 const wantsMain = String(def?.defaultRack || "").toLowerCase() === "main"; 5994 const rack = wantsMain ? ensureMainSideRack() : ensureRightRack(); 5995 if (rack) rack.appendChild(el); 5996 } 5997 } 5998 5999 function ensureMobileHostedPluginPanel(panelId) { 6000 const id = String(panelId || "").trim(); 6001 if (!id) return null; 6002 const existing = getPanelElement(id); 6003 if (existing instanceof HTMLElement) return existing; 6004 const entry = panelRegistry.get(id); 6005 const src = typeof entry?.source === "string" ? entry.source : ""; 6006 if (!src.startsWith("plugin:")) return null; 6007 const def = pluginPanelDefsByPanelId.get(id); 6008 const render = def?.render; 6009 if (typeof render !== "function") return null; 6010 6011 const shell = document.createElement("section"); 6012 shell.className = "panel panelFill pluginPanel mobileHostedPluginPanel"; 6013 shell.dataset.panelId = id; 6014 shell.innerHTML = ` 6015 <div class="panelHeader"> 6016 <div class="panelTitle">${escapeHtml(def?.title || id)}</div> 6017 <div class="row"></div> 6018 </div> 6019 <div class="panelBody" data-pluginmount="1"></div> 6020 `; 6021 6022 const mount = shell.querySelector("[data-pluginmount]"); 6023 if (mount instanceof HTMLElement) { 6024 const pluginId = String(def?.pluginId || "").trim(); 6025 const api = { 6026 toast, 6027 send: (eventName, payload) => { 6028 const ev = String(eventName || "").trim(); 6029 if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false; 6030 const wsRef = window.__bzlWs; 6031 if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false; 6032 const msg = payload && typeof payload === "object" ? payload : {}; 6033 wsRef.send(JSON.stringify({ ...msg, type: `plugin:${pluginId}:${ev}` })); 6034 return true; 6035 }, 6036 getUser: () => loggedInUser, 6037 getRole: () => loggedInRole, 6038 storage: { 6039 get(key) { 6040 try { 6041 return localStorage.getItem(`bzl_panel_${id}_${String(key || "")}`); 6042 } catch { 6043 return null; 6044 } 6045 }, 6046 set(key, value) { 6047 try { 6048 localStorage.setItem(`bzl_panel_${id}_${String(key || "")}`, String(value ?? "")); 6049 return true; 6050 } catch { 6051 return false; 6052 } 6053 } 6054 } 6055 }; 6056 try { 6057 const cleanup = render(mount, api); 6058 if (typeof cleanup === "function") shell.__panelCleanup = cleanup; 6059 } catch (e) { 6060 console.warn(`Plugin ${pluginId} panel render failed:`, e?.message || e); 6061 mount.textContent = `Failed to render panel "${id}".`; 6062 } 6063 } 6064 6065 panelRegistry.set(id, { 6066 ...(entry || { id, title: def?.title || id, icon: def?.icon || "", source: `plugin:${def?.pluginId || ""}`, role: def?.role || "aux", defaultRack: def?.defaultRack || "right" }), 6067 title: def?.title || (entry?.title || id), 6068 icon: def?.icon || (entry?.icon || ""), 6069 role: def?.role || (entry?.role || "aux"), 6070 defaultRack: def?.defaultRack || (entry?.defaultRack || "right"), 6071 element: shell 6072 }); 6073 mobileHostEphemeralPanelIds.add(id); 6074 return shell; 6075 } 6076 6077 function hostPanelInMobileScreen(panelId) { 6078 const id = String(panelId || "").trim(); 6079 if (!id) return false; 6080 if (!(mobileScreenHostEl instanceof HTMLElement)) return false; 6081 if (rackLayoutEnabled && isDocked(id)) { 6082 undockPanel(id); 6083 applyDockState(); 6084 } 6085 let el = getPanelElement(id); 6086 if (!(el instanceof HTMLElement)) el = ensureMobileHostedPluginPanel(id); 6087 if (!(el instanceof HTMLElement)) return false; 6088 el.classList.remove("hidden"); 6089 6090 restoreHostedPanelIfAny(); 6091 const parent = el.parentElement; 6092 if (parent instanceof HTMLElement) mobileHostRestoreParentByPanelId.set(id, parent); 6093 mobileHostPanelId = id; 6094 mobileHostedPanelIds.clear(); 6095 mobileHostedPanelIds.add(id); 6096 mobileScreenHostEl.innerHTML = ""; 6097 mobileScreenHostEl.appendChild(el); 6098 return true; 6099 } 6100 6101 function hostHivesInMobileScreen() { 6102 if (!(mobileScreenHostEl instanceof HTMLElement)) return false; 6103 if (rackLayoutEnabled) { 6104 if (isDocked("hives")) undockPanel("hives"); 6105 applyDockState(); 6106 } 6107 const hivesEl = getPanelElement("hives"); 6108 if (!(hivesEl instanceof HTMLElement)) return false; 6109 6110 restoreHostedPanelIfAny(); 6111 6112 const hivesParent = hivesEl.parentElement; 6113 if (hivesParent instanceof HTMLElement) mobileHostRestoreParentByPanelId.set("hives", hivesParent); 6114 6115 mobileScreenHostEl.innerHTML = ""; 6116 hivesEl.classList.remove("hidden"); 6117 mobileScreenHostEl.appendChild(hivesEl); 6118 6119 mobileHostedPanelIds.clear(); 6120 mobileHostedPanelIds.add("hives"); 6121 mobileHostPanelId = "hives"; 6122 6123 return true; 6124 } 6125 6126 function setMobileScreen(screenId, { pushHistory = true } = {}) { 6127 if (!appRoot) return; 6128 const screen = mobileScreenFromLegacyPanel(screenId); 6129 if (onboardingNeedsAcceptanceNow() && screen !== "onboarding" && screen !== "account") { 6130 setMobileScreen("onboarding", { pushHistory: false }); 6131 return; 6132 } 6133 const nextIsMore = screen === "more"; 6134 if (nextIsMore) { 6135 setMobileMoreOpen(true); 6136 return; 6137 } 6138 6139 if (pushHistory) { 6140 const current = String(appRoot.getAttribute("data-mobile-screen") || "").trim(); 6141 if (current && current !== "more" && current !== screen) { 6142 const layout = loadMobileLayout(); 6143 layout.history = [current, ...(layout.history || [])].filter((x, idx, arr) => x && arr.indexOf(x) === idx).slice(0, 12); 6144 saveMobileLayout(layout); 6145 } 6146 } 6147 6148 setMobileMoreOpen(false); 6149 6150 // Core screens map directly. 6151 if (screen === "people") { 6152 setPeopleOpen(true); 6153 peopleDrawerEl?.classList.remove("hidden"); 6154 renderPeoplePanel(); 6155 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "peopleList" })); 6156 } else { 6157 setPeopleOpen(false); 6158 } 6159 6160 if (screen === "moderation" && !canModerate) { 6161 appRoot.setAttribute("data-mobile-screen", "hives"); 6162 return; 6163 } 6164 6165 if (screen === "account") { 6166 restoreHostedPanelIfAny(); 6167 appRoot.setAttribute("data-mobile-screen", screen); 6168 return; 6169 } 6170 6171 if (screen === "people") { 6172 const hosted = hostPanelInMobileScreen("people"); 6173 appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "people"); 6174 return; 6175 } 6176 6177 if (screen === "profile") { 6178 const target = String(activeProfileUsername || loggedInUser || "").trim().toLowerCase(); 6179 if (target) setCenterView("profile", target); 6180 else renderProfilePanel(); 6181 const hosted = hostPanelInMobileScreen("profile"); 6182 appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives"); 6183 return; 6184 } 6185 6186 if (screen === "onboarding") { 6187 const hosted = hostPanelInMobileScreen("onboarding"); 6188 appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives"); 6189 return; 6190 } 6191 6192 if (screen === "hives") { 6193 const hosted = hostHivesInMobileScreen(); 6194 appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives"); 6195 return; 6196 } 6197 6198 const hostableCorePanelId = screen === "chat" ? "chat" : screen === "moderation" ? "moderation" : ""; 6199 if (hostableCorePanelId) { 6200 const hosted = hostPanelInMobileScreen(hostableCorePanelId); 6201 appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives"); 6202 return; 6203 } 6204 6205 // Plugin screen: host it. 6206 const hosted = hostPanelInMobileScreen(screen); 6207 appRoot.setAttribute("data-mobile-screen", hosted ? "host" : "hives"); 6208 } 6209 6210 function setMobilePanel(next) { 6211 if (!appRoot) return; 6212 // Back-compat shim: old callers still call setMobilePanel("chat"/"main"/etc). 6213 if (!isMobileScreenMode()) return; 6214 mobilePanel = mobileScreenFromLegacyPanel(next); 6215 setMobileScreen(mobilePanel, { pushHistory: true }); 6216 } 6217 6218 function applyMobileMode() { 6219 if (!appRoot) return; 6220 const wasMobile = appRoot.classList.contains("mobileScreens"); 6221 const mobile = isMobileScreenMode(); 6222 appRoot.classList.toggle("mobileScreens", mobile); 6223 if (mobileNavEl) mobileNavEl.classList.toggle("hidden", !mobile); 6224 if (mobile) stopAnyPanelResize(); 6225 6226 if (!mobile) { 6227 setMobileMoreOpen(false); 6228 restoreHostedPanelIfAny(); 6229 return; 6230 } 6231 6232 if (mobileFourthBtn instanceof HTMLElement) { 6233 mobileFourthBtn.textContent = "People"; 6234 mobileFourthBtn.setAttribute("data-mobilescreen", "people"); 6235 } 6236 6237 // Apply persisted layout only when entering mobile mode (avoid resetting state on keyboard/URL-bar resizes). 6238 const current = String(appRoot.getAttribute("data-mobile-screen") || "").trim(); 6239 if (!wasMobile || !current) { 6240 const layout = loadMobileLayout(); 6241 const desired = onboardingNeedsAcceptanceNow() ? "onboarding" : mobileScreenFromLegacyPanel(layout.active || "hives"); 6242 setMobileScreen(desired, { pushHistory: false }); 6243 } 6244 renderMobileNav(); 6245 if (mobileMoreOpen) renderMobileMoreList(); 6246 6247 if (!wasMobile) { 6248 if (canResizeSidebarNow()) applySidebarWidth(readStoredSidebarWidth(), false); 6249 if (canResizeChatNow()) applyChatWidth(readStoredChatWidth(), false); 6250 if (canResizeModNow()) applyModWidth(readStoredModWidth(), false); 6251 if (canResizePeopleNow()) applyPeopleWidth(readStoredPeopleWidth(), false); 6252 setComposerOpen(composerOpen); 6253 } 6254 } 6255 6256 function shiftMobilePanel(delta) { 6257 if (!isMobileScreenMode()) return; 6258 const order = canModerate 6259 ? ["account", "onboarding", "hives", "chat", "people", "profile", "moderation"] 6260 : ["account", "onboarding", "hives", "chat", "people", "profile"]; 6261 const current = mobileScreenFromLegacyPanel(appRoot?.getAttribute("data-mobile-screen") || "hives"); 6262 const idx = order.indexOf(current); 6263 const at = idx >= 0 ? idx : 0; 6264 const nextIdx = Math.max(0, Math.min(order.length - 1, at + delta)); 6265 setMobileScreen(order[nextIdx]); 6266 const layout = loadMobileLayout(); 6267 layout.active = order[nextIdx]; 6268 saveMobileLayout(layout); 6269 renderMobileNav(); 6270 } 6271 6272 function renderMobileNav() { 6273 if (!(mobileNavEl instanceof HTMLElement)) return; 6274 if (!appRoot) return; 6275 const active = String(appRoot.getAttribute("data-mobile-screen") || "hives").trim(); 6276 const buttons = Array.from(mobileNavEl.querySelectorAll("[data-mobilescreen]")); 6277 for (const btn of buttons) { 6278 const id = String(btn.getAttribute("data-mobilescreen") || "").trim(); 6279 const on = id !== "more" && (active === id || (active === "host" && id === mobileHostPanelId)); 6280 btn.classList.toggle("primary", on); 6281 btn.classList.toggle("ghost", !on); 6282 } 6283 } 6284 6285 function toast(title, body, timeoutMs = 2800) { 6286 const el = document.createElement("div"); 6287 el.className = "toast"; 6288 el.innerHTML = `<div class="toastTitle">${escapeHtml(title)}</div><div class="toastBody">${escapeHtml(body)}</div>`; 6289 toastHost.appendChild(el); 6290 setTimeout(() => el.remove(), timeoutMs); 6291 } 6292 6293 function sendDevLog(level, scope, message, data) { 6294 try { 6295 if (!canModerate) return false; 6296 const wsRef = window.__bzlWs; 6297 if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false; 6298 wsRef.send(JSON.stringify({ type: "devLogClient", level, scope, message, data })); 6299 return true; 6300 } catch { 6301 return false; 6302 } 6303 } 6304 6305 window.bzlDevLog = sendDevLog; 6306 6307 // Plugin event handlers: pluginId -> eventName -> Set<fn(msg)> 6308 const pluginClientHandlers = new Map(); 6309 // Moderation plugin tabs: fullTabId -> { title, ownerOnly, render(mount, api), pluginId } 6310 const modPluginTabs = new Map(); 6311 // Plugin panels by panelId (so mobile can render plugin screens even when rack layout is off). 6312 const pluginPanelDefsByPanelId = new Map(); 6313 6314 // Minimal plugin host (client-side). Plugins are trusted by the owner who installs them. 6315 // Plugin scripts can call `window.BzlPluginHost.register("pluginId", (ctx) => { ... })`. 6316 if (!window.BzlPluginHost) { 6317 const pluginInits = new Map(); 6318 window.BzlPluginHost = { 6319 apiVersion: 3, 6320 register(pluginId, initFn) { 6321 const id = String(pluginId || "").trim().toLowerCase(); 6322 if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(id)) throw new Error("Invalid plugin id"); 6323 if (typeof initFn !== "function") throw new Error("init must be a function"); 6324 if (pluginInits.has(id)) return false; 6325 pluginInits.set(id, initFn); 6326 try { 6327 initFn({ 6328 id, 6329 toast, 6330 getUser: () => loggedInUser, 6331 getRole: () => loggedInRole, 6332 on(eventName, handler) { 6333 const ev = String(eventName || "").trim(); 6334 if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) throw new Error("Invalid event name"); 6335 if (typeof handler !== "function") throw new Error("handler must be a function"); 6336 let byEvent = pluginClientHandlers.get(id); 6337 if (!byEvent) { 6338 byEvent = new Map(); 6339 pluginClientHandlers.set(id, byEvent); 6340 } 6341 let set = byEvent.get(ev); 6342 if (!set) { 6343 set = new Set(); 6344 byEvent.set(ev, set); 6345 } 6346 set.add(handler); 6347 return () => { 6348 try { 6349 set.delete(handler); 6350 } catch { 6351 // ignore 6352 } 6353 }; 6354 }, 6355 ui: { 6356 registerModTab(tabDef) { 6357 const tabId = String(tabDef?.id || id).trim().toLowerCase(); 6358 if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(tabId)) throw new Error("Invalid tab id"); 6359 const title = typeof tabDef?.title === "string" ? tabDef.title.trim().slice(0, 22) : tabId; 6360 const ownerOnly = Boolean(tabDef?.ownerOnly); 6361 const render = tabDef?.render; 6362 if (typeof render !== "function") throw new Error("render must be a function"); 6363 6364 const fullId = `plugin:${id}:${tabId}`; 6365 modPluginTabs.set(fullId, { title, ownerOnly, render, pluginId: id }); 6366 6367 const tabsEl = modPanelEl?.querySelector?.(".modTabs"); 6368 if (tabsEl && !tabsEl.querySelector(`[data-modtab="${CSS.escape(fullId)}"]`)) { 6369 const btn = document.createElement("button"); 6370 btn.type = "button"; 6371 btn.className = "ghost"; 6372 btn.textContent = title; 6373 btn.setAttribute("data-modtab", fullId); 6374 btn.dataset.ownerOnly = ownerOnly ? "1" : "0"; 6375 tabsEl.appendChild(btn); 6376 } 6377 6378 // If the tab isn't visible for this user, don't allow it to become active. 6379 if (ownerOnly && loggedInRole !== "owner" && modTab === fullId) { 6380 modTab = "server"; 6381 renderModPanel(); 6382 } 6383 return true; 6384 }, 6385 registerPanel(panelDef) { 6386 const panelId = String(panelDef?.id || id).trim().toLowerCase(); 6387 if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(panelId)) throw new Error("Invalid panel id"); 6388 const title = typeof panelDef?.title === "string" ? panelDef.title.trim().slice(0, 40) : panelId; 6389 const icon = typeof panelDef?.icon === "string" ? panelDef.icon.trim().slice(0, 10) : ""; 6390 const defaultRack = 6391 typeof panelDef?.defaultRack === "string" && /^(main|right)$/i.test(panelDef.defaultRack) 6392 ? panelDef.defaultRack.toLowerCase() 6393 : "right"; 6394 const role = 6395 typeof panelDef?.role === "string" && /^(primary|aux|transient|utility)$/i.test(panelDef.role) 6396 ? panelDef.role.toLowerCase() 6397 : "aux"; 6398 const source = `plugin:${id}`; 6399 const render = typeof panelDef?.render === "function" ? panelDef.render : null; 6400 6401 pluginPanelDefsByPanelId.set(panelId, { pluginId: id, panelId, title, icon, defaultRack, role, render }); 6402 6403 // Create a visible shell only when rack layout is enabled (for now). 6404 // Otherwise, plugins should continue using their existing DOM hooks. 6405 let element = null; 6406 if (rackLayoutEnabled) { 6407 const shell = ensurePluginPanelShell(panelId, title, icon, defaultRack, role); 6408 element = shell; 6409 const mount = shell ? shell.querySelector("[data-pluginmount]") : null; 6410 if (mount) { 6411 mount.innerHTML = ""; 6412 const api = { 6413 toast, 6414 send: (eventName, payload) => { 6415 const ev = String(eventName || "").trim(); 6416 if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false; 6417 const wsRef = window.__bzlWs; 6418 if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false; 6419 const msg = payload && typeof payload === "object" ? payload : {}; 6420 wsRef.send(JSON.stringify({ ...msg, type: `plugin:${id}:${ev}` })); 6421 return true; 6422 }, 6423 getUser: () => loggedInUser, 6424 getRole: () => loggedInRole, 6425 storage: { 6426 get(key) { 6427 try { 6428 return localStorage.getItem(`bzl_panel_${panelId}_${String(key || "")}`); 6429 } catch { 6430 return null; 6431 } 6432 }, 6433 set(key, value) { 6434 try { 6435 localStorage.setItem(`bzl_panel_${panelId}_${String(key || "")}`, String(value ?? "")); 6436 return true; 6437 } catch { 6438 return false; 6439 } 6440 }, 6441 }, 6442 }; 6443 try { 6444 const cleanup = render ? render(mount, api) : null; 6445 if (typeof cleanup === "function") { 6446 // Store cleanup on the shell so future hot-reload / uninstall can call it. 6447 shell.__panelCleanup = cleanup; 6448 } 6449 } catch (e) { 6450 console.warn(`Plugin ${id} panel render failed:`, e?.message || e); 6451 mount.textContent = `Failed to render panel "${panelId}".`; 6452 } 6453 } 6454 6455 enableRackDnD(); 6456 } 6457 6458 panelRegistry.set(panelId, { id: panelId, title, icon, source, role, defaultRack, element }); 6459 applyPluginPresetHint(panelDef); 6460 applyDockState(); 6461 syncRackStateFromDom(); 6462 return true; 6463 }, 6464 }, 6465 devLog: (level, message, data) => sendDevLog(level, `plugin:${id}`, message, data), 6466 send(eventName, payload) { 6467 const ev = String(eventName || "").trim(); 6468 if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false; 6469 const wsRef = window.__bzlWs; 6470 if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false; 6471 const msg = payload && typeof payload === "object" ? payload : {}; 6472 wsRef.send(JSON.stringify({ ...msg, type: `plugin:${id}:${ev}` })); 6473 return true; 6474 }, 6475 }); 6476 } catch (e) { 6477 console.warn(`Plugin ${id} init failed:`, e?.message || e); 6478 toast("Plugin error", `Failed to init "${id}".`); 6479 } 6480 return true; 6481 }, 6482 }; 6483 } 6484 6485 function renderTypingIndicator() { 6486 if (!typingIndicator) return; 6487 if (!activeChatPostId) { 6488 typingIndicator.textContent = ""; 6489 return; 6490 } 6491 const set = typingUsersByPostId.get(activeChatPostId); 6492 if (!set || set.size === 0) { 6493 typingIndicator.textContent = ""; 6494 return; 6495 } 6496 const names = Array.from(set.values()); 6497 let text = ""; 6498 if (names.length === 1) text = `@${names[0]} is typing`; 6499 else if (names.length === 2) text = `@${names[0]} and @${names[1]} are typing`; 6500 else text = `@${names[0]}, @${names[1]} and ${names.length - 2} others are typing`; 6501 typingIndicator.innerHTML = `${escapeHtml(text)} <span class="typingDots"><span>.</span><span>.</span><span>.</span></span>`; 6502 } 6503 6504 function highlightMentionsInText(text) { 6505 const escaped = escapeHtml(text || ""); 6506 if (!escaped) return ""; 6507 return escaped.replace(/(^|[\s(>])@([a-z0-9][a-z0-9_.-]{0,31})/gi, (full, lead, name) => { 6508 const normalized = String(name || "").toLowerCase(); 6509 const mine = loggedInUser && normalized === loggedInUser ? " mentionTokenMe" : ""; 6510 return `${lead}<span class="mentionToken${mine}">@${escapeHtml(name)}</span>`; 6511 }); 6512 } 6513 6514 function decorateMentionNodesInElement(rootEl) { 6515 if (!rootEl) return; 6516 const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT); 6517 const targets = []; 6518 for (let node = walker.nextNode(); node; node = walker.nextNode()) { 6519 const parent = node.parentElement; 6520 if (!parent) continue; 6521 if (parent.closest(".mentionToken")) continue; 6522 if (parent.closest("a")) continue; 6523 const text = String(node.nodeValue || ""); 6524 if (!/@[a-z0-9_][a-z0-9_.-]{0,31}/i.test(text)) continue; 6525 targets.push(node); 6526 } 6527 for (const node of targets) { 6528 const text = String(node.nodeValue || ""); 6529 const re = /(^|[\s(>])@([a-z0-9_][a-z0-9_.-]{0,31})/gi; 6530 let match; 6531 let last = 0; 6532 const frag = document.createDocumentFragment(); 6533 let changed = false; 6534 while ((match = re.exec(text))) { 6535 const start = match.index; 6536 const lead = match[1] || ""; 6537 const rawName = match[2] || ""; 6538 const mentionStart = start + lead.length; 6539 if (mentionStart > last) { 6540 frag.appendChild(document.createTextNode(text.slice(last, mentionStart))); 6541 } 6542 const normalized = String(rawName).toLowerCase(); 6543 const span = document.createElement("span"); 6544 span.className = `mentionToken${loggedInUser && normalized === loggedInUser ? " mentionTokenMe" : ""}`; 6545 span.textContent = `@${rawName}`; 6546 frag.appendChild(span); 6547 last = mentionStart + 1 + rawName.length; 6548 changed = true; 6549 } 6550 if (!changed) continue; 6551 if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last))); 6552 node.parentNode?.replaceChild(frag, node); 6553 } 6554 } 6555 6556 function youtubeVideoIdFromUrl(rawUrl) { 6557 const raw = String(rawUrl || "").trim(); 6558 if (!raw) return ""; 6559 const urlText = /^https?:\/\//i.test(raw) ? raw : `https://${raw.replace(/^\/+/, "")}`; 6560 let url; 6561 try { 6562 url = new URL(urlText); 6563 } catch { 6564 return ""; 6565 } 6566 6567 const host = String(url.hostname || "").toLowerCase(); 6568 const path = String(url.pathname || ""); 6569 const isYouTube = 6570 host === "youtu.be" || 6571 host.endsWith(".youtu.be") || 6572 host === "youtube.com" || 6573 host.endsWith(".youtube.com") || 6574 host === "youtube-nocookie.com" || 6575 host.endsWith(".youtube-nocookie.com"); 6576 if (!isYouTube) return ""; 6577 6578 let id = ""; 6579 if (host.includes("youtu.be")) { 6580 id = path.split("/").filter(Boolean)[0] || ""; 6581 } else { 6582 const v = url.searchParams.get("v"); 6583 if (v) id = v; 6584 if (!id) { 6585 const parts = path.split("/").filter(Boolean); 6586 if (parts[0] === "shorts") id = parts[1] || ""; 6587 if (!id && parts[0] === "embed") id = parts[1] || ""; 6588 } 6589 } 6590 6591 id = String(id || "").trim(); 6592 if (!/^[a-zA-Z0-9_-]{11}$/.test(id)) return ""; 6593 return id; 6594 } 6595 6596 function buildYouTubeEmbedEl(videoId) { 6597 const id = String(videoId || "").trim(); 6598 if (!/^[a-zA-Z0-9_-]{11}$/.test(id)) return null; 6599 const wrap = document.createElement("div"); 6600 wrap.className = "ytEmbed"; 6601 const iframe = document.createElement("iframe"); 6602 iframe.setAttribute("title", "YouTube video"); 6603 iframe.setAttribute("loading", "lazy"); 6604 iframe.setAttribute("allowfullscreen", "true"); 6605 iframe.setAttribute( 6606 "allow", 6607 "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 6608 ); 6609 iframe.setAttribute("referrerpolicy", "strict-origin-when-cross-origin"); 6610 iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-presentation allow-popups"); 6611 iframe.src = `https://www.youtube-nocookie.com/embed/${id}`; 6612 wrap.appendChild(iframe); 6613 return wrap; 6614 } 6615 6616 function decorateYouTubeEmbedsInElement(rootEl) { 6617 if (!rootEl) return; 6618 const existing = rootEl.querySelectorAll(".ytEmbed iframe[src*=\"youtube-nocookie.com/embed/\"]"); 6619 if (existing && existing.length) return; 6620 6621 const anchors = Array.from(rootEl.querySelectorAll("a[href]")); 6622 for (const a of anchors) { 6623 const href = a.getAttribute("href") || ""; 6624 const id = youtubeVideoIdFromUrl(href); 6625 if (!id) continue; 6626 const next = a.nextElementSibling; 6627 if (next && next.classList.contains("ytEmbed")) continue; 6628 const embed = buildYouTubeEmbedEl(id); 6629 if (!embed) continue; 6630 a.insertAdjacentElement("afterend", embed); 6631 } 6632 6633 const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT); 6634 const nodes = []; 6635 for (let node = walker.nextNode(); node; node = walker.nextNode()) { 6636 const parent = node.parentElement; 6637 if (!parent) continue; 6638 if (parent.closest("a")) continue; 6639 if (parent.closest(".ytEmbed")) continue; 6640 const text = String(node.nodeValue || ""); 6641 if (!/(youtu\.be\/|youtube\.com\/|youtube-nocookie\.com\/)/i.test(text)) continue; 6642 nodes.push(node); 6643 } 6644 6645 for (const node of nodes) { 6646 const text = String(node.nodeValue || ""); 6647 const re = /(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi; 6648 let match; 6649 let last = 0; 6650 const frag = document.createDocumentFragment(); 6651 let changed = false; 6652 while ((match = re.exec(text))) { 6653 const urlToken = String(match[0] || ""); 6654 const start = match.index; 6655 if (start > last) frag.appendChild(document.createTextNode(text.slice(last, start))); 6656 const id = youtubeVideoIdFromUrl(urlToken); 6657 if (!id) { 6658 frag.appendChild(document.createTextNode(urlToken)); 6659 last = start + urlToken.length; 6660 continue; 6661 } 6662 changed = true; 6663 const a = document.createElement("a"); 6664 const href = /^https?:\/\//i.test(urlToken) ? urlToken : `https://${urlToken}`; 6665 a.href = href; 6666 a.target = "_blank"; 6667 a.rel = "noopener noreferrer nofollow"; 6668 a.textContent = urlToken; 6669 frag.appendChild(a); 6670 frag.appendChild(buildYouTubeEmbedEl(id)); 6671 last = start + urlToken.length; 6672 } 6673 if (!changed) continue; 6674 if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last))); 6675 node.parentNode?.replaceChild(frag, node); 6676 } 6677 } 6678 6679 function findChatMessage(postId, messageId) { 6680 const list = chatByPost.get(postId) || []; 6681 return list.find((m) => m && m.id === messageId) || null; 6682 } 6683 6684 function setReplyToMessage(message) { 6685 replyToMessage = message || null; 6686 if (!chatReplyBanner || !chatReplyWho || !chatReplyText) return; 6687 if (!replyToMessage) { 6688 chatReplyBanner.classList.add("hidden"); 6689 chatReplyWho.textContent = ""; 6690 chatReplyText.textContent = ""; 6691 return; 6692 } 6693 chatReplyBanner.classList.remove("hidden"); 6694 const who = replyToMessage.fromUser ? `@${replyToMessage.fromUser}` : "unknown"; 6695 chatReplyWho.textContent = who; 6696 const text = String(replyToMessage.text || "").replace(/\s+/g, " ").trim(); 6697 chatReplyText.textContent = text ? `- ${text.slice(0, 96)}` : "- [media]"; 6698 } 6699 6700 function listMentionCandidates(query) { 6701 const q = String(query || "") 6702 .trim() 6703 .toLowerCase() 6704 .replace(/^@+/, ""); 6705 const list = Array.isArray(peopleMembers) && peopleMembers.length ? peopleMembers : fallbackPeopleFromProfiles(); 6706 const filtered = list 6707 .map((m) => String(m.username || "").toLowerCase()) 6708 .filter(Boolean) 6709 .filter((u) => (q ? u.includes(q) : true)) 6710 .slice(0, 8); 6711 return Array.from(new Set(filtered)); 6712 } 6713 6714 function getCaretRect() { 6715 const sel = window.getSelection(); 6716 if (!sel || sel.rangeCount === 0) return null; 6717 const range = sel.getRangeAt(0).cloneRange(); 6718 range.collapse(true); 6719 const rects = range.getClientRects(); 6720 if (rects && rects.length) return rects[0]; 6721 const node = range.startContainer && range.startContainer.parentElement ? range.startContainer.parentElement : null; 6722 return node ? node.getBoundingClientRect() : null; 6723 } 6724 6725 function renderMentionMenu() { 6726 if (!mentionMenuEl) return; 6727 const open = Boolean(mentionState.open && mentionState.items.length); 6728 mentionMenuEl.classList.toggle("hidden", !open); 6729 if (!open) { 6730 mentionMenuEl.innerHTML = ""; 6731 return; 6732 } 6733 const rect = mentionState.anchorRect || getCaretRect(); 6734 if (rect) { 6735 const top = Math.min(window.innerHeight - 180, rect.bottom + 6); 6736 const left = Math.min(window.innerWidth - 220, rect.left); 6737 mentionMenuEl.style.top = `${Math.max(10, top)}px`; 6738 mentionMenuEl.style.left = `${Math.max(10, left)}px`; 6739 } 6740 mentionMenuEl.innerHTML = mentionState.items 6741 .map((u, idx) => { 6742 const on = idx === mentionState.selected; 6743 return `<div class="mentionItem ${on ? "isOn" : ""}" role="option" data-mentionpick="${escapeHtml(u)}">@${escapeHtml(u)}</div>`; 6744 }) 6745 .join(""); 6746 } 6747 6748 mentionMenuEl?.addEventListener("mousedown", (e) => { 6749 const item = e.target.closest("[data-mentionpick]"); 6750 if (!item) return; 6751 e.preventDefault(); // keep focus in editor 6752 const picked = item.getAttribute("data-mentionpick") || ""; 6753 if (!picked) return; 6754 replaceCurrentMentionToken(picked); 6755 closeMentionMenu(); 6756 chatEditor?.focus(); 6757 }); 6758 6759 function closeMentionMenu() { 6760 mentionState = { open: false, query: "", selected: 0, items: [], anchorRect: null }; 6761 renderMentionMenu(); 6762 } 6763 6764 function replaceCurrentMentionToken(username) { 6765 const sel = window.getSelection(); 6766 if (!sel || sel.rangeCount === 0) return; 6767 const range = sel.getRangeAt(0); 6768 if (!range.collapsed) return; 6769 const node = range.startContainer; 6770 if (!node || node.nodeType !== Node.TEXT_NODE) return; 6771 const text = String(node.nodeValue || ""); 6772 const caret = range.startOffset; 6773 const before = text.slice(0, caret); 6774 const after = text.slice(caret); 6775 const atIndex = before.lastIndexOf("@"); 6776 if (atIndex < 0) return; 6777 const prefix = before.slice(0, atIndex); 6778 const next = `${prefix}@${username} ${after}`; 6779 node.nodeValue = next; 6780 const newOffset = (prefix + `@${username} `).length; 6781 const newRange = document.createRange(); 6782 newRange.setStart(node, Math.min(newOffset, node.nodeValue.length)); 6783 newRange.collapse(true); 6784 sel.removeAllRanges(); 6785 sel.addRange(newRange); 6786 } 6787 6788 function wsUrl() { 6789 const isHttps = location.protocol === "https:"; 6790 const proto = isHttps ? "wss:" : "ws:"; 6791 return `${proto}//${location.host}/ws`; 6792 } 6793 6794 function setConn(state) { 6795 if (state === "open") { 6796 connBadge.textContent = "Connected"; 6797 connBadge.className = "badge badge-good"; 6798 } else if (state === "closed") { 6799 connBadge.textContent = "Disconnected"; 6800 connBadge.className = "badge badge-bad"; 6801 } else { 6802 connBadge.textContent = "Connecting..."; 6803 connBadge.className = "badge badge-warn"; 6804 } 6805 } 6806 6807 function escapeHtml(str) { 6808 return String(str) 6809 .replaceAll("&", "&") 6810 .replaceAll("<", "<") 6811 .replaceAll(">", ">") 6812 .replaceAll('"', """) 6813 .replaceAll("'", "'"); 6814 } 6815 6816 function cssEscape(str) { 6817 const raw = String(str ?? ""); 6818 if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(raw); 6819 return raw.replace(/[^a-zA-Z0-9_-]/g, (m) => `\\${m}`); 6820 } 6821 6822 function parseKeywords(str) { 6823 if (!str) return []; 6824 const parts = str 6825 .split(",") 6826 .map((s) => s.trim().toLowerCase()) 6827 .filter(Boolean); 6828 return Array.from(new Set(parts)).slice(0, 6); 6829 } 6830 6831 function normalizePostMode(mode) { 6832 const m = String(mode || "").trim().toLowerCase(); 6833 if (m === "walkie") return "walkie"; 6834 if (m === "stream") return "stream"; 6835 return "text"; 6836 } 6837 6838 function normalizeStreamKind(kind) { 6839 const k = String(kind || "").trim().toLowerCase(); 6840 if (k === "screen" || k === "audio") return k; 6841 return "webcam"; 6842 } 6843 6844 function streamKindLabel(kind) { 6845 const k = normalizeStreamKind(kind); 6846 if (k === "screen") return "Screen share"; 6847 if (k === "audio") return "Audio only"; 6848 return "Webcam"; 6849 } 6850 6851 function isStreamPost(post) { 6852 return normalizePostMode(post?.mode || post?.chatMode || "") === "stream"; 6853 } 6854 6855 function formatCountdown(expiresAt) { 6856 if (!Number(expiresAt || 0) || Number(expiresAt) <= 0) return "permanent"; 6857 const ms = expiresAt - Date.now(); 6858 if (ms <= 0) return "expired"; 6859 const totalSeconds = Math.floor(ms / 1000); 6860 const seconds = totalSeconds % 60; 6861 const totalMinutes = Math.floor(totalSeconds / 60); 6862 const minutes = totalMinutes % 60; 6863 const hours = Math.floor(totalMinutes / 60); 6864 if (hours > 0) return `${hours}h ${minutes}m`; 6865 if (minutes > 0) return `${minutes}m ${seconds}s`; 6866 return `${seconds}s`; 6867 } 6868 6869 function formatBoostRemaining(boostUntil) { 6870 const ms = boostUntil - Date.now(); 6871 if (ms <= 0) return ""; 6872 const totalSeconds = Math.floor(ms / 1000); 6873 const seconds = totalSeconds % 60; 6874 const totalMinutes = Math.floor(totalSeconds / 60); 6875 const minutes = totalMinutes % 60; 6876 const hours = Math.floor(totalMinutes / 60); 6877 if (hours > 0) return `${hours}h ${minutes}m`; 6878 if (minutes > 0) return `${minutes}m ${seconds}s`; 6879 return `${seconds}s`; 6880 } 6881 6882 function rankTime(post) { 6883 return Math.max(Number(post.lastActivityAt || post.createdAt || 0), Number(post.boostUntil || 0)); 6884 } 6885 6886 function normalizePrefs(raw) { 6887 const starred = Array.isArray(raw?.starredPostIds) ? raw.starredPostIds.filter((x) => typeof x === "string" && x) : []; 6888 const hidden = Array.isArray(raw?.hiddenPostIds) ? raw.hiddenPostIds.filter((x) => typeof x === "string" && x) : []; 6889 const ignored = Array.isArray(raw?.ignoredUsers) ? raw.ignoredUsers.filter((x) => typeof x === "string" && x) : []; 6890 const blocked = Array.isArray(raw?.blockedUsers) ? raw.blockedUsers.filter((x) => typeof x === "string" && x) : []; 6891 const cleanUsers = (list) => 6892 [...new Set(list.map((u) => String(u).trim().toLowerCase().replace(/^@+/, "")).filter(Boolean))].slice(0, 400); 6893 return { 6894 starredPostIds: [...new Set(starred)], 6895 hiddenPostIds: [...new Set(hidden)], 6896 ignoredUsers: cleanUsers(ignored), 6897 blockedUsers: cleanUsers(blocked), 6898 }; 6899 } 6900 6901 function setUserPrefs(raw) { 6902 userPrefs = normalizePrefs(raw || {}); 6903 if (!loggedInUser && activeHiveView !== "all") activeHiveView = "all"; 6904 } 6905 6906 function normalizeCollections(rawList) { 6907 const list = Array.isArray(rawList) ? rawList : []; 6908 const out = []; 6909 for (const item of list) { 6910 if (!item || typeof item !== "object") continue; 6911 const id = String(item.id || "").trim(); 6912 const name = String(item.name || "").trim(); 6913 if (!id || !name) continue; 6914 out.push({ 6915 id, 6916 name, 6917 order: Number(item.order || 0) || 0, 6918 archived: Boolean(item.archived), 6919 visibility: item.visibility === "gated" ? "gated" : "public", 6920 allowedRoles: Array.isArray(item.allowedRoles) ? item.allowedRoles.map((x) => String(x || "").toLowerCase()).filter(Boolean) : [] 6921 }); 6922 } 6923 out.sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || a.name.localeCompare(b.name)); 6924 return out; 6925 } 6926 6927 function activeCollections() { 6928 return collections.filter((c) => !c.archived); 6929 } 6930 6931 function ensureActiveCollectionView() { 6932 if (!String(activeHiveView).startsWith("collection:")) return; 6933 const id = String(activeHiveView).slice("collection:".length); 6934 if (!activeCollections().some((c) => c.id === id)) activeHiveView = "all"; 6935 } 6936 6937 function renderCollectionSelect() { 6938 if (!postCollectionEl) return; 6939 const list = activeCollections(); 6940 const opts = list.map((c) => `<option value="${escapeHtml(c.id)}">${escapeHtml(c.name)}</option>`).join(""); 6941 postCollectionEl.innerHTML = opts; 6942 if (!postCollectionEl.value && list.length) postCollectionEl.value = list[0].id; 6943 } 6944 6945 function normalizeRoleDefs(rawList) { 6946 const list = Array.isArray(rawList) ? rawList : []; 6947 const out = []; 6948 for (const item of list) { 6949 if (!item || typeof item !== "object") continue; 6950 const key = String(item.key || "").trim().toLowerCase(); 6951 const label = String(item.label || "").trim(); 6952 if (!key || !label) continue; 6953 out.push({ 6954 key, 6955 label, 6956 color: /^#[0-9a-f]{6}$/i.test(String(item.color || "")) ? String(item.color).toLowerCase() : "", 6957 order: Number(item.order || 0) || 0 6958 }); 6959 } 6960 out.sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || a.label.localeCompare(b.label)); 6961 return out; 6962 } 6963 6964 function normalizePlugins(rawList) { 6965 const list = Array.isArray(rawList) ? rawList : []; 6966 const out = []; 6967 for (const item of list) { 6968 if (!item || typeof item !== "object") continue; 6969 const id = String(item.id || "").trim().toLowerCase(); 6970 if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(id)) continue; 6971 out.push({ 6972 id, 6973 name: String(item.name || id).trim().slice(0, 64) || id, 6974 version: String(item.version || "0.0.0").trim().slice(0, 32), 6975 description: String(item.description || "").trim().slice(0, 240), 6976 enabled: Boolean(item.enabled), 6977 entryClient: String(item.entryClient || "").trim(), 6978 entryServer: String(item.entryServer || "").trim(), 6979 permissions: Array.isArray(item.permissions) 6980 ? item.permissions.filter((p) => typeof p === "string" && p.trim()).map((p) => p.trim().slice(0, 64)).slice(0, 24) 6981 : [], 6982 error: String(item.error || "").trim().slice(0, 280), 6983 }); 6984 } 6985 out.sort((a, b) => a.name.localeCompare(b.name)); 6986 return out; 6987 } 6988 6989 function isOwnerUser() { 6990 return Boolean(loggedInUser && isOwnerRole(loggedInRole)); 6991 } 6992 6993 function canManagePlugins() { 6994 return Boolean(loggedInUser && canManagePluginsRole(loggedInRole)); 6995 } 6996 6997 function renderPluginsAdminHtml() { 6998 if (!canManagePlugins()) return `<div class="muted small">Admin/owner only.</div>`; 6999 const status = pluginAdminStatus ? `<div class="small muted">${escapeHtml(pluginAdminStatus)}</div>` : ""; 7000 const busyLine = pluginAdminBusy ? `<div class="small muted">Working...</div>` : ""; 7001 const listHtml = !plugins.length 7002 ? `<div class="muted small">No plugins installed yet.</div>` 7003 : plugins 7004 .map((p) => { 7005 const badges = []; 7006 if (p.entryClient) badges.push(`<span class="pluginBadge">client</span>`); 7007 if (p.entryServer) badges.push(`<span class="pluginBadge">server</span>`); 7008 for (const perm of p.permissions || []) badges.push(`<span class="pluginBadge">${escapeHtml(perm)}</span>`); 7009 const err = p.error ? `<div class="pluginError">${escapeHtml(p.error)}</div>` : ""; 7010 return `<div class="pluginRow"> 7011 <div class="pluginLeft"> 7012 <div class="pluginName">${escapeHtml(p.name)} <span class="muted small">v${escapeHtml(p.version)}</span></div> 7013 ${p.description ? `<div class="pluginDesc">${escapeHtml(p.description)}</div>` : ""} 7014 ${badges.length ? `<div class="pluginBadges">${badges.join("")}</div>` : ""} 7015 ${err} 7016 </div> 7017 <div class="pluginRight"> 7018 <label class="checkRow" style="justify-content:flex-end; gap:10px"> 7019 <span>Enabled</span> 7020 <input type="checkbox" data-pluginenable="${escapeHtml(p.id)}" ${p.enabled ? "checked" : ""} ${ 7021 pluginEnableInFlight.has(p.id) || pluginAdminBusy ? "disabled" : "" 7022 } /> 7023 </label> 7024 <button type="button" class="danger smallBtn" data-pluginuninstall="${escapeHtml(p.id)}">Uninstall</button> 7025 </div> 7026 </div>`; 7027 }) 7028 .join(""); 7029 return ` 7030 <div class="small muted">Admin/owner only. Install optional plugins to extend your instance.</div> 7031 <div class="pluginInstallRow" style="margin-top:10px"> 7032 <input data-pluginzip="1" type="file" accept=".zip,application/zip" /> 7033 <button data-plugininstall="1" class="ghost" type="button">Install</button> 7034 <button data-pluginreload="1" class="ghost" type="button">Reload</button> 7035 </div> 7036 ${busyLine} 7037 ${status} 7038 <div class="pluginsList">${listHtml}</div> 7039 `; 7040 } 7041 7042 function ensureEnabledPluginClientScripts() { 7043 if (!Array.isArray(plugins) || !plugins.length) return; 7044 for (const p of plugins) { 7045 if (!p || !p.enabled) continue; 7046 if (!p.entryClient) continue; 7047 const wantVersion = String(p.version || "0"); 7048 const loadedVersion = loadedPluginClientVersionById.get(p.id) || ""; 7049 if (loadedVersion && loadedVersion === wantVersion) continue; 7050 const src = `/plugins/${encodeURIComponent(p.id)}/${p.entryClient}?v=${encodeURIComponent(p.version || "0")}`; 7051 const script = document.createElement("script"); 7052 script.src = src; 7053 script.defer = true; 7054 script.onload = () => { 7055 loadedPluginClientVersionById.set(p.id, wantVersion); 7056 }; 7057 script.onerror = () => { 7058 pluginAdminStatus = `Failed to load plugin "${p.id}".`; 7059 toast("Plugins", pluginAdminStatus); 7060 renderModPanel(); 7061 }; 7062 document.head.appendChild(script); 7063 } 7064 } 7065 7066 function setPlugins(rawList) { 7067 plugins = normalizePlugins(rawList); 7068 ensureEnabledPluginClientScripts(); 7069 if (canModerate && modTab === "server") renderModPanel(); 7070 } 7071 7072 function roleDefByKey(key) { 7073 return customRoles.find((r) => r.key === key) || null; 7074 } 7075 7076 function roleTokenLabel(token) { 7077 const t = String(token || ""); 7078 if (t === "owner" || t === "admin" || t === "moderator" || t === "member") return t; 7079 if (t.startsWith("role:")) { 7080 const key = t.slice("role:".length); 7081 const found = roleDefByKey(key); 7082 return found ? found.label : key; 7083 } 7084 return t; 7085 } 7086 7087 function userCustomRoleKeys(username) { 7088 const member = (peopleMembers || []).find((m) => m && m.username === username); 7089 const keys = Array.isArray(member?.customRoles) ? member.customRoles : []; 7090 return keys.filter((x) => typeof x === "string" && x); 7091 } 7092 7093 function renderCustomRoleBadges(username) { 7094 const keys = userCustomRoleKeys(username); 7095 if (!keys.length) return ""; 7096 const parts = keys 7097 .map((key) => { 7098 const def = roleDefByKey(key); 7099 if (!def) return `<span class="modStatus">${escapeHtml(key)}</span>`; 7100 const style = def.color ? ` style="border-color:${escapeHtml(def.color)}66;color:${escapeHtml(def.color)}"` : ""; 7101 return `<span class="modStatus"${style}>${escapeHtml(def.label)}</span>`; 7102 }) 7103 .join(" "); 7104 return `<span class="customRoleRow">${parts}</span>`; 7105 } 7106 7107 function availableGateTokens() { 7108 const base = ["member", "moderator", "admin", "owner"]; 7109 const custom = customRoles.map((r) => `role:${r.key}`); 7110 return [...base, ...custom]; 7111 } 7112 7113 function prefSet(key) { 7114 return new Set(Array.isArray(userPrefs?.[key]) ? userPrefs[key] : []); 7115 } 7116 7117 function totalReactions(post) { 7118 const reactions = post?.reactions && typeof post.reactions === "object" ? post.reactions : {}; 7119 let total = 0; 7120 for (const count of Object.values(reactions)) total += Number(count || 0); 7121 return total; 7122 } 7123 7124 function sortPosts(list) { 7125 const mode = String(sortByEl?.value || "activity"); 7126 if (mode === "popular") { 7127 return list.sort((a, b) => totalReactions(b) - totalReactions(a) || rankTime(b) - rankTime(a) || b.createdAt - a.createdAt); 7128 } 7129 if (mode === "expiring") { 7130 const exp = (p) => { 7131 const t = Number(p?.expiresAt || 0) || 0; 7132 return t > 0 ? t : Number.MAX_SAFE_INTEGER; 7133 }; 7134 return list.sort((a, b) => exp(a) - exp(b) || rankTime(b) - rankTime(a)); 7135 } 7136 return list.sort((a, b) => rankTime(b) - rankTime(a) || b.createdAt - a.createdAt); 7137 } 7138 7139 function currentSortMode() { 7140 return String(sortByEl?.value || "activity"); 7141 } 7142 7143 function updateMobileSortCycleLabel() { 7144 if (!(mobileSortCycleBtn instanceof HTMLElement)) return; 7145 const mode = currentSortMode(); 7146 const label = mode === "popular" ? "Popular" : mode === "expiring" ? "Ending" : "Recent"; 7147 mobileSortCycleBtn.textContent = label; 7148 } 7149 7150 function getProfile(username) { 7151 if (!username) return { image: "", color: "" }; 7152 const p = profiles[username] || {}; 7153 return { image: p.image || "", color: p.color || "" }; 7154 } 7155 7156 function normalizeProfileLinks(list) { 7157 if (!Array.isArray(list)) return []; 7158 const out = []; 7159 for (const item of list) { 7160 if (!item || typeof item !== "object") continue; 7161 const label = String(item.label || "") 7162 .replace(/\s+/g, " ") 7163 .trim() 7164 .slice(0, 40); 7165 const url = String(item.url || "").trim().slice(0, 280); 7166 if (!/^https?:\/\//i.test(url)) continue; 7167 out.push({ label: label || "Link", url }); 7168 if (out.length >= 8) break; 7169 } 7170 return out; 7171 } 7172 7173 function normalizeProfileData(raw, fallbackUsername = "") { 7174 const username = String(raw?.username || fallbackUsername || "") 7175 .trim() 7176 .toLowerCase(); 7177 const image = typeof raw?.image === "string" ? raw.image : getProfile(username).image || ""; 7178 const colorRaw = typeof raw?.color === "string" ? raw.color : getProfile(username).color || ""; 7179 const color = /^#[0-9a-f]{6}$/i.test(colorRaw) ? colorRaw.toLowerCase() : ""; 7180 const pronouns = String(raw?.pronouns || "") 7181 .replace(/\s+/g, " ") 7182 .trim() 7183 .slice(0, 40); 7184 const bioHtml = typeof raw?.bioHtml === "string" ? raw.bioHtml : ""; 7185 const themeSongUrl = typeof raw?.themeSongUrl === "string" ? raw.themeSongUrl.trim() : ""; 7186 const links = normalizeProfileLinks(raw?.links); 7187 return { username, image, color, pronouns, bioHtml, themeSongUrl, links }; 7188 } 7189 7190 function asProfileLink(url) { 7191 const value = String(url || "").trim(); 7192 if (!/^https?:\/\//i.test(value)) return ""; 7193 return value; 7194 } 7195 7196 function renderUserPill(username) { 7197 if (!username) return `<span class="muted small">anon</span>`; 7198 const p = getProfile(username); 7199 const image = typeof p.image === "string" ? p.image : ""; 7200 const color = p.color && /^#[0-9a-f]{6}$/i.test(p.color) ? p.color : ""; 7201 const safeTextColor = color ? safeTextColorFromHex(color) : ""; 7202 const style = safeTextColor ? `style="color:${escapeHtml(safeTextColor)}"` : ""; 7203 const img = image ? `<img alt="" src="${escapeHtml(image)}" />` : ""; 7204 const extra = renderCustomRoleBadges(username); 7205 const normalized = String(username || "").trim().toLowerCase(); 7206 return `<button type="button" class="userPill userPillLink" data-viewprofile="${escapeHtml( 7207 normalized 7208 )}" title="View profile"><span class="pfp">${img}</span><span ${style}>@${escapeHtml(username)}</span>${extra}</button>`; 7209 } 7210 7211 function hexToRgb(hex) { 7212 const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex || ""); 7213 if (!m) return null; 7214 return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) }; 7215 } 7216 7217 function srgbToLinear(x) { 7218 const c = x / 255; 7219 if (c <= 0.04045) return c / 12.92; 7220 return Math.pow((c + 0.055) / 1.055, 2.4); 7221 } 7222 7223 function relativeLuminanceFromRgb(rgb) { 7224 if (!rgb) return 1; 7225 const r = srgbToLinear(rgb.r); 7226 const g = srgbToLinear(rgb.g); 7227 const b = srgbToLinear(rgb.b); 7228 return 0.2126 * r + 0.7152 * g + 0.0722 * b; 7229 } 7230 7231 function rgbToHex(rgb) { 7232 const clamp = (n) => Math.max(0, Math.min(255, Math.round(n))); 7233 const to2 = (n) => clamp(n).toString(16).padStart(2, "0"); 7234 return `#${to2(rgb.r)}${to2(rgb.g)}${to2(rgb.b)}`; 7235 } 7236 7237 function mixRgb(a, b, t) { 7238 const k = Math.max(0, Math.min(1, Number(t) || 0)); 7239 return { 7240 r: a.r + (b.r - a.r) * k, 7241 g: a.g + (b.g - a.g) * k, 7242 b: a.b + (b.b - a.b) * k, 7243 }; 7244 } 7245 7246 function safeTextColorFromHex(hex) { 7247 const rgb = hexToRgb(hex); 7248 if (!rgb) return ""; 7249 const baseLum = relativeLuminanceFromRgb(rgb); 7250 if (baseLum >= 0.38) return rgbToHex(rgb); 7251 const white = { r: 255, g: 255, b: 255 }; 7252 let best = rgb; 7253 for (let t = 0.10; t <= 0.85; t += 0.08) { 7254 const mixed = mixRgb(rgb, white, t); 7255 if (relativeLuminanceFromRgb(mixed) >= 0.42) { 7256 best = mixed; 7257 break; 7258 } 7259 best = mixed; 7260 } 7261 return rgbToHex(best); 7262 } 7263 7264 function tintStylesFromHex(hex) { 7265 const rgb = hexToRgb(hex); 7266 if (!rgb) return ""; 7267 const bg = `rgba(${rgb.r},${rgb.g},${rgb.b},0.10)`; 7268 const border = `rgba(${rgb.r},${rgb.g},${rgb.b},0.22)`; 7269 return `style="background:${bg};border-color:${border}"`; 7270 } 7271 7272 function cardTintStylesFromHex(hex) { 7273 const rgb = hexToRgb(hex); 7274 if (!rgb) return ""; 7275 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)`; 7276 const border = `rgba(${rgb.r},${rgb.g},${rgb.b},0.34)`; 7277 const glow = `0 10px 24px rgba(${rgb.r},${rgb.g},${rgb.b},0.14)`; 7278 return `style="background:${bg};border-color:${border};box-shadow:${glow}"`; 7279 } 7280 7281 function matchesFilter(post, filterSet, authorQuery, hiddenSet, starredSet, ignoreUserSet, visibleCollectionIds) { 7282 if (visibleCollectionIds && !visibleCollectionIds.has(String(post.collectionId || ""))) return false; 7283 if (hiddenSet.has(post.id) && activeHiveView !== "hidden") return false; 7284 if (activeHiveView === "starred" && !starredSet.has(post.id)) return false; 7285 if (activeHiveView === "hidden" && !hiddenSet.has(post.id)) return false; 7286 const author = String(post.author || "").toLowerCase(); 7287 if (author && ignoreUserSet && ignoreUserSet.has(author) && (!loggedInUser || author !== String(loggedInUser).toLowerCase())) return false; 7288 if (String(activeHiveView).startsWith("collection:")) { 7289 const collectionId = String(activeHiveView).slice("collection:".length); 7290 if ((post.collectionId || "") !== collectionId) return false; 7291 } 7292 if (filterSet.size > 0) { 7293 let matched = false; 7294 for (const kw of post.keywords || []) { 7295 if (filterSet.has(kw)) { 7296 matched = true; 7297 break; 7298 } 7299 } 7300 if (!matched) return false; 7301 } 7302 if (authorQuery && !author.includes(authorQuery)) return false; 7303 return true; 7304 } 7305 7306 function postTitle(post) { 7307 if (post.locked) return "Protected post"; 7308 const text = String(post.title || post.content || "").replace(/\s+/g, " ").trim(); 7309 if (!text) return "(untitled)"; 7310 return text.length > 96 ? `${text.slice(0, 96)}...` : text; 7311 } 7312 7313 function myReactKey(kind, id, emoji) { 7314 return `${kind}:${id}:${emoji}`; 7315 } 7316 7317 function toggleMyReact(kind, id, emoji) { 7318 const key = myReactKey(kind, id, emoji); 7319 if (myReacts.has(key)) myReacts.delete(key); 7320 else myReacts.add(key); 7321 } 7322 7323 function markReactPulse(kind, id, emoji) { 7324 const key = myReactKey(kind, id, emoji); 7325 reactPulseByKey.set(key, Date.now()); 7326 } 7327 7328 function renderReactionButtons({ kind, id, reactions, postId }) { 7329 if (!showReactions) return ""; 7330 const r = reactions && typeof reactions === "object" ? reactions : {}; 7331 const emojis = kind === "post" ? allowedPostReactions : allowedChatReactions; 7332 return `<div class="reactionsRow"> 7333 ${emojis 7334 .map((emoji) => { 7335 const count = Number(r[emoji] || 0); 7336 const key = myReactKey(kind, id, emoji); 7337 const isOn = myReacts.has(key); 7338 const pulseAt = reactPulseByKey.get(key) || 0; 7339 const pulse = pulseAt && Date.now() - pulseAt < 650; 7340 if (pulseAt && !pulse) reactPulseByKey.delete(key); 7341 const cls = `${isOn ? "reactBtn isOn" : "reactBtn"}${pulse ? " pulse" : ""}`; 7342 const attrs = 7343 kind === "post" 7344 ? `data-react="1" data-kind="post" data-postid="${escapeHtml(id)}" data-emoji="${escapeHtml(emoji)}"` 7345 : `data-react="1" data-kind="chat" data-postid="${escapeHtml(postId || "")}" data-msgid="${escapeHtml( 7346 id 7347 )}" data-emoji="${escapeHtml(emoji)}"`; 7348 return `<span class="${cls}" ${attrs}>${escapeHtml(emoji)} <span class="count">${count || ""}</span></span>`; 7349 }) 7350 .join("")} 7351 </div>`; 7352 } 7353 7354 function markRead(postId) { 7355 if (!postId) return; 7356 unreadByPostId.delete(postId); 7357 } 7358 7359 function bumpUnread(postId) { 7360 const current = unreadByPostId.get(postId) || 0; 7361 unreadByPostId.set(postId, Math.min(99, current + 1)); 7362 } 7363 7364 function notifSupported() { 7365 return typeof window.Notification !== "undefined"; 7366 } 7367 7368 function notifState() { 7369 if (!notifSupported()) return "unsupported"; 7370 return Notification.permission; // default | denied | granted 7371 } 7372 7373 function updateNotifUi() { 7374 if (!enableNotifsBtn || !notifStatus) return; 7375 const state = notifState(); 7376 const secure = location.protocol === "https:"; 7377 const hint = secure ? "" : " (requires HTTPS: use tunnel)"; 7378 const activeRules = []; 7379 if (readNotifReplyPingPref()) activeRules.push("replies/pings"); 7380 if (readNotifMyHiveChatPref()) activeRules.push("my hive chat"); 7381 if (readNotifRecentHiveChatPref()) activeRules.push("recent hive chat"); 7382 if (readNotifNewHivePref()) activeRules.push("new hives"); 7383 const rulesLabel = activeRules.length ? activeRules.join(", ") : "no active rules"; 7384 7385 if (state === "unsupported") { 7386 enableNotifsBtn.classList.add("hidden"); 7387 notifStatus.textContent = `Browser notifications not supported. Alerts: ${rulesLabel}.`; 7388 return; 7389 } 7390 7391 enableNotifsBtn.classList.remove("hidden"); 7392 if (!secure) { 7393 enableNotifsBtn.disabled = true; 7394 notifStatus.textContent = `Browser notifications disabled on HTTP${hint}. Alerts: ${rulesLabel}.`; 7395 return; 7396 } 7397 7398 enableNotifsBtn.disabled = state === "granted"; 7399 enableNotifsBtn.textContent = state === "granted" ? "Notifications enabled" : "Enable notifications"; 7400 notifStatus.textContent = 7401 state === "granted" 7402 ? `Browser alerts enabled. Active rules: ${rulesLabel}.` 7403 : state === "denied" 7404 ? `Browser alerts blocked in settings. Active rules: ${rulesLabel}.` 7405 : `Active rules: ${rulesLabel}.`; 7406 } 7407 7408 function maybeNotify(title, body, data) { 7409 if (notifState() !== "granted") return; 7410 if (windowFocused && !document.hidden) return; 7411 try { 7412 const n = new Notification(title, { body, data }); 7413 n.onclick = () => { 7414 window.focus(); 7415 if (data?.postId) openChat(data.postId); 7416 if (data?.threadId) openDmThread(data.threadId); 7417 n.close(); 7418 }; 7419 } catch { 7420 // ignore 7421 } 7422 } 7423 7424 function renderLanHint() { 7425 if (!lanHint) return; 7426 if (!canModerate || !Array.isArray(lanUrls) || lanUrls.length === 0) { 7427 lanHint.textContent = ""; 7428 return; 7429 } 7430 lanHint.innerHTML = `LAN: <span class="muted">${lanUrls.map(escapeHtml).join(" | ")}</span>`; 7431 } 7432 7433 function renderFeed() { 7434 const filter = parseKeywords(filterKeywordsEl.value); 7435 const filterSet = new Set(filter); 7436 const authorQuery = String(filterAuthorEl?.value || "") 7437 .trim() 7438 .replace(/^@+/, "") 7439 .toLowerCase(); 7440 ensureActiveCollectionView(); 7441 const hiddenSet = prefSet("hiddenPostIds"); 7442 const starredSet = prefSet("starredPostIds"); 7443 const ignoreUserSet = new Set([...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase())); 7444 const visibleCollectionIds = new Set(activeCollections().map((c) => c.id)); 7445 if (!loggedInUser && activeHiveView !== "all") activeHiveView = "all"; 7446 if (hiveTabsEl) { 7447 const collectionTabs = activeCollections() 7448 .map((c) => { 7449 const view = `collection:${c.id}`; 7450 const on = view === activeHiveView; 7451 const cls = on ? "primary" : "ghost"; 7452 return `<button type="button" data-hiveview="${escapeHtml(view)}" class="${cls}">${escapeHtml(c.name)}</button>`; 7453 }) 7454 .join(""); 7455 const allOn = activeHiveView === "all"; 7456 const starredOn = activeHiveView === "starred"; 7457 const hiddenOn = activeHiveView === "hidden"; 7458 hiveTabsEl.innerHTML = ` 7459 <button type="button" data-hiveview="all" class="${allOn ? "primary" : "ghost"}">All</button> 7460 ${collectionTabs} 7461 <button type="button" data-hiveview="starred" class="${starredOn ? "primary" : "ghost"}" ${loggedInUser ? "" : "disabled"}>Starred</button> 7462 <button type="button" data-hiveview="hidden" class="${hiddenOn ? "primary" : "ghost"}" ${loggedInUser ? "" : "disabled"}>Hidden</button> 7463 `; 7464 } 7465 7466 const list = sortPosts(Array.from(posts.values())).filter((p) => 7467 matchesFilter(p, filterSet, authorQuery, hiddenSet, starredSet, ignoreUserSet, visibleCollectionIds) 7468 ); 7469 7470 if (list.length === 0) { 7471 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>`; 7472 return; 7473 } 7474 7475 feedEl.innerHTML = list 7476 .map((p) => { 7477 const tags = (p.keywords || []).map((k) => `<span class="tag">#${escapeHtml(k)}</span>`).join(""); 7478 const collectionName = activeCollections().find((c) => c.id === p.collectionId)?.name || "General"; 7479 const collectionTag = `<span class="tag">/${escapeHtml(collectionName)}</span>`; 7480 const postedLine = `<div class="small muted">posted ${escapeHtml(new Date(p.createdAt).toLocaleString())}</div>`; 7481 const editedLine = 7482 Number(p.editCount || 0) > 0 7483 ? `<div class="small muted">edited (${Number(p.editCount || 0)}) at ${escapeHtml( 7484 new Date(Number(p.editedAt || p.createdAt)).toLocaleString() 7485 )}</div>` 7486 : ""; 7487 const deletedLine = p.deleted 7488 ? `<div class="small muted">Post was deleted${ 7489 p.deletedBy ? ` by @${escapeHtml(p.deletedBy)}` : "" 7490 } at ${escapeHtml(new Date(Number(p.deletedAt || Date.now())).toLocaleString())}${ 7491 p.deleteReason ? ` (${escapeHtml(p.deleteReason)})` : "" 7492 }</div>` 7493 : ""; 7494 const authorLine = p.author ? `<div class="small postAuthor">${renderUserPill(p.author)}</div>` : ""; 7495 const boostText = formatBoostRemaining(Number(p.boostUntil || 0)); 7496 const boostLine = boostText ? `<div class="countdown boost" data-boost="${p.id}">boost ${boostText}</div>` : ""; 7497 7498 const canBoost = Boolean(loggedInUser && !p.locked && !p.deleted && p.author && loggedInUser !== p.author); 7499 const canManageOwnPost = Boolean(loggedInUser && !p.locked && !p.deleted && p.author && loggedInUser === p.author); 7500 const streamMode = normalizePostMode(p.mode || p.chatMode || ""); 7501 const isStream = streamMode === "stream"; 7502 const streamLive = Boolean(streamLiveByPostId.get(p.id) ?? p.streamLive); 7503 const streamKind = normalizeStreamKind(p.streamKind || "webcam"); 7504 const streamLine = isStream 7505 ? `<div class="small muted postStreamLine">Stream Β· ${escapeHtml(streamKindLabel(streamKind))}${streamLive ? " Β· live now" : ""}</div>` 7506 : ""; 7507 const boostControls = canBoost 7508 ? `<div class="boostRow"> 7509 <select data-boostsel="${p.id}"> 7510 <option value="300000">+5m</option> 7511 <option value="900000">+15m</option> 7512 <option value="1800000">+30m</option> 7513 <option value="3600000" selected>+1h</option> 7514 <option value="7200000">+2h</option> 7515 </select> 7516 <button type="button" data-boostbtn="${p.id}">Boost</button> 7517 </div>` 7518 : ""; 7519 7520 const reactionsHtml = p.locked || p.deleted ? "" : renderReactionButtons({ kind: "post", id: p.id, reactions: p.reactions || {} }); 7521 const isHidden = hiddenSet.has(p.id); 7522 const menuItems = ` 7523 ${canManageOwnPost ? `<button type="button" class="ghost" data-editpost="${p.id}">Edit</button>` : ""} 7524 ${canManageOwnPost ? `<button type="button" class="ghost danger" data-deletepost="${p.id}">Delete</button>` : ""} 7525 ${loggedInUser ? `<button type="button" class="ghost" data-hidepost="${p.id}">${isHidden ? "Unhide" : "Hide"}</button>` : ""} 7526 ${loggedInUser && !p.deleted ? `<button type="button" class="ghost" data-reportpost="${p.id}">Report</button>` : ""} 7527 `.trim(); 7528 const hasMenu = Boolean(menuItems); 7529 const kebabBtn = hasMenu 7530 ? `<button type="button" class="ghost smallBtn kebabBtn" data-postmenu="${p.id}" aria-haspopup="menu" aria-expanded="false" title="More">⋮</button>` 7531 : ""; 7532 const postMenu = hasMenu 7533 ? `<div class="postMenu hidden" role="menu" data-postmenu-panel="${p.id}">${menuItems}</div>` 7534 : ""; 7535 7536 const unread = unreadByPostId.get(p.id) || 0; 7537 const unreadDot = unread ? `<span class="badgeDot" title="${unread} unread"></span>` : ""; 7538 const unreadClass = unread ? " isUnread" : ""; 7539 const newClass = newPostAnimIds.has(p.id) ? " isNew" : ""; 7540 const buzzClass = buzzTimers.has(p.id) ? " isBuzz" : ""; 7541 const lockLine = p.locked ? `<div class="small muted">π password protected</div>` : ""; 7542 const cardTint = p.author ? cardTintStylesFromHex(getProfile(p.author).color) : ""; 7543 const contentHtml = typeof p.contentHtml === "string" && p.contentHtml.trim() ? p.contentHtml : ""; 7544 const contentText = typeof p.content === "string" && p.content.trim() ? escapeHtml(p.content) : ""; 7545 const content = contentHtml ? contentHtml : contentText ? `<div class="muted">${contentText}</div>` : ""; 7546 const contentBlock = content ? `<div class="postContent">${content}</div>` : ""; 7547 const lastChat = (chatByPost.get(p.id) || []).filter((m) => !m?.deleted).slice(-1)[0] || null; 7548 const lastChatFrom = lastChat ? String(lastChat.fromUser || "").trim() : ""; 7549 const lastChatText = lastChat ? String(lastChat.text || "").replace(/\s+/g, " ").trim().slice(0, 92) : ""; 7550 const lastChatWho = lastChat 7551 ? (lastChatFrom && lastChatFrom.toLowerCase() === "mod" ? "MOD" : `@${escapeHtml(lastChatFrom || "unknown")}`) 7552 : ""; 7553 const lastChatLine = lastChat 7554 ? `<div class="small muted postLastChat">Last chat: ${lastChatWho}${lastChatText ? ` β ${escapeHtml(lastChatText)}` : ""}</div>` 7555 : ""; 7556 const typersSet = typingUsersByPostId.get(p.id); 7557 const typingUsers = typersSet ? Array.from(typersSet.values()).slice(0, 2) : []; 7558 const typingMore = typersSet && typersSet.size > typingUsers.length ? ` +${typersSet.size - typingUsers.length}` : ""; 7559 const typingLine = typingUsers.length 7560 ? `<div class="small muted postTypingLine">${typingUsers.map((u) => `@${escapeHtml(u)}`).join(", ")}${typingMore} typing...</div>` 7561 : ""; 7562 7563 return ` 7564 <article class="post${unreadClass}${newClass}${buzzClass}" data-id="${p.id}" ${cardTint}> 7565 <div class="postTop"> 7566 <div class="postTitleRow"> 7567 <div class="postTitle">${escapeHtml(postTitle(p))}</div> 7568 ${postedLine} 7569 ${authorLine} 7570 ${lockLine} 7571 </div> 7572 <div class="rightCol"> 7573 ${unreadDot} 7574 <div class="countdown" data-countdown="${p.id}">${formatCountdown(p.expiresAt)}</div> 7575 ${boostLine} 7576 ${boostControls} 7577 <div class="postActionsRow"> 7578 <button type="button" data-chat="${p.id}">${p.locked ? "Unlock" : p.deleted ? "View" : isStream ? "Watch" : "Chat"}</button> 7579 ${kebabBtn} 7580 ${postMenu} 7581 </div> 7582 </div> 7583 </div> 7584 ${deletedLine} 7585 ${editedLine} 7586 ${streamLine} 7587 ${contentBlock} 7588 ${typingLine} 7589 ${lastChatLine} 7590 <div class="postMeta">${collectionTag}${tags ? ` ${tags}` : ""}</div> 7591 ${reactionsHtml} 7592 </article>`; 7593 }) 7594 .join(""); 7595 7596 try { 7597 feedEl.querySelectorAll?.(".postContent").forEach((el) => decorateYouTubeEmbedsInElement(el)); 7598 } catch { 7599 // ignore 7600 } 7601 } 7602 7603 function isMobileChatScreenActive() { 7604 if (!isMobileScreenMode() || !appRoot) return false; 7605 const screen = String(appRoot.getAttribute("data-mobile-screen") || "").trim(); 7606 return screen === "chat" || (screen === "host" && mobileHostPanelId === "chat"); 7607 } 7608 7609 function renderMobileChatListHtml() { 7610 const dmActive = activeDmThreadsSorted().slice(0, 30); 7611 const recentPostIds = recentHiveChatIds.slice(0, 24); 7612 const recentPosts = recentPostIds.map((id) => posts.get(id)).filter((p) => p && !p.deleted); 7613 const recentPostIdSet = new Set(recentPosts.map((p) => String(p.id))); 7614 const availablePosts = sortPosts(Array.from(posts.values())) 7615 .filter((p) => p && !p.deleted && !recentPostIdSet.has(String(p.id))) 7616 .slice(0, 60); 7617 7618 if (!dmActive.length && !recentPosts.length && !availablePosts.length) { 7619 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>`; 7620 } 7621 7622 const dmSection = dmActive.length 7623 ? `<div class="mobileChatSection"> 7624 <div class="small muted">DMs</div> 7625 ${dmActive 7626 .map((t) => { 7627 const who = `@${escapeHtml(String(t.other || "unknown"))}`; 7628 const when = dmActivityAt(t) ? new Date(dmActivityAt(t)).toLocaleTimeString() : "active"; 7629 return `<button type="button" class="ghost mobileChatListItem" data-dmopen="${escapeHtml(t.id)}"> 7630 <span class="mobileChatListTop">${who}</span> 7631 <span class="mobileChatListMeta">private chat Β· ${escapeHtml(when)}</span> 7632 </button>`; 7633 }) 7634 .join("")} 7635 </div>` 7636 : ""; 7637 7638 const postItem = (p) => { 7639 const title = escapeHtml(postTitle(p)); 7640 const author = p.author ? `@${escapeHtml(String(p.author || ""))}` : "anon"; 7641 const exp = formatCountdown(p.expiresAt); 7642 const lock = p.locked ? " Β· locked" : ""; 7643 const mode = normalizePostMode(p.mode || p.chatMode || ""); 7644 const streamLive = mode === "stream" && Boolean(streamLiveByPostId.get(p.id) ?? p.streamLive); 7645 const streamTag = mode === "stream" ? ` Β· stream${streamLive ? " live" : ""}` : ""; 7646 return `<button type="button" class="ghost mobileChatListItem" data-mobilechatopen="${escapeHtml(p.id)}"> 7647 <span class="mobileChatListTop">${title}</span> 7648 <span class="mobileChatListMeta">${author} Β· ${escapeHtml(exp)}${lock}${streamTag}</span> 7649 </button>`; 7650 }; 7651 7652 const recentSection = recentPosts.length 7653 ? `<div class="mobileChatSection"> 7654 <div class="small muted">Recent Hive Chats</div> 7655 ${recentPosts.map(postItem).join("")} 7656 </div>` 7657 : ""; 7658 7659 const hivesSection = availablePosts.length 7660 ? `<div class="mobileChatSection"> 7661 <div class="small muted">Available Hives</div> 7662 ${availablePosts.map(postItem).join("")} 7663 </div>` 7664 : ""; 7665 7666 return `<div class="mobileChatList">${dmSection}${recentSection}${hivesSection}</div>`; 7667 } 7668 7669 function onboardingRequiresAcceptance() { 7670 return Boolean(onboardingState.enabled && onboardingState.requireAcceptance); 7671 } 7672 7673 function onboardingNeedsAcceptanceNow() { 7674 if (!onboardingRequiresAcceptance()) return false; 7675 return Boolean(onboardingState.needsAcceptance || Number(onboardingState.acceptedRulesVersion || 0) < Number(onboardingState.rulesVersion || 1)); 7676 } 7677 7678 function onboardingBlocksReadingNow() { 7679 return Boolean(loggedInUser && onboardingNeedsAcceptanceNow() && onboardingState.blockReadUntilAccepted); 7680 } 7681 7682 function authGateShouldLock() { 7683 return !loggedInUser || onboardingNeedsAcceptanceNow(); 7684 } 7685 7686 function updateSplashProgress() { 7687 if (!(bzlSplashProgressFill instanceof HTMLElement)) return; 7688 if (!(splashAudio instanceof HTMLAudioElement) || !Number.isFinite(Number(splashAudio.duration)) || Number(splashAudio.duration) <= 0) { 7689 bzlSplashProgressFill.style.transform = "scaleX(0)"; 7690 return; 7691 } 7692 const duration = Number(splashAudio.duration || 0); 7693 const current = Math.max(0, Math.min(duration, Number(splashAudio.currentTime || 0))); 7694 const pct = duration > 0 ? current / duration : 0; 7695 bzlSplashProgressFill.style.transform = `scaleX(${Math.max(0, Math.min(1, pct))})`; 7696 } 7697 7698 function pickSplashTip() { 7699 if (!SPLASH_TIPS.length) return "Loading your hive..."; 7700 if (SPLASH_TIPS.length === 1) return SPLASH_TIPS[0]; 7701 let next = SPLASH_TIPS[Math.floor(Math.random() * SPLASH_TIPS.length)]; 7702 let guard = 0; 7703 while (next === splashLastTip && guard < 8) { 7704 next = SPLASH_TIPS[Math.floor(Math.random() * SPLASH_TIPS.length)]; 7705 guard += 1; 7706 } 7707 splashLastTip = next; 7708 return next; 7709 } 7710 7711 function showNextSplashTip() { 7712 if (!(bzlSplashTipEl instanceof HTMLElement)) return; 7713 bzlSplashTipEl.textContent = pickSplashTip(); 7714 } 7715 7716 function startSplashTipRotation() { 7717 stopSplashTipRotation(); 7718 showNextSplashTip(); 7719 splashTipTimer = setInterval(() => { 7720 if (!splashVisible) { 7721 stopSplashTipRotation(); 7722 return; 7723 } 7724 showNextSplashTip(); 7725 }, 5200); 7726 } 7727 7728 function stopSplashTipRotation() { 7729 if (!splashTipTimer) return; 7730 clearInterval(splashTipTimer); 7731 splashTipTimer = 0; 7732 } 7733 7734 function queueSplashProgressTick() { 7735 if (!splashVisible) return; 7736 if (splashProgressRaf) cancelAnimationFrame(splashProgressRaf); 7737 splashProgressRaf = requestAnimationFrame(() => { 7738 splashProgressRaf = 0; 7739 updateSplashProgress(); 7740 queueSplashProgressTick(); 7741 }); 7742 } 7743 7744 function finishSplashIfReady() { 7745 if (!splashVisible) return; 7746 if (!splashMinDone || !splashAudioDone) return; 7747 splashVisible = false; 7748 if (splashProgressRaf) { 7749 cancelAnimationFrame(splashProgressRaf); 7750 splashProgressRaf = 0; 7751 } 7752 stopSplashTipRotation(); 7753 if (bzlSplashProgressFill instanceof HTMLElement) bzlSplashProgressFill.style.transform = "scaleX(1)"; 7754 if (bzlSplashEl instanceof HTMLElement) bzlSplashEl.classList.add("hidden"); 7755 } 7756 7757 function markSplashAudioDone() { 7758 splashAudioDone = true; 7759 splashNeedsGesture = false; 7760 bzlSplashStartBtn?.classList.add("hidden"); 7761 finishSplashIfReady(); 7762 } 7763 7764 function tryPlaySplashAudio({ fromGesture = false } = {}) { 7765 if (!(splashAudio instanceof HTMLAudioElement)) { 7766 markSplashAudioDone(); 7767 return; 7768 } 7769 const p = splashAudio.play(); 7770 if (!p || typeof p.then !== "function") return; 7771 p.then(() => { 7772 splashNeedsGesture = false; 7773 bzlSplashStartBtn?.classList.add("hidden"); 7774 }).catch((err) => { 7775 const name = String(err?.name || ""); 7776 const autoplayBlocked = name === "NotAllowedError" || /notallowed/i.test(String(err?.message || "")); 7777 if (autoplayBlocked && !fromGesture) { 7778 splashNeedsGesture = true; 7779 bzlSplashStartBtn?.classList.remove("hidden"); 7780 return; 7781 } 7782 markSplashAudioDone(); 7783 }); 7784 } 7785 7786 function initSplashSequence() { 7787 if (!(bzlSplashEl instanceof HTMLElement)) { 7788 splashVisible = false; 7789 splashMinDone = true; 7790 splashAudioDone = true; 7791 return; 7792 } 7793 splashStartedAt = Date.now(); 7794 splashVisible = true; 7795 splashMinDone = false; 7796 splashAudioDone = false; 7797 splashNeedsGesture = false; 7798 bzlSplashEl.classList.remove("hidden"); 7799 bzlSplashStartBtn?.classList.add("hidden"); 7800 startSplashTipRotation(); 7801 splashAudio = new Audio(SPLASH_SFX_URL); 7802 splashAudio.preload = "auto"; 7803 splashAudio.addEventListener("ended", () => { 7804 markSplashAudioDone(); 7805 }); 7806 splashAudio.addEventListener("error", () => { 7807 markSplashAudioDone(); 7808 }); 7809 splashAudio.addEventListener("loadedmetadata", () => updateSplashProgress()); 7810 splashAudio.addEventListener("timeupdate", () => updateSplashProgress()); 7811 tryPlaySplashAudio(); 7812 queueSplashProgressTick(); 7813 setTimeout(() => { 7814 splashMinDone = true; 7815 finishSplashIfReady(); 7816 }, SPLASH_MIN_MS); 7817 } 7818 7819 function renderAuthGateOnboarding() { 7820 if (!(authGateOnboardingBodyEl instanceof HTMLElement)) return; 7821 if (authGateEl instanceof HTMLElement) { 7822 const buttons = Array.from(authGateEl.querySelectorAll("button[data-authgate-tab]")); 7823 for (const btn of buttons) { 7824 const on = String(btn.getAttribute("data-authgate-tab") || "") === authGateOnboardingTab; 7825 btn.classList.toggle("primary", on); 7826 btn.classList.toggle("ghost", !on); 7827 } 7828 } 7829 const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {}; 7830 if (!cfg.enabled) { 7831 authGateOnboardingBodyEl.innerHTML = `<div class="small muted">Onboarding is disabled on this server.</div>`; 7832 if (authGateAcceptBtn instanceof HTMLButtonElement) authGateAcceptBtn.classList.add("hidden"); 7833 return; 7834 } 7835 const tab = ["about", "rules", "roles"].includes(authGateOnboardingTab) ? authGateOnboardingTab : "about"; 7836 const rules = onboardingRuleListFromConfig(cfg); 7837 const about = typeof cfg?.about?.content === "string" ? cfg.about.content.trim() : ""; 7838 const roleIds = Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds) ? cfg.roleSelect.selfAssignableRoleIds : []; 7839 const roleItems = roleIds 7840 .map((key) => customRoles.find((r) => String(r?.key || "") === String(key))) 7841 .filter(Boolean) 7842 .map((r) => `<span class="tag">${escapeHtml(String(r.label || r.key || ""))}</span>`) 7843 .join(" "); 7844 authGateOnboardingBodyEl.innerHTML = 7845 tab === "about" 7846 ? about 7847 ? `<div class="onboardingAbout">${about}</div>` 7848 : `<div class="small muted">No About content published yet.</div>` 7849 : tab === "rules" 7850 ? rules.length 7851 ? `<div class="onbRuleList">${rules 7852 .map( 7853 (r) => `<article class="onbRuleViewerCard"> 7854 <div class="row" style="justify-content:space-between;align-items:center;"> 7855 <b>${escapeHtml(r.name || "Rule")}</b> 7856 ${onboardingSeverityBadge(r.severity)} 7857 </div> 7858 ${r.shortDescription ? `<div class="small muted">${escapeHtml(r.shortDescription)}</div>` : ""} 7859 ${r.description ? `<div class="small">${r.description}</div>` : ""} 7860 </article>` 7861 ) 7862 .join("")}</div>` 7863 : `<div class="small muted">No rules configured.</div>` 7864 : cfg?.roleSelect?.enabled 7865 ? roleItems 7866 ? `<div class="row" style="flex-wrap:wrap;gap:8px;">${roleItems}</div>` 7867 : `<div class="small muted">No self-assignable roles configured.</div>` 7868 : `<div class="small muted">Role select is disabled.</div>`; 7869 if (authGateAcceptBtn instanceof HTMLButtonElement) { 7870 const needs = onboardingNeedsAcceptanceNow(); 7871 const showAccept = onboardingRequiresAcceptance() && tab === "rules"; 7872 authGateAcceptBtn.classList.toggle("hidden", !showAccept); 7873 authGateAcceptBtn.disabled = !loggedInUser || !needs; 7874 authGateAcceptBtn.textContent = needs ? "Accept rules and continue" : "Accepted"; 7875 } 7876 } 7877 7878 function renderAuthGate() { 7879 if (!(authGateEl instanceof HTMLElement)) return; 7880 const locked = authGateShouldLock(); 7881 authGateEl.classList.toggle("hidden", !locked); 7882 authGateEl.setAttribute("aria-hidden", locked ? "false" : "true"); 7883 if (!(appRoot instanceof HTMLElement)) return; 7884 appRoot.classList.toggle("authLockedWorkspace", locked); 7885 if (!locked) return; 7886 const needsRules = Boolean(loggedInUser && onboardingNeedsAcceptanceNow()); 7887 if (needsRules) authGateOnboardingTab = "rules"; 7888 if (authGateHintEl instanceof HTMLElement) { 7889 authGateHintEl.textContent = needsRules 7890 ? "Accept this server's rules on the Rules tab to enter." 7891 : registrationEnabled 7892 ? "Create an account or sign in to enter this server." 7893 : canRegisterFirstUser 7894 ? "No users exist yet. Create the first account to enter." 7895 : "Sign in to enter this server."; 7896 } 7897 if (authGateCodeRowEl instanceof HTMLElement) authGateCodeRowEl.classList.toggle("hidden", !registrationEnabled); 7898 if (authGateRegisterEl instanceof HTMLButtonElement) { 7899 authGateRegisterEl.classList.toggle("hidden", !(registrationEnabled || canRegisterFirstUser)); 7900 } 7901 renderAuthGateOnboarding(); 7902 } 7903 7904 function openOnboardingView() { 7905 onboardingViewerTab = "about"; 7906 renderOnboardingPanel(); 7907 if (isMobileScreenMode()) { 7908 const layout = loadMobileLayout(); 7909 layout.active = "onboarding"; 7910 saveMobileLayout(layout); 7911 setMobileScreen("onboarding"); 7912 renderMobileNav(); 7913 return; 7914 } 7915 if (rackLayoutEnabled) { 7916 try { 7917 if ( 7918 layoutPresetEl instanceof HTMLSelectElement && 7919 Array.from(layoutPresetEl.options || []).some((opt) => String(opt.value || "") === "onboardingDefault") 7920 ) { 7921 if (layoutPresetEl.value !== "onboardingDefault") layoutPresetEl.value = "onboardingDefault"; 7922 applyPreset("onboardingDefault"); 7923 } 7924 restorePanelToWorkspaceSlot("onboarding", "workspaceLeftSlot"); 7925 restorePanelToWorkspaceSlot("hives", "workspaceRightSlot"); 7926 } catch { 7927 // ignore layout failures 7928 } 7929 } 7930 try { 7931 onboardingPanelEl?.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" }); 7932 } catch { 7933 // ignore 7934 } 7935 } 7936 7937 function renderOnboardingGateHint() { 7938 if (!(onboardingGateHintEl instanceof HTMLElement)) return; 7939 const show = onboardingBlocksReadingNow(); 7940 onboardingGateHintEl.classList.toggle("hidden", !show); 7941 if (!show) { 7942 onboardingGateHintEl.innerHTML = ""; 7943 return; 7944 } 7945 onboardingGateHintEl.innerHTML = ` 7946 <div class="onboardingGateText">This instance requires that you read and accept its guidelines before you can view posts.</div> 7947 <button type="button" class="ghost smallBtn" data-onboarding-open="1">Go to Onboarding</button> 7948 `; 7949 } 7950 7951 function onboardingSeverityLabel(severity) { 7952 const s = String(severity || "").toLowerCase(); 7953 if (s === "critical") return "Critical"; 7954 if (s === "warn") return "Warn"; 7955 return "Info"; 7956 } 7957 7958 function onboardingSeverityBadge(severity) { 7959 const s = String(severity || "info").toLowerCase(); 7960 const cls = s === "critical" ? "onbSeverityCritical" : s === "warn" ? "onbSeverityWarn" : "onbSeverityInfo"; 7961 return `<span class="tag ${cls}">${escapeHtml(onboardingSeverityLabel(s))}</span>`; 7962 } 7963 7964 function onboardingRuleListFromConfig(cfg) { 7965 const list = Array.isArray(cfg?.rules?.items) ? cfg.rules.items : []; 7966 return list 7967 .map((r, index) => ({ 7968 id: String(r?.id || `r${index + 1}`).trim().slice(0, 40) || `r${index + 1}`, 7969 order: Number.isFinite(Number(r?.order)) ? Math.max(1, Math.floor(Number(r.order))) : index + 1, 7970 name: String(r?.name || "").trim().slice(0, 60) || `Rule ${index + 1}`, 7971 shortDescription: String(r?.shortDescription || "").trim().slice(0, 180), 7972 description: String(r?.description || "").slice(0, 6000), 7973 severity: ["info", "warn", "critical"].includes(String(r?.severity || "").toLowerCase()) 7974 ? String(r.severity).toLowerCase() 7975 : "info", 7976 })) 7977 .sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || String(a.id || "").localeCompare(String(b.id || ""))); 7978 } 7979 7980 function onboardingDraftStampFromConfig(cfg) { 7981 return JSON.stringify({ 7982 enabled: Boolean(cfg?.enabled), 7983 aboutUpdatedAt: Number(cfg?.about?.updatedAt || 0), 7984 rulesVersion: Number(cfg?.rules?.version || 1), 7985 itemCount: Array.isArray(cfg?.rules?.items) ? cfg.rules.items.length : 0, 7986 roleSelectEnabled: Boolean(cfg?.roleSelect?.enabled), 7987 selfAssignableCount: Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds) ? cfg.roleSelect.selfAssignableRoleIds.length : 0, 7988 }); 7989 } 7990 7991 function syncOnboardingAdminDraft(force = false) { 7992 const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {}; 7993 const stamp = onboardingDraftStampFromConfig(cfg); 7994 if (!force && stamp === onboardingAdminDraftStamp) return; 7995 onboardingAdminDraft = { 7996 enabled: Boolean(cfg?.enabled), 7997 aboutContent: String(cfg?.about?.content || ""), 7998 requireAcceptance: Boolean(cfg?.rules?.requireAcceptance), 7999 blockReadUntilAccepted: Boolean(cfg?.rules?.blockReadUntilAccepted), 8000 roleSelectEnabled: Boolean(cfg?.roleSelect?.enabled), 8001 selfAssignableRoleIds: Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds) 8002 ? cfg.roleSelect.selfAssignableRoleIds.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean) 8003 : [], 8004 rules: onboardingRuleListFromConfig(cfg), 8005 }; 8006 onboardingAdminDraftStamp = stamp; 8007 onboardingAdminExpandedRuleIds.clear(); 8008 if (onboardingAdminDraft.rules[0]?.id) onboardingAdminExpandedRuleIds.add(onboardingAdminDraft.rules[0].id); 8009 } 8010 8011 function normalizeOnboardingDraftRules() { 8012 onboardingAdminDraft.rules = (Array.isArray(onboardingAdminDraft.rules) ? onboardingAdminDraft.rules : []) 8013 .map((r, index) => ({ 8014 id: String(r?.id || `r${index + 1}`).trim().slice(0, 40) || `r${index + 1}`, 8015 order: index + 1, 8016 name: String(r?.name || "").trim().slice(0, 60) || `Rule ${index + 1}`, 8017 shortDescription: String(r?.shortDescription || "").trim().slice(0, 180), 8018 description: String(r?.description || "").slice(0, 6000), 8019 severity: ["info", "warn", "critical"].includes(String(r?.severity || "").toLowerCase()) 8020 ? String(r.severity).toLowerCase() 8021 : "info", 8022 })) 8023 .slice(0, 200); 8024 } 8025 8026 function renderOnboardingPanel() { 8027 if (!(onboardingPanelEl instanceof HTMLElement) || !(onboardingPanelBodyEl instanceof HTMLElement)) return; 8028 const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {}; 8029 if (!cfg.enabled) { 8030 onboardingPanelEl.classList.add("hidden"); 8031 onboardingPanelBodyEl.innerHTML = `<div class="small muted">Onboarding is disabled for this server.</div>`; 8032 if (onboardingPanelAcceptBtn instanceof HTMLButtonElement) onboardingPanelAcceptBtn.classList.add("hidden"); 8033 return; 8034 } 8035 8036 onboardingPanelEl.classList.remove("hidden"); 8037 const needs = onboardingNeedsAcceptanceNow(); 8038 const rules = onboardingRuleListFromConfig(cfg); 8039 const about = typeof cfg?.about?.content === "string" ? cfg.about.content.trim() : ""; 8040 const roleIds = Array.isArray(cfg?.roleSelect?.selfAssignableRoleIds) ? cfg.roleSelect.selfAssignableRoleIds : []; 8041 const roleItems = roleIds 8042 .map((key) => customRoles.find((r) => String(r?.key || "") === String(key))) 8043 .filter(Boolean) 8044 .map((r) => `<span class="tag">${escapeHtml(String(r.label || r.key || ""))}</span>`) 8045 .join(" "); 8046 8047 onboardingPanelBodyEl.innerHTML = ` 8048 <div class="onbTabs"> 8049 <button type="button" class="${onboardingViewerTab === "about" ? "primary" : "ghost"} smallBtn" data-onbtab="about">About</button> 8050 <button type="button" class="${onboardingViewerTab === "rules" ? "primary" : "ghost"} smallBtn" data-onbtab="rules">Rules</button> 8051 <button type="button" class="${onboardingViewerTab === "roles" ? "primary" : "ghost"} smallBtn" data-onbtab="roles">Roles</button> 8052 </div> 8053 ${ 8054 onboardingViewerTab === "about" 8055 ? about 8056 ? `<div class="onboardingAbout">${about}</div>` 8057 : `<div class="small muted">No About content published yet.</div>` 8058 : onboardingViewerTab === "rules" 8059 ? rules.length 8060 ? `<div class="onbRuleList">${rules 8061 .map( 8062 (r) => `<article class="onbRuleViewerCard"> 8063 <div class="row" style="justify-content:space-between;align-items:center;"> 8064 <b>${escapeHtml(r.name || "Rule")}</b> 8065 ${onboardingSeverityBadge(r.severity)} 8066 </div> 8067 ${r.shortDescription ? `<div class="small muted">${escapeHtml(r.shortDescription)}</div>` : ""} 8068 ${r.description ? `<div class="small">${r.description}</div>` : ""} 8069 </article>` 8070 ) 8071 .join("")}</div>` 8072 : `<div class="small muted">No rules configured.</div>` 8073 : cfg?.roleSelect?.enabled 8074 ? roleItems 8075 ? `<div class="row" style="flex-wrap:wrap;gap:8px;">${roleItems}</div>` 8076 : `<div class="small muted">No self-assignable roles configured.</div>` 8077 : `<div class="small muted">Role select is disabled.</div>` 8078 } 8079 <div class="small ${needs ? "badText" : "goodText"}" style="margin-top:10px;"> 8080 ${ 8081 onboardingRequiresAcceptance() 8082 ? needs 8083 ? "Rules acceptance required before posting/chat." 8084 : `Rules accepted${onboardingState.acceptedAt ? ` at ${escapeHtml(formatLocalTime(onboardingState.acceptedAt))}` : "."}` 8085 : "Rules acceptance is optional on this server." 8086 } 8087 </div>`; 8088 8089 if (onboardingPanelAcceptBtn instanceof HTMLButtonElement) { 8090 const showAccept = onboardingRequiresAcceptance() && onboardingViewerTab === "rules"; 8091 onboardingPanelAcceptBtn.classList.toggle("hidden", !showAccept); 8092 onboardingPanelAcceptBtn.disabled = !loggedInUser || !needs; 8093 onboardingPanelAcceptBtn.textContent = needs ? "Accept and continue" : "Accepted"; 8094 } 8095 } 8096 8097 function renderOnboardingCard() { 8098 if (!(onboardingCard instanceof HTMLElement) || !(onboardingBody instanceof HTMLElement)) return; 8099 // Onboarding now lives as a first-class workspace panel; keep the old account card hidden. 8100 onboardingCard.classList.add("hidden"); 8101 onboardingBody.innerHTML = ""; 8102 if (onboardingAcceptBtn instanceof HTMLButtonElement) { 8103 onboardingAcceptBtn.classList.add("hidden"); 8104 onboardingAcceptBtn.disabled = true; 8105 } 8106 renderOnboardingPanel(); 8107 return; 8108 8109 const cfg = normalizeInstanceBranding(instanceBranding).onboarding || {}; 8110 if (!cfg.enabled) { 8111 onboardingCard.classList.add("hidden"); 8112 onboardingBody.innerHTML = ""; 8113 return; 8114 } 8115 onboardingCard.classList.remove("hidden"); 8116 const needs = onboardingNeedsAcceptanceNow(); 8117 const rules = onboardingRuleListFromConfig(cfg).slice(0, 6); 8118 const about = typeof cfg?.about?.content === "string" ? cfg.about.content.trim() : ""; 8119 const aboutBlock = about ? `<div class="onboardingAbout">${about}</div>` : `<div class="small muted">No About text set yet.</div>`; 8120 const rulesBlock = rules.length 8121 ? `<ol class="onboardingRules">${rules 8122 .map( 8123 (r) => 8124 `<li><b>${escapeHtml(r.name || "Rule")}</b>${r.shortDescription ? `<div class="small muted">${escapeHtml(r.shortDescription)}</div>` : ""}</li>` 8125 ) 8126 .join("")}</ol>` 8127 : `<div class="small muted">No rules published yet.</div>`; 8128 onboardingBody.innerHTML = ` 8129 ${aboutBlock} 8130 <div class="small" style="margin-top:10px;"><b>Rules</b></div> 8131 ${rulesBlock} 8132 ${ 8133 onboardingRequiresAcceptance() 8134 ? `<div class="small ${needs ? "badText" : "goodText"}" style="margin-top:10px;"> 8135 ${needs ? "Rules acceptance required before posting/chat." : `Rules accepted${onboardingState.acceptedAt ? ` at ${escapeHtml(formatLocalTime(onboardingState.acceptedAt))}` : "."}`} 8136 </div>` 8137 : `<div class="small muted" style="margin-top:10px;">Rules acceptance is optional on this server.</div>` 8138 } 8139 `; 8140 if (onboardingAcceptBtn instanceof HTMLButtonElement) { 8141 const showAccept = onboardingRequiresAcceptance() && onboardingViewerTab === "rules"; 8142 onboardingAcceptBtn.classList.toggle("hidden", !showAccept); 8143 onboardingAcceptBtn.disabled = !loggedInUser || !needs; 8144 onboardingAcceptBtn.textContent = needs ? "Accept and continue" : "Accepted"; 8145 } 8146 renderOnboardingPanel(); 8147 } 8148 8149 function setAuthUi() { 8150 if (loggedInUser) { 8151 guestAuthPanelRevealed = false; 8152 userLabel.innerHTML = renderUserPill(loggedInUser); 8153 logoutBtn.classList.remove("hidden"); 8154 const roleText = loggedInRole && loggedInRole !== "member" ? ` (${loggedInRole})` : ""; 8155 authHint.textContent = onboardingNeedsAcceptanceNow() 8156 ? `Signed in${roleText}. Accept server rules to unlock posting/chat.` 8157 : `Signed in${roleText}. You can post, chat, and boost others.`; 8158 } else { 8159 userLabel.textContent = "Signed out"; 8160 logoutBtn.classList.add("hidden"); 8161 authHint.textContent = registrationEnabled 8162 ? "Sign in or create an account with the registration code." 8163 : canRegisterFirstUser 8164 ? "No users exist yet. Create the first user from this computer." 8165 : "Sign in to post, chat, and boost."; 8166 } 8167 applyInstanceAppearance(); 8168 8169 const canMakePermanent = userCanCreatePermanentHive(); 8170 if (ttlMinutesEl) { 8171 ttlMinutesEl.min = canMakePermanent ? "0" : "1"; 8172 if (!canMakePermanent && Number(ttlMinutesEl.value || 0) <= 0) ttlMinutesEl.value = "60"; 8173 } 8174 if (ttlPermanentEl instanceof HTMLInputElement) { 8175 ttlPermanentEl.disabled = !canMakePermanent; 8176 if (!canMakePermanent) ttlPermanentEl.checked = false; 8177 } 8178 syncTtlUiFromMinutes(); 8179 8180 codeRow.classList.toggle("hidden", !registrationEnabled); 8181 registerBtn.classList.toggle("hidden", !(registrationEnabled || canRegisterFirstUser)); 8182 if (authGateUserEl instanceof HTMLInputElement && !authGateUserEl.value && authUser instanceof HTMLInputElement) { 8183 authGateUserEl.value = authUser.value || ""; 8184 } 8185 if (authGateCodeEl instanceof HTMLInputElement && !authGateCodeEl.value && authCode instanceof HTMLInputElement) { 8186 authGateCodeEl.value = authCode.value || ""; 8187 } 8188 if (loggedInUser && authGatePassEl instanceof HTMLInputElement) authGatePassEl.value = ""; 8189 renderOnboardingGateHint(); 8190 renderOnboardingCard(); 8191 renderAuthGate(); 8192 renderModPanel(); 8193 if (tourBtn instanceof HTMLButtonElement) { 8194 tourBtn.textContent = shouldAutoShowGuidedTour(loggedInUser) ? "Tour" : "Tour (replay)"; 8195 } 8196 } 8197 8198 function normalizeTourUser(raw) { 8199 return String(raw || "") 8200 .trim() 8201 .toLowerCase() 8202 .replace(/[^a-z0-9_.-]/g, "") 8203 .slice(0, 64); 8204 } 8205 8206 function guidedTourStorageKey(user = loggedInUser) { 8207 const who = normalizeTourUser(user) || "guest"; 8208 return `bzl_guidedTour_pref_v${TOUR_SEEN_VERSION}:${location.host}:${who}`; 8209 } 8210 8211 function readGuidedTourPref(user = loggedInUser) { 8212 const fallback = { completed: false, dontShow: false }; 8213 try { 8214 const raw = localStorage.getItem(guidedTourStorageKey(user)); 8215 if (!raw) return fallback; 8216 const parsed = JSON.parse(raw); 8217 if (!parsed || typeof parsed !== "object") return fallback; 8218 return { 8219 completed: Boolean(parsed.completed), 8220 dontShow: Boolean(parsed.dontShow), 8221 }; 8222 } catch { 8223 return fallback; 8224 } 8225 } 8226 8227 function writeGuidedTourPref(user = loggedInUser, patch = {}) { 8228 const current = readGuidedTourPref(user); 8229 const next = { 8230 completed: Object.prototype.hasOwnProperty.call(patch, "completed") ? Boolean(patch.completed) : current.completed, 8231 dontShow: Object.prototype.hasOwnProperty.call(patch, "dontShow") ? Boolean(patch.dontShow) : current.dontShow, 8232 }; 8233 try { 8234 localStorage.setItem(guidedTourStorageKey(user), JSON.stringify(next)); 8235 } catch { 8236 // ignore 8237 } 8238 } 8239 8240 function shouldAutoShowGuidedTour(user = loggedInUser) { 8241 const pref = readGuidedTourPref(user); 8242 return !pref.completed && !pref.dontShow; 8243 } 8244 8245 function guidedTourViewportAnchor(targetEl) { 8246 const target = targetEl instanceof HTMLElement ? targetEl : null; 8247 if (!(target instanceof HTMLElement)) return null; 8248 const panel = target.closest?.(".rackPanel"); 8249 if (panel instanceof HTMLElement) { 8250 const rackId = rackIdForPanelElement(panel); 8251 if (isWorkspaceRackId(rackId)) return panel; 8252 } 8253 return target; 8254 } 8255 8256 function focusGuidedTourTarget(targetEl) { 8257 const target = targetEl instanceof HTMLElement ? targetEl : null; 8258 if (!(target instanceof HTMLElement)) return; 8259 const anchor = guidedTourViewportAnchor(target); 8260 if (!(anchor instanceof HTMLElement)) return; 8261 try { 8262 const panel = anchor.closest?.(".rackPanel"); 8263 if (panel instanceof HTMLElement && anchor === panel && isWorkspaceRackId(rackIdForPanelElement(panel))) { 8264 followWorkspacePanel(panel); 8265 } else { 8266 anchor.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" }); 8267 } 8268 } catch { 8269 // ignore 8270 } 8271 requestAnimationFrame(() => updateGuidedTourSpotlight()); 8272 setTimeout(() => updateGuidedTourSpotlight(), 140); 8273 setTimeout(() => updateGuidedTourSpotlight(), 320); 8274 } 8275 8276 function revealAuthPanelForGuests() { 8277 if (loggedInUser) return; 8278 if (guestAuthPanelRevealed) return; 8279 const target = accountPanel instanceof HTMLElement ? accountPanel : authForm; 8280 if (!(target instanceof HTMLElement)) return; 8281 try { 8282 if (sidebarScrollEl instanceof HTMLElement) { 8283 const top = Math.max(0, target.offsetTop - 16); 8284 sidebarScrollEl.scrollTo({ top, behavior: "smooth" }); 8285 } else { 8286 target.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" }); 8287 } 8288 guestAuthPanelRevealed = true; 8289 } catch { 8290 // ignore 8291 } 8292 } 8293 8294 function clearTourContextDim() { 8295 for (const el of Array.from(document.querySelectorAll(".rackPanel.tourContextDim"))) { 8296 el.classList.remove("tourContextDim"); 8297 } 8298 } 8299 8300 function setTourContextDimForTarget(targetEl) { 8301 clearTourContextDim(); 8302 const target = targetEl instanceof HTMLElement ? targetEl : null; 8303 if (!(target instanceof HTMLElement)) return; 8304 const targetPanel = target.closest?.(".rackPanel"); 8305 if (!(targetPanel instanceof HTMLElement)) return; 8306 const panels = Array.from(document.querySelectorAll(".rackPanel")); 8307 for (const panel of panels) { 8308 if (!(panel instanceof HTMLElement)) continue; 8309 if (panel === targetPanel) continue; 8310 panel.classList.add("tourContextDim"); 8311 } 8312 } 8313 8314 function clearGuidedTourTarget() { 8315 if (guidedTourTargetEl instanceof HTMLElement) { 8316 guidedTourTargetEl.classList.remove("tourTargetPulse"); 8317 } 8318 clearTourContextDim(); 8319 guidedTourTargetEl = null; 8320 if (guidedTourOverlayEl instanceof HTMLElement) { 8321 delete guidedTourOverlayEl.dataset.cardpos; 8322 guidedTourOverlayEl.style.removeProperty("--tour-x"); 8323 guidedTourOverlayEl.style.removeProperty("--tour-y"); 8324 guidedTourOverlayEl.style.removeProperty("--tour-r"); 8325 guidedTourOverlayEl.style.removeProperty("--tour-left"); 8326 guidedTourOverlayEl.style.removeProperty("--tour-top"); 8327 guidedTourOverlayEl.style.removeProperty("--tour-w"); 8328 guidedTourOverlayEl.style.removeProperty("--tour-h"); 8329 guidedTourOverlayEl.style.removeProperty("--tour-br"); 8330 } 8331 if (guidedTourFocusEl instanceof HTMLElement) guidedTourFocusEl.classList.add("hidden"); 8332 } 8333 8334 function clearGuidedTourTimers() { 8335 if (guidedTourTaskTimer) { 8336 clearInterval(guidedTourTaskTimer); 8337 guidedTourTaskTimer = null; 8338 } 8339 if (guidedTourAutoAdvanceTimer) { 8340 clearTimeout(guidedTourAutoAdvanceTimer); 8341 guidedTourAutoAdvanceTimer = null; 8342 } 8343 } 8344 8345 function updateGuidedTourSpotlight() { 8346 if (!(guidedTourOverlayEl instanceof HTMLElement) || !(guidedTourTargetEl instanceof HTMLElement)) return; 8347 setTourContextDimForTarget(guidedTourTargetEl); 8348 } 8349 8350 function setGuidedTourCardPlacement(targetEl) { 8351 if (!(guidedTourOverlayEl instanceof HTMLElement)) return; 8352 if (window.innerWidth <= 780) { 8353 guidedTourOverlayEl.dataset.cardpos = "bottom"; 8354 return; 8355 } 8356 const target = targetEl instanceof HTMLElement ? targetEl : null; 8357 if (!(target instanceof HTMLElement)) { 8358 guidedTourOverlayEl.dataset.cardpos = "bottom"; 8359 return; 8360 } 8361 const rect = target.getBoundingClientRect(); 8362 let pos = "bottom"; 8363 if (rect.top > window.innerHeight * 0.58) pos = "top"; 8364 else if (rect.bottom < window.innerHeight * 0.42) pos = "bottom"; 8365 else if (rect.left > window.innerWidth * 0.58) pos = "left"; 8366 else if (rect.right < window.innerWidth * 0.42) pos = "right"; 8367 guidedTourOverlayEl.dataset.cardpos = pos; 8368 } 8369 8370 function ensureGuidedTourUi() { 8371 if (guidedTourOverlayEl instanceof HTMLElement) return; 8372 8373 const overlay = document.createElement("div"); 8374 overlay.id = "guidedTourOverlay"; 8375 overlay.className = "guidedTourOverlay hidden"; 8376 overlay.innerHTML = ` 8377 <div class="guidedTourShade"></div> 8378 <div class="guidedTourFocus hidden" id="guidedTourFocus"></div> 8379 <div class="guidedTourCard"> 8380 <div class="guidedTourStep" id="guidedTourStep"></div> 8381 <div class="guidedTourTitle" id="guidedTourTitle"></div> 8382 <div class="guidedTourBody small" id="guidedTourBody"></div> 8383 <div class="guidedTourTask" id="guidedTourTask"></div> 8384 <div class="guidedTourStatus small muted" id="guidedTourStatus"></div> 8385 <label class="guidedTourDontShow small muted"> 8386 <input type="checkbox" id="guidedTourDontShow" /> 8387 <span>Don't show this tour again</span> 8388 </label> 8389 <div class="row guidedTourActions"> 8390 <button type="button" class="ghost smallBtn" id="guidedTourPrev">Back</button> 8391 <button type="button" class="primary smallBtn" id="guidedTourNext">Next</button> 8392 <button type="button" class="ghost smallBtn" id="guidedTourSkip">End tour</button> 8393 </div> 8394 </div> 8395 `; 8396 document.body.appendChild(overlay); 8397 8398 guidedTourOverlayEl = overlay; 8399 guidedTourCardEl = overlay.querySelector(".guidedTourCard"); 8400 guidedTourFocusEl = overlay.querySelector("#guidedTourFocus"); 8401 guidedTourStepEl = overlay.querySelector("#guidedTourStep"); 8402 guidedTourTitleEl = overlay.querySelector("#guidedTourTitle"); 8403 guidedTourBodyEl = overlay.querySelector("#guidedTourBody"); 8404 guidedTourTaskEl = overlay.querySelector("#guidedTourTask"); 8405 guidedTourStatusEl = overlay.querySelector("#guidedTourStatus"); 8406 guidedTourDontShowEl = overlay.querySelector("#guidedTourDontShow"); 8407 guidedTourPrevBtn = overlay.querySelector("#guidedTourPrev"); 8408 guidedTourNextBtn = overlay.querySelector("#guidedTourNext"); 8409 guidedTourSkipBtn = overlay.querySelector("#guidedTourSkip"); 8410 8411 guidedTourPrevBtn?.addEventListener("click", () => guidedTourGo(-1)); 8412 guidedTourNextBtn?.addEventListener("click", () => guidedTourGo(1)); 8413 guidedTourSkipBtn?.addEventListener("click", () => stopGuidedTour({ completed: false })); 8414 window.addEventListener("resize", () => { 8415 if (guidedTourState.active) updateGuidedTourSpotlight(); 8416 }); 8417 window.addEventListener( 8418 "scroll", 8419 () => { 8420 if (guidedTourState.active) updateGuidedTourSpotlight(); 8421 }, 8422 true 8423 ); 8424 } 8425 8426 function tourCanMakePermanent() { 8427 return Boolean(loggedInUser) && (isStaffRole(loggedInRole) || Boolean(normalizeInstanceBranding(instanceBranding).allowMemberPermanentPosts)); 8428 } 8429 8430 function buildGuidedTourSteps({ startedSignedIn = Boolean(loggedInUser) } = {}) { 8431 const canPermanent = tourCanMakePermanent(); 8432 const durationNote = canPermanent 8433 ? "Use Quick duration for common timers, or toggle Keep forever for a permanent hive." 8434 : "Use Quick duration for common timers. Keep forever is available when enabled by server staff."; 8435 const workspaceVisibleCount = () => { 8436 const rack = document.getElementById("mainWorkspaceRack"); 8437 if (!(rack instanceof HTMLElement)) return 0; 8438 return rack.querySelectorAll(":scope > .rackPanel:not(.hidden)").length; 8439 }; 8440 8441 return [ 8442 ...(!startedSignedIn 8443 ? [ 8444 { 8445 title: "Sign in first", 8446 selector: "#authGate", 8447 body: "You need an account before using the workstation. Sign in or create one here.", 8448 taskLabel: "Required: sign in or create an account.", 8449 requireTask: true, 8450 taskCheck: () => Boolean(loggedInUser), 8451 }, 8452 ] 8453 : []), 8454 { 8455 title: "Start with New Hive", 8456 selector: "#toggleComposer", 8457 body: "Tutorial mode keeps only core panels visible. Start by opening the composer.", 8458 taskLabel: "Required: press New Hive.", 8459 requireTask: true, 8460 onEnter: () => { 8461 try { 8462 if (rackLayoutEnabled && layoutPresetEl instanceof HTMLSelectElement) { 8463 const canUse = Array.from(layoutPresetEl.options || []).some((opt) => String(opt.value || "") === "tutorial"); 8464 if (canUse) { 8465 if (layoutPresetEl.value !== "tutorial") layoutPresetEl.value = "tutorial"; 8466 applyPreset("tutorial"); 8467 } 8468 } 8469 } catch { 8470 // ignore layout failures 8471 } 8472 }, 8473 taskCheck: () => Boolean(composerOpen), 8474 }, 8475 { 8476 title: "Create your first post", 8477 selector: "#newPostForm", 8478 body: "Enter a title and body, then send your first hive post.", 8479 taskLabel: "Required: publish one hive post.", 8480 requireTask: true, 8481 onEnter: () => { 8482 setComposerOpen(true); 8483 guidedTourStepContext.firstPostCutoff = Date.now(); 8484 }, 8485 taskCheck: () => { 8486 const me = String(loggedInUser || "").trim().toLowerCase(); 8487 if (!me) return false; 8488 const cutoff = Number(guidedTourStepContext.firstPostCutoff || 0); 8489 for (const post of posts.values()) { 8490 if (!post || typeof post !== "object") continue; 8491 if (String(post.author || "").trim().toLowerCase() !== me) continue; 8492 const createdAt = Number(post.createdAt || 0); 8493 if (createdAt > 0 && createdAt >= cutoff) return true; 8494 } 8495 return false; 8496 }, 8497 }, 8498 { 8499 title: "Post duration", 8500 selector: "#ttlPreset", 8501 body: 8502 `TTL is now called Post duration. ${durationNote} You can still type exact minutes when needed.`, 8503 taskLabel: "Optional: change Quick duration or toggle Keep forever.", 8504 onEnter: () => { 8505 setComposerOpen(true); 8506 guidedTourStepContext.durationBaseline = `${String(ttlPresetEl?.value || "60")}|${ttlPermanentEl?.checked ? "1" : "0"}`; 8507 }, 8508 taskCheck: () => { 8509 const current = `${String(ttlPresetEl?.value || "60")}|${ttlPermanentEl?.checked ? "1" : "0"}`; 8510 return current !== String(guidedTourStepContext.durationBaseline || ""); 8511 }, 8512 }, 8513 { 8514 title: "Composer toggles", 8515 selector: "#pollinatePanel", 8516 body: 8517 "Before you post, check toggles: Protected adds a password, Hive mode selects text/walkie/stream, and Post duration controls expiry.", 8518 taskLabel: "", 8519 onEnter: () => { 8520 setComposerOpen(true); 8521 }, 8522 taskCheck: null, 8523 }, 8524 { 8525 title: "Open a hive chat room", 8526 selector: "#feed", 8527 body: "Each hive card is its own chat room. Open a hive and tap Chat to join that room.", 8528 taskLabel: "Required: open a hive chat.", 8529 requireTask: true, 8530 taskCheck: () => Boolean(activeChatPostId), 8531 }, 8532 { 8533 title: "Chat panel basics", 8534 selector: ".chat", 8535 body: 8536 "This panel is your active room. Header shows the current chat, the message list stays in the middle, and the composer at the bottom supports text, images, audio, replies, and mentions.", 8537 taskLabel: "", 8538 taskCheck: null, 8539 }, 8540 { 8541 title: "Members and profile", 8542 selector: "#peopleDrawer", 8543 body: "Members list is on the right rail. Click your own name to open your profile panel.", 8544 taskLabel: "Required: open your profile from Members list.", 8545 requireTask: true, 8546 onEnter: () => { 8547 peopleTab = "members"; 8548 renderPeoplePanel(); 8549 setRightCollapsed(false); 8550 }, 8551 taskCheck: () => String(activeProfileUsername || "").toLowerCase() === String(loggedInUser || "").toLowerCase(), 8552 }, 8553 { 8554 title: "Minimize all workspace panels", 8555 selector: "#mainWorkspaceRack", 8556 body: "Minimize everything in the workspace so the hotbar workflow is clear.", 8557 taskLabel: "Required: minimize all workspace panels.", 8558 requireTask: true, 8559 taskCheck: () => workspaceVisibleCount() === 0, 8560 }, 8561 { 8562 title: "Hotbar and shortcuts", 8563 selector: "#dockHotbar", 8564 body: 8565 "Use the hotbar to restore panels (click, drag, or keyboard). Keyboard: Up restores from hotbar, Down docks hovered workspace panel, Left/Right reorders, Up cycles size.", 8566 taskLabel: "Required: add one panel back from the hotbar.", 8567 requireTask: true, 8568 onEnter: () => { 8569 guidedTourStepContext.workspaceCountBaseline = workspaceVisibleCount(); 8570 }, 8571 taskCheck: () => workspaceVisibleCount() > Number(guidedTourStepContext.workspaceCountBaseline || 0), 8572 }, 8573 { 8574 title: "Sizes and reorder", 8575 selector: "#mainWorkspaceRack", 8576 body: "Panels can be skinny, half, or full width. Add another panel, then try resizing and reordering.", 8577 taskLabel: "Required: get at least 2 workspace panels and change their order once.", 8578 requireTask: true, 8579 onEnter: () => { 8580 const rack = document.getElementById("mainWorkspaceRack"); 8581 const ids = rack instanceof HTMLElement 8582 ? Array.from(rack.querySelectorAll(":scope > .rackPanel:not(.hidden)")).map((el) => String(el.dataset.panelId || "")) 8583 : []; 8584 guidedTourStepContext.reorderBaseline = ids.join("|"); 8585 }, 8586 taskCheck: () => { 8587 const rack = document.getElementById("mainWorkspaceRack"); 8588 if (!(rack instanceof HTMLElement)) return false; 8589 const ids = Array.from(rack.querySelectorAll(":scope > .rackPanel:not(.hidden)")).map((el) => String(el.dataset.panelId || "")); 8590 if (ids.length < 2) return false; 8591 return ids.join("|") !== String(guidedTourStepContext.reorderBaseline || ""); 8592 }, 8593 }, 8594 { 8595 title: "User bar", 8596 selector: "#accountPanel", 8597 body: "This user bar shows sign-in state, account controls, and quick access to personal settings.", 8598 taskLabel: "", 8599 taskCheck: null, 8600 }, 8601 { 8602 title: "You're ready", 8603 selector: "#poweredByTileLink", 8604 body: 8605 'Bzl is open source: <a href="https://github.com/bzlapp/Bzl/" target="_blank" rel="noopener noreferrer">github.com/bzlapp/Bzl</a><br><br>Have fun building your workspace.', 8606 taskLabel: "", 8607 taskCheck: null, 8608 }, 8609 ]; 8610 } 8611 8612 function guidedTourTaskSatisfied(step) { 8613 if (!step || typeof step.taskCheck !== "function") return false; 8614 try { 8615 return Boolean(step.taskCheck()); 8616 } catch { 8617 return false; 8618 } 8619 } 8620 8621 function guidedTourGo(delta) { 8622 if (!guidedTourState.active) return; 8623 const current = guidedTourCurrentStep(); 8624 if (Number(delta || 0) > 0 && current?.requireTask && !guidedTourTaskSatisfied(current)) { 8625 if (guidedTourStatusEl) guidedTourStatusEl.textContent = "Complete the required step to continue."; 8626 return; 8627 } 8628 const next = Math.max(0, Math.min(guidedTourState.steps.length - 1, guidedTourState.index + Number(delta || 0))); 8629 if (next === guidedTourState.index && delta > 0 && next >= guidedTourState.steps.length - 1) { 8630 stopGuidedTour({ completed: true }); 8631 return; 8632 } 8633 guidedTourState.index = next; 8634 renderGuidedTourStep(); 8635 } 8636 8637 function guidedTourCurrentStep() { 8638 if (!guidedTourState.active) return null; 8639 return guidedTourState.steps[guidedTourState.index] || null; 8640 } 8641 8642 function renderGuidedTourStep() { 8643 const step = guidedTourCurrentStep(); 8644 if (!step) return; 8645 8646 clearGuidedTourTimers(); 8647 clearGuidedTourTarget(); 8648 guidedTourStepContext = {}; 8649 8650 if (typeof step.onEnter === "function") { 8651 try { 8652 step.onEnter(); 8653 } catch { 8654 // ignore task baseline errors 8655 } 8656 } 8657 8658 if (guidedTourStepEl) guidedTourStepEl.textContent = `Step ${guidedTourState.index + 1} / ${guidedTourState.steps.length}`; 8659 if (guidedTourTitleEl) guidedTourTitleEl.textContent = step.title || "Tour"; 8660 if (guidedTourBodyEl) guidedTourBodyEl.innerHTML = step.body || ""; 8661 if (guidedTourTaskEl) guidedTourTaskEl.textContent = step.taskLabel || ""; 8662 if (guidedTourStatusEl) { 8663 guidedTourStatusEl.textContent = step.taskLabel 8664 ? step.requireTask 8665 ? "This step is required to continue." 8666 : "Do the task or press Next." 8667 : ""; 8668 } 8669 8670 const selector = String(step.selector || "").trim(); 8671 const target = selector ? document.querySelector(selector) : null; 8672 setGuidedTourCardPlacement(target instanceof HTMLElement ? target : null); 8673 if (target instanceof HTMLElement) { 8674 guidedTourTargetEl = target; 8675 guidedTourTargetEl.classList.add("tourTargetPulse"); 8676 focusGuidedTourTarget(guidedTourTargetEl); 8677 } 8678 8679 if (guidedTourPrevBtn) guidedTourPrevBtn.disabled = guidedTourState.index <= 0; 8680 if (guidedTourNextBtn) { 8681 guidedTourNextBtn.textContent = guidedTourState.index >= guidedTourState.steps.length - 1 ? "Finish" : "Next"; 8682 guidedTourNextBtn.disabled = Boolean(step.requireTask && !guidedTourTaskSatisfied(step)); 8683 } 8684 8685 if (typeof step.taskCheck === "function") { 8686 guidedTourTaskTimer = setInterval(() => { 8687 if (!guidedTourState.active) return; 8688 const current = guidedTourCurrentStep(); 8689 if (!current || current !== step) return; 8690 const ok = guidedTourTaskSatisfied(step); 8691 if (guidedTourNextBtn) guidedTourNextBtn.disabled = Boolean(step.requireTask && !ok); 8692 if (!ok) return; 8693 clearGuidedTourTimers(); 8694 if (guidedTourStatusEl) guidedTourStatusEl.textContent = "Task complete. Moving on..."; 8695 guidedTourAutoAdvanceTimer = setTimeout(() => guidedTourGo(1), TOUR_AUTO_ADVANCE_MS); 8696 }, TOUR_TASK_POLL_MS); 8697 } 8698 } 8699 8700 function startGuidedTour({ auto = false } = {}) { 8701 if (!loggedInUser) { 8702 toast("Tour", "Sign in first to start the tutorial."); 8703 renderAuthGate(); 8704 return; 8705 } 8706 if (onboardingNeedsAcceptanceNow()) { 8707 toast("Tour", "Accept server rules first, then start the tutorial."); 8708 renderAuthGate(); 8709 return; 8710 } 8711 try { 8712 if (rackLayoutEnabled && layoutPresetEl instanceof HTMLSelectElement) { 8713 const canUse = Array.from(layoutPresetEl.options || []).some((opt) => String(opt.value || "") === "tutorial"); 8714 if (canUse) { 8715 if (layoutPresetEl.value !== "tutorial") layoutPresetEl.value = "tutorial"; 8716 applyPreset("tutorial"); 8717 } 8718 } 8719 } catch { 8720 // ignore preset setup failures 8721 } 8722 ensureGuidedTourUi(); 8723 const startedSignedIn = Boolean(loggedInUser); 8724 guidedTourState = { active: true, index: 0, steps: buildGuidedTourSteps({ startedSignedIn }), startedSignedIn }; 8725 if (guidedTourOverlayEl instanceof HTMLElement) guidedTourOverlayEl.classList.remove("hidden"); 8726 document.body.classList.add("tourActive"); 8727 if (guidedTourDontShowEl instanceof HTMLInputElement) guidedTourDontShowEl.checked = false; 8728 if (!auto) toast("Tour", "Tour started. Complete optional tasks or press Next."); 8729 renderGuidedTourStep(); 8730 } 8731 8732 function stopGuidedTour({ completed = false, seenUser = loggedInUser } = {}) { 8733 const wasActive = Boolean(guidedTourState.active); 8734 clearGuidedTourTimers(); 8735 clearGuidedTourTarget(); 8736 if (guidedTourOverlayEl instanceof HTMLElement) guidedTourOverlayEl.classList.add("hidden"); 8737 document.body.classList.remove("tourActive"); 8738 guidedTourState = { active: false, index: 0, steps: [], startedSignedIn: false }; 8739 if (wasActive) { 8740 const suppressFuture = Boolean(guidedTourDontShowEl instanceof HTMLInputElement && guidedTourDontShowEl.checked); 8741 if (completed) { 8742 writeGuidedTourPref(seenUser, { completed: true, dontShow: false }); 8743 } else if (suppressFuture) { 8744 writeGuidedTourPref(seenUser, { dontShow: true }); 8745 } 8746 if (tourBtn instanceof HTMLButtonElement) { 8747 tourBtn.textContent = shouldAutoShowGuidedTour(seenUser) ? "Tour" : "Tour (replay)"; 8748 } 8749 if (completed) toast("Tour", "Tour complete. Welcome to Bzl."); 8750 } 8751 } 8752 8753 function maybeAutoStartGuidedTour() { 8754 if (!loggedInUser) return; 8755 if (onboardingNeedsAcceptanceNow()) return; 8756 const user = normalizeTourUser(loggedInUser || "guest"); 8757 if (!user) return; 8758 if (guidedTourState.active) return; 8759 if (!shouldAutoShowGuidedTour(user)) return; 8760 if (guidedTourAutoStartedForUser === user) return; 8761 guidedTourAutoStartedForUser = user; 8762 setTimeout(() => { 8763 const currentUser = normalizeTourUser(loggedInUser || "guest"); 8764 if (!currentUser || currentUser !== user) return; 8765 if (!guidedTourState.active) startGuidedTour({ auto: true }); 8766 }, 550); 8767 } 8768 8769 function roleLabel(role) { 8770 const r = String(role || "member").toLowerCase(); 8771 return r === "owner" || r === "admin" || r === "moderator" ? r : "member"; 8772 } 8773 8774 function peopleOnlineCardStyle(member) { 8775 if (!member?.online) return ""; 8776 const rgb = hexToRgb(member.color || ""); 8777 if (!rgb) { 8778 return `style="border-color:rgba(255,62,165,0.35);box-shadow:0 10px 24px rgba(255,62,165,0.12);"`; 8779 } 8780 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);"`; 8781 } 8782 8783 function renderPeoplePanel() { 8784 if (!peopleDrawerEl || !peopleListEl) return; 8785 ensurePeopleFallback(); 8786 const membersTabOn = peopleTab === "members"; 8787 peopleMembersTabBtn?.classList.toggle("primary", membersTabOn); 8788 peopleMembersTabBtn?.classList.toggle("ghost", !membersTabOn); 8789 peopleDmsTabBtn?.classList.toggle("primary", !membersTabOn); 8790 peopleDmsTabBtn?.classList.toggle("ghost", membersTabOn); 8791 peopleMembersViewEl?.classList.toggle("hidden", !membersTabOn); 8792 peopleDmsViewEl?.classList.toggle("hidden", membersTabOn); 8793 if (!membersTabOn) { 8794 if (!peopleDmsViewEl) return; 8795 if (!loggedInUser) { 8796 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>`; 8797 return; 8798 } 8799 8800 const blockedSet = prefSet("blockedUsers"); 8801 const eligibleMembers = peopleMembers 8802 .filter((m) => m?.username && String(m.username).toLowerCase() !== String(loggedInUser).toLowerCase()) 8803 .filter((m) => !blockedSet.has(String(m.username || "").toLowerCase())) 8804 .map((m) => String(m.username)) 8805 .sort((a, b) => a.localeCompare(b)) 8806 .slice(0, 250); 8807 8808 const picker = 8809 eligibleMembers.length > 0 8810 ? `<div class="dmNewRow"> 8811 <select class="dmToSelect" data-dmto="1"> 8812 <option value="">New DM...</option> 8813 ${eligibleMembers.map((u) => `<option value="${escapeHtml(u)}">@${escapeHtml(u)}</option>`).join("")} 8814 </select> 8815 <button type="button" class="primary" data-dmrequestfromselect="1">Request</button> 8816 </div>` 8817 : `<div class="muted">No other members yet.</div>`; 8818 8819 const threads = Array.isArray(dmThreads) ? [...dmThreads].sort((a, b) => dmActivityAt(b) - dmActivityAt(a)) : []; 8820 const listHtml = threads.length 8821 ? threads 8822 .map((t) => { 8823 const other = String(t.other || ""); 8824 const isBlocked = blockedSet.has(other.toLowerCase()); 8825 const status = String(t.status || "unknown"); 8826 const when = dmActivityAt(t); 8827 const whenTxt = when ? new Date(when).toLocaleString() : ""; 8828 const statusBadge = 8829 status === "incoming" 8830 ? `<span class="tag dmTag dmTagIncoming">request</span>` 8831 : status === "outgoing" 8832 ? `<span class="tag dmTag dmTagPending">pending</span>` 8833 : status === "active" 8834 ? `<span class="tag dmTag dmTagActive">active</span>` 8835 : status === "declined" 8836 ? `<span class="tag dmTag dmTagDeclined">declined</span>` 8837 : `<span class="tag dmTag">unknown</span>`; 8838 8839 const blockedBadge = isBlocked ? `<span class="tag dmTag dmTagDeclined">blocked</span>` : ""; 8840 8841 let actions = 8842 status === "incoming" 8843 ? `<div class="row" style="gap:8px;justify-content:flex-end"> 8844 <button type="button" class="primary smallBtn" data-dmaccept="${escapeHtml(t.id)}">Accept</button> 8845 <button type="button" class="ghost smallBtn" data-dmdecline="${escapeHtml(t.id)}">Decline</button> 8846 </div>` 8847 : status === "active" 8848 ? `<button type="button" class="primary smallBtn" data-dmopen="${escapeHtml(t.id)}">Open</button>` 8849 : status === "declined" 8850 ? `<button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(other)}">Request again</button>` 8851 : `<span class="muted small">Waiting...</span>`; 8852 8853 if (isBlocked) { 8854 actions = 8855 status === "active" 8856 ? `<button type="button" class="primary smallBtn" data-dmopen="${escapeHtml(t.id)}" disabled>Open</button>` 8857 : `<span class="muted small">Blocked</span>`; 8858 } 8859 if (canModerate && other) { 8860 actions += ` <button type="button" class="ghost smallBtn" data-moddm="${escapeHtml(other)}">Mod DM</button>`; 8861 } 8862 8863 return `<div class="dmThreadCard"> 8864 <div class="dmThreadTop"> 8865 <div class="dmThreadLeft"> 8866 ${renderUserPill(other)} 8867 ${statusBadge} 8868 ${blockedBadge} 8869 </div> 8870 <div class="dmThreadRight">${actions}</div> 8871 </div> 8872 <div class="small muted">${whenTxt ? `Last activity: ${escapeHtml(whenTxt)}` : "No messages yet."} <span class="muted">β’</span> DMs purge daily.</div> 8873 </div>`; 8874 }) 8875 .join("") 8876 : `<div class="muted">No DMs yet. Start one from the Members tab or a profile.</div>`; 8877 8878 peopleDmsViewEl.innerHTML = ` 8879 <div class="dmHeader"> 8880 <div class="small muted">Private 1:1 chats (encrypted at rest). Incoming requests must be accepted.</div> 8881 ${picker} 8882 </div> 8883 <div class="dmThreadList">${listHtml}</div> 8884 `; 8885 return; 8886 } 8887 8888 const q = (peopleSearchEl?.value || "").trim().toLowerCase(); 8889 const list = peopleMembers 8890 .filter((m) => (q ? String(m.username || "").toLowerCase().includes(q) : true)) 8891 .sort((a, b) => Number(Boolean(b.online)) - Number(Boolean(a.online)) || String(a.username).localeCompare(String(b.username))); 8892 8893 if (!list.length) { 8894 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>`; 8895 return; 8896 } 8897 peopleListEl.innerHTML = list 8898 .map((m) => { 8899 const username = String(m.username || ""); 8900 const status = String(m.status || (m.online ? "online" : "offline")); 8901 const role = roleLabel(m.role); 8902 const statusText = `${status}${m.online ? "" : ""}`; 8903 const cardStyle = peopleOnlineCardStyle(m); 8904 const canDm = Boolean(loggedInUser && username && String(username).toLowerCase() !== String(loggedInUser).toLowerCase()); 8905 const canModDm = Boolean(canModerate && username && String(username).toLowerCase() !== String(loggedInUser || "").toLowerCase()); 8906 return `<div class="peopleCard" data-viewprofile="${escapeHtml(username)}" ${cardStyle}> 8907 <div class="peopleCardTop"> 8908 <div>${renderUserPill(username)} <span class="modStatus">${escapeHtml(role)}</span></div> 8909 <div class="peopleStatus">${escapeHtml(statusText)}</div> 8910 </div> 8911 <div class="peopleCardActions"> 8912 <button type="button" class="ghost smallBtn" data-viewprofile="${escapeHtml(username)}">Profile</button> 8913 <button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(username)}" ${canDm ? "" : "disabled"}>DM</button> 8914 ${canModDm ? `<button type="button" class="ghost smallBtn" data-moddm="${escapeHtml(username)}">Mod DM</button>` : ""} 8915 </div> 8916 </div>`; 8917 }) 8918 .join(""); 8919 } 8920 8921 function statusBadge(status) { 8922 const s = String(status || ""); 8923 if (!s) return `<span class="muted">-</span>`; 8924 return `<span class="modStatus">${escapeHtml(s)}</span>`; 8925 } 8926 8927 function userStateText(user) { 8928 const t = Date.now(); 8929 if (user.banned) return "banned"; 8930 if (Number(user.suspendedUntil || 0) > t) return `suspended until ${new Date(user.suspendedUntil).toLocaleString()}`; 8931 if (Number(user.mutedUntil || 0) > t) return `muted until ${new Date(user.mutedUntil).toLocaleString()}`; 8932 return "active"; 8933 } 8934 8935 function promptReason(actionLabel) { 8936 const value = prompt(`Reason for ${actionLabel}:`); 8937 if (!value) return ""; 8938 return value.trim(); 8939 } 8940 8941 function requestModData() { 8942 if (!canModerate) return; 8943 ws.send(JSON.stringify({ type: "modListUsers", limit: 200 })); 8944 ws.send(JSON.stringify({ type: "modListLog", limit: 200 })); 8945 ws.send(JSON.stringify({ type: "devLogList", limit: 300 })); 8946 const status = modReportStatusEl ? modReportStatusEl.value : "open"; 8947 ws.send(JSON.stringify({ type: "modListReports", status, limit: 200 })); 8948 } 8949 8950 function renderModPanel() { 8951 if (!modPanelEl || !modBodyEl) return; 8952 modPanelEl.classList.toggle("hidden", !canModerate); 8953 if (appRoot) appRoot.classList.toggle("hasMod", canModerate); 8954 if (!canModerate) { 8955 modBodyEl.innerHTML = ""; 8956 if (isMobileScreenMode() && appRoot?.getAttribute("data-mobile-screen") === "moderation") setMobileScreen("hives", { pushHistory: false }); 8957 return; 8958 } 8959 if (modReportStatusEl) modReportStatusEl.classList.toggle("hidden", modTab !== "reports"); 8960 8961 const tabs = Array.from(modPanelEl.querySelectorAll("[data-modtab]")); 8962 for (const btn of tabs) { 8963 const on = btn.getAttribute("data-modtab") === modTab; 8964 btn.classList.toggle("primary", on); 8965 btn.classList.toggle("ghost", !on); 8966 // Owner-only plugin tabs should not show for non-owners. 8967 const ownerOnly = btn.dataset.ownerOnly === "1"; 8968 btn.classList.toggle("hidden", Boolean(ownerOnly && !isOwnerRole(loggedInRole) && !isAdminRole(loggedInRole))); 8969 } 8970 8971 // Plugin-provided moderation tabs (render into modBody). 8972 if (modPluginTabs.has(modTab)) { 8973 const def = modPluginTabs.get(modTab); 8974 if (def?.ownerOnly && !isOwnerRole(loggedInRole) && !isAdminRole(loggedInRole)) { 8975 modTab = "server"; 8976 renderModPanel(); 8977 return; 8978 } 8979 modBodyEl.innerHTML = ` 8980 <div class="modCard"> 8981 <div class="modRowTop"><div><b>${escapeHtml(def?.title || "Plugin")}</b></div></div> 8982 <div id="modPluginMount" class="modActions"></div> 8983 </div> 8984 `; 8985 const mount = modBodyEl.querySelector("#modPluginMount"); 8986 if (mount) { 8987 const api = { 8988 toast, 8989 send: (eventName, payload) => { 8990 const ev = String(eventName || "").trim(); 8991 if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false; 8992 const wsRef = window.__bzlWs; 8993 if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false; 8994 const msg = payload && typeof payload === "object" ? payload : {}; 8995 wsRef.send(JSON.stringify({ ...msg, type: `plugin:${def.pluginId}:${ev}` })); 8996 return true; 8997 }, 8998 getUser: () => loggedInUser, 8999 getRole: () => loggedInRole, 9000 }; 9001 try { 9002 def.render(mount, api); 9003 } catch (e) { 9004 mount.textContent = "Failed to render plugin tab."; 9005 console.warn(`Plugin tab render failed (${modTab}):`, e?.message || e); 9006 } 9007 } 9008 return; 9009 } 9010 9011 if (modTab === "server") { 9012 const isOwner = isOwnerRole(loggedInRole) || isAdminRole(loggedInRole); 9013 const b = normalizeInstanceBranding(instanceBranding); 9014 const loading = Boolean(serverInfoStatus.loading); 9015 const err = String(serverInfoStatus.error || ""); 9016 const info = serverInfo && typeof serverInfo === "object" ? serverInfo : null; 9017 const health = serverHealth && typeof serverHealth === "object" ? serverHealth : null; 9018 const stats = health?.stats && typeof health.stats === "object" ? health.stats : null; 9019 const rl = info?.config?.rateLimits && typeof info.config.rateLimits === "object" ? info.config.rateLimits : null; 9020 const updatedAt = serverInfoStatus.at ? formatLocalTime(serverInfoStatus.at) : ""; 9021 9022 const statusLine = loading 9023 ? `<span class="muted">Loading...</span>` 9024 : err 9025 ? `<span class="bad">${escapeHtml(err)}</span>` 9026 : updatedAt 9027 ? `<span class="muted">Updated: ${escapeHtml(updatedAt)}</span>` 9028 : `<span class="muted">Not loaded yet.</span>`; 9029 9030 const instanceOwnerControls = `<label> 9031 <span>Title</span> 9032 <input data-instance-title maxlength="32" value="${escapeHtml(b.title)}" /> 9033 </label> 9034 <label> 9035 <span>Subtitle</span> 9036 <input data-instance-subtitle maxlength="80" value="${escapeHtml(b.subtitle)}" /> 9037 </label> 9038 <label class="row" style="gap:10px; align-items:center"> 9039 <input data-instance-allowpermanent type="checkbox" ${b.allowMemberPermanentPosts ? "checked" : ""} /> 9040 <span>Allow members to create permanent hives</span> 9041 </label>`; 9042 9043 const instanceControls = isOwner 9044 ? `${instanceOwnerControls} 9045 <div class="small muted">Look & feel moved to View β Advanced display. Users can now set personal themes there.</div> 9046 <div class="row" style="gap:8px"> 9047 <button type="button" class="primary" data-instance-save="1">Save</button> 9048 <button type="button" class="ghost" data-server-refresh="1">Refresh server</button> 9049 </div>` 9050 : `<div class="small muted">Only the owner/admin can edit core instance settings.</div> 9051 <div class="small">Title: <b>${escapeHtml(b.title)}</b></div> 9052 <div class="small">Subtitle: <b>${escapeHtml(b.subtitle)}</b></div> 9053 <div class="small">Members can create permanent hives: <b>${b.allowMemberPermanentPosts ? "yes" : "no"}</b></div> 9054 <div class="small muted" style="margin-top:6px;">Look & feel moved to View β Advanced display.</div> 9055 <div class="row" style="gap:8px; margin-top:8px"> 9056 <button type="button" class="ghost" data-server-refresh="1">Refresh server</button> 9057 </div>`; 9058 9059 const serverLines = [ 9060 info?.port ? `Port: ${Number(info.port)}` : "", 9061 typeof info?.registrationEnabled === "boolean" ? `Registration enabled: ${info.registrationEnabled ? "yes" : "no"}` : "", 9062 typeof health?.uptimeSec === "number" ? `Uptime: ${Math.floor(health.uptimeSec)}s` : "", 9063 typeof stats?.sockets === "number" ? `Sockets: ${Math.floor(stats.sockets)}` : "", 9064 typeof stats?.activePosts === "number" ? `Active hives: ${Math.floor(stats.activePosts)}` : "", 9065 typeof stats?.users === "number" ? `Users: ${Math.floor(stats.users)}` : "", 9066 typeof stats?.activeRateLimitBuckets === "number" ? `Active rate limit buckets: ${Math.floor(stats.activeRateLimitBuckets)}` : "", 9067 ].filter(Boolean); 9068 9069 const rlLines = rl 9070 ? [ 9071 `Mod actions: ${rl.mod?.max ?? "?"} / ${rl.mod?.windowMs ?? "?"}ms`, 9072 `Login: ${rl.login?.max ?? "?"} / ${rl.login?.windowMs ?? "?"}ms`, 9073 `Register: ${rl.register?.max ?? "?"} / ${rl.register?.windowMs ?? "?"}ms`, 9074 `Resume: ${rl.resume?.max ?? "?"} / ${rl.resume?.windowMs ?? "?"}ms`, 9075 `Reports: ${rl.report?.max ?? "?"} / ${rl.report?.windowMs ?? "?"}ms`, 9076 ] 9077 : []; 9078 9079 modBodyEl.innerHTML = ` 9080 <div class="modCard"> 9081 <div class="modRowTop"> 9082 <div><b>Server</b></div> 9083 <div class="small">${statusLine}</div> 9084 </div> 9085 <div class="small muted">Server status, appearance, and plugins.</div> 9086 </div> 9087 <div class="modCard"> 9088 <div class="modRowTop"><div><b>Instance settings</b></div></div> 9089 <div class="modActions">${instanceControls}</div> 9090 </div> 9091 <div class="modCard"> 9092 <div class="modRowTop"><div><b>Plugins</b></div></div> 9093 <div class="modActions">${renderPluginsAdminHtml()}</div> 9094 </div> 9095 <div class="modCard"> 9096 <div class="modRowTop"><div><b>Runtime</b></div></div> 9097 <div class="small">${serverLines.length ? serverLines.map((x) => `<div>${escapeHtml(x)}</div>`).join("") : `<div class="muted">No data yet.</div>`}</div> 9098 ${ 9099 rlLines.length 9100 ? `<div class="small muted" style="margin-top:10px">Rate limits</div> 9101 <div class="small">${rlLines.map((x) => `<div>${escapeHtml(x)}</div>`).join("")}</div>` 9102 : "" 9103 } 9104 </div> 9105 `; 9106 return; 9107 } 9108 9109 if (modTab === "onboarding") { 9110 const isOwner = isOwnerRole(loggedInRole) || isAdminRole(loggedInRole); 9111 const canEdit = isStaffRole(loggedInRole); 9112 syncOnboardingAdminDraft(false); 9113 normalizeOnboardingDraftRules(); 9114 const roleOptions = customRoles 9115 .map( 9116 (r) => 9117 `<label class="checkRow"> 9118 <span>${escapeHtml(String(r.label || r.key || ""))}</span> 9119 <input type="checkbox" data-onboarding-rolecheck="${escapeHtml(String(r.key || ""))}" ${ 9120 onboardingAdminDraft.selfAssignableRoleIds.includes(String(r.key || "")) ? "checked" : "" 9121 } /> 9122 </label>` 9123 ) 9124 .join(""); 9125 const rulesCards = onboardingAdminDraft.rules.length 9126 ? onboardingAdminDraft.rules 9127 .map((r, idx) => { 9128 const expanded = onboardingAdminExpandedRuleIds.has(r.id); 9129 return `<article class="onbRuleEditorCard" data-onb-ruleid="${escapeHtml(r.id)}"> 9130 <div class="row" style="justify-content:space-between;align-items:center;"> 9131 <button type="button" class="ghost smallBtn" data-onb-ruletoggle="${escapeHtml(r.id)}">${expanded ? "βΎ" : "βΈ"} Rule ${idx + 1}</button> 9132 <div class="row" style="gap:6px;"> 9133 <button type="button" class="ghost smallBtn" data-onb-ruleup="${escapeHtml(r.id)}" ${idx <= 0 ? "disabled" : ""}>β</button> 9134 <button type="button" class="ghost smallBtn" data-onb-ruledown="${escapeHtml(r.id)}" ${ 9135 idx >= onboardingAdminDraft.rules.length - 1 ? "disabled" : "" 9136 }>β</button> 9137 <button type="button" class="ghost smallBtn" data-onb-ruledelete="${escapeHtml(r.id)}">Delete</button> 9138 </div> 9139 </div> 9140 ${ 9141 expanded 9142 ? `<div class="onbRuleEditorBody"> 9143 <label><span>Name</span><input data-onb-rulefield="name" data-onb-ruleid="${escapeHtml(r.id)}" value="${escapeHtml( 9144 r.name 9145 )}" maxlength="60" /></label> 9146 <label><span>Short description</span><input data-onb-rulefield="shortDescription" data-onb-ruleid="${escapeHtml( 9147 r.id 9148 )}" value="${escapeHtml(r.shortDescription)}" maxlength="180" /></label> 9149 <label><span>Full description</span><textarea data-onb-rulefield="description" data-onb-ruleid="${escapeHtml( 9150 r.id 9151 )}" rows="4">${escapeHtml(r.description)}</textarea></label> 9152 <label><span>Severity</span> 9153 <select data-onb-rulefield="severity" data-onb-ruleid="${escapeHtml(r.id)}"> 9154 <option value="info" ${r.severity === "info" ? "selected" : ""}>Info</option> 9155 <option value="warn" ${r.severity === "warn" ? "selected" : ""}>Warn</option> 9156 <option value="critical" ${r.severity === "critical" ? "selected" : ""}>Critical</option> 9157 </select> 9158 </label> 9159 </div>` 9160 : "" 9161 } 9162 </article>`; 9163 }) 9164 .join("") 9165 : `<div class="small muted">No rules yet. Add your first rule.</div>`; 9166 9167 modBodyEl.innerHTML = ` 9168 <div class="modCard"> 9169 <div class="modRowTop"><div><b>Onboarding</b></div></div> 9170 <div class="small muted">Configure About, Rules, and Role Select.</div> 9171 <div class="onbTabs" style="margin-top:8px;"> 9172 <button type="button" class="${onboardingAdminTab === "about" ? "primary" : "ghost"} smallBtn" data-onb-admin-tab="about">About</button> 9173 <button type="button" class="${onboardingAdminTab === "rules" ? "primary" : "ghost"} smallBtn" data-onb-admin-tab="rules">Rules</button> 9174 <button type="button" class="${onboardingAdminTab === "roles" ? "primary" : "ghost"} smallBtn" data-onb-admin-tab="roles">Roles</button> 9175 </div> 9176 </div> 9177 <div class="modCard"> 9178 ${ 9179 onboardingAdminTab === "about" 9180 ? `<label class="checkRow"> 9181 <span>Enable onboarding panel</span> 9182 <input type="checkbox" data-onboarding-enabled ${onboardingAdminDraft.enabled ? "checked" : ""} ${canEdit ? "" : "disabled"} /> 9183 </label> 9184 <label> 9185 <span>About (rich text allowed)</span> 9186 <textarea data-onboarding-about rows="10" ${canEdit ? "" : "disabled"}>${escapeHtml(onboardingAdminDraft.aboutContent)}</textarea> 9187 </label> 9188 <div class="small muted">Updated by: ${escapeHtml(String(normalizeInstanceBranding(instanceBranding).onboarding?.about?.updatedBy || "n/a"))}</div> 9189 <div class="small muted">Updated at: ${escapeHtml( 9190 formatLocalTime(normalizeInstanceBranding(instanceBranding).onboarding?.about?.updatedAt || 0) || "n/a" 9191 )}</div>` 9192 : onboardingAdminTab === "rules" 9193 ? `<label class="checkRow"> 9194 <span>Require rules acceptance before posting/chat</span> 9195 <input type="checkbox" data-onboarding-require ${onboardingAdminDraft.requireAcceptance ? "checked" : ""} ${ 9196 canEdit ? "" : "disabled" 9197 } /> 9198 </label> 9199 <label class="checkRow"> 9200 <span>Block reading hives until accepted ${isOwner ? "" : "(owner only)"}</span> 9201 <input type="checkbox" data-onboarding-blockread ${onboardingAdminDraft.blockReadUntilAccepted ? "checked" : ""} ${ 9202 canEdit && isOwner ? "" : "disabled" 9203 } /> 9204 </label> 9205 <div class="row" style="justify-content:space-between;align-items:center;margin:8px 0;"> 9206 <div><b>Rules</b></div> 9207 <button type="button" class="primary smallBtn" data-onb-ruleadd="1" ${canEdit ? "" : "disabled"}>+ Add Rule</button> 9208 </div> 9209 <div class="onbRuleEditorList">${rulesCards}</div>` 9210 : `<label class="checkRow"> 9211 <span>Enable custom role select in onboarding</span> 9212 <input type="checkbox" data-onboarding-roleenabled ${onboardingAdminDraft.roleSelectEnabled ? "checked" : ""} ${ 9213 canEdit ? "" : "disabled" 9214 } /> 9215 </label> 9216 <div class="small muted">Choose self-assignable roles:</div> 9217 <div class="onbRoleGrid">${roleOptions || `<div class="small muted">No custom roles defined.</div>`}</div>` 9218 } 9219 </div> 9220 <div class="modCard"> 9221 <div class="row" style="gap:8px;"> 9222 <button type="button" class="primary" data-onboarding-save="1" ${canEdit ? "" : "disabled"}>Save</button> 9223 <button type="button" class="ghost" data-onboarding-publish="1" ${canEdit ? "" : "disabled"}>Publish</button> 9224 <button type="button" class="ghost" data-onboarding-refresh="1">Reload</button> 9225 </div> 9226 </div> 9227 `; 9228 return; 9229 } 9230 9231 if (modTab === "users") { 9232 const roleList = customRoles.length 9233 ? customRoles 9234 .map((r) => { 9235 const swatch = r.color ? `<span class="roleSwatch" style="background:${escapeHtml(r.color)}"></span>` : ""; 9236 return `<div class="roleRow"> 9237 <div class="roleRowLeft"> 9238 ${swatch} 9239 <div class="roleMeta"> 9240 <div><b>${escapeHtml(r.label)}</b></div> 9241 <div class="roleKey">${escapeHtml(r.key)}</div> 9242 </div> 9243 </div> 9244 <div class="row" style="gap:8px"> 9245 <button type="button" class="ghost smallBtn" data-rolearchive="${escapeHtml(r.key)}">Archive</button> 9246 </div> 9247 </div>`; 9248 }) 9249 .join("") 9250 : `<div class="muted">No custom roles yet.</div>`; 9251 9252 const roleAdminCard = `<div class="modCard"> 9253 <div class="modRowTop"> 9254 <div><b>Custom roles</b></div> 9255 </div> 9256 <div class="roleCreateRow" style="margin-bottom:10px"> 9257 <label> 9258 <span>Key</span> 9259 <input data-rolekey maxlength="18" placeholder="vip" /> 9260 </label> 9261 <label> 9262 <span>Label</span> 9263 <input data-rolelabel maxlength="24" placeholder="VIP" /> 9264 </label> 9265 <label> 9266 <span>Color</span> 9267 <input data-rolecolor type="color" value="#ff3ea5" /> 9268 </label> 9269 <button type="button" data-rolecreate="1">Create</button> 9270 </div> 9271 <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">admin</span>, <span class="tag">owner</span>, or <span class="tag">role:yourkey</span>.</div> 9272 <div class="gateList">${roleList}</div> 9273 </div>`; 9274 if (!modUsers.length) { 9275 modBodyEl.innerHTML = `${roleAdminCard}<div class="muted">No users found.</div>`; 9276 return; 9277 } 9278 modBodyEl.innerHTML = 9279 roleAdminCard + 9280 modUsers 9281 .map((u) => { 9282 const role = u.role || "member"; 9283 const status = userStateText(u); 9284 const canPromote = loggedInRole === "owner" && u.username !== loggedInUser; 9285 const canManageCustomRoles = canModerate && u.username !== loggedInUser; 9286 const canResetPassword = 9287 canModerate && 9288 u.username !== loggedInUser && 9289 role !== "owner" && 9290 (role !== "admin" || isOwnerRole(loggedInRole)) && 9291 (role !== "moderator" || isOwnerRole(loggedInRole) || isAdminRole(loggedInRole)); 9292 const customBadges = renderCustomRoleBadges(u.username); 9293 return `<div class="modCard"> 9294 <div class="modRowTop"> 9295 <div><b>@${escapeHtml(u.username)}</b> ${statusBadge(role)}</div> 9296 <div class="muted">${escapeHtml(status)}</div> 9297 </div> 9298 <div class="small muted">custom roles: ${customBadges || `<span class="muted">(none)</span>`}</div> 9299 <div class="modActions"> 9300 <button type="button" data-modaction="user_mute" data-targettype="user" data-targetid="${escapeHtml(u.username)}" data-minutes="30">Mute 30m</button> 9301 <button type="button" data-modaction="user_unmute" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Unmute</button> 9302 <button type="button" data-modaction="user_suspend" data-targettype="user" data-targetid="${escapeHtml(u.username)}" data-minutes="120">Suspend 2h</button> 9303 <button type="button" data-modaction="user_unsuspend" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Unsuspend</button> 9304 <button type="button" data-modaction="user_ban" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Ban</button> 9305 <button type="button" data-modaction="user_unban" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Unban</button> 9306 ${canResetPassword ? `<button type="button" data-modaction="user_password_reset" data-targettype="user" data-targetid="${escapeHtml(u.username)}">Reset password</button>` : ""} 9307 ${ 9308 canPromote && role === "member" 9309 ? `<button type="button" data-modaction="user_role_set" data-targettype="user" data-targetid="${escapeHtml( 9310 u.username 9311 )}" data-role="moderator">Make mod</button>` 9312 : "" 9313 } 9314 ${ 9315 canPromote && role === "moderator" 9316 ? `<button type="button" data-modaction="user_role_set" data-targettype="user" data-targetid="${escapeHtml( 9317 u.username 9318 )}" data-role="admin">Make admin</button>` 9319 : "" 9320 } 9321 ${ 9322 canPromote && role === "admin" 9323 ? `<button type="button" class="danger" data-modaction="user_role_set" data-targettype="user" data-targetid="${escapeHtml( 9324 u.username 9325 )}" data-role="moderator">Remove admin</button>` 9326 : "" 9327 } 9328 ${ 9329 canPromote && role === "moderator" 9330 ? `<button type="button" class="danger" data-modaction="user_role_set" data-targettype="user" data-targetid="${escapeHtml( 9331 u.username 9332 )}" data-role="member">Remove mod</button>` 9333 : "" 9334 } 9335 ${ 9336 canManageCustomRoles 9337 ? `<button type="button" data-usermanageroles="${escapeHtml(u.username)}">Manage custom roles</button>` 9338 : "" 9339 } 9340 </div> 9341 </div>`; 9342 }) 9343 .join(""); 9344 return; 9345 } 9346 9347 if (modTab === "hives") { 9348 const hives = Array.from(posts.values()).sort((a, b) => rankTime(b) - rankTime(a) || b.createdAt - a.createdAt); 9349 const collectionControls = canModerate 9350 ? `<div class="modCard"> 9351 <div class="modRowTop"> 9352 <div><b>Collections</b></div> 9353 <button type="button" data-createcollection="1">Create collection</button> 9354 </div> 9355 <div class="modActions"> 9356 ${activeCollections() 9357 .map((c) => { 9358 const canArchive = c.id !== "general"; 9359 const gateLabel = 9360 c.visibility === "gated" 9361 ? `gated: ${(c.allowedRoles || []).map((t) => roleTokenLabel(t)).join(", ") || "(none)"}` 9362 : "public"; 9363 return `<span class="tag">/${escapeHtml(c.name)}</span>${ 9364 c.id !== "general" 9365 ? `<button type="button" data-collectiongate="${escapeHtml(c.id)}">Gate...</button> 9366 <button type="button" data-collectionpublic="${escapeHtml(c.id)}">Make public</button>` 9367 : "" 9368 } 9369 <span class="small muted">${escapeHtml(gateLabel)}</span>${ 9370 canArchive 9371 ? `<button type="button" data-archivecollection="${escapeHtml(c.id)}">Archive ${escapeHtml(c.name)}</button>` 9372 : "" 9373 }`; 9374 }) 9375 .join(" ")} 9376 </div> 9377 </div>` 9378 : ""; 9379 if (!hives.length) { 9380 modBodyEl.innerHTML = `${collectionControls}<div class="muted">No active hives.</div>`; 9381 return; 9382 } 9383 modBodyEl.innerHTML = 9384 collectionControls + 9385 hives 9386 .map((p) => { 9387 const title = postTitle(p); 9388 const author = p.author ? `@${p.author}` : "unknown"; 9389 const collection = activeCollections().find((c) => c.id === p.collectionId)?.name || "General"; 9390 const openReports = modReports.filter( 9391 (r) => r && r.status === "open" && (r.postId === p.id || (r.targetType === "post" && r.targetId === p.id)) 9392 ).length; 9393 return `<div class="modCard"> 9394 <div class="modRowTop"> 9395 <div><b>${escapeHtml(title)}</b></div> 9396 <div class="muted">${formatCountdown(p.expiresAt)}</div> 9397 </div> 9398 <div class="small">author: ${escapeHtml(author)} | collection: /${escapeHtml(collection)} | id: ${escapeHtml(p.id)}</div> 9399 <div class="small muted">open reports: ${openReports}</div> 9400 <div class="modActions"> 9401 <button type="button" data-chat="${p.id}">Open chat</button> 9402 <button type="button" data-modaction="post_ttl_set" data-targettype="post" data-targetid="${escapeHtml( 9403 p.id 9404 )}" data-ttl="0">Permanent</button> 9405 <button type="button" data-modaction="post_ttl_set" data-targettype="post" data-targetid="${escapeHtml( 9406 p.id 9407 )}" data-ttl="60">TTL 1h</button> 9408 <button type="button" data-modaction="post_ttl_set" data-targettype="post" data-targetid="${escapeHtml( 9409 p.id 9410 )}" data-ttl="1440">TTL 1d</button> 9411 <button type="button" data-modaction="post_ttl_set" data-targettype="post" data-targetid="${escapeHtml( 9412 p.id 9413 )}" data-ttlprompt="1">Set TTL...</button> 9414 ${ 9415 p.readOnly 9416 ? `<button type="button" data-modaction="post_readonly_set" data-targettype="post" data-targetid="${escapeHtml( 9417 p.id 9418 )}" data-readonly="0">Make writable</button>` 9419 : `<button type="button" data-modaction="post_readonly_set" data-targettype="post" data-targetid="${escapeHtml( 9420 p.id 9421 )}" data-readonly="1">Read-only</button>` 9422 } 9423 ${ 9424 p.protected 9425 ? `<button type="button" data-modaction="post_protection_set" data-targettype="post" data-targetid="${escapeHtml( 9426 p.id 9427 )}" data-unprotect="1">Unprotect</button> 9428 <button type="button" data-modaction="post_protection_set" data-targettype="post" data-targetid="${escapeHtml( 9429 p.id 9430 )}" data-protect="1">Change password...</button>` 9431 : `<button type="button" data-modaction="post_protection_set" data-targettype="post" data-targetid="${escapeHtml( 9432 p.id 9433 )}" data-protect="1">Protect...</button>` 9434 } 9435 <button type="button" data-modaction="message_purge_recent" data-targettype="post" data-targetid="${escapeHtml( 9436 p.id 9437 )}" data-count="25">Purge 25 msgs</button> 9438 <button type="button" data-modaction="message_purge_recent" data-targettype="post" data-targetid="${escapeHtml( 9439 p.id 9440 )}" data-count="50">Purge 50 msgs</button> 9441 ${ 9442 p.deleted 9443 ? `<button type="button" data-modaction="post_restore" data-targettype="post" data-targetid="${escapeHtml( 9444 p.id 9445 )}" ${p.restoreAvailable ? "" : "disabled"}>${p.restoreAvailable ? "Restore hive" : "No restore snapshot"}</button>` 9446 : `<button type="button" data-modaction="post_delete" data-targettype="post" data-targetid="${escapeHtml( 9447 p.id 9448 )}">Delete hive</button>` 9449 } 9450 <button type="button" class="danger" data-modaction="post_erase" data-targettype="post" data-targetid="${escapeHtml( 9451 p.id 9452 )}">Erase</button> 9453 </div> 9454 </div>`; 9455 }) 9456 .join(""); 9457 return; 9458 } 9459 9460 if (modTab === "log") { 9461 const isOwner = loggedInRole === "owner"; 9462 const viewTabs = ` 9463 <div class="row" style="gap:10px; flex-wrap:wrap; margin-bottom:10px;"> 9464 <button type="button" class="${modLogView === "dev" ? "primary" : "ghost"} smallBtn" data-modlogview="dev">Server dev log</button> 9465 <button type="button" class="${modLogView === "moderation" ? "primary" : "ghost"} smallBtn" data-modlogview="moderation">Moderation log</button> 9466 </div> 9467 `; 9468 const nukeCard = isOwner 9469 ? `<div class="modCard"> 9470 <div class="modRowTop"> 9471 <div><b>NUKE</b></div> 9472 <button type="button" class="danger" data-nuke="1" disabled>NUKE</button> 9473 </div> 9474 <div class="small muted" style="margin-bottom:10px">Clears all hives, reports, moderation log, and hive media uploads. Keeps users + profiles.</div> 9475 <label class="row small" style="gap:10px;align-items:center;justify-content:flex-start"> 9476 <input type="checkbox" data-nukeconfirm="1" /> 9477 <span>ARE YOU SURE?</span> 9478 </label> 9479 </div>` 9480 : ""; 9481 9482 if (modLogView === "dev") { 9483 const lines = devLog 9484 .slice(0, 300) 9485 .reverse() 9486 .map((e) => { 9487 const ts = e?.createdAt ? new Date(e.createdAt).toLocaleString() : ""; 9488 const lvl = String(e?.level || "info").toUpperCase(); 9489 const scope = String(e?.scope || "server"); 9490 const msg = String(e?.message || ""); 9491 const data = String(e?.data || ""); 9492 const extra = data ? ` ${data}` : ""; 9493 return `[${ts}] ${lvl} ${scope}: ${msg}${extra}`; 9494 }) 9495 .join("\n"); 9496 9497 modBodyEl.innerHTML = ` 9498 ${viewTabs} 9499 <div class="modCard"> 9500 <div class="modRowTop"> 9501 <div><b>Dev log</b></div> 9502 <div class="row" style="gap:10px; flex-wrap:wrap; justify-content:flex-end"> 9503 <button type="button" class="ghost smallBtn" data-devlogrefresh="1">Refresh</button> 9504 <button type="button" class="ghost smallBtn" data-devlogcopy="1">Copy</button> 9505 ${isOwner ? `<button type="button" class="danger smallBtn" data-devlogclear="1">Clear</button>` : ""} 9506 </div> 9507 </div> 9508 <label class="row small muted" style="gap:10px; align-items:center; justify-content:flex-start; margin-bottom:10px;"> 9509 <input type="checkbox" data-devlogautoscroll="1" ${devLogAutoScroll ? "checked" : ""} /> 9510 <span>Auto-scroll</span> 9511 <button type="button" class="ghost smallBtn" data-devlogtest="1" style="margin-left:auto;">Test log</button> 9512 </label> 9513 <pre class="devLogPre" id="devLogPre">${escapeHtml(lines || "(empty)")}</pre> 9514 </div> 9515 `; 9516 9517 const pre = document.getElementById("devLogPre"); 9518 if (pre && devLogAutoScroll) pre.scrollTop = pre.scrollHeight; 9519 return; 9520 } 9521 9522 if (!modLog.length) { 9523 modBodyEl.innerHTML = `${viewTabs}${nukeCard}<div class="muted">No moderation log entries yet.</div>`; 9524 return; 9525 } 9526 modBodyEl.innerHTML = 9527 viewTabs + 9528 nukeCard + 9529 modLog 9530 .map( 9531 (entry) => `<div class="modCard"> 9532 <div class="modRowTop"> 9533 <div><b>${escapeHtml(entry.actionType || "action")}</b> ${statusBadge(entry.targetType || "")}</div> 9534 <div class="muted">${new Date(entry.createdAt).toLocaleString()}</div> 9535 </div> 9536 <div class="small">by @${escapeHtml(entry.actor || "unknown")} on ${escapeHtml(entry.targetId || "(none)")}</div> 9537 <div class="small muted">${escapeHtml(entry.reason || "")}</div> 9538 ${ 9539 entry?.metadata?.beforePreview || entry?.metadata?.beforeText 9540 ? `<div class="small muted">content: ${escapeHtml(entry.metadata.beforePreview || entry.metadata.beforeText || "")}</div>` 9541 : "" 9542 } 9543 ${ 9544 entry?.metadata?.editCount 9545 ? `<div class="small muted">edits: ${escapeHtml(String(entry.metadata.editCount))}</div>` 9546 : "" 9547 } 9548 ${ 9549 entry?.targetType === "post" && (entry?.actionType === "post_delete" || entry?.actionType === "self_post_delete") 9550 ? `<div class="modActions"> 9551 <button type="button" data-modaction="post_restore" data-targettype="post" data-targetid="${escapeHtml( 9552 entry.targetId || "" 9553 )}">Restore hive</button> 9554 </div>` 9555 : "" 9556 } 9557 ${ 9558 entry?.targetType === "chat" && 9559 (entry?.actionType === "message_delete" || entry?.actionType === "self_message_delete") 9560 ? `<div class="modActions"> 9561 <button type="button" data-modaction="message_restore" data-targettype="chat" data-targetid="${escapeHtml( 9562 entry.targetId || "" 9563 )}">Restore message</button> 9564 </div>` 9565 : "" 9566 } 9567 </div>` 9568 ) 9569 .join(""); 9570 return; 9571 } 9572 9573 if (!modReports.length) { 9574 modBodyEl.innerHTML = `<div class="muted">No reports for this filter.</div>`; 9575 return; 9576 } 9577 modBodyEl.innerHTML = modReports 9578 .map((r) => { 9579 const status = r.status || "open"; 9580 const canAct = status === "open"; 9581 return `<div class="modCard"> 9582 <div class="modRowTop"> 9583 <div><b>${escapeHtml(r.targetType || "target")}</b> ${statusBadge(status)}</div> 9584 <div class="muted">${new Date(r.createdAt).toLocaleString()}</div> 9585 </div> 9586 <div class="small">target: ${escapeHtml(r.targetId || "")}</div> 9587 <div class="small">reporter: @${escapeHtml(r.reporter || "")}</div> 9588 <div class="small muted">${escapeHtml(r.reason || "")}</div> 9589 ${ 9590 canAct 9591 ? `<div class="modActions"> 9592 <button type="button" data-modaction="report_resolve" data-targettype="report" data-targetid="${escapeHtml(r.id)}">Resolve</button> 9593 <button type="button" data-modaction="report_dismiss" data-targettype="report" data-targetid="${escapeHtml(r.id)}">Dismiss</button> 9594 </div>` 9595 : "" 9596 } 9597 </div>`; 9598 }) 9599 .join(""); 9600 } 9601 9602 function isMapChatActive() { 9603 return Boolean(!activeDmThreadId && !activeChatPostId && activeMapsRoomId); 9604 } 9605 9606 function normalizeMapChatScope(scope) { 9607 const s = String(scope || "").trim().toLowerCase(); 9608 return s === "global" ? "global" : "local"; 9609 } 9610 9611 function mapChatListFor(mapId, scope) { 9612 const mid = String(mapId || "").trim().toLowerCase(); 9613 if (!mid) return []; 9614 const sc = normalizeMapChatScope(scope); 9615 const store = sc === "global" ? mapsChatGlobalByMapId : mapsChatLocalByMapId; 9616 const arr = store.get(mid); 9617 return Array.isArray(arr) ? arr : []; 9618 } 9619 9620 function pushMapChatMessage(mapId, scope, message) { 9621 const mid = String(mapId || "").trim().toLowerCase(); 9622 if (!mid) return; 9623 const sc = normalizeMapChatScope(scope); 9624 const store = sc === "global" ? mapsChatGlobalByMapId : mapsChatLocalByMapId; 9625 const prev = store.get(mid); 9626 const arr = Array.isArray(prev) ? prev.slice() : []; 9627 arr.push(message); 9628 if (arr.length > 240) arr.splice(0, arr.length - 240); 9629 store.set(mid, arr); 9630 } 9631 9632 function renderChatPanel(forceScroll = false) { 9633 updateChatModToggleVisibility(); 9634 renderChatContextSelect(); 9635 const mobileChatScreen = isMobileChatScreenActive(); 9636 const mediaState = captureMediaState(chatMessagesEl); 9637 const activePost = activeChatPostId ? posts.get(activeChatPostId) : null; 9638 if ( 9639 streamCurrentPostId && 9640 (activeDmThreadId || !activePost || !isStreamPost(activePost) || String(activePost.id || "") !== String(streamCurrentPostId)) 9641 ) { 9642 leaveActiveStream(true); 9643 } 9644 if (activeDmThreadId) { 9645 renderStreamStage(null); 9646 const thread = dmThreadsById.get(activeDmThreadId) || null; 9647 if (!thread) { 9648 activeDmThreadId = null; 9649 } else { 9650 const atBottomBefore = 9651 chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24; 9652 chatTitle.textContent = `@${thread.other}`; 9653 if (chatBackToListBtn) chatBackToListBtn.classList.toggle("hidden", !mobileChatScreen); 9654 const status = String(thread.status || "unknown"); 9655 const statusTxt = 9656 status === "incoming" 9657 ? "DM request (accept to chat)" 9658 : status === "outgoing" 9659 ? "DM request pending" 9660 : status === "declined" 9661 ? "DM request declined" 9662 : "Private chat"; 9663 chatMeta.textContent = `with @${thread.other} | ${statusTxt} | purged daily`; 9664 9665 const messages = dmMessagesByThreadId.get(activeDmThreadId) || []; 9666 if (status !== "active" && messages.length === 0) { 9667 const promptHtml = 9668 status === "incoming" 9669 ? `<div class="row" style="gap:8px;justify-content:flex-start"> 9670 <button type="button" class="primary smallBtn" data-dmaccept="${escapeHtml(thread.id)}">Accept</button> 9671 <button type="button" class="ghost smallBtn" data-dmdecline="${escapeHtml(thread.id)}">Decline</button> 9672 </div>` 9673 : status === "declined" 9674 ? `<button type="button" class="ghost smallBtn" data-dmrequest="${escapeHtml(thread.other)}">Request again</button>` 9675 : `<div class="muted">Waiting for @${escapeHtml(thread.other)}...</div>`; 9676 chatMessagesEl.innerHTML = `<div class="small muted">${promptHtml}</div>`; 9677 restoreMediaState(chatMessagesEl, mediaState); 9678 setReplyToMessage(null); 9679 return; 9680 } 9681 9682 chatMessagesEl.innerHTML = messages 9683 .map((m, index) => { 9684 const from = m.fromUser || ""; 9685 const isYou = loggedInUser && from && from === loggedInUser; 9686 const isModMsg = Boolean(m?.asMod) || String(from || "").toLowerCase() === "mod"; 9687 const rail = chatRailClass({ 9688 fromUser: from, 9689 isModMessage: isModMsg 9690 }); 9691 const prev = index > 0 ? messages[index - 1] : null; 9692 const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); 9693 const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || ""); 9694 const youTag = isModMsg ? "" : isYou ? `<span class="muted">(you)</span>` : ""; 9695 const time = new Date(m.createdAt).toLocaleTimeString(); 9696 const tint = tintStylesFromHex(getProfile(from).color); 9697 const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; 9698 const content = html ? html : highlightMentionsInText(m.text || ""); 9699 return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${rail}" data-msgid="${escapeHtml(m.id)}" ${tint}> 9700 <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> 9701 <div class="content">${content}</div> 9702 </div>`; 9703 }) 9704 .join(""); 9705 for (const contentEl of chatMessagesEl.querySelectorAll(".chatMsg .content")) { 9706 decorateMentionNodesInElement(contentEl); 9707 decorateYouTubeEmbedsInElement(contentEl); 9708 } 9709 restoreMediaState(chatMessagesEl, mediaState); 9710 if (forceScroll || atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; 9711 return; 9712 } 9713 } 9714 9715 const post = activePost; 9716 if (!post) { 9717 renderStreamStage(null); 9718 if (isMapChatActive()) { 9719 const mapId = String(activeMapsRoomId || "").trim().toLowerCase(); 9720 const scope = normalizeMapChatScope(activeMapsChatScope); 9721 const atBottomBefore = 9722 chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24; 9723 9724 const title = activeMapsRoomTitle ? `Map: ${activeMapsRoomTitle}` : `Map: ${mapId}`; 9725 chatTitle.textContent = activeMapsRoomTitle ? `Map: ${activeMapsRoomTitle}` : "Map chat"; 9726 if (chatBackToListBtn) chatBackToListBtn.classList.toggle("hidden", !mobileChatScreen); 9727 chatMeta.innerHTML = ` 9728 <span class="muted">${escapeHtml(title)}</span> 9729 <span class="muted">|</span> 9730 <span class="mapChatToggle"> 9731 <button type="button" class="${scope === "local" ? "primary" : "ghost"} smallBtn" data-mapchatscope="local" title="Local chat (nearby)">Local</button> 9732 <button type="button" class="${scope === "global" ? "primary" : "ghost"} smallBtn" data-mapchatscope="global" title="Global chat (entire map)">Global</button> 9733 </span> 9734 `; 9735 9736 if (chatPanelEl) chatPanelEl.classList.remove("walkie"); 9737 if (walkieBarEl) walkieBarEl.classList.add("hidden"); 9738 if (chatForm) chatForm.classList.remove("hidden"); 9739 9740 const messages = mapChatListFor(mapId, scope); 9741 if (!messages.length) { 9742 chatMessagesEl.innerHTML = `<div class="small muted">${ 9743 scope === "local" ? "Local chat is proximity-based. Say something nearby." : "No messages yet. Say hello!" 9744 }</div>`; 9745 restoreMediaState(chatMessagesEl, mediaState); 9746 setReplyToMessage(null); 9747 return; 9748 } 9749 9750 chatMessagesEl.innerHTML = messages 9751 .map((m, index) => { 9752 const from = String(m.fromUser || ""); 9753 const isYou = loggedInUser && from && from === loggedInUser; 9754 const rail = chatRailClass({ 9755 fromUser: from, 9756 isModMessage: Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod" 9757 }); 9758 const prev = index > 0 ? messages[index - 1] : null; 9759 const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); 9760 const who = renderUserPill(from || ""); 9761 const youTag = isYou ? `<span class="muted">(you)</span>` : ""; 9762 const time = new Date(Number(m.createdAt || 0) || Date.now()).toLocaleTimeString(); 9763 const tint = tintStylesFromHex(getProfile(from).color); 9764 const content = highlightMentionsInText(String(m.text || "")); 9765 return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${rail}" data-msgid="${escapeHtml(String(m.id || ""))}" ${tint}> 9766 <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> 9767 <div class="content">${content}</div> 9768 </div>`; 9769 }) 9770 .join(""); 9771 for (const contentEl of chatMessagesEl.querySelectorAll(".chatMsg .content")) { 9772 decorateMentionNodesInElement(contentEl); 9773 decorateYouTubeEmbedsInElement(contentEl); 9774 } 9775 restoreMediaState(chatMessagesEl, mediaState); 9776 if (forceScroll || atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; 9777 setReplyToMessage(null); 9778 return; 9779 } 9780 9781 if (chatBackToListBtn) chatBackToListBtn.classList.add("hidden"); 9782 if (mobileChatScreen) { 9783 chatTitle.textContent = "Chats"; 9784 chatMeta.textContent = "Select a hive chat."; 9785 if (chatPanelEl) chatPanelEl.classList.remove("walkie"); 9786 if (walkieBarEl) walkieBarEl.classList.add("hidden"); 9787 if (chatForm) chatForm.classList.add("hidden"); 9788 chatMessagesEl.innerHTML = renderMobileChatListHtml(); 9789 restoreMediaState(chatMessagesEl, mediaState); 9790 setReplyToMessage(null); 9791 return; 9792 } 9793 chatTitle.textContent = "Chat"; 9794 chatMeta.textContent = "Select a post to chat."; 9795 if (chatPanelEl) chatPanelEl.classList.remove("walkie"); 9796 if (walkieBarEl) walkieBarEl.classList.add("hidden"); 9797 if (chatForm) chatForm.classList.remove("hidden"); 9798 chatMessagesEl.innerHTML = `<div class="small muted">No chat selected.</div> 9799 <div class="uiHint">Open a hive and press <b>Chat</b>, or use People -> DMs to open a private thread.</div> 9800 <div class="row" style="gap:8px;justify-content:flex-start;margin-top:8px;"> 9801 <button type="button" class="ghost smallBtn" data-chatemptyopen="hives">Open Hives</button> 9802 <button type="button" class="ghost smallBtn" data-chatemptyopen="people">Open People</button> 9803 </div>`; 9804 restoreMediaState(chatMessagesEl, mediaState); 9805 setReplyToMessage(null); 9806 return; 9807 } 9808 9809 updateChatModToggleVisibility(); 9810 renderStreamStage(post); 9811 const mode = normalizePostMode(post.mode || post.chatMode || ""); 9812 const isWalkie = mode === "walkie"; 9813 const isStream = mode === "stream"; 9814 if (chatPanelEl) chatPanelEl.classList.toggle("walkie", isWalkie); 9815 if (walkieBarEl) walkieBarEl.classList.toggle("hidden", !isWalkie); 9816 if (chatForm) chatForm.classList.toggle("hidden", isWalkie); 9817 if (walkieRecordBtn) walkieRecordBtn.disabled = !(isWalkie && loggedInUser); 9818 if (isWalkie && walkieStatusEl && !loggedInUser) walkieStatusEl.textContent = "Sign in to talk."; 9819 if (!isWalkie && walkieStatusEl) walkieStatusEl.textContent = ""; 9820 9821 const atBottomBefore = 9822 chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24; 9823 chatTitle.textContent = postTitle(post); 9824 if (chatBackToListBtn) chatBackToListBtn.classList.toggle("hidden", !mobileChatScreen); 9825 const tags = (post.keywords || []).map((k) => `#${k}`).join(" "); 9826 const author = post.author ? `by @${post.author}` : ""; 9827 const exp = formatCountdown(post.expiresAt); 9828 const ro = post.readOnly ? " | read-only" : ""; 9829 const streamMeta = isStream ? ` | stream (${streamKindLabel(post.streamKind || "webcam")})` : ""; 9830 chatMeta.textContent = `${author}${isWalkie ? " | walkie talkie" : ""}${streamMeta}${ro} | ${ 9831 exp === "permanent" ? "permanent" : `expires in ${exp}` 9832 } | ${tags}`.trim(); 9833 const canChatWrite = Boolean(isStaffRole(loggedInRole) || !post.readOnly); 9834 if (chatEditor) chatEditor.contentEditable = String(Boolean(canChatWrite && !isWalkie)); 9835 const chatSendBtn = chatForm?.querySelector?.("button[type='submit']") || null; 9836 if (chatSendBtn) chatSendBtn.disabled = !(loggedInUser && canChatWrite && !isWalkie); 9837 if (post.deleted) { 9838 chatMessagesEl.innerHTML = `<div class="small muted">Post was deleted.</div>`; 9839 restoreMediaState(chatMessagesEl, mediaState); 9840 setReplyToMessage(null); 9841 return; 9842 } 9843 9844 const messages = chatByPost.get(post.id) || []; 9845 const ignoreUserSet = new Set( 9846 [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase()) 9847 ); 9848 const selfLower = String(loggedInUser || "").toLowerCase(); 9849 const visibleMessages = messages.filter((m) => { 9850 const fromLower = String(m?.fromUser || "").toLowerCase(); 9851 if (!fromLower || fromLower === selfLower) return true; 9852 return !ignoreUserSet.has(fromLower); 9853 }); 9854 9855 chatMessagesEl.innerHTML = visibleMessages 9856 .map((m, index) => { 9857 const isModMsg = Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod"; 9858 const from = isModMsg ? "MOD" : m.fromUser || ""; 9859 const rail = chatRailClass({ fromUser: from, isModMessage: isModMsg }); 9860 const prev = index > 0 ? visibleMessages[index - 1] : null; 9861 const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); 9862 const mentions = Array.isArray(m.mentions) ? m.mentions.map((u) => String(u || "").toLowerCase()) : []; 9863 const mentionMe = Boolean(loggedInUser && mentions.includes(loggedInUser)); 9864 const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || ""); 9865 const youTag = !isModMsg && loggedInUser && from && from === loggedInUser ? `<span class="muted">(you)</span>` : ""; 9866 const time = new Date(m.createdAt).toLocaleTimeString(); 9867 const tint = isModMsg ? "" : tintStylesFromHex(getProfile(from).color); 9868 const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; 9869 const content = html ? html : highlightMentionsInText(m.text || ""); 9870 const replyMeta = m.replyTo && typeof m.replyTo === "object" ? m.replyTo : null; 9871 const replyBlock = replyMeta 9872 ? `<div class="chatReplyRef"><span class="small muted">@${escapeHtml(replyMeta.fromUser || "unknown")}</span><div class="small">${escapeHtml( 9873 String(replyMeta.text || "[media]").slice(0, 120) 9874 )}</div></div>` 9875 : ""; 9876 const reacts = renderReactionButtons({ kind: "chat", id: m.id, reactions: m.reactions || {}, postId: post.id }); 9877 const deletedLine = m.deleted 9878 ? `<div class="small muted">message deleted${ 9879 m.deletedBy ? ` by @${escapeHtml(m.deletedBy)}` : "" 9880 } at ${escapeHtml(new Date(Number(m.deletedAt || m.createdAt || Date.now())).toLocaleString())}</div>` 9881 : ""; 9882 const editedLine = 9883 !m.deleted && Number(m.editCount || 0) > 0 9884 ? `<div class="small muted">edited (${Number(m.editCount || 0)}) at ${escapeHtml( 9885 new Date(Number(m.editedAt || m.createdAt || Date.now())).toLocaleTimeString() 9886 )}</div>` 9887 : ""; 9888 const reportAction = loggedInUser && !m.deleted 9889 ? `<button type="button" class="ghost smallBtn" data-reportchat="${escapeHtml(m.id)}" data-postid="${escapeHtml( 9890 post.id 9891 )}">Report</button>` 9892 : ""; 9893 const canManageOwnMessage = Boolean(loggedInUser && m.fromUser && m.fromUser === loggedInUser && !m.deleted); 9894 const replyAction = loggedInUser && !m.deleted 9895 ? `<button type="button" class="ghost smallBtn" data-replymsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(post.id)}">Reply</button>` 9896 : ""; 9897 const ownEditAction = canManageOwnMessage 9898 ? `<button type="button" class="ghost smallBtn" data-editmsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(post.id)}">Edit</button>` 9899 : ""; 9900 const ownDeleteAction = canManageOwnMessage 9901 ? `<button type="button" class="ghost smallBtn" data-deletemsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(post.id)}">Delete</button>` 9902 : ""; 9903 return `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""} ${rail} ${isModMsg ? "isModMsg" : ""}" data-msgid="${escapeHtml(m.id)}" ${tint}> 9904 <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> 9905 ${replyBlock} 9906 ${deletedLine} 9907 ${editedLine} 9908 <div class="content">${content}</div> 9909 <div class="chatActionsRow"> 9910 <div class="chatReactions">${m.deleted ? "" : reacts}</div> 9911 <div class="chatTools">${replyAction}${ownEditAction}${ownDeleteAction}${reportAction}</div> 9912 </div> 9913 </div>`; 9914 }) 9915 .join(""); 9916 for (const contentEl of chatMessagesEl.querySelectorAll(".chatMsg .content")) { 9917 decorateMentionNodesInElement(contentEl); 9918 decorateYouTubeEmbedsInElement(contentEl); 9919 } 9920 restoreMediaState(chatMessagesEl, mediaState); 9921 if (forceScroll || atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; 9922 } 9923 9924 function captureMediaState(containerEl) { 9925 if (!containerEl) return []; 9926 const list = []; 9927 for (const el of containerEl.querySelectorAll("audio, video")) { 9928 try { 9929 const src = el.currentSrc || el.getAttribute("src") || ""; 9930 if (!src) continue; 9931 list.push({ 9932 src, 9933 currentTime: Number(el.currentTime || 0), 9934 paused: Boolean(el.paused), 9935 volume: Number.isFinite(el.volume) ? el.volume : 1, 9936 playbackRate: Number.isFinite(el.playbackRate) ? el.playbackRate : 1 9937 }); 9938 } catch { 9939 // ignore 9940 } 9941 } 9942 return list; 9943 } 9944 9945 function restoreMediaState(containerEl, mediaState) { 9946 if (!containerEl || !Array.isArray(mediaState) || mediaState.length === 0) return; 9947 const els = Array.from(containerEl.querySelectorAll("audio, video")); 9948 for (const s of mediaState) { 9949 const src = String(s?.src || ""); 9950 if (!src) continue; 9951 const el = els.find((x) => (x.currentSrc || x.getAttribute("src") || "") === src); 9952 if (!el) continue; 9953 try { 9954 if (Number.isFinite(s.volume)) el.volume = s.volume; 9955 if (Number.isFinite(s.playbackRate)) el.playbackRate = s.playbackRate; 9956 if (Number.isFinite(s.currentTime)) el.currentTime = s.currentTime; 9957 if (!s.paused) el.play().catch(() => {}); 9958 } catch { 9959 // ignore 9960 } 9961 } 9962 } 9963 9964 function appendChatHtmlAndDecorate(html, atBottomBefore) { 9965 if (!chatMessagesEl) return null; 9966 chatMessagesEl.insertAdjacentHTML("beforeend", html); 9967 const last = chatMessagesEl.lastElementChild; 9968 if (last && last.classList && last.classList.contains("chatMsg")) { 9969 const contentEl = last.querySelector(".content"); 9970 if (contentEl) { 9971 decorateMentionNodesInElement(contentEl); 9972 decorateYouTubeEmbedsInElement(contentEl); 9973 } 9974 } 9975 if (atBottomBefore) chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; 9976 return last; 9977 } 9978 9979 function appendPostChatMessageToDom(postId, message) { 9980 if (!chatMessagesEl) return false; 9981 const post = postId ? posts.get(postId) : null; 9982 if (!post || post.deleted) return false; 9983 if (!activeChatPostId || activeChatPostId !== postId) return false; 9984 if (activeDmThreadId) return false; 9985 if (!chatMessagesEl.querySelector(".chatMsg")) return false; 9986 9987 const atBottomBefore = 9988 chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24; 9989 9990 const ignoreUserSet = new Set( 9991 [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase()) 9992 ); 9993 const selfLower = String(loggedInUser || "").toLowerCase(); 9994 9995 const messages = chatByPost.get(postId) || []; 9996 let prevVisible = null; 9997 for (let i = messages.length - 2; i >= 0; i -= 1) { 9998 const pm = messages[i]; 9999 const fromLower = String(pm?.fromUser || "").toLowerCase(); 10000 if (!fromLower || fromLower === selfLower || !ignoreUserSet.has(fromLower)) { 10001 prevVisible = pm; 10002 break; 10003 } 10004 } 10005 10006 const m = message; 10007 const isModMsg = Boolean(m?.asMod) || String(m?.fromUser || "").trim().toLowerCase() === "mod"; 10008 const from = isModMsg ? "MOD" : m?.fromUser || ""; 10009 const isYou = loggedInUser && from && from === loggedInUser; 10010 const rail = chatRailClass({ fromUser: from, isModMessage: isModMsg }); 10011 const sameAuthorAsPrev = Boolean(prevVisible && String(prevVisible.fromUser || "") === from); 10012 const mentions = Array.isArray(m?.mentions) ? m.mentions.map((u) => String(u || "").toLowerCase()) : []; 10013 const mentionMe = Boolean(loggedInUser && mentions.includes(loggedInUser)); 10014 const who = isModMsg ? `<span class="modPill">MOD</span>` : renderUserPill(from || ""); 10015 const youTag = !isModMsg && isYou ? `<span class="muted">(you)</span>` : ""; 10016 const time = new Date(m.createdAt).toLocaleTimeString(); 10017 const tint = isModMsg ? "" : tintStylesFromHex(getProfile(from).color); 10018 const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; 10019 const content = html ? html : highlightMentionsInText(m.text || ""); 10020 const replyMeta = m.replyTo && typeof m.replyTo === "object" ? m.replyTo : null; 10021 const replyBlock = replyMeta 10022 ? `<div class="chatReplyRef"><span class="small muted">@${escapeHtml(replyMeta.fromUser || "unknown")}</span><div class="small">${escapeHtml( 10023 String(replyMeta.text || "[media]").slice(0, 120) 10024 )}</div></div>` 10025 : ""; 10026 const reacts = renderReactionButtons({ kind: "chat", id: m.id, reactions: m.reactions || {}, postId }); 10027 const deletedLine = m.deleted 10028 ? `<div class="small muted">message deleted${m.deletedBy ? ` by @${escapeHtml(m.deletedBy)}` : ""} at ${escapeHtml( 10029 new Date(Number(m.deletedAt || m.createdAt || Date.now())).toLocaleString() 10030 )}</div>` 10031 : ""; 10032 const editedLine = 10033 !m.deleted && Number(m.editCount || 0) > 0 10034 ? `<div class="small muted">edited (${Number(m.editCount || 0)}) at ${escapeHtml( 10035 new Date(Number(m.editedAt || m.createdAt || Date.now())).toLocaleTimeString() 10036 )}</div>` 10037 : ""; 10038 const reportAction = 10039 loggedInUser && !m.deleted 10040 ? `<button type="button" class="ghost smallBtn" data-reportchat="${escapeHtml(m.id)}" data-postid="${escapeHtml(postId)}">Report</button>` 10041 : ""; 10042 const canManageOwnMessage = Boolean(loggedInUser && m.fromUser && m.fromUser === loggedInUser && !m.deleted); 10043 const replyAction = 10044 loggedInUser && !m.deleted 10045 ? `<button type="button" class="ghost smallBtn" data-replymsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(postId)}">Reply</button>` 10046 : ""; 10047 const ownEditAction = canManageOwnMessage 10048 ? `<button type="button" class="ghost smallBtn" data-editmsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(postId)}">Edit</button>` 10049 : ""; 10050 const ownDeleteAction = canManageOwnMessage 10051 ? `<button type="button" class="ghost smallBtn" data-deletemsg="${escapeHtml(m.id)}" data-postid="${escapeHtml(postId)}">Delete</button>` 10052 : ""; 10053 10054 const msgHtml = `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${mentionMe ? "mentionMe" : ""} ${rail} ${isModMsg ? "isModMsg" : ""}" data-msgid="${escapeHtml( 10055 m.id 10056 )}" ${tint}> 10057 <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> 10058 ${replyBlock} 10059 ${deletedLine} 10060 ${editedLine} 10061 <div class="content">${content}</div> 10062 <div class="chatActionsRow"> 10063 <div class="chatReactions">${m.deleted ? "" : reacts}</div> 10064 <div class="chatTools">${replyAction}${ownEditAction}${ownDeleteAction}${reportAction}</div> 10065 </div> 10066 </div>`; 10067 10068 appendChatHtmlAndDecorate(msgHtml, atBottomBefore); 10069 return true; 10070 } 10071 10072 function appendDmMessageToDom(threadId, message) { 10073 if (!chatMessagesEl) return false; 10074 if (!activeDmThreadId || activeDmThreadId !== threadId) return false; 10075 if (!chatMessagesEl.querySelector(".chatMsg")) return false; 10076 const thread = dmThreadsById.get(threadId) || null; 10077 if (!thread || String(thread.status || "unknown") !== "active") return false; 10078 10079 const atBottomBefore = 10080 chatMessagesEl.scrollHeight - chatMessagesEl.scrollTop - chatMessagesEl.clientHeight < 24; 10081 10082 const messages = dmMessagesByThreadId.get(threadId) || []; 10083 const prev = messages.length >= 2 ? messages[messages.length - 2] : null; 10084 10085 const m = message; 10086 const from = m.fromUser || ""; 10087 const isYou = loggedInUser && from && from === loggedInUser; 10088 const rail = chatRailClass({ fromUser: from, isModMessage: false }); 10089 const sameAuthorAsPrev = Boolean(prev && String(prev.fromUser || "") === from); 10090 const who = renderUserPill(from || ""); 10091 const youTag = isYou ? `<span class="muted">(you)</span>` : ""; 10092 const time = new Date(m.createdAt).toLocaleTimeString(); 10093 const tint = tintStylesFromHex(getProfile(from).color); 10094 const html = typeof m.html === "string" && m.html.trim() ? m.html : ""; 10095 const content = html ? html : highlightMentionsInText(m.text || ""); 10096 10097 const msgHtml = `<div class="chatMsg ${sameAuthorAsPrev ? "isStacked" : ""} ${rail}" data-msgid="${escapeHtml(m.id)}" ${tint}> 10098 <div class="meta"><span class="chatHeaderInline">${who}${youTag}<span class="muted">|</span><span>${escapeHtml(time)}</span></span></div> 10099 <div class="content">${content}</div> 10100 </div>`; 10101 10102 appendChatHtmlAndDecorate(msgHtml, atBottomBefore); 10103 return true; 10104 } 10105 10106 function pulseChatMessage(messageId) { 10107 if (!chatMessagesEl) return; 10108 const id = String(messageId || ""); 10109 if (!id) return; 10110 const el = chatMessagesEl.querySelector(`[data-msgid="${cssEscape(id)}"]`); 10111 if (!el) return; 10112 el.classList.add("isNewMsg"); 10113 window.setTimeout(() => el.classList.remove("isNewMsg"), 720); 10114 } 10115 10116 function updateActiveChatMeta() { 10117 if (activeDmThreadId) return; 10118 const post = activeChatPostId ? posts.get(activeChatPostId) : null; 10119 if (!post) return; 10120 const tags = (post.keywords || []).map((k) => `#${k}`).join(" "); 10121 const author = post.author ? `by @${post.author}` : ""; 10122 const exp = formatCountdown(post.expiresAt); 10123 const mode = normalizePostMode(post.mode || post.chatMode || ""); 10124 const modeMeta = 10125 mode === "walkie" ? " | walkie talkie" : mode === "stream" ? ` | stream (${streamKindLabel(post.streamKind || "webcam")})` : ""; 10126 chatMeta.textContent = `${author}${modeMeta} | ${exp === "permanent" ? "permanent" : `expires in ${exp}`} | ${tags}`.trim(); 10127 } 10128 10129 function openDmThread(threadId, opts = null) { 10130 const id = String(threadId || "").trim(); 10131 if (!id) return; 10132 const options = opts && typeof opts === "object" ? opts : {}; 10133 if (!options.preserveFocus) blurFocusedChatComposer(); 10134 const thread = dmThreadsById.get(id) || null; 10135 if (!thread) { 10136 pendingOpenDmThreadId = id; 10137 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "dmList" })); 10138 toast("DMs", "Thread not found yet. Refreshing DM list."); 10139 return; 10140 } 10141 if (String(thread.status || "") !== "active") { 10142 pendingOpenDmThreadId = id; 10143 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "dmList" })); 10144 toast("DMs", "DM is not active yet."); 10145 return; 10146 } 10147 pendingOpenDmThreadId = ""; 10148 if (activeChatPostId) ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); 10149 activeChatPostId = null; 10150 activeDmThreadId = id; 10151 touchRecentDmChat(id); 10152 setReplyToMessage(null); 10153 ws.send(JSON.stringify({ type: "dmHistory", threadId: id })); 10154 renderChatPanel(true); 10155 if (isMobileSwipeMode()) { 10156 setMobileScreen("chat"); 10157 renderMobileNav(); 10158 } 10159 } 10160 10161 function sendModDmPrompt(rawUsername) { 10162 const to = String(rawUsername || "") 10163 .trim() 10164 .replace(/^@+/, "") 10165 .toLowerCase(); 10166 if (!to) return; 10167 if (!loggedInUser) { 10168 toast("Sign in required", "Sign in to send moderator DMs."); 10169 return; 10170 } 10171 if (!canModerate) { 10172 toast("Moderator only", "You need moderator permissions."); 10173 return; 10174 } 10175 if (to === String(loggedInUser).toLowerCase()) { 10176 toast("Unavailable", "Can't send a moderator DM to yourself."); 10177 return; 10178 } 10179 const text = String(prompt(`Send moderator DM to @${to}:`) || "").trim(); 10180 if (!text) return; 10181 ws.send(JSON.stringify({ type: "dmSendMod", to, text })); 10182 toast("Moderator DM", `Sent to @${to}.`); 10183 } 10184 10185 function openChat(postId, opts = null) { 10186 activeDmThreadId = null; 10187 stopWalkieRecording(); 10188 const options = opts && typeof opts === "object" ? opts : {}; 10189 if (!options.preserveFocus) blurFocusedChatComposer(); 10190 const sourceEl = options.sourceEl instanceof HTMLElement ? options.sourceEl : null; 10191 const post = posts.get(postId); 10192 if (!post) return; 10193 if (post.deleted) { 10194 activeChatPostId = postId; 10195 touchRecentHiveChat(postId); 10196 renderChatPanel(true); 10197 if (isMobileSwipeMode()) setMobilePanel("chat"); 10198 return; 10199 } 10200 if (post.locked) { 10201 unlockPostFlow(postId, true); 10202 return; 10203 } 10204 10205 // Rack mode: prefer an existing workspace chat panel; if none exists, create a split view 10206 // (referrer on left, chat on right) so opening chat is deterministic. 10207 if (rackLayoutEnabled && !isStreamPost(post)) { 10208 const workspaceTarget = chooseWorkspaceChatTarget(sourceEl); 10209 if (workspaceTarget?.kind === "instance" && workspaceTarget.panelId) { 10210 touchRecentHiveChat(postId); 10211 markRead(postId); 10212 renderFeed(); 10213 ws.send(JSON.stringify({ type: "getChat", postId })); 10214 setChatInstancePanelPost(workspaceTarget.panelId, postId, true); 10215 renderChatContextSelect(); 10216 return; 10217 } 10218 if (workspaceTarget?.kind === "main") { 10219 activeChatPostId = postId; 10220 touchRecentHiveChat(postId); 10221 markRead(postId); 10222 renderFeed(); 10223 ws.send(JSON.stringify({ type: "getChat", postId })); 10224 renderChatPanel(true); 10225 renderTypingIndicator(); 10226 if (isMobileSwipeMode()) setMobilePanel("chat"); 10227 return; 10228 } 10229 if (ensureChatWorkspaceSplit(sourceEl)) { 10230 activeChatPostId = postId; 10231 touchRecentHiveChat(postId); 10232 markRead(postId); 10233 renderFeed(); 10234 ws.send(JSON.stringify({ type: "getChat", postId })); 10235 renderChatPanel(true); 10236 renderTypingIndicator(); 10237 if (isMobileSwipeMode()) setMobilePanel("chat"); 10238 return; 10239 } 10240 } 10241 if (activeChatPostId && activeChatPostId !== postId) { 10242 ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); 10243 setReplyToMessage(null); 10244 } 10245 activeChatPostId = postId; 10246 touchRecentHiveChat(postId); 10247 markRead(postId); 10248 renderFeed(); 10249 ws.send(JSON.stringify({ type: "getChat", postId })); 10250 renderChatPanel(true); 10251 renderTypingIndicator(); 10252 if (isMobileSwipeMode()) setMobilePanel("chat"); 10253 } 10254 10255 let pendingOpenChatAfterUnlock = null; 10256 function unlockPostFlow(postId, openChatAfter) { 10257 const pw = prompt("Password for this post:"); 10258 if (!pw) return; 10259 pendingOpenChatAfterUnlock = openChatAfter ? postId : null; 10260 ws.send(JSON.stringify({ type: "unlockPost", postId, password: pw })); 10261 } 10262 10263 function runCmd(target, cmd) { 10264 target.focus(); 10265 document.execCommand(cmd); 10266 } 10267 10268 function runLink(target) { 10269 target.focus(); 10270 const url = prompt("Link URL (https://...)"); 10271 if (!url) return; 10272 document.execCommand("createLink", false, url); 10273 } 10274 10275 function runEmoji(target) { 10276 target.focus(); 10277 const raw = prompt("Emoji to insert (example: ππ₯π)"); 10278 const emoji = String(raw || "").trim(); 10279 if (!emoji) return; 10280 document.execCommand("insertText", false, emoji); 10281 } 10282 10283 function readFileAsDataUrl(file) { 10284 return new Promise((resolve, reject) => { 10285 const reader = new FileReader(); 10286 reader.onload = () => resolve(String(reader.result || "")); 10287 reader.onerror = () => reject(new Error("Failed to read file")); 10288 reader.readAsDataURL(file); 10289 }); 10290 } 10291 10292 async function resizeImageToSquareDataUrl(file, sizePx) { 10293 const dataUrl = await readFileAsDataUrl(file); 10294 const img = new Image(); 10295 img.src = dataUrl; 10296 await img.decode(); 10297 const canvas = document.createElement("canvas"); 10298 canvas.width = sizePx; 10299 canvas.height = sizePx; 10300 const ctx = canvas.getContext("2d"); 10301 if (!ctx) return ""; 10302 const side = Math.min(img.width, img.height); 10303 const sx = Math.floor((img.width - side) / 2); 10304 const sy = Math.floor((img.height - side) / 2); 10305 ctx.drawImage(img, sx, sy, side, side, 0, 0, sizePx, sizePx); 10306 // Preserve transparency for avatars (JPEG strips alpha). 10307 const webp = canvas.toDataURL("image/webp", 0.9); 10308 if (typeof webp === "string" && webp.startsWith("data:image/webp")) return webp; 10309 return canvas.toDataURL("image/png"); 10310 } 10311 10312 async function uploadMediaFile(file, kind) { 10313 if (!file) return ""; 10314 const maxBytes = kind === "audio" ? CLIENT_AUDIO_UPLOAD_MAX_BYTES : CLIENT_IMAGE_UPLOAD_MAX_BYTES; 10315 if (file.size > maxBytes) { 10316 toast("File too large", `${kind === "audio" ? "Audio" : "Image"} is too large for this server.`); 10317 return ""; 10318 } 10319 const token = getSessionToken(); 10320 if (!token) { 10321 toast("Sign in required", "Please sign in before uploading files."); 10322 return ""; 10323 } 10324 const loweredName = String(file.name || "").toLowerCase(); 10325 let contentType = (file.type || "").toLowerCase(); 10326 if (!contentType) { 10327 if (kind === "image") { 10328 if (loweredName.endsWith(".gif")) contentType = "image/gif"; 10329 else if (loweredName.endsWith(".png")) contentType = "image/png"; 10330 else if (loweredName.endsWith(".webp")) contentType = "image/webp"; 10331 else if (loweredName.endsWith(".jpg") || loweredName.endsWith(".jpeg")) contentType = "image/jpeg"; 10332 } else if (kind === "audio") { 10333 if (loweredName.endsWith(".mp3")) contentType = "audio/mpeg"; 10334 else if (loweredName.endsWith(".wav")) contentType = "audio/wav"; 10335 else if (loweredName.endsWith(".ogg")) contentType = "audio/ogg"; 10336 else if (loweredName.endsWith(".webm")) contentType = "audio/webm"; 10337 else if (loweredName.endsWith(".aac")) contentType = "audio/aac"; 10338 else if (loweredName.endsWith(".m4a") || loweredName.endsWith(".mp4")) contentType = "audio/mp4"; 10339 } 10340 } 10341 const headers = { 10342 Authorization: `Bearer ${token}`, 10343 "Content-Type": contentType || "application/octet-stream" 10344 }; 10345 try { 10346 const res = await fetch(`/api/upload?kind=${encodeURIComponent(kind)}`, { 10347 method: "POST", 10348 headers, 10349 body: file 10350 }); 10351 const payload = await res.json().catch(() => ({})); 10352 if (!res.ok) { 10353 toast("Upload failed", payload?.error || "Upload failed."); 10354 return ""; 10355 } 10356 if (!payload?.url) { 10357 toast("Upload failed", "Server did not return a media URL."); 10358 return ""; 10359 } 10360 return String(payload.url); 10361 } catch { 10362 toast("Upload failed", "Network error while uploading file."); 10363 return ""; 10364 } 10365 } 10366 10367 async function ensureWalkieContext() { 10368 if (walkieCtx) return walkieCtx; 10369 const ctx = new (window.AudioContext || window.webkitAudioContext)(); 10370 walkieCtx = ctx; 10371 return ctx; 10372 } 10373 10374 async function ensureWalkieDispatchBuffer() { 10375 if (walkieDispatchBuffer) return walkieDispatchBuffer; 10376 const ctx = await ensureWalkieContext(); 10377 try { 10378 const res = await fetch("/assets/walkie/dispatch.mp3"); 10379 const arr = await res.arrayBuffer(); 10380 walkieDispatchBuffer = await ctx.decodeAudioData(arr); 10381 return walkieDispatchBuffer; 10382 } catch { 10383 walkieDispatchBuffer = null; 10384 return null; 10385 } 10386 } 10387 10388 async function ensureWalkieGraph() { 10389 const ctx = await ensureWalkieContext(); 10390 if (walkieMixNode && walkieDestNode) return { ctx, mix: walkieMixNode, dest: walkieDestNode }; 10391 10392 if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") { 10393 throw new Error("Microphone is not supported in this browser."); 10394 } 10395 const host = String(location.hostname || "").toLowerCase(); 10396 const isLocal = 10397 host === "localhost" || 10398 host === "127.0.0.1" || 10399 host === "::1" || 10400 host.startsWith("192.168.") || 10401 host.startsWith("10.") || 10402 host.startsWith("172.16.") || 10403 host.startsWith("172.17.") || 10404 host.startsWith("172.18.") || 10405 host.startsWith("172.19.") || 10406 host.startsWith("172.20.") || 10407 host.startsWith("172.21.") || 10408 host.startsWith("172.22.") || 10409 host.startsWith("172.23.") || 10410 host.startsWith("172.24.") || 10411 host.startsWith("172.25.") || 10412 host.startsWith("172.26.") || 10413 host.startsWith("172.27.") || 10414 host.startsWith("172.28.") || 10415 host.startsWith("172.29.") || 10416 host.startsWith("172.30.") || 10417 host.startsWith("172.31."); 10418 if (!window.isSecureContext && !isLocal) { 10419 throw new Error("Microphone requires HTTPS (or localhost). Use your Cloudflare tunnel URL."); 10420 } 10421 10422 if (!walkieMicStream) { 10423 walkieMicStream = await navigator.mediaDevices.getUserMedia({ 10424 audio: { 10425 echoCancellation: true, 10426 noiseSuppression: true, 10427 autoGainControl: true, 10428 }, 10429 }); 10430 } 10431 10432 const micSource = new MediaStreamAudioSourceNode(ctx, { mediaStream: walkieMicStream }); 10433 const mix = new GainNode(ctx, { gain: 1 }); 10434 micSource.connect(mix); 10435 10436 let head = mix; 10437 let tail = null; 10438 let usedWorklet = false; 10439 10440 if (ctx.audioWorklet) { 10441 try { 10442 await ctx.audioWorklet.addModule("/assets/walkie/transmission-processor.js"); 10443 const pre = new AudioWorkletNode(ctx, "transmission-sat", { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1] }); 10444 const hp1 = new BiquadFilterNode(ctx, { type: "highpass", Q: 0.9, frequency: 420 }); 10445 const hp2 = new BiquadFilterNode(ctx, { type: "highpass", Q: 0.9, frequency: 420 }); 10446 const lp1 = new BiquadFilterNode(ctx, { type: "lowpass", Q: 0.9, frequency: 4200 }); 10447 const lp2 = new BiquadFilterNode(ctx, { type: "lowpass", Q: 0.9, frequency: 4200 }); 10448 const dip = new BiquadFilterNode(ctx, { type: "peaking", frequency: 680, Q: 0.8, gain: -1.1 }); 10449 const mid = new BiquadFilterNode(ctx, { type: "peaking", frequency: 1550, Q: 1.25, gain: 2.0 }); 10450 const post = new AudioWorkletNode(ctx, "transmission-post", { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1] }); 10451 10452 head.connect(pre); 10453 pre.connect(hp1); 10454 hp1.connect(hp2); 10455 hp2.connect(lp1); 10456 lp1.connect(lp2); 10457 lp2.connect(dip); 10458 dip.connect(mid); 10459 mid.connect(post); 10460 tail = post; 10461 10462 pre.parameters.get("drive")?.setValueAtTime(0.32, ctx.currentTime); 10463 pre.parameters.get("asym")?.setValueAtTime(0.12, ctx.currentTime); 10464 pre.parameters.get("mix")?.setValueAtTime(1, ctx.currentTime); 10465 10466 post.parameters.get("drive")?.setValueAtTime(0.42, ctx.currentTime); 10467 post.parameters.get("asym")?.setValueAtTime(0.12, ctx.currentTime); 10468 post.parameters.get("comp")?.setValueAtTime(0.38, ctx.currentTime); 10469 post.parameters.get("crush")?.setValueAtTime(0.04, ctx.currentTime); 10470 post.parameters.get("badAmount")?.setValueAtTime(0.22, ctx.currentTime); 10471 post.parameters.get("wowDepth")?.setValueAtTime(0.18, ctx.currentTime); 10472 post.parameters.get("dropRate")?.setValueAtTime(0.18, ctx.currentTime); 10473 post.parameters.get("dropDepth")?.setValueAtTime(0.25, ctx.currentTime); 10474 post.parameters.get("crackle")?.setValueAtTime(0.22, ctx.currentTime); 10475 post.parameters.get("lfoRate")?.setValueAtTime(0.75, ctx.currentTime); 10476 post.parameters.get("noise")?.setValueAtTime(0.18, ctx.currentTime); 10477 post.parameters.get("hiss")?.setValueAtTime(0.16, ctx.currentTime); 10478 post.parameters.get("noiseColor")?.setValueAtTime(0.15, ctx.currentTime); 10479 post.parameters.get("outGain")?.setValueAtTime(0.92, ctx.currentTime); 10480 10481 usedWorklet = true; 10482 } catch { 10483 usedWorklet = false; 10484 } 10485 } 10486 10487 if (!usedWorklet) { 10488 const hp = new BiquadFilterNode(ctx, { type: "highpass", Q: 0.85, frequency: 420 }); 10489 const lp = new BiquadFilterNode(ctx, { type: "lowpass", Q: 0.85, frequency: 4200 }); 10490 const comp = new DynamicsCompressorNode(ctx, { threshold: -22, knee: 28, ratio: 5.2, attack: 0.004, release: 0.18 }); 10491 const shaper = new WaveShaperNode(ctx, { 10492 curve: (() => { 10493 const n = 512; 10494 const c = new Float32Array(n); 10495 for (let i = 0; i < n; i++) { 10496 const x = (i / (n - 1)) * 2 - 1; 10497 c[i] = Math.tanh(x * 2.4); 10498 } 10499 return c; 10500 })(), 10501 oversample: "2x", 10502 }); 10503 head.connect(hp); 10504 hp.connect(lp); 10505 lp.connect(comp); 10506 comp.connect(shaper); 10507 tail = shaper; 10508 } 10509 10510 const dest = new MediaStreamAudioDestinationNode(ctx); 10511 tail.connect(dest); 10512 10513 walkieMixNode = mix; 10514 walkieDestNode = dest; 10515 return { ctx, mix, dest }; 10516 } 10517 10518 function shouldHandleWalkieHotkey(evt) { 10519 if (!evt) return false; 10520 if (evt.repeat) return false; 10521 if (evt.code !== "Backquote") return false; 10522 const tag = String(document.activeElement?.tagName || "").toLowerCase(); 10523 if (tag === "input" || tag === "textarea") return false; 10524 if (document.activeElement?.isContentEditable) { 10525 const el = document.activeElement; 10526 if (el && el === chatEditor && canWalkieTalkNow()) return true; 10527 return false; 10528 } 10529 return true; 10530 } 10531 10532 function isTextEntryFocused() { 10533 const el = document.activeElement; 10534 if (!el) return false; 10535 const tag = String(el.tagName || "").toLowerCase(); 10536 if (tag === "textarea") return true; 10537 if (tag === "input") { 10538 const type = String(el.getAttribute?.("type") || "text").toLowerCase(); 10539 return !["button", "checkbox", "color", "file", "hidden", "radio", "range", "reset", "submit"].includes(type); 10540 } 10541 return Boolean(el.isContentEditable); 10542 } 10543 10544 function isMapSurfaceActiveForHotkey() { 10545 if (isMapChatActive()) return true; 10546 const active = document.activeElement instanceof HTMLElement ? document.activeElement : null; 10547 if (active?.closest?.('[data-panel-id="maps"]')) return true; 10548 if (isMobileScreenMode() && appRoot) { 10549 const mobile = String(appRoot.getAttribute("data-mobile-screen") || "").trim(); 10550 if (mobile === "maps") return true; 10551 if (mobile === "host" && mobileHostPanelId === "maps") return true; 10552 } 10553 return false; 10554 } 10555 10556 function shouldSubmitChatOnEnter(evt) { 10557 if (!evt || evt.key !== "Enter") return false; 10558 const mode = readChatEnterModePref(); 10559 if (mode === "enter") return !(evt.shiftKey || evt.altKey || evt.ctrlKey || evt.metaKey); 10560 return Boolean(evt.ctrlKey || evt.metaKey); 10561 } 10562 10563 function cycleLayoutPresetBy(step) { 10564 if (!layoutPresetEl || !rackLayoutEnabled || layoutPresetEl.disabled) return; 10565 const options = Array.from(layoutPresetEl.options || []) 10566 .map((opt) => String(opt.value || "").trim()) 10567 .filter((v) => v); 10568 if (!options.length) return; 10569 const current = resolvePresetKey(String(layoutPresetEl.value || rackLayoutState?.presetId || "onboardingDefault")); 10570 let idx = options.indexOf(current); 10571 if (idx < 0) idx = 0; 10572 const len = options.length; 10573 const next = options[(idx + step + len) % len]; 10574 if (!next) return; 10575 layoutPresetEl.value = next; 10576 applyPreset(next); 10577 } 10578 10579 let hotkeyPanelContext = ""; 10580 let hoveredWorkspacePanelId = ""; 10581 let hoveredHotbarPanelId = ""; 10582 10583 function isWorkspaceRackId(rackId) { 10584 const id = String(rackId || "").trim(); 10585 return id === "mainWorkspaceRack" || id === "workspaceLeftSlot" || id === "workspaceRightSlot"; 10586 } 10587 10588 function hoveredWorkspacePanelElement() { 10589 const id = String(hoveredWorkspacePanelId || "").trim(); 10590 if (!id) return null; 10591 const el = getPanelElement(id); 10592 if (!(el instanceof HTMLElement)) return null; 10593 if (el.classList.contains("hidden")) return null; 10594 const parentId = String(el.parentElement?.id || "").trim(); 10595 if (!isWorkspaceRackId(parentId)) return null; 10596 return el; 10597 } 10598 10599 function followWorkspacePanel(panelEl) { 10600 const panel = panelEl instanceof HTMLElement ? panelEl : null; 10601 if (!(panel instanceof HTMLElement)) return; 10602 const workspace = panel.closest?.("#mainWorkspaceRack"); 10603 if (!(workspace instanceof HTMLElement)) return; 10604 const left = panel.offsetLeft; 10605 const width = panel.offsetWidth || 0; 10606 const target = Math.max(0, Math.round(left - (workspace.clientWidth - width) / 2)); 10607 const max = Math.max(0, workspace.scrollWidth - workspace.clientWidth); 10608 workspace.scrollTo({ left: Math.min(max, target), behavior: "smooth" }); 10609 } 10610 10611 function glowWorkspacePanel(panelEl) { 10612 const panel = panelEl instanceof HTMLElement ? panelEl : null; 10613 if (!(panel instanceof HTMLElement)) return; 10614 panel.classList.remove("workspaceArrivalGlow"); 10615 // Restart animation on repeated arrivals. 10616 void panel.offsetWidth; 10617 panel.classList.add("workspaceArrivalGlow"); 10618 const token = String(Date.now()); 10619 panel.dataset.workspaceArrivalToken = token; 10620 setTimeout(() => { 10621 if (panel.dataset.workspaceArrivalToken !== token) return; 10622 panel.classList.remove("workspaceArrivalGlow"); 10623 }, 900); 10624 } 10625 10626 function focusWorkspaceArrival(panelEl) { 10627 const panel = panelEl instanceof HTMLElement ? panelEl : null; 10628 if (!(panel instanceof HTMLElement)) return; 10629 const rackId = rackIdForPanelElement(panel); 10630 if (!isWorkspaceRackId(rackId)) return; 10631 followWorkspacePanel(panel); 10632 glowWorkspacePanel(panel); 10633 } 10634 10635 function cycleHoveredWorkspacePanelSize() { 10636 if (!rackLayoutEnabled) return false; 10637 const panelEl = hoveredWorkspacePanelElement(); 10638 if (!(panelEl instanceof HTMLElement)) return false; 10639 const panelId = String(panelEl.dataset.panelId || "").trim(); 10640 if (!panelId || isRightRackFixedPanel(panelId)) return false; 10641 const order = ["skinny", "half", "full"]; 10642 const current = panelWorkspaceSize(panelId); 10643 const idx = order.indexOf(current); 10644 const next = order[(idx + 1 + order.length) % order.length]; 10645 setPanelWorkspaceSize(panelId, next); 10646 applyAllWorkspacePanelSizes(); 10647 followWorkspacePanel(panelEl); 10648 return true; 10649 } 10650 10651 function moveHoveredWorkspacePanelHorizontal(step) { 10652 if (!rackLayoutEnabled) return false; 10653 const panelEl = hoveredWorkspacePanelElement(); 10654 if (!(panelEl instanceof HTMLElement)) return false; 10655 const parent = panelEl.parentElement; 10656 if (!(parent instanceof HTMLElement)) return false; 10657 const peers = Array.from(parent.querySelectorAll(":scope > .rackPanel:not(.hidden)")); 10658 const idx = peers.indexOf(panelEl); 10659 if (idx < 0) return false; 10660 const nextIdx = idx + (step < 0 ? -1 : 1); 10661 if (nextIdx < 0 || nextIdx >= peers.length) return false; 10662 const other = peers[nextIdx]; 10663 if (!(other instanceof HTMLElement)) return false; 10664 if (step < 0) parent.insertBefore(panelEl, other); 10665 else parent.insertBefore(other, panelEl); 10666 syncRackStateFromDom(); 10667 enforceWorkspaceRules(); 10668 followWorkspacePanel(panelEl); 10669 return true; 10670 } 10671 10672 function dockHoveredWorkspacePanelToHotbar() { 10673 if (!rackLayoutEnabled) return false; 10674 const panelEl = hoveredWorkspacePanelElement(); 10675 if (!(panelEl instanceof HTMLElement)) return false; 10676 const panelId = String(panelEl.dataset.panelId || "").trim(); 10677 if (!panelId || isRightRackFixedPanel(panelId)) return false; 10678 dockPanel(panelId); 10679 return true; 10680 } 10681 10682 function restoreHoveredHotbarPanelToWorkspace() { 10683 if (!rackLayoutEnabled) return false; 10684 const panelId = String(hoveredHotbarPanelId || "").trim(); 10685 if (!panelId) return false; 10686 restorePanelFromHotbar(panelId, { userAdded: true }); 10687 return true; 10688 } 10689 function updateHotkeyPanelContextFromTarget(target) { 10690 const el = target instanceof HTMLElement ? target : null; 10691 if (!el) return; 10692 if (el.closest("#hivesPanel")) { 10693 hotkeyPanelContext = "hives"; 10694 return; 10695 } 10696 if (el.closest("aside.chat") || el.closest(".chatInstance") || el.closest("[data-panel-id^='chat:post:']")) { 10697 hotkeyPanelContext = "chat"; 10698 } 10699 } 10700 10701 function activePanelContextForHotkeys() { 10702 if (isMobileScreenMode() && appRoot) { 10703 const mobile = String(appRoot.getAttribute("data-mobile-screen") || "").trim(); 10704 if (mobile === "hives") return "hives"; 10705 if (mobile === "chat" || (mobile === "host" && mobileHostPanelId === "chat")) return "chat"; 10706 } 10707 const ae = document.activeElement instanceof HTMLElement ? document.activeElement : null; 10708 if (ae) { 10709 if (ae.closest("#hivesPanel")) return "hives"; 10710 if (ae.closest("aside.chat") || ae.closest(".chatInstance") || ae.closest("[data-panel-id^='chat:post:']")) return "chat"; 10711 } 10712 return hotkeyPanelContext || ""; 10713 } 10714 10715 function cycleHiveViewBy(step) { 10716 if (!hiveTabsEl) return false; 10717 const views = Array.from(hiveTabsEl.querySelectorAll("button[data-hiveview]:not([disabled])")) 10718 .map((b) => String(b.getAttribute("data-hiveview") || "").trim()) 10719 .filter(Boolean); 10720 if (!views.length) return false; 10721 let idx = views.indexOf(String(activeHiveView || "all")); 10722 if (idx < 0) idx = 0; 10723 const len = views.length; 10724 const next = views[(idx + step + len) % len]; 10725 if (!next || next === activeHiveView) return false; 10726 activeHiveView = next; 10727 renderFeed(); 10728 return true; 10729 } 10730 10731 function cycleChatContextBy(step) { 10732 renderChatContextSelect(); 10733 if (!(chatContextSelectEl instanceof HTMLSelectElement)) return false; 10734 const items = [ 10735 "__list__", 10736 ...Array.from(chatContextSelectEl.options || []) 10737 .map((o) => String(o.value || "").trim()) 10738 .filter((v) => v && (v.startsWith("dm:") || v.startsWith("post:"))), 10739 ]; 10740 if (items.length <= 1) return false; 10741 const current = activeDmThreadId ? `dm:${activeDmThreadId}` : activeChatPostId ? `post:${activeChatPostId}` : "__list__"; 10742 let idx = items.indexOf(current); 10743 if (idx < 0) idx = 0; 10744 const len = items.length; 10745 const next = items[(idx + step + len) % len]; 10746 if (!next || next === current) return false; 10747 if (next === "__list__") { 10748 if (activeChatPostId && ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); 10749 activeChatPostId = null; 10750 activeDmThreadId = null; 10751 activeMapsRoomId = ""; 10752 activeMapsRoomTitle = ""; 10753 setReplyToMessage(null); 10754 renderChatPanel(true); 10755 return true; 10756 } 10757 if (next.startsWith("dm:")) { 10758 return openChatContextValue(next, { preserveFocus: false }); 10759 } 10760 if (next.startsWith("post:")) { 10761 return openChatContextValue(next, { preserveFocus: false }); 10762 } 10763 return false; 10764 } 10765 10766 function streamIceConfig() { 10767 if (!Array.isArray(streamIceServers) || !streamIceServers.length) return []; 10768 return streamIceServers.map((row) => { 10769 const urls = Array.isArray(row?.urls) 10770 ? row.urls.map((x) => String(x || "").trim()).filter(Boolean) 10771 : typeof row?.urls === "string" && row.urls.trim() 10772 ? [row.urls.trim()] 10773 : []; 10774 const out = { urls }; 10775 if (typeof row?.username === "string" && row.username.trim()) out.username = row.username.trim(); 10776 if (typeof row?.credential === "string" && row.credential.trim()) out.credential = row.credential.trim(); 10777 return out; 10778 }); 10779 } 10780 10781 function stopStreamTracks(stream) { 10782 const tracks = stream && typeof stream.getTracks === "function" ? stream.getTracks() : []; 10783 for (const track of tracks) { 10784 try { 10785 track.onended = null; 10786 track.stop(); 10787 } catch { 10788 // ignore 10789 } 10790 } 10791 } 10792 10793 function closeStreamPeer(clientId) { 10794 const id = String(clientId || "").trim(); 10795 if (!id) return; 10796 const pc = streamPeerByClientId.get(id); 10797 streamPeerByClientId.delete(id); 10798 streamRemoteMediaByClientId.delete(id); 10799 streamPeerUsernameByClientId.delete(id); 10800 const remoteAudioEl = streamRemoteAudioByClientId.get(id); 10801 if (remoteAudioEl) { 10802 try { 10803 remoteAudioEl.pause(); 10804 } catch { 10805 // ignore 10806 } 10807 remoteAudioEl.srcObject = null; 10808 remoteAudioEl.remove(); 10809 } 10810 streamRemoteAudioByClientId.delete(id); 10811 if (!pc) return; 10812 try { 10813 pc.onicecandidate = null; 10814 pc.onconnectionstatechange = null; 10815 pc.ontrack = null; 10816 pc.close(); 10817 } catch { 10818 // ignore 10819 } 10820 } 10821 10822 function closeAllStreamPeers() { 10823 for (const id of Array.from(streamPeerByClientId.keys())) closeStreamPeer(id); 10824 } 10825 10826 function clearStreamMediaPreview() { 10827 if (streamStageVideoEl) { 10828 try { 10829 streamStageVideoEl.pause(); 10830 } catch { 10831 // ignore 10832 } 10833 streamStageVideoEl.srcObject = null; 10834 streamStageVideoEl.classList.add("hidden"); 10835 streamStageVideoEl.muted = false; 10836 } 10837 if (streamStageAudioEl) { 10838 try { 10839 streamStageAudioEl.pause(); 10840 } catch { 10841 // ignore 10842 } 10843 streamStageAudioEl.srcObject = null; 10844 streamStageAudioEl.classList.add("hidden"); 10845 streamStageAudioEl.muted = false; 10846 } 10847 streamRemoteMedia = null; 10848 } 10849 10850 function ensureRemoteAudioEl(clientId) { 10851 const id = String(clientId || "").trim(); 10852 if (!id) return null; 10853 const existing = streamRemoteAudioByClientId.get(id); 10854 if (existing) return existing; 10855 const el = document.createElement("audio"); 10856 el.autoplay = true; 10857 el.controls = false; 10858 el.preload = "none"; 10859 el.className = "streamStageAudio hidden"; 10860 streamRemoteAudioByClientId.set(id, el); 10861 streamStageEl?.appendChild(el); 10862 return el; 10863 } 10864 10865 function updateStreamOutputMuteState() { 10866 const deafened = Boolean(streamVoiceDeafened); 10867 const hostingPreview = streamCurrentRole === "host"; 10868 if (streamStageAudioEl) streamStageAudioEl.muted = deafened || hostingPreview; 10869 if (streamStageVideoEl) streamStageVideoEl.muted = deafened || hostingPreview; 10870 for (const el of streamRemoteAudioByClientId.values()) { 10871 el.muted = deafened; 10872 } 10873 } 10874 10875 function updateStreamLocalMuteState() { 10876 const media = streamCurrentRole === "host" ? streamLocalMedia : streamVoiceMedia; 10877 const tracks = media && typeof media.getAudioTracks === "function" ? media.getAudioTracks() : []; 10878 for (const track of tracks) track.enabled = !streamVoiceMuted; 10879 } 10880 10881 function streamDisplayNameForClientId(peerClientId) { 10882 const id = String(peerClientId || "").trim(); 10883 if (!id) return "Unknown"; 10884 if (id === String(clientId || "").trim()) return `${loggedInUser ? `@${loggedInUser}` : "You"} (you)`; 10885 const username = String(streamPeerUsernameByClientId.get(id) || "").trim(); 10886 return username ? `@${username}` : `Peer ${id.slice(0, 6)}`; 10887 } 10888 10889 function renderStreamVoiceUsers() { 10890 if (!streamVoiceUsersEl) return; 10891 const rows = []; 10892 for (const [peerId, audioEl] of streamRemoteAudioByClientId.entries()) { 10893 if (!audioEl || peerId === streamCurrentHostClientId) continue; 10894 const vol = Math.max(0, Math.min(1, Number(streamPeerVolumeByClientId.get(peerId) ?? audioEl.volume ?? 1))); 10895 rows.push({ peerId, label: streamDisplayNameForClientId(peerId), vol }); 10896 } 10897 if (!rows.length) { 10898 streamVoiceUsersEl.innerHTML = `<div>No active voice peers.</div>`; 10899 return; 10900 } 10901 streamVoiceUsersEl.innerHTML = rows 10902 .map( 10903 (row) => `<label class="streamVoiceUserRow"> 10904 <span>${escapeHtml(row.label)}</span> 10905 <input type="range" min="0" max="100" step="1" value="${escapeHtml(String(Math.round(row.vol * 100)))}" data-streamvol="${escapeHtml(row.peerId)}" /> 10906 </label>` 10907 ) 10908 .join(""); 10909 } 10910 10911 function renderStreamVoiceControls(post, isHosting, isViewing) { 10912 if (!streamVoiceControlsEl) return; 10913 const streamPost = post && isStreamPost(post) ? post : null; 10914 const enabled = Boolean(streamPost && (isHosting || isViewing)); 10915 streamVoiceControlsEl.classList.toggle("hidden", !enabled); 10916 if (!enabled) return; 10917 if (streamVoiceJoinToggleEl) { 10918 streamVoiceJoinToggleEl.disabled = isHosting; 10919 streamVoiceJoinToggleEl.checked = isHosting ? true : Boolean(streamVoiceJoined); 10920 } 10921 if (streamVoiceMuteBtn) { 10922 streamVoiceMuteBtn.disabled = !(isHosting || streamVoiceJoined); 10923 streamVoiceMuteBtn.textContent = streamVoiceMuted ? "Unmute mic" : "Mute mic"; 10924 } 10925 if (streamVoiceDeafenBtn) { 10926 streamVoiceDeafenBtn.disabled = !(isHosting || isViewing); 10927 streamVoiceDeafenBtn.textContent = streamVoiceDeafened ? "Undeafen" : "Deafen"; 10928 } 10929 renderStreamVoiceUsers(); 10930 } 10931 10932 function addVoiceTracksToPeer(peerClientId) { 10933 const id = String(peerClientId || "").trim(); 10934 if (!id) return; 10935 const pc = streamPeerByClientId.get(id); 10936 if (!pc) return; 10937 const media = streamCurrentRole === "host" ? streamLocalMedia : streamVoiceMedia; 10938 if (!media || typeof media.getAudioTracks !== "function") return; 10939 const tracks = media.getAudioTracks(); 10940 if (!tracks.length) return; 10941 for (const track of tracks) { 10942 const hasSender = pc 10943 .getSenders() 10944 .some((sender) => sender?.track && sender.track.id === track.id && sender.track.kind === "audio"); 10945 if (hasSender) continue; 10946 try { 10947 pc.addTrack(track, media); 10948 } catch { 10949 // ignore addTrack failures 10950 } 10951 } 10952 } 10953 10954 async function renegotiateStreamPeer(peerClientId) { 10955 const id = String(peerClientId || "").trim(); 10956 if (!id) return; 10957 const pc = streamPeerByClientId.get(id); 10958 if (!pc || !streamCurrentPostId || !ws || ws.readyState !== WebSocket.OPEN) return; 10959 try { 10960 const offer = await pc.createOffer(); 10961 await pc.setLocalDescription(offer); 10962 ws.send( 10963 JSON.stringify({ 10964 type: "streamSignal", 10965 postId: streamCurrentPostId, 10966 targetClientId: id, 10967 signal: { type: "offer", sdp: String(offer.sdp || "") }, 10968 }) 10969 ); 10970 } catch (e) { 10971 console.warn("stream renegotiation failed:", e?.message || e); 10972 } 10973 } 10974 10975 function attachStreamPreview(stream, kind, local = false) { 10976 const streamObj = stream && typeof stream.getTracks === "function" ? stream : null; 10977 if (!streamObj) { 10978 clearStreamMediaPreview(); 10979 return; 10980 } 10981 const k = normalizeStreamKind(kind); 10982 const hasVideo = streamObj.getVideoTracks().length > 0; 10983 if (k === "audio" || !hasVideo) { 10984 if (streamStageVideoEl) { 10985 streamStageVideoEl.srcObject = null; 10986 streamStageVideoEl.classList.add("hidden"); 10987 } 10988 if (streamStageAudioEl) { 10989 streamStageAudioEl.srcObject = streamObj; 10990 streamStageAudioEl.classList.remove("hidden"); 10991 streamStageAudioEl.muted = Boolean(local); 10992 streamStageAudioEl.play?.().catch(() => {}); 10993 } 10994 updateStreamOutputMuteState(); 10995 return; 10996 } 10997 if (streamStageAudioEl) { 10998 streamStageAudioEl.srcObject = null; 10999 streamStageAudioEl.classList.add("hidden"); 11000 } 11001 if (streamStageVideoEl) { 11002 streamStageVideoEl.srcObject = streamObj; 11003 streamStageVideoEl.classList.remove("hidden"); 11004 streamStageVideoEl.muted = Boolean(local); 11005 streamStageVideoEl.play?.().catch(() => {}); 11006 } 11007 updateStreamOutputMuteState(); 11008 } 11009 11010 function streamCanHostPost(post) { 11011 if (!post || !loggedInUser) return false; 11012 if (String(post.author || "") === String(loggedInUser || "")) return true; 11013 return isStaffRole(loggedInRole); 11014 } 11015 11016 function streamResetState(keepPostId = false) { 11017 closeAllStreamPeers(); 11018 stopStreamTracks(streamLocalMedia); 11019 stopStreamTracks(streamVoiceMedia); 11020 streamLocalMedia = null; 11021 streamVoiceMedia = null; 11022 streamVoiceJoined = false; 11023 streamVoiceMuted = false; 11024 streamVoiceDeafened = false; 11025 streamRemoteMedia = null; 11026 streamRemoteHostClientId = ""; 11027 streamCurrentHostClientId = ""; 11028 streamCurrentRole = "idle"; 11029 streamRemoteMediaByClientId.clear(); 11030 streamPeerUsernameByClientId.clear(); 11031 streamPeerVolumeByClientId.clear(); 11032 if (!keepPostId) streamCurrentPostId = ""; 11033 clearStreamMediaPreview(); 11034 renderStreamVoiceUsers(); 11035 } 11036 11037 function leaveActiveStream(sendSignal = true) { 11038 const postId = String(streamCurrentPostId || "").trim(); 11039 const wasRole = streamCurrentRole; 11040 if (sendSignal && ws?.readyState === WebSocket.OPEN && postId) { 11041 if (wasRole === "host") ws.send(JSON.stringify({ type: "streamHostStop", postId })); 11042 else if (wasRole === "viewer") ws.send(JSON.stringify({ type: "streamLeave", postId })); 11043 } 11044 streamResetState(false); 11045 } 11046 11047 function streamStageCurrentPost() { 11048 if (activeDmThreadId) return null; 11049 const post = activeChatPostId ? posts.get(activeChatPostId) : null; 11050 if (!post || post.deleted || !isStreamPost(post)) return null; 11051 return post; 11052 } 11053 11054 function createStreamPeer(targetClientId) { 11055 const target = String(targetClientId || "").trim(); 11056 if (!target) return null; 11057 if (typeof RTCPeerConnection !== "function") { 11058 toast("Stream", "WebRTC is not available in this browser."); 11059 return null; 11060 } 11061 const existing = streamPeerByClientId.get(target); 11062 if (existing) return existing; 11063 const pc = new RTCPeerConnection({ iceServers: streamIceConfig() }); 11064 streamPeerByClientId.set(target, pc); 11065 pc.onicecandidate = (evt) => { 11066 if (!evt.candidate || !ws || ws.readyState !== WebSocket.OPEN || !streamCurrentPostId) return; 11067 ws.send( 11068 JSON.stringify({ 11069 type: "streamSignal", 11070 postId: streamCurrentPostId, 11071 targetClientId: target, 11072 signal: { type: "candidate", candidate: evt.candidate }, 11073 }) 11074 ); 11075 }; 11076 pc.ontrack = (evt) => { 11077 const remote = evt.streams && evt.streams[0] ? evt.streams[0] : null; 11078 if (!remote) return; 11079 streamRemoteMediaByClientId.set(target, remote); 11080 if (target === streamCurrentHostClientId && streamCurrentRole !== "host") { 11081 streamRemoteMedia = remote; 11082 attachStreamPreview(remote, streamRemoteKind, false); 11083 if (streamStagePlaceholderEl) streamStagePlaceholderEl.classList.add("hidden"); 11084 } else { 11085 const remoteAudioEl = ensureRemoteAudioEl(target); 11086 if (remoteAudioEl) { 11087 remoteAudioEl.srcObject = remote; 11088 const savedVolume = Number(streamPeerVolumeByClientId.get(target)); 11089 remoteAudioEl.volume = Number.isFinite(savedVolume) ? Math.max(0, Math.min(1, savedVolume)) : 1; 11090 remoteAudioEl.play?.().catch(() => {}); 11091 } 11092 updateStreamOutputMuteState(); 11093 renderStreamVoiceUsers(); 11094 } 11095 }; 11096 pc.onconnectionstatechange = () => { 11097 const state = String(pc.connectionState || ""); 11098 if (state === "failed" || state === "closed" || state === "disconnected") closeStreamPeer(target); 11099 }; 11100 if (streamCurrentRole === "host" && streamLocalMedia) { 11101 for (const track of streamLocalMedia.getTracks()) { 11102 try { 11103 pc.addTrack(track, streamLocalMedia); 11104 } catch { 11105 // ignore 11106 } 11107 } 11108 } else if (streamVoiceJoined && streamVoiceMedia) { 11109 addVoiceTracksToPeer(target); 11110 } 11111 return pc; 11112 } 11113 11114 async function handleStreamSignalMessage(msg) { 11115 const postId = String(msg.postId || "").trim(); 11116 const fromClientId = String(msg.fromClientId || "").trim(); 11117 const signal = msg.signal && typeof msg.signal === "object" ? msg.signal : null; 11118 if (!postId || !fromClientId || !signal) return; 11119 if (!streamCurrentPostId || streamCurrentPostId !== postId) return; 11120 const type = String(signal.type || "").trim().toLowerCase(); 11121 if (!type) return; 11122 const pc = createStreamPeer(fromClientId); 11123 if (!pc) return; 11124 try { 11125 if (type === "offer") { 11126 await pc.setRemoteDescription(new RTCSessionDescription({ type: "offer", sdp: String(signal.sdp || "") })); 11127 if (streamCurrentRole === "host" || streamVoiceJoined) addVoiceTracksToPeer(fromClientId); 11128 const answer = await pc.createAnswer(); 11129 await pc.setLocalDescription(answer); 11130 ws?.send( 11131 JSON.stringify({ 11132 type: "streamSignal", 11133 postId, 11134 targetClientId: fromClientId, 11135 signal: { type: "answer", sdp: String(answer.sdp || "") }, 11136 }) 11137 ); 11138 return; 11139 } 11140 if (type === "answer") { 11141 await pc.setRemoteDescription(new RTCSessionDescription({ type: "answer", sdp: String(signal.sdp || "") })); 11142 return; 11143 } 11144 if (type === "candidate" && signal.candidate) { 11145 await pc.addIceCandidate(new RTCIceCandidate(signal.candidate)); 11146 } 11147 } catch (e) { 11148 console.warn("stream signal failed:", e?.message || e); 11149 } 11150 } 11151 11152 async function handleStreamPeerJoinMessage(msg) { 11153 const postId = String(msg.postId || "").trim(); 11154 const peerClientId = String(msg.peerClientId || msg.viewerClientId || "").trim(); 11155 const peerUsername = String(msg.peerUsername || msg.viewerUsername || "").trim(); 11156 const initiateOffer = msg.initiateOffer === true; 11157 if (!postId || !peerClientId) return; 11158 if (streamCurrentRole === "idle" || streamCurrentPostId !== postId) return; 11159 if (peerUsername) streamPeerUsernameByClientId.set(peerClientId, peerUsername); 11160 const pc = createStreamPeer(peerClientId); 11161 if (!pc) return; 11162 if (!initiateOffer) return; 11163 try { 11164 if (streamCurrentRole === "host" || streamVoiceJoined) addVoiceTracksToPeer(peerClientId); 11165 const offer = await pc.createOffer(); 11166 await pc.setLocalDescription(offer); 11167 ws?.send( 11168 JSON.stringify({ 11169 type: "streamSignal", 11170 postId, 11171 targetClientId: peerClientId, 11172 signal: { type: "offer", sdp: String(offer.sdp || "") }, 11173 }) 11174 ); 11175 } catch (e) { 11176 console.warn("stream offer failed:", e?.message || e); 11177 closeStreamPeer(peerClientId); 11178 } 11179 } 11180 11181 async function tryGetMicrophoneTrack() { 11182 if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") return null; 11183 try { 11184 const micStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); 11185 const track = micStream.getAudioTracks()[0] || null; 11186 if (!track) { 11187 try { 11188 for (const t of micStream.getTracks()) t.stop(); 11189 } catch {} 11190 return null; 11191 } 11192 track.addEventListener( 11193 "ended", 11194 () => { 11195 try { 11196 for (const t of micStream.getTracks()) { 11197 if (t.id !== track.id) t.stop(); 11198 } 11199 } catch {} 11200 }, 11201 { once: true } 11202 ); 11203 return track; 11204 } catch { 11205 return null; 11206 } 11207 } 11208 11209 async function ensureStreamAudioForKind(media, kind, opts = null) { 11210 if (!media) return media; 11211 const options = opts && typeof opts === "object" ? opts : {}; 11212 const includeMicForScreen = options.includeMicForScreen !== false; 11213 const audioTracks = media.getAudioTracks(); 11214 const hasAudio = audioTracks.length > 0; 11215 if (kind === "audio" || kind === "webcam") { 11216 if (hasAudio) return media; 11217 const micTrack = await tryGetMicrophoneTrack(); 11218 if (micTrack) media.addTrack(micTrack); 11219 return media; 11220 } 11221 if (kind === "screen") { 11222 if (!includeMicForScreen) return media; 11223 const micTrack = await tryGetMicrophoneTrack(); 11224 if (micTrack) media.addTrack(micTrack); 11225 } 11226 return media; 11227 } 11228 11229 async function enableStreamVoice() { 11230 if (streamCurrentRole !== "viewer" || !streamCurrentPostId) return false; 11231 if (streamVoiceJoined && streamVoiceMedia) { 11232 updateStreamLocalMuteState(); 11233 return true; 11234 } 11235 if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") { 11236 toast("Voice", "Microphone access is not available in this browser."); 11237 return false; 11238 } 11239 try { 11240 const media = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); 11241 if (!media.getAudioTracks().length) { 11242 stopStreamTracks(media); 11243 toast("Voice", "No microphone track available."); 11244 return false; 11245 } 11246 streamVoiceMedia = media; 11247 streamVoiceJoined = true; 11248 updateStreamLocalMuteState(); 11249 for (const peerId of streamPeerByClientId.keys()) { 11250 addVoiceTracksToPeer(peerId); 11251 renegotiateStreamPeer(peerId); 11252 } 11253 renderChatPanel(false); 11254 return true; 11255 } catch (e) { 11256 toast("Voice", String(e?.message || "Unable to access microphone.")); 11257 return false; 11258 } 11259 } 11260 11261 function disableStreamVoice() { 11262 if (!streamVoiceJoined && !streamVoiceMedia) return; 11263 streamVoiceJoined = false; 11264 stopStreamTracks(streamVoiceMedia); 11265 streamVoiceMedia = null; 11266 for (const peerId of streamPeerByClientId.keys()) { 11267 renegotiateStreamPeer(peerId); 11268 } 11269 renderChatPanel(false); 11270 } 11271 11272 async function startStreamHost(post) { 11273 if (!post || !isStreamPost(post)) return; 11274 if (!streamEnabled) { 11275 toast("Stream", "Streaming is disabled on this instance."); 11276 return; 11277 } 11278 if (!loggedInUser) { 11279 toast("Stream", "Sign in to start a stream."); 11280 return; 11281 } 11282 if (!streamCanHostPost(post)) { 11283 toast("Stream", "Only the hive owner or a moderator can host this stream."); 11284 return; 11285 } 11286 const kind = normalizeStreamKind(post.streamKind || "webcam"); 11287 try { 11288 if (!navigator.mediaDevices) throw new Error("Media devices are unavailable in this browser."); 11289 let media = null; 11290 let includeMicForScreen = true; 11291 if (kind === "screen") { 11292 media = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }); 11293 try { 11294 const remembered = readBoolPref("bzl_stream_screen_include_mic", true); 11295 const ask = window.confirm( 11296 remembered 11297 ? "Include your microphone with screen share? (Recommended)" 11298 : "Include your microphone with screen share?" 11299 ); 11300 includeMicForScreen = Boolean(ask); 11301 writeBoolPref("bzl_stream_screen_include_mic", includeMicForScreen); 11302 } catch { 11303 includeMicForScreen = true; 11304 } 11305 } else if (kind === "audio") { 11306 media = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); 11307 } else { 11308 media = await navigator.mediaDevices.getUserMedia({ audio: true, video: true }); 11309 } 11310 if (!media) throw new Error("No stream media available."); 11311 media = await ensureStreamAudioForKind(media, kind, { includeMicForScreen }); 11312 if (!media.getAudioTracks().length) { 11313 throw new Error("No audio track available. Share system audio and/or allow microphone access to go live with sound."); 11314 } 11315 11316 leaveActiveStream(false); 11317 streamCurrentPostId = String(post.id || ""); 11318 streamCurrentRole = "host"; 11319 streamRemoteKind = kind; 11320 streamCurrentHostClientId = String(clientId || ""); 11321 streamLocalMedia = media; 11322 streamVoiceJoined = true; 11323 streamVoiceMuted = false; 11324 streamVoiceDeafened = false; 11325 updateStreamLocalMuteState(); 11326 updateStreamOutputMuteState(); 11327 for (const track of media.getTracks()) { 11328 track.onended = () => { 11329 if (streamCurrentRole === "host" && streamCurrentPostId === String(post.id || "")) leaveActiveStream(true); 11330 }; 11331 } 11332 attachStreamPreview(media, kind, true); 11333 if (streamStagePlaceholderEl) streamStagePlaceholderEl.classList.add("hidden"); 11334 if (ws?.readyState === WebSocket.OPEN) { 11335 ws.send(JSON.stringify({ type: "streamHostStart", postId: post.id, streamKind: kind })); 11336 } 11337 renderChatPanel(false); 11338 } catch (e) { 11339 toast("Stream", String(e?.message || "Unable to start stream.")); 11340 } 11341 } 11342 11343 function joinStream(post) { 11344 if (!post || !isStreamPost(post)) return; 11345 if (!streamEnabled) { 11346 toast("Stream", "Streaming is disabled on this instance."); 11347 return; 11348 } 11349 if (typeof RTCPeerConnection !== "function") { 11350 toast("Stream", "WebRTC is not available in this browser."); 11351 return; 11352 } 11353 leaveActiveStream(false); 11354 streamCurrentPostId = String(post.id || ""); 11355 streamCurrentRole = "viewer"; 11356 streamRemoteKind = normalizeStreamKind(post.streamKind || "webcam"); 11357 streamCurrentHostClientId = ""; 11358 streamRemoteHostClientId = ""; 11359 streamVoiceJoined = false; 11360 streamVoiceMuted = false; 11361 streamVoiceDeafened = false; 11362 if (streamStagePlaceholderEl) { 11363 streamStagePlaceholderEl.classList.remove("hidden"); 11364 streamStagePlaceholderEl.textContent = "Connecting to stream..."; 11365 } 11366 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "streamJoin", postId: post.id })); 11367 renderChatPanel(false); 11368 } 11369 11370 function renderStreamStage(post) { 11371 const streamPost = post && isStreamPost(post) ? post : null; 11372 if (!streamPost) { 11373 if (streamStageEl) streamStageEl.classList.add("hidden"); 11374 if (streamVoiceControlsEl) streamVoiceControlsEl.classList.add("hidden"); 11375 return; 11376 } 11377 if (streamStageEl) streamStageEl.classList.remove("hidden"); 11378 const postId = String(streamPost.id || ""); 11379 const live = Boolean(streamLiveByPostId.get(postId) ?? streamPost.streamLive); 11380 const kind = normalizeStreamKind(streamPost.streamKind || "webcam"); 11381 const canHost = streamCanHostPost(streamPost); 11382 const isHosting = streamCurrentRole === "host" && streamCurrentPostId === postId; 11383 const isViewing = streamCurrentRole === "viewer" && streamCurrentPostId === postId; 11384 if (streamStageTitleEl) { 11385 streamStageTitleEl.textContent = `${streamKindLabel(kind)} stream`; 11386 } 11387 if (streamStageStatusEl) { 11388 if (isHosting) streamStageStatusEl.textContent = "You are live."; 11389 else if (isViewing) streamStageStatusEl.textContent = streamRemoteMedia ? "Watching live stream. Voice room connected." : "Connecting..."; 11390 else if (live) streamStageStatusEl.textContent = "Live now. Join to watch."; 11391 else if (canHost) streamStageStatusEl.textContent = "Offline. Start a stream for this hive."; 11392 else streamStageStatusEl.textContent = "Stream is offline."; 11393 } 11394 if (streamStagePrimaryBtn) { 11395 let label = "Join stream"; 11396 let disabled = false; 11397 if (isHosting) label = "Stop stream"; 11398 else if (isViewing) label = "Leave stream"; 11399 else if (!live && canHost) label = `Go live (${streamKindLabel(kind)})`; 11400 else if (!live && !canHost) { 11401 label = "Offline"; 11402 disabled = true; 11403 } 11404 streamStagePrimaryBtn.textContent = label; 11405 streamStagePrimaryBtn.disabled = disabled; 11406 } 11407 if (streamStagePlaceholderEl && !isHosting && !isViewing) { 11408 streamStagePlaceholderEl.classList.remove("hidden"); 11409 streamStagePlaceholderEl.textContent = live 11410 ? "Join this stream to watch live with chat." 11411 : canHost 11412 ? "Tap Go live to start screen share, webcam, or audio stream." 11413 : "Waiting for the stream owner to go live."; 11414 } 11415 renderStreamVoiceControls(streamPost, isHosting, isViewing); 11416 } 11417 11418 function canWalkieTalkNow() { 11419 if (!loggedInUser || !ws || ws.readyState !== WebSocket.OPEN) return false; 11420 if (!activeChatPostId) return false; 11421 const post = posts.get(activeChatPostId); 11422 if (!post || post.deleted) return false; 11423 return String(post.mode || post.chatMode || "").toLowerCase() === "walkie"; 11424 } 11425 11426 async function startWalkieRecording() { 11427 if (walkieRecording) return; 11428 if (!canWalkieTalkNow()) return; 11429 try { 11430 if (walkieStatusEl) walkieStatusEl.textContent = "Requesting microphone..."; 11431 const { ctx, mix, dest } = await ensureWalkieGraph(); 11432 if (ctx.state === "suspended") await ctx.resume(); 11433 11434 walkieChunks = []; 11435 const stream = dest.stream; 11436 const preferred = [ 11437 "audio/webm;codecs=opus", 11438 "audio/ogg;codecs=opus", 11439 "audio/webm", 11440 "audio/ogg", 11441 ]; 11442 let mimeType = ""; 11443 for (const t of preferred) { 11444 if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(t)) { 11445 mimeType = t; 11446 break; 11447 } 11448 } 11449 const rec = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); 11450 walkieRecorder = rec; 11451 walkieStartAt = Date.now(); 11452 walkieRecording = true; 11453 if (walkieBarEl) walkieBarEl.classList.add("isRecording"); 11454 if (walkieStatusEl) walkieStatusEl.textContent = "Recording... release to send."; 11455 11456 const dispatch = await ensureWalkieDispatchBuffer(); 11457 if (dispatch) { 11458 const src = new AudioBufferSourceNode(ctx, { buffer: dispatch }); 11459 const g = new GainNode(ctx, { gain: 0.75 }); 11460 src.connect(g); 11461 g.connect(mix); 11462 src.start(); 11463 // Local feedback so user hears the click (quiet). 11464 const mon = new GainNode(ctx, { gain: 0.10 }); 11465 g.connect(mon); 11466 mon.connect(ctx.destination); 11467 } 11468 11469 rec.addEventListener("dataavailable", (e) => { 11470 if (e.data && e.data.size > 0) walkieChunks.push(e.data); 11471 }); 11472 rec.addEventListener("stop", async () => { 11473 const tookMs = Date.now() - walkieStartAt; 11474 walkieRecording = false; 11475 if (walkieBarEl) walkieBarEl.classList.remove("isRecording"); 11476 if (walkieStatusEl) walkieStatusEl.textContent = "Processing..."; 11477 11478 // Give some browsers a tick to deliver the final dataavailable. 11479 await new Promise((r) => window.setTimeout(r, 0)); 11480 const blob = new Blob(walkieChunks, { type: rec.mimeType || "audio/webm" }); 11481 walkieChunks = []; 11482 if (!blob || blob.size < 800 || tookMs < 160) { 11483 if (walkieStatusEl) walkieStatusEl.textContent = ""; 11484 toast("Walkie Talkie", "No audio captured. Check mic permissions/input and try again."); 11485 return; 11486 } 11487 11488 const ext = (rec.mimeType || "").includes("ogg") ? "ogg" : "webm"; 11489 const file = new File([blob], `walkie-${Date.now()}.${ext}`, { type: rec.mimeType || blob.type || "audio/webm" }); 11490 if (walkieStatusEl) walkieStatusEl.textContent = "Uploading..."; 11491 const url = await uploadMediaFile(file, "audio"); 11492 if (!url) { 11493 if (walkieStatusEl) walkieStatusEl.textContent = ""; 11494 return; 11495 } 11496 const post = posts.get(activeChatPostId); 11497 if (!post || post.deleted) { 11498 if (walkieStatusEl) walkieStatusEl.textContent = ""; 11499 return; 11500 } 11501 ws.send(JSON.stringify({ type: "chatMessage", postId: activeChatPostId, text: "", html: `<audio controls preload=\"none\" src=\"${escapeHtml(url)}\"></audio>` })); 11502 if (walkieStatusEl) walkieStatusEl.textContent = "Sent."; 11503 window.setTimeout(() => { 11504 if (walkieStatusEl && walkieStatusEl.textContent === "Sent.") walkieStatusEl.textContent = ""; 11505 }, 900); 11506 playSfx("ping", { volume: 0.22 }); 11507 }); 11508 11509 // Timeslice helps avoid empty blobs in some browsers. 11510 rec.start(250); 11511 } catch (e) { 11512 walkieRecording = false; 11513 if (walkieBarEl) walkieBarEl.classList.remove("isRecording"); 11514 const name = String(e?.name || ""); 11515 const msg = String(e?.message || ""); 11516 const pretty = 11517 name === "NotAllowedError" 11518 ? "Microphone permission denied. Allow mic access in your browser settings." 11519 : name === "NotFoundError" 11520 ? "No microphone device found." 11521 : name === "NotReadableError" 11522 ? "Microphone is in use by another app." 11523 : msg || "Microphone recording failed."; 11524 if (walkieStatusEl) walkieStatusEl.textContent = ""; 11525 toast("Walkie Talkie", pretty); 11526 } 11527 } 11528 11529 async function stopWalkieRecording() { 11530 if (!walkieRecorder || !walkieRecording) return; 11531 try { 11532 const { ctx, mix } = await ensureWalkieGraph(); 11533 const dispatch = await ensureWalkieDispatchBuffer(); 11534 if (dispatch) { 11535 const src = new AudioBufferSourceNode(ctx, { buffer: dispatch }); 11536 const g = new GainNode(ctx, { gain: 0.55 }); 11537 src.connect(g); 11538 g.connect(mix); 11539 src.start(); 11540 const mon = new GainNode(ctx, { gain: 0.08 }); 11541 g.connect(mon); 11542 mon.connect(ctx.destination); 11543 window.setTimeout(() => { 11544 try { 11545 if (walkieRecorder && walkieRecorder.state !== "inactive") walkieRecorder.stop(); 11546 } catch { 11547 // ignore 11548 } 11549 walkieRecorder = null; 11550 }, 160); 11551 return; 11552 } 11553 } catch { 11554 // ignore 11555 } 11556 try { 11557 if (walkieRecorder && walkieRecorder.state !== "inactive") walkieRecorder.stop(); 11558 } catch { 11559 // ignore 11560 } 11561 walkieRecorder = null; 11562 } 11563 11564 function insertAudioTag(target, srcUrl) { 11565 if (!srcUrl) return; 11566 target.focus(); 11567 const safe = escapeHtml(srcUrl); 11568 document.execCommand("insertHTML", false, `<audio controls preload="none" src="${safe}"></audio>`); 11569 } 11570 11571 function installDropUpload(targetEl, { allowImages = true, allowAudio = true } = {}) { 11572 if (!targetEl) return; 11573 const setActive = (on) => { 11574 try { 11575 targetEl.classList.toggle("isDropActive", Boolean(on)); 11576 } catch { 11577 // ignore 11578 } 11579 }; 11580 targetEl.addEventListener("dragover", (e) => { 11581 if (!e.dataTransfer) return; 11582 if (!e.dataTransfer.types || !Array.from(e.dataTransfer.types).includes("Files")) return; 11583 e.preventDefault(); 11584 setActive(true); 11585 }); 11586 targetEl.addEventListener("dragleave", () => setActive(false)); 11587 targetEl.addEventListener("drop", async (e) => { 11588 setActive(false); 11589 const files = e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : []; 11590 if (!files.length) return; 11591 e.preventDefault(); 11592 e.stopPropagation(); 11593 11594 for (const file of files.slice(0, 4)) { 11595 const type = String(file.type || "").toLowerCase(); 11596 const name = String(file.name || "").toLowerCase(); 11597 const isImg = type.startsWith("image/") || /\.(gif|png|jpe?g|webp)$/.test(name); 11598 const isAud = type.startsWith("audio/") || /\.(mp3|wav|ogg|m4a|aac|webm)$/.test(name); 11599 if (isImg && allowImages) { 11600 const url = await uploadMediaFile(file, "image"); 11601 if (!url) continue; 11602 targetEl.focus(); 11603 document.execCommand("insertImage", false, url); 11604 } else if (isAud && allowAudio) { 11605 const url = await uploadMediaFile(file, "audio"); 11606 if (!url) continue; 11607 insertAudioTag(targetEl, url); 11608 } 11609 } 11610 }); 11611 } 11612 11613 document.querySelector(".editorShell .toolbar")?.addEventListener("click", (e) => { 11614 const btn = e.target.closest("button"); 11615 if (!btn) return; 11616 const cmd = btn.getAttribute("data-cmd"); 11617 if (cmd) { 11618 runCmd(editor, cmd); 11619 return; 11620 } 11621 if (btn.getAttribute("data-link")) { 11622 runLink(editor); 11623 return; 11624 } 11625 if (btn.getAttribute("data-postimg")) { 11626 postImageInput?.click(); 11627 return; 11628 } 11629 if (btn.getAttribute("data-postaudio")) { 11630 postAudioInput?.click(); 11631 return; 11632 } 11633 if (btn.getAttribute("data-postemoji")) runEmoji(editor); 11634 }); 11635 11636 document.addEventListener("click", (e) => { 11637 const btn = e.target.closest?.("button"); 11638 if (!btn) return; 11639 const toolbar = btn.closest?.(".chatComposer .toolbar"); 11640 if (!toolbar) return; 11641 const composer = toolbar.closest?.(".chatComposer"); 11642 if (!composer) return; 11643 const targetEditor = composer.querySelector?.(".chatEditor") || chatEditor; 11644 if (!(targetEditor instanceof HTMLElement)) return; 11645 chatUploadTargetEditor = targetEditor; 11646 11647 const cmd = btn.getAttribute("data-chatcmd"); 11648 if (cmd) { 11649 runCmd(targetEditor, cmd); 11650 return; 11651 } 11652 if (btn.getAttribute("data-chatlink")) { 11653 runLink(targetEditor); 11654 return; 11655 } 11656 if (btn.getAttribute("data-chatimg")) { 11657 chatImageInput?.click(); 11658 return; 11659 } 11660 if (btn.getAttribute("data-chataudio")) { 11661 chatAudioInput?.click(); 11662 return; 11663 } 11664 if (btn.getAttribute("data-chatemoji")) runEmoji(targetEditor); 11665 }); 11666 11667 profileBioToolbar?.addEventListener("click", (e) => { 11668 const btn = e.target.closest("button"); 11669 if (!btn) return; 11670 const cmd = btn.getAttribute("data-profilecmd"); 11671 if (cmd) { 11672 runCmd(profileBioEditor, cmd); 11673 return; 11674 } 11675 if (btn.getAttribute("data-profilelink")) { 11676 runLink(profileBioEditor); 11677 return; 11678 } 11679 if (btn.getAttribute("data-profileimg")) { 11680 profileBioImageFileInput?.click(); 11681 return; 11682 } 11683 if (btn.getAttribute("data-profileaudio")) { 11684 profileBioAudioFileInput?.click(); 11685 return; 11686 } 11687 if (btn.getAttribute("data-profileemoji")) runEmoji(profileBioEditor); 11688 }); 11689 11690 editModalToolbar?.addEventListener("click", (e) => { 11691 const btn = e.target.closest("button"); 11692 if (!btn) return; 11693 const cmd = btn.getAttribute("data-editcmd"); 11694 if (cmd) { 11695 runCmd(editModalEditor, cmd); 11696 return; 11697 } 11698 if (btn.getAttribute("data-editlink")) { 11699 runLink(editModalEditor); 11700 return; 11701 } 11702 if (btn.getAttribute("data-editimg")) { 11703 editModalImageInput?.click(); 11704 return; 11705 } 11706 if (btn.getAttribute("data-editaudio")) { 11707 editModalAudioInput?.click(); 11708 return; 11709 } 11710 if (btn.getAttribute("data-editemoji")) runEmoji(editModalEditor); 11711 }); 11712 11713 editModalImageInput?.addEventListener("change", async () => { 11714 const file = editModalImageInput.files && editModalImageInput.files[0] ? editModalImageInput.files[0] : null; 11715 editModalImageInput.value = ""; 11716 if (!file) return; 11717 const url = await uploadMediaFile(file, "image"); 11718 if (!url) return; 11719 editModalEditor?.focus(); 11720 document.execCommand("insertImage", false, url); 11721 }); 11722 11723 editModalAudioInput?.addEventListener("change", async () => { 11724 const file = editModalAudioInput.files && editModalAudioInput.files[0] ? editModalAudioInput.files[0] : null; 11725 editModalAudioInput.value = ""; 11726 if (!file) return; 11727 const url = await uploadMediaFile(file, "audio"); 11728 if (!url) return; 11729 insertAudioTag(editModalEditor, url); 11730 }); 11731 11732 editModal?.addEventListener("click", (e) => { 11733 if (e.target?.getAttribute?.("data-modalclose")) setEditModalOpen(false); 11734 }); 11735 11736 editModalCloseBtn?.addEventListener("click", () => setEditModalOpen(false)); 11737 editModalCancelBtn?.addEventListener("click", () => setEditModalOpen(false)); 11738 11739 editModalSaveBtn?.addEventListener("click", () => { 11740 if (!editContext) return; 11741 if (!editModalEditor) return; 11742 const { html, text, hasImg, hasAudio } = collectEditorPayload(editModalEditor); 11743 if (!text && !hasImg && !hasAudio) { 11744 if (editModalStatus) editModalStatus.textContent = "Please add text, an image, or audio."; 11745 editModalEditor.focus(); 11746 return; 11747 } 11748 if (editContext.kind === "post") { 11749 const title = String(editModalPostTitleInput?.value || "") 11750 .replace(/\s+/g, " ") 11751 .trim() 11752 .slice(0, 96); 11753 if (!title) { 11754 if (editModalStatus) editModalStatus.textContent = "Title is required."; 11755 editModalPostTitleInput?.focus(); 11756 return; 11757 } 11758 const post = posts.get(editContext.postId); 11759 const wasProtected = Boolean(post?.protected); 11760 const wantsProtected = Boolean(editModalProtectedToggle?.checked); 11761 const password = String(editModalPasswordInput?.value || ""); 11762 if (wantsProtected && !wasProtected && password.trim().length < 4) { 11763 if (editModalStatus) editModalStatus.textContent = "Set a password (min 4 chars) to protect this post."; 11764 editModalPasswordInput?.focus(); 11765 return; 11766 } 11767 const keywords = parseKeywordsInput(editModalKeywordsInput?.value || ""); 11768 const collectionId = String(editModalCollectionSelect?.value || post?.collectionId || "general"); 11769 const mode = normalizePostMode(editModalModeSelect?.value || post?.mode || "text"); 11770 const streamKind = normalizeStreamKind(editModalStreamKindSelect?.value || post?.streamKind || "webcam"); 11771 ws.send( 11772 JSON.stringify({ 11773 type: "editPost", 11774 postId: editContext.postId, 11775 title, 11776 content: text, 11777 contentHtml: html, 11778 keywords, 11779 collectionId, 11780 protected: wantsProtected, 11781 password: password.trim(), 11782 mode, 11783 streamKind 11784 }) 11785 ); 11786 setEditModalOpen(false); 11787 return; 11788 } 11789 if (editContext.kind === "chat") { 11790 ws.send(JSON.stringify({ type: "editChatMessage", postId: editContext.postId, messageId: editContext.messageId, text, html })); 11791 setEditModalOpen(false); 11792 } 11793 }); 11794 11795 function sendLogin(username, password) { 11796 ws.send(JSON.stringify({ type: "login", username: String(username || "").trim(), password: String(password || "") })); 11797 } 11798 11799 function sendRegister(username, password, code) { 11800 ws.send( 11801 JSON.stringify({ 11802 type: "register", 11803 username: String(username || "").trim(), 11804 password: String(password || ""), 11805 code: String(code || "").trim(), 11806 }) 11807 ); 11808 } 11809 11810 authForm.addEventListener("submit", (e) => { 11811 e.preventDefault(); 11812 sendLogin(authUser.value, authPass.value); 11813 }); 11814 11815 registerBtn.addEventListener("click", () => sendRegister(authUser.value, authPass.value, authCode.value)); 11816 11817 authGateFormEl?.addEventListener("submit", (e) => { 11818 e.preventDefault(); 11819 sendLogin(authGateUserEl?.value || "", authGatePassEl?.value || ""); 11820 }); 11821 11822 authGateRegisterEl?.addEventListener("click", () => 11823 sendRegister(authGateUserEl?.value || "", authGatePassEl?.value || "", authGateCodeEl?.value || "") 11824 ); 11825 11826 authGateEl?.addEventListener("click", (e) => { 11827 const tabBtn = e.target?.closest?.("button[data-authgate-tab]"); 11828 if (tabBtn) { 11829 const tab = String(tabBtn.getAttribute("data-authgate-tab") || "about").trim(); 11830 if (["about", "rules", "roles"].includes(tab)) { 11831 authGateOnboardingTab = tab; 11832 const buttons = Array.from(authGateEl.querySelectorAll("button[data-authgate-tab]")); 11833 for (const btn of buttons) { 11834 const on = String(btn.getAttribute("data-authgate-tab") || "") === authGateOnboardingTab; 11835 btn.classList.toggle("primary", on); 11836 btn.classList.toggle("ghost", !on); 11837 } 11838 renderAuthGateOnboarding(); 11839 } 11840 } 11841 }); 11842 11843 authGateAcceptBtn?.addEventListener("click", () => { 11844 if (!loggedInUser) { 11845 toast("Sign in required", "Sign in to accept server rules."); 11846 return; 11847 } 11848 ws.send(JSON.stringify({ type: "onboardingAcceptRules" })); 11849 }); 11850 11851 authGateRefreshBtn?.addEventListener("click", () => { 11852 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" })); 11853 }); 11854 11855 bzlSplashStartBtn?.addEventListener("click", () => { 11856 if (!splashNeedsGesture) return; 11857 tryPlaySplashAudio({ fromGesture: true }); 11858 }); 11859 11860 tourBtn?.addEventListener("click", () => startGuidedTour({ auto: false })); 11861 11862 logoutBtn.addEventListener("click", () => ws.send(JSON.stringify({ type: "logout" }))); 11863 11864 profileImageInput.addEventListener("change", async () => { 11865 profileStatus.textContent = ""; 11866 const file = profileImageInput.files && profileImageInput.files[0] ? profileImageInput.files[0] : null; 11867 if (!file) return; 11868 try { 11869 pendingProfileImage = await resizeImageToSquareDataUrl(file, 96); 11870 if (pendingProfileImage) { 11871 profilePreview.src = pendingProfileImage; 11872 profilePreview.classList.add("hasImg"); 11873 } 11874 } catch { 11875 profileStatus.textContent = "Failed to load image."; 11876 } 11877 }); 11878 11879 removeProfileImageBtn.addEventListener("click", () => { 11880 pendingProfileImage = ""; 11881 profilePreview.removeAttribute("src"); 11882 profilePreview.classList.remove("hasImg"); 11883 }); 11884 11885 saveProfileBtn.addEventListener("click", () => { 11886 profileStatus.textContent = ""; 11887 const color = nameColorInput.value; 11888 ws.send(JSON.stringify({ type: "updateProfile", image: pendingProfileImage, color })); 11889 }); 11890 11891 profileBackBtn?.addEventListener("click", () => setCenterView("hives")); 11892 11893 profileEditToggleBtn?.addEventListener("click", () => { 11894 isEditingProfile = !isEditingProfile; 11895 if (profileEditToggleBtn) profileEditToggleBtn.textContent = isEditingProfile ? "Close editor" : "Edit profile"; 11896 renderCenterPanels(); 11897 }); 11898 11899 profileCancelBtn?.addEventListener("click", () => { 11900 isEditingProfile = false; 11901 if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile"; 11902 renderCenterPanels(); 11903 }); 11904 11905 profileAddLinkBtn?.addEventListener("click", () => { 11906 const links = profileLinksFromEditor(); 11907 links.push({ label: "Link", url: "https://" }); 11908 renderProfileLinksEditor(links); 11909 }); 11910 11911 profileLinksEditor?.addEventListener("click", (e) => { 11912 const btn = e.target.closest("[data-linkremove]"); 11913 if (!btn) return; 11914 const idx = Number(btn.getAttribute("data-linkremove") || -1); 11915 const links = profileLinksFromEditor(); 11916 if (idx < 0 || idx >= links.length) return; 11917 links.splice(idx, 1); 11918 renderProfileLinksEditor(links); 11919 }); 11920 11921 profileThemeSongUploadBtn?.addEventListener("click", () => profileThemeSongFileInput?.click()); 11922 11923 profileThemeSongClearBtn?.addEventListener("click", () => syncProfileSongPreview("")); 11924 11925 profileThemeSongFileInput?.addEventListener("change", async () => { 11926 const file = profileThemeSongFileInput.files && profileThemeSongFileInput.files[0] ? profileThemeSongFileInput.files[0] : null; 11927 profileThemeSongFileInput.value = ""; 11928 if (!file) return; 11929 const url = await uploadMediaFile(file, "audio"); 11930 if (!url) return; 11931 syncProfileSongPreview(url); 11932 }); 11933 11934 profileBioImageFileInput?.addEventListener("change", async () => { 11935 const file = profileBioImageFileInput.files && profileBioImageFileInput.files[0] ? profileBioImageFileInput.files[0] : null; 11936 profileBioImageFileInput.value = ""; 11937 if (!file) return; 11938 const url = await uploadMediaFile(file, "image"); 11939 if (!url) return; 11940 profileBioEditor?.focus(); 11941 document.execCommand("insertImage", false, url); 11942 }); 11943 11944 profileBioAudioFileInput?.addEventListener("change", async () => { 11945 const file = profileBioAudioFileInput.files && profileBioAudioFileInput.files[0] ? profileBioAudioFileInput.files[0] : null; 11946 profileBioAudioFileInput.value = ""; 11947 if (!file) return; 11948 const url = await uploadMediaFile(file, "audio"); 11949 if (!url) return; 11950 insertAudioTag(profileBioEditor, url); 11951 }); 11952 11953 profileSaveBtn?.addEventListener("click", () => { 11954 if (!loggedInUser || !activeProfile || activeProfile.username !== loggedInUser) return; 11955 const pronouns = String(profilePronounsInput?.value || "") 11956 .replace(/\s+/g, " ") 11957 .trim() 11958 .slice(0, 40); 11959 const bioHtml = String(profileBioEditor?.innerHTML || ""); 11960 const themeSongUrl = String(profileThemeSongUrlInput?.value || "").trim(); 11961 const links = profileLinksFromEditor(); 11962 ws.send(JSON.stringify({ type: "updateProfile", pronouns, bioHtml, themeSongUrl, links })); 11963 }); 11964 11965 newPostForm.addEventListener("submit", (e) => { 11966 e.preventDefault(); 11967 if (onboardingNeedsAcceptanceNow()) { 11968 toast("Onboarding", "Accept server rules first."); 11969 return; 11970 } 11971 const title = String(postTitleInput?.value || "") 11972 .replace(/\s+/g, " ") 11973 .trim() 11974 .slice(0, 96); 11975 if (!title) { 11976 toast("Post title", "Please add a short title."); 11977 postTitleInput?.focus(); 11978 return; 11979 } 11980 const html = editor.innerHTML.trim(); 11981 const text = editor.innerText.trim(); 11982 const hasImg = Boolean(editor.querySelector("img")); 11983 const hasAudio = Boolean(editor.querySelector("audio")); 11984 if (!text && !hasImg && !hasAudio) { 11985 toast("Post body", "Please add body text, image, or audio."); 11986 editor.focus(); 11987 return; 11988 } 11989 11990 const keywords = parseKeywords(keywordsEl.value); 11991 const collectionId = String(postCollectionEl?.value || "").trim(); 11992 if (!collectionId) { 11993 toast("Collection", "Please choose a collection."); 11994 return; 11995 } 11996 const ttlMinutes = Number(ttlMinutesEl.value || 60); 11997 const canMakePermanent = 11998 isStaffRole(loggedInRole) || Boolean(normalizeInstanceBranding(instanceBranding).allowMemberPermanentPosts); 11999 const minMinutes = canMakePermanent ? 0 : 1; 12000 const ttl = Math.max(minMinutes, Math.min(2880, Math.floor(ttlMinutes))) * 60_000; 12001 12002 const isProtected = Boolean(isProtectedEl?.checked); 12003 const password = typeof postPasswordEl?.value === "string" ? postPasswordEl.value : ""; 12004 if (isProtected && password.trim().length < 4) { 12005 toast("Protected post", "Password must be at least 4 characters."); 12006 return; 12007 } 12008 const mode = normalizePostMode(postModeEl?.value || "text"); 12009 const streamKind = normalizeStreamKind(streamKindEl?.value || "webcam"); 12010 ws.send( 12011 JSON.stringify({ 12012 type: "newPost", 12013 title, 12014 collectionId, 12015 contentHtml: html, 12016 content: text, 12017 keywords, 12018 ttl, 12019 protected: isProtected, 12020 password, 12021 mode, 12022 streamKind, 12023 }) 12024 ); 12025 if (postTitleInput) postTitleInput.value = ""; 12026 editor.innerHTML = ""; 12027 if (postPasswordEl) postPasswordEl.value = ""; 12028 if (isProtectedEl) isProtectedEl.checked = false; 12029 if (postModeEl) postModeEl.value = "text"; 12030 if (streamKindEl) streamKindEl.value = "webcam"; 12031 syncComposerModeUi(); 12032 if (isMobileSwipeMode()) setComposerOpen(false); 12033 }); 12034 12035 toggleComposerBtn?.addEventListener("click", () => { 12036 if (isMobileScreenMode()) { 12037 setComposerOpen(true); 12038 const layout = loadMobileLayout(); 12039 layout.active = "composer"; 12040 saveMobileLayout(layout); 12041 setMobileScreen("composer"); 12042 renderMobileNav(); 12043 if (composerOpen) (postTitleInput || editor)?.focus(); 12044 return; 12045 } 12046 setComposerOpen(!composerOpen); 12047 if (composerOpen) (postTitleInput || editor)?.focus(); 12048 }); 12049 toggleComposerInlineBtn?.addEventListener("click", () => setComposerOpen(false)); 12050 12051 function submitChat() { 12052 if (onboardingNeedsAcceptanceNow()) { 12053 toast("Onboarding", "Accept server rules first."); 12054 return; 12055 } 12056 const html = chatEditor.innerHTML.trim(); 12057 const text = chatEditor.innerText.trim(); 12058 const hasImg = Boolean(chatEditor.querySelector("img")); 12059 const hasAudio = Boolean(chatEditor.querySelector("audio")); 12060 if (activeDmThreadId) { 12061 if (!text && !hasImg && !hasAudio) return; 12062 if (!loggedInUser) { 12063 toast("Sign in required", "Sign in to send DMs."); 12064 return; 12065 } 12066 const thread = dmThreadsById.get(activeDmThreadId) || null; 12067 if (!thread) { 12068 toast("DMs", "This DM thread is unavailable."); 12069 return; 12070 } 12071 if (String(thread.status || "") !== "active") { 12072 toast("DMs", "You can only send messages after the DM is accepted."); 12073 return; 12074 } 12075 ws.send(JSON.stringify({ type: "dmSend", threadId: activeDmThreadId, text, html })); 12076 chatEditor.innerHTML = ""; 12077 return; 12078 } 12079 12080 if (isMapChatActive()) { 12081 if (!text && !hasImg && !hasAudio) return; 12082 if (hasImg || hasAudio) { 12083 toast("Maps chat", "Maps chat is text-only for now."); 12084 return; 12085 } 12086 if (!loggedInUser) { 12087 toast("Sign in required", "Sign in to chat in maps."); 12088 return; 12089 } 12090 try { 12091 ws.send(JSON.stringify({ type: "plugin:maps:chatSend", mapId: activeMapsRoomId, scope: normalizeMapChatScope(activeMapsChatScope), text })); 12092 // Optimistic add so it feels instant (server will also echo back). 12093 pushMapChatMessage(activeMapsRoomId, activeMapsChatScope, { 12094 id: `local_${Date.now()}_${Math.random().toString(16).slice(2)}`, 12095 fromUser: loggedInUser, 12096 text, 12097 createdAt: Date.now(), 12098 }); 12099 } catch { 12100 // ignore 12101 } 12102 chatEditor.innerHTML = ""; 12103 setReplyToMessage(null); 12104 renderChatPanel(true); 12105 return; 12106 } 12107 12108 if (!activeChatPostId || (!text && !hasImg && !hasAudio)) return; 12109 const post = posts.get(activeChatPostId); 12110 if (post && String(post.mode || post.chatMode || "").toLowerCase() === "walkie") { 12111 toast("Walkie Talkie", "This hive is walkie-only. Hold ~ to talk."); 12112 return; 12113 } 12114 if (post?.readOnly && !isStaffRole(loggedInRole)) { 12115 toast("Read-only", "This hive is read-only."); 12116 return; 12117 } 12118 if (post?.deleted) { 12119 toast("Unavailable", "This post was deleted."); 12120 return; 12121 } 12122 const replyToId = replyToMessage?.id ? String(replyToMessage.id) : ""; 12123 const wantsMod = Boolean(canModerate && chatModToggleEl instanceof HTMLInputElement && chatModToggleEl.checked); 12124 ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); 12125 ws.send(JSON.stringify({ type: "chatMessage", postId: activeChatPostId, text, html, replyToId, asMod: wantsMod })); 12126 chatEditor.innerHTML = ""; 12127 setReplyToMessage(null); 12128 } 12129 12130 filterKeywordsEl.addEventListener("input", () => renderFeed()); 12131 filterAuthorEl?.addEventListener("input", () => renderFeed()); 12132 sortByEl?.addEventListener("change", () => { 12133 updateMobileSortCycleLabel(); 12134 renderFeed(); 12135 }); 12136 hiveTabsEl?.addEventListener("click", (e) => { 12137 const btn = e.target.closest("[data-hiveview]"); 12138 if (!btn) return; 12139 const next = btn.getAttribute("data-hiveview") || "all"; 12140 if (!loggedInUser && next !== "all") { 12141 toast("Sign in required", "Sign in to use Starred and Hidden views."); 12142 return; 12143 } 12144 activeHiveView = next; 12145 renderFeed(); 12146 }); 12147 clearFilterBtn.addEventListener("click", () => { 12148 filterKeywordsEl.value = ""; 12149 if (filterAuthorEl) filterAuthorEl.value = ""; 12150 if (sortByEl) sortByEl.value = "activity"; 12151 updateMobileSortCycleLabel(); 12152 activeHiveView = "all"; 12153 renderFeed(); 12154 }); 12155 12156 mobileHiveSearchBtn?.addEventListener("click", () => { 12157 const initial = String(filterAuthorEl?.value || "").trim() 12158 ? `@${String(filterAuthorEl?.value || "").trim()}` 12159 : String(filterKeywordsEl?.value || "").trim(); 12160 const raw = prompt("Search hives by @author or keywords:", initial); 12161 if (raw === null) return; 12162 const q = String(raw || "").trim(); 12163 if (!q) { 12164 if (filterAuthorEl) filterAuthorEl.value = ""; 12165 if (filterKeywordsEl) filterKeywordsEl.value = ""; 12166 renderFeed(); 12167 return; 12168 } 12169 const parts = q.split(/\s+/).filter(Boolean); 12170 const authorPart = parts.find((part) => part.startsWith("@")) || (q.startsWith("@") ? q : ""); 12171 const author = authorPart.replace(/^@+/, "").trim(); 12172 const keywordParts = authorPart ? parts.filter((part) => part !== authorPart) : parts; 12173 if (filterAuthorEl) filterAuthorEl.value = author || ""; 12174 if (filterKeywordsEl) filterKeywordsEl.value = keywordParts.join(", "); 12175 renderFeed(); 12176 }); 12177 12178 mobileSortCycleBtn?.addEventListener("click", () => { 12179 if (!sortByEl) return; 12180 const order = ["activity", "popular", "expiring"]; 12181 const current = String(sortByEl.value || "activity"); 12182 const at = Math.max(0, order.indexOf(current)); 12183 const next = order[(at + 1) % order.length]; 12184 sortByEl.value = next; 12185 updateMobileSortCycleLabel(); 12186 renderFeed(); 12187 }); 12188 12189 feedEl.addEventListener("click", (e) => { 12190 const profileLink = e.target.closest("[data-viewprofile]"); 12191 if (profileLink) { 12192 const username = profileLink.getAttribute("data-viewprofile") || ""; 12193 if (username) openUserProfile(username); 12194 return; 12195 } 12196 12197 const menuBtn = e.target.closest("button[data-postmenu]"); 12198 if (menuBtn) { 12199 const postId = menuBtn.getAttribute("data-postmenu") || ""; 12200 if (!postId) return; 12201 const wasOpen = openPostMenuId === postId; 12202 12203 for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden"); 12204 for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false"); 12205 12206 if (!wasOpen) { 12207 const panel = feedEl.querySelector(`[data-postmenu-panel="${cssEscape(postId)}"]`); 12208 if (panel) panel.classList.remove("hidden"); 12209 menuBtn.setAttribute("aria-expanded", "true"); 12210 openPostMenuId = postId; 12211 } else { 12212 openPostMenuId = ""; 12213 } 12214 return; 12215 } 12216 12217 const chatBtn = e.target.closest("button[data-chat]"); 12218 if (chatBtn) { 12219 if (openPostMenuId) { 12220 for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden"); 12221 for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false"); 12222 openPostMenuId = ""; 12223 } 12224 const postId = chatBtn.getAttribute("data-chat"); 12225 const post = postId ? posts.get(postId) : null; 12226 if (post?.locked) unlockPostFlow(postId, true); 12227 else openChat(postId, { sourceEl: chatBtn }); 12228 return; 12229 } 12230 12231 const boostBtn = e.target.closest("button[data-boostbtn]"); 12232 if (boostBtn) { 12233 const postId = boostBtn.getAttribute("data-boostbtn"); 12234 const card = boostBtn.closest(".post"); 12235 const sel = card ? card.querySelector("select[data-boostsel]") : null; 12236 const boostMs = sel ? Number(sel.value) : 3_600_000; 12237 ws.send(JSON.stringify({ type: "boostPost", postId, boostMs })); 12238 return; 12239 } 12240 12241 const reportPostBtn = e.target.closest("button[data-reportpost]"); 12242 if (reportPostBtn) { 12243 if (openPostMenuId) { 12244 for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden"); 12245 for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false"); 12246 openPostMenuId = ""; 12247 } 12248 const postId = reportPostBtn.getAttribute("data-reportpost") || ""; 12249 if (!postId) return; 12250 const post = posts.get(postId); 12251 if (post?.deleted) { 12252 toast("Unavailable", "This post was deleted."); 12253 return; 12254 } 12255 const reason = promptReason("post report"); 12256 if (!reason) return; 12257 ws.send(JSON.stringify({ type: "reportCreate", targetType: "post", targetId: postId, postId, reason })); 12258 return; 12259 } 12260 12261 const hideBtn = e.target.closest("button[data-hidepost]"); 12262 if (hideBtn) { 12263 if (openPostMenuId) { 12264 for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden"); 12265 for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false"); 12266 openPostMenuId = ""; 12267 } 12268 const postId = hideBtn.getAttribute("data-hidepost") || ""; 12269 if (!postId) return; 12270 const hidden = prefSet("hiddenPostIds").has(postId); 12271 ws.send(JSON.stringify({ type: hidden ? "unhidePost" : "hidePost", postId })); 12272 return; 12273 } 12274 12275 const react = e.target.closest("[data-react]"); 12276 if (react && react.getAttribute("data-kind") === "post") { 12277 if (openPostMenuId) { 12278 for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden"); 12279 for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false"); 12280 openPostMenuId = ""; 12281 } 12282 const postId = react.getAttribute("data-postid") || ""; 12283 const emoji = react.getAttribute("data-emoji") || ""; 12284 if (!postId || !emoji) return; 12285 const post = posts.get(postId); 12286 if (post?.deleted) { 12287 toast("Unavailable", "This post was deleted."); 12288 return; 12289 } 12290 markReactPulse("post", postId, emoji); 12291 toggleMyReact("post", postId, emoji); 12292 ws.send(JSON.stringify({ type: "react", targetType: "post", postId, emoji })); 12293 renderFeed(); 12294 return; 12295 } 12296 12297 const editPostBtn = e.target.closest("button[data-editpost]"); 12298 if (editPostBtn) { 12299 if (openPostMenuId) { 12300 for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden"); 12301 for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false"); 12302 openPostMenuId = ""; 12303 } 12304 const postId = editPostBtn.getAttribute("data-editpost") || ""; 12305 const post = postId ? posts.get(postId) : null; 12306 if (!post || post.deleted || post.locked) return; 12307 openEditModalForPost(post); 12308 return; 12309 } 12310 12311 const deletePostBtn = e.target.closest("button[data-deletepost]"); 12312 if (deletePostBtn) { 12313 if (openPostMenuId) { 12314 for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden"); 12315 for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false"); 12316 openPostMenuId = ""; 12317 } 12318 const postId = deletePostBtn.getAttribute("data-deletepost") || ""; 12319 if (!postId) return; 12320 const ok = confirm("Delete this post? It will show as deleted."); 12321 if (!ok) return; 12322 ws.send(JSON.stringify({ type: "deletePostSelf", postId })); 12323 } 12324 }); 12325 12326 onboardingGateHintEl?.addEventListener("click", (e) => { 12327 const btn = e.target?.closest?.("button[data-onboarding-open]"); 12328 if (!btn) return; 12329 openOnboardingView(); 12330 }); 12331 12332 window.addEventListener("keydown", (e) => { 12333 if (e.key !== "Escape") return; 12334 if (!openPostMenuId) return; 12335 for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden"); 12336 for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false"); 12337 openPostMenuId = ""; 12338 }); 12339 12340 window.addEventListener("keydown", (e) => { 12341 if (e.defaultPrevented) return; 12342 if (e.repeat) return; 12343 if (e.key === "?" && !isTextEntryFocused()) { 12344 e.preventDefault(); 12345 setShortcutHelpOpen(true); 12346 return; 12347 } 12348 if (e.altKey || e.ctrlKey || e.metaKey) return; 12349 if (isTextEntryFocused()) return; 12350 if (e.key === "ArrowUp") { 12351 if (restoreHoveredHotbarPanelToWorkspace() || cycleHoveredWorkspacePanelSize()) { 12352 e.preventDefault(); 12353 return; 12354 } 12355 } 12356 if (e.key === "ArrowLeft") { 12357 if (moveHoveredWorkspacePanelHorizontal(-1)) { 12358 e.preventDefault(); 12359 return; 12360 } 12361 } 12362 if (e.key === "ArrowRight") { 12363 if (moveHoveredWorkspacePanelHorizontal(1)) { 12364 e.preventDefault(); 12365 return; 12366 } 12367 } 12368 if (e.key === "ArrowDown") { 12369 if (dockHoveredWorkspacePanelToHotbar()) { 12370 e.preventDefault(); 12371 return; 12372 } 12373 } 12374 const ctx = activePanelContextForHotkeys(); 12375 const plus = e.key === "=" || e.code === "NumpadAdd"; 12376 const minus = e.key === "-" || e.code === "NumpadSubtract"; 12377 if (ctx === "hives" && (plus || minus)) { 12378 e.preventDefault(); 12379 cycleHiveViewBy(plus ? 1 : -1); 12380 return; 12381 } 12382 if (ctx === "chat" && (plus || minus)) { 12383 e.preventDefault(); 12384 cycleChatContextBy(plus ? 1 : -1); 12385 return; 12386 } 12387 if (e.key === "[") { 12388 e.preventDefault(); 12389 cycleLayoutPresetBy(-1); 12390 return; 12391 } 12392 if (e.key === "]") { 12393 e.preventDefault(); 12394 cycleLayoutPresetBy(1); 12395 return; 12396 } 12397 if ((e.key === "r" || e.key === "R") && rackLayoutEnabled) { 12398 if (isMapSurfaceActiveForHotkey()) return; 12399 e.preventDefault(); 12400 const collapsed = Boolean(appRoot?.classList.contains("rightCollapsed")); 12401 setRightCollapsed(!collapsed); 12402 } 12403 }); 12404 12405 window.addEventListener( 12406 "pointerdown", 12407 (e) => { 12408 updateHotkeyPanelContextFromTarget(e.target); 12409 }, 12410 true 12411 ); 12412 12413 window.addEventListener( 12414 "pointermove", 12415 (e) => { 12416 const target = e.target instanceof HTMLElement ? e.target : null; 12417 const panel = target?.closest?.(".rackPanel[data-panel-id]"); 12418 if (panel instanceof HTMLElement) { 12419 const panelId = String(panel.dataset.panelId || "").trim(); 12420 const rackId = String(panel.parentElement?.id || "").trim(); 12421 hoveredWorkspacePanelId = panelId && isWorkspaceRackId(rackId) ? panelId : ""; 12422 } else { 12423 hoveredWorkspacePanelId = ""; 12424 } 12425 const orb = target?.closest?.("[data-undock]"); 12426 hoveredHotbarPanelId = orb instanceof HTMLElement ? String(orb.getAttribute("data-undock") || "").trim() : ""; 12427 }, 12428 true 12429 ); 12430 window.addEventListener("blur", () => { 12431 hoveredWorkspacePanelId = ""; 12432 hoveredHotbarPanelId = ""; 12433 }); 12434 12435 window.addEventListener("click", (e) => { 12436 if (!openPostMenuId) return; 12437 const esc = cssEscape(openPostMenuId); 12438 const inside = e.target?.closest?.(`[data-postmenu-panel="${esc}"], button[data-postmenu="${esc}"]`); 12439 if (inside) return; 12440 for (const panel of feedEl.querySelectorAll(".postMenu")) panel.classList.add("hidden"); 12441 for (const btn of feedEl.querySelectorAll("button[data-postmenu]")) btn.setAttribute("aria-expanded", "false"); 12442 openPostMenuId = ""; 12443 }); 12444 12445 chatMessagesEl.addEventListener("click", (e) => { 12446 const emptyActionBtn = e.target.closest("button[data-chatemptyopen]"); 12447 if (emptyActionBtn) { 12448 const target = String(emptyActionBtn.getAttribute("data-chatemptyopen") || "").trim().toLowerCase(); 12449 if (target === "hives") { 12450 if (isMobileSwipeMode()) { 12451 setMobilePanel("hives"); 12452 } else { 12453 const hivesHeader = hivesPanelEl?.querySelector?.(".panelHeader"); 12454 hivesHeader?.scrollIntoView?.({ block: "nearest", behavior: "smooth" }); 12455 } 12456 return; 12457 } 12458 if (target === "people") { 12459 const peopleEl = getPanelElement("people") || peopleDrawerEl; 12460 if (peopleEl && typeof undockPanel === "function" && isDocked("people")) undockPanel("people"); 12461 peopleEl?.scrollIntoView?.({ block: "nearest", behavior: "smooth" }); 12462 return; 12463 } 12464 } 12465 12466 const mobileChatOpenBtn = e.target.closest("button[data-mobilechatopen]"); 12467 if (mobileChatOpenBtn) { 12468 const postId = mobileChatOpenBtn.getAttribute("data-mobilechatopen") || ""; 12469 if (postId) openChat(postId); 12470 return; 12471 } 12472 12473 const dmAcceptBtn = e.target.closest("button[data-dmaccept]"); 12474 if (dmAcceptBtn) { 12475 const threadId = dmAcceptBtn.getAttribute("data-dmaccept") || ""; 12476 if (threadId) { 12477 pendingOpenDmThreadId = threadId; 12478 ws.send(JSON.stringify({ type: "dmRequestRespond", threadId, accept: true })); 12479 } 12480 return; 12481 } 12482 const dmDeclineBtn = e.target.closest("button[data-dmdecline]"); 12483 if (dmDeclineBtn) { 12484 const threadId = dmDeclineBtn.getAttribute("data-dmdecline") || ""; 12485 if (threadId) ws.send(JSON.stringify({ type: "dmRequestRespond", threadId, accept: false })); 12486 return; 12487 } 12488 const dmOpenBtn = e.target.closest("button[data-dmopen]"); 12489 if (dmOpenBtn) { 12490 const threadId = dmOpenBtn.getAttribute("data-dmopen") || ""; 12491 if (threadId) openDmThread(threadId); 12492 return; 12493 } 12494 const dmRequestBtn = e.target.closest("button[data-dmrequest]"); 12495 if (dmRequestBtn && activeDmThreadId) { 12496 const to = String(dmRequestBtn.getAttribute("data-dmrequest") || "") 12497 .trim() 12498 .replace(/^@+/, "") 12499 .toLowerCase(); 12500 if (to) ws.send(JSON.stringify({ type: "dmRequestCreate", to })); 12501 return; 12502 } 12503 12504 const profileLink = e.target.closest("[data-viewprofile]"); 12505 if (profileLink) { 12506 const username = profileLink.getAttribute("data-viewprofile") || ""; 12507 if (username) openUserProfile(username); 12508 return; 12509 } 12510 12511 const mention = e.target.closest(".mentionToken"); 12512 if (mention) { 12513 const raw = String(mention.textContent || "").trim(); 12514 const username = raw.replace(/^@+/, "").toLowerCase(); 12515 if (username) openUserProfile(username); 12516 return; 12517 } 12518 12519 const editBtn = e.target.closest("button[data-editmsg]"); 12520 if (editBtn) { 12521 const messageId = editBtn.getAttribute("data-editmsg") || ""; 12522 const postId = editBtn.getAttribute("data-postid") || activeChatPostId || ""; 12523 if (!messageId || !postId) return; 12524 const message = findChatMessage(postId, messageId); 12525 if (!message || message.deleted) return; 12526 openEditModalForChatMessage(message, postId); 12527 return; 12528 } 12529 12530 const deleteBtn = e.target.closest("button[data-deletemsg]"); 12531 if (deleteBtn) { 12532 const messageId = deleteBtn.getAttribute("data-deletemsg") || ""; 12533 if (!messageId) return; 12534 const ok = confirm("Delete this message?"); 12535 if (!ok) return; 12536 ws.send(JSON.stringify({ type: "deleteChatMessageSelf", messageId })); 12537 return; 12538 } 12539 12540 const replyBtn = e.target.closest("button[data-replymsg]"); 12541 if (replyBtn) { 12542 const messageId = replyBtn.getAttribute("data-replymsg") || ""; 12543 const postId = replyBtn.getAttribute("data-postid") || activeChatPostId || ""; 12544 if (!messageId || !postId) return; 12545 const message = findChatMessage(postId, messageId); 12546 if (!message) return; 12547 setReplyToMessage(message); 12548 chatEditor?.focus(); 12549 return; 12550 } 12551 12552 const reportChatBtn = e.target.closest("button[data-reportchat]"); 12553 if (reportChatBtn) { 12554 const messageId = reportChatBtn.getAttribute("data-reportchat") || ""; 12555 const postId = reportChatBtn.getAttribute("data-postid") || activeChatPostId || ""; 12556 if (!messageId || !postId) return; 12557 const message = findChatMessage(postId, messageId); 12558 if (!message || message.deleted) { 12559 toast("Unavailable", "That message was deleted."); 12560 return; 12561 } 12562 const reason = promptReason("message report"); 12563 if (!reason) return; 12564 ws.send(JSON.stringify({ type: "reportCreate", targetType: "chat", targetId: messageId, postId, reason })); 12565 return; 12566 } 12567 12568 const react = e.target.closest("[data-react]"); 12569 if (!react || react.getAttribute("data-kind") !== "chat") return; 12570 const postId = react.getAttribute("data-postid") || ""; 12571 const messageId = react.getAttribute("data-msgid") || ""; 12572 const emoji = react.getAttribute("data-emoji") || ""; 12573 if (!postId || !messageId || !emoji) return; 12574 markReactPulse("chat", messageId, emoji); 12575 toggleMyReact("chat", messageId, emoji); 12576 ws.send(JSON.stringify({ type: "react", targetType: "chat", postId, messageId, emoji })); 12577 renderChatPanel(); 12578 }); 12579 12580 chatReplyCancelBtn?.addEventListener("click", () => setReplyToMessage(null)); 12581 12582 chatBackToListBtn?.addEventListener("click", () => { 12583 if (activeChatPostId && ws?.readyState === WebSocket.OPEN) { 12584 ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); 12585 } 12586 activeChatPostId = null; 12587 activeDmThreadId = null; 12588 activeMapsRoomId = ""; 12589 activeMapsRoomTitle = ""; 12590 setReplyToMessage(null); 12591 renderChatPanel(true); 12592 }); 12593 12594 chatContextSelectEl?.addEventListener("change", () => { 12595 if (syncingChatContextSelect) return; 12596 const raw = String(chatContextSelectEl.value || "").trim(); 12597 if (!raw) return; 12598 openChatContextValue(raw, { preserveFocus: false }); 12599 }); 12600 12601 modPanelEl?.addEventListener("click", (e) => { 12602 const tabBtn = e.target.closest("[data-modtab]"); 12603 if (tabBtn) { 12604 modTab = tabBtn.getAttribute("data-modtab") || "reports"; 12605 if (modTab === "server") requestServerInfo(); 12606 if (modTab === "onboarding") syncOnboardingAdminDraft(true); 12607 renderModPanel(); 12608 return; 12609 } 12610 }); 12611 12612 modRefreshBtn?.addEventListener("click", () => { 12613 if (!canModerate) return; 12614 if (modTab === "server") requestServerInfo(); 12615 else if (modTab === "onboarding") { 12616 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" })); 12617 syncOnboardingAdminDraft(true); 12618 renderModPanel(); 12619 } 12620 else requestModData(); 12621 }); 12622 modReportStatusEl?.addEventListener("change", () => { 12623 if (!canModerate) return; 12624 ws.send(JSON.stringify({ type: "modListReports", status: modReportStatusEl.value || "open", limit: 200 })); 12625 }); 12626 12627 modModal?.addEventListener("click", (e) => { 12628 if (e.target?.getAttribute?.("data-modmodalclose")) setModModalOpen(false); 12629 }); 12630 modModalClose?.addEventListener("click", () => setModModalOpen(false)); 12631 modModalCancel?.addEventListener("click", () => setModModalOpen(false)); 12632 12633 modModalBody?.addEventListener("change", (e) => { 12634 if (!modModalContext) return; 12635 if (modModalContext.kind === "collectionGate") { 12636 if (e.target?.name === "gateVisibility") updateGateModalVisibility(); 12637 return; 12638 } 12639 if (modModalContext.kind !== "userRoles") return; 12640 const checkbox = e.target?.closest?.("input[type='checkbox'][data-userrolekey]"); 12641 if (!checkbox) return; 12642 const key = checkbox.getAttribute("data-userrolekey") || ""; 12643 const enabled = Boolean(checkbox.checked); 12644 if (!key) return; 12645 ws.send(JSON.stringify({ type: "userCustomRoleSet", targetId: modModalContext.username, key, enabled })); 12646 }); 12647 12648 modModalPrimary?.addEventListener("click", () => { 12649 if (!modModalContext) return; 12650 if (modModalStatus) modModalStatus.textContent = ""; 12651 if (modModalContext.kind === "collectionCreate") { 12652 const name = String(document.getElementById("modModalCollectionName")?.value || "").trim(); 12653 if (!name) { 12654 if (modModalStatus) modModalStatus.textContent = "Name is required."; 12655 return; 12656 } 12657 ws.send(JSON.stringify({ type: "collectionCreate", name })); 12658 setModModalOpen(false); 12659 return; 12660 } 12661 if (modModalContext.kind === "collectionGate") { 12662 const collectionId = String(modModalContext.collectionId || ""); 12663 const visibility = String(modModalBody?.querySelector("input[name='gateVisibility']:checked")?.value || "public"); 12664 if (visibility !== "gated") { 12665 ws.send(JSON.stringify({ type: "collectionSetGate", collectionId, visibility: "public", allowedRoles: [] })); 12666 setModModalOpen(false); 12667 return; 12668 } 12669 const allowedRoles = Array.from(modModalBody?.querySelectorAll("input[data-gatetoken]:checked") || []).map((el) => 12670 String(el.getAttribute("data-gatetoken") || "") 12671 ); 12672 if (!allowedRoles.length) { 12673 if (modModalStatus) modModalStatus.textContent = "Pick at least one allowed role for gated collections."; 12674 return; 12675 } 12676 ws.send(JSON.stringify({ type: "collectionSetGate", collectionId, visibility: "gated", allowedRoles })); 12677 setModModalOpen(false); 12678 } 12679 }); 12680 12681 modBodyEl?.addEventListener("click", (e) => { 12682 const modLogViewBtn = e.target.closest("button[data-modlogview]"); 12683 if (modLogViewBtn) { 12684 const next = String(modLogViewBtn.getAttribute("data-modlogview") || "dev"); 12685 modLogView = next === "moderation" ? "moderation" : "dev"; 12686 localStorage.setItem("bzl_modLogView", modLogView); 12687 if (modLogView === "dev" && ws.readyState === WebSocket.OPEN) { 12688 ws.send(JSON.stringify({ type: "devLogList", limit: 300 })); 12689 } 12690 renderModPanel(); 12691 return; 12692 } 12693 12694 const devLogRefreshBtn = e.target.closest("button[data-devlogrefresh]"); 12695 if (devLogRefreshBtn) { 12696 if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "devLogList", limit: 300 })); 12697 return; 12698 } 12699 12700 const devLogCopyBtn = e.target.closest("button[data-devlogcopy]"); 12701 if (devLogCopyBtn) { 12702 const text = String(document.getElementById("devLogPre")?.textContent || "").trim(); 12703 if (!text) { 12704 toast("Dev log", "Nothing to copy."); 12705 return; 12706 } 12707 navigator.clipboard 12708 .writeText(text) 12709 .then(() => toast("Dev log", "Copied.")) 12710 .catch(() => toast("Dev log", "Copy failed.")); 12711 return; 12712 } 12713 12714 const devLogClearBtn = e.target.closest("button[data-devlogclear]"); 12715 if (devLogClearBtn) { 12716 if (!(canModerate && (isOwnerRole(loggedInRole) || isAdminRole(loggedInRole)))) return; 12717 const ok = confirm("Clear the server dev log?"); 12718 if (!ok) return; 12719 ws.send(JSON.stringify({ type: "devLogClear" })); 12720 return; 12721 } 12722 12723 const devLogTestBtn = e.target.closest("button[data-devlogtest]"); 12724 if (devLogTestBtn) { 12725 sendDevLog("info", "ui", "Dev log test", { at: Date.now() }); 12726 return; 12727 } 12728 12729 const devLogAutoScrollToggle = e.target.closest("input[data-devlogautoscroll]"); 12730 if (devLogAutoScrollToggle) { 12731 devLogAutoScroll = Boolean(devLogAutoScrollToggle.checked); 12732 localStorage.setItem("bzl_devLogAutoScroll", devLogAutoScroll ? "1" : "0"); 12733 renderModPanel(); 12734 return; 12735 } 12736 12737 const serverRefreshBtn = e.target.closest("button[data-server-refresh]"); 12738 if (serverRefreshBtn) { 12739 requestServerInfo(); 12740 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" })); 12741 return; 12742 } 12743 12744 const onboardingRefreshBtn = e.target.closest("button[data-onboarding-refresh]"); 12745 if (onboardingRefreshBtn) { 12746 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" })); 12747 syncOnboardingAdminDraft(true); 12748 renderModPanel(); 12749 return; 12750 } 12751 12752 const onbAdminTabBtn = e.target.closest("button[data-onb-admin-tab]"); 12753 if (onbAdminTabBtn) { 12754 const tab = String(onbAdminTabBtn.getAttribute("data-onb-admin-tab") || "about").trim(); 12755 if (!["about", "rules", "roles"].includes(tab)) return; 12756 onboardingAdminTab = tab; 12757 renderModPanel(); 12758 return; 12759 } 12760 12761 const onbRuleAddBtn = e.target.closest("button[data-onb-ruleadd]"); 12762 if (onbRuleAddBtn) { 12763 if (!(canModerate && isStaffRole(loggedInRole))) return; 12764 normalizeOnboardingDraftRules(); 12765 const nextIndex = onboardingAdminDraft.rules.length + 1; 12766 const id = `r${Date.now()}_${nextIndex}`; 12767 onboardingAdminDraft.rules.push({ 12768 id, 12769 order: nextIndex, 12770 name: `Rule ${nextIndex}`, 12771 shortDescription: "", 12772 description: "", 12773 severity: "info", 12774 }); 12775 normalizeOnboardingDraftRules(); 12776 onboardingAdminExpandedRuleIds.add(id); 12777 onboardingAdminTab = "rules"; 12778 renderModPanel(); 12779 return; 12780 } 12781 12782 const onbRuleToggleBtn = e.target.closest("button[data-onb-ruletoggle]"); 12783 if (onbRuleToggleBtn) { 12784 const id = String(onbRuleToggleBtn.getAttribute("data-onb-ruletoggle") || "").trim(); 12785 if (!id) return; 12786 if (onboardingAdminExpandedRuleIds.has(id)) onboardingAdminExpandedRuleIds.delete(id); 12787 else onboardingAdminExpandedRuleIds.add(id); 12788 renderModPanel(); 12789 return; 12790 } 12791 12792 const onbRuleDeleteBtn = e.target.closest("button[data-onb-ruledelete]"); 12793 if (onbRuleDeleteBtn) { 12794 if (!(canModerate && isStaffRole(loggedInRole))) return; 12795 const id = String(onbRuleDeleteBtn.getAttribute("data-onb-ruledelete") || "").trim(); 12796 onboardingAdminDraft.rules = onboardingAdminDraft.rules.filter((r) => r.id !== id); 12797 onboardingAdminExpandedRuleIds.delete(id); 12798 normalizeOnboardingDraftRules(); 12799 renderModPanel(); 12800 return; 12801 } 12802 12803 const onbRuleUpBtn = e.target.closest("button[data-onb-ruleup]"); 12804 if (onbRuleUpBtn) { 12805 if (!(canModerate && isStaffRole(loggedInRole))) return; 12806 const id = String(onbRuleUpBtn.getAttribute("data-onb-ruleup") || "").trim(); 12807 const idx = onboardingAdminDraft.rules.findIndex((r) => r.id === id); 12808 if (idx <= 0) return; 12809 const tmp = onboardingAdminDraft.rules[idx - 1]; 12810 onboardingAdminDraft.rules[idx - 1] = onboardingAdminDraft.rules[idx]; 12811 onboardingAdminDraft.rules[idx] = tmp; 12812 normalizeOnboardingDraftRules(); 12813 renderModPanel(); 12814 return; 12815 } 12816 12817 const onbRuleDownBtn = e.target.closest("button[data-onb-ruledown]"); 12818 if (onbRuleDownBtn) { 12819 if (!(canModerate && isStaffRole(loggedInRole))) return; 12820 const id = String(onbRuleDownBtn.getAttribute("data-onb-ruledown") || "").trim(); 12821 const idx = onboardingAdminDraft.rules.findIndex((r) => r.id === id); 12822 if (idx < 0 || idx >= onboardingAdminDraft.rules.length - 1) return; 12823 const tmp = onboardingAdminDraft.rules[idx + 1]; 12824 onboardingAdminDraft.rules[idx + 1] = onboardingAdminDraft.rules[idx]; 12825 onboardingAdminDraft.rules[idx] = tmp; 12826 normalizeOnboardingDraftRules(); 12827 renderModPanel(); 12828 return; 12829 } 12830 12831 const onboardingSaveBtn = e.target.closest("button[data-onboarding-save],button[data-onboarding-publish]"); 12832 if (onboardingSaveBtn) { 12833 if (!(canModerate && isStaffRole(loggedInRole))) return; 12834 const publish = onboardingSaveBtn.hasAttribute("data-onboarding-publish"); 12835 normalizeOnboardingDraftRules(); 12836 ws.send( 12837 JSON.stringify({ 12838 type: "instanceSetOnboarding", 12839 publish, 12840 enabled: Boolean(onboardingAdminDraft.enabled), 12841 about: { content: String(onboardingAdminDraft.aboutContent || "") }, 12842 rules: { 12843 requireAcceptance: Boolean(onboardingAdminDraft.requireAcceptance), 12844 blockReadUntilAccepted: Boolean(onboardingAdminDraft.blockReadUntilAccepted), 12845 items: onboardingAdminDraft.rules, 12846 }, 12847 roleSelect: { 12848 enabled: Boolean(onboardingAdminDraft.roleSelectEnabled), 12849 selfAssignableRoleIds: onboardingAdminDraft.selfAssignableRoleIds, 12850 } 12851 }) 12852 ); 12853 toast("Onboarding", publish ? "Publishing..." : "Saving..."); 12854 return; 12855 } 12856 12857 const instanceSaveBtn = e.target.closest("button[data-instance-save]"); 12858 if (instanceSaveBtn) { 12859 if (!(canModerate && (isOwnerRole(loggedInRole) || isAdminRole(loggedInRole)))) return; 12860 const title = String(modBodyEl.querySelector("input[data-instance-title]")?.value || "").replace(/\s+/g, " ").trim().slice(0, 32); 12861 const subtitle = String(modBodyEl.querySelector("input[data-instance-subtitle]")?.value || "").replace(/\s+/g, " ").trim().slice(0, 80); 12862 const allowMemberPermanentPosts = Boolean(modBodyEl.querySelector("input[data-instance-allowpermanent]")?.checked); 12863 if (!title) { 12864 toast("Instance", "Title is required."); 12865 return; 12866 } 12867 const appearance = normalizeInstanceBranding(instanceBranding).appearance || {}; 12868 ws.send( 12869 JSON.stringify({ 12870 type: "instanceSetBranding", 12871 title, 12872 subtitle, 12873 allowMemberPermanentPosts, 12874 appearance 12875 }) 12876 ); 12877 toast("Instance", "Saving..."); 12878 return; 12879 } 12880 12881 const instanceSaveAppearanceBtn = e.target.closest("button[data-instance-saveappearance]"); 12882 if (instanceSaveAppearanceBtn) { 12883 if (!(canModerate && isStaffRole(loggedInRole))) return; 12884 const bg = String(modBodyEl.querySelector("input[data-instance-bg]")?.value || "").trim(); 12885 const panel = String(modBodyEl.querySelector("input[data-instance-panel]")?.value || "").trim(); 12886 const text = String(modBodyEl.querySelector("input[data-instance-text]")?.value || "").trim(); 12887 const good = String(modBodyEl.querySelector("input[data-instance-good]")?.value || "").trim(); 12888 const bad = String(modBodyEl.querySelector("input[data-instance-bad]")?.value || "").trim(); 12889 const accent = String(modBodyEl.querySelector("input[data-instance-accent]")?.value || "").trim(); 12890 const accent2 = String(modBodyEl.querySelector("input[data-instance-accent2]")?.value || "").trim(); 12891 const fontBody = String(modBodyEl.querySelector("select[data-instance-fontbody]")?.value || "").trim(); 12892 const fontMono = String(modBodyEl.querySelector("select[data-instance-fontmono]")?.value || "").trim(); 12893 const mutedPct = String(modBodyEl.querySelector("input[data-instance-mutedpct]")?.value || "").trim(); 12894 const linePct = String(modBodyEl.querySelector("input[data-instance-linepct]")?.value || "").trim(); 12895 const panel2Pct = String(modBodyEl.querySelector("input[data-instance-panel2pct]")?.value || "").trim(); 12896 ws.send( 12897 JSON.stringify({ 12898 type: "instanceSetAppearance", 12899 appearance: { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct } 12900 }) 12901 ); 12902 toast("Theme", "Saving..."); 12903 return; 12904 } 12905 12906 const themeResetBtn = e.target.closest("button[data-theme-reset]"); 12907 if (themeResetBtn) { 12908 if (!(canModerate && isStaffRole(loggedInRole))) return; 12909 applyInstanceAppearance(); 12910 renderModPanel(); 12911 toast("Theme", "Reset to saved theme."); 12912 return; 12913 } 12914 12915 const pluginReloadBtn = e.target.closest("button[data-pluginreload]"); 12916 if (pluginReloadBtn) { 12917 if (!canManagePlugins()) return; 12918 pluginAdminBusy = true; 12919 pluginAdminStatus = "Reloading plugins..."; 12920 renderModPanel(); 12921 ws.send(JSON.stringify({ type: "pluginReload" })); 12922 return; 12923 } 12924 12925 const pluginUninstallBtn = e.target.closest("button[data-pluginuninstall]"); 12926 if (pluginUninstallBtn) { 12927 if (!canManagePlugins()) return; 12928 const id = String(pluginUninstallBtn.getAttribute("data-pluginuninstall") || "").trim().toLowerCase(); 12929 if (!id) return; 12930 const ok = confirm(`Uninstall "${id}"? This deletes the plugin files from this server.`); 12931 if (!ok) return; 12932 pluginAdminBusy = true; 12933 pluginAdminStatus = `Uninstalling "${id}"...`; 12934 renderModPanel(); 12935 ws.send(JSON.stringify({ type: "pluginUninstall", id })); 12936 return; 12937 } 12938 12939 const pluginInstallBtn = e.target.closest("button[data-plugininstall]"); 12940 if (pluginInstallBtn) { 12941 if (!canManagePlugins()) return; 12942 const input = modBodyEl.querySelector("input[type='file'][data-pluginzip]") || null; 12943 const file = input?.files && input.files[0] ? input.files[0] : null; 12944 if (!file) { 12945 pluginAdminStatus = "Choose a .zip file first."; 12946 renderModPanel(); 12947 return; 12948 } 12949 const token = getSessionToken(); 12950 if (!token) { 12951 pluginAdminStatus = "Session missing. Please sign out/in and try again."; 12952 renderModPanel(); 12953 return; 12954 } 12955 const maxZipBytes = 50 * 1024 * 1024; 12956 const fileSize = Number(file.size || 0); 12957 if (fileSize > maxZipBytes) { 12958 pluginAdminStatus = `Plugin zip is too large (${Math.ceil(fileSize / (1024 * 1024))}MB). Max is 50MB.`; 12959 renderModPanel(); 12960 return; 12961 } 12962 pluginAdminBusy = true; 12963 pluginAdminStatus = `Uploading plugin (${Math.ceil(fileSize / 1024)} KB)...`; 12964 renderModPanel(); 12965 (async () => { 12966 const controller = new AbortController(); 12967 const timeoutMs = 2 * 60 * 1000; 12968 const timeout = setTimeout(() => { 12969 try { 12970 controller.abort(new Error("UPLOAD_TIMEOUT")); 12971 } catch { 12972 // ignore 12973 } 12974 }, timeoutMs); 12975 try { 12976 const res = await fetch("/api/plugin-install", { 12977 method: "POST", 12978 headers: { "Content-Type": "application/zip", Authorization: `Bearer ${token}` }, 12979 body: file, 12980 credentials: "same-origin", 12981 signal: controller.signal, 12982 }); 12983 const json = await res.json().catch(() => null); 12984 if (!res.ok || !json || !json.ok) { 12985 pluginAdminBusy = false; 12986 pluginAdminStatus = String(json?.error || `Install failed (${res.status}).`); 12987 renderModPanel(); 12988 return; 12989 } 12990 if (input) input.value = ""; 12991 pluginAdminBusy = false; 12992 pluginAdminStatus = `Installed "${json.plugin?.id || "plugin"}". Enable it below.`; 12993 toast("Plugins", "Installed. Enable it to activate."); 12994 renderModPanel(); 12995 } catch (err) { 12996 pluginAdminBusy = false; 12997 const message = String(err?.message || ""); 12998 if (message.includes("UPLOAD_TIMEOUT")) { 12999 pluginAdminStatus = "Upload timed out after 2 minutes. Try a smaller zip or better network."; 13000 } else if (String(err?.name || "") === "AbortError") { 13001 pluginAdminStatus = "Upload was interrupted."; 13002 } else { 13003 pluginAdminStatus = `Install failed: ${message || "unknown error"}`; 13004 } 13005 renderModPanel(); 13006 } finally { 13007 clearTimeout(timeout); 13008 } 13009 })(); 13010 return; 13011 } 13012 13013 const nukeBtn = e.target.closest("button[data-nuke]"); 13014 if (nukeBtn) { 13015 if (!(canModerate && loggedInRole === "owner")) return; 13016 const confirmEl = modBodyEl.querySelector("input[data-nukeconfirm]"); 13017 const okToggle = Boolean(confirmEl?.checked); 13018 if (!okToggle) { 13019 toast("NUKE", "Toggle ARE YOU SURE? first."); 13020 return; 13021 } 13022 const ok = confirm("NUKE the board? This clears all hives, reports, moderation log, and hive media uploads."); 13023 if (!ok) return; 13024 ws.send(JSON.stringify({ type: "nukeBoard", confirm: true, confirmText: "ARE YOU SURE?" })); 13025 toast("NUKE", "Working..."); 13026 return; 13027 } 13028 13029 const openChatBtn = e.target.closest("button[data-chat]"); 13030 if (openChatBtn) { 13031 const postId = openChatBtn.getAttribute("data-chat") || ""; 13032 if (postId) openChat(postId); 13033 return; 13034 } 13035 13036 const createCollectionBtn = e.target.closest("button[data-createcollection]"); 13037 if (createCollectionBtn) { 13038 openCollectionCreateModal(); 13039 return; 13040 } 13041 13042 const archiveCollectionBtn = e.target.closest("button[data-archivecollection]"); 13043 if (archiveCollectionBtn) { 13044 const collectionId = archiveCollectionBtn.getAttribute("data-archivecollection") || ""; 13045 if (!collectionId) return; 13046 const ok = confirm("Archive this collection? Existing hives stay visible in All."); 13047 if (!ok) return; 13048 ws.send(JSON.stringify({ type: "collectionArchive", collectionId })); 13049 return; 13050 } 13051 13052 const collectionGateBtn = e.target.closest("button[data-collectiongate]"); 13053 if (collectionGateBtn) { 13054 const collectionId = collectionGateBtn.getAttribute("data-collectiongate") || ""; 13055 if (!collectionId) return; 13056 openCollectionGateModal(collectionId); 13057 return; 13058 } 13059 13060 const collectionPublicBtn = e.target.closest("button[data-collectionpublic]"); 13061 if (collectionPublicBtn) { 13062 const collectionId = collectionPublicBtn.getAttribute("data-collectionpublic") || ""; 13063 if (!collectionId) return; 13064 ws.send(JSON.stringify({ type: "collectionSetGate", collectionId, visibility: "public", allowedRoles: [] })); 13065 return; 13066 } 13067 13068 const roleCreateBtn = e.target.closest("button[data-rolecreate]"); 13069 if (roleCreateBtn) { 13070 const card = roleCreateBtn.closest(".modCard"); 13071 const label = String(card?.querySelector("input[data-rolelabel]")?.value || "").trim(); 13072 let key = String(card?.querySelector("input[data-rolekey]")?.value || "") 13073 .trim() 13074 .toLowerCase(); 13075 if (!key && label) { 13076 key = label 13077 .toLowerCase() 13078 .replace(/[^a-z0-9]+/g, "_") 13079 .replace(/^_+|_+$/g, "") 13080 .slice(0, 18); 13081 const keyEl = card?.querySelector("input[data-rolekey]"); 13082 if (keyEl && key) keyEl.value = key; 13083 } 13084 const color = String(card?.querySelector("input[data-rolecolor]")?.value || "#ff3ea5").trim(); 13085 if (!key || !label) { 13086 toast("Roles", "Key and label are required."); 13087 return; 13088 } 13089 ws.send(JSON.stringify({ type: "roleCreate", key, label, color })); 13090 return; 13091 } 13092 13093 const roleArchiveBtn = e.target.closest("button[data-rolearchive]"); 13094 if (roleArchiveBtn) { 13095 const key = roleArchiveBtn.getAttribute("data-rolearchive") || ""; 13096 if (!key) return; 13097 const ok = confirm(`Archive role "${key}"?`); 13098 if (!ok) return; 13099 ws.send(JSON.stringify({ type: "roleArchive", key })); 13100 return; 13101 } 13102 13103 const userManageRolesBtn = e.target.closest("button[data-usermanageroles]"); 13104 if (userManageRolesBtn) { 13105 const targetId = userManageRolesBtn.getAttribute("data-usermanageroles") || ""; 13106 if (!targetId) return; 13107 openUserRolesModal(targetId); 13108 return; 13109 } 13110 13111 const actionBtn = e.target.closest("button[data-modaction]"); 13112 if (!actionBtn) return; 13113 const actionType = actionBtn.getAttribute("data-modaction") || ""; 13114 const targetType = actionBtn.getAttribute("data-targettype") || ""; 13115 const targetId = actionBtn.getAttribute("data-targetid") || ""; 13116 if (!actionType || !targetType || !targetId) return; 13117 13118 const metadata = {}; 13119 13120 if (actionType === "user_password_reset") { 13121 const pw = prompt("Set a new password (min 4 chars):"); 13122 if (pw === null) return; 13123 const next = String(pw || ""); 13124 if (next.length < 4) { 13125 toast("Password reset", "Password must be at least 4 characters."); 13126 return; 13127 } 13128 const ok = confirm("Reset this user's password to the value you entered?"); 13129 if (!ok) return; 13130 metadata.newPassword = next; 13131 } 13132 13133 if (actionType === "post_erase") { 13134 const ok = confirm("Erase this hive permanently? This cannot be restored."); 13135 if (!ok) return; 13136 } 13137 13138 if (actionType === "post_readonly_set") { 13139 metadata.readOnly = actionBtn.getAttribute("data-readonly") === "1"; 13140 } 13141 13142 if (actionType === "post_protection_set") { 13143 if (actionBtn.hasAttribute("data-unprotect")) { 13144 metadata.enabled = false; 13145 } else { 13146 const pw = prompt("Set post password (min 4 chars):"); 13147 if (pw === null) return; 13148 const next = String(pw || ""); 13149 if (next.length < 4) { 13150 toast("Protected post", "Password must be at least 4 characters."); 13151 return; 13152 } 13153 metadata.enabled = true; 13154 metadata.password = next; 13155 } 13156 } 13157 13158 const reason = promptReason(actionType); 13159 if (!reason) return; 13160 const minutesAttr = actionBtn.getAttribute("data-minutes"); 13161 const roleAttr = actionBtn.getAttribute("data-role"); 13162 const countAttr = actionBtn.getAttribute("data-count"); 13163 const ttlAttr = actionBtn.getAttribute("data-ttl"); 13164 const ttlPrompt = actionBtn.hasAttribute("data-ttlprompt"); 13165 if (minutesAttr) metadata.minutes = Number(minutesAttr); 13166 if (roleAttr) metadata.role = roleAttr; 13167 if (countAttr) metadata.count = Number(countAttr); 13168 if (ttlAttr) metadata.ttlMinutes = Number(ttlAttr); 13169 if (ttlPrompt && actionType === "post_ttl_set") { 13170 const raw = prompt("Set TTL minutes (0 = permanent):", "60"); 13171 if (raw === null) return; 13172 const n = Math.max(0, Math.min(2880, Math.floor(Number(raw)))); 13173 if (!Number.isFinite(n)) { 13174 toast("TTL", "Enter a valid number."); 13175 return; 13176 } 13177 metadata.ttlMinutes = n; 13178 } 13179 ws.send(JSON.stringify({ type: "modAction", actionType, targetType, targetId, reason, metadata })); 13180 }); 13181 13182 modBodyEl?.addEventListener("change", (e) => { 13183 const onbEnabled = e.target?.closest?.("input[data-onboarding-enabled]"); 13184 if (onbEnabled) { 13185 onboardingAdminDraft.enabled = Boolean(onbEnabled.checked); 13186 return; 13187 } 13188 const onbRequire = e.target?.closest?.("input[data-onboarding-require]"); 13189 if (onbRequire) { 13190 onboardingAdminDraft.requireAcceptance = Boolean(onbRequire.checked); 13191 return; 13192 } 13193 const onbBlockRead = e.target?.closest?.("input[data-onboarding-blockread]"); 13194 if (onbBlockRead) { 13195 onboardingAdminDraft.blockReadUntilAccepted = Boolean(onbBlockRead.checked); 13196 return; 13197 } 13198 const onbRoleEnabled = e.target?.closest?.("input[data-onboarding-roleenabled]"); 13199 if (onbRoleEnabled) { 13200 onboardingAdminDraft.roleSelectEnabled = Boolean(onbRoleEnabled.checked); 13201 return; 13202 } 13203 const onbRoleCheck = e.target?.closest?.("input[data-onboarding-rolecheck]"); 13204 if (onbRoleCheck) { 13205 const key = String(onbRoleCheck.getAttribute("data-onboarding-rolecheck") || "").trim().toLowerCase(); 13206 if (!key) return; 13207 const set = new Set(onboardingAdminDraft.selfAssignableRoleIds || []); 13208 if (onbRoleCheck.checked) set.add(key); 13209 else set.delete(key); 13210 onboardingAdminDraft.selfAssignableRoleIds = Array.from(set); 13211 return; 13212 } 13213 const onbRuleField = e.target?.closest?.("[data-onb-rulefield]"); 13214 if (onbRuleField) { 13215 const id = String(onbRuleField.getAttribute("data-onb-ruleid") || "").trim(); 13216 const field = String(onbRuleField.getAttribute("data-onb-rulefield") || "").trim(); 13217 if (!id || !field) return; 13218 const rule = onboardingAdminDraft.rules.find((r) => r.id === id); 13219 if (!rule) return; 13220 if (field === "severity") { 13221 rule.severity = ["info", "warn", "critical"].includes(String(onbRuleField.value || "").toLowerCase()) 13222 ? String(onbRuleField.value || "").toLowerCase() 13223 : "info"; 13224 return; 13225 } 13226 rule[field] = String(onbRuleField.value || ""); 13227 return; 13228 } 13229 13230 const presetSelect = e.target?.closest?.("select[data-theme-preset]"); 13231 if (presetSelect) { 13232 if (!(canModerate && isStaffRole(loggedInRole))) return; 13233 const id = String(presetSelect.value || "").trim(); 13234 if (!id) return; 13235 const preset = THEME_PRESETS.find((p) => p.id === id) || null; 13236 if (!preset) return; 13237 const a = preset.appearance || {}; 13238 const setValue = (selector, value) => { 13239 const el = modBodyEl.querySelector(selector); 13240 if (!el) return; 13241 el.value = String(value ?? ""); 13242 }; 13243 setValue("input[data-instance-bg]", a.bg); 13244 setValue("input[data-instance-panel]", a.panel); 13245 setValue("input[data-instance-text]", a.text); 13246 setValue("input[data-instance-good]", a.good); 13247 setValue("input[data-instance-bad]", a.bad); 13248 setValue("input[data-instance-accent]", a.accent); 13249 setValue("input[data-instance-accent2]", a.accent2); 13250 setValue("input[data-instance-mutedpct]", a.mutedPct); 13251 setValue("input[data-instance-linepct]", a.linePct); 13252 setValue("input[data-instance-panel2pct]", a.panel2Pct); 13253 setValue("select[data-instance-fontbody]", a.fontBody); 13254 setValue("select[data-instance-fontmono]", a.fontMono); 13255 applyInstanceAppearance(a); 13256 toast("Theme", `Preset "${preset.name}" applied (preview). Click Save to persist.`); 13257 return; 13258 } 13259 13260 const toggle = e.target?.closest?.("input[type='checkbox'][data-pluginenable]"); 13261 if (toggle) { 13262 if (!canManagePlugins()) return; 13263 const id = String(toggle.getAttribute("data-pluginenable") || "").trim().toLowerCase(); 13264 if (!id) return; 13265 const enabled = Boolean(toggle.checked); 13266 if (pluginEnableInFlight.has(id)) return; 13267 const wsRef = window.__bzlWs; 13268 if (!wsRef || wsRef.readyState !== WebSocket.OPEN) { 13269 toast("Plugins", "Not connected."); 13270 return; 13271 } 13272 pluginEnableInFlight.add(id); 13273 // Optimistic UI update to avoid flicker/repeated toggles. 13274 for (const p of plugins) { 13275 if (p && String(p.id || "").toLowerCase() === id) p.enabled = enabled; 13276 } 13277 pluginAdminStatus = enabled ? "Enabling..." : "Disabling..."; 13278 renderModPanel(); 13279 wsRef.send(JSON.stringify({ type: "pluginSetEnabled", id, enabled })); 13280 return; 13281 } 13282 }); 13283 13284 modBodyEl?.addEventListener("input", (e) => { 13285 const aboutEl = e.target?.closest?.("textarea[data-onboarding-about]"); 13286 if (aboutEl) { 13287 onboardingAdminDraft.aboutContent = String(aboutEl.value || ""); 13288 return; 13289 } 13290 const onbRuleField = e.target?.closest?.("input[data-onb-rulefield],textarea[data-onb-rulefield]"); 13291 if (!onbRuleField) return; 13292 const id = String(onbRuleField.getAttribute("data-onb-ruleid") || "").trim(); 13293 const field = String(onbRuleField.getAttribute("data-onb-rulefield") || "").trim(); 13294 if (!id || !field) return; 13295 const rule = onboardingAdminDraft.rules.find((r) => r.id === id); 13296 if (!rule) return; 13297 rule[field] = String(onbRuleField.value || ""); 13298 }); 13299 13300 modBodyEl?.addEventListener("change", (e) => { 13301 const toggle = e.target?.closest?.("input[data-nukeconfirm]"); 13302 if (!toggle) return; 13303 const btn = modBodyEl.querySelector("button[data-nuke]"); 13304 if (!btn) return; 13305 btn.disabled = !Boolean(toggle.checked); 13306 }); 13307 13308 chatForm.addEventListener("submit", (e) => { 13309 e.preventDefault(); 13310 submitChat(); 13311 }); 13312 13313 chatMeta?.addEventListener("click", (e) => { 13314 const btn = e.target?.closest?.("button[data-mapchatscope]"); 13315 if (!btn) return; 13316 const scope = normalizeMapChatScope(btn.getAttribute("data-mapchatscope") || "local"); 13317 activeMapsChatScope = scope; 13318 // Fetch global history on-demand when switching to global. 13319 if (scope === "global" && activeMapsRoomId) { 13320 try { 13321 const wsRef = window.__bzlWs; 13322 if (wsRef && wsRef.readyState === WebSocket.OPEN) { 13323 wsRef.send(JSON.stringify({ type: "plugin:maps:chatHistoryReq", mapId: activeMapsRoomId })); 13324 } 13325 } catch { 13326 // ignore 13327 } 13328 } 13329 renderChatPanel(true); 13330 }); 13331 13332 chatEditor.addEventListener("keydown", (e) => { 13333 if (mentionState.open) { 13334 if (e.key === "ArrowDown") { 13335 e.preventDefault(); 13336 mentionState.selected = Math.min(mentionState.items.length - 1, mentionState.selected + 1); 13337 renderMentionMenu(); 13338 return; 13339 } 13340 if (e.key === "ArrowUp") { 13341 e.preventDefault(); 13342 mentionState.selected = Math.max(0, mentionState.selected - 1); 13343 renderMentionMenu(); 13344 return; 13345 } 13346 if (e.key === "Enter" || e.key === "Tab") { 13347 e.preventDefault(); 13348 const picked = mentionState.items[mentionState.selected]; 13349 if (picked) replaceCurrentMentionToken(picked); 13350 closeMentionMenu(); 13351 return; 13352 } 13353 if (e.key === "Escape") { 13354 e.preventDefault(); 13355 closeMentionMenu(); 13356 return; 13357 } 13358 } 13359 if (e.key !== "Enter") return; 13360 if (!shouldSubmitChatOnEnter(e)) return; 13361 e.preventDefault(); 13362 submitChat(); 13363 }); 13364 13365 chatEditor.addEventListener("input", () => { 13366 if (!activeChatPostId || !loggedInUser) return; 13367 const textTail = String(chatEditor.innerText || "").slice(-80); 13368 const m = /@([a-z0-9_.-]{0,31})$/i.exec(textTail); 13369 if (m) { 13370 const query = String(m[1] || ""); 13371 mentionState.open = true; 13372 mentionState.query = query; 13373 mentionState.items = listMentionCandidates(query); 13374 mentionState.selected = 0; 13375 mentionState.anchorRect = getCaretRect(); 13376 renderMentionMenu(); 13377 } else { 13378 closeMentionMenu(); 13379 } 13380 13381 const t = Date.now(); 13382 if (t - lastTypingSentAt > 900) { 13383 ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: true })); 13384 lastTypingSentAt = t; 13385 } 13386 if (typingStopTimer) clearTimeout(typingStopTimer); 13387 typingStopTimer = setTimeout(() => { 13388 if (!activeChatPostId) return; 13389 ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); 13390 }, 1800); 13391 }); 13392 13393 chatEditor.addEventListener("focus", () => { 13394 chatUploadTargetEditor = chatEditor; 13395 }); 13396 13397 chatEditor.addEventListener("blur", () => { 13398 if (!activeChatPostId || !loggedInUser) return; 13399 ws.send(JSON.stringify({ type: "typing", postId: activeChatPostId, isTyping: false })); 13400 setTimeout(() => closeMentionMenu(), 0); 13401 }); 13402 13403 editor.addEventListener("keydown", (e) => { 13404 if (e.key !== "Enter") return; 13405 if (!(e.ctrlKey || e.metaKey)) return; 13406 e.preventDefault(); 13407 newPostForm.requestSubmit(); 13408 }); 13409 13410 chatImageInput.addEventListener("change", async () => { 13411 const file = chatImageInput.files && chatImageInput.files[0] ? chatImageInput.files[0] : null; 13412 chatImageInput.value = ""; 13413 if (!file) return; 13414 try { 13415 const url = await uploadMediaFile(file, "image"); 13416 if (!url) return; 13417 const target = chatUploadTargetEditor instanceof HTMLElement ? chatUploadTargetEditor : chatEditor; 13418 target.focus(); 13419 document.execCommand("insertImage", false, url); 13420 } catch { 13421 // ignore 13422 } 13423 }); 13424 13425 postImageInput?.addEventListener("change", async () => { 13426 const file = postImageInput.files && postImageInput.files[0] ? postImageInput.files[0] : null; 13427 postImageInput.value = ""; 13428 if (!file) return; 13429 try { 13430 const url = await uploadMediaFile(file, "image"); 13431 if (!url) return; 13432 editor.focus(); 13433 document.execCommand("insertImage", false, url); 13434 } catch { 13435 // ignore 13436 } 13437 }); 13438 13439 chatAudioInput?.addEventListener("change", async () => { 13440 const file = chatAudioInput.files && chatAudioInput.files[0] ? chatAudioInput.files[0] : null; 13441 chatAudioInput.value = ""; 13442 if (!file) return; 13443 try { 13444 const url = await uploadMediaFile(file, "audio"); 13445 if (!url) return; 13446 const target = chatUploadTargetEditor instanceof HTMLElement ? chatUploadTargetEditor : chatEditor; 13447 insertAudioTag(target, url); 13448 } catch { 13449 // ignore 13450 } 13451 }); 13452 13453 postAudioInput?.addEventListener("change", async () => { 13454 const file = postAudioInput.files && postAudioInput.files[0] ? postAudioInput.files[0] : null; 13455 postAudioInput.value = ""; 13456 if (!file) return; 13457 try { 13458 const url = await uploadMediaFile(file, "audio"); 13459 if (!url) return; 13460 insertAudioTag(editor, url); 13461 } catch { 13462 // ignore 13463 } 13464 }); 13465 13466 setInterval(() => { 13467 for (const el of document.querySelectorAll("[data-countdown]")) { 13468 const id = el.getAttribute("data-countdown"); 13469 const post = posts.get(id); 13470 if (!post) continue; 13471 el.textContent = formatCountdown(post.expiresAt); 13472 } 13473 for (const el of document.querySelectorAll("[data-boost]")) { 13474 const id = el.getAttribute("data-boost"); 13475 const post = posts.get(id); 13476 if (!post) continue; 13477 const txt = formatBoostRemaining(Number(post.boostUntil || 0)); 13478 if (!txt) { 13479 el.remove(); 13480 continue; 13481 } 13482 el.textContent = `boost ${txt}`; 13483 } 13484 if (activeChatPostId) updateActiveChatMeta(); 13485 }, 1000); 13486 13487 function unlockSfxOnce() { 13488 if (!pendingOpenSfx) return; 13489 playSfx("open", { volume: 0.34 }).then((ok) => { 13490 if (ok) pendingOpenSfx = false; 13491 }); 13492 } 13493 13494 window.addEventListener("pointerdown", unlockSfxOnce, { once: true, capture: true }); 13495 window.addEventListener("keydown", unlockSfxOnce, { once: true, capture: true }); 13496 13497 playSfx("open", { volume: 0.34 }).then((ok) => { 13498 if (ok) pendingOpenSfx = false; 13499 }); 13500 13501 let ws = null; 13502 let wsKeepaliveTimer = null; 13503 let wsReconnectTimer = null; 13504 let wsReconnectAttempt = 0; 13505 let lastForegroundResyncAt = 0; 13506 let wsStaleWatchdogTimer = null; 13507 let wsLastInboundAt = 0; 13508 let wsLastStaleReconnectAt = 0; 13509 const WS_STALE_CHECK_MS = 15_000; 13510 const WS_STALE_INBOUND_MS = 180_000; 13511 const WS_STALE_RECONNECT_COOLDOWN_MS = 90_000; 13512 13513 function clearWsKeepalive() { 13514 if (!wsKeepaliveTimer) return; 13515 try { 13516 clearInterval(wsKeepaliveTimer); 13517 } catch { 13518 // ignore 13519 } 13520 wsKeepaliveTimer = null; 13521 } 13522 13523 function clearWsReconnect() { 13524 if (!wsReconnectTimer) return; 13525 try { 13526 clearTimeout(wsReconnectTimer); 13527 } catch { 13528 // ignore 13529 } 13530 wsReconnectTimer = null; 13531 } 13532 13533 function clearWsStaleWatchdog() { 13534 if (!wsStaleWatchdogTimer) return; 13535 try { 13536 clearInterval(wsStaleWatchdogTimer); 13537 } catch { 13538 // ignore 13539 } 13540 wsStaleWatchdogTimer = null; 13541 } 13542 13543 function noteWsInbound() { 13544 wsLastInboundAt = Date.now(); 13545 } 13546 13547 function startWsStaleWatchdog(sock) { 13548 clearWsStaleWatchdog(); 13549 if (!readStayConnectedPref()) return; 13550 if (!sock || sock.readyState !== WebSocket.OPEN) return; 13551 noteWsInbound(); 13552 wsStaleWatchdogTimer = setInterval(() => { 13553 if (!sock || sock !== ws) return; 13554 if (sock.readyState !== WebSocket.OPEN) return; 13555 const now = Date.now(); 13556 const idleMs = now - wsLastInboundAt; 13557 if (idleMs < WS_STALE_INBOUND_MS) return; 13558 if (now - wsLastStaleReconnectAt < WS_STALE_RECONNECT_COOLDOWN_MS) return; 13559 wsLastStaleReconnectAt = now; 13560 setConn("connecting"); 13561 try { 13562 sock.close(4001, "stale-inbound-timeout"); 13563 } catch { 13564 // ignore 13565 } 13566 setTimeout(() => { 13567 if (!readStayConnectedPref()) return; 13568 if (ws && ws !== sock && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; 13569 connectWs(); 13570 }, 900); 13571 }, WS_STALE_CHECK_MS); 13572 } 13573 13574 function startWsKeepalive(sock) { 13575 clearWsKeepalive(); 13576 if (!readStayConnectedPref()) return; 13577 wsKeepaliveTimer = setInterval(() => { 13578 if (!sock || sock !== ws) return; 13579 if (sock.readyState !== WebSocket.OPEN) return; 13580 try { 13581 sock.send(JSON.stringify({ type: "ping" })); 13582 } catch { 13583 // ignore 13584 } 13585 }, 25_000); 13586 } 13587 13588 function scheduleWsReconnect() { 13589 clearWsReconnect(); 13590 if (!readStayConnectedPref()) return; 13591 const attempt = Math.min(6, Math.max(0, wsReconnectAttempt)); 13592 const base = 1000 * Math.pow(2, attempt); 13593 const jitter = Math.floor(Math.random() * 250); 13594 const delay = Math.min(15_000, base) + jitter; 13595 wsReconnectAttempt += 1; 13596 setConn("connecting"); 13597 wsReconnectTimer = setTimeout(() => { 13598 wsReconnectTimer = null; 13599 connectWs(); 13600 }, delay); 13601 } 13602 13603 function connectWs() { 13604 if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; 13605 clearWsKeepalive(); 13606 clearWsStaleWatchdog(); 13607 setConn("connecting"); 13608 const sock = new WebSocket(wsUrl()); 13609 ws = sock; 13610 window.__bzlWs = sock; 13611 13612 sock.addEventListener("open", () => { 13613 if (sock !== ws) return; 13614 noteWsInbound(); 13615 setConn("open"); 13616 wsReconnectAttempt = 0; 13617 clearWsReconnect(); 13618 startWsKeepalive(sock); 13619 startWsStaleWatchdog(sock); 13620 const token = getSessionToken(); 13621 if (token) { 13622 try { 13623 sock.send(JSON.stringify({ type: "resumeSession", token })); 13624 } catch { 13625 // ignore 13626 } 13627 } 13628 setTimeout(() => { 13629 if (sock !== ws) return; 13630 requestForegroundResync("ws-open"); 13631 }, 120); 13632 }); 13633 13634 sock.addEventListener("close", () => { 13635 if (sock !== ws) return; 13636 leaveActiveStream(false); 13637 setConn("closed"); 13638 clearWsKeepalive(); 13639 clearWsStaleWatchdog(); 13640 scheduleWsReconnect(); 13641 }); 13642 13643 sock.addEventListener("error", () => { 13644 if (sock !== ws) return; 13645 setConn("closed"); 13646 clearWsStaleWatchdog(); 13647 }); 13648 13649 sock.addEventListener("message", onWsMessage); 13650 } 13651 13652 function requestForegroundResync(reason = "") { 13653 const now = Date.now(); 13654 if (now - lastForegroundResyncAt < 1400) return; 13655 lastForegroundResyncAt = now; 13656 if (!ws || ws.readyState !== WebSocket.OPEN) return; 13657 try { 13658 ws.send(JSON.stringify({ type: "peopleList" })); 13659 ws.send(JSON.stringify({ type: "dmList" })); 13660 if (activeDmThreadId) { 13661 ws.send(JSON.stringify({ type: "dmHistory", threadId: activeDmThreadId })); 13662 } else if (activeChatPostId) { 13663 ws.send(JSON.stringify({ type: "getChat", postId: activeChatPostId })); 13664 } 13665 if (activeMapsRoomId) { 13666 ws.send(JSON.stringify({ type: "plugin:maps:chatHistoryReq", mapId: activeMapsRoomId })); 13667 } 13668 if (reason === "visibility") ws.send(JSON.stringify({ type: "onboardingGet" })); 13669 } catch { 13670 // ignore 13671 } 13672 } 13673 13674 function onWsMessage(evt) { 13675 noteWsInbound(); 13676 let msg; 13677 try { 13678 msg = JSON.parse(evt.data); 13679 } catch { 13680 return; 13681 } 13682 if (!msg || typeof msg !== "object") return; 13683 13684 if (msg.type === "init") { 13685 leaveActiveStream(false); 13686 clientId = msg.clientId || null; 13687 canRegisterFirstUser = Boolean(msg.auth?.canRegisterFirstUser); 13688 registrationEnabled = Boolean(msg.auth?.registrationEnabled); 13689 loggedInRole = "member"; 13690 canModerate = false; 13691 dmThreads = []; 13692 dmThreadsById = new Map(); 13693 dmMessagesByThreadId.clear(); 13694 activeDmThreadId = null; 13695 pendingOpenDmThreadId = ""; 13696 lanUrls = []; 13697 modReports = []; 13698 modUsers = []; 13699 modLog = []; 13700 devLog = []; 13701 profiles = msg.profiles && typeof msg.profiles === "object" ? msg.profiles : {}; 13702 instanceBranding = normalizeInstanceBranding(msg.instance || {}); 13703 onboardingState = normalizeOnboardingState(msg.auth?.onboarding || {}); 13704 renderInstanceBranding(); 13705 collections = normalizeCollections(msg.collections); 13706 customRoles = normalizeRoleDefs(msg.roles?.custom); 13707 setPlugins(msg.plugins); 13708 streamEnabled = Boolean(msg.stream?.enabled); 13709 streamIceServers = Array.isArray(msg.stream?.iceServers) && msg.stream.iceServers.length ? msg.stream.iceServers : streamIceServers; 13710 renderCollectionSelect(); 13711 peopleMembers = Array.isArray(msg.people?.members) ? msg.people.members : []; 13712 if (!peopleMembers.length && ws.readyState === WebSocket.OPEN) { 13713 ws.send(JSON.stringify({ type: "peopleList" })); 13714 } 13715 if (msg.reactions?.allowed && Array.isArray(msg.reactions.allowed)) allowedReactions = msg.reactions.allowed; 13716 if (msg.reactions?.allowedPost && Array.isArray(msg.reactions.allowedPost)) allowedPostReactions = msg.reactions.allowedPost; 13717 if (msg.reactions?.allowedChat && Array.isArray(msg.reactions.allowedChat)) allowedChatReactions = msg.reactions.allowedChat; 13718 setUserPrefs({ starredPostIds: [], hiddenPostIds: [] }); 13719 unreadByPostId.clear(); 13720 streamLiveByPostId.clear(); 13721 posts.clear(); 13722 for (const p of msg.posts || []) { 13723 posts.set(p.id, p); 13724 if (p && typeof p.id === "string") streamLiveByPostId.set(p.id, Boolean(p.streamLive)); 13725 } 13726 setAuthUi(); 13727 renderFeed(); 13728 renderChatPanel(); 13729 renderLanHint(); 13730 renderPeoplePanel(); 13731 renderCenterPanels(); 13732 maybeAutoStartGuidedTour(); 13733 if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" })); 13734 return; 13735 } 13736 13737 // Generic plugin event dispatch: `plugin:<pluginId>:<eventName>` 13738 // (Maps has some core-handled messages below; for other plugins, dispatch + stop.) 13739 if (typeof msg.type === "string") { 13740 const m = msg.type.match(/^plugin:([a-z0-9][a-z0-9_.-]{0,31}):([a-zA-Z0-9][a-zA-Z0-9_.-]{0,63})$/); 13741 if (m) { 13742 const pluginId = String(m[1] || "").toLowerCase(); 13743 const ev = String(m[2] || ""); 13744 const byEvent = pluginClientHandlers.get(pluginId); 13745 const set = byEvent ? byEvent.get(ev) : null; 13746 if (set && set.size) { 13747 for (const fn of Array.from(set)) { 13748 try { 13749 fn(msg); 13750 } catch (e) { 13751 console.warn(`Plugin handler failed (${pluginId}:${ev}):`, e?.message || e); 13752 } 13753 } 13754 } 13755 if (pluginId !== "maps") return; 13756 } 13757 } 13758 13759 if (msg.type === "plugin:maps:joinOk") { 13760 const map = msg.map && typeof msg.map === "object" ? msg.map : null; 13761 const mapId = map && typeof map.id === "string" ? map.id.trim().toLowerCase() : ""; 13762 if (mapId) { 13763 activeMapsRoomId = mapId; 13764 activeMapsRoomTitle = map && typeof map.title === "string" ? map.title.trim().slice(0, 64) : mapId; 13765 activeMapsChatScope = "local"; 13766 try { 13767 if (ws.readyState === WebSocket.OPEN) { 13768 ws.send(JSON.stringify({ type: "plugin:maps:chatHistoryReq", mapId })); 13769 } 13770 } catch { 13771 // ignore 13772 } 13773 if (isMapChatActive()) renderChatPanel(true); 13774 } 13775 return; 13776 } 13777 13778 if (msg.type === "plugin:maps:left") { 13779 const wasActive = Boolean(activeMapsRoomId); 13780 activeMapsRoomId = ""; 13781 activeMapsRoomTitle = ""; 13782 activeMapsChatScope = "local"; 13783 if (wasActive && !activeDmThreadId && !activeChatPostId) renderChatPanel(true); 13784 return; 13785 } 13786 13787 if (msg.type === "plugin:maps:chatHistory") { 13788 const mapId = typeof msg.mapId === "string" ? msg.mapId.trim().toLowerCase() : ""; 13789 const scope = normalizeMapChatScope(msg.scope || "global"); 13790 const messages = Array.isArray(msg.messages) ? msg.messages : []; 13791 if (mapId && scope === "global") { 13792 mapsChatGlobalByMapId.set( 13793 mapId, 13794 messages 13795 .map((m) => ({ 13796 id: String(m?.id || ""), 13797 fromUser: String(m?.fromUser || m?.username || ""), 13798 text: String(m?.text || ""), 13799 createdAt: Number(m?.createdAt || 0) || Date.now(), 13800 })) 13801 .filter((m) => m.id && m.fromUser && m.text) 13802 .slice(-240) 13803 ); 13804 if (isMapChatActive()) renderChatPanel(false); 13805 } 13806 return; 13807 } 13808 13809 if (msg.type === "plugin:maps:chatMessage") { 13810 const mapId = typeof msg.mapId === "string" ? msg.mapId.trim().toLowerCase() : ""; 13811 const scope = normalizeMapChatScope(msg.scope || "local"); 13812 const m = msg.message && typeof msg.message === "object" ? msg.message : null; 13813 if (mapId && m) { 13814 pushMapChatMessage(mapId, scope, { 13815 id: String(m.id || ""), 13816 fromUser: String(m.fromUser || m.username || ""), 13817 text: String(m.text || ""), 13818 createdAt: Number(m.createdAt || 0) || Date.now(), 13819 }); 13820 if (isMapChatActive()) renderChatPanel(false); 13821 } 13822 return; 13823 } 13824 13825 if (msg.type === "streamState") { 13826 const postId = String(msg.postId || "").trim(); 13827 if (!postId) return; 13828 const live = Boolean(msg.live); 13829 streamLiveByPostId.set(postId, live); 13830 const post = posts.get(postId); 13831 if (post) { 13832 post.streamLive = live; 13833 post.streamKind = normalizeStreamKind(msg.kind || post.streamKind || "webcam"); 13834 post.streamHost = String(msg.host || ""); 13835 post.streamHostClientId = String(msg.hostClientId || ""); 13836 post.streamViewerCount = Math.max(0, Number(msg.viewerCount || 0) || 0); 13837 } 13838 if (live && streamCurrentRole === "viewer" && streamCurrentPostId === postId && !streamCurrentHostClientId) { 13839 streamCurrentHostClientId = String(msg.hostClientId || ""); 13840 streamRemoteHostClientId = streamCurrentHostClientId; 13841 } 13842 if (!live && streamCurrentPostId === postId && streamCurrentRole !== "idle") { 13843 leaveActiveStream(false); 13844 } 13845 renderFeed(); 13846 if (activeChatPostId === postId || streamCurrentPostId === postId) renderChatPanel(false); 13847 return; 13848 } 13849 13850 if (msg.type === "streamEnded") { 13851 const postId = String(msg.postId || "").trim(); 13852 if (!postId) return; 13853 streamLiveByPostId.set(postId, false); 13854 const post = posts.get(postId); 13855 if (post) post.streamLive = false; 13856 if (streamCurrentPostId === postId && streamCurrentRole !== "idle") { 13857 leaveActiveStream(false); 13858 if (activeChatPostId === postId) toast("Stream", "Live stream ended."); 13859 } 13860 renderFeed(); 13861 if (activeChatPostId === postId) renderChatPanel(false); 13862 return; 13863 } 13864 13865 if (msg.type === "streamJoinAck") { 13866 const postId = String(msg.postId || "").trim(); 13867 if (!postId || streamCurrentRole !== "viewer" || streamCurrentPostId !== postId) return; 13868 if (!Boolean(msg.live)) { 13869 leaveActiveStream(false); 13870 toast("Stream", "This stream is offline."); 13871 renderChatPanel(false); 13872 return; 13873 } 13874 streamCurrentHostClientId = String(msg.hostClientId || ""); 13875 streamRemoteHostClientId = streamCurrentHostClientId; 13876 streamRemoteKind = normalizeStreamKind(msg.kind || "webcam"); 13877 const peers = Array.isArray(msg.peerClientIds) ? msg.peerClientIds : []; 13878 const peerUsers = msg.peerUsernames && typeof msg.peerUsernames === "object" ? msg.peerUsernames : {}; 13879 for (const peerIdRaw of peers) { 13880 const peerId = String(peerIdRaw || "").trim(); 13881 if (!peerId || peerId === String(clientId || "")) continue; 13882 const peerName = String(peerUsers[peerId] || "").trim(); 13883 if (peerName) streamPeerUsernameByClientId.set(peerId, peerName); 13884 handleStreamPeerJoinMessage({ postId, peerClientId: peerId, peerUsername: peerName, initiateOffer: true }); 13885 } 13886 renderChatPanel(false); 13887 return; 13888 } 13889 13890 if (msg.type === "streamPeerJoin" || msg.type === "streamViewerJoin") { 13891 handleStreamPeerJoinMessage(msg); 13892 return; 13893 } 13894 13895 if (msg.type === "streamPeerLeave" || msg.type === "streamViewerLeave") { 13896 const postId = String(msg.postId || "").trim(); 13897 const peerClientId = String(msg.peerClientId || msg.viewerClientId || "").trim(); 13898 if (!postId || !peerClientId) return; 13899 if (streamCurrentPostId === postId) { 13900 closeStreamPeer(peerClientId); 13901 renderStreamVoiceUsers(); 13902 } 13903 return; 13904 } 13905 13906 if (msg.type === "streamSignal") { 13907 handleStreamSignalMessage(msg); 13908 return; 13909 } 13910 13911 if (msg.type === "collectionsUpdated") { 13912 const prevView = activeHiveView; 13913 collections = normalizeCollections(msg.collections); 13914 renderCollectionSelect(); 13915 ensureActiveCollectionView(); 13916 if (activeHiveView !== prevView) renderFeed(); 13917 renderModPanel(); 13918 return; 13919 } 13920 13921 if (msg.type === "instanceUpdated" && msg.instance && typeof msg.instance === "object") { 13922 instanceBranding = normalizeInstanceBranding(msg.instance); 13923 onboardingState = normalizeOnboardingState(onboardingState); 13924 if (modTab === "onboarding") syncOnboardingAdminDraft(true); 13925 renderInstanceBranding(); 13926 applyInstanceAppearance(); 13927 setAuthUi(); 13928 return; 13929 } 13930 13931 if (msg.type === "instanceOk" && msg.instance && typeof msg.instance === "object") { 13932 instanceBranding = normalizeInstanceBranding(msg.instance); 13933 onboardingState = normalizeOnboardingState(onboardingState); 13934 if (modTab === "onboarding") syncOnboardingAdminDraft(true); 13935 renderInstanceBranding(); 13936 applyInstanceAppearance(); 13937 setAuthUi(); 13938 toast("Instance", "Saved."); 13939 return; 13940 } 13941 13942 if (msg.type === "postsSnapshot") { 13943 streamLiveByPostId.clear(); 13944 posts.clear(); 13945 for (const post of Array.isArray(msg.posts) ? msg.posts : []) { 13946 posts.set(post.id, post); 13947 if (post && typeof post.id === "string") streamLiveByPostId.set(post.id, Boolean(post.streamLive)); 13948 } 13949 if (activeChatPostId && !posts.has(activeChatPostId)) { 13950 activeChatPostId = null; 13951 } 13952 renderFeed(); 13953 renderChatPanel(); 13954 return; 13955 } 13956 13957 if (msg.type === "boardReset") { 13958 posts.clear(); 13959 streamLiveByPostId.clear(); 13960 chatByPost.clear(); 13961 unreadByPostId.clear(); 13962 typingUsersByPostId.clear(); 13963 newPostAnimIds.clear(); 13964 if (buzzTimers.size) { 13965 for (const t of buzzTimers.values()) clearTimeout(t); 13966 buzzTimers.clear(); 13967 } 13968 activeChatPostId = null; 13969 renderFeed(); 13970 renderChatPanel(true); 13971 renderTypingIndicator(); 13972 renderModPanel(); 13973 if (canModerate) requestModData(); 13974 toast("Board reset", "All hives, reports, and logs were cleared."); 13975 return; 13976 } 13977 13978 if (msg.type === "rolesUpdated") { 13979 customRoles = normalizeRoleDefs(msg.roles); 13980 renderPeoplePanel(); 13981 renderModPanel(); 13982 return; 13983 } 13984 13985 if (msg.type === "pluginsUpdated") { 13986 setPlugins(msg.plugins); 13987 return; 13988 } 13989 13990 if (msg.type === "profilesUpdated" && msg.profiles && typeof msg.profiles === "object") { 13991 const nextProfiles = msg.profiles; 13992 const nextKeys = Object.keys(nextProfiles); 13993 const currentKeys = Object.keys(profiles || {}); 13994 if (nextKeys.length === 0 && currentKeys.length > 0) { 13995 return; 13996 } 13997 profiles = nextProfiles; 13998 setAuthUi(); 13999 renderFeed(); 14000 renderChatPanel(); 14001 renderPeoplePanel(); 14002 if (centerView === "profile") renderCenterPanels(); 14003 return; 14004 } 14005 14006 if (msg.type === "userProfile" && msg.profile) { 14007 const profile = normalizeProfileData(msg.profile); 14008 if (!profile.username) return; 14009 if (activeProfileUsername && profile.username !== activeProfileUsername) return; 14010 activeProfile = profile; 14011 setCenterView("profile", profile.username); 14012 return; 14013 } 14014 14015 if (msg.type === "userProfileUpdated" && msg.profile) { 14016 const profile = normalizeProfileData(msg.profile); 14017 if (!profile.username) return; 14018 if (centerView === "profile" && activeProfileUsername === profile.username) { 14019 activeProfile = profile; 14020 renderCenterPanels(); 14021 } 14022 return; 14023 } 14024 14025 if (msg.type === "newPost" && msg.post) { 14026 const isNewId = !posts.has(msg.post.id); 14027 posts.set(msg.post.id, msg.post); 14028 streamLiveByPostId.set(msg.post.id, Boolean(msg.post.streamLive)); 14029 renderFeed(); 14030 if (isNewId) { 14031 newPostAnimIds.add(msg.post.id); 14032 setTimeout(() => { 14033 newPostAnimIds.delete(msg.post.id); 14034 renderFeed(); 14035 }, 950); 14036 } 14037 const author = msg.post.author || ""; 14038 const title = postTitle(msg.post); 14039 const authorLower = String(author || "").toLowerCase(); 14040 const selfLower = String(loggedInUser || "").toLowerCase(); 14041 const ignoreUserSet = new Set( 14042 [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase()) 14043 ); 14044 if (author && loggedInUser && author === loggedInUser) { 14045 playSfx("post", { volume: 0.36 }); 14046 } 14047 if (author && author !== loggedInUser && readNotifNewHivePref() && !(authorLower && authorLower !== selfLower && ignoreUserSet.has(authorLower))) { 14048 if (readNotifSoundPref()) playSfx("notif", { volume: 0.5 }); 14049 if (!windowFocused || document.hidden) { 14050 maybeNotify(`Bzl: ${title}`, `New post by @${author}`, { postId: msg.post.id }); 14051 } else { 14052 toast("New post", `${author ? `@${author}: ` : ""}${title}`); 14053 } 14054 } 14055 return; 14056 } 14057 14058 if (msg.type === "postUpdated" && msg.post) { 14059 posts.set(msg.post.id, msg.post); 14060 streamLiveByPostId.set(msg.post.id, Boolean(msg.post.streamLive)); 14061 renderFeed(); 14062 renderChatPanel(); 14063 return; 14064 } 14065 14066 if (msg.type === "deletePost") { 14067 if (userPrefs?.starredPostIds) userPrefs.starredPostIds = userPrefs.starredPostIds.filter((id) => id !== msg.id); 14068 if (userPrefs?.hiddenPostIds) userPrefs.hiddenPostIds = userPrefs.hiddenPostIds.filter((id) => id !== msg.id); 14069 if (streamCurrentPostId && String(streamCurrentPostId) === String(msg.id || "")) leaveActiveStream(false); 14070 posts.delete(msg.id); 14071 streamLiveByPostId.delete(msg.id); 14072 chatByPost.delete(msg.id); 14073 unreadByPostId.delete(msg.id); 14074 typingUsersByPostId.delete(msg.id); 14075 if (buzzTimers.has(msg.id)) { 14076 clearTimeout(buzzTimers.get(msg.id)); 14077 buzzTimers.delete(msg.id); 14078 } 14079 if (activeChatPostId === msg.id) activeChatPostId = null; 14080 renderFeed(); 14081 renderChatPanel(); 14082 renderTypingIndicator(); 14083 return; 14084 } 14085 14086 if (msg.type === "loginOk") { 14087 loggedInUser = msg.username || null; 14088 loggedInRole = typeof msg.role === "string" ? msg.role : "member"; 14089 canModerate = Boolean(msg.canModerate); 14090 onboardingState = normalizeOnboardingState(msg.onboarding || onboardingState); 14091 if (typeof msg.sessionToken === "string" && msg.sessionToken) setSessionToken(msg.sessionToken); 14092 const profile = msg.profile || {}; 14093 pendingProfileImage = typeof profile.image === "string" ? profile.image : ""; 14094 if (pendingProfileImage) { 14095 profilePreview.src = pendingProfileImage; 14096 profilePreview.classList.add("hasImg"); 14097 } else { 14098 profilePreview.removeAttribute("src"); 14099 profilePreview.classList.remove("hasImg"); 14100 } 14101 if (profile.color) nameColorInput.value = profile.color; 14102 setUserPrefs(msg.prefs || {}); 14103 authPass.value = ""; 14104 profileStatus.textContent = ""; 14105 setAuthUi(); 14106 renderFeed(); 14107 renderLanHint(); 14108 if (centerView === "profile" && activeProfileUsername === loggedInUser) { 14109 ws.send(JSON.stringify({ type: "getUserProfile", username: loggedInUser })); 14110 } else { 14111 renderCenterPanels(); 14112 } 14113 if (canModerate) requestModData(); 14114 if (rackLayoutEnabled) applyDockState(); 14115 updateLayoutPresetOptions(); 14116 renderOnboardingCard(); 14117 maybeAutoStartGuidedTour(); 14118 return; 14119 } 14120 14121 if (msg.type === "logoutOk") { 14122 const priorUser = loggedInUser; 14123 setSessionToken(""); 14124 leaveActiveStream(false); 14125 loggedInUser = null; 14126 loggedInRole = "member"; 14127 canModerate = false; 14128 onboardingState = normalizeOnboardingState({ acceptedRulesVersion: 0, acceptedAt: 0, needsAcceptance: false }); 14129 dmThreads = []; 14130 dmThreadsById = new Map(); 14131 dmMessagesByThreadId.clear(); 14132 activeDmThreadId = null; 14133 pendingOpenDmThreadId = ""; 14134 stopWalkieRecording(); 14135 streamLiveByPostId.clear(); 14136 lanUrls = []; 14137 modReports = []; 14138 modUsers = []; 14139 modLog = []; 14140 setUserPrefs({ starredPostIds: [], hiddenPostIds: [] }); 14141 activeHiveView = "all"; 14142 guidedTourAutoStartedForUser = ""; 14143 stopGuidedTour({ completed: false, seenUser: priorUser }); 14144 setAuthUi(); 14145 maybeAutoStartGuidedTour(); 14146 renderFeed(); 14147 renderLanHint(); 14148 renderPeoplePanel(); 14149 renderCenterPanels(); 14150 if (rackLayoutEnabled) applyDockState(); 14151 updateLayoutPresetOptions(); 14152 renderOnboardingCard(); 14153 return; 14154 } 14155 14156 if (msg.type === "authState") { 14157 if (!loggedInUser || msg.username !== loggedInUser) return; 14158 loggedInRole = typeof msg.role === "string" ? msg.role : loggedInRole; 14159 canModerate = Boolean(msg.canModerate); 14160 onboardingState = normalizeOnboardingState(msg.onboarding || onboardingState); 14161 if (!canModerate) lanUrls = []; 14162 if (msg.prefs && typeof msg.prefs === "object") setUserPrefs(msg.prefs); 14163 setAuthUi(); 14164 renderLanHint(); 14165 if (rackLayoutEnabled) applyDockState(); 14166 renderPeoplePanel(); 14167 if (canModerate) requestModData(); 14168 updateLayoutPresetOptions(); 14169 renderOnboardingCard(); 14170 return; 14171 } 14172 14173 if (msg.type === "onboardingState" && msg.onboarding && typeof msg.onboarding === "object") { 14174 onboardingState = normalizeOnboardingState(msg.onboarding); 14175 setAuthUi(); 14176 renderOnboardingCard(); 14177 return; 14178 } 14179 14180 if (msg.type === "sessionInvalid") { 14181 setSessionToken(""); 14182 setUserPrefs({ starredPostIds: [], hiddenPostIds: [] }); 14183 dmThreads = []; 14184 dmThreadsById = new Map(); 14185 dmMessagesByThreadId.clear(); 14186 activeDmThreadId = null; 14187 pendingOpenDmThreadId = ""; 14188 return; 14189 } 14190 14191 if (msg.type === "userPrefs") { 14192 setUserPrefs(msg.prefs || {}); 14193 renderFeed(); 14194 return; 14195 } 14196 14197 if (msg.type === "peopleSnapshot") { 14198 peopleMembers = Array.isArray(msg.members) ? msg.members : []; 14199 renderPeoplePanel(); 14200 return; 14201 } 14202 14203 if (msg.type === "dmSnapshot") { 14204 setDmThreads(Array.isArray(msg.threads) ? msg.threads : []); 14205 return; 14206 } 14207 14208 if (msg.type === "dmThreadOk" && msg.thread) { 14209 const t = normalizeDmThread(msg.thread); 14210 if (!t) return; 14211 upsertDmThread(t); 14212 if (pendingOpenDmThreadId && pendingOpenDmThreadId === t.id && String(t.status || "") === "active") { 14213 openDmThread(t.id); 14214 } 14215 return; 14216 } 14217 14218 if (msg.type === "dmThreadUpdated" && msg.thread) { 14219 const me = String(loggedInUser || "").trim().toLowerCase(); 14220 const a = msg.thread?.a ? normalizeDmThread(msg.thread.a) : null; 14221 const b = msg.thread?.b ? normalizeDmThread(msg.thread.b) : null; 14222 const mine = me ? [a, b].find((t) => t && String(t.other || "").toLowerCase() !== me) : a || b; 14223 if (mine) { 14224 upsertDmThread(mine); 14225 if (pendingOpenDmThreadId && pendingOpenDmThreadId === mine.id && String(mine.status || "") === "active") { 14226 openDmThread(mine.id); 14227 } 14228 if (activeDmThreadId && mine.id === activeDmThreadId) { 14229 const current = dmMessagesByThreadId.get(activeDmThreadId) || null; 14230 if (!current || current.length === 0) ws.send(JSON.stringify({ type: "dmHistory", threadId: activeDmThreadId })); 14231 } 14232 } 14233 return; 14234 } 14235 14236 if (msg.type === "dmHistory") { 14237 const threadId = String(msg.threadId || "").trim(); 14238 if (!threadId) return; 14239 const messages = Array.isArray(msg.messages) ? msg.messages.map(normalizeDmMessage).filter(Boolean) : []; 14240 dmMessagesByThreadId.set(threadId, messages); 14241 if (activeDmThreadId === threadId) renderChatPanel(true); 14242 return; 14243 } 14244 14245 if (msg.type === "dmMessage" && msg.threadId && msg.message) { 14246 const threadId = String(msg.threadId || "").trim(); 14247 const message = normalizeDmMessage(msg.message); 14248 if (!threadId || !message) return; 14249 const existing = dmMessagesByThreadId.get(threadId) || []; 14250 if (!existing.some((m) => m.id === message.id)) { 14251 existing.push(message); 14252 dmMessagesByThreadId.set(threadId, existing); 14253 } 14254 const sender = String(message.fromUser || ""); 14255 const isFromYou = Boolean(sender && loggedInUser && sender === loggedInUser); 14256 if (activeDmThreadId === threadId && windowFocused && !document.hidden) { 14257 if (!appendDmMessageToDom(threadId, message)) renderChatPanel(); 14258 pulseChatMessage(message.id); 14259 } else { 14260 if (!isFromYou) { 14261 const title = `DM from @${sender || "unknown"}`; 14262 const body = String(message.text || "").slice(0, 160) || "New message"; 14263 if (!windowFocused || document.hidden) maybeNotify(`Bzl: ${title}`, body, { threadId }); 14264 else toast("DM", `${sender ? `@${sender}: ` : ""}${body}`); 14265 playSfx("ping", { volume: 0.38 }); 14266 } 14267 renderPeoplePanel(); 14268 } 14269 return; 14270 } 14271 14272 if (msg.type === "dmModMessageReceived") { 14273 const threadId = String(msg.threadId || "").trim(); 14274 if (!threadId) return; 14275 if (!dmThreadsById.has(threadId) && ws?.readyState === WebSocket.OPEN) { 14276 pendingOpenDmThreadId = threadId; 14277 ws.send(JSON.stringify({ type: "dmList" })); 14278 } 14279 if (isMobileScreenMode()) { 14280 const layout = loadMobileLayout(); 14281 layout.active = "chat"; 14282 saveMobileLayout(layout); 14283 setMobileScreen("chat"); 14284 renderMobileNav(); 14285 } 14286 if (dmThreadsById.has(threadId)) openDmThread(threadId); 14287 toast("Moderator message", "Opened priority moderator DM."); 14288 return; 14289 } 14290 14291 if (msg.type === "lanInfo") { 14292 lanUrls = Array.isArray(msg.lanUrls) ? msg.lanUrls : []; 14293 renderLanHint(); 14294 return; 14295 } 14296 14297 if (msg.type === "loginError") { 14298 authHint.textContent = msg.message || "Login failed."; 14299 return; 14300 } 14301 14302 if (msg.type === "profileOk") { 14303 const profile = msg.profile || {}; 14304 pendingProfileImage = typeof profile.image === "string" ? profile.image : pendingProfileImage; 14305 if (pendingProfileImage) { 14306 profilePreview.src = pendingProfileImage; 14307 profilePreview.classList.add("hasImg"); 14308 } else { 14309 profilePreview.removeAttribute("src"); 14310 profilePreview.classList.remove("hasImg"); 14311 } 14312 if (profile.color) nameColorInput.value = profile.color; 14313 profileStatus.textContent = "Saved."; 14314 const normalized = normalizeProfileData(profile, loggedInUser || ""); 14315 if (loggedInUser && normalized.username === loggedInUser) { 14316 activeProfile = normalized; 14317 activeProfileUsername = loggedInUser; 14318 if (centerView === "profile") { 14319 isEditingProfile = false; 14320 if (profileEditToggleBtn) profileEditToggleBtn.textContent = "Edit profile"; 14321 renderCenterPanels(); 14322 } 14323 } 14324 return; 14325 } 14326 14327 if (msg.type === "error") { 14328 const m = msg.message || "Error"; 14329 authHint.textContent = m; 14330 profileStatus.textContent = m; 14331 toast("Error", m); 14332 return; 14333 } 14334 14335 if (msg.type === "rateLimited") { 14336 const m = msg.message || "Too many requests. Please wait and try again."; 14337 toast("Rate limit", m); 14338 return; 14339 } 14340 14341 if (msg.type === "permissionDenied") { 14342 const m = msg.message || "Permission denied."; 14343 if (/(owner|moderator) access required/i.test(m)) { 14344 pluginAdminStatus = m; 14345 pluginAdminBusy = false; 14346 pluginEnableInFlight.clear(); 14347 renderModPanel(); 14348 } 14349 toast("Moderation", m); 14350 return; 14351 } 14352 14353 if (msg.type === "collectionOk") { 14354 toast("Collections", "Collection created."); 14355 return; 14356 } 14357 14358 if (msg.type === "roleOk") { 14359 toast("Roles", "Role created."); 14360 return; 14361 } 14362 14363 if (msg.type === "pluginOk") { 14364 if (msg.uninstalled) pluginAdminStatus = "Plugin uninstalled."; 14365 else if (typeof msg.enabled === "boolean") pluginAdminStatus = msg.enabled ? "Plugin enabled." : "Plugin disabled."; 14366 else if (msg.reloaded) pluginAdminStatus = "Plugins reloaded."; 14367 else pluginAdminStatus = "Plugin updated."; 14368 pluginAdminBusy = false; 14369 if (msg.id) pluginEnableInFlight.delete(String(msg.id || "").trim().toLowerCase()); 14370 if (modTab === "server") renderModPanel(); 14371 return; 14372 } 14373 14374 if (msg.type === "postUnlocked") { 14375 const postId = msg.postId || ""; 14376 if (!postId || !msg.post) return; 14377 posts.set(postId, msg.post); 14378 streamLiveByPostId.set(postId, Boolean(msg.post.streamLive)); 14379 if (Array.isArray(msg.messages)) { 14380 chatByPost.set(postId, msg.messages); 14381 hydrateOwnRecentChatFromHistory(postId, msg.messages); 14382 } 14383 renderFeed(); 14384 renderChatPanel(); 14385 renderTypingIndicator(); 14386 if (pendingOpenChatAfterUnlock === postId) { 14387 pendingOpenChatAfterUnlock = null; 14388 openChat(postId); 14389 } else { 14390 toast("Unlocked", "You can view and chat in this post."); 14391 } 14392 return; 14393 } 14394 14395 if (msg.type === "chatHistory") { 14396 const history = Array.isArray(msg.messages) ? msg.messages : []; 14397 chatByPost.set(msg.postId, history); 14398 hydrateOwnRecentChatFromHistory(msg.postId, history); 14399 markRead(msg.postId); 14400 renderChatPanel(true); 14401 renderTypingIndicator(); 14402 renderChatInstancesForPost(msg.postId); 14403 return; 14404 } 14405 14406 if (msg.type === "modSnapshot") { 14407 if (Array.isArray(msg.reports)) modReports = msg.reports; 14408 if (Array.isArray(msg.users)) modUsers = msg.users; 14409 if (Array.isArray(msg.log)) modLog = msg.log; 14410 renderModPanel(); 14411 return; 14412 } 14413 14414 if (msg.type === "devLogSnapshot") { 14415 if (Array.isArray(msg.log)) devLog = msg.log; 14416 if (canModerate && modTab === "log" && modLogView === "dev") renderModPanel(); 14417 return; 14418 } 14419 14420 if (msg.type === "devLogAppended" && msg.entry) { 14421 devLog.unshift(msg.entry); 14422 if (devLog.length > 300) devLog.splice(300); 14423 if (canModerate && modTab === "log" && modLogView === "dev") renderModPanel(); 14424 return; 14425 } 14426 14427 if (msg.type === "modLogAppended" && msg.entry) { 14428 modLog.unshift(msg.entry); 14429 if (modLog.length > 200) modLog.splice(200); 14430 renderModPanel(); 14431 return; 14432 } 14433 14434 if (msg.type === "modActionApplied") { 14435 requestModData(); 14436 renderFeed(); 14437 renderChatPanel(); 14438 renderModPanel(); 14439 return; 14440 } 14441 14442 if (msg.type === "nukeOk") { 14443 toast( 14444 "NUKE complete", 14445 `Cleared ${Number(msg.deletedPosts || 0)} hives and deleted ${Number(msg.deletedUploads || 0)} uploads (kept ${Number(msg.keptUploads || 0)} profile files).` 14446 ); 14447 return; 14448 } 14449 14450 if (msg.type === "reportCreated" && msg.report) { 14451 if (canModerate) { 14452 const idx = modReports.findIndex((r) => r.id === msg.report.id); 14453 if (idx >= 0) modReports[idx] = msg.report; 14454 else modReports.unshift(msg.report); 14455 if (modReports.length > 200) modReports.splice(200); 14456 renderModPanel(); 14457 } else if (msg.report.reporter === loggedInUser) { 14458 toast("Report submitted", "Thanks. A moderator will review it."); 14459 } 14460 return; 14461 } 14462 14463 if (msg.type === "reportUpdated" && msg.report) { 14464 const idx = modReports.findIndex((r) => r.id === msg.report.id); 14465 if (idx >= 0) modReports[idx] = msg.report; 14466 else modReports.unshift(msg.report); 14467 if (modReports.length > 200) modReports.splice(200); 14468 renderModPanel(); 14469 return; 14470 } 14471 14472 if (msg.type === "reactionUpdated" && msg.targetType === "chat") { 14473 const postId = msg.postId || ""; 14474 const messageId = msg.messageId || ""; 14475 const reactions = msg.reactions && typeof msg.reactions === "object" ? msg.reactions : {}; 14476 const arr = chatByPost.get(postId) || []; 14477 const m = arr.find((x) => x && x.id === messageId); 14478 if (m) m.reactions = reactions; 14479 if (activeChatPostId === postId) renderChatPanel(); 14480 renderChatInstancesForPost(postId); 14481 return; 14482 } 14483 14484 if (msg.type === "typing") { 14485 const postId = msg.postId || ""; 14486 const username = msg.username || ""; 14487 if (!postId || !username) return; 14488 if (loggedInUser && username === loggedInUser) return; 14489 const ignoreUserSet = new Set( 14490 [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase()) 14491 ); 14492 const usernameLower = String(username || "").toLowerCase(); 14493 const selfLower = String(loggedInUser || "").toLowerCase(); 14494 if (usernameLower && usernameLower !== selfLower && ignoreUserSet.has(usernameLower)) return; 14495 const isTyping = Boolean(msg.isTyping); 14496 const set = typingUsersByPostId.get(postId) || new Set(); 14497 if (isTyping) set.add(username); 14498 else set.delete(username); 14499 if (set.size === 0) typingUsersByPostId.delete(postId); 14500 else typingUsersByPostId.set(postId, set); 14501 if (activeChatPostId === postId) renderTypingIndicator(); 14502 renderChatInstancesForPost(postId); 14503 return; 14504 } 14505 14506 if (msg.type === "chatMessage") { 14507 const arr = chatByPost.get(msg.postId) || []; 14508 arr.push(msg.message); 14509 if (arr.length > 200) arr.splice(0, arr.length - 200); 14510 chatByPost.set(msg.postId, arr); 14511 const sender = msg.message?.fromUser || ""; 14512 if (sender) { 14513 const set = typingUsersByPostId.get(msg.postId); 14514 if (set && set.has(sender)) { 14515 set.delete(sender); 14516 if (set.size === 0) typingUsersByPostId.delete(msg.postId); 14517 } 14518 } 14519 const isFromYou = Boolean(sender && loggedInUser && sender === loggedInUser); 14520 if (isFromYou) noteOwnRecentChat(msg.postId, msg.message?.createdAt); 14521 const senderLower = String(sender || "").toLowerCase(); 14522 const selfLower = String(loggedInUser || "").toLowerCase(); 14523 const ignoreUserSet = new Set( 14524 [...prefSet("ignoredUsers").values(), ...prefSet("blockedUsers").values()].map((u) => String(u).toLowerCase()) 14525 ); 14526 if (!isFromYou && senderLower && senderLower !== selfLower && ignoreUserSet.has(senderLower)) { 14527 if (activeChatPostId === msg.postId) renderChatPanel(); 14528 renderChatInstancesForPost(msg.postId); 14529 return; 14530 } 14531 const p = posts.get(msg.postId); 14532 const postOwnerLower = String(p?.author || "").toLowerCase(); 14533 const isOnYourHive = Boolean(selfLower && postOwnerLower && selfLower === postOwnerLower); 14534 const mentions = Array.isArray(msg.message?.mentions) ? msg.message.mentions.map((u) => String(u || "").toLowerCase()) : []; 14535 const mentionsYou = Boolean(selfLower && mentions.includes(selfLower) && !isFromYou); 14536 const replyToUserLower = String(msg.message?.replyTo?.fromUser || "").toLowerCase(); 14537 const repliesYou = Boolean(selfLower && replyToUserLower && replyToUserLower === selfLower && !isFromYou); 14538 const isRecentHiveChat = hasOwnRecentChat(msg.postId); 14539 const shouldAlertReplies = readNotifReplyPingPref() && (mentionsYou || repliesYou); 14540 const shouldAlertMyHive = readNotifMyHiveChatPref() && isOnYourHive; 14541 const shouldAlertRecent = readNotifRecentHiveChatPref() && isRecentHiveChat; 14542 const shouldAlert = Boolean(!isFromYou && (shouldAlertReplies || shouldAlertMyHive || shouldAlertRecent)); 14543 if (shouldAlert && readNotifSoundPref()) playSfx("notif", { volume: 0.5 }); 14544 const title = p ? postTitle(p) : "Chat"; 14545 const body = sender ? `@${sender}: ${msg.message?.text || ""}` : msg.message?.text || ""; 14546 const notifyTitle = mentionsYou || repliesYou ? `Bzl: Reply in ${title}` : `Bzl: ${title}`; 14547 const toastTitle = mentionsYou || repliesYou ? "Reply ping" : title; 14548 const toastBody = mentionsYou || repliesYou ? `@${sender} replied/mentioned you` : body.slice(0, 120); 14549 if (activeChatPostId === msg.postId && windowFocused && !document.hidden) { 14550 markRead(msg.postId); 14551 if (!appendPostChatMessageToDom(msg.postId, msg.message)) renderChatPanel(); 14552 pulseChatMessage(msg.message?.id); 14553 renderTypingIndicator(); 14554 if (shouldAlert) toast(toastTitle, toastBody); 14555 } else { 14556 if (!buzzTimers.has(msg.postId)) { 14557 const t = window.setTimeout(() => { 14558 buzzTimers.delete(msg.postId); 14559 renderFeed(); 14560 }, 750); 14561 buzzTimers.set(msg.postId, t); 14562 } else { 14563 clearTimeout(buzzTimers.get(msg.postId)); 14564 const t = window.setTimeout(() => { 14565 buzzTimers.delete(msg.postId); 14566 renderFeed(); 14567 }, 750); 14568 buzzTimers.set(msg.postId, t); 14569 } 14570 bumpUnread(msg.postId); 14571 renderFeed(); 14572 if (shouldAlert) { 14573 if (!windowFocused || document.hidden) { 14574 maybeNotify(notifyTitle, body.slice(0, 160), { postId: msg.postId }); 14575 } else { 14576 toast(toastTitle, toastBody); 14577 } 14578 } 14579 } 14580 renderChatInstancesForPost(msg.postId); 14581 } 14582 } 14583 14584 setConn("connecting"); 14585 connectWs(); 14586 initSplashSequence(); 14587 14588 renderLanHint(); 14589 writeHintsEnabledPref(readHintsEnabledPref()); 14590 initDisplayPrefsUi(); 14591 initAppearanceControls(); 14592 if (stayConnectedEl) { 14593 stayConnectedEl.checked = readStayConnectedPref(); 14594 stayConnectedEl.addEventListener("change", () => { 14595 const on = Boolean(stayConnectedEl.checked); 14596 writeStayConnectedPref(on); 14597 if (on) { 14598 if (!ws || ws.readyState === WebSocket.CLOSED) connectWs(); 14599 startWsKeepalive(ws); 14600 if (ws && ws.readyState === WebSocket.OPEN) startWsStaleWatchdog(ws); 14601 } else { 14602 clearWsReconnect(); 14603 clearWsKeepalive(); 14604 clearWsStaleWatchdog(); 14605 } 14606 }); 14607 } 14608 if (enableHintsEl) { 14609 enableHintsEl.checked = readHintsEnabledPref(); 14610 enableHintsEl.addEventListener("change", () => { 14611 writeHintsEnabledPref(Boolean(enableHintsEl.checked)); 14612 }); 14613 } 14614 if (notifSoundToggleEl) { 14615 notifSoundToggleEl.checked = readNotifSoundPref(); 14616 notifSoundToggleEl.addEventListener("change", () => { 14617 writeNotifSoundPref(Boolean(notifSoundToggleEl.checked)); 14618 updateNotifUi(); 14619 }); 14620 } 14621 if (notifNewHiveToggleEl) { 14622 notifNewHiveToggleEl.checked = readNotifNewHivePref(); 14623 notifNewHiveToggleEl.addEventListener("change", () => { 14624 writeNotifNewHivePref(Boolean(notifNewHiveToggleEl.checked)); 14625 updateNotifUi(); 14626 }); 14627 } 14628 if (notifReplyPingToggleEl) { 14629 notifReplyPingToggleEl.checked = readNotifReplyPingPref(); 14630 notifReplyPingToggleEl.addEventListener("change", () => { 14631 writeNotifReplyPingPref(Boolean(notifReplyPingToggleEl.checked)); 14632 updateNotifUi(); 14633 }); 14634 } 14635 if (notifMyHiveChatsToggleEl) { 14636 notifMyHiveChatsToggleEl.checked = readNotifMyHiveChatPref(); 14637 notifMyHiveChatsToggleEl.addEventListener("change", () => { 14638 writeNotifMyHiveChatPref(Boolean(notifMyHiveChatsToggleEl.checked)); 14639 updateNotifUi(); 14640 }); 14641 } 14642 if (notifRecentHiveChatsToggleEl) { 14643 notifRecentHiveChatsToggleEl.checked = readNotifRecentHiveChatPref(); 14644 notifRecentHiveChatsToggleEl.addEventListener("change", () => { 14645 writeNotifRecentHiveChatPref(Boolean(notifRecentHiveChatsToggleEl.checked)); 14646 updateNotifUi(); 14647 }); 14648 } 14649 if (chatEnterModeEl) { 14650 chatEnterModeEl.value = readChatEnterModePref(); 14651 chatEnterModeEl.addEventListener("change", () => { 14652 writeChatEnterModePref(chatEnterModeEl.value); 14653 }); 14654 } 14655 if (resetCurrentLayoutBtn) { 14656 resetCurrentLayoutBtn.addEventListener("click", () => { 14657 if (!rackLayoutEnabled) return; 14658 const currentPreset = String(rackLayoutState?.presetId || layoutPresetEl?.value || "defaultSocial"); 14659 applyPreset(currentPreset); 14660 toast("Layout", "Current preset layout reset."); 14661 }); 14662 } 14663 renderPeoplePanel(); 14664 setPeopleOpen(getPeopleOpen()); 14665 composerOpen = getComposerOpen(); 14666 setComposerOpen(composerOpen); 14667 applySidebarWidth(readStoredSidebarWidth(), false); 14668 applyChatWidth(readStoredChatWidth(), false); 14669 applyModWidth(readStoredModWidth(), false); 14670 applyPeopleWidth(readStoredPeopleWidth(), false); 14671 applyChatDock(); 14672 14673 if (toggleReactionsEl) { 14674 toggleReactionsEl.checked = showReactions; 14675 toggleReactionsEl.addEventListener("change", () => { 14676 showReactions = Boolean(toggleReactionsEl.checked); 14677 localStorage.setItem("bzl_showReactions", showReactions ? "1" : "0"); 14678 renderFeed(); 14679 renderChatPanel(); 14680 }); 14681 } 14682 14683 if (hivesViewModeEl) { 14684 const pref = readStringPref(HIVES_VIEW_MODE_KEY, "auto"); 14685 hivesViewModeEl.value = pref === "cards" || pref === "list" ? pref : "auto"; 14686 hivesViewModeEl.addEventListener("change", () => { 14687 const next = String(hivesViewModeEl.value || "auto").toLowerCase(); 14688 writeStringPref(HIVES_VIEW_MODE_KEY, next === "cards" || next === "list" ? next : "auto"); 14689 applyHivesViewMode(); 14690 }); 14691 } 14692 installHivesAutoViewMode(); 14693 applyHivesViewMode(); 14694 updateMobileSortCycleLabel(); 14695 14696 if (chatHeaderEl && appRoot) { 14697 chatHeaderEl.setAttribute("draggable", "true"); 14698 chatHeaderEl.title = "Drag left/right to dock chat"; 14699 chatHeaderEl.addEventListener("dragstart", (e) => { 14700 try { 14701 e.dataTransfer.effectAllowed = "move"; 14702 e.dataTransfer.setData("text/plain", "bzl:dock:chat"); 14703 } catch { 14704 // ignore 14705 } 14706 appRoot.classList.add("isDocking"); 14707 }); 14708 chatHeaderEl.addEventListener("dragend", () => { 14709 appRoot.classList.remove("isDocking"); 14710 }); 14711 appRoot.addEventListener("dragover", (e) => { 14712 if (!appRoot.classList.contains("isDocking")) return; 14713 e.preventDefault(); 14714 try { 14715 e.dataTransfer.dropEffect = "move"; 14716 } catch { 14717 // ignore 14718 } 14719 }); 14720 appRoot.addEventListener("drop", (e) => { 14721 if (!appRoot.classList.contains("isDocking")) return; 14722 e.preventDefault(); 14723 appRoot.classList.remove("isDocking"); 14724 const next = e.clientX > window.innerWidth * 0.58 ? "right" : "left"; 14725 if (next === chatDock) return; 14726 chatDock = next; 14727 localStorage.setItem("bzl_chatDock", chatDock); 14728 applyChatDock(); 14729 }); 14730 } 14731 14732 installDropUpload(editor, { allowImages: true, allowAudio: true }); 14733 installDropUpload(chatEditor, { allowImages: true, allowAudio: true }); 14734 installDropUpload(profileBioEditor, { allowImages: true, allowAudio: true }); 14735 installDropUpload(editModalEditor, { allowImages: true, allowAudio: true }); 14736 14737 mediaModal?.addEventListener("click", (e) => { 14738 if (e.target?.getAttribute?.("data-mediamodalclose")) setMediaModalOpen(false); 14739 }); 14740 mediaModalClose?.addEventListener("click", () => setMediaModalOpen(false)); 14741 mediaModalCopyLink?.addEventListener("click", async () => { 14742 const url = String(mediaModalOpenLink?.href || "").trim(); 14743 if (!url || url === "#") return; 14744 try { 14745 await navigator.clipboard.writeText(url); 14746 if (mediaModalStatus) mediaModalStatus.textContent = "Copied."; 14747 } catch { 14748 if (mediaModalStatus) mediaModalStatus.textContent = "Copy failed (clipboard blocked)."; 14749 } 14750 }); 14751 shortcutHelpModal?.addEventListener("click", (e) => { 14752 if (e.target?.getAttribute?.("data-shortcutclose")) setShortcutHelpOpen(false); 14753 }); 14754 shortcutHelpCloseBtn?.addEventListener("click", () => setShortcutHelpOpen(false)); 14755 openShortcutHelpBtn?.addEventListener("click", () => setShortcutHelpOpen(true)); 14756 document.addEventListener("keydown", (e) => { 14757 if (e.key !== "Escape") return; 14758 if (mediaModal && !mediaModal.classList.contains("hidden")) { 14759 setMediaModalOpen(false); 14760 return; 14761 } 14762 if (shortcutHelpModal && !shortcutHelpModal.classList.contains("hidden")) setShortcutHelpOpen(false); 14763 }); 14764 document.body.addEventListener("click", (e) => { 14765 const img = e.target?.closest?.("img"); 14766 if (!img) return; 14767 if (img.id === "profilePreview") return; 14768 if (img.closest("#mediaModal")) return; 14769 const inAllowed = 14770 img.closest(".chatMsg .content") || 14771 img.closest(".profileBio") || 14772 img.closest(".profileCard") || 14773 img.closest(".editor") || 14774 img.closest("#editModalEditor"); 14775 if (!inAllowed) return; 14776 const src = img.getAttribute("src") || ""; 14777 if (!src) return; 14778 openMediaModal(src); 14779 }); 14780 14781 setSidebarHidden(getSidebarHidden()); 14782 toggleSidebarBtn?.addEventListener("click", () => setSidebarHidden(true)); 14783 showSidebarBtn?.addEventListener("click", () => setSidebarHidden(false)); 14784 togglePeopleBtn?.addEventListener("click", () => setPeopleOpen(!peopleOpen)); 14785 closePeopleBtn?.addEventListener("click", () => setPeopleOpen(false)); 14786 peopleMembersTabBtn?.addEventListener("click", () => { 14787 peopleTab = "members"; 14788 renderPeoplePanel(); 14789 }); 14790 peopleDmsTabBtn?.addEventListener("click", () => { 14791 peopleTab = "dms"; 14792 renderPeoplePanel(); 14793 }); 14794 peopleSearchEl?.addEventListener("input", () => renderPeoplePanel()); 14795 peopleListEl?.addEventListener("click", (e) => { 14796 const modDmBtn = e.target.closest("button[data-moddm]"); 14797 if (modDmBtn) { 14798 sendModDmPrompt(modDmBtn.getAttribute("data-moddm") || ""); 14799 return; 14800 } 14801 const dmBtn = e.target.closest("button[data-dmrequest]"); 14802 if (dmBtn) { 14803 const to = String(dmBtn.getAttribute("data-dmrequest") || "") 14804 .trim() 14805 .replace(/^@+/, "") 14806 .toLowerCase(); 14807 if (!to) return; 14808 if (!loggedInUser) { 14809 toast("Sign in required", "Sign in to start a DM."); 14810 return; 14811 } 14812 if (to === String(loggedInUser).toLowerCase()) return; 14813 ws.send(JSON.stringify({ type: "dmRequestCreate", to })); 14814 peopleTab = "dms"; 14815 renderPeoplePanel(); 14816 return; 14817 } 14818 const btn = e.target.closest("[data-viewprofile]"); 14819 if (!btn) return; 14820 const username = btn.getAttribute("data-viewprofile") || ""; 14821 openUserProfile(username); 14822 }); 14823 14824 peopleDmsViewEl?.addEventListener("click", (e) => { 14825 const modDmBtn = e.target.closest("button[data-moddm]"); 14826 if (modDmBtn) { 14827 sendModDmPrompt(modDmBtn.getAttribute("data-moddm") || ""); 14828 return; 14829 } 14830 const profileLink = e.target.closest("[data-viewprofile]"); 14831 if (profileLink) { 14832 const username = profileLink.getAttribute("data-viewprofile") || ""; 14833 if (username) openUserProfile(username); 14834 return; 14835 } 14836 14837 const openBtn = e.target.closest("button[data-dmopen]"); 14838 if (openBtn) { 14839 const threadId = openBtn.getAttribute("data-dmopen") || ""; 14840 if (!threadId) return; 14841 openDmThread(threadId); 14842 return; 14843 } 14844 14845 const acceptBtn = e.target.closest("button[data-dmaccept]"); 14846 if (acceptBtn) { 14847 const threadId = acceptBtn.getAttribute("data-dmaccept") || ""; 14848 if (!threadId) return; 14849 pendingOpenDmThreadId = threadId; 14850 ws.send(JSON.stringify({ type: "dmRequestRespond", threadId, accept: true })); 14851 return; 14852 } 14853 14854 const declineBtn = e.target.closest("button[data-dmdecline]"); 14855 if (declineBtn) { 14856 const threadId = declineBtn.getAttribute("data-dmdecline") || ""; 14857 if (!threadId) return; 14858 ws.send(JSON.stringify({ type: "dmRequestRespond", threadId, accept: false })); 14859 return; 14860 } 14861 14862 const requestAgainBtn = e.target.closest("button[data-dmrequest]"); 14863 if (requestAgainBtn) { 14864 const to = String(requestAgainBtn.getAttribute("data-dmrequest") || "") 14865 .trim() 14866 .replace(/^@+/, "") 14867 .toLowerCase(); 14868 if (!to || !loggedInUser) return; 14869 if (to === String(loggedInUser).toLowerCase()) return; 14870 ws.send(JSON.stringify({ type: "dmRequestCreate", to })); 14871 return; 14872 } 14873 14874 const requestFromSelectBtn = e.target.closest("button[data-dmrequestfromselect]"); 14875 if (requestFromSelectBtn) { 14876 const sel = peopleDmsViewEl.querySelector("select[data-dmto]"); 14877 const to = String(sel?.value || "") 14878 .trim() 14879 .replace(/^@+/, "") 14880 .toLowerCase(); 14881 if (!to) return; 14882 if (!loggedInUser) { 14883 toast("Sign in required", "Sign in to start a DM."); 14884 return; 14885 } 14886 if (to === String(loggedInUser).toLowerCase()) return; 14887 ws.send(JSON.stringify({ type: "dmRequestCreate", to })); 14888 if (sel) sel.value = ""; 14889 return; 14890 } 14891 }); 14892 14893 onboardingAcceptBtn?.addEventListener("click", () => { 14894 if (!loggedInUser) { 14895 toast("Sign in required", "Sign in to accept server rules."); 14896 return; 14897 } 14898 ws.send(JSON.stringify({ type: "onboardingAcceptRules" })); 14899 }); 14900 14901 onboardingRefreshBtn?.addEventListener("click", () => { 14902 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" })); 14903 }); 14904 14905 onboardingPanelAcceptBtn?.addEventListener("click", () => { 14906 if (!loggedInUser) { 14907 toast("Sign in required", "Sign in to accept server rules."); 14908 return; 14909 } 14910 ws.send(JSON.stringify({ type: "onboardingAcceptRules" })); 14911 }); 14912 14913 onboardingPanelRefreshBtn?.addEventListener("click", () => { 14914 if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "onboardingGet" })); 14915 }); 14916 14917 onboardingPanelBodyEl?.addEventListener("click", (e) => { 14918 const tabBtn = e.target.closest?.("button[data-onbtab]"); 14919 if (!tabBtn) return; 14920 const tab = String(tabBtn.getAttribute("data-onbtab") || "about").trim(); 14921 if (!["about", "rules", "roles"].includes(tab)) return; 14922 onboardingViewerTab = tab; 14923 renderOnboardingPanel(); 14924 }); 14925 14926 profileCard?.addEventListener("click", (e) => { 14927 const modDmBtn = e.target.closest("button[data-moddm]"); 14928 if (modDmBtn) { 14929 sendModDmPrompt(modDmBtn.getAttribute("data-moddm") || ""); 14930 return; 14931 } 14932 const dmBtn = e.target.closest("button[data-dmrequest]"); 14933 if (!dmBtn) return; 14934 const to = String(dmBtn.getAttribute("data-dmrequest") || "") 14935 .trim() 14936 .replace(/^@+/, "") 14937 .toLowerCase(); 14938 if (!to) return; 14939 if (!loggedInUser) { 14940 toast("Sign in required", "Sign in to start a DM."); 14941 return; 14942 } 14943 if (to === String(loggedInUser).toLowerCase()) return; 14944 ws.send(JSON.stringify({ type: "dmRequestCreate", to })); 14945 peopleTab = "dms"; 14946 setPeopleOpen(true); 14947 renderPeoplePanel(); 14948 }); 14949 profileCard?.addEventListener("click", (e) => { 14950 const ignoreBtn = e.target.closest("button[data-ignoreuser],button[data-unignoreuser],button[data-blockuser],button[data-unblockuser]"); 14951 if (!ignoreBtn) return; 14952 const raw = 14953 ignoreBtn.getAttribute("data-ignoreuser") || 14954 ignoreBtn.getAttribute("data-unignoreuser") || 14955 ignoreBtn.getAttribute("data-blockuser") || 14956 ignoreBtn.getAttribute("data-unblockuser") || 14957 ""; 14958 const username = String(raw).trim().replace(/^@+/, "").toLowerCase(); 14959 if (!username || !loggedInUser) return; 14960 if (username === String(loggedInUser).toLowerCase()) return; 14961 if (ignoreBtn.hasAttribute("data-ignoreuser")) ws.send(JSON.stringify({ type: "ignoreUser", username })); 14962 else if (ignoreBtn.hasAttribute("data-unignoreuser")) ws.send(JSON.stringify({ type: "unignoreUser", username })); 14963 else if (ignoreBtn.hasAttribute("data-blockuser")) ws.send(JSON.stringify({ type: "blockUser", username })); 14964 else if (ignoreBtn.hasAttribute("data-unblockuser")) ws.send(JSON.stringify({ type: "unblockUser", username })); 14965 }); 14966 chatResizeHandle?.addEventListener("mousedown", (e) => { 14967 e.preventDefault(); 14968 startChatResize(e.clientX); 14969 }); 14970 chatResizeHandle?.addEventListener("dblclick", () => applyChatWidth(CHAT_WIDTH_DEFAULT)); 14971 sidebarResizeHandle?.addEventListener("mousedown", (e) => { 14972 e.preventDefault(); 14973 startSidebarResize(e.clientX); 14974 }); 14975 sidebarResizeHandle?.addEventListener("dblclick", () => applySidebarWidth(SIDEBAR_WIDTH_DEFAULT)); 14976 mainResizeHandle?.addEventListener("mousedown", (e) => { 14977 e.preventDefault(); 14978 startModResize(e.clientX); 14979 }); 14980 mainResizeHandle?.addEventListener("dblclick", () => applyModWidth(MOD_WIDTH_DEFAULT)); 14981 peopleResizeHandle?.addEventListener("mousedown", (e) => { 14982 e.preventDefault(); 14983 startPeopleResize(e.clientX); 14984 }); 14985 peopleResizeHandle?.addEventListener("dblclick", () => applyPeopleWidth(PEOPLE_WIDTH_DEFAULT)); 14986 sidebarPanelEl?.addEventListener("mousedown", (e) => { 14987 if (e.button !== 0 || isMobileSwipeMode()) return; 14988 const rect = sidebarPanelEl.getBoundingClientRect(); 14989 if (Math.abs(e.clientX - rect.right) > 12) return; 14990 e.preventDefault(); 14991 startSidebarResize(e.clientX); 14992 }); 14993 chatPanelEl?.addEventListener("mousedown", (e) => { 14994 if (e.button !== 0 || isMobileSwipeMode()) return; 14995 const rect = chatPanelEl.getBoundingClientRect(); 14996 if (Math.abs(e.clientX - rect.right) > 12) return; 14997 e.preventDefault(); 14998 startChatResize(e.clientX); 14999 }); 15000 modPanelEl?.addEventListener("mousedown", (e) => { 15001 if (e.button !== 0 || isMobileSwipeMode() || modPanelEl.classList.contains("hidden")) return; 15002 const rect = modPanelEl.getBoundingClientRect(); 15003 if (Math.abs(e.clientX - rect.left) > 12) return; 15004 e.preventDefault(); 15005 startModResize(e.clientX); 15006 }); 15007 peopleDrawerEl?.addEventListener("mousedown", (e) => { 15008 if (e.button !== 0 || isMobileSwipeMode() || peopleDrawerEl.classList.contains("hidden")) return; 15009 const rect = peopleDrawerEl.getBoundingClientRect(); 15010 if (Math.abs(e.clientX - rect.left) > 12) return; 15011 e.preventDefault(); 15012 startPeopleResize(e.clientX); 15013 }); 15014 mobileNavEl?.addEventListener("click", (e) => { 15015 const btn = e.target.closest("[data-mobilescreen]"); 15016 if (!btn) return; 15017 const id = String(btn.getAttribute("data-mobilescreen") || "").trim(); 15018 if (!id) return; 15019 if (id === "more") { 15020 renderMobileMoreList(); 15021 setMobileMoreOpen(true); 15022 return; 15023 } 15024 const layout = loadMobileLayout(); 15025 layout.active = id; 15026 saveMobileLayout(layout); 15027 setMobileScreen(id); 15028 renderMobileNav(); 15029 }); 15030 15031 function renderMobileMoreList() { 15032 if (!(mobileMoreListEl instanceof HTMLElement)) return; 15033 const q = String(mobileMoreSearchEl?.value || "").trim().toLowerCase(); 15034 const { core, plugins } = availableMobileScreens(); 15035 15036 const filter = (item) => { 15037 if (!q) return true; 15038 return String(item.title || "").toLowerCase().includes(q) || String(item.id || "").toLowerCase().includes(q); 15039 }; 15040 15041 const section = (title, items) => { 15042 const wrap = document.createElement("div"); 15043 const head = document.createElement("div"); 15044 head.className = "muted small"; 15045 head.textContent = title; 15046 head.style.margin = "6px 0 6px 2px"; 15047 wrap.appendChild(head); 15048 const list = document.createElement("div"); 15049 list.style.display = "flex"; 15050 list.style.flexDirection = "column"; 15051 list.style.gap = "10px"; 15052 for (const it of items.filter(filter)) { 15053 const row = document.createElement("button"); 15054 row.type = "button"; 15055 row.className = "mobileMoreItem"; 15056 row.innerHTML = `<span>${escapeHtml(it.title || it.id)}</span><span class="muted small">${escapeHtml(it.core ? "core" : "plugin")}</span>`; 15057 row.onclick = () => { 15058 const layout = loadMobileLayout(); 15059 layout.active = it.id; 15060 saveMobileLayout(layout); 15061 setMobileScreen(it.id); 15062 renderMobileNav(); 15063 setMobileMoreOpen(false); 15064 }; 15065 list.appendChild(row); 15066 } 15067 wrap.appendChild(list); 15068 return wrap; 15069 }; 15070 15071 mobileMoreListEl.innerHTML = ""; 15072 mobileMoreListEl.appendChild(section("Core", core)); 15073 if (plugins.length) mobileMoreListEl.appendChild(section("Plugins", plugins)); 15074 } 15075 15076 mobileMoreSearchEl?.addEventListener("input", () => { 15077 if (!mobileMoreOpen) return; 15078 renderMobileMoreList(); 15079 }); 15080 15081 mobileMoreCloseBtn?.addEventListener("click", () => setMobileMoreOpen(false)); 15082 mobileMoreSheetEl?.addEventListener("click", (e) => { 15083 const target = e.target; 15084 if (!target) return; 15085 if (target.closest?.("[data-mobilemoreclose]")) setMobileMoreOpen(false); 15086 }); 15087 15088 streamStagePrimaryBtn?.addEventListener("click", () => { 15089 const post = streamStageCurrentPost(); 15090 if (!post) return; 15091 const postId = String(post.id || ""); 15092 if (!postId) return; 15093 if (streamCurrentRole === "host" && streamCurrentPostId === postId) { 15094 leaveActiveStream(true); 15095 renderChatPanel(false); 15096 return; 15097 } 15098 if (streamCurrentRole === "viewer" && streamCurrentPostId === postId) { 15099 leaveActiveStream(true); 15100 renderChatPanel(false); 15101 return; 15102 } 15103 const live = Boolean(streamLiveByPostId.get(postId) ?? post.streamLive); 15104 if (live) joinStream(post); 15105 else startStreamHost(post); 15106 }); 15107 15108 streamVoiceJoinToggleEl?.addEventListener("change", async () => { 15109 const enabled = Boolean(streamVoiceJoinToggleEl.checked); 15110 if (streamCurrentRole !== "viewer") { 15111 streamVoiceJoinToggleEl.checked = streamCurrentRole === "host"; 15112 return; 15113 } 15114 if (enabled) { 15115 const ok = await enableStreamVoice(); 15116 if (!ok) streamVoiceJoinToggleEl.checked = false; 15117 } else { 15118 disableStreamVoice(); 15119 streamVoiceJoinToggleEl.checked = false; 15120 } 15121 renderChatPanel(false); 15122 }); 15123 15124 streamVoiceMuteBtn?.addEventListener("click", () => { 15125 if (!(streamCurrentRole === "host" || streamVoiceJoined)) return; 15126 streamVoiceMuted = !streamVoiceMuted; 15127 updateStreamLocalMuteState(); 15128 renderChatPanel(false); 15129 }); 15130 15131 streamVoiceDeafenBtn?.addEventListener("click", () => { 15132 if (!(streamCurrentRole === "host" || streamCurrentRole === "viewer")) return; 15133 streamVoiceDeafened = !streamVoiceDeafened; 15134 updateStreamOutputMuteState(); 15135 renderChatPanel(false); 15136 }); 15137 15138 streamVoiceUsersEl?.addEventListener("input", (e) => { 15139 const input = e.target; 15140 if (!(input instanceof HTMLInputElement)) return; 15141 const peerId = String(input.getAttribute("data-streamvol") || "").trim(); 15142 if (!peerId) return; 15143 const volPct = Math.max(0, Math.min(100, Number(input.value) || 0)); 15144 const vol = volPct / 100; 15145 streamPeerVolumeByClientId.set(peerId, vol); 15146 const audioEl = streamRemoteAudioByClientId.get(peerId); 15147 if (audioEl) audioEl.volume = vol; 15148 }); 15149 15150 walkieRecordBtn?.addEventListener("pointerdown", (e) => { 15151 e.preventDefault(); 15152 startWalkieRecording(); 15153 }); 15154 walkieRecordBtn?.addEventListener("pointerup", (e) => { 15155 e.preventDefault(); 15156 stopWalkieRecording(); 15157 }); 15158 walkieRecordBtn?.addEventListener("pointerleave", () => stopWalkieRecording()); 15159 walkieRecordBtn?.addEventListener("mousedown", (e) => { 15160 e.preventDefault(); 15161 startWalkieRecording(); 15162 }); 15163 walkieRecordBtn?.addEventListener("mouseup", (e) => { 15164 e.preventDefault(); 15165 stopWalkieRecording(); 15166 }); 15167 walkieRecordBtn?.addEventListener( 15168 "touchstart", 15169 (e) => { 15170 e.preventDefault(); 15171 startWalkieRecording(); 15172 }, 15173 { passive: false } 15174 ); 15175 walkieRecordBtn?.addEventListener( 15176 "touchend", 15177 (e) => { 15178 e.preventDefault(); 15179 stopWalkieRecording(); 15180 }, 15181 { passive: false } 15182 ); 15183 15184 window.addEventListener("keydown", (e) => { 15185 if (!shouldHandleWalkieHotkey(e)) return; 15186 if (!canWalkieTalkNow()) return; 15187 e.preventDefault(); 15188 startWalkieRecording(); 15189 }); 15190 window.addEventListener("keyup", (e) => { 15191 if (!shouldHandleWalkieHotkey(e)) return; 15192 if (!canWalkieTalkNow()) return; 15193 e.preventDefault(); 15194 stopWalkieRecording(); 15195 }); 15196 window.addEventListener("pointerup", () => stopWalkieRecording()); 15197 window.addEventListener("mouseup", () => stopWalkieRecording()); 15198 window.addEventListener("mousemove", (e) => { 15199 if (chatResizeDragging) { 15200 const next = chatResizeStartWidth + (e.clientX - chatResizeStartX); 15201 applyChatWidth(next, false); 15202 return; 15203 } 15204 if (sidebarResizeDragging) { 15205 const next = sidebarResizeStartWidth + (e.clientX - sidebarResizeStartX); 15206 applySidebarWidth(next, false); 15207 return; 15208 } 15209 if (modResizeDragging) { 15210 const next = modResizeStartWidth - (e.clientX - modResizeStartX); 15211 applyModWidth(next, false); 15212 return; 15213 } 15214 if (peopleResizeDragging) { 15215 const next = peopleResizeStartWidth - (e.clientX - peopleResizeStartX); 15216 applyPeopleWidth(next, false); 15217 } 15218 }); 15219 window.addEventListener("mouseup", () => { 15220 if (chatResizeDragging && chatPanelEl) { 15221 applyChatWidth(chatPanelEl.getBoundingClientRect().width || readStoredChatWidth()); 15222 } 15223 if (sidebarResizeDragging && sidebarPanelEl) { 15224 applySidebarWidth(sidebarPanelEl.getBoundingClientRect().width || readStoredSidebarWidth()); 15225 } 15226 if (modResizeDragging && modPanelEl) { 15227 applyModWidth(modPanelEl.getBoundingClientRect().width || readStoredModWidth()); 15228 } 15229 if (peopleResizeDragging && peopleDrawerEl) { 15230 applyPeopleWidth(peopleDrawerEl.getBoundingClientRect().width || readStoredPeopleWidth()); 15231 } 15232 stopAnyPanelResize(); 15233 }); 15234 15235 appRoot?.addEventListener( 15236 "touchstart", 15237 (e) => { 15238 if (!isMobileSwipeMode()) return; 15239 if (!e.touches || e.touches.length !== 1) return; 15240 const t = e.touches[0]; 15241 touchStartX = t.clientX; 15242 touchStartY = t.clientY; 15243 touchTracking = true; 15244 }, 15245 { passive: true } 15246 ); 15247 15248 appRoot?.addEventListener( 15249 "touchend", 15250 (e) => { 15251 if (!isMobileSwipeMode() || !touchTracking) return; 15252 touchTracking = false; 15253 if (!e.changedTouches || e.changedTouches.length !== 1) return; 15254 const t = e.changedTouches[0]; 15255 const dx = t.clientX - touchStartX; 15256 const dy = t.clientY - touchStartY; 15257 if (Math.abs(dx) < 60) return; 15258 if (Math.abs(dx) < Math.abs(dy) * 1.2) return; 15259 if (dx < 0) shiftMobilePanel(1); 15260 else shiftMobilePanel(-1); 15261 }, 15262 { passive: true } 15263 ); 15264 15265 window.addEventListener("resize", applyMobileMode); 15266 applyMobileMode(); 15267 15268 // Initialize experimental rack layout (safe no-op when disabled). 15269 initRackLayout(); 15270 15271 window.addEventListener("focus", () => { 15272 windowFocused = true; 15273 updateNotifUi(); 15274 if (readStayConnectedPref() && (!ws || ws.readyState === WebSocket.CLOSED)) { 15275 connectWs(); 15276 return; 15277 } 15278 requestForegroundResync("focus"); 15279 }); 15280 window.addEventListener("blur", () => { 15281 windowFocused = false; 15282 stopAnyPanelResize(); 15283 }); 15284 document.addEventListener("visibilitychange", () => { 15285 updateNotifUi(); 15286 if (document.hidden) return; 15287 if (readStayConnectedPref() && (!ws || ws.readyState === WebSocket.CLOSED)) { 15288 connectWs(); 15289 return; 15290 } 15291 requestForegroundResync("visibility"); 15292 }); 15293 15294 enableNotifsBtn?.addEventListener("click", async () => { 15295 if (!notifSupported()) return; 15296 try { 15297 const res = await Notification.requestPermission(); 15298 if (res === "granted") toast("Notifications", "Enabled."); 15299 } catch { 15300 // ignore 15301 } 15302 updateNotifUi(); 15303 }); 15304 15305 updateNotifUi();