- 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:
+163
-267
@@ -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">></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');
|
||||
|
||||
Reference in New Issue
Block a user