diff --git a/lib/components/chat-mention-provider.react.js b/lib/components/chat-mention-provider.react.js index 1c9250a06..1e6987bed 100644 --- a/lib/components/chat-mention-provider.react.js +++ b/lib/components/chat-mention-provider.react.js @@ -1,280 +1,279 @@ // @flow import * as React from 'react'; import genesis from '../facts/genesis.js'; import { threadInfoSelector } from '../selectors/thread-selectors.js'; import SentencePrefixSearchIndex from '../shared/sentence-prefix-search-index.js'; import type { ResolvedThreadInfo, ThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { ChatMentionCandidate, ChatMentionCandidatesObj, - LegacyThreadInfo, } from '../types/thread-types.js'; import { useResolvedThreadInfosObj } from '../utils/entity-helpers.js'; import { getNameForThreadEntity } from '../utils/entity-text.js'; import { useSelector } from '../utils/redux-utils.js'; type Props = { +children: React.Node, }; export type ChatMentionContextType = { +getChatMentionSearchIndex: ( - threadInfo: LegacyThreadInfo | ThreadInfo, + threadInfo: ThreadInfo, ) => SentencePrefixSearchIndex, +communityThreadIDForGenesisThreads: { +[id: string]: string }, +chatMentionCandidatesObj: ChatMentionCandidatesObj, }; const emptySearchIndex = new SentencePrefixSearchIndex(); const ChatMentionContext: React.Context = React.createContext({ getChatMentionSearchIndex: () => emptySearchIndex, communityThreadIDForGenesisThreads: {}, chatMentionCandidatesObj: {}, }); function ChatMentionContextProvider(props: Props): React.Node { const { children } = props; const { communityThreadIDForGenesisThreads, chatMentionCandidatesObj } = useChatMentionCandidatesObjAndUtils(); const searchIndices = useChatMentionSearchIndex(chatMentionCandidatesObj); const getChatMentionSearchIndex = React.useCallback( - (threadInfo: LegacyThreadInfo | ThreadInfo) => { + (threadInfo: ThreadInfo) => { if (threadInfo.community === genesis.id) { return searchIndices[communityThreadIDForGenesisThreads[threadInfo.id]]; } return searchIndices[threadInfo.community ?? threadInfo.id]; }, [communityThreadIDForGenesisThreads, searchIndices], ); const value = React.useMemo( () => ({ getChatMentionSearchIndex, communityThreadIDForGenesisThreads, chatMentionCandidatesObj, }), [ getChatMentionSearchIndex, communityThreadIDForGenesisThreads, chatMentionCandidatesObj, ], ); return ( {children} ); } function getChatMentionCandidates( threadInfos: { +[id: string]: ThreadInfo }, resolvedThreadInfos: { +[id: string]: ResolvedThreadInfo, }, ): { chatMentionCandidatesObj: ChatMentionCandidatesObj, communityThreadIDForGenesisThreads: { +[id: string]: string }, } { const result: { [string]: { [string]: ChatMentionCandidate, }, } = {}; const visitedGenesisThreads = new Set(); const communityThreadIDForGenesisThreads: { [string]: string } = {}; for (const currentThreadID in resolvedThreadInfos) { const currentResolvedThreadInfo = resolvedThreadInfos[currentThreadID]; const { community: currentThreadCommunity } = currentResolvedThreadInfo; if (!currentThreadCommunity) { if (!result[currentThreadID]) { result[currentThreadID] = { [currentThreadID]: { threadInfo: currentResolvedThreadInfo, rawChatName: threadInfos[currentThreadID].uiName, }, }; } continue; } if (!result[currentThreadCommunity]) { result[currentThreadCommunity] = {}; result[currentThreadCommunity][currentThreadCommunity] = { threadInfo: resolvedThreadInfos[currentThreadCommunity], rawChatName: threadInfos[currentThreadCommunity].uiName, }; } // Handle GENESIS community case: mentioning inside GENESIS should only // show chats and threads inside the top level that is below GENESIS. if ( resolvedThreadInfos[currentThreadCommunity].type === threadTypes.GENESIS ) { if (visitedGenesisThreads.has(currentThreadID)) { continue; } const threadTraversePath = [currentResolvedThreadInfo]; visitedGenesisThreads.add(currentThreadID); let currentlySelectedThreadID = currentResolvedThreadInfo.parentThreadID; while (currentlySelectedThreadID) { const currentlySelectedThreadInfo = resolvedThreadInfos[currentlySelectedThreadID]; if ( visitedGenesisThreads.has(currentlySelectedThreadID) || !currentlySelectedThreadInfo || currentlySelectedThreadInfo.type === threadTypes.GENESIS ) { break; } threadTraversePath.push(currentlySelectedThreadInfo); visitedGenesisThreads.add(currentlySelectedThreadID); currentlySelectedThreadID = currentlySelectedThreadInfo.parentThreadID; } const lastThreadInTraversePath = threadTraversePath[threadTraversePath.length - 1]; let lastThreadInTraversePathParentID; if (lastThreadInTraversePath.parentThreadID) { lastThreadInTraversePathParentID = resolvedThreadInfos[ lastThreadInTraversePath.parentThreadID ] ? lastThreadInTraversePath.parentThreadID : lastThreadInTraversePath.id; } else { lastThreadInTraversePathParentID = lastThreadInTraversePath.id; } if ( resolvedThreadInfos[lastThreadInTraversePathParentID].type === threadTypes.GENESIS ) { if (!result[lastThreadInTraversePath.id]) { result[lastThreadInTraversePath.id] = {}; } for (const threadInfo of threadTraversePath) { result[lastThreadInTraversePath.id][threadInfo.id] = { threadInfo, rawChatName: threadInfos[threadInfo.id].uiName, }; communityThreadIDForGenesisThreads[threadInfo.id] = lastThreadInTraversePath.id; } if ( lastThreadInTraversePath.type !== threadTypes.PERSONAL && lastThreadInTraversePath.type !== threadTypes.PRIVATE ) { result[genesis.id][lastThreadInTraversePath.id] = { threadInfo: lastThreadInTraversePath, rawChatName: threadInfos[lastThreadInTraversePath.id].uiName, }; } } else { if ( !communityThreadIDForGenesisThreads[lastThreadInTraversePathParentID] ) { result[lastThreadInTraversePathParentID] = {}; communityThreadIDForGenesisThreads[lastThreadInTraversePathParentID] = lastThreadInTraversePathParentID; } const lastThreadInTraversePathParentCommunityThreadID = communityThreadIDForGenesisThreads[lastThreadInTraversePathParentID]; for (const threadInfo of threadTraversePath) { result[lastThreadInTraversePathParentCommunityThreadID][ threadInfo.id ] = { threadInfo, rawChatName: threadInfos[threadInfo.id].uiName, }; communityThreadIDForGenesisThreads[threadInfo.id] = lastThreadInTraversePathParentCommunityThreadID; } } continue; } result[currentThreadCommunity][currentThreadID] = { threadInfo: currentResolvedThreadInfo, rawChatName: threadInfos[currentThreadID].uiName, }; } return { chatMentionCandidatesObj: result, communityThreadIDForGenesisThreads, }; } // Without allAtOnce, useChatMentionCandidatesObjAndUtils is very expensive. // useResolvedThreadInfosObj would trigger its recalculation for each ENS name // as it streams in, but we would prefer to trigger its recaculation just once // for every update of the underlying Redux data. const useResolvedThreadInfosObjOptions = { allAtOnce: true }; function useChatMentionCandidatesObjAndUtils(): { chatMentionCandidatesObj: ChatMentionCandidatesObj, resolvedThreadInfos: { +[id: string]: ResolvedThreadInfo, }, communityThreadIDForGenesisThreads: { +[id: string]: string }, } { const threadInfos = useSelector(threadInfoSelector); const resolvedThreadInfos = useResolvedThreadInfosObj( threadInfos, useResolvedThreadInfosObjOptions, ); const { chatMentionCandidatesObj, communityThreadIDForGenesisThreads } = React.useMemo( () => getChatMentionCandidates(threadInfos, resolvedThreadInfos), [threadInfos, resolvedThreadInfos], ); return { chatMentionCandidatesObj, resolvedThreadInfos, communityThreadIDForGenesisThreads, }; } function useChatMentionSearchIndex( chatMentionCandidatesObj: ChatMentionCandidatesObj, ): { +[id: string]: SentencePrefixSearchIndex, } { return React.useMemo(() => { const result: { [string]: SentencePrefixSearchIndex } = {}; for (const communityThreadID in chatMentionCandidatesObj) { const searchIndex = new SentencePrefixSearchIndex(); const searchIndexEntries = []; for (const threadID in chatMentionCandidatesObj[communityThreadID]) { searchIndexEntries.push({ id: threadID, uiName: chatMentionCandidatesObj[communityThreadID][threadID].threadInfo .uiName, rawChatName: chatMentionCandidatesObj[communityThreadID][threadID].rawChatName, }); } // Sort the keys so that the order of the search result is consistent searchIndexEntries.sort(({ uiName: uiNameA }, { uiName: uiNameB }) => uiNameA.localeCompare(uiNameB), ); for (const { id, uiName, rawChatName } of searchIndexEntries) { const names = [uiName]; if (rawChatName) { typeof rawChatName === 'string' ? names.push(rawChatName) : names.push(getNameForThreadEntity(rawChatName)); } searchIndex.addEntry(id, names.join(' ')); } result[communityThreadID] = searchIndex; } return result; }, [chatMentionCandidatesObj]); } export { ChatMentionContextProvider, ChatMentionContext }; diff --git a/lib/hooks/chat-mention-hooks.js b/lib/hooks/chat-mention-hooks.js index 9183d1cee..64e1db2a6 100644 --- a/lib/hooks/chat-mention-hooks.js +++ b/lib/hooks/chat-mention-hooks.js @@ -1,48 +1,45 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { ChatMentionContext, type ChatMentionContextType, } from '../components/chat-mention-provider.react.js'; import genesis from '../facts/genesis.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { - ChatMentionCandidates, - LegacyThreadInfo, -} from '../types/thread-types.js'; +import type { ChatMentionCandidates } from '../types/thread-types.js'; function useChatMentionContext(): ChatMentionContextType { const context = React.useContext(ChatMentionContext); invariant(context, 'ChatMentionContext not found'); return context; } function useThreadChatMentionCandidates( - threadInfo: LegacyThreadInfo | ThreadInfo, + threadInfo: ThreadInfo, ): ChatMentionCandidates { const { communityThreadIDForGenesisThreads, chatMentionCandidatesObj } = useChatMentionContext(); return React.useMemo(() => { const communityID = threadInfo.community === genesis.id ? communityThreadIDForGenesisThreads[threadInfo.id] : threadInfo.community ?? threadInfo.id; const allChatsWithinCommunity = chatMentionCandidatesObj[communityID]; if (!allChatsWithinCommunity) { return {}; } const { [threadInfo.id]: _, ...result } = allChatsWithinCommunity; return result; }, [ chatMentionCandidatesObj, communityThreadIDForGenesisThreads, threadInfo.community, threadInfo.id, ]); } export { useThreadChatMentionCandidates, useChatMentionContext }; diff --git a/lib/hooks/child-threads.js b/lib/hooks/child-threads.js index 87379b9f8..be24acfcf 100644 --- a/lib/hooks/child-threads.js +++ b/lib/hooks/child-threads.js @@ -1,126 +1,125 @@ // @flow import * as React from 'react'; import { fetchSingleMostRecentMessagesFromThreadsActionTypes, useFetchSingleMostRecentMessagesFromThreads, } from '../actions/message-actions.js'; import { type ChatThreadItem, useFilteredChatListData, } 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 { ThreadInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from '../types/thread-types.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; type ThreadFilter = { - +predicate?: (thread: LegacyThreadInfo | ThreadInfo) => boolean, + +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: ?(RawThreadInfo | LegacyThreadInfo | ThreadInfo)) => { + (thread: ?(RawThreadInfo | 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/promote-sidebar.react.js b/lib/hooks/promote-sidebar.react.js index b342548ef..031c85b6a 100644 --- a/lib/hooks/promote-sidebar.react.js +++ b/lib/hooks/promote-sidebar.react.js @@ -1,96 +1,94 @@ // @flow import * as React from 'react'; import { changeThreadSettingsActionTypes, useChangeThreadSettings, } from '../actions/thread-actions.js'; import { createLoadingStatusSelector } from '../selectors/loading-selectors.js'; import { threadInfoSelector } from '../selectors/thread-selectors.js'; import { threadHasPermission, threadIsSidebar, } from '../shared/thread-utils.js'; import type { LoadingStatus } from '../types/loading-types.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; -import type { LegacyThreadInfo } from '../types/thread-types.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; function canPromoteSidebar( - sidebarThreadInfo: LegacyThreadInfo | ThreadInfo, - parentThreadInfo: ?LegacyThreadInfo | ?ThreadInfo, + sidebarThreadInfo: ThreadInfo, + parentThreadInfo: ?ThreadInfo, ): boolean { if (!threadIsSidebar(sidebarThreadInfo)) { return false; } const canChangeThreadType = threadHasPermission( sidebarThreadInfo, threadPermissions.EDIT_PERMISSIONS, ); const canCreateSubchannelsInParent = threadHasPermission( parentThreadInfo, threadPermissions.CREATE_SUBCHANNELS, ); return canChangeThreadType && canCreateSubchannelsInParent; } type PromoteSidebarType = { +onPromoteSidebar: () => void, +loading: LoadingStatus, +canPromoteSidebar: boolean, }; function usePromoteSidebar( - threadInfo: LegacyThreadInfo | ThreadInfo, + threadInfo: ThreadInfo, onError?: () => mixed, ): PromoteSidebarType { const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useChangeThreadSettings(); const loadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, ); const loadingStatus = useSelector(loadingStatusSelector); const { parentThreadID } = threadInfo; - const parentThreadInfo: ?LegacyThreadInfo | ?ThreadInfo = useSelector( - state => - parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, + const parentThreadInfo: ?ThreadInfo = useSelector(state => + parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const canPromote = canPromoteSidebar(threadInfo, parentThreadInfo); const onClick = React.useCallback(() => { try { void dispatchActionPromise( changeThreadSettingsActionTypes, (async () => { return await callChangeThreadSettings({ threadID: threadInfo.id, changes: { type: threadTypes.COMMUNITY_OPEN_SUBTHREAD }, }); })(), ); } catch (e) { onError?.(); throw e; } }, [threadInfo.id, callChangeThreadSettings, dispatchActionPromise, onError]); const returnValues = React.useMemo( () => ({ onPromoteSidebar: onClick, loading: loadingStatus, canPromoteSidebar: canPromote, }), [onClick, loadingStatus, canPromote], ); return returnValues; } export { usePromoteSidebar, canPromoteSidebar }; diff --git a/lib/hooks/relationship-prompt.js b/lib/hooks/relationship-prompt.js index 234ce4795..7e255c224 100644 --- a/lib/hooks/relationship-prompt.js +++ b/lib/hooks/relationship-prompt.js @@ -1,128 +1,127 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { updateRelationships as serverUpdateRelationships, updateRelationshipsActionTypes, } from '../actions/relationship-actions.js'; import { getSingleOtherUser } from '../shared/thread-utils.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { type RelationshipAction, relationshipActions, } from '../types/relationship-types.js'; -import type { LegacyThreadInfo } from '../types/thread-types.js'; import type { UserInfo } from '../types/user-types.js'; import { useLegacyAshoatKeyserverCall } from '../utils/action-utils.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; type RelationshipCallbacks = { +blockUser: () => void, +unblockUser: () => void, +friendUser: () => void, +unfriendUser: () => void, }; type RelationshipPromptData = { +otherUserInfo: ?UserInfo, +callbacks: RelationshipCallbacks, }; function useRelationshipPrompt( - threadInfo: LegacyThreadInfo | ThreadInfo, + threadInfo: ThreadInfo, onErrorCallback?: () => void, pendingPersonalThreadUserInfo?: ?UserInfo, ): RelationshipPromptData { // We're fetching the info from state because we need the most recent // relationship status. Additionally, member info does not contain info // about relationship. const otherUserInfo = useSelector(state => { const otherUserID = getSingleOtherUser(threadInfo, state.currentUserInfo?.id) ?? pendingPersonalThreadUserInfo?.id; const { userInfos } = state.userStore; return otherUserID && userInfos[otherUserID] ? userInfos[otherUserID] : pendingPersonalThreadUserInfo; }); const callbacks = useRelationshipCallbacks( otherUserInfo?.id, onErrorCallback, ); return React.useMemo( () => ({ otherUserInfo, callbacks, }), [callbacks, otherUserInfo], ); } function useRelationshipCallbacks( otherUserID?: string, onErrorCallback?: () => void, ): RelationshipCallbacks { const callUpdateRelationships = useLegacyAshoatKeyserverCall( serverUpdateRelationships, ); const updateRelationship = React.useCallback( async (action: RelationshipAction) => { try { invariant(otherUserID, 'Other user info id should be present'); return await callUpdateRelationships({ action, userIDs: [otherUserID], }); } catch (e) { onErrorCallback?.(); throw e; } }, [callUpdateRelationships, onErrorCallback, otherUserID], ); const dispatchActionPromise = useDispatchActionPromise(); const onButtonPress = React.useCallback( (action: RelationshipAction) => { void dispatchActionPromise( updateRelationshipsActionTypes, updateRelationship(action), ); }, [dispatchActionPromise, updateRelationship], ); const blockUser = React.useCallback( () => onButtonPress(relationshipActions.BLOCK), [onButtonPress], ); const unblockUser = React.useCallback( () => onButtonPress(relationshipActions.UNBLOCK), [onButtonPress], ); const friendUser = React.useCallback( () => onButtonPress(relationshipActions.FRIEND), [onButtonPress], ); const unfriendUser = React.useCallback( () => onButtonPress(relationshipActions.UNFRIEND), [onButtonPress], ); return React.useMemo( () => ({ blockUser, unblockUser, friendUser, unfriendUser, }), [blockUser, friendUser, unblockUser, unfriendUser], ); } export { useRelationshipPrompt, useRelationshipCallbacks }; diff --git a/lib/hooks/search-threads.js b/lib/hooks/search-threads.js index f0ef539fc..89f3bd631 100644 --- a/lib/hooks/search-threads.js +++ b/lib/hooks/search-threads.js @@ -1,114 +1,114 @@ // @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 { ThreadInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo, SidebarInfo } from '../types/thread-types.js'; +import type { SidebarInfo } 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: LegacyThreadInfo | ThreadInfo, + 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: LegacyThreadInfo | ThreadInfo, + threadInfo: ThreadInfo, ): SearchThreadsResult { const childThreadInfos = useSelector( state => sidebarInfoSelector(state)[threadInfo.id] ?? [], ); return useSearchThreads(threadInfo, childThreadInfos); } function useSearchSubchannels( - threadInfo: LegacyThreadInfo | ThreadInfo, + threadInfo: ThreadInfo, ): SearchThreadsResult { const filterFunc = React.useCallback( - (thread: ?(LegacyThreadInfo | ThreadInfo | RawThreadInfo)) => + (thread: ?(ThreadInfo | RawThreadInfo)) => threadIsChannel(thread) && thread?.parentThreadID === threadInfo.id, [threadInfo.id], ); const childThreadInfos = useFilteredChatListData(filterFunc); return useSearchThreads(threadInfo, childThreadInfos); } export { useSearchSubchannels, useSearchSidebars }; diff --git a/lib/hooks/toggle-unread-status.js b/lib/hooks/toggle-unread-status.js index 831288a6b..f9378e00b 100644 --- a/lib/hooks/toggle-unread-status.js +++ b/lib/hooks/toggle-unread-status.js @@ -1,55 +1,54 @@ // @flow import * as React from 'react'; import { setThreadUnreadStatusActionTypes, useSetThreadUnreadStatus, } from '../actions/activity-actions.js'; import type { SetThreadUnreadStatusPayload, SetThreadUnreadStatusRequest, } from '../types/activity-types.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from '../types/thread-types.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; function useToggleUnreadStatus( - threadInfo: LegacyThreadInfo | ThreadInfo, + threadInfo: ThreadInfo, mostRecentNonLocalMessage: ?string, afterAction: () => void, ): () => void { const dispatchActionPromise = useDispatchActionPromise(); const { currentUser } = threadInfo; const boundSetThreadUnreadStatus: ( request: SetThreadUnreadStatusRequest, ) => Promise = useSetThreadUnreadStatus(); const toggleUnreadStatus = React.useCallback(() => { const request = { threadID: threadInfo.id, unread: !currentUser.unread, latestMessage: mostRecentNonLocalMessage, }; void dispatchActionPromise( setThreadUnreadStatusActionTypes, boundSetThreadUnreadStatus(request), undefined, ({ threadID: threadInfo.id, unread: !currentUser.unread, }: { +threadID: string, +unread: boolean }), ); afterAction(); }, [ threadInfo.id, currentUser.unread, mostRecentNonLocalMessage, dispatchActionPromise, afterAction, boundSetThreadUnreadStatus, ]); return toggleUnreadStatus; } export default useToggleUnreadStatus; diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js index 92558f6be..f577ed879 100644 --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -1,716 +1,713 @@ // @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 { sidebarInfoSelector, threadInfoFromSourceMessageIDSelector, threadInfoSelector, } from './thread-selectors.js'; import { createMessageInfo, getMostRecentNonLocalMessageID, messageKey, robotextForMessageInfo, sortMessageInfoList, } from '../shared/message-utils.js'; import { threadInChatList, threadIsPending, threadIsTopLevel, } from '../shared/thread-utils.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type ComposableMessageInfo, isComposableMessageType, type LocalMessageInfo, type MessageInfo, type MessageStore, type RobotextMessageInfo, } from '../types/message-types.js'; import type { ThreadInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import { maxReadSidebars, maxUnreadSidebars, type SidebarInfo, - type LegacyThreadInfo, } from '../types/thread-types.js'; import type { AccountUserInfo, RelativeUserInfo, UserInfo, } 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: LegacyThreadInfo | ThreadInfo, + 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: LegacyThreadInfo | ThreadInfo, + 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: ?(LegacyThreadInfo | ThreadInfo | RawThreadInfo), - ) => boolean, + filterFunction: (threadInfo: ?(ThreadInfo | RawThreadInfo)) => 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 | RawThreadInfo)) => 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: ?LegacyThreadInfo | ?ThreadInfo, + +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 a56794b56..a23844eb7 100644 --- a/lib/selectors/nav-selectors.js +++ b/lib/selectors/nav-selectors.js @@ -1,217 +1,214 @@ // @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 { ThreadInfo, RawThreadInfo, } 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 { - LegacyThreadInfo, - RelativeMemberInfo, -} from '../types/thread-types'; +import type { RelativeMemberInfo } 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, + 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/shared/avatar-utils.js b/lib/shared/avatar-utils.js index b0a27c592..657ca6c50 100644 --- a/lib/shared/avatar-utils.js +++ b/lib/shared/avatar-utils.js @@ -1,376 +1,367 @@ // @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 { ClientAvatar, ClientEmojiAvatar, GenericUserInfoWithAvatar, ResolvedClientAvatar, } from '../types/avatar-types.js'; import type { ThreadInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; -import type { - LegacyRawThreadInfo, - LegacyThreadInfo, -} from '../types/thread-types.js'; +import type { LegacyRawThreadInfo } 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 - | RawThreadInfo - | LegacyThreadInfo - | ThreadInfo, + threadInfo: LegacyRawThreadInfo | RawThreadInfo | 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: RawThreadInfo | LegacyThreadInfo | ThreadInfo, - containingThreadInfo: ?LegacyThreadInfo | ?ThreadInfo, + thread: RawThreadInfo | 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: RawThreadInfo | LegacyThreadInfo | ThreadInfo, -): ClientAvatar { +function useAvatarForThread(thread: RawThreadInfo | 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/edit-messages-utils.js b/lib/shared/edit-messages-utils.js index 1dde2fab0..fcd604dcb 100644 --- a/lib/shared/edit-messages-utils.js +++ b/lib/shared/edit-messages-utils.js @@ -1,89 +1,88 @@ // @flow import * as React from 'react'; import { threadHasPermission, threadIsPending } from './thread-utils.js'; import { sendEditMessageActionTypes, useSendEditMessage, } from '../actions/message-actions.js'; import type { ComposableMessageInfo, RawMessageInfo, RobotextMessageInfo, SendEditMessageResult, } from '../types/message-types'; import { messageTypes } from '../types/message-types-enum.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; -import type { LegacyThreadInfo } from '../types/thread-types.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; function useEditMessage(): ( messageID: string, newText: string, ) => Promise { const callEditMessage = useSendEditMessage(); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( (messageID, newText) => { const editMessagePromise = (async () => { const result = await callEditMessage({ targetMessageID: messageID, text: newText, }); return ({ newMessageInfos: result.newMessageInfos, }: { +newMessageInfos: $ReadOnlyArray }); })(); void dispatchActionPromise( sendEditMessageActionTypes, editMessagePromise, ); return editMessagePromise; }, [dispatchActionPromise, callEditMessage], ); } function useCanEditMessage( - threadInfo: LegacyThreadInfo | ThreadInfo, + threadInfo: ThreadInfo, targetMessageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const currentUserInfo = useSelector(state => state.currentUserInfo); if (targetMessageInfo.type !== messageTypes.TEXT) { return false; } if (!currentUserInfo || !currentUserInfo.id) { return false; } const currentUserId = currentUserInfo.id; const targetMessageCreatorId = targetMessageInfo.creator.id; if (currentUserId !== targetMessageCreatorId) { return false; } const hasPermission = threadHasPermission( threadInfo, threadPermissions.EDIT_MESSAGE, ); return hasPermission; } function getMessageLabel(hasBeenEdited: ?boolean, threadID: string): ?string { const isPending = threadIsPending(threadID); if (hasBeenEdited && !isPending) { return 'Edited'; } return null; } export { useCanEditMessage, useEditMessage, getMessageLabel }; diff --git a/lib/shared/inline-engagement-utils.js b/lib/shared/inline-engagement-utils.js index 7cbdf5eae..ebc39a4e7 100644 --- a/lib/shared/inline-engagement-utils.js +++ b/lib/shared/inline-engagement-utils.js @@ -1,28 +1,25 @@ // @flow import type { ReactionInfo } from '../selectors/chat-selectors.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from '../types/thread-types.js'; -function getInlineEngagementSidebarText( - threadInfo: ?LegacyThreadInfo | ?ThreadInfo, -): string { +function getInlineEngagementSidebarText(threadInfo: ?ThreadInfo): string { if (!threadInfo) { return ''; } const repliesCount = threadInfo.repliesCount || 1; return `${repliesCount} ${repliesCount > 1 ? 'replies' : 'reply'}`; } function reactionsToRawString(reactions: ReactionInfo): string { const reactionStringParts = []; for (const reaction in reactions) { const reactionInfo = reactions[reaction]; reactionStringParts.push(`${reaction}${reactionInfo.users.length}`); } return reactionStringParts.join(''); } export { getInlineEngagementSidebarText, reactionsToRawString }; diff --git a/lib/shared/mention-utils.js b/lib/shared/mention-utils.js index 060f76a87..31789e23d 100644 --- a/lib/shared/mention-utils.js +++ b/lib/shared/mention-utils.js @@ -1,219 +1,218 @@ // @flow import * as React from 'react'; import { markdownUserMentionRegexString } from './account-utils.js'; import SentencePrefixSearchIndex from './sentence-prefix-search-index.js'; import { stringForUserExplicit } from './user-utils.js'; import { useENSNames } from '../hooks/ens-cache.js'; import { useUserSearchIndex } from '../selectors/nav-selectors.js'; import type { ResolvedThreadInfo, ThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { ChatMentionCandidates, - LegacyThreadInfo, RelativeMemberInfo, } from '../types/thread-types.js'; import { chatNameMaxLength, idSchemaRegex } from '../utils/validation-utils.js'; export type TypeaheadMatchedStrings = { +textBeforeAtSymbol: string, +query: string, }; export type Selection = { +start: number, +end: number, }; type MentionTypeaheadUserSuggestionItem = { +type: 'user', +userInfo: RelativeMemberInfo, }; type MentionTypeaheadChatSuggestionItem = { +type: 'chat', +threadInfo: ResolvedThreadInfo, }; export type MentionTypeaheadSuggestionItem = | MentionTypeaheadUserSuggestionItem | MentionTypeaheadChatSuggestionItem; export type TypeaheadTooltipActionItem = { +key: string, +execute: () => mixed, +actionButtonContent: SuggestionItemType, }; // The simple-markdown package already breaks words out for us, and we are // supposed to only match when the first word of the input matches const markdownUserMentionRegex: RegExp = new RegExp( `^(@(${markdownUserMentionRegexString}))\\b`, ); function isUserMentioned(username: string, text: string): boolean { return new RegExp(`\\B@${username}\\b`, 'i').test(text); } const userMentionsExtractionRegex = new RegExp( `\\B(@(${markdownUserMentionRegexString}))\\b`, 'g', ); const chatMentionRegexString = `([^\\\\]|^)(@\\[\\[(${idSchemaRegex}):((.{0,${chatNameMaxLength}}?)(?!\\\\).|^)\\]\\])`; const chatMentionRegex: RegExp = new RegExp(`^${chatMentionRegexString}`); const globalChatMentionRegex: RegExp = new RegExp(chatMentionRegexString, 'g'); function encodeChatMentionText(text: string): string { return text.replace(/]/g, '\\]'); } function decodeChatMentionText(text: string): string { return text.replace(/\\]/g, ']'); } function getRawChatMention(threadInfo: ResolvedThreadInfo): string { return `@[[${threadInfo.id}:${encodeChatMentionText(threadInfo.uiName)}]]`; } function renderChatMentionsWithAltText(text: string): string { return text.replace( globalChatMentionRegex, (...match) => `${match[1]}@${decodeChatMentionText(match[4])}`, ); } function extractUserMentionsFromText(text: string): string[] { const iterator = text.matchAll(userMentionsExtractionRegex); return [...iterator].map(matches => matches[2]); } function getTypeaheadRegexMatches( text: string, selection: Selection, regex: RegExp, ): null | RegExp$matchResult { if ( selection.start === selection.end && (selection.start === text.length || /\s/.test(text[selection.end])) ) { return text.slice(0, selection.start).match(regex); } return null; } const useENSNamesOptions = { allAtOnce: true }; function useMentionTypeaheadUserSuggestions( threadMembers: $ReadOnlyArray, typeaheadMatchedStrings: ?TypeaheadMatchedStrings, ): $ReadOnlyArray { const userSearchIndex = useUserSearchIndex(threadMembers); const resolvedThreadMembers = useENSNames(threadMembers, useENSNamesOptions); const usernamePrefix: ?string = typeaheadMatchedStrings?.query; return React.useMemo(() => { // If typeaheadMatchedStrings is undefined, we want to return no results if (usernamePrefix === undefined || usernamePrefix === null) { return []; } const userIDs = userSearchIndex.getSearchResults(usernamePrefix); const usersInThread = resolvedThreadMembers.filter(member => member.role); return usersInThread .filter(user => usernamePrefix.length === 0 || userIDs.includes(user.id)) .sort((userA, userB) => stringForUserExplicit(userA).localeCompare( stringForUserExplicit(userB), ), ) .map(userInfo => ({ type: 'user', userInfo })); }, [userSearchIndex, resolvedThreadMembers, usernamePrefix]); } function useMentionTypeaheadChatSuggestions( chatSearchIndex: SentencePrefixSearchIndex, chatMentionCandidates: ChatMentionCandidates, typeaheadMatchedStrings: ?TypeaheadMatchedStrings, ): $ReadOnlyArray { const chatNamePrefix: ?string = typeaheadMatchedStrings?.query; return React.useMemo(() => { const result = []; if (chatNamePrefix === undefined || chatNamePrefix === null) { return result; } const threadIDs = chatSearchIndex.getSearchResults(chatNamePrefix); for (const threadID of threadIDs) { if (!chatMentionCandidates[threadID]) { continue; } result.push({ type: 'chat', threadInfo: chatMentionCandidates[threadID].threadInfo, }); } return result; }, [chatSearchIndex, chatMentionCandidates, chatNamePrefix]); } function getNewTextAndSelection( textBeforeAtSymbol: string, entireText: string, query: string, suggestionText: string, ): { newText: string, newSelectionStart: number, } { const totalMatchLength = textBeforeAtSymbol.length + query.length + 1; // 1 for @ char let newSuffixText = entireText.slice(totalMatchLength); newSuffixText = (newSuffixText[0] !== ' ' ? ' ' : '') + newSuffixText; const newText = textBeforeAtSymbol + suggestionText + newSuffixText; const newSelectionStart = newText.length - newSuffixText.length + 1; return { newText, newSelectionStart }; } function useUserMentionsCandidates( - threadInfo: LegacyThreadInfo | ThreadInfo, - parentThreadInfo: ?LegacyThreadInfo | ?ThreadInfo, + threadInfo: ThreadInfo, + parentThreadInfo: ?ThreadInfo, ): $ReadOnlyArray { return React.useMemo(() => { if (threadInfo.type !== threadTypes.SIDEBAR) { return threadInfo.members; } if (parentThreadInfo) { return parentThreadInfo.members; } // This scenario should not occur unless the user logs out while looking at // a sidebar. In that scenario, the Redux store may be cleared before // ReactNav finishes transitioning away from the previous screen return []; }, [threadInfo, parentThreadInfo]); } export { markdownUserMentionRegex, isUserMentioned, extractUserMentionsFromText, useMentionTypeaheadUserSuggestions, useMentionTypeaheadChatSuggestions, getNewTextAndSelection, getTypeaheadRegexMatches, useUserMentionsCandidates, chatMentionRegex, encodeChatMentionText, decodeChatMentionText, getRawChatMention, renderChatMentionsWithAltText, }; diff --git a/lib/shared/reaction-utils.js b/lib/shared/reaction-utils.js index f21ae1de6..961680d12 100644 --- a/lib/shared/reaction-utils.js +++ b/lib/shared/reaction-utils.js @@ -1,110 +1,109 @@ // @flow import _sortBy from 'lodash/fp/sortBy.js'; import * as React from 'react'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import { threadHasPermission } from './thread-utils.js'; import { stringForUserExplicit } from './user-utils.js'; import { useENSNames } from '../hooks/ens-cache.js'; import type { ReactionInfo } from '../selectors/chat-selectors.js'; import type { ComposableMessageInfo, RobotextMessageInfo, } from '../types/message-types.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; -import type { LegacyThreadInfo } from '../types/thread-types.js'; import { useSelector } from '../utils/redux-utils.js'; function useViewerAlreadySelectedMessageReactions( reactions: ReactionInfo, ): $ReadOnlyArray { return React.useMemo(() => { const alreadySelectedEmojis = []; for (const reaction in reactions) { const reactionInfo = reactions[reaction]; if (reactionInfo.viewerReacted) { alreadySelectedEmojis.push(reaction); } } return alreadySelectedEmojis; }, [reactions]); } export type MessageReactionListInfo = { +id: string, +isViewer: boolean, +reaction: string, +username: string, }; function useMessageReactionsList( reactions: ReactionInfo, ): $ReadOnlyArray { const withoutENSNames = React.useMemo(() => { const result = []; for (const reaction in reactions) { const reactionInfo = reactions[reaction]; reactionInfo.users.forEach(user => { result.push({ ...user, username: stringForUserExplicit(user), reaction, }); }); } const sortByNumReactions = (reactionInfo: MessageReactionListInfo) => { const numOfReactions = reactions[reactionInfo.reaction].users.length; return numOfReactions ? -numOfReactions : 0; }; return _sortBy( ([sortByNumReactions, 'username']: $ReadOnlyArray< ((reactionInfo: MessageReactionListInfo) => mixed) | string, >), )(result); }, [reactions]); return useENSNames(withoutENSNames); } function useCanCreateReactionFromMessage( - threadInfo: LegacyThreadInfo | ThreadInfo, + threadInfo: ThreadInfo, targetMessageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const targetMessageCreatorRelationship = useSelector( state => state.userStore.userInfos[targetMessageInfo.creator.id] ?.relationshipStatus, ); if ( !targetMessageInfo.id || threadInfo.sourceMessageID === targetMessageInfo.id ) { return false; } const creatorRelationshipHasBlock = targetMessageCreatorRelationship && relationshipBlockedInEitherDirection(targetMessageCreatorRelationship); const hasPermission = threadHasPermission( threadInfo, threadPermissions.REACT_TO_MESSAGE, ); return hasPermission && !creatorRelationshipHasBlock; } export { useViewerAlreadySelectedMessageReactions, useMessageReactionsList, useCanCreateReactionFromMessage, }; diff --git a/lib/shared/search-utils.js b/lib/shared/search-utils.js index 1de0d7f1d..c7af8d084 100644 --- a/lib/shared/search-utils.js +++ b/lib/shared/search-utils.js @@ -1,452 +1,451 @@ // @flow import * as React from 'react'; import { messageID } from './message-utils.js'; import SearchIndex from './search-index.js'; import { getContainingThreadID, threadMemberHasPermission, userIsMember, } from './thread-utils.js'; import { searchMessagesActionTypes, useSearchMessages as useSearchMessagesAction, } from '../actions/message-actions.js'; import { searchUsers, searchUsersActionTypes, } from '../actions/user-actions.js'; import { ENSCacheContext } from '../components/ens-cache-provider.react.js'; import genesis from '../facts/genesis.js'; import type { ChatMessageInfoItem, MessageListData, } from '../selectors/chat-selectors.js'; import { useUserSearchIndex } from '../selectors/nav-selectors.js'; import { relationshipBlockedInEitherDirection } from '../shared/relationship-utils.js'; import type { MessageInfo, RawMessageInfo } from '../types/message-types.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes } from '../types/thread-types-enum.js'; -import type { LegacyThreadInfo } from '../types/thread-types.js'; import type { AccountUserInfo, GlobalAccountUserInfo, UserListItem, } from '../types/user-types.js'; import { useLegacyAshoatKeyserverCall } from '../utils/action-utils.js'; import { isValidENSName } from '../utils/ens-helpers.js'; import { values } from '../utils/objects.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; const notFriendNotice = 'not friend'; function appendUserInfo({ results, excludeUserIDs, userInfo, parentThreadInfo, communityThreadInfo, containingThreadInfo, }: { +results: { [id: string]: { ...AccountUserInfo | GlobalAccountUserInfo, isMemberOfParentThread: boolean, isMemberOfContainingThread: boolean, }, }, +excludeUserIDs: $ReadOnlyArray, +userInfo: AccountUserInfo | GlobalAccountUserInfo, - +parentThreadInfo: ?LegacyThreadInfo | ?ThreadInfo, - +communityThreadInfo: ?LegacyThreadInfo | ?ThreadInfo, - +containingThreadInfo: ?LegacyThreadInfo | ?ThreadInfo, + +parentThreadInfo: ?ThreadInfo, + +communityThreadInfo: ?ThreadInfo, + +containingThreadInfo: ?ThreadInfo, }) { const { id } = userInfo; if (excludeUserIDs.includes(id) || id in results) { return; } if ( communityThreadInfo && !threadMemberHasPermission( communityThreadInfo, id, threadPermissions.KNOW_OF, ) ) { return; } results[id] = { ...userInfo, isMemberOfParentThread: userIsMember(parentThreadInfo, id), isMemberOfContainingThread: userIsMember(containingThreadInfo, id), }; } function usePotentialMemberItems({ text, userInfos, excludeUserIDs, includeServerSearchUsers, inputParentThreadInfo, inputCommunityThreadInfo, threadType, }: { +text: string, +userInfos: { +[id: string]: AccountUserInfo }, +excludeUserIDs: $ReadOnlyArray, +includeServerSearchUsers?: $ReadOnlyArray, - +inputParentThreadInfo?: ?LegacyThreadInfo | ?ThreadInfo, - +inputCommunityThreadInfo?: ?LegacyThreadInfo | ?ThreadInfo, + +inputParentThreadInfo?: ?ThreadInfo, + +inputCommunityThreadInfo?: ?ThreadInfo, +threadType?: ?ThreadType, }): UserListItem[] { const memoizedUserInfos = React.useMemo(() => values(userInfos), [userInfos]); const searchIndex: SearchIndex = useUserSearchIndex(memoizedUserInfos); const communityThreadInfo = React.useMemo( () => inputCommunityThreadInfo && inputCommunityThreadInfo.id !== genesis.id ? inputCommunityThreadInfo : null, [inputCommunityThreadInfo], ); const parentThreadInfo = React.useMemo( () => inputParentThreadInfo && inputParentThreadInfo.id !== genesis.id ? inputParentThreadInfo : null, [inputParentThreadInfo], ); const containgThreadID = threadType ? getContainingThreadID(parentThreadInfo, threadType) : null; const containingThreadInfo = React.useMemo(() => { if (containgThreadID === parentThreadInfo?.id) { return parentThreadInfo; } else if (containgThreadID === communityThreadInfo?.id) { return communityThreadInfo; } return null; }, [containgThreadID, communityThreadInfo, parentThreadInfo]); const filteredUserResults = React.useMemo(() => { const results: { [id: string]: { ...AccountUserInfo | GlobalAccountUserInfo, isMemberOfParentThread: boolean, isMemberOfContainingThread: boolean, }, } = {}; if (text === '') { for (const id in userInfos) { appendUserInfo({ results, excludeUserIDs, userInfo: userInfos[id], parentThreadInfo, communityThreadInfo, containingThreadInfo, }); } } else { const ids = searchIndex.getSearchResults(text); for (const id of ids) { appendUserInfo({ results, excludeUserIDs, userInfo: userInfos[id], parentThreadInfo, communityThreadInfo, containingThreadInfo, }); } } if (includeServerSearchUsers) { for (const userInfo of includeServerSearchUsers) { appendUserInfo({ results, excludeUserIDs, userInfo, parentThreadInfo, communityThreadInfo, containingThreadInfo, }); } } let userResults = values(results); if (text === '') { userResults = userResults.filter(userInfo => { if (!containingThreadInfo) { return userInfo.relationshipStatus === userRelationshipStatus.FRIEND; } if (!userInfo.isMemberOfContainingThread) { return false; } const { relationshipStatus } = userInfo; if (!relationshipStatus) { return true; } return !relationshipBlockedInEitherDirection(relationshipStatus); }); } return userResults; }, [ text, userInfos, searchIndex, excludeUserIDs, includeServerSearchUsers, parentThreadInfo, containingThreadInfo, communityThreadInfo, ]); const sortedMembers = React.useMemo(() => { const nonFriends = []; const blockedUsers = []; const friends = []; const containingThreadMembers = []; const parentThreadMembers = []; for (const userResult of filteredUserResults) { const { relationshipStatus } = userResult; if ( relationshipStatus && relationshipBlockedInEitherDirection(relationshipStatus) ) { blockedUsers.push(userResult); } else if (userResult.isMemberOfParentThread) { parentThreadMembers.push(userResult); } else if (userResult.isMemberOfContainingThread) { containingThreadMembers.push(userResult); } else if (relationshipStatus === userRelationshipStatus.FRIEND) { friends.push(userResult); } else { nonFriends.push(userResult); } } const sortedResults = parentThreadMembers .concat(containingThreadMembers) .concat(friends) .concat(nonFriends) .concat(blockedUsers); return sortedResults.map( ({ isMemberOfContainingThread, isMemberOfParentThread, relationshipStatus, ...result }) => { let notice, alert; const username = result.username; if ( relationshipStatus && relationshipBlockedInEitherDirection(relationshipStatus) ) { notice = 'user is blocked'; alert = { title: 'User is blocked', text: `Before you add ${username} to this chat, ` + 'you’ll need to unblock them. You can do this from the Block List ' + 'in the Profile tab.', }; } else if (!isMemberOfContainingThread && containingThreadInfo) { if (threadType !== threadTypes.SIDEBAR) { notice = 'not in community'; alert = { title: 'Not in community', text: 'You can only add members of the community to this chat', }; } else { notice = 'not in parent chat'; alert = { title: 'Not in parent chat', text: 'You can only add members of the parent chat to a thread', }; } } else if ( !containingThreadInfo && relationshipStatus !== userRelationshipStatus.FRIEND ) { notice = notFriendNotice; alert = { title: 'Not a friend', text: `Before you add ${username} to this chat, ` + 'you’ll need to send them a friend request. ' + 'You can do this from the Friend List in the Profile tab.', }; } else if (parentThreadInfo && !isMemberOfParentThread) { notice = 'not in parent chat'; } if (notice) { result = { ...result, notice }; } if (alert) { result = { ...result, alert }; } return result; }, ); }, [containingThreadInfo, filteredUserResults, parentThreadInfo, threadType]); return sortedMembers; } function useSearchMessages(): ( query: string, threadID: string, onResultsReceived: ( messages: $ReadOnlyArray, endReached: boolean, queryID: number, threadID: string, ) => mixed, queryID: number, cursor?: ?string, ) => void { const callSearchMessages = useSearchMessagesAction(); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( (query, threadID, onResultsReceived, queryID, cursor) => { const searchMessagesPromise = (async () => { if (query === '') { onResultsReceived([], true, queryID, threadID); return; } const { messages, endReached } = await callSearchMessages({ query, threadID, cursor, }); onResultsReceived(messages, endReached, queryID, threadID); })(); void dispatchActionPromise( searchMessagesActionTypes, searchMessagesPromise, ); }, [callSearchMessages, dispatchActionPromise], ); } function useForwardLookupSearchText(originalText: string): string { const cacheContext = React.useContext(ENSCacheContext); const { ensCache } = cacheContext; const lowercaseText = originalText.toLowerCase(); const [usernameToSearch, setUsernameToSearch] = React.useState(lowercaseText); React.useEffect(() => { void (async () => { if (!ensCache || !isValidENSName(lowercaseText)) { setUsernameToSearch(lowercaseText); return; } const address = await ensCache.getAddressForName(lowercaseText); if (address) { setUsernameToSearch(address); } else { setUsernameToSearch(lowercaseText); } })(); }, [ensCache, lowercaseText]); return usernameToSearch; } function useSearchUsers( usernameInputText: string, ): $ReadOnlyArray { const currentUserID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const forwardLookupSearchText = useForwardLookupSearchText(usernameInputText); const [serverSearchResults, setServerSearchResults] = React.useState< $ReadOnlyArray, >([]); const callSearchUsers = useLegacyAshoatKeyserverCall(searchUsers); const dispatchActionPromise = useDispatchActionPromise(); React.useEffect(() => { if (forwardLookupSearchText.length === 0) { setServerSearchResults([]); return; } const searchUsersPromise = (async () => { try { const { userInfos } = await callSearchUsers(forwardLookupSearchText); setServerSearchResults( userInfos.filter(({ id }) => id !== currentUserID), ); } catch (err) { setServerSearchResults([]); } })(); void dispatchActionPromise(searchUsersActionTypes, searchUsersPromise); }, [ callSearchUsers, currentUserID, dispatchActionPromise, forwardLookupSearchText, ]); return serverSearchResults; } function filterChatMessageInfosForSearch( chatMessageInfos: MessageListData, translatedSearchResults: $ReadOnlyArray, ): ?(ChatMessageInfoItem[]) { if (!chatMessageInfos) { return null; } const idSet = new Set(translatedSearchResults.map(messageID)); const uniqueChatMessageInfoItemsMap = new Map(); for (const item of chatMessageInfos) { if (item.itemType !== 'message' || item.messageInfoType !== 'composable') { continue; } const id = messageID(item.messageInfo); if (idSet.has(id)) { uniqueChatMessageInfoItemsMap.set(id, item); } } const sortedChatMessageInfoItems: ChatMessageInfoItem[] = []; for (let i = 0; i < translatedSearchResults.length; i++) { const id = messageID(translatedSearchResults[i]); const match = uniqueChatMessageInfoItemsMap.get(id); if (match) { sortedChatMessageInfoItems.push(match); } } return sortedChatMessageInfoItems; } export { usePotentialMemberItems, notFriendNotice, useSearchMessages, useSearchUsers, filterChatMessageInfosForSearch, useForwardLookupSearchText, }; diff --git a/lib/shared/user-utils.js b/lib/shared/user-utils.js index 7a962b38e..3bdedb2ee 100644 --- a/lib/shared/user-utils.js +++ b/lib/shared/user-utils.js @@ -1,51 +1,48 @@ // @flow import { memberHasAdminPowers } from './thread-utils.js'; import { useENSNames } from '../hooks/ens-cache.js'; import type { ThreadInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { - LegacyThreadInfo, - ServerThreadInfo, -} from '../types/thread-types.js'; +import type { ServerThreadInfo } 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: LegacyThreadInfo | ThreadInfo | RawThreadInfo | ServerThreadInfo, + community: ThreadInfo | RawThreadInfo | 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 25a4f1d48..5c4213432 100644 --- a/lib/utils/drawer-utils.react.js +++ b/lib/utils/drawer-utils.react.js @@ -1,126 +1,125 @@ // @flow import * as React from 'react'; import { values } from './objects.js'; import { threadInFilterList, threadIsChannel } from '../shared/thread-utils.js'; import type { ResolvedThreadInfo, ThreadInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { communitySubthreads } from '../types/thread-types-enum.js'; -import type { LegacyThreadInfo } 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]: RawThreadInfo | LegacyThreadInfo | ThreadInfo, + +[id: string]: RawThreadInfo | 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/lib/utils/message-pinning-utils.js b/lib/utils/message-pinning-utils.js index e4d8c658f..0383ee548 100644 --- a/lib/utils/message-pinning-utils.js +++ b/lib/utils/message-pinning-utils.js @@ -1,39 +1,32 @@ // @flow import { isInvalidPinSourceForThread } from '../shared/message-utils.js'; import { threadHasPermission } from '../shared/thread-utils.js'; import type { MessageInfo, RawMessageInfo } from '../types/message-types.js'; import type { ThreadInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; -import type { - LegacyRawThreadInfo, - LegacyThreadInfo, -} from '../types/thread-types.js'; +import type { LegacyRawThreadInfo } from '../types/thread-types.js'; function canToggleMessagePin( messageInfo: RawMessageInfo | MessageInfo, - threadInfo: - | LegacyRawThreadInfo - | RawThreadInfo - | LegacyThreadInfo - | ThreadInfo, + threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, ): boolean { const isValidMessage = !isInvalidPinSourceForThread(messageInfo, threadInfo); const hasManagePinsPermission = threadHasPermission( threadInfo, threadPermissions.MANAGE_PINS, ); return isValidMessage && hasManagePinsPermission; } function pinnedMessageCountText(pinnedCount: number): string { const messageNoun = pinnedCount === 1 ? 'message' : 'messages'; return `${pinnedCount} pinned ${messageNoun}`; } export { canToggleMessagePin, pinnedMessageCountText }; diff --git a/lib/utils/role-utils.js b/lib/utils/role-utils.js index 3bf439409..18cf21316 100644 --- a/lib/utils/role-utils.js +++ b/lib/utils/role-utils.js @@ -1,128 +1,124 @@ // @flow import * as React from 'react'; import { useSelector } from './redux-utils.js'; import { threadInfoSelector } from '../selectors/thread-selectors.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { configurableCommunityPermissions, type ThreadRolePermissionsBlob, type UserSurfacedPermission, } from '../types/thread-permission-types.js'; -import type { - LegacyThreadInfo, - RelativeMemberInfo, - RoleInfo, -} from '../types/thread-types'; +import type { RelativeMemberInfo, RoleInfo } from '../types/thread-types'; import { threadTypes } from '../types/thread-types-enum.js'; function constructRoleDeletionMessagePrompt( defaultRoleName: string, memberCount: number, ): string { let message; if (memberCount === 0) { message = 'Are you sure you want to delete this role?'; } else { const messageNoun = memberCount === 1 ? 'member' : 'members'; const messageVerb = memberCount === 1 ? 'is' : 'are'; message = `There ${messageVerb} currently ${memberCount} ${messageNoun} with ` + `this role. Deleting the role will automatically assign the ` + `${messageNoun} affected to the ${defaultRoleName} role.`; } return message; } type RoleDeletableAndEditableStatus = { +isDeletable: boolean, +isEditable: boolean, }; function useRoleDeletableAndEditableStatus( roleName: string, defaultRoleID: string, existingRoleID: string, ): RoleDeletableAndEditableStatus { return React.useMemo(() => { const canDelete = roleName !== 'Admins' && defaultRoleID !== existingRoleID; const canEdit = roleName !== 'Admins'; return { isDeletable: canDelete, isEditable: canEdit, }; }, [roleName, defaultRoleID, existingRoleID]); } function useRolesFromCommunityThreadInfo( - threadInfo: LegacyThreadInfo | ThreadInfo, + threadInfo: ThreadInfo, memberInfos: $ReadOnlyArray, ): $ReadOnlyMap { // Our in-code system has chat-specific roles, while the // user-surfaced system has roles only for communities. We retrieve roles // from the top-level community thread for accuracy, with a rare fallback // for potential issues reading memberInfos, primarily in GENESIS threads. // The special case is GENESIS threads, since per prior discussion // (see context: https://linear.app/comm/issue/ENG-4077/), we don't really // support roles for it. Also with GENESIS, the list of members are not // populated in the community root. So in this case to prevent crashing, we // should just return the role name from the current thread info. const { community } = threadInfo; const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const topMostThreadInfo = communityThreadInfo || threadInfo; const roleMap = new Map(); if (topMostThreadInfo.type === threadTypes.GENESIS) { memberInfos.forEach(memberInfo => roleMap.set( memberInfo.id, memberInfo.role ? threadInfo.roles[memberInfo.role] : null, ), ); return roleMap; } const { members: memberInfosFromTopMostThreadInfo, roles } = topMostThreadInfo; memberInfosFromTopMostThreadInfo.forEach(memberInfo => { roleMap.set(memberInfo.id, memberInfo.role ? roles[memberInfo.role] : null); }); return roleMap; } function toggleUserSurfacedPermission( rolePermissions: ThreadRolePermissionsBlob, userSurfacedPermission: UserSurfacedPermission, ): ThreadRolePermissionsBlob { const userSurfacedPermissionSet = Array.from( configurableCommunityPermissions[userSurfacedPermission], ); const currentRolePermissions = { ...rolePermissions }; const roleHasPermission = userSurfacedPermissionSet.every( permission => currentRolePermissions[permission], ); if (roleHasPermission) { for (const permission of userSurfacedPermissionSet) { delete currentRolePermissions[permission]; } } else { for (const permission of userSurfacedPermissionSet) { currentRolePermissions[permission] = true; } } return currentRolePermissions; } export { constructRoleDeletionMessagePrompt, useRoleDeletableAndEditableStatus, useRolesFromCommunityThreadInfo, toggleUserSurfacedPermission, };