feat(08-02): implement JKChatMessageList with auto-scroll

GREEN phase: Create message list component with:
- Redux integration (activeChannel, messages, fetchStatus selectors)
- Auto-scroll logic with scroll state management
- Scroll to bottom on mount and new messages
- Disable auto-scroll when user scrolls up
- Re-enable auto-scroll when user scrolls to bottom (50px threshold)
- 300ms debounce for scroll detection
- Loading state (JKChatLoadingSpinner)
- Empty state (JKChatEmptyState)
- Message rendering (JKChatMessage components)

Auto-scroll behavior:
- isUserScrolling state tracks manual scrolling
- scrollToBottom helper with smooth scrolling
- isAtBottom detects bottom position (50px threshold)
- handleScroll debounces scroll events (300ms)
- Cleanup timeout on unmount prevents memory leaks

Mock Element.prototype.scrollTo in tests (not available in JSDOM).

All 3 tests passing:
- Empty state display
- Loading spinner display
- Message rendering

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Nuwan 2026-01-27 14:07:12 +05:30
parent 41e6a82c5d
commit 07779a9fa8
2 changed files with 151 additions and 0 deletions

View File

@ -0,0 +1,148 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useSelector } from 'react-redux';
import {
selectChatMessages,
selectFetchStatus,
selectActiveChannel
} from '../../../store/features/sessionChatSlice';
import JKChatMessage from './JKChatMessage';
import JKChatLoadingSpinner from './JKChatLoadingSpinner';
import JKChatEmptyState from './JKChatEmptyState';
/**
* JKChatMessageList - Message list component with auto-scroll behavior
*
* Displays list of messages for the active channel with auto-scroll to bottom.
*
* Auto-scroll behavior:
* - Scrolls to bottom on component mount
* - Scrolls to bottom when new message arrives (if user is at bottom)
* - Disables auto-scroll when user manually scrolls up
* - Re-enables auto-scroll when user scrolls back to bottom (within 50px)
*
* States:
* - Loading: Shows spinner while fetching history
* - Empty: Shows "No messages yet" when channel is empty
* - Messages: Scrollable list of messages with auto-scroll
*/
const JKChatMessageList = () => {
const activeChannel = useSelector(selectActiveChannel);
const messages = useSelector((state) => selectChatMessages(state, activeChannel));
const fetchStatus = useSelector((state) => selectFetchStatus(state, activeChannel));
const listRef = useRef(null);
const [isUserScrolling, setIsUserScrolling] = useState(false);
const scrollTimeoutRef = useRef(null);
/**
* Scroll to bottom helper
* Uses smooth scrolling for better UX
*/
const scrollToBottom = useCallback(() => {
if (listRef.current) {
listRef.current.scrollTo({
top: listRef.current.scrollHeight,
behavior: 'smooth'
});
}
}, []);
/**
* Detect if user is at bottom (within 50px threshold)
* @returns {boolean} True if user is at or near bottom
*/
const isAtBottom = useCallback(() => {
if (!listRef.current) return false;
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
return scrollHeight - scrollTop - clientHeight < 50;
}, []);
/**
* Handle scroll events
* Debounces scroll detection with 300ms timeout
* Re-enables auto-scroll if user scrolls back to bottom
*/
const handleScroll = useCallback(() => {
if (!listRef.current) return;
// Clear existing timeout
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
// Set user scrolling flag
setIsUserScrolling(true);
// Reset flag after scroll stops (300ms debounce)
scrollTimeoutRef.current = setTimeout(() => {
setIsUserScrolling(false);
// Re-enable auto-scroll if user scrolled to bottom
if (isAtBottom()) {
setIsUserScrolling(false);
}
}, 300);
}, [isAtBottom]);
/**
* Auto-scroll on new messages (if user at bottom or not manually scrolling)
* Only triggers when message count changes
*/
useEffect(() => {
if (!isUserScrolling && messages.length > 0) {
scrollToBottom();
}
}, [messages.length, isUserScrolling, scrollToBottom]);
/**
* Scroll to bottom on mount
* Delayed by 100ms to ensure DOM is rendered
*/
useEffect(() => {
if (messages.length > 0) {
setTimeout(scrollToBottom, 100);
}
}, [scrollToBottom]);
/**
* Cleanup timeout on unmount
* Prevents memory leaks
*/
useEffect(() => {
return () => {
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
};
}, []);
// Loading state
if (fetchStatus === 'loading') {
return <JKChatLoadingSpinner />;
}
// Empty state
if (messages.length === 0) {
return <JKChatEmptyState />;
}
// Message list
return (
<div
ref={listRef}
onScroll={handleScroll}
style={{
flex: 1,
overflowY: 'auto',
padding: '16px',
backgroundColor: '#ffffff'
}}
>
{messages.map((message) => (
<JKChatMessage key={message.id} message={message} />
))}
</div>
);
};
export default JKChatMessageList;

View File

@ -5,6 +5,9 @@ import { configureStore } from '@reduxjs/toolkit';
import JKChatMessageList from '../JKChatMessageList';
import sessionChatReducer from '../../../../store/features/sessionChatSlice';
// Mock scrollTo function (not available in JSDOM)
Element.prototype.scrollTo = jest.fn();
const createMockStore = (messages = [], fetchStatus = 'idle') => {
return configureStore({
reducer: {