/**
* 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 = `${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' ? `Violet is typing...
` : ''}
`;
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 = data.has_ai_access !== undefined ? data.has_ai_access : true;
updateVioletBadge();
paywallModal.classList.add("hidden");
if (data.msg) 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, "$1");
} else {
div.className = `msg ${msg.sent ? "msg-sent" : "msg-received"}`;
div.innerHTML = `
${formatTs(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;
const roleIcon = {root: "👑", admin: "⚔️", mod: "🛡️"}[u.role] || "";
li.innerHTML = `
${roleIcon ? `${roleIcon} ` : ''}
${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);
}
});
// 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);
// Refresh the admin user list so buttons update
socket.emit("admin_get_users");
socket.emit("admin_get_bans");
socket.emit("admin_get_mutes");
});
// ── 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 = ``;
} else {
roleHTML = `${u.role}`;
}
row.innerHTML = `
${escapeHTML(u.username)}
${roleHTML}
${u.online ? 'online' : ''}
${u.is_verified ? 'verified' : 'unverified'}
${u.has_ai_access ? 'AI' : ''}
${canVerify ? `` : ''}
${canToggleAI ? `` : ''}
`;
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 = `
${escapeHTML(b.username)}
${b.ip ? `${b.ip}` : ''}
${b.created_at}
`;
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 = `
${escapeHTML(m.username)}
${m.created_at}
`;
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();
};
});