diff --git a/native/chat/message-result.react.js b/native/chat/message-result.react.js index fa7b4bcd7..a93cacf08 100644 --- a/native/chat/message-result.react.js +++ b/native/chat/message-result.react.js @@ -1,20 +1,68 @@ // @flow import * as React from 'react'; -import { View } from 'react-native'; +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 { 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'; type MessageResultProps = { +item: ChatMessageInfoItemWithHeight, +threadInfo: ThreadInfo, + +navigation: AppNavigationProp<'TogglePinModal'>, + +route: NavigationRoute<'TogglePinModal'>, }; -/* eslint-disable no-unused-vars */ function MessageResult(props: MessageResultProps): React.Node { - return ; + 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 4aac856de..6aab78708 100644 --- a/native/chat/message.react.js +++ b/native/chat/message.react.js @@ -1,141 +1,144 @@ // @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'>, - +route: NavigationRoute<'MessageList'>, + +navigation: + | ChatNavigationProp<'MessageList'> + | AppNavigationProp<'TogglePinModal'>, + +route: NavigationRoute<'MessageList'> | NavigationRoute<'TogglePinModal'>, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, }; 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/robotext-message.react.js b/native/chat/robotext-message.react.js index db7deeb3f..b0c05b85b 100644 --- a/native/chat/robotext-message.react.js +++ b/native/chat/robotext-message.react.js @@ -1,214 +1,217 @@ // @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/thread-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'>, - +route: NavigationRoute<'MessageList'>, + +navigation: + | ChatNavigationProp<'MessageList'> + | AppNavigationProp<'TogglePinModal'>, + +route: NavigationRoute<'MessageList'> | NavigationRoute<'TogglePinModal'>, +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 inlineEngagement = null; if (item.threadCreatedFromMessage || Object.keys(item.reactions).length > 0) { inlineEngagement = ( ); } 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 visibleEntryIDs = React.useMemo(() => { const result = []; if (item.threadCreatedFromMessage || canCreateSidebarFromMessage) { result.push('sidebar'); } return result; }, [item.threadCreatedFromMessage, canCreateSidebarFromMessage]); 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, 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) { 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} {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 62ca8bf57..745e356ce 100644 --- a/native/chat/text-message.react.js +++ b/native/chat/text-message.react.js @@ -1,286 +1,289 @@ // @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'>, - +route: NavigationRoute<'MessageList'>, + +navigation: + | ChatNavigationProp<'MessageList'> + | AppNavigationProp<'TogglePinModal'>, + +route: NavigationRoute<'MessageList'> | NavigationRoute<'TogglePinModal'>, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, }; 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, 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 }; diff --git a/native/chat/toggle-pin-modal.react.js b/native/chat/toggle-pin-modal.react.js index 1311bef2b..0ae75f652 100644 --- a/native/chat/toggle-pin-modal.react.js +++ b/native/chat/toggle-pin-modal.react.js @@ -1,198 +1,203 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import { toggleMessagePin, toggleMessagePinActionTypes, } from 'lib/actions/thread-actions.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-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'; export type TogglePinModalParams = { +item: ChatMessageInfoItemWithHeight, +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 = useServerCall(toggleMessagePin); 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 modifiedItem = React.useMemo(() => { // The if / else if / else conditional is for Flow if (item.messageShapeType === 'robotext') { return item; } else if (item.messageShapeType === 'multimedia') { return { ...item, threadCreatedFromMessage: undefined, reactions: {}, startsConversation: false, startsCluster: true, endsCluster: true, messageInfo: { ...item.messageInfo, creator: { ...item.messageInfo.creator, isViewer: false, }, }, }; } else { return { ...item, threadCreatedFromMessage: undefined, reactions: {}, startsConversation: false, startsCluster: true, endsCluster: true, messageInfo: { ...item.messageInfo, creator: { ...item.messageInfo.creator, isViewer: false, }, }, }; } }, [item]); 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, }; }, [callToggleMessagePin, messageInfo.id, modalInfo.action]); const onPress = React.useCallback(() => { 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;