- 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
+5
View File
@@ -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 - Private groups with plan-based member limits, including the owner
- Premium-configurable recording presence for both sending and viewing - Premium-configurable recording presence for both sending and viewing
- Premium-configurable automatic voice sending - 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 - Plan-based custom username colors
- Personal text archives retained for at least 30 days - Personal text archives retained for at least 30 days
- Plan-based voice archive access and JSON/CSV exports - 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 | | Group members, including owner | 2 | 4 | 8 |
| Recording status send/view | No | Yes | Yes | | Recording status send/view | No | Yes | Yes |
| Automatic voice send | No | Yes | Yes | | Automatic voice send | No | Yes | Yes |
| Automatic voice play | No | Yes | Yes |
| Custom username color | No | Yes | Yes | | Custom username color | No | Yes | Yes |
| Text archive | 30 days | 90 days | 365 days | | Text archive | 30 days | 90 days | 365 days |
| Text export | Yes | Yes | Yes | | 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. 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 ## Mailgun
Configure these fields under **Admin > Plans & Platform**: Configure these fields under **Admin > Plans & Platform**:
+30 -7
View File
@@ -350,12 +350,14 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim($_POST['username']); $username = trim($_POST['username']);
$color = trim($_POST['color']); $color = trim($_POST['color']);
$newpass = trim($_POST['new_password']); $newpass = trim($_POST['new_password']);
$manualPlanId = max(0, (int)($_POST['manual_plan_id'] ?? 0));
$errors = []; $errors = [];
if ($username === '') $errors[] = 'Username required.'; if ($username === '') $errors[] = 'Username required.';
if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $username)) $errors[] = 'Username: letters, numbers, _ and - only.'; 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 (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 (!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'); } 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'); 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) // Also update username in messages (denormalised)
db()->prepare("UPDATE messages SET username = ?, color = ? WHERE user_id = ?")->execute([$username, $color, $id]); 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)]); $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]); 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'); redirect('tab=users');
} }
@@ -1502,10 +1505,13 @@ td.actions{white-space:nowrap;width:1px}
<?php <?php
$allUsers = db()->query(" $allUsers = db()->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 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 (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(); ")->fetchAll();
?> ?>
@@ -1520,7 +1526,7 @@ td.actions{white-space:nowrap;width:1px}
<div class="tbl-wrap"> <div class="tbl-wrap">
<table> <table>
<thead><tr> <thead><tr>
<th>ID</th><th>Username</th><th>Color</th><th>Messages</th> <th>ID</th><th>Username</th><th>Tier</th><th>Color</th><th>Messages</th>
<th>Sessions</th><th>Last Seen</th><th>Joined</th><th>Actions</th> <th>Sessions</th><th>Last Seen</th><th>Joined</th><th>Actions</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
@@ -1530,6 +1536,12 @@ td.actions{white-space:nowrap;width:1px}
<td style="color:<?= esc($u['color']) ?>;font-family:var(--mono);font-weight:600"> <td style="color:<?= esc($u['color']) ?>;font-family:var(--mono);font-weight:600">
<?= esc($u['username']) ?> <?= esc($u['username']) ?>
</td> </td>
<td>
<span class="badge <?= $u['manual_plan_id'] ? 'badge-o' : 'badge-b' ?>">
<?= esc($u['manual_plan_name'] ?: $u['billing_plan_name'] ?: 'Free') ?>
</span>
<div class="dim"><?= $u['manual_plan_id'] ? 'ADMIN' : 'BILLING' ?></div>
</td>
<td> <td>
<div style="display:flex;align-items:center;gap:6px"> <div style="display:flex;align-items:center;gap:6px">
<span style="background:<?= esc($u['color']) ?>;width:14px;height:14px;border-radius:50%;display:inline-block;flex-shrink:0"></span> <span style="background:<?= esc($u['color']) ?>;width:14px;height:14px;border-radius:50%;display:inline-block;flex-shrink:0"></span>
@@ -1543,7 +1555,7 @@ td.actions{white-space:nowrap;width:1px}
<td class="actions"> <td class="actions">
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-sm btn-g" <button class="btn btn-sm btn-g"
onclick="openEditUser(<?= $u['id'] ?>,'<?= esc(addslashes($u['username'])) ?>','<?= esc($u['color']) ?>')"> onclick='openEditUser(<?= json_encode((int)$u['id']) ?>,<?= json_encode($u['username']) ?>,<?= json_encode($u['color']) ?>,<?= json_encode((int)($u['manual_plan_id'] ?? 0)) ?>)'>
EDIT EDIT
</button> </button>
<form method="post" style="display:inline"> <form method="post" style="display:inline">
@@ -1780,6 +1792,16 @@ td.actions{white-space:nowrap;width:1px}
<label>New Password <small style="color:var(--t2);text-transform:none">(leave blank to keep current)</small></label> <label>New Password <small style="color:var(--t2);text-transform:none">(leave blank to keep current)</small></label>
<input type="password" name="new_password" autocomplete="new-password" placeholder="leave blank to keep…"> <input type="password" name="new_password" autocomplete="new-password" placeholder="leave blank to keep…">
</div> </div>
<div class="field">
<label>Tier Override</label>
<select name="manual_plan_id" id="edit-user-plan">
<option value="0">Follow Stripe / billing status</option>
<?php foreach (plans(false) as $plan): ?>
<option value="<?= (int)$plan['id'] ?>"><?= esc($plan['name']) ?> (admin assigned)</option>
<?php endforeach; ?>
</select>
<div class="dim" style="margin-top:5px">An admin tier grants its features without payment and takes precedence over Stripe.</div>
</div>
<div class="alert warn" style="font-size:10px;margin-top:4px;margin-bottom:0"> <div class="alert warn" style="font-size:10px;margin-top:4px;margin-bottom:0">
⚠ Changing username also updates it in all their messages. ⚠ Changing username also updates it in all their messages.
</div> </div>
@@ -1818,11 +1840,12 @@ function openEditMsg(id, src, text, username, rqs) {
} }
// ── Edit user ────────────────────────────────────────────────────────────── // ── Edit user ──────────────────────────────────────────────────────────────
function openEditUser(id, username, color) { function openEditUser(id, username, color, manualPlanId) {
document.getElementById('edit-user-id').value = id; document.getElementById('edit-user-id').value = id;
document.getElementById('edit-user-name').value = username; document.getElementById('edit-user-name').value = username;
document.getElementById('edit-color-txt').value = color; document.getElementById('edit-color-txt').value = color;
document.getElementById('edit-color-pick').value = color; document.getElementById('edit-color-pick').value = color;
document.getElementById('edit-user-plan').value = String(manualPlanId || 0);
updateSwatch('edit-color-swatch', color); updateSwatch('edit-color-swatch', color);
openModal('modal-edit-user'); openModal('modal-edit-user');
} }
+7
View File
@@ -50,6 +50,9 @@ function sendTextMessage(): never {
VALUES (?,?,?,?,?,?,?)")->execute([ VALUES (?,?,?,?,?,?,?)")->execute([
$user['id'], $groupId, $user['username'], $user['color'], $message, $created, dayKey(), $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']); trackEvent('message_text', '/api/messages.php', (int)$user['id']);
jsonResponse(['success' => true, 'message' => messagePayload([ jsonResponse(['success' => true, 'message' => messagePayload([
'id' => $db->lastInsertId(), 'group_id' => $groupId, 'username' => $user['username'], '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]', $user['id'], $groupId, $user['username'], $user['color'], '[Voice clip]',
$filename, $storedMime, $duration, $created, dayKey(), $filename, $storedMime, $duration, $created, dayKey(),
]); ]);
if ($groupId !== null) {
$db->prepare("UPDATE chat_groups SET updated_at=? WHERE id=?")->execute([$created, $groupId]);
}
} catch (Throwable $e) { } catch (Throwable $e) {
deleteVoiceFile($filename); deleteVoiceFile($filename);
throw $e; throw $e;
@@ -165,6 +171,7 @@ function pollMessages(): never {
'messages' => array_map('messagePayload', $rows), 'messages' => array_map('messagePayload', $rows),
'online' => (int)$onlineStmt->fetchColumn(), 'online' => (int)$onlineStmt->fetchColumn(),
'recording' => $recording, 'recording' => $recording,
'groups' => userGroups((int)$user['id']),
'group_id' => $groupId, 'group_id' => $groupId,
'server_time' => time(), 'server_time' => time(),
]); ]);
+1
View File
@@ -847,6 +847,7 @@ input, button { font-family: inherit; }
} }
.cc-voice-autoplay input { accent-color: var(--g); } .cc-voice-autoplay input { accent-color: var(--g); }
.cc-voice-autoplay + .cc-voice-autoplay { margin-left: 0; }
.cc-voice-review { .cc-voice-review {
display: flex; display: flex;
+134 -15
View File
@@ -6,11 +6,13 @@
const state = { const state = {
config: {}, user: null, groups: [], billing: null, config: {}, user: null, groups: [], billing: null,
view: ['chat', 'groups', 'archive', 'account'].includes(initialView) ? initialView : 'chat', 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, loginChallenge: initialChallenge,
verifyToken: new URLSearchParams(location.search).get('verify') || '', verifyToken: new URLSearchParams(location.search).get('verify') || '',
voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0, 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; let root;
const $ = (s, c) => (c || root).querySelector(s); const $ = (s, c) => (c || root).querySelector(s);
@@ -240,7 +242,20 @@
api('billing.php', null, 'action=status').catch(() => null) api('billing.php', null, 'action=status').catch(() => null)
]); ]);
state.groups = groups.groups || []; 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; 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(); app();
if (state.verifyToken) { if (state.verifyToken) {
state.view = 'account'; state.view = 'account';
@@ -273,9 +288,23 @@
function groupOptions() { function groupOptions() {
return '<option value="">PUBLIC LOBBY</option>' + state.groups.map(group => 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(''); ).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() { function chat() {
const max = state.config.chat?.max_message_length || 500; const max = state.config.chat?.max_message_length || 500;
const voice = state.config.voice?.enabled !== false && feature('voice_messages', true); 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> ${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> <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_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')} <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>` : ''} <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..."> <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>`; <span class="cc-compose-counter" id="count">${max}</span><button class="cc-compose-send" id="send">SEND</button></div>`;
$('#group-select').addEventListener('change', event => { $('#group-select').addEventListener('change', event => {
state.groupId = event.target.value ? Number(event.target.value) : null; 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; state.lastId = 0;
chat(); chat();
}); });
@@ -306,6 +341,10 @@
$('#voice-send')?.addEventListener('click', sendVoice); $('#voice-send')?.addEventListener('click', sendVoice);
$('#voice-discard')?.addEventListener('click', discardVoice); $('#voice-discard')?.addEventListener('click', discardVoice);
$('#auto-send')?.addEventListener('change', event => { state.voice.autoSend = event.target.checked; }); $('#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')); bindAudioPlayers($('#cc-view'));
state.lastId = 0; state.lastId = 0;
startPoll(); startPoll();
@@ -322,31 +361,60 @@
} }
function startPoll() { function startPoll() {
stopPoll(); stopPoll();
poll(); const generation = state.pollGeneration;
state.pollTimer = setInterval(poll, state.config.chat?.poll_interval_ms || 2000); poll(generation);
state.pollTimer = setInterval(() => poll(generation), state.config.chat?.poll_interval_ms || 2000);
} }
function stopPoll() { function stopPoll() {
if (state.pollTimer) clearInterval(state.pollTimer); if (state.pollTimer) clearInterval(state.pollTimer);
state.pollTimer = null; state.pollTimer = null;
state.polling = false; state.polling = false;
state.pollGeneration++;
} }
async function poll() { async function poll(generation = state.pollGeneration) {
if (state.polling || state.view !== 'chat') return; if (state.polling || state.view !== 'chat' || generation !== state.pollGeneration) return;
const requestedGroup = state.groupId;
const requestedSince = state.lastId;
state.polling = true; state.polling = true;
const query = new URLSearchParams({ action: 'poll', since: state.lastId, group_id: state.groupId || '' }); try {
const query = new URLSearchParams({ action: 'poll', since: requestedSince, group_id: requestedGroup || '' });
const result = await api('messages.php', null, query.toString()).catch(() => null); const result = await api('messages.php', null, query.toString()).catch(() => null);
if (result && !result.error) { 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-conn-dot').className = 'cc-conn-dot online';
$('#cc-online-count').textContent = result.online; $('#cc-online-count').textContent = result.online;
if (state.lastId === 0) renderMessages(result.messages || []); syncGroups(result.groups, true);
else (result.messages || []).forEach(appendMessage); 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 (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'); const recording = $('#recording');
recording.hidden = !result.recording?.length; recording.hidden = !result.recording?.length;
recording.textContent = result.recording?.length recording.textContent = result.recording?.length
? result.recording.map(user => user.username).join(', ') + ' is recording...' : ''; ? result.recording.map(user => user.username).join(', ')
} else $('#cc-conn-dot').className = 'cc-conn-dot offline'; + (result.recording.length === 1 ? ' is' : ' are') + ' recording...'
state.polling = false; : '';
} 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) { function renderMessages(messages) {
const list = $('#messages'); const list = $('#messages');
@@ -355,7 +423,7 @@
if (messages.length) state.lastId = messages[messages.length - 1].id; if (messages.length) state.lastId = messages[messages.length - 1].id;
list.scrollTop = list.scrollHeight; list.scrollTop = list.scrollHeight;
} }
function appendMessage(message) { function appendMessage(message, incoming = false) {
const list = $('#messages'); const list = $('#messages');
if (!list || list.querySelector(`[data-id="${message.id}"]`)) return; if (!list || list.querySelector(`[data-id="${message.id}"]`)) return;
list.querySelector('.cc-loading-msg, .cc-empty-day')?.remove(); 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>`; <span class="cc-msg-sep">&gt;</span><span class="cc-msg-text">${content}</span>`;
list.appendChild(item); list.appendChild(item);
bindAudioPlayers(item); bindAudioPlayers(item);
if (incoming && message.message_type === 'voice' && message.username !== state.user.username) {
enqueueVoiceAutoplay(item);
}
list.scrollTop = list.scrollHeight; 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) { async function recording(active) {
if (!feature('recording_status')) return; if (!feature('recording_status')) return;
@@ -472,6 +578,9 @@
$('#group-create').addEventListener('click', createGroup); $('#group-create').addEventListener('click', createGroup);
$$('[data-open]').forEach(button => button.addEventListener('click', () => { $$('[data-open]').forEach(button => button.addEventListener('click', () => {
state.groupId = Number(button.dataset.open); 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'; state.view = 'chat';
renderView(); renderView();
})); }));
@@ -481,6 +590,12 @@
const [groupId, userId] = button.dataset.remove.split(':').map(Number); const [groupId, userId] = button.dataset.remove.split(':').map(Number);
removeMember(groupId, userId); 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) { function groupCard(group) {
const owner = group.owner_id === Number(state.user.id); const owner = group.owner_id === Number(state.user.id);
@@ -634,6 +749,10 @@
state.groups = []; state.groups = [];
state.billing = null; state.billing = null;
state.groupId = null; state.groupId = null;
state.groupSeen = {};
state.groupUnread = {};
state.voice.playQueue = [];
state.voice.queuePlaying = false;
auth('login'); auth('login');
} }
+13 -2
View File
@@ -174,6 +174,7 @@ function initDB(PDO $db): void {
email TEXT, email TEXT,
email_verified_at INTEGER, email_verified_at INTEGER,
plan_id INTEGER, plan_id INTEGER,
manual_plan_id INTEGER,
stripe_customer_id TEXT, stripe_customer_id TEXT,
stripe_subscription_id TEXT, stripe_subscription_id TEXT,
subscription_status TEXT NOT NULL DEFAULT 'free', subscription_status TEXT NOT NULL DEFAULT 'free',
@@ -187,6 +188,7 @@ function initDB(PDO $db): void {
'email' => 'TEXT', 'email' => 'TEXT',
'email_verified_at' => 'INTEGER', 'email_verified_at' => 'INTEGER',
'plan_id' => 'INTEGER', 'plan_id' => 'INTEGER',
'manual_plan_id' => 'INTEGER',
'stripe_customer_id' => 'TEXT', 'stripe_customer_id' => 'TEXT',
'stripe_subscription_id' => 'TEXT', 'stripe_subscription_id' => 'TEXT',
'subscription_status' => "TEXT NOT NULL DEFAULT 'free'", 'subscription_status' => "TEXT NOT NULL DEFAULT 'free'",
@@ -347,6 +349,7 @@ function seedPlans(PDO $db): void {
'price' => 0, 'sort' => 0, 'price' => 0, 'sort' => 0,
'features' => [ 'features' => [
'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false, '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, 'group_member_limit' => 2, 'username_color' => false, 'text_archive_days' => 30,
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false, '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, 'price' => 499, 'sort' => 10,
'features' => [ 'features' => [
'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true, '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, 'group_member_limit' => 4, 'username_color' => true, 'text_archive_days' => 90,
'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true, '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, 'price' => 999, 'sort' => 20,
'features' => [ 'features' => [
'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true, '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, 'group_member_limit' => 8, 'username_color' => true, 'text_archive_days' => 365,
'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true, '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 { function userPlan(array $user): array {
$free = null; $free = null;
$effectivePlanId = (int)($user['manual_plan_id'] ?? 0) ?: (int)($user['plan_id'] ?? 0);
foreach (plans(false) as $plan) { foreach (plans(false) as $plan) {
if ($plan['slug'] === 'free') $free = $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' => []]; return $free ?? ['id' => 0, 'slug' => 'free', 'name' => 'Free', 'features' => []];
} }
@@ -434,6 +440,7 @@ function userPublicPayload(array $user): array {
'email' => $user['email'] ?? null, 'email' => $user['email'] ?? null,
'email_verified' => !empty($user['email_verified_at']), 'email_verified' => !empty($user['email_verified_at']),
'plan' => ['id' => $plan['id'], 'slug' => $plan['slug'], 'name' => $plan['name']], 'plan' => ['id' => $plan['id'], 'slug' => $plan['slug'], 'name' => $plan['name']],
'plan_source' => !empty($user['manual_plan_id']) ? 'admin' : 'billing',
'features' => $plan['features'], 'features' => $plan['features'],
'subscription' => [ 'subscription' => [
'status' => $user['subscription_status'] ?? 'free', 'status' => $user['subscription_status'] ?? 'free',
@@ -533,7 +540,9 @@ function requireGroupAccess(array $user, ?int $groupId): void {
function userGroups(int $userId): array { function userGroups(int $userId): array {
$stmt = getDB()->prepare("SELECT g.id,g.name,g.owner_id,g.created_at,gm.role, $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 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"); WHERE gm.user_id=? ORDER BY g.updated_at DESC,g.id DESC");
$stmt->execute([$userId]); $stmt->execute([$userId]);
@@ -544,6 +553,8 @@ function userGroups(int $userId): array {
$group['id'] = (int)$group['id']; $group['id'] = (int)$group['id'];
$group['owner_id'] = (int)$group['owner_id']; $group['owner_id'] = (int)$group['owner_id'];
$group['member_count'] = (int)$group['member_count']; $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']]); $members->execute([$group['id']]);
$group['members'] = $members->fetchAll(); $group['members'] = $members->fetchAll();
} }
+4 -4
View File
@@ -6,7 +6,7 @@
<title>CyberChat — Embed Example</title> <title>CyberChat — Embed Example</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/css/cyberchat.css"> <link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.2">
<style> <style>
html, body { html, body {
height: auto; height: auto;
@@ -88,13 +88,13 @@
<span class="kw">&lt;link</span> href=<span class="str">"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&amp;family=Rajdhani:wght@400;600;700&amp;family=Orbitron:wght@700;900&amp;display=swap"</span> rel=<span class="str">"stylesheet"</span><span class="kw">&gt;</span> <span class="kw">&lt;link</span> href=<span class="str">"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&amp;family=Rajdhani:wght@400;600;700&amp;family=Orbitron:wght@700;900&amp;display=swap"</span> rel=<span class="str">"stylesheet"</span><span class="kw">&gt;</span>
<span class="cm">&lt;!-- 2. CyberChat stylesheet --&gt;</span> <span class="cm">&lt;!-- 2. CyberChat stylesheet --&gt;</span>
<span class="kw">&lt;link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css"</span><span class="kw">&gt;</span> <span class="kw">&lt;link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css?v=20260608.2"</span><span class="kw">&gt;</span>
<span class="cm">&lt;!-- 3. Container with any dimensions --&gt;</span> <span class="cm">&lt;!-- 3. Container with any dimensions --&gt;</span>
<span class="kw">&lt;div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">&gt;&lt;/div&gt;</span> <span class="kw">&lt;div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">&gt;&lt;/div&gt;</span>
<span class="cm">&lt;!-- 4. Script + init --&gt;</span> <span class="cm">&lt;!-- 4. Script + init --&gt;</span>
<span class="kw">&lt;script</span> src=<span class="str">"assets/js/cyberchat-v4.js"</span><span class="kw">&gt;&lt;/script&gt;</span> <span class="kw">&lt;script</span> src=<span class="str">"assets/js/cyberchat-v4.js?v=20260608.2"</span><span class="kw">&gt;&lt;/script&gt;</span>
<span class="kw">&lt;script&gt;</span> <span class="kw">&lt;script&gt;</span>
window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span> window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span>
CyberChat.init(<span class="str">'#my-chat'</span>); CyberChat.init(<span class="str">'#my-chat'</span>);
@@ -104,7 +104,7 @@
<script> <script>
window.CYBERCHAT_BASE = ''; window.CYBERCHAT_BASE = '';
</script> </script>
<script src="assets/js/cyberchat-v4.js"></script> <script src="assets/js/cyberchat-v4.js?v=20260608.2"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#chat-here'); CyberChat.init('#chat-here');
+2 -2
View File
@@ -7,14 +7,14 @@
<title>Chat @ TyClifford.com</title> <title>Chat @ TyClifford.com</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/css/cyberchat.css"> <link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.2">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script> <script>
window.CYBERCHAT_BASE = ''; window.CYBERCHAT_BASE = '';
</script> </script>
<script src="assets/js/cyberchat-v4.js"></script> <script src="assets/js/cyberchat-v4.js?v=20260608.2"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#app'); CyberChat.init('#app');
+6 -5
View File
@@ -27,7 +27,7 @@ function handleAdminPlatformAction(string $action): ?string {
$db = getDB(); $db = getDB();
$plans = $_POST['plans'] ?? []; $plans = $_POST['plans'] ?? [];
$boolFeatures = [ $boolFeatures = [
'voice_messages', 'recording_status', 'auto_voice_send', 'username_color', 'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play', 'username_color',
'text_export', 'voice_archive', 'voice_export', 'email_2fa', 'text_export', 'voice_archive', 'voice_export', 'email_2fa',
]; ];
$db->beginTransaction(); $db->beginTransaction();
@@ -81,6 +81,7 @@ function handleAdminPlatformAction(string $action): ?string {
$id = (int)$db->lastInsertId(); $id = (int)$db->lastInsertId();
$features = [ $features = [
'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false, '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, 'group_member_limit' => 2, 'username_color' => false, 'text_archive_days' => 30,
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false, 'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false,
]; ];
@@ -106,8 +107,8 @@ function renderAdminPlatformTab(): void {
$plans = plans(false); $plans = plans(false);
$db = getDB(); $db = getDB();
$subscribers = $db->query("SELECT u.id,u.username,u.email,u.email_verified_at,u.subscription_status, $subscribers = $db->query("SELECT u.id,u.username,u.email,u.email_verified_at,u.subscription_status,
u.subscription_period_end,u.cancel_at_period_end,p.name AS plan_name,u.stripe_customer_id u.subscription_period_end,u.cancel_at_period_end,p.name AS plan_name,u.stripe_customer_id,u.manual_plan_id
FROM users u LEFT JOIN plans p ON p.id=u.plan_id FROM users u LEFT JOIN plans p ON p.id=COALESCE(u.manual_plan_id,u.plan_id)
ORDER BY (u.subscription_status IN ('active','trialing','past_due')) DESC,u.created_at DESC LIMIT 500")->fetchAll(); ORDER BY (u.subscription_status IN ('active','trialing','past_due')) DESC,u.created_at DESC LIMIT 500")->fetchAll();
$stats = [ $stats = [
'visits_24h' => (int)$db->query("SELECT COUNT(*) FROM visitor_events WHERE event_type='visit' AND created_at>" . (time() - 86400))->fetchColumn(), 'visits_24h' => (int)$db->query("SELECT COUNT(*) FROM visitor_events WHERE event_type='visit' AND created_at>" . (time() - 86400))->fetchColumn(),
@@ -118,7 +119,7 @@ function renderAdminPlatformTab(): void {
$events = $db->query("SELECT event_type,COUNT(*) AS total FROM visitor_events $events = $db->query("SELECT event_type,COUNT(*) AS total FROM visitor_events
WHERE created_at>" . (time() - 2592000) . " GROUP BY event_type ORDER BY total DESC")->fetchAll(); WHERE created_at>" . (time() - 2592000) . " GROUP BY event_type ORDER BY total DESC")->fetchAll();
$featureKeys = [ $featureKeys = [
'voice_messages', 'recording_status', 'auto_voice_send', 'group_member_limit', 'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play', 'group_member_limit',
'username_color', 'text_archive_days', 'text_export', 'voice_archive', 'username_color', 'text_archive_days', 'text_export', 'voice_archive',
'voice_export', 'email_2fa', 'voice_export', 'email_2fa',
]; ];
@@ -225,7 +226,7 @@ function renderAdminPlatformTab(): void {
<?php foreach ($subscribers as $subscriber): ?><tr> <?php foreach ($subscribers as $subscriber): ?><tr>
<td><?= esc($subscriber['username']) ?></td> <td><?= esc($subscriber['username']) ?></td>
<td class="dim"><?= esc($subscriber['email'] ?: 'not set') ?> <?= $subscriber['email_verified_at'] ? '<span class="badge badge-g">VERIFIED</span>' : '' ?></td> <td class="dim"><?= esc($subscriber['email'] ?: 'not set') ?> <?= $subscriber['email_verified_at'] ? '<span class="badge badge-g">VERIFIED</span>' : '' ?></td>
<td><?= esc($subscriber['plan_name'] ?: 'Free') ?></td> <td><?= esc($subscriber['plan_name'] ?: 'Free') ?><?= $subscriber['manual_plan_id'] ? ' <span class="badge badge-o">ADMIN</span>' : '' ?></td>
<td><span class="badge <?= in_array($subscriber['subscription_status'], ['active','trialing'], true) ? 'badge-g' : 'badge-o' ?>"><?= esc(strtoupper($subscriber['subscription_status'])) ?></span></td> <td><span class="badge <?= in_array($subscriber['subscription_status'], ['active','trialing'], true) ? 'badge-g' : 'badge-o' ?>"><?= esc(strtoupper($subscriber['subscription_status'])) ?></span></td>
<td class="dim"><?= $subscriber['subscription_period_end'] ? esc(fmtTime((int)$subscriber['subscription_period_end'])) : '-' ?><?= $subscriber['cancel_at_period_end'] ? ' / CANCELING' : '' ?></td> <td class="dim"><?= $subscriber['subscription_period_end'] ? esc(fmtTime((int)$subscriber['subscription_period_end'])) : '-' ?><?= $subscriber['cancel_at_period_end'] ? ' / CANCELING' : '' ?></td>
<td class="dim"><?= esc($subscriber['stripe_customer_id'] ?: '-') ?></td> <td class="dim"><?= esc($subscriber['stripe_customer_id'] ?: '-') ?></td>