Compare commits

...

10 Commits

Author SHA1 Message Date
Flatlogic Bot
09fa2a7096 final v1 fonctionnel 2026-02-17 15:50:11 +00:00
Flatlogic Bot
c11359b2d2 Autosave: 20260217-152131 2026-02-17 15:21:31 +00:00
Flatlogic Bot
24671bdbc7 VOX a moitié fonctionnel 2026-02-17 15:07:03 +00:00
Flatlogic Bot
04cad1c49b PTT semi focntionnel 2026-02-17 14:47:12 +00:00
Flatlogic Bot
08664dda0d Autosave: 20260217-125051 2026-02-17 12:50:51 +00:00
Flatlogic Bot
d8c5bbb218 Autosave: 20260217-122132 2026-02-17 12:21:32 +00:00
Flatlogic Bot
920e26ada3 + a coté du nom de serveur 2026-02-17 10:15:53 +00:00
Flatlogic Bot
95cfa227e9 Autosave: 20260217-082815 2026-02-17 08:28:15 +00:00
Flatlogic Bot
75e3425c41 Flux RSS v1.0 2026-02-17 08:21:23 +00:00
Flatlogic Bot
35c2bad3b7 final v0.8 2026-02-17 01:03:52 +00:00
31 changed files with 1729 additions and 454 deletions

View File

