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; } // Initialize E2EE Keyring from sessionStorage const keyringStorageKey = `keyring_${room}`; let storedKeys = []; try { const raw = sessionStorage.getItem(keyringStorageKey); if (raw) { storedKeys = JSON.parse(raw); } } catch (e) {} // Initialize E2EE with stored keys for (const phrase of storedKeys) { await window.addKeyToRing(phrase, room); } // Check URL hash for new key const hash = window.location.hash; if (hash && hash.startsWith('#key=')) { const passphrase = decodeURIComponent(hash.substring(5)); if (passphrase) { if (!storedKeys.includes(passphrase)) { storedKeys.push(passphrase); sessionStorage.setItem(keyringStorageKey, JSON.stringify(storedKeys)); } const keyId = await window.addKeyToRing(passphrase, room); if (keyId) { window.setActiveKey(keyId); } // Clear URL hash securely history.replaceState(null, null, window.location.pathname + window.location.search); } } // 🔒 Security: Escape HTML to prevent XSS function escapeHtml(text) { if (!text) return text; return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function updateRoomTitle() { const e2ee = window.isCryptoEnabled && window.isCryptoEnabled(); document.getElementById("room-title").innerHTML = `
Transient.chat Room: ${escapeHtml(room)} ${e2ee ? '🔒 E2EE Active' : ''}
`; } updateRoomTitle(); 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(); }); // Manage sidebar keyring list UI const keyringListDiv = document.getElementById("keyring-list"); const addKeyBtn = document.getElementById("add-key-btn"); function renderKeyring() { if (!keyringListDiv) return; keyringListDiv.innerHTML = ""; const list = window.getKeyring(); if (list.length === 0) { keyringListDiv.innerHTML = `No active keys. Messages you send will be plaintext.`; return; } const activeId = window.getActiveKeyId(); list.forEach(item => { const row = document.createElement("div"); row.style.display = "flex"; row.style.alignItems = "center"; row.style.justifyContent = "space-between"; row.style.marginBottom = "5px"; row.style.padding = "4px"; row.style.border = "1px solid #444"; row.style.borderRadius = "3px"; row.style.background = activeId === item.keyId ? "rgba(76, 175, 80, 0.15)" : "transparent"; const label = document.createElement("label"); label.style.display = "flex"; label.style.alignItems = "center"; label.style.cursor = "pointer"; label.style.flex = "1"; label.style.overflow = "hidden"; label.style.textOverflow = "ellipsis"; label.style.whiteSpace = "nowrap"; const radio = document.createElement("input"); radio.type = "radio"; radio.name = "active-key"; radio.checked = activeId === item.keyId; radio.style.marginRight = "5px"; radio.addEventListener("change", () => { window.setActiveKey(item.keyId); renderKeyring(); updateRoomTitle(); }); label.appendChild(radio); const textSpan = document.createElement("span"); textSpan.textContent = `🔑 ${item.passphrase.substring(0, 3)}*** (${item.keyId})`; textSpan.title = `Passphrase: ${item.passphrase} (Key ID: ${item.keyId})`; label.appendChild(textSpan); row.appendChild(label); const delBtn = document.createElement("button"); delBtn.textContent = "❌"; delBtn.style.background = "none"; delBtn.style.border = "none"; delBtn.style.color = "#ff5722"; delBtn.style.cursor = "pointer"; delBtn.style.padding = "0 5px"; delBtn.title = "Remove key"; delBtn.onclick = () => { window.removeKeyFromRing(item.keyId); storedKeys = storedKeys.filter(k => k !== item.passphrase); sessionStorage.setItem(keyringStorageKey, JSON.stringify(storedKeys)); window.location.reload(); }; row.appendChild(delBtn); keyringListDiv.appendChild(row); }); } if (addKeyBtn) { addKeyBtn.onclick = async () => { const phrase = prompt("Enter a passphrase to add to your E2EE keyring for this room:"); if (phrase) { const trimmed = phrase.trim(); if (trimmed) { if (!storedKeys.includes(trimmed)) { storedKeys.push(trimmed); sessionStorage.setItem(keyringStorageKey, JSON.stringify(storedKeys)); } const keyId = await window.addKeyToRing(trimmed, room); if (keyId) { window.setActiveKey(keyId); } window.location.reload(); } } }; } renderKeyring(); 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 = async () => { const base64Audio = reader.result; let payload = { username, text: "🎤 Voice Message", audio: base64Audio }; if (window.isCryptoEnabled && window.isCryptoEnabled()) { try { const clearPayload = { text: "🎤 Voice Message", audio: base64Audio }; const encrypted = await window.encryptData(JSON.stringify(clearPayload)); payload = { username: username, text: encrypted.ciphertext, iv: encrypted.iv, keyId: encrypted.keyId, encrypted: true }; } catch (err) { console.error("Encryption error:", err); alert("Failed to encrypt audio."); return; } } // Send immediately fetch(`/chat/${encodeURIComponent(room)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }).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); } }); } function renderUsers(data) { 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 }), }); refresh(); } }; div.appendChild(btn); } usersDiv.appendChild(div); }); } async function renderMessages(data) { 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; }); for (const msg of newMessages) { // Skip if already rendered if (seenMessageTimestamps.has(msg.timestamp)) continue; 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"); } let isDecryptionFailed = false; let textToRender = msg.text; let audioToRender = msg.audio; if (msg.encrypted) { if (window.isCryptoEnabled && window.isCryptoEnabled() && window.getKeyringIds().includes(msg.keyId)) { try { const decryptedJson = await window.decryptData(msg.text, msg.iv, msg.keyId); const parsed = JSON.parse(decryptedJson); textToRender = parsed.text; audioToRender = parsed.audio; } catch (err) { textToRender = "âš ī¸ Failed to decrypt message (bad key)"; audioToRender = null; } } else { textToRender = `🔒 Message encrypted (Key ID: ${msg.keyId}).`; audioToRender = null; isDecryptionFailed = true; } } div.innerHTML = `${escapeHtml(msg.username)}: ${linkify(textToRender)}`; if (isDecryptionFailed) { const addKeyLink = document.createElement("a"); addKeyLink.href = "#"; addKeyLink.textContent = " Enter passphrase to decrypt"; addKeyLink.style.color = "#2196f3"; addKeyLink.style.textDecoration = "underline"; addKeyLink.style.marginLeft = "5px"; addKeyLink.onclick = async (e) => { e.preventDefault(); const phrase = prompt(`Enter the passphrase for Key ID ${msg.keyId}:`); if (phrase) { const trimmed = phrase.trim(); if (trimmed) { if (!storedKeys.includes(trimmed)) { storedKeys.push(trimmed); sessionStorage.setItem(keyringStorageKey, JSON.stringify(storedKeys)); } const derivedId = await window.addKeyToRing(trimmed, room); if (derivedId) { if (derivedId === msg.keyId) { window.setActiveKey(derivedId); window.location.reload(); } else { alert(`Incorrect passphrase. The entered passphrase generated Key ID: ${derivedId}, but this message requires Key ID: ${msg.keyId}.`); } } } } }; div.appendChild(addKeyLink); } if (audioToRender) { const audioContainer = document.createElement("div"); audioContainer.className = "audio-container"; const audio = document.createElement("audio"); audio.controls = true; audio.className = "voice-message-audio"; audio.src = audioToRender; // 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", async e => { e.preventDefault(); const text = input.value.trim(); if (!text) return; let payload = { username, text }; if (window.isCryptoEnabled && window.isCryptoEnabled()) { try { const clearPayload = { text: text, audio: null }; const encrypted = await window.encryptData(JSON.stringify(clearPayload)); payload = { username: username, text: encrypted.ciphertext, iv: encrypted.iv, keyId: encrypted.keyId, encrypted: true }; } catch (err) { console.error("Encryption error:", err); alert("Failed to encrypt message."); return; } } fetch(`/chat/${encodeURIComponent(room)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }).then(() => { input.value = ""; refresh(); // force fetch after post to ensure sync }); }); async function refresh() { if (isRefreshing) return; isRefreshing = true; try { const res = await fetch(`/chat/${encodeURIComponent(room)}/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: username, key: userKey, since: lastTimestamp }) }); if (!res.ok) { localStorage.clear(); alert("Session expired or you were removed."); window.location.href = '/'; return; } const data = await res.json(); renderUsers(data); await renderMessages(data.messages); cleanExpiredMessages(); } catch (e) { console.error("Refresh error:", e); } finally { firstLoad = false; isRefreshing = false; } } // Start smart poll loop instead of setInterval let pollTimeoutId = null; async function pollLoop() { await refresh(); const interval = windowFocused ? 1000 : 4000; pollTimeoutId = setTimeout(pollLoop, interval); } // Initialize E2EE and begin polling await pollLoop(); });