document.addEventListener("DOMContentLoaded", async () => { const params = new URLSearchParams(window.location.search); const room = params.get("room") || ""; const username = localStorage.getItem("username"); const userKey = localStorage.getItem("userKey"); const initialWatchPartyRequested = params.has("watchparty"); let firstLoad = true; let isRefreshing = false; let currentRoomKey = null; // AES-GCM 256-bit CryptoKey derived from room password let isRoomOwner = false; let watchPartyActive = false; if (!room || !username || !userKey) { alert("Missing room or user credentials."); window.location.href = `/index.html${room ? '?room=' + encodeURIComponent(room) : ''}`; return; } document.title = `${room} - Transient.chat`; // 1. Validate user session 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 error."); window.location.href = '/'; return; } // đ Security: Escape HTML function escapeHtml(text) { if (!text) return text; return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function updateRoomTitle() { const roomTitleEl = document.getElementById("room-title"); if (roomTitleEl) { roomTitleEl.innerHTML = `
`; } } updateRoomTitle(); // DOM Elements 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"); const watchpartyToggleBtn = document.getElementById("watchparty-toggle"); const videoSection = document.getElementById("video-section"); // Modal elements const passwordModal = document.getElementById("password-modal"); const passwordForm = document.getElementById("password-form"); const passwordInput = document.getElementById("modal-password-input"); // Audio & Sounds 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'), }; let soundEnabled = localStorage.getItem("soundEnabled") !== "false"; let externalContentEnabled = localStorage.getItem("externalContentEnabled") !== "false"; let autoplayEnabled = localStorage.getItem("autoplayEnabled") !== "false"; soundToggleBtn.textContent = soundEnabled ? "đ Sound: On" : "đ Sound: Off"; soundToggleBtn.classList.toggle("toggle-off", !soundEnabled); const updateExternalToggleBtn = () => { externalToggleBtn.textContent = externalContentEnabled ? "đĨī¸ External Content: On" : "đ External Content: Off"; externalToggleBtn.classList.toggle("toggle-off", !externalContentEnabled); }; updateExternalToggleBtn(); soundToggleBtn.addEventListener("click", () => { soundEnabled = !soundEnabled; localStorage.setItem("soundEnabled", soundEnabled); soundToggleBtn.textContent = soundEnabled ? "đ Sound: On" : "đ Sound: Off"; soundToggleBtn.classList.toggle("toggle-off", !soundEnabled); }); externalToggleBtn.addEventListener("click", () => { externalContentEnabled = !externalContentEnabled; localStorage.setItem("externalContentEnabled", externalContentEnabled); updateExternalToggleBtn(); }); // đŖī¸ Autoplay Toggle const autoplayToggleBtn = document.createElement("button"); autoplayToggleBtn.id = "autoplay-toggle"; autoplayToggleBtn.style.marginBottom = "10px"; autoplayToggleBtn.style.textAlign = "left"; autoplayToggleBtn.style.display = "inline-block"; if (externalToggleBtn && externalToggleBtn.parentNode) { externalToggleBtn.parentNode.insertBefore(autoplayToggleBtn, externalToggleBtn.nextSibling); } const updateAutoplayToggleBtn = () => { autoplayToggleBtn.textContent = autoplayEnabled ? "đŖī¸ Autoplay: On" : "đ Autoplay: Off"; autoplayToggleBtn.classList.toggle("toggle-off", !autoplayEnabled); }; updateAutoplayToggleBtn(); autoplayToggleBtn.addEventListener("click", () => { autoplayEnabled = !autoplayEnabled; localStorage.setItem("autoplayEnabled", autoplayEnabled); updateAutoplayToggleBtn(); }); // đ¤ Voice Message Button Setup const micBtn = document.createElement("button"); micBtn.textContent = "đ¤"; micBtn.type = "button"; micBtn.className = "mic-button"; micBtn.title = "Hold to record voice message (max 15s)"; if (input && input.parentNode) { input.parentNode.insertBefore(micBtn, input.nextSibling); } // 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"; }); } // đ Room Password & Encryption Key Derivation Logic const sessionPasswordKey = `room_password_${room}`; async function authenticateAndDeriveKey(password) { try { const res = await fetch("/rooms/join", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ room: room, password: password, username: username, key: userKey }) }); if (!res.ok) { const errData = await res.json().catch(() => ({})); return { success: false, error: errData.error || "Room authentication failed" }; } const data = await res.json(); isRoomOwner = !!data.is_owner; // Store password in sessionStorage for this browser tab session sessionStorage.setItem(sessionPasswordKey, password); // Derive 256-bit AES-GCM encryption key using PBKDF2 currentRoomKey = await window.deriveRoomKey(password, room); return { success: true }; } catch (err) { console.error("Auth error:", err); return { success: false, error: "Network or server error" }; } } // Check if password exists in sessionStorage const storedPassword = sessionStorage.getItem(sessionPasswordKey); if (storedPassword) { const result = await authenticateAndDeriveKey(storedPassword); if (!result.success) { sessionStorage.removeItem(sessionPasswordKey); showPasswordModal(); } else { startChatSession(); } } else { showPasswordModal(); } function showPasswordModal() { if (passwordModal) { passwordModal.classList.remove("hidden"); if (passwordInput) passwordInput.focus(); } } function hidePasswordModal() { if (passwordModal) { passwordModal.classList.add("hidden"); } } if (passwordForm) { passwordForm.addEventListener("submit", async (e) => { e.preventDefault(); const enteredPass = passwordInput.value.trim(); if (!enteredPass) return; const submitBtn = document.getElementById("modal-submit-btn"); if (submitBtn) submitBtn.disabled = true; const result = await authenticateAndDeriveKey(enteredPass); if (submitBtn) submitBtn.disabled = false; if (result.success) { hidePasswordModal(); startChatSession(); } else { alert(result.error || "Incorrect room password."); passwordInput.value = ""; passwordInput.focus(); } }); } // đŦ Watch Party State & Video Synchronization const videoControls = document.getElementById("video-controls"); const videoInput = document.getElementById("video-url-input"); const videoButton = document.getElementById("start-video-button"); const pauseButton = document.getElementById("pause-video-button"); const resumeButton = document.getElementById("resume-video-button"); const clearButton = document.getElementById("clear-video-button"); const iframe = document.getElementById("video-iframe"); const pausedOverlay = document.getElementById("paused-overlay"); let lastVideoId = null; function extractYouTubeId(url) { if (!url) return null; const match = url.match(/(?:youtube\.com\/.*v=|youtu\.be\/)([\w-]{11})/); return match ? match[1] : null; } function loadYouTubeVideo(videoId, startSeconds = 0) { if (lastVideoId === videoId && !iframe.classList.contains("hidden")) return; iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1&start=${startSeconds}`; iframe.classList.remove("hidden"); lastVideoId = videoId; } function clearVideoUI() { if (iframe) { iframe.src = ""; iframe.classList.add("hidden"); } lastVideoId = null; if (pausedOverlay) pausedOverlay.classList.add("hidden"); updateVideoControlsVisibility(false, false); } function updateVideoControlsVisibility(hasVideo, isPaused) { if (!videoControls) return; if (isRoomOwner) { videoControls.classList.remove("hidden"); if (hasVideo) { if (videoInput) videoInput.classList.add("hidden"); if (videoButton) videoButton.classList.add("hidden"); if (clearButton) clearButton.classList.remove("hidden"); if (isPaused) { if (pauseButton) pauseButton.classList.add("hidden"); if (resumeButton) resumeButton.classList.remove("hidden"); } else { if (pauseButton) pauseButton.classList.remove("hidden"); if (resumeButton) resumeButton.classList.add("hidden"); } } else { if (videoInput) videoInput.classList.remove("hidden"); if (videoButton) videoButton.classList.remove("hidden"); if (clearButton) clearButton.classList.add("hidden"); if (pauseButton) pauseButton.classList.add("hidden"); if (resumeButton) resumeButton.classList.add("hidden"); } } else { videoControls.classList.add("hidden"); } } function setWatchPartyActive(active) { watchPartyActive = active; if (active) { document.body.classList.add("watchparty-active"); if (videoSection) videoSection.classList.remove("hidden"); if (watchpartyToggleBtn) { watchpartyToggleBtn.textContent = "đŦ Watch Party: On"; watchpartyToggleBtn.classList.remove("toggle-off"); } } else { document.body.classList.remove("watchparty-active"); if (videoSection) videoSection.classList.add("hidden"); if (watchpartyToggleBtn) { watchpartyToggleBtn.textContent = "đŦ Watch Party: Off"; watchpartyToggleBtn.classList.add("toggle-off"); } } } if (watchpartyToggleBtn) { watchpartyToggleBtn.addEventListener("click", () => { setWatchPartyActive(!watchPartyActive); }); } if (videoButton && videoInput) { videoButton.addEventListener("click", async () => { const url = videoInput.value.trim(); const videoId = extractYouTubeId(url); if (!videoId) return alert("Please enter a valid YouTube URL."); try { await fetch(`/watchparty/${encodeURIComponent(room)}/video`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }), }); loadYouTubeVideo(videoId, 0); updateVideoControlsVisibility(true, false); } catch (err) { console.error("Failed to start video:", err); } }); } if (pauseButton) { pauseButton.addEventListener("click", async () => { try { await fetch(`/watchparty/${encodeURIComponent(room)}/video/pause`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username }), }); } catch (err) { console.error("Failed to pause video:", err); } }); } if (resumeButton) { resumeButton.addEventListener("click", async () => { try { await fetch(`/watchparty/${encodeURIComponent(room)}/video/resume`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username }), }); } catch (err) { console.error("Failed to resume video:", err); } }); } if (clearButton) { clearButton.addEventListener("click", async () => { try { await fetch(`/watchparty/${encodeURIComponent(room)}/video/clear`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username }), }); clearVideoUI(); } catch (err) { console.error("Failed to clear video:", err); } }); } async function syncWatchPartyFromServer() { try { const res = await fetch(`/watchparty/${encodeURIComponent(room)}/video`); if (!res.ok) return; const data = await res.json(); const { url, elapsed, is_paused, is_active } = data; if (url) { const videoId = extractYouTubeId(url); if (videoId) { // Auto-open watch party panel if video is playing if (!watchPartyActive) { setWatchPartyActive(true); } if (is_paused) { if (iframe && !iframe.classList.contains("hidden")) { iframe.classList.add("hidden"); iframe.src = ""; lastVideoId = null; } if (pausedOverlay) pausedOverlay.classList.remove("hidden"); updateVideoControlsVisibility(true, true); } else { if (pausedOverlay) pausedOverlay.classList.add("hidden"); loadYouTubeVideo(videoId, elapsed || 0); updateVideoControlsVisibility(true, false); } return; } } // No active video URL if (lastVideoId !== null) { clearVideoUI(); } else { updateVideoControlsVisibility(false, false); } } catch (err) { // Silently ignore sync failures } } // đ¤ Audio & Voice Messages Recording let mediaRecorder; let audioChunks = []; let isRecording = false; let recordingTimeout; async function startRecording() { if (isRecording || !currentRoomKey) return; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const options = { mimeType: 'audio/webm;codecs=opus', bitsPerSecond: 16000 }; 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; const clearPayload = { text: "đ¤ Voice Message", audio: base64Audio }; try { const encrypted = await window.encryptRoomPayload(currentRoomKey, clearPayload); await fetch(`/chat/${encodeURIComponent(room)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username, key: userKey, ciphertext: encrypted.ciphertext, iv: encrypted.iv }) }); refresh(); } catch (err) { console.error("Audio encryption error:", err); alert("Failed to send encrypted voice note."); } }; stream.getTracks().forEach(t => t.stop()); }; mediaRecorder.start(); recordingTimeout = setTimeout(() => { if (isRecording && mediaRecorder.state === "recording") mediaRecorder.stop(); }, 15000); } 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(); }); // Audio Playback Queue 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', onComplete, { once: true }); audio.play().catch(onComplete); } // đ Linkify URLs & Media Embeds function linkify(text) { const safeText = escapeHtml(text); 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 const ytMatch = url.match(youtubeRegex); if (ytMatch) { const videoId = ytMatch[1]; const embedUrl = `https://www.youtube.com/embed/${videoId}`; if (externalContentEnabled) { return ` ${url}