/**
* CyberChat Client v3.0
* Embed-ready real-time chat with registration & login
*/
(function(window, document) {
'use strict';
// ─── State ────────────────────────────────────────────────────────────────
const state = {
config: null,
auth: { authenticated: false, username: null, color: null },
chat: { lastId: 0, pollTimer: null, polling: false },
voice: {
recorder: null, stream: null, timer: null, startedAt: 0,
chunks: [], blob: null, duration: 0, fallback: null,
autoPlay: true, starting: false, queue: [], current: null,
playbackBlocked: false
},
ui: { darkMode: true, atBottom: true, screen: 'loading' },
online: 0,
};
// ─── DOM refs ─────────────────────────────────────────────────────────────
let root, msgList, msgInput, charCount, sendBtn, scrollBtn,
toastArea, onlineBadge, connStatus, voiceRecordBtn, voiceStatus,
voiceReview, voicePreview, voiceSendBtn, voiceDiscardBtn,
voiceQueueBtn, voiceQueueCount;
// ─── Utils ────────────────────────────────────────────────────────────────
const $ = (sel, ctx) => (ctx || root).querySelector(sel);
const esc = str => String(str)
.replace(/&/g,'&').replace(//g,'>')
.replace(/"/g,'"').replace(/'/g,''');
function apiPath(ep) {
// Auto-detect base path from the script tag if CYBERCHAT_BASE not explicitly set
if (!window._CC_BASE_RESOLVED) {
window._CC_BASE_RESOLVED = true;
if (!window.CYBERCHAT_BASE) {
var scripts = document.querySelectorAll('script[src]');
for (var i = 0; i < scripts.length; i++) {
var src = scripts[i].src;
if (src.indexOf('cyberchat.js') !== -1) {
// e.g. /chat/assets/js/cyberchat.js -> /chat
window.CYBERCHAT_BASE = src.replace(/\/assets\/js\/cyberchat\.js.*$/, '');
break;
}
}
}
}
var base = (window.CYBERCHAT_BASE || '').replace(/\/$/, '');
return base + '/api/' + ep;
}
function publicPath(path) {
if (/^(https?:)?\/\//.test(path || '') || String(path || '').startsWith('/')) return path;
var base = (window.CYBERCHAT_BASE || '').replace(/\/$/, '');
return base + '/' + String(path || '').replace(/^\//, '');
}
async function apiFetch(ep, opts) {
const res = await fetch(apiPath(ep), { credentials: 'include', ...(opts||{}) });
if (!res.ok && res.status !== 400 && res.status !== 401 && res.status !== 403 && res.status !== 409) {
const text = await res.text().catch(() => '');
throw new Error('HTTP ' + res.status + (text ? ': ' + text.slice(0, 120) : ''));
}
const ct = res.headers.get('content-type') || '';
if (!ct.includes('application/json')) {
const text = await res.text().catch(() => '');
throw new Error('Expected JSON but got: ' + text.slice(0, 160));
}
return res.json();
}
function fmtTime(ts) {
return new Date(ts * 1000).toLocaleTimeString('en-US',
{ hour:'2-digit', minute:'2-digit', hour12:false });
}
function linkify(str) {
return str.replace(/(https?:\/\/[^\s<>]+)/g,
'$1');
}
function saveTheme(dark) {
try { localStorage.setItem('cc_theme', dark ? 'dark' : 'light'); } catch(e){}
}
function loadTheme() {
try { return localStorage.getItem('cc_theme') !== 'light'; } catch(e){ return true; }
}
function loadVoiceAutoplay(defaultEnabled) {
try {
const saved = localStorage.getItem('cc_voice_autoplay');
return saved === null ? !!defaultEnabled : saved === 'true';
} catch(e) {
return !!defaultEnabled;
}
}
function saveVoiceAutoplay(enabled) {
try { localStorage.setItem('cc_voice_autoplay', enabled ? 'true' : 'false'); } catch(e){}
}
function toast(msg, type) {
if (!toastArea) return;
const el = document.createElement('div');
el.className = 'cc-toast ' + (type || 'error');
el.textContent = msg;
toastArea.appendChild(el);
setTimeout(() => { el.classList.add('cc-toast-fade'); setTimeout(() => el.remove(), 350); }, 2800);
}
// ─── Init ─────────────────────────────────────────────────────────────────
async function init(sel) {
root = typeof sel === 'string' ? document.querySelector(sel) : sel;
if (!root) { console.error('[CyberChat] Container not found:', sel); return; }
root.id = 'cyberchat-root';
// Load config from server
try {
const r = await fetch(apiPath('config.php'), { credentials: 'include' });
state.config = await r.json();
} catch(e) {
state.config = {
chat: { max_message_length: 500, max_username_length: 12, poll_interval_ms: 2000 },
ui: { app_title: 'CYBERCHAT', app_subtitle: 'v3.0 // SECURE CHANNEL' },
security: { min_password_length: 4 },
colors: { user_palette: ['#00ff9f','#00b8ff','#ff2d78','#ff9f00','#ff3c3c'] },
voice: { enabled: true, max_duration_seconds: 60, max_upload_bytes: 12582912, auto_play_default: true }
};
}
state.ui.darkMode = loadTheme();
state.voice.autoPlay = loadVoiceAutoplay(state.config?.voice?.auto_play_default !== false);
if (!state.ui.darkMode) document.body.classList.add('cc-light');
renderShell();
cacheRefs();
bindGlobal();
// Check existing session
try {
const check = await apiFetch('auth.php?action=check');
if (check.authenticated) {
state.auth = { authenticated: true, username: check.username, color: check.color };
showChat();
return;
}
} catch(e) {}
showAuth('register'); // Default to register for new visitors
}
// ─── Shell render (persistent chrome) ────────────────────────────────────
function renderShell() {
const cfg = state.config;
const title = esc(cfg?.ui?.app_title || 'CYBERCHAT');
const sub = esc(cfg?.ui?.app_subtitle || 'v3.0 // SECURE CHANNEL');
root.innerHTML = `
`;
}
function cacheRefs() {
toastArea = $('#cc-toasts');
onlineBadge = $('#cc-online-count');
connStatus = $('#cc-conn-dot');
}
// ─── Auth screen ──────────────────────────────────────────────────────────
function showAuth(initialTab) {
stopPolling();
state.ui.screen = 'auth';
const cfg = state.config;
const maxUser = cfg?.chat?.max_username_length || 12;
const minPass = cfg?.security?.min_password_length || 4;
const maxMsg = cfg?.chat?.max_message_length || 500;
const tab = initialTab || 'register';
$('#cc-body').classList.remove('cc-has-voice');
$('#cc-body').innerHTML = `
`;
bindAuth();
// Autofocus first input
setTimeout(() => {
const first = tab === 'register'
? $('#cc-reg-user')
: $('#cc-login-user');
first && first.focus();
}, 80);
}
function bindAuth() {
// Tab switching
const tabs = root.querySelectorAll('.cc-auth-tab');
tabs.forEach(t => {
t.addEventListener('click', () => {
const target = t.dataset.target;
const which = t.dataset.tab;
tabs.forEach(x => x.classList.remove('active'));
t.classList.add('active');
root.querySelectorAll('.cc-auth-form').forEach(f => f.classList.remove('active'));
$('#' + target).classList.add('active');
clearAuthErr();
const inp = $('#' + target + ' .cc-input');
inp && inp.focus();
});
});
// Register
const regBtn = $('#cc-reg-btn');
if (regBtn) {
regBtn.addEventListener('click', doRegister);
['cc-reg-user','cc-reg-pass','cc-reg-pass2'].forEach(id => {
const el = $('#' + id);
if (el) el.addEventListener('keydown', e => { if(e.key==='Enter') doRegister(); });
});
}
// Login
const loginBtn = $('#cc-login-btn');
if (loginBtn) {
loginBtn.addEventListener('click', doLogin);
['cc-login-user','cc-login-pass'].forEach(id => {
const el = $('#' + id);
if (el) el.addEventListener('keydown', e => { if(e.key==='Enter') doLogin(); });
});
}
}
function showAuthErr(msg) {
const el = $('#cc-auth-err');
if (el) { el.textContent = '⚠ ' + msg; el.classList.add('visible'); }
}
function clearAuthErr() {
const el = $('#cc-auth-err');
if (el) { el.textContent = ''; el.classList.remove('visible'); }
}
async function doRegister() {
const user = ($('#cc-reg-user')?.value || '').trim();
const pass = $('#cc-reg-pass')?.value || '';
const pass2 = $('#cc-reg-pass2')?.value || '';
const btn = $('#cc-reg-btn');
const cfg = state.config;
const maxUser = cfg?.chat?.max_username_length || 12;
const minPass = cfg?.security?.min_password_length || 4;
clearAuthErr();
if (!user) return showAuthErr('Please enter a callsign');
if (user.length > maxUser) return showAuthErr(`Callsign max ${maxUser} characters`);
if (!/^[a-zA-Z0-9_\-]+$/.test(user)) return showAuthErr('Letters, numbers, _ and - only');
if (!pass) return showAuthErr('Please enter a passphrase');
if (pass.length < minPass) return showAuthErr(`Passphrase min ${minPass} characters`);
if (pass !== pass2) return showAuthErr('Passphrases do not match');
btn.disabled = true;
btn.querySelector('.cc-btn-inner').textContent = 'CREATING...';
const fd = new FormData();
fd.append('action', 'register');
fd.append('username', user);
fd.append('password', pass);
try {
const res = await apiFetch('auth.php', { method:'POST', body:fd });
if (res.success) {
state.auth = { authenticated: true, username: res.username, color: res.color };
toast('Welcome, ' + res.username + '!', 'success');
showChat();
} else {
showAuthErr(res.error || 'Registration failed');
btn.disabled = false;
btn.querySelector('.cc-btn-inner').textContent = 'CREATE IDENTITY';
}
} catch(e) {
showAuthErr('Server error: ' + (e.message || 'cannot reach /api/auth.php — check CYBERCHAT_BASE and PHP'));
btn.disabled = false;
btn.querySelector('.cc-btn-inner').textContent = 'CREATE IDENTITY';
}
}
async function doLogin() {
const user = ($('#cc-login-user')?.value || '').trim();
const pass = $('#cc-login-pass')?.value || '';
const btn = $('#cc-login-btn');
clearAuthErr();
if (!user) return showAuthErr('Please enter your callsign');
if (!pass) return showAuthErr('Please enter your passphrase');
btn.disabled = true;
btn.querySelector('.cc-btn-inner').textContent = 'AUTHENTICATING...';
const fd = new FormData();
fd.append('action', 'login');
fd.append('username', user);
fd.append('password', pass);
try {
const res = await apiFetch('auth.php', { method:'POST', body:fd });
if (res.success) {
state.auth = { authenticated: true, username: res.username, color: res.color };
toast('Access granted, ' + res.username, 'success');
showChat();
} else {
showAuthErr(res.error || 'Authentication failed');
btn.disabled = false;
btn.querySelector('.cc-btn-inner').textContent = 'AUTHENTICATE';
}
} catch(e) {
showAuthErr('Server error: ' + (e.message || 'cannot reach /api/auth.php — check CYBERCHAT_BASE and PHP'));
btn.disabled = false;
btn.querySelector('.cc-btn-inner').textContent = 'AUTHENTICATE';
}
}
// ─── Chat screen ──────────────────────────────────────────────────────────
function showChat() {
state.ui.screen = 'chat';
const { username, color } = state.auth;
const maxMsg = state.config?.chat?.max_message_length || 500;
const voiceEnabled = state.config?.voice?.enabled !== false;
const voiceMax = state.config?.voice?.max_duration_seconds || 60;
const today = new Date().toLocaleDateString('en-US',
{ weekday:'short', month:'short', day:'numeric', year:'numeric' });
$('#cc-body').classList.toggle('cc-has-voice', voiceEnabled);
$('#cc-body').innerHTML = `
YOU:
${esc(username)}
${esc(today.toUpperCase())}
${voiceEnabled ? `
` : ''}
${maxMsg}
`;
// Cache
msgList = $('#cc-messages');
msgInput = $('#cc-msg-input');
charCount = $('#cc-char-count');
sendBtn = $('#cc-send-btn');
scrollBtn = $('#cc-scroll-btn');
voiceRecordBtn = $('#cc-voice-record');
voiceStatus = $('#cc-voice-status');
voiceReview = $('#cc-voice-review');
voicePreview = $('#cc-voice-preview');
voiceSendBtn = $('#cc-voice-send');
voiceDiscardBtn = $('#cc-voice-discard');
voiceQueueBtn = $('#cc-voice-queue');
voiceQueueCount = $('#cc-voice-queue-count');
bindChat();
updateVoiceQueueUI();
state.chat.lastId = 0;
startPolling();
msgInput && msgInput.focus();
}
function bindChat() {
$('#cc-logout-btn')?.addEventListener('click', doLogout);
msgInput?.addEventListener('input', updateCharCount);
msgInput?.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); }
});
sendBtn?.addEventListener('click', doSend);
voiceRecordBtn?.addEventListener('click', toggleVoiceRecording);
voiceSendBtn?.addEventListener('click', sendVoiceClip);
voiceDiscardBtn?.addEventListener('click', discardVoiceClip);
voiceQueueBtn?.addEventListener('click', () => {
state.voice.playbackBlocked = false;
playNextVoice();
});
$('#cc-voice-autoplay')?.addEventListener('change', e => {
state.voice.autoPlay = !!e.target.checked;
saveVoiceAutoplay(state.voice.autoPlay);
if (state.voice.autoPlay) {
state.voice.playbackBlocked = false;
playNextVoice();
}
updateVoiceQueueUI();
});
msgList?.addEventListener('scroll', () => {
const el = msgList;
state.ui.atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 40;
const show = !state.ui.atBottom && el.scrollHeight > el.clientHeight + 120;
scrollBtn?.classList.toggle('visible', show);
});
scrollBtn?.addEventListener('click', () => scrollToBottom(false));
}
function updateCharCount() {
const max = state.config?.chat?.max_message_length || 500;
const rem = max - (msgInput?.value?.length || 0);
if (!charCount) return;
charCount.textContent = rem;
charCount.className = 'cc-compose-counter' +
(rem < 20 ? ' danger' : rem < 60 ? ' warn' : '');
}
async function doLogout() {
discardVoiceClip();
clearVoiceQueue();
stopPolling();
try {
const fd = new FormData();
fd.append('action','logout');
await apiFetch('auth.php', { method:'POST', body:fd });
} catch(e){}
state.auth = { authenticated:false, username:null, color:null };
state.chat.lastId = 0;
showAuth('login');
}
// ─── Voice clips ──────────────────────────────────────────────────────────
function supportedWebmMime() {
if (!window.MediaRecorder) return '';
const options = ['audio/webm;codecs=opus', 'audio/webm'];
return options.find(type => !MediaRecorder.isTypeSupported || MediaRecorder.isTypeSupported(type)) || '';
}
async function toggleVoiceRecording() {
if (state.voice.starting) return;
if (state.voice.recorder || state.voice.fallback) {
stopVoiceCapture(false);
return;
}
discardVoiceClip();
if (!navigator.mediaDevices?.getUserMedia) {
toast('Voice recording is not supported in this browser', 'error');
return;
}
state.voice.starting = true;
voiceRecordBtn?.setAttribute('disabled', 'disabled');
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
state.voice.stream = stream;
state.voice.startedAt = Date.now();
state.voice.chunks = [];
const mime = supportedWebmMime();
if (mime) {
try {
startMediaRecorder(stream, mime);
} catch (e) {
startWavRecorder(stream);
}
} else {
startWavRecorder(stream);
}
voiceRecordBtn?.classList.add('recording');
const label = $('#cc-voice-record-label');
if (label) label.textContent = 'STOP';
updateVoiceTimer();
state.voice.timer = setInterval(updateVoiceTimer, 200);
} catch (e) {
cleanupVoiceStream();
toast(e?.name === 'NotAllowedError' ? 'Microphone permission was denied' : 'Could not access microphone', 'error');
} finally {
state.voice.starting = false;
voiceRecordBtn?.removeAttribute('disabled');
}
}
function startMediaRecorder(stream, mime) {
const recorder = new MediaRecorder(stream, { mimeType: mime });
recorder.addEventListener('dataavailable', e => {
if (e.data?.size) state.voice.chunks.push(e.data);
});
recorder.addEventListener('stop', () => {
if (recorder._ccCanceled) {
state.voice.recorder = null;
return;
}
const duration = Math.min((Date.now() - state.voice.startedAt) / 1000,
state.config?.voice?.max_duration_seconds || 60);
const blob = new Blob(state.voice.chunks, { type: recorder.mimeType || mime || 'audio/webm' });
finishVoiceClip(blob, duration);
state.voice.recorder = null;
}, { once: true });
recorder.start(250);
state.voice.recorder = recorder;
}
function startWavRecorder(stream) {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) throw new Error('Audio recording unsupported');
const context = new AudioCtx();
const source = context.createMediaStreamSource(stream);
const processor = context.createScriptProcessor(4096, 1, 1);
const samples = [];
processor.onaudioprocess = e => samples.push(new Float32Array(e.inputBuffer.getChannelData(0)));
source.connect(processor);
processor.connect(context.destination);
state.voice.fallback = { context, source, processor, samples, sampleRate: context.sampleRate };
}
function updateVoiceTimer() {
const max = state.config?.voice?.max_duration_seconds || 60;
const elapsed = Math.min((Date.now() - state.voice.startedAt) / 1000, max);
if (voiceStatus) voiceStatus.textContent = 'RECORDING ' + formatDuration(elapsed) + ' / ' + formatDuration(max);
if (elapsed >= max) stopVoiceCapture(false);
}
function stopVoiceCapture(cancel) {
clearInterval(state.voice.timer);
state.voice.timer = null;
const duration = state.voice.startedAt ? (Date.now() - state.voice.startedAt) / 1000 : 0;
if (state.voice.recorder && state.voice.recorder.state !== 'inactive') {
if (cancel) {
state.voice.recorder._ccCanceled = true;
state.voice.chunks = [];
}
state.voice.recorder.stop();
} else if (state.voice.fallback) {
const wav = state.voice.fallback;
wav.processor.disconnect();
wav.source.disconnect();
wav.context.close();
state.voice.fallback = null;
if (!cancel) finishVoiceClip(encodeWav(wav.samples, wav.sampleRate), duration);
}
cleanupVoiceStream();
voiceRecordBtn?.classList.remove('recording');
const label = $('#cc-voice-record-label');
if (label) label.textContent = 'RECORD';
if (cancel && voiceStatus) {
voiceStatus.textContent = 'VOICE CLIP · MAX ' + (state.config?.voice?.max_duration_seconds || 60) + 's';
}
}
function cleanupVoiceStream() {
state.voice.stream?.getTracks().forEach(track => track.stop());
state.voice.stream = null;
}
function finishVoiceClip(blob, duration) {
if (!blob?.size || duration < 0.25) {
discardVoiceClip();
toast('Voice clip was too short', 'error');
return;
}
state.voice.blob = blob;
state.voice.duration = duration;
if (voicePreview) {
if (voicePreview.dataset.objectUrl) URL.revokeObjectURL(voicePreview.dataset.objectUrl);
const url = URL.createObjectURL(blob);
voicePreview.src = url;
voicePreview.dataset.objectUrl = url;
}
if (voiceReview) voiceReview.hidden = false;
if (voiceStatus) voiceStatus.textContent = 'READY · ' + formatDuration(duration);
}
function discardVoiceClip() {
if (state.voice.recorder || state.voice.fallback) stopVoiceCapture(true);
if (voicePreview?.dataset.objectUrl) URL.revokeObjectURL(voicePreview.dataset.objectUrl);
if (voicePreview) {
voicePreview.removeAttribute('src');
delete voicePreview.dataset.objectUrl;
voicePreview.load();
}
state.voice.blob = null;
state.voice.duration = 0;
if (voiceReview) voiceReview.hidden = true;
if (voiceStatus) voiceStatus.textContent =
'VOICE CLIP · MAX ' + (state.config?.voice?.max_duration_seconds || 60) + 's';
}
async function sendVoiceClip() {
if (!state.voice.blob || voiceSendBtn?.disabled) return;
const maxBytes = state.config?.voice?.max_upload_bytes || 12582912;
if (state.voice.blob.size > maxBytes) {
toast('Voice clip exceeds the upload limit', 'error');
return;
}
voiceSendBtn.disabled = true;
const fd = new FormData();
fd.append('action', 'send_voice');
fd.append('duration', state.voice.duration.toFixed(2));
fd.append('voice', state.voice.blob, state.voice.blob.type.includes('wav') ? 'voice.wav' : 'voice.webm');
try {
const res = await apiFetch('messages.php', { method: 'POST', body: fd });
if (res.error) {
toast(res.error, 'error');
} else if (res.success && res.message) {
if (!root.querySelector('[data-id="' + res.message.id + '"]')) appendMsg(res.message, true);
state.chat.lastId = Math.max(state.chat.lastId, res.message.id);
discardVoiceClip();
}
} catch (e) {
toast('Voice send failed: ' + (e.message || 'check server'), 'error');
}
if (voiceSendBtn) voiceSendBtn.disabled = false;
}
function encodeWav(chunks, sampleRate) {
const length = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const buffer = new ArrayBuffer(44 + length * 2);
const view = new DataView(buffer);
const write = (offset, text) => {
for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i));
};
write(0, 'RIFF'); view.setUint32(4, 36 + length * 2, true); write(8, 'WAVE');
write(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true);
view.setUint16(22, 1, true); view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true); view.setUint16(32, 2, true);
view.setUint16(34, 16, true); write(36, 'data'); view.setUint32(40, length * 2, true);
let offset = 44;
chunks.forEach(chunk => chunk.forEach(sample => {
const value = Math.max(-1, Math.min(1, sample));
view.setInt16(offset, value < 0 ? value * 0x8000 : value * 0x7fff, true);
offset += 2;
}));
return new Blob([view], { type: 'audio/wav' });
}
function formatDuration(seconds) {
const total = Math.max(0, Math.ceil(Number(seconds) || 0));
return Math.floor(total / 60) + ':' + String(total % 60).padStart(2, '0');
}
// Incoming voice clips use one FIFO player so multiple polls never overlap.
function enqueueVoiceClip(audio) {
if (!audio || audio.dataset.voiceQueued === 'true') return;
audio.dataset.voiceQueued = 'true';
state.voice.queue.push(audio);
audio.addEventListener('play', () => {
if (state.voice.current === audio) return;
const index = state.voice.queue.indexOf(audio);
if (index !== -1) state.voice.queue.splice(index, 1);
delete audio.dataset.voiceQueued;
updateVoiceQueueUI();
}, { once: true });
updateVoiceQueueUI();
if (state.voice.autoPlay && !state.voice.playbackBlocked) playNextVoice();
}
async function playNextVoice() {
if (state.voice.current || !state.voice.queue.length) {
updateVoiceQueueUI();
return;
}
const audio = state.voice.queue.shift();
if (!audio?.isConnected) {
delete audio?.dataset?.voiceQueued;
playNextVoice();
return;
}
state.voice.current = audio;
audio.closest('.cc-msg')?.classList.add('cc-msg-voice-playing');
let finished = false;
const finish = () => {
if (finished) return;
finished = true;
audio.closest('.cc-msg')?.classList.remove('cc-msg-voice-playing');
delete audio.dataset.voiceQueued;
if (state.voice.current === audio) state.voice.current = null;
updateVoiceQueueUI();
if (state.voice.autoPlay && !state.voice.playbackBlocked) playNextVoice();
};
audio.addEventListener('ended', finish, { once: true });
audio.addEventListener('error', finish, { once: true });
updateVoiceQueueUI();
try {
await audio.play();
} catch (e) {
audio.removeEventListener('ended', finish);
audio.removeEventListener('error', finish);
audio.closest('.cc-msg')?.classList.remove('cc-msg-voice-playing');
state.voice.current = null;
state.voice.queue.unshift(audio);
state.voice.playbackBlocked = true;
updateVoiceQueueUI();
}
}
function clearVoiceQueue() {
if (state.voice.current) {
state.voice.current.pause();
state.voice.current.closest('.cc-msg')?.classList.remove('cc-msg-voice-playing');
delete state.voice.current.dataset.voiceQueued;
}
state.voice.queue.forEach(audio => delete audio.dataset.voiceQueued);
state.voice.queue = [];
state.voice.current = null;
state.voice.playbackBlocked = false;
updateVoiceQueueUI();
}
function updateVoiceQueueUI() {
const waiting = state.voice.queue.length;
const total = waiting + (state.voice.current ? 1 : 0);
if (voiceQueueCount) voiceQueueCount.textContent = String(total);
if (!voiceQueueBtn) return;
voiceQueueBtn.disabled = total === 0;
voiceQueueBtn.classList.toggle('playing', !!state.voice.current);
voiceQueueBtn.classList.toggle('blocked', state.voice.playbackBlocked);
const label = state.voice.current
? 'PLAYING'
: state.voice.playbackBlocked
? 'TAP TO PLAY'
: 'PLAY NEXT';
voiceQueueBtn.firstChild.textContent = label + ' ';
}
// ─── Send ─────────────────────────────────────────────────────────────────
async function doSend() {
const msg = msgInput?.value?.trim();
if (!msg || sendBtn?.disabled) return;
const max = state.config?.chat?.max_message_length || 500;
if (msg.length > max) { toast('Message too long (max ' + max + ' chars)', 'error'); return; }
msgInput.value = '';
updateCharCount();
sendBtn.disabled = true;
const fd = new FormData();
fd.append('action','send');
fd.append('message', msg);
try {
const res = await apiFetch('messages.php', { method:'POST', body:fd });
if (res.error) {
toast(res.error, 'error');
msgInput.value = msg;
updateCharCount();
} else if (res.success && res.message) {
if (!root.querySelector('[data-id="' + res.message.id + '"]')) {
appendMsg(res.message, true);
state.chat.lastId = Math.max(state.chat.lastId, res.message.id);
}
}
} catch(e) {
toast('Send failed: ' + (e.message || 'check server'), 'error');
msgInput.value = msg;
updateCharCount();
}
sendBtn.disabled = false;
msgInput?.focus();
}
// ─── Polling ─────────────────────────────────────────────────────────────
function startPolling() {
stopPolling();
doPoll();
const iv = state.config?.chat?.poll_interval_ms || 2000;
state.chat.pollTimer = setInterval(doPoll, iv);
}
function stopPolling() {
if (state.chat.pollTimer) {
clearInterval(state.chat.pollTimer);
state.chat.pollTimer = null;
}
state.chat.polling = false;
}
async function doPoll() {
if (state.chat.polling) return;
state.chat.polling = true;
try {
const since = state.chat.lastId;
const data = await apiFetch('messages.php?action=poll&since=' + since);
if (data.error === 'Not authenticated' || data.error === 'Session expired') {
stopPolling();
state.auth.authenticated = false;
toast('Session expired — please login again', 'error');
showAuth('login');
state.chat.polling = false;
return;
}
setConn('online');
if (Array.isArray(data.messages)) {
if (since === 0) {
renderMsgList(data.messages);
} else if (data.messages.length > 0) {
data.messages.forEach(m => appendMsg(m, true));
state.chat.lastId = data.messages[data.messages.length - 1].id;
}
}
if (data.online !== undefined && onlineBadge) {
onlineBadge.textContent = data.online;
}
} catch(e) {
setConn('offline');
}
state.chat.polling = false;
}
// ─── Message rendering ────────────────────────────────────────────────────
function renderMsgList(msgs) {
if (!msgList) return;
if (!msgs.length) {
msgList.innerHTML = '// NO MESSAGES TODAY — BE THE FIRST TO TRANSMIT
';
return;
}
msgList.innerHTML = '';
msgs.forEach(m => appendMsg(m, false));
scrollToBottom(true);
if (msgs.length) state.chat.lastId = msgs[msgs.length - 1].id;
}
function appendMsg(msg, animate) {
if (!msgList) return;
// Remove loading placeholder if present
const loader = msgList.querySelector('.cc-loading-msg, .cc-empty-day');
if (loader) loader.remove();
const isOwn = msg.username === state.auth.username;
const div = document.createElement('div');
div.className = 'cc-msg' + (isOwn ? ' cc-msg-own' : '') + (animate ? ' cc-msg-new' : '');
div.dataset.id = msg.id;
const isVoice = msg.message_type === 'voice' && msg.voice_url;
const safeText = isVoice
? 'VOICE ' +
formatDuration(msg.voice_duration) + ''
: linkify(esc(msg.message));
div.innerHTML =
'' + fmtTime(msg.created_at) + '' +
'' + esc(msg.username) + '' +
'›' +
'' + safeText + '';
msgList.appendChild(div);
if (isVoice && animate && !isOwn) enqueueVoiceClip(div.querySelector('audio'));
if (state.ui.atBottom) scrollToBottom(false);
}
function scrollToBottom(instant) {
if (!msgList) return;
if (instant) { msgList.scrollTop = msgList.scrollHeight; }
else { msgList.scrollTo({ top: msgList.scrollHeight, behavior: 'smooth' }); }
state.ui.atBottom = true;
scrollBtn?.classList.remove('visible');
}
// ─── Global events ────────────────────────────────────────────────────────
function bindGlobal() {
// Theme toggle
$('#cc-theme-btn')?.addEventListener('click', () => {
state.ui.darkMode = !state.ui.darkMode;
document.body.classList.toggle('cc-light', !state.ui.darkMode);
const icon = $('#cc-theme-icon');
if (icon) icon.textContent = state.ui.darkMode ? '☀' : '☾';
saveTheme(state.ui.darkMode);
});
const themeIcon = $('#cc-theme-icon');
if (themeIcon) themeIcon.textContent = state.ui.darkMode ? '☀' : '☾';
}
// ─── Conn status ─────────────────────────────────────────────────────────
function setConn(status) {
if (!connStatus) return;
connStatus.className = 'cc-conn-dot ' + status;
}
// ─── Public ───────────────────────────────────────────────────────────────
window.CyberChat = { init };
})(window, document);