diff --git a/lib/hooks/sidebar-hooks.js b/lib/hooks/sidebar-hooks.js index 6df604b0b..5e76aa38c 100644 --- a/lib/hooks/sidebar-hooks.js +++ b/lib/hooks/sidebar-hooks.js @@ -1,55 +1,60 @@ // @flow import _orderBy from 'lodash/fp/orderBy.js'; import * as React from 'react'; import { useGetLastUpdatedTimes } from './thread-time.js'; import { childThreadInfos } from '../selectors/thread-selectors.js'; import { getMostRecentNonLocalMessageID } from '../shared/message-utils.js'; import { threadInChatList } from '../shared/thread-utils.js'; import { threadTypeIsSidebar } from '../types/thread-types-enum.js'; import type { SidebarInfo } from '../types/thread-types.js'; import { useSelector } from '../utils/redux-utils.js'; function useSidebarInfos(): { +[id: string]: $ReadOnlyArray } { const childThreadInfoByParentID = useSelector(childThreadInfos); const messageStore = useSelector(state => state.messageStore); const getLastUpdatedTimes = useGetLastUpdatedTimes(); return React.useMemo(() => { const result: { [id: string]: $ReadOnlyArray } = {}; for (const parentID in childThreadInfoByParentID) { const childThreads = childThreadInfoByParentID[parentID]; const sidebarInfos = []; for (const childThreadInfo of childThreads) { if ( !threadInChatList(childThreadInfo) || !threadTypeIsSidebar(childThreadInfo.type) ) { continue; } - const { lastUpdatedTime, lastUpdatedAtLeastTime } = getLastUpdatedTimes( + const { + lastUpdatedTime, + lastUpdatedAtLeastTime, + lastUpdatedAtMostTime, + } = getLastUpdatedTimes( childThreadInfo, messageStore, messageStore.messages, ); const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID( childThreadInfo.id, messageStore, ); sidebarInfos.push({ threadInfo: childThreadInfo, lastUpdatedTime, lastUpdatedAtLeastTime, + lastUpdatedAtMostTime, mostRecentNonLocalMessage, }); } result[parentID] = _orderBy('lastUpdatedAtLeastTime')('desc')( sidebarInfos, ); } return result; }, [childThreadInfoByParentID, messageStore, getLastUpdatedTimes]); } export { useSidebarInfos }; diff --git a/lib/hooks/thread-time.js b/lib/hooks/thread-time.js index 0a33e9a1b..e21d6aa81 100644 --- a/lib/hooks/thread-time.js +++ b/lib/hooks/thread-time.js @@ -1,107 +1,133 @@ // @flow import * as React from 'react'; import { useGetLatestMessageEdit } from './latest-message-edit.js'; import { messageSpecs } from '../shared/messages/message-specs.js'; import type { MessageInfo, RawMessageInfo, MessageStore, } from '../types/message-types.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { LastUpdatedTimes } from '../types/thread-types.js'; import { useSelector } from '../utils/redux-utils.js'; function useGetLastUpdatedTimes(): ( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { +[id: string]: ?MessageInfo | RawMessageInfo }, ) => LastUpdatedTimes { const viewerID = useSelector(state => state.currentUserInfo?.id); const fetchMessage = useGetLatestMessageEdit(); return React.useCallback( (threadInfo, messageStore, messages) => { // This callback returns two variables: // - lastUpdatedTime: this is a Promise that resolves with the final value // - lastUpdatedAtLeastTime: this is a number that represents what we // should use while we're waiting for lastUpdatedTime to resolve. It's // set based on the most recent message whose spec returns a non-Promise // when getLastUpdatedTime is called + // - lastUpdatedAtMostTime: this is a number that helps us when trying to + // pick the top N items. we initially sort by this, then resolve the + // lastUpdatedTime promises until we have N items that after resolving + // lastUpdatedTime, have higher values than any of the other items' + // lastUpdatedAtMostTime let lastUpdatedAtLeastTime = threadInfo.creationTime; const thread = messageStore.threads[threadInfo.id]; if (!thread || !viewerID) { return { lastUpdatedAtLeastTime, - lastUpdatedTime: Promise.resolve(lastUpdatedAtLeastTime), + lastUpdatedAtMostTime: threadInfo.creationTime, + lastUpdatedTime: () => Promise.resolve(threadInfo.creationTime), }; } const getLastUpdatedTimeParams = { threadInfo, viewerID, fetchMessage, }; - let lastUpdatedTime: ?Promise; + let lastUpdatedTime: ?() => Promise; + let highestTimestamp: ?number; for (const messageID of thread.messageIDs) { const messageInfo = messages[messageID]; if (!messageInfo) { continue; } // We call getLastUpdatedTime on the message spec. It can return either // ?number or Promise. If the message spec doesn't implement // getLastUpdatedTime, then we default to messageInfo.time. const { getLastUpdatedTime } = messageSpecs[messageInfo.type]; - const lastUpdatedTimePromisable = getLastUpdatedTime + const lastUpdatedTimeResult = getLastUpdatedTime ? getLastUpdatedTime(messageInfo, getLastUpdatedTimeParams) : messageInfo.time; + // We only need to consider the first positive number result because + // thread.messageIDs ordered chronologically. + if ( + !highestTimestamp && + lastUpdatedTimeResult && + typeof lastUpdatedTimeResult === 'number' + ) { + highestTimestamp = lastUpdatedTimeResult; + } + // We rely on the fact that thread.messageIDs is ordered chronologically // (newest first) to chain together lastUpdatedTime. An older message's // lastUpdatedTime is only considered if all of the newer messages // return falsey. - lastUpdatedTime = (async () => { + lastUpdatedTime = async () => { if (lastUpdatedTime) { - const earlierChecks = await lastUpdatedTime; + const earlierChecks = await lastUpdatedTime(); if (earlierChecks) { return earlierChecks; } } - return await lastUpdatedTimePromisable; - })(); + if ( + !lastUpdatedTimeResult || + typeof lastUpdatedTimeResult === 'number' + ) { + return lastUpdatedTimeResult; + } + return await lastUpdatedTimeResult(); + }; - if (typeof lastUpdatedTimePromisable === 'number') { + if (typeof lastUpdatedTimeResult === 'number') { // We break from the loop the first time this condition is met. // There's no need to consider any older messages, since both // lastUpdated and lastUpdatedAtLeastTime will be this value (or // higher, in the case of lastUpdated). That is also why this loop // only sets lastUpdatedAtLeastTime once: once we get to this // "baseline" case, there's no need to consider any more messages. - lastUpdatedAtLeastTime = lastUpdatedTimePromisable; + lastUpdatedAtLeastTime = lastUpdatedTimeResult; break; } } - const lastUpdatedWithFallback = (async () => { + const lastUpdatedWithFallback = async () => { if (lastUpdatedTime) { - const earlierChecks = await lastUpdatedTime; + const earlierChecks = await lastUpdatedTime(); if (earlierChecks) { return earlierChecks; } } return lastUpdatedAtLeastTime; - })(); + }; + + const lastUpdatedAtMostTime = highestTimestamp ?? threadInfo.creationTime; return { lastUpdatedAtLeastTime, + lastUpdatedAtMostTime, lastUpdatedTime: lastUpdatedWithFallback, }; }, [viewerID, fetchMessage], ); } export { useGetLastUpdatedTimes }; diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js index a068d4dd4..48276010c 100644 --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -1,776 +1,775 @@ // @flow import invariant from 'invariant'; import _orderBy from 'lodash/fp/orderBy.js'; import * as React from 'react'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import { threadInfoFromSourceMessageIDSelector, threadInfoSelector, } from './thread-selectors.js'; import { useSidebarInfos } from '../hooks/sidebar-hooks.js'; import { useGetLastUpdatedTimes } from '../hooks/thread-time.js'; import { createMessageInfo, getMostRecentNonLocalMessageID, messageKey, robotextForMessageInfo, sortMessageInfoList, } from '../shared/message-utils.js'; import { messageSpecs } from '../shared/messages/message-specs.js'; import { getSidebarItems, getAllInitialSidebarItems, getAllFinalSidebarItems, type SidebarItem, } from '../shared/sidebar-item-utils.js'; import { threadInChatList, threadIsPending } from '../shared/thread-utils.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type ComposableMessageInfo, isComposableMessageType, type LocalMessageInfo, type MessageInfo, type MessageStore, type RobotextMessageInfo, } from '../types/message-types.js'; import type { ThreadInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { threadTypeIsSidebar } from '../types/thread-types-enum.js'; -import type { SidebarInfo, LastUpdatedTimes } from '../types/thread-types.js'; import type { AccountUserInfo, RelativeUserInfo, UserInfo, } from '../types/user-types.js'; import type { EntityText } from '../utils/entity-text.js'; import memoize2 from '../utils/memoize.js'; import { useSelector } from '../utils/redux-utils.js'; type ChatThreadItemBase = { +type: 'chatThreadItem', +threadInfo: ThreadInfo, +mostRecentNonLocalMessage: ?string, +sidebars: $ReadOnlyArray, +pendingPersonalThreadUserInfo?: UserInfo, }; export type ChatThreadItem = $ReadOnly<{ ...ChatThreadItemBase, +lastUpdatedTime: number, +lastUpdatedTimeIncludingSidebars: number, }>; 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 isEmptyMediaMessage(messageInfo: MessageInfo): boolean { return ( (messageInfo.type === messageTypes.MULTIMEDIA || messageInfo.type === messageTypes.IMAGES) && messageInfo.media.length === 0 ); } type CreatedChatThreadItem = $ReadOnly<{ ...ChatThreadItemBase, - ...LastUpdatedTimes, - +lastUpdatedTimeIncludingSidebars: Promise, + +lastUpdatedAtLeastTime: number, +lastUpdatedAtLeastTimeIncludingSidebars: number, - +allSidebarInfos: $ReadOnlyArray, + +lastUpdatedAtMostTimeIncludingSidebars: number, + +getFinalChatThreadItem: () => Promise, }>; function useCreateChatThreadItem(): ThreadInfo => CreatedChatThreadItem { const messageInfos = useSelector(messageInfoSelector); const sidebarInfos = useSidebarInfos(); const messageStore = useSelector(state => state.messageStore); const getLastUpdatedTimes = useGetLastUpdatedTimes(); return React.useCallback( threadInfo => { const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID( threadInfo.id, messageStore, ); - const { lastUpdatedTime, lastUpdatedAtLeastTime } = getLastUpdatedTimes( - threadInfo, - messageStore, - messageInfos, - ); + const { lastUpdatedTime, lastUpdatedAtLeastTime, lastUpdatedAtMostTime } = + getLastUpdatedTimes(threadInfo, messageStore, messageInfos); const sidebars = sidebarInfos[threadInfo.id] ?? []; + const chatThreadItemBase = { + type: 'chatThreadItem', + threadInfo, + mostRecentNonLocalMessage, + }; + + const getFinalChatThreadItem = async () => { + const lastUpdatedTimePromise = lastUpdatedTime(); + + const lastUpdatedTimeIncludingSidebarsPromise = (async () => { + const lastUpdatedTimePromises = [ + lastUpdatedTimePromise, + ...sidebars.map(sidebar => sidebar.lastUpdatedTime()), + ]; + const lastUpdatedTimes = await Promise.all(lastUpdatedTimePromises); + const max = lastUpdatedTimes.reduce((a, b) => Math.max(a, b), -1); + return max; + })(); + + const [ + lastUpdatedTimeResult, + lastUpdatedTimeIncludingSidebars, + allSidebarItems, + ] = await Promise.all([ + lastUpdatedTimePromise, + lastUpdatedTimeIncludingSidebarsPromise, + getAllFinalSidebarItems(sidebars), + ]); + + return { + ...chatThreadItemBase, + sidebars: getSidebarItems(allSidebarItems), + lastUpdatedTime: lastUpdatedTimeResult, + lastUpdatedTimeIncludingSidebars: lastUpdatedTimeIncludingSidebars, + }; + }; + const lastUpdatedAtLeastTimeIncludingSidebars = sidebars.length > 0 ? Math.max(lastUpdatedAtLeastTime, sidebars[0].lastUpdatedAtLeastTime) : lastUpdatedAtLeastTime; - const lastUpdatedTimeIncludingSidebars = (async () => { - const lastUpdatedTimePromises = [ - lastUpdatedTime, - ...sidebars.map(sidebar => sidebar.lastUpdatedTime), - ]; - const lastUpdatedTimes = await Promise.all(lastUpdatedTimePromises); - const max = lastUpdatedTimes.reduce((a, b) => Math.max(a, b), -1); - return max; - })(); + const lastUpdatedAtMostTimeIncludingSidebars = + sidebars.length > 0 + ? Math.max(lastUpdatedAtMostTime, sidebars[0].lastUpdatedAtMostTime) + : lastUpdatedAtMostTime; const allInitialSidebarItems = getAllInitialSidebarItems(sidebars); - const sidebarItems = getSidebarItems(allInitialSidebarItems); return { - type: 'chatThreadItem', - threadInfo, - mostRecentNonLocalMessage, - lastUpdatedTime, + ...chatThreadItemBase, + sidebars: getSidebarItems(allInitialSidebarItems), lastUpdatedAtLeastTime, - lastUpdatedTimeIncludingSidebars, lastUpdatedAtLeastTimeIncludingSidebars, - sidebars: sidebarItems, - allSidebarInfos: sidebars, + lastUpdatedAtMostTimeIncludingSidebars, + getFinalChatThreadItem, }; }, [messageInfos, messageStore, sidebarInfos, getLastUpdatedTimes], ); } function useFlattenedChatListData(): $ReadOnlyArray { return useFilteredChatListData(threadInChatList); } function useFilteredChatListData( filterFunction: (threadInfo: ?(ThreadInfo | RawThreadInfo)) => boolean, ): $ReadOnlyArray { const threadInfos = useSelector(threadInfoSelector); const filteredThreadInfos = React.useMemo( () => Object.values(threadInfos).filter(filterFunction), [threadInfos, filterFunction], ); return useChatThreadItems(filteredThreadInfos); } const sortFunc = _orderBy('lastUpdatedTimeIncludingSidebars')('desc'); function useChatThreadItems( threadInfos: $ReadOnlyArray, ): $ReadOnlyArray { const getChatThreadItem = useCreateChatThreadItem(); const createdChatThreadItems = React.useMemo( () => threadInfos.map(getChatThreadItem), [threadInfos, getChatThreadItem], ); const initialChatThreadItems = React.useMemo( () => createdChatThreadItems.map(createdChatThreadItem => { const { - allSidebarInfos, - lastUpdatedTime, lastUpdatedAtLeastTime, - lastUpdatedTimeIncludingSidebars, lastUpdatedAtLeastTimeIncludingSidebars, + lastUpdatedAtMostTimeIncludingSidebars, + getFinalChatThreadItem, ...rest } = createdChatThreadItem; return { ...rest, lastUpdatedTime: lastUpdatedAtLeastTime, lastUpdatedTimeIncludingSidebars: lastUpdatedAtLeastTimeIncludingSidebars, }; }), [createdChatThreadItems], ); const [chatThreadItems, setChatThreadItems] = React.useState< $ReadOnlyArray, >(initialChatThreadItems); const prevCreatedChatThreadItemsRef = React.useRef(createdChatThreadItems); React.useEffect(() => { if (createdChatThreadItems === prevCreatedChatThreadItemsRef.current) { return; } prevCreatedChatThreadItemsRef.current = createdChatThreadItems; setChatThreadItems(initialChatThreadItems); void (async () => { const finalChatThreadItems = await Promise.all( - createdChatThreadItems.map(async createdChatThreadItem => { - const { - allSidebarInfos, - lastUpdatedTime: lastUpdatedTimePromise, - lastUpdatedAtLeastTime, - lastUpdatedTimeIncludingSidebars: - lastUpdatedTimeIncludingSidebarsPromise, - lastUpdatedAtLeastTimeIncludingSidebars, - ...rest - } = createdChatThreadItem; - const [ - lastUpdatedTime, - lastUpdatedTimeIncludingSidebars, - allSidebarItems, - ] = await Promise.all([ - lastUpdatedTimePromise, - lastUpdatedTimeIncludingSidebarsPromise, - getAllFinalSidebarItems(allSidebarInfos), - ]); - const sidebars = getSidebarItems(allSidebarItems); - return { - ...rest, - lastUpdatedTime, - lastUpdatedTimeIncludingSidebars, - sidebars, - }; - }), + createdChatThreadItems.map(createdChatThreadItem => + createdChatThreadItem.getFinalChatThreadItem(), + ), ); if (createdChatThreadItems !== prevCreatedChatThreadItemsRef.current) { // If these aren't equal, it indicates that the effect has fired again. // We should discard this result as it is now outdated. return; } // The callback below is basically // setChatThreadItems(finalChatThreadItems), but it has extra logic to // preserve objects if they are unchanged. setChatThreadItems(prevChatThreadItems => { if (prevChatThreadItems.length !== finalChatThreadItems.length) { console.log( 'unexpected: prevChatThreadItems.length !== ' + 'finalChatThreadItems.length', ); return finalChatThreadItems; } let somethingChanged = false; const result: Array = []; for (let i = 0; i < prevChatThreadItems.length; i++) { const prevChatThreadItem = prevChatThreadItems[i]; const newChatThreadItem = finalChatThreadItems[i]; if ( prevChatThreadItem.threadInfo.id !== newChatThreadItem.threadInfo.id ) { console.log( 'unexpected: prevChatThreadItem.threadInfo.id !== ' + 'newChatThreadItem.threadInfo.id', ); return finalChatThreadItems; } if ( prevChatThreadItem.lastUpdatedTime !== newChatThreadItem.lastUpdatedTime || prevChatThreadItem.lastUpdatedTimeIncludingSidebars !== newChatThreadItem.lastUpdatedTimeIncludingSidebars || prevChatThreadItem.sidebars.length !== newChatThreadItem.sidebars.length ) { somethingChanged = true; result[i] = newChatThreadItem; continue; } const sidebarsMatching = prevChatThreadItem.sidebars.every( (prevSidebar, j) => { const newSidebar = newChatThreadItem.sidebars[j]; if ( newSidebar.type !== 'sidebar' || prevSidebar.type !== 'sidebar' ) { return newSidebar.type === prevSidebar.type; } return newSidebar.threadInfo.id === prevSidebar.threadInfo.id; }, ); if (!sidebarsMatching) { somethingChanged = true; result[i] = newChatThreadItem; continue; } result[i] = prevChatThreadItem; } if (somethingChanged) { return result; } else { return prevChatThreadItems; } }); })(); }, [createdChatThreadItems, initialChatThreadItems]); return React.useMemo(() => sortFunc(chatThreadItems), [chatThreadItems]); } export type RobotextChatMessageInfoItem = { +itemType: 'message', +messageInfoType: 'robotext', +messageInfos: $ReadOnlyArray, +startsConversation: boolean, +startsCluster: boolean, endsCluster: boolean, +robotext: EntityText, +threadCreatedFromMessage: ?ThreadInfo, +reactions: ReactionInfo, }; export type ComposableChatMessageInfoItem = { +itemType: 'message', +messageInfoType: 'composable', +messageInfo: ComposableMessageInfo, +localMessageInfo: ?LocalMessageInfo, +startsConversation: boolean, +startsCluster: boolean, endsCluster: boolean, +threadCreatedFromMessage: ?ThreadInfo, +reactions: ReactionInfo, +hasBeenEdited: boolean, +isPinned: boolean, }; export type ChatMessageInfoItem = | RobotextChatMessageInfoItem | ComposableChatMessageInfoItem; export type ChatMessageItem = { itemType: 'loader' } | ChatMessageInfoItem; export type ReactionInfo = { +[reaction: string]: MessageReactionInfo }; type MessageReactionInfo = { +viewerReacted: boolean, +users: $ReadOnlyArray, }; type TargetMessageReactions = Map>; 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, viewerID: string, ): ChatMessageItem[] { const thread = messageStore.threads[threadID]; const threadMessageInfos = (thread?.messageIDs ?? []) .map((messageID: string) => messageInfos[messageID]) .filter(Boolean); const messages = additionalMessages.length > 0 ? sortMessageInfoList([...threadMessageInfos, ...additionalMessages]) : threadMessageInfos; const targetMessageReactionsMap = new Map(); // We need to iterate backwards to put the order of messages in chronological // order, starting with the oldest. This avoids the scenario where the most // recent message with the remove_reaction action may try to remove a user // that hasn't been added to the messageReactionUsersInfoMap, causing it // to be skipped. for (let i = messages.length - 1; i >= 0; i--) { const messageInfo = messages[i]; if (messageInfo.type !== messageTypes.REACTION) { continue; } if (!targetMessageReactionsMap.has(messageInfo.targetMessageID)) { const reactsMap: TargetMessageReactions = new Map(); targetMessageReactionsMap.set(messageInfo.targetMessageID, reactsMap); } const messageReactsMap = targetMessageReactionsMap.get( messageInfo.targetMessageID, ); invariant(messageReactsMap, 'messageReactsInfo should be set'); if (!messageReactsMap.has(messageInfo.reaction)) { const usersInfoMap = new Map(); messageReactsMap.set(messageInfo.reaction, usersInfoMap); } const messageReactionUsersInfoMap = messageReactsMap.get( messageInfo.reaction, ); invariant( messageReactionUsersInfoMap, 'messageReactionUsersInfoMap should be set', ); if (messageInfo.action === 'add_reaction') { messageReactionUsersInfoMap.set( messageInfo.creator.id, messageInfo.creator, ); } else { messageReactionUsersInfoMap.delete(messageInfo.creator.id); } } const targetMessageEditMap = new Map(); for (let i = messages.length - 1; i >= 0; i--) { const messageInfo = messages[i]; if (messageInfo.type !== messageTypes.EDIT_MESSAGE) { continue; } targetMessageEditMap.set(messageInfo.targetMessageID, messageInfo.text); } const targetMessagePinStatusMap = new Map(); // Once again, we iterate backwards to put the order of messages in // chronological order (i.e. oldest to newest) to handle pinned messages. // This is important because we want to make sure that the most recent pin // action is the one that is used to determine whether a message // is pinned or not. for (let i = messages.length - 1; i >= 0; i--) { const messageInfo = messages[i]; if (messageInfo.type !== messageTypes.TOGGLE_PIN) { continue; } targetMessagePinStatusMap.set( messageInfo.targetMessageID, messageInfo.action === 'pin', ); } const chatMessageItems: ChatMessageItem[] = []; let lastMessageInfo = null; for (let i = messages.length - 1; i >= 0; i--) { const messageInfo = messages[i]; if ( messageInfo.type === messageTypes.REACTION || messageInfo.type === messageTypes.EDIT_MESSAGE ) { continue; } let originalMessageInfo = messageInfo.type === messageTypes.SIDEBAR_SOURCE ? messageInfo.sourceMessage : messageInfo; if (isEmptyMediaMessage(originalMessageInfo)) { continue; } let hasBeenEdited = false; if ( originalMessageInfo.type === messageTypes.TEXT && originalMessageInfo.id ) { const newText = targetMessageEditMap.get(originalMessageInfo.id); if (newText !== undefined) { hasBeenEdited = true; originalMessageInfo = { ...originalMessageInfo, text: newText, }; } } 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 = !threadTypeIsSidebar(threadInfos[threadID]?.type) && messageInfo.id ? threadInfoFromSourceMessageID[messageInfo.id] : undefined; const isPinned = !!( originalMessageInfo.id && targetMessagePinStatusMap.get(originalMessageInfo.id) ); const renderedReactions: ReactionInfo = (() => { const result: { [string]: MessageReactionInfo } = {}; let messageReactsMap; if (originalMessageInfo.id) { messageReactsMap = targetMessageReactionsMap.get( originalMessageInfo.id, ); } if (!messageReactsMap) { return result; } for (const reaction of messageReactsMap.keys()) { const reactionUsersInfoMap = messageReactsMap.get(reaction); invariant(reactionUsersInfoMap, 'reactionUsersInfoMap should be set'); if (reactionUsersInfoMap.size === 0) { continue; } const reactionUserInfos = [...reactionUsersInfoMap.values()]; const messageReactionInfo = { users: reactionUserInfos, viewerReacted: reactionUsersInfoMap.has(viewerID), }; result[reaction] = messageReactionInfo; } return result; })(); const threadInfo = threadInfos[threadID]; const parentThreadInfo = threadInfo?.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; const lastChatMessageItem = chatMessageItems.length > 0 ? chatMessageItems[chatMessageItems.length - 1] : undefined; const messageSpec = messageSpecs[originalMessageInfo.type]; if ( !threadCreatedFromMessage && Object.keys(renderedReactions).length === 0 && !hasBeenEdited && !isPinned && lastChatMessageItem && lastChatMessageItem.itemType === 'message' && lastChatMessageItem.messageInfoType === 'robotext' && !lastChatMessageItem.threadCreatedFromMessage && Object.keys(lastChatMessageItem.reactions).length === 0 && !isComposableMessageType(originalMessageInfo.type) && messageSpec?.mergeIntoPrecedingRobotextMessageItem ) { const { mergeIntoPrecedingRobotextMessageItem } = messageSpec; const mergeResult = mergeIntoPrecedingRobotextMessageItem( originalMessageInfo, lastChatMessageItem, { threadInfo, parentThreadInfo }, ); if (mergeResult.shouldMerge) { chatMessageItems[chatMessageItems.length - 1] = mergeResult.item; continue; } } 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', messageInfoType: 'composable', messageInfo: originalMessageInfo, localMessageInfo, startsConversation, startsCluster, endsCluster: false, threadCreatedFromMessage, reactions: renderedReactions, hasBeenEdited, isPinned, }); } else { invariant( originalMessageInfo.type !== messageTypes.TEXT && originalMessageInfo.type !== messageTypes.IMAGES && originalMessageInfo.type !== messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const robotext = robotextForMessageInfo( originalMessageInfo, threadInfo, parentThreadInfo, ); chatMessageItems.push({ itemType: 'message', messageInfoType: 'robotext', messageInfos: [originalMessageInfo], startsConversation, startsCluster, endsCluster: false, threadCreatedFromMessage, robotext, reactions: renderedReactions, }); } lastMessageInfo = originalMessageInfo; } if (chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } chatMessageItems.reverse(); const hideSpinner = thread ? thread.startReached : threadIsPending(threadID); if (hideSpinner) { return chatMessageItems; } return [...chatMessageItems, ({ itemType: 'loader' }: ChatMessageItem)]; } const baseMessageListData = ( threadID: ?string, additionalMessages: $ReadOnlyArray, ): ((state: BaseAppState<>) => ?(ChatMessageItem[])) => createSelector( (state: BaseAppState<>) => state.messageStore, messageInfoSelector, threadInfoSelector, threadInfoFromSourceMessageIDSelector, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, ( messageStore: MessageStore, messageInfos: { +[id: string]: ?MessageInfo }, threadInfos: { +[id: string]: ThreadInfo, }, threadInfoFromSourceMessageID: { +[id: string]: ThreadInfo, }, viewerID: ?string, ): ?(ChatMessageItem[]) => { if (!threadID || !viewerID) { return null; } return createChatMessageItems( threadID, messageStore, messageInfos, threadInfos, threadInfoFromSourceMessageID, additionalMessages, viewerID, ); }, ); export type MessageListData = ?(ChatMessageItem[]); const messageListData: ( threadID: ?string, additionalMessages: $ReadOnlyArray, ) => (state: BaseAppState<>) => MessageListData = memoize2(baseMessageListData); export type UseMessageListDataArgs = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, +threadInfo: ?ThreadInfo, }; function useMessageListData({ searching, userInfoInputArray, threadInfo, }: UseMessageListDataArgs): MessageListData { const messageInfos = useSelector(messageInfoSelector); const containingThread = useSelector(state => { if ( !threadInfo || !threadTypeIsSidebar(threadInfo.type) || !threadInfo.containingThreadID ) { return null; } return state.messageStore.threads[threadInfo.containingThreadID]; }); const pendingSidebarEditMessageInfo = React.useMemo(() => { const sourceMessageID = threadInfo?.sourceMessageID; const threadMessageInfos = (containingThread?.messageIDs ?? []) .map((messageID: string) => messageInfos[messageID]) .filter(Boolean) .filter( message => message.type === messageTypes.EDIT_MESSAGE && message.targetMessageID === sourceMessageID, ); if (threadMessageInfos.length === 0) { return null; } return threadMessageInfos[0]; }, [threadInfo, containingThread, messageInfos]); const pendingSidebarSourceMessageInfo = useSelector(state => { const sourceMessageID = threadInfo?.sourceMessageID; if ( !threadInfo || !threadTypeIsSidebar(threadInfo.type) || !sourceMessageID ) { return null; } const thread = state.messageStore.threads[threadInfo.id]; const shouldSourceBeAdded = !thread || (thread.startReached && thread.messageIDs.every( id => messageInfos[id]?.type !== messageTypes.SIDEBAR_SOURCE, )); return shouldSourceBeAdded ? messageInfos[sourceMessageID] : null; }); invariant( !pendingSidebarSourceMessageInfo || pendingSidebarSourceMessageInfo.type !== messageTypes.SIDEBAR_SOURCE, 'sidebars can not be created from sidebar_source message', ); const additionalMessages = React.useMemo(() => { if (!pendingSidebarSourceMessageInfo) { return ([]: MessageInfo[]); } const result: MessageInfo[] = [pendingSidebarSourceMessageInfo]; if (pendingSidebarEditMessageInfo) { result.push(pendingSidebarEditMessageInfo); } return result; }, [pendingSidebarSourceMessageInfo, pendingSidebarEditMessageInfo]); const boundMessageListData = useSelector( messageListData(threadInfo?.id, additionalMessages), ); return React.useMemo(() => { if (searching && userInfoInputArray.length === 0) { return []; } return boundMessageListData; }, [searching, userInfoInputArray.length, boundMessageListData]); } export { messageInfoSelector, createChatMessageItems, messageListData, useFlattenedChatListData, useFilteredChatListData, useChatThreadItems, useMessageListData, }; diff --git a/lib/shared/messages/message-spec.js b/lib/shared/messages/message-spec.js index 6774f694a..a568611a0 100644 --- a/lib/shared/messages/message-spec.js +++ b/lib/shared/messages/message-spec.js @@ -1,148 +1,148 @@ // @flow import type { TType } from 'tcomb'; import type { RobotextChatMessageInfoItem } from '../../selectors/chat-selectors.js'; import type { PlatformDetails } from '../../types/device-types.js'; import type { Media } from '../../types/media-types.js'; import type { ClientDBMessageInfo, MessageInfo, RawComposableMessageInfo, RawMessageInfo, RawRobotextMessageInfo, } from '../../types/message-types.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported.js'; import type { ThreadInfo } from '../../types/minimally-encoded-thread-permissions-types.js'; import type { NotifTexts } from '../../types/notif-types.js'; import type { RelativeUserInfo, UserInfo } from '../../types/user-types.js'; import type { EntityText } from '../../utils/entity-text.js'; import { type ParserRules } from '../markdown.js'; export type MessageTitleParam = { +messageInfo: Info, +threadInfo: ThreadInfo, +markdownRules: ParserRules, }; export type RawMessageInfoFromServerDBRowParams = { +localID: ?string, +media?: $ReadOnlyArray, +derivedMessages?: $ReadOnlyMap< string, RawComposableMessageInfo | RawRobotextMessageInfo, >, }; export type CreateMessageInfoParams = { +threadInfos: { +[id: string]: ThreadInfo, }, +createMessageInfoFromRaw: (rawInfo: RawMessageInfo) => ?MessageInfo, +createRelativeUserInfos: ( userIDs: $ReadOnlyArray, ) => RelativeUserInfo[], }; export type RobotextParams = { +threadInfo: ?ThreadInfo, +parentThreadInfo: ?ThreadInfo, }; export type NotificationTextsParams = { +notifTargetUserInfo: UserInfo, +parentThreadInfo: ?ThreadInfo, }; export type GeneratesNotifsParams = { +notifTargetUserID: string, +userNotMemberOfSubthreads: Set, +fetchMessageInfoByID: (messageID: string) => Promise, }; export const pushTypes = Object.freeze({ NOTIF: 'notif', RESCIND: 'rescind', }); export type PushType = $Values; export type CreationSideEffectsFunc = ( messageInfo: RawInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => Promise; export type MergeRobotextMessageItemResult = | { +shouldMerge: false } | { +shouldMerge: true, +item: RobotextChatMessageInfoItem }; export type ShowInMessagePreviewParams = { +threadInfo: ThreadInfo, +viewerID: string, +fetchMessage: (messageID: string) => Promise, }; export type MessageSpec = { +messageContentForServerDB?: (data: Data | RawInfo) => string, +messageContentForClientDB?: (data: RawInfo) => string, +messageTitle?: (param: MessageTitleParam) => EntityText, +rawMessageInfoFromServerDBRow?: ( row: Object, params: RawMessageInfoFromServerDBRowParams, ) => ?RawInfo, +rawMessageInfoFromClientDB: ( clientDBMessageInfo: ClientDBMessageInfo, ) => RawInfo, +createMessageInfo: ( rawMessageInfo: RawInfo, creator: RelativeUserInfo, params: CreateMessageInfoParams, ) => ?Info, +rawMessageInfoFromMessageData?: (messageData: Data, id: ?string) => RawInfo, +robotext?: (messageInfo: Info, params: RobotextParams) => EntityText, +shimUnsupportedMessageInfo?: ( rawMessageInfo: RawInfo, platformDetails: ?PlatformDetails, ) => RawInfo | RawUnsupportedMessageInfo, +unshimMessageInfo?: ( unwrapped: RawInfo, messageInfo: RawMessageInfo, ) => ?RawMessageInfo, +notificationTexts?: ( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, params: NotificationTextsParams, ) => Promise, +notificationCollapseKey?: ( rawMessageInfo: RawInfo, messageData: Data, ) => ?string, +generatesNotifs?: ( rawMessageInfo: RawInfo, messageData: Data, params: GeneratesNotifsParams, ) => Promise, +userIDs?: (rawMessageInfo: RawInfo) => $ReadOnlyArray, +startsThread?: boolean, +threadIDs?: (rawMessageInfo: RawInfo) => $ReadOnlyArray, +includedInRepliesCount?: boolean, +canBeSidebarSource: boolean, +canBePinned: boolean, +canBeRenderedIndependently?: boolean, +parseDerivedMessages?: (row: Object, requiredIDs: Set) => void, +useCreationSideEffectsFunc?: () => CreationSideEffectsFunc, +validator: TType, +mergeIntoPrecedingRobotextMessageItem?: ( messageInfo: Info, precedingMessageInfoItem: RobotextChatMessageInfoItem, params: RobotextParams, ) => MergeRobotextMessageItemResult, +showInMessagePreview?: ( messageInfo: Info, params: ShowInMessagePreviewParams, ) => Promise, +getLastUpdatedTime?: ( messageInfoOrRawMessageInfo: Info | RawInfo, params: ShowInMessagePreviewParams, - ) => ?number | Promise, + ) => ?number | (() => Promise), }; diff --git a/lib/shared/sidebar-item-utils.js b/lib/shared/sidebar-item-utils.js index b8ed4aa87..2125910b9 100644 --- a/lib/shared/sidebar-item-utils.js +++ b/lib/shared/sidebar-item-utils.js @@ -1,106 +1,116 @@ // @flow import _orderBy from 'lodash/fp/orderBy.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { maxReadSidebars, maxUnreadSidebars, type SidebarInfo, } from '../types/thread-types.js'; import { threeDays } from '../utils/date-utils.js'; export type SidebarThreadItem = { +type: 'sidebar', +threadInfo: ThreadInfo, +mostRecentNonLocalMessage: ?string, +lastUpdatedTime: number, }; export type SidebarItem = | SidebarThreadItem | { +type: 'seeMore', +unread: boolean, } | { +type: 'spacer' }; const sortFunc = _orderBy('lastUpdatedTime')('desc'); function getSidebarItems( allSidebarItems: $ReadOnlyArray, ): SidebarItem[] { const sorted = sortFunc(allSidebarItems); const numUnreadSidebars = sorted.filter( sidebar => sidebar.threadInfo.currentUser.unread, ).length; let numReadSidebarsToShow = maxReadSidebars - numUnreadSidebars; const threeDaysAgo = Date.now() - threeDays; const sidebarItems: SidebarItem[] = []; for (const sidebar of sorted) { 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--; } } const numReadButRecentSidebars = sorted.filter( sidebar => !sidebar.threadInfo.currentUser.unread && sidebar.lastUpdatedTime > threeDaysAgo, ).length; if ( sidebarItems.length < numUnreadSidebars + numReadButRecentSidebars || (sidebarItems.length < sorted.length && sidebarItems.length > 0) ) { sidebarItems.push({ type: 'seeMore', unread: numUnreadSidebars > maxUnreadSidebars, }); } if (sidebarItems.length !== 0) { sidebarItems.push({ type: 'spacer', }); } return sidebarItems; } function getAllInitialSidebarItems( sidebarInfos: $ReadOnlyArray, ): SidebarThreadItem[] { return sidebarInfos.map(sidebarItem => { - const { lastUpdatedTime, lastUpdatedAtLeastTime, ...rest } = sidebarItem; + const { + lastUpdatedTime, + lastUpdatedAtLeastTime, + lastUpdatedAtMostTime, + ...rest + } = sidebarItem; return { ...rest, type: 'sidebar', lastUpdatedTime: lastUpdatedAtLeastTime, }; }); } async function getAllFinalSidebarItems( sidebarInfos: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const allSidebarItemPromises = sidebarInfos.map(async sidebarItem => { - const { lastUpdatedTime, lastUpdatedAtLeastTime, ...rest } = sidebarItem; - const finalLastUpdatedTime = await lastUpdatedTime; + const { + lastUpdatedTime, + lastUpdatedAtLeastTime, + lastUpdatedAtMostTime, + ...rest + } = sidebarItem; + const finalLastUpdatedTime = await lastUpdatedTime(); return { ...rest, type: 'sidebar', lastUpdatedTime: finalLastUpdatedTime, }; }); return await Promise.all(allSidebarItemPromises); } export { getSidebarItems, getAllInitialSidebarItems, getAllFinalSidebarItems }; diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js index 93dfee35f..3142030a6 100644 --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -1,536 +1,539 @@ // @flow import t, { type TInterface } from 'tcomb'; import { type AvatarDBContent, type ClientAvatar, clientAvatarValidator, type UpdateUserAvatarRequest, } from './avatar-types.js'; import type { CalendarQuery } from './entry-types.js'; import type { Media } from './media-types.js'; import type { MessageTruncationStatuses, RawMessageInfo, } from './message-types.js'; import type { RawThreadInfo, ResolvedThreadInfo, ThreadInfo, ThickRawThreadInfo, } from './minimally-encoded-thread-permissions-types.js'; import { type ThreadSubscription, threadSubscriptionValidator, } from './subscription-types.js'; import { type ThreadPermissionsInfo, threadPermissionsInfoValidator, type ThreadRolePermissionsBlob, threadRolePermissionsBlobValidator, type UserSurfacedPermission, } from './thread-permission-types.js'; import { type ThinThreadType, type ThickThreadType, thinThreadTypeValidator, } from './thread-types-enum.js'; import type { ClientUpdateInfo, ServerUpdateInfo } from './update-types.js'; import type { UserInfo, UserInfos } from './user-types.js'; import type { SpecialRole } from '../permissions/special-roles.js'; import { type ThreadEntity } from '../utils/entity-text.js'; import { tID, tShape, tUserID } from '../utils/validation-utils.js'; export type LegacyMemberInfo = { +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +isSender: boolean, }; export const legacyMemberInfoValidator: TInterface = tShape({ id: tUserID, role: t.maybe(tID), permissions: threadPermissionsInfoValidator, isSender: t.Boolean, }); export type ClientLegacyRoleInfo = { +id: string, +name: string, +permissions: ThreadRolePermissionsBlob, +isDefault: boolean, }; export const clientLegacyRoleInfoValidator: TInterface = tShape({ id: tID, name: t.String, permissions: threadRolePermissionsBlobValidator, isDefault: t.Boolean, }); export type ServerLegacyRoleInfo = { +id: string, +name: string, +permissions: ThreadRolePermissionsBlob, +isDefault: boolean, +specialRole: ?SpecialRole, }; export type LegacyThreadCurrentUserInfo = { +role: ?string, +permissions: ThreadPermissionsInfo, +subscription: ThreadSubscription, +unread: ?boolean, }; export const legacyThreadCurrentUserInfoValidator: TInterface = tShape({ role: t.maybe(tID), permissions: threadPermissionsInfoValidator, subscription: threadSubscriptionValidator, unread: t.maybe(t.Boolean), }); export type LegacyThinRawThreadInfo = { +id: string, +type: ThinThreadType, +name?: ?string, +avatar?: ?ClientAvatar, +description?: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID?: ?string, +community: ?string, +members: $ReadOnlyArray, +roles: { +[id: string]: ClientLegacyRoleInfo }, +currentUser: LegacyThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, }; export type ThickMemberInfo = { +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +subscription: ThreadSubscription, +isSender: boolean, }; export type ThreadTimestamps = { +name: number, +avatar: number, +description: number, +color: number, +members: { +[id: string]: { +isMember: number, +subscription: number, }, }, +currentUser: { +unread: number, }, }; export const threadTimestampsValidator: TInterface = tShape({ name: t.Number, avatar: t.Number, description: t.Number, color: t.Number, members: t.dict( tUserID, tShape({ isMember: t.Number, subscription: t.Number, }), ), currentUser: tShape({ unread: t.Number, }), }); export type LegacyThickRawThreadInfo = { +thick: true, +id: string, +type: ThickThreadType, +name?: ?string, +avatar?: ?ClientAvatar, +description?: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID?: ?string, +containingThreadID?: ?string, +members: $ReadOnlyArray, +roles: { +[id: string]: ClientLegacyRoleInfo }, +currentUser: LegacyThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, +timestamps: ThreadTimestamps, }; export type LegacyRawThreadInfo = | LegacyThinRawThreadInfo | LegacyThickRawThreadInfo; export type LegacyRawThreadInfos = { +[id: string]: LegacyRawThreadInfo, }; export const legacyThinRawThreadInfoValidator: TInterface = tShape({ id: tID, type: thinThreadTypeValidator, name: t.maybe(t.String), avatar: t.maybe(clientAvatarValidator), description: t.maybe(t.String), color: t.String, creationTime: t.Number, parentThreadID: t.maybe(tID), containingThreadID: t.maybe(tID), community: t.maybe(tID), members: t.list(legacyMemberInfoValidator), roles: t.dict(tID, clientLegacyRoleInfoValidator), currentUser: legacyThreadCurrentUserInfoValidator, sourceMessageID: t.maybe(tID), repliesCount: t.Number, pinnedCount: t.maybe(t.Number), }); export type MixedRawThreadInfos = { +[id: string]: LegacyRawThreadInfo | RawThreadInfo, }; export type ThickRawThreadInfos = { +[id: string]: ThickRawThreadInfo, }; export type RawThreadInfos = { +[id: string]: RawThreadInfo, }; export type ServerMemberInfo = { +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +subscription: ThreadSubscription, +unread: ?boolean, +isSender: boolean, }; export type ServerThreadInfo = { +id: string, +type: ThinThreadType, +name: ?string, +avatar?: AvatarDBContent, +description: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +depth: number, +members: $ReadOnlyArray, +roles: { +[id: string]: ServerLegacyRoleInfo }, +sourceMessageID?: string, +repliesCount: number, +pinnedCount: number, }; export type LegacyThreadStore = { +threadInfos: MixedRawThreadInfos, }; export type ThreadStore = { +threadInfos: RawThreadInfos, }; export type ClientDBThreadInfo = { +id: string, +type: number, +name: ?string, +avatar?: ?string, +description: ?string, +color: string, +creationTime: string, +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +members: string, +roles: string, +currentUser: string, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, +timestamps?: ?string, }; export type ThreadDeletionRequest = { +threadID: string, +accountPassword?: empty, }; export type RemoveMembersRequest = { +threadID: string, +memberIDs: $ReadOnlyArray, }; export type RoleChangeRequest = { +threadID: string, +memberIDs: $ReadOnlyArray, +role: string, }; export type ChangeThreadSettingsResult = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, }; export type ChangeThreadSettingsPayload = { +threadID: string, +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, }; export type LeaveThreadRequest = { +threadID: string, }; export type LeaveThreadResult = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type LeaveThreadPayload = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; type BaseThreadChanges = { +type: ThinThreadType, +name: string, +description: string, +color: string, +parentThreadID: ?string, +avatar: UpdateUserAvatarRequest, }; export type ThreadChanges = Partial; export type ThinThreadChanges = $ReadOnly< $Partial<{ ...BaseThreadChanges, +newMemberIDs: $ReadOnlyArray }>, >; export type UpdateThreadRequest = { +threadID: string, +changes: ThinThreadChanges, +accountPassword?: empty, }; export type UpdateThickThreadRequest = $ReadOnly<{ ...UpdateThreadRequest, +changes: ThreadChanges, }>; export type BaseNewThreadRequest = { +id?: ?string, +name?: ?string, +description?: ?string, +color?: ?string, +parentThreadID?: ?string, +initialMemberIDs?: ?$ReadOnlyArray, +ghostMemberIDs?: ?$ReadOnlyArray, }; type NewThinThreadRequest = | $ReadOnly<{ +type: 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12, ...BaseNewThreadRequest, }> | $ReadOnly<{ +type: 5, +sourceMessageID: string, ...BaseNewThreadRequest, +parentThreadID: string, }>; export type ClientNewThinThreadRequest = $ReadOnly<{ ...NewThinThreadRequest, +calendarQuery: CalendarQuery, }>; export type ServerNewThinThreadRequest = $ReadOnly<{ ...NewThinThreadRequest, +calendarQuery?: ?CalendarQuery, }>; export type NewThickThreadRequest = | $ReadOnly<{ +type: 13 | 14 | 15, ...BaseNewThreadRequest, }> | $ReadOnly<{ +type: 16, +sourceMessageID: string, ...BaseNewThreadRequest, +parentThreadID: string, }>; export type NewThreadResponse = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, +userInfos: UserInfos, +newThreadID: string, }; export type NewThreadResult = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, +userInfos: UserInfos, +newThreadID: string, }; export type ServerThreadJoinRequest = { +threadID: string, +calendarQuery?: ?CalendarQuery, +inviteLinkSecret?: string, +defaultSubscription?: ThreadSubscription, }; export type ClientThreadJoinRequest = { +threadID: string, +calendarQuery: CalendarQuery, +inviteLinkSecret?: string, +defaultSubscription?: ThreadSubscription, }; export type ThreadJoinResult = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +userInfos: UserInfos, }; export type ThreadJoinPayload = { +updatesResult: { newUpdates: $ReadOnlyArray, }, +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +userInfos: $ReadOnlyArray, +keyserverID?: string, }; export type ThreadFetchMediaResult = { +media: $ReadOnlyArray, }; export type ThreadFetchMediaRequest = { +threadID: string, +limit: number, +offset: number, }; export type LastUpdatedTimes = { // The last updated time is at least this number, but possibly higher // We won't know for sure until the below Promise resolves +lastUpdatedAtLeastTime: number, - +lastUpdatedTime: Promise, + // The last updated time is no more than this number, but possible lower + // We won't know for sure until the below Promise resolves + +lastUpdatedAtMostTime: number, + +lastUpdatedTime: () => Promise, }; export type SidebarInfo = $ReadOnly<{ ...LastUpdatedTimes, +threadInfo: ThreadInfo, +mostRecentNonLocalMessage: ?string, }>; export type ToggleMessagePinRequest = { +messageID: string, +action: 'pin' | 'unpin', }; export type ToggleMessagePinResult = { +newMessageInfos: $ReadOnlyArray, +threadID: string, }; type CreateRoleAction = { +community: string, +name: string, +permissions: $ReadOnlyArray, +action: 'create_role', }; type EditRoleAction = { +community: string, +existingRoleID: string, +name: string, +permissions: $ReadOnlyArray, +action: 'edit_role', }; export type RoleModificationRequest = CreateRoleAction | EditRoleAction; export type RoleModificationResult = { +threadInfo: LegacyRawThreadInfo | RawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type RoleModificationPayload = { +threadInfo: LegacyRawThreadInfo | RawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type RoleDeletionRequest = { +community: string, +roleID: string, }; export type RoleDeletionResult = { +threadInfo: LegacyRawThreadInfo | RawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type RoleDeletionPayload = { +threadInfo: LegacyRawThreadInfo | RawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; // We can show a max of 3 sidebars inline underneath their parent in the chat // tab. If there are more, we show a button that opens a modal to see the rest export const maxReadSidebars = 3; // We can show a max of 5 sidebars inline underneath their parent // in the chat tab if every one of the displayed sidebars is unread export const maxUnreadSidebars = 5; export type ThreadStoreThreadInfos = LegacyRawThreadInfos; export type ChatMentionCandidate = { +threadInfo: ResolvedThreadInfo, +rawChatName: string | ThreadEntity, }; export type ChatMentionCandidates = { +[id: string]: ChatMentionCandidate, }; export type ChatMentionCandidatesObj = { +[id: string]: ChatMentionCandidates, }; export type UserProfileThreadInfo = { +threadInfo: ThreadInfo, +pendingPersonalThreadUserInfo?: UserInfo, };