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 usersjocadbz
parent
d36d0d46fd
commit
935446280f
|
|
@ -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
|
||||||
|
|
@ -237,4 +250,4 @@ func ChatHandler(app *App) http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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');
|
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,8 +691,59 @@
|
||||||
</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;
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue