548 lines
20 KiB
JavaScript
548 lines
20 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;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 🔒 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, "'");
|
||
|
|
}
|
||
|
|
|
||
|
|
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)}
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
|
||
|
|
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();
|
||
|
|
});
|
||
|
|
|
||
|
|
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 = () => {
|
||
|
|
const base64Audio = reader.result;
|
||
|
|
// Send immediately
|
||
|
|
fetch(`/chat/${encodeURIComponent(room)}`, {
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify({ username, text: "🎤 Voice Message", audio: base64Audio })
|
||
|
|
}).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);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadUsers() {
|
||
|
|
const res = await fetch(`/chat/${encodeURIComponent(room)}/users`);
|
||
|
|
const data = await res.json();
|
||
|
|
|
||
|
|
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 }),
|
||
|
|
});
|
||
|
|
await loadUsers();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
div.appendChild(btn);
|
||
|
|
}
|
||
|
|
|
||
|
|
usersDiv.appendChild(div);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadMessages() {
|
||
|
|
const url = `/chat/${encodeURIComponent(room)}` + (lastTimestamp ? `?since=${encodeURIComponent(lastTimestamp)}` : "");
|
||
|
|
const res = await fetch(url);
|
||
|
|
const data = await res.json();
|
||
|
|
|
||
|
|
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;
|
||
|
|
});
|
||
|
|
|
||
|
|
newMessages.forEach(msg => {
|
||
|
|
// Skip if already rendered
|
||
|
|
if (seenMessageTimestamps.has(msg.timestamp)) return;
|
||
|
|
|
||
|
|
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");
|
||
|
|
}
|
||
|
|
div.innerHTML = `<strong>${escapeHtml(msg.username)}:</strong> ${linkify(msg.text)}`;
|
||
|
|
|
||
|
|
if (msg.audio) {
|
||
|
|
const audioContainer = document.createElement("div");
|
||
|
|
audioContainer.className = "audio-container";
|
||
|
|
const audio = document.createElement("audio");
|
||
|
|
audio.controls = true;
|
||
|
|
audio.className = "voice-message-audio";
|
||
|
|
|
||
|
|
audio.src = msg.audio;
|
||
|
|
|
||
|
|
// 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", e => {
|
||
|
|
e.preventDefault();
|
||
|
|
const text = input.value.trim();
|
||
|
|
if (!text) return;
|
||
|
|
|
||
|
|
fetch(`/chat/${encodeURIComponent(room)}`, {
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify({ username, text })
|
||
|
|
}).then(() => {
|
||
|
|
input.value = "";
|
||
|
|
refresh(); // force fetch after post to ensure sync
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
async function refresh() {
|
||
|
|
if (isRefreshing) return;
|
||
|
|
isRefreshing = true;
|
||
|
|
|
||
|
|
try {
|
||
|
|
// 🔒 Check if user is still valid
|
||
|
|
const res = await fetch('/validate', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ username, key: userKey })
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!res.ok) {
|
||
|
|
localStorage.clear();
|
||
|
|
alert("Session expired or you were removed.");
|
||
|
|
window.location.href = '/';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
await refreshUserPresence();
|
||
|
|
await loadMessages();
|
||
|
|
await loadUsers();
|
||
|
|
cleanExpiredMessages();
|
||
|
|
} catch (e) {
|
||
|
|
console.error("Refresh error:", e);
|
||
|
|
} finally {
|
||
|
|
firstLoad = false;
|
||
|
|
isRefreshing = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// First full load, then enable polling
|
||
|
|
await refresh();
|
||
|
|
setInterval(refresh, 1000);
|
||
|
|
});
|