aprhodite/static/chat.js

581 lines
19 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) {
// 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();
});
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 = `<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=${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().endsWith(":violet");
if (isVioletRoom) {
// 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, "<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);
}
});
// 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;
}