/* ─── State ─────────────────────────────────────────── */
let ws = null;
let currentRoom = "general";
let currentUser = "";
let rooms = {};
let reconnectTimer = null;
let reconnectDelay = 1000;
/* ─── DOM refs ──────────────────────────────────────── */
const messagesEl = document.getElementById("messages");
const msgInput = document.getElementById("msg-input");
const channelNameEl = document.getElementById("ch-name");
const channelDescEl = document.getElementById("ch-desc");
const channelListEl = document.getElementById("channel-list");
const userDisplayEl = document.getElementById("user-display");
const connStatus = document.getElementById("conn-status");
const userModal = document.getElementById("user-modal-overlay");
const userNameInput = document.getElementById("username-input");
const typingEl = document.getElementById("typing-indicator");
/* ─── Init ──────────────────────────────────────────── */
window.addEventListener("DOMContentLoaded", () => {
const saved = sessionStorage.getItem("guardia_user");
if (saved) { currentUser = saved; startApp(); }
else { userModal.classList.add("show"); userNameInput.focus(); }
});
document.getElementById("username-form").addEventListener("submit", e => {
e.preventDefault();
const name = userNameInput.value.trim();
if (!name) return;
currentUser = name;
sessionStorage.setItem("guardia_user", name);
userModal.classList.remove("show");
startApp();
});
function startApp() {
userDisplayEl.textContent = currentUser;
connect();
}
/* ─── WebSocket ─────────────────────────────────────── */
function connect() {
const proto = location.protocol === "https:" ? "wss" : "ws";
const url = `${proto}://${location.host}/ws/chat/${currentRoom}/${encodeURIComponent(currentUser)}`;
ws = new WebSocket(url);
showStatus("reconnecting", "연결 중…");
ws.onopen = () => {
showStatus("online", "연결됨");
reconnectDelay = 1000;
setTimeout(() => connStatus.classList.remove("show"), 2000);
};
ws.onmessage = e => {
const data = JSON.parse(e.data);
if (data.type === "init") {
rooms = data.rooms || {};
renderChannelList();
updateChannelHeader();
messagesEl.innerHTML = "";
if (data.messages?.length) {
data.messages.forEach(renderMessage);
scrollBottom();
} else {
renderWelcome();
}
} else {
renderMessage(data);
scrollBottom();
}
};
ws.onclose = () => {
showStatus("offline", "연결 끊김 — 재연결 중…");
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, 15000);
connect();
}, reconnectDelay);
};
ws.onerror = () => ws.close();
}
function sendMessage() {
const text = msgInput.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ content: text }));
msgInput.value = "";
msgInput.style.height = "auto";
}
/* ─── Channel switching ─────────────────────────────── */
function switchRoom(roomId) {
if (roomId === currentRoom) return;
currentRoom = roomId;
if (ws) { ws.onclose = null; ws.close(); }
clearTimeout(reconnectTimer);
connect();
document.querySelectorAll(".channel-item").forEach(el => {
el.classList.toggle("active", el.dataset.room === roomId);
});
updateChannelHeader();
}
function updateChannelHeader() {
const name = rooms[currentRoom] || currentRoom;
channelNameEl.textContent = "# " + name;
channelDescEl.textContent = roomDesc(currentRoom);
}
function roomDesc(id) {
const descs = {
general: "팀 전체 공지 및 일반 대화",
deploy: "배포 작업 요청 및 진행 현황",
ops: "서버 운영 및 장애 대응",
pm: "PM 승인 워크플로우 및 진척 관리",
alerts: "자동 알림 및 임계치 경보",
};
return descs[id] || "";
}
/* ─── Render ────────────────────────────────────────── */
function renderChannelList() {
channelListEl.innerHTML = "";
Object.entries(rooms).forEach(([id, label]) => {
const el = document.createElement("div");
el.className = "channel-item" + (id === currentRoom ? " active" : "");
el.dataset.room = id;
el.innerHTML = ` ${label}`;
el.addEventListener("click", () => switchRoom(id));
channelListEl.appendChild(el);
});
}
function renderWelcome() {
const div = document.createElement("div");
div.className = "date-divider";
div.textContent = `# ${rooms[currentRoom] || currentRoom} 채널에 오신 것을 환영합니다`;
messagesEl.appendChild(div);
const hint = document.createElement("div");
hint.style.cssText = "padding:8px 0;font-size:13px;color:var(--text-muted)";
hint.innerHTML = "💡 @bot 도움 을 입력하면 GUARDiA Bot 명령어를 확인할 수 있습니다.";
messagesEl.appendChild(hint);
}
let lastDate = "";
let lastSender = "";
function renderMessage(msg) {
const msgDate = new Date(msg.timestamp).toLocaleDateString("ko-KR", {
month: "long", day: "numeric", weekday: "long"
});
if (msgDate !== lastDate) {
const divider = document.createElement("div");
divider.className = "date-divider";
divider.textContent = msgDate;
messagesEl.appendChild(divider);
lastDate = msgDate;
lastSender = "";
}
const isBot = msg.sender_type === "BOT";
const isSame = msg.sender === lastSender && !isBot;
lastSender = msg.sender;
const group = document.createElement("div");
group.className = "msg-group";
group.dataset.msgId = msg.message_id;
const initials = msg.sender.slice(0, 2).toUpperCase();
const time = new Date(msg.timestamp).toLocaleTimeString("ko-KR", {
hour: "2-digit", minute: "2-digit"
});
const avatarHTML = isSame
? `
${escHtml(code.trim())}`
);
// Inline code
text = text.replace(/`([^`]+)`/g, (_, code) =>
`${escHtml(code)}`
);
// Bold
text = text.replace(/\*\*(.+?)\*\*/g, "$1");
// Remaining escaping for non-tagged parts (basic)
return text;
}
function escHtml(s) {
return String(s)
.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
}
function escAttr(s) {
return String(s || "").replace(/'/g, "\\'");
}
/* ─── UI helpers ────────────────────────────────────── */
function scrollBottom() {
requestAnimationFrame(() => {
messagesEl.scrollTop = messagesEl.scrollHeight;
});
}
function showStatus(cls, text) {
connStatus.className = `show ${cls}`;
connStatus.textContent = text;
}
/* ─── Input events ──────────────────────────────────── */
document.getElementById("send-btn").addEventListener("click", sendMessage);
msgInput.addEventListener("keydown", e => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
msgInput.addEventListener("input", () => {
msgInput.style.height = "auto";
msgInput.style.height = Math.min(msgInput.scrollHeight, 120) + "px";
});