glo/static/js/02-chat.js
2026-10-31 08:52:49 +00:00

2051 lines
63 KiB
JavaScript

// static/js/02-chat.js
"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();
Glow.state.renderedMessages = [];
Glow.els.chatBox.replaceChildren();
Glow.resetUnreadMessages();
};
Glow.clearDms = () => {
Glow.state.dms = [];
Glow.state.activeDm = null;
Glow.state.dmTypingUsers = [];
Glow.els.dmBox?.replaceChildren();
};
Glow.getActiveChannel = () =>
Glow.state.activeDm || Glow.state.activeChannel || Glow.state.activeRoom || null;
Glow.getCurrentConversationKind = () => {
if (Glow.state.activeDm) return "dm";
if (Glow.state.activeTeam && Glow.state.activeChannel) return "channel";
if (Glow.state.activeRoom) return "room";
return null;
};
Glow.getCurrentScopeInfo = () => {
if (Glow.state.activeDm) {
return { scope: "dm", scopeId: Glow.state.activeDm.id };
}
if (Glow.state.activeTeam && Glow.state.activeChannel) {
return { scope: "channel", scopeId: Glow.state.activeChannel.id };
}
if (Glow.state.activeRoom) {
return { scope: "room", scopeId: Glow.state.activeRoom.id };
}
return null;
};
Glow.getChannelMessageUrl = (channel, afterId = null) => {
if (Glow.state.activeDm) {
const base = `/api/dms/${encodeURIComponent(channel.id)}/messages`;
return afterId === null ? base : `${base}?after_id=${encodeURIComponent(afterId)}`;
}
const team = Glow.state.activeTeam;
if (team && Glow.state.activeChannel) {
const base =
`/api/teams/${encodeURIComponent(team.slug)}` +
`/channels/${encodeURIComponent(channel.slug)}/messages`;
return afterId === null ? base : `${base}?after_id=${encodeURIComponent(afterId)}`;
}
const base = `/api/rooms/${encodeURIComponent(channel.slug)}/messages`;
return afterId === null ? base : `${base}?after_id=${encodeURIComponent(afterId)}`;
};
Glow.getChannelTypingUrl = channel => {
if (Glow.state.activeDm) {
return `/api/dms/${encodeURIComponent(channel.id)}/typing`;
}
const team = Glow.state.activeTeam;
if (team && Glow.state.activeChannel) {
return (
`/api/teams/${encodeURIComponent(team.slug)}` +
`/channels/${encodeURIComponent(channel.slug)}/typing`
);
}
return `/api/rooms/${encodeURIComponent(channel.slug)}/typing`;
};
Glow.renderEmptyState = (message = "No messages yet.") => {
if (Glow.els.chatBox.children.length) return;
const state = document.createElement("div");
state.className = "empty-state";
const card = document.createElement("div");
card.className = "empty-card";
card.textContent = message;
state.append(card);
Glow.els.chatBox.append(state);
};
Glow.renderDmEmptyState = (message = "No direct messages yet.") => {
Glow.els.dmBox.replaceChildren();
const state = document.createElement("div");
state.className = "empty-state";
const card = document.createElement("div");
card.className = "empty-card";
card.textContent = message;
state.append(card);
Glow.els.dmBox.append(state);
};
Glow.renderLoadOlderButton = hasMessages => {
Glow.els.chatBox.querySelector(".load-older-wrap")?.remove();
if (!hasMessages) return;
const wrap = document.createElement("div");
wrap.className = "load-older-wrap";
const button = document.createElement("button");
button.className = "small-button";
button.type = "button";
button.textContent = "Load older messages";
button.addEventListener("click", () => Glow.loadOlderMessages());
wrap.append(button);
Glow.els.chatBox.prepend(wrap);
};
Glow.canGroupWithPrevious = message => {
const previous = Glow.state.renderedMessages.at(-1);
if (!previous) return false;
if (Number(previous.user_id) !== Number(message.user_id)) return false;
if (Glow.dateKey(previous.created_at) !== Glow.dateKey(message.created_at)) return false;
const previousTime = new Date(previous.created_at).getTime();
const currentTime = new Date(message.created_at).getTime();
return currentTime - previousTime <= 5 * 60 * 1000;
};
Glow.copyMessage = async text => {
try {
await navigator.clipboard.writeText(text);
Glow.showToast("Copied.");
} catch (_) {
Glow.showToast("Could not copy.", "error");
}
};
Glow.extractUrls = text => {
if (typeof text !== "string" || !text) return [];
const matches = text.match(/\bhttps?:\/\/[^\s<>"']+/gi) || [];
return [...new Set(matches)];
};
Glow.isImageAttachment = attachment =>
String(attachment?.mime_type || "").startsWith("image/");
Glow.isAudioAttachment = attachment =>
String(attachment?.mime_type || "").startsWith("audio/");
Glow.isVideoAttachment = attachment =>
String(attachment?.mime_type || "").startsWith("video/");
Glow.formatBytes = bytes => {
const value = Number(bytes) || 0;
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
};
Glow.buildUrlActions = text => {
const urls = Glow.extractUrls(text);
if (!urls.length) return null;
const row = document.createElement("div");
row.className = "message-url-actions";
for (const url of urls.slice(0, 3)) {
const open = document.createElement("a");
open.className = "small-button";
open.href = url;
open.target = "_blank";
open.rel = "noopener noreferrer";
open.textContent = "Open link";
const copy = document.createElement("button");
copy.className = "small-button";
copy.type = "button";
copy.textContent = "Copy link";
copy.addEventListener("click", () => Glow.copyMessage(url));
row.append(open, copy);
}
return row;
};
Glow.buildProfileLink = (username, message = null) => {
const button = document.createElement("button");
button.className = "message-author profile-link";
button.type = "button";
button.textContent = username;
if (message) Glow.applyUserColor(button, message);
button.addEventListener("click", () => {
Glow.openProfile?.(username);
});
return button;
};
Glow.renderReactionBar = (message, container) => {
const reactions = Array.isArray(message.reactions) ? message.reactions : [];
if (!reactions.length) return;
const row = document.createElement("div");
row.className = "message-reactions";
for (const reaction of reactions) {
const button = document.createElement("button");
button.className = "reaction-chip";
button.type = "button";
button.textContent = `${reaction.emoji} ${reaction.count}`;
if (reaction.reacted) button.classList.add("active");
button.addEventListener("click", async () => {
try {
if (reaction.reacted) {
await Glow.apiRemoveReaction(message.id, reaction.emoji);
} else {
await Glow.apiAddReaction(message.id, reaction.emoji);
}
await Glow.refreshMessageReactions?.(message.id);
} catch (error) {
Glow.showToast(error.message || "Could not update reaction.", "error");
}
});
row.append(button);
}
container.append(row);
};
Glow.updateMessageReactionsDom = (messageId, reactions) => {
const row = Glow.els.chatBox.querySelector(
`[data-message-id="${CSS.escape(String(messageId))}"]`
);
if (!row) return;
row.querySelector(".message-reactions")?.remove();
const bubble = row.querySelector(".message-bubble");
if (!bubble) return;
const message = Glow.state.renderedMessages.find(
item => Number(item.id) === Number(messageId)
);
if (message) message.reactions = reactions || [];
Glow.renderReactionBar({ id: messageId, reactions: reactions || [] }, bubble);
};
Glow.refreshMessageReactions = async messageId => {
const data = await Glow.apiGetMessageReactions(messageId);
Glow.updateMessageReactionsDom(messageId, data.reactions || []);
};
Glow.canEditMessage = message => {
const currentUser = Glow.state.currentUser;
if (!currentUser) return false;
if (Number(message.user_id) === Number(currentUser.id)) return true;
return Glow.canModerate();
};
Glow.openEditMessageDialog = async message => {
const values = await Glow.openDialog({
title: "Edit message",
confirmText: "Save changes",
fields: [
{
name: "text",
label: "Message text",
value: message.text || "",
multiline: true,
rows: 4,
maxLength: Glow.MAX_MESSAGE_LENGTH
}
]
});
if (!values) return;
try {
const attachmentIds = Array.isArray(message.attachments)
? message.attachments.map(item => item.id)
: [];
const data = await Glow.apiEditMessage(message.id, {
text: values.text,
attachment_ids: attachmentIds
});
Glow.replaceRenderedMessage(data.message);
Glow.showToast("Message updated.");
} catch (error) {
Glow.showToast(error.message || "Could not edit message.", "error");
}
};
Glow.openReceiptDetails = async message => {
try {
const data = await Glow.apiGetMessageReceipts(message.id);
const container = document.createElement("div");
container.className = "member-list";
if (!(data.receipts || []).length) {
const empty = document.createElement("div");
empty.className = "member-row";
empty.textContent = "No read receipts yet.";
container.append(empty);
} else {
for (const item of data.receipts) {
const row = document.createElement("div");
row.className = "member-row";
const left = document.createElement("span");
left.textContent = item.username;
const right = document.createElement("span");
right.textContent = new Date(item.seen_at).toLocaleString();
row.append(left, right);
container.append(row);
}
}
await Glow.openDialog({
title: "Read receipts",
body: container,
confirmText: "Close",
cancelText: "Close"
});
} catch (error) {
Glow.showToast(error.message || "Could not load read receipts.", "error");
}
};
Glow.toggleBookmark = async message => {
try {
if (message.is_bookmarked) {
await Glow.apiUnbookmarkMessage(message.id);
message.is_bookmarked = false;
Glow.showToast("Bookmark removed.");
} else {
await Glow.apiBookmarkMessage(message.id);
message.is_bookmarked = true;
Glow.showToast("Message bookmarked.");
}
Glow.replaceRenderedMessage(message);
} catch (error) {
Glow.showToast(error.message || "Could not update bookmark.", "error");
}
};
Glow.togglePin = async message => {
try {
if (message.is_pinned) {
await Glow.apiUnpinMessage(message.id);
message.is_pinned = false;
message.pinned_at = null;
Glow.showToast("Message unpinned.");
} else {
await Glow.apiPinMessage(message.id);
message.is_pinned = true;
message.pinned_at = new Date().toISOString();
Glow.showToast("Message pinned.");
}
Glow.replaceRenderedMessage(message);
} catch (error) {
Glow.showToast(error.message || "Could not update pin.", "error");
}
};
Glow.shareMessage = async message => {
try {
const data = await Glow.apiCreateMessageShare(message.id);
const url = new URL(data.share.url, window.location.origin).toString();
await navigator.clipboard.writeText(url);
Glow.showToast("Message share link copied.");
} catch (error) {
Glow.showToast(error.message || "Could not create message share.", "error");
}
};
Glow.shareAttachment = async attachment => {
try {
const data = await Glow.apiCreateAttachmentShare(attachment.id);
const url = new URL(data.share.url, window.location.origin).toString();
await navigator.clipboard.writeText(url);
Glow.showToast("Attachment share link copied.");
} catch (error) {
Glow.showToast(error.message || "Could not create attachment share.", "error");
}
};
Glow.showPinsDialog = async () => {
const scope = Glow.getCurrentScopeInfo();
if (!scope) {
Glow.showToast("Open a conversation first.", "error");
return;
}
try {
const data = await Glow.apiListPins(scope.scope, scope.scopeId);
const container = document.createElement("div");
container.className = "member-list";
if (!(data.messages || []).length) {
const empty = document.createElement("div");
empty.className = "member-row";
empty.textContent = "No pinned messages.";
container.append(empty);
} else {
for (const message of data.messages) {
const row = document.createElement("button");
row.className = "member-row bookmark-row";
row.type = "button";
const left = document.createElement("span");
left.textContent = `${message.username}: ${String(message.text || "").slice(0, 80)}`;
const right = document.createElement("span");
right.textContent = Glow.relativeTime(message.created_at);
row.append(left, right);
row.addEventListener("click", () => {
Glow.closeDialog({});
const node = Glow.els.chatBox.querySelector(
`[data-message-id="${CSS.escape(String(message.id))}"]`
);
if (node) {
node.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
container.append(row);
}
}
await Glow.openDialog({
title: "Pinned messages",
body: container,
confirmText: "Close",
cancelText: "Close"
});
} catch (error) {
Glow.showToast(error.message || "Could not load pins.", "error");
}
};
Glow.showBookmarksDialog = async () => {
try {
const data = await Glow.apiListBookmarks();
const container = document.createElement("div");
container.className = "member-list";
if (!(data.messages || []).length) {
const empty = document.createElement("div");
empty.className = "member-row";
empty.textContent = "No bookmarks yet.";
container.append(empty);
} else {
for (const message of data.messages) {
const row = document.createElement("div");
row.className = "member-row";
const left = document.createElement("div");
left.textContent = `${message.username}: ${String(message.text || "").slice(0, 100)}`;
const right = document.createElement("span");
right.textContent = new Date(message.created_at).toLocaleString();
row.append(left, right);
container.append(row);
}
}
await Glow.openDialog({
title: "Bookmarks",
body: container,
confirmText: "Close",
cancelText: "Close"
});
} catch (error) {
Glow.showToast(error.message || "Could not load bookmarks.", "error");
}
};
Glow.handleSlashCommand = async text => {
const trimmed = String(text || "").trim();
if (!trimmed.startsWith("/")) return false;
if (trimmed === "/shrug") {
Glow.els.messageInput.value = "¯\\_(ツ)_/¯";
Glow.autoResizeComposer();
Glow.updateMessageCount();
return true;
}
if (trimmed.startsWith("/me ")) {
Glow.els.messageInput.value =
`* ${Glow.state.currentUser?.username || "user"} ${trimmed.slice(4).trim()}`;
Glow.autoResizeComposer();
Glow.updateMessageCount();
return true;
}
if (trimmed === "/pins") {
await Glow.showPinsDialog();
Glow.els.messageInput.value = "";
Glow.autoResizeComposer();
Glow.updateMessageCount();
return true;
}
if (trimmed === "/bookmarks") {
await Glow.showBookmarksDialog();
Glow.els.messageInput.value = "";
Glow.autoResizeComposer();
Glow.updateMessageCount();
return true;
}
if (trimmed.startsWith("/schedule ")) {
const body = trimmed.slice("/schedule ".length).trim();
const match = body.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\s+([\s\S]+)$/);
if (!match) {
Glow.showToast("Use /schedule YYYY-MM-DD HH:MM message", "error");
return true;
}
const [, datePart, timePart, messageText] = match;
const local = new Date(`${datePart}T${timePart}:00`);
if (Number.isNaN(local.getTime()) || local.getTime() <= Date.now()) {
Glow.showToast("Choose a future date/time.", "error");
return true;
}
const scope = Glow.getCurrentScopeInfo();
if (!scope) {
Glow.showToast("Open a conversation first.", "error");
return true;
}
try {
await Glow.apiCreateScheduledMessage({
text: messageText,
deliver_at: local.toISOString(),
scope_kind: scope.scope,
scope_id: scope.scopeId,
attachment_ids: []
});
Glow.els.messageInput.value = "";
Glow.autoResizeComposer();
Glow.updateMessageCount();
Glow.showToast("Message scheduled.");
} catch (error) {
Glow.showToast(error.message || "Could not schedule message.", "error");
}
return true;
}
Glow.showToast("Unknown slash command.", "error");
return true;
};
Glow.buildAttachments = attachments => {
const list = document.createElement("div");
list.className = "message-attachments-list";
for (const attachment of attachments || []) {
if (Glow.isImageAttachment(attachment)) {
const imageLink = document.createElement("button");
imageLink.className = "message-image-link";
imageLink.type = "button";
const image = document.createElement("img");
image.className = "message-image-preview";
image.src = attachment.url;
image.alt = attachment.original_name || "Image attachment";
image.loading = "lazy";
imageLink.append(image);
imageLink.addEventListener("click", () => {
Glow.openMediaModal?.({
url: attachment.url,
name: attachment.original_name,
mimeType: attachment.mime_type
});
});
list.append(imageLink);
} else if (Glow.isAudioAttachment(attachment)) {
const audioWrap = document.createElement("div");
audioWrap.className = "message-audio-wrap";
const audio = document.createElement("audio");
audio.controls = true;
audio.preload = "metadata";
audio.src = attachment.url;
const previewButton = document.createElement("button");
previewButton.className = "small-button";
previewButton.type = "button";
previewButton.textContent = "Open player";
previewButton.addEventListener("click", () => {
Glow.openMediaModal?.({
url: attachment.url,
name: attachment.original_name,
mimeType: attachment.mime_type
});
});
audioWrap.append(audio, previewButton);
list.append(audioWrap);
} else if (Glow.isVideoAttachment(attachment)) {
const videoWrap = document.createElement("div");
videoWrap.className = "message-video-wrap";
const video = document.createElement("video");
video.controls = true;
video.preload = "metadata";
video.src = attachment.url;
video.className = "message-video-preview";
const previewButton = document.createElement("button");
previewButton.className = "small-button";
previewButton.type = "button";
previewButton.textContent = "Open video";
previewButton.addEventListener("click", () => {
Glow.openMediaModal?.({
url: attachment.url,
name: attachment.original_name,
mimeType: attachment.mime_type
});
});
videoWrap.append(video, previewButton);
list.append(videoWrap);
}
const link = document.createElement("a");
link.className = "message-attachment";
link.href = attachment.url;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.download = attachment.original_name;
link.textContent = `Attachment: ${attachment.original_name} (${Glow.formatBytes(attachment.size_bytes)})`;
list.append(link);
}
return list;
};
Glow.buildMessage = (message, grouped = false) => {
const row = document.createElement("article");
const isOwnMessage =
Glow.state.currentUser &&
Number(message.user_id) === Number(Glow.state.currentUser.id);
const isDmView = Boolean(Glow.state.activeDm);
row.className =
`message-row${isOwnMessage ? " self" : ""}` +
`${grouped ? " grouped" : ""}` +
`${message.is_pinned ? " pinned" : ""}` +
`${isDmView ? " is-dm-view" : ""}`;
row.dataset.messageId = String(message.id);
row.dataset.userId = String(message.user_id);
row.dataset.username = String(message.username || "");
if (!grouped) {
const meta = document.createElement("div");
meta.className = "message-meta";
const author = Glow.buildProfileLink(message.username, message);
const time = document.createElement("time");
time.className = "message-time";
time.dateTime = message.created_at;
time.textContent = Glow.relativeTime(message.created_at);
time.title = new Date(message.created_at).toLocaleString();
meta.append(author, time);
if (message.is_pinned) {
const pin = document.createElement("span");
pin.className = "message-pin-badge";
pin.textContent = "Pinned";
meta.append(pin);
}
row.append(meta);
}
const content = document.createElement("div");
content.className = "message-content-row";
const actions = document.createElement("div");
actions.className = "message-actions";
if (Glow.reactions?.enabled !== false) {
const react = document.createElement("button");
react.className = "reaction-trigger primary";
react.type = "button";
react.textContent = "😊";
react.setAttribute("aria-label", "Add reaction");
react.addEventListener("click", () => {
Glow.openReactionPopup?.(message);
});
actions.append(react);
}
if (!message.parent_message_id) {
const thread = document.createElement("button");
thread.className = "message-action primary";
thread.type = "button";
const replyCount = Number(message.thread_reply_count) || 0;
thread.textContent =
replyCount > 0
? `${replyCount} ${replyCount === 1 ? "reply" : "replies"}`
: "Reply";
thread.setAttribute("aria-label", "Open message thread");
thread.addEventListener("click", () => {
Glow.openThread(message.id);
});
actions.append(thread);
}
if (Glow.canEditMessage(message)) {
const edit = document.createElement("button");
edit.className = "message-action secondary";
edit.type = "button";
edit.textContent = "Edit";
edit.addEventListener("click", () => {
Glow.openEditMessageDialog(message);
});
actions.append(edit);
}
const copy = document.createElement("button");
copy.className = "message-action secondary";
copy.type = "button";
copy.textContent = "Copy";
copy.addEventListener("click", () => {
Glow.copyMessage(message.text);
});
actions.append(copy);
const urls = Glow.extractUrls(message.text);
if (urls.length) {
const copyUrl = document.createElement("button");
copyUrl.className = "message-action secondary";
copyUrl.type = "button";
copyUrl.textContent = "URL";
copyUrl.addEventListener("click", () => {
Glow.copyMessage(urls[0]);
});
actions.append(copyUrl);
}
const receipts = document.createElement("button");
receipts.className = "message-action secondary";
receipts.type = "button";
receipts.textContent = `Seen ${Number(message.read_receipt_count) || 0}`;
receipts.addEventListener("click", () => Glow.openReceiptDetails(message));
actions.append(receipts);
if (Glow.canModerate()) {
const remove = document.createElement("button");
remove.className = "admin-delete secondary";
remove.type = "button";
remove.textContent = "×";
remove.setAttribute("aria-label", "Delete message");
remove.addEventListener("click", () => {
Glow.deleteMessage(message.id);
});
actions.append(remove);
}
const bubble = document.createElement("div");
bubble.className = "message-bubble";
const text = document.createElement("div");
text.className = "message-text";
text.textContent = message.text;
bubble.append(text);
if (message.is_edited) {
const edited = document.createElement("div");
edited.className = "message-edited";
edited.textContent = "edited";
bubble.append(edited);
}
const urlActions = Glow.buildUrlActions(message.text);
if (urlActions) bubble.append(urlActions);
const attachments = Array.isArray(message.attachments)
? message.attachments
: message.attachment
? [message.attachment]
: [];
if (attachments.length) {
const mediaSection = document.createElement("div");
mediaSection.className = "message-media-section";
mediaSection.append(Glow.buildAttachments(attachments));
bubble.append(mediaSection);
}
Glow.renderReactionBar(message, bubble);
content.append(...(isOwnMessage ? [actions, bubble] : [bubble, actions]));
row.append(content);
return row;
};
Glow.replaceRenderedMessage = updatedMessage => {
const index = Glow.state.renderedMessages.findIndex(
item => Number(item.id) === Number(updatedMessage.id)
);
if (index === -1) return;
Glow.state.renderedMessages[index] = updatedMessage;
const existing = Glow.els.chatBox.querySelector(
`[data-message-id="${CSS.escape(String(updatedMessage.id))}"]`
);
if (!existing) return;
const prev = Glow.state.renderedMessages[index - 1];
const grouped = !!(
prev &&
Number(prev.user_id) === Number(updatedMessage.user_id) &&
Glow.dateKey(prev.created_at) === Glow.dateKey(updatedMessage.created_at) &&
new Date(updatedMessage.created_at).getTime() - new Date(prev.created_at).getTime() <=
5 * 60 * 1000
);
existing.replaceWith(Glow.buildMessage(updatedMessage, grouped));
};
Glow.notifyForMessage = message => {
const currentUser = Glow.state.currentUser;
const isOwnMessage =
currentUser &&
Number(message.user_id) === Number(currentUser.id);
if (isOwnMessage) return;
const isMention =
currentUser &&
String(message.text || "")
.toLowerCase()
.includes(`@${currentUser.username.toLowerCase()}`);
if (!document.hidden && !isMention) return;
Glow.state.unreadMessages += 1;
Glow.updateDocumentTitle();
Glow.showNewMessages();
if (
Glow.state.notificationsEnabled &&
"Notification" in window &&
Notification.permission === "granted"
) {
let contextName = "glow";
if (Glow.state.activeDm) {
const peer =
Glow.state.activeDm.other_user ||
(Glow.state.activeDm.members || []).find(
member => Number(member.id) !== Number(currentUser?.id)
);
contextName = peer?.display_name || peer?.username || "direct message";
} else {
contextName =
Glow.state.activeChannel?.name ||
Glow.state.activeRoom?.name ||
"glow";
}
const title = isMention
? `Mention from ${message.username}`
: `New message in ${contextName}`;
new Notification(title, {
body: String(message.text || "").slice(0, 180),
tag: `glow-message-${message.id}`
});
}
};
Glow.markVisibleMessagesRead = async () => {
const ids = Glow.state.renderedMessages.map(item => item.id).slice(-100);
if (!ids.length || !Glow.state.currentUser) return;
try {
await Glow.apiMarkRead(ids);
} catch (_) {}
};
Glow.appendMessages = (messages, forceScroll = false, notify = false) => {
if (!Array.isArray(messages) || !messages.length) {
if (!Glow.state.knownMessageIds.size) {
Glow.renderEmptyState();
}
return;
}
const nearBottom = Glow.isNearBottom();
const fragment = document.createDocumentFragment();
for (const message of messages) {
const id = Number(message.id);
if (!id || Glow.state.knownMessageIds.has(id)) continue;
const previous = Glow.state.renderedMessages.at(-1);
const dateChanged =
!previous ||
Glow.dateKey(previous.created_at) !== Glow.dateKey(message.created_at);
const grouped = Glow.canGroupWithPrevious(message);
if (dateChanged) {
const separator = document.createElement("div");
separator.className = "date-separator";
separator.dataset.dateKey = Glow.dateKey(message.created_at);
const text = document.createElement("span");
text.textContent = Glow.formatDateLabel(message.created_at);
separator.append(text);
fragment.append(separator);
}
Glow.state.knownMessageIds.add(id);
Glow.state.lastSeenId = Math.max(Glow.state.lastSeenId, id);
Glow.state.renderedMessages.push(message);
fragment.append(Glow.buildMessage(message, grouped));
if (notify) Glow.notifyForMessage(message);
}
if (fragment.childNodes.length) {
Glow.els.chatBox.querySelector(".empty-state")?.remove();
Glow.els.chatBox.append(fragment);
if (forceScroll || nearBottom) {
Glow.els.chatBox.scrollTop = Glow.els.chatBox.scrollHeight;
if (nearBottom) Glow.resetUnreadMessages();
}
Glow.markVisibleMessagesRead();
Glow.renderLoadOlderButton(Glow.state.renderedMessages.length > 0);
}
};
Glow.prependHistoryMessages = messages => {
if (!Array.isArray(messages) || !messages.length) {
Glow.showToast("No older messages found.");
return;
}
const previousHeight = Glow.els.chatBox.scrollHeight;
Glow.els.chatBox.querySelector(".load-older-wrap")?.remove();
const newItems = [];
for (const message of messages) {
const id = Number(message.id);
if (!id || Glow.state.knownMessageIds.has(id)) continue;
newItems.push(message);
}
Glow.state.renderedMessages = [...newItems, ...Glow.state.renderedMessages];
Glow.state.knownMessageIds = new Set(
Glow.state.renderedMessages.map(item => Number(item.id))
);
const old = [...Glow.state.renderedMessages];
Glow.els.chatBox.replaceChildren();
const fragment = document.createDocumentFragment();
let previous = null;
for (const message of old) {
const dateChanged =
!previous ||
Glow.dateKey(previous.created_at) !== Glow.dateKey(message.created_at);
const grouped =
!!previous &&
Number(previous.user_id) === Number(message.user_id) &&
Glow.dateKey(previous.created_at) === Glow.dateKey(message.created_at) &&
new Date(message.created_at).getTime() - new Date(previous.created_at).getTime() <=
5 * 60 * 1000;
if (dateChanged) {
const separator = document.createElement("div");
separator.className = "date-separator";
const label = document.createElement("span");
label.textContent = Glow.formatDateLabel(message.created_at);
separator.append(label);
fragment.append(separator);
}
fragment.append(Glow.buildMessage(message, grouped));
previous = message;
}
Glow.els.chatBox.append(fragment);
Glow.renderLoadOlderButton(Glow.state.renderedMessages.length > 0);
const newHeight = Glow.els.chatBox.scrollHeight;
Glow.els.chatBox.scrollTop += newHeight - previousHeight;
};
Glow.loadOlderMessages = async () => {
const oldest = Glow.state.renderedMessages[0];
if (!oldest) return;
try {
let params;
if (Glow.state.activeDm) {
params = {
kind: "dm",
dm_id: Glow.state.activeDm.id,
before_id: oldest.id
};
} else if (Glow.state.activeTeam && Glow.state.activeChannel) {
params = {
kind: "channel",
slug: Glow.state.activeChannel.slug,
team_slug: Glow.state.activeTeam.slug,
before_id: oldest.id
};
} else {
params = {
kind: "room",
slug: Glow.state.activeRoom.slug,
before_id: oldest.id
};
}
const data = await Glow.apiLoadHistory(params);
Glow.prependHistoryMessages(data.messages || []);
} catch (error) {
Glow.showToast(error.message || "Could not load older messages.", "error");
}
};
Glow.loadMessages = async () => {
const room = Glow.state.activeRoom;
if (!room) return;
const requestId = ++Glow.state.messageRequestId;
Glow.els.chatBox.setAttribute("aria-busy", "true");
try {
const data = await Glow.apiRequest(
`/api/rooms/${encodeURIComponent(room.slug)}/messages`
);
if (
requestId !== Glow.state.messageRequestId ||
Glow.state.activeRoom?.slug !== room.slug
) {
return;
}
Glow.clearMessages();
Glow.appendMessages(data.messages || [], true, false);
Glow.state.lastSeenId = Number(data.latest_id) || 0;
Glow.state.initialized = true;
if (!(data.messages || []).length) {
Glow.renderEmptyState();
}
Glow.renderLoadOlderButton((data.messages || []).length > 0);
} catch (error) {
if (requestId !== Glow.state.messageRequestId) return;
Glow.clearMessages();
Glow.renderEmptyState(error.message || "Could not load messages.");
Glow.showToast(error.message || "Could not load messages.", "error");
} finally {
if (requestId === Glow.state.messageRequestId) {
Glow.els.chatBox.setAttribute("aria-busy", "false");
}
}
};
Glow.loadChannelMessages = async () => {
const channel = Glow.getActiveChannel();
if (!channel) return;
const requestId = ++Glow.state.channelRequestId;
Glow.els.chatBox.setAttribute("aria-busy", "true");
try {
const data = await Glow.apiRequest(Glow.getChannelMessageUrl(channel));
if (
requestId !== Glow.state.channelRequestId ||
Glow.getActiveChannel()?.slug !== channel.slug
) {
return;
}
Glow.clearMessages();
Glow.state.activeChannel = data.channel || channel;
Glow.state.activeRoom = Glow.state.activeChannel;
Glow.appendMessages(data.messages || [], true, false);
Glow.state.lastSeenId = Number(data.latest_id) || 0;
Glow.state.initialized = true;
if (!(data.messages || []).length) {
Glow.renderEmptyState();
}
Glow.renderLoadOlderButton((data.messages || []).length > 0);
} catch (error) {
if (requestId !== Glow.state.channelRequestId) return;
Glow.clearMessages();
Glow.renderEmptyState(error.message || "Could not load channel messages.");
Glow.showToast(error.message || "Could not load channel messages.", "error");
} finally {
if (requestId === Glow.state.channelRequestId) {
Glow.els.chatBox.setAttribute("aria-busy", "false");
}
}
};
Glow.loadDmMessages = async dm => {
if (!dm) return;
const requestId = ++Glow.state.dmRequestId;
Glow.els.chatBox.setAttribute("aria-busy", "true");
try {
const data = await Glow.apiGetDmMessages(dm.id);
if (
requestId !== Glow.state.dmRequestId ||
Number(Glow.state.activeDm?.id) !== Number(dm.id)
) {
return;
}
Glow.clearMessages();
Glow.state.activeDm = data.dm || dm;
Glow.appendMessages(data.messages || [], true, false);
Glow.state.lastSeenId = Number(data.latest_id) || 0;
Glow.state.initialized = true;
if (!(data.messages || []).length) {
Glow.renderEmptyState("No direct messages yet.");
}
Glow.renderLoadOlderButton((data.messages || []).length > 0);
} catch (error) {
if (requestId !== Glow.state.dmRequestId) return;
Glow.clearMessages();
Glow.renderEmptyState(error.message || "Could not load direct messages.");
Glow.showToast(error.message || "Could not load direct messages.", "error");
} finally {
if (requestId === Glow.state.dmRequestId) {
Glow.els.chatBox.setAttribute("aria-busy", "false");
}
}
};
Glow.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();
if (!Glow.state.currentUser) {
Glow.renderDmEmptyState("Sign in to view direct messages.");
return;
}
box.append(Glow.renderDmToolbar());
if (!(Glow.state.dms || []).length) {
const empty = document.createElement("div");
empty.className = "empty-state page-empty-state";
const card = document.createElement("div");
card.className = "empty-card";
card.textContent = "No direct conversations yet.";
empty.append(card);
box.append(empty);
return;
}
const list = document.createElement("div");
list.className =
`dm-list ${Glow.state.dmLayoutMode === "compact" ? "compact" : "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.tabIndex = 0;
const peer =
(dm.members || []).find(
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 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 dm-card-footer";
const left = document.createElement("span");
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();
updated.textContent =
dm.latest_message?.created_at || dm.last_message_created_at
? `Updated ${Glow.relativeTime(dm.latest_message?.created_at || dm.last_message_created_at)}`
: `Created ${Glow.relativeTime(dm.created_at)}`;
footer.append(left, updated);
const open = async () => {
Glow.state.activeDm = dm;
Glow.state.activeTeam = null;
Glow.state.activeChannel = null;
Glow.state.activeRoom = null;
Glow.state.activeView = "dm";
Glow.state.typingUsers = [];
Glow.renderTyping();
Glow.updateViewUI();
await Glow.loadDmMessages(dm);
Glow.pollTyping();
Glow.pingPresence?.();
Glow.refreshPresence?.();
Glow.schedulePoll();
};
article.addEventListener("click", open);
article.addEventListener("keydown", event => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
open();
}
});
article.append(title, sender, subtitle, footer);
return article;
};
Glow.loadDms = async () => {
if (!Glow.state.currentUser) {
Glow.clearDms();
Glow.renderDmEmptyState("Sign in to view direct messages.");
return;
}
Glow.els.dmBox.setAttribute("aria-busy", "true");
try {
const data = await Glow.apiListDms();
Glow.state.dms = data.dms || [];
Glow.renderDmList();
} catch (error) {
Glow.renderDmEmptyState(error.message || "Could not load direct messages.");
Glow.showToast(error.message || "Could not load direct messages.", "error");
} finally {
Glow.els.dmBox.setAttribute("aria-busy", "false");
}
};
Glow.openCreateDmDialog = async () => {
if (!Glow.state.currentUser) {
Glow.showToast("Sign in to start a direct message.", "error");
return;
}
const values = await Glow.openDialog({
title: "Start direct message",
confirmText: "Open DM",
fields: [
{
name: "username",
label: "Username",
placeholder: "username",
maxLength: 30
}
]
});
if (!values?.username) return;
try {
const data = await Glow.apiCreateDm({
username: values.username.toLowerCase()
});
await Glow.loadDms();
const dm = data.dm;
if (dm) {
Glow.state.activeDm = dm;
Glow.state.activeTeam = null;
Glow.state.activeChannel = null;
Glow.state.activeRoom = null;
Glow.state.activeView = "dm";
Glow.updateViewUI();
await Glow.loadDmMessages(dm);
Glow.pingPresence?.();
Glow.refreshPresence?.();
Glow.schedulePoll();
}
} catch (error) {
Glow.showToast(error.message || "Could not start direct message.", "error");
}
};
Glow.pollMessages = async () => {
const channel = Glow.getActiveChannel();
if (Glow.state.activeDm) return Glow.pollDmMessages();
if (Glow.state.pollInFlight || !channel || document.hidden) return;
Glow.state.pollInFlight = true;
try {
let afterId = Glow.state.lastSeenId;
let hasMore = true;
let safety = 0;
while (hasMore && safety < 20) {
safety += 1;
const data = await Glow.apiRequest(
Glow.getChannelMessageUrl(channel, afterId)
);
if (Glow.getActiveChannel()?.slug !== channel.slug) return;
Glow.appendMessages(data.messages || [], false, true);
afterId = Math.max(afterId, Number(data.latest_id) || 0);
Glow.state.lastSeenId = Math.max(Glow.state.lastSeenId, afterId);
hasMore = Boolean(data.has_more) && (data.messages || []).length > 0;
}
} catch (error) {
if (
error.status !== 401 &&
error.code !== "blacklisted" &&
error.code !== "network"
) {
console.error("Message polling failed:", error);
}
} finally {
Glow.state.pollInFlight = false;
}
};
Glow.pollDmMessages = async () => {
const dm = Glow.state.activeDm;
if (Glow.state.dmPollInFlight || !dm || document.hidden) return;
Glow.state.dmPollInFlight = true;
try {
let afterId = Glow.state.lastSeenId;
let hasMore = true;
let safety = 0;
while (hasMore && safety < 20) {
safety += 1;
const data = await Glow.apiGetDmMessages(dm.id, afterId);
if (Number(Glow.state.activeDm?.id) !== Number(dm.id)) return;
Glow.appendMessages(data.messages || [], false, true);
afterId = Math.max(afterId, Number(data.latest_id) || 0);
Glow.state.lastSeenId = Math.max(Glow.state.lastSeenId, afterId);
hasMore = Boolean(data.has_more) && (data.messages || []).length > 0;
}
} catch (error) {
if (
error.status !== 401 &&
error.code !== "blacklisted" &&
error.code !== "network"
) {
console.error("DM polling failed:", error);
}
} finally {
Glow.state.dmPollInFlight = false;
}
};
Glow.updateChatControls = () => {
const user = Glow.state.currentUser;
const channel = Glow.state.activeDm || Glow.state.activeChannel || Glow.state.activeRoom;
const restricted = user && user.status === "restricted";
const canSend = Boolean(user && channel && !restricted && channel.can_write !== false);
Glow.els.messageInput.disabled = !canSend;
Glow.els.sendButton.disabled = !canSend;
Glow.els.attachmentInput.disabled = !canSend;
Glow.els.attachmentButton.classList.toggle("disabled", !canSend);
if (!user) {
Glow.els.messageInput.placeholder = "sign in to send";
} else if (restricted) {
Glow.els.messageInput.placeholder = "account temporarily restricted";
} else if (Glow.state.activeDm) {
Glow.els.messageInput.placeholder = "direct message";
} else if (channel?.can_write === false) {
Glow.els.messageInput.placeholder = "you cannot post in this channel";
} else {
Glow.els.messageInput.placeholder = "message";
}
};
Glow.setSelectedAttachment = files => {
Glow.state.selectedAttachment = Array.isArray(files) ? files : [];
Glow.els.attachmentName.textContent = Glow.state.selectedAttachment
.map(file => file.name)
.join(", ");
};
Glow.uploadAttachment = async file => {
const form = new FormData();
form.append("attachment", file);
return Glow.apiRequest("/api/attachments", {
method: "POST",
body: form
});
};
Glow.uploadSelectedAttachments = async files => {
const uploaded = [];
for (const file of files) {
const result = await Glow.uploadAttachment(file);
if (result?.attachment) uploaded.push(result.attachment);
}
return uploaded;
};
Glow.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 (
!Glow.state.typingIndicatorsEnabled ||
!Glow.state.currentUser ||
!channel
) {
return;
}
const now = Date.now();
if (isTyping && now - Glow.state.typingLastSent < 1500) return;
Glow.state.typingLastSent = now;
try {
if (Glow.state.activeDm) {
await Glow.apiSetDmTyping(channel.id, isTyping);
} else {
await Glow.apiRequest(Glow.getChannelTypingUrl(channel), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ typing: isTyping })
});
}
} catch (_) {}
};
Glow.pollTyping = async () => {
const channel = Glow.getActiveChannel();
if (!channel || !Glow.state.typingIndicatorsEnabled || document.hidden) return;
try {
let data;
if (Glow.state.activeDm) {
data = await Glow.apiGetDmTyping(channel.id);
if (Number(Glow.state.activeDm?.id) !== Number(channel.id)) return;
Glow.state.dmTypingUsers = data.users || [];
Glow.renderTyping();
return;
}
data = await Glow.apiRequest(Glow.getChannelTypingUrl(channel));
if (Glow.getActiveChannel()?.slug !== channel.slug) return;
Glow.state.typingUsers = data.users || [];
Glow.renderTyping();
} catch (_) {}
};
Glow.renderTyping = () => {
const users = Glow.state.activeDm
? Glow.state.dmTypingUsers || []
: Glow.state.typingUsers || [];
if (!users.length || !Glow.state.typingIndicatorsEnabled) {
Glow.els.typingBar.textContent = "";
Glow.els.typingBar.classList.add("hidden");
return;
}
const names = users.map(user => user.username);
Glow.els.typingBar.textContent =
names.length === 1
? `${names[0]} is typing…`
: `${names.slice(0, -1).join(", ")} and ${names.at(-1)} are typing…`;
Glow.els.typingBar.classList.remove("hidden");
};
Glow.sendMessage = async event => {
event.preventDefault();
const team = Glow.state.activeTeam;
const dm = Glow.state.activeDm;
const channel = Glow.getActiveChannel();
if (!channel || !Glow.state.currentUser) return;
const text = Glow.els.messageInput.value.trim();
const selected = Glow.state.selectedAttachment || [];
if (!text && !selected.length) return;
if (text.startsWith("/")) {
const handled = await Glow.handleSlashCommand(text);
if (handled) return;
}
Glow.els.sendButton.disabled = true;
try {
const uploadedAttachments = await Glow.uploadSelectedAttachments(selected);
const payload = {
text,
attachment_id: uploadedAttachments[0]?.id || null,
attachment_ids: uploadedAttachments.map(item => item.id)
};
let data;
if (dm) {
data = await Glow.apiSendDmMessage(dm.id, payload);
} else if (team && Glow.state.activeChannel) {
data = await Glow.apiRequest(
`/api/teams/${encodeURIComponent(team.slug)}` +
`/channels/${encodeURIComponent(channel.slug)}/messages`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}
);
} else {
data = await Glow.apiRequest(
`/api/rooms/${encodeURIComponent(channel.slug)}/messages`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}
);
}
if (dm) {
if (Number(Glow.state.activeDm?.id) !== Number(dm.id)) return;
} else if (Glow.getActiveChannel()?.slug !== channel.slug) {
return;
}
Glow.els.messageInput.value = "";
Glow.autoResizeComposer();
Glow.updateMessageCount();
Glow.els.attachmentInput.value = "";
Glow.setSelectedAttachment([]);
Glow.closeEmojiAutocomplete();
Glow.appendMessages([data.message], true, false);
Glow.sendTyping(false);
} catch (error) {
if (error.code === "restricted") {
Glow.showToast(
error.restrictedUntil
? `Restricted until ${new Date(error.restrictedUntil).toLocaleString()}.`
: "Your account is restricted.",
"error"
);
} else if (error.status === 429 && error.retryAfter) {
Glow.showToast(`Rate limited. Try again in ${error.retryAfter} seconds.`, "error");
} else {
Glow.showToast(error.message || "Could not send message.", "error");
}
} finally {
Glow.updateChatControls();
if (!Glow.els.messageInput.disabled) {
Glow.els.messageInput.focus();
}
}
};
Glow.deleteMessage = async messageId => {
if (!Glow.canModerate()) return;
const result = await Glow.openDialog({
title: "Delete message?",
body: "This message will be permanently removed.",
confirmText: "Delete",
danger: true
});
if (!result) return;
try {
await Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}`, {
method: "DELETE"
});
Glow.els.chatBox
.querySelector(`[data-message-id="${CSS.escape(String(messageId))}"]`)
?.remove();
Glow.state.knownMessageIds.delete(Number(messageId));
Glow.state.renderedMessages = Glow.state.renderedMessages.filter(
message => Number(message.id) !== Number(messageId)
);
if (!Glow.els.chatBox.querySelector(".message-row")) {
Glow.clearMessages();
Glow.renderEmptyState();
}
Glow.showToast("Message deleted.");
} catch (error) {
Glow.showToast(error.message, "error");
}
};
Glow.openThread = async messageId => {
const requestId = ++Glow.state.threadRequestId;
try {
Glow.state.activeView = "thread";
Glow.state.activeThread = null;
Glow.state.threadReplies = [];
Glow.updateViewUI();
Glow.els.threadBox.setAttribute("aria-busy", "true");
const data = await Glow.apiRequest(
`/api/messages/${encodeURIComponent(messageId)}/thread`
);
if (requestId !== Glow.state.threadRequestId) return;
Glow.state.activeThread = data.root;
Glow.state.threadReplies = data.replies || [];
if (data.dm) {
Glow.state.activeDm = data.dm;
Glow.state.activeTeam = null;
Glow.state.activeChannel = null;
Glow.state.activeRoom = null;
} else {
if (data.team) {
Glow.state.activeTeam = {
...Glow.state.activeTeam,
slug: data.team.slug,
name: data.team.name
};
}
if (data.channel) {
Glow.state.activeChannel = data.channel;
Glow.state.activeRoom = data.channel;
}
}
Glow.renderThread();
} catch (error) {
if (requestId !== Glow.state.threadRequestId) return;
Glow.showToast(error.message || "Could not load thread.", "error");
Glow.returnToChannel();
} finally {
if (requestId === Glow.state.threadRequestId) {
Glow.els.threadBox.setAttribute("aria-busy", "false");
}
}
};
Glow.returnToChannel = async () => {
Glow.state.activeThread = null;
Glow.state.threadReplies = [];
Glow.state.activeView = Glow.state.activeDm ? "dm" : "channel";
Glow.updateViewUI();
if (Glow.state.activeDm) {
await Glow.loadDmMessages(Glow.state.activeDm);
} else if (Glow.state.activeChannel) {
await Glow.loadChannelMessages();
} else {
await Glow.loadMessages();
}
Glow.schedulePoll();
};
Glow.buildThreadMessage = (message, root = false) => {
const row = Glow.buildMessage(message, false);
row.classList.add("thread-message");
if (root) row.classList.add("thread-root");
return row;
};
Glow.renderThread = () => {
const box = Glow.els.threadBox;
const root = Glow.state.activeThread;
box.replaceChildren();
if (!root) {
Glow.renderThreadEmptyState("Thread not found.");
return;
}
const heading = document.createElement("div");
heading.className = "thread-heading";
const label = document.createElement("div");
label.className = "thread-label";
label.textContent = "Thread";
const count = document.createElement("div");
count.className = "thread-count";
count.textContent =
Glow.state.threadReplies.length === 1
? "1 reply"
: `${Glow.state.threadReplies.length} replies`;
heading.append(label, count);
box.append(heading);
box.append(Glow.buildThreadMessage(root, true));
const separator = document.createElement("div");
separator.className = "thread-replies-label";
separator.textContent =
Glow.state.threadReplies.length === 1
? "1 reply"
: `${Glow.state.threadReplies.length} replies`;
box.append(separator);
if (!Glow.state.threadReplies.length) {
const empty = document.createElement("div");
empty.className = "thread-empty";
empty.textContent = "No replies yet. Start the conversation.";
box.append(empty);
} else {
for (const reply of Glow.state.threadReplies) {
box.append(Glow.buildThreadMessage(reply));
}
}
box.scrollTop = box.scrollHeight;
Glow.updateThreadControls();
};
Glow.renderThreadEmptyState = message => {
Glow.els.threadBox.replaceChildren();
const state = document.createElement("div");
state.className = "empty-state";
const card = document.createElement("div");
card.className = "empty-card";
card.textContent = message;
state.append(card);
Glow.els.threadBox.append(state);
};
Glow.updateThreadControls = () => {
const user = Glow.state.currentUser;
const thread = Glow.state.activeThread;
const channel = Glow.state.activeDm || Glow.state.activeChannel || Glow.state.activeRoom;
const restricted = user?.status === "restricted";
const canReply = Boolean(
user &&
thread &&
!restricted &&
(Glow.state.activeDm || channel?.can_write)
);
Glow.els.threadInput.disabled = !canReply;
Glow.els.threadSendButton.disabled = !canReply;
Glow.els.threadAttachmentInput.disabled = !canReply;
Glow.els.threadAttachmentButton.classList.toggle("disabled", !canReply);
if (!user) {
Glow.els.threadInput.placeholder = "sign in to reply";
} else if (restricted) {
Glow.els.threadInput.placeholder = "account temporarily restricted";
} else if (Glow.state.activeDm) {
Glow.els.threadInput.placeholder = "reply in thread";
} else if (!channel?.can_write) {
Glow.els.threadInput.placeholder = "you cannot reply in this thread";
} else {
Glow.els.threadInput.placeholder = "reply in thread";
}
};
Glow.setSelectedThreadAttachment = files => {
Glow.state.selectedThreadAttachment = Array.isArray(files) ? files : [];
Glow.els.threadAttachmentName.textContent =
Glow.state.selectedThreadAttachment.map(file => file.name).join(", ");
};
Glow.updateThreadMessageCount = () => {
Glow.els.threadMessageCount.textContent =
`${Glow.els.threadInput.value.length} / ${Glow.MAX_MESSAGE_LENGTH}`;
};
Glow.autoResizeThreadComposer = () => {
const input = Glow.els.threadInput;
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, 150)}px`;
};
Glow.sendThreadReply = async event => {
event.preventDefault();
const root = Glow.state.activeThread;
if (!root || !Glow.state.currentUser) return;
const text = Glow.els.threadInput.value.trim();
const selected = Glow.state.selectedThreadAttachment || [];
if (!text && !selected.length) return;
Glow.els.threadSendButton.disabled = true;
try {
const uploadedAttachments = await Glow.uploadSelectedAttachments(selected);
const data = await Glow.apiRequest(
`/api/messages/${encodeURIComponent(root.id)}/thread`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text,
attachment_id: uploadedAttachments[0]?.id || null,
attachment_ids: uploadedAttachments.map(item => item.id)
})
}
);
Glow.state.threadReplies.push(data.reply);
Glow.els.threadInput.value = "";
Glow.autoResizeThreadComposer();
Glow.updateThreadMessageCount();
Glow.els.threadAttachmentInput.value = "";
Glow.setSelectedThreadAttachment([]);
Glow.closeEmojiAutocomplete();
Glow.renderThread();
Glow.refreshRootReplyCount(root.id, Glow.state.threadReplies.length);
} catch (error) {
Glow.showToast(error.message || "Could not send reply.", "error");
} finally {
Glow.updateThreadControls();
if (!Glow.els.threadInput.disabled) {
Glow.els.threadInput.focus();
}
}
};
Glow.refreshRootReplyCount = (messageId, count) => {
const row = Glow.els.chatBox.querySelector(
`[data-message-id="${CSS.escape(String(messageId))}"]`
);
if (!row) return;
const threadButton = [...row.querySelectorAll(".message-action")].find(button => {
return button.getAttribute("aria-label") === "Open message thread";
});
if (!threadButton) return;
threadButton.textContent =
count > 0 ? `${count} ${count === 1 ? "reply" : "replies"}` : "Reply";
const message = Glow.state.renderedMessages.find(
item => Number(item.id) === Number(messageId)
);
if (message) {
message.thread_reply_count = count;
}
};
Glow.pollThread = async () => {
const root = Glow.state.activeThread;
if (Glow.state.threadPollInFlight || !root || document.hidden) return;
Glow.state.threadPollInFlight = true;
try {
const data = await Glow.apiRequest(
`/api/messages/${encodeURIComponent(root.id)}/thread`
);
if (Glow.state.activeThread?.id !== root.id) return;
Glow.state.activeThread = data.root;
Glow.state.threadReplies = data.replies || [];
Glow.renderThread();
Glow.refreshRootReplyCount(root.id, Glow.state.threadReplies.length);
} catch (error) {
if (error.status !== 401 && error.status !== 404) {
console.error("Thread polling failed:", error);
}
} finally {
Glow.state.threadPollInFlight = false;
}
};