diff --git a/native/chat/message-result.react.js b/native/chat/message-result.react.js index bbc7f4e3e..04dfa9944 100644 --- a/native/chat/message-result.react.js +++ b/native/chat/message-result.react.js @@ -1,73 +1,75 @@ // @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-results-screen.react.js b/native/chat/message-results-screen.react.js index c768070f2..b519cc957 100644 --- a/native/chat/message-results-screen.react.js +++ b/native/chat/message-results-screen.react.js @@ -1,179 +1,200 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { fetchPinnedMessages } from 'lib/actions/message-actions.js'; import { messageListData } from 'lib/selectors/chat-selectors.js'; import { createMessageInfo } from 'lib/shared/message-utils.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useServerCall } from 'lib/utils/action-utils.js'; import { useHeightMeasurer } from './chat-context.js'; import type { ChatNavigationProp } from './chat.react'; import MessageResult from './message-result.react.js'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; export type MessageResultsScreenParams = { +threadInfo: ThreadInfo, }; type MessageResultsScreenProps = { +navigation: ChatNavigationProp<'MessageResultsScreen'>, +route: NavigationRoute<'MessageResultsScreen'>, }; function MessageResultsScreen(props: MessageResultsScreenProps): React.Node { const { navigation, route } = props; const { threadInfo } = route.params; const { id: threadID } = threadInfo; const [rawMessageResults, setRawMessageResults] = React.useState([]); const measureMessages = useHeightMeasurer(); const [measuredMessages, setMeasuredMessages] = React.useState([]); + const [messageVerticalBounds, setMessageVerticalBounds] = React.useState(); + const scrollViewContainerRef = React.useRef(); + const callFetchPinnedMessages = useServerCall(fetchPinnedMessages); const userInfos = useSelector(state => state.userStore.userInfos); React.useEffect(() => { (async () => { const result = await callFetchPinnedMessages({ threadID }); setRawMessageResults(result.pinnedMessages); })(); }, [callFetchPinnedMessages, threadID]); const translatedMessageResults = React.useMemo(() => { const threadInfos = { [threadID]: threadInfo }; return rawMessageResults .map(messageInfo => createMessageInfo(messageInfo, null, userInfos, threadInfos), ) .filter(Boolean); }, [rawMessageResults, userInfos, threadID, threadInfo]); const chatMessageInfos = useSelector( messageListData(threadInfo.id, translatedMessageResults), ); const sortedUniqueChatMessageInfoItems = React.useMemo(() => { if (!chatMessageInfos) { return []; } const chatMessageInfoItems = chatMessageInfos.filter( item => item.itemType === 'message' && item.isPinned, ); // By the nature of using messageListData and passing in // the desired translatedMessageResults as additional // messages, we will have duplicate ChatMessageInfoItems. const uniqueChatMessageInfoItemsMap = new Map(); chatMessageInfoItems.forEach( item => item.messageInfo && item.messageInfo.id && uniqueChatMessageInfoItemsMap.set(item.messageInfo.id, item), ); // Push the items in the order they appear in the rawMessageResults // since the messages fetched from the server are already sorted // in the order of pin_time (newest first). const sortedChatMessageInfoItems = []; for (let i = 0; i < rawMessageResults.length; i++) { sortedChatMessageInfoItems.push( uniqueChatMessageInfoItemsMap.get(rawMessageResults[i].id), ); } return sortedChatMessageInfoItems.filter(Boolean); }, [chatMessageInfos, rawMessageResults]); const measureCallback = React.useCallback( (listDataWithHeights: $ReadOnlyArray) => { setMeasuredMessages(listDataWithHeights); }, [], ); React.useEffect(() => { measureMessages( sortedUniqueChatMessageInfoItems, threadInfo, measureCallback, ); }, [ measureCallback, measureMessages, sortedUniqueChatMessageInfoItems, threadInfo, ]); const modifiedItems = React.useMemo( () => measuredMessages.map(item => { invariant(item.itemType !== 'loader', 'should not be loader'); invariant( item.messageShapeType !== 'robotext', 'should not be robotext', ); if (item.messageShapeType === 'multimedia') { return { ...item, startsConversation: false, startsCluster: true, endsCluster: true, messageInfo: { ...item.messageInfo, creator: { ...item.messageInfo.creator, isViewer: false, }, }, }; } return { ...item, startsConversation: false, startsCluster: true, endsCluster: true, messageInfo: { ...item.messageInfo, creator: { ...item.messageInfo.creator, isViewer: false, }, }, }; }), [measuredMessages], ); + const onLayout = React.useCallback(() => { + scrollViewContainerRef.current?.measure( + (x, y, width, height, pageX, pageY) => { + if ( + height === null || + height === undefined || + pageY === null || + pageY === undefined + ) { + return; + } + + setMessageVerticalBounds({ height, y: pageY }); + }, + ); + }, []); + const messageResultsToDisplay = React.useMemo( () => modifiedItems.map(item => ( )), - [modifiedItems, threadInfo, navigation, route], + [modifiedItems, threadInfo, navigation, route, messageVerticalBounds], ); return ( - + {messageResultsToDisplay} ); } export default MessageResultsScreen; diff --git a/native/chat/toggle-pin-modal.react.js b/native/chat/toggle-pin-modal.react.js index 0ae75f652..de85a06c9 100644 --- a/native/chat/toggle-pin-modal.react.js +++ b/native/chat/toggle-pin-modal.react.js @@ -1,203 +1,204 @@ // @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;