import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import debounce from 'lodash/debounce'; import throttle from 'lodash/throttle'; import { lookup } from 'mime-types'; import { Channel as ChannelClass, ChannelState, Channel as ChannelType, DeleteMessageOptions, EventHandler, LocalMessage, localMessageToNewMessagePayload, MessageLabel, MessageResponse, Reaction, SendMessageAPIResponse, SendMessageOptions, StreamChat, Event as StreamEvent, Message as StreamMessage, Thread, UpdateMessageOptions, } from 'stream-chat'; import { useChannelDataState } from './hooks/useChannelDataState'; import { useCreateChannelContext } from './hooks/useCreateChannelContext'; import { useCreateInputMessageInputContext } from './hooks/useCreateInputMessageInputContext'; import { useCreateMessagesContext } from './hooks/useCreateMessagesContext'; import { useCreateOwnCapabilitiesContext } from './hooks/useCreateOwnCapabilitiesContext'; import { useCreatePaginatedMessageListContext } from './hooks/useCreatePaginatedMessageListContext'; import { useCreateThreadContext } from './hooks/useCreateThreadContext'; import { useCreateTypingContext } from './hooks/useCreateTypingContext'; import { useMessageListPagination } from './hooks/useMessageListPagination'; import { useTargetedMessage } from './hooks/useTargetedMessage'; import { AttachmentPickerContextValue, AttachmentPickerProvider, } from '../../contexts/attachmentPickerContext/AttachmentPickerContext'; import { AudioPlayerContextProps, AudioPlayerProvider, } from '../../contexts/audioPlayerContext/AudioPlayerContext'; import { ChannelContextValue, ChannelProvider } from '../../contexts/channelContext/ChannelContext'; import type { UseChannelStateValue } from '../../contexts/channelsStateContext/useChannelState'; import { useChannelState } from '../../contexts/channelsStateContext/useChannelState'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { MessageComposerProvider } from '../../contexts/messageComposerContext/MessageComposerContext'; import { MessageContextValue } from '../../contexts/messageContext/MessageContext'; import { InputMessageInputContextValue, MessageInputProvider, } from '../../contexts/messageInputContext/MessageInputContext'; import { MessagesContextValue, MessagesProvider, } from '../../contexts/messagesContext/MessagesContext'; import { OwnCapabilitiesContextValue, OwnCapabilitiesProvider, } from '../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; import { PaginatedMessageListContextValue, PaginatedMessageListProvider, } from '../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, ThreadProvider, ThreadType, } from '../../contexts/threadContext/ThreadContext'; import { TranslationContextValue, useTranslationContext, } from '../../contexts/translationContext/TranslationContext'; import { TypingProvider } from '../../contexts/typingContext/TypingContext'; import { useStableCallback } from '../../hooks'; import { useAppStateListener } from '../../hooks/useAppStateListener'; import { useAttachmentPickerBottomSheet } from '../../hooks/useAttachmentPickerBottomSheet'; import { usePrunableMessageList } from '../../hooks/usePrunableMessageList'; import { isDocumentPickerAvailable, isImageMediaLibraryAvailable, isImagePickerAvailable, NativeHandlers, } from '../../native'; import { ChannelUnreadStateStore, ChannelUnreadStateStoreType, } from '../../state-store/channel-unread-state'; import { MessageInputHeightStore } from '../../state-store/message-input-height-store'; import { primitives } from '../../theme'; import { FileTypes } from '../../types/types'; import { addReactionToLocalState } from '../../utils/addReactionToLocalState'; import { compressedImageURI } from '../../utils/compressImage'; import { patchMessageTextCommand } from '../../utils/patchMessageTextCommand'; import { getFileNameFromPath, isBouncedMessage, isLocalUrl, MessageStatusTypes, ReactionData, } from '../../utils/utils'; import { AttachmentPicker } from '../AttachmentPicker/AttachmentPicker'; import type { KeyboardCompatibleViewProps } from '../KeyboardCompatibleView/KeyboardControllerAvoidingView'; import { Emoji } from '../MessageMenu/EmojiPickerList'; import { emojis } from '../MessageMenu/emojis'; import { toUnicodeScalarString } from '../MessageMenu/utils/toUnicodeScalarString'; export type MarkReadFunctionOptions = { /** * Signal, whether the `channelUnreadUiState` should be updated. * By default, the local state update is prevented when the Channel component is mounted. * This is in order to keep the UI indicating the original unread state, when the user opens a channel. */ updateChannelUnreadState?: boolean; }; export const reactionData: ReactionData[] = [ { Icon: ({ size = 12 }: { size?: number }) => , type: 'like', isMain: true, }, { Icon: ({ size = 12 }: { size?: number }) => , type: 'haha', isMain: true, }, { Icon: ({ size = 12 }: { size?: number }) => , type: 'love', isMain: true, }, { Icon: ({ size = 12 }: { size?: number }) => , type: 'wow', isMain: true, }, { Icon: ({ size = 12 }: { size?: number }) => , type: 'sad', isMain: true, }, ...emojis.map((emoji) => ({ Icon: ({ size = 12 }: { size?: number }) => , type: toUnicodeScalarString(emoji), })), ]; /** * If count of unread messages is less than 4, then no need to scroll to first unread message, * since first unread message will be in visible frame anyways. */ const scrollToFirstUnreadThreshold = 0; const defaultThrottleInterval = 500; const defaultDebounceInterval = 500; const throttleOptions = { leading: true, trailing: true, }; const debounceOptions = { leading: true, trailing: true, }; export type ChannelPropsWithContext = Pick & Partial< Pick< AttachmentPickerContextValue, | 'bottomInset' | 'topInset' | 'disableAttachmentPicker' | 'numberOfAttachmentPickerImageColumns' | 'numberOfAttachmentImagesToLoadPerCall' > > & Partial< Pick< ChannelContextValue, | 'enableMessageGroupingByUser' | 'enforceUniqueReaction' | 'hideStickyDateHeader' | 'hideDateSeparators' | 'maxTimeBetweenGroupedMessages' | 'maximumMessageLimit' > > & Pick & Partial< Pick< InputMessageInputContextValue, | 'additionalTextInputProps' | 'allowSendBeforeAttachmentsUpload' | 'asyncMessagesLockDistance' | 'asyncMessagesMinimumPressDuration' | 'audioRecordingSendOnComplete' | 'asyncMessagesSlideToCancelDistance' | 'attachmentPickerBottomSheetHeight' | 'attachmentSelectionBarHeight' | 'audioRecordingEnabled' | 'compressImageQuality' | 'createPollOptionGap' | 'doFileUploadRequest' | 'handleAttachButtonPress' | 'hasCameraPicker' | 'hasCommands' | 'hasFilePicker' | 'hasImagePicker' | 'messageInputFloating' | 'openPollCreationDialog' | 'setInputRef' > > & Pick & Partial< Pick > & Pick & Partial< Pick< MessagesContextValue, | 'additionalPressableProps' | 'customMessageSwipeAction' | 'disableTypingIndicator' | 'dismissKeyboardOnMessageTouch' | 'enableSwipeToReply' | 'urlPreviewType' | 'FlatList' | 'forceAlignMessages' | 'getMessageGroupStyle' | 'giphyVersion' | 'handleBan' | 'handleCopy' | 'handleDelete' | 'handleDeleteForMe' | 'handleEdit' | 'handleFlag' | 'handleMarkUnread' | 'handleMute' | 'handlePinMessage' | 'handleReaction' | 'handleQuotedReply' | 'handleRetry' | 'handleThreadReply' | 'handleBlockUser' | 'isAttachmentEqual' | 'markdownRules' | 'messageActions' | 'messageContentOrder' | 'messageOverlayTargetId' | 'messageTextNumberOfLines' | 'messageSwipeToReplyHitSlop' | 'myMessageTheme' | 'onLongPressMessage' | 'onPressInMessage' | 'onPressMessage' | 'reactionListPosition' | 'reactionListType' | 'shouldShowUnreadUnderlay' | 'selectReaction' | 'supportedReactions' | 'hasCreatePoll' > > & Partial> & Partial< Pick > & { shouldSyncChannel: boolean; thread: ThreadType; /** * Additional props passed to keyboard avoiding view */ additionalKeyboardAvoidingViewProps?: Partial; /** * When true, disables the KeyboardCompatibleView wrapper * * Channel internally uses the [KeyboardCompatibleView](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/KeyboardCompatibleView/KeyboardCompatibleView.tsx) * component to adjust the height of Channel when the keyboard is opened or dismissed. This prop provides the ability to disable this functionality in case you * want to use [KeyboardAvoidingView](https://facebook.github.io/react-native/docs/keyboardavoidingview) or handle dismissal yourself. * KeyboardAvoidingView works well when your component occupies 100% of screen height, otherwise it may raise some issues. */ disableKeyboardCompatibleView?: boolean; /** * Overrides the Stream default mark channel read request (Advanced usage only) * @param channel Channel object */ doMarkReadRequest?: ( channel: ChannelType, setChannelUnreadUiState?: (data: ChannelUnreadStateStoreType['channelUnreadState']) => void, ) => void; /** * Overrides the Stream default send message request (Advanced usage only) * @param channelId * @param messageData Message object */ doSendMessageRequest?: ( channelId: string, messageData: StreamMessage, options?: SendMessageOptions, ) => Promise; /** * A method invoked just after the first optimistic update of a new message, * but before any other HTTP requests happen. Can be used to do extra work * (such as creating a channel, or editing a message) before the local message * is sent. * @param channelId * @param messageData Message object */ preSendMessageRequest?: (options: { localMessage: LocalMessage; message: StreamMessage; options?: SendMessageOptions; }) => Promise; /** * Overrides the Stream default update message request (Advanced usage only) * @param channelId * @param updatedMessage UpdatedMessage object */ doUpdateMessageRequest?: ( channelId: string, updatedMessage: Parameters[0], options?: UpdateMessageOptions, ) => ReturnType; /** * When true, messageList will be scrolled at first unread message, when opened. */ initialScrollToFirstUnreadMessage?: boolean; keyboardBehavior?: KeyboardCompatibleViewProps['behavior']; keyboardVerticalOffset?: number; /** * Boolean flag to enable/disable marking the channel as read on mount */ markReadOnMount?: boolean; /** * Load the channel at a specified message instead of the most recent message. */ messageId?: string; /** * @deprecated * The time interval for throttling while updating the message state */ newMessageStateUpdateThrottleInterval?: number; overrideOwnCapabilities?: Partial; /** * If true, multiple audio players will be allowed to play simultaneously * @default true */ allowConcurrentAudioPlayback?: boolean; stateUpdateThrottleInterval?: number; /** * Tells if channel is rendering a thread list */ threadList?: boolean; /** * A boolean signifying whether the Channel component should run channel.watch() * whenever it mounts up a new channel. If set to `false`, it is the integrator's * responsibility to run channel.watch() if they wish to receive WebSocket events * for that channel. * * Can be particularly useful whenever we are viewing channels in a read-only mode * or perhaps want them in an ephemeral state (i.e not created until the first message * is sent). */ initializeOnMount?: boolean; }; const ChannelWithContext = (props: PropsWithChildren) => { const { disableAttachmentPicker = !isImageMediaLibraryAvailable(), additionalKeyboardAvoidingViewProps, additionalPressableProps, additionalTextInputProps, allowConcurrentAudioPlayback = false, allowThreadMessagesInChannel = true, asyncMessagesLockDistance = 50, asyncMessagesMinimumPressDuration = 500, asyncMessagesSlideToCancelDistance = 75, audioRecordingSendOnComplete = false, attachmentPickerBottomSheetHeight = disableAttachmentPicker ? 72 : 333, attachmentSelectionBarHeight = 72, audioRecordingEnabled = false, numberOfAttachmentImagesToLoadPerCall = 25, numberOfAttachmentPickerImageColumns = 3, giphyVersion = 'fixed_height', bottomInset = 0, channel, children, client, compressImageQuality, createPollOptionGap, customMessageSwipeAction, disableKeyboardCompatibleView = false, disableTypingIndicator, dismissKeyboardOnMessageTouch = true, doFileUploadRequest, doMarkReadRequest, doSendMessageRequest, preSendMessageRequest, doUpdateMessageRequest, enableMessageGroupingByUser = true, enableOfflineSupport, allowSendBeforeAttachmentsUpload = enableOfflineSupport, enableSwipeToReply = true, enforceUniqueReaction = false, FlatList = NativeHandlers.FlatList, forceAlignMessages, getMessageGroupStyle, handleAttachButtonPress, handleBan, handleCopy, handleDelete, handleDeleteForMe, handleEdit, handleFlag, handleMarkUnread, handleMute, handlePinMessage, handleQuotedReply, handleReaction, handleRetry, handleThreadReply, handleBlockUser, hasCameraPicker = isImagePickerAvailable(), hasCommands, hasCreatePoll, // If pickDocument isn't available, default to hiding the file picker hasFilePicker = isDocumentPickerAvailable(), hasImagePicker = isImagePickerAvailable() || isImageMediaLibraryAvailable(), hideDateSeparators = false, hideStickyDateHeader = false, initialScrollToFirstUnreadMessage = false, isAttachmentEqual, isMessageAIGenerated = () => false, keyboardBehavior, keyboardVerticalOffset, loadingMore: loadingMoreProp, loadingMoreRecent: loadingMoreRecentProp, markdownRules, markReadOnMount = true, maxTimeBetweenGroupedMessages, messageActions, messageContentOrder = [ 'quoted_reply', 'gallery', 'files', 'poll', 'ai_text', 'attachments', 'text', 'location', ], messageOverlayTargetId, messageInputFloating = false, messageId, messageSwipeToReplyHitSlop, messageTextNumberOfLines, myMessageTheme, // TODO: Think about this one newMessageStateUpdateThrottleInterval = defaultThrottleInterval, onLongPressMessage, onPressInMessage, onPressMessage, onAlsoSentToChannelHeaderPress, openPollCreationDialog, overrideOwnCapabilities, reactionListPosition = 'top', reactionListType = 'clustered', selectReaction, setInputRef, setThreadMessages, shouldShowUnreadUnderlay = true, shouldSyncChannel, stateUpdateThrottleInterval = defaultThrottleInterval, supportedReactions = reactionData, t, thread: threadFromProps, threadList, threadMessages, topInset, isOnline, maximumMessageLimit, initializeOnMount = true, urlPreviewType = 'full', } = props; const components = useComponentsContext(); const { KeyboardCompatibleView, LoadingErrorIndicator } = components; const { thread: threadProps, threadInstance } = threadFromProps; const styles = useStyles(); const [deleted, setDeleted] = useState(false); const [error, setError] = useState(false); const [lastRead, setLastRead] = useState(); const [thread, setThread] = useState(threadProps || null); const [threadHasMore, setThreadHasMore] = useState(true); const [threadLoadingMore, setThreadLoadingMore] = useState(false); const [channelUnreadStateStore] = useState(new ChannelUnreadStateStore()); const [messageInputHeightStore] = useState(new MessageInputHeightStore()); // TODO: Think if we can remove this and just rely on the channelUnreadStateStore everywhere. const setChannelUnreadState = useCallback( (data: ChannelUnreadStateStoreType['channelUnreadState']) => { channelUnreadStateStore.channelUnreadState = data; }, [channelUnreadStateStore], ); const { bottomSheetRef, closePicker, openPicker } = useAttachmentPickerBottomSheet(); const syncingChannelRef = useRef(false); const { highlightedMessageId, setTargetedMessage, targetedMessage } = useTargetedMessage(); /** * This ref will hold the abort controllers for * requests made for uploading images/files in the messageInputContext * 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; const { copyStateFromChannel, initStateFromChannel, setRead, setTyping, state: channelState, } = useChannelDataState(channel); const { copyMessagesStateFromChannel: rawCopyMessagesStateFromChannel, loadChannelAroundMessage: loadChannelAroundMessageFn, loadChannelAtFirstUnreadMessage, loadInitialMessagesStateFromChannel, loadLatestMessages, loadMore, loadMoreRecent, state: channelMessagesState, } = useMessageListPagination({ channel, }); const { setMessages: copyMessagesStateFromChannel, viewabilityChangedCallback } = usePrunableMessageList({ maximumMessageLimit, setMessages: rawCopyMessagesStateFromChannel }); 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( () => throttle( () => { if (channel) { copyStateFromChannel(channel); copyMessagesStateFromChannel(channel); } }, stateUpdateThrottleInterval, throttleOptions, ), [stateUpdateThrottleInterval, channel, copyStateFromChannel, copyMessagesStateFromChannel], ); const handleEvent: EventHandler = useStableCallback((event) => { if (shouldSyncChannel) { /** * Ignore user.watching.start and user.watching.stop as we should not copy the entire state when * they occur. Also ignore all poll related events since they're being handled in their own * reactive state and have no business having an effect on the Channel component. */ if ( event.type.startsWith('poll.') || event.type === 'user.watching.start' || event.type === 'user.watching.stop' ) { return; } // If the event is typing.start or typing.stop, set the typing state if (event.type === 'typing.start' || event.type === 'typing.stop') { if (event.user?.id !== client.userID) { setTyping(channel); } return; } else { if (thread?.id) { const updatedThreadMessages = (thread.id && channel && channel.state.threads[thread.id]) || threadMessages; setThreadMessages(updatedThreadMessages); if (channel && event.message?.id === thread.id && !threadInstance) { const updatedThread = channel.state.formatMessage(event.message); setThread(updatedThread); } } } if (event.type === 'notification.mark_unread') { if (!(event.last_read_at && event.user)) { return; } setChannelUnreadState({ first_unread_message_id: event.first_unread_message_id, last_read: new Date(event.last_read_at), last_read_message_id: event.last_read_message_id, unread_messages: event.unread_messages ?? 0, }); } if (event.type === 'channel.truncated' && event.cid === channel.cid) { setChannelUnreadState(undefined); } // only update channel state if the events are not the previously subscribed useEffect's subscription events if (channel) { // 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) { return; } let errored = false; if ((!channel.initialized || !channel.state.isUpToDate) && initializeOnMount) { try { await channel?.watch(); } catch (err) { console.warn('Channel watch request failed with error:', err); setError(true); errored = true; channel.offlineMode = true; } } if (!errored) { initStateFromChannel(channel); loadInitialMessagesStateFromChannel(channel, channel.state.messagePagination.hasPrev); } if (client.user?.id && channel.state.read[client.user.id]) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { user, ...ownReadState } = channel.state.read[client.user.id]; setChannelUnreadState(ownReadState); } if (messageId) { await loadChannelAroundMessage({ messageId, setTargetedMessage }); } else if ( initialScrollToFirstUnreadMessage && client.user && unreadCount > scrollToFirstUnreadThreshold ) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { user, ...ownReadState } = channel.state.read[client.user.id]; await loadChannelAtFirstUnreadMessage({ channelUnreadState: ownReadState, setChannelUnreadState, setTargetedMessage, }); } if (unreadCount > 0 && markReadOnMount) { await markRead({ updateChannelUnreadState: false }); } listener = channel.on(handleEvent); }; initChannel(); return () => { copyChannelState.cancel(); loadMoreThreadFinished.cancel(); listener?.unsubscribe(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [channel.cid, messageId, shouldSyncChannel]); // subscribe to channel.deleted event useEffect(() => { const { unsubscribe } = client.on('channel.deleted', (event) => { if (event.cid === channel?.cid) { setDeleted(true); } }); return unsubscribe; }, [channel?.cid, client]); const threadPropsExists = !!threadProps; useEffect(() => { if (threadProps && shouldSyncChannel) { setThread(threadProps); if (channel && threadProps?.id) { setThreadMessages(channel.state.threads?.[threadProps.id] || []); } } else { setThread(null); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [threadPropsExists, shouldSyncChannel]); const handleAppBackground = useCallback(() => { const channelData = channel.data; if (channelData?.own_capabilities?.includes('send-typing-events')) { channel.sendEvent({ parent_id: thread?.id, type: 'typing.stop', } as StreamEvent); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [thread?.id, channelId]); useAppStateListener(undefined, handleAppBackground); /** * CHANNEL METHODS */ const markReadInternal: ChannelContextValue['markRead'] = throttle( async (options?: MarkReadFunctionOptions) => { const { updateChannelUnreadState = true } = options ?? {}; if (!channel || channel?.disconnected || !clientChannelConfig?.read_events) { return; } if (doMarkReadRequest) { doMarkReadRequest(channel, updateChannelUnreadState ? setChannelUnreadState : undefined); } else { try { const response = await channel.markRead(); if (updateChannelUnreadState && response && lastRead) { setChannelUnreadState({ last_read: lastRead, last_read_message_id: response?.event.last_read_message_id, unread_messages: 0, }); setLastRead(new Date()); } } catch (err) { console.log('Error marking channel as read:', err); } } }, defaultThrottleInterval, throttleOptions, ); const markRead = useStableCallback(markReadInternal); const reloadThread = useStableCallback(async () => { if (!channel || !thread?.id) { return; } setThreadLoadingMore(true); try { const parentID = thread.id; const limit = 50; // channel.state.threads[parentID] = []; const queryResponse = await channel.getReplies(parentID, { limit, }); const updatedHasMore = queryResponse.messages.length === limit; const updatedThreadMessages = channel.state.threads[parentID] || []; loadMoreThreadFinished(updatedHasMore, updatedThreadMessages); const { messages } = await channel.getMessagesById([parentID]); const [threadMessage] = messages; if (threadMessage && !threadInstance) { const formattedMessage = channel.state.formatMessage(threadMessage); setThread(formattedMessage); } } catch (err) { console.warn('Thread loading request failed with error', err); if (err instanceof Error) { setError(err); } else { setError(true); } setThreadLoadingMore(false); throw err; } }); const resyncChannel = useStableCallback(async () => { if (!channel || syncingChannelRef.current || (!channel.initialized && !channel.offlineMode)) { return; } syncingChannelRef.current = true; setError(false); const parseMessage = (message: LocalMessage) => ({ ...message, created_at: message.created_at.toString(), pinned_at: message.pinned_at?.toString(), updated_at: message.updated_at?.toString(), }) as unknown as MessageResponse; const getRecoverableFailedMessages = (messages: LocalMessage[] = []) => messages .filter( (message) => message.status === MessageStatusTypes.FAILED && !channel.state.findMessage(message.id, message.parent_id), ) .map(parseMessage); try { if (channelMessagesState?.messages) { await channel?.watch({ messages: { // Do we want to reduce this to the default as well ? limit: channelMessagesState.messages.length, }, }); channel.offlineMode = false; } if (!thread) { copyChannelState(); const failedMessages = getRecoverableFailedMessages(channelMessagesState.messages); if (failedMessages?.length) { channel.state.addMessagesSorted(failedMessages); } await markRead(); channel.state.setIsUpToDate(true); } else { await reloadThread(); const failedThreadMessages = thread ? getRecoverableFailedMessages(threadMessages) : []; if (failedThreadMessages.length) { channel.state.addMessagesSorted(failedThreadMessages); setThreadMessages([...channel.state.threads[thread.id]]); } } } catch (err) { if (err instanceof Error) { setError(err); } else { setError(true); } } 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); resyncChannelRef.current = resyncChannel; useEffect(() => { const connectionChangedHandler = () => { if (shouldSyncChannel) { resyncChannelRef.current(); } }; let connectionChangedSubscription: ReturnType; if (enableOfflineSupport && client.offlineDb) { connectionChangedSubscription = client.offlineDb.syncManager.onSyncStatusChange( (statusChanged) => { if (statusChanged) { connectionChangedHandler(); } }, ); } else { connectionChangedSubscription = client.on('connection.changed', (event) => { if (event.online) { connectionChangedHandler(); } }); } return () => { connectionChangedSubscription.unsubscribe(); }; }, [enableOfflineSupport, client, shouldSyncChannel]); // In case the channel is disconnected which may happen when channel is deleted, // underlying js client throws an error. Following function ensures that Channel component // won't result in error in such a case. const getChannelConfigSafely = () => { try { return channel?.getConfig(); } catch (_) { return null; } }; /** * Channel configs for use in disabling local functionality. * Nullish coalescing is used to give first priority to props to override * the server settings. Then priority to server settings to override defaults. */ const clientChannelConfig = getChannelConfigSafely(); const reloadChannel = useStableCallback(async () => { try { await loadLatestMessages(); } catch (err) { console.warn('Reloading channel failed with error:', err); } }); const loadChannelAroundMessage: ChannelContextValue['loadChannelAroundMessage'] = useStableCallback(async ({ messageId: messageIdToLoadAround }): Promise => { if (!messageIdToLoadAround) { return; } try { if (thread) { setThreadLoadingMore(true); try { await channel.state.loadMessageIntoState(messageIdToLoadAround, thread.id); setThreadLoadingMore(false); setThreadMessages(channel.state.threads[thread.id]); if (setTargetedMessage) { setTargetedMessage(messageIdToLoadAround); } } catch (err) { if (err instanceof Error) { setError(err); } else { setError(true); } setThreadLoadingMore(false); } } else { await loadChannelAroundMessageFn({ messageId: messageIdToLoadAround, setTargetedMessage, }); } } catch (err) { console.warn('Loading channel around message failed with error:', err); } }); /** * MESSAGE METHODS */ const updateMessage: MessagesContextValue['updateMessage'] = useStableCallback( (updatedMessage, extraState = {}, throttled = false) => { if (!channel) { return; } 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); } }, ); const replaceMessage = useStableCallback( (oldMessage: LocalMessage, 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); } } }, ); const uploadPendingAttachments = useStableCallback(async (message: LocalMessage) => { const updatedMessage = { ...message }; if (updatedMessage.attachments?.length) { for (let i = 0; i < updatedMessage.attachments?.length; i++) { const attachment = updatedMessage.attachments[i]; // If the attachment is already uploaded, skip it. if ( (attachment.image_url && !isLocalUrl(attachment.image_url)) || (attachment.asset_url && !isLocalUrl(attachment.asset_url)) ) { continue; } const image = attachment.originalFile; const file = attachment.originalFile; if (attachment.type === FileTypes.Image && image?.uri) { 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'; const uploadResponse = doFileUploadRequest ? await doFileUploadRequest(image) : await channel.sendImage(compressedUri, filename, contentType); attachment.image_url = uploadResponse.file; delete attachment.originalFile; client.offlineDb?.executeQuerySafely( (db) => db.updateMessage({ message: { ...updatedMessage, cid: channel.cid }, }), { method: 'updateMessage' }, ); } if (attachment.type !== FileTypes.Image && 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 = doFileUploadRequest ? await doFileUploadRequest(file) : await channel.sendFile(file.uri, file.name, file.type); attachment.asset_url = response.file; if (response.thumb_url) { attachment.thumb_url = response.thumb_url; } delete attachment.originalFile; client.offlineDb?.executeQuerySafely( (db) => db.updateMessage({ message: { ...updatedMessage, cid: channel.cid }, }), { method: 'updateMessage' }, ); } } } return updatedMessage; }); const sendMessageRequest = useStableCallback( async ({ localMessage, message, options, retrying, }: { localMessage: LocalMessage; message: StreamMessage; options?: SendMessageOptions; retrying?: boolean; }) => { let failedMessageUpdated = false; const handleFailedMessage = () => { if (!failedMessageUpdated) { const updatedMessage = { ...localMessage, cid: channel.cid, status: MessageStatusTypes.FAILED, }; updateMessage(updatedMessage); threadInstance?.upsertReplyLocally?.({ message: updatedMessage }); optimisticallyUpdatedNewMessages.delete(localMessage.id); client.offlineDb?.executeQuerySafely( (db) => db.updateMessage({ message: updatedMessage, }), { method: 'updateMessage' }, ); failedMessageUpdated = true; } }; try { if (!isOnline) { await handleFailedMessage(); } const updatedLocalMessage = await uploadPendingAttachments(localMessage); const { attachments } = updatedLocalMessage; const { text, mentioned_users } = message; if (!channel.id) { return; } const messageData = { ...message, attachments, text: patchMessageTextCommand(text ?? '', mentioned_users ?? []), // We cannot send an error message, so we convert it to a regular message. type: message.type === 'error' ? 'regular' : message.type, } as StreamMessage; let messageResponse = {} as SendMessageAPIResponse; if (doSendMessageRequest) { messageResponse = await doSendMessageRequest(channel?.cid || '', messageData, options); } else if (channel) { messageResponse = await channel.sendMessage(messageData, options); } if (messageResponse?.message) { const newMessageResponse = { ...messageResponse.message, status: MessageStatusTypes.RECEIVED, }; client.offlineDb?.executeQuerySafely( (db) => db.updateMessage({ message: { ...newMessageResponse, cid: channel.cid }, }), { method: 'updateMessage' }, ); if (retrying) { replaceMessage(localMessage, newMessageResponse); } else { updateMessage(newMessageResponse, {}, true); } } } catch (err) { console.log('Error sending message:', err); await handleFailedMessage(); } }, ); const sendMessage: InputMessageInputContextValue['sendMessage'] = useStableCallback( async ({ localMessage, message, options }) => { if (channel?.state?.filterErrorMessages) { channel.state.filterErrorMessages(); } updateMessage(localMessage); threadInstance?.upsertReplyLocally?.({ message: localMessage }); optimisticallyUpdatedNewMessages.add(localMessage.id); // 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. client.offlineDb?.executeQuerySafely( (db) => db.upsertMessages({ // @ts-ignore messages: [{ ...localMessage, cid: channel.cid, status: MessageStatusTypes.FAILED }], }), { method: 'upsertMessages' }, ); if (preSendMessageRequest) { await preSendMessageRequest({ localMessage, message, options }); } await sendMessageRequest({ localMessage, message, options }); }, ); const retrySendMessage: MessagesContextValue['retrySendMessage'] = useStableCallback( async (localMessage) => { const statusPendingMessage = { ...localMessage, status: MessageStatusTypes.SENDING, }; const messageWithoutReservedFields = localMessageToNewMessagePayload(statusPendingMessage); // For bounced messages, we don't need to update the message, instead always send a new message. if (!isBouncedMessage(localMessage)) { updateMessage(messageWithoutReservedFields as MessageResponse); } await sendMessageRequest({ localMessage, message: messageWithoutReservedFields, retrying: true, }); }, ); const editMessage: InputMessageInputContextValue['editMessage'] = useStableCallback( async ({ localMessage, options }) => { if (!channel) { throw new Error('Channel has not been initialized'); } const cid = channel.cid; const currentMessage = channel.state.findMessage(localMessage.id, localMessage.parent_id); const isFailedMessage = currentMessage?.status === MessageStatusTypes.FAILED || localMessage.status === MessageStatusTypes.FAILED; const optimisticEditedAt = new Date(); const optimisticEditedAtString = optimisticEditedAt.toISOString(); const optimisticMessage = { ...currentMessage, ...localMessage, cid, message_text_updated_at: isFailedMessage ? undefined : optimisticEditedAtString, updated_at: optimisticEditedAt, } as unknown as LocalMessage; updateMessage(optimisticMessage); threadInstance?.updateParentMessageOrReplyLocally( optimisticMessage as unknown as MessageResponse, ); client.offlineDb?.executeQuerySafely( (db) => db.updateMessage({ message: { ...optimisticMessage, cid }, }), { method: 'updateMessage' }, ); const response = doUpdateMessageRequest ? await doUpdateMessageRequest(cid, localMessage, options) : await client.updateMessage(localMessage, undefined, options); if (response?.message) { updateMessage(response.message); threadInstance?.updateParentMessageOrReplyLocally(response.message); client.offlineDb?.executeQuerySafely( (db) => db.updateMessage({ message: { ...response.message, cid }, }), { method: 'updateMessage' }, ); } return response; }, ); /** * Removes the message from local state */ const removeMessage: MessagesContextValue['removeMessage'] = useStableCallback( async (message) => { if (channel) { channel.state.removeMessage(message); copyMessagesStateFromChannel(channel); if (thread) { setThreadMessages(channel.state.threads[thread.id] || []); } } if (client.offlineDb) { await client.offlineDb.handleRemoveMessage({ messageId: message.id }); } }, ); const sendReaction = useStableCallback(async (type: string, messageId: string) => { if (!channel?.id || !client.user) { throw new Error('Channel has not been initialized'); } const payload: Parameters = [ messageId, { type, } as Reaction, { enforce_unique: enforceUniqueReaction }, ]; if (enableOfflineSupport) { await addReactionToLocalState({ channel, enforceUniqueReaction, messageId, reactionType: type, user: client.user, }); copyMessagesStateFromChannel(channel); } const sendReactionResponse = await channel.sendReaction(...payload); if (sendReactionResponse?.message) { threadInstance?.upsertReplyLocally?.({ message: sendReactionResponse.message }); } }); const deleteMessage: MessagesContextValue['deleteMessage'] = useStableCallback( async (message, optionsOrHardDelete = false) => { let options: DeleteMessageOptions = {}; if (typeof optionsOrHardDelete === 'boolean') { options = optionsOrHardDelete ? { hardDelete: true } : {}; } else if (optionsOrHardDelete?.deleteForMe) { options = { deleteForMe: true }; } else if (optionsOrHardDelete?.hardDelete) { options = { hardDelete: true }; } if (!channel.id) { throw new Error('Channel has not been initialized yet'); } if (message.status === MessageStatusTypes.FAILED) { await removeMessage(message); return; } const updatedMessage = { ...message, cid: channel.cid, deleted_at: new Date(), type: 'deleted' as MessageLabel, }; updateMessage(updatedMessage); threadInstance?.upsertReplyLocally({ message: updatedMessage }); const data = await client.deleteMessage(message.id, options); 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) { channel.state.removeReaction({ created_at: '', message_id: messageId, type, updated_at: '', }); copyMessagesStateFromChannel(channel); } await channel.deleteReaction(...payload); }, ); /** * THREAD METHODS */ const openThread: ThreadContextValue['openThread'] = useCallback( (message) => { setThread(message); if (channel.initialized) { channel.markRead({ thread_id: message.id }); } // This was causing inconsistencies within the thread state as well as being responsible // of threads essentially never unloading (due to all of the previous threads + 50 loading // every time we'd run this). It seemingly has no impact (other than a performance boost) // and having it was causing issues with the Threads V2 architecture. // setThreadMessages(newThreadMessages); }, [channel, setThread], ); const closeThread: ThreadContextValue['closeThread'] = useCallback(() => { setThread(null); setThreadMessages([]); }, [setThread, setThreadMessages]); // hard limit to prevent you from scrolling faster than 1 page per 2 seconds const loadMoreThreadFinished = useRef( debounce( (newThreadHasMore: boolean, updatedThreadMessages: ChannelState['threads'][string]) => { setThreadHasMore(newThreadHasMore); setThreadLoadingMore(false); setThreadMessages(updatedThreadMessages); }, defaultDebounceInterval, debounceOptions, ), ).current; 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, }); 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; } }); const handleClosePicker = useStableCallback(() => closePicker(bottomSheetRef)); const handleOpenPicker = useStableCallback(() => openPicker(bottomSheetRef)); const attachmentPickerContext = useMemo( () => ({ bottomInset, bottomSheetRef, closePicker: handleClosePicker, disableAttachmentPicker, openPicker: handleOpenPicker, topInset, numberOfAttachmentPickerImageColumns, attachmentPickerBottomSheetHeight, attachmentSelectionBarHeight, numberOfAttachmentImagesToLoadPerCall, }), [ bottomInset, bottomSheetRef, handleClosePicker, disableAttachmentPicker, handleOpenPicker, topInset, numberOfAttachmentPickerImageColumns, attachmentPickerBottomSheetHeight, attachmentSelectionBarHeight, numberOfAttachmentImagesToLoadPerCall, ], ); const ownCapabilitiesContext = useCreateOwnCapabilitiesContext({ channel, overrideCapabilities: overrideOwnCapabilities, }); const channelContext = useCreateChannelContext({ channel, channelUnreadStateStore, disabled: !!channel?.data?.frozen, enableMessageGroupingByUser, enforceUniqueReaction, error, hideDateSeparators, hideStickyDateHeader, highlightedMessageId, isChannelActive: shouldSyncChannel, lastRead, loadChannelAroundMessage, loadChannelAtFirstUnreadMessage, loading: channelMessagesState.loading, markRead, maximumMessageLimit, maxTimeBetweenGroupedMessages, members: channelState.members ?? {}, read: channelState.read ?? {}, reloadChannel, scrollToFirstUnreadThreshold, setChannelUnreadState, setLastRead, setTargetedMessage, targetedMessage, threadList, uploadAbortControllerRef, watcherCount: channelState.watcherCount, watchers: channelState.watchers, }); // This is mainly a hack to get around an issue with sendMessage not being passed correctly as a // useMemo() dependency. The easy fix is to add it to the dependency array, however that would mean // that this (very used) context is essentially going to cause rerenders on pretty much every Channel // render, since sendMessage is an inline function. Wrapping it in useCallback() is one way to fix it // 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); // sendMessageRef.current = sendMessage; // const sendMessageStable = useCallback((...args) => { // return sendMessageRef.current(...args); // }, []); const inputMessageInputContext = useCreateInputMessageInputContext({ additionalTextInputProps, allowSendBeforeAttachmentsUpload, asyncMessagesLockDistance, asyncMessagesMinimumPressDuration, audioRecordingSendOnComplete, asyncMessagesSlideToCancelDistance, attachmentPickerBottomSheetHeight, attachmentSelectionBarHeight, audioRecordingEnabled, channelId, compressImageQuality, createPollOptionGap, doFileUploadRequest, editMessage, handleAttachButtonPress, hasCameraPicker, hasCommands: hasCommands ?? !!clientChannelConfig?.commands?.length, hasFilePicker, hasImagePicker, messageInputFloating, messageInputHeightStore, openPollCreationDialog, sendMessage, setInputRef, }); const messageListContext = useCreatePaginatedMessageListContext({ channelId, hasMore: channelMessagesState.hasMore, loadingMore: loadingMoreProp !== undefined ? loadingMoreProp : channelMessagesState.loadingMore, loadingMoreRecent: loadingMoreRecentProp !== undefined ? loadingMoreRecentProp : channelMessagesState.loadingMoreRecent, loadLatestMessages, loadMore, loadMoreRecent, messages: channelMessagesState.messages ?? [], viewabilityChangedCallback, }); const messagesContext = useCreateMessagesContext({ additionalPressableProps, channelId, customMessageSwipeAction, deleteMessage, deleteReaction, disableTypingIndicator, dismissKeyboardOnMessageTouch, enableMessageGroupingByUser, enableSwipeToReply, FlatList, forceAlignMessages, getMessageGroupStyle, giphyVersion, handleBan, handleCopy, handleDelete, handleDeleteForMe, handleEdit, handleFlag, handleMarkUnread, handleMute, handlePinMessage, handleQuotedReply, handleReaction, handleRetry, handleThreadReply, handleBlockUser, hasCreatePoll: hasCreatePoll === undefined ? pollCreationEnabled : hasCreatePoll && pollCreationEnabled, initialScrollToFirstUnreadMessage: !messageId && initialScrollToFirstUnreadMessage, // when messageId is set, we scroll to the messageId instead of first unread isAttachmentEqual, isMessageAIGenerated, markdownRules, messageActions, messageContentOrder, messageOverlayTargetId, messageSwipeToReplyHitSlop, messageTextNumberOfLines, myMessageTheme, onLongPressMessage, onPressInMessage, onPressMessage, reactionListPosition, reactionListType, removeMessage, retrySendMessage, selectReaction, sendReaction, shouldShowUnreadUnderlay, supportedReactions, targetedMessage, updateMessage, urlPreviewType, }); const threadContext = useCreateThreadContext({ allowThreadMessagesInChannel, onAlsoSentToChannelHeaderPress, closeThread, loadMoreThread, openThread, reloadThread, setThreadLoadingMore, thread, threadHasMore, threadInstance, threadLoadingMore, threadMessages, }); const typingContext = useCreateTypingContext({ typing: channelState.typing ?? {}, }); const audioPlayerContext = useMemo( () => ({ allowConcurrentAudioPlayback }), [allowConcurrentAudioPlayback], ); const messageComposerContext = useMemo( () => ({ channel, thread, threadInstance }), [channel, thread, threadInstance], ); // TODO: replace the null view with appropriate message. Currently this is waiting a design decision. if (deleted) { return null; } if (!channel || (error && channelMessagesState.messages?.length === 0)) { return ; } if (!channel?.cid || !channel.watch) { return ( {t('Please select a channel first')} ); } return ( {children} ); }; export type ChannelProps = Partial> & Pick & { thread?: LocalMessage | ThreadType | null; }; /** * * The wrapper component for a chat channel. Channel needs to be placed inside a Chat component * to receive the StreamChat client instance. MessageList, Thread, and MessageComposer must be * children of the Channel component to receive the ChannelContext. * * @example ./Channel.md */ export const Channel = (props: PropsWithChildren) => { const { client, enableOfflineSupport, isOnline, isMessageAIGenerated } = useChatContext(); const { t } = useTranslationContext(); const threadFromProps = props?.thread; const threadInstance = (threadFromProps as ThreadType)?.threadInstance as Thread; const threadMessage = ( threadInstance ? (threadFromProps as ThreadType).thread : threadFromProps ) as LocalMessage; const thread: ThreadType = { thread: threadMessage, threadInstance, }; const shouldSyncChannel = threadMessage?.id ? !!props.threadList : true; const { setThreadMessages, threadMessages } = useChannelState( props.channel, props.threadList ? threadMessage?.id : undefined, ); return ( ); }; const useStyles = () => { const { theme: { channel: { selectChannel }, semantics, }, } = useTheme(); return useMemo(() => { return StyleSheet.create({ selectChannel: { fontWeight: primitives.typographyFontWeightSemiBold, fontSize: primitives.typographyFontSizeMd, lineHeight: primitives.typographyLineHeightNormal, padding: primitives.spacingMd, color: semantics.textPrimary, ...selectChannel, }, }); }, [selectChannel, semantics]); };