// SecureChat App — Main Frontend Logic const API = window.location.origin + "/api"; let token = null; let me = null; let privateKey = null; // CryptoKey object let ws = null; // Cache of decrypted messages and public keys const pubKeyCache = {}; // userId -> CryptoKey const msgCache = {}; // convId -> [{...}] const groupMsgCache = {}; // groupId -> [{...}] let currentChat = null; // { type: 'dm', id: conv_id, other_user } | { type: 'group', id: group_id, name, members } let conversations = []; let groups = []; let typingTimeout = null; let selectedGroupMembers = []; // for create group modal let currentGroupData = null; // ── Call State ──────────────────────────────────────────────────────────────── let callState = null; let callType = null; let callTarget = null; let localStream = null; let remoteStream = null; let peerConnection = null; let pendingCallOffer = null; const ICE_SERVERS = { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' } ] }; // ── Bootstrap ─────────────────────────────────────────────────────────────── window.addEventListener("DOMContentLoaded", async () => { const params = new URLSearchParams(window.location.search); const urlToken = params.get("token"); const newUser = params.get("new_user") === "True"; const googleSetup = params.get("google_setup"); if (googleSetup) { pendingGoogleToken = googleSetup; window.history.replaceState({}, "", "/"); showGoogleSetup(); return; } if (urlToken) { localStorage.setItem("sc_token", urlToken); window.history.replaceState({}, "", "/"); if (newUser) { await setupSession(urlToken, true); return; } } const stored = localStorage.getItem("sc_token"); if (stored) { await setupSession(stored); } }); async function setupSession(tok, generateKeys = false) { try { const res = await apiFetch("/auth/me", tok); if (!res) { showAuth(); return; } token = tok; me = res; // Load or generate private key const storedKeyJwk = Crypto.loadPrivateKey(); if (storedKeyJwk && me.public_key) { privateKey = await Crypto.importPrivateKey(storedKeyJwk); } else if (!me.public_key || generateKeys) { // Generate new key pair const kp = await Crypto.generateKeyPair(); privateKey = kp.keyPair.privateKey; Crypto.storePrivateKey(kp.privateKeyJwk); const pubKeyStr = JSON.stringify(kp.publicKeyJwk); await apiFetch("/auth/profile", token, "PUT", { public_key: pubKeyStr }); me.public_key = pubKeyStr; } // Cache our own public key for group message encryption if (me.public_key) { try { pubKeyCache[me.id] = await Crypto.importPublicKey(me.public_key); } catch (e) {} } showApp(); updateMyProfile(); await loadChats(); connectWebSocket(); } catch (e) { showAuth(); } } function showAuth() { document.getElementById("auth").style.display = "flex"; document.getElementById("app").style.display = "none"; } function showApp() { document.getElementById("auth").style.display = "none"; document.getElementById("app").style.display = "flex"; } // ── API Helper ─────────────────────────────────────────────────────────────── async function apiFetch(path, tok, method = "GET", body = null) { const opts = { method, headers: { "Content-Type": "application/json", "Authorization": `Bearer ${tok || token}` } }; if (body) opts.body = JSON.stringify(body); const res = await fetch(API + path, opts); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || res.statusText); } return res.json(); } // ── Auth ───────────────────────────────────────────────────────────────────── function showTab(tab) { document.getElementById("auth-tabs").classList.remove("hidden"); document.getElementById("form-login").classList.toggle("hidden", tab !== "login"); document.getElementById("form-register").classList.toggle("hidden", tab !== "register"); document.getElementById("form-google-setup").classList.add("hidden"); const active = "flex-1 py-2 rounded-lg text-sm font-medium transition-all glass-btn-active text-white"; const inactive = "flex-1 py-2 rounded-lg text-sm font-medium transition-all text-gray-400"; document.getElementById("tab-login").className = tab === "login" ? active : inactive; document.getElementById("tab-register").className = tab === "register" ? active : inactive; } async function doLogin() { const username = document.getElementById("login-username").value.trim(); const password = document.getElementById("login-password").value; const errEl = document.getElementById("login-error"); const btn = document.getElementById("login-btn"); try { errEl.classList.add("hidden"); if (!username || !password) throw new Error("Please fill in all required fields"); btn.disabled = true; btn.textContent = "Signing in…"; const data = await apiFetch("/auth/login", null, "POST", { username, password }); localStorage.setItem("sc_token", data.token); await setupSession(data.token); } catch (e) { errEl.textContent = e.message; errEl.classList.remove("hidden"); btn.disabled = false; btn.textContent = "Sign In"; } } async function doRegister() { const display_name = document.getElementById("reg-display").value.trim(); const username = document.getElementById("reg-username").value.trim(); const email = document.getElementById("reg-email").value.trim(); const password = document.getElementById("reg-password").value; const errEl = document.getElementById("reg-error"); const btn = document.getElementById("reg-btn"); try { errEl.classList.add("hidden"); if (!display_name || !username || !password) throw new Error("Please fill in all required fields"); if (password.length < 8) throw new Error("Password must be at least 8 characters"); btn.disabled = true; btn.textContent = "Generating keys…"; const kp = await Crypto.generateKeyPair(); const publicKeyStr = JSON.stringify(kp.publicKeyJwk); Crypto.storePrivateKey(kp.privateKeyJwk); btn.textContent = "Creating account…"; const data = await apiFetch("/auth/register", null, "POST", { username, display_name, password, email: email || null, public_key: publicKeyStr }); localStorage.setItem("sc_token", data.token); token = data.token; me = data.user; privateKey = kp.keyPair.privateKey; showApp(); updateMyProfile(); try { await loadChats(); } catch (e) { console.error("loadChats error:", e); } connectWebSocket(); } catch (e) { console.error("Registration error:", e); errEl.textContent = e.message; errEl.classList.remove("hidden"); btn.disabled = false; btn.textContent = "Create Account"; } } function googleLogin() { window.location.href = "/api/auth/google"; } function showGoogleSetup() { document.getElementById("auth-tabs").classList.add("hidden"); document.getElementById("form-login").classList.add("hidden"); document.getElementById("form-register").classList.add("hidden"); document.getElementById("form-google-setup").classList.remove("hidden"); } let pendingGoogleToken = null; async function completeGoogleSetup() { const username = document.getElementById("gs-username").value.trim(); const display_name = document.getElementById("gs-display").value.trim(); const errEl = document.getElementById("gs-error"); const btn = document.getElementById("gs-btn"); if (!pendingGoogleToken) { pendingGoogleToken = new URLSearchParams(window.location.search).get("google_setup"); } try { errEl.classList.add("hidden"); if (!username || !display_name) throw new Error("Please fill in all fields"); btn.disabled = true; btn.textContent = "Creating account…"; const data = await apiFetch("/auth/register", null, "POST", { username, display_name, email: null, password: "", google_setup_token: pendingGoogleToken }); localStorage.setItem("sc_token", data.token); token = data.token; me = data.user; pendingGoogleToken = null; const kp = await Crypto.generateKeyPair(); privateKey = kp.keyPair.privateKey; Crypto.storePrivateKey(kp.privateKeyJwk); const pubKeyStr = JSON.stringify(kp.publicKeyJwk); await apiFetch("/auth/profile", token, "PUT", { public_key: pubKeyStr }); me.public_key = pubKeyStr; if (me.public_key) { try { pubKeyCache[me.id] = await Crypto.importPublicKey(me.public_key); } catch (e) {} } showApp(); updateMyProfile(); try { await loadChats(); } catch (e) { console.error("loadChats error:", e); } connectWebSocket(); } catch (e) { console.error("Google setup error:", e); errEl.textContent = e.message; errEl.classList.remove("hidden"); btn.disabled = false; btn.textContent = "Create Account"; } } function logout() { localStorage.removeItem("sc_token"); Crypto.clearPrivateKey(); if (ws) ws.close(); showAuth(); } // ── Profile ─────────────────────────────────────────────────────────────────── function updateMyProfile() { document.getElementById("my-display-name").textContent = me.display_name; document.getElementById("my-username").textContent = "@" + me.username; const av = document.getElementById("my-avatar"); av.textContent = me.display_name[0].toUpperCase(); av.style.background = me.avatar_color; } function showSettings() { document.getElementById("settings-display-name").value = me.display_name; document.getElementById("settings-username").value = "@" + me.username; document.getElementById("settings-msg").classList.add("hidden"); document.getElementById("settings-password-section").classList.toggle("hidden", !!me.has_password); document.getElementById("settings-password").value = ""; document.getElementById("settings-password-confirm").value = ""; document.getElementById("settings-pw-error").classList.add("hidden"); document.getElementById("modal-settings").classList.remove("hidden"); } async function setPassword() { const password = document.getElementById("settings-password").value; const confirm = document.getElementById("settings-password-confirm").value; const errEl = document.getElementById("settings-pw-error"); const btn = document.getElementById("settings-pw-btn"); try { errEl.classList.add("hidden"); if (!password || password.length < 8) throw new Error("Password must be at least 8 characters"); if (password !== confirm) throw new Error("Passwords do not match"); btn.disabled = true; btn.textContent = "Setting password…"; await apiFetch("/auth/set-password", token, "POST", { password }); me.has_password = true; btn.textContent = "Password set!"; setTimeout(() => { document.getElementById("settings-password-section").classList.add("hidden"); }, 1500); } catch (e) { errEl.textContent = e.message; errEl.classList.remove("hidden"); btn.disabled = false; btn.textContent = "Set Password"; } } async function saveSettings() { const newName = document.getElementById("settings-display-name").value.trim(); if (!newName) return; await apiFetch("/auth/profile", token, "PUT", { display_name: newName }); me.display_name = newName; updateMyProfile(); document.getElementById("settings-msg").classList.remove("hidden"); setTimeout(() => { document.getElementById("settings-msg").classList.add("hidden"); closeModal("modal-settings"); }, 1200); } // ── WebSocket ───────────────────────────────────────────────────────────────── function connectWebSocket() { const proto = window.location.protocol === "https:" ? "wss" : "ws"; ws = new WebSocket(`${proto}://${window.location.host}/ws/${token}`); ws.onmessage = handleWsMessage; ws.onclose = () => setTimeout(connectWebSocket, 3000); } async function handleWsMessage(evt) { const msg = JSON.parse(evt.data); if (msg.type === "dm") { await handleIncomingDm(msg); } else if (msg.type === "group") { await handleIncomingGroup(msg); } else if (msg.type === "typing") { showTyping(msg); } else if (msg.type === "read_receipt") { handleReadReceipt(msg); } else if (msg.type === "call_offer") { handleCallOffer(msg); } else if (msg.type === "call_answer") { handleCallAnswer(msg); } else if (msg.type === "ice_candidate") { handleIceCandidate(msg); } else if (msg.type === "call_end") { handleCallEnd(msg); } else if (msg.type === "video_toggle") { handleVideoToggle(msg); } } async function handleIncomingDm(msg) { const { conversation_id, message } = msg; // For pending DM (no id yet), link it when we get the echo or a reply if (currentChat && currentChat.type === "dm" && !currentChat.id) { const matchesEcho = message.sender_id === me.id; const matchesReply = message.sender_id === currentChat.other_user?.id; if (matchesEcho || matchesReply) { currentChat.id = conversation_id; } } // Skip our own messages — shown optimistically in sendDm if (message.sender_id === me.id) { await loadChats(); return; } if (!msgCache[conversation_id]) msgCache[conversation_id] = []; let plaintext = null; if (privateKey) { try { plaintext = await Crypto.decryptWithPrivateKey(message.ciphertext, privateKey); } catch (e) { plaintext = "[Encrypted message]"; } } const withPlain = { ...message, plaintext }; msgCache[conversation_id].push(withPlain); if (currentChat && currentChat.type === "dm" && currentChat.id === conversation_id) { appendMessage(withPlain, currentChat.other_user); scrollToBottom(); sendReadReceipt(conversation_id); } await loadChats(); } async function handleIncomingGroup(msg) { const { group_id, message } = msg; if (!groupMsgCache[group_id]) groupMsgCache[group_id] = []; let plaintext = null; if (privateKey && message.encrypted_aes_key) { try { const aesKey = await Crypto.unwrapAesKey(message.encrypted_aes_key, privateKey); plaintext = await Crypto.decryptWithAes(message.ciphertext, message.iv, aesKey); } catch (e) { plaintext = "[Encrypted message]"; } } const withPlain = { ...message, plaintext }; groupMsgCache[group_id].push(withPlain); if (currentChat && currentChat.type === "group" && currentChat.id === group_id) { appendMessage(withPlain, null, true); scrollToBottom(); } await loadChats(); } function showTyping(msg) { if (!currentChat) return; const relevant = (currentChat.type === "dm" && msg.conversation_id === currentChat.id) || (currentChat.type === "group" && msg.group_id === currentChat.id); if (!relevant && msg.user_id !== currentChat?.other_user?.id) return; const ind = document.getElementById("typing-indicator"); const txt = document.getElementById("typing-text"); if (msg.is_typing) { txt.textContent = `${msg.display_name} is typing…`; ind.classList.remove("hidden"); clearTimeout(typingTimeout); typingTimeout = setTimeout(() => ind.classList.add("hidden"), 3000); } else { ind.classList.add("hidden"); } } // ── Chats Loading ───────────────────────────────────────────────────────────── let activeTab = "dms"; function setTab(tab) { activeTab = tab; const activeClass = "flex-1 text-xs py-1.5 rounded-lg glass-btn-active text-white font-medium"; const inactiveClass = "flex-1 text-xs py-1.5 rounded-lg text-gray-400 hover:text-white transition-colors"; document.getElementById("tab-dms").className = tab === "dms" ? activeClass : inactiveClass; document.getElementById("tab-groups").className = tab === "groups" ? activeClass : inactiveClass; renderChatList(); } function sendReadReceipt(conversationId) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "read", conversation_id: conversationId })); setTimeout(() => loadChats(), 200); } } function handleReadReceipt(msg) { const { conversation_id } = msg; if (msgCache[conversation_id]) { let changed = false; for (const m of msgCache[conversation_id]) { if (m.sender_id === me.id && !m.is_read) { m.is_read = true; changed = true; } } if (changed && currentChat && currentChat.id === conversation_id) { const otherUser = currentChat.other_user; renderMessages(msgCache[conversation_id], otherUser); } } } // ── Call Functions ──────────────────────────────────────────────────────────── function showCallDropdown() { document.getElementById("call-dropdown").classList.toggle("hidden"); } function showCallModal() { document.getElementById("modal-call").classList.remove("hidden"); } function hideCallModal() { document.getElementById("modal-call").classList.add("hidden"); } function showIncomingCall() { document.getElementById("incoming-call").classList.remove("hidden"); } function hideIncomingCall() { document.getElementById("incoming-call").classList.add("hidden"); } async function startCall(type) { if (!currentChat || currentChat.type !== "dm" || !currentChat.other_user) return; document.getElementById("call-dropdown").classList.add("hidden"); callType = type; callTarget = currentChat.other_user; callState = "calling"; try { const constraints = { audio: true, video: type === "video" }; localStream = await navigator.mediaDevices.getUserMedia(constraints); peerConnection = new RTCPeerConnection(ICE_SERVERS); localStream.getTracks().forEach(t => peerConnection.addTrack(t, localStream)); remoteStream = new MediaStream(); peerConnection.ontrack = (event) => { event.streams[0].getTracks().forEach(t => remoteStream.addTrack(t)); const el = document.getElementById("remote-video"); if (el) el.srcObject = remoteStream; }; peerConnection.onicecandidate = (event) => { if (event.candidate && ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "ice_candidate", target_id: callTarget.id, candidate: event.candidate })); } }; peerConnection.oniceconnectionstatechange = () => { if (peerConnection.connectionState === "disconnected" || peerConnection.connectionState === "failed") { endCall(); } }; const offer = await peerConnection.createOffer(); await peerConnection.setLocalDescription(offer); ws.send(JSON.stringify({ type: "call_offer", target_id: callTarget.id, call_type: type, sdp: peerConnection.localDescription })); showCallModal(); document.getElementById("call-status").textContent = "Calling…"; document.getElementById("call-peer-name").textContent = callTarget.display_name; setupCallMedia(); } catch (e) { console.error("startCall error:", e); cleanupCall(); } } function handleCallOffer(msg) { if (callState) { ws.send(JSON.stringify({ type: "call_end", target_id: msg.from })); return; } callType = msg.call_type; callTarget = { id: msg.from, display_name: msg.from_display_name, avatar_color: msg.from_avatar_color }; pendingCallOffer = msg.sdp; callState = "ringing"; document.getElementById("incoming-caller-name").textContent = msg.from_display_name; document.getElementById("incoming-call-type").textContent = msg.call_type === "video" ? "Video Call" : "Audio Call"; const av = document.getElementById("incoming-avatar"); av.textContent = msg.from_display_name[0].toUpperCase(); av.style.background = msg.from_avatar_color || "#6366f1"; document.getElementById("incoming-call-type-icon").textContent = msg.call_type === "video" ? "📹" : "📞"; document.getElementById("call-dropdown").classList.add("hidden"); showIncomingCall(); } async function acceptCall() { hideIncomingCall(); callState = "connected"; try { const constraints = { audio: true, video: callType === "video" }; localStream = await navigator.mediaDevices.getUserMedia(constraints); peerConnection = new RTCPeerConnection(ICE_SERVERS); localStream.getTracks().forEach(t => peerConnection.addTrack(t, localStream)); remoteStream = new MediaStream(); peerConnection.ontrack = (event) => { event.streams[0].getTracks().forEach(t => remoteStream.addTrack(t)); const el = document.getElementById("remote-video"); if (el) el.srcObject = remoteStream; }; peerConnection.onicecandidate = (event) => { if (event.candidate && ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "ice_candidate", target_id: callTarget.id, candidate: event.candidate })); } }; peerConnection.oniceconnectionstatechange = () => { if (peerConnection.connectionState === "disconnected" || peerConnection.connectionState === "failed") { endCall(); } }; await peerConnection.setRemoteDescription(new RTCSessionDescription(pendingCallOffer)); pendingCallOffer = null; const answer = await peerConnection.createAnswer(); await peerConnection.setLocalDescription(answer); ws.send(JSON.stringify({ type: "call_answer", target_id: callTarget.id, sdp: peerConnection.localDescription })); showCallModal(); document.getElementById("call-status").textContent = "Connected"; document.getElementById("call-peer-name").textContent = callTarget.display_name; setupCallMedia(); } catch (e) { console.error("acceptCall error:", e); cleanupCall(); } } function rejectCall() { hideIncomingCall(); if (ws && ws.readyState === WebSocket.OPEN && callTarget) { ws.send(JSON.stringify({ type: "call_end", target_id: callTarget.id })); } cleanupCall(); } function handleCallAnswer(msg) { if (!peerConnection || callState !== "calling") return; callState = "connected"; document.getElementById("call-status").textContent = "Connected"; peerConnection.setRemoteDescription(new RTCSessionDescription(msg.sdp)).catch(e => console.error("setRemoteDescription error:", e)); } function handleIceCandidate(msg) { if (peerConnection && msg.candidate) { peerConnection.addIceCandidate(new RTCIceCandidate(msg.candidate)).catch(() => {}); } } function handleCallEnd(msg) { if (callState === "ringing") hideIncomingCall(); if (callState) { callState = null; const status = document.getElementById("call-status"); if (status) { status.textContent = "Call ended"; setTimeout(hideCallModal, 1500); } cleanupCall(); } } function endCall() { if (ws && ws.readyState === WebSocket.OPEN && callTarget) { ws.send(JSON.stringify({ type: "call_end", target_id: callTarget.id })); } cleanupCall(); hideCallModal(); hideIncomingCall(); } function cleanupCall() { if (peerConnection) { peerConnection.close(); peerConnection = null; } if (localStream) { localStream.getTracks().forEach(t => t.stop()); localStream = null; } remoteStream = null; callState = null; callType = null; callTarget = null; pendingCallOffer = null; const lv = document.getElementById("local-video"); const rv = document.getElementById("remote-video"); if (lv) lv.srcObject = null; if (rv) rv.srcObject = null; document.getElementById("local-video-avatar").classList.add("hidden"); document.getElementById("remote-video-avatar").classList.add("hidden"); } function setupCallMedia() { const lv = document.getElementById("local-video"); if (lv) lv.srcObject = localStream; // Init local avatar (shows me when my camera is off) const localInner = document.getElementById("local-video-avatar-inner"); if (localInner && me) { localInner.textContent = me.display_name[0].toUpperCase(); localInner.style.background = me.avatar_color || "#6366f1"; } // Init remote avatar (shows other person when their camera is off) const remoteInner = document.getElementById("remote-video-avatar-inner"); if (remoteInner && callTarget) { remoteInner.textContent = callTarget.display_name[0].toUpperCase(); remoteInner.style.background = callTarget.avatar_color || "#6366f1"; } const videoContent = document.getElementById("call-video-content"); const audioContent = document.getElementById("call-audio-content"); const videoBtn = document.getElementById("call-video-btn"); if (callType === "video") { if (videoContent) videoContent.classList.remove("hidden"); if (audioContent) audioContent.classList.add("hidden"); if (videoBtn) videoBtn.classList.remove("hidden"); } else { if (videoContent) videoContent.classList.add("hidden"); if (audioContent) audioContent.classList.remove("hidden"); if (videoBtn) videoBtn.classList.add("hidden"); const av = document.getElementById("call-audio-avatar"); if (av) { av.textContent = callTarget.display_name[0].toUpperCase(); av.style.background = callTarget.avatar_color || "#6366f1"; } } } function toggleMute() { if (localStream) { const t = localStream.getAudioTracks()[0]; if (t) { t.enabled = !t.enabled; document.getElementById("call-mute-btn").textContent = t.enabled ? "🔊 Mute" : "🔇 Unmute"; } } } function toggleVideo() { if (localStream && callType === "video") { const t = localStream.getVideoTracks()[0]; if (t) { t.enabled = !t.enabled; const isOn = t.enabled; document.getElementById("call-video-btn").textContent = isOn ? "📹 Video" : "🚫 Video Off"; const overlay = document.getElementById("local-video-avatar"); if (overlay) { overlay.classList.toggle("hidden", isOn); } if (ws && ws.readyState === WebSocket.OPEN && callTarget) { ws.send(JSON.stringify({ type: "video_toggle", target_id: callTarget.id, enabled: isOn })); } } } } function handleVideoToggle(msg) { const overlay = document.getElementById("remote-video-avatar"); const inner = document.getElementById("remote-video-avatar-inner"); if (!overlay || !inner) return; if (msg.enabled) { overlay.classList.add("hidden"); } else { inner.textContent = (msg.display_name || "?")[0].toUpperCase(); inner.style.background = msg.avatar_color || "#6366f1"; overlay.classList.remove("hidden"); } } async function loadChats() { [conversations, groups] = await Promise.all([ apiFetch("/chat/conversations"), apiFetch("/chat/groups") ]); renderChatList(); } function filterChats() { renderChatList(); } async function deleteConversation(convId, event) { if (event) { event.stopPropagation(); } if (!confirm("Delete this conversation?")) return; try { await apiFetch(`/chat/conversations/${convId}`, token, "DELETE"); if (currentChat && currentChat.type === "dm" && currentChat.id === convId) { currentChat = null; document.getElementById("chat-window").classList.add("hidden"); document.getElementById("chat-window").classList.remove("flex"); document.getElementById("empty-state").classList.remove("hidden"); document.getElementById("call-btn").classList.add("hidden"); } delete msgCache[convId]; await loadChats(); } catch (e) { console.error("deleteConversation error:", e); } } function renderChatList() { const q = document.getElementById("sidebar-search").value.toLowerCase(); const list = document.getElementById("chat-list"); list.innerHTML = ""; if (activeTab === "dms") { let filtered = conversations; if (q) filtered = filtered.filter(c => c.other_user.display_name.toLowerCase().includes(q) || c.other_user.username.includes(q)); filtered.forEach(conv => { const isActive = currentChat && currentChat.type === "dm" && currentChat.id === conv.id; const div = document.createElement("div"); div.className = `sidebar-item flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all mx-1 mb-0.5 ${isActive ? "active" : ""}`; div.onclick = () => openDmChat(conv); div.innerHTML = `
${conv.other_user.display_name[0].toUpperCase()}

