/** * 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"); // ── 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) { // We have a token, notify the join screen but wait for user to click "Enter" // to derive crypto key if they want to. Actually, for UX, if we have a token // we can try a "restore" join which might skip password entry. // But for encryption, we NEED that password to derive the key. // Let's keep it simple: if you have a token, you still need to log in to // re-derive your E2E key. } }); // ── 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; 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(); if (data.system_msg) { addMessage("lobby", { system: true, text: data.system_msg }); } }); 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", (data) => { if (state.pms[data.room]) return; // Already accepted $("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); pmModal.classList.add("hidden"); }; $("pm-decline-btn").onclick = () => pmModal.classList.add("hidden"); }); socket.on("pm_ready", (data) => { openPMTab(data.with, data.room); }); async function openPMTab(otherUser, room) { if (state.pms[room]) { switchTab(room); return; } state.pms[room] = { username: otherUser, messages: [] }; // 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=${otherUser}`, { headers: { "Authorization": `Bearer ${localStorage.getItem("sexychat_token")}` } }); const data = await resp.json(); if (data.messages) { for (const m of data.messages) { const plain = await SexyChato.decrypt(state.cryptoKey, m.ciphertext, m.nonce); addMessage(room, { sender: m.from_me ? state.username : otherUser, text: plain, 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 && state.cryptoKey) { try { text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce); } catch (err) { text = "[Encrypted Message - Click to login/derive key]"; } } 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(); let text = "[Decryption Error]"; if (data.ciphertext && state.cryptoKey) { text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce); } addMessage("ai-violet", { sender: AI_BOT_NAME, text: text, 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 (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().includes(":violet"); if (isVioletRoom) { if (!state.isRegistered || !state.cryptoKey) { addMessage(state.currentRoom, { system: true, text: "You must be logged in to chat with Violet." }); } else { // 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 if (state.isRegistered && state.cryptoKey) { // E2E PM Flow const encrypted = await SexyChato.encrypt(state.cryptoKey, 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"; }); // 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 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 = `
${msg.ts} ${msg.sender}
${escapeHTML(msg.text)}
`; } list.appendChild(div); list.scrollTop = list.scrollHeight; } function renderNicklist() { nicklist.innerHTML = ""; state.nicklist.forEach(u => { const li = document.createElement("li"); const isIgnored = state.ignoredUsers.has(u.username); const isUnverified = u.is_registered && !u.is_verified; li.innerHTML = ` ${u.is_admin ? ' ' : ''} ${u.username} ${u.is_registered ? '' : ''} ${isIgnored ? ' (ignored)' : ''} ${isUnverified ? ' (unverified)' : ''} `; li.oncontextmenu = (e) => showContextMenu(e, u); li.onclick = () => { if (u.username !== state.username) socket.emit("pm_open", { target: u.username }); }; nicklist.appendChild(li); }); } function showContextMenu(e, user) { e.preventDefault(); if (user.username === state.username) return; // Position menu contextMenu.style.left = `${e.pageX}px`; contextMenu.style.top = `${e.pageY}px`; contextMenu.classList.remove("hidden"); // Configure items const pmItem = contextMenu.querySelector('[data-action="pm"]'); const ignoreItem = contextMenu.querySelector('[data-action="ignore"]'); const unignoreItem = contextMenu.querySelector('[data-action="unignore"]'); const verifyItem = contextMenu.querySelector('[data-action="verify"]'); const modItems = contextMenu.querySelectorAll(".mod-item"); const isIgnored = state.ignoredUsers.has(user.username); const isUnverified = user.is_registered && !user.is_verified; ignoreItem.classList.toggle("hidden", !state.isRegistered || isIgnored); unignoreItem.classList.toggle("hidden", !state.isRegistered || !isIgnored); // Verify option only for admins if target is unverified const showVerify = state.isAdmin && isUnverified; if (verifyItem) verifyItem.classList.toggle("hidden", !showVerify); modItems.forEach(el => { if (el.dataset.action !== "verify") { el.classList.toggle("hidden", !state.isAdmin); } }); // Cleanup previous listeners const newMenu = contextMenu.cloneNode(true); contextMenu.replaceWith(newMenu); // Add new listeners newMenu.querySelectorAll(".menu-item").forEach(item => { item.onclick = () => { const action = item.dataset.action; executeMenuAction(action, user.username); newMenu.classList.add("hidden"); }; }); } function executeMenuAction(action, target) { switch(action) { case "pm": socket.emit("pm_open", { target }); break; case "ignore": socket.emit("user_ignore", { target }); break; case "unignore": socket.emit("user_unignore", { target }); break; case "kick": socket.emit("mod_kick", { target }); break; case "ban": socket.emit("mod_ban", { target }); break; case "kickban": socket.emit("mod_kickban", { target }); break; case "verify": socket.emit("mod_verify", { target }); break; } } // Global click to hide context menu window.addEventListener("click", (e) => { const menu = $("context-menu"); if (menu && !menu.contains(e.target)) { menu.classList.add("hidden"); } }); // ── Admin Tools ─────────────────────────────────────────────────────────── window.modKick = (u) => socket.emit("mod_kick", { target: u }); window.modBan = (u) => socket.emit("mod_ban", { target: u }); window.modMute = (u) => socket.emit("mod_mute", { target: u }); // ── Modals & Misc ────────────────────────────────────────────────────────── $("sidebar-toggle").onclick = () => { sidebar.classList.toggle("open"); }; $("tab-ai-violet").onclick = () => switchTab("ai-violet"); $("tab-lobby").onclick = () => switchTab("lobby"); $("close-paywall").onclick = () => paywallModal.classList.add("hidden"); $("unlock-btn").onclick = async () => { // Generate dummy secret for the stub endpoint // In production, this would redirect to a real payment gateway (Stripe) const secret = "change-me-payment-webhook-secret"; const token = localStorage.getItem("sexychat_token"); try { const resp = await fetch("/api/payment/success", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` }, body: JSON.stringify({ secret }) }); const res = await resp.json(); if (res.status === "ok") { // socket event should handle UI unlock, but we can optimistically update state.hasAiAccess = true; updateVioletBadge(); paywallModal.classList.add("hidden"); } } catch (err) { alert("Payment simulation failed."); } }; logoutBtn.onclick = () => { localStorage.removeItem("sexychat_token"); location.reload(); }; function escapeHTML(str) { const p = document.createElement("p"); p.textContent = str; return p.innerHTML; }