add chat :p
All checks were successful
Code quality checks / biome (push) Successful in 22s

This commit is contained in:
creations 2025-06-08 19:57:50 -04:00
parent ae3224c18b
commit a3b03fdec4
Signed by: creations
GPG key ID: 8F553AA4320FC711
14 changed files with 2200 additions and 37 deletions

480
public/js/chat.js Normal file
View file

@ -0,0 +1,480 @@
class ChatManager {
constructor() {
this.chatId = this.getChatId();
this.messages = [];
this.websocket = null;
this.isDestroyed = false;
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.hasJoinedChat = false;
this.hasLoadedHistory = false;
this.settings = {
showImages: localStorage.getItem("chat-show-images") !== "false",
use24hTime: localStorage.getItem("chat-24h-time") !== "false",
};
this.initializeElements();
this.bindEvents();
this.loadStoredUsername();
this.connectWebSocket();
}
getChatId() {
const metaTag = document.querySelector('meta[name="chat-id"]');
if (!metaTag) {
throw new Error("Chat ID not found in meta tags");
}
return metaTag.content;
}
initializeElements() {
this.elements = {
loadingState: document.getElementById("loadingState"),
errorState: document.getElementById("errorState"),
errorMessage: document.getElementById("errorMessage"),
retryBtn: document.getElementById("retryBtn"),
messagesContainer: document.getElementById("messagesContainer"),
emptyState: document.getElementById("emptyState"),
chatForm: document.getElementById("chatForm"),
usernameInput: document.getElementById("usernameInput"),
messageInput: document.getElementById("messageInput"),
sendBtn: document.getElementById("sendBtn"),
settingsBtn: document.getElementById("settingsBtn"),
settingsDropdown: document.getElementById("settingsDropdown"),
imageToggle: document.getElementById("imageToggle"),
timeToggle: document.getElementById("timeToggle"),
};
this.elements.imageToggle.checked = this.settings.showImages;
this.elements.timeToggle.checked = this.settings.use24hTime;
}
bindEvents() {
this.elements.chatForm.addEventListener("submit", (e) => {
e.preventDefault();
this.sendMessage();
});
this.elements.retryBtn.addEventListener("click", () => {
this.connectWebSocket();
});
this.elements.usernameInput.addEventListener("input", () => {
const username = this.elements.usernameInput.value.trim();
if (username) {
localStorage.setItem("chat-username", username);
}
this.validateForm();
});
this.elements.messageInput.addEventListener("input", () => {
this.validateForm();
});
this.elements.settingsBtn.addEventListener("click", () => {
this.toggleSettingsDropdown();
});
this.elements.imageToggle.addEventListener("change", () => {
this.settings.showImages = this.elements.imageToggle.checked;
localStorage.setItem("chat-show-images", this.settings.showImages);
this.renderMessages();
});
this.elements.timeToggle.addEventListener("change", () => {
this.settings.use24hTime = this.elements.timeToggle.checked;
localStorage.setItem("chat-24h-time", this.settings.use24hTime);
this.renderMessages();
});
document.addEventListener("click", (e) => {
if (!e.target.closest(".settings-container")) {
this.elements.settingsDropdown.style.display = "none";
}
});
document.addEventListener("visibilitychange", () => {
if (!document.hidden && !this.isConnected) {
this.connectWebSocket();
}
});
window.addEventListener("beforeunload", () => {
this.cleanup();
});
this.elements.messageInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
this.sendMessage();
}
});
}
loadStoredUsername() {
const storedUsername = localStorage.getItem("chat-username");
if (storedUsername) {
this.elements.usernameInput.value = storedUsername;
}
this.validateForm();
}
validateForm() {
const username = this.elements.usernameInput.value.trim();
const message = this.elements.messageInput.value.trim();
const isValid = username.length > 0 && message.length > 0;
this.elements.sendBtn.disabled = !isValid;
}
connectWebSocket() {
if (this.websocket) {
this.websocket.close();
}
this.showLoading();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}`;
try {
this.websocket = new WebSocket(wsUrl);
this.websocket.onopen = () => {
console.log("WebSocket connected");
this.isConnected = true;
this.reconnectAttempts = 0;
this.hasLoadedHistory = false;
this.loadChatHistory();
};
this.websocket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.handleWebSocketMessage(message);
} catch (error) {
console.error("Error parsing WebSocket message:", error);
}
};
this.websocket.onerror = (error) => {
console.error("WebSocket error:", error);
this.isConnected = false;
};
this.websocket.onclose = (event) => {
console.log("WebSocket closed:", event.code, event.reason);
this.isConnected = false;
if (
!this.isDestroyed &&
this.reconnectAttempts < this.maxReconnectAttempts
) {
this.reconnectAttempts++;
console.log(
`Attempting to reconnect... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`,
);
setTimeout(
() => {
if (!this.isDestroyed) {
this.connectWebSocket();
}
},
Math.min(1000 * this.reconnectAttempts, 5000),
);
} else if (!this.isDestroyed) {
this.showError("Connection lost. Please refresh the page.");
}
};
} catch (error) {
console.error("Failed to create WebSocket:", error);
this.showError("Failed to connect to chat server");
}
}
loadChatHistory() {
if (
this.websocket &&
this.websocket.readyState === WebSocket.OPEN &&
!this.hasLoadedHistory
) {
this.websocket.send(
JSON.stringify({
type: "load_history",
streamId: this.chatId,
}),
);
this.hasLoadedHistory = true;
}
}
handleWebSocketMessage(message) {
switch (message.type) {
case "connected":
console.log("Connected to chat server");
this.loadChatHistory();
break;
case "chat_history":
this.messages = message.data.messages || [];
this.renderMessages();
this.showMessages();
this.scrollToBottom();
break;
case "chat_message":
this.messages.push(message.data);
this.renderMessages();
this.scrollToBottom();
break;
case "user_joined":
this.showSystemMessage(`${message.data.username} joined`);
break;
case "user_left":
this.showSystemMessage(`${message.data.username} left`);
break;
case "error":
console.error("WebSocket error:", message.error);
this.showError(message.error);
break;
default:
console.log("Unknown message type:", message.type);
}
}
joinChat(username) {
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
this.websocket.send(
JSON.stringify({
type: "join_chat",
streamId: this.chatId,
username: username,
}),
);
}
}
sendMessage() {
const username = this.elements.usernameInput.value.trim();
const message = this.elements.messageInput.value.trim();
if (!username || !message) {
return;
}
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
this.showError("Not connected to chat server");
return;
}
if (!this.hasJoinedChat) {
this.joinChat(username);
this.hasJoinedChat = true;
}
this.elements.sendBtn.disabled = true;
this.elements.messageInput.disabled = true;
try {
this.websocket.send(
JSON.stringify({
type: "send_message",
message: message,
}),
);
this.elements.messageInput.value = "";
localStorage.setItem("chat-username", username);
} catch (error) {
console.error("Failed to send message:", error);
} finally {
this.elements.messageInput.disabled = false;
this.validateForm();
this.elements.messageInput.focus();
}
}
toggleSettingsDropdown() {
const dropdown = this.elements.settingsDropdown;
dropdown.style.display =
dropdown.style.display === "block" ? "none" : "block";
}
processMessageContent(text) {
if (!this.settings.showImages) {
return this.escapeHtml(text);
}
const imageUrlRegex =
/https:\/\/[^\s]+\.(?:jpg|jpeg|png|gif|webp|svg)(?:\?[^\s]*)?/gi;
const parts = text.split(imageUrlRegex);
const imageUrls = text.match(imageUrlRegex) || [];
let processedContent = "";
for (let i = 0; i < parts.length; i++) {
if (parts[i]) {
processedContent += this.escapeHtml(parts[i]);
}
if (imageUrls[i]) {
processedContent += this.createImageElement(imageUrls[i]);
}
}
return processedContent;
}
createImageElement(url) {
const imageId = `img_${Math.random().toString(36).substr(2, 9)}`;
return `
<div class="message-image-container">
<img
id="${imageId}"
src="${this.escapeHtml(url)}"
alt="Shared image"
class="message-image"
loading="lazy"
style="max-width: 300px; max-height: 200px; border-radius: 8px; margin: 8px 0; display: block;"
>
</div>
`;
}
renderMessages() {
this.elements.messagesContainer.innerHTML = "";
if (this.messages.length === 0) {
this.showEmpty();
return;
}
for (const message of this.messages) {
const messageElement = this.createMessageElement(message);
this.elements.messagesContainer.appendChild(messageElement);
}
}
createMessageElement(message) {
const messageDiv = document.createElement("div");
messageDiv.className = "message";
const time = new Date(message.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: !this.settings.use24hTime,
});
const processedContent = this.processMessageContent(message.message);
messageDiv.innerHTML = `
<div class="message-header">
<span class="message-username">${this.escapeHtml(message.username)}</span>
<span class="message-time">${time}</span>
</div>
<div class="message-content">${processedContent}</div>
`;
setTimeout(() => {
const images = messageDiv.querySelectorAll(".message-image");
for (const img of images) {
img.addEventListener("error", () => {
img.style.display = "none";
const errorDiv = document.createElement("div");
errorDiv.style.cssText =
"padding: 8px; color: #ff6b6b; font-style: italic; background: rgba(255,107,107,0.1); border-radius: 4px; margin: 8px 0;";
errorDiv.textContent = "Failed to load image";
img.parentNode.appendChild(errorDiv);
});
}
}, 0);
return messageDiv;
}
showSystemMessage(text) {
const messageDiv = document.createElement("div");
messageDiv.className = "message system-message";
messageDiv.innerHTML = `
<div class="message-content">
<em>${this.escapeHtml(text)}</em>
</div>
`;
this.elements.messagesContainer.appendChild(messageDiv);
this.scrollToBottom();
}
scrollToBottom() {
setTimeout(() => {
const container = this.elements.messagesContainer;
container.scrollTop = container.scrollHeight;
}, 50);
}
cleanup() {
this.isDestroyed = true;
if (this.websocket) {
if (this.websocket.readyState === WebSocket.OPEN) {
this.websocket.send(
JSON.stringify({
type: "leave_chat",
}),
);
}
this.websocket.close();
this.websocket = null;
}
}
escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
showLoading() {
this.elements.loadingState.style.display = "flex";
this.elements.errorState.style.display = "none";
this.elements.messagesContainer.style.display = "none";
this.elements.emptyState.style.display = "none";
}
showError(message) {
this.elements.loadingState.style.display = "none";
this.elements.errorState.style.display = "flex";
this.elements.messagesContainer.style.display = "none";
this.elements.emptyState.style.display = "none";
this.elements.errorMessage.textContent = message;
}
showMessages() {
this.elements.loadingState.style.display = "none";
this.elements.errorState.style.display = "none";
this.elements.messagesContainer.style.display = "flex";
this.elements.emptyState.style.display = "none";
}
showEmpty() {
this.elements.loadingState.style.display = "none";
this.elements.errorState.style.display = "none";
this.elements.messagesContainer.style.display = "none";
this.elements.emptyState.style.display = "flex";
}
}
try {
new ChatManager();
} catch (error) {
console.error("Failed to initialize ChatManager:", error);
}