${escHtml(conv.other_user.display_name)}

@${conv.other_user.username}

${conv.unread_count > 0 ? `${conv.unread_count}` : ""} `; list.appendChild(div); }); } else { let filtered = groups; if (q) filtered = filtered.filter(g => g.name.toLowerCase().includes(q)); filtered.forEach(group => { const isActive = currentChat && currentChat.type === "group" && currentChat.id === group.id; const div = document.createElement("div"); div.className = `sidebar-item flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all mx-1 mb-0.5 ${isActive ? "active" : ""}`; div.onclick = () => openGroupChat(group); div.innerHTML = `
${group.name[0].toUpperCase()}

${escHtml(group.name)}

${group.member_count} members

`; list.appendChild(div); }); } } // ── Open Chats ──────────────────────────────────────────────────────────────── async function openDmChat(conv) { // Get full conversation with messages const full = await apiFetch(`/chat/conversations?target_user_id=${conv.other_user.id}`, token, "POST"); currentChat = { type: "dm", id: full.id, other_user: full.other_user }; // Show header document.getElementById("chat-avatar").textContent = full.other_user.display_name[0].toUpperCase(); document.getElementById("chat-avatar").style.background = full.other_user.avatar_color; document.getElementById("chat-name").textContent = full.other_user.display_name; document.getElementById("chat-sub").textContent = "@" + full.other_user.username; document.getElementById("call-btn").classList.remove("hidden"); document.getElementById("group-settings-btn").classList.add("hidden"); document.getElementById("empty-state").classList.add("hidden"); document.getElementById("chat-window").classList.remove("hidden"); document.getElementById("chat-window").classList.add("flex"); // Decrypt and cache messages const decrypted = await Promise.all(full.messages.map(async m => { if (!privateKey) return { ...m, plaintext: "[No key]" }; try { const plain = await Crypto.decryptWithPrivateKey(m.ciphertext, privateKey); return { ...m, plaintext: plain }; } catch { return { ...m, plaintext: "[Encrypted message]" }; } })); msgCache[full.id] = decrypted; renderMessages(decrypted, full.other_user); renderChatList(); document.getElementById("msg-input").focus(); sendReadReceipt(full.id); } async function openGroupChat(group) { const full = await apiFetch(`/chat/groups/${group.id}`); currentGroupData = full; currentChat = { type: "group", id: full.id, name: full.name, members: full.members }; document.getElementById("chat-avatar").textContent = full.name[0].toUpperCase(); document.getElementById("chat-avatar").style.background = full.avatar_color; document.getElementById("chat-name").textContent = full.name; document.getElementById("chat-sub").textContent = `${full.members.length} members`; document.getElementById("call-btn").classList.add("hidden"); document.getElementById("call-dropdown").classList.add("hidden"); document.getElementById("group-settings-btn").classList.remove("hidden"); document.getElementById("empty-state").classList.add("hidden"); document.getElementById("chat-window").classList.remove("hidden"); document.getElementById("chat-window").classList.add("flex"); // Decrypt messages const decrypted = await Promise.all(full.messages.map(async m => { if (!privateKey || !m.encrypted_aes_key) return { ...m, plaintext: "[Encrypted message]" }; try { const aesKey = await Crypto.unwrapAesKey(m.encrypted_aes_key, privateKey); const plain = await Crypto.decryptWithAes(m.ciphertext, m.iv, aesKey); return { ...m, plaintext: plain }; } catch { return { ...m, plaintext: "[Encrypted message]" }; } })); groupMsgCache[full.id] = decrypted; renderMessages(decrypted, null, true); renderChatList(); document.getElementById("msg-input").focus(); } // ── Render Messages ─────────────────────────────────────────────────────────── function renderMessages(messages, otherUser, isGroup = false) { const container = document.getElementById("messages"); container.innerHTML = ""; messages.forEach(m => appendMessage(m, otherUser, isGroup)); scrollToBottom(); } function appendMessage(m, otherUser, isGroup = false) { const container = document.getElementById("messages"); const isMe = m.sender_id === me.id; const div = document.createElement("div"); div.className = `flex ${isMe ? "justify-end" : "justify-start"} gap-2 fade-in`; const time = timeAgo(m.created_at); const text = m.plaintext || "[Encrypted message]"; if (isGroup && !isMe) { div.innerHTML = `
${escHtml(m.sender_name)}
${escHtml(text)}
${time}
`; } else if (isMe) { const readIcon = m.is_read ? '✓✓' : ''; div.innerHTML = `
${escHtml(text)}
${time}${readIcon}
`; } else { div.innerHTML = `
${escHtml(text)}
${time}
`; } container.appendChild(div); } function scrollToBottom() { const m = document.getElementById("messages"); m.scrollTop = m.scrollHeight; } // ── Send Message ────────────────────────────────────────────────────────────── async function sendMessage() { const input = document.getElementById("msg-input"); const text = input.value.trim(); if (!text || !currentChat || !ws || ws.readyState !== WebSocket.OPEN) return; input.value = ""; input.style.height = "auto"; if (currentChat.type === "dm") { await sendDm(text); } else { await sendGroupMsg(text); } } async function sendDm(text) { const other = currentChat.other_user; // Get recipient's public key (cached or fetch) if (!pubKeyCache[other.id]) { if (other.public_key) { pubKeyCache[other.id] = await Crypto.importPublicKey(other.public_key); } else { const data = await apiFetch(`/chat/users/${other.id}/public_key`); pubKeyCache[other.id] = await Crypto.importPublicKey(data.public_key); } } // Also encrypt for self (so we can read our own messages) let myPubKey = pubKeyCache[me.id]; if (!myPubKey && me.public_key) { myPubKey = await Crypto.importPublicKey(me.public_key); pubKeyCache[me.id] = myPubKey; } const ciphertextForRecipient = await Crypto.encryptForRecipient(text, pubKeyCache[other.id]); const ciphertextForSender = myPubKey ? await Crypto.encryptForRecipient(text, myPubKey) : ciphertextForRecipient; const payload = { type: "dm", ciphertext_for_recipient: ciphertextForRecipient, ciphertext_for_sender: ciphertextForSender }; if (currentChat.id) { payload.conversation_id = currentChat.id; } else { payload.target_user_id = other.id; } ws.send(JSON.stringify(payload)); // Show message immediately const optimistic = { id: Date.now(), sender_id: me.id, plaintext: text, created_at: new Date().toISOString(), is_read: false }; if (currentChat.id) { if (!msgCache[currentChat.id]) msgCache[currentChat.id] = []; msgCache[currentChat.id].push(optimistic); } appendMessage(optimistic, other); scrollToBottom(); } async function sendGroupMsg(text) { // Get all members' public keys const members = currentGroupData?.members || currentChat.members; const aesKey = await Crypto.generateAesKey(); const { ciphertext, iv } = await Crypto.encryptWithAes(text, aesKey); const encrypted_aes_keys = {}; for (const m of members) { const uid = m.user.id; if (!pubKeyCache[uid]) { if (m.user.public_key) { pubKeyCache[uid] = await Crypto.importPublicKey(m.user.public_key); } else { try { const data = await apiFetch(`/chat/users/${uid}/public_key`); if (data.public_key) pubKeyCache[uid] = await Crypto.importPublicKey(data.public_key); } catch {} } } if (pubKeyCache[uid]) { encrypted_aes_keys[uid] = await Crypto.wrapAesKey(aesKey, pubKeyCache[uid]); } } ws.send(JSON.stringify({ type: "group", group_id: currentChat.id, ciphertext, iv, encrypted_aes_keys })); } function handleKey(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); } // Auto resize const ta = e.target; ta.style.height = "auto"; ta.style.height = Math.min(ta.scrollHeight, 128) + "px"; } function handleTyping() { if (!currentChat || !ws || ws.readyState !== WebSocket.OPEN) return; if (currentChat.type === "dm") { ws.send(JSON.stringify({ type: "typing", target_id: currentChat.other_user.id, conversation_id: currentChat.id, is_typing: true })); clearTimeout(typingTimeout); typingTimeout = setTimeout(() => { ws.send(JSON.stringify({ type: "typing", target_id: currentChat.other_user.id, conversation_id: currentChat.id, is_typing: false })); }, 2000); } } // ── New Chat ────────────────────────────────────────────────────────────────── function showNewChat() { document.getElementById("modal-new-chat").classList.remove("hidden"); document.getElementById("search-user-input").value = ""; document.getElementById("user-search-results").innerHTML = ""; setTimeout(() => document.getElementById("search-user-input").focus(), 100); } let searchTimeout = null; async function searchUsers() { const q = document.getElementById("search-user-input").value.trim(); clearTimeout(searchTimeout); if (!q) { document.getElementById("user-search-results").innerHTML = ""; return; } searchTimeout = setTimeout(async () => { const users = await apiFetch(`/chat/users/search?q=${encodeURIComponent(q)}`); const el = document.getElementById("user-search-results"); el.innerHTML = users.map(u => `
${u.display_name[0].toUpperCase()}

