diff --git a/native/chat/multimedia-message.react.js b/native/chat/multimedia-message.react.js index 39e550116..3c7aec72d 100644 --- a/native/chat/multimedia-message.react.js +++ b/native/chat/multimedia-message.react.js @@ -1,254 +1,264 @@ // @flow import type { LeafRoute, NavigationProp, ParamListBase, } from '@react-navigation/core'; import { useNavigation, useRoute } from '@react-navigation/native'; import * as React from 'react'; import { View } from 'react-native'; import { messageKey } from 'lib/shared/message-utils.js'; +import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils.js'; import { useCanCreateSidebarFromMessage } from 'lib/shared/sidebar-utils.js'; import type { MediaInfo } from 'lib/types/media-types.js'; import ComposedMessage from './composed-message.react.js'; import { InnerMultimediaMessage } from './inner-multimedia-message.react.js'; import { getMediaKey, multimediaMessageSendFailed, } from './multimedia-message-utils.js'; import { getMessageTooltipKey } from './utils.js'; import { ChatContext, type ChatContextType } from '../chat/chat-context.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import type { OverlayContextType } from '../navigation/overlay-context.js'; import { ImageModalRouteName, MultimediaMessageTooltipModalRouteName, VideoPlaybackModalRouteName, } from '../navigation/route-names.js'; import { fixedTooltipHeight } from '../tooltip/tooltip.react.js'; import type { ChatMultimediaMessageInfoItem } from '../types/chat-types.js'; import type { VerticalBounds, LayoutCoordinates, } from '../types/layout-types.js'; type BaseProps = { ...React.ElementConfig, +item: ChatMultimediaMessageInfoItem, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, +canTogglePins: boolean, +shouldDisplayPinIndicator: boolean, }; type Props = { ...BaseProps, +navigation: NavigationProp, +route: LeafRoute<>, +overlayContext: ?OverlayContextType, +chatContext: ?ChatContextType, +canCreateSidebarFromMessage: boolean, + +canCreateReactionFromMessage: boolean, }; type State = { +clickable: boolean, }; class MultimediaMessage extends React.PureComponent { state: State = { clickable: true, }; view: ?React.ElementRef; setClickable = (clickable: boolean) => { this.setState({ clickable }); }; onPressMultimedia = ( mediaInfo: MediaInfo, initialCoordinates: LayoutCoordinates, ) => { const { navigation, item, route, verticalBounds } = this.props; navigation.navigate<'VideoPlaybackModal' | 'ImageModal'>({ name: mediaInfo.type === 'video' || mediaInfo.type === 'encrypted_video' ? VideoPlaybackModalRouteName : ImageModalRouteName, key: getMediaKey(item, mediaInfo), params: { presentedFrom: route.key, mediaInfo, item, initialCoordinates, verticalBounds, }, }); }; visibleEntryIDs(): $ReadOnlyArray { const result = []; if (this.props.canTogglePins) { this.props.item.isPinned ? result.push('unpin') : result.push('pin'); } if ( this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ) { result.push('sidebar'); } if (!this.props.item.messageInfo.creator.isViewer) { result.push('report'); } return result; } onLayout = () => {}; viewRef = (view: ?React.ElementRef) => { this.view = view; }; onLongPress = () => { const visibleEntryIDs = this.visibleEntryIDs(); - if (visibleEntryIDs.length === 0) { + if ( + visibleEntryIDs.length === 0 && + !this.props.canCreateReactionFromMessage + ) { return; } const { view, props: { verticalBounds }, } = this; if (!view || !verticalBounds) { return; } if (!this.state.clickable) { return; } this.setClickable(false); const { item } = this.props; if (!this.props.focused) { this.props.toggleFocus(messageKey(item.messageInfo)); } this.props.overlayContext?.setScrollBlockingModalStatus('open'); view.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; const multimediaTop = pageY; const multimediaBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = fixedTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const aboveMargin = isViewer ? 30 : 50; const aboveSpace = fixedTooltipHeight + aboveMargin; let margin = belowMargin; if ( multimediaBottom + belowSpace > boundsBottom && multimediaTop - aboveSpace > boundsTop ) { margin = aboveMargin; } const currentInputBarHeight = this.props.chatContext?.chatInputBarHeights.get(item.threadInfo.id) ?? 0; this.props.navigation.navigate<'MultimediaMessageTooltipModal'>({ name: MultimediaMessageTooltipModalRouteName, params: { presentedFrom: this.props.route.key, item, initialCoordinates: coordinates, verticalBounds, tooltipLocation: 'fixed', margin, visibleEntryIDs, chatInputBarHeight: currentInputBarHeight, }, key: getMessageTooltipKey(item), }); }); }; canNavigateToSidebar(): boolean { return ( !!this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ); } render(): React.Node { const { item, focused, toggleFocus, verticalBounds, shouldDisplayPinIndicator, navigation, route, overlayContext, chatContext, canCreateSidebarFromMessage, canTogglePins, + canCreateReactionFromMessage, ...viewProps } = this.props; return ( ); } } const ConnectedMultimediaMessage: React.ComponentType = React.memo(function ConnectedMultimediaMessage(props: BaseProps) { const navigation = useNavigation(); const route = useRoute(); const overlayContext = React.useContext(OverlayContext); const chatContext = React.useContext(ChatContext); const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( props.item.threadInfo, props.item.messageInfo, ); - + const canCreateReactionFromMessage = useCanCreateReactionFromMessage( + props.item.threadInfo, + props.item.messageInfo, + ); return ( ); }); export default ConnectedMultimediaMessage; diff --git a/native/chat/robotext-message.react.js b/native/chat/robotext-message.react.js index 9c8d088e7..14c9ad7b6 100644 --- a/native/chat/robotext-message.react.js +++ b/native/chat/robotext-message.react.js @@ -1,246 +1,252 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { chatMessageItemKey, chatMessageItemHasEngagement, chatMessageItemEngagementTargetMessageInfo, } from 'lib/shared/chat-message-item-utils.js'; +import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils.js'; import { useCanCreateSidebarFromMessage } from 'lib/shared/sidebar-utils.js'; import { inlineEngagementCenterStyle } from './chat-constants.js'; import type { ChatNavigationProp } from './chat.react.js'; import { InlineEngagement } from './inline-engagement.react.js'; import { InnerRobotextMessage } from './inner-robotext-message.react.js'; import { Timestamp } from './timestamp.react.js'; import { getMessageTooltipKey, useContentAndHeaderOpacity } from './utils.js'; import { ChatContext } from '../chat/chat-context.js'; import { KeyboardContext } from '../keyboard/keyboard-state.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { OverlayContext } from '../navigation/overlay-context.js'; import { RobotextMessageTooltipModalRouteName } from '../navigation/route-names.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; import { fixedTooltipHeight } from '../tooltip/tooltip.react.js'; import type { ChatRobotextMessageInfoItemWithHeight } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; import { AnimatedView } from '../types/styles.js'; type Props = { ...React.ElementConfig, +item: ChatRobotextMessageInfoItemWithHeight, +navigation: | ChatNavigationProp<'MessageList'> | AppNavigationProp<'TogglePinModal'> | ChatNavigationProp<'PinnedMessagesScreen'> | ChatNavigationProp<'MessageSearch'>, +route: | NavigationRoute<'MessageList'> | NavigationRoute<'TogglePinModal'> | NavigationRoute<'PinnedMessagesScreen'> | NavigationRoute<'MessageSearch'>, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, }; function RobotextMessage(props: Props): React.Node { const { item, navigation, route, focused, toggleFocus, verticalBounds, ...viewProps } = props; let timestamp = null; if (focused || item.startsConversation) { timestamp = ; } const styles = useStyles(unboundStyles); const engagementTargetMessageInfo = chatMessageItemEngagementTargetMessageInfo(item); let inlineEngagement = null; if ( engagementTargetMessageInfo && chatMessageItemHasEngagement(item, item.threadInfo.id) ) { inlineEngagement = ( ); } const chatContext = React.useContext(ChatContext); const keyboardState = React.useContext(KeyboardContext); const key = chatMessageItemKey(item); const onPress = React.useCallback(() => { const didDismiss = keyboardState && keyboardState.dismissKeyboardIfShowing(); if (!didDismiss) { toggleFocus(key); } }, [keyboardState, toggleFocus, key]); const overlayContext = React.useContext(OverlayContext); const viewRef = React.useRef>(); const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( item.threadInfo, engagementTargetMessageInfo, ); + const canCreateReactionFromMessage = useCanCreateReactionFromMessage( + item.threadInfo, + engagementTargetMessageInfo, + ); const visibleEntryIDs = React.useMemo(() => { const result = []; if (item.threadCreatedFromMessage || canCreateSidebarFromMessage) { result.push('sidebar'); } return result; }, [item.threadCreatedFromMessage, canCreateSidebarFromMessage]); const openRobotextTooltipModal = React.useCallback( ( x: number, y: number, width: number, height: number, pageX: number, pageY: number, ) => { invariant( verticalBounds, 'verticalBounds should be present in openRobotextTooltipModal', ); const coordinates = { x: pageX, y: pageY, width, height }; const messageTop = pageY; const messageBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = fixedTooltipHeight + belowMargin; const aboveMargin = 30; const aboveSpace = fixedTooltipHeight + aboveMargin; let margin = 0; if ( messageBottom + belowSpace > boundsBottom && messageTop - aboveSpace > boundsTop ) { margin = aboveMargin; } const currentInputBarHeight = chatContext?.chatInputBarHeights.get(item.threadInfo.id) ?? 0; props.navigation.navigate<'RobotextMessageTooltipModal'>({ name: RobotextMessageTooltipModalRouteName, params: { presentedFrom: props.route.key, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs, tooltipLocation: 'fixed', margin, item, chatInputBarHeight: currentInputBarHeight, }, key: getMessageTooltipKey(item), }); }, [ item, props.navigation, props.route.key, verticalBounds, visibleEntryIDs, chatContext, ], ); const onLongPress = React.useCallback(() => { if (keyboardState && keyboardState.dismissKeyboardIfShowing()) { return; } - if (visibleEntryIDs.length === 0) { + if (visibleEntryIDs.length === 0 && !canCreateReactionFromMessage) { return; } if (!viewRef.current || !verticalBounds) { return; } if (!focused) { toggleFocus(key); } invariant(overlayContext, 'RobotextMessage should have OverlayContext'); overlayContext.setScrollBlockingModalStatus('open'); viewRef.current?.measure(openRobotextTooltipModal); }, [ focused, key, keyboardState, overlayContext, toggleFocus, verticalBounds, viewRef, visibleEntryIDs, openRobotextTooltipModal, + canCreateReactionFromMessage, ]); const onLayout = React.useCallback(() => {}, []); const contentAndHeaderOpacity = useContentAndHeaderOpacity(item); const viewStyle: { height?: number } = {}; if (!__DEV__) { // We don't force view height in dev mode because we // want to measure it in Message to see if it's correct viewStyle.height = item.contentHeight; } return ( {timestamp} {inlineEngagement} ); } const unboundStyles = { sidebar: { marginTop: inlineEngagementCenterStyle.topOffset, marginBottom: -inlineEngagementCenterStyle.topOffset, alignSelf: 'center', }, }; export { RobotextMessage }; diff --git a/native/chat/text-message.react.js b/native/chat/text-message.react.js index 8401bab99..05fcf2c71 100644 --- a/native/chat/text-message.react.js +++ b/native/chat/text-message.react.js @@ -1,312 +1,309 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { messageKey } from 'lib/shared/message-utils.js'; import { useCanCreateSidebarFromMessage } from 'lib/shared/sidebar-utils.js'; import { useThreadHasPermission } from 'lib/shared/thread-utils.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import type { ChatNavigationProp } from './chat.react.js'; import ComposedMessage from './composed-message.react.js'; import { InnerTextMessage } from './inner-text-message.react.js'; import { MessageEditingContext } from './message-editing-context.react.js'; import { MessagePressResponderContext, type MessagePressResponderContextType, } from './message-press-responder-context.js'; import textMessageSendFailed from './text-message-send-failed.js'; import { getMessageTooltipKey } from './utils.js'; import { ChatContext, type ChatContextType } from '../chat/chat-context.js'; import { MarkdownContext } from '../markdown/markdown-context.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { useCanEditMessageNative } from '../navigation/nav-selectors.js'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context.js'; import { TextMessageTooltipModalRouteName } from '../navigation/route-names.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { fixedTooltipHeight } from '../tooltip/tooltip.react.js'; import type { ChatTextMessageInfoItemWithHeight } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; type BaseProps = { ...React.ElementConfig, +item: ChatTextMessageInfoItemWithHeight, +navigation: | ChatNavigationProp<'MessageList'> | AppNavigationProp<'TogglePinModal'> | ChatNavigationProp<'PinnedMessagesScreen'> | ChatNavigationProp<'MessageSearch'>, +route: | NavigationRoute<'MessageList'> | NavigationRoute<'TogglePinModal'> | NavigationRoute<'PinnedMessagesScreen'> | NavigationRoute<'MessageSearch'>, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, +canTogglePins: boolean, +shouldDisplayPinIndicator: boolean, }; type Props = { ...BaseProps, // Redux state +canCreateSidebarFromMessage: boolean, // withOverlayContext +overlayContext: ?OverlayContextType, // ChatContext +chatContext: ?ChatContextType, // MarkdownContext +isLinkModalActive: boolean, +isUserProfileBottomSheetActive: boolean, +canEditMessage: boolean, +currentUserIsVoiced: boolean, }; class TextMessage extends React.PureComponent { message: ?React.ElementRef; messagePressResponderContext: MessagePressResponderContextType; constructor(props: Props) { super(props); this.messagePressResponderContext = { onPressMessage: this.onPress, }; } render(): React.Node { const { item, navigation, route, focused, toggleFocus, verticalBounds, shouldDisplayPinIndicator, overlayContext, chatContext, isLinkModalActive, isUserProfileBottomSheetActive, canCreateSidebarFromMessage, canEditMessage, canTogglePins, currentUserIsVoiced, ...viewProps } = this.props; let swipeOptions = 'none'; const canReply = this.canReply(); const canNavigateToSidebar = this.canNavigateToSidebar(); if (isLinkModalActive) { swipeOptions = 'none'; } else if (canReply && canNavigateToSidebar) { swipeOptions = 'both'; } else if (canReply) { swipeOptions = 'reply'; } else if (canNavigateToSidebar) { swipeOptions = 'sidebar'; } return ( ); } messageRef = (message: ?React.ElementRef) => { this.message = message; }; canReply(): boolean { return this.props.currentUserIsVoiced; } canNavigateToSidebar(): boolean { return ( !!this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ); } visibleEntryIDs(): $ReadOnlyArray { const result = ['copy']; if (this.canReply()) { result.push('reply'); } if (this.props.canEditMessage) { result.push('edit'); } if (this.props.canTogglePins) { this.props.item.isPinned ? result.push('unpin') : result.push('pin'); } if ( this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ) { result.push('sidebar'); } if (!this.props.item.messageInfo.creator.isViewer) { result.push('report'); } return result; } onPress = () => { const visibleEntryIDs = this.visibleEntryIDs(); - if (visibleEntryIDs.length === 0) { - return; - } const { message, props: { verticalBounds, isLinkModalActive, isUserProfileBottomSheetActive, }, } = this; if ( !message || !verticalBounds || isLinkModalActive || isUserProfileBottomSheetActive ) { return; } const { focused, toggleFocus, item } = this.props; if (!focused) { toggleFocus(messageKey(item.messageInfo)); } const { overlayContext } = this.props; invariant(overlayContext, 'TextMessage should have OverlayContext'); overlayContext.setScrollBlockingModalStatus('open'); message.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; const messageTop = pageY; const messageBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = fixedTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const aboveMargin = isViewer ? 30 : 50; const aboveSpace = fixedTooltipHeight + aboveMargin; let margin = belowMargin; if ( messageBottom + belowSpace > boundsBottom && messageTop - aboveSpace > boundsTop ) { margin = aboveMargin; } const currentInputBarHeight = this.props.chatContext?.chatInputBarHeights.get(item.threadInfo.id) ?? 0; this.props.navigation.navigate<'TextMessageTooltipModal'>({ name: TextMessageTooltipModalRouteName, params: { presentedFrom: this.props.route.key, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs, tooltipLocation: 'fixed', margin, item, chatInputBarHeight: currentInputBarHeight, }, key: getMessageTooltipKey(item), }); }); }; } const ConnectedTextMessage: React.ComponentType = React.memo(function ConnectedTextMessage(props: BaseProps) { const overlayContext = React.useContext(OverlayContext); const chatContext = React.useContext(ChatContext); const markdownContext = React.useContext(MarkdownContext); invariant(markdownContext, 'markdownContext should be set'); const { linkModalActive, userProfileBottomSheetActive, clearMarkdownContextData, } = markdownContext; const key = messageKey(props.item.messageInfo); // We check if there is an key in the object - if not, we // default to false. The likely situation where the former statement // evaluates to null is when the thread is opened for the first time. const isLinkModalActive = linkModalActive[key] ?? false; const isUserProfileBottomSheetActive = userProfileBottomSheetActive[key] ?? false; const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( props.item.threadInfo, props.item.messageInfo, ); const messageEditingContext = React.useContext(MessageEditingContext); const editMessageID = messageEditingContext?.editState.editedMessage?.id; const isThisMessageEdited = editMessageID === props.item.messageInfo.id; const canEditMessage = useCanEditMessageNative(props.item.threadInfo, props.item.messageInfo) && !isThisMessageEdited; React.useEffect(() => clearMarkdownContextData, [clearMarkdownContextData]); const currentUserCanReply = useThreadHasPermission( props.item.threadInfo, threadPermissions.VOICED, ); return ( ); }); export { ConnectedTextMessage as TextMessage };