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 e6f6b10a2b..272f2e6571 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 { MessageContextValue } from '../../contexts'; @@ -268,7 +271,7 @@ export type ChannelPropsWithContext< 'messages' | 'loadingMore' | 'loadingMoreRecent' > > & - UseChannelStateValue & + Pick, 'threadMessages' | 'setThreadMessages'> & Partial< Pick< MessagesContextValue, @@ -439,6 +442,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; @@ -567,7 +574,6 @@ const ChannelWithContext = < maxMessageLength: maxMessageLengthProp, maxNumberOfFiles = 10, maxTimeBetweenGroupedMessages, - members, mentionAllAppUsersEnabled = false, mentionAllAppUsersQuery, Message = MessageDefault, @@ -598,7 +604,6 @@ const ChannelWithContext = < MessageReactionPicker = MessageReactionPickerDefault, MessageReplies = MessageRepliesDefault, MessageRepliesAvatars = MessageRepliesAvatarsDefault, - messages, MessageSimple = MessageSimpleDefault, MessageStatus = MessageStatusDefault, MessageSystem = MessageSystemDefault, @@ -611,6 +616,7 @@ const ChannelWithContext = < MoreOptionsButton = MoreOptionsButtonDefault, myMessageTheme, NetworkDownIndicator = NetworkDownIndicatorDefault, + // TODO: Think about this one newMessageStateUpdateThrottleInterval = defaultThrottleInterval, numberOfLines = 5, onChangeText, @@ -623,7 +629,6 @@ const ChannelWithContext = < ReactionListBottom = ReactionListBottomDefault, reactionListPosition = 'top', ReactionListTop = ReactionListTopDefault, - read, Reply = ReplyDefault, ScrollToBottomButton = ScrollToBottomButtonDefault, selectReaction, @@ -631,13 +636,7 @@ const ChannelWithContext = < sendImageAsync = false, SendMessageDisallowedIndicator = SendMessageDisallowedIndicatorDefault, setInputRef, - setMembers, - setMessages, - setRead, setThreadMessages, - setTyping, - setWatcherCount, - setWatchers, shouldShowUnreadUnderlay = true, shouldSyncChannel, ShowThreadMessageInChannelButton = ShowThreadMessageInChannelButtonDefault, @@ -651,14 +650,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; @@ -673,15 +669,10 @@ const ChannelWithContext = < colors: { black }, }, } = useTheme(); - const [deleted, setDeleted] = useState(false); + 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, ); @@ -691,19 +682,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 @@ -715,50 +694,142 @@ const ChannelWithContext = < const channelId = channel?.id || ''; const pollCreationEnabled = !channel.disconnected && !!channel?.id && channel?.getConfig()?.polls; + const { + copyStateFromChannel, + initStateFromChannel, + setTyping, + state: channelState, + } = useChannelDataState(channel); + + const { + copyMessagesStateFromChannel, + loadChannelAroundMessage: loadChannelAroundMessageFn, + loadChannelAtFirstUnreadMessage, + loadInitialMessagesStateFromChannel, + loadLatestMessages, + loadMore, + loadMoreRecent, + state: channelMessagesState, + } = useMessageListPagination({ + 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( + () => { + if (channel) { + copyStateFromChannel(channel); + copyMessagesStateFromChannel(channel); + } + }, + copyChannelStateThrottlingTime, + 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(() => { + let listener: ReturnType; 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 }); } + listener = channel.on(handleEvent); }; initChannel(); return () => { copyChannelState.cancel(); - copyReadState.cancel(); - copyTypingState.cancel(); - loadMoreFinished.cancel(); loadMoreThreadFinished.cancel(); + listener?.unsubscribe(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [channelId, messageId]); + }, [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; + }, [channel?.cid, client]); + + /** + * 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; + }, [channel.cid, client, copyChannelState]); const threadPropsExists = !!threadProps; @@ -810,442 +881,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); @@ -1281,105 +916,39 @@ 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); + 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); + try { + if (!thread) { 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); + const failedMessages = channelMessagesState.messages + ?.filter((message) => message.status === MessageStatusTypes.FAILED) + .map(parseMessage); + if (failedMessages?.length) { + channel.state.addMessagesSorted(failedMessages); + } } 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(); - - if (failedMessages.length) { - channel.state.addMessagesSorted(failedMessages); - copyChannelState(); - } - - await reloadThread(); - - if (thread && failedThreadMessages.length) { - channel.state.addMessagesSorted(failedThreadMessages); - setThreadMessages([...channel.state.threads[thread.id]]); + 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) { @@ -1387,7 +956,6 @@ const ChannelWithContext = < } else { setError(true); } - setLoading(false); } syncingChannelRef.current = false; @@ -1408,7 +976,7 @@ const ChannelWithContext = < if (enableOfflineSupport) { connectionChangedSubscription = DBSyncManager.onSyncStatusChange((statusChanged) => { if (statusChanged) { - connectionChangedHandler(); + copyChannelState(); } }); } else { @@ -1424,19 +992,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. @@ -1455,22 +1010,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); } }; @@ -1481,11 +1075,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); } }; @@ -1535,7 +1130,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']; @@ -1704,8 +1301,6 @@ const ChannelWithContext = < attachments: message.attachments || [], }); - mergeOverlappingMessageSetsRef.current(); - updateMessage(messagePreview, { commands: [], messageInput: '', @@ -1746,161 +1341,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, ) => @@ -1935,7 +1375,8 @@ const ChannelWithContext = < ) => { if (channel) { channel.state.removeMessage(message); - setMessages(channel.state.messages); + copyMessagesStateFromChannel(channel); + if (thread) { setThreadMessages(channel.state.threads[thread.id] || []); } @@ -1974,7 +1415,7 @@ const ChannelWithContext = < user: client.user, }); - setMessages(channel.state.messages); + copyMessagesStateFromChannel(channel); const sendReactionResponse = await DBSyncManager.queueTask({ client, @@ -2060,7 +1501,7 @@ const ChannelWithContext = < user: client.user, }); - setMessages(channel.state.messages); + copyMessagesStateFromChannel(channel); await DBSyncManager.queueTask({ client, @@ -2180,13 +1621,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, @@ -2195,8 +1636,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 @@ -2274,16 +1715,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({ @@ -2410,13 +1851,13 @@ 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 ; } @@ -2493,22 +1934,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, ); @@ -2524,21 +1950,9 @@ export const Channel = < shouldSyncChannel={shouldSyncChannel} {...{ isMessageAIGenerated, - 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..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 { act, 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,7 +15,7 @@ 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 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'; @@ -24,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. @@ -203,17 +208,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( @@ -340,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 new file mode 100644 index 0000000000..3fb6c8fdaf --- /dev/null +++ b/package/src/components/Channel/hooks/useChannelDataState.ts @@ -0,0 +1,235 @@ +import { useCallback, useState } from 'react'; + +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 + */ +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, +>( + channel: Channel, +) => { + const [state, setState] = useState>({ + hasMore: true, + hasMoreNewer: false, + loading: false, + loadingMore: false, + loadingMoreRecent: false, + messages: channel?.state?.messages || [], + pinnedMessages: channel?.state?.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((prev) => ({ + ...prev, + hasMore, + loading: false, + messages: [...channel.state.messages], + pinnedMessages: [...channel.state.pinnedMessages], + })); + }, + [], + ); + + 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, +>( + channel: Channel, +) => { + const [state, setState] = useState>({ + members: channel.state.members, + read: channel.state.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..8d0d87dd84 --- /dev/null +++ b/package/src/components/Channel/hooks/useMessageListPagination.tsx @@ -0,0 +1,246 @@ +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(channel); + + // 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; + }) => { + 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..fa4325cefb 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, @@ -440,6 +436,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 +518,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 +651,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 +704,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 +736,8 @@ const MessageListWithContext = < } onStartReachedInPromise.current = ( threadList && !!threadInstance && loadMoreRecentThread - ? loadMoreRecentThread({ limit }) - : loadMoreRecent(limit) + ? loadMoreRecentThread({}) + : loadMoreRecent() ) .then(callback) .catch(onError); @@ -831,8 +811,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 +821,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,6 +836,7 @@ const MessageListWithContext = < const goToNewMessages = async () => { const isNotLatestSet = channel.state.messages !== channel.state.latestMessages; + if (isNotLatestSet) { resetPaginationTrackersRef.current(); await reloadChannel(); @@ -925,7 +900,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 +919,7 @@ const MessageListWithContext = < return; } // the message we want was not loaded yet, so lets load it - loadChannelAroundMessage({ messageId }); + await loadChannelAroundMessage({ messageId }); }; /** @@ -985,6 +960,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,11 +1023,12 @@ const MessageListWithContext = < ]); const dismissImagePicker = () => { - if (!hasMoved && selectedPicker) { + if (selectedPicker) { setSelectedPicker(undefined); closePicker(); } }; + const onScrollBeginDrag: ScrollViewProps['onScrollBeginDrag'] = (event) => { !hasMoved && selectedPicker && setHasMoved(true); onUserScrollEvent(event); @@ -1155,17 +1132,17 @@ 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, @@ -1194,26 +1171,21 @@ const MessageListWithContext = < {...additionalFlatListPropsExcludingStyle} /> )} - - {!loading && ( - <> - - {messageListLengthAfterUpdate && StickyHeader ? ( - - ) : null} - - {!disableTypingIndicator && TypingIndicator && ( - - - - )} - - + + {messageListLengthAfterUpdate && StickyHeader ? ( + + ) : null} + + {!disableTypingIndicator && TypingIndicator && ( + + + )} + ); @@ -1267,8 +1239,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 +1257,6 @@ export const MessageList = < enableMessageGroupingByUser, error, FlatList, - hasNoMoreRecentMessagesToLoad, hideStickyDateHeader, initialScrollToFirstUnreadMessage, InlineDateSeparator, 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" > + + + 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 7d643d1fb3..859dd2a594 100644 --- a/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx +++ b/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx @@ -8,14 +8,10 @@ import React, { 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'; @@ -23,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(