// static/js/04-main.js "use strict"; window.Glow = window.Glow || {}; Glow.loadRooms = async () => { const previousSlug = Glow.state.activeRoom?.slug; const data = await Glow.apiRequest("/api/rooms"); Glow.state.rooms = data.rooms || []; Glow.els.roomSelect.replaceChildren(); for (const room of Glow.state.rooms) { const option = document.createElement("option"); option.value = room.slug; option.textContent = `${room.name}${room.visibility === "private" ? " (private)" : ""}`; Glow.els.roomSelect.append(option); } Glow.els.roomSelect.disabled = !Glow.state.rooms.length; const selected = Glow.state.rooms.find(room => room.slug === previousSlug) || Glow.state.rooms.find(room => room.slug === "general") || Glow.state.rooms[0] || null; Glow.state.activeRoom = selected; Glow.updateViewUI(); Glow.updateChatControls(); }; Glow.updateViewUI = () => { const view = Glow.state.activeView || "feed"; const room = Glow.state.activeRoom; const dm = Glow.state.activeDm; const feedActive = view === "feed"; const dmListActive = view === "dms"; const chatActive = view === "room" || view === "channel" || view === "dm"; const threadActive = view === "thread"; const pagesActive = view === "pages"; const pageActive = view === "page"; const profileActive = view === "profile"; const settingsActive = view === "settings"; Glow.els.feedBox.classList.toggle("hidden", !feedActive); Glow.els.dmBox?.classList.toggle("hidden", !dmListActive); Glow.els.chatBox.classList.toggle("hidden", !chatActive); Glow.els.threadBox?.classList.toggle("hidden", !threadActive); Glow.els.pagesBox?.classList.toggle("hidden", !pagesActive); Glow.els.pageDetailBox?.classList.toggle("hidden", !pageActive); Glow.els.profileBox?.classList.toggle("hidden", !(profileActive || settingsActive)); Glow.els.composerWrap.classList.toggle("hidden", !chatActive); Glow.els.threadComposerWrap?.classList.toggle("hidden", !threadActive); if (!chatActive) { Glow.state.typingUsers = []; Glow.state.dmTypingUsers = []; if (Glow.els.typingBar) { Glow.els.typingBar.textContent = ""; Glow.els.typingBar.classList.add("hidden"); } } Glow.els.feedButton.classList.toggle("active", feedActive); Glow.els.feedButton.setAttribute("aria-pressed", String(feedActive)); Glow.els.pagesButton?.classList.toggle("active", pagesActive || pageActive); Glow.els.pagesButton?.setAttribute("aria-pressed", String(pagesActive || pageActive)); Glow.els.dmsButton?.classList.toggle("active", dmListActive || view === "dm"); Glow.els.dmsButton?.setAttribute("aria-pressed", String(dmListActive || view === "dm")); Glow.els.backButton?.classList.toggle( "hidden", !(threadActive || pageActive || profileActive || settingsActive || view === "dm") ); Glow.els.roomSelect.classList.toggle("active", chatActive && !dm); Glow.els.teamSelect?.classList.toggle("hidden", dmListActive || view === "dm"); Glow.els.roomSelect?.classList.toggle("hidden", dmListActive || view === "dm"); if (feedActive) { Glow.els.headerTitle.textContent = "Feed"; Glow.els.headerSubtitle.textContent = Glow.state.currentUser ? "Recent activity across your accessible rooms" : "Sign in or create an account to personalize your feed"; Glow.els.roomSelect.disabled = !Glow.state.rooms.length; Glow.els.presenceLabel?.classList.add("hidden"); return; } if (dmListActive) { Glow.els.headerTitle.textContent = "Direct messages"; Glow.els.headerSubtitle.textContent = Glow.state.currentUser ? "Private conversations" : "Sign in to open private conversations"; Glow.els.presenceLabel?.classList.add("hidden"); return; } if (threadActive) { Glow.els.headerTitle.textContent = "Thread"; Glow.els.headerSubtitle.textContent = dm ? "Direct message replies" : room?.name || "Conversation replies"; Glow.updateThreadControls?.(); return; } if (pagesActive) { Glow.els.headerTitle.textContent = "Pages"; Glow.els.headerSubtitle.textContent = "Shared knowledge and resources"; Glow.els.presenceLabel?.classList.add("hidden"); return; } if (pageActive) { Glow.els.headerTitle.textContent = Glow.state.activePage?.title || "Page"; Glow.els.headerSubtitle.textContent = "Shared knowledge and resources"; Glow.els.presenceLabel?.classList.add("hidden"); return; } if (profileActive) { Glow.els.headerTitle.textContent = Glow.state.activeProfile?.display_name || Glow.state.activeProfile?.username || "Profile"; Glow.els.headerSubtitle.textContent = "User profile"; Glow.els.presenceLabel?.classList.add("hidden"); return; } if (settingsActive) { Glow.els.headerTitle.textContent = "Settings"; Glow.els.headerSubtitle.textContent = "Appearance, profile, and preferences"; Glow.els.presenceLabel?.classList.add("hidden"); return; } if (view === "dm" && dm) { const peer = (dm.members || []).find( member => Number(member.id) !== Number(Glow.state.currentUser?.id) ) || dm.members?.[0]; Glow.els.headerTitle.textContent = peer?.display_name || peer?.username || "Direct message"; Glow.els.headerSubtitle.textContent = peer?.username ? `@${peer.username}` : "Private conversation"; Glow.refreshPresence?.(); Glow.updateChatControls(); return; } if (room) { Glow.els.headerTitle.textContent = room.name; Glow.els.headerSubtitle.textContent = room.description || (room.visibility === "private" ? "private room" : "public room"); Glow.els.roomSelect.value = room.slug; } else { Glow.els.headerTitle.textContent = "No rooms"; Glow.els.headerSubtitle.textContent = "No accessible chat rooms"; } Glow.updateChatControls(); }; Glow.updateAuthUI = () => { const user = Glow.state.currentUser; const authenticated = Boolean(user); Glow.els.authOverlay.classList.toggle("hidden", authenticated); Glow.els.logoutButton.classList.toggle("hidden", !authenticated); Glow.els.headerAccount.textContent = user ? `${user.username} ยท ${user.role}` : "not signed in"; document.body.classList.toggle("is-moderator", Glow.canModerate()); document.body.classList.toggle("is-admin", Glow.isAdmin()); Glow.els.adminFab.classList.toggle("visible", Glow.canModerate()); Glow.els.presenceDot.classList.toggle("online", authenticated); Glow.els.presenceDot.classList.toggle("new", Boolean(user?.status === "restricted")); if (user?.status === "restricted") { Glow.showToast("Your account is temporarily restricted.", "error"); } Glow.updateViewUI(); Glow.updateChatControls(); Glow.updateThreadControls?.(); Glow.renderHeaderMenu?.(); }; Glow.checkInvite = async () => { const params = new URLSearchParams(window.location.search); const roomToken = params.get("invite"); const teamToken = params.get("team_invite"); if (roomToken) { try { const data = await Glow.apiRequest( `/api/invites/${encodeURIComponent(roomToken)}/accept`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) } ); Glow.showToast(`Joined private room: ${data.room.name}`); window.history.replaceState({}, "", window.location.pathname); } catch (error) { if (error.status !== 401) { Glow.showToast(error.message, "error"); window.history.replaceState({}, "", window.location.pathname); } } } if (teamToken) { try { const data = await Glow.apiAcceptTeamInvite(teamToken); Glow.showToast(`Joined team: ${data.team.name}`); window.history.replaceState({}, "", window.location.pathname); } catch (error) { if (error.status !== 401) { Glow.showToast(error.message, "error"); window.history.replaceState({}, "", window.location.pathname); } } } }; Glow.checkAuth = async () => { try { const data = await Glow.apiRequest("/api/me"); Glow.state.currentUser = data.authenticated ? data.user : null; Glow.state.csrfToken = data.csrf_token || ""; Glow.updateAuthUI(); await Glow.checkInvite(); await Glow.loadRooms(); await Glow.loadDms?.().catch(() => {}); Glow.state.activeView = "feed"; Glow.updateViewUI(); await Glow.loadFeed(); await Glow.loadMySettings?.().catch(() => {}); Glow.calls?.scheduleInvitePolling?.(); } catch (error) { Glow.showToast(error.message || "Could not connect.", "error"); } finally { Glow.schedulePoll(); } }; Glow.authenticate = async endpoint => { Glow.clearAuthNotice(); const accessKey = Glow.els.accessKeyInput.value; const username = Glow.els.usernameInput.value.trim().toLowerCase(); const password = Glow.els.passwordInput.value; if (!accessKey || !username || !password) { Glow.showAuthNotice("Enter the private access key, username, and password."); return; } Glow.els.loginButton.disabled = true; Glow.els.registerButton.disabled = true; try { const data = await Glow.apiRequest(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ access_key: accessKey, username, password }) }); Glow.state.currentUser = data.user; Glow.state.csrfToken = data.csrf_token || ""; Glow.els.passwordInput.value = ""; Glow.updateAuthUI(); await Glow.checkInvite(); await Glow.loadRooms(); await Glow.loadDms?.().catch(() => {}); Glow.state.activeView = "feed"; Glow.updateViewUI(); await Glow.loadFeed(); await Glow.loadMySettings?.().catch(() => {}); Glow.calls?.scheduleInvitePolling?.(); Glow.showToast( endpoint.endsWith("register") ? "Account created." : "Signed in." ); } catch (error) { Glow.showAuthNotice( error.status === 429 && error.retryAfter ? `Please wait ${error.retryAfter} seconds before trying again.` : error.message ); } finally { Glow.els.loginButton.disabled = false; Glow.els.registerButton.disabled = false; Glow.schedulePoll(); } }; Glow.logout = async () => { try { await Glow.apiRequest("/api/logout", { method: "POST" }); } catch (_) {} Glow.state.currentUser = null; Glow.state.csrfToken = ""; Glow.state.rooms = []; Glow.state.activeRoom = null; Glow.state.teams = []; Glow.state.activeTeam = null; Glow.state.channels = []; Glow.state.activeChannel = null; Glow.state.dms = []; Glow.state.activeDm = null; Glow.state.activeView = "feed"; Glow.state.activeThread = null; Glow.state.threadReplies = []; Glow.state.activePage = null; Glow.state.activeProfile = null; Glow.state.settings = null; Glow.clearMessages(); Glow.clearFeed(); Glow.clearDms(); Glow.closeHeaderMenu?.(); Glow.closeReactionPopup?.(); Glow.closeMediaModal?.(); Glow.endLocalCallUi?.(); clearTimeout(Glow.state.calls?.invitePollTimer); Glow.updateAuthUI(); Glow.els.authOverlay.classList.remove("hidden"); Glow.els.accessKeyInput.focus(); await Glow.refreshCsrfToken().catch(() => {}); await Glow.loadFeed().catch(() => {}); Glow.showToast("Signed out."); }; Glow.toggleNotifications = async () => { if (!("Notification" in window)) { Glow.showToast("Browser notifications are not supported.", "error"); return; } if (!Glow.state.notificationsEnabled) { const permission = await Notification.requestPermission(); if (permission !== "granted") { Glow.showToast("Notification permission was not granted.", "error"); return; } } Glow.state.notificationsEnabled = !Glow.state.notificationsEnabled; localStorage.setItem( "glow_notifications_enabled", String(Glow.state.notificationsEnabled) ); Glow.updateAdminPreferenceButtons?.(); Glow.showToast( Glow.state.notificationsEnabled ? "Browser notifications enabled." : "Browser notifications disabled." ); }; Glow.toggleTypingIndicators = () => { Glow.state.typingIndicatorsEnabled = !Glow.state.typingIndicatorsEnabled; localStorage.setItem( "glow_typing_enabled", String(Glow.state.typingIndicatorsEnabled) ); if (!Glow.state.typingIndicatorsEnabled) { Glow.state.typingUsers = []; Glow.state.dmTypingUsers = []; Glow.renderTyping(); } Glow.updateAdminPreferenceButtons?.(); Glow.showToast( Glow.state.typingIndicatorsEnabled ? "Typing indicators enabled." : "Typing indicators disabled." ); }; Glow.pingPresence = async () => { if (!Glow.state.currentUser) return; const payload = { view: Glow.state.activeView }; if (Glow.state.activeDm?.id) { payload.direct_conversation_id = Glow.state.activeDm.id; } else if (Glow.state.activeChannel?.id) { payload.channel_id = Glow.state.activeChannel.id; } else if (Glow.state.activeRoom?.id) { payload.room_id = Glow.state.activeRoom.id; } try { await Glow.apiPingPresence(payload); } catch (_) {} }; Glow.refreshPresence = async () => { try { if (Glow.state.activeDm?.id) { const data = await Glow.apiGetDmPresence(Glow.state.activeDm.id); Glow.state.presence.dmOnlineCount = Number(data.online_count) || 0; Glow.els.presenceLabel.textContent = `${Glow.state.presence.dmOnlineCount} online`; Glow.els.presenceLabel.classList.remove("hidden"); return; } if (Glow.state.activeTeam?.slug && Glow.state.activeChannel?.slug) { const data = await Glow.apiGetChannelPresence( Glow.state.activeTeam.slug, Glow.state.activeChannel.slug ); Glow.state.presence.channelOnlineCount = Number(data.online_count) || 0; Glow.els.presenceLabel.textContent = `${Glow.state.presence.channelOnlineCount} online`; Glow.els.presenceLabel.classList.remove("hidden"); return; } if (Glow.state.activeRoom?.slug) { const data = await Glow.apiGetRoomPresence(Glow.state.activeRoom.slug); Glow.state.presence.roomOnlineCount = Number(data.online_count) || 0; Glow.els.presenceLabel.textContent = `${Glow.state.presence.roomOnlineCount} online`; Glow.els.presenceLabel.classList.remove("hidden"); return; } Glow.els.presenceLabel.classList.add("hidden"); } catch (_) { Glow.els.presenceLabel?.classList.add("hidden"); } }; Glow.schedulePoll = () => { clearTimeout(Glow.state.pollTimer); if (Glow.els.blockedOverlay.classList.contains("hidden") === false) return; Glow.state.pollTimer = setTimeout(async () => { if (Glow.state.activeView === "feed") { await Glow.pollFeed(); } else if ( Glow.state.activeView === "room" || Glow.state.activeView === "channel" || Glow.state.activeView === "dm" ) { await Glow.pollMessages(); await Glow.pollCallSignals?.(); } else if (Glow.state.activeView === "thread") { await Glow.pollThread?.(); await Glow.pollCallSignals?.(); } else if (Glow.state.activeView === "dms") { await Glow.loadDms?.().catch(() => {}); } await Glow.pingPresence?.(); await Glow.refreshPresence?.(); Glow.schedulePoll(); }, document.hidden ? 12000 : 5000); }; Glow.els.chatForm.addEventListener("submit", Glow.sendMessage); Glow.els.threadForm?.addEventListener("submit", event => Glow.sendThreadReply?.(event)); Glow.els.messageInput.addEventListener("input", () => { Glow.autoResizeComposer(); Glow.updateMessageCount(); clearTimeout(Glow.state.typingTimer); if (Glow.els.messageInput.value.trim()) { Glow.sendTyping(true); Glow.state.typingTimer = setTimeout(() => { Glow.sendTyping(false); }, 2500); } else { Glow.sendTyping(false); } }); Glow.els.threadInput?.addEventListener("input", () => { Glow.autoResizeThreadComposer?.(); Glow.updateThreadMessageCount?.(); }); Glow.els.messageInput.addEventListener("keydown", event => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); Glow.sendMessage(event); } }); Glow.els.threadInput?.addEventListener("keydown", event => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); Glow.sendThreadReply?.(event); } }); Glow.els.attachmentInput.addEventListener("change", () => { const files = Glow.normalizeSelectedFiles(Glow.els.attachmentInput.files); if (!files.length) { Glow.setSelectedAttachment([]); return; } const invalid = files.find(file => file.size > Glow.MAX_ATTACHMENT_BYTES); if (invalid) { Glow.showToast("Attachments must be 5 MB or smaller.", "error"); Glow.els.attachmentInput.value = ""; Glow.setSelectedAttachment([]); return; } Glow.setSelectedAttachment(files); }); Glow.els.threadAttachmentInput?.addEventListener("change", () => { const files = Glow.normalizeSelectedFiles(Glow.els.threadAttachmentInput.files); if (!files.length) { Glow.setSelectedThreadAttachment?.([]); return; } const invalid = files.find(file => file.size > Glow.MAX_ATTACHMENT_BYTES); if (invalid) { Glow.showToast("Attachments must be 5 MB or smaller.", "error"); Glow.els.threadAttachmentInput.value = ""; Glow.setSelectedThreadAttachment?.([]); return; } Glow.setSelectedThreadAttachment?.(files); }); Glow.els.feedButton.addEventListener("click", Glow.openFeed); Glow.els.dmsButton?.addEventListener("click", async () => { Glow.state.activeView = "dms"; Glow.updateViewUI(); await Glow.loadDms?.(); Glow.schedulePoll(); }); Glow.els.pagesButton?.addEventListener("click", () => { Glow.openPages?.(); }); Glow.els.backButton?.addEventListener("click", async () => { if (Glow.state.activeView === "thread") { await Glow.returnToChannel?.(); } else if (Glow.state.activeView === "page") { await Glow.openPages?.(); } else if (Glow.state.activeView === "profile" || Glow.state.activeView === "settings") { await Glow.openFeed?.(); } else if (Glow.state.activeView === "dm") { Glow.state.activeView = "dms"; Glow.updateViewUI(); await Glow.loadDms?.(); } }); Glow.els.loginButton.addEventListener("click", () => { Glow.authenticate("/api/login"); }); Glow.els.registerButton.addEventListener("click", () => { Glow.authenticate("/api/register"); }); Glow.els.passwordInput.addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); Glow.authenticate("/api/login"); } }); Glow.els.logoutButton.addEventListener("click", Glow.logout); Glow.els.roomSelect.addEventListener("change", async () => { const selected = Glow.state.rooms.find( room => room.slug === Glow.els.roomSelect.value ); if (!selected) return; Glow.state.activeDm = null; Glow.state.activeRoom = selected; Glow.state.activeView = "room"; Glow.state.typingUsers = []; Glow.state.dmTypingUsers = []; Glow.renderTyping(); Glow.updateViewUI(); await Glow.loadMessages(); Glow.pollTyping(); Glow.pingPresence?.(); Glow.refreshPresence?.(); Glow.schedulePoll(); }); Glow.els.teamSelect?.addEventListener("change", () => { const teams = Glow.state.teams || []; Glow.state.activeTeam = teams.find(team => team.slug === Glow.els.teamSelect.value) || null; }); Glow.els.adminFab.addEventListener("click", Glow.openAdminPanel); Glow.els.closeAdminButton.addEventListener("click", Glow.closeAdminPanel); Glow.els.createRoomButton.addEventListener("click", Glow.createRoom); Glow.els.clearMessagesButton.addEventListener("click", Glow.clearActiveRoom); Glow.els.typingToggleButton.addEventListener("click", Glow.toggleTypingIndicators); Glow.els.notificationsToggleButton.addEventListener("click", Glow.toggleNotifications); Glow.els.themeButton?.addEventListener("click", Glow.openAccentColorPicker); Glow.els.settingsButton?.addEventListener("click", () => { Glow.toggleHeaderMenu?.(); }); Glow.els.jumpToLatestButton.addEventListener("click", () => { Glow.els.chatBox.scrollTop = Glow.els.chatBox.scrollHeight; Glow.resetUnreadMessages(); }); Glow.els.chatBox.addEventListener("scroll", () => { if (Glow.isNearBottom()) { Glow.resetUnreadMessages(); } }); Glow.els.adminPanel.addEventListener("click", event => { if (event.target === Glow.els.adminPanel) { Glow.closeAdminPanel(); } }); Glow.els.dialogOverlay.addEventListener("click", event => { if (event.target === Glow.els.dialogOverlay) { Glow.closeDialog(null); } }); Glow.els.headerMenuOverlay?.addEventListener("click", event => { if (event.target === Glow.els.headerMenuOverlay) { Glow.closeHeaderMenu?.(); } }); Glow.els.closeReactionPopupButton?.addEventListener("click", () => { Glow.closeReactionPopup?.(); }); Glow.els.reactionPopupOverlay?.addEventListener("click", event => { if (event.target === Glow.els.reactionPopupOverlay) { Glow.closeReactionPopup?.(); } }); Glow.els.closeMediaButton?.addEventListener("click", () => { Glow.closeMediaModal?.(); }); Glow.els.mediaOverlay?.addEventListener("click", event => { if (event.target === Glow.els.mediaOverlay) { Glow.closeMediaModal?.(); } }); Glow.els.closeCallPanelButton?.addEventListener("click", () => { Glow.els.callOverlay?.classList.add("hidden"); }); document.addEventListener("keydown", event => { if (event.key !== "Escape") return; if (!Glow.els.dialogOverlay.classList.contains("hidden")) { Glow.closeDialog(null); return; } if (!Glow.els.mediaOverlay?.classList.contains("hidden")) { Glow.closeMediaModal?.(); return; } if (!Glow.els.reactionPopupOverlay?.classList.contains("hidden")) { Glow.closeReactionPopup?.(); return; } if (!Glow.els.headerMenuOverlay?.classList.contains("hidden")) { Glow.closeHeaderMenu?.(); return; } if (!Glow.els.callOverlay?.classList.contains("hidden")) { Glow.els.callOverlay.classList.add("hidden"); return; } if (Glow.state.activeView === "thread") { Glow.returnToChannel?.(); return; } if (Glow.state.activeView === "page") { Glow.openPages?.(); return; } if (Glow.state.activeView === "dm") { Glow.state.activeView = "dms"; Glow.updateViewUI(); Glow.loadDms?.(); return; } if (Glow.els.adminPanel.classList.contains("open")) { Glow.closeAdminPanel(); } }); document.addEventListener("visibilitychange", () => { Glow.schedulePoll(); Glow.calls?.scheduleInvitePolling?.(); if (!document.hidden) { Glow.resetUnreadMessages(); if (Glow.state.activeView === "feed") { Glow.pollFeed(); } else if ( Glow.state.activeView === "room" || Glow.state.activeView === "channel" || Glow.state.activeView === "dm" ) { Glow.pollMessages(); Glow.pollTyping(); Glow.pollCallSignals?.(); } else if (Glow.state.activeView === "thread") { Glow.pollThread?.(); Glow.pollCallSignals?.(); } else if (Glow.state.activeView === "dms") { Glow.loadDms?.(); } Glow.pingPresence?.(); Glow.refreshPresence?.(); } }); window.addEventListener("online", () => { Glow.showToast("Back online."); if (Glow.state.activeView === "feed") { Glow.pollFeed(); } else if ( Glow.state.activeView === "room" || Glow.state.activeView === "channel" || Glow.state.activeView === "dm" ) { Glow.pollMessages(); Glow.pollTyping(); Glow.pollCallSignals?.(); } else if (Glow.state.activeView === "thread") { Glow.pollThread?.(); Glow.pollCallSignals?.(); } else if (Glow.state.activeView === "dms") { Glow.loadDms?.(); } Glow.pingPresence?.(); Glow.refreshPresence?.(); Glow.calls?.scheduleInvitePolling?.(); }); window.addEventListener("offline", () => { Glow.showToast("You are offline.", "error"); }); setInterval(() => { if ( Glow.state.activeView === "room" || Glow.state.activeView === "channel" || Glow.state.activeView === "dm" ) { Glow.pollTyping(); } }, 2500); setInterval(() => { if ( Glow.state.activeView === "room" || Glow.state.activeView === "channel" || Glow.state.activeView === "dm" ) { Glow.pingPresence?.(); Glow.refreshPresence?.(); } }, 10000); Glow.applyAccentColor(Glow.state.accentColor); Glow.autoResizeComposer(); Glow.updateMessageCount(); Glow.autoResizeThreadComposer?.(); Glow.updateThreadMessageCount?.(); Glow.checkAuth();