transient.chat/static/assets/js/chat.js

863 lines
33 KiB
JavaScript
Raw Normal View History

2026-05-12 23:50:49 +00:00
document.addEventListener("DOMContentLoaded", async () => {
const params = new URLSearchParams(window.location.search);
const room = params.get("room") || "";
2026-05-12 23:50:49 +00:00
const username = localStorage.getItem("username");
const userKey = localStorage.getItem("userKey");
const initialWatchPartyRequested = params.has("watchparty");
2026-05-12 23:50:49 +00:00
let firstLoad = true;
let isRefreshing = false;
let currentRoomKey = null; // AES-GCM 256-bit CryptoKey derived from room password
let isRoomOwner = false;
let watchPartyActive = false;
2026-05-12 23:50:49 +00:00
if (!room || !username || !userKey) {
alert("Missing room or user credentials.");
window.location.href = `/index.html${room ? '?room=' + encodeURIComponent(room) : ''}`;
2026-05-12 23:50:49 +00:00
return;
}
document.title = `${room} - Transient.chat`;
2026-05-12 23:50:49 +00:00
// 1. Validate user session
2026-05-12 23:50:49 +00:00
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 = '/';
2026-05-12 23:50:49 +00:00
return;
}
// 🔒 Security: Escape HTML
2026-05-12 23:50:49 +00:00
function escapeHtml(text) {
if (!text) return text;
return text
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function updateRoomTitle() {
const roomTitleEl = document.getElementById("room-title");
if (roomTitleEl) {
roomTitleEl.innerHTML = `
<div class="title-row">
<a href="server_list.html">
<img src="/assets/images/transientchat-blue.png" alt="Transient.chat" style="height: 60px;"/>
</a>
<span>Room: ${escapeHtml(room)}</span>
<span style="color: #4caf50; font-size: 0.8em; margin-left: 12px;">🔒 Private & Encrypted</span>
</div>
`;
}
}
updateRoomTitle();
2026-05-12 23:50:49 +00:00
// DOM Elements
2026-05-12 23:50:49 +00:00
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");
2026-05-12 23:50:49 +00:00
// Modal elements
const passwordModal = document.getElementById("password-modal");
const passwordForm = document.getElementById("password-form");
const passwordInput = document.getElementById("modal-password-input");
2026-05-12 23:50:49 +00:00
// Audio & Sounds setup
2026-05-12 23:50:49 +00:00
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";
2026-05-12 23:50:49 +00:00
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);
}
2026-05-12 23:50:49 +00:00
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();
});
2026-05-12 23:50:49 +00:00
// 🎤 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);
}
2026-05-12 23:50:49 +00:00
// 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}`;
2026-05-12 23:50:49 +00:00
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
})
});
2026-05-12 23:50:49 +00:00
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.");
2026-05-12 23:50:49 +00:00
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
2026-05-12 23:50:49 +00:00
let mediaRecorder;
let audioChunks = [];
let isRecording = false;
let recordingTimeout;
async function startRecording() {
if (isRecording || !currentRoomKey) return;
2026-05-12 23:50:49 +00:00
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);
2026-05-12 23:50:49 +00:00
const blob = new Blob(audioChunks, { type: 'audio/webm' });
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = async () => {
2026-05-12 23:50:49 +00:00
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.");
}
2026-05-12 23:50:49 +00:00
};
stream.getTracks().forEach(t => t.stop());
};
mediaRecorder.start();
recordingTimeout = setTimeout(() => {
if (isRecording && mediaRecorder.state === "recording") mediaRecorder.stop();
}, 15000);
2026-05-12 23:50:49 +00:00
} 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
2026-05-12 23:50:49 +00:00
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);
2026-05-12 23:50:49 +00:00
}
// 🔗 Linkify URLs & Media Embeds
2026-05-12 23:50:49 +00:00
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
2026-05-12 23:50:49 +00:00
const ytMatch = url.match(youtubeRegex);
if (ytMatch) {
const videoId = ytMatch[1];
const embedUrl = `https://www.youtube.com/embed/${videoId}`;
if (externalContentEnabled) {
return `
<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>
<div style="padding: 0.5em; text-align: center;">
<iframe
src="${embedUrl}"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
style="width: 500px; height: 280px; max-width: 100%; border-radius: 4px;"
></iframe>
</div>
`;
2026-05-12 23:50:49 +00:00
} else {
return `
<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>
<div class="external-image-placeholder" style="padding: 0.5em; text-align: center;">
<button onclick="
this.style.display='none';
const iframe = document.createElement('iframe');
iframe.src = '${embedUrl}';
iframe.style.width = '500px';
iframe.style.height = '280px';
iframe.style.maxWidth = '100%';
iframe.style.borderRadius = '4px';
iframe.frameBorder = 0;
iframe.allowFullscreen = true;
document.getElementById('${id}').appendChild(iframe);
">Show external video</button>
<div id="${id}" style="margin-top: 0.5em;"></div>
</div>
`;
2026-05-12 23:50:49 +00:00
}
}
// Image
2026-05-12 23:50:49 +00:00
if (imagePattern.test(url)) {
if (externalContentEnabled) {
return `
<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>
<div style="padding: 0.5em; text-align: center;">
<img src="${url}" style="max-width: 100%; max-height: 400px; border-radius: 4px;" />
</div>
`;
2026-05-12 23:50:49 +00:00
} else {
return `
<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>
<div class="external-image-placeholder" style="padding: 0.5em; text-align: center;">
<button onclick="
this.style.display='none';
const img = document.createElement('img');
img.src = '${url}';
img.style.maxWidth = '100%';
img.style.maxHeight = '400px';
img.style.borderRadius = '4px';
document.getElementById('${id}').appendChild(img);
">Show image</button>
<div id="${id}" style="margin-top: 0.5em;"></div>
</div>
`;
2026-05-12 23:50:49 +00:00
}
}
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`;
});
}
// Message Rendering & State
let lastUserList = [];
const seenMessageTimestamps = new Set();
let unreadCount = 0;
let windowFocused = true;
2026-05-12 23:50:49 +00:00
window.addEventListener('focus', () => {
windowFocused = true;
unreadCount = 0;
document.title = `${room} - Transient.chat`;
});
2026-05-12 23:50:49 +00:00
window.addEventListener('blur', () => {
windowFocused = false;
});
2026-05-12 23:50:49 +00:00
function renderUsers(users, owner) {
const currentList = users || [];
isRoomOwner = owner === username;
2026-05-12 23:50:49 +00:00
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().catch(() => {});
if (left.length > 0) sounds.out.play().catch(() => {});
2026-05-12 23:50:49 +00:00
}
lastUserList = currentList;
currentList.forEach(user => {
const div = document.createElement("div");
div.textContent = user + (user === owner ? " 👑" : "");
2026-05-12 23:50:49 +00:00
if (isRoomOwner && user !== username) {
2026-05-12 23:50:49 +00:00
const btn = document.createElement("button");
btn.textContent = "Kick";
btn.className = "kick-button";
btn.onclick = async () => {
if (confirm(`Kick ${user} from this room?`)) {
2026-05-12 23:50:49 +00:00
await fetch(`/chat/${encodeURIComponent(room)}/kick`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ owner: username, key: userKey, target: user }),
2026-05-12 23:50:49 +00:00
});
refresh();
2026-05-12 23:50:49 +00:00
}
};
div.appendChild(btn);
}
usersDiv.appendChild(div);
});
}
async function renderMessages(messagesList) {
if (!Array.isArray(messagesList)) return;
2026-05-12 23:50:49 +00:00
const now = Date.now();
const oneHourAgo = now - 60 * 60 * 1000;
let hasNewIncoming = false;
for (const msg of messagesList) {
if (seenMessageTimestamps.has(msg.timestamp)) continue;
2026-05-12 23:50:49 +00:00
const msgTime = new Date(msg.timestamp).getTime();
if (!isNaN(msgTime) && !firstLoad && msgTime < oneHourAgo) {
continue;
}
2026-05-12 23:50:49 +00:00
seenMessageTimestamps.add(msg.timestamp);
2026-05-12 23:50:49 +00:00
const div = document.createElement("div");
div.classList.add("message");
div.setAttribute("data-timestamp", msg.timestamp);
if (msg.username === username) {
div.classList.add("my-message");
} else if (!firstLoad) {
hasNewIncoming = true;
2026-05-12 23:50:49 +00:00
}
// System notices
if (msg.username === "SYSTEM" || msg.system_notice) {
div.classList.add("system-message");
div.innerHTML = `<strong>📢 SYSTEM:</strong> ${escapeHtml(msg.system_notice || msg.text || "")}`;
messagesDiv.appendChild(div);
continue;
}
// Encrypted chat messages
let textToRender = "";
let audioToRender = null;
if (msg.ciphertext && msg.iv && currentRoomKey) {
try {
const decrypted = await window.decryptRoomPayload(currentRoomKey, msg.ciphertext, msg.iv);
textToRender = decrypted.text || "";
audioToRender = decrypted.audio || null;
} catch (err) {
textToRender = "⚠️ Decryption error (corrupted payload)";
}
} else {
textToRender = "🔒 Encrypted Message";
}
div.innerHTML = `<strong>${escapeHtml(msg.username)}:</strong> ${linkify(textToRender)}`;
if (audioToRender) {
2026-05-12 23:50:49 +00:00
const audioContainer = document.createElement("div");
audioContainer.className = "audio-container";
const audioElement = document.createElement("audio");
audioElement.controls = true;
audioElement.src = audioToRender;
audioElement.className = "voice-message-audio";
audioContainer.appendChild(audioElement);
div.appendChild(audioContainer);
2026-05-12 23:50:49 +00:00
if (autoplayEnabled && !firstLoad && msg.username !== username) {
audioQueue.push(audioElement);
2026-05-12 23:50:49 +00:00
processAudioQueue();
}
}
messagesDiv.appendChild(div);
}
2026-05-12 23:50:49 +00:00
if (hasNewIncoming && soundEnabled) {
sounds.message.play().catch(() => {});
2026-05-12 23:50:49 +00:00
}
if (hasNewIncoming && !windowFocused) {
unreadCount++;
document.title = `(${unreadCount}) ${room} - Transient.chat`;
2026-05-12 23:50:49 +00:00
}
messagesDiv.scrollTop = messagesDiv.scrollHeight;
2026-05-12 23:50:49 +00:00
}
function cleanExpiredMessages() {
const oneHourAgo = Date.now() - 60 * 60 * 1000;
const msgDivs = document.querySelectorAll("#messages .message");
msgDivs.forEach(div => {
const ts = div.getAttribute("data-timestamp");
if (ts) {
const t = new Date(ts).getTime();
if (!isNaN(t) && t < oneHourAgo) {
div.remove();
seenMessageTimestamps.delete(ts);
}
}
2026-05-12 23:50:49 +00:00
});
}
2026-05-12 23:50:49 +00:00
async function refresh() {
if (isRefreshing || !currentRoomKey) return;
2026-05-12 23:50:49 +00:00
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
})
2026-05-12 23:50:49 +00:00
});
if (res.status === 401) {
// Room authorization required or expired
sessionStorage.removeItem(sessionPasswordKey);
showPasswordModal();
isRefreshing = false;
2026-05-12 23:50:49 +00:00
return;
}
if (res.status === 403) {
alert("You are not authorized in this room or have been kicked.");
window.location.href = "/server_list.html";
return;
}
if (res.ok) {
const data = await res.json();
renderUsers(data.users, data.owner);
await renderMessages(data.messages);
cleanExpiredMessages();
firstLoad = false;
}
} catch (err) {
console.error("Poll error:", err);
2026-05-12 23:50:49 +00:00
} finally {
isRefreshing = false;
}
}
// Message form submit
if (form) {
form.addEventListener("submit", async (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text || !currentRoomKey) return;
input.value = "";
try {
const payload = { text: text, audio: null };
const encrypted = await window.encryptRoomPayload(currentRoomKey, payload);
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("Send error:", err);
alert("Failed to send encrypted message.");
}
});
}
function startChatSession() {
if (initialWatchPartyRequested) {
setWatchPartyActive(true);
}
refresh();
setInterval(refresh, 1000);
setInterval(syncWatchPartyFromServer, 1000);
}
2026-05-12 23:50:49 +00:00
});