/**
* ATC Team Chat — identity gate, team room + DMs, file share, profile photos.
* Soft identity via localStorage.atc_actor (shared with Ops desk).
*/
(() => {
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const ACTOR_KEY = "atc_actor";
const USERS = [
{ id: "jody", name: "Jody van Dongen", short: "Jody", team: "admin", role: "Datacenter Engineer", focus: "Storage · servers · network · rack & stack · installs" },
{ id: "laurens", name: "Laurens Rammers", short: "Laurens", team: "admin", role: "Datacenter Engineer", focus: "Storage · servers · network · rack & stack · installs" },
{ id: "mo", name: "Mohamed El Kadi", short: "Mo", team: "fde", role: "Data FDE", focus: "\"Data Plumbers\" 😉 · OME Cockpit · OpenManage AI" },
{ id: "bart", name: "Bart Sjerps", short: "Bart", team: "fde", role: "Data FDE", focus: "\"Data Plumbers\" 😉 · FDE cluster · AI workloads" },
{ id: "guest", name: "Guest", short: "Guest", team: "guest", role: "Visitor", focus: "Temporary access — select yourself next time" },
];
const state = {
me: null,
rooms: [],
presence: [],
activeRoomId: null,
messages: [],
ws: null,
typing: null,
avatarBust: {},
unread: {},
notifyAsked: false,
origTitle: document.title,
};
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
function shortName(u) {
if (!u) return "?";
return u.short || (u.name || u.id || "?").split(" ")[0];
}
function avatarUrl(userId, fallbackUrl) {
if (!userId) return "";
const base = fallbackUrl || `/api/team/avatar/${userId}`;
const bust = state.avatarBust[userId];
if (!bust) return base;
return base.includes("?") ? `${base}&t=${bust}` : `${base}?t=${bust}`;
}
function userById(id) {
return USERS.find((u) => u.id === id) || state.presence.find((u) => u.id === id) || null;
}
function getActor() {
const v = localStorage.getItem(ACTOR_KEY);
return USERS.some((u) => u.id === v) ? v : null;
}
function setActor(id) {
if (!USERS.some((u) => u.id === id)) return;
localStorage.setItem(ACTOR_KEY, id);
state.me = id;
const sel = $("#ops-actor");
if (sel && sel.value !== id) sel.value = id;
updateIdentityBadge();
refreshAvatarPreview();
ensureNotifyPermission();
connectWs(true);
}
function unreadTotal() {
return Object.values(state.unread).reduce((a, b) => a + (b || 0), 0);
}
function updateUnreadBadge() {
const el = $("#team-unread");
const combo = document.querySelector(".ops-team-combo");
const n = unreadTotal();
if (el) {
if (n > 0) {
el.classList.remove("hidden");
el.textContent = n > 99 ? "99+" : String(n);
} else {
el.classList.add("hidden");
el.textContent = "0";
}
}
if (combo) {
combo.classList.toggle("has-unread", n > 0);
if (n > 0) {
combo.classList.remove("pulse");
void combo.offsetWidth;
combo.classList.add("pulse");
}
}
document.title = n > 0 ? `(${n}) Team · ${state.origTitle}` : state.origTitle;
}
function clearUnread(roomId) {
if (roomId == null) return;
delete state.unread[String(roomId)];
updateUnreadBadge();
}
function bumpUnread(roomId) {
const key = String(roomId);
state.unread[key] = (state.unread[key] || 0) + 1;
updateUnreadBadge();
}
function ensureNotifyPermission() {
if (!("Notification" in window) || state.notifyAsked) return;
state.notifyAsked = true;
if (Notification.permission === "default") {
Notification.requestPermission().catch(() => {});
}
}
function notifyNewMessage(msg) {
if (!msg || msg.author_id === state.me) return;
const who = msg.author_short || shortName({ name: msg.author_name, id: msg.author_id });
const body = (msg.body || (msg.file ? "Shared a file" : "New message")).slice(0, 120);
const room = state.rooms.find((r) => Number(r.id) === Number(msg.room_id));
const roomLabel =
room?.kind === "team"
? "ATC Team"
: room?.peer_short || shortName(userById(room?.peer_id)) || "Team chat";
// soft soundless flash via badge already; browser notification when allowed
if ("Notification" in window && Notification.permission === "granted" && document.hidden) {
try {
const n = new Notification(`${who} · ${roomLabel}`, {
body,
tag: `team-${msg.room_id}`,
renotify: true,
});
n.onclick = () => {
window.focus();
openTeam();
selectRoom(Number(msg.room_id)).catch(() => {});
n.close();
};
} catch (_) {}
}
}
function onIncomingMessage(msg) {
if (!msg) return;
const teamOpen = $("#team-drawer")?.classList.contains("open");
const viewing = teamOpen && Number(msg.room_id) === Number(state.activeRoomId);
if (viewing) {
if (!state.messages.some((m) => m.id === msg.id)) {
state.messages.push(msg);
renderMessages(true);
}
clearUnread(msg.room_id);
} else if (msg.author_id !== state.me) {
bumpUnread(msg.room_id);
notifyNewMessage(msg);
}
const room = state.rooms.find((r) => Number(r.id) === Number(msg.room_id));
if (room) room.last_message = msg;
renderRoomList();
}
function updateIdentityBadge() {
const badge = $("#team-identity-badge");
const btn = $("#btn-team");
const u = userById(state.me);
if (!badge) return;
if (!u) {
badge.classList.add("hidden");
badge.innerHTML = "";
if (btn) btn.title = "ATC team chat · files";
return;
}
badge.classList.remove("hidden");
const url = avatarUrl(u.id, u.avatar_url);
badge.innerHTML = `${esc(shortName(u))}`;
badge.title = `Acting as ${u.name} — click to switch`;
if (btn) btn.title = `Team chat · as ${u.name}`;
}
function refreshAvatarPreview() {
const u = userById(state.me || getActor());
const img = $("#identity-avatar-img");
const initials = $("#identity-avatar-initials");
const label = $("#identity-avatar-label");
const row = $("#identity-avatar-row");
if (!row) return;
if (!u) {
row.classList.add("dim");
if (label) label.textContent = "Profile photo";
if (img) {
img.hidden = true;
img.removeAttribute("src");
}
if (initials) {
initials.hidden = false;
initials.textContent = "?";
}
return;
}
row.classList.remove("dim");
if (label) label.textContent = `${u.name} · profile photo`;
const url = avatarUrl(u.id, u.avatar_url);
if (img) {
img.hidden = false;
img.onerror = () => {
img.hidden = true;
if (initials) {
initials.hidden = false;
initials.textContent = (u.name || "?").slice(0, 1).toUpperCase();
}
};
img.onload = () => {
if (initials) initials.hidden = true;
};
img.src = url;
}
if (initials) {
initials.hidden = false;
initials.textContent = (u.name || "?").slice(0, 1).toUpperCase();
}
}
function needsIdentityGate() {
return !getActor();
}
function openIdentityGate(force = false, { mustPick = false } = {}) {
const gate = $("#identity-gate");
if (!gate) return;
if (!force && !needsIdentityGate() && !mustPick) {
gate.classList.add("hidden");
gate.setAttribute("aria-hidden", "true");
return;
}
state._identityMustPick = mustPick || needsIdentityGate();
const grid = $("#identity-gate-grid");
if (grid) {
grid.innerHTML = USERS.map((u) => {
const url = avatarUrl(u.id, u.avatar_url);
const selected = (state.me || getActor()) === u.id ? "selected" : "";
const guest = u.team === "guest" ? "guest" : "";
return ``;
}).join("");
}
refreshAvatarPreview();
gate.classList.remove("hidden");
gate.setAttribute("aria-hidden", "false");
document.body.classList.add("identity-locked");
}
function closeIdentityGate() {
if (state._identityMustPick && needsIdentityGate()) return;
const gate = $("#identity-gate");
gate?.classList.add("hidden");
gate?.setAttribute("aria-hidden", "true");
state._identityMustPick = false;
document.body.classList.remove("identity-locked");
}
function confirmIdentity(id) {
setActor(id);
state._identityMustPick = false;
refreshAvatarPreview();
closeIdentityGate();
if (state._openTeamAfterId) {
state._openTeamAfterId = false;
openTeam();
}
}
async function uploadAvatar(file) {
const me = state.me || getActor();
if (!me || !file) return;
const fd = new FormData();
fd.append("user_id", me);
fd.append("file", file, file.name);
const res = await fetch("/api/team/avatar", { method: "POST", body: fd });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.detail || res.statusText || "Avatar upload failed");
return;
}
state.avatarBust[me] = Date.now();
if (data.avatar_url) {
const u = userById(me);
if (u) u.avatar_url = data.avatar_url;
}
updateIdentityBadge();
refreshAvatarPreview();
openIdentityGate(true);
renderPresence();
renderMessages(false);
}
function closeOtherDrawers() {
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer", "#network-drawer", "#console-drawer"].forEach((id) => {
const el = $(id);
if (el) {
el.classList.remove("open");
el.setAttribute("aria-hidden", "true");
}
});
}
function openTeam() {
if (needsIdentityGate()) {
openIdentityGate(true);
return;
}
state.me = getActor();
const drawer = $("#team-drawer");
const scrim = $("#scrim");
if (!drawer) return;
closeOtherDrawers();
drawer.classList.add("open");
drawer.setAttribute("aria-hidden", "false");
if (scrim) {
scrim.classList.add("open");
scrim.dataset.mode = "team-drawer";
}
connectWs();
refreshRooms().then(() => {
const team = state.rooms.find((r) => r.kind === "team");
if (team) selectRoom(team.id);
else if (state.rooms[0]) selectRoom(state.rooms[0].id);
});
}
function closeTeam() {
const drawer = $("#team-drawer");
const scrim = $("#scrim");
drawer?.classList.remove("open");
drawer?.setAttribute("aria-hidden", "true");
if (scrim?.dataset.mode === "team-drawer") {
scrim.classList.remove("open");
delete scrim.dataset.mode;
}
}
function connectWs(force = false) {
if (!state.me) return;
if (!force && state.ws && state.ws.readyState <= 1) return;
if (force && state.ws) {
try {
state.ws.close();
} catch (_) {}
state.ws = null;
}
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/ws/team-chat?user=${encodeURIComponent(state.me)}`);
state.ws = ws;
ws.addEventListener("message", (ev) => {
let data;
try {
data = JSON.parse(ev.data);
} catch {
return;
}
if (data.type === "presence" || data.type === "hello" || data.type === "pong") {
if (data.presence) {
state.presence = data.presence;
renderRoomList();
renderPresence();
}
} else if (data.type === "avatar" && data.user_id) {
state.avatarBust[data.user_id] = Date.now();
const u = userById(data.user_id);
if (u && data.avatar_url) u.avatar_url = data.avatar_url;
updateIdentityBadge();
refreshAvatarPreview();
renderPresence();
renderMessages(false);
renderRoomList();
} else if (data.type === "message" && data.message) {
onIncomingMessage(data.message);
} else if (data.type === "typing") {
if (Number(data.room_id) === Number(state.activeRoomId) && data.user_id !== state.me) {
const el = $("#team-typing");
if (el) {
el.textContent = `${data.name || "Someone"} is typing…`;
clearTimeout(state.typing);
state.typing = setTimeout(() => {
el.textContent = "";
}, 2500);
}
}
}
});
ws.addEventListener("close", () => {
state.ws = null;
setTimeout(() => {
if (state.me) connectWs();
}, 2500);
});
}
// heartbeat once
if (!window.__teamChatHeartbeat) {
window.__teamChatHeartbeat = setInterval(() => {
if (state.ws?.readyState === 1) state.ws.send(JSON.stringify({ type: "ping" }));
}, 25000);
}
async function refreshRooms() {
const res = await fetch(`/api/team/rooms?me=${encodeURIComponent(state.me)}`);
const data = await res.json();
if (!res.ok) throw new Error(data.detail || res.statusText);
state.rooms = data.rooms || [];
state.presence = data.presence || [];
if (data.users?.length) {
data.users.forEach((nu) => {
const local = USERS.find((u) => u.id === nu.id);
if (local) {
if (nu.name) local.name = nu.name;
if (nu.short) local.short = nu.short;
if (nu.avatar_url) local.avatar_url = nu.avatar_url;
}
});
updateIdentityBadge();
}
renderRoomList();
renderPresence();
}
function renderPresence() {
const host = $("#team-presence");
if (!host) return;
host.innerHTML = (state.presence || [])
.map((u) => {
const on = u.online ? "on" : "off";
const url = avatarUrl(u.id, u.avatar_url);
return `
${esc(shortName(u))}`;
})
.join("");
}
function renderRoomList() {
const host = $("#team-room-list");
if (!host) return;
const team = state.rooms.filter((r) => r.kind === "team");
const dms = state.rooms.filter((r) => r.kind === "dm");
const item = (r) => {
const active = Number(r.id) === Number(state.activeRoomId) ? "active" : "";
const title =
r.kind === "team"
? "ATC Team"
: r.peer_short || shortName(userById(r.peer_id)) || r.peer_name || r.title;
const preview = r.last_message
? `${r.last_message.author_short || shortName({ name: r.last_message.author_name })}: ${r.last_message.body || "file"}`
: "No messages yet";
const peer = r.peer_id ? state.presence.find((p) => p.id === r.peer_id) : null;
const live = r.kind === "team" ? "" : peer?.online ? "live" : "";
const av =
r.kind === "dm" && r.peer_id
? `
`
: "";
const unread = state.unread[String(r.id)] || 0;
const unreadHtml = unread
? `${unread > 99 ? "99+" : unread}`
: "";
return ``;
};
host.innerHTML = `
No DMs yet
`}`; } async function selectRoom(roomId) { state.activeRoomId = roomId; clearUnread(roomId); renderRoomList(); const res = await fetch(`/api/team/rooms/${roomId}/messages?limit=300`); const data = await res.json(); if (!res.ok) throw new Error(data.detail || res.statusText); state.messages = data.messages || []; const title = $("#team-chat-title"); const sub = $("#team-chat-sub"); const room = data.room || state.rooms.find((r) => Number(r.id) === Number(roomId)); if (title) { title.textContent = room?.kind === "team" ? "ATC Team" : room?.peer_short || shortName(userById(room?.peer_id)) || room?.title || "Chat"; } if (sub) { if (room?.kind === "dm") { const peer = room.peer_id || (room.key || "").split(":").filter((x) => x !== "dm" && x !== state.me)[0]; const p = userById(peer); sub.textContent = p ? `${p.role} · ${p.focus || ""}` : "Direct message"; } else { sub.textContent = "Jody · Laurens · Mo · Bart — shared room"; } } renderMessages(false); } function fmtTime(ts) { if (!ts) return ""; try { return new Date(ts * 1000).toLocaleString(); } catch { return ""; } } function formatBytes(n) { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } function renderMessages(stickBottom) { const host = $("#team-messages"); if (!host) return; const nearBottom = host.scrollHeight - host.scrollTop - host.clientHeight < 80; host.innerHTML = state.messages .map((m) => { const mine = m.author_id === state.me; const name = m.author_short || shortName({ name: m.author_name, id: m.author_id }); const url = avatarUrl(m.author_id, m.author_avatar); const file = m.file ? ` ${esc(m.file.filename)} ` : ""; return `