Initial commit

This commit is contained in:
admin 2026-09-24 22:33:36 +00:00
commit a1e3727c3f
24 changed files with 17656 additions and 0 deletions

2
.env Normal file
View File

@ -0,0 +1,2 @@
ADMIN_USERNAME=admin
ADMIN_PASSWORD=StrongPass123!

33
Dockerfile Normal file
View File

@ -0,0 +1,33 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PORT=5000
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/data /app/data/uploads /tmp /run/gunicorn \
&& chown -R 1000:1000 /app /tmp /run/gunicorn
USER 1000:1000
EXPOSE 5000
CMD ["gunicorn", \
"--bind", "0.0.0.0:5000", \
"--workers", "1", \
"--threads", "4", \
"--timeout", "60", \
"--graceful-timeout", "20", \
"--keep-alive", "5", \
"--worker-tmp-dir", "/tmp", \
"--access-logfile", "-", \
"--error-logfile", "-", \
"app:app"]

1990
app.py Normal file

File diff suppressed because it is too large Load Diff

61
docker-compose.yml Normal file
View File

@ -0,0 +1,61 @@
services:
monet4:
build:
context: .
dockerfile: Dockerfile
container_name: monet4
env_file:
- .env
environment:
PORT: 5000
FLASK_DEBUG: 0
CHAT_DB_PATH: /app/data/chat.db
CHAT_UPLOAD_DIR: /app/data/uploads
CHAT_SECRET_KEY_PATH: /app/data/flask_secret.key
CHAT_CLIENT_HASH_KEY_PATH: /app/data/client_hash.key
SESSION_COOKIE_SECURE: 1
TRUSTED_PROXY_HOPS: 1
GUNICORN_CMD_ARGS: "--worker-tmp-dir /tmp"
FLASK_SECRET_KEY: ${FLASK_SECRET_KEY:-}
CHAT_CLIENT_HASH_KEY: ${CHAT_CLIENT_HASH_KEY:-}
volumes:
- chat_data:/app/data
networks:
- privatenet
restart: unless-stopped
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
- /run/gunicorn:size=16m,mode=1777
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/api/csrf', timeout=5).read()"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
labels:
caddy: money.backend-3.com
caddy.reverse_proxy: "{{upstreams 5000}}"
volumes:
chat_data:
networks:
privatenet:
external: true

1031
features.py Normal file

File diff suppressed because it is too large Load Diff

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
flask
werkzeug
gunicorn
markdown

3966
routes.py Normal file

File diff suppressed because it is too large Load Diff

97
sanitizer.py Normal file
View File

@ -0,0 +1,97 @@
import html
from html.parser import HTMLParser
_ALLOWED_TAGS = {
"b",
"strong",
"i",
"em",
"u",
"p",
"br",
"ul",
"ol",
"li",
"blockquote",
"code",
"pre",
"a",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
}
_ALLOWED_ATTRS = {
"a": {"href", "title", "target", "rel"},
}
_ALLOWED_SCHEMES = ("http://", "https://", "mailto:")
def _escape_attr(value: str) -> str:
return html.escape(value, quote=True)
def sanitize_html_fragment(raw: str) -> str:
class SafeHtmlParser(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.parts = []
def handle_starttag(self, tag, attrs):
tag = tag.lower()
if tag not in _ALLOWED_TAGS:
return
allowed = _ALLOWED_ATTRS.get(tag, set())
clean_attrs = []
for key, value in attrs:
key = (key or "").lower()
value = value or ""
if key not in allowed:
continue
if tag == "a" and key == "href":
lowered = value.strip().lower()
if not lowered.startswith(_ALLOWED_SCHEMES):
continue
clean_attrs.append((key, value))
if tag == "a":
attr_keys = {key for key, _ in clean_attrs}
if "target" not in attr_keys:
clean_attrs.append(("target", "_blank"))
if "rel" not in attr_keys:
clean_attrs.append(("rel", "noopener noreferrer"))
attr_text = "".join(
f' {key}="{_escape_attr(value)}"'
for key, value in clean_attrs
)
self.parts.append(f"<{tag}{attr_text}>")
def handle_endtag(self, tag):
tag = tag.lower()
if tag in _ALLOWED_TAGS and tag not in {"br"}:
self.parts.append(f"</{tag}>")
def handle_data(self, data):
self.parts.append(html.escape(data))
def handle_entityref(self, name):
self.parts.append(f"&{name};")
def handle_charref(self, name):
self.parts.append(f"&#{name};")
parser = SafeHtmlParser()
parser.feed(raw or "")
parser.close()
return "".join(parser.parts)

2
static/js/.env Normal file
View File

@ -0,0 +1,2 @@
ADMIN_USERNAME=admin
ADMIN_PASSWORD=StrongPass123!

621
static/js/00-config.js Normal file
View File

@ -0,0 +1,621 @@
// 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"),
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"),
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,
peerConnection: null,
localStream: null,
muted: false,
signalLastSeenId: 0,
pollTimer: null,
incomingCall: null,
pendingOffer: 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();
}
};

405
static/js/01-api.js Normal file
View File

@ -0,0 +1,405 @@
// static/js/01-api.js
"use strict";
window.Glow = window.Glow || {};
Glow.refreshCsrfToken = async () => {
const response = await fetch("/api/csrf", {
credentials: "same-origin",
headers: {
Accept: "application/json"
}
});
if (!response.ok) {
throw new Error("Could not refresh security token.");
}
const data = await response.json();
Glow.state.csrfToken = data.csrf_token || "";
};
Glow.handleUnauthorized = () => {
Glow.state.currentUser = null;
Glow.state.csrfToken = "";
Glow.state.rooms = [];
Glow.state.activeRoom = null;
Glow.state.teams = [];
Glow.state.activeTeam = null;
Glow.state.channels = [];
Glow.state.activeChannel = null;
Glow.state.dms = [];
Glow.state.activeDm = null;
Glow.state.activeThread = null;
Glow.state.threadReplies = [];
Glow.state.pages = [];
Glow.state.activePage = null;
Glow.state.pageTypeFilter = "";
Glow.state.activeProfile = null;
Glow.state.settings = null;
Glow.state.selectedAttachment = [];
Glow.state.selectedThreadAttachment = [];
Glow.state.typingUsers = [];
Glow.state.dmTypingUsers = [];
Glow.state.pollInFlight = false;
Glow.state.channelPollInFlight = false;
Glow.state.dmPollInFlight = false;
Glow.state.feedPollInFlight = false;
Glow.state.threadPollInFlight = false;
Glow.state.messageRequestId += 1;
Glow.state.channelRequestId += 1;
Glow.state.dmRequestId += 1;
Glow.state.feedRequestId += 1;
Glow.state.threadRequestId += 1;
Glow.state.calls.activeCall = null;
Glow.state.calls.signalLastSeenId = 0;
Glow.state.calls.incomingCall = null;
Glow.state.calls.pendingOffer = null;
clearTimeout(Glow.state.pollTimer);
clearTimeout(Glow.state.typingTimer);
clearTimeout(Glow.state.calls.pollTimer);
Glow.clearMessages?.();
Glow.clearFeed?.();
Glow.clearDms?.();
Glow.closeHeaderMenu?.();
Glow.closeReactionPopup?.();
Glow.closeMediaModal?.();
Glow.endLocalCallUi?.();
Glow.setSelectedAttachment?.([]);
Glow.setSelectedThreadAttachment?.([]);
Glow.els.threadBox?.replaceChildren();
Glow.els.pagesBox?.replaceChildren();
Glow.els.pageDetailBox?.replaceChildren();
Glow.els.profileBox?.replaceChildren();
if (Glow.els.teamSelect) {
Glow.els.teamSelect.replaceChildren();
Glow.els.teamSelect.disabled = true;
}
if (Glow.els.roomSelect) {
Glow.els.roomSelect.replaceChildren();
Glow.els.roomSelect.disabled = true;
}
if (Glow.els.typingBar) {
Glow.els.typingBar.textContent = "";
Glow.els.typingBar.classList.add("hidden");
}
if (Glow.els.presenceLabel) {
Glow.els.presenceLabel.textContent = "";
Glow.els.presenceLabel.classList.add("hidden");
}
Glow.state.activeView = "feed";
Glow.updateAuthUI?.();
Glow.showAuthNotice?.("Your session has expired. Please sign in again.");
Glow.els.authOverlay.classList.remove("hidden");
};
Glow.apiRequest = async (url, options = {}, retryCsrf = true) => {
const method = (options.method || "GET").toUpperCase();
const headers = new Headers(options.headers || {});
headers.set("Accept", "application/json");
if (!["GET", "HEAD"].includes(method)) {
headers.set("X-CSRF-Token", Glow.state.csrfToken || "");
}
let response;
try {
response = await fetch(url, {
credentials: "same-origin",
...options,
method,
headers
});
} catch (_) {
const error = new Error("Network error. Check your connection and try again.");
error.code = "network";
throw error;
}
let data = {};
try {
data = await response.json();
} catch (_) {
data = {};
}
if (response.status === 403 && data.code === "blacklisted") {
Glow.showBlocked(data.error);
throw new Error(data.error || "Access blocked.");
}
if (response.status === 403 && data.code === "csrf" && retryCsrf) {
await Glow.refreshCsrfToken();
return Glow.apiRequest(url, options, false);
}
if (response.status === 401) {
Glow.handleUnauthorized();
}
if (!response.ok) {
const error = new Error(data.error || "Request failed.");
error.status = response.status;
error.code = data.code;
error.retryAfter = data.retry_after;
error.restrictedUntil = data.restricted_until;
error.data = data;
throw error;
}
if (data.csrf_token) {
Glow.state.csrfToken = data.csrf_token;
}
return data;
};
/* profile/settings */
Glow.apiGetMyProfile = () => Glow.apiRequest("/api/profile");
Glow.apiUpdateMyProfile = payload =>
Glow.apiRequest("/api/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiGetPublicProfile = username =>
Glow.apiRequest(`/api/profiles/${encodeURIComponent(username)}`);
Glow.apiGetMySettings = () => Glow.apiRequest("/api/settings");
Glow.apiUpdateMySettings = payload =>
Glow.apiRequest("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
/* teams/channels */
Glow.apiCreateTeam = payload =>
Glow.apiRequest("/api/teams", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiUpdateTeam = (teamSlug, payload) =>
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiCreateChannel = (teamSlug, payload) =>
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/channels`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiUpdateChannel = (teamSlug, channelSlug, payload) =>
Glow.apiRequest(
`/api/teams/${encodeURIComponent(teamSlug)}/channels/${encodeURIComponent(channelSlug)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}
);
Glow.apiDeleteChannel = (teamSlug, channelSlug) =>
Glow.apiRequest(
`/api/teams/${encodeURIComponent(teamSlug)}/channels/${encodeURIComponent(channelSlug)}`,
{ method: "DELETE" }
);
Glow.apiListTeamMembers = teamSlug =>
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/members`);
Glow.apiAddTeamMember = (teamSlug, payload) =>
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/members`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiUpdateTeamMember = (teamSlug, userId, payload) =>
Glow.apiRequest(
`/api/teams/${encodeURIComponent(teamSlug)}/members/${encodeURIComponent(userId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}
);
Glow.apiRemoveTeamMember = (teamSlug, userId) =>
Glow.apiRequest(
`/api/teams/${encodeURIComponent(teamSlug)}/members/${encodeURIComponent(userId)}`,
{ method: "DELETE" }
);
Glow.apiCreateTeamInvite = (teamSlug, payload = {}) =>
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/invites`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiAcceptTeamInvite = token =>
Glow.apiRequest(`/api/team-invites/${encodeURIComponent(token)}/accept`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
/* rooms */
Glow.apiCreateRoom = payload =>
Glow.apiRequest("/api/rooms", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiUpdateRoom = (roomSlug, payload) =>
Glow.apiRequest(`/api/rooms/${encodeURIComponent(roomSlug)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
/* reactions */
Glow.apiGetMessageReactions = messageId =>
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/reactions`);
Glow.apiAddReaction = (messageId, emoji) =>
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/reactions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ emoji })
});
Glow.apiRemoveReaction = (messageId, emoji) =>
Glow.apiRequest(
`/api/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(emoji)}`,
{ method: "DELETE" }
);
/* message editing / receipts / search / presence */
Glow.apiEditMessage = (messageId, payload) =>
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiSearch = query =>
Glow.apiRequest(`/api/search?q=${encodeURIComponent(query)}`);
Glow.apiMarkRead = messageIds =>
Glow.apiRequest("/api/read-receipts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message_ids: messageIds })
});
Glow.apiGetMessageReceipts = messageId =>
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/receipts`);
Glow.apiPingPresence = payload =>
Glow.apiRequest("/api/presence", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiGetRoomPresence = roomSlug =>
Glow.apiRequest(`/api/rooms/${encodeURIComponent(roomSlug)}/presence`);
Glow.apiGetChannelPresence = (teamSlug, channelSlug) =>
Glow.apiRequest(
`/api/teams/${encodeURIComponent(teamSlug)}/channels/${encodeURIComponent(channelSlug)}/presence`
);
Glow.apiGetDmPresence = dmId =>
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/presence`);
Glow.apiLoadHistory = params => {
const qs = new URLSearchParams(params);
return Glow.apiRequest(`/api/messages/history?${qs.toString()}`);
};
/* DMs */
Glow.apiListDms = () => Glow.apiRequest("/api/dms");
Glow.apiCreateDm = payload =>
Glow.apiRequest("/api/dms", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiGetDmMessages = (dmId, afterId = null) => {
const base = `/api/dms/${encodeURIComponent(dmId)}/messages`;
return Glow.apiRequest(
afterId == null ? base : `${base}?after_id=${encodeURIComponent(afterId)}`
);
};
Glow.apiSendDmMessage = (dmId, payload) =>
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiGetDmTyping = dmId =>
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/typing`);
Glow.apiSetDmTyping = (dmId, typing) =>
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/typing`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ typing })
});
/* calls */
Glow.apiCreateCall = dmId =>
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/calls`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
Glow.apiGetCall = callId =>
Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}`);
Glow.apiPostCallSignal = (callId, payload) =>
Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}/signal`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiGetCallSignals = (callId, afterId = 0) =>
Glow.apiRequest(
`/api/calls/${encodeURIComponent(callId)}/signals?after_id=${encodeURIComponent(afterId)}`
);
Glow.apiEndCall = callId =>
Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}/end`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});

1779
static/js/02-chat.js Normal file

File diff suppressed because it is too large Load Diff

689
static/js/03-admin.js Normal file
View File

