diff --git a/native/chat/chat-item-height-measurer.react.js b/native/chat/chat-item-height-measurer.react.js index edc3b12a1..6314ae598 100644 --- a/native/chat/chat-item-height-measurer.react.js +++ b/native/chat/chat-item-height-measurer.react.js @@ -1,167 +1,170 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import type { ChatMessageItem } from 'lib/selectors/chat-selectors'; import { messageID } from 'lib/shared/message-utils'; import { messageTypes, type MessageType } from 'lib/types/message-types'; import NodeHeightMeasurer from '../components/node-height-measurer.react'; import { InputStateContext } from '../input/input-state'; import type { MeasurementTask } from './chat-context-provider.react'; import { useComposedMessageMaxWidth } from './composed-message-width'; import { dummyNodeForRobotextMessageHeightMeasurement } from './inner-robotext-message.react'; import { dummyNodeForTextMessageHeightMeasurement } from './inner-text-message.react'; import { MessageListContextProvider } from './message-list-types'; import { multimediaMessageContentSizes } from './multimedia-message-utils'; import { chatMessageItemKey } from './utils'; type Props = { +measurement: MeasurementTask, }; const heightMeasurerKey = (item: ChatMessageItem) => { if (item.itemType !== 'message') { return null; } const { messageInfo } = item; if (messageInfo.type === messageTypes.TEXT) { return messageInfo.text; } else if (item.robotext && typeof item.robotext === 'string') { return item.robotext; } return null; }; const heightMeasurerDummy = (item: ChatMessageItem) => { invariant( item.itemType === 'message', 'NodeHeightMeasurer asked for dummy for non-message item', ); const { messageInfo } = item; if (messageInfo.type === messageTypes.TEXT) { return dummyNodeForTextMessageHeightMeasurement(messageInfo.text); } else if (item.robotext && typeof item.robotext === 'string') { return dummyNodeForRobotextMessageHeightMeasurement(item.robotext); } invariant(false, 'NodeHeightMeasurer asked for dummy for non-text message'); }; function ChatItemHeightMeasurer(props: Props) { const composedMessageMaxWidth = useComposedMessageMaxWidth(); const inputState = React.useContext(InputStateContext); const inputStatePendingUploads = inputState?.pendingUploads; const { measurement } = props; const { threadInfo } = measurement; const heightMeasurerMergeItem = React.useCallback( (item: ChatMessageItem, height: ?number) => { if (item.itemType !== 'message') { return item; } const { messageInfo } = item; const messageType: MessageType = messageInfo.type; invariant( messageType !== messageTypes.SIDEBAR_SOURCE, 'Sidebar source messages should be replaced by sourceMessage before being measured', ); if ( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { // Conditional due to Flow... const localMessageInfo = item.localMessageInfo ? item.localMessageInfo : null; const id = messageID(messageInfo); const pendingUploads = inputStatePendingUploads?.[id]; const sizes = multimediaMessageContentSizes( messageInfo, composedMessageMaxWidth, ); return { itemType: 'message', messageShapeType: 'multimedia', messageInfo, localMessageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, threadCreatedFromMessage: item.threadCreatedFromMessage, pendingUploads, + reactions: item.reactions, ...sizes, }; } invariant( height !== null && height !== undefined, 'height should be set', ); if (messageInfo.type === messageTypes.TEXT) { // Conditional due to Flow... const localMessageInfo = item.localMessageInfo ? item.localMessageInfo : null; return { itemType: 'message', messageShapeType: 'text', messageInfo, localMessageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, threadCreatedFromMessage: item.threadCreatedFromMessage, contentHeight: height, + reactions: item.reactions, }; } else { invariant( typeof item.robotext === 'string', "Flow can't handle our fancy types :(", ); return { itemType: 'message', messageShapeType: 'robotext', messageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, threadCreatedFromMessage: item.threadCreatedFromMessage, robotext: item.robotext, contentHeight: height, + reactions: item.reactions, }; } }, [composedMessageMaxWidth, inputStatePendingUploads, threadInfo], ); return ( ); } const MemoizedChatItemHeightMeasurer: React.ComponentType = React.memo( ChatItemHeightMeasurer, ); export default MemoizedChatItemHeightMeasurer; diff --git a/native/chat/multimedia-message-utils.js b/native/chat/multimedia-message-utils.js index 015a9bd3d..3b0fcea36 100644 --- a/native/chat/multimedia-message-utils.js +++ b/native/chat/multimedia-message-utils.js @@ -1,143 +1,143 @@ // @flow import invariant from 'invariant'; import { messageKey } from 'lib/shared/message-utils'; import type { MediaInfo } from 'lib/types/media-types'; import type { MultimediaMessageInfo } from 'lib/types/message-types'; import type { ChatMultimediaMessageInfoItem, MultimediaContentSizes, } from '../types/chat-types'; import { inlineSidebarStyle, clusterEndHeight } from './chat-constants'; import { failedSendHeight } from './failed-send.react'; import { authorNameHeight } from './message-header.react'; const spaceBetweenImages = 4; function getMediaPerRow(mediaCount: number): number { if (mediaCount === 0) { return 0; // ??? } else if (mediaCount === 1) { return 1; } else if (mediaCount === 2) { return 2; } else if (mediaCount === 3) { return 3; } else if (mediaCount === 4) { return 2; } else { return 3; } } function multimediaMessageSendFailed( item: ChatMultimediaMessageInfoItem, ): boolean { const { messageInfo, localMessageInfo, pendingUploads } = item; const { id: serverID } = messageInfo; if (serverID !== null && serverID !== undefined) { return false; } const { isViewer } = messageInfo.creator; if (!isViewer) { return false; } if (localMessageInfo && localMessageInfo.sendFailed) { return true; } for (const media of messageInfo.media) { const pendingUpload = pendingUploads && pendingUploads[media.id]; if (pendingUpload && pendingUpload.failed) { return true; } } return !pendingUploads; } // The results are merged into ChatMultimediaMessageInfoItem function multimediaMessageContentSizes( messageInfo: MultimediaMessageInfo, composedMessageMaxWidth: number, ): MultimediaContentSizes { invariant(messageInfo.media.length > 0, 'should have media'); if (messageInfo.media.length === 1) { const [media] = messageInfo.media; const { height, width } = media.dimensions; let imageHeight = height; if (width > composedMessageMaxWidth) { imageHeight = (height * composedMessageMaxWidth) / width; } if (imageHeight < 50) { imageHeight = 50; } let contentWidth = height ? (width * imageHeight) / height : 0; if (contentWidth > composedMessageMaxWidth) { contentWidth = composedMessageMaxWidth; } return { imageHeight, contentHeight: imageHeight, contentWidth }; } const contentWidth = composedMessageMaxWidth; const mediaPerRow = getMediaPerRow(messageInfo.media.length); const marginSpace = spaceBetweenImages * (mediaPerRow - 1); const imageHeight = (contentWidth - marginSpace) / mediaPerRow; const numRows = Math.ceil(messageInfo.media.length / mediaPerRow); const contentHeight = numRows * imageHeight + (numRows - 1) * spaceBetweenImages; return { imageHeight, contentHeight, contentWidth }; } // Given a ChatMultimediaMessageInfoItem, determines exact height of row function multimediaMessageItemHeight( item: ChatMultimediaMessageInfoItem, ): number { const { messageInfo, contentHeight, startsCluster, endsCluster } = item; const { creator } = messageInfo; const { isViewer } = creator; let height = 5 + contentHeight; // 5 from marginBottom in ComposedMessage if (!isViewer && startsCluster) { height += authorNameHeight; } if (endsCluster) { height += clusterEndHeight; } if (multimediaMessageSendFailed(item)) { height += failedSendHeight; } - if (item.threadCreatedFromMessage) { + if (item.threadCreatedFromMessage || item.reactions.size > 0) { height += inlineSidebarStyle.height + inlineSidebarStyle.marginTop + inlineSidebarStyle.marginBottom; } return height; } function getMediaKey( item: ChatMultimediaMessageInfoItem, mediaInfo: MediaInfo, ): string { return `multimedia|${messageKey(item.messageInfo)}|${mediaInfo.index}`; } export { multimediaMessageContentSizes, multimediaMessageItemHeight, multimediaMessageSendFailed, getMediaPerRow, spaceBetweenImages, getMediaKey, }; diff --git a/native/chat/utils.js b/native/chat/utils.js index 82fc4e28f..ed14e626d 100644 --- a/native/chat/utils.js +++ b/native/chat/utils.js @@ -1,420 +1,420 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import Animated from 'react-native-reanimated'; import { useMessageListData } from 'lib/selectors/chat-selectors'; import type { ChatMessageItem } from 'lib/selectors/chat-selectors'; import { messageKey } from 'lib/shared/message-utils'; import { colorIsDark, viewerIsMember } from 'lib/shared/thread-utils'; import type { ThreadInfo } from 'lib/types/thread-types'; import { KeyboardContext } from '../keyboard/keyboard-state'; import { OverlayContext } from '../navigation/overlay-context'; import { MultimediaMessageTooltipModalRouteName, RobotextMessageTooltipModalRouteName, TextMessageTooltipModalRouteName, } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import type { ChatMessageInfoItemWithHeight, ChatMessageItemWithHeight, ChatRobotextMessageInfoItemWithHeight, ChatTextMessageInfoItemWithHeight, } from '../types/chat-types'; import type { LayoutCoordinates, VerticalBounds } from '../types/layout-types'; import type { AnimatedViewStyle } from '../types/styles'; import { clusterEndHeight, inlineSidebarStyle } from './chat-constants'; import { ChatContext, useHeightMeasurer } from './chat-context'; import { failedSendHeight } from './failed-send.react'; import { authorNameHeight } from './message-header.react'; import { multimediaMessageItemHeight } from './multimedia-message-utils'; import { getSidebarThreadInfo } from './sidebar-navigation'; import textMessageSendFailed from './text-message-send-failed'; import { timestampHeight } from './timestamp.react'; /* eslint-disable import/no-named-as-default-member */ const { Node, Extrapolate, interpolateNode, interpolateColors, block, call, eq, cond, sub, } = Animated; /* eslint-enable import/no-named-as-default-member */ function textMessageItemHeight( item: ChatTextMessageInfoItemWithHeight, ): number { const { messageInfo, contentHeight, startsCluster, endsCluster } = item; const { isViewer } = messageInfo.creator; let height = 5 + contentHeight; // 5 from marginBottom in ComposedMessage if (!isViewer && startsCluster) { height += authorNameHeight; } if (endsCluster) { height += clusterEndHeight; } if (textMessageSendFailed(item)) { height += failedSendHeight; } - if (item.threadCreatedFromMessage) { + if (item.threadCreatedFromMessage || item.reactions.size > 0) { height += inlineSidebarStyle.height + inlineSidebarStyle.marginTop + inlineSidebarStyle.marginBottom; } return height; } function robotextMessageItemHeight( item: ChatRobotextMessageInfoItemWithHeight, ): number { - if (item.threadCreatedFromMessage) { + if (item.threadCreatedFromMessage || item.reactions.size > 0) { return item.contentHeight + inlineSidebarStyle.height; } return item.contentHeight; } function messageItemHeight(item: ChatMessageInfoItemWithHeight): number { let height = 0; if (item.messageShapeType === 'text') { height += textMessageItemHeight(item); } else if (item.messageShapeType === 'multimedia') { height += multimediaMessageItemHeight(item); } else { height += robotextMessageItemHeight(item); } if (item.startsConversation) { height += timestampHeight; } return height; } function chatMessageItemHeight(item: ChatMessageItemWithHeight): number { if (item.itemType === 'loader') { return 56; } return messageItemHeight(item); } function useMessageTargetParameters( sourceMessage: ChatMessageInfoItemWithHeight, initialCoordinates: LayoutCoordinates, messageListVerticalBounds: VerticalBounds, currentInputBarHeight: number, targetInputBarHeight: number, sidebarThreadInfo: ?ThreadInfo, ): { +position: number, +color: string, } { const messageListData = useMessageListData({ searching: false, userInfoInputArray: [], threadInfo: sidebarThreadInfo, }); const [ messagesWithHeight, setMessagesWithHeight, ] = React.useState>(null); const measureMessages = useHeightMeasurer(); React.useEffect(() => { if (messageListData) { measureMessages( messageListData, sidebarThreadInfo, setMessagesWithHeight, ); } }, [measureMessages, messageListData, sidebarThreadInfo]); const sourceMessageID = sourceMessage.messageInfo?.id; const targetDistanceFromBottom = React.useMemo(() => { if (!messagesWithHeight) { return 0; } let offset = 0; for (const message of messagesWithHeight) { offset += chatMessageItemHeight(message); if (message.messageInfo && message.messageInfo.id === sourceMessageID) { return offset; } } return ( messageListVerticalBounds.height + chatMessageItemHeight(sourceMessage) ); }, [ messageListVerticalBounds.height, messagesWithHeight, sourceMessage, sourceMessageID, ]); if (!sidebarThreadInfo) { return { position: 0, color: sourceMessage.threadInfo.color, }; } const authorNameComponentHeight = sourceMessage.messageInfo.creator.isViewer ? 0 : authorNameHeight; const currentDistanceFromBottom = messageListVerticalBounds.height + messageListVerticalBounds.y - initialCoordinates.y + timestampHeight + authorNameComponentHeight + currentInputBarHeight; return { position: targetDistanceFromBottom + targetInputBarHeight - currentDistanceFromBottom, color: sidebarThreadInfo.color, }; } type AnimatedMessageArgs = { +sourceMessage: ChatMessageInfoItemWithHeight, +initialCoordinates: LayoutCoordinates, +messageListVerticalBounds: VerticalBounds, +progress: Node, +targetInputBarHeight: ?number, }; function useAnimatedMessageTooltipButton({ sourceMessage, initialCoordinates, messageListVerticalBounds, progress, targetInputBarHeight, }: AnimatedMessageArgs): { +style: AnimatedViewStyle, +threadColorOverride: ?Node, +isThreadColorDarkOverride: ?boolean, } { const chatContext = React.useContext(ChatContext); invariant(chatContext, 'chatContext should be set'); const { currentTransitionSidebarSourceID, setCurrentTransitionSidebarSourceID, chatInputBarHeights, sidebarAnimationType, setSidebarAnimationType, } = chatContext; const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const sidebarThreadInfo = React.useMemo(() => { return getSidebarThreadInfo(sourceMessage, viewerID); }, [sourceMessage, viewerID]); const currentInputBarHeight = chatInputBarHeights.get(sourceMessage.threadInfo.id) ?? 0; const keyboardState = React.useContext(KeyboardContext); const viewerIsSidebarMember = viewerIsMember(sidebarThreadInfo); React.useEffect(() => { const newSidebarAnimationType = !currentInputBarHeight || !targetInputBarHeight || keyboardState?.keyboardShowing || !viewerIsSidebarMember ? 'fade_source_message' : 'move_source_message'; setSidebarAnimationType(newSidebarAnimationType); }, [ currentInputBarHeight, keyboardState?.keyboardShowing, setSidebarAnimationType, sidebarThreadInfo, targetInputBarHeight, viewerIsSidebarMember, ]); const { position: targetPosition, color: targetColor, } = useMessageTargetParameters( sourceMessage, initialCoordinates, messageListVerticalBounds, currentInputBarHeight, targetInputBarHeight ?? currentInputBarHeight, sidebarThreadInfo, ); React.useEffect(() => { return () => setCurrentTransitionSidebarSourceID(null); }, [setCurrentTransitionSidebarSourceID]); const bottom = React.useMemo( () => interpolateNode(progress, { inputRange: [0.3, 1], outputRange: [targetPosition, 0], extrapolate: Extrapolate.CLAMP, }), [progress, targetPosition], ); const [ isThreadColorDarkOverride, setThreadColorDarkOverride, ] = React.useState(null); const setThreadColorBrightness = React.useCallback(() => { const isSourceThreadDark = colorIsDark(sourceMessage.threadInfo.color); const isTargetThreadDark = colorIsDark(targetColor); if (isSourceThreadDark !== isTargetThreadDark) { setThreadColorDarkOverride(isTargetThreadDark); } }, [sourceMessage.threadInfo.color, targetColor]); const threadColorOverride = React.useMemo(() => { if ( sourceMessage.messageShapeType !== 'text' || !currentTransitionSidebarSourceID ) { return null; } return block([ cond(eq(progress, 1), call([], setThreadColorBrightness)), interpolateColors(progress, { inputRange: [0, 1], outputColorRange: [ `#${targetColor}`, `#${sourceMessage.threadInfo.color}`, ], }), ]); }, [ currentTransitionSidebarSourceID, progress, setThreadColorBrightness, sourceMessage.messageShapeType, sourceMessage.threadInfo.color, targetColor, ]); const messageContainerStyle = React.useMemo(() => { return { bottom: currentTransitionSidebarSourceID ? bottom : 0, opacity: currentTransitionSidebarSourceID && sidebarAnimationType === 'fade_source_message' ? 0 : 1, }; }, [bottom, currentTransitionSidebarSourceID, sidebarAnimationType]); return { style: messageContainerStyle, threadColorOverride, isThreadColorDarkOverride, }; } function getMessageTooltipKey(item: ChatMessageInfoItemWithHeight): string { return `tooltip|${messageKey(item.messageInfo)}`; } function isMessageTooltipKey(key: string): boolean { return key.startsWith('tooltip|'); } function useOverlayPosition(item: ChatMessageInfoItemWithHeight) { const overlayContext = React.useContext(OverlayContext); invariant(overlayContext, 'should be set'); for (const overlay of overlayContext.visibleOverlays) { if ( (overlay.routeName === MultimediaMessageTooltipModalRouteName || overlay.routeName === TextMessageTooltipModalRouteName || overlay.routeName === RobotextMessageTooltipModalRouteName) && overlay.routeKey === getMessageTooltipKey(item) ) { return overlay.position; } } return undefined; } function useContentAndHeaderOpacity( item: ChatMessageInfoItemWithHeight, ): number | Node { const overlayPosition = useOverlayPosition(item); const chatContext = React.useContext(ChatContext); return React.useMemo( () => overlayPosition && chatContext?.sidebarAnimationType === 'move_source_message' ? sub( 1, interpolateNode(overlayPosition, { inputRange: [0.05, 0.06], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }), ) : 1, [chatContext?.sidebarAnimationType, overlayPosition], ); } function useDeliveryIconOpacity( item: ChatMessageInfoItemWithHeight, ): number | Node { const overlayPosition = useOverlayPosition(item); const chatContext = React.useContext(ChatContext); return React.useMemo(() => { if ( !overlayPosition || !chatContext?.currentTransitionSidebarSourceID || chatContext?.sidebarAnimationType === 'fade_source_message' ) { return 1; } return interpolateNode(overlayPosition, { inputRange: [0.05, 0.06, 1], outputRange: [1, 0, 0], extrapolate: Extrapolate.CLAMP, }); }, [ chatContext?.currentTransitionSidebarSourceID, chatContext?.sidebarAnimationType, overlayPosition, ]); } function chatMessageItemKey( item: ChatMessageItemWithHeight | ChatMessageItem, ): string { if (item.itemType === 'loader') { return 'loader'; } return messageKey(item.messageInfo); } export { chatMessageItemKey, chatMessageItemHeight, useAnimatedMessageTooltipButton, messageItemHeight, getMessageTooltipKey, isMessageTooltipKey, useContentAndHeaderOpacity, useDeliveryIconOpacity, }; diff --git a/native/types/chat-types.js b/native/types/chat-types.js index f1dfb974d..a74ddfe41 100644 --- a/native/types/chat-types.js +++ b/native/types/chat-types.js @@ -1,65 +1,69 @@ // @flow +import type { MessageReactionInfo } from 'lib/selectors/chat-selectors'; import type { LocalMessageInfo, MultimediaMessageInfo, RobotextMessageInfo, } from 'lib/types/message-types'; import type { TextMessageInfo } from 'lib/types/messages/text'; import type { ThreadInfo } from 'lib/types/thread-types'; import type { MessagePendingUploads } from '../input/input-state'; export type ChatRobotextMessageInfoItemWithHeight = { +itemType: 'message', +messageShapeType: 'robotext', +messageInfo: RobotextMessageInfo, +threadInfo: ThreadInfo, +startsConversation: boolean, +startsCluster: boolean, +endsCluster: boolean, +robotext: string, +threadCreatedFromMessage: ?ThreadInfo, +contentHeight: number, + +reactions: $ReadOnlyMap, }; export type ChatTextMessageInfoItemWithHeight = { +itemType: 'message', +messageShapeType: 'text', +messageInfo: TextMessageInfo, +localMessageInfo: ?LocalMessageInfo, +threadInfo: ThreadInfo, +startsConversation: boolean, +startsCluster: boolean, +endsCluster: boolean, +contentHeight: number, +threadCreatedFromMessage: ?ThreadInfo, + +reactions: $ReadOnlyMap, }; export type MultimediaContentSizes = { +imageHeight: number, +contentHeight: number, +contentWidth: number, }; export type ChatMultimediaMessageInfoItem = { ...MultimediaContentSizes, +itemType: 'message', +messageShapeType: 'multimedia', +messageInfo: MultimediaMessageInfo, +localMessageInfo: ?LocalMessageInfo, +threadInfo: ThreadInfo, +startsConversation: boolean, +startsCluster: boolean, +endsCluster: boolean, +threadCreatedFromMessage: ?ThreadInfo, +pendingUploads: ?MessagePendingUploads, + +reactions: $ReadOnlyMap, }; export type ChatMessageInfoItemWithHeight = | ChatRobotextMessageInfoItemWithHeight | ChatTextMessageInfoItemWithHeight | ChatMultimediaMessageInfoItem; export type ChatMessageItemWithHeight = | { itemType: 'loader' } | ChatMessageInfoItemWithHeight;