d0e4a870be
Admin tier assignment without payment via Users > Edit > Tier Override. Fixed live group membership refresh, polling, unread indicators, and incoming voice clip population. Added asset cache busting so clients receive the fixes immediately.
761 lines
41 KiB
JavaScript
761 lines
41 KiB
JavaScript
(function(window, document) {
|
|
'use strict';
|
|
|
|
const initialView = new URLSearchParams(location.search).get('view');
|
|
const initialChallenge = new URLSearchParams(location.search).get('login_challenge') || '';
|
|
const state = {
|
|
config: {}, user: null, groups: [], billing: null,
|
|
view: ['chat', 'groups', 'archive', 'account'].includes(initialView) ? initialView : 'chat',
|
|
groupId: null, lastId: 0, pollTimer: null, polling: false, pollGeneration: 0,
|
|
groupSeen: {}, groupUnread: {},
|
|
loginChallenge: initialChallenge,
|
|
verifyToken: new URLSearchParams(location.search).get('verify') || '',
|
|
voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0,
|
|
chunks: [], blob: null, duration: 0, url: '', autoSend: false, autoPlay: false,
|
|
playQueue: [], queuePlaying: false, autoplayWarningShown: false, sending: false }
|
|
};
|
|
let root;
|
|
const $ = (s, c) => (c || root).querySelector(s);
|
|
const $$ = (s, c) => Array.from((c || root).querySelectorAll(s));
|
|
const esc = value => String(value == null ? '' : value)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
|
|
function base() {
|
|
if (window.CYBERCHAT_BASE != null) return String(window.CYBERCHAT_BASE).replace(/\/$/, '');
|
|
const script = Array.from(document.scripts).find(item => item.src.includes('cyberchat-v4.js'));
|
|
return script ? script.src.replace(/\/assets\/js\/cyberchat-v4\.js.*$/, '') : '';
|
|
}
|
|
function apiUrl(file, query = '') { return base() + '/api/' + file + (query ? '?' + query : ''); }
|
|
function publicUrl(path) {
|
|
if (!path) return '';
|
|
return /^(https?:)?\/\//.test(path) || path.startsWith('/') ? path : base() + '/' + path;
|
|
}
|
|
function form(data) {
|
|
const body = new FormData();
|
|
Object.entries(data).forEach(([key, value]) => body.append(key, value));
|
|
return { method: 'POST', body };
|
|
}
|
|
async function api(file, options, query = '') {
|
|
const response = await fetch(apiUrl(file, query), { credentials: 'include', ...(options || {}) });
|
|
const data = await response.json().catch(() => ({ error: 'The server returned an invalid response' }));
|
|
if (!response.ok && !data.error) data.error = 'Request failed';
|
|
return data;
|
|
}
|
|
function feature(key, fallback = false) {
|
|
return state.user?.features && Object.prototype.hasOwnProperty.call(state.user.features, key)
|
|
? state.user.features[key] : fallback;
|
|
}
|
|
function toast(message, type = 'error') {
|
|
const area = $('#cc-toasts');
|
|
if (!area) return;
|
|
const item = document.createElement('div');
|
|
item.className = 'cc-toast ' + type;
|
|
item.textContent = message;
|
|
area.appendChild(item);
|
|
setTimeout(() => { item.classList.add('cc-toast-fade'); setTimeout(() => item.remove(), 350); }, 2800);
|
|
}
|
|
function duration(seconds) {
|
|
const total = Math.max(0, Math.ceil(Number(seconds) || 0));
|
|
return Math.floor(total / 60) + ':' + String(total % 60).padStart(2, '0');
|
|
}
|
|
function audioClock(seconds) {
|
|
const total = Math.max(0, Math.floor(Number(seconds) || 0));
|
|
return Math.floor(total / 60) + ':' + String(total % 60).padStart(2, '0');
|
|
}
|
|
function clock(timestamp) {
|
|
return new Date(Number(timestamp) * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
function money(cents, currency) {
|
|
return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency || 'USD' }).format(cents / 100);
|
|
}
|
|
function linkify(text) {
|
|
return text.replace(/(https?:\/\/[^\s<>]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
}
|
|
function audioPlayer(src = '', id = '') {
|
|
return `<span class="cc-cyber-audio">
|
|
<audio ${id ? `id="${id}"` : ''} preload="metadata" ${src ? `src="${esc(src)}"` : ''}></audio>
|
|
<button class="cc-audio-play" type="button" aria-label="Play audio"><span class="cc-audio-play-icon"></span></button>
|
|
<span class="cc-audio-signal" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
|
<input class="cc-audio-seek" type="range" min="0" max="1000" value="0" aria-label="Audio position">
|
|
<span class="cc-audio-time">0:00 / --:--</span>
|
|
<button class="cc-audio-mute" type="button" aria-label="Mute audio">VOL</button>
|
|
</span>`;
|
|
}
|
|
function bindAudioPlayers(scope = root) {
|
|
$$('.cc-cyber-audio', scope).forEach(player => {
|
|
if (player.dataset.bound) return;
|
|
player.dataset.bound = '1';
|
|
const audio = $('audio', player);
|
|
const play = $('.cc-audio-play', player);
|
|
const seek = $('.cc-audio-seek', player);
|
|
const time = $('.cc-audio-time', player);
|
|
const mute = $('.cc-audio-mute', player);
|
|
const update = () => {
|
|
const current = Number.isFinite(audio.currentTime) ? audio.currentTime : 0;
|
|
const total = Number.isFinite(audio.duration) ? audio.duration : 0;
|
|
seek.value = total ? Math.round((current / total) * 1000) : 0;
|
|
seek.style.setProperty('--cc-audio-progress', `${seek.value / 10}%`);
|
|
time.textContent = `${audioClock(current)} / ${total ? duration(total) : '--:--'}`;
|
|
};
|
|
play.addEventListener('click', () => audio.paused ? audio.play().catch(() => toast('Audio playback failed')) : audio.pause());
|
|
seek.addEventListener('input', () => {
|
|
if (Number.isFinite(audio.duration)) audio.currentTime = (Number(seek.value) / 1000) * audio.duration;
|
|
update();
|
|
});
|
|
mute.addEventListener('click', () => {
|
|
audio.muted = !audio.muted;
|
|
mute.textContent = audio.muted ? 'MUTE' : 'VOL';
|
|
mute.classList.toggle('active', audio.muted);
|
|
});
|
|
audio.addEventListener('play', () => {
|
|
$$('.cc-cyber-audio audio').forEach(other => { if (other !== audio) other.pause(); });
|
|
player.classList.add('playing');
|
|
play.setAttribute('aria-label', 'Pause audio');
|
|
});
|
|
audio.addEventListener('pause', () => {
|
|
player.classList.remove('playing');
|
|
play.setAttribute('aria-label', 'Play audio');
|
|
});
|
|
audio.addEventListener('ended', () => { player.classList.remove('playing'); update(); });
|
|
audio.addEventListener('loadedmetadata', update);
|
|
audio.addEventListener('durationchange', update);
|
|
audio.addEventListener('timeupdate', update);
|
|
update();
|
|
});
|
|
}
|
|
|
|
async function init(selector) {
|
|
root = typeof selector === 'string' ? document.querySelector(selector) : selector;
|
|
if (!root) return;
|
|
root.id = 'cyberchat-root';
|
|
state.config = await api('config.php').catch(() => ({
|
|
chat: { max_message_length: 500, max_username_length: 12, poll_interval_ms: 2000 },
|
|
security: { min_password_length: 4 }, voice: { enabled: true, max_duration_seconds: 60 },
|
|
ui: { app_title: 'CYBERCHAT', app_subtitle: 'SECURE CHANNEL' }
|
|
}));
|
|
try { document.body.classList.toggle('cc-light', localStorage.getItem('cc_theme') === 'light'); } catch (e) {}
|
|
shell();
|
|
api('stats.php', form({ event: 'visit', path: location.pathname })).catch(() => {});
|
|
const check = await api('auth.php', null, 'action=check').catch(() => ({ authenticated: false }));
|
|
if (check.authenticated) {
|
|
state.user = check.user;
|
|
await enter();
|
|
} else auth(state.loginChallenge ? '2fa' : 'register');
|
|
}
|
|
|
|
function shell() {
|
|
root.innerHTML = `
|
|
<div class="cc-toast-area" id="cc-toasts"></div>
|
|
<header class="cc-header">
|
|
<div class="cc-logo"><span class="cc-logo-glyph">+</span><div>
|
|
<div class="cc-logo-text">${esc(state.config.ui?.app_title || 'CYBERCHAT')}</div>
|
|
<div class="cc-logo-sub">${esc(state.config.ui?.app_subtitle || 'SECURE CHANNEL')}</div>
|
|
</div></div>
|
|
<div class="cc-header-center"><span class="cc-conn-dot" id="cc-conn-dot"></span>
|
|
<span class="cc-online-num" id="cc-online-count">--</span><span class="cc-online-label">online</span></div>
|
|
<div class="cc-header-right"><button class="cc-theme-btn cc-theme-text" id="cc-theme">THEME</button></div>
|
|
</header><div class="cc-body" id="cc-body"></div>`;
|
|
$('#cc-theme').addEventListener('click', () => {
|
|
const light = !document.body.classList.contains('cc-light');
|
|
document.body.classList.toggle('cc-light', light);
|
|
try { localStorage.setItem('cc_theme', light ? 'light' : 'dark'); } catch (e) {}
|
|
});
|
|
}
|
|
|
|
function auth(tab) {
|
|
stopPoll();
|
|
const maxUser = state.config.chat?.max_username_length || 12;
|
|
const minPass = state.config.security?.min_password_length || 4;
|
|
$('#cc-body').innerHTML = `<div class="cc-auth-wrap"><div class="cc-auth-panel">
|
|
<div class="cc-auth-brand"><div class="cc-auth-brand-line"></div><span>IDENTIFY YOURSELF</span><div class="cc-auth-brand-line"></div></div>
|
|
${state.verifyToken ? '<div class="cc-auth-notice">Log in to finish email verification.</div>' : ''}
|
|
${state.loginChallenge ? '<div class="cc-auth-notice">Enter the code from your login email.</div>' : ''}
|
|
<div class="cc-auth-tabs"><button class="cc-auth-tab ${tab === 'register' ? 'active' : ''}" data-tab="register">REGISTER</button>
|
|
<button class="cc-auth-tab ${tab === 'login' ? 'active' : ''}" data-tab="login">LOGIN</button></div>
|
|
<div class="cc-auth-form ${tab === 'register' ? 'active' : ''}" data-form="register">
|
|
<p class="cc-form-hint">Email is optional for free registration.</p>
|
|
<div class="cc-field"><label class="cc-field-label">CALLSIGN</label><input class="cc-input" id="reg-user" maxlength="${maxUser}"></div>
|
|
<div class="cc-field"><label class="cc-field-label">PASSPHRASE (MIN ${minPass})</label><input class="cc-input" id="reg-pass" type="password"></div>
|
|
<div class="cc-field"><label class="cc-field-label">CONFIRM</label><input class="cc-input" id="reg-pass2" type="password"></div>
|
|
<button class="cc-btn cc-btn-register" id="register">CREATE IDENTITY</button></div>
|
|
<div class="cc-auth-form ${tab === 'login' ? 'active' : ''}" data-form="login">
|
|
<p class="cc-form-hint">Welcome back.</p>
|
|
<div class="cc-field"><label class="cc-field-label">CALLSIGN</label><input class="cc-input" id="login-user"></div>
|
|
<div class="cc-field"><label class="cc-field-label">PASSPHRASE</label><input class="cc-input" id="login-pass" type="password"></div>
|
|
<button class="cc-btn cc-btn-login" id="login">AUTHENTICATE</button></div>
|
|
<div class="cc-auth-form" data-form="2fa"><p class="cc-form-hint">Enter the code sent to your verified email.</p>
|
|
<div class="cc-field"><label class="cc-field-label">SIX-DIGIT CODE</label><input class="cc-input" id="two-code" maxlength="6" inputmode="numeric"></div>
|
|
<button class="cc-btn cc-btn-login" id="two-submit">VERIFY CODE</button></div>
|
|
<div class="cc-auth-error" id="auth-error"></div></div></div>`;
|
|
$$('[data-tab]').forEach(button => button.addEventListener('click', () => switchAuth(button.dataset.tab)));
|
|
$('#register').addEventListener('click', register);
|
|
$('#login').addEventListener('click', login);
|
|
if (state.loginChallenge) $('#two-submit').addEventListener('click', () => verify2fa(state.loginChallenge));
|
|
}
|
|
function switchAuth(name) {
|
|
$$('[data-tab]').forEach(button => button.classList.toggle('active', button.dataset.tab === name));
|
|
$$('[data-form]').forEach(panel => panel.classList.toggle('active', panel.dataset.form === name));
|
|
authError('');
|
|
}
|
|
function authError(message) {
|
|
const item = $('#auth-error');
|
|
item.textContent = message;
|
|
item.classList.toggle('visible', !!message);
|
|
}
|
|
async function register() {
|
|
const username = $('#reg-user').value.trim();
|
|
const password = $('#reg-pass').value;
|
|
if (!username || !password) return authError('Username and password are required');
|
|
if (password !== $('#reg-pass2').value) return authError('Passphrases do not match');
|
|
const result = await api('auth.php', form({ action: 'register', username, password }));
|
|
if (result.error) return authError(result.error);
|
|
state.user = result.user;
|
|
await enter();
|
|
}
|
|
async function login() {
|
|
const result = await api('auth.php', form({
|
|
action: 'login', username: $('#login-user').value.trim(), password: $('#login-pass').value
|
|
}));
|
|
if (result.error) return authError(result.error);
|
|
if (result.requires_2fa) {
|
|
state.loginChallenge = result.challenge;
|
|
switchAuth('2fa');
|
|
$('#two-submit').addEventListener('click', () => verify2fa(state.loginChallenge));
|
|
return;
|
|
}
|
|
state.user = result.user;
|
|
await enter();
|
|
}
|
|
async function verify2fa(challenge) {
|
|
const result = await api('auth.php', form({ action: 'verify_2fa', challenge, code: $('#two-code').value.trim() }));
|
|
if (result.error) return authError(result.error);
|
|
state.loginChallenge = '';
|
|
history.replaceState({}, '', location.pathname);
|
|
state.user = result.user;
|
|
await enter();
|
|
}
|
|
|
|
async function enter() {
|
|
const [groups, billing] = await Promise.all([
|
|
api('groups.php', null, 'action=list').catch(() => ({ groups: [] })),
|
|
api('billing.php', null, 'action=status').catch(() => null)
|
|
]);
|
|
state.groups = groups.groups || [];
|
|
state.groupSeen = {};
|
|
state.groupUnread = {};
|
|
state.groups.forEach(group => { state.groupSeen[group.id] = Number(group.latest_message_id || 0); });
|
|
state.billing = billing;
|
|
if (feature('auto_voice_play')) {
|
|
try {
|
|
const saved = localStorage.getItem('cc_auto_voice_play');
|
|
state.voice.autoPlay = saved === null
|
|
? state.config.voice?.auto_play_default !== false
|
|
: saved === '1';
|
|
} catch (e) {
|
|
state.voice.autoPlay = state.config.voice?.auto_play_default !== false;
|
|
}
|
|
} else state.voice.autoPlay = false;
|
|
app();
|
|
if (state.verifyToken) {
|
|
state.view = 'account';
|
|
renderView();
|
|
await verifyEmail(state.verifyToken, '');
|
|
state.verifyToken = '';
|
|
history.replaceState({}, '', location.pathname);
|
|
}
|
|
}
|
|
function app() {
|
|
$('#cc-body').innerHTML = `<nav class="cc-app-nav">
|
|
<button data-view="chat">CHAT</button><button data-view="groups">GROUPS</button>
|
|
<button data-view="archive">MY ARCHIVE</button><button data-view="account">ACCOUNT</button>
|
|
</nav><main class="cc-view" id="cc-view"></main>`;
|
|
$$('[data-view]').forEach(button => button.addEventListener('click', () => {
|
|
state.view = button.dataset.view;
|
|
renderView();
|
|
}));
|
|
renderView();
|
|
}
|
|
function renderView() {
|
|
stopPoll();
|
|
stopVoice(true);
|
|
$$('[data-view]').forEach(button => button.classList.toggle('active', button.dataset.view === state.view));
|
|
if (state.view === 'chat') chat();
|
|
else if (state.view === 'groups') groupsView();
|
|
else if (state.view === 'archive') archiveView();
|
|
else accountView();
|
|
}
|
|
|
|
function groupOptions() {
|
|
return '<option value="">PUBLIC LOBBY</option>' + state.groups.map(group =>
|
|
`<option value="${group.id}" ${state.groupId === group.id ? 'selected' : ''}>${state.groupUnread[group.id] ? '● ' : ''}${esc(group.name)} (${group.member_count})</option>`
|
|
).join('');
|
|
}
|
|
function syncGroups(groups, markActivity = false) {
|
|
if (!Array.isArray(groups)) return;
|
|
groups.forEach(group => {
|
|
const latest = Number(group.latest_message_id || 0);
|
|
const seen = Number(state.groupSeen[group.id] || 0);
|
|
if (markActivity && latest > seen && Number(group.id) !== Number(state.groupId)) {
|
|
state.groupUnread[group.id] = true;
|
|
}
|
|
if (!(group.id in state.groupSeen)) state.groupSeen[group.id] = latest;
|
|
});
|
|
state.groups = groups;
|
|
const select = $('#group-select');
|
|
if (select) select.innerHTML = groupOptions();
|
|
}
|
|
function chat() {
|
|
const max = state.config.chat?.max_message_length || 500;
|
|
const voice = state.config.voice?.enabled !== false && feature('voice_messages', true);
|
|
$('#cc-view').innerHTML = `<div class="cc-userbar"><div class="cc-userbar-left">
|
|
<span class="cc-you-label">YOU:</span><span class="cc-you-name" style="color:${esc(state.user.color)};border-color:${esc(state.user.color)}">${esc(state.user.username)}</span>
|
|
<span class="cc-plan-badge">${esc(state.user.plan.name)}</span></div>
|
|
<select class="cc-compact-select" id="group-select">${groupOptions()}</select></div>
|
|
<div class="cc-recording-indicator" id="recording" hidden></div>
|
|
<div class="cc-messages" id="messages"><div class="cc-loading-msg"><div class="cc-spin"></div><span>LOADING CHANNEL</span></div></div>
|
|
${voice ? `<div class="cc-voice-compose"><button class="cc-voice-record" id="record"><span class="cc-voice-dot"></span><span id="record-label">RECORD</span></button>
|
|
<span class="cc-voice-status" id="voice-status">VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s</span>
|
|
${feature('auto_voice_send') ? `<label class="cc-voice-autoplay"><input id="auto-send" type="checkbox" ${state.voice.autoSend ? 'checked' : ''}> AUTO SEND</label>` : ''}
|
|
${feature('auto_voice_play') ? `<label class="cc-voice-autoplay"><input id="auto-play" type="checkbox" ${state.voice.autoPlay ? 'checked' : ''}> AUTO PLAY</label>` : ''}
|
|
<div class="cc-voice-review" id="voice-review" hidden>${audioPlayer('', 'voice-preview')}
|
|
<button class="cc-voice-action send" id="voice-send">SEND</button><button class="cc-voice-action" id="voice-discard">DISCARD</button></div></div>` : ''}
|
|
<div class="cc-compose"><input class="cc-compose-input" id="message" maxlength="${max}" placeholder="transmit message...">
|
|
<span class="cc-compose-counter" id="count">${max}</span><button class="cc-compose-send" id="send">SEND</button></div>`;
|
|
$('#group-select').addEventListener('change', event => {
|
|
state.groupId = event.target.value ? Number(event.target.value) : null;
|
|
if (state.groupId !== null) {
|
|
state.groupUnread[state.groupId] = false;
|
|
const group = state.groups.find(item => Number(item.id) === state.groupId);
|
|
if (group) state.groupSeen[state.groupId] = Number(group.latest_message_id || 0);
|
|
}
|
|
state.lastId = 0;
|
|
chat();
|
|
});
|
|
$('#message').addEventListener('input', event => $('#count').textContent = max - event.target.value.length);
|
|
$('#message').addEventListener('keydown', event => {
|
|
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendText(); }
|
|
});
|
|
$('#send').addEventListener('click', sendText);
|
|
$('#record')?.addEventListener('click', toggleVoice);
|
|
$('#voice-send')?.addEventListener('click', sendVoice);
|
|
$('#voice-discard')?.addEventListener('click', discardVoice);
|
|
$('#auto-send')?.addEventListener('change', event => { state.voice.autoSend = event.target.checked; });
|
|
$('#auto-play')?.addEventListener('change', event => {
|
|
state.voice.autoPlay = event.target.checked;
|
|
try { localStorage.setItem('cc_auto_voice_play', event.target.checked ? '1' : '0'); } catch (e) {}
|
|
});
|
|
bindAudioPlayers($('#cc-view'));
|
|
state.lastId = 0;
|
|
startPoll();
|
|
}
|
|
async function sendText() {
|
|
const input = $('#message');
|
|
const message = input.value.trim();
|
|
if (!message) return;
|
|
input.value = '';
|
|
const result = await api('messages.php', form({ action: 'send', message, group_id: state.groupId || '' }));
|
|
if (result.error) { input.value = message; return toast(result.error); }
|
|
appendMessage(result.message);
|
|
state.lastId = Math.max(state.lastId, result.message.id);
|
|
}
|
|
function startPoll() {
|
|
stopPoll();
|
|
const generation = state.pollGeneration;
|
|
poll(generation);
|
|
state.pollTimer = setInterval(() => poll(generation), state.config.chat?.poll_interval_ms || 2000);
|
|
}
|
|
function stopPoll() {
|
|
if (state.pollTimer) clearInterval(state.pollTimer);
|
|
state.pollTimer = null;
|
|
state.polling = false;
|
|
state.pollGeneration++;
|
|
}
|
|
async function poll(generation = state.pollGeneration) {
|
|
if (state.polling || state.view !== 'chat' || generation !== state.pollGeneration) return;
|
|
const requestedGroup = state.groupId;
|
|
const requestedSince = state.lastId;
|
|
state.polling = true;
|
|
try {
|
|
const query = new URLSearchParams({ action: 'poll', since: requestedSince, group_id: requestedGroup || '' });
|
|
const result = await api('messages.php', null, query.toString()).catch(() => null);
|
|
if (generation !== state.pollGeneration || Number(state.groupId || 0) !== Number(requestedGroup || 0)) return;
|
|
if (result && !result.error && Number(result.group_id || 0) === Number(requestedGroup || 0)) {
|
|
$('#cc-conn-dot').className = 'cc-conn-dot online';
|
|
$('#cc-online-count').textContent = result.online;
|
|
syncGroups(result.groups, true);
|
|
if (requestedSince === 0) renderMessages(result.messages || []);
|
|
else (result.messages || []).forEach(message => appendMessage(message, true));
|
|
if (result.messages?.length) state.lastId = result.messages[result.messages.length - 1].id;
|
|
if (requestedGroup !== null) {
|
|
state.groupUnread[requestedGroup] = false;
|
|
state.groupSeen[requestedGroup] = Math.max(Number(state.groupSeen[requestedGroup] || 0), Number(state.lastId || 0));
|
|
}
|
|
const recording = $('#recording');
|
|
recording.hidden = !result.recording?.length;
|
|
recording.textContent = result.recording?.length
|
|
? result.recording.map(user => user.username).join(', ')
|
|
+ (result.recording.length === 1 ? ' is' : ' are') + ' recording...'
|
|
: '';
|
|
} else {
|
|
$('#cc-conn-dot').className = 'cc-conn-dot offline';
|
|
if (result?.error && requestedGroup !== null) await recoverGroupAccess(requestedGroup, generation);
|
|
}
|
|
} finally {
|
|
if (generation === state.pollGeneration) state.polling = false;
|
|
}
|
|
}
|
|
async function recoverGroupAccess(groupId, generation) {
|
|
const result = await api('groups.php', null, 'action=list').catch(() => null);
|
|
if (!result || result.error || generation !== state.pollGeneration) return;
|
|
syncGroups(result.groups || []);
|
|
if (!state.groups.some(group => Number(group.id) === Number(groupId))) {
|
|
state.groupId = null;
|
|
state.lastId = 0;
|
|
toast('Your group access changed; returned to the public lobby');
|
|
chat();
|
|
}
|
|
}
|
|
function renderMessages(messages) {
|
|
const list = $('#messages');
|
|
list.innerHTML = messages.length ? '' : '<div class="cc-empty-day">NO MESSAGES IN THIS CHANNEL TODAY</div>';
|
|
messages.forEach(appendMessage);
|
|
if (messages.length) state.lastId = messages[messages.length - 1].id;
|
|
list.scrollTop = list.scrollHeight;
|
|
}
|
|
function appendMessage(message, incoming = false) {
|
|
const list = $('#messages');
|
|
if (!list || list.querySelector(`[data-id="${message.id}"]`)) return;
|
|
list.querySelector('.cc-loading-msg, .cc-empty-day')?.remove();
|
|
const item = document.createElement('div');
|
|
item.className = 'cc-msg' + (message.username === state.user.username ? ' cc-msg-own' : '') + ' cc-msg-new';
|
|
item.dataset.id = message.id;
|
|
const content = message.message_type === 'voice' && message.voice_url
|
|
? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}</span>${audioPlayer(publicUrl(message.voice_url))}</span>`
|
|
: linkify(esc(message.message));
|
|
item.innerHTML = `<span class="cc-msg-time">${clock(message.created_at)}</span>
|
|
<span class="cc-msg-user" style="color:${esc(message.color)}">${esc(message.username)}</span>
|
|
<span class="cc-msg-sep">></span><span class="cc-msg-text">${content}</span>`;
|
|
list.appendChild(item);
|
|
bindAudioPlayers(item);
|
|
if (incoming && message.message_type === 'voice' && message.username !== state.user.username) {
|
|
enqueueVoiceAutoplay(item);
|
|
}
|
|
list.scrollTop = list.scrollHeight;
|
|
}
|
|
function enqueueVoiceAutoplay(item) {
|
|
if (!feature('auto_voice_play') || !state.voice.autoPlay) return;
|
|
const audio = $('audio', item);
|
|
if (!audio) return;
|
|
state.voice.playQueue.push(audio);
|
|
playNextQueuedVoice();
|
|
}
|
|
function playNextQueuedVoice() {
|
|
if (state.voice.queuePlaying || !state.voice.playQueue.length) return;
|
|
const audio = state.voice.playQueue.shift();
|
|
state.voice.queuePlaying = true;
|
|
let started = false;
|
|
let settled = false;
|
|
const done = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
state.voice.queuePlaying = false;
|
|
playNextQueuedVoice();
|
|
};
|
|
audio.addEventListener('playing', () => { started = true; }, { once: true });
|
|
audio.addEventListener('ended', done, { once: true });
|
|
audio.addEventListener('error', done, { once: true });
|
|
audio.addEventListener('pause', () => {
|
|
if (started && !audio.ended) done();
|
|
}, { once: true });
|
|
audio.play().catch(() => {
|
|
settled = true;
|
|
state.voice.queuePlaying = false;
|
|
state.voice.playQueue = [];
|
|
if (!state.voice.autoplayWarningShown) {
|
|
state.voice.autoplayWarningShown = true;
|
|
toast('Browser blocked auto play. Press play once to enable audio.');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function recording(active) {
|
|
if (!feature('recording_status')) return;
|
|
await api('messages.php', form({ action: 'recording', active: active ? '1' : '0', group_id: state.groupId || '' })).catch(() => {});
|
|
}
|
|
async function toggleVoice() {
|
|
if (state.voice.recorder) return stopVoice(false);
|
|
discardVoice();
|
|
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Voice recording is not supported');
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
const mime = ['audio/webm;codecs=opus', 'audio/webm'].find(type => MediaRecorder.isTypeSupported(type));
|
|
if (!mime) { stream.getTracks().forEach(track => track.stop()); return toast('WebM recording is not supported'); }
|
|
state.voice.stream = stream;
|
|
state.voice.chunks = [];
|
|
state.voice.started = Date.now();
|
|
const recorder = new MediaRecorder(stream, { mimeType: mime });
|
|
recorder.addEventListener('dataavailable', event => { if (event.data.size) state.voice.chunks.push(event.data); });
|
|
recorder.addEventListener('stop', () => {
|
|
if (!recorder._discard) finishVoice(new Blob(state.voice.chunks, { type: mime }), (Date.now() - state.voice.started) / 1000);
|
|
}, { once: true });
|
|
recorder.start(250);
|
|
state.voice.recorder = recorder;
|
|
$('#record').classList.add('recording');
|
|
$('#record-label').textContent = 'STOP';
|
|
recording(true);
|
|
state.voice.heartbeat = setInterval(() => recording(true), 4000);
|
|
state.voice.timer = setInterval(voiceTimer, 200);
|
|
voiceTimer();
|
|
} catch (e) { toast(e.name === 'NotAllowedError' ? 'Microphone permission was denied' : 'Could not access microphone'); }
|
|
}
|
|
function voiceTimer() {
|
|
const max = state.config.voice?.max_duration_seconds || 60;
|
|
const elapsed = Math.min((Date.now() - state.voice.started) / 1000, max);
|
|
if ($('#voice-status')) $('#voice-status').textContent = `RECORDING ${duration(elapsed)} / ${duration(max)}`;
|
|
if (elapsed >= max) stopVoice(false);
|
|
}
|
|
function stopVoice(discard) {
|
|
clearInterval(state.voice.timer);
|
|
clearInterval(state.voice.heartbeat);
|
|
state.voice.timer = state.voice.heartbeat = null;
|
|
recording(false);
|
|
if (state.voice.recorder && state.voice.recorder.state !== 'inactive') {
|
|
state.voice.recorder._discard = discard;
|
|
state.voice.recorder.stop();
|
|
}
|
|
state.voice.recorder = null;
|
|
state.voice.stream?.getTracks().forEach(track => track.stop());
|
|
state.voice.stream = null;
|
|
$('#record')?.classList.remove('recording');
|
|
if ($('#record-label')) $('#record-label').textContent = 'RECORD';
|
|
}
|
|
function finishVoice(blob, length) {
|
|
if (!blob.size || length < 0.25) return toast('Voice clip was too short');
|
|
state.voice.blob = blob;
|
|
state.voice.duration = length;
|
|
if (state.voice.autoSend && feature('auto_voice_send')) return sendVoice();
|
|
if (state.voice.url) URL.revokeObjectURL(state.voice.url);
|
|
state.voice.url = URL.createObjectURL(blob);
|
|
$('#voice-preview').src = state.voice.url;
|
|
$('#voice-preview').load();
|
|
$('#voice-review').hidden = false;
|
|
$('#voice-status').textContent = 'READY ' + duration(length);
|
|
}
|
|
function discardVoice() {
|
|
if (state.voice.recorder) stopVoice(true);
|
|
if (state.voice.url) URL.revokeObjectURL(state.voice.url);
|
|
state.voice.url = '';
|
|
state.voice.blob = null;
|
|
state.voice.duration = 0;
|
|
if ($('#voice-review')) $('#voice-review').hidden = true;
|
|
}
|
|
async function sendVoice() {
|
|
if (!state.voice.blob || state.voice.sending) return;
|
|
state.voice.sending = true;
|
|
const body = new FormData();
|
|
body.append('action', 'send_voice');
|
|
body.append('group_id', state.groupId || '');
|
|
body.append('duration', state.voice.duration.toFixed(2));
|
|
body.append('voice', state.voice.blob, 'voice.webm');
|
|
const result = await api('messages.php', { method: 'POST', body });
|
|
state.voice.sending = false;
|
|
if (result.error) return toast(result.error);
|
|
appendMessage(result.message);
|
|
state.lastId = Math.max(state.lastId, result.message.id);
|
|
discardVoice();
|
|
}
|
|
|
|
function groupsView() {
|
|
const limit = Number(feature('group_member_limit', 2));
|
|
$('#cc-view').innerHTML = `<div class="cc-page"><div class="cc-page-head"><div><h2>GROUPS</h2>
|
|
<p>Up to ${limit} people per group, including you.</p></div></div>
|
|
<section class="cc-card"><h3>CREATE GROUP</h3><div class="cc-form-grid">
|
|
<input class="cc-input" id="group-name" maxlength="40" placeholder="Group name">
|
|
<input class="cc-input" id="group-members" placeholder="Member callsigns, comma separated">
|
|
<button class="cc-inline-btn primary" id="group-create">CREATE</button></div></section>
|
|
<div class="cc-card-grid">${state.groups.length ? state.groups.map(groupCard).join('') : '<div class="cc-empty-card">No private groups yet.</div>'}</div></div>`;
|
|
$('#group-create').addEventListener('click', createGroup);
|
|
$$('[data-open]').forEach(button => button.addEventListener('click', () => {
|
|
state.groupId = Number(button.dataset.open);
|
|
state.groupUnread[state.groupId] = false;
|
|
const group = state.groups.find(item => Number(item.id) === state.groupId);
|
|
if (group) state.groupSeen[state.groupId] = Number(group.latest_message_id || 0);
|
|
state.view = 'chat';
|
|
renderView();
|
|
}));
|
|
$$('[data-delete]').forEach(button => button.addEventListener('click', () => deleteGroup(Number(button.dataset.delete))));
|
|
$$('[data-add]').forEach(button => button.addEventListener('click', () => addMember(Number(button.dataset.add))));
|
|
$$('[data-remove]').forEach(button => button.addEventListener('click', () => {
|
|
const [groupId, userId] = button.dataset.remove.split(':').map(Number);
|
|
removeMember(groupId, userId);
|
|
}));
|
|
api('groups.php', null, 'action=list').then(result => {
|
|
if (result.error || state.view !== 'groups') return;
|
|
const before = JSON.stringify(state.groups);
|
|
syncGroups(result.groups || []);
|
|
if (JSON.stringify(state.groups) !== before) groupsView();
|
|
}).catch(() => {});
|
|
}
|
|
function groupCard(group) {
|
|
const owner = group.owner_id === Number(state.user.id);
|
|
return `<section class="cc-card"><div class="cc-card-title"><h3>${esc(group.name)}</h3>
|
|
<button class="cc-inline-btn" data-open="${group.id}">OPEN CHAT</button></div>
|
|
<div class="cc-member-list">${group.members.map(member => `<span style="color:${esc(member.color)}">${esc(member.username)}
|
|
${member.role === 'owner' ? '(owner)' : ''}${owner && member.role !== 'owner' ? ` <button data-remove="${group.id}:${member.id}">x</button>` : ''}</span>`).join('')}</div>
|
|
${owner ? `<div class="cc-form-grid compact"><input class="cc-input" id="add-${group.id}" placeholder="Callsign">
|
|
<button class="cc-inline-btn" data-add="${group.id}">ADD</button><button class="cc-inline-btn danger" data-delete="${group.id}">DELETE</button></div>` : ''}</section>`;
|
|
}
|
|
async function createGroup() {
|
|
const result = await api('groups.php', form({ action: 'create', name: $('#group-name').value.trim(), members: $('#group-members').value.trim() }));
|
|
if (result.error) return toast(result.error);
|
|
state.groups = result.groups;
|
|
groupsView();
|
|
}
|
|
async function addMember(groupId) {
|
|
const result = await api('groups.php', form({ action: 'add_member', group_id: groupId, username: $('#add-' + groupId).value.trim() }));
|
|
if (result.error) return toast(result.error);
|
|
state.groups = result.groups;
|
|
groupsView();
|
|
}
|
|
async function removeMember(groupId, userId) {
|
|
const result = await api('groups.php', form({ action: 'remove_member', group_id: groupId, user_id: userId }));
|
|
if (result.error) return toast(result.error);
|
|
state.groups = result.groups;
|
|
groupsView();
|
|
}
|
|
async function deleteGroup(groupId) {
|
|
if (!confirm('Delete this group and its messages?')) return;
|
|
const result = await api('groups.php', form({ action: 'delete', group_id: groupId }));
|
|
if (result.error) return toast(result.error);
|
|
state.groups = result.groups;
|
|
if (state.groupId === groupId) state.groupId = null;
|
|
groupsView();
|
|
}
|
|
|
|
async function archiveView(search = '') {
|
|
$('#cc-view').innerHTML = '<div class="cc-loading-msg"><div class="cc-spin"></div><span>LOADING YOUR ARCHIVE</span></div>';
|
|
const result = await api('archive.php', null, new URLSearchParams({ action: 'list', search }).toString());
|
|
if (result.error) return $('#cc-view').innerHTML = `<div class="cc-empty-card">${esc(result.error)}</div>`;
|
|
$('#cc-view').innerHTML = `<div class="cc-page"><div class="cc-page-head"><div><h2>MY ARCHIVE</h2>
|
|
<p>Your own messages for ${result.retention_days} days. ${result.voice_included ? 'Voice included.' : 'Text only on this plan.'}</p></div>
|
|
${result.can_export ? `<div class="cc-actions"><a class="cc-inline-btn" href="${apiUrl('archive.php', 'action=export&format=json')}">JSON</a>
|
|
<a class="cc-inline-btn" href="${apiUrl('archive.php', 'action=export&format=csv')}">CSV</a></div>` : ''}</div>
|
|
<div class="cc-search-row"><input class="cc-input" id="archive-search" value="${esc(search)}" placeholder="Search your messages">
|
|
<button class="cc-inline-btn primary" id="archive-submit">SEARCH</button></div>
|
|
<div class="cc-archive-list">${result.messages.length ? result.messages.map(archiveRow).join('') : '<div class="cc-empty-card">No matching messages.</div>'}</div></div>`;
|
|
bindAudioPlayers($('#cc-view'));
|
|
$('#archive-submit').addEventListener('click', () => archiveView($('#archive-search').value.trim()));
|
|
}
|
|
function archiveRow(message) {
|
|
const content = message.message_type === 'voice' && message.voice_url
|
|
? audioPlayer(publicUrl(message.voice_url)) : esc(message.message);
|
|
return `<div class="cc-archive-row"><time>${esc(message.day_key)} ${clock(message.created_at)}</time>
|
|
<span class="cc-type-tag">${esc(message.message_type)}</span><div>${content}</div></div>`;
|
|
}
|
|
|
|
async function accountView() {
|
|
if (!state.billing) state.billing = await api('billing.php', null, 'action=status').catch(() => null);
|
|
const billing = state.billing;
|
|
const showBilling = billing && (billing.subscriptions_enabled || billing.can_manage);
|
|
$('#cc-view').innerHTML = `<div class="cc-page"><div class="cc-page-head"><div><h2>ACCOUNT</h2>
|
|
<p>${esc(state.user.username)} / ${esc(state.user.plan.name)}</p></div><button class="cc-inline-btn danger" id="logout">LOG OUT</button></div>
|
|
<div class="cc-card-grid"><section class="cc-card"><h3>EMAIL VERIFICATION</h3>
|
|
<p>${state.user.email_verified ? `Verified: ${esc(state.user.email)}` : 'Email is only required before premium activation.'}</p>
|
|
<div class="cc-form-grid"><input class="cc-input" id="email" type="email" value="${esc(state.user.email || '')}" placeholder="you@example.com">
|
|
<button class="cc-inline-btn primary" id="email-send">SEND VERIFICATION</button></div>
|
|
<div class="cc-form-grid compact"><input class="cc-input" id="email-code" maxlength="6" placeholder="Six-digit code">
|
|
<button class="cc-inline-btn" id="email-verify">VERIFY</button></div></section>
|
|
<section class="cc-card"><h3>IDENTITY & SECURITY</h3>
|
|
${feature('username_color') ? `<div class="cc-form-grid compact"><input type="color" id="color" value="${esc(state.user.color)}">
|
|
<button class="cc-inline-btn" id="color-save">SAVE COLOR</button></div>` : '<p>Custom username color is not included in this plan.</p>'}
|
|
${feature('email_2fa') ? `<label class="cc-switch"><input type="checkbox" id="two-toggle" ${state.user.two_factor_enabled ? 'checked' : ''}> EMAIL TWO-FACTOR AUTHENTICATION</label>` : '<p>Email two-factor authentication is not included in this plan.</p>'}
|
|
</section></div>
|
|
${showBilling ? billingHtml(billing) : ''}
|
|
<section class="cc-card cc-feature-card"><h3>CURRENT FEATURES</h3><div class="cc-feature-list">
|
|
${Object.entries(state.user.features).map(([key, value]) => `<span><b>${esc(key.replace(/_/g, ' '))}</b> ${esc(String(value))}</span>`).join('')}</div></section>
|
|
</div>`;
|
|
$('#logout').addEventListener('click', () => logout(true));
|
|
$('#email-send').addEventListener('click', requestEmail);
|
|
$('#email-verify').addEventListener('click', () => verifyEmail('', $('#email-code').value.trim()));
|
|
$('#color-save')?.addEventListener('click', saveColor);
|
|
$('#two-toggle')?.addEventListener('change', saveTwoFactor);
|
|
$$('[data-plan]').forEach(button => button.addEventListener('click', () => checkout(Number(button.dataset.plan))));
|
|
$('#billing-manage')?.addEventListener('click', portal);
|
|
$('#billing-cancel')?.addEventListener('click', cancelSubscription);
|
|
}
|
|
function billingHtml(billing) {
|
|
const alreadySubscribed = ['active', 'trialing', 'past_due'].includes(state.user.subscription.status);
|
|
const plans = billing.subscriptions_enabled && !alreadySubscribed ? billing.plans : [];
|
|
return `<section class="cc-card cc-billing-card"><div class="cc-card-title"><div><h3>SUBSCRIPTION</h3>
|
|
<p>Status: ${esc(state.user.subscription.status)}${state.user.subscription.cancel_at_period_end ? ' / cancels at period end' : ''}</p></div>
|
|
<div class="cc-actions">${billing.can_manage ? '<button class="cc-inline-btn" id="billing-manage">MANAGE IN STRIPE</button>' : ''}
|
|
${state.user.subscription.manageable && !state.user.subscription.cancel_at_period_end ? '<button class="cc-inline-btn danger" id="billing-cancel">CANCEL</button>' : ''}</div></div>
|
|
${plans.length ? `<div class="cc-plan-grid">${plans.map(plan => `<article class="cc-plan"><h4>${esc(plan.name)}</h4>
|
|
<div class="cc-price">${money(plan.price_cents, plan.currency)}<small>/${esc(plan.interval)}</small></div>
|
|
<p>${esc(plan.description)}</p><button class="cc-inline-btn primary" data-plan="${plan.id}" ${plan.checkout_ready ? '' : 'disabled'}>SELECT PLAN</button></article>`).join('')}</div>` : ''}
|
|
${billing.subscriptions_enabled && alreadySubscribed ? '<p>Use the Stripe portal to change plans or payment details.</p>' : ''}
|
|
${!billing.subscriptions_enabled && billing.can_manage ? '<p>New subscriptions are hidden. Your existing subscription remains manageable.</p>' : ''}</section>`;
|
|
}
|
|
async function requestEmail() {
|
|
const result = await api('account.php', form({ action: 'request_email_verification', email: $('#email').value.trim() }));
|
|
toast(result.error || result.message, result.error ? 'error' : 'success');
|
|
}
|
|
async function verifyEmail(token, code) {
|
|
const result = await api('account.php', form({ action: 'verify_email', token, code }));
|
|
if (result.error) return toast(result.error);
|
|
state.user = result.user;
|
|
state.billing = await api('billing.php', null, 'action=status').catch(() => state.billing);
|
|
toast('Email verified', 'success');
|
|
accountView();
|
|
}
|
|
async function saveColor() {
|
|
const result = await api('account.php', form({ action: 'set_color', color: $('#color').value }));
|
|
if (result.error) return toast(result.error);
|
|
state.user = result.user;
|
|
toast('Color updated', 'success');
|
|
accountView();
|
|
}
|
|
async function saveTwoFactor(event) {
|
|
const result = await api('account.php', form({ action: 'set_2fa', enabled: event.target.checked ? '1' : '0' }));
|
|
if (result.error) { event.target.checked = !event.target.checked; return toast(result.error); }
|
|
state.user = result.user;
|
|
toast('Two-factor setting updated', 'success');
|
|
}
|
|
async function checkout(planId) {
|
|
if (!state.user.email_verified) return toast('Verify an email before premium checkout');
|
|
const result = await api('billing.php', form({ action: 'checkout', plan_id: planId }));
|
|
if (result.error) return toast(result.error);
|
|
location.href = result.url;
|
|
}
|
|
async function portal() {
|
|
const result = await api('billing.php', form({ action: 'portal' }));
|
|
if (result.error) return toast(result.error);
|
|
location.href = result.url;
|
|
}
|
|
async function cancelSubscription() {
|
|
if (!confirm('Cancel this subscription at the end of its billing period?')) return;
|
|
const result = await api('billing.php', form({ action: 'cancel' }));
|
|
if (result.error) return toast(result.error);
|
|
state.user = result.user;
|
|
state.billing = await api('billing.php', null, 'action=status');
|
|
accountView();
|
|
}
|
|
async function logout(request = true) {
|
|
stopPoll();
|
|
stopVoice(true);
|
|
if (request) await api('auth.php', form({ action: 'logout' })).catch(() => {});
|
|
state.user = null;
|
|
state.groups = [];
|
|
state.billing = null;
|
|
state.groupId = null;
|
|
state.groupSeen = {};
|
|
state.groupUnread = {};
|
|
state.voice.playQueue = [];
|
|
state.voice.queuePlaying = false;
|
|
auth('login');
|
|
}
|
|
|
|
window.CyberChat = { init };
|
|
})(window, document);
|