Chat: Add real-time connection status, typing indicators, smart scrolling, and message delivery status

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
jocadbz
Joca 2026-01-15 23:00:31 -03:00
parent d36d0d46fd
commit 935446280f
Signed by: jocadbz
GPG Key ID: B1836DCE2F50BDF7
2 changed files with 386 additions and 9 deletions

View File

@ -83,9 +83,10 @@ func init() {
} }
type IncomingChatMessage struct { type IncomingChatMessage struct {
Type string `json:"type"` Type string `json:"type"`
Content string `json:"content"` Content string `json:"content"`
ReplyTo int `json:"replyTo"` ReplyTo int `json:"replyTo"`
Username string `json:"username"`
} }
func ChatHandler(app *App) http.HandlerFunc { func ChatHandler(app *App) http.HandlerFunc {
@ -170,7 +171,19 @@ func ChatHandler(app *App) http.HandlerFunc {
continue 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 { if err := models.CreateChatMessage(app.DB, boardID, userID, chatMsg.Content, chatMsg.ReplyTo); err != nil {
log.Printf("Error saving chat message: %v", err) log.Printf("Error saving chat message: %v", err)
continue continue

View File

@ -34,6 +34,126 @@
padding: 10px; padding: 10px;
text-align: center; text-align: center;
border-bottom: 1px solid #001858; 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 { .chat-breadcrumb {
background-color: #f3d2c1; background-color: #f3d2c1;
@ -221,6 +341,18 @@
.chat-breadcrumb a { .chat-breadcrumb a {
color: #fef6e4; 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 { .chat-header {
border-color: #fef6e4; border-color: #fef6e4;
} }
@ -293,10 +425,17 @@
<span>{{.Board.Name}}</span> <span>{{.Board.Name}}</span>
</div> </div>
<header class="chat-header"> <header class="chat-header">
<div class="connection-status">
<div class="connection-dot connecting" id="connection-dot"></div>
<span id="connection-text">Connecting...</span>
</div>
<h2>{{.Board.Name}}</h2> <h2>{{.Board.Name}}</h2>
<p>{{.Board.Description}}</p> <p>{{.Board.Description}}</p>
</header> </header>
<div class="chat-messages" id="chat-messages"> <div class="chat-messages" id="chat-messages">
<div class="typing-indicator" id="typing-indicator">
<span id="typing-users"></span><span class="typing-dots"><span>.</span><span>.</span><span>.</span></span>
</div>
{{range .Messages}} {{range .Messages}}
<div class="chat-message{{if .Mentions}}{{range .Mentions}}{{if eq . $.CurrentUsername}} chat-message-highlighted{{end}}{{end}}{{end}}" id="msg-{{.ID}}"> <div class="chat-message{{if .Mentions}}{{range .Mentions}}{{if eq . $.CurrentUsername}} chat-message-highlighted{{end}}{{end}}{{end}}" id="msg-{{.ID}}">
<div class="chat-message-header"> <div class="chat-message-header">
@ -318,6 +457,10 @@
</div> </div>
{{end}} {{end}}
</div> </div>
<button class="jump-to-bottom" id="jump-to-bottom" title="Jump to bottom">
<span class="unread-badge" id="unread-badge" style="display: none;">0</span>
</button>
<div class="chat-input"> <div class="chat-input">
<div id="reply-indicator" class="reply-indicator"> <div id="reply-indicator" class="reply-indicator">
<span id="reply-username">Replying to </span> <span id="reply-username">Replying to </span>
@ -334,45 +477,188 @@
let ws; let ws;
let autocompleteActive = false; let autocompleteActive = false;
let replyToId = -1; let replyToId = -1;
let reconnectAttempts = 0;
let reconnectTimeout;
let typingTimeout;
let isUserAtBottom = true;
let unreadCount = 0;
const allUsernames = {{.AllUsernames}}; const allUsernames = {{.AllUsernames}};
const currentUsername = "{{.CurrentUsername}}"; 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() { function connectWebSocket() {
const boardID = {{.Board.ID}}; const boardID = {{.Board.ID}};
updateConnectionStatus('connecting');
ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true&id=' + boardID); ws = new WebSocket('ws://' + window.location.host + '{{.BasePath}}/chat/?ws=true&id=' + boardID);
ws.onopen = function() {
updateConnectionStatus('connected');
reconnectAttempts = 0;
};
ws.onmessage = function(event) { ws.onmessage = function(event) {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
// Handle typing indicator
if (msg.type === 'typing') {
showTypingIndicator(msg.username);
return;
}
// Handle regular message
appendMessage(msg); appendMessage(msg);
}; };
ws.onclose = function() { ws.onclose = function() {
updateConnectionStatus('disconnected');
console.log("WebSocket closed, reconnecting..."); console.log("WebSocket closed, reconnecting...");
setTimeout(connectWebSocket, 5000); scheduleReconnect();
}; };
ws.onerror = function(error) { ws.onerror = function(error) {
console.error("WebSocket error:", 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() { function sendMessage() {
const input = document.getElementById('chat-input-text'); const input = document.getElementById('chat-input-text');
const content = input.value.trim(); const content = input.value.trim();
if (content === '') return; if (content === '') return;
const tempId = 'temp-' + Date.now();
const msg = { const msg = {
type: 'message', type: 'message',
content: content, content: content,
replyTo: replyToId 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) { if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg)); ws.send(JSON.stringify(msg));
input.value = ''; input.value = '';
cancelReply(); 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 { } 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'); console.error("WebSocket is not open. Current state:", ws ? ws.readyState : 'undefined');
} }
} }
function appendMessage(msg) { // 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, isOptimistic = false) {
const messages = document.getElementById('chat-messages'); 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'); const msgDiv = document.createElement('div');
let highlightClass = ''; let highlightClass = '';
if (msg.mentions && msg.mentions.includes(currentUsername)) { if (msg.mentions && msg.mentions.includes(currentUsername)) {
@ -386,11 +672,17 @@
} }
let replyHTML = msg.replyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.replyTo})">Replying to message...</div>` : ''; let replyHTML = msg.replyTo > 0 ? `<div class="chat-message-reply" onclick="scrollToMessage(${msg.replyTo})">Replying to message...</div>` : '';
let statusHTML = '';
if (isOptimistic) {
statusHTML = `<span class="message-status sending"></span>`;
}
msgDiv.innerHTML = ` msgDiv.innerHTML = `
<div class="chat-message-header"> <div class="chat-message-header">
${pfpHTML} ${pfpHTML}
<span class="chat-message-username">${msg.username}</span> <span class="chat-message-username">${msg.username}</span>
<span class="chat-message-timestamp">${new Date(msg.timestamp).toLocaleString()}</span> <span class="chat-message-timestamp">${new Date(msg.timestamp).toLocaleString()}</span>
${statusHTML}
</div> </div>
${replyHTML} ${replyHTML}
<div class="chat-message-content">${msg.content}</div> <div class="chat-message-content">${msg.content}</div>
@ -399,9 +691,60 @@
</div> </div>
`; `;
messages.appendChild(msgDiv); 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) { function replyToMessage(id, username) {
replyToId = id; replyToId = id;
const replyIndicator = document.getElementById('reply-indicator'); const replyIndicator = document.getElementById('reply-indicator');
@ -533,7 +876,28 @@
window.onload = function() { window.onload = function() {
connectWebSocket(); 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();
}; };
</script> </script>
</body> </body>