${escHtml(u.display_name)}

@${u.username}

`).join(""); }, 300); } function showChatHeader(user) { document.getElementById("chat-avatar").textContent = user.display_name[0].toUpperCase(); document.getElementById("chat-avatar").style.background = user.avatar_color; document.getElementById("chat-name").textContent = user.display_name; document.getElementById("chat-sub").textContent = "@" + user.username; document.getElementById("call-btn").classList.remove("hidden"); document.getElementById("call-dropdown").classList.add("hidden"); document.getElementById("group-settings-btn").classList.add("hidden"); } async function startDmWith(userId, user) { closeModal("modal-new-chat"); setTab("dms"); currentChat = { type: "dm", id: null, other_user: user }; showChatHeader(user); document.getElementById("empty-state").classList.add("hidden"); document.getElementById("chat-window").classList.remove("hidden"); document.getElementById("chat-window").classList.add("flex"); document.getElementById("messages").innerHTML = ""; document.getElementById("msg-input").focus(); } // ── New Group ───────────────────────────────────────────────────────────────── function showNewGroup() { selectedGroupMembers = []; document.getElementById("group-name-input").value = ""; document.getElementById("group-desc-input").value = ""; document.getElementById("group-search-input").value = ""; document.getElementById("group-search-results").innerHTML = ""; document.getElementById("group-members-list").innerHTML = ""; document.getElementById("modal-new-group").classList.remove("hidden"); } async function searchGroupUsers() { const q = document.getElementById("group-search-input").value.trim(); if (!q) { document.getElementById("group-search-results").innerHTML = ""; return; } const users = await apiFetch(`/chat/users/search?q=${encodeURIComponent(q)}`); const el = document.getElementById("group-search-results"); el.innerHTML = users.filter(u => !selectedGroupMembers.find(m => m.id === u.id)).map(u => `
${u.display_name[0].toUpperCase()}
${escHtml(u.display_name)} @${u.username}
`).join(""); } function addGroupMember(user) { if (!selectedGroupMembers.find(m => m.id === user.id)) { selectedGroupMembers.push(user); } document.getElementById("group-search-input").value = ""; document.getElementById("group-search-results").innerHTML = ""; renderGroupMemberChips(); } function removeGroupMember(userId) { selectedGroupMembers = selectedGroupMembers.filter(m => m.id !== userId); renderGroupMemberChips(); } function renderGroupMemberChips() { document.getElementById("group-members-list").innerHTML = selectedGroupMembers.map(u => `
${escHtml(u.display_name)}
`).join(""); } async function createGroup() { const name = document.getElementById("group-name-input").value.trim(); const description = document.getElementById("group-desc-input").value.trim(); if (!name) return; const group = await apiFetch("/chat/groups", token, "POST", { name, description, member_ids: selectedGroupMembers.map(m => m.id) }); closeModal("modal-new-group"); setTab("groups"); await loadChats(); await openGroupChat(group); } // ── Group Settings ──────────────────────────────────────────────────────────── async function showGroupSettings() { if (!currentGroupData) return; const full = await apiFetch(`/chat/groups/${currentGroupData.id}`); currentGroupData = full; document.getElementById("gs-name").textContent = full.name; document.getElementById("gs-members").innerHTML = full.members.map(m => { const isMe = m.user.id === me.id; return `
${m.user.display_name[0].toUpperCase()}

