glo/static/js/00-config.js

498 lines
16 KiB
JavaScript

// static/js/00-config.js
"use strict";
window.Glow = window.Glow || {};
Glow.els = {
chatBox: document.getElementById("chatBox"),
dmBox: document.getElementById("dmBox"),
feedBox: document.getElementById("feedBox"),
feedButton: document.getElementById("feedButton"),
dmsButton: document.getElementById("dmsButton"),
composerWrap: document.getElementById("composerWrap"),
chatForm: document.getElementById("chatForm"),
messageInput: document.getElementById("messageInput"),
sendButton: document.getElementById("sendButton"),
messageCount: document.getElementById("messageCount"),
attachmentInput: document.getElementById("attachmentInput"),
attachmentButton: document.getElementById("attachmentButton"),
attachmentName: document.getElementById("attachmentName"),
authOverlay: document.getElementById("authOverlay"),
authNotice: document.getElementById("authNotice"),
accessKeyInput: document.getElementById("accessKeyInput"),
usernameInput: document.getElementById("usernameInput"),
passwordInput: document.getElementById("passwordInput"),
loginButton: document.getElementById("loginButton"),
registerButton: document.getElementById("registerButton"),
roomSelect: document.getElementById("roomSelect"),
headerTitle: document.getElementById("headerTitle"),
headerSubtitle: document.getElementById("headerSubtitle"),
headerAccount: document.getElementById("headerAccount"),
presenceDot: document.getElementById("presenceDot"),
presenceLabel: document.getElementById("presenceLabel"),
logoutButton: document.getElementById("logoutButton"),
adminFab: document.getElementById("adminFab"),
adminPanel: document.getElementById("adminPanel"),
closeAdminButton: document.getElementById("closeAdminButton"),
clearMessagesButton: document.getElementById("clearMessagesButton"),
createRoomButton: document.getElementById("createRoomButton"),
typingToggleButton: document.getElementById("typingToggleButton"),
notificationsToggleButton: document.getElementById("notificationsToggleButton"),
themeButton: document.getElementById("themeButton"),
adminUsers: document.getElementById("adminUsers"),
adminRooms: document.getElementById("adminRooms"),
statusToast: document.getElementById("statusToast"),
blockedOverlay: document.getElementById("blockedOverlay"),
blockedMessage: document.getElementById("blockedMessage"),
newMessagesBar: document.getElementById("newMessagesBar"),
jumpToLatestButton: document.getElementById("jumpToLatestButton"),
typingBar: document.getElementById("typingBar"),
dialogOverlay: document.getElementById("dialogOverlay"),
dialogTitle: document.getElementById("dialogTitle"),
dialogBody: document.getElementById("dialogBody"),
dialogActions: document.getElementById("dialogActions"),
teamSelect: document.getElementById("teamSelect"),
pagesButton: document.getElementById("pagesButton"),
backButton: document.getElementById("backButton"),
threadBox: document.getElementById("threadBox"),
threadComposerWrap: document.getElementById("threadComposerWrap"),
threadForm: document.getElementById("threadForm"),
threadInput: document.getElementById("threadInput"),
threadSendButton: document.getElementById("threadSendButton"),
threadMessageCount: document.getElementById("threadMessageCount"),
threadAttachmentInput: document.getElementById("threadAttachmentInput"),
threadAttachmentButton: document.getElementById("threadAttachmentButton"),
threadAttachmentName: document.getElementById("threadAttachmentName"),
pagesBox: document.getElementById("pagesBox"),
pageDetailBox: document.getElementById("pageDetailBox"),
settingsButton: document.getElementById("settingsButton"),
profileBox: document.getElementById("profileBox"),
headerMenuOverlay: document.getElementById("headerMenuOverlay"),
headerMenuList: document.getElementById("headerMenuList"),
reactionPopupOverlay: document.getElementById("reactionPopupOverlay"),
reactionPopupGrid: document.getElementById("reactionPopupGrid"),
reactionPopupList: document.getElementById("reactionPopupList"),
reactionCustomInput: document.getElementById("reactionCustomInput"),
reactionCustomButton: document.getElementById("reactionCustomButton"),
closeReactionPopupButton: document.getElementById("closeReactionPopupButton"),
mediaOverlay: document.getElementById("mediaOverlay"),
mediaTitle: document.getElementById("mediaTitle"),
mediaImage: document.getElementById("mediaImage"),
mediaAudio: document.getElementById("mediaAudio"),
mediaVideo: document.getElementById("mediaVideo"),
mediaFileFallback: document.getElementById("mediaFileFallback"),
mediaFallbackLink: document.getElementById("mediaFallbackLink"),
mediaDownloadLink: document.getElementById("mediaDownloadLink"),
closeMediaButton: document.getElementById("closeMediaButton"),
callOverlay: document.getElementById("callOverlay"),
callStatus: document.getElementById("callStatus"),
callPeerLabel: document.getElementById("callPeerLabel"),
startCallButton: document.getElementById("startCallButton"),
answerCallButton: document.getElementById("answerCallButton"),
addToCallButton: document.getElementById("addToCallButton"),
muteCallButton: document.getElementById("muteCallButton"),
hangupCallButton: document.getElementById("hangupCallButton"),
closeCallPanelButton: document.getElementById("closeCallPanelButton"),
remoteAudio: document.getElementById("remoteAudio")
};
Glow.state = {
currentUser: null,
csrfToken: "",
rooms: [],
activeRoom: null,
teams: [],
activeTeam: null,
channels: [],
activeChannel: null,
dms: [],
activeDm: null,
activeView: "feed",
lastSeenId: 0,
knownMessageIds: new Set(),
renderedMessages: [],
messageRequestId: 0,
channelRequestId: 0,
dmRequestId: 0,
pollInFlight: false,
channelPollInFlight: false,
dmPollInFlight: false,
feedLastSeenId: 0,
feedKnownMessageIds: new Set(),
feedEntries: [],
feedRequestId: 0,
feedPollInFlight: false,
activeThread: null,
threadReplies: [],
threadRequestId: 0,
threadPollInFlight: false,
pages: [],
activePage: null,
pageTypeFilter: "",
activeProfile: null,
settings: null,
pollTimer: null,
initialized: false,
toastTimer: null,
selectedAttachment: [],
selectedThreadAttachment: [],
typingTimer: null,
typingLastSent: 0,
typingUsers: [],
dmTypingUsers: [],
typingIndicatorsEnabled:
localStorage.getItem("glow_typing_enabled") !== "false",
notificationsEnabled:
localStorage.getItem("glow_notifications_enabled") === "true",
accentColor:
localStorage.getItem("glow_accent_color") || "#f7dce8",
unreadMessages: 0,
originalTitle: document.title,
dialogResolver: null,
lastFocusedElement: null,
presence: {
roomOnlineCount: 0,
channelOnlineCount: 0,
dmOnlineCount: 0
},
calls: {
activeCall: null,
ws: null,
session: null,
localStream: null,
muted: false,
selfPeerId: null,
peerConnections: new Map(),
remoteStreams: new Map(),
peers: [],
reconnectTimer: null,
invites: [],
invitePollTimer: null
}
};
Glow.MAX_MESSAGE_LENGTH = 1000;
Glow.MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
Glow.DEFAULT_ACCENT_COLOR = "#f7dce8";
Glow.normalizeAccentColor = value => {
if (typeof value !== "string") return null;
const color = value.trim();
if (!/^#[0-9a-fA-F]{6}$/.test(color)) return null;
return color.toLowerCase();
};
Glow.normalizeNameColor = value => {
if (value == null || value === "") return null;
if (typeof value !== "string") return null;
const color = value.trim();
if (!/^#[0-9a-fA-F]{6}$/.test(color)) return null;
return color.toLowerCase();
};
Glow.hexToRgb = hex => {
const normalized = Glow.normalizeAccentColor(hex);
if (!normalized) return null;
return {
r: parseInt(normalized.slice(1, 3), 16),
g: parseInt(normalized.slice(3, 5), 16),
b: parseInt(normalized.slice(5, 7), 16)
};
};
Glow.relativeLuminance = hex => {
const rgb = Glow.hexToRgb(hex);
if (!rgb) return 0;
const channel = value => {
const normalized = value / 255;
return normalized <= 0.03928
? normalized / 12.92
: Math.pow((normalized + 0.055) / 1.055, 2.4);
};
return (
0.2126 * channel(rgb.r) +
0.7152 * channel(rgb.g) +
0.0722 * channel(rgb.b)
);
};
Glow.accentTextColor = hex => {
return Glow.relativeLuminance(hex) >= 0.18 ? hex : "#f2f2f4";
};
Glow.applyAccentColor = value => {
const accent =
Glow.normalizeAccentColor(value) || Glow.DEFAULT_ACCENT_COLOR;
const rgb = Glow.hexToRgb(accent);
const root = document.documentElement;
Glow.state.accentColor = accent;
root.style.setProperty("--accent", accent);
root.style.setProperty("--accent-active", Glow.accentTextColor(accent));
root.style.setProperty(
"--accent-soft",
`rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.16)`
);
root.style.setProperty(
"--accent-hover",
`rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.28)`
);
localStorage.setItem("glow_accent_color", accent);
Glow.updateThemeButton?.();
};
Glow.resetAccentColor = () => {
localStorage.removeItem("glow_accent_color");
Glow.applyAccentColor(Glow.DEFAULT_ACCENT_COLOR);
};
Glow.roleDefaultNameColor = role => {
switch (String(role || "").toLowerCase()) {
case "admin":
return "#ff6b6b";
case "moderator":
return "#7dd3fc";
default:
return "#f5f5f7";
}
};
Glow.resolveUserNameColor = userish => {
const explicit =
Glow.normalizeNameColor(userish?.name_color) ||
Glow.normalizeNameColor(userish?.user_name_color);
if (explicit) return explicit;
return Glow.roleDefaultNameColor(userish?.role || userish?.user_role);
};
Glow.applyUserColor = (element, userish) => {
if (!(element instanceof HTMLElement)) return;
element.style.color = Glow.resolveUserNameColor(userish);
};
Glow.canModerate = () => {
const user = Glow.state.currentUser;
return Boolean(user && ["moderator", "admin"].includes(user.role));
};
Glow.isAdmin = () => {
const user = Glow.state.currentUser;
return Boolean(user && user.role === "admin");
};
Glow.showToast = (message, type = "") => {
clearTimeout(Glow.state.toastTimer);
Glow.els.statusToast.textContent = String(message);
Glow.els.statusToast.className = `status-toast show ${type}`.trim();
Glow.state.toastTimer = setTimeout(() => {
Glow.els.statusToast.className = "status-toast";
}, 3500);
};
Glow.showAuthNotice = (message, type = "error") => {
Glow.els.authNotice.textContent = String(message);
Glow.els.authNotice.className = `notice show ${type}`;
};
Glow.clearAuthNotice = () => {
Glow.els.authNotice.textContent = "";
Glow.els.authNotice.className = "notice";
};
Glow.showBlocked = message => {
Glow.els.blockedMessage.textContent = message || "Access has been blocked.";
Glow.els.blockedOverlay.classList.remove("hidden");
Glow.els.authOverlay.classList.add("hidden");
Glow.els.messageInput.disabled = true;
Glow.els.sendButton.disabled = true;
Glow.els.roomSelect.disabled = true;
Glow.els.attachmentInput.disabled = true;
Glow.els.attachmentButton.classList.add("disabled");
clearTimeout(Glow.state.pollTimer);
};
Glow.relativeTime = iso => {
const seconds = Math.max(
0,
Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
);
if (seconds < 5) return "now";
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
};
Glow.formatDateLabel = iso => {
const date = new Date(iso);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const target = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const diff = Math.round((today - target) / 86400000);
if (diff === 0) return "Today";
if (diff === 1) return "Yesterday";
return date.toLocaleDateString(undefined, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric"
});
};
Glow.dateKey = iso => {
const date = new Date(iso);
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0")
].join("-");
};
Glow.isNearBottom = () => {
const activeBox =
Glow.state.activeView === "feed"
? Glow.els.feedBox
: Glow.state.activeView === "thread"
? Glow.els.threadBox
: Glow.state.activeView === "dms"
? Glow.els.dmBox
: Glow.els.chatBox;
if (!activeBox) return true;
return activeBox.scrollHeight - activeBox.scrollTop - activeBox.clientHeight < 100;
};
Glow.autoResizeComposer = () => {
const input = Glow.els.messageInput;
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, 150)}px`;
};
Glow.updateMessageCount = () => {
const count = Glow.els.messageInput.value.length;
Glow.els.messageCount.textContent = `${count} / ${Glow.MAX_MESSAGE_LENGTH}`;
};
Glow.updateDocumentTitle = () => {
if (!Glow.state.unreadMessages) {
document.title = Glow.state.originalTitle;
return;
}
document.title = `(${Glow.state.unreadMessages})` + Glow.state.originalTitle;
};
Glow.resetUnreadMessages = () => {
Glow.state.unreadMessages = 0;
Glow.updateDocumentTitle();
Glow.els.newMessagesBar.classList.add("hidden");
};
Glow.showNewMessages = () => {
Glow.els.newMessagesBar.classList.remove("hidden");
};
Glow.normalizeSelectedFiles = fileList => {
if (!fileList) return [];
return [...fileList].filter(Boolean);
};
Glow.openDialog = ({
title,
body,
confirmText = "Confirm",
cancelText = "Cancel",
danger = false,
fields = []
}) => {
if (Glow.state.dialogResolver) {
Glow.closeDialog(null);
}
Glow.state.lastFocusedElement = document.activeElement;
Glow.els.dialogTitle.textContent = title;
Glow.els.dialogBody.replaceChildren();
Glow.els.dialogActions.replaceChildren();
if (typeof body === "string") {
const paragraph = document.createElement("p");
paragraph.textContent = body;
Glow.els.dialogBody.append(paragraph);
} else if (body instanceof Node) {
Glow.els.dialogBody.append(body);
}
const fieldElements = {};
for (const field of fields) {
const label = document.createElement("label");
label.className = "dialog-label";
label.textContent = field.label;
const isMultiline = Boolean(field.multiline);
const input = document.createElement(isMultiline ? "textarea" : "input");
input.className = "field";
input.name = field.name;
input.value = field.value || "";
input.placeholder = field.placeholder || "";
if (isMultiline) {
input.rows = field.rows || 3;
input.maxLength = field.maxLength || 300;
} else {
input.type = field.type || "text";
input.maxLength = field.maxLength || 300;
if (field.type === "number") {
input.min = field.min || "1";
input.max = field.max || "43200";
input.step = field.step || "1";
}
}
label.append(input);
Glow.els.dialogBody.append(label);
fieldElements[field.name] = input;
}
const cancel = document.createElement("button");
cancel.className = "button secondary";
cancel.type = "button";
cancel.textContent = cancelText;
const confirmButton = document.createElement("button");
confirmButton.className = danger ? "danger-button" : "button";
confirmButton.type = "button";
confirmButton.textContent = confirmText;
Glow.els.dialogActions.append(cancel, confirmButton);
Glow.els.dialogOverlay.classList.remove("hidden");
return new Promise(resolve => {
Glow.state.dialogResolver = resolve;
cancel.addEventListener("click", () => Glow.closeDialog(null), { once: true });
confirmButton.addEventListener(
"click",
() => {
const values = {};
for (const [name, input] of Object.entries(fieldElements)) {
values[name] = input.value.trim();
}
Glow.closeDialog(values);
},
{ once: true }
);
setTimeout(() => {
const first = Object.values(fieldElements)[0] || confirmButton;
first.focus();
}, 0);
});
};
Glow.closeDialog = (result = null) => {
Glow.els.dialogOverlay.classList.add("hidden");
const resolve = Glow.state.dialogResolver;
Glow.state.dialogResolver = null;
if (resolve) resolve(result);
if (Glow.state.lastFocusedElement instanceof HTMLElement) {
Glow.state.lastFocusedElement.focus();
}
};