- 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:
@@ -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**:
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+141
-22
@@ -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 result = await api('messages.php', null, query.toString()).catch(() => null);
|
const query = new URLSearchParams({ action: 'poll', since: requestedSince, group_id: requestedGroup || '' });
|
||||||
if (result && !result.error) {
|
const result = await api('messages.php', null, query.toString()).catch(() => null);
|
||||||
$('#cc-conn-dot').className = 'cc-conn-dot online';
|
if (generation !== state.pollGeneration || Number(state.groupId || 0) !== Number(requestedGroup || 0)) return;
|
||||||
$('#cc-online-count').textContent = result.online;
|
if (result && !result.error && Number(result.group_id || 0) === Number(requestedGroup || 0)) {
|
||||||
if (state.lastId === 0) renderMessages(result.messages || []);
|
$('#cc-conn-dot').className = 'cc-conn-dot online';
|
||||||
else (result.messages || []).forEach(appendMessage);
|
$('#cc-online-count').textContent = result.online;
|
||||||
if (result.messages?.length) state.lastId = result.messages[result.messages.length - 1].id;
|
syncGroups(result.groups, true);
|
||||||
const recording = $('#recording');
|
if (requestedSince === 0) renderMessages(result.messages || []);
|
||||||
recording.hidden = !result.recording?.length;
|
else (result.messages || []).forEach(message => appendMessage(message, true));
|
||||||
recording.textContent = result.recording?.length
|
if (result.messages?.length) state.lastId = result.messages[result.messages.length - 1].id;
|
||||||
? result.recording.map(user => user.username).join(', ') + ' is recording...' : '';
|
if (requestedGroup !== null) {
|
||||||
} else $('#cc-conn-dot').className = 'cc-conn-dot offline';
|
state.groupUnread[requestedGroup] = false;
|
||||||
state.polling = 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) {
|
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">></span><span class="cc-msg-text">${content}</span>`;
|
<span class="cc-msg-sep">></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
@@ -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
@@ -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"><link</span> href=<span class="str">"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;600;700&family=Orbitron:wght@700;900&display=swap"</span> rel=<span class="str">"stylesheet"</span><span class="kw">></span>
|
<span class="kw"><link</span> href=<span class="str">"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;600;700&family=Orbitron:wght@700;900&display=swap"</span> rel=<span class="str">"stylesheet"</span><span class="kw">></span>
|
||||||
|
|
||||||
<span class="cm"><!-- 2. CyberChat stylesheet --></span>
|
<span class="cm"><!-- 2. CyberChat stylesheet --></span>
|
||||||
<span class="kw"><link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css"</span><span class="kw">></span>
|
<span class="kw"><link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css?v=20260608.2"</span><span class="kw">></span>
|
||||||
|
|
||||||
<span class="cm"><!-- 3. Container with any dimensions --></span>
|
<span class="cm"><!-- 3. Container with any dimensions --></span>
|
||||||
<span class="kw"><div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">></div></span>
|
<span class="kw"><div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">></div></span>
|
||||||
|
|
||||||
<span class="cm"><!-- 4. Script + init --></span>
|
<span class="cm"><!-- 4. Script + init --></span>
|
||||||
<span class="kw"><script</span> src=<span class="str">"assets/js/cyberchat-v4.js"</span><span class="kw">></script></span>
|
<span class="kw"><script</span> src=<span class="str">"assets/js/cyberchat-v4.js?v=20260608.2"</span><span class="kw">></script></span>
|
||||||
<span class="kw"><script></span>
|
<span class="kw"><script></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
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user