conversations, unread count, autoscrolling to recent messages, cursor in text bar

This commit is contained in:
jackiettran
2025-11-09 22:16:26 -05:00
parent 7a5bff8f2b
commit 3442e880d8
5 changed files with 415 additions and 101 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { messageAPI } from '../services/api';
import { User, Message } from '../types';
import { useAuth } from '../contexts/AuthContext';
@@ -9,9 +9,10 @@ interface ChatWindowProps {
show: boolean;
onClose: () => void;
recipient: User;
onMessagesRead?: (partnerId: string, count: number) => void;
}
const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) => {
const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient, onMessagesRead }) => {
const { user: currentUser } = useAuth();
const { isConnected, joinConversation, leaveConversation, onNewMessage, onUserTyping, emitTypingStart, emitTypingStop } = useSocket();
const [messages, setMessages] = useState<Message[]>([]);
@@ -19,11 +20,18 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
const [sending, setSending] = useState(false);
const [loading, setLoading] = useState(true);
const [isRecipientTyping, setIsRecipientTyping] = useState(false);
const [initialUnreadMessageIds, setInitialUnreadMessageIds] = useState<Set<string>>(new Set());
const [isAtBottom, setIsAtBottom] = useState(true);
const [hasScrolledToUnread, setHasScrolledToUnread] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (show) {
setHasScrolledToUnread(false); // Reset flag when opening chat
fetchMessages();
// Join conversation room when chat opens
@@ -41,7 +49,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
}, [show, recipient.id, isConnected]);
// Create a stable callback for handling new messages
const handleNewMessage = useCallback((message: Message) => {
const handleNewMessage = useCallback(async (message: Message) => {
console.log('[ChatWindow] Received new_message event:', message);
// Only add messages that are part of this conversation
@@ -59,10 +67,25 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
console.log('[ChatWindow] Adding new message to chat');
return [...prevMessages, message];
});
// Mark incoming messages from recipient as read
if (message.senderId === recipient.id && message.receiverId === currentUser?.id && !message.isRead) {
console.log('[ChatWindow] Marking new incoming message as read');
try {
await messageAPI.markAsRead(message.id);
// Notify parent component that message was marked read
if (onMessagesRead) {
onMessagesRead(recipient.id, 1);
}
} catch (error) {
console.error(`Failed to mark message ${message.id} as read:`, error);
}
}
} else {
console.log('[ChatWindow] Message not for this conversation, ignoring');
}
}, [recipient.id, currentUser?.id]);
}, [recipient.id, currentUser?.id, onMessagesRead]);
// Listen for new messages in real-time
useEffect(() => {
@@ -112,9 +135,38 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
};
}, [isConnected, show, recipient.id, onUserTyping]);
// Initial scroll to unread messages (runs synchronously after DOM updates, refs are ready)
useLayoutEffect(() => {
if (!loading && !hasScrolledToUnread && messages.length > 0) {
if (initialUnreadMessageIds.size > 0) {
// Find the oldest unread message
const oldestUnread = messages.find(m => initialUnreadMessageIds.has(m.id));
if (oldestUnread && messageRefs.current.has(oldestUnread.id)) {
console.log(`[ChatWindow] Scrolling to oldest unread message: ${oldestUnread.id}`);
messageRefs.current.get(oldestUnread.id)?.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
} else {
console.log('[ChatWindow] Unread message ref not found, scrolling to bottom');
scrollToBottom();
}
} else {
// No unread messages, scroll to bottom
console.log('[ChatWindow] No unread messages, scrolling to bottom');
scrollToBottom();
}
setHasScrolledToUnread(true);
}
}, [loading, hasScrolledToUnread, messages, initialUnreadMessageIds]);
// Auto-scroll for new messages (only if user is at bottom)
useEffect(() => {
scrollToBottom();
}, [messages, isRecipientTyping]);
if (isAtBottom && hasScrolledToUnread) {
scrollToBottom();
}
}, [messages, isRecipientTyping, isAtBottom, hasScrolledToUnread]);
const fetchMessages = async () => {
try {
@@ -133,6 +185,29 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
);
setMessages(allMessages);
// Mark all unread messages from recipient as read
const unreadMessages = receivedFromRecipient.filter((msg: Message) => !msg.isRead);
if (unreadMessages.length > 0) {
console.log(`[ChatWindow] Marking ${unreadMessages.length} messages as read`);
// Save unread message IDs for scrolling purposes
setInitialUnreadMessageIds(new Set(unreadMessages.map((m: Message) => m.id)));
// Mark each message as read
const markReadPromises = unreadMessages.map((message: Message) =>
messageAPI.markAsRead(message.id).catch((err: any) => {
console.error(`Failed to mark message ${message.id} as read:`, err);
})
);
await Promise.all(markReadPromises);
// Notify parent component that messages were marked read
if (onMessagesRead) {
onMessagesRead(recipient.id, unreadMessages.length);
}
}
} catch (error) {
console.error('Failed to fetch messages:', error);
} finally {
@@ -144,6 +219,22 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
const setMessageRef = useCallback((id: string) => (el: HTMLDivElement | null) => {
if (el) {
messageRefs.current.set(id, el);
} else {
messageRefs.current.delete(id);
}
}, []);
const handleScroll = () => {
if (!messagesContainerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current;
const isBottom = scrollHeight - scrollTop - clientHeight < 50; // 50px threshold
setIsAtBottom(isBottom);
};
// Handle typing indicators with debouncing
const handleTyping = useCallback(() => {
if (!isConnected) return;
@@ -201,6 +292,12 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
setNewMessage(messageContent); // Restore message on error
} finally {
setSending(false);
// Defer focus until after all DOM updates and scroll operations complete
requestAnimationFrame(() => {
requestAnimationFrame(() => {
inputRef.current?.focus();
});
});
}
};
@@ -279,9 +376,11 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
</div>
{/* Messages Area */}
<div
className="p-3 overflow-auto flex-grow-1"
style={{
<div
ref={messagesContainerRef}
onScroll={handleScroll}
className="p-3 overflow-auto flex-grow-1"
style={{
backgroundColor: '#f8f9fa',
minHeight: 0
}}
@@ -305,7 +404,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
formatDate(message.createdAt) !== formatDate(messages[index - 1].createdAt);
return (
<div key={message.id}>
<div key={message.id} ref={setMessageRef(message.id)}>
{showDate && (
<div className="text-center my-3">
<small className="text-muted bg-white px-2 py-1 rounded">
@@ -355,6 +454,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
<form onSubmit={handleSend} className="border-top p-3 flex-shrink-0">
<div className="input-group">
<input
ref={inputRef}
type="text"
className="form-control"
placeholder="Type a message..."