- Configurable tier-based Auto Play with a saved user toggle.

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.
This commit is contained in:
Ty Clifford
2026-06-08 15:59:04 -04:00
parent 063b8dc3e8
commit d0e4a870be
9 changed files with 209 additions and 42 deletions
+141 -22
View File
@@ -6,11 +6,13 @@
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,
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, sending: false }
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);
@@ -240,7 +242,20 @@
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';
@@ -273,9 +288,23 @@
function groupOptions() {
return '<option value="">PUBLIC LOBBY</option>' + state.groups.map(group =>
`<option value="${group.id}" ${state.groupId === group.id ? 'selected' : ''}>${esc(group.name)} (${group.member_count})</option>`
`<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);
@@ -288,12 +317,18 @@
${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();
});
@@ -306,6 +341,10 @@
$('#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();
@@ -322,31 +361,60 @@
}
function startPoll() {
stopPoll();
poll();
state.pollTimer = setInterval(poll, state.config.chat?.poll_interval_ms || 2000);
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() {
if (state.polling || state.view !== 'chat') return;
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;
const query = new URLSearchParams({ action: 'poll', since: state.lastId, group_id: state.groupId || '' });
const result = await api('messages.php', null, query.toString()).catch(() => null);
if (result && !result.error) {
$('#cc-conn-dot').className = 'cc-conn-dot online';
$('#cc-online-count').textContent = result.online;
if (state.lastId === 0) renderMessages(result.messages || []);
else (result.messages || []).forEach(appendMessage);
if (result.messages?.length) state.lastId = result.messages[result.messages.length - 1].id;
const recording = $('#recording');
recording.hidden = !result.recording?.length;
recording.textContent = result.recording?.length
? result.recording.map(user => user.username).join(', ') + ' is recording...' : '';
} else $('#cc-conn-dot').className = 'cc-conn-dot offline';
state.polling = false;
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');
@@ -355,7 +423,7 @@
if (messages.length) state.lastId = messages[messages.length - 1].id;
list.scrollTop = list.scrollHeight;
}
function appendMessage(message) {
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();
@@ -370,8 +438,46 @@
<span class="cc-msg-sep">&gt;</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;
@@ -472,6 +578,9 @@
$('#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();
}));
@@ -481,6 +590,12 @@
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);
@@ -634,6 +749,10 @@
state.groups = [];
state.billing = null;
state.groupId = null;
state.groupSeen = {};
state.groupUnread = {};
state.voice.playQueue = [];
state.voice.queuePlaying = false;
auth('login');
}