- Implemented.

Replaced polling with a single queued cursor worker, timeout recovery, 
backoff, deduplication, chronological insertion, and capped DOM history 
in cyberchat-v4.js.
Removed Groups UI, API, configuration, tier features, admin controls, 
and archive exposure.
Legacy group records remain stored but inaccessible.
Added configurable polling limits in config.json.
This commit is contained in:
Ty Clifford
2026-06-08 23:28:42 -04:00
parent ef7a9a330f
commit ccdbc47642
12 changed files with 368 additions and 667 deletions
+163 -267
View File
@@ -4,11 +4,13 @@
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, pollController: null,
polling: false, pollRequested: false, pollGeneration: 0, groupsAccess: null,
groupSeen: {}, groupUnread: {},
config: {}, user: null, billing: null,
view: ['chat', 'archive', 'account'].includes(initialView) ? initialView : 'chat',
cursor: 0,
poll: {
generation: 0, active: false, controller: null, wakeTimer: null, wakeResolve: null,
failures: 0, immediate: false, queue: [], queuedIds: new Set(), initial: true
},
loginChallenge: initialChallenge,
verifyToken: new URLSearchParams(location.search).get('verify') || '',
voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0,
@@ -16,6 +18,7 @@
playQueue: [], queuePlaying: false, autoplayWarningShown: false, sending: false }
};
let root;
let wakeListenersBound = false;
const $ = (s, c) => (c || root).querySelector(s);
const $$ = (s, c) => Array.from((c || root).querySelectorAll(s));
const esc = value => String(value == null ? '' : value)
@@ -51,30 +54,6 @@
return state.user?.features && Object.prototype.hasOwnProperty.call(state.user.features, key)
? state.user.features[key] : fallback;
}
function groupsAvailable() {
return state.config.groups?.enabled !== false && state.groupsAccess !== false;
}
function applyGroupsAvailability(enabled, groups = [], platformEnabled = enabled) {
const next = enabled !== false;
const changed = groupsAvailable() !== next;
state.groupsAccess = next;
state.config.groups = { ...(state.config.groups || {}), enabled: platformEnabled !== false };
if (next) {
if (changed) syncGroups(groups);
} else {
state.groups = [];
state.groupSeen = {};
state.groupUnread = {};
state.groupId = null;
if (state.view === 'groups') state.view = 'chat';
}
return changed;
}
function disableGroups(message = '', platformEnabled = state.config.groups?.enabled !== false) {
applyGroupsAvailability(false, [], platformEnabled);
if (message) toast(message);
app();
}
function toast(message, type = 'error') {
const area = $('#cc-toasts');
if (!area) return;
@@ -159,10 +138,20 @@
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 },
chat: {
max_message_length: 500, max_username_length: 12, poll_interval_ms: 2000,
poll_timeout_ms: 12000, poll_max_backoff_ms: 15000, max_rendered_messages: 500
},
security: { min_password_length: 4 }, voice: { enabled: true, max_duration_seconds: 60 },
ui: { app_title: 'CYBERCHAT', app_subtitle: 'SECURE CHANNEL' }
}));
if (!wakeListenersBound) {
document.addEventListener('visibilitychange', () => {
if (!document.hidden) requestPoll();
});
window.addEventListener('online', requestPoll);
wakeListenersBound = true;
}
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(() => {});
@@ -266,19 +255,7 @@
}
async function enter() {
const [groups, billing] = await Promise.all([
groupsAvailable()
? api('groups.php', null, 'action=list').catch(() => ({ groups: [] }))
: Promise.resolve({ groups: [], enabled: false, platform_enabled: false }),
api('billing.php', null, 'action=status').catch(() => null)
]);
state.groupsAccess = groups.enabled !== false;
state.config.groups = { ...(state.config.groups || {}), enabled: groups.platform_enabled !== false };
state.groups = groupsAvailable() ? (groups.groups || []) : [];
state.groupSeen = {};
state.groupUnread = {};
state.groups.forEach(group => { state.groupSeen[group.id] = Number(group.latest_message_id || 0); });
state.billing = billing;
state.billing = await api('billing.php', null, 'action=status').catch(() => null);
if (feature('auto_voice_play')) {
try {
const saved = localStorage.getItem('cc_auto_voice_play');
@@ -299,9 +276,8 @@
}
}
function app() {
if (!groupsAvailable() && state.view === 'groups') state.view = 'chat';
$('#cc-body').innerHTML = `<nav class="cc-app-nav">
<button data-view="chat">CHAT</button>${groupsAvailable() ? '<button data-view="groups">GROUPS</button>' : ''}
<button data-view="chat">CHAT</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', () => {
@@ -315,37 +291,16 @@
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' && groupsAvailable()) 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>
${groupsAvailable() ? `<select class="cc-compact-select" id="group-select">${groupOptions()}</select>` : ''}</div>
<span class="cc-plan-badge">${esc(state.user.plan.name)}</span></div></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>
@@ -356,16 +311,6 @@
<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(); }
@@ -380,7 +325,7 @@
try { localStorage.setItem('cc_auto_voice_play', event.target.checked ? '1' : '0'); } catch (e) {}
});
bindAudioPlayers($('#cc-view'));
state.lastId = 0;
state.cursor = 0;
startPoll();
}
async function sendText() {
@@ -388,150 +333,191 @@
const message = input.value.trim();
if (!message) return;
input.value = '';
const result = await api('messages.php', form({
action: 'send', message, group_id: state.groupId || '',
})).catch(() => ({ error: 'Message send failed. Polling will continue.' }));
if (result.groups_enabled === false) {
input.value = message;
return disableGroups(result.error, result.groups_platform_enabled !== false);
}
const result = await api('messages.php', form({ action: 'send', message }))
.catch(() => ({ error: 'Message send failed. Polling will continue.' }));
if (result.error) { input.value = message; return toast(result.error); }
appendMessage(result.message);
requestPoll();
}
function startPoll() {
stopPoll();
const generation = state.pollGeneration;
schedulePoll(generation, 0);
}
function schedulePoll(generation, delay = state.config.chat?.poll_interval_ms || 2000) {
if (generation !== state.pollGeneration || state.view !== 'chat') return;
if (state.pollTimer) clearTimeout(state.pollTimer);
state.pollTimer = setTimeout(async () => {
state.pollTimer = null;
await poll(generation);
if (generation !== state.pollGeneration || state.view !== 'chat') return;
const delay = state.pollRequested ? 0 : state.config.chat?.poll_interval_ms || 2000;
state.pollRequested = false;
schedulePoll(generation, delay);
}, Math.max(0, delay));
state.cursor = 0;
state.poll.active = true;
state.poll.failures = 0;
state.poll.immediate = true;
state.poll.initial = true;
state.poll.queue = [];
state.poll.queuedIds.clear();
void pollWorker(state.poll.generation);
}
function requestPoll() {
if (state.view !== 'chat') return;
if (state.polling) {
state.pollRequested = true;
return;
}
schedulePoll(state.pollGeneration, 0);
if (state.view !== 'chat' || !state.poll.active) return;
state.poll.immediate = true;
const wake = state.poll.wakeResolve;
if (wake) wake();
}
function stopPoll() {
if (state.pollTimer) clearTimeout(state.pollTimer);
state.pollController?.abort();
state.pollTimer = null;
state.pollController = null;
state.polling = false;
state.pollRequested = false;
state.pollGeneration++;
state.poll.active = false;
state.poll.generation++;
state.poll.controller?.abort();
state.poll.controller = null;
if (state.poll.wakeTimer) clearTimeout(state.poll.wakeTimer);
state.poll.wakeTimer = null;
const wake = state.poll.wakeResolve;
state.poll.wakeResolve = null;
if (wake) wake();
state.poll.immediate = false;
state.poll.queue = [];
state.poll.queuedIds.clear();
}
async function poll(generation = state.pollGeneration) {
if (state.polling || state.view !== 'chat' || generation !== state.pollGeneration) return;
const requestedGroup = state.groupId;
const requestedSince = state.lastId;
async function pollWorker(generation) {
const interval = Math.max(250, Number(state.config.chat?.poll_interval_ms) || 2000);
const maxBackoff = Math.max(interval, Number(state.config.chat?.poll_max_backoff_ms) || 15000);
while (state.poll.active && state.poll.generation === generation && state.view === 'chat') {
state.poll.immediate = false;
const result = await pollOnce(generation);
if (!state.poll.active || state.poll.generation !== generation || state.view !== 'chat') break;
let delay = 0;
if (result.ok) {
state.poll.failures = 0;
if (!result.hasMore && !state.poll.immediate) delay = interval;
} else {
state.poll.failures++;
if (!state.poll.immediate) {
delay = Math.min(maxBackoff, 500 * (2 ** Math.min(state.poll.failures - 1, 6)));
}
}
if (delay > 0) await waitForPoll(delay, generation);
}
}
function waitForPoll(delay, generation) {
if (!state.poll.active || state.poll.generation !== generation) return Promise.resolve();
return new Promise(resolve => {
let settled = false;
let timer = null;
const finish = () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
if (state.poll.wakeTimer === timer) state.poll.wakeTimer = null;
if (state.poll.wakeResolve === finish) state.poll.wakeResolve = null;
resolve();
};
timer = setTimeout(finish, Math.max(0, delay));
state.poll.wakeTimer = timer;
state.poll.wakeResolve = finish;
if (state.poll.immediate) finish();
});
}
async function pollOnce(generation) {
const requestedSince = state.cursor;
const controller = new AbortController();
const timeoutMs = Math.max(1000, Number(state.config.chat?.poll_timeout_ms) || 12000);
const timeout = setTimeout(() => controller.abort(), timeoutMs);
state.pollController = controller;
state.polling = true;
state.poll.controller = controller;
try {
const query = new URLSearchParams({ action: 'poll', since: requestedSince, group_id: requestedGroup || '' });
const result = await api('messages.php', { signal: controller.signal }, 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)) {
const availabilityChanged = applyGroupsAvailability(
result.groups_enabled !== false,
result.groups || [],
result.groups_platform_enabled !== false
);
if (availabilityChanged) {
toast(groupsAvailable() ? 'Private groups are now available' : 'Private groups have been disabled', 'success');
app();
return;
}
$('#cc-conn-dot').className = 'cc-conn-dot online';
$('#cc-online-count').textContent = result.online;
if (groupsAvailable()) syncGroups(result.groups, true);
if (requestedSince === 0) renderMessages(result.messages || []);
else (result.messages || []).forEach(message => appendMessage(message, true));
if (result.messages?.length) {
state.lastId = Math.max(state.lastId, ...result.messages.map(message => Number(message.id) || 0));
}
if (requestedGroup !== null) {
state.groupUnread[requestedGroup] = false;
state.groupSeen[requestedGroup] = Math.max(Number(state.groupSeen[requestedGroup] || 0), Number(state.lastId || 0));
}
const recording = $('#recording');
const query = new URLSearchParams({ action: 'poll', since: requestedSince });
const result = await api('messages.php', { signal: controller.signal }, query.toString());
if (!state.poll.active || state.poll.generation !== generation || state.view !== 'chat') {
return { ok: false, hasMore: false };
}
if (!result || result.error || !Array.isArray(result.messages)) {
throw new Error(result?.error || 'Invalid poll response');
}
$('#cc-conn-dot').className = 'cc-conn-dot online';
$('#cc-online-count').textContent = result.online;
const initial = state.poll.initial;
enqueuePollMessages(result.messages, !initial);
state.poll.initial = false;
const messageCursor = result.messages.reduce(
(cursor, message) => Math.max(cursor, Number(message.id) || 0),
requestedSince
);
const nextCursor = Math.max(requestedSince, Number(result.cursor) || 0, messageCursor);
state.cursor = Math.max(state.cursor, nextCursor);
const recording = $('#recording');
if (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);
}
return { ok: true, hasMore: Boolean(result.has_more) && nextCursor > requestedSince };
} catch (error) {
if (state.poll.active && state.poll.generation === generation) {
$('#cc-conn-dot').className = 'cc-conn-dot offline';
}
return { ok: false, hasMore: false };
} finally {
clearTimeout(timeout);
if (state.pollController === controller) state.pollController = null;
if (generation === state.pollGeneration) state.polling = false;
if (state.poll.controller === controller) state.poll.controller = null;
}
}
async function recoverGroupAccess(groupId, generation) {
const result = await api('groups.php', null, 'action=list').catch(() => null);
if (!result || result.error || generation !== state.pollGeneration) return;
if (result.enabled === false) {
disableGroups(
result.platform_enabled === false ? 'Private groups have been disabled' : 'Private groups are not available on your tier',
result.platform_enabled !== false
);
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) {
function enqueuePollMessages(messages, incoming) {
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 = Math.max(state.lastId, ...messages.map(message => Number(message.id) || 0));
if (!list) return;
const sorted = [...messages].sort((a, b) => (Number(a.id) || 0) - (Number(b.id) || 0));
sorted.forEach(message => {
const numericId = Number(message.id);
if (!Number.isInteger(numericId) || numericId < 1) return;
const id = String(numericId);
if (state.poll.queuedIds.has(id) || list.querySelector(`[data-id="${id}"]`)) return;
state.poll.queuedIds.add(id);
state.poll.queue.push({ message, incoming });
});
drainPollQueue();
if (!list.querySelector('.cc-msg') && state.poll.queue.length === 0) {
list.innerHTML = '<div class="cc-empty-day">NO MESSAGES IN THIS CHANNEL TODAY</div>';
}
list.scrollTop = list.scrollHeight;
}
function drainPollQueue() {
while (state.poll.queue.length) {
const entry = state.poll.queue.shift();
const id = String(entry.message?.id ?? '');
try {
appendMessage(entry.message, entry.incoming);
} catch (error) {
console.error('Could not render chat message', error);
} finally {
state.poll.queuedIds.delete(id);
}
}
}
function appendMessage(message, incoming = false) {
const list = $('#messages');
if (!list || list.querySelector(`[data-id="${message.id}"]`)) return;
const id = Number(message?.id);
if (!list || !Number.isInteger(id) || id < 1 || list.querySelector(`[data-id="${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;
item.dataset.id = String(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">&gt;</span><span class="cc-msg-text">${content}</span>`;
list.appendChild(item);
const nextMessage = Array.from(list.querySelectorAll('.cc-msg'))
.find(existing => Number(existing.dataset.id) > id);
if (nextMessage) list.insertBefore(item, nextMessage);
else list.appendChild(item);
trimMessageBuffer(list);
bindAudioPlayers(item);
if (incoming && message.message_type === 'voice' && message.username !== state.user.username) {
enqueueVoiceAutoplay(item);
}
list.scrollTop = list.scrollHeight;
}
function trimMessageBuffer(list) {
const limit = Math.max(20, Number(state.config.chat?.max_rendered_messages) || 500);
const messages = Array.from(list.querySelectorAll('.cc-msg'));
messages.slice(0, Math.max(0, messages.length - limit)).forEach(message => {
$('audio', message)?.pause();
message.remove();
});
}
function enqueueVoiceAutoplay(item) {
if (!feature('auto_voice_play') || !state.voice.autoPlay) return;
const audio = $('audio', item);
@@ -570,7 +556,7 @@
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(() => {});
await api('messages.php', form({ action: 'recording', active: active ? '1' : '0' })).catch(() => {});
}
async function toggleVoice() {
if (state.voice.recorder) return stopVoice(false);
@@ -644,102 +630,17 @@
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 })
.catch(() => ({ error: 'Voice send failed. Polling will continue.' }));
state.voice.sending = false;
if (result.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false);
if (result.error) return toast(result.error);
appendMessage(result.message);
discardVoice();
requestPoll();
}
function groupsView() {
if (!groupsAvailable()) {
state.view = 'chat';
renderView();
return;
}
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;
if (result.enabled === false) {
disableGroups(
result.platform_enabled === false ? 'Private groups have been disabled' : 'Private groups are not available on your tier',
result.platform_enabled !== false
);
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.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false);
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.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false);
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.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false);
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.groups_enabled === false) return disableGroups(result.error, result.groups_platform_enabled !== false);
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());
@@ -853,12 +754,7 @@
stopVoice(true);
if (request) await api('auth.php', form({ action: 'logout' })).catch(() => {});
state.user = null;
state.groups = [];
state.billing = null;
state.groupId = null;
state.groupsAccess = null;
state.groupSeen = {};
state.groupUnread = {};
state.voice.playQueue = [];
state.voice.queuePlaying = false;
auth('login');