/** * 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 = `
Team
${team.map(item).join("")}
Direct
${dms.map(item).join("") || `

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)} ${esc(formatBytes(m.file.size || 0))} · ${esc(m.file.mime || "file")} ` : ""; return `
${esc(name)}${esc(fmtTime(m.created_at))}
${m.body ? `
${esc(m.body)}
` : ""} ${file}
`; }) .join(""); if (stickBottom || nearBottom) host.scrollTop = host.scrollHeight; } async function sendMessage() { const input = $("#team-input"); const body = (input?.value || "").trim(); if (!body || !state.activeRoomId) return; input.value = ""; const res = await fetch(`/api/team/rooms/${state.activeRoomId}/messages`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ author_id: state.me, body }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { alert(data.detail || res.statusText); return; } if (data.message && !state.messages.some((m) => m.id === data.message.id)) { state.messages.push(data.message); renderMessages(true); } } async function uploadFiles(fileList) { if (!state.activeRoomId || !fileList?.length) return; for (const file of fileList) { const fd = new FormData(); fd.append("uploader_id", state.me); fd.append("body", ""); fd.append("file", file, file.name); const res = await fetch(`/api/team/rooms/${state.activeRoomId}/files`, { method: "POST", body: fd }); const data = await res.json().catch(() => ({})); if (!res.ok) { alert(data.detail || `Upload failed: ${file.name}`); continue; } if (data.message && !state.messages.some((m) => m.id === data.message.id)) { state.messages.push(data.message); renderMessages(true); } } } function notifyTyping() { if (state.ws?.readyState === 1 && state.activeRoomId) { state.ws.send(JSON.stringify({ type: "typing", room_id: state.activeRoomId })); } } function bind() { state.me = getActor(); updateIdentityBadge(); updateUnreadBadge(); // Always first popup on UI open so everyone can pick themselves immediately openIdentityGate(true, { mustPick: needsIdentityGate() }); if (state.me) { ensureNotifyPermission(); connectWs(); refreshRooms().catch(() => {}); } $("#identity-gate-grid")?.addEventListener("click", (e) => { const btn = e.target.closest("[data-actor]"); if (!btn) return; confirmIdentity(btn.dataset.actor); }); $("#identity-avatar-file")?.addEventListener("change", (e) => { const file = e.target.files?.[0]; if (!getActor()) { alert("Pick who you are first, then upload a photo."); e.target.value = ""; return; } uploadAvatar(file).catch((err) => alert(err.message)); e.target.value = ""; }); $("#team-identity-badge")?.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); openIdentityGate(true); }); $("#btn-team")?.addEventListener("click", (e) => { if (e.target.closest("#team-identity-badge")) return; if (needsIdentityGate()) { state._openTeamAfterId = true; openIdentityGate(true, { mustPick: true }); return; } openTeam(); }); $("#btn-team-close")?.addEventListener("click", closeTeam); $("#btn-team-switch")?.addEventListener("click", () => openIdentityGate(true)); $("#scrim")?.addEventListener("click", () => { if ($("#scrim")?.dataset.mode === "team-drawer") closeTeam(); }); $("#team-room-list")?.addEventListener("click", (e) => { const btn = e.target.closest("[data-room-id]"); if (!btn) return; selectRoom(Number(btn.dataset.roomId)).catch((err) => alert(err.message)); }); $("#team-form")?.addEventListener("submit", (e) => { e.preventDefault(); sendMessage().catch((err) => alert(err.message)); }); $("#team-input")?.addEventListener("input", () => notifyTyping()); $("#team-file")?.addEventListener("change", (e) => { const files = e.target.files; uploadFiles(files).catch((err) => alert(err.message)); e.target.value = ""; }); const drop = $("#team-chat-main"); drop?.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("drag-files"); }); drop?.addEventListener("dragleave", () => drop.classList.remove("drag-files")); drop?.addEventListener("drop", (e) => { e.preventDefault(); drop.classList.remove("drag-files"); if (e.dataTransfer?.files?.length) uploadFiles(e.dataTransfer.files).catch((err) => alert(err.message)); }); $("#ops-actor")?.addEventListener("change", (e) => { if (e.target.value) setActor(e.target.value); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { if (!$("#identity-gate")?.classList.contains("hidden")) { if (!state._identityMustPick && !needsIdentityGate()) closeIdentityGate(); } else if ($("#team-drawer")?.classList.contains("open")) { closeTeam(); } } }); $("#identity-gate")?.addEventListener("click", (e) => { if (e.target.id === "identity-gate" && !state._identityMustPick && !needsIdentityGate()) { closeIdentityGate(); } }); } bind(); window.cockpitTeam = { open: openTeam, close: closeTeam, setActor, getActor, openIdentityGate, uploadAvatar, }; })();