import { useNavigation } from '@react-navigation/native' import { some } from 'lodash' import React, { memo, useEffect } from 'react' import { AccessibilityActionEvent, Pressable, StyleSheet, useWindowDimensions, View, } from 'react-native' import Animated from 'react-native-reanimated' import { MessageReaction } from '../../components/conversation/message_reaction' import { Avatar, Icon, Spinner, Text, TextButton, TextInlineButton } from '../../components/display' import { useAnimatedMessageBackgroundColor, useInteractionTintedBackgroundColor, useTheme, } from '../../hooks' import { useDeletingIds } from '../../hooks' import { useConversationMessage } from '../../hooks/use_conversation_message' import { useLiveRelativeTime } from '../../hooks/use_live_relative_time' import { useMessageCreateOrUpdate } from '../../hooks/use_message_create_or_update' import { MessageResource } from '../../types' import { DenormalizedMessageAttachmentResource } from '../../types/resources/denormalized_attachment_resource' import { ReactionCountResource } from '../../types/resources/reaction' import { pluralize } from '../../utils' import { isNewMessage } from '../../utils/cache/messages_cache' import { stripEmptyTextCharacters } from '../../utils/message_text' import { Haptic } from '../../utils/native_adapters' import { CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL, MESSAGE_AUTHOR_AVATAR_COLUMN_WIDTH, platformFontWeightMedium, } from '../../utils/styles' import { ComponentErrorBoundary } from '../page/component_error_boundary' import { MessageAttachments } from './message_attachments' import { MessageMarkdown } from './message_markdown' import { MessageReadReceipts } from './message_read_receipts' import { TheirReplyConnector, MyReplyConnector, AVATAR_CONNECTOR_SPACING } from './reply_connectors' /** Message * Component for display of a message within a conversation list */ interface MessageProps extends MessageResource { canDeleteNonAuthoredMessages: boolean conversationDisabled: boolean conversation_id: number readByOthers?: boolean inReplyScreen?: boolean highlighted?: boolean onHighlightShown?: () => void } export const Message = memo(function MessageRow({ canDeleteNonAuthoredMessages, conversationDisabled, conversation_id, readByOthers, inReplyScreen, highlighted = false, onHighlightShown, ...message }: MessageProps) { const { reactionCounts, pending, error, attachments, author } = message const text = stripEmptyTextCharacters(message.text ?? '') const deletingMessageIds = useDeletingIds('deleteMessage') const isDeleting = deletingMessageIds.has(message.id) const styles = useMessageStyles(message.mine, isDeleting) const navigation = useNavigation() const { colors } = useTheme() const hasReactions = reactionCounts.length > 0 const [showMessageMetaToggle, setShowMessageMetaToggle] = React.useState(false) const showMessageMeta = showMessageMetaToggle || message.myLatestInConversation || !!pending || !!error || false const timestamp = useLiveRelativeTime(message.createdAt) const wasEdited = Boolean(message.textEditedAt) const isPersisted = !isNewMessage(message) const [temporarilyHidePendingState, setTemporarilyHidePendingState] = React.useState(true) const [messageBubbleHeight, setMessageBubbleHeight] = React.useState(0) const { animatedBackgroundColor, flash, handleMessagePressIn, handleMessagePressOut } = useAnimatedMessageBackgroundColor() const isReplyRootMessage = message.replyRootId === message.id const isDeletedReplyRootMessage = isReplyRootMessage && !!message.deletedAt const showReadReceipts = message.mine && !pending && !error const replyToReplyRootMessage = !isReplyRootMessage && !!message.replyRootId const { message: fetchedReplyRoot } = useConversationMessage({ conversation_id, messageId: message.replyRootId || '', enabled: replyToReplyRootMessage && !message.replyRoot, }) const replyRoot = message.replyRoot ?? fetchedReplyRoot const metaProps = { authorName: author.name, createdAt: message.createdAt, } const renderAuthor = (!message.mine && message.renderAuthor) || false const showReplyCountButton = !inReplyScreen && isReplyRootMessage const replyCountText = pluralize(message.replyCount, 'reply') const messagePendingLabel = isPersisted ? 'Saving' : 'Sending' const replyRootAuthorName = replyToReplyRootMessage ? replyRoot?.author.name : author.name const messageText = isDeletedReplyRootMessage ? 'Message deleted' : text const messageBottomMargin = message.lastInGroup || message.nextIsReplyShadowMessage ? 16 : hasReactions || showMessageMeta || showReplyCountButton ? 8 : 4 const messageIsReplyLabel = replyToReplyRootMessage ? 'Reply' : '' const attachmentLabel = some(attachments) ? pluralize(attachments.length, 'attachment') : '' const replyCountLabel = isReplyRootMessage ? replyCountText : '' const saveStatusLabel = error ? `Failed to ${isPersisted ? 'edit' : 'send'}` : pending ? messagePendingLabel : '' const accessibilityLabel = `${author?.name || ''} ${messageIsReplyLabel} ${attachmentLabel} ${ messageText || '' } ${saveStatusLabel} ${timestamp} ${replyCountLabel}` useEffect(() => { if (!highlighted) return flash() onHighlightShown?.() }, [highlighted, flash, onHighlightShown]) useEffect(() => { if (pending) { const timer = setTimeout(() => { setTemporarilyHidePendingState(false) }, 2000) return () => clearTimeout(timer) } return () => {} }, [pending]) const retryFailedSaveMutation = useMessageCreateOrUpdate({ conversationId: conversation_id, message, }) const handleRetry = () => { if (text && error) { retryFailedSaveMutation.mutateAsync({ text, attachments: message.attachmentsForCreate, }) } } const handleMessagePress = () => { setShowMessageMetaToggle(!showMessageMetaToggle) } const handleMessageLongPress = () => { if (!isPersisted) return Haptic.impactLight() navigation.navigate('MessageActions', { message_id: message.id, conversation_id, canDeleteNonAuthoredMessages, inReplyScreen, reply_root_author_name: replyRootAuthorName, }) } const handleReactionLongPress = (reaction: ReactionCountResource) => { Haptic.impactLight() navigation.navigate('Reactions', { message_id: message.id, conversation_id, reaction_value: reaction.value, }) } const handleMessageAttachmentLongPress = (attachment: DenormalizedMessageAttachmentResource) => { Haptic.impactLight() navigation.navigate('AttachmentActions', { attachmentId: attachment?.id, attachmentContentType: attachment?.attributes.contentType, attachmentUrl: attachment?.attributes.url, conversation_id, canDeleteNonAuthoredMessages, myMessage: message.mine, }) } const handleNavigateToReplyPress = () => { navigation.navigate('ConversationReply', { conversation_id, reply_root_id: message.id, reply_root_author_name: author.name, }) } const accessibilityActions = [ { name: 'navigateToReplies', label: 'Navigate to replies', }, ] const handleAccessibilityAction = (event: AccessibilityActionEvent) => { if (event.nativeEvent.actionName === 'navigateToReplies') { handleNavigateToReplyPress() } } return ( {!message.mine && ( {renderAuthor ? ( ) : ( )} )} {renderAuthor && !isDeletedReplyRootMessage && ( {author.name} )} setMessageBubbleHeight(e.nativeEvent.layout.height)} > {isDeletedReplyRootMessage ? ( {messageText} ) : ( <> {text && ( )} )} {showReplyCountButton && ( {replyCountText} )} {hasReactions && !isDeletedReplyRootMessage && ( {reactionCounts.map(reaction => ( ))} )} {showMessageMeta && !isDeletedReplyRootMessage && ( {showReadReceipts && } {error ? ( {retryFailedSaveMutation.isPending ? ( ) : ( )} Failed to {isPersisted ? 'edit' : 'send'} |{' '} Try again ) : pending && (!temporarilyHidePendingState || isPersisted) ? ( {messagePendingLabel} ) : isPersisted ? ( {timestamp} ) : null} {!pending && !error && wasEdited && ( |{' '} Edited )} )} {message.mine && ( )} ) }) const useMessageStyles = (mine: boolean, isDeleting: boolean) => { const { colors } = useTheme() const myMessageBackgroundColor = useInteractionTintedBackgroundColor() const { width } = useWindowDimensions() const tabletWidth = width >= 744 // Smallest iPad Mini's width const messageBubbleBackgroundColor = mine ? myMessageBackgroundColor : colors.fillColorNeutral070 return StyleSheet.create({ message: { gap: 8, flexDirection: mine ? 'row-reverse' : 'row', paddingHorizontal: CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL, opacity: isDeleting ? 0.5 : 1, }, messageContent: { flex: 1, gap: 4, }, avatar: { marginBottom: AVATAR_CONNECTOR_SPACING, }, avatarPlaceholder: { width: MESSAGE_AUTHOR_AVATAR_COLUMN_WIDTH, // Same width as avatar }, authorName: { fontWeight: platformFontWeightMedium, }, messageBubble: { backgroundColor: messageBubbleBackgroundColor, borderRadius: 8, maxWidth: tabletWidth ? 360 : '80%', alignSelf: mine ? 'flex-end' : 'flex-start', }, messageText: { paddingVertical: 6, paddingHorizontal: 8, }, messageReactions: { flexDirection: 'row', gap: 4, justifyContent: mine ? 'flex-end' : 'flex-start', }, messageReplyCount: { alignSelf: mine ? 'flex-end' : 'flex-start', }, messageMeta: { flexDirection: 'row', alignItems: 'center', justifyContent: mine ? 'flex-end' : 'flex-start', gap: 4, }, editedText: { fontWeight: platformFontWeightMedium, }, pendingIndicatorContainer: { flexDirection: 'row', alignItems: 'center', gap: 4, }, errorContainer: { flexDirection: 'row', alignItems: 'center', gap: 4, }, errorIcon: { color: colors.statusErrorIcon, }, errorText: { color: colors.statusErrorText, }, replyRootDeletedText: { color: colors.textColorDefaultSecondary, fontStyle: 'italic', }, }) }