1780 lines
49 KiB
JavaScript
1780 lines
49 KiB
JavaScript
// static/js/02-chat.js
|
||
"use strict";
|
||
|
||
window.Glow = window.Glow || {};
|
||
|
||
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.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("Message copied.");
|
||
} catch (_) {
|
||
Glow.showToast("Could not copy message.", "error");
|
||
}
|
||
};
|
||
|
||
Glow.extractUrls = text => {
|
||
if (typeof text !== "string" || !text) return [];
|
||
const matches = text.match(/\bhttps?:\/\/[^\s<>"']+/gi) || [];
|
||
return [...new Set(matches)];
|
||
};
|
||
|
||
Glow.isImageAttachment = attachment => {
|
||
return String(attachment?.mime_type || "").startsWith("image/");
|
||
};
|
||
|
||
Glow.isAudioAttachment = attachment => {
|
||
return String(attachment?.mime_type || "").startsWith("audio/");
|
||
};
|
||
|
||
Glow.isVideoAttachment = attachment => {
|
||
return 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.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);
|
||
|
||
row.className =
|
||
`message-row${isOwnMessage ? " self" : ""}` +
|
||
`${grouped ? " grouped" : ""}`;
|
||
|
||
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);
|
||
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";
|
||
react.type = "button";
|
||
react.textContent = "😊";
|
||
react.setAttribute("aria-label", "Add reaction");
|
||
react.addEventListener("click", event => {
|
||
Glow.openReactionPopup?.(message, event.currentTarget);
|
||
});
|
||
actions.append(react);
|
||
}
|
||
|
||
if (!message.parent_message_id) {
|
||
const thread = document.createElement("button");
|
||
thread.className = "message-action";
|
||
thread.type = "button";
|
||
|
||
const replyCount = Number(message.thread_reply_count) || 0;
|
||
thread.textContent =
|
||
replyCount > 0
|
||
? `${replyCount} ${replyCount === 1 ? "reply" : "replies"}`
|
||
: "Reply";
|
||
|
||
thread.setAttribute("aria-label", "Open message thread");
|
||
thread.addEventListener("click", () => {
|
||
Glow.openThread(message.id);
|
||
});
|
||
|
||
actions.append(thread);
|
||
}
|
||
|
||
if (Glow.canEditMessage(message)) {
|
||
const edit = document.createElement("button");
|
||
edit.className = "message-action";
|
||
edit.type = "button";
|
||
edit.textContent = "Edit";
|
||
edit.addEventListener("click", () => {
|
||
Glow.openEditMessageDialog(message);
|
||
});
|
||
actions.append(edit);
|
||
}
|
||
|
||
const copy = document.createElement("button");
|
||
copy.className = "message-action";
|
||
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";
|
||
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";
|
||
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";
|
||
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.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) {
|
||
bubble.append(Glow.buildAttachments(attachments));
|
||
}
|
||
|
||
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.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 (_) {
|
||
// non-blocking
|
||
}
|
||
};
|
||
|
||
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.renderDmList = () => {
|
||
const box = Glow.els.dmBox;
|
||
box.replaceChildren();
|
||
|
||
if (!Glow.state.currentUser) {
|
||
Glow.renderDmEmptyState("Sign in to view direct messages.");
|
||
return;
|
||
}
|
||
|
||
const header = document.createElement("div");
|
||
header.className = "pages-header";
|
||
|
||
const copy = document.createElement("div");
|
||
|
||
const title = document.createElement("h2");
|
||
title.textContent = "Direct messages";
|
||
|
||
const subtitle = document.createElement("p");
|
||
subtitle.textContent = "Private conversations with other users.";
|
||
|
||
copy.append(title, subtitle);
|
||
|
||
const create = document.createElement("button");
|
||
create.className = "button";
|
||
create.type = "button";
|
||
create.textContent = "New DM";
|
||
create.addEventListener("click", () => Glow.openCreateDmDialog());
|
||
|
||
header.append(copy, create);
|
||
box.append(header);
|
||
|
||
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";
|
||
|
||
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.members?.[0];
|
||
|
||
const title = document.createElement("h3");
|
||
title.textContent = peer?.display_name || peer?.username || "Conversation";
|
||
if (peer) {
|
||
Glow.applyUserColor(title, peer);
|
||
}
|
||
|
||
const subtitle = document.createElement("p");
|
||
subtitle.className = "page-card-excerpt";
|
||
subtitle.textContent =
|
||
dm.latest_message?.text || "No messages yet.";
|
||
|
||
const footer = document.createElement("div");
|
||
footer.className = "page-card-footer";
|
||
|
||
const left = document.createElement("span");
|
||
left.textContent = peer?.username ? `@${peer.username}` : "DM";
|
||
|
||
const updated = document.createElement("time");
|
||
updated.dateTime = dm.latest_message?.created_at || dm.created_at;
|
||
updated.title = new Date(dm.latest_message?.created_at || dm.created_at).toLocaleString();
|
||
updated.textContent = dm.latest_message?.created_at
|
||
? `Updated ${Glow.relativeTime(dm.latest_message.created_at)}`
|
||
: `Created ${Glow.relativeTime(dm.created_at)}`;
|
||
|
||
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, 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.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;
|
||
|
||
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) {
|
||
try {
|
||
data = await Glow.apiRequest(
|
||
`/api/teams/${encodeURIComponent(team.slug)}` +
|
||
`/channels/${encodeURIComponent(channel.slug)}/messages`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload)
|
||
}
|
||
);
|
||
} catch (error) {
|
||
if (error.status !== 404) throw error;
|
||
|
||
data = await Glow.apiRequest(
|
||
`/api/rooms/${encodeURIComponent(channel.slug)}/messages`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload)
|
||
}
|
||
);
|
||
}
|
||
} 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.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.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;
|
||
}
|
||
};
|