glo/static/js/02-chat.js
2026-11-02 04:29:12 +00:00

1912 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// static/js/02-chat.js
"use strict";
window.Glow = window.Glow || {};
Glow.EMOJI_SHORTCODES = {
smile: "🙂",
grin: "😁",
joy: "😂",
heart: "❤️",
fire: "🔥",
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.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(String(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.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;
}
Glow.showToast("that slash command is currently unavailable.", "error");
return true;
};
Glow.buildAttachments = attachments => {
const list = document.createElement("div");
list.className = "message-attachments-list";
for (const attachment of attachments || []) {
if (Glow.isImageAttachment(attachment)) {
const imageLink = document.createElement("button");
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());
actions.append(create);
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 cards";
for (const dm of Glow.state.dms) {
list.append(Glow.buildDmCard(dm));
}
box.append(list);
};
Glow.buildDmCard = dm => {
const article = document.createElement("article");
article.className = "page-card dm-card";
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");
const updatedAt = dm.latest_message?.created_at || dm.last_message_created_at || dm.created_at;
updated.dateTime = updatedAt;
updated.title = new Date(updatedAt).toLocaleString();
updated.textContent =
dm.latest_message?.created_at || dm.last_message_created_at
? `updated ${Glow.relativeTime(updatedAt)}`
: `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) => {
const suggestions = Glow.getEmojiSuggestions(query);
Glow.state.emojiAutocomplete = {
open: suggestions.length > 0,
owner,
query,
suggestions,
selectedIndex: 0
};
Glow.renderEmojiAutocomplete();
};
Glow.closeEmojiAutocomplete = () => {
Glow.state.emojiAutocomplete = {
open: false,
owner: "main",
query: "",
suggestions: [],
selectedIndex: 0
};
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);
};
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 || "could not delete 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;
}
};