Spaces:
Sleeping
Sleeping
| // 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 = ` | |
| <div class="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-semibold shrink-0" style="background:${conv.other_user.avatar_color}"> | |
| ${conv.other_user.display_name[0].toUpperCase()} | |
| </div> | |
| <div class="flex-1 min-w-0"> | |
| <p class="text-sm font-medium truncate">${escHtml(conv.other_user.display_name)}</p> | |
| <p class="text-xs text-gray-500 truncate">@${conv.other_user.username}</p> | |
| </div> | |
| ${conv.unread_count > 0 ? `<span class="w-5 h-5 rounded-full unread-badge text-white text-xs flex items-center justify-center font-medium">${conv.unread_count}</span>` : ""} | |
| <button onclick="deleteConversation(${conv.id}, event)" class="shrink-0 w-6 h-6 rounded-lg text-gray-600 hover:text-red-400 hover:bg-white/10 flex items-center justify-center text-xs transition-colors" title="Delete conversation">β</button> | |
| `; | |
| 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 = ` | |
| <div class="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-semibold shrink-0" style="background:${group.avatar_color}"> | |
| ${group.name[0].toUpperCase()} | |
| </div> | |
| <div class="flex-1 min-w-0"> | |
| <p class="text-sm font-medium truncate">${escHtml(group.name)}</p> | |
| <p class="text-xs text-gray-500">${group.member_count} members</p> | |
| </div> | |
| `; | |
| 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 = ` | |
| <div class="flex flex-col items-start gap-0.5 max-w-[72%]"> | |
| <span class="text-xs text-gray-500 ml-1">${escHtml(m.sender_name)}</span> | |
| <div class="msg-bubble glass rounded-2xl rounded-tl-sm px-4 py-2.5 text-sm text-gray-100">${escHtml(text)}</div> | |
| <span class="text-xs text-gray-500 ml-1">${time}</span> | |
| </div>`; | |
| } else if (isMe) { | |
| const readIcon = m.is_read ? '<span class="text-[10px] text-white/40 ml-1">ββ</span>' : '<span class="text-[10px] text-white/30">β</span>'; | |
| div.innerHTML = ` | |
| <div class="flex flex-col items-end gap-0.5 max-w-[72%]"> | |
| <div class="msg-bubble glass-btn rounded-2xl rounded-tr-sm px-4 py-2.5 text-sm text-white">${escHtml(text)}</div> | |
| <span class="text-xs text-gray-500 flex items-center gap-1">${time}${readIcon}</span> | |
| </div>`; | |
| } else { | |
| div.innerHTML = ` | |
| <div class="flex flex-col items-start gap-0.5 max-w-[72%]"> | |
| <div class="msg-bubble bg-white/8 rounded-2xl rounded-tl-sm px-4 py-2.5 text-sm text-gray-100">${escHtml(text)}</div> | |
| <span class="text-xs text-gray-600 ml-1">${time}</span> | |
| </div>`; | |
| } | |
| 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 => ` | |
| <div onclick="startDmWith(${u.id}, ${JSON.stringify(u).replace(/"/g, """)})" class="flex items-center gap-3 px-3 py-2 rounded-xl cursor-pointer hover:bg-white/5 transition-colors"> | |
| <div class="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-semibold" style="background:${u.avatar_color}">${u.display_name[0].toUpperCase()}</div> | |
| <div> | |
| <p class="text-sm font-medium">${escHtml(u.display_name)}</p> | |
| <p class="text-xs text-gray-500">@${u.username}</p> | |
| </div> | |
| </div> | |
| `).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 => ` | |
| <div onclick='addGroupMember(${JSON.stringify(u)})' class="flex items-center gap-3 px-3 py-2 rounded-xl cursor-pointer hover:bg-white/5 transition-colors"> | |
| <div class="w-7 h-7 rounded-full flex items-center justify-center text-white text-xs font-semibold" style="background:${u.avatar_color}">${u.display_name[0].toUpperCase()}</div> | |
| <div class="text-sm">${escHtml(u.display_name)} <span class="text-gray-500">@${u.username}</span></div> | |
| </div> | |
| `).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 => ` | |
| <div class="flex items-center gap-1 glass rounded-lg px-2 py-1 text-xs text-white/80"> | |
| <span>${escHtml(u.display_name)}</span> | |
| <button onclick="removeGroupMember(${u.id})" class="ml-1 hover:text-white">β</button> | |
| </div> | |
| `).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 `<div class="flex items-center gap-3 px-2 py-2 rounded-lg"> | |
| <div class="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-semibold" style="background:${m.user.avatar_color}">${m.user.display_name[0].toUpperCase()}</div> | |
| <div class="flex-1 min-w-0"> | |
| <p class="text-sm font-medium">${escHtml(m.user.display_name)}${isMe ? ' <span class="text-xs text-gray-500">(You)</span>' : ''}</p> | |
| <p class="text-xs text-gray-500">@${m.user.username}</p> | |
| </div> | |
| <button onclick="removeFromGroup(${m.user.id})" class="text-red-400 hover:text-red-300 text-xs ml-2">${isMe ? 'Leave' : 'Remove'}</button> | |
| </div>`; | |
| }).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 => ` | |
| <div onclick="addToGroup(${u.id})" class="flex items-center gap-3 px-3 py-2 rounded-xl cursor-pointer hover:bg-white/5 transition-colors"> | |
| <div class="w-7 h-7 rounded-full flex items-center justify-center text-white text-xs font-semibold" style="background:${u.avatar_color}">${u.display_name[0].toUpperCase()}</div> | |
| <div class="text-sm">${escHtml(u.display_name)}</div> | |
| </div> | |
| `).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, ">").replace(/"/g, """); | |
| } |