diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js index e348bb74c..a13b96ab4 100644 --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -1,494 +1,454 @@ // @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 * as React from 'react'; import { useSelector } from 'react-redux'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import { messageKey, robotextForMessageInfo, createMessageInfo, getMostRecentNonLocalMessageID, + sortMessageInfoList, } from '../shared/message-utils'; import { threadIsTopLevel, threadInChatList, useSidebarCandidate, threadIsPendingSidebar, } from '../shared/thread-utils'; import { type MessageInfo, type MessageStore, type ComposableMessageInfo, type RobotextMessageInfo, type LocalMessageInfo, messageTypes, isComposableMessageType, } from '../types/message-types'; import type { BaseAppState } from '../types/redux-types'; import { type ThreadInfo, type RawThreadInfo, type SidebarInfo, maxReadSidebars, maxUnreadSidebars, } from '../types/thread-types'; import type { UserInfo, AccountUserInfo } from '../types/user-types'; import { threeDays } from '../utils/date-utils'; +import memoize2 from '../utils/memoize'; import { threadInfoSelector, sidebarInfoSelector, threadInfoFromSourceMessageIDSelector, } 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 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 (const messageID of thread.messageIDs) { const messageInfo = messages[messageID]; if (messageInfo) { return messageInfo; } } 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.id, 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[] => 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, +threadCreatedFromMessage: ?ThreadInfo, |}; export type ChatMessageInfoItem = | RobotextChatMessageInfoItem | {| +itemType: 'message', +messageInfo: ComposableMessageInfo, +localMessageInfo: ?LocalMessageInfo, +startsConversation: boolean, +startsCluster: boolean, endsCluster: boolean, +threadCreatedFromMessage: ?ThreadInfo, |}; export type ChatMessageItem = {| itemType: 'loader' |} | ChatMessageInfoItem; const msInFiveMinutes = 5 * 60 * 1000; function createChatMessageItems( threadID: string, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, threadInfos: { [id: string]: ThreadInfo }, threadInfoFromSourceMessageID: { [id: string]: ThreadInfo }, + additionalMessages: $ReadOnlyArray, ): ChatMessageItem[] { const thread = messageStore.threads[threadID]; - if (!thread) { - return []; - } - const threadMessageInfos = thread.messageIDs + + const threadMessageInfos = (thread?.messageIDs ?? []) .map((messageID: string) => messageInfos[messageID]) .filter(Boolean); + const messages = + additionalMessages.length > 0 + ? sortMessageInfoList([...threadMessageInfos, ...additionalMessages]) + : threadMessageInfos; + if (messages.length === 0) { + return []; + } + const chatMessageItems = []; let lastMessageInfo = null; - for (let i = threadMessageInfos.length - 1; i >= 0; i--) { - const messageInfo = threadMessageInfos[i]; + for (let i = messages.length - 1; i >= 0; i--) { + const messageInfo = messages[i]; const originalMessageInfo = messageInfo.type === messageTypes.SIDEBAR_SOURCE ? messageInfo.sourceMessage : messageInfo; let startsConversation = true; let startsCluster = true; if ( lastMessageInfo && lastMessageInfo.time + msInFiveMinutes > originalMessageInfo.time ) { startsConversation = false; if ( isComposableMessageType(lastMessageInfo.type) && isComposableMessageType(originalMessageInfo.type) && lastMessageInfo.creator.id === originalMessageInfo.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; } const threadCreatedFromMessage = messageInfo.id ? threadInfoFromSourceMessageID[messageInfo.id] : undefined; if (isComposableMessageType(originalMessageInfo.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( originalMessageInfo.type === messageTypes.TEXT || originalMessageInfo.type === messageTypes.IMAGES || originalMessageInfo.type === messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const localMessageInfo = messageStore.local[messageKey(originalMessageInfo)]; chatMessageItems.push({ itemType: 'message', messageInfo: originalMessageInfo, localMessageInfo, startsConversation, startsCluster, endsCluster: false, threadCreatedFromMessage, }); } else { invariant( originalMessageInfo.type !== messageTypes.TEXT && originalMessageInfo.type !== messageTypes.IMAGES && originalMessageInfo.type !== messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const robotext = robotextForMessageInfo( originalMessageInfo, threadInfos[threadID], ); chatMessageItems.push({ itemType: 'message', messageInfo: originalMessageInfo, startsConversation, startsCluster, endsCluster: false, threadCreatedFromMessage, robotext, }); } lastMessageInfo = originalMessageInfo; } 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) { + if (thread?.startReached ?? true) { return chatMessageItems; } return [...chatMessageItems, ({ itemType: 'loader' }: ChatMessageItem)]; } -const baseMessageListData = (threadID: ?string) => +const baseMessageListData = ( + threadID: ?string, + additionalMessages: $ReadOnlyArray, +) => createSelector( (state: BaseAppState<*>) => state.messageStore, messageInfoSelector, threadInfoSelector, threadInfoFromSourceMessageIDSelector, ( messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, threadInfos: { [id: string]: ThreadInfo }, threadInfoFromSourceMessageID: { [id: string]: ThreadInfo }, ): ?(ChatMessageItem[]) => { if (!threadID) { return null; } return createChatMessageItems( threadID, messageStore, messageInfos, threadInfos, threadInfoFromSourceMessageID, + additionalMessages, ); }, ); const messageListData: ( threadID: ?string, -) => (state: BaseAppState<*>) => ?(ChatMessageItem[]) = _memoize( + additionalMessages: $ReadOnlyArray, +) => (state: BaseAppState<*>) => ?(ChatMessageItem[]) = memoize2( baseMessageListData, ); -function getSourceMessageChatItemForPendingSidebar( - messageInfo: ComposableMessageInfo | RobotextMessageInfo, - threadInfos: { [id: string]: ThreadInfo }, -): ChatMessageInfoItem { - if (isComposableMessageType(messageInfo.type)) { - invariant( - messageInfo.type === messageTypes.TEXT || - messageInfo.type === messageTypes.IMAGES || - messageInfo.type === messageTypes.MULTIMEDIA, - "Flow doesn't understand isComposableMessageType above", - ); - const messageItem = { - itemType: 'message', - messageInfo: messageInfo, - startsConversation: true, - startsCluster: true, - endsCluster: true, - localMessageInfo: null, - threadCreatedFromMessage: undefined, - }; - return messageItem; - } 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[messageInfo.threadID], - ); - const messageItem = { - itemType: 'message', - messageInfo: messageInfo, - startsConversation: true, - startsCluster: true, - endsCluster: true, - threadCreatedFromMessage: undefined, - robotext, - }; - return messageItem; - } -} - type UseMessageListDataArgs = {| +sourceMessageID: ?string, +searching: boolean, +userInfoInputArray: $ReadOnlyArray, +threadInfo: ?ThreadInfo, |}; function useMessageListData({ sourceMessageID, searching, userInfoInputArray, threadInfo, }: UseMessageListDataArgs) { - const threadInfos = useSelector(threadInfoSelector); const sidebarCandidate = useSidebarCandidate(sourceMessageID); const sidebarSourceMessageInfo = useSelector((state) => sourceMessageID && !sidebarCandidate && threadIsPendingSidebar(threadInfo) ? messageInfoSelector(state)[sourceMessageID] : null, ); invariant( !sidebarSourceMessageInfo || sidebarSourceMessageInfo.type !== messageTypes.SIDEBAR_SOURCE, 'sidebars can not be created from sidebar_source message', ); - const boundMessageListData = useSelector(messageListData(threadInfo?.id)); + + const additionalMessages = React.useMemo( + () => (sidebarSourceMessageInfo ? [sidebarSourceMessageInfo] : []), + [sidebarSourceMessageInfo], + ); + const boundMessageListData = useSelector( + messageListData(threadInfo?.id, additionalMessages), + ); return React.useMemo(() => { if (searching && userInfoInputArray.length === 0) { return []; - } else if (sidebarSourceMessageInfo) { - return [ - getSourceMessageChatItemForPendingSidebar( - sidebarSourceMessageInfo, - threadInfos, - ), - ]; } return boundMessageListData; - }, [ - searching, - userInfoInputArray.length, - sidebarSourceMessageInfo, - boundMessageListData, - threadInfos, - ]); + }, [searching, userInfoInputArray.length, boundMessageListData]); } export { messageInfoSelector, createChatThreadItem, chatListData, createChatMessageItems, messageListData, useFlattenedChatListData, - getSourceMessageChatItemForPendingSidebar, useMessageListData, }; diff --git a/lib/utils/memoize.js b/lib/utils/memoize.js new file mode 100644 index 000000000..62c4018dd --- /dev/null +++ b/lib/utils/memoize.js @@ -0,0 +1,12 @@ +// @flow + +import _memoize from 'lodash/memoize'; + +export default function memoize2( + f: (t: T, u: U) => V, +): (t: T, u: U) => V { + const memoized = _memoize<[T], (U) => V>((t: T) => + _memoize<[U], V>((u: U) => f(t, u)), + ); + return (t: T, u: U) => memoized(t)(u); +}