RewriteEngine On
diff --git a/README.md b/README.md
index 5a640b8..c519ab8 100644
--- a/README.md
+++ b/README.md
@@ -138,12 +138,12 @@ Set a PDO MySQL DSN in the dashboard, enable the mirror, and optionally enable a
## Embedding
```html
-
+
-
+
@@ -163,7 +163,7 @@ api/auth.php Registration, login, 2FA, sessions
api/billing.php Stripe checkout, portal, and cancellation
api/messages.php Text, voice, polling, and recording presence
api/stripe-webhook.php Stripe event synchronization
-assets/js/cyberchat-v4.js Browser application
+assets/js/cyberchat-app.js Browser application
lib/mailer.php Reusable Mailgun sender
lib/stripe.php Stripe REST client and subscription sync
lib/mysql_mirror.php Optional SQLite-to-MySQL mirror
diff --git a/admin.php b/admin.php
index a3fc22a..8405866 100644
--- a/admin.php
+++ b/admin.php
@@ -25,7 +25,7 @@ session_start();
function cfg(): array {
static $c = null;
- if ($c === null) $c = json_decode(file_get_contents(CONFIG_FILE), true) ?? [];
+ if ($c === null) $c = getConfig();
return $c;
}
@@ -52,11 +52,6 @@ function ensureAdminMessageColumns(PDO $d): void {
}
}
-function adminPublicMessagePredicate(PDO $d, string $alias = ''): string {
- $prefix = $alias !== '' ? rtrim($alias, '.') . '.' : '';
- return isset(tableColumns($d, 'messages')['group_id']) ? "{$prefix}group_id IS NULL" : '1=1';
-}
-
function archiveDB(string $year, string $month, string $day): ?PDO {
$tpl = cfgVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite');
$path = storagePath(str_replace(['{year}','{month}','{day}'], [$year,$month,$day], $tpl));
@@ -65,6 +60,8 @@ function archiveDB(string $year, string $month, string $day): ?PDO {
$a->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$a->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
ensureAdminMessageColumns($a);
+ removeLegacyGroupSchema($a, false);
+ $a->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)");
return $a;
}
@@ -91,10 +88,12 @@ function createArchiveDB(string $year, string $month, string $day): PDO {
day_key TEXT NOT NULL
)");
ensureAdminMessageColumns($a);
+ removeLegacyGroupSchema($a, false);
$a->exec("CREATE TABLE IF NOT EXISTS archive_meta (
key TEXT PRIMARY KEY,
value TEXT
)");
+ $a->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)");
return $a;
}
@@ -155,9 +154,7 @@ function refreshArchiveMeta(PDO $archive): void {
key TEXT PRIMARY KEY,
value TEXT
)");
- $count = (int)$archive->query(
- "SELECT COUNT(*) FROM messages WHERE " . adminPublicMessagePredicate($archive)
- )->fetchColumn();
+ $count = (int)$archive->query("SELECT COUNT(*) FROM messages")->fetchColumn();
$meta = $archive->prepare("INSERT OR REPLACE INTO archive_meta(key,value) VALUES(?,?)");
$meta->execute(['archived_at', (string)time()]);
$meta->execute(['message_count', (string)$count]);
@@ -199,8 +196,7 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
if ($act === 'archive_voice_clip') {
$id = (int)($_POST['msg_id'] ?? 0);
$live = db();
- $stmt = $live->prepare("SELECT * FROM messages WHERE id = ? AND message_type = 'voice' AND "
- . adminPublicMessagePredicate($live));
+ $stmt = $live->prepare("SELECT * FROM messages WHERE id = ? AND message_type = 'voice'");
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) {
@@ -241,8 +237,7 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
if ($adb->inTransaction()) $adb->rollBack();
throw $e;
}
- $delete = $live->prepare("DELETE FROM messages WHERE id = ? AND message_type = 'voice' AND "
- . adminPublicMessagePredicate($live));
+ $delete = $live->prepare("DELETE FROM messages WHERE id = ? AND message_type = 'voice'");
$delete->execute([$id]);
if ($delete->rowCount() !== 1) {
throw new RuntimeException('The live clip changed before it could be removed.');
@@ -259,11 +254,10 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
$src = $_POST['msg_src'] ?? 'live'; // 'live' or 'archive:Y-m-d'
if ($src === 'live') {
$live = db();
- $public = adminPublicMessagePredicate($live);
- $row = $live->prepare("SELECT voice_file FROM messages WHERE id = ? AND $public");
+ $row = $live->prepare("SELECT voice_file FROM messages WHERE id = ?");
$row->execute([$id]);
deleteRecordingsForRows($row->fetchAll());
- $live->prepare("DELETE FROM messages WHERE id = ? AND $public")->execute([$id]);
+ $live->prepare("DELETE FROM messages WHERE id = ?")->execute([$id]);
flash("Message #$id deleted.");
} else {
[$_, $day] = explode(':', $src, 2);
@@ -295,8 +289,8 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
}
if ($src === 'live') {
$live = db();
- $live->prepare("UPDATE messages SET message = ?, username = ? WHERE id = ? AND "
- . adminPublicMessagePredicate($live))->execute([$txt, $username, $id]);
+ $live->prepare("UPDATE messages SET message = ?, username = ? WHERE id = ?")
+ ->execute([$txt, $username, $id]);
flash("Message #$id updated.");
} else {
[$_, $day] = explode(':', $src, 2);
@@ -314,12 +308,11 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
$ids = array_map('intval', $_POST['msg_ids'] ?? []);
if ($ids) {
$live = db();
- $public = adminPublicMessagePredicate($live);
$ph = implode(',', array_fill(0, count($ids), '?'));
- $rows = $live->prepare("SELECT voice_file FROM messages WHERE id IN ($ph) AND $public");
+ $rows = $live->prepare("SELECT voice_file FROM messages WHERE id IN ($ph)");
$rows->execute($ids);
deleteRecordingsForRows($rows->fetchAll());
- $live->prepare("DELETE FROM messages WHERE id IN ($ph) AND $public")->execute($ids);
+ $live->prepare("DELETE FROM messages WHERE id IN ($ph)")->execute($ids);
flash(count($ids) . ' message(s) deleted.');
}
redirect($_POST['return_qs'] ?? '');
@@ -328,11 +321,10 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
if ($act === 'delete_day_live') {
$day = $_POST['day_key'];
$live = db();
- $public = adminPublicMessagePredicate($live);
- $rows = $live->prepare("SELECT voice_file FROM messages WHERE day_key = ? AND $public");
+ $rows = $live->prepare("SELECT voice_file FROM messages WHERE day_key = ?");
$rows->execute([$day]);
deleteRecordingsForRows($rows->fetchAll());
- $st = $live->prepare("DELETE FROM messages WHERE day_key = ? AND $public");
+ $st = $live->prepare("DELETE FROM messages WHERE day_key = ?");
$st->execute([$day]);
flash("All messages for $day deleted (" . $st->rowCount() . " rows).");
redirect('tab=messages');
@@ -356,9 +348,8 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
if ($act === 'clear_all_live') {
$live = db();
- $public = adminPublicMessagePredicate($live);
- deleteRecordingsForRows($live->query("SELECT voice_file FROM messages WHERE $public")->fetchAll());
- $live->exec("DELETE FROM messages WHERE $public");
+ deleteRecordingsForRows($live->query("SELECT voice_file FROM messages")->fetchAll());
+ $live->exec("DELETE FROM messages");
flash("All live messages cleared.");
redirect('tab=messages');
}
@@ -474,12 +465,11 @@ $stats = [];
if ($isLoggedIn) {
try {
$live = db();
- $public = adminPublicMessagePredicate($live);
$stats['users'] = $live->query("SELECT COUNT(*) FROM users")->fetchColumn();
- $stats['messages'] = $live->query("SELECT COUNT(*) FROM messages WHERE $public")->fetchColumn();
- $stats['voice'] = $live->query("SELECT COUNT(*) FROM messages WHERE message_type = 'voice' AND $public")->fetchColumn();
+ $stats['messages'] = $live->query("SELECT COUNT(*) FROM messages")->fetchColumn();
+ $stats['voice'] = $live->query("SELECT COUNT(*) FROM messages WHERE message_type = 'voice'")->fetchColumn();
$stats['sessions'] = $live->query("SELECT COUNT(*) FROM sessions")->fetchColumn();
- $stats['today'] = $live->query("SELECT COUNT(*) FROM messages WHERE day_key = '" . date('Y-m-d') . "' AND $public")->fetchColumn();
+ $stats['today'] = $live->query("SELECT COUNT(*) FROM messages WHERE day_key = '" . date('Y-m-d') . "'")->fetchColumn();
$timeout = (int)cfgVal('security.session_timeout_hours', 24) * 3600;
$stats['active'] = db()->query("SELECT COUNT(*) FROM sessions WHERE last_active > " . (time() - $timeout))->fetchColumn();
} catch (Exception $e) { $stats = ['users'=>'?','messages'=>'?','voice'=>'?','sessions'=>'?','today'=>'?','active'=>'?']; }
@@ -496,8 +486,8 @@ if ($isLoggedIn) {
$sz = filesize($f);
$cnt = 0;
try {
- $x = new PDO('sqlite:'.$f);
- $cnt = $x->query("SELECT COUNT(*) FROM messages WHERE " . adminPublicMessagePredicate($x))->fetchColumn();
+ $x = archiveDB($m[1], $m[2], $m[3]);
+ if ($x) $cnt = $x->query("SELECT COUNT(*) FROM messages")->fetchColumn();
} catch(Exception $e) {}
$archives[] = ['day'=>$day,'path'=>$f,'size'=>$sz,'count'=>$cnt,'year'=>$m[1],'month'=>$m[2],'daynum'=>$m[3]];
}
@@ -969,7 +959,7 @@ td.actions{white-space:nowrap;width:1px}
query("SELECT m.*,u.username as uname FROM messages m LEFT JOIN users u ON u.id=m.user_id
- WHERE " . adminPublicMessagePredicate($live, 'm') . " ORDER BY m.created_at DESC LIMIT 8")->fetchAll();
+ ORDER BY m.created_at DESC LIMIT 8")->fetchAll();
if (empty($recent)): ?>
NO MESSAGES YET
@@ -1048,7 +1038,7 @@ td.actions{white-space:nowrap;width:1px}
$fType = trim($_GET['type'] ?? '');
$live = db();
- $where = [adminPublicMessagePredicate($live)]; $params = [];
+ $where = []; $params = [];
if ($fUser) { $where[] = "username LIKE ?"; $params[] = '%'.$fUser.'%'; }
if ($fText) { $where[] = "message LIKE ?"; $params[] = '%'.$fText.'%'; }
if ($fDay) { $where[] = "day_key = ?"; $params[] = $fDay; }
@@ -1057,8 +1047,7 @@ td.actions{white-space:nowrap;width:1px}
$msgs = $live->prepare($sql); $msgs->execute($params); $msgs = $msgs->fetchAll();
// Days available
- $days = $live->query("SELECT DISTINCT day_key FROM messages WHERE "
- . adminPublicMessagePredicate($live) . " ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN);
+ $days = $live->query("SELECT DISTINCT day_key FROM messages ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN);
?>
@@ -1203,7 +1192,7 @@ td.actions{white-space:nowrap;width:1px}
$voiceRows = [];
if ($vSource !== 'archive') {
$live = db();
- $vWhere = ["message_type = 'voice'", adminPublicMessagePredicate($live)];
+ $vWhere = ["message_type = 'voice'"];
$vParams = [];
if ($vUser !== '') { $vWhere[] = 'username LIKE ?'; $vParams[] = '%' . $vUser . '%'; }
if ($vDay !== '') { $vWhere[] = 'day_key = ?'; $vParams[] = $vDay; }
@@ -1221,8 +1210,7 @@ td.actions{white-space:nowrap;width:1px}
try {
$vAdb = archiveDB($arc['year'], $arc['month'], $arc['daynum']);
if (!$vAdb) continue;
- $vSql = "SELECT * FROM messages WHERE message_type = 'voice' AND "
- . adminPublicMessagePredicate($vAdb);
+ $vSql = "SELECT * FROM messages WHERE message_type = 'voice'";
$vParams = [];
if ($vUser !== '') { $vSql .= ' AND username LIKE ?'; $vParams[] = '%' . $vUser . '%'; }
$vSql .= ' ORDER BY created_at DESC LIMIT 500';
@@ -1242,8 +1230,7 @@ td.actions{white-space:nowrap;width:1px}
$voiceRows = array_slice($voiceRows, 0, 500);
$live = db();
$voiceDays = array_unique(array_merge(
- $live->query("SELECT DISTINCT day_key FROM messages WHERE message_type = 'voice' AND "
- . adminPublicMessagePredicate($live) . " ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN),
+ $live->query("SELECT DISTINCT day_key FROM messages WHERE message_type = 'voice' ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN),
array_column($archives, 'day')
));
rsort($voiceDays);
@@ -1356,7 +1343,7 @@ td.actions{white-space:nowrap;width:1px}
if ($adb) {
$fAUser = trim($_GET['au'] ?? '');
$fAText = trim($_GET['at'] ?? '');
- $awhere = [adminPublicMessagePredicate($adb)]; $aparams = [];
+ $awhere = []; $aparams = [];
if ($fAUser) { $awhere[] = "username LIKE ?"; $aparams[] = '%'.$fAUser.'%'; }
if ($fAText) { $awhere[] = "message LIKE ?"; $aparams[] = '%'.$fAText.'%'; }
$asql = "SELECT * FROM messages" . ($awhere ? " WHERE ".implode(" AND ", $awhere) : "") . " ORDER BY created_at DESC LIMIT 500";
@@ -1539,8 +1526,7 @@ td.actions{white-space:nowrap;width:1px}
$live = db();
$allUsers = $live->query("
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 AND "
- . adminPublicMessagePredicate($live, 'm') . ") 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
FROM users u
LEFT JOIN plans p ON p.id=u.plan_id
diff --git a/api/messages.php b/api/messages.php
index e6212a1..e6782e3 100644
--- a/api/messages.php
+++ b/api/messages.php
@@ -30,10 +30,6 @@ function messagePayload(array $row): array {
];
}
-function publicMessageCondition(PDO $db): string {
- return isset(tableColumns($db, 'messages')['group_id']) ? ' AND group_id IS NULL' : '';
-}
-
function sendTextMessage(): never {
$user = authRequired();
$message = trim((string)($_POST['message'] ?? ''));
@@ -155,17 +151,16 @@ function pollMessages(): never {
$since = max(0, (int)($_GET['since'] ?? 0));
$limit = max(1, min(250, (int)getConfigVal('chat.messages_per_page', 100)));
$db = getDB();
- $publicOnly = publicMessageCondition($db);
if ($since === 0) {
$stmt = $db->prepare("SELECT * FROM messages
- WHERE day_key=?$publicOnly ORDER BY id DESC LIMIT ?");
+ WHERE day_key=? ORDER BY id DESC LIMIT ?");
$stmt->execute([dayKey(), $limit]);
$rows = array_reverse($stmt->fetchAll());
$hasMore = false;
} else {
$stmt = $db->prepare("SELECT * FROM messages
- WHERE day_key=?$publicOnly AND id>? ORDER BY id LIMIT ?");
+ WHERE day_key=? AND id>? ORDER BY id LIMIT ?");
$stmt->execute([dayKey(), $since, $limit + 1]);
$rows = $stmt->fetchAll();
$hasMore = count($rows) > $limit;
diff --git a/assets/css/cyberchat.css b/assets/css/cyberchat.css
index 046082a..88c20b3 100644
--- a/assets/css/cyberchat.css
+++ b/assets/css/cyberchat.css
@@ -1178,9 +1178,6 @@ html, body {
.cc-inline-btn.danger { color: var(--r); border-color: var(--r); }
.cc-inline-btn:disabled { opacity: .35; cursor: not-allowed; }
.cc-actions { display: flex; gap: 6px; flex-wrap: wrap; }
-.cc-member-list { display: flex; gap: 6px; flex-wrap: wrap; }
-.cc-member-list span { padding: 4px 7px; border: 1px solid var(--bd); border-radius: 2px; font: 10px var(--mono); }
-.cc-member-list button { color: var(--r); background: none; border: 0; margin-left: 4px; }
.cc-empty-card { padding: 30px; color: var(--t1); border: 1px dashed var(--bd); text-align: center; font: 10px var(--mono); }
.cc-search-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin-bottom: 12px; }
.cc-archive-list { display: flex; flex-direction: column; gap: 6px; }
diff --git a/assets/js/cyberchat-v4.js b/assets/js/cyberchat-app.js
similarity index 98%
rename from assets/js/cyberchat-v4.js
rename to assets/js/cyberchat-app.js
index 0a5b796..d22438a 100644
--- a/assets/js/cyberchat-v4.js
+++ b/assets/js/cyberchat-app.js
@@ -19,6 +19,11 @@
};
let root;
let wakeListenersBound = false;
+ const displayedFeatures = new Set([
+ 'voice_messages', 'recording_status', 'auto_voice_send', 'auto_voice_play',
+ 'username_color', 'text_archive_days', 'text_export', 'voice_archive',
+ 'voice_export', 'email_2fa'
+ ]);
const $ = (s, c) => (c || root).querySelector(s);
const $$ = (s, c) => Array.from((c || root).querySelectorAll(s));
const esc = value => String(value == null ? '' : value)
@@ -27,8 +32,8 @@
function base() {
if (window.CYBERCHAT_BASE != null) return String(window.CYBERCHAT_BASE).replace(/\/$/, '');
- const script = Array.from(document.scripts).find(item => item.src.includes('cyberchat-v4.js'));
- return script ? script.src.replace(/\/assets\/js\/cyberchat-v4\.js.*$/, '') : '';
+ const script = Array.from(document.scripts).find(item => item.src.includes('cyberchat-app.js'));
+ return script ? script.src.replace(/\/assets\/js\/cyberchat-app\.js.*$/, '') : '';
}
function apiUrl(file, query = '') { return base() + '/api/' + file + (query ? '?' + query : ''); }
function publicUrl(path) {
@@ -54,6 +59,10 @@
return state.user?.features && Object.prototype.hasOwnProperty.call(state.user.features, key)
? state.user.features[key] : fallback;
}
+ function visibleFeatures() {
+ return Object.entries(state.user?.features || {})
+ .filter(([key]) => displayedFeatures.has(String(key).toLowerCase()));
+ }
function toast(message, type = 'error') {
const area = $('#cc-toasts');
if (!area) return;
@@ -681,7 +690,7 @@
${showBilling ? billingHtml(billing) : ''}
CURRENT FEATURES
- ${Object.entries(state.user.features).map(([key, value]) => `${esc(key.replace(/_/g, ' '))} ${esc(String(value))}`).join('')}
+ ${visibleFeatures().map(([key, value]) => `${esc(key.replace(/_/g, ' '))} ${esc(String(value))}`).join('')}
`;
$('#logout').addEventListener('click', () => logout(true));
$('#email-send').addEventListener('click', requestEmail);
diff --git a/assets/js/cyberchat.js b/assets/js/cyberchat.js
deleted file mode 100644
index a107806..0000000
--- a/assets/js/cyberchat.js
+++ /dev/null
@@ -1,1027 +0,0 @@
-/**
- * CyberChat Client v3.0
- * Embed-ready real-time chat with registration & login
- */
-
-(function(window, document) {
- 'use strict';
-
- // ─── State ────────────────────────────────────────────────────────────────
- const state = {
- config: null,
- auth: { authenticated: false, username: null, color: null },
- chat: { lastId: 0, pollTimer: null, polling: false },
- voice: {
- recorder: null, stream: null, timer: null, startedAt: 0,
- chunks: [], blob: null, duration: 0, fallback: null,
- autoPlay: true, starting: false, queue: [], current: null,
- playbackBlocked: false
- },
- ui: { darkMode: true, atBottom: true, screen: 'loading' },
- online: 0,
- };
-
- // ─── DOM refs ─────────────────────────────────────────────────────────────
- let root, msgList, msgInput, charCount, sendBtn, scrollBtn,
- toastArea, onlineBadge, connStatus, voiceRecordBtn, voiceStatus,
- voiceReview, voicePreview, voiceSendBtn, voiceDiscardBtn,
- voiceQueueBtn, voiceQueueCount;
-
- // ─── Utils ────────────────────────────────────────────────────────────────
- const $ = (sel, ctx) => (ctx || root).querySelector(sel);
- const esc = str => String(str)
- .replace(/&/g,'&').replace(//g,'>')
- .replace(/"/g,'"').replace(/'/g,''');
-
- function apiPath(ep) {
- // Auto-detect base path from the script tag if CYBERCHAT_BASE not explicitly set
- if (!window._CC_BASE_RESOLVED) {
- window._CC_BASE_RESOLVED = true;
- if (!window.CYBERCHAT_BASE) {
- var scripts = document.querySelectorAll('script[src]');
- for (var i = 0; i < scripts.length; i++) {
- var src = scripts[i].src;
- if (src.indexOf('cyberchat.js') !== -1) {
- // e.g. /chat/assets/js/cyberchat.js -> /chat
- window.CYBERCHAT_BASE = src.replace(/\/assets\/js\/cyberchat\.js.*$/, '');
- break;
- }
- }
- }
- }
- var base = (window.CYBERCHAT_BASE || '').replace(/\/$/, '');
- return base + '/api/' + ep;
- }
-
- function publicPath(path) {
- if (/^(https?:)?\/\//.test(path || '') || String(path || '').startsWith('/')) return path;
- var base = (window.CYBERCHAT_BASE || '').replace(/\/$/, '');
- return base + '/' + String(path || '').replace(/^\//, '');
- }
-
- async function apiFetch(ep, opts) {
- const res = await fetch(apiPath(ep), { credentials: 'include', ...(opts||{}) });
- if (!res.ok && res.status !== 400 && res.status !== 401 && res.status !== 403 && res.status !== 409) {
- const text = await res.text().catch(() => '');
- throw new Error('HTTP ' + res.status + (text ? ': ' + text.slice(0, 120) : ''));
- }
- const ct = res.headers.get('content-type') || '';
- if (!ct.includes('application/json')) {
- const text = await res.text().catch(() => '');
- throw new Error('Expected JSON but got: ' + text.slice(0, 160));
- }
- return res.json();
- }
-
- function fmtTime(ts) {
- return new Date(ts * 1000).toLocaleTimeString('en-US',
- { hour:'2-digit', minute:'2-digit', hour12:false });
- }
-
- function linkify(str) {
- return str.replace(/(https?:\/\/[^\s<>]+)/g,
- '$1');
- }
-
- function saveTheme(dark) {
- try { localStorage.setItem('cc_theme', dark ? 'dark' : 'light'); } catch(e){}
- }
- function loadTheme() {
- try { return localStorage.getItem('cc_theme') !== 'light'; } catch(e){ return true; }
- }
- function loadVoiceAutoplay(defaultEnabled) {
- try {
- const saved = localStorage.getItem('cc_voice_autoplay');
- return saved === null ? !!defaultEnabled : saved === 'true';
- } catch(e) {
- return !!defaultEnabled;
- }
- }
- function saveVoiceAutoplay(enabled) {
- try { localStorage.setItem('cc_voice_autoplay', enabled ? 'true' : 'false'); } catch(e){}
- }
-
- function toast(msg, type) {
- if (!toastArea) return;
- const el = document.createElement('div');
- el.className = 'cc-toast ' + (type || 'error');
- el.textContent = msg;
- toastArea.appendChild(el);
- setTimeout(() => { el.classList.add('cc-toast-fade'); setTimeout(() => el.remove(), 350); }, 2800);
- }
-
- // ─── Init ─────────────────────────────────────────────────────────────────
- async function init(sel) {
- root = typeof sel === 'string' ? document.querySelector(sel) : sel;
- if (!root) { console.error('[CyberChat] Container not found:', sel); return; }
- root.id = 'cyberchat-root';
-
- // Load config from server
- try {
- const r = await fetch(apiPath('config.php'), { credentials: 'include' });
- state.config = await r.json();
- } catch(e) {
- state.config = {
- chat: { max_message_length: 500, max_username_length: 12, poll_interval_ms: 2000 },
- ui: { app_title: 'CYBERCHAT', app_subtitle: 'v3.0 // SECURE CHANNEL' },
- security: { min_password_length: 4 },
- colors: { user_palette: ['#00ff9f','#00b8ff','#ff2d78','#ff9f00','#ff3c3c'] },
- voice: { enabled: true, max_duration_seconds: 60, max_upload_bytes: 12582912, auto_play_default: true }
- };
- }
-
- state.ui.darkMode = loadTheme();
- state.voice.autoPlay = loadVoiceAutoplay(state.config?.voice?.auto_play_default !== false);
- if (!state.ui.darkMode) document.body.classList.add('cc-light');
-
- renderShell();
- cacheRefs();
- bindGlobal();
-
- // Check existing session
- try {
- const check = await apiFetch('auth.php?action=check');
- if (check.authenticated) {
- state.auth = { authenticated: true, username: check.username, color: check.color };
- showChat();
- return;
- }
- } catch(e) {}
-
- showAuth('register'); // Default to register for new visitors
- }
-
- // ─── Shell render (persistent chrome) ────────────────────────────────────
- function renderShell() {
- const cfg = state.config;
- const title = esc(cfg?.ui?.app_title || 'CYBERCHAT');
- const sub = esc(cfg?.ui?.app_subtitle || 'v3.0 // SECURE CHANNEL');
-
- root.innerHTML = `
-
-
-
-
-
-
-
- `;
- }
-
- function cacheRefs() {
- toastArea = $('#cc-toasts');
- onlineBadge = $('#cc-online-count');
- connStatus = $('#cc-conn-dot');
- }
-
- // ─── Auth screen ──────────────────────────────────────────────────────────
- function showAuth(initialTab) {
- stopPolling();
- state.ui.screen = 'auth';
-
- const cfg = state.config;
- const maxUser = cfg?.chat?.max_username_length || 12;
- const minPass = cfg?.security?.min_password_length || 4;
- const maxMsg = cfg?.chat?.max_message_length || 500;
- const tab = initialTab || 'register';
-
- $('#cc-body').classList.remove('cc-has-voice');
- $('#cc-body').innerHTML = `
-
-
-
-
-
-
IDENTIFY YOURSELF
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
-
- bindAuth();
-
- // Autofocus first input
- setTimeout(() => {
- const first = tab === 'register'
- ? $('#cc-reg-user')
- : $('#cc-login-user');
- first && first.focus();
- }, 80);
- }
-
- function bindAuth() {
- // Tab switching
- const tabs = root.querySelectorAll('.cc-auth-tab');
- tabs.forEach(t => {
- t.addEventListener('click', () => {
- const target = t.dataset.target;
- const which = t.dataset.tab;
- tabs.forEach(x => x.classList.remove('active'));
- t.classList.add('active');
- root.querySelectorAll('.cc-auth-form').forEach(f => f.classList.remove('active'));
- $('#' + target).classList.add('active');
- clearAuthErr();
- const inp = $('#' + target + ' .cc-input');
- inp && inp.focus();
- });
- });
-
- // Register
- const regBtn = $('#cc-reg-btn');
- if (regBtn) {
- regBtn.addEventListener('click', doRegister);
- ['cc-reg-user','cc-reg-pass','cc-reg-pass2'].forEach(id => {
- const el = $('#' + id);
- if (el) el.addEventListener('keydown', e => { if(e.key==='Enter') doRegister(); });
- });
- }
-
- // Login
- const loginBtn = $('#cc-login-btn');
- if (loginBtn) {
- loginBtn.addEventListener('click', doLogin);
- ['cc-login-user','cc-login-pass'].forEach(id => {
- const el = $('#' + id);
- if (el) el.addEventListener('keydown', e => { if(e.key==='Enter') doLogin(); });
- });
- }
- }
-
- function showAuthErr(msg) {
- const el = $('#cc-auth-err');
- if (el) { el.textContent = '⚠ ' + msg; el.classList.add('visible'); }
- }
- function clearAuthErr() {
- const el = $('#cc-auth-err');
- if (el) { el.textContent = ''; el.classList.remove('visible'); }
- }
-
- async function doRegister() {
- const user = ($('#cc-reg-user')?.value || '').trim();
- const pass = $('#cc-reg-pass')?.value || '';
- const pass2 = $('#cc-reg-pass2')?.value || '';
- const btn = $('#cc-reg-btn');
- const cfg = state.config;
- const maxUser = cfg?.chat?.max_username_length || 12;
- const minPass = cfg?.security?.min_password_length || 4;
-
- clearAuthErr();
-
- if (!user) return showAuthErr('Please enter a callsign');
- if (user.length > maxUser) return showAuthErr(`Callsign max ${maxUser} characters`);
- if (!/^[a-zA-Z0-9_\-]+$/.test(user)) return showAuthErr('Letters, numbers, _ and - only');
- if (!pass) return showAuthErr('Please enter a passphrase');
- if (pass.length < minPass) return showAuthErr(`Passphrase min ${minPass} characters`);
- if (pass !== pass2) return showAuthErr('Passphrases do not match');
-
- btn.disabled = true;
- btn.querySelector('.cc-btn-inner').textContent = 'CREATING...';
-
- const fd = new FormData();
- fd.append('action', 'register');
- fd.append('username', user);
- fd.append('password', pass);
-
- try {
- const res = await apiFetch('auth.php', { method:'POST', body:fd });
- if (res.success) {
- state.auth = { authenticated: true, username: res.username, color: res.color };
- toast('Welcome, ' + res.username + '!', 'success');
- showChat();
- } else {
- showAuthErr(res.error || 'Registration failed');
- btn.disabled = false;
- btn.querySelector('.cc-btn-inner').textContent = 'CREATE IDENTITY';
- }
- } catch(e) {
- showAuthErr('Server error: ' + (e.message || 'cannot reach /api/auth.php — check CYBERCHAT_BASE and PHP'));
- btn.disabled = false;
- btn.querySelector('.cc-btn-inner').textContent = 'CREATE IDENTITY';
- }
- }
-
- async function doLogin() {
- const user = ($('#cc-login-user')?.value || '').trim();
- const pass = $('#cc-login-pass')?.value || '';
- const btn = $('#cc-login-btn');
-
- clearAuthErr();
-
- if (!user) return showAuthErr('Please enter your callsign');
- if (!pass) return showAuthErr('Please enter your passphrase');
-
- btn.disabled = true;
- btn.querySelector('.cc-btn-inner').textContent = 'AUTHENTICATING...';
-
- const fd = new FormData();
- fd.append('action', 'login');
- fd.append('username', user);
- fd.append('password', pass);
-
- try {
- const res = await apiFetch('auth.php', { method:'POST', body:fd });
- if (res.success) {
- state.auth = { authenticated: true, username: res.username, color: res.color };
- toast('Access granted, ' + res.username, 'success');
- showChat();
- } else {
- showAuthErr(res.error || 'Authentication failed');
- btn.disabled = false;
- btn.querySelector('.cc-btn-inner').textContent = 'AUTHENTICATE';
- }
- } catch(e) {
- showAuthErr('Server error: ' + (e.message || 'cannot reach /api/auth.php — check CYBERCHAT_BASE and PHP'));
- btn.disabled = false;
- btn.querySelector('.cc-btn-inner').textContent = 'AUTHENTICATE';
- }
- }
-
- // ─── Chat screen ──────────────────────────────────────────────────────────
- function showChat() {
- state.ui.screen = 'chat';
- const { username, color } = state.auth;
- const maxMsg = state.config?.chat?.max_message_length || 500;
- const voiceEnabled = state.config?.voice?.enabled !== false;
- const voiceMax = state.config?.voice?.max_duration_seconds || 60;
- const today = new Date().toLocaleDateString('en-US',
- { weekday:'short', month:'short', day:'numeric', year:'numeric' });
-
- $('#cc-body').classList.toggle('cc-has-voice', voiceEnabled);
- $('#cc-body').innerHTML = `
-
-
- YOU:
-
- ${esc(username)}
-
- ${esc(today.toUpperCase())}
-
-
-
-
-
-
-
-
-
-
LOADING CHANNEL DATA...
-
-
-
-
-
- ${voiceEnabled ? `
-
-
-
VOICE CLIP · MAX ${voiceMax}s
-
-
-
-
` : ''}
-
-
-
- ${maxMsg}
-
-
- `;
-
- // Cache
- msgList = $('#cc-messages');
- msgInput = $('#cc-msg-input');
- charCount = $('#cc-char-count');
- sendBtn = $('#cc-send-btn');
- scrollBtn = $('#cc-scroll-btn');
- voiceRecordBtn = $('#cc-voice-record');
- voiceStatus = $('#cc-voice-status');
- voiceReview = $('#cc-voice-review');
- voicePreview = $('#cc-voice-preview');
- voiceSendBtn = $('#cc-voice-send');
- voiceDiscardBtn = $('#cc-voice-discard');
- voiceQueueBtn = $('#cc-voice-queue');
- voiceQueueCount = $('#cc-voice-queue-count');
-
- bindChat();
- updateVoiceQueueUI();
- state.chat.lastId = 0;
- startPolling();
- msgInput && msgInput.focus();
- }
-
- function bindChat() {
- $('#cc-logout-btn')?.addEventListener('click', doLogout);
-
- msgInput?.addEventListener('input', updateCharCount);
- msgInput?.addEventListener('keydown', e => {
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); }
- });
- sendBtn?.addEventListener('click', doSend);
- voiceRecordBtn?.addEventListener('click', toggleVoiceRecording);
- voiceSendBtn?.addEventListener('click', sendVoiceClip);
- voiceDiscardBtn?.addEventListener('click', discardVoiceClip);
- voiceQueueBtn?.addEventListener('click', () => {
- state.voice.playbackBlocked = false;
- playNextVoice();
- });
- $('#cc-voice-autoplay')?.addEventListener('change', e => {
- state.voice.autoPlay = !!e.target.checked;
- saveVoiceAutoplay(state.voice.autoPlay);
- if (state.voice.autoPlay) {
- state.voice.playbackBlocked = false;
- playNextVoice();
- }
- updateVoiceQueueUI();
- });
-
- msgList?.addEventListener('scroll', () => {
- const el = msgList;
- state.ui.atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 40;
- const show = !state.ui.atBottom && el.scrollHeight > el.clientHeight + 120;
- scrollBtn?.classList.toggle('visible', show);
- });
-
- scrollBtn?.addEventListener('click', () => scrollToBottom(false));
- }
-
- function updateCharCount() {
- const max = state.config?.chat?.max_message_length || 500;
- const rem = max - (msgInput?.value?.length || 0);
- if (!charCount) return;
- charCount.textContent = rem;
- charCount.className = 'cc-compose-counter' +
- (rem < 20 ? ' danger' : rem < 60 ? ' warn' : '');
- }
-
- async function doLogout() {
- discardVoiceClip();
- clearVoiceQueue();
- stopPolling();
- try {
- const fd = new FormData();
- fd.append('action','logout');
- await apiFetch('auth.php', { method:'POST', body:fd });
- } catch(e){}
- state.auth = { authenticated:false, username:null, color:null };
- state.chat.lastId = 0;
- showAuth('login');
- }
-
- // ─── Voice clips ──────────────────────────────────────────────────────────
- function supportedWebmMime() {
- if (!window.MediaRecorder) return '';
- const options = ['audio/webm;codecs=opus', 'audio/webm'];
- return options.find(type => !MediaRecorder.isTypeSupported || MediaRecorder.isTypeSupported(type)) || '';
- }
-
- async function toggleVoiceRecording() {
- if (state.voice.starting) return;
- if (state.voice.recorder || state.voice.fallback) {
- stopVoiceCapture(false);
- return;
- }
- discardVoiceClip();
- if (!navigator.mediaDevices?.getUserMedia) {
- toast('Voice recording is not supported in this browser', 'error');
- return;
- }
- state.voice.starting = true;
- voiceRecordBtn?.setAttribute('disabled', 'disabled');
- try {
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
- state.voice.stream = stream;
- state.voice.startedAt = Date.now();
- state.voice.chunks = [];
- const mime = supportedWebmMime();
- if (mime) {
- try {
- startMediaRecorder(stream, mime);
- } catch (e) {
- startWavRecorder(stream);
- }
- } else {
- startWavRecorder(stream);
- }
- voiceRecordBtn?.classList.add('recording');
- const label = $('#cc-voice-record-label');
- if (label) label.textContent = 'STOP';
- updateVoiceTimer();
- state.voice.timer = setInterval(updateVoiceTimer, 200);
- } catch (e) {
- cleanupVoiceStream();
- toast(e?.name === 'NotAllowedError' ? 'Microphone permission was denied' : 'Could not access microphone', 'error');
- } finally {
- state.voice.starting = false;
- voiceRecordBtn?.removeAttribute('disabled');
- }
- }
-
- function startMediaRecorder(stream, mime) {
- const recorder = new MediaRecorder(stream, { mimeType: mime });
- recorder.addEventListener('dataavailable', e => {
- if (e.data?.size) state.voice.chunks.push(e.data);
- });
- recorder.addEventListener('stop', () => {
- if (recorder._ccCanceled) {
- state.voice.recorder = null;
- return;
- }
- const duration = Math.min((Date.now() - state.voice.startedAt) / 1000,
- state.config?.voice?.max_duration_seconds || 60);
- const blob = new Blob(state.voice.chunks, { type: recorder.mimeType || mime || 'audio/webm' });
- finishVoiceClip(blob, duration);
- state.voice.recorder = null;
- }, { once: true });
- recorder.start(250);
- state.voice.recorder = recorder;
- }
-
- function startWavRecorder(stream) {
- const AudioCtx = window.AudioContext || window.webkitAudioContext;
- if (!AudioCtx) throw new Error('Audio recording unsupported');
- const context = new AudioCtx();
- const source = context.createMediaStreamSource(stream);
- const processor = context.createScriptProcessor(4096, 1, 1);
- const samples = [];
- processor.onaudioprocess = e => samples.push(new Float32Array(e.inputBuffer.getChannelData(0)));
- source.connect(processor);
- processor.connect(context.destination);
- state.voice.fallback = { context, source, processor, samples, sampleRate: context.sampleRate };
- }
-
- function updateVoiceTimer() {
- const max = state.config?.voice?.max_duration_seconds || 60;
- const elapsed = Math.min((Date.now() - state.voice.startedAt) / 1000, max);
- if (voiceStatus) voiceStatus.textContent = 'RECORDING ' + formatDuration(elapsed) + ' / ' + formatDuration(max);
- if (elapsed >= max) stopVoiceCapture(false);
- }
-
- function stopVoiceCapture(cancel) {
- clearInterval(state.voice.timer);
- state.voice.timer = null;
- const duration = state.voice.startedAt ? (Date.now() - state.voice.startedAt) / 1000 : 0;
- if (state.voice.recorder && state.voice.recorder.state !== 'inactive') {
- if (cancel) {
- state.voice.recorder._ccCanceled = true;
- state.voice.chunks = [];
- }
- state.voice.recorder.stop();
- } else if (state.voice.fallback) {
- const wav = state.voice.fallback;
- wav.processor.disconnect();
- wav.source.disconnect();
- wav.context.close();
- state.voice.fallback = null;
- if (!cancel) finishVoiceClip(encodeWav(wav.samples, wav.sampleRate), duration);
- }
- cleanupVoiceStream();
- voiceRecordBtn?.classList.remove('recording');
- const label = $('#cc-voice-record-label');
- if (label) label.textContent = 'RECORD';
- if (cancel && voiceStatus) {
- voiceStatus.textContent = 'VOICE CLIP · MAX ' + (state.config?.voice?.max_duration_seconds || 60) + 's';
- }
- }
-
- function cleanupVoiceStream() {
- state.voice.stream?.getTracks().forEach(track => track.stop());
- state.voice.stream = null;
- }
-
- function finishVoiceClip(blob, duration) {
- if (!blob?.size || duration < 0.25) {
- discardVoiceClip();
- toast('Voice clip was too short', 'error');
- return;
- }
- state.voice.blob = blob;
- state.voice.duration = duration;
- if (voicePreview) {
- if (voicePreview.dataset.objectUrl) URL.revokeObjectURL(voicePreview.dataset.objectUrl);
- const url = URL.createObjectURL(blob);
- voicePreview.src = url;
- voicePreview.dataset.objectUrl = url;
- }
- if (voiceReview) voiceReview.hidden = false;
- if (voiceStatus) voiceStatus.textContent = 'READY · ' + formatDuration(duration);
- }
-
- function discardVoiceClip() {
- if (state.voice.recorder || state.voice.fallback) stopVoiceCapture(true);
- if (voicePreview?.dataset.objectUrl) URL.revokeObjectURL(voicePreview.dataset.objectUrl);
- if (voicePreview) {
- voicePreview.removeAttribute('src');
- delete voicePreview.dataset.objectUrl;
- voicePreview.load();
- }
- state.voice.blob = null;
- state.voice.duration = 0;
- if (voiceReview) voiceReview.hidden = true;
- if (voiceStatus) voiceStatus.textContent =
- 'VOICE CLIP · MAX ' + (state.config?.voice?.max_duration_seconds || 60) + 's';
- }
-
- async function sendVoiceClip() {
- if (!state.voice.blob || voiceSendBtn?.disabled) return;
- const maxBytes = state.config?.voice?.max_upload_bytes || 12582912;
- if (state.voice.blob.size > maxBytes) {
- toast('Voice clip exceeds the upload limit', 'error');
- return;
- }
- voiceSendBtn.disabled = true;
- const fd = new FormData();
- fd.append('action', 'send_voice');
- fd.append('duration', state.voice.duration.toFixed(2));
- fd.append('voice', state.voice.blob, state.voice.blob.type.includes('wav') ? 'voice.wav' : 'voice.webm');
- try {
- const res = await apiFetch('messages.php', { method: 'POST', body: fd });
- if (res.error) {
- toast(res.error, 'error');
- } else if (res.success && res.message) {
- if (!root.querySelector('[data-id="' + res.message.id + '"]')) appendMsg(res.message, true);
- state.chat.lastId = Math.max(state.chat.lastId, res.message.id);
- discardVoiceClip();
- }
- } catch (e) {
- toast('Voice send failed: ' + (e.message || 'check server'), 'error');
- }
- if (voiceSendBtn) voiceSendBtn.disabled = false;
- }
-
- function encodeWav(chunks, sampleRate) {
- const length = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
- const buffer = new ArrayBuffer(44 + length * 2);
- const view = new DataView(buffer);
- const write = (offset, text) => {
- for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i));
- };
- write(0, 'RIFF'); view.setUint32(4, 36 + length * 2, true); write(8, 'WAVE');
- write(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true);
- view.setUint16(22, 1, true); view.setUint32(24, sampleRate, true);
- view.setUint32(28, sampleRate * 2, true); view.setUint16(32, 2, true);
- view.setUint16(34, 16, true); write(36, 'data'); view.setUint32(40, length * 2, true);
- let offset = 44;
- chunks.forEach(chunk => chunk.forEach(sample => {
- const value = Math.max(-1, Math.min(1, sample));
- view.setInt16(offset, value < 0 ? value * 0x8000 : value * 0x7fff, true);
- offset += 2;
- }));
- return new Blob([view], { type: 'audio/wav' });
- }
-
- function formatDuration(seconds) {
- const total = Math.max(0, Math.ceil(Number(seconds) || 0));
- return Math.floor(total / 60) + ':' + String(total % 60).padStart(2, '0');
- }
-
- // Incoming voice clips use one FIFO player so multiple polls never overlap.
- function enqueueVoiceClip(audio) {
- if (!audio || audio.dataset.voiceQueued === 'true') return;
- audio.dataset.voiceQueued = 'true';
- state.voice.queue.push(audio);
- audio.addEventListener('play', () => {
- if (state.voice.current === audio) return;
- const index = state.voice.queue.indexOf(audio);
- if (index !== -1) state.voice.queue.splice(index, 1);
- delete audio.dataset.voiceQueued;
- updateVoiceQueueUI();
- }, { once: true });
- updateVoiceQueueUI();
- if (state.voice.autoPlay && !state.voice.playbackBlocked) playNextVoice();
- }
-
- async function playNextVoice() {
- if (state.voice.current || !state.voice.queue.length) {
- updateVoiceQueueUI();
- return;
- }
-
- const audio = state.voice.queue.shift();
- if (!audio?.isConnected) {
- delete audio?.dataset?.voiceQueued;
- playNextVoice();
- return;
- }
-
- state.voice.current = audio;
- audio.closest('.cc-msg')?.classList.add('cc-msg-voice-playing');
- let finished = false;
- const finish = () => {
- if (finished) return;
- finished = true;
- audio.closest('.cc-msg')?.classList.remove('cc-msg-voice-playing');
- delete audio.dataset.voiceQueued;
- if (state.voice.current === audio) state.voice.current = null;
- updateVoiceQueueUI();
- if (state.voice.autoPlay && !state.voice.playbackBlocked) playNextVoice();
- };
- audio.addEventListener('ended', finish, { once: true });
- audio.addEventListener('error', finish, { once: true });
- updateVoiceQueueUI();
-
- try {
- await audio.play();
- } catch (e) {
- audio.removeEventListener('ended', finish);
- audio.removeEventListener('error', finish);
- audio.closest('.cc-msg')?.classList.remove('cc-msg-voice-playing');
- state.voice.current = null;
- state.voice.queue.unshift(audio);
- state.voice.playbackBlocked = true;
- updateVoiceQueueUI();
- }
- }
-
- function clearVoiceQueue() {
- if (state.voice.current) {
- state.voice.current.pause();
- state.voice.current.closest('.cc-msg')?.classList.remove('cc-msg-voice-playing');
- delete state.voice.current.dataset.voiceQueued;
- }
- state.voice.queue.forEach(audio => delete audio.dataset.voiceQueued);
- state.voice.queue = [];
- state.voice.current = null;
- state.voice.playbackBlocked = false;
- updateVoiceQueueUI();
- }
-
- function updateVoiceQueueUI() {
- const waiting = state.voice.queue.length;
- const total = waiting + (state.voice.current ? 1 : 0);
- if (voiceQueueCount) voiceQueueCount.textContent = String(total);
- if (!voiceQueueBtn) return;
- voiceQueueBtn.disabled = total === 0;
- voiceQueueBtn.classList.toggle('playing', !!state.voice.current);
- voiceQueueBtn.classList.toggle('blocked', state.voice.playbackBlocked);
- const label = state.voice.current
- ? 'PLAYING'
- : state.voice.playbackBlocked
- ? 'TAP TO PLAY'
- : 'PLAY NEXT';
- voiceQueueBtn.firstChild.textContent = label + ' ';
- }
-
- // ─── Send ─────────────────────────────────────────────────────────────────
- async function doSend() {
- const msg = msgInput?.value?.trim();
- if (!msg || sendBtn?.disabled) return;
- const max = state.config?.chat?.max_message_length || 500;
- if (msg.length > max) { toast('Message too long (max ' + max + ' chars)', 'error'); return; }
-
- msgInput.value = '';
- updateCharCount();
- sendBtn.disabled = true;
-
- const fd = new FormData();
- fd.append('action','send');
- fd.append('message', msg);
-
- try {
- const res = await apiFetch('messages.php', { method:'POST', body:fd });
- if (res.error) {
- toast(res.error, 'error');
- msgInput.value = msg;
- updateCharCount();
- } else if (res.success && res.message) {
- if (!root.querySelector('[data-id="' + res.message.id + '"]')) {
- appendMsg(res.message, true);
- state.chat.lastId = Math.max(state.chat.lastId, res.message.id);
- }
- }
- } catch(e) {
- toast('Send failed: ' + (e.message || 'check server'), 'error');
- msgInput.value = msg;
- updateCharCount();
- }
-
- sendBtn.disabled = false;
- msgInput?.focus();
- }
-
- // ─── Polling ─────────────────────────────────────────────────────────────
- function startPolling() {
- stopPolling();
- doPoll();
- const iv = state.config?.chat?.poll_interval_ms || 2000;
- state.chat.pollTimer = setInterval(doPoll, iv);
- }
-
- function stopPolling() {
- if (state.chat.pollTimer) {
- clearInterval(state.chat.pollTimer);
- state.chat.pollTimer = null;
- }
- state.chat.polling = false;
- }
-
- async function doPoll() {
- if (state.chat.polling) return;
- state.chat.polling = true;
- try {
- const since = state.chat.lastId;
- const data = await apiFetch('messages.php?action=poll&since=' + since);
-
- if (data.error === 'Not authenticated' || data.error === 'Session expired') {
- stopPolling();
- state.auth.authenticated = false;
- toast('Session expired — please login again', 'error');
- showAuth('login');
- state.chat.polling = false;
- return;
- }
-
- setConn('online');
-
- if (Array.isArray(data.messages)) {
- if (since === 0) {
- renderMsgList(data.messages);
- } else if (data.messages.length > 0) {
- data.messages.forEach(m => appendMsg(m, true));
- state.chat.lastId = data.messages[data.messages.length - 1].id;
- }
- }
-
- if (data.online !== undefined && onlineBadge) {
- onlineBadge.textContent = data.online;
- }
-
- } catch(e) {
- setConn('offline');
- }
- state.chat.polling = false;
- }
-
- // ─── Message rendering ────────────────────────────────────────────────────
- function renderMsgList(msgs) {
- if (!msgList) return;
- if (!msgs.length) {
- msgList.innerHTML = '// NO MESSAGES TODAY — BE THE FIRST TO TRANSMIT
';
- return;
- }
- msgList.innerHTML = '';
- msgs.forEach(m => appendMsg(m, false));
- scrollToBottom(true);
- if (msgs.length) state.chat.lastId = msgs[msgs.length - 1].id;
- }
-
- function appendMsg(msg, animate) {
- if (!msgList) return;
-
- // Remove loading placeholder if present
- const loader = msgList.querySelector('.cc-loading-msg, .cc-empty-day');
- if (loader) loader.remove();
-
- const isOwn = msg.username === state.auth.username;
- const div = document.createElement('div');
- div.className = 'cc-msg' + (isOwn ? ' cc-msg-own' : '') + (animate ? ' cc-msg-new' : '');
- div.dataset.id = msg.id;
-
- const isVoice = msg.message_type === 'voice' && msg.voice_url;
- const safeText = isVoice
- ? 'VOICE ' +
- formatDuration(msg.voice_duration) + ''
- : linkify(esc(msg.message));
-
- div.innerHTML =
- '' + fmtTime(msg.created_at) + '' +
- '' + esc(msg.username) + '' +
- '›' +
- '' + safeText + '';
-
- msgList.appendChild(div);
- if (isVoice && animate && !isOwn) enqueueVoiceClip(div.querySelector('audio'));
- if (state.ui.atBottom) scrollToBottom(false);
- }
-
- function scrollToBottom(instant) {
- if (!msgList) return;
- if (instant) { msgList.scrollTop = msgList.scrollHeight; }
- else { msgList.scrollTo({ top: msgList.scrollHeight, behavior: 'smooth' }); }
- state.ui.atBottom = true;
- scrollBtn?.classList.remove('visible');
- }
-
- // ─── Global events ────────────────────────────────────────────────────────
- function bindGlobal() {
- // Theme toggle
- $('#cc-theme-btn')?.addEventListener('click', () => {
- state.ui.darkMode = !state.ui.darkMode;
- document.body.classList.toggle('cc-light', !state.ui.darkMode);
- const icon = $('#cc-theme-icon');
- if (icon) icon.textContent = state.ui.darkMode ? '☀' : '☾';
- saveTheme(state.ui.darkMode);
- });
- const themeIcon = $('#cc-theme-icon');
- if (themeIcon) themeIcon.textContent = state.ui.darkMode ? '☀' : '☾';
- }
-
- // ─── Conn status ─────────────────────────────────────────────────────────
- function setConn(status) {
- if (!connStatus) return;
- connStatus.className = 'cc-conn-dot ' + status;
- }
-
- // ─── Public ───────────────────────────────────────────────────────────────
- window.CyberChat = { init };
-
-})(window, document);
diff --git a/bootstrap.php b/bootstrap.php
index 7480b56..7a49c70 100644
--- a/bootstrap.php
+++ b/bootstrap.php
@@ -56,6 +56,13 @@ function getConfig(bool $reload = false): array {
if (!file_exists(CONFIG_FILE)) throw new RuntimeException('config.json not found');
$config = json_decode((string)file_get_contents(CONFIG_FILE), true);
if (!is_array($config)) throw new RuntimeException('config.json is invalid: ' . json_last_error_msg());
+ if (array_key_exists('groups', $config)) {
+ unset($config['groups']);
+ $json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ if ($json !== false && is_writable(CONFIG_FILE)) {
+ file_put_contents(CONFIG_FILE, $json . PHP_EOL, LOCK_EX);
+ }
+ }
}
return $config;
}
@@ -165,6 +172,51 @@ function ensureColumns(PDO $db, string $table, array $additions): void {
}
}
+function removeLegacyGroupSchema(PDO $db, bool $mainDatabase): void {
+ $messageColumns = tableColumns($db, 'messages');
+ if (isset($messageColumns['group_id'])) {
+ $voiceFiles = $db->query("SELECT DISTINCT voice_file FROM messages
+ WHERE group_id IS NOT NULL AND voice_file IS NOT NULL AND voice_file != ''")->fetchAll(PDO::FETCH_COLUMN);
+ $db->exec('PRAGMA foreign_keys=OFF');
+ try {
+ $db->beginTransaction();
+ $db->exec("DROP TABLE IF EXISTS messages_without_groups");
+ $db->exec("CREATE TABLE messages_without_groups (
+ id INTEGER PRIMARY KEY" . ($mainDatabase ? ' AUTOINCREMENT' : '') . ",
+ user_id INTEGER NOT NULL,
+ username TEXT NOT NULL,
+ color TEXT NOT NULL,
+ message TEXT NOT NULL,
+ message_type TEXT NOT NULL DEFAULT 'text',
+ voice_file TEXT,
+ voice_mime TEXT,
+ voice_duration REAL,
+ created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
+ day_key TEXT NOT NULL" .
+ ($mainDatabase ? ", FOREIGN KEY(user_id) REFERENCES users(id)" : '') . "
+ )");
+ $db->exec("INSERT INTO messages_without_groups
+ (id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
+ SELECT id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key
+ FROM messages WHERE group_id IS NULL");
+ $db->exec("DROP TABLE messages");
+ $db->exec("ALTER TABLE messages_without_groups RENAME TO messages");
+ $db->exec("DROP TABLE IF EXISTS group_members");
+ $db->exec("DROP TABLE IF EXISTS chat_groups");
+ $db->commit();
+ foreach ($voiceFiles as $voiceFile) deleteVoiceFile((string)$voiceFile);
+ } catch (Throwable $e) {
+ if ($db->inTransaction()) $db->rollBack();
+ throw $e;
+ } finally {
+ $db->exec('PRAGMA foreign_keys=ON');
+ }
+ } else {
+ $db->exec("DROP TABLE IF EXISTS group_members");
+ $db->exec("DROP TABLE IF EXISTS chat_groups");
+ }
+}
+
function initDB(PDO $db): void {
$db->exec("CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -226,6 +278,7 @@ function initDB(PDO $db): void {
'voice_mime' => 'TEXT',
'voice_duration' => 'REAL',
]);
+ removeLegacyGroupSchema($db, true);
$db->exec("CREATE TABLE IF NOT EXISTS plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -368,11 +421,9 @@ function seedPlans(PDO $db): void {
$insertFeature->execute([$planId, $key, json_encode($value)]);
}
}
- $db->exec("DELETE FROM plan_features WHERE feature_key IN ('groups_available','group_member_limit')");
- $db->exec("UPDATE plans SET description='Premium voice, identity, security, and 90-day personal archives.'
- WHERE slug='plus' AND description='Premium voice, identity, security, and groups for four.'");
- $db->exec("UPDATE plans SET description='Premium voice, security, and one year of personal archives.'
- WHERE slug='pro' AND description='Expanded groups and one year of personal archives.'");
+ $db->exec("DELETE FROM plan_features WHERE feature_key LIKE 'group%'");
+ $db->exec("UPDATE plans SET description='Configurable premium chat features.'
+ WHERE lower(description) LIKE '%group%'");
$freeId = (int)$db->query("SELECT id FROM plans WHERE slug = 'free'")->fetchColumn();
$db->prepare("UPDATE users SET plan_id = ? WHERE plan_id IS NULL")->execute([$freeId]);
}
@@ -388,6 +439,7 @@ function plans(bool $activeOnly = false): array {
foreach ($featureStmt->fetchAll() as $feature) {
$row['features'][$feature['feature_key']] = json_decode($feature['value_json'], true);
}
+ $row['features'] = visiblePlanFeatures($row['features']);
$row['id'] = (int)$row['id'];
$row['price_cents'] = (int)$row['price_cents'];
$row['active'] = (bool)$row['active'];
@@ -395,6 +447,14 @@ function plans(bool $activeOnly = false): array {
return $rows;
}
+function visiblePlanFeatures(array $features): array {
+ return array_filter(
+ $features,
+ fn(string $key): bool => !str_starts_with(strtolower($key), 'group'),
+ ARRAY_FILTER_USE_KEY
+ );
+}
+
function planById(int $planId): ?array {
foreach (plans(false) as $plan) if ($plan['id'] === $planId) return $plan;
return null;
@@ -425,7 +485,7 @@ function userPublicPayload(array $user): array {
'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'],
+ 'features' => visiblePlanFeatures($plan['features']),
'subscription' => [
'status' => $user['subscription_status'] ?? 'free',
'period_end' => isset($user['subscription_period_end']) ? (int)$user['subscription_period_end'] : null,
@@ -506,11 +566,6 @@ function dayKey(): string {
return date('Y-m-d');
}
-function publicMessagesOnly(PDO $db, string $alias = ''): string {
- $prefix = $alias !== '' ? rtrim($alias, '.') . '.' : '';
- return isset(tableColumns($db, 'messages')['group_id']) ? " AND {$prefix}group_id IS NULL" : '';
-}
-
function voiceUploadDir(): string {
return storagePath((string)getConfigVal('voice.upload_dir', 'uploads/voice'));
}
@@ -544,6 +599,7 @@ function getArchiveDB(string $year, string $month, string $day): PDO {
'message_type' => "TEXT NOT NULL DEFAULT 'text'",
'voice_file' => 'TEXT', 'voice_mime' => 'TEXT', 'voice_duration' => 'REAL',
]);
+ removeLegacyGroupSchema($db, false);
$db->exec("CREATE TABLE IF NOT EXISTS archive_meta (key TEXT PRIMARY KEY,value TEXT)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)");
return $db;
@@ -567,8 +623,7 @@ function archiveYesterdayIfNeeded(): void {
$flag = ROOT_DIR . '/db/.archived_' . $yesterday;
if (file_exists($flag)) return;
$db = getDB();
- $publicOnly = publicMessagesOnly($db);
- $stmt = $db->prepare("SELECT * FROM messages WHERE day_key=?$publicOnly ORDER BY id");
+ $stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? ORDER BY id");
$stmt->execute([$yesterday]);
$rows = $stmt->fetchAll();
if ($rows) {
@@ -590,7 +645,7 @@ function archiveYesterdayIfNeeded(): void {
$meta->execute(['message_count', (string)count($rows)]);
$archive->commit();
}
- $db->prepare("DELETE FROM messages WHERE day_key=?$publicOnly")->execute([$yesterday]);
+ $db->prepare("DELETE FROM messages WHERE day_key=?")->execute([$yesterday]);
file_put_contents($flag, date('c'), LOCK_EX);
}
@@ -600,7 +655,7 @@ function personalArchiveRows(array $user, string $search = '', int $limit = 500)
$includeVoice = (bool)featureValue($user, 'voice_archive', false);
$rows = [];
$db = getDB();
- $sql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?" . publicMessagesOnly($db);
+ $sql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?";
$params = [$user['id'], $cutoff];
if (!$includeVoice) $sql .= " AND message_type='text'";
if ($search !== '') { $sql .= " AND message LIKE ?"; $params[] = '%' . $search . '%'; }
@@ -611,7 +666,7 @@ function personalArchiveRows(array $user, string $search = '', int $limit = 500)
foreach (archiveFilesSince($cutoff) as [, $year, $month, $day]) {
$archive = getArchiveDB($year, $month, $day);
- $archiveSql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?" . publicMessagesOnly($archive);
+ $archiveSql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?";
$archiveParams = [$user['id'], $cutoff];
if (!$includeVoice) $archiveSql .= " AND message_type='text'";
if ($search !== '') { $archiveSql .= " AND message LIKE ?"; $archiveParams[] = '%' . $search . '%'; }
diff --git a/embed-example.html b/embed-example.html
index 66f8108..c7389fe 100644
--- a/embed-example.html
+++ b/embed-example.html
@@ -6,7 +6,7 @@
CyberChat — Embed Example
-
+