diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js index 1a8d5c937..f6dab39fb 100644 --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -1,411 +1,433 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter'; import _flow from 'lodash/fp/flow'; import _map from 'lodash/fp/map'; import _orderBy from 'lodash/fp/orderBy'; import _memoize from 'lodash/memoize'; import * as React from 'react'; import { useSelector } from 'react-redux'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import { messageKey, robotextForMessageInfo, createMessageInfo, getMostRecentNonLocalMessageID, } from '../shared/message-utils'; import { threadIsTopLevel, threadInChatList } from '../shared/thread-utils'; import { type MessageInfo, type MessageStore, type ComposableMessageInfo, type RobotextMessageInfo, type LocalMessageInfo, messageTypes, isComposableMessageType, } from '../types/message-types'; import type { BaseAppState } from '../types/redux-types'; import { type ThreadInfo, type RawThreadInfo, type SidebarInfo, maxReadSidebars, maxUnreadSidebars, } from '../types/thread-types'; import type { UserInfo } from '../types/user-types'; import { threeDays } from '../utils/date-utils'; -import { threadInfoSelector, sidebarInfoSelector } from './thread-selectors'; +import { + threadInfoSelector, + sidebarInfoSelector, + threadInfoFromSourceMessageIDSelector, +} from './thread-selectors'; type SidebarItem = | {| ...SidebarInfo, +type: 'sidebar', |} | {| +type: 'seeMore', +unread: boolean, +showingSidebarsInline: boolean, |}; export type ChatThreadItem = {| +type: 'chatThreadItem', +threadInfo: ThreadInfo, +mostRecentMessageInfo: ?MessageInfo, +mostRecentNonLocalMessage: ?string, +lastUpdatedTime: number, +lastUpdatedTimeIncludingSidebars: number, +sidebars: $ReadOnlyArray, +pendingPersonalThreadUserInfo?: UserInfo, |}; const messageInfoSelector: ( state: BaseAppState<*>, ) => { [id: string]: MessageInfo } = createObjectSelector( (state: BaseAppState<*>) => state.messageStore.messages, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, threadInfoSelector, createMessageInfo, ); function getMostRecentMessageInfo( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { [id: string]: MessageInfo }, ): ?MessageInfo { const thread = messageStore.threads[threadInfo.id]; if (!thread) { return null; } for (let messageID of thread.messageIDs) { return messages[messageID]; } return null; } function getLastUpdatedTime( threadInfo: ThreadInfo, mostRecentMessageInfo: ?MessageInfo, ): number { return mostRecentMessageInfo ? mostRecentMessageInfo.time : threadInfo.creationTime; } function createChatThreadItem( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { [id: string]: MessageInfo }, sidebarInfos: ?$ReadOnlyArray, ): ChatThreadItem { const mostRecentMessageInfo = getMostRecentMessageInfo( threadInfo, messageStore, messages, ); const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID( threadInfo, messageStore, ); const lastUpdatedTime = getLastUpdatedTime(threadInfo, mostRecentMessageInfo); const sidebars = sidebarInfos ?? []; const allSidebarItems = sidebars.map((sidebarInfo) => ({ type: 'sidebar', ...sidebarInfo, })); const lastUpdatedTimeIncludingSidebars = allSidebarItems.length > 0 ? Math.max(lastUpdatedTime, allSidebarItems[0].lastUpdatedTime) : lastUpdatedTime; const numUnreadSidebars = allSidebarItems.filter( (sidebar) => sidebar.threadInfo.currentUser.unread, ).length; let numReadSidebarsToShow = maxReadSidebars - numUnreadSidebars; const threeDaysAgo = Date.now() - threeDays; const sidebarItems = []; for (const sidebar of allSidebarItems) { if (sidebarItems.length >= maxUnreadSidebars) { break; } else if (sidebar.threadInfo.currentUser.unread) { sidebarItems.push(sidebar); } else if ( sidebar.lastUpdatedTime > threeDaysAgo && numReadSidebarsToShow > 0 ) { sidebarItems.push(sidebar); numReadSidebarsToShow--; } } if (sidebarItems.length < allSidebarItems.length) { sidebarItems.push({ type: 'seeMore', unread: numUnreadSidebars > maxUnreadSidebars, showingSidebarsInline: sidebarItems.length !== 0, }); } return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo, mostRecentNonLocalMessage, lastUpdatedTime, lastUpdatedTimeIncludingSidebars, sidebars: sidebarItems, }; } const chatListData: ( state: BaseAppState<*>, ) => ChatThreadItem[] = createSelector( threadInfoSelector, (state: BaseAppState<*>) => state.messageStore, messageInfoSelector, sidebarInfoSelector, ( threadInfos: { [id: string]: ThreadInfo }, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, sidebarInfos: { [id: string]: $ReadOnlyArray }, ): ChatThreadItem[] => getChatThreadItems( threadInfos, messageStore, messageInfos, sidebarInfos, threadIsTopLevel, ), ); function useFlattenedChatListData(): ChatThreadItem[] { const threadInfos = useSelector(threadInfoSelector); const messageInfos = useSelector(messageInfoSelector); const sidebarInfos = useSelector(sidebarInfoSelector); const messageStore = useSelector((state) => state.messageStore); return React.useMemo( () => getChatThreadItems( threadInfos, messageStore, messageInfos, sidebarInfos, threadInChatList, ), [messageInfos, messageStore, sidebarInfos, threadInfos], ); } function getChatThreadItems( threadInfos: { [id: string]: ThreadInfo }, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, sidebarInfos: { [id: string]: $ReadOnlyArray }, filterFunction: (threadInfo: ?(ThreadInfo | RawThreadInfo)) => boolean, ): ChatThreadItem[] { return _flow( _filter(filterFunction), _map((threadInfo: ThreadInfo): ChatThreadItem => createChatThreadItem( threadInfo, messageStore, messageInfos, sidebarInfos[threadInfo.id], ), ), _orderBy('lastUpdatedTimeIncludingSidebars')('desc'), )(threadInfos); } export type RobotextChatMessageInfoItem = {| - itemType: 'message', - messageInfo: RobotextMessageInfo, - startsConversation: boolean, - startsCluster: boolean, + +itemType: 'message', + +messageInfo: RobotextMessageInfo, + +startsConversation: boolean, + +startsCluster: boolean, endsCluster: boolean, - robotext: string, + +robotext: string, + +threadCreatedFromMessage: ?ThreadInfo, |}; export type ChatMessageInfoItem = | RobotextChatMessageInfoItem | {| - itemType: 'message', - messageInfo: ComposableMessageInfo, - localMessageInfo: ?LocalMessageInfo, - startsConversation: boolean, - startsCluster: boolean, + +itemType: 'message', + +messageInfo: ComposableMessageInfo, + +localMessageInfo: ?LocalMessageInfo, + +startsConversation: boolean, + +startsCluster: boolean, endsCluster: boolean, + +threadCreatedFromMessage: ?ThreadInfo, |}; export type ChatMessageItem = {| itemType: 'loader' |} | ChatMessageInfoItem; const msInFiveMinutes = 5 * 60 * 1000; function createChatMessageItems( threadID: string, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, threadInfos: { [id: string]: ThreadInfo }, + threadInfoFromSourceMessageID: { [id: string]: ThreadInfo }, ): ChatMessageItem[] { const thread = messageStore.threads[threadID]; if (!thread) { return []; } const threadMessageInfos = thread.messageIDs .map((messageID: string) => messageInfos[messageID]) .filter(Boolean); const chatMessageItems = []; let lastMessageInfo = null; for (let i = threadMessageInfos.length - 1; i >= 0; i--) { const messageInfo = threadMessageInfos[i]; const originalMessageInfo = messageInfo.type === messageTypes.SIDEBAR_SOURCE ? messageInfo.sourceMessage : messageInfo; let startsConversation = true; let startsCluster = true; if ( lastMessageInfo && lastMessageInfo.time + msInFiveMinutes > originalMessageInfo.time ) { startsConversation = false; if ( isComposableMessageType(lastMessageInfo.type) && isComposableMessageType(originalMessageInfo.type) && lastMessageInfo.creator.id === originalMessageInfo.creator.id ) { startsCluster = false; } } if (startsCluster && chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } + const threadCreatedFromMessage = messageInfo.id + ? threadInfoFromSourceMessageID[messageInfo.id] + : undefined; if (isComposableMessageType(originalMessageInfo.type)) { // We use these invariants instead of just checking the messageInfo.type // directly in the conditional above so that isComposableMessageType can // be the source of truth invariant( originalMessageInfo.type === messageTypes.TEXT || originalMessageInfo.type === messageTypes.IMAGES || originalMessageInfo.type === messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const localMessageInfo = messageStore.local[messageKey(originalMessageInfo)]; chatMessageItems.push({ itemType: 'message', messageInfo: originalMessageInfo, localMessageInfo, startsConversation, startsCluster, endsCluster: false, + threadCreatedFromMessage, }); } else { invariant( originalMessageInfo.type !== messageTypes.TEXT && originalMessageInfo.type !== messageTypes.IMAGES && originalMessageInfo.type !== messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const robotext = robotextForMessageInfo( originalMessageInfo, threadInfos[threadID], ); chatMessageItems.push({ itemType: 'message', messageInfo: originalMessageInfo, startsConversation, startsCluster, endsCluster: false, + threadCreatedFromMessage, robotext, }); } lastMessageInfo = originalMessageInfo; } if (chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } chatMessageItems.reverse(); if (thread.startReached) { return chatMessageItems; } return [...chatMessageItems, ({ itemType: 'loader' }: ChatMessageItem)]; } const baseMessageListData = (threadID: string) => createSelector( (state: BaseAppState<*>) => state.messageStore, messageInfoSelector, threadInfoSelector, + threadInfoFromSourceMessageIDSelector, ( messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, threadInfos: { [id: string]: ThreadInfo }, + threadInfoFromSourceMessageID: { [id: string]: ThreadInfo }, ): ChatMessageItem[] => - createChatMessageItems(threadID, messageStore, messageInfos, threadInfos), + createChatMessageItems( + threadID, + messageStore, + messageInfos, + threadInfos, + threadInfoFromSourceMessageID, + ), ); const messageListData: ( threadID: string, ) => (state: BaseAppState<*>) => ChatMessageItem[] = _memoize( baseMessageListData, ); function getSourceMessageChatItemForPendingSidebar( messageInfo: ComposableMessageInfo | RobotextMessageInfo, threadInfos: { [id: string]: ThreadInfo }, ): ChatMessageInfoItem { if (isComposableMessageType(messageInfo.type)) { invariant( messageInfo.type === messageTypes.TEXT || messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const messageItem = { itemType: 'message', messageInfo: messageInfo, startsConversation: true, startsCluster: true, endsCluster: false, localMessageInfo: null, + threadCreatedFromMessage: undefined, }; return messageItem; } else { invariant( messageInfo.type !== messageTypes.TEXT && messageInfo.type !== messageTypes.IMAGES && messageInfo.type !== messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const robotext = robotextForMessageInfo( messageInfo, threadInfos[messageInfo.threadID], ); const messageItem = { itemType: 'message', messageInfo: messageInfo, startsConversation: true, startsCluster: true, endsCluster: false, + threadCreatedFromMessage: undefined, robotext, }; return messageItem; } } export { messageInfoSelector, createChatThreadItem, chatListData, createChatMessageItems, messageListData, useFlattenedChatListData, getSourceMessageChatItemForPendingSidebar, }; diff --git a/native/chat/composed-message.react.js b/native/chat/composed-message.react.js index 217d2d924..44b7de834 100644 --- a/native/chat/composed-message.react.js +++ b/native/chat/composed-message.react.js @@ -1,171 +1,193 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { StyleSheet, View, Platform } from 'react-native'; import Icon from 'react-native-vector-icons/Feather'; import { createMessageReply } from 'lib/shared/message-utils'; import { assertComposableMessageType } from 'lib/types/message-types'; import { type InputState, InputStateContext } from '../input/input-state'; import { useSelector } from '../redux/redux-utils'; import { type Colors, useColors } from '../themes/colors'; import { composedMessageMaxWidthSelector } from './composed-message-width'; import { FailedSend } from './failed-send.react'; +import { + InlineSidebar, + inlineSidebarMarginBottom, + inlineSidebarMarginTop, +} from './inline-sidebar.react'; import { MessageHeader } from './message-header.react'; import type { ChatMessageInfoItemWithHeight } from './message.react'; import SwipeableMessage from './swipeable-message.react'; const clusterEndHeight = 7; type BaseProps = {| ...React.ElementConfig, +item: ChatMessageInfoItemWithHeight, +sendFailed: boolean, +focused: boolean, +canSwipe?: boolean, +children: React.Node, |}; type Props = {| ...BaseProps, // Redux state +composedMessageMaxWidth: number, +colors: Colors, // withInputState +inputState: ?InputState, |}; class ComposedMessage extends React.PureComponent { render() { assertComposableMessageType(this.props.item.messageInfo.type); const { item, sendFailed, focused, canSwipe, children, composedMessageMaxWidth, colors, inputState, ...viewProps } = this.props; const { id, creator } = item.messageInfo; const { isViewer } = creator; const alignStyle = isViewer ? styles.rightChatBubble : styles.leftChatBubble; const containerStyle = [ styles.alignment, { marginBottom: 5 + (item.endsCluster ? clusterEndHeight : 0) }, ]; const messageBoxStyle = { maxWidth: composedMessageMaxWidth }; 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'; } deliveryIcon = ( ); } const fullMessageBoxStyle = [styles.messageBox, messageBoxStyle]; let messageBox; if (canSwipe && (Platform.OS !== 'android' || Platform.Version >= 21)) { messageBox = ( {children} ); } else { messageBox = {children}; } + let inlineSidebar = null; + if (item.threadCreatedFromMessage) { + const positioning = isViewer ? 'right' : 'left'; + inlineSidebar = ( + + + + ); + } return ( {messageBox} {deliveryIcon} {failedSendInfo} + {inlineSidebar} ); } 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.addReply(createMessageReply(item.messageInfo.text)); }; } const styles = StyleSheet.create({ alignment: { marginLeft: 12, marginRight: 7, }, content: { alignItems: 'center', flexDirection: 'row', }, icon: { fontSize: 16, textAlign: 'center', }, iconContainer: { marginLeft: 2, width: 16, }, + inlineSidebar: { + marginBottom: inlineSidebarMarginBottom, + marginTop: inlineSidebarMarginTop, + }, leftChatBubble: { justifyContent: 'flex-start', }, messageBox: { marginRight: 5, }, rightChatBubble: { justifyContent: 'flex-end', }, }); const ConnectedComposedMessage = React.memo( function ConnectedComposedMessage(props: BaseProps) { const composedMessageMaxWidth = useSelector( composedMessageMaxWidthSelector, ); const colors = useColors(); const inputState = React.useContext(InputStateContext); return ( ); }, ); export { ConnectedComposedMessage as ComposedMessage, clusterEndHeight }; diff --git a/native/chat/inline-sidebar.react.js b/native/chat/inline-sidebar.react.js new file mode 100644 index 000000000..6e0b2b444 --- /dev/null +++ b/native/chat/inline-sidebar.react.js @@ -0,0 +1,106 @@ +// @flow + +import { useNavigation } from '@react-navigation/native'; +import * as React from 'react'; +import { Text, View } from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; + +import type { ThreadInfo } from 'lib/types/thread-types'; + +import Button from '../components/button.react'; +import { MessageListRouteName } from '../navigation/route-names'; +import { useStyles } from '../themes/colors'; +import type { ChatNavigationProp } from './chat.react'; + +type Props = {| + +threadInfo: ThreadInfo, + +positioning: 'left' | 'center' | 'right', +|}; +function InlineSidebar(props: Props) { + const { threadInfo } = props; + const navigation: ChatNavigationProp<'MessageList'> = (useNavigation(): any); + + const onPress = React.useCallback(() => { + navigation.navigate({ + name: MessageListRouteName, + params: { threadInfo }, + key: `${MessageListRouteName}${threadInfo.id}`, + }); + }, [navigation, threadInfo]); + + const styles = useStyles(unboundStyles); + let viewerIcon, nonViewerIcon, alignStyle; + if (props.positioning === 'right') { + viewerIcon = ; + alignStyle = styles.rightAlign; + } else if (props.positioning === 'left') { + nonViewerIcon = ( + + ); + alignStyle = styles.leftAlign; + } else { + nonViewerIcon = ( + + ); + alignStyle = styles.centerAlign; + } + + const unreadStyle = threadInfo.currentUser.unread ? styles.unread : null; + return ( + + + + ); +} + +const inlineSidebarHeight = 20; +const inlineSidebarMarginTop = 5; +const inlineSidebarMarginBottom = 3; + +const unboundStyles = { + content: { + flexDirection: 'row', + marginRight: 30, + marginLeft: 10, + flex: 1, + height: inlineSidebarHeight, + }, + unread: { + fontWeight: 'bold', + }, + sidebar: { + flexDirection: 'row', + display: 'flex', + alignItems: 'center', + }, + icon: { + color: 'listForegroundTertiaryLabel', + }, + name: { + paddingTop: 1, + color: 'listForegroundTertiaryLabel', + fontSize: 16, + paddingLeft: 4, + paddingRight: 2, + }, + leftAlign: { + justifyContent: 'flex-start', + }, + rightAlign: { + justifyContent: 'flex-end', + }, + centerAlign: { + justifyContent: 'center', + }, +}; + +export { + InlineSidebar, + inlineSidebarHeight, + inlineSidebarMarginTop, + inlineSidebarMarginBottom, +}; diff --git a/native/chat/message-list-container.react.js b/native/chat/message-list-container.react.js index fbe0a0f7b..e995df9a2 100644 --- a/native/chat/message-list-container.react.js +++ b/native/chat/message-list-container.react.js @@ -1,512 +1,515 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { type ChatMessageItem, messageListData as messageListDataSelector, messageInfoSelector, getSourceMessageChatItemForPendingSidebar, } from 'lib/selectors/chat-selectors'; import { threadInfoSelector, threadInfoFromSourceMessageIDSelector, } from 'lib/selectors/thread-selectors'; import { userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, } from 'lib/selectors/user-selectors'; import { messageID } from 'lib/shared/message-utils'; import { getPotentialMemberItems } from 'lib/shared/search-utils'; import { createPendingThread, getCurrentUser, getPendingThreadKey, pendingThreadType, threadHasAdminRole, threadIsPending, } from 'lib/shared/thread-utils'; import { messageTypes } from 'lib/types/message-types'; import { type ThreadInfo, threadTypes } from 'lib/types/thread-types'; import type { AccountUserInfo, UserListItem } from 'lib/types/user-types'; import ContentLoading from '../components/content-loading.react'; import NodeHeightMeasurer from '../components/node-height-measurer.react'; import { type InputState, InputStateContext } from '../input/input-state'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type Colors, useColors, useStyles } from '../themes/colors'; import ChatInputBar from './chat-input-bar.react'; import { chatMessageItemKey } from './chat-list.react'; import type { ChatNavigationProp } from './chat.react'; import { composedMessageMaxWidthSelector } from './composed-message-width'; import { dummyNodeForRobotextMessageHeightMeasurement } from './inner-robotext-message.react'; import { dummyNodeForTextMessageHeightMeasurement } from './inner-text-message.react'; import MessageListThreadSearch from './message-list-thread-search.react'; import { MessageListContext, useMessageListContext, } from './message-list-types'; import MessageList from './message-list.react'; import type { ChatMessageInfoItemWithHeight } from './message.react'; import { multimediaMessageContentSizes } from './multimedia-message.react'; export type ChatMessageItemWithHeight = | {| itemType: 'loader' |} | ChatMessageInfoItemWithHeight; type BaseProps = {| +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, |}; type Props = {| ...BaseProps, // Redux state +usernameInputText: string, +updateUsernameInput: (text: string) => void, +userInfoInputArray: $ReadOnlyArray, +updateTagInput: (items: $ReadOnlyArray) => void, +otherUserInfos: { [id: string]: AccountUserInfo }, +userSearchResults: $ReadOnlyArray, +threadInfo: ThreadInfo, +messageListData: $ReadOnlyArray, +composedMessageMaxWidth: number, +colors: Colors, +styles: typeof unboundStyles, // withInputState +inputState: ?InputState, // withOverlayContext +overlayContext: ?OverlayContextType, |}; type State = {| +listDataWithHeights: ?$ReadOnlyArray, |}; class MessageListContainer extends React.PureComponent { state: State = { listDataWithHeights: null, }; pendingListDataWithHeights: ?$ReadOnlyArray; get frozen() { const { overlayContext } = this.props; invariant( overlayContext, 'MessageListContainer should have OverlayContext', ); return overlayContext.scrollBlockingModalStatus !== 'closed'; } componentDidUpdate(prevProps: Props) { const oldListData = prevProps.messageListData; const newListData = this.props.messageListData; if (!newListData && oldListData) { this.setState({ listDataWithHeights: null }); } if (!this.frozen && this.pendingListDataWithHeights) { this.setState({ listDataWithHeights: this.pendingListDataWithHeights }); this.pendingListDataWithHeights = undefined; } } render() { const { threadInfo, styles } = this.props; const { listDataWithHeights } = this.state; const { searching } = this.props.route.params; let searchComponent = null; if (searching) { searchComponent = ( ); } const showMessageList = !searching || this.props.userInfoInputArray.length > 0; let threadContent = null; if (showMessageList) { let messageList; if (listDataWithHeights) { messageList = ( ); } else { messageList = ( ); } threadContent = ( {messageList} ); } return ( {searchComponent} {threadContent} ); } heightMeasurerID = (item: ChatMessageItem) => { return chatMessageItemKey(item); }; heightMeasurerKey = (item: ChatMessageItem) => { if (item.itemType !== 'message') { return null; } const { messageInfo } = item; if (messageInfo.type === messageTypes.TEXT) { return messageInfo.text; } else if (item.robotext && typeof item.robotext === 'string') { return item.robotext; } return null; }; heightMeasurerDummy = (item: ChatMessageItem) => { invariant( item.itemType === 'message', 'NodeHeightMeasurer asked for dummy for non-message item', ); const { messageInfo } = item; if (messageInfo.type === messageTypes.TEXT) { return dummyNodeForTextMessageHeightMeasurement(messageInfo.text); } else if (item.robotext && typeof item.robotext === 'string') { return dummyNodeForRobotextMessageHeightMeasurement(item.robotext); } invariant(false, 'NodeHeightMeasurer asked for dummy for non-text message'); }; heightMeasurerMergeItem = (item: ChatMessageItem, height: ?number) => { if (item.itemType !== 'message') { return item; } const { messageInfo } = item; invariant( messageInfo.type !== messageTypes.SIDEBAR_SOURCE, 'Sidebar source messages should be replaced by sourceMessage before being measured', ); const { threadInfo } = this.props; if ( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { const { inputState } = this.props; // Conditional due to Flow... const localMessageInfo = item.localMessageInfo ? item.localMessageInfo : null; const id = messageID(messageInfo); const pendingUploads = inputState && inputState.pendingUploads && inputState.pendingUploads[id]; const sizes = multimediaMessageContentSizes( messageInfo, this.props.composedMessageMaxWidth, ); return { itemType: 'message', messageShapeType: 'multimedia', messageInfo, localMessageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, + threadCreatedFromMessage: item.threadCreatedFromMessage, pendingUploads, ...sizes, }; } invariant(height !== null && height !== undefined, 'height should be set'); if (messageInfo.type === messageTypes.TEXT) { // Conditional due to Flow... const localMessageInfo = item.localMessageInfo ? item.localMessageInfo : null; return { itemType: 'message', messageShapeType: 'text', messageInfo, localMessageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, + threadCreatedFromMessage: item.threadCreatedFromMessage, contentHeight: height, }; } else { invariant( typeof item.robotext === 'string', "Flow can't handle our fancy types :(", ); return { itemType: 'message', messageShapeType: 'robotext', messageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, + threadCreatedFromMessage: item.threadCreatedFromMessage, robotext: item.robotext, contentHeight: height, }; } }; allHeightsMeasured = ( listDataWithHeights: $ReadOnlyArray, ) => { if (this.frozen) { this.pendingListDataWithHeights = listDataWithHeights; } else { this.setState({ listDataWithHeights }); } }; } const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, threadContent: { flex: 1, }, }; export default React.memo(function ConnectedMessageListContainer( props: BaseProps, ) { const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const [usernameInputText, setUsernameInputText] = React.useState(''); const [userInfoInputArray, setUserInfoInputArray] = React.useState< $ReadOnlyArray, >([]); const updateTagInput = React.useCallback( (input: $ReadOnlyArray) => setUserInfoInputArray(input), [], ); const updateUsernameInput = React.useCallback( (text: string) => setUsernameInputText(text), [], ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const userSearchResults = React.useMemo( () => getPotentialMemberItems( usernameInputText, otherUserInfos, userSearchIndex, userInfoInputArray.map((userInfo) => userInfo.id), ), [usernameInputText, otherUserInfos, userSearchIndex, userInfoInputArray], ); const threadInfos = useSelector(threadInfoSelector); const userInfos = useSelector((state) => state.userStore.userInfos); const threadInfoRef = React.useRef(props.route.params.threadInfo); const [originalThreadInfo, setOriginalThreadInfo] = React.useState( props.route.params.threadInfo, ); const { searching } = props.route.params; const inputState = React.useContext(InputStateContext); const hideSearch = React.useCallback(() => { setOriginalThreadInfo(threadInfoRef.current); props.navigation.setParams({ searching: false, }); }, [props.navigation]); React.useEffect(() => { if (!searching) { return; } inputState?.registerSendCallback(hideSearch); return () => inputState?.unregisterSendCallback(hideSearch); }, [hideSearch, inputState, searching]); const threadCandidates = React.useMemo(() => { const infos = new Map(); for (const threadID in threadInfos) { const info = threadInfos[threadID]; if (info.parentThreadID || threadHasAdminRole(info)) { continue; } const key = getPendingThreadKey(info.members.map((member) => member.id)); const indexedThread = infos.get(key); if (!indexedThread || info.creationTime < indexedThread.creationTime) { infos.set(key, info); } } return infos; }, [threadInfos]); const { sidebarSourceMessageID } = props.route.params; const sidebarCandidate = useSelector((state) => { if (!sidebarSourceMessageID) { return null; } return threadInfoFromSourceMessageIDSelector(state)[sidebarSourceMessageID]; }); const latestThreadInfo = React.useMemo((): ?ThreadInfo => { const threadInfoFromParams = originalThreadInfo; const threadInfoFromStore = threadInfos[threadInfoFromParams.id]; if (threadInfoFromStore) { return threadInfoFromStore; } else if (!viewerID || !threadIsPending(threadInfoFromParams.id)) { return undefined; } const pendingThreadMemberIDs = searching ? [...userInfoInputArray.map((user) => user.id), viewerID] : threadInfoFromParams.members.map((member) => member.id); const threadKey = getPendingThreadKey(pendingThreadMemberIDs); if ( threadInfoFromParams.type !== threadTypes.SIDEBAR && threadCandidates.get(threadKey) ) { return threadCandidates.get(threadKey); } if (sidebarCandidate) { return sidebarCandidate; } const updatedThread = searching ? createPendingThread( viewerID, pendingThreadType(userInfoInputArray.length), userInfoInputArray, ) : threadInfoFromParams; return { ...updatedThread, currentUser: getCurrentUser(updatedThread, viewerID, userInfos), }; }, [ originalThreadInfo, threadInfos, viewerID, searching, userInfoInputArray, threadCandidates, sidebarCandidate, userInfos, ]); if (latestThreadInfo) { threadInfoRef.current = latestThreadInfo; } const threadInfo = threadInfoRef.current; const { setParams } = props.navigation; React.useEffect(() => { setParams({ threadInfo }); }, [setParams, threadInfo]); const threadID = threadInfoRef.current.id; const boundMessageListData = useSelector(messageListDataSelector(threadID)); const sidebarSourceMessageInfo = useSelector((state) => sidebarSourceMessageID && !sidebarCandidate ? messageInfoSelector(state)[sidebarSourceMessageID] : null, ); invariant( !sidebarSourceMessageInfo || sidebarSourceMessageInfo.type !== messageTypes.SIDEBAR_SOURCE, 'sidebars can not be created from sidebar_source message', ); const messageListData = React.useMemo(() => { if (searching && userInfoInputArray.length === 0) { return []; } else if (sidebarSourceMessageInfo) { return [ getSourceMessageChatItemForPendingSidebar( sidebarSourceMessageInfo, threadInfos, ), ]; } return boundMessageListData; }, [ searching, userInfoInputArray.length, sidebarSourceMessageInfo, boundMessageListData, threadInfos, ]); const composedMessageMaxWidth = useSelector(composedMessageMaxWidthSelector); const colors = useColors(); const styles = useStyles(unboundStyles); const overlayContext = React.useContext(OverlayContext); const messageListContext = useMessageListContext(threadID); return ( ); }); diff --git a/native/chat/multimedia-message.react.js b/native/chat/multimedia-message.react.js index d0a7ae4be..72898c069 100644 --- a/native/chat/multimedia-message.react.js +++ b/native/chat/multimedia-message.react.js @@ -1,289 +1,299 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import type { Media, Corners } from 'lib/types/media-types'; import type { MultimediaMessageInfo, LocalMessageInfo, } from 'lib/types/message-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import type { MessagePendingUploads } from '../input/input-state'; import type { NavigationRoute } from '../navigation/route-names'; import { type VerticalBounds } from '../types/layout-types'; import type { ViewStyle } from '../types/styles'; import type { ChatNavigationProp } from './chat.react'; import { ComposedMessage, clusterEndHeight } from './composed-message.react'; import { failedSendHeight } from './failed-send.react'; +import { + inlineSidebarHeight, + inlineSidebarMarginBottom, + inlineSidebarMarginTop, +} from './inline-sidebar.react'; import { authorNameHeight } from './message-header.react'; import MultimediaMessageMultimedia from './multimedia-message-multimedia.react'; import sendFailed from './multimedia-message-send-failed'; import { allCorners, filterCorners, getRoundedContainerStyle, } from './rounded-corners'; type ContentSizes = {| - imageHeight: number, - contentHeight: number, - contentWidth: number, + +imageHeight: number, + +contentHeight: number, + +contentWidth: number, |}; export type ChatMultimediaMessageInfoItem = {| ...ContentSizes, - itemType: 'message', - messageShapeType: 'multimedia', - messageInfo: MultimediaMessageInfo, - localMessageInfo: ?LocalMessageInfo, - threadInfo: ThreadInfo, - startsConversation: boolean, - startsCluster: boolean, - endsCluster: boolean, - pendingUploads: ?MessagePendingUploads, + +itemType: 'message', + +messageShapeType: 'multimedia', + +messageInfo: MultimediaMessageInfo, + +localMessageInfo: ?LocalMessageInfo, + +threadInfo: ThreadInfo, + +startsConversation: boolean, + +startsCluster: boolean, + +endsCluster: boolean, + +threadCreatedFromMessage: ?ThreadInfo, + +pendingUploads: ?MessagePendingUploads, |}; function getMediaPerRow(mediaCount: number) { if (mediaCount === 0) { return 0; // ??? } else if (mediaCount === 1) { return 1; } else if (mediaCount === 2) { return 2; } else if (mediaCount === 3) { return 3; } else if (mediaCount === 4) { return 2; } else { return 3; } } // Called by MessageListContainer // The results are merged into ChatMultimediaMessageInfoItem function multimediaMessageContentSizes( messageInfo: MultimediaMessageInfo, composedMessageMaxWidth: number, ): ContentSizes { invariant(messageInfo.media.length > 0, 'should have media'); if (messageInfo.media.length === 1) { const [media] = messageInfo.media; const { height, width } = media.dimensions; let imageHeight = height; if (width > composedMessageMaxWidth) { imageHeight = (height * composedMessageMaxWidth) / width; } if (imageHeight < 50) { imageHeight = 50; } let contentWidth = height ? (width * imageHeight) / height : 0; if (contentWidth > composedMessageMaxWidth) { contentWidth = composedMessageMaxWidth; } return { imageHeight, contentHeight: imageHeight, contentWidth }; } const contentWidth = composedMessageMaxWidth; const mediaPerRow = getMediaPerRow(messageInfo.media.length); const marginSpace = spaceBetweenImages * (mediaPerRow - 1); const imageHeight = (contentWidth - marginSpace) / mediaPerRow; const numRows = Math.ceil(messageInfo.media.length / mediaPerRow); const contentHeight = numRows * imageHeight + (numRows - 1) * spaceBetweenImages; return { imageHeight, contentHeight, contentWidth }; } // Called by Message // Given a ChatMultimediaMessageInfoItem, determines exact height of row function multimediaMessageItemHeight(item: ChatMultimediaMessageInfoItem) { const { messageInfo, contentHeight, startsCluster, endsCluster } = item; const { creator } = messageInfo; const { isViewer } = creator; let height = 5 + contentHeight; // 5 from marginBottom in ComposedMessage if (!isViewer && startsCluster) { height += authorNameHeight; } if (endsCluster) { height += clusterEndHeight; } if (sendFailed(item)) { height += failedSendHeight; } + if (item.threadCreatedFromMessage) { + height += + inlineSidebarHeight + inlineSidebarMarginTop + inlineSidebarMarginBottom; + } return height; } const borderRadius = 16; type Props = {| ...React.ElementConfig, - item: ChatMultimediaMessageInfoItem, - navigation: ChatNavigationProp<'MessageList'>, - route: NavigationRoute<'MessageList'>, - focused: boolean, - toggleFocus: (messageKey: string) => void, - verticalBounds: ?VerticalBounds, + +item: ChatMultimediaMessageInfoItem, + +navigation: ChatNavigationProp<'MessageList'>, + +route: NavigationRoute<'MessageList'>, + +focused: boolean, + +toggleFocus: (messageKey: string) => void, + +verticalBounds: ?VerticalBounds, |}; class MultimediaMessage extends React.PureComponent { render() { const { item, navigation, route, focused, toggleFocus, verticalBounds, ...viewProps } = this.props; const containerStyle = { height: item.contentHeight, width: item.contentWidth, }; return ( {this.renderContent()} ); } renderContent(): React.Node { const { messageInfo, imageHeight } = this.props.item; invariant(messageInfo.media.length > 0, 'should have media'); if (messageInfo.media.length === 1) { return this.renderImage(messageInfo.media[0], 0, 0, allCorners); } const mediaPerRow = getMediaPerRow(messageInfo.media.length); const rowHeight = imageHeight + spaceBetweenImages; const rows = []; for ( let i = 0, verticalOffset = 0; i < messageInfo.media.length; i += mediaPerRow, verticalOffset += rowHeight ) { const rowMedia = []; for (let j = i; j < i + mediaPerRow; j++) { rowMedia.push(messageInfo.media[j]); } const firstRow = i === 0; const lastRow = i + mediaPerRow >= messageInfo.media.length; const row = []; let j = 0; for (; j < rowMedia.length; j++) { const media = rowMedia[j]; const firstInRow = j === 0; const lastInRow = j + 1 === rowMedia.length; const inLastColumn = j + 1 === mediaPerRow; const corners = { topLeft: firstRow && firstInRow, topRight: firstRow && inLastColumn, bottomLeft: lastRow && firstInRow, bottomRight: lastRow && inLastColumn, }; const style = lastInRow ? null : styles.imageBeforeImage; row.push( this.renderImage(media, i + j, verticalOffset, corners, style), ); } for (; j < mediaPerRow; j++) { const key = `filler${j}`; const style = j + 1 < mediaPerRow ? [styles.filler, styles.imageBeforeImage] : styles.filler; row.push(); } const rowStyle = lastRow ? styles.row : [styles.row, styles.rowAboveRow]; rows.push( {row} , ); } return {rows}; } renderImage( media: Media, index: number, verticalOffset: number, corners: Corners, style?: ViewStyle, ): React.Node { const filteredCorners = filterCorners(corners, this.props.item); const roundedStyle = getRoundedContainerStyle( filteredCorners, borderRadius, ); const { pendingUploads } = this.props.item; const mediaInfo = { ...media, corners: filteredCorners, index, }; const pendingUpload = pendingUploads && pendingUploads[media.id]; return ( ); } } const spaceBetweenImages = 4; const styles = StyleSheet.create({ filler: { flex: 1, }, grid: { flex: 1, justifyContent: 'space-between', }, imageBeforeImage: { marginRight: spaceBetweenImages, }, row: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', }, rowAboveRow: { marginBottom: spaceBetweenImages, }, }); export { borderRadius as multimediaMessageBorderRadius, MultimediaMessage, multimediaMessageContentSizes, multimediaMessageItemHeight, sendFailed as multimediaMessageSendFailed, }; diff --git a/native/chat/robotext-message.react.js b/native/chat/robotext-message.react.js index 74ccac06d..45c1dbb54 100644 --- a/native/chat/robotext-message.react.js +++ b/native/chat/robotext-message.react.js @@ -1,202 +1,229 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { useSelector } from 'react-redux'; import { messageKey } from 'lib/shared/message-utils'; import { relationshipBlockedInEitherDirection } from 'lib/shared/relationship-utils'; import { threadHasPermission } from 'lib/shared/thread-utils'; import type { RobotextMessageInfo } from 'lib/types/message-types'; import { type ThreadInfo, threadPermissions } from 'lib/types/thread-types'; 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 { useStyles } from '../themes/colors'; import type { VerticalBounds } from '../types/layout-types'; import type { ChatNavigationProp } from './chat.react'; +import { InlineSidebar, inlineSidebarHeight } from './inline-sidebar.react'; import { InnerRobotextMessage } from './inner-robotext-message.react'; import { robotextMessageTooltipHeight } from './robotext-message-tooltip-modal.react'; import { Timestamp } from './timestamp.react'; export type ChatRobotextMessageInfoItemWithHeight = {| - itemType: 'message', - messageShapeType: 'robotext', - messageInfo: RobotextMessageInfo, - threadInfo: ThreadInfo, - startsConversation: boolean, - startsCluster: boolean, - endsCluster: boolean, - robotext: string, - contentHeight: number, + +itemType: 'message', + +messageShapeType: 'robotext', + +messageInfo: RobotextMessageInfo, + +threadInfo: ThreadInfo, + +startsConversation: boolean, + +startsCluster: boolean, + +endsCluster: boolean, + +robotext: string, + +threadCreatedFromMessage: ?ThreadInfo, + +contentHeight: number, |}; function robotextMessageItemHeight( item: ChatRobotextMessageInfoItemWithHeight, ) { + if (item.threadCreatedFromMessage) { + return item.contentHeight + inlineSidebarHeight; + } return item.contentHeight; } type Props = {| ...React.ElementConfig, +item: ChatRobotextMessageInfoItemWithHeight, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, |}; function RobotextMessage(props: Props) { 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) { + inlineSidebar = ( + + + + ); + } + const robotext = item.robotext; 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 messageCreatorUserInfo = useSelector( (state) => state.userStore.userInfos[props.item.messageInfo.creator.id], ); const visibleEntryIDs = React.useMemo(() => { const canCreateSidebars = threadHasPermission( item.threadInfo, threadPermissions.CREATE_SIDEBARS, ); const creatorRelationship = messageCreatorUserInfo.relationshipStatus; const creatorRelationshipHasBlock = creatorRelationship && relationshipBlockedInEitherDirection(creatorRelationship); if (canCreateSidebars && !creatorRelationshipHasBlock) { return ['sidebar']; } return []; }, [item.threadInfo, messageCreatorUserInfo.relationshipStatus]); 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 = robotextMessageTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const aboveMargin = isViewer ? 30 : 50; const aboveSpace = robotextMessageTooltipHeight + aboveMargin; let location = 'below', margin = 0; if ( messageBottom + belowSpace > boundsBottom && messageTop - aboveSpace > boundsTop ) { location = 'above'; margin = aboveMargin; } props.navigation.navigate({ name: RobotextMessageTooltipModalRouteName, params: { presentedFrom: props.route.key, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs, location, margin, item, robotext, }, }); }, [ item, props.navigation, props.route.key, robotext, verticalBounds, visibleEntryIDs, ], ); const onLongPress = React.useCallback(() => { if (visibleEntryIDs.length === 0) { return; } if (keyboardState && keyboardState.dismissKeyboardIfShowing()) { 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(() => {}, []); return ( {timestamp} + {inlineSidebar} ); } +const unboundStyles = { + sidebar: { + marginTop: -5, + marginBottom: 5, + }, +}; + export { robotextMessageItemHeight, RobotextMessage }; diff --git a/native/chat/text-message.react.js b/native/chat/text-message.react.js index 2b55aa1e5..b5e9e035c 100644 --- a/native/chat/text-message.react.js +++ b/native/chat/text-message.react.js @@ -1,252 +1,262 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { useSelector } from 'react-redux'; import { messageKey } from 'lib/shared/message-utils'; import { relationshipBlockedInEitherDirection } from 'lib/shared/relationship-utils'; import { threadHasPermission } from 'lib/shared/thread-utils'; import type { LocalMessageInfo } from 'lib/types/message-types'; import type { TextMessageInfo } from 'lib/types/message/text'; import { type ThreadInfo, threadPermissions } from 'lib/types/thread-types'; import type { UserInfo } from 'lib/types/user-types'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state'; import { MarkdownLinkContext } from '../markdown/markdown-link-context'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { TextMessageTooltipModalRouteName } from '../navigation/route-names'; import type { VerticalBounds } from '../types/layout-types'; import type { ChatNavigationProp } from './chat.react'; import { ComposedMessage, clusterEndHeight } from './composed-message.react'; import { failedSendHeight } from './failed-send.react'; +import { + inlineSidebarHeight, + inlineSidebarMarginBottom, + inlineSidebarMarginTop, +} from './inline-sidebar.react'; import { InnerTextMessage } from './inner-text-message.react'; import { authorNameHeight } from './message-header.react'; import textMessageSendFailed from './text-message-send-failed'; import { textMessageTooltipHeight } from './text-message-tooltip-modal.react'; export type ChatTextMessageInfoItemWithHeight = {| - itemType: 'message', - messageShapeType: 'text', - messageInfo: TextMessageInfo, - localMessageInfo: ?LocalMessageInfo, - threadInfo: ThreadInfo, - startsConversation: boolean, - startsCluster: boolean, - endsCluster: boolean, - contentHeight: number, + +itemType: 'message', + +messageShapeType: 'text', + +messageInfo: TextMessageInfo, + +localMessageInfo: ?LocalMessageInfo, + +threadInfo: ThreadInfo, + +startsConversation: boolean, + +startsCluster: boolean, + +endsCluster: boolean, + +contentHeight: number, + +threadCreatedFromMessage: ?ThreadInfo, |}; function textMessageItemHeight(item: ChatTextMessageInfoItemWithHeight) { const { messageInfo, contentHeight, startsCluster, endsCluster } = item; const { isViewer } = messageInfo.creator; let height = 5 + contentHeight; // 5 from marginBottom in ComposedMessage if (!isViewer && startsCluster) { height += authorNameHeight; } if (endsCluster) { height += clusterEndHeight; } if (textMessageSendFailed(item)) { height += failedSendHeight; } + if (item.threadCreatedFromMessage) { + height += + inlineSidebarHeight + inlineSidebarMarginTop + inlineSidebarMarginBottom; + } return height; } 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 +messageCreatorUserInfo: UserInfo, // withKeyboardState +keyboardState: ?KeyboardState, // withOverlayContext +overlayContext: ?OverlayContextType, // MarkdownLinkContext +linkPressActive: boolean, |}; class TextMessage extends React.PureComponent { message: ?React.ElementRef; render() { const { item, navigation, route, focused, toggleFocus, verticalBounds, keyboardState, overlayContext, linkPressActive, messageCreatorUserInfo, ...viewProps } = this.props; const canSwipe = threadHasPermission( item.threadInfo, threadPermissions.VOICED, ); return ( ); } messageRef = (message: ?React.ElementRef) => { this.message = message; }; visibleEntryIDs() { const result = ['copy']; const canReply = threadHasPermission( this.props.item.threadInfo, threadPermissions.VOICED, ); const canCreateSidebars = threadHasPermission( this.props.item.threadInfo, threadPermissions.CREATE_SIDEBARS, ); const creatorRelationship = this.props.messageCreatorUserInfo .relationshipStatus; const creatorRelationshipHasBlock = creatorRelationship && relationshipBlockedInEitherDirection(creatorRelationship); if (canReply) { result.push('reply'); } if (canCreateSidebars && !creatorRelationshipHasBlock) { result.push('sidebar'); } return result; } onPress = () => { if (this.dismissKeyboardIfShowing()) { return; } const { message, props: { verticalBounds, linkPressActive }, } = this; if (!message || !verticalBounds || linkPressActive) { 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 = textMessageTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const aboveMargin = isViewer ? 30 : 50; const aboveSpace = textMessageTooltipHeight + aboveMargin; let location = 'below', margin = belowMargin; if ( messageBottom + belowSpace > boundsBottom && messageTop - aboveSpace > boundsTop ) { location = 'above'; margin = aboveMargin; } this.props.navigation.navigate({ name: TextMessageTooltipModalRouteName, params: { presentedFrom: this.props.route.key, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs: this.visibleEntryIDs(), location, margin, item, }, }); }); }; dismissKeyboardIfShowing = () => { const { keyboardState } = this.props; return !!(keyboardState && keyboardState.dismissKeyboardIfShowing()); }; } const ConnectedTextMessage = React.memo( function ConnectedTextMessage(props: BaseProps) { const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); const [linkPressActive, setLinkPressActive] = React.useState(false); const markdownLinkContext = React.useMemo( () => ({ setLinkPressActive, }), [setLinkPressActive], ); const messageCreatorUserInfo = useSelector( (state) => state.userStore.userInfos[props.item.messageInfo.creator.id], ); return ( ); }, ); export { ConnectedTextMessage as TextMessage, textMessageItemHeight }; diff --git a/web/selectors/chat-selectors.js b/web/selectors/chat-selectors.js index 5ebdf41bd..af86065d5 100644 --- a/web/selectors/chat-selectors.js +++ b/web/selectors/chat-selectors.js @@ -1,151 +1,155 @@ // @flow import invariant from 'invariant'; import { createSelector } from 'reselect'; import { messageInfoSelector, type ChatThreadItem, createChatThreadItem, chatListData, type ChatMessageItem, createChatMessageItems, } from 'lib/selectors/chat-selectors'; import { threadInfoSelector, sidebarInfoSelector, + threadInfoFromSourceMessageIDSelector, } from 'lib/selectors/thread-selectors'; import type { MessageStore, MessageInfo } from 'lib/types/message-types'; import { type ThreadInfo, type SidebarInfo, threadTypes, } from 'lib/types/thread-types'; import type { AppState } from '../redux/redux-setup'; const activeChatThreadItem: ( state: AppState, ) => ?ChatThreadItem = createSelector( threadInfoSelector, (state: AppState) => state.messageStore, messageInfoSelector, (state: AppState) => state.navInfo.activeChatThreadID, ( threadInfos: { [id: string]: ThreadInfo }, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, activeChatThreadID: ?string, ): ?ChatThreadItem => { if (!activeChatThreadID) { return null; } const threadInfo = threadInfos[activeChatThreadID]; if (!threadInfo) { return null; } return createChatThreadItem(threadInfo, messageStore, messageInfos, null); }, ); const webChatListData: (state: AppState) => ChatThreadItem[] = createSelector( chatListData, sidebarInfoSelector, activeChatThreadItem, ( data: ChatThreadItem[], sidebarInfos: { [id: string]: $ReadOnlyArray }, activeItem: ?ChatThreadItem, ): ChatThreadItem[] => { if (!activeItem) { return data; } const result = []; for (const item of data) { if (item.threadInfo.id === activeItem.threadInfo.id) { return data; } if ( activeItem.threadInfo.type !== threadTypes.SIDEBAR || activeItem.threadInfo.parentThreadID !== item.threadInfo.id ) { result.push(item); continue; } const { parentThreadID } = activeItem.threadInfo; invariant( parentThreadID, `thread ID ${activeItem.threadInfo.id} is a sidebar without a parent`, ); for (const sidebarItem of item.sidebars) { if (sidebarItem.type !== 'sidebar') { continue; } else if (sidebarItem.threadInfo.id === activeItem.threadInfo.id) { return data; } } const activeSidebarInfo = sidebarInfos[parentThreadID].find( (sidebar) => sidebar.threadInfo.id === activeItem.threadInfo.id, ); invariant( activeSidebarInfo, `could not find sidebarInfo for thread ID ${activeItem.threadInfo.id}`, ); let newSidebarItemInserted = false; const newSidebarItems = []; for (const sidebarItem of item.sidebars) { if ( !newSidebarItemInserted && (sidebarItem.lastUpdatedTime === undefined || sidebarItem.lastUpdatedTime < activeSidebarInfo.lastUpdatedTime) ) { newSidebarItemInserted = true; newSidebarItems.push({ type: 'sidebar', ...activeSidebarInfo }); } newSidebarItems.push(sidebarItem); } result.push({ ...item, sidebars: newSidebarItems, }); } if (activeItem.threadInfo.type !== threadTypes.SIDEBAR) { result.unshift(activeItem); } return result; }, ); const webMessageListData: ( state: AppState, ) => ?(ChatMessageItem[]) = createSelector( (state: AppState) => state.navInfo.activeChatThreadID, (state: AppState) => state.messageStore, messageInfoSelector, threadInfoSelector, + threadInfoFromSourceMessageIDSelector, ( threadID: ?string, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, threadInfos: { [id: string]: ThreadInfo }, + threadInfoFromSourceMessageID: { [id: string]: ThreadInfo }, ): ?(ChatMessageItem[]) => { if (!threadID) { return null; } return createChatMessageItems( threadID, messageStore, messageInfos, threadInfos, + threadInfoFromSourceMessageID, ); }, ); export { webChatListData, webMessageListData };