ae0fb45c48
Sent messages wait for polling to catch up before rendering. Missing interleaved messages cannot appear above later ones. Messages sort by server timestamp, then ID. Timestamps are assigned atomically during SQLite insertion. Client updated to 20260609.5.
837 lines
42 KiB
JavaScript
837 lines
42 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') || '',
|
|
voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0,
|
|
chunks: [], blob: null, 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', '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 = '') {
|
|
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); }
|
|
acknowledgeSentMessage(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.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;
|
|
return { ...message, id };
|
|
}
|
|
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.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 item = document.createElement('div');
|
|
item.className = 'cc-msg' + (isOwnMessage(message) ? ' cc-msg-own' : '') + ' cc-msg-new';
|
|
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)}</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>`;
|
|
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 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);
|
|
acknowledgeSentMessage(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">
|
|
${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);
|