/** * 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 = `
online
`; } 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 = `
IDENTIFY YOURSELF

New here? Choose your callsign and set a passphrase.

Welcome back. Enter your callsign and passphrase.

`; 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())}
LOADING CHANNEL DATA...
${voiceEnabled ? `
VOICE CLIP · MAX ${voiceMax}s
` : ''}
${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);