forked from ComputerTech/aprhodite
642 lines
22 KiB
JavaScript
642 lines
22 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) {
|
||
// Auto-restore session from stored JWT
|
||
joinBtn.disabled = true;
|
||
joinBtn.innerText = "Restoring session...";
|
||
socket.connect();
|
||
socket.emit("join", { mode: "restore" });
|
||
|
||
// If restore fails, reset the form so user can log in manually
|
||
const restoreTimeout = setTimeout(() => {
|
||
joinBtn.disabled = false;
|
||
joinBtn.innerText = "Enter the Room";
|
||
}, 5000);
|
||
|
||
const origJoined = socket.listeners("joined");
|
||
socket.once("joined", () => clearTimeout(restoreTimeout));
|
||
socket.once("error", () => {
|
||
clearTimeout(restoreTimeout);
|
||
joinBtn.disabled = false;
|
||
joinBtn.innerText = "Enter the Room";
|
||
});
|
||
}
|
||
});
|
||
|
||
// ── Socket Events ──────────────────────────────────────────────────────────
|
||
|
||
socket.on("joined", (data) => {
|
||
state.username = data.username;
|
||
state.isAdmin = data.is_admin;
|
||
state.isRegistered = data.is_registered;
|
||
state.hasAiAccess = data.has_ai_access;
|
||
state.aiMessagesUsed = data.ai_messages_used;
|
||
|
||
if (data.token) localStorage.setItem("sexychat_token", data.token);
|
||
if (data.ignored_list) state.ignoredUsers = new Set(data.ignored_list);
|
||
|
||
$("my-username-badge").innerText = state.username;
|
||
joinScreen.classList.add("hidden");
|
||
chatScreen.classList.remove("hidden");
|
||
updateVioletBadge();
|
||
});
|
||
|
||
socket.on("error", (data) => {
|
||
joinError.innerText = data.msg;
|
||
joinBtn.disabled = false;
|
||
joinBtn.innerText = "Enter the Room";
|
||
if (data.msg.includes("Session expired")) {
|
||
localStorage.removeItem("sexychat_token");
|
||
}
|
||
});
|
||
|
||
socket.on("nicklist", (data) => {
|
||
state.nicklist = data.users;
|
||
renderNicklist();
|
||
$("user-count-badge").innerText = `${data.users.length} online`;
|
||
});
|
||
|
||
socket.on("system", (data) => {
|
||
addMessage("lobby", { system: true, text: data.msg, ts: data.ts });
|
||
});
|
||
|
||
socket.on("message", (data) => {
|
||
if (state.ignoredUsers.has(data.username)) return;
|
||
addMessage("lobby", {
|
||
sender: data.username,
|
||
text: data.text,
|
||
ts: data.ts,
|
||
isAdmin: data.is_admin,
|
||
isRegistered: data.is_registered,
|
||
sent: data.username === state.username
|
||
});
|
||
});
|
||
|
||
// ── Private Messaging ─────────────────────────────────────────────────────
|
||
|
||
socket.on("pm_invite", async (data) => {
|
||
if (state.pms[data.room]) return; // Already accepted
|
||
// Store the room key for later use
|
||
if (data.room_key) {
|
||
state._pendingRoomKeys = state._pendingRoomKeys || {};
|
||
state._pendingRoomKeys[data.room] = data.room_key;
|
||
}
|
||
$("pm-modal-title").innerText = `${data.from} wants to whisper with you privately.`;
|
||
pmModal.classList.remove("hidden");
|
||
|
||
$("pm-accept-btn").onclick = () => {
|
||
socket.emit("pm_accept", { room: data.room });
|
||
openPMTab(data.from, data.room, data.room_key);
|
||
pmModal.classList.add("hidden");
|
||
};
|
||
$("pm-decline-btn").onclick = () => pmModal.classList.add("hidden");
|
||
});
|
||
|
||
socket.on("pm_ready", (data) => {
|
||
openPMTab(data.with, data.room, data.room_key);
|
||
});
|
||
|
||
async function openPMTab(otherUser, room, roomKeyB64) {
|
||
if (state.pms[room]) {
|
||
switchTab(room);
|
||
return;
|
||
}
|
||
|
||
state.pms[room] = { username: otherUser, messages: [], sharedKey: null };
|
||
|
||
// Import the server-provided room key for user-to-user PMs
|
||
const isViolet = otherUser.toLowerCase() === "violet";
|
||
if (!isViolet && roomKeyB64) {
|
||
try {
|
||
state.pms[room].sharedKey = await SexyChato.importKeyBase64(roomKeyB64);
|
||
} catch (err) {
|
||
console.error("Failed to import room key", err);
|
||
}
|
||
}
|
||
|
||
// Create Tab
|
||
let title = `👤 ${otherUser}`;
|
||
if (otherUser.toLowerCase() === "violet") title = `🤖 Violet`;
|
||
|
||
const tab = document.createElement("button");
|
||
tab.className = "tab-btn";
|
||
tab.dataset.room = room;
|
||
tab.id = `tab-${room}`;
|
||
tab.innerHTML = `<span>${title}</span>`;
|
||
tab.onclick = () => switchTab(room);
|
||
tabBar.appendChild(tab);
|
||
|
||
// Create Panel
|
||
const panel = document.createElement("div");
|
||
panel.className = "panel";
|
||
panel.id = `panel-${room}`;
|
||
panel.dataset.room = room;
|
||
panel.innerHTML = `
|
||
<div id="messages-${room}" class="messages" role="log"></div>
|
||
${otherUser.toLowerCase() === 'violet' ? `<div id="typing-${room}" class="typing-indicator hidden">Violet is typing...</div>` : ''}
|
||
`;
|
||
panels.appendChild(panel);
|
||
|
||
switchTab(room);
|
||
|
||
// Load History if registered
|
||
if (state.isRegistered && state.cryptoKey) {
|
||
try {
|
||
const resp = await fetch(`/api/pm/history?with=${encodeURIComponent(otherUser)}`, {
|
||
headers: { "Authorization": `Bearer ${localStorage.getItem("sexychat_token")}` }
|
||
});
|
||
const histData = await resp.json();
|
||
|
||
// Use room_key from history response if we don't have one yet
|
||
if (!isViolet && histData.room_key && !state.pms[room].sharedKey) {
|
||
try {
|
||
state.pms[room].sharedKey = await SexyChato.importKeyBase64(histData.room_key);
|
||
} catch (err) {
|
||
console.error("Failed to import history room key", err);
|
||
}
|
||
}
|
||
|
||
// Pick the right decryption key: room key for users, personal key for Violet
|
||
const decryptKey = isViolet ? state.cryptoKey : (state.pms[room].sharedKey || state.cryptoKey);
|
||
|
||
if (histData.messages) {
|
||
for (const m of histData.messages) {
|
||
try {
|
||
const plain = await SexyChato.decrypt(decryptKey, m.ciphertext, m.nonce);
|
||
addMessage(room, {
|
||
sender: m.from_me ? state.username : otherUser,
|
||
text: plain,
|
||
ts: m.ts,
|
||
sent: m.from_me
|
||
});
|
||
} catch (err) {
|
||
addMessage(room, {
|
||
sender: m.from_me ? state.username : otherUser,
|
||
text: "[Could not decrypt message]",
|
||
ts: m.ts,
|
||
sent: m.from_me
|
||
});
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to load PM history", err);
|
||
}
|
||
}
|
||
}
|
||
|
||
socket.on("pm_message", async (data) => {
|
||
if (state.ignoredUsers.has(data.from)) return;
|
||
let text = data.text;
|
||
if (data.ciphertext) {
|
||
// Pick the right key: room shared key for user-to-user, personal key for Violet
|
||
const pm = state.pms[data.room];
|
||
const decryptKey = pm?.sharedKey || state.cryptoKey;
|
||
if (decryptKey) {
|
||
try {
|
||
text = await SexyChato.decrypt(decryptKey, data.ciphertext, data.nonce);
|
||
} catch (err) {
|
||
text = "[Encrypted Message - Could not decrypt]";
|
||
}
|
||
} else {
|
||
text = "[Encrypted Message - No key available]";
|
||
}
|
||
}
|
||
|
||
addMessage(data.room, {
|
||
sender: data.from,
|
||
text: text,
|
||
ts: data.ts,
|
||
sent: data.from === state.username
|
||
});
|
||
|
||
if (state.currentRoom !== data.room) {
|
||
const tab = $(`tab-${data.room}`);
|
||
if (tab) tab.classList.add("unread");
|
||
}
|
||
});
|
||
|
||
// ── Violet AI Logic ───────────────────────────────────────────────────────
|
||
|
||
socket.on("violet_typing", (data) => {
|
||
const room = data.room || "lobby";
|
||
const indicator = $(`typing-${room}`);
|
||
if (indicator) {
|
||
indicator.classList.toggle("hidden", !data.busy);
|
||
}
|
||
});
|
||
|
||
socket.on("ai_response", async (data) => {
|
||
if (data.error === "ai_limit_reached") {
|
||
paywallModal.classList.remove("hidden");
|
||
return;
|
||
}
|
||
|
||
state.aiMessagesUsed = data.ai_messages_used;
|
||
state.hasAiAccess = data.has_ai_access;
|
||
updateVioletBadge();
|
||
|
||
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) {
|
||
e.preventDefault();
|
||
messageForm.requestSubmit();
|
||
}
|
||
});
|
||
|
||
// Auto-expand textarea
|
||
messageInput.addEventListener("input", () => {
|
||
messageInput.style.height = "auto";
|
||
messageInput.style.height = (messageInput.scrollHeight) + "px";
|
||
});
|
||
|
||
function switchTab(room) {
|
||
state.currentRoom = room;
|
||
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
||
document.querySelectorAll(".panel").forEach(p => p.classList.remove("active"));
|
||
|
||
const tab = $(`tab-${room}`);
|
||
if (tab) {
|
||
tab.classList.add("active");
|
||
tab.classList.remove("unread");
|
||
}
|
||
$(`panel-${room}`).classList.add("active");
|
||
|
||
// Scroll to bottom
|
||
const box = $(`messages-${room}`);
|
||
if (box) box.scrollTop = box.scrollHeight;
|
||
}
|
||
|
||
function addMessage(room, msg) {
|
||
const list = $(`messages-${room}`);
|
||
if (!list) return;
|
||
|
||
const div = document.createElement("div");
|
||
if (msg.system) {
|
||
div.className = "msg msg-system";
|
||
div.innerHTML = msg.text.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
|
||
} else {
|
||
div.className = `msg ${msg.sent ? "msg-sent" : "msg-received"}`;
|
||
div.innerHTML = `
|
||
<div class="msg-meta">${msg.ts} ${msg.sender}</div>
|
||
<div class="msg-bubble">${escapeHTML(msg.text)}</div>
|
||
`;
|
||
}
|
||
|
||
list.appendChild(div);
|
||
list.scrollTop = list.scrollHeight;
|
||
}
|
||
|
||
function renderNicklist() {
|
||
nicklist.innerHTML = "";
|
||
state.nicklist.forEach(u => {
|
||
const li = document.createElement("li");
|
||
const isIgnored = state.ignoredUsers.has(u.username);
|
||
const isUnverified = u.is_registered && !u.is_verified;
|
||
|
||
li.innerHTML = `
|
||
<span class="${isUnverified ? 'unverified' : ''}">
|
||
${u.is_admin ? '<span class="mod-star">★</span> ' : ''}
|
||
<span class="${isIgnored ? 'dimmed' : ''}">${u.username}</span>
|
||
${u.is_registered ? '<span class="reg-mark">✔</span>' : ''}
|
||
${isIgnored ? ' <small>(ignored)</small>' : ''}
|
||
${isUnverified ? ' <small>(unverified)</small>' : ''}
|
||
</span>
|
||
`;
|
||
|
||
li.oncontextmenu = (e) => showContextMenu(e, u);
|
||
li.onclick = () => {
|
||
if (u.username !== state.username) socket.emit("pm_open", { target: u.username });
|
||
};
|
||
nicklist.appendChild(li);
|
||
});
|
||
}
|
||
|
||
function showContextMenu(e, user) {
|
||
e.preventDefault();
|
||
if (user.username === state.username) return;
|
||
|
||
// Position menu
|
||
contextMenu.style.left = `${e.pageX}px`;
|
||
contextMenu.style.top = `${e.pageY}px`;
|
||
contextMenu.classList.remove("hidden");
|
||
|
||
// Configure items
|
||
const pmItem = contextMenu.querySelector('[data-action="pm"]');
|
||
const ignoreItem = contextMenu.querySelector('[data-action="ignore"]');
|
||
const unignoreItem = contextMenu.querySelector('[data-action="unignore"]');
|
||
const verifyItem = contextMenu.querySelector('[data-action="verify"]');
|
||
const modItems = contextMenu.querySelectorAll(".mod-item");
|
||
|
||
const isIgnored = state.ignoredUsers.has(user.username);
|
||
const isUnverified = user.is_registered && !user.is_verified;
|
||
|
||
ignoreItem.classList.toggle("hidden", !state.isRegistered || isIgnored);
|
||
unignoreItem.classList.toggle("hidden", !state.isRegistered || !isIgnored);
|
||
|
||
// Verify option only for admins if target is unverified
|
||
const showVerify = state.isAdmin && isUnverified;
|
||
if (verifyItem) verifyItem.classList.toggle("hidden", !showVerify);
|
||
|
||
modItems.forEach(el => {
|
||
if (el.dataset.action !== "verify") {
|
||
el.classList.toggle("hidden", !state.isAdmin);
|
||
}
|
||
});
|
||
|
||
// Store target for click handler (uses event delegation below)
|
||
contextMenu._targetUser = user.username;
|
||
|
||
// Remove old inline onclick handlers and re-bind
|
||
contextMenu.querySelectorAll(".menu-item").forEach(item => {
|
||
item.onclick = () => {
|
||
const action = item.dataset.action;
|
||
executeMenuAction(action, contextMenu._targetUser);
|
||
contextMenu.classList.add("hidden");
|
||
};
|
||
});
|
||
}
|
||
|
||
function executeMenuAction(action, target) {
|
||
switch(action) {
|
||
case "pm":
|
||
socket.emit("pm_open", { target });
|
||
break;
|
||
case "ignore":
|
||
socket.emit("user_ignore", { target });
|
||
break;
|
||
case "unignore":
|
||
socket.emit("user_unignore", { target });
|
||
break;
|
||
case "kick":
|
||
socket.emit("mod_kick", { target });
|
||
break;
|
||
case "ban":
|
||
socket.emit("mod_ban", { target });
|
||
break;
|
||
case "kickban":
|
||
socket.emit("mod_kickban", { target });
|
||
break;
|
||
case "verify":
|
||
socket.emit("mod_verify", { target });
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Global click to hide context menu
|
||
window.addEventListener("click", (e) => {
|
||
const menu = $("context-menu");
|
||
if (menu && !menu.contains(e.target)) {
|
||
menu.classList.add("hidden");
|
||
}
|
||
});
|
||
|
||
// ── Admin Tools ───────────────────────────────────────────────────────────
|
||
|
||
window.modKick = (u) => socket.emit("mod_kick", { target: u });
|
||
window.modBan = (u) => socket.emit("mod_ban", { target: u });
|
||
window.modMute = (u) => socket.emit("mod_mute", { target: u });
|
||
|
||
// ── Modals & Misc ──────────────────────────────────────────────────────────
|
||
|
||
$("sidebar-toggle").onclick = () => {
|
||
sidebar.classList.toggle("open");
|
||
};
|
||
|
||
// tab-ai-violet is created dynamically when user opens Violet PM
|
||
$("tab-lobby").onclick = () => switchTab("lobby");
|
||
|
||
$("close-paywall").onclick = () => paywallModal.classList.add("hidden");
|
||
$("unlock-btn").onclick = async () => {
|
||
// In production, this redirects to a real payment gateway (Stripe Checkout).
|
||
// The server-side webhook will unlock AI access after payment confirmation.
|
||
// For now, show a placeholder message.
|
||
alert("Payment integration coming soon. Contact the administrator to unlock Violet.");
|
||
};
|
||
|
||
logoutBtn.onclick = () => {
|
||
localStorage.removeItem("sexychat_token");
|
||
location.reload();
|
||
};
|
||
|
||
function escapeHTML(str) {
|
||
const p = document.createElement("p");
|
||
p.textContent = str;
|
||
return p.innerHTML;
|
||
}
|