diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index c52f422f58..78f399cca7 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -694,7 +694,8 @@ const ChannelWithContext = < const [deleted, setDeleted] = useState(false); const [editing, setEditing] = useState | undefined>(undefined); const [error, setError] = useState(false); - const [lastRead, setLastRead] = useState['lastRead']>(); + const [lastRead, setLastRead] = useState(); + const [quotedMessage, setQuotedMessage] = useState | undefined>( undefined, ); @@ -961,6 +962,7 @@ const ChannelWithContext = < last_read_message_id: response?.event.last_read_message_id, unread_messages: 0, }); + setLastRead(new Date()); } } catch (err) { console.log('Error marking channel as read:', err); diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 43e40ed2df..8edcc648eb 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -10,7 +10,7 @@ import { ViewToken, } from 'react-native'; -import type { FormatMessageResponse } from 'stream-chat'; +import type { Channel, Event, FormatMessageResponse, MessageResponse } from 'stream-chat'; import { isMessageWithStylesReadByAndDateSeparator, @@ -108,6 +108,36 @@ const flatListViewabilityConfig: ViewabilityConfig = { viewAreaCoveragePercentThreshold: 1, }; +const hasReadLastMessage = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>( + channel: Channel, + userId: string, +) => { + const latestMessageIdInChannel = channel.state.latestMessages.slice(-1)[0]?.id; + const lastReadMessageIdServer = channel.state.read[userId]?.last_read_message_id; + return latestMessageIdInChannel === lastReadMessageIdServer; +}; + +const getPreviousLastMessage = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>( + messages: MessageType[], + newMessage?: MessageResponse, +) => { + if (!newMessage) return; + let previousLastMessage; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (!msg?.id) break; + if (msg.id !== newMessage.id) { + previousLastMessage = msg; + break; + } + } + return previousLastMessage; +}; + type MessageListPropsWithContext< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, > = Pick & @@ -126,6 +156,7 @@ type MessageListPropsWithContext< | 'NetworkDownIndicator' | 'reloadChannel' | 'scrollToFirstUnreadThreshold' + | 'setChannelUnreadState' | 'setTargetedMessage' | 'StickyHeader' | 'targetedMessage' @@ -271,6 +302,7 @@ const MessageListWithContext = < reloadChannel, ScrollToBottomButton, selectedPicker, + setChannelUnreadState, setFlatListRef, setMessages, setSelectedPicker, @@ -418,19 +450,32 @@ const MessageListWithContext = < const lastItem = viewableItems[viewableItems.length - 1]; if (lastItem) { - const lastItemCreatedAt = lastItem.item.created_at; + const lastItemMessage = lastItem.item; + const lastItemCreatedAt = lastItemMessage.created_at; const unreadIndicatorDate = channelUnreadState?.last_read.getTime(); const lastItemDate = lastItemCreatedAt.getTime(); if ( !channel.state.messagePagination.hasPrev && - processedMessageList[processedMessageList.length - 1].id === lastItem.item.id + processedMessageList[processedMessageList.length - 1].id === lastItemMessage.id + ) { + setIsUnreadNotificationOpen(false); + return; + } + /** + * This is a special case where there is a single long message by the sender. + * When a message is sent, we mark it as read before it actually has a `created_at` timestamp. + * This is a workaround to prevent the unread indicator from showing when the message is sent. + */ + if ( + viewableItems.length === 1 && + channel.countUnread() === 0 && + lastItemMessage.user.id === client.userID ) { setIsUnreadNotificationOpen(false); return; } - if (unreadIndicatorDate && lastItemDate > unreadIndicatorDate) { setIsUnreadNotificationOpen(true); } else { @@ -485,19 +530,56 @@ const MessageListWithContext = < * Effect to mark the channel as read when the user scrolls to the bottom of the message list. */ useEffect(() => { - const listener: ReturnType = channel.on('message.new', (event) => { - const newMessageToCurrentChannel = event.cid === channel.cid; - const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel; + const shouldMarkRead = () => { + return ( + !channelUnreadState?.first_unread_message_id && + !scrollToBottomButtonVisible && + client.user?.id && + !hasReadLastMessage(channel, client.user?.id) + ); + }; - if (newMessageToCurrentChannel && mainChannelUpdated && !scrollToBottomButtonVisible) { - markRead(); + const handleEvent = async (event: Event) => { + const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel; + console.log(mainChannelUpdated, shouldMarkRead()); + // When the scrollToBottomButtonVisible is true, we need to manually update the channelUnreadState. + if (scrollToBottomButtonVisible || channelUnreadState?.first_unread_message_id) { + 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, + }; + }); + } else if (mainChannelUpdated && shouldMarkRead()) { + console.log('marking read'); + await markRead(); } - }); + }; + + const listener: ReturnType = channel.on('message.new', handleEvent); return () => { listener?.unsubscribe(); }; - }, [channel, markRead, scrollToBottomButtonVisible]); + }, [ + channel, + channelUnreadState?.first_unread_message_id, + client.user?.id, + markRead, + scrollToBottomButtonVisible, + setChannelUnreadState, + threadList, + ]); useEffect(() => { const lastReceivedMessage = getLastReceivedMessage(processedMessageList); @@ -537,6 +619,7 @@ const MessageListWithContext = < setTimeout(() => { channelResyncScrollSet.current = true; if (channel.countUnread() > 0) { + console.log('marking read'); markRead(); } }, 500); @@ -901,6 +984,13 @@ const MessageListWithContext = < } setScrollToBottomButtonVisible(false); + /** + * When we are not in the bottom of the list, and we receive new messages, we need to mark the channel as read. + We would still need to show the unread label, where the first unread message appeared so we don't update the channelUnreadState. + */ + await markRead({ + updateChannelUnreadState: false, + }); }; const scrollToIndexFailedRetryCountRef = useRef(0); @@ -1212,6 +1302,7 @@ export const MessageList = < NetworkDownIndicator, reloadChannel, scrollToFirstUnreadThreshold, + setChannelUnreadState, setTargetedMessage, StickyHeader, targetedMessage, @@ -1277,6 +1368,7 @@ export const MessageList = < ScrollToBottomButton, scrollToFirstUnreadThreshold, selectedPicker, + setChannelUnreadState, setMessages, setSelectedPicker, setTargetedMessage,