diff --git a/config/index.ts b/config/index.ts index cb3f09b..693d9e5 100644 --- a/config/index.ts +++ b/config/index.ts @@ -10,7 +10,7 @@ const environment: Environment = { const srsUrl = process.env.SRS_URL; function verifyRequiredVariables(): void { - const requiredVariables = ["HOST", "PORT", "SRS_URL"]; + const requiredVariables = ["HOST", "PORT", "SRS_URL", "REDIS_URL"]; let hasError = false; diff --git a/public/chat.html b/public/chat.html new file mode 100644 index 0000000..6792ed9 --- /dev/null +++ b/public/chat.html @@ -0,0 +1,79 @@ + + + + + + + Chat + + + + + + + +
+
+
+ +
+ +
+

Chat Settings

+
+ Show Images + +
+
+ 24-Hour Time + +
+
+
+
+
+
+
+
+

Connecting...

+
+ + + +
+
+
+ + +
+
+
+ + + diff --git a/public/css/chat.css b/public/css/chat.css new file mode 100644 index 0000000..9625b54 --- /dev/null +++ b/public/css/chat.css @@ -0,0 +1,453 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + height: 100vh; + overflow: hidden; +} + +.chat-widget { + height: 100vh; + display: flex; + flex-direction: column; + background: var(--bg-card); + border: 1px solid var(--border-primary); +} + +.chat-header { + padding: 0.5rem; + border-bottom: 1px solid var(--border-primary); + background: var(--bg-secondary); + flex-shrink: 0; +} + +.username-section { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.username-section .username-input { + flex: 1; + padding: 0.375rem; + font-size: 0.75rem; + border: 1px solid var(--border-primary); + border-radius: 0.25rem; + background: var(--bg-input); + color: var(--text-primary); +} + +.username-section .username-input:focus { + outline: none; + border-color: var(--accent-blue); +} + +.username-section .username-input::placeholder { + color: var(--text-secondary); +} + +.chat-messages { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} + +.loading-state, +.error-state, +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1rem; + flex: 1; +} + +.loading-state p, +.error-state p, +.empty-state p { + margin: 0.25rem 0 0 0; + font-size: 0.75rem; + color: var(--text-secondary); +} + +.error-state p { + color: var(--accent-red); +} + +.retry-btn { + background: var(--accent-blue); + color: white; + border: none; + padding: 0.375rem 0.75rem; + border-radius: 0.25rem; + font-size: 0.75rem; + cursor: pointer; + margin-top: 0.375rem; +} + +.retry-btn:hover { + background: #2563eb; +} + +.messages-container { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.message { + padding: 0.375rem 0.5rem; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 0.375rem; + font-size: 0.75rem; + animation: messageSlideIn 0.2s ease-out; +} + +.message.system-message { + background: var(--bg-primary); + opacity: 0.7; + text-align: center; + font-style: italic; + padding: 0.25rem 0.5rem; +} + +.message-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.125rem; +} + +.message-username { + font-weight: 600; + color: var(--accent-blue); + font-size: 0.6875rem; +} + +.message-time { + font-size: 0.625rem; + color: var(--text-muted); +} + +.message-content { + color: var(--text-primary); + word-wrap: break-word; + line-height: 1.2; + font-size: 0.6875rem; +} + +.chat-input-form { + padding: 0.5rem; + border-top: 1px solid var(--border-primary); + background: var(--bg-secondary); + flex-shrink: 0; +} + +.message-row { + display: flex; + gap: 0.375rem; + align-items: center; +} + +.message-input { + flex: 1; + padding: 0.375rem; + font-size: 0.75rem; + border: 1px solid var(--border-primary); + border-radius: 0.25rem; + background: var(--bg-input); + color: var(--text-primary); +} + +.message-input:focus { + outline: none; + border-color: var(--accent-blue); +} + +.message-input::placeholder { + color: var(--text-secondary); +} + +.send-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: var(--accent-blue); + border: none; + border-radius: 0.25rem; + color: white; + cursor: pointer; + flex-shrink: 0; +} + +.send-btn:hover:not(:disabled) { + background: #2563eb; +} + +.send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.send-btn svg { + width: 14px; + height: 14px; +} + +@keyframes messageSlideIn { + from { + opacity: 0; + transform: translateY(3px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.messages-container::-webkit-scrollbar { + width: 4px; +} + +.messages-container::-webkit-scrollbar-track { + background: var(--bg-primary); +} + +.messages-container::-webkit-scrollbar-thumb { + background: var(--border-secondary); + border-radius: 2px; +} + +.messages-container::-webkit-scrollbar-thumb:hover { + background: var(--border-accent); +} + +.settings-container { + position: relative; + display: inline-block; +} + +.settings-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + border-radius: 0.25rem; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.settings-btn:hover { + color: var(--text-primary); + background: var(--bg-primary); +} + +.settings-btn svg { + width: 14px; + height: 14px; +} + +.settings-dropdown { + position: absolute; + top: 100%; + right: 0; + background: var(--bg-card); + border: 1px solid var(--border-primary); + border-radius: 0.375rem; + padding: 0.5rem; + min-width: 160px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 1000; + display: none; + margin-top: 0.375rem; +} + +.settings-dropdown h4 { + margin: 0 0 0.5rem 0; + color: var(--text-primary); + font-size: 0.75rem; + font-weight: 600; +} + +.setting-item { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.375rem; + padding: 0.125rem 0; +} + +.setting-item:last-child { + margin-bottom: 0; +} + +.setting-label { + color: var(--text-secondary); + font-size: 0.6875rem; +} + +.setting-toggle { + position: relative; + width: 32px; + height: 16px; +} + +.setting-toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--border-secondary); + transition: 0.3s; + border-radius: 16px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 12px; + width: 12px; + left: 2px; + bottom: 2px; + background-color: var(--text-primary); + transition: 0.3s; + border-radius: 50%; +} + +input:checked + .toggle-slider { + background-color: var(--accent-blue); +} + +input:checked + .toggle-slider:before { + transform: translateX(16px); +} + +.message-image-container { + margin: 0.25rem 0; +} + +.message-image { + max-width: 200px !important; + max-height: 120px !important; + border-radius: 4px !important; + margin: 0.25rem 0 !important; + display: block !important; +} + +@media (max-width: 400px) { + .chat-header { + padding: 0.375rem; + } + + .username-section { + gap: 0.375rem; + } + + .username-section .username-input { + font-size: 0.6875rem; + padding: 0.25rem; + } + + .settings-btn { + width: 28px; + height: 28px; + } + + .settings-btn svg { + width: 12px; + height: 12px; + } + + .message-input { + font-size: 0.6875rem; + padding: 0.25rem; + } + + .send-btn { + width: 28px; + height: 28px; + } + + .send-btn svg { + width: 12px; + height: 12px; + } + + .message { + padding: 0.25rem 0.375rem; + } + + .message-content, + .message-username { + font-size: 0.625rem; + } + + .message-time { + font-size: 0.5625rem; + } + + .messages-container { + padding: 0.375rem; + } + + .chat-input-form { + padding: 0.375rem; + } + + .message-row { + gap: 0.25rem; + } +} + +@media (max-width: 280px) { + .username-section .username-input { + min-width: 0; + font-size: 0.625rem; + } + + .settings-btn, + .send-btn { + width: 24px; + height: 24px; + min-width: 24px; + } + + .settings-btn svg, + .send-btn svg { + width: 10px; + height: 10px; + } + + .message-input { + font-size: 0.625rem; + min-width: 0; + } +} diff --git a/public/css/moveableChat.css b/public/css/moveableChat.css new file mode 100644 index 0000000..49569f9 --- /dev/null +++ b/public/css/moveableChat.css @@ -0,0 +1,210 @@ +.moveable-chat-frame { + position: fixed; + top: 20%; + right: 20px; + width: 350px; + height: 500px; + background: var(--bg-card); + border-radius: 12px 12px 0 0; + box-shadow: var(--shadow-xl); + backdrop-filter: blur(20px); + z-index: 1000; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 280px; + min-height: 200px; + max-width: 90vw; + max-height: 90vh; + transition: all 0.3s ease; +} + +.moveable-chat-frame.minimized { + height: 44px; + resize: none; +} + +.moveable-chat-frame.minimized .chat-frame-content { + display: none; +} + +.moveable-chat-frame.minimized .resize-handle { + display: none; +} + +.chat-frame-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-bottom: none; + cursor: move; + user-select: none; + border-radius: 12px 12px 0 0; + flex-shrink: 0; +} + +.chat-frame-title { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-primary); + font-weight: 600; + font-size: 14px; +} + +.chat-frame-title svg { + color: var(--accent-blue); +} + +.chat-frame-controls { + display: flex; + gap: 4px; +} + +.chat-control-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: none; + border: none; + border-radius: 4px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s ease; +} + +.chat-control-btn:hover { + background: var(--bg-primary); + color: var(--text-primary); +} + +.chat-frame-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border-primary); + border-top: none; + border-radius: 0; +} + +.chat-frame-iframe { + width: 100%; + height: 100%; + border: none; + background: var(--bg-card); +} + +.resize-handle { + position: absolute; + background: transparent; + z-index: 10; +} + +.resize-handle-n, +.resize-handle-s { + left: 0; + right: 0; + height: 4px; + cursor: ns-resize; +} + +.resize-handle-n { + top: 0; +} +.resize-handle-s { + bottom: 0; +} + +.resize-handle-e, +.resize-handle-w { + top: 0; + bottom: 0; + width: 4px; + cursor: ew-resize; +} + +.resize-handle-e { + right: 0; +} +.resize-handle-w { + left: 0; +} + +.resize-handle-ne, +.resize-handle-nw, +.resize-handle-se, +.resize-handle-sw { + width: 8px; + height: 8px; +} + +.resize-handle-ne { + top: 0; + right: 0; + cursor: ne-resize; +} + +.resize-handle-nw { + top: 0; + left: 0; + cursor: nw-resize; +} + +.resize-handle-se { + bottom: 0; + right: 0; + cursor: se-resize; +} + +.resize-handle-sw { + bottom: 0; + left: 0; + cursor: sw-resize; +} + +.chat-open #toggleChatBtn { + background: var(--accent-blue); + color: white; +} + +@media (max-width: 768px) { + .moveable-chat-frame { + width: calc(100vw - 20px); + height: 60vh; + right: 10px; + left: 10px; + top: auto; + bottom: 10px; + border-radius: 12px 12px 0 0; + } + + .moveable-chat-frame.minimized { + height: 44px; + bottom: 10px; + } + + .resize-handle { + display: none; + } +} + +.chat-frame-loading { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + color: var(--text-secondary); + font-size: 14px; +} + +.chat-frame-loading .spinner { + width: 20px; + height: 20px; + margin-right: 8px; +} diff --git a/public/css/style.css b/public/css/style.css index 993233a..9c60f3a 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -22,12 +22,14 @@ --accent-red: #f85149; --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3); - --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 10px 10px -5px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px + rgba(0, 0, 0, 0.3); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px + rgba(0, 0, 0, 0.3); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 10px 10px -5px + rgba(0, 0, 0, 0.3); } - * { box-sizing: border-box; margin: 0; diff --git a/public/js/chat.js b/public/js/chat.js new file mode 100644 index 0000000..0b28101 --- /dev/null +++ b/public/js/chat.js @@ -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 ` +
+ Shared image +
+ `; + } + + 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 = ` +
+ ${this.escapeHtml(message.username)} + ${time} +
+
${processedContent}
+ `; + + 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 = ` +
+ ${this.escapeHtml(text)} +
+ `; + + 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); +} diff --git a/public/js/moveableChat.js b/public/js/moveableChat.js new file mode 100644 index 0000000..f548083 --- /dev/null +++ b/public/js/moveableChat.js @@ -0,0 +1,511 @@ +class MoveableChatFrame { + constructor() { + try { + this.initializeElements(); + if (!this.frame) { + console.warn( + "MoveableChatFrame: Required elements not found, skipping initialization", + ); + return; + } + + this.isDragging = false; + this.isResizing = false; + this.isMinimized = false; + this.dragOffset = { x: 0, y: 0 }; + this.resizeHandle = null; + this.chatLoaded = false; + + this.initializeEventListeners(); + this.loadSavedState(); + } catch (error) { + console.error("MoveableChatFrame initialization failed:", error); + } + } + + initializeElements() { + this.frame = document.getElementById("moveableChatFrame"); + this.header = document.getElementById("chatFrameHeader"); + this.toggleBtn = document.getElementById("toggleChatBtn"); + this.minimizeBtn = document.getElementById("minimizeChatBtn"); + this.closeBtn = document.getElementById("closeChatBtn"); + + if (!this.frame || !this.header || !this.toggleBtn) { + console.warn("MoveableChatFrame: Missing required DOM elements"); + return false; + } + return true; + } + + initializeEventListeners() { + if ( + !this.toggleBtn || + !this.minimizeBtn || + !this.closeBtn || + !this.header + ) { + console.warn("MoveableChatFrame: Cannot bind events - missing elements"); + return; + } + + try { + this.toggleBtn.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + this.toggleChat(); + }); + + this.minimizeBtn.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + this.toggleMinimize(); + }); + + this.closeBtn.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + this.closeChat(); + }); + + this.header.addEventListener("mousedown", (e) => { + if (e.target.closest(".chat-control-btn")) return; + e.preventDefault(); + this.startDragging(e); + }); + + const resizeHandles = this.frame.querySelectorAll(".resize-handle"); + for (const handle of resizeHandles) { + handle.addEventListener("mousedown", (e) => { + e.preventDefault(); + this.startResizing(e, handle); + }); + } + + document.addEventListener( + "mousemove", + (e) => { + if (this.isDragging) { + this.drag(e); + } else if (this.isResizing) { + this.resize(e); + } + }, + { passive: true }, + ); + + document.addEventListener( + "mouseup", + () => { + this.stopDragging(); + this.stopResizing(); + }, + { passive: true }, + ); + + this.header.addEventListener( + "touchstart", + (e) => { + if (e.target.closest(".chat-control-btn")) return; + e.preventDefault(); + this.startDragging(e.touches[0]); + }, + { passive: false }, + ); + + document.addEventListener( + "touchmove", + (e) => { + if (this.isDragging) { + e.preventDefault(); + this.drag(e.touches[0]); + } + }, + { passive: false }, + ); + + document.addEventListener( + "touchend", + () => { + this.stopDragging(); + }, + { passive: true }, + ); + + window.addEventListener( + "resize", + () => { + this.constrainToViewport(); + }, + { passive: true }, + ); + } catch (error) { + console.error("Error binding MoveableChatFrame events:", error); + } + } + + toggleChat() { + if (!this.frame) return; + + if (this.frame.style.display === "none") { + this.openChat(); + } else { + this.closeChat(); + } + } + + openChat() { + if (!this.frame) return; + + this.frame.style.display = "flex"; + document.body.classList.add("chat-open"); + + if (!this.chatLoaded) { + this.loadChatContent(); + } + + this.saveState(); + } + + loadChatContent() { + const streamId = document.querySelector('meta[name="stream-id"]')?.content; + if (!streamId) { + this.showChatError("Stream ID not found"); + return; + } + + console.log("Loading chat for stream ID:", streamId); + this.showLoading(); + + const iframe = document.createElement("iframe"); + iframe.className = "chat-frame-iframe"; + iframe.src = `/chat/${encodeURIComponent(streamId)}`; + iframe.style.cssText = ` + width: 100%; + height: 100%; + border: none; + border-radius: 0 0 12px 12px; + background: var(--bg-card); + `; + + iframe.onload = () => { + this.chatLoaded = true; + this.hideLoading(); + console.log("Chat iframe loaded successfully"); + }; + + iframe.onerror = () => { + console.error("Failed to load chat iframe"); + this.showChatError("Failed to load chat"); + }; + + const loadTimeout = setTimeout(() => { + if (!this.chatLoaded) { + console.warn("Chat loading timeout"); + this.showChatError("Chat loading timeout"); + } + }, 10000); + + iframe.addEventListener("load", () => { + clearTimeout(loadTimeout); + }); + + const content = this.frame.querySelector(".chat-frame-content"); + if (content) { + content.innerHTML = ""; + content.appendChild(iframe); + } + } + + closeChat() { + if (!this.frame) return; + + this.frame.style.display = "none"; + document.body.classList.remove("chat-open"); + + const content = this.frame.querySelector(".chat-frame-content"); + if (content) { + content.innerHTML = ""; + } + + this.chatLoaded = false; + this.saveState(); + } + + toggleMinimize() { + if (!this.frame || !this.minimizeBtn) return; + + this.isMinimized = !this.isMinimized; + this.frame.classList.toggle("minimized", this.isMinimized); + + const icon = this.minimizeBtn.querySelector("svg path"); + if (icon) { + if (this.isMinimized) { + icon.setAttribute("d", "M18 15l-6-6-6 6"); + this.minimizeBtn.title = "Maximize"; + } else { + icon.setAttribute("d", "M6 9l6 6 6-6"); + this.minimizeBtn.title = "Minimize"; + } + } + + this.saveState(); + } + + showLoading() { + if (!this.frame) return; + + const loadingDiv = document.createElement("div"); + loadingDiv.className = "chat-frame-loading"; + loadingDiv.innerHTML = ` +
+ Loading chat... + `; + + const content = this.frame.querySelector(".chat-frame-content"); + if (content) { + content.innerHTML = ""; + content.appendChild(loadingDiv); + } + } + + hideLoading() {} + + showChatError(message) { + if (!this.frame) return; + + const errorDiv = document.createElement("div"); + errorDiv.className = "chat-frame-error"; + errorDiv.innerHTML = ` +
+ + + + + + ${message} + +
+ `; + + const content = this.frame.querySelector(".chat-frame-content"); + if (content) { + content.innerHTML = ""; + content.appendChild(errorDiv); + } + } + + startDragging(event) { + if (!this.frame) return; + + this.isDragging = true; + const rect = this.frame.getBoundingClientRect(); + this.dragOffset.x = event.clientX - rect.left; + this.dragOffset.y = event.clientY - rect.top; + this.frame.style.transition = "none"; + document.body.style.userSelect = "none"; + } + + drag(event) { + if (!this.isDragging || !this.frame) return; + + const x = event.clientX - this.dragOffset.x; + const y = event.clientY - this.dragOffset.y; + + const maxX = window.innerWidth - this.frame.offsetWidth; + const maxY = window.innerHeight - this.frame.offsetHeight; + + const constrainedX = Math.max(0, Math.min(x, maxX)); + const constrainedY = Math.max(0, Math.min(y, maxY)); + + this.frame.style.left = `${constrainedX}px`; + this.frame.style.top = `${constrainedY}px`; + this.frame.style.right = "auto"; + this.frame.style.bottom = "auto"; + } + + stopDragging() { + if (this.isDragging && this.frame) { + this.isDragging = false; + this.frame.style.transition = "all 0.3s ease"; + document.body.style.userSelect = ""; + this.savePosition(); + } + } + + startResizing(event, handle) { + if (this.isMinimized || !this.frame) return; + + this.isResizing = true; + this.resizeHandle = handle; + this.frame.style.transition = "none"; + document.body.style.userSelect = "none"; + event.preventDefault(); + } + + resize(event) { + if (!this.isResizing || !this.resizeHandle || !this.frame) return; + + const rect = this.frame.getBoundingClientRect(); + const handleClass = this.resizeHandle.className; + + let newWidth = rect.width; + let newHeight = rect.height; + let newLeft = rect.left; + let newTop = rect.top; + + if (handleClass.includes("resize-handle-e")) { + newWidth = event.clientX - rect.left; + } + if (handleClass.includes("resize-handle-w")) { + newWidth = rect.right - event.clientX; + newLeft = event.clientX; + } + if (handleClass.includes("resize-handle-s")) { + newHeight = event.clientY - rect.top; + } + if (handleClass.includes("resize-handle-n")) { + newHeight = rect.bottom - event.clientY; + newTop = event.clientY; + } + + newWidth = Math.max(280, Math.min(newWidth, window.innerWidth)); + newHeight = Math.max(200, Math.min(newHeight, window.innerHeight)); + + this.frame.style.width = `${newWidth}px`; + this.frame.style.height = `${newHeight}px`; + this.frame.style.left = `${newLeft}px`; + this.frame.style.top = `${newTop}px`; + this.frame.style.right = "auto"; + this.frame.style.bottom = "auto"; + } + + stopResizing() { + if (this.isResizing && this.frame) { + this.isResizing = false; + this.resizeHandle = null; + this.frame.style.transition = "all 0.3s ease"; + document.body.style.userSelect = ""; + this.savePosition(); + } + } + + constrainToViewport() { + if (!this.frame || this.frame.style.display === "none") return; + + const rect = this.frame.getBoundingClientRect(); + const maxX = window.innerWidth - rect.width; + const maxY = window.innerHeight - rect.height; + + const currentX = Number.parseInt(this.frame.style.left) || rect.left; + const currentY = Number.parseInt(this.frame.style.top) || rect.top; + + const constrainedX = Math.max(0, Math.min(currentX, maxX)); + const constrainedY = Math.max(0, Math.min(currentY, maxY)); + + this.frame.style.left = `${constrainedX}px`; + this.frame.style.top = `${constrainedY}px`; + this.frame.style.right = "auto"; + this.frame.style.bottom = "auto"; + } + + savePosition() { + if (!this.frame) return; + + try { + const rect = this.frame.getBoundingClientRect(); + const position = { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; + localStorage.setItem("moveable-chat-position", JSON.stringify(position)); + } catch (error) { + console.warn("Failed to save chat position:", error); + } + } + + saveState() { + if (!this.frame) return; + + try { + const state = { + visible: this.frame.style.display !== "none", + minimized: this.isMinimized, + }; + localStorage.setItem("moveable-chat-state", JSON.stringify(state)); + } catch (error) { + console.warn("Failed to save chat state:", error); + } + } + + loadSavedState() { + if (!this.frame) return; + + const savedPosition = localStorage.getItem("moveable-chat-position"); + if (savedPosition) { + try { + const position = JSON.parse(savedPosition); + this.frame.style.left = `${position.left}px`; + this.frame.style.top = `${position.top}px`; + this.frame.style.width = `${position.width}px`; + this.frame.style.height = `${position.height}px`; + this.frame.style.right = "auto"; + this.frame.style.bottom = "auto"; + } catch (e) { + console.warn("Failed to load saved chat position:", e); + } + } + + const savedState = localStorage.getItem("moveable-chat-state"); + if (savedState) { + try { + const state = JSON.parse(savedState); + if (state.visible) { + this.openChat(); + } + if (state.minimized) { + this.isMinimized = true; + this.toggleMinimize(); + } + } catch (e) { + console.warn("Failed to load saved chat state:", e); + } + } + + setTimeout(() => { + this.constrainToViewport(); + }, 100); + } +} + +function initializeMoveableChat() { + try { + const requiredElements = [ + "moveableChatFrame", + "chatFrameHeader", + "toggleChatBtn", + ]; + + const missingElements = requiredElements.filter( + (id) => !document.getElementById(id), + ); + + if (missingElements.length > 0) { + console.warn( + "MoveableChatFrame: Missing required elements:", + missingElements, + ); + return; + } + + window.moveableChatFrame = new MoveableChatFrame(); + } catch (error) { + console.error("Failed to initialize MoveableChatFrame:", error); + } +} + +setTimeout(initializeMoveableChat, 100); diff --git a/public/js/watch.js b/public/js/watch.js index 9db6087..068e22c 100644 --- a/public/js/watch.js +++ b/public/js/watch.js @@ -49,12 +49,9 @@ class StreamWatcher { bitrate: document.getElementById("bitrate"), quality: document.getElementById("quality"), streamId: document.getElementById("streamId"), - streamApp: document.getElementById("streamApp"), - streamDuration: document.getElementById("streamDuration"), videoCodec: document.getElementById("videoCodec"), audioCodec: document.getElementById("audioCodec"), resolution: document.getElementById("resolution"), - rtmpUrl: document.getElementById("rtmpUrl"), hlsUrl: document.getElementById("hlsUrl"), flvUrl: document.getElementById("flvUrl"), webrtcUrl: document.getElementById("webrtcUrl"), @@ -103,7 +100,6 @@ class StreamWatcher { }); const urlElements = [ - this.elements.rtmpUrl, this.elements.hlsUrl, this.elements.flvUrl, this.elements.webrtcUrl, @@ -228,10 +224,6 @@ class StreamWatcher { } this.elements.streamId.textContent = stream.name || this.streamId; - this.elements.streamApp.textContent = stream.app || "-"; - this.elements.streamDuration.textContent = isOnline - ? this.formatDuration(stream.publish?.duration) - : "-"; this.elements.videoCodec.textContent = stream.video ? `${stream.video.codec} ${stream.video.profile || ""}`.trim() : "-"; @@ -247,7 +239,6 @@ class StreamWatcher { const srsHost = new URL(this.srsUrl).host; const streamPath = `${stream.app}/${stream.name}`; - this.elements.rtmpUrl.textContent = `rtmp://${srsHost}/${streamPath}`; this.elements.hlsUrl.textContent = `${this.srsUrl}/${streamPath}.m3u8`; this.elements.flvUrl.textContent = `${this.srsUrl}/${streamPath}.flv`; this.elements.webrtcUrl.textContent = `webrtc://${srsHost}/${streamPath}`; diff --git a/public/watch.html b/public/watch.html index e4e7276..29c4317 100644 --- a/public/watch.html +++ b/public/watch.html @@ -8,9 +8,11 @@ + + @@ -26,6 +28,15 @@ Back to Dashboard

Loading Stream...

+
+ +
@@ -78,14 +89,6 @@ Stream ID: - -
- Application: - - -
-
- Duration: - - -
Video Codec: - @@ -104,10 +107,6 @@

Stream URLs

-
- RTMP: -
-
-
HLS:
-
@@ -124,6 +123,43 @@
+ + - \ No newline at end of file + diff --git a/src/index.ts b/src/index.ts index f095bcd..acb998d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import { echo } from "@atums/echo"; +import { redis } from "bun"; import { verifyRequiredVariables } from "@config"; import { serverHandler } from "@server"; @@ -6,6 +7,13 @@ import { serverHandler } from "@server"; async function main(): Promise { verifyRequiredVariables(); + try { + await redis.connect(); + } catch (error) { + echo.error({ message: "Failed to connect to Redis:", error }); + process.exit(1); + } + serverHandler.initialize(); } diff --git a/src/routes/chat/[id].ts b/src/routes/chat/[id].ts new file mode 100644 index 0000000..806e650 --- /dev/null +++ b/src/routes/chat/[id].ts @@ -0,0 +1,40 @@ +import { resolve } from "node:path"; + +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "text/html", +}; + +async function handler(request: ExtendedRequest): Promise { + const { id } = request.params; + + if (!id) { + return new Response("Chat ID is required", { status: 400 }); + } + + const filePath = resolve("public", "chat.html"); + const bunFile = Bun.file(filePath); + + if (!(await bunFile.exists())) { + return new Response("Chat page not found", { status: 404 }); + } + + const html = new HTMLRewriter() + .on("head", { + element(head) { + head.append(``, { + html: true, + }); + }, + }) + .transform(await bunFile.text()); + + return new Response(html, { + headers: { + "Content-Type": "text/html", + }, + }); +} + +export { handler, routeDef }; diff --git a/src/server.ts b/src/server.ts index d24b025..89b3a75 100644 --- a/src/server.ts +++ b/src/server.ts @@ -132,6 +132,16 @@ class ServerHandler { const headers = request.headers; let ip = server.requestIP(request)?.address; + + if (headers.get("upgrade") === "websocket") { + const success = server.upgrade(request); + if (success) { + return new Response(null, { status: 101 }); + } + echo.error("WebSocket upgrade failed"); + return new Response("WebSocket upgrade failed", { status: 400 }); + } + let response: Response; if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") { diff --git a/src/websocket.ts b/src/websocket.ts index 87ef56e..e6fc24f 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -1,27 +1,339 @@ import { echo } from "@atums/echo"; +import { redis } from "bun"; import type { ServerWebSocket } from "bun"; class WebSocketHandler { + private chatRooms: Map> = new Map(); + private userSockets: Map = new Map(); + public handleMessage(ws: ServerWebSocket, message: string): void { - echo.info(`WebSocket received: ${message}`); try { - ws.send(`You said: ${message}`); - } catch (error) { - echo.error({ message: "WebSocket send error", error }); + const parsedMessage: WebSocketMessage = JSON.parse(message); + + switch (parsedMessage.type) { + case "join_chat": + this.handleJoinChat(ws, parsedMessage); + break; + case "send_message": + this.handleSendMessage(ws, parsedMessage); + break; + case "leave_chat": + this.handleLeaveChat(ws, parsedMessage); + break; + case "load_history": + this.handleLoadHistory(ws, parsedMessage); + break; + default: + this.sendError(ws, "Unknown message type"); + } + } catch { + this.sendError(ws, "Invalid message format"); } } public handleOpen(ws: ServerWebSocket): void { - echo.info("WebSocket connection opened."); + this.userSockets.set(ws, {}); + try { - ws.send("Welcome to the WebSocket server!"); - } catch (error) { - echo.error({ message: "WebSocket send error", error }); + ws.send( + JSON.stringify({ + type: "connected", + data: { message: "Connected to chat server" }, + }), + ); + } catch {} + } + + public handleClose(ws: ServerWebSocket): void { + const userInfo = this.userSockets.get(ws); + if (userInfo?.streamId) { + this.removeFromChatRoom(ws, userInfo.streamId); + this.broadcastUserLeft( + userInfo.streamId, + userInfo.username || "Anonymous", + ); + } + + this.userSockets.delete(ws); + } + + private async handleLoadHistory( + ws: ServerWebSocket, + message: WebSocketMessage, + ): Promise { + const { streamId } = message; + + if (!streamId) { + this.sendError(ws, "StreamId is required"); + return; + } + + try { + const chatHistory = await this.getChatHistory(streamId); + this.sendResponse(ws, { + type: "chat_history", + data: { messages: chatHistory, streamId }, + }); + } catch { + this.sendError(ws, "Failed to load chat history"); } } - public handleClose(_ws: ServerWebSocket, code: number, reason: string): void { - echo.warn(`WebSocket closed with code ${code}, reason: ${reason}`); + private async handleJoinChat( + ws: ServerWebSocket, + message: WebSocketMessage, + ): Promise { + const { streamId, username } = message; + + if (!streamId || !username) { + this.sendError(ws, "StreamId and username are required"); + return; + } + + try { + const userInfo = this.userSockets.get(ws); + if (userInfo?.streamId && userInfo.streamId !== streamId) { + this.removeFromChatRoom(ws, userInfo.streamId); + } + + this.addToChatRoom(ws, streamId); + this.userSockets.set(ws, { streamId, username }); + + const chatHistory = await this.getChatHistory(streamId); + this.sendResponse(ws, { + type: "chat_history", + data: { messages: chatHistory, streamId }, + }); + + this.broadcastUserJoined(streamId, username, ws); + } catch { + this.sendError(ws, "Failed to join chat"); + } + } + + private async handleSendMessage( + ws: ServerWebSocket, + message: WebSocketMessage, + ): Promise { + const userInfo = this.userSockets.get(ws); + const { message: messageText } = message; + + if (!userInfo?.streamId || !userInfo?.username) { + this.sendError(ws, "You must join a chat room first"); + return; + } + + if (!messageText || messageText.trim().length === 0) { + this.sendError(ws, "Message cannot be empty"); + return; + } + + if (messageText.length > 500) { + this.sendError(ws, "Message too long (max 500 characters)"); + return; + } + + try { + const chatMessage: ChatMessage = { + id: crypto.randomUUID(), + username: userInfo.username, + message: messageText.trim(), + timestamp: Date.now(), + streamId: userInfo.streamId, + }; + + await this.saveChatMessage(userInfo.streamId, chatMessage); + + this.broadcastToChatRoom(userInfo.streamId, { + type: "chat_message", + data: chatMessage, + }); + } catch { + this.sendError(ws, "Failed to send message"); + } + } + + private handleLeaveChat( + ws: ServerWebSocket, + message: WebSocketMessage, + ): void { + const userInfo = this.userSockets.get(ws); + const { streamId } = message; + + const targetStreamId = streamId || userInfo?.streamId; + + if (targetStreamId) { + this.removeFromChatRoom(ws, targetStreamId); + this.broadcastUserLeft(targetStreamId, userInfo?.username || "Anonymous"); + + this.userSockets.set(ws, { username: userInfo?.username }); + } + } + + private addToChatRoom(ws: ServerWebSocket, streamId: string): void { + if (!ws || !streamId) { + return; + } + + if (!this.chatRooms.has(streamId)) { + this.chatRooms.set(streamId, new Set()); + } + + const room = this.chatRooms.get(streamId); + + if (room) { + room.add(ws); + } + } + + private removeFromChatRoom(ws: ServerWebSocket, streamId: string): void { + const room = this.chatRooms.get(streamId); + if (room) { + room.delete(ws); + if (room.size === 0) { + this.chatRooms.delete(streamId); + } + } + } + + private broadcastToChatRoom( + streamId: string, + response: WebSocketResponse, + ): void { + const room = this.chatRooms.get(streamId); + if (room) { + const message = JSON.stringify(response); + for (const ws of room) { + try { + ws.send(message); + } catch { + room.delete(ws); + } + } + } + } + + private broadcastUserJoined( + streamId: string, + username: string, + excludeWs: ServerWebSocket, + ): void { + const room = this.chatRooms.get(streamId); + if (room) { + const message = JSON.stringify({ + type: "user_joined", + data: { username, streamId }, + }); + + for (const ws of room) { + if (ws !== excludeWs) { + try { + ws.send(message); + } catch { + room.delete(ws); + } + } + } + } + } + + private broadcastUserLeft(streamId: string, username: string): void { + this.broadcastToChatRoom(streamId, { + type: "user_left", + data: { username, streamId }, + }); + } + + private sendResponse(ws: ServerWebSocket, response: WebSocketResponse): void { + try { + ws.send(JSON.stringify(response)); + } catch { + this.sendError(ws, "Failed to send response"); + } + } + + private sendError(ws: ServerWebSocket, error: string): void { + this.sendResponse(ws, { type: "error", error }); + } + + private async getChatHistory(streamId: string): Promise { + try { + const chatKey = `chat:${streamId}`; + const messagesData = await redis.get(chatKey); + + if (messagesData) { + const messages = JSON.parse(messagesData); + if (Array.isArray(messages)) { + return messages.sort((a, b) => a.timestamp - b.timestamp); + } + } + return []; + } catch { + return []; + } + } + + private async saveChatMessage( + streamId: string, + message: ChatMessage, + ): Promise { + try { + const chatKey = `chat:${streamId}`; + + const existingData = await redis.get(chatKey); + let messages: ChatMessage[] = []; + + if (existingData) { + try { + messages = JSON.parse(existingData); + if (!Array.isArray(messages)) { + messages = []; + } + } catch { + messages = []; + } + } + + messages.push(message); + + if (messages.length > 100) { + messages = messages.slice(-100); + } + + await redis.set(chatKey, JSON.stringify(messages)); + await redis.expire(chatKey, 86400); + + const activeRoomsData = await redis.get("chat:active_rooms"); + let activeRooms: string[] = []; + + if (activeRoomsData) { + try { + activeRooms = JSON.parse(activeRoomsData); + if (!Array.isArray(activeRooms)) { + activeRooms = []; + } + } catch { + activeRooms = []; + } + } + + if (!activeRooms.includes(streamId)) { + activeRooms.push(streamId); + await redis.set("chat:active_rooms", JSON.stringify(activeRooms)); + await redis.expire("chat:active_rooms", 86400); + } + } catch (error) { + echo.error({ message: "Error saving chat message", error }); + throw error; + } + } + + public getChatRoomInfo(): { [streamId: string]: number } { + const info: { [streamId: string]: number } = {}; + this.chatRooms.forEach((connections, streamId) => { + info[streamId] = connections.size; + }); + return info; } } diff --git a/types/chat.d.ts b/types/chat.d.ts new file mode 100644 index 0000000..44ed412 --- /dev/null +++ b/types/chat.d.ts @@ -0,0 +1,31 @@ +interface ChatMessage { + id: string; + username: string; + message: string; + timestamp: number; + streamId: string; +} + +interface WebSocketMessage { + type: "join_chat" | "send_message" | "leave_chat" | "load_history"; + streamId?: string; + username?: string; + message?: string; +} + +interface WebSocketResponse { + type: + | "chat_message" + | "chat_history" + | "user_joined" + | "user_left" + | "error" + | "connected"; + data?: any; + error?: string; +} + +interface UserInfo { + streamId?: string | undefined; + username?: string | undefined; +}