diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index db3d768ca3..8be772c5c4 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import type { Channel as StreamChatChannel } from 'stream-chat'; import { RouteProp, useFocusEffect, useNavigation } from '@react-navigation/native'; import { @@ -142,6 +142,14 @@ export const ChannelScreen: React.FC = ({ setSelectedThread(undefined); }); + const onThreadSelect = useCallback((thread) => { + setSelectedThread(thread); + navigation.navigate('ThreadScreen', { + channel, + thread, + }); + }, [channel, navigation]); + if (!channel || !chatClient) { return null; } @@ -161,13 +169,7 @@ export const ChannelScreen: React.FC = ({ > - onThreadSelect={(thread) => { - setSelectedThread(thread); - navigation.navigate('ThreadScreen', { - channel, - thread, - }); - }} + onThreadSelect={onThreadSelect} /> diff --git a/package/expo-package/yarn.lock b/package/expo-package/yarn.lock index aeb2acdeea..1f25f07e3f 100644 --- a/package/expo-package/yarn.lock +++ b/package/expo-package/yarn.lock @@ -4733,10 +4733,10 @@ stream-buffers@2.2.x, stream-buffers@~2.2.0: resolved "https://registry.yarnpkg.com/stream-buffers/-/stream-buffers-2.2.0.tgz#91d5f5130d1cef96dcfa7f726945188741d09ee4" integrity sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg== -stream-chat-react-native-core@6.7.1: - version "6.7.1" - resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-6.7.1.tgz#37cdfbc5c7f8a5bac5634b954da4bbdcd2fb95f2" - integrity sha512-4ePEMt1W+iw3zUulSDRFO9Nt4HPa8kW6wJ3Qv+ZN+y886rvcUuTuH18MFrdrJtHYb+UxCU+X3oz++3qzq7Jzxw== +stream-chat-react-native-core@6.7.2: + version "6.7.2" + resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-6.7.2.tgz#955e048b80c55175db084ccbb8519f52ef4bb00c" + integrity sha512-WJFOCfQ7Xpn8Lr4AE6hUh4Qhrn1eGzsoAcKmL8eSoB/etxdNllOyZ3zrwvZgyy+KIEg9bcX4y+3OWtdKW6qfsA== dependencies: "@gorhom/bottom-sheet" "^5.1.1" dayjs "1.10.5" diff --git a/package/native-package/yarn.lock b/package/native-package/yarn.lock index 317331eb77..e586735c0f 100644 --- a/package/native-package/yarn.lock +++ b/package/native-package/yarn.lock @@ -3409,10 +3409,10 @@ statuses@~1.5.0: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -stream-chat-react-native-core@6.7.1: - version "6.7.1" - resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-6.7.1.tgz#37cdfbc5c7f8a5bac5634b954da4bbdcd2fb95f2" - integrity sha512-4ePEMt1W+iw3zUulSDRFO9Nt4HPa8kW6wJ3Qv+ZN+y886rvcUuTuH18MFrdrJtHYb+UxCU+X3oz++3qzq7Jzxw== +stream-chat-react-native-core@6.7.2: + version "6.7.2" + resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-6.7.2.tgz#955e048b80c55175db084ccbb8519f52ef4bb00c" + integrity sha512-WJFOCfQ7Xpn8Lr4AE6hUh4Qhrn1eGzsoAcKmL8eSoB/etxdNllOyZ3zrwvZgyy+KIEg9bcX4y+3OWtdKW6qfsA== dependencies: "@gorhom/bottom-sheet" "^5.1.1" dayjs "1.10.5" diff --git a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx index df602a0034..f0652f0d75 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx @@ -3,10 +3,6 @@ import { I18nManager, StyleSheet, TextInput, TextInputProps } from 'react-native import throttle from 'lodash/throttle'; -import { - ChannelContextValue, - useChannelContext, -} from '../../contexts/channelContext/ChannelContext'; import { MessageInputContextValue, useMessageInputContext, @@ -53,22 +49,22 @@ const isCommand = (text: string) => text[0] === '/' && text.split(' ').length <= type AutoCompleteInputPropsWithContext< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, -> = Pick, 'giphyEnabled'> & - Pick< - MessageInputContextValue, - | 'additionalTextInputProps' - | 'autoCompleteSuggestionsLimit' - | 'giphyActive' - | 'maxMessageLength' - | 'mentionAllAppUsersEnabled' - | 'mentionAllAppUsersQuery' - | 'numberOfLines' - | 'onChange' - | 'setGiphyActive' - | 'setInputBoxRef' - | 'text' - | 'triggerSettings' - > & +> = Pick< + MessageInputContextValue, + | 'additionalTextInputProps' + | 'autoCompleteSuggestionsLimit' + | 'giphyActive' + | 'giphyEnabled' + | 'maxMessageLength' + | 'mentionAllAppUsersEnabled' + | 'mentionAllAppUsersQuery' + | 'numberOfLines' + | 'onChange' + | 'setGiphyActive' + | 'setInputBoxRef' + | 'text' + | 'triggerSettings' +> & Pick< SuggestionsContextValue, 'closeSuggestions' | 'openSuggestions' | 'updateSuggestions' @@ -484,8 +480,8 @@ export const AutoCompleteInput = < >( props: AutoCompleteInputProps, ) => { - const { giphyEnabled } = useChannelContext(); const { + giphyEnabled, additionalTextInputProps, autoCompleteSuggestionsLimit, giphyActive, diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 1bc8ad5a7f..4033eddd95 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -74,6 +74,7 @@ import { useTranslationContext, } from '../../contexts/translationContext/TranslationContext'; import { TypingProvider } from '../../contexts/typingContext/TypingContext'; +import { useStableCallback } from '../../hooks'; import { useAppStateListener } from '../../hooks/useAppStateListener'; import { @@ -716,6 +717,12 @@ const ChannelWithContext = < * Its a map of filename to AbortController */ const uploadAbortControllerRef = useRef>(new Map()); + /** + * This ref keeps track of message IDs which have already been optimistically updated. + * We need it to make sure we don't react on message.new/notification.message_new events + * if this is indeed the case, as it's a full list update for nothing. + */ + const optimisticallyUpdatedNewMessages = useMemo>(() => new Set(), []); const channelId = channel?.id || ''; const pollCreationEnabled = !channel.disconnected && !!channel?.id && channel?.getConfig()?.polls; @@ -741,14 +748,33 @@ const ChannelWithContext = < channel, }); - /** - * Since we copy the current channel state all together, we need to find the greatest time among the below two and apply it as the throttling time for copying the channel state. - * This is done until we remove the newMessageStateUpdateThrottleInterval prop. - */ - const copyChannelStateThrottlingTime = - newMessageStateUpdateThrottleInterval > stateUpdateThrottleInterval - ? newMessageStateUpdateThrottleInterval - : stateUpdateThrottleInterval; + const setReadThrottled = useMemo( + () => + throttle( + () => { + if (channel) { + setRead(channel); + } + }, + stateUpdateThrottleInterval, + throttleOptions, + ), + [channel, stateUpdateThrottleInterval, setRead], + ); + + const copyMessagesStateFromChannelThrottled = useMemo( + () => + throttle( + () => { + if (channel) { + copyMessagesStateFromChannel(channel); + } + }, + newMessageStateUpdateThrottleInterval, + throttleOptions, + ), + [channel, newMessageStateUpdateThrottleInterval, copyMessagesStateFromChannel], + ); const copyChannelState = useMemo( () => @@ -759,13 +785,13 @@ const ChannelWithContext = < copyMessagesStateFromChannel(channel); } }, - copyChannelStateThrottlingTime, + stateUpdateThrottleInterval, throttleOptions, ), - [channel, copyChannelStateThrottlingTime, copyMessagesStateFromChannel, copyStateFromChannel], + [stateUpdateThrottleInterval, channel, copyStateFromChannel, copyMessagesStateFromChannel], ); - const handleEvent: EventHandler = (event) => { + const handleEvent: EventHandler = useStableCallback((event) => { if (shouldSyncChannel) { /** * Ignore user.watching.start and user.watching.stop as we should not copy the entire state when @@ -781,9 +807,11 @@ const ChannelWithContext = < } // 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); + if (event.type === 'typing.start' || event.type === 'typing.stop') { + if (event.user?.id !== client.userID) { + setTyping(channel); + } + return; } else { if (thread?.id) { const updatedThreadMessages = @@ -817,17 +845,35 @@ const ChannelWithContext = < // only update channel state if the events are not the previously subscribed useEffect's subscription events if (channel && channel.initialized) { + // we skip the new message events if we've already done an optimistic update for the new message + if (event.type === 'message.new' || event.type === 'notification.message_new') { + const messageId = event.message?.id ?? ''; + if ( + event.user?.id !== client.userID || + !optimisticallyUpdatedNewMessages.has(messageId) + ) { + copyMessagesStateFromChannelThrottled(); + } + optimisticallyUpdatedNewMessages.delete(messageId); + return; + } + + if (event.type === 'message.read' || event.type === 'notification.mark_read') { + setReadThrottled(); + return; + } + copyChannelState(); } } - }; + }); useEffect(() => { let listener: ReturnType; const initChannel = async () => { setLastRead(new Date()); const unreadCount = channel.countUnread(); - if (!channel || !shouldSyncChannel || channel.offlineMode) { + if (!channel || !shouldSyncChannel) { return; } let errored = false; @@ -898,20 +944,6 @@ const ChannelWithContext = < return unsubscribe; }, [channel?.cid, client]); - /** - * Subscription to the Notification mark_read event. - */ - useEffect(() => { - const handleEvent: EventHandler = (event) => { - if (channel.cid === event.cid) { - setRead(channel); - } - }; - - const { unsubscribe } = client.on('notification.mark_read', handleEvent); - return unsubscribe; - }, [channel, client, setRead]); - const threadPropsExists = !!threadProps; useEffect(() => { @@ -944,7 +976,7 @@ const ChannelWithContext = < /** * CHANNEL METHODS */ - const markRead: ChannelContextValue['markRead'] = throttle( + const markReadInternal: ChannelContextValue['markRead'] = throttle( async (options?: MarkReadFunctionOptions) => { const { updateChannelUnreadState = true } = options ?? {}; if (!channel || channel?.disconnected || !clientChannelConfig?.read_events) { @@ -973,7 +1005,9 @@ const ChannelWithContext = < throttleOptions, ); - const reloadThread = async () => { + const markRead = useStableCallback(markReadInternal); + + const reloadThread = useStableCallback(async () => { if (!channel || !thread?.id) { return; } @@ -1006,9 +1040,9 @@ const ChannelWithContext = < setThreadLoadingMore(false); throw err; } - }; + }); - const resyncChannel = async () => { + const resyncChannel = useStableCallback(async () => { if (!channel || syncingChannelRef.current) { return; } @@ -1064,7 +1098,7 @@ const ChannelWithContext = < } syncingChannelRef.current = false; - }; + }); // resync channel is added to ref so that it can be used in useEffect without adding it as a dependency const resyncChannelRef = useRef(resyncChannel); @@ -1115,16 +1149,16 @@ const ChannelWithContext = < */ const clientChannelConfig = getChannelConfigSafely(); - const reloadChannel = async () => { + const reloadChannel = useStableCallback(async () => { try { await loadLatestMessages(); } catch (err) { console.warn('Reloading channel failed with error:', err); } - }; + }); const loadChannelAroundMessage: ChannelContextValue['loadChannelAroundMessage'] = - async ({ messageId: messageIdToLoadAround }): Promise => { + useStableCallback(async ({ messageId: messageIdToLoadAround }): Promise => { if (!messageIdToLoadAround) { return; } @@ -1155,350 +1189,354 @@ const ChannelWithContext = < } catch (err) { console.warn('Loading channel around message failed with error:', err); } - }; + }); /** * MESSAGE METHODS */ - const updateMessage: MessagesContextValue['updateMessage'] = ( - updatedMessage, - extraState = {}, - ) => { - if (!channel) { - return; - } + const updateMessage: MessagesContextValue['updateMessage'] = + useStableCallback((updatedMessage, extraState = {}, throttled = false) => { + if (!channel) { + return; + } - channel.state.addMessageSorted(updatedMessage, true); - copyMessagesStateFromChannel(channel); + channel.state.addMessageSorted(updatedMessage, true); + if (throttled) { + copyMessagesStateFromChannelThrottled(); + } else { + copyMessagesStateFromChannel(channel); + } - if (thread && updatedMessage.parent_id) { - extraState.threadMessages = channel.state.threads[updatedMessage.parent_id] || []; - setThreadMessages(extraState.threadMessages); - } - }; + if (thread && updatedMessage.parent_id) { + extraState.threadMessages = channel.state.threads[updatedMessage.parent_id] || []; + setThreadMessages(extraState.threadMessages); + } + }); - const replaceMessage = ( - oldMessage: MessageResponse, - newMessage: MessageResponse, - ) => { - if (channel) { - channel.state.removeMessage(oldMessage); - channel.state.addMessageSorted(newMessage, true); - copyMessagesStateFromChannel(channel); + const replaceMessage = useStableCallback( + ( + oldMessage: MessageResponse, + newMessage: MessageResponse, + ) => { + 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); + if (thread && newMessage.parent_id) { + const threadMessages = channel.state.threads[newMessage.parent_id] || []; + setThreadMessages(threadMessages); + } } - } - }; + }, + ); - const createMessagePreview = ({ - attachments, - mentioned_users, - parent_id, - poll, - poll_id, - text, - ...extraFields - }: Partial>) => { - // Exclude following properties from message.user within message preview, - // since they could be long arrays and have no meaning as sender of message. - // Storing such large value within user's table may cause sqlite queries to crash. - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { channel_mutes, devices, mutes, ...messageUser } = client.user; - - const preview = { - __html: text, + const createMessagePreview = useStableCallback( + ({ attachments, - created_at: new Date(), - html: text, - id: `${client.userID}-${generateRandomId()}`, - mentioned_users: - mentioned_users?.map((userId) => ({ - id: userId, - })) || [], + mentioned_users, parent_id, poll, poll_id, - reactions: [], - status: MessageStatusTypes.SENDING, text, - type: 'regular', - user: { - ...messageUser, - id: client.userID, - }, - ...extraFields, - } as unknown as MessageResponse; - - /** - * This is added to the message for local rendering prior to the message - * being returned from the backend, it is removed when the message is sent - * as quoted_message is a reserved field. - */ - if (preview.quoted_message_id) { - const quotedMessage = channelMessagesState.messages?.find( - (message) => message.id === preview.quoted_message_id, - ); + ...extraFields + }: Partial>) => { + // Exclude following properties from message.user within message preview, + // since they could be long arrays and have no meaning as sender of message. + // Storing such large value within user's table may cause sqlite queries to crash. + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { channel_mutes, devices, mutes, ...messageUser } = client.user; + + const preview = { + __html: text, + attachments, + created_at: new Date(), + html: text, + id: `${client.userID}-${generateRandomId()}`, + mentioned_users: + mentioned_users?.map((userId) => ({ + id: userId, + })) || [], + parent_id, + poll, + poll_id, + reactions: [], + status: MessageStatusTypes.SENDING, + text, + type: 'regular', + user: { + ...messageUser, + id: client.userID, + }, + ...extraFields, + } as unknown as MessageResponse; - preview.quoted_message = - quotedMessage as MessageResponse['quoted_message']; - } - return preview; - }; + /** + * This is added to the message for local rendering prior to the message + * being returned from the backend, it is removed when the message is sent + * as quoted_message is a reserved field. + */ + if (preview.quoted_message_id) { + const quotedMessage = channelMessagesState.messages?.find( + (message) => message.id === preview.quoted_message_id, + ); - const uploadPendingAttachments = async (message: MessageResponse) => { - const updatedMessage = { ...message }; - if (updatedMessage.attachments?.length) { - for (let i = 0; i < updatedMessage.attachments?.length; i++) { - const attachment = updatedMessage.attachments[i]; - const image = attachment.originalImage; - const file = attachment.originalFile; - // check if image_url is not a remote url - if ( - attachment.type === FileTypes.Image && - image?.uri && - attachment.image_url && - isLocalUrl(attachment.image_url) - ) { - const filename = image.name ?? getFileNameFromPath(image.uri); - // if any upload is in progress, cancel it - const controller = uploadAbortControllerRef.current.get(filename); - if (controller) { - controller.abort(); - uploadAbortControllerRef.current.delete(filename); - } - const compressedUri = await compressedImageURI(image, compressImageQuality); - const contentType = lookup(filename) || 'multipart/form-data'; + preview.quoted_message = + quotedMessage as MessageResponse['quoted_message']; + } + return preview; + }, + ); - const uploadResponse = doImageUploadRequest - ? await doImageUploadRequest(image, channel) - : await channel.sendImage(compressedUri, filename, contentType); + const uploadPendingAttachments = useStableCallback( + async (message: MessageResponse) => { + const updatedMessage = { ...message }; + if (updatedMessage.attachments?.length) { + for (let i = 0; i < updatedMessage.attachments?.length; i++) { + const attachment = updatedMessage.attachments[i]; + const image = attachment.originalImage; + const file = attachment.originalFile; + // check if image_url is not a remote url + if ( + attachment.type === FileTypes.Image && + image?.uri && + attachment.image_url && + isLocalUrl(attachment.image_url) + ) { + const filename = image.name ?? getFileNameFromPath(image.uri); + // if any upload is in progress, cancel it + const controller = uploadAbortControllerRef.current.get(filename); + if (controller) { + controller.abort(); + uploadAbortControllerRef.current.delete(filename); + } + const compressedUri = await compressedImageURI(image, compressImageQuality); + const contentType = lookup(filename) || 'multipart/form-data'; - attachment.image_url = uploadResponse.file; - delete attachment.originalFile; + const uploadResponse = doImageUploadRequest + ? await doImageUploadRequest(image, channel) + : await channel.sendImage(compressedUri, filename, contentType); - await dbApi.updateMessage({ - message: { ...updatedMessage, cid: channel.cid }, - }); - } + attachment.image_url = uploadResponse.file; + delete attachment.originalFile; - if ( - (attachment.type === FileTypes.File || - attachment.type === FileTypes.Audio || - attachment.type === FileTypes.VoiceRecording || - attachment.type === FileTypes.Video) && - attachment.asset_url && - isLocalUrl(attachment.asset_url) && - file?.uri - ) { - // if any upload is in progress, cancel it - const controller = uploadAbortControllerRef.current.get(file.name); - if (controller) { - controller.abort(); - uploadAbortControllerRef.current.delete(file.name); - } - const response = doDocUploadRequest - ? await doDocUploadRequest(file, channel) - : await channel.sendFile(file.uri, file.name, file.mimeType); - attachment.asset_url = response.file; - if (response.thumb_url) { - attachment.thumb_url = response.thumb_url; + await dbApi.updateMessage({ + message: { ...updatedMessage, cid: channel.cid }, + }); } - delete attachment.originalFile; - await dbApi.updateMessage({ - message: { ...updatedMessage, cid: channel.cid }, - }); + if ( + (attachment.type === FileTypes.File || + attachment.type === FileTypes.Audio || + attachment.type === FileTypes.VoiceRecording || + attachment.type === FileTypes.Video) && + attachment.asset_url && + isLocalUrl(attachment.asset_url) && + file?.uri + ) { + // if any upload is in progress, cancel it + const controller = uploadAbortControllerRef.current.get(file.name); + if (controller) { + controller.abort(); + uploadAbortControllerRef.current.delete(file.name); + } + const response = doDocUploadRequest + ? await doDocUploadRequest(file, channel) + : await channel.sendFile(file.uri, file.name, file.mimeType); + attachment.asset_url = response.file; + if (response.thumb_url) { + attachment.thumb_url = response.thumb_url; + } + + delete attachment.originalFile; + await dbApi.updateMessage({ + message: { ...updatedMessage, cid: channel.cid }, + }); + } } } - } - - return updatedMessage; - }; - const sendMessageRequest = async ( - message: MessageResponse, - retrying?: boolean, - ) => { - try { - const updatedMessage = await uploadPendingAttachments(message); - const extraFields = omit(updatedMessage, [ - '__html', - 'attachments', - 'created_at', - 'deleted_at', - 'html', - 'id', - 'latest_reactions', - 'mentioned_users', - 'own_reactions', - 'parent_id', - 'quoted_message', - 'reaction_counts', - 'reaction_groups', - 'reactions', - 'status', - 'text', - 'type', - 'updated_at', - 'user', - ]); - const { attachments, id, mentioned_users, parent_id, text } = updatedMessage; - if (!channel.id) { - return; - } + return updatedMessage; + }, + ); - const mentionedUserIds = mentioned_users?.map((user) => user.id) || []; + const sendMessageRequest = useStableCallback( + async (message: MessageResponse, retrying?: boolean) => { + try { + const updatedMessage = await uploadPendingAttachments(message); + const extraFields = omit(updatedMessage, [ + '__html', + 'attachments', + 'created_at', + 'deleted_at', + 'html', + 'id', + 'latest_reactions', + 'mentioned_users', + 'own_reactions', + 'parent_id', + 'quoted_message', + 'reaction_counts', + 'reaction_groups', + 'reactions', + 'status', + 'text', + 'type', + 'updated_at', + 'user', + ]); + const { attachments, id, mentioned_users, parent_id, text } = updatedMessage; + if (!channel.id) { + return; + } - const messageData = { - attachments, - id, - mentioned_users: mentionedUserIds, - parent_id, - text: patchMessageTextCommand(text ?? '', mentionedUserIds), - ...extraFields, - } as StreamMessage; + const mentionedUserIds = mentioned_users?.map((user) => user.id) || []; + + const messageData = { + attachments, + id, + mentioned_users: mentionedUserIds, + parent_id, + text: patchMessageTextCommand(text ?? '', mentionedUserIds), + ...extraFields, + } as StreamMessage; + + let messageResponse = {} as SendMessageAPIResponse; + if (doSendMessageRequest) { + messageResponse = await doSendMessageRequest(channel?.cid || '', messageData); + } else if (channel) { + messageResponse = await channel.sendMessage(messageData); + } - let messageResponse = {} as SendMessageAPIResponse; - if (doSendMessageRequest) { - messageResponse = await doSendMessageRequest(channel?.cid || '', messageData); - } else if (channel) { - messageResponse = await channel.sendMessage(messageData); - } + if (messageResponse.message) { + messageResponse.message.status = MessageStatusTypes.RECEIVED; - if (messageResponse.message) { - messageResponse.message.status = MessageStatusTypes.RECEIVED; + if (enableOfflineSupport) { + await dbApi.updateMessage({ + message: { ...messageResponse.message, cid: channel.cid }, + }); + } + if (retrying) { + replaceMessage(message, messageResponse.message); + } else { + updateMessage(messageResponse.message, {}, true); + } + } + } catch (err) { + console.log(err); + message.status = MessageStatusTypes.FAILED; + const updatedMessage = { ...message, cid: channel.cid }; + updateMessage(updatedMessage); + threadInstance?.upsertReplyLocally?.({ message: updatedMessage }); + optimisticallyUpdatedNewMessages.delete(message.id); if (enableOfflineSupport) { await dbApi.updateMessage({ - message: { ...messageResponse.message, cid: channel.cid }, + message: { ...message, cid: channel.cid }, }); } - if (retrying) { - replaceMessage(message, messageResponse.message); - } else { - updateMessage(messageResponse.message); - } } - } catch (err) { - console.log(err); - message.status = MessageStatusTypes.FAILED; - const updatedMessage = { ...message, cid: channel.cid }; - updateMessage(updatedMessage); - threadInstance?.upsertReplyLocally?.({ message: updatedMessage }); + }, + ); - if (enableOfflineSupport) { - await dbApi.updateMessage({ - message: { ...message, cid: channel.cid }, - }); + const sendMessage: InputMessageInputContextValue['sendMessage'] = + useStableCallback(async (message) => { + if (channel?.state?.filterErrorMessages) { + channel.state.filterErrorMessages(); } - } - }; - const sendMessage: InputMessageInputContextValue['sendMessage'] = async ( - message, - ) => { - if (channel?.state?.filterErrorMessages) { - channel.state.filterErrorMessages(); - } + const messagePreview = createMessagePreview({ + ...message, + attachments: message.attachments || [], + }); - const messagePreview = createMessagePreview({ - ...message, - attachments: message.attachments || [], - }); + updateMessage(messagePreview, { + commands: [], + messageInput: '', + }); + threadInstance?.upsertReplyLocally?.({ message: messagePreview }); + optimisticallyUpdatedNewMessages.add(messagePreview.id); - updateMessage(messagePreview, { - commands: [], - messageInput: '', - }); - threadInstance?.upsertReplyLocally?.({ message: messagePreview }); + if (enableOfflineSupport) { + // While sending a message, we add the message to local db with failed status, so that + // if app gets closed before message gets sent and next time user opens the app + // then user can see that message in failed state and can retry. + // If succesfull, it will be updated with received status. + await dbApi.upsertMessages({ + messages: [{ ...messagePreview, cid: channel.cid, status: MessageStatusTypes.FAILED }], + }); + } - if (enableOfflineSupport) { - // While sending a message, we add the message to local db with failed status, so that - // if app gets closed before message gets sent and next time user opens the app - // then user can see that message in failed state and can retry. - // If succesfull, it will be updated with received status. - await dbApi.upsertMessages({ - messages: [{ ...messagePreview, cid: channel.cid, status: MessageStatusTypes.FAILED }], - }); - } + await sendMessageRequest(messagePreview); + }); - await sendMessageRequest(messagePreview); - }; + const retrySendMessage: MessagesContextValue['retrySendMessage'] = + useStableCallback(async (message) => { + const statusPendingMessage = { + ...message, + status: MessageStatusTypes.SENDING, + }; - const retrySendMessage: MessagesContextValue['retrySendMessage'] = async ( - message, - ) => { - const statusPendingMessage = { - ...message, - status: MessageStatusTypes.SENDING, - }; + const messageWithoutReservedFields = removeReservedFields(statusPendingMessage); - const messageWithoutReservedFields = removeReservedFields(statusPendingMessage); + // For bounced messages, we don't need to update the message, instead always send a new message. + if (!isBouncedMessage(message)) { + updateMessage(messageWithoutReservedFields as MessageResponse); + } - // For bounced messages, we don't need to update the message, instead always send a new message. - if (!isBouncedMessage(message)) { - updateMessage(messageWithoutReservedFields as MessageResponse); - } + await sendMessageRequest( + messageWithoutReservedFields as MessageResponse, + true, + ); + }); - await sendMessageRequest( - messageWithoutReservedFields as MessageResponse, - true, + const editMessage: InputMessageInputContextValue['editMessage'] = + useStableCallback((updatedMessage) => + doUpdateMessageRequest + ? doUpdateMessageRequest(channel?.cid || '', updatedMessage) + : client.updateMessage(updatedMessage), ); - }; - const editMessage: InputMessageInputContextValue['editMessage'] = ( - updatedMessage, - ) => - doUpdateMessageRequest - ? doUpdateMessageRequest(channel?.cid || '', updatedMessage) - : client.updateMessage(updatedMessage); - - const setEditingState: MessagesContextValue['setEditingState'] = ( - message, - ) => { - clearQuotedMessageState(); - setEditing(message); - }; + const setEditingState: MessagesContextValue['setEditingState'] = + useStableCallback((message) => { + clearQuotedMessageState(); + setEditing(message); + }); - const setQuotedMessageState: MessagesContextValue['setQuotedMessageState'] = ( - messageOrBoolean, - ) => { - setQuotedMessage(messageOrBoolean); - }; + const setQuotedMessageState: MessagesContextValue['setQuotedMessageState'] = + useStableCallback((messageOrBoolean) => { + setQuotedMessage(messageOrBoolean); + }); const clearEditingState: InputMessageInputContextValue['clearEditingState'] = - () => setEditing(undefined); + useStableCallback(() => setEditing(undefined)); const clearQuotedMessageState: InputMessageInputContextValue['clearQuotedMessageState'] = - () => setQuotedMessage(undefined); + useStableCallback(() => setQuotedMessage(undefined)); /** * Removes the message from local state */ - const removeMessage: MessagesContextValue['removeMessage'] = async ( - message, - ) => { - if (channel) { - channel.state.removeMessage(message); - copyMessagesStateFromChannel(channel); + const removeMessage: MessagesContextValue['removeMessage'] = + useStableCallback(async (message) => { + if (channel) { + channel.state.removeMessage(message); + copyMessagesStateFromChannel(channel); - if (thread) { - setThreadMessages(channel.state.threads[thread.id] || []); + if (thread) { + setThreadMessages(channel.state.threads[thread.id] || []); + } } - } - if (enableOfflineSupport) { - await dbApi.deleteMessage({ - id: message.id, - }); - } - }; + if (enableOfflineSupport) { + await dbApi.deleteMessage({ + id: message.id, + }); + } + }); - const sendReaction = async (type: string, messageId: string) => { + const sendReaction = useStableCallback(async (type: string, messageId: string) => { if (!channel?.id || !client.user) { throw new Error('Channel has not been initialized'); } @@ -1539,91 +1577,88 @@ const ChannelWithContext = < if (sendReactionResponse?.message) { threadInstance?.upsertReplyLocally?.({ message: sendReactionResponse.message }); } - }; + }); - const deleteMessage: MessagesContextValue['deleteMessage'] = async ( - message, - ) => { - if (!channel.id) { - throw new Error('Channel has not been initialized yet'); - } + const deleteMessage: MessagesContextValue['deleteMessage'] = + useStableCallback(async (message) => { + if (!channel.id) { + throw new Error('Channel has not been initialized yet'); + } + + if (!enableOfflineSupport) { + if (message.status === MessageStatusTypes.FAILED) { + await removeMessage(message); + return; + } + await client.deleteMessage(message.id); + return; + } - if (!enableOfflineSupport) { if (message.status === MessageStatusTypes.FAILED) { + await DBSyncManager.dropPendingTasks({ messageId: message.id }); await removeMessage(message); + } else { + const updatedMessage = { + ...message, + cid: channel.cid, + deleted_at: new Date().toISOString(), + type: 'deleted', + }; + updateMessage(updatedMessage); + + threadInstance?.upsertReplyLocally({ message: updatedMessage }); + + const data = await DBSyncManager.queueTask({ + client, + task: { + channelId: channel.id, + channelType: channel.type, + messageId: message.id, + payload: [message.id], + type: 'delete-message', + }, + }); + + if (data?.message) { + updateMessage({ ...data.message }); + } + } + }); + + const deleteReaction: MessagesContextValue['deleteReaction'] = + useStableCallback(async (type: string, messageId: string) => { + if (!channel?.id || !client.user) { + throw new Error('Channel has not been initialized'); + } + + const payload: Parameters = [messageId, type]; + + if (!enableOfflineSupport) { + await channel.deleteReaction(...payload); return; } - await client.deleteMessage(message.id); - return; - } - if (message.status === MessageStatusTypes.FAILED) { - await DBSyncManager.dropPendingTasks({ messageId: message.id }); - await removeMessage(message); - } else { - const updatedMessage = { - ...message, - cid: channel.cid, - deleted_at: new Date().toISOString(), - type: 'deleted', - }; - updateMessage(updatedMessage); + removeReactionFromLocalState({ + channel, + messageId, + reactionType: type, + user: client.user, + }); - threadInstance?.upsertReplyLocally({ message: updatedMessage }); + copyMessagesStateFromChannel(channel); - const data = await DBSyncManager.queueTask({ + await DBSyncManager.queueTask({ client, task: { channelId: channel.id, channelType: channel.type, - messageId: message.id, - payload: [message.id], - type: 'delete-message', + messageId, + payload, + type: 'delete-reaction', }, }); - - if (data?.message) { - updateMessage({ ...data.message }); - } - } - }; - - const deleteReaction: MessagesContextValue['deleteReaction'] = async ( - type: string, - messageId: string, - ) => { - if (!channel?.id || !client.user) { - throw new Error('Channel has not been initialized'); - } - - const payload: Parameters = [messageId, type]; - - if (!enableOfflineSupport) { - await channel.deleteReaction(...payload); - return; - } - - removeReactionFromLocalState({ - channel, - messageId, - reactionType: type, - user: client.user, }); - copyMessagesStateFromChannel(channel); - - await DBSyncManager.queueTask({ - client, - task: { - channelId: channel.id, - channelType: channel.type, - messageId, - payload, - type: 'delete-reaction', - }, - }); - }; - /** * THREAD METHODS */ @@ -1664,46 +1699,47 @@ const ChannelWithContext = < ), ).current; - const loadMoreThread: ThreadContextValue['loadMoreThread'] = async () => { - if (threadLoadingMore || !thread?.id) { - return; - } - setThreadLoadingMore(true); + const loadMoreThread: ThreadContextValue['loadMoreThread'] = + useStableCallback(async () => { + if (threadLoadingMore || !thread?.id) { + return; + } + setThreadLoadingMore(true); - try { - if (channel) { - const parentID = thread.id; - - /** - * In the channel is re-initializing, then threads may get wiped out during the process - * (check `addMessagesSorted` method on channel.state). In those cases, we still want to - * preserve the messages on active thread, so lets simply copy messages from UI state to - * `channel.state`. - */ - channel.state.threads[parentID] = threadMessages; - const oldestMessageID = threadMessages?.[0]?.id; - - const limit = 50; - const queryResponse = await channel.getReplies(parentID, { - id_lt: oldestMessageID, - limit, - }); + try { + if (channel) { + const parentID = thread.id; + + /** + * In the channel is re-initializing, then threads may get wiped out during the process + * (check `addMessagesSorted` method on channel.state). In those cases, we still want to + * preserve the messages on active thread, so lets simply copy messages from UI state to + * `channel.state`. + */ + channel.state.threads[parentID] = threadMessages; + const oldestMessageID = threadMessages?.[0]?.id; + + const limit = 50; + const queryResponse = await channel.getReplies(parentID, { + id_lt: oldestMessageID, + limit, + }); - const updatedHasMore = queryResponse.messages.length === limit; - const updatedThreadMessages = channel.state.threads[parentID] || []; - loadMoreThreadFinished(updatedHasMore, updatedThreadMessages); - } - } catch (err) { - console.warn('Message pagination request failed with error', err); - if (err instanceof Error) { - setError(err); - } else { - setError(true); + const updatedHasMore = queryResponse.messages.length === limit; + const updatedThreadMessages = channel.state.threads[parentID] || []; + loadMoreThreadFinished(updatedHasMore, updatedThreadMessages); + } + } catch (err) { + console.warn('Message pagination request failed with error', err); + if (err instanceof Error) { + setError(err); + } else { + setError(true); + } + setThreadLoadingMore(false); + throw err; } - setThreadLoadingMore(false); - throw err; - } - }; + }); const ownCapabilitiesContext = useCreateOwnCapabilitiesContext({ channel, @@ -1755,9 +1791,11 @@ const ChannelWithContext = < // but it is definitely not trivial, especially considering it depends on other inline functions that // are not wrapped in a useCallback() themselves hence creating a huge cascading change. Can be removed // once our memoization issues are fixed in most places in the app or we move to a reactive state store. - const sendMessageRef = - useRef['sendMessage']>(sendMessage); - sendMessageRef.current = sendMessage; + // const sendMessageRef = useRef(sendMessage); + // sendMessageRef.current = sendMessage; + // const sendMessageStable = useCallback((...args) => { + // return sendMessageRef.current(...args); + // }, []); const inputMessageInputContext = useCreateInputMessageInputContext({ additionalTextInputProps, @@ -1811,7 +1849,7 @@ const ChannelWithContext = < quotedMessage, SendButton, sendImageAsync, - sendMessage: (...args) => sendMessageRef.current(...args), + sendMessage, SendMessageDisallowedIndicator, setInputRef, setQuotedMessageState, @@ -1944,11 +1982,13 @@ const ChannelWithContext = < VideoThumbnail, }); - const suggestionsContext = { - AutoCompleteSuggestionHeader, - AutoCompleteSuggestionItem, - AutoCompleteSuggestionList, - }; + const suggestionsContext = useMemo(() => { + return { + AutoCompleteSuggestionHeader, + AutoCompleteSuggestionItem, + AutoCompleteSuggestionList, + }; + }, [AutoCompleteSuggestionHeader, AutoCompleteSuggestionItem, AutoCompleteSuggestionList]); const threadContext = useCreateThreadContext({ allowThreadMessagesInChannel, diff --git a/package/src/components/Channel/__tests__/Channel.test.js b/package/src/components/Channel/__tests__/Channel.test.js index 755f3a28f4..7f263ed528 100644 --- a/package/src/components/Channel/__tests__/Channel.test.js +++ b/package/src/components/Channel/__tests__/Channel.test.js @@ -356,7 +356,7 @@ describe('Channel initial load useEffect', () => { cleanup(); }); - it('should not call channel.watch if channel is not initialized', async () => { + it('should still call channel.watch if we are online and DB channels are loaded', async () => { const messages = Array.from({ length: 10 }, (_, i) => generateMessage({ id: i })); const mockedChannel = generateChannelResponse({ messages, @@ -366,13 +366,18 @@ describe('Channel initial load useEffect', () => { const channel = chatClient.channel('messaging', mockedChannel.id); await channel.watch(); channel.offlineMode = true; - channel.state = channelInitialState; + channel.state = { + ...channelInitialState, + messagePagination: { + hasPrev: true, + }, + }; const watchSpy = jest.fn(); channel.watch = watchSpy; renderComponent({ channel }); - await waitFor(() => expect(watchSpy).not.toHaveBeenCalled()); + await waitFor(() => expect(watchSpy).toHaveBeenCalledTimes(1)); }); it("should call channel.watch if channel is initialized and it's not in offline mode", async () => { diff --git a/package/src/components/Channel/hooks/useMessageListPagination.tsx b/package/src/components/Channel/hooks/useMessageListPagination.tsx index 7f4e2a07f5..29e7d62162 100644 --- a/package/src/components/Channel/hooks/useMessageListPagination.tsx +++ b/package/src/components/Channel/hooks/useMessageListPagination.tsx @@ -6,6 +6,7 @@ import { Channel, ChannelState, MessageResponse } from 'stream-chat'; import { useChannelMessageDataState } from './useChannelDataState'; import { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; +import { useStableCallback } from '../../../hooks'; import { DefaultStreamChatGenerics } from '../../../types/types'; import { findInMessagesByDate, findInMessagesById } from '../../../utils/utils'; @@ -66,7 +67,7 @@ export const useMessageListPagination = < /** * This function loads the latest messages in the channel. */ - const loadLatestMessages = async () => { + const loadLatestMessages = useStableCallback(async () => { try { setLoading(true); await channel.state.loadMessageIntoState('latest'); @@ -75,12 +76,12 @@ export const useMessageListPagination = < } 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) => { + const loadMore = useStableCallback(async (limit: number = 20) => { if (!channel.state.messagePagination.hasPrev) { return; } @@ -104,12 +105,12 @@ export const useMessageListPagination = < 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) => { + const loadMoreRecent = useStableCallback(async (limit: number = 10) => { if (!channel.state.messagePagination.hasNext) { return; } @@ -133,7 +134,7 @@ export const useMessageListPagination = < console.warn('Message pagination(fetching new messages) request failed with error:', e); return; } - }; + }); /** * Loads channel around a specific message @@ -141,171 +142,175 @@ export const useMessageListPagination = < * @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); + useStableCallback( + 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); + if (setTargetedMessage) { + setTargetedMessage(messageIdToLoadAround); + } + } catch (error) { + setLoadingMore(false); + setLoading(false); + console.warn( + 'Message pagination(fetching messages in the channel around a message id) request failed with error:', + error, + ); + return; } - } catch (error) { - setLoadingMore(false); - setLoading(false); - console.warn( - 'Message pagination(fetching messages in the channel around a message id) request failed with error:', - error, - ); - return; - } - }; + }, + ); /** * Fetch messages around a specific timestamp. */ - const fetchMessagesAround = async < - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, - >( - channel: Channel, - timestamp: string, - limit: number, - ): Promise[]> => { - try { - const { messages } = await channel.query( - { messages: { created_at_around: timestamp, limit } }, - 'new', - ); - return messages; - } catch (error) { - console.error('Error fetching messages around timestamp:', error); - throw error; - } - }; + const fetchMessagesAround = useStableCallback( + async ( + channel: Channel, + timestamp: string, + limit: number, + ): Promise[]> => { + try { + const { messages } = await channel.query( + { messages: { created_at_around: timestamp, limit } }, + 'new', + ); + return messages; + } catch (error) { + console.error('Error fetching messages around timestamp:', error); + throw error; + } + }, + ); /** * Loads channel at first unread message. */ const loadChannelAtFirstUnreadMessage: ChannelContextValue['loadChannelAtFirstUnreadMessage'] = - async ({ channelUnreadState, limit = 25, setChannelUnreadState, setTargetedMessage }) => { - try { - if (!channelUnreadState?.unread_messages) { - return; - } - const { first_unread_message_id, last_read, last_read_message_id } = channelUnreadState; - let firstUnreadMessageId = first_unread_message_id; - let lastReadMessageId = last_read_message_id; - let isInCurrentMessageSet = false; - const messagesState = channel.state.messages; + useStableCallback( + async ({ channelUnreadState, limit = 25, setChannelUnreadState, setTargetedMessage }) => { + try { + if (!channelUnreadState?.unread_messages) { + return; + } + const { first_unread_message_id, last_read, last_read_message_id } = channelUnreadState; + let firstUnreadMessageId = first_unread_message_id; + let lastReadMessageId = last_read_message_id; + let isInCurrentMessageSet = false; + const messagesState = channel.state.messages; - // If the first unread message is already in the current message set, we don't need to load more messages. - if (firstUnreadMessageId) { - const messageIdx = findInMessagesById(messagesState, firstUnreadMessageId); - isInCurrentMessageSet = messageIdx !== -1; - } - // If the last read message is already in the current message set, we don't need to load more messages, and we set the first unread message id as that is what we want to operate on. - else if (lastReadMessageId) { - const messageIdx = findInMessagesById(messagesState, lastReadMessageId); - isInCurrentMessageSet = messageIdx !== -1; - firstUnreadMessageId = messageIdx > -1 ? messagesState[messageIdx + 1]?.id : undefined; - } else { - const lastReadTimestamp = last_read.getTime(); - const { index: lastReadIdx, message: lastReadMessage } = findInMessagesByDate( - messagesState, - last_read, - ); - if (lastReadMessage) { - lastReadMessageId = lastReadMessage.id; - firstUnreadMessageId = messagesState[lastReadIdx + 1].id; - isInCurrentMessageSet = !!firstUnreadMessageId; + // If the first unread message is already in the current message set, we don't need to load more messages. + if (firstUnreadMessageId) { + const messageIdx = findInMessagesById(messagesState, firstUnreadMessageId); + isInCurrentMessageSet = messageIdx !== -1; + } + // If the last read message is already in the current message set, we don't need to load more messages, and we set the first unread message id as that is what we want to operate on. + else if (lastReadMessageId) { + const messageIdx = findInMessagesById(messagesState, lastReadMessageId); + isInCurrentMessageSet = messageIdx !== -1; + firstUnreadMessageId = messageIdx > -1 ? messagesState[messageIdx + 1]?.id : undefined; } else { - setLoadingMore(true); - setLoading(true); - let messages; - try { - messages = await fetchMessagesAround(channel, last_read.toISOString(), limit); - } catch (error) { - setLoading(false); - loadMoreFinished(channel.state.messagePagination.hasPrev, messagesState); - console.log('Loading channel at first unread message failed with error:', error); - return; - } + const lastReadTimestamp = last_read.getTime(); + const { index: lastReadIdx, message: lastReadMessage } = findInMessagesByDate( + messagesState, + last_read, + ); + if (lastReadMessage) { + lastReadMessageId = lastReadMessage.id; + firstUnreadMessageId = messagesState[lastReadIdx + 1].id; + isInCurrentMessageSet = !!firstUnreadMessageId; + } else { + setLoadingMore(true); + setLoading(true); + let messages; + try { + messages = await fetchMessagesAround(channel, last_read.toISOString(), limit); + } catch (error) { + setLoading(false); + loadMoreFinished(channel.state.messagePagination.hasPrev, messagesState); + console.log('Loading channel at first unread message failed with error:', error); + return; + } - const firstMessageWithCreationDate = messages.find((msg) => msg.created_at); - if (!firstMessageWithCreationDate) { - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - throw new Error('Failed to jump to first unread message id.'); - } - const firstMessageTimestamp = new Date( - firstMessageWithCreationDate.created_at as string, - ).getTime(); + const firstMessageWithCreationDate = messages.find((msg) => msg.created_at); + if (!firstMessageWithCreationDate) { + loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); + throw new Error('Failed to jump to first unread message id.'); + } + const firstMessageTimestamp = new Date( + firstMessageWithCreationDate.created_at as string, + ).getTime(); - if (lastReadTimestamp < firstMessageTimestamp) { - // whole channel is unread - firstUnreadMessageId = firstMessageWithCreationDate.id; - } else { - const result = findInMessagesByDate(messages, last_read); - lastReadMessageId = result.message?.id; + if (lastReadTimestamp < firstMessageTimestamp) { + // whole channel is unread + firstUnreadMessageId = firstMessageWithCreationDate.id; + } else { + const result = findInMessagesByDate(messages, last_read); + lastReadMessageId = result.message?.id; + } + loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); } - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); } - } - // If we still don't have the first and last read message id, we can't proceed. - if (!firstUnreadMessageId && !lastReadMessageId) { - throw new Error('Failed to jump to first unread message id.'); - } + // If we still don't have the first and last read message id, we can't proceed. + if (!firstUnreadMessageId && !lastReadMessageId) { + throw new Error('Failed to jump to first unread message id.'); + } - // If the first unread message is not in the current message set, we need to load message around the id. - if (!isInCurrentMessageSet) { - try { - setLoadingMore(true); - setLoading(true); - const targetedMessage = (firstUnreadMessageId || lastReadMessageId) as string; - await channel.state.loadMessageIntoState(targetedMessage, undefined, limit); - /** - * if the index of the last read message on the page is beyond the half of the page, - * we have arrived to the oldest page of the channel - */ - const indexOfTarget = channel.state.messages.findIndex( - (message) => message.id === targetedMessage, - ); + // If the first unread message is not in the current message set, we need to load message around the id. + if (!isInCurrentMessageSet) { + try { + setLoadingMore(true); + setLoading(true); + const targetedMessage = (firstUnreadMessageId || lastReadMessageId) as string; + await channel.state.loadMessageIntoState(targetedMessage, undefined, limit); + /** + * if the index of the last read message on the page is beyond the half of the page, + * we have arrived to the oldest page of the channel + */ + const indexOfTarget = channel.state.messages.findIndex( + (message) => message.id === targetedMessage, + ); - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - firstUnreadMessageId = - firstUnreadMessageId ?? channel.state.messages[indexOfTarget + 1].id; - } catch (error) { - setLoading(false); - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - console.log('Loading channel at first unread message failed with error:', error); - return; + loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); + firstUnreadMessageId = + firstUnreadMessageId ?? channel.state.messages[indexOfTarget + 1].id; + } catch (error) { + setLoading(false); + loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); + console.log('Loading channel at first unread message failed with error:', error); + return; + } } - } - if (!firstUnreadMessageId) { - throw new Error('Failed to jump to first unread message id.'); - } - if (!first_unread_message_id && setChannelUnreadState) { - setChannelUnreadState({ - ...channelUnreadState, - first_unread_message_id: firstUnreadMessageId, - last_read_message_id: lastReadMessageId, - }); - } + if (!firstUnreadMessageId) { + throw new Error('Failed to jump to first unread message id.'); + } + if (!first_unread_message_id && setChannelUnreadState) { + setChannelUnreadState({ + ...channelUnreadState, + first_unread_message_id: firstUnreadMessageId, + last_read_message_id: lastReadMessageId, + }); + } - jumpToMessageFinished(channel.state.messagePagination.hasNext, firstUnreadMessageId); - if (setTargetedMessage) { - setTargetedMessage(firstUnreadMessageId); + jumpToMessageFinished(channel.state.messagePagination.hasNext, firstUnreadMessageId); + if (setTargetedMessage) { + setTargetedMessage(firstUnreadMessageId); + } + } catch (error) { + console.log('Loading channel at first unread message failed with error:', error); } - } catch (error) { - console.log('Loading channel at first unread message failed with error:', error); - } - }; + }, + ); return { copyMessagesStateFromChannel, diff --git a/package/src/components/KeyboardCompatibleView/KeyboardCompatibleView.tsx b/package/src/components/KeyboardCompatibleView/KeyboardCompatibleView.tsx index c4726c0615..8f5c77f8f8 100644 --- a/package/src/components/KeyboardCompatibleView/KeyboardCompatibleView.tsx +++ b/package/src/components/KeyboardCompatibleView/KeyboardCompatibleView.tsx @@ -197,6 +197,8 @@ export class KeyboardCompatibleView extends React.Component< this.unsetKeyboardListeners(); } + keyboardContextValue = { dismissKeyboard: this.dismissKeyboard }; + render() { const { behavior, children, contentContainerStyle, enabled, style, ...props } = this.props; const bottomHeight = enabled ? this.state.bottom : 0; @@ -215,7 +217,7 @@ export class KeyboardCompatibleView extends React.Component< }; } return ( - + + + + {children} diff --git a/package/src/components/Message/MessageSimple/MessageSimple.tsx b/package/src/components/Message/MessageSimple/MessageSimple.tsx index fe476b53dd..693e18a762 100644 --- a/package/src/components/Message/MessageSimple/MessageSimple.tsx +++ b/package/src/components/Message/MessageSimple/MessageSimple.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { Dimensions, LayoutChangeEvent, StyleSheet, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; @@ -12,6 +12,8 @@ import Animated, { withSpring, } from 'react-native-reanimated'; +const AnimatedWrapper = Animated.createAnimatedComponent(View); + import { MessageContextValue, useMessageContext, @@ -205,77 +207,104 @@ const MessageSimpleWithContext = < const translateX = useSharedValue(0); const touchStart = useSharedValue<{ x: number; y: number } | null>(null); + const isSwiping = useSharedValue(false); + const [isBeingSwiped, setIsBeingSwiped] = useState(false); - const onSwipeToReply = () => { + const onSwipeToReply = useCallback(() => { clearQuotedMessageState(); setQuotedMessageState(message); - }; + }, [clearQuotedMessageState, message, setQuotedMessageState]); const THRESHOLD = 25; const triggerHaptic = NativeHandlers.triggerHaptic; - const swipeGesture = Gesture.Pan() - .hitSlop(messageSwipeToReplyHitSlop) - .onBegin((event) => { - touchStart.value = { x: event.x, y: event.y }; - }) - .onTouchesMove((event, state) => { - if (!touchStart.value || !event.changedTouches.length) { - state.fail(); - return; - } - - const xDiff = Math.abs(event.changedTouches[0].x - touchStart.value.x); - const yDiff = Math.abs(event.changedTouches[0].y - touchStart.value.y); - const isHorizontalPanning = xDiff > yDiff; - - if (isHorizontalPanning) { - state.activate(); - } else { - state.fail(); - } - }) - .onStart(() => { - translateX.value = 0; - }) - .onChange(({ translationX }) => { - if (translationX > 0) { - translateX.value = translationX; - } - }) - .onEnd(() => { - if (translateX.value >= THRESHOLD) { - runOnJS(onSwipeToReply)(); - if (triggerHaptic) { - runOnJS(triggerHaptic)('impactMedium'); - } - } - translateX.value = withSpring(0, { - dampingRatio: 1, - duration: 500, - overshootClamping: true, - stiffness: 1, - }); - }); - - const messageBubbleAnimatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: translateX.value }], - })); - - const swipeContentAnimatedStyle = useAnimatedStyle(() => ({ - opacity: interpolate(translateX.value, [0, THRESHOLD], [0, 1]), - transform: [ - { - translateX: interpolate( - translateX.value, - [0, THRESHOLD], - [-THRESHOLD, 0], - Extrapolation.CLAMP, - ), - }, - ], - })); + const swipeGesture = useMemo( + () => + Gesture.Pan() + .hitSlop(messageSwipeToReplyHitSlop) + .onBegin((event) => { + touchStart.value = { x: event.x, y: event.y }; + }) + .onTouchesMove((event, state) => { + if (!touchStart.value || !event.changedTouches.length) { + state.fail(); + return; + } + + const xDiff = Math.abs(event.changedTouches[0].x - touchStart.value.x); + const yDiff = Math.abs(event.changedTouches[0].y - touchStart.value.y); + const isHorizontalPanning = xDiff > yDiff; + + if (isHorizontalPanning) { + state.activate(); + isSwiping.value = true; + runOnJS(setIsBeingSwiped)(true); + } else { + state.fail(); + } + }) + .onStart(() => { + translateX.value = 0; + }) + .onChange(({ translationX }) => { + if (translationX > 0) { + translateX.value = translationX; + } + }) + .onEnd(() => { + if (translateX.value >= THRESHOLD) { + runOnJS(onSwipeToReply)(); + if (triggerHaptic) { + runOnJS(triggerHaptic)('impactMedium'); + } + } + translateX.value = withSpring( + 0, + { + dampingRatio: 1, + duration: 500, + overshootClamping: true, + stiffness: 1, + }, + () => { + isSwiping.value = false; + runOnJS(setIsBeingSwiped)(false); + }, + ); + }), + [isSwiping, messageSwipeToReplyHitSlop, onSwipeToReply, touchStart, translateX, triggerHaptic], + ); + + const messageBubbleAnimatedStyle = useAnimatedStyle( + () => + isSwiping.value + ? { + transform: [{ translateX: translateX.value }], + } + : {}, + [], + ); + + const swipeContentAnimatedStyle = useAnimatedStyle( + () => + isSwiping.value + ? { + opacity: interpolate(translateX.value, [0, THRESHOLD], [0, 1]), + transform: [ + { + translateX: interpolate( + translateX.value, + [0, THRESHOLD], + [-THRESHOLD, 0], + Extrapolation.CLAMP, + ), + }, + ], + } + : {}, + [], + ); const renderMessageBubble = useMemo( () => ( @@ -309,18 +338,31 @@ const MessageSimpleWithContext = < () => ( - - {MessageSwipeContent ? : null} - - {renderMessageBubble} + {isBeingSwiped ? ( + <> + + {MessageSwipeContent ? : null} + + + {renderMessageBubble} + + + ) : ( + renderMessageBubble + )} ), [ MessageSwipeContent, contentWrapper, + isBeingSwiped, messageBubbleAnimatedStyle, messageSwipeToReplyHitSlop, renderMessageBubble, diff --git a/package/src/components/MessageInput/InputButtons.tsx b/package/src/components/MessageInput/InputButtons.tsx index 4ffa702bee..b842a1f5a8 100644 --- a/package/src/components/MessageInput/InputButtons.tsx +++ b/package/src/components/MessageInput/InputButtons.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { StyleSheet, View } from 'react-native'; import { @@ -29,12 +29,12 @@ export type InputButtonsWithContextProps< | 'hasCommands' | 'hasFilePicker' | 'hasImagePicker' + | 'hasText' | 'MoreOptionsButton' | 'openCommandsPicker' | 'selectedPicker' | 'setShowMoreOptions' | 'showMoreOptions' - | 'text' | 'toggleAttachmentPicker' >; @@ -51,11 +51,11 @@ export const InputButtonsWithContext = < hasCommands, hasFilePicker, hasImagePicker, + hasText, MoreOptionsButton, openCommandsPicker, setShowMoreOptions, showMoreOptions, - text, } = props; const { @@ -64,6 +64,10 @@ export const InputButtonsWithContext = < }, } = useTheme(); + const handleShowMoreOptions = useCallback(() => { + setShowMoreOptions(true); + }, [setShowMoreOptions]); + const ownCapabilities = useOwnCapabilitiesContext(); if (giphyActive) { @@ -71,7 +75,7 @@ export const InputButtonsWithContext = < } return !showMoreOptions && (hasCameraPicker || hasImagePicker || hasFilePicker) && hasCommands ? ( - setShowMoreOptions(true)} /> + ) : ( <> {(hasCameraPicker || hasImagePicker || hasFilePicker) && ownCapabilities.uploadFile && ( @@ -81,7 +85,7 @@ export const InputButtonsWithContext = < )} - {hasCommands && !text && ( + {hasCommands && !hasText && ( @@ -100,9 +104,9 @@ const areEqual = (); @@ -188,12 +192,12 @@ export const InputButtons = < hasCommands, hasFilePicker, hasImagePicker, + hasText, MoreOptionsButton, openCommandsPicker, selectedPicker, setShowMoreOptions, showMoreOptions, - text, toggleAttachmentPicker, }} {...props} diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index c0202a1d06..34c1cd1004 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -108,7 +108,7 @@ type MessageInputPropsWithContext< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, > = Pick & Pick, 'isOnline'> & - Pick, 'members' | 'threadList' | 'watchers'> & + Pick, 'channel' | 'members' | 'threadList' | 'watchers'> & Pick< MessageInputContextValue, | 'additionalTextInputProps' @@ -198,6 +198,7 @@ const MessageInputWithContext = < AudioRecordingLockIndicator, AudioRecordingPreview, AutoCompleteSuggestionList, + channel, closeAttachmentPicker, closePollCreationDialog, cooldownEndsAt, @@ -746,7 +747,6 @@ const MessageInputWithContext = < })), }; - const { channel } = useChannelContext(); const { aiState } = useAIState(channel); const stopGenerating = useCallback(() => channel?.stopAIResponse(), [channel]); @@ -860,9 +860,8 @@ const MessageInputWithContext = < {shouldDisplayStopAIGeneration ? ( - ) : ( - isSendingButtonVisible() && - (cooldownRemainingSeconds ? ( + ) : isSendingButtonVisible() ? ( + cooldownRemainingSeconds ? ( ) : ( @@ -870,8 +869,8 @@ const MessageInputWithContext = < disabled={sending.current || !isValidMessage() || (giphyActive && !isOnline)} /> - )) - )} + ) + ) : null} {audioRecordingEnabled && isAudioRecorderAvailable() && !micLocked && ( (); + const { channel, members, threadList, watchers } = useChannelContext(); const { additionalTextInputProps, @@ -1263,6 +1269,7 @@ export const MessageInput = < AutoCompleteSuggestionHeader, AutoCompleteSuggestionItem, AutoCompleteSuggestionList, + channel, clearEditingState, clearQuotedMessageState, closeAttachmentPicker, diff --git a/package/src/components/MessageInput/MoreOptionsButton.tsx b/package/src/components/MessageInput/MoreOptionsButton.tsx index 300453cd39..813992adeb 100644 --- a/package/src/components/MessageInput/MoreOptionsButton.tsx +++ b/package/src/components/MessageInput/MoreOptionsButton.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type { GestureResponderEvent } from 'react-native'; -import { TouchableOpacity } from 'react-native-gesture-handler'; +import { TouchableOpacity } from 'react-native'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { CircleRight } from '../../icons/CircleRight'; diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 5434a65770..34922dae03 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -53,6 +53,7 @@ import { import { mergeThemes, ThemeProvider, useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; +import { useStableCallback } from '../../hooks'; import { DefaultStreamChatGenerics, FileTypes } from '../../types/types'; // This is just to make sure that the scrolling happens in a different task queue. @@ -362,6 +363,14 @@ const MessageListWithContext = < const [autoscrollToRecent, setAutoscrollToRecent] = useState(false); + const maintainVisibleContentPosition = useMemo( + () => ({ + autoscrollToTopThreshold: autoscrollToRecent ? 10 : undefined, + minIndexForVisible: 1, + }), + [autoscrollToRecent], + ); + /** * We want to call onEndReached and onStartReached only once, per content length. * We keep track of calls to these functions per content length, with following trackers. @@ -392,8 +401,9 @@ const MessageListWithContext = < */ const messageIdLastScrolledToRef = useRef(undefined); const [hasMoved, setHasMoved] = useState(false); - const [lastReceivedId, setLastReceivedId] = useState( - getLastReceivedMessage(processedMessageList)?.id, + const lastReceivedId = useMemo( + () => getLastReceivedMessage(processedMessageList)?.id, + [processedMessageList], ); const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); @@ -404,7 +414,7 @@ const MessageListWithContext = < const channelRef = useRef(channel); channelRef.current = channel; - const updateStickyHeaderDateIfNeeded = (viewableItems: ViewToken[]) => { + const updateStickyHeaderDateIfNeeded = useStableCallback((viewableItems: ViewToken[]) => { if (!viewableItems.length) { return; } @@ -431,12 +441,12 @@ const MessageListWithContext = < setStickyHeaderDate(lastItem.item.created_at); } } - }; + }); /** * This function should show or hide the unread indicator depending on the */ - const updateStickyUnreadIndicator = (viewableItems: ViewToken[]) => { + const updateStickyUnreadIndicator = useStableCallback((viewableItems: ViewToken[]) => { if (!viewableItems.length) { setIsUnreadNotificationOpen(false); return; @@ -482,7 +492,7 @@ const MessageListWithContext = < setIsUnreadNotificationOpen(false); } } - }; + }); /** * FlatList doesn't accept changeable function for onViewableItemsChanged prop. @@ -580,9 +590,6 @@ const MessageListWithContext = < ]); useEffect(() => { - const lastReceivedMessage = getLastReceivedMessage(processedMessageList); - setLastReceivedId(lastReceivedMessage?.id); - /** * Scroll down when * created_at timestamp of top message before update is lesser than created_at timestamp of top message after update - channel has resynced @@ -617,7 +624,6 @@ const MessageListWithContext = < setTimeout(() => { channelResyncScrollSet.current = true; if (channel.countUnread() > 0) { - console.log('marking read'); markRead(); } }, 500); @@ -679,7 +685,7 @@ const MessageListWithContext = < // eslint-disable-next-line react-hooks/exhaustive-deps }, [channel, rawMessageList, threadList]); - const goToMessage = async (messageId: string) => { + const goToMessage = useStableCallback(async (messageId: string) => { const indexOfParentInMessageList = processedMessageList.findIndex( (message) => message?.id === messageId, ); @@ -707,7 +713,7 @@ const MessageListWithContext = < } catch (e) { console.warn('Error while scrolling to message', e); } - }; + }); /** * Check if a messageId needs to be scrolled to after list loads, and scroll to it @@ -750,78 +756,99 @@ const MessageListWithContext = < // TODO: do not apply on RN 0.73 and above const shouldApplyAndroidWorkaround = inverted && Platform.OS === 'android'; - const renderItem = ({ - index, - item: message, - }: { - index: number; - item: MessageType; - }) => { - if (!channel || channel.disconnected || (!channel.initialized && !channel.offlineMode)) { - return null; - } + const renderItem = useCallback( + ({ index, item: message }: { index: number; item: MessageType }) => { + if (!channel || channel.disconnected || (!channel.initialized && !channel.offlineMode)) { + return null; + } - const createdAtTimestamp = message.created_at && new Date(message.created_at).getTime(); - const lastReadTimestamp = channelUnreadState?.last_read.getTime(); - const isNewestMessage = index === 0; - const isLastReadMessage = - channelUnreadState?.last_read_message_id === message.id || - (!channelUnreadState?.unread_messages && createdAtTimestamp === lastReadTimestamp); - - const showUnreadSeparator = - isLastReadMessage && - !isNewestMessage && - // The `channelUnreadState?.first_unread_message_id` is here for sent messages unread label - (!!channelUnreadState?.first_unread_message_id || !!channelUnreadState?.unread_messages); - - const showUnreadUnderlay = !!shouldShowUnreadUnderlay && showUnreadSeparator; - - const wrapMessageInTheme = client.userID === message.user?.id && !!myMessageTheme; - const renderDateSeperator = isMessageWithStylesReadByAndDateSeparator(message) && - message.dateSeparator && ; - const renderMessage = ( - - ); + const createdAtTimestamp = message.created_at && new Date(message.created_at).getTime(); + const lastReadTimestamp = channelUnreadState?.last_read.getTime(); + const isNewestMessage = index === 0; + const isLastReadMessage = + channelUnreadState?.last_read_message_id === message.id || + (!channelUnreadState?.unread_messages && createdAtTimestamp === lastReadTimestamp); + + const showUnreadSeparator = + isLastReadMessage && + !isNewestMessage && + // The `channelUnreadState?.first_unread_message_id` is here for sent messages unread label + (!!channelUnreadState?.first_unread_message_id || !!channelUnreadState?.unread_messages); + + const showUnreadUnderlay = !!shouldShowUnreadUnderlay && showUnreadSeparator; + + const wrapMessageInTheme = client.userID === message.user?.id && !!myMessageTheme; + const renderDateSeperator = isMessageWithStylesReadByAndDateSeparator(message) && + message.dateSeparator && ; + const renderMessage = ( + + ); - return ( - - {message.type === 'system' ? ( - - ) : wrapMessageInTheme ? ( - + return ( + + {message.type === 'system' ? ( + + ) : wrapMessageInTheme ? ( + + + {renderDateSeperator} + {renderMessage} + + + ) : ( {renderDateSeperator} {renderMessage} - - ) : ( - - {renderDateSeperator} - {renderMessage} - - )} - {showUnreadUnderlay && } - - ); - }; + )} + {showUnreadUnderlay && } + + ); + }, + [ + InlineDateSeparator, + InlineUnreadIndicator, + Message, + MessageSystem, + channel, + channelUnreadState?.first_unread_message_id, + channelUnreadState?.last_read, + channelUnreadState?.last_read_message_id, + channelUnreadState?.unread_messages, + client.userID, + goToMessage, + highlightedMessageId, + lastReceivedId, + messageContainer, + modifiedTheme, + myMessageTheme, + onThreadSelect, + screenPadding, + shouldApplyAndroidWorkaround, + shouldShowUnreadUnderlay, + threadList, + ], + ); /** * We are keeping full control on message pagination, and not relying on react-native for it. @@ -846,7 +873,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 () => { + const maybeCallOnStartReached = useStableCallback(async () => { // If onStartReached has already been called for given data length, then ignore. if ( processedMessageList?.length && @@ -883,14 +910,14 @@ const MessageListWithContext = < ) .then(callback) .catch(onError); - }; + }); /** * 1. Makes a call to `loadMore` function, which queries more older messages. * 2. Ensures that we call `loadMore`, once per content length * 3. If the call to `loadMoreRecent` is in progress, we wait for it to finish to make sure scroll doesn't jump. */ - const maybeCallOnEndReached = async () => { + const maybeCallOnEndReached = useStableCallback(async () => { // If onEndReached has already been called for given messageList length, then ignore. if (processedMessageList?.length && onEndReachedTracker.current[processedMessageList.length]) { return; @@ -919,9 +946,9 @@ const MessageListWithContext = < onEndReachedInPromise.current = (threadList ? loadMoreThread() : loadMore()) .then(callback) .catch(onError); - }; + }); - const onUserScrollEvent: NonNullable = (event) => { + const onUserScrollEvent: NonNullable = useStableCallback((event) => { const nativeEvent = event.nativeEvent; clearTimeout(onScrollEventTimeoutRef.current); const offset = nativeEvent.contentOffset.y; @@ -942,9 +969,9 @@ const MessageListWithContext = < if (isScrollAtEnd) { maybeCallOnEndReached(); } - }; + }); - const handleScroll: ScrollViewProps['onScroll'] = (event) => { + const handleScroll: ScrollViewProps['onScroll'] = useStableCallback((event) => { const messageListHasMessages = processedMessageList.length > 0; const offset = event.nativeEvent.contentOffset.y; // Show scrollToBottom button once scroll position goes beyond 150. @@ -966,9 +993,9 @@ const MessageListWithContext = < if (onListScroll) { onListScroll(event); } - }; + }); - const goToNewMessages = async () => { + const goToNewMessages = useStableCallback(async () => { const isNotLatestSet = channel.state.messages !== channel.state.latestMessages; if (isNotLatestSet) { @@ -989,7 +1016,7 @@ const MessageListWithContext = < await markRead({ updateChannelUnreadState: false, }); - }; + }); const scrollToIndexFailedRetryCountRef = useRef(0); const failScrollTimeoutId = useRef>(undefined); @@ -1090,35 +1117,35 @@ const MessageListWithContext = < threadList, ]); - const dismissImagePicker = () => { + const dismissImagePicker = useStableCallback(() => { if (selectedPicker) { setSelectedPicker(undefined); closePicker(); } - }; + }); - const onScrollBeginDrag: ScrollViewProps['onScrollBeginDrag'] = (event) => { + const onScrollBeginDrag: ScrollViewProps['onScrollBeginDrag'] = useStableCallback((event) => { !hasMoved && selectedPicker && setHasMoved(true); onUserScrollEvent(event); - }; + }); - const onScrollEndDrag: ScrollViewProps['onScrollEndDrag'] = (event) => { + const onScrollEndDrag: ScrollViewProps['onScrollEndDrag'] = useStableCallback((event) => { hasMoved && selectedPicker && setHasMoved(false); onUserScrollEvent(event); - }; + }); - const refCallback = (ref: FlatListType>) => { + const refCallback = useStableCallback((ref: FlatListType>) => { flatListRef.current = ref; if (setFlatListRef) { setFlatListRef(ref); } - }; + }); - const onUnreadNotificationClose = async () => { + const onUnreadNotificationClose = useStableCallback(async () => { await markRead(); setIsUnreadNotificationOpen(false); - }; + }); const debugRef = useDebugContext(); @@ -1179,6 +1206,25 @@ const MessageListWithContext = < additionalFlatListPropsExcludingStyle = rest; } + const flatListStyle = useMemo( + () => [ + styles.listContainer, + listContainer, + additionalFlatListProps?.style, + shouldApplyAndroidWorkaround ? styles.invertAndroid : undefined, + ], + [additionalFlatListProps?.style, listContainer, shouldApplyAndroidWorkaround], + ); + + const flatListContentContainerStyle = useMemo( + () => [ + styles.contentContainer, + additionalFlatListProps?.contentContainerStyle, + contentContainer, + ], + [additionalFlatListProps?.contentContainerStyle, contentContainer], + ); + if (!FlatList) { return null; } @@ -1203,11 +1249,7 @@ const MessageListWithContext = < ) : ( => (message as MessagesWithStylesReadByAndDateSeparator).readBy !== undefined; +export const shouldIncludeMessageInList = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>( + message: MessageType, + options: { deletedMessagesVisibilityType?: DeletedMessagesVisibilityType; userId?: string }, +) => { + const { deletedMessagesVisibilityType, userId } = options; + const isMessageTypeDeleted = message.type === 'deleted'; + switch (deletedMessagesVisibilityType) { + case 'sender': + return !isMessageTypeDeleted || message.user?.id === userId; + + case 'receiver': + return !isMessageTypeDeleted || message.user?.id !== userId; + + case 'never': + return !isMessageTypeDeleted; + + default: + return !!message; + } +}; + export const useMessageList = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, >( @@ -63,51 +88,57 @@ export const useMessageList = < ? undefined : read; - const dateSeparators = getDateSeparators({ - deletedMessagesVisibilityType, - hideDateSeparators, - messages: messageList, - userId: client.userID, - }); - - const messageGroupStyles = getMessagesGroupStyles({ - dateSeparators, - hideDateSeparators, - maxTimeBetweenGroupedMessages, - messages: messageList, - noGroupByUser, - userId: client.userID, - }); - const readData = useLastReadData({ messages: messageList, read: readList, userID: client.userID, }); - const messagesWithStylesReadByAndDateSeparator = messageList - .filter((msg) => { - const isMessageTypeDeleted = msg.type === 'deleted'; - if (deletedMessagesVisibilityType === 'sender') { - return !isMessageTypeDeleted || msg.user?.id === client.userID; - } else if (deletedMessagesVisibilityType === 'receiver') { - return !isMessageTypeDeleted || msg.user?.id !== client.userID; - } else if (deletedMessagesVisibilityType === 'never') { - return !isMessageTypeDeleted; - } else { - return msg; + const processedMessageList = useMemo[]>(() => { + const dateSeparators = getDateSeparators({ + deletedMessagesVisibilityType, + hideDateSeparators, + messages: messageList, + userId: client.userID, + }); + + const messageGroupStyles = getMessagesGroupStyles({ + dateSeparators, + hideDateSeparators, + maxTimeBetweenGroupedMessages, + messages: messageList, + noGroupByUser, + userId: client.userID, + }); + + const newMessageList = []; + for (const message of messageList) { + if ( + shouldIncludeMessageInList(message, { + deletedMessagesVisibilityType, + userId: client.userID, + }) + ) { + const messageId = message.id; + newMessageList.unshift({ + ...message, + dateSeparator: dateSeparators[messageId] || undefined, + groupStyles: messageGroupStyles[messageId] || ['single'], + readBy: messageId ? readData[messageId] || false : false, + }); } - }) - .map((msg) => ({ - ...msg, - dateSeparator: dateSeparators[msg.id] || undefined, - groupStyles: messageGroupStyles[msg.id] || ['single'], - readBy: msg.id ? readData[msg.id] || false : false, - })); - - const processedMessageList = [ - ...messagesWithStylesReadByAndDateSeparator, - ].reverse() as MessageType[]; + } + return newMessageList; + }, [ + client.userID, + deletedMessagesVisibilityType, + getMessagesGroupStyles, + hideDateSeparators, + maxTimeBetweenGroupedMessages, + messageList, + noGroupByUser, + readData, + ]); return { /** Messages enriched with dates/readby/groups and also reversed in order */ diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap index 176e5615e3..574ac34481 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -406,254 +406,123 @@ exports[`Thread should match thread snapshot 1`] = ` style={ [ { - "position": "absolute", - }, - { - "opacity": undefined, - "transform": [ - { - "translateX": undefined, - }, - ], + "alignItems": "center", + "flexDirection": "row", }, {}, ] } > - - - - - - - - - - - - - - Message6 - + Message6 - + @@ -918,254 +787,123 @@ exports[`Thread should match thread snapshot 1`] = ` style={ [ { - "position": "absolute", - }, - { - "opacity": undefined, - "transform": [ - { - "translateX": undefined, - }, - ], + "alignItems": "center", + "flexDirection": "row", }, {}, ] } > - - - - - - - - - - - - - - Message5 - + Message5 - + @@ -1453,269 +1191,138 @@ exports[`Thread should match thread snapshot 1`] = ` "left": 750, "right": 750, } - } - style={ - [ - { - "alignItems": "center", - "flexDirection": "row", - }, - {}, - ] - } - > - - - - - - - - - - - + } + style={ + [ + { + "alignItems": "center", + "flexDirection": "row", + }, + {}, + ] + } + > - - - Message4 - + Message4 - + @@ -1973,254 +1580,123 @@ exports[`Thread should match thread snapshot 1`] = ` style={ [ { - "position": "absolute", - }, - { - "opacity": undefined, - "transform": [ - { - "translateX": undefined, - }, - ], + "alignItems": "center", + "flexDirection": "row", }, {}, ] } > - - - - - - - - - - - - - - Message3 - + Message3 - + diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 69332c214e..4af1fd68c0 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -4,6 +4,7 @@ import React, { useCallback, useContext, useEffect, + useMemo, useRef, useState, } from 'react'; @@ -125,6 +126,7 @@ export type LocalMessageInputContext< url: string; }; }; + giphyEnabled: boolean; closeAttachmentPicker: () => void; /** The time at which the active cooldown will end */ cooldownEndsAt: Date; @@ -151,6 +153,7 @@ export type LocalMessageInputContext< */ fileUploads: FileUpload[]; giphyActive: boolean; + hasText: boolean; /** * An array of image objects which are set for upload. It has the following structure: * @@ -608,18 +611,19 @@ export const MessageInputProvider = < text, } = useMessageDetailsForState(editing, initialValue); const { endsAt: cooldownEndsAt, start: startCooldown } = useCooldown(); + const { onChangeText, emojiSearchIndex, autoCompleteTriggerSettings } = value; const threadId = thread?.id; useEffect(() => { setSendThreadMessageInChannel(false); }, [threadId]); - const appendText = (newText: string) => { + const appendText = useStableCallback((newText: string) => { setText((prevText) => `${prevText}${newText}`); - }; + }); /** Checks if the message is valid or not. Accordingly we can enable/disable send button */ - const isValidMessage = () => { + const isValidMessage = useStableCallback(() => { if (text && text.trim()) { return true; } @@ -653,41 +657,44 @@ export const MessageInputProvider = < } return false; - }; + }); - const onChange = (newText: string) => { - if (sending.current) { - return; - } - setText(newText); + const onChange = useCallback( + (newText: string) => { + if (sending.current) { + return; + } + setText(newText); - if (newText && channel && channelCapabities.sendTypingEvents && isOnline) { - logChatPromiseExecution(channel.keystroke(thread?.id), 'start typing event'); - } + if (newText && channel && channelCapabities.sendTypingEvents && isOnline) { + logChatPromiseExecution(channel.keystroke(thread?.id), 'start typing event'); + } - if (value.onChangeText) { - value.onChangeText(newText); - } - }; + if (onChangeText) { + onChangeText(newText); + } + }, + [channel, channelCapabities.sendTypingEvents, isOnline, setText, thread?.id, onChangeText], + ); - const openCommandsPicker = () => { + const openCommandsPicker = useStableCallback(() => { appendText('/'); if (inputBoxRef.current) { inputBoxRef.current.focus(); } - }; + }); - const openMentionsPicker = () => { + const openMentionsPicker = useStableCallback(() => { appendText('@'); if (inputBoxRef.current) { inputBoxRef.current.focus(); } - }; + }); /** * Function for capturing a photo and uploading it */ - const takeAndUploadImage = async (mediaType?: MediaTypes) => { + const takeAndUploadImage = useStableCallback(async (mediaType?: MediaTypes) => { setSelectedPicker(undefined); closePicker(); const photo = await NativeHandlers.takePhoto({ @@ -711,12 +718,12 @@ export const MessageInputProvider = < await uploadNewFile({ ...photo, mimeType: photo.type, type: FileTypes.Video }); } } - }; + }); /** * Function for picking a photo from native image picker and uploading it */ - const pickAndUploadImageFromNativePicker = async () => { + const pickAndUploadImageFromNativePicker = useStableCallback(async () => { const result = await NativeHandlers.pickImage(); if (result.askToOpenSettings) { Alert.alert( @@ -749,7 +756,7 @@ export const MessageInputProvider = < } }); } - }; + }); /** * Function to open the attachment picker if the MediaLibary is installed. @@ -779,11 +786,11 @@ export const MessageInputProvider = < } }, [closeAttachmentPicker, openAttachmentPicker, selectedPicker]); - const onSelectItem = (item: UserResponse) => { + const onSelectItem = useStableCallback((item: UserResponse) => { setMentionedUsers((prevMentionedUsers) => [...prevMentionedUsers, item.id]); - }; + }); - const pickFile = async () => { + const pickFile = useStableCallback(async () => { if (!isDocumentPickerAvailable()) { console.log( 'The file picker is not installed. Check our Getting Started documentation to install it.', @@ -812,7 +819,7 @@ export const MessageInputProvider = < await uploadNewFile(asset); }); } - }; + }); const removeFile = useCallback( (id: string) => { @@ -834,246 +841,257 @@ export const MessageInputProvider = < [imageUploads, setImageUploads, setNumberOfUploads], ); - const resetInput = (pendingAttachments: Attachment[] = []) => { - /** - * If the MediaLibrary is available, reset the selected files and images - */ - if (isImageMediaLibraryAvailable()) { - setSelectedFiles([]); - setSelectedImages([]); - } - - setFileUploads([]); - setGiphyActive(false); - setShowMoreOptions(true); - setImageUploads([]); - setMentionedUsers([]); - setNumberOfUploads( - (prevNumberOfUploads) => prevNumberOfUploads - (pendingAttachments?.length || 0), - ); - setText(''); - if (value.editing) { - value.clearEditingState(); - } - }; + const resetInput = useStableCallback( + (pendingAttachments: Attachment[] = []) => { + /** + * If the MediaLibrary is available, reset the selected files and images + */ + if (isImageMediaLibraryAvailable()) { + setSelectedFiles([]); + setSelectedImages([]); + } - const mapImageUploadToAttachment = (image: ImageUpload): Attachment => { - const mime_type: string | boolean = lookup(image.file.name as string); - const name = image.file.name as string; - return { - fallback: name, - image_url: image.url, - mime_type: mime_type ? mime_type : undefined, - original_height: image.height, - original_width: image.width, - originalImage: image.file, - type: FileTypes.Image, - }; - }; + setFileUploads([]); + setGiphyActive(false); + setShowMoreOptions(true); + setImageUploads([]); + setMentionedUsers([]); + setNumberOfUploads( + (prevNumberOfUploads) => prevNumberOfUploads - (pendingAttachments?.length || 0), + ); + setText(''); + if (value.editing) { + value.clearEditingState(); + } + }, + ); - const mapFileUploadToAttachment = (file: FileUpload): Attachment => { - if (file.type === FileTypes.Image) { + const mapImageUploadToAttachment = useStableCallback( + (image: ImageUpload): Attachment => { + const mime_type: string | boolean = lookup(image.file.name as string); + const name = image.file.name as string; return { - fallback: file.file.name, - image_url: file.url, - mime_type: file.file.mimeType, - originalFile: file.file, + fallback: name, + image_url: image.url, + mime_type: mime_type ? mime_type : undefined, + original_height: image.height, + original_width: image.width, + originalImage: image.file, type: FileTypes.Image, }; - } else if (file.type === FileTypes.Audio) { - return { - asset_url: file.url || file.file.uri, - duration: file.file.duration, - file_size: file.file.size, - mime_type: file.file.mimeType, - originalFile: file.file, - title: file.file.name, - type: FileTypes.Audio, - }; - } else if (file.type === FileTypes.Video) { - return { - asset_url: file.url || file.file.uri, - duration: file.file.duration, - file_size: file.file.size, - mime_type: file.file.mimeType, - originalFile: file.file, - thumb_url: file.thumb_url, - title: file.file.name, - type: FileTypes.Video, - }; - } else if (file.type === FileTypes.VoiceRecording) { - return { - asset_url: file.url || file.file.uri, - duration: file.file.duration, - file_size: file.file.size, - mime_type: file.file.mimeType, - originalFile: file.file, - title: file.file.name, - type: FileTypes.VoiceRecording, - waveform_data: file.file.waveform_data, - }; - } else { - return { - asset_url: file.url || file.file.uri, - file_size: file.file.size, - mime_type: file.file.mimeType, - originalFile: file.file, - title: file.file.name, - type: FileTypes.File, - }; - } - }; + }, + ); + + const mapFileUploadToAttachment = useStableCallback( + (file: FileUpload): Attachment => { + if (file.type === FileTypes.Image) { + return { + fallback: file.file.name, + image_url: file.url, + mime_type: file.file.mimeType, + originalFile: file.file, + type: FileTypes.Image, + }; + } else if (file.type === FileTypes.Audio) { + return { + asset_url: file.url || file.file.uri, + duration: file.file.duration, + file_size: file.file.size, + mime_type: file.file.mimeType, + originalFile: file.file, + title: file.file.name, + type: FileTypes.Audio, + }; + } else if (file.type === FileTypes.Video) { + return { + asset_url: file.url || file.file.uri, + duration: file.file.duration, + file_size: file.file.size, + mime_type: file.file.mimeType, + originalFile: file.file, + thumb_url: file.thumb_url, + title: file.file.name, + type: FileTypes.Video, + }; + } else if (file.type === FileTypes.VoiceRecording) { + return { + asset_url: file.url || file.file.uri, + duration: file.file.duration, + file_size: file.file.size, + mime_type: file.file.mimeType, + originalFile: file.file, + title: file.file.name, + type: FileTypes.VoiceRecording, + waveform_data: file.file.waveform_data, + }; + } else { + return { + asset_url: file.url || file.file.uri, + file_size: file.file.size, + mime_type: file.file.mimeType, + originalFile: file.file, + title: file.file.name, + type: FileTypes.File, + }; + } + }, + ); // TODO: Figure out why this is async, as it doesn't await any promise. - const sendMessage = async ({ - customMessageData, - }: { - customMessageData?: Partial>; - } = {}) => { - if (sending.current) { - return; - } - const linkInfos = parseLinksFromText(text); + const sendMessage = useStableCallback( + async ({ + customMessageData, + }: { + customMessageData?: Partial>; + } = {}) => { + if (sending.current) { + return; + } + const linkInfos = parseLinksFromText(text); - if (!channelCapabities.sendLinks && linkInfos.length > 0) { - Alert.alert(t('Links are disabled'), t('Sending links is not allowed in this conversation')); + if (!channelCapabities.sendLinks && linkInfos.length > 0) { + Alert.alert( + t('Links are disabled'), + t('Sending links is not allowed in this conversation'), + ); - return; - } + return; + } - sending.current = true; + sending.current = true; - startCooldown(); + startCooldown(); - const prevText = giphyEnabled && giphyActive ? `/giphy ${text}` : text; - setText(''); + const prevText = giphyEnabled && giphyActive ? `/giphy ${text}` : text; + setText(''); - if (inputBoxRef.current) { - inputBoxRef.current.clear(); - } + if (inputBoxRef.current) { + inputBoxRef.current.clear(); + } - const attachments = [] as Attachment[]; - for (const image of imageUploads) { - if (enableOfflineSupport) { - if (image.state === FileState.NOT_SUPPORTED) { - return; + const attachments = [] as Attachment[]; + for (const image of imageUploads) { + if (enableOfflineSupport) { + if (image.state === FileState.NOT_SUPPORTED) { + return; + } + attachments.push(mapImageUploadToAttachment(image)); + continue; } - attachments.push(mapImageUploadToAttachment(image)); - continue; - } - if ((!image || image.state === FileState.UPLOAD_FAILED) && !enableOfflineSupport) { - continue; - } + if ((!image || image.state === FileState.UPLOAD_FAILED) && !enableOfflineSupport) { + continue; + } - if (image.state === FileState.UPLOADING) { - // TODO: show error to user that they should wait until image is uploaded - if (value.sendImageAsync) { - /** - * If user hit send before image uploaded, push ID into a queue to later - * be matched with the successful CDN response - */ - setAsyncIds((prevAsyncIds) => [...prevAsyncIds, image.id]); - } else { - sending.current = false; - return setText(prevText); + if (image.state === FileState.UPLOADING) { + // TODO: show error to user that they should wait until image is uploaded + if (value.sendImageAsync) { + /** + * If user hit send before image uploaded, push ID into a queue to later + * be matched with the successful CDN response + */ + setAsyncIds((prevAsyncIds) => [...prevAsyncIds, image.id]); + } else { + sending.current = false; + return setText(prevText); + } } - } - // To get the mime type of the image from the file name and send it as an response for an image - if (image.state === FileState.UPLOADED || image.state === FileState.FINISHED) { - attachments.push(mapImageUploadToAttachment(image)); + // To get the mime type of the image from the file name and send it as an response for an image + if (image.state === FileState.UPLOADED || image.state === FileState.FINISHED) { + attachments.push(mapImageUploadToAttachment(image)); + } } - } - for (const file of fileUploads) { - if (enableOfflineSupport) { - if (file.state === FileState.NOT_SUPPORTED) { + for (const file of fileUploads) { + if (enableOfflineSupport) { + if (file.state === FileState.NOT_SUPPORTED) { + return; + } + attachments.push(mapFileUploadToAttachment(file)); + continue; + } + + if (!file || file.state === FileState.UPLOAD_FAILED) { + continue; + } + + if (file.state === FileState.UPLOADING) { + // TODO: show error to user that they should wait until image is uploaded + sending.current = false; return; } - attachments.push(mapFileUploadToAttachment(file)); - continue; - } - if (!file || file.state === FileState.UPLOAD_FAILED) { - continue; + if (file.state === FileState.UPLOADED || file.state === FileState.FINISHED) { + attachments.push(mapFileUploadToAttachment(file)); + } } - if (file.state === FileState.UPLOADING) { - // TODO: show error to user that they should wait until image is uploaded + // Disallow sending message if its empty. + if (!prevText && attachments.length === 0 && !customMessageData?.poll_id) { sending.current = false; return; } - if (file.state === FileState.UPLOADED || file.state === FileState.FINISHED) { - attachments.push(mapFileUploadToAttachment(file)); - } - } - - // Disallow sending message if its empty. - if (!prevText && attachments.length === 0 && !customMessageData?.poll_id) { - sending.current = false; - return; - } - - const message = value.editing; - if (message && message.type !== 'error') { - const updatedMessage = { - ...message, - attachments, - mentioned_users: mentionedUsers, - quoted_message: undefined, - text: prevText, - ...customMessageData, - } as Parameters['updateMessage']>[0]; - - // TODO: Remove this line and show an error when submit fails - value.clearEditingState(); - - const updateMessagePromise = value - .editMessage( - // @ts-ignore - removeReservedFields(updatedMessage), - ) - .then(value.clearEditingState); - resetInput(attachments); - logChatPromiseExecution(updateMessagePromise, 'update message'); - - sending.current = false; - } else { - try { - /** - * If the message is bounced by moderation, we firstly remove the message from message list and then send a new message. - */ - if (message && isBouncedMessage(message as MessageType)) { - await removeMessage(message); - } - value.sendMessage({ + const message = value.editing; + if (message && message.type !== 'error') { + const updatedMessage = { + ...message, attachments, - mentioned_users: uniq(mentionedUsers), - /** Parent message id - in case of thread */ - parent_id: thread?.id, - quoted_message_id: value.quotedMessage ? value.quotedMessage.id : undefined, - show_in_channel: sendThreadMessageInChannel || undefined, + mentioned_users: mentionedUsers, + quoted_message: undefined, text: prevText, ...customMessageData, - } as unknown as StreamMessage); - - value.clearQuotedMessageState(); - sending.current = false; + } as Parameters['updateMessage']>[0]; + + // TODO: Remove this line and show an error when submit fails + value.clearEditingState(); + + const updateMessagePromise = value + .editMessage( + // @ts-ignore + removeReservedFields(updatedMessage), + ) + .then(value.clearEditingState); + logChatPromiseExecution(updateMessagePromise, 'update message'); resetInput(attachments); - } catch (_error) { + sending.current = false; - if (value.quotedMessage && typeof value.quotedMessage !== 'boolean') { - value.setQuotedMessageState(value.quotedMessage); + } else { + try { + /** + * If the message is bounced by moderation, we firstly remove the message from message list and then send a new message. + */ + if (message && isBouncedMessage(message as MessageType)) { + await removeMessage(message); + } + value.sendMessage({ + attachments, + mentioned_users: uniq(mentionedUsers), + /** Parent message id - in case of thread */ + parent_id: thread?.id, + quoted_message_id: value.quotedMessage ? value.quotedMessage.id : undefined, + show_in_channel: sendThreadMessageInChannel || undefined, + text: prevText, + ...customMessageData, + } as unknown as StreamMessage); + + value.clearQuotedMessageState(); + sending.current = false; + resetInput(attachments); + } catch (_error) { + sending.current = false; + if (value.quotedMessage && typeof value.quotedMessage !== 'boolean') { + value.setQuotedMessageState(value.quotedMessage); + } + setText(prevText.slice(giphyEnabled && giphyActive ? 7 : 0)); // 7 because of '/giphy ' length + console.log('Failed to send message'); } - setText(prevText.slice(giphyEnabled && giphyActive ? 7 : 0)); // 7 because of '/giphy ' length - console.log('Failed to send message'); } - } - }; + }, + ); - const sendMessageAsync = (id: string) => { + const sendMessageAsync = useStableCallback((id: string) => { const image = asyncUploads[id]; if (!image || image.state === FileState.UPLOAD_FAILED) { return; @@ -1109,31 +1127,31 @@ export const MessageInputProvider = < console.log('Failed'); } } - }; + }); - const setInputBoxRef = (ref: TextInput | null) => { + const setInputBoxRef = useStableCallback((ref: TextInput | null) => { inputBoxRef.current = ref; if (value.setInputRef) { value.setInputRef(ref); } - }; + }); - const getTriggerSettings = () => { + const triggerSettings = useMemo(() => { try { let triggerSettings: TriggerSettings = {}; if (channel) { - if (value.autoCompleteTriggerSettings) { - triggerSettings = value.autoCompleteTriggerSettings({ + if (autoCompleteTriggerSettings) { + triggerSettings = autoCompleteTriggerSettings({ channel, client, - emojiSearchIndex: value.emojiSearchIndex, + emojiSearchIndex, onMentionSelectItem: onSelectItem, }); } else { triggerSettings = ACITriggerSettings({ channel, client, - emojiSearchIndex: value.emojiSearchIndex, + emojiSearchIndex, onMentionSelectItem: onSelectItem, }); } @@ -1143,11 +1161,11 @@ export const MessageInputProvider = < console.warn('Error in getting trigger settings', error); throw error; } - }; + }, [channel, client, onSelectItem, autoCompleteTriggerSettings, emojiSearchIndex]); - const triggerSettings = getTriggerSettings(); + // const triggerSettings = getTriggerSettings(); - const updateMessage = async () => { + const updateMessage = useStableCallback(async () => { try { if (value.editing) { await client.updateMessage({ @@ -1162,51 +1180,54 @@ export const MessageInputProvider = < } catch (error) { console.log(error); } - }; + }); const regexCondition = /File (extension \.\w{2,4}|type \S+) is not supported/; - const getUploadSetStateAction = + const getUploadSetStateAction = useStableCallback( ( id: string, fileState: FileStateValue, extraData: Partial = {}, ): React.SetStateAction => - (prevUploads: UploadType[]) => - prevUploads.map((prevUpload) => { - if (prevUpload.id === id) { - return { - ...prevUpload, - ...extraData, - state: fileState, - }; - } - return prevUpload; - }); + (prevUploads: UploadType[]) => + prevUploads.map((prevUpload) => { + if (prevUpload.id === id) { + return { + ...prevUpload, + ...extraData, + state: fileState, + }; + } + return prevUpload; + }), + ); - const handleFileOrImageUploadError = (error: unknown, isImageError: boolean, id: string) => { - if (isImageError) { - setNumberOfUploads((prevNumberOfUploads) => prevNumberOfUploads - 1); - if (error instanceof Error) { - if (regexCondition.test(error.message)) { - return setImageUploads(getUploadSetStateAction(id, FileState.NOT_SUPPORTED)); - } + const handleFileOrImageUploadError = useStableCallback( + (error: unknown, isImageError: boolean, id: string) => { + if (isImageError) { + setNumberOfUploads((prevNumberOfUploads) => prevNumberOfUploads - 1); + if (error instanceof Error) { + if (regexCondition.test(error.message)) { + return setImageUploads(getUploadSetStateAction(id, FileState.NOT_SUPPORTED)); + } - return setImageUploads(getUploadSetStateAction(id, FileState.UPLOAD_FAILED)); - } - } else { - setNumberOfUploads((prevNumberOfUploads) => prevNumberOfUploads - 1); + return setImageUploads(getUploadSetStateAction(id, FileState.UPLOAD_FAILED)); + } + } else { + setNumberOfUploads((prevNumberOfUploads) => prevNumberOfUploads - 1); - if (error instanceof Error) { - if (regexCondition.test(error.message)) { - return setFileUploads(getUploadSetStateAction(id, FileState.NOT_SUPPORTED)); + if (error instanceof Error) { + if (regexCondition.test(error.message)) { + return setFileUploads(getUploadSetStateAction(id, FileState.NOT_SUPPORTED)); + } + return setFileUploads(getUploadSetStateAction(id, FileState.UPLOAD_FAILED)); } - return setFileUploads(getUploadSetStateAction(id, FileState.UPLOAD_FAILED)); } - } - }; + }, + ); - const uploadFile = async ({ newFile }: { newFile: FileUpload }) => { + const uploadFile = useStableCallback(async ({ newFile }: { newFile: FileUpload }) => { const { file, id } = newFile; // The file name can have special characters, so we escape it. @@ -1249,9 +1270,9 @@ export const MessageInputProvider = < } handleFileOrImageUploadError(error, false, id); } - }; + }); - const uploadImage = async ({ newImage }: { newImage: ImageUpload }) => { + const uploadImage = useStableCallback(async ({ newImage }: { newImage: ImageUpload }) => { const { file, id } = newImage || {}; if (!file) { @@ -1332,9 +1353,9 @@ export const MessageInputProvider = < } handleFileOrImageUploadError(error, true, id); } - }; + }); - const uploadNewFile = async (file: File) => { + const uploadNewFile = useStableCallback(async (file: File) => { try { const id: string = generateRandomId(); const fileConfig = getFileUploadConfig(); @@ -1380,9 +1401,9 @@ export const MessageInputProvider = < } catch (error) { console.log('Error uploading file', error); } - }; + }); - const uploadNewImage = async (image: Partial) => { + const uploadNewImage = useStableCallback(async (image: Partial) => { try { const id = generateRandomId(); const imageUploadConfig = getImageUploadConfig(); @@ -1428,15 +1449,15 @@ export const MessageInputProvider = < } catch (error) { console.log('Error uploading image', error); } - }; + }); - const openPollCreationDialog = () => { + const openPollCreationDialog = useStableCallback(() => { if (openPollCreationDialogFromContext) { openPollCreationDialogFromContext({ sendMessage }); return; } defaultOpenPollCreationDialog(); - }; + }); const messageInputContext = useCreateMessageInputContext({ appendText, @@ -1446,6 +1467,7 @@ export const MessageInputProvider = < cooldownEndsAt, fileUploads, giphyActive, + giphyEnabled, imageUploads, inputBoxRef, isValidMessage, @@ -1490,6 +1512,7 @@ export const MessageInputProvider = < uploadNewImage, ...value, closePollCreationDialog, + hasText: !!text, openPollCreationDialog, sendMessage, // overriding the originally passed in sendMessage showPollCreationDialog, @@ -1519,3 +1542,10 @@ export const useMessageInputContext = < return contextValue; }; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +const useStableCallback = (callback: T): T => { + const ref = useRef(callback); + ref.current = callback; + return useCallback(((...args: unknown[]) => ref.current(...args)) as unknown as T, []); +}; diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts index ea42939ca3..b9e48da58f 100644 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts @@ -41,11 +41,13 @@ export const useCreateMessageInputContext = < FileUploadPreview, fileUploads, giphyActive, + giphyEnabled, handleAttachButtonPress, hasCameraPicker, hasCommands, hasFilePicker, hasImagePicker, + hasText, ImageUploadPreview, imageUploads, initialValue, @@ -164,11 +166,13 @@ export const useCreateMessageInputContext = < FileUploadPreview, fileUploads, giphyActive, + giphyEnabled, handleAttachButtonPress, hasCameraPicker, hasCommands, hasFilePicker, hasImagePicker, + hasText, ImageUploadPreview, imageUploads, initialValue, @@ -246,6 +250,8 @@ export const useCreateMessageInputContext = < editingdep, fileUploadsValue, giphyActive, + giphyEnabled, + hasText, imageUploadsValue, maxMessageLength, mentionedUsersLength, diff --git a/package/src/contexts/messageInputContext/hooks/useMessageDetailsForState.ts b/package/src/contexts/messageInputContext/hooks/useMessageDetailsForState.ts index 8708b6e4e1..9ff7843a73 100644 --- a/package/src/contexts/messageInputContext/hooks/useMessageDetailsForState.ts +++ b/package/src/contexts/messageInputContext/hooks/useMessageDetailsForState.ts @@ -22,20 +22,22 @@ export const useMessageDetailsForState = < const [imageUploads, setImageUploads] = useState([]); const [mentionedUsers, setMentionedUsers] = useState([]); const [numberOfUploads, setNumberOfUploads] = useState(0); - const [showMoreOptions, setShowMoreOptions] = useState(true); const initialTextValue = initialValue || ''; const [text, setText] = useState(initialTextValue); + const isEqualToInitialText = text === initialTextValue; + + const [showMoreOptions, setShowMoreOptions] = useState(true); + useEffect(() => { - if (text !== initialTextValue) { + if (!isEqualToInitialText) { setShowMoreOptions(false); } if (fileUploads.length || imageUploads.length) { setShowMoreOptions(false); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [text, imageUploads.length, fileUploads.length]); + }, [isEqualToInitialText, imageUploads.length, fileUploads.length]); const messageValue = message ? stringifyMessage(message) : ''; diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 7bbd02e82a..f849d5ff46 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -322,6 +322,7 @@ export type MessagesContextValue< messageInput?: string; threadMessages?: ChannelState['threads'][string]; }, + throttled?: boolean, ) => void; /** * Custom UI component to display enriched url preview. diff --git a/package/src/contexts/suggestionsContext/SuggestionsContext.tsx b/package/src/contexts/suggestionsContext/SuggestionsContext.tsx index 16fbb2c137..42794e6607 100644 --- a/package/src/contexts/suggestionsContext/SuggestionsContext.tsx +++ b/package/src/contexts/suggestionsContext/SuggestionsContext.tsx @@ -1,4 +1,4 @@ -import React, { PropsWithChildren, useContext, useState } from 'react'; +import React, { PropsWithChildren, useCallback, useContext, useMemo, useState } from 'react'; import type { CommandResponse, UserResponse } from 'stream-chat'; @@ -95,35 +95,54 @@ export const SuggestionsProvider = < children, value, }: PropsWithChildren<{ value?: Partial> }>) => { + const { AutoCompleteSuggestionHeader, AutoCompleteSuggestionItem, AutoCompleteSuggestionList } = + value ?? {}; const [triggerType, setTriggerType] = useState(null); const [suggestions, setSuggestions] = useState>(); const [suggestionsViewActive, setSuggestionsViewActive] = useState(false); - const openSuggestions = (component: SuggestionComponentType) => { + const openSuggestions = useCallback((component: SuggestionComponentType) => { setTriggerType(component); setSuggestionsViewActive(true); - }; - - const updateSuggestions = (newSuggestions: Suggestions) => { - setSuggestions(newSuggestions); - setSuggestionsViewActive(!!triggerType); - }; + }, []); + + const updateSuggestions = useCallback( + (newSuggestions: Suggestions) => { + setSuggestions(newSuggestions); + setSuggestionsViewActive(!!triggerType); + }, + [triggerType], + ); - const closeSuggestions = () => { + const closeSuggestions = useCallback(() => { setTriggerType(null); setSuggestions(undefined); setSuggestionsViewActive(false); - }; - - const suggestionsContext = { - ...value, + }, []); + + const suggestionsContext = useMemo(() => { + return { + AutoCompleteSuggestionHeader, + AutoCompleteSuggestionItem, + AutoCompleteSuggestionList, + closeSuggestions, + openSuggestions, + suggestions, + suggestionsViewActive, + triggerType, + updateSuggestions, + }; + }, [ + AutoCompleteSuggestionHeader, + AutoCompleteSuggestionItem, + AutoCompleteSuggestionList, closeSuggestions, openSuggestions, suggestions, suggestionsViewActive, triggerType, updateSuggestions, - }; + ]); return ( diff --git a/package/src/hooks/index.ts b/package/src/hooks/index.ts index 7e8c310424..bc4a9e87da 100644 --- a/package/src/hooks/index.ts +++ b/package/src/hooks/index.ts @@ -3,3 +3,4 @@ export * from './useStreami18n'; export * from './useViewport'; export * from './useScreenDimensions'; export * from './useStateStore'; +export * from './useStableCallback'; diff --git a/package/src/hooks/useStableCallback.ts b/package/src/hooks/useStableCallback.ts new file mode 100644 index 0000000000..8ef86ffb05 --- /dev/null +++ b/package/src/hooks/useStableCallback.ts @@ -0,0 +1,37 @@ +import { useCallback, useRef } from 'react'; + +export type StableCallback = (...args: A) => R; + +/** + * A utility hook implementing a stable callback. It takes in an unstable method that + * is supposed to be invoked somewhere deeper in the DOM tree without making it + * change its reference every time the parent component rerenders. It will also return + * the value of the callback if it does return one. + * A common use-case would be having a function whose invocation depends on state + * somewhere high up in the DOM tree and wanting to use the same function deeper + * down, for example in a leaf node and simply using useCallback results in + * cascading dependency hell. If we wrap it in useStableCallback, we would be able + * to: + * - Use the same function as a dependency of another hook (since it is stable) + * - Still invoke it and get the latest state + * + * **Caveats:** + * - Never wrap a function that is supposed to return a React.ReactElement in + * useStableCallback, since React will not know that the DOM needs to be updated + * whenever the callback value changes (for example, renderItem from FlatList must + * never be wrapped in this hook) + * - Always prefer using a standard useCallback/stable function wherever possible + * (the purpose of useStableCallback is to bridge the gap between top level contexts + * and cascading rereders in downstream components - **not** as an escape hatch) + * @param callback - the callback we want to stabilize + */ +export const useStableCallback = ( + callback: StableCallback, +): StableCallback => { + const ref = useRef(callback); + ref.current = callback; + + return useCallback>((...args) => { + return ref.current(...args); + }, []); +};