diff --git a/native/chat/chat-thread-list.react.js b/native/chat/chat-thread-list.react.js index 145e0bd0b..5df5d43f2 100644 --- a/native/chat/chat-thread-list.react.js +++ b/native/chat/chat-thread-list.react.js @@ -1,400 +1,408 @@ // @flow import invariant from 'invariant'; import _sum from 'lodash/fp/sum'; import * as React from 'react'; import { View, FlatList, Platform, TextInput } from 'react-native'; import { FloatingAction } from 'react-native-floating-action'; import IonIcon from 'react-native-vector-icons/Ionicons'; import { createSelector } from 'reselect'; import { searchUsers } from 'lib/actions/user-actions'; import { type ChatThreadItem, chatListData, } from 'lib/selectors/chat-selectors'; import { threadSearchIndex as threadSearchIndexSelector } from 'lib/selectors/nav-selectors'; import { usersWithPersonalThreadSelector } from 'lib/selectors/user-selectors'; import SearchIndex from 'lib/shared/search-index'; import { createPendingThreadItem } from 'lib/shared/thread-utils'; import type { UserSearchResult } from 'lib/types/search-types'; import type { ThreadInfo } from 'lib/types/thread-types'; +import { threadTypes } from 'lib/types/thread-types'; import type { GlobalAccountUserInfo, UserInfo } from 'lib/types/user-types'; import { useServerCall } from 'lib/utils/action-utils'; import Search from '../components/search.react'; import type { TabNavigationProp } from '../navigation/app-navigator.react'; import { ComposeThreadRouteName, MessageListRouteName, SidebarListModalRouteName, HomeChatThreadListRouteName, BackgroundChatThreadListRouteName, type NavigationRoute, } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type IndicatorStyle, indicatorStyleSelector, useStyles, } from '../themes/colors'; import ChatThreadListItem from './chat-thread-list-item.react'; import type { ChatTopTabsNavigationProp, ChatNavigationProp, } from './chat.react'; const floatingActions = [ { text: 'Compose', icon: , name: 'compose', position: 1, }, ]; type Item = | ChatThreadItem | {| +type: 'search', +searchText: string |} | {| +type: 'empty', +emptyItem: React.ComponentType<{||}> |}; type BaseProps = {| +navigation: | ChatTopTabsNavigationProp<'HomeChatThreadList'> | ChatTopTabsNavigationProp<'BackgroundChatThreadList'>, +route: | NavigationRoute<'HomeChatThreadList'> | NavigationRoute<'BackgroundChatThreadList'>, +filterThreads: (threadItem: ThreadInfo) => boolean, +emptyItem?: React.ComponentType<{||}>, |}; type Props = {| ...BaseProps, // Redux state +chatListData: $ReadOnlyArray, +viewerID: ?string, +threadSearchIndex: SearchIndex, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, +usersWithPersonalThread: $ReadOnlySet, // async functions that hit server APIs +searchUsers: (usernamePrefix: string) => Promise, |}; type State = {| +searchText: string, +threadsSearchResults: Set, +usersSearchResults: $ReadOnlyArray, +openedSwipeableId: string, |}; type PropsAndState = {| ...Props, ...State |}; class ChatThreadList extends React.PureComponent { state: State = { searchText: '', threadsSearchResults: new Set(), usersSearchResults: [], openedSwipeableId: '', }; searchInput: ?React.ElementRef; flatList: ?FlatList; scrollPos = 0; componentDidMount() { const chatNavigation: ?ChatNavigationProp< 'ChatThreadList', > = this.props.navigation.dangerouslyGetParent(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); const tabNavigation: ?TabNavigationProp< 'Chat', > = chatNavigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { const chatNavigation: ?ChatNavigationProp< 'ChatThreadList', > = this.props.navigation.dangerouslyGetParent(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); const tabNavigation: ?TabNavigationProp< 'Chat', > = chatNavigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.removeListener('tabPress', this.onTabPress); } onTabPress = () => { if (!this.props.navigation.isFocused()) { return; } if (this.scrollPos > 0 && this.flatList) { this.flatList.scrollToOffset({ offset: 0, animated: true }); } else if (this.props.route.name === BackgroundChatThreadListRouteName) { this.props.navigation.navigate({ name: HomeChatThreadListRouteName }); } }; renderItem = (row: { item: Item }) => { const item = row.item; if (item.type === 'search') { return ( ); } if (item.type === 'empty') { const EmptyItem = item.emptyItem; return ; } return ( ); }; searchInputRef = (searchInput: ?React.ElementRef) => { this.searchInput = searchInput; }; static keyExtractor(item: Item) { if (item.type === 'chatThreadItem') { return item.threadInfo.id; } else if (item.type === 'empty') { return 'empty'; } else { return 'search'; } } static getItemLayout(data: ?$ReadOnlyArray, index: number) { if (!data) { return { length: 0, offset: 0, index }; } const offset = ChatThreadList.heightOfItems( data.filter((_, i) => i < index), ); const item = data[index]; const length = item ? ChatThreadList.itemHeight(item) : 0; return { length, offset, index }; } static itemHeight(item: Item): number { if (item.type === 'search') { return Platform.OS === 'ios' ? 54.5 : 55; } // itemHeight for emptyItem might be wrong because of line wrapping // but we don't care because we'll only ever be rendering this item by itself // and it should always be on-screen if (item.type === 'empty') { return 123; } return 60 + item.sidebars.length * 30; } static heightOfItems(data: $ReadOnlyArray): number { return _sum(data.map(ChatThreadList.itemHeight)); } listDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.chatListData, (propsAndState: PropsAndState) => propsAndState.searchText, (propsAndState: PropsAndState) => propsAndState.threadsSearchResults, (propsAndState: PropsAndState) => propsAndState.emptyItem, (propsAndState: PropsAndState) => propsAndState.usersSearchResults, ( reduxChatListData: $ReadOnlyArray, searchText: string, threadsSearchResults: Set, emptyItem?: React.ComponentType<{||}>, usersSearchResults: $ReadOnlyArray, ): Item[] => { + const personalThreads = []; const chatItems = []; - if (!searchText) { - chatItems.push( - ...reduxChatListData.filter((item) => - this.props.filterThreads(item.threadInfo), - ), - ); + + const pushItem = (item) => { + if (item.threadInfo.type === threadTypes.PERSONAL) { + personalThreads.push(item); + } else { + chatItems.push(item); + } + }; + + if (searchText) { + reduxChatListData + .filter((item) => threadsSearchResults.has(item.threadInfo.id)) + .forEach(pushItem); } else { - chatItems.push( - ...reduxChatListData.filter((item) => - threadsSearchResults.has(item.threadInfo.id), - ), - ); + reduxChatListData + .filter((item) => this.props.filterThreads(item.threadInfo)) + .forEach(pushItem); } + const { viewerID } = this.props; if (viewerID) { chatItems.push( ...usersSearchResults.map((user) => createPendingThreadItem(viewerID, user), ), ); } - if (emptyItem && chatItems.length === 0) { + if (emptyItem && chatItems.length === 0 && personalThreads.length === 0) { chatItems.push({ type: 'empty', emptyItem }); } - return [{ type: 'search', searchText }, ...chatItems]; + return [{ type: 'search', searchText }, ...personalThreads, ...chatItems]; }, ); get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } render() { let floatingAction = null; if (Platform.OS === 'android') { floatingAction = ( ); } // this.props.viewerID is in extraData since it's used by MessagePreview // within ChatThreadListItem return ( {floatingAction} ); } flatListRef = (flatList: ?FlatList) => { this.flatList = flatList; }; onScroll = (event: { +nativeEvent: { +contentOffset: { +y: number } } }) => { this.scrollPos = event.nativeEvent.contentOffset.y; }; async searchUsers(usernamePrefix: string) { if (usernamePrefix.length === 0) { return []; } const { userInfos } = await this.props.searchUsers(usernamePrefix); return userInfos.filter( (info) => !this.props.usersWithPersonalThread.has(info.id) && info.id !== this.props.viewerID, ); } onChangeSearchText = async (searchText: string) => { const results = this.props.threadSearchIndex.getSearchResults(searchText); this.setState({ searchText, threadsSearchResults: new Set(results) }); const usersSearchResults = await this.searchUsers(searchText); this.setState({ usersSearchResults }); }; onPressItem = ( threadInfo: ThreadInfo, pendingPersonalThreadUserInfo?: UserInfo, ) => { this.onChangeSearchText(''); if (this.searchInput) { this.searchInput.blur(); } this.props.navigation.navigate({ name: MessageListRouteName, params: { threadInfo, pendingPersonalThreadUserInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }; onPressSeeMoreSidebars = (threadInfo: ThreadInfo) => { this.onChangeSearchText(''); if (this.searchInput) { this.searchInput.blur(); } this.props.navigation.navigate({ name: SidebarListModalRouteName, params: { threadInfo }, }); }; onSwipeableWillOpen = (threadInfo: ThreadInfo) => { this.setState((state) => ({ ...state, openedSwipeableId: threadInfo.id })); }; composeThread = () => { this.props.navigation.navigate({ name: ComposeThreadRouteName, params: {}, }); }; } const unboundStyles = { icon: { fontSize: 28, }, container: { flex: 1, }, search: { marginBottom: 8, marginHorizontal: 12, marginTop: Platform.OS === 'android' ? 10 : 8, }, flatList: { flex: 1, backgroundColor: 'listBackground', }, }; export default React.memo(function ConnectedChatThreadList( props: BaseProps, ) { const boundChatListData = useSelector(chatListData); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const threadSearchIndex = useSelector(threadSearchIndexSelector); const styles = useStyles(unboundStyles); const indicatorStyle = useSelector(indicatorStyleSelector); const callSearchUsers = useServerCall(searchUsers); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); return ( ); });