diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js index 679c901e8..cffbe55f2 100644 --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -1,368 +1,407 @@ // @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 PropTypes from 'prop-types'; +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 } from '../shared/thread-utils'; +import { threadIsTopLevel, threadInChatList } from '../shared/thread-utils'; import { type MessageInfo, type MessageStore, type ComposableMessageInfo, type RobotextMessageInfo, type LocalMessageInfo, messageInfoPropType, localMessageInfoPropType, messageTypes, isComposableMessageType, } from '../types/message-types'; import type { BaseAppState } from '../types/redux-types'; import { type ThreadInfo, threadInfoPropType, + type RawThreadInfo, type SidebarInfo, maxReadSidebars, maxUnreadSidebars, } from '../types/thread-types'; import { userInfoPropType } from '../types/user-types'; import type { UserInfo } from '../types/user-types'; import { threeDays } from '../utils/date-utils'; import { threadInfoSelector, sidebarInfoSelector } 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 chatThreadItemPropType = PropTypes.exact({ type: PropTypes.oneOf(['chatThreadItem']).isRequired, threadInfo: threadInfoPropType.isRequired, mostRecentMessageInfo: messageInfoPropType, mostRecentNonLocalMessage: PropTypes.string, lastUpdatedTime: PropTypes.number.isRequired, lastUpdatedTimeIncludingSidebars: PropTypes.number.isRequired, sidebars: PropTypes.arrayOf( PropTypes.oneOfType([ PropTypes.exact({ type: PropTypes.oneOf(['sidebar']).isRequired, threadInfo: threadInfoPropType.isRequired, lastUpdatedTime: PropTypes.number.isRequired, mostRecentNonLocalMessage: PropTypes.string, }), PropTypes.exact({ type: PropTypes.oneOf(['seeMore']).isRequired, unread: PropTypes.bool.isRequired, showingSidebarsInline: PropTypes.bool.isRequired, }), ]), ).isRequired, pendingPersonalThreadUserInfo: userInfoPropType, }); 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[] => - _flow( - _filter(threadIsTopLevel), - _map((threadInfo: ThreadInfo): ChatThreadItem => - createChatThreadItem( - threadInfo, - messageStore, - messageInfos, - sidebarInfos[threadInfo.id], - ), - ), - _orderBy('lastUpdatedTimeIncludingSidebars')('desc'), - )(threadInfos), + 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, endsCluster: boolean, robotext: string, |}; export type ChatMessageInfoItem = | RobotextChatMessageInfoItem | {| itemType: 'message', messageInfo: ComposableMessageInfo, localMessageInfo: ?LocalMessageInfo, startsConversation: boolean, startsCluster: boolean, endsCluster: boolean, |}; export type ChatMessageItem = {| itemType: 'loader' |} | ChatMessageInfoItem; const chatMessageItemPropType = PropTypes.oneOfType([ PropTypes.shape({ itemType: PropTypes.oneOf(['loader']).isRequired, }), PropTypes.shape({ itemType: PropTypes.oneOf(['message']).isRequired, messageInfo: messageInfoPropType.isRequired, localMessageInfo: localMessageInfoPropType, startsConversation: PropTypes.bool.isRequired, startsCluster: PropTypes.bool.isRequired, endsCluster: PropTypes.bool.isRequired, robotext: PropTypes.string, }), ]); const msInFiveMinutes = 5 * 60 * 1000; function createChatMessageItems( threadID: string, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, threadInfos: { [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]; let startsConversation = true; let startsCluster = true; if ( lastMessageInfo && lastMessageInfo.time + msInFiveMinutes > messageInfo.time ) { startsConversation = false; if ( isComposableMessageType(lastMessageInfo.type) && isComposableMessageType(messageInfo.type) && lastMessageInfo.creator.id === messageInfo.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; } if (isComposableMessageType(messageInfo.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( messageInfo.type === messageTypes.TEXT || messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const localMessageInfo = messageStore.local[messageKey(messageInfo)]; chatMessageItems.push({ itemType: 'message', messageInfo, localMessageInfo, startsConversation, startsCluster, endsCluster: false, }); } 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[threadID], ); chatMessageItems.push({ itemType: 'message', messageInfo, startsConversation, startsCluster, endsCluster: false, robotext, }); } lastMessageInfo = messageInfo; } 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, ( messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, threadInfos: { [id: string]: ThreadInfo }, ): ChatMessageItem[] => createChatMessageItems(threadID, messageStore, messageInfos, threadInfos), ); const messageListData: ( threadID: string, ) => (state: BaseAppState<*>) => ChatMessageItem[] = _memoize( baseMessageListData, ); export { messageInfoSelector, createChatThreadItem, chatThreadItemPropType, chatListData, chatMessageItemPropType, createChatMessageItems, messageListData, + useFlattenedChatListData, }; diff --git a/native/chat/chat-thread-list.react.js b/native/chat/chat-thread-list.react.js index bbebdfe84..e556cbfad 100644 --- a/native/chat/chat-thread-list.react.js +++ b/native/chat/chat-thread-list.react.js @@ -1,423 +1,426 @@ // @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, + useFlattenedChatListData, } 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 { createPendingThread, createPendingThreadItem, + threadIsTopLevel, } 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 { 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 chatItems = []; if (!searchText) { chatItems.push( - ...reduxChatListData.filter((item) => - this.props.filterThreads(item.threadInfo), + ...reduxChatListData.filter( + (item) => + threadIsTopLevel(item.threadInfo) && + this.props.filterThreads(item.threadInfo), ), ); } else { const personalThreads = []; const nonPersonalThreads = []; for (const item of reduxChatListData) { if (!threadsSearchResults.has(item.threadInfo.id)) { continue; } if (item.threadInfo.type === threadTypes.PERSONAL) { personalThreads.push({ ...item, sidebars: [] }); } else { nonPersonalThreads.push({ ...item, sidebars: [] }); } } chatItems.push(...personalThreads, ...nonPersonalThreads); const { viewerID } = this.props; if (viewerID) { chatItems.push( ...usersSearchResults.map((user) => createPendingThreadItem(viewerID, user), ), ); } } if (emptyItem && chatItems.length === 0) { chatItems.push({ type: 'empty', emptyItem }); } return [{ type: 'search', searchText }, ...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 = () => { if (this.props.viewerID) { this.props.navigation.navigate({ name: MessageListRouteName, params: { threadInfo: createPendingThread( this.props.viewerID, threadTypes.CHAT_SECRET, [], ), searching: true, }, }); } }; } 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 boundChatListData = useFlattenedChatListData(); 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 ( ); });