radchat/public/index.js
2025-01-22 20:04:47 -06:00

832 lines
21 KiB
JavaScript

function toggleSettings() {
const panel = document.getElementById("settings-panel");
panel.style.display = panel.style.display === "block"
? "none"
: "block";
if (panel.style.display === "block") {
const username = document.getElementById("username");
username.focus();
username.selectionStart = username.selectionEnd = username.value
.length;
}
}
function toggleUsers() {
const panel = document.getElementById("users-panel");
panel.style.display = panel.style.display === "block"
? "none"
: "block";
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute(
"data-theme",
);
const newTheme = currentTheme === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", newTheme);
localStorage.setItem("theme", newTheme);
}
function replaceDiv(oldDiv, newDiv) {
oldDiv.parentNode.replaceChild(newDiv, oldDiv);
}
function cancelEditMessage(messageId) {
const editMessageDiv = document.getElementById(
".edit-message@" + messageId,
);
replaceDiv(editMessageDiv, editMessageOriginalDiv);
editMessageOriginalDiv = null;
editing = false;
editMessageNumber = null;
}
async function submitEditMessage(messageId) {
const editMessageDiv = document.getElementById(
".edit-message@" + messageId,
);
const content =
editMessageOriginalDiv.querySelector(".content").textContent;
const newContent =
editMessageDiv.querySelector(".edit-message-textarea").value;
if (content === null) {
cancelEditMessage(messageId);
return;
}
if (content === "") {
cancelEditMessage(messageId);
deleteMessage(messageId);
return;
}
if (newContent === content) {
cancelEditMessage(messageId);
return;
}
try {
const response = await fetch("/messages", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
messageId: messageId,
messageContent: newContent,
}),
});
if (response.ok) {
editing = false;
editMessageNumber = null;
editMessageOriginalDiv = null;
updateMessagesInPlace();
} else {
console.error("Failed to edit message");
}
} catch (error) {
console.error("Error editing message:", error);
}
}
editMessageOriginalDiv = null;
editMessageNumber = null;
editing = false;
async function editMessage(messageId) {
if (editing) {
cancelEditMessage(editMessageNumber);
}
editing = true;
editMessageNumber = messageId;
const originalMessageDiv = document.getElementById(
".message@" + messageId,
);
const height = originalMessageDiv.scrollHeight;
const queryContent = originalMessageDiv.querySelector(".content");
// we need to replace all the html elements with their text content, this includes links like img, a, iframe
queryContent.querySelectorAll("a").forEach((link) => {
link.textContent = link.href;
});
queryContent.querySelectorAll("img").forEach((img) => {
img.textContent = img.src;
});
queryContent.querySelectorAll("iframe").forEach((iframe) => {
iframe.textContent = iframe.src;
});
const content =
originalMessageDiv.querySelector(".content").textContent;
editMessageOriginalDiv = originalMessageDiv.cloneNode(true);
const editMessageDiv = document.createElement("div");
const editTextArea = document.createElement("textarea");
editTextArea.className = "edit-message-textarea";
editTextArea.id = "edit-message-textarea";
editTextArea.value = content;
editTextArea.style.height = height + "px";
editMessageDiv.className = "edit-message";
editMessageDiv.id = ".edit-message@" + messageId;
editMessageDiv.innerHTML += `<div class="button-container">` +
`<button class="edit-message-button" onclick="submitEditMessage('${messageId}')">Submit</button>` +
`<button class="edit-message-button" onclick="cancelEditMessage('${messageId}')">Cancel</button>` +
`</div>`;
editMessageDiv.appendChild(editTextArea);
replaceDiv(originalMessageDiv, editMessageDiv);
}
async function deleteMessage(messageId) {
if (confirm("Delete this message?")) {
try {
const response = await fetch("/messages", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ messageId: messageId }),
});
if (response.ok) {
updateMessagesInPlace();
} else {
console.error("Failed to delete message");
}
} catch (error) {
console.error("Error deleting message:", error);
}
}
}
async function updateMessagesInPlace() {
lastMessageCount = await getMessageCount();
const currentScrollLocation = getScrollLocation();
loadMessages(true, currentScrollLocation);
}
function getScrollLocation() {
const messagesDiv = document.getElementById("messages");
return messagesDiv.scrollTop;
}
function setScrollLocation(height) {
const messagesDiv = document.getElementById("messages");
messagesDiv.scrollTop = height;
}
document.addEventListener("click", function(event) {
const settingsPanel = document.getElementById("settings-panel");
const settingsButton = document.querySelector(".settings-button");
const usersPanel = document.getElementById("users-panel");
const usersButton = document.querySelector(".users-button");
if (
!settingsPanel.contains(event.target) &&
!settingsButton.contains(event.target)
) {
settingsPanel.style.display = "none";
}
if (
!usersPanel.contains(event.target) &&
!usersButton.contains(event.target)
) {
usersPanel.style.display = "none";
}
});
document.addEventListener("keypress", function(event) {
if (event.key === "Enter" && !event.shiftKey) {
const settingsPanel = document.getElementById("settings-panel");
const inputPanel = document.getElementById("message");
if (settingsPanel.contains(event.target)) {
setUsername();
}
if (inputPanel.contains(event.target)) {
event.preventDefault();
sendMessage();
}
}
});
document.addEventListener("input", function(_) {
const msg = document.getElementById("message");
msg.style.height = "auto";
msg.style.height = (msg.scrollHeight) + "px";
});
document.addEventListener("blur", function(_) {
const msg = document.getElementById("message");
msg.style.height = "auto";
}, true);
async function loadUsers() {
try {
const response = await fetch("/users");
const data = await response.json();
const usersList = document.getElementById("users-list");
usersList.innerHTML = "";
data.users.sort().forEach((user) => {
const userDiv = document.createElement("div");
userDiv.className = "user-item";
userDiv.textContent = user;
usersList.appendChild(userDiv);
});
} catch (error) {
console.error("Error loading users:", error);
}
}
async function getMessageCount() {
try {
const response = await fetch("/messages/length");
const text = await response.json();
return text.length;
} catch (error) {
console.error("Error getting message count:", error);
}
}
let lastMessageCount = 0;
async function loadMessages(forceUpdate = false, scrollLocation) {
if (editing) {
return;
}
try {
const newMessageCount = await getMessageCount();
const update = newMessageCount != lastMessageCount ||
lastMessageCount === 0 || forceUpdate;
let messagesDiv = document.getElementById("messages");
while (messagesDiv === null) {
await new Promise((resolve) =>
setTimeout(resolve, 100)
);
messagesDiv = document.getElementById(
"messages",
);
}
if (messagesDiv.scrollTop != bottom) {
// show a button to scroll to the bottom
const scrollToBottomButton = document
.getElementById(
"scroll",
);
scrollToBottomButton.style.display = "block";
scrollToBottomButton.onclick = scrollToBottom;
} else {
// hide the button
const scrollToBottomButton = document
.getElementById(
"scroll",
);
scrollToBottomButton.style.display = "none";
}
if (update) {
const response = await fetch("/messages");
const text = await response.text();
const tempDiv = document.createElement("div");
tempDiv.innerHTML = text;
const messages = tempDiv.getElementsByTagName("p");
messagesDiv.innerHTML = "";
Array.from(messages).forEach((msg) => {
const messageDiv = document.createElement(
"div",
);
messageDiv.className = "message";
const [
messageId,
username,
timestamp,
content,
] = msg
.innerHTML.split(
"<br>",
);
messageDiv.id = ".message@" + messageId;
const usernameDiv = document.createElement(
"div",
);
usernameDiv.innerHTML = username;
compareUsername = usernameDiv.textContent;
const embeddedContent = contentEmbedding(
content,
);
let deleteHtml = "";
let editHtml = "";
const parser = new DOMParser();
const contentHtmlString = content;
const doc = parser.parseFromString(
contentHtmlString,
"text/html",
);
const contentString =
doc.querySelector("span").textContent;
const isMultiline = contentString.match(
"\\n",
) && true || false;
if (
compareUsername ===
document.getElementById(
"current-user",
).textContent
) {
deleteHtml =
`<button class="delete-button" title="Delete message" onclick="deleteMessage('${messageId}')" style="display: inline;">🗑️</button>`;
editHtml =
`<button class="edit-button" title="Edit message" onclick="editMessage('${messageId}')" style="display: inline;">📝</button>`;
}
messageDiv.innerHTML = `
<div class="message-header">
<div class="username">${username} ${timestamp} ${deleteHtml} ${editHtml}</div>
</div>
<div class="content">${embeddedContent}</div>`;
messagesDiv.appendChild(messageDiv);
});
if (scrollLocation !== undefined) {
setScrollLocation(scrollLocation);
} else {
scrollToBottom();
}
lastMessageCount = newMessageCount;
}
} catch (error) {
console.error("Error loading messages:", error);
}
}
function contentEmbedding(content) {
return content.replace(
/(?![^<]*>)(https?:\/\/[^\s<]+)/g,
function(url) {
const videoId = getYouTubeID(
url,
);
if (videoId) {
return `<div class="video-embed"><iframe
width="100%"
height="315"
src="https://www.youtube.com/embed/${videoId}"
frameborder="0"
onerror="console.log('Video failed to load:', this.src); this.value=this.src"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe></div>`;
} else if (isImageUrl(url)) {
return `<div class="image-embed"><img
src="${url}"
alt="${url}"
loading="lazy"
onerror="console.log('Image failed to load:', this.src); this.value=this.src"></div>`;
}
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`;
},
);
}
function isImageUrl(url) {
return url.match(/\.(jpeg|jpg|gif|png|webp|bmp)($|\?)/i) != null;
}
function getYouTubeID(url) {
// First check if it's a Shorts URL
if (url.includes("/shorts/")) {
const shortsMatch = url.match(/\/shorts\/([^/?]+)/);
return shortsMatch ? shortsMatch[1] : false;
}
// Otherwise check regular YouTube URLs
const regExp =
/^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/;
const match = url.match(regExp);
return (match && match[7].length == 11) ? match[7] : false;
}
function scrollToBottom() {
const messagesDiv = document.getElementById("messages");
messagesDiv.scrollTop = messagesDiv.scrollHeight;
bottom = messagesDiv.scrollTop;
}
async function checkUsername() {
try {
const response = await fetch("/username/status");
const data = await response.json();
if (!data.hasUsername) {
document.getElementById("settings-panel").style
.display = "block";
const username = document.getElementById("username");
username.focus();
username.selectionStart =
username.selectionEnd =
username.value.length;
}
} catch (error) {
console.error("Error checking username status:", error);
}
}
async function updateCurrentUser() {
try {
const response = await fetch("/username/status");
const data = await response.json();
const userDiv = document.getElementById("current-user");
if (data.hasUsername) {
userDiv.textContent = data.username;
} else {
userDiv.textContent = "";
}
} catch (error) {
console.error("Error getting username:", error);
}
}
async function setUsername() {
const username = document.getElementById("username").value;
if (!username) {
showUsernameStatus("Please enter a username", "red");
return;
}
try {
const response = await fetch("/username", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username: username }),
});
const data = await response.json();
if (response.ok) {
showUsernameStatus(
"Username set successfully!",
"green",
);
updateCurrentUser();
setTimeout(() => {
document.getElementById("settings-panel").style
.display = "none";
}, 750);
updateMessagesInPlace();
} else {
showUsernameStatus(
data.error || "Failed to set username",
"red",
);
}
} catch (error) {
showUsernameStatus("Error connecting to server", "red");
}
}
let lastMessage = "";
async function sendMessage() {
const messageInput = document.getElementById("message");
const message = messageInput.value;
if (!message) {
return;
}
try {
lastMessage = message;
const response = await fetch("/messages", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ message: message }),
});
const data = await response.json();
if (response.ok) {
messageInput.value = "";
messageInput.style.height = "auto";
loadMessages(true);
} else {
showStatus(
data.error || "Failed to send message",
"red",
);
}
} catch (error) {
showStatus("Error connecting to server", "red");
}
}
function showStatus(message, color) {
const status = document.getElementById("status");
status.textContent = message;
status.style.color = color;
setTimeout(() => {
status.textContent = "";
}, 3000);
}
function showUsernameStatus(message, color) {
const status = document.getElementById("username-status");
status.textContent = message;
status.style.color = color;
setTimeout(() => {
status.textContent = "";
}, 3000);
}
async function pingCheck() {
try {
await fetch("/ping", { method: "POST" });
} catch (error) {
console.error("Ping failed:", error);
}
}
async function timeZoneCheck() {
try {
const timeZone =
Intl.DateTimeFormat().resolvedOptions().timeZone;
const response = await fetch("/timezone", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ timezone: timeZone }),
});
const data = await response.json();
if (!response.ok) {
console.error("Failed to set timezone:", data.error);
}
} catch (error) {
console.error("Error checking timezone:", error);
}
}
function initializeTheme() {
const savedTheme = localStorage.getItem("theme");
if (savedTheme) {
document.documentElement.setAttribute("data-theme", savedTheme);
} else {
// Check system preference
if (
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: light)")
.matches
) {
document.documentElement.setAttribute(
"data-theme",
"light",
);
} else {
document.documentElement.setAttribute(
"data-theme",
"dark",
);
}
}
}
document.addEventListener("keyup", function(event) {
const inputPanel = document.getElementById("message");
if (inputPanel.contains(event.target) && event.key === "ArrowUp") {
if (inputPanel.value === "") {
inputPanel.value = lastMessage;
}
}
});
let bottom = 0;
async function initialize() {
await checkUsername();
await updateCurrentUser();
await timeZoneCheck();
await loadMessages(true);
initializePanels();
initializeTheme();
initializeSearchBox();
setInterval(loadMessages, 1000);
setInterval(loadUsers, 1000);
setInterval(pingCheck, 3000);
setTimeout(scrollToBottom, 100);
}
function initializePanels() {
usersPanel = document.getElementById("users-panel");
if (usersPanel) {
usersPanel.style.display = "none";
}
settingsPanel = document.getElementById("settings-panel");
if (settingsPanel) {
settingsPanel.style.display = "none";
}
}
function initializeSearchBox() {
const searchContainer = document.getElementById("searchContainer");
const searchButton = document.getElementById("searchButton");
const searchInput = document.getElementById("searchInput");
const searchCount = document.getElementById("searchCount");
let currentMatchIndex = -1;
let matches = [];
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function clearHighlights() {
// Remove all existing highlights
const highlights = document.querySelectorAll(
".search-highlight",
);
highlights.forEach((highlight) => {
const parent = highlight.parentNode;
parent.replaceChild(
document.createTextNode(highlight.textContent),
highlight,
);
parent.normalize();
});
matches = [];
currentMatchIndex = -1;
searchCount.textContent = "";
}
function findTextNodes(element, textNodes = []) {
// Skip certain elements
if (element.nodeType === Node.ELEMENT_NODE) { // Check if it's an element node first
if (
element.tagName === "SCRIPT" ||
element.tagName === "STYLE" ||
element.tagName === "NOSCRIPT" ||
(element.classList &&
element.classList.contains(
"search-container",
)) ||
(element.classList &&
element.classList.contains(
"search-highlight",
))
) {
return textNodes;
}
}
// Check if this node is a text node with non-whitespace content
if (
element.nodeType === Node.TEXT_NODE &&
element.textContent.trim()
) {
textNodes.push(element);
}
// Recursively check all child nodes
const children = element.childNodes;
for (let i = 0; i < children.length; i++) {
findTextNodes(children[i], textNodes);
}
return textNodes;
}
function findAndHighlight(searchText) {
if (!searchText) {
clearHighlights();
return;
}
clearHighlights();
const searchRegex = new RegExp(escapeRegExp(searchText), "gi");
const textNodes = findTextNodes(document.body);
textNodes.forEach((node) => {
const matches = [
...node.textContent.matchAll(searchRegex),
];
if (matches.length > 0) {
const span = document.createElement("span");
span.innerHTML = node.textContent.replace(
searchRegex,
(match) =>
`<span class="search-highlight">${match}</span>`,
);
node.parentNode.replaceChild(span, node);
}
});
// Collect all highlights
matches = Array.from(
document.querySelectorAll(".search-highlight"),
);
if (matches.length > 0) {
currentMatchIndex = 0;
matches[0].classList.add("current");
matches[0].scrollIntoView({
behavior: "smooth",
block: "center",
});
}
// Update count
searchCount.textContent = matches.length > 0
? `${currentMatchIndex + 1}/${matches.length}`
: "No matches";
}
function nextMatch() {
if (matches.length === 0) return;
matches[currentMatchIndex].classList.remove("current");
currentMatchIndex = (currentMatchIndex + 1) % matches.length;
matches[currentMatchIndex].classList.add("current");
matches[currentMatchIndex].scrollIntoView({
behavior: "smooth",
block: "center",
});
searchCount.textContent = `${currentMatchIndex + 1
}/${matches.length}`;
}
searchButton.addEventListener("click", () => {
if (!searchContainer.classList.contains("expanded")) {
searchContainer.classList.add("expanded");
searchInput.focus();
} else {
nextMatch();
}
});
searchInput.addEventListener("input", (e) => {
findAndHighlight(e.target.value.trim());
});
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
nextMatch();
} else if (e.key === "Escape") {
searchContainer.classList.remove("expanded");
searchInput.value = "";
clearHighlights();
}
});
// Debug function to check search coverage (call from console: checkSearchCoverage())
window.checkSearchCoverage = function() {
const textNodes = findTextNodes(document.body);
console.log(
"Total searchable text nodes found:",
textNodes.length,
);
textNodes.forEach((node, i) => {
console.log(`Node ${i + 1}:`, {
text: node.textContent,
parent: node.parentElement.tagName,
path: getNodePath(node),
});
});
};
function getNodePath(node) {
const path = [];
while (node && node.parentElement) {
let index = Array.from(node.parentElement.childNodes)
.indexOf(node);
path.unshift(`${node.parentElement.tagName}[${index}]`);
node = node.parentElement;
}
return path.join(" > ");
}
// Close search when clicking outside
document.addEventListener("click", (e) => {
if (!searchContainer.contains(e.target)) {
searchContainer.classList.remove("expanded");
searchInput.value = "";
clearHighlights();
}
});
}
initialize();