@ -0,0 +1,689 @@
// static/js/03-admin.js
"use strict";
window.Glow = window.Glow || {};
Glow.closeAdminPanel = () => {
Glow.els.adminPanel.classList.remove("open");
Glow.els.adminPanel.setAttribute("aria-hidden", "true");
Glow.els.adminFab.focus();
};
Glow.renderAdminUsers = users => {
Glow.els.adminUsers.replaceChildren();
const heading = document.createElement("h3");
heading.textContent = "Users";
Glow.els.adminUsers.append(heading);
if (!users.length) {
const empty = document.createElement("p");
empty.className = "admin-empty";
empty.textContent = "No users found.";
Glow.els.adminUsers.append(empty);
return;
}
for (const user of users) {
const row = document.createElement("div");
row.className = "admin-user";
const details = document.createElement("div");
const name = document.createElement("div");
name.className = "admin-user-name";
name.textContent = `${user.username} · ${user.role}`;
const info = document.createElement("div");
info.className = "admin-user-info";
info.textContent =
`${user.status} · ${user.message_count} message(s)` +
(user.restricted_until
? ` · until ${new Date(user.restricted_until).toLocaleString()}`
: "");
details.append(name, info);
const actions = document.createElement("div");
actions.className = "admin-user-actions";
const current =
Glow.state.currentUser &&
Number(Glow.state.currentUser.id) === Number(user.id);
if (!current && Glow.isAdmin()) {
const role = document.createElement("button");
role.className = "small-button";
role.type = "button";
role.textContent =
user.role === "moderator" ? "Make user" : "Make moderator";
role.addEventListener("click", () => {
Glow.updateAdminUser(user.id, {
role: user.role === "moderator" ? "user" : "moderator"
});
});
const restrict = document.createElement("button");
restrict.className = "small-button";
restrict.type = "button";
restrict.textContent =
user.status === "restricted" ? "Unrestrict" : "Restrict";
restrict.addEventListener("click", () => {
Glow.toggleRestriction(user);
});
const blacklist = document.createElement("button");
blacklist.className = "small-button danger";
blacklist.type = "button";
blacklist.textContent =
user.status === "blacklisted" ? "Unblacklist" : "Blacklist";
blacklist.addEventListener("click", () => {
Glow.toggleBlacklist(user);
});
actions.append(role, restrict, blacklist);
}
row.append(details, actions);
Glow.els.adminUsers.append(row);
}
};
Glow.renderAdminRooms = rooms => {
Glow.els.adminRooms.replaceChildren();
const heading = document.createElement("h3");
heading.textContent = "Rooms";
Glow.els.adminRooms.append(heading);
if (!rooms.length) {
const empty = document.createElement("p");
empty.className = "admin-empty";
empty.textContent = "No rooms found.";
Glow.els.adminRooms.append(empty);
return;
}
for (const room of rooms) {
const row = document.createElement("div");
row.className = "admin-room";
const details = document.createElement("div");
const name = document.createElement("div");
name.className = "admin-room-name";
name.textContent = `${room.name} · ${room.visibility}`;
const info = document.createElement("div");
info.className = "admin-room-info";
info.textContent =
`/${room.slug}` +
(room.description ? ` · ${room.description}` : "");
details.append(name, info);
const actions = document.createElement("div");
actions.className = "admin-room-actions";
if (Glow.isAdmin()) {
const edit = document.createElement("button");
edit.className = "small-button";
edit.type = "button";
edit.textContent = "Edit";
edit.addEventListener("click", () => Glow.editRoom(room));
actions.append(edit);
if (room.visibility === "private") {
const members = document.createElement("button");
members.className = "small-button";
members.type = "button";
members.textContent = "Members";
members.addEventListener("click", () => {
Glow.manageRoomMembers(room);
});
const invite = document.createElement("button");
invite.className = "small-button";
invite.type = "button";
invite.textContent = "Invite link";
invite.addEventListener("click", () => {
Glow.createInviteLink(room);
});
actions.append(members, invite);
}
if (room.slug !== "general") {
const remove = document.createElement("button");
remove.className = "small-button danger";
remove.type = "button";
remove.textContent = "Delete";
remove.addEventListener("click", () => Glow.deleteRoom(room));
actions.append(remove);
}
}
row.append(details, actions);
Glow.els.adminRooms.append(row);
}
};
Glow.openAdminPanel = async () => {
if (!Glow.canModerate()) return;
Glow.els.adminPanel.classList.add("open");
Glow.els.adminPanel.setAttribute("aria-hidden", "false");
Glow.els.adminUsers.textContent = "Loading users…";
Glow.els.adminRooms.textContent = "Loading rooms…";
try {
const [usersData, roomsData] = await Promise.all([
Glow.apiRequest("/api/admin/users"),
Glow.apiRequest("/api/admin/rooms")
]);
Glow.renderAdminUsers(usersData.users || []);
Glow.renderAdminRooms(roomsData.rooms || []);
Glow.updateAdminPreferenceButtons();
setTimeout(() => Glow.els.closeAdminButton.focus(), 0);
} catch (error) {
Glow.showToast(error.message, "error");
}
};
Glow.updateThemeButton = () => {
if (!Glow.els.themeButton) return;
Glow.els.themeButton.textContent = "Accent color";
};
Glow.updateAdminPreferenceButtons = () => {
Glow.els.typingToggleButton.textContent =
`Typing indicators: ${Glow.state.typingIndicatorsEnabled ? "on" : "off"}`;
Glow.els.notificationsToggleButton.textContent =
`Notifications: ${Glow.state.notificationsEnabled ? "on" : "off"}`;
Glow.updateThemeButton();
};
Glow.openAccentColorPicker = async () => {
const container = document.createElement("div");
container.className = "accent-picker";
const description = document.createElement("p");
description.className = "accent-picker-description";
description.textContent =
"Choose an accent color for focus rings, buttons, selected controls, and highlighted mentions. It is stored only in this browser unless saved in account settings.";
const label = document.createElement("label");
label.className = "accent-picker-label";
label.textContent = "Accent color";
const row = document.createElement("div");
row.className = "accent-picker-row";
const colorInput = document.createElement("input");
colorInput.className = "accent-color-input";
colorInput.type = "color";
colorInput.value =
Glow.normalizeAccentColor(Glow.state.accentColor) ||
Glow.DEFAULT_ACCENT_COLOR;
colorInput.setAttribute("aria-label", "Choose accent color");
const hexInput = document.createElement("input");
hexInput.className = "field accent-hex-input";
hexInput.type = "text";
hexInput.value = colorInput.value;
hexInput.maxLength = 7;
hexInput.autocomplete = "off";
hexInput.autocapitalize = "off";
hexInput.spellcheck = false;
hexInput.placeholder = "#f7dce8";
hexInput.setAttribute(
"aria-label",
"Accent color hexadecimal value"
);
const preview = document.createElement("span");
preview.className = "accent-preview";
preview.style.background = colorInput.value;
preview.setAttribute("aria-hidden", "true");
const error = document.createElement("div");
error.className = "accent-picker-error";
error.setAttribute("role", "status");
const updatePreview = value => {
const normalized = Glow.normalizeAccentColor(value);
if (!normalized) {
preview.style.background = "transparent";
error.textContent =
"Use a six-digit hex color, such as #7dd3fc.";
return false;
}
preview.style.background = normalized;
error.textContent = "";
if (colorInput.value !== normalized) {
colorInput.value = normalized;
}
return true;
};
colorInput.addEventListener("input", () => {
const color = Glow.normalizeAccentColor(colorInput.value);
if (!color) return;
hexInput.value = color;
updatePreview(color);
});
hexInput.addEventListener("input", () => {
updatePreview(hexInput.value.trim());
});
row.append(colorInput, hexInput, preview);
label.append(row);
container.append(description, label, error);
const result = await Glow.openDialog({
title: "Accent color",
body: container,
confirmText: "Save color",
cancelText: "Cancel"
});
if (!result) return;
const accent = Glow.normalizeAccentColor(hexInput.value);
if (!accent) {
Glow.showToast(
"Choose a valid six-digit hexadecimal color.",
"error"
);
return;
}
Glow.applyAccentColor(accent);
Glow.showToast("Accent color updated.");
};
Glow.toggleRestriction = async user => {
if (user.status === "restricted") {
return Glow.updateAdminUser(user.id, {
status: "active",
restricted_until: null
});
}
const values = await Glow.openDialog({
title: `Restrict ${user.username}`,
body: "Enter the restriction duration in minutes.",
confirmText: "Restrict",
danger: true,
fields: [
{
name: "minutes",
label: "Duration in minutes",
type: "number",
value: "60",
maxLength: 5
}
]
});
if (!values) return;
const minutes = Number(values.minutes);
if (!Number.isFinite(minutes) || minutes < 1 || minutes > 43200) {
Glow.showToast(
"Enter a duration between 1 minute and 30 days.",
"error"
);
return;
}
await Glow.updateAdminUser(user.id, {
status: "restricted",
restricted_until: new Date(
Date.now() + minutes * 60 * 1000
).toISOString()
});
};
Glow.toggleBlacklist = async user => {
const blacklisting = user.status !== "blacklisted";
const result = await Glow.openDialog({
title: blacklisting
? `Blacklist ${user.username}?`
: `Unblacklist ${user.username}?`,
body: blacklisting
? "The account will be denied access immediately."
: "The account will be restored to active status.",
confirmText: blacklisting ? "Blacklist" : "Unblacklist",
danger: blacklisting
});
if (!result) return;
await Glow.updateAdminUser(user.id, {
status: blacklisting ? "blacklisted" : "active"
});
};
Glow.updateAdminUser = async (userId, changes) => {
try {
await Glow.apiRequest(`/api/admin/users/${encodeURIComponent(userId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes)
});
Glow.showToast("User updated.");
await Glow.openAdminPanel();
} catch (error) {
Glow.showToast(error.message, "error");
}
};
Glow.createRoom = async () => {
const values = await Glow.openDialog({
title: "Create room",
body: "Choose a room name, description, and visibility.",
confirmText: "Create",
fields: [
{
name: "name",
label: "Room name",
placeholder: "Project chat",
maxLength: 60
},
{
name: "description",
label: "Description",
placeholder: "Optional room description",
multiline: true,
rows: 3,
maxLength: 300
},
{
name: "visibility",
label: "Visibility: public or private",
value: "public",
maxLength: 7
}
]
});
if (!values) return;
const visibility = values.visibility.toLowerCase();
if (!["public", "private"].includes(visibility)) {
Glow.showToast("Visibility must be public or private.", "error");
return;
}
try {
await Glow.apiRequest("/api/rooms", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: values.name,
description: values.description,
visibility
})
});
Glow.showToast("Room created.");
await Glow.loadRooms();
await Glow.openAdminPanel();
} catch (error) {
Glow.showToast(error.message, "error");
}
};
Glow.editRoom = async room => {
const values = await Glow.openDialog({
title: `Edit ${room.name}`,
confirmText: "Save",
fields: [
{
name: "name",
label: "Room name",
value: room.name,
maxLength: 60
},
{
name: "description",
label: "Description",
value: room.description || "",
multiline: true,
rows: 3,
maxLength: 300
},
{
name: "visibility",
label: "Visibility: public or private",
value: room.visibility,
maxLength: 7
}
]
});
if (!values) return;
const visibility = values.visibility.toLowerCase();
if (!["public", "private"].includes(visibility)) {
Glow.showToast("Visibility must be public or private.", "error");
return;
}
await Glow.updateRoom(room.slug, {
name: values.name,
description: values.description,
visibility
});
};
Glow.updateRoom = async (slug, changes) => {
try {
await Glow.apiRequest(`/api/rooms/${encodeURIComponent(slug)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes)
});
Glow.showToast("Room updated.");
await Glow.loadRooms();
if (!Glow.state.activeDm) {
await Glow.loadMessages();
}
await Glow.openAdminPanel();
} catch (error) {
Glow.showToast(error.message, "error");
}
};
Glow.deleteRoom = async room => {
const result = await Glow.openDialog({
title: `Delete ${room.name}?`,
body: "This permanently deletes the room and every message in it.",
confirmText: "Delete room",
danger: true
});
if (!result) return;
try {
await Glow.apiRequest(`/api/rooms/${encodeURIComponent(room.slug)}`, {
method: "DELETE"
});
Glow.showToast("Room deleted.");
await Glow.loadRooms();
if (!Glow.state.activeDm) {
await Glow.loadMessages();
}
await Glow.openAdminPanel();
} catch (error) {
Glow.showToast(error.message, "error");
}
};
Glow.clearActiveRoom = async () => {
const room = Glow.state.activeRoom;
if (!room || Glow.state.activeDm) return;
const result = await Glow.openDialog({
title: `Clear ${room.name}?`,
body: "All messages in this room will be permanently deleted.",
confirmText: "Clear room",
danger: true
});
if (!result) return;
try {
await Glow.apiRequest(
`/api/rooms/${encodeURIComponent(room.slug)}/messages`,
{ method: "DELETE" }
);
Glow.clearMessages();
Glow.renderEmptyState();
Glow.showToast("Room messages cleared.");
} catch (error) {
Glow.showToast(error.message, "error");
}
};
Glow.manageRoomMembers = async room => {
try {
const data = await Glow.apiRequest(
`/api/rooms/${encodeURIComponent(room.slug)}/members`
);
const container = document.createElement("div");
container.className = "member-list";
for (const member of data.members || []) {
const row = document.createElement("div");
row.className = "member-row";
const name = document.createElement("span");
name.textContent = `${member.username} · ${member.membership_role}`;
const remove = document.createElement("button");
remove.className = "small-button danger";
remove.type = "button";
remove.textContent = "Remove";
remove.disabled = member.membership_role === "owner";
remove.addEventListener("click", async () => {
try {
await Glow.apiRequest(
`/api/rooms/${encodeURIComponent(room.slug)}/members/${member.id}`,
{ method: "DELETE" }
);
Glow.showToast("Member removed.");
Glow.closeDialog({});
Glow.manageRoomMembers(room);
} catch (error) {
Glow.showToast(error.message, "error");
}
});
row.append(name, remove);
container.append(row);
}
const values = await Glow.openDialog({
title: `${room.name} members`,
body: container,
confirmText: "Add member",
fields: [
{
name: "username",
label: "Username to add",
placeholder: "username",
maxLength: 30
}
]
});
if (!values?.username) return;
await Glow.apiRequest(
`/api/rooms/${encodeURIComponent(room.slug)}/members`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: values.username })
}
);
Glow.showToast("Member added.");
await Glow.manageRoomMembers(room);
} catch (error) {
Glow.showToast(error.message, "error");
}
};
Glow.createInviteLink = async room => {
try {
const data = await Glow.apiRequest(
`/api/rooms/${encodeURIComponent(room.slug)}/invites`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
}
);
const url = new URL(
data.invite.url,
window.location.origin
).toString();
const values = await Glow.openDialog({
title: "Private-room invite link",
body: "Copy and share this link. It can be used once before it expires.",
confirmText: "Copy link",
fields: [
{
name: "invite",
label: "Invite link",
value: url,
maxLength: 500
}
]
});
if (values) {
await navigator.clipboard.writeText(url);
Glow.showToast("Invite link copied.");
}
} catch (error) {
Glow.showToast(error.message, "error");
}
};

859
static/js/04-main.js Normal file
View File

