diff --git a/native/chat/chat-list.react.js b/native/chat/chat-list.react.js index bc501cdfe..ef3e6c3f6 100644 --- a/native/chat/chat-list.react.js +++ b/native/chat/chat-list.react.js @@ -1,315 +1,305 @@ // @flow import invariant from 'invariant'; import _sum from 'lodash/fp/sum'; import * as React from 'react'; import { FlatList, Animated, Easing, StyleSheet, TouchableWithoutFeedback, View, } from 'react-native'; -import type { - Props as FlatListProps, - DefaultProps as FlatListDefaultProps, -} from 'react-native/Libraries/Lists/FlatList'; import type { ChatMessageItem } from 'lib/selectors/chat-selectors'; import { localIDPrefix, messageKey } from 'lib/shared/message-utils'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state'; import type { TabNavigationProp } from '../navigation/app-navigator.react'; import { useSelector } from '../redux/redux-utils'; import type { ChatMessageItemWithHeight } from '../types/chat-types'; import type { ScrollEvent } from '../types/react-native'; import type { ViewStyle } from '../types/styles'; import type { ChatNavigationProp } from './chat.react'; import NewMessagesPill from './new-messages-pill.react'; import { chatMessageItemHeight } from './utils'; function chatMessageItemKey( item: ChatMessageItemWithHeight | ChatMessageItem, ): string { if (item.itemType === 'loader') { return 'loader'; } return messageKey(item.messageInfo); } const animationSpec = { duration: 150, useNativeDriver: true, }; type BaseProps = { - ...$ReadOnly< - $Exact< - React.Config< - FlatListProps, - FlatListDefaultProps, - >, - >, - >, + ...React.ElementConfig, +navigation: ChatNavigationProp<'MessageList'>, +data: $ReadOnlyArray, + ... }; type Props = { ...BaseProps, // Redux state +viewerID: ?string, // withKeyboardState +keyboardState: ?KeyboardState, + ... }; type State = { +newMessageCount: number, }; class ChatList extends React.PureComponent { state: State = { newMessageCount: 0, }; flatList: ?React.ElementRef; scrollPos = 0; newMessagesPillProgress = 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: ?TabNavigationProp< 'Chat', > = this.props.navigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { const tabNavigation: ?TabNavigationProp< 'Chat', > = this.props.navigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.removeListener('tabPress', this.onTabPress); } 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() { 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; 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 ) { newRemoteMessageCount++; } adjustScrollPos += chatMessageItemHeight(curItem); curDataIndex++; curItem = this.props.data[curDataIndex]; } if (!curItem) { // Should never happen... 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() { const { navigation, viewerID, ...rest } = this.props; const { newMessageCount } = this.state; return ( 0 ? 'auto' : 'none'} containerStyle={styles.newMessagesPillContainer} style={this.newMessagesPillStyle} /> ); } flatListRef = (flatList: ?React.ElementRef) => { this.flatList = flatList; }; static getItemLayout = ( data: ?$ReadOnlyArray, 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); } - // $FlowFixMe FlatList doesn't type ScrollView props this.props.onScroll && this.props.onScroll(event); }; onPressNewMessagesPill = () => { const { flatList } = this; if (!flatList) { return; } flatList.scrollToOffset({ offset: 0 }); this.toggleNewMessagesPill(false); }; 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 viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); return ( ); }, ); export { ConnectedChatList as ChatList, chatMessageItemKey };