SecureChat / src /static /app.js
ausername-12345
fix GC [no key], camera-off avatar, timestamps, GC remove error handling
e0701ea
// 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, "&quot;")})" 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}