${escHtml(m.user.display_name)}${isMe ? ' (You)' : ''}

@${m.user.username}

`; }).join(""); document.getElementById("gs-add-section").style.display = "block"; document.getElementById("gs-search-input").value = ""; document.getElementById("gs-search-results").innerHTML = ""; document.getElementById("modal-group-settings").classList.remove("hidden"); } async function searchGsUsers() { const q = document.getElementById("gs-search-input").value.trim(); if (!q) { document.getElementById("gs-search-results").innerHTML = ""; return; } const users = await apiFetch(`/chat/users/search?q=${encodeURIComponent(q)}`); const currentIds = currentGroupData.members.map(m => m.user.id); const el = document.getElementById("gs-search-results"); el.innerHTML = users.filter(u => !currentIds.includes(u.id)).map(u => `
${u.display_name[0].toUpperCase()}
${escHtml(u.display_name)}
`).join(""); } async function addToGroup(userId) { await apiFetch(`/chat/groups/${currentGroupData.id}/members`, token, "POST", { user_id: userId }); await showGroupSettings(); } async function removeFromGroup(userId) { try { await apiFetch(`/chat/groups/${currentGroupData.id}/members/${userId}`, token, "DELETE"); } catch (e) { console.error("removeFromGroup error:", e); return; } if (userId === me.id) { closeModal("modal-group-settings"); currentChat = null; currentGroupData = null; document.getElementById("chat-window").classList.add("hidden"); document.getElementById("chat-window").classList.remove("flex"); document.getElementById("empty-state").classList.remove("hidden"); document.getElementById("call-btn").classList.add("hidden"); await loadChats(); } else { await showGroupSettings(); } } // ── Modals ──────────────────────────────────────────────────────────────────── function closeModal(id) { document.getElementById(id).classList.add("hidden"); } // Close on backdrop click document.addEventListener("click", (e) => { ["modal-new-chat", "modal-new-group", "modal-settings", "modal-group-settings", "modal-call"].forEach(id => { const el = document.getElementById(id); if (e.target === el) el.classList.add("hidden"); }); // Close call dropdown when clicking outside const dd = document.getElementById("call-dropdown"); const btn = document.getElementById("call-btn"); if (dd && !dd.classList.contains("hidden") && !dd.contains(e.target) && !btn.contains(e.target)) { dd.classList.add("hidden"); } }); // ── Utils ───────────────────────────────────────────────────────────────────── function timeAgo(dateStr) { const now = Date.now(); const then = new Date(dateStr).getTime(); const diff = now - then; const mins = Math.floor(diff / 60000); if (mins < 1) return "now"; if (mins < 60) return mins + "m ago"; const hrs = Math.floor(mins / 60); if (hrs < 24) return hrs + "h ago"; const days = Math.floor(hrs / 24); if (days < 7) return days + "d ago"; return new Date(dateStr).toLocaleDateString([], { month: "short", day: "numeric" }); } function escHtml(str) { return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); }