// static/js/02-chat.js "use strict"; window.Glow = window.Glow || {}; 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.getCurrentConversationKind = () => { if (Glow.state.activeDm) return "dm"; if (Glow.state.activeTeam && Glow.state.activeChannel) return "channel"; if (Glow.state.activeRoom) return "room"; return 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(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.toggleBookmark = async message => { try { if (message.is_bookmarked) { await Glow.apiUnbookmarkMessage(message.id); message.is_bookmarked = false; Glow.showToast("Bookmark removed."); } else { await Glow.apiBookmarkMessage(message.id); message.is_bookmarked = true; Glow.showToast("Message bookmarked."); } Glow.replaceRenderedMessage(message); } catch (error) { Glow.showToast(error.message || "Could not update bookmark.", "error"); } }; Glow.togglePin = async message => { try { if (message.is_pinned) { await Glow.apiUnpinMessage(message.id); message.is_pinned = false; message.pinned_at = null; Glow.showToast("Message unpinned."); } else { await Glow.apiPinMessage(message.id); message.is_pinned = true; message.pinned_at = new Date().toISOString(); Glow.showToast("Message pinned."); } Glow.replaceRenderedMessage(message); } catch (error) { Glow.showToast(error.message || "Could not update pin.", "error"); } }; Glow.shareMessage = async message => { try { const data = await Glow.apiCreateMessageShare(message.id); const url = new URL(data.share.url, window.location.origin).toString(); await navigator.clipboard.writeText(url); Glow.showToast("Message share link copied."); } catch (error) { Glow.showToast(error.message || "Could not create message share.", "error"); } }; Glow.shareAttachment = async attachment => { try { const data = await Glow.apiCreateAttachmentShare(attachment.id); const url = new URL(data.share.url, window.location.origin).toString(); await navigator.clipboard.writeText(url); Glow.showToast("Attachment share link copied."); } catch (error) { Glow.showToast(error.message || "Could not create attachment share.", "error"); } }; Glow.showPinsDialog = async () => { const scope = Glow.getCurrentScopeInfo(); if (!scope) { Glow.showToast("Open a conversation first.", "error"); return; } try { const data = await Glow.apiListPins(scope.scope, scope.scopeId); const container = document.createElement("div"); container.className = "member-list"; if (!(data.messages || []).length) { const empty = document.createElement("div"); empty.className = "member-row"; empty.textContent = "No pinned messages."; container.append(empty); } else { for (const message of data.messages) { const row = document.createElement("button"); row.className = "member-row bookmark-row"; row.type = "button"; const left = document.createElement("span"); left.textContent = `${message.username}: ${String(message.text || "").slice(0, 80)}`; const right = document.createElement("span"); right.textContent = Glow.relativeTime(message.created_at); row.append(left, right); row.addEventListener("click", () => { Glow.closeDialog({}); const node = Glow.els.chatBox.querySelector( `[data-message-id="${CSS.escape(String(message.id))}"]` ); if (node) { node.scrollIntoView({ behavior: "smooth", block: "center" }); } }); container.append(row); } } await Glow.openDialog({ title: "Pinned messages", body: container, confirmText: "Close", cancelText: "Close" }); } catch (error) { Glow.showToast(error.message || "Could not load pins.", "error"); } }; Glow.showBookmarksDialog = async () => { try { const data = await Glow.apiListBookmarks(); const container = document.createElement("div"); container.className = "member-list"; if (!(data.messages || []).length) { const empty = document.createElement("div"); empty.className = "member-row"; empty.textContent = "No bookmarks yet."; container.append(empty); } else { for (const message of data.messages) { const row = document.createElement("div"); row.className = "member-row"; const left = document.createElement("div"); left.textContent = `${message.username}: ${String(message.text || "").slice(0, 100)}`; const right = document.createElement("span"); right.textContent = new Date(message.created_at).toLocaleString(); row.append(left, right); container.append(row); } } await Glow.openDialog({ title: "Bookmarks", body: container, confirmText: "Close", cancelText: "Close" }); } catch (error) { Glow.showToast(error.message || "Could not load bookmarks.", "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; } if (trimmed === "/pins") { await Glow.showPinsDialog(); Glow.els.messageInput.value = ""; Glow.autoResizeComposer(); Glow.updateMessageCount(); return true; } if (trimmed === "/bookmarks") { await Glow.showBookmarksDialog(); Glow.els.messageInput.value = ""; Glow.autoResizeComposer(); Glow.updateMessageCount(); return true; } if (trimmed.startsWith("/schedule ")) { const body = trimmed.slice("/schedule ".length).trim(); const match = body.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\s+([\s\S]+)$/); if (!match) { Glow.showToast("Use /schedule YYYY-MM-DD HH:MM message", "error"); return true; } const [, datePart, timePart, messageText] = match; const local = new Date(`${datePart}T${timePart}:00`); if (Number.isNaN(local.getTime()) || local.getTime() <= Date.now()) { Glow.showToast("Choose a future date/time.", "error"); return true; } const scope = Glow.getCurrentScopeInfo(); if (!scope) { Glow.showToast("Open a conversation first.", "error"); return true; } try { await Glow.apiCreateScheduledMessage({ text: messageText, deliver_at: local.toISOString(), scope_kind: scope.scope, scope_id: scope.scopeId, attachment_ids: [] }); Glow.els.messageInput.value = ""; Glow.autoResizeComposer(); Glow.updateMessageCount(); Glow.showToast("Message scheduled."); } catch (error) { Glow.showToast(error.message || "Could not schedule message.", "error"); } return true; } Glow.showToast("Unknown slash command.", "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); row.className = `message-row${isOwnMessage ? " self" : ""}` + `${grouped ? " grouped" : ""}` + `${message.is_pinned ? " pinned" : ""}`; 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"; react.type = "button"; react.textContent = "😊"; 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"; 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"; edit.type = "button"; edit.textContent = "Edit"; edit.addEventListener("click", () => { Glow.openEditMessageDialog(message); }); actions.append(edit); } const copy = document.createElement("button"); copy.className = "message-action"; 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"; 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"; 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"; remove.type = "button"; remove.textContent = "×"; 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.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) { bubble.append(Glow.buildAttachments(attachments)); } 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.renderDmList = () => { const box = Glow.els.dmBox; box.replaceChildren(); if (!Glow.state.currentUser) { Glow.renderDmEmptyState("Sign in to view direct messages."); return; } const header = document.createElement("div"); header.className = "pages-header"; const copy = document.createElement("div"); const title = document.createElement("h2"); title.textContent = "Direct messages"; const subtitle = document.createElement("p"); subtitle.textContent = "Private conversations with other users."; copy.append(title, subtitle); const create = document.createElement("button"); create.className = "button"; create.type = "button"; create.textContent = "New DM"; create.addEventListener("click", () => Glow.openCreateDmDialog()); header.append(copy, create); box.append(header); 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"; 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 title = document.createElement("h3"); title.textContent = peer?.display_name || peer?.username || "Conversation"; if (peer) Glow.applyUserColor(title, peer); const subtitle = document.createElement("p"); subtitle.className = "page-card-excerpt"; subtitle.textContent = dm.latest_message?.text || dm.last_message_text || "No messages yet."; const footer = document.createElement("div"); footer.className = "page-card-footer"; const left = document.createElement("span"); left.textContent = peer?.username ? `@${peer.username}` : "DM"; const updated = document.createElement("time"); updated.dateTime = dm.latest_message?.created_at || dm.last_message_created_at || dm.created_at; updated.title = new Date(dm.latest_message?.created_at || dm.last_message_created_at || dm.created_at).toLocaleString(); updated.textContent = dm.latest_message?.created_at || dm.last_message_created_at ? `Updated ${Glow.relativeTime(dm.latest_message?.created_at || dm.last_message_created_at)}` : `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, 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.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.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, "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.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; } };