diff --git a/lib/hooks/search-threads.js b/lib/hooks/search-threads.js index 3e9cbaf28..fa6544b47 100644 --- a/lib/hooks/search-threads.js +++ b/lib/hooks/search-threads.js @@ -1,114 +1,185 @@ // @flow import * as React from 'react'; import { useSidebarInfos } from './sidebar-hooks.js'; import { type ChatThreadItem, useFilteredChatListData, } from '../selectors/chat-selectors.js'; import { useThreadSearchIndex } from '../selectors/nav-selectors.js'; +import { + type SidebarThreadItem, + getAllInitialSidebarItems, + getAllFinalSidebarItems, +} from '../shared/sidebar-item-utils.js'; import { threadIsChannel } from '../shared/thread-utils.js'; import type { SetState } from '../types/hook-types.js'; import type { ThreadInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import type { SidebarInfo } from '../types/thread-types.js'; export type ThreadSearchState = { +text: string, +results: $ReadOnlySet, }; type SearchThreadsResult = { +listData: $ReadOnlyArray, +searchState: ThreadSearchState, +setSearchState: SetState, +onChangeSearchInputText: (text: string) => mixed, +clearQuery: (event: SyntheticEvent) => void, }; -function useSearchThreads( +type ChildThreadInfos = { + +threadInfo: RawThreadInfo | ThreadInfo, + ... +}; +function useSearchThreads( threadInfo: ThreadInfo, childThreadInfos: $ReadOnlyArray, ): SearchThreadsResult { const [searchState, setSearchState] = React.useState({ text: '', results: new Set(), }); const listData = React.useMemo(() => { if (!searchState.text) { return childThreadInfos; } return childThreadInfos.filter(thread => searchState.results.has(thread.threadInfo.id), ); }, [childThreadInfos, searchState]); const justThreadInfos = React.useMemo( () => childThreadInfos.map(childThreadInfo => childThreadInfo.threadInfo), [childThreadInfos], ); const searchIndex = useThreadSearchIndex(justThreadInfos); const onChangeSearchInputText = React.useCallback( (text: string) => { setSearchState({ text, results: new Set(searchIndex.getSearchResults(text)), }); }, [searchIndex, setSearchState], ); const clearQuery = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); setSearchState({ text: '', results: new Set() }); }, [setSearchState], ); return React.useMemo( () => ({ listData, searchState, setSearchState, onChangeSearchInputText, clearQuery, }), [ listData, setSearchState, searchState, onChangeSearchInputText, clearQuery, ], ); } const emptyArray: $ReadOnlyArray = []; function useSearchSidebars( threadInfo: ThreadInfo, -): SearchThreadsResult { +): SearchThreadsResult { const sidebarsByParentID = useSidebarInfos(); const childThreadInfos = sidebarsByParentID[threadInfo.id] ?? emptyArray; - return useSearchThreads(threadInfo, childThreadInfos); + const initialSidebarItems = React.useMemo( + () => getAllInitialSidebarItems(childThreadInfos), + [childThreadInfos], + ); + const [sidebarItems, setSidebarItems] = + React.useState<$ReadOnlyArray>(initialSidebarItems); + + const prevChildThreadInfosRef = React.useRef(childThreadInfos); + React.useEffect(() => { + if (childThreadInfos === prevChildThreadInfosRef.current) { + return; + } + prevChildThreadInfosRef.current = childThreadInfos; + + setSidebarItems(initialSidebarItems); + + void (async () => { + const finalSidebarItems = await getAllFinalSidebarItems(childThreadInfos); + if (childThreadInfos !== prevChildThreadInfosRef.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 setSidebarItems(finalSidebarItems), but + // it has extra logic to preserve objects if they are unchanged. + setSidebarItems(prevSidebarItems => { + if (prevSidebarItems.length !== finalSidebarItems.length) { + console.log( + 'unexpected: prevSidebarItems.length !== finalSidebarItems.length', + ); + return finalSidebarItems; + } + let somethingChanged = false; + const result = []; + for (let i = 0; i < prevSidebarItems.length; i++) { + const prevSidebarItem = prevSidebarItems[i]; + const newSidebarItem = finalSidebarItems[i]; + if (prevSidebarItem.threadInfo.id !== newSidebarItem.threadInfo.id) { + console.log( + 'unexpected: prevSidebarItem.threadInfo.id !== ' + + 'newSidebarItem.threadInfo.id', + ); + return finalSidebarItems; + } + if ( + prevSidebarItem.lastUpdatedTime !== newSidebarItem.lastUpdatedTime + ) { + somethingChanged = true; + result[i] = newSidebarItem; + } else { + result[i] = prevSidebarItem; + } + } + if (somethingChanged) { + return result; + } else { + return prevSidebarItems; + } + }); + })(); + }, [childThreadInfos, initialSidebarItems]); + + return useSearchThreads(threadInfo, sidebarItems); } function useSearchSubchannels( threadInfo: ThreadInfo, ): SearchThreadsResult { const filterFunc = React.useCallback( (thread: ?(ThreadInfo | RawThreadInfo)) => threadIsChannel(thread) && thread?.parentThreadID === threadInfo.id, [threadInfo.id], ); const childThreadInfos = useFilteredChatListData(filterFunc); return useSearchThreads(threadInfo, childThreadInfos); } export { useSearchSubchannels, useSearchSidebars }; diff --git a/lib/hooks/sidebar-hooks.js b/lib/hooks/sidebar-hooks.js index 3f9710c8a..ad409ff80 100644 --- a/lib/hooks/sidebar-hooks.js +++ b/lib/hooks/sidebar-hooks.js @@ -1,52 +1,53 @@ // @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 { lastUpdatedAtLeastTime: lastUpdatedTime } = getLastUpdatedTimes( + const { lastUpdatedTime, lastUpdatedAtLeastTime } = getLastUpdatedTimes( childThreadInfo, messageStore, messageStore.messages, ); const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID( childThreadInfo.id, messageStore, ); sidebarInfos.push({ threadInfo: childThreadInfo, lastUpdatedTime, + lastUpdatedAtLeastTime, mostRecentNonLocalMessage, }); } result[parentID] = _orderBy('lastUpdatedTime')('desc')(sidebarInfos); } return result; }, [childThreadInfoByParentID, messageStore, getLastUpdatedTimes]); } export { useSidebarInfos }; diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js index 0d5612667..040e8d32c 100644 --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -1,614 +1,613 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter.js'; import _flow from 'lodash/fp/flow.js'; import _map from 'lodash/fp/map.js'; 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, 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 { 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'; export type ChatThreadItem = { +type: 'chatThreadItem', +threadInfo: ThreadInfo, +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 isEmptyMediaMessage(messageInfo: MessageInfo): boolean { return ( (messageInfo.type === messageTypes.MULTIMEDIA || messageInfo.type === messageTypes.IMAGES) && messageInfo.media.length === 0 ); } function useCreateChatThreadItem(): ThreadInfo => ChatThreadItem { 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 { lastUpdatedAtLeastTime: lastUpdatedTime } = getLastUpdatedTimes( + const { lastUpdatedAtLeastTime } = getLastUpdatedTimes( threadInfo, messageStore, messageInfos, ); const sidebars = sidebarInfos[threadInfo.id] ?? []; - const allSidebarItems = sidebars.map(sidebarInfo => ({ - type: 'sidebar', - ...sidebarInfo, - })); - const lastUpdatedTimeIncludingSidebars = - allSidebarItems.length > 0 - ? Math.max(lastUpdatedTime, allSidebarItems[0].lastUpdatedTime) - : lastUpdatedTime; + const lastUpdatedAtLeastTimeIncludingSidebars = + sidebars.length > 0 + ? Math.max(lastUpdatedAtLeastTime, sidebars[0].lastUpdatedAtLeastTime) + : lastUpdatedAtLeastTime; - const sidebarItems = getSidebarItems(allSidebarItems); + const allInitialSidebarItems = getAllInitialSidebarItems(sidebars); + const sidebarItems = getSidebarItems(allInitialSidebarItems); return { type: 'chatThreadItem', threadInfo, mostRecentNonLocalMessage, - lastUpdatedTime, - lastUpdatedTimeIncludingSidebars, + lastUpdatedTime: lastUpdatedAtLeastTime, + lastUpdatedTimeIncludingSidebars: + lastUpdatedAtLeastTimeIncludingSidebars, sidebars: sidebarItems, }; }, [messageInfos, messageStore, sidebarInfos, getLastUpdatedTimes], ); } function useFlattenedChatListData(): $ReadOnlyArray { return useFilteredChatListData(threadInChatList); } function useFilteredChatListData( filterFunction: (threadInfo: ?(ThreadInfo | RawThreadInfo)) => boolean, ): $ReadOnlyArray { const threadInfos = useSelector(threadInfoSelector); const getChatThreadItem = useCreateChatThreadItem(); return React.useMemo( () => _flow( _filter(filterFunction), _map(getChatThreadItem), _orderBy('lastUpdatedTimeIncludingSidebars')('desc'), )(threadInfos), [getChatThreadItem, filterFunction, threadInfos], ); } 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, useCreateChatThreadItem, createChatMessageItems, messageListData, useFlattenedChatListData, useFilteredChatListData, useMessageListData, }; diff --git a/lib/shared/sidebar-item-utils.js b/lib/shared/sidebar-item-utils.js index 7914225b8..983a2b6e5 100644 --- a/lib/shared/sidebar-item-utils.js +++ b/lib/shared/sidebar-item-utils.js @@ -1,68 +1,100 @@ // @flow import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import { maxReadSidebars, maxUnreadSidebars } from '../types/thread-types.js'; +import { + maxReadSidebars, + maxUnreadSidebars, + type SidebarInfo, +} from '../types/thread-types.js'; import { threeDays } from '../utils/date-utils.js'; -type SidebarThreadItem = { +export type SidebarThreadItem = { +type: 'sidebar', +threadInfo: ThreadInfo, +mostRecentNonLocalMessage: ?string, +lastUpdatedTime: number, }; export type SidebarItem = | SidebarThreadItem | { +type: 'seeMore', +unread: boolean, } | { +type: 'spacer' }; function getSidebarItems( allSidebarItems: $ReadOnlyArray, ): SidebarItem[] { const numUnreadSidebars = allSidebarItems.filter( sidebar => sidebar.threadInfo.currentUser.unread, ).length; let numReadSidebarsToShow = maxReadSidebars - numUnreadSidebars; const threeDaysAgo = Date.now() - threeDays; const sidebarItems: SidebarItem[] = []; 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--; } } const numReadButRecentSidebars = allSidebarItems.filter( sidebar => !sidebar.threadInfo.currentUser.unread && sidebar.lastUpdatedTime > threeDaysAgo, ).length; if ( sidebarItems.length < numUnreadSidebars + numReadButRecentSidebars || (sidebarItems.length < allSidebarItems.length && sidebarItems.length > 0) ) { sidebarItems.push({ type: 'seeMore', unread: numUnreadSidebars > maxUnreadSidebars, }); } if (sidebarItems.length !== 0) { sidebarItems.push({ type: 'spacer', }); } return sidebarItems; } -export { getSidebarItems }; +function getAllInitialSidebarItems( + sidebarInfos: $ReadOnlyArray, +): SidebarThreadItem[] { + return sidebarInfos.map(sidebarItem => { + const { lastUpdatedTime, lastUpdatedAtLeastTime, ...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; + 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 771c14772..93dfee35f 100644 --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -1,536 +1,536 @@ // @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, }; -export type SidebarInfo = { +export type SidebarInfo = $ReadOnly<{ + ...LastUpdatedTimes, +threadInfo: ThreadInfo, - +lastUpdatedTime: number, +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, }; diff --git a/native/chat/chat-thread-list-item.react.js b/native/chat/chat-thread-list-item.react.js index 420ea6fac..9e3dfe7bd 100644 --- a/native/chat/chat-thread-list-item.react.js +++ b/native/chat/chat-thread-list-item.react.js @@ -1,304 +1,303 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import * as React from 'react'; import { Text, View } from 'react-native'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypeIsThick } from 'lib/types/thread-types-enum.js'; import type { UserInfo } from 'lib/types/user-types.js'; import { shortAbsoluteDate } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import ChatThreadListSeeMoreSidebars from './chat-thread-list-see-more-sidebars.react.js'; import ChatThreadListSidebar from './chat-thread-list-sidebar.react.js'; import MessagePreview from './message-preview.react.js'; import SwipeableThread from './swipeable-thread.react.js'; import ThreadAvatar from '../avatars/thread-avatar.react.js'; import Button from '../components/button.react.js'; import SingleLine from '../components/single-line.react.js'; import ThreadAncestorsLabel from '../components/thread-ancestors-label.react.js'; import UnreadDot from '../components/unread-dot.react.js'; import { useColors, useStyles } from '../themes/colors.js'; type Props = { +data: ChatThreadItem, +onPressItem: ( threadInfo: ThreadInfo, pendingPersonalThreadUserInfo?: UserInfo, ) => void, +onPressSeeMoreSidebars: (threadInfo: ThreadInfo) => void, +onSwipeableWillOpen: (threadInfo: ThreadInfo) => void, +currentlyOpenedSwipeableId: string, }; function ChatThreadListItem({ data, onPressItem, onPressSeeMoreSidebars, onSwipeableWillOpen, currentlyOpenedSwipeableId, }: Props): React.Node { const styles = useStyles(unboundStyles); const colors = useColors(); const numOfSidebarsWithExtendedArrow = data.sidebars.filter(sidebarItem => sidebarItem.type === 'sidebar').length - 1; const sidebars = React.useMemo( () => data.sidebars.map((sidebarItem, index) => { if (sidebarItem.type === 'sidebar') { - const { type, ...sidebarInfo } = sidebarItem; return ( ); } else if (sidebarItem.type === 'seeMore') { return ( ); } else { return ; } }), [ currentlyOpenedSwipeableId, data.sidebars, data.threadInfo, numOfSidebarsWithExtendedArrow, onPressItem, onPressSeeMoreSidebars, onSwipeableWillOpen, styles.spacer, ], ); const onPress = React.useCallback(() => { onPressItem(data.threadInfo, data.pendingPersonalThreadUserInfo); }, [onPressItem, data.threadInfo, data.pendingPersonalThreadUserInfo]); const threadNameStyle = React.useMemo(() => { if (!data.threadInfo.currentUser.unread) { return styles.threadName; } return [styles.threadName, styles.unreadThreadName]; }, [ data.threadInfo.currentUser.unread, styles.threadName, styles.unreadThreadName, ]); const lastActivity = shortAbsoluteDate(data.lastUpdatedTime); const lastActivityStyle = React.useMemo(() => { if (!data.threadInfo.currentUser.unread) { return styles.lastActivity; } return [styles.lastActivity, styles.unreadLastActivity]; }, [ data.threadInfo.currentUser.unread, styles.lastActivity, styles.unreadLastActivity, ]); const resolvedThreadInfo = useResolvedThreadInfo(data.threadInfo); const unreadDot = React.useMemo( () => ( ), [data.threadInfo.currentUser.unread, styles.avatarContainer], ); const threadAvatar = React.useMemo( () => ( ), [data.threadInfo, styles.avatarContainer], ); const isThick = threadTypeIsThick(data.threadInfo.type); const iconStyle = data.threadInfo.currentUser.unread ? styles.iconUnread : styles.iconRead; const iconName = isThick ? 'lock' : 'server'; const threadDetails = React.useMemo( () => ( {resolvedThreadInfo.uiName} {lastActivity} ), [ iconStyle, data.threadInfo, iconName, lastActivity, lastActivityStyle, resolvedThreadInfo.uiName, styles.header, styles.iconContainer, styles.row, styles.threadDetails, threadNameStyle, ], ); const swipeableThreadContent = React.useMemo( () => ( ), [ colors.listIosHighlightUnderlay, onPress, styles.container, styles.content, threadAvatar, threadDetails, unreadDot, ], ); const swipeableThread = React.useMemo( () => ( {swipeableThreadContent} ), [ currentlyOpenedSwipeableId, data.mostRecentNonLocalMessage, data.threadInfo, onSwipeableWillOpen, swipeableThreadContent, ], ); const chatThreadListItem = React.useMemo( () => ( <> {swipeableThread} {sidebars} ), [sidebars, swipeableThread], ); return chatThreadListItem; } const chatThreadListItemHeight = 70; const spacerHeight = 6; const unboundStyles = { container: { height: chatThreadListItemHeight, justifyContent: 'center', backgroundColor: 'listBackground', }, content: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, avatarContainer: { marginLeft: 6, marginBottom: 12, }, threadDetails: { paddingLeft: 12, paddingRight: 18, justifyContent: 'center', flex: 1, marginTop: 5, }, lastActivity: { color: 'listForegroundTertiaryLabel', fontSize: 14, marginLeft: 10, }, unreadLastActivity: { color: 'listForegroundLabel', fontWeight: 'bold', }, row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, threadName: { color: 'listForegroundSecondaryLabel', flex: 1, fontSize: 21, }, unreadThreadName: { color: 'listForegroundLabel', fontWeight: '500', }, spacer: { height: spacerHeight, }, header: { flexDirection: 'row', alignItems: 'center', }, iconContainer: { marginRight: 6, }, iconRead: { color: 'listForegroundTertiaryLabel', }, iconUnread: { color: 'listForegroundLabel', }, }; export { ChatThreadListItem, chatThreadListItemHeight, spacerHeight }; diff --git a/native/chat/chat-thread-list-sidebar.react.js b/native/chat/chat-thread-list-sidebar.react.js index 2ce41716e..67da85305 100644 --- a/native/chat/chat-thread-list-sidebar.react.js +++ b/native/chat/chat-thread-list-sidebar.react.js @@ -1,159 +1,156 @@ // @flow import * as React from 'react'; import { View } from 'react-native'; +import type { SidebarThreadItem } from 'lib/shared/sidebar-item-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { SidebarInfo } from 'lib/types/thread-types.js'; import { sidebarHeight, SidebarItem } from './sidebar-item.react.js'; import SwipeableThread from './swipeable-thread.react.js'; import Button from '../components/button.react.js'; import UnreadDot from '../components/unread-dot.react.js'; import { useColors, useStyles } from '../themes/colors.js'; import ExtendedArrow from '../vectors/arrow-extended.react.js'; import Arrow from '../vectors/arrow.react.js'; type Props = { - +sidebarInfo: SidebarInfo, + +sidebarItem: SidebarThreadItem, +onPressItem: (threadInfo: ThreadInfo) => void, +onSwipeableWillOpen: (threadInfo: ThreadInfo) => void, +currentlyOpenedSwipeableId: string, +extendArrow: boolean, }; function ChatThreadListSidebar(props: Props): React.Node { const colors = useColors(); const styles = useStyles(unboundStyles); const { - sidebarInfo, + sidebarItem, onSwipeableWillOpen, currentlyOpenedSwipeableId, onPressItem, extendArrow = false, } = props; - const { threadInfo } = sidebarInfo; + const { threadInfo } = sidebarItem; const onPress = React.useCallback( () => onPressItem(threadInfo), [threadInfo, onPressItem], ); const arrow = React.useMemo(() => { if (extendArrow) { return ( ); } return ( ); }, [extendArrow, styles.arrow, styles.extendedArrow]); const unreadIndicator = React.useMemo( () => ( - + ), - [ - sidebarInfo.threadInfo.currentUser.unread, - styles.unreadIndicatorContainer, - ], + [threadInfo.currentUser.unread, styles.unreadIndicatorContainer], ); - const sidebarItem = React.useMemo( - () => , - [sidebarInfo], + const sidebarItemElement = React.useMemo( + () => , + [sidebarItem], ); const swipeableThread = React.useMemo( () => ( - {sidebarItem} + {sidebarItemElement} ), [ currentlyOpenedSwipeableId, onSwipeableWillOpen, - sidebarInfo.mostRecentNonLocalMessage, - sidebarInfo.threadInfo, - sidebarItem, + sidebarItem.mostRecentNonLocalMessage, + threadInfo, + sidebarItemElement, styles.swipeableThreadContainer, ], ); const chatThreadListSidebar = React.useMemo( () => ( ), [ arrow, colors.listIosHighlightUnderlay, onPress, styles.sidebar, swipeableThread, unreadIndicator, ], ); return chatThreadListSidebar; } const unboundStyles = { arrow: { left: 28, position: 'absolute', top: -12, }, extendedArrow: { left: 28, position: 'absolute', top: -6, }, sidebar: { alignItems: 'center', flexDirection: 'row', width: '100%', height: sidebarHeight, paddingLeft: 6, paddingRight: 18, backgroundColor: 'listBackground', }, swipeableThreadContainer: { flex: 1, height: '100%', }, unreadIndicatorContainer: { alignItems: 'center', flexDirection: 'row', justifyContent: 'flex-start', paddingLeft: 6, width: 56, }, }; export default ChatThreadListSidebar; diff --git a/native/chat/sidebar-item.react.js b/native/chat/sidebar-item.react.js index c8a110718..af1c39d22 100644 --- a/native/chat/sidebar-item.react.js +++ b/native/chat/sidebar-item.react.js @@ -1,83 +1,83 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; -import type { SidebarInfo } from 'lib/types/thread-types.js'; +import type { SidebarThreadItem } from 'lib/shared/sidebar-item-utils.js'; import { shortAbsoluteDate } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import SingleLine from '../components/single-line.react.js'; import { useStyles } from '../themes/colors.js'; type Props = { - +sidebarInfo: SidebarInfo, + +sidebarItem: SidebarThreadItem, }; function SidebarItem(props: Props): React.Node { - const { lastUpdatedTime } = props.sidebarInfo; + const { lastUpdatedTime } = props.sidebarItem; const lastActivity = React.useMemo( () => shortAbsoluteDate(lastUpdatedTime), [lastUpdatedTime], ); - const { threadInfo } = props.sidebarInfo; + const { threadInfo } = props.sidebarItem; const { uiName } = useResolvedThreadInfo(threadInfo); const styles = useStyles(unboundStyles); const unreadStyle = threadInfo.currentUser.unread ? styles.unread : null; const singleLineStyle = React.useMemo( () => [styles.name, unreadStyle], [styles.name, unreadStyle], ); const lastActivityStyle = React.useMemo( () => [styles.lastActivity, unreadStyle], [styles.lastActivity, unreadStyle], ); const sidebarItem = React.useMemo( () => ( {uiName} {lastActivity} ), [ lastActivity, lastActivityStyle, singleLineStyle, styles.itemContainer, uiName, ], ); return sidebarItem; } const sidebarHeight = 30; const unboundStyles = { itemContainer: { flexDirection: 'row', height: sidebarHeight, alignItems: 'center', }, unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, name: { color: 'listForegroundSecondaryLabel', flex: 1, fontSize: 16, paddingLeft: 3, paddingBottom: 2, }, lastActivity: { color: 'listForegroundTertiaryLabel', fontSize: 14, marginLeft: 10, }, }; export { SidebarItem, sidebarHeight }; diff --git a/native/chat/sidebar-list-modal.react.js b/native/chat/sidebar-list-modal.react.js index 52be903b8..ce73f65c0 100644 --- a/native/chat/sidebar-list-modal.react.js +++ b/native/chat/sidebar-list-modal.react.js @@ -1,142 +1,142 @@ // @flow import * as React from 'react'; import { View } from 'react-native'; import { useSearchSidebars } from 'lib/hooks/search-threads.js'; +import type { SidebarThreadItem } from 'lib/shared/sidebar-item-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { SidebarInfo } from 'lib/types/thread-types.js'; import { SidebarItem } from './sidebar-item.react.js'; import ThreadListModal from './thread-list-modal.react.js'; import Button from '../components/button.react.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useColors, useStyles } from '../themes/colors.js'; import ExtendedArrow from '../vectors/arrow-extended.react.js'; import Arrow from '../vectors/arrow.react.js'; export type SidebarListModalParams = { +threadInfo: ThreadInfo, }; type Props = { +navigation: RootNavigationProp<'SidebarListModal'>, +route: NavigationRoute<'SidebarListModal'>, }; function SidebarListModal(props: Props): React.Node { const { listData, searchState, setSearchState, onChangeSearchInputText } = useSearchSidebars(props.route.params.threadInfo); const numOfSidebarsWithExtendedArrow = listData.length - 1; const createRenderItem = React.useCallback( (onPressItem: (threadInfo: ThreadInfo) => void) => // eslint-disable-next-line react/display-name - (row: { +item: SidebarInfo, +index: number, ... }) => { + (row: { +item: SidebarThreadItem, +index: number, ... }) => { let extendArrow: boolean = false; if (row.index < numOfSidebarsWithExtendedArrow) { extendArrow = true; } return ( ); }, [numOfSidebarsWithExtendedArrow], ); return ( ); } function Item(props: { - item: SidebarInfo, + item: SidebarThreadItem, onPressItem: (threadInfo: ThreadInfo) => void, extendArrow: boolean, }): React.Node { const { item, onPressItem, extendArrow } = props; const { threadInfo } = item; const onPressButton = React.useCallback( () => onPressItem(threadInfo), [onPressItem, threadInfo], ); const colors = useColors(); const styles = useStyles(unboundStyles); let arrow; if (extendArrow) { arrow = ( ); } else { arrow = ( ); } return ( ); } const unboundStyles = { arrow: { position: 'absolute', top: -12, }, extendedArrow: { position: 'absolute', top: -6, }, sidebar: { paddingLeft: 0, paddingRight: 5, height: 38, }, sidebarItemContainer: { flex: 1, }, sidebarRowContainer: { flex: 1, flexDirection: 'row', }, spacer: { width: 30, }, }; export default SidebarListModal; diff --git a/native/chat/thread-list-modal.react.js b/native/chat/thread-list-modal.react.js index 70982f589..9143c9f56 100644 --- a/native/chat/thread-list-modal.react.js +++ b/native/chat/thread-list-modal.react.js @@ -1,197 +1,197 @@ // @flow import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; import { FlatList, Text, TextInput, TouchableOpacity, View, } from 'react-native'; import type { ThreadSearchState } from 'lib/hooks/search-threads.js'; -import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import type { SetState } from 'lib/types/hook-types.js'; -import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { SidebarInfo } from 'lib/types/thread-types.js'; +import type { + ThreadInfo, + RawThreadInfo, +} from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { useNavigateToThread } from './message-list-types.js'; import Modal from '../components/modal.react.js'; import Search from '../components/search.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import ThreadPill from '../components/thread-pill.react.js'; import { useIndicatorStyle, useStyles } from '../themes/colors.js'; import { waitForModalInputFocus } from '../utils/timers.js'; -function keyExtractor(sidebarInfo: SidebarInfo | ChatThreadItem) { +type ChatItem = { + +threadInfo: RawThreadInfo | ThreadInfo, + ... +}; +function keyExtractor(sidebarInfo: ChatItem) { return sidebarInfo.threadInfo.id; } -function getItemLayout( - data: ?$ReadOnlyArray, - index: number, -) { +function getItemLayout(data: ?$ReadOnlyArray, index: number) { return { length: 24, offset: 24 * index, index }; } type Props = { +threadInfo: ThreadInfo, +createRenderItem: ( onPressItem: (threadInfo: ThreadInfo) => void, ) => (row: { +item: U, +index: number, ... }) => React.Node, +listData: $ReadOnlyArray, +searchState: ThreadSearchState, +setSearchState: SetState, +onChangeSearchInputText: (text: string) => mixed, +searchPlaceholder?: string, +modalTitle: string, }; -function ThreadListModal( - props: Props, -): React.Node { +function ThreadListModal(props: Props): React.Node { const { threadInfo: parentThreadInfo, searchState, setSearchState, onChangeSearchInputText, listData, createRenderItem, searchPlaceholder, modalTitle, } = props; const searchTextInputRef = React.useRef>(); const setSearchTextInputRef = React.useCallback( async (textInput: ?React.ElementRef) => { searchTextInputRef.current = textInput; if (!textInput) { return; } await waitForModalInputFocus(); if (searchTextInputRef.current) { searchTextInputRef.current.focus(); } }, [], ); const navigateToThread = useNavigateToThread(); const onPressItem = React.useCallback( (threadInfo: ThreadInfo) => { setSearchState({ text: '', results: new Set(), }); if (searchTextInputRef.current) { searchTextInputRef.current.blur(); } navigateToThread({ threadInfo }); }, [navigateToThread, setSearchState], ); const renderItem = React.useMemo( () => createRenderItem(onPressItem), [createRenderItem, onPressItem], ); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const navigation = useNavigation(); return ( {modalTitle} ); } const unboundStyles = { parentNameWrapper: { alignItems: 'flex-start', }, body: { paddingHorizontal: 16, flex: 1, }, headerTopRow: { flexDirection: 'row', justifyContent: 'space-between', height: 32, alignItems: 'center', }, header: { borderBottomColor: 'subthreadsModalSearch', borderBottomWidth: 1, height: 94, padding: 16, justifyContent: 'space-between', }, modal: { borderRadius: 8, paddingHorizontal: 0, backgroundColor: 'subthreadsModalBackground', paddingTop: 0, justifyContent: 'flex-start', }, search: { height: 40, marginVertical: 16, backgroundColor: 'subthreadsModalSearch', }, title: { color: 'listForegroundLabel', fontSize: 20, fontWeight: '500', lineHeight: 26, alignSelf: 'center', marginLeft: 2, }, closeIcon: { color: 'subthreadsModalClose', }, closeButton: { marginRight: 2, height: 40, alignItems: 'center', justifyContent: 'center', }, }; export default ThreadListModal; diff --git a/web/chat/chat-thread-list-item.react.js b/web/chat/chat-thread-list-item.react.js index 2a4fb7554..7eea87fd6 100644 --- a/web/chat/chat-thread-list-item.react.js +++ b/web/chat/chat-thread-list-item.react.js @@ -1,174 +1,173 @@ // @flow import { faServer as server, faLock as lock, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; import * as React from 'react'; import SWMansionIcon from 'lib/components/swmansion-icon.react.js'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import { useAncestorThreads } from 'lib/shared/ancestor-threads.js'; import { threadTypeIsThick } from 'lib/types/thread-types-enum.js'; import { shortAbsoluteDate } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo, useResolvedThreadInfos, } from 'lib/utils/entity-helpers.js'; import ChatThreadListItemMenu from './chat-thread-list-item-menu.react.js'; import ChatThreadListSeeMoreSidebars from './chat-thread-list-see-more-sidebars.react.js'; import ChatThreadListSidebar from './chat-thread-list-sidebar.react.js'; import css from './chat-thread-list.css'; import MessagePreview from './message-preview.react.js'; import ThreadAvatar from '../avatars/thread-avatar.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useOnClickThread, useThreadIsActive, } from '../selectors/thread-selectors.js'; type Props = { +item: ChatThreadItem, }; function ChatThreadListItem(props: Props): React.Node { const { item } = props; const { threadInfo, lastUpdatedTimeIncludingSidebars, mostRecentNonLocalMessage, } = item; const { id: threadID, currentUser } = threadInfo; const unresolvedAncestorThreads = useAncestorThreads(threadInfo); const ancestorThreads = useResolvedThreadInfos(unresolvedAncestorThreads); const lastActivity = shortAbsoluteDate(lastUpdatedTimeIncludingSidebars); const active = useThreadIsActive(threadID); const isCreateMode = useSelector( state => state.navInfo.chatMode === 'create', ); const onClick = useOnClickThread(item.threadInfo); const selectItemIfNotActiveCreation = React.useCallback( (event: SyntheticEvent) => { if (!isCreateMode || !active) { onClick(event); } }, [isCreateMode, active, onClick], ); const containerClassName = classNames({ [css.thread]: true, [css.activeThread]: active, }); const { unread } = currentUser; const titleClassName = classNames({ [css.title]: true, [css.unread]: unread, }); const lastActivityClassName = classNames({ [css.lastActivity]: true, [css.unread]: unread, [css.dark]: !unread, }); const breadCrumbsClassName = classNames(css.breadCrumbs, { [css.unread]: unread, }); let unreadDot; if (unread) { unreadDot =
; } const sidebars = item.sidebars.map((sidebarItem, index) => { if (sidebarItem.type === 'sidebar') { - const { type, ...sidebarInfo } = sidebarItem; return ( 0} - key={sidebarInfo.threadInfo.id} + key={sidebarItem.threadInfo.id} /> ); } else if (sidebarItem.type === 'seeMore') { return ( ); } else { return
; } }); const ancestorPath = ancestorThreads.map((thread, idx) => { const isNotLast = idx !== ancestorThreads.length - 1; const chevron = isNotLast && ( ); return ( {thread.uiName} {chevron} ); }); const { uiName } = useResolvedThreadInfo(threadInfo); const isThick = threadTypeIsThick(threadInfo.type); const iconClass = unread ? css.iconUnread : css.iconRead; const icon = isThick ? lock : server; const breadCrumbs = isThick ? 'Local DM' : ancestorPath; return ( <>
{unreadDot}

{breadCrumbs}

{uiName}
{lastActivity}
{sidebars} ); } export default ChatThreadListItem; diff --git a/web/chat/chat-thread-list-sidebar.react.js b/web/chat/chat-thread-list-sidebar.react.js index 40e1d5ab3..3427faa9f 100644 --- a/web/chat/chat-thread-list-sidebar.react.js +++ b/web/chat/chat-thread-list-sidebar.react.js @@ -1,48 +1,48 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; -import type { SidebarInfo } from 'lib/types/thread-types.js'; +import type { SidebarThreadItem } from 'lib/shared/sidebar-item-utils.js'; import ChatThreadListItemMenu from './chat-thread-list-item-menu.react.js'; import css from './chat-thread-list.css'; import SidebarItem from './sidebar-item.react.js'; import { useThreadIsActive } from '../selectors/thread-selectors.js'; type Props = { - +sidebarInfo: SidebarInfo, + +sidebarItem: SidebarThreadItem, +isSubsequentItem: boolean, }; function ChatThreadListSidebar(props: Props): React.Node { - const { sidebarInfo, isSubsequentItem } = props; - const { threadInfo, mostRecentNonLocalMessage } = sidebarInfo; + const { sidebarItem, isSubsequentItem } = props; + const { threadInfo, mostRecentNonLocalMessage } = sidebarItem; const { currentUser: { unread }, id: threadID, } = threadInfo; const active = useThreadIsActive(threadID); let unreadDot; if (unread) { unreadDot =
; } return (
{unreadDot}
- +
); } export default ChatThreadListSidebar; diff --git a/web/chat/sidebar-item.react.js b/web/chat/sidebar-item.react.js index 851aa8b0c..38ac2333a 100644 --- a/web/chat/sidebar-item.react.js +++ b/web/chat/sidebar-item.react.js @@ -1,56 +1,56 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; -import type { SidebarInfo } from 'lib/types/thread-types.js'; +import type { SidebarThreadItem } from 'lib/shared/sidebar-item-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import css from './chat-thread-list.css'; import { useOnClickThread } from '../selectors/thread-selectors.js'; type Props = { - +sidebarInfo: SidebarInfo, + +sidebarItem: SidebarThreadItem, +extendArrow?: boolean, }; function SidebarItem(props: Props): React.Node { const { - sidebarInfo: { threadInfo }, + sidebarItem: { threadInfo }, extendArrow = false, } = props; const { currentUser: { unread }, } = threadInfo; const onClick = useOnClickThread(threadInfo); const unreadCls = classNames(css.sidebarTitle, { [css.unread]: unread }); let arrow; if (extendArrow) { arrow = ( thread arrow ); } else { arrow = ( thread arrow ); } const { uiName } = useResolvedThreadInfo(threadInfo); return ( {arrow}
{uiName}
); } export default SidebarItem;