863 lines
No EOL
33 KiB
JavaScript
863 lines
No EOL
33 KiB
JavaScript
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, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
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();
|
|
|
|
// 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 `
|
|
<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>
|
|
`;
|
|
} 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>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// Image
|
|
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>
|
|
`;
|
|
} 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>
|
|
`;
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
window.addEventListener('focus', () => {
|
|
windowFocused = true;
|
|
unreadCount = 0;
|
|
document.title = `${room} - Transient.chat`;
|
|
});
|
|
|
|
window.addEventListener('blur', () => {
|
|
windowFocused = false;
|
|
});
|
|
|
|
function renderUsers(users, owner) {
|
|
const currentList = users || [];
|
|
isRoomOwner = 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().catch(() => {});
|
|
if (left.length > 0) sounds.out.play().catch(() => {});
|
|
}
|
|
|
|
lastUserList = currentList;
|
|
|
|
currentList.forEach(user => {
|
|
const div = document.createElement("div");
|
|
div.textContent = user + (user === owner ? " 👑" : "");
|
|
|
|
if (isRoomOwner && user !== username) {
|
|
const btn = document.createElement("button");
|
|
btn.textContent = "Kick";
|
|
btn.className = "kick-button";
|
|
btn.onclick = async () => {
|
|
if (confirm(`Kick ${user} from this room?`)) {
|
|
await fetch(`/chat/${encodeURIComponent(room)}/kick`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ owner: username, key: userKey, target: user }),
|
|
});
|
|
refresh();
|
|
}
|
|
};
|
|
div.appendChild(btn);
|
|
}
|
|
|
|
usersDiv.appendChild(div);
|
|
});
|
|
}
|
|
|
|
async function renderMessages(messagesList) {
|
|
if (!Array.isArray(messagesList)) return;
|
|
|
|
const now = Date.now();
|
|
const oneHourAgo = now - 60 * 60 * 1000;
|
|
let hasNewIncoming = false;
|
|
|
|
for (const msg of messagesList) {
|
|
if (seenMessageTimestamps.has(msg.timestamp)) continue;
|
|
|
|
const msgTime = new Date(msg.timestamp).getTime();
|
|
if (!isNaN(msgTime) && !firstLoad && msgTime < oneHourAgo) {
|
|
continue;
|
|
}
|
|
|
|
seenMessageTimestamps.add(msg.timestamp);
|
|
|
|
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;
|
|
}
|
|
|
|
// 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) {
|
|
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);
|
|
|
|
if (autoplayEnabled && !firstLoad && msg.username !== username) {
|
|
audioQueue.push(audioElement);
|
|
processAudioQueue();
|
|
}
|
|
}
|
|
|
|
messagesDiv.appendChild(div);
|
|
}
|
|
|
|
if (hasNewIncoming && soundEnabled) {
|
|
sounds.message.play().catch(() => {});
|
|
}
|
|
|
|
if (hasNewIncoming && !windowFocused) {
|
|
unreadCount++;
|
|
document.title = `(${unreadCount}) ${room} - Transient.chat`;
|
|
}
|
|
|
|
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function refresh() {
|
|
if (isRefreshing || !currentRoomKey) 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
|
|
})
|
|
});
|
|
|
|
if (res.status === 401) {
|
|
// Room authorization required or expired
|
|
sessionStorage.removeItem(sessionPasswordKey);
|
|
showPasswordModal();
|
|
isRefreshing = false;
|
|
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);
|
|
} 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);
|
|
}
|
|
}); |