1028 lines
39 KiB
JavaScript
1028 lines
39 KiB
JavaScript
/**
|
||
* 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,'"').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,
|
||
'<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);
|