ccdbc47642
Replaced polling with a single queued cursor worker, timeout recovery, backoff, deduplication, chronological insertion, and capped DOM history in cyberchat-v4.js. Removed Groups UI, API, configuration, tier features, admin controls, and archive exposure. Legacy group records remain stored but inaccessible. Added configurable polling limits in config.json.
765 lines
39 KiB
JavaScript
765 lines
39 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, queue: [], queuedIds: new Set(), initial: true
|
|
},
|
|
loginChallenge: initialChallenge,
|
|
verifyToken: new URLSearchParams(location.search).get('verify') || '',
|
|
voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0,
|
|
chunks: [], blob: null, duration: 0, url: '', autoSend: false, autoPlay: false,
|
|
playQueue: [], queuePlaying: false, autoplayWarningShown: false, sending: false }
|
|
};
|
|
let root;
|
|
let wakeListenersBound = false;
|
|
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 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,
|
|
poll_timeout_ms: 12000, poll_max_backoff_ms: 15000, max_rendered_messages: 500
|
|
},
|
|
security: { min_password_length: 4 }, voice: { enabled: true, max_duration_seconds: 60 },
|
|
ui: { app_title: 'CYBERCHAT', app_subtitle: 'SECURE CHANNEL' }
|
|
}));
|
|
if (!wakeListenersBound) {
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden) requestPoll();
|
|
});
|
|
window.addEventListener('online', requestPoll);
|
|
wakeListenersBound = true;
|
|
}
|
|
try { document.body.classList.toggle('cc-light', localStorage.getItem('cc_theme') === 'light'); } catch (e) {}
|
|
shell();
|
|
api('stats.php', form({ event: 'visit', path: location.pathname })).catch(() => {});
|
|
const check = await api('auth.php', null, 'action=check').catch(() => ({ authenticated: false }));
|
|
if (check.authenticated) {
|
|
state.user = check.user;
|
|
await enter();
|
|
} else auth(state.loginChallenge ? '2fa' : 'register');
|
|
}
|
|
|
|
function shell() {
|
|
root.innerHTML = `
|
|
<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() {
|
|
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 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></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>`;
|
|
$('#message').addEventListener('input', event => $('#count').textContent = max - event.target.value.length);
|
|
$('#message').addEventListener('keydown', event => {
|
|
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendText(); }
|
|
});
|
|
$('#send').addEventListener('click', sendText);
|
|
$('#record')?.addEventListener('click', toggleVoice);
|
|
$('#voice-send')?.addEventListener('click', sendVoice);
|
|
$('#voice-discard')?.addEventListener('click', discardVoice);
|
|
$('#auto-send')?.addEventListener('change', event => { state.voice.autoSend = event.target.checked; });
|
|
$('#auto-play')?.addEventListener('change', event => {
|
|
state.voice.autoPlay = event.target.checked;
|
|
try { localStorage.setItem('cc_auto_voice_play', event.target.checked ? '1' : '0'); } catch (e) {}
|
|
});
|
|
bindAudioPlayers($('#cc-view'));
|
|
state.cursor = 0;
|
|
startPoll();
|
|
}
|
|
async function sendText() {
|
|
const input = $('#message');
|
|
const message = input.value.trim();
|
|
if (!message) return;
|
|
input.value = '';
|
|
const result = await api('messages.php', form({ action: 'send', message }))
|
|
.catch(() => ({ error: 'Message send failed. Polling will continue.' }));
|
|
if (result.error) { input.value = message; return toast(result.error); }
|
|
appendMessage(result.message);
|
|
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.queue = [];
|
|
state.poll.queuedIds.clear();
|
|
void pollWorker(state.poll.generation);
|
|
}
|
|
function requestPoll() {
|
|
if (state.view !== 'chat' || !state.poll.active) return;
|
|
state.poll.immediate = true;
|
|
const wake = state.poll.wakeResolve;
|
|
if (wake) wake();
|
|
}
|
|
function stopPoll() {
|
|
state.poll.active = false;
|
|
state.poll.generation++;
|
|
state.poll.controller?.abort();
|
|
state.poll.controller = null;
|
|
if (state.poll.wakeTimer) clearTimeout(state.poll.wakeTimer);
|
|
state.poll.wakeTimer = null;
|
|
const wake = state.poll.wakeResolve;
|
|
state.poll.wakeResolve = null;
|
|
if (wake) wake();
|
|
state.poll.immediate = false;
|
|
state.poll.queue = [];
|
|
state.poll.queuedIds.clear();
|
|
}
|
|
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;
|
|
enqueuePollMessages(result.messages, !initial);
|
|
state.poll.initial = false;
|
|
const messageCursor = result.messages.reduce(
|
|
(cursor, message) => Math.max(cursor, Number(message.id) || 0),
|
|
requestedSince
|
|
);
|
|
const nextCursor = Math.max(requestedSince, Number(result.cursor) || 0, messageCursor);
|
|
state.cursor = Math.max(state.cursor, nextCursor);
|
|
const recording = $('#recording');
|
|
if (recording) {
|
|
recording.hidden = !result.recording?.length;
|
|
recording.textContent = result.recording?.length
|
|
? result.recording.map(user => user.username).join(', ')
|
|
+ (result.recording.length === 1 ? ' is' : ' are') + ' recording...'
|
|
: '';
|
|
}
|
|
return { ok: true, hasMore: Boolean(result.has_more) && nextCursor > requestedSince };
|
|
} catch (error) {
|
|
if (state.poll.active && state.poll.generation === generation) {
|
|
$('#cc-conn-dot').className = 'cc-conn-dot offline';
|
|
}
|
|
return { ok: false, hasMore: false };
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
if (state.poll.controller === controller) state.poll.controller = null;
|
|
}
|
|
}
|
|
function enqueuePollMessages(messages, incoming) {
|
|
const list = $('#messages');
|
|
if (!list) return;
|
|
const sorted = [...messages].sort((a, b) => (Number(a.id) || 0) - (Number(b.id) || 0));
|
|
sorted.forEach(message => {
|
|
const numericId = Number(message.id);
|
|
if (!Number.isInteger(numericId) || numericId < 1) return;
|
|
const id = String(numericId);
|
|
if (state.poll.queuedIds.has(id) || list.querySelector(`[data-id="${id}"]`)) return;
|
|
state.poll.queuedIds.add(id);
|
|
state.poll.queue.push({ message, incoming });
|
|
});
|
|
drainPollQueue();
|
|
if (!list.querySelector('.cc-msg') && state.poll.queue.length === 0) {
|
|
list.innerHTML = '<div class="cc-empty-day">NO MESSAGES IN THIS CHANNEL TODAY</div>';
|
|
}
|
|
list.scrollTop = list.scrollHeight;
|
|
}
|
|
function drainPollQueue() {
|
|
while (state.poll.queue.length) {
|
|
const entry = state.poll.queue.shift();
|
|
const id = String(entry.message?.id ?? '');
|
|
try {
|
|
appendMessage(entry.message, entry.incoming);
|
|
} catch (error) {
|
|
console.error('Could not render chat message', error);
|
|
} finally {
|
|
state.poll.queuedIds.delete(id);
|
|
}
|
|
}
|
|
}
|
|
function appendMessage(message, incoming = false) {
|
|
const list = $('#messages');
|
|
const id = Number(message?.id);
|
|
if (!list || !Number.isInteger(id) || id < 1 || list.querySelector(`[data-id="${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 = String(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>`;
|
|
const nextMessage = Array.from(list.querySelectorAll('.cc-msg'))
|
|
.find(existing => Number(existing.dataset.id) > id);
|
|
if (nextMessage) list.insertBefore(item, nextMessage);
|
|
else list.appendChild(item);
|
|
trimMessageBuffer(list);
|
|
bindAudioPlayers(item);
|
|
if (incoming && message.message_type === 'voice' && message.username !== state.user.username) {
|
|
enqueueVoiceAutoplay(item);
|
|
}
|
|
list.scrollTop = list.scrollHeight;
|
|
}
|
|
function trimMessageBuffer(list) {
|
|
const limit = Math.max(20, Number(state.config.chat?.max_rendered_messages) || 500);
|
|
const messages = Array.from(list.querySelectorAll('.cc-msg'));
|
|
messages.slice(0, Math.max(0, messages.length - limit)).forEach(message => {
|
|
$('audio', message)?.pause();
|
|
message.remove();
|
|
});
|
|
}
|
|
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' })).catch(() => {});
|
|
}
|
|
async function toggleVoice() {
|
|
if (state.voice.recorder) return stopVoice(false);
|
|
discardVoice();
|
|
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Voice recording is not supported');
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
const mime = ['audio/webm;codecs=opus', 'audio/webm'].find(type => MediaRecorder.isTypeSupported(type));
|
|
if (!mime) { stream.getTracks().forEach(track => track.stop()); return toast('WebM recording is not supported'); }
|
|
state.voice.stream = stream;
|
|
state.voice.chunks = [];
|
|
state.voice.started = Date.now();
|
|
const recorder = new MediaRecorder(stream, { mimeType: mime });
|
|
recorder.addEventListener('dataavailable', event => { if (event.data.size) state.voice.chunks.push(event.data); });
|
|
recorder.addEventListener('stop', () => {
|
|
if (!recorder._discard) finishVoice(new Blob(state.voice.chunks, { type: mime }), (Date.now() - state.voice.started) / 1000);
|
|
}, { once: true });
|
|
recorder.start(250);
|
|
state.voice.recorder = recorder;
|
|
$('#record').classList.add('recording');
|
|
$('#record-label').textContent = 'STOP';
|
|
recording(true);
|
|
state.voice.heartbeat = setInterval(() => recording(true), 4000);
|
|
state.voice.timer = setInterval(voiceTimer, 200);
|
|
voiceTimer();
|
|
} catch (e) { toast(e.name === 'NotAllowedError' ? 'Microphone permission was denied' : 'Could not access microphone'); }
|
|
}
|
|
function voiceTimer() {
|
|
const max = state.config.voice?.max_duration_seconds || 60;
|
|
const elapsed = Math.min((Date.now() - state.voice.started) / 1000, max);
|
|
if ($('#voice-status')) $('#voice-status').textContent = `RECORDING ${duration(elapsed)} / ${duration(max)}`;
|
|
if (elapsed >= max) stopVoice(false);
|
|
}
|
|
function stopVoice(discard) {
|
|
clearInterval(state.voice.timer);
|
|
clearInterval(state.voice.heartbeat);
|
|
state.voice.timer = state.voice.heartbeat = null;
|
|
recording(false);
|
|
if (state.voice.recorder && state.voice.recorder.state !== 'inactive') {
|
|
state.voice.recorder._discard = discard;
|
|
state.voice.recorder.stop();
|
|
}
|
|
state.voice.recorder = null;
|
|
state.voice.stream?.getTracks().forEach(track => track.stop());
|
|
state.voice.stream = null;
|
|
$('#record')?.classList.remove('recording');
|
|
if ($('#record-label')) $('#record-label').textContent = 'RECORD';
|
|
}
|
|
function finishVoice(blob, length) {
|
|
if (!blob.size || length < 0.25) return toast('Voice clip was too short');
|
|
state.voice.blob = blob;
|
|
state.voice.duration = length;
|
|
if (state.voice.autoSend && feature('auto_voice_send')) return sendVoice();
|
|
if (state.voice.url) URL.revokeObjectURL(state.voice.url);
|
|
state.voice.url = URL.createObjectURL(blob);
|
|
$('#voice-preview').src = state.voice.url;
|
|
$('#voice-preview').load();
|
|
$('#voice-review').hidden = false;
|
|
$('#voice-status').textContent = 'READY ' + duration(length);
|
|
}
|
|
function discardVoice() {
|
|
if (state.voice.recorder) stopVoice(true);
|
|
if (state.voice.url) URL.revokeObjectURL(state.voice.url);
|
|
state.voice.url = '';
|
|
state.voice.blob = null;
|
|
state.voice.duration = 0;
|
|
if ($('#voice-review')) $('#voice-review').hidden = true;
|
|
}
|
|
async function sendVoice() {
|
|
if (!state.voice.blob || state.voice.sending) return;
|
|
state.voice.sending = true;
|
|
const body = new FormData();
|
|
body.append('action', 'send_voice');
|
|
body.append('duration', state.voice.duration.toFixed(2));
|
|
body.append('voice', state.voice.blob, 'voice.webm');
|
|
const result = await api('messages.php', { method: 'POST', body })
|
|
.catch(() => ({ error: 'Voice send failed. Polling will continue.' }));
|
|
state.voice.sending = false;
|
|
if (result.error) return toast(result.error);
|
|
appendMessage(result.message);
|
|
discardVoice();
|
|
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)) : 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.billing = null;
|
|
state.voice.playQueue = [];
|
|
state.voice.queuePlaying = false;
|
|
auth('login');
|
|
}
|
|
|
|
window.CyberChat = { init };
|
|
})(window, document);
|