- 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
- 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**:
+30 -7
View File
@@ -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}
<?php
$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 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}
<div class="tbl-wrap">
<table>
<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>
</tr></thead>
<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">
<?= esc($u['username']) ?>
</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>
<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>
@@ -1543,7 +1555,7 @@ td.actions{white-space:nowrap;width:1px}
<td class="actions">
<div class="btn-group">
<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
</button>
<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>
<input type="password" name="new_password" autocomplete="new-password" placeholder="leave blank to keep…">
</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">
⚠ Changing username also updates it in all their messages.
</div>
@@ -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');
}
+7
View File
@@ -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(),
]);
+1
View File
@@ -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;
+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');
}
+13 -2
View File
@@ -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();
}
+4 -4
View File
@@ -6,7 +6,7 @@
<title>CyberChat — Embed Example</title>
<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 rel="stylesheet" href="assets/css/cyberchat.css">
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.2">
<style>
html, body {
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="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="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="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>
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>);
@@ -104,7 +104,7 @@
<script>
window.CYBERCHAT_BASE = '';
</script>
<script src="assets/js/cyberchat-v4.js"></script>
<script src="assets/js/cyberchat-v4.js?v=20260608.2"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#chat-here');
+2 -2
View File
@@ -7,14 +7,14 @@
<title>Chat @ TyClifford.com</title>
<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 rel="stylesheet" href="assets/css/cyberchat.css">
<link rel="stylesheet" href="assets/css/cyberchat.css?v=20260608.2">
</head>
<body>
<div id="app"></div>
<script>
window.CYBERCHAT_BASE = '';
</script>
<script src="assets/js/cyberchat-v4.js"></script>
<script src="assets/js/cyberchat-v4.js?v=20260608.2"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#app');
+6 -5
View File
@@ -27,7 +27,7 @@ function handleAdminPlatformAction(string $action): ?string {
$db = getDB();
$plans = $_POST['plans'] ?? [];
$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',
];
$db->beginTransaction();
@@ -81,6 +81,7 @@ function handleAdminPlatformAction(string $action): ?string {
$id = (int)$db->lastInsertId();
$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,
];
@@ -106,8 +107,8 @@ function renderAdminPlatformTab(): void {
$plans = plans(false);
$db = getDB();
$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
FROM users u LEFT JOIN plans p ON p.id=u.plan_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=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();
$stats = [
'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
WHERE created_at>" . (time() - 2592000) . " GROUP BY event_type ORDER BY total DESC")->fetchAll();
$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',
'voice_export', 'email_2fa',
];
@@ -225,7 +226,7 @@ function renderAdminPlatformTab(): void {
<?php foreach ($subscribers as $subscriber): ?><tr>
<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><?= 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 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>