diff --git a/static/js/02-chat.js b/static/js/02-chat.js index 5c6c52c..7ab9944 100644 --- a/static/js/02-chat.js +++ b/static/js/02-chat.js @@ -9,7 +9,6 @@ Glow.EMOJI_SHORTCODES = { heart: "❤️", fire: "🔥", thumbsup: "👍", - thumbsup: "👍", clap: "👏", eyes: "👀", check: "✅", @@ -43,13 +42,6 @@ Glow.clearDms = () => { 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 }; @@ -68,6 +60,7 @@ Glow.getChannelMessageUrl = (channel, afterId = null) => { 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 = @@ -75,6 +68,7 @@ Glow.getChannelMessageUrl = (channel, afterId = null) => { `/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)}`; }; @@ -83,6 +77,7 @@ 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 ( @@ -90,10 +85,11 @@ Glow.getChannelTypingUrl = channel => { `/channels/${encodeURIComponent(channel.slug)}/typing` ); } + return `/api/rooms/${encodeURIComponent(channel.slug)}/typing`; }; -Glow.renderEmptyState = (message = "No messages yet.") => { +Glow.renderEmptyState = (message = "no messages yet.") => { if (Glow.els.chatBox.children.length) return; const state = document.createElement("div"); state.className = "empty-state"; @@ -104,7 +100,7 @@ Glow.renderEmptyState = (message = "No messages yet.") => { Glow.els.chatBox.append(state); }; -Glow.renderDmEmptyState = (message = "No direct messages yet.") => { +Glow.renderDmEmptyState = (message = "no direct messages yet.") => { Glow.els.dmBox.replaceChildren(); const state = document.createElement("div"); state.className = "empty-state"; @@ -118,13 +114,16 @@ Glow.renderDmEmptyState = (message = "No direct messages yet.") => { 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.textContent = "load older messages"; button.addEventListener("click", () => Glow.loadOlderMessages()); + wrap.append(button); Glow.els.chatBox.prepend(wrap); }; @@ -134,6 +133,7 @@ Glow.canGroupWithPrevious = message => { 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; @@ -141,10 +141,10 @@ Glow.canGroupWithPrevious = message => { Glow.copyMessage = async text => { try { - await navigator.clipboard.writeText(text); - Glow.showToast("Copied."); + await navigator.clipboard.writeText(String(text || "")); + Glow.showToast("copied."); } catch (_) { - Glow.showToast("Could not copy.", "error"); + Glow.showToast("could not copy.", "error"); } }; @@ -165,31 +165,35 @@ Glow.isVideoAttachment = attachment => 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`; + 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"; + open.textContent = "open link"; const copy = document.createElement("button"); copy.className = "small-button"; copy.type = "button"; - copy.textContent = "Copy link"; + copy.textContent = "copy link"; copy.addEventListener("click", () => Glow.copyMessage(url)); + row.append(open, copy); } + return row; }; @@ -208,8 +212,10 @@ Glow.buildProfileLink = (username, message = null) => { 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"; @@ -225,11 +231,12 @@ Glow.renderReactionBar = (message, container) => { } await Glow.refreshMessageReactions?.(message.id); } catch (error) { - Glow.showToast(error.message || "Could not update reaction.", "error"); + Glow.showToast(error.message || "could not update reaction.", "error"); } }); row.append(button); } + container.append(row); }; @@ -238,6 +245,7 @@ Glow.updateMessageReactionsDom = (messageId, reactions) => { `[data-message-id="${CSS.escape(String(messageId))}"]` ); if (!row) return; + row.querySelector(".message-reactions")?.remove(); const bubble = row.querySelector(".message-bubble"); if (!bubble) return; @@ -263,12 +271,12 @@ Glow.canEditMessage = message => { Glow.openEditMessageDialog = async message => { const values = await Glow.openDialog({ - title: "Edit message", - confirmText: "Save changes", + title: "edit message", + confirmText: "save changes", fields: [ { name: "text", - label: "Message text", + label: "message text", value: message.text || "", multiline: true, rows: 4, @@ -277,6 +285,7 @@ Glow.openEditMessageDialog = async message => { ] }); if (!values) return; + try { const attachmentIds = Array.isArray(message.attachments) ? message.attachments.map(item => item.id) @@ -286,9 +295,9 @@ Glow.openEditMessageDialog = async message => { attachment_ids: attachmentIds }); Glow.replaceRenderedMessage(data.message); - Glow.showToast("Message updated."); + Glow.showToast("message updated."); } catch (error) { - Glow.showToast(error.message || "Could not edit message.", "error"); + Glow.showToast(error.message || "could not edit message.", "error"); } }; @@ -297,10 +306,11 @@ Glow.openReceiptDetails = async message => { 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."; + empty.textContent = "no read receipts yet."; container.append(empty); } else { for (const item of data.receipts) { @@ -314,153 +324,15 @@ Glow.openReceiptDetails = async message => { container.append(row); } } + await Glow.openDialog({ - title: "Read receipts", + title: "read receipts", body: container, - confirmText: "Close", - cancelText: "Close" + 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.showToast(error.message || "could not load read receipts.", "error"); } }; @@ -469,7 +341,7 @@ Glow.handleSlashCommand = async text => { if (!trimmed.startsWith("/")) return false; if (trimmed === "/shrug") { - Glow.els.messageInput.value = "¯\\_(ツ)_/¯"; + Glow.els.messageInput.value = "¯\\_(ツ)_/¯"; Glow.autoResizeComposer(); Glow.updateMessageCount(); return true; @@ -483,65 +355,14 @@ Glow.handleSlashCommand = async text => { 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"); + 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"); @@ -551,7 +372,7 @@ Glow.buildAttachments = attachments => { const image = document.createElement("img"); image.className = "message-image-preview"; image.src = attachment.url; - image.alt = attachment.original_name || "Image attachment"; + image.alt = attachment.original_name || "image attachment"; image.loading = "lazy"; imageLink.append(image); @@ -562,10 +383,12 @@ Glow.buildAttachments = attachments => { 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"; @@ -574,7 +397,7 @@ Glow.buildAttachments = attachments => { const previewButton = document.createElement("button"); previewButton.className = "small-button"; previewButton.type = "button"; - previewButton.textContent = "Open player"; + previewButton.textContent = "open player"; previewButton.addEventListener("click", () => { Glow.openMediaModal?.({ url: attachment.url, @@ -582,11 +405,13 @@ Glow.buildAttachments = attachments => { 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"; @@ -596,7 +421,7 @@ Glow.buildAttachments = attachments => { const previewButton = document.createElement("button"); previewButton.className = "small-button"; previewButton.type = "button"; - previewButton.textContent = "Open video"; + previewButton.textContent = "open video"; previewButton.addEventListener("click", () => { Glow.openMediaModal?.({ url: attachment.url, @@ -604,6 +429,7 @@ Glow.buildAttachments = attachments => { mimeType: attachment.mime_type }); }); + videoWrap.append(video, previewButton); list.append(videoWrap); } @@ -614,9 +440,10 @@ Glow.buildAttachments = attachments => { link.target = "_blank"; link.rel = "noopener noreferrer"; link.download = attachment.original_name; - link.textContent = `Attachment: ${attachment.original_name} (${Glow.formatBytes(attachment.size_bytes)})`; + link.textContent = `attachment: ${attachment.original_name} (${Glow.formatBytes(attachment.size_bytes)})`; list.append(link); } + return list; }; @@ -654,7 +481,7 @@ Glow.buildMessage = (message, grouped = false) => { if (message.is_pinned) { const pin = document.createElement("span"); pin.className = "message-pin-badge"; - pin.textContent = "Pinned"; + pin.textContent = "pinned"; meta.append(pin); } @@ -671,8 +498,8 @@ Glow.buildMessage = (message, grouped = false) => { const react = document.createElement("button"); react.className = "reaction-trigger primary"; react.type = "button"; - react.textContent = "😊"; - react.setAttribute("aria-label", "Add reaction"); + react.textContent = "🙂"; + react.setAttribute("aria-label", "add reaction"); react.addEventListener("click", () => { Glow.openReactionPopup?.(message); }); @@ -687,8 +514,8 @@ Glow.buildMessage = (message, grouped = false) => { thread.textContent = replyCount > 0 ? `${replyCount} ${replyCount === 1 ? "reply" : "replies"}` - : "Reply"; - thread.setAttribute("aria-label", "Open message thread"); + : "reply"; + thread.setAttribute("aria-label", "open message thread"); thread.addEventListener("click", () => { Glow.openThread(message.id); }); @@ -699,7 +526,7 @@ Glow.buildMessage = (message, grouped = false) => { const edit = document.createElement("button"); edit.className = "message-action secondary"; edit.type = "button"; - edit.textContent = "Edit"; + edit.textContent = "edit"; edit.addEventListener("click", () => { Glow.openEditMessageDialog(message); }); @@ -709,7 +536,7 @@ Glow.buildMessage = (message, grouped = false) => { const copy = document.createElement("button"); copy.className = "message-action secondary"; copy.type = "button"; - copy.textContent = "Copy"; + copy.textContent = "copy"; copy.addEventListener("click", () => { Glow.copyMessage(message.text); }); @@ -720,7 +547,7 @@ Glow.buildMessage = (message, grouped = false) => { const copyUrl = document.createElement("button"); copyUrl.className = "message-action secondary"; copyUrl.type = "button"; - copyUrl.textContent = "URL"; + copyUrl.textContent = "url"; copyUrl.addEventListener("click", () => { Glow.copyMessage(urls[0]); }); @@ -730,7 +557,7 @@ Glow.buildMessage = (message, grouped = false) => { const receipts = document.createElement("button"); receipts.className = "message-action secondary"; receipts.type = "button"; - receipts.textContent = `Seen ${Number(message.read_receipt_count) || 0}`; + receipts.textContent = `seen ${Number(message.read_receipt_count) || 0}`; receipts.addEventListener("click", () => Glow.openReceiptDetails(message)); actions.append(receipts); @@ -738,8 +565,8 @@ Glow.buildMessage = (message, grouped = false) => { const remove = document.createElement("button"); remove.className = "admin-delete secondary"; remove.type = "button"; - remove.textContent = "×"; - remove.setAttribute("aria-label", "Delete message"); + remove.textContent = "×"; + remove.setAttribute("aria-label", "delete message"); remove.addEventListener("click", () => { Glow.deleteMessage(message.id); }); @@ -769,6 +596,7 @@ Glow.buildMessage = (message, grouped = false) => { : message.attachment ? [message.attachment] : []; + if (attachments.length) { const mediaSection = document.createElement("div"); mediaSection.className = "message-media-section"; @@ -844,9 +672,11 @@ Glow.notifyForMessage = message => { Glow.state.activeRoom?.name || "glow"; } + const title = isMention - ? `Mention from ${message.username}` - : `New message in ${contextName}`; + ? `mention from ${message.username}` + : `new message in ${contextName}`; + new Notification(title, { body: String(message.text || "").slice(0, 180), tag: `glow-message-${message.id}` @@ -915,7 +745,7 @@ Glow.appendMessages = (messages, forceScroll = false, notify = false) => { Glow.prependHistoryMessages = messages => { if (!Array.isArray(messages) || !messages.length) { - Glow.showToast("No older messages found."); + Glow.showToast("no older messages found."); return; } @@ -972,6 +802,7 @@ Glow.prependHistoryMessages = messages => { Glow.loadOlderMessages = async () => { const oldest = Glow.state.renderedMessages[0]; if (!oldest) return; + try { let params; if (Glow.state.activeDm) { @@ -994,41 +825,48 @@ Glow.loadOlderMessages = async () => { 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.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"); + 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"); @@ -1039,31 +877,37 @@ Glow.loadMessages = async () => { 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"); + 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"); @@ -1073,30 +917,36 @@ Glow.loadChannelMessages = async () => { 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.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"); + 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"); @@ -1114,24 +964,10 @@ Glow.renderDmToolbar = () => { const create = document.createElement("button"); create.className = "button"; create.type = "button"; - create.textContent = "New DM"; + create.textContent = "new dm"; create.addEventListener("click", () => Glow.openCreateDmDialog()); - const layout = document.createElement("button"); - layout.className = "button secondary"; - layout.type = "button"; - layout.id = "dmLayoutToggle"; - layout.textContent = - Glow.state.dmLayoutMode === "compact" - ? "Layout: compact" - : "Layout: cards"; - layout.addEventListener("click", () => { - const next = Glow.state.dmLayoutMode === "compact" ? "cards" : "compact"; - Glow.saveDmLayoutPreference(next); - Glow.renderDmList(); - }); - - actions.append(create, layout); + actions.append(create); toolbar.append(actions); return toolbar; }; @@ -1139,8 +975,9 @@ Glow.renderDmToolbar = () => { Glow.renderDmList = () => { const box = Glow.els.dmBox; box.replaceChildren(); + if (!Glow.state.currentUser) { - Glow.renderDmEmptyState("Sign in to view direct messages."); + Glow.renderDmEmptyState("sign in to view direct messages."); return; } @@ -1151,25 +988,25 @@ Glow.renderDmList = () => { empty.className = "empty-state page-empty-state"; const card = document.createElement("div"); card.className = "empty-card"; - card.textContent = "No direct conversations yet."; + card.textContent = "no direct conversations yet."; empty.append(card); box.append(empty); return; } const list = document.createElement("div"); - list.className = - `dm-list ${Glow.state.dmLayoutMode === "compact" ? "compact" : "cards"}`; + list.className = "dm-list cards"; + for (const dm of Glow.state.dms) { list.append(Glow.buildDmCard(dm)); } + box.append(list); }; Glow.buildDmCard = dm => { - const compact = Glow.state.dmLayoutMode === "compact"; const article = document.createElement("article"); - article.className = `page-card dm-card${compact ? " compact" : ""}`; + article.className = "page-card dm-card"; article.tabIndex = 0; const peer = @@ -1183,7 +1020,7 @@ Glow.buildDmCard = dm => { } const title = document.createElement("h3"); - title.textContent = peer?.display_name || peer?.username || "Conversation"; + title.textContent = peer?.display_name || peer?.username || "conversation"; if (peer) Glow.applyUserColor(title, peer); const sender = document.createElement("div"); @@ -1193,7 +1030,7 @@ Glow.buildDmCard = dm => { 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."; + dm.latest_message?.text || dm.last_message_text || "no messages yet."; const footer = document.createElement("div"); footer.className = "page-card-footer dm-card-footer"; @@ -1203,12 +1040,13 @@ Glow.buildDmCard = dm => { unread ? `${dm.unread_count} unread` : (peer?.status_message || " "); 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(); + 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(dm.latest_message?.created_at || dm.last_message_created_at)}` - : `Created ${Glow.relativeTime(dm.created_at)}`; + ? `updated ${Glow.relativeTime(updatedAt)}` + : `created ${Glow.relativeTime(dm.created_at)}`; footer.append(left, updated); @@ -1243,17 +1081,19 @@ Glow.buildDmCard = dm => { Glow.loadDms = async () => { if (!Glow.state.currentUser) { Glow.clearDms(); - Glow.renderDmEmptyState("Sign in to view direct messages."); + 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"); + 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"); } @@ -1261,22 +1101,25 @@ Glow.loadDms = async () => { Glow.openCreateDmDialog = async () => { if (!Glow.state.currentUser) { - Glow.showToast("Sign in to start a direct message.", "error"); + Glow.showToast("sign in to start a direct message.", "error"); return; } + const values = await Glow.openDialog({ - title: "Start direct message", - confirmText: "Open DM", + title: "start direct message", + confirmText: "open dm", fields: [ { name: "username", - label: "Username", + label: "username", placeholder: "username", maxLength: 30 } ] }); + if (!values?.username) return; + try { const data = await Glow.apiCreateDm({ username: values.username.toLowerCase() @@ -1296,7 +1139,7 @@ Glow.openCreateDmDialog = async () => { Glow.schedulePoll(); } } catch (error) { - Glow.showToast(error.message || "Could not start direct message.", "error"); + Glow.showToast(error.message || "could not start direct message.", "error"); } }; @@ -1310,12 +1153,14 @@ Glow.pollMessages = async () => { 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); @@ -1327,7 +1172,7 @@ Glow.pollMessages = async () => { error.code !== "blacklisted" && error.code !== "network" ) { - console.error("Message polling failed:", error); + console.error("message polling failed:", error); } } finally { Glow.state.pollInFlight = false; @@ -1343,10 +1188,12 @@ Glow.pollDmMessages = async () => { 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); @@ -1358,7 +1205,7 @@ Glow.pollDmMessages = async () => { error.code !== "blacklisted" && error.code !== "network" ) { - console.error("DM polling failed:", error); + console.error("dm polling failed:", error); } } finally { Glow.state.dmPollInFlight = false; @@ -1467,7 +1314,7 @@ Glow.renderEmojiAutocomplete = () => { const button = document.createElement("button"); button.className = "emoji-suggestion"; button.type = "button"; - button.textContent = `${item.emoji} ::${item.name}`; + button.textContent = `${item.emoji} ::${item.name}`; if (index === state.selectedIndex) { button.classList.add("active"); } @@ -1480,15 +1327,14 @@ Glow.renderEmojiAutocomplete = () => { panel.classList.remove("hidden"); }; -Glow.openEmojiAutocomplete = (owner, query, triggerIndex) => { +Glow.openEmojiAutocomplete = (owner, query) => { const suggestions = Glow.getEmojiSuggestions(query); Glow.state.emojiAutocomplete = { open: suggestions.length > 0, owner, query, suggestions, - selectedIndex: 0, - triggerIndex + selectedIndex: 0 }; Glow.renderEmojiAutocomplete(); }; @@ -1499,8 +1345,7 @@ Glow.closeEmojiAutocomplete = () => { owner: "main", query: "", suggestions: [], - selectedIndex: 0, - triggerIndex: -1 + selectedIndex: 0 }; Glow.els.emojiAutocomplete?.classList.add("hidden"); Glow.els.threadEmojiAutocomplete?.classList.add("hidden"); @@ -1518,8 +1363,7 @@ 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 index = forcedIndex == null ? state.selectedIndex : forcedIndex; const chosen = state.suggestions[index]; if (!chosen) return false; @@ -1572,10 +1416,10 @@ Glow.handleEmojiAutocompleteInput = owner => { return; } - Glow.openEmojiAutocomplete(owner, parsed.query, parsed.start); + Glow.openEmojiAutocomplete(owner, parsed.query); }; -Glow.handleEmojiAutocompleteKeydown = event, owner => { +Glow.handleEmojiAutocompleteKeydown = (event, owner) => { const state = Glow.state.emojiAutocomplete; if (!state.open || state.owner !== owner) return false; @@ -1615,9 +1459,11 @@ Glow.sendTyping = async (isTyping = true) => { ) { 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); @@ -1644,6 +1490,7 @@ Glow.pollTyping = async () => { Glow.renderTyping(); return; } + data = await Glow.apiRequest(Glow.getChannelTypingUrl(channel)); if (Glow.getActiveChannel()?.slug !== channel.slug) return; Glow.state.typingUsers = data.users || []; @@ -1665,13 +1512,14 @@ Glow.renderTyping = () => { 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…`; + ? `${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(); @@ -1687,6 +1535,7 @@ Glow.sendMessage = async event => { } Glow.els.sendButton.disabled = true; + try { const uploadedAttachments = await Glow.uploadSelectedAttachments(selected); const payload = { @@ -1737,14 +1586,14 @@ Glow.sendMessage = async event => { if (error.code === "restricted") { Glow.showToast( error.restrictedUntil - ? `Restricted until ${new Date(error.restrictedUntil).toLocaleString()}.` - : "Your account is restricted.", + ? `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"); + Glow.showToast(`rate limited. try again in ${error.retryAfter} seconds.`, "error"); } else { - Glow.showToast(error.message || "Could not send message.", "error"); + Glow.showToast(error.message || "could not send message.", "error"); } } finally { Glow.updateChatControls(); @@ -1756,10 +1605,11 @@ Glow.sendMessage = async event => { 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", + title: "delete message?", + body: "this message will be permanently removed.", + confirmText: "delete", danger: true }); if (!result) return; @@ -1768,25 +1618,30 @@ Glow.deleteMessage = async messageId => { 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."); + + Glow.showToast("message deleted."); } catch (error) { - Glow.showToast(error.message, "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; @@ -1820,10 +1675,11 @@ Glow.openThread = async messageId => { 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.showToast(error.message || "could not load thread.", "error"); Glow.returnToChannel(); } finally { if (requestId === Glow.state.threadRequestId) { @@ -1845,6 +1701,7 @@ Glow.returnToChannel = async () => { } else { await Glow.loadMessages(); } + Glow.schedulePoll(); }; @@ -1861,24 +1718,26 @@ Glow.renderThread = () => { box.replaceChildren(); if (!root) { - Glow.renderThreadEmptyState("Thread not found."); + 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"; + 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"); @@ -1892,7 +1751,7 @@ Glow.renderThread = () => { if (!Glow.state.threadReplies.length) { const empty = document.createElement("div"); empty.className = "thread-empty"; - empty.textContent = "No replies yet. Start the conversation."; + empty.textContent = "no replies yet. start the conversation."; box.append(empty); } else { for (const reply of Glow.state.threadReplies) { @@ -1972,6 +1831,7 @@ Glow.sendThreadReply = async event => { if (!text && !selected.length) return; Glow.els.threadSendButton.disabled = true; + try { const uploadedAttachments = await Glow.uploadSelectedAttachments(selected); const data = await Glow.apiRequest( @@ -1997,7 +1857,7 @@ Glow.sendThreadReply = async event => { Glow.renderThread(); Glow.refreshRootReplyCount(root.id, Glow.state.threadReplies.length); } catch (error) { - Glow.showToast(error.message || "Could not send reply.", "error"); + Glow.showToast(error.message || "could not send reply.", "error"); } finally { Glow.updateThreadControls(); if (!Glow.els.threadInput.disabled) { @@ -2011,13 +1871,14 @@ Glow.refreshRootReplyCount = (messageId, count) => { `[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"; + return button.getAttribute("aria-label") === "open message thread"; }); if (!threadButton) return; threadButton.textContent = - count > 0 ? `${count} ${count === 1 ? "reply" : "replies"}` : "Reply"; + count > 0 ? `${count} ${count === 1 ? "reply" : "replies"}` : "reply"; const message = Glow.state.renderedMessages.find( item => Number(item.id) === Number(messageId) @@ -2043,7 +1904,7 @@ Glow.pollThread = async () => { Glow.refreshRootReplyCount(root.id, Glow.state.threadReplies.length); } catch (error) { if (error.status !== 401 && error.status !== 404) { - console.error("Thread polling failed:", error); + console.error("thread polling failed:", error); } } finally { Glow.state.threadPollInFlight = false;