From d0e4a870bef52121e0464f651120b9782b9153c2 Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Mon, 8 Jun 2026 15:59:04 -0400 Subject: [PATCH] - 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. --- README.md | 5 ++ admin.php | 37 +++++++-- api/messages.php | 7 ++ assets/css/cyberchat.css | 1 + assets/js/cyberchat-v4.js | 163 +++++++++++++++++++++++++++++++++----- bootstrap.php | 15 +++- embed-example.html | 8 +- index.html | 4 +- lib/admin_platform.php | 11 +-- 9 files changed, 209 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index fa31ca1..b94924b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ CyberChat is a framework-free PHP, SQLite, JavaScript, and HTML5 chat applicatio - Private groups with plan-based member limits, including the owner - Premium-configurable recording presence for both sending and viewing - Premium-configurable automatic voice sending +- Tier-configurable automatic playback of newly received voice clips +- Administrator tier overrides that grant plan features without payment - Plan-based custom username colors - Personal text archives retained for at least 30 days - Plan-based voice archive access and JSON/CSV exports @@ -64,6 +66,7 @@ The defaults are starting points and are fully editable in the dashboard. | Group members, including owner | 2 | 4 | 8 | | Recording status send/view | No | Yes | Yes | | Automatic voice send | No | Yes | Yes | +| Automatic voice play | No | Yes | Yes | | Custom username color | No | Yes | Yes | | Text archive | 30 days | 90 days | 365 days | | Text export | Yes | Yes | Yes | @@ -72,6 +75,8 @@ The defaults are starting points and are fully editable in the dashboard. Each tier can independently configure its price, currency, billing interval, Stripe Price ID, active status, sort order, archive retention, group limit, and every feature entitlement. +Administrators can assign a tier directly from **Admin > Users > Edit > Tier Override**. The override grants that tier's features without changing Stripe billing data and remains in effect until it is changed back to **Follow Stripe / billing status**. + ## Mailgun Configure these fields under **Admin > Plans & Platform**: diff --git a/admin.php b/admin.php index df327b2..0c36eb3 100644 --- a/admin.php +++ b/admin.php @@ -350,12 +350,14 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') { $username = trim($_POST['username']); $color = trim($_POST['color']); $newpass = trim($_POST['new_password']); + $manualPlanId = max(0, (int)($_POST['manual_plan_id'] ?? 0)); $errors = []; if ($username === '') $errors[] = 'Username required.'; if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $username)) $errors[] = 'Username: letters, numbers, _ and - only.'; if (strlen($username) > (int)cfgVal('chat.max_username_length', 12)) $errors[] = 'Username too long.'; if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $errors[] = 'Invalid color hex.'; + if ($manualPlanId > 0 && !planById($manualPlanId)) $errors[] = 'Invalid tier override.'; if ($errors) { flash(implode(' ', $errors), 'err'); redirect('tab=users'); } @@ -366,7 +368,8 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') { flash('Username already taken.', 'err'); redirect('tab=users'); } - db()->prepare("UPDATE users SET username = ?, color = ? WHERE id = ?")->execute([$username, $color, $id]); + db()->prepare("UPDATE users SET username = ?, color = ?, manual_plan_id = ? WHERE id = ?") + ->execute([$username, $color, $manualPlanId ?: null, $id]); // Also update username in messages (denormalised) db()->prepare("UPDATE messages SET username = ?, color = ? WHERE user_id = ?")->execute([$username, $color, $id]); @@ -374,7 +377,7 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') { $hash = password_hash($newpass, PASSWORD_BCRYPT, ['cost' => (int)cfgVal('security.bcrypt_cost', 10)]); db()->prepare("UPDATE users SET password_hash = ? WHERE id = ?")->execute([$hash, $id]); } - flash("User #$id updated."); + flash("User #$id updated, including tier assignment."); redirect('tab=users'); } @@ -1502,10 +1505,13 @@ td.actions{white-space:nowrap;width:1px} query(" - SELECT u.*, + SELECT u.*,p.name AS billing_plan_name,mp.name AS manual_plan_name, (SELECT COUNT(*) FROM messages m WHERE m.user_id = u.id) as msg_count, (SELECT COUNT(*) FROM sessions s WHERE s.user_id = u.id) as sess_count - FROM users u ORDER BY u.created_at DESC + FROM users u + LEFT JOIN plans p ON p.id=u.plan_id + LEFT JOIN plans mp ON mp.id=u.manual_plan_id + ORDER BY u.created_at DESC ")->fetchAll(); ?> @@ -1520,7 +1526,7 @@ td.actions{white-space:nowrap;width:1px}
- + @@ -1530,6 +1536,12 @@ td.actions{white-space:nowrap;width:1px} +
IDUsernameColorMessagesIDUsernameTierColorMessages SessionsLast SeenJoinedActions
+ + + +
+
@@ -1543,7 +1555,7 @@ td.actions{white-space:nowrap;width:1px}
@@ -1780,6 +1792,16 @@ td.actions{white-space:nowrap;width:1px}
+
+ + +
An admin tier grants its features without payment and takes precedence over Stripe.
+
⚠ Changing username also updates it in all their messages.
@@ -1818,11 +1840,12 @@ function openEditMsg(id, src, text, username, rqs) { } // ── Edit user ────────────────────────────────────────────────────────────── -function openEditUser(id, username, color) { +function openEditUser(id, username, color, manualPlanId) { document.getElementById('edit-user-id').value = id; document.getElementById('edit-user-name').value = username; document.getElementById('edit-color-txt').value = color; document.getElementById('edit-color-pick').value = color; + document.getElementById('edit-user-plan').value = String(manualPlanId || 0); updateSwatch('edit-color-swatch', color); openModal('modal-edit-user'); } diff --git a/api/messages.php b/api/messages.php index 74c2771..2f57490 100644 --- a/api/messages.php +++ b/api/messages.php @@ -50,6 +50,9 @@ function sendTextMessage(): never { VALUES (?,?,?,?,?,?,?)")->execute([ $user['id'], $groupId, $user['username'], $user['color'], $message, $created, dayKey(), ]); + if ($groupId !== null) { + $db->prepare("UPDATE chat_groups SET updated_at=? WHERE id=?")->execute([$created, $groupId]); + } trackEvent('message_text', '/api/messages.php', (int)$user['id']); jsonResponse(['success' => true, 'message' => messagePayload([ 'id' => $db->lastInsertId(), 'group_id' => $groupId, 'username' => $user['username'], @@ -95,6 +98,9 @@ function sendVoiceMessage(): never { $user['id'], $groupId, $user['username'], $user['color'], '[Voice clip]', $filename, $storedMime, $duration, $created, dayKey(), ]); + if ($groupId !== null) { + $db->prepare("UPDATE chat_groups SET updated_at=? WHERE id=?")->execute([$created, $groupId]); + } } catch (Throwable $e) { deleteVoiceFile($filename); throw $e; @@ -165,6 +171,7 @@ function pollMessages(): never { 'messages' => array_map('messagePayload', $rows), 'online' => (int)$onlineStmt->fetchColumn(), 'recording' => $recording, + 'groups' => userGroups((int)$user['id']), 'group_id' => $groupId, 'server_time' => time(), ]); diff --git a/assets/css/cyberchat.css b/assets/css/cyberchat.css index e4a1b00..046082a 100644 --- a/assets/css/cyberchat.css +++ b/assets/css/cyberchat.css @@ -847,6 +847,7 @@ input, button { font-family: inherit; } } .cc-voice-autoplay input { accent-color: var(--g); } +.cc-voice-autoplay + .cc-voice-autoplay { margin-left: 0; } .cc-voice-review { display: flex; diff --git a/assets/js/cyberchat-v4.js b/assets/js/cyberchat-v4.js index 3972e31..e2b8608 100644 --- a/assets/js/cyberchat-v4.js +++ b/assets/js/cyberchat-v4.js @@ -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 '' + state.groups.map(group => - `` + `` ).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 ? `
VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s ${feature('auto_voice_send') ? `` : ''} + ${feature('auto_voice_play') ? `` : ''}
` : ''}
${max}
`; $('#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 @@ >${content}`; 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'); } diff --git a/bootstrap.php b/bootstrap.php index eb72cee..2a9f3aa 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -174,6 +174,7 @@ function initDB(PDO $db): void { email TEXT, email_verified_at INTEGER, plan_id INTEGER, + manual_plan_id INTEGER, stripe_customer_id TEXT, stripe_subscription_id TEXT, subscription_status TEXT NOT NULL DEFAULT 'free', @@ -187,6 +188,7 @@ function initDB(PDO $db): void { 'email' => 'TEXT', 'email_verified_at' => 'INTEGER', 'plan_id' => 'INTEGER', + 'manual_plan_id' => 'INTEGER', 'stripe_customer_id' => 'TEXT', 'stripe_subscription_id' => 'TEXT', 'subscription_status' => "TEXT NOT NULL DEFAULT 'free'", @@ -347,6 +349,7 @@ function seedPlans(PDO $db): void { 'price' => 0, 'sort' => 0, 'features' => [ 'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false, + 'auto_voice_play' => false, 'group_member_limit' => 2, 'username_color' => false, 'text_archive_days' => 30, 'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false, ], @@ -356,6 +359,7 @@ function seedPlans(PDO $db): void { 'price' => 499, 'sort' => 10, 'features' => [ 'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true, + 'auto_voice_play' => true, 'group_member_limit' => 4, 'username_color' => true, 'text_archive_days' => 90, 'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true, ], @@ -365,6 +369,7 @@ function seedPlans(PDO $db): void { 'price' => 999, 'sort' => 20, 'features' => [ 'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true, + 'auto_voice_play' => true, 'group_member_limit' => 8, 'username_color' => true, 'text_archive_days' => 365, 'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true, ], @@ -413,9 +418,10 @@ function planById(int $planId): ?array { function userPlan(array $user): array { $free = null; + $effectivePlanId = (int)($user['manual_plan_id'] ?? 0) ?: (int)($user['plan_id'] ?? 0); foreach (plans(false) as $plan) { if ($plan['slug'] === 'free') $free = $plan; - if ((int)($user['plan_id'] ?? 0) === $plan['id']) return $plan; + if ($effectivePlanId === $plan['id']) return $plan; } return $free ?? ['id' => 0, 'slug' => 'free', 'name' => 'Free', 'features' => []]; } @@ -434,6 +440,7 @@ function userPublicPayload(array $user): array { 'email' => $user['email'] ?? null, 'email_verified' => !empty($user['email_verified_at']), 'plan' => ['id' => $plan['id'], 'slug' => $plan['slug'], 'name' => $plan['name']], + 'plan_source' => !empty($user['manual_plan_id']) ? 'admin' : 'billing', 'features' => $plan['features'], 'subscription' => [ 'status' => $user['subscription_status'] ?? 'free', @@ -533,7 +540,9 @@ function requireGroupAccess(array $user, ?int $groupId): void { function userGroups(int $userId): array { $stmt = getDB()->prepare("SELECT g.id,g.name,g.owner_id,g.created_at,gm.role, - (SELECT COUNT(*) FROM group_members x WHERE x.group_id=g.id) AS member_count + (SELECT COUNT(*) FROM group_members x WHERE x.group_id=g.id) AS member_count, + (SELECT MAX(m.id) FROM messages m WHERE m.group_id=g.id) AS latest_message_id, + (SELECT MAX(m.created_at) FROM messages m WHERE m.group_id=g.id) AS latest_message_at FROM chat_groups g JOIN group_members gm ON gm.group_id=g.id WHERE gm.user_id=? ORDER BY g.updated_at DESC,g.id DESC"); $stmt->execute([$userId]); @@ -544,6 +553,8 @@ function userGroups(int $userId): array { $group['id'] = (int)$group['id']; $group['owner_id'] = (int)$group['owner_id']; $group['member_count'] = (int)$group['member_count']; + $group['latest_message_id'] = (int)($group['latest_message_id'] ?? 0); + $group['latest_message_at'] = (int)($group['latest_message_at'] ?? 0); $members->execute([$group['id']]); $group['members'] = $members->fetchAll(); } diff --git a/embed-example.html b/embed-example.html index f1169a4..7e236c9 100644 --- a/embed-example.html +++ b/embed-example.html @@ -6,7 +6,7 @@ CyberChat — Embed Example - +