ef7a9a330f
Fixed polling after voice/text sends. Changes include: Stalled polls abort and retry automatically. Polling now schedules after each completed request. Local sends no longer advance the server cursor and skip interleaved messages. Sends trigger immediate reconciliation. API requests bypass browser caches.
869 lines
46 KiB
JavaScript
869 lines
46 KiB
JavaScript
(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, '"').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, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
}
|
|
function audioPlayer(src = '', id = '') {
|
|
return `<span class="cc-cyber-audio">
|
|
<audio ${id ? `id="${id}"` : ''} preload="metadata" ${src ? `src="${esc(src)}"` : ''}></audio>
|
|
<button class="cc-audio-play" type="button" aria-label="Play audio"><span class="cc-audio-play-icon"></span></button>
|
|
<span class="cc-audio-signal" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
|
<input class="cc-audio-seek" type="range" min="0" max="1000" value="0" aria-label="Audio position">
|
|
<span class="cc-audio-time">0:00 / --:--</span>
|
|
<button class="cc-audio-mute" type="button" aria-label="Mute audio">VOL</button>
|
|
</span>`;
|
|
}
|
|
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 = `
|
|
<div class="cc-toast-area" id="cc-toasts"></div>
|
|
<header class="cc-header">
|
|
<div class="cc-logo"><span class="cc-logo-glyph">+</span><div>
|
|
<div class="cc-logo-text">${esc(state.config.ui?.app_title || 'CYBERCHAT')}</div>
|
|
<div class="cc-logo-sub">${esc(state.config.ui?.app_subtitle || 'SECURE CHANNEL')}</div>
|
|
</div></div>
|
|
<div class="cc-header-center"><span class="cc-conn-dot" id="cc-conn-dot"></span>
|
|
<span class="cc-online-num" id="cc-online-count">--</span><span class="cc-online-label">online</span></div>
|
|
<div class="cc-header-right"><button class="cc-theme-btn cc-theme-text" id="cc-theme">THEME</button></div>
|
|
</header><div class="cc-body" id="cc-body"></div>`;
|
|
$('#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 = `<div class="cc-auth-wrap"><div class="cc-auth-panel">
|
|
<div class="cc-auth-brand"><div class="cc-auth-brand-line"></div><span>IDENTIFY YOURSELF</span><div class="cc-auth-brand-line"></div></div>
|
|
${state.verifyToken ? '<div class="cc-auth-notice">Log in to finish email verification.</div>' : ''}
|
|
${state.loginChallenge ? '<div class="cc-auth-notice">Enter the code from your login email.</div>' : ''}
|
|
<div class="cc-auth-tabs"><button class="cc-auth-tab ${tab === 'register' ? 'active' : ''}" data-tab="register">REGISTER</button>
|
|
<button class="cc-auth-tab ${tab === 'login' ? 'active' : ''}" data-tab="login">LOGIN</button></div>
|
|
<div class="cc-auth-form ${tab === 'register' ? 'active' : ''}" data-form="register">
|
|
<p class="cc-form-hint">Email is optional for free registration.</p>
|
|
<div class="cc-field"><label class="cc-field-label">CALLSIGN</label><input class="cc-input" id="reg-user" maxlength="${maxUser}"></div>
|
|
<div class="cc-field"><label class="cc-field-label">PASSPHRASE (MIN ${minPass})</label><input class="cc-input" id="reg-pass" type="password"></div>
|
|
<div class="cc-field"><label class="cc-field-label">CONFIRM</label><input class="cc-input" id="reg-pass2" type="password"></div>
|
|
<button class="cc-btn cc-btn-register" id="register">CREATE IDENTITY</button></div>
|
|
<div class="cc-auth-form ${tab === 'login' ? 'active' : ''}" data-form="login">
|
|
<p class="cc-form-hint">Welcome back.</p>
|
|
<div class="cc-field"><label class="cc-field-label">CALLSIGN</label><input class="cc-input" id="login-user"></div>
|
|
<div class="cc-field"><label class="cc-field-label">PASSPHRASE</label><input class="cc-input" id="login-pass" type="password"></div>
|
|
<button class="cc-btn cc-btn-login" id="login">AUTHENTICATE</button></div>
|
|
<div class="cc-auth-form" data-form="2fa"><p class="cc-form-hint">Enter the code sent to your verified email.</p>
|
|
<div class="cc-field"><label class="cc-field-label">SIX-DIGIT CODE</label><input class="cc-input" id="two-code" maxlength="6" inputmode="numeric"></div>
|
|
<button class="cc-btn cc-btn-login" id="two-submit">VERIFY CODE</button></div>
|
|
<div class="cc-auth-error" id="auth-error"></div></div></div>`;
|
|
$$('[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 = `<nav class="cc-app-nav">
|
|
<button data-view="chat">CHAT</button>${groupsAvailable() ? '<button data-view="groups">GROUPS</button>' : ''}
|
|
<button data-view="archive">MY ARCHIVE</button><button data-view="account">ACCOUNT</button>
|
|
</nav><main class="cc-view" id="cc-view"></main>`;
|
|
$$('[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 '<option value="">PUBLIC LOBBY</option>' + state.groups.map(group =>
|
|
`<option value="${group.id}" ${state.groupId === group.id ? 'selected' : ''}>${state.groupUnread[group.id] ? '● ' : ''}${esc(group.name)} (${group.member_count})</option>`
|
|
).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 = `<div class="cc-userbar"><div class="cc-userbar-left">
|
|
<span class="cc-you-label">YOU:</span><span class="cc-you-name" style="color:${esc(state.user.color)};border-color:${esc(state.user.color)}">${esc(state.user.username)}</span>
|
|
<span class="cc-plan-badge">${esc(state.user.plan.name)}</span></div>
|
|
${groupsAvailable() ? `<select class="cc-compact-select" id="group-select">${groupOptions()}</select>` : ''}</div>
|
|
<div class="cc-recording-indicator" id="recording" hidden></div>
|
|
<div class="cc-messages" id="messages"><div class="cc-loading-msg"><div class="cc-spin"></div><span>LOADING CHANNEL</span></div></div>
|
|
${voice ? `<div class="cc-voice-compose"><button class="cc-voice-record" id="record"><span class="cc-voice-dot"></span><span id="record-label">RECORD</span></button>
|
|
<span class="cc-voice-status" id="voice-status">VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s</span>
|
|
${feature('auto_voice_send') ? `<label class="cc-voice-autoplay"><input id="auto-send" type="checkbox" ${state.voice.autoSend ? 'checked' : ''}> AUTO SEND</label>` : ''}
|
|
${feature('auto_voice_play') ? `<label class="cc-voice-autoplay"><input id="auto-play" type="checkbox" ${state.voice.autoPlay ? 'checked' : ''}> AUTO PLAY</label>` : ''}
|
|
<div class="cc-voice-review" id="voice-review" hidden>${audioPlayer('', 'voice-preview')}
|
|
<button class="cc-voice-action send" id="voice-send">SEND</button><button class="cc-voice-action" id="voice-discard">DISCARD</button></div></div>` : ''}
|
|
<div class="cc-compose"><input class="cc-compose-input" id="message" maxlength="${max}" placeholder="transmit message...">
|
|
<span class="cc-compose-counter" id="count">${max}</span><button class="cc-compose-send" id="send">SEND</button></div>`;
|
|
$('#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 ? '' : '<div class="cc-empty-day">NO MESSAGES IN THIS CHANNEL TODAY</div>';
|
|
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
|
|
? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}</span>${audioPlayer(publicUrl(message.voice_url))}</span>`
|
|
: linkify(esc(message.message));
|
|
item.innerHTML = `<span class="cc-msg-time">${clock(message.created_at)}</span>
|
|
<span class="cc-msg-user" style="color:${esc(message.color)}">${esc(message.username)}</span>
|
|
<span class="cc-msg-sep">></span><span class="cc-msg-text">${content}</span>`;
|
|
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 = `<div class="cc-page"><div class="cc-page-head"><div><h2>GROUPS</h2>
|
|
<p>Up to ${limit} people per group, including you.</p></div></div>
|
|
<section class="cc-card"><h3>CREATE GROUP</h3><div class="cc-form-grid">
|
|
<input class="cc-input" id="group-name" maxlength="40" placeholder="Group name">
|
|
<input class="cc-input" id="group-members" placeholder="Member callsigns, comma separated">
|
|
<button class="cc-inline-btn primary" id="group-create">CREATE</button></div></section>
|
|
<div class="cc-card-grid">${state.groups.length ? state.groups.map(groupCard).join('') : '<div class="cc-empty-card">No private groups yet.</div>'}</div></div>`;
|
|
$('#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 `<section class="cc-card"><div class="cc-card-title"><h3>${esc(group.name)}</h3>
|
|
<button class="cc-inline-btn" data-open="${group.id}">OPEN CHAT</button></div>
|
|
<div class="cc-member-list">${group.members.map(member => `<span style="color:${esc(member.color)}">${esc(member.username)}
|
|
${member.role === 'owner' ? '(owner)' : ''}${owner && member.role !== 'owner' ? ` <button data-remove="${group.id}:${member.id}">x</button>` : ''}</span>`).join('')}</div>
|
|
${owner ? `<div class="cc-form-grid compact"><input class="cc-input" id="add-${group.id}" placeholder="Callsign">
|
|
<button class="cc-inline-btn" data-add="${group.id}">ADD</button><button class="cc-inline-btn danger" data-delete="${group.id}">DELETE</button></div>` : ''}</section>`;
|
|
}
|
|
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 = '<div class="cc-loading-msg"><div class="cc-spin"></div><span>LOADING YOUR ARCHIVE</span></div>';
|
|
const result = await api('archive.php', null, new URLSearchParams({ action: 'list', search }).toString());
|
|
if (result.error) return $('#cc-view').innerHTML = `<div class="cc-empty-card">${esc(result.error)}</div>`;
|
|
$('#cc-view').innerHTML = `<div class="cc-page"><div class="cc-page-head"><div><h2>MY ARCHIVE</h2>
|
|
<p>Your own messages for ${result.retention_days} days. ${result.voice_included ? 'Voice included.' : 'Text only on this plan.'}</p></div>
|
|
${result.can_export ? `<div class="cc-actions"><a class="cc-inline-btn" href="${apiUrl('archive.php', 'action=export&format=json')}">JSON</a>
|
|
<a class="cc-inline-btn" href="${apiUrl('archive.php', 'action=export&format=csv')}">CSV</a></div>` : ''}</div>
|
|
<div class="cc-search-row"><input class="cc-input" id="archive-search" value="${esc(search)}" placeholder="Search your messages">
|
|
<button class="cc-inline-btn primary" id="archive-submit">SEARCH</button></div>
|
|
<div class="cc-archive-list">${result.messages.length ? result.messages.map(archiveRow).join('') : '<div class="cc-empty-card">No matching messages.</div>'}</div></div>`;
|
|
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 `<div class="cc-archive-row"><time>${esc(message.day_key)} ${clock(message.created_at)}</time>
|
|
<span class="cc-type-tag">${esc(message.message_type)}</span><div>${content}</div></div>`;
|
|
}
|
|
|
|
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 = `<div class="cc-page"><div class="cc-page-head"><div><h2>ACCOUNT</h2>
|
|
<p>${esc(state.user.username)} / ${esc(state.user.plan.name)}</p></div><button class="cc-inline-btn danger" id="logout">LOG OUT</button></div>
|
|
<div class="cc-card-grid"><section class="cc-card"><h3>EMAIL VERIFICATION</h3>
|
|
<p>${state.user.email_verified ? `Verified: ${esc(state.user.email)}` : 'Email is only required before premium activation.'}</p>
|
|
<div class="cc-form-grid"><input class="cc-input" id="email" type="email" value="${esc(state.user.email || '')}" placeholder="you@example.com">
|
|
<button class="cc-inline-btn primary" id="email-send">SEND VERIFICATION</button></div>
|
|
<div class="cc-form-grid compact"><input class="cc-input" id="email-code" maxlength="6" placeholder="Six-digit code">
|
|
<button class="cc-inline-btn" id="email-verify">VERIFY</button></div></section>
|
|
<section class="cc-card"><h3>IDENTITY & SECURITY</h3>
|
|
${feature('username_color') ? `<div class="cc-form-grid compact"><input type="color" id="color" value="${esc(state.user.color)}">
|
|
<button class="cc-inline-btn" id="color-save">SAVE COLOR</button></div>` : '<p>Custom username color is not included in this plan.</p>'}
|
|
${feature('email_2fa') ? `<label class="cc-switch"><input type="checkbox" id="two-toggle" ${state.user.two_factor_enabled ? 'checked' : ''}> EMAIL TWO-FACTOR AUTHENTICATION</label>` : '<p>Email two-factor authentication is not included in this plan.</p>'}
|
|
</section></div>
|
|
${showBilling ? billingHtml(billing) : ''}
|
|
<section class="cc-card cc-feature-card"><h3>CURRENT FEATURES</h3><div class="cc-feature-list">
|
|
${Object.entries(state.user.features).map(([key, value]) => `<span><b>${esc(key.replace(/_/g, ' '))}</b> ${esc(String(value))}</span>`).join('')}</div></section>
|
|
</div>`;
|
|
$('#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 `<section class="cc-card cc-billing-card"><div class="cc-card-title"><div><h3>SUBSCRIPTION</h3>
|
|
<p>Status: ${esc(state.user.subscription.status)}${state.user.subscription.cancel_at_period_end ? ' / cancels at period end' : ''}</p></div>
|
|
<div class="cc-actions">${billing.can_manage ? '<button class="cc-inline-btn" id="billing-manage">MANAGE IN STRIPE</button>' : ''}
|
|
${state.user.subscription.manageable && !state.user.subscription.cancel_at_period_end ? '<button class="cc-inline-btn danger" id="billing-cancel">CANCEL</button>' : ''}</div></div>
|
|
${plans.length ? `<div class="cc-plan-grid">${plans.map(plan => `<article class="cc-plan"><h4>${esc(plan.name)}</h4>
|
|
<div class="cc-price">${money(plan.price_cents, plan.currency)}<small>/${esc(plan.interval)}</small></div>
|
|
<p>${esc(plan.description)}</p><button class="cc-inline-btn primary" data-plan="${plan.id}" ${plan.checkout_ready ? '' : 'disabled'}>SELECT PLAN</button></article>`).join('')}</div>` : ''}
|
|
${billing.subscriptions_enabled && alreadySubscribed ? '<p>Use the Stripe portal to change plans or payment details.</p>' : ''}
|
|
${!billing.subscriptions_enabled && billing.can_manage ? '<p>New subscriptions are hidden. Your existing subscription remains manageable.</p>' : ''}</section>`;
|
|
}
|
|
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);
|