@ -50,7 +50,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
// Fetch all DM channels for current user
try {
$stmt = db()->prepare("
SELECT c.id, u.username as other_user, u.avatar_url, u.status, u.id as other_user_id
SELECT c.id, u.display_name as other_user, u.username as login_name, u.avatar_url, u.status, u.id as other_user_id
FROM channels c
JOIN channel_members cm1 ON c.id = cm1.channel_id
JOIN channel_members cm2 ON c.id = cm2.channel_id

View File

@ -44,7 +44,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$server_id = $stmt->fetchColumn();
$stmt = db()->prepare("
SELECT m.*, u.username, u.avatar_url,
SELECT m.*, u.display_name as username, u.username as login_name, u.avatar_url,
(SELECT r.color FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_color,
(SELECT r.icon_url FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_icon
FROM messages m
@ -76,7 +76,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$server_id = $stmt->fetchColumn();
$stmt = db()->prepare("
SELECT m.*, u.username, u.avatar_url,
SELECT m.*, u.display_name as username, u.username as login_name, u.avatar_url,
(SELECT r.color FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_color,
(SELECT r.icon_url FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_icon
FROM messages m
@ -241,7 +241,7 @@ try {
// Fetch message with username and role color for the response
$stmt = db()->prepare("
SELECT m.*, u.username, u.avatar_url,
SELECT m.*, u.display_name as username, u.username as login_name, u.avatar_url,
(SELECT r.color FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_color,
(SELECT r.icon_url FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_icon
FROM messages m
@ -256,7 +256,7 @@ try {
'message' => [
'id' => $msg['id'],
'user_id' => $msg['user_id'],
'username' => $msg['username'],
'username' => $msg['username'], 'login_name' => $msg['login_name'],
'avatar_url' => $msg['avatar_url'],
'role_color' => $msg['role_color'],
'role_icon' => $msg['role_icon'],

View File

@ -28,7 +28,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
// Fetch members and their roles
$stmt = db()->prepare("
SELECT u.id, u.username, u.avatar_url,
SELECT u.id, u.display_name as username, u.username as login_name, u.avatar_url,
GROUP_CONCAT(r.id) as role_ids,
GROUP_CONCAT(r.name) as role_names,
(SELECT r2.color FROM roles r2 JOIN user_roles ur2 ON r2.id = ur2.role_id WHERE ur2.user_id = u.id AND r2.server_id = ? ORDER BY r2.position DESC LIMIT 1) as role_color,

View File

@ -138,7 +138,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmt_bot->execute();
$bot = $stmt_bot->fetch();
if (!$bot) {
$stmt_create_bot = db()->prepare("INSERT INTO users (username, is_bot, status, avatar_url, email, password_hash) VALUES ('RSS Bot', 1, 'online', 'https://cdn-icons-png.flaticon.com/512/3607/3607436.png', 'rss-bot@system.internal', 'bot-no-password')");
$stmt_create_bot = db()->prepare("INSERT INTO users (username, display_name, is_bot, status, avatar_url, email, password_hash) VALUES ('RSS Bot', 'RSS Bot', 1, 'online', 'https://cdn-icons-png.flaticon.com/512/3607/3607436.png', 'rss-bot@system.internal', 'bot-no-password')");
$stmt_create_bot->execute();
$bot_id = db()->lastInsertId();
} else {
@ -152,9 +152,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($category) $parts[] = $category;
if ($pubDate) $parts[] = $pubDate;
if ($author) $parts[] = $author;
$content .= implode(" · ", $parts) . "\n";
$content .= implode(" · ", $parts);
}
$content .= $description;
$metadata = json_encode([
'title' => $title,

View File

@ -16,15 +16,15 @@ if (empty($query)) {
try {
if ($type === 'users') {
$stmt = db()->prepare("
SELECT id, username, avatar_url, status
SELECT id, display_name as username, username as login_name, avatar_url, status
FROM users
WHERE username LIKE ?
WHERE username LIKE ? OR display_name LIKE ?
LIMIT 20
");
$stmt->execute(["%" . $query . "%"]);
$stmt->execute(["%" . $query . "%", "%" . $query . "%"]);
$results = $stmt->fetchAll();
} else {
$sql = "SELECT m.*, u.username, u.avatar_url
$sql = "SELECT m.*, u.display_name as username, u.username as login_name, u.avatar_url
FROM messages m
JOIN users u ON m.user_id = u.id
WHERE m.content LIKE ? ";

View File

@ -48,7 +48,7 @@ try {
// Top active users
$stmt = db()->prepare("
SELECT u.username, COUNT(*) as message_count
SELECT u.display_name as username, u.username as login_name, COUNT(*) as message_count
FROM messages m
JOIN channels c ON m.channel_id = c.id
JOIN users u ON m.user_id = u.id

View File

@ -21,15 +21,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$log['user_id'] = $user['id'];
$username = !empty($_POST['username']) ? $_POST['username'] : $user['username'];
$display_name = !empty($_POST['display_name']) ? $_POST['display_name'] : $user['display_name'];
$avatar_url = isset($_POST['avatar_url']) ? $_POST['avatar_url'] : $user['avatar_url'];
$dnd_mode = isset($_POST['dnd_mode']) ? (int)$_POST['dnd_mode'] : 0;
$sound_notifications = isset($_POST['sound_notifications']) ? (int)$_POST['sound_notifications'] : 0;
$theme = !empty($_POST['theme']) ? $_POST['theme'] : $user['theme'];
$voice_mode = !empty($_POST['voice_mode']) ? $_POST['voice_mode'] : ($user['voice_mode'] ?? 'vox');
$voice_ptt_key = !empty($_POST['voice_ptt_key']) ? $_POST['voice_ptt_key'] : ($user['voice_ptt_key'] ?? 'v');
$voice_vox_threshold = isset($_POST['voice_vox_threshold']) ? (float)$_POST['voice_vox_threshold'] : ($user['voice_vox_threshold'] ?? 0.1);
try {
$stmt = db()->prepare("UPDATE users SET username = ?, avatar_url = ?, dnd_mode = ?, sound_notifications = ?, theme = ? WHERE id = ?");
$success = $stmt->execute([$username, $avatar_url, $dnd_mode, $sound_notifications, $theme, $user['id']]);
$stmt = db()->prepare("UPDATE users SET display_name = ?, avatar_url = ?, dnd_mode = ?, sound_notifications = ?, theme = ?, voice_mode = ?, voice_ptt_key = ?, voice_vox_threshold = ? WHERE id = ?");
$success = $stmt->execute([$display_name, $avatar_url, $dnd_mode, $sound_notifications, $theme, $voice_mode, $voice_ptt_key, $voice_vox_threshold, $user['id']]);
$log['db_success'] = $success;
file_put_contents('requests.log', json_encode($log) . "\n", FILE_APPEND);

239
api_v1_voice.php Normal file
View File

@ -0,0 +1,239 @@
<?php // Vocal secours — WebRTC P2P + signalisation PHP + participants + PTT + VOX (gating via GainNode)
// Mutualisé OVH : nécessite ./data écrivable.
declare(strict_types=1);
require_once "auth/session.php";
header("X-Content-Type-Options: nosniff");
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
$DATA_DIR = __DIR__ . "/data";
if (!is_dir($DATA_DIR)) @mkdir($DATA_DIR, 0775, true);
$user = getCurrentUser();
$current_user_id = $user ? (int)$user["id"] : 0;
function room_id(string $s): string {
$s = preg_replace("~[^a-zA-Z0-9_\-]~", "", $s);
return $s !== "" ? $s : "secours";
}
function peer_id(): string {
return bin2hex(random_bytes(8));
}
function room_log_file(string $room): string {
return __DIR__ . "/data/" . $room . ".log";
}
function room_participants_file(string $room): string {
return __DIR__ . "/data/" . $room . ".participants.json";
}
function chat_log_file_for_today(): string {
// Un fichier par jour : YYYY-MM-DD.chat.log
$d = date("Y-m-d");
return __DIR__ . "/data/" . $d . ".chat.log";
}
function now_ms(): int {
return (int) floor(microtime(true) * 1000);
}
function json_out($data, int $code = 200): void {
http_response_code($code);
header("Content-Type: application/json; charset=utf-8");
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function read_json_file(string $path): array {
if (!file_exists($path)) return [];
$raw = @file_get_contents($path);
if ($raw === false || $raw === "") return [];
$j = json_decode($raw, true);
return is_array($j) ? $j : [];
}
function write_json_file(string $path, array $data): void {
file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
}
function tail_lines(string $path, int $maxLines = 120): array {
if (!is_file($path)) return [];
$fp = fopen($path, "rb");
if (!$fp) return [];
$lines = [];
fseek($fp, 0, SEEK_END);
$pos = ftell($fp);
$buffer = "";
while ($pos > 0 && count($lines) < $maxLines) {
$readSize = min($pos, 4096);
$pos -= $readSize;
fseek($fp, $pos);
$chunk = fread($fp, $readSize);
$buffer = $chunk . $buffer;
$chunkLines = explode("\n", $buffer);
$buffer = array_shift($chunkLines);
while (!empty($chunkLines)) {
$line = array_pop($chunkLines);
if (trim($line) !== "") {
array_unshift($lines, trim($line));
if (count($lines) >= $maxLines) break;
}
}
}
fclose($fp);
return $lines;
}
// Logic for signaling
$action = $_REQUEST["action"] ?? "";
$room = room_id($_REQUEST["room"] ?? "secours");
$my_id = $_REQUEST["peer_id"] ?? "";
if ($action === "join") {
$name = $_REQUEST["name"] ?? "User";
$p_file = room_participants_file($room);
$ps = read_json_file($p_file);
// Cleanup old participants (> 10s)
$stale_time = now_ms() - 10000;
foreach ($ps as $id => $p) {
if (($p["last_seen"] ?? 0) < $stale_time) unset($ps[$id]);
}
$new_id = peer_id();
$ps[$new_id] = [
"id" => $new_id,
"user_id" => $current_user_id,
"name" => $name,
"avatar_url" => $user["avatar_url"] ?? "",
"last_seen" => now_ms()
];
write_json_file($p_file, $ps);
// DB Integration for sidebar
if ($current_user_id > 0) {
try {
$stmt = db()->prepare("INSERT INTO voice_sessions (user_id, channel_id, last_seen) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE channel_id = ?, last_seen = ?");
$stmt->execute([$current_user_id, $room, now_ms(), $room, now_ms()]);
} catch (Exception $e) {}
}
json_out(["success" => true, "peer_id" => $new_id, "participants" => $ps]);
}
if ($action === "poll") {
if (!$my_id) json_out(["error" => "Missing peer_id"], 400);
$p_file = room_participants_file($room);
$ps = read_json_file($p_file);
if (isset($ps[$my_id])) {
$ps[$my_id]["last_seen"] = now_ms();
}
$stale_time = now_ms() - 10000;
foreach ($ps as $id => $p) {
if (($p["last_seen"] ?? 0) < $stale_time) unset($ps[$id]);
}
write_json_file($p_file, $ps);
// Update DB last_seen
if ($current_user_id > 0) {
try {
$stmt = db()->prepare("UPDATE voice_sessions SET last_seen = ? WHERE user_id = ?");
$stmt->execute([now_ms(), $current_user_id]);
} catch (Exception $e) {}
}
// Read signals
$log_file = room_log_file($room);
$signals = [];
if (file_exists($log_file)) {
$lines = file($log_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$remaining = [];
$now = now_ms();
foreach ($lines as $line) {
$sig = json_decode($line, true);
if ($sig && isset($sig["to"]) && $sig["to"] === $my_id) {
$signals[] = $sig;
} elseif ($sig && ($now - ($sig["time"] ?? 0) < 30000)) {
$remaining[] = $line;
}
}
file_put_contents($log_file, implode("\n", $remaining) . (empty($remaining) ? "" : "\n"), LOCK_EX);
}
json_out(["success" => true, "participants" => $ps, "signals" => $signals]);
}
if ($action === "signal") {
if (!$my_id) json_out(["error" => "Missing peer_id"], 400);
$to = $_REQUEST["to"] ?? "";
$data = $_REQUEST["data"] ?? "";
if (!$to || !$data) json_out(["error" => "Missing to/data"], 400);
$sig = [
"from" => $my_id,
"to" => $to,
"data" => json_decode($data, true),
"time" => now_ms()
];
file_put_contents(room_log_file($room), json_encode($sig) . "\n", FILE_APPEND | LOCK_EX);
json_out(["success" => true]);
}
if ($action === "list_all") {
// Periodic cleanup of the DB table (stale sessions > 15s)
if (rand(1, 10) === 1) {
try {
$stale_db_time = now_ms() - 15000;
$stmt = db()->prepare("DELETE FROM voice_sessions WHERE last_seen < ?");
$stmt->execute([$stale_db_time]);
} catch (Exception $e) {}
}
try {
$stmt = db()->prepare("
SELECT vs.channel_id, vs.user_id, u.username, u.display_name, u.avatar_url
FROM voice_sessions vs
JOIN users u ON vs.user_id = u.id
WHERE vs.last_seen > ?
");
$stale_db_time = now_ms() - 15000;
$stmt->execute([$stale_db_time]);
$sessions = $stmt->fetchAll(PDO::FETCH_ASSOC);
$by_channel = [];
foreach ($sessions as $s) {
$by_channel[$s['channel_id']][] = $s;
}
json_out(["success" => true, "channels" => $by_channel]);
} catch (Exception $e) {
json_out(["error" => $e->getMessage()], 500);
}
}
if ($action === "leave") {
if ($my_id) {
$p_file = room_participants_file($room);
$ps = read_json_file($p_file);
unset($ps[$my_id]);
write_json_file($p_file, $ps);
}
if ($current_user_id > 0) {
try {
$stmt = db()->prepare("DELETE FROM voice_sessions WHERE user_id = ?");
$stmt->execute([$current_user_id]);
} catch (Exception $e) {}
}
json_out(["success" => true]);
}
json_out(["error" => "Unknown action"], 404);

View File

@ -471,6 +471,12 @@ body {
box-shadow: 0 0 0 0.25rem rgba(88, 101, 242, 0.25);
}
.form-control:disabled, .form-control[readonly] {
background-color: var(--bg-servers) !important;
color: var(--text-primary) !important;
opacity: 0.6;
}
/* User Panel */
.user-panel {
height: 52px;
@ -623,6 +629,10 @@ body {
width: 100%;
outline: none;
font-size: 1em;
resize: none;
height: 24px;
line-height: 24px;
overflow-y: hidden;
}
/* Members Sidebar */
@ -952,6 +962,29 @@ body {
background-color: #232428 !important;
}
.voice-status-icon {
animation: voice-pulse 2s infinite;
}
@keyframes voice-pulse {
0% { opacity: 0.4; }
50% { opacity: 1; }
100% { opacity: 0.4; }
}
[data-theme="light"] .voice-controls {
background-color: #ebedef !important;
border-top: 1px solid rgba(0,0,0,0.05);
}
.form-range::-webkit-slider-thumb {
background: var(--blurple);
}
.form-range::-moz-range-thumb {
background: var(--blurple);
}
/* Roles Management */
#roles-list .list-group-item:hover {
background-color: var(--separator-soft) !important;
@ -1248,6 +1281,10 @@ body {
color: #ffffff;
}
[data-theme="dark"] .text-muted {
color: var(--text-muted) !important;
}
.permission-item {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
@ -1257,3 +1294,122 @@ body {
}
/* Markdown Styles */
.message-text code {
background-color: #1e1f22;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 0.85em;
color: #dbdee1;
}
.message-text pre.code-block {
background-color: #1e1f22;
padding: 1rem;
border-radius: 4px;
margin: 0.5rem 0;
font-family: 'Consolas', 'Monaco', 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 0.85em;
overflow-x: auto;
}
.message-text blockquote {
border-left: 4px solid #4e5058;
padding-left: 1rem;
margin: 0.5rem 0;
color: var(--text-muted);
}
.message-text .spoiler {
background-color: #1e1f22;
color: transparent !important;
border-radius: 3px;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
}
.message-text .spoiler:hover {
background-color: #2b2d31;
}
.message-text .spoiler.revealed {
background-color: rgba(255, 255, 255, 0.1);
color: inherit !important;
cursor: default;
}
.message-text h1 { font-size: 1.5em; font-weight: 700; margin: 4px 0 2px 0; color: #fff; }
.message-text h2 { font-size: 1.25em; font-weight: 600; margin: 3px 0 1px 0; color: #fff; }
.message-text h3 { font-size: 1.1em; font-weight: 600; margin: 2px 0 0 0; color: #fff; }
[data-theme="light"] .message-text code {
background-color: #ebedef;
color: #313338;
}
[data-theme="light"] .message-text pre.code-block {
background-color: #ebedef;
color: #313338;
}
[data-theme="light"] .message-text .spoiler {
background-color: #dbdee1;
}
[data-theme="light"] .message-text .spoiler.revealed {
background-color: #f2f3f5;
}
[data-theme="light"] .message-text h1,
[data-theme="light"] .message-text h2,
[data-theme="light"] .message-text h3 {
color: #313338;
}
/* Voice System */
.voice-users-list {
margin-top: 2px;
}
.voice-user {
padding: 2px 0;
cursor: default;
}
.voice-user .message-avatar {
background-size: cover;
background-position: center;
border-radius: 50%;
background-color: var(--bg-servers);
}
.voice-controls {
margin-top: auto;
background-color: #232428;
z-index: 10;
}
.voice-status-icon {
animation: voice-pulse 2s infinite;
}
@keyframes voice-pulse {
0% { opacity: 0.4; }
50% { opacity: 1; }
100% { opacity: 0.4; }
}
[data-theme="light"] .voice-controls {
background-color: #ebedef;
border-top: 1px solid rgba(0,0,0,0.05);
}
.form-range::-webkit-slider-thumb {
background: var(--blurple);
}
.form-range::-moz-range-thumb {
background: var(--blurple);
}

View File

@ -402,6 +402,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (chatInput) {
chatInput.value += emoji;
chatInput.focus();
chatInput.dispatchEvent(new Event('input'));
}
}, { keepOpen: true, width: "900px", height: "500px" });
return;
@ -456,13 +457,34 @@ document.addEventListener('DOMContentLoaded', () => {
let ws;
let voiceHandler;
if (typeof VoiceChannel !== 'undefined') {
voiceHandler = new VoiceChannel(null, window.voiceSettings);
window.voiceHandler = voiceHandler;
console.log('VoiceHandler initialized');
// Start global voice sessions polling
setInterval(() => {
VoiceChannel.refreshAllVoiceUsers();
}, 3000);
VoiceChannel.refreshAllVoiceUsers();
}
function connectWS() {
console.log('Connecting to WebSocket...');
try {
ws = new WebSocket('ws://' + window.location.hostname + ':8080');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// Use reverse proxy path /ws
ws = new WebSocket(protocol + '//' + window.location.hostname + '/ws');
if (typeof VoiceChannel !== 'undefined') {
voiceHandler = new VoiceChannel(ws);
}
ws.onopen = () => {
console.log('WebSocket connected');
if (voiceHandler) voiceHandler.ws = ws;
ws.send(JSON.stringify({
type: 'presence',
user_id: window.currentUserId,
status: 'online'
}));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
@ -495,24 +517,24 @@ document.addEventListener('DOMContentLoaded', () => {
} else if (msg.type === 'reaction') {
updateReactionUI(msg.message_id, msg.reactions);
} else if (msg.type === 'message_edit') {
const el = document.querySelector(`.message-item[data-id="${msg.message_id}"] .message-text`);
if (el) el.innerHTML = msg.content.replace(/\n/g, '<br>');
const item = document.querySelector(`.message-item[data-id="${msg.message_id}"]`);
if (item) {
item.dataset.rawContent = msg.content;
const el = item.querySelector('.message-text');
if (el) el.innerHTML = parseCustomEmotes(msg.content);
}
} else if (msg.type === 'message_delete') {
document.querySelector(`.message-item[data-id="${msg.message_id}"]`)?.remove();
} else if (msg.type === 'presence') {
updatePresenceUI(msg.user_id, msg.status);
}
};
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'presence',
user_id: window.currentUserId,
status: 'online'
}));
ws.onclose = () => {
console.log('WebSocket connection closed. Reconnecting...');
setTimeout(connectWS, 3000);
};
ws.onclose = () => setTimeout(connectWS, 3000);
} catch (e) {
console.warn('WebSocket connection failed.');
console.warn('WebSocket connection failed:', e);
}
}
connectWS();
@ -549,7 +571,22 @@ document.addEventListener('DOMContentLoaded', () => {
}, 3000);
}
chatInput?.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
chatForm?.dispatchEvent(new Event('submit', { cancelable: true }));
}
});
chatInput?.addEventListener('input', () => {
chatInput.style.height = 'auto';
chatInput.style.height = Math.min(chatInput.scrollHeight, 200) + 'px';
if (chatInput.scrollHeight > 200) {
chatInput.style.overflowY = 'auto';
} else {
chatInput.style.overflowY = 'hidden';
}
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'typing',
@ -567,6 +604,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (!content && !file) return;
chatInput.value = '';
chatInput.style.height = '24px';
chatInput.style.overflowY = 'hidden';
const formData = new FormData();
formData.append('content', content);
formData.append('channel_id', currentChannel);
@ -631,8 +670,32 @@ document.addEventListener('DOMContentLoaded', () => {
xhr.send(formData);
});
// Handle Reaction Clicks
// Handle Click Events
document.addEventListener('click', (e) => {
console.log('Global click at:', e.target);
// Voice Channel Click
const voiceItem = e.target.closest('.voice-item');
if (voiceItem) {
e.preventDefault();
console.log('Voice item clicked, Channel ID:', voiceItem.dataset.channelId);
const channelId = voiceItem.dataset.channelId;
if (voiceHandler) {
if (voiceHandler.currentChannelId == channelId) {
console.log('Already in this channel:', channelId);
return;
} else {
console.log('Joining voice channel:', channelId);
voiceHandler.join(channelId);
// Update active state in UI
document.querySelectorAll('.voice-item').forEach(i => i.classList.remove('active'));
voiceItem.classList.add('active');
}
} else {
console.error('voiceHandler not initialized');
}
return;
}
const badge = e.target.closest('.reaction-badge');
if (badge) {
const msgId = badge.parentElement.dataset.messageId;
@ -708,21 +771,9 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
// Voice
if (voiceHandler) {
document.querySelectorAll('.voice-item').forEach(item => {
item.addEventListener('click', () => {
const cid = item.dataset.channelId;
if (voiceHandler.currentChannelId == cid) {
voiceHandler.leave();
item.classList.remove('active');
} else {
voiceHandler.join(cid);
document.querySelectorAll('.voice-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
}
});
});
// Presence indicators initialization (can be expanded)
if (window.currentUserId) {
// ... (existing presence logic if any)
}
// Message Actions (Edit/Delete)
@ -732,19 +783,32 @@ document.addEventListener('DOMContentLoaded', () => {
const msgId = editBtn.dataset.id;
const msgItem = editBtn.closest('.message-item');
const textEl = msgItem.querySelector('.message-text');
const originalContent = textEl.innerText;
const originalContent = msgItem.dataset.rawContent || textEl.innerText;
const input = document.createElement('input');
input.type = 'text';
const input = document.createElement('textarea');
input.className = 'form-control bg-dark text-white';
input.style.resize = 'none';
input.style.overflowY = 'hidden';
input.rows = 1;
input.value = originalContent;
textEl.innerHTML = '';
textEl.appendChild(input);
const resizeInput = () => {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 200) + 'px';
input.style.overflowY = input.scrollHeight > 200 ? 'auto' : 'hidden';
};
input.addEventListener('input', resizeInput);
setTimeout(resizeInput, 0);
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
input.onkeydown = async (ev) => {
if (ev.key === 'Enter') {
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
const newContent = input.value.trim();
if (newContent && newContent !== originalContent) {
const resp = await fetch('api_v1_messages.php', {
@ -753,14 +817,15 @@ document.addEventListener('DOMContentLoaded', () => {
body: JSON.stringify({ id: msgId, content: newContent })
});
if ((await resp.json()).success) {
textEl.innerHTML = newContent.replace(/\n/g, '<br>');
textEl.innerHTML = parseCustomEmotes(newContent);
msgItem.dataset.rawContent = newContent;
ws?.send(JSON.stringify({ type: 'message_edit', message_id: msgId, content: newContent }));
}
} else {
textEl.innerHTML = originalContent.replace(/\n/g, '<br>');
textEl.innerHTML = parseCustomEmotes(originalContent);
}
} else if (ev.key === 'Escape') {
textEl.innerHTML = originalContent.replace(/\n/g, '<br>');
textEl.innerHTML = parseCustomEmotes(originalContent);
}
};
return;
@ -814,6 +879,8 @@ document.addEventListener('DOMContentLoaded', () => {
data.messages.forEach(msg => {
const div = document.createElement('div');
div.className = 'message-item p-2 border-bottom border-secondary';
div.dataset.id = msg.id;
div.dataset.rawContent = msg.content;
div.style.backgroundColor = 'transparent';
const authorStyle = msg.role_color ? `color: ${msg.role_color};` : '';
div.innerHTML = `
@ -826,7 +893,7 @@ document.addEventListener('DOMContentLoaded', () => {
<span class="message-time">${msg.time}</span>
</div>
<div class="message-text" style="font-size: 0.9em;">
${escapeHTML(msg.content).replace(/\n/g, '<br>')}
${parseCustomEmotes(msg.content)}
</div>
</div>
</div>
@ -966,8 +1033,8 @@ document.addEventListener('DOMContentLoaded', () => {
} else {
item.innerHTML = `
<div class="flex-grow-1">
<div class="search-result-author">${res.username}</div>
<div class="search-result-text">${res.content}</div>
<div class="search-result-author">${escapeHTML(res.username)}</div>
<div class="search-result-text">${parseCustomEmotes(res.content)}</div>
</div>
`;
}
@ -2375,15 +2442,93 @@ document.addEventListener('DOMContentLoaded', () => {
// User Settings - Save handled in index.php
function escapeHTML(str) {
if (!str) return "";
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function parseMarkdown(text) {
if (!text) return "";
// Escape HTML first
let html = escapeHTML(text);
// Code blocks: ```language\ncontent```
const codeBlocks = [];
html = html.replace(/```(?:(\w+)\n)?([\s\S]*?)```/g, (match, lang, content) => {
const placeholder = `__CODE_BLOCK_${codeBlocks.length}__`;
codeBlocks.push(`<pre class="code-block"><code class="language-${lang || 'text'}">${content}</code></pre>`);
return placeholder;
});
// Inline code: `content`
const inlineCodes = [];
html = html.replace(/`([^`\n]+)`/g, (match, content) => {
const placeholder = `__INLINE_CODE_${inlineCodes.length}__`;
inlineCodes.push(`<code>${content}</code>`);
return placeholder;
});
// Bold: **text**
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Italics: *text* or _text_
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
html = html.replace(/_([^_]+)_/g, '<em>$1</em>');
// Underline: __text__
html = html.replace(/__([^_]+)__/g, '<u>$1</u>');
// Strikethrough: ~~text~~
html = html.replace(/~~([^~]+)~~/g, '<del>$1</del>');
// Spoiler: ||text||
html = html.replace(/\|\|([^|]+)\|\|/g, '<span class="spoiler" onclick="this.classList.toggle(\'revealed\')">$1</span>');
// Headers: # H1, ## H2, ### H3 (must be at start of line)
html = html.replace(/^# (.*$)/gm, '<h1>$1</h1>');
html = html.replace(/^## (.*$)/gm, '<h2>$1</h2>');
html = html.replace(/^### (.*$)/gm, '<h3>$1</h3>');
// Subtext: -# text (must be at start of line)
html = html.replace(/^-# (.*$)/gm, '<small class="text-muted d-block" style="font-size: 0.8em;">$1</small>');
// Blockquotes: > text or >>> text (must be at start of line)
html = html.replace(/^&gt; (.*$)/gm, '<blockquote>$1</blockquote>');
html = html.replace(/^&gt;&gt;&gt; ([\s\S]*$)/g, '<blockquote>$1</blockquote>');
// Hyperlinks: [text](url)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
// Pure links: <url>
html = html.replace(/&lt;(https?:\/\/[^&]+)&gt;/g, '<a href="$1" target="_blank">$1</a>');
// Newlines to <br> (only those not inside placeholders)
html = html.replace(/\n/g, '<br>');
// Remove extra space around headers and blockquotes added by nl2br
html = html.replace(/(<br>)\s*(<h1>|<h2>|<h3>|<blockquote>)/gi, '$2');
html = html.replace(/(<\/h1>|<\/h2>|<\/h3>|<\/blockquote>)\s*(<br>)/gi, '$1');
// Re-insert inline code
inlineCodes.forEach((code, i) => {
html = html.replace(`__INLINE_CODE_${i}__`, code);
});
// Re-insert code blocks
codeBlocks.forEach((block, i) => {
html = html.replace(`__CODE_BLOCK_${i}__`, block);
});
return html;
}
function parseCustomEmotes(text) {
let parsed = escapeHTML(text);
let parsed = parseMarkdown(text);
(window.CUSTOM_EMOTES_CACHE || []).forEach(emote => {
const imgHtml = `<img src="${emote.path}" alt="${emote.name}" title="${emote.code}" style="width: 24px; height: 24px; vertical-align: middle; object-fit: contain;">`;
// Only replace if it's not inside a tag attribute or code block (simplified)
parsed = parsed.split(emote.code).join(imgHtml);
});
return parsed;
@ -2409,6 +2554,7 @@ document.addEventListener('DOMContentLoaded', () => {
const div = document.createElement('div');
div.className = 'message-item';
div.dataset.id = msg.id;
div.dataset.rawContent = msg.content;
if (parseInt(msg.id) > lastMessageId) {
lastMessageId = parseInt(msg.id);
@ -2483,7 +2629,7 @@ document.addEventListener('DOMContentLoaded', () => {
<span class="message-timestamp">${msg.timestamp || 'Just now'}</span>
${pinnedBadge}
</div>
<div class="message-text">${contentWithMentions.replace(/\n/g, '<br>')}</div>
<div class="message-text">${contentWithMentions}</div>
${attachmentHtml}
${embedHtml}
<div class="message-reactions mt-1" data-message-id="${msg.id}"></div>

View File

@ -1,242 +1,257 @@
console.log('voice.js loaded');
class VoiceChannel {
constructor(ws) {
this.ws = ws;
constructor(ws, settings) {
// ws is ignored now as we use PHP signaling, but kept for compatibility
this.settings = settings || { mode: 'vox', pttKey: 'v', voxThreshold: 0.1 };
console.log('VoiceChannel constructor called with settings:', this.settings);
this.localStream = null;
this.screenStream = null;
this.analysisStream = null;
this.peers = {}; // userId -> RTCPeerConnection
this.participants = {}; // userId -> username
this.participants = {}; // userId -> {name}
this.currentChannelId = null;
this.isScreenSharing = false;
this.myPeerId = null;
this.pollInterval = null;
this.remoteAudios = {}; // userId -> Audio element
this.audioContext = null;
this.analyser = null;
this.microphone = null;
this.scriptProcessor = null;
this.isTalking = false;
this.pttPressed = false;
this.voxActive = false;
this.lastVoiceTime = 0;
this.voxHoldTime = 500;
// Track who is speaking to persist across UI refreshes
this.speakingUsers = new Set();
this.setupPTTListeners();
window.addEventListener('beforeunload', () => this.leave());
}
setupPTTListeners() {
window.addEventListener('keydown', (e) => {
// Ignore if in input field
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (this.settings.mode !== 'ptt') return;
const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() ||
(e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) ||
(this.settings.pttKey === '0' && e.code === 'Numpad0');
if (isMatch) {
if (!this.pttPressed) {
console.log('PTT Key Pressed:', e.key, e.code, 'Expected:', this.settings.pttKey);
this.pttPressed = true;
this.updateMuteState();
}
}
});
window.addEventListener('keyup', (e) => {
if (this.settings.mode !== 'ptt') return;
const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() ||
(e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) ||
(this.settings.pttKey === '0' && e.code === 'Numpad0');
if (isMatch) {
console.log('PTT Key Released:', e.key, e.code, 'Expected:', this.settings.pttKey);
this.pttPressed = false;
this.updateMuteState();
}
});
}
async join(channelId) {
if (this.currentChannelId === channelId) return;
if (this.currentChannelId) this.leave();
console.log('VoiceChannel.join process started for channel:', channelId);
if (this.currentChannelId === channelId) {
console.log('Already in this channel');
return;
}
if (this.currentChannelId) {
console.log('Leaving previous channel:', this.currentChannelId);
this.leave();
}
console.log('Joining voice channel:', channelId);
this.currentChannelId = channelId;
try {
console.log('Requesting microphone access...');
this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
this.ws.send(JSON.stringify({
type: 'voice_join',
channel_id: channelId,
user_id: window.currentUserId,
username: window.currentUsername
}));
console.log('Microphone access granted');
this.setMute(true);
this.updateVoiceUI();
// Always setup VOX logic for volume meter and detection
this.setupVOX();
// Join via PHP
console.log('Calling API join...');
const url = `api_v1_voice.php?action=join&room=${channelId}&name=${encodeURIComponent(window.currentUsername || 'Unknown')}`;
const resp = await fetch(url);
const data = await resp.json();
console.log('API join response:', data);
if (data.success) {
this.myPeerId = data.peer_id;
console.log('Joined room with peer_id:', this.myPeerId);
// Start polling
this.startPolling();
this.updateVoiceUI();
} else {
console.error('API join failed:', data.error);
}
} catch (e) {
console.error('Failed to get local stream:', e);
alert('Could not access microphone.');
console.error('Failed to join voice:', e);
alert('Microphone access required for voice channels. Error: ' + e.message);
this.currentChannelId = null;
}
}
async toggleScreenShare() {
if (!this.currentChannelId) return;
startPolling() {
if (this.pollInterval) clearInterval(this.pollInterval);
this.pollInterval = setInterval(() => this.poll(), 1000);
this.poll(); // Initial poll
}
if (this.isScreenSharing) {
this.stopScreenShare();
} else {
try {
this.screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
this.isScreenSharing = true;
async poll() {
if (!this.myPeerId || !this.currentChannelId) return;
try {
const resp = await fetch(`api_v1_voice.php?action=poll&room=${this.currentChannelId}&peer_id=${this.myPeerId}`);
const data = await resp.json();
if (data.success) {
// Update participants
const oldPs = Object.keys(this.participants);
this.participants = data.participants;
const newPs = Object.keys(this.participants);
const videoTrack = this.screenStream.getVideoTracks()[0];
videoTrack.onended = () => this.stopScreenShare();
// Replace or add track to all peers
Object.values(this.peers).forEach(pc => {
pc.addTrack(videoTrack, this.screenStream);
// Renegotiate
this.renegotiate(pc);
// If new people joined, initiate offer if I'm the "older" one (not really necessary here, can just offer to anyone I don't have a peer for)
newPs.forEach(pid => {
if (pid !== this.myPeerId && !this.peers[pid]) {
console.log('New peer found via poll:', pid);
this.createPeerConnection(pid, true);
}
});
this.updateVoiceUI();
this.showLocalVideo();
} catch (e) {
console.error('Failed to share screen:', e);
}
}
}
// Cleanup left peers
oldPs.forEach(pid => {
if (!this.participants[pid] && this.peers[pid]) {
console.log('Peer left:', pid);
this.peers[pid].close();
delete this.peers[pid];
}
});
stopScreenShare() {
if (this.screenStream) {
this.screenStream.getTracks().forEach(track => track.stop());
this.screenStream = null;
}
this.isScreenSharing = false;
// Remove video track from all peers
Object.entries(this.peers).forEach(([userId, pc]) => {
const senders = pc.getSenders();
const videoSender = senders.find(s => s.track && s.track.kind === 'video');
if (videoSender) {
pc.removeTrack(videoSender);
this.renegotiate(pc);
}
});
this.updateVoiceUI();
const localVideo = document.getElementById('local-video-container');
if (localVideo) localVideo.innerHTML = '';
}
renegotiate(pc) {
// Find which user this PC belongs to
const userId = Object.keys(this.peers).find(key => this.peers[key] === pc);
if (!userId) return;
pc.createOffer().then(offer => {
return pc.setLocalDescription(offer);
}).then(() => {
this.ws.send(JSON.stringify({
type: 'voice_offer',
to: userId,
from: window.currentUserId,
username: window.currentUsername,
offer: pc.localDescription,
channel_id: this.currentChannelId
}));
});
}
leave() {
if (!this.currentChannelId) return;
this.stopScreenShare();
this.ws.send(JSON.stringify({
type: 'voice_leave',
channel_id: this.currentChannelId,
user_id: window.currentUserId
}));
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop());
this.localStream = null;
}
Object.values(this.peers).forEach(pc => pc.close());
this.peers = {};
this.participants = {};
this.currentChannelId = null;
this.updateVoiceUI();
}
async handleSignaling(data) {
const { type, from, to, offer, answer, candidate, channel_id, username } = data;
if (channel_id != this.currentChannelId) return;
if (to && to != window.currentUserId) return;
switch (type) {
case 'voice_join':
if (from != window.currentUserId) {
this.participants[from] = username || `User ${from}`;
this.createPeerConnection(from, true);
this.updateVoiceUI();
// Handle incoming signals
if (data.signals && data.signals.length > 0) {
for (const sig of data.signals) {
await this.handleSignaling(sig);
}
}
break;
case 'voice_offer':
this.participants[from] = username || `User ${from}`;
await this.handleOffer(from, offer);
this.updateVoiceUI();
break;
case 'voice_answer':
await this.handleAnswer(from, answer);
break;
case 'voice_ice_candidate':
await this.handleCandidate(from, candidate);
break;
case 'voice_leave':
if (this.peers[from]) {
this.peers[from].close();
delete this.peers[from];
}
delete this.participants[from];
this.updateVoiceUI();
const remoteVideo = document.getElementById(`remote-video-${from}`);
if (remoteVideo) remoteVideo.remove();
break;
}
} catch (e) {
console.error('Polling error:', e);
}
}
async sendSignal(to, data) {
if (!this.myPeerId || !this.currentChannelId) return;
await fetch(`api_v1_voice.php?action=signal&room=${this.currentChannelId}&peer_id=${this.myPeerId}&to=${to}&data=${encodeURIComponent(JSON.stringify(data))}`);
}
createPeerConnection(userId, isOfferor) {
if (this.peers[userId]) return this.peers[userId];
console.log('Creating PeerConnection for:', userId, 'as offeror:', isOfferor);
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
});
this.peers[userId] = pc;
pc.oniceconnectionstatechange = () => {
console.log(`ICE Connection State with ${userId}: ${pc.iceConnectionState}`);
};
if (this.localStream) {
this.localStream.getTracks().forEach(track => {
console.log(`Adding track ${track.kind} to peer ${userId}`);
pc.addTrack(track, this.localStream);
});
}
if (this.screenStream) {
this.screenStream.getTracks().forEach(track => {
pc.addTrack(track, this.screenStream);
});
}
pc.onicecandidate = (event) => {
if (event.candidate) {
this.ws.send(JSON.stringify({
type: 'voice_ice_candidate',
to: userId,
from: window.currentUserId,
candidate: event.candidate,
channel_id: this.currentChannelId
}));
this.sendSignal(userId, { type: 'ice_candidate', candidate: event.candidate });
}
};
pc.ontrack = (event) => {
if (event.track.kind === 'audio') {
const remoteAudio = new Audio();
remoteAudio.srcObject = event.streams[0];
remoteAudio.play();
} else if (event.track.kind === 'video') {
this.handleRemoteVideo(userId, event.streams[0]);
console.log('Received remote track from:', userId, event);
if (this.remoteAudios[userId]) {
this.remoteAudios[userId].pause();
this.remoteAudios[userId].remove();
this.remoteAudios[userId].srcObject = null;
}
const remoteAudio = new Audio();
remoteAudio.style.display = 'none';
remoteAudio.srcObject = event.streams[0];
document.body.appendChild(remoteAudio);
this.remoteAudios[userId] = remoteAudio;
remoteAudio.play().catch(e => console.warn('Autoplay prevented:', e));
};
if (isOfferor) {
pc.createOffer().then(offer => {
return pc.setLocalDescription(offer);
}).then(() => {
this.ws.send(JSON.stringify({
type: 'voice_offer',
to: userId,
from: window.currentUserId,
username: window.currentUsername,
offer: pc.localDescription,
channel_id: this.currentChannelId
}));
this.sendSignal(userId, { type: 'offer', offer: pc.localDescription });
});
}
return pc;
}
async handleSignaling(sig) {
const from = sig.from;
const data = sig.data;
console.log('Handling signaling from:', from, 'type:', data.type);
switch (data.type) {
case 'offer':
await this.handleOffer(from, data.offer);
break;
case 'answer':
await this.handleAnswer(from, data.answer);
break;
case 'ice_candidate':
await this.handleCandidate(from, data.candidate);
break;
case 'voice_speaking':
this.updateSpeakingUI(data.user_id, data.speaking);
break;
}
}
async handleOffer(from, offer) {
const pc = this.createPeerConnection(from, false);
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
this.ws.send(JSON.stringify({
type: 'voice_answer',
to: from,
from: window.currentUserId,
answer: pc.localDescription,
channel_id: this.currentChannelId
}));
this.sendSignal(from, { type: 'answer', answer: pc.localDescription });
}
async handleAnswer(from, answer) {
@ -249,116 +264,257 @@ class VoiceChannel {
if (pc) await pc.addIceCandidate(new RTCIceCandidate(candidate));
}
handleRemoteVideo(userId, stream) {
let container = document.getElementById('video-grid');
if (!container) {
container = document.createElement('div');
container.id = 'video-grid';
container.className = 'video-grid';
const chatContainer = document.querySelector('.chat-container');
if (chatContainer) {
chatContainer.insertBefore(container, document.getElementById('messages-list'));
} else {
document.body.appendChild(container);
}
setupVOX() {
if (!this.localStream) {
console.warn('Cannot setup VOX: no localStream');
return;
}
let video = document.getElementById(`remote-video-${userId}`);
if (!video) {
video = document.createElement('video');
video.id = `remote-video-${userId}`;
video.autoplay = true;
video.playsinline = true;
container.appendChild(video);
console.log('Setting up VOX logic...');
try {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
// Re-ensure context is running
if (this.audioContext.state === 'suspended') {
this.audioContext.resume().then(() => console.log('AudioContext resumed'));
}
// Cleanup old nodes
if (this.scriptProcessor) {
this.scriptProcessor.onaudioprocess = null;
try { this.scriptProcessor.disconnect(); } catch(e) {}
}
if (this.microphone) {
try { this.microphone.disconnect(); } catch(e) {}
}
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 512;
// Use a cloned stream for analysis so VOX works even when localStream is muted/disabled
if (this.analysisStream) {
this.analysisStream.getTracks().forEach(t => t.stop());
}
this.analysisStream = this.localStream.clone();
this.analysisStream.getAudioTracks().forEach(t => t.enabled = true); // Ensure analysis stream is NOT muted
this.microphone = this.audioContext.createMediaStreamSource(this.analysisStream);
this.scriptProcessor = this.audioContext.createScriptProcessor(2048, 1, 1);
this.microphone.connect(this.analyser);
this.analyser.connect(this.scriptProcessor);
// Avoid feedback: connect to a gain node with 0 volume then to destination
const silence = this.audioContext.createGain();
silence.gain.value = 0;
this.scriptProcessor.connect(silence);
silence.connect(this.audioContext.destination);
this.voxActive = false;
this.currentVolume = 0;
this.scriptProcessor.onaudioprocess = () => {
const array = new Uint8Array(this.analyser.frequencyBinCount);
this.analyser.getByteFrequencyData(array);
let values = 0;
for (let i = 0; i < array.length; i++) values += array[i];
const average = values / array.length;
this.currentVolume = average / 255;
if (this.settings.mode !== 'vox') {
this.voxActive = false;
return;
}
if (this.currentVolume > this.settings.voxThreshold) {
this.lastVoiceTime = Date.now();
if (!this.voxActive) {
this.voxActive = true;
this.updateMuteState();
}
} else {
if (this.voxActive && Date.now() - this.lastVoiceTime > this.voxHoldTime) {
this.voxActive = false;
this.updateMuteState();
}
}
};
console.log('VOX logic setup complete');
} catch (e) {
console.error('Failed to setup VOX:', e);
}
video.srcObject = stream;
}
showLocalVideo() {
let container = document.getElementById('video-grid');
if (!container) {
container = document.createElement('div');
container.id = 'video-grid';
container.className = 'video-grid';
const chatContainer = document.querySelector('.chat-container');
if (chatContainer) {
chatContainer.insertBefore(container, document.getElementById('messages-list'));
getVolume() {
return this.currentVolume || 0;
}
updateMuteState() {
if (!this.currentChannelId || !this.localStream) return;
let shouldTalk = (this.settings.mode === 'ptt') ? this.pttPressed : this.voxActive;
console.log('updateMuteState: shouldTalk =', shouldTalk, 'mode =', this.settings.mode);
if (this.isTalking !== shouldTalk) {
this.isTalking = shouldTalk;
this.setMute(!shouldTalk);
this.updateSpeakingUI(window.currentUserId, shouldTalk);
// Notify others
const msg = { type: 'voice_speaking', channel_id: this.currentChannelId, user_id: window.currentUserId, speaking: shouldTalk };
// ... (rest of method remains same, but I'll update it for clarity)
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
} else {
document.body.appendChild(container);
Object.keys(this.peers).forEach(pid => {
this.sendSignal(pid, msg);
});
}
}
}
let video = document.getElementById('local-video');
if (!video) {
video = document.createElement('video');
video.id = 'local-video';
video.autoplay = true;
video.playsinline = true;
video.muted = true;
container.appendChild(video);
setMute(mute) {
if (this.localStream) {
console.log('Setting mute to:', mute);
this.localStream.getAudioTracks().forEach(track => { track.enabled = !mute; });
}
video.srcObject = this.screenStream;
}
leave() {
if (!this.currentChannelId) return;
console.log('Leaving voice channel:', this.currentChannelId);
if (this.pollInterval) clearInterval(this.pollInterval);
fetch(`api_v1_voice.php?action=leave&room=${this.currentChannelId}&peer_id=${this.myPeerId}`);
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop());
this.localStream = null;
}
if (this.analysisStream) {
this.analysisStream.getTracks().forEach(track => track.stop());
this.analysisStream = null;
}
if (this.scriptProcessor) {
try {
this.scriptProcessor.disconnect();
this.scriptProcessor.onaudioprocess = null;
} catch(e) {}
this.scriptProcessor = null;
}
if (this.microphone) {
try { this.microphone.disconnect(); } catch(e) {}
this.microphone = null;
}
if (this.audioContext && this.audioContext.state !== 'closed') {
// Keep AudioContext alive but suspended to reuse it
this.audioContext.suspend();
}
Object.values(this.peers).forEach(pc => pc.close());
Object.values(this.remoteAudios).forEach(audio => {
audio.pause();
audio.remove();
audio.srcObject = null;
});
this.peers = {};
this.remoteAudios = {};
this.participants = {};
this.currentChannelId = null;
this.myPeerId = null;
this.speakingUsers.clear();
this.updateVoiceUI();
}
updateVoiceUI() {
document.querySelectorAll('.voice-users-list').forEach(el => el.innerHTML = '');
if (this.currentChannelId) {
const channelEl = document.querySelector(`.voice-item[data-channel-id="${this.currentChannelId}"]`);
if (channelEl) {
let listEl = channelEl.querySelector('.voice-users-list');
if (!listEl) {
listEl = document.createElement('div');
listEl.className = 'voice-users-list ms-3';
channelEl.appendChild(listEl);
}
// Me
this.addVoiceUserToUI(listEl, window.currentUserId, window.currentUsername);
// Others
Object.entries(this.participants).forEach(([uid, name]) => {
this.addVoiceUserToUI(listEl, uid, name);
});
}
// We now use a global update mechanism for all channels
VoiceChannel.refreshAllVoiceUsers();
// Show voice controls if not already there
if (this.currentChannelId) {
if (!document.querySelector('.voice-controls')) {
const controls = document.createElement('div');
controls.className = 'voice-controls p-2 d-flex justify-content-between align-items-center border-top bg-dark';
controls.style.backgroundColor = '#232428';
controls.innerHTML = `
<div class="d-flex align-items-center">
<div class="voice-status-icon text-success me-2"></div>
<div class="small">Voice Connected</div>
<div class="voice-status-icon text-success me-2" style="font-size: 8px;"></div>
<div class="small fw-bold" style="font-size: 11px; color: #248046;">Voice (${this.settings.mode.toUpperCase()})</div>
</div>
<div>
<button class="btn btn-sm btn-outline-light me-2" id="btn-screen-share">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
</button>
<button class="btn btn-sm btn-danger" id="btn-voice-leave">
<button class="btn btn-sm text-muted" id="btn-voice-leave" title="Disconnect">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"></path><line x1="23" y1="1" x2="1" y2="23"></line></svg>
</button>
</div>
`;
document.querySelector('.channels-sidebar').appendChild(controls);
document.getElementById('btn-screen-share').onclick = () => this.toggleScreenShare();
document.getElementById('btn-voice-leave').onclick = () => this.leave();
const sidebar = document.querySelector('.channels-sidebar');
if (sidebar) sidebar.appendChild(controls);
const btnLeave = document.getElementById('btn-voice-leave');
if (btnLeave) btnLeave.onclick = () => this.leave();
}
} else {
const controls = document.querySelector('.voice-controls');
if (controls) controls.remove();
const grid = document.getElementById('video-grid');
if (grid) grid.remove();
}
}
addVoiceUserToUI(container, userId, username) {
updateSpeakingUI(userId, isSpeaking) {
if (isSpeaking) {
this.speakingUsers.add(userId);
} else {
this.speakingUsers.delete(userId);
}
const userEls = document.querySelectorAll(`.voice-user[data-user-id="${userId}"]`);
userEls.forEach(el => {
const avatar = el.querySelector('.message-avatar');
if (avatar) {
avatar.style.boxShadow = isSpeaking ? '0 0 0 2px #23a559' : 'none';
}
});
}
static async refreshAllVoiceUsers() {
try {
const resp = await fetch('api_v1_voice.php?action=list_all');
const data = await resp.json();
if (data.success) {
// Clear all lists first
document.querySelectorAll('.voice-users-list').forEach(el => el.innerHTML = '');
// Populate based on data
Object.keys(data.channels).forEach(channelId => {
// Fix: The voice-users-list is a sibling of the container of the voice-item
const voiceItem = document.querySelector(`.voice-item[data-channel-id="${channelId}"]`);
if (voiceItem) {
const container = voiceItem.closest('.channel-item-container');
if (container) {
const listEl = container.querySelector('.voice-users-list');
if (listEl) {
data.channels[channelId].forEach(p => {
const isSpeaking = window.voiceHandler && window.voiceHandler.speakingUsers.has(p.user_id);
VoiceChannel.renderUserToUI(listEl, p.user_id, p.display_name || p.username, p.avatar_url, isSpeaking);
});
}
}
}
});
}
} catch (e) {
console.error('Failed to refresh voice users:', e);
}
}
static renderUserToUI(container, userId, username, avatarUrl, isSpeaking = false) {
const userEl = document.createElement('div');
userEl.className = 'voice-user small text-muted d-flex align-items-center mb-1';
userEl.dataset.userId = userId;
userEl.style.paddingLeft = '8px';
const avatarStyle = avatarUrl ? `background-image: url('${avatarUrl}'); background-size: cover;` : "background-color: #555;";
const boxShadow = isSpeaking ? 'box-shadow: 0 0 0 2px #23a559;' : '';
userEl.innerHTML = `
<div class="message-avatar me-2" style="width: 16px; height: 16px;"></div>
<span>${username}</span>
<div class="message-avatar me-2" style="width: 16px; height: 16px; border-radius: 50%; transition: box-shadow 0.2s; ${avatarStyle} ${boxShadow}"></div>
<span style="font-size: 13px;">${username}</span>
`;
container.appendChild(userEl);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -10,8 +10,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($username && $email && $password) {
$hash = password_hash($password, PASSWORD_DEFAULT);
try {
$stmt = db()->prepare("INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)");
$stmt->execute([$username, $email, $hash]);
$stmt = db()->prepare("INSERT INTO users (username, display_name, email, password_hash) VALUES (?, ?, ?, ?)");
$stmt->execute([$username, $username, $email, $hash]);
$userId = db()->lastInsertId();
// Add to default server

0
data/22.log Normal file
View File

View File

@ -0,0 +1 @@
{"de65a0b0b1a29c9a":{"id":"de65a0b0b1a29c9a","user_id":2,"name":"swefpifh ᵇʰᶠʳ","avatar_url":"","last_seen":1771343410040}}

0
data/3.log Normal file
View File

1
data/3.participants.json Normal file
View File

@ -0,0 +1 @@
{"920464469dc771ed":{"id":"920464469dc771ed","user_id":3,"name":"swefheim","avatar_url":"","last_seen":1771343410598}}

10
data/6.log Normal file
View File

@ -0,0 +1,10 @@
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"offer","offer":{"type":"offer","sdp":"v=0\r\no=mozilla...THIS_IS_SDPARTA-99.0 1705174900585877835 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\na=sendrecv\r\na=fingerprint:sha-256 AA:37:B9:FA:14:15:DC:34:29:97:DC:55:77:28:8E:74:C0:94:15:08:DF:5B:E9:CC:36:81:E5:D9:5C:49:FB:46\r\na=group:BUNDLE 0\r\na=ice-options:trickle\r\na=msid-semantic:WMS *\r\nm=audio 9 UDP\/TLS\/RTP\/SAVPF 109 9 0 8 101\r\nc=IN IP4 0.0.0.0\r\na=sendrecv\r\na=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\na=extmap:2\/recvonly urn:ietf:params:rtp-hdrext:csrc-audio-level\r\na=extmap:3 urn:ietf:params:rtp-hdrext:sdes:mid\r\na=extmap-allow-mixed\r\na=fmtp:109 maxplaybackrate=48000;stereo=1;useinbandfec=1\r\na=fmtp:101 0-15\r\na=ice-pwd:075627a25c9f07d3908305504803057d\r\na=ice-ufrag:03d0add1\r\na=mid:0\r\na=msid:{7a2c5afc-d053-43fd-9196-3caf4510feb3} {8439b857-ead8-4cb1-8111-cb3400348461}\r\na=rtcp-mux\r\na=rtpmap:109 opus\/48000\/2\r\na=rtpmap:9 G722\/8000\/1\r\na=rtpmap:0 PCMU\/8000\r\na=rtpmap:8 PCMA\/8000\r\na=rtpmap:101 telephone-event\/8000\r\na=setup:actpass\r\na=ssrc:1663488241 cname:{7dcb8038-2b50-4e42-827c-c4617a225c01}\r\n"}},"time":1771336456644}
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:0 1 UDP 2122252543 192.168.26.26 62572 typ host","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456645}
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:2 1 TCP 2105524479 192.168.26.26 9 typ host tcptype active","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456647}
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:2 2 TCP 2105524478 192.168.26.26 9 typ host tcptype active","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456652}
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:0 2 UDP 2122252542 192.168.26.26 62573 typ host","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456654}
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:1 1 UDP 1686052351 78.246.210.10 31184 typ srflx raddr 192.168.26.26 rport 62572","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456720}
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:1 2 UDP 1686052350 78.246.210.10 31185 typ srflx raddr 192.168.26.26 rport 62573","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456757}
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:1 1 UDP 1686052863 78.246.210.10 31184 typ srflx raddr 192.168.26.26 rport 62572","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456776}
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:1 2 UDP 1686052862 78.246.210.10 31185 typ srflx raddr 192.168.26.26 rport 62573","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456802}
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456805}

1
data/6.participants.json Normal file
View File

@ -0,0 +1 @@
{"0cce3619c0f299fa":{"id":"0cce3619c0f299fa","user_id":2,"name":"swefpifh ᵇʰᶠʳ","avatar_url":"","last_seen":1771336452806},"75b3938d276e81e1":{"id":"75b3938d276e81e1","user_id":2,"name":"swefpifh ᵇʰᶠʳ","avatar_url":"","last_seen":1771336462293}}

View File

@ -0,0 +1 @@
{"0fbf720bc2f110c0":{"id":"0fbf720bc2f110c0","name":"AI","last_seen":1771336229774}}

1
data/test.txt Normal file
View File

@ -0,0 +1 @@
hello

1
data/test_www.txt Normal file
View File

@ -0,0 +1 @@
hello

View File

@ -0,0 +1,12 @@
-- Add voice settings to users and create voice sessions table
ALTER TABLE users ADD COLUMN voice_mode ENUM('vox', 'ptt') DEFAULT 'vox';
ALTER TABLE users ADD COLUMN voice_ptt_key VARCHAR(20) DEFAULT 'v';
ALTER TABLE users ADD COLUMN voice_vox_threshold FLOAT DEFAULT 0.1;
CREATE TABLE IF NOT EXISTS voice_sessions (
user_id INT PRIMARY KEY,
channel_id INT NOT NULL,
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
);

View File

@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN display_name VARCHAR(50) AFTER username;
UPDATE users SET display_name = username WHERE display_name IS NULL OR display_name = "";

View File

@ -16,3 +16,4 @@
2026-02-16 23:37:00 - Server: 1 - Orders: [{"id":"11","position":0,"category_id":null},{"id":"12","position":1,"category_id":null},{"id":"17","position":2,"category_id":null},{"id":"19","position":3,"category_id":null},{"id":"10","position":4,"category_id":null},{"id":"1","position":5,"category_id":"10"},{"id":"6","position":6,"category_id":"10"},{"id":"15","position":7,"category_id":"10"},{"id":"2","position":8,"category_id":"10"},{"id":"18","position":9,"category_id":"10"},{"id":"14","position":10,"category_id":null},{"id":"13","position":11,"category_id":null},{"id":"9","position":12,"category_id":null},{"id":"3","position":13,"category_id":null}]
2026-02-16 23:37:02 - Server: 1 - Orders: [{"id":"11","position":0,"category_id":null},{"id":"17","position":1,"category_id":null},{"id":"12","position":2,"category_id":null},{"id":"19","position":3,"category_id":null},{"id":"10","position":4,"category_id":null},{"id":"1","position":5,"category_id":"10"},{"id":"6","position":6,"category_id":"10"},{"id":"15","position":7,"category_id":"10"},{"id":"2","position":8,"category_id":"10"},{"id":"18","position":9,"category_id":"10"},{"id":"14","position":10,"category_id":null},{"id":"13","position":11,"category_id":null},{"id":"9","position":12,"category_id":null},{"id":"3","position":13,"category_id":null}]
2026-02-17 00:31:00 - Server: 1 - Orders: [{"id":"11","position":0,"category_id":null},{"id":"17","position":1,"category_id":null},{"id":"12","position":2,"category_id":null},{"id":"20","position":3,"category_id":null},{"id":"19","position":4,"category_id":null},{"id":"10","position":5,"category_id":null},{"id":"1","position":6,"category_id":"10"},{"id":"6","position":7,"category_id":"10"},{"id":"15","position":8,"category_id":"10"},{"id":"2","position":9,"category_id":"10"},{"id":"18","position":10,"category_id":"10"},{"id":"14","position":11,"category_id":null},{"id":"13","position":12,"category_id":null},{"id":"9","position":13,"category_id":null},{"id":"3","position":14,"category_id":null}]
2026-02-17 08:19:05 - Server: 1 - Orders: [{"id":"11","position":0,"category_id":null},{"id":"17","position":1,"category_id":null},{"id":"20","position":2,"category_id":null},{"id":"21","position":3,"category_id":null},{"id":"19","position":4,"category_id":null},{"id":"10","position":5,"category_id":null},{"id":"1","position":6,"category_id":"10"},{"id":"6","position":7,"category_id":"10"},{"id":"15","position":8,"category_id":"10"},{"id":"2","position":9,"category_id":"10"},{"id":"18","position":10,"category_id":"10"},{"id":"14","position":11,"category_id":null},{"id":"13","position":12,"category_id":null},{"id":"9","position":13,"category_id":null},{"id":"3","position":14,"category_id":null}]

606
index.php
View File

@ -28,8 +28,88 @@ function renderRoleIcon($icon, $size = '14px') {
}
}
// Helper to parse markdown in content
function parse_markdown($text) {
if (empty($text)) return "";
// First escape HTML
$html = htmlspecialchars($text);
// Code blocks: ```language\ncontent```
$code_blocks = [];
$html = preg_replace_callback('/```(?:(\w+)\n)?([\s\S]*?)```/', function($matches) use (&$code_blocks) {
$lang = $matches[1] ?? 'text';
$content = $matches[2];
$placeholder = "__CODE_BLOCK_" . count($code_blocks) . "__";
$code_blocks[] = '<pre class="code-block"><code class="language-' . htmlspecialchars($lang) . '">' . $content . '</code></pre>';
return $placeholder;
}, $html);
// Inline code: `content`
$inline_codes = [];
$html = preg_replace_callback('/`([^`\n]+)`/', function($matches) use (&$inline_codes) {
$content = $matches[1];
$placeholder = "__INLINE_CODE_" . count($inline_codes) . "__";
$inline_codes[] = '<code>' . $content . '</code>';
return $placeholder;
}, $html);
// Bold: **text**
$html = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $html);
// Italics: *text* or _text_
$html = preg_replace('/\*([^*]+)\*/', '<em>$1</em>', $html);
$html = preg_replace('/_([^_]+)_/', '<em>$1</em>', $html);
// Underline: __text__
$html = preg_replace('/__([^_]+)__/', '<u>$1</u>', $html);
// Strikethrough: ~~text~~
$html = preg_replace('/~~([^~]+)~~/', '<del>$1</del>', $html);
// Spoiler: ||text||
$html = preg_replace('/\|\|([^|]+)\|\|/', '<span class="spoiler" onclick="this.classList.toggle(\'revealed\')">$1</span>', $html);
// Headers: # H1, ## H2, ### H3 (must be at start of line)
$html = preg_replace('/^# (.*$)/m', '<h1>$1</h1>', $html);
$html = preg_replace('/^## (.*$)/m', '<h2>$1</h2>', $html);
$html = preg_replace('/^### (.*$)/m', '<h3>$1</h3>', $html);
// Subtext: -# text (must be at start of line)
$html = preg_replace('/^-# (.*$)/m', '<small class="d-block" style="font-size: 0.8em; color: var(--text-muted);">$1</small>', $html);
// Blockquotes: > text or >>> text
$html = preg_replace('/^&gt; (.*$)/m', '<blockquote>$1</blockquote>', $html);
$html = preg_replace('/^&gt;&gt;&gt; ([\s\S]*$)/', '<blockquote>$1</blockquote>', $html);
// Hyperlinks: [text](url)
$html = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '<a href="$2" target="_blank">$1</a>', $html);
// Pure links: <url>
$html = preg_replace('/&lt;(https?:\/\/[^&]+)&gt;/', '<a href="$1" target="_blank">$1</a>', $html);
// Newlines to <br> (only those not inside placeholders)
$html = nl2br($html);
// Remove extra space around headers and blockquotes added by nl2br
$html = preg_replace('/(<br\s*\/?>\s*)\s*(<h[1-3]>|<blockquote>)/i', '$2', $html);
$html = preg_replace('/(<\/h[1-3]>|<\/blockquote>)\s*(<br\s*\/?>\s*)/i', '$1', $html);
// Re-insert inline code
foreach ($inline_codes as $i => $code) {
$html = str_replace("__INLINE_CODE_$i" . "__", $code, $html);
}
// Re-insert code blocks
foreach ($code_blocks as $i => $block) {
$html = str_replace("__CODE_BLOCK_$i" . "__", $block, $html);
}
return $html;
}
// Helper to parse emotes in content
function parse_emotes($content) {
function parse_emotes($content, $username_to_mention = null) {
static $custom_emotes_cache;
if ($custom_emotes_cache === null) {
try {
@ -39,7 +119,14 @@ function parse_emotes($content) {
}
}
$result = htmlspecialchars($content);
$result = parse_markdown($content);
// Parse mentions if username provided
if ($username_to_mention) {
$mention_pattern = '/@' . preg_quote($username_to_mention, '/') . '\b/';
$result = preg_replace($mention_pattern, '<span class="mention">@' . htmlspecialchars($username_to_mention) . '</span>', $result);
}
foreach ($custom_emotes_cache as $ce) {
$emote_html = '<img src="' . htmlspecialchars($ce['path']) . '" alt="' . htmlspecialchars($ce['name']) . '" title="' . htmlspecialchars($ce['code']) . '" style="width: 24px; height: 24px; vertical-align: middle; object-fit: contain;">';
$result = str_replace($ce['code'], $emote_html, $result);
@ -66,7 +153,7 @@ if ($is_dm_view) {
$active_server_id = 'dms';
// Fetch DM channels
$stmt = db()->prepare("
SELECT c.id, u.username as other_user, u.avatar_url, u.status, u.id as other_user_id
SELECT c.id, u.display_name as other_user, u.avatar_url, u.status, u.id as other_user_id
FROM channels c
JOIN channel_members cm1 ON c.id = cm1.channel_id
JOIN channel_members cm2 ON c.id = cm2.channel_id
@ -82,7 +169,7 @@ if ($is_dm_view) {
if ($active_channel_id) {
// Fetch DM messages
$stmt = db()->prepare("
SELECT m.*, u.username, u.avatar_url
SELECT m.*, u.display_name as username, u.username as login_name, u.avatar_url
FROM messages m
JOIN users u ON m.user_id = u.id
WHERE m.channel_id = ?
@ -153,13 +240,13 @@ if ($is_dm_view) {
$active_thread = null;
if ($active_thread_id) {
$stmt = db()->prepare("SELECT t.*, u.username FROM forum_threads t JOIN users u ON t.user_id = u.id WHERE t.id = ?");
$stmt = db()->prepare("SELECT t.*, u.display_name as username, u.username as login_name FROM forum_threads t JOIN users u ON t.user_id = u.id WHERE t.id = ?");
$stmt->execute([$active_thread_id]);
$active_thread = $stmt->fetch();
if ($active_thread) {
$stmt = db()->prepare("
SELECT m.*, u.username, u.avatar_url,
SELECT m.*, u.display_name as username, u.username as login_name, u.avatar_url,
(SELECT r.color FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_color,
(SELECT r.icon_url FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_icon
FROM messages m
@ -190,7 +277,7 @@ if ($is_dm_view) {
}
$stmt = db()->prepare("
SELECT t.*, u.username, u.avatar_url,
SELECT t.*, u.display_name as username, u.username as login_name, u.avatar_url,
(SELECT COUNT(*) FROM messages m WHERE m.thread_id = t.id) as message_count,
(SELECT MAX(created_at) FROM messages m WHERE m.thread_id = t.id) as last_activity,
(SELECT r.color FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_color,
@ -206,8 +293,9 @@ if ($is_dm_view) {
} else {
// Fetch messages
$display_limit = !empty($active_channel['message_limit']) ? (int)$active_channel['message_limit'] : 50;
$stmt = db()->prepare("
SELECT m.*, u.username, u.avatar_url,
SELECT m.*, u.display_name as username, u.username as login_name, u.avatar_url,
(SELECT r.color FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_color,
(SELECT r.icon_url FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_icon
FROM messages m
@ -223,9 +311,22 @@ if ($is_dm_view) {
$current_channel_name = 'general';
foreach($channels as $c) if($c['id'] == $active_channel_id) $current_channel_name = $c['name'];
// Fetch voice sessions for the sidebar
$stmt_vs = db()->prepare("
SELECT vs.channel_id, vs.user_id, u.username, u.display_name, u.avatar_url
FROM voice_sessions vs
JOIN users u ON vs.user_id = u.id
");
$stmt_vs->execute();
$voice_sessions = $stmt_vs->fetchAll();
$voice_users_by_channel = [];
foreach($voice_sessions as $vs) {
$voice_users_by_channel[$vs['channel_id']][] = $vs;
}
// Fetch members
$stmt = db()->prepare("
SELECT u.id, u.username, u.avatar_url, u.status,
SELECT u.id, u.display_name as username, u.username as login_name, u.avatar_url, u.status,
(SELECT GROUP_CONCAT(r.id) FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ?) as role_ids,
(SELECT r.color FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_color,
(SELECT r.icon_url FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_icon
@ -276,7 +377,7 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
<script>
window.currentUserId = <?php echo $current_user_id; ?>;
window.activeServerId = "<?php echo $active_server_id; ?>";
window.currentUsername = "<?php echo addslashes($user['username']); ?>";
window.currentUsername = "<?php echo addslashes($user['display_name'] ?? $user['username']); ?>";
window.isServerOwner = <?php echo ($is_owner ?? false) ? 'true' : 'false'; ?>;
window.canManageServer = <?php echo ($can_manage_server ?? false) ? 'true' : 'false'; ?>;
window.canManageChannels = <?php echo ($can_manage_channels ?? false) ? 'true' : 'false'; ?>;
@ -339,7 +440,7 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
<div class="channels-header">
<?php
if ($is_dm_view) {
echo "Direct Messages";
echo '<span class="text-truncate flex-grow-1" style="min-width: 0;">Direct Messages</span>';
} else {
$active_server_name = 'Server';
foreach($servers as $s) {
@ -348,9 +449,9 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
break;
}
}
echo htmlspecialchars($active_server_name);
echo '<span class="text-truncate flex-grow-1" style="min-width: 0;">' . htmlspecialchars($active_server_name) . '</span>';
?>
<div class="ms-auto d-flex align-items-center">
<div class="d-flex align-items-center ps-2">
<?php if ($can_manage_channels): ?>
<span class="add-channel-btn me-2" style="cursor: pointer; opacity: 0.7;" data-bs-toggle="modal" data-bs-target="#addChannelModal" data-type="chat" data-category-id="" title="Create Channel">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
@ -381,6 +482,7 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
<?php
// Helper to render a channel item
function renderChannelItem($c, $active_channel_id, $active_server_id, $can_manage_channels) {
global $voice_users_by_channel;
if ($c['type'] === 'separator') {
?>
<div class="channel-item-container separator-item d-flex align-items-center justify-content-between px-2 py-1" data-id="<?php echo $c['id']; ?>" data-type="separator" style="min-height: 24px;">
@ -405,43 +507,57 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
return;
}
?>
<div class="channel-item-container d-flex align-items-center" data-id="<?php echo $c['id']; ?>">
<a href="?server_id=<?php echo $active_server_id; ?>&channel_id=<?php echo $c['id']; ?>"
class="channel-item flex-grow-1 <?php echo ($c['id'] == $active_channel_id) ? 'active' : ''; ?> <?php echo ($c['type'] === 'voice') ? 'voice-item' : ''; ?>" <?php echo ($c['type'] === 'voice') ? 'data-channel-id="'.$c['id'].'"' : ''; ?>>
<span class="d-flex align-items-center">
<span class="me-1" style="width: 20px; display: inline-block; text-align: center;">
<?php
if ($c['type'] === 'announcement') echo '<i class="fa-solid fa-bullhorn"></i>';
elseif ($c['type'] === 'rules') echo '<i class="fa-solid fa-gavel"></i>';
elseif ($c['type'] === 'autorole') echo '<i class="fa-solid fa-shield-halved"></i>';
elseif ($c['type'] === 'forum') echo '<i class="fa-solid fa-comments"></i>';
elseif ($c['type'] === 'voice') echo '<i class="fa-solid fa-volume-up"></i>';
else echo '<i class="fa-solid fa-hashtag"></i>';
?>
<div class="channel-item-container" data-id="<?php echo $c['id']; ?>">
<div class="d-flex align-items-center">
<a href="?server_id=<?php echo $active_server_id; ?>&channel_id=<?php echo $c['id']; ?>"
class="channel-item flex-grow-1 <?php echo ($c['id'] == $active_channel_id) ? 'active' : ''; ?> <?php echo ($c['type'] === 'voice') ? 'voice-item' : ''; ?>" <?php echo ($c['type'] === 'voice') ? 'data-channel-id="'.$c['id'].'"' : ''; ?>>
<span class="d-flex align-items-center">
<span class="me-1" style="width: 20px; display: inline-block; text-align: center;">
<?php
if ($c['type'] === 'announcement') echo '<i class="fa-solid fa-bullhorn"></i>';
elseif ($c['type'] === 'rules') echo '<i class="fa-solid fa-gavel"></i>';
elseif ($c['type'] === 'autorole') echo '<i class="fa-solid fa-shield-halved"></i>';
elseif ($c['type'] === 'forum') echo '<i class="fa-solid fa-comments"></i>';
elseif ($c['type'] === 'voice') echo '<i class="fa-solid fa-volume-up"></i>';
else echo '<i class="fa-solid fa-hashtag"></i>';
?>
</span>
<?php if (!empty($c['icon'])): ?>
<span class="me-1" style="font-size: 14px;"><?php echo renderRoleIcon($c['icon'], '14px'); ?></span>
<?php endif; ?>
<span class="channel-name-text"><?php echo htmlspecialchars($c['name']); ?></span>
</span>
<?php if (!empty($c['icon'])): ?>
<span class="me-1" style="font-size: 14px;"><?php echo renderRoleIcon($c['icon'], '14px'); ?></span>
<?php if ($c['type'] === 'voice' && !empty($c['status'])): ?>
<div class="channel-status small text-muted ms-4" style="font-size: 0.75em; margin-top: -2px;"><?php echo htmlspecialchars($c['status']); ?></div>
<?php endif; ?>
<span class="channel-name-text"><?php echo htmlspecialchars($c['name']); ?></span>
</span>
<?php if ($c['type'] === 'voice' && !empty($c['status'])): ?>
<div class="channel-status small text-muted ms-4" style="font-size: 0.75em; margin-top: -2px;"><?php echo htmlspecialchars($c['status']); ?></div>
</a>
<?php if ($can_manage_channels): ?>
<span class="channel-settings-btn ms-1" style="cursor: pointer; color: var(--text-muted);"
data-bs-toggle="modal" data-bs-target="#editChannelModal"
data-id="<?php echo $c['id']; ?>"
data-name="<?php echo htmlspecialchars($c['name']); ?>"
data-type="<?php echo $c['type']; ?>"
data-files="<?php echo $c['allow_file_sharing']; ?>"
data-limit="<?php echo $c['message_limit']; ?>"
data-status="<?php echo htmlspecialchars($c['status'] ?? ''); ?>"
data-icon="<?php echo htmlspecialchars($c['icon'] ?? ''); ?>"
data-rules-role="<?php echo $c['rules_role_id'] ?? ''; ?>"
data-category="<?php echo $c['category_id'] ?? ''; ?>">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33 1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
</span>
<?php endif; ?>
</a>
<?php if ($can_manage_channels): ?>
<span class="channel-settings-btn ms-1" style="cursor: pointer; color: var(--text-muted);"
data-bs-toggle="modal" data-bs-target="#editChannelModal"
data-id="<?php echo $c['id']; ?>"
data-name="<?php echo htmlspecialchars($c['name']); ?>"
data-type="<?php echo $c['type']; ?>"
data-files="<?php echo $c['allow_file_sharing']; ?>"
data-limit="<?php echo $c['message_limit']; ?>"
data-status="<?php echo htmlspecialchars($c['status'] ?? ''); ?>"
data-icon="<?php echo htmlspecialchars($c['icon'] ?? ''); ?>"
data-rules-role="<?php echo $c['rules_role_id'] ?? ''; ?>"
data-category="<?php echo $c['category_id'] ?? ''; ?>">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33 1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
</span>
</div>
<?php if ($c['type'] === 'voice'): ?>
<div class="voice-users-list ms-4 mb-1">
<?php if (isset($voice_users_by_channel[$c['id']])): ?>
<?php foreach($voice_users_by_channel[$c['id']] as $v_user): ?>
<div class="voice-user small text-muted d-flex align-items-center mb-1" data-user-id="<?php echo $v_user['user_id']; ?>">
<div class="message-avatar me-2" style="width: 16px; height: 16px; <?php echo $v_user['avatar_url'] ? "background-image: url('{$v_user['avatar_url']}');" : ""; ?>"></div>
<span><?php echo htmlspecialchars($v_user['display_name'] ?? $v_user['username']); ?></span>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php
@ -509,9 +625,9 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
<div class="message-avatar" style="width: 32px; height: 32px; <?php echo $user['avatar_url'] ? "background-image: url('{$user['avatar_url']}');" : ""; ?>"></div>
<div style="flex: 1; min-width: 0;">
<div style="font-weight: bold; font-size: 0.85em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<?php echo htmlspecialchars($user['username']); ?>
<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>
</div>
<div style="color: var(--text-muted); font-size: 0.75em;">#<?php echo str_pad($user['id'], 4, '0', STR_PAD_LEFT); ?></div>
<div style="color: var(--text-muted); font-size: 0.75em;">@<?php echo htmlspecialchars($user['username']); ?> #<?php echo str_pad($user['id'], 4, '0', STR_PAD_LEFT); ?></div>
</div>
</div>
<div class="user-actions">
@ -547,7 +663,7 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
}
?>
</span>
<span class="flex-grow-1"><?php echo htmlspecialchars($current_channel_name); ?></span>
<span class="flex-grow-1 text-truncate me-2" style="min-width: 0;"><?php echo htmlspecialchars($current_channel_name); ?></span>
<div class="d-flex align-items-center">
<button id="toggle-members-btn" class="btn btn-link text-muted p-1 me-2" title="Toggle Members List">
@ -590,7 +706,7 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
$is_solution = ($active_thread['solution_message_id'] == $m['id']);
?>
<!-- Message rendering code (reused) -->
<div class="message-item <?php echo $is_mentioned ? 'mentioned' : ''; ?> <?php echo $is_solution ? 'is-solution' : ''; ?>" data-id="<?php echo $m['id']; ?>">
<div class="message-item <?php echo $is_mentioned ? 'mentioned' : ''; ?> <?php echo $is_solution ? 'is-solution' : ''; ?>" data-id="<?php echo $m['id']; ?>" data-raw-content="<?php echo htmlspecialchars($m['content']); ?>">
<div class="message-avatar" style="<?php echo $m['avatar_url'] ? "background-image: url('{$m['avatar_url']}');" : ""; ?>"></div>
<div class="message-content">
<div class="message-author" style="<?php echo !empty($m['role_color']) ? "color: {$m['role_color']};" : ""; ?>">
@ -606,10 +722,18 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>
</span>
<?php endif; ?>
<?php if ($m['user_id'] == $current_user_id): ?>
<span class="action-btn edit" title="Edit" data-id="<?php echo $m['id']; ?>">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
</span>
<span class="action-btn delete" title="Delete" data-id="<?php echo $m['id']; ?>">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
</span>
<?php endif; ?>
</div>
</div>
<div class="message-text">
<?php echo nl2br(htmlspecialchars($m['content'])); ?>
<?php echo parse_emotes($m['content'], $user['username']); ?>
</div>
<div class="message-reactions mt-1" data-message-id="<?php echo $m['id']; ?>">
<?php
@ -639,7 +763,7 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
<div class="rule-item mb-3 p-3 rounded bg-dark border-start border-4 border-primary d-flex justify-content-between align-items-center" data-id="<?php echo $rule['id']; ?>">
<div class="rule-content flex-grow-1">
<span class="rule-number fw-bold me-2"><?php echo $i++; ?>.</span>
<?php echo nl2br(htmlspecialchars($rule['content'])); ?>
<?php echo parse_emotes($rule['content']); ?>
</div>
<?php if($can_manage_channels): ?>
<div class="rule-actions ms-3 d-flex gap-2">
@ -814,7 +938,7 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
$mention_pattern = '/@' . preg_quote($user['username'], '/') . '\b/';
$is_mentioned = preg_match($mention_pattern, $m['content']);
?>
<div class="message-item <?php echo $is_mentioned ? 'mentioned' : ''; ?> <?php echo $m['is_pinned'] ? 'pinned' : ''; ?> <?php echo $channel_type === 'announcement' ? 'announcement-style' : ''; ?>" data-id="<?php echo $m['id']; ?>">
<div class="message-item <?php echo $is_mentioned ? 'mentioned' : ''; ?> <?php echo $m['is_pinned'] ? 'pinned' : ''; ?> <?php echo $channel_type === 'announcement' ? 'announcement-style' : ''; ?>" data-id="<?php echo $m['id']; ?>" data-raw-content="<?php echo htmlspecialchars($m['content']); ?>">
<div class="message-avatar" style="<?php echo $m['avatar_url'] ? "background-image: url('{$m['avatar_url']}');" : ""; ?>"></div>
<div class="message-content">
<div class="message-header">
@ -831,22 +955,7 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
<?php endif; ?>
</div>
<div class="message-text">
<?php
$msg_content = htmlspecialchars($m['content']);
$msg_content = preg_replace($mention_pattern, '<span class="mention">@' . htmlspecialchars($user['username']) . '</span>', $msg_content);
// Custom Emotes parsing
static $custom_emotes_cache;
if ($custom_emotes_cache === null) {
$custom_emotes_cache = db()->query("SELECT name, path, code FROM custom_emotes")->fetchAll();
}
foreach ($custom_emotes_cache as $ce) {
$emote_html = '<img src="' . htmlspecialchars($ce['path']) . '" alt="' . htmlspecialchars($ce['name']) . '" title="' . htmlspecialchars($ce['code']) . '" style="width: 24px; height: 24px; vertical-align: middle; object-fit: contain;">';
$msg_content = str_replace($ce['code'], $emote_html, $msg_content);
}
echo nl2br($msg_content);
?>
<?php echo parse_emotes($m['content'], $user['username']); ?>
<?php if ($m['attachment_url']): ?>
<div class="message-attachment mt-2">
<?php
@ -976,7 +1085,7 @@ $emote_html = '<img src="' . htmlspecialchars($ce['path']) . '" alt="' . htmlspe
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="8" y1="8" x2="16" y2="16"></line><line x1="16" y1="8" x2="8" y2="16"></line></svg>
</div>
<?php endif; ?>
<input type="text" id="chat-input" class="chat-input" placeholder="Message #<?php echo htmlspecialchars($current_channel_name); ?>" autocomplete="off">
<textarea id="chat-input" class="chat-input" placeholder="Message #<?php echo htmlspecialchars($current_channel_name); ?>" autocomplete="off" rows="1"></textarea>
<button type="button" class="btn border-0 text-muted p-2" id="chat-emoji-btn" title="Emoji Picker">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M8 14s1.5 2 4 2 4-2 4-2"></path><line x1="9" y1="9" x2="9.01" y2="9"></line><line x1="15" y1="9" x2="15.01" y2="9"></line></svg>
</button>
@ -1010,81 +1119,263 @@ $emote_html = '<img src="' . htmlspecialchars($ce['path']) . '" alt="' . htmlspe
<!-- User Settings Modal -->
<div class="modal fade" id="userSettingsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">User Settings</h5>
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content border-0 shadow-lg" style="background-color: #313338; min-height: 500px;">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-bold">User Settings</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="user-settings-form">
<div class="row">
<div class="col-md-4 text-center">
<div class="message-avatar mx-auto mb-3" id="settings-avatar-preview" style="width: 100px; height: 100px; <?php echo $user['avatar_url'] ? "background-image: url('{$user['avatar_url']}');" : ""; ?>"></div>
<input type="hidden" name="avatar_url" id="settings-avatar-url" value="<?php echo htmlspecialchars($user['avatar_url'] ?? ''); ?>">
<p class="small text-muted">Pick an avatar from Pexels or search for one.</p>
</div>
<div class="col-md-8">
<div class="mb-3">
<label class="form-label text-uppercase fw-bold" style="font-size: 0.7em; color: var(--text-muted);">Username</label>
<input type="text" name="username" class="form-control" value="<?php echo htmlspecialchars($user['username']); ?>" required>
</div>
<div class="mb-3">
<label class="form-label text-uppercase fw-bold" style="font-size: 0.7em; color: var(--text-muted);">Settings</label>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" name="dnd_mode" id="dnd-switch" value="1" <?php echo ($user['dnd_mode'] ?? 0) ? 'checked' : ''; ?>>
<label class="form-check-label text-white" for="dnd-switch">Do Not Disturb</label>
<div class="form-text text-muted" style="font-size: 0.8em;">Mute all desktop notifications.</div>
<div class="modal-body p-0">
<div class="d-flex flex-row h-100" style="min-height: 450px;">
<!-- Settings Sidebar -->
<div class="settings-nav-sidebar p-3 border-end border-secondary" style="width: 200px; background-color: #2b2d31;">
<ul class="nav flex-column nav-pills" id="userSettingsTabs" role="tablist">
<li class="nav-item mb-1">
<button class="nav-link active w-100 text-start text-white border-0 py-2 px-3" data-bs-toggle="pill" data-bs-target="#settings-profile" type="button">My Profile</button>
</li>
<li class="nav-item mb-1">
<button class="nav-link w-100 text-start text-white border-0 py-2 px-3" data-bs-toggle="pill" data-bs-target="#settings-appearance" type="button">Appearance</button>
</li>
<li class="nav-item mb-1">
<button class="nav-link w-100 text-start text-white border-0 py-2 px-3" data-bs-toggle="pill" data-bs-target="#settings-voice" type="button">Voice & Video</button>
</li>
<li class="nav-item mb-1">
<button class="nav-link w-100 text-start text-white border-0 py-2 px-3" data-bs-toggle="pill" data-bs-target="#settings-notifications" type="button">Notifications</button>
</li>
</ul>
</div>
<!-- Settings Content -->
<div class="flex-grow-1 p-4 overflow-auto custom-scrollbar">
<form id="user-settings-form">
<div class="tab-content" id="userSettingsContent">
<!-- Profile Tab -->
<div class="tab-pane fade show active" id="settings-profile" role="tabpanel">
<h5 class="mb-4 fw-bold text-uppercase" style="font-size: 0.8em; color: var(--text-muted);">User Profile</h5>
<div class="row align-items-center mb-4 p-3 rounded" style="background-color: #232428;">
<div class="col-md-3 text-center">
<div class="message-avatar mx-auto mb-2" id="settings-avatar-preview" style="width: 80px; height: 80px; <?php echo $user['avatar_url'] ? "background-image: url('{$user['avatar_url']}');" : ""; ?>"></div>
<input type="hidden" name="avatar_url" id="settings-avatar-url" value="<?php echo htmlspecialchars($user['avatar_url'] ?? ''); ?>">
</div>
<div class="col-md-9">
<div class="mb-3">
<label class="form-label text-uppercase fw-bold" style="font-size: 0.7em; color: var(--text-muted);">Username display</label>
<input type="text" name="display_name" class="form-control bg-dark text-white border-0" value="<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>" required>
</div>
</div>
</div>
<div class="mb-4">
<label class="form-label text-uppercase fw-bold" style="font-size: 0.7em; color: var(--text-muted);">Search Avatars</label>
<div class="input-group mb-2 shadow-sm">
<input type="text" id="avatar-search-query" class="form-control bg-dark border-0 text-white" placeholder="e.g. cat, abstract, gamer">
<button class="btn btn-primary px-3" type="button" id="search-avatar-btn">Search</button>
</div>
<div id="avatar-results" class="d-flex flex-wrap gap-2 overflow-auto p-2 rounded" style="max-height: 180px; background-color: #1e1f22;">
<div class="w-100 text-center text-muted py-3 small">Search for images to change your avatar.</div>
</div>
</div>
</div>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" name="sound_notifications" id="sound-switch" value="1" <?php echo ($user['sound_notifications'] ?? 0) ? 'checked' : ''; ?>>
<label class="form-check-label text-white" for="sound-switch">Sound Notifications</label>
<div class="form-text text-muted" style="font-size: 0.8em;">Play a sound when you are mentioned.</div>
</div>
<div class="mb-3">
<label class="form-label text-uppercase fw-bold" style="font-size: 0.7em; color: var(--text-muted);">Appearance</label>
<div class="row g-2">
<!-- Appearance Tab -->
<div class="tab-pane fade" id="settings-appearance" role="tabpanel">
<h5 class="mb-4 fw-bold text-uppercase" style="font-size: 0.8em; color: var(--text-muted);">Appearance Settings</h5>
<div class="row g-3">
<div class="col-6">
<input type="radio" class="btn-check" name="theme" id="theme-dark" value="dark" <?php echo ($user['theme'] ?? 'dark') == 'dark' ? 'checked' : ''; ?> onchange="document.body.setAttribute('data-theme', 'dark')">
<label class="btn btn-outline-secondary w-100 py-3" for="theme-dark">
<i class="fa-solid fa-moon d-block mb-1"></i>
Dark
<label class="btn btn-outline-secondary w-100 py-4 d-flex flex-column align-items-center" for="theme-dark">
<i class="fa-solid fa-moon mb-2 fs-3"></i>
<span>Dark Theme</span>
</label>
</div>
<div class="col-6">
<input type="radio" class="btn-check" name="theme" id="theme-light" value="light" <?php echo ($user['theme'] ?? 'dark') == 'light' ? 'checked' : ''; ?> onchange="document.body.setAttribute('data-theme', 'light')">
<label class="btn btn-outline-secondary w-100 py-3" for="theme-light">
<i class="fa-solid fa-sun d-block mb-1"></i>
Light
<label class="btn btn-outline-secondary w-100 py-4 d-flex flex-column align-items-center" for="theme-light">
<i class="fa-solid fa-sun mb-2 fs-3"></i>
<span>Light Theme</span>
</label>
</div>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label text-uppercase fw-bold" style="font-size: 0.7em; color: var(--text-muted);">Search Avatars</label>
<div class="input-group mb-2">
<input type="text" id="avatar-search-query" class="form-control" placeholder="e.g. cat, abstract, gamer">
<button class="btn btn-outline-secondary" type="button" id="search-avatar-btn">Search</button>
</div>
<div id="avatar-results" class="d-flex flex-wrap gap-2 overflow-auto" style="max-height: 200px;">
<!-- Pexels results here -->
</div>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<!-- Voice Tab -->
<div class="tab-pane fade" id="settings-voice" role="tabpanel">
<h5 class="mb-4 fw-bold text-uppercase" style="font-size: 0.8em; color: var(--text-muted);">Voice Settings</h5>
<div class="p-3 rounded mb-4" style="background-color: #232428;">
<label class="form-label text-uppercase fw-bold mb-3" style="font-size: 0.7em; color: var(--text-muted);">Input Mode</label>
<div class="d-flex gap-3 mb-4">
<div class="form-check custom-radio-card flex-grow-1">
<input class="form-check-input d-none" type="radio" name="voice_mode" id="voice-mode-vox" value="vox" <?php echo ($user['voice_mode'] ?? 'vox') == 'vox' ? 'checked' : ''; ?> onchange="togglePTTSettings('vox')">
<label class="form-check-label w-100 p-3 rounded border border-secondary text-center cursor-pointer" for="voice-mode-vox" style="cursor: pointer;">
<i class="fa-solid fa-microphone mb-2 d-block"></i>
Voice Activity
</label>
</div>
<div class="form-check custom-radio-card flex-grow-1">
<input class="form-check-input d-none" type="radio" name="voice_mode" id="voice-mode-ptt" value="ptt" <?php echo ($user['voice_mode'] ?? 'vox') == 'ptt' ? 'checked' : ''; ?> onchange="togglePTTSettings('ptt')">
<label class="form-check-label w-100 p-3 rounded border border-secondary text-center cursor-pointer" for="voice-mode-ptt" style="cursor: pointer;">
<i class="fa-solid fa-keyboard mb-2 d-block"></i>
Push to Talk
</label>
</div>
</div>
<div id="ptt-settings-container" style="<?php echo ($user['voice_mode'] ?? 'vox') == 'ptt' ? '' : 'display: none;'; ?>">
<div class="mb-3">
<label class="form-label small text-white fw-bold">Shortcut Key</label>
<input type="text" name="voice_ptt_key" id="voice_ptt_key_input" class="form-control bg-dark text-white border-0" value="<?php echo htmlspecialchars($user['voice_ptt_key'] ?? 'v'); ?>" placeholder="Click and press a key..." readonly style="cursor: pointer; caret-color: transparent;">
<div class="form-text text-muted" style="font-size: 0.8em;">Click the box and press any key to set your PTT shortcut.</div>
</div>
</div>
<div id="vox-settings-container" style="<?php echo ($user['voice_mode'] ?? 'vox') == 'vox' ? '' : 'display: none;'; ?>">
<div class="mb-3">
<label class="form-label small text-white fw-bold">Input Sensitivity</label>
<div class="voice-meter-container mb-2" style="height: 8px; background: #1e1f22; border-radius: 4px; overflow: hidden; position: relative;">
<div id="voice-meter-bar" style="height: 100%; width: 0%; background: #23a559; transition: width 0.1s;"></div>
<div id="voice-meter-threshold" style="position: absolute; top: 0; bottom: 0; width: 2px; background: #f23f43; z-index: 2;"></div>
</div>
<input type="range" name="voice_vox_threshold" id="vox_threshold_input" class="form-range" min="0" max="1" step="0.01" value="<?php echo $user['voice_vox_threshold'] ?? 0.1; ?>">
<div class="d-flex justify-content-between small text-muted mt-1">
<span>Sensitive</span>
<span>Loud Only</span>
</div>
</div>
</div>
</div>
<div class="p-3 rounded border border-info border-opacity-25" style="background-color: rgba(0, 168, 252, 0.05);">
<div class="d-flex">
<i class="fa-solid fa-circle-info text-info me-3 mt-1"></i>
<div>
<div class="fw-bold text-info small mb-1">Microphone Access</div>
<div class="text-muted small">Voice channels require microphone permission. If you don't hear anything, check your browser's site settings.</div>
</div>
</div>
</div>
</div>
<!-- Notifications Tab -->
<div class="tab-pane fade" id="settings-notifications" role="tabpanel">
<h5 class="mb-4 fw-bold text-uppercase" style="font-size: 0.8em; color: var(--text-muted);">Notifications</h5>
<div class="p-3 rounded" style="background-color: #232428;">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" name="dnd_mode" id="dnd-switch" value="1" <?php echo ($user['dnd_mode'] ?? 0) ? 'checked' : ''; ?>>
<label class="form-check-label text-white" for="dnd-switch">Do Not Disturb</label>
<div class="form-text text-muted" style="font-size: 0.8em;">Mute all desktop notifications.</div>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="sound_notifications" id="sound-switch" value="1" <?php echo ($user['sound_notifications'] ?? 0) ? 'checked' : ''; ?>>
<label class="form-check-label text-white" for="sound-switch">Sound Notifications</label>
<div class="form-text text-muted" style="font-size: 0.8em;">Play a sound when you are mentioned.</div>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="modal-footer border-0" style="background-color: #2b2d31;">
<button type="button" class="btn btn-link text-white text-decoration-none" data-bs-dismiss="modal">Cancel</button>
<button type="button" onclick="handleSaveUserSettings(this)" class="btn btn-primary" style="background-color: var(--blurple); border: none; padding: 10px 24px;">Save Changes</button>
<button type="button" onclick="handleSaveUserSettings(this)" class="btn btn-primary" style="background-color: var(--blurple); border: none; padding: 10px 32px; font-weight: 600;">Save Changes</button>
</div>
</div>
</div>
</div>
<style>
.custom-radio-card input:checked + label {
background-color: var(--blurple) !important;
border-color: var(--blurple) !important;
color: white !important;
}
.custom-radio-card label:hover {
background-color: rgba(255,255,255,0.05);
}
.cursor-pointer { cursor: pointer; }
</style>
<script>
function togglePTTSettings(mode) {
console.log('Toggling voice mode to:', mode);
const pttContainer = document.getElementById('ptt-settings-container');
const voxContainer = document.getElementById('vox-settings-container');
if (pttContainer) pttContainer.style.display = (mode === 'ptt' ? 'block' : 'none');
if (voxContainer) voxContainer.style.display = (mode === 'vox' ? 'block' : 'none');
}
// Special handler for PTT key input to make it more intuitive
document.addEventListener('DOMContentLoaded', () => {
const pttInput = document.getElementById('voice_ptt_key_input');
if (pttInput) {
pttInput.addEventListener('keydown', (e) => {
e.preventDefault();
pttInput.value = e.key;
});
}
// Voice meter update
const voxThresholdInput = document.getElementById('vox_threshold_input');
const meterThreshold = document.getElementById('voice-meter-threshold');
const meterBar = document.getElementById('voice-meter-bar');
// Handle voice tab activation for mic preview
const voiceTabBtn = document.querySelector('[data-bs-target="#settings-voice"]');
if (voiceTabBtn) {
voiceTabBtn.addEventListener('shown.bs.tab', async () => {
if (window.voiceHandler) {
if (!window.voiceHandler.localStream) {
try {
console.log('Voice tab active, requesting mic for preview...');
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
window.voiceHandler.localStream = stream;
window.voiceHandler.setupVOX();
} catch (e) {
console.error('Failed to get mic for preview:', e);
}
} else {
console.log('Voice tab active, using existing localStream for preview');
window.voiceHandler.setupVOX();
}
}
});
}
if (voxThresholdInput && meterThreshold) {
const updateThresholdPos = () => {
// Threshold input is 0 (loud) to 1 (quiet)
// But actually 1.0 means high threshold, so quiet needs more voice
// 0.1 means low threshold, easy to trigger.
// Meter is 0 to 100%.
meterThreshold.style.left = (voxThresholdInput.value * 100) + '%';
};
voxThresholdInput.addEventListener('input', updateThresholdPos);
updateThresholdPos();
}
setInterval(() => {
if (window.voiceHandler && meterBar && document.getElementById('settings-voice').classList.contains('active')) {
const volume = window.voiceHandler.getVolume(); // 0 to 1
meterBar.style.width = (volume * 100) + '%';
// Color feedback
const threshold = parseFloat(voxThresholdInput.value);
if (volume > threshold) {
meterBar.style.backgroundColor = '#23a559'; // Green
} else {
meterBar.style.backgroundColor = '#4f545c'; // Grey
}
}
}, 50);
});
function handlePTTKeyCapture(e, input) {
e.preventDefault();
input.value = e.key;
}
async function handleSaveUserSettings(btn) {
const originalContent = btn.innerHTML;
const form = document.getElementById('user-settings-form');
@ -1103,23 +1394,53 @@ async function handleSaveUserSettings(btn) {
formData.set('dnd_mode', dndMode);
formData.set('sound_notifications', soundNotifications);
// Explicitly get theme to ensure it's captured
// Explicitly get theme and voice_mode to ensure they are captured
const themeInput = form.querySelector('input[name="theme"]:checked');
const theme = themeInput ? themeInput.value : 'dark';
formData.set('theme', theme);
if (themeInput) formData.set('theme', themeInput.value);
const voiceModeInput = form.querySelector('input[name="voice_mode"]:checked');
if (voiceModeInput) formData.set('voice_mode', voiceModeInput.value);
try {
console.log('Sending save request...');
const resp = await fetch('api_v1_user.php?v=' + Date.now(), {
method: 'POST',
body: formData
});
const result = await resp.json();
console.log('Response received:', result);
if (result.success) {
btn.innerHTML = '<i class="fa-solid fa-check me-2"></i> Saved!';
setTimeout(() => window.location.reload(), 500);
// Update current username in window
if (formData.get('display_name')) {
window.currentUsername = formData.get('display_name');
}
// Update local voiceHandler settings without reload
if (window.voiceHandler) {
const mode = formData.get('voice_mode');
const pttKey = formData.get('voice_ptt_key');
const voxThreshold = parseFloat(formData.get('voice_vox_threshold'));
window.voiceHandler.settings.mode = mode;
window.voiceHandler.settings.pttKey = pttKey;
window.voiceHandler.settings.voxThreshold = voxThreshold;
console.log('Voice settings updated locally:', window.voiceHandler.settings);
if (mode === 'vox' && !window.voiceHandler.audioContext) {
window.voiceHandler.setupVOX();
}
window.voiceHandler.updateVoiceUI();
}
setTimeout(() => {
btn.innerHTML = originalContent;
btn.disabled = false;
// Optional: close modal after save?
// bootstrap.Modal.getInstance(document.getElementById('userSettingsModal')).hide();
}, 1500);
} else {
alert('Error: ' + (result.error || 'Unknown error'));
btn.disabled = false;
@ -1134,6 +1455,7 @@ async function handleSaveUserSettings(btn) {
}
</script>
<!-- Server Settings Modal -->
<!-- Server Settings Modal -->
<div class="modal fade" id="serverSettingsModal" tabindex="-1">
@ -1684,7 +2006,6 @@ async function handleSaveUserSettings(btn) {
<ul class="dropdown-menu dropdown-menu-dark shadow border-secondary" id="add-permission-role-list" style="max-height: 300px; overflow-y: auto; min-width: 200px;">
<!-- Roles loaded here -->
</ul>
</div>
</div>
<div id="channel-permissions-roles-list" class="list-group list-group-flush overflow-auto flex-grow-1" style="max-height: 350px; overflow-x: hidden;">
<!-- List of roles with overrides -->
@ -1905,14 +2226,19 @@ async function handleSaveUserSettings(btn) {
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
window.currentUserId = <?php echo $current_user_id; ?>;
window.currentUsername = "<?php echo addslashes($user['username']); ?>";
window.currentChannelName = "<?php echo addslashes($current_channel_name); ?>";
window.isServerOwner = <?php echo ($is_owner ?? false) ? 'true' : 'false'; ?>;
window.canManageServer = <?php echo ($can_manage_server ?? false) ? 'true' : 'false'; ?>;
window.isDndMode = <?php echo ($user['dnd_mode'] ?? 0) ? 'true' : 'false'; ?>;
</script>
<script>
window.currentUserId = <?php echo $current_user_id; ?>;
window.currentUsername = '<?php echo addslashes($user['display_name'] ?? $user['username']); ?>';
window.currentAvatarUrl = '<?php echo addslashes($user['avatar_url'] ?? ''); ?>';
window.activeChannelId = <?php echo $active_channel_id; ?>;
window.serverRoles = <?php echo json_encode($server_roles ?? []); ?>;
window.voiceSettings = {
mode: '<?php echo $user['voice_mode'] ?? 'vox'; ?>',
pttKey: '<?php echo $user['voice_ptt_key'] ?? 'v'; ?>',
voxThreshold: <?php echo $user['voice_vox_threshold'] ?? 0.1; ?>
};
</script>
<script src="assets/js/voice.js?v=<?php echo time(); ?>"></script>
<script src="assets/js/main.js?v=<?php echo time(); ?>"></script>
<script>

View File

@ -424,3 +424,211 @@
2026-02-17 00:34:40 - GET /index.php?server_id=1 - POST: []
2026-02-17 00:34:45 - GET /index.php?server_id=1&channel_id=20 - POST: []
2026-02-17 00:35:23 - GET /index.php?server_id=1&channel_id=20 - POST: []
2026-02-17 00:43:23 - - POST: []
2026-02-17 00:44:01 - GET /index.php - POST: []
2026-02-17 00:44:15 - GET /?fl_project=38443 - POST: []
2026-02-17 00:45:12 - GET /index.php?server_id=1&channel_id=1 - POST: []
2026-02-17 00:45:14 - GET /index.php?server_id=1&channel_id=20 - POST: []
2026-02-17 00:45:21 - GET /index.php?server_id=1&channel_id=15 - POST: []
2026-02-17 00:45:23 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 00:48:04 - GET / - POST: []
2026-02-17 00:48:39 - GET /?fl_project=38443 - POST: []
2026-02-17 00:48:50 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 00:57:46 - GET / - POST: []
2026-02-17 00:58:08 - GET /?fl_project=38443 - POST: []
2026-02-17 00:58:11 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 00:58:14 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 01:02:05 - GET / - POST: []
2026-02-17 01:02:20 - GET /?fl_project=38443 - POST: []
2026-02-17 01:02:36 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 08:06:32 - GET /index.php?server_id=1&channel_id=20 - POST: []
2026-02-17 08:06:36 - GET /index.php - POST: []
2026-02-17 08:06:39 - GET /index.php?server_id=1&channel_id=20 - POST: []
2026-02-17 08:10:08 - GET /?fl_project=38443 - POST: []
2026-02-17 08:15:51 - GET /?fl_project=38443 - POST: []
2026-02-17 08:17:13 - GET /?fl_project=38443 - POST: []
2026-02-17 08:18:43 - GET /index.php?server_id=1&channel_id=20 - POST: []
2026-02-17 08:19:02 - GET /index.php?server_id=1&channel_id=21 - POST: []
2026-02-17 08:19:06 - GET /index.php?server_id=1&channel_id=20 - POST: []
2026-02-17 08:19:16 - GET /index.php?server_id=1 - POST: []
2026-02-17 08:19:29 - GET /index.php?server_id=1&channel_id=21 - POST: []
2026-02-17 08:20:44 - GET /index.php?server_id=1&channel_id=17 - POST: []
2026-02-17 08:20:46 - GET /index.php?server_id=1&channel_id=11 - POST: []
2026-02-17 08:20:48 - GET /index.php?server_id=1&channel_id=21 - POST: []
2026-02-17 08:27:06 - GET /?fl_project=38443 - POST: []
2026-02-17 08:31:06 - GET / - POST: []
2026-02-17 08:31:24 - GET /?fl_project=38443 - POST: []
2026-02-17 08:46:59 - GET /index.php?server_id=1&channel_id=21 - POST: []
{"date":"2026-02-17 08:47:14","method":"POST","post":{"avatar_url":"","display_name":"swefpifh\u00b2","dnd_mode":"1","sound_notifications":"1","theme":"dark"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 08:47:14 - GET /index.php?server_id=1&channel_id=21 - POST: []
2026-02-17 08:47:19 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 09:19:30 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 09:19:32 - GET /index.php - POST: []
{"date":"2026-02-17 09:20:03","method":"POST","post":{"avatar_url":"","display_name":"swefheim","theme":"light","dnd_mode":"0","sound_notifications":"0"},"session":{"user_id":3},"user_id":3,"db_success":true}
2026-02-17 09:20:03 - GET /index.php - POST: []
{"date":"2026-02-17 09:20:46","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","dnd_mode":"1","sound_notifications":"1","theme":"dark"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 09:20:47 - GET /index.php?server_id=1&channel_id=6 - POST: []
{"date":"2026-02-17 09:21:25","method":"POST","post":{"avatar_url":"","display_name":"\u1d47\u02b0\u1da0\u02b3 swefpifh","dnd_mode":"1","sound_notifications":"1","theme":"dark"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 09:21:25 - GET /index.php?server_id=1&channel_id=6 - POST: []
{"date":"2026-02-17 09:22:02","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","dnd_mode":"1","sound_notifications":"1","theme":"dark"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 09:22:02 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 09:22:13 - GET /index.php - POST: []
2026-02-17 09:22:18 - GET /index.php?server_id=1&channel_id=1 - POST: []
2026-02-17 09:22:20 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 09:22:25 - GET /index.php?server_id=1&channel_id=15 - POST: []
2026-02-17 09:22:29 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 09:36:55 - GET /?fl_project=38443 - POST: []
2026-02-17 09:48:03 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 09:56:28 - GET /?fl_project=38443 - POST: []
2026-02-17 09:57:00 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 11:37:20 - GET /index.php?server_id=1&channel_id=21 - POST: []
2026-02-17 11:43:18 - GET /?fl_project=38443 - POST: []
2026-02-17 11:43:46 - GET /?fl_project=38443 - POST: []
2026-02-17 11:46:49 - GET /index.php?server_id=1&channel_id=22 - POST: []
2026-02-17 11:46:58 - GET /index.php?server_id=1&channel_id=3 - POST: []
2026-02-17 11:49:14 - GET /?fl_project=38443 - POST: []
2026-02-17 11:56:37 - GET /?fl_project=38443 - POST: []
2026-02-17 11:56:49 - GET /index.php?server_id=1&channel_id=3 - POST: []
2026-02-17 11:56:58 - GET /index.php?server_id=1&channel_id=3 - POST: []
2026-02-17 11:57:08 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 11:57:14 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 12:01:03 - GET /?fl_project=38443 - POST: []
2026-02-17 12:08:31 - GET /?fl_project=38443 - POST: []
2026-02-17 12:08:36 - GET /index.php?server_id=1&channel_id=6 - POST: []
{"date":"2026-02-17 12:09:05","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"ptt","voice_ptt_key":"0","voice_vox_threshold":"0.1","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 12:09:05 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 12:14:02 - GET /?fl_project=38443 - POST: []
2026-02-17 12:14:47 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 12:15:05 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 12:18:43 - GET /?fl_project=38443 - POST: []
2026-02-17 12:22:00 - GET /?fl_project=38443 - POST: []
2026-02-17 12:23:42 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 12:24:23 - GET /?fl_project=38443 - POST: []
2026-02-17 12:25:06 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 12:25:50 - GET /?fl_project=38443 - POST: []
2026-02-17 12:27:17 - GET /?fl_project=38443 - POST: []
2026-02-17 12:28:56 - GET /?fl_project=38443 - POST: []
2026-02-17 12:32:39 - GET /?fl_project=38443 - POST: []
2026-02-17 12:34:47 - GET /?fl_project=38443 - POST: []
2026-02-17 12:38:46 - GET /?fl_project=38443 - POST: []
2026-02-17 12:44:53 - GET /?fl_project=38443 - POST: []
2026-02-17 12:45:22 - GET /?fl_project=38443 - POST: []
2026-02-17 12:50:07 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 12:51:03 - GET /?fl_project=38443 - POST: []
2026-02-17 13:29:32 - GET /?fl_project=38443 - POST: []
2026-02-17 13:34:34 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 13:35:23 - - POST: []
2026-02-17 13:35:28 - GET / - POST: []
2026-02-17 13:44:18 - GET /?fl_project=38443 - POST: []
2026-02-17 13:44:19 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 13:44:44 - GET /?fl_project=38443 - POST: []
2026-02-17 13:46:48 - GET /?fl_project=38443 - POST: []
2026-02-17 13:47:13 - GET /index.php - POST: []
2026-02-17 13:47:48 - GET /?fl_project=38443 - POST: []
2026-02-17 13:47:48 - GET /?fl_project=38443 - POST: []
2026-02-17 13:49:26 - GET /?fl_project=38443 - POST: []
2026-02-17 13:52:42 - GET /?fl_project=38443 - POST: []
2026-02-17 13:53:30 - GET /?fl_project=38443 - POST: []
2026-02-17 13:53:31 - GET /?fl_project=38443 - POST: []
2026-02-17 13:53:33 - GET /?fl_project=38443 - POST: []
2026-02-17 13:53:34 - GET /?fl_project=38443 - POST: []
2026-02-17 13:53:44 - GET /?fl_project=38443 - POST: []
2026-02-17 13:53:50 - GET /index.php - POST: []
2026-02-17 13:53:52 - GET /index.php - POST: []
2026-02-17 13:54:13 - GET /index.php?server_id=1&channel_id=15 - POST: []
2026-02-17 13:54:23 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 13:54:34 - GET /index.php?server_id=1&channel_id=3 - POST: []
2026-02-17 13:54:40 - GET /index.php?server_id=1&channel_id=22 - POST: []
{"date":"2026-02-17 13:54:53","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"ptt","voice_ptt_key":"0","voice_vox_threshold":"0.1","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 13:54:54 - GET /index.php?server_id=1&channel_id=22 - POST: []
2026-02-17 13:55:35 - GET /index.php?server_id=1&channel_id=6 - POST: []
{"date":"2026-02-17 13:56:01","method":"POST","post":{"avatar_url":"","display_name":"swefheim","theme":"light","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.1","dnd_mode":"0","sound_notifications":"0"},"session":{"user_id":3},"user_id":3,"db_success":true}
2026-02-17 13:56:02 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 13:56:22 - GET /?fl_project=38443 - POST: []
2026-02-17 13:56:32 - GET /index.php - POST: []
2026-02-17 14:03:44 - GET /?fl_project=38443 - POST: []
2026-02-17 14:03:45 - GET /?fl_project=38443 - POST: []
2026-02-17 14:04:57 - GET /index.php?server_id=1&channel_id=22 - POST: []
2026-02-17 14:05:10 - GET /index.php - POST: []
2026-02-17 14:05:34 - GET /index.php - POST: []
2026-02-17 14:05:36 - GET /index.php - POST: []
2026-02-17 14:12:18 - GET /index.php?server_id=1&channel_id=22 - POST: []
{"date":"2026-02-17 14:12:41","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.1","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 14:12:42 - GET /index.php?server_id=1&channel_id=22 - POST: []
2026-02-17 14:12:48 - GET /index.php?server_id=1&channel_id=22 - POST: []
{"date":"2026-02-17 14:12:58","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"ptt","voice_ptt_key":"0","voice_vox_threshold":"0.1","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 14:12:59 - GET /index.php?server_id=1&channel_id=22 - POST: []
2026-02-17 14:23:18 - GET / - POST: []
2026-02-17 14:23:53 - GET /?fl_project=38443 - POST: []
2026-02-17 14:23:54 - GET /?fl_project=38443 - POST: []
2026-02-17 14:34:38 - GET /index.php?server_id=1&channel_id=22 - POST: []
2026-02-17 14:34:58 - GET /index.php?server_id=1&channel_id=22 - POST: []
2026-02-17 14:35:14 - GET /index.php - POST: []
{"date":"2026-02-17 14:36:59","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.1","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 14:37:00 - GET /index.php?server_id=1&channel_id=22 - POST: []
2026-02-17 14:42:23 - GET /?fl_project=38443 - POST: []
2026-02-17 14:42:24 - GET /?fl_project=38443 - POST: []
2026-02-17 14:43:06 - GET /index.php?server_id=1&channel_id=22 - POST: []
{"date":"2026-02-17 14:43:18","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.1","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 14:43:19 - GET /index.php?server_id=1&channel_id=22 - POST: []
2026-02-17 14:43:51 - GET /index.php - POST: []
{"date":"2026-02-17 14:44:07","method":"POST","post":{"avatar_url":"","display_name":"swefheim","theme":"light","voice_mode":"ptt","voice_ptt_key":"0","voice_vox_threshold":"0.1","dnd_mode":"0","sound_notifications":"0"},"session":{"user_id":3},"user_id":3,"db_success":true}
2026-02-17 14:44:07 - GET /index.php - POST: []
{"date":"2026-02-17 14:44:43","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"ptt","voice_ptt_key":"v","voice_vox_threshold":"0.1","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 14:44:43 - GET /index.php?server_id=1&channel_id=22 - POST: []
{"date":"2026-02-17 14:45:25","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.1","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 14:45:26 - GET /index.php?server_id=1&channel_id=22 - POST: []
{"date":"2026-02-17 14:46:17","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"ptt","voice_ptt_key":"v","voice_vox_threshold":"0.1","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 14:46:17 - GET /index.php?server_id=1&channel_id=22 - POST: []
{"date":"2026-02-17 14:46:39","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.1","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 14:46:40 - GET /index.php?server_id=1&channel_id=22 - POST: []
{"date":"2026-02-17 14:48:43","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 14:48:44 - GET /index.php?server_id=1&channel_id=22 - POST: []
{"date":"2026-02-17 14:48:59","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"1","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 14:48:59 - GET /index.php?server_id=1&channel_id=22 - POST: []
2026-02-17 14:59:20 - GET / - POST: []
2026-02-17 14:59:50 - GET /?fl_project=38443 - POST: []
2026-02-17 14:59:50 - GET /?fl_project=38443 - POST: []
2026-02-17 15:02:24 - GET /index.php?server_id=1&channel_id=22 - POST: []
2026-02-17 15:02:30 - GET /index.php?server_id=1&channel_id=1 - POST: []
{"date":"2026-02-17 15:02:45","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 15:02:57 - GET /index.php - POST: []
2026-02-17 15:03:02 - GET /index.php - POST: []
{"date":"2026-02-17 15:03:19","method":"POST","post":{"avatar_url":"","display_name":"swefheim","theme":"light","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.06","dnd_mode":"0","sound_notifications":"0"},"session":{"user_id":3},"user_id":3,"db_success":true}
2026-02-17 15:03:33 - GET /index.php?server_id=1&channel_id=1 - POST: []
{"date":"2026-02-17 15:03:41","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.01","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
{"date":"2026-02-17 15:03:44","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.01","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
{"date":"2026-02-17 15:04:13","method":"POST","post":{"avatar_url":"","display_name":"swefheim","theme":"light","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.38","dnd_mode":"0","sound_notifications":"0"},"session":{"user_id":3},"user_id":3,"db_success":true}
2026-02-17 15:04:30 - GET /index.php?server_id=1&channel_id=1 - POST: []
{"date":"2026-02-17 15:04:48","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.06","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 15:04:52 - GET /index.php?server_id=1&channel_id=1 - POST: []
{"date":"2026-02-17 15:05:02","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.06","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
{"date":"2026-02-17 15:05:57","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.06","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 15:06:05 - GET /index.php?server_id=1&channel_id=1 - POST: []
2026-02-17 15:06:09 - GET /index.php?server_id=1&channel_id=1 - POST: []
{"date":"2026-02-17 15:07:56","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"ptt","voice_ptt_key":"v","voice_vox_threshold":"0.06","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
{"date":"2026-02-17 15:08:11","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"ptt","voice_ptt_key":"0","voice_vox_threshold":"0.06","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 15:14:44 - GET /?fl_project=38443 - POST: []
2026-02-17 15:14:44 - GET /?fl_project=38443 - POST: []
2026-02-17 15:18:05 - GET /?fl_project=38443 - POST: []
2026-02-17 15:18:05 - GET /?fl_project=38443 - POST: []
2026-02-17 15:19:12 - GET /index.php?server_id=1&channel_id=1 - POST: []
{"date":"2026-02-17 15:19:29","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"ptt","voice_ptt_key":"0","voice_vox_threshold":"0.06","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
{"date":"2026-02-17 15:19:34","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"0","voice_vox_threshold":"0.06","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
{"date":"2026-02-17 15:19:53","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"0","voice_vox_threshold":"0.06","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 15:19:56 - GET /index.php?server_id=1&channel_id=1 - POST: []
2026-02-17 15:20:01 - GET /index.php?server_id=1&channel_id=1 - POST: []
2026-02-17 15:23:33 - GET /?fl_project=38443 - POST: []
2026-02-17 15:23:33 - GET /?fl_project=38443 - POST: []
2026-02-17 15:32:11 - GET /index.php?server_id=1&channel_id=1 - POST: []
2026-02-17 15:32:29 - GET /index.php?server_id=1&channel_id=1 - POST: []
2026-02-17 15:33:22 - GET /index.php?server_id=1&channel_id=1 - POST: []
2026-02-17 15:35:42 - GET / - POST: []
2026-02-17 15:36:35 - GET /?fl_project=38443 - POST: []
2026-02-17 15:36:35 - GET /?fl_project=38443 - POST: []
2026-02-17 15:46:33 - GET /index.php?server_id=1&channel_id=1 - POST: []
2026-02-17 15:46:36 - GET /index.php - POST: []
{"date":"2026-02-17 15:47:00","method":"POST","post":{"avatar_url":"","display_name":"swefheim","theme":"light","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0","dnd_mode":"0","sound_notifications":"0"},"session":{"user_id":3},"user_id":3,"db_success":true}
{"date":"2026-02-17 15:47:36","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"ptt","voice_ptt_key":"v","voice_vox_threshold":"0.06","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
{"date":"2026-02-17 15:48:00","method":"POST","post":{"avatar_url":"","display_name":"swefheim","theme":"light","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0","dnd_mode":"0","sound_notifications":"0"},"session":{"user_id":3},"user_id":3,"db_success":true}
{"date":"2026-02-17 15:48:41","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.06","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}

3
ws/server.log Normal file
View File

@ -0,0 +1,3 @@
Server started on 0.0.0.0:8080
New client connected
Client disconnected

View File

@ -28,7 +28,7 @@ while (true) {
}
foreach ($read as $client_socket) {
$data = socket_read($client_socket, 1024);
$data = socket_read($client_socket, 65536);
if ($data === false || strlen($data) === 0) {
$key = array_search($client_socket, $clients);
unset($clients[$key]);
@ -67,6 +67,8 @@ function perform_handshake($receved_header, $client_conn, $host, $port) {
}
}
if (!isset($headers['Sec-WebSocket-Key'])) return;
$secKey = $headers['Sec-WebSocket-Key'];
$secAccept = base64_encode(pack('H*', sha1($secKey . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')));
$upgrade = "HTTP/1.1 101 Switching Protocols\r\n" .
@ -77,26 +79,32 @@ function perform_handshake($receved_header, $client_conn, $host, $port) {
}
function unmask($text) {
if (strlen($text) < 2) return null;
$length = ord($text[1]) & 127;
if ($length == 126) {
if (strlen($text) < 8) return null;
$masks = substr($text, 4, 4);
$data = substr($text, 8);
} elseif ($length == 127) {
if (strlen($text) < 14) return null;
$masks = substr($text, 10, 4);
$data = substr($text, 14);
} else {
if (strlen($text) < 6) return null;
$masks = substr($text, 2, 4);
$data = substr($text, 6);
}
$text = "";
$decoded = "";
for ($i = 0; $i < strlen($data); ++$i) {
$text .= $data[$i] ^ $masks[$i % 4];
$decoded .= $data[$i] ^ $masks[$i % 4];
}
return $text;
return $decoded;
}
function mask($text) {
$b1 = 0x80 | (0x1 & 0x0f);
$b1 = 0x81; // FIN + Opcode 1 (text)
$length = strlen($text);
if ($length <= 125)
$header = pack('CC', $b1, $length);