Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/tutorial/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"emoji-mart": "^5.6.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"stream-chat": "^10.0.0-rc.10",
"stream-chat": "10.0.0-rc.11",
"stream-chat-react": "workspace:^"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion examples/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"modern-normalize": "^3.0.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"stream-chat": "^10.0.0-rc.10",
"stream-chat": "10.0.0-rc.11",
"stream-chat-react": "workspace:^"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@
"react-dom": "^19.2.6",
"sass": "^1.100.0",
"semantic-release": "^25.0.3",
"stream-chat": "^10.0.0-rc.10",
"stream-chat": "10.0.0-rc.11",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "^8.1.3",
Expand Down
19 changes: 10 additions & 9 deletions src/components/Channel/__tests__/channelSwitchReset.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { Channel } from '../Channel';
import { CHANNEL_CONTAINER_ID } from '../constants';
import { Chat } from '../../Chat';
import { MessageList } from '../../MessageList';
import { MESSAGE_LIST_MAIN_PANEL_CLASS } from '../../MessageList/MessageListMainPanel';
import { initClientWithChannels } from '../../../mock-builders';

import type { Channel as ChannelType, StreamChat } from 'stream-chat';
Expand All @@ -29,8 +28,10 @@ const renderChannel = (client: StreamChat, channel: ChannelType) => (
);

const channelContainer = () => document.getElementById(CHANNEL_CONTAINER_ID);
const messageListPanel = () =>
document.querySelector(`.${MESSAGE_LIST_MAIN_PANEL_CLASS.split(' ').join('.')}`);
// The list element, not the panel: the panel is rendered above the message list's key so the
// notification area it holds survives a switch, which makes it a poor witness to the rebuild. This
// one is inside the key, and it is what carries the scroll state the key exists to reset.
const messageListElement = () => document.querySelector('.str-chat__message-list');

const setupTwo = () =>
initClientWithChannels({
Expand Down Expand Up @@ -65,11 +66,11 @@ describe('switching channels', () => {
} = await setupTwo();

const { rerender } = render(renderChannel(client, channelA));
const panelBefore = messageListPanel();
const listBefore = messageListElement();

rerender(renderChannel(client, channelB));

expect(messageListPanel()).not.toBe(panelBefore);
expect(messageListElement()).not.toBe(listBefore);
});

it('keeps the message list intact when the same channel re-renders', async () => {
Expand All @@ -79,11 +80,11 @@ describe('switching channels', () => {
} = await setupTwo();

const { rerender } = render(renderChannel(client, channelA));
const panelBefore = messageListPanel();
const listBefore = messageListElement();

rerender(renderChannel(client, channelA));

expect(messageListPanel()).toBe(panelBefore);
expect(messageListElement()).toBe(listBefore);
});

it('rebuilds the message list for a replacement instance of the same channel', async () => {
Expand All @@ -95,10 +96,10 @@ describe('switching channels', () => {
const second = client.channel('messaging', 'channel-a');

const { rerender } = render(renderChannel(client, first));
const panelBefore = messageListPanel();
const listBefore = messageListElement();

rerender(renderChannel(client, second));

await waitFor(() => expect(messageListPanel()).not.toBe(panelBefore));
await waitFor(() => expect(messageListElement()).not.toBe(listBefore));
});
});
159 changes: 80 additions & 79 deletions src/components/MessageList/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,8 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
const {
EmptyStateIndicator = DefaultEmptyStateIndicator,
LoadingIndicator = DefaultLoadingIndicator,
MessageListMainPanel = DefaultMessageListMainPanel,
MessageListWrapper = 'ul',
NewMessageNotification = DefaultNewMessageNotification,
NotificationList = DefaultNotificationList,
TypingIndicator = DefaultTypingIndicator,
UnreadMessagesNotification = DefaultUnreadMessagesNotification,
} = useComponentContext();
Expand Down Expand Up @@ -170,8 +168,6 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
messageListIsThread: isThreadList,
});

const notificationTarget = useNotificationTarget();

useIncomingMessageAnnouncements({
activeThreadId: thread?.id,
channel,
Expand Down Expand Up @@ -355,14 +351,11 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
}}
>
<MessageTranslationViewProvider>
<MessageListMainPanel>
<DialogManagerProvider id={dialogManagerId}>
{!isThreadList && showUnreadMessagesNotification && (
<UnreadMessagesNotification
unreadCount={channelUnreadUiState?.unreadCount}
/>
)}
{/*todo: apply styles
<DialogManagerProvider id={dialogManagerId}>
{!isThreadList && showUnreadMessagesNotification && (
<UnreadMessagesNotification unreadCount={channelUnreadUiState?.unreadCount} />
)}
{/*todo: apply styles
.str-chat__list {
overflow-y: hidden;
}
Expand All @@ -371,71 +364,67 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
height: 100%;
}
*/}
<FloatingDateSeparator
listElement={listElement}
processedMessages={enrichedMessages}
withDateSeparator={withDateSeparator}
/>
<div
className={clsx(messageListClass, customClasses?.threadList)}
onScroll={onScroll}
ref={setListElement}
tabIndex={0}
>
{showEmptyStateIndicator ? (
<EmptyStateIndicator listType={isThreadList ? 'thread' : 'message'} />
) : (
<InfiniteScrollPaginator
className='str-chat__message-list-scroll'
data-testid='reverse-infinite-scroll'
element={internalListElement}
loadNextOnScrollToBottom={
canPaginateReplies ? messagePaginator.toHead : undefined
}
loadNextOnScrollToTop={
canPaginateReplies ? loadOlderMessages : undefined
}
onScroll={onScroll}
ref={setListElement}
threshold={loadMoreScrollThreshold}
{...restInternalInfiniteScrollProps}
>
{threadHead}
{isLoading && (
<div className='str-chat__list__loading' key='loading-indicator'>
{props.loadingMore && <LoadingIndicator />}
</div>
)}
<MessageListWrapper className='str-chat__ul'>
{elements}
</MessageListWrapper>
<TypingIndicator
isMessageListScrolledToBottom={isMessageListScrolledToBottom}
scrollToBottom={scrollToBottom}
/>

<div key='bottom' />
</InfiniteScrollPaginator>
)}
<NewMessageNotification
newMessageCount={channelUnreadUiState?.unreadCount}
showNotification={
(hasNewMessages || hasMoreNewer) && !isMessageListScrolledToBottom
<FloatingDateSeparator
listElement={listElement}
processedMessages={enrichedMessages}
withDateSeparator={withDateSeparator}
/>
<div
className={clsx(messageListClass, customClasses?.threadList)}
onScroll={onScroll}
ref={setListElement}
tabIndex={0}
>
{showEmptyStateIndicator ? (
<EmptyStateIndicator listType={isThreadList ? 'thread' : 'message'} />
) : (
<InfiniteScrollPaginator
className='str-chat__message-list-scroll'
data-testid='reverse-infinite-scroll'
element={internalListElement}
loadNextOnScrollToBottom={
canPaginateReplies ? messagePaginator.toHead : undefined
}
/>
{/* An empty list has nothing to jump to β€” see the matching gate in
VirtualizedMessageList. */}
{messages.length > 0 && (
<ScrollToLatestMessageButton
loadNextOnScrollToTop={canPaginateReplies ? loadOlderMessages : undefined}
onScroll={onScroll}
ref={setListElement}
threshold={loadMoreScrollThreshold}
{...restInternalInfiniteScrollProps}
>
{threadHead}
{isLoading && (
<div className='str-chat__list__loading' key='loading-indicator'>
{props.loadingMore && <LoadingIndicator />}
</div>
)}
<MessageListWrapper className='str-chat__ul'>
{elements}
</MessageListWrapper>
<TypingIndicator
isMessageListScrolledToBottom={isMessageListScrolledToBottom}
isNotAtLatestMessageSet={hasMoreNewer && messages.length > 0}
onClick={scrollToBottomFromNotification}
scrollToBottom={scrollToBottom}
/>
)}
</div>
</DialogManagerProvider>
<NotificationList panel={notificationTarget} />
</MessageListMainPanel>

<div key='bottom' />
</InfiniteScrollPaginator>
)}
<NewMessageNotification
newMessageCount={channelUnreadUiState?.unreadCount}
showNotification={
(hasNewMessages || hasMoreNewer) && !isMessageListScrolledToBottom
}
/>
{/* An empty list has nothing to jump to β€” see the matching gate in
VirtualizedMessageList. */}
{messages.length > 0 && (
<ScrollToLatestMessageButton
isMessageListScrolledToBottom={isMessageListScrolledToBottom}
isNotAtLatestMessageSet={hasMoreNewer && messages.length > 0}
onClick={scrollToBottomFromNotification}
/>
)}
</div>
</DialogManagerProvider>
</MessageTranslationViewProvider>
</MessageListContextProvider>
);
Expand Down Expand Up @@ -546,12 +535,24 @@ export type MessageListProps = Partial<Pick<MessageProps, PropsDrilledToMessage>
export const MessageList = (props: MessageListProps) => {
const channel = useChannel();
const thread = useThreadContext();
const notificationTarget = useNotificationTarget();
const {
MessageListMainPanel = DefaultMessageListMainPanel,
NotificationList = DefaultNotificationList,
} = useComponentContext();

// Scroll position and the rest of this list's local state belong to whatever it is showing -- a
// thread's replies or a channel's messages -- so a different one starts from scratch. `Channel`
// and `Thread` used to provide this reset by remounting their entire subtree; it belongs here,
// where the state actually lives.
// The panel and the notification area sit *above* the key on purpose.
//
// Scroll position and the rest of the list's local state belong to whatever it is showing -- a
// thread's replies or a channel's messages -- so a different one starts from scratch. A
// notification does not: it reports something the user just did, and its countdown and entry
// animation have to outlive the switch. Rendering it here keeps the element, its timer and the
// panel box it is positioned against whole, without anything having to move.
// See specs/notification-list-stable-host/spec.md.
return (
<MessageListWithContext {...props} key={getMessageSourceKey({ channel, thread })} />
<MessageListMainPanel>
<MessageListWithContext {...props} key={getMessageSourceKey({ channel, thread })} />
<NotificationList panel={notificationTarget} />
</MessageListMainPanel>
);
};
Loading