eb57cde99f
Per-tier audio bitrate: Free 64, Plus 96, Pro 128 kbps. Configurable text/voice reply threads and depth. Registration/login-only honeypot and internal CAPTCHA. Optional Google reCAPTCHA v2/v3 with server verification. Archive/export support for replies and bitrate. Dashboard controls for all settings. Verified JavaScript, JSON, SQLite migrations, desktop/mobile UI, and browser console. Native PHP lint was unavailable because PHP is not installed. Google implementation follows Siteverify and reCAPTCHA v3.
1112 lines
54 KiB
JavaScript
1112 lines
54 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, billing: null,
|
|
view: ['chat', 'archive', 'account'].includes(initialView) ? initialView : 'chat',
|
|
cursor: 0,
|
|
poll: {
|
|
generation: 0, active: false, controller: null, wakeTimer: null, wakeResolve: null,
|
|
failures: 0, immediate: false, messages: new Map(), pendingThrough: 0, initial: true
|
|
},
|
|
loginChallenge: initialChallenge,
|
|
verifyToken: new URLSearchParams(location.search).get('verify') || '',
|
|
reply: null,
|
|
captcha: { internal: {}, widgets: {}, scriptPromise: null },
|
|
voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0,
|
|
chunks: [], blob: null, mime: '', duration: 0, url: '', autoSend: false, autoPlay: false,
|
|
playQueue: [], currentAudio: null, queuePlaying: false, autoplayWarningShown: false, sending: false }
|
|
};
|
|
let root;
|
|
let wakeListenersBound = false;
|
|
const displayedFeatures = new Set([
|
|
'voice_messages', 'voice_bitrate_kbps', 'recording_status', 'auto_voice_send', 'auto_voice_play',
|
|
'username_color', 'text_archive_days', 'text_export', 'voice_archive',
|
|
'voice_export', 'email_2fa'
|
|
]);
|
|
const $ = (s, c) => (c || root).querySelector(s);
|
|
const $$ = (s, c) => Array.from((c || root).querySelectorAll(s));
|
|
const esc = value => String(value == null ? '' : value)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
|
|
function base() {
|
|
if (window.CYBERCHAT_BASE != null) return String(window.CYBERCHAT_BASE).replace(/\/$/, '');
|
|
const script = Array.from(document.scripts).find(item => item.src.includes('cyberchat-app.js'));
|
|
return script ? script.src.replace(/\/assets\/js\/cyberchat-app\.js.*$/, '') : '';
|
|
}
|
|
function apiUrl(file, query = '') { return base() + '/api/' + file + (query ? '?' + query : ''); }
|
|
function publicUrl(path) {
|
|
if (!path) return '';
|
|
return /^(https?:)?\/\//.test(path) || path.startsWith('/') ? path : base() + '/' + path;
|
|
}
|
|
function form(data) {
|
|
const body = new FormData();
|
|
Object.entries(data).forEach(([key, value]) => body.append(key, value));
|
|
return { method: 'POST', body };
|
|
}
|
|
async function api(file, options, query = '') {
|
|
const response = await fetch(apiUrl(file, query), {
|
|
credentials: 'include',
|
|
cache: 'no-store',
|
|
...(options || {}),
|
|
});
|
|
const data = await response.json().catch(() => ({ error: 'The server returned an invalid response' }));
|
|
if (!response.ok && !data.error) data.error = 'Request failed';
|
|
return data;
|
|
}
|
|
function feature(key, fallback = false) {
|
|
return state.user?.features && Object.prototype.hasOwnProperty.call(state.user.features, key)
|
|
? state.user.features[key] : fallback;
|
|
}
|
|
function visibleFeatures() {
|
|
return Object.entries(state.user?.features || {})
|
|
.filter(([key]) => displayedFeatures.has(String(key).toLowerCase()));
|
|
}
|
|
function toast(message, type = 'error') {
|
|
const area = $('#cc-toasts');
|
|
if (!area) return;
|
|
const item = document.createElement('div');
|
|
item.className = 'cc-toast ' + type;
|
|
item.textContent = message;
|
|
area.appendChild(item);
|
|
setTimeout(() => { item.classList.add('cc-toast-fade'); setTimeout(() => item.remove(), 350); }, 2800);
|
|
}
|
|
function duration(seconds) {
|
|
const total = Math.max(0, Math.ceil(Number(seconds) || 0));
|
|
return Math.floor(total / 60) + ':' + String(total % 60).padStart(2, '0');
|
|
}
|
|
function audioClock(seconds) {
|
|
const total = Math.max(0, Math.floor(Number(seconds) || 0));
|
|
return Math.floor(total / 60) + ':' + String(total % 60).padStart(2, '0');
|
|
}
|
|
function clock(timestamp) {
|
|
return new Date(Number(timestamp) * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
function money(cents, currency) {
|
|
return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency || 'USD' }).format(cents / 100);
|
|
}
|
|
function linkify(text) {
|
|
return text.replace(/(https?:\/\/[^\s<>]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
}
|
|
function audioPlayer(src = '', id = '', mime = '') {
|
|
return `<span class="cc-cyber-audio">
|
|
<audio ${id ? `id="${id}"` : ''} preload="metadata" playsinline>${src
|
|
? `<source src="${esc(src)}" ${mime ? `type="${esc(mime)}"` : ''}>`
|
|
: ''}</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,
|
|
poll_timeout_ms: 12000, poll_max_backoff_ms: 15000, max_rendered_messages: 500,
|
|
replies_enabled: true, reply_max_depth: 4
|
|
},
|
|
security: { min_password_length: 4, captcha: {} },
|
|
voice: { enabled: true, max_duration_seconds: 60 },
|
|
ui: { app_title: 'CYBERCHAT', app_subtitle: 'SECURE CHANNEL' }
|
|
}));
|
|
if (!wakeListenersBound) {
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden) requestPoll();
|
|
});
|
|
window.addEventListener('online', requestPoll);
|
|
wakeListenersBound = true;
|
|
}
|
|
try { document.body.classList.toggle('cc-light', localStorage.getItem('cc_theme') === 'light'); } catch (e) {}
|
|
shell();
|
|
api('stats.php', form({ event: 'visit', path: location.pathname })).catch(() => {});
|
|
const check = await api('auth.php', null, 'action=check').catch(() => ({ authenticated: false }));
|
|
if (check.authenticated) {
|
|
state.user = check.user;
|
|
await enter();
|
|
} else auth(state.loginChallenge ? '2fa' : 'register');
|
|
}
|
|
|
|
function shell() {
|
|
root.innerHTML = `
|
|
<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 authCaptchaEnabled(operation) {
|
|
const captcha = state.config.security?.captcha || {};
|
|
return operation === 'register'
|
|
? captcha.registration_enabled === true
|
|
: captcha.login_enabled === true;
|
|
}
|
|
function authCaptchaMarkup(operation) {
|
|
if (!authCaptchaEnabled(operation)) return '';
|
|
const captcha = state.config.security?.captcha || {};
|
|
return `${captcha.honeypot_enabled ? `<div class="cc-captcha-trap" aria-hidden="true">
|
|
<label>Website<input id="${operation}-hp-website" tabindex="-1" autocomplete="off"></label>
|
|
<label><input id="${operation}-hp-contact" type="checkbox" tabindex="-1"> Contact me</label>
|
|
</div>` : ''}
|
|
<div class="cc-captcha-block">
|
|
${captcha.internal_enabled ? `<div class="cc-internal-captcha">
|
|
<label class="cc-field-label">HUMAN CHECK</label>
|
|
<div class="cc-captcha-row"><span id="${operation}-captcha-question">LOADING...</span>
|
|
<input class="cc-input" id="${operation}-captcha-answer" inputmode="numeric" autocomplete="off" aria-label="CAPTCHA answer">
|
|
<button class="cc-captcha-refresh" id="${operation}-captcha-refresh" type="button">NEW</button></div>
|
|
</div>` : ''}
|
|
${captcha.google_enabled && captcha.google_version === 'v2'
|
|
? `<div class="cc-google-captcha" id="${operation}-google-captcha"></div>` : ''}
|
|
${captcha.google_enabled && captcha.google_version === 'v3'
|
|
? '<div class="cc-captcha-note">Protected by reCAPTCHA v3</div>' : ''}
|
|
</div>`;
|
|
}
|
|
async function loadInternalCaptcha(operation) {
|
|
if (!authCaptchaEnabled(operation) || !state.config.security?.captcha?.internal_enabled) return;
|
|
const result = await api('auth.php', null, new URLSearchParams({
|
|
action: 'captcha_challenge', operation
|
|
}).toString()).catch(() => ({ error: 'Could not load CAPTCHA' }));
|
|
const question = $(`#${operation}-captcha-question`);
|
|
if (result.error || !result.enabled) {
|
|
state.captcha.internal[operation] = null;
|
|
if (question) question.textContent = 'UNAVAILABLE';
|
|
return;
|
|
}
|
|
state.captcha.internal[operation] = result.token;
|
|
if (question) question.textContent = result.question;
|
|
const answer = $(`#${operation}-captcha-answer`);
|
|
if (answer) answer.value = '';
|
|
}
|
|
function loadGoogleCaptcha() {
|
|
const captcha = state.config.security?.captcha || {};
|
|
if (!captcha.google_enabled || !captcha.google_site_key) return Promise.resolve();
|
|
if (window.grecaptcha) return Promise.resolve();
|
|
if (state.captcha.scriptPromise) return state.captcha.scriptPromise;
|
|
state.captcha.scriptPromise = new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
const render = captcha.google_version === 'v3'
|
|
? encodeURIComponent(captcha.google_site_key) : 'explicit';
|
|
script.src = `https://www.google.com/recaptcha/api.js?render=${render}`;
|
|
script.async = true;
|
|
script.defer = true;
|
|
script.onload = resolve;
|
|
script.onerror = () => reject(new Error('Google reCAPTCHA could not load'));
|
|
document.head.appendChild(script);
|
|
});
|
|
return state.captcha.scriptPromise;
|
|
}
|
|
async function renderGoogleCaptcha(operation) {
|
|
const captcha = state.config.security?.captcha || {};
|
|
if (!authCaptchaEnabled(operation) || !captcha.google_enabled
|
|
|| captcha.google_version !== 'v2' || !captcha.google_site_key) return;
|
|
await loadGoogleCaptcha();
|
|
const target = $(`#${operation}-google-captcha`);
|
|
if (!target || state.captcha.widgets[operation] != null) return;
|
|
state.captcha.widgets[operation] = window.grecaptcha.render(target, {
|
|
sitekey: captcha.google_site_key,
|
|
theme: document.body.classList.contains('cc-light') ? 'light' : 'dark',
|
|
});
|
|
}
|
|
async function setupAuthCaptcha() {
|
|
['register', 'login'].forEach(operation => {
|
|
$(`#${operation}-captcha-refresh`)?.addEventListener('click', () => loadInternalCaptcha(operation));
|
|
});
|
|
const activeOperation = $('[data-form].active')?.dataset.form;
|
|
if (activeOperation === 'register' || activeOperation === 'login') {
|
|
void loadInternalCaptcha(activeOperation);
|
|
}
|
|
const captcha = state.config.security?.captcha || {};
|
|
if (!captcha.google_enabled || !captcha.google_site_key) return;
|
|
try {
|
|
await loadGoogleCaptcha();
|
|
const active = $('[data-form].active')?.dataset.form;
|
|
if (active === 'register' || active === 'login') await renderGoogleCaptcha(active);
|
|
} catch (error) {
|
|
authError(error.message);
|
|
}
|
|
}
|
|
async function authForm(operation, values) {
|
|
const body = new FormData();
|
|
Object.entries(values).forEach(([key, value]) => body.append(key, value));
|
|
const captcha = state.config.security?.captcha || {};
|
|
if (!authCaptchaEnabled(operation)) return { method: 'POST', body };
|
|
if (captcha.honeypot_enabled) {
|
|
body.append('_hp_website', $(`#${operation}-hp-website`)?.value || '');
|
|
body.append('_hp_contact', $(`#${operation}-hp-contact`)?.checked ? '1' : '0');
|
|
}
|
|
if (captcha.internal_enabled) {
|
|
const token = state.captcha.internal[operation];
|
|
const answer = $(`#${operation}-captcha-answer`)?.value.trim() || '';
|
|
if (!token || !answer) throw new Error('Complete the human check');
|
|
body.append('captcha_internal_token', token);
|
|
body.append('captcha_internal_answer', answer);
|
|
}
|
|
if (captcha.google_enabled) {
|
|
if (!captcha.google_site_key || !window.grecaptcha) throw new Error('Google reCAPTCHA is unavailable');
|
|
let token = '';
|
|
if (captcha.google_version === 'v3') {
|
|
token = await new Promise((resolve, reject) => {
|
|
window.grecaptcha.ready(() => {
|
|
window.grecaptcha.execute(captcha.google_site_key, { action: operation }).then(resolve, reject);
|
|
});
|
|
});
|
|
} else {
|
|
const widget = state.captcha.widgets[operation];
|
|
if (widget == null) throw new Error('Google reCAPTCHA is still loading');
|
|
token = window.grecaptcha.getResponse(widget);
|
|
}
|
|
if (!token) throw new Error('Complete the Google reCAPTCHA check');
|
|
body.append('captcha_google_token', token);
|
|
}
|
|
return { method: 'POST', body };
|
|
}
|
|
function refreshAuthCaptcha(operation) {
|
|
void loadInternalCaptcha(operation);
|
|
const widget = state.captcha.widgets[operation];
|
|
if (widget != null && window.grecaptcha) window.grecaptcha.reset(widget);
|
|
}
|
|
|
|
function auth(tab) {
|
|
stopPoll();
|
|
state.captcha.internal = {};
|
|
state.captcha.widgets = {};
|
|
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>
|
|
${authCaptchaMarkup('register')}
|
|
<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>
|
|
${authCaptchaMarkup('login')}
|
|
<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));
|
|
void setupAuthCaptcha();
|
|
}
|
|
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('');
|
|
if (name === 'register' || name === 'login') {
|
|
if (!state.captcha.internal[name]) void loadInternalCaptcha(name);
|
|
renderGoogleCaptcha(name).catch(error => authError(error.message));
|
|
}
|
|
}
|
|
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');
|
|
let request;
|
|
try { request = await authForm('register', { action: 'register', username, password }); }
|
|
catch (error) { return authError(error.message); }
|
|
const result = await api('auth.php', request);
|
|
if (result.error) {
|
|
refreshAuthCaptcha('register');
|
|
return authError(result.error);
|
|
}
|
|
state.user = result.user;
|
|
await enter();
|
|
}
|
|
async function login() {
|
|
let request;
|
|
try {
|
|
request = await authForm('login', {
|
|
action: 'login', username: $('#login-user').value.trim(), password: $('#login-pass').value
|
|
});
|
|
} catch (error) { return authError(error.message); }
|
|
const result = await api('auth.php', request);
|
|
if (result.error) {
|
|
refreshAuthCaptcha('login');
|
|
return authError(result.error);
|
|
}
|
|
if (result.requires_2fa) {
|
|
state.loginChallenge = result.challenge;
|
|
switchAuth('2fa');
|
|
$('#two-submit').addEventListener('click', () => verify2fa(state.loginChallenge));
|
|
return;
|
|
}
|
|
state.user = result.user;
|
|
await enter();
|
|
}
|
|
async function verify2fa(challenge) {
|
|
const result = await api('auth.php', form({ action: 'verify_2fa', challenge, code: $('#two-code').value.trim() }));
|
|
if (result.error) return authError(result.error);
|
|
state.loginChallenge = '';
|
|
history.replaceState({}, '', location.pathname);
|
|
state.user = result.user;
|
|
await enter();
|
|
}
|
|
|
|
async function enter() {
|
|
state.billing = await api('billing.php', null, 'action=status').catch(() => null);
|
|
if (feature('auto_voice_play')) {
|
|
try {
|
|
const saved = localStorage.getItem('cc_auto_voice_play');
|
|
state.voice.autoPlay = saved === null
|
|
? state.config.voice?.auto_play_default !== false
|
|
: saved === '1';
|
|
} catch (e) {
|
|
state.voice.autoPlay = state.config.voice?.auto_play_default !== false;
|
|
}
|
|
} else state.voice.autoPlay = false;
|
|
app();
|
|
if (state.verifyToken) {
|
|
state.view = 'account';
|
|
renderView();
|
|
await verifyEmail(state.verifyToken, '');
|
|
state.verifyToken = '';
|
|
history.replaceState({}, '', location.pathname);
|
|
}
|
|
}
|
|
function app() {
|
|
$('#cc-body').innerHTML = `<nav class="cc-app-nav">
|
|
<button data-view="chat">CHAT</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 === 'archive') archiveView();
|
|
else accountView();
|
|
}
|
|
|
|
function voiceBitrate() {
|
|
return Math.max(48, Math.min(320, Number(feature('voice_bitrate_kbps', 64)) || 64));
|
|
}
|
|
function replyPreviewText(message) {
|
|
if (!message) return '';
|
|
return message.message_type === 'voice' ? '[Voice clip]' : String(message.message || '').slice(0, 120);
|
|
}
|
|
function setReply(message) {
|
|
state.reply = {
|
|
id: Number(message.id),
|
|
username: message.username,
|
|
message: replyPreviewText(message),
|
|
depth: Number(message.reply_depth) || 0,
|
|
};
|
|
const bar = $('#reply-context');
|
|
if (bar) {
|
|
bar.hidden = false;
|
|
$('#reply-context-user').textContent = state.reply.username;
|
|
$('#reply-context-text').textContent = state.reply.message;
|
|
}
|
|
$('#message')?.focus();
|
|
}
|
|
function clearReply() {
|
|
state.reply = null;
|
|
const bar = $('#reply-context');
|
|
if (bar) bar.hidden = true;
|
|
}
|
|
|
|
function chat() {
|
|
const max = state.config.chat?.max_message_length || 500;
|
|
const voice = state.config.voice?.enabled !== false && feature('voice_messages', true);
|
|
state.reply = null;
|
|
$('#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></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 · ${voiceBitrate()} KBPS</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-reply-context" id="reply-context" hidden><span>REPLYING TO <b id="reply-context-user"></b></span>
|
|
<span id="reply-context-text"></span><button id="reply-cancel" type="button">CANCEL</button></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>`;
|
|
$('#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);
|
|
$('#reply-cancel').addEventListener('click', clearReply);
|
|
$('#record')?.addEventListener('click', toggleVoice);
|
|
$('#voice-send')?.addEventListener('click', sendVoice);
|
|
$('#voice-discard')?.addEventListener('click', discardVoice);
|
|
$('#auto-send')?.addEventListener('change', event => { state.voice.autoSend = event.target.checked; });
|
|
$('#auto-play')?.addEventListener('change', event => {
|
|
state.voice.autoPlay = event.target.checked;
|
|
try { localStorage.setItem('cc_auto_voice_play', event.target.checked ? '1' : '0'); } catch (e) {}
|
|
});
|
|
bindAudioPlayers($('#cc-view'));
|
|
state.cursor = 0;
|
|
startPoll();
|
|
}
|
|
async function sendText() {
|
|
const input = $('#message');
|
|
const message = input.value.trim();
|
|
if (!message) return;
|
|
const max = state.config.chat?.max_message_length || 500;
|
|
input.value = '';
|
|
$('#count').textContent = max;
|
|
const result = await api('messages.php', form({
|
|
action: 'send', message, reply_to_id: state.reply?.id || ''
|
|
}))
|
|
.catch(() => ({ error: 'Message send failed. Polling will continue.' }));
|
|
if (result.error) {
|
|
input.value = message;
|
|
$('#count').textContent = max - message.length;
|
|
return toast(result.error);
|
|
}
|
|
acknowledgeSentMessage(result.message);
|
|
clearReply();
|
|
requestPoll();
|
|
}
|
|
function startPoll() {
|
|
stopPoll();
|
|
state.cursor = 0;
|
|
state.poll.active = true;
|
|
state.poll.failures = 0;
|
|
state.poll.immediate = true;
|
|
state.poll.initial = true;
|
|
state.poll.messages.clear();
|
|
state.poll.pendingThrough = 0;
|
|
void pollWorker(state.poll.generation);
|
|
}
|
|
function requestPoll() {
|
|
if (state.view !== 'chat' || !state.poll.active) return;
|
|
state.poll.immediate = true;
|
|
const wake = state.poll.wakeResolve;
|
|
if (wake) wake();
|
|
}
|
|
function stopPoll() {
|
|
state.poll.active = false;
|
|
state.poll.generation++;
|
|
state.poll.controller?.abort();
|
|
state.poll.controller = null;
|
|
if (state.poll.wakeTimer) clearTimeout(state.poll.wakeTimer);
|
|
state.poll.wakeTimer = null;
|
|
const wake = state.poll.wakeResolve;
|
|
state.poll.wakeResolve = null;
|
|
if (wake) wake();
|
|
state.poll.immediate = false;
|
|
state.poll.messages.clear();
|
|
state.poll.pendingThrough = 0;
|
|
resetVoicePlaybackQueue();
|
|
}
|
|
async function pollWorker(generation) {
|
|
const interval = Math.max(250, Number(state.config.chat?.poll_interval_ms) || 2000);
|
|
const maxBackoff = Math.max(interval, Number(state.config.chat?.poll_max_backoff_ms) || 15000);
|
|
while (state.poll.active && state.poll.generation === generation && state.view === 'chat') {
|
|
state.poll.immediate = false;
|
|
const result = await pollOnce(generation);
|
|
if (!state.poll.active || state.poll.generation !== generation || state.view !== 'chat') break;
|
|
let delay = 0;
|
|
if (result.ok) {
|
|
state.poll.failures = 0;
|
|
if (!result.hasMore && !state.poll.immediate) delay = interval;
|
|
} else {
|
|
state.poll.failures++;
|
|
if (!state.poll.immediate) {
|
|
delay = Math.min(maxBackoff, 500 * (2 ** Math.min(state.poll.failures - 1, 6)));
|
|
}
|
|
}
|
|
if (delay > 0) await waitForPoll(delay, generation);
|
|
}
|
|
}
|
|
function waitForPoll(delay, generation) {
|
|
if (!state.poll.active || state.poll.generation !== generation) return Promise.resolve();
|
|
return new Promise(resolve => {
|
|
let settled = false;
|
|
let timer = null;
|
|
const finish = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timer) clearTimeout(timer);
|
|
if (state.poll.wakeTimer === timer) state.poll.wakeTimer = null;
|
|
if (state.poll.wakeResolve === finish) state.poll.wakeResolve = null;
|
|
resolve();
|
|
};
|
|
timer = setTimeout(finish, Math.max(0, delay));
|
|
state.poll.wakeTimer = timer;
|
|
state.poll.wakeResolve = finish;
|
|
if (state.poll.immediate) finish();
|
|
});
|
|
}
|
|
async function pollOnce(generation) {
|
|
const requestedSince = state.cursor;
|
|
const controller = new AbortController();
|
|
const timeoutMs = Math.max(1000, Number(state.config.chat?.poll_timeout_ms) || 12000);
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
state.poll.controller = controller;
|
|
try {
|
|
const query = new URLSearchParams({ action: 'poll', since: requestedSince });
|
|
const result = await api('messages.php', { signal: controller.signal }, query.toString());
|
|
if (!state.poll.active || state.poll.generation !== generation || state.view !== 'chat') {
|
|
return { ok: false, hasMore: false };
|
|
}
|
|
if (!result || result.error || !Array.isArray(result.messages)) {
|
|
throw new Error(result?.error || 'Invalid poll response');
|
|
}
|
|
$('#cc-conn-dot').className = 'cc-conn-dot online';
|
|
$('#cc-online-count').textContent = result.online;
|
|
const initial = state.poll.initial;
|
|
const merged = mergeMessages(result.messages, !initial);
|
|
state.poll.initial = false;
|
|
const nextCursor = Math.max(requestedSince, merged.maxId);
|
|
state.cursor = Math.max(state.cursor, nextCursor);
|
|
if (state.cursor >= state.poll.pendingThrough) state.poll.pendingThrough = 0;
|
|
const recording = $('#recording');
|
|
if (recording) {
|
|
recording.hidden = !result.recording?.length;
|
|
recording.textContent = result.recording?.length
|
|
? result.recording.map(user => user.username).join(', ')
|
|
+ (result.recording.length === 1 ? ' is' : ' are') + ' recording...'
|
|
: '';
|
|
}
|
|
const madeProgress = nextCursor > requestedSince;
|
|
const catchingUpToSend = state.poll.pendingThrough > nextCursor;
|
|
return {
|
|
ok: true,
|
|
hasMore: madeProgress && (Boolean(result.has_more) || catchingUpToSend),
|
|
};
|
|
} catch (error) {
|
|
if (state.poll.active && state.poll.generation === generation) {
|
|
$('#cc-conn-dot').className = 'cc-conn-dot offline';
|
|
}
|
|
return { ok: false, hasMore: false };
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
if (state.poll.controller === controller) state.poll.controller = null;
|
|
}
|
|
}
|
|
function normalizeMessage(message) {
|
|
const id = Number(message?.id);
|
|
if (!Number.isSafeInteger(id) || id < 1) return null;
|
|
const replyToId = Number(message?.reply_to_id);
|
|
return {
|
|
...message,
|
|
id,
|
|
reply_to_id: Number.isSafeInteger(replyToId) && replyToId > 0 ? replyToId : null,
|
|
reply_depth: Math.max(0, Number(message?.reply_depth) || 0),
|
|
voice_bitrate_kbps: Number(message?.voice_bitrate_kbps) || null,
|
|
};
|
|
}
|
|
function acknowledgeSentMessage(message) {
|
|
const acknowledged = normalizeMessage(message);
|
|
if (!acknowledged) return;
|
|
if (state.poll.messages.has(acknowledged.id) || acknowledged.id <= state.cursor) return;
|
|
state.poll.pendingThrough = Math.max(state.poll.pendingThrough, acknowledged.id);
|
|
}
|
|
function isOwnMessage(message) {
|
|
const userId = Number(message?.user_id);
|
|
return Number.isSafeInteger(userId) && userId > 0
|
|
? userId === Number(state.user?.id)
|
|
: message?.username === state.user?.username;
|
|
}
|
|
function messageRenderKey(message) {
|
|
return JSON.stringify([
|
|
message.user_id ?? null, message.username, message.color, message.message,
|
|
message.message_type, message.voice_url, message.voice_mime,
|
|
message.voice_duration, message.voice_bitrate_kbps, message.reply_to_id,
|
|
message.reply_depth, message.reply_username, message.reply_message,
|
|
message.reply_message_type, message.created_at
|
|
]);
|
|
}
|
|
function compareMessagesChronologically(a, b) {
|
|
return (Number(a.created_at) || 0) - (Number(b.created_at) || 0) || a.id - b.id;
|
|
}
|
|
function mergeMessages(messages, incoming) {
|
|
const incomingIds = new Set();
|
|
let maxId = state.cursor;
|
|
for (const rawMessage of Array.isArray(messages) ? messages : []) {
|
|
const message = normalizeMessage(rawMessage);
|
|
if (!message) continue;
|
|
maxId = Math.max(maxId, message.id);
|
|
const previous = state.poll.messages.get(message.id);
|
|
state.poll.messages.set(message.id, previous ? { ...previous, ...message } : message);
|
|
if (!previous && incoming) incomingIds.add(message.id);
|
|
}
|
|
reconcileMessages(incomingIds);
|
|
return { maxId };
|
|
}
|
|
function reconcileMessages(incomingIds = new Set()) {
|
|
const list = $('#messages');
|
|
if (!list) return;
|
|
trimMessageStore();
|
|
list.querySelector('.cc-loading-msg, .cc-empty-day')?.remove();
|
|
const sorted = [...state.poll.messages.values()].sort(compareMessagesChronologically);
|
|
const nodes = new Map();
|
|
Array.from(list.querySelectorAll('.cc-msg')).forEach(item => {
|
|
const id = Number(item.dataset.id);
|
|
if (!Number.isSafeInteger(id) || nodes.has(id)) {
|
|
$('audio', item)?.pause();
|
|
item.remove();
|
|
return;
|
|
}
|
|
nodes.set(id, item);
|
|
});
|
|
for (const message of sorted) {
|
|
const renderKey = messageRenderKey(message);
|
|
let item = nodes.get(message.id);
|
|
if (!item || item.dataset.renderKey !== renderKey) {
|
|
const replacement = createMessageElement(message);
|
|
if (item) {
|
|
$('audio', item)?.pause();
|
|
item.replaceWith(replacement);
|
|
}
|
|
item = replacement;
|
|
nodes.set(message.id, item);
|
|
}
|
|
}
|
|
const retainedIds = new Set(sorted.map(message => message.id));
|
|
nodes.forEach((item, id) => {
|
|
if (retainedIds.has(id)) return;
|
|
$('audio', item)?.pause();
|
|
item.remove();
|
|
});
|
|
const currentOrder = Array.from(list.querySelectorAll('.cc-msg')).map(item => Number(item.dataset.id));
|
|
const desiredOrder = sorted.map(message => message.id);
|
|
if (currentOrder.length !== desiredOrder.length
|
|
|| currentOrder.some((id, index) => id !== desiredOrder[index])) {
|
|
desiredOrder.forEach(id => list.appendChild(nodes.get(id)));
|
|
}
|
|
sorted.forEach(message => {
|
|
if (!incomingIds.has(message.id) || isOwnMessage(message)) return;
|
|
const item = nodes.get(message.id);
|
|
if (message.message_type === 'voice' && item) enqueueVoiceAutoplay(item);
|
|
});
|
|
if (!sorted.length) {
|
|
list.innerHTML = '<div class="cc-empty-day">NO MESSAGES IN THIS CHANNEL TODAY</div>';
|
|
}
|
|
list.scrollTop = list.scrollHeight;
|
|
}
|
|
function createMessageElement(message) {
|
|
const id = message.id;
|
|
const depth = Math.max(0, Number(message.reply_depth) || 0);
|
|
const maxDepth = Math.max(1, Number(state.config.chat?.reply_max_depth) || 4);
|
|
const item = document.createElement('div');
|
|
item.className = 'cc-msg' + (isOwnMessage(message) ? ' cc-msg-own' : '')
|
|
+ (depth ? ' cc-msg-thread' : '') + ' cc-msg-new';
|
|
item.style.setProperty('--cc-reply-indent', `${Math.min(depth, 6) * 12}px`);
|
|
item.dataset.id = String(id);
|
|
item.dataset.renderKey = messageRenderKey(message);
|
|
const content = message.message_type === 'voice' && message.voice_url
|
|
? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}${message.voice_bitrate_kbps ? ` · ${message.voice_bitrate_kbps} KBPS` : ''}</span>${audioPlayer(publicUrl(message.voice_url), '', message.voice_mime)}</span>`
|
|
: linkify(esc(message.message));
|
|
const parentText = message.reply_message_type === 'voice'
|
|
? '[Voice clip]'
|
|
: (message.reply_message || 'Original message unavailable');
|
|
const replyReference = message.reply_to_id
|
|
? `<button class="cc-reply-ref" type="button" data-parent-id="${message.reply_to_id}">
|
|
<b>${esc(message.reply_username || `#${message.reply_to_id}`)}</b>
|
|
<span>${esc(String(parentText).slice(0, 120))}</span></button>`
|
|
: '';
|
|
const replyAction = state.config.chat?.replies_enabled !== false && depth < maxDepth
|
|
? '<button class="cc-msg-reply" type="button">REPLY</button>' : '';
|
|
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">${replyReference}${content}</span>${replyAction}`;
|
|
$('.cc-msg-reply', item)?.addEventListener('click', () => setReply(message));
|
|
$('.cc-reply-ref', item)?.addEventListener('click', event => {
|
|
const parentId = Number(event.currentTarget.dataset.parentId);
|
|
const parent = $(`.cc-msg[data-id="${parentId}"]`);
|
|
if (!parent) return toast('Original message is outside the current view', 'info');
|
|
parent.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
parent.classList.add('cc-msg-focus');
|
|
setTimeout(() => parent.classList.remove('cc-msg-focus'), 1200);
|
|
});
|
|
bindAudioPlayers(item);
|
|
return item;
|
|
}
|
|
function trimMessageStore() {
|
|
const limit = Math.max(20, Number(state.config.chat?.max_rendered_messages) || 500);
|
|
const messages = [...state.poll.messages.values()].sort(compareMessagesChronologically);
|
|
messages.slice(0, Math.max(0, messages.length - limit))
|
|
.forEach(message => state.poll.messages.delete(message.id));
|
|
}
|
|
function enqueueVoiceAutoplay(item) {
|
|
if (!feature('auto_voice_play') || !state.voice.autoPlay) return;
|
|
const audio = $('audio', item);
|
|
if (!audio) return;
|
|
state.voice.playQueue.push(audio);
|
|
playNextQueuedVoice();
|
|
}
|
|
function playNextQueuedVoice() {
|
|
if (state.voice.queuePlaying || !state.voice.playQueue.length) return;
|
|
const audio = state.voice.playQueue.shift();
|
|
if (!audio?.isConnected) return playNextQueuedVoice();
|
|
state.voice.queuePlaying = true;
|
|
state.voice.currentAudio = audio;
|
|
let started = false;
|
|
let settled = false;
|
|
const done = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
state.voice.queuePlaying = false;
|
|
if (state.voice.currentAudio === audio) state.voice.currentAudio = null;
|
|
playNextQueuedVoice();
|
|
};
|
|
audio.addEventListener('playing', () => { started = true; }, { once: true });
|
|
audio.addEventListener('ended', done, { once: true });
|
|
audio.addEventListener('error', done, { once: true });
|
|
audio.addEventListener('pause', () => {
|
|
if (started && !audio.ended) done();
|
|
}, { once: true });
|
|
audio.play().catch(() => {
|
|
settled = true;
|
|
state.voice.queuePlaying = false;
|
|
if (state.voice.currentAudio === audio) state.voice.currentAudio = null;
|
|
state.voice.playQueue = [];
|
|
if (!state.voice.autoplayWarningShown) {
|
|
state.voice.autoplayWarningShown = true;
|
|
toast('Browser blocked auto play. Press play once to enable audio.');
|
|
}
|
|
});
|
|
}
|
|
function resetVoicePlaybackQueue() {
|
|
const current = state.voice.currentAudio;
|
|
state.voice.playQueue = [];
|
|
state.voice.currentAudio = null;
|
|
state.voice.queuePlaying = false;
|
|
if (current && !current.paused) current.pause();
|
|
}
|
|
|
|
async function recording(active) {
|
|
if (!feature('recording_status')) return;
|
|
await api('messages.php', form({ action: 'recording', active: active ? '1' : '0' })).catch(() => {});
|
|
}
|
|
async function toggleVoice() {
|
|
if (state.voice.recorder) return stopVoice(false);
|
|
discardVoice();
|
|
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Voice recording is not supported');
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
const candidates = [
|
|
'audio/webm;codecs=opus', 'audio/webm',
|
|
'audio/mp4;codecs=mp4a.40.2', 'audio/mp4', 'audio/aac'
|
|
];
|
|
let recorder = null;
|
|
let mime = '';
|
|
const audioBitsPerSecond = voiceBitrate() * 1000;
|
|
for (const candidate of candidates) {
|
|
if (MediaRecorder.isTypeSupported && !MediaRecorder.isTypeSupported(candidate)) continue;
|
|
try {
|
|
recorder = new MediaRecorder(stream, { mimeType: candidate, audioBitsPerSecond });
|
|
mime = candidate;
|
|
break;
|
|
} catch {
|
|
try {
|
|
recorder = new MediaRecorder(stream, { mimeType: candidate });
|
|
mime = candidate;
|
|
break;
|
|
} catch {}
|
|
}
|
|
}
|
|
if (!recorder) {
|
|
try { recorder = new MediaRecorder(stream, { audioBitsPerSecond }); }
|
|
catch { recorder = new MediaRecorder(stream); }
|
|
}
|
|
state.voice.stream = stream;
|
|
state.voice.chunks = [];
|
|
state.voice.started = Date.now();
|
|
const recordedMime = recorder.mimeType || mime || 'audio/webm';
|
|
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: recordedMime }),
|
|
(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)} · ${voiceBitrate()} KBPS`;
|
|
}
|
|
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.mime = blob.type || 'audio/webm';
|
|
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)} · ${voiceBitrate()} KBPS`;
|
|
}
|
|
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.mime = '';
|
|
state.voice.duration = 0;
|
|
if ($('#voice-review')) $('#voice-review').hidden = true;
|
|
if ($('#voice-status') && !state.voice.recorder) {
|
|
$('#voice-status').textContent = `VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s · ${voiceBitrate()} KBPS`;
|
|
}
|
|
}
|
|
async function sendVoice() {
|
|
if (!state.voice.blob || state.voice.sending) return;
|
|
state.voice.sending = true;
|
|
const body = new FormData();
|
|
body.append('action', 'send_voice');
|
|
body.append('duration', state.voice.duration.toFixed(2));
|
|
body.append('reply_to_id', state.reply?.id || '');
|
|
const uploadName = state.voice.mime.includes('mp4') ? 'voice.m4a'
|
|
: state.voice.mime.includes('aac') ? 'voice.aac'
|
|
: state.voice.mime.includes('wav') ? 'voice.wav' : 'voice.webm';
|
|
body.append('voice', state.voice.blob, uploadName);
|
|
const result = await api('messages.php', { method: 'POST', body })
|
|
.catch(() => ({ error: 'Voice send failed. Polling will continue.' }));
|
|
state.voice.sending = false;
|
|
if (result.error) return toast(result.error);
|
|
acknowledgeSentMessage(result.message);
|
|
discardVoice();
|
|
clearReply();
|
|
requestPoll();
|
|
}
|
|
|
|
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), '', message.voice_mime) : esc(message.message);
|
|
const reply = message.reply_to_id
|
|
? `<div class="cc-archive-reply">Reply to ${esc(message.reply_username || `#${message.reply_to_id}`)}:
|
|
${esc(message.reply_message_type === 'voice' ? '[Voice clip]' : (message.reply_message || 'Original unavailable'))}</div>`
|
|
: '';
|
|
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)}${message.voice_bitrate_kbps ? ` · ${message.voice_bitrate_kbps}k` : ''}</span>
|
|
<div>${reply}${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">
|
|
${visibleFeatures().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.billing = null;
|
|
state.voice.playQueue = [];
|
|
state.voice.queuePlaying = false;
|
|
auth('login');
|
|
}
|
|
|
|
window.CyberChat = { init };
|
|
})(window, document);
|