diff --git a/api_v1_voice.php b/api_v1_voice.php index b36d663..fc3a4b1 100644 --- a/api_v1_voice.php +++ b/api_v1_voice.php @@ -1,5 +1,4 @@ - 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"); @@ -98,76 +36,94 @@ $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) {} + $stmt = db()->prepare("INSERT INTO voice_sessions (user_id, channel_id, peer_id, name, last_seen) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE channel_id = ?, peer_id = ?, name = ?, last_seen = ?"); + $stmt->execute([$current_user_id, $room, $new_id, $name, now_ms(), $room, $new_id, $name, now_ms()]); + } catch (Exception $e) { + json_out(["error" => "DB Error: " . $e->getMessage()], 500); + } + } else { + json_out(["error" => "Not logged in"], 401); } + // Fetch all participants in this room + $ps = []; + try { + $stmt = db()->prepare("SELECT vs.*, u.avatar_url FROM voice_sessions vs JOIN users u ON vs.user_id = u.id WHERE vs.channel_id = ? AND vs.last_seen > ?"); + $stmt->execute([$room, now_ms() - 7000]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as $row) { + $ps[$row["peer_id"]] = [ + "id" => $row["peer_id"], + "user_id" => (int)$row["user_id"], + "name" => $row["name"], + "avatar_url" => $row["avatar_url"], + "last_seen" => (int)$row["last_seen"] + ]; + } + } 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 + // Update 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]); + $stmt = db()->prepare("UPDATE voice_sessions SET last_seen = ? WHERE user_id = ? AND peer_id = ?"); + $stmt->execute([now_ms(), $current_user_id, $my_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; - } + // Fetch participants + $ps = []; + try { + $stmt = db()->prepare("SELECT vs.*, u.avatar_url FROM voice_sessions vs JOIN users u ON vs.user_id = u.id WHERE vs.channel_id = ? AND vs.last_seen > ?"); + $stmt->execute([$room, now_ms() - 7000]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as $row) { + $ps[$row["peer_id"]] = [ + "id" => $row["peer_id"], + "user_id" => (int)$row["user_id"], + "name" => $row["name"], + "avatar_url" => $row["avatar_url"], + "last_seen" => (int)$row["last_seen"] + ]; } - file_put_contents($log_file, implode("\n", $remaining) . (empty($remaining) ? "" : "\n"), LOCK_EX); - } + } catch (Exception $e) {} + + // Read signals + $signals = []; + try { + $stmt = db()->prepare("SELECT * FROM voice_signals WHERE to_peer_id = ? AND room_id = ?"); + $stmt->execute([$my_id, $room]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + $ids_to_delete = []; + foreach ($rows as $row) { + $signals[] = [ + "from" => $row["from_peer_id"], + "to" => $row["to_peer_id"], + "data" => json_decode($row["data"], true), + "time" => (int)$row["created_at"] + ]; + $ids_to_delete[] = $row["id"]; + } + if (!empty($ids_to_delete)) { + $placeholders = implode(',', array_fill(0, count($ids_to_delete), '?')); + db()->prepare("DELETE FROM voice_signals WHERE id IN ($placeholders)")->execute($ids_to_delete); + } + + // Occasional cleanup of old signals + if (rand(1, 20) === 1) { + $stale_sig = now_ms() - 60000; + db()->prepare("DELETE FROM voice_signals WHERE created_at < ?")->execute([$stale_sig]); + } + } catch (Exception $e) {} json_out(["success" => true, "participants" => $ps, "signals" => $signals]); } @@ -178,27 +134,16 @@ if ($action === "signal") { $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); + try { + $stmt = db()->prepare("INSERT INTO voice_signals (room_id, from_peer_id, to_peer_id, data, created_at) VALUES (?, ?, ?, ?, ?)"); + $stmt->execute([$room, $my_id, $to, $data, now_ms()]); + } catch (Exception $e) { + json_out(["error" => "Signal Error: " . $e->getMessage()], 500); + } 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 @@ -206,7 +151,7 @@ if ($action === "list_all") { JOIN users u ON vs.user_id = u.id WHERE vs.last_seen > ? "); - $stale_db_time = now_ms() - 15000; + $stale_db_time = now_ms() - 30000; $stmt->execute([$stale_db_time]); $sessions = $stmt->fetchAll(PDO::FETCH_ASSOC); @@ -222,12 +167,11 @@ if ($action === "list_all") { 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 peer_id = ?"); + $stmt->execute([$my_id]); + } catch (Exception $e) {} + } elseif ($current_user_id > 0) { try { $stmt = db()->prepare("DELETE FROM voice_sessions WHERE user_id = ?"); $stmt->execute([$current_user_id]); @@ -236,4 +180,4 @@ if ($action === "leave") { json_out(["success" => true]); } -json_out(["error" => "Unknown action"], 404); \ No newline at end of file +json_out(["error" => "Unknown action"], 404); diff --git a/assets/js/main.js b/assets/js/main.js index e26e253..3b0e7be 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,11 +1,6 @@ document.addEventListener('DOMContentLoaded', () => { - const fileUpload = document.getElementById('file-upload'); - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const messagesList = document.getElementById('messages-list'); - const typingIndicator = document.getElementById('typing-indicator'); - - function scrollToBottom(force = false) { + // Scroll to bottom helper + const messagesList = document.getElementById('messages-list'); if (!messagesList) return; // Smart scroll: only scroll if user is already at the bottom or if forced (e.g. sending a message) @@ -20,8 +15,9 @@ document.addEventListener('DOMContentLoaded', () => { // Backup for non-smooth support or rendering delays setTimeout(() => { - if (force || messagesList.scrollHeight - messagesList.scrollTop <= messagesList.clientHeight + threshold + 200) { - messagesList.scrollTop = messagesList.scrollHeight; + const currentList = document.getElementById('messages-list'); + if (currentList && (force || currentList.scrollHeight - currentList.scrollTop <= currentList.clientHeight + threshold + 200)) { + currentList.scrollTop = currentList.scrollHeight; } }, 100); } @@ -444,10 +440,68 @@ document.addEventListener('DOMContentLoaded', () => { // Scroll to bottom scrollToBottom(true); - const currentChannel = window.activeChannelId || new URLSearchParams(window.location.search).get('channel_id') || 1; - const currentThread = new URLSearchParams(window.location.search).get('thread_id'); + let currentChannel = window.activeChannelId || new URLSearchParams(window.location.search).get('channel_id') || 1; + let currentThread = new URLSearchParams(window.location.search).get('thread_id'); let typingTimeout; + async function loadChannel(url, pushState = true) { + try { + const resp = await fetch(url); + const html = await resp.text(); + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + + const newChat = doc.querySelector('.chat-container'); + const newMembers = doc.querySelector('.members-sidebar'); + const newChannels = doc.querySelector('.channels-list'); + + if (newChat) { + const chatContainer = document.querySelector('.chat-container'); + if (chatContainer) chatContainer.innerHTML = newChat.innerHTML; + } + if (newMembers) { + const membersSidebar = document.querySelector('.members-sidebar'); + if (membersSidebar) { + membersSidebar.innerHTML = newMembers.innerHTML; + if (newMembers.classList.contains('hidden')) membersSidebar.classList.add('hidden'); + else membersSidebar.classList.remove('hidden'); + } + } + if (newChannels) { + const channelsList = document.querySelector('.channels-list'); + if (channelsList) channelsList.innerHTML = newChannels.innerHTML; + } + + // Update state + const urlObj = new URL(url, window.location.origin); + const params = urlObj.searchParams; + currentChannel = params.get('channel_id') || 1; + currentThread = params.get('thread_id'); + window.activeChannelId = currentChannel; + + // Update Title + document.title = doc.title; + + // Re-init features + findLastMessageId(); + scrollToBottom(true); + + if (pushState) history.pushState(null, '', url); + + // Re-sync voice UI to ensure green highlight is correct + if (window.voiceHandler) { + window.voiceHandler.updateVoiceUI(); + } + } catch (e) { + console.error('Failed to load channel:', e); + window.location.href = url; // Fallback + } + } + + window.addEventListener('popstate', () => { + loadChannel(window.location.href, false); + }); + // Notification Permission if ("Notification" in window && Notification.permission === "default") { Notification.requestPermission(); @@ -563,139 +617,160 @@ document.addEventListener('DOMContentLoaded', () => { }, 1000); function showTyping(username) { + const typingIndicator = document.getElementById('typing-indicator'); if (!typingIndicator) return; typingIndicator.textContent = `${username} is typing...`; clearTimeout(typingTimeout); typingTimeout = setTimeout(() => { - if (typingIndicator) typingIndicator.textContent = ''; + const currentIndicator = document.getElementById('typing-indicator'); + if (currentIndicator) currentIndicator.textContent = ''; }, 3000); } - chatInput?.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { + document.addEventListener('keydown', (e) => { + if (e.target.id === 'chat-input') { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + const form = document.getElementById('chat-form'); + if (form) form.dispatchEvent(new Event('submit', { cancelable: true })); + } + } + }); + + document.addEventListener('input', (e) => { + if (e.target.id === 'chat-input') { + const chatInput = e.target; + 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', + channel_id: currentChannel, + user_id: window.currentUserId, + username: window.currentUsername + })); + } + } + }); + + document.addEventListener('submit', (e) => { + if (e.target.id === 'chat-form') { 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', - channel_id: currentChannel, - user_id: window.currentUserId, - username: window.currentUsername - })); - } - }); - - chatForm?.addEventListener('submit', (e) => { - e.preventDefault(); - const content = chatInput.value.trim(); - const file = fileUpload.files[0]; - 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); - if (currentThread) { - formData.append('thread_id', currentThread); - } - - const progressContainer = document.getElementById('upload-progress-container'); - const progressBar = document.getElementById('upload-progress-bar'); - const progressPercent = document.getElementById('upload-percentage'); - const progressFilename = document.getElementById('upload-filename'); - - if (file) { - formData.append('file', file); - fileUpload.value = ''; // Clear file input + const form = e.target; + const chatInput = form.querySelector('#chat-input'); + const fileUpload = form.querySelector('#file-upload'); - // Show progress bar - progressContainer.style.display = 'block'; - progressFilename.textContent = `Uploading: ${file.name}`; - progressBar.style.width = '0%'; - progressPercent.textContent = '0%'; - } + const content = chatInput.value.trim(); + const file = fileUpload.files[0]; + if (!content && !file) return; - const xhr = new XMLHttpRequest(); - xhr.open('POST', 'api_v1_messages.php', true); - - xhr.upload.onprogress = (ev) => { - if (ev.lengthComputable && file) { - const percent = Math.round((ev.loaded / ev.total) * 100); - progressBar.style.width = percent + '%'; - progressPercent.textContent = percent + '%'; + chatInput.value = ''; + chatInput.style.height = '24px'; + chatInput.style.overflowY = 'hidden'; + const formData = new FormData(); + formData.append('content', content); + formData.append('channel_id', currentChannel); + if (currentThread) { + formData.append('thread_id', currentThread); } - }; + + const progressContainer = document.getElementById('upload-progress-container'); + const progressBar = document.getElementById('upload-progress-bar'); + const progressPercent = document.getElementById('upload-percentage'); + const progressFilename = document.getElementById('upload-filename'); - xhr.onload = () => { - if (xhr.status === 200) { - const result = JSON.parse(xhr.responseText); - if (result.success) { - appendMessage(result.message); - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ - type: 'message', - data: JSON.stringify({ - ...result.message, - channel_id: currentChannel - }) - })); - } - } else { - alert(result.error || 'Failed to send message'); + if (file) { + formData.append('file', file); + fileUpload.value = ''; // Clear file input + + // Show progress bar + if (progressContainer) progressContainer.style.display = 'block'; + if (progressFilename) progressFilename.textContent = `Uploading: ${file.name}`; + if (progressBar) progressBar.style.width = '0%'; + if (progressPercent) progressPercent.textContent = '0%'; + } + + const xhr = new XMLHttpRequest(); + xhr.open('POST', 'api_v1_messages.php', true); + + xhr.upload.onprogress = (ev) => { + if (ev.lengthComputable && file) { + const percent = Math.round((ev.loaded / ev.total) * 100); + if (progressBar) progressBar.style.width = percent + '%'; + if (progressPercent) progressPercent.textContent = percent + '%'; } - } - progressContainer.style.display = 'none'; - }; + }; - xhr.onerror = () => { - console.error('XHR Error'); - progressContainer.style.display = 'none'; - alert('An error occurred during the upload.'); - }; + xhr.onload = () => { + if (xhr.status === 200) { + const result = JSON.parse(xhr.responseText); + if (result.success) { + appendMessage(result.message); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'message', + data: JSON.stringify({ + ...result.message, + channel_id: currentChannel + }) + })); + } + } else { + alert(result.error || 'Failed to send message'); + } + } + if (progressContainer) progressContainer.style.display = 'none'; + }; - xhr.send(formData); + xhr.onerror = () => { + console.error('XHR Error'); + if (progressContainer) progressContainer.style.display = 'none'; + alert('An error occurred during the upload.'); + }; + + xhr.send(formData); + } }); // Handle Click Events document.addEventListener('click', (e) => { + const link = e.target.closest('a'); + if (link && link.href && link.href.startsWith(window.location.origin) && !link.dataset.bsToggle && !link.dataset.bsTarget && link.target !== '_blank') { + const url = new URL(link.href); + const targetServerId = url.searchParams.get('server_id'); + const targetChannelId = url.searchParams.get('channel_id'); + const targetThreadId = url.searchParams.get('thread_id'); + + // If it's a channel or thread within the same server, use AJAX + if (targetServerId === activeServerId && (targetChannelId || targetThreadId)) { + e.preventDefault(); + loadChannel(link.href); + return; + } + + // Also handle DM switching + if (targetServerId === 'dms' && activeServerId === 'dms' && targetChannelId) { + e.preventDefault(); + loadChannel(link.href); + return; + } + } + 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'); - } + if (window.voiceHandler) window.voiceHandler.join(channelId); return; } - + const badge = e.target.closest('.reaction-badge'); if (badge) { const msgId = badge.parentElement.dataset.messageId; @@ -993,57 +1068,61 @@ document.addEventListener('DOMContentLoaded', () => { }); // Global Search - const searchInput = document.getElementById('global-search'); - const searchType = document.getElementById('search-type'); - const searchResults = document.getElementById('search-results'); - - searchInput?.addEventListener('input', async () => { - const q = searchInput.value.trim(); - const type = searchType.value; - if (q.length < 2) { - searchResults.style.display = 'none'; - return; - } - - const resp = await fetch(`api_v1_search.php?q=${encodeURIComponent(q)}&type=${type}&channel_id=${currentChannel}`); - const data = await resp.json(); - - if (data.success && data.results.length > 0) { - searchResults.innerHTML = ''; - data.results.forEach(res => { - const item = document.createElement('div'); - item.className = 'search-result-item d-flex align-items-center gap-2'; - if (type === 'users') { - item.innerHTML = ` -
-