document.addEventListener("DOMContentLoaded", async () => { const params = new URLSearchParams(window.location.search); const room = params.get("room") || "Unknown Room"; const username = localStorage.getItem("username"); const userKey = localStorage.getItem("userKey"); let firstLoad = true; let isRefreshing = false; if (!room || !username || !userKey) { alert("Missing room or user credentials."); const url = `/index.html${room ? '?room=' + encodeURIComponent(room) : ''}`; window.location.href = url; return; } document.title = room; try { const res = await fetch('/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, key: userKey }) }); if (!res.ok) { localStorage.clear(); alert("User validation failed."); window.location.href = '/'; return; } } catch (err) { console.error('Validation error:', err); localStorage.clear(); alert("Validation failed."); const redirectUrl = `/index.html${room ? '?room=' + encodeURIComponent(room) : ''}`; window.location.href = redirectUrl; return; } // 🔒 Security: Escape HTML to prevent XSS function escapeHtml(text) { if (!text) return text; return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } document.getElementById("room-title").innerHTML = `
Transient.chat Room: ${escapeHtml(room)}
`; const messagesDiv = document.getElementById("messages"); const form = document.getElementById("chat-form"); const input = document.getElementById("message-input"); const usersDiv = document.getElementById("user-list"); const soundToggleBtn = document.getElementById("sound-toggle"); const externalToggleBtn = document.getElementById("external-toggle"); // 🎤 Voice Message Setup const micBtn = document.createElement("button"); micBtn.textContent = "🎤"; micBtn.type = "button"; micBtn.className = "mic-button"; micBtn.title = "Hold to record (max 15s)"; // Insert mic button after input (between input and send button) if (input && input.parentNode) { input.parentNode.insertBefore(micBtn, input.nextSibling); } // đŸ—Ŗī¸ Autoplay Toggle const autoplayToggleBtn = document.createElement("button"); autoplayToggleBtn.id = "autoplay-toggle"; // Insert after external toggle if (externalToggleBtn && externalToggleBtn.parentNode) { externalToggleBtn.parentNode.insertBefore(autoplayToggleBtn, externalToggleBtn.nextSibling); } // Audio setup const sounds = { message: new Audio('/assets/sounds/message.wav'), in: new Audio('/assets/sounds/user-in.wav'), out: new Audio('/assets/sounds/user-out.wav'), }; // Load saved preferences from localStorage soundEnabled = localStorage.getItem("soundEnabled") !== "false"; // default true externalContentEnabled = localStorage.getItem("externalContentEnabled") !== "false"; // default true let autoplayEnabled = localStorage.getItem("autoplayEnabled") !== "false"; // default true soundToggleBtn.textContent = soundEnabled ? "🔊 Sound: On" : "🔇 Sound: Off"; soundToggleBtn.classList.toggle("toggle-off", !soundEnabled); const updateAutoplayToggleBtn = () => { autoplayToggleBtn.textContent = autoplayEnabled ? "đŸ—Ŗī¸ Autoplay: On" : "🔇 Autoplay: Off"; autoplayToggleBtn.classList.toggle("toggle-off", !autoplayEnabled); }; updateAutoplayToggleBtn(); const updateExternalToggleBtn = () => { externalToggleBtn.textContent = externalContentEnabled ? "đŸ–Ĩī¸ External Content: On" : "🔒 External Content: Off"; externalToggleBtn.classList.toggle("toggle-off", !externalContentEnabled); }; updateExternalToggleBtn(); // Reset style on load // Mobile Toggle for User List & Settings const userListContainer = document.getElementById("user-list-container"); if (userListContainer) { const toggleBtn = document.createElement("button"); toggleBtn.id = "user-list-toggle"; toggleBtn.textContent = "Show Users & Settings"; userListContainer.prepend(toggleBtn); toggleBtn.addEventListener("click", () => { const isExpanded = userListContainer.classList.toggle("expanded"); toggleBtn.textContent = isExpanded ? "Hide Users & Settings" : "Show Users & Settings"; }); } soundToggleBtn.addEventListener("click", () => { soundEnabled = !soundEnabled; localStorage.setItem("soundEnabled", soundEnabled); // ✅ Persist soundToggleBtn.textContent = soundEnabled ? "🔊 Sound: On" : "🔇 Sound: Off"; soundToggleBtn.classList.toggle("toggle-off", !soundEnabled); }); externalToggleBtn.addEventListener("click", () => { externalContentEnabled = !externalContentEnabled; localStorage.setItem("externalContentEnabled", externalContentEnabled); // ✅ Persist updateExternalToggleBtn(); }); autoplayToggleBtn.addEventListener("click", () => { autoplayEnabled = !autoplayEnabled; localStorage.setItem("autoplayEnabled", autoplayEnabled); updateAutoplayToggleBtn(); }); // Recording Logic let mediaRecorder; let audioChunks = []; let isRecording = false; let recordingTimeout; async function startRecording() { if (isRecording) return; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); // Low bitrate preference (16kbps) const options = { mimeType: 'audio/webm;codecs=opus', bitsPerSecond: 16000 }; // Fallback if codec not supported if (!MediaRecorder.isTypeSupported(options.mimeType)) delete options.mimeType; mediaRecorder = new MediaRecorder(stream, options); audioChunks = []; isRecording = true; micBtn.classList.add("recording"); micBtn.textContent = "âšī¸"; mediaRecorder.ondataavailable = e => audioChunks.push(e.data); mediaRecorder.onstop = () => { micBtn.classList.remove("recording"); micBtn.textContent = "🎤"; isRecording = false; clearTimeout(recordingTimeout); const blob = new Blob(audioChunks, { type: 'audio/webm' }); const reader = new FileReader(); reader.readAsDataURL(blob); reader.onloadend = () => { const base64Audio = reader.result; // Send immediately fetch(`/chat/${encodeURIComponent(room)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, text: "🎤 Voice Message", audio: base64Audio }) }).then(() => refresh()); }; stream.getTracks().forEach(t => t.stop()); }; mediaRecorder.start(); recordingTimeout = setTimeout(() => { if (isRecording && mediaRecorder.state === "recording") mediaRecorder.stop(); }, 15000); // 15s limit } catch (err) { console.error("Mic error:", err); alert("Could not access microphone."); } } function stopRecording() { if (isRecording && mediaRecorder && mediaRecorder.state === "recording") { mediaRecorder.stop(); } } micBtn.addEventListener("mousedown", startRecording); micBtn.addEventListener("mouseup", stopRecording); micBtn.addEventListener("mouseleave", stopRecording); micBtn.addEventListener("touchstart", (e) => { e.preventDefault(); startRecording(); }); micBtn.addEventListener("touchend", (e) => { e.preventDefault(); stopRecording(); }); async function refreshUserPresence() { const res = await fetch(`/chat/${encodeURIComponent(room)}/users`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username }) }); if (!res.ok) { alert("You were removed from the room."); window.location.href = "/server_list.html"; // ✅ go to server list return; } } let lastUserList = []; let lastTimestamp = null; const seenMessageTimestamps = new Set(); const audioQueue = []; let isPlayingAudio = false; function processAudioQueue() { if (isPlayingAudio || audioQueue.length === 0) return; const audio = audioQueue.shift(); isPlayingAudio = true; const onComplete = () => { isPlayingAudio = false; processAudioQueue(); }; audio.addEventListener('ended', onComplete, { once: true }); audio.addEventListener('error', () => { console.error("Audio playback error"); onComplete(); }, { once: true }); audio.play().catch(e => { console.log("Autoplay blocked or failed:", e); onComplete(); }); } let windowFocused = true; let unreadCount = 0; window.addEventListener('focus', () => { windowFocused = true; unreadCount = 0; document.title = `${room}`; }); window.addEventListener('blur', () => { windowFocused = false; }); function linkify(text) { // 1. Escape the raw text first to prevent XSS const safeText = escapeHtml(text); // 2. Replace URLs in the escaped text with HTML return safeText.replace(/(https?:\/\/[^\s]+)/g, (url) => { const imagePattern = /\.(png|jpe?g|gif)$/i; const youtubeRegex = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]{11})/; const id = `preview-${Math.random().toString(36).substr(2, 9)}`; // ✅ YouTube Video const ytMatch = url.match(youtubeRegex); if (ytMatch) { const videoId = ytMatch[1]; const embedUrl = `https://www.youtube.com/embed/${videoId}`; if (externalContentEnabled) { return ` ${url}
`; } else { return ` ${url}
`; } } // ✅ Image if (imagePattern.test(url)) { if (externalContentEnabled) { return ` ${url}
`; } else { return ` ${url}
`; } } // 🔗 Default fallback: plain link return `${url}`; }); } function cleanExpiredMessages() { const now = Date.now(); const oneHourAgo = now - 60 * 60 * 1000; const messageDivs = document.querySelectorAll("#messages .message"); messageDivs.forEach(msgDiv => { const ts = msgDiv.getAttribute("data-timestamp"); if (!ts) return; const cleanedTs = ts.replace(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}).+$/, '$1Z'); const parsedTimestamp = Date.parse(cleanedTs); if (isNaN(parsedTimestamp)) return; if (parsedTimestamp < oneHourAgo) { msgDiv.remove(); seenMessageTimestamps.delete(ts); } }); } async function loadUsers() { const res = await fetch(`/chat/${encodeURIComponent(room)}/users`); const data = await res.json(); const currentList = data.users || []; const isOwner = data.owner === username; usersDiv.innerHTML = ""; const joined = currentList.filter(user => !lastUserList.includes(user)); const left = lastUserList.filter(user => !currentList.includes(user)); if (!firstLoad && soundEnabled) { if (joined.length > 0) sounds.in.play(); if (left.length > 0) sounds.out.play(); } lastUserList = currentList; currentList.forEach(user => { const div = document.createElement("div"); div.textContent = user; if (isOwner && user !== username) { const btn = document.createElement("button"); btn.textContent = "Kick"; btn.className = "kick-button"; btn.onclick = async () => { if (confirm(`Kick ${user}?`)) { await fetch(`/chat/${encodeURIComponent(room)}/kick`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ owner: username, target: user }), }); await loadUsers(); } }; div.appendChild(btn); } usersDiv.appendChild(div); }); } async function loadMessages() { const url = `/chat/${encodeURIComponent(room)}` + (lastTimestamp ? `?since=${encodeURIComponent(lastTimestamp)}` : ""); const res = await fetch(url); const data = await res.json(); if (!Array.isArray(data)) return; const now = Date.now(); const oneHourAgo = now - 60 * 60 * 1000; const newMessages = data.filter(msg => { const msgTime = new Date(msg.timestamp).getTime(); if (isNaN(msgTime)) return false; if (firstLoad) return true; return msgTime >= oneHourAgo; }); newMessages.forEach(msg => { // Skip if already rendered if (seenMessageTimestamps.has(msg.timestamp)) return; const div = document.createElement("div"); div.classList.add("message"); div.setAttribute("data-timestamp", msg.timestamp); seenMessageTimestamps.add(msg.timestamp); if (msg.username === username) { div.classList.add("my-message"); } div.innerHTML = `${escapeHtml(msg.username)}: ${linkify(msg.text)}`; if (msg.audio) { const audioContainer = document.createElement("div"); audioContainer.className = "audio-container"; const audio = document.createElement("audio"); audio.controls = true; audio.className = "voice-message-audio"; audio.src = msg.audio; // Autoplay if enabled, not first load, and not my own message if (autoplayEnabled && !firstLoad && msg.username !== username) { audioQueue.push(audio); processAudioQueue(); } audioContainer.appendChild(audio); div.appendChild(audioContainer); } messagesDiv.appendChild(div); }); if (newMessages.length > 0) { messagesDiv.scrollTop = messagesDiv.scrollHeight; lastTimestamp = newMessages[newMessages.length - 1].timestamp; } if (!firstLoad && soundEnabled && newMessages.some(msg => msg.username !== username)) { sounds.message.play(); } if (!windowFocused && newMessages.length > 0) { unreadCount += newMessages.length; document.title = `${room} (${unreadCount})`; } } form.addEventListener("submit", e => { e.preventDefault(); const text = input.value.trim(); if (!text) return; fetch(`/chat/${encodeURIComponent(room)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, text }) }).then(() => { input.value = ""; refresh(); // force fetch after post to ensure sync }); }); async function refresh() { if (isRefreshing) return; isRefreshing = true; try { // 🔒 Check if user is still valid const res = await fetch('/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, key: userKey }) }); if (!res.ok) { localStorage.clear(); alert("Session expired or you were removed."); window.location.href = '/'; return; } await refreshUserPresence(); await loadMessages(); await loadUsers(); cleanExpiredMessages(); } catch (e) { console.error("Refresh error:", e); } finally { firstLoad = false; isRefreshing = false; } } // First full load, then enable polling await refresh(); setInterval(refresh, 1000); });