581 lines
19 KiB
JavaScript
581 lines
19 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|