@ -0,0 +1,859 @@
// static/js/04-main.js
"use strict";
window.Glow = window.Glow || {};
Glow.loadRooms = async () => {
const previousSlug = Glow.state.activeRoom?.slug;
const data = await Glow.apiRequest("/api/rooms");
Glow.state.rooms = data.rooms || [];
Glow.els.roomSelect.replaceChildren();
for (const room of Glow.state.rooms) {
const option = document.createElement("option");
option.value = room.slug;
option.textContent =
`${room.name}${room.visibility === "private" ? " (private)" : ""}`;
Glow.els.roomSelect.append(option);
}
Glow.els.roomSelect.disabled = !Glow.state.rooms.length;
const selected =
Glow.state.rooms.find(room => room.slug === previousSlug) ||
Glow.state.rooms.find(room => room.slug === "general") ||
Glow.state.rooms[0] ||
null;
Glow.state.activeRoom = selected;
Glow.updateViewUI();
Glow.updateChatControls();
};
Glow.updateViewUI = () => {
const view = Glow.state.activeView || "feed";
const room = Glow.state.activeRoom;
const dm = Glow.state.activeDm;
const feedActive = view === "feed";
const dmListActive = view === "dms";
const chatActive = view === "room" || view === "channel" || view === "dm";
const threadActive = view === "thread";
const pagesActive = view === "pages";
const pageActive = view === "page";
const profileActive = view === "profile";
const settingsActive = view === "settings";
Glow.els.feedBox.classList.toggle("hidden", !feedActive);
Glow.els.dmBox?.classList.toggle("hidden", !dmListActive);
Glow.els.chatBox.classList.toggle("hidden", !chatActive);
Glow.els.threadBox?.classList.toggle("hidden", !threadActive);
Glow.els.pagesBox?.classList.toggle("hidden", !pagesActive);
Glow.els.pageDetailBox?.classList.toggle("hidden", !pageActive);
Glow.els.profileBox?.classList.toggle("hidden", !(profileActive || settingsActive));
Glow.els.composerWrap.classList.toggle("hidden", !chatActive);
Glow.els.threadComposerWrap?.classList.toggle("hidden", !threadActive);
if (!chatActive) {
Glow.state.typingUsers = [];
Glow.state.dmTypingUsers = [];
if (Glow.els.typingBar) {
Glow.els.typingBar.textContent = "";
Glow.els.typingBar.classList.add("hidden");
}
}
Glow.els.feedButton.classList.toggle("active", feedActive);
Glow.els.feedButton.setAttribute("aria-pressed", String(feedActive));
Glow.els.pagesButton?.classList.toggle("active", pagesActive || pageActive);
Glow.els.pagesButton?.setAttribute("aria-pressed", String(pagesActive || pageActive));
Glow.els.dmsButton?.classList.toggle("active", dmListActive || view === "dm");
Glow.els.dmsButton?.setAttribute("aria-pressed", String(dmListActive || view === "dm"));
Glow.els.backButton?.classList.toggle(
"hidden",
!(threadActive || pageActive || profileActive || settingsActive || view === "dm")
);
Glow.els.roomSelect.classList.toggle("active", chatActive && !dm);
Glow.els.teamSelect?.classList.toggle("hidden", dmListActive || view === "dm");
Glow.els.roomSelect?.classList.toggle("hidden", dmListActive || view === "dm");
if (feedActive) {
Glow.els.headerTitle.textContent = "Feed";
Glow.els.headerSubtitle.textContent = Glow.state.currentUser
? "Recent activity across your accessible rooms"
: "Sign in or create an account to personalize your feed";
Glow.els.roomSelect.disabled = !Glow.state.rooms.length;
Glow.els.presenceLabel?.classList.add("hidden");
return;
}
if (dmListActive) {
Glow.els.headerTitle.textContent = "Direct messages";
Glow.els.headerSubtitle.textContent = Glow.state.currentUser
? "Private conversations"
: "Sign in to open private conversations";
Glow.els.presenceLabel?.classList.add("hidden");
return;
}
if (threadActive) {
Glow.els.headerTitle.textContent = "Thread";
Glow.els.headerSubtitle.textContent =
dm
? "Direct message replies"
: room?.name || "Conversation replies";
Glow.updateThreadControls?.();
return;
}
if (pagesActive) {
Glow.els.headerTitle.textContent = "Pages";
Glow.els.headerSubtitle.textContent = "Shared knowledge and resources";
Glow.els.presenceLabel?.classList.add("hidden");
return;
}
if (pageActive) {
Glow.els.headerTitle.textContent = Glow.state.activePage?.title || "Page";
Glow.els.headerSubtitle.textContent = "Shared knowledge and resources";
Glow.els.presenceLabel?.classList.add("hidden");
return;
}
if (profileActive) {
Glow.els.headerTitle.textContent =
Glow.state.activeProfile?.display_name ||
Glow.state.activeProfile?.username ||
"Profile";
Glow.els.headerSubtitle.textContent = "User profile";
Glow.els.presenceLabel?.classList.add("hidden");
return;
}
if (settingsActive) {
Glow.els.headerTitle.textContent = "Settings";
Glow.els.headerSubtitle.textContent = "Appearance, profile, and preferences";
Glow.els.presenceLabel?.classList.add("hidden");
return;
}
if (view === "dm" && dm) {
const peer =
(dm.members || []).find(
member => Number(member.id) !== Number(Glow.state.currentUser?.id)
) || dm.members?.[0];
Glow.els.headerTitle.textContent =
peer?.display_name || peer?.username || "Direct message";
Glow.els.headerSubtitle.textContent =
peer?.username ? `@${peer.username}` : "Private conversation";
Glow.refreshPresence?.();
Glow.updateChatControls();
return;
}
if (room) {
Glow.els.headerTitle.textContent = room.name;
Glow.els.headerSubtitle.textContent =
room.description ||
(room.visibility === "private" ? "private room" : "public room");
Glow.els.roomSelect.value = room.slug;
} else {
Glow.els.headerTitle.textContent = "No rooms";
Glow.els.headerSubtitle.textContent = "No accessible chat rooms";
}
Glow.updateChatControls();
};
Glow.updateAuthUI = () => {
const user = Glow.state.currentUser;
const authenticated = Boolean(user);
Glow.els.authOverlay.classList.toggle("hidden", authenticated);
Glow.els.logoutButton.classList.toggle("hidden", !authenticated);
Glow.els.headerAccount.textContent =
user ? `${user.username} · ${user.role}` : "not signed in";
document.body.classList.toggle("is-moderator", Glow.canModerate());
document.body.classList.toggle("is-admin", Glow.isAdmin());
Glow.els.adminFab.classList.toggle("visible", Glow.canModerate());
Glow.els.presenceDot.classList.toggle("online", authenticated);
Glow.els.presenceDot.classList.toggle("new", Boolean(user?.status === "restricted"));
if (user?.status === "restricted") {
Glow.showToast("Your account is temporarily restricted.", "error");
}
Glow.updateViewUI();
Glow.updateChatControls();
Glow.updateThreadControls?.();
Glow.renderHeaderMenu?.();
};
Glow.checkInvite = async () => {
const params = new URLSearchParams(window.location.search);
const roomToken = params.get("invite");
const teamToken = params.get("team_invite");
if (roomToken) {
try {
const data = await Glow.apiRequest(
`/api/invites/${encodeURIComponent(roomToken)}/accept`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
}
);
Glow.showToast(`Joined private room: ${data.room.name}`);
window.history.replaceState({}, "", window.location.pathname);
} catch (error) {
if (error.status !== 401) {
Glow.showToast(error.message, "error");
window.history.replaceState({}, "", window.location.pathname);
}
}
}
if (teamToken) {
try {
const data = await Glow.apiAcceptTeamInvite(teamToken);
Glow.showToast(`Joined team: ${data.team.name}`);
window.history.replaceState({}, "", window.location.pathname);
} catch (error) {
if (error.status !== 401) {
Glow.showToast(error.message, "error");
window.history.replaceState({}, "", window.location.pathname);
}
}
}
};
Glow.checkAuth = async () => {
try {
const data = await Glow.apiRequest("/api/me");
Glow.state.currentUser = data.authenticated ? data.user : null;
Glow.state.csrfToken = data.csrf_token || "";
Glow.updateAuthUI();
await Glow.checkInvite();
await Glow.loadRooms();
await Glow.loadDms?.().catch(() => {});
Glow.state.activeView = "feed";
Glow.updateViewUI();
await Glow.loadFeed();
await Glow.loadMySettings?.().catch(() => {});
} catch (error) {
Glow.showToast(error.message || "Could not connect.", "error");
} finally {
Glow.schedulePoll();
}
};
Glow.authenticate = async endpoint => {
Glow.clearAuthNotice();
const username = Glow.els.usernameInput.value.trim().toLowerCase();
const password = Glow.els.passwordInput.value;
if (!username || !password) {
Glow.showAuthNotice("Enter a username and password.");
return;
}
Glow.els.loginButton.disabled = true;
Glow.els.registerButton.disabled = true;
try {
const data = await Glow.apiRequest(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
Glow.state.currentUser = data.user;
Glow.state.csrfToken = data.csrf_token || "";
Glow.els.passwordInput.value = "";
Glow.updateAuthUI();
await Glow.checkInvite();
await Glow.loadRooms();
await Glow.loadDms?.().catch(() => {});
Glow.state.activeView = "feed";
Glow.updateViewUI();
await Glow.loadFeed();
await Glow.loadMySettings?.().catch(() => {});
Glow.showToast(
endpoint.endsWith("register") ? "Account created." : "Signed in."
);
} catch (error) {
Glow.showAuthNotice(
error.status === 429 && error.retryAfter
? `Please wait ${error.retryAfter} seconds before trying again.`
: error.message
);
} finally {
Glow.els.loginButton.disabled = false;
Glow.els.registerButton.disabled = false;
Glow.schedulePoll();
}
};
Glow.logout = async () => {
try {
await Glow.apiRequest("/api/logout", { method: "POST" });
} catch (_) {
// ignore
}
Glow.state.currentUser = null;
Glow.state.csrfToken = "";
Glow.state.rooms = [];
Glow.state.activeRoom = null;
Glow.state.teams = [];
Glow.state.activeTeam = null;
Glow.state.channels = [];
Glow.state.activeChannel = null;
Glow.state.dms = [];
Glow.state.activeDm = null;
Glow.state.activeView = "feed";
Glow.state.activeThread = null;
Glow.state.threadReplies = [];
Glow.state.activePage = null;
Glow.state.activeProfile = null;
Glow.state.settings = null;
Glow.clearMessages();
Glow.clearFeed();
Glow.clearDms();
Glow.closeHeaderMenu?.();
Glow.closeReactionPopup?.();
Glow.closeMediaModal?.();
Glow.endLocalCallUi?.();
Glow.updateAuthUI();
Glow.els.authOverlay.classList.remove("hidden");
Glow.els.usernameInput.focus();
await Glow.refreshCsrfToken().catch(() => {});
await Glow.loadFeed().catch(() => {});
Glow.showToast("Signed out.");
};
Glow.toggleNotifications = async () => {
if (!("Notification" in window)) {
Glow.showToast("Browser notifications are not supported.", "error");
return;
}
if (!Glow.state.notificationsEnabled) {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
Glow.showToast("Notification permission was not granted.", "error");
return;
}
}
Glow.state.notificationsEnabled = !Glow.state.notificationsEnabled;
localStorage.setItem(
"glow_notifications_enabled",
String(Glow.state.notificationsEnabled)
);
Glow.updateAdminPreferenceButtons?.();
Glow.showToast(
Glow.state.notificationsEnabled
? "Browser notifications enabled."
: "Browser notifications disabled."
);
};
Glow.toggleTypingIndicators = () => {
Glow.state.typingIndicatorsEnabled = !Glow.state.typingIndicatorsEnabled;
localStorage.setItem(
"glow_typing_enabled",
String(Glow.state.typingIndicatorsEnabled)
);
if (!Glow.state.typingIndicatorsEnabled) {
Glow.state.typingUsers = [];
Glow.state.dmTypingUsers = [];
Glow.renderTyping();
}
Glow.updateAdminPreferenceButtons?.();
Glow.showToast(
Glow.state.typingIndicatorsEnabled
? "Typing indicators enabled."
: "Typing indicators disabled."
);
};
Glow.pingPresence = async () => {
if (!Glow.state.currentUser) return;
const payload = {
view: Glow.state.activeView
};
if (Glow.state.activeDm?.id) {
payload.direct_conversation_id = Glow.state.activeDm.id;
} else if (Glow.state.activeChannel?.id) {
payload.channel_id = Glow.state.activeChannel.id;
} else if (Glow.state.activeRoom?.id) {
payload.room_id = Glow.state.activeRoom.id;
}
try {
await Glow.apiPingPresence(payload);
} catch (_) {
// non-blocking
}
};
Glow.refreshPresence = async () => {
try {
if (Glow.state.activeDm?.id) {
const data = await Glow.apiGetDmPresence(Glow.state.activeDm.id);
Glow.state.presence.dmOnlineCount = Number(data.online_count) || 0;
Glow.els.presenceLabel.textContent =
`${Glow.state.presence.dmOnlineCount} online`;
Glow.els.presenceLabel.classList.remove("hidden");
return;
}
if (Glow.state.activeTeam?.slug && Glow.state.activeChannel?.slug) {
const data = await Glow.apiGetChannelPresence(
Glow.state.activeTeam.slug,
Glow.state.activeChannel.slug
);
Glow.state.presence.channelOnlineCount = Number(data.online_count) || 0;
Glow.els.presenceLabel.textContent =
`${Glow.state.presence.channelOnlineCount} online`;
Glow.els.presenceLabel.classList.remove("hidden");
return;
}
if (Glow.state.activeRoom?.slug) {
const data = await Glow.apiGetRoomPresence(Glow.state.activeRoom.slug);
Glow.state.presence.roomOnlineCount = Number(data.online_count) || 0;
Glow.els.presenceLabel.textContent =
`${Glow.state.presence.roomOnlineCount} online`;
Glow.els.presenceLabel.classList.remove("hidden");
return;
}
Glow.els.presenceLabel.classList.add("hidden");
} catch (_) {
Glow.els.presenceLabel?.classList.add("hidden");
}
};
Glow.schedulePoll = () => {
clearTimeout(Glow.state.pollTimer);
if (Glow.els.blockedOverlay.classList.contains("hidden") === false) {
return;
}
Glow.state.pollTimer = setTimeout(async () => {
if (Glow.state.activeView === "feed") {
await Glow.pollFeed();
} else if (
Glow.state.activeView === "room" ||
Glow.state.activeView === "channel" ||
Glow.state.activeView === "dm"
) {
await Glow.pollMessages();
await Glow.pollCallSignals?.();
} else if (Glow.state.activeView === "thread") {
await Glow.pollThread?.();
await Glow.pollCallSignals?.();
} else if (Glow.state.activeView === "dms") {
await Glow.loadDms?.().catch(() => {});
}
await Glow.pingPresence?.();
await Glow.refreshPresence?.();
Glow.schedulePoll();
}, document.hidden ? 12000 : 5000);
};
Glow.els.chatForm.addEventListener("submit", Glow.sendMessage);
Glow.els.threadForm?.addEventListener("submit", event =>
Glow.sendThreadReply?.(event)
);
Glow.els.messageInput.addEventListener("input", () => {
Glow.autoResizeComposer();
Glow.updateMessageCount();
clearTimeout(Glow.state.typingTimer);
if (Glow.els.messageInput.value.trim()) {
Glow.sendTyping(true);
Glow.state.typingTimer = setTimeout(() => {
Glow.sendTyping(false);
}, 2500);
} else {
Glow.sendTyping(false);
}
});
Glow.els.threadInput?.addEventListener("input", () => {
Glow.autoResizeThreadComposer?.();
Glow.updateThreadMessageCount?.();
});
Glow.els.messageInput.addEventListener("keydown", event => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
Glow.sendMessage(event);
}
});
Glow.els.threadInput?.addEventListener("keydown", event => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
Glow.sendThreadReply?.(event);
}
});
Glow.els.attachmentInput.addEventListener("change", () => {
const files = Glow.normalizeSelectedFiles(Glow.els.attachmentInput.files);
if (!files.length) {
Glow.setSelectedAttachment([]);
return;
}
const invalid = files.find(file => file.size > Glow.MAX_ATTACHMENT_BYTES);
if (invalid) {
Glow.showToast("Attachments must be 5 MB or smaller.", "error");
Glow.els.attachmentInput.value = "";
Glow.setSelectedAttachment([]);
return;
}
Glow.setSelectedAttachment(files);
});
Glow.els.threadAttachmentInput?.addEventListener("change", () => {
const files = Glow.normalizeSelectedFiles(Glow.els.threadAttachmentInput.files);
if (!files.length) {
Glow.setSelectedThreadAttachment?.([]);
return;
}
const invalid = files.find(file => file.size > Glow.MAX_ATTACHMENT_BYTES);
if (invalid) {
Glow.showToast("Attachments must be 5 MB or smaller.", "error");
Glow.els.threadAttachmentInput.value = "";
Glow.setSelectedThreadAttachment?.([]);
return;
}
Glow.setSelectedThreadAttachment?.(files);
});
Glow.els.feedButton.addEventListener("click", Glow.openFeed);
Glow.els.dmsButton?.addEventListener("click", async () => {
Glow.state.activeView = "dms";
Glow.updateViewUI();
await Glow.loadDms?.();
Glow.schedulePoll();
});
Glow.els.pagesButton?.addEventListener("click", () => {
Glow.openPages?.();
});
Glow.els.backButton?.addEventListener("click", async () => {
if (Glow.state.activeView === "thread") {
await Glow.returnToChannel?.();
} else if (Glow.state.activeView === "page") {
await Glow.openPages?.();
} else if (Glow.state.activeView === "profile" || Glow.state.activeView === "settings") {
await Glow.openFeed?.();
} else if (Glow.state.activeView === "dm") {
Glow.state.activeView = "dms";
Glow.updateViewUI();
await Glow.loadDms?.();
}
});
Glow.els.loginButton.addEventListener("click", () => {
Glow.authenticate("/api/login");
});
Glow.els.registerButton.addEventListener("click", () => {
Glow.authenticate("/api/register");
});
Glow.els.passwordInput.addEventListener("keydown", event => {
if (event.key === "Enter") {
event.preventDefault();
Glow.authenticate("/api/login");
}
});
Glow.els.logoutButton.addEventListener("click", Glow.logout);
Glow.els.roomSelect.addEventListener("change", async () => {
const selected = Glow.state.rooms.find(
room => room.slug === Glow.els.roomSelect.value
);
if (!selected) return;
Glow.state.activeDm = null;
Glow.state.activeRoom = selected;
Glow.state.activeView = "room";
Glow.state.typingUsers = [];
Glow.state.dmTypingUsers = [];
Glow.renderTyping();
Glow.updateViewUI();
await Glow.loadMessages();
Glow.pollTyping();
Glow.pingPresence?.();
Glow.refreshPresence?.();
Glow.schedulePoll();
});
Glow.els.teamSelect?.addEventListener("change", () => {
const teams = Glow.state.teams || [];
Glow.state.activeTeam =
teams.find(team => team.slug === Glow.els.teamSelect.value) || null;
});
Glow.els.adminFab.addEventListener("click", Glow.openAdminPanel);
Glow.els.closeAdminButton.addEventListener("click", Glow.closeAdminPanel);
Glow.els.createRoomButton.addEventListener("click", Glow.createRoom);
Glow.els.clearMessagesButton.addEventListener("click", Glow.clearActiveRoom);
Glow.els.typingToggleButton.addEventListener("click", Glow.toggleTypingIndicators);
Glow.els.notificationsToggleButton.addEventListener("click", Glow.toggleNotifications);
Glow.els.themeButton?.addEventListener("click", Glow.openAccentColorPicker);
Glow.els.settingsButton?.addEventListener("click", () => {
Glow.toggleHeaderMenu?.();
});
Glow.els.jumpToLatestButton.addEventListener("click", () => {
Glow.els.chatBox.scrollTop = Glow.els.chatBox.scrollHeight;
Glow.resetUnreadMessages();
});
Glow.els.chatBox.addEventListener("scroll", () => {
if (Glow.isNearBottom()) {
Glow.resetUnreadMessages();
}
});
Glow.els.adminPanel.addEventListener("click", event => {
if (event.target === Glow.els.adminPanel) {
Glow.closeAdminPanel();
}
});
Glow.els.dialogOverlay.addEventListener("click", event => {
if (event.target === Glow.els.dialogOverlay) {
Glow.closeDialog(null);
}
});
Glow.els.headerMenuOverlay?.addEventListener("click", event => {
if (event.target === Glow.els.headerMenuOverlay) {
Glow.closeHeaderMenu?.();
}
});
Glow.els.closeReactionPopupButton?.addEventListener("click", () => {
Glow.closeReactionPopup?.();
});
Glow.els.reactionPopupOverlay?.addEventListener("click", event => {
if (event.target === Glow.els.reactionPopupOverlay) {
Glow.closeReactionPopup?.();
}
});
Glow.els.closeMediaButton?.addEventListener("click", () => {
Glow.closeMediaModal?.();
});
Glow.els.mediaOverlay?.addEventListener("click", event => {
if (event.target === Glow.els.mediaOverlay) {
Glow.closeMediaModal?.();
}
});
Glow.els.closeCallPanelButton?.addEventListener("click", () => {
Glow.els.callOverlay?.classList.add("hidden");
});
document.addEventListener("keydown", event => {
if (event.key !== "Escape") return;
if (!Glow.els.dialogOverlay.classList.contains("hidden")) {
Glow.closeDialog(null);
return;
}
if (!Glow.els.mediaOverlay?.classList.contains("hidden")) {
Glow.closeMediaModal?.();
return;
}
if (!Glow.els.reactionPopupOverlay?.classList.contains("hidden")) {
Glow.closeReactionPopup?.();
return;
}
if (!Glow.els.headerMenuOverlay?.classList.contains("hidden")) {
Glow.closeHeaderMenu?.();
return;
}
if (!Glow.els.callOverlay?.classList.contains("hidden")) {
Glow.els.callOverlay.classList.add("hidden");
return;
}
if (Glow.state.activeView === "thread") {
Glow.returnToChannel?.();
return;
}
if (Glow.state.activeView === "page") {
Glow.openPages?.();
return;
}
if (Glow.state.activeView === "dm") {
Glow.state.activeView = "dms";
Glow.updateViewUI();
Glow.loadDms?.();
return;
}
if (Glow.els.adminPanel.classList.contains("open")) {
Glow.closeAdminPanel();
}
});
document.addEventListener("visibilitychange", () => {
Glow.schedulePoll();
if (!document.hidden) {
Glow.resetUnreadMessages();
if (Glow.state.activeView === "feed") {
Glow.pollFeed();
} else if (
Glow.state.activeView === "room" ||
Glow.state.activeView === "channel" ||
Glow.state.activeView === "dm"
) {
Glow.pollMessages();
Glow.pollTyping();
Glow.pollCallSignals?.();
} else if (Glow.state.activeView === "thread") {
Glow.pollThread?.();
Glow.pollCallSignals?.();
} else if (Glow.state.activeView === "dms") {
Glow.loadDms?.();
}
Glow.pingPresence?.();
Glow.refreshPresence?.();
}
});
window.addEventListener("online", () => {
Glow.showToast("Back online.");
if (Glow.state.activeView === "feed") {
Glow.pollFeed();
} else if (
Glow.state.activeView === "room" ||
Glow.state.activeView === "channel" ||
Glow.state.activeView === "dm"
) {
Glow.pollMessages();
Glow.pollTyping();
Glow.pollCallSignals?.();
} else if (Glow.state.activeView === "thread") {
Glow.pollThread?.();
Glow.pollCallSignals?.();
} else if (Glow.state.activeView === "dms") {
Glow.loadDms?.();
}
Glow.pingPresence?.();
Glow.refreshPresence?.();
});
window.addEventListener("offline", () => {
Glow.showToast("You are offline.", "error");
});
setInterval(() => {
if (
Glow.state.activeView === "room" ||
Glow.state.activeView === "channel" ||
Glow.state.activeView === "dm"
) {
Glow.pollTyping();
}
}, 2500);
setInterval(() => {
if (
Glow.state.activeView === "room" ||
Glow.state.activeView === "channel" ||
Glow.state.activeView === "dm"
) {
Glow.pingPresence?.();
Glow.refreshPresence?.();
}
}, 10000);
Glow.applyAccentColor(Glow.state.accentColor);
Glow.autoResizeComposer();
Glow.updateMessageCount();
Glow.autoResizeThreadComposer?.();
Glow.updateThreadMessageCount?.();
Glow.checkAuth();

