diff --git a/native/chat/composed-message.react.js b/native/chat/composed-message.react.js index f97c89bbf..624a2c5ab 100644 --- a/native/chat/composed-message.react.js +++ b/native/chat/composed-message.react.js @@ -1,434 +1,434 @@ // @flow import Icon from '@expo/vector-icons/Feather.js'; import invariant from 'invariant'; import * as React from 'react'; import { StyleSheet, View, TouchableOpacity } from 'react-native'; import { useDerivedValue, withTiming, interpolateColor, useAnimatedStyle, } from 'react-native-reanimated'; import { chatMessageItemHasEngagement } from 'lib/shared/chat-message-item-utils.js'; import { getMessageLabel } from 'lib/shared/edit-messages-utils.js'; import { createMessageReply } from 'lib/shared/message-utils.js'; import { assertComposableMessageType } from 'lib/types/message-types.js'; import { clusterEndHeight, composedMessageStyle, avatarOffset, } from './chat-constants.js'; import { useComposedMessageMaxWidth } from './composed-message-width.js'; import { FailedSend } from './failed-send.react.js'; import { InlineEngagement } from './inline-engagement.react.js'; import { MessageEditingContext } from './message-editing-context.react.js'; import { MessageHeader } from './message-header.react.js'; import { useNavigateToSidebar } from './sidebar-navigation.js'; import SwipeableMessage from './swipeable-message.react.js'; import { useContentAndHeaderOpacity, useDeliveryIconOpacity } from './utils.js'; import UserAvatar from '../avatars/user-avatar.react.js'; import CommIcon from '../components/comm-icon.react.js'; import { InputStateContext } from '../input/input-state.js'; import { useColors } from '../themes/colors.js'; -import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; +import type { ChatComposedMessageInfoItemWithHeight } from '../types/chat-types.js'; import { type AnimatedStyleObj, type ViewStyle, AnimatedView, } from '../types/styles.js'; import { useNavigateToUserProfileBottomSheet } from '../user-profile/user-profile-utils.js'; type SwipeOptions = 'reply' | 'sidebar' | 'both' | 'none'; type Props = { ...React.ElementConfig, - +item: ChatMessageInfoItemWithHeight, + +item: ChatComposedMessageInfoItemWithHeight, +sendFailed: boolean, +focused: boolean, +swipeOptions: SwipeOptions, +shouldDisplayPinIndicator: boolean, +children: React.Node, }; const ConnectedComposedMessage: React.ComponentType = React.memo( function ConnectedComposedMessage(props: Props) { const composedMessageMaxWidth = useComposedMessageMaxWidth(); const colors = useColors(); const inputState = React.useContext(InputStateContext); const navigateToSidebar = useNavigateToSidebar(props.item); const contentAndHeaderOpacity = useContentAndHeaderOpacity(props.item); const deliveryIconOpacity = useDeliveryIconOpacity(props.item); const messageEditingContext = React.useContext(MessageEditingContext); const progress = useDerivedValue(() => { const isThisThread = messageEditingContext?.editState.editedMessage?.threadID === props.item.threadInfo.id; const isHighlighted = messageEditingContext?.editState.editedMessage?.id === props.item.messageInfo.id && isThisThread; return withTiming(isHighlighted ? 1 : 0); }); const editedMessageStyle = useAnimatedStyle(() => { const backgroundColor = interpolateColor( progress.value, [0, 1], ['transparent', `#${props.item.threadInfo.color}40`], ); return { backgroundColor, }; }); assertComposableMessageType(props.item.messageInfo.type); const { item, sendFailed, swipeOptions, shouldDisplayPinIndicator, children, focused, ...viewProps } = props; const { hasBeenEdited, isPinned } = item; const { id, creator } = item.messageInfo; const { isViewer } = creator; const alignStyle = isViewer ? styles.rightChatBubble : styles.leftChatBubble; const containerStyle = React.useMemo(() => { let containerMarginBottom = 5; if (item.endsCluster) { containerMarginBottom += clusterEndHeight; } return { marginBottom: containerMarginBottom }; }, [item.endsCluster]); const messageBoxContainerStyle = React.useMemo( () => [ styles.messageBoxContainer, isViewer ? styles.rightChatContainer : styles.leftChatContainer, ], [isViewer], ); const deliveryIcon = React.useMemo(() => { if (!isViewer) { return undefined; } let deliveryIconName; let deliveryIconColor = `#${item.threadInfo.color}`; const notDeliveredP2PMessages = item?.localMessageInfo?.outboundP2PMessageIDs ?? []; if ( id !== null && id !== undefined && notDeliveredP2PMessages.length === 0 ) { deliveryIconName = 'check-circle'; } else if (sendFailed) { deliveryIconName = 'x-circle'; deliveryIconColor = colors.redText; } else { deliveryIconName = 'circle'; } const animatedStyle: AnimatedStyleObj = { opacity: deliveryIconOpacity }; return ( ); }, [ colors.redText, deliveryIconOpacity, id, isViewer, item?.localMessageInfo?.outboundP2PMessageIDs, item.threadInfo.color, sendFailed, ]); const editInputMessage = inputState?.editInputMessage; const reply = React.useCallback(() => { invariant(editInputMessage, 'editInputMessage should be set in reply'); invariant(item.messageInfo.text, 'text should be set in reply'); editInputMessage({ message: createMessageReply(item.messageInfo.text), mode: 'prepend', }); }, [editInputMessage, item.messageInfo.text]); const triggerReply = swipeOptions === 'reply' || swipeOptions === 'both' ? reply : undefined; const triggerSidebar = swipeOptions === 'sidebar' || swipeOptions === 'both' ? navigateToSidebar : undefined; const navigateToUserProfileBottomSheet = useNavigateToUserProfileBottomSheet(); const onPressAvatar = React.useCallback( () => navigateToUserProfileBottomSheet(item.messageInfo.creator.id), [item.messageInfo.creator.id, navigateToUserProfileBottomSheet], ); const avatar = React.useMemo(() => { if (!isViewer && item.endsCluster) { return ( ); } else if (!isViewer) { return ; } else { return undefined; } }, [ isViewer, item.endsCluster, item.messageInfo.creator.id, onPressAvatar, ]); const pinIconPositioning = isViewer ? 'left' : 'right'; const pinIconName = pinIconPositioning === 'left' ? 'pin-mirror' : 'pin'; const messageBoxTopLevelContainerStyle = pinIconPositioning === 'left' ? styles.rightMessageBoxTopLevelContainerStyle : styles.leftMessageBoxTopLevelContainerStyle; const pinIcon = React.useMemo(() => { if (!isPinned || !shouldDisplayPinIndicator) { return undefined; } return ( ); }, [ isPinned, item.threadInfo.color, pinIconName, shouldDisplayPinIndicator, ]); const messageBoxStyle = React.useMemo( () => ({ opacity: contentAndHeaderOpacity, maxWidth: composedMessageMaxWidth, }), [composedMessageMaxWidth, contentAndHeaderOpacity], ); const messageBox = React.useMemo( () => ( {pinIcon} {avatar} {children} ), [ avatar, children, isViewer, item.threadInfo.color, messageBoxContainerStyle, messageBoxStyle, messageBoxTopLevelContainerStyle, pinIcon, triggerReply, triggerSidebar, ], ); const label = getMessageLabel(hasBeenEdited, item.threadInfo.id); const inlineEngagement = React.useMemo(() => { if (!chatMessageItemHasEngagement(item, item.threadInfo.id)) { return undefined; } const positioning = isViewer ? 'right' : 'left'; return ( ); }, [label, isViewer, item]); const viewStyle = React.useMemo(() => { const baseStyle: Array = [styles.alignment]; if (__DEV__) { return baseStyle; } if (item.messageShapeType === 'text') { baseStyle.push({ height: item.contentHeight }); } else if (item.messageShapeType === 'multimedia') { const height = item.inlineEngagementHeight ? item.contentHeight + item.inlineEngagementHeight : item.contentHeight; baseStyle.push({ height }); } return baseStyle; }, [ item.contentHeight, item.inlineEngagementHeight, item.messageShapeType, ]); const messageHeaderStyle = React.useMemo( () => ({ opacity: contentAndHeaderOpacity, }), [contentAndHeaderOpacity], ); const animatedContainerStyle = React.useMemo( () => [containerStyle, editedMessageStyle], [containerStyle, editedMessageStyle], ); const contentStyle = React.useMemo( () => [styles.content, alignStyle], [alignStyle], ); const failedSend = React.useMemo( () => (sendFailed ? : undefined), [item, sendFailed], ); const composedMessage = React.useMemo(() => { return ( {deliveryIcon} {messageBox} {inlineEngagement} {failedSend} ); }, [ animatedContainerStyle, contentStyle, deliveryIcon, failedSend, focused, inlineEngagement, item, messageBox, messageHeaderStyle, viewProps, viewStyle, ]); return composedMessage; }, ); const styles = StyleSheet.create({ alignment: { marginLeft: composedMessageStyle.marginLeft, marginRight: composedMessageStyle.marginRight, }, avatarContainer: { paddingRight: 8, paddingTop: 4, }, avatarOffset: { width: avatarOffset, }, content: { alignItems: 'center', flexDirection: 'row-reverse', }, icon: { fontSize: 16, textAlign: 'center', }, iconContainer: { marginLeft: 2, width: 16, }, leftChatBubble: { justifyContent: 'flex-end', }, leftChatContainer: { alignItems: 'flex-start', }, leftMessageBoxTopLevelContainerStyle: { flexDirection: 'row-reverse', }, messageBoxContainer: { marginRight: 5, }, pinIconContainer: { marginRight: 4, marginTop: 4, }, rightChatBubble: { justifyContent: 'flex-start', }, rightChatContainer: { alignItems: 'flex-end', }, rightMessageBoxTopLevelContainerStyle: { flexDirection: 'row', }, swipeableContainer: { alignItems: 'flex-end', flexDirection: 'row', }, }); export default ConnectedComposedMessage; diff --git a/native/chat/failed-send.react.js b/native/chat/failed-send.react.js index fc4d60c8a..4bd86d16b 100644 --- a/native/chat/failed-send.react.js +++ b/native/chat/failed-send.react.js @@ -1,180 +1,180 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { messageID } from 'lib/shared/message-utils.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type RawComposableMessageInfo, assertComposableRawMessage, } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { multimediaMessageSendFailed } from './multimedia-message-utils.js'; import textMessageSendFailed from './text-message-send-failed.js'; import Button from '../components/button.react.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; -import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; +import type { ChatComposedMessageInfoItemWithHeight } from '../types/chat-types.js'; const failedSendHeight = 22; const unboundStyles = { deliveryFailed: { color: 'listSeparatorLabel', paddingHorizontal: 3, }, failedSendInfo: { flex: 1, flexDirection: 'row', justifyContent: 'flex-end', marginRight: 20, paddingTop: 5, }, retrySend: { paddingHorizontal: 3, }, }; type BaseProps = { - +item: ChatMessageInfoItemWithHeight, + +item: ChatComposedMessageInfoItemWithHeight, }; type Props = { ...BaseProps, +rawMessageInfo: ?RawComposableMessageInfo, +styles: $ReadOnly, +inputState: ?InputState, +parentThreadInfo: ?ThreadInfo, }; class FailedSend extends React.PureComponent { retryingText = false; retryingMedia = false; componentDidUpdate(prevProps: Props) { const newItem = this.props.item; const prevItem = prevProps.item; if ( newItem.messageShapeType === 'multimedia' && prevItem.messageShapeType === 'multimedia' ) { const isFailed = multimediaMessageSendFailed(newItem); const wasFailed = multimediaMessageSendFailed(prevItem); const isDone = newItem.messageInfo.id !== null && newItem.messageInfo.id !== undefined; const wasDone = prevItem.messageInfo.id !== null && prevItem.messageInfo.id !== undefined; if ((isFailed && !wasFailed) || (isDone && !wasDone)) { this.retryingMedia = false; } } else if ( newItem.messageShapeType === 'text' && prevItem.messageShapeType === 'text' ) { const isFailed = textMessageSendFailed(newItem); const wasFailed = textMessageSendFailed(prevItem); const isDone = newItem.messageInfo.id !== null && newItem.messageInfo.id !== undefined; const wasDone = prevItem.messageInfo.id !== null && prevItem.messageInfo.id !== undefined; if ((isFailed && !wasFailed) || (isDone && !wasDone)) { this.retryingText = false; } } } render(): React.Node { if (!this.props.rawMessageInfo) { return null; } const threadColor = { color: `#${this.props.item.threadInfo.color}`, }; return ( DELIVERY FAILED. ); } retrySend = () => { const { rawMessageInfo } = this.props; if (!rawMessageInfo) { return; } if (rawMessageInfo.type === messageTypes.TEXT) { if (this.retryingText) { return; } this.retryingText = true; } else if ( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ) { if (this.retryingMedia) { return; } this.retryingMedia = true; } const { inputState } = this.props; invariant( inputState, 'inputState should be initialized before user can hit retry', ); const { localID } = rawMessageInfo; invariant(localID, 'failed RawMessageInfo should have localID'); void inputState.retryMessage( localID, this.props.item.threadInfo, this.props.parentThreadInfo, ); }; } const ConnectedFailedSend: React.ComponentType = React.memo(function ConnectedFailedSend(props: BaseProps) { const id = messageID(props.item.messageInfo); const rawMessageInfo = useSelector(state => { const message = state.messageStore.messages[id]; return message ? assertComposableRawMessage(message) : null; }); const styles = useStyles(unboundStyles); const inputState = React.useContext(InputStateContext); const { parentThreadID } = props.item.threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); return ( ); }); export { ConnectedFailedSend as FailedSend, failedSendHeight }; diff --git a/native/chat/inline-engagement.react.js b/native/chat/inline-engagement.react.js index 8104e0f1b..9663debfe 100644 --- a/native/chat/inline-engagement.react.js +++ b/native/chat/inline-engagement.react.js @@ -1,550 +1,550 @@ // @flow import { useNavigation } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import Animated, { Extrapolate, interpolateNode, } from 'react-native-reanimated'; import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import { getInlineEngagementSidebarText } from 'lib/shared/inline-engagement-utils.js'; import type { MessageInfo } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { avatarOffset, composedMessageStyle, inlineEngagementLabelStyle, inlineEngagementRightStyle, inlineEngagementStyle, } from './chat-constants.js'; import { useNavigateToThread } from './message-list-types.js'; import { useSendReaction } from './reaction-message-utils.js'; import CommIcon from '../components/comm-icon.react.js'; import GestureTouchableOpacity from '../components/gesture-touchable-opacity.react.js'; import { MessageReactionsModalRouteName } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; -import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; +import type { ChatComposedMessageInfoItemWithHeight } from '../types/chat-types.js'; function dummyNodeForInlineEngagementHeightMeasurement( sidebarInfo: ?ThreadInfo, reactions: ReactionInfo, ): React.Element { return ( ); } type DummyInlineEngagementNodeProps = { ...React.ElementConfig, +editedLabel?: ?string, +sidebarInfo: ?ThreadInfo, +reactions: ReactionInfo, }; function DummyInlineEngagementNode( props: DummyInlineEngagementNodeProps, ): React.Node { const { editedLabel, sidebarInfo, reactions, ...rest } = props; const dummyEditedLabel = React.useMemo(() => { if (!editedLabel) { return null; } return ( {editedLabel} ); }, [editedLabel]); const dummySidebarItem = React.useMemo(() => { if (!sidebarInfo) { return null; } const repliesText = getInlineEngagementSidebarText(sidebarInfo); return ( {repliesText} ); }, [sidebarInfo]); const dummyReactionsList = React.useMemo(() => { if (Object.keys(reactions).length === 0) { return null; } return Object.keys(reactions).map(reaction => { const numOfReacts = reactions[reaction].users.length; return ( {`${reaction} ${numOfReacts}`} ); }); }, [reactions]); const dummyContainerStyle = React.useMemo( () => [unboundStyles.inlineEngagement, unboundStyles.dummyInlineEngagement], [], ); if (!dummyEditedLabel && !dummySidebarItem && !dummyReactionsList) { return null; } return ( {dummyEditedLabel} {dummySidebarItem} {dummyReactionsList} ); } type Props = { +messageInfo: MessageInfo, +threadInfo: ThreadInfo, +sidebarThreadInfo: ?ThreadInfo, +reactions: ReactionInfo, +disabled?: boolean, +positioning?: 'left' | 'right' | 'center', +label?: ?string, }; function InlineEngagement(props: Props): React.Node { const { messageInfo, threadInfo, sidebarThreadInfo, reactions, disabled = false, positioning, label, } = props; const isLeft = positioning === 'left'; const isRight = positioning === 'right'; const isCenter = positioning === 'center'; const navigateToThread = useNavigateToThread(); const { navigate } = useNavigation(); const styles = useStyles(unboundStyles); const editedLabelStyle = React.useMemo(() => { const stylesResult = [styles.messageLabel, styles.messageLabelColor]; if (isLeft) { stylesResult.push(styles.messageLabelLeft); } else { stylesResult.push(styles.messageLabelRight); } return stylesResult; }, [ isLeft, styles.messageLabel, styles.messageLabelColor, styles.messageLabelLeft, styles.messageLabelRight, ]); const editedLabel = React.useMemo(() => { if (!label) { return null; } return ( {label} ); }, [editedLabelStyle, label]); const unreadStyle = sidebarThreadInfo?.currentUser.unread ? styles.unread : null; const repliesStyles = React.useMemo( () => [styles.repliesText, styles.repliesTextColor, unreadStyle], [styles.repliesText, styles.repliesTextColor, unreadStyle], ); const onPressSidebar = React.useCallback(() => { if (sidebarThreadInfo && !disabled) { navigateToThread({ threadInfo: sidebarThreadInfo }); } }, [disabled, navigateToThread, sidebarThreadInfo]); const repliesText = getInlineEngagementSidebarText(sidebarThreadInfo); const sidebarStyle = React.useMemo(() => { const stylesResult = [styles.sidebar, styles.sidebarColor]; if (Object.keys(reactions).length === 0) { return stylesResult; } if (isRight) { stylesResult.push(styles.sidebarMarginLeft); } else { stylesResult.push(styles.sidebarMarginRight); } return stylesResult; }, [ isRight, reactions, styles.sidebar, styles.sidebarColor, styles.sidebarMarginLeft, styles.sidebarMarginRight, ]); const sidebarItem = React.useMemo(() => { if (!sidebarThreadInfo) { return null; } return ( {repliesText} ); }, [ sidebarThreadInfo, onPressSidebar, sidebarStyle, styles.icon, repliesStyles, repliesText, ]); const sendReaction = useSendReaction(messageInfo.id, threadInfo, reactions); const onPressReaction = React.useCallback( (reaction: string) => sendReaction(reaction), [sendReaction], ); const onLongPressReaction = React.useCallback(() => { navigate<'MessageReactionsModal'>({ name: MessageReactionsModalRouteName, params: { reactions }, }); }, [navigate, reactions]); const reactionStyle = React.useMemo(() => { const stylesResult = [ styles.reactionsContainer, styles.reactionsContainerColor, ]; if (isRight) { stylesResult.push(styles.reactionsContainerMarginLeft); } else { stylesResult.push(styles.reactionsContainerMarginRight); } return stylesResult; }, [ isRight, styles.reactionsContainer, styles.reactionsContainerColor, styles.reactionsContainerMarginLeft, styles.reactionsContainerMarginRight, ]); const reactionList = React.useMemo(() => { if (Object.keys(reactions).length === 0) { return null; } return Object.keys(reactions).map(reaction => { const reactionInfo = reactions[reaction]; const numOfReacts = reactionInfo.users.length; const style = reactionInfo.viewerReacted ? [...reactionStyle, styles.reactionsContainerSelected] : reactionStyle; return ( onPressReaction(reaction)} onLongPress={onLongPressReaction} activeOpacity={0.7} key={reaction} > {`${reaction} ${numOfReacts}`} ); }); }, [ onLongPressReaction, onPressReaction, reactionStyle, reactions, styles.reaction, styles.reactionColor, styles.reactionsContainerSelected, ]); const inlineEngagementPositionStyle = React.useMemo(() => { const styleResult = [styles.inlineEngagement]; if (isRight) { styleResult.push(styles.rightInlineEngagement); } else if (isCenter) { styleResult.push(styles.centerInlineEngagement); } return styleResult; }, [ isCenter, isRight, styles.centerInlineEngagement, styles.inlineEngagement, styles.rightInlineEngagement, ]); return ( {editedLabel} {sidebarItem} {reactionList} ); } const unboundStyles = { inlineEngagement: { flexDirection: 'row', marginBottom: inlineEngagementStyle.marginBottom, marginLeft: avatarOffset, flexWrap: 'wrap', top: inlineEngagementStyle.topOffset, }, dummyInlineEngagement: { marginRight: 8, }, centerInlineEngagement: { marginLeft: 20, marginRight: 20, justifyContent: 'center', }, rightInlineEngagement: { flexDirection: 'row-reverse', marginLeft: inlineEngagementRightStyle.marginLeft, }, sidebar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 4, borderRadius: 8, marginTop: inlineEngagementStyle.marginTop, }, dummySidebar: { paddingRight: 8, // 14 (icon) + 4 (marginRight of icon) + 8 (original left padding) paddingLeft: 26, marginRight: 4, }, sidebarColor: { backgroundColor: 'inlineEngagementBackground', }, sidebarMarginLeft: { marginLeft: 4, }, sidebarMarginRight: { marginRight: 4, }, icon: { color: 'inlineEngagementLabel', marginRight: 4, }, repliesText: { fontSize: 14, lineHeight: 22, }, repliesTextColor: { color: 'inlineEngagementLabel', }, unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, reactionsContainer: { display: 'flex', flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 4, borderRadius: 8, marginTop: inlineEngagementStyle.marginTop, }, dummyReactionContainer: { marginRight: 4, }, reactionsContainerColor: { backgroundColor: 'inlineEngagementBackground', }, reactionsContainerSelected: { borderWidth: 1, borderColor: 'inlineEngagementLabel', paddingHorizontal: 7, paddingVertical: 3, }, reactionsContainerMarginLeft: { marginLeft: 4, }, reactionsContainerMarginRight: { marginRight: 4, }, reaction: { fontSize: 14, lineHeight: 22, }, reactionColor: { color: 'inlineEngagementLabel', }, messageLabel: { paddingHorizontal: 3, fontSize: 13, top: inlineEngagementLabelStyle.topOffset, height: inlineEngagementLabelStyle.height, marginTop: inlineEngagementStyle.marginTop, }, dummyMessageLabel: { marginLeft: 9, marginRight: 4, }, messageLabelColor: { color: 'messageLabel', }, messageLabelLeft: { marginLeft: 9, marginRight: 4, }, messageLabelRight: { marginRight: 10, marginLeft: 4, }, avatarOffset: { width: avatarOffset, }, }; type TooltipInlineEngagementProps = { - +item: ChatMessageInfoItemWithHeight, + +item: ChatComposedMessageInfoItemWithHeight, +isOpeningSidebar: boolean, +progress: Animated.Node, +windowWidth: number, +positioning: 'left' | 'right' | 'center', +initialCoordinates: { +x: number, +y: number, +width: number, +height: number, }, }; function TooltipInlineEngagement( props: TooltipInlineEngagementProps, ): React.Node { const { item, isOpeningSidebar, progress, windowWidth, initialCoordinates, positioning, } = props; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return const inlineEngagementStyles = React.useMemo(() => { if (positioning === 'left') { return { position: 'absolute', left: composedMessageStyle.marginLeft, }; } else if (positioning === 'right') { return { position: 'absolute', right: composedMessageStyle.marginRight, }; } invariant( false, `${positioning} is not a valid positioning value for TooltipInlineEngagement`, ); }, [positioning]); const inlineEngagementContainer = React.useMemo(() => { const opacity = isOpeningSidebar ? 0 : interpolateNode(progress, { inputRange: [0, 1], outputRange: [1, 0], extrapolate: Extrapolate.CLAMP, }); return { position: 'absolute', width: windowWidth, top: initialCoordinates.height, left: -initialCoordinates.x, opacity, }; }, [ initialCoordinates.height, initialCoordinates.x, isOpeningSidebar, progress, windowWidth, ]); return ( ); } export { InlineEngagement, TooltipInlineEngagement, DummyInlineEngagementNode, dummyNodeForInlineEngagementHeightMeasurement, }; diff --git a/native/chat/message-header.react.js b/native/chat/message-header.react.js index b6c87f08d..e85f274f6 100644 --- a/native/chat/message-header.react.js +++ b/native/chat/message-header.react.js @@ -1,148 +1,148 @@ // @flow import { useRoute } from '@react-navigation/native'; import * as React from 'react'; import { View, TouchableOpacity } from 'react-native'; import { useStringForUser } from 'lib/hooks/ens-cache.js'; import { clusterEndHeight, avatarOffset } from './chat-constants.js'; import type { DisplayType } from './timestamp.react.js'; import { Timestamp, timestampHeight } from './timestamp.react.js'; import SingleLine from '../components/single-line.react.js'; import { MessageListRouteName } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; -import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; +import type { ChatComposedMessageInfoItemWithHeight } from '../types/chat-types.js'; import { useNavigateToUserProfileBottomSheet } from '../user-profile/user-profile-utils.js'; type Props = { - +item: ChatMessageInfoItemWithHeight, + +item: ChatComposedMessageInfoItemWithHeight, +focused: boolean, +display: DisplayType, }; function MessageHeader(props: Props): React.Node { const styles = useStyles(unboundStyles); const { item, focused, display } = props; const { creator, time } = item.messageInfo; const { isViewer } = creator; const route = useRoute(); const modalDisplay = display === 'modal'; const shouldShowUsername = !isViewer && (modalDisplay || item.startsCluster); const stringForUser = useStringForUser(shouldShowUsername ? creator : null); const navigateToUserProfileBottomSheet = useNavigateToUserProfileBottomSheet(); const onPressAuthorName = React.useCallback( () => navigateToUserProfileBottomSheet(item.messageInfo.creator.id), [item.messageInfo.creator.id, navigateToUserProfileBottomSheet], ); const authorNameStyle = React.useMemo(() => { const style = [styles.authorName]; if (modalDisplay) { style.push(styles.modal); } return style; }, [modalDisplay, styles.authorName, styles.modal]); const authorName = React.useMemo(() => { if (!stringForUser) { return null; } return ( {stringForUser} ); }, [ authorNameStyle, onPressAuthorName, stringForUser, styles.authorNameContainer, ]); // We only want to render the top-placed timestamp for a message if it's // rendered in the message list, and not any separate screens (i.e. // the PinnedMessagesScreen). const presentedFromMessageList = typeof route.params?.presentedFrom === 'string' && route.params.presentedFrom.startsWith(MessageListRouteName); const messageInMessageList = route.name === MessageListRouteName || presentedFromMessageList; const timestamp = React.useMemo(() => { if (!messageInMessageList || (!modalDisplay && !item.startsConversation)) { return null; } return ; }, [ display, item.startsConversation, messageInMessageList, modalDisplay, time, ]); const containerStyle = React.useMemo(() => { if (!focused || modalDisplay) { return null; } let topMargin = 0; if (!item.startsCluster && !item.messageInfo.creator.isViewer) { topMargin += authorNameHeight + clusterEndHeight; } if (!item.startsConversation) { topMargin += timestampHeight; } return { marginTop: topMargin }; }, [ focused, item.messageInfo.creator.isViewer, item.startsCluster, item.startsConversation, modalDisplay, ]); return ( {timestamp} {authorName} ); } const authorNameHeight = 25; const unboundStyles = { authorNameContainer: { bottom: 0, marginRight: 7, marginLeft: 12 + avatarOffset, alignSelf: 'baseline', }, authorName: { color: 'listBackgroundSecondaryLabel', fontSize: 14, height: authorNameHeight, paddingHorizontal: 12, paddingVertical: 4, }, modal: { // high contrast framed against OverlayNavigator-dimmed background color: 'white', }, }; export { MessageHeader, authorNameHeight }; diff --git a/native/chat/message-tooltip-button-avatar.react.js b/native/chat/message-tooltip-button-avatar.react.js index b905a13b8..b9ad67297 100644 --- a/native/chat/message-tooltip-button-avatar.react.js +++ b/native/chat/message-tooltip-button-avatar.react.js @@ -1,38 +1,38 @@ // @flow import * as React from 'react'; import { View, StyleSheet } from 'react-native'; import { avatarOffset } from './chat-constants.js'; import UserAvatar from '../avatars/user-avatar.react.js'; -import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; +import type { ChatComposedMessageInfoItemWithHeight } from '../types/chat-types.js'; type Props = { - +item: ChatMessageInfoItemWithHeight, + +item: ChatComposedMessageInfoItemWithHeight, }; function MessageTooltipButtonAvatar(props: Props): React.Node { const { item } = props; if (item.messageInfo.creator.isViewer) { return null; } return ( ); } const styles = StyleSheet.create({ avatarContainer: { bottom: 0, left: -avatarOffset, position: 'absolute', }, }); const MemoizedMessageTooltipButtonAvatar: React.ComponentType = React.memo(MessageTooltipButtonAvatar); export default MemoizedMessageTooltipButtonAvatar; diff --git a/native/chat/rounded-corners.js b/native/chat/rounded-corners.js index 647652102..be55079e9 100644 --- a/native/chat/rounded-corners.js +++ b/native/chat/rounded-corners.js @@ -1,54 +1,54 @@ // @flow import type { Corners } from 'lib/types/media-types.js'; -import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; +import type { ChatComposedMessageInfoItemWithHeight } from '../types/chat-types.js'; type FilteredCorners = { +bottomLeft: void | boolean, +bottomRight: void | boolean, +topLeft: void | boolean, +topRight: void | boolean, }; function filterCorners( corners: Corners, - item: ChatMessageInfoItemWithHeight, + item: ChatComposedMessageInfoItemWithHeight, ): FilteredCorners { const { startsCluster, endsCluster } = item; const { isViewer } = item.messageInfo.creator; const { topLeft, topRight, bottomLeft, bottomRight } = corners; return { topLeft: topLeft && (isViewer || startsCluster), topRight: topRight && (!isViewer || startsCluster), bottomLeft: bottomLeft && (isViewer || endsCluster), bottomRight: bottomRight && (!isViewer || endsCluster), }; } const allCorners: FilteredCorners = { topLeft: true, topRight: true, bottomLeft: true, bottomRight: true, }; type RoundedContainerStyle = { +borderBottomLeftRadius: number, +borderBottomRightRadius: number, +borderTopLeftRadius: number, +borderTopRightRadius: number, }; function getRoundedContainerStyle( corners: Corners, borderRadius?: number = 8, ): RoundedContainerStyle { const { topLeft, topRight, bottomLeft, bottomRight } = corners; return { borderTopLeftRadius: topLeft ? borderRadius : 0, borderTopRightRadius: topRight ? borderRadius : 0, borderBottomLeftRadius: bottomLeft ? borderRadius : 0, borderBottomRightRadius: bottomRight ? borderRadius : 0, }; } export { allCorners, filterCorners, getRoundedContainerStyle }; diff --git a/native/chat/toggle-pin-modal.react.js b/native/chat/toggle-pin-modal.react.js index 966eddada..5b3ea634e 100644 --- a/native/chat/toggle-pin-modal.react.js +++ b/native/chat/toggle-pin-modal.react.js @@ -1,164 +1,164 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import { toggleMessagePinActionTypes, useToggleMessagePin, } from 'lib/actions/message-actions.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import MessageResult from './message-result.react.js'; import Button from '../components/button.react.js'; import Modal from '../components/modal.react.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; -import type { ChatMessageInfoItemWithHeight } from '../types/chat-types'; +import type { ChatComposedMessageInfoItemWithHeight } from '../types/chat-types.js'; export type TogglePinModalParams = { - +item: ChatMessageInfoItemWithHeight, + +item: ChatComposedMessageInfoItemWithHeight, +threadInfo: ThreadInfo, }; type TogglePinModalProps = { +navigation: AppNavigationProp<'TogglePinModal'>, +route: NavigationRoute<'TogglePinModal'>, }; function TogglePinModal(props: TogglePinModalProps): React.Node { const { navigation, route } = props; const { item, threadInfo } = route.params; const { messageInfo, isPinned } = item; const styles = useStyles(unboundStyles); const callToggleMessagePin = useToggleMessagePin(); const dispatchActionPromise = useDispatchActionPromise(); const modalInfo = React.useMemo(() => { if (isPinned) { return { name: 'Remove Pinned Message', action: 'unpin', confirmationText: 'Are you sure you want to remove this pinned message?', buttonText: 'Remove Pinned Message', buttonStyle: styles.removePinButton, }; } return { name: 'Pin Message', action: 'pin', confirmationText: 'You may pin this message to the channel you are ' + 'currently viewing. To unpin a message, select the pinned messages ' + 'icon in the channel.', buttonText: 'Pin Message', buttonStyle: styles.pinButton, }; }, [isPinned, styles.pinButton, styles.removePinButton]); const createToggleMessagePinPromise = React.useCallback(async () => { invariant(messageInfo.id, 'messageInfo.id should be defined'); const result = await callToggleMessagePin({ messageID: messageInfo.id, action: modalInfo.action, }); return ({ newMessageInfos: result.newMessageInfos, threadID: result.threadID, }: { +newMessageInfos: $ReadOnlyArray, +threadID: string }); }, [callToggleMessagePin, messageInfo.id, modalInfo.action]); const onPress = React.useCallback(() => { void dispatchActionPromise( toggleMessagePinActionTypes, createToggleMessagePinPromise(), ); navigation.goBack(); }, [createToggleMessagePinPromise, dispatchActionPromise, navigation]); const onCancel = React.useCallback(() => { navigation.goBack(); }, [navigation]); return ( {modalInfo.name} {modalInfo.confirmationText} ); } const unboundStyles = { modal: { backgroundColor: 'modalForeground', borderColor: 'modalForegroundBorder', }, modalHeader: { fontSize: 18, color: 'modalForegroundLabel', }, modalConfirmationText: { fontSize: 12, color: 'modalBackgroundLabel', marginTop: 4, }, buttonsContainer: { flexDirection: 'column', flex: 1, justifyContent: 'flex-end', marginBottom: 0, height: 72, paddingHorizontal: 16, }, removePinButton: { borderRadius: 5, height: 48, justifyContent: 'center', alignItems: 'center', backgroundColor: 'vibrantRedButton', }, pinButton: { borderRadius: 5, height: 48, justifyContent: 'center', alignItems: 'center', backgroundColor: 'purpleButton', }, cancelButton: { borderRadius: 5, height: 48, justifyContent: 'center', alignItems: 'center', }, textColor: { color: 'modalButtonLabel', }, }; export default TogglePinModal; diff --git a/native/types/chat-types.js b/native/types/chat-types.js index bae153c9e..d5c7a847a 100644 --- a/native/types/chat-types.js +++ b/native/types/chat-types.js @@ -1,78 +1,82 @@ // @flow import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import type { LocalMessageInfo, MultimediaMessageInfo, RobotextMessageInfo, } from 'lib/types/message-types.js'; import type { TextMessageInfo } from 'lib/types/messages/text.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { EntityText } from 'lib/utils/entity-text.js'; import type { MessagePendingUploads } from '../input/input-state.js'; export type ChatRobotextMessageInfoItemWithHeight = { +itemType: 'message', +messageShapeType: 'robotext', +messageInfo: RobotextMessageInfo, +threadInfo: ThreadInfo, +startsConversation: boolean, +startsCluster: boolean, +endsCluster: boolean, +robotext: EntityText, +threadCreatedFromMessage: ?ThreadInfo, +contentHeight: number, +reactions: ReactionInfo, }; export type ChatTextMessageInfoItemWithHeight = { +itemType: 'message', +messageShapeType: 'text', +messageInfo: TextMessageInfo, +localMessageInfo: ?LocalMessageInfo, +threadInfo: ThreadInfo, +startsConversation: boolean, +startsCluster: boolean, +endsCluster: boolean, +contentHeight: number, +threadCreatedFromMessage: ?ThreadInfo, +reactions: ReactionInfo, +hasBeenEdited: ?boolean, +isPinned: ?boolean, }; // We "measure" the contentHeight of a multimedia message using the media // dimensions. This means for multimedia messages we only need to actually // measure the inline engagement node 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: ReactionInfo, +hasBeenEdited: ?boolean, +isPinned: ?boolean, +inlineEngagementHeight: ?number, }; +export type ChatComposedMessageInfoItemWithHeight = + | ChatTextMessageInfoItemWithHeight + | ChatMultimediaMessageInfoItem; + export type ChatMessageInfoItemWithHeight = | ChatRobotextMessageInfoItemWithHeight | ChatTextMessageInfoItemWithHeight | ChatMultimediaMessageInfoItem; export type ChatMessageItemWithHeight = | { itemType: 'loader' } | ChatMessageInfoItemWithHeight;