(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 = `
`;
$('#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 = `
${state.verifyToken ? '
Log in to finish email verification.
' : ''}
${state.loginChallenge ? '
Enter the code from your login 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() ? `
` : ''}
${voice ? `` : ''}
${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.
${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 = '';
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)}
${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);