(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, groups: [], billing: null, view: ['chat', 'groups', 'archive', 'account'].includes(initialView) ? initialView : 'chat', groupId: null, lastId: 0, pollTimer: null, pollController: null, polling: false, pollRequested: false, pollGeneration: 0, groupsAccess: null, groupSeen: {}, groupUnread: {}, 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: [], queuePlaying: false, autoplayWarningShown: false, sending: false } }; let root; 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-v4.js')); return script ? script.src.replace(/\/assets\/js\/cyberchat-v4\.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 groupsAvailable() { return state.config.groups?.enabled !== false && state.groupsAccess !== false; } function applyGroupsAvailability(enabled, groups = [], platformEnabled = enabled) { const next = enabled !== false; const changed = groupsAvailable() !== next; state.groupsAccess = next; state.config.groups = { ...(state.config.groups || {}), enabled: platformEnabled !== false }; if (next) { if (changed) syncGroups(groups); } else { state.groups = []; state.groupSeen = {}; state.groupUnread = {}; state.groupId = null; if (state.view === 'groups') state.view = 'chat'; } return changed; } function disableGroups(message = '', platformEnabled = state.config.groups?.enabled !== false) { applyGroupsAvailability(false, [], platformEnabled); if (message) toast(message); app(); } 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 }, security: { min_password_length: 4 }, voice: { enabled: true, max_duration_seconds: 60 }, ui: { app_title: 'CYBERCHAT', app_subtitle: 'SECURE CHANNEL' } })); 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() { const [groups, billing] = await Promise.all([ groupsAvailable() ? api('groups.php', null, 'action=list').catch(() => ({ groups: [] })) : Promise.resolve({ groups: [], enabled: false, platform_enabled: false }), api('billing.php', null, 'action=status').catch(() => null) ]); state.groupsAccess = groups.enabled !== false; state.config.groups = { ...(state.config.groups || {}), enabled: groups.platform_enabled !== false }; state.groups = groupsAvailable() ? (groups.groups || []) : []; state.groupSeen = {}; state.groupUnread = {}; state.groups.forEach(group => { state.groupSeen[group.id] = Number(group.latest_message_id || 0); }); state.billing = billing; 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() { if (!groupsAvailable() && state.view === 'groups') state.view = 'chat'; $('#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 === 'groups' && groupsAvailable()) groupsView(); else if (state.view === 'archive') archiveView(); else accountView(); } function groupOptions() { return '' + state.groups.map(group => `` ).join(''); } function syncGroups(groups, markActivity = false) { if (!Array.isArray(groups)) return; groups.forEach(group => { const latest = Number(group.latest_message_id || 0); const seen = Number(state.groupSeen[group.id] || 0); if (markActivity && latest > seen && Number(group.id) !== Number(state.groupId)) { state.groupUnread[group.id] = true; } if (!(group.id in state.groupSeen)) state.groupSeen[group.id] = latest; }); state.groups = groups; const select = $('#group-select'); if (select) select.innerHTML = groupOptions(); } 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)}
${groupsAvailable() ? `` : ''}
LOADING CHANNEL
${voice ? `
VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s ${feature('auto_voice_send') ? `` : ''} ${feature('auto_voice_play') ? `` : ''}
` : ''}
${max}
`; $('#group-select')?.addEventListener('change', event => { state.groupId = event.target.value ? Number(event.target.value) : null; if (state.groupId !== null) { state.groupUnread[state.groupId] = false; const group = state.groups.find(item => Number(item.id) === state.groupId); if (group) state.groupSeen[state.groupId] = Number(group.latest_message_id || 0); } state.lastId = 0; chat(); }); $('#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.lastId = 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, group_id: state.groupId || '', })).catch(() => ({ error: 'Message send failed. Polling will continue.' })); if (result.groups_enabled === false) { input.value = message; return disableGroups(result.error, result.groups_platform_enabled !== false); } if (result.error) { input.value = message; return toast(result.error); } appendMessage(result.message); requestPoll(); } function startPoll() { stopPoll(); const generation = state.pollGeneration; schedulePoll(generation, 0); } function schedulePoll(generation, delay = state.config.chat?.poll_interval_ms || 2000) { if (generation !== state.pollGeneration || state.view !== 'chat') return; if (state.pollTimer) clearTimeout(state.pollTimer); state.pollTimer = setTimeout(async () => { state.pollTimer = null; await poll(generation); if (generation !== state.pollGeneration || state.view !== 'chat') return; const delay = state.pollRequested ? 0 : state.config.chat?.poll_interval_ms || 2000; state.pollRequested = false; schedulePoll(generation, delay); }, Math.max(0, delay)); } function requestPoll() { if (state.view !== 'chat') return; if (state.polling) { state.pollRequested = true; return; } schedulePoll(state.pollGeneration, 0); } function stopPoll() { if (state.pollTimer) clearTimeout(state.pollTimer); state.pollController?.abort(); state.pollTimer = null; state.pollController = null; state.polling = false; state.pollRequested = false; state.pollGeneration++; } async function poll(generation = state.pollGeneration) { if (state.polling || state.view !== 'chat' || generation !== state.pollGeneration) return; const requestedGroup = state.groupId; const requestedSince = state.lastId; const controller = new AbortController(); const timeoutMs = Math.max(1000, Number(state.config.chat?.poll_timeout_ms) || 12000); const timeout = setTimeout(() => controller.abort(), timeoutMs); state.pollController = controller; state.polling = true; try { const query = new URLSearchParams({ action: 'poll', since: requestedSince, group_id: requestedGroup || '' }); const result = await api('messages.php', { signal: controller.signal }, query.toString()).catch(() => null); if (generation !== state.pollGeneration || Number(state.groupId || 0) !== Number(requestedGroup || 0)) return; if (result && !result.error && Number(result.group_id || 0) === Number(requestedGroup || 0)) { const availabilityChanged = applyGroupsAvailability( result.groups_enabled !== false, result.groups || [], result.groups_platform_enabled !== false ); if (availabilityChanged) { toast(groupsAvailable() ? 'Private groups are now available' : 'Private groups have been disabled', 'success'); app(); return; } $('#cc-conn-dot').className = 'cc-conn-dot online'; $('#cc-online-count').textContent = result.online; if (groupsAvailable()) syncGroups(result.groups, true); if (requestedSince === 0) renderMessages(result.messages || []); else (result.messages || []).forEach(message => appendMessage(message, true)); if (result.messages?.length) { state.lastId = Math.max(state.lastId, ...result.messages.map(message => Number(message.id) || 0)); } if (requestedGroup !== null) { state.groupUnread[requestedGroup] = false; state.groupSeen[requestedGroup] = Math.max(Number(state.groupSeen[requestedGroup] || 0), Number(state.lastId || 0)); } const recording = $('#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...' : ''; } else { $('#cc-conn-dot').className = 'cc-conn-dot offline'; if (result?.error && requestedGroup !== null) await recoverGroupAccess(requestedGroup, generation); } } finally { clearTimeout(timeout); if (state.pollController === controller) state.pollController = null; if (generation === state.pollGeneration) state.polling = false; } } async function recoverGroupAccess(groupId, generation) { const result = await api('groups.php', null, 'action=list').catch(() => null); if (!result || result.error || generation !== state.pollGeneration) return; if (result.enabled === false) { disableGroups( result.platform_enabled === false ? 'Private groups have been disabled' : 'Private groups are not available on your tier', result.platform_enabled !== false ); return; } syncGroups(result.groups || []); if (!state.groups.some(group => Number(group.id) === Number(groupId))) { state.groupId = null; state.lastId = 0; toast('Your group access changed; returned to the public lobby'); chat(); } } function renderMessages(messages) { const list = $('#messages'); list.innerHTML = messages.length ? '' : '
NO MESSAGES IN THIS CHANNEL TODAY
'; messages.forEach(appendMessage); if (messages.length) { state.lastId = Math.max(state.lastId, ...messages.map(message => Number(message.id) || 0)); } list.scrollTop = list.scrollHeight; } function appendMessage(message, incoming = false) { const list = $('#messages'); if (!list || list.querySelector(`[data-id="${message.id}"]`)) return; list.querySelector('.cc-loading-msg, .cc-empty-day')?.remove(); const item = document.createElement('div'); item.className = 'cc-msg' + (message.username === state.user.username ? ' cc-msg-own' : '') + ' cc-msg-new'; item.dataset.id = message.id; 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}`; list.appendChild(item); bindAudioPlayers(item); if (incoming && message.message_type === 'voice' && message.username !== state.user.username) { enqueueVoiceAutoplay(item); } list.scrollTop = list.scrollHeight; } 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(); state.voice.queuePlaying = true; let started = false; let settled = false; const done = () => { if (settled) return; settled = true; state.voice.queuePlaying = false; 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; state.voice.playQueue = []; if (!state.voice.autoplayWarningShown) { state.voice.autoplayWarningShown = true; toast('Browser blocked auto play. Press play once to enable audio.'); } }); } async function recording(active) { if (!feature('recording_status')) return; await api('messages.php', form({ action: 'recording', active: active ? '1' : '0', group_id: state.groupId || '' })).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('group_id', state.groupId || ''); 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.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false); if (result.error) return toast(result.error); appendMessage(result.message); discardVoice(); requestPoll(); } function groupsView() { if (!groupsAvailable()) { state.view = 'chat'; renderView(); return; } const limit = Number(feature('group_member_limit', 2)); $('#cc-view').innerHTML = `

GROUPS

Up to ${limit} people per group, including you.

CREATE GROUP

${state.groups.length ? state.groups.map(groupCard).join('') : '
No private groups yet.
'}
`; $('#group-create').addEventListener('click', createGroup); $$('[data-open]').forEach(button => button.addEventListener('click', () => { state.groupId = Number(button.dataset.open); state.groupUnread[state.groupId] = false; const group = state.groups.find(item => Number(item.id) === state.groupId); if (group) state.groupSeen[state.groupId] = Number(group.latest_message_id || 0); state.view = 'chat'; renderView(); })); $$('[data-delete]').forEach(button => button.addEventListener('click', () => deleteGroup(Number(button.dataset.delete)))); $$('[data-add]').forEach(button => button.addEventListener('click', () => addMember(Number(button.dataset.add)))); $$('[data-remove]').forEach(button => button.addEventListener('click', () => { const [groupId, userId] = button.dataset.remove.split(':').map(Number); removeMember(groupId, userId); })); api('groups.php', null, 'action=list').then(result => { if (result.error || state.view !== 'groups') return; if (result.enabled === false) { disableGroups( result.platform_enabled === false ? 'Private groups have been disabled' : 'Private groups are not available on your tier', result.platform_enabled !== false ); return; } const before = JSON.stringify(state.groups); syncGroups(result.groups || []); if (JSON.stringify(state.groups) !== before) groupsView(); }).catch(() => {}); } function groupCard(group) { const owner = group.owner_id === Number(state.user.id); return `

${esc(group.name)}

${group.members.map(member => `${esc(member.username)} ${member.role === 'owner' ? '(owner)' : ''}${owner && member.role !== 'owner' ? ` ` : ''}`).join('')}
${owner ? `
` : ''}
`; } async function createGroup() { const result = await api('groups.php', form({ action: 'create', name: $('#group-name').value.trim(), members: $('#group-members').value.trim() })); if (result.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false); if (result.error) return toast(result.error); state.groups = result.groups; groupsView(); } async function addMember(groupId) { const result = await api('groups.php', form({ action: 'add_member', group_id: groupId, username: $('#add-' + groupId).value.trim() })); if (result.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false); if (result.error) return toast(result.error); state.groups = result.groups; groupsView(); } async function removeMember(groupId, userId) { const result = await api('groups.php', form({ action: 'remove_member', group_id: groupId, user_id: userId })); if (result.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false); if (result.error) return toast(result.error); state.groups = result.groups; groupsView(); } async function deleteGroup(groupId) { if (!confirm('Delete this group and its messages?')) return; const result = await api('groups.php', form({ action: 'delete', group_id: groupId })); if (result.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false); if (result.error) return toast(result.error); state.groups = result.groups; if (state.groupId === groupId) state.groupId = null; groupsView(); } 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

${Object.entries(state.user.features).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.groups = []; state.billing = null; state.groupId = null; state.groupsAccess = null; state.groupSeen = {}; state.groupUnread = {}; state.voice.playQueue = []; state.voice.queuePlaying = false; auth('login'); } window.CyberChat = { init }; })(window, document);