import React, { useEffect, useRef } from 'react'; import { Alert, Clipboard, GestureResponderEvent, Image, Keyboard, PixelRatio, Platform, StyleProp, StyleSheet, ViewStyle, } from 'react-native'; import { TapGestureHandler, TapGestureHandlerGestureEvent } from 'react-native-gesture-handler'; import Animated, { cancelAnimation, runOnJS, useAnimatedGestureHandler, useAnimatedStyle, useSharedValue, withSequence, withTiming, } from 'react-native-reanimated'; import { useCreateMessageContext } from './hooks/useCreateMessageContext'; import { messageActions as defaultMessageActions } from './utils/messageActions'; import { removeReservedFields } from './utils/removeReservedFields'; import { isMessageWithStylesReadByAndDateSeparator, MessageType, } from '../MessageList/hooks/useMessageList'; import { ChannelContextValue, useChannelContext, } from '../../contexts/channelContext/ChannelContext'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; import { KeyboardContextValue, useKeyboardContext, } from '../../contexts/keyboardContext/KeyboardContext'; import { MessageContextValue, MessageProvider, Reactions, } from '../../contexts/messageContext/MessageContext'; import { MessageAction, MessageOverlayContextValue, useMessageOverlayContext, } from '../../contexts/messageOverlayContext/MessageOverlayContext'; import { MessagesContextValue, useMessagesContext, } from '../../contexts/messagesContext/MessagesContext'; import { OverlayContextValue, useOverlayContext, } from '../../contexts/overlayContext/OverlayContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; import { TranslationContextValue, useTranslationContext, } from '../../contexts/translationContext/TranslationContext'; import { Copy, CurveLineLeftUp, Delete, Edit, MessageFlag, Mute, SendUp, ThreadReply, UserDelete, } from '../../icons'; import { triggerHaptic } from '../../native'; import { emojiRegex } from '../../utils/utils'; import type { Attachment, MessageResponse, Reaction } from 'stream-chat'; import type { DefaultAttachmentType, DefaultChannelType, DefaultCommandType, DefaultEventType, DefaultMessageType, DefaultReactionType, DefaultUserType, UnknownType, } from '../../types/types'; const prefetchImage = ({ height, url }: { height: number | string; url: string }) => { if (url.includes('&h=%2A')) { Image.prefetch( url.replace('h=%2A', `h=${PixelRatio.getPixelSizeForLayoutSize(Number(height))}`), ) .catch(() => Image.prefetch(url)) .catch(() => { // do nothing, not a big deal that prefetch failed }); } else { Image.prefetch(url).catch(() => { // do nothing, not a big deal that prefetch failed }); } }; export type TouchableHandlerPayload = { defaultHandler?: () => void; emitter?: | 'card' | 'fileAttachment' | 'gallery' | 'giphy' | 'message' | 'messageContent' | 'messageReplies' | 'reactionList' | 'textLink' | 'textMention'; event?: GestureResponderEvent; }; export type MessageTouchableHandlerPayload< At extends UnknownType = DefaultAttachmentType, Ch extends UnknownType = DefaultChannelType, Co extends string = DefaultCommandType, Ev extends UnknownType = DefaultEventType, Me extends UnknownType = DefaultMessageType, Re extends UnknownType = DefaultReactionType, Us extends UnknownType = DefaultUserType, > = TouchableHandlerPayload & { actionHandlers?: MessageActionHandlers; message?: MessageType; }; export type MessageActionHandlers = { deleteMessage: () => Promise; editMessage: () => void; quotedReply: () => void; resendMessage: () => Promise; showMessageOverlay: () => void; toggleBanUser: () => Promise; toggleMuteUser: () => Promise; toggleReaction: (reactionType: string) => Promise; }; export type MessagePropsWithContext< At extends UnknownType = DefaultAttachmentType, Ch extends UnknownType = DefaultChannelType, Co extends string = DefaultCommandType, Ev extends UnknownType = DefaultEventType, Me extends UnknownType = DefaultMessageType, Re extends UnknownType = DefaultReactionType, Us extends UnknownType = DefaultUserType, > = Pick< ChannelContextValue, | 'channel' | 'disabled' | 'enforceUniqueReaction' | 'isAdmin' | 'isModerator' | 'members' | 'readEventsEnabled' > & Pick, 'client' | 'mutedUsers'> & Pick & Partial, 'groupStyles' | 'message'>> & Pick, 'groupStyles' | 'message'> & Pick< MessagesContextValue, | 'animatedLongPress' | 'blockUser' | 'copyMessage' | 'deleteMessage' | 'dismissKeyboardOnMessageTouch' | 'editMessage' | 'flagMessage' | 'forceAlignMessages' | 'handleBlock' | 'handleCopy' | 'handleDelete' | 'handleEdit' | 'handleFlag' | 'handleMute' | 'handleQuotedReply' | 'handleReaction' | 'handleRetry' | 'handleThreadReply' | 'messageActions' | 'messageContentOrder' | 'MessageSimple' | 'mutesEnabled' | 'muteUser' | 'onDoubleTapMessage' | 'onLongPressMessage' | 'onPressInMessage' | 'onPressMessage' | 'OverlayReactionList' | 'quotedRepliesEnabled' | 'quotedReply' | 'reactionsEnabled' | 'removeMessage' | 'retry' | 'retrySendMessage' | 'selectReaction' | 'setEditingState' | 'setQuotedMessageState' | 'supportedReactions' | 'threadRepliesEnabled' | 'threadReply' | 'updateMessage' > & Pick, 'setData'> & Pick & Pick, 'openThread'> & Pick & { messagesContext: MessagesContextValue; /** * Whether or not users are able to long press messages. */ enableLongPress?: boolean; goToMessage?: (messageId: string) => void; /** * Array of allowed actions or null on message, this can also be a function returning the array. * If all the actions need to be disabled an empty array should be provided as value of prop */ /** * You can call methods available on the Message * component such as handleEdit, handleDelete, handleAction etc. * * Source - [Message](https://github.com/GetStream/stream-chat-react-native/blob/master/src/components/Message/Message.tsx) * * By default, we show the overlay with all the message actions on long press. * * @param message Message object which was long pressed * @param event Event object for onLongPress event **/ onLongPress?: ( payload: Partial>, ) => void; /** * You can call methods available on the Message * component such as handleEdit, handleDelete, handleAction etc. * * Source - [Message](https://github.com/GetStream/stream-chat-react-native/blob/master/src/components/Message/Message.tsx) * * By default, we will dismiss the keyboard on press. * * @param message Message object which was long pressed * @param event Event object for onLongPress event * */ onPress?: ( payload: Partial>, ) => void; onPressIn?: ( payload: Partial>, ) => void; /** * Handler to open the thread on message. This is callback for touch event for replies button. * * @param message A message object to open the thread upon. */ onThreadSelect?: (message: MessageType) => void; showUnreadUnderlay?: boolean; style?: StyleProp; targetedMessage?: boolean; }; /** * Since this component doesn't consume `messages` from `MessagesContext`, * we memoized and broke it up to prevent new messages from re-rendering * each individual Message component. */ const MessageWithContext = < At extends UnknownType = DefaultAttachmentType, Ch extends UnknownType = DefaultChannelType, Co extends string = DefaultCommandType, Ev extends UnknownType = DefaultEventType, Me extends UnknownType = DefaultMessageType, Re extends UnknownType = DefaultReactionType, Us extends UnknownType = DefaultUserType, >( props: MessagePropsWithContext, ) => { const { animatedLongPress = Platform.OS === 'ios' && !props.message.deleted_at, blockUser: blockUserProp, channel, client, copyMessage: copyMessageProp, deleteMessage: deleteMessageProp, disabled, dismissKeyboard, dismissKeyboardOnMessageTouch, editMessage: editMessageProp, enableLongPress = true, enforceUniqueReaction, flagMessage: flagMessageProp, forceAlignMessages = false, goToMessage, groupStyles = ['bottom'], handleBlock, handleCopy, handleDelete, handleEdit, handleFlag, handleMute, handleQuotedReply, handleReaction: handleReactionProp, handleRetry, handleThreadReply, isAdmin, isModerator, lastReceivedId, members, message, messageActions: messageActionsProp = defaultMessageActions, messageContentOrder: messageContentOrderProp, messagesContext, MessageSimple, mutesEnabled, muteUser: muteUserProp, onDoubleTapMessage: onDoubleTapMessageProp, onLongPress: onLongPressProp, onLongPressMessage: onLongPressMessageProp, onPress: onPressProp, onPressMessage: onPressMessageProp, onPressIn: onPressInProp, onPressInMessage: onPressInMessageProp, onThreadSelect, openThread, OverlayReactionList, preventPress, quotedRepliesEnabled, reactionsEnabled, readEventsEnabled, removeMessage, quotedReply: quotedReplyProp, retry: retryProp, retrySendMessage, selectReaction, setData, setEditingState, setOverlay, setQuotedMessageState, showAvatar, showMessageStatus, showUnreadUnderlay, style, supportedReactions, t, targetedMessage, threadList = false, threadRepliesEnabled, threadReply: threadReplyProp, updateMessage, } = props; const { theme: { colors: { accent_blue, accent_red, bg_gradient_start, grey, targetedMessageBackground }, messageSimple: { gallery: { halfSize, size }, targetedMessageUnderlay, }, }, } = useTheme(); const doubleTapRef = useRef(null); const pressActive = useSharedValue(false); const scale = useSharedValue(1); /** * This is a cleanup effect to prevent the onLongPress * handler from being called if the component has dismounted. */ useEffect( () => () => { pressActive.value = false; cancelAnimation(scale); }, [], ); const scaleStyle = useAnimatedStyle( () => ({ transform: [ { scale: scale.value, }, ], }), [], ); const targetedOpacity = useSharedValue(0); const targetedStyle = useAnimatedStyle( () => ({ opacity: targetedOpacity.value, }), [], ); useEffect(() => { targetedOpacity.value = withTiming(targetedMessage ? 1 : 0, { duration: 1000, }); }, [targetedMessage]); const actionsEnabled = message.type === 'regular' && message.status === 'received'; const isMyMessage = client && message && client.userID === message.user?.id; const canModifyMessage = isMyMessage || isModerator || isAdmin; const handleAction = async (name: string, value: string) => { if (message.id) { const data = await channel?.sendAction(message.id, { [name]: value }); if (data?.message) { updateMessage(data.message); } else { removeMessage({ id: message.id, parent_id: message.parent_id, }); } } }; const onPressQuotedMessage = (quotedMessage: MessageType) => { if (!goToMessage) return; pressActive.value = false; cancelAnimation(scale); scale.value = withTiming(1, { duration: 100 }); goToMessage(quotedMessage.id); }; const onPress = (error = message.type === 'error' || message.status === 'failed') => { if (dismissKeyboardOnMessageTouch) { Keyboard.dismiss(); } const quotedMessage = message.quoted_message as MessageType; if (error) { showMessageOverlay(false, true); } else if (quotedMessage) { onPressQuotedMessage(quotedMessage); } }; const alignment = forceAlignMessages && (forceAlignMessages === 'left' || forceAlignMessages === 'right') ? forceAlignMessages : isMyMessage ? 'right' : 'left'; /** * attachments contain files/images or other attachments * * if a URL attachment is present with a file/image then we * show just the text and markdown should make the link * clickable */ const attachments = !message.deleted_at && Array.isArray(message.attachments) ? message.attachments.reduce( (acc, cur) => { if (cur.type === 'file' || cur.type === 'video') { acc.files.push(cur); acc.other = []; // remove other attachments if a file exists } else if (cur.type === 'image' && !cur.title_link && !cur.og_scrape_url) { /** * this next if is not combined with the above one for cases where we have * an image with no url links at all falling back to being an attachment */ if (cur.image_url || cur.thumb_url) { acc.images.push(cur); acc.other = []; // remove other attachments if an image exists } // only add other attachments if there are no files/images } else if (!acc.files.length && !acc.images.length) { acc.other.push(cur); } return acc; }, { files: [] as Attachment[], images: [] as Attachment[], other: [] as Attachment[], }, ) : { files: [] as Attachment[], images: [] as Attachment[], other: [] as Attachment[], }; /** * Check if any actions to prevent long press */ const hasAttachmentActions = !message.deleted_at && Array.isArray(message.attachments) && message.attachments.some((attachment) => attachment.actions && attachment.actions.length); // prefetch images for Gallery component rendering const attachmentImageLength = attachments.images.length; useEffect(() => { if (attachmentImageLength) { attachments.images.slice(0, 4).forEach((image, index) => { const url = image.image_url || image.thumb_url; if (url) { if (attachmentImageLength <= 2) { prefetchImage({ height: size || 200, url }); } else if (attachmentImageLength === 3) { if (index === 0) { prefetchImage({ height: size || 200, url }); } else { prefetchImage({ height: halfSize || 100, url }); } } else { prefetchImage({ height: halfSize || 100, url }); } } }); } }, [attachmentImageLength]); const messageContentOrder = messageContentOrderProp.filter((content) => { if (content === 'quoted_reply') { return !!message.quoted_message; } switch (content) { case 'attachments': return !!attachments.other.length; case 'files': return !!attachments.files.length; case 'gallery': return !!attachments.images.length; case 'text': default: return !!message.text; } }); const onlyEmojis = !attachments.files.length && !attachments.images.length && !attachments.other.length && !!message.text && emojiRegex.test(message.text); const onOpenThread = () => { if (onThreadSelect) { onThreadSelect(message); } if (openThread) { openThread(message); } }; const hasReactions = !!reactionsEnabled && !message.deleted_at && !!message.latest_reactions && message.latest_reactions.length > 0; const clientId = client.userID; const reactions = hasReactions ? supportedReactions.reduce((acc, cur) => { const reactionType = cur.type; const reactionsOfReactionType = message.latest_reactions?.filter( (reaction) => reaction.type === reactionType, ); if (reactionsOfReactionType?.length) { const hasOwnReaction = reactionsOfReactionType.some( (reaction) => reaction.user_id === clientId, ); acc.push({ own: hasOwnReaction, type: reactionType }); } return acc; }, [] as Reactions) : []; const handleToggleReaction = async (reactionType: string) => { const messageId = message.id; const ownReaction = !!reactions.find( (reaction) => reaction.own && reaction.type === reactionType, ); // Change reaction in local state, make API call in background, revert to old message if fails try { if (channel && messageId) { if (ownReaction) { await channel.deleteReaction(messageId, reactionType); } else { await channel.sendReaction( messageId, { type: reactionType, } as Reaction, { enforce_unique: enforceUniqueReaction }, ); } } } catch (err) { console.log(err); } }; const handleResendMessage = () => retrySendMessage(message as MessageResponse); const handleQuotedReplyMessage = () => { setQuotedMessageState(message); }; const isMuted = (client.mutedUsers || []).some( (mute) => mute.user.id === client.userID && mute.target.id === message.user?.id, ); const handleToggleMuteUser = async () => { if (!message.user?.id) { return; } if (isMuted) { await client.unmuteUser(message.user.id); } else { await client.muteUser(message.user.id); } }; const handleDeleteMessage = async () => { const data = await client.deleteMessage(message.id); updateMessage(data.message); }; const handleEditMessage = () => { setEditingState(message); }; const handleToggleBanUser = async () => { const messageUser = message.user; if (!messageUser) { return; } if (messageUser.banned) { await client.unbanUser(messageUser.id); } else { await client.banUser(messageUser.id); } }; const showMessageOverlay = async ( messageReactions = false, error = message.type === 'error' || message.status === 'failed', ) => { await dismissKeyboard(); const blockUser = blockUserProp ? blockUserProp(message) : blockUserProp === null ? null : { action: async () => { setOverlay('none'); if (message.user?.id) { if (handleBlock) { handleBlock(message); } await handleToggleBanUser(); } }, icon: , title: message.user?.banned ? t('Unblock User') : t('Block User'), }; const copyMessage = copyMessageProp ? copyMessageProp(message) : copyMessageProp === null ? null : { // using depreciated Clipboard from react-native until expo supports the community version or their own action: () => { setOverlay('none'); if (handleCopy) { handleCopy(message); } Clipboard.setString(message.text || ''); }, icon: , title: t('Copy Message'), }; const deleteMessage = deleteMessageProp ? deleteMessageProp(message) : deleteMessageProp === null ? null : { action: () => { setOverlay('alert'); if (message.id) { Alert.alert( t('Delete Message'), t('Are you sure you want to permanently delete this message?'), [ { onPress: () => setOverlay('none'), text: t('Cancel') }, { onPress: async () => { setOverlay('none'); if (handleDelete) { handleDelete(message); } await handleDeleteMessage(); }, style: 'destructive', text: t('Delete'), }, ], { cancelable: false }, ); } }, icon: , title: t('Delete Message'), titleStyle: { color: accent_red }, }; const editMessage = editMessageProp ? editMessageProp(message) : editMessageProp === null ? null : { action: () => { setOverlay('none'); if (handleEdit) { handleEdit(message); } handleEditMessage(); }, icon: , title: t('Edit Message'), }; const flagMessage = flagMessageProp ? flagMessageProp(message) : flagMessageProp === null ? null : { action: () => { setOverlay('alert'); if (message.id) { Alert.alert( t('Flag Message'), t( 'Do you want to send a copy of this message to a moderator for further investigation?', ), [ { onPress: () => setOverlay('none'), text: t('Cancel') }, { onPress: async () => { try { if (handleFlag) { handleFlag(message); } await client.flagMessage(message.id); Alert.alert( t('Message flagged'), t('The message has been reported to a moderator.'), [ { onPress: () => setOverlay('none'), text: t('Dismiss'), }, ], ); } catch (err) { Alert.alert( t('Something went wrong'), t("The operation couldn't be completed."), [ { onPress: () => setOverlay('none'), text: t('Dismiss'), }, ], ); } }, text: t('Flag'), }, ], { cancelable: false }, ); } }, icon: , title: t('Flag Message'), }; const handleReaction = !error ? selectReaction ? selectReaction(message) : async (reactionType: string) => { if (handleReactionProp) { handleReactionProp(message, reactionType); } await handleToggleReaction(reactionType); } : undefined; const muteUser = muteUserProp ? muteUserProp(message) : muteUserProp === null ? null : { action: async () => { setOverlay('none'); if (message.user?.id) { if (handleMute) { handleMute(message); } await handleToggleMuteUser(); } }, icon: , title: isMuted ? t('Unmute User') : t('Mute User'), }; const quotedReply = quotedReplyProp ? quotedReplyProp(message) : quotedReplyProp === null ? null : { action: () => { setOverlay('none'); if (handleQuotedReply) { handleQuotedReply(message); } handleQuotedReplyMessage(); }, icon: , title: t('Reply'), }; const retry = retryProp ? retryProp(message) : retryProp === null ? null : { action: async () => { setOverlay('none'); const messageWithoutReservedFields = removeReservedFields(message); if (handleRetry) { handleRetry(messageWithoutReservedFields); } await handleResendMessage(); }, icon: , title: t('Resend'), }; const threadReply = threadReplyProp ? threadReplyProp(message) : threadReplyProp === null ? null : { action: () => { setOverlay('none'); if (handleThreadReply) { handleThreadReply(message); } onOpenThread(); }, icon: , title: t('Thread Reply'), }; const isThreadMessage = threadList || !!message.parent_id; const dismissOverlay = () => setOverlay('none'); const messageActions = typeof messageActionsProp !== 'function' ? messageActionsProp : messageActionsProp({ blockUser, canModifyMessage, copyMessage, deleteMessage, dismissOverlay, editMessage, error, flagMessage, isMyMessage, isThreadMessage, message, messageReactions, mutesEnabled, muteUser, quotedRepliesEnabled, quotedReply, retry, threadRepliesEnabled, threadReply, }); setData({ alignment, clientId: client.userID, files: attachments.files, groupStyles, handleReaction: reactionsEnabled ? handleReaction : undefined, images: attachments.images, message, messageActions: messageActions?.filter(Boolean) as MessageAction[] | undefined, messageContext: { ...messageContext, disabled: true, preventPress: true }, messageReactionTitle: !error && messageReactions ? t('Message Reactions') : undefined, messagesContext: { ...messagesContext, messageContentOrder }, onlyEmojis, otherAttachments: attachments.other, OverlayReactionList, supportedReactions, threadList, }); setOverlay('message'); }; const actionHandlers: MessageActionHandlers = { deleteMessage: handleDeleteMessage, editMessage: handleEditMessage, quotedReply: handleQuotedReplyMessage, resendMessage: handleResendMessage, showMessageOverlay, toggleBanUser: handleToggleBanUser, toggleMuteUser: handleToggleMuteUser, toggleReaction: handleToggleReaction, }; const onLongPressMessage = disabled || hasAttachmentActions ? () => null : onLongPressMessageProp ? (payload?: TouchableHandlerPayload) => onLongPressMessageProp({ actionHandlers, defaultHandler: payload?.defaultHandler || showMessageOverlay, emitter: payload?.emitter || 'message', event: payload?.event, message, }) : onLongPressProp ? (payload?: TouchableHandlerPayload) => onLongPressProp({ actionHandlers, defaultHandler: payload?.defaultHandler || showMessageOverlay, emitter: payload?.emitter || 'message', event: payload?.event, }) : enableLongPress ? () => showMessageOverlay(false) : () => null; const onDoubleTapMessage = () => { if (onDoubleTapMessageProp) { onDoubleTapMessageProp({ actionHandlers, message }); } }; const messageContext = useCreateMessageContext({ actionsEnabled, alignment, canModifyMessage, channel, disabled, files: attachments.files, goToMessage, groupStyles, handleAction, handleDeleteMessage, handleEditMessage, handleQuotedReplyMessage, handleResendMessage, handleToggleBanUser, handleToggleMuteUser, handleToggleReaction, hasReactions, images: attachments.images, isMyMessage, lastGroupMessage: groupStyles?.[0] === 'single' || groupStyles?.[0] === 'bottom', lastReceivedId, members, message, messageContentOrder, onLongPress: !animatedLongPress ? onLongPressMessage : () => null, onlyEmojis, onOpenThread, onPress: (payload) => { onPressProp ? onPressProp({ actionHandlers, defaultHandler: payload.defaultHandler || onPress, emitter: payload.emitter || 'message', event: payload.event, message, }) : onPressMessageProp ? onPressMessageProp({ actionHandlers, defaultHandler: payload.defaultHandler || onPress, emitter: payload.emitter || 'message', event: payload.event, message, }) : payload.defaultHandler ? payload.defaultHandler() : onPress(); }, onPressIn: onPressInProp || onPressInMessageProp ? (payload) => { onPressInProp ? onPressInProp({ actionHandlers, defaultHandler: payload.defaultHandler, emitter: payload.emitter || 'message', event: payload.event, message, }) : onPressInMessageProp && onPressInMessageProp({ actionHandlers, defaultHandler: payload.defaultHandler, emitter: payload.emitter || 'message', event: payload.event, message, }); } : null, otherAttachments: attachments.other, preventPress, reactions, readEventsEnabled, showAvatar, showMessageOverlay, showMessageStatus: typeof showMessageStatus === 'boolean' ? showMessageStatus : isMyMessage, threadList, }); const onLongPressTouchable = useAnimatedGestureHandler( { onFinish: () => { pressActive.value = false; cancelAnimation(scale); scale.value = withTiming(1, { duration: 100 }); }, onStart: () => { pressActive.value = true; cancelAnimation(scale); /** * React native longPress active occurs on 370ms, * to hijack this we must make sure the timing is * longer, otherwise onPress will fire instead */ scale.value = withSequence( withTiming(1, { duration: 100 }), withTiming(0.98, { duration: 400 }, () => { if (pressActive.value) { runOnJS(onLongPressMessage)(); runOnJS(triggerHaptic)('impactMedium'); } }), withTiming(1.02, { duration: 100 }), withTiming(1.0, { duration: 300 }), ); }, }, [onLongPressMessage], ); const onDoubleTap = useAnimatedGestureHandler( { onActive: () => { pressActive.value = false; cancelAnimation(scale); scale.value = withTiming(1, { duration: 100 }); runOnJS(onDoubleTapMessage)(); }, }, [onDoubleTapMessage], ); return message.deleted_at || messageContentOrder.length ? ( ) : null; }; const areEqual = < At extends UnknownType = DefaultAttachmentType, Ch extends UnknownType = DefaultChannelType, Co extends string = DefaultCommandType, Ev extends UnknownType = DefaultEventType, Me extends UnknownType = DefaultMessageType, Re extends UnknownType = DefaultReactionType, Us extends UnknownType = DefaultUserType, >( prevProps: MessagePropsWithContext, nextProps: MessagePropsWithContext, ) => { const { goToMessage: prevGoToMessage, lastReceivedId: prevLastReceivedId, message: prevMessage, mutedUsers: prevMutedUsers, showUnreadUnderlay: prevShowUnreadUnderlay, t: prevT, targetedMessage: prevTargetedMessage, } = prevProps; const { goToMessage: nextGoToMessage, lastReceivedId: nextLastReceivedId, message: nextMessage, mutedUsers: nextMutedUsers, showUnreadUnderlay: nextShowUnreadUnderlay, t: nextT, targetedMessage: nextTargetedMessage, } = nextProps; const repliesEqual = prevMessage.reply_count === nextMessage.reply_count; if (!repliesEqual) return false; const lastReceivedIdChangedAndMatters = prevLastReceivedId !== nextLastReceivedId && (prevLastReceivedId === prevMessage.id || prevLastReceivedId === nextMessage.id || nextLastReceivedId === prevMessage.id || nextLastReceivedId === nextMessage.id); if (lastReceivedIdChangedAndMatters) return false; const goToMessageChangedAndMatters = nextMessage.quoted_message_id && prevGoToMessage !== nextGoToMessage; if (goToMessageChangedAndMatters) return false; const messageEqual = prevMessage.deleted_at === nextMessage.deleted_at && (isMessageWithStylesReadByAndDateSeparator(prevMessage) && prevMessage.readBy) === (isMessageWithStylesReadByAndDateSeparator(nextMessage) && nextMessage.readBy) && prevMessage.status === nextMessage.status && prevMessage.type === nextMessage.type && prevMessage.text === nextMessage.text && prevMessage.updated_at === nextMessage.updated_at; if (!messageEqual) return false; const quotedMessageEqual = prevMessage.quoted_message?.id === nextMessage.quoted_message?.id && prevMessage.quoted_message?.deleted_at === nextMessage.quoted_message?.deleted_at; if (!quotedMessageEqual) return false; const messageUserBannedEqual = prevMessage.user?.banned === nextMessage.user?.banned; if (!messageUserBannedEqual) return false; const prevMessageAttachments = prevMessage.attachments; const nextMessageAttachments = nextMessage.attachments; const attachmentsEqual = (Array.isArray(prevMessageAttachments) && Array.isArray(nextMessageAttachments) && prevMessageAttachments.length === nextMessageAttachments.length && prevMessageAttachments.every((attachment, index) => attachment.type === 'image' ? attachment.image_url === nextMessageAttachments[index].image_url && attachment.thumb_url === nextMessageAttachments[index].thumb_url : attachment.type === nextMessageAttachments[index].type, )) || prevMessageAttachments === nextMessageAttachments; if (!attachmentsEqual) return false; const latestReactionsEqual = Array.isArray(prevMessage.latest_reactions) && Array.isArray(nextMessage.latest_reactions) ? prevMessage.latest_reactions.length === nextMessage.latest_reactions.length && prevMessage.latest_reactions.every( ({ type }, index) => type === nextMessage.latest_reactions?.[index].type, ) : prevMessage.latest_reactions === nextMessage.latest_reactions; if (!latestReactionsEqual) return false; const mutedUserSame = prevMutedUsers.length === nextMutedUsers.length || prevMutedUsers.some((mutedUser) => mutedUser.target.id === prevMessage.user?.id) === nextMutedUsers.some((mutedUser) => mutedUser.target.id === nextMessage.user?.id); if (!mutedUserSame) return false; const showUnreadUnderlayEqual = prevShowUnreadUnderlay === nextShowUnreadUnderlay; if (!showUnreadUnderlayEqual) return false; const tEqual = prevT === nextT; if (!tEqual) return false; const targetedMessageEqual = prevTargetedMessage === nextTargetedMessage; if (!targetedMessageEqual) return false; return true; }; const MemoizedMessage = React.memo(MessageWithContext, areEqual) as typeof MessageWithContext; export type MessageProps< At extends UnknownType = DefaultAttachmentType, Ch extends UnknownType = DefaultChannelType, Co extends string = DefaultCommandType, Ev extends UnknownType = DefaultEventType, Me extends UnknownType = DefaultMessageType, Re extends UnknownType = DefaultReactionType, Us extends UnknownType = DefaultUserType, > = Partial, 'groupStyles' | 'message'>> & Pick, 'groupStyles' | 'message'>; /** * Message - A high level component which implements all the logic required for a message. * The actual rendering of the message is delegated via the "Message" property * * @example ./Message.md */ export const Message = < At extends UnknownType = DefaultAttachmentType, Ch extends UnknownType = DefaultChannelType, Co extends string = DefaultCommandType, Ev extends UnknownType = DefaultEventType, Me extends UnknownType = DefaultMessageType, Re extends UnknownType = DefaultReactionType, Us extends UnknownType = DefaultUserType, >( props: MessageProps, ) => { const { channel, disabled, enforceUniqueReaction, isAdmin, isModerator, members, readEventsEnabled, } = useChannelContext(); const { client, mutedUsers } = useChatContext(); const { dismissKeyboard } = useKeyboardContext(); const { setData } = useMessageOverlayContext(); const messagesContext = useMessagesContext(); const { setOverlay } = useOverlayContext(); const { openThread } = useThreadContext(); const { t } = useTranslationContext(); return ( {...messagesContext} {...{ channel, client, disabled, dismissKeyboard, enforceUniqueReaction, isAdmin, isModerator, members, messagesContext, mutedUsers, openThread, readEventsEnabled, setData, setOverlay, t, }} {...props} /> ); };