Files
chat/assets/js/cyberchat.js
T
Ty Clifford 33a176f18e v2.0.0
2026-06-08 12:26:08 -04:00

1028 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
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,
'<a href="$1" target="_blank" rel="noopener noreferrer" style="color:inherit;opacity:0.75;text-decoration:underline">$1</a>');
}
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 = `
<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">${title}</div>
<div class="cc-logo-sub">${sub}</div>
</div>
</div>
<div class="cc-header-center">
<span class="cc-conn-dot" id="cc-conn-dot"></span>
<span id="cc-online-count" class="cc-online-num">—</span>
<span class="cc-online-label">online</span>
</div>
<div class="cc-header-right">
<button class="cc-theme-btn" id="cc-theme-btn" title="Toggle light/dark mode">
<span id="cc-theme-icon">☀</span>
</button>
</div>
</header>
<div class="cc-body" id="cc-body">
<!-- Content injected per screen -->
</div>
`;
}
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 = `
<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>
<div class="cc-auth-tabs" id="cc-auth-tabs">
<button class="cc-auth-tab ${tab==='register'?'active':''}" data-target="cc-reg" data-tab="register">
<span class="cc-tab-icon">✦</span> REGISTER
</button>
<button class="cc-auth-tab ${tab==='login'?'active':''}" data-target="cc-login" data-tab="login">
<span class="cc-tab-icon">▶</span> LOGIN
</button>
</div>
<!-- REGISTER -->
<div class="cc-auth-form ${tab==='register'?'active':''}" id="cc-reg">
<p class="cc-form-hint">New here? Choose your callsign and set a passphrase.</p>
<div class="cc-field">
<label class="cc-field-label">CALLSIGN <span class="cc-field-note">max ${maxUser} chars, letters/numbers/-/_</span></label>
<input id="cc-reg-user" class="cc-input" type="text"
maxlength="${maxUser}" placeholder="choose your name..."
autocomplete="username" spellcheck="false" autocorrect="off">
</div>
<div class="cc-field">
<label class="cc-field-label">PASSPHRASE <span class="cc-field-note">min ${minPass} chars</span></label>
<input id="cc-reg-pass" class="cc-input" type="password"
placeholder="create a passphrase..."
autocomplete="new-password">
</div>
<div class="cc-field">
<label class="cc-field-label">CONFIRM PASSPHRASE</label>
<input id="cc-reg-pass2" class="cc-input" type="password"
placeholder="repeat passphrase..."
autocomplete="new-password">
</div>
<button class="cc-btn cc-btn-register" id="cc-reg-btn">
<span class="cc-btn-inner">CREATE IDENTITY</span>
</button>
</div>
<!-- LOGIN -->
<div class="cc-auth-form ${tab==='login'?'active':''}" id="cc-login">
<p class="cc-form-hint">Welcome back. Enter your callsign and passphrase.</p>
<div class="cc-field">
<label class="cc-field-label">CALLSIGN</label>
<input id="cc-login-user" class="cc-input" type="text"
maxlength="${maxUser}" placeholder="your callsign..."
autocomplete="username" spellcheck="false" autocorrect="off">
</div>
<div class="cc-field">
<label class="cc-field-label">PASSPHRASE</label>
<input id="cc-login-pass" class="cc-input" type="password"
placeholder="your passphrase..."
autocomplete="current-password">
</div>
<button class="cc-btn cc-btn-login" id="cc-login-btn">
<span class="cc-btn-inner">AUTHENTICATE</span>
</button>
</div>
<div class="cc-auth-error" id="cc-auth-err"></div>
</div>
</div>
`;
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 = `
<div class="cc-userbar">
<div class="cc-userbar-left">
<span class="cc-you-label">YOU:</span>
<span class="cc-you-name" id="cc-you-name"
style="color:${esc(color)};border-color:${esc(color)};
box-shadow:0 0 8px ${esc(color)}55">
${esc(username)}
</span>
<span class="cc-day-tag">${esc(today.toUpperCase())}</span>
</div>
<div class="cc-userbar-right">
<button class="cc-logout-btn" id="cc-logout-btn">⏻ DISCONNECT</button>
</div>
</div>
<div class="cc-messages" id="cc-messages">
<div class="cc-loading-msg">
<div class="cc-spin"></div>
<span>LOADING CHANNEL DATA...</span>
</div>
</div>
<button class="cc-scroll-to-bottom" id="cc-scroll-btn" title="Scroll to latest">▼</button>
${voiceEnabled ? `
<div class="cc-voice-compose">
<button class="cc-voice-record" id="cc-voice-record" type="button" title="Record a voice clip">
<span class="cc-voice-dot"></span><span id="cc-voice-record-label">RECORD</span>
</button>
<span class="cc-voice-status" id="cc-voice-status">VOICE CLIP · MAX ${voiceMax}s</span>
<button class="cc-voice-queue" id="cc-voice-queue" type="button" disabled>
PLAY NEXT <span id="cc-voice-queue-count">0</span>
</button>
<label class="cc-voice-autoplay">
<input type="checkbox" id="cc-voice-autoplay" ${state.voice.autoPlay ? 'checked' : ''}>
AUTO PLAY
</label>
<div class="cc-voice-review" id="cc-voice-review" hidden>
<audio id="cc-voice-preview" controls preload="metadata"></audio>
<button id="cc-voice-send" class="cc-voice-action send" type="button">SEND</button>
<button id="cc-voice-discard" class="cc-voice-action" type="button">DISCARD</button>
</div>
</div>` : ''}
<div class="cc-compose">
<input type="text" class="cc-compose-input" id="cc-msg-input"
placeholder="transmit message… (Enter to send)"
maxlength="${maxMsg}" autocomplete="off" spellcheck="true">
<span class="cc-compose-counter" id="cc-char-count">${maxMsg}</span>
<button class="cc-compose-send" id="cc-send-btn" title="Send">➤</button>
</div>
`;
// 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 = '<div class="cc-empty-day">// NO MESSAGES TODAY — BE THE FIRST TO TRANSMIT</div>';
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
? '<span class="cc-voice-message"><span class="cc-voice-label">VOICE ' +
formatDuration(msg.voice_duration) + '</span><audio controls preload="metadata" src="' +
esc(publicPath(msg.voice_url)) + '"></audio></span>'
: linkify(esc(msg.message));
div.innerHTML =
'<span class="cc-msg-time">' + fmtTime(msg.created_at) + '</span>' +
'<span class="cc-msg-user" style="color:' + esc(msg.color) + ';' +
'text-shadow:0 0 10px ' + esc(msg.color) + '66">' + esc(msg.username) + '</span>' +
'<span class="cc-msg-sep"></span>' +
'<span class="cc-msg-text">' + safeText + '</span>';
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);