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