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