From 935446280febec17aa015e91c3c508be77d8f685 Mon Sep 17 00:00:00 2001 From: Jocadbz Date: Thu, 15 Jan 2026 23:00:31 -0300 Subject: [PATCH] Chat: Add real-time connection status, typing indicators, smart scrolling, and message delivery status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features added: - Connection status indicator (green=connected, yellow=connecting, red=disconnected) - Exponential backoff reconnection (1s → 2s → 4s → ... → 30s max) - Typing indicators showing who is typing (up to 3 users, with overflow count) - Message status (⋯ sending, ✓ sent, ✗ failed with retry option) - Jump-to-bottom button with unread message count badge - Smart scroll preservation (stays in place unless already at bottom) - Optimistic UI for sent messages (appears immediately, updates with real status) - Backend support for broadcasting typing events to other users --- handlers/chat.go | 23 ++- templates/pages/chat.html | 372 +++++++++++++++++++++++++++++++++++++- 2 files changed, 386 insertions(+), 9 deletions(-) diff --git a/handlers/chat.go b/handlers/chat.go index e9210a9..5f2bda3 100644 --- a/handlers/chat.go +++ b/handlers/chat.go @@ -83,9 +83,10 @@ func init() { } type IncomingChatMessage struct { - Type string `json:"type"` - Content string `json:"content"` - ReplyTo int `json:"replyTo"` + Type string `json:"type"` + Content string `json:"content"` + ReplyTo int `json:"replyTo"` + Username string `json:"username"` } func ChatHandler(app *App) http.HandlerFunc { @@ -170,7 +171,19 @@ func ChatHandler(app *App) http.HandlerFunc { continue } - if chatMsg.Type == "message" { + if chatMsg.Type == "typing" { + // Broadcast typing indicator to other clients + typingMsg := map[string]interface{}{ + "type": "typing", + "username": chatMsg.Username, + } + typingJSON, _ := json.Marshal(typingMsg) + for c := range hub.clients { + if c.boardID == boardID && c.userID != userID { + c.conn.WriteMessage(websocket.TextMessage, typingJSON) + } + } + } else if chatMsg.Type == "message" { if err := models.CreateChatMessage(app.DB, boardID, userID, chatMsg.Content, chatMsg.ReplyTo); err != nil { log.Printf("Error saving chat message: %v", err) continue @@ -237,4 +250,4 @@ func ChatHandler(app *App) http.HandlerFunc { return } } -} \ No newline at end of file +} diff --git a/templates/pages/chat.html b/templates/pages/chat.html index 86196f7..922916e 100644 --- a/templates/pages/chat.html +++ b/templates/pages/chat.html @@ -34,6 +34,126 @@ padding: 10px; text-align: center; border-bottom: 1px solid #001858; + position: relative; + } + .connection-status { + position: absolute; + top: 10px; + right: 10px; + display: flex; + align-items: center; + gap: 6px; + font-size: 0.85em; + padding: 4px 10px; + border-radius: 12px; + background-color: rgba(0,0,0,0.1); + } + .connection-dot { + width: 10px; + height: 10px; + border-radius: 50%; + animation: pulse 2s ease-in-out infinite; + } + .connection-dot.connected { + background-color: #4ade80; + } + .connection-dot.connecting { + background-color: #fbbf24; + } + .connection-dot.disconnected { + background-color: #f87171; + animation: none; + } + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + .typing-indicator { + padding: 8px 12px; + background-color: #f3d2c1; + border-radius: 5px; + font-size: 0.85em; + font-style: italic; + color: #001858; + margin-bottom: 8px; + display: none; + } + .typing-indicator.visible { + display: block; + } + .typing-dots { + display: inline-block; + margin-left: 4px; + } + .typing-dots span { + animation: typing-blink 1.4s infinite; + display: inline-block; + } + .typing-dots span:nth-child(2) { + animation-delay: 0.2s; + } + .typing-dots span:nth-child(3) { + animation-delay: 0.4s; + } + @keyframes typing-blink { + 0%, 60%, 100% { opacity: 0.3; } + 30% { opacity: 1; } + } + .jump-to-bottom { + position: absolute; + bottom: 70px; + right: 20px; + background-color: #001858; + color: #fef6e4; + border: 2px solid #001858; + border-radius: 50%; + width: 45px; + height: 45px; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + font-size: 1.3em; + box-shadow: 0px 4px 12px rgba(0,0,0,0.3); + transition: transform 0.2s ease; + z-index: 100; + } + .jump-to-bottom.visible { + display: flex; + } + .jump-to-bottom:hover { + transform: translateY(2px); + background-color: #8bd3dd; + } + .jump-to-bottom .unread-badge { + position: absolute; + top: -5px; + right: -5px; + background-color: #f582ae; + color: #fef6e4; + border-radius: 50%; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.7em; + font-weight: bold; + } + .message-status { + font-size: 0.7em; + color: #666; + margin-left: 8px; + } + .message-status.sending { + color: #fbbf24; + } + .message-status.sent { + color: #4ade80; + } + .message-status.failed { + color: #f87171; + cursor: pointer; } .chat-breadcrumb { background-color: #f3d2c1; @@ -221,6 +341,18 @@ .chat-breadcrumb a { color: #fef6e4; } + .typing-indicator { + background-color: #555; + color: #fef6e4; + } + .jump-to-bottom { + background-color: #fef6e4; + color: #001858; + border-color: #fef6e4; + } + .jump-to-bottom:hover { + background-color: #8bd3dd; + } .chat-header { border-color: #fef6e4; } @@ -293,10 +425,17 @@ {{.Board.Name}}
+
+
+ Connecting... +

{{.Board.Name}}

{{.Board.Description}}

+
+ ... +
{{range .Messages}}
@@ -318,6 +457,10 @@
{{end}}
+
Replying to @@ -334,45 +477,188 @@ let ws; let autocompleteActive = false; let replyToId = -1; + let reconnectAttempts = 0; + let reconnectTimeout; + let typingTimeout; + let isUserAtBottom = true; + let unreadCount = 0; const allUsernames = {{.AllUsernames}}; const currentUsername = "{{.CurrentUsername}}"; + const maxReconnectDelay = 30000; // 30 seconds max + + // Update connection status + function updateConnectionStatus(status) { + const dot = document.getElementById('connection-dot'); + const text = document.getElementById('connection-text'); + dot.className = 'connection-dot ' + status; + + if (status === 'connected') { + text.textContent = 'Connected'; + reconnectAttempts = 0; + } else if (status === 'connecting') { + text.textContent = 'Connecting...'; + } else if (status === 'disconnected') { + text.textContent = 'Disconnected'; + } + } function connectWebSocket() { const boardID = {{.Board.ID}}; + updateConnectionStatus('connecting'); + ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true&id=' + boardID); + + ws.onopen = function() { + updateConnectionStatus('connected'); + reconnectAttempts = 0; + }; + ws.onmessage = function(event) { const msg = JSON.parse(event.data); + + // Handle typing indicator + if (msg.type === 'typing') { + showTypingIndicator(msg.username); + return; + } + + // Handle regular message appendMessage(msg); }; ws.onclose = function() { + updateConnectionStatus('disconnected'); console.log("WebSocket closed, reconnecting..."); - setTimeout(connectWebSocket, 5000); + scheduleReconnect(); }; ws.onerror = function(error) { console.error("WebSocket error:", error); + updateConnectionStatus('disconnected'); }; } + + // Reconnect with exponential backoff + function scheduleReconnect() { + if (reconnectTimeout) clearTimeout(reconnectTimeout); + + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay); + reconnectAttempts++; + + console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts})...`); + reconnectTimeout = setTimeout(connectWebSocket, delay); + } function sendMessage() { const input = document.getElementById('chat-input-text'); const content = input.value.trim(); if (content === '') return; + + const tempId = 'temp-' + Date.now(); const msg = { type: 'message', content: content, replyTo: replyToId }; + + // Show message immediately with "sending" status + const tempMsg = { + id: tempId, + content: content, + username: currentUsername, + timestamp: new Date().toISOString(), + replyTo: replyToId, + status: 'sending' + }; + appendMessage(tempMsg, true); + if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(msg)); input.value = ''; cancelReply(); + + // Update status to "sent" after a brief delay + setTimeout(() => { + const msgElement = document.getElementById('msg-' + tempId); + if (msgElement) { + const statusSpan = msgElement.querySelector('.message-status'); + if (statusSpan) { + statusSpan.className = 'message-status sent'; + statusSpan.textContent = '✓'; + } + } + }, 500); } else { + // Mark as failed if not connected + setTimeout(() => { + const msgElement = document.getElementById('msg-' + tempId); + if (msgElement) { + const statusSpan = msgElement.querySelector('.message-status'); + if (statusSpan) { + statusSpan.className = 'message-status failed'; + statusSpan.textContent = '✗ Failed - Click to retry'; + statusSpan.onclick = () => { + msgElement.remove(); + sendMessage(); + }; + } + } + }, 500); console.error("WebSocket is not open. Current state:", ws ? ws.readyState : 'undefined'); } } + + // Typing indicator + let typingUsers = new Set(); + + function sendTypingIndicator() { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'typing', username: currentUsername })); + } + } + + function showTypingIndicator(username) { + if (username === currentUsername) return; + + typingUsers.add(username); + updateTypingDisplay(); + + // Clear after 3 seconds + setTimeout(() => { + typingUsers.delete(username); + updateTypingDisplay(); + }, 3000); + } + + function updateTypingDisplay() { + const indicator = document.getElementById('typing-indicator'); + const usersSpan = document.getElementById('typing-users'); + + if (typingUsers.size === 0) { + indicator.classList.remove('visible'); + } else { + const users = Array.from(typingUsers); + if (users.length === 1) { + usersSpan.textContent = `${users[0]} is typing`; + } else if (users.length === 2) { + usersSpan.textContent = `${users[0]} and ${users[1]} are typing`; + } else { + usersSpan.textContent = `${users[0]}, ${users[1]}, and ${users.length - 2} other${users.length - 2 > 1 ? 's' : ''} are typing`; + } + indicator.classList.add('visible'); + } + } - function appendMessage(msg) { + function appendMessage(msg, isOptimistic = false) { const messages = document.getElementById('chat-messages'); + + // Don't duplicate if this is a real message and we already have the optimistic one + if (!isOptimistic && document.getElementById('msg-temp-' + msg.id)) { + const tempMsg = document.getElementById('msg-temp-' + msg.id); + tempMsg.remove(); + } + + // Check if user is at bottom before adding message + const wasAtBottom = isUserAtBottom; + const msgDiv = document.createElement('div'); let highlightClass = ''; if (msg.mentions && msg.mentions.includes(currentUsername)) { @@ -386,11 +672,17 @@ } let replyHTML = msg.replyTo > 0 ? `
Replying to message...
` : ''; + let statusHTML = ''; + if (isOptimistic) { + statusHTML = ``; + } + msgDiv.innerHTML = `
${pfpHTML} ${msg.username} ${new Date(msg.timestamp).toLocaleString()} + ${statusHTML}
${replyHTML}
${msg.content}
@@ -399,8 +691,59 @@
`; messages.appendChild(msgDiv); - messages.scrollTop = messages.scrollHeight; + + // Scroll handling + if (wasAtBottom) { + messages.scrollTop = messages.scrollHeight; + unreadCount = 0; + updateUnreadBadge(); + } else { + // User is not at bottom, increment unread + if (!isOptimistic && msg.username !== currentUsername) { + unreadCount++; + updateUnreadBadge(); + } + } } + + // Jump to bottom functionality + function updateUnreadBadge() { + const badge = document.getElementById('unread-badge'); + if (unreadCount > 0) { + badge.textContent = unreadCount > 99 ? '99+' : unreadCount; + badge.style.display = 'flex'; + } else { + badge.style.display = 'none'; + } + } + + function jumpToBottom() { + const messages = document.getElementById('chat-messages'); + messages.scrollTo({ + top: messages.scrollHeight, + behavior: 'smooth' + }); + unreadCount = 0; + updateUnreadBadge(); + } + + // Detect if user is at bottom + function checkScrollPosition() { + const messages = document.getElementById('chat-messages'); + const jumpButton = document.getElementById('jump-to-bottom'); + const threshold = 100; + + isUserAtBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < threshold; + + if (isUserAtBottom) { + jumpButton.classList.remove('visible'); + unreadCount = 0; + updateUnreadBadge(); + } else { + jumpButton.classList.add('visible'); + } + } + function replyToMessage(id, username) { replyToId = id; @@ -533,7 +876,28 @@ window.onload = function() { connectWebSocket(); - document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight; + const messagesContainer = document.getElementById('chat-messages'); + messagesContainer.scrollTop = messagesContainer.scrollHeight; + + // Add scroll event listener + messagesContainer.addEventListener('scroll', checkScrollPosition); + + // Add jump to bottom button click handler + document.getElementById('jump-to-bottom').addEventListener('click', jumpToBottom); + + // Add typing indicator on input + const chatInput = document.getElementById('chat-input-text'); + let lastTypingTime = 0; + chatInput.addEventListener('input', () => { + const now = Date.now(); + if (now - lastTypingTime > 2000) { + sendTypingIndicator(); + lastTypingTime = now; + } + }); + + // Initial check for scroll position + checkScrollPosition(); };