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

783 lines
No EOL
30 KiB
JavaScript

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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function updateRoomTitle() {
const e2ee = window.isCryptoEnabled && window.isCryptoEnabled();
document.getElementById("room-title").innerHTML = `
<div class="title-row">
<a href="server_list.html">
<img src="/assets/images/transientchat-blue.png" alt="Transient.chat" style="height: 60px;"/>
</a>
Room: ${escapeHtml(room)}
${e2ee ? '<span style="color: #4caf50; font-size: 0.8em; margin-left: 10px;">🔒 E2EE Active</span>' : ''}
</div>
`;
}
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 = `<em style="color: #888; font-size: 0.95em;">No active keys. Messages you send will be plaintext.</em>`;
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 `
<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: 640px; height: 360px; 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.title = 'YouTube video player';
iframe.frameBorder = 0;
iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share';
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
iframe.allowFullscreen = true;
iframe.style.width = '640px';
iframe.style.height = '360px';
iframe.style.maxWidth = '100%';
iframe.style.borderRadius = '4px';
document.getElementById('${id}').appendChild(iframe);
">
Show external content
</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="width: 100%; height: auto; 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.width = '100%';
img.style.height = 'auto';
img.style.borderRadius = '4px';
document.getElementById('${id}').appendChild(img);
">
Show external content
</button>
<div id="${id}" style="margin-top: 0.5em;"></div>
</div>
`;
}
}
// 🔗 Default fallback: plain link
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`;
});
}
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 = `<strong>${escapeHtml(msg.username)}:</strong> ${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();
});