// static/js/02-chat.js "use strict"; window.Glow = window.Glow || {}; Glow.EMOJI_SHORTCODES = { smile: "πŸ™‚", grin: "😁", joy: "πŸ˜‚", heart: "❀️", fire: "πŸ”₯", thumbsup: "πŸ‘", clap: "πŸ‘", eyes: "πŸ‘€", check: "βœ…", rocket: "πŸš€", thinking: "πŸ€”", wave: "πŸ‘‹", tada: "πŸŽ‰", sparkles: "✨", cry: "😒", laugh: "πŸ˜„", sob: "😭", star: "⭐", warning: "⚠️" }; Glow.clearMessages = () => { Glow.state.lastSeenId = 0; Glow.state.knownMessageIds.clear(); Glow.state.renderedMessages = []; Glow.els.chatBox.replaceChildren(); Glow.resetUnreadMessages(); }; Glow.clearDms = () => { Glow.state.dms = []; Glow.state.activeDm = null; Glow.state.dmTypingUsers = []; Glow.els.dmBox?.replaceChildren(); }; Glow.getActiveChannel = () => Glow.state.activeDm || Glow.state.activeChannel || Glow.state.activeRoom || null; Glow.getCurrentScopeInfo = () => { if (Glow.state.activeDm) { return { scope: "dm", scopeId: Glow.state.activeDm.id }; } if (Glow.state.activeTeam && Glow.state.activeChannel) { return { scope: "channel", scopeId: Glow.state.activeChannel.id }; } if (Glow.state.activeRoom) { return { scope: "room", scopeId: Glow.state.activeRoom.id }; } return null; }; Glow.getChannelMessageUrl = (channel, afterId = null) => { if (Glow.state.activeDm) { const base = `/api/dms/${encodeURIComponent(channel.id)}/messages`; return afterId === null ? base : `${base}?after_id=${encodeURIComponent(afterId)}`; } const team = Glow.state.activeTeam; if (team && Glow.state.activeChannel) { const base = `/api/teams/${encodeURIComponent(team.slug)}` + `/channels/${encodeURIComponent(channel.slug)}/messages`; return afterId === null ? base : `${base}?after_id=${encodeURIComponent(afterId)}`; } const base = `/api/rooms/${encodeURIComponent(channel.slug)}/messages`; return afterId === null ? base : `${base}?after_id=${encodeURIComponent(afterId)}`; }; Glow.getChannelTypingUrl = channel => { if (Glow.state.activeDm) { return `/api/dms/${encodeURIComponent(channel.id)}/typing`; } const team = Glow.state.activeTeam; if (team && Glow.state.activeChannel) { return ( `/api/teams/${encodeURIComponent(team.slug)}` + `/channels/${encodeURIComponent(channel.slug)}/typing` ); } return `/api/rooms/${encodeURIComponent(channel.slug)}/typing`; }; Glow.renderEmptyState = (message = "no messages yet.") => { if (Glow.els.chatBox.children.length) return; const state = document.createElement("div"); state.className = "empty-state"; const card = document.createElement("div"); card.className = "empty-card"; card.textContent = message; state.append(card); Glow.els.chatBox.append(state); }; Glow.renderDmEmptyState = (message = "no direct messages yet.") => { Glow.els.dmBox.replaceChildren(); const state = document.createElement("div"); state.className = "empty-state"; const card = document.createElement("div"); card.className = "empty-card"; card.textContent = message; state.append(card); Glow.els.dmBox.append(state); }; Glow.renderLoadOlderButton = hasMessages => { Glow.els.chatBox.querySelector(".load-older-wrap")?.remove(); if (!hasMessages) return; const wrap = document.createElement("div"); wrap.className = "load-older-wrap"; const button = document.createElement("button"); button.className = "small-button"; button.type = "button"; button.textContent = "load older messages"; button.addEventListener("click", () => Glow.loadOlderMessages()); wrap.append(button); Glow.els.chatBox.prepend(wrap); }; Glow.canGroupWithPrevious = message => { const previous = Glow.state.renderedMessages.at(-1); if (!previous) return false; if (Number(previous.user_id) !== Number(message.user_id)) return false; if (Glow.dateKey(previous.created_at) !== Glow.dateKey(message.created_at)) return false; const previousTime = new Date(previous.created_at).getTime(); const currentTime = new Date(message.created_at).getTime(); return currentTime - previousTime <= 5 * 60 * 1000; }; Glow.copyMessage = async text => { try { await navigator.clipboard.writeText(String(text || "")); Glow.showToast("copied."); } catch (_) { Glow.showToast("could not copy.", "error"); } }; Glow.extractUrls = text => { if (typeof text !== "string" || !text) return []; const matches = text.match(/\bhttps?:\/\/[^\s<>"']+/gi) || []; return [...new Set(matches)]; }; Glow.isImageAttachment = attachment => String(attachment?.mime_type || "").startsWith("image/"); Glow.isAudioAttachment = attachment => String(attachment?.mime_type || "").startsWith("audio/"); Glow.isVideoAttachment = attachment => String(attachment?.mime_type || "").startsWith("video/"); Glow.formatBytes = bytes => { const value = Number(bytes) || 0; if (value < 1024) return `${value} b`; if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} kb`; return `${(value / (1024 * 1024)).toFixed(1)} mb`; }; Glow.buildUrlActions = text => { const urls = Glow.extractUrls(text); if (!urls.length) return null; const row = document.createElement("div"); row.className = "message-url-actions"; for (const url of urls.slice(0, 3)) { const open = document.createElement("a"); open.className = "small-button"; open.href = url; open.target = "_blank"; open.rel = "noopener noreferrer"; open.textContent = "open link"; const copy = document.createElement("button"); copy.className = "small-button"; copy.type = "button"; copy.textContent = "copy link"; copy.addEventListener("click", () => Glow.copyMessage(url)); row.append(open, copy); } return row; }; Glow.buildProfileLink = (username, message = null) => { const button = document.createElement("button"); button.className = "message-author profile-link"; button.type = "button"; button.textContent = username; if (message) Glow.applyUserColor(button, message); button.addEventListener("click", () => { Glow.openProfile?.(username); }); return button; }; Glow.renderReactionBar = (message, container) => { const reactions = Array.isArray(message.reactions) ? message.reactions : []; if (!reactions.length) return; const row = document.createElement("div"); row.className = "message-reactions"; for (const reaction of reactions) { const button = document.createElement("button"); button.className = "reaction-chip"; button.type = "button"; button.textContent = `${reaction.emoji} ${reaction.count}`; if (reaction.reacted) button.classList.add("active"); button.addEventListener("click", async () => { try { if (reaction.reacted) { await Glow.apiRemoveReaction(message.id, reaction.emoji); } else { await Glow.apiAddReaction(message.id, reaction.emoji); } await Glow.refreshMessageReactions?.(message.id); } catch (error) { Glow.showToast(error.message || "could not update reaction.", "error"); } }); row.append(button); } container.append(row); }; Glow.updateMessageReactionsDom = (messageId, reactions) => { const row = Glow.els.chatBox.querySelector( `[data-message-id="${CSS.escape(String(messageId))}"]` ); if (!row) return; row.querySelector(".message-reactions")?.remove(); const bubble = row.querySelector(".message-bubble"); if (!bubble) return; const message = Glow.state.renderedMessages.find( item => Number(item.id) === Number(messageId) ); if (message) message.reactions = reactions || []; Glow.renderReactionBar({ id: messageId, reactions: reactions || [] }, bubble); }; Glow.refreshMessageReactions = async messageId => { const data = await Glow.apiGetMessageReactions(messageId); Glow.updateMessageReactionsDom(messageId, data.reactions || []); }; Glow.canEditMessage = message => { const currentUser = Glow.state.currentUser; if (!currentUser) return false; if (Number(message.user_id) === Number(currentUser.id)) return true; return Glow.canModerate(); }; Glow.openEditMessageDialog = async message => { const values = await Glow.openDialog({ title: "edit message", confirmText: "save changes", fields: [ { name: "text", label: "message text", value: message.text || "", multiline: true, rows: 4, maxLength: Glow.MAX_MESSAGE_LENGTH } ] }); if (!values) return; try { const attachmentIds = Array.isArray(message.attachments) ? message.attachments.map(item => item.id) : []; const data = await Glow.apiEditMessage(message.id, { text: values.text, attachment_ids: attachmentIds }); Glow.replaceRenderedMessage(data.message); Glow.showToast("message updated."); } catch (error) { Glow.showToast(error.message || "could not edit message.", "error"); } }; Glow.openReceiptDetails = async message => { try { const data = await Glow.apiGetMessageReceipts(message.id); const container = document.createElement("div"); container.className = "member-list"; if (!(data.receipts || []).length) { const empty = document.createElement("div"); empty.className = "member-row"; empty.textContent = "no read receipts yet."; container.append(empty); } else { for (const item of data.receipts) { const row = document.createElement("div"); row.className = "member-row"; const left = document.createElement("span"); left.textContent = item.username; const right = document.createElement("span"); right.textContent = new Date(item.seen_at).toLocaleString(); row.append(left, right); container.append(row); } } await Glow.openDialog({ title: "read receipts", body: container, confirmText: "close", cancelText: "close" }); } catch (error) { Glow.showToast(error.message || "could not load read receipts.", "error"); } }; Glow.handleSlashCommand = async text => { const trimmed = String(text || "").trim(); if (!trimmed.startsWith("/")) return false; if (trimmed === "/shrug") { Glow.els.messageInput.value = "Β―\\_(ツ)_/Β―"; Glow.autoResizeComposer(); Glow.updateMessageCount(); return true; } if (trimmed.startsWith("/me ")) { Glow.els.messageInput.value = `* ${Glow.state.currentUser?.username || "user"} ${trimmed.slice(4).trim()}`; Glow.autoResizeComposer(); Glow.updateMessageCount(); return true; } Glow.showToast("that slash command is currently unavailable.", "error"); return true; }; Glow.buildAttachments = attachments => { const list = document.createElement("div"); list.className = "message-attachments-list"; for (const attachment of attachments || []) { if (Glow.isImageAttachment(attachment)) { const imageLink = document.createElement("button"); imageLink.className = "message-image-link"; imageLink.type = "button"; const image = document.createElement("img"); image.className = "message-image-preview"; image.src = attachment.url; image.alt = attachment.original_name || "image attachment"; image.loading = "lazy"; imageLink.append(image); imageLink.addEventListener("click", () => { Glow.openMediaModal?.({ url: attachment.url, name: attachment.original_name, mimeType: attachment.mime_type }); }); list.append(imageLink); } else if (Glow.isAudioAttachment(attachment)) { const audioWrap = document.createElement("div"); audioWrap.className = "message-audio-wrap"; const audio = document.createElement("audio"); audio.controls = true; audio.preload = "metadata"; audio.src = attachment.url; const previewButton = document.createElement("button"); previewButton.className = "small-button"; previewButton.type = "button"; previewButton.textContent = "open player"; previewButton.addEventListener("click", () => { Glow.openMediaModal?.({ url: attachment.url, name: attachment.original_name, mimeType: attachment.mime_type }); }); audioWrap.append(audio, previewButton); list.append(audioWrap); } else if (Glow.isVideoAttachment(attachment)) { const videoWrap = document.createElement("div"); videoWrap.className = "message-video-wrap"; const video = document.createElement("video"); video.controls = true; video.preload = "metadata"; video.src = attachment.url; video.className = "message-video-preview"; const previewButton = document.createElement("button"); previewButton.className = "small-button"; previewButton.type = "button"; previewButton.textContent = "open video"; previewButton.addEventListener("click", () => { Glow.openMediaModal?.({ url: attachment.url, name: attachment.original_name, mimeType: attachment.mime_type }); }); videoWrap.append(video, previewButton); list.append(videoWrap); } const link = document.createElement("a"); link.className = "message-attachment"; link.href = attachment.url; link.target = "_blank"; link.rel = "noopener noreferrer"; link.download = attachment.original_name; link.textContent = `attachment: ${attachment.original_name} (${Glow.formatBytes(attachment.size_bytes)})`; list.append(link); } return list; }; Glow.buildMessage = (message, grouped = false) => { const row = document.createElement("article"); const isOwnMessage = Glow.state.currentUser && Number(message.user_id) === Number(Glow.state.currentUser.id); const isDmView = Boolean(Glow.state.activeDm); row.className = `message-row${isOwnMessage ? " self" : ""}` + `${grouped ? " grouped" : ""}` + `${message.is_pinned ? " pinned" : ""}` + `${isDmView ? " is-dm-view" : ""}`; row.dataset.messageId = String(message.id); row.dataset.userId = String(message.user_id); row.dataset.username = String(message.username || ""); if (!grouped) { const meta = document.createElement("div"); meta.className = "message-meta"; const author = Glow.buildProfileLink(message.username, message); const time = document.createElement("time"); time.className = "message-time"; time.dateTime = message.created_at; time.textContent = Glow.relativeTime(message.created_at); time.title = new Date(message.created_at).toLocaleString(); meta.append(author, time); if (message.is_pinned) { const pin = document.createElement("span"); pin.className = "message-pin-badge"; pin.textContent = "pinned"; meta.append(pin); } row.append(meta); } const content = document.createElement("div"); content.className = "message-content-row"; const actions = document.createElement("div"); actions.className = "message-actions"; if (Glow.reactions?.enabled !== false) { const react = document.createElement("button"); react.className = "reaction-trigger primary"; react.type = "button"; react.textContent = "\u{1F60A}"; react.setAttribute("aria-label", "add reaction"); react.addEventListener("click", () => { Glow.openReactionPopup?.(message); }); actions.append(react); } if (!message.parent_message_id) { const thread = document.createElement("button"); thread.className = "message-action primary"; thread.type = "button"; const replyCount = Number(message.thread_reply_count) || 0; thread.textContent = replyCount > 0 ? `${replyCount} ${replyCount === 1 ? "reply" : "replies"}` : "reply"; thread.setAttribute("aria-label", "open message thread"); thread.addEventListener("click", () => { Glow.openThread(message.id); }); actions.append(thread); } if (Glow.canEditMessage(message)) { const edit = document.createElement("button"); edit.className = "message-action secondary"; edit.type = "button"; edit.textContent = "edit"; edit.addEventListener("click", () => { Glow.openEditMessageDialog(message); }); actions.append(edit); } const copy = document.createElement("button"); copy.className = "message-action secondary"; copy.type = "button"; copy.textContent = "copy"; copy.addEventListener("click", () => { Glow.copyMessage(message.text); }); actions.append(copy); const urls = Glow.extractUrls(message.text); if (urls.length) { const copyUrl = document.createElement("button"); copyUrl.className = "message-action secondary"; copyUrl.type = "button"; copyUrl.textContent = "url"; copyUrl.addEventListener("click", () => { Glow.copyMessage(urls[0]); }); actions.append(copyUrl); } const receipts = document.createElement("button"); receipts.className = "message-action secondary"; receipts.type = "button"; receipts.textContent = `seen ${Number(message.read_receipt_count) || 0}`; receipts.addEventListener("click", () => Glow.openReceiptDetails(message)); actions.append(receipts); if (Glow.canModerate()) { const remove = document.createElement("button"); remove.className = "admin-delete secondary"; remove.type = "button"; remove.textContent = "\u00D7"; remove.setAttribute("aria-label", "delete message"); remove.addEventListener("click", () => { Glow.deleteMessage(message.id); }); actions.append(remove); } const bubble = document.createElement("div"); bubble.className = "message-bubble"; const text = document.createElement("div"); text.className = "message-text"; text.textContent = message.text; bubble.append(text); if (message.is_edited) { const edited = document.createElement("div"); edited.className = "message-edited"; edited.textContent = "edited"; bubble.append(edited); } const urlActions = Glow.buildUrlActions(message.text); if (urlActions) bubble.append(urlActions); const attachments = Array.isArray(message.attachments) ? message.attachments : message.attachment ? [message.attachment] : []; if (attachments.length) { const mediaSection = document.createElement("div"); mediaSection.className = "message-media-section"; mediaSection.append(Glow.buildAttachments(attachments)); bubble.append(mediaSection); } Glow.renderReactionBar(message, bubble); content.append(...(isOwnMessage ? [actions, bubble] : [bubble, actions])); row.append(content); return row; }; Glow.replaceRenderedMessage = updatedMessage => { const index = Glow.state.renderedMessages.findIndex( item => Number(item.id) === Number(updatedMessage.id) ); if (index === -1) return; Glow.state.renderedMessages[index] = updatedMessage; const existing = Glow.els.chatBox.querySelector( `[data-message-id="${CSS.escape(String(updatedMessage.id))}"]` ); if (!existing) return; const prev = Glow.state.renderedMessages[index - 1]; const grouped = !!( prev && Number(prev.user_id) === Number(updatedMessage.user_id) && Glow.dateKey(prev.created_at) === Glow.dateKey(updatedMessage.created_at) && new Date(updatedMessage.created_at).getTime() - new Date(prev.created_at).getTime() <= 5 * 60 * 1000 ); existing.replaceWith(Glow.buildMessage(updatedMessage, grouped)); }; Glow.notifyForMessage = message => { const currentUser = Glow.state.currentUser; const isOwnMessage = currentUser && Number(message.user_id) === Number(currentUser.id); if (isOwnMessage) return; const isMention = currentUser && String(message.text || "") .toLowerCase() .includes(`@${currentUser.username.toLowerCase()}`); if (!document.hidden && !isMention) return; Glow.state.unreadMessages += 1; Glow.updateDocumentTitle(); Glow.showNewMessages(); if ( Glow.state.notificationsEnabled && "Notification" in window && Notification.permission === "granted" ) { let contextName = "glow"; if (Glow.state.activeDm) { const peer = Glow.state.activeDm.other_user || (Glow.state.activeDm.members || []).find( member => Number(member.id) !== Number(currentUser?.id) ); contextName = peer?.display_name || peer?.username || "direct message"; } else { contextName = Glow.state.activeChannel?.name || Glow.state.activeRoom?.name || "glow"; } const title = isMention ? `mention from ${message.username}` : `new message in ${contextName}`; new Notification(title, { body: String(message.text || "").slice(0, 180), tag: `glow-message-${message.id}` }); } }; Glow.markVisibleMessagesRead = async () => { const ids = Glow.state.renderedMessages.map(item => item.id).slice(-100); if (!ids.length || !Glow.state.currentUser) return; try { await Glow.apiMarkRead(ids); } catch (_) {} }; Glow.appendMessages = (messages, forceScroll = false, notify = false) => { if (!Array.isArray(messages) || !messages.length) { if (!Glow.state.knownMessageIds.size) { Glow.renderEmptyState(); } return; } const nearBottom = Glow.isNearBottom(); const fragment = document.createDocumentFragment(); for (const message of messages) { const id = Number(message.id); if (!id || Glow.state.knownMessageIds.has(id)) continue; const previous = Glow.state.renderedMessages.at(-1); const dateChanged = !previous || Glow.dateKey(previous.created_at) !== Glow.dateKey(message.created_at); const grouped = Glow.canGroupWithPrevious(message); if (dateChanged) { const separator = document.createElement("div"); separator.className = "date-separator"; separator.dataset.dateKey = Glow.dateKey(message.created_at); const text = document.createElement("span"); text.textContent = Glow.formatDateLabel(message.created_at); separator.append(text); fragment.append(separator); } Glow.state.knownMessageIds.add(id); Glow.state.lastSeenId = Math.max(Glow.state.lastSeenId, id); Glow.state.renderedMessages.push(message); fragment.append(Glow.buildMessage(message, grouped)); if (notify) Glow.notifyForMessage(message); } if (fragment.childNodes.length) { Glow.els.chatBox.querySelector(".empty-state")?.remove(); Glow.els.chatBox.append(fragment); if (forceScroll || nearBottom) { Glow.els.chatBox.scrollTop = Glow.els.chatBox.scrollHeight; if (nearBottom) Glow.resetUnreadMessages(); } Glow.markVisibleMessagesRead(); Glow.renderLoadOlderButton(Glow.state.renderedMessages.length > 0); } }; Glow.prependHistoryMessages = messages => { if (!Array.isArray(messages) || !messages.length) { Glow.showToast("no older messages found."); return; } const previousHeight = Glow.els.chatBox.scrollHeight; Glow.els.chatBox.querySelector(".load-older-wrap")?.remove(); const newItems = []; for (const message of messages) { const id = Number(message.id); if (!id || Glow.state.knownMessageIds.has(id)) continue; newItems.push(message); } Glow.state.renderedMessages = [...newItems, ...Glow.state.renderedMessages]; Glow.state.knownMessageIds = new Set( Glow.state.renderedMessages.map(item => Number(item.id)) ); const old = [...Glow.state.renderedMessages]; Glow.els.chatBox.replaceChildren(); const fragment = document.createDocumentFragment(); let previous = null; for (const message of old) { const dateChanged = !previous || Glow.dateKey(previous.created_at) !== Glow.dateKey(message.created_at); const grouped = !!previous && Number(previous.user_id) === Number(message.user_id) && Glow.dateKey(previous.created_at) === Glow.dateKey(message.created_at) && new Date(message.created_at).getTime() - new Date(previous.created_at).getTime() <= 5 * 60 * 1000; if (dateChanged) { const separator = document.createElement("div"); separator.className = "date-separator"; const label = document.createElement("span"); label.textContent = Glow.formatDateLabel(message.created_at); separator.append(label); fragment.append(separator); } fragment.append(Glow.buildMessage(message, grouped)); previous = message; } Glow.els.chatBox.append(fragment); Glow.renderLoadOlderButton(Glow.state.renderedMessages.length > 0); const newHeight = Glow.els.chatBox.scrollHeight; Glow.els.chatBox.scrollTop += newHeight - previousHeight; }; Glow.loadOlderMessages = async () => { const oldest = Glow.state.renderedMessages[0]; if (!oldest) return; try { let params; if (Glow.state.activeDm) { params = { kind: "dm", dm_id: Glow.state.activeDm.id, before_id: oldest.id }; } else if (Glow.state.activeTeam && Glow.state.activeChannel) { params = { kind: "channel", slug: Glow.state.activeChannel.slug, team_slug: Glow.state.activeTeam.slug, before_id: oldest.id }; } else { params = { kind: "room", slug: Glow.state.activeRoom.slug, before_id: oldest.id }; } const data = await Glow.apiLoadHistory(params); Glow.prependHistoryMessages(data.messages || []); } catch (error) { Glow.showToast(error.message || "could not load older messages.", "error"); } }; Glow.loadMessages = async () => { const room = Glow.state.activeRoom; if (!room) return; const requestId = ++Glow.state.messageRequestId; Glow.els.chatBox.setAttribute("aria-busy", "true"); try { const data = await Glow.apiRequest( `/api/rooms/${encodeURIComponent(room.slug)}/messages` ); if ( requestId !== Glow.state.messageRequestId || Glow.state.activeRoom?.slug !== room.slug ) { return; } Glow.clearMessages(); Glow.appendMessages(data.messages || [], true, false); Glow.state.lastSeenId = Number(data.latest_id) || 0; Glow.state.initialized = true; if (!(data.messages || []).length) { Glow.renderEmptyState(); } Glow.renderLoadOlderButton((data.messages || []).length > 0); } catch (error) { if (requestId !== Glow.state.messageRequestId) return; Glow.clearMessages(); Glow.renderEmptyState(error.message || "could not load messages."); Glow.showToast(error.message || "could not load messages.", "error"); } finally { if (requestId === Glow.state.messageRequestId) { Glow.els.chatBox.setAttribute("aria-busy", "false"); } } }; Glow.loadChannelMessages = async () => { const channel = Glow.getActiveChannel(); if (!channel) return; const requestId = ++Glow.state.channelRequestId; Glow.els.chatBox.setAttribute("aria-busy", "true"); try { const data = await Glow.apiRequest(Glow.getChannelMessageUrl(channel)); if ( requestId !== Glow.state.channelRequestId || Glow.getActiveChannel()?.slug !== channel.slug ) { return; } Glow.clearMessages(); Glow.state.activeChannel = data.channel || channel; Glow.state.activeRoom = Glow.state.activeChannel; Glow.appendMessages(data.messages || [], true, false); Glow.state.lastSeenId = Number(data.latest_id) || 0; Glow.state.initialized = true; if (!(data.messages || []).length) { Glow.renderEmptyState(); } Glow.renderLoadOlderButton((data.messages || []).length > 0); } catch (error) { if (requestId !== Glow.state.channelRequestId) return; Glow.clearMessages(); Glow.renderEmptyState(error.message || "could not load channel messages."); Glow.showToast(error.message || "could not load channel messages.", "error"); } finally { if (requestId === Glow.state.channelRequestId) { Glow.els.chatBox.setAttribute("aria-busy", "false"); } } }; Glow.loadDmMessages = async dm => { if (!dm) return; const requestId = ++Glow.state.dmRequestId; Glow.els.chatBox.setAttribute("aria-busy", "true"); try { const data = await Glow.apiGetDmMessages(dm.id); if ( requestId !== Glow.state.dmRequestId || Number(Glow.state.activeDm?.id) !== Number(dm.id) ) { return; } Glow.clearMessages(); Glow.state.activeDm = data.dm || dm; Glow.appendMessages(data.messages || [], true, false); Glow.state.lastSeenId = Number(data.latest_id) || 0; Glow.state.initialized = true; if (!(data.messages || []).length) { Glow.renderEmptyState("no direct messages yet."); } Glow.renderLoadOlderButton((data.messages || []).length > 0); } catch (error) { if (requestId !== Glow.state.dmRequestId) return; Glow.clearMessages(); Glow.renderEmptyState(error.message || "could not load direct messages."); Glow.showToast(error.message || "could not load direct messages.", "error"); } finally { if (requestId === Glow.state.dmRequestId) { Glow.els.chatBox.setAttribute("aria-busy", "false"); } } }; Glow.renderDmToolbar = () => { const toolbar = document.createElement("div"); toolbar.className = "dm-toolbar"; const actions = document.createElement("div"); actions.className = "dm-toolbar-actions"; const create = document.createElement("button"); create.className = "button"; create.type = "button"; create.textContent = "new dm"; create.addEventListener("click", () => Glow.openCreateDmDialog()); actions.append(create); toolbar.append(actions); return toolbar; }; Glow.renderDmList = () => { const box = Glow.els.dmBox; box.replaceChildren(); if (!Glow.state.currentUser) { Glow.renderDmEmptyState("sign in to view direct messages."); return; } box.append(Glow.renderDmToolbar()); if (!(Glow.state.dms || []).length) { const empty = document.createElement("div"); empty.className = "empty-state page-empty-state"; const card = document.createElement("div"); card.className = "empty-card"; card.textContent = "no direct conversations yet."; empty.append(card); box.append(empty); return; } const list = document.createElement("div"); list.className = "dm-list cards"; for (const dm of Glow.state.dms) { list.append(Glow.buildDmCard(dm)); } box.append(list); }; Glow.buildDmCard = dm => { const article = document.createElement("article"); article.className = "page-card dm-card"; article.tabIndex = 0; const peer = (dm.members || []).find( member => Number(member.id) !== Number(Glow.state.currentUser?.id) ) || dm.other_user || dm.members?.[0]; const unread = Number(dm.unread_count || 0) > 0; if (unread) { article.classList.add("unread"); } const title = document.createElement("h3"); title.textContent = peer?.display_name || peer?.username || "conversation"; if (peer) Glow.applyUserColor(title, peer); const sender = document.createElement("div"); sender.className = "dm-card-sender"; sender.textContent = peer?.username ? `@${peer.username}` : "direct message"; const subtitle = document.createElement("p"); subtitle.className = "page-card-excerpt dm-card-preview"; subtitle.textContent = dm.latest_message?.text || dm.last_message_text || "no messages yet."; const footer = document.createElement("div"); footer.className = "page-card-footer dm-card-footer"; const left = document.createElement("span"); left.textContent = unread ? `${dm.unread_count} unread` : (peer?.status_message || " "); const updated = document.createElement("time"); const updatedAt = dm.latest_message?.created_at || dm.last_message_created_at || dm.created_at; updated.dateTime = updatedAt; updated.title = new Date(updatedAt).toLocaleString(); updated.textContent = dm.latest_message?.created_at || dm.last_message_created_at ? `updated ${Glow.relativeTime(updatedAt)}` : `created ${Glow.relativeTime(dm.created_at)}`; footer.append(left, updated); const open = async () => { Glow.state.activeDm = dm; Glow.state.activeTeam = null; Glow.state.activeChannel = null; Glow.state.activeRoom = null; Glow.state.activeView = "dm"; Glow.state.typingUsers = []; Glow.renderTyping(); Glow.updateViewUI(); await Glow.loadDmMessages(dm); Glow.pollTyping(); Glow.pingPresence?.(); Glow.refreshPresence?.(); Glow.schedulePoll(); }; article.addEventListener("click", open); article.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); open(); } }); article.append(title, sender, subtitle, footer); return article; }; Glow.loadDms = async () => { if (!Glow.state.currentUser) { Glow.clearDms(); Glow.renderDmEmptyState("sign in to view direct messages."); return; } Glow.els.dmBox.setAttribute("aria-busy", "true"); try { const data = await Glow.apiListDms(); Glow.state.dms = data.dms || []; Glow.renderDmList(); } catch (error) { Glow.renderDmEmptyState(error.message || "could not load direct messages."); Glow.showToast(error.message || "could not load direct messages.", "error"); } finally { Glow.els.dmBox.setAttribute("aria-busy", "false"); } }; Glow.openCreateDmDialog = async () => { if (!Glow.state.currentUser) { Glow.showToast("sign in to start a direct message.", "error"); return; } const values = await Glow.openDialog({ title: "start direct message", confirmText: "open dm", fields: [ { name: "username", label: "username", placeholder: "username", maxLength: 30 } ] }); if (!values?.username) return; try { const data = await Glow.apiCreateDm({ username: values.username.toLowerCase() }); await Glow.loadDms(); const dm = data.dm; if (dm) { Glow.state.activeDm = dm; Glow.state.activeTeam = null; Glow.state.activeChannel = null; Glow.state.activeRoom = null; Glow.state.activeView = "dm"; Glow.updateViewUI(); await Glow.loadDmMessages(dm); Glow.pingPresence?.(); Glow.refreshPresence?.(); Glow.schedulePoll(); } } catch (error) { Glow.showToast(error.message || "could not start direct message.", "error"); } }; Glow.pollMessages = async () => { const channel = Glow.getActiveChannel(); if (Glow.state.activeDm) return Glow.pollDmMessages(); if (Glow.state.pollInFlight || !channel || document.hidden) return; Glow.state.pollInFlight = true; try { let afterId = Glow.state.lastSeenId; let hasMore = true; let safety = 0; while (hasMore && safety < 20) { safety += 1; const data = await Glow.apiRequest( Glow.getChannelMessageUrl(channel, afterId) ); if (Glow.getActiveChannel()?.slug !== channel.slug) return; Glow.appendMessages(data.messages || [], false, true); afterId = Math.max(afterId, Number(data.latest_id) || 0); Glow.state.lastSeenId = Math.max(Glow.state.lastSeenId, afterId); hasMore = Boolean(data.has_more) && (data.messages || []).length > 0; } } catch (error) { if ( error.status !== 401 && error.code !== "blacklisted" && error.code !== "network" ) { console.error("message polling failed:", error); } } finally { Glow.state.pollInFlight = false; } }; Glow.pollDmMessages = async () => { const dm = Glow.state.activeDm; if (Glow.state.dmPollInFlight || !dm || document.hidden) return; Glow.state.dmPollInFlight = true; try { let afterId = Glow.state.lastSeenId; let hasMore = true; let safety = 0; while (hasMore && safety < 20) { safety += 1; const data = await Glow.apiGetDmMessages(dm.id, afterId); if (Number(Glow.state.activeDm?.id) !== Number(dm.id)) return; Glow.appendMessages(data.messages || [], false, true); afterId = Math.max(afterId, Number(data.latest_id) || 0); Glow.state.lastSeenId = Math.max(Glow.state.lastSeenId, afterId); hasMore = Boolean(data.has_more) && (data.messages || []).length > 0; } } catch (error) { if ( error.status !== 401 && error.code !== "blacklisted" && error.code !== "network" ) { console.error("dm polling failed:", error); } } finally { Glow.state.dmPollInFlight = false; } }; Glow.updateChatControls = () => { const user = Glow.state.currentUser; const channel = Glow.state.activeDm || Glow.state.activeChannel || Glow.state.activeRoom; const restricted = user && user.status === "restricted"; const canSend = Boolean(user && channel && !restricted && channel.can_write !== false); Glow.els.messageInput.disabled = !canSend; Glow.els.sendButton.disabled = !canSend; Glow.els.attachmentInput.disabled = !canSend; Glow.els.attachmentButton.classList.toggle("disabled", !canSend); if (!user) { Glow.els.messageInput.placeholder = "sign in to send"; } else if (restricted) { Glow.els.messageInput.placeholder = "account temporarily restricted"; } else if (Glow.state.activeDm) { Glow.els.messageInput.placeholder = "direct message"; } else if (channel?.can_write === false) { Glow.els.messageInput.placeholder = "you cannot post in this channel"; } else { Glow.els.messageInput.placeholder = "message"; } }; Glow.setSelectedAttachment = files => { Glow.state.selectedAttachment = Array.isArray(files) ? files : []; Glow.els.attachmentName.textContent = Glow.state.selectedAttachment .map(file => file.name) .join(", "); }; Glow.uploadAttachment = async file => { const form = new FormData(); form.append("attachment", file); return Glow.apiRequest("/api/attachments", { method: "POST", body: form }); }; Glow.uploadSelectedAttachments = async files => { const uploaded = []; for (const file of files) { const result = await Glow.uploadAttachment(file); if (result?.attachment) uploaded.push(result.attachment); } return uploaded; }; Glow.parseEmojiShortcodeAtCursor = input => { if (!(input instanceof HTMLTextAreaElement)) return null; const value = input.value || ""; const caret = input.selectionStart ?? value.length; const before = value.slice(0, caret); const match = before.match(/::([a-z0-9_+-]{0,24})$/i); if (!match) return null; return { query: match[1].toLowerCase(), start: caret - match[0].length, end: caret }; }; Glow.getEmojiSuggestions = query => { const entries = Object.entries(Glow.EMOJI_SHORTCODES); if (!query) { return entries.slice(0, 8).map(([name, emoji]) => ({ name, emoji })); } return entries .filter(([name]) => name.includes(query)) .slice(0, 8) .map(([name, emoji]) => ({ name, emoji })); }; Glow.renderEmojiAutocomplete = () => { const state = Glow.state.emojiAutocomplete; const panel = state.owner === "thread" ? Glow.els.threadEmojiAutocomplete : Glow.els.emojiAutocomplete; const otherPanel = state.owner === "thread" ? Glow.els.emojiAutocomplete : Glow.els.threadEmojiAutocomplete; otherPanel?.classList.add("hidden"); otherPanel?.replaceChildren(); if (!panel) return; panel.replaceChildren(); if (!state.open || !state.suggestions.length) { panel.classList.add("hidden"); return; } state.suggestions.forEach((item, index) => { const button = document.createElement("button"); button.className = "emoji-suggestion"; button.type = "button"; button.textContent = `${item.emoji} ::${item.name}`; if (index === state.selectedIndex) { button.classList.add("active"); } button.addEventListener("click", () => { Glow.applyEmojiSuggestion(index); }); panel.append(button); }); panel.classList.remove("hidden"); }; Glow.openEmojiAutocomplete = (owner, query) => { const suggestions = Glow.getEmojiSuggestions(query); Glow.state.emojiAutocomplete = { open: suggestions.length > 0, owner, query, suggestions, selectedIndex: 0 }; Glow.renderEmojiAutocomplete(); }; Glow.closeEmojiAutocomplete = () => { Glow.state.emojiAutocomplete = { open: false, owner: "main", query: "", suggestions: [], selectedIndex: 0 }; Glow.els.emojiAutocomplete?.classList.add("hidden"); Glow.els.threadEmojiAutocomplete?.classList.add("hidden"); }; Glow.moveEmojiSelection = delta => { const state = Glow.state.emojiAutocomplete; if (!state.open || !state.suggestions.length) return; const size = state.suggestions.length; state.selectedIndex = (state.selectedIndex + delta + size) % size; Glow.renderEmojiAutocomplete(); }; Glow.applyEmojiSuggestion = (forcedIndex = null) => { const state = Glow.state.emojiAutocomplete; if (!state.open || !state.suggestions.length) return false; const index = forcedIndex == null ? state.selectedIndex : forcedIndex; const chosen = state.suggestions[index]; if (!chosen) return false; const input = state.owner === "thread" ? Glow.els.threadInput : Glow.els.messageInput; if (!(input instanceof HTMLTextAreaElement)) return false; const parsed = Glow.parseEmojiShortcodeAtCursor(input); if (!parsed) { Glow.closeEmojiAutocomplete(); return false; } const value = input.value; input.value = value.slice(0, parsed.start) + chosen.emoji + value.slice(parsed.end); const nextPos = parsed.start + chosen.emoji.length; input.setSelectionRange(nextPos, nextPos); if (state.owner === "thread") { Glow.autoResizeThreadComposer?.(); Glow.updateThreadMessageCount?.(); } else { Glow.autoResizeComposer(); Glow.updateMessageCount(); } Glow.closeEmojiAutocomplete(); input.focus(); return true; }; Glow.handleEmojiAutocompleteInput = owner => { const input = owner === "thread" ? Glow.els.threadInput : Glow.els.messageInput; const parsed = Glow.parseEmojiShortcodeAtCursor(input); if (!parsed) { if (Glow.state.emojiAutocomplete.open && Glow.state.emojiAutocomplete.owner === owner) { Glow.closeEmojiAutocomplete(); } return; } Glow.openEmojiAutocomplete(owner, parsed.query); }; Glow.handleEmojiAutocompleteKeydown = (event, owner) => { const state = Glow.state.emojiAutocomplete; if (!state.open || state.owner !== owner) return false; if (event.key === "ArrowDown") { event.preventDefault(); Glow.moveEmojiSelection(1); return true; } if (event.key === "ArrowUp") { event.preventDefault(); Glow.moveEmojiSelection(-1); return true; } if (event.key === "Escape") { event.preventDefault(); Glow.closeEmojiAutocomplete(); return true; } if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); Glow.applyEmojiSuggestion(); return true; } return false; }; Glow.sendTyping = async (isTyping = true) => { const channel = Glow.getActiveChannel(); if ( !Glow.state.typingIndicatorsEnabled || !Glow.state.currentUser || !channel ) { return; } const now = Date.now(); if (isTyping && now - Glow.state.typingLastSent < 1500) return; Glow.state.typingLastSent = now; try { if (Glow.state.activeDm) { await Glow.apiSetDmTyping(channel.id, isTyping); } else { await Glow.apiRequest(Glow.getChannelTypingUrl(channel), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ typing: isTyping }) }); } } catch (_) {} }; Glow.pollTyping = async () => { const channel = Glow.getActiveChannel(); if (!channel || !Glow.state.typingIndicatorsEnabled || document.hidden) return; try { let data; if (Glow.state.activeDm) { data = await Glow.apiGetDmTyping(channel.id); if (Number(Glow.state.activeDm?.id) !== Number(channel.id)) return; Glow.state.dmTypingUsers = data.users || []; Glow.renderTyping(); return; } data = await Glow.apiRequest(Glow.getChannelTypingUrl(channel)); if (Glow.getActiveChannel()?.slug !== channel.slug) return; Glow.state.typingUsers = data.users || []; Glow.renderTyping(); } catch (_) {} }; Glow.renderTyping = () => { const users = Glow.state.activeDm ? Glow.state.dmTypingUsers || [] : Glow.state.typingUsers || []; if (!users.length || !Glow.state.typingIndicatorsEnabled) { Glow.els.typingBar.textContent = ""; Glow.els.typingBar.classList.add("hidden"); return; } const names = users.map(user => user.username); Glow.els.typingBar.textContent = names.length === 1 ? `${names[0]} is typing…` : `${names.slice(0, -1).join(", ")} and ${names.at(-1)} are typing…`; Glow.els.typingBar.classList.remove("hidden"); }; Glow.sendMessage = async event => { event.preventDefault(); const team = Glow.state.activeTeam; const dm = Glow.state.activeDm; const channel = Glow.getActiveChannel(); if (!channel || !Glow.state.currentUser) return; const text = Glow.els.messageInput.value.trim(); const selected = Glow.state.selectedAttachment || []; if (!text && !selected.length) return; if (text.startsWith("/")) { const handled = await Glow.handleSlashCommand(text); if (handled) return; } Glow.els.sendButton.disabled = true; try { const uploadedAttachments = await Glow.uploadSelectedAttachments(selected); const payload = { text, attachment_id: uploadedAttachments[0]?.id || null, attachment_ids: uploadedAttachments.map(item => item.id) }; let data; if (dm) { data = await Glow.apiSendDmMessage(dm.id, payload); } else if (team && Glow.state.activeChannel) { data = await Glow.apiRequest( `/api/teams/${encodeURIComponent(team.slug)}` + `/channels/${encodeURIComponent(channel.slug)}/messages`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) } ); } else { data = await Glow.apiRequest( `/api/rooms/${encodeURIComponent(channel.slug)}/messages`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) } ); } if (dm) { if (Number(Glow.state.activeDm?.id) !== Number(dm.id)) return; } else if (Glow.getActiveChannel()?.slug !== channel.slug) { return; } Glow.els.messageInput.value = ""; Glow.autoResizeComposer(); Glow.updateMessageCount(); Glow.els.attachmentInput.value = ""; Glow.setSelectedAttachment([]); Glow.closeEmojiAutocomplete(); Glow.appendMessages([data.message], true, false); Glow.sendTyping(false); } catch (error) { if (error.code === "restricted") { Glow.showToast( error.restrictedUntil ? `restricted until ${new Date(error.restrictedUntil).toLocaleString()}.` : "your account is restricted.", "error" ); } else if (error.status === 429 && error.retryAfter) { Glow.showToast(`rate limited. try again in ${error.retryAfter} seconds.`, "error"); } else { Glow.showToast(error.message || "could not send message.", "error"); } } finally { Glow.updateChatControls(); if (!Glow.els.messageInput.disabled) { Glow.els.messageInput.focus(); } } }; Glow.deleteMessage = async messageId => { if (!Glow.canModerate()) return; const result = await Glow.openDialog({ title: "delete message?", body: "this message will be permanently removed.", confirmText: "delete", danger: true }); if (!result) return; try { await Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}`, { method: "DELETE" }); Glow.els.chatBox .querySelector(`[data-message-id="${CSS.escape(String(messageId))}"]`) ?.remove(); Glow.state.knownMessageIds.delete(Number(messageId)); Glow.state.renderedMessages = Glow.state.renderedMessages.filter( message => Number(message.id) !== Number(messageId) ); if (!Glow.els.chatBox.querySelector(".message-row")) { Glow.clearMessages(); Glow.renderEmptyState(); } Glow.showToast("message deleted."); } catch (error) { Glow.showToast(error.message || "could not delete message.", "error"); } }; Glow.openThread = async messageId => { const requestId = ++Glow.state.threadRequestId; try { Glow.state.activeView = "thread"; Glow.state.activeThread = null; Glow.state.threadReplies = []; Glow.updateViewUI(); Glow.els.threadBox.setAttribute("aria-busy", "true"); const data = await Glow.apiRequest( `/api/messages/${encodeURIComponent(messageId)}/thread` ); if (requestId !== Glow.state.threadRequestId) return; Glow.state.activeThread = data.root; Glow.state.threadReplies = data.replies || []; if (data.dm) { Glow.state.activeDm = data.dm; Glow.state.activeTeam = null; Glow.state.activeChannel = null; Glow.state.activeRoom = null; } else { if (data.team) { Glow.state.activeTeam = { ...Glow.state.activeTeam, slug: data.team.slug, name: data.team.name }; } if (data.channel) { Glow.state.activeChannel = data.channel; Glow.state.activeRoom = data.channel; } } Glow.renderThread(); } catch (error) { if (requestId !== Glow.state.threadRequestId) return; Glow.showToast(error.message || "could not load thread.", "error"); Glow.returnToChannel(); } finally { if (requestId === Glow.state.threadRequestId) { Glow.els.threadBox.setAttribute("aria-busy", "false"); } } }; Glow.returnToChannel = async () => { Glow.state.activeThread = null; Glow.state.threadReplies = []; Glow.state.activeView = Glow.state.activeDm ? "dm" : "channel"; Glow.updateViewUI(); if (Glow.state.activeDm) { await Glow.loadDmMessages(Glow.state.activeDm); } else if (Glow.state.activeChannel) { await Glow.loadChannelMessages(); } else { await Glow.loadMessages(); } Glow.schedulePoll(); }; Glow.buildThreadMessage = (message, root = false) => { const row = Glow.buildMessage(message, false); row.classList.add("thread-message"); if (root) row.classList.add("thread-root"); return row; }; Glow.renderThread = () => { const box = Glow.els.threadBox; const root = Glow.state.activeThread; box.replaceChildren(); if (!root) { Glow.renderThreadEmptyState("thread not found."); return; } const heading = document.createElement("div"); heading.className = "thread-heading"; const label = document.createElement("div"); label.className = "thread-label"; label.textContent = "thread"; const count = document.createElement("div"); count.className = "thread-count"; count.textContent = Glow.state.threadReplies.length === 1 ? "1 reply" : `${Glow.state.threadReplies.length} replies`; heading.append(label, count); box.append(heading); box.append(Glow.buildThreadMessage(root, true)); const separator = document.createElement("div"); separator.className = "thread-replies-label"; separator.textContent = Glow.state.threadReplies.length === 1 ? "1 reply" : `${Glow.state.threadReplies.length} replies`; box.append(separator); if (!Glow.state.threadReplies.length) { const empty = document.createElement("div"); empty.className = "thread-empty"; empty.textContent = "no replies yet. start the conversation."; box.append(empty); } else { for (const reply of Glow.state.threadReplies) { box.append(Glow.buildThreadMessage(reply)); } } box.scrollTop = box.scrollHeight; Glow.updateThreadControls(); }; Glow.renderThreadEmptyState = message => { Glow.els.threadBox.replaceChildren(); const state = document.createElement("div"); state.className = "empty-state"; const card = document.createElement("div"); card.className = "empty-card"; card.textContent = message; state.append(card); Glow.els.threadBox.append(state); }; Glow.updateThreadControls = () => { const user = Glow.state.currentUser; const thread = Glow.state.activeThread; const channel = Glow.state.activeDm || Glow.state.activeChannel || Glow.state.activeRoom; const restricted = user?.status === "restricted"; const canReply = Boolean( user && thread && !restricted && (Glow.state.activeDm || channel?.can_write) ); Glow.els.threadInput.disabled = !canReply; Glow.els.threadSendButton.disabled = !canReply; Glow.els.threadAttachmentInput.disabled = !canReply; Glow.els.threadAttachmentButton.classList.toggle("disabled", !canReply); if (!user) { Glow.els.threadInput.placeholder = "sign in to reply"; } else if (restricted) { Glow.els.threadInput.placeholder = "account temporarily restricted"; } else if (Glow.state.activeDm) { Glow.els.threadInput.placeholder = "reply in thread"; } else if (!channel?.can_write) { Glow.els.threadInput.placeholder = "you cannot reply in this thread"; } else { Glow.els.threadInput.placeholder = "reply in thread"; } }; Glow.setSelectedThreadAttachment = files => { Glow.state.selectedThreadAttachment = Array.isArray(files) ? files : []; Glow.els.threadAttachmentName.textContent = Glow.state.selectedThreadAttachment.map(file => file.name).join(", "); }; Glow.updateThreadMessageCount = () => { Glow.els.threadMessageCount.textContent = `${Glow.els.threadInput.value.length} / ${Glow.MAX_MESSAGE_LENGTH}`; }; Glow.autoResizeThreadComposer = () => { const input = Glow.els.threadInput; input.style.height = "auto"; input.style.height = `${Math.min(input.scrollHeight, 150)}px`; }; Glow.sendThreadReply = async event => { event.preventDefault(); const root = Glow.state.activeThread; if (!root || !Glow.state.currentUser) return; const text = Glow.els.threadInput.value.trim(); const selected = Glow.state.selectedThreadAttachment || []; if (!text && !selected.length) return; Glow.els.threadSendButton.disabled = true; try { const uploadedAttachments = await Glow.uploadSelectedAttachments(selected); const data = await Glow.apiRequest( `/api/messages/${encodeURIComponent(root.id)}/thread`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, attachment_id: uploadedAttachments[0]?.id || null, attachment_ids: uploadedAttachments.map(item => item.id) }) } ); Glow.state.threadReplies.push(data.reply); Glow.els.threadInput.value = ""; Glow.autoResizeThreadComposer(); Glow.updateThreadMessageCount(); Glow.els.threadAttachmentInput.value = ""; Glow.setSelectedThreadAttachment([]); Glow.closeEmojiAutocomplete(); Glow.renderThread(); Glow.refreshRootReplyCount(root.id, Glow.state.threadReplies.length); } catch (error) { Glow.showToast(error.message || "could not send reply.", "error"); } finally { Glow.updateThreadControls(); if (!Glow.els.threadInput.disabled) { Glow.els.threadInput.focus(); } } }; Glow.refreshRootReplyCount = (messageId, count) => { const row = Glow.els.chatBox.querySelector( `[data-message-id="${CSS.escape(String(messageId))}"]` ); if (!row) return; const threadButton = [...row.querySelectorAll(".message-action")].find(button => { return button.getAttribute("aria-label") === "open message thread"; }); if (!threadButton) return; threadButton.textContent = count > 0 ? `${count} ${count === 1 ? "reply" : "replies"}` : "reply"; const message = Glow.state.renderedMessages.find( item => Number(item.id) === Number(messageId) ); if (message) { message.thread_reply_count = count; } }; Glow.pollThread = async () => { const root = Glow.state.activeThread; if (Glow.state.threadPollInFlight || !root || document.hidden) return; Glow.state.threadPollInFlight = true; try { const data = await Glow.apiRequest( `/api/messages/${encodeURIComponent(root.id)}/thread` ); if (Glow.state.activeThread?.id !== root.id) return; Glow.state.activeThread = data.root; Glow.state.threadReplies = data.replies || []; Glow.renderThread(); Glow.refreshRootReplyCount(root.id, Glow.state.threadReplies.length); } catch (error) { if (error.status !== 401 && error.status !== 404) { console.error("thread polling failed:", error); } } finally { Glow.state.threadPollInFlight = false; } };