diff --git a/native/chat/composed-message.react.js b/native/chat/composed-message.react.js index 8d37843a2..e4a02c776 100644 --- a/native/chat/composed-message.react.js +++ b/native/chat/composed-message.react.js @@ -1,288 +1,323 @@ // @flow import Icon from '@expo/vector-icons/Feather.js'; import invariant from 'invariant'; import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import Animated from 'react-native-reanimated'; 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 { 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 { type InputState, InputStateContext } from '../input/input-state.js'; import { type Colors, useColors } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; import { type AnimatedStyleObj, AnimatedView } from '../types/styles.js'; import { useShouldRenderAvatars } from '../utils/avatar-utils.js'; /* eslint-disable import/no-named-as-default-member */ const { Node } = Animated; /* eslint-enable import/no-named-as-default-member */ type SwipeOptions = 'reply' | 'sidebar' | 'both' | 'none'; type BaseProps = { ...React.ElementConfig, +item: ChatMessageInfoItemWithHeight, +sendFailed: boolean, +focused: boolean, +swipeOptions: SwipeOptions, + +shouldDisplayPinIndicator: boolean, +children: React.Node, }; type Props = { ...BaseProps, // Redux state +composedMessageMaxWidth: number, +colors: Colors, +contentAndHeaderOpacity: number | Node, +deliveryIconOpacity: number | Node, // withInputState +inputState: ?InputState, +navigateToSidebar: () => mixed, +shouldRenderAvatars: boolean, }; class ComposedMessage extends React.PureComponent { render() { assertComposableMessageType(this.props.item.messageInfo.type); const { item, sendFailed, focused, swipeOptions, + shouldDisplayPinIndicator, children, composedMessageMaxWidth, colors, inputState, navigateToSidebar, contentAndHeaderOpacity, deliveryIconOpacity, shouldRenderAvatars, ...viewProps } = this.props; const { id, creator } = item.messageInfo; - const { hasBeenEdited } = item; + const { hasBeenEdited, isPinned } = item; const { isViewer } = creator; const alignStyle = isViewer ? styles.rightChatBubble : styles.leftChatBubble; let containerMarginBottom = 5; if (item.endsCluster) { containerMarginBottom += clusterEndHeight; } const highlightStyle = id && this.props.inputState?.editState.editedMessage?.id === id ? { backgroundColor: `#${item.threadInfo.color}40` } : null; const containerStyle = [ { marginBottom: containerMarginBottom }, highlightStyle, ]; const messageBoxContainerStyle = [styles.messageBoxContainer]; const positioningStyle = isViewer ? styles.rightChatContainer : styles.leftChatContainer; messageBoxContainerStyle.push(positioningStyle); let deliveryIcon = null; let failedSendInfo = null; if (isViewer) { let deliveryIconName; let deliveryIconColor = `#${item.threadInfo.color}`; if (id !== null && id !== undefined) { deliveryIconName = 'check-circle'; } else if (sendFailed) { deliveryIconName = 'x-circle'; deliveryIconColor = colors.redText; failedSendInfo = ; } else { deliveryIconName = 'circle'; } const animatedStyle: AnimatedStyleObj = { opacity: deliveryIconOpacity }; deliveryIcon = ( ); } const triggerReply = swipeOptions === 'reply' || swipeOptions === 'both' ? this.reply : undefined; const triggerSidebar = swipeOptions === 'sidebar' || swipeOptions === 'both' ? navigateToSidebar : undefined; let avatar; if (!isViewer && item.endsCluster && shouldRenderAvatars) { avatar = ( ); } else if (!isViewer && shouldRenderAvatars) { avatar = ; } + const pinIconPositioning = isViewer ? 'left' : 'right'; + const pinIconName = pinIconPositioning === 'left' ? 'pin-mirror' : 'pin'; + const messageBoxTopLevelContainerStyle = + pinIconPositioning === 'left' + ? styles.rightMessageBoxTopLevelContainerStyle + : styles.leftMessageBoxTopLevelContainerStyle; + + let pinIcon; + if (isPinned && shouldDisplayPinIndicator) { + pinIcon = ( + + + + ); + } + const messageBoxStyle = { opacity: contentAndHeaderOpacity, maxWidth: composedMessageMaxWidth, }; const messageBox = ( - - - {avatar} - {children} - + + {pinIcon} + + + {avatar} + {children} + + ); let inlineEngagement = null; const label = getMessageLabel(hasBeenEdited, item.threadInfo); if ( item.threadCreatedFromMessage || Object.keys(item.reactions).length > 0 || label ) { const positioning = isViewer ? 'right' : 'left'; inlineEngagement = ( ); } return ( {deliveryIcon} {messageBox} {failedSendInfo} {inlineEngagement} ); } reply = () => { const { inputState, item } = this.props; invariant(inputState, 'inputState should be set in reply'); invariant(item.messageInfo.text, 'text should be set in reply'); inputState.editInputMessage({ message: createMessageReply(item.messageInfo.text), mode: 'prepend', }); }; } const styles = StyleSheet.create({ alignment: { marginLeft: composedMessageStyle.marginLeft, marginRight: composedMessageStyle.marginRight, }, avatarContainer: { marginRight: 8, }, 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: { - flex: 1, marginRight: 5, }, + pinIconContainer: { + marginRight: 4, + marginTop: 4, + }, rightChatBubble: { justifyContent: 'flex-start', }, rightChatContainer: { alignItems: 'flex-end', }, + rightMessageBoxTopLevelContainerStyle: { + flexDirection: 'row', + }, swipeableContainer: { alignItems: 'flex-end', flexDirection: 'row', }, }); const ConnectedComposedMessage: React.ComponentType = React.memo(function ConnectedComposedMessage(props: BaseProps) { 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 shouldRenderAvatars = useShouldRenderAvatars(); return ( ); }); export default ConnectedComposedMessage; diff --git a/native/chat/message-list.react.js b/native/chat/message-list.react.js index 2edb398a1..1a2ea7ab6 100644 --- a/native/chat/message-list.react.js +++ b/native/chat/message-list.react.js @@ -1,379 +1,380 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find.js'; import * as React from 'react'; import { View, TouchableWithoutFeedback } from 'react-native'; import { createSelector } from 'reselect'; import { fetchMessagesBeforeCursorActionTypes, fetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, } from 'lib/actions/message-actions.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { messageKey } from 'lib/shared/message-utils.js'; import { useWatchThread } from 'lib/shared/thread-utils.js'; import type { FetchMessageInfosPayload } from 'lib/types/message-types.js'; import { type ThreadInfo, threadTypes } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import ChatList from './chat-list.react.js'; import type { ChatNavigationProp } from './chat.react.js'; import { Message } from './message.react.js'; import RelationshipPrompt from './relationship-prompt.react.js'; import ListLoadingIndicator from '../components/list-loading-indicator.react.js'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state.js'; import { defaultStackScreenOptions } from '../navigation/options.js'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles, type IndicatorStyle, useIndicatorStyle, } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight, ChatMessageItemWithHeight, } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; import type { ViewableItemsChange } from '../types/react-native.js'; type BaseProps = { +threadInfo: ThreadInfo, +messageListData: $ReadOnlyArray, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; type Props = { ...BaseProps, // Redux state +startReached: boolean, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +fetchMessagesBeforeCursor: ( threadID: string, beforeMessageID: string, ) => Promise, +fetchMostRecentMessages: ( threadID: string, ) => Promise, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, }; type State = { +focusedMessageKey: ?string, +messageListVerticalBounds: ?VerticalBounds, +loadingFromScroll: boolean, }; type PropsAndState = { ...Props, ...State, }; type FlatListExtraData = { messageListVerticalBounds: ?VerticalBounds, focusedMessageKey: ?string, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, }; class MessageList extends React.PureComponent { state: State = { focusedMessageKey: null, messageListVerticalBounds: null, loadingFromScroll: false, }; flatListContainer: ?React.ElementRef; flatListExtraDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.messageListVerticalBounds, (propsAndState: PropsAndState) => propsAndState.focusedMessageKey, (propsAndState: PropsAndState) => propsAndState.navigation, (propsAndState: PropsAndState) => propsAndState.route, ( messageListVerticalBounds: ?VerticalBounds, focusedMessageKey: ?string, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, ) => ({ messageListVerticalBounds, focusedMessageKey, navigation, route, }), ); get flatListExtraData(): FlatListExtraData { return this.flatListExtraDataSelector({ ...this.props, ...this.state }); } static getOverlayContext(props: Props) { const { overlayContext } = props; invariant(overlayContext, 'MessageList should have OverlayContext'); return overlayContext; } static scrollDisabled(props: Props) { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus !== 'closed'; } static modalOpen(props: Props) { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus === 'open'; } componentDidUpdate(prevProps: Props) { const newListData = this.props.messageListData; const oldListData = prevProps.messageListData; if ( this.state.loadingFromScroll && (newListData.length > oldListData.length || this.props.startReached) ) { this.setState({ loadingFromScroll: false }); } const modalIsOpen = MessageList.modalOpen(this.props); const modalWasOpen = MessageList.modalOpen(prevProps); if (!modalIsOpen && modalWasOpen) { this.setState({ focusedMessageKey: null }); } if (defaultStackScreenOptions.gestureEnabled) { const scrollIsDisabled = MessageList.scrollDisabled(this.props); const scrollWasDisabled = MessageList.scrollDisabled(prevProps); if (!scrollWasDisabled && scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: false }); } else if (scrollWasDisabled && !scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: true }); } } } dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; renderItem = (row: { item: ChatMessageItemWithHeight, ... }) => { if (row.item.itemType === 'loader') { return ( ); } const messageInfoItem: ChatMessageInfoItemWithHeight = row.item; const { messageListVerticalBounds, focusedMessageKey, navigation, route } = this.flatListExtraData; const focused = messageKey(messageInfoItem.messageInfo) === focusedMessageKey; return ( ); }; toggleMessageFocus = (inMessageKey: string) => { if (this.state.focusedMessageKey === inMessageKey) { this.setState({ focusedMessageKey: null }); } else { this.setState({ focusedMessageKey: inMessageKey }); } }; // Actually header, it's just that our FlatList is inverted ListFooterComponent = () => ; render() { const { messageListData, startReached } = this.props; const footer = startReached ? this.ListFooterComponent : undefined; let relationshipPrompt = null; if (this.props.threadInfo.type === threadTypes.PERSONAL) { relationshipPrompt = ( ); } return ( {relationshipPrompt} ); } flatListContainerRef = ( flatListContainer: ?React.ElementRef, ) => { this.flatListContainer = flatListContainer; }; onFlatListContainerLayout = () => { const { flatListContainer } = this; if (!flatListContainer) { return; } flatListContainer.measure((x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ messageListVerticalBounds: { height, y: pageY } }); }); }; onViewableItemsChanged = (info: ViewableItemsChange) => { if (this.state.focusedMessageKey) { let focusedMessageVisible = false; for (const token of info.viewableItems) { if ( token.item.itemType === 'message' && messageKey(token.item.messageInfo) === this.state.focusedMessageKey ) { focusedMessageVisible = true; break; } } if (!focusedMessageVisible) { this.setState({ focusedMessageKey: null }); } } const loader = _find({ key: 'loader' })(info.viewableItems); if (!loader || this.state.loadingFromScroll) { return; } this.setState({ loadingFromScroll: true }); const oldestMessageServerID = this.oldestMessageServerID(); const threadID = this.props.threadInfo.id; if (oldestMessageServerID) { this.props.dispatchActionPromise( fetchMessagesBeforeCursorActionTypes, this.props.fetchMessagesBeforeCursor(threadID, oldestMessageServerID), ); } else { this.props.dispatchActionPromise( fetchMostRecentMessagesActionTypes, this.props.fetchMostRecentMessages(threadID), ); } }; oldestMessageServerID(): ?string { const data = this.props.messageListData; for (let i = data.length - 1; i >= 0; i--) { if (data[i].itemType === 'message' && data[i].messageInfo.id) { return data[i].messageInfo.id; } } return null; } } const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, header: { height: 12, }, listLoadingIndicator: { flex: 1, }, }; registerFetchKey(fetchMessagesBeforeCursorActionTypes); registerFetchKey(fetchMostRecentMessagesActionTypes); const ConnectedMessageList: React.ComponentType = React.memo(function ConnectedMessageList(props: BaseProps) { const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); const threadID = props.threadInfo.id; const startReached = useSelector( state => !!( state.messageStore.threads[threadID] && state.messageStore.threads[threadID].startReached ), ); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const dispatchActionPromise = useDispatchActionPromise(); const callFetchMessagesBeforeCursor = useServerCall( fetchMessagesBeforeCursor, ); const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages); useWatchThread(props.threadInfo); return ( ); }); export default ConnectedMessageList; diff --git a/native/chat/message-result.react.js b/native/chat/message-result.react.js index 04dfa9944..2492d50e8 100644 --- a/native/chat/message-result.react.js +++ b/native/chat/message-result.react.js @@ -1,75 +1,76 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import { longAbsoluteDate } from 'lib/utils/date-utils.js'; import type { ChatNavigationProp } from './chat.react'; import { MessageListContextProvider } from './message-list-types.js'; import { Message } from './message.react.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import type { NavigationRoute } from '../navigation/route-names'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; type MessageResultProps = { +item: ChatMessageInfoItemWithHeight, +threadInfo: ThreadInfo, +navigation: | AppNavigationProp<'TogglePinModal'> | ChatNavigationProp<'MessageResultsScreen'>, +route: | NavigationRoute<'TogglePinModal'> | NavigationRoute<'MessageResultsScreen'>, +messageVerticalBounds: ?VerticalBounds, }; function MessageResult(props: MessageResultProps): React.Node { const styles = useStyles(unboundStyles); const onToggleFocus = React.useCallback(() => {}, []); return ( {longAbsoluteDate(props.item.messageInfo.time)} ); } const unboundStyles = { container: { marginTop: 5, backgroundColor: 'panelForeground', overflow: 'scroll', maxHeight: 400, }, viewContainer: { marginTop: 10, marginBottom: 10, }, messageDate: { color: 'messageLabel', fontSize: 12, marginLeft: 55, }, }; export default MessageResult; diff --git a/native/chat/message.react.js b/native/chat/message.react.js index 529285309..79eaa063c 100644 --- a/native/chat/message.react.js +++ b/native/chat/message.react.js @@ -1,148 +1,151 @@ // @flow import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { LayoutAnimation, TouchableWithoutFeedback, PixelRatio, } from 'react-native'; import shallowequal from 'shallowequal'; import { messageKey } from 'lib/shared/message-utils.js'; import type { ChatNavigationProp } from './chat.react.js'; import MultimediaMessage from './multimedia-message.react.js'; import { RobotextMessage } from './robotext-message.react.js'; import { TextMessage } from './text-message.react.js'; import { messageItemHeight } from './utils.js'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import type { NavigationRoute } from '../navigation/route-names.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; import { type VerticalBounds } from '../types/layout-types.js'; import type { LayoutEvent } from '../types/react-native.js'; type BaseProps = { +item: ChatMessageInfoItemWithHeight, +focused: boolean, +navigation: | ChatNavigationProp<'MessageList'> | AppNavigationProp<'TogglePinModal'> | ChatNavigationProp<'MessageResultsScreen'>, +route: | NavigationRoute<'MessageList'> | NavigationRoute<'TogglePinModal'> | NavigationRoute<'MessageResultsScreen'>, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, + shouldDisplayPinIndicator: boolean, }; type Props = { ...BaseProps, +keyboardState: ?KeyboardState, }; class Message extends React.Component { shouldComponentUpdate(nextProps: Props): boolean { const { item, ...props } = this.props; const { item: nextItem, ...newProps } = nextProps; return !_isEqual(item, nextItem) || !shallowequal(props, newProps); } componentDidUpdate(prevProps: Props) { if ( (prevProps.focused || prevProps.item.startsConversation) !== (this.props.focused || this.props.item.startsConversation) ) { LayoutAnimation.easeInEaseOut(); } } render() { let message; if (this.props.item.messageShapeType === 'text') { message = ( ); } else if (this.props.item.messageShapeType === 'multimedia') { message = ( ); } else { message = ( ); } const onLayout = __DEV__ ? this.onLayout : undefined; return ( {message} ); } onLayout = (event: LayoutEvent) => { if (this.props.focused) { return; } const measuredHeight = event.nativeEvent.layout.height; const expectedHeight = messageItemHeight(this.props.item); const pixelRatio = 1 / PixelRatio.get(); const distance = Math.abs(measuredHeight - expectedHeight); if (distance < pixelRatio) { return; } const approxMeasuredHeight = Math.round(measuredHeight * 100) / 100; const approxExpectedHeight = Math.round(expectedHeight * 100) / 100; console.log( `Message height for ${this.props.item.messageShapeType} ` + `${messageKey(this.props.item.messageInfo)} was expected to be ` + `${approxExpectedHeight} but is actually ${approxMeasuredHeight}. ` + "This means MessageList's FlatList isn't getting the right item " + 'height for some of its nodes, which is guaranteed to cause glitchy ' + 'behavior. Please investigate!!', ); }; dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const ConnectedMessage: React.ComponentType = React.memo( function ConnectedMessage(props: BaseProps) { const keyboardState = React.useContext(KeyboardContext); return ; }, ); export { ConnectedMessage as Message }; diff --git a/native/chat/multimedia-message.react.js b/native/chat/multimedia-message.react.js index b6ceb7211..cfd0d94c0 100644 --- a/native/chat/multimedia-message.react.js +++ b/native/chat/multimedia-message.react.js @@ -1,266 +1,269 @@ // @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.js'; import { threadHasPermission, useCanCreateSidebarFromMessage, } from 'lib/shared/thread-utils.js'; import type { MediaInfo } from 'lib/types/media-types.js'; import { threadPermissions } from 'lib/types/thread-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, + +shouldDisplayPinIndicator: boolean, }; type Props = { ...BaseProps, +navigation: NavigationProp, +route: LeafRoute<>, +overlayContext: ?OverlayContextType, +chatContext: ?ChatContextType, +canCreateSidebarFromMessage: boolean, +canTogglePins: 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() { 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) { 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() { return ( this.props.item.threadCreatedFromMessage || this.props.canCreateSidebarFromMessage ); } render() { const { item, focused, toggleFocus, verticalBounds, + shouldDisplayPinIndicator, navigation, route, overlayContext, chatContext, canCreateSidebarFromMessage, canTogglePins, ...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 canTogglePins = threadHasPermission( props.item.threadInfo, threadPermissions.MANAGE_PINS, ); return ( ); }); export default ConnectedMultimediaMessage; diff --git a/native/chat/text-message.react.js b/native/chat/text-message.react.js index 2d2a6aa65..b2d15a855 100644 --- a/native/chat/text-message.react.js +++ b/native/chat/text-message.react.js @@ -1,293 +1,296 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { useCanEditMessage } from 'lib/shared/edit-messages-utils.js'; import { messageKey } from 'lib/shared/message-utils.js'; import { threadHasPermission, useCanCreateSidebarFromMessage, } from 'lib/shared/thread-utils.js'; import { threadPermissions } from 'lib/types/thread-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 { 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 { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { TextMessageTooltipModalRouteName } 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'; import { useShouldRenderEditButton } from '../utils/edit-messages-utils.js'; type BaseProps = { ...React.ElementConfig, +item: ChatTextMessageInfoItemWithHeight, +navigation: | ChatNavigationProp<'MessageList'> | AppNavigationProp<'TogglePinModal'> | ChatNavigationProp<'MessageResultsScreen'>, +route: | NavigationRoute<'MessageList'> | NavigationRoute<'TogglePinModal'> | NavigationRoute<'MessageResultsScreen'>, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, + +shouldDisplayPinIndicator: boolean, }; type Props = { ...BaseProps, // Redux state +canCreateSidebarFromMessage: boolean, // withOverlayContext +overlayContext: ?OverlayContextType, // ChatContext +chatContext: ?ChatContextType, // MarkdownContext +isLinkModalActive: boolean, +canEditMessage: boolean, +shouldRenderEditButton: boolean, +canTogglePins: 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, + shouldDisplayPinIndicator, overlayContext, chatContext, isLinkModalActive, canCreateSidebarFromMessage, canEditMessage, shouldRenderEditButton, canTogglePins, ...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.canEditMessage && this.props.shouldRenderEditButton) { 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 }, } = 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, 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, 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 shouldRenderEditButton = useShouldRenderEditButton(); const canEditMessage = useCanEditMessage( props.item.threadInfo, props.item.messageInfo, ); const canTogglePins = threadHasPermission( props.item.threadInfo, threadPermissions.MANAGE_PINS, ); React.useEffect(() => clearMarkdownContextData, [clearMarkdownContextData]); return ( ); }); export { ConnectedTextMessage as TextMessage };