430
static/js/05-feed.js Normal file
View File

@ -0,0 +1,430 @@
// static/js/05-feed.js
"use strict";
window.Glow = window.Glow || {};
Glow.clearFeed = () => {
Glow.state.feedLastSeenId = 0;
Glow.state.feedKnownMessageIds.clear();
Glow.state.feedEntries = [];
Glow.els.feedBox.replaceChildren();
};
Glow.renderFeedEmptyState = message => {
if (Glow.els.feedBox.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.feedBox.append(state);
};
Glow.openFeedRoom = async slug => {
const room = Glow.state.rooms.find(item => item.slug === slug);
if (!room) {
Glow.showToast("This room is no longer available.", "error");
return;
}
Glow.state.activeDm = null;
Glow.state.activeChannel = null;
Glow.state.activeRoom = room;
Glow.state.activeView = "room";
Glow.els.roomSelect.value = room.slug;
Glow.updateViewUI();
await Glow.loadMessages();
Glow.pingPresence?.();
Glow.refreshPresence?.();
Glow.schedulePoll();
};
Glow.openFeedEntry = async entry => {
if (
entry.team_slug &&
entry.channel_slug &&
typeof Glow.loadChannelsForTeam === "function"
) {
let team = Glow.state.teams?.find(item => item.slug === entry.team_slug);
if (!team && typeof Glow.loadTeamsForAdapter === "function") {
try {
await Glow.loadTeamsForAdapter();
team = Glow.state.teams?.find(item => item.slug === entry.team_slug);
} catch (_) {
team = null;
}
}
if (team) {
Glow.state.activeTeam = team;
Glow.state.activeDm = null;
if (Glow.els.teamSelect) {
Glow.els.teamSelect.value = team.slug;
}
try {
await Glow.loadChannelsForTeam(team.slug, entry.channel_slug);
if (Glow.state.activeChannel) {
Glow.state.activeView = "channel";
Glow.updateViewUI();
await Glow.loadChannelMessages?.();
Glow.pollTyping?.();
Glow.pingPresence?.();
Glow.refreshPresence?.();
Glow.schedulePoll();
return;
}
} catch (error) {
Glow.showToast(error.message || "Could not open this channel.", "error");
return;
}
}
}
if (entry.room_slug) {
await Glow.openFeedRoom(entry.room_slug);
return;
}
Glow.showToast("This conversation is no longer available.", "error");
};
Glow.buildFeedReactions = entry => {
const reactions = Array.isArray(entry.reactions) ? entry.reactions : [];
if (!reactions.length) return null;
const row = document.createElement("div");
row.className = "feed-entry-reactions";
for (const reaction of reactions) {
const chip = document.createElement("button");
chip.className = "reaction-chip";
chip.type = "button";
chip.textContent = `${reaction.emoji} ${reaction.count}`;
if (reaction.reacted) {
chip.classList.add("active");
}
chip.addEventListener("click", async () => {
try {
if (reaction.reacted) {
await Glow.apiRemoveReaction(entry.id, reaction.emoji);
} else {
await Glow.apiAddReaction(entry.id, reaction.emoji);
}
await Glow.loadFeed();
} catch (error) {
Glow.showToast(error.message || "Could not update reaction.", "error");
}
});
row.append(chip);
}
return row;
};
Glow.buildFeedAttachmentBlock = entry => {
const attachments = Array.isArray(entry.attachments)
? entry.attachments
: entry.attachment
? [entry.attachment]
: [];
if (!attachments.length) return null;
const wrap = document.createElement("div");
wrap.className = "message-attachments-list";
for (const attachment of attachments) {
if (String(attachment.mime_type || "").startsWith("image/")) {
const imageButton = document.createElement("button");
imageButton.className = "message-image-link";
imageButton.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";
imageButton.append(image);
imageButton.addEventListener("click", () => {
Glow.openMediaModal?.({
url: attachment.url,
name: attachment.original_name,
mimeType: attachment.mime_type
});
});
wrap.append(imageButton);
} else if (String(attachment.mime_type || "").startsWith("audio/")) {
const audio = document.createElement("audio");
audio.controls = true;
audio.preload = "metadata";
audio.src = attachment.url;
wrap.append(audio);
} else if (String(attachment.mime_type || "").startsWith("video/")) {
const video = document.createElement("video");
video.controls = true;
video.preload = "metadata";
video.src = attachment.url;
video.className = "message-video-preview";
wrap.append(video);
}
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}`;
wrap.append(link);
}
return wrap;
};
Glow.buildFeedEntry = entry => {
const card = document.createElement("article");
card.className = "feed-entry";
if (entry.is_mention) {
card.classList.add("mention");
}
card.dataset.messageId = String(entry.id);
if (entry.room_slug) card.dataset.roomSlug = entry.room_slug;
if (entry.team_slug) card.dataset.teamSlug = entry.team_slug;
if (entry.channel_slug) card.dataset.channelSlug = entry.channel_slug;
const heading = document.createElement("div");
heading.className = "feed-entry-heading";
const room = document.createElement("button");
room.className = "feed-room-link";
room.type = "button";
if (entry.team_name && entry.channel_name) {
room.textContent = `${entry.team_name} · # ${entry.channel_name}`;
} else {
room.textContent =
`${entry.room_visibility === "private" ? "(private) " : "# "}${entry.room_name || "channel"}`;
}
room.addEventListener("click", () => {
Glow.openFeedEntry(entry);
});
const time = document.createElement("time");
time.className = "feed-entry-time";
time.dateTime = entry.created_at;
time.textContent = Glow.relativeTime(entry.created_at);
time.title = new Date(entry.created_at).toLocaleString();
heading.append(room, time);
const author = document.createElement("button");
author.className = "feed-entry-author profile-link";
author.type = "button";
author.textContent = entry.username;
Glow.applyUserColor(author, entry);
author.addEventListener("click", () => {
Glow.openProfile?.(entry.username);
});
const body = document.createElement("div");
body.className = "feed-entry-body";
body.textContent = entry.text;
const attachmentBlock = Glow.buildFeedAttachmentBlock(entry);
const reactions = Glow.buildFeedReactions(entry);
const openButton = document.createElement("button");
openButton.className = "small-button feed-open-room";
openButton.type = "button";
openButton.textContent = "Open channel";
openButton.addEventListener("click", () => {
Glow.openFeedEntry(entry);
});
card.addEventListener("dblclick", () => {
Glow.openFeedEntry(entry);
});
card.append(heading, author, body);
if (attachmentBlock) {
card.append(attachmentBlock);
}
if (reactions) {
card.append(reactions);
}
card.append(openButton);
return card;
};
Glow.appendFeedEntries = (entries, forceScroll = false) => {
if (!Array.isArray(entries) || !entries.length) {
if (!Glow.state.feedKnownMessageIds.size) {
Glow.renderFeedEmptyState(
Glow.state.currentUser
? "No activity in your accessible rooms yet."
: "Sign in or create an account to see your personalized activity feed."
);
}
return;
}
const nearBottom =
Glow.els.feedBox.scrollHeight -
Glow.els.feedBox.scrollTop -
Glow.els.feedBox.clientHeight < 100;
const fragment = document.createDocumentFragment();
for (const entry of entries) {
const id = Number(entry.id);
if (!id || Glow.state.feedKnownMessageIds.has(id)) {
continue;
}
const previous = Glow.state.feedEntries.at(-1);
const dateChanged =
!previous ||
Glow.dateKey(previous.created_at) !== Glow.dateKey(entry.created_at);
if (dateChanged) {
const separator = document.createElement("div");
separator.className = "date-separator";
separator.dataset.dateKey = Glow.dateKey(entry.created_at);
const label = document.createElement("span");
label.textContent = Glow.formatDateLabel(entry.created_at);
separator.append(label);
fragment.append(separator);
}
Glow.state.feedKnownMessageIds.add(id);
Glow.state.feedLastSeenId = Math.max(Glow.state.feedLastSeenId, id);
Glow.state.feedEntries.push(entry);
fragment.append(Glow.buildFeedEntry(entry));
}
if (!fragment.childNodes.length) return;
Glow.els.feedBox.querySelector(".empty-state")?.remove();
Glow.els.feedBox.append(fragment);
if (forceScroll || nearBottom) {
Glow.els.feedBox.scrollTop = Glow.els.feedBox.scrollHeight;
}
};
Glow.loadFeed = async () => {
const requestId = ++Glow.state.feedRequestId;
Glow.els.feedBox.setAttribute("aria-busy", "true");
try {
const data = await Glow.apiRequest("/api/feed");
if (requestId !== Glow.state.feedRequestId || Glow.state.activeView !== "feed") {
return;
}
Glow.clearFeed();
Glow.appendFeedEntries(data.entries || [], true);
Glow.state.feedLastSeenId = Number(data.latest_id) || 0;
if (!(data.entries || []).length) {
Glow.renderFeedEmptyState(
Glow.state.currentUser
? "No activity in your accessible rooms yet."
: "Sign in or create an account to see your personalized activity feed."
);
}
} catch (error) {
if (requestId !== Glow.state.feedRequestId) {
return;
}
Glow.clearFeed();
Glow.renderFeedEmptyState(error.message || "Could not load activity feed.");
Glow.showToast(error.message || "Could not load activity feed.", "error");
} finally {
if (requestId === Glow.state.feedRequestId) {
Glow.els.feedBox.setAttribute("aria-busy", "false");
}
}
};
Glow.pollFeed = async () => {
if (
Glow.state.feedPollInFlight ||
Glow.state.activeView !== "feed" ||
document.hidden
) {
return;
}
Glow.state.feedPollInFlight = true;
try {
const data = await Glow.apiRequest(
`/api/feed?after_id=${encodeURIComponent(Glow.state.feedLastSeenId)}`
);
if (Glow.state.activeView !== "feed") {
return;
}
Glow.appendFeedEntries(data.entries || [], false);
Glow.state.feedLastSeenId = Math.max(
Glow.state.feedLastSeenId,
Number(data.latest_id) || 0
);
} catch (error) {
if (error.status === 404) {
console.warn("Feed API is unavailable. Deploy the /api/feed backend route.");
return;
}
if (
error.status !== 401 &&
error.code !== "blacklisted" &&
error.code !== "network"
) {
console.error("Feed polling failed:", error);
}
} finally {
Glow.state.feedPollInFlight = false;
}
};
Glow.openFeed = async () => {
Glow.state.activeDm = null;
Glow.state.activeView = "feed";
Glow.updateViewUI();
await Glow.loadFeed();
Glow.schedulePoll();
};

465
static/js/06-pages.js Normal file
View File

@ -0,0 +1,465 @@
// static/js/06-pages.js
"use strict";
window.Glow = window.Glow || {};
Glow.renderPagesEmptyState = message => {
Glow.els.pagesBox.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.pagesBox.append(state);
};
Glow.pageTypeLabel = type => {
const labels = {
project: "Project",
document: "Document",
update: "Update",
resource: "Resource"
};
return labels[type] || "Page";
};
Glow.loadPages = async (pageType = Glow.state.pageTypeFilter) => {
const team = Glow.state.activeTeam;
if (!team) {
Glow.renderPagesEmptyState("Select a team to view pages.");
return;
}
Glow.els.pagesBox.setAttribute("aria-busy", "true");
try {
const query = pageType ? `?type=${encodeURIComponent(pageType)}` : "";
const data = await Glow.apiRequest(
`/api/teams/${encodeURIComponent(team.slug)}/pages${query}`
);
if (Glow.state.activeTeam?.slug !== team.slug) return;
Glow.state.pages = data.pages || [];
Glow.state.pageTypeFilter = pageType || "";
Glow.renderPages();
} catch (error) {
Glow.renderPagesEmptyState(error.message || "Could not load pages.");
Glow.showToast(error.message || "Could not load pages.", "error");
} finally {
Glow.els.pagesBox.setAttribute("aria-busy", "false");
}
};
Glow.renderPages = () => {
const box = Glow.els.pagesBox;
const team = Glow.state.activeTeam;
const pages = Glow.state.pages || [];
box.replaceChildren();
const header = document.createElement("div");
header.className = "pages-header";
const copy = document.createElement("div");
const title = document.createElement("h2");
title.textContent = `${team?.name || "Team"} pages`;
const subtitle = document.createElement("p");
subtitle.textContent = "Shared team knowledge and public resources.";
copy.append(title, subtitle);
header.append(copy);
if (team?.can_manage) {
const create = document.createElement("button");
create.className = "button";
create.type = "button";
create.textContent = "New page";
create.addEventListener("click", Glow.createPage);
header.append(create);
}
box.append(header);
const filters = document.createElement("div");
filters.className = "page-filters";
const filterOptions = [
["", "All"],
["project", "Projects"],
["document", "Documents"],
["update", "Updates"],
["resource", "Resources"]
];
for (const [value, label] of filterOptions) {
const button = document.createElement("button");
button.className = "page-filter";
button.type = "button";
button.textContent = label;
button.classList.toggle("active", Glow.state.pageTypeFilter === value);
button.addEventListener("click", () => Glow.loadPages(value));
filters.append(button);
}
box.append(filters);
if (!pages.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 pages match this filter.";
empty.append(card);
box.append(empty);
return;
}
const list = document.createElement("div");
list.className = "pages-list";
for (const page of pages) {
list.append(Glow.buildPageCard(page));
}
box.append(list);
};
Glow.buildPageCard = page => {
const article = document.createElement("article");
article.className = "page-card";
article.tabIndex = 0;
const meta = document.createElement("div");
meta.className = "page-card-meta";
const type = document.createElement("span");
type.className = "page-badge type";
type.textContent = Glow.pageTypeLabel(page.page_type);
const visibility = document.createElement("span");
visibility.className = `page-badge visibility ${page.visibility}`;
visibility.textContent = page.visibility;
meta.append(type, visibility);
const title = document.createElement("h3");
title.textContent = page.title;
const excerpt = document.createElement("p");
excerpt.className = "page-card-excerpt";
const compactBody = (page.body || "").replace(/\s+/g, " ").trim();
excerpt.textContent =
compactBody.length > 180
? `${compactBody.slice(0, 180)}…`
: compactBody || "No content yet.";
const footer = document.createElement("div");
footer.className = "page-card-footer";
const context = document.createElement("span");
context.textContent = page.channel_name ? `#${page.channel_name}` : "Team page";
const updated = document.createElement("time");
updated.dateTime = page.updated_at;
updated.title = new Date(page.updated_at).toLocaleString();
updated.textContent = `Updated ${Glow.relativeTime(page.updated_at)}`;
footer.append(context, updated);
const open = () => Glow.openPage(page.slug);
article.addEventListener("click", open);
article.addEventListener("keydown", event => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
open();
}
});
article.append(meta, title, excerpt, footer);
return article;
};
Glow.openPages = async () => {
Glow.state.activeDm = null;
Glow.state.activeView = "pages";
Glow.state.activePage = null;
Glow.updateViewUI();
await Glow.loadPages();
};
Glow.openPage = async pageSlug => {
const team = Glow.state.activeTeam;
if (!team) return;
Glow.state.activeView = "page";
Glow.state.activePage = null;
Glow.updateViewUI();
Glow.els.pageDetailBox.setAttribute("aria-busy", "true");
try {
const data = await Glow.apiRequest(
`/api/teams/${encodeURIComponent(team.slug)}/pages/${encodeURIComponent(pageSlug)}`
);
Glow.state.activePage = data.page;
Glow.renderPageDetail();
} catch (error) {
Glow.showToast(error.message || "Could not load page.", "error");
Glow.openPages();
} finally {
Glow.els.pageDetailBox.setAttribute("aria-busy", "false");
}
};
Glow.renderPageDetail = () => {
const box = Glow.els.pageDetailBox;
const page = Glow.state.activePage;
box.replaceChildren();
if (!page) {
const state = document.createElement("div");
state.className = "empty-state";
const card = document.createElement("div");
card.className = "empty-card";
card.textContent = "Page not found.";
state.append(card);
box.append(state);
return;
}
const header = document.createElement("div");
header.className = "page-detail-header";
const meta = document.createElement("div");
meta.className = "page-detail-meta";
const type = document.createElement("span");
type.className = "page-badge type";
type.textContent = Glow.pageTypeLabel(page.page_type);
const visibility = document.createElement("span");
visibility.className = `page-badge visibility ${page.visibility}`;
visibility.textContent = page.visibility;
const updated = document.createElement("time");
updated.className = "page-updated";
updated.dateTime = page.updated_at;
updated.title = new Date(page.updated_at).toLocaleString();
updated.textContent = `Updated ${Glow.relativeTime(page.updated_at)}`;
meta.append(type, visibility, updated);
const title = document.createElement("h1");
title.textContent = page.title;
const context = document.createElement("p");
context.className = "page-detail-context";
context.textContent = page.channel_name
? `${page.team_name} · #${page.channel_name}`
: page.team_name;
header.append(meta, title, context);
box.append(header);
const body = document.createElement("article");
body.className = "page-body markdown-body";
body.innerHTML = page.body_html || "<p>No content yet.</p>";
box.append(body);
if (page.can_edit) {
const actions = document.createElement("div");
actions.className = "page-detail-actions";
const edit = document.createElement("button");
edit.className = "button";
edit.type = "button";
edit.textContent = "Edit";
edit.addEventListener("click", () => Glow.editPage(page));
const remove = document.createElement("button");
remove.className = "danger-button";
remove.type = "button";
remove.textContent = "Delete";
remove.addEventListener("click", () => Glow.deletePage(page));
actions.append(edit, remove);
box.append(actions);
}
};
Glow.pageDialogFields = (page = null) => [
{
name: "title",
label: "Title",
value: page?.title || "",
placeholder: "Page title",
maxLength: 160
},
{
name: "page_type",
label: "Type: project, document, update, or resource",
value: page?.page_type || "document",
maxLength: 12
},
{
name: "visibility",
label: "Visibility: team or public",
value: page?.visibility || "team",
maxLength: 6
},
{
name: "channel_slug",
label: "Channel slug (optional)",
value: page?.channel_slug || "",
placeholder: "general",
maxLength: 48
},
{
name: "body",
label: "Markdown content",
value: page?.body || "",
multiline: true,
rows: 14,
maxLength: 100000
}
];
Glow.validatePageValues = values => {
if (!values.title) {
Glow.showToast("Page title is required.", "error");
return null;
}
const pageType = values.page_type.toLowerCase();
if (!["project", "document", "update", "resource"].includes(pageType)) {
Glow.showToast("Choose a valid page type.", "error");
return null;
}
const visibility = values.visibility.toLowerCase();
if (!["team", "public"].includes(visibility)) {
Glow.showToast("Visibility must be team or public.", "error");
return null;
}
return {
title: values.title,
body: values.body,
page_type: pageType,
visibility,
channel_slug: values.channel_slug.toLowerCase()
};
};
Glow.createPage = async () => {
const team = Glow.state.activeTeam;
if (!team?.can_manage) return;
const values = await Glow.openDialog({
title: "Create page",
body: "Create a shared team page or a public resource. Markdown is supported.",
confirmText: "Create page",
fields: Glow.pageDialogFields()
});
if (!values) return;
const payload = Glow.validatePageValues(values);
if (!payload) return;
try {
const data = await Glow.apiRequest(
`/api/teams/${encodeURIComponent(team.slug)}/pages`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}
);
Glow.showToast("Page created.");
await Glow.openPage(data.page.slug);
} catch (error) {
Glow.showToast(error.message || "Could not create page.", "error");
}
};
Glow.editPage = async page => {
if (!page.can_edit) return;
const values = await Glow.openDialog({
title: `Edit ${page.title}`,
confirmText: "Save",
fields: Glow.pageDialogFields(page)
});
if (!values) return;
const payload = Glow.validatePageValues(values);
if (!payload) return;
try {
const data = await Glow.apiRequest(
`/api/teams/${encodeURIComponent(page.team_slug)}/pages/${encodeURIComponent(page.slug)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}
);
Glow.state.activePage = data.page;
Glow.renderPageDetail();
Glow.showToast("Page updated.");
} catch (error) {
Glow.showToast(error.message || "Could not update page.", "error");
}
};
Glow.deletePage = async page => {
if (!page.can_edit) return;
const result = await Glow.openDialog({
title: `Delete ${page.title}?`,
body: "This page will no longer be available.",
confirmText: "Delete page",
danger: true
});
if (!result) return;
try {
await Glow.apiRequest(
`/api/teams/${encodeURIComponent(page.team_slug)}/pages/${encodeURIComponent(page.slug)}`,
{ method: "DELETE" }
);
Glow.showToast("Page deleted.");
await Glow.openPages();
} catch (error) {
Glow.showToast(error.message || "Could not delete page.", "error");
}
};

View File

@ -0,0 +1,681 @@
// static/js/07-team-channel-adapter.js
"use strict";
window.Glow = window.Glow || {};
Glow.teamAdapter = Glow.teamAdapter || {
initialized: false,
originalLoadRooms: null,
originalUpdateViewUI: null,
chatBarVisibilityInstalled: false
};
Glow.loadTeamsForAdapter = async () => {
if (!Glow.els.teamSelect) return;
try {
const data = await Glow.apiRequest("/api/teams");
const teams = data.teams || [];
const selectedSlug =
Glow.state.activeTeam?.slug ||
Glow.els.teamSelect.value;
Glow.state.teams = teams;
Glow.els.teamSelect.replaceChildren();
for (const team of teams) {
const option = document.createElement("option");
option.value = team.slug;
option.textContent = team.name;
Glow.els.teamSelect.append(option);
}
Glow.state.activeTeam =
teams.find(team => team.slug === selectedSlug) ||
teams.find(team => team.slug === "general") ||
teams[0] ||
null;
Glow.els.teamSelect.disabled = !Glow.state.activeTeam;
if (Glow.state.activeTeam) {
Glow.els.teamSelect.value = Glow.state.activeTeam.slug;
}
} catch (error) {
console.warn("Team adapter could not load teams:", error);
Glow.state.teams = [];
Glow.state.activeTeam = null;
Glow.els.teamSelect.disabled = true;
}
};
Glow.loadChannelsForTeam = async (teamSlug, preferredChannelSlug = null) => {
if (!Glow.els.roomSelect) return;
if (!teamSlug) {
Glow.state.channels = [];
Glow.state.activeChannel = null;
Glow.els.roomSelect.replaceChildren();
Glow.els.roomSelect.disabled = true;
return;
}
const data = await Glow.apiRequest(
`/api/teams/${encodeURIComponent(teamSlug)}/channels`
);
const channels = data.channels || [];
Glow.state.channels = channels;
const matchingTeam = Glow.state.teams.find(team => team.slug === teamSlug);
if (matchingTeam || data.team) {
Glow.state.activeTeam = {
...(matchingTeam || {}),
...(data.team || {})
};
}
Glow.els.roomSelect.replaceChildren();
for (const channel of channels) {
const option = document.createElement("option");
option.value = channel.slug;
option.textContent = `# ${channel.name}`;
Glow.els.roomSelect.append(option);
}
const selected =
channels.find(channel => channel.slug === preferredChannelSlug) ||
channels.find(channel => channel.slug === Glow.state.activeChannel?.slug) ||
channels.find(channel => channel.slug === Glow.state.activeRoom?.slug) ||
channels.find(channel => channel.slug === "general") ||
channels[0] ||
null;
Glow.state.activeChannel = selected;
Glow.state.activeRoom = selected;
Glow.els.roomSelect.disabled = !selected;
if (selected) {
Glow.els.roomSelect.value = selected.slug;
}
};
Glow.decorateChannelSelector = () => {
if (!Glow.els.roomSelect) return;
Glow.els.roomSelect.classList.add("channel-select");
Glow.els.roomSelect.setAttribute("aria-label", "Select channel");
};
Glow.updateTeamChannelHeader = () => {
const team = Glow.state.activeTeam;
const channel = Glow.state.activeChannel || Glow.state.activeRoom;
const isChannelView =
Glow.state.activeView === "channel" ||
Glow.state.activeView === "room";
if (
Glow.state.activeView === "dm" ||
Glow.state.activeView === "dms"
) {
return;
}
if (!isChannelView || !team || !channel || !Glow.els.headerTitle) {
return;
}
Glow.els.headerTitle.textContent = `# ${channel.name}`;
Glow.els.headerSubtitle.textContent =
`${team.name} · ${channel.description || "Channel conversation"}`;
};
Glow.applyComposerVisibility = () => {
const view = Glow.state.activeView || "feed";
const showChannelComposer =
view === "room" || view === "channel" || view === "dm";
const showThreadComposer = view === "thread";
Glow.els.composerWrap?.classList.toggle("hidden", !showChannelComposer);
Glow.els.threadComposerWrap?.classList.toggle("hidden", !showThreadComposer);
if (!showChannelComposer) {
Glow.state.typingUsers = [];
Glow.state.dmTypingUsers = [];
Glow.renderTyping?.();
}
};
Glow.openCreateTeamDialog = async () => {
const values = await Glow.openDialog({
title: "Create team",
body: "Create a team. Each user can own up to 3 teams.",
confirmText: "Create team",
fields: [
{
name: "name",
label: "Team name",
placeholder: "Design",
maxLength: 60
},
{
name: "description",
label: "Description",
placeholder: "Optional team description",
multiline: true,
rows: 3,
maxLength: 300
},
{
name: "slug",
label: "Slug (optional)",
placeholder: "design",
maxLength: 48
}
]
});
if (!values) return;
try {
const data = await Glow.apiCreateTeam({
name: values.name,
description: values.description,
slug: values.slug
});
Glow.showToast("Team created.");
await Glow.refreshTeamAndChannelData(data.team?.slug || null);
} catch (error) {
Glow.showToast(error.message || "Could not create team.", "error");
}
};
Glow.openEditTeamDialog = async team => {
if (!team?.can_manage) {
Glow.showToast("You cannot edit this team.", "error");
return;
}
const values = await Glow.openDialog({
title: `Edit ${team.name}`,
confirmText: "Save",
fields: [
{
name: "name",
label: "Team name",
value: team.name || "",
maxLength: 60
},
{
name: "description",
label: "Description",
value: team.description || "",
multiline: true,
rows: 3,
maxLength: 300
}
]
});
if (!values) return;
try {
await Glow.apiUpdateTeam(team.slug, {
name: values.name,
description: values.description
});
Glow.showToast("Team updated.");
await Glow.refreshTeamAndChannelData(team.slug);
} catch (error) {
Glow.showToast(error.message || "Could not update team.", "error");
}
};
Glow.openCreateChannelDialog = async team => {
if (!team?.can_manage) {
Glow.showToast("You cannot add channels to this team.", "error");
return;
}
const values = await Glow.openDialog({
title: `Add channel to ${team.name}`,
confirmText: "Create channel",
fields: [
{
name: "name",
label: "Channel name",
placeholder: "announcements",
maxLength: 60
},
{
name: "description",
label: "Description",
placeholder: "Optional channel description",
multiline: true,
rows: 3,
maxLength: 300
},
{
name: "slug",
label: "Slug (optional)",
placeholder: "announcements",
maxLength: 48
}
]
});
if (!values) return;
try {
const data = await Glow.apiCreateChannel(team.slug, {
name: values.name,
description: values.description,
slug: values.slug
});
Glow.showToast("Channel created.");
await Glow.refreshTeamAndChannelData(team.slug, data.channel?.slug || null);
} catch (error) {
Glow.showToast(error.message || "Could not create channel.", "error");
}
};
Glow.openEditChannelDialog = async (team, channel) => {
if (!team?.can_manage || !channel) {
Glow.showToast("You cannot edit this channel.", "error");
return;
}
const values = await Glow.openDialog({
title: `Edit # ${channel.name}`,
confirmText: "Save",
fields: [
{
name: "name",
label: "Channel name",
value: channel.name || "",
maxLength: 60
},
{
name: "description",
label: "Description",
value: channel.description || "",
multiline: true,
rows: 3,
maxLength: 300
}
]
});
if (!values) return;
try {
await Glow.apiUpdateChannel(team.slug, channel.slug, {
name: values.name,
description: values.description
});
Glow.showToast("Channel updated.");
await Glow.refreshTeamAndChannelData(team.slug, channel.slug);
} catch (error) {
Glow.showToast(error.message || "Could not update channel.", "error");
}
};
Glow.manageTeamMembers = async team => {
if (!team?.can_manage) {
Glow.showToast("You cannot manage this team.", "error");
return;
}
try {
const data = await Glow.apiListTeamMembers(team.slug);
const container = document.createElement("div");
container.className = "member-list";
for (const member of data.members || []) {
const row = document.createElement("div");
row.className = "member-row";
const name = document.createElement("span");
name.textContent = `${member.username} · ${member.membership_role}`;
const actions = document.createElement("div");
actions.className = "settings-actions";
const promote = document.createElement("button");
promote.className = "small-button";
promote.type = "button";
promote.textContent =
member.membership_role === "member" ? "Make admin" : "Make member";
promote.disabled = member.membership_role === "owner";
promote.addEventListener("click", async () => {
try {
await Glow.apiUpdateTeamMember(team.slug, member.id, {
membership_role:
member.membership_role === "member" ? "admin" : "member"
});
Glow.closeDialog({});
Glow.manageTeamMembers(team);
} catch (error) {
Glow.showToast(error.message || "Could not update member.", "error");
}
});
const remove = document.createElement("button");
remove.className = "small-button danger";
remove.type = "button";
remove.textContent = "Remove";
remove.disabled = member.membership_role === "owner";
remove.addEventListener("click", async () => {
try {
await Glow.apiRemoveTeamMember(team.slug, member.id);
Glow.closeDialog({});
Glow.manageTeamMembers(team);
} catch (error) {
Glow.showToast(error.message || "Could not remove member.", "error");
}
});
actions.append(promote, remove);
row.append(name, actions);
container.append(row);
}
const values = await Glow.openDialog({
title: `${team.name} members`,
body: container,
confirmText: "Add member",
fields: [
{
name: "username",
label: "Username to add",
placeholder: "username",
maxLength: 30
},
{
name: "membership_role",
label: "Role: member or admin",
value: "member",
maxLength: 6
}
]
});
if (!values?.username) return;
await Glow.apiAddTeamMember(team.slug, {
username: values.username,
membership_role: values.membership_role || "member"
});
Glow.showToast("Member added.");
await Glow.manageTeamMembers(team);
} catch (error) {
Glow.showToast(error.message || "Could not manage team members.", "error");
}
};
Glow.createTeamInviteLink = async team => {
if (!team?.can_manage) return;
try {
const values = await Glow.openDialog({
title: `Create invite for ${team.name}`,
confirmText: "Create link",
fields: [
{
name: "membership_role",
label: "Invite role: member or admin",
value: "member",
maxLength: 6
}
]
});
if (!values) return;
const data = await Glow.apiCreateTeamInvite(team.slug, {
membership_role: values.membership_role || "member"
});
const url = new URL(data.invite.url, window.location.origin).toString();
const result = await Glow.openDialog({
title: "Team invite link",
body: "Copy and share this link. It can be used once before it expires.",
confirmText: "Copy link",
fields: [
{
name: "invite",
label: "Invite link",
value: url,
maxLength: 500
}
]
});
if (result) {
await navigator.clipboard.writeText(url);
Glow.showToast("Invite link copied.");
}
} catch (error) {
Glow.showToast(error.message || "Could not create team invite.", "error");
}
};
Glow.deleteCurrentChannel = async team => {
const channel = Glow.state.activeChannel;
if (!team?.can_manage || !channel) return;
const result = await Glow.openDialog({
title: `Delete # ${channel.name}?`,
body: "This channel will no longer be available.",
confirmText: "Delete channel",
danger: true
});
if (!result) return;
try {
await Glow.apiDeleteChannel(team.slug, channel.slug);
Glow.showToast("Channel deleted.");
await Glow.refreshTeamAndChannelData(team.slug);
} catch (error) {
Glow.showToast(error.message || "Could not delete channel.", "error");
}
};
Glow.refreshTeamAndChannelData = async (teamSlug = null, channelSlug = null) => {
await Glow.loadTeamsForAdapter();
const targetTeamSlug =
teamSlug ||
Glow.state.activeTeam?.slug ||
Glow.state.teams?.[0]?.slug ||
null;
if (!targetTeamSlug) {
Glow.updateViewUI();
return;
}
await Glow.loadChannelsForTeam(targetTeamSlug, channelSlug);
Glow.state.activeDm = null;
Glow.state.activeView = "channel";
Glow.updateViewUI();
if (Glow.state.activeChannel) {
await Glow.loadChannelMessages?.();
Glow.pollTyping?.();
Glow.pingPresence?.();
Glow.refreshPresence?.();
} else {
Glow.clearMessages?.();
Glow.renderEmptyState?.("No channels are available in this team.");
}
Glow.schedulePoll?.();
};
Glow.installTeamAdapter = () => {
if (Glow.teamAdapter.initialized) return;
if (!Glow.els.teamSelect || !Glow.els.roomSelect) {
console.warn("Team adapter skipped: expected team/channel elements are missing.");
return;
}
Glow.teamAdapter.initialized = true;
Glow.state.teams ||= [];
Glow.state.channels ||= [];
Glow.state.activeTeam ||= null;
Glow.state.activeChannel ||= null;
Glow.decorateChannelSelector();
Glow.teamAdapter.originalLoadRooms = Glow.loadRooms;
Glow.loadRooms = async () => {
await Glow.teamAdapter.originalLoadRooms();
await Glow.loadTeamsForAdapter();
Glow.decorateChannelSelector();
if (Glow.state.activeTeam) {
try {
await Glow.loadChannelsForTeam(
Glow.state.activeTeam.slug,
Glow.state.activeRoom?.slug
);
} catch (error) {
console.warn("Team adapter could not load channels after rooms:", error);
}
}
Glow.updateTeamChannelHeader();
};
Glow.teamAdapter.originalUpdateViewUI = Glow.updateViewUI;
Glow.updateViewUI = () => {
Glow.teamAdapter.originalUpdateViewUI?.();
Glow.updateTeamChannelHeader();
Glow.applyComposerVisibility();
};
const initializeAdapter = async () => {
await Glow.loadTeamsForAdapter();
Glow.decorateChannelSelector();
if (!Glow.state.activeTeam) {
Glow.applyComposerVisibility();
return;
}
try {
await Glow.loadChannelsForTeam(
Glow.state.activeTeam.slug,
Glow.state.activeRoom?.slug
);
Glow.updateTeamChannelHeader();
} catch (error) {
console.warn("Team adapter could not load initial channels:", error);
}
Glow.applyComposerVisibility();
};
initializeAdapter();
Glow.els.teamSelect.addEventListener("change", async () => {
const selected = Glow.state.teams.find(
team => team.slug === Glow.els.teamSelect.value
);
if (!selected) return;
Glow.state.activeDm = null;
Glow.state.activeTeam = selected;
Glow.state.activeChannel = null;
Glow.state.activeRoom = null;
Glow.state.typingUsers = [];
Glow.state.dmTypingUsers = [];
Glow.renderTyping?.();
Glow.clearMessages?.();
try {
await Glow.loadChannelsForTeam(selected.slug);
Glow.state.activeView = "channel";
Glow.updateViewUI();
if (Glow.state.activeChannel) {
await Glow.loadChannelMessages?.();
Glow.pollTyping?.();
Glow.pingPresence?.();
Glow.refreshPresence?.();
} else {
Glow.renderEmptyState?.("No channels are available in this team.");
}
} catch (error) {
Glow.showToast(error.message || "Could not load channels for this team.", "error");
}
Glow.schedulePoll?.();
});
Glow.els.roomSelect.addEventListener("change", () => {
window.setTimeout(() => {
const selected = Glow.state.channels.find(
channel => channel.slug === Glow.els.roomSelect.value
);
if (!selected) return;
Glow.state.activeDm = null;
Glow.state.activeChannel = selected;
Glow.state.activeRoom = selected;
if (
Glow.state.activeView === "room" ||
Glow.state.activeView === "channel"
) {
Glow.state.activeView = "channel";
}
Glow.updateViewUI();
Glow.pingPresence?.();
Glow.refreshPresence?.();
}, 0);
});
console.info("Team/channel adapter enabled.");
};
Glow.installTeamAdapter();
if (!Glow.teamAdapter.chatBarVisibilityInstalled) {
Glow.teamAdapter.chatBarVisibilityInstalled = true;
const previousUpdateViewUI = Glow.updateViewUI;
Glow.updateViewUI = () => {
previousUpdateViewUI?.();
Glow.applyComposerVisibility();
};
}
Glow.applyComposerVisibility();

451
static/js/08-settings.js Normal file
View File

@ -0,0 +1,451 @@
// static/js/08-settings.js
"use strict";
window.Glow = window.Glow || {};
Glow.state = Glow.state || {};
Glow.state.settings = Glow.state.settings || null;
Glow.applyThemeMode = mode => {
const normalized = mode === "light" ? "light" : "dark";
document.body.classList.toggle("theme-light", normalized === "light");
localStorage.setItem("glow_theme_mode", normalized);
};
Glow.applyCustomCss = cssText => {
let style = document.getElementById("glowCustomCss");
if (!style) {
style = document.createElement("style");
style.id = "glowCustomCss";
document.head.append(style);
}
style.textContent = typeof cssText === "string" ? cssText : "";
};
Glow.loadMySettings = async () => {
try {
const data = await Glow.apiGetMySettings();
Glow.state.settings = data.settings || {};
const accent = Glow.state.settings.accent_color || Glow.state.accentColor;
Glow.applyAccentColor(accent);
const mode =
Glow.state.settings.theme_mode ||
localStorage.getItem("glow_theme_mode") ||
"dark";
Glow.applyThemeMode(mode);
Glow.applyCustomCss(Glow.state.settings.custom_css || "");
} catch (_) {
const mode = localStorage.getItem("glow_theme_mode") || "dark";
Glow.applyThemeMode(mode);
}
};
Glow.renderHeaderMenu = () => {
const list = Glow.els.headerMenuList;
if (!list) return;
list.replaceChildren();
const addItem = ({ label, hint = "", onClick, hidden = false }) => {
if (hidden) return;
const button = document.createElement("button");
button.className = "header-menu-item";
button.type = "button";
const left = document.createElement("span");
left.textContent = label;
const right = document.createElement("small");
right.textContent = hint;
button.append(left, right);
button.addEventListener("click", () => {
Glow.closeHeaderMenu?.();
onClick?.();
});
list.append(button);
};
addItem({
label: "My profile",
hint: Glow.state.currentUser ? `@${Glow.state.currentUser.username}` : "",
onClick: () => Glow.openMyProfile?.(),
hidden: !Glow.state.currentUser
});
addItem({
label: "Settings",
hint: "Theme, CSS, profile",
onClick: () => Glow.openSettings?.()
});
addItem({
label: "Direct messages",
hint: Glow.state.currentUser ? "Private chat" : "",
onClick: async () => {
Glow.state.activeView = "dms";
Glow.updateViewUI();
await Glow.loadDms?.();
Glow.schedulePoll?.();
},
hidden: !Glow.state.currentUser
});
addItem({
label: "Create team",
hint: "Up to 3 max",
onClick: () => Glow.openCreateTeamDialog?.(),
hidden: !Glow.state.currentUser
});
addItem({
label: "Edit current team",
hint: Glow.state.activeTeam?.can_manage ? "Manage" : "",
onClick: () => Glow.openEditTeamDialog?.(Glow.state.activeTeam),
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
});
addItem({
label: "Add channel",
hint: Glow.state.activeTeam?.name || "",
onClick: () => Glow.openCreateChannelDialog?.(Glow.state.activeTeam),
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
});
addItem({
label: "Edit current channel",
hint: Glow.state.activeChannel?.name || Glow.state.activeRoom?.name || "",
onClick: () => Glow.openEditChannelDialog?.(
Glow.state.activeTeam,
Glow.state.activeChannel || Glow.state.activeRoom
),
hidden:
!Glow.state.activeTeam ||
!Glow.state.activeTeam.can_manage ||
!(Glow.state.activeChannel || Glow.state.activeRoom) ||
Glow.state.activeView === "dm"
});
addItem({
label: "Manage team members",
hint: Glow.state.activeTeam?.can_manage ? "Members" : "",
onClick: () => Glow.manageTeamMembers?.(Glow.state.activeTeam),
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
});
addItem({
label: "Create team invite",
hint: Glow.state.activeTeam?.can_manage ? "Share" : "",
onClick: () => Glow.createTeamInviteLink?.(Glow.state.activeTeam),
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
});
addItem({
label: "Delete current channel",
hint: Glow.state.activeChannel?.name || "",
onClick: () => Glow.deleteCurrentChannel?.(Glow.state.activeTeam),
hidden:
!Glow.state.activeTeam ||
!Glow.state.activeTeam.can_manage ||
!Glow.state.activeChannel ||
Boolean(Glow.state.activeChannel?.legacy_room_id)
});
};
Glow.openHeaderMenu = () => {
Glow.renderHeaderMenu();
Glow.els.headerMenuOverlay?.classList.remove("hidden");
};
Glow.closeHeaderMenu = () => {
Glow.els.headerMenuOverlay?.classList.add("hidden");
};
Glow.toggleHeaderMenu = () => {
if (Glow.els.headerMenuOverlay?.classList.contains("hidden")) {
Glow.openHeaderMenu();
} else {
Glow.closeHeaderMenu();
}
};
Glow.openSettings = async () => {
Glow.state.activeView = "settings";
Glow.updateViewUI();
if (!Glow.state.settings && Glow.state.currentUser) {
await Glow.loadMySettings();
}
Glow.renderSettingsView();
};
Glow.renderNameColorPreview = value => {
const wrap = document.createElement("div");
wrap.className = "settings-row";
const label = document.createElement("div");
label.className = "header-subtitle";
label.textContent = "Preview";
const preview = document.createElement("div");
preview.className = "name-color-preview";
preview.textContent =
Glow.state.currentUser?.username || "username";
preview.style.color =
Glow.normalizeNameColor(value) ||
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
wrap.append(label, preview);
return wrap;
};
Glow.renderSettingsView = () => {
const box = Glow.els.profileBox;
box.replaceChildren();
const card = document.createElement("div");
card.className = "settings-card";
const appearance = document.createElement("section");
appearance.className = "settings-section";
const aTitle = document.createElement("h2");
aTitle.textContent = "Appearance";
const aText = document.createElement("p");
aText.textContent = "Set your theme mode, accent color, username color, and custom CSS.";
const themeRow = document.createElement("div");
themeRow.className = "settings-theme-row";
const currentTheme =
Glow.state.settings?.theme_mode ||
localStorage.getItem("glow_theme_mode") ||
"dark";
const darkButton = document.createElement("button");
darkButton.className = `theme-pill${currentTheme !== "light" ? " active" : ""}`;
darkButton.type = "button";
darkButton.textContent = "Dark";
const lightButton = document.createElement("button");
lightButton.className = `theme-pill${currentTheme === "light" ? " active" : ""}`;
lightButton.type = "button";
lightButton.textContent = "Light";
darkButton.addEventListener("click", () => {
Glow.applyThemeMode("dark");
Glow.state.settings ||= {};
Glow.state.settings.theme_mode = "dark";
Glow.renderSettingsView();
});
lightButton.addEventListener("click", () => {
Glow.applyThemeMode("light");
Glow.state.settings ||= {};
Glow.state.settings.theme_mode = "light";
Glow.renderSettingsView();
});
themeRow.append(darkButton, lightButton);
const accentWrap = document.createElement("div");
accentWrap.className = "settings-row";
const accentLabel = document.createElement("label");
accentLabel.className = "dialog-label";
accentLabel.textContent = "Accent color";
const accentInput = document.createElement("input");
accentInput.type = "color";
accentInput.className = "accent-color-input";
accentInput.value =
Glow.normalizeAccentColor(
Glow.state.settings?.accent_color || Glow.state.accentColor
) || Glow.DEFAULT_ACCENT_COLOR;
accentInput.addEventListener("input", () => {
Glow.applyAccentColor(accentInput.value);
Glow.state.settings ||= {};
Glow.state.settings.accent_color = accentInput.value;
});
accentLabel.append(accentInput);
accentWrap.append(accentLabel);
const nameColorWrap = document.createElement("div");
nameColorWrap.className = "settings-row";
const nameColorLabel = document.createElement("label");
nameColorLabel.className = "dialog-label";
nameColorLabel.textContent = "Username color";
const nameColorInput = document.createElement("input");
nameColorInput.type = "color";
nameColorInput.className = "accent-color-input";
nameColorInput.value =
Glow.normalizeNameColor(Glow.state.settings?.name_color) ||
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
const nameColorText = document.createElement("input");
nameColorText.className = "field accent-hex-input";
nameColorText.type = "text";
nameColorText.maxLength = 7;
nameColorText.value =
Glow.normalizeNameColor(Glow.state.settings?.name_color) ||
"";
nameColorText.placeholder = "#7dd3fc";
const nameRow = document.createElement("div");
nameRow.className = "accent-picker-row";
const previewSwatch = document.createElement("span");
previewSwatch.className = "accent-preview";
previewSwatch.style.background =
Glow.normalizeNameColor(Glow.state.settings?.name_color) ||
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
const help = document.createElement("div");
help.className = "header-subtitle";
help.textContent =
`Leave empty to use your role default (${Glow.roleDefaultNameColor(Glow.state.currentUser?.role)}).`;
const updatePreview = value => {
const normalized = Glow.normalizeNameColor(value);
const finalColor =
normalized || Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
previewSwatch.style.background = finalColor;
return finalColor;
};
nameColorInput.addEventListener("input", () => {
nameColorText.value = nameColorInput.value;
Glow.state.settings ||= {};
Glow.state.settings.name_color = nameColorInput.value;
updatePreview(nameColorInput.value);
Glow.renderSettingsView();
});
nameColorText.addEventListener("input", () => {
const normalized = Glow.normalizeNameColor(nameColorText.value.trim());
Glow.state.settings ||= {};
Glow.state.settings.name_color = normalized || null;
updatePreview(nameColorText.value.trim());
});
const resetNameColor = document.createElement("button");
resetNameColor.className = "button secondary";
resetNameColor.type = "button";
resetNameColor.textContent = "Use role default";
resetNameColor.addEventListener("click", () => {
Glow.state.settings ||= {};
Glow.state.settings.name_color = null;
Glow.renderSettingsView();
});
nameRow.append(nameColorInput, nameColorText, previewSwatch);
nameColorLabel.append(nameRow);
nameColorWrap.append(nameColorLabel, help, Glow.renderNameColorPreview(
Glow.state.settings?.name_color
), resetNameColor);
appearance.append(aTitle, aText, themeRow, accentWrap, nameColorWrap);
const cssSection = document.createElement("section");
cssSection.className = "settings-section";
const cTitle = document.createElement("h2");
cTitle.textContent = "Custom CSS";
const cText = document.createElement("p");
cText.textContent =
"Optional CSS applied only for your account in this browser session and saved to your settings. \n ex: *,html,body{font-size:95%;text-transform:lowercase;}";
const cssInput = document.createElement("textarea");
cssInput.className = "field settings-custom-css";
cssInput.placeholder = "/* Custom CSS */";
cssInput.value = Glow.state.settings?.custom_css || "";
cssInput.addEventListener("input", () => {
Glow.applyCustomCss(cssInput.value);
Glow.state.settings ||= {};
Glow.state.settings.custom_css = cssInput.value;
});
cssSection.append(cTitle, cText, cssInput);
const accountSection = document.createElement("section");
accountSection.className = "settings-section";
const accountTitle = document.createElement("h2");
accountTitle.textContent = "Account";
const accountText = document.createElement("p");
accountText.textContent = "Update your public profile and application preferences.";
const actions = document.createElement("div");
actions.className = "settings-actions";
const editProfile = document.createElement("button");
editProfile.className = "button";
editProfile.type = "button";
editProfile.textContent = "Edit profile";
editProfile.addEventListener("click", () => {
Glow.openMyProfile?.(true);
});
const saveSettings = document.createElement("button");
saveSettings.className = "button";
saveSettings.type = "button";
saveSettings.textContent = "Save settings";
saveSettings.addEventListener("click", async () => {
try {
const payload = {
accent_color:
Glow.normalizeAccentColor(
Glow.state.settings?.accent_color || Glow.state.accentColor
) || Glow.DEFAULT_ACCENT_COLOR,
name_color:
Glow.normalizeNameColor(Glow.state.settings?.name_color) || null,
custom_css: Glow.state.settings?.custom_css || "",
theme_mode:
Glow.state.settings?.theme_mode ||
localStorage.getItem("glow_theme_mode") ||
"dark"
};
const data = await Glow.apiUpdateMySettings(payload);
Glow.state.settings = data.settings || payload;
Glow.showToast("Settings saved.");
Glow.renderSettingsView();
} catch (error) {
Glow.showToast(error.message || "Could not save settings.", "error");
}
});
const resetAccent = document.createElement("button");
resetAccent.className = "button secondary";
resetAccent.type = "button";
resetAccent.textContent = "Reset accent";
resetAccent.addEventListener("click", () => {
Glow.resetAccentColor();
Glow.state.settings ||= {};
Glow.state.settings.accent_color = Glow.DEFAULT_ACCENT_COLOR;
Glow.renderSettingsView();
});
actions.append(editProfile, saveSettings, resetAccent);
accountSection.append(accountTitle, accountText, actions);
card.append(appearance, cssSection, accountSection);
box.append(card);
};

349
static/js/09-profile.js Normal file
View File

@ -0,0 +1,349 @@
// static/js/09-profile.js
"use strict";
window.Glow = window.Glow || {};
Glow.escapeHtml = value =>
String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
Glow.renderProfileBody = profile => {
const wrap = document.createElement("div");
wrap.className = "profile-rendered";
const content = String(profile?.bio_html || "").trim();
if (!content) {
const empty = document.createElement("div");
empty.className = "profile-empty";
empty.textContent = "No profile bio yet.";
return empty;
}
wrap.innerHTML = content;
return wrap;
};
Glow.openProfile = async username => {
Glow.state.activeView = "profile";
Glow.state.activeProfile = null;
Glow.updateViewUI();
const box = Glow.els.profileBox;
box.setAttribute("aria-busy", "true");
box.replaceChildren();
try {
const data = await Glow.apiGetPublicProfile(username);
Glow.state.activeProfile = data.profile;
Glow.renderProfileView(false);
} catch (error) {
box.replaceChildren();
const state = document.createElement("div");
state.className = "empty-state";
const card = document.createElement("div");
card.className = "empty-card";
card.textContent = error.message || "Could not load profile.";
state.append(card);
box.append(state);
Glow.showToast(error.message || "Could not load profile.", "error");
} finally {
box.setAttribute("aria-busy", "false");
}
};
Glow.openMyProfile = async (edit = false) => {
if (!Glow.state.currentUser) {
Glow.showToast("Sign in to view your profile.", "error");
return;
}
Glow.state.activeView = "profile";
Glow.state.activeProfile = null;
Glow.updateViewUI();
const box = Glow.els.profileBox;
box.setAttribute("aria-busy", "true");
box.replaceChildren();
try {
const data = await Glow.apiGetMyProfile();
Glow.state.activeProfile = data.profile;
Glow.renderProfileView(edit);
} catch (error) {
Glow.showToast(error.message || "Could not load your profile.", "error");
} finally {
box.setAttribute("aria-busy", "false");
}
};
Glow.renderProfileView = (editing = false) => {
const box = Glow.els.profileBox;
const profile = Glow.state.activeProfile;
box.replaceChildren();
if (!profile) {
const state = document.createElement("div");
state.className = "empty-state";
const card = document.createElement("div");
card.className = "empty-card";
card.textContent = "Profile not found.";
state.append(card);
box.append(state);
return;
}
const card = document.createElement("div");
card.className = "profile-card";
const banner = document.createElement("div");
banner.className = "profile-banner";
const content = document.createElement("div");
content.className = "profile-content";
const header = document.createElement("div");
header.className = "profile-header";
const avatar = document.createElement("div");
avatar.className = "profile-avatar";
avatar.textContent = (profile.display_name || profile.username || "?")
.slice(0, 1)
.toUpperCase();
const actions = document.createElement("div");
actions.className = "profile-actions";
if (profile.is_self) {
const editButton = document.createElement("button");
editButton.className = "button";
editButton.type = "button";
editButton.textContent = editing ? "View profile" : "Edit profile";
editButton.addEventListener("click", () => {
Glow.renderProfileView(!editing);
});
const settingsButton = document.createElement("button");
settingsButton.className = "button secondary";
settingsButton.type = "button";
settingsButton.textContent = "Settings";
settingsButton.addEventListener("click", () => {
Glow.openSettings?.();
});
actions.append(editButton, settingsButton);
}
header.append(avatar, actions);
const name = document.createElement("div");
name.className = "profile-name";
name.textContent = profile.display_name || profile.username;
name.style.color = Glow.resolveUserNameColor({
role: profile.role,
name_color: profile.name_color
});
const handle = document.createElement("div");
handle.className = "profile-handle";
handle.textContent = `@${profile.username}`;
handle.style.color = Glow.resolveUserNameColor({
role: profile.role,
name_color: profile.name_color
});
const badges = document.createElement("div");
badges.className = "profile-badges";
const roleBadge = document.createElement("span");
roleBadge.className = "profile-badge";
roleBadge.textContent = profile.role;
const statusBadge = document.createElement("span");
statusBadge.className = "profile-badge";
statusBadge.textContent = profile.status;
badges.append(roleBadge, statusBadge);
const grid = document.createElement("div");
grid.className = "profile-grid";
if (editing && profile.is_self) {
grid.append(Glow.buildProfileEditor(profile));
} else {
const bioSection = document.createElement("section");
bioSection.className = "profile-section";
const bioTitle = document.createElement("h3");
bioTitle.textContent = "Bio";
bioSection.append(bioTitle, Glow.renderProfileBody(profile));
const metaSection = document.createElement("section");
metaSection.className = "profile-section";
const metaTitle = document.createElement("h3");
metaTitle.textContent = "Profile";
const metaList = document.createElement("dl");
metaList.className = "profile-meta-list";
const addMeta = (label, value) => {
const dt = document.createElement("dt");
dt.textContent = label;
const dd = document.createElement("dd");
dd.textContent = value || "—";
metaList.append(dt, dd);
};
addMeta("Display name", profile.display_name || profile.username);
addMeta("Website", profile.website || "");
addMeta("Location", profile.location || "");
addMeta(
"Joined",
profile.created_at ? new Date(profile.created_at).toLocaleDateString() : ""
);
addMeta("Bio mode", profile.bio_mode || "txt");
addMeta("Name color", profile.name_color || "role default");
metaSection.append(metaTitle, metaList);
grid.append(bioSection, metaSection);
}
content.append(header, name, handle, badges, grid);
card.append(banner, content);
box.append(card);
};
Glow.buildProfileEditor = profile => {
const section = document.createElement("section");
section.className = "profile-section";
const title = document.createElement("h3");
title.textContent = "Edit profile";
const editor = document.createElement("div");
editor.className = "profile-editor";
const displayName = document.createElement("input");
displayName.className = "field";
displayName.type = "text";
displayName.maxLength = 80;
displayName.placeholder = "Display name";
displayName.value = profile.display_name || "";
const website = document.createElement("input");
website.className = "field";
website.type = "url";
website.maxLength = 300;
website.placeholder = "Website URL";
website.value = profile.website || "";
const location = document.createElement("input");
location.className = "field";
location.type = "text";
location.maxLength = 120;
location.placeholder = "Location";
location.value = profile.location || "";
const modeRow = document.createElement("div");
modeRow.className = "profile-mode-row";
let selectedMode = profile.bio_mode || "txt";
const body = document.createElement("textarea");
body.className = "field";
body.maxLength = 5000;
body.placeholder = "Tell people about yourself";
body.value = profile.bio || "";
for (const mode of ["txt", "md", "html"]) {
const button = document.createElement("button");
button.className = `mode-pill${selectedMode === mode ? " active" : ""}`;
button.type = "button";
button.textContent = mode.toUpperCase();
button.addEventListener("click", () => {
selectedMode = mode;
for (const el of modeRow.querySelectorAll(".mode-pill")) {
el.classList.toggle("active", el.textContent.toLowerCase() === mode);
}
});
modeRow.append(button);
}
const help = document.createElement("p");
help.className = "header-subtitle";
help.textContent =
"HTML is sanitized on save/display. Scripts, event handlers, and unsafe URLs are removed.";
const actions = document.createElement("div");
actions.className = "settings-actions";
const save = document.createElement("button");
save.className = "button";
save.type = "button";
save.textContent = "Save profile";
save.addEventListener("click", async () => {
try {
const data = await Glow.apiUpdateMyProfile({
display_name: displayName.value,
website: website.value,
location: location.value,
bio: body.value,
bio_mode: selectedMode
});
Glow.state.activeProfile = {
...data.profile,
name_color: Glow.state.settings?.name_color || profile.name_color || null
};
Glow.showToast("Profile updated.");
Glow.renderProfileView(false);
} catch (error) {
Glow.showToast(error.message || "Could not update profile.", "error");
}
});
const preview = document.createElement("button");
preview.className = "button secondary";
preview.type = "button";
preview.textContent = "Preview";
preview.addEventListener("click", async () => {
try {
const data = await Glow.apiUpdateMyProfile({
display_name: displayName.value,
website: website.value,
location: location.value,
bio: body.value,
bio_mode: selectedMode
});
Glow.state.activeProfile = {
...data.profile,
name_color: Glow.state.settings?.name_color || profile.name_color || null
};
Glow.renderProfileView(false);
} catch (error) {
Glow.showToast(error.message || "Could not preview profile.", "error");
}
});
actions.append(save, preview);
editor.append(displayName, website, location, modeRow, help, body, actions);
section.append(title, editor);
return section;
};

166
static/js/10-reactions.js Normal file
View File

@ -0,0 +1,166 @@
// static/js/10-reactions.js
"use strict";
window.Glow = window.Glow || {};
Glow.reactions = Glow.reactions || {
enabled: true,
emojis: ["👍", "❤️", "🔥", "😂", "🎉", "👏", "😮", "😢", "👀", "✅", "🚀", "💡"],
activeMessage: null
};
Glow.submitCustomReaction = async () => {
const message = Glow.reactions.activeMessage;
const input = Glow.els.reactionCustomInput;
if (!message || !input) return;
const emoji = input.value.trim();
if (!emoji) return;
try {
const existing = (message.reactions || []).find(item => item.emoji === emoji);
if (existing?.reacted) {
await Glow.apiRemoveReaction(message.id, emoji);
} else {
await Glow.apiAddReaction(message.id, emoji);
}
const data = await Glow.apiGetMessageReactions(message.id);
message.reactions = data.reactions || [];
Glow.updateMessageReactionsDom?.(message.id, message.reactions);
Glow.renderReactionPopupUsers(message.reactions);
input.value = "";
if (Glow.state.activeView === "feed") {
Glow.loadFeed?.();
}
} catch (error) {
Glow.showToast(error.message || "Could not update reaction.", "error");
}
};
Glow.openReactionPopup = async message => {
Glow.reactions.activeMessage = message;
const grid = Glow.els.reactionPopupGrid;
const list = Glow.els.reactionPopupList;
const customInput = Glow.els.reactionCustomInput;
if (!grid || !list) return;
grid.replaceChildren();
list.replaceChildren();
for (const emoji of Glow.reactions.emojis) {
const button = document.createElement("button");
button.className = "reaction-popup-button";
button.type = "button";
button.textContent = emoji;
button.addEventListener("click", async () => {
try {
const existing = (message.reactions || []).find(item => item.emoji === emoji);
if (existing?.reacted) {
await Glow.apiRemoveReaction(message.id, emoji);
} else {
await Glow.apiAddReaction(message.id, emoji);
}
const data = await Glow.apiGetMessageReactions(message.id);
message.reactions = data.reactions || [];
Glow.updateMessageReactionsDom?.(message.id, message.reactions);
Glow.renderReactionPopupUsers(message.reactions);
if (Glow.state.activeView === "feed") {
Glow.loadFeed?.();
}
} catch (error) {
Glow.showToast(error.message || "Could not update reaction.", "error");
}
});
grid.append(button);
}
try {
const data = await Glow.apiGetMessageReactions(message.id);
message.reactions = data.reactions || [];
} catch (_) {
message.reactions = [];
}
if (customInput) {
customInput.value = "";
}
Glow.renderReactionPopupUsers(message.reactions || []);
Glow.els.reactionPopupOverlay?.classList.remove("hidden");
setTimeout(() => {
Glow.els.reactionCustomInput?.focus();
}, 0);
};
Glow.renderReactionPopupUsers = reactions => {
const list = Glow.els.reactionPopupList;
if (!list) return;
list.replaceChildren();
if (!reactions.length) {
const empty = document.createElement("div");
empty.className = "reaction-popup-user";
empty.textContent = "No reactions yet.";
list.append(empty);
return;
}
for (const reaction of reactions) {
const section = document.createElement("div");
section.className = "reaction-popup-section";
const title = document.createElement("div");
title.className = "reaction-popup-user";
title.innerHTML = `<strong>${reaction.emoji} ${reaction.count}</strong>`;
section.append(title);
for (const user of reaction.users || []) {
const row = document.createElement("div");
row.className = "reaction-popup-user";
const left = document.createElement("span");
left.textContent = user.username;
const right = document.createElement("span");
right.textContent = new Date(user.created_at).toLocaleString();
row.append(left, right);
section.append(row);
}
list.append(section);
}
};
Glow.closeReactionPopup = () => {
Glow.reactions.activeMessage = null;
if (Glow.els.reactionCustomInput) {
Glow.els.reactionCustomInput.value = "";
}
Glow.els.reactionPopupOverlay?.classList.add("hidden");
};
Glow.els.reactionCustomButton?.addEventListener("click", () => {
Glow.submitCustomReaction?.();
});
Glow.els.reactionCustomInput?.addEventListener("keydown", event => {
if (event.key === "Enter") {
event.preventDefault();
Glow.submitCustomReaction?.();
}
});

65
static/js/11-media.js Normal file
View File

@ -0,0 +1,65 @@
// static/js/11-media.js
"use strict";
window.Glow = window.Glow || {};
Glow.resetMediaModal = () => {
if (!Glow.els.mediaOverlay) return;
Glow.els.mediaImage.classList.add("hidden");
Glow.els.mediaAudio.classList.add("hidden");
Glow.els.mediaVideo.classList.add("hidden");
Glow.els.mediaFileFallback.classList.add("hidden");
Glow.els.mediaImage.removeAttribute("src");
Glow.els.mediaImage.alt = "";
Glow.els.mediaAudio.pause();
Glow.els.mediaAudio.removeAttribute("src");
Glow.els.mediaAudio.load();
Glow.els.mediaVideo.pause();
Glow.els.mediaVideo.removeAttribute("src");
Glow.els.mediaVideo.load();
Glow.els.mediaTitle.textContent = "Media";
Glow.els.mediaDownloadLink.href = "#";
Glow.els.mediaDownloadLink.removeAttribute("download");
Glow.els.mediaFallbackLink.href = "#";
};
Glow.openMediaModal = ({ url, name = "Media", mimeType = "" }) => {
if (!url || !Glow.els.mediaOverlay) return;
Glow.resetMediaModal();
const mime = String(mimeType || "").toLowerCase();
Glow.els.mediaTitle.textContent = name;
Glow.els.mediaDownloadLink.href = url;
Glow.els.mediaDownloadLink.download = name;
Glow.els.mediaFallbackLink.href = url;
if (mime.startsWith("image/")) {
Glow.els.mediaImage.src = url;
Glow.els.mediaImage.alt = name;
Glow.els.mediaImage.classList.remove("hidden");
} else if (mime.startsWith("audio/")) {
Glow.els.mediaAudio.src = url;
Glow.els.mediaAudio.classList.remove("hidden");
} else if (mime.startsWith("video/")) {
Glow.els.mediaVideo.src = url;
Glow.els.mediaVideo.classList.remove("hidden");
} else {
Glow.els.mediaFileFallback.classList.remove("hidden");
}
Glow.els.mediaOverlay.classList.remove("hidden");
};
Glow.closeMediaModal = () => {
if (!Glow.els.mediaOverlay) return;
Glow.els.mediaOverlay.classList.add("hidden");
Glow.resetMediaModal();
};

389
static/js/12-calls.js Normal file
View File

@ -0,0 +1,389 @@
// static/js/12-calls.js
"use strict";
window.Glow = window.Glow || {};
Glow.calls = Glow.calls || {};
Glow.calls.iceServers = [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun1.l.google.com:19302" }
];
Glow.getActiveDmPeer = () => {
if (!Glow.state.activeDm || !Glow.state.currentUser) return null;
return (Glow.state.activeDm.members || []).find(
member => Number(member.id) !== Number(Glow.state.currentUser.id)
) || null;
};
Glow.renderCallUi = () => {
const call = Glow.state.calls.activeCall;
const hasCall = Boolean(call);
const incoming = Glow.state.calls.incomingCall;
const peer = Glow.getActiveDmPeer();
if (!Glow.els.callStatus) return;
Glow.els.callPeerLabel.textContent =
peer?.display_name || peer?.username || "No active call";
Glow.els.callStatus.textContent = incoming
? "Incoming call"
: hasCall
? call.active
? "In call"
: "Ended"
: "Idle";
Glow.els.startCallButton?.classList.toggle(
"hidden",
!Glow.state.activeDm || hasCall || Boolean(incoming)
);
Glow.els.answerCallButton?.classList.toggle("hidden", !incoming);
Glow.els.muteCallButton?.classList.toggle("hidden", !hasCall);
Glow.els.hangupCallButton?.classList.toggle("hidden", !hasCall && !incoming);
Glow.els.muteCallButton.textContent =
Glow.state.calls.muted ? "Unmute" : "Mute";
};
Glow.openCallPanel = () => {
Glow.els.callOverlay?.classList.remove("hidden");
Glow.renderCallUi();
};
Glow.endLocalCallUi = async () => {
try {
if (Glow.state.calls.peerConnection) {
Glow.state.calls.peerConnection.onicecandidate = null;
Glow.state.calls.peerConnection.ontrack = null;
Glow.state.calls.peerConnection.close();
}
} catch (_) {}
try {
if (Glow.state.calls.localStream) {
for (const track of Glow.state.calls.localStream.getTracks()) {
track.stop();
}
}
} catch (_) {}
Glow.state.calls.peerConnection = null;
Glow.state.calls.localStream = null;
Glow.state.calls.activeCall = null;
Glow.state.calls.incomingCall = null;
Glow.state.calls.pendingOffer = null;
Glow.state.calls.signalLastSeenId = 0;
Glow.state.calls.muted = false;
clearTimeout(Glow.state.calls.pollTimer);
if (Glow.els.remoteAudio) {
Glow.els.remoteAudio.srcObject = null;
}
Glow.renderCallUi();
};
Glow.ensurePeerConnection = async () => {
if (Glow.state.calls.peerConnection) {
return Glow.state.calls.peerConnection;
}
const pc = new RTCPeerConnection({
iceServers: Glow.calls.iceServers
});
pc.onicecandidate = async event => {
if (!event.candidate || !Glow.state.calls.activeCall) return;
try {
await Glow.apiPostCallSignal(Glow.state.calls.activeCall.id, {
signal_type: "ice",
payload: { candidate: event.candidate }
});
} catch (_) {}
};
pc.ontrack = event => {
const stream = event.streams?.[0];
if (stream && Glow.els.remoteAudio) {
Glow.els.remoteAudio.srcObject = stream;
}
};
Glow.state.calls.peerConnection = pc;
return pc;
};
Glow.ensureLocalAudio = async () => {
if (Glow.state.calls.localStream) {
return Glow.state.calls.localStream;
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false
});
Glow.state.calls.localStream = stream;
return stream;
};
Glow.attachLocalTracks = async () => {
const pc = await Glow.ensurePeerConnection();
const stream = await Glow.ensureLocalAudio();
const existingSenders = pc.getSenders().map(sender => sender.track).filter(Boolean);
for (const track of stream.getTracks()) {
if (!existingSenders.includes(track)) {
pc.addTrack(track, stream);
}
}
};
Glow.startCall = async () => {
if (!Glow.state.activeDm) {
Glow.showToast("Open a DM to start a call.", "error");
return;
}
if (!navigator.mediaDevices?.getUserMedia || !window.RTCPeerConnection) {
Glow.showToast("Voice calls are not supported in this browser.", "error");
return;
}
try {
const data = await Glow.apiCreateCall(Glow.state.activeDm.id);
Glow.state.calls.activeCall = data.call;
Glow.state.calls.incomingCall = null;
Glow.state.calls.pendingOffer = null;
Glow.state.calls.signalLastSeenId = 0;
Glow.openCallPanel();
await Glow.attachLocalTracks();
const pc = await Glow.ensurePeerConnection();
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await Glow.apiPostCallSignal(data.call.id, {
signal_type: "offer",
payload: { sdp: offer }
});
await Glow.apiPostCallSignal(data.call.id, {
signal_type: "ring",
payload: {}
});
Glow.renderCallUi();
Glow.scheduleCallPolling();
} catch (error) {
Glow.showToast(error.message || "Could not start call.", "error");
await Glow.endLocalCallUi();
}
};
Glow.answerIncomingCall = async () => {
const incoming = Glow.state.calls.incomingCall;
const pendingOffer = Glow.state.calls.pendingOffer;
if (!incoming || !pendingOffer) return;
try {
Glow.state.calls.activeCall = incoming;
Glow.state.calls.incomingCall = null;
Glow.openCallPanel();
await Glow.attachLocalTracks();
const pc = await Glow.ensurePeerConnection();
await pc.setRemoteDescription(new RTCSessionDescription(pendingOffer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await Glow.apiPostCallSignal(incoming.id, {
signal_type: "answer",
payload: { sdp: answer }
});
Glow.state.calls.pendingOffer = null;
Glow.renderCallUi();
Glow.scheduleCallPolling();
} catch (error) {
Glow.showToast(error.message || "Could not answer call.", "error");
await Glow.endLocalCallUi();
}
};
Glow.handleCallSignal = async signal => {
const activeCall = Glow.state.calls.activeCall;
const incomingCall = Glow.state.calls.incomingCall;
if (signal.signal_type === "ring") {
if (!activeCall && !incomingCall) {
try {
const data = await Glow.apiGetCall(signal.call_session_id);
Glow.state.calls.incomingCall = data.call;
Glow.openCallPanel();
Glow.renderCallUi();
} catch (_) {}
}
return;
}
if (signal.signal_type === "offer") {
try {
const data = await Glow.apiGetCall(signal.call_session_id);
Glow.state.calls.incomingCall = data.call;
Glow.state.calls.pendingOffer = signal.payload?.sdp || signal.payload;
Glow.openCallPanel();
Glow.renderCallUi();
} catch (_) {}
return;
}
if (!Glow.state.calls.activeCall || Number(Glow.state.calls.activeCall.id) !== Number(signal.call_session_id)) {
return;
}
const pc = await Glow.ensurePeerConnection();
if (signal.signal_type === "answer") {
const sdp = signal.payload?.sdp || signal.payload;
if (sdp) {
await pc.setRemoteDescription(new RTCSessionDescription(sdp));
}
return;
}
if (signal.signal_type === "ice") {
const candidate = signal.payload?.candidate || signal.payload;
if (candidate) {
try {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
} catch (_) {}
}
return;
}
if (signal.signal_type === "hangup") {
Glow.showToast("Call ended.");
await Glow.endLocalCallUi();
return;
}
};
Glow.pollCallSignals = async () => {
const activeCall = Glow.state.calls.activeCall;
const incomingCall = Glow.state.calls.incomingCall;
const call = activeCall || incomingCall;
if (!call || document.hidden) return;
try {
const data = await Glow.apiGetCallSignals(
call.id,
Glow.state.calls.signalLastSeenId || 0
);
const signals = data.signals || [];
for (const signal of signals) {
Glow.state.calls.signalLastSeenId = Math.max(
Glow.state.calls.signalLastSeenId || 0,
Number(signal.id) || 0
);
if (
Glow.state.currentUser &&
Number(signal.user_id) === Number(Glow.state.currentUser.id)
) {
continue;
}
await Glow.handleCallSignal(signal);
}
} catch (_) {}
};
Glow.scheduleCallPolling = () => {
clearTimeout(Glow.state.calls.pollTimer);
if (!Glow.state.calls.activeCall && !Glow.state.calls.incomingCall) {
return;
}
Glow.state.calls.pollTimer = setTimeout(async () => {
await Glow.pollCallSignals();
Glow.scheduleCallPolling();
}, document.hidden ? 5000 : 2000);
};
Glow.toggleMuteCall = () => {
const stream = Glow.state.calls.localStream;
if (!stream) return;
Glow.state.calls.muted = !Glow.state.calls.muted;
for (const track of stream.getAudioTracks()) {
track.enabled = !Glow.state.calls.muted;
}
Glow.renderCallUi();
};
Glow.hangupCall = async () => {
const call = Glow.state.calls.activeCall || Glow.state.calls.incomingCall;
if (!call) return;
try {
await Glow.apiEndCall(call.id);
} catch (_) {
// ignore
}
Glow.showToast("Call ended.");
await Glow.endLocalCallUi();
};
Glow.syncActiveCallForDm = async () => {
if (!Glow.state.activeDm) return;
try {
const createResult = await Glow.apiCreateCall(Glow.state.activeDm.id);
const call = createResult.call;
if (call?.active) {
Glow.state.calls.activeCall = call;
Glow.renderCallUi();
Glow.scheduleCallPolling();
}
} catch (_) {
// ignore if no active call semantics vary
}
};
Glow.els.startCallButton?.addEventListener("click", () => {
Glow.startCall?.();
});
Glow.els.answerCallButton?.addEventListener("click", () => {
Glow.answerIncomingCall?.();
});
Glow.els.muteCallButton?.addEventListener("click", () => {
Glow.toggleMuteCall?.();
});
Glow.els.hangupCallButton?.addEventListener("click", () => {
Glow.hangupCall?.();
});

2582
static/style.css Normal file

File diff suppressed because it is too large Load Diff

539
templates/index.html Normal file
View File

@ -0,0 +1,539 @@
<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta
name="viewport"
content="width=device-width,initial-scale=1,viewport-fit=cover"
>
<meta name="theme-color" content="#0f1012">
<meta name="color-scheme" content="dark">
<meta name="referrer" content="same-origin">
<title>pre.v1</title>
<link
rel="stylesheet"
href="{{ url_for('static', filename='style.css', v=asset_version) }}"
>
</head>
<body>
<div class="app-shell">
<header class="chat-header">
<div class="header-inner">
<div class="header-brand">
<span
id="presenceDot"
class="header-dot"
aria-hidden="true"
></span>
<div class="header-room-copy">
<div id="headerTitle" class="header-title">Feed</div>
<div id="headerSubtitle" class="header-subtitle">Recent activity</div>
</div>
</div>
<div class="header-navigation">
<button
id="feedButton"
class="header-action feed-button active"
type="button"
aria-label="Open activity feed"
aria-pressed="true"
>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>
</svg>
</button>
<button
id="pagesButton"
class="header-action pages-button"
type="button"
aria-label="Open team pages"
aria-pressed="false"
>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
<g fill="none">
<path d="M3.5 2.5h17v19h-17z"/>
<path stroke="currentColor" stroke-linecap="square" stroke-width="2" d="M3.5 2.5h17v19h-17zM8 8h8m-8 4h8m-8 4h5"/>
</g>
</svg>
</button>
<button
id="dmsButton"
class="header-action"
type="button"
aria-label="Open direct messages"
aria-pressed="false"
title="Direct messages"
>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</button>
<button
id="backButton"
class="header-action hidden"
type="button"
aria-label="Go back"
>
Back
</button>
<select
id="teamSelect"
class="room-select team-select"
aria-label="Select team"
disabled
>
<option value="">Loading teams…</option>
</select>
<select
id="roomSelect"
class="room-select channel-select"
aria-label="Select channel"
disabled
>
<option value="">Loading channels…</option>
</select>
<div id="presenceLabel" class="presence-label hidden" aria-live="polite"></div>
<button
id="settingsButton"
class="header-action"
type="button"
aria-label="Open settings and profile menu"
title="Settings and profile"
>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3l2.3.8l2.4-.5l1.3 2.1l2.2 1l-.3 2.4l1.1 2.1l-1.6 1.8l-.3 2.4l-2.4.5L14 21l-2-.8l-2 .8l-1.4-2.1l-2.4-.5l-.3-2.4L4.3 14l1.1-2.1l-.3-2.4l2.2-1l1.3-2.1l2.4.5zM12 15.5A3.5 3.5 0 1 0 12 8.5a3.5 3.5 0 0 0 0 7z"/>
</svg>
</button>
</div>
<div class="header-account-wrap">
<div id="headerAccount" class="header-account">not signed in</div>
<button
id="logoutButton"
class="header-action hidden"
type="button"
aria-label="Sign out"
title="Sign out"
>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="square" stroke-width="2" d="M15.5 16.5L20 12l-4.5-4.5m3.25 4.5H9m0 8.5H4v-17h5"/>
</svg>
</button>
</div>
</div>
</header>
<main
id="feedBox"
class="feed-box"
aria-live="polite"
aria-busy="true"
aria-label="Activity feed"
>
<div class="empty-state">
<div class="empty-card">Loading activity…</div>
</div>
</main>
<main
id="dmBox"
class="dm-box hidden"
aria-live="polite"
aria-busy="false"
aria-label="Direct messages"
>
<div class="empty-state">
<div class="empty-card">Sign in to view direct messages.</div>
</div>
</main>
<main
id="chatBox"
class="chat-box hidden"
aria-live="off"
aria-busy="true"
aria-label="Channel messages"
></main>
<main
id="threadBox"
class="thread-box hidden"
aria-live="off"
aria-busy="false"
aria-label="Message thread"
></main>
<main
id="pagesBox"
class="pages-box hidden"
aria-live="polite"
aria-busy="false"
aria-label="Team pages"
></main>
<main
id="pageDetailBox"
class="page-detail-box hidden"
aria-live="polite"
aria-busy="false"
aria-label="Page details"
></main>
<main
id="profileBox"
class="profile-box hidden"
aria-live="polite"
aria-busy="false"
aria-label="Profile"
></main>
<div id="newMessagesBar" class="new-messages-bar hidden">
<button id="jumpToLatestButton" class="small-button" type="button">
New messages · Jump to latest
</button>
</div>
<div id="typingBar" class="typing-bar hidden" aria-live="polite"></div>
<div id="composerWrap" class="composer-wrap hidden">
<form id="chatForm" class="composer">
<label class="sr-only" for="messageInput">Message</label>
<div class="composer-field-wrap">
<textarea
id="messageInput"
class="composer-input"
rows="1"
maxlength="1000"
autocomplete="off"
placeholder="sign in to send"
aria-label="Message"
disabled
></textarea>
<div class="composer-meta">
<span id="attachmentName" class="attachment-name"></span>
<span id="messageCount" class="message-count">0 / 1000</span>
</div>
</div>
<label
id="attachmentButton"
class="attachment-button disabled"
for="attachmentInput"
title="Attach file(s)"
aria-label="Attach file(s)"
>
+
</label>
<input
id="attachmentInput"
class="sr-only"
type="file"
accept=".txt,.pdf,.png,.jpg,.jpeg,.gif,.webp"
multiple
disabled
>
<button id="sendButton" class="send-button" type="submit" disabled>
Send
</button>
</form>
</div>
<div id="threadComposerWrap" class="composer-wrap hidden">
<form id="threadForm" class="composer">
<label class="sr-only" for="threadInput">Reply in thread</label>
<div class="composer-field-wrap">
<textarea
id="threadInput"
class="composer-input"
rows="1"
maxlength="1000"
autocomplete="off"
placeholder="reply in thread"
aria-label="Reply in thread"
disabled
></textarea>
<div class="composer-meta">
<span id="threadAttachmentName" class="attachment-name"></span>
<span id="threadMessageCount" class="message-count">0 / 1000</span>
</div>
</div>
<label
id="threadAttachmentButton"
class="attachment-button disabled"
for="threadAttachmentInput"
title="Attach file(s)"
aria-label="Attach file(s)"
>
+
</label>
<input
id="threadAttachmentInput"
class="sr-only"
type="file"
accept=".txt,.pdf,.png,.jpg,.jpeg,.gif,.webp"
multiple
disabled
>
<button id="threadSendButton" class="send-button" type="submit" disabled>
Reply
</button>
</form>
</div>
</div>
<section
id="authOverlay"
class="auth-overlay"
role="dialog"
aria-modal="true"
aria-label="Account access"
>
<div class="auth-card">
<p></p>
<div id="authNotice" class="notice" role="status"></div>
<label class="sr-only" for="usernameInput">Username</label>
<input
id="usernameInput"
class="field"
type="text"
autocomplete="username"
autocapitalize="none"
spellcheck="false"
maxlength="30"
placeholder="username"
>
<label class="sr-only" for="passwordInput">Password</label>
<input
id="passwordInput"
class="field"
type="password"
autocomplete="current-password"
maxlength="256"
placeholder="password"
>
<div class="auth-actions">
<button id="loginButton" class="button" type="button">sign in</button>
<button id="registerButton" class="button secondary" type="button">create account</button>
</div>
<p class="auth-help"></p>
</div>
</section>
<section
id="blockedOverlay"
class="blocked-overlay hidden"
role="alertdialog"
aria-modal="true"
aria-label="Access blocked"
>
<div class="auth-card">
<h1>Access blocked</h1>
<p id="blockedMessage">Access to this chat service is unavailable.</p>
</div>
</section>
<section
id="headerMenuOverlay"
class="header-menu-overlay hidden"
role="dialog"
aria-modal="true"
aria-label="Header menu"
>
<div class="header-menu-card">
<div class="header-menu-title">Quick actions</div>
<div id="headerMenuList" class="header-menu-list"></div>
</div>
</section>
<section
id="reactionPopupOverlay"
class="reaction-popup-overlay hidden"
role="dialog"
aria-modal="true"
aria-label="Reactions"
>
<div class="reaction-popup-card">
<h2 class="reaction-popup-title">Reactions</h2>
<div id="reactionPopupGrid" class="reaction-popup-grid"></div>
<div class="reaction-popup-custom">
<input
id="reactionCustomInput"
class="field reaction-custom-input"
type="text"
maxlength="16"
placeholder="Custom emoji"
aria-label="Custom reaction"
>
<button
id="reactionCustomButton"
class="button secondary"
type="button"
>
Add
</button>
</div>
<div id="reactionPopupList" class="reaction-popup-list"></div>
<div class="dialog-actions">
<button id="closeReactionPopupButton" class="button secondary" type="button">Close</button>
</div>
</div>
</section>
<section
id="mediaOverlay"
class="media-overlay hidden"
role="dialog"
aria-modal="true"
aria-label="Media preview"
>
<div class="media-card">
<div class="media-header">
<div id="mediaTitle" class="media-title">Media</div>
<div class="settings-actions">
<a id="mediaDownloadLink" class="button secondary" href="#" download>Download</a>
<button id="closeMediaButton" class="button secondary" type="button">Close</button>
</div>
</div>
<div class="media-body">
<img id="mediaImage" class="hidden" alt="">
<audio id="mediaAudio" class="hidden" controls preload="metadata"></audio>
<video id="mediaVideo" class="hidden" controls preload="metadata"></video>
<div id="mediaFileFallback" class="media-file-fallback hidden">
<p>This file cannot be previewed here.</p>
<a id="mediaFallbackLink" class="button" href="#" target="_blank" rel="noopener noreferrer">Open file</a>
</div>
</div>
</div>
</section>
<section
id="callOverlay"
class="call-overlay hidden"
role="dialog"
aria-modal="true"
aria-label="Voice call"
>
<div class="call-card">
<div class="call-title-row">
<div>
<div class="call-title">Voice call</div>
<div id="callStatus" class="call-status">Idle</div>
</div>
<button id="closeCallPanelButton" class="button secondary" type="button">Hide</button>
</div>
<div id="callPeerLabel" class="call-peer-label">No active call</div>
<audio id="remoteAudio" autoplay playsinline></audio>
<div class="call-actions">
<button id="startCallButton" class="button" type="button">Start call</button>
<button id="answerCallButton" class="button hidden" type="button">Answer</button>
<button id="muteCallButton" class="button secondary hidden" type="button">Mute</button>
<button id="hangupCallButton" class="danger-button hidden" type="button">Hang up</button>
</div>
</div>
</section>
<button
id="adminFab"
class="admin-fab"
type="button"
aria-label="Open moderation controls"
title="Moderation controls"
>
#
</button>
<section
id="adminPanel"
class="admin-panel"
role="dialog"
aria-modal="true"
aria-label="Moderation panel"
aria-hidden="true"
>
<div class="admin-sheet">
<div class="admin-sheet-header">
<div>
<strong>Moderation</strong>
<div class="header-subtitle"></div>
</div>
<button id="closeAdminButton" class="button secondary" type="button">
Close
</button>
</div>
<div class="admin-actions">
<button id="createRoomButton" class="button" type="button">Add chat</button>
<button id="clearMessagesButton" class="danger-button" type="button">Clear selected room</button>
<button id="typingToggleButton" class="button secondary" type="button">Typing indicator: on</button>
<button id="notificationsToggleButton" class="button secondary" type="button">Notifications: off</button>
<button id="themeButton" class="button secondary" type="button" aria-label="Choose accent color">Accent color</button>
</div>
<div id="adminRooms" class="admin-rooms"></div>
<div id="adminUsers" class="admin-users"></div>
</div>
</section>
<section
id="dialogOverlay"
class="dialog-overlay hidden"
role="dialog"
aria-modal="true"
aria-labelledby="dialogTitle"
>
<div class="dialog-card">
<h2 id="dialogTitle">Confirm</h2>
<div id="dialogBody" class="dialog-body"></div>
<div id="dialogActions" class="dialog-actions"></div>
</div>
</section>
<div id="statusToast" class="status-toast" role="status" aria-live="polite"></div>
<script src="{{ url_for('static', filename='js/00-config.js', v=asset_version) }}" defer></script>
<script src="{{ url_for('static', filename='js/01-api.js', v=asset_version) }}" defer></script>
<script src="{{ url_for('static', filename='js/02-chat.js', v=asset_version) }}" defer></script>
<script src="{{ url_for('static', filename='js/03-admin.js', v=asset_version) }}" defer></script>
<script src="{{ url_for('static', filename='js/05-feed.js', v=asset_version) }}" defer></script>
<script src="{{ url_for('static', filename='js/06-pages.js', v=asset_version) }}" defer></script>
<script src="{{ url_for('static', filename='js/08-settings.js', v=asset_version) }}" defer></script>
<script src="{{ url_for('static', filename='js/09-profile.js', v=asset_version) }}" defer></script>
<script src="{{ url_for('static', filename='js/10-reactions.js', v=asset_version) }}" defer></script>
<script src="{{ url_for('static', filename='js/11-media.js', v=asset_version) }}" defer></script>
<script src="{{ url_for('static', filename='js/12-calls.js', v=asset_version) }}" defer></script>
<script src="{{ url_for('static', filename='js/04-main.js', v=asset_version) }}" defer></script>
<script src="{{ url_for('static', filename='js/07-team-channel-adapter.js', v=asset_version) }}" defer></script>
</body>
</html>