aprhodite/static/chat.js

633 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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) {
// 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;
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 = `<span>${title}</span>`;
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 = `
<div id="messages-${room}" class="messages" role="log"></div>
${otherUser.toLowerCase() === 'violet' ? `<div id="typing-${room}" class="typing-indicator hidden">Violet is typing...</div>` : ''}
`;
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();
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 (!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");
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) {
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 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, "<strong>$1</strong>");
} else {
div.className = `msg ${msg.sent ? "msg-sent" : "msg-received"}`;
div.innerHTML = `
<div class="msg-meta">${msg.ts} ${msg.sender}</div>
<div class="msg-bubble">${escapeHTML(msg.text)}</div>
`;
}
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 = `
<span class="${isUnverified ? 'unverified' : ''}">
${u.is_admin ? '<span class="mod-star">★</span> ' : ''}
<span class="${isIgnored ? 'dimmed' : ''}">${u.username}</span>
${u.is_registered ? '<span class="reg-mark">✔</span>' : ''}
${isIgnored ? ' <small>(ignored)</small>' : ''}
${isUnverified ? ' <small>(unverified)</small>' : ''}
</span>
`;
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);
}
});
// Store target for click handler (uses event delegation below)
contextMenu._targetUser = user.username;
// Remove old inline onclick handlers and re-bind
contextMenu.querySelectorAll(".menu-item").forEach(item => {
item.onclick = () => {
const action = item.dataset.action;
executeMenuAction(action, contextMenu._targetUser);
contextMenu.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 is created dynamically when user opens Violet PM
$("tab-lobby").onclick = () => switchTab("lobby");
$("close-paywall").onclick = () => paywallModal.classList.add("hidden");
$("unlock-btn").onclick = async () => {
// In production, this redirects to a real payment gateway (Stripe Checkout).
// The server-side webhook will unlock AI access after payment confirmation.
// For now, show a placeholder message.
alert("Payment integration coming soon. Contact the administrator to unlock Violet.");
};
logoutBtn.onclick = () => {
localStorage.removeItem("sexychat_token");
location.reload();
};
function escapeHTML(str) {
const p = document.createElement("p");
p.textContent = str;
return p.innerHTML;
}