From 88d12f9d04bc1e0c567083e59b241fa1f8ad18a8 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Mon, 18 Nov 2024 14:01:50 +0530 Subject: [PATCH 01/10] refactor: remove stale subscriber count logic and types refactor (#2782) --- .../MessageList/hooks/useMessageList.ts | 7 +- .../MessageList/utils/getReadStates.ts | 4 +- .../ChannelsStateContext.tsx | 69 ++----------------- .../channelsStateContext/useChannelState.ts | 31 ++------- 4 files changed, 15 insertions(+), 96 deletions(-) diff --git a/package/src/components/MessageList/hooks/useMessageList.ts b/package/src/components/MessageList/hooks/useMessageList.ts index 159a9c158f..cafa27493c 100644 --- a/package/src/components/MessageList/hooks/useMessageList.ts +++ b/package/src/components/MessageList/hooks/useMessageList.ts @@ -1,9 +1,6 @@ import type { ChannelState, MessageResponse } from 'stream-chat'; -import { - ChannelContextValue, - useChannelContext, -} from '../../../contexts/channelContext/ChannelContext'; +import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import { DeletedMessagesVisibilityType, @@ -61,7 +58,7 @@ export const useMessageList = < const { threadMessages } = useThreadContext(); const messageList = threadList ? threadMessages : messages; - const readList: ChannelContextValue['read'] | undefined = threadList + const readList: ChannelState['read'] | undefined = threadList ? undefined : read; diff --git a/package/src/components/MessageList/utils/getReadStates.ts b/package/src/components/MessageList/utils/getReadStates.ts index 0d8541478b..f4aa4a1e24 100644 --- a/package/src/components/MessageList/utils/getReadStates.ts +++ b/package/src/components/MessageList/utils/getReadStates.ts @@ -1,4 +1,4 @@ -import type { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; +import { ChannelState } from 'stream-chat'; import type { PaginatedMessageListContextValue } from '../../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadContext'; import type { DefaultStreamChatGenerics } from '../../../types/types'; @@ -10,7 +10,7 @@ export const getReadStates = < messages: | PaginatedMessageListContextValue['messages'] | ThreadContextValue['threadMessages'], - read?: ChannelContextValue['read'], + read?: ChannelState['read'], ) => { const readData: Record = {}; diff --git a/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx b/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx index 32f0c68c60..1fd811a09f 100644 --- a/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx +++ b/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx @@ -7,11 +7,11 @@ import React, { useReducer, useRef, } from 'react'; +import { ChannelState as StreamChannelState } from 'stream-chat'; import type { DefaultStreamChatGenerics } from '../../types/types'; import { ActiveChannelsProvider } from '../activeChannelsRefContext/ActiveChannelsRefContext'; -import type { ChannelContextValue } from '../channelContext/ChannelContext'; import type { PaginatedMessageListContextValue } from '../paginatedMessageListContext/PaginatedMessageListContext'; import type { ThreadContextValue } from '../threadContext/ThreadContext'; import type { TypingContextValue } from '../typingContext/TypingContext'; @@ -22,14 +22,13 @@ import { isTestEnvironment } from '../utils/isTestEnvironment'; export type ChannelState< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, > = { - members: ChannelContextValue['members']; + members: StreamChannelState['members']; messages: PaginatedMessageListContextValue['messages']; - read: ChannelContextValue['read']; - subscriberCount: number; + read: StreamChannelState['read']; threadMessages: ThreadContextValue['threadMessages']; typing: TypingContextValue['typing']; - watcherCount: ChannelContextValue['watcherCount']; - watchers: ChannelContextValue['watchers']; + watcherCount: number; + watchers: StreamChannelState['watchers']; }; type ChannelsState< @@ -56,25 +55,12 @@ type SetStateAction< type: 'SET_STATE'; }; -type IncreaseSubscriberCountAction = { - payload: { cid: string }; - type: 'INCREASE_SUBSCRIBER_COUNT'; -}; -type DecreaseSubscriberCountAction = { - payload: { cid: string }; - type: 'DECREASE_SUBSCRIBER_COUNT'; -}; - type Action = - | SetStateAction - | IncreaseSubscriberCountAction - | DecreaseSubscriberCountAction; + SetStateAction; export type ChannelsStateContextValue< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, > = { - decreaseSubscriberCount: (value: { cid: string }) => void; - increaseSubscriberCount: (value: { cid: string }) => void; setState: (value: Payload) => void; state: ChannelsState; }; @@ -95,39 +81,6 @@ function reducer(state: ChannelsState, action: Action) { }, }; - case 'INCREASE_SUBSCRIBER_COUNT': { - const currentCount = state[action.payload.cid]?.subscriberCount ?? 0; - return { - ...state, - [action.payload.cid]: { - ...(state[action.payload.cid] || {}), - subscriberCount: currentCount + 1, - }, - }; - } - - case 'DECREASE_SUBSCRIBER_COUNT': { - const currentCount = state[action.payload.cid]?.subscriberCount ?? 0; - - // If there last subscribed Channel component unsubscribes, we clear the channel state. - if (currentCount <= 1) { - const stateShallowCopy = { - ...state, - }; - - delete stateShallowCopy[action.payload.cid]; - - return stateShallowCopy; - } - - return { - ...state, - [action.payload.cid]: { - ...(state[action.payload.cid] || {}), - subscriberCount: currentCount - 1, - }, - }; - } default: throw new Error(); } @@ -150,18 +103,8 @@ export const ChannelsStateProvider = < dispatch({ payload, type: 'SET_STATE' }); }, []); - const increaseSubscriberCount = useCallback((payload: { cid: string }) => { - dispatch({ payload, type: 'INCREASE_SUBSCRIBER_COUNT' }); - }, []); - - const decreaseSubscriberCount = useCallback((payload: { cid: string }) => { - dispatch({ payload, type: 'DECREASE_SUBSCRIBER_COUNT' }); - }, []); - const value = useMemo( () => ({ - decreaseSubscriberCount, - increaseSubscriberCount, setState, state, }), diff --git a/package/src/contexts/channelsStateContext/useChannelState.ts b/package/src/contexts/channelsStateContext/useChannelState.ts index f92d8cb398..2c69cabf02 100644 --- a/package/src/contexts/channelsStateContext/useChannelState.ts +++ b/package/src/contexts/channelsStateContext/useChannelState.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import type { Channel as ChannelType } from 'stream-chat'; @@ -11,15 +11,12 @@ import type { DefaultStreamChatGenerics } from '../../types/types'; type StateManagerParams< Key extends Keys, StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, -> = Omit< - ChannelsStateContextValue, - 'increaseSubscriberCount' | 'decreaseSubscriberCount' -> & { +> = ChannelsStateContextValue & { cid: string; key: Key; }; -/* +/* This hook takes care of creating a useState-like interface which can be used later to call updates to the ChannelsStateContext reducer. It receives the cid and key which it wants to update and perform the state updates. Also supports a initialState. @@ -28,15 +25,7 @@ function useStateManager< Key extends Keys, StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, >( - { - cid, - key, - setState, - state, - }: Omit< - StateManagerParams, - 'increaseSubscriberCount' | 'decreaseSubscriberCount' - >, + { cid, key, setState, state }: StateManagerParams, initialValue?: ChannelState[Key], ) { // eslint-disable-next-line react-hooks/exhaustive-deps @@ -79,17 +68,7 @@ export function useChannelState< threadId?: string, ): UseChannelStateValue { const cid = channel?.id || 'id'; // in case channel is not initialized, use generic id string for indexing - const { decreaseSubscriberCount, increaseSubscriberCount, setState, state } = - useChannelsStateContext(); - - // Keeps track of how many Channel components are subscribed to this Channel state (Channel vs Thread concurrency) - useEffect(() => { - increaseSubscriberCount({ cid }); - return () => { - decreaseSubscriberCount({ cid }); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const { setState, state } = useChannelsStateContext(); const [members, setMembers] = useStateManager( { From 8ec325395c28768cef6c65f5d0c8b42fe8e42437 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Tue, 19 Nov 2024 21:01:38 +0530 Subject: [PATCH 02/10] feat: new message list pagination implementation --- examples/SampleApp/src/ChatUsers.ts | 8 +- package/src/components/Channel/Channel.tsx | 1019 ++++------------- .../Channel/__tests__/Channel.test.js | 11 - .../Channel/hooks/useChannelDataState.ts | 215 ++++ .../useCreatePaginatedMessageListContext.ts | 13 +- .../hooks/useMessageListPagination.tsx | 245 ++++ .../components/MessageList/MessageList.tsx | 140 +-- .../MessageList/utils/getReadStates.ts | 1 + .../__snapshots__/Thread.test.js.snap | 23 + .../channelContext/ChannelContext.tsx | 60 +- .../ChannelsStateContext.tsx | 9 - .../channelsStateContext/useChannelState.ts | 83 +- .../PaginatedMessageListContext.tsx | 33 +- 13 files changed, 793 insertions(+), 1067 deletions(-) create mode 100644 package/src/components/Channel/hooks/useChannelDataState.ts create mode 100644 package/src/components/Channel/hooks/useMessageListPagination.tsx diff --git a/examples/SampleApp/src/ChatUsers.ts b/examples/SampleApp/src/ChatUsers.ts index d52b40d3fb..6b1b37be5c 100644 --- a/examples/SampleApp/src/ChatUsers.ts +++ b/examples/SampleApp/src/ChatUsers.ts @@ -8,6 +8,8 @@ export const USER_TOKENS: Record = { 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiZTJldGVzdDIifQ.2ZsHCMJ7i0vZvRJ5yoT-bm8OD_KAzBgJ-kB6bHGZ4FI', e2etest3: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiZTJldGVzdDMifQ.RWHY-MYkpP8FTJkfgrxUlCQhwap6eB7DTsp_HsZ1oIw', + khushal: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoia2h1c2hhbCJ9.NG3b6I8MgkLevwuTTqDXTpOol-Yj_1NCyvxewL_tg4U', neil: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibmVpbCJ9.ty2YhwFaVEYkq1iUfY8s1G0Um3MpiVYpWK-b5kMky0w', qatest1: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicWF0ZXN0MSJ9.5Nnj6MsauhjP7_D8jW9WbRovLv5uaxn8LPZZ-HB3mh4', @@ -41,7 +43,11 @@ export const USERS: Record> = { image: 'https://randomuser.me/api/portraits/thumb/men/11.jpg', name: 'QA Test 2', }, - + khushal: { + id: 'khushal', + image: 'https://ca.slack-edge.com/T02RM6X6B-U02DTREQ2KX-41639a005d53-512', + name: 'Khushal Agarwal', + }, thierry: { id: 'thierry', image: 'https://ca.slack-edge.com/T02RM6X6B-U02RM6X6D-g28a1278a98e-512', diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 486a031a96..cf5b434c31 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -11,6 +11,7 @@ import { ChannelState, Channel as ChannelType, EventHandler, + FormatMessageResponse, logChatPromiseExecution, MessageResponse, Reaction, @@ -21,6 +22,7 @@ import { Thread, } from 'stream-chat'; +import { useChannelDataState } from './hooks/useChannelDataState'; import { useCreateChannelContext } from './hooks/useCreateChannelContext'; import { useCreateInputMessageInputContext } from './hooks/useCreateInputMessageInputContext'; @@ -34,6 +36,7 @@ import { useCreateThreadContext } from './hooks/useCreateThreadContext'; import { useCreateTypingContext } from './hooks/useCreateTypingContext'; +import { useMessageListPagination } from './hooks/useMessageListPagination'; import { useTargetedMessage } from './hooks/useTargetedMessage'; import { ChannelContextValue, ChannelProvider } from '../../contexts/channelContext/ChannelContext'; @@ -265,7 +268,7 @@ export type ChannelPropsWithContext< 'messages' | 'loadingMore' | 'loadingMoreRecent' > > & - UseChannelStateValue & + Pick, 'threadMessages' | 'setThreadMessages'> & Partial< Pick< MessagesContextValue, @@ -556,7 +559,6 @@ const ChannelWithContext = < maxMessageLength: maxMessageLengthProp, maxNumberOfFiles = 10, maxTimeBetweenGroupedMessages, - members, mentionAllAppUsersEnabled = false, mentionAllAppUsersQuery, Message = MessageDefault, @@ -579,7 +581,6 @@ const ChannelWithContext = < MessageReactionPicker = MessageReactionPickerDefault, MessageReplies = MessageRepliesDefault, MessageRepliesAvatars = MessageRepliesAvatarsDefault, - messages, MessageSimple = MessageSimpleDefault, MessageStatus = MessageStatusDefault, MessageSystem = MessageSystemDefault, @@ -592,7 +593,8 @@ const ChannelWithContext = < MoreOptionsButton = MoreOptionsButtonDefault, myMessageTheme, NetworkDownIndicator = NetworkDownIndicatorDefault, - newMessageStateUpdateThrottleInterval = defaultThrottleInterval, + // TODO: Think about this one + // newMessageStateUpdateThrottleInterval = defaultThrottleInterval, numberOfLines = 5, onChangeText, onLongPressMessage, @@ -604,7 +606,6 @@ const ChannelWithContext = < ReactionListBottom = ReactionListBottomDefault, reactionListPosition = 'top', ReactionListTop = ReactionListTopDefault, - read, Reply = ReplyDefault, ScrollToBottomButton = ScrollToBottomButtonDefault, selectReaction, @@ -612,13 +613,7 @@ const ChannelWithContext = < sendImageAsync = false, SendMessageDisallowedIndicator = SendMessageDisallowedIndicatorDefault, setInputRef, - setMembers, - setMessages, - setRead, setThreadMessages, - setTyping, - setWatcherCount, - setWatchers, shouldShowUnreadUnderlay = true, shouldSyncChannel, ShowThreadMessageInChannelButton = ShowThreadMessageInChannelButtonDefault, @@ -630,14 +625,11 @@ const ChannelWithContext = < thread: threadFromProps, threadList, threadMessages, - typing, TypingIndicator = TypingIndicatorDefault, TypingIndicatorContainer = TypingIndicatorContainerDefault, UploadProgressIndicator = UploadProgressIndicatorDefault, UrlPreview = CardDefault, VideoThumbnail = VideoThumbnailDefault, - watcherCount, - watchers, } = props; const { thread: threadProps, threadInstance } = threadFromProps; @@ -648,15 +640,9 @@ const ChannelWithContext = < colors: { black }, }, } = useTheme(); - const [deleted, setDeleted] = useState(false); const [editing, setEditing] = useState | undefined>(undefined); const [error, setError] = useState(false); - const [hasMore, setHasMore] = useState(true); const [lastRead, setLastRead] = useState['lastRead']>(); - const [loading, setLoading] = useState(false); - const [loadingMore, setLoadingMore] = useState(false); - - const [loadingMoreRecent, setLoadingMoreRecent] = useState(false); const [quotedMessage, setQuotedMessage] = useState | undefined>( undefined, ); @@ -666,19 +652,7 @@ const ChannelWithContext = < const syncingChannelRef = useRef(false); - /** - * Flag to track if we know for sure that there are no more recent messages to load. - * This is necessary to avoid unnecessary api calls to load recent messages on pagination. - */ - const [hasNoMoreRecentMessagesToLoad, setHasNoMoreRecentMessagesToLoad] = useState(true); - - const { prevTargetedMessage, setTargetedMessage, targetedMessage } = useTargetedMessage(); - - /** - * If we loaded a channel around message - * We may have moved latest message to a new message set in that case mark this ref to avoid fetching - */ - const hasOverlappingRecentMessagesRef = useRef(false); + const { setTargetedMessage, targetedMessage } = useTargetedMessage(); /** * This ref will hold the abort controllers for @@ -690,50 +664,122 @@ const ChannelWithContext = < const channelId = channel?.id || ''; const pollCreationEnabled = !channel.disconnected && !!channel?.id && channel?.getConfig()?.polls; + const { + copyStateFromChannel, + initStateFromChannel, + setTyping, + state: channelState, + } = useChannelDataState(); + + const { + copyMessagesStateFromChannel, + loadChannelAroundMessage: loadChannelAroundMessageFn, + loadChannelAtFirstUnreadMessage, + loadInitialMessagesStateFromChannel, + loadLatestMessages, + loadMore, + loadMoreRecent, + state: channelMessagesState, + } = useMessageListPagination({ + channel, + }); + + const copyChannelState = useRef( + throttle( + () => { + if (channel) { + copyStateFromChannel(channel); + copyMessagesStateFromChannel(channel); + } + }, + stateUpdateThrottleInterval, + throttleOptions, + ), + ).current; + + const handleEvent: EventHandler = (event) => { + if (shouldSyncChannel) { + // Ignore user.watching.start and user.watching.stop events + const ignorableEvents = ['user.watching.start', 'user.watching.stop']; + if (ignorableEvents.includes(event.type)) return; + + // If the event is typing.start or typing.stop, set the typing state + const isTypingEvent = event.type === 'typing.start' || event.type === 'typing.stop'; + if (isTypingEvent) { + setTyping(channel); + } else { + if (thread?.id) { + const updatedThreadMessages = + (thread.id && channel && channel.state.threads[thread.id]) || threadMessages; + setThreadMessages(updatedThreadMessages); + + if (channel && event.message?.id === thread.id && !threadInstance) { + const updatedThread = channel.state.formatMessage(event.message); + setThread(updatedThread); + } + } + } + + // only update channel state if the events are not the previously subscribed useEffect's subscription events + if (channel && channel.initialized) { + copyChannelState(); + } + } + }; + useEffect(() => { const initChannel = async () => { if (!channel || !shouldSyncChannel || channel.offlineMode) return; - /** - * Loading channel at first unread message requires channel to be initialized in the first place, - * since we use read state on channel to decide what offset to load channel at. - * Also there is no use case from UX perspective, why one would need loading uninitialized channel at particular message. - * If the channel is not initiated, then we need to do channel.watch, which is more expensive for backend than channel.query. - */ - if (!channel.initialized) { - await loadChannel(); + let errored = false; + + if (!channel.initialized || !channel.state.isUpToDate) { + try { + await channel?.watch(); + } catch (err) { + console.warn('Channel watch request failed with error:', err); + setError(true); + errored = true; + } } - if (messageId) { - loadChannelAroundMessage({ messageId }); + if (!errored) { + initStateFromChannel(channel); + loadInitialMessagesStateFromChannel(channel, channel.state.messagePagination.hasPrev); } - // The condition, where if the count of unread messages is greater than 4, then scroll to the first unread message. - else if ( + + if (messageId) { + await loadChannelAroundMessage({ messageId, setTargetedMessage }); + } else if ( initialScrollToFirstUnreadMessage && channel.countUnread() > scrollToFirstUnreadThreshold ) { - loadChannelAtFirstUnreadMessage(); - } - // If the messageId is undefined and the last message and the current message id do not match we load the channel at the very bottom. - else if ( - channel.state.messages?.[channel.state.messages.length - 1]?.id !== - channel.state.latestMessages?.[channel.state.latestMessages.length - 1]?.id && - !messageId - ) { - await loadChannel(); + await loadChannelAtFirstUnreadMessage({ setTargetedMessage }); } + channel.on(handleEvent); }; initChannel(); return () => { copyChannelState.cancel(); - copyReadState.cancel(); - copyTypingState.cancel(); - loadMoreFinished.cancel(); loadMoreThreadFinished.cancel(); + channel.off(handleEvent); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [channel.cid, messageId, shouldSyncChannel]); + + /** + * Subscription to the Notification mark_read event. + */ + useEffect(() => { + const handleEvent: EventHandler = (event) => { + if (channel.cid === event.cid) copyChannelState(); }; + + const { unsubscribe } = client.on('notification.mark_read', handleEvent); + return unsubscribe; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [channelId, messageId]); + }, []); const threadPropsExists = !!threadProps; @@ -785,442 +831,6 @@ const ChannelWithContext = < ), ).current; - const copyMessagesState = useRef( - throttle( - () => { - if (channel) { - clearInterval(mergeSetsIntervalRef.current); - setMessages(channel.state.messages); - restartSetsMergeFuncRef.current(); - } - }, - newMessageStateUpdateThrottleInterval, - throttleOptions, - ), - ).current; - - const copyTypingState = useRef( - throttle( - () => { - if (channel) { - setTyping({ ...channel.state.typing }); - } - }, - stateUpdateThrottleInterval, - throttleOptions, - ), - ).current; - - const copyReadState = useRef( - throttle( - () => { - if (channel) { - setRead({ ...channel.state.read }); - } - }, - stateUpdateThrottleInterval, - throttleOptions, - ), - ).current; - - const copyChannelState = useRef( - throttle( - () => { - setLoading(false); - if (channel) { - setMembers({ ...channel.state.members }); - setMessages([...channel.state.messages]); - setRead({ ...channel.state.read }); - setTyping({ ...channel.state.typing }); - setWatcherCount(channel.state.watcher_count); - setWatchers({ ...channel.state.watchers }); - } - }, - stateUpdateThrottleInterval, - throttleOptions, - ), - ).current; - - // subscribe to specific channel events - useEffect(() => { - const channelSubscriptions: Array> = []; - if (channel && shouldSyncChannel) { - channelSubscriptions.push(channel.on('message.new', copyMessagesState)); - channelSubscriptions.push(channel.on('message.read', copyReadState)); - channelSubscriptions.push(channel.on('typing.start', copyTypingState)); - channelSubscriptions.push(channel.on('typing.stop', copyTypingState)); - } - return () => { - channelSubscriptions.forEach((s) => s.unsubscribe()); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [channelId, shouldSyncChannel]); - - // subscribe to the generic all channel event - useEffect(() => { - const handleEvent: EventHandler = (event) => { - const ignorableEvents = ['user.watching.start', 'user.watching.stop']; - if (ignorableEvents.includes(event.type)) return; - if (shouldSyncChannel) { - const isTypingEvent = event.type === 'typing.start' || event.type === 'typing.stop'; - if (!isTypingEvent) { - if (thread?.id) { - const updatedThreadMessages = - (thread.id && channel && channel.state.threads[thread.id]) || threadMessages; - setThreadMessages(updatedThreadMessages); - } - - if (channel && thread?.id && event.message?.id === thread.id && !threadInstance) { - const updatedThread = channel.state.formatMessage(event.message); - setThread(updatedThread); - } - } - - // only update channel state if the events are not the previously subscribed useEffect's subscription events - if ( - channel && - channel.initialized && - event.type !== 'message.new' && - event.type !== 'message.read' && - event.type !== 'typing.start' && - event.type !== 'typing.stop' - ) { - copyChannelState(); - } - } - }; - const { unsubscribe } = channel.on(handleEvent); - return unsubscribe; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [channelId, thread?.id, shouldSyncChannel]); - - // subscribe to channel.deleted event - useEffect(() => { - const { unsubscribe } = client.on('channel.deleted', (event) => { - if (event.cid === channel?.cid) { - setDeleted(true); - } - }); - - return unsubscribe; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [channelId]); - - useEffect(() => { - const handleEvent: EventHandler = (event) => { - if (channel.cid === event.cid) copyChannelState(); - }; - - const { unsubscribe } = client.on('notification.mark_read', handleEvent); - return unsubscribe; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const channelQueryCallRef = useRef( - async ( - queryCall: () => Promise, - onAfterQueryCall: (() => void) | undefined = undefined, - // if we are scrolling to a message after the query, pass it here - scrollToMessageId: string | (() => string | undefined) | undefined = undefined, - ) => { - setError(false); - try { - clearInterval(mergeSetsIntervalRef.current); - await queryCall(); - setLastRead(new Date()); - setHasMore(true); - const currentMessages = channel.state.messages; - const hadCurrentLatestMessages = - currentMessages.length > 0 && currentMessages === channel.state.latestMessages; - if (typeof scrollToMessageId === 'function') { - scrollToMessageId = scrollToMessageId(); - } - - const scrollToMessageIndex = scrollToMessageId - ? currentMessages.findIndex(({ id }) => id === scrollToMessageId) - : -1; - if (channel && scrollToMessageIndex !== -1) { - copyChannelState.cancel(); - // We assume that on average user sees 5 messages on screen - // We dont want new renders to happen while scrolling to the targeted message - // hence we limit the number of messages to be rendered after the targeted message to 5 - 1 = 4 - // NOTE: we have one drawback here, if there were already a split latest and current message set - // the previous latest message set will be thrown away as we cannot merge it with the current message set after the target message is set - const limitAfter = 4; - const currentLength = currentMessages.length; - const noOfMessagesAfter = currentLength - scrollToMessageIndex - 1; - // number of messages are over the limit, limit the length of messages - if (noOfMessagesAfter > limitAfter) { - const endIndex = scrollToMessageIndex + limitAfter; - channel.state.clearMessages(); - channel.state.messages = currentMessages.slice(0, endIndex + 1); - splitLatestCurrentMessageSetRef.current(); - const restOfMessages = currentMessages.slice(endIndex + 1); - if (hadCurrentLatestMessages) { - const latestSet = channel.state.messageSets.find((set) => set.isLatest); - if (latestSet) { - latestSet.messages = restOfMessages; - hasOverlappingRecentMessagesRef.current = true; - } - } - } - } - const hasLatestMessages = channel.state.latestMessages.length > 0; - channel.state.setIsUpToDate(hasLatestMessages); - setHasNoMoreRecentMessagesToLoad(hasLatestMessages); - copyChannelState(); - if (scrollToMessageIndex !== -1) { - // since we need to scroll after immediately do this without throttle - copyChannelState.flush(); - } - onAfterQueryCall?.(); - } catch (err) { - if (err instanceof Error) { - setError(err); - } else { - setError(true); - } - setLoading(false); - setLastRead(new Date()); - } - }, - ); - - /** - * Loads channel at first unread message. - */ - const loadChannelAtFirstUnreadMessage = () => { - if (!channel) return; - let unreadMessageIdToScrollTo: string | undefined; - // query for messages around the last read date - return channelQueryCallRef.current( - async () => { - const unreadCount = channel.countUnread(); - if (unreadCount === 0) return; - const isLatestMessageSetShown = !!channel.state.messageSets.find( - (set) => set.isCurrent && set.isLatest, - ); - if (isLatestMessageSetShown && unreadCount <= channel.state.messages.length) { - unreadMessageIdToScrollTo = - channel.state.messages[channel.state.messages.length - unreadCount].id; - return; - } - const lastReadDate = channel.lastRead(); - - // if last read date is present we can just fetch messages around that date - // last read date not being present is an edge case if somewhere the user of SDK deletes the read state (this will usually never happen) - if (lastReadDate) { - setLoading(true); - // get totally 30 messages... max 15 before last read date and max 15 after last read date - // ref: https://github.com/GetStream/chat/pull/2588 - const res = await channel.query( - { - messages: { - created_at_around: lastReadDate, - limit: 30, - }, - watch: true, - }, - 'new', - ); - unreadMessageIdToScrollTo = res.messages.find( - (m) => lastReadDate < (m.created_at ? new Date(m.created_at) : new Date()), - )?.id; - if (unreadMessageIdToScrollTo) { - channel.state.loadMessageIntoState(unreadMessageIdToScrollTo); - } - } else { - await loadLatestMessagesRef.current(); - } - }, - () => { - if (unreadMessageIdToScrollTo) { - restartSetsMergeFuncRef.current(); - } - }, - () => unreadMessageIdToScrollTo, - ); - }; - - /** - * Loads channel around a specific message - * - * @param messageId If undefined, channel will be loaded at most recent message. - */ - const loadChannelAroundMessage: ChannelContextValue['loadChannelAroundMessage'] = - async ({ messageId: messageIdToLoadAround }) => { - if (thread) { - if (messageIdToLoadAround) { - setThreadLoadingMore(true); - try { - await channel.state.loadMessageIntoState(messageIdToLoadAround, thread.id); - setThreadLoadingMore(false); - setThreadMessages(channel.state.threads[thread.id]); - setTargetedMessage(messageIdToLoadAround); - } catch (err) { - if (err instanceof Error) { - setError(err); - } else { - setError(true); - } - setThreadLoadingMore(false); - } - } - } else { - await channelQueryCallRef.current( - async () => { - setLoading(true); - if (messageIdToLoadAround) { - setMessages([]); - await channel.state.loadMessageIntoState(messageIdToLoadAround); - const currentMessageSet = channel.state.messageSets.find((set) => set.isCurrent); - if (currentMessageSet && !currentMessageSet?.isLatest) { - // if the current message set is not the latest, we will throw away the latest messages - // in order to attempt to not throw away, will attempt to merge it by loading 25 more messages - const recentCurrentSetMsgId = - currentMessageSet.messages[currentMessageSet.messages.length - 1].id; - await channel.query( - { - messages: { - id_gte: recentCurrentSetMsgId, - limit: 25, - }, - }, - 'current', - ); - // if the gap is more than 25, we will unfortunately have to throw away the latest messages - } - } - }, - () => { - if (messageIdToLoadAround) { - clearInterval(mergeSetsIntervalRef.current); // do not merge sets as we will scroll/highlight to the message - setTargetedMessage(messageIdToLoadAround); - } - }, - messageIdToLoadAround, - ); - } - }; - - useEffect(() => { - if (!targetedMessage && prevTargetedMessage) { - // we cleared the merge sets interval to wait for the targeted message to be set - // now restart it since its done - restartSetsMergeFuncRef.current(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [targetedMessage]); - - /** - * Utility method to mark that current set if latest into two. - * With an empty latest set - * This is useful when we know that we dont know the latest messages anymore - * Or if we are loading a channel around a message - */ - const splitLatestCurrentMessageSetRef = useRef(() => { - const currentLatestSet = channel.state.messageSets.find((set) => set.isCurrent && set.isLatest); - if (!currentLatestSet) return; - // unmark the current latest set - currentLatestSet.isLatest = false; - // create a new set with empty latest messages - channel.state.messageSets.push({ - isCurrent: false, - isLatest: true, - messages: [], - pagination: { - hasNext: true, - hasPrev: true, - }, - }); - }); - - /** - * Utility method to merge current and latest message set. - * Returns true if merge was successful, false otherwise. - */ - const mergeOverlappingMessageSetsRef = useRef((limitToMaxRenderPerBatch = false) => { - if (hasOverlappingRecentMessagesRef.current) { - const limit = 5; // 5 is the load to recent limit, a larger value seems to cause jumpiness in some devices.. - // merge current and latest sets - const latestMessageSet = channel.state.messageSets.find((set) => set.isLatest); - const currentMessageSet = channel.state.messageSets.find((set) => set.isCurrent); - if (latestMessageSet && currentMessageSet && latestMessageSet !== currentMessageSet) { - if (limitToMaxRenderPerBatch && latestMessageSet.messages.length > limit) { - currentMessageSet.messages = currentMessageSet.messages.concat( - latestMessageSet.messages.slice(0, limit), - ); - latestMessageSet.messages = latestMessageSet.messages.slice(limit); - } else { - channel.state.messageSets = channel.state.messageSets.filter((set) => !set.isLatest); - currentMessageSet.messages = currentMessageSet.messages.concat(latestMessageSet.messages); - currentMessageSet.isLatest = true; - hasOverlappingRecentMessagesRef.current = false; - clearInterval(mergeSetsIntervalRef.current); - } - return true; - } - } - return false; - }); - - const mergeSetsIntervalRef = useRef(); - - // clear the interval on unmount - useEffect( - () => () => { - clearInterval(mergeSetsIntervalRef.current); - }, - [], - ); - - // if we had split the latest and current message set, we try to merge them back - // temporarily commented out the interval as it was causing issues with jankiness during scrolling - const restartSetsMergeFuncRef = useRef(() => { - clearInterval(mergeSetsIntervalRef.current); - if (!hasOverlappingRecentMessagesRef.current) return; - // mergeSetsIntervalRef.current = setInterval(() => { - // const currentLength = channel.state.messages.length || 0; - // const didMerge = mergeOverlappingMessageSetsRef.current(true); - // if (didMerge && channel.state.messages.length !== currentLength) { - // setMessages(channel.state.messages); - // } - // }, 1000); - }); - - /** - * Shows the latest messages from the channel state - * If recent messages are empty, fetches new - * @param clearLatest If true, clears the latest messages before loading (useful for complete refresh) - */ - const loadLatestMessagesRef = useRef(async (clearLatest = false) => { - mergeOverlappingMessageSetsRef.current(); - if (clearLatest) { - const latestSet = channel.state.messageSets.find((set) => set.isLatest); - if (latestSet) latestSet.messages = []; - } - if (channel.state.latestMessages.length === 0) { - await channel.query({}, 'latest'); - } - await channel.state.loadMessageIntoState('latest'); - setMessages([...channel.state.messages]); - }); - - const loadChannel = () => - channelQueryCallRef.current( - async () => { - if (!channel?.initialized || !channel.state.isUpToDate) { - await channel?.watch(); - } else { - await channel.state.loadMessageIntoState('latest'); - } - }, - () => { - channel?.state.setIsUpToDate(true); - setHasNoMoreRecentMessagesToLoad(true); - }, - ); - const reloadThread = async () => { if (!channel || !thread?.id) return; setThreadLoadingMore(true); @@ -1256,105 +866,40 @@ const ChannelWithContext = < const resyncChannel = async () => { if (!channel || syncingChannelRef.current) return; - hasOverlappingRecentMessagesRef.current = false; - clearInterval(mergeSetsIntervalRef.current); syncingChannelRef.current = true; - setError(false); - try { - /** - * Allow a buffer of 30 new messages, so that MessageList won't move its scroll position, - * giving smooth user experience. - */ - const state = await channel.watch({ - messages: { - limit: messages.length + 30, - }, - }); - - const oldListTopMessage = messages[0]; - const oldListTopMessageId = messages[0]?.id; - const oldListBottomMessage = messages[messages.length - 1]; - - const newListTopMessage = state.messages[0]; - const newListBottomMessage = state.messages[state.messages.length - 1]; - - if ( - !oldListTopMessage || // previous list was empty - !oldListBottomMessage || // previous list was empty - !newListTopMessage || // new list is truncated - !newListBottomMessage // new list is truncated - ) { - /** Channel was truncated */ - channel.state.clearMessages(); - channel.state.setIsUpToDate(true); - channel.state.addMessagesSorted(state.messages); - channel.state.addPinnedMessages(state.pinned_messages); - - copyChannelState(); - return; - } - - const parseMessage = (message: typeof oldListTopMessage) => - ({ - ...message, - created_at: message.created_at.toString(), - pinned_at: message.pinned_at?.toString(), - updated_at: message.updated_at?.toString(), - } as unknown as MessageResponse); - - const failedMessages = messages - .filter((message) => message.status === MessageStatusTypes.FAILED) - .map(parseMessage); - - const failedThreadMessages = thread - ? threadMessages - .filter((message) => message.status === MessageStatusTypes.FAILED) - .map(parseMessage) - : []; - - const oldListTopMessageCreatedAt = oldListTopMessage.created_at; - const oldListBottomMessageCreatedAt = oldListBottomMessage.created_at; - const newListTopMessageCreatedAt = newListTopMessage.created_at - ? new Date(newListTopMessage.created_at) - : new Date(); - const newListBottomMessageCreatedAt = newListBottomMessage?.created_at - ? new Date(newListBottomMessage.created_at) - : new Date(); - - let finalMessages = []; - - if ( - oldListTopMessage && - oldListTopMessageCreatedAt && - oldListBottomMessageCreatedAt && - newListTopMessageCreatedAt < oldListTopMessageCreatedAt && - newListBottomMessageCreatedAt >= oldListBottomMessageCreatedAt - ) { - const index = state.messages.findIndex((message) => message.id === oldListTopMessageId); - finalMessages = state.messages.slice(index); - } else { - finalMessages = state.messages; - } - channel.state.setIsUpToDate(true); - channel.state.clearMessages(); - channel.state.addMessagesSorted(finalMessages); - channel.state.addPinnedMessages(state.pinned_messages); - setHasNoMoreRecentMessagesToLoad(true); - setHasMore(true); - copyChannelState(); + const parseMessage = (message: FormatMessageResponse) => + ({ + ...message, + created_at: message.created_at.toString(), + pinned_at: message.pinned_at?.toString(), + updated_at: message.updated_at?.toString(), + } as unknown as MessageResponse); - if (failedMessages.length) { - channel.state.addMessagesSorted(failedMessages); + try { + if (!thread) { copyChannelState(); - } - await reloadThread(); + const failedMessages = channelMessagesState.messages + ?.filter((message) => message.status === MessageStatusTypes.FAILED) + .map(parseMessage); - if (thread && failedThreadMessages.length) { - channel.state.addMessagesSorted(failedThreadMessages); - setThreadMessages([...channel.state.threads[thread.id]]); + if (failedMessages?.length) { + channel.state.addMessagesSorted(failedMessages); + } + } else { + await reloadThread(); + + const failedThreadMessages = thread + ? threadMessages + .filter((message) => message.status === MessageStatusTypes.FAILED) + .map(parseMessage) + : []; + if (failedThreadMessages.length) { + channel.state.addMessagesSorted(failedThreadMessages); + setThreadMessages([...channel.state.threads[thread.id]]); + } } } catch (err) { if (err instanceof Error) { @@ -1362,7 +907,6 @@ const ChannelWithContext = < } else { setError(true); } - setLoading(false); } syncingChannelRef.current = false; @@ -1383,7 +927,7 @@ const ChannelWithContext = < if (enableOfflineSupport) { connectionChangedSubscription = DBSyncManager.onSyncStatusChange((statusChanged) => { if (statusChanged) { - connectionChangedHandler(); + copyChannelState(); } }); } else { @@ -1399,19 +943,6 @@ const ChannelWithContext = < // eslint-disable-next-line react-hooks/exhaustive-deps }, [enableOfflineSupport, shouldSyncChannel]); - const reloadChannel = () => - channelQueryCallRef.current( - async () => { - setLoading(true); - await loadLatestMessagesRef.current(true); - setLoading(false); - }, - () => { - channel?.state.setIsUpToDate(true); - setHasNoMoreRecentMessagesToLoad(true); - }, - ); - // In case the channel is disconnected which may happen when channel is deleted, // underlying js client throws an error. Following function ensures that Channel component // won't result in error in such a case. @@ -1430,22 +961,61 @@ const ChannelWithContext = < */ const clientChannelConfig = getChannelConfigSafely(); + const reloadChannel = async () => { + try { + await loadLatestMessages(); + } catch (err) { + console.warn('Reloading channel failed with error:', err); + } + }; + + const loadChannelAroundMessage: ChannelContextValue['loadChannelAroundMessage'] = + async ({ messageId: messageIdToLoadAround }): Promise => { + if (!messageIdToLoadAround) return; + try { + if (thread) { + setThreadLoadingMore(true); + try { + await channel.state.loadMessageIntoState(messageIdToLoadAround, thread.id); + setThreadLoadingMore(false); + setThreadMessages(channel.state.threads[thread.id]); + if (setTargetedMessage) { + setTargetedMessage(messageIdToLoadAround); + } + } catch (err) { + if (err instanceof Error) { + setError(err); + } else { + setError(true); + } + setThreadLoadingMore(false); + } + } else { + await loadChannelAroundMessageFn({ + messageId: messageIdToLoadAround, + setTargetedMessage, + }); + } + } catch (err) { + console.warn('Loading channel around message failed with error:', err); + } + }; + /** * MESSAGE METHODS */ - const updateMessage: MessagesContextValue['updateMessage'] = ( updatedMessage, extraState = {}, ) => { - if (channel) { - channel.state.addMessageSorted(updatedMessage, true); - if (thread && updatedMessage.parent_id) { - extraState.threadMessages = channel.state.threads[updatedMessage.parent_id] || []; - setThreadMessages(extraState.threadMessages); - } + if (!channel) return; + + channel.state.addMessageSorted(updatedMessage, true); + copyMessagesStateFromChannel(channel); - setMessages([...channel.state.messages]); + if (thread && updatedMessage.parent_id) { + extraState.threadMessages = channel.state.threads[updatedMessage.parent_id] || []; + setThreadMessages(extraState.threadMessages); } }; @@ -1456,11 +1026,12 @@ const ChannelWithContext = < if (channel) { channel.state.removeMessage(oldMessage); channel.state.addMessageSorted(newMessage, true); + copyMessagesStateFromChannel(channel); + if (thread && newMessage.parent_id) { const threadMessages = channel.state.threads[newMessage.parent_id] || []; setThreadMessages(threadMessages); } - setMessages(channel.state.messages); } }; @@ -1510,7 +1081,9 @@ const ChannelWithContext = < * as quoted_message is a reserved field. */ if (preview.quoted_message_id) { - const quotedMessage = messages.find((message) => message.id === preview.quoted_message_id); + const quotedMessage = channelMessagesState.messages?.find( + (message) => message.id === preview.quoted_message_id, + ); preview.quoted_message = quotedMessage as MessageResponse['quoted_message']; @@ -1679,8 +1252,6 @@ const ChannelWithContext = < attachments: message.attachments || [], }); - mergeOverlappingMessageSetsRef.current(); - updateMessage(messagePreview, { commands: [], messageInput: '', @@ -1721,161 +1292,6 @@ const ChannelWithContext = < ); }; - // hard limit to prevent you from scrolling faster than 1 page per 2 seconds - const loadMoreFinished = useRef( - debounce( - (updatedHasMore: boolean, newMessages: ChannelState['messages']) => { - setLoading(false); - setLoadingMore(false); - setError(false); - setHasMore(updatedHasMore); - setMessages(newMessages); - }, - defaultDebounceInterval, - debounceOptions, - ), - ).current; - - /** - * This function loads more messages before the first message in current channel state. - */ - const loadMore = useCallback['loadMore']>( - async (limit = 20) => { - if (loadingMore || hasMore === false) { - return; - } - - const currentMessages = channel.state.messages; - - if (!currentMessages.length) { - return setLoadingMore(false); - } - - const oldestMessage = currentMessages && currentMessages[0]; - - if (oldestMessage && oldestMessage.status !== MessageStatusTypes.RECEIVED) { - return setLoadingMore(false); - } - - setLoadingMore(true); - - const oldestID = oldestMessage && oldestMessage.id; - - try { - if (channel) { - const queryResponse = await channel.query({ - messages: { id_lt: oldestID, limit }, - }); - - const updatedHasMore = queryResponse.messages.length === limit; - loadMoreFinished(updatedHasMore, channel.state.messages); - } - } catch (err) { - if (err instanceof Error) { - setError(err); - } else { - setError(true); - } - setLoadingMore(false); - throw err; - } - }, - /* - * This function is passed to useCreatePaginatedMessageListContext - * Where the deps are [channelId, hasMore, loadingMoreRecent, loadingMore] - * and only those deps should be used here because of that - */ - // eslint-disable-next-line react-hooks/exhaustive-deps - [channelId, hasMore, loadingMore], - ); - - /** - * This function loads more messages after the most recent message in current channel state. - */ - const loadMoreRecent = useCallback< - PaginatedMessageListContextValue['loadMoreRecent'] - >( - async (limit = 5) => { - const latestMessageSet = channel.state.messageSets.find((set) => set.isLatest); - const latestLengthBeforeMerge = latestMessageSet?.messages.length || 0; - const didMerge = mergeOverlappingMessageSetsRef.current(true); - if (didMerge) { - if (latestMessageSet && latestLengthBeforeMerge > 0) { - const shouldSetStateUpToDate = - latestMessageSet.messages.length < limit && latestMessageSet.isCurrent; - setLoadingMoreRecent(true); - channel.state.setIsUpToDate(shouldSetStateUpToDate); - setHasNoMoreRecentMessagesToLoad(shouldSetStateUpToDate); - loadMoreRecentFinished(channel.state.messages); - restartSetsMergeFuncRef.current(); - return; - } - } - if (channel.state.isUpToDate) { - setLoadingMoreRecent(false); - return; - } - const currentMessages = channel.state.messages; - const recentMessage = currentMessages[currentMessages.length - 1]; - - if (recentMessage?.status !== MessageStatusTypes.RECEIVED) { - setLoadingMoreRecent(false); - return; - } - setLoadingMoreRecent(true); - try { - if (channel) { - const queryResponse = await channel.query({ - messages: { - id_gte: recentMessage.id, - limit, - }, - watch: true, - }); - const gotAllRecentMessages = queryResponse.messages.length < limit; - const currentSet = channel.state.messageSets.find((set) => set.isCurrent); - if (gotAllRecentMessages && currentSet && !currentSet.isLatest) { - channel.state.messageSets = channel.state.messageSets.filter((set) => !set.isLatest); - // make current set as the latest - currentSet.isLatest = true; - } - channel.state.setIsUpToDate(gotAllRecentMessages); - setHasNoMoreRecentMessagesToLoad(gotAllRecentMessages); - loadMoreRecentFinished(channel.state.messages); - } - } catch (err) { - console.warn('Message pagination request failed with error', err); - if (err instanceof Error) { - setError(err); - } else { - setError(true); - } - setLoadingMoreRecent(false); - throw err; - } - }, - /* - * This function is passed to useCreatePaginatedMessageListContext - * Where the deps are [channelId, hasMore, loadingMoreRecent, loadingMore, hasNoMoreRecentMessagesToLoad] - * and and only those deps should be used here because of that - */ - // eslint-disable-next-line react-hooks/exhaustive-deps - [channelId, hasNoMoreRecentMessagesToLoad], - ); - - // hard limit to prevent you from scrolling faster than 1 page per 2 seconds - const loadMoreRecentFinished = useRef( - debounce( - (newMessages: ChannelState['messages']) => { - setLoadingMoreRecent(false); - setMessages(newMessages); - setError(false); - }, - defaultDebounceInterval, - debounceOptions, - ), - ).current; - const editMessage: InputMessageInputContextValue['editMessage'] = ( updatedMessage, ) => @@ -1910,7 +1326,8 @@ const ChannelWithContext = < ) => { if (channel) { channel.state.removeMessage(message); - setMessages(channel.state.messages); + copyMessagesStateFromChannel(channel); + if (thread) { setThreadMessages(channel.state.threads[thread.id] || []); } @@ -1949,7 +1366,7 @@ const ChannelWithContext = < user: client.user, }); - setMessages(channel.state.messages); + copyMessagesStateFromChannel(channel); const sendReactionResponse = await DBSyncManager.queueTask({ client, @@ -2035,7 +1452,7 @@ const ChannelWithContext = < user: client.user, }); - setMessages(channel.state.messages); + copyMessagesStateFromChannel(channel); await DBSyncManager.queueTask({ client, @@ -2155,13 +1572,13 @@ const ChannelWithContext = < isChannelActive: shouldSyncChannel, lastRead, loadChannelAroundMessage, - loading, + loading: channelMessagesState.loading, LoadingIndicator, markRead, maxTimeBetweenGroupedMessages, - members, + members: channelState.members ?? {}, NetworkDownIndicator, - read, + read: channelState.read ?? {}, reloadChannel, scrollToFirstUnreadThreshold, setLastRead, @@ -2170,8 +1587,8 @@ const ChannelWithContext = < targetedMessage, threadList, uploadAbortControllerRef, - watcherCount, - watchers, + watcherCount: channelState.watcherCount, + watchers: channelState.watchers, }); // This is mainly a hack to get around an issue with sendMessage not being passed correctly as a @@ -2248,16 +1665,16 @@ const ChannelWithContext = < const messageListContext = useCreatePaginatedMessageListContext({ channelId, - hasMore, - hasNoMoreRecentMessagesToLoad, - loadingMore: loadingMoreProp !== undefined ? loadingMoreProp : loadingMore, + hasMore: channelMessagesState.hasMore, + loadingMore: loadingMoreProp !== undefined ? loadingMoreProp : channelMessagesState.loadingMore, loadingMoreRecent: - loadingMoreRecentProp !== undefined ? loadingMoreRecentProp : loadingMoreRecent, + loadingMoreRecentProp !== undefined + ? loadingMoreRecentProp + : channelMessagesState.loadingMoreRecent, + loadLatestMessages, loadMore, loadMoreRecent, - messages, - setLoadingMore, - setLoadingMoreRecent, + messages: channelMessagesState.messages ?? [], }); const messagesContext = useCreateMessagesContext({ @@ -2382,13 +1799,10 @@ const ChannelWithContext = < }); const typingContext = useCreateTypingContext({ - typing, + typing: channelState.typing ?? {}, }); - // TODO: replace the null view with appropriate message. Currently this is waiting a design decision. - if (deleted) return null; - - if (!channel || (error && messages.length === 0)) { + if (!channel || (error && channelMessagesState.messages?.length === 0)) { return ; } @@ -2464,22 +1878,7 @@ export const Channel = < const shouldSyncChannel = threadMessage?.id ? !!props.threadList : true; - const { - members, - messages, - read, - setMembers, - setMessages, - setRead, - setThreadMessages, - setTyping, - setWatcherCount, - setWatchers, - threadMessages, - typing, - watcherCount, - watchers, - } = useChannelState( + const { setThreadMessages, threadMessages } = useChannelState( props.channel, props.threadList ? threadMessage?.id : undefined, ); @@ -2494,21 +1893,9 @@ export const Channel = < {...props} shouldSyncChannel={shouldSyncChannel} {...{ - members, - messages: props.messages || messages, - read, - setMembers, - setMessages, - setRead, setThreadMessages, - setTyping, - setWatcherCount, - setWatchers, thread, threadMessages, - typing, - watcherCount, - watchers, }} /> ); diff --git a/package/src/components/Channel/__tests__/Channel.test.js b/package/src/components/Channel/__tests__/Channel.test.js index 6c5a55fd45..bc72c3c48d 100644 --- a/package/src/components/Channel/__tests__/Channel.test.js +++ b/package/src/components/Channel/__tests__/Channel.test.js @@ -203,17 +203,6 @@ describe('Channel', () => { await waitFor(() => expect(channelQuerySpy).toHaveBeenCalled()); }); - it('should render null if channel gets deleted', async () => { - const { getByTestId, queryByTestId } = renderComponent({ - channel, - children: , - }); - - await waitFor(() => expect(getByTestId('children')).toBeTruthy()); - act(() => dispatchChannelDeletedEvent(chatClient, channel)); - expect(queryByTestId('children')).toBeNull(); - }); - describe('ChannelContext', () => { it('renders children without crashing', async () => { const { getByTestId } = render( diff --git a/package/src/components/Channel/hooks/useChannelDataState.ts b/package/src/components/Channel/hooks/useChannelDataState.ts new file mode 100644 index 0000000000..b24ab84e2a --- /dev/null +++ b/package/src/components/Channel/hooks/useChannelDataState.ts @@ -0,0 +1,215 @@ +import { useCallback, useState } from 'react'; + +import { Channel, ChannelState as StreamChannelState } from 'stream-chat'; + +import { DefaultStreamChatGenerics } from '../../../types/types'; +import { MessageType } from '../../MessageList/hooks/useMessageList'; + +/** + * The ChannelMessagesState object + */ +export type ChannelMessagesState< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = { + hasMore?: boolean; + hasMoreNewer?: boolean; + loading?: boolean; + loadingMore?: boolean; + loadingMoreRecent?: boolean; + messages?: StreamChannelState['messages']; + pinnedMessages?: StreamChannelState['pinnedMessages']; + targetedMessageId?: string; +}; + +/** + * The ChannelThreadState object + */ +export type ChannelThreadState< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = { + thread: MessageType | null; + threadHasMore?: boolean; + threadLoadingMore?: boolean; + threadMessages?: StreamChannelState['messages']; +}; + +/** + * The ChannelState object + */ +export type ChannelState< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = ChannelMessagesState & { + members?: StreamChannelState['members']; + read?: StreamChannelState['read']; + typing?: StreamChannelState['typing']; + watcherCount?: number; + watchers?: StreamChannelState['watchers']; +}; + +/** + * The useChannelMessageDataState hook that handles the state for the channel messages. + */ +export const useChannelMessageDataState = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>() => { + const [state, setState] = useState>({ + hasMore: true, + hasMoreNewer: false, + loading: true, + loadingMore: false, + loadingMoreRecent: false, + messages: [], + pinnedMessages: [], + targetedMessageId: undefined, + }); + + const copyMessagesStateFromChannel = useCallback((channel: Channel) => { + setState((prev) => ({ + ...prev, + messages: [...channel.state.messages], + pinnedMessages: [...channel.state.pinnedMessages], + })); + }, []); + + const loadInitialMessagesStateFromChannel = useCallback( + (channel: Channel, hasMore: boolean) => { + setState({ + ...state, + hasMore, + loading: false, + messages: [...channel.state.messages], + pinnedMessages: [...channel.state.pinnedMessages], + }); + }, + [state], + ); + + const jumpToLatestMessage = useCallback(() => { + setState((prev) => ({ + ...prev, + hasMoreNewer: false, + loading: false, + targetedMessageId: undefined, + })); + }, []); + + const jumpToMessageFinished = useCallback((hasMoreNewer: boolean, targetedMessageId: string) => { + setState((prev) => ({ + ...prev, + hasMoreNewer, + loading: false, + targetedMessageId, + })); + }, []); + + const loadMoreFinished = useCallback( + (hasMore: boolean, messages: ChannelState['messages']) => { + setState((prev) => ({ + ...prev, + hasMore, + loadingMore: false, + messages, + })); + }, + [], + ); + + const setLoadingMore = useCallback((loadingMore: boolean) => { + setState((prev) => ({ + ...prev, + loadingMore, + })); + }, []); + + const setLoadingMoreRecent = useCallback((loadingMoreRecent: boolean) => { + setState((prev) => ({ + ...prev, + loadingMoreRecent, + })); + }, []); + + const setLoading = useCallback((loading: boolean) => { + setState((prev) => ({ + ...prev, + loading, + })); + }, []); + + const loadMoreRecentFinished = useCallback( + (hasMoreNewer: boolean, messages: ChannelState['messages']) => { + setState((prev) => ({ + ...prev, + hasMoreNewer, + loadingMoreRecent: false, + messages, + })); + }, + [], + ); + + return { + copyMessagesStateFromChannel, + jumpToLatestMessage, + jumpToMessageFinished, + loadInitialMessagesStateFromChannel, + loadMoreFinished, + loadMoreRecentFinished, + setLoading, + setLoadingMore, + setLoadingMoreRecent, + state, + }; +}; + +/** + * The useChannelThreadState hook that handles the state for the channel member, read, typing, watchers, etc. + */ +export const useChannelDataState = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>() => { + const [state, setState] = useState>({ + members: {}, + read: {}, + typing: {}, + watcherCount: 0, + watchers: {}, + }); + + const initStateFromChannel = useCallback( + (channel: Channel) => { + setState({ + ...state, + members: { ...channel.state.members }, + read: { ...channel.state.read }, + typing: { ...channel.state.typing }, + watcherCount: channel.state.watcher_count, + watchers: { ...channel.state.watchers }, + }); + }, + [state], + ); + + const copyStateFromChannel = useCallback((channel: Channel) => { + setState((prev) => ({ + ...prev, + members: { ...channel.state.members }, + read: { ...channel.state.read }, + watcherCount: channel.state.watcher_count, + watchers: { ...channel.state.watchers }, + })); + }, []); + + const setTyping = useCallback((channel: Channel) => { + setState((prev) => ({ + ...prev, + typing: { ...channel.state.typing }, // Synchronize the typing state from the channel + })); + }, []); + + return { + copyStateFromChannel, + initStateFromChannel, + setTyping, + state, + }; +}; diff --git a/package/src/components/Channel/hooks/useCreatePaginatedMessageListContext.ts b/package/src/components/Channel/hooks/useCreatePaginatedMessageListContext.ts index 82795282a4..1e8e46a613 100644 --- a/package/src/components/Channel/hooks/useCreatePaginatedMessageListContext.ts +++ b/package/src/components/Channel/hooks/useCreatePaginatedMessageListContext.ts @@ -9,9 +9,9 @@ export const useCreatePaginatedMessageListContext = < >({ channelId, hasMore, - hasNoMoreRecentMessagesToLoad, loadingMore, loadingMoreRecent, + loadLatestMessages, loadMore, loadMoreRecent, messages, @@ -25,9 +25,9 @@ export const useCreatePaginatedMessageListContext = < const paginatedMessagesContext: PaginatedMessageListContextValue = useMemo( () => ({ hasMore, - hasNoMoreRecentMessagesToLoad, loadingMore, loadingMoreRecent, + loadLatestMessages, loadMore, loadMoreRecent, messages, @@ -35,14 +35,7 @@ export const useCreatePaginatedMessageListContext = < setLoadingMoreRecent, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [ - channelId, - hasMore, - loadingMoreRecent, - loadingMore, - hasNoMoreRecentMessagesToLoad, - messagesStr, - ], + [channelId, hasMore, loadingMore, loadingMoreRecent, messagesStr], ); return paginatedMessagesContext; diff --git a/package/src/components/Channel/hooks/useMessageListPagination.tsx b/package/src/components/Channel/hooks/useMessageListPagination.tsx new file mode 100644 index 0000000000..3e01465978 --- /dev/null +++ b/package/src/components/Channel/hooks/useMessageListPagination.tsx @@ -0,0 +1,245 @@ +import { useRef } from 'react'; + +import debounce from 'lodash/debounce'; +import { Channel, ChannelState } from 'stream-chat'; + +import { useChannelMessageDataState } from './useChannelDataState'; + +import { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; +import { DefaultStreamChatGenerics } from '../../../types/types'; + +const defaultDebounceInterval = 500; +const debounceOptions = { + leading: true, + trailing: true, +}; + +/** + * The useMessageListPagination hook handles pagination for the message list. + * It provides functionality to load more messages, load more recent messages, load latest messages, and load channel around a specific message. + * + * @param channel The channel object for which the message list pagination is being handled. + */ +export const useMessageListPagination = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>({ + channel, +}: { + channel: Channel; +}) => { + const { + copyMessagesStateFromChannel, + jumpToLatestMessage, + jumpToMessageFinished, + loadInitialMessagesStateFromChannel, + loadMoreFinished: loadMoreFinishedFn, + loadMoreRecentFinished: loadMoreRecentFinishedFn, + setLoading, + setLoadingMore, + setLoadingMoreRecent, + state, + } = useChannelMessageDataState(); + + // hard limit to prevent you from scrolling faster than 1 page per 2 seconds + const loadMoreFinished = useRef( + debounce( + (hasMore: boolean, messages: ChannelState['messages']) => { + loadMoreFinishedFn(hasMore, messages); + }, + defaultDebounceInterval, + debounceOptions, + ), + ).current; + + // hard limit to prevent you from scrolling faster than 1 page per 2 seconds + const loadMoreRecentFinished = useRef( + debounce( + (hasMore: boolean, newMessages: ChannelState['messages']) => { + loadMoreRecentFinishedFn(hasMore, newMessages); + }, + defaultDebounceInterval, + debounceOptions, + ), + ).current; + + /** + * This function loads the latest messages in the channel. + */ + const loadLatestMessages = async () => { + try { + setLoading(true); + await channel.state.loadMessageIntoState('latest'); + loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); + jumpToLatestMessage(); + } catch (err) { + console.warn('Loading latest messages failed with error:', err); + } + }; + + /** + * This function loads more messages before the first message in current channel state. + */ + const loadMore = async (limit = 20) => { + if (!channel.state.messagePagination.hasPrev) { + return; + } + + if (state.loadingMore || state.loadingMoreRecent) { + return; + } + + setLoadingMore(true); + const oldestMessage = state.messages?.[0]; + const oldestID = oldestMessage?.id; + + try { + await channel.query({ + messages: { id_lt: oldestID, limit }, + watchers: { limit }, + }); + loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); + setLoadingMore(false); + } catch (e) { + setLoadingMore(false); + console.warn('Message pagination(fetching old messages) request failed with error:', e); + } + }; + + /** + * This function loads more messages after the most recent message in current channel state. + */ + const loadMoreRecent = async (limit = 10) => { + if (!channel.state.messagePagination.hasNext) { + return; + } + + if (state.loadingMore || state.loadingMoreRecent) { + return; + } + + setLoadingMoreRecent(true); + const newestMessage = state.messages?.[state?.messages.length - 1]; + const newestID = newestMessage?.id; + + try { + await channel.query({ + messages: { id_gt: newestID, limit }, + watchers: { limit }, + }); + loadMoreRecentFinished(channel.state.messagePagination.hasNext, channel.state.messages); + } catch (e) { + setLoadingMoreRecent(false); + console.warn('Message pagination(fetching new messages) request failed with error:', e); + return; + } + }; + + /** + * Loads channel around a specific message + * + * @param messageId If undefined, channel will be loaded at most recent message. + */ + const loadChannelAroundMessage: ChannelContextValue['loadChannelAroundMessage'] = + async ({ limit = 25, messageId: messageIdToLoadAround, setTargetedMessage }) => { + if (!messageIdToLoadAround) return; + setLoadingMore(true); + setLoading(true); + try { + await channel.state.loadMessageIntoState(messageIdToLoadAround, undefined, limit); + loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); + jumpToMessageFinished(channel.state.messagePagination.hasNext, messageIdToLoadAround); + if (setTargetedMessage) { + setTargetedMessage(messageIdToLoadAround); + } + } catch (error) { + console.warn( + 'Message pagination(fetching messages in the channel around a message id) request failed with error:', + error, + ); + return; + } + }; + + /** + * Loads channel at first unread message. + */ + const loadChannelAtFirstUnreadMessage = async ({ + limit = 25, + setTargetedMessage, + }: { + limit?: number; + setTargetedMessage?: (messageId: string) => void; + }) => { + if (!channel) return; + let unreadMessageIdToScrollTo: string | undefined; + const unreadCount = channel.countUnread(); + if (unreadCount === 0) return; + const isLatestMessageSetShown = !!channel.state.messageSets.find( + (set) => set.isCurrent && set.isLatest, + ); + if (isLatestMessageSetShown && unreadCount <= channel.state.messages.length) { + unreadMessageIdToScrollTo = + channel.state.messages[channel.state.messages.length - unreadCount].id; + if (unreadMessageIdToScrollTo) { + setLoadingMore(true); + await channel.state.loadMessageIntoState(unreadMessageIdToScrollTo, undefined, limit); + loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); + jumpToMessageFinished(channel.state.messagePagination.hasNext, unreadMessageIdToScrollTo); + if (setTargetedMessage) { + setTargetedMessage(unreadMessageIdToScrollTo); + } + } + return; + } + const lastReadDate = channel.lastRead(); + let messages; + if (lastReadDate) { + try { + messages = ( + await channel.query( + { + messages: { + created_at_around: lastReadDate, + limit: 30, + }, + watch: true, + }, + 'new', + ) + ).messages; + + unreadMessageIdToScrollTo = messages.find( + (m) => lastReadDate < (m.created_at ? new Date(m.created_at) : new Date()), + )?.id; + if (unreadMessageIdToScrollTo) { + setLoadingMore(true); + await channel.state.loadMessageIntoState(unreadMessageIdToScrollTo, undefined, limit); + loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); + jumpToMessageFinished(channel.state.messagePagination.hasNext, unreadMessageIdToScrollTo); + if (setTargetedMessage) { + setTargetedMessage(unreadMessageIdToScrollTo); + } + } + } catch (error) { + console.warn( + 'Message pagination(fetching messages in the channel around unread message) request failed with error:', + error, + ); + return; + } + } else { + await loadLatestMessages(); + } + }; + + return { + copyMessagesStateFromChannel, + loadChannelAroundMessage, + loadChannelAtFirstUnreadMessage, + loadInitialMessagesStateFromChannel, + loadLatestMessages, + loadMore, + loadMoreRecent, + state, + }; +}; diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index e2c0ecde58..0f1d466b76 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -126,10 +126,7 @@ type MessageListPropsWithContext< > & Pick, 'client'> & Pick, 'setMessages'> & - Pick< - PaginatedMessageListContextValue, - 'hasNoMoreRecentMessagesToLoad' | 'loadMore' | 'loadMoreRecent' - > & + Pick, 'loadMore' | 'loadMoreRecent'> & Pick & Pick< MessagesContextValue, @@ -239,7 +236,6 @@ const MessageListWithContext = < EmptyStateIndicator, FlatList, FooterComponent = InlineLoadingMoreIndicator, - hasNoMoreRecentMessagesToLoad, HeaderComponent = LoadingMoreRecentIndicator, hideStickyDateHeader, initialScrollToFirstUnreadMessage, @@ -355,11 +351,6 @@ const MessageListWithContext = < */ const initialScrollSettingTimeoutRef = useRef>(); - /** - * The timeout id used to temporarily load the initial scroll set flag - */ - const onScrollEventTimeoutRef = useRef>(); - /** * Last messageID that was scrolled to after loading a new message list, * this flag keeps track of it so that we dont scroll to it again on target message set @@ -440,6 +431,9 @@ const MessageListWithContext = < } }, [disabled]); + /** + * Effect to mark the channel as read when the user scrolls to the bottom of the message list. + */ useEffect(() => { const getShouldMarkReadAutomatically = (): boolean => { if (loading || !channel) { @@ -519,34 +513,16 @@ const MessageListWithContext = < } }; - if (threadList || hasNoMoreRecentMessagesToLoad) { + if (threadList) { scrollToBottomIfNeeded(); } else { setScrollToBottomButtonVisible(false); } - if ( - !hasNoMoreRecentMessagesToLoad && - flatListRef.current && - messageListLengthBeforeUpdate.current === 0 && - messageListLengthAfterUpdate < 10 - ) { - /** - * Trigger onStartReached on first load, if messages are not enough to fill the screen. - * This is important especially for android, where you can't overscroll. - */ - maybeCallOnStartReached(10); - } - messageListLengthBeforeUpdate.current = messageListLengthAfterUpdate; topMessageBeforeUpdate.current = topMessageAfterUpdate; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - threadList, - hasNoMoreRecentMessagesToLoad, - messageListLengthAfterUpdate, - topMessageAfterUpdate?.id, - ]); + }, [threadList, messageListLengthAfterUpdate, topMessageAfterUpdate?.id]); useEffect(() => { if (!rawMessageList.length) return; @@ -670,9 +646,19 @@ const MessageListWithContext = < threadList={threadList} /> ); - return wrapMessageInTheme ? ( + return ( <> - + {wrapMessageInTheme ? ( + + + {shouldApplyAndroidWorkaround && renderDateSeperator} + {renderMessage} + + + ) : ( - + )} {!shouldApplyAndroidWorkaround && renderDateSeperator} {/* Adding indicator below the messages, since the list is inverted */} - {insertInlineUnreadIndicator && } - - ) : ( - <> - - {shouldApplyAndroidWorkaround && renderDateSeperator} - {renderMessage} + + {insertInlineUnreadIndicator && } - {!shouldApplyAndroidWorkaround && renderDateSeperator} - {/* Adding indicator below the messages, since the list is inverted */} - {insertInlineUnreadIndicator && } ); }; @@ -724,7 +699,7 @@ const MessageListWithContext = < * 2. Ensures that we call `loadMoreRecent`, once per content length * 3. If the call to `loadMore` is in progress, we wait for it to finish to make sure scroll doesn't jump. */ - const maybeCallOnStartReached = async (limit?: number) => { + const maybeCallOnStartReached = async () => { // If onStartReached has already been called for given data length, then ignore. if ( processedMessageList?.length && @@ -756,8 +731,8 @@ const MessageListWithContext = < } onStartReachedInPromise.current = ( threadList && !!threadInstance && loadMoreRecentThread - ? loadMoreRecentThread({ limit }) - : loadMoreRecent(limit) + ? loadMoreRecentThread({}) + : loadMoreRecent() ) .then(callback) .catch(onError); @@ -799,29 +774,6 @@ const MessageListWithContext = < .catch(onError); }; - const onUserScrollEvent: NonNullable = (event) => { - const nativeEvent = event.nativeEvent; - clearTimeout(onScrollEventTimeoutRef.current); - const offset = nativeEvent.contentOffset.y; - const visibleLength = nativeEvent.layoutMeasurement.height; - const contentLength = nativeEvent.contentSize.height; - if (!channel || !channelResyncScrollSet.current) { - return; - } - - // Check if scroll has reached either start of end of list. - const isScrollAtStart = offset < 100; - const isScrollAtEnd = contentLength - visibleLength - offset < 100; - - if (isScrollAtStart) { - maybeCallOnStartReached(); - } - - if (isScrollAtEnd) { - maybeCallOnEndReached(); - } - }; - const handleScroll: ScrollViewProps['onScroll'] = (event) => { const offset = event.nativeEvent.contentOffset.y; const messageListHasMessages = processedMessageList.length > 0; @@ -831,8 +783,7 @@ const MessageListWithContext = < const notLatestSet = channel.state.messages !== channel.state.latestMessages; const showScrollToBottomButton = - messageListHasMessages && - ((!threadList && notLatestSet) || !isScrollAtBottom || !hasNoMoreRecentMessagesToLoad); + messageListHasMessages && ((!threadList && notLatestSet) || !isScrollAtBottom); /** * 1. If I scroll up -> show scrollToBottom button. @@ -842,12 +793,7 @@ const MessageListWithContext = < */ setScrollToBottomButtonVisible(showScrollToBottomButton); - const shouldMarkRead = - !threadList && - !notLatestSet && - offset <= 0 && - hasNoMoreRecentMessagesToLoad && - channel.countUnread() > 0; + const shouldMarkRead = !threadList && !notLatestSet && offset <= 0 && channel.countUnread() > 0; if (shouldMarkRead) { markRead(); @@ -862,9 +808,10 @@ const MessageListWithContext = < const goToNewMessages = async () => { const isNotLatestSet = channel.state.messages !== channel.state.latestMessages; + if (isNotLatestSet) { - resetPaginationTrackersRef.current(); await reloadChannel(); + resetPaginationTrackersRef.current(); } else if (flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, @@ -925,7 +872,7 @@ const MessageListWithContext = < // this onScrollToIndexFailed will be called again }); - const goToMessage = (messageId: string) => { + const goToMessage = async (messageId: string) => { const indexOfParentInMessageList = processedMessageList.findIndex( (message) => message?.id === messageId, ); @@ -944,7 +891,7 @@ const MessageListWithContext = < return; } // the message we want was not loaded yet, so lets load it - loadChannelAroundMessage({ messageId }); + await loadChannelAroundMessage({ messageId }); }; /** @@ -985,6 +932,7 @@ const MessageListWithContext = < viewPosition: 0.5, // try to place message in the center of the screen }); } + // the message we want to scroll to has not been loaded in the state yet if (indexOfParentInMessageList === -1) { loadChannelAroundMessage({ messageId: messageIdToScroll }); @@ -1047,19 +995,18 @@ const MessageListWithContext = < ]); const dismissImagePicker = () => { - if (!hasMoved && selectedPicker) { + if (selectedPicker) { setSelectedPicker(undefined); closePicker(); } }; - const onScrollBeginDrag: ScrollViewProps['onScrollBeginDrag'] = (event) => { + + const onScrollBeginDrag: ScrollViewProps['onScrollBeginDrag'] = () => { !hasMoved && selectedPicker && setHasMoved(true); - onUserScrollEvent(event); }; - const onScrollEndDrag: ScrollViewProps['onScrollEndDrag'] = (event) => { + const onScrollEndDrag: ScrollViewProps['onScrollEndDrag'] = () => { hasMoved && selectedPicker && setHasMoved(false); - onUserScrollEvent(event); }; const refCallback = (ref: FlatListType>) => { @@ -1155,28 +1102,29 @@ const MessageListWithContext = < ]} /** Disables the MessageList UI. Which means, message actions, reactions won't work. */ data={processedMessageList} - extraData={disabled || !hasNoMoreRecentMessagesToLoad} + extraData={disabled} inverted={shouldApplyAndroidWorkaround ? false : inverted} ItemSeparatorComponent={WrappedItemSeparatorComponent} keyboardShouldPersistTaps='handled' keyExtractor={keyExtractor} ListFooterComponent={ListFooterComponent} /** - if autoscrollToTopThreshold is 10, we scroll to recent if before new list update it was already at the bottom (10 offset or below) - minIndexForVisible = 1 means that beyond item at index 1 will not change position on list updates - minIndexForVisible is not used when autoscrollToTopThreshold = 10 - */ + if autoscrollToTopThreshold is 10, we scroll to recent if before new list update it was already at the bottom (10 offset or below) + minIndexForVisible = 1 means that beyond item at index 1 will not change position on list updates + minIndexForVisible is not used when autoscrollToTopThreshold = 10 + */ ListHeaderComponent={ListHeaderComponent} maintainVisibleContentPosition={{ autoscrollToTopThreshold: autoscrollToRecent ? 10 : undefined, minIndexForVisible: 1, }} maxToRenderPerBatch={30} - onMomentumScrollEnd={onUserScrollEvent} + onEndReached={maybeCallOnEndReached} onScroll={handleScroll} onScrollBeginDrag={onScrollBeginDrag} onScrollEndDrag={onScrollEndDrag} onScrollToIndexFailed={onScrollToIndexFailedRef.current} + onStartReached={maybeCallOnStartReached} onTouchEnd={dismissImagePicker} onViewableItemsChanged={onViewableItemsChanged.current} ref={refCallback} @@ -1267,8 +1215,7 @@ export const MessageList = < TypingIndicator, TypingIndicatorContainer, } = useMessagesContext(); - const { hasNoMoreRecentMessagesToLoad, loadMore, loadMoreRecent } = - usePaginatedMessageListContext(); + const { loadMore, loadMoreRecent } = usePaginatedMessageListContext(); const { overlay } = useOverlayContext(); const { loadMoreRecentThread, loadMoreThread, thread, threadInstance } = useThreadContext(); @@ -1286,7 +1233,6 @@ export const MessageList = < enableMessageGroupingByUser, error, FlatList, - hasNoMoreRecentMessagesToLoad, hideStickyDateHeader, initialScrollToFirstUnreadMessage, InlineDateSeparator, diff --git a/package/src/components/MessageList/utils/getReadStates.ts b/package/src/components/MessageList/utils/getReadStates.ts index f4aa4a1e24..ee2f8708a8 100644 --- a/package/src/components/MessageList/utils/getReadStates.ts +++ b/package/src/components/MessageList/utils/getReadStates.ts @@ -1,4 +1,5 @@ import { ChannelState } from 'stream-chat'; + import type { PaginatedMessageListContextValue } from '../../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadContext'; import type { DefaultStreamChatGenerics } from '../../../types/types'; 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 6b13824ab7..c2f5ded018 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -160,6 +160,7 @@ exports[`Thread should match thread snapshot 1`] = ` } maxToRenderPerBatch={30} onContentSizeChange={[Function]} + onEndReached={[Function]} onLayout={[Function]} onMomentumScrollBegin={[Function]} onMomentumScrollEnd={[Function]} @@ -167,6 +168,7 @@ exports[`Thread should match thread snapshot 1`] = ` onScrollBeginDrag={[Function]} onScrollEndDrag={[Function]} onScrollToIndexFailed={[Function]} + onStartReached={[Function]} onTouchEnd={[Function]} onViewableItemsChanged={[Function]} removeClippedSubviews={false} @@ -575,6 +577,13 @@ exports[`Thread should match thread snapshot 1`] = ` ] } /> + + + Promise; + loadChannelAroundMessage: ({ + limit, + messageId, + setTargetedMessage, + }: { + limit?: number; + messageId?: string; + setTargetedMessage?: (messageId: string) => void; + }) => Promise; - loading: boolean; /** * Custom loading indicator to override the Stream default */ @@ -113,6 +120,31 @@ export type ChannelContextValue< * Its a map of filename and AbortController */ uploadAbortControllerRef: React.MutableRefObject>; + disabled?: boolean; + enableMessageGroupingByUser?: boolean; + isChannelActive?: boolean; + lastRead?: Date; + + loading?: boolean; + /** + * Maximum time in milliseconds that should occur between messages + * to still consider them grouped together + */ + maxTimeBetweenGroupedMessages?: number; + /** + * Custom UI component for sticky header of channel. + * + * **Default** [DateHeader](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageList/DateHeader.tsx) + */ + StickyHeader?: React.ComponentType; + + /** + * Id of message, around which Channel/MessageList gets loaded when opened. + * You will see a highlighted background for targetted message, when opened. + */ + targetedMessage?: string; + threadList?: boolean; + watcherCount?: ChannelState['watcher_count']; /** * * ```json @@ -136,29 +168,7 @@ export type ChannelContextValue< * } * ``` */ - watchers: ChannelState['watchers']; - disabled?: boolean; - enableMessageGroupingByUser?: boolean; - isChannelActive?: boolean; - lastRead?: Date; - /** - * Maximum time in milliseconds that should occur between messages - * to still consider them grouped together - */ - maxTimeBetweenGroupedMessages?: number; - /** - * Custom UI component for sticky header of channel. - * - * **Default** [DateHeader](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageList/DateHeader.tsx) - */ - StickyHeader?: React.ComponentType; - /** - * Id of message, around which Channel/MessageList gets loaded when opened. - * You will see a highlighted background for targetted message, when opened. - */ - targetedMessage?: string; - threadList?: boolean; - watcherCount?: ChannelState['watcher_count']; + watchers?: ChannelState['watchers']; }; export const ChannelContext = React.createContext( diff --git a/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx b/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx index 1fd811a09f..859dd2a594 100644 --- a/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx +++ b/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx @@ -7,14 +7,11 @@ import React, { useReducer, useRef, } from 'react'; -import { ChannelState as StreamChannelState } from 'stream-chat'; import type { DefaultStreamChatGenerics } from '../../types/types'; import { ActiveChannelsProvider } from '../activeChannelsRefContext/ActiveChannelsRefContext'; -import type { PaginatedMessageListContextValue } from '../paginatedMessageListContext/PaginatedMessageListContext'; import type { ThreadContextValue } from '../threadContext/ThreadContext'; -import type { TypingContextValue } from '../typingContext/TypingContext'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; import { isTestEnvironment } from '../utils/isTestEnvironment'; @@ -22,13 +19,7 @@ import { isTestEnvironment } from '../utils/isTestEnvironment'; export type ChannelState< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, > = { - members: StreamChannelState['members']; - messages: PaginatedMessageListContextValue['messages']; - read: StreamChannelState['read']; threadMessages: ThreadContextValue['threadMessages']; - typing: TypingContextValue['typing']; - watcherCount: number; - watchers: StreamChannelState['watchers']; }; type ChannelsState< diff --git a/package/src/contexts/channelsStateContext/useChannelState.ts b/package/src/contexts/channelsStateContext/useChannelState.ts index 2c69cabf02..f0683afcfa 100644 --- a/package/src/contexts/channelsStateContext/useChannelState.ts +++ b/package/src/contexts/channelsStateContext/useChannelState.ts @@ -21,7 +21,7 @@ type StateManagerParams< updates to the ChannelsStateContext reducer. It receives the cid and key which it wants to update and perform the state updates. Also supports a initialState. */ -function useStateManager< +export function useStateManager< Key extends Keys, StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, >( @@ -45,20 +45,8 @@ function useStateManager< export type UseChannelStateValue< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, > = { - members: ChannelState['members']; - messages: ChannelState['messages']; - read: ChannelState['read']; - setMembers: (value: ChannelState['members']) => void; - setMessages: (value: ChannelState['messages']) => void; - setRead: (value: ChannelState['read']) => void; setThreadMessages: (value: ChannelState['threadMessages']) => void; - setTyping: (value: ChannelState['typing']) => void; - setWatcherCount: (value: ChannelState['watcherCount']) => void; - setWatchers: (value: ChannelState['watchers']) => void; threadMessages: ChannelState['threadMessages']; - typing: ChannelState['typing']; - watcherCount: ChannelState['watcherCount']; - watchers: ChannelState['watchers']; }; export function useChannelState< @@ -70,63 +58,6 @@ export function useChannelState< const cid = channel?.id || 'id'; // in case channel is not initialized, use generic id string for indexing const { setState, state } = useChannelsStateContext(); - const [members, setMembers] = useStateManager( - { - cid, - key: 'members', - setState, - state, - }, - channel?.state?.members || {}, - ); - - const [messages, setMessages] = useStateManager( - { - cid, - key: 'messages', - setState, - state, - }, - channel?.state?.messages || [], - ); - - const [read, setRead] = useStateManager( - { - cid, - key: 'read', - setState, - state, - }, - channel?.state?.read || {}, - ); - - const [typing, setTyping] = useStateManager( - { - cid, - key: 'typing', - setState, - state, - }, - {}, - ); - - const [watcherCount, setWatcherCount] = useStateManager({ - cid, - key: 'watcherCount', - setState, - state, - }); - - const [watchers, setWatchers] = useStateManager( - { - cid, - key: 'watchers', - setState, - state, - }, - {}, - ); - const [threadMessages, setThreadMessages] = useStateManager( { cid, @@ -138,19 +69,7 @@ export function useChannelState< ); return { - members, - messages, - read, - setMembers, - setMessages, - setRead, setThreadMessages, - setTyping, - setWatcherCount, - setWatchers, threadMessages, - typing, - watcherCount, - watchers, }; } diff --git a/package/src/contexts/paginatedMessageListContext/PaginatedMessageListContext.tsx b/package/src/contexts/paginatedMessageListContext/PaginatedMessageListContext.tsx index 2247aae736..1c34d29dd7 100644 --- a/package/src/contexts/paginatedMessageListContext/PaginatedMessageListContext.tsx +++ b/package/src/contexts/paginatedMessageListContext/PaginatedMessageListContext.tsx @@ -11,21 +11,10 @@ export type PaginatedMessageListContextValue< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, > = { /** - * Has more messages to load - */ - hasMore: boolean; - /** - * Flag to indicate that are no more recent messages to be loaded - */ - hasNoMoreRecentMessagesToLoad: boolean; - /** - * Is loading more messages - */ - loadingMore: boolean; - /** - * Is loading more recent messages + * Load latest messages + * @returns Promise */ - loadingMoreRecent: boolean; + loadLatestMessages: () => Promise; /** * Load more messages */ @@ -38,14 +27,26 @@ export type PaginatedMessageListContextValue< * Messages from client state */ messages: ChannelState['messages']; + /** + * Has more messages to load + */ + hasMore?: boolean; + /** + * Is loading more messages + */ + loadingMore?: boolean; + /** + * Is loading more recent messages + */ + loadingMoreRecent?: boolean; /** * Set loadingMore */ - setLoadingMore: React.Dispatch>; + setLoadingMore?: (loadingMore: boolean) => void; /** * Set loadingMoreRecent */ - setLoadingMoreRecent: React.Dispatch>; + setLoadingMoreRecent?: (loadingMoreRecent: boolean) => void; }; export const PaginatedMessageListContext = React.createContext( From deaf359ba66c506e3b64646c0fdfe60172c3a1f5 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 20 Nov 2024 13:42:14 +0530 Subject: [PATCH 03/10] fix: channel state initial data --- package/src/components/Channel/Channel.tsx | 2 +- .../Channel/hooks/useChannelDataState.ts | 26 +++++++++++-------- .../hooks/useMessageListPagination.tsx | 2 +- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index cf5b434c31..22c76e13d0 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -669,7 +669,7 @@ const ChannelWithContext = < initStateFromChannel, setTyping, state: channelState, - } = useChannelDataState(); + } = useChannelDataState(channel); const { copyMessagesStateFromChannel, diff --git a/package/src/components/Channel/hooks/useChannelDataState.ts b/package/src/components/Channel/hooks/useChannelDataState.ts index b24ab84e2a..2036c48ce7 100644 --- a/package/src/components/Channel/hooks/useChannelDataState.ts +++ b/package/src/components/Channel/hooks/useChannelDataState.ts @@ -51,15 +51,17 @@ export type ChannelState< */ export const useChannelMessageDataState = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, ->() => { +>( + channel: Channel, +) => { const [state, setState] = useState>({ hasMore: true, hasMoreNewer: false, - loading: true, + loading: false, loadingMore: false, loadingMoreRecent: false, - messages: [], - pinnedMessages: [], + messages: channel?.state.messages || [], + pinnedMessages: channel?.state.pinnedMessages || [], targetedMessageId: undefined, }); @@ -73,15 +75,15 @@ export const useChannelMessageDataState = < const loadInitialMessagesStateFromChannel = useCallback( (channel: Channel, hasMore: boolean) => { - setState({ - ...state, + setState((prev) => ({ + ...prev, hasMore, loading: false, messages: [...channel.state.messages], pinnedMessages: [...channel.state.pinnedMessages], - }); + })); }, - [state], + [], ); const jumpToLatestMessage = useCallback(() => { @@ -166,10 +168,12 @@ export const useChannelMessageDataState = < */ export const useChannelDataState = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, ->() => { +>( + channel: Channel, +) => { const [state, setState] = useState>({ - members: {}, - read: {}, + members: channel.state.members, + read: channel.state.read, typing: {}, watcherCount: 0, watchers: {}, diff --git a/package/src/components/Channel/hooks/useMessageListPagination.tsx b/package/src/components/Channel/hooks/useMessageListPagination.tsx index 3e01465978..6d0e805cb0 100644 --- a/package/src/components/Channel/hooks/useMessageListPagination.tsx +++ b/package/src/components/Channel/hooks/useMessageListPagination.tsx @@ -38,7 +38,7 @@ export const useMessageListPagination = < setLoadingMore, setLoadingMoreRecent, state, - } = useChannelMessageDataState(); + } = useChannelMessageDataState(channel); // hard limit to prevent you from scrolling faster than 1 page per 2 seconds const loadMoreFinished = useRef( From bc98c8f9b8d143b84175e2838b3fb7ce9fd4bb3c Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 20 Nov 2024 16:24:57 +0530 Subject: [PATCH 04/10] fix: revert back the onStartReached change --- .../components/MessageList/MessageList.tsx | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 0f1d466b76..adc61bff04 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -351,6 +351,11 @@ const MessageListWithContext = < */ const initialScrollSettingTimeoutRef = useRef>(); + /** + * The timeout id used to temporarily load the initial scroll set flag + */ + const onScrollEventTimeoutRef = useRef>(); + /** * Last messageID that was scrolled to after loading a new message list, * this flag keeps track of it so that we dont scroll to it again on target message set @@ -774,6 +779,29 @@ const MessageListWithContext = < .catch(onError); }; + const onUserScrollEvent: NonNullable = (event) => { + const nativeEvent = event.nativeEvent; + clearTimeout(onScrollEventTimeoutRef.current); + const offset = nativeEvent.contentOffset.y; + const visibleLength = nativeEvent.layoutMeasurement.height; + const contentLength = nativeEvent.contentSize.height; + if (!channel || !channelResyncScrollSet.current) { + return; + } + + // Check if scroll has reached either start of end of list. + const isScrollAtStart = offset < 100; + const isScrollAtEnd = contentLength - visibleLength - offset < 100; + + if (isScrollAtStart) { + maybeCallOnStartReached(); + } + + if (isScrollAtEnd) { + maybeCallOnEndReached(); + } + }; + const handleScroll: ScrollViewProps['onScroll'] = (event) => { const offset = event.nativeEvent.contentOffset.y; const messageListHasMessages = processedMessageList.length > 0; @@ -810,8 +838,8 @@ const MessageListWithContext = < const isNotLatestSet = channel.state.messages !== channel.state.latestMessages; if (isNotLatestSet) { - await reloadChannel(); resetPaginationTrackersRef.current(); + await reloadChannel(); } else if (flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, @@ -1001,12 +1029,14 @@ const MessageListWithContext = < } }; - const onScrollBeginDrag: ScrollViewProps['onScrollBeginDrag'] = () => { + const onScrollBeginDrag: ScrollViewProps['onScrollBeginDrag'] = (event) => { !hasMoved && selectedPicker && setHasMoved(true); + onUserScrollEvent(event); }; - const onScrollEndDrag: ScrollViewProps['onScrollEndDrag'] = () => { + const onScrollEndDrag: ScrollViewProps['onScrollEndDrag'] = (event) => { hasMoved && selectedPicker && setHasMoved(false); + onUserScrollEvent(event); }; const refCallback = (ref: FlatListType>) => { @@ -1119,12 +1149,11 @@ const MessageListWithContext = < minIndexForVisible: 1, }} maxToRenderPerBatch={30} - onEndReached={maybeCallOnEndReached} + onMomentumScrollEnd={onUserScrollEvent} onScroll={handleScroll} onScrollBeginDrag={onScrollBeginDrag} onScrollEndDrag={onScrollEndDrag} onScrollToIndexFailed={onScrollToIndexFailedRef.current} - onStartReached={maybeCallOnStartReached} onTouchEnd={dismissImagePicker} onViewableItemsChanged={onViewableItemsChanged.current} ref={refCallback} From 74dfcf1b50cfadaf99a17133a4b831c064c30e2d Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 20 Nov 2024 16:27:14 +0530 Subject: [PATCH 05/10] fix: lint and tests --- package/src/components/Channel/__tests__/Channel.test.js | 3 +-- .../Thread/__tests__/__snapshots__/Thread.test.js.snap | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/package/src/components/Channel/__tests__/Channel.test.js b/package/src/components/Channel/__tests__/Channel.test.js index bc72c3c48d..242e631437 100644 --- a/package/src/components/Channel/__tests__/Channel.test.js +++ b/package/src/components/Channel/__tests__/Channel.test.js @@ -1,7 +1,7 @@ import React, { useContext, useEffect } from 'react'; import { View } from 'react-native'; -import { act, cleanup, render, waitFor } from '@testing-library/react-native'; +import { cleanup, render, waitFor } from '@testing-library/react-native'; import { StreamChat } from 'stream-chat'; import { ChannelContext, ChannelProvider } from '../../../contexts/channelContext/ChannelContext'; @@ -15,7 +15,6 @@ import { ThreadContext, ThreadProvider } from '../../../contexts/threadContext/T import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; -import dispatchChannelDeletedEvent from '../../../mock-builders/event/channelDeleted'; import { generateChannelResponse } from '../../../mock-builders/generator/channel'; import { generateMember } from '../../../mock-builders/generator/member'; import { generateMessage } from '../../../mock-builders/generator/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 c2f5ded018..c554a56d09 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -160,7 +160,6 @@ exports[`Thread should match thread snapshot 1`] = ` } maxToRenderPerBatch={30} onContentSizeChange={[Function]} - onEndReached={[Function]} onLayout={[Function]} onMomentumScrollBegin={[Function]} onMomentumScrollEnd={[Function]} @@ -168,7 +167,6 @@ exports[`Thread should match thread snapshot 1`] = ` onScrollBeginDrag={[Function]} onScrollEndDrag={[Function]} onScrollToIndexFailed={[Function]} - onStartReached={[Function]} onTouchEnd={[Function]} onViewableItemsChanged={[Function]} removeClippedSubviews={false} From 64818b6064607a23af5a1a924f966616c11679f6 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Fri, 22 Nov 2024 14:58:29 +0530 Subject: [PATCH 06/10] fix: added tests for the hooks and message pagination --- package/src/components/Channel/Channel.tsx | 1 - .../Channel/__tests__/Channel.test.js | 245 +++++++++- .../useMessageListPagination.test.js | 419 ++++++++++++++++++ .../Channel/hooks/useChannelDataState.ts | 20 +- .../hooks/useMessageListPagination.tsx | 3 +- .../components/MessageList/MessageList.tsx | 33 +- .../MessageList/ScrollToBottomButton.tsx | 2 +- .../MessageList/__tests__/MessageList.test.js | 176 +++++++- .../__tests__/ScrollToBottomButton.test.js | 6 +- .../ScrollToBottomButton.test.js.snap | 2 +- 10 files changed, 876 insertions(+), 31 deletions(-) create mode 100644 package/src/components/Channel/__tests__/useMessageListPagination.test.js diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 22c76e13d0..61c72fddbb 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -884,7 +884,6 @@ const ChannelWithContext = < const failedMessages = channelMessagesState.messages ?.filter((message) => message.status === MessageStatusTypes.FAILED) .map(parseMessage); - if (failedMessages?.length) { channel.state.addMessagesSorted(failedMessages); } diff --git a/package/src/components/Channel/__tests__/Channel.test.js b/package/src/components/Channel/__tests__/Channel.test.js index 242e631437..4254bcabb0 100644 --- a/package/src/components/Channel/__tests__/Channel.test.js +++ b/package/src/components/Channel/__tests__/Channel.test.js @@ -1,7 +1,7 @@ import React, { useContext, useEffect } from 'react'; import { View } from 'react-native'; -import { cleanup, render, waitFor } from '@testing-library/react-native'; +import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react-native'; import { StreamChat } from 'stream-chat'; import { ChannelContext, ChannelProvider } from '../../../contexts/channelContext/ChannelContext'; @@ -15,6 +15,7 @@ import { ThreadContext, ThreadProvider } from '../../../contexts/threadContext/T import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; +import dispatchConnectionChanged from '../../../mock-builders/event/connectionChanged'; import { generateChannelResponse } from '../../../mock-builders/generator/channel'; import { generateMember } from '../../../mock-builders/generator/member'; import { generateMessage } from '../../../mock-builders/generator/message'; @@ -23,6 +24,11 @@ import { getTestClientWithUser } from '../../../mock-builders/mock'; import { Attachment } from '../../Attachment/Attachment'; import { Chat } from '../../Chat/Chat'; import { Channel } from '../Channel'; +import { + channelInitialState, + useChannelDataState, + useChannelMessageDataState, +} from '../hooks/useChannelDataState'; // This component is used for performing effects in a component that consumes ChannelContext, // i.e. making use of the callbacks & values provided by the Channel component. @@ -328,3 +334,240 @@ describe('Channel', () => { }); }); }); + +describe('Channel initial load useEffect', () => { + let chatClient; + + const renderComponent = (props = {}) => + render( + + {props.children} + , + ); + + beforeEach(async () => { + chatClient = await getTestClientWithUser(user); + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + it('should not call channel.watch if channel is not initialized', async () => { + const messages = Array.from({ length: 10 }, (_, i) => generateMessage({ id: i })); + const mockedChannel = generateChannelResponse({ + messages, + }); + + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); + channel.offlineMode = true; + channel.state = channelInitialState; + const watchSpy = jest.fn(); + channel.watch = watchSpy; + + renderComponent({ channel }); + + await waitFor(() => expect(watchSpy).not.toHaveBeenCalled()); + }); + + it("should call channel.watch if channel is initialized and it's not in offline mode", async () => { + const messages = Array.from({ length: 10 }, (_, i) => generateMessage({ id: i })); + const mockedChannel = generateChannelResponse({ + messages, + }); + + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); + + channel.state = { + ...channelInitialState, + members: Object.fromEntries( + Array.from({ length: 10 }, (_, i) => [i, generateMember({ id: i })]), + ), + messagePagination: { + hasPrev: true, + }, + messages: Array.from({ length: 10 }, (_, i) => generateMessage({ id: i })), + }; + const watchSpy = jest.fn(); + + channel.offlineMode = false; + channel.initialied = false; + channel.watch = watchSpy; + + renderComponent({ channel }); + + const { result: channelMessageState } = renderHook(() => useChannelMessageDataState(channel)); + const { result: channelState } = renderHook(() => useChannelDataState(channel)); + + await waitFor(() => expect(watchSpy).toHaveBeenCalled()); + await waitFor(() => expect(channelMessageState.current.state.messages).toHaveLength(10)); + await waitFor(() => expect(Object.keys(channelState.current.state.members)).toHaveLength(10)); + }); + + function getElementsAround(array, key, id) { + const index = array.findIndex((obj) => obj[key] === id); + + if (index === -1) { + return []; + } + + const start = Math.max(0, index - 12); // 12 before the index + const end = Math.min(array.length, index + 13); // 12 after the index + return array.slice(start, end); + } + + it('should call the loadChannelAroundMessage when messageId is passed to a channel', async () => { + const messages = Array.from({ length: 105 }, (_, i) => generateMessage({ id: i })); + const messageToSearch = messages[50]; + const mockedChannel = generateChannelResponse({ + messages, + }); + + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); + + const loadMessageIntoState = jest.fn(() => { + const newMessages = getElementsAround(messages, 'id', messageToSearch.id); + channel.state.messages = newMessages; + }); + + channel.state = { + ...channelInitialState, + loadMessageIntoState, + messagePagination: { + hasNext: true, + hasPrev: true, + }, + messages, + }; + + renderComponent({ channel, messageId: messageToSearch.id }); + + await waitFor(() => { + expect(loadMessageIntoState).toHaveBeenCalledWith(messageToSearch.id, undefined, 25); + }); + + const { result: channelMessageState } = renderHook(() => useChannelMessageDataState(channel)); + await waitFor(() => expect(channelMessageState.current.state.messages).toHaveLength(25)); + await waitFor(() => + expect( + channelMessageState.current.state.messages.find( + (message) => message.id === messageToSearch.id, + ), + ).toBeTruthy(), + ); + }); + + it("should not call loadChannelAtFirstUnreadMessage if channel's unread count is 0", async () => { + const mockedChannel = generateChannelResponse({ + messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })), + }); + + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); + const messages = Array.from({ length: 100 }, (_, i) => generateMessage({ id: i })); + + const loadMessageIntoState = jest.fn(); + channel.state = { + ...channelInitialState, + loadMessageIntoState, + messagePagination: { + hasNext: true, + hasPrev: true, + }, + messages, + }; + channel.countUnread = jest.fn(() => 0); + + renderComponent({ channel, initialScrollToFirstUnreadMessage: true }); + + await waitFor(() => { + expect(loadMessageIntoState).not.toHaveBeenCalled(); + }); + }); + + it("should call loadChannelAtFirstUnreadMessage if channel's unread count is greater than 0", async () => { + const mockedChannel = generateChannelResponse({ + messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })), + }); + + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); + const messages = Array.from({ length: 100 }, (_, i) => generateMessage({ id: i })); + + let targetedMessageId = 0; + const loadMessageIntoState = jest.fn((id) => { + targetedMessageId = id; + const newMessages = getElementsAround(messages, 'id', id); + channel.state.messages = newMessages; + }); + + channel.state = { + ...channelInitialState, + loadMessageIntoState, + messagePagination: { + hasNext: true, + hasPrev: true, + }, + messages, + messageSets: [{ isCurrent: true, isLatest: true }], + }; + + channel.countUnread = jest.fn(() => 15); + + renderComponent({ channel, initialScrollToFirstUnreadMessage: true }); + + await waitFor(() => { + expect(loadMessageIntoState).toHaveBeenCalledTimes(1); + }); + + const { result: channelMessageState } = renderHook(() => useChannelMessageDataState(channel)); + await waitFor(() => + expect( + channelMessageState.current.state.messages.find( + (message) => message.id === targetedMessageId, + ), + ).toBeDefined(), + ); + }); + + it('should call resyncChannel when connection changed event is triggered', async () => { + const mockedChannel = generateChannelResponse({ + messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })), + }); + + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); + + renderComponent({ channel }); + + await waitFor(() => { + act(() => dispatchConnectionChanged(chatClient, false)); + }); + + await waitFor(() => { + channel.state.addMessagesSorted( + Array.from({ length: 10 }, (_, i) => + generateMessage({ status: 'failed', text: `message-${i}` }), + ), + ); + }); + + await waitFor(() => { + act(() => dispatchConnectionChanged(chatClient)); + }); + + await waitFor(() => { + expect(channel.state.messages.length).toBe(20); + }); + }); +}); diff --git a/package/src/components/Channel/__tests__/useMessageListPagination.test.js b/package/src/components/Channel/__tests__/useMessageListPagination.test.js new file mode 100644 index 0000000000..3c9dbcca8e --- /dev/null +++ b/package/src/components/Channel/__tests__/useMessageListPagination.test.js @@ -0,0 +1,419 @@ +import { act, cleanup, renderHook, waitFor } from '@testing-library/react-native'; + +import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; +import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; +import { generateChannelResponse } from '../../../mock-builders/generator/channel'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { generateUser } from '../../../mock-builders/generator/user'; +import { getTestClientWithUser } from '../../../mock-builders/mock'; +import { channelInitialState } from '../hooks/useChannelDataState'; +import * as ChannelStateHooks from '../hooks/useChannelDataState'; +import { useMessageListPagination } from '../hooks/useMessageListPagination'; + +describe('useMessageListPagination', () => { + let chatClient; + let channel; + + const mockedHook = (state, values) => + jest.spyOn(ChannelStateHooks, 'useChannelMessageDataState').mockImplementation(() => ({ + copyMessagesStateFromChannel: jest.fn(), + jumpToLatestMessage: jest.fn(), + jumpToMessageFinished: jest.fn(), + loadInitialMessagesStateFromChannel: jest.fn(), + loadMoreFinished: jest.fn(), + loadMoreRecentFinished: jest.fn(), + setLoading: jest.fn(), + setLoadingMore: jest.fn(), + setLoadingMoreRecent: jest.fn(), + state: { ...channelInitialState, ...state }, + ...values, + })); + + beforeEach(async () => { + // Reset all modules before each test + jest.resetModules(); + const user = generateUser({ id: 'id', name: 'name' }); + chatClient = await getTestClientWithUser(user); + + const mockedChannel = generateChannelResponse({ + messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })), + }); + + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); + }); + + afterEach(() => { + // Clear all mocks after each test + jest.clearAllMocks(); + // Restore all mocks to their original implementation + jest.restoreAllMocks(); + cleanup(); + }); + + it('should set the state when the loadLatestMessages is called', async () => { + const loadMessageIntoState = jest.fn(() => { + channel.state.messages = Array.from({ length: 20 }, (_, i) => + generateMessage({ text: `message-${i}` }), + ); + channel.state.messagePagination.hasPrev = true; + }); + channel.state = { + ...channelInitialState, + loadMessageIntoState, + messagePagination: { + hasNext: true, + hasPrev: true, + }, + }; + const { result } = renderHook(() => useMessageListPagination({ channel })); + + await act(async () => { + await result.current.loadLatestMessages(); + }); + + await waitFor(() => { + expect(loadMessageIntoState).toHaveBeenCalledTimes(1); + expect(result.current.state.hasMore).toBe(true); + expect(result.current.state.messages.length).toBe(20); + }); + }); + + describe('loadMore', () => { + afterEach(() => { + // Clear all mocks after each test + jest.clearAllMocks(); + // Restore all mocks to their original implementation + jest.restoreAllMocks(); + cleanup(); + }); + it('should not set the state when the loadMore function is called and hasPrev is false', async () => { + const queryFn = jest.fn(); + channel.state = { + ...channelInitialState, + messagePagination: { + hasNext: true, + hasPrev: false, + }, + }; + channel.query = queryFn; + const { result } = renderHook(() => useMessageListPagination({ channel })); + + await act(async () => { + await result.current.loadMore(); + }); + + await waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(0); + }); + }); + + it('should not set the state when the loading more and loading more recent boolean are true', async () => { + const queryFn = jest.fn(); + channel.state = { + ...channelInitialState, + messagePagination: { + hasNext: true, + hasPrev: true, + }, + }; + channel.query = queryFn; + + mockedHook({ loadingMore: true, loadingMoreRecent: true }); + + const { result } = renderHook(() => useMessageListPagination({ channel })); + + await act(async () => { + await result.current.loadMore(); + }); + + await waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(0); + }); + }); + + it('should set the state when the loadMore function is called and hasPrev is true and loadingMore is false and loadingMoreRecent is false', async () => { + const messages = Array.from({ length: 20 }, (_, i) => + generateMessage({ text: `message-${i}` }), + ); + const queryFn = jest.fn(() => { + channel.state.messages = Array.from({ length: 40 }, (_, i) => + generateMessage({ text: `message-${i}` }), + ); + channel.state.messagePagination.hasPrev = true; + }); + channel.state = { + ...channelInitialState, + messagePagination: { + hasNext: true, + hasPrev: true, + }, + messages, + }; + channel.query = queryFn; + + const { result } = renderHook(() => useMessageListPagination({ channel })); + + await act(async () => { + await result.current.loadMore(); + }); + + await waitFor(() => { + expect(queryFn).toHaveBeenCalledWith({ + messages: { id_lt: messages[0].id, limit: 20 }, + watchers: { limit: 20 }, + }); + expect(result.current.state.hasMore).toBe(true); + expect(result.current.state.messages.length).toBe(40); + }); + }); + }); + + describe('loadMoreRecent', () => { + afterEach(() => { + // Clear all mocks after each test + jest.clearAllMocks(); + // Restore all mocks to their original implementation + jest.restoreAllMocks(); + cleanup(); + }); + + it('should not set the state when the loadMoreRecent function is called and hasNext is false', async () => { + const queryFn = jest.fn(); + channel.state = { + ...channelInitialState, + messagePagination: { + hasNext: false, + hasPrev: true, + }, + }; + channel.query = queryFn; + const { result } = renderHook(() => useMessageListPagination({ channel })); + + await act(async () => { + await result.current.loadMoreRecent(); + }); + + await waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(0); + }); + }); + + it('should not set the state when the loading more and loading more recent boolean are true', async () => { + const queryFn = jest.fn(); + channel.state = { + ...channelInitialState, + messagePagination: { + hasNext: true, + hasPrev: true, + }, + }; + channel.query = queryFn; + + mockedHook({ loadingMore: true, loadingMoreRecent: true }); + + const { result } = renderHook(() => useMessageListPagination({ channel })); + + await act(async () => { + await result.current.loadMoreRecent(); + }); + + await waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(0); + }); + }); + + it('should set the state when the loadMoreRecent function is called and hasNext is true and loadingMore is false and loadingMoreRecent is false', async () => { + const messages = Array.from({ length: 20 }, (_, i) => + generateMessage({ text: `message-${i}` }), + ); + const queryFn = jest.fn(() => { + channel.state.messages = Array.from({ length: 40 }, (_, i) => + generateMessage({ text: `message-${i}` }), + ); + channel.state.messagePagination.hasPrev = true; + }); + channel.state = { + ...channelInitialState, + messagePagination: { + hasNext: true, + hasPrev: true, + }, + messages, + }; + channel.query = queryFn; + + const { result } = renderHook(() => useMessageListPagination({ channel })); + + await act(async () => { + await result.current.loadMoreRecent(); + }); + + await waitFor(() => { + expect(queryFn).toHaveBeenCalledWith({ + messages: { id_gt: messages[messages.length - 1].id, limit: 10 }, + watchers: { limit: 10 }, + }); + expect(result.current.state.hasMore).toBe(true); + expect(result.current.state.messages.length).toBe(40); + }); + }); + }); + + describe('loadChannelAroundMessage', () => { + afterEach(() => { + // Clear all mocks after each test + jest.clearAllMocks(); + // Restore all mocks to their original implementation + jest.restoreAllMocks(); + cleanup(); + }); + + it('should not do anything when the messageId to search for is not passed', async () => { + const loadMessageIntoState = jest.fn(() => { + channel.state.messages = Array.from({ length: 20 }, (_, i) => + generateMessage({ text: `message-${i}` }), + ); + channel.state.messagePagination.hasPrev = true; + }); + channel.state = { + ...channelInitialState, + loadMessageIntoState, + messagePagination: { + hasNext: true, + hasPrev: true, + }, + }; + const { result } = renderHook(() => useMessageListPagination({ channel })); + + await act(async () => { + await result.current.loadChannelAroundMessage({ messageId: undefined }); + }); + + await waitFor(() => { + expect(loadMessageIntoState).toHaveBeenCalledTimes(0); + }); + }); + + it('should call the loadMessageIntoState function when the messageId to search for is passed and set the state', async () => { + const loadMessageIntoState = jest.fn(() => { + channel.state.messages = Array.from({ length: 20 }, (_, i) => + generateMessage({ text: `message-${i}` }), + ); + channel.state.messagePagination.hasPrev = true; + }); + channel.state = { + ...channelInitialState, + loadMessageIntoState, + messagePagination: { + hasNext: false, + hasPrev: true, + }, + }; + const { result } = renderHook(() => useMessageListPagination({ channel })); + + await act(async () => { + await result.current.loadChannelAroundMessage({ messageId: 'message-5' }); + }); + + await waitFor(() => { + expect(loadMessageIntoState).toHaveBeenCalledTimes(1); + expect(result.current.state.hasMore).toBe(true); + expect(result.current.state.hasMoreNewer).toBe(false); + expect(result.current.state.messages.length).toBe(20); + expect(result.current.state.targetedMessageId).toBe('message-5'); + }); + }); + }); + + describe('loadChannelAtFirstUnreadMessage', () => { + afterEach(() => { + // Clear all mocks after each test + jest.clearAllMocks(); + // Restore all mocks to their original implementation + jest.restoreAllMocks(); + cleanup(); + }); + + it('should not do anything when the unread count is 0', async () => { + const loadMessageIntoState = jest.fn(() => { + channel.state.messages = Array.from({ length: 20 }, (_, i) => + generateMessage({ text: `message-${i}` }), + ); + channel.state.messagePagination.hasPrev = true; + }); + channel.state = { + ...channelInitialState, + loadMessageIntoState, + messagePagination: { + hasNext: true, + hasPrev: true, + }, + }; + + channel.countUnread = jest.fn(() => 0); + + const { result } = renderHook(() => useMessageListPagination({ channel })); + + await act(async () => { + await result.current.loadChannelAtFirstUnreadMessage({}); + }); + + await waitFor(() => { + expect(loadMessageIntoState).toHaveBeenCalledTimes(0); + }); + }); + + function getElementsAround(array, key, id, limit) { + const index = array.findIndex((obj) => obj[key] === id); + + if (index === -1) { + return []; + } + + const start = Math.max(0, index - limit); // 12 before the index + const end = Math.min(array.length, index + limit); // 12 after the index + return array.slice(start, end); + } + + it('should call the loadMessageIntoState function when the unread count is greater than 0 and set the state', async () => { + const messages = Array.from({ length: 30 }, (_, i) => + generateMessage({ text: `message-${i}` }), + ); + const loadMessageIntoState = jest.fn((messageId) => { + channel.state.messages = getElementsAround(messages, 'id', messageId, 5); + channel.state.messagePagination.hasPrev = true; + }); + channel.state = { + ...channelInitialState, + loadMessageIntoState, + messagePagination: { + hasNext: false, + hasPrev: true, + }, + messages, + messageSets: [{ isCurrent: true, isLatest: true }], + }; + + const unreadCount = 5; + channel.countUnread = jest.fn(() => unreadCount); + + const { result } = renderHook(() => useMessageListPagination({ channel })); + + await act(async () => { + await result.current.loadChannelAtFirstUnreadMessage({}); + }); + + await waitFor(() => { + expect(loadMessageIntoState).toHaveBeenCalledTimes(1); + expect(result.current.state.hasMore).toBe(true); + expect(result.current.state.hasMoreNewer).toBe(false); + expect(result.current.state.messages.length).toBe(10); + expect(result.current.state.targetedMessageId).toBe( + messages[messages.length - unreadCount].id, + ); + }); + }); + }); +}); diff --git a/package/src/components/Channel/hooks/useChannelDataState.ts b/package/src/components/Channel/hooks/useChannelDataState.ts index 2036c48ce7..3fb6c8fdaf 100644 --- a/package/src/components/Channel/hooks/useChannelDataState.ts +++ b/package/src/components/Channel/hooks/useChannelDataState.ts @@ -5,6 +5,22 @@ import { Channel, ChannelState as StreamChannelState } from 'stream-chat'; import { DefaultStreamChatGenerics } from '../../../types/types'; import { MessageType } from '../../MessageList/hooks/useMessageList'; +export const channelInitialState = { + hasMore: true, + hasMoreNewer: false, + loading: false, + loadingMore: false, + loadingMoreRecent: false, + members: {}, + messages: [], + pinnedMessages: [], + read: {}, + targetedMessageId: undefined, + typing: {}, + watcherCount: 0, + watchers: {}, +}; + /** * The ChannelMessagesState object */ @@ -60,8 +76,8 @@ export const useChannelMessageDataState = < loading: false, loadingMore: false, loadingMoreRecent: false, - messages: channel?.state.messages || [], - pinnedMessages: channel?.state.pinnedMessages || [], + messages: channel?.state?.messages || [], + pinnedMessages: channel?.state?.pinnedMessages || [], targetedMessageId: undefined, }); diff --git a/package/src/components/Channel/hooks/useMessageListPagination.tsx b/package/src/components/Channel/hooks/useMessageListPagination.tsx index 6d0e805cb0..8d0d87dd84 100644 --- a/package/src/components/Channel/hooks/useMessageListPagination.tsx +++ b/package/src/components/Channel/hooks/useMessageListPagination.tsx @@ -148,6 +148,7 @@ export const useMessageListPagination = < await channel.state.loadMessageIntoState(messageIdToLoadAround, undefined, limit); loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); jumpToMessageFinished(channel.state.messagePagination.hasNext, messageIdToLoadAround); + if (setTargetedMessage) { setTargetedMessage(messageIdToLoadAround); } @@ -170,13 +171,13 @@ export const useMessageListPagination = < limit?: number; setTargetedMessage?: (messageId: string) => void; }) => { - if (!channel) return; let unreadMessageIdToScrollTo: string | undefined; const unreadCount = channel.countUnread(); if (unreadCount === 0) return; const isLatestMessageSetShown = !!channel.state.messageSets.find( (set) => set.isCurrent && set.isLatest, ); + if (isLatestMessageSetShown && unreadCount <= channel.state.messages.length) { unreadMessageIdToScrollTo = channel.state.messages[channel.state.messages.length - unreadCount].id; diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index adc61bff04..fa4325cefb 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -1171,26 +1171,21 @@ const MessageListWithContext = < {...additionalFlatListPropsExcludingStyle} /> )} - - {!loading && ( - <> - - {messageListLengthAfterUpdate && StickyHeader ? ( - - ) : null} - - {!disableTypingIndicator && TypingIndicator && ( - - - - )} - - + + {messageListLengthAfterUpdate && StickyHeader ? ( + + ) : null} + + {!disableTypingIndicator && TypingIndicator && ( + + + )} + ); diff --git a/package/src/components/MessageList/ScrollToBottomButton.tsx b/package/src/components/MessageList/ScrollToBottomButton.tsx index 7b1461a435..ee06cd32ff 100644 --- a/package/src/components/MessageList/ScrollToBottomButton.tsx +++ b/package/src/components/MessageList/ScrollToBottomButton.tsx @@ -80,7 +80,7 @@ export const ScrollToBottomButton = (props: ScrollToBottomButtonProps) => { diff --git a/package/src/components/MessageList/__tests__/MessageList.test.js b/package/src/components/MessageList/__tests__/MessageList.test.js index 3f45963c32..e994180744 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.js +++ b/package/src/components/MessageList/__tests__/MessageList.test.js @@ -1,6 +1,6 @@ import React from 'react'; -import { act, cleanup, render, screen, waitFor } from '@testing-library/react-native'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; @@ -14,6 +14,8 @@ import { generateMessage } from '../../../mock-builders/generator/message'; import { generateUser } from '../../../mock-builders/generator/user'; import { getTestClientWithUser } from '../../../mock-builders/mock'; import { Channel } from '../../Channel/Channel'; +import { channelInitialState } from '../../Channel/hooks/useChannelDataState'; +import * as MessageListPaginationHook from '../../Channel/hooks/useMessageListPagination'; import { Chat } from '../../Chat/Chat'; import { MessageList } from '../MessageList'; @@ -47,7 +49,7 @@ describe('MessageList', () => { act(() => dispatchMessageNewEvent(chatClient, newMessage, mockedChannel.channel)); await waitFor(() => { - expect(queryAllByTestId('message-notification')).toHaveLength(0); + expect(queryAllByTestId('scroll-to-bottom-button')).toHaveLength(0); expect(getByText(newMessage.text)).toBeTruthy(); }); }, 10000); @@ -381,3 +383,173 @@ describe('MessageList', () => { }); }); }); + +describe('MessageList pagination', () => { + afterEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + + const mockedHook = (values) => { + const messages = Array.from({ length: 100 }, (_, i) => + generateMessage({ text: `message-${i}` }), + ); + return jest + .spyOn(MessageListPaginationHook, 'useMessageListPagination') + .mockImplementation(() => ({ + copyMessagesStateFromChannel: jest.fn(), + loadChannelAroundMessage: jest.fn(), + loadChannelAtFirstUnreadMessage: jest.fn(), + loadInitialMessagesStateFromChannel: jest.fn(), + loadLatestMessages: jest.fn(), + loadMore: jest.fn(), + loadMoreRecent: jest.fn(), + state: { ...channelInitialState, messages }, + ...values, + })); + }; + + it('should load more recent messages when the user scrolls to the start of the list', async () => { + const user1 = generateUser(); + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user: user1 })], + messages: Array.from({ length: 100 }, (_, i) => generateMessage({ text: `message-${i}` })), + }); + + const chatClient = await getTestClientWithUser({ id: 'testID' }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); + + const loadMoreRecent = jest.fn(() => Promise.resolve()); + mockedHook({ loadMoreRecent }); + + const { getByTestId } = render( + + + + + + + , + ); + + act(() => { + // scroll to the top of the list + const flatList = getByTestId('message-flat-list'); + fireEvent(flatList, 'momentumScrollEnd', { + nativeEvent: { + contentOffset: { y: 0 }, // Scroll position at the top + contentSize: { height: 2000, width: 200 }, // Total content size + layoutMeasurement: { height: 400, width: 200 }, // Visible area size + }, + }); + }); + + await waitFor(() => { + expect(loadMoreRecent).toHaveBeenCalledTimes(1); + }); + }); + + it('should load more messages when the user scrolls to the end of the list', async () => { + const user1 = generateUser(); + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user: user1 })], + messages: Array.from({ length: 100 }, (_, i) => generateMessage({ text: `message-${i}` })), + }); + + const chatClient = await getTestClientWithUser({ id: 'testID' }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); + + const loadMore = jest.fn(() => Promise.resolve()); + mockedHook({ loadMore }); + + const { getByTestId } = render( + + + + + + + , + ); + + act(() => { + // scroll to the top of the list + const flatList = getByTestId('message-flat-list'); + fireEvent(flatList, 'momentumScrollEnd', { + nativeEvent: { + contentOffset: { y: 1900 }, // Scroll position at the top + contentSize: { height: 2000, width: 200 }, // Total content size + layoutMeasurement: { height: 400, width: 200 }, // Visible area size + }, + }); + }); + + await waitFor(() => { + expect(loadMore).toHaveBeenCalledTimes(1); + }); + }); + + it('should call load latest messages when the scroll to bottom button is pressed', async () => { + const user1 = generateUser(); + const messages = Array.from({ length: 10 }, (_, i) => + generateMessage({ text: `message-${i}` }), + ); + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user: user1 })], + messages, + }); + + const chatClient = await getTestClientWithUser({ id: 'testID' }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); + + channel.state = { + ...channelInitialState, + latestMessages: [], + members: Object.fromEntries( + Array.from({ length: 10 }, (_, i) => [i, generateMember({ id: i })]), + ), + messages: Array.from({ length: 10 }, (_, i) => generateMessage({ id: i })), + messageSets: [{ isCurrent: true, isLatest: true }], + }; + + const loadLatestMessages = jest.fn(() => Promise.resolve()); + mockedHook({ loadLatestMessages }); + + const { getByTestId } = render( + + + + + + + , + ); + + act(() => { + // scroll to the top of the list + const flatList = getByTestId('message-flat-list'); + fireEvent(flatList, 'scroll', { + nativeEvent: { + contentOffset: { y: 1900 }, // Scroll position at the top + contentSize: { height: 2000, width: 200 }, // Total content size + layoutMeasurement: { height: 400, width: 200 }, // Visible area size + }, + }); + }); + + await waitFor(() => { + const scrollToBottomButton = getByTestId('scroll-to-bottom-button'); + expect(scrollToBottomButton).toBeTruthy(); + + fireEvent.press(scrollToBottomButton); + + expect(loadLatestMessages).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.js b/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.js index ad84ff3755..e77cbad740 100644 --- a/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.js +++ b/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.js @@ -22,7 +22,7 @@ describe('ScrollToBottomButton', () => { ); await waitFor(() => { - expect(queryByTestId('message-notification')).toBeFalsy(); + expect(queryByTestId('scroll-to-bottom-button')).toBeFalsy(); }); }); @@ -38,7 +38,7 @@ describe('ScrollToBottomButton', () => { ); await waitFor(() => { - expect(queryByTestId('message-notification')).toBeTruthy(); + expect(queryByTestId('scroll-to-bottom-button')).toBeTruthy(); }); }); @@ -53,7 +53,7 @@ describe('ScrollToBottomButton', () => { , ); - fireEvent.press(getByTestId('message-notification')); + fireEvent.press(getByTestId('scroll-to-bottom-button')); expect(onPress).toHaveBeenCalledTimes(1); }); diff --git a/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap b/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap index e972093ab0..286fdbf9a2 100644 --- a/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap +++ b/package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.js.snap @@ -37,7 +37,7 @@ exports[`ScrollToBottomButton should render the message notification and match s "right": 20, } } - testID="message-notification" + testID="scroll-to-bottom-button" > Date: Fri, 22 Nov 2024 16:48:55 +0530 Subject: [PATCH 07/10] fix: podlock file for sample app --- examples/SampleApp/ios/Podfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 8540fa528d..0846bf4974 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -2476,7 +2476,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 1dca942403ed9342f98334bf4c3621f011aa7946 - DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 + DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5 FBLazyVector: 7075bb12898bc3998fd60f4b7ca422496cc2cdf7 Firebase: 91fefd38712feb9186ea8996af6cbdef41473442 FirebaseAnalytics: b5efc493eb0f40ec560b04a472e3e1a15d39ca13 @@ -2490,7 +2490,7 @@ SPEC CHECKSUMS: FirebaseRemoteConfigInterop: 6efda51fb5e2f15b16585197e26eaa09574e8a4d FirebaseSessions: dbd14adac65ce996228652c1fc3a3f576bdf3ecc fmt: 10c6e61f4be25dc963c36bd73fc7b1705fe975be - glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a + glog: 69ef571f3de08433d766d614c73a9838a06bf7eb GoogleAppMeasurement: f3abf08495ef2cba7829f15318c373b8d9226491 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 @@ -2583,4 +2583,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 4f662370295f8f9cee909f1a4c59a614999a209d -COCOAPODS: 1.14.3 +COCOAPODS: 1.16.2 From aa353f13aab25857f8cea4da9f49d13f6bfb920f Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Tue, 26 Nov 2024 11:45:25 +0530 Subject: [PATCH 08/10] fix: throttle logic for the copy message state --- package/src/components/Channel/Channel.tsx | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index ccb39b55ba..d95ae97232 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -437,6 +437,10 @@ export type ChannelPropsWithContext< * Load the channel at a specified message instead of the most recent message. */ messageId?: string; + /** + * @deprecated + * The time interval for throttling while updating the message state + */ newMessageStateUpdateThrottleInterval?: number; overrideOwnCapabilities?: Partial; stateUpdateThrottleInterval?: number; @@ -594,7 +598,7 @@ const ChannelWithContext = < myMessageTheme, NetworkDownIndicator = NetworkDownIndicatorDefault, // TODO: Think about this one - // newMessageStateUpdateThrottleInterval = defaultThrottleInterval, + newMessageStateUpdateThrottleInterval = defaultThrottleInterval, numberOfLines = 5, onChangeText, onLongPressMessage, @@ -684,6 +688,15 @@ const ChannelWithContext = < channel, }); + /** + * Since we copy the current channel state all together, we need to find the greatest time among the below two and apply it as the throttling time for copying the channel state. + * This is done until we remove the newMessageStateUpdateThrottleInterval prop. + */ + const copyChannelStateThrottlingTime = + newMessageStateUpdateThrottleInterval > stateUpdateThrottleInterval + ? newMessageStateUpdateThrottleInterval + : stateUpdateThrottleInterval; + const copyChannelState = useRef( throttle( () => { @@ -692,7 +705,7 @@ const ChannelWithContext = < copyMessagesStateFromChannel(channel); } }, - stateUpdateThrottleInterval, + copyChannelStateThrottlingTime, throttleOptions, ), ).current; @@ -728,6 +741,7 @@ const ChannelWithContext = < }; useEffect(() => { + let listener: ReturnType; const initChannel = async () => { if (!channel || !shouldSyncChannel || channel.offlineMode) return; let errored = false; @@ -755,7 +769,7 @@ const ChannelWithContext = < ) { await loadChannelAtFirstUnreadMessage({ setTargetedMessage }); } - channel.on(handleEvent); + listener = channel.on(handleEvent); }; initChannel(); @@ -763,7 +777,7 @@ const ChannelWithContext = < return () => { copyChannelState.cancel(); loadMoreThreadFinished.cancel(); - channel.off(handleEvent); + listener.unsubscribe(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [channel.cid, messageId, shouldSyncChannel]); From 5a9f7853c905a892b2ec058304a930051916820f Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Mon, 2 Dec 2024 22:52:36 +0530 Subject: [PATCH 09/10] fix: add back channel.deleted event --- package/src/components/Channel/Channel.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index d95ae97232..ce07035155 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -644,6 +644,7 @@ const ChannelWithContext = < colors: { black }, }, } = useTheme(); + const [deleted, setDeleted] = useState(false); const [editing, setEditing] = useState | undefined>(undefined); const [error, setError] = useState(false); const [lastRead, setLastRead] = useState['lastRead']>(); @@ -777,11 +778,23 @@ const ChannelWithContext = < return () => { copyChannelState.cancel(); loadMoreThreadFinished.cancel(); - listener.unsubscribe(); + listener?.unsubscribe(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [channel.cid, messageId, shouldSyncChannel]); + // subscribe to channel.deleted event + useEffect(() => { + const { unsubscribe } = client.on('channel.deleted', (event) => { + if (event.cid === channel?.cid) { + setDeleted(true); + } + }); + + return unsubscribe; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [channelId]); + /** * Subscription to the Notification mark_read event. */ @@ -1815,6 +1828,9 @@ const ChannelWithContext = < typing: channelState.typing ?? {}, }); + // TODO: replace the null view with appropriate message. Currently this is waiting a design decision. + if (deleted) return null; + if (!channel || (error && channelMessagesState.messages?.length === 0)) { return ; } From 9af929068c86cce697b3246dbee01da500763e6e Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Fri, 6 Dec 2024 21:55:05 +0530 Subject: [PATCH 10/10] fix: useeffect deps --- package/src/components/Channel/Channel.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index ce07035155..dd333e6d4f 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -792,8 +792,7 @@ const ChannelWithContext = < }); return unsubscribe; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [channelId]); + }, [channel?.cid, client]); /** * Subscription to the Notification mark_read event. @@ -805,8 +804,7 @@ const ChannelWithContext = < const { unsubscribe } = client.on('notification.mark_read', handleEvent); return unsubscribe; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [channel.cid, client, copyChannelState]); const threadPropsExists = !!threadProps;