(function(window, document) { 'use strict'; const initialView = new URLSearchParams(location.search).get('view'); const initialChallenge = new URLSearchParams(location.search).get('login_challenge') || ''; const state = { config: {}, user: null, billing: null, view: ['chat', 'archive', 'account'].includes(initialView) ? initialView : 'chat', cursor: 0, poll: { generation: 0, active: false, controller: null, wakeTimer: null, wakeResolve: null, failures: 0, immediate: false, messages: new Map(), initial: true }, loginChallenge: initialChallenge, verifyToken: new URLSearchParams(location.search).get('verify') || '', voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0, chunks: [], blob: null, duration: 0, url: '', autoSend: false, autoPlay: false, playQueue: [], currentAudio: null, queuePlaying: false, autoplayWarningShown: false, sending: false } }; let root; let wakeListenersBound = false; const displayedFeatures = new Set([ 'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play', 'username_color', 'text_archive_days', 'text_export', 'voice_archive', 'voice_export', 'email_2fa' ]); const $ = (s, c) => (c || root).querySelector(s); const $$ = (s, c) => Array.from((c || root).querySelectorAll(s)); const esc = value => String(value == null ? '' : value) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); function base() { if (window.CYBERCHAT_BASE != null) return String(window.CYBERCHAT_BASE).replace(/\/$/, ''); const script = Array.from(document.scripts).find(item => item.src.includes('cyberchat-app.js')); return script ? script.src.replace(/\/assets\/js\/cyberchat-app\.js.*$/, '') : ''; } function apiUrl(file, query = '') { return base() + '/api/' + file + (query ? '?' + query : ''); } function publicUrl(path) { if (!path) return ''; return /^(https?:)?\/\//.test(path) || path.startsWith('/') ? path : base() + '/' + path; } function form(data) { const body = new FormData(); Object.entries(data).forEach(([key, value]) => body.append(key, value)); return { method: 'POST', body }; } async function api(file, options, query = '') { const response = await fetch(apiUrl(file, query), { credentials: 'include', cache: 'no-store', ...(options || {}), }); const data = await response.json().catch(() => ({ error: 'The server returned an invalid response' })); if (!response.ok && !data.error) data.error = 'Request failed'; return data; } function feature(key, fallback = false) { return state.user?.features && Object.prototype.hasOwnProperty.call(state.user.features, key) ? state.user.features[key] : fallback; } function visibleFeatures() { return Object.entries(state.user?.features || {}) .filter(([key]) => displayedFeatures.has(String(key).toLowerCase())); } function toast(message, type = 'error') { const area = $('#cc-toasts'); if (!area) return; const item = document.createElement('div'); item.className = 'cc-toast ' + type; item.textContent = message; area.appendChild(item); setTimeout(() => { item.classList.add('cc-toast-fade'); setTimeout(() => item.remove(), 350); }, 2800); } function duration(seconds) { const total = Math.max(0, Math.ceil(Number(seconds) || 0)); return Math.floor(total / 60) + ':' + String(total % 60).padStart(2, '0'); } function audioClock(seconds) { const total = Math.max(0, Math.floor(Number(seconds) || 0)); return Math.floor(total / 60) + ':' + String(total % 60).padStart(2, '0'); } function clock(timestamp) { return new Date(Number(timestamp) * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } function money(cents, currency) { return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency || 'USD' }).format(cents / 100); } function linkify(text) { return text.replace(/(https?:\/\/[^\s<>]+)/g, '$1'); } function audioPlayer(src = '', id = '') { return ` 0:00 / --:-- `; } function bindAudioPlayers(scope = root) { $$('.cc-cyber-audio', scope).forEach(player => { if (player.dataset.bound) return; player.dataset.bound = '1'; const audio = $('audio', player); const play = $('.cc-audio-play', player); const seek = $('.cc-audio-seek', player); const time = $('.cc-audio-time', player); const mute = $('.cc-audio-mute', player); const update = () => { const current = Number.isFinite(audio.currentTime) ? audio.currentTime : 0; const total = Number.isFinite(audio.duration) ? audio.duration : 0; seek.value = total ? Math.round((current / total) * 1000) : 0; seek.style.setProperty('--cc-audio-progress', `${seek.value / 10}%`); time.textContent = `${audioClock(current)} / ${total ? duration(total) : '--:--'}`; }; play.addEventListener('click', () => audio.paused ? audio.play().catch(() => toast('Audio playback failed')) : audio.pause()); seek.addEventListener('input', () => { if (Number.isFinite(audio.duration)) audio.currentTime = (Number(seek.value) / 1000) * audio.duration; update(); }); mute.addEventListener('click', () => { audio.muted = !audio.muted; mute.textContent = audio.muted ? 'MUTE' : 'VOL'; mute.classList.toggle('active', audio.muted); }); audio.addEventListener('play', () => { $$('.cc-cyber-audio audio').forEach(other => { if (other !== audio) other.pause(); }); player.classList.add('playing'); play.setAttribute('aria-label', 'Pause audio'); }); audio.addEventListener('pause', () => { player.classList.remove('playing'); play.setAttribute('aria-label', 'Play audio'); }); audio.addEventListener('ended', () => { player.classList.remove('playing'); update(); }); audio.addEventListener('loadedmetadata', update); audio.addEventListener('durationchange', update); audio.addEventListener('timeupdate', update); update(); }); } async function init(selector) { root = typeof selector === 'string' ? document.querySelector(selector) : selector; if (!root) return; root.id = 'cyberchat-root'; state.config = await api('config.php').catch(() => ({ chat: { max_message_length: 500, max_username_length: 12, poll_interval_ms: 2000, poll_timeout_ms: 12000, poll_max_backoff_ms: 15000, max_rendered_messages: 500 }, security: { min_password_length: 4 }, voice: { enabled: true, max_duration_seconds: 60 }, ui: { app_title: 'CYBERCHAT', app_subtitle: 'SECURE CHANNEL' } })); if (!wakeListenersBound) { document.addEventListener('visibilitychange', () => { if (!document.hidden) requestPoll(); }); window.addEventListener('online', requestPoll); wakeListenersBound = true; } try { document.body.classList.toggle('cc-light', localStorage.getItem('cc_theme') === 'light'); } catch (e) {} shell(); api('stats.php', form({ event: 'visit', path: location.pathname })).catch(() => {}); const check = await api('auth.php', null, 'action=check').catch(() => ({ authenticated: false })); if (check.authenticated) { state.user = check.user; await enter(); } else auth(state.loginChallenge ? '2fa' : 'register'); } function shell() { root.innerHTML = `
--online
`; $('#cc-theme').addEventListener('click', () => { const light = !document.body.classList.contains('cc-light'); document.body.classList.toggle('cc-light', light); try { localStorage.setItem('cc_theme', light ? 'light' : 'dark'); } catch (e) {} }); } function auth(tab) { stopPoll(); const maxUser = state.config.chat?.max_username_length || 12; const minPass = state.config.security?.min_password_length || 4; $('#cc-body').innerHTML = `
IDENTIFY YOURSELF
${state.verifyToken ? '
Log in to finish email verification.
' : ''} ${state.loginChallenge ? '
Enter the code from your login email.
' : ''}

Email is optional for free registration.

Welcome back.

Enter the code sent to your verified email.

`; $$('[data-tab]').forEach(button => button.addEventListener('click', () => switchAuth(button.dataset.tab))); $('#register').addEventListener('click', register); $('#login').addEventListener('click', login); if (state.loginChallenge) $('#two-submit').addEventListener('click', () => verify2fa(state.loginChallenge)); } function switchAuth(name) { $$('[data-tab]').forEach(button => button.classList.toggle('active', button.dataset.tab === name)); $$('[data-form]').forEach(panel => panel.classList.toggle('active', panel.dataset.form === name)); authError(''); } function authError(message) { const item = $('#auth-error'); item.textContent = message; item.classList.toggle('visible', !!message); } async function register() { const username = $('#reg-user').value.trim(); const password = $('#reg-pass').value; if (!username || !password) return authError('Username and password are required'); if (password !== $('#reg-pass2').value) return authError('Passphrases do not match'); const result = await api('auth.php', form({ action: 'register', username, password })); if (result.error) return authError(result.error); state.user = result.user; await enter(); } async function login() { const result = await api('auth.php', form({ action: 'login', username: $('#login-user').value.trim(), password: $('#login-pass').value })); if (result.error) return authError(result.error); if (result.requires_2fa) { state.loginChallenge = result.challenge; switchAuth('2fa'); $('#two-submit').addEventListener('click', () => verify2fa(state.loginChallenge)); return; } state.user = result.user; await enter(); } async function verify2fa(challenge) { const result = await api('auth.php', form({ action: 'verify_2fa', challenge, code: $('#two-code').value.trim() })); if (result.error) return authError(result.error); state.loginChallenge = ''; history.replaceState({}, '', location.pathname); state.user = result.user; await enter(); } async function enter() { state.billing = await api('billing.php', null, 'action=status').catch(() => null); if (feature('auto_voice_play')) { try { const saved = localStorage.getItem('cc_auto_voice_play'); state.voice.autoPlay = saved === null ? state.config.voice?.auto_play_default !== false : saved === '1'; } catch (e) { state.voice.autoPlay = state.config.voice?.auto_play_default !== false; } } else state.voice.autoPlay = false; app(); if (state.verifyToken) { state.view = 'account'; renderView(); await verifyEmail(state.verifyToken, ''); state.verifyToken = ''; history.replaceState({}, '', location.pathname); } } function app() { $('#cc-body').innerHTML = `
`; $$('[data-view]').forEach(button => button.addEventListener('click', () => { state.view = button.dataset.view; renderView(); })); renderView(); } function renderView() { stopPoll(); stopVoice(true); $$('[data-view]').forEach(button => button.classList.toggle('active', button.dataset.view === state.view)); if (state.view === 'chat') chat(); else if (state.view === 'archive') archiveView(); else accountView(); } function chat() { const max = state.config.chat?.max_message_length || 500; const voice = state.config.voice?.enabled !== false && feature('voice_messages', true); $('#cc-view').innerHTML = `
YOU:${esc(state.user.username)} ${esc(state.user.plan.name)}
LOADING CHANNEL
${voice ? `
VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s ${feature('auto_voice_send') ? `` : ''} ${feature('auto_voice_play') ? `` : ''}
` : ''}
${max}
`; $('#message').addEventListener('input', event => $('#count').textContent = max - event.target.value.length); $('#message').addEventListener('keydown', event => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendText(); } }); $('#send').addEventListener('click', sendText); $('#record')?.addEventListener('click', toggleVoice); $('#voice-send')?.addEventListener('click', sendVoice); $('#voice-discard')?.addEventListener('click', discardVoice); $('#auto-send')?.addEventListener('change', event => { state.voice.autoSend = event.target.checked; }); $('#auto-play')?.addEventListener('change', event => { state.voice.autoPlay = event.target.checked; try { localStorage.setItem('cc_auto_voice_play', event.target.checked ? '1' : '0'); } catch (e) {} }); bindAudioPlayers($('#cc-view')); state.cursor = 0; startPoll(); } async function sendText() { const input = $('#message'); const message = input.value.trim(); if (!message) return; input.value = ''; const result = await api('messages.php', form({ action: 'send', message })) .catch(() => ({ error: 'Message send failed. Polling will continue.' })); if (result.error) { input.value = message; return toast(result.error); } mergeMessages([result.message], false); requestPoll(); } function startPoll() { stopPoll(); state.cursor = 0; state.poll.active = true; state.poll.failures = 0; state.poll.immediate = true; state.poll.initial = true; state.poll.messages.clear(); void pollWorker(state.poll.generation); } function requestPoll() { if (state.view !== 'chat' || !state.poll.active) return; state.poll.immediate = true; const wake = state.poll.wakeResolve; if (wake) wake(); } function stopPoll() { state.poll.active = false; state.poll.generation++; state.poll.controller?.abort(); state.poll.controller = null; if (state.poll.wakeTimer) clearTimeout(state.poll.wakeTimer); state.poll.wakeTimer = null; const wake = state.poll.wakeResolve; state.poll.wakeResolve = null; if (wake) wake(); state.poll.immediate = false; state.poll.messages.clear(); resetVoicePlaybackQueue(); } async function pollWorker(generation) { const interval = Math.max(250, Number(state.config.chat?.poll_interval_ms) || 2000); const maxBackoff = Math.max(interval, Number(state.config.chat?.poll_max_backoff_ms) || 15000); while (state.poll.active && state.poll.generation === generation && state.view === 'chat') { state.poll.immediate = false; const result = await pollOnce(generation); if (!state.poll.active || state.poll.generation !== generation || state.view !== 'chat') break; let delay = 0; if (result.ok) { state.poll.failures = 0; if (!result.hasMore && !state.poll.immediate) delay = interval; } else { state.poll.failures++; if (!state.poll.immediate) { delay = Math.min(maxBackoff, 500 * (2 ** Math.min(state.poll.failures - 1, 6))); } } if (delay > 0) await waitForPoll(delay, generation); } } function waitForPoll(delay, generation) { if (!state.poll.active || state.poll.generation !== generation) return Promise.resolve(); return new Promise(resolve => { let settled = false; let timer = null; const finish = () => { if (settled) return; settled = true; if (timer) clearTimeout(timer); if (state.poll.wakeTimer === timer) state.poll.wakeTimer = null; if (state.poll.wakeResolve === finish) state.poll.wakeResolve = null; resolve(); }; timer = setTimeout(finish, Math.max(0, delay)); state.poll.wakeTimer = timer; state.poll.wakeResolve = finish; if (state.poll.immediate) finish(); }); } async function pollOnce(generation) { const requestedSince = state.cursor; const controller = new AbortController(); const timeoutMs = Math.max(1000, Number(state.config.chat?.poll_timeout_ms) || 12000); const timeout = setTimeout(() => controller.abort(), timeoutMs); state.poll.controller = controller; try { const query = new URLSearchParams({ action: 'poll', since: requestedSince }); const result = await api('messages.php', { signal: controller.signal }, query.toString()); if (!state.poll.active || state.poll.generation !== generation || state.view !== 'chat') { return { ok: false, hasMore: false }; } if (!result || result.error || !Array.isArray(result.messages)) { throw new Error(result?.error || 'Invalid poll response'); } $('#cc-conn-dot').className = 'cc-conn-dot online'; $('#cc-online-count').textContent = result.online; const initial = state.poll.initial; const merged = mergeMessages(result.messages, !initial); state.poll.initial = false; const nextCursor = Math.max(requestedSince, merged.maxId); state.cursor = Math.max(state.cursor, nextCursor); const recording = $('#recording'); if (recording) { recording.hidden = !result.recording?.length; recording.textContent = result.recording?.length ? result.recording.map(user => user.username).join(', ') + (result.recording.length === 1 ? ' is' : ' are') + ' recording...' : ''; } return { ok: true, hasMore: Boolean(result.has_more) && nextCursor > requestedSince }; } catch (error) { if (state.poll.active && state.poll.generation === generation) { $('#cc-conn-dot').className = 'cc-conn-dot offline'; } return { ok: false, hasMore: false }; } finally { clearTimeout(timeout); if (state.poll.controller === controller) state.poll.controller = null; } } function normalizeMessage(message) { const id = Number(message?.id); if (!Number.isSafeInteger(id) || id < 1) return null; return { ...message, id }; } function isOwnMessage(message) { const userId = Number(message?.user_id); return Number.isSafeInteger(userId) && userId > 0 ? userId === Number(state.user?.id) : message?.username === state.user?.username; } function messageRenderKey(message) { return JSON.stringify([ message.user_id ?? null, message.username, message.color, message.message, message.message_type, message.voice_url, message.voice_mime, message.voice_duration, message.created_at ]); } function mergeMessages(messages, incoming) { const incomingIds = new Set(); let maxId = state.cursor; for (const rawMessage of Array.isArray(messages) ? messages : []) { const message = normalizeMessage(rawMessage); if (!message) continue; maxId = Math.max(maxId, message.id); const previous = state.poll.messages.get(message.id); state.poll.messages.set(message.id, previous ? { ...previous, ...message } : message); if (!previous && incoming) incomingIds.add(message.id); } reconcileMessages(incomingIds); return { maxId }; } function reconcileMessages(incomingIds = new Set()) { const list = $('#messages'); if (!list) return; trimMessageStore(); list.querySelector('.cc-loading-msg, .cc-empty-day')?.remove(); const sorted = [...state.poll.messages.values()].sort((a, b) => a.id - b.id); const nodes = new Map(); Array.from(list.querySelectorAll('.cc-msg')).forEach(item => { const id = Number(item.dataset.id); if (!Number.isSafeInteger(id) || nodes.has(id)) { $('audio', item)?.pause(); item.remove(); return; } nodes.set(id, item); }); for (const message of sorted) { const renderKey = messageRenderKey(message); let item = nodes.get(message.id); if (!item || item.dataset.renderKey !== renderKey) { const replacement = createMessageElement(message); if (item) { $('audio', item)?.pause(); item.replaceWith(replacement); } item = replacement; nodes.set(message.id, item); } } const retainedIds = new Set(sorted.map(message => message.id)); nodes.forEach((item, id) => { if (retainedIds.has(id)) return; $('audio', item)?.pause(); item.remove(); }); const currentOrder = Array.from(list.querySelectorAll('.cc-msg')).map(item => Number(item.dataset.id)); const desiredOrder = sorted.map(message => message.id); if (currentOrder.length !== desiredOrder.length || currentOrder.some((id, index) => id !== desiredOrder[index])) { desiredOrder.forEach(id => list.appendChild(nodes.get(id))); } sorted.forEach(message => { if (!incomingIds.has(message.id) || isOwnMessage(message)) return; const item = nodes.get(message.id); if (message.message_type === 'voice' && item) enqueueVoiceAutoplay(item); }); if (!sorted.length) { list.innerHTML = '
NO MESSAGES IN THIS CHANNEL TODAY
'; } list.scrollTop = list.scrollHeight; } function createMessageElement(message) { const id = message.id; const item = document.createElement('div'); item.className = 'cc-msg' + (isOwnMessage(message) ? ' cc-msg-own' : '') + ' cc-msg-new'; item.dataset.id = String(id); item.dataset.renderKey = messageRenderKey(message); const content = message.message_type === 'voice' && message.voice_url ? `VOICE ${duration(message.voice_duration)}${audioPlayer(publicUrl(message.voice_url))}` : linkify(esc(message.message)); item.innerHTML = `${clock(message.created_at)} ${esc(message.username)} >${content}`; bindAudioPlayers(item); return item; } function trimMessageStore() { const limit = Math.max(20, Number(state.config.chat?.max_rendered_messages) || 500); const ids = [...state.poll.messages.keys()].sort((a, b) => a - b); ids.slice(0, Math.max(0, ids.length - limit)).forEach(id => state.poll.messages.delete(id)); } function enqueueVoiceAutoplay(item) { if (!feature('auto_voice_play') || !state.voice.autoPlay) return; const audio = $('audio', item); if (!audio) return; state.voice.playQueue.push(audio); playNextQueuedVoice(); } function playNextQueuedVoice() { if (state.voice.queuePlaying || !state.voice.playQueue.length) return; const audio = state.voice.playQueue.shift(); if (!audio?.isConnected) return playNextQueuedVoice(); state.voice.queuePlaying = true; state.voice.currentAudio = audio; let started = false; let settled = false; const done = () => { if (settled) return; settled = true; state.voice.queuePlaying = false; if (state.voice.currentAudio === audio) state.voice.currentAudio = null; playNextQueuedVoice(); }; audio.addEventListener('playing', () => { started = true; }, { once: true }); audio.addEventListener('ended', done, { once: true }); audio.addEventListener('error', done, { once: true }); audio.addEventListener('pause', () => { if (started && !audio.ended) done(); }, { once: true }); audio.play().catch(() => { settled = true; state.voice.queuePlaying = false; if (state.voice.currentAudio === audio) state.voice.currentAudio = null; state.voice.playQueue = []; if (!state.voice.autoplayWarningShown) { state.voice.autoplayWarningShown = true; toast('Browser blocked auto play. Press play once to enable audio.'); } }); } function resetVoicePlaybackQueue() { const current = state.voice.currentAudio; state.voice.playQueue = []; state.voice.currentAudio = null; state.voice.queuePlaying = false; if (current && !current.paused) current.pause(); } async function recording(active) { if (!feature('recording_status')) return; await api('messages.php', form({ action: 'recording', active: active ? '1' : '0' })).catch(() => {}); } async function toggleVoice() { if (state.voice.recorder) return stopVoice(false); discardVoice(); if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Voice recording is not supported'); try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mime = ['audio/webm;codecs=opus', 'audio/webm'].find(type => MediaRecorder.isTypeSupported(type)); if (!mime) { stream.getTracks().forEach(track => track.stop()); return toast('WebM recording is not supported'); } state.voice.stream = stream; state.voice.chunks = []; state.voice.started = Date.now(); const recorder = new MediaRecorder(stream, { mimeType: mime }); recorder.addEventListener('dataavailable', event => { if (event.data.size) state.voice.chunks.push(event.data); }); recorder.addEventListener('stop', () => { if (!recorder._discard) finishVoice(new Blob(state.voice.chunks, { type: mime }), (Date.now() - state.voice.started) / 1000); }, { once: true }); recorder.start(250); state.voice.recorder = recorder; $('#record').classList.add('recording'); $('#record-label').textContent = 'STOP'; recording(true); state.voice.heartbeat = setInterval(() => recording(true), 4000); state.voice.timer = setInterval(voiceTimer, 200); voiceTimer(); } catch (e) { toast(e.name === 'NotAllowedError' ? 'Microphone permission was denied' : 'Could not access microphone'); } } function voiceTimer() { const max = state.config.voice?.max_duration_seconds || 60; const elapsed = Math.min((Date.now() - state.voice.started) / 1000, max); if ($('#voice-status')) $('#voice-status').textContent = `RECORDING ${duration(elapsed)} / ${duration(max)}`; if (elapsed >= max) stopVoice(false); } function stopVoice(discard) { clearInterval(state.voice.timer); clearInterval(state.voice.heartbeat); state.voice.timer = state.voice.heartbeat = null; recording(false); if (state.voice.recorder && state.voice.recorder.state !== 'inactive') { state.voice.recorder._discard = discard; state.voice.recorder.stop(); } state.voice.recorder = null; state.voice.stream?.getTracks().forEach(track => track.stop()); state.voice.stream = null; $('#record')?.classList.remove('recording'); if ($('#record-label')) $('#record-label').textContent = 'RECORD'; } function finishVoice(blob, length) { if (!blob.size || length < 0.25) return toast('Voice clip was too short'); state.voice.blob = blob; state.voice.duration = length; if (state.voice.autoSend && feature('auto_voice_send')) return sendVoice(); if (state.voice.url) URL.revokeObjectURL(state.voice.url); state.voice.url = URL.createObjectURL(blob); $('#voice-preview').src = state.voice.url; $('#voice-preview').load(); $('#voice-review').hidden = false; $('#voice-status').textContent = 'READY ' + duration(length); } function discardVoice() { if (state.voice.recorder) stopVoice(true); if (state.voice.url) URL.revokeObjectURL(state.voice.url); state.voice.url = ''; state.voice.blob = null; state.voice.duration = 0; if ($('#voice-review')) $('#voice-review').hidden = true; } async function sendVoice() { if (!state.voice.blob || state.voice.sending) return; state.voice.sending = true; const body = new FormData(); body.append('action', 'send_voice'); body.append('duration', state.voice.duration.toFixed(2)); body.append('voice', state.voice.blob, 'voice.webm'); const result = await api('messages.php', { method: 'POST', body }) .catch(() => ({ error: 'Voice send failed. Polling will continue.' })); state.voice.sending = false; if (result.error) return toast(result.error); mergeMessages([result.message], false); discardVoice(); requestPoll(); } async function archiveView(search = '') { $('#cc-view').innerHTML = '
LOADING YOUR ARCHIVE
'; const result = await api('archive.php', null, new URLSearchParams({ action: 'list', search }).toString()); if (result.error) return $('#cc-view').innerHTML = `
${esc(result.error)}
`; $('#cc-view').innerHTML = `

MY ARCHIVE

Your own messages for ${result.retention_days} days. ${result.voice_included ? 'Voice included.' : 'Text only on this plan.'}

${result.can_export ? `` : ''}
${result.messages.length ? result.messages.map(archiveRow).join('') : '
No matching messages.
'}
`; bindAudioPlayers($('#cc-view')); $('#archive-submit').addEventListener('click', () => archiveView($('#archive-search').value.trim())); } function archiveRow(message) { const content = message.message_type === 'voice' && message.voice_url ? audioPlayer(publicUrl(message.voice_url)) : esc(message.message); return `
${esc(message.message_type)}
${content}
`; } async function accountView() { if (!state.billing) state.billing = await api('billing.php', null, 'action=status').catch(() => null); const billing = state.billing; const showBilling = billing && (billing.subscriptions_enabled || billing.can_manage); $('#cc-view').innerHTML = `

ACCOUNT

${esc(state.user.username)} / ${esc(state.user.plan.name)}

EMAIL VERIFICATION

${state.user.email_verified ? `Verified: ${esc(state.user.email)}` : 'Email is only required before premium activation.'}

IDENTITY & SECURITY

${feature('username_color') ? `
` : '

Custom username color is not included in this plan.

'} ${feature('email_2fa') ? `` : '

Email two-factor authentication is not included in this plan.

'}
${showBilling ? billingHtml(billing) : ''}

CURRENT FEATURES

${visibleFeatures().map(([key, value]) => `${esc(key.replace(/_/g, ' '))} ${esc(String(value))}`).join('')}
`; $('#logout').addEventListener('click', () => logout(true)); $('#email-send').addEventListener('click', requestEmail); $('#email-verify').addEventListener('click', () => verifyEmail('', $('#email-code').value.trim())); $('#color-save')?.addEventListener('click', saveColor); $('#two-toggle')?.addEventListener('change', saveTwoFactor); $$('[data-plan]').forEach(button => button.addEventListener('click', () => checkout(Number(button.dataset.plan)))); $('#billing-manage')?.addEventListener('click', portal); $('#billing-cancel')?.addEventListener('click', cancelSubscription); } function billingHtml(billing) { const alreadySubscribed = ['active', 'trialing', 'past_due'].includes(state.user.subscription.status); const plans = billing.subscriptions_enabled && !alreadySubscribed ? billing.plans : []; return `

SUBSCRIPTION

Status: ${esc(state.user.subscription.status)}${state.user.subscription.cancel_at_period_end ? ' / cancels at period end' : ''}

${billing.can_manage ? '' : ''} ${state.user.subscription.manageable && !state.user.subscription.cancel_at_period_end ? '' : ''}
${plans.length ? `
${plans.map(plan => `

${esc(plan.name)}

${money(plan.price_cents, plan.currency)}/${esc(plan.interval)}

${esc(plan.description)}

`).join('')}
` : ''} ${billing.subscriptions_enabled && alreadySubscribed ? '

Use the Stripe portal to change plans or payment details.

' : ''} ${!billing.subscriptions_enabled && billing.can_manage ? '

New subscriptions are hidden. Your existing subscription remains manageable.

' : ''}
`; } async function requestEmail() { const result = await api('account.php', form({ action: 'request_email_verification', email: $('#email').value.trim() })); toast(result.error || result.message, result.error ? 'error' : 'success'); } async function verifyEmail(token, code) { const result = await api('account.php', form({ action: 'verify_email', token, code })); if (result.error) return toast(result.error); state.user = result.user; state.billing = await api('billing.php', null, 'action=status').catch(() => state.billing); toast('Email verified', 'success'); accountView(); } async function saveColor() { const result = await api('account.php', form({ action: 'set_color', color: $('#color').value })); if (result.error) return toast(result.error); state.user = result.user; toast('Color updated', 'success'); accountView(); } async function saveTwoFactor(event) { const result = await api('account.php', form({ action: 'set_2fa', enabled: event.target.checked ? '1' : '0' })); if (result.error) { event.target.checked = !event.target.checked; return toast(result.error); } state.user = result.user; toast('Two-factor setting updated', 'success'); } async function checkout(planId) { if (!state.user.email_verified) return toast('Verify an email before premium checkout'); const result = await api('billing.php', form({ action: 'checkout', plan_id: planId })); if (result.error) return toast(result.error); location.href = result.url; } async function portal() { const result = await api('billing.php', form({ action: 'portal' })); if (result.error) return toast(result.error); location.href = result.url; } async function cancelSubscription() { if (!confirm('Cancel this subscription at the end of its billing period?')) return; const result = await api('billing.php', form({ action: 'cancel' })); if (result.error) return toast(result.error); state.user = result.user; state.billing = await api('billing.php', null, 'action=status'); accountView(); } async function logout(request = true) { stopPoll(); stopVoice(true); if (request) await api('auth.php', form({ action: 'logout' })).catch(() => {}); state.user = null; state.billing = null; state.voice.playQueue = []; state.voice.queuePlaying = false; auth('login'); } window.CyberChat = { init }; })(window, document);