/** * chat.js – SexyChat Frontend Logic (Phase 2) * * Features: * - Socket.io with JWT reconnect * - AES-GCM Encryption (via crypto.js) * - PM Persistence & History * - Violet AI (Transit-encrypted) * - Trial/Paywall management */ "use strict"; const socket = io({ autoConnect: false, auth: { token: localStorage.getItem("sexychat_token") } }); const state = { username: null, isAdmin: false, isRegistered: false, hasAiAccess: false, aiMessagesUsed: 0, currentRoom: "lobby", pms: {}, // room -> { username, key, messages: [] } nicklist: [], ignoredUsers: new Set(), cryptoKey: null, // derived from password isSidebarOpen: window.innerWidth > 768, authMode: "guest" }; const AI_BOT_NAME = "Violet"; const AI_FREE_LIMIT = 3; // ── Selectors ───────────────────────────────────────────────────────────── const $ = (id) => document.getElementById(id); const joinScreen = $("join-screen"); const chatScreen = $("chat-screen"); const joinForm = $("join-form"); const usernameInput = $("username-input"); const passwordInput = $("password-input"); const emailInput = $("email-input"); const modPassword = $("mod-password-input"); const joinBtn = $("join-btn"); const joinError = $("join-error"); const authTabs = document.querySelectorAll(".auth-tab"); const sidebar = $("nicklist-sidebar"); const nicklist = $("nicklist"); const tabBar = $("tab-bar"); const panels = $("panels"); const messageForm = $("message-form"); const messageInput = $("message-input"); const logoutBtn = $("logout-btn"); const pmModal = $("pm-modal"); const paywallModal = $("paywall-modal"); const contextMenu = $("context-menu"); const trialBadge = $("violet-trial-badge"); const violetTyping = $("violet-typing"); const settingsModal = $("settings-modal"); // ── Auth & Init ─────────────────────────────────────────────────────────── // Toggle Auth Modes authTabs.forEach(tab => { tab.addEventListener("click", () => { authTabs.forEach(t => t.classList.remove("active")); tab.classList.add("active"); state.authMode = tab.dataset.mode; // UI visibility based on mode const isAuth = state.authMode !== "guest"; const isReg = state.authMode === "register"; document.querySelectorAll(".auth-only").forEach(el => el.classList.toggle("hidden", !isAuth)); document.querySelectorAll(".register-only").forEach(el => el.classList.toggle("hidden", !isReg)); usernameInput.placeholder = isAuth ? "Username" : "Choose your nickname"; passwordInput.required = isAuth; }); }); // Join the Room joinForm.addEventListener("submit", async (e) => { e.preventDefault(); const username = usernameInput.value.trim(); const password = passwordInput.value.trim(); const email = emailInput.value.trim(); const modPw = modPassword.value.trim(); if (!username) return; if (state.authMode !== "guest" && !password) return; joinBtn.disabled = true; joinBtn.innerText = "Connecting..."; // Derive Encryption Key if password provided if (password) { try { state.cryptoKey = await SexyChato.deriveKey(password, username); } catch (err) { console.error("Key derivation failed", err); } } socket.connect(); socket.emit("join", { mode: state.authMode, username, password, email, mod_password: modPw }); }); // Handle Token Restore on Load window.addEventListener("DOMContentLoaded", () => { const token = localStorage.getItem("sexychat_token"); if (token) { // Auto-restore session from stored JWT joinBtn.disabled = true; joinBtn.innerText = "Restoring session..."; socket.connect(); socket.emit("join", { mode: "restore" }); // If restore fails, reset the form so user can log in manually const restoreTimeout = setTimeout(() => { joinBtn.disabled = false; joinBtn.innerText = "Enter the Room"; }, 5000); const origJoined = socket.listeners("joined"); socket.once("joined", () => clearTimeout(restoreTimeout)); socket.once("error", () => { clearTimeout(restoreTimeout); joinBtn.disabled = false; joinBtn.innerText = "Enter the Room"; }); } }); // ── Socket Events ────────────────────────────────────────────────────────── socket.on("joined", (data) => { state.username = data.username; state.isAdmin = data.is_admin; state.isRegistered = data.is_registered; state.hasAiAccess = data.has_ai_access; state.aiMessagesUsed = data.ai_messages_used; state.email = data.email || null; if (data.token) localStorage.setItem("sexychat_token", data.token); if (data.ignored_list) state.ignoredUsers = new Set(data.ignored_list); $("my-username-badge").innerText = state.username; joinScreen.classList.add("hidden"); chatScreen.classList.remove("hidden"); updateVioletBadge(); }); socket.on("error", (data) => { joinError.innerText = data.msg; joinBtn.disabled = false; joinBtn.innerText = "Enter the Room"; if (data.msg.includes("Session expired")) { localStorage.removeItem("sexychat_token"); } }); socket.on("nicklist", (data) => { state.nicklist = data.users; renderNicklist(); $("user-count-badge").innerText = `${data.users.length} online`; }); socket.on("system", (data) => { addMessage("lobby", { system: true, text: data.msg, ts: data.ts }); }); socket.on("message", (data) => { if (state.ignoredUsers.has(data.username)) return; addMessage("lobby", { sender: data.username, text: data.text, ts: data.ts, isAdmin: data.is_admin, isRegistered: data.is_registered, sent: data.username === state.username }); }); // ── Private Messaging ───────────────────────────────────────────────────── socket.on("pm_invite", async (data) => { if (state.pms[data.room]) return; // Already accepted // Store the room key for later use if (data.room_key) { state._pendingRoomKeys = state._pendingRoomKeys || {}; state._pendingRoomKeys[data.room] = data.room_key; } $("pm-modal-title").innerText = `${data.from} wants to whisper with you privately.`; pmModal.classList.remove("hidden"); $("pm-accept-btn").onclick = () => { socket.emit("pm_accept", { room: data.room }); openPMTab(data.from, data.room, data.room_key); pmModal.classList.add("hidden"); }; $("pm-decline-btn").onclick = () => pmModal.classList.add("hidden"); }); socket.on("pm_ready", (data) => { openPMTab(data.with, data.room, data.room_key); }); async function openPMTab(otherUser, room, roomKeyB64) { if (state.pms[room]) { switchTab(room); return; } state.pms[room] = { username: otherUser, messages: [], sharedKey: null }; // Import the server-provided room key for user-to-user PMs const isViolet = otherUser.toLowerCase() === "violet"; if (!isViolet && roomKeyB64) { try { state.pms[room].sharedKey = await SexyChato.importKeyBase64(roomKeyB64); } catch (err) { console.error("Failed to import room key", err); } } // Create Tab let title = `👤 ${otherUser}`; if (otherUser.toLowerCase() === "violet") title = `🤖 Violet`; const tab = document.createElement("button"); tab.className = "tab-btn"; tab.dataset.room = room; tab.id = `tab-${room}`; tab.innerHTML = `${title}`; tab.onclick = () => switchTab(room); tabBar.appendChild(tab); // Create Panel const panel = document.createElement("div"); panel.className = "panel"; panel.id = `panel-${room}`; panel.dataset.room = room; panel.innerHTML = `
${otherUser.toLowerCase() === 'violet' ? `` : ''} `; panels.appendChild(panel); switchTab(room); // Load History if registered if (state.isRegistered && state.cryptoKey) { try { const resp = await fetch(`/api/pm/history?with=${encodeURIComponent(otherUser)}`, { headers: { "Authorization": `Bearer ${localStorage.getItem("sexychat_token")}` } }); const histData = await resp.json(); // Use room_key from history response if we don't have one yet if (!isViolet && histData.room_key && !state.pms[room].sharedKey) { try { state.pms[room].sharedKey = await SexyChato.importKeyBase64(histData.room_key); } catch (err) { console.error("Failed to import history room key", err); } } // Pick the right decryption key: room key for users, personal key for Violet const decryptKey = isViolet ? state.cryptoKey : (state.pms[room].sharedKey || state.cryptoKey); if (histData.messages) { for (const m of histData.messages) { try { const plain = await SexyChato.decrypt(decryptKey, m.ciphertext, m.nonce); addMessage(room, { sender: m.from_me ? state.username : otherUser, text: plain, ts: m.ts, sent: m.from_me }); } catch (err) { addMessage(room, { sender: m.from_me ? state.username : otherUser, text: "[Could not decrypt message]", ts: m.ts, sent: m.from_me }); } } } } catch (err) { console.error("Failed to load PM history", err); } } } socket.on("pm_message", async (data) => { if (state.ignoredUsers.has(data.from)) return; let text = data.text; if (data.ciphertext) { // Pick the right key: room shared key for user-to-user, personal key for Violet const pm = state.pms[data.room]; const decryptKey = pm?.sharedKey || state.cryptoKey; if (decryptKey) { try { text = await SexyChato.decrypt(decryptKey, data.ciphertext, data.nonce); } catch (err) { text = "[Encrypted Message - Could not decrypt]"; } } else { text = "[Encrypted Message - No key available]"; } } addMessage(data.room, { sender: data.from, text: text, ts: data.ts, sent: data.from === state.username }); if (state.currentRoom !== data.room) { const tab = $(`tab-${data.room}`); if (tab) tab.classList.add("unread"); } }); // ── Violet AI Logic ─────────────────────────────────────────────────────── socket.on("violet_typing", (data) => { const room = data.room || "lobby"; const indicator = $(`typing-${room}`); if (indicator) { indicator.classList.toggle("hidden", !data.busy); } }); socket.on("ai_response", async (data) => { if (data.error === "ai_limit_reached") { paywallModal.classList.remove("hidden"); return; } state.aiMessagesUsed = data.ai_messages_used; state.hasAiAccess = data.has_ai_access; updateVioletBadge(); const room = data.room || "ai-violet"; let text = data.text || "[Decryption Error]"; if (data.ciphertext && state.cryptoKey) { text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce); } addMessage(room, { sender: AI_BOT_NAME, text: text, ts: data.ts || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), sent: false }); }); socket.on("ai_unlock", (data) => { state.hasAiAccess = true; updateVioletBadge(); paywallModal.classList.add("hidden"); addMessage("ai-violet", { system: true, text: data.msg }); }); socket.on("ignore_status", (data) => { if (data.ignored) state.ignoredUsers.add(data.target); else state.ignoredUsers.delete(data.target); renderNicklist(); }); function updateVioletBadge() { if (!trialBadge) return; if (state.hasAiAccess) { trialBadge.classList.add("hidden"); } else { const left = AI_FREE_LIMIT - state.aiMessagesUsed; trialBadge.innerText = Math.max(0, left); trialBadge.classList.toggle("hidden", left <= 0 && state.aiMessagesUsed < AI_FREE_LIMIT); if (left <= 0) { trialBadge.innerText = "!"; // Paywall indicator } } } // ── UI Actions ──────────────────────────────────────────────────────────── messageForm.addEventListener("submit", async (e) => { e.preventDefault(); const text = messageInput.value.trim(); if (!text) return; if (state.currentRoom === "lobby") { socket.emit("message", { text }); } else if (state.currentRoom.startsWith("pm:")) { const isVioletRoom = state.currentRoom.toLowerCase().endsWith(":violet"); // /reset command in Violet PM clears conversation memory if (isVioletRoom && text.toLowerCase() === "/reset") { socket.emit("violet_reset"); messageInput.value = ""; messageInput.style.height = "auto"; return; } if (isVioletRoom) { if (state.isRegistered && state.cryptoKey) { // AI Transit Encryption PM Flow const transitKeyB64 = await SexyChato.exportKeyBase64(state.cryptoKey); const encrypted = await SexyChato.encrypt(state.cryptoKey, text); socket.emit("pm_message", { room: state.currentRoom, ciphertext: encrypted.ciphertext, nonce: encrypted.nonce, transit_key: transitKeyB64 }); } else { // Guest/admin plaintext fallback socket.emit("pm_message", { room: state.currentRoom, text }); } } else if (state.isRegistered && state.cryptoKey) { // User-to-user encrypted PM: use the shared room key if available const pm = state.pms[state.currentRoom]; const encryptKey = pm?.sharedKey || state.cryptoKey; const encrypted = await SexyChato.encrypt(encryptKey, text); socket.emit("pm_message", { room: state.currentRoom, ciphertext: encrypted.ciphertext, nonce: encrypted.nonce }); } else { // Guest PM (Plaintext) socket.emit("pm_message", { room: state.currentRoom, text }); } } messageInput.value = ""; messageInput.style.height = "auto"; }); // Enter to send, Shift+Enter for newline messageInput.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey && prefs.enterToSend) { e.preventDefault(); messageForm.requestSubmit(); } }); // Auto-expand textarea messageInput.addEventListener("input", () => { messageInput.style.height = "auto"; messageInput.style.height = (messageInput.scrollHeight) + "px"; }); function switchTab(room) { state.currentRoom = room; document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); document.querySelectorAll(".panel").forEach(p => p.classList.remove("active")); const tab = $(`tab-${room}`); if (tab) { tab.classList.add("active"); tab.classList.remove("unread"); } $(`panel-${room}`).classList.add("active"); // Scroll to bottom const box = $(`messages-${room}`); if (box) box.scrollTop = box.scrollHeight; } function formatTs(ts) { if (!ts || prefs.timeFormat === "24h") return ts || ""; // Convert HH:MM (24h) to 12h const parts = (ts || "").match(/^(\d{1,2}):(\d{2})$/); if (!parts) return ts; let h = parseInt(parts[1], 10); const m = parts[2]; const ampm = h >= 12 ? "PM" : "AM"; h = h % 12 || 12; return `${h}:${m} ${ampm}`; } function addMessage(room, msg) { const list = $(`messages-${room}`); if (!list) return; const div = document.createElement("div"); if (msg.system) { div.className = "msg msg-system"; div.innerHTML = msg.text.replace(/\*\*(.*?)\*\*/g, "$1"); } else { div.className = `msg ${msg.sent ? "msg-sent" : "msg-received"}`; div.innerHTML = `