diff --git a/lib/hooks/child-threads.js b/lib/hooks/child-threads.js index eff8666c4..48151d977 100644 --- a/lib/hooks/child-threads.js +++ b/lib/hooks/child-threads.js @@ -1,129 +1,123 @@ // @flow import * as React from 'react'; import { useFetchSingleMostRecentMessagesFromThreads, fetchSingleMostRecentMessagesFromThreadsActionTypes, } from '../actions/message-actions.js'; import { useFilteredChatListData, type ChatThreadItem, } from '../selectors/chat-selectors.js'; import { useGlobalThreadSearchIndex } from '../selectors/nav-selectors.js'; import { childThreadInfos } from '../selectors/thread-selectors.js'; import { threadInChatList } from '../shared/thread-utils.js'; import threadWatcher from '../shared/thread-watcher.js'; import type { MinimallyEncodedRawThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyRawThreadInfo, ThreadInfo } from '../types/thread-types.js'; +import type { ThreadInfo } from '../types/thread-types.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; type ThreadFilter = { +predicate?: (thread: ThreadInfo) => boolean, +searchText?: string, }; function useFilteredChildThreads( threadID: string, filter?: ThreadFilter, ): $ReadOnlyArray { const defaultPredicate = React.useCallback(() => true, []); const { predicate = defaultPredicate, searchText = '' } = filter ?? {}; const childThreads = useSelector(state => childThreadInfos(state)[threadID]); const subchannelIDs = React.useMemo(() => { if (!childThreads) { return new Set(); } return new Set( childThreads.filter(predicate).map(threadInfo => threadInfo.id), ); }, [childThreads, predicate]); const filterSubchannels = React.useCallback( - ( - thread: ?( - | LegacyRawThreadInfo - | MinimallyEncodedRawThreadInfo - | ThreadInfo - ), - ) => { + (thread: ?(MinimallyEncodedRawThreadInfo | ThreadInfo)) => { const candidateThreadID = thread?.id; if (!candidateThreadID) { return false; } return subchannelIDs.has(candidateThreadID); }, [subchannelIDs], ); const allSubchannelsList = useFilteredChatListData(filterSubchannels); const searchIndex = useGlobalThreadSearchIndex(); const searchResultIDs = React.useMemo( () => searchIndex.getSearchResults(searchText), [searchIndex, searchText], ); const searchTextExists = !!searchText.length; const subchannelIDsNotInChatList = React.useMemo( () => new Set( allSubchannelsList .filter(item => !threadInChatList(item.threadInfo)) .map(item => item.threadInfo.id), ), [allSubchannelsList], ); React.useEffect(() => { if (!subchannelIDsNotInChatList.size) { return undefined; } subchannelIDsNotInChatList.forEach(tID => threadWatcher.watchID(tID)); return () => subchannelIDsNotInChatList.forEach(tID => threadWatcher.removeID(tID)); }, [subchannelIDsNotInChatList]); const filteredSubchannelsChatList = React.useMemo(() => { if (!searchTextExists) { return allSubchannelsList; } return allSubchannelsList.filter(item => searchResultIDs.includes(item.threadInfo.id), ); }, [allSubchannelsList, searchResultIDs, searchTextExists]); const threadIDsWithNoMessages = React.useMemo( () => new Set( filteredSubchannelsChatList .filter(item => !item.mostRecentMessageInfo) .map(item => item.threadInfo.id), ), [filteredSubchannelsChatList], ); const dispatchActionPromise = useDispatchActionPromise(); const fetchSingleMostRecentMessages = useFetchSingleMostRecentMessagesFromThreads(); React.useEffect(() => { if (!threadIDsWithNoMessages.size) { return; } void dispatchActionPromise( fetchSingleMostRecentMessagesFromThreadsActionTypes, fetchSingleMostRecentMessages(Array.from(threadIDsWithNoMessages)), ); }, [ threadIDsWithNoMessages, fetchSingleMostRecentMessages, dispatchActionPromise, ]); return filteredSubchannelsChatList; } export { useFilteredChildThreads }; diff --git a/lib/hooks/search-threads.js b/lib/hooks/search-threads.js index d6a9dd7e7..eb39e3f5a 100644 --- a/lib/hooks/search-threads.js +++ b/lib/hooks/search-threads.js @@ -1,120 +1,111 @@ // @flow import * as React from 'react'; import { type ChatThreadItem, useFilteredChatListData, } from '../selectors/chat-selectors.js'; import { useThreadSearchIndex } from '../selectors/nav-selectors.js'; import { sidebarInfoSelector } from '../selectors/thread-selectors.js'; import { threadIsChannel } from '../shared/thread-utils.js'; import type { SetState } from '../types/hook-types.js'; import type { MinimallyEncodedRawThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { - SidebarInfo, - ThreadInfo, - LegacyRawThreadInfo, -} from '../types/thread-types.js'; +import type { SidebarInfo, ThreadInfo } from '../types/thread-types.js'; import { useSelector } from '../utils/redux-utils.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( 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, ], ); } function useSearchSidebars( threadInfo: ThreadInfo, ): SearchThreadsResult { const childThreadInfos = useSelector( state => sidebarInfoSelector(state)[threadInfo.id] ?? [], ); return useSearchThreads(threadInfo, childThreadInfos); } function useSearchSubchannels( threadInfo: ThreadInfo, ): SearchThreadsResult { const filterFunc = React.useCallback( - ( - thread: ?( - | ThreadInfo - | LegacyRawThreadInfo - | MinimallyEncodedRawThreadInfo - ), - ) => threadIsChannel(thread) && thread?.parentThreadID === threadInfo.id, + (thread: ?(ThreadInfo | MinimallyEncodedRawThreadInfo)) => + threadIsChannel(thread) && thread?.parentThreadID === threadInfo.id, [threadInfo.id], ); const childThreadInfos = useFilteredChatListData(filterFunc); return useSearchThreads(threadInfo, childThreadInfos); } export { useSearchSubchannels, useSearchSidebars }; diff --git a/lib/reducers/integrity-reducer.js b/lib/reducers/integrity-reducer.js index 193af4355..c18ce873a 100644 --- a/lib/reducers/integrity-reducer.js +++ b/lib/reducers/integrity-reducer.js @@ -1,91 +1,90 @@ // @flow import { setClientDBStoreActionType } from '../actions/client-db-store-actions.js'; import { updateIntegrityStoreActionType } from '../actions/integrity-actions.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { keyserverAuthActionTypes, logInActionTypes, keyserverRegisterActionTypes, } from '../actions/user-actions.js'; import type { ThreadStoreOperation } from '../ops/thread-store-ops'; import type { IntegrityStore } from '../types/integrity-types'; import type { MinimallyEncodedRawThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseAction } from '../types/redux-types.js'; import { fullStateSyncActionType } from '../types/socket-types.js'; -import type { LegacyRawThreadInfo } from '../types/thread-types.js'; import { hash } from '../utils/objects.js'; function reduceIntegrityStore( state: IntegrityStore, action: BaseAction, threadInfos: { - +[string]: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo, + +[string]: MinimallyEncodedRawThreadInfo, }, threadStoreOperations: $ReadOnlyArray, ): IntegrityStore { if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success || action.type === keyserverRegisterActionTypes.success || action.type === fullStateSyncActionType || (action.type === setClientDBStoreActionType && !!action.payload.threadStore && state.threadHashingStatus !== 'completed') ) { return { threadHashes: {}, threadHashingStatus: 'starting' }; } else if (action.type === keyserverAuthActionTypes.success) { return { ...state, threadHashingStatus: 'starting' }; } let newState = state; if (action.type === updateIntegrityStoreActionType) { if (action.payload.threadIDsToHash) { const newThreadHashes = Object.fromEntries( action.payload.threadIDsToHash .map(id => [id, threadInfos[id]]) .filter(([, info]) => !!info) .map(([id, info]) => [id, hash(info)]), ); newState = { ...newState, threadHashes: { ...newState.threadHashes, ...newThreadHashes, }, }; } if (action.payload.threadHashingStatus) { newState = { ...newState, threadHashingStatus: action.payload.threadHashingStatus, }; } } if (threadStoreOperations.length === 0) { return newState; } let processedThreadHashes = { ...newState.threadHashes }; let threadHashingStatus = newState.threadHashingStatus; for (const operation of threadStoreOperations) { if (operation.type === 'replace') { processedThreadHashes[operation.payload.id] = hash( operation.payload.threadInfo, ); } else if (operation.type === 'remove') { for (const id of operation.payload.ids) { delete processedThreadHashes[id]; } } else if (operation.type === 'remove_all') { processedThreadHashes = {}; threadHashingStatus = 'completed'; } } return { ...newState, threadHashes: processedThreadHashes, threadHashingStatus, }; } export { reduceIntegrityStore }; diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js index 70b74806d..4366eff2c 100644 --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -1,716 +1,707 @@ // @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 { threadInfoSelector, sidebarInfoSelector, threadInfoFromSourceMessageIDSelector, } from './thread-selectors.js'; import { messageKey, robotextForMessageInfo, createMessageInfo, getMostRecentNonLocalMessageID, sortMessageInfoList, } from '../shared/message-utils.js'; import { threadIsPending, threadIsTopLevel, threadInChatList, } from '../shared/thread-utils.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type MessageInfo, type MessageStore, type ComposableMessageInfo, type RobotextMessageInfo, type LocalMessageInfo, isComposableMessageType, } from '../types/message-types.js'; import type { MinimallyEncodedRawThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import { type SidebarInfo, type ThreadInfo, maxReadSidebars, maxUnreadSidebars, - type LegacyRawThreadInfo, } from '../types/thread-types.js'; import type { UserInfo, AccountUserInfo, RelativeUserInfo, } from '../types/user-types.js'; import { threeDays } from '../utils/date-utils.js'; import type { EntityText } from '../utils/entity-text.js'; import memoize2 from '../utils/memoize.js'; import { useSelector } from '../utils/redux-utils.js'; export type SidebarItem = | { ...SidebarInfo, +type: 'sidebar', } | { +type: 'seeMore', +unread: boolean, } | { +type: 'spacer' }; export type ChatThreadItem = { +type: 'chatThreadItem', +threadInfo: ThreadInfo, +mostRecentMessageInfo: ?MessageInfo, +mostRecentNonLocalMessage: ?string, +lastUpdatedTime: number, +lastUpdatedTimeIncludingSidebars: number, +sidebars: $ReadOnlyArray, +pendingPersonalThreadUserInfo?: UserInfo, }; const messageInfoSelector: (state: BaseAppState<>) => { +[id: string]: ?MessageInfo, } = createObjectSelector( (state: BaseAppState<>) => state.messageStore.messages, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<>) => state.userStore.userInfos, threadInfoSelector, createMessageInfo, ); function isEmptyMediaMessage(messageInfo: MessageInfo): boolean { return ( (messageInfo.type === messageTypes.MULTIMEDIA || messageInfo.type === messageTypes.IMAGES) && messageInfo.media.length === 0 ); } function getMostRecentMessageInfo( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { +[id: string]: ?MessageInfo }, ): ?MessageInfo { const thread = messageStore.threads[threadInfo.id]; if (!thread) { return null; } for (const messageID of thread.messageIDs) { const messageInfo = messages[messageID]; if (!messageInfo || isEmptyMediaMessage(messageInfo)) { continue; } return messageInfo; } return null; } function getLastUpdatedTime( threadInfo: ThreadInfo, mostRecentMessageInfo: ?MessageInfo, ): number { return mostRecentMessageInfo ? mostRecentMessageInfo.time : threadInfo.creationTime; } function createChatThreadItem( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { +[id: string]: ?MessageInfo }, sidebarInfos: ?$ReadOnlyArray, ): ChatThreadItem { const mostRecentMessageInfo = getMostRecentMessageInfo( threadInfo, messageStore, messages, ); const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID( threadInfo.id, messageStore, ); const lastUpdatedTime = getLastUpdatedTime(threadInfo, mostRecentMessageInfo); const sidebars = sidebarInfos ?? []; const allSidebarItems = sidebars.map(sidebarInfo => ({ type: 'sidebar', ...sidebarInfo, })); const lastUpdatedTimeIncludingSidebars = allSidebarItems.length > 0 ? Math.max(lastUpdatedTime, allSidebarItems[0].lastUpdatedTime) : lastUpdatedTime; const numUnreadSidebars = allSidebarItems.filter( sidebar => sidebar.threadInfo.currentUser.unread, ).length; let numReadSidebarsToShow = maxReadSidebars - numUnreadSidebars; const threeDaysAgo = Date.now() - threeDays; const sidebarItems: 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 { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo, mostRecentNonLocalMessage, lastUpdatedTime, lastUpdatedTimeIncludingSidebars, sidebars: sidebarItems, }; } const chatListData: (state: BaseAppState<>) => $ReadOnlyArray = createSelector( threadInfoSelector, (state: BaseAppState<>) => state.messageStore, messageInfoSelector, sidebarInfoSelector, ( threadInfos: { +[id: string]: ThreadInfo }, messageStore: MessageStore, messageInfos: { +[id: string]: ?MessageInfo }, sidebarInfos: { +[id: string]: $ReadOnlyArray }, ): $ReadOnlyArray => getChatThreadItems( threadInfos, messageStore, messageInfos, sidebarInfos, threadIsTopLevel, ), ); function useFlattenedChatListData(): $ReadOnlyArray { return useFilteredChatListData(threadInChatList); } function useFilteredChatListData( filterFunction: ( - threadInfo: ?( - | ThreadInfo - | LegacyRawThreadInfo - | MinimallyEncodedRawThreadInfo - ), + threadInfo: ?(ThreadInfo | MinimallyEncodedRawThreadInfo), ) => boolean, ): $ReadOnlyArray { const threadInfos = useSelector(threadInfoSelector); const messageInfos = useSelector(messageInfoSelector); const sidebarInfos = useSelector(sidebarInfoSelector); const messageStore = useSelector(state => state.messageStore); return React.useMemo( () => getChatThreadItems( threadInfos, messageStore, messageInfos, sidebarInfos, filterFunction, ), [messageInfos, messageStore, sidebarInfos, filterFunction, threadInfos], ); } function getChatThreadItems( threadInfos: { +[id: string]: ThreadInfo }, messageStore: MessageStore, messageInfos: { +[id: string]: ?MessageInfo }, sidebarInfos: { +[id: string]: $ReadOnlyArray }, filterFunction: ( - threadInfo: ?( - | ThreadInfo - | LegacyRawThreadInfo - | MinimallyEncodedRawThreadInfo - ), + threadInfo: ?(ThreadInfo | MinimallyEncodedRawThreadInfo), ) => boolean, ): $ReadOnlyArray { return _flow( _filter(filterFunction), _map((threadInfo: ThreadInfo): ChatThreadItem => createChatThreadItem( threadInfo, messageStore, messageInfos, sidebarInfos[threadInfo.id], ), ), _orderBy('lastUpdatedTimeIncludingSidebars')('desc'), )(threadInfos); } export type RobotextChatMessageInfoItem = { +itemType: 'message', +messageInfoType: 'robotext', +messageInfo: RobotextMessageInfo, +startsConversation: boolean, +startsCluster: boolean, endsCluster: boolean, +robotext: EntityText, +threadCreatedFromMessage: ?ThreadInfo, +reactions: ReactionInfo, }; export type ChatMessageInfoItem = | RobotextChatMessageInfoItem | { +itemType: 'message', +messageInfoType: 'composable', +messageInfo: ComposableMessageInfo, +localMessageInfo: ?LocalMessageInfo, +startsConversation: boolean, +startsCluster: boolean, endsCluster: boolean, +threadCreatedFromMessage: ?ThreadInfo, +reactions: ReactionInfo, +hasBeenEdited: boolean, +isPinned: boolean, }; 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 = messageInfo.id && threadInfos[threadID]?.type !== threadTypes.SIDEBAR ? 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; })(); 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 threadInfo = threadInfos[threadID]; const parentThreadInfo = threadInfo?.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; const robotext = robotextForMessageInfo( originalMessageInfo, threadInfo, parentThreadInfo, ); chatMessageItems.push({ itemType: 'message', messageInfoType: 'robotext', messageInfo: 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 || threadInfo.type !== threadTypes.SIDEBAR || !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 || threadInfo.type !== threadTypes.SIDEBAR || !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, createChatThreadItem, chatListData, createChatMessageItems, messageListData, useFlattenedChatListData, useFilteredChatListData, useMessageListData, }; diff --git a/lib/selectors/nav-selectors.js b/lib/selectors/nav-selectors.js index c6b3ac1b1..b45603672 100644 --- a/lib/selectors/nav-selectors.js +++ b/lib/selectors/nav-selectors.js @@ -1,217 +1,211 @@ // @flow import * as React from 'react'; import { createSelector } from 'reselect'; import { useENSNames } from '../hooks/ens-cache.js'; import SearchIndex from '../shared/search-index.js'; import { memberHasAdminPowers } from '../shared/thread-utils.js'; import type { Platform } from '../types/device-types.js'; import { type CalendarQuery, defaultCalendarQuery, } from '../types/entry-types.js'; import type { CalendarFilter } from '../types/filter-types.js'; import type { MinimallyEncodedRawThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseNavInfo } from '../types/nav-types.js'; import type { BaseAppState } from '../types/redux-types.js'; -import type { - RelativeMemberInfo, - ThreadInfo, - LegacyRawThreadInfo, -} from '../types/thread-types'; +import type { RelativeMemberInfo, ThreadInfo } from '../types/thread-types'; import type { UserInfo } from '../types/user-types.js'; import { getConfig } from '../utils/config.js'; import { values } from '../utils/objects.js'; import { useSelector } from '../utils/redux-utils.js'; function timeUntilCalendarRangeExpiration( lastUserInteractionCalendar: number, ): ?number { const inactivityLimit = getConfig().calendarRangeInactivityLimit; if (inactivityLimit === null || inactivityLimit === undefined) { return null; } return lastUserInteractionCalendar + inactivityLimit - Date.now(); } function calendarRangeExpired(lastUserInteractionCalendar: number): boolean { const timeUntil = timeUntilCalendarRangeExpiration( lastUserInteractionCalendar, ); if (timeUntil === null || timeUntil === undefined) { return false; } return timeUntil <= 0; } const currentCalendarQuery: ( state: BaseAppState<>, ) => (calendarActive: boolean) => CalendarQuery = createSelector( (state: BaseAppState<>) => state.entryStore.lastUserInteractionCalendar, (state: BaseAppState<>) => state.navInfo, (state: BaseAppState<>) => state.calendarFilters, ( lastUserInteractionCalendar: number, navInfo: BaseNavInfo, calendarFilters: $ReadOnlyArray, ) => { // Return a function since we depend on the time of evaluation return (calendarActive: boolean, platform: ?Platform): CalendarQuery => { if (calendarActive) { return { startDate: navInfo.startDate, endDate: navInfo.endDate, filters: calendarFilters, }; } if (calendarRangeExpired(lastUserInteractionCalendar)) { return defaultCalendarQuery(platform); } return { startDate: navInfo.startDate, endDate: navInfo.endDate, filters: calendarFilters, }; }; }, ); // Without allAtOnce, useThreadSearchIndex and useUserSearchIndex are very // expensive. useENSNames would trigger their recalculation for each ENS name // as it streams in, but we would prefer to trigger their recaculation just // once for every update of the underlying Redux data. const useENSNamesOptions = { allAtOnce: true }; function useUserSearchIndex( userInfos: $ReadOnlyArray, ): SearchIndex { const membersWithENSNames = useENSNames(userInfos, useENSNamesOptions); const memberMap = React.useMemo(() => { const result = new Map(); for (const userInfo of membersWithENSNames) { result.set(userInfo.id, userInfo); } return result; }, [membersWithENSNames]); return React.useMemo(() => { const searchIndex = new SearchIndex(); for (const userInfo of userInfos) { const searchTextArray = []; const rawUsername = userInfo.username; if (rawUsername) { searchTextArray.push(rawUsername); } const resolvedUserInfo = memberMap.get(userInfo.id); const resolvedUsername = resolvedUserInfo?.username; if (resolvedUsername && resolvedUsername !== rawUsername) { searchTextArray.push(resolvedUsername); } searchIndex.addEntry(userInfo.id, searchTextArray.join(' ')); } return searchIndex; }, [userInfos, memberMap]); } function useThreadSearchIndex( - threadInfos: $ReadOnlyArray< - LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, - >, + threadInfos: $ReadOnlyArray, ): SearchIndex { const userInfos = useSelector(state => state.userStore.userInfos); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const nonViewerMembers = React.useMemo(() => { const allMembersOfAllThreads = new Map(); for (const threadInfo of threadInfos) { for (const member of threadInfo.members) { const isParentAdmin = memberHasAdminPowers(member); if (!member.role && !isParentAdmin) { continue; } if (member.id === viewerID) { continue; } if (!allMembersOfAllThreads.has(member.id)) { const userInfo = userInfos[member.id]; if (userInfo?.username) { allMembersOfAllThreads.set(member.id, userInfo); } } } } return [...allMembersOfAllThreads.values()]; }, [threadInfos, userInfos, viewerID]); const nonViewerMembersWithENSNames = useENSNames( nonViewerMembers, useENSNamesOptions, ); const memberMap = React.useMemo(() => { const result = new Map(); for (const userInfo of nonViewerMembersWithENSNames) { result.set(userInfo.id, userInfo); } return result; }, [nonViewerMembersWithENSNames]); return React.useMemo(() => { const searchIndex = new SearchIndex(); for (const threadInfo of threadInfos) { const searchTextArray = []; if (threadInfo.name) { searchTextArray.push(threadInfo.name); } if (threadInfo.description) { searchTextArray.push(threadInfo.description); } for (const member of threadInfo.members) { const isParentAdmin = memberHasAdminPowers(member); if (!member.role && !isParentAdmin) { continue; } if (member.id === viewerID) { continue; } const userInfo = userInfos[member.id]; const rawUsername = userInfo?.username; if (rawUsername) { searchTextArray.push(rawUsername); } const resolvedUserInfo = memberMap.get(member.id); const username = resolvedUserInfo?.username; if (username && username !== rawUsername) { searchTextArray.push(username); } } searchIndex.addEntry(threadInfo.id, searchTextArray.join(' ')); } return searchIndex; }, [threadInfos, viewerID, userInfos, memberMap]); } function useGlobalThreadSearchIndex(): SearchIndex { const threadInfos = useSelector(state => state.threadStore.threadInfos); const threadInfosArray = React.useMemo( () => values(threadInfos), [threadInfos], ); return useThreadSearchIndex(threadInfosArray); } export { timeUntilCalendarRangeExpiration, currentCalendarQuery, useUserSearchIndex, useThreadSearchIndex, useGlobalThreadSearchIndex, }; diff --git a/lib/selectors/thread-selectors.js b/lib/selectors/thread-selectors.js index b5e9c52fb..394242c1d 100644 --- a/lib/selectors/thread-selectors.js +++ b/lib/selectors/thread-selectors.js @@ -1,552 +1,552 @@ // @flow import _compact from 'lodash/fp/compact.js'; import _filter from 'lodash/fp/filter.js'; import _flow from 'lodash/fp/flow.js'; import _map from 'lodash/fp/map.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _orderBy from 'lodash/fp/orderBy.js'; import _some from 'lodash/fp/some.js'; import _sortBy from 'lodash/fp/sortBy.js'; import _memoize from 'lodash/memoize.js'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import { filteredThreadIDsSelector, includeDeletedSelector, } from './calendar-filter-selectors.js'; import { relativeMemberInfoSelectorForMembersOfThread } from './user-selectors.js'; import genesis from '../facts/genesis.js'; import { getAvatarForThread, getRandomDefaultEmojiAvatar, } from '../shared/avatar-utils.js'; import { createEntryInfo } from '../shared/entry-utils.js'; import { getMostRecentNonLocalMessageID } from '../shared/message-utils.js'; import { threadInHomeChatList, threadInBackgroundChatList, threadInFilterList, threadInfoFromRawThreadInfo, threadHasPermission, threadInChatList, threadHasAdminRole, roleIsAdminRole, threadIsPending, getPendingThreadID, } from '../shared/thread-utils.js'; import type { ClientAvatar, ClientEmojiAvatar } from '../types/avatar-types'; import type { EntryInfo } from '../types/entry-types.js'; import type { MessageStore, RawMessageInfo } from '../types/message-types.js'; import type { MinimallyEncodedRawThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { threadTypes, threadTypeIsCommunityRoot, type ThreadType, } from '../types/thread-types-enum.js'; import type { SidebarInfo, RelativeMemberInfo, ThreadInfo, RawThreadInfos, - LegacyRawThreadInfo, + MinimallyEncodedRawThreadInfos, } from '../types/thread-types.js'; import { dateString, dateFromString } from '../utils/date-utils.js'; import { values } from '../utils/objects.js'; const _mapValuesWithKeys = _mapValues.convert({ cap: false }); type ThreadInfoSelectorType = (state: BaseAppState<>) => { +[id: string]: ThreadInfo, }; const threadInfoSelector: ThreadInfoSelectorType = createObjectSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<>) => state.userStore.userInfos, threadInfoFromRawThreadInfo, ); const communityThreadSelector: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( threadInfoSelector, (threadInfos: { +[id: string]: ThreadInfo }) => { const result = []; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (!threadTypeIsCommunityRoot(threadInfo.type)) { continue; } result.push(threadInfo); } return result; }, ); const canBeOnScreenThreadInfos: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( threadInfoSelector, (threadInfos: { +[id: string]: ThreadInfo }) => { const result = []; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (!threadInFilterList(threadInfo)) { continue; } result.push(threadInfo); } return result; }, ); const onScreenThreadInfos: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( filteredThreadIDsSelector, canBeOnScreenThreadInfos, ( inputThreadIDs: ?$ReadOnlySet, threadInfos: $ReadOnlyArray, ): $ReadOnlyArray => { const threadIDs = inputThreadIDs; if (!threadIDs) { return threadInfos; } return threadInfos.filter(threadInfo => threadIDs.has(threadInfo.id)); }, ); const onScreenEntryEditableThreadInfos: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( onScreenThreadInfos, (threadInfos: $ReadOnlyArray): $ReadOnlyArray => threadInfos.filter(threadInfo => threadHasPermission(threadInfo, threadPermissions.EDIT_ENTRIES), ), ); const entryInfoSelector: (state: BaseAppState<>) => { +[id: string]: EntryInfo, } = createObjectSelector( (state: BaseAppState<>) => state.entryStore.entryInfos, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<>) => state.userStore.userInfos, createEntryInfo, ); // "current" means within startDate/endDate range, not deleted, and in // onScreenThreadInfos const currentDaysToEntries: (state: BaseAppState<>) => { +[dayString: string]: EntryInfo[], } = createSelector( entryInfoSelector, (state: BaseAppState<>) => state.entryStore.daysToEntries, (state: BaseAppState<>) => state.navInfo.startDate, (state: BaseAppState<>) => state.navInfo.endDate, onScreenThreadInfos, includeDeletedSelector, ( entryInfos: { +[id: string]: EntryInfo }, daysToEntries: { +[day: string]: string[] }, startDateString: string, endDateString: string, onScreen: $ReadOnlyArray, includeDeleted: boolean, ) => { const allDaysWithinRange: { [string]: string[] } = {}, startDate = dateFromString(startDateString), endDate = dateFromString(endDateString); for ( const curDate = startDate; curDate <= endDate; curDate.setDate(curDate.getDate() + 1) ) { allDaysWithinRange[dateString(curDate)] = []; } return _mapValuesWithKeys((_: string[], dayString: string) => _flow( _map((entryID: string) => entryInfos[entryID]), _compact, _filter( (entryInfo: EntryInfo) => (includeDeleted || !entryInfo.deleted) && _some(['id', entryInfo.threadID])(onScreen), ), _sortBy('creationTime'), )(daysToEntries[dayString] ? daysToEntries[dayString] : []), )(allDaysWithinRange); }, ); const childThreadInfos: (state: BaseAppState<>) => { +[id: string]: $ReadOnlyArray, } = createSelector( threadInfoSelector, (threadInfos: { +[id: string]: ThreadInfo }) => { const result: { [string]: ThreadInfo[] } = {}; for (const id in threadInfos) { const threadInfo = threadInfos[id]; const parentThreadID = threadInfo.parentThreadID; if (parentThreadID === null || parentThreadID === undefined) { continue; } if (result[parentThreadID] === undefined) { result[parentThreadID] = ([]: ThreadInfo[]); } result[parentThreadID].push(threadInfo); } return result; }, ); const containedThreadInfos: (state: BaseAppState<>) => { +[id: string]: $ReadOnlyArray, } = createSelector( threadInfoSelector, (threadInfos: { +[id: string]: ThreadInfo }) => { const result: { [string]: ThreadInfo[] } = {}; for (const id in threadInfos) { const threadInfo = threadInfos[id]; const { containingThreadID } = threadInfo; if (containingThreadID === null || containingThreadID === undefined) { continue; } if (result[containingThreadID] === undefined) { result[containingThreadID] = ([]: ThreadInfo[]); } result[containingThreadID].push(threadInfo); } return result; }, ); function getMostRecentRawMessageInfo( threadInfo: ThreadInfo, messageStore: MessageStore, ): ?RawMessageInfo { const thread = messageStore.threads[threadInfo.id]; if (!thread) { return null; } for (const messageID of thread.messageIDs) { return messageStore.messages[messageID]; } return null; } const sidebarInfoSelector: (state: BaseAppState<>) => { +[id: string]: $ReadOnlyArray, } = createObjectSelector( childThreadInfos, (state: BaseAppState<>) => state.messageStore, (childThreads: $ReadOnlyArray, messageStore: MessageStore) => { const sidebarInfos = []; for (const childThreadInfo of childThreads) { if ( !threadInChatList(childThreadInfo) || childThreadInfo.type !== threadTypes.SIDEBAR ) { continue; } const mostRecentRawMessageInfo = getMostRecentRawMessageInfo( childThreadInfo, messageStore, ); const lastUpdatedTime = mostRecentRawMessageInfo?.time ?? childThreadInfo.creationTime; const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID( childThreadInfo.id, messageStore, ); sidebarInfos.push({ threadInfo: childThreadInfo, lastUpdatedTime, mostRecentNonLocalMessage, }); } return _orderBy('lastUpdatedTime')('desc')(sidebarInfos); }, ); const unreadCount: (state: BaseAppState<>) => number = createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, - (threadInfos: RawThreadInfos): number => + (threadInfos: MinimallyEncodedRawThreadInfos): number => values(threadInfos).filter( threadInfo => threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); const unreadBackgroundCount: (state: BaseAppState<>) => number = createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, - (threadInfos: RawThreadInfos): number => + (threadInfos: MinimallyEncodedRawThreadInfos): number => values(threadInfos).filter( threadInfo => threadInBackgroundChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); const baseUnreadCountSelectorForCommunity: ( communityID: string, ) => (BaseAppState<>) => number = (communityID: string) => createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, - (threadInfos: RawThreadInfos): number => + (threadInfos: MinimallyEncodedRawThreadInfos): number => Object.values(threadInfos).filter( threadInfo => threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread && (communityID === threadInfo.community || communityID === threadInfo.id), ).length, ); const unreadCountSelectorForCommunity: ( communityID: string, ) => (state: BaseAppState<>) => number = _memoize( baseUnreadCountSelectorForCommunity, ); const baseAncestorThreadInfos: ( threadID: string, ) => (BaseAppState<>) => $ReadOnlyArray = (threadID: string) => createSelector( (state: BaseAppState<>) => threadInfoSelector(state), (threadInfos: { +[id: string]: ThreadInfo, }): $ReadOnlyArray => { const pathComponents: ThreadInfo[] = []; let node: ?ThreadInfo = threadInfos[threadID]; while (node) { pathComponents.push(node); node = node.parentThreadID ? threadInfos[node.parentThreadID] : null; } pathComponents.reverse(); return pathComponents; }, ); const ancestorThreadInfos: ( threadID: string, ) => (state: BaseAppState<>) => $ReadOnlyArray = _memoize( baseAncestorThreadInfos, ); const baseOtherUsersButNoOtherAdmins: ( threadID: string, ) => (BaseAppState<>) => boolean = (threadID: string) => createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos[threadID], relativeMemberInfoSelectorForMembersOfThread(threadID), ( - threadInfo: ?LegacyRawThreadInfo | ?MinimallyEncodedRawThreadInfo, + threadInfo: ?MinimallyEncodedRawThreadInfo, members: $ReadOnlyArray, ): boolean => { if (!threadInfo) { return false; } if (!threadHasAdminRole(threadInfo)) { return false; } let otherUsersExist = false; let otherAdminsExist = false; for (const member of members) { const role = member.role; if (role === undefined || role === null || member.isViewer) { continue; } otherUsersExist = true; if (roleIsAdminRole(threadInfo?.roles[role])) { otherAdminsExist = true; break; } } return otherUsersExist && !otherAdminsExist; }, ); const otherUsersButNoOtherAdmins: ( threadID: string, ) => (state: BaseAppState<>) => boolean = _memoize( baseOtherUsersButNoOtherAdmins, ); function mostRecentlyReadThread( messageStore: MessageStore, threadInfos: RawThreadInfos, ): ?string { let mostRecent = null; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threadInfo.currentUser.unread) { continue; } const threadMessageInfo = messageStore.threads[threadID]; if (!threadMessageInfo) { continue; } const mostRecentMessageTime = threadMessageInfo.messageIDs.length === 0 ? threadInfo.creationTime : messageStore.messages[threadMessageInfo.messageIDs[0]].time; if (mostRecent && mostRecent.time >= mostRecentMessageTime) { continue; } const topLevelThreadID = threadInfo.type === threadTypes.SIDEBAR ? threadInfo.parentThreadID : threadID; mostRecent = { threadID: topLevelThreadID, time: mostRecentMessageTime }; } return mostRecent ? mostRecent.threadID : null; } const mostRecentlyReadThreadSelector: (state: BaseAppState<>) => ?string = createSelector( (state: BaseAppState<>) => state.messageStore, (state: BaseAppState<>) => state.threadStore.threadInfos, mostRecentlyReadThread, ); const threadInfoFromSourceMessageIDSelector: (state: BaseAppState<>) => { +[id: string]: ThreadInfo, } = createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, threadInfoSelector, ( rawThreadInfos: RawThreadInfos, threadInfos: { +[id: string]: ThreadInfo }, ) => { const pendingToRealizedThreadIDs = pendingToRealizedThreadIDsSelector(rawThreadInfos); const result: { [string]: ThreadInfo } = {}; for (const realizedID of pendingToRealizedThreadIDs.values()) { const threadInfo = threadInfos[realizedID]; if (threadInfo && threadInfo.sourceMessageID) { result[threadInfo.sourceMessageID] = threadInfo; } } return result; }, ); const pendingToRealizedThreadIDsSelector: ( rawThreadInfos: RawThreadInfos, ) => $ReadOnlyMap = createSelector( (rawThreadInfos: RawThreadInfos) => rawThreadInfos, (rawThreadInfos: RawThreadInfos) => { const result = new Map(); for (const threadID in rawThreadInfos) { const rawThreadInfo = rawThreadInfos[threadID]; if ( threadIsPending(threadID) || (rawThreadInfo.parentThreadID !== genesis.id && rawThreadInfo.type !== threadTypes.SIDEBAR) ) { continue; } const actualMemberIDs = rawThreadInfo.members .filter(member => member.role) .map(member => member.id); const pendingThreadID = getPendingThreadID( rawThreadInfo.type, actualMemberIDs, rawThreadInfo.sourceMessageID, ); const existingResult = result.get(pendingThreadID); if ( !existingResult || rawThreadInfos[existingResult].creationTime > rawThreadInfo.creationTime ) { result.set(pendingThreadID, threadID); } } return result; }, ); const baseSavedEmojiAvatarSelectorForThread: ( threadID: string, containingThreadID: ?string, ) => (BaseAppState<>) => () => ClientAvatar = ( threadID: string, containingThreadID: ?string, ) => createSelector( (state: BaseAppState<>) => threadInfoSelector(state)[threadID], (state: BaseAppState<>) => containingThreadID ? threadInfoSelector(state)[containingThreadID] : null, (threadInfo: ThreadInfo, containingThreadInfo: ?ThreadInfo) => { return () => { let threadAvatar = getAvatarForThread(threadInfo, containingThreadInfo); if (threadAvatar.type !== 'emoji') { threadAvatar = getRandomDefaultEmojiAvatar(); } return threadAvatar; }; }, ); const savedEmojiAvatarSelectorForThread: ( threadID: string, containingThreadID: ?string, ) => (state: BaseAppState<>) => () => ClientEmojiAvatar = _memoize( baseSavedEmojiAvatarSelectorForThread, ); const baseThreadInfosSelectorForThreadType: ( threadType: ThreadType, ) => (BaseAppState<>) => $ReadOnlyArray = ( threadType: ThreadType, ) => createSelector( (state: BaseAppState<>) => threadInfoSelector(state), (threadInfos: { +[id: string]: ThreadInfo, }): $ReadOnlyArray => { const result = []; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threadInfo.type === threadType) { result.push(threadInfo); } } return result; }, ); const threadInfosSelectorForThreadType: ( threadType: ThreadType, ) => (state: BaseAppState<>) => $ReadOnlyArray = _memoize( baseThreadInfosSelectorForThreadType, ); export { ancestorThreadInfos, threadInfoSelector, communityThreadSelector, onScreenThreadInfos, onScreenEntryEditableThreadInfos, entryInfoSelector, currentDaysToEntries, childThreadInfos, containedThreadInfos, unreadCount, unreadBackgroundCount, unreadCountSelectorForCommunity, otherUsersButNoOtherAdmins, mostRecentlyReadThread, mostRecentlyReadThreadSelector, sidebarInfoSelector, threadInfoFromSourceMessageIDSelector, pendingToRealizedThreadIDsSelector, savedEmojiAvatarSelectorForThread, threadInfosSelectorForThreadType, }; diff --git a/lib/shared/avatar-utils.js b/lib/shared/avatar-utils.js index b2acddb21..f76dfb594 100644 --- a/lib/shared/avatar-utils.js +++ b/lib/shared/avatar-utils.js @@ -1,366 +1,366 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import stringHash from 'string-hash'; import { selectedThreadColors } from './color-utils.js'; import { threadOtherMembers } from './thread-utils.js'; import genesis from '../facts/genesis.js'; import { useENSAvatar } from '../hooks/ens-cache.js'; import { threadInfoSelector } from '../selectors/thread-selectors.js'; import { getETHAddressForUserInfo } from '../shared/account-utils.js'; import type { ClientEmojiAvatar, ClientAvatar, ResolvedClientAvatar, GenericUserInfoWithAvatar, } from '../types/avatar-types.js'; import type { MinimallyEncodedRawThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { LegacyRawThreadInfo, ThreadInfo } from '../types/thread-types.js'; import type { UserInfos } from '../types/user-types.js'; import { useSelector } from '../utils/redux-utils.js'; import { ashoatKeyserverID } from '../utils/validation-utils.js'; const defaultAnonymousUserEmojiAvatar: ClientEmojiAvatar = { color: selectedThreadColors[4], emoji: '๐Ÿ‘ค', type: 'emoji', }; const defaultEmojiAvatars: $ReadOnlyArray = [ { color: selectedThreadColors[0], emoji: '๐Ÿ˜€', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐Ÿ˜ƒ', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '๐Ÿ˜„', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '๐Ÿ˜', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐Ÿ˜†', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐Ÿ™‚', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐Ÿ˜‰', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿ˜Š', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐Ÿ˜‡', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐Ÿฅฐ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐Ÿ˜', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐Ÿคฉ', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '๐Ÿฅณ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐Ÿ˜', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '๐Ÿ˜Ž', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐Ÿง', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿฅธ', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '๐Ÿค—', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '๐Ÿ˜ค', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '๐Ÿคฏ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐Ÿค”', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐Ÿซก', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐Ÿคซ', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '๐Ÿ˜ฎ', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐Ÿ˜ฒ', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '๐Ÿค ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐Ÿค‘', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿ‘ฉโ€๐Ÿš€', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '๐Ÿฅท', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '๐Ÿ‘ป', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐Ÿ‘พ', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '๐Ÿค–', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '๐Ÿ˜บ', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐Ÿ˜ธ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐Ÿ˜น', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐Ÿ˜ป', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐ŸŽฉ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐Ÿ‘‘', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐Ÿถ', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '๐Ÿฑ', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '๐Ÿญ', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '๐Ÿน', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐Ÿฐ', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '๐Ÿป', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐Ÿผ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿปโ€โ„๏ธ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐Ÿจ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐Ÿฏ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐Ÿฆ', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '๐Ÿธ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿ”', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐Ÿง', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '๐Ÿฆ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐Ÿค', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿฆ„', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐Ÿ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐Ÿฆ‹', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '๐Ÿฌ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐Ÿณ', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '๐Ÿ‹', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '๐Ÿฆˆ', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐Ÿฆญ', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '๐Ÿ˜', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐Ÿฆ›', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐Ÿ“', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐Ÿฆƒ', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '๐Ÿฆฉ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐Ÿฆ”', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '๐Ÿ…', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐Ÿ†', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '๐Ÿฆ“', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐Ÿฆ’', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐Ÿฆ˜', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐ŸŽ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐Ÿ•', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐Ÿฉ', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '๐Ÿฆฎ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐Ÿˆ', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '๐Ÿฆš', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐Ÿฆœ', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐Ÿฆข', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '๐Ÿ•Š๏ธ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐Ÿ‡', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿฆฆ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐Ÿฟ๏ธ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐Ÿ‰', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '๐ŸŒด', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐ŸŒฑ', type: 'emoji' }, { color: selectedThreadColors[2], emoji: 'โ˜˜๏ธ', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '๐Ÿ€', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐Ÿ„', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '๐ŸŒฟ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐Ÿชด', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐Ÿ’', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐ŸŒท', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '๐ŸŒน', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐ŸŒธ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐ŸŒป', type: 'emoji' }, { color: selectedThreadColors[3], emoji: 'โญ', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐ŸŒŸ', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '๐Ÿ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐ŸŽ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐ŸŠ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐Ÿ‹', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '๐Ÿ“', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐Ÿซ', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '๐Ÿˆ', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '๐Ÿ’', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐Ÿฅญ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐Ÿ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐Ÿฅ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿ…', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐Ÿฅฆ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐Ÿฅ•', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '๐Ÿฅ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐Ÿฅฏ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐Ÿž', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '๐Ÿฅ–', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐Ÿฅจ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐Ÿง€', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐Ÿฅž', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿง‡', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐Ÿฅ“', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐Ÿ”', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '๐ŸŸ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐Ÿ•', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '๐Ÿฅ—', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '๐Ÿ', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐Ÿœ', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '๐Ÿฒ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐Ÿ›', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿฃ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐Ÿฑ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐ŸฅŸ', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '๐Ÿค', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐Ÿ™', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '๐Ÿš', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '๐Ÿฅ', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐Ÿฆ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐Ÿง', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '๐Ÿญ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿฉ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐Ÿช', type: 'emoji' }, { color: selectedThreadColors[9], emoji: 'โ˜•๏ธ', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '๐Ÿต', type: 'emoji' }, { color: selectedThreadColors[1], emoji: 'โšฝ๏ธ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿ€', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐Ÿˆ', type: 'emoji' }, { color: selectedThreadColors[4], emoji: 'โšพ๏ธ', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '๐ŸฅŽ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐ŸŽพ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐Ÿ‰', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '๐ŸŽฑ', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '๐Ÿ†', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐ŸŽจ', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '๐ŸŽค', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '๐ŸŽง', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐ŸŽผ', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '๐ŸŽน', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐Ÿฅ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐ŸŽท', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐ŸŽบ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐ŸŽธ', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '๐Ÿช•', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐ŸŽป', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '๐ŸŽฒ', type: 'emoji' }, { color: selectedThreadColors[3], emoji: 'โ™Ÿ๏ธ', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐ŸŽฎ', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '๐Ÿš—', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '๐Ÿš™', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐ŸšŒ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐ŸŽ๏ธ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐Ÿ›ป', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '๐Ÿšš', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿš›', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '๐Ÿš˜', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '๐Ÿš€', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐Ÿš', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '๐Ÿ›ถ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: 'โ›ต๏ธ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿšค', type: 'emoji' }, { color: selectedThreadColors[8], emoji: 'โš“', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐Ÿฐ', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '๐ŸŽก', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐Ÿ’Ž', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '๐Ÿ”ฎ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐Ÿ’ˆ', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐Ÿงธ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐ŸŽŠ', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐ŸŽ‰', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿชฉ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐Ÿš‚', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '๐Ÿš†', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐ŸšŠ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '๐Ÿ›ฐ๏ธ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '๐Ÿ ', type: 'emoji' }, { color: selectedThreadColors[3], emoji: 'โ›ฐ๏ธ', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '๐Ÿ”๏ธ', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '๐Ÿ—ป', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '๐Ÿ›๏ธ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: 'โ›ฉ๏ธ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '๐Ÿงฒ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '๐ŸŽ', type: 'emoji' }, ]; function getRandomDefaultEmojiAvatar(): ClientEmojiAvatar { const randomIndex = Math.floor(Math.random() * defaultEmojiAvatars.length); return defaultEmojiAvatars[randomIndex]; } function getDefaultAvatar(hashKey: string, color?: string): ClientEmojiAvatar { let key = hashKey; if (key.startsWith(`${ashoatKeyserverID}|`)) { key = key.slice(`${ashoatKeyserverID}|`.length); } const avatarIndex = stringHash(key) % defaultEmojiAvatars.length; return { ...defaultEmojiAvatars[avatarIndex], color: color ? color : defaultEmojiAvatars[avatarIndex].color, }; } function getAvatarForUser(user: ?GenericUserInfoWithAvatar): ClientAvatar { if (user?.avatar) { return user.avatar; } if (!user?.username) { return defaultAnonymousUserEmojiAvatar; } return getDefaultAvatar(user.username); } function getUserAvatarForThread( threadInfo: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ClientAvatar { if (threadInfo.type === threadTypes.PRIVATE) { invariant(viewerID, 'viewerID should be set for PRIVATE threads'); return getAvatarForUser(userInfos[viewerID]); } invariant( threadInfo.type === threadTypes.PERSONAL, 'threadInfo should be a PERSONAL type', ); const memberInfos = threadOtherMembers(threadInfo.members, viewerID) .map(member => userInfos[member.id] && userInfos[member.id]) .filter(Boolean); if (memberInfos.length === 0) { return defaultAnonymousUserEmojiAvatar; } return getAvatarForUser(memberInfos[0]); } function getAvatarForThread( - thread: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, + thread: MinimallyEncodedRawThreadInfo | ThreadInfo, containingThreadInfo: ?ThreadInfo, ): ClientAvatar { if (thread.avatar) { return thread.avatar; } if (containingThreadInfo && containingThreadInfo.id !== genesis.id) { return containingThreadInfo.avatar ? containingThreadInfo.avatar : getDefaultAvatar(containingThreadInfo.id, containingThreadInfo.color); } return getDefaultAvatar(thread.id, thread.color); } function useAvatarForThread( - thread: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, + thread: MinimallyEncodedRawThreadInfo | ThreadInfo, ): ClientAvatar { const containingThreadID = thread.containingThreadID; const containingThreadInfo = useSelector(state => containingThreadID ? threadInfoSelector(state)[containingThreadID] : null, ); return getAvatarForThread(thread, containingThreadInfo); } function useENSResolvedAvatar( avatarInfo: ClientAvatar, userInfo: ?GenericUserInfoWithAvatar, ): ResolvedClientAvatar { const ethAddress = React.useMemo( () => getETHAddressForUserInfo(userInfo), [userInfo], ); const ensAvatarURI = useENSAvatar(ethAddress); const resolvedAvatar = React.useMemo(() => { if (avatarInfo.type !== 'ens') { return avatarInfo; } if (ensAvatarURI) { return { type: 'image', uri: ensAvatarURI, }; } return defaultAnonymousUserEmojiAvatar; }, [ensAvatarURI, avatarInfo]); return resolvedAvatar; } export { defaultAnonymousUserEmojiAvatar, defaultEmojiAvatars, getRandomDefaultEmojiAvatar, getDefaultAvatar, getAvatarForUser, getUserAvatarForThread, getAvatarForThread, useAvatarForThread, useENSResolvedAvatar, }; diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index c23aa9ccd..dc4fc6622 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1947 +1,1923 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _omitBy from 'lodash/fp/omitBy.js'; import * as React from 'react'; import { getUserAvatarForThread } from './avatar-utils.js'; import { generatePendingThreadColor } from './color-utils.js'; import { type ParserRules } from './markdown.js'; import { extractUserMentionsFromText } from './mention-utils.js'; import { getMessageTitle, isInvalidSidebarSource } from './message-utils.js'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import { useForwardLookupSearchText } from './search-utils.js'; import threadWatcher from './thread-watcher.js'; import { fetchMostRecentMessagesActionTypes, useFetchMostRecentMessages, } from '../actions/message-actions.js'; import type { RemoveUsersFromThreadInput } from '../actions/thread-actions'; import { newThreadActionTypes, removeUsersFromThreadActionTypes, } from '../actions/thread-actions.js'; import { searchUsers as searchUserCall } from '../actions/user-actions.js'; import ashoat from '../facts/ashoat.js'; import genesis from '../facts/genesis.js'; import { useLoggedInUserInfo } from '../hooks/account-hooks.js'; import { hasPermission, permissionsToBitmaskHex, threadPermissionsFromBitmaskHex, } from '../permissions/minimally-encoded-thread-permissions.js'; import { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions.js'; import type { ChatThreadItem, ChatMessageInfoItem, } from '../selectors/chat-selectors.js'; import { useGlobalThreadSearchIndex } from '../selectors/nav-selectors.js'; import { threadInfoSelector, pendingToRealizedThreadIDsSelector, threadInfosSelectorForThreadType, } from '../selectors/thread-selectors.js'; import { getRelativeMemberInfos, usersWithPersonalThreadSelector, } from '../selectors/user-selectors.js'; import type { CalendarQuery } from '../types/entry-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type RobotextMessageInfo, type ComposableMessageInfo, } from '../types/message-types.js'; import type { MinimallyEncodedRawThreadInfo, MinimallyEncodedThreadCurrentUserInfo, MinimallyEncodedThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { decodeMinimallyEncodedRoleInfo, minimallyEncodeRawThreadInfo, minimallyEncodeThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadPermissionPropagationPrefixes, threadPermissions, configurableCommunityPermissions, type ThreadPermission, type ThreadPermissionsInfo, type UserSurfacedPermission, threadPermissionFilterPrefixes, } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes, threadTypeIsCommunityRoot, assertThreadType, } from '../types/thread-types-enum.js'; import type { LegacyRawThreadInfo, LegacyThreadInfo, MemberInfo, ServerThreadInfo, ThreadCurrentUserInfo, RoleInfo, ServerMemberInfo, ClientNewThreadRequest, NewThreadResult, ChangeThreadSettingsPayload, UserProfileThreadInfo, RelativeMemberInfo, ThreadInfo, RawThreadInfos, } from '../types/thread-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import { type ClientUpdateInfo } from '../types/update-types.js'; import type { GlobalAccountUserInfo, UserInfos, AccountUserInfo, LoggedInUserInfo, UserInfo, } from '../types/user-types.js'; import { useLegacyAshoatKeyserverCall } from '../utils/action-utils.js'; import type { GetENSNames } from '../utils/ens-helpers.js'; import { ET, entityTextToRawString, getEntityTextAsString, type ThreadEntity, type UserEntity, } from '../utils/entity-text.js'; import { useDispatchActionPromise, type DispatchActionPromise, } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; import { firstLine } from '../utils/string-utils.js'; import { trimText } from '../utils/text-utils.js'; import { pendingThreadIDRegex } from '../utils/validation-utils.js'; function threadHasPermission( threadInfo: ?( | ThreadInfo | LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo ), permission: ThreadPermission, ): boolean { if (!threadInfo) { return false; } invariant( !permissionsDisabledByBlock.has(permission) || threadInfo?.uiName, `${permission} can be disabled by a block, but threadHasPermission can't ` + 'check for a block on RawThreadInfo. Please pass in ThreadInfo instead!', ); if (threadInfo.minimallyEncoded) { return hasPermission(threadInfo.currentUser.permissions, permission); } return permissionLookup(threadInfo.currentUser.permissions, permission); } function viewerIsMember( threadInfo: ?( | LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo ), ): boolean { return !!( threadInfo && threadInfo.currentUser.role !== null && threadInfo.currentUser.role !== undefined ); } function threadIsInHome( - threadInfo: ?( - | LegacyRawThreadInfo - | MinimallyEncodedRawThreadInfo - | ThreadInfo - ), + threadInfo: ?(MinimallyEncodedRawThreadInfo | ThreadInfo), ): boolean { return !!(threadInfo && threadInfo.currentUser.subscription.home); } // Can have messages function threadInChatList( threadInfo: ?( | LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo ), ): boolean { return ( viewerIsMember(threadInfo) && threadHasPermission(threadInfo, threadPermissions.VISIBLE) ); } function threadIsTopLevel( - threadInfo: ?( - | LegacyRawThreadInfo - | MinimallyEncodedRawThreadInfo - | ThreadInfo - ), + threadInfo: ?(MinimallyEncodedRawThreadInfo | ThreadInfo), ): boolean { return threadInChatList(threadInfo) && threadIsChannel(threadInfo); } function threadIsChannel( - threadInfo: ?( - | LegacyRawThreadInfo - | MinimallyEncodedRawThreadInfo - | ThreadInfo - ), + threadInfo: ?(MinimallyEncodedRawThreadInfo | ThreadInfo), ): boolean { return !!(threadInfo && threadInfo.type !== threadTypes.SIDEBAR); } function threadIsSidebar( - threadInfo: ?( - | LegacyRawThreadInfo - | MinimallyEncodedRawThreadInfo - | ThreadInfo - ), + threadInfo: ?(MinimallyEncodedRawThreadInfo | ThreadInfo), ): boolean { return threadInfo?.type === threadTypes.SIDEBAR; } function threadInBackgroundChatList( - threadInfo: ?( - | LegacyRawThreadInfo - | MinimallyEncodedRawThreadInfo - | ThreadInfo - ), + threadInfo: ?(MinimallyEncodedRawThreadInfo | ThreadInfo), ): boolean { return threadInChatList(threadInfo) && !threadIsInHome(threadInfo); } function threadInHomeChatList( - threadInfo: ?( - | LegacyRawThreadInfo - | MinimallyEncodedRawThreadInfo - | ThreadInfo - ), + threadInfo: ?(MinimallyEncodedRawThreadInfo | ThreadInfo), ): boolean { return threadInChatList(threadInfo) && threadIsInHome(threadInfo); } // Can have Calendar entries, // does appear as a top-level entity in the thread list function threadInFilterList( threadInfo: ?( | LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo ), ): boolean { return ( threadInChatList(threadInfo) && !!threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function userIsMember( threadInfo: ?( | LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo ), userID: string, ): boolean { if (!threadInfo) { return false; } if (threadInfo.id === genesis.id) { return true; } return threadInfo.members.some(member => member.id === userID && member.role); } function threadActualMembers( memberInfos: $ReadOnlyArray, ): $ReadOnlyArray { return memberInfos .filter(memberInfo => memberInfo.role) .map(memberInfo => memberInfo.id); } function threadOtherMembers( memberInfos: $ReadOnlyArray, viewerID: ?string, ): $ReadOnlyArray { return memberInfos.filter( memberInfo => memberInfo.role && memberInfo.id !== viewerID, ); } function threadMembersWithoutAddedAshoat< T: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, >(threadInfo: T): $PropertyType { if (threadInfo.community !== genesis.id) { return threadInfo.members; } return threadInfo.members.filter( member => member.id !== ashoat.id || member.role, ); } function threadIsGroupChat(threadInfo: ThreadInfo): boolean { return threadInfo.members.length > 2; } function threadOrParentThreadIsGroupChat( threadInfo: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, ) { return threadMembersWithoutAddedAshoat(threadInfo).length > 2; } function threadIsPending(threadID: ?string): boolean { return !!threadID?.startsWith('pending'); } function threadIsPendingSidebar(threadID: ?string): boolean { return !!threadID?.startsWith('pending/sidebar/'); } function getSingleOtherUser( threadInfo: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, viewerID: ?string, ): ?string { if (!viewerID) { return undefined; } const otherMembers = threadOtherMembers(threadInfo.members, viewerID); if (otherMembers.length !== 1) { return undefined; } return otherMembers[0].id; } function getPendingThreadID( threadType: ThreadType, memberIDs: $ReadOnlyArray, sourceMessageID: ?string, ): string { const pendingThreadKey = sourceMessageID ? `sidebar/${sourceMessageID}` : [...memberIDs].sort().join('+'); const pendingThreadTypeString = sourceMessageID ? '' : `type${threadType}/`; return `pending/${pendingThreadTypeString}${pendingThreadKey}`; } type PendingThreadIDContents = { +threadType: ThreadType, +memberIDs: $ReadOnlyArray, +sourceMessageID: ?string, }; function parsePendingThreadID( pendingThreadID: string, ): ?PendingThreadIDContents { const pendingRegex = new RegExp(`^${pendingThreadIDRegex}$`); const pendingThreadIDMatches = pendingRegex.exec(pendingThreadID); if (!pendingThreadIDMatches) { return null; } const [threadTypeString, threadKey] = pendingThreadIDMatches[1].split('/'); const threadType = threadTypeString === 'sidebar' ? threadTypes.SIDEBAR : assertThreadType(Number(threadTypeString.replace('type', ''))); const memberIDs = threadTypeString === 'sidebar' ? [] : threadKey.split('+'); const sourceMessageID = threadTypeString === 'sidebar' ? threadKey : null; return { threadType, memberIDs, sourceMessageID, }; } type UserIDAndUsername = { +id: string, +username: string, ... }; type CreatePendingThreadArgs = { +viewerID: string, +threadType: ThreadType, +members: $ReadOnlyArray, +parentThreadInfo?: ?ThreadInfo, +threadColor?: ?string, +name?: ?string, +sourceMessageID?: string, }; function createPendingThread({ viewerID, threadType, members, parentThreadInfo, threadColor, name, sourceMessageID, }: CreatePendingThreadArgs): MinimallyEncodedThreadInfo { const now = Date.now(); if (!members.some(member => member.id === viewerID)) { throw new Error( 'createPendingThread should be called with the viewer as a member', ); } const memberIDs = members.map(member => member.id); const threadID = getPendingThreadID(threadType, memberIDs, sourceMessageID); const permissions = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, }; const membershipPermissions = getAllThreadPermissions( makePermissionsBlob(permissions, null, threadID, threadType), threadID, ); const role = { id: `${threadID}/role`, name: 'Members', permissions, isDefault: true, }; const rawThreadInfo = { id: threadID, type: threadType, name: name ?? null, description: null, color: threadColor ?? generatePendingThreadColor(memberIDs), creationTime: now, parentThreadID: parentThreadInfo?.id ?? null, containingThreadID: getContainingThreadID(parentThreadInfo, threadType), community: getCommunity(parentThreadInfo), members: members.map(member => ({ id: member.id, role: role.id, permissions: membershipPermissions, isSender: false, })), roles: { [role.id]: role, }, currentUser: { role: role.id, permissions: membershipPermissions, subscription: { pushNotifs: false, home: false, }, unread: false, }, repliesCount: 0, sourceMessageID, pinnedCount: 0, }; const userInfos: { [string]: UserInfo } = {}; for (const member of members) { const { id, username } = member; userInfos[id] = { id, username }; } return threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, userInfos); } type PendingPersonalThread = { +threadInfo: ThreadInfo, +pendingPersonalThreadUserInfo: UserInfo, }; function createPendingPersonalThread( loggedInUserInfo: LoggedInUserInfo, userID: string, username: string, ): PendingPersonalThread { const pendingPersonalThreadUserInfo = { id: userID, username: username, }; const threadInfo = createPendingThread({ viewerID: loggedInUserInfo.id, threadType: threadTypes.PERSONAL, members: [loggedInUserInfo, pendingPersonalThreadUserInfo], }); return { threadInfo, pendingPersonalThreadUserInfo }; } function createPendingThreadItem( loggedInUserInfo: LoggedInUserInfo, user: UserIDAndUsername, ): ChatThreadItem { const { threadInfo, pendingPersonalThreadUserInfo } = createPendingPersonalThread(loggedInUserInfo, user.id, user.username); return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo: null, mostRecentNonLocalMessage: null, lastUpdatedTime: threadInfo.creationTime, lastUpdatedTimeIncludingSidebars: threadInfo.creationTime, sidebars: [], pendingPersonalThreadUserInfo, }; } // Returns map from lowercase username to AccountUserInfo function memberLowercaseUsernameMap( members: $ReadOnlyArray, ): Map { const memberMap = new Map(); for (const member of members) { const { id, role, username } = member; if (!role || !username) { continue; } memberMap.set(username.toLowerCase(), { id, username }); } return memberMap; } // Returns map from user ID to AccountUserInfo function extractMentionedMembers( text: string, threadInfo: ThreadInfo, ): Map { const memberMap = memberLowercaseUsernameMap(threadInfo.members); const mentions = extractUserMentionsFromText(text); const mentionedMembers = new Map(); for (const mention of mentions) { const userInfo = memberMap.get(mention.toLowerCase()); if (userInfo) { mentionedMembers.set(userInfo.id, userInfo); } } return mentionedMembers; } // When a member of the parent is mentioned in a sidebar, // they will be automatically added to that sidebar function extractNewMentionedParentMembers( messageText: string, threadInfo: ThreadInfo, parentThreadInfo: ThreadInfo, ): AccountUserInfo[] { const mentionedMembersOfParent = extractMentionedMembers( messageText, parentThreadInfo, ); for (const member of threadInfo.members) { if (member.role) { mentionedMembersOfParent.delete(member.id); } } return [...mentionedMembersOfParent.values()]; } type SharedCreatePendingSidebarInput = { +sourceMessageInfo: ComposableMessageInfo | RobotextMessageInfo, +parentThreadInfo: ThreadInfo, +loggedInUserInfo: LoggedInUserInfo, }; type BaseCreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +messageTitle: string, }; function baseCreatePendingSidebar( input: BaseCreatePendingSidebarInput, ): MinimallyEncodedThreadInfo { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, messageTitle, } = input; const { color, type: parentThreadType } = parentThreadInfo; const threadName = trimText(messageTitle, 30); const initialMembers = new Map(); const { id: viewerID, username: viewerUsername } = loggedInUserInfo; initialMembers.set(viewerID, { id: viewerID, username: viewerUsername }); if (userIsMember(parentThreadInfo, sourceMessageInfo.creator.id)) { const { id: sourceAuthorID, username: sourceAuthorUsername } = sourceMessageInfo.creator; invariant( sourceAuthorUsername, 'sourceAuthorUsername should be set in createPendingSidebar', ); const initialMemberUserInfo = { id: sourceAuthorID, username: sourceAuthorUsername, }; initialMembers.set(sourceAuthorID, initialMemberUserInfo); } const singleOtherUser = getSingleOtherUser(parentThreadInfo, viewerID); if (parentThreadType === threadTypes.PERSONAL && singleOtherUser) { const singleOtherUsername = parentThreadInfo.members.find( member => member.id === singleOtherUser, )?.username; invariant( singleOtherUsername, 'singleOtherUsername should be set in createPendingSidebar', ); const singleOtherUserInfo = { id: singleOtherUser, username: singleOtherUsername, }; initialMembers.set(singleOtherUser, singleOtherUserInfo); } if (sourceMessageInfo.type === messageTypes.TEXT) { const mentionedMembersOfParent = extractMentionedMembers( sourceMessageInfo.text, parentThreadInfo, ); for (const [memberID, member] of mentionedMembersOfParent) { initialMembers.set(memberID, member); } } return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members: [...initialMembers.values()], parentThreadInfo, threadColor: color, name: threadName, sourceMessageID: sourceMessageInfo.id, }); } // The message title here may have ETH addresses that aren't resolved to ENS // names. This function should only be used in cases where we're sure that we // don't care about the thread title. We should prefer createPendingSidebar // wherever possible type CreateUnresolvedPendingSidebarInput = { ...SharedCreatePendingSidebarInput, +markdownRules: ParserRules, }; function createUnresolvedPendingSidebar( input: CreateUnresolvedPendingSidebarInput, ): MinimallyEncodedThreadInfo { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, parentThreadInfo, markdownRules, ); const messageTitle = entityTextToRawString(messageTitleEntityText, { ignoreViewer: true, }); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, loggedInUserInfo, }); } type CreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +markdownRules: ParserRules, +getENSNames: ?GetENSNames, }; async function createPendingSidebar( input: CreatePendingSidebarInput, ): Promise { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, getENSNames, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, parentThreadInfo, markdownRules, ); const messageTitle = await getEntityTextAsString( messageTitleEntityText, getENSNames, { ignoreViewer: true }, ); invariant( messageTitle !== null && messageTitle !== undefined, 'getEntityTextAsString only returns falsey when passed falsey', ); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, loggedInUserInfo, }); } function pendingThreadType(numberOfOtherMembers: number): 4 | 6 | 7 { if (numberOfOtherMembers === 0) { return threadTypes.PRIVATE; } else if (numberOfOtherMembers === 1) { return threadTypes.PERSONAL; } else { return threadTypes.LOCAL; } } function threadTypeCanBePending(threadType: ThreadType): boolean { return ( threadType === threadTypes.PERSONAL || threadType === threadTypes.LOCAL || threadType === threadTypes.SIDEBAR || threadType === threadTypes.PRIVATE ); } type CreateRealThreadParameters = { +threadInfo: ThreadInfo, +dispatchActionPromise: DispatchActionPromise, +createNewThread: ClientNewThreadRequest => Promise, +sourceMessageID: ?string, +viewerID: ?string, +handleError?: () => mixed, +calendarQuery: CalendarQuery, }; async function createRealThreadFromPendingThread({ threadInfo, dispatchActionPromise, createNewThread, sourceMessageID, viewerID, calendarQuery, }: CreateRealThreadParameters): Promise { if (!threadIsPending(threadInfo.id)) { return threadInfo.id; } const otherMemberIDs = threadOtherMembers(threadInfo.members, viewerID).map( member => member.id, ); let resultPromise; if (threadInfo.type !== threadTypes.SIDEBAR) { invariant( otherMemberIDs.length > 0, 'otherMemberIDs should not be empty for threads', ); resultPromise = createNewThread({ type: pendingThreadType(otherMemberIDs.length), initialMemberIDs: otherMemberIDs, color: threadInfo.color, calendarQuery, }); } else { invariant( sourceMessageID, 'sourceMessageID should be set when creating a sidebar', ); resultPromise = createNewThread({ type: threadTypes.SIDEBAR, initialMemberIDs: otherMemberIDs, color: threadInfo.color, sourceMessageID, parentThreadID: threadInfo.parentThreadID, name: threadInfo.name, calendarQuery, }); } void dispatchActionPromise(newThreadActionTypes, resultPromise); const { newThreadID } = await resultPromise; return newThreadID; } type RawThreadInfoOptions = { +filterThreadEditAvatarPermission?: boolean, +excludePinInfo?: boolean, +filterManageInviteLinksPermission?: boolean, +filterVoicedInAnnouncementChannelsPermission?: boolean, +minimallyEncodePermissions?: boolean, }; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?LegacyRawThreadInfo | ?MinimallyEncodedRawThreadInfo { const filterThreadEditAvatarPermission = options?.filterThreadEditAvatarPermission; const excludePinInfo = options?.excludePinInfo; const filterManageInviteLinksPermission = options?.filterManageInviteLinksPermission; const filterVoicedInAnnouncementChannelsPermission = options?.filterVoicedInAnnouncementChannelsPermission; const shouldMinimallyEncodePermissions = options?.minimallyEncodePermissions; const filterThreadPermissions = _omitBy( (v, k) => (filterThreadEditAvatarPermission && [ threadPermissions.EDIT_THREAD_AVATAR, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_THREAD_AVATAR, ].includes(k)) || (excludePinInfo && [ threadPermissions.MANAGE_PINS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.MANAGE_PINS, ].includes(k)) || (filterManageInviteLinksPermission && [threadPermissions.MANAGE_INVITE_LINKS].includes(k)) || (filterVoicedInAnnouncementChannelsPermission && [ threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissionFilterPrefixes.TOP_LEVEL + threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS, ].includes(k)), ); const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( serverThreadInfo.id === genesis.id && serverMember.id !== viewerID && serverMember.id !== ashoat.id ) { continue; } const memberPermissions = filterThreadPermissions(serverMember.permissions); members.push({ id: serverMember.id, role: serverMember.role, permissions: memberPermissions, isSender: serverMember.isSender, }); if (serverMember.id === viewerID) { currentUser = { role: serverMember.role, permissions: memberPermissions, subscription: serverMember.subscription, unread: serverMember.unread, }; } } let currentUserPermissions; if (currentUser) { currentUserPermissions = currentUser.permissions; } else { currentUserPermissions = filterThreadPermissions( getAllThreadPermissions(null, serverThreadInfo.id), ); currentUser = { role: null, permissions: currentUserPermissions, subscription: { home: false, pushNotifs: false, }, unread: null, }; } if (!permissionLookup(currentUserPermissions, threadPermissions.KNOW_OF)) { return null; } const rolesWithFilteredThreadPermissions = _mapValues(role => ({ ...role, permissions: filterThreadPermissions(role.permissions), }))(serverThreadInfo.roles); let rawThreadInfo: any = { id: serverThreadInfo.id, type: serverThreadInfo.type, name: serverThreadInfo.name, description: serverThreadInfo.description, color: serverThreadInfo.color, creationTime: serverThreadInfo.creationTime, parentThreadID: serverThreadInfo.parentThreadID, members, roles: rolesWithFilteredThreadPermissions, currentUser, repliesCount: serverThreadInfo.repliesCount, containingThreadID: serverThreadInfo.containingThreadID, community: serverThreadInfo.community, }; const sourceMessageID = serverThreadInfo.sourceMessageID; if (sourceMessageID) { rawThreadInfo = { ...rawThreadInfo, sourceMessageID }; } if (serverThreadInfo.avatar) { rawThreadInfo = { ...rawThreadInfo, avatar: serverThreadInfo.avatar }; } if (!excludePinInfo) { rawThreadInfo = { ...rawThreadInfo, pinnedCount: serverThreadInfo.pinnedCount, }; } return shouldMinimallyEncodePermissions ? minimallyEncodeRawThreadInfo(rawThreadInfo) : rawThreadInfo; } function threadUIName(threadInfo: ThreadInfo): string | ThreadEntity { if (threadInfo.name) { return firstLine(threadInfo.name); } let threadMembers: $ReadOnlyArray; let memberEntities: $ReadOnlyArray; // Branching below is to appease flow if (threadInfo.minimallyEncoded) { threadMembers = threadInfo.members.filter(memberInfo => memberInfo.role); memberEntities = threadMembers.map(member => ET.user({ userInfo: member })); } else { threadMembers = threadInfo.members.filter(memberInfo => memberInfo.role); memberEntities = threadMembers.map(member => ET.user({ userInfo: member })); } return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'uiName', uiName: memberEntities, ifJustViewer: threadInfo.type === threadTypes.PRIVATE ? 'viewer_username' : 'just_you_string', }; } function threadInfoFromRawThreadInfo( rawThreadInfo: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): MinimallyEncodedThreadInfo { let threadInfo: ThreadInfo; if (rawThreadInfo.minimallyEncoded) { threadInfo = { minimallyEncoded: true, id: rawThreadInfo.id, type: rawThreadInfo.type, name: rawThreadInfo.name, uiName: '', description: rawThreadInfo.description, color: rawThreadInfo.color, creationTime: rawThreadInfo.creationTime, parentThreadID: rawThreadInfo.parentThreadID, containingThreadID: rawThreadInfo.containingThreadID, community: rawThreadInfo.community, members: getRelativeMemberInfos(rawThreadInfo, viewerID, userInfos), roles: rawThreadInfo.roles, currentUser: getMinimallyEncodedCurrentUser( rawThreadInfo, viewerID, userInfos, ), repliesCount: rawThreadInfo.repliesCount, }; } else { threadInfo = { id: rawThreadInfo.id, type: rawThreadInfo.type, name: rawThreadInfo.name, uiName: '', description: rawThreadInfo.description, color: rawThreadInfo.color, creationTime: rawThreadInfo.creationTime, parentThreadID: rawThreadInfo.parentThreadID, containingThreadID: rawThreadInfo.containingThreadID, community: rawThreadInfo.community, members: getRelativeMemberInfos(rawThreadInfo, viewerID, userInfos), roles: rawThreadInfo.roles, currentUser: getCurrentUser(rawThreadInfo, viewerID, userInfos), repliesCount: rawThreadInfo.repliesCount, }; threadInfo = minimallyEncodeThreadInfo(threadInfo); } threadInfo = { ...threadInfo, uiName: threadUIName(threadInfo), }; const { sourceMessageID, avatar, pinnedCount } = rawThreadInfo; if (sourceMessageID) { threadInfo = { ...threadInfo, sourceMessageID }; } if (avatar) { threadInfo = { ...threadInfo, avatar }; } else if ( rawThreadInfo.type === threadTypes.PERSONAL || rawThreadInfo.type === threadTypes.PRIVATE ) { threadInfo = { ...threadInfo, avatar: getUserAvatarForThread(rawThreadInfo, viewerID, userInfos), }; } if (pinnedCount) { threadInfo = { ...threadInfo, pinnedCount }; } return threadInfo; } function getCurrentUser( threadInfo: LegacyRawThreadInfo | LegacyThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } return { ...threadInfo.currentUser, permissions: { ...threadInfo.currentUser.permissions, ...disabledPermissions, }, }; } function getMinimallyEncodedCurrentUser( threadInfo: MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo, viewerID: ?string, userInfos: UserInfos, ): MinimallyEncodedThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } const decodedPermissions = threadPermissionsFromBitmaskHex( threadInfo.currentUser.permissions, ); const updatedPermissions = { ...decodedPermissions, ...disabledPermissions, }; const encodedUpdatedPermissions = permissionsToBitmaskHex(updatedPermissions); return { ...threadInfo.currentUser, permissions: encodedUpdatedPermissions, }; } function threadIsWithBlockedUserOnly( threadInfo: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock?: boolean, ): boolean { if ( threadOrParentThreadIsGroupChat(threadInfo) || threadOrParentThreadHasAdminRole(threadInfo) ) { return false; } const otherUserID = getSingleOtherUser(threadInfo, viewerID); if (!otherUserID) { return false; } const otherUserRelationshipStatus = userInfos[otherUserID]?.relationshipStatus; if (checkOnlyViewerBlock) { return ( otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ); } return ( !!otherUserRelationshipStatus && relationshipBlockedInEitherDirection(otherUserRelationshipStatus) ); } function threadFrozenDueToBlock( threadInfo: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos); } function threadFrozenDueToViewerBlock( - threadInfo: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, + threadInfo: MinimallyEncodedRawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos, true); } const threadTypeDescriptions: { [ThreadType]: string } = { [threadTypes.COMMUNITY_OPEN_SUBTHREAD]: 'Anybody in the parent channel can see an open subchannel.', [threadTypes.COMMUNITY_SECRET_SUBTHREAD]: 'Only visible to its members and admins of ancestor channels.', }; function memberIsAdmin( memberInfo: RelativeMemberInfo | MemberInfo, - threadInfo: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, + threadInfo: MinimallyEncodedRawThreadInfo | ThreadInfo, ): boolean { return !!( memberInfo.role && roleIsAdminRole(threadInfo.roles[memberInfo.role]) ); } // Since we don't have access to all of the ancestor ThreadInfos, we approximate // "parent admin" as anybody with CHANGE_ROLE permissions. function memberHasAdminPowers( memberInfo: RelativeMemberInfo | MemberInfo | ServerMemberInfo, ): boolean { if (memberInfo.minimallyEncoded) { return hasPermission(memberInfo.permissions, threadPermissions.CHANGE_ROLE); } return !!memberInfo.permissions[threadPermissions.CHANGE_ROLE]?.value; } function roleIsAdminRole(roleInfo: ?RoleInfo): boolean { return !!(roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'); } function threadHasAdminRole( threadInfo: ?( | LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo | ServerThreadInfo ), ): boolean { if (!threadInfo) { return false; } return !!_find({ name: 'Admins' })(threadInfo.roles); } function threadOrParentThreadHasAdminRole( threadInfo: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, ) { return ( threadMembersWithoutAddedAshoat(threadInfo).filter(member => memberHasAdminPowers(member), ).length > 0 ); } function identifyInvalidatedThreads( updateInfos: $ReadOnlyArray, ): Set { const invalidated = new Set(); for (const updateInfo of updateInfos) { if (updateInfo.type === updateTypes.DELETE_THREAD) { invalidated.add(updateInfo.threadID); } } return invalidated; } const permissionsDisabledByBlockArray = [ threadPermissions.VOICED, threadPermissions.EDIT_ENTRIES, threadPermissions.EDIT_THREAD_NAME, threadPermissions.EDIT_THREAD_COLOR, threadPermissions.EDIT_THREAD_DESCRIPTION, threadPermissions.CREATE_SUBCHANNELS, threadPermissions.CREATE_SIDEBARS, threadPermissions.JOIN_THREAD, threadPermissions.EDIT_PERMISSIONS, threadPermissions.ADD_MEMBERS, threadPermissions.REMOVE_MEMBERS, ]; const permissionsDisabledByBlock: Set = new Set( permissionsDisabledByBlockArray, ); const disabledPermissions: ThreadPermissionsInfo = permissionsDisabledByBlockArray.reduce( (permissions: ThreadPermissionsInfo, permission: string) => ({ ...permissions, [permission]: { value: false, source: null }, }), {}, ); // Consider updating itemHeight in native/chat/chat-thread-list.react.js // if you change this const emptyItemText: string = `Background chats are just like normal chats, except they don't ` + `contribute to your unread count.\n\n` + `To move a chat over here, switch the โ€œBackgroundโ€ option in its settings.`; function threadNoun(threadType: ThreadType, parentThreadID: ?string): string { if (threadType === threadTypes.SIDEBAR) { return 'thread'; } else if ( threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD && parentThreadID === genesis.id ) { return 'chat'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.GENESIS ) { return 'channel'; } else { return 'chat'; } } function threadLabel(threadType: ThreadType): string { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD ) { return 'Open'; } else if (threadType === threadTypes.PERSONAL) { return 'Personal'; } else if (threadType === threadTypes.SIDEBAR) { return 'Thread'; } else if (threadType === threadTypes.PRIVATE) { return 'Private'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS ) { return 'Community'; } else { return 'Secret'; } } function useWatchThread(threadInfo: ?ThreadInfo) { const dispatchActionPromise = useDispatchActionPromise(); const callFetchMostRecentMessages = useFetchMostRecentMessages(); const threadID = threadInfo?.id; const threadNotInChatList = !threadInChatList(threadInfo); React.useEffect(() => { if (threadID && threadNotInChatList) { threadWatcher.watchID(threadID); void dispatchActionPromise( fetchMostRecentMessagesActionTypes, callFetchMostRecentMessages({ threadID }), ); } return () => { if (threadID && threadNotInChatList) { threadWatcher.removeID(threadID); } }; }, [ callFetchMostRecentMessages, dispatchActionPromise, threadNotInChatList, threadID, ]); } type ExistingThreadInfoFinderParams = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, }; type ExistingThreadInfoFinder = ( params: ExistingThreadInfoFinderParams, ) => ?ThreadInfo; // TODO (atul): Parameterize function once `createPendingThread` is updated. function useExistingThreadInfoFinder( baseThreadInfo: ?ThreadInfo, ): ExistingThreadInfoFinder { const threadInfos = useSelector(threadInfoSelector); const loggedInUserInfo = useLoggedInUserInfo(); const userInfos = useSelector(state => state.userStore.userInfos); const pendingToRealizedThreadIDs = useSelector(state => pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos), ); return React.useCallback( (params: ExistingThreadInfoFinderParams): ?ThreadInfo => { if (!baseThreadInfo) { return null; } const realizedThreadInfo = threadInfos[baseThreadInfo.id]; if (realizedThreadInfo) { return realizedThreadInfo; } if (!loggedInUserInfo || !threadIsPending(baseThreadInfo.id)) { return baseThreadInfo; } const viewerID = loggedInUserInfo?.id; invariant( threadTypeCanBePending(baseThreadInfo.type), `ThreadInfo has pending ID ${baseThreadInfo.id}, but type that ` + `should not be pending ${baseThreadInfo.type}`, ); const { searching, userInfoInputArray } = params; const { sourceMessageID } = baseThreadInfo; const pendingThreadID = searching ? getPendingThreadID( pendingThreadType(userInfoInputArray.length), [...userInfoInputArray.map(user => user.id), viewerID], sourceMessageID, ) : getPendingThreadID( baseThreadInfo.type, baseThreadInfo.members.map(member => member.id), sourceMessageID, ); const realizedThreadID = pendingToRealizedThreadIDs.get(pendingThreadID); if (realizedThreadID && threadInfos[realizedThreadID]) { return threadInfos[realizedThreadID]; } const updatedThread = searching ? createPendingThread({ viewerID, threadType: pendingThreadType(userInfoInputArray.length), members: [loggedInUserInfo, ...userInfoInputArray], }) : baseThreadInfo; if (updatedThread.minimallyEncoded) { return { ...updatedThread, currentUser: getMinimallyEncodedCurrentUser( updatedThread, viewerID, userInfos, ), }; } else { return { ...updatedThread, currentUser: getCurrentUser(updatedThread, viewerID, userInfos), }; } }, [ baseThreadInfo, threadInfos, loggedInUserInfo, pendingToRealizedThreadIDs, userInfos, ], ); } type ThreadTypeParentRequirement = 'optional' | 'required' | 'disabled'; function getThreadTypeParentRequirement( threadType: ThreadType, ): ThreadTypeParentRequirement { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || //threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.SIDEBAR ) { return 'required'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS || threadType === threadTypes.PERSONAL || threadType === threadTypes.PRIVATE ) { return 'disabled'; } else { return 'optional'; } } function threadMemberHasPermission( threadInfo: | ServerThreadInfo | LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, memberID: string, permission: ThreadPermission, ): boolean { for (const member of threadInfo.members) { if (member.id !== memberID) { continue; } if (member.minimallyEncoded) { return hasPermission(member.permissions, permission); } return permissionLookup(member.permissions, permission); } return false; } function useCanCreateSidebarFromMessage( threadInfo: ThreadInfo, messageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const messageCreatorUserInfo = useSelector( state => state.userStore.userInfos[messageInfo.creator.id], ); if ( !messageInfo.id || threadInfo.sourceMessageID === messageInfo.id || isInvalidSidebarSource(messageInfo) ) { return false; } const messageCreatorRelationship = messageCreatorUserInfo?.relationshipStatus; const creatorRelationshipHasBlock = messageCreatorRelationship && relationshipBlockedInEitherDirection(messageCreatorRelationship); const hasCreateSidebarsPermission = threadHasPermission( threadInfo, threadPermissions.CREATE_SIDEBARS, ); return hasCreateSidebarsPermission && !creatorRelationshipHasBlock; } function useSidebarExistsOrCanBeCreated( threadInfo: ThreadInfo, messageItem: ChatMessageInfoItem, ): boolean { const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( threadInfo, messageItem.messageInfo, ); return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage; } function checkIfDefaultMembersAreVoiced(threadInfo: ThreadInfo): boolean { const defaultRoleID = Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].isDefault, ); invariant( defaultRoleID !== undefined, 'all threads should have a default role', ); const defaultRole = threadInfo.roles[defaultRoleID]; const defaultRolePermissions = defaultRole.minimallyEncoded ? decodeMinimallyEncodedRoleInfo(defaultRole).permissions : defaultRole.permissions; return !!defaultRolePermissions[threadPermissions.VOICED]; } const draftKeySuffix = '/message_composer'; function draftKeyFromThreadID(threadID: string): string { return `${threadID}${draftKeySuffix}`; } function getContainingThreadID( parentThreadInfo: | ?ServerThreadInfo | LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, threadType: ThreadType, ): ?string { if (!parentThreadInfo) { return null; } if (threadType === threadTypes.SIDEBAR) { return parentThreadInfo.id; } if (!parentThreadInfo.containingThreadID) { return parentThreadInfo.id; } return parentThreadInfo.containingThreadID; } function getCommunity( parentThreadInfo: | ?ServerThreadInfo | LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, ): ?string { if (!parentThreadInfo) { return null; } const { id, community, type } = parentThreadInfo; if (community !== null && community !== undefined) { return community; } if (threadTypeIsCommunityRoot(type)) { return id; } return null; } function getThreadListSearchResults( chatListData: $ReadOnlyArray, searchText: string, threadFilter: ThreadInfo => boolean, threadSearchResults: $ReadOnlySet, usersSearchResults: $ReadOnlyArray, loggedInUserInfo: ?LoggedInUserInfo, ): $ReadOnlyArray { if (!searchText) { return chatListData.filter( item => threadIsTopLevel(item.threadInfo) && threadFilter(item.threadInfo), ); } const privateThreads = []; const personalThreads = []; const otherThreads = []; for (const item of chatListData) { if (!threadSearchResults.has(item.threadInfo.id)) { continue; } if (item.threadInfo.type === threadTypes.PRIVATE) { privateThreads.push({ ...item, sidebars: [] }); } else if (item.threadInfo.type === threadTypes.PERSONAL) { personalThreads.push({ ...item, sidebars: [] }); } else { otherThreads.push({ ...item, sidebars: [] }); } } const chatItems: ChatThreadItem[] = [ ...privateThreads, ...personalThreads, ...otherThreads, ]; if (loggedInUserInfo) { chatItems.push( ...usersSearchResults.map(user => createPendingThreadItem(loggedInUserInfo, user), ), ); } return chatItems; } type ThreadListSearchResult = { +threadSearchResults: $ReadOnlySet, +usersSearchResults: $ReadOnlyArray, }; function useThreadListSearch( searchText: string, viewerID: ?string, ): ThreadListSearchResult { const callSearchUsers = useLegacyAshoatKeyserverCall(searchUserCall); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); const forwardLookupSearchText = useForwardLookupSearchText(searchText); const searchUsers = React.useCallback( async (usernamePrefix: string) => { if (usernamePrefix.length === 0) { return ([]: GlobalAccountUserInfo[]); } const { userInfos } = await callSearchUsers(usernamePrefix); return userInfos.filter( info => !usersWithPersonalThread.has(info.id) && info.id !== viewerID, ); }, [callSearchUsers, usersWithPersonalThread, viewerID], ); const [threadSearchResults, setThreadSearchResults] = React.useState( new Set(), ); const [usersSearchResults, setUsersSearchResults] = React.useState< $ReadOnlyArray, >([]); const threadSearchIndex = useGlobalThreadSearchIndex(); React.useEffect(() => { void (async () => { const results = threadSearchIndex.getSearchResults(searchText); setThreadSearchResults(new Set(results)); const usersResults = await searchUsers(forwardLookupSearchText); setUsersSearchResults(usersResults); })(); }, [searchText, forwardLookupSearchText, threadSearchIndex, searchUsers]); return { threadSearchResults, usersSearchResults }; } function removeMemberFromThread( threadInfo: ThreadInfo, memberInfo: RelativeMemberInfo, dispatchActionPromise: DispatchActionPromise, removeUserFromThreadServerCall: ( input: RemoveUsersFromThreadInput, ) => Promise, ) { const customKeyName = `${removeUsersFromThreadActionTypes.started}:${memberInfo.id}`; void dispatchActionPromise( removeUsersFromThreadActionTypes, removeUserFromThreadServerCall({ threadID: threadInfo.id, memberIDs: [memberInfo.id], }), { customKeyName }, ); } function getAvailableThreadMemberActions( memberInfo: RelativeMemberInfo, threadInfo: ThreadInfo, canEdit: ?boolean = true, ): $ReadOnlyArray<'change_role' | 'remove_user'> { const role = memberInfo.role; if (!canEdit || !role) { return []; } const canRemoveMembers = threadHasPermission( threadInfo, threadPermissions.REMOVE_MEMBERS, ); const canChangeRoles = threadHasPermission( threadInfo, threadPermissions.CHANGE_ROLE, ); const result = []; if (canChangeRoles && memberInfo.username && threadHasAdminRole(threadInfo)) { result.push('change_role'); } if ( canRemoveMembers && !memberInfo.isViewer && (canChangeRoles || threadInfo.roles[role]?.isDefault) ) { result.push('remove_user'); } return result; } function patchThreadInfoToIncludeMentionedMembersOfParent( threadInfo: ThreadInfo, parentThreadInfo: ThreadInfo, messageText: string, viewerID: string, ): ThreadInfo { const members: UserIDAndUsername[] = threadInfo.members .map(({ id, username }) => username ? ({ id, username }: UserIDAndUsername) : null, ) .filter(Boolean); const mentionedNewMembers = extractNewMentionedParentMembers( messageText, threadInfo, parentThreadInfo, ); if (mentionedNewMembers.length === 0) { return threadInfo; } members.push(...mentionedNewMembers); return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members, parentThreadInfo, threadColor: threadInfo.color, name: threadInfo.name, sourceMessageID: threadInfo.sourceMessageID, }); } function threadInfoInsideCommunity( - threadInfo: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, + threadInfo: MinimallyEncodedRawThreadInfo | ThreadInfo, communityID: string, ): boolean { return threadInfo.community === communityID || threadInfo.id === communityID; } type RoleAndMemberCount = { [roleName: string]: number, }; function useRoleMemberCountsForCommunity( threadInfo: ThreadInfo, ): RoleAndMemberCount { return React.useMemo(() => { const roleIDsToNames: { [string]: string } = {}; Object.keys(threadInfo.roles).forEach(roleID => { roleIDsToNames[roleID] = threadInfo.roles[roleID].name; }); const roleNamesToMemberCount: RoleAndMemberCount = {}; threadInfo.members.forEach(({ role: roleID }) => { invariant(roleID, 'Community member should have a role'); const roleName = roleIDsToNames[roleID]; roleNamesToMemberCount[roleName] = (roleNamesToMemberCount[roleName] ?? 0) + 1; }); // For all community roles with no members, add them to the list with 0 Object.keys(roleIDsToNames).forEach(roleName => { if (roleNamesToMemberCount[roleIDsToNames[roleName]] === undefined) { roleNamesToMemberCount[roleIDsToNames[roleName]] = 0; } }); return roleNamesToMemberCount; }, [threadInfo]); } type RoleUserSurfacedPermissions = { +[roleName: string]: $ReadOnlySet, }; // Iterates through the existing roles in the community and 'reverse maps' // the set of permission literals for each role to user-facing permission enums // to help pre-populate the permission checkboxes when editing roles. function useRoleUserSurfacedPermissions( threadInfo: ThreadInfo, ): RoleUserSurfacedPermissions { return React.useMemo(() => { const roleNamesToPermissions: { [string]: Set } = {}; Object.keys(threadInfo.roles).forEach(roleID => { const roleName = threadInfo.roles[roleID].name; const rolePermissions = threadInfo.minimallyEncoded ? Object.keys( decodeMinimallyEncodedRoleInfo(threadInfo.roles[roleID]) .permissions, ) : Object.keys(threadInfo.roles[roleID].permissions); const setOfUserSurfacedPermissions = new Set(); rolePermissions.forEach(rolePermission => { const userSurfacedPermission = Object.keys( configurableCommunityPermissions, ).find(key => configurableCommunityPermissions[key].has(rolePermission), ); if (userSurfacedPermission) { setOfUserSurfacedPermissions.add(userSurfacedPermission); } }); roleNamesToPermissions[roleName] = setOfUserSurfacedPermissions; }); return roleNamesToPermissions; }, [threadInfo]); } function communityOrThreadNoun( - threadInfo: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, + threadInfo: MinimallyEncodedRawThreadInfo | ThreadInfo, ): string { return threadTypeIsCommunityRoot(threadInfo.type) ? 'community' : threadNoun(threadInfo.type, threadInfo.parentThreadID); } function getThreadsToDeleteText( - threadInfo: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, + threadInfo: MinimallyEncodedRawThreadInfo | ThreadInfo, ): string { return `${ threadTypeIsCommunityRoot(threadInfo.type) ? 'Subchannels and threads' : 'Threads' } within this ${communityOrThreadNoun(threadInfo)}`; } function useUserProfileThreadInfo(userInfo: ?UserInfo): ?UserProfileThreadInfo { const userID = userInfo?.id; const username = userInfo?.username; const loggedInUserInfo = useLoggedInUserInfo(); const isViewerProfile = loggedInUserInfo?.id === userID; const privateThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.PRIVATE, ); const privateThreadInfos = useSelector(privateThreadInfosSelector); const personalThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.PERSONAL, ); const personalThreadInfos = useSelector(personalThreadInfosSelector); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); return React.useMemo(() => { if (!loggedInUserInfo || !userID || !username) { return null; } if (isViewerProfile) { const privateThreadInfo: ?ThreadInfo = privateThreadInfos[0]; return privateThreadInfo ? { threadInfo: privateThreadInfo } : null; } if (usersWithPersonalThread.has(userID)) { const personalThreadInfo: ?ThreadInfo = personalThreadInfos.find( threadInfo => userID === getSingleOtherUser(threadInfo, loggedInUserInfo.id), ); return personalThreadInfo ? { threadInfo: personalThreadInfo } : null; } const pendingPersonalThreadInfo = createPendingPersonalThread( loggedInUserInfo, userID, username, ); return pendingPersonalThreadInfo; }, [ isViewerProfile, loggedInUserInfo, personalThreadInfos, privateThreadInfos, userID, username, usersWithPersonalThread, ]); } function assertAllThreadInfosAreLegacy(rawThreadInfos: RawThreadInfos): { [id: string]: LegacyRawThreadInfo, } { return Object.fromEntries( Object.entries(rawThreadInfos).map(([id, rawThreadInfo]) => { invariant( !rawThreadInfo.minimallyEncoded, `rawThreadInfos shouldn't be minimallyEncoded`, ); return [id, rawThreadInfo]; }), ); } export { threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadIsChannel, threadIsSidebar, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadOtherMembers, threadIsGroupChat, threadIsPending, threadIsPendingSidebar, getSingleOtherUser, getPendingThreadID, parsePendingThreadID, createPendingThread, createUnresolvedPendingSidebar, extractNewMentionedParentMembers, createPendingSidebar, pendingThreadType, createRealThreadFromPendingThread, getCurrentUser, getMinimallyEncodedCurrentUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, threadUIName, threadInfoFromRawThreadInfo, threadTypeDescriptions, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadNoun, threadLabel, useWatchThread, useExistingThreadInfoFinder, getThreadTypeParentRequirement, threadMemberHasPermission, useCanCreateSidebarFromMessage, useSidebarExistsOrCanBeCreated, checkIfDefaultMembersAreVoiced, draftKeySuffix, draftKeyFromThreadID, threadTypeCanBePending, getContainingThreadID, getCommunity, getThreadListSearchResults, useThreadListSearch, removeMemberFromThread, getAvailableThreadMemberActions, threadMembersWithoutAddedAshoat, patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, useRoleMemberCountsForCommunity, useRoleUserSurfacedPermissions, getThreadsToDeleteText, useUserProfileThreadInfo, assertAllThreadInfosAreLegacy, }; diff --git a/lib/shared/user-utils.js b/lib/shared/user-utils.js index 446a541a1..441165c99 100644 --- a/lib/shared/user-utils.js +++ b/lib/shared/user-utils.js @@ -1,53 +1,45 @@ // @flow import { memberHasAdminPowers } from './thread-utils.js'; import { useENSNames } from '../hooks/ens-cache.js'; import type { MinimallyEncodedRawThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { - LegacyRawThreadInfo, - ServerThreadInfo, - ThreadInfo, -} from '../types/thread-types.js'; +import type { ServerThreadInfo, ThreadInfo } from '../types/thread-types.js'; import type { UserInfo } from '../types/user-types.js'; import { useSelector } from '../utils/redux-utils.js'; function stringForUser( user: ?{ +username?: ?string, +isViewer?: ?boolean, ... }, ): string { if (user?.isViewer) { return 'you'; } return stringForUserExplicit(user); } function stringForUserExplicit(user: ?{ +username: ?string, ... }): string { if (user?.username) { return user.username; } return 'anonymous'; } function useKeyserverAdmin( - community: - | ThreadInfo - | LegacyRawThreadInfo - | MinimallyEncodedRawThreadInfo - | ServerThreadInfo, + community: ThreadInfo | MinimallyEncodedRawThreadInfo | ServerThreadInfo, ): ?UserInfo { const userInfos = useSelector(state => state.userStore.userInfos); // This hack only works as long as there is only one admin // Linear task to revert this: // https://linear.app/comm/issue/ENG-1707/revert-fix-getting-the-keyserver-admin-info const admin = community.members.find(memberHasAdminPowers); const adminUserInfo = admin ? userInfos[admin.id] : undefined; const [adminUserInfoWithENSName] = useENSNames([adminUserInfo]); return adminUserInfoWithENSName; } export { stringForUser, stringForUserExplicit, useKeyserverAdmin }; diff --git a/lib/utils/drawer-utils.react.js b/lib/utils/drawer-utils.react.js index eb9c28488..6ffa56c4c 100644 --- a/lib/utils/drawer-utils.react.js +++ b/lib/utils/drawer-utils.react.js @@ -1,129 +1,122 @@ // @flow import * as React from 'react'; import { values } from './objects.js'; import { threadInFilterList, threadIsChannel } from '../shared/thread-utils.js'; import type { MinimallyEncodedRawThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { communitySubthreads } from '../types/thread-types-enum.js'; -import type { - LegacyRawThreadInfo, - ResolvedThreadInfo, - ThreadInfo, -} from '../types/thread-types.js'; +import type { ResolvedThreadInfo, ThreadInfo } from '../types/thread-types.js'; type WritableCommunityDrawerItemData = { threadInfo: ThreadInfo, itemChildren: $ReadOnlyArray>, hasSubchannelsButton: boolean, labelStyle: T, }; export type CommunityDrawerItemData = $ReadOnly< WritableCommunityDrawerItemData, >; function createRecursiveDrawerItemsData( childThreadInfosMap: { +[id: string]: $ReadOnlyArray, }, communities: $ReadOnlyArray, labelStyles: $ReadOnlyArray, maxDepth: number, ): $ReadOnlyArray> { const result: $ReadOnlyArray< WritableCommunityDrawerItemData, > = communities.map(community => ({ threadInfo: community, itemChildren: [], labelStyle: labelStyles[0], hasSubchannelsButton: false, })); let queue = result.map(item => [item, 0]); for (let i = 0; i < queue.length; i++) { const [item, lvl] = queue[i]; const itemChildThreadInfos = childThreadInfosMap[item.threadInfo.id] ?? []; if (lvl < maxDepth) { item.itemChildren = itemChildThreadInfos .filter(childItem => communitySubthreads.includes(childItem.type)) .map(childItem => ({ threadInfo: childItem, itemChildren: [], labelStyle: labelStyles[Math.min(lvl + 1, labelStyles.length - 1)], hasSubchannelsButton: lvl + 1 === maxDepth && threadHasSubchannels(childItem, childThreadInfosMap), })); queue = queue.concat( item.itemChildren.map(childItem => [childItem, lvl + 1]), ); } } return result; } function threadHasSubchannels( threadInfo: ThreadInfo, childThreadInfosMap: { +[id: string]: $ReadOnlyArray, }, ): boolean { if (!childThreadInfosMap[threadInfo.id]?.length) { return false; } return childThreadInfosMap[threadInfo.id].some(thread => threadIsChannel(thread), ); } function useAppendCommunitySuffix( communities: $ReadOnlyArray, ): $ReadOnlyArray { return React.useMemo(() => { const result: ResolvedThreadInfo[] = []; const names = new Map(); for (const chat of communities) { let name = chat.uiName; const numberOfOccurrences = names.get(name); names.set(name, (numberOfOccurrences ?? 0) + 1); if (numberOfOccurrences) { name = `${name} (${numberOfOccurrences.toString()})`; } // Branching to appease `flow`. if (chat.minimallyEncoded) { result.push({ ...chat, uiName: name }); } else { result.push({ ...chat, uiName: name }); } } return result; }, [communities]); } function filterThreadIDsBelongingToCommunity( communityID: string, threadInfosObj: { - +[id: string]: - | LegacyRawThreadInfo - | MinimallyEncodedRawThreadInfo - | ThreadInfo, + +[id: string]: MinimallyEncodedRawThreadInfo | ThreadInfo, }, ): $ReadOnlySet { const threadInfos = values(threadInfosObj); const threadIDs = threadInfos .filter( thread => (thread.community === communityID || thread.id === communityID) && threadInFilterList(thread), ) .map(item => item.id); return new Set(threadIDs); } export { createRecursiveDrawerItemsData, useAppendCommunitySuffix, filterThreadIDsBelongingToCommunity, }; diff --git a/native/avatars/edit-thread-avatar.react.js b/native/avatars/edit-thread-avatar.react.js index d826139f5..9f1a86214 100644 --- a/native/avatars/edit-thread-avatar.react.js +++ b/native/avatars/edit-thread-avatar.react.js @@ -1,125 +1,122 @@ // @flow import { useNavigation } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { ActivityIndicator, TouchableOpacity, View } from 'react-native'; import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js'; import type { MinimallyEncodedRawThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { - LegacyRawThreadInfo, - ThreadInfo, -} from 'lib/types/thread-types.js'; +import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useNativeSetThreadAvatar, useSelectFromGalleryAndUpdateThreadAvatar, useShowAvatarActionSheet, } from './avatar-hooks.js'; import EditAvatarBadge from './edit-avatar-badge.react.js'; import ThreadAvatar from './thread-avatar.react.js'; import { EmojiThreadAvatarCreationRouteName, ThreadAvatarCameraModalRouteName, } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; type Props = { - +threadInfo: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, + +threadInfo: MinimallyEncodedRawThreadInfo | ThreadInfo, +disabled?: boolean, }; function EditThreadAvatar(props: Props): React.Node { const styles = useStyles(unboundStyles); const { threadInfo, disabled } = props; const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext should be set'); const { threadAvatarSaveInProgress } = editThreadAvatarContext; const nativeSetThreadAvatar = useNativeSetThreadAvatar(); const selectFromGalleryAndUpdateThreadAvatar = useSelectFromGalleryAndUpdateThreadAvatar(); const { navigate } = useNavigation(); const navigateToThreadEmojiAvatarCreation = React.useCallback(() => { navigate<'EmojiThreadAvatarCreation'>({ name: EmojiThreadAvatarCreationRouteName, params: { threadInfo, }, }); }, [navigate, threadInfo]); const selectFromGallery = React.useCallback( () => selectFromGalleryAndUpdateThreadAvatar(threadInfo.id), [selectFromGalleryAndUpdateThreadAvatar, threadInfo.id], ); const navigateToCamera = React.useCallback(() => { navigate<'ThreadAvatarCameraModal'>({ name: ThreadAvatarCameraModalRouteName, params: { threadID: threadInfo.id }, }); }, [navigate, threadInfo.id]); const removeAvatar = React.useCallback( () => nativeSetThreadAvatar(threadInfo.id, { type: 'remove' }), [nativeSetThreadAvatar, threadInfo.id], ); const actionSheetConfig = React.useMemo(() => { const configOptions = [ { id: 'emoji', onPress: navigateToThreadEmojiAvatarCreation }, { id: 'image', onPress: selectFromGallery }, { id: 'camera', onPress: navigateToCamera }, ]; if (threadInfo.avatar) { configOptions.push({ id: 'remove', onPress: removeAvatar }); } return configOptions; }, [ navigateToCamera, navigateToThreadEmojiAvatarCreation, removeAvatar, selectFromGallery, threadInfo.avatar, ]); const showAvatarActionSheet = useShowAvatarActionSheet(actionSheetConfig); let spinner; if (threadAvatarSaveInProgress) { spinner = ( ); } return ( {spinner} {!disabled ? : null} ); } const unboundStyles = { spinnerContainer: { position: 'absolute', alignItems: 'center', justifyContent: 'center', top: 0, bottom: 0, left: 0, right: 0, }, }; export default EditThreadAvatar; diff --git a/native/avatars/thread-avatar.react.js b/native/avatars/thread-avatar.react.js index a3d14de53..b54b16e8c 100644 --- a/native/avatars/thread-avatar.react.js +++ b/native/avatars/thread-avatar.react.js @@ -1,58 +1,50 @@ // @flow import * as React from 'react'; import { useAvatarForThread, useENSResolvedAvatar, } from 'lib/shared/avatar-utils.js'; import { getSingleOtherUser } from 'lib/shared/thread-utils.js'; import type { AvatarSize } from 'lib/types/avatar-types.js'; import type { MinimallyEncodedRawThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; -import type { - LegacyRawThreadInfo, - ResolvedThreadInfo, - ThreadInfo, -} from 'lib/types/thread-types.js'; +import type { ResolvedThreadInfo, ThreadInfo } from 'lib/types/thread-types.js'; import Avatar from './avatar.react.js'; import { useSelector } from '../redux/redux-utils.js'; type Props = { - +threadInfo: - | LegacyRawThreadInfo - | MinimallyEncodedRawThreadInfo - | ThreadInfo - | ResolvedThreadInfo, + +threadInfo: MinimallyEncodedRawThreadInfo | ThreadInfo | ResolvedThreadInfo, +size: AvatarSize, }; function ThreadAvatar(props: Props): React.Node { const { threadInfo, size } = props; const avatarInfo = useAvatarForThread(threadInfo); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); let displayUserIDForThread; if (threadInfo.type === threadTypes.PRIVATE) { displayUserIDForThread = viewerID; } else if (threadInfo.type === threadTypes.PERSONAL) { displayUserIDForThread = getSingleOtherUser(threadInfo, viewerID); } const displayUser = useSelector(state => displayUserIDForThread ? state.userStore.userInfos[displayUserIDForThread] : null, ); const resolvedThreadAvatar = useENSResolvedAvatar(avatarInfo, displayUser); return ; } export default ThreadAvatar; diff --git a/native/chat/settings/emoji-thread-avatar-creation.react.js b/native/chat/settings/emoji-thread-avatar-creation.react.js index 242f8b9d3..465df5c46 100644 --- a/native/chat/settings/emoji-thread-avatar-creation.react.js +++ b/native/chat/settings/emoji-thread-avatar-creation.react.js @@ -1,64 +1,61 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js'; import { savedEmojiAvatarSelectorForThread } from 'lib/selectors/thread-selectors.js'; import type { UpdateUserAvatarRequest } from 'lib/types/avatar-types.js'; import type { MinimallyEncodedRawThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { - LegacyRawThreadInfo, - ThreadInfo, -} from 'lib/types/thread-types.js'; +import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useNativeSetThreadAvatar } from '../../avatars/avatar-hooks.js'; import EmojiAvatarCreation from '../../avatars/emoji-avatar-creation.react.js'; import type { ChatNavigationProp } from '../../chat/chat.react.js'; import { displayActionResultModal } from '../../navigation/action-result-modal.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; export type EmojiThreadAvatarCreationParams = { - +threadInfo: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, + +threadInfo: MinimallyEncodedRawThreadInfo | ThreadInfo, }; type Props = { +navigation: ChatNavigationProp<'EmojiThreadAvatarCreation'>, +route: NavigationRoute<'EmojiThreadAvatarCreation'>, }; function EmojiThreadAvatarCreation(props: Props): React.Node { const { id: threadID, containingThreadID } = props.route.params.threadInfo; const selector = savedEmojiAvatarSelectorForThread( threadID, containingThreadID, ); const savedEmojiAvatarFunc = useSelector(selector); const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext should be set'); const { threadAvatarSaveInProgress } = editThreadAvatarContext; const nativeSetThreadAvatar = useNativeSetThreadAvatar(); const setAvatar = React.useCallback( async (avatarRequest: UpdateUserAvatarRequest) => { const result = await nativeSetThreadAvatar(threadID, avatarRequest); displayActionResultModal('Avatar updated!'); return result; }, [nativeSetThreadAvatar, threadID], ); return ( ); } export default EmojiThreadAvatarCreation; diff --git a/web/avatars/edit-thread-avatar-menu.react.js b/web/avatars/edit-thread-avatar-menu.react.js index 321480b7a..135bfe878 100644 --- a/web/avatars/edit-thread-avatar-menu.react.js +++ b/web/avatars/edit-thread-avatar-menu.react.js @@ -1,125 +1,122 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import type { MinimallyEncodedRawThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { - LegacyRawThreadInfo, - ThreadInfo, -} from 'lib/types/thread-types.js'; +import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useUploadAvatarMedia } from './avatar-hooks.react.js'; import css from './edit-avatar-menu.css'; import ThreadEmojiAvatarSelectionModal from './thread-emoji-avatar-selection-modal.react.js'; import MenuItem from '../components/menu-item.react.js'; import Menu from '../components/menu.react.js'; import { allowedMimeTypeString } from '../media/file-utils.js'; const editIcon = (
); type Props = { - +threadInfo: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, + +threadInfo: MinimallyEncodedRawThreadInfo | ThreadInfo, }; function EditThreadAvatarMenu(props: Props): React.Node { const { threadInfo } = props; const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext should be set'); const { baseSetThreadAvatar } = editThreadAvatarContext; const removeThreadAvatar = React.useCallback( () => baseSetThreadAvatar(threadInfo.id, { type: 'remove' }), [baseSetThreadAvatar, threadInfo.id], ); const removeMenuItem = React.useMemo( () => ( ), [removeThreadAvatar], ); const imageInputRef = React.useRef(); const onImageMenuItemClicked = React.useCallback( () => imageInputRef.current?.click(), [], ); const uploadAvatarMedia = useUploadAvatarMedia(); const onImageSelected = React.useCallback( async (event: SyntheticEvent) => { const { target } = event; invariant(target instanceof HTMLInputElement, 'target not input'); const uploadResult = await uploadAvatarMedia(target.files[0]); await baseSetThreadAvatar(threadInfo.id, uploadResult); }, [baseSetThreadAvatar, threadInfo.id, uploadAvatarMedia], ); const imageMenuItem = React.useMemo( () => ( ), [onImageMenuItemClicked], ); const { pushModal } = useModalContext(); const openEmojiSelectionModal = React.useCallback( () => pushModal(), [pushModal, threadInfo], ); const emojiMenuItem = React.useMemo( () => ( ), [openEmojiSelectionModal], ); const menuItems = React.useMemo(() => { const items = [emojiMenuItem, imageMenuItem]; if (threadInfo.avatar) { items.push(removeMenuItem); } return items; }, [emojiMenuItem, imageMenuItem, removeMenuItem, threadInfo.avatar]); return (
{menuItems}
); } export default EditThreadAvatarMenu; diff --git a/web/avatars/edit-thread-avatar.react.js b/web/avatars/edit-thread-avatar.react.js index 42b0e2730..0bb8319c2 100644 --- a/web/avatars/edit-thread-avatar.react.js +++ b/web/avatars/edit-thread-avatar.react.js @@ -1,52 +1,49 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js'; import { threadHasPermission } from 'lib/shared/thread-utils.js'; import type { MinimallyEncodedRawThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; -import type { - LegacyRawThreadInfo, - ThreadInfo, -} from 'lib/types/thread-types.js'; +import type { ThreadInfo } from 'lib/types/thread-types.js'; import EditThreadAvatarMenu from './edit-thread-avatar-menu.react.js'; import css from './edit-thread-avatar.css'; import ThreadAvatar from './thread-avatar.react.js'; type Props = { - +threadInfo: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, + +threadInfo: MinimallyEncodedRawThreadInfo | ThreadInfo, +disabled?: boolean, }; function EditThreadAvatar(props: Props): React.Node { const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext should be set'); const { threadAvatarSaveInProgress } = editThreadAvatarContext; const { threadInfo } = props; const canEditThreadAvatar = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_AVATAR, ); let editThreadAvatarMenu; if (canEditThreadAvatar && !threadAvatarSaveInProgress) { editThreadAvatarMenu = ; } return (
{editThreadAvatarMenu}
); } export default EditThreadAvatar; diff --git a/web/avatars/thread-avatar.react.js b/web/avatars/thread-avatar.react.js index 357f5c129..1165cb3a3 100644 --- a/web/avatars/thread-avatar.react.js +++ b/web/avatars/thread-avatar.react.js @@ -1,60 +1,57 @@ // @flow import * as React from 'react'; import { useAvatarForThread, useENSResolvedAvatar, } from 'lib/shared/avatar-utils.js'; import { getSingleOtherUser } from 'lib/shared/thread-utils.js'; import type { AvatarSize } from 'lib/types/avatar-types.js'; import type { MinimallyEncodedRawThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; -import type { - LegacyRawThreadInfo, - ThreadInfo, -} from 'lib/types/thread-types.js'; +import type { ThreadInfo } from 'lib/types/thread-types.js'; import Avatar from './avatar.react.js'; import { useSelector } from '../redux/redux-utils.js'; type Props = { - +threadInfo: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, + +threadInfo: MinimallyEncodedRawThreadInfo | ThreadInfo, +size: AvatarSize, +showSpinner?: boolean, }; function ThreadAvatar(props: Props): React.Node { const { threadInfo, size, showSpinner } = props; const avatarInfo = useAvatarForThread(threadInfo); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); let displayUserIDForThread; if (threadInfo.type === threadTypes.PRIVATE) { displayUserIDForThread = viewerID; } else if (threadInfo.type === threadTypes.PERSONAL) { displayUserIDForThread = getSingleOtherUser(threadInfo, viewerID); } const displayUser = useSelector(state => displayUserIDForThread ? state.userStore.userInfos[displayUserIDForThread] : null, ); const resolvedThreadAvatar = useENSResolvedAvatar(avatarInfo, displayUser); return ( ); } export default ThreadAvatar; diff --git a/web/avatars/thread-emoji-avatar-selection-modal.react.js b/web/avatars/thread-emoji-avatar-selection-modal.react.js index 057000861..afe2c0548 100644 --- a/web/avatars/thread-emoji-avatar-selection-modal.react.js +++ b/web/avatars/thread-emoji-avatar-selection-modal.react.js @@ -1,57 +1,54 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js'; import { getDefaultAvatar, useAvatarForThread, } from 'lib/shared/avatar-utils.js'; import type { ClientAvatar, ClientEmojiAvatar, } from 'lib/types/avatar-types.js'; import type { MinimallyEncodedRawThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { - LegacyRawThreadInfo, - ThreadInfo, -} from 'lib/types/thread-types.js'; +import type { ThreadInfo } from 'lib/types/thread-types.js'; import EmojiAvatarSelectionModal from './emoji-avatar-selection-modal.react.js'; type Props = { - +threadInfo: LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo | ThreadInfo, + +threadInfo: MinimallyEncodedRawThreadInfo | ThreadInfo, }; function ThreadEmojiAvatarSelectionModal(props: Props): React.Node { const { threadInfo } = props; const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext should be set'); const { baseSetThreadAvatar, threadAvatarSaveInProgress } = editThreadAvatarContext; const currentThreadAvatar: ClientAvatar = useAvatarForThread(threadInfo); const defaultThreadAvatar: ClientEmojiAvatar = getDefaultAvatar( threadInfo.id, threadInfo.color, ); const setEmojiAvatar = React.useCallback( (pendingEmojiAvatar: ClientEmojiAvatar): Promise => baseSetThreadAvatar(threadInfo.id, pendingEmojiAvatar), [baseSetThreadAvatar, threadInfo.id], ); return ( ); } export default ThreadEmojiAvatarSelectionModal; diff --git a/web/selectors/thread-selectors.js b/web/selectors/thread-selectors.js index ca20a0e54..6c3a47d1f 100644 --- a/web/selectors/thread-selectors.js +++ b/web/selectors/thread-selectors.js @@ -1,159 +1,165 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { createSelector } from 'reselect'; import { ENSCacheContext } from 'lib/components/ens-cache-provider.react.js'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; import { createPendingSidebar, threadInHomeChatList, } from 'lib/shared/thread-utils.js'; import type { ComposableMessageInfo, RobotextMessageInfo, } from 'lib/types/message-types.js'; -import type { RawThreadInfos, ThreadInfo } from 'lib/types/thread-types.js'; +import type { + MinimallyEncodedRawThreadInfos, + ThreadInfo, +} from 'lib/types/thread-types.js'; import { values } from 'lib/utils/objects.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { getDefaultTextMessageRules } from '../markdown/rules.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import type { AppState } from '../redux/redux-setup.js'; import { useSelector } from '../redux/redux-utils.js'; function useOnClickThread( thread: ?ThreadInfo, ): (event: SyntheticEvent) => void { const dispatch = useDispatch(); return React.useCallback( (event: SyntheticEvent) => { invariant( thread?.id, 'useOnClickThread should be called with threadID set', ); event.preventDefault(); const { id: threadID } = thread; let payload; if (threadID.includes('pending')) { payload = { chatMode: 'view', activeChatThreadID: threadID, pendingThread: thread, tab: 'chat', }; } else { payload = { chatMode: 'view', activeChatThreadID: threadID, tab: 'chat', }; } dispatch({ type: updateNavInfoActionType, payload }); }, [dispatch, thread], ); } function useThreadIsActive(threadID: string): boolean { return useSelector(state => threadID === state.navInfo.activeChatThreadID); } function useOnClickPendingSidebar( messageInfo: ComposableMessageInfo | RobotextMessageInfo, threadInfo: ThreadInfo, ): (event: SyntheticEvent) => mixed { const dispatch = useDispatch(); const loggedInUserInfo = useLoggedInUserInfo(); const cacheContext = React.useContext(ENSCacheContext); const { getENSNames } = cacheContext; const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); return React.useCallback( async (event: SyntheticEvent) => { event.preventDefault(); if (!loggedInUserInfo) { return; } const pendingSidebarInfo = await createPendingSidebar({ sourceMessageInfo: messageInfo, parentThreadInfo: threadInfo, loggedInUserInfo, markdownRules: getDefaultTextMessageRules(chatMentionCandidates) .simpleMarkdownRules, getENSNames, }); dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: pendingSidebarInfo.id, pendingThread: pendingSidebarInfo, }, }); }, [ loggedInUserInfo, chatMentionCandidates, threadInfo, messageInfo, getENSNames, dispatch, ], ); } function useOnClickNewThread(): (event: SyntheticEvent) => void { const dispatch = useDispatch(); return React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); dispatch({ type: updateNavInfoActionType, payload: { chatMode: 'create', selectedUserList: [], }, }); }, [dispatch], ); } function useDrawerSelectedThreadID(): ?string { const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const pickedCommunityID = useSelector( state => state.communityPickerStore.calendar, ); const inCalendar = useSelector(state => state.navInfo.tab === 'calendar'); return inCalendar ? pickedCommunityID : activeChatThreadID; } const unreadCountInSelectedCommunity: (state: AppState) => number = createSelector( (state: AppState) => state.threadStore.threadInfos, (state: AppState) => state.communityPickerStore.chat, - (threadInfos: RawThreadInfos, communityID: ?string): number => + ( + threadInfos: MinimallyEncodedRawThreadInfos, + communityID: ?string, + ): number => values(threadInfos).filter( threadInfo => threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread && (!communityID || communityID === threadInfo.community), ).length, ); export { useOnClickThread, useThreadIsActive, useOnClickPendingSidebar, useOnClickNewThread, useDrawerSelectedThreadID, unreadCountInSelectedCommunity, };