b4714c70d1
Ship team/DM chat with files, avatars, unread alerts, and a first-open identity gate (including Guest); fold Team into Ops desk; clarify Datacenter Engineer vs "Data Plumbers" FDE roles; refresh Present story/tech slides to match. Co-authored-by: Cursor <cursoragent@cursor.com>
714 lines
24 KiB
JavaScript
714 lines
24 KiB
JavaScript
/**
|
|
* 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, ">")
|
|
.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 = `<span class="tib-av"><img src="${esc(url)}" alt="" onerror="this.remove()"/></span><span class="tib-dot ${esc(u.team)}"></span><strong>${esc(shortName(u))}</strong>`;
|
|
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 `<button type="button" class="id-card team-${esc(u.team)} ${selected} ${guest}" data-actor="${esc(u.id)}">
|
|
<span class="id-av"><img src="${esc(url)}" alt="" onerror="this.parentElement.classList.add('fallback')"/><span class="id-av-fallback">${esc((u.short || u.name || "?").slice(0, 1))}</span></span>
|
|
<span class="id-kicker">${u.team === "admin" ? "DC Engineer" : u.team === "guest" ? "Visitor" : "Data FDE · \"Data Plumbers\" 😉"}</span>
|
|
<strong>${esc(u.name)}</strong>
|
|
<span class="id-role">${esc(u.role)}</span>
|
|
<span class="id-focus">${esc(u.focus)}</span>
|
|
</button>`;
|
|
}).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 `<span class="tp-pill ${on}" title="${esc(u.name)}"><img class="tp-av" src="${esc(url)}" alt="" onerror="this.remove()"/><i></i>${esc(shortName(u))}</span>`;
|
|
})
|
|
.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
|
|
? `<img class="tr-av" src="${esc(avatarUrl(r.peer_id, r.peer_avatar))}" alt="" onerror="this.classList.add('hidden')"/>`
|
|
: "";
|
|
const unread = state.unread[String(r.id)] || 0;
|
|
const unreadHtml = unread
|
|
? `<span class="tr-unread">${unread > 99 ? "99+" : unread}</span>`
|
|
: "";
|
|
return `<button type="button" class="team-room ${active} ${live} ${unread ? "has-unread" : ""}" data-room-id="${r.id}">
|
|
${av}
|
|
<span class="tr-text"><span class="tr-title">${esc(title)}${unreadHtml}</span>
|
|
<span class="tr-preview">${esc(preview.slice(0, 64))}</span></span>
|
|
</button>`;
|
|
};
|
|
host.innerHTML = `
|
|
<div class="team-room-group"><span>Team</span></div>
|
|
${team.map(item).join("")}
|
|
<div class="team-room-group"><span>Direct</span></div>
|
|
${dms.map(item).join("") || `<p class="hint">No DMs yet</p>`}`;
|
|
}
|
|
|
|
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
|
|
? `<a class="team-file" href="${esc(m.file.url)}" target="_blank" rel="noopener">
|
|
<span class="tf-name">${esc(m.file.filename)}</span>
|
|
<span class="tf-meta">${esc(formatBytes(m.file.size || 0))} · ${esc(m.file.mime || "file")}</span>
|
|
</a>`
|
|
: "";
|
|
return `<div class="team-bubble ${mine ? "mine" : "theirs"} team-${esc(m.author_team || "")}">
|
|
<img class="tb-av" src="${esc(url)}" alt="" onerror="this.classList.add('hidden')"/>
|
|
<div class="tb-content">
|
|
<div class="tb-meta"><strong>${esc(name)}</strong><span>${esc(fmtTime(m.created_at))}</span></div>
|
|
${m.body ? `<div class="tb-body">${esc(m.body)}</div>` : ""}
|
|
${file}
|
|
</div>
|
|
</div>`;
|
|
})
|
|
.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,
|
|
};
|
|
})();
|