From 7ce0e690a9a95998d51256318ca2543667bf6d2d Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Fri, 28 Nov 2025 21:17:01 +0530 Subject: [PATCH 01/13] fix: performance improvement for message list render item --- package/src/components/Channel/Channel.tsx | 38 ++-- .../Channel/hooks/useCreateChannelContext.ts | 4 +- package/src/components/Message/Message.tsx | 15 -- .../Message/MessageSimple/MessageContent.tsx | 2 - .../Message/MessageSimple/MessageWrapper.tsx | 123 ++++++++++++ .../__tests__/MessageStatus.test.js | 6 +- .../Message/hooks/useCreateMessageContext.ts | 3 - .../MessageList/MessageFlashList.tsx | 144 ++++---------- .../components/MessageList/MessageList.tsx | 178 +++++------------- .../UnreadMessagesNotification.tsx | 4 +- .../MessageList/__tests__/MessageList.test.js | 18 +- .../MessageList/hooks/useMessageList.ts | 3 + .../utils/getLastReceivedMessage.ts | 5 +- .../__snapshots__/Thread.test.js.snap | 12 +- .../channelContext/ChannelContext.tsx | 17 +- .../messageContext/MessageContext.tsx | 5 +- .../src/state-store/channel-unread-state.ts | 28 +++ 17 files changed, 305 insertions(+), 300 deletions(-) create mode 100644 package/src/components/Message/MessageSimple/MessageWrapper.tsx create mode 100644 package/src/state-store/channel-unread-state.ts diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index b2674cdd79..80c38aaa74 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -102,8 +102,12 @@ import { isImagePickerAvailable, NativeHandlers, } from '../../native'; +import { + ChannelUnreadStateStore, + ChannelUnreadStateStoreType, +} from '../../state-store/channel-unread-state'; import * as dbApi from '../../store/apis'; -import { ChannelUnreadState, FileTypes } from '../../types/types'; +import { FileTypes } from '../../types/types'; import { addReactionToLocalState } from '../../utils/addReactionToLocalState'; import { compressedImageURI } from '../../utils/compressImage'; import { patchMessageTextCommand } from '../../utils/patchMessageTextCommand'; @@ -421,7 +425,7 @@ export type ChannelPropsWithContext = Pick & */ doMarkReadRequest?: ( channel: ChannelType, - setChannelUnreadUiState?: (state: ChannelUnreadState) => void, + setChannelUnreadUiState?: (data: ChannelUnreadStateStoreType['channelUnreadState']) => void, ) => void; /** * Overrides the Stream default send message request (Advanced usage only) @@ -735,10 +739,13 @@ const ChannelWithContext = (props: PropsWithChildren) = const [thread, setThread] = useState(threadProps || null); const [threadHasMore, setThreadHasMore] = useState(true); const [threadLoadingMore, setThreadLoadingMore] = useState(false); - const [channelUnreadState, setChannelUnreadState] = useState( - undefined, + const channelUnreadStateStore = useMemo(() => new ChannelUnreadStateStore(), []); + const setChannelUnreadState = useCallback( + (data: ChannelUnreadStateStoreType['channelUnreadState']) => { + channelUnreadStateStore.channelUnreadState = data; + }, + [channelUnreadStateStore], ); - const { bottomSheetRef, closePicker, openPicker } = useAttachmentPickerBottomSheet(); const syncingChannelRef = useRef(false); @@ -863,16 +870,14 @@ const ChannelWithContext = (props: PropsWithChildren) = } if (event.type === 'notification.mark_unread') { - setChannelUnreadState((prev) => { - if (!(event.last_read_at && event.user)) { - return prev; - } - return { - first_unread_message_id: event.first_unread_message_id, - last_read: new Date(event.last_read_at), - last_read_message_id: event.last_read_message_id, - unread_messages: event.unread_messages ?? 0, - }; + if (!(event.last_read_at && event.user)) { + return; + } + setChannelUnreadState({ + first_unread_message_id: event.first_unread_message_id, + last_read: new Date(event.last_read_at), + last_read_message_id: event.last_read_message_id, + unread_messages: event.unread_messages ?? 0, }); } @@ -1707,7 +1712,8 @@ const ChannelWithContext = (props: PropsWithChildren) = const channelContext = useCreateChannelContext({ channel, - channelUnreadState, + channelUnreadState: channelUnreadStateStore.channelUnreadState, + channelUnreadStateStore, disabled: !!channel?.data?.frozen, EmptyStateIndicator, enableMessageGroupingByUser, diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts index 2abb66883e..9650bdeb5a 100644 --- a/package/src/components/Channel/hooks/useCreateChannelContext.ts +++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts @@ -5,6 +5,7 @@ import type { ChannelContextValue } from '../../../contexts/channelContext/Chann export const useCreateChannelContext = ({ channel, channelUnreadState, + channelUnreadStateStore, disabled, EmptyStateIndicator, enableMessageGroupingByUser, @@ -44,12 +45,12 @@ export const useCreateChannelContext = ({ const readUsers = Object.values(read); const readUsersLength = readUsers.length; const readUsersLastReads = readUsers.map(({ last_read }) => last_read.toISOString()).join(); - const stringifiedChannelUnreadState = JSON.stringify(channelUnreadState); const channelContext: ChannelContextValue = useMemo( () => ({ channel, channelUnreadState, + channelUnreadStateStore, disabled, EmptyStateIndicator, enableMessageGroupingByUser, @@ -94,7 +95,6 @@ export const useCreateChannelContext = ({ membersLength, readUsersLength, readUsersLastReads, - stringifiedChannelUnreadState, targetedMessage, threadList, watcherCount, diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index d1460cb61d..7817139c20 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -252,7 +252,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { handleRetry, handleThreadReply, isTargetedMessage, - lastReceivedId, members, message, messageActions: messageActionsProp = defaultMessageActions, @@ -650,7 +649,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { isMessageAIGenerated, isMyMessage, lastGroupMessage: groupStyles?.[0] === 'single' || groupStyles?.[0] === 'bottom', - lastReceivedId, members, message, messageContentOrder, @@ -783,7 +781,6 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit groupStyles: prevGroupStyles, isAttachmentEqual, isTargetedMessage: prevIsTargetedMessage, - lastReceivedId: prevLastReceivedId, members: prevMembers, message: prevMessage, messagesContext: prevMessagesContext, @@ -797,7 +794,6 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit goToMessage: nextGoToMessage, groupStyles: nextGroupStyles, isTargetedMessage: nextIsTargetedMessage, - lastReceivedId: nextLastReceivedId, members: nextMembers, message: nextMessage, messagesContext: nextMessagesContext, @@ -826,17 +822,6 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit return false; } - const lastReceivedIdChangedAndMatters = - prevLastReceivedId !== nextLastReceivedId && - (prevLastReceivedId === prevMessage.id || - prevLastReceivedId === nextMessage.id || - nextLastReceivedId === prevMessage.id || - nextLastReceivedId === nextMessage.id); - - if (lastReceivedIdChangedAndMatters) { - return false; - } - const goToMessageChangedAndMatters = nextMessage.quoted_message_id && prevGoToMessage !== nextGoToMessage; diff --git a/package/src/components/Message/MessageSimple/MessageContent.tsx b/package/src/components/Message/MessageSimple/MessageContent.tsx index 6c7ca849bd..aa7efb1af0 100644 --- a/package/src/components/Message/MessageSimple/MessageContent.tsx +++ b/package/src/components/Message/MessageSimple/MessageContent.tsx @@ -535,7 +535,6 @@ export const MessageContent = (props: MessageContentProps) => { isEditedMessageOpen, isMessageAIGenerated, isMyMessage, - lastReceivedId, message, messageContentOrder, onLongPress, @@ -575,7 +574,6 @@ export const MessageContent = (props: MessageContentProps) => { isEditedMessageOpen, isMessageAIGenerated, isMyMessage, - lastReceivedId, message, messageContentOrder, MessageError, diff --git a/package/src/components/Message/MessageSimple/MessageWrapper.tsx b/package/src/components/Message/MessageSimple/MessageWrapper.tsx new file mode 100644 index 0000000000..1fa3a83e49 --- /dev/null +++ b/package/src/components/Message/MessageSimple/MessageWrapper.tsx @@ -0,0 +1,123 @@ +import React from 'react'; + +import { View } from 'react-native'; + +import { LocalMessage } from 'stream-chat'; + +import { MessageListProps } from '../../../components/MessageList/MessageList'; +import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { MessageContextValue } from '../../../contexts/messageContext/MessageContext'; +import { useMessagesContext } from '../../../contexts/messagesContext/MessagesContext'; +import { ThemeProvider, useTheme } from '../../../contexts/themeContext/ThemeContext'; + +import { Theme } from '../../../contexts/themeContext/utils/theme'; +import { useStateStore } from '../../../hooks/useStateStore'; +import { ChannelUnreadStateStoreType } from '../../../state-store/channel-unread-state'; + +const channelUnreadStateSelector = (state: ChannelUnreadStateStoreType) => ({ + first_unread_message_id: state.channelUnreadState?.first_unread_message_id, + last_read: state.channelUnreadState?.last_read, + last_read_message_id: state.channelUnreadState?.last_read_message_id, + unread_messages: state.channelUnreadState?.unread_messages, +}); + +export type MessageWrapperProps = Pick & + Pick & { + isNewestMessage?: boolean; + message: LocalMessage; + modifiedTheme?: Theme; + dateSeparatorDate?: Date; + messageGroupStyles?: string[]; + }; + +export const MessageWrapper = (props: MessageWrapperProps) => { + const { + dateSeparatorDate, + isNewestMessage, + message, + messageGroupStyles, + goToMessage, + onThreadSelect, + modifiedTheme, + } = props; + const { client } = useChatContext(); + const { channelUnreadStateStore, channel, highlightedMessageId, threadList } = + useChannelContext(); + const { + InlineDateSeparator, + InlineUnreadIndicator, + Message, + MessageSystem, + myMessageTheme, + shouldShowUnreadUnderlay, + } = useMessagesContext(); + + const { first_unread_message_id, last_read, last_read_message_id, unread_messages } = + useStateStore(channelUnreadStateStore.state, channelUnreadStateSelector); + const { + theme: { + messageList: { messageContainer }, + screenPadding, + }, + } = useTheme(); + if (!channel || channel.disconnected) { + return null; + } + + const createdAtTimestamp = message.created_at && new Date(message.created_at).getTime(); + const lastReadTimestamp = last_read?.getTime(); + const isLastReadMessage = + last_read_message_id === message.id || + (!unread_messages && createdAtTimestamp === lastReadTimestamp); + + const showUnreadSeparator = + isLastReadMessage && + !isNewestMessage && + // The `channelUnreadState?.first_unread_message_id` is here for sent messages unread label + (!!first_unread_message_id || !!unread_messages); + + const showUnreadUnderlay = !!shouldShowUnreadUnderlay && showUnreadSeparator; + + const wrapMessageInTheme = client.userID === message.user?.id && !!myMessageTheme; + const renderDateSeperator = dateSeparatorDate ? ( + + ) : null; + + const renderMessage = ( + + ); + + return ( + + {message.type === 'system' ? ( + + ) : wrapMessageInTheme ? ( + + + {renderDateSeperator} + {renderMessage} + + + ) : ( + + {renderDateSeperator} + {renderMessage} + + )} + {showUnreadUnderlay && } + + ); +}; diff --git a/package/src/components/Message/MessageSimple/__tests__/MessageStatus.test.js b/package/src/components/Message/MessageSimple/__tests__/MessageStatus.test.js index 36958bfe38..e741c75844 100644 --- a/package/src/components/Message/MessageSimple/__tests__/MessageStatus.test.js +++ b/package/src/components/Message/MessageSimple/__tests__/MessageStatus.test.js @@ -81,11 +81,7 @@ describe('MessageStatus', () => { - + , diff --git a/package/src/components/Message/hooks/useCreateMessageContext.ts b/package/src/components/Message/hooks/useCreateMessageContext.ts index d2bb4d994f..b44fa768e5 100644 --- a/package/src/components/Message/hooks/useCreateMessageContext.ts +++ b/package/src/components/Message/hooks/useCreateMessageContext.ts @@ -22,7 +22,6 @@ export const useCreateMessageContext = ({ isMessageAIGenerated, isMyMessage, lastGroupMessage, - lastReceivedId, members, message, messageContentOrder, @@ -74,7 +73,6 @@ export const useCreateMessageContext = ({ isMessageAIGenerated, isMyMessage, lastGroupMessage, - lastReceivedId, members, message, messageContentOrder, @@ -105,7 +103,6 @@ export const useCreateMessageContext = ({ hasReactions, isEditedMessageOpen, lastGroupMessage, - lastReceivedId, membersValue, myMessageThemeString, reactionsValue, diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index a5cdce21bc..e408d611f7 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -17,8 +17,6 @@ import { InlineLoadingMoreIndicator } from './InlineLoadingMoreIndicator'; import { InlineLoadingMoreRecentIndicator } from './InlineLoadingMoreRecentIndicator'; import { InlineLoadingMoreRecentThreadIndicator } from './InlineLoadingMoreRecentThreadIndicator'; -import { getLastReceivedMessageFlashList } from './utils/getLastReceivedMessageFlashList'; - import { AttachmentPickerContextValue, useAttachmentPickerContext, @@ -44,11 +42,13 @@ import { PaginatedMessageListContextValue, usePaginatedMessageListContext, } from '../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; -import { mergeThemes, ThemeProvider, useTheme } from '../../contexts/themeContext/ThemeContext'; +import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; -import { useStableCallback } from '../../hooks'; +import { useStableCallback, useStateStore } from '../../hooks'; +import { ChannelUnreadStateStoreType } from '../../state-store/channel-unread-state'; import { FileTypes } from '../../types/types'; +import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper'; let FlashList; @@ -102,6 +102,7 @@ type MessageFlashListPropsWithContext = Pick< ChannelContextValue, | 'channel' | 'channelUnreadState' + | 'channelUnreadStateStore' | 'disabled' | 'EmptyStateIndicator' | 'hideStickyDateHeader' @@ -250,6 +251,13 @@ const getItemTypeInternal = (message: LocalMessage) => { return 'generic-message'; }; +const channelUnreadStateSelector = (state: ChannelUnreadStateStoreType) => ({ + first_unread_message_id: state.channelUnreadState?.first_unread_message_id, + last_read: state.channelUnreadState?.last_read, + last_read_message_id: state.channelUnreadState?.last_read_message_id, + unread_messages: state.channelUnreadState?.unread_messages, +}); + const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => { const LoadingMoreRecentIndicator = props.threadList ? InlineLoadingMoreRecentThreadIndicator @@ -257,7 +265,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const { additionalFlashListProps, channel, - channelUnreadState, + channelUnreadStateStore, client, closePicker, DateHeader, @@ -268,9 +276,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => FooterComponent = LoadingMoreRecentIndicator, HeaderComponent = InlineLoadingMoreIndicator, hideStickyDateHeader, - highlightedMessageId, - InlineDateSeparator, - InlineUnreadIndicator, isListActive = false, isLiveStreaming = false, legacyImageViewerSwipeBehaviour, @@ -283,8 +288,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => loadMoreThread, markRead, maximumMessageLimit, - Message, - MessageSystem, myMessageTheme, readEvents, NetworkDownIndicator, @@ -299,7 +302,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => setMessages, setSelectedPicker, setTargetedMessage, - shouldShowUnreadUnderlay, StickyHeader, targetedMessage, thread, @@ -310,6 +312,10 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => UnreadMessagesNotification, } = props; const flashListRef = useRef | null>(null); + const channelUnreadState = useStateStore( + channelUnreadStateStore.state, + channelUnreadStateSelector, + ); const [hasMoved, setHasMoved] = useState(false); const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); @@ -378,11 +384,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => client.userID, ); - const lastReceivedId = useMemo( - () => getLastReceivedMessageFlashList(processedMessageList)?.id, - [processedMessageList], - ); - const [autoscrollToRecent, setAutoscrollToRecent] = useState(true); useEffect(() => { @@ -574,18 +575,16 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => (scrollToBottomButtonVisible || channelUnreadState?.first_unread_message_id) && !isMyOwnMessage ) { - setChannelUnreadState((prev) => { - const previousUnreadCount = prev?.unread_messages ?? 0; - const previousLastMessage = getPreviousLastMessage(channel.state.messages, event.message); - return { - ...(prev || {}), - last_read: - prev?.last_read ?? - (previousUnreadCount === 0 && previousLastMessage?.created_at - ? new Date(previousLastMessage.created_at) - : new Date(0)), // not having information about the last read message means the whole channel is unread, - unread_messages: previousUnreadCount + 1, - }; + const previousUnreadCount = channelUnreadState?.unread_messages ?? 0; + const previousLastMessage = getPreviousLastMessage(channel.state.messages, event.message); + setChannelUnreadState({ + ...channelUnreadState, + last_read: + channelUnreadState.last_read ?? + (previousUnreadCount === 0 && previousLastMessage?.created_at + ? new Date(previousLastMessage.created_at) + : new Date(0)), // not having information about the last read message means the whole channel is unread, + unread_messages: previousUnreadCount + 1, }); } else if (mainChannelUpdated && shouldMarkRead()) { await markRead(); @@ -599,7 +598,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => }; }, [ channel, - channelUnreadState?.first_unread_message_id, + channelUnreadState, client.user?.id, markRead, scrollToBottomButtonVisible, @@ -657,7 +656,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const lastItemMessage = lastItem.item; const lastItemCreatedAt = lastItemMessage.created_at; - const unreadIndicatorDate = channelUnreadState?.last_read.getTime(); + const unreadIndicatorDate = channelUnreadState?.last_read?.getTime(); const lastItemDate = lastItemCreatedAt.getTime(); if ( @@ -718,88 +717,23 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const renderItem = useCallback( ({ index, item: message }: { index: number; item: LocalMessage }) => { - if (!channel || channel.disconnected || (!channel.initialized && !channel.offlineMode)) { - return null; - } - - const createdAtTimestamp = message.created_at && new Date(message.created_at).getTime(); - const lastReadTimestamp = channelUnreadState?.last_read.getTime(); + const dateSeparatorDate = dateSeparatorsRef.current[message.id]; + const messageGroupStyles = messageGroupStylesRef.current[message.id] ?? []; const isNewestMessage = index === 0; - const isLastReadMessage = - channelUnreadState?.last_read_message_id === message.id || - (!channelUnreadState?.unread_messages && createdAtTimestamp === lastReadTimestamp); - - const showUnreadSeparator = - isLastReadMessage && - !isNewestMessage && - // The `channelUnreadState?.first_unread_message_id` is here for sent messages unread label - (!!channelUnreadState?.first_unread_message_id || !!channelUnreadState?.unread_messages); - - const showUnreadUnderlay = !!shouldShowUnreadUnderlay && showUnreadSeparator; - - const wrapMessageInTheme = client.userID === message.user?.id && !!myMessageTheme; - const renderDateSeperator = dateSeparatorsRef.current[message.id] && ( - - ); - const renderMessage = ( - ); - - return ( - - {message.type === 'system' ? ( - - ) : wrapMessageInTheme ? ( - - - {renderDateSeperator} - {renderMessage} - - - ) : ( - - {renderDateSeperator} - {renderMessage} - - )} - {showUnreadUnderlay && } - - ); }, - [ - InlineDateSeparator, - InlineUnreadIndicator, - Message, - MessageSystem, - channel, - channelUnreadState?.first_unread_message_id, - channelUnreadState?.last_read, - channelUnreadState?.last_read_message_id, - channelUnreadState?.unread_messages, - client.userID, - dateSeparatorsRef, - goToMessage, - highlightedMessageId, - lastReceivedId, - messageGroupStylesRef, - modifiedTheme, - myMessageTheme, - onThreadSelect, - shouldShowUnreadUnderlay, - threadList, - ], + [dateSeparatorsRef, goToMessage, messageGroupStylesRef, modifiedTheme, onThreadSelect], ); const messagesWithImages = @@ -1194,6 +1128,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => { const { channel, channelUnreadState, + channelUnreadStateStore, disabled, EmptyStateIndicator, enableMessageGroupingByUser, @@ -1242,6 +1177,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => { {...{ channel, channelUnreadState, + channelUnreadStateStore, client, closePicker, DateHeader, diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index ddc300cdb1..b52342ce24 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -17,7 +17,6 @@ import { useShouldScrollToRecentOnNewOwnMessage } from './hooks/useShouldScrollT import { InlineLoadingMoreIndicator } from './InlineLoadingMoreIndicator'; import { InlineLoadingMoreRecentIndicator } from './InlineLoadingMoreRecentIndicator'; import { InlineLoadingMoreRecentThreadIndicator } from './InlineLoadingMoreRecentThreadIndicator'; -import { getLastReceivedMessage } from './utils/getLastReceivedMessage'; import { AttachmentPickerContextValue, @@ -45,11 +44,13 @@ import { PaginatedMessageListContextValue, usePaginatedMessageListContext, } from '../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; -import { mergeThemes, ThemeProvider, useTheme } from '../../contexts/themeContext/ThemeContext'; +import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; -import { useStableCallback } from '../../hooks'; +import { useStableCallback, useStateStore } from '../../hooks'; +import { ChannelUnreadStateStoreType } from '../../state-store/channel-unread-state'; import { FileTypes } from '../../types/types'; +import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper'; // This is just to make sure that the scrolling happens in a different task queue. // TODO: Think if we really need this and strive to remove it if we can. @@ -115,6 +116,13 @@ const getPreviousLastMessage = (messages: LocalMessage[], newMessage?: MessageRe return previousLastMessage; }; +const channelUnreadStateSelector = (state: ChannelUnreadStateStoreType) => ({ + first_unread_message_id: state.channelUnreadState?.first_unread_message_id, + last_read: state.channelUnreadState?.last_read, + last_read_message_id: state.channelUnreadState?.last_read_message_id, + unread_messages: state.channelUnreadState?.unread_messages, +}); + type MessageListPropsWithContext = Pick< AttachmentPickerContextValue, 'closePicker' | 'selectedPicker' | 'setSelectedPicker' @@ -124,10 +132,10 @@ type MessageListPropsWithContext = Pick< ChannelContextValue, | 'channel' | 'channelUnreadState' + | 'channelUnreadStateStore' | 'disabled' | 'EmptyStateIndicator' | 'hideStickyDateHeader' - | 'highlightedMessageId' | 'loadChannelAroundMessage' | 'loading' | 'LoadingIndicator' @@ -150,14 +158,9 @@ type MessageListPropsWithContext = Pick< | 'DateHeader' | 'disableTypingIndicator' | 'FlatList' - | 'InlineDateSeparator' - | 'InlineUnreadIndicator' | 'legacyImageViewerSwipeBehaviour' - | 'Message' | 'ScrollToBottomButton' - | 'MessageSystem' | 'myMessageTheme' - | 'shouldShowUnreadUnderlay' | 'TypingIndicator' | 'TypingIndicatorContainer' | 'UnreadMessagesNotification' @@ -250,7 +253,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const { additionalFlatListProps, channel, - channelUnreadState, + channelUnreadStateStore, client, closePicker, DateHeader, @@ -261,9 +264,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { FooterComponent = InlineLoadingMoreIndicator, HeaderComponent = LoadingMoreRecentIndicator, hideStickyDateHeader, - highlightedMessageId, - InlineDateSeparator, - InlineUnreadIndicator, inverted = true, isListActive = false, isLiveStreaming = false, @@ -277,8 +277,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { loadMoreThread, markRead, maximumMessageLimit, - Message, - MessageSystem, myMessageTheme, NetworkDownIndicator, noGroupByUser, @@ -293,7 +291,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { setMessages, setSelectedPicker, setTargetedMessage, - shouldShowUnreadUnderlay, StickyHeader, targetedMessage, thread, @@ -305,11 +302,14 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { } = props; const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false); const { theme } = useTheme(); + const channelUnreadState = useStateStore( + channelUnreadStateStore.state, + channelUnreadStateSelector, + ); const { colors: { white_snow }, - messageList: { container, contentContainer, listContainer, messageContainer }, - screenPadding, + messageList: { container, contentContainer, listContainer }, } = theme; const myMessageThemeString = useMemo(() => JSON.stringify(myMessageTheme), [myMessageTheme]); @@ -395,10 +395,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { */ const messageIdLastScrolledToRef = useRef(undefined); const [hasMoved, setHasMoved] = useState(false); - const lastReceivedId = useMemo( - () => getLastReceivedMessage(processedMessageList)?.id, - [processedMessageList], - ); + const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); const [stickyHeaderDate, setStickyHeaderDate] = useState(); @@ -437,8 +434,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { } }); - const messagesLength = useRef(processedMessageList.length); - /** * This function should show or hide the unread indicator depending on the */ @@ -446,17 +441,15 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { // we need this check to make sure that regular list change do not trigger // the unread notification to appear (for example if the old last read messages // go out of the viewport). - if (processedMessageList.length !== messagesLength.current) { - return; - } - messagesLength.current = processedMessageList.length; + const lastReadMessageId = channelUnreadState?.last_read_message_id; + const lastReadMessageVisible = viewableItems.some((item) => item.item.id === lastReadMessageId); - if (!viewableItems.length || !readEvents) { - setIsUnreadNotificationOpen(false); - return; - } - - if (selectedPicker === 'images') { + if ( + !viewableItems.length || + !readEvents || + lastReadMessageVisible || + selectedPicker === 'images' + ) { setIsUnreadNotificationOpen(false); return; } @@ -467,7 +460,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const lastItemMessage = lastItem.item; const lastItemCreatedAt = lastItemMessage.created_at; - const unreadIndicatorDate = channelUnreadState?.last_read.getTime(); + const unreadIndicatorDate = channelUnreadState?.last_read?.getTime(); const lastItemDate = lastItemCreatedAt.getTime(); if ( @@ -563,18 +556,16 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { (scrollToBottomButtonVisible || channelUnreadState?.first_unread_message_id) && !isMyOwnMessage ) { - setChannelUnreadState((prev) => { - const previousUnreadCount = prev?.unread_messages ?? 0; - const previousLastMessage = getPreviousLastMessage(channel.state.messages, event.message); - return { - ...(prev || {}), - last_read: - prev?.last_read ?? - (previousUnreadCount === 0 && previousLastMessage?.created_at - ? new Date(previousLastMessage.created_at) - : new Date(0)), // not having information about the last read message means the whole channel is unread, - unread_messages: previousUnreadCount + 1, - }; + const previousUnreadCount = channelUnreadState.unread_messages ?? 0; + const previousLastMessage = getPreviousLastMessage(channel.state.messages, event.message); + setChannelUnreadState({ + ...channelUnreadState, + last_read: + channelUnreadState.last_read ?? + (previousUnreadCount === 0 && previousLastMessage?.created_at + ? new Date(previousLastMessage.created_at) + : new Date(0)), // not having information about the last read message means the whole channel is unread, + unread_messages: previousUnreadCount + 1, }); } else if (mainChannelUpdated && shouldMarkRead()) { await markRead(); @@ -588,7 +579,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }; }, [ channel, - channelUnreadState?.first_unread_message_id, + channelUnreadState, client.user?.id, markRead, scrollToBottomButtonVisible, @@ -781,94 +772,23 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const renderItem = useCallback( ({ index, item: message }: { index: number; item: LocalMessage }) => { - if (!channel || channel.disconnected || (!channel.initialized && !channel.offlineMode)) { - return null; - } - - const createdAtTimestamp = message.created_at && new Date(message.created_at).getTime(); - const lastReadTimestamp = channelUnreadState?.last_read.getTime(); + const dateSeparatorDate = dateSeparatorsRef.current[message.id]; + const messageGroupStyles = messageGroupStylesRef.current[message.id] ?? []; const isNewestMessage = index === 0; - const isLastReadMessage = - channelUnreadState?.last_read_message_id === message.id || - (!channelUnreadState?.unread_messages && createdAtTimestamp === lastReadTimestamp); - - const showUnreadSeparator = - isLastReadMessage && - !isNewestMessage && - // The `channelUnreadState?.first_unread_message_id` is here for sent messages unread label - (!!channelUnreadState?.first_unread_message_id || !!channelUnreadState?.unread_messages); - const showUnreadUnderlay = !!shouldShowUnreadUnderlay && showUnreadSeparator; - - const wrapMessageInTheme = client.userID === message.user?.id && !!myMessageTheme; - const renderDateSeperator = dateSeparatorsRef.current[message.id] && ( - - ); - - const renderMessage = ( - ); - - return ( - - {message.type === 'system' ? ( - - ) : wrapMessageInTheme ? ( - - - {renderDateSeperator} - {renderMessage} - - - ) : ( - - {renderDateSeperator} - {renderMessage} - - )} - {showUnreadUnderlay && } - - ); }, - [ - InlineDateSeparator, - InlineUnreadIndicator, - Message, - MessageSystem, - channel, - channelUnreadState?.first_unread_message_id, - channelUnreadState?.last_read, - channelUnreadState?.last_read_message_id, - channelUnreadState?.unread_messages, - client.userID, - dateSeparatorsRef, - goToMessage, - highlightedMessageId, - lastReceivedId, - messageContainer, - messageGroupStylesRef, - modifiedTheme, - myMessageTheme, - onThreadSelect, - screenPadding, - shouldShowUnreadUnderlay, - threadList, - ], + [dateSeparatorsRef, goToMessage, messageGroupStylesRef, modifiedTheme, onThreadSelect], ); /** @@ -1299,6 +1219,7 @@ export const MessageList = (props: MessageListProps) => { const { channel, channelUnreadState, + channelUnreadStateStore, disabled, EmptyStateIndicator, enableMessageGroupingByUser, @@ -1347,6 +1268,7 @@ export const MessageList = (props: MessageListProps) => { {...{ channel, channelUnreadState, + channelUnreadStateStore, client, closePicker, DateHeader, diff --git a/package/src/components/MessageList/UnreadMessagesNotification.tsx b/package/src/components/MessageList/UnreadMessagesNotification.tsx index bca539e3be..47192ee192 100644 --- a/package/src/components/MessageList/UnreadMessagesNotification.tsx +++ b/package/src/components/MessageList/UnreadMessagesNotification.tsx @@ -21,7 +21,7 @@ export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProp const { onCloseHandler, onPressHandler } = props; const { t } = useTranslationContext(); const { - channelUnreadState, + channelUnreadStateStore, loadChannelAtFirstUnreadMessage, markRead, setChannelUnreadState, @@ -33,7 +33,7 @@ export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProp await onPressHandler(); } else { await loadChannelAtFirstUnreadMessage({ - channelUnreadState, + channelUnreadState: channelUnreadStateStore.channelUnreadState, setChannelUnreadState, setTargetedMessage, }); diff --git a/package/src/components/MessageList/__tests__/MessageList.test.js b/package/src/components/MessageList/__tests__/MessageList.test.js index 116c36a57d..066d148c74 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.js +++ b/package/src/components/MessageList/__tests__/MessageList.test.js @@ -382,12 +382,19 @@ describe('MessageList', () => { }); }); - it("should render the UnreadMessagesIndicator when there's unread messages", async () => { + it("should render the InlineUnreadIndicator when there's unread messages", async () => { const user1 = generateUser(); const user2 = generateUser(); const messages = Array.from({ length: 10 }, (_, i) => generateMessage({ id: `${i}`, text: `message-${i}` }), ); + const read_data = { + [user1.id]: { + last_read: new Date(), + last_read_message_id: '5', + unread_messages: 5, + }, + }; const mockedChannel = generateChannelResponse({ members: [generateMember({ user: user1 }), generateMember({ user: user2 })], }); @@ -397,23 +404,18 @@ describe('MessageList', () => { const channel = chatClient.channel('messaging', mockedChannel.id); await channel.watch(); - const channelUnreadState = { - last_read: new Date(), - last_read_message_id: '5', - unread_messages: 5, - }; - channel.state = { ...channelInitialState, latestMessages: [], messages, + read: read_data, }; const { queryByLabelText } = render( - + , diff --git a/package/src/components/MessageList/hooks/useMessageList.ts b/package/src/components/MessageList/hooks/useMessageList.ts index 01969ce221..f01f4d8eda 100644 --- a/package/src/components/MessageList/hooks/useMessageList.ts +++ b/package/src/components/MessageList/hooks/useMessageList.ts @@ -23,6 +23,9 @@ export type UseMessageListParams = { isFlashList?: boolean; }; +/** + * FIXME: To change it to a more specific type. + */ export type GroupType = string; export type MessageGroupStyles = { diff --git a/package/src/components/MessageList/utils/getLastReceivedMessage.ts b/package/src/components/MessageList/utils/getLastReceivedMessage.ts index 2752950258..d53e15c7d8 100644 --- a/package/src/components/MessageList/utils/getLastReceivedMessage.ts +++ b/package/src/components/MessageList/utils/getLastReceivedMessage.ts @@ -7,10 +7,7 @@ export const getLastReceivedMessage = (messages: LocalMessage[]) => { * There are no status on dates so they will be skipped */ for (const message of messages) { - if ( - message?.status === MessageStatusTypes.RECEIVED || - message?.status === MessageStatusTypes.SENDING - ) { + if (message?.status !== MessageStatusTypes.FAILED) { return message; } } diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap index af67127f37..c39ef91687 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -232,10 +232,10 @@ exports[`Thread should match thread snapshot 1`] = ` } > >; + setChannelUnreadState?: (data: ChannelUnreadStateStoreType['channelUnreadState']) => void; setTargetedMessage?: (messageId: string) => void; }) => Promise; @@ -123,7 +127,7 @@ export type ChannelContextValue = { read: ChannelState['read']; reloadChannel: () => Promise; scrollToFirstUnreadThreshold: number; - setChannelUnreadState: React.Dispatch>; + setChannelUnreadState: (data: ChannelUnreadStateStoreType['channelUnreadState']) => void; setLastRead: React.Dispatch>; setTargetedMessage: (messageId?: string) => void; /** @@ -131,7 +135,12 @@ export type ChannelContextValue = { * Its a map of filename and AbortController */ uploadAbortControllerRef: React.MutableRefObject>; + /** + * Channel unread data + * @deprecated Use channelUnreadStateStore instead + */ channelUnreadState?: ChannelUnreadState; + channelUnreadStateStore: ChannelUnreadStateStore; disabled?: boolean; enableMessageGroupingByUser?: boolean; /** diff --git a/package/src/contexts/messageContext/MessageContext.tsx b/package/src/contexts/messageContext/MessageContext.tsx index 70c44865b3..45e7f0e51c 100644 --- a/package/src/contexts/messageContext/MessageContext.tsx +++ b/package/src/contexts/messageContext/MessageContext.tsx @@ -112,7 +112,10 @@ export type MessageContextValue = { * @returns */ handleReaction?: (reactionType: string) => Promise; - /** Latest message id on current channel */ + /** + * Latest message id on current channel + * @deprecated and will be removed in the future. This is pretty much accessible through the message-list itself. + */ lastReceivedId?: string; /** * Theme provided only to messages that are the current users diff --git a/package/src/state-store/channel-unread-state.ts b/package/src/state-store/channel-unread-state.ts new file mode 100644 index 0000000000..2e938dec5b --- /dev/null +++ b/package/src/state-store/channel-unread-state.ts @@ -0,0 +1,28 @@ +import { StateStore } from 'stream-chat'; + +import type { ChannelUnreadState as ChannelUnreadStateType } from '../types/types'; + +export type ChannelUnreadStateStoreType = { + channelUnreadState?: ChannelUnreadStateType; +}; + +const INITIAL_STATE: ChannelUnreadStateStoreType = { + channelUnreadState: undefined, +}; + +export class ChannelUnreadStateStore { + public state: StateStore; + + constructor() { + this.state = new StateStore(INITIAL_STATE); + } + + set channelUnreadState(data: ChannelUnreadStateStoreType['channelUnreadState']) { + this.state.next({ channelUnreadState: data }); + } + + get channelUnreadState() { + const { channelUnreadState } = this.state.getLatestValue(); + return channelUnreadState; + } +} From 70ed7721e0ab89d29534521e14c75c853cff2d5d Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Thu, 4 Dec 2025 14:17:42 +0530 Subject: [PATCH 02/13] fix: date separator and group styles extraction to store --- package/src/components/Channel/Channel.tsx | 3 + .../Channel/hooks/useCreateMessagesContext.ts | 2 + .../Message/MessageSimple/MessageWrapper.tsx | 63 ++++++---- .../MessageList/MessageFlashList.tsx | 104 ++++++++-------- .../components/MessageList/MessageList.tsx | 111 ++++++++++-------- .../__tests__/useMessageDateSeparator.test.ts | 83 +++++++++++++ .../hooks/useMessageDateSeparator.ts | 66 +++++++++++ .../hooks/useMessageGroupStyles.ts | 76 ++++++++++++ .../MessageList/hooks/useMessageList.ts | 95 ++++++++++----- .../utils/__tests__/getDateSeparators.test.ts | 71 ----------- .../MessageList/utils/getDateSeparators.ts | 36 +++--- .../MessageList/utils/getGroupStyles.ts | 77 ++++++------ .../MessageListItemContext.tsx | 63 ++++++++++ .../messagesContext/MessagesContext.tsx | 11 +- .../message-list-prev-next-state.ts | 40 +++++++ 15 files changed, 623 insertions(+), 278 deletions(-) create mode 100644 package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts create mode 100644 package/src/components/MessageList/hooks/useMessageDateSeparator.ts create mode 100644 package/src/components/MessageList/hooks/useMessageGroupStyles.ts delete mode 100644 package/src/components/MessageList/utils/__tests__/getDateSeparators.test.ts create mode 100644 package/src/contexts/messageListItemContext/MessageListItemContext.tsx create mode 100644 package/src/state-store/message-list-prev-next-state.ts diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 702b5e7a78..a6aa0d5725 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -325,6 +325,7 @@ export type ChannelPropsWithContext = Pick & | 'forceAlignMessages' | 'Gallery' | 'getMessagesGroupStyles' + | 'getMessageGroupStyle' | 'Giphy' | 'giphyVersion' | 'handleBan' @@ -614,6 +615,7 @@ const ChannelWithContext = (props: PropsWithChildren) = forceAlignMessages, Gallery = GalleryDefault, getMessagesGroupStyles, + getMessageGroupStyle, Giphy = GiphyDefault, giphyVersion = 'fixed_height', handleAttachButtonPress, @@ -1912,6 +1914,7 @@ const ChannelWithContext = (props: PropsWithChildren) = FlatList, forceAlignMessages, Gallery, + getMessageGroupStyle, getMessagesGroupStyles, Giphy, giphyVersion, diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index 3ac1e7236d..1a1a8c58a6 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -27,6 +27,7 @@ export const useCreateMessagesContext = ({ FlatList, forceAlignMessages, Gallery, + getMessageGroupStyle, getMessagesGroupStyles, Giphy, giphyVersion, @@ -145,6 +146,7 @@ export const useCreateMessagesContext = ({ FlatList, forceAlignMessages, Gallery, + getMessageGroupStyle, getMessagesGroupStyles, Giphy, giphyVersion, diff --git a/package/src/components/Message/MessageSimple/MessageWrapper.tsx b/package/src/components/Message/MessageSimple/MessageWrapper.tsx index 1fa3a83e49..0c3114ecc2 100644 --- a/package/src/components/Message/MessageSimple/MessageWrapper.tsx +++ b/package/src/components/Message/MessageSimple/MessageWrapper.tsx @@ -4,14 +4,14 @@ import { View } from 'react-native'; import { LocalMessage } from 'stream-chat'; -import { MessageListProps } from '../../../components/MessageList/MessageList'; +import { useMessageDateSeparator } from '../../../components/MessageList/hooks/useMessageDateSeparator'; +import { useMessageGroupStyles } from '../../../components/MessageList/hooks/useMessageGroupStyles'; import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; import { useChatContext } from '../../../contexts/chatContext/ChatContext'; -import { MessageContextValue } from '../../../contexts/messageContext/MessageContext'; +import { useMessageListItemContext } from '../../../contexts/messageListItemContext/MessageListItemContext'; import { useMessagesContext } from '../../../contexts/messagesContext/MessagesContext'; import { ThemeProvider, useTheme } from '../../../contexts/themeContext/ThemeContext'; -import { Theme } from '../../../contexts/themeContext/utils/theme'; import { useStateStore } from '../../../hooks/useStateStore'; import { ChannelUnreadStateStoreType } from '../../../state-store/channel-unread-state'; @@ -22,29 +22,24 @@ const channelUnreadStateSelector = (state: ChannelUnreadStateStoreType) => ({ unread_messages: state.channelUnreadState?.unread_messages, }); -export type MessageWrapperProps = Pick & - Pick & { - isNewestMessage?: boolean; - message: LocalMessage; - modifiedTheme?: Theme; - dateSeparatorDate?: Date; - messageGroupStyles?: string[]; - }; +export type MessageWrapperProps = { + isNewestMessage?: boolean; + message: LocalMessage; +}; export const MessageWrapper = (props: MessageWrapperProps) => { - const { - dateSeparatorDate, - isNewestMessage, - message, - messageGroupStyles, - goToMessage, - onThreadSelect, - modifiedTheme, - } = props; + const { isNewestMessage, message } = props; const { client } = useChatContext(); - const { channelUnreadStateStore, channel, highlightedMessageId, threadList } = - useChannelContext(); const { + channelUnreadStateStore, + channel, + hideDateSeparators, + highlightedMessageId, + maxTimeBetweenGroupedMessages, + threadList, + } = useChannelContext(); + const { + getMessageGroupStyle, InlineDateSeparator, InlineUnreadIndicator, Message, @@ -52,6 +47,28 @@ export const MessageWrapper = (props: MessageWrapperProps) => { myMessageTheme, shouldShowUnreadUnderlay, } = useMessagesContext(); + const { + goToMessage, + onThreadSelect, + noGroupByUser, + modifiedTheme, + messageListPreviousAndNextMessageStore, + } = useMessageListItemContext(); + + const dateSeparatorDate = useMessageDateSeparator({ + hideDateSeparators, + message, + messageListPreviousAndNextMessageStore, + }); + + const groupStyles = useMessageGroupStyles({ + dateSeparatorDate, + getMessageGroupStyle, + maxTimeBetweenGroupedMessages, + message, + messageListPreviousAndNextMessageStore, + noGroupByUser, + }); const { first_unread_message_id, last_read, last_read_message_id, unread_messages } = useStateStore(channelUnreadStateStore.state, channelUnreadStateSelector); @@ -87,7 +104,7 @@ export const MessageWrapper = (props: MessageWrapperProps) => { const renderMessage = ( { return 'generic-message'; }; +const renderItem = ({ index, item: message }: { index: number; item: LocalMessage }) => { + const isNewestMessage = index === 0; + return ; +}; + const channelUnreadStateSelector = (state: ChannelUnreadStateStoreType) => ({ first_unread_message_id: state.channelUnreadState?.first_unread_message_id, last_read: state.channelUnreadState?.last_read, @@ -355,8 +364,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => ); const { - dateSeparatorsRef, - messageGroupStylesRef, + messageListPreviousAndNextMessageStore, processedMessageList, rawMessageList, viewabilityChangedCallback, @@ -715,25 +723,21 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => [], ); - const renderItem = useCallback( - ({ index, item: message }: { index: number; item: LocalMessage }) => { - const dateSeparatorDate = dateSeparatorsRef.current[message.id]; - const messageGroupStyles = messageGroupStylesRef.current[message.id] ?? []; - const isNewestMessage = index === 0; - - return ( - - ); - }, - [dateSeparatorsRef, goToMessage, messageGroupStylesRef, modifiedTheme, onThreadSelect], + const messageListItemContextValue: MessageListItemContextValue = useMemo( + () => ({ + goToMessage, + messageListPreviousAndNextMessageStore, + modifiedTheme, + noGroupByUser, + onThreadSelect, + }), + [ + goToMessage, + messageListPreviousAndNextMessageStore, + modifiedTheme, + noGroupByUser, + onThreadSelect, + ], ); const messagesWithImages = @@ -1063,34 +1067,36 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => {EmptyStateIndicator ? : null} ) : ( - + + + )} {messageListLengthAfterUpdate && StickyHeader ? ( diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index b52342ce24..a80b6c6ecf 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -32,6 +32,10 @@ import { ImageGalleryContextValue, useImageGalleryContext, } from '../../contexts/imageGalleryContext/ImageGalleryContext'; +import { + MessageListItemContextValue, + MessageListItemProvider, +} from '../../contexts/messageListItemContext/MessageListItemContext'; import { MessagesContextValue, useMessagesContext, @@ -237,6 +241,12 @@ type MessageListPropsWithContext = Pick< isLiveStreaming?: boolean; }; +const renderItem = ({ index, item: message }: { index: number; item: LocalMessage }) => { + const isNewestMessage = index === 0; + + return ; +}; + /** * The message list component renders a list of messages. It consumes the following contexts: * @@ -325,8 +335,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { * processedMessageList changes on any state change */ const { - dateSeparatorsRef, - messageGroupStylesRef, + messageListPreviousAndNextMessageStore, processedMessageList, rawMessageList, viewabilityChangedCallback, @@ -770,25 +779,21 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [targetedMessage]); - const renderItem = useCallback( - ({ index, item: message }: { index: number; item: LocalMessage }) => { - const dateSeparatorDate = dateSeparatorsRef.current[message.id]; - const messageGroupStyles = messageGroupStylesRef.current[message.id] ?? []; - const isNewestMessage = index === 0; - - return ( - - ); - }, - [dateSeparatorsRef, goToMessage, messageGroupStylesRef, modifiedTheme, onThreadSelect], + const messageListItemContextValue: MessageListItemContextValue = useMemo( + () => ({ + goToMessage, + messageListPreviousAndNextMessageStore, + modifiedTheme, + noGroupByUser, + onThreadSelect, + }), + [ + goToMessage, + messageListPreviousAndNextMessageStore, + modifiedTheme, + noGroupByUser, + onThreadSelect, + ], ); /** @@ -1152,42 +1157,44 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { {EmptyStateIndicator ? : null} ) : ( - + + maintainVisibleContentPosition={maintainVisibleContentPosition} + maxToRenderPerBatch={30} + onMomentumScrollEnd={onUserScrollEvent} + onScroll={handleScroll} + onScrollBeginDrag={onScrollBeginDrag} + onScrollEndDrag={onScrollEndDrag} + onScrollToIndexFailed={onScrollToIndexFailedRef.current} + onTouchEnd={dismissImagePicker} + onViewableItemsChanged={stableOnViewableItemsChanged} + ref={refCallback} + renderItem={renderItem} + scrollEventThrottle={isLiveStreaming ? 16 : undefined} + showsVerticalScrollIndicator={false} + // @ts-expect-error react-native internal + strictMode={isLiveStreaming} + style={flatListStyle} + testID='message-flat-list' + viewabilityConfig={flatListViewabilityConfig} + {...additionalFlatListPropsExcludingStyle} + /> + )} {messageListLengthAfterUpdate && StickyHeader ? ( diff --git a/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts b/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts new file mode 100644 index 0000000000..3861acd871 --- /dev/null +++ b/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts @@ -0,0 +1,83 @@ +import { renderHook } from '@testing-library/react-native'; + +import { LocalMessage } from 'stream-chat'; + +import { MessagePreviousAndNextMessageStore } from '../../../state-store/message-list-prev-next-state'; +import { useMessageDateSeparator } from '../hooks/useMessageDateSeparator'; + +describe('useMessageDateSeparator', () => { + let messageListPreviousAndNextMessageStore: MessagePreviousAndNextMessageStore; + let messages: LocalMessage[]; + + beforeEach(() => { + messageListPreviousAndNextMessageStore = new MessagePreviousAndNextMessageStore(); + messages = [ + { + created_at: new Date('2020-01-01T00:00:00.000Z'), + id: '1', + text: 'Hello', + }, + { + created_at: new Date('2020-01-02T00:00:00.000Z'), + id: '2', + text: 'World', + }, + { + created_at: new Date('2020-01-03T00:00:00.000Z'), + id: '3', + text: 'Hello World', + }, + ] as LocalMessage[]; + messageListPreviousAndNextMessageStore.setMessageListPreviousAndNextMessage(messages); + }); + + it('should return undefined if no message is passed', () => { + const { result } = renderHook(() => + useMessageDateSeparator({ message: undefined, messageListPreviousAndNextMessageStore }), + ); + expect(result.current).toBeUndefined(); + }); + + it('should return undefined if the hideDateSeparators prop is true', () => { + const { result } = renderHook(() => + useMessageDateSeparator({ + hideDateSeparators: true, + message: messages[1], + messageListPreviousAndNextMessageStore, + }), + ); + expect(result.current).toBeUndefined(); + }); + + it('should return the date separator for a message if previous message is not the same day', () => { + const { result } = renderHook(() => + useMessageDateSeparator({ message: messages[1], messageListPreviousAndNextMessageStore }), + ); + expect(result.current).toBe(messages[1].created_at); + }); + + it('should return undefined if the message is the same day as the previous message', () => { + const messages = [ + { + created_at: new Date('2020-01-01T01:00:00.000Z'), + id: '1', + text: 'Hello', + }, + { + created_at: new Date('2020-01-01T02:00:00.000Z'), + id: '2', + text: 'World', + }, + ] as LocalMessage[]; + const messageListPreviousAndNextMessageStore = new MessagePreviousAndNextMessageStore(); + messageListPreviousAndNextMessageStore.setMessageListPreviousAndNextMessage(messages); + const { result: resultOfFirstMessage } = renderHook(() => + useMessageDateSeparator({ message: messages[0], messageListPreviousAndNextMessageStore }), + ); + expect(resultOfFirstMessage.current).toBe(messages[0].created_at); + const { result: resultOfSecondMessage } = renderHook(() => + useMessageDateSeparator({ message: messages[1], messageListPreviousAndNextMessageStore }), + ); + expect(resultOfSecondMessage.current).toBeUndefined(); + }); +}); diff --git a/package/src/components/MessageList/hooks/useMessageDateSeparator.ts b/package/src/components/MessageList/hooks/useMessageDateSeparator.ts new file mode 100644 index 0000000000..80957b0b1a --- /dev/null +++ b/package/src/components/MessageList/hooks/useMessageDateSeparator.ts @@ -0,0 +1,66 @@ +import { useCallback, useMemo } from 'react'; + +import { LocalMessage } from 'stream-chat'; + +import { useStateStore } from '../../../hooks/useStateStore'; +import { + MessagePreviousAndNextMessageStore, + MessagePreviousAndNextMessageStoreType, +} from '../../../state-store/message-list-prev-next-state'; + +export const getDateSeparatorValue = ({ + hideDateSeparators, + message, + previousMessage, +}: { + hideDateSeparators?: boolean; + message?: LocalMessage; + previousMessage?: LocalMessage; +}) => { + if (hideDateSeparators) { + return undefined; + } + + const previousMessageDate = previousMessage?.created_at.toDateString(); + const messageDate = message?.created_at.toDateString(); + + if (previousMessageDate !== messageDate) { + return message?.created_at; + } + + return undefined; +}; + +/** + * Hook to get whether a message should have a date separator above it + */ +export const useMessageDateSeparator = ({ + hideDateSeparators, + message, + messageListPreviousAndNextMessageStore, +}: { + hideDateSeparators?: boolean; + message?: LocalMessage; + messageListPreviousAndNextMessageStore: MessagePreviousAndNextMessageStore; +}) => { + const selector = useCallback( + (state: MessagePreviousAndNextMessageStoreType) => ({ + previousMessage: message ? state.messageList[message.id]?.previousMessage : undefined, + }), + [message], + ); + const { previousMessage } = useStateStore(messageListPreviousAndNextMessageStore.state, selector); + + const dateSeparatorDate = useMemo(() => { + if (!message && !previousMessage) { + return undefined; + } + return getDateSeparatorValue({ + hideDateSeparators, + message, + previousMessage, + }); + }, [hideDateSeparators, message, previousMessage]); + + return dateSeparatorDate; +}; diff --git a/package/src/components/MessageList/hooks/useMessageGroupStyles.ts b/package/src/components/MessageList/hooks/useMessageGroupStyles.ts new file mode 100644 index 0000000000..3a3a189215 --- /dev/null +++ b/package/src/components/MessageList/hooks/useMessageGroupStyles.ts @@ -0,0 +1,76 @@ +import { useCallback, useMemo } from 'react'; + +import { LocalMessage } from 'stream-chat'; + +import { useMessageDateSeparator } from './useMessageDateSeparator'; + +import { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext'; +import { useStateStore } from '../../../hooks/useStateStore'; +import { + MessagePreviousAndNextMessageStore, + MessagePreviousAndNextMessageStoreType, +} from '../../../state-store/message-list-prev-next-state'; +import { getGroupStyle } from '../utils/getGroupStyles'; + +/** + * Hook to get the group styles for a message + */ +export const useMessageGroupStyles = ({ + noGroupByUser, + dateSeparatorDate, + maxTimeBetweenGroupedMessages, + message, + messageListPreviousAndNextMessageStore, + getMessageGroupStyle = getGroupStyle, +}: { + noGroupByUser?: boolean; + getMessageGroupStyle: MessagesContextValue['getMessageGroupStyle']; + dateSeparatorDate?: Date; + nextMessageDateSeparatorDate?: Date; + maxTimeBetweenGroupedMessages?: number; + message: LocalMessage; + messageListPreviousAndNextMessageStore: MessagePreviousAndNextMessageStore; +}) => { + const selector = useCallback( + (state: MessagePreviousAndNextMessageStoreType) => ({ + nextMessage: state.messageList[message.id]?.nextMessage, + previousMessage: state.messageList[message.id]?.previousMessage, + }), + [message.id], + ); + const { previousMessage, nextMessage } = useStateStore( + messageListPreviousAndNextMessageStore.state, + selector, + ); + + // This is needed to calculate the group styles for the next message + const nextMessageDateSeparatorDate = useMessageDateSeparator({ + message: nextMessage, + messageListPreviousAndNextMessageStore, + }); + + const groupStyles = useMemo(() => { + if (noGroupByUser) { + return []; + } + return getMessageGroupStyle({ + dateSeparatorDate, + maxTimeBetweenGroupedMessages, + message, + nextMessage, + nextMessageDateSeparatorDate, + previousMessage, + }); + }, [ + noGroupByUser, + getMessageGroupStyle, + dateSeparatorDate, + maxTimeBetweenGroupedMessages, + message, + nextMessage, + nextMessageDateSeparatorDate, + previousMessage, + ]); + + return groupStyles; +}; diff --git a/package/src/components/MessageList/hooks/useMessageList.ts b/package/src/components/MessageList/hooks/useMessageList.ts index f01f4d8eda..b3518344a2 100644 --- a/package/src/components/MessageList/hooks/useMessageList.ts +++ b/package/src/components/MessageList/hooks/useMessageList.ts @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import type { LocalMessage } from 'stream-chat'; @@ -12,11 +12,15 @@ import { usePaginatedMessageListContext } from '../../../contexts/paginatedMessa import { useThreadContext } from '../../../contexts/threadContext/ThreadContext'; import { useRAFCoalescedValue } from '../../../hooks'; +import { MessagePreviousAndNextMessageStore } from '../../../state-store/message-list-prev-next-state'; import { DateSeparators, getDateSeparators } from '../utils/getDateSeparators'; import { getGroupStyles } from '../utils/getGroupStyles'; export type UseMessageListParams = { deletedMessagesVisibilityType?: DeletedMessagesVisibilityType; + /** + * @deprecated + */ noGroupByUser?: boolean; threadList?: boolean; isLiveStreaming?: boolean; @@ -38,18 +42,22 @@ export const shouldIncludeMessageInList = ( ) => { const { deletedMessagesVisibilityType, userId } = options; const isMessageTypeDeleted = message.type === 'deleted'; + const isSender = message.user?.id === userId; + + if (!isMessageTypeDeleted) { + return true; + } + switch (deletedMessagesVisibilityType) { + case 'always': + return true; case 'sender': - return !isMessageTypeDeleted || message.user?.id === userId; - + return isSender; case 'receiver': - return !isMessageTypeDeleted || message.user?.id !== userId; - + return !isSender; case 'never': - return !isMessageTypeDeleted; - default: - return !!message; + return false; } }; @@ -63,62 +71,90 @@ export const useMessageList = (params: UseMessageListParams) => { const { threadMessages } = useThreadContext(); const messageList = threadList ? threadMessages : messages; + const filteredMessageList = useMemo(() => { + const filteredMessages = []; + for (const message of messageList) { + if ( + shouldIncludeMessageInList(message, { + deletedMessagesVisibilityType, + userId: client.userID, + }) + ) { + filteredMessages.push(message); + } + } + return filteredMessages; + }, [messageList, deletedMessagesVisibilityType, client.userID]); + + const messageListPreviousAndNextMessageStore = useMemo( + () => new MessagePreviousAndNextMessageStore(), + [], + ); + + useEffect(() => { + messageListPreviousAndNextMessageStore.setMessageListPreviousAndNextMessage( + filteredMessageList, + ); + }, [filteredMessageList, messageListPreviousAndNextMessageStore]); + + /** + * @deprecated use `useDateSeparator` hook instead directly in the Message. + */ const dateSeparators = useMemo( () => getDateSeparators({ - deletedMessagesVisibilityType, hideDateSeparators, - messages: messageList, - userId: client.userID, + messages: filteredMessageList, }), - [deletedMessagesVisibilityType, hideDateSeparators, messageList, client.userID], + [hideDateSeparators, filteredMessageList], ); + /** + * @deprecated use `useDateSeparator` hook instead directly in the Message. + */ const dateSeparatorsRef = useRef(dateSeparators); dateSeparatorsRef.current = dateSeparators; + /** + * @deprecated use `useMessageGroupStyles` hook instead directly in the Message. + */ const messageGroupStyles = useMemo( () => getMessagesGroupStyles({ dateSeparators: dateSeparatorsRef.current, hideDateSeparators, maxTimeBetweenGroupedMessages, - messages: messageList, + messages: filteredMessageList, noGroupByUser, userId: client.userID, }), [ - dateSeparatorsRef, getMessagesGroupStyles, hideDateSeparators, maxTimeBetweenGroupedMessages, - messageList, + filteredMessageList, noGroupByUser, client.userID, ], ); + /** + * @deprecated use `useMessageGroupStyles` hook instead directly in the Message. + */ const messageGroupStylesRef = useRef(messageGroupStyles); messageGroupStylesRef.current = messageGroupStyles; const processedMessageList = useMemo(() => { const newMessageList = []; - for (const message of messageList) { - if ( - shouldIncludeMessageInList(message, { - deletedMessagesVisibilityType, - userId: client.userID, - }) - ) { - if (isFlashList) { - newMessageList.push(message); - } else { - newMessageList.unshift(message); - } + for (const message of filteredMessageList) { + if (isFlashList) { + newMessageList.push(message); + } else { + newMessageList.unshift(message); } } return newMessageList; - }, [client.userID, deletedMessagesVisibilityType, isFlashList, messageList]); + }, [filteredMessageList, isFlashList]); const data = useRAFCoalescedValue(processedMessageList, isLiveStreaming); @@ -128,12 +164,13 @@ export const useMessageList = (params: UseMessageListParams) => { dateSeparatorsRef, /** Message group styles */ messageGroupStylesRef, + messageListPreviousAndNextMessageStore, /** Messages enriched with dates/readby/groups and also reversed in order */ processedMessageList: data, /** Raw messages from the channel state */ rawMessageList: messageList, viewabilityChangedCallback, }), - [data, messageList, viewabilityChangedCallback], + [data, messageList, messageListPreviousAndNextMessageStore, viewabilityChangedCallback], ); }; diff --git a/package/src/components/MessageList/utils/__tests__/getDateSeparators.test.ts b/package/src/components/MessageList/utils/__tests__/getDateSeparators.test.ts deleted file mode 100644 index c6ee0e346e..0000000000 --- a/package/src/components/MessageList/utils/__tests__/getDateSeparators.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { PaginatedMessageListContextValue } from '../../../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; -import { getDateSeparators } from '../getDateSeparators'; - -describe('getDateSeparators', () => { - it('should return an empty object if no messages are passed', () => { - expect(getDateSeparators({ messages: [] })).toEqual({}); - }); - - it('should return a date separator for each message in a new day', () => { - const firstDate = new Date('2020-01-01T00:00:00.000Z'); - const secondDate = new Date('2020-01-02T00:00:00.000Z'); - const messages = [ - { - created_at: firstDate, - id: '1', - text: 'foo', - }, - { - created_at: secondDate, - id: '2', - text: 'bar', - }, - { - created_at: secondDate, - id: '3', - text: 'baz', - }, - ] as PaginatedMessageListContextValue['messages']; - - expect(getDateSeparators({ messages })).toEqual({ - 1: firstDate, - 2: secondDate, - }); - }); - - it('should return a date separator for the visible message in a day if deleted messages are not visible to the user', () => { - const firstDate = new Date('2020-01-01T00:00:00.000Z'); - const secondDate = new Date('2020-01-02T00:00:00.000Z'); - - const messages = [ - { - created_at: firstDate, - id: '1', - text: 'foo', - type: 'regular', - }, - { - created_at: secondDate, - id: '2', - text: 'bar', - type: 'deleted', - }, - { - created_at: secondDate, - id: '3', - text: 'baz', - type: 'regular', - }, - ] as PaginatedMessageListContextValue['messages']; - - expect(getDateSeparators({ deletedMessagesVisibilityType: 'never', messages })).toEqual({ - 1: firstDate, - 3: secondDate, - }); - - expect(getDateSeparators({ deletedMessagesVisibilityType: 'receiver', messages })).toEqual({ - 1: firstDate, - 3: secondDate, - }); - }); -}); diff --git a/package/src/components/MessageList/utils/getDateSeparators.ts b/package/src/components/MessageList/utils/getDateSeparators.ts index 90f35d5c32..575debf957 100644 --- a/package/src/components/MessageList/utils/getDateSeparators.ts +++ b/package/src/components/MessageList/utils/getDateSeparators.ts @@ -2,10 +2,19 @@ import type { DeletedMessagesVisibilityType } from '../../../contexts/messagesCo import type { PaginatedMessageListContextValue } from '../../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadContext'; +/** + * @deprecated in favor of `useDateSeparator` hook instead directly in the Message. + */ export type GetDateSeparatorsParams = { messages: PaginatedMessageListContextValue['messages'] | ThreadContextValue['threadMessages']; + /** + * @deprecated The computations are done ahead of time in the useMessageList hook so this parameter is no longer needed. + */ deletedMessagesVisibilityType?: DeletedMessagesVisibilityType; hideDateSeparators?: boolean; + /** + * @deprecated The computations are done ahead of time in the useMessageList hook so this parameter is no longer needed. + */ userId?: string; }; @@ -13,33 +22,20 @@ export type DateSeparators = { [key: string]: Date; }; +/** + * @deprecated in favor of `useDateSeparator` hook instead directly in the Message. + */ export const getDateSeparators = (params: GetDateSeparatorsParams) => { - const { deletedMessagesVisibilityType, hideDateSeparators, messages, userId } = params; + const { hideDateSeparators, messages } = params; const dateSeparators: DateSeparators = {}; if (hideDateSeparators) { return dateSeparators; } - const messagesWithoutDeleted = messages.filter((message) => { - const isMessageTypeDeleted = message.type === 'deleted'; - - const isDeletedMessageVisibleToSender = - deletedMessagesVisibilityType === 'sender' || deletedMessagesVisibilityType === 'always'; - - const isDeletedMessageVisibleToReceiver = - deletedMessagesVisibilityType === 'receiver' || deletedMessagesVisibilityType === 'always'; - - return ( - !isMessageTypeDeleted || - (userId === message.user?.id && isDeletedMessageVisibleToSender) || - (userId !== message.user?.id && isDeletedMessageVisibleToReceiver) - ); - }); - - for (let i = 0; i < messagesWithoutDeleted.length; i++) { - const previousMessage = messagesWithoutDeleted[i - 1]; - const message = messagesWithoutDeleted[i]; + for (let i = 0; i < messages.length; i++) { + const previousMessage = messages[i - 1]; + const message = messages[i]; const messageDate = message.created_at.toDateString(); diff --git a/package/src/components/MessageList/utils/getGroupStyles.ts b/package/src/components/MessageList/utils/getGroupStyles.ts index 56de390307..55630bd6f2 100644 --- a/package/src/components/MessageList/utils/getGroupStyles.ts +++ b/package/src/components/MessageList/utils/getGroupStyles.ts @@ -8,25 +8,46 @@ import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadC import { isEditedMessage } from '../../../utils/utils'; import type { GroupType } from '../hooks/useMessageList'; +export type MessageGroupStylesParams = { + message: LocalMessage; + previousMessage: LocalMessage; + nextMessage: LocalMessage; + maxTimeBetweenGroupedMessages?: number; + dateSeparatorDate?: Date; + nextMessageDateSeparatorDate?: Date; +}; + +/** + * @deprecated in favor of `useMessageGroupStyles` hook instead directly in the Message. + */ export type GetGroupStylesParams = { dateSeparators: DateSeparators; messages: PaginatedMessageListContextValue['messages'] | ThreadContextValue['threadMessages']; + /** + * @deprecated in favor of `useDateSeparator` hook instead directly in the Message. + */ hideDateSeparators?: boolean; maxTimeBetweenGroupedMessages?: number; noGroupByUser?: boolean; + /** + * @deprecated + */ userId?: string; }; export type GroupStyle = '' | 'middle' | 'top' | 'bottom' | 'single'; -const getGroupStyle = ( - dateSeparators: DateSeparators, - message: LocalMessage, - previousMessage: LocalMessage, - nextMessage: LocalMessage, - hideDateSeparators?: boolean, - maxTimeBetweenGroupedMessages?: number, -): GroupStyle[] => { +/** + * Get the group styles for a message + */ +export const getGroupStyle = ({ + message, + previousMessage, + nextMessage, + maxTimeBetweenGroupedMessages, + nextMessageDateSeparatorDate, + dateSeparatorDate, +}: MessageGroupStylesParams): GroupStyle[] => { const groupStyles: GroupStyle[] = []; const isPrevMessageTypeDeleted = previousMessage?.type === 'deleted'; @@ -41,7 +62,7 @@ const getGroupStyle = ( userId !== previousMessage?.user?.id || !!isPrevMessageTypeDeleted || // NOTE: This is needed for the group styles to work after the message separated by date. - (!hideDateSeparators && dateSeparators[message.id]) || + dateSeparatorDate || isEditedMessage(previousMessage); const isBottomMessage = @@ -50,7 +71,7 @@ const getGroupStyle = ( nextMessage.type === 'error' || userId !== nextMessage?.user?.id || !!isNextMessageTypeDeleted || - (!hideDateSeparators && dateSeparators[nextMessage.id]) || + nextMessageDateSeparatorDate || (maxTimeBetweenGroupedMessages !== undefined && (nextMessage.created_at as Date).getTime() - (message.created_at as Date).getTime() > maxTimeBetweenGroupedMessages) || @@ -98,15 +119,11 @@ const getGroupStyle = ( return groupStyles; }; +/** + * @deprecated in favor of `useMessageGroupStyles` hook instead directly in the Message. + */ export const getGroupStyles = (params: GetGroupStylesParams) => { - const { - dateSeparators, - hideDateSeparators, - maxTimeBetweenGroupedMessages, - messages, - noGroupByUser, - userId, - } = params; + const { dateSeparators, maxTimeBetweenGroupedMessages, messages, noGroupByUser } = params; if (noGroupByUser) { return {}; @@ -114,25 +131,19 @@ export const getGroupStyles = (params: GetGroupStylesParams) => { const messageGroupStyles: { [key: string]: GroupType[] } = {}; - const messagesFilteredForNonUser = messages.filter((message) => { - const isMessageTypeDeleted = message.type === 'deleted'; - return !isMessageTypeDeleted || userId === message.user?.id; - }); - - for (let i = 0; i < messagesFilteredForNonUser.length; i++) { - const previousMessage = messagesFilteredForNonUser[i - 1]; - const message = messagesFilteredForNonUser[i]; - const nextMessage = messagesFilteredForNonUser[i + 1]; + for (let i = 0; i < messages.length; i++) { + const previousMessage = messages[i - 1]; + const message = messages[i]; + const nextMessage = messages[i + 1]; if (message.id) { - messageGroupStyles[message.id] = getGroupStyle( - dateSeparators, + messageGroupStyles[message.id] = getGroupStyle({ + dateSeparatorDate: dateSeparators[message.id], + maxTimeBetweenGroupedMessages, message, - previousMessage, nextMessage, - hideDateSeparators, - maxTimeBetweenGroupedMessages, - ); + previousMessage, + }); } } diff --git a/package/src/contexts/messageListItemContext/MessageListItemContext.tsx b/package/src/contexts/messageListItemContext/MessageListItemContext.tsx new file mode 100644 index 0000000000..90fb5d4872 --- /dev/null +++ b/package/src/contexts/messageListItemContext/MessageListItemContext.tsx @@ -0,0 +1,63 @@ +import React, { createContext, PropsWithChildren, useContext } from 'react'; + +import { MessageListProps } from '../../components/MessageList/MessageList'; +import { MessagePreviousAndNextMessageStore } from '../../state-store/message-list-prev-next-state'; + +import { Theme } from '../themeContext/utils/theme'; +import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; +import { isTestEnvironment } from '../utils/isTestEnvironment'; + +export type MessageListItemContextValue = { + /** + * Handler to go to a particular message when its quoted. + * + * @param messageId The id of the message to go to. + * @returns void + */ + goToMessage: (messageId: string) => void; + /** + * Store to get the previous and next message in the message list + */ + messageListPreviousAndNextMessageStore: MessagePreviousAndNextMessageStore; + /** + * Theme to use for the message list item + */ + modifiedTheme: Theme; + /** + * Whether to group messages by user + */ + noGroupByUser?: boolean; + /** + * Handler to open the thread on message. This is callback for touch event for replies button. + * + * @param message A message object to open the thread upon. + */ + onThreadSelect: MessageListProps['onThreadSelect']; +}; + +export const MessageListItemContext = createContext( + DEFAULT_BASE_CONTEXT_VALUE as MessageListItemContextValue, +); + +export const MessageListItemProvider = ({ + children, + value, +}: PropsWithChildren<{ + value?: MessageListItemContextValue; +}>) => ( + + {children} + +); + +export const useMessageListItemContext = () => { + const contextValue = useContext(MessageListItemContext) as unknown as MessageListItemContextValue; + + if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { + throw new Error( + 'The useMessageListItemContext hook was called outside of the MessageListItemContext provider. Make sure you have configured MessageListItem component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#message-list', + ); + } + + return contextValue; +}; diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 3e5441292c..868648fbcf 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -55,7 +55,11 @@ import type { MessageSystemProps } from '../../components/MessageList/MessageSys import type { ScrollToBottomButtonProps } from '../../components/MessageList/ScrollToBottomButton'; import { TypingIndicatorContainerProps } from '../../components/MessageList/TypingIndicatorContainer'; import { UnreadMessagesNotificationProps } from '../../components/MessageList/UnreadMessagesNotification'; -import type { getGroupStyles } from '../../components/MessageList/utils/getGroupStyles'; +import type { + getGroupStyles, + GroupStyle, + MessageGroupStylesParams, +} from '../../components/MessageList/utils/getGroupStyles'; import { MessageActionListProps } from '../../components/MessageMenu/MessageActionList'; import type { MessageActionListItemProps, @@ -401,7 +405,12 @@ export type MessagesContextValue = Pick GroupStyle[]; /** * Handler to access when a ban user action is invoked. * @param message diff --git a/package/src/state-store/message-list-prev-next-state.ts b/package/src/state-store/message-list-prev-next-state.ts new file mode 100644 index 0000000000..566bf79ec9 --- /dev/null +++ b/package/src/state-store/message-list-prev-next-state.ts @@ -0,0 +1,40 @@ +import { LocalMessage, StateStore } from 'stream-chat'; + +export type MessagePreviousAndNextMessageStoreType = { + messageList: Record< + string, + { + previousMessage: LocalMessage; + nextMessage: LocalMessage; + } + >; +}; + +const INITIAL_STATE: MessagePreviousAndNextMessageStoreType = { + messageList: {}, +}; + +export class MessagePreviousAndNextMessageStore { + public state: StateStore; + + constructor() { + this.state = new StateStore(INITIAL_STATE); + } + + public setMessageListPreviousAndNextMessage(messages: LocalMessage[]) { + const currentValue = this.state.getLatestValue(); + const messageList: MessagePreviousAndNextMessageStoreType['messageList'] = { + ...currentValue.messageList, + }; + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + const previousMessage = messages[i - 1]; + const nextMessage = messages[i + 1]; + messageList[message.id] = { + nextMessage, + previousMessage, + }; + } + this.state.partialNext({ messageList }); + } +} From fc3a8399603cdd4924cd4f6a957e0b2c65383eb6 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Thu, 4 Dec 2025 14:42:35 +0530 Subject: [PATCH 03/13] fix: date separator and group styles extraction to store --- package/src/components/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 407bcd1893..472d31a1b9 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -162,6 +162,8 @@ export * from './MessageList/utils/getGroupStyles'; export * from './MessageList/utils/getLastReceivedMessage'; export * from './Message/hooks/useMessageDeliveryData'; export * from './Message/hooks/useMessageReadData'; +export * from './MessageList/hooks/useMessageDateSeparator'; +export * from './MessageList/hooks/useMessageGroupStyles'; export * from './MessageMenu/MessageActionList'; export * from './MessageMenu/MessageActionListItem'; From 9f7638e1fef495bd0daa1c865770f65a24cfc86e Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Thu, 4 Dec 2025 17:16:33 +0530 Subject: [PATCH 04/13] test: update snapshots --- .../__snapshots__/MessageStatus.test.js.snap | 17 ++---- .../__snapshots__/AttachButton.test.js.snap | 53 ++++++------------- .../__snapshots__/SendButton.test.js.snap | 36 ++++--------- .../__snapshots__/Thread.test.js.snap | 17 ++---- 4 files changed, 37 insertions(+), 86 deletions(-) diff --git a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap index 605e11641c..f771d43d78 100644 --- a/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap +++ b/package/src/components/Message/MessageSimple/__tests__/__snapshots__/MessageStatus.test.js.snap @@ -137,18 +137,11 @@ exports[`MessageStatus should render message status with read by container 1`] = > Date: Fri, 5 Dec 2025 13:15:03 +0530 Subject: [PATCH 05/13] fix: remove redundant prop from useMessageGroupStyles --- .../src/components/MessageList/hooks/useMessageGroupStyles.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/package/src/components/MessageList/hooks/useMessageGroupStyles.ts b/package/src/components/MessageList/hooks/useMessageGroupStyles.ts index 3a3a189215..4d4d286bc8 100644 --- a/package/src/components/MessageList/hooks/useMessageGroupStyles.ts +++ b/package/src/components/MessageList/hooks/useMessageGroupStyles.ts @@ -26,7 +26,6 @@ export const useMessageGroupStyles = ({ noGroupByUser?: boolean; getMessageGroupStyle: MessagesContextValue['getMessageGroupStyle']; dateSeparatorDate?: Date; - nextMessageDateSeparatorDate?: Date; maxTimeBetweenGroupedMessages?: number; message: LocalMessage; messageListPreviousAndNextMessageStore: MessagePreviousAndNextMessageStore; From 5248e2bf16622d600eeb61a30e1236de5a2afe3d Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Fri, 5 Dec 2025 21:07:45 +0530 Subject: [PATCH 06/13] fix: isNewest prop rerender --- .../Message/MessageSimple/MessageWrapper.tsx | 14 +++++++++++--- .../components/MessageList/MessageFlashList.tsx | 5 ++--- package/src/components/MessageList/MessageList.tsx | 6 ++---- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/package/src/components/Message/MessageSimple/MessageWrapper.tsx b/package/src/components/Message/MessageSimple/MessageWrapper.tsx index 0c3114ecc2..fbaef957de 100644 --- a/package/src/components/Message/MessageSimple/MessageWrapper.tsx +++ b/package/src/components/Message/MessageSimple/MessageWrapper.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { View } from 'react-native'; @@ -14,6 +14,7 @@ import { ThemeProvider, useTheme } from '../../../contexts/themeContext/ThemeCon import { useStateStore } from '../../../hooks/useStateStore'; import { ChannelUnreadStateStoreType } from '../../../state-store/channel-unread-state'; +import { MessagePreviousAndNextMessageStoreType } from '../../../state-store/message-list-prev-next-state'; const channelUnreadStateSelector = (state: ChannelUnreadStateStoreType) => ({ first_unread_message_id: state.channelUnreadState?.first_unread_message_id, @@ -23,12 +24,11 @@ const channelUnreadStateSelector = (state: ChannelUnreadStateStoreType) => ({ }); export type MessageWrapperProps = { - isNewestMessage?: boolean; message: LocalMessage; }; export const MessageWrapper = (props: MessageWrapperProps) => { - const { isNewestMessage, message } = props; + const { message } = props; const { client } = useChatContext(); const { channelUnreadStateStore, @@ -61,6 +61,14 @@ export const MessageWrapper = (props: MessageWrapperProps) => { messageListPreviousAndNextMessageStore, }); + const selector = useCallback( + (state: MessagePreviousAndNextMessageStoreType) => ({ + nextMessage: state.messageList[message.id]?.nextMessage, + }), + [message.id], + ); + const { nextMessage } = useStateStore(messageListPreviousAndNextMessageStore.state, selector); + const isNewestMessage = nextMessage === undefined; const groupStyles = useMessageGroupStyles({ dateSeparatorDate, getMessageGroupStyle, diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index bc42f6b579..07b6056bd0 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -255,9 +255,8 @@ const getItemTypeInternal = (message: LocalMessage) => { return 'generic-message'; }; -const renderItem = ({ index, item: message }: { index: number; item: LocalMessage }) => { - const isNewestMessage = index === 0; - return ; +const renderItem = ({ item: message }: { item: LocalMessage }) => { + return ; }; const channelUnreadStateSelector = (state: ChannelUnreadStateStoreType) => ({ diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index a80b6c6ecf..661d779ba2 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -241,10 +241,8 @@ type MessageListPropsWithContext = Pick< isLiveStreaming?: boolean; }; -const renderItem = ({ index, item: message }: { index: number; item: LocalMessage }) => { - const isNewestMessage = index === 0; - - return ; +const renderItem = ({ item: message }: { item: LocalMessage }) => { + return ; }; /** From e1f3e4c3e6ae167bf87f90c56f1c41c5203b5d45 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Tue, 9 Dec 2025 18:43:14 +0530 Subject: [PATCH 07/13] fix: optimize the message list prev and next state --- package/src/state-store/message-list-prev-next-state.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package/src/state-store/message-list-prev-next-state.ts b/package/src/state-store/message-list-prev-next-state.ts index 566bf79ec9..bd89b2aade 100644 --- a/package/src/state-store/message-list-prev-next-state.ts +++ b/package/src/state-store/message-list-prev-next-state.ts @@ -23,9 +23,8 @@ export class MessagePreviousAndNextMessageStore { public setMessageListPreviousAndNextMessage(messages: LocalMessage[]) { const currentValue = this.state.getLatestValue(); - const messageList: MessagePreviousAndNextMessageStoreType['messageList'] = { - ...currentValue.messageList, - }; + const messageList: MessagePreviousAndNextMessageStoreType['messageList'] = + currentValue.messageList; for (let i = 0; i < messages.length; i++) { const message = messages[i]; const previousMessage = messages[i - 1]; From cc6ab463c17bffde574476e1bdcf82eb3785f0c1 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Tue, 9 Dec 2025 18:49:38 +0530 Subject: [PATCH 08/13] fix: optimize the message list prev and next state --- .../message-list-prev-next-state.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/package/src/state-store/message-list-prev-next-state.ts b/package/src/state-store/message-list-prev-next-state.ts index bd89b2aade..63bbd71cdc 100644 --- a/package/src/state-store/message-list-prev-next-state.ts +++ b/package/src/state-store/message-list-prev-next-state.ts @@ -23,17 +23,29 @@ export class MessagePreviousAndNextMessageStore { public setMessageListPreviousAndNextMessage(messages: LocalMessage[]) { const currentValue = this.state.getLatestValue(); - const messageList: MessagePreviousAndNextMessageStoreType['messageList'] = + const prevMessageList: MessagePreviousAndNextMessageStoreType['messageList'] = currentValue.messageList; + const newMessageList: MessagePreviousAndNextMessageStoreType['messageList'] = {}; for (let i = 0; i < messages.length; i++) { const message = messages[i]; const previousMessage = messages[i - 1]; const nextMessage = messages[i + 1]; - messageList[message.id] = { - nextMessage, - previousMessage, - }; + + const existing = prevMessageList[message.id]; + + if ( + existing && + existing.previousMessage === previousMessage && + existing.nextMessage === nextMessage + ) { + newMessageList[message.id] = existing; + } else { + newMessageList[message.id] = { + nextMessage, + previousMessage, + }; + } } - this.state.partialNext({ messageList }); + this.state.partialNext({ messageList: newMessageList }); } } From 6b7715ec0f95f79b7780a2105bf04f8bcf3a6d23 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Tue, 9 Dec 2025 18:53:36 +0530 Subject: [PATCH 09/13] fix: optimize the message list store usage --- package/src/components/Channel/Channel.tsx | 2 +- .../src/components/MessageList/hooks/useMessageList.ts | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index a6aa0d5725..aa08e4d3ea 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -771,7 +771,7 @@ const ChannelWithContext = (props: PropsWithChildren) = const [thread, setThread] = useState(threadProps || null); const [threadHasMore, setThreadHasMore] = useState(true); const [threadLoadingMore, setThreadLoadingMore] = useState(false); - const channelUnreadStateStore = useMemo(() => new ChannelUnreadStateStore(), []); + const [channelUnreadStateStore] = useState(new ChannelUnreadStateStore()); const setChannelUnreadState = useCallback( (data: ChannelUnreadStateStoreType['channelUnreadState']) => { channelUnreadStateStore.channelUnreadState = data; diff --git a/package/src/components/MessageList/hooks/useMessageList.ts b/package/src/components/MessageList/hooks/useMessageList.ts index b3518344a2..7782faced6 100644 --- a/package/src/components/MessageList/hooks/useMessageList.ts +++ b/package/src/components/MessageList/hooks/useMessageList.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { LocalMessage } from 'stream-chat'; @@ -70,6 +70,9 @@ export const useMessageList = (params: UseMessageListParams) => { const { messages, viewabilityChangedCallback } = usePaginatedMessageListContext(); const { threadMessages } = useThreadContext(); const messageList = threadList ? threadMessages : messages; + const [messageListPreviousAndNextMessageStore] = useState( + () => new MessagePreviousAndNextMessageStore(), + ); const filteredMessageList = useMemo(() => { const filteredMessages = []; @@ -86,11 +89,6 @@ export const useMessageList = (params: UseMessageListParams) => { return filteredMessages; }, [messageList, deletedMessagesVisibilityType, client.userID]); - const messageListPreviousAndNextMessageStore = useMemo( - () => new MessagePreviousAndNextMessageStore(), - [], - ); - useEffect(() => { messageListPreviousAndNextMessageStore.setMessageListPreviousAndNextMessage( filteredMessageList, From 023d1b29f46399c7ad49fab8ffc61dfa180102c6 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 10 Dec 2025 23:17:18 +0530 Subject: [PATCH 10/13] fix: optimize MessageWrapper --- .../Message/MessageSimple/MessageWrapper.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/package/src/components/Message/MessageSimple/MessageWrapper.tsx b/package/src/components/Message/MessageSimple/MessageWrapper.tsx index fbaef957de..39a4a36f22 100644 --- a/package/src/components/Message/MessageSimple/MessageWrapper.tsx +++ b/package/src/components/Message/MessageSimple/MessageWrapper.tsx @@ -18,8 +18,8 @@ import { MessagePreviousAndNextMessageStoreType } from '../../../state-store/mes const channelUnreadStateSelector = (state: ChannelUnreadStateStoreType) => ({ first_unread_message_id: state.channelUnreadState?.first_unread_message_id, - last_read: state.channelUnreadState?.last_read, last_read_message_id: state.channelUnreadState?.last_read_message_id, + last_read_timestamp: state.channelUnreadState?.last_read?.getTime(), unread_messages: state.channelUnreadState?.unread_messages, }); @@ -27,7 +27,7 @@ export type MessageWrapperProps = { message: LocalMessage; }; -export const MessageWrapper = (props: MessageWrapperProps) => { +export const MessageWrapper = React.memo((props: MessageWrapperProps) => { const { message } = props; const { client } = useChatContext(); const { @@ -78,7 +78,7 @@ export const MessageWrapper = (props: MessageWrapperProps) => { noGroupByUser, }); - const { first_unread_message_id, last_read, last_read_message_id, unread_messages } = + const { first_unread_message_id, last_read_timestamp, last_read_message_id, unread_messages } = useStateStore(channelUnreadStateStore.state, channelUnreadStateSelector); const { theme: { @@ -91,10 +91,9 @@ export const MessageWrapper = (props: MessageWrapperProps) => { } const createdAtTimestamp = message.created_at && new Date(message.created_at).getTime(); - const lastReadTimestamp = last_read?.getTime(); const isLastReadMessage = last_read_message_id === message.id || - (!unread_messages && createdAtTimestamp === lastReadTimestamp); + (!unread_messages && createdAtTimestamp === last_read_timestamp); const showUnreadSeparator = isLastReadMessage && @@ -145,4 +144,4 @@ export const MessageWrapper = (props: MessageWrapperProps) => { {showUnreadUnderlay && } ); -}; +}); From 0e05faae9ca1bf6bede4bd74e8284d93d233f945 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Thu, 11 Dec 2025 10:50:47 +0530 Subject: [PATCH 11/13] fix: usage of state store in list --- .../MessageList/MessageFlashList.tsx | 37 +++++++++---------- .../components/MessageList/MessageList.tsx | 23 ++++-------- 2 files changed, 24 insertions(+), 36 deletions(-) diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 07b6056bd0..842a3f355a 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -49,8 +49,7 @@ import { import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; -import { useStableCallback, useStateStore } from '../../hooks'; -import { ChannelUnreadStateStoreType } from '../../state-store/channel-unread-state'; +import { useStableCallback } from '../../hooks'; import { FileTypes } from '../../types/types'; import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper'; @@ -259,13 +258,6 @@ const renderItem = ({ item: message }: { item: LocalMessage }) => { return ; }; -const channelUnreadStateSelector = (state: ChannelUnreadStateStoreType) => ({ - first_unread_message_id: state.channelUnreadState?.first_unread_message_id, - last_read: state.channelUnreadState?.last_read, - last_read_message_id: state.channelUnreadState?.last_read_message_id, - unread_messages: state.channelUnreadState?.unread_messages, -}); - const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => { const LoadingMoreRecentIndicator = props.threadList ? InlineLoadingMoreRecentThreadIndicator @@ -320,10 +312,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => UnreadMessagesNotification, } = props; const flashListRef = useRef | null>(null); - const channelUnreadState = useStateStore( - channelUnreadStateStore.state, - channelUnreadStateSelector, - ); const [hasMoved, setHasMoved] = useState(false); const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); @@ -566,6 +554,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => */ useEffect(() => { const shouldMarkRead = () => { + const channelUnreadState = channelUnreadStateStore.channelUnreadState; return ( !channelUnreadState?.first_unread_message_id && !scrollToBottomButtonVisible && @@ -577,6 +566,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const handleEvent = async (event: Event) => { const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel; const isMyOwnMessage = event.message?.user?.id === client.user?.id; + const channelUnreadState = channelUnreadStateStore.channelUnreadState; // When the scrollToBottomButtonVisible is true, we need to manually update the channelUnreadState when its a received message. if ( (scrollToBottomButtonVisible || channelUnreadState?.first_unread_message_id) && @@ -587,7 +577,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => setChannelUnreadState({ ...channelUnreadState, last_read: - channelUnreadState.last_read ?? + channelUnreadState?.last_read ?? (previousUnreadCount === 0 && previousLastMessage?.created_at ? new Date(previousLastMessage.created_at) : new Date(0)), // not having information about the last read message means the whole channel is unread, @@ -605,7 +595,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => }; }, [ channel, - channelUnreadState, + channelUnreadStateStore, client.user?.id, markRead, scrollToBottomButtonVisible, @@ -646,12 +636,19 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => * This function should show or hide the unread indicator depending on the */ const updateStickyUnreadIndicator = useStableCallback((viewableItems: ViewToken[]) => { - if (!viewableItems.length || !readEvents) { - setIsUnreadNotificationOpen(false); - return; - } + const channelUnreadState = channelUnreadStateStore.channelUnreadState; + // we need this check to make sure that regular list change do not trigger + // the unread notification to appear (for example if the old last read messages + // go out of the viewport). + const lastReadMessageId = channelUnreadState?.last_read_message_id; + const lastReadMessageVisible = viewableItems.some((item) => item.item.id === lastReadMessageId); - if (selectedPicker === 'images') { + if ( + !viewableItems.length || + !readEvents || + lastReadMessageVisible || + selectedPicker === 'images' + ) { setIsUnreadNotificationOpen(false); return; } diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 661d779ba2..a86fd62e47 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -51,8 +51,7 @@ import { import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; -import { useStableCallback, useStateStore } from '../../hooks'; -import { ChannelUnreadStateStoreType } from '../../state-store/channel-unread-state'; +import { useStableCallback } from '../../hooks'; import { FileTypes } from '../../types/types'; import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper'; @@ -120,13 +119,6 @@ const getPreviousLastMessage = (messages: LocalMessage[], newMessage?: MessageRe return previousLastMessage; }; -const channelUnreadStateSelector = (state: ChannelUnreadStateStoreType) => ({ - first_unread_message_id: state.channelUnreadState?.first_unread_message_id, - last_read: state.channelUnreadState?.last_read, - last_read_message_id: state.channelUnreadState?.last_read_message_id, - unread_messages: state.channelUnreadState?.unread_messages, -}); - type MessageListPropsWithContext = Pick< AttachmentPickerContextValue, 'closePicker' | 'selectedPicker' | 'setSelectedPicker' @@ -310,10 +302,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { } = props; const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false); const { theme } = useTheme(); - const channelUnreadState = useStateStore( - channelUnreadStateStore.state, - channelUnreadStateSelector, - ); const { colors: { white_snow }, @@ -445,6 +433,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { * This function should show or hide the unread indicator depending on the */ const updateStickyUnreadIndicator = useStableCallback((viewableItems: ViewToken[]) => { + const channelUnreadState = channelUnreadStateStore.channelUnreadState; // we need this check to make sure that regular list change do not trigger // the unread notification to appear (for example if the old last read messages // go out of the viewport). @@ -547,6 +536,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { */ useEffect(() => { const shouldMarkRead = () => { + const channelUnreadState = channelUnreadStateStore.channelUnreadState; return ( !channelUnreadState?.first_unread_message_id && !scrollToBottomButtonVisible && @@ -558,17 +548,18 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const handleEvent = async (event: Event) => { const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel; const isMyOwnMessage = event.message?.user?.id === client.user?.id; + const channelUnreadState = channelUnreadStateStore.channelUnreadState; // When the scrollToBottomButtonVisible is true, we need to manually update the channelUnreadState when its a received message. if ( (scrollToBottomButtonVisible || channelUnreadState?.first_unread_message_id) && !isMyOwnMessage ) { - const previousUnreadCount = channelUnreadState.unread_messages ?? 0; + const previousUnreadCount = channelUnreadState?.unread_messages ?? 0; const previousLastMessage = getPreviousLastMessage(channel.state.messages, event.message); setChannelUnreadState({ ...channelUnreadState, last_read: - channelUnreadState.last_read ?? + channelUnreadState?.last_read ?? (previousUnreadCount === 0 && previousLastMessage?.created_at ? new Date(previousLastMessage.created_at) : new Date(0)), // not having information about the last read message means the whole channel is unread, @@ -586,7 +577,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }; }, [ channel, - channelUnreadState, + channelUnreadStateStore, client.user?.id, markRead, scrollToBottomButtonVisible, From 9f9cfae939934ee4d0b9ca74a6223662d1449426 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Fri, 12 Dec 2025 17:57:50 +0530 Subject: [PATCH 12/13] fix: rely on processedMessageList for calculations --- .../MessageList/hooks/useMessageList.ts | 42 ++++++++----------- .../message-list-prev-next-state.ts | 6 +-- 2 files changed, 21 insertions(+), 27 deletions(-) diff --git a/package/src/components/MessageList/hooks/useMessageList.ts b/package/src/components/MessageList/hooks/useMessageList.ts index 7782faced6..483209902e 100644 --- a/package/src/components/MessageList/hooks/useMessageList.ts +++ b/package/src/components/MessageList/hooks/useMessageList.ts @@ -74,26 +74,32 @@ export const useMessageList = (params: UseMessageListParams) => { () => new MessagePreviousAndNextMessageStore(), ); - const filteredMessageList = useMemo(() => { - const filteredMessages = []; + const processedMessageList = useMemo(() => { + const newMessageList = []; for (const message of messageList) { if ( - shouldIncludeMessageInList(message, { + !shouldIncludeMessageInList(message, { deletedMessagesVisibilityType, userId: client.userID, }) ) { - filteredMessages.push(message); + continue; + } + if (isFlashList) { + newMessageList.push(message); + } else { + newMessageList.unshift(message); } } - return filteredMessages; - }, [messageList, deletedMessagesVisibilityType, client.userID]); + return newMessageList; + }, [messageList, deletedMessagesVisibilityType, client.userID, isFlashList]); useEffect(() => { messageListPreviousAndNextMessageStore.setMessageListPreviousAndNextMessage( - filteredMessageList, + processedMessageList, + isFlashList, ); - }, [filteredMessageList, messageListPreviousAndNextMessageStore]); + }, [processedMessageList, messageListPreviousAndNextMessageStore, isFlashList]); /** * @deprecated use `useDateSeparator` hook instead directly in the Message. @@ -102,9 +108,9 @@ export const useMessageList = (params: UseMessageListParams) => { () => getDateSeparators({ hideDateSeparators, - messages: filteredMessageList, + messages: processedMessageList, }), - [hideDateSeparators, filteredMessageList], + [hideDateSeparators, processedMessageList], ); /** @@ -122,7 +128,7 @@ export const useMessageList = (params: UseMessageListParams) => { dateSeparators: dateSeparatorsRef.current, hideDateSeparators, maxTimeBetweenGroupedMessages, - messages: filteredMessageList, + messages: processedMessageList, noGroupByUser, userId: client.userID, }), @@ -130,7 +136,7 @@ export const useMessageList = (params: UseMessageListParams) => { getMessagesGroupStyles, hideDateSeparators, maxTimeBetweenGroupedMessages, - filteredMessageList, + processedMessageList, noGroupByUser, client.userID, ], @@ -142,18 +148,6 @@ export const useMessageList = (params: UseMessageListParams) => { const messageGroupStylesRef = useRef(messageGroupStyles); messageGroupStylesRef.current = messageGroupStyles; - const processedMessageList = useMemo(() => { - const newMessageList = []; - for (const message of filteredMessageList) { - if (isFlashList) { - newMessageList.push(message); - } else { - newMessageList.unshift(message); - } - } - return newMessageList; - }, [filteredMessageList, isFlashList]); - const data = useRAFCoalescedValue(processedMessageList, isLiveStreaming); return useMemo( diff --git a/package/src/state-store/message-list-prev-next-state.ts b/package/src/state-store/message-list-prev-next-state.ts index 63bbd71cdc..e8532aa72c 100644 --- a/package/src/state-store/message-list-prev-next-state.ts +++ b/package/src/state-store/message-list-prev-next-state.ts @@ -21,15 +21,15 @@ export class MessagePreviousAndNextMessageStore { this.state = new StateStore(INITIAL_STATE); } - public setMessageListPreviousAndNextMessage(messages: LocalMessage[]) { + public setMessageListPreviousAndNextMessage(messages: LocalMessage[], isFlashList?: boolean) { const currentValue = this.state.getLatestValue(); const prevMessageList: MessagePreviousAndNextMessageStoreType['messageList'] = currentValue.messageList; const newMessageList: MessagePreviousAndNextMessageStoreType['messageList'] = {}; for (let i = 0; i < messages.length; i++) { const message = messages[i]; - const previousMessage = messages[i - 1]; - const nextMessage = messages[i + 1]; + const previousMessage = isFlashList ? messages[i - 1] : messages[i + 1]; + const nextMessage = isFlashList ? messages[i + 1] : messages[i - 1]; const existing = prevMessageList[message.id]; From cad84174d4a79fd4ae8c67a66dcbaff2715e1152 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Fri, 12 Dec 2025 18:23:21 +0530 Subject: [PATCH 13/13] fix: rely on processedMessageList for calculations --- .../__tests__/useMessageDateSeparator.test.ts | 4 ++-- .../src/components/MessageList/hooks/useMessageList.ts | 8 ++++---- package/src/state-store/message-list-prev-next-state.ts | 9 ++++++++- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts b/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts index 3861acd871..07203353a6 100644 --- a/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts +++ b/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts @@ -28,7 +28,7 @@ describe('useMessageDateSeparator', () => { text: 'Hello World', }, ] as LocalMessage[]; - messageListPreviousAndNextMessageStore.setMessageListPreviousAndNextMessage(messages); + messageListPreviousAndNextMessageStore.setMessageListPreviousAndNextMessage({ messages }); }); it('should return undefined if no message is passed', () => { @@ -70,7 +70,7 @@ describe('useMessageDateSeparator', () => { }, ] as LocalMessage[]; const messageListPreviousAndNextMessageStore = new MessagePreviousAndNextMessageStore(); - messageListPreviousAndNextMessageStore.setMessageListPreviousAndNextMessage(messages); + messageListPreviousAndNextMessageStore.setMessageListPreviousAndNextMessage({ messages }); const { result: resultOfFirstMessage } = renderHook(() => useMessageDateSeparator({ message: messages[0], messageListPreviousAndNextMessageStore }), ); diff --git a/package/src/components/MessageList/hooks/useMessageList.ts b/package/src/components/MessageList/hooks/useMessageList.ts index 483209902e..75366278ea 100644 --- a/package/src/components/MessageList/hooks/useMessageList.ts +++ b/package/src/components/MessageList/hooks/useMessageList.ts @@ -62,7 +62,7 @@ export const shouldIncludeMessageInList = ( }; export const useMessageList = (params: UseMessageListParams) => { - const { noGroupByUser, threadList, isLiveStreaming, isFlashList } = params; + const { noGroupByUser, threadList, isLiveStreaming, isFlashList = false } = params; const { client } = useChatContext(); const { hideDateSeparators, maxTimeBetweenGroupedMessages } = useChannelContext(); const { deletedMessagesVisibilityType, getMessagesGroupStyles = getGroupStyles } = @@ -95,10 +95,10 @@ export const useMessageList = (params: UseMessageListParams) => { }, [messageList, deletedMessagesVisibilityType, client.userID, isFlashList]); useEffect(() => { - messageListPreviousAndNextMessageStore.setMessageListPreviousAndNextMessage( - processedMessageList, + messageListPreviousAndNextMessageStore.setMessageListPreviousAndNextMessage({ isFlashList, - ); + messages: processedMessageList, + }); }, [processedMessageList, messageListPreviousAndNextMessageStore, isFlashList]); /** diff --git a/package/src/state-store/message-list-prev-next-state.ts b/package/src/state-store/message-list-prev-next-state.ts index e8532aa72c..3cde61704c 100644 --- a/package/src/state-store/message-list-prev-next-state.ts +++ b/package/src/state-store/message-list-prev-next-state.ts @@ -21,7 +21,14 @@ export class MessagePreviousAndNextMessageStore { this.state = new StateStore(INITIAL_STATE); } - public setMessageListPreviousAndNextMessage(messages: LocalMessage[], isFlashList?: boolean) { + // The default value of isFlashList is true as the logic in the function makes more sense when the list is not reversed. + public setMessageListPreviousAndNextMessage({ + messages, + isFlashList = true, + }: { + messages: LocalMessage[]; + isFlashList?: boolean; + }) { const currentValue = this.state.getLatestValue(); const prevMessageList: MessagePreviousAndNextMessageStoreType['messageList'] = currentValue.messageList;