From c5ba80129ec1608a69bdc7209961c4f4d019a3ed Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 31 Oct 2026 08:52:49 +0000 Subject: [PATCH] Update static/js/02-chat.js --- static/js/02-chat.js | 331 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 291 insertions(+), 40 deletions(-) diff --git a/static/js/02-chat.js b/static/js/02-chat.js index be836c3..5c6c52c 100644 --- a/static/js/02-chat.js +++ b/static/js/02-chat.js @@ -2,6 +2,29 @@ "use strict"; window.Glow = window.Glow || {}; +Glow.EMOJI_SHORTCODES = { + smile: "🙂", + grin: "😁", + joy: "😂", + heart: "❤️", + fire: "🔥", + thumbsup: "👍", + thumbsup: "👍", + clap: "👏", + eyes: "👀", + check: "✅", + rocket: "🚀", + thinking: "🤔", + wave: "👋", + tada: "🎉", + sparkles: "✨", + cry: "😢", + laugh: "😄", + sob: "😭", + star: "⭐", + warning: "⚠️" +}; + Glow.clearMessages = () => { Glow.state.lastSeenId = 0; Glow.state.knownMessageIds.clear(); @@ -446,7 +469,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; @@ -592,9 +615,6 @@ 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); } return list; @@ -605,11 +625,13 @@ Glow.buildMessage = (message, grouped = false) => { const isOwnMessage = Glow.state.currentUser && Number(message.user_id) === Number(Glow.state.currentUser.id); + const isDmView = Boolean(Glow.state.activeDm); row.className = `message-row${isOwnMessage ? " self" : ""}` + `${grouped ? " grouped" : ""}` + - `${message.is_pinned ? " pinned" : ""}`; + `${message.is_pinned ? " pinned" : ""}` + + `${isDmView ? " is-dm-view" : ""}`; row.dataset.messageId = String(message.id); row.dataset.userId = String(message.user_id); @@ -647,9 +669,9 @@ Glow.buildMessage = (message, grouped = false) => { if (Glow.reactions?.enabled !== false) { const react = document.createElement("button"); - react.className = "reaction-trigger"; + react.className = "reaction-trigger primary"; react.type = "button"; - react.textContent = "😊"; + react.textContent = "😊"; react.setAttribute("aria-label", "Add reaction"); react.addEventListener("click", () => { Glow.openReactionPopup?.(message); @@ -659,7 +681,7 @@ Glow.buildMessage = (message, grouped = false) => { if (!message.parent_message_id) { const thread = document.createElement("button"); - thread.className = "message-action"; + thread.className = "message-action primary"; thread.type = "button"; const replyCount = Number(message.thread_reply_count) || 0; thread.textContent = @@ -675,7 +697,7 @@ Glow.buildMessage = (message, grouped = false) => { if (Glow.canEditMessage(message)) { const edit = document.createElement("button"); - edit.className = "message-action"; + edit.className = "message-action secondary"; edit.type = "button"; edit.textContent = "Edit"; edit.addEventListener("click", () => { @@ -685,7 +707,7 @@ Glow.buildMessage = (message, grouped = false) => { } const copy = document.createElement("button"); - copy.className = "message-action"; + copy.className = "message-action secondary"; copy.type = "button"; copy.textContent = "Copy"; copy.addEventListener("click", () => { @@ -693,11 +715,10 @@ Glow.buildMessage = (message, grouped = false) => { }); actions.append(copy); - const urls = Glow.extractUrls(message.text); if (urls.length) { const copyUrl = document.createElement("button"); - copyUrl.className = "message-action"; + copyUrl.className = "message-action secondary"; copyUrl.type = "button"; copyUrl.textContent = "URL"; copyUrl.addEventListener("click", () => { @@ -707,7 +728,7 @@ Glow.buildMessage = (message, grouped = false) => { } const receipts = document.createElement("button"); - receipts.className = "message-action"; + receipts.className = "message-action secondary"; receipts.type = "button"; receipts.textContent = `Seen ${Number(message.read_receipt_count) || 0}`; receipts.addEventListener("click", () => Glow.openReceiptDetails(message)); @@ -715,9 +736,9 @@ Glow.buildMessage = (message, grouped = false) => { if (Glow.canModerate()) { const remove = document.createElement("button"); - remove.className = "admin-delete"; + remove.className = "admin-delete secondary"; remove.type = "button"; - remove.textContent = "×"; + remove.textContent = "×"; remove.setAttribute("aria-label", "Delete message"); remove.addEventListener("click", () => { Glow.deleteMessage(message.id); @@ -729,6 +750,7 @@ Glow.buildMessage = (message, grouped = false) => { bubble.className = "message-bubble"; const text = document.createElement("div"); + text.className = "message-text"; text.textContent = message.text; bubble.append(text); @@ -748,7 +770,10 @@ Glow.buildMessage = (message, grouped = false) => { ? [message.attachment] : []; if (attachments.length) { - bubble.append(Glow.buildAttachments(attachments)); + const mediaSection = document.createElement("div"); + mediaSection.className = "message-media-section"; + mediaSection.append(Glow.buildAttachments(attachments)); + bubble.append(mediaSection); } Glow.renderReactionBar(message, bubble); @@ -1079,6 +1104,38 @@ Glow.loadDmMessages = async dm => { } }; +Glow.renderDmToolbar = () => { + const toolbar = document.createElement("div"); + toolbar.className = "dm-toolbar"; + + const actions = document.createElement("div"); + actions.className = "dm-toolbar-actions"; + + const create = document.createElement("button"); + create.className = "button"; + create.type = "button"; + create.textContent = "New DM"; + create.addEventListener("click", () => Glow.openCreateDmDialog()); + + 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); + toolbar.append(actions); + return toolbar; +}; + Glow.renderDmList = () => { const box = Glow.els.dmBox; box.replaceChildren(); @@ -1087,22 +1144,7 @@ Glow.renderDmList = () => { 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); + box.append(Glow.renderDmToolbar()); if (!(Glow.state.dms || []).length) { const empty = document.createElement("div"); @@ -1116,7 +1158,8 @@ Glow.renderDmList = () => { } const list = document.createElement("div"); - list.className = "dm-list"; + list.className = + `dm-list ${Glow.state.dmLayoutMode === "compact" ? "compact" : "cards"}`; for (const dm of Glow.state.dms) { list.append(Glow.buildDmCard(dm)); } @@ -1124,8 +1167,9 @@ Glow.renderDmList = () => { }; Glow.buildDmCard = dm => { + const compact = Glow.state.dmLayoutMode === "compact"; const article = document.createElement("article"); - article.className = "page-card dm-card"; + article.className = `page-card dm-card${compact ? " compact" : ""}`; article.tabIndex = 0; const peer = @@ -1133,19 +1177,31 @@ Glow.buildDmCard = dm => { member => Number(member.id) !== Number(Glow.state.currentUser?.id) ) || dm.other_user || dm.members?.[0]; + const unread = Number(dm.unread_count || 0) > 0; + if (unread) { + article.classList.add("unread"); + } + const title = document.createElement("h3"); title.textContent = peer?.display_name || peer?.username || "Conversation"; if (peer) Glow.applyUserColor(title, peer); + const sender = document.createElement("div"); + sender.className = "dm-card-sender"; + sender.textContent = peer?.username ? `@${peer.username}` : "direct message"; + const subtitle = document.createElement("p"); - subtitle.className = "page-card-excerpt"; + subtitle.className = "page-card-excerpt dm-card-preview"; subtitle.textContent = dm.latest_message?.text || dm.last_message_text || "No messages yet."; const footer = document.createElement("div"); - footer.className = "page-card-footer"; + footer.className = "page-card-footer dm-card-footer"; + const left = document.createElement("span"); - left.textContent = peer?.username ? `@${peer.username}` : "DM"; + left.textContent = + 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(); @@ -1153,6 +1209,7 @@ Glow.buildDmCard = dm => { 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 () => { @@ -1179,7 +1236,7 @@ Glow.buildDmCard = dm => { } }); - article.append(title, subtitle, footer); + article.append(title, sender, subtitle, footer); return article; }; @@ -1357,6 +1414,198 @@ Glow.uploadSelectedAttachments = async files => { return uploaded; }; +Glow.parseEmojiShortcodeAtCursor = input => { + if (!(input instanceof HTMLTextAreaElement)) return null; + const value = input.value || ""; + const caret = input.selectionStart ?? value.length; + const before = value.slice(0, caret); + const match = before.match(/::([a-z0-9_+-]{0,24})$/i); + if (!match) return null; + return { + query: match[1].toLowerCase(), + start: caret - match[0].length, + end: caret + }; +}; + +Glow.getEmojiSuggestions = query => { + const entries = Object.entries(Glow.EMOJI_SHORTCODES); + if (!query) { + return entries.slice(0, 8).map(([name, emoji]) => ({ name, emoji })); + } + return entries + .filter(([name]) => name.includes(query)) + .slice(0, 8) + .map(([name, emoji]) => ({ name, emoji })); +}; + +Glow.renderEmojiAutocomplete = () => { + const state = Glow.state.emojiAutocomplete; + const panel = + state.owner === "thread" + ? Glow.els.threadEmojiAutocomplete + : Glow.els.emojiAutocomplete; + + const otherPanel = + state.owner === "thread" + ? Glow.els.emojiAutocomplete + : Glow.els.threadEmojiAutocomplete; + + otherPanel?.classList.add("hidden"); + otherPanel?.replaceChildren(); + + if (!panel) return; + + panel.replaceChildren(); + + if (!state.open || !state.suggestions.length) { + panel.classList.add("hidden"); + return; + } + + state.suggestions.forEach((item, index) => { + const button = document.createElement("button"); + button.className = "emoji-suggestion"; + button.type = "button"; + button.textContent = `${item.emoji} ::${item.name}`; + if (index === state.selectedIndex) { + button.classList.add("active"); + } + button.addEventListener("click", () => { + Glow.applyEmojiSuggestion(index); + }); + panel.append(button); + }); + + panel.classList.remove("hidden"); +}; + +Glow.openEmojiAutocomplete = (owner, query, triggerIndex) => { + const suggestions = Glow.getEmojiSuggestions(query); + Glow.state.emojiAutocomplete = { + open: suggestions.length > 0, + owner, + query, + suggestions, + selectedIndex: 0, + triggerIndex + }; + Glow.renderEmojiAutocomplete(); +}; + +Glow.closeEmojiAutocomplete = () => { + Glow.state.emojiAutocomplete = { + open: false, + owner: "main", + query: "", + suggestions: [], + selectedIndex: 0, + triggerIndex: -1 + }; + Glow.els.emojiAutocomplete?.classList.add("hidden"); + Glow.els.threadEmojiAutocomplete?.classList.add("hidden"); +}; + +Glow.moveEmojiSelection = delta => { + const state = Glow.state.emojiAutocomplete; + if (!state.open || !state.suggestions.length) return; + const size = state.suggestions.length; + state.selectedIndex = (state.selectedIndex + delta + size) % size; + Glow.renderEmojiAutocomplete(); +}; + +Glow.applyEmojiSuggestion = (forcedIndex = null) => { + const state = Glow.state.emojiAutocomplete; + if (!state.open || !state.suggestions.length) return false; + + const index = + forcedIndex == null ? state.selectedIndex : forcedIndex; + const chosen = state.suggestions[index]; + if (!chosen) return false; + + const input = + state.owner === "thread" + ? Glow.els.threadInput + : Glow.els.messageInput; + + if (!(input instanceof HTMLTextAreaElement)) return false; + + const parsed = Glow.parseEmojiShortcodeAtCursor(input); + if (!parsed) { + Glow.closeEmojiAutocomplete(); + return false; + } + + const value = input.value; + input.value = + value.slice(0, parsed.start) + + chosen.emoji + + value.slice(parsed.end); + + const nextPos = parsed.start + chosen.emoji.length; + input.setSelectionRange(nextPos, nextPos); + + if (state.owner === "thread") { + Glow.autoResizeThreadComposer?.(); + Glow.updateThreadMessageCount?.(); + } else { + Glow.autoResizeComposer(); + Glow.updateMessageCount(); + } + + Glow.closeEmojiAutocomplete(); + input.focus(); + return true; +}; + +Glow.handleEmojiAutocompleteInput = owner => { + const input = + owner === "thread" + ? Glow.els.threadInput + : Glow.els.messageInput; + + const parsed = Glow.parseEmojiShortcodeAtCursor(input); + if (!parsed) { + if (Glow.state.emojiAutocomplete.open && Glow.state.emojiAutocomplete.owner === owner) { + Glow.closeEmojiAutocomplete(); + } + return; + } + + Glow.openEmojiAutocomplete(owner, parsed.query, parsed.start); +}; + +Glow.handleEmojiAutocompleteKeydown = event, owner => { + const state = Glow.state.emojiAutocomplete; + if (!state.open || state.owner !== owner) return false; + + if (event.key === "ArrowDown") { + event.preventDefault(); + Glow.moveEmojiSelection(1); + return true; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + Glow.moveEmojiSelection(-1); + return true; + } + + if (event.key === "Escape") { + event.preventDefault(); + Glow.closeEmojiAutocomplete(); + return true; + } + + if (event.key === "Enter" || event.key === "Tab") { + event.preventDefault(); + Glow.applyEmojiSuggestion(); + return true; + } + + return false; +}; + Glow.sendTyping = async (isTyping = true) => { const channel = Glow.getActiveChannel(); if ( @@ -1416,8 +1665,8 @@ 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"); }; @@ -1481,6 +1730,7 @@ Glow.sendMessage = async event => { Glow.updateMessageCount(); Glow.els.attachmentInput.value = ""; Glow.setSelectedAttachment([]); + Glow.closeEmojiAutocomplete(); Glow.appendMessages([data.message], true, false); Glow.sendTyping(false); } catch (error) { @@ -1743,6 +1993,7 @@ Glow.sendThreadReply = async event => { Glow.updateThreadMessageCount(); Glow.els.threadAttachmentInput.value = ""; Glow.setSelectedThreadAttachment([]); + Glow.closeEmojiAutocomplete(); Glow.renderThread(); Glow.refreshRootReplyCount(root.id, Glow.state.threadReplies.length); } catch (error) {