forked from ComputerTech/aprhodite
1010 lines
36 KiB
JavaScript
1010 lines
36 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,
|
||
role: "user",
|
||
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.role = data.role || "user";
|
||
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();
|
||
|
||
// Show admin panel button for mods+
|
||
const adminBtn = $("admin-btn");
|
||
if (adminBtn) adminBtn.classList.toggle("hidden", !state.isAdmin);
|
||
});
|
||
|
||
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();
|
||
|
||
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, "<strong>$1</strong>");
|
||
} else {
|
||
div.className = `msg ${msg.sent ? "msg-sent" : "msg-received"}`;
|
||
div.innerHTML = `
|
||
<div class="msg-meta">${formatTs(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;
|
||
const roleIcon = {root: "👑", admin: "⚔️", mod: "🛡️"}[u.role] || "";
|
||
|
||
li.innerHTML = `
|
||
<span class="${isUnverified ? 'unverified' : ''}">
|
||
${roleIcon ? `<span class="mod-star">${roleIcon}</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 "mute":
|
||
socket.emit("mod_mute", { 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();
|
||
};
|
||
|
||
// ── Settings Panel ─────────────────────────────────────────────────────────
|
||
|
||
// Load saved preferences from localStorage
|
||
const prefs = {
|
||
fontSize: parseInt(localStorage.getItem("sc_fontsize") || "14"),
|
||
timeFormat: localStorage.getItem("sc_timeformat") || "12h",
|
||
enterToSend: localStorage.getItem("sc_enter_send") !== "false",
|
||
sounds: localStorage.getItem("sc_sounds") !== "false",
|
||
theme: localStorage.getItem("sc_theme") || "midnight-purple",
|
||
};
|
||
|
||
// Apply saved font size on load
|
||
document.documentElement.style.setProperty("--chat-font-size", prefs.fontSize + "px");
|
||
// Apply saved theme on load
|
||
document.documentElement.setAttribute("data-theme", prefs.theme);
|
||
|
||
$("settings-btn").onclick = () => {
|
||
// Populate current values
|
||
$("settings-username").textContent = state.username || "—";
|
||
$("settings-email").textContent = state.email || "Not set";
|
||
$("settings-ai-status").textContent = state.hasAiAccess ? "✓ Unlimited" : `Free (${AI_FREE_LIMIT - state.aiMessagesUsed} left)`;
|
||
$("settings-ai-used").textContent = state.aiMessagesUsed.toString();
|
||
|
||
// Show password change only for registered users
|
||
document.querySelectorAll(".registered-only").forEach(el =>
|
||
el.classList.toggle("hidden", !state.isRegistered)
|
||
);
|
||
|
||
// Sync toggle states
|
||
$("settings-fontsize").value = prefs.fontSize;
|
||
$("settings-fontsize-val").textContent = prefs.fontSize + "px";
|
||
document.querySelectorAll("[data-tf]").forEach(b => {
|
||
b.classList.toggle("active", b.dataset.tf === prefs.timeFormat);
|
||
});
|
||
$("settings-enter-send").textContent = prefs.enterToSend ? "On" : "Off";
|
||
$("settings-enter-send").classList.toggle("active", prefs.enterToSend);
|
||
$("settings-sounds").textContent = prefs.sounds ? "On" : "Off";
|
||
$("settings-sounds").classList.toggle("active", prefs.sounds);
|
||
|
||
// Sync theme picker
|
||
document.querySelectorAll(".theme-swatch").forEach(s => {
|
||
s.classList.toggle("active", s.dataset.theme === prefs.theme);
|
||
});
|
||
|
||
settingsModal.classList.remove("hidden");
|
||
};
|
||
|
||
$("close-settings").onclick = () => settingsModal.classList.add("hidden");
|
||
settingsModal.addEventListener("click", (e) => {
|
||
if (e.target === settingsModal) settingsModal.classList.add("hidden");
|
||
});
|
||
|
||
// Tab switching
|
||
document.querySelectorAll(".settings-tab").forEach(tab => {
|
||
tab.addEventListener("click", () => {
|
||
document.querySelectorAll(".settings-tab").forEach(t => t.classList.remove("active"));
|
||
document.querySelectorAll(".settings-pane").forEach(p => p.classList.remove("active"));
|
||
tab.classList.add("active");
|
||
$("stab-" + tab.dataset.stab).classList.add("active");
|
||
});
|
||
});
|
||
|
||
// Font size slider
|
||
$("settings-fontsize").addEventListener("input", (e) => {
|
||
const val = parseInt(e.target.value);
|
||
prefs.fontSize = val;
|
||
localStorage.setItem("sc_fontsize", val);
|
||
$("settings-fontsize-val").textContent = val + "px";
|
||
document.documentElement.style.setProperty("--chat-font-size", val + "px");
|
||
});
|
||
|
||
// Theme picker
|
||
document.querySelectorAll(".theme-swatch").forEach(swatch => {
|
||
swatch.addEventListener("click", () => {
|
||
const theme = swatch.dataset.theme;
|
||
prefs.theme = theme;
|
||
localStorage.setItem("sc_theme", theme);
|
||
document.documentElement.setAttribute("data-theme", theme);
|
||
document.querySelectorAll(".theme-swatch").forEach(s => s.classList.remove("active"));
|
||
swatch.classList.add("active");
|
||
});
|
||
});
|
||
|
||
// Timestamp format toggle
|
||
document.querySelectorAll("[data-tf]").forEach(btn => {
|
||
btn.addEventListener("click", () => {
|
||
document.querySelectorAll("[data-tf]").forEach(b => b.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
prefs.timeFormat = btn.dataset.tf;
|
||
localStorage.setItem("sc_timeformat", btn.dataset.tf);
|
||
});
|
||
});
|
||
|
||
// Enter-to-send toggle
|
||
$("settings-enter-send").addEventListener("click", () => {
|
||
prefs.enterToSend = !prefs.enterToSend;
|
||
localStorage.setItem("sc_enter_send", prefs.enterToSend);
|
||
$("settings-enter-send").textContent = prefs.enterToSend ? "On" : "Off";
|
||
$("settings-enter-send").classList.toggle("active", prefs.enterToSend);
|
||
});
|
||
|
||
// Sounds toggle
|
||
$("settings-sounds").addEventListener("click", () => {
|
||
prefs.sounds = !prefs.sounds;
|
||
localStorage.setItem("sc_sounds", prefs.sounds);
|
||
$("settings-sounds").textContent = prefs.sounds ? "On" : "Off";
|
||
$("settings-sounds").classList.toggle("active", prefs.sounds);
|
||
});
|
||
|
||
// Password change
|
||
$("settings-change-pw").addEventListener("click", () => {
|
||
const oldPw = $("settings-old-pw").value;
|
||
const newPw = $("settings-new-pw").value;
|
||
const confirmPw = $("settings-confirm-pw").value;
|
||
const msgEl = $("settings-pw-msg");
|
||
|
||
if (!oldPw || !newPw) {
|
||
msgEl.textContent = "Please fill in both fields.";
|
||
msgEl.className = "settings-msg error";
|
||
msgEl.classList.remove("hidden");
|
||
return;
|
||
}
|
||
if (newPw.length < 6) {
|
||
msgEl.textContent = "New password must be at least 6 characters.";
|
||
msgEl.className = "settings-msg error";
|
||
msgEl.classList.remove("hidden");
|
||
return;
|
||
}
|
||
if (newPw !== confirmPw) {
|
||
msgEl.textContent = "Passwords do not match.";
|
||
msgEl.className = "settings-msg error";
|
||
msgEl.classList.remove("hidden");
|
||
return;
|
||
}
|
||
|
||
socket.emit("change_password", { old_password: oldPw, new_password: newPw });
|
||
});
|
||
|
||
socket.on("password_changed", (data) => {
|
||
const msgEl = $("settings-pw-msg");
|
||
if (data.success) {
|
||
msgEl.textContent = "✓ Password updated successfully.";
|
||
msgEl.className = "settings-msg success";
|
||
$("settings-old-pw").value = "";
|
||
$("settings-new-pw").value = "";
|
||
$("settings-confirm-pw").value = "";
|
||
} else {
|
||
msgEl.textContent = data.msg || "Failed to change password.";
|
||
msgEl.className = "settings-msg error";
|
||
}
|
||
msgEl.classList.remove("hidden");
|
||
});
|
||
|
||
// Violet memory reset from settings
|
||
$("settings-violet-reset").addEventListener("click", () => {
|
||
socket.emit("violet_reset");
|
||
$("settings-violet-reset").textContent = "Memory Cleared!";
|
||
setTimeout(() => { $("settings-violet-reset").textContent = "Reset Violet Memory"; }, 2000);
|
||
});
|
||
|
||
// Premium button (placeholder)
|
||
$("settings-upgrade").addEventListener("click", () => {
|
||
alert("Premium subscriptions are coming soon! Stay tuned.");
|
||
});
|
||
|
||
function escapeHTML(str) {
|
||
const p = document.createElement("p");
|
||
p.textContent = str;
|
||
return p.innerHTML;
|
||
}
|
||
|
||
// ── Role updated (live notification) ──────────────────────────────────────
|
||
|
||
socket.on("role_updated", (data) => {
|
||
state.role = data.role;
|
||
state.isAdmin = ["mod", "admin", "root"].includes(data.role);
|
||
const adminBtn = $("admin-btn");
|
||
if (adminBtn) adminBtn.classList.toggle("hidden", !state.isAdmin);
|
||
});
|
||
|
||
// ── Admin Panel ───────────────────────────────────────────────────────────
|
||
|
||
const adminModal = $("admin-modal");
|
||
const ROLE_POWER = { user: 0, mod: 1, admin: 2, root: 3 };
|
||
|
||
if ($("admin-btn")) {
|
||
$("admin-btn").onclick = () => {
|
||
adminModal.classList.remove("hidden");
|
||
socket.emit("admin_get_users");
|
||
socket.emit("admin_get_bans");
|
||
socket.emit("admin_get_mutes");
|
||
};
|
||
}
|
||
|
||
$("close-admin").onclick = () => adminModal.classList.add("hidden");
|
||
adminModal.addEventListener("click", (e) => {
|
||
if (e.target === adminModal) adminModal.classList.add("hidden");
|
||
});
|
||
|
||
// Admin tab switching
|
||
document.querySelectorAll(".admin-tab").forEach(tab => {
|
||
tab.addEventListener("click", () => {
|
||
document.querySelectorAll(".admin-tab").forEach(t => t.classList.remove("active"));
|
||
document.querySelectorAll(".admin-pane").forEach(p => p.classList.remove("active"));
|
||
tab.classList.add("active");
|
||
$("atab-" + tab.dataset.atab).classList.add("active");
|
||
});
|
||
});
|
||
|
||
// Admin toast helper
|
||
function adminToast(msg) {
|
||
const t = $("admin-toast");
|
||
t.textContent = msg;
|
||
t.classList.remove("hidden");
|
||
setTimeout(() => t.classList.add("hidden"), 2500);
|
||
}
|
||
|
||
socket.on("admin_action_ok", (data) => adminToast(data.msg));
|
||
|
||
// ── Users pane ───────────────────────────────────────────────────────────
|
||
|
||
let adminUserCache = [];
|
||
|
||
socket.on("admin_users", (data) => {
|
||
adminUserCache = data.users;
|
||
renderAdminUsers(adminUserCache);
|
||
});
|
||
|
||
$("admin-user-search").addEventListener("input", (e) => {
|
||
const q = e.target.value.toLowerCase();
|
||
renderAdminUsers(adminUserCache.filter(u => u.username.toLowerCase().includes(q)));
|
||
});
|
||
|
||
function renderAdminUsers(users) {
|
||
const list = $("admin-user-list");
|
||
list.innerHTML = "";
|
||
const myPower = ROLE_POWER[state.role] || 0;
|
||
|
||
users.forEach(u => {
|
||
const row = document.createElement("div");
|
||
row.className = "admin-user-row";
|
||
|
||
const canEditRole = myPower > ROLE_POWER[u.role] && myPower >= ROLE_POWER.admin;
|
||
const canVerify = myPower >= ROLE_POWER.mod;
|
||
const canToggleAI = myPower >= ROLE_POWER.admin && u.role !== "root";
|
||
|
||
// Build role selector
|
||
let roleHTML = "";
|
||
if (canEditRole) {
|
||
const opts = ["user", "mod"];
|
||
if (myPower >= ROLE_POWER.root) opts.push("admin");
|
||
roleHTML = `<select class="au-select" data-uid="${u.id}" data-action="set-role">
|
||
${opts.map(r => `<option value="${r}" ${u.role === r ? "selected" : ""}>${r}</option>`).join("")}
|
||
</select>`;
|
||
} else {
|
||
roleHTML = `<span class="au-role ${u.role}">${u.role}</span>`;
|
||
}
|
||
|
||
row.innerHTML = `
|
||
<span class="au-name">${escapeHTML(u.username)}</span>
|
||
${roleHTML}
|
||
<span class="au-badges">
|
||
${u.online ? '<span class="au-badge online">online</span>' : ''}
|
||
${u.is_verified ? '<span class="au-badge verified">verified</span>' : '<span class="au-badge unverified">unverified</span>'}
|
||
${u.has_ai_access ? '<span class="au-badge ai-on">AI</span>' : ''}
|
||
</span>
|
||
<span class="au-actions">
|
||
${canVerify ? `<button class="au-btn" data-uid="${u.id}" data-action="verify">${u.is_verified ? 'Unverify' : 'Verify'}</button>` : ''}
|
||
${canToggleAI ? `<button class="au-btn" data-uid="${u.id}" data-action="toggle-ai">${u.has_ai_access ? 'Revoke AI' : 'Grant AI'}</button>` : ''}
|
||
</span>
|
||
`;
|
||
list.appendChild(row);
|
||
});
|
||
|
||
// Event delegation for actions
|
||
list.onclick = (e) => {
|
||
const btn = e.target.closest("[data-action]");
|
||
if (!btn) return;
|
||
const uid = parseInt(btn.dataset.uid);
|
||
const action = btn.dataset.action;
|
||
if (action === "verify") socket.emit("admin_verify_user", { user_id: uid });
|
||
else if (action === "toggle-ai") socket.emit("admin_toggle_ai", { user_id: uid });
|
||
};
|
||
list.onchange = (e) => {
|
||
const sel = e.target.closest("[data-action='set-role']");
|
||
if (!sel) return;
|
||
const uid = parseInt(sel.dataset.uid);
|
||
socket.emit("admin_set_role", { user_id: uid, role: sel.value });
|
||
};
|
||
}
|
||
|
||
// ── Bans pane ────────────────────────────────────────────────────────────
|
||
|
||
socket.on("admin_bans", (data) => {
|
||
const list = $("admin-ban-list");
|
||
const empty = $("admin-no-bans");
|
||
list.innerHTML = "";
|
||
empty.classList.toggle("hidden", data.bans.length > 0);
|
||
|
||
data.bans.forEach(b => {
|
||
const row = document.createElement("div");
|
||
row.className = "admin-ban-row";
|
||
row.innerHTML = `
|
||
<div>
|
||
<span class="ab-name">${escapeHTML(b.username)}</span>
|
||
${b.ip ? `<small style="color:var(--text-dim);margin-left:8px">${b.ip}</small>` : ''}
|
||
<small style="color:var(--text-dim);margin-left:8px">${b.created_at}</small>
|
||
</div>
|
||
<button class="au-btn danger" data-bid="${b.id}">Unban</button>
|
||
`;
|
||
list.appendChild(row);
|
||
});
|
||
|
||
list.onclick = (e) => {
|
||
const btn = e.target.closest("[data-bid]");
|
||
if (!btn) return;
|
||
socket.emit("admin_unban", { ban_id: parseInt(btn.dataset.bid) });
|
||
btn.closest(".admin-ban-row").remove();
|
||
};
|
||
});
|
||
|
||
// ── Mutes pane ───────────────────────────────────────────────────────────
|
||
|
||
socket.on("admin_mutes", (data) => {
|
||
const list = $("admin-mute-list");
|
||
const empty = $("admin-no-mutes");
|
||
list.innerHTML = "";
|
||
empty.classList.toggle("hidden", data.mutes.length > 0);
|
||
|
||
data.mutes.forEach(m => {
|
||
const row = document.createElement("div");
|
||
row.className = "admin-mute-row";
|
||
row.innerHTML = `
|
||
<div>
|
||
<span class="am-name">${escapeHTML(m.username)}</span>
|
||
<small style="color:var(--text-dim);margin-left:8px">${m.created_at}</small>
|
||
</div>
|
||
<button class="au-btn danger" data-mid="${m.id}">Unmute</button>
|
||
`;
|
||
list.appendChild(row);
|
||
});
|
||
|
||
list.onclick = (e) => {
|
||
const btn = e.target.closest("[data-mid]");
|
||
if (!btn) return;
|
||
socket.emit("admin_unmute", { mute_id: parseInt(btn.dataset.mid) });
|
||
btn.closest(".admin-mute-row").remove();
|
||
};
|
||
});
|