diff --git a/static/js/02-chat.js b/static/js/02-chat.js index 2282375..a4caa5e 100644 --- a/static/js/02-chat.js +++ b/static/js/02-chat.js @@ -1,6 +1,5 @@ // static/js/02-chat.js "use strict"; - window.Glow = window.Glow || {}; Glow.clearMessages = () => { @@ -22,18 +21,22 @@ 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 "dm"; + return { scope: "dm", scopeId: Glow.state.activeDm.id }; } - if (Glow.state.activeTeam && Glow.state.activeChannel) { - return "channel"; + return { scope: "channel", scopeId: Glow.state.activeChannel.id }; } - if (Glow.state.activeRoom) { - return "room"; + return { scope: "room", scopeId: Glow.state.activeRoom.id }; } - return null; }; @@ -42,17 +45,13 @@ 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 = `/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)}`; }; @@ -61,43 +60,34 @@ 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); }; @@ -105,16 +95,13 @@ 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.addEventListener("click", () => Glow.loadOlderMessages()); - wrap.append(button); Glow.els.chatBox.prepend(wrap); }; @@ -124,7 +111,6 @@ 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; @@ -133,9 +119,9 @@ Glow.canGroupWithPrevious = message => { Glow.copyMessage = async text => { try { await navigator.clipboard.writeText(text); - Glow.showToast("Message copied."); + Glow.showToast("Copied."); } catch (_) { - Glow.showToast("Could not copy message.", "error"); + Glow.showToast("Could not copy.", "error"); } }; @@ -145,17 +131,14 @@ Glow.extractUrls = text => { return [...new Set(matches)]; }; -Glow.isImageAttachment = attachment => { - return String(attachment?.mime_type || "").startsWith("image/"); -}; +Glow.isImageAttachment = attachment => + String(attachment?.mime_type || "").startsWith("image/"); -Glow.isAudioAttachment = attachment => { - return String(attachment?.mime_type || "").startsWith("audio/"); -}; +Glow.isAudioAttachment = attachment => + String(attachment?.mime_type || "").startsWith("audio/"); -Glow.isVideoAttachment = attachment => { - return String(attachment?.mime_type || "").startsWith("video/"); -}; +Glow.isVideoAttachment = attachment => + String(attachment?.mime_type || "").startsWith("video/"); Glow.formatBytes = bytes => { const value = Number(bytes) || 0; @@ -167,10 +150,8 @@ Glow.formatBytes = bytes => { 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"; @@ -184,10 +165,8 @@ Glow.buildUrlActions = text => { copy.type = "button"; copy.textContent = "Copy link"; copy.addEventListener("click", () => Glow.copyMessage(url)); - row.append(open, copy); } - return row; }; @@ -196,9 +175,7 @@ Glow.buildProfileLink = (username, message = null) => { button.className = "message-author profile-link"; button.type = "button"; button.textContent = username; - if (message) { - Glow.applyUserColor(button, message); - } + if (message) Glow.applyUserColor(button, message); button.addEventListener("click", () => { Glow.openProfile?.(username); }); @@ -208,18 +185,14 @@ 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"; button.type = "button"; button.textContent = `${reaction.emoji} ${reaction.count}`; - if (reaction.reacted) button.classList.add("active"); - button.addEventListener("click", async () => { try { if (reaction.reacted) { @@ -232,10 +205,8 @@ Glow.renderReactionBar = (message, container) => { Glow.showToast(error.message || "Could not update reaction.", "error"); } }); - row.append(button); } - container.append(row); }; @@ -244,20 +215,14 @@ 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; const message = Glow.state.renderedMessages.find( item => Number(item.id) === Number(messageId) ); - - if (message) { - message.reactions = reactions || []; - } - + if (message) message.reactions = reactions || []; Glow.renderReactionBar({ id: messageId, reactions: reactions || [] }, bubble); }; @@ -288,19 +253,15 @@ Glow.openEditMessageDialog = async message => { } ] }); - 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) { @@ -311,10 +272,8 @@ Glow.openEditMessageDialog = async message => { 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"; @@ -324,18 +283,14 @@ Glow.openReceiptDetails = async message => { 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, @@ -347,10 +302,223 @@ Glow.openReceiptDetails = async message => { } }; +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"); @@ -362,8 +530,8 @@ Glow.buildAttachments = attachments => { 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, @@ -371,12 +539,10 @@ 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"; @@ -393,13 +559,11 @@ 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"; @@ -417,7 +581,6 @@ Glow.buildAttachments = attachments => { mimeType: attachment.mime_type }); }); - videoWrap.append(video, previewButton); list.append(videoWrap); } @@ -429,9 +592,17 @@ Glow.buildAttachments = attachments => { link.rel = "noopener noreferrer"; link.download = attachment.original_name; link.textContent = `Attachment: ${attachment.original_name} (${Glow.formatBytes(attachment.size_bytes)})`; - list.append(link); - } + const share = document.createElement("button"); + share.className = "small-button"; + share.type = "button"; + share.textContent = "Share file"; + share.addEventListener("click", () => { + Glow.shareAttachment(attachment); + }); + + list.append(link, share); + } return list; }; @@ -443,7 +614,8 @@ Glow.buildMessage = (message, grouped = false) => { row.className = `message-row${isOwnMessage ? " self" : ""}` + - `${grouped ? " grouped" : ""}`; + `${grouped ? " grouped" : ""}` + + `${message.is_pinned ? " pinned" : ""}`; row.dataset.messageId = String(message.id); row.dataset.userId = String(message.user_id); @@ -462,6 +634,14 @@ Glow.buildMessage = (message, grouped = false) => { 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); } @@ -477,8 +657,8 @@ Glow.buildMessage = (message, grouped = false) => { react.type = "button"; react.textContent = "😊"; react.setAttribute("aria-label", "Add reaction"); - react.addEventListener("click", event => { - Glow.openReactionPopup?.(message, event.currentTarget); + react.addEventListener("click", () => { + Glow.openReactionPopup?.(message); }); actions.append(react); } @@ -487,18 +667,15 @@ Glow.buildMessage = (message, grouped = false) => { 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); } @@ -522,6 +699,27 @@ Glow.buildMessage = (message, grouped = false) => { }); actions.append(copy); + const bookmark = document.createElement("button"); + bookmark.className = "message-action"; + bookmark.type = "button"; + bookmark.textContent = message.is_bookmarked ? "Saved" : "Save"; + bookmark.addEventListener("click", () => Glow.toggleBookmark(message)); + actions.append(bookmark); + + const pin = document.createElement("button"); + pin.className = "message-action"; + pin.type = "button"; + pin.textContent = message.is_pinned ? "Unpin" : "Pin"; + pin.addEventListener("click", () => Glow.togglePin(message)); + actions.append(pin); + + const share = document.createElement("button"); + share.className = "message-action"; + share.type = "button"; + share.textContent = "Share"; + share.addEventListener("click", () => Glow.shareMessage(message)); + actions.append(share); + const urls = Glow.extractUrls(message.text); if (urls.length) { const copyUrl = document.createElement("button"); @@ -575,13 +773,11 @@ Glow.buildMessage = (message, grouped = false) => { : 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; @@ -594,7 +790,6 @@ Glow.replaceRenderedMessage = updatedMessage => { if (index === -1) return; Glow.state.renderedMessages[index] = updatedMessage; - const existing = Glow.els.chatBox.querySelector( `[data-message-id="${CSS.escape(String(updatedMessage.id))}"]` ); @@ -617,7 +812,6 @@ Glow.notifyForMessage = message => { const isOwnMessage = currentUser && Number(message.user_id) === Number(currentUser.id); - if (isOwnMessage) return; const isMention = @@ -638,11 +832,12 @@ Glow.notifyForMessage = message => { Notification.permission === "granted" ) { let contextName = "glow"; - if (Glow.state.activeDm) { - const peer = (Glow.state.activeDm.members || []).find( - member => Number(member.id) !== Number(currentUser?.id) - ); + 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 = @@ -650,11 +845,9 @@ Glow.notifyForMessage = message => { 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}` @@ -665,12 +858,9 @@ Glow.notifyForMessage = message => { 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 (_) { - // non-blocking - } + } catch (_) {} }; Glow.appendMessages = (messages, forceScroll = false, notify = false) => { @@ -686,26 +876,20 @@ Glow.appendMessages = (messages, forceScroll = false, notify = false) => { for (const message of messages) { const id = Number(message.id); - - if (!id || Glow.state.knownMessageIds.has(id)) { - continue; - } + 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); } @@ -713,23 +897,18 @@ Glow.appendMessages = (messages, forceScroll = false, notify = false) => { 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 (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); } @@ -758,7 +937,6 @@ Glow.prependHistoryMessages = messages => { const old = [...Glow.state.renderedMessages]; Glow.els.chatBox.replaceChildren(); - const fragment = document.createDocumentFragment(); let previous = null; @@ -766,7 +944,6 @@ Glow.prependHistoryMessages = messages => { const dateChanged = !previous || Glow.dateKey(previous.created_at) !== Glow.dateKey(message.created_at); - const grouped = !!previous && Number(previous.user_id) === Number(message.user_id) && @@ -777,7 +954,6 @@ Glow.prependHistoryMessages = messages => { 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); @@ -790,7 +966,6 @@ Glow.prependHistoryMessages = messages => { Glow.els.chatBox.append(fragment); Glow.renderLoadOlderButton(Glow.state.renderedMessages.length > 0); - const newHeight = Glow.els.chatBox.scrollHeight; Glow.els.chatBox.scrollTop += newHeight - previousHeight; }; @@ -798,10 +973,8 @@ Glow.prependHistoryMessages = messages => { Glow.loadOlderMessages = async () => { const oldest = Glow.state.renderedMessages[0]; if (!oldest) return; - try { let params; - if (Glow.state.activeDm) { params = { kind: "dm", @@ -822,7 +995,6 @@ Glow.loadOlderMessages = async () => { before_id: oldest.id }; } - const data = await Glow.apiLoadHistory(params); Glow.prependHistoryMessages(data.messages || []); } catch (error) { @@ -833,31 +1005,25 @@ Glow.loadOlderMessages = async () => { 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; @@ -874,32 +1040,25 @@ 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; @@ -915,30 +1074,24 @@ 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.renderLoadOlderButton((data.messages || []).length > 0); } catch (error) { if (requestId !== Glow.state.dmRequestId) return; @@ -955,7 +1108,6 @@ Glow.loadDmMessages = async dm => { Glow.renderDmList = () => { const box = Glow.els.dmBox; box.replaceChildren(); - if (!Glow.state.currentUser) { Glow.renderDmEmptyState("Sign in to view direct messages."); return; @@ -963,15 +1115,11 @@ Glow.renderDmList = () => { 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"); @@ -979,18 +1127,15 @@ Glow.renderDmList = () => { 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; @@ -998,11 +1143,9 @@ Glow.renderDmList = () => { const list = document.createElement("div"); list.className = "dm-list"; - for (const dm of Glow.state.dms) { list.append(Glow.buildDmCard(dm)); } - box.append(list); }; @@ -1014,32 +1157,28 @@ Glow.buildDmCard = dm => { const peer = (dm.members || []).find( member => Number(member.id) !== Number(Glow.state.currentUser?.id) - ) || dm.members?.[0]; + ) || 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); - } + if (peer) Glow.applyUserColor(title, peer); const subtitle = document.createElement("p"); subtitle.className = "page-card-excerpt"; subtitle.textContent = - dm.latest_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"; - 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.created_at; - updated.title = new Date(dm.latest_message?.created_at || dm.created_at).toLocaleString(); - updated.textContent = dm.latest_message?.created_at - ? `Updated ${Glow.relativeTime(dm.latest_message.created_at)}` - : `Created ${Glow.relativeTime(dm.created_at)}`; - + 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 () => { @@ -1076,9 +1215,7 @@ Glow.loadDms = async () => { 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 || []; @@ -1096,7 +1233,6 @@ Glow.openCreateDmDialog = async () => { Glow.showToast("Sign in to start a direct message.", "error"); return; } - const values = await Glow.openDialog({ title: "Start direct message", confirmText: "Open DM", @@ -1109,17 +1245,13 @@ Glow.openCreateDmDialog = async () => { } ] }); - 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; @@ -1139,33 +1271,23 @@ Glow.openCreateDmDialog = async () => { Glow.pollMessages = async () => { const channel = Glow.getActiveChannel(); - - if (Glow.state.activeDm) { - return Glow.pollDmMessages(); - } - + if (Glow.state.activeDm) return Glow.pollDmMessages(); if (Glow.state.pollInFlight || !channel || document.hidden) return; - Glow.state.pollInFlight = true; + 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) { @@ -1183,27 +1305,20 @@ Glow.pollMessages = async () => { Glow.pollDmMessages = async () => { const dm = Glow.state.activeDm; - if (Glow.state.dmPollInFlight || !dm || document.hidden) return; - Glow.state.dmPollInFlight = true; + 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) { @@ -1223,7 +1338,6 @@ 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; @@ -1254,7 +1368,6 @@ Glow.setSelectedAttachment = files => { Glow.uploadAttachment = async file => { const form = new FormData(); form.append("attachment", file); - return Glow.apiRequest("/api/attachments", { method: "POST", body: form @@ -1272,7 +1385,6 @@ Glow.uploadSelectedAttachments = async files => { Glow.sendTyping = async (isTyping = true) => { const channel = Glow.getActiveChannel(); - if ( !Glow.state.typingIndicatorsEnabled || !Glow.state.currentUser || @@ -1280,12 +1392,9 @@ 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); @@ -1301,32 +1410,19 @@ Glow.sendTyping = async (isTyping = true) => { Glow.pollTyping = async () => { const channel = Glow.getActiveChannel(); - - if (!channel || !Glow.state.typingIndicatorsEnabled || document.hidden) { - return; - } + 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; - } - + 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; - } - + if (Glow.getActiveChannel()?.slug !== channel.slug) return; Glow.state.typingUsers = data.users || []; Glow.renderTyping(); } catch (_) {} @@ -1344,31 +1440,30 @@ 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…`; - 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; - Glow.els.sendButton.disabled = true; + 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 = { @@ -1378,32 +1473,18 @@ Glow.sendMessage = async event => { }; let data; - if (dm) { data = await Glow.apiSendDmMessage(dm.id, payload); } else if (team && Glow.state.activeChannel) { - try { - 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) - } - ); - } catch (error) { - if (error.status !== 404) throw error; - - data = await Glow.apiRequest( - `/api/rooms/${encodeURIComponent(channel.slug)}/messages`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) - } - ); - } + 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`, @@ -1451,35 +1532,29 @@ 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", 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"); @@ -1488,19 +1563,16 @@ Glow.deleteMessage = async messageId => { 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; @@ -1519,13 +1591,11 @@ Glow.openThread = async messageId => { 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; @@ -1542,7 +1612,6 @@ Glow.returnToChannel = async () => { Glow.state.activeThread = null; Glow.state.threadReplies = []; Glow.state.activeView = Glow.state.activeDm ? "dm" : "channel"; - Glow.updateViewUI(); if (Glow.state.activeDm) { @@ -1552,7 +1621,6 @@ Glow.returnToChannel = async () => { } else { await Glow.loadMessages(); } - Glow.schedulePoll(); }; @@ -1566,7 +1634,6 @@ Glow.buildThreadMessage = (message, root = false) => { Glow.renderThread = () => { const box = Glow.els.threadBox; const root = Glow.state.activeThread; - box.replaceChildren(); if (!root) { @@ -1576,21 +1643,18 @@ Glow.renderThread = () => { 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"); @@ -1599,7 +1663,6 @@ Glow.renderThread = () => { Glow.state.threadReplies.length === 1 ? "1 reply" : `${Glow.state.threadReplies.length} replies`; - box.append(separator); if (!Glow.state.threadReplies.length) { @@ -1619,14 +1682,11 @@ Glow.renderThread = () => { 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); }; @@ -1636,7 +1696,6 @@ Glow.updateThreadControls = () => { 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 && @@ -1681,20 +1740,16 @@ Glow.autoResizeThreadComposer = () => { 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`, { @@ -1709,13 +1764,11 @@ Glow.sendThreadReply = async event => { ); 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) { @@ -1733,11 +1786,9 @@ 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"; }); - if (!threadButton) return; threadButton.textContent = @@ -1746,7 +1797,6 @@ Glow.refreshRootReplyCount = (messageId, count) => { const message = Glow.state.renderedMessages.find( item => Number(item.id) === Number(messageId) ); - if (message) { message.thread_reply_count = count; } @@ -1754,17 +1804,14 @@ Glow.refreshRootReplyCount = (messageId, count) => { Glow.pollThread = async () => { const root = Glow.state.activeThread; - if (Glow.state.threadPollInFlight || !root || document.hidden) return; - Glow.state.threadPollInFlight = true; + 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(); @@ -1776,4 +1823,4 @@ Glow.pollThread = async () => { } finally { Glow.state.threadPollInFlight = false; } -}; +}; \ No newline at end of file