diff --git a/lib/shared/chat-message-item-utils.js b/lib/shared/chat-message-item-utils.js index bd1c58e88..37079eba4 100644 --- a/lib/shared/chat-message-item-utils.js +++ b/lib/shared/chat-message-item-utils.js @@ -1,65 +1,75 @@ // @flow import { messageKey } from './message-utils.js'; import type { ReactionInfo } from '../selectors/chat-selectors.js'; import { getMessageLabel } from '../shared/edit-messages-utils.js'; import type { RobotextMessageInfo, ComposableMessageInfo, } from '../types/message-types.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { longAbsoluteDate } from '../utils/date-utils.js'; type ChatMessageItemMessageInfo = ComposableMessageInfo | RobotextMessageInfo; // This complicated type matches both ChatMessageItem and // ChatMessageItemWithHeight, and is a disjoint union of types type BaseChatMessageInfoItem = { +itemType: 'message', +messageInfo: ChatMessageItemMessageInfo, +messageInfos?: ?void, ... }; type BaseChatMessageItem = | BaseChatMessageInfoItem | { +itemType: 'loader', +messageInfo?: ?void, +messageInfos?: ?void, ... }; function chatMessageItemKey(item: BaseChatMessageItem): string { if (item.itemType === 'loader') { return 'loader'; } return messageKey(item.messageInfo); } function chatMessageInfoItemTimestamp(item: BaseChatMessageInfoItem): string { return longAbsoluteDate(item.messageInfo.time); } +function chatMessageItemHasNonViewerMessage( + item: BaseChatMessageItem, + viewerID: ?string, +): boolean { + return ( + item.itemType === 'message' && item.messageInfo.creator.id !== viewerID + ); +} + type BaseChatMessageItemForEngagementCheck = { +threadCreatedFromMessage: ?ThreadInfo, +reactions: ReactionInfo, +hasBeenEdited?: ?boolean, ... }; function chatMessageItemHasEngagement( item: BaseChatMessageItemForEngagementCheck, threadID: string, ): boolean { const label = getMessageLabel(item.hasBeenEdited, threadID); return ( !!label || !!item.threadCreatedFromMessage || Object.keys(item.reactions).length > 0 ); } export { chatMessageItemKey, chatMessageInfoItemTimestamp, + chatMessageItemHasNonViewerMessage, chatMessageItemHasEngagement, }; diff --git a/native/chat/chat-list.react.js b/native/chat/chat-list.react.js index 366781eb6..79a54671e 100644 --- a/native/chat/chat-list.react.js +++ b/native/chat/chat-list.react.js @@ -1,352 +1,353 @@ // @flow import type { TabNavigationState, BottomTabOptions, BottomTabNavigationEventMap, } from '@react-navigation/core'; import invariant from 'invariant'; import _sum from 'lodash/fp/sum.js'; import * as React from 'react'; import { Animated, Easing, StyleSheet, TouchableWithoutFeedback, View, FlatList as ReactNativeFlatList, } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; -import { chatMessageItemKey } from 'lib/shared/chat-message-item-utils.js'; +import { + chatMessageItemKey, + chatMessageItemHasNonViewerMessage, +} from 'lib/shared/chat-message-item-utils.js'; import { localIDPrefix } from 'lib/shared/message-utils.js'; import type { ChatNavigationProp } from './chat.react.js'; import NewMessagesPill from './new-messages-pill.react.js'; import { chatMessageItemHeight } from './utils.js'; import { InputStateContext } from '../input/input-state.js'; import type { InputState } from '../input/input-state.js'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state.js'; import type { ScreenParamList } from '../navigation/route-names.js'; import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; import type { ScrollEvent } from '../types/react-native.js'; import type { ViewStyle } from '../types/styles.js'; type FlatListElementRef = React.ElementRef; type FlatListProps = React.ElementConfig; const animationSpec = { duration: 150, useNativeDriver: true, }; type BaseProps = { ...FlatListProps, +navigation: ChatNavigationProp<'MessageList'>, +data: $ReadOnlyArray, ... }; type Props = { ...BaseProps, // Redux state +viewerID: ?string, // withKeyboardState +keyboardState: ?KeyboardState, +inputState: ?InputState, ... }; type State = { +newMessageCount: number, }; class ChatList extends React.PureComponent { state: State = { newMessageCount: 0, }; flatList: ?FlatListElementRef; scrollPos = 0; newMessagesPillProgress: Animated.Value = new Animated.Value(0); newMessagesPillStyle: ViewStyle; constructor(props: Props) { super(props); const sendButtonTranslateY = this.newMessagesPillProgress.interpolate({ inputRange: [0, 1], outputRange: ([10, 0]: number[]), // Flow... }); this.newMessagesPillStyle = { opacity: this.newMessagesPillProgress, transform: [{ translateY: sendButtonTranslateY }], }; } componentDidMount() { const tabNavigation = this.props.navigation.getParent< ScreenParamList, 'Chat', TabNavigationState, BottomTabOptions, BottomTabNavigationEventMap, TabNavigationProp<'Chat'>, >(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', this.onTabPress); this.props.inputState?.addScrollToMessageListener(this.scrollToMessage); } componentWillUnmount() { const tabNavigation = this.props.navigation.getParent< ScreenParamList, 'Chat', TabNavigationState, BottomTabOptions, BottomTabNavigationEventMap, TabNavigationProp<'Chat'>, >(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.removeListener('tabPress', this.onTabPress); this.props.inputState?.removeScrollToMessageListener(this.scrollToMessage); } onTabPress = () => { const { flatList } = this; if (!this.props.navigation.isFocused() || !flatList) { return; } if (this.scrollPos > 0) { flatList.scrollToOffset({ offset: 0 }); } else { this.props.navigation.popToTop(); } }; get scrolledToBottom(): boolean { return this.scrollPos <= 0; } componentDidUpdate(prevProps: Props) { const { flatList } = this; if (!flatList || this.props.data === prevProps.data) { return; } if (this.props.data.length < prevProps.data.length) { // This should only happen due to MessageStorePruner, // which will only prune a thread when it is off-screen flatList.scrollToOffset({ offset: 0, animated: false }); return; } const { scrollPos } = this; + const { viewerID } = this.props; let curDataIndex = 0, prevDataIndex = 0, heightSoFar = 0; let adjustScrollPos = 0, newLocalMessage = false, newRemoteMessageCount = 0; while (prevDataIndex < prevProps.data.length && heightSoFar <= scrollPos) { const prevItem = prevProps.data[prevDataIndex]; invariant(prevItem, 'prevItem should exist'); const prevItemKey = chatMessageItemKey(prevItem); const prevItemHeight = chatMessageItemHeight(prevItem); let curItem = this.props.data[curDataIndex]; while (curItem) { const curItemKey = chatMessageItemKey(curItem); if (curItemKey === prevItemKey) { break; } if (curItemKey.startsWith(localIDPrefix)) { newLocalMessage = true; - } else if ( - curItem.itemType === 'message' && - curItem.messageInfo.creator.id !== this.props.viewerID - ) { + } else if (chatMessageItemHasNonViewerMessage(curItem, viewerID)) { newRemoteMessageCount++; } adjustScrollPos += chatMessageItemHeight(curItem); curDataIndex++; curItem = this.props.data[curDataIndex]; } if (!curItem) { // The only case in which we would expect the length of data to // decrease, but find that an item was removed, is if the start // of the chat is reached. In that case, the spinner at the top // will no longer be rendered. We break here as we expect the // spinner to be the last item. if (prevItemKey === 'loader') { break; } console.log( `items not removed from ChatList, but ${prevItemKey} now missing`, ); return; } const curItemHeight = chatMessageItemHeight(curItem); adjustScrollPos += curItemHeight - prevItemHeight; heightSoFar += prevItemHeight; prevDataIndex++; curDataIndex++; } if (adjustScrollPos === 0) { return; } flatList.scrollToOffset({ offset: scrollPos + adjustScrollPos, animated: false, }); if (newLocalMessage || scrollPos <= 0) { flatList.scrollToOffset({ offset: 0 }); } else if (newRemoteMessageCount > 0) { this.setState(prevState => ({ newMessageCount: prevState.newMessageCount + newRemoteMessageCount, })); this.toggleNewMessagesPill(true); } } render(): React.Node { const { navigation, viewerID, ...rest } = this.props; const { newMessageCount } = this.state; return ( 0 ? 'auto' : 'none'} containerStyle={styles.newMessagesPillContainer} style={this.newMessagesPillStyle} /> ); } flatListRef = (flatList: any) => { this.flatList = flatList; }; static getItemLayout = ( data: ?$ReadOnlyArray, index: number, ): { length: number, offset: number, index: number } => { if (!data) { return { length: 0, offset: 0, index }; } const offset = ChatList.heightOfItems(data.filter((_, i) => i < index)); const item = data[index]; const length = item ? chatMessageItemHeight(item) : 0; return { length, offset, index }; }; static heightOfItems( data: $ReadOnlyArray, ): number { return _sum(data.map(chatMessageItemHeight)); } toggleNewMessagesPill(show: boolean) { Animated.timing(this.newMessagesPillProgress, { ...animationSpec, easing: show ? Easing.ease : Easing.out(Easing.ease), toValue: show ? 1 : 0, }).start(({ finished }) => { if (finished && !show) { this.setState({ newMessageCount: 0 }); } }); } onScroll = (event: ScrollEvent) => { this.scrollPos = event.nativeEvent.contentOffset.y; if (this.scrollPos <= 0) { this.toggleNewMessagesPill(false); } this.props.onScroll && this.props.onScroll(event); }; onPressNewMessagesPill = () => { const { flatList } = this; if (!flatList) { return; } flatList.scrollToOffset({ offset: 0 }); this.toggleNewMessagesPill(false); }; scrollToMessage = (key?: string) => { const { flatList } = this; const { data } = this.props; if (!flatList || !key) { return; } const index = data.findIndex(item => chatMessageItemKey(item) === key); if (index < 0) { console.warn("Couldn't find message to scroll to"); return; } flatList.scrollToIndex({ index, animated: true, viewPosition: 0.5, }); }; onPressBackground = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const styles = StyleSheet.create({ container: { flex: 1, }, newMessagesPillContainer: { bottom: 30, position: 'absolute', right: 30, }, }); const ConnectedChatList: React.ComponentType = React.memo( function ConnectedChatList(props: BaseProps) { const keyboardState = React.useContext(KeyboardContext); const inputState = React.useContext(InputStateContext); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); return ( ); }, ); export default ConnectedChatList;