diff --git a/lib/shared/reaction-utils.js b/lib/shared/reaction-utils.js index 72d8324d7..250a93645 100644 --- a/lib/shared/reaction-utils.js +++ b/lib/shared/reaction-utils.js @@ -1,27 +1,64 @@ // @flow import invariant from 'invariant'; import type { MessageReactionInfo } from '../selectors/chat-selectors'; +import type { + RobotextMessageInfo, + ComposableMessageInfo, +} from '../types/message-types'; +import { threadPermissions, type ThreadInfo } from '../types/thread-types'; +import { useSelector } from '../utils/redux-utils'; +import { relationshipBlockedInEitherDirection } from './relationship-utils'; +import { threadHasPermission } from './thread-utils'; function stringForReactionList( reactions: $ReadOnlyMap, ): string { const reactionText = []; for (const reaction of reactions.keys()) { const reactionInfo = reactions.get(reaction); invariant(reactionInfo, 'reactionInfo should be set'); reactionText.push(reaction); const { size: numberOfReacts } = reactionInfo.users; if (numberOfReacts <= 1) { continue; } reactionText.push(numberOfReacts > 9 ? '9+' : numberOfReacts.toString()); } return reactionText.join(' '); } -export { stringForReactionList }; +function useCanCreateReactionFromMessage( + threadInfo: ThreadInfo, + targetMessageInfo: ComposableMessageInfo | RobotextMessageInfo, +): boolean { + const targetMessageCreatorRelationship = useSelector( + state => + state.userStore.userInfos[targetMessageInfo.creator.id] + ?.relationshipStatus, + ); + + if ( + !targetMessageInfo.id || + threadInfo.sourceMessageID === targetMessageInfo.id + ) { + return false; + } + + const creatorRelationshipHasBlock = + targetMessageCreatorRelationship && + relationshipBlockedInEitherDirection(targetMessageCreatorRelationship); + + const hasPermission = threadHasPermission( + threadInfo, + threadPermissions.VOICED, + ); + + return hasPermission && !creatorRelationshipHasBlock; +} + +export { stringForReactionList, useCanCreateReactionFromMessage }; diff --git a/native/chat/multimedia-message.react.js b/native/chat/multimedia-message.react.js index cdc5bac84..3ee5c08a5 100644 --- a/native/chat/multimedia-message.react.js +++ b/native/chat/multimedia-message.react.js @@ -1,262 +1,262 @@ // @flow import type { LeafRoute, NavigationProp, ParamListBase, } from '@react-navigation/native'; import { useNavigation, useRoute } from '@react-navigation/native'; import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import { messageKey } from 'lib/shared/message-utils'; +import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils'; import { useCanCreateSidebarFromMessage } from 'lib/shared/thread-utils'; import type { MediaInfo } from 'lib/types/media-types'; import { ChatContext, type ChatContextType } from '../chat/chat-context'; import { OverlayContext } from '../navigation/overlay-context'; import type { OverlayContextType } from '../navigation/overlay-context'; import { ImageModalRouteName, MultimediaMessageTooltipModalRouteName, VideoPlaybackModalRouteName, } from '../navigation/route-names'; import { fixedTooltipHeight } from '../navigation/tooltip.react'; import type { ChatMultimediaMessageInfoItem } from '../types/chat-types'; import { type VerticalBounds } from '../types/layout-types'; import type { LayoutCoordinates } from '../types/layout-types'; import ComposedMessage from './composed-message.react'; import { InnerMultimediaMessage } from './inner-multimedia-message.react'; import { getMediaKey, multimediaMessageSendFailed, } from './multimedia-message-utils'; -import { useCanCreateReactionFromMessage } from './reaction-message-utils'; import { getMessageTooltipKey } from './utils'; type BaseProps = { ...React.ElementConfig, +item: ChatMultimediaMessageInfoItem, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, }; 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' ? VideoPlaybackModalRouteName : ImageModalRouteName, key: getMediaKey(item, mediaInfo), params: { presentedFrom: route.key, mediaInfo, item, initialCoordinates, verticalBounds, }, }); }; visibleEntryIDs() { const result = []; if ( this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ) { result.push('sidebar'); } if (this.props.canCreateReactionFromMessage) { result.push('react'); } 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) { 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, location: 'fixed', margin, visibleEntryIDs, chatInputBarHeight: currentInputBarHeight, }, key: getMessageTooltipKey(item), }); }); }; canNavigateToSidebar() { return ( this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ); } render() { const { item, focused, toggleFocus, verticalBounds, navigation, route, overlayContext, chatContext, canCreateSidebarFromMessage, canCreateReactionFromMessage, ...viewProps } = this.props; return ( ); } } const styles = StyleSheet.create({ expand: { flex: 1, }, }); 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/reaction-message-utils.js b/native/chat/reaction-message-utils.js index 39e765d04..ef8af7bd4 100644 --- a/native/chat/reaction-message-utils.js +++ b/native/chat/reaction-message-utils.js @@ -1,124 +1,87 @@ // @flow import invariant from 'invariant'; import Alert from 'react-native/Libraries/Alert/Alert'; import { sendReactionMessage, sendReactionMessageActionTypes, } from 'lib/actions/message-actions'; -import { relationshipBlockedInEitherDirection } from 'lib/shared/relationship-utils'; -import { threadHasPermission } from 'lib/shared/thread-utils'; -import type { - RobotextMessageInfo, - ComposableMessageInfo, -} from 'lib/types/message-types'; -import { threadPermissions, type ThreadInfo } from 'lib/types/thread-types'; import type { BindServerCall, DispatchFunctions } from 'lib/utils/action-utils'; -import { useSelector } from 'lib/utils/redux-utils'; import type { TooltipRoute } from '../navigation/tooltip.react'; function onPressReact( route: | TooltipRoute<'TextMessageTooltipModal'> | TooltipRoute<'MultimediaMessageTooltipModal'> | TooltipRoute<'RobotextMessageTooltipModal'>, dispatchFunctions: DispatchFunctions, bindServerCall: BindServerCall, ) { const messageID = route.params.item.messageInfo.id; invariant(messageID, 'messageID should be set'); const threadID = route.params.item.threadInfo.id; invariant(threadID, 'threadID should be set'); const reactionInput = '👍'; const viewerReacted = route.params.item.reactions.get(reactionInput) ?.viewerReacted; const action = viewerReacted ? 'remove_reaction' : 'add_reaction'; sendReaction( messageID, threadID, reactionInput, action, dispatchFunctions, bindServerCall, ); } function sendReaction( messageID: string, threadID: string, reaction: string, action: 'add_reaction' | 'remove_reaction', dispatchFunctions: DispatchFunctions, bindServerCall: BindServerCall, ) { const callSendReactionMessage = bindServerCall(sendReactionMessage); const reactionMessagePromise = (async () => { try { const result = await callSendReactionMessage({ threadID, targetMessageID: messageID, reaction, action, }); return { serverID: result.id, threadID, time: result.newMessageInfo.time, newMessageInfos: [result.newMessageInfo], }; } catch (e) { Alert.alert( 'Couldn’t send the reaction', 'Please try again later', [{ text: 'OK' }], { cancelable: true, }, ); throw e; } })(); dispatchFunctions.dispatchActionPromise( sendReactionMessageActionTypes, reactionMessagePromise, ); } -function useCanCreateReactionFromMessage( - threadInfo: ThreadInfo, - targetMessageInfo: ComposableMessageInfo | RobotextMessageInfo, -): boolean { - const targetMessageCreatorRelationship = useSelector( - state => - state.userStore.userInfos[targetMessageInfo.creator.id] - ?.relationshipStatus, - ); - - if ( - !targetMessageInfo.id || - threadInfo.sourceMessageID === targetMessageInfo.id - ) { - return false; - } - - const creatorRelationshipHasBlock = - targetMessageCreatorRelationship && - relationshipBlockedInEitherDirection(targetMessageCreatorRelationship); - - const hasPermission = threadHasPermission( - threadInfo, - threadPermissions.VOICED, - ); - - return hasPermission && !creatorRelationshipHasBlock; -} - -export { onPressReact, useCanCreateReactionFromMessage }; +export { onPressReact }; diff --git a/native/chat/robotext-message.react.js b/native/chat/robotext-message.react.js index 8a872cb52..9c06322d1 100644 --- a/native/chat/robotext-message.react.js +++ b/native/chat/robotext-message.react.js @@ -1,228 +1,228 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { messageKey } from 'lib/shared/message-utils'; +import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils'; import { useCanCreateSidebarFromMessage } from 'lib/shared/thread-utils'; import { ChatContext } from '../chat/chat-context'; import { KeyboardContext } from '../keyboard/keyboard-state'; import { OverlayContext } from '../navigation/overlay-context'; import { RobotextMessageTooltipModalRouteName } from '../navigation/route-names'; import type { NavigationRoute } from '../navigation/route-names'; import { fixedTooltipHeight } from '../navigation/tooltip.react'; import { useStyles } from '../themes/colors'; import type { ChatRobotextMessageInfoItemWithHeight } from '../types/chat-types'; import type { VerticalBounds } from '../types/layout-types'; import { AnimatedView } from '../types/styles'; import { inlineSidebarCenterStyle } from './chat-constants'; import type { ChatNavigationProp } from './chat.react'; import { InlineSidebar } from './inline-sidebar.react'; import { InnerRobotextMessage } from './inner-robotext-message.react'; -import { useCanCreateReactionFromMessage } from './reaction-message-utils'; import { Timestamp } from './timestamp.react'; import { getMessageTooltipKey, useContentAndHeaderOpacity } from './utils'; type Props = { ...React.ElementConfig, +item: ChatRobotextMessageInfoItemWithHeight, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, +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); let inlineSidebar = null; if (item.threadCreatedFromMessage || item.reactions.size > 0) { inlineSidebar = ( ); } const chatContext = React.useContext(ChatContext); const keyboardState = React.useContext(KeyboardContext); const key = messageKey(item.messageInfo); 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, item.messageInfo, ); const canCreateReactionFromMessage = useCanCreateReactionFromMessage( item.threadInfo, item.messageInfo, ); const visibleEntryIDs = React.useMemo(() => { const result = []; if (item.threadCreatedFromMessage || canCreateSidebarFromMessage) { result.push('sidebar'); } if (canCreateReactionFromMessage) { result.push('react'); } return result; }, [ item.threadCreatedFromMessage, canCreateSidebarFromMessage, canCreateReactionFromMessage, ]); const openRobotextTooltipModal = React.useCallback( (x, y, width, height, pageX, pageY) => { 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 { isViewer } = item.messageInfo.creator; const aboveMargin = isViewer ? 30 : 50; 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, location: '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) { return; } if (!viewRef.current || !verticalBounds) { return; } if (!focused) { toggleFocus(messageKey(item.messageInfo)); } invariant(overlayContext, 'RobotextMessage should have OverlayContext'); overlayContext.setScrollBlockingModalStatus('open'); viewRef.current?.measure(openRobotextTooltipModal); }, [ focused, item, keyboardState, overlayContext, toggleFocus, verticalBounds, viewRef, visibleEntryIDs, openRobotextTooltipModal, ]); const onLayout = React.useCallback(() => {}, []); const contentAndHeaderOpacity = useContentAndHeaderOpacity(item); return ( {timestamp} {inlineSidebar} ); } const unboundStyles = { sidebar: { marginTop: inlineSidebarCenterStyle.topOffset, marginBottom: -inlineSidebarCenterStyle.topOffset, alignSelf: 'center', }, }; export { RobotextMessage }; diff --git a/native/chat/text-message.react.js b/native/chat/text-message.react.js index d7a51941b..566af94e3 100644 --- a/native/chat/text-message.react.js +++ b/native/chat/text-message.react.js @@ -1,268 +1,268 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { messageKey } from 'lib/shared/message-utils'; +import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils'; import { threadHasPermission, useCanCreateSidebarFromMessage, } from 'lib/shared/thread-utils'; import { threadPermissions } from 'lib/types/thread-types'; import { ChatContext, type ChatContextType } from '../chat/chat-context'; import { MarkdownContext } from '../markdown/markdown-context'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { TextMessageTooltipModalRouteName } from '../navigation/route-names'; import { fixedTooltipHeight } from '../navigation/tooltip.react'; import type { ChatTextMessageInfoItemWithHeight } from '../types/chat-types'; import type { VerticalBounds } from '../types/layout-types'; import type { ChatNavigationProp } from './chat.react'; import ComposedMessage from './composed-message.react'; import { InnerTextMessage } from './inner-text-message.react'; import { MessagePressResponderContext, type MessagePressResponderContextType, } from './message-press-responder-context'; -import { useCanCreateReactionFromMessage } from './reaction-message-utils'; import textMessageSendFailed from './text-message-send-failed'; import { getMessageTooltipKey } from './utils'; type BaseProps = { ...React.ElementConfig, +item: ChatTextMessageInfoItemWithHeight, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, }; type Props = { ...BaseProps, // Redux state +canCreateSidebarFromMessage: boolean, +canCreateReactionFromMessage: boolean, // withOverlayContext +overlayContext: ?OverlayContextType, // ChatContext +chatContext: ?ChatContextType, // MarkdownContext +isLinkModalActive: boolean, }; class TextMessage extends React.PureComponent { message: ?React.ElementRef; messagePressResponderContext: MessagePressResponderContextType; constructor(props: Props) { super(props); this.messagePressResponderContext = { onPressMessage: this.onPress, }; } render() { const { item, navigation, route, focused, toggleFocus, verticalBounds, overlayContext, chatContext, isLinkModalActive, canCreateSidebarFromMessage, canCreateReactionFromMessage, ...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() { return threadHasPermission( this.props.item.threadInfo, threadPermissions.VOICED, ); } canNavigateToSidebar() { return ( this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ); } visibleEntryIDs() { const result = ['copy']; if (this.canReply()) { result.push('reply'); } if ( this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ) { result.push('sidebar'); } if (this.props.canCreateReactionFromMessage) { result.push('react'); } 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 }, } = this; if (!message || !verticalBounds || isLinkModalActive) { 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, location: '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, 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 canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( props.item.threadInfo, props.item.messageInfo, ); const canCreateReactionFromMessage = useCanCreateReactionFromMessage( props.item.threadInfo, props.item.messageInfo, ); React.useEffect(() => clearMarkdownContextData, [clearMarkdownContextData]); return ( ); }, ); export { ConnectedTextMessage as TextMessage };