Update static/js/02-chat.js

This commit is contained in:
dev 2026-10-31 08:52:49 +00:00
parent f684f6ff69
commit c5ba80129e

View File

@ -2,6 +2,29 @@
"use strict"; "use strict";
window.Glow = window.Glow || {}; 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.clearMessages = () => {
Glow.state.lastSeenId = 0; Glow.state.lastSeenId = 0;
Glow.state.knownMessageIds.clear(); Glow.state.knownMessageIds.clear();
@ -446,7 +469,7 @@ Glow.handleSlashCommand = async text => {
if (!trimmed.startsWith("/")) return false; if (!trimmed.startsWith("/")) return false;
if (trimmed === "/shrug") { if (trimmed === "/shrug") {
Glow.els.messageInput.value = "¯\\_(ツ)_/¯"; Glow.els.messageInput.value = "¯\\_(ツ)_/¯";
Glow.autoResizeComposer(); Glow.autoResizeComposer();
Glow.updateMessageCount(); Glow.updateMessageCount();
return true; return true;
@ -592,9 +615,6 @@ Glow.buildAttachments = attachments => {
link.rel = "noopener noreferrer"; link.rel = "noopener noreferrer";
link.download = attachment.original_name; 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); list.append(link);
} }
return list; return list;
@ -605,11 +625,13 @@ Glow.buildMessage = (message, grouped = false) => {
const isOwnMessage = const isOwnMessage =
Glow.state.currentUser && Glow.state.currentUser &&
Number(message.user_id) === Number(Glow.state.currentUser.id); Number(message.user_id) === Number(Glow.state.currentUser.id);
const isDmView = Boolean(Glow.state.activeDm);
row.className = row.className =
`message-row${isOwnMessage ? " self" : ""}` + `message-row${isOwnMessage ? " self" : ""}` +
`${grouped ? " grouped" : ""}` + `${grouped ? " grouped" : ""}` +
`${message.is_pinned ? " pinned" : ""}`; `${message.is_pinned ? " pinned" : ""}` +
`${isDmView ? " is-dm-view" : ""}`;
row.dataset.messageId = String(message.id); row.dataset.messageId = String(message.id);
row.dataset.userId = String(message.user_id); row.dataset.userId = String(message.user_id);
@ -647,9 +669,9 @@ Glow.buildMessage = (message, grouped = false) => {
if (Glow.reactions?.enabled !== false) { if (Glow.reactions?.enabled !== false) {
const react = document.createElement("button"); const react = document.createElement("button");
react.className = "reaction-trigger"; react.className = "reaction-trigger primary";
react.type = "button"; react.type = "button";
react.textContent = "😊"; react.textContent = "😊";
react.setAttribute("aria-label", "Add reaction"); react.setAttribute("aria-label", "Add reaction");
react.addEventListener("click", () => { react.addEventListener("click", () => {
Glow.openReactionPopup?.(message); Glow.openReactionPopup?.(message);
@ -659,7 +681,7 @@ Glow.buildMessage = (message, grouped = false) => {
if (!message.parent_message_id) { if (!message.parent_message_id) {
const thread = document.createElement("button"); const thread = document.createElement("button");
thread.className = "message-action"; thread.className = "message-action primary";
thread.type = "button"; thread.type = "button";
const replyCount = Number(message.thread_reply_count) || 0; const replyCount = Number(message.thread_reply_count) || 0;
thread.textContent = thread.textContent =
@ -675,7 +697,7 @@ Glow.buildMessage = (message, grouped = false) => {
if (Glow.canEditMessage(message)) { if (Glow.canEditMessage(message)) {
const edit = document.createElement("button"); const edit = document.createElement("button");
edit.className = "message-action"; edit.className = "message-action secondary";
edit.type = "button"; edit.type = "button";
edit.textContent = "Edit"; edit.textContent = "Edit";
edit.addEventListener("click", () => { edit.addEventListener("click", () => {
@ -685,7 +707,7 @@ Glow.buildMessage = (message, grouped = false) => {
} }
const copy = document.createElement("button"); const copy = document.createElement("button");
copy.className = "message-action"; copy.className = "message-action secondary";
copy.type = "button"; copy.type = "button";
copy.textContent = "Copy"; copy.textContent = "Copy";
copy.addEventListener("click", () => { copy.addEventListener("click", () => {
@ -693,11 +715,10 @@ Glow.buildMessage = (message, grouped = false) => {
}); });
actions.append(copy); actions.append(copy);
const urls = Glow.extractUrls(message.text); const urls = Glow.extractUrls(message.text);
if (urls.length) { if (urls.length) {
const copyUrl = document.createElement("button"); const copyUrl = document.createElement("button");
copyUrl.className = "message-action"; copyUrl.className = "message-action secondary";
copyUrl.type = "button"; copyUrl.type = "button";
copyUrl.textContent = "URL"; copyUrl.textContent = "URL";
copyUrl.addEventListener("click", () => { copyUrl.addEventListener("click", () => {
@ -707,7 +728,7 @@ Glow.buildMessage = (message, grouped = false) => {
} }
const receipts = document.createElement("button"); const receipts = document.createElement("button");
receipts.className = "message-action"; receipts.className = "message-action secondary";
receipts.type = "button"; 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)); receipts.addEventListener("click", () => Glow.openReceiptDetails(message));
@ -715,9 +736,9 @@ Glow.buildMessage = (message, grouped = false) => {
if (Glow.canModerate()) { if (Glow.canModerate()) {
const remove = document.createElement("button"); const remove = document.createElement("button");
remove.className = "admin-delete"; remove.className = "admin-delete secondary";
remove.type = "button"; remove.type = "button";
remove.textContent = "×"; remove.textContent = "×";
remove.setAttribute("aria-label", "Delete message"); remove.setAttribute("aria-label", "Delete message");
remove.addEventListener("click", () => { remove.addEventListener("click", () => {
Glow.deleteMessage(message.id); Glow.deleteMessage(message.id);
@ -729,6 +750,7 @@ Glow.buildMessage = (message, grouped = false) => {
bubble.className = "message-bubble"; bubble.className = "message-bubble";
const text = document.createElement("div"); const text = document.createElement("div");
text.className = "message-text";
text.textContent = message.text; text.textContent = message.text;
bubble.append(text); bubble.append(text);
@ -748,7 +770,10 @@ Glow.buildMessage = (message, grouped = false) => {
? [message.attachment] ? [message.attachment]
: []; : [];
if (attachments.length) { 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); 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 = () => { Glow.renderDmList = () => {
const box = Glow.els.dmBox; const box = Glow.els.dmBox;
box.replaceChildren(); box.replaceChildren();
@ -1087,22 +1144,7 @@ Glow.renderDmList = () => {
return; return;
} }
const header = document.createElement("div"); box.append(Glow.renderDmToolbar());
header.className = "pages-header";
const copy = document.createElement("div");
const title = document.createElement("h2");
title.textContent = "Direct messages";
const subtitle = document.createElement("p");
subtitle.textContent = "Private conversations with other users.";
copy.append(title, subtitle);
const create = document.createElement("button");
create.className = "button";
create.type = "button";
create.textContent = "New DM";
create.addEventListener("click", () => Glow.openCreateDmDialog());
header.append(copy, create);
box.append(header);
if (!(Glow.state.dms || []).length) { if (!(Glow.state.dms || []).length) {
const empty = document.createElement("div"); const empty = document.createElement("div");
@ -1116,7 +1158,8 @@ Glow.renderDmList = () => {
} }
const list = document.createElement("div"); 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) { for (const dm of Glow.state.dms) {
list.append(Glow.buildDmCard(dm)); list.append(Glow.buildDmCard(dm));
} }
@ -1124,8 +1167,9 @@ Glow.renderDmList = () => {
}; };
Glow.buildDmCard = dm => { Glow.buildDmCard = dm => {
const compact = Glow.state.dmLayoutMode === "compact";
const article = document.createElement("article"); const article = document.createElement("article");
article.className = "page-card dm-card"; article.className = `page-card dm-card${compact ? " compact" : ""}`;
article.tabIndex = 0; article.tabIndex = 0;
const peer = const peer =
@ -1133,19 +1177,31 @@ Glow.buildDmCard = dm => {
member => Number(member.id) !== Number(Glow.state.currentUser?.id) member => Number(member.id) !== Number(Glow.state.currentUser?.id)
) || dm.other_user || dm.members?.[0]; ) || 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"); 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); 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"); const subtitle = document.createElement("p");
subtitle.className = "page-card-excerpt"; subtitle.className = "page-card-excerpt dm-card-preview";
subtitle.textContent = 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"); const footer = document.createElement("div");
footer.className = "page-card-footer"; footer.className = "page-card-footer dm-card-footer";
const left = document.createElement("span"); 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"); const updated = document.createElement("time");
updated.dateTime = dm.latest_message?.created_at || dm.last_message_created_at || 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.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 dm.latest_message?.created_at || dm.last_message_created_at
? `Updated ${Glow.relativeTime(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)}`; : `Created ${Glow.relativeTime(dm.created_at)}`;
footer.append(left, updated); footer.append(left, updated);
const open = async () => { const open = async () => {
@ -1179,7 +1236,7 @@ Glow.buildDmCard = dm => {
} }
}); });
article.append(title, subtitle, footer); article.append(title, sender, subtitle, footer);
return article; return article;
}; };
@ -1357,6 +1414,198 @@ Glow.uploadSelectedAttachments = async files => {
return uploaded; 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) => { Glow.sendTyping = async (isTyping = true) => {
const channel = Glow.getActiveChannel(); const channel = Glow.getActiveChannel();
if ( if (
@ -1416,8 +1665,8 @@ Glow.renderTyping = () => {
const names = users.map(user => user.username); const names = users.map(user => user.username);
Glow.els.typingBar.textContent = Glow.els.typingBar.textContent =
names.length === 1 names.length === 1
? `${names[0]} is typing…` ? `${names[0]} is typing…`
: `${names.slice(0, -1).join(", ")} and ${names.at(-1)} are typing…`; : `${names.slice(0, -1).join(", ")} and ${names.at(-1)} are typing…`;
Glow.els.typingBar.classList.remove("hidden"); Glow.els.typingBar.classList.remove("hidden");
}; };
@ -1481,6 +1730,7 @@ Glow.sendMessage = async event => {
Glow.updateMessageCount(); Glow.updateMessageCount();
Glow.els.attachmentInput.value = ""; Glow.els.attachmentInput.value = "";
Glow.setSelectedAttachment([]); Glow.setSelectedAttachment([]);
Glow.closeEmojiAutocomplete();
Glow.appendMessages([data.message], true, false); Glow.appendMessages([data.message], true, false);
Glow.sendTyping(false); Glow.sendTyping(false);
} catch (error) { } catch (error) {
@ -1743,6 +1993,7 @@ Glow.sendThreadReply = async event => {
Glow.updateThreadMessageCount(); Glow.updateThreadMessageCount();
Glow.els.threadAttachmentInput.value = ""; Glow.els.threadAttachmentInput.value = "";
Glow.setSelectedThreadAttachment([]); Glow.setSelectedThreadAttachment([]);
Glow.closeEmojiAutocomplete();
Glow.renderThread(); Glow.renderThread();
Glow.refreshRootReplyCount(root.id, Glow.state.threadReplies.length); Glow.refreshRootReplyCount(root.id, Glow.state.threadReplies.length);
} catch (error) { } catch (error) {