783 lines
No EOL
30 KiB
JavaScript
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, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
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();
|
|
}); |