diff --git a/lib/components/chat-mention-provider.react.js b/lib/components/chat-mention-provider.react.js index 0e549eae2..0b2d06158 100644 --- a/lib/components/chat-mention-provider.react.js +++ b/lib/components/chat-mention-provider.react.js @@ -1,244 +1,241 @@ // @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 { - MinimallyEncodedResolvedThreadInfo, - MinimallyEncodedThreadInfo, -} from '../types/minimally-encoded-thread-permissions-types.js'; +import type { MinimallyEncodedResolvedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { ChatMentionCandidates, ChatMentionCandidatesObj, ResolvedThreadInfo, - LegacyThreadInfo, + ThreadInfo, } from '../types/thread-types.js'; import { useResolvedThreadInfosObj } from '../utils/entity-helpers.js'; import { useSelector } from '../utils/redux-utils.js'; type Props = { +children: React.Node, }; export type ChatMentionContextType = { +getChatMentionSearchIndex: ( - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + 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 | MinimallyEncodedThreadInfo) => { + (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]: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, }): { chatMentionCandidatesObj: ChatMentionCandidatesObj, communityThreadIDForGenesisThreads: { +[id: string]: string }, } { const result: { [string]: { [string]: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, }, } = {}; const visitedGenesisThreads = new Set(); const communityThreadIDForGenesisThreads: { [string]: string } = {}; for (const currentThreadID in threadInfos) { const currentThreadInfo = threadInfos[currentThreadID]; const { community: currentThreadCommunity } = currentThreadInfo; if (!currentThreadCommunity) { if (!result[currentThreadID]) { result[currentThreadID] = { [currentThreadID]: currentThreadInfo }; } continue; } if (!result[currentThreadCommunity]) { result[currentThreadCommunity] = {}; result[currentThreadCommunity][currentThreadCommunity] = threadInfos[currentThreadCommunity]; } // Handle GENESIS community case: mentioning inside GENESIS should only // show chats and threads inside the top level that is below GENESIS. if (threadInfos[currentThreadCommunity].type === threadTypes.GENESIS) { if (visitedGenesisThreads.has(currentThreadID)) { continue; } const threadTraversePath = [currentThreadInfo]; visitedGenesisThreads.add(currentThreadID); let currentlySelectedThreadID = currentThreadInfo.parentThreadID; while (currentlySelectedThreadID) { const currentlySelectedThreadInfo = threadInfos[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 = threadInfos[ lastThreadInTraversePath.parentThreadID ] ? lastThreadInTraversePath.parentThreadID : lastThreadInTraversePath.id; } else { lastThreadInTraversePathParentID = lastThreadInTraversePath.id; } if ( threadInfos[lastThreadInTraversePathParentID].type === threadTypes.GENESIS ) { if (!result[lastThreadInTraversePath.id]) { result[lastThreadInTraversePath.id] = {}; } for (const threadInfo of threadTraversePath) { result[lastThreadInTraversePath.id][threadInfo.id] = threadInfo; communityThreadIDForGenesisThreads[threadInfo.id] = lastThreadInTraversePath.id; } if ( lastThreadInTraversePath.type !== threadTypes.PERSONAL && lastThreadInTraversePath.type !== threadTypes.PRIVATE ) { result[genesis.id][lastThreadInTraversePath.id] = lastThreadInTraversePath; } } else { if ( !communityThreadIDForGenesisThreads[lastThreadInTraversePathParentID] ) { result[lastThreadInTraversePathParentID] = {}; communityThreadIDForGenesisThreads[lastThreadInTraversePathParentID] = lastThreadInTraversePathParentID; } const lastThreadInTraversePathParentCommunityThreadID = communityThreadIDForGenesisThreads[lastThreadInTraversePathParentID]; for (const threadInfo of threadTraversePath) { result[lastThreadInTraversePathParentCommunityThreadID][ threadInfo.id ] = threadInfo; communityThreadIDForGenesisThreads[threadInfo.id] = lastThreadInTraversePathParentCommunityThreadID; } } continue; } result[currentThreadCommunity][currentThreadID] = currentThreadInfo; } 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: ChatMentionCandidates, communityThreadIDForGenesisThreads: { +[id: string]: string }, } { const threadInfos = useSelector(threadInfoSelector); const resolvedThreadInfos = useResolvedThreadInfosObj( threadInfos, useResolvedThreadInfosObjOptions, ); const { chatMentionCandidatesObj, communityThreadIDForGenesisThreads } = React.useMemo( () => getChatMentionCandidates(resolvedThreadInfos), [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].uiName, }); } // 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 } of searchIndexEntries) { searchIndex.addEntry(id, uiName); } 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 5cd37b270..d33a09856 100644 --- a/lib/hooks/chat-mention-hooks.js +++ b/lib/hooks/chat-mention-hooks.js @@ -1,48 +1,47 @@ // @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 { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { ChatMentionCandidates, - LegacyThreadInfo, + ThreadInfo, } from '../types/thread-types.js'; function useChatMentionContext(): ChatMentionContextType { const context = React.useContext(ChatMentionContext); invariant(context, 'ChatMentionContext not found'); return context; } function useThreadChatMentionCandidates( - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + 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 e322e43f6..714f54679 100644 --- a/lib/hooks/child-threads.js +++ b/lib/hooks/child-threads.js @@ -1,127 +1,122 @@ // @flow import * as React from 'react'; import { useFetchSingleMostRecentMessagesFromThreads, fetchSingleMostRecentMessagesFromThreadsActionTypes, } from '../actions/message-actions.js'; import { useFilteredChatListData, type ChatThreadItem, } from '../selectors/chat-selectors.js'; import { useGlobalThreadSearchIndex } from '../selectors/nav-selectors.js'; import { childThreadInfos } from '../selectors/thread-selectors.js'; import { threadInChatList } from '../shared/thread-utils.js'; import threadWatcher from '../shared/thread-watcher.js'; -import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo, RawThreadInfo } from '../types/thread-types.js'; +import type { RawThreadInfo, ThreadInfo } from '../types/thread-types.js'; import { useDispatchActionPromise } from '../utils/action-utils.js'; import { useSelector } from '../utils/redux-utils.js'; type ThreadFilter = { - +predicate?: ( - thread: LegacyThreadInfo | MinimallyEncodedThreadInfo, - ) => 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: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), - ) => { + (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; } 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 a8948b420..fc858819b 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 { MinimallyEncodedThreadInfo } 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 type { ThreadInfo } from '../types/thread-types.js'; import { useDispatchActionPromise } from '../utils/action-utils.js'; import { useSelector } from '../utils/redux-utils.js'; function canPromoteSidebar( - sidebarThreadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, - parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, + 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 | MinimallyEncodedThreadInfo, + 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 | ?MinimallyEncodedThreadInfo = - 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 { 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 1f5353df5..286f33bc1 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 { MinimallyEncodedThreadInfo } 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 { ThreadInfo } from '../types/thread-types.js'; import type { UserInfo } from '../types/user-types.js'; import { useDispatchActionPromise, useServerCall, } from '../utils/action-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 | MinimallyEncodedThreadInfo, + 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 = useServerCall(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) => { 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 645afe6af..24cb94e9f 100644 --- a/lib/hooks/search-threads.js +++ b/lib/hooks/search-threads.js @@ -1,116 +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 { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { SidebarInfo, - LegacyThreadInfo, RawThreadInfo, + ThreadInfo, } from '../types/thread-types.js'; import { useSelector } from '../utils/redux-utils.js'; export type ThreadSearchState = { +text: string, +results: $ReadOnlySet, }; type SearchThreadsResult = { +listData: $ReadOnlyArray, +searchState: ThreadSearchState, +setSearchState: SetState, +onChangeSearchInputText: (text: string) => mixed, +clearQuery: (event: SyntheticEvent) => void, }; function useSearchThreads( - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + 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 | MinimallyEncodedThreadInfo, + threadInfo: ThreadInfo, ): SearchThreadsResult { const childThreadInfos = useSelector( state => sidebarInfoSelector(state)[threadInfo.id] ?? [], ); return useSearchThreads(threadInfo, childThreadInfos); } function useSearchSubchannels( - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: ThreadInfo, ): SearchThreadsResult { const filterFunc = React.useCallback( - ( - thread: ?(LegacyThreadInfo | MinimallyEncodedThreadInfo | RawThreadInfo), - ) => threadIsChannel(thread) && thread?.parentThreadID === threadInfo.id, + (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 5a88848fb..dad5cf202 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 { useSetThreadUnreadStatus, setThreadUnreadStatusActionTypes, } from '../actions/activity-actions.js'; import type { SetThreadUnreadStatusPayload, SetThreadUnreadStatusRequest, } from '../types/activity-types.js'; -import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from '../types/thread-types.js'; +import type { ThreadInfo } from '../types/thread-types.js'; import { useDispatchActionPromise } from '../utils/action-utils.js'; function useToggleUnreadStatus( - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + 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, }; 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/shared/ancestor-threads.js b/lib/shared/ancestor-threads.js index 7181364a0..1641c53f4 100644 --- a/lib/shared/ancestor-threads.js +++ b/lib/shared/ancestor-threads.js @@ -1,30 +1,32 @@ // @flow import genesis from '../facts/genesis.js'; import { threadInfoSelector, ancestorThreadInfos, } from '../selectors/thread-selectors.js'; import { threadIsPending } from '../shared/thread-utils.js'; -import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import { type LegacyThreadInfo } from '../types/thread-types.js'; +import { + type LegacyThreadInfo, + type ThreadInfo, +} from '../types/thread-types.js'; import { useSelector } from '../utils/redux-utils.js'; function useAncestorThreads( - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: ThreadInfo, ): $ReadOnlyArray { return useSelector(state => { if (!threadIsPending(threadInfo.id)) { const ancestorThreads = ancestorThreadInfos(threadInfo.id)(state); if (ancestorThreads.length > 1) { return ancestorThreads.slice(0, -1); } return ancestorThreads; } const genesisThreadInfo = threadInfoSelector(state)[genesis.id]; return genesisThreadInfo ? [genesisThreadInfo] : []; }); } export { useAncestorThreads }; diff --git a/lib/shared/avatar-utils.js b/lib/shared/avatar-utils.js index 298e7630c..d4e1667dd 100644 --- a/lib/shared/avatar-utils.js +++ b/lib/shared/avatar-utils.js @@ -1,366 +1,363 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import stringHash from 'string-hash'; import { selectedThreadColors } from './color-utils.js'; import { threadOtherMembers } from './thread-utils.js'; import genesis from '../facts/genesis.js'; import { useENSAvatar } from '../hooks/ens-cache.js'; import { threadInfoSelector } from '../selectors/thread-selectors.js'; import { getETHAddressForUserInfo } from '../shared/account-utils.js'; import type { ClientEmojiAvatar, ClientAvatar, ResolvedClientAvatar, GenericUserInfoWithAvatar, } from '../types/avatar-types.js'; -import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; -import type { LegacyThreadInfo, RawThreadInfo } from '../types/thread-types.js'; +import type { RawThreadInfo, ThreadInfo } from '../types/thread-types.js'; import type { UserInfos } from '../types/user-types.js'; import { useSelector } from '../utils/redux-utils.js'; import { ashoatKeyserverID } from '../utils/validation-utils.js'; const defaultAnonymousUserEmojiAvatar: ClientEmojiAvatar = { color: selectedThreadColors[4], emoji: 'ðŸ‘Ī', type: 'emoji', }; const defaultEmojiAvatars: $ReadOnlyArray = [ { color: selectedThreadColors[0], emoji: '😀', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '😃', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '😄', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '😁', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '😆', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🙂', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '😉', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '😊', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '😇', type: 'emoji' }, { color: selectedThreadColors[9], emoji: 'ðŸĨ°', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '😍', type: 'emoji' }, { color: selectedThreadColors[1], emoji: 'ðŸĪĐ', type: 'emoji' }, { color: selectedThreadColors[3], emoji: 'ðŸĨģ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '😝', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '😎', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🧐', type: 'emoji' }, { color: selectedThreadColors[7], emoji: 'ðŸĨļ', type: 'emoji' }, { color: selectedThreadColors[3], emoji: 'ðŸĪ—', type: 'emoji' }, { color: selectedThreadColors[0], emoji: 'ðŸ˜Ī', type: 'emoji' }, { color: selectedThreadColors[0], emoji: 'ðŸĪŊ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: 'ðŸĪ”', type: 'emoji' }, { color: selectedThreadColors[8], emoji: 'ðŸŦĄ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: 'ðŸĪŦ', type: 'emoji' }, { color: selectedThreadColors[3], emoji: 'ðŸ˜Ū', type: 'emoji' }, { color: selectedThreadColors[4], emoji: 'ðŸ˜ē', type: 'emoji' }, { color: selectedThreadColors[5], emoji: 'ðŸĪ ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: 'ðŸĪ‘', type: 'emoji' }, { color: selectedThreadColors[7], emoji: 'ðŸ‘Đ‍🚀', type: 'emoji' }, { color: selectedThreadColors[2], emoji: 'ðŸĨ·', type: 'emoji' }, { color: selectedThreadColors[5], emoji: 'ðŸ‘ŧ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: 'ðŸ‘ū', type: 'emoji' }, { color: selectedThreadColors[2], emoji: 'ðŸĪ–', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '😚', type: 'emoji' }, { color: selectedThreadColors[4], emoji: 'ðŸ˜ļ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: 'ðŸ˜đ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: 'ðŸ˜ŧ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: 'ðŸŽĐ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '👑', type: 'emoji' }, { color: selectedThreadColors[9], emoji: 'ðŸķ', type: 'emoji' }, { color: selectedThreadColors[0], emoji: 'ðŸą', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🐭', type: 'emoji' }, { color: selectedThreadColors[3], emoji: 'ðŸđ', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🐰', type: 'emoji' }, { color: selectedThreadColors[5], emoji: 'ðŸŧ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🐞', type: 'emoji' }, { color: selectedThreadColors[7], emoji: 'ðŸŧ‍❄ïļ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: 'ðŸĻ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: 'ðŸŊ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: 'ðŸĶ', type: 'emoji' }, { color: selectedThreadColors[2], emoji: 'ðŸļ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🐔', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🐧', type: 'emoji' }, { color: selectedThreadColors[5], emoji: 'ðŸĶ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: 'ðŸĪ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: 'ðŸĶ„', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🐝', type: 'emoji' }, { color: selectedThreadColors[9], emoji: 'ðŸĶ‹', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🐎', type: 'emoji' }, { color: selectedThreadColors[1], emoji: 'ðŸģ', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🐋', type: 'emoji' }, { color: selectedThreadColors[3], emoji: 'ðŸĶˆ', type: 'emoji' }, { color: selectedThreadColors[4], emoji: 'ðŸĶ­', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🐘', type: 'emoji' }, { color: selectedThreadColors[6], emoji: 'ðŸĶ›', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🐐', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🐓', type: 'emoji' }, { color: selectedThreadColors[9], emoji: 'ðŸĶƒ', type: 'emoji' }, { color: selectedThreadColors[0], emoji: 'ðŸĶĐ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: 'ðŸĶ”', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🐅', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🐆', type: 'emoji' }, { color: selectedThreadColors[5], emoji: 'ðŸĶ“', type: 'emoji' }, { color: selectedThreadColors[4], emoji: 'ðŸĶ’', type: 'emoji' }, { color: selectedThreadColors[6], emoji: 'ðŸĶ˜', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🐎', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🐕', type: 'emoji' }, { color: selectedThreadColors[1], emoji: 'ðŸĐ', type: 'emoji' }, { color: selectedThreadColors[0], emoji: 'ðŸĶŪ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🐈', type: 'emoji' }, { color: selectedThreadColors[2], emoji: 'ðŸĶš', type: 'emoji' }, { color: selectedThreadColors[4], emoji: 'ðŸĶœ', type: 'emoji' }, { color: selectedThreadColors[4], emoji: 'ðŸĶĒ', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🕊ïļ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🐇', type: 'emoji' }, { color: selectedThreadColors[7], emoji: 'ðŸĶĶ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: 'ðŸŋïļ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🐉', type: 'emoji' }, { color: selectedThreadColors[0], emoji: 'ðŸŒī', type: 'emoji' }, { color: selectedThreadColors[7], emoji: 'ðŸŒą', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '☘ïļ', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🍀', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🍄', type: 'emoji' }, { color: selectedThreadColors[5], emoji: 'ðŸŒŋ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: 'ðŸŠī', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🍁', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '💐', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🌷', type: 'emoji' }, { color: selectedThreadColors[0], emoji: 'ðŸŒđ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: 'ðŸŒļ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: 'ðŸŒŧ', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '⭐', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🌟', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🍏', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🍎', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🍐', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🍊', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🍋', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🍓', type: 'emoji' }, { color: selectedThreadColors[1], emoji: 'ðŸŦ', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🍈', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🍒', type: 'emoji' }, { color: selectedThreadColors[4], emoji: 'ðŸĨ­', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🍍', type: 'emoji' }, { color: selectedThreadColors[1], emoji: 'ðŸĨ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🍅', type: 'emoji' }, { color: selectedThreadColors[8], emoji: 'ðŸĨĶ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: 'ðŸĨ•', type: 'emoji' }, { color: selectedThreadColors[0], emoji: 'ðŸĨ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: 'ðŸĨŊ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🍞', type: 'emoji' }, { color: selectedThreadColors[3], emoji: 'ðŸĨ–', type: 'emoji' }, { color: selectedThreadColors[4], emoji: 'ðŸĨĻ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🧀', type: 'emoji' }, { color: selectedThreadColors[6], emoji: 'ðŸĨž', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🧇', type: 'emoji' }, { color: selectedThreadColors[8], emoji: 'ðŸĨ“', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🍔', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🍟', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🍕', type: 'emoji' }, { color: selectedThreadColors[2], emoji: 'ðŸĨ—', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🍝', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🍜', type: 'emoji' }, { color: selectedThreadColors[5], emoji: 'ðŸē', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🍛', type: 'emoji' }, { color: selectedThreadColors[7], emoji: 'ðŸĢ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: 'ðŸą', type: 'emoji' }, { color: selectedThreadColors[9], emoji: 'ðŸĨŸ', type: 'emoji' }, { color: selectedThreadColors[0], emoji: 'ðŸĪ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🍙', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🍚', type: 'emoji' }, { color: selectedThreadColors[3], emoji: 'ðŸĨ', type: 'emoji' }, { color: selectedThreadColors[4], emoji: 'ðŸĶ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🧁', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🍭', type: 'emoji' }, { color: selectedThreadColors[7], emoji: 'ðŸĐ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🍊', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '☕ïļ', type: 'emoji' }, { color: selectedThreadColors[0], emoji: 'ðŸĩ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: 'âš―ïļ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🏀', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🏈', type: 'emoji' }, { color: selectedThreadColors[4], emoji: 'âšūïļ', type: 'emoji' }, { color: selectedThreadColors[0], emoji: 'ðŸĨŽ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: 'ðŸŽū', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🏐', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🏉', type: 'emoji' }, { color: selectedThreadColors[3], emoji: 'ðŸŽą', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🏆', type: 'emoji' }, { color: selectedThreadColors[1], emoji: 'ðŸŽĻ', type: 'emoji' }, { color: selectedThreadColors[2], emoji: 'ðŸŽĪ', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🎧', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🎞', type: 'emoji' }, { color: selectedThreadColors[5], emoji: 'ðŸŽđ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: 'ðŸĨ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🎷', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🎚', type: 'emoji' }, { color: selectedThreadColors[9], emoji: 'ðŸŽļ', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🊕', type: 'emoji' }, { color: selectedThreadColors[1], emoji: 'ðŸŽŧ', type: 'emoji' }, { color: selectedThreadColors[2], emoji: 'ðŸŽē', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '♟ïļ', type: 'emoji' }, { color: selectedThreadColors[4], emoji: 'ðŸŽŪ', type: 'emoji' }, { color: selectedThreadColors[5], emoji: '🚗', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🚙', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🚌', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🏎ïļ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: 'ðŸ›ŧ', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🚚', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🚛', type: 'emoji' }, { color: selectedThreadColors[2], emoji: '🚘', type: 'emoji' }, { color: selectedThreadColors[0], emoji: '🚀', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🚁', type: 'emoji' }, { color: selectedThreadColors[5], emoji: 'ðŸ›ķ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: 'â›ĩïļ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: 'ðŸšĪ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '⚓', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🏰', type: 'emoji' }, { color: selectedThreadColors[0], emoji: 'ðŸŽĄ', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '💎', type: 'emoji' }, { color: selectedThreadColors[2], emoji: 'ðŸ”Ū', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '💈', type: 'emoji' }, { color: selectedThreadColors[4], emoji: 'ðŸ§ļ', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🎊', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🎉', type: 'emoji' }, { color: selectedThreadColors[7], emoji: 'ðŸŠĐ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: '🚂', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '🚆', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🚊', type: 'emoji' }, { color: selectedThreadColors[1], emoji: '🛰ïļ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: '🏠', type: 'emoji' }, { color: selectedThreadColors[3], emoji: '⛰ïļ', type: 'emoji' }, { color: selectedThreadColors[4], emoji: '🏔ïļ', type: 'emoji' }, { color: selectedThreadColors[5], emoji: 'ðŸ—ŧ', type: 'emoji' }, { color: selectedThreadColors[6], emoji: '🏛ïļ', type: 'emoji' }, { color: selectedThreadColors[7], emoji: 'â›Đïļ', type: 'emoji' }, { color: selectedThreadColors[8], emoji: 'ðŸ§ē', type: 'emoji' }, { color: selectedThreadColors[9], emoji: '🎁', type: 'emoji' }, ]; function getRandomDefaultEmojiAvatar(): ClientEmojiAvatar { const randomIndex = Math.floor(Math.random() * defaultEmojiAvatars.length); return defaultEmojiAvatars[randomIndex]; } function getDefaultAvatar(hashKey: string, color?: string): ClientEmojiAvatar { let key = hashKey; if (key.startsWith(`${ashoatKeyserverID}|`)) { key = key.slice(`${ashoatKeyserverID}|`.length); } const avatarIndex = stringHash(key) % defaultEmojiAvatars.length; return { ...defaultEmojiAvatars[avatarIndex], color: color ? color : defaultEmojiAvatars[avatarIndex].color, }; } function getAvatarForUser(user: ?GenericUserInfoWithAvatar): ClientAvatar { if (user?.avatar) { return user.avatar; } if (!user?.username) { return defaultAnonymousUserEmojiAvatar; } return getDefaultAvatar(user.username); } function getUserAvatarForThread( - threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: 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 | MinimallyEncodedThreadInfo, - containingThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, + 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 | MinimallyEncodedThreadInfo, -): 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 7e551520d..e6a04e421 100644 --- a/lib/shared/edit-messages-utils.js +++ b/lib/shared/edit-messages-utils.js @@ -1,86 +1,85 @@ // @flow import * as React from 'react'; import { threadIsPending, threadHasPermission } from './thread-utils.js'; import { sendEditMessageActionTypes, useSendEditMessage, } from '../actions/message-actions.js'; import type { SendEditMessageResult, RobotextMessageInfo, ComposableMessageInfo, RawMessageInfo, } from '../types/message-types'; import { messageTypes } from '../types/message-types-enum.js'; -import type { MinimallyEncodedThreadInfo } 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 { type ThreadInfo } from '../types/thread-types.js'; import { useDispatchActionPromise } from '../utils/action-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 }); })(); dispatchActionPromise(sendEditMessageActionTypes, editMessagePromise); return editMessagePromise; }, [dispatchActionPromise, callEditMessage], ); } function useCanEditMessage( - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + 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 c87ec213d..2a121bef6 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 { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { LegacyThreadInfo } from '../types/thread-types.js'; +import type { ThreadInfo } from '../types/thread-types.js'; -function getInlineEngagementSidebarText( - threadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, -): 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 0feb41afb..3ea1c3381 100644 --- a/lib/shared/mention-utils.js +++ b/lib/shared/mention-utils.js @@ -1,196 +1,193 @@ // @flow import { oldValidUsernameRegexString } from './account-utils.js'; import SentencePrefixSearchIndex from './sentence-prefix-search-index.js'; import { threadOtherMembers } from './thread-utils.js'; import { stringForUserExplicit } from './user-utils.js'; -import type { - MinimallyEncodedResolvedThreadInfo, - MinimallyEncodedThreadInfo, -} from '../types/minimally-encoded-thread-permissions-types.js'; +import type { MinimallyEncodedResolvedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { - LegacyThreadInfo, ResolvedThreadInfo, ChatMentionCandidates, RelativeMemberInfo, + ThreadInfo, } from '../types/thread-types.js'; import { idSchemaRegex, chatNameMaxLength } 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 | MinimallyEncodedResolvedThreadInfo, }; 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( `^(@(${oldValidUsernameRegexString}))\\b`, ); function isUserMentioned(username: string, text: string): boolean { return new RegExp(`\\B@${username}\\b`, 'i').test(text); } const userMentionsExtractionRegex = new RegExp( `\\B(@(${oldValidUsernameRegexString}))\\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 | MinimallyEncodedResolvedThreadInfo, ): 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; } function getMentionTypeaheadUserSuggestions( userSearchIndex: SentencePrefixSearchIndex, threadMembers: $ReadOnlyArray, viewerID: ?string, usernamePrefix: string, ): $ReadOnlyArray { const userIDs = userSearchIndex.getSearchResults(usernamePrefix); const usersInThread = threadOtherMembers(threadMembers, viewerID); return usersInThread .filter(user => usernamePrefix.length === 0 || userIDs.includes(user.id)) .sort((userA, userB) => stringForUserExplicit(userA).localeCompare(stringForUserExplicit(userB)), ) .map(userInfo => ({ type: 'user', userInfo })); } function getMentionTypeaheadChatSuggestions( chatSearchIndex: SentencePrefixSearchIndex, chatMentionCandidates: ChatMentionCandidates, chatNamePrefix: string, ): $ReadOnlyArray { const result = []; const threadIDs = chatSearchIndex.getSearchResults(chatNamePrefix); for (const threadID of threadIDs) { if (!chatMentionCandidates[threadID]) { continue; } result.push({ type: 'chat', threadInfo: chatMentionCandidates[threadID], }); } return result; } 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 getUserMentionsCandidates( - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, - parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, + threadInfo: ThreadInfo, + parentThreadInfo: ?ThreadInfo, ): $ReadOnlyArray { 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 []; } export { markdownUserMentionRegex, isUserMentioned, extractUserMentionsFromText, getMentionTypeaheadUserSuggestions, getMentionTypeaheadChatSuggestions, getNewTextAndSelection, getTypeaheadRegexMatches, getUserMentionsCandidates, chatMentionRegex, encodeChatMentionText, decodeChatMentionText, getRawChatMention, renderChatMentionsWithAltText, }; diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js index b59c506b3..543b3d9c8 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,749 +1,752 @@ // @flow import invariant from 'invariant'; import _maxBy from 'lodash/fp/maxBy.js'; import _orderBy from 'lodash/fp/orderBy.js'; import * as React from 'react'; import { codeBlockRegex, type ParserRules } from './markdown.js'; import type { CreationSideEffectsFunc } from './messages/message-spec.js'; import { messageSpecs } from './messages/message-specs.js'; import { threadIsGroupChat } from './thread-utils.js'; import { useStringForUser } from '../hooks/ens-cache.js'; import { contentStringForMediaArray } from '../media/media-utils.js'; import type { ChatMessageInfoItem } from '../selectors/chat-selectors.js'; import { threadInfoSelector } from '../selectors/thread-selectors.js'; import { userIDsToRelativeUserInfos } from '../selectors/user-selectors.js'; import { type PlatformDetails, isWebPlatform } from '../types/device-types.js'; import type { Media } from '../types/media-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type RawMultimediaMessageInfo, type MessageData, type MessageTruncationStatus, type MultimediaMessageData, type MessageStore, type ComposableMessageInfo, messageTruncationStatus, type RawComposableMessageInfo, type ThreadMessageInfo, } from '../types/message-types.js'; import type { EditMessageInfo, RawEditMessageInfo, } from '../types/messages/edit.js'; import type { ImagesMessageData } from '../types/messages/images.js'; import type { MediaMessageData } from '../types/messages/media.js'; import type { RawReactionMessageInfo, ReactionMessageInfo, } from '../types/messages/reaction.js'; -import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; -import type { RawThreadInfo, LegacyThreadInfo } from '../types/thread-types.js'; +import type { + RawThreadInfo, + LegacyThreadInfo, + ThreadInfo, +} from '../types/thread-types.js'; import type { UserInfos } from '../types/user-types.js'; import { extractKeyserverIDFromID } from '../utils/action-utils.js'; import { type EntityText, ET, useEntityTextAsString, } from '../utils/entity-text.js'; import { useSelector } from '../utils/redux-utils.js'; const localIDPrefix = 'local'; const defaultMediaMessageOptions = Object.freeze({}); // Prefers localID function messageKey(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.localID) { return messageInfo.localID; } invariant(messageInfo.id, 'localID should exist if ID does not'); return messageInfo.id; } // Prefers serverID function messageID(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.id) { return messageInfo.id; } invariant(messageInfo.localID, 'localID should exist if ID does not'); return messageInfo.localID; } function robotextForMessageInfo( messageInfo: RobotextMessageInfo, - threadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, - parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, + threadInfo: ?ThreadInfo, + parentThreadInfo: ?ThreadInfo, ): EntityText { const messageSpec = messageSpecs[messageInfo.type]; invariant( messageSpec.robotext, `we're not aware of messageType ${messageInfo.type}`, ); return messageSpec.robotext(messageInfo, { threadInfo, parentThreadInfo }); } function createMessageInfo( rawMessageInfo: RawMessageInfo, viewerID: ?string, userInfos: UserInfos, threadInfos: { +[id: string]: LegacyThreadInfo }, ): ?MessageInfo { const creatorInfo = userInfos[rawMessageInfo.creatorID]; const creator = { id: rawMessageInfo.creatorID, username: creatorInfo ? creatorInfo.username : 'anonymous', isViewer: rawMessageInfo.creatorID === viewerID, }; const createRelativeUserInfos = (userIDs: $ReadOnlyArray) => userIDsToRelativeUserInfos(userIDs, viewerID, userInfos); const createMessageInfoFromRaw = (rawInfo: RawMessageInfo) => createMessageInfo(rawInfo, viewerID, userInfos, threadInfos); const messageSpec = messageSpecs[rawMessageInfo.type]; return messageSpec.createMessageInfo(rawMessageInfo, creator, { threadInfos, createMessageInfoFromRaw, createRelativeUserInfos, }); } type LengthResult = { +local: number, +realized: number, }; function findMessageIDMaxLengths( messageIDs: $ReadOnlyArray, ): LengthResult { const result = { local: 0, realized: 0, }; for (const id of messageIDs) { if (!id) { continue; } if (id.startsWith(localIDPrefix)) { result.local = Math.max(result.local, id.length - localIDPrefix.length); } else { result.realized = Math.max(result.realized, id.length); } } return result; } function extendMessageID(id: ?string, lengths: LengthResult): ?string { if (!id) { return id; } if (id.startsWith(localIDPrefix)) { const zeroPaddedID = id .substr(localIDPrefix.length) .padStart(lengths.local, '0'); return `${localIDPrefix}${zeroPaddedID}`; } return id.padStart(lengths.realized, '0'); } function sortMessageInfoList( messageInfos: $ReadOnlyArray, ): T[] { const lengths = findMessageIDMaxLengths( messageInfos.map(message => message?.id), ); return _orderBy([ 'time', (message: T) => extendMessageID(message?.id, lengths), ])(['desc', 'desc'])(messageInfos); } const sortMessageIDs: (messages: { +[id: string]: RawMessageInfo }) => ( messageIDs: $ReadOnlyArray, ) => string[] = messages => messageIDs => { const lengths = findMessageIDMaxLengths(messageIDs); return _orderBy([ (id: string) => messages[id].time, (id: string) => extendMessageID(id, lengths), ])(['desc', 'desc'])(messageIDs); }; function rawMessageInfoFromMessageData( messageData: MessageData, id: ?string, ): RawMessageInfo { const messageSpec = messageSpecs[messageData.type]; invariant( messageSpec.rawMessageInfoFromMessageData, `we're not aware of messageType ${messageData.type}`, ); return messageSpec.rawMessageInfoFromMessageData(messageData, id); } function mostRecentMessageTimestamp( messageInfos: $ReadOnlyArray, previousTimestamp: number, ): number { if (messageInfos.length === 0) { return previousTimestamp; } return _maxBy('time')(messageInfos).time; } function usersInMessageInfos( messageInfos: $ReadOnlyArray, ): string[] { const userIDs = new Set(); for (const messageInfo of messageInfos) { if (messageInfo.creatorID) { userIDs.add(messageInfo.creatorID); } else if (messageInfo.creator) { userIDs.add(messageInfo.creator.id); } } return [...userIDs]; } function combineTruncationStatuses( first: MessageTruncationStatus, second: ?MessageTruncationStatus, ): MessageTruncationStatus { if ( first === messageTruncationStatus.EXHAUSTIVE || second === messageTruncationStatus.EXHAUSTIVE ) { return messageTruncationStatus.EXHAUSTIVE; } else if ( first === messageTruncationStatus.UNCHANGED && second !== null && second !== undefined ) { return second; } else { return first; } } function shimUnsupportedRawMessageInfos( rawMessageInfos: $ReadOnlyArray, platformDetails: ?PlatformDetails, ): RawMessageInfo[] { if (platformDetails && isWebPlatform(platformDetails.platform)) { return [...rawMessageInfos]; } return rawMessageInfos.map(rawMessageInfo => { const { shimUnsupportedMessageInfo } = messageSpecs[rawMessageInfo.type]; if (shimUnsupportedMessageInfo) { return shimUnsupportedMessageInfo(rawMessageInfo, platformDetails); } return rawMessageInfo; }); } type MediaMessageDataCreationInput = { +threadID: string, +creatorID: string, +media: $ReadOnlyArray, +localID?: ?string, +time?: ?number, +sidebarCreation?: ?boolean, ... }; function createMediaMessageData( input: MediaMessageDataCreationInput, options: { +forceMultimediaMessageType?: boolean, } = defaultMediaMessageOptions, ): MultimediaMessageData { let allMediaArePhotos = true; const photoMedia = []; for (const singleMedia of input.media) { if (singleMedia.type !== 'photo') { allMediaArePhotos = false; break; } else { photoMedia.push(singleMedia); } } const { localID, threadID, creatorID, sidebarCreation } = input; const { forceMultimediaMessageType = false } = options; const time = input.time ? input.time : Date.now(); let messageData; if (allMediaArePhotos && !forceMultimediaMessageType) { messageData = ({ type: messageTypes.IMAGES, threadID, creatorID, time, media: photoMedia, }: ImagesMessageData); if (localID) { messageData = { ...messageData, localID }; } if (sidebarCreation) { messageData = { ...messageData, sidebarCreation }; } } else { messageData = ({ type: messageTypes.MULTIMEDIA, threadID, creatorID, time, media: input.media, }: MediaMessageData); if (localID) { messageData = { ...messageData, localID }; } if (sidebarCreation) { messageData = { ...messageData, sidebarCreation }; } } return messageData; } type MediaMessageInfoCreationInput = { ...$Exact, +id?: ?string, }; function createMediaMessageInfo( input: MediaMessageInfoCreationInput, options: { +forceMultimediaMessageType?: boolean, } = defaultMediaMessageOptions, ): RawMultimediaMessageInfo { const messageData = createMediaMessageData(input, options); const createRawMessageInfo = messageSpecs[messageData.type].rawMessageInfoFromMessageData; invariant( createRawMessageInfo, 'multimedia message spec should have rawMessageInfoFromMessageData', ); const result = createRawMessageInfo(messageData, input.id); invariant( result.type === messageTypes.MULTIMEDIA || result.type === messageTypes.IMAGES, `media messageSpec returned MessageType ${result.type}`, ); return result; } function stripLocalID( rawMessageInfo: | RawComposableMessageInfo | RawReactionMessageInfo | RawEditMessageInfo, ) { const { localID, ...rest } = rawMessageInfo; return rest; } function stripLocalIDs( input: $ReadOnlyArray, ): RawMessageInfo[] { const output = []; for (const rawMessageInfo of input) { if (rawMessageInfo.localID) { invariant( rawMessageInfo.id, 'serverID should be set if localID is being stripped', ); output.push(stripLocalID(rawMessageInfo)); } else { output.push(rawMessageInfo); } } return output; } // Normally we call trim() to remove whitespace at the beginning and end of each // message. However, our Markdown parser supports a "codeBlock" format where the // user can indent each line to indicate a code block. If we match the // corresponding RegEx, we'll only trim whitespace off the end. function trimMessage(message: string): string { message = message.replace(/^\n*/, ''); return codeBlockRegex.exec(message) ? message.trimEnd() : message.trim(); } function createMessageQuote(message: string): string { // add `>` to each line to include empty lines in the quote return message.replace(/^/gm, '> '); } function createMessageReply(message: string): string { return createMessageQuote(message) + '\n\n'; } function getMostRecentNonLocalMessageID( threadID: string, messageStore: MessageStore, ): ?string { const thread = messageStore.threads[threadID]; return thread?.messageIDs.find(id => !id.startsWith(localIDPrefix)); } function getOldestNonLocalMessageID( threadID: string, messageStore: MessageStore, ): ?string { const thread = messageStore.threads[threadID]; if (!thread) { return thread; } const { messageIDs } = thread; for (let i = messageIDs.length - 1; i >= 0; i--) { const id = messageIDs[i]; if (!id.startsWith(localIDPrefix)) { return id; } } return undefined; } function getMessageTitle( messageInfo: | ComposableMessageInfo | RobotextMessageInfo | ReactionMessageInfo | EditMessageInfo, - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, - parentThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, + threadInfo: ThreadInfo, + parentThreadInfo: ?ThreadInfo, markdownRules: ParserRules, ): EntityText { const { messageTitle } = messageSpecs[messageInfo.type]; if (messageTitle) { return messageTitle({ messageInfo, threadInfo, markdownRules }); } invariant( messageInfo.type !== messageTypes.TEXT && messageInfo.type !== messageTypes.IMAGES && messageInfo.type !== messageTypes.MULTIMEDIA && messageInfo.type !== messageTypes.REACTION && messageInfo.type !== messageTypes.EDIT_MESSAGE, 'messageTitle can only be auto-generated for RobotextMessageInfo', ); return robotextForMessageInfo(messageInfo, threadInfo, parentThreadInfo); } function mergeThreadMessageInfos( first: ThreadMessageInfo, second: ThreadMessageInfo, messages: { +[id: string]: RawMessageInfo }, ): ThreadMessageInfo { let firstPointer = 0; let secondPointer = 0; const mergedMessageIDs = []; let firstCandidate = first.messageIDs[firstPointer]; let secondCandidate = second.messageIDs[secondPointer]; while (firstCandidate !== undefined || secondCandidate !== undefined) { if (firstCandidate === undefined) { mergedMessageIDs.push(secondCandidate); secondPointer++; } else if (secondCandidate === undefined) { mergedMessageIDs.push(firstCandidate); firstPointer++; } else if (firstCandidate === secondCandidate) { mergedMessageIDs.push(firstCandidate); firstPointer++; secondPointer++; } else { const firstMessage = messages[firstCandidate]; const secondMessage = messages[secondCandidate]; invariant( firstMessage && secondMessage, 'message in messageIDs not present in MessageStore', ); if ( (firstMessage.id && secondMessage.id && firstMessage.id === secondMessage.id) || (firstMessage.localID && secondMessage.localID && firstMessage.localID === secondMessage.localID) ) { mergedMessageIDs.push(firstCandidate); firstPointer++; secondPointer++; } else if (firstMessage.time < secondMessage.time) { mergedMessageIDs.push(secondCandidate); secondPointer++; } else { mergedMessageIDs.push(firstCandidate); firstPointer++; } } firstCandidate = first.messageIDs[firstPointer]; secondCandidate = second.messageIDs[secondPointer]; } return { messageIDs: mergedMessageIDs, startReached: first.startReached && second.startReached, }; } type MessagePreviewPart = { +text: string, // unread has highest contrast, followed by primary, followed by secondary +style: 'unread' | 'primary' | 'secondary', }; export type MessagePreviewResult = { +message: MessagePreviewPart, +username: ?MessagePreviewPart, }; function useMessagePreview( originalMessageInfo: ?MessageInfo, - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: ThreadInfo, markdownRules: ParserRules, ): ?MessagePreviewResult { let messageInfo; if ( originalMessageInfo && originalMessageInfo.type === messageTypes.SIDEBAR_SOURCE ) { messageInfo = originalMessageInfo.sourceMessage; } else { messageInfo = originalMessageInfo; } const { parentThreadID } = threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const hasUsername = threadIsGroupChat(threadInfo) || threadInfo.name !== '' || messageInfo?.creator.isViewer; const shouldDisplayUser = messageInfo?.type === messageTypes.TEXT && hasUsername; const stringForUser = useStringForUser( shouldDisplayUser ? messageInfo?.creator : null, ); const { unread } = threadInfo.currentUser; const username = React.useMemo(() => { if (!shouldDisplayUser) { return null; } invariant( stringForUser, 'useStringForUser should only return falsey if pass null or undefined', ); return { text: stringForUser, style: unread ? 'unread' : 'secondary', }; }, [shouldDisplayUser, stringForUser, unread]); const messageTitleEntityText = React.useMemo(() => { if (!messageInfo) { return messageInfo; } return getMessageTitle( messageInfo, threadInfo, parentThreadInfo, markdownRules, ); }, [messageInfo, threadInfo, parentThreadInfo, markdownRules]); const threadID = threadInfo.id; const entityTextToStringParams = React.useMemo( () => ({ threadID, }), [threadID], ); const messageTitle = useEntityTextAsString( messageTitleEntityText, entityTextToStringParams, ); const isTextMessage = messageInfo?.type === messageTypes.TEXT; const message = React.useMemo(() => { if (messageTitle === null || messageTitle === undefined) { return messageTitle; } let style; if (unread) { style = 'unread'; } else if (isTextMessage) { style = 'primary'; } else { style = 'secondary'; } return { text: messageTitle, style }; }, [messageTitle, unread, isTextMessage]); return React.useMemo(() => { if (!message) { return message; } return { message, username }; }, [message, username]); } function useMessageCreationSideEffectsFunc( messageType: $PropertyType, ): CreationSideEffectsFunc { const messageSpec = messageSpecs[messageType]; invariant(messageSpec, `we're not aware of messageType ${messageType}`); invariant( messageSpec.useCreationSideEffectsFunc, `no useCreationSideEffectsFunc in message spec for ${messageType}`, ); return messageSpec.useCreationSideEffectsFunc(); } function getPinnedContentFromMessage(targetMessage: RawMessageInfo): string { let pinnedContent; if ( targetMessage.type === messageTypes.IMAGES || targetMessage.type === messageTypes.MULTIMEDIA ) { pinnedContent = contentStringForMediaArray(targetMessage.media); } else { pinnedContent = 'a message'; } return pinnedContent; } function modifyItemForResultScreen( item: ChatMessageInfoItem, ): ChatMessageInfoItem { if (item.messageInfoType === 'composable') { return { ...item, startsConversation: false, startsCluster: true, endsCluster: true, messageInfo: { ...item.messageInfo, creator: { ...item.messageInfo.creator, isViewer: false, }, }, }; } return item; } function constructChangeRoleEntityText( affectedUsers: EntityText | string, roleName: ?string, ): EntityText { if (!roleName) { return ET`assigned ${affectedUsers} a new role`; } return ET`assigned ${affectedUsers} the "${roleName}" role`; } function useNextLocalID(): string { const nextLocalID = useSelector(state => state.nextLocalID); return `${localIDPrefix}${nextLocalID}`; } function isInvalidSidebarSource( message: RawMessageInfo | MessageInfo, ): boolean %checks { return ( (message.type === messageTypes.REACTION || message.type === messageTypes.EDIT_MESSAGE || message.type === messageTypes.SIDEBAR_SOURCE || message.type === messageTypes.TOGGLE_PIN) && !messageSpecs[message.type].canBeSidebarSource ); } // Prefer checking isInvalidPinSourceForThread below. This function doesn't // check whether the user is attempting to pin a SIDEBAR_SOURCE in the context // of its parent thread, so it's not suitable for permission checks. We only // use it in the message-fetchers.js code where we don't have access to the // RawThreadInfo and don't need to do permission checks. function isInvalidPinSource( messageInfo: RawMessageInfo | MessageInfo, ): boolean { return !messageSpecs[messageInfo.type].canBePinned; } function isInvalidPinSourceForThread( messageInfo: RawMessageInfo | MessageInfo, - threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | ThreadInfo, ): boolean { const isValidPinSource = !isInvalidPinSource(messageInfo); const isFirstMessageInSidebar = threadInfo.sourceMessageID === messageInfo.id; return !isValidPinSource || isFirstMessageInSidebar; } function isUnableToBeRenderedIndependently( message: RawMessageInfo | MessageInfo, ): boolean { return !messageSpecs[message.type].canBeRenderedIndependently; } function findNewestMessageTimePerKeyserver( messageInfos: $ReadOnlyArray, ): { [keyserverID: string]: number } { const timePerKeyserver: { [keyserverID: string]: number } = {}; for (const messageInfo of messageInfos) { const keyserverID = extractKeyserverIDFromID(messageInfo.threadID); if ( !timePerKeyserver[keyserverID] || timePerKeyserver[keyserverID] < messageInfo.time ) { timePerKeyserver[keyserverID] = messageInfo.time; } } return timePerKeyserver; } export { localIDPrefix, messageKey, messageID, robotextForMessageInfo, createMessageInfo, sortMessageInfoList, sortMessageIDs, rawMessageInfoFromMessageData, mostRecentMessageTimestamp, usersInMessageInfos, combineTruncationStatuses, shimUnsupportedRawMessageInfos, createMediaMessageData, createMediaMessageInfo, stripLocalIDs, trimMessage, createMessageQuote, createMessageReply, getMostRecentNonLocalMessageID, getOldestNonLocalMessageID, getMessageTitle, mergeThreadMessageInfos, useMessagePreview, useMessageCreationSideEffectsFunc, getPinnedContentFromMessage, modifyItemForResultScreen, constructChangeRoleEntityText, useNextLocalID, isInvalidSidebarSource, isInvalidPinSource, isInvalidPinSourceForThread, isUnableToBeRenderedIndependently, findNewestMessageTimePerKeyserver, }; diff --git a/lib/shared/notif-utils.js b/lib/shared/notif-utils.js index f20f4e49d..44580e156 100644 --- a/lib/shared/notif-utils.js +++ b/lib/shared/notif-utils.js @@ -1,327 +1,326 @@ // @flow import invariant from 'invariant'; import { isUserMentioned } from './mention-utils.js'; import { robotextForMessageInfo } from './message-utils.js'; import type { NotificationTextsParams } from './messages/message-spec.js'; import { messageSpecs } from './messages/message-specs.js'; import { threadNoun } from './thread-utils.js'; import { type MessageType, messageTypes } from '../types/message-types-enum.js'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type MessageData, type SidebarSourceMessageInfo, } from '../types/message-types.js'; import type { CreateSidebarMessageInfo } from '../types/messages/create-sidebar.js'; import type { TextMessageInfo } from '../types/messages/text.js'; -import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { NotifTexts, ResolvedNotifTexts } from '../types/notif-types.js'; import { type ThreadType, threadTypes } from '../types/thread-types-enum.js'; -import { type LegacyThreadInfo } from '../types/thread-types.js'; +import type { LegacyThreadInfo, ThreadInfo } from '../types/thread-types.js'; import type { RelativeUserInfo, UserInfo } from '../types/user-types.js'; import { prettyDate } from '../utils/date-utils.js'; import type { GetENSNames } from '../utils/ens-helpers.js'; import { ET, getEntityTextAsString, type EntityText, type ThreadEntity, } from '../utils/entity-text.js'; import { promiseAll } from '../utils/promises.js'; import { trimText } from '../utils/text-utils.js'; async function notifTextsForMessageInfo( messageInfos: MessageInfo[], threadInfo: LegacyThreadInfo, parentThreadInfo: ?LegacyThreadInfo, notifTargetUserInfo: UserInfo, getENSNames: ?GetENSNames, ): Promise { const fullNotifTexts = await fullNotifTextsForMessageInfo( messageInfos, threadInfo, parentThreadInfo, notifTargetUserInfo, getENSNames, ); if (!fullNotifTexts) { return fullNotifTexts; } const merged = trimText(fullNotifTexts.merged, 300); const body = trimText(fullNotifTexts.body, 300); const title = trimText(fullNotifTexts.title, 100); if (!fullNotifTexts.prefix) { return { merged, body, title }; } const prefix = trimText(fullNotifTexts.prefix, 50); return { merged, body, title, prefix }; } function notifTextsForEntryCreationOrEdit( messageInfos: $ReadOnlyArray, threadInfo: LegacyThreadInfo, ): NotifTexts { const hasCreateEntry = messageInfos.some( messageInfo => messageInfo.type === messageTypes.CREATE_ENTRY, ); const messageInfo = messageInfos[0]; const thread = ET.thread({ display: 'shortName', threadInfo }); const creator = ET.user({ userInfo: messageInfo.creator }); const prefix = ET`${creator}`; if (!hasCreateEntry) { invariant( messageInfo.type === messageTypes.EDIT_ENTRY, 'messageInfo should be messageTypes.EDIT_ENTRY!', ); const date = prettyDate(messageInfo.date); let body = ET`updated the text of an event in ${thread}`; body = ET`${body} scheduled for ${date}: "${messageInfo.text}"`; const merged = ET`${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } invariant( messageInfo.type === messageTypes.CREATE_ENTRY || messageInfo.type === messageTypes.EDIT_ENTRY, 'messageInfo should be messageTypes.CREATE_ENTRY/EDIT_ENTRY!', ); const date = prettyDate(messageInfo.date); let body = ET`created an event in ${thread}`; body = ET`${body} scheduled for ${date}: "${messageInfo.text}"`; const merged = ET`${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } type NotifTextsForSubthreadCreationInput = { +creator: RelativeUserInfo, +threadType: ThreadType, - +parentThreadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + +parentThreadInfo: ThreadInfo, +childThreadName: ?string, +childThreadUIName: string | ThreadEntity, }; function notifTextsForSubthreadCreation( input: NotifTextsForSubthreadCreationInput, ): NotifTexts { const { creator, threadType, parentThreadInfo, childThreadName, childThreadUIName, } = input; const prefix = ET`${ET.user({ userInfo: creator })}`; let body: string | EntityText = `created a new ${threadNoun( threadType, parentThreadInfo.id, )}`; if (parentThreadInfo.name && parentThreadInfo.type !== threadTypes.GENESIS) { body = ET`${body} in ${parentThreadInfo.name}`; } let merged = ET`${prefix} ${body}`; if (childThreadName) { merged = ET`${merged} called "${childThreadName}"`; } return { merged, body, title: childThreadUIName, prefix, }; } type NotifTextsForSidebarCreationInput = { +createSidebarMessageInfo: CreateSidebarMessageInfo, +sidebarSourceMessageInfo?: ?SidebarSourceMessageInfo, +firstSidebarMessageInfo?: ?TextMessageInfo, - +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: ThreadInfo, +params: NotificationTextsParams, }; function notifTextsForSidebarCreation( input: NotifTextsForSidebarCreationInput, ): NotifTexts { const { sidebarSourceMessageInfo, createSidebarMessageInfo, firstSidebarMessageInfo, threadInfo, params, } = input; const creator = ET.user({ userInfo: createSidebarMessageInfo.creator }); const prefix = ET`${creator}`; const initialName = createSidebarMessageInfo.initialThreadState.name; const sourceMessageAuthorPossessive = ET.user({ userInfo: createSidebarMessageInfo.sourceMessageAuthor, possessive: true, }); let body: string | EntityText = 'started a thread in response to'; body = ET`${body} ${sourceMessageAuthorPossessive} message`; const { username } = params.notifTargetUserInfo; if ( username && sidebarSourceMessageInfo && sidebarSourceMessageInfo.sourceMessage.type === messageTypes.TEXT && isUserMentioned(username, sidebarSourceMessageInfo.sourceMessage.text) ) { body = ET`${body} that tagged you`; } else if ( username && firstSidebarMessageInfo && isUserMentioned(username, firstSidebarMessageInfo.text) ) { body = ET`${body} and tagged you`; } else if (initialName) { body = ET`${body} "${initialName}"`; } return { merged: ET`${prefix} ${body}`, body, title: threadInfo.uiName, prefix, }; } function mostRecentMessageInfoType( messageInfos: $ReadOnlyArray, ): MessageType { if (messageInfos.length === 0) { throw new Error('expected MessageInfo, but none present!'); } return messageInfos[0].type; } async function fullNotifTextsForMessageInfo( messageInfos: $ReadOnlyArray, threadInfo: LegacyThreadInfo, parentThreadInfo: ?LegacyThreadInfo, notifTargetUserInfo: UserInfo, getENSNames: ?GetENSNames, ): Promise { const mostRecentType = mostRecentMessageInfoType(messageInfos); const messageSpec = messageSpecs[mostRecentType]; invariant( messageSpec.notificationTexts, `we're not aware of messageType ${mostRecentType}`, ); const unresolvedNotifTexts = await messageSpec.notificationTexts( messageInfos, threadInfo, { notifTargetUserInfo, parentThreadInfo }, ); if (!unresolvedNotifTexts) { return unresolvedNotifTexts; } const resolveToString = async ( entityText: string | EntityText, ): Promise => { if (typeof entityText === 'string') { return entityText; } const notifString = await getEntityTextAsString(entityText, getENSNames, { prefixThisThreadNounWith: 'your', }); invariant( notifString !== null && notifString !== undefined, 'getEntityTextAsString only returns falsey when passed falsey', ); return notifString; }; let promises = { merged: resolveToString(unresolvedNotifTexts.merged), body: resolveToString(unresolvedNotifTexts.body), title: resolveToString(ET`${unresolvedNotifTexts.title}`), }; if (unresolvedNotifTexts.prefix) { promises = { ...promises, prefix: resolveToString(unresolvedNotifTexts.prefix), }; } return await promiseAll(promises); } function notifRobotextForMessageInfo( messageInfo: RobotextMessageInfo, threadInfo: LegacyThreadInfo, parentThreadInfo: ?LegacyThreadInfo, ): EntityText { const robotext = robotextForMessageInfo( messageInfo, threadInfo, parentThreadInfo, ); return robotext.map(entity => { if ( typeof entity !== 'string' && entity.type === 'thread' && entity.id === threadInfo.id ) { return ET.thread({ display: 'shortName', threadInfo, possessive: entity.possessive, }); } return entity; }); } function getNotifCollapseKey( rawMessageInfo: RawMessageInfo, messageData: MessageData, ): ?string { const messageSpec = messageSpecs[rawMessageInfo.type]; return ( messageSpec.notificationCollapseKey?.(rawMessageInfo, messageData) ?? null ); } type Unmerged = $ReadOnly<{ body: string, title: string, prefix?: string, ... }>; type Merged = { body: string, title: string, }; function mergePrefixIntoBody(unmerged: Unmerged): Merged { const { body, title, prefix } = unmerged; const merged = prefix ? `${prefix} ${body}` : body; return { body: merged, title }; } export { notifRobotextForMessageInfo, notifTextsForMessageInfo, notifTextsForEntryCreationOrEdit, notifTextsForSubthreadCreation, notifTextsForSidebarCreation, getNotifCollapseKey, mergePrefixIntoBody, }; diff --git a/lib/shared/reaction-utils.js b/lib/shared/reaction-utils.js index 83de00f07..4b813f0e1 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 { RobotextMessageInfo, ComposableMessageInfo, } from '../types/message-types.js'; -import type { MinimallyEncodedThreadInfo } 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 type { ThreadInfo } 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 | MinimallyEncodedThreadInfo, + 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 339ba2e25..ff29ee210 100644 --- a/lib/shared/search-utils.js +++ b/lib/shared/search-utils.js @@ -1,388 +1,387 @@ // @flow import * as React from 'react'; import { messageID } from './message-utils.js'; import SearchIndex from './search-index.js'; import { userIsMember, threadMemberHasPermission, getContainingThreadID, } from './thread-utils.js'; import { useSearchMessages as useSearchMessagesAction, searchMessagesActionTypes, } 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 type { MessageInfo, RawMessageInfo } from '../types/message-types.js'; -import type { MinimallyEncodedThreadInfo } 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 ThreadInfo } from '../types/thread-types.js'; import type { AccountUserInfo, UserListItem, GlobalAccountUserInfo, } from '../types/user-types.js'; import { useServerCall, useDispatchActionPromise, } from '../utils/action-utils.js'; import { isValidENSName } from '../utils/ens-helpers.js'; import { values } from '../utils/objects.js'; import { useSelector } from '../utils/redux-utils.js'; const notFriendNotice = 'not friend'; function getPotentialMemberItems({ text, userInfos, searchIndex, excludeUserIDs, includeServerSearchUsers, inputParentThreadInfo, inputCommunityThreadInfo, threadType, }: { +text: string, +userInfos: { +[id: string]: AccountUserInfo }, +searchIndex: SearchIndex, +excludeUserIDs: $ReadOnlyArray, +includeServerSearchUsers?: $ReadOnlyArray, - +inputParentThreadInfo?: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, - +inputCommunityThreadInfo?: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, + +inputParentThreadInfo?: ?ThreadInfo, + +inputCommunityThreadInfo?: ?ThreadInfo, +threadType?: ?ThreadType, }): UserListItem[] { const communityThreadInfo = inputCommunityThreadInfo && inputCommunityThreadInfo.id !== genesis.id ? inputCommunityThreadInfo : null; const parentThreadInfo = inputParentThreadInfo && inputParentThreadInfo.id !== genesis.id ? inputParentThreadInfo : null; const containgThreadID = threadType ? getContainingThreadID(parentThreadInfo, threadType) : null; let containingThreadInfo = null; if (containgThreadID === parentThreadInfo?.id) { containingThreadInfo = parentThreadInfo; } else if (containgThreadID === communityThreadInfo?.id) { containingThreadInfo = communityThreadInfo; } const results: { [id: string]: { ...AccountUserInfo | GlobalAccountUserInfo, isMemberOfParentThread: boolean, isMemberOfContainingThread: boolean, }, } = {}; const appendUserInfo = ( userInfo: AccountUserInfo | GlobalAccountUserInfo, ) => { 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), }; }; if (text === '') { for (const id in userInfos) { appendUserInfo(userInfos[id]); } } else { const ids = searchIndex.getSearchResults(text); for (const id of ids) { appendUserInfo(userInfos[id]); } } if (includeServerSearchUsers) { for (const userInfo of includeServerSearchUsers) { appendUserInfo(userInfo); } } const blockedRelationshipsStatuses = new Set([ userRelationshipStatus.BLOCKED_BY_VIEWER, userRelationshipStatus.BLOCKED_VIEWER, userRelationshipStatus.BOTH_BLOCKED, ]); 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 !blockedRelationshipsStatuses.has(relationshipStatus); }); } const nonFriends = []; const blockedUsers = []; const friends = []; const containingThreadMembers = []; const parentThreadMembers = []; for (const userResult of userResults) { const { relationshipStatus } = userResult; if ( relationshipStatus && blockedRelationshipsStatuses.has(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 && blockedRelationshipsStatuses.has(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; }, ); } 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); })(); 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(() => { (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 = useServerCall(searchUsers); const dispatchActionPromise = useDispatchActionPromise(); React.useEffect(() => { const searchUsersPromise = (async () => { if (forwardLookupSearchText.length === 0) { setServerSearchResults([]); } else { try { const { userInfos } = await callSearchUsers(forwardLookupSearchText); setServerSearchResults( userInfos.filter(({ id }) => id !== currentUserID), ); } catch (err) { setServerSearchResults([]); } } })(); 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 { getPotentialMemberItems, notFriendNotice, useSearchMessages, useSearchUsers, filterChatMessageInfosForSearch, useForwardLookupSearchText, }; diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index 98f3fc810..b3c08e1bf 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1892 +1,1856 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _omitBy from 'lodash/fp/omitBy.js'; import * as React from 'react'; import { generatePendingThreadColor } from './color-utils.js'; import { type ParserRules } from './markdown.js'; import { extractUserMentionsFromText } from './mention-utils.js'; import { getMessageTitle, isInvalidSidebarSource } from './message-utils.js'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import { useForwardLookupSearchText } from './search-utils.js'; import threadWatcher from './thread-watcher.js'; import { fetchMostRecentMessagesActionTypes, useFetchMostRecentMessages, } from '../actions/message-actions.js'; import type { RemoveUsersFromThreadInput } from '../actions/thread-actions'; import { changeThreadMemberRolesActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, } from '../actions/thread-actions.js'; import { searchUsers as searchUserCall } from '../actions/user-actions.js'; import ashoat from '../facts/ashoat.js'; import genesis from '../facts/genesis.js'; import { useLoggedInUserInfo } from '../hooks/account-hooks.js'; import { hasPermission, permissionsToBitmaskHex, threadPermissionsFromBitmaskHex, } from '../permissions/minimally-encoded-thread-permissions.js'; import { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions.js'; import type { ChatThreadItem, ChatMessageInfoItem, } from '../selectors/chat-selectors.js'; import { useGlobalThreadSearchIndex } from '../selectors/nav-selectors.js'; import { threadInfoSelector, pendingToRealizedThreadIDsSelector, threadInfosSelectorForThreadType, } from '../selectors/thread-selectors.js'; import { getRelativeMemberInfos, usersWithPersonalThreadSelector, } from '../selectors/user-selectors.js'; import { getUserAvatarForThread } from '../shared/avatar-utils.js'; import type { CalendarQuery } from '../types/entry-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type RobotextMessageInfo, type ComposableMessageInfo, } from '../types/message-types.js'; import type { MinimallyEncodedRawThreadInfo, MinimallyEncodedThreadCurrentUserInfo, MinimallyEncodedThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { decodeMinimallyEncodedRoleInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadPermissionPropagationPrefixes, threadPermissions, configurableCommunityPermissions, type ThreadPermission, type ThreadPermissionsInfo, type UserSurfacedPermission, threadPermissionFilterPrefixes, } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes, threadTypeIsCommunityRoot, assertThreadType, } from '../types/thread-types-enum.js'; import { type LegacyRawThreadInfo, type LegacyThreadInfo, type MemberInfo, type ServerThreadInfo, type ThreadCurrentUserInfo, type RoleInfo, type ServerMemberInfo, type ClientNewThreadRequest, type NewThreadResult, type ChangeThreadSettingsPayload, type UserProfileThreadInfo, type RelativeMemberInfo, type RawThreadInfo, + type ThreadInfo, } from '../types/thread-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import { type ClientUpdateInfo } from '../types/update-types.js'; import type { GlobalAccountUserInfo, UserInfos, AccountUserInfo, LoggedInUserInfo, UserInfo, } from '../types/user-types.js'; import { useDispatchActionPromise, useServerCall, } from '../utils/action-utils.js'; import type { DispatchActionPromise } from '../utils/action-utils.js'; import type { GetENSNames } from '../utils/ens-helpers.js'; import { ET, entityTextToRawString, getEntityTextAsString, type ThreadEntity, type UserEntity, } from '../utils/entity-text.js'; import { useSelector } from '../utils/redux-utils.js'; import { firstLine } from '../utils/string-utils.js'; import { trimText } from '../utils/text-utils.js'; import { pendingThreadIDRegex } from '../utils/validation-utils.js'; function threadHasPermission( - threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), + threadInfo: ?(ThreadInfo | RawThreadInfo), permission: ThreadPermission, ): boolean { if (!threadInfo) { return false; } invariant( !permissionsDisabledByBlock.has(permission) || threadInfo?.uiName, `${permission} can be disabled by a block, but threadHasPermission can't ` + 'check for a block on RawThreadInfo. Please pass in ThreadInfo instead!', ); if (threadInfo.minimallyEncoded) { return hasPermission(threadInfo.currentUser.permissions, permission); } return permissionLookup(threadInfo.currentUser.permissions, permission); } -function viewerIsMember( - threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), -): boolean { +function viewerIsMember(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return !!( threadInfo && threadInfo.currentUser.role !== null && threadInfo.currentUser.role !== undefined ); } -function threadIsInHome( - threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), -): boolean { +function threadIsInHome(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return !!(threadInfo && threadInfo.currentUser.subscription.home); } // Can have messages -function threadInChatList( - threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), -): boolean { +function threadInChatList(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return ( viewerIsMember(threadInfo) && threadHasPermission(threadInfo, threadPermissions.VISIBLE) ); } -function threadIsTopLevel( - threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), -): boolean { +function threadIsTopLevel(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return threadInChatList(threadInfo) && threadIsChannel(threadInfo); } -function threadIsChannel( - threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), -): boolean { +function threadIsChannel(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return !!(threadInfo && threadInfo.type !== threadTypes.SIDEBAR); } -function threadIsSidebar( - threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), -): boolean { +function threadIsSidebar(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return threadInfo?.type === threadTypes.SIDEBAR; } function threadInBackgroundChatList( - threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), + threadInfo: ?(RawThreadInfo | ThreadInfo), ): boolean { return threadInChatList(threadInfo) && !threadIsInHome(threadInfo); } function threadInHomeChatList( - threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), + threadInfo: ?(RawThreadInfo | ThreadInfo), ): boolean { return threadInChatList(threadInfo) && threadIsInHome(threadInfo); } // Can have Calendar entries, // does appear as a top-level entity in the thread list function threadInFilterList( - threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), + threadInfo: ?(RawThreadInfo | ThreadInfo), ): boolean { return ( threadInChatList(threadInfo) && !!threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function userIsMember( - threadInfo: ?(LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo), + threadInfo: ?(RawThreadInfo | ThreadInfo), userID: string, ): boolean { if (!threadInfo) { return false; } if (threadInfo.id === genesis.id) { return true; } return threadInfo.members.some(member => member.id === userID && member.role); } function threadActualMembers( memberInfos: $ReadOnlyArray, ): $ReadOnlyArray { return memberInfos .filter(memberInfo => memberInfo.role) .map(memberInfo => memberInfo.id); } function threadOtherMembers( memberInfos: $ReadOnlyArray, viewerID: ?string, ): $ReadOnlyArray { return memberInfos.filter( memberInfo => memberInfo.role && memberInfo.id !== viewerID, ); } -function threadMembersWithoutAddedAshoat< - T: LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo, ->(threadInfo: T): $PropertyType { +function threadMembersWithoutAddedAshoat( + threadInfo: T, +): $PropertyType { if (threadInfo.community !== genesis.id) { return threadInfo.members; } return threadInfo.members.filter( member => member.id !== ashoat.id || member.role, ); } -function threadIsGroupChat( - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, -): boolean { +function threadIsGroupChat(threadInfo: ThreadInfo): boolean { return threadInfo.members.length > 2; } function threadOrParentThreadIsGroupChat( - threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | ThreadInfo, ) { return threadMembersWithoutAddedAshoat(threadInfo).length > 2; } function threadIsPending(threadID: ?string): boolean { return !!threadID?.startsWith('pending'); } function threadIsPendingSidebar(threadID: ?string): boolean { return !!threadID?.startsWith('pending/sidebar/'); } function getSingleOtherUser( - threadInfo: LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, ): ?string { if (!viewerID) { return undefined; } const otherMembers = threadOtherMembers(threadInfo.members, viewerID); if (otherMembers.length !== 1) { return undefined; } return otherMembers[0].id; } function getPendingThreadID( threadType: ThreadType, memberIDs: $ReadOnlyArray, sourceMessageID: ?string, ): string { const pendingThreadKey = sourceMessageID ? `sidebar/${sourceMessageID}` : [...memberIDs].sort().join('+'); const pendingThreadTypeString = sourceMessageID ? '' : `type${threadType}/`; return `pending/${pendingThreadTypeString}${pendingThreadKey}`; } type PendingThreadIDContents = { +threadType: ThreadType, +memberIDs: $ReadOnlyArray, +sourceMessageID: ?string, }; function parsePendingThreadID( pendingThreadID: string, ): ?PendingThreadIDContents { const pendingRegex = new RegExp(`^${pendingThreadIDRegex}$`); const pendingThreadIDMatches = pendingRegex.exec(pendingThreadID); if (!pendingThreadIDMatches) { return null; } const [threadTypeString, threadKey] = pendingThreadIDMatches[1].split('/'); const threadType = threadTypeString === 'sidebar' ? threadTypes.SIDEBAR : assertThreadType(Number(threadTypeString.replace('type', ''))); const memberIDs = threadTypeString === 'sidebar' ? [] : threadKey.split('+'); const sourceMessageID = threadTypeString === 'sidebar' ? threadKey : null; return { threadType, memberIDs, sourceMessageID, }; } type UserIDAndUsername = { +id: string, +username: string, ... }; type CreatePendingThreadArgs = { +viewerID: string, +threadType: ThreadType, +members: $ReadOnlyArray, - +parentThreadInfo?: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, + +parentThreadInfo?: ?ThreadInfo, +threadColor?: ?string, +name?: ?string, +sourceMessageID?: string, }; function createPendingThread({ viewerID, threadType, members, parentThreadInfo, threadColor, name, sourceMessageID, }: CreatePendingThreadArgs): LegacyThreadInfo { const now = Date.now(); if (!members.some(member => member.id === viewerID)) { throw new Error( 'createPendingThread should be called with the viewer as a member', ); } const memberIDs = members.map(member => member.id); const threadID = getPendingThreadID(threadType, memberIDs, sourceMessageID); const permissions = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, }; const membershipPermissions = getAllThreadPermissions( makePermissionsBlob(permissions, null, threadID, threadType), threadID, ); const role = { id: `${threadID}/role`, name: 'Members', permissions, isDefault: true, }; const rawThreadInfo = { id: threadID, type: threadType, name: name ?? null, description: null, color: threadColor ?? generatePendingThreadColor(memberIDs), creationTime: now, parentThreadID: parentThreadInfo?.id ?? null, containingThreadID: getContainingThreadID(parentThreadInfo, threadType), community: getCommunity(parentThreadInfo), members: members.map(member => ({ id: member.id, role: role.id, permissions: membershipPermissions, isSender: false, })), roles: { [role.id]: role, }, currentUser: { role: role.id, permissions: membershipPermissions, subscription: { pushNotifs: false, home: false, }, unread: false, }, repliesCount: 0, sourceMessageID, pinnedCount: 0, }; const userInfos: { [string]: UserInfo } = {}; for (const member of members) { const { id, username } = member; userInfos[id] = { id, username }; } return threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, userInfos); } type PendingPersonalThread = { +threadInfo: LegacyThreadInfo, +pendingPersonalThreadUserInfo: UserInfo, }; function createPendingPersonalThread( loggedInUserInfo: LoggedInUserInfo, userID: string, username: string, ): PendingPersonalThread { const pendingPersonalThreadUserInfo = { id: userID, username: username, }; const threadInfo = createPendingThread({ viewerID: loggedInUserInfo.id, threadType: threadTypes.PERSONAL, members: [loggedInUserInfo, pendingPersonalThreadUserInfo], }); return { threadInfo, pendingPersonalThreadUserInfo }; } function createPendingThreadItem( loggedInUserInfo: LoggedInUserInfo, user: UserIDAndUsername, ): ChatThreadItem { const { threadInfo, pendingPersonalThreadUserInfo } = createPendingPersonalThread(loggedInUserInfo, user.id, user.username); return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo: null, mostRecentNonLocalMessage: null, lastUpdatedTime: threadInfo.creationTime, lastUpdatedTimeIncludingSidebars: threadInfo.creationTime, sidebars: [], pendingPersonalThreadUserInfo, }; } // Returns map from lowercase username to AccountUserInfo function memberLowercaseUsernameMap( members: $ReadOnlyArray, ): Map { const memberMap = new Map(); for (const member of members) { const { id, role, username } = member; if (!role || !username) { continue; } memberMap.set(username.toLowerCase(), { id, username }); } return memberMap; } // Returns map from user ID to AccountUserInfo function extractMentionedMembers( text: string, - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: ThreadInfo, ): Map { const memberMap = memberLowercaseUsernameMap(threadInfo.members); const mentions = extractUserMentionsFromText(text); const mentionedMembers = new Map(); for (const mention of mentions) { const userInfo = memberMap.get(mention.toLowerCase()); if (userInfo) { mentionedMembers.set(userInfo.id, userInfo); } } return mentionedMembers; } // When a member of the parent is mentioned in a sidebar, // they will be automatically added to that sidebar function extractNewMentionedParentMembers( messageText: string, - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, - parentThreadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: ThreadInfo, + parentThreadInfo: ThreadInfo, ): AccountUserInfo[] { const mentionedMembersOfParent = extractMentionedMembers( messageText, parentThreadInfo, ); for (const member of threadInfo.members) { if (member.role) { mentionedMembersOfParent.delete(member.id); } } return [...mentionedMembersOfParent.values()]; } type SharedCreatePendingSidebarInput = { +sourceMessageInfo: ComposableMessageInfo | RobotextMessageInfo, - +parentThreadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + +parentThreadInfo: ThreadInfo, +loggedInUserInfo: LoggedInUserInfo, }; type BaseCreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +messageTitle: string, }; function baseCreatePendingSidebar( input: BaseCreatePendingSidebarInput, ): LegacyThreadInfo { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, messageTitle, } = input; const { color, type: parentThreadType } = parentThreadInfo; const threadName = trimText(messageTitle, 30); const initialMembers = new Map(); const { id: viewerID, username: viewerUsername } = loggedInUserInfo; initialMembers.set(viewerID, { id: viewerID, username: viewerUsername }); if (userIsMember(parentThreadInfo, sourceMessageInfo.creator.id)) { const { id: sourceAuthorID, username: sourceAuthorUsername } = sourceMessageInfo.creator; invariant( sourceAuthorUsername, 'sourceAuthorUsername should be set in createPendingSidebar', ); const initialMemberUserInfo = { id: sourceAuthorID, username: sourceAuthorUsername, }; initialMembers.set(sourceAuthorID, initialMemberUserInfo); } const singleOtherUser = getSingleOtherUser(parentThreadInfo, viewerID); if (parentThreadType === threadTypes.PERSONAL && singleOtherUser) { const singleOtherUsername = parentThreadInfo.members.find( member => member.id === singleOtherUser, )?.username; invariant( singleOtherUsername, 'singleOtherUsername should be set in createPendingSidebar', ); const singleOtherUserInfo = { id: singleOtherUser, username: singleOtherUsername, }; initialMembers.set(singleOtherUser, singleOtherUserInfo); } if (sourceMessageInfo.type === messageTypes.TEXT) { const mentionedMembersOfParent = extractMentionedMembers( sourceMessageInfo.text, parentThreadInfo, ); for (const [memberID, member] of mentionedMembersOfParent) { initialMembers.set(memberID, member); } } return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members: [...initialMembers.values()], parentThreadInfo, threadColor: color, name: threadName, sourceMessageID: sourceMessageInfo.id, }); } // The message title here may have ETH addresses that aren't resolved to ENS // names. This function should only be used in cases where we're sure that we // don't care about the thread title. We should prefer createPendingSidebar // wherever possible type CreateUnresolvedPendingSidebarInput = { ...SharedCreatePendingSidebarInput, +markdownRules: ParserRules, }; function createUnresolvedPendingSidebar( input: CreateUnresolvedPendingSidebarInput, ): LegacyThreadInfo { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, parentThreadInfo, markdownRules, ); const messageTitle = entityTextToRawString(messageTitleEntityText, { ignoreViewer: true, }); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, loggedInUserInfo, }); } type CreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +markdownRules: ParserRules, +getENSNames: ?GetENSNames, }; async function createPendingSidebar( input: CreatePendingSidebarInput, ): Promise { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, getENSNames, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, parentThreadInfo, markdownRules, ); const messageTitle = await getEntityTextAsString( messageTitleEntityText, getENSNames, { ignoreViewer: true }, ); invariant( messageTitle !== null && messageTitle !== undefined, 'getEntityTextAsString only returns falsey when passed falsey', ); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, loggedInUserInfo, }); } function pendingThreadType(numberOfOtherMembers: number): 4 | 6 | 7 { if (numberOfOtherMembers === 0) { return threadTypes.PRIVATE; } else if (numberOfOtherMembers === 1) { return threadTypes.PERSONAL; } else { return threadTypes.LOCAL; } } function threadTypeCanBePending(threadType: ThreadType): boolean { return ( threadType === threadTypes.PERSONAL || threadType === threadTypes.LOCAL || threadType === threadTypes.SIDEBAR || threadType === threadTypes.PRIVATE ); } type CreateRealThreadParameters = { - +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: ThreadInfo, +dispatchActionPromise: DispatchActionPromise, +createNewThread: ClientNewThreadRequest => Promise, +sourceMessageID: ?string, +viewerID: ?string, +handleError?: () => mixed, +calendarQuery: CalendarQuery, }; async function createRealThreadFromPendingThread({ threadInfo, dispatchActionPromise, createNewThread, sourceMessageID, viewerID, calendarQuery, }: CreateRealThreadParameters): Promise { if (!threadIsPending(threadInfo.id)) { return threadInfo.id; } const otherMemberIDs = threadOtherMembers(threadInfo.members, viewerID).map( member => member.id, ); let resultPromise; if (threadInfo.type !== threadTypes.SIDEBAR) { invariant( otherMemberIDs.length > 0, 'otherMemberIDs should not be empty for threads', ); resultPromise = createNewThread({ type: pendingThreadType(otherMemberIDs.length), initialMemberIDs: otherMemberIDs, color: threadInfo.color, calendarQuery, }); } else { invariant( sourceMessageID, 'sourceMessageID should be set when creating a sidebar', ); resultPromise = createNewThread({ type: threadTypes.SIDEBAR, initialMemberIDs: otherMemberIDs, color: threadInfo.color, sourceMessageID, parentThreadID: threadInfo.parentThreadID, name: threadInfo.name, calendarQuery, }); } dispatchActionPromise(newThreadActionTypes, resultPromise); const { newThreadID } = await resultPromise; return newThreadID; } type RawThreadInfoOptions = { +filterThreadEditAvatarPermission?: boolean, +excludePinInfo?: boolean, +filterManageInviteLinksPermission?: boolean, +filterVoicedInAnnouncementChannelsPermission?: boolean, }; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?LegacyRawThreadInfo { const filterThreadEditAvatarPermission = options?.filterThreadEditAvatarPermission; const excludePinInfo = options?.excludePinInfo; const filterManageInviteLinksPermission = options?.filterManageInviteLinksPermission; const filterVoicedInAnnouncementChannelsPermission = options?.filterVoicedInAnnouncementChannelsPermission; const filterThreadPermissions = _omitBy( (v, k) => (filterThreadEditAvatarPermission && [ threadPermissions.EDIT_THREAD_AVATAR, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_THREAD_AVATAR, ].includes(k)) || (excludePinInfo && [ threadPermissions.MANAGE_PINS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.MANAGE_PINS, ].includes(k)) || (filterManageInviteLinksPermission && [threadPermissions.MANAGE_INVITE_LINKS].includes(k)) || (filterVoicedInAnnouncementChannelsPermission && [ threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissionFilterPrefixes.TOP_LEVEL + threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS, ].includes(k)), ); const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( serverThreadInfo.id === genesis.id && serverMember.id !== viewerID && serverMember.id !== ashoat.id ) { continue; } const memberPermissions = filterThreadPermissions(serverMember.permissions); members.push({ id: serverMember.id, role: serverMember.role, permissions: memberPermissions, isSender: serverMember.isSender, }); if (serverMember.id === viewerID) { currentUser = { role: serverMember.role, permissions: memberPermissions, subscription: serverMember.subscription, unread: serverMember.unread, }; } } let currentUserPermissions; if (currentUser) { currentUserPermissions = currentUser.permissions; } else { currentUserPermissions = filterThreadPermissions( getAllThreadPermissions(null, serverThreadInfo.id), ); currentUser = { role: null, permissions: currentUserPermissions, subscription: { home: false, pushNotifs: false, }, unread: null, }; } if (!permissionLookup(currentUserPermissions, threadPermissions.KNOW_OF)) { return null; } const rolesWithFilteredThreadPermissions = _mapValues(role => ({ ...role, permissions: filterThreadPermissions(role.permissions), }))(serverThreadInfo.roles); let rawThreadInfo: any = { id: serverThreadInfo.id, type: serverThreadInfo.type, name: serverThreadInfo.name, description: serverThreadInfo.description, color: serverThreadInfo.color, creationTime: serverThreadInfo.creationTime, parentThreadID: serverThreadInfo.parentThreadID, members, roles: rolesWithFilteredThreadPermissions, currentUser, repliesCount: serverThreadInfo.repliesCount, containingThreadID: serverThreadInfo.containingThreadID, community: serverThreadInfo.community, }; const sourceMessageID = serverThreadInfo.sourceMessageID; if (sourceMessageID) { rawThreadInfo = { ...rawThreadInfo, sourceMessageID }; } if (serverThreadInfo.avatar) { rawThreadInfo = { ...rawThreadInfo, avatar: serverThreadInfo.avatar }; } if (!excludePinInfo) { return { ...rawThreadInfo, pinnedCount: serverThreadInfo.pinnedCount, }; } return rawThreadInfo; } -function threadUIName( - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, -): string | ThreadEntity { +function threadUIName(threadInfo: ThreadInfo): string | ThreadEntity { if (threadInfo.name) { return firstLine(threadInfo.name); } let threadMembers: $ReadOnlyArray; let memberEntities: $ReadOnlyArray; // Branching below is to appease flow if (threadInfo.minimallyEncoded) { threadMembers = threadInfo.members.filter(memberInfo => memberInfo.role); memberEntities = threadMembers.map(member => ET.user({ userInfo: member })); } else { threadMembers = threadInfo.members.filter(memberInfo => memberInfo.role); memberEntities = threadMembers.map(member => ET.user({ userInfo: member })); } return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'uiName', uiName: memberEntities, ifJustViewer: threadInfo.type === threadTypes.PRIVATE ? 'viewer_username' : 'just_you_string', }; } function threadInfoFromRawThreadInfo( rawThreadInfo: LegacyRawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): LegacyThreadInfo { let threadInfo: LegacyThreadInfo = { id: rawThreadInfo.id, type: rawThreadInfo.type, name: rawThreadInfo.name, uiName: '', description: rawThreadInfo.description, color: rawThreadInfo.color, creationTime: rawThreadInfo.creationTime, parentThreadID: rawThreadInfo.parentThreadID, containingThreadID: rawThreadInfo.containingThreadID, community: rawThreadInfo.community, members: getRelativeMemberInfos(rawThreadInfo, viewerID, userInfos), roles: rawThreadInfo.roles, currentUser: getCurrentUser(rawThreadInfo, viewerID, userInfos), repliesCount: rawThreadInfo.repliesCount, }; threadInfo = { ...threadInfo, uiName: threadUIName(threadInfo), }; const { sourceMessageID, avatar, pinnedCount } = rawThreadInfo; if (sourceMessageID) { threadInfo = { ...threadInfo, sourceMessageID }; } if (avatar) { threadInfo = { ...threadInfo, avatar }; } else if ( rawThreadInfo.type === threadTypes.PERSONAL || rawThreadInfo.type === threadTypes.PRIVATE ) { threadInfo = { ...threadInfo, avatar: getUserAvatarForThread(rawThreadInfo, viewerID, userInfos), }; } if (pinnedCount) { threadInfo = { ...threadInfo, pinnedCount }; } return threadInfo; } function getCurrentUser( threadInfo: LegacyRawThreadInfo | LegacyThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } return { ...threadInfo.currentUser, permissions: { ...threadInfo.currentUser.permissions, ...disabledPermissions, }, }; } function getMinimallyEncodedCurrentUser( threadInfo: MinimallyEncodedRawThreadInfo | MinimallyEncodedThreadInfo, viewerID: ?string, userInfos: UserInfos, ): MinimallyEncodedThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } const decodedPermissions = threadPermissionsFromBitmaskHex( threadInfo.currentUser.permissions, ); const updatedPermissions = { ...decodedPermissions, ...disabledPermissions, }; const encodedUpdatedPermissions = permissionsToBitmaskHex(updatedPermissions); return { ...threadInfo.currentUser, permissions: encodedUpdatedPermissions, }; } function threadIsWithBlockedUserOnly( - threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock?: boolean, ): boolean { if ( threadOrParentThreadIsGroupChat(threadInfo) || threadOrParentThreadHasAdminRole(threadInfo) ) { return false; } const otherUserID = getSingleOtherUser(threadInfo, viewerID); if (!otherUserID) { return false; } const otherUserRelationshipStatus = userInfos[otherUserID]?.relationshipStatus; if (checkOnlyViewerBlock) { return ( otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ); } return ( !!otherUserRelationshipStatus && relationshipBlockedInEitherDirection(otherUserRelationshipStatus) ); } function threadFrozenDueToBlock( - threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos); } function threadFrozenDueToViewerBlock( - threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos, true); } const threadTypeDescriptions: { [ThreadType]: string } = { [threadTypes.COMMUNITY_OPEN_SUBTHREAD]: 'Anybody in the parent channel can see an open subchannel.', [threadTypes.COMMUNITY_SECRET_SUBTHREAD]: 'Only visible to its members and admins of ancestor channels.', }; function memberIsAdmin( memberInfo: RelativeMemberInfo | MemberInfo, - threadInfo: LegacyThreadInfo | RawThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | ThreadInfo, ): boolean { return !!( memberInfo.role && roleIsAdminRole(threadInfo.roles[memberInfo.role]) ); } // Since we don't have access to all of the ancestor ThreadInfos, we approximate // "parent admin" as anybody with CHANGE_ROLE permissions. function memberHasAdminPowers( memberInfo: RelativeMemberInfo | MemberInfo | ServerMemberInfo, ): boolean { if (memberInfo.minimallyEncoded) { return hasPermission(memberInfo.permissions, threadPermissions.CHANGE_ROLE); } return !!memberInfo.permissions[threadPermissions.CHANGE_ROLE]?.value; } function roleIsAdminRole(roleInfo: ?RoleInfo): boolean { return !!(roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'); } function threadHasAdminRole( - threadInfo: ?( - | RawThreadInfo - | LegacyThreadInfo - | ServerThreadInfo - | MinimallyEncodedThreadInfo - ), + threadInfo: ?(RawThreadInfo | ThreadInfo | ServerThreadInfo), ): boolean { if (!threadInfo) { return false; } return !!_find({ name: 'Admins' })(threadInfo.roles); } function threadOrParentThreadHasAdminRole( - threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | ThreadInfo, ) { return ( threadMembersWithoutAddedAshoat(threadInfo).filter(member => memberHasAdminPowers(member), ).length > 0 ); } function identifyInvalidatedThreads( updateInfos: $ReadOnlyArray, ): Set { const invalidated = new Set(); for (const updateInfo of updateInfos) { if (updateInfo.type === updateTypes.DELETE_THREAD) { invalidated.add(updateInfo.threadID); } } return invalidated; } const permissionsDisabledByBlockArray = [ threadPermissions.VOICED, threadPermissions.EDIT_ENTRIES, threadPermissions.EDIT_THREAD_NAME, threadPermissions.EDIT_THREAD_COLOR, threadPermissions.EDIT_THREAD_DESCRIPTION, threadPermissions.CREATE_SUBCHANNELS, threadPermissions.CREATE_SIDEBARS, threadPermissions.JOIN_THREAD, threadPermissions.EDIT_PERMISSIONS, threadPermissions.ADD_MEMBERS, threadPermissions.REMOVE_MEMBERS, ]; const permissionsDisabledByBlock: Set = new Set( permissionsDisabledByBlockArray, ); const disabledPermissions: ThreadPermissionsInfo = permissionsDisabledByBlockArray.reduce( (permissions: ThreadPermissionsInfo, permission: string) => ({ ...permissions, [permission]: { value: false, source: null }, }), {}, ); // Consider updating itemHeight in native/chat/chat-thread-list.react.js // if you change this const emptyItemText: string = `Background chats are just like normal chats, except they don't ` + `contribute to your unread count.\n\n` + `To move a chat over here, switch the “Background” option in its settings.`; function threadNoun(threadType: ThreadType, parentThreadID: ?string): string { if (threadType === threadTypes.SIDEBAR) { return 'thread'; } else if ( threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD && parentThreadID === genesis.id ) { return 'chat'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.GENESIS ) { return 'channel'; } else { return 'chat'; } } function threadLabel(threadType: ThreadType): string { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD ) { return 'Open'; } else if (threadType === threadTypes.PERSONAL) { return 'Personal'; } else if (threadType === threadTypes.SIDEBAR) { return 'Thread'; } else if (threadType === threadTypes.PRIVATE) { return 'Private'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS ) { return 'Community'; } else { return 'Secret'; } } -function useWatchThread( - threadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, -) { +function useWatchThread(threadInfo: ?ThreadInfo) { const dispatchActionPromise = useDispatchActionPromise(); const callFetchMostRecentMessages = useFetchMostRecentMessages(); const threadID = threadInfo?.id; const threadNotInChatList = !threadInChatList(threadInfo); React.useEffect(() => { if (threadID && threadNotInChatList) { threadWatcher.watchID(threadID); dispatchActionPromise( fetchMostRecentMessagesActionTypes, callFetchMostRecentMessages({ threadID }), ); } return () => { if (threadID && threadNotInChatList) { threadWatcher.removeID(threadID); } }; }, [ callFetchMostRecentMessages, dispatchActionPromise, threadNotInChatList, threadID, ]); } type ExistingThreadInfoFinderParams = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, }; type ExistingThreadInfoFinder = ( params: ExistingThreadInfoFinderParams, -) => ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo; +) => ?ThreadInfo; // TODO (atul): Parameterize function once `createPendingThread` is updated. function useExistingThreadInfoFinder( - baseThreadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, + baseThreadInfo: ?ThreadInfo, ): ExistingThreadInfoFinder { const threadInfos = useSelector(threadInfoSelector); const loggedInUserInfo = useLoggedInUserInfo(); const userInfos = useSelector(state => state.userStore.userInfos); const pendingToRealizedThreadIDs = useSelector(state => pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos), ); return React.useCallback( - ( - params: ExistingThreadInfoFinderParams, - ): ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo => { + (params: ExistingThreadInfoFinderParams): ?ThreadInfo => { if (!baseThreadInfo) { return null; } const realizedThreadInfo = threadInfos[baseThreadInfo.id]; if (realizedThreadInfo) { return realizedThreadInfo; } if (!loggedInUserInfo || !threadIsPending(baseThreadInfo.id)) { return baseThreadInfo; } const viewerID = loggedInUserInfo?.id; invariant( threadTypeCanBePending(baseThreadInfo.type), `ThreadInfo has pending ID ${baseThreadInfo.id}, but type that ` + `should not be pending ${baseThreadInfo.type}`, ); const { searching, userInfoInputArray } = params; const { sourceMessageID } = baseThreadInfo; const pendingThreadID = searching ? getPendingThreadID( pendingThreadType(userInfoInputArray.length), [...userInfoInputArray.map(user => user.id), viewerID], sourceMessageID, ) : getPendingThreadID( baseThreadInfo.type, baseThreadInfo.members.map(member => member.id), sourceMessageID, ); const realizedThreadID = pendingToRealizedThreadIDs.get(pendingThreadID); if (realizedThreadID && threadInfos[realizedThreadID]) { return threadInfos[realizedThreadID]; } const updatedThread = searching ? createPendingThread({ viewerID, threadType: pendingThreadType(userInfoInputArray.length), members: [loggedInUserInfo, ...userInfoInputArray], }) : baseThreadInfo; if (updatedThread.minimallyEncoded) { return { ...updatedThread, currentUser: getMinimallyEncodedCurrentUser( updatedThread, viewerID, userInfos, ), }; } else { return { ...updatedThread, currentUser: getCurrentUser(updatedThread, viewerID, userInfos), }; } }, [ baseThreadInfo, threadInfos, loggedInUserInfo, pendingToRealizedThreadIDs, userInfos, ], ); } type ThreadTypeParentRequirement = 'optional' | 'required' | 'disabled'; function getThreadTypeParentRequirement( threadType: ThreadType, ): ThreadTypeParentRequirement { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || //threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.SIDEBAR ) { return 'required'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS || threadType === threadTypes.PERSONAL || threadType === threadTypes.PRIVATE ) { return 'disabled'; } else { return 'optional'; } } function threadMemberHasPermission( - threadInfo: - | ServerThreadInfo - | RawThreadInfo - | LegacyThreadInfo - | MinimallyEncodedThreadInfo, + threadInfo: ServerThreadInfo | RawThreadInfo | ThreadInfo, memberID: string, permission: ThreadPermission, ): boolean { for (const member of threadInfo.members) { if (member.id !== memberID) { continue; } if (member.minimallyEncoded) { return hasPermission(member.permissions, permission); } return permissionLookup(member.permissions, permission); } return false; } function useCanCreateSidebarFromMessage( - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: ThreadInfo, messageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const messageCreatorUserInfo = useSelector( state => state.userStore.userInfos[messageInfo.creator.id], ); if ( !messageInfo.id || threadInfo.sourceMessageID === messageInfo.id || isInvalidSidebarSource(messageInfo) ) { return false; } const messageCreatorRelationship = messageCreatorUserInfo?.relationshipStatus; const creatorRelationshipHasBlock = messageCreatorRelationship && relationshipBlockedInEitherDirection(messageCreatorRelationship); const hasCreateSidebarsPermission = threadHasPermission( threadInfo, threadPermissions.CREATE_SIDEBARS, ); return hasCreateSidebarsPermission && !creatorRelationshipHasBlock; } function useSidebarExistsOrCanBeCreated( - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: ThreadInfo, messageItem: ChatMessageInfoItem, ): boolean { const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( threadInfo, messageItem.messageInfo, ); return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage; } -function checkIfDefaultMembersAreVoiced( - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, -): boolean { +function checkIfDefaultMembersAreVoiced(threadInfo: ThreadInfo): boolean { const defaultRoleID = Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].isDefault, ); invariant( defaultRoleID !== undefined, 'all threads should have a default role', ); const defaultRole = threadInfo.roles[defaultRoleID]; const defaultRolePermissions = defaultRole.minimallyEncoded ? decodeMinimallyEncodedRoleInfo(defaultRole).permissions : defaultRole.permissions; return !!defaultRolePermissions[threadPermissions.VOICED]; } const draftKeySuffix = '/message_composer'; function draftKeyFromThreadID(threadID: string): string { return `${threadID}${draftKeySuffix}`; } function getContainingThreadID( parentThreadInfo: | ?ServerThreadInfo | RawThreadInfo - | LegacyThreadInfo + | ThreadInfo | MinimallyEncodedThreadInfo, threadType: ThreadType, ): ?string { if (!parentThreadInfo) { return null; } if (threadType === threadTypes.SIDEBAR) { return parentThreadInfo.id; } if (!parentThreadInfo.containingThreadID) { return parentThreadInfo.id; } return parentThreadInfo.containingThreadID; } function getCommunity( - parentThreadInfo: - | ?ServerThreadInfo - | RawThreadInfo - | LegacyThreadInfo - | MinimallyEncodedThreadInfo, + parentThreadInfo: ?ServerThreadInfo | RawThreadInfo | ThreadInfo, ): ?string { if (!parentThreadInfo) { return null; } const { id, community, type } = parentThreadInfo; if (community !== null && community !== undefined) { return community; } if (threadTypeIsCommunityRoot(type)) { return id; } return null; } function getThreadListSearchResults( chatListData: $ReadOnlyArray, searchText: string, threadFilter: LegacyThreadInfo => boolean, threadSearchResults: $ReadOnlySet, usersSearchResults: $ReadOnlyArray, loggedInUserInfo: ?LoggedInUserInfo, ): $ReadOnlyArray { if (!searchText) { return chatListData.filter( item => threadIsTopLevel(item.threadInfo) && threadFilter(item.threadInfo), ); } const privateThreads = []; const personalThreads = []; const otherThreads = []; for (const item of chatListData) { if (!threadSearchResults.has(item.threadInfo.id)) { continue; } if (item.threadInfo.type === threadTypes.PRIVATE) { privateThreads.push({ ...item, sidebars: [] }); } else if (item.threadInfo.type === threadTypes.PERSONAL) { personalThreads.push({ ...item, sidebars: [] }); } else { otherThreads.push({ ...item, sidebars: [] }); } } const chatItems: ChatThreadItem[] = [ ...privateThreads, ...personalThreads, ...otherThreads, ]; if (loggedInUserInfo) { chatItems.push( ...usersSearchResults.map(user => createPendingThreadItem(loggedInUserInfo, user), ), ); } return chatItems; } type ThreadListSearchResult = { +threadSearchResults: $ReadOnlySet, +usersSearchResults: $ReadOnlyArray, }; function useThreadListSearch( searchText: string, viewerID: ?string, ): ThreadListSearchResult { const callSearchUsers = useServerCall(searchUserCall); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); const forwardLookupSearchText = useForwardLookupSearchText(searchText); const searchUsers = React.useCallback( async (usernamePrefix: string) => { if (usernamePrefix.length === 0) { return ([]: GlobalAccountUserInfo[]); } const { userInfos } = await callSearchUsers(usernamePrefix); return userInfos.filter( info => !usersWithPersonalThread.has(info.id) && info.id !== viewerID, ); }, [callSearchUsers, usersWithPersonalThread, viewerID], ); const [threadSearchResults, setThreadSearchResults] = React.useState( new Set(), ); const [usersSearchResults, setUsersSearchResults] = React.useState< $ReadOnlyArray, >([]); const threadSearchIndex = useGlobalThreadSearchIndex(); React.useEffect(() => { (async () => { const results = threadSearchIndex.getSearchResults(searchText); setThreadSearchResults(new Set(results)); const usersResults = await searchUsers(forwardLookupSearchText); setUsersSearchResults(usersResults); })(); }, [searchText, forwardLookupSearchText, threadSearchIndex, searchUsers]); return { threadSearchResults, usersSearchResults }; } function removeMemberFromThread( - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: ThreadInfo, memberInfo: RelativeMemberInfo, dispatchActionPromise: DispatchActionPromise, removeUserFromThreadServerCall: ( input: RemoveUsersFromThreadInput, ) => Promise, ) { const customKeyName = `${removeUsersFromThreadActionTypes.started}:${memberInfo.id}`; dispatchActionPromise( removeUsersFromThreadActionTypes, removeUserFromThreadServerCall({ threadID: threadInfo.id, memberIDs: [memberInfo.id], }), { customKeyName }, ); } function switchMemberAdminRoleInThread( - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: ThreadInfo, memberInfo: RelativeMemberInfo, isCurrentlyAdmin: boolean, dispatchActionPromise: DispatchActionPromise, changeUserRoleServerCall: ( threadID: string, memberIDs: $ReadOnlyArray, newRole: string, ) => Promise, ) { let newRole = null; for (const roleID in threadInfo.roles) { const role = threadInfo.roles[roleID]; if (isCurrentlyAdmin && role.isDefault) { newRole = role.id; break; } else if (!isCurrentlyAdmin && roleIsAdminRole(role)) { newRole = role.id; break; } } invariant(newRole !== null, 'Could not find new role'); const customKeyName = `${changeThreadMemberRolesActionTypes.started}:${memberInfo.id}`; dispatchActionPromise( changeThreadMemberRolesActionTypes, changeUserRoleServerCall(threadInfo.id, [memberInfo.id], newRole), { customKeyName }, ); } function getAvailableThreadMemberActions( memberInfo: RelativeMemberInfo, - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: ThreadInfo, canEdit: ?boolean = true, ): $ReadOnlyArray<'change_role' | 'remove_user'> { const role = memberInfo.role; if (!canEdit || !role) { return []; } const canRemoveMembers = threadHasPermission( threadInfo, threadPermissions.REMOVE_MEMBERS, ); const canChangeRoles = threadHasPermission( threadInfo, threadPermissions.CHANGE_ROLE, ); const result = []; if (canChangeRoles && memberInfo.username && threadHasAdminRole(threadInfo)) { result.push('change_role'); } if ( canRemoveMembers && !memberInfo.isViewer && (canChangeRoles || threadInfo.roles[role]?.isDefault) ) { result.push('remove_user'); } return result; } function patchThreadInfoToIncludeMentionedMembersOfParent( - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, - parentThreadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: ThreadInfo, + parentThreadInfo: ThreadInfo, messageText: string, viewerID: string, -): LegacyThreadInfo | MinimallyEncodedThreadInfo { +): ThreadInfo { const members: UserIDAndUsername[] = threadInfo.members .map(({ id, username }) => username ? ({ id, username }: UserIDAndUsername) : null, ) .filter(Boolean); const mentionedNewMembers = extractNewMentionedParentMembers( messageText, threadInfo, parentThreadInfo, ); if (mentionedNewMembers.length === 0) { return threadInfo; } members.push(...mentionedNewMembers); return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members, parentThreadInfo, threadColor: threadInfo.color, name: threadInfo.name, sourceMessageID: threadInfo.sourceMessageID, }); } function threadInfoInsideCommunity( - threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | ThreadInfo, communityID: string, ): boolean { return threadInfo.community === communityID || threadInfo.id === communityID; } type RoleAndMemberCount = { [roleName: string]: number, }; function useRoleMemberCountsForCommunity( - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: ThreadInfo, ): RoleAndMemberCount { return React.useMemo(() => { const roleIDsToNames: { [string]: string } = {}; Object.keys(threadInfo.roles).forEach(roleID => { roleIDsToNames[roleID] = threadInfo.roles[roleID].name; }); const roleNamesToMemberCount: RoleAndMemberCount = {}; threadInfo.members.forEach(({ role: roleID }) => { invariant(roleID, 'Community member should have a role'); const roleName = roleIDsToNames[roleID]; roleNamesToMemberCount[roleName] = (roleNamesToMemberCount[roleName] ?? 0) + 1; }); // For all community roles with no members, add them to the list with 0 Object.keys(roleIDsToNames).forEach(roleName => { if (roleNamesToMemberCount[roleIDsToNames[roleName]] === undefined) { roleNamesToMemberCount[roleIDsToNames[roleName]] = 0; } }); return roleNamesToMemberCount; }, [threadInfo]); } type RoleUserSurfacedPermissions = { +[roleName: string]: $ReadOnlySet, }; // Iterates through the existing roles in the community and 'reverse maps' // the set of permission literals for each role to user-facing permission enums // to help pre-populate the permission checkboxes when editing roles. function useRoleUserSurfacedPermissions( threadInfo: LegacyThreadInfo, ): RoleUserSurfacedPermissions { return React.useMemo(() => { const roleNamesToPermissions: { [string]: Set } = {}; Object.keys(threadInfo.roles).forEach(roleID => { const roleName = threadInfo.roles[roleID].name; const rolePermissions = Object.keys(threadInfo.roles[roleID].permissions); const setOfUserSurfacedPermissions = new Set(); rolePermissions.forEach(rolePermission => { const userSurfacedPermission = Object.keys( configurableCommunityPermissions, ).find(key => configurableCommunityPermissions[key].has(rolePermission), ); if (userSurfacedPermission) { setOfUserSurfacedPermissions.add(userSurfacedPermission); } }); roleNamesToPermissions[roleName] = setOfUserSurfacedPermissions; }); return roleNamesToPermissions; }, [threadInfo]); } -function communityOrThreadNoun( - threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, -): string { +function communityOrThreadNoun(threadInfo: RawThreadInfo | ThreadInfo): string { return threadTypeIsCommunityRoot(threadInfo.type) ? 'community' : threadNoun(threadInfo.type, threadInfo.parentThreadID); } function getThreadsToDeleteText( - threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | ThreadInfo, ): string { return `${ threadTypeIsCommunityRoot(threadInfo.type) ? 'Subchannels and threads' : 'Threads' } within this ${communityOrThreadNoun(threadInfo)}`; } function useUserProfileThreadInfo(userInfo: ?UserInfo): ?UserProfileThreadInfo { const userID = userInfo?.id; const username = userInfo?.username; const loggedInUserInfo = useLoggedInUserInfo(); const isViewerProfile = loggedInUserInfo?.id === userID; const privateThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.PRIVATE, ); const privateThreadInfos = useSelector(privateThreadInfosSelector); const personalThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.PERSONAL, ); const personalThreadInfos = useSelector(personalThreadInfosSelector); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); return React.useMemo(() => { if (!loggedInUserInfo || !userID || !username) { return null; } if (isViewerProfile) { const privateThreadInfo: ?LegacyThreadInfo = privateThreadInfos[0]; return privateThreadInfo ? { threadInfo: privateThreadInfo } : null; } if (usersWithPersonalThread.has(userID)) { const personalThreadInfo: ?LegacyThreadInfo = personalThreadInfos.find( threadInfo => userID === getSingleOtherUser(threadInfo, loggedInUserInfo.id), ); return personalThreadInfo ? { threadInfo: personalThreadInfo } : null; } const pendingPersonalThreadInfo = createPendingPersonalThread( loggedInUserInfo, userID, username, ); return pendingPersonalThreadInfo; }, [ isViewerProfile, loggedInUserInfo, personalThreadInfos, privateThreadInfos, userID, username, usersWithPersonalThread, ]); } export { threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadIsChannel, threadIsSidebar, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadOtherMembers, threadIsGroupChat, threadIsPending, threadIsPendingSidebar, getSingleOtherUser, getPendingThreadID, parsePendingThreadID, createPendingThread, createUnresolvedPendingSidebar, extractNewMentionedParentMembers, createPendingSidebar, pendingThreadType, createRealThreadFromPendingThread, getCurrentUser, getMinimallyEncodedCurrentUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, threadUIName, threadInfoFromRawThreadInfo, threadTypeDescriptions, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadNoun, threadLabel, useWatchThread, useExistingThreadInfoFinder, getThreadTypeParentRequirement, threadMemberHasPermission, useCanCreateSidebarFromMessage, useSidebarExistsOrCanBeCreated, checkIfDefaultMembersAreVoiced, draftKeySuffix, draftKeyFromThreadID, threadTypeCanBePending, getContainingThreadID, getCommunity, getThreadListSearchResults, useThreadListSearch, removeMemberFromThread, switchMemberAdminRoleInThread, getAvailableThreadMemberActions, threadMembersWithoutAddedAshoat, patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, useRoleMemberCountsForCommunity, useRoleUserSurfacedPermissions, getThreadsToDeleteText, useUserProfileThreadInfo, }; diff --git a/lib/shared/user-utils.js b/lib/shared/user-utils.js index 56eb3c07a..05c245c8f 100644 --- a/lib/shared/user-utils.js +++ b/lib/shared/user-utils.js @@ -1,53 +1,48 @@ // @flow import { memberHasAdminPowers } from './thread-utils.js'; import { useENSNames } from '../hooks/ens-cache.js'; -import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { RawThreadInfo, ServerThreadInfo, - LegacyThreadInfo, + ThreadInfo, } from '../types/thread-types.js'; import type { UserInfo } from '../types/user-types.js'; import { useSelector } from '../utils/redux-utils.js'; function stringForUser( user: ?{ +username?: ?string, +isViewer?: ?boolean, ... }, ): string { if (user?.isViewer) { return 'you'; } return stringForUserExplicit(user); } function stringForUserExplicit(user: ?{ +username: ?string, ... }): string { if (user?.username) { return user.username; } return 'anonymous'; } function useKeyserverAdmin( - community: - | LegacyThreadInfo - | RawThreadInfo - | ServerThreadInfo - | MinimallyEncodedThreadInfo, + 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/types/thread-types.js b/lib/types/thread-types.js index 4a230f3fd..3331e4975 100644 --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -1,493 +1,496 @@ // @flow import t, { type TInterface } from 'tcomb'; import { type AvatarDBContent, type ClientAvatar, clientAvatarValidator, type UpdateUserAvatarRequest, } from './avatar-types.js'; import type { CalendarQuery } from './entry-types.js'; import type { Media } from './media-types.js'; import type { MessageTruncationStatuses, RawMessageInfo, } from './message-types.js'; import type { MinimallyEncodedMemberInfo, MinimallyEncodedRawThreadInfo, MinimallyEncodedRelativeMemberInfo, MinimallyEncodedResolvedThreadInfo, MinimallyEncodedRoleInfo, + MinimallyEncodedThreadInfo, } from './minimally-encoded-thread-permissions-types.js'; import { type ThreadSubscription, threadSubscriptionValidator, } from './subscription-types.js'; import { type ThreadPermissionsInfo, threadPermissionsInfoValidator, type ThreadRolePermissionsBlob, threadRolePermissionsBlobValidator, type UserSurfacedPermission, } from './thread-permission-types.js'; import { type ThreadType, threadTypeValidator } from './thread-types-enum.js'; import type { ClientUpdateInfo, ServerUpdateInfo } from './update-types.js'; import type { UserInfo, UserInfos } from './user-types.js'; import { type ThreadEntity, threadEntityValidator, } from '../utils/entity-text.js'; import { tID, tShape } from '../utils/validation-utils.js'; export type LegacyMemberInfo = { +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +isSender: boolean, }; export const legacyMemberInfoValidator: TInterface = tShape({ id: t.String, role: t.maybe(tID), permissions: threadPermissionsInfoValidator, isSender: t.Boolean, }); export type MemberInfo = LegacyMemberInfo | MinimallyEncodedMemberInfo; export type LegacyRelativeMemberInfo = $ReadOnly<{ ...LegacyMemberInfo, +username: ?string, +isViewer: boolean, }>; const legacyRelativeMemberInfoValidator = tShape({ ...legacyMemberInfoValidator.meta.props, username: t.maybe(t.String), isViewer: t.Boolean, }); export type RelativeMemberInfo = | LegacyRelativeMemberInfo | MinimallyEncodedRelativeMemberInfo; export type LegacyRoleInfo = { +id: string, +name: string, +permissions: ThreadRolePermissionsBlob, +isDefault: boolean, }; export const legacyRoleInfoValidator: TInterface = tShape({ id: tID, name: t.String, permissions: threadRolePermissionsBlobValidator, isDefault: t.Boolean, }); export type RoleInfo = LegacyRoleInfo | MinimallyEncodedRoleInfo; export type ThreadCurrentUserInfo = { +role: ?string, +permissions: ThreadPermissionsInfo, +subscription: ThreadSubscription, +unread: ?boolean, }; export const threadCurrentUserInfoValidator: TInterface = tShape({ role: t.maybe(tID), permissions: threadPermissionsInfoValidator, subscription: threadSubscriptionValidator, unread: t.maybe(t.Boolean), }); export type LegacyRawThreadInfo = { +id: string, +type: ThreadType, +name: ?string, +avatar?: ?ClientAvatar, +description: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +members: $ReadOnlyArray, +roles: { +[id: string]: LegacyRoleInfo }, +currentUser: ThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, }; export type LegacyRawThreadInfos = { +[id: string]: LegacyRawThreadInfo, }; export const legacyRawThreadInfoValidator: TInterface = tShape({ id: tID, type: threadTypeValidator, name: t.maybe(t.String), avatar: t.maybe(clientAvatarValidator), description: t.maybe(t.String), color: t.String, creationTime: t.Number, parentThreadID: t.maybe(tID), containingThreadID: t.maybe(tID), community: t.maybe(tID), members: t.list(legacyMemberInfoValidator), roles: t.dict(tID, legacyRoleInfoValidator), currentUser: threadCurrentUserInfoValidator, sourceMessageID: t.maybe(tID), repliesCount: t.Number, pinnedCount: t.maybe(t.Number), }); export type RawThreadInfo = LegacyRawThreadInfo | MinimallyEncodedRawThreadInfo; export type LegacyThreadInfo = { +id: string, +type: ThreadType, +name: ?string, +uiName: string | ThreadEntity, +avatar?: ?ClientAvatar, +description: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +members: $ReadOnlyArray, +roles: { +[id: string]: LegacyRoleInfo }, +currentUser: ThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, }; export const legacyThreadInfoValidator: TInterface = tShape({ id: tID, type: threadTypeValidator, name: t.maybe(t.String), uiName: t.union([t.String, threadEntityValidator]), avatar: t.maybe(clientAvatarValidator), description: t.maybe(t.String), color: t.String, creationTime: t.Number, parentThreadID: t.maybe(tID), containingThreadID: t.maybe(tID), community: t.maybe(tID), members: t.list(legacyRelativeMemberInfoValidator), roles: t.dict(tID, legacyRoleInfoValidator), currentUser: threadCurrentUserInfoValidator, sourceMessageID: t.maybe(tID), repliesCount: t.Number, pinnedCount: t.maybe(t.Number), }); +export type ThreadInfo = LegacyThreadInfo | MinimallyEncodedThreadInfo; + export type ResolvedThreadInfo = { +id: string, +type: ThreadType, +name: ?string, +uiName: string, +avatar?: ?ClientAvatar, +description: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +members: $ReadOnlyArray, +roles: { +[id: string]: LegacyRoleInfo }, +currentUser: ThreadCurrentUserInfo, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, }; export type ServerMemberInfo = { +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +subscription: ThreadSubscription, +unread: ?boolean, +isSender: boolean, }; export type ServerThreadInfo = { +id: string, +type: ThreadType, +name: ?string, +avatar?: AvatarDBContent, +description: ?string, +color: string, // hex, without "#" or "0x" +creationTime: number, // millisecond timestamp +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +depth: number, +members: $ReadOnlyArray, +roles: { +[id: string]: LegacyRoleInfo }, +sourceMessageID?: string, +repliesCount: number, +pinnedCount: number, }; export type ThreadStore = { +threadInfos: LegacyRawThreadInfos, }; export const threadStoreValidator: TInterface = tShape({ threadInfos: t.dict(tID, legacyRawThreadInfoValidator), }); export type ClientDBThreadInfo = { +id: string, +type: number, +name: ?string, +avatar?: ?string, +description: ?string, +color: string, +creationTime: string, +parentThreadID: ?string, +containingThreadID: ?string, +community: ?string, +members: string, +roles: string, +currentUser: string, +sourceMessageID?: string, +repliesCount: number, +pinnedCount?: number, }; export type ThreadDeletionRequest = { +threadID: string, +accountPassword?: empty, }; export type RemoveMembersRequest = { +threadID: string, +memberIDs: $ReadOnlyArray, }; export type RoleChangeRequest = { +threadID: string, +memberIDs: $ReadOnlyArray, +role: string, }; export type ChangeThreadSettingsResult = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, }; export type ChangeThreadSettingsPayload = { +threadID: string, +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, }; export type LeaveThreadRequest = { +threadID: string, }; export type LeaveThreadResult = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type LeaveThreadPayload = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type ThreadChanges = Partial<{ +type: ThreadType, +name: string, +description: string, +color: string, +parentThreadID: ?string, +newMemberIDs: $ReadOnlyArray, +avatar: UpdateUserAvatarRequest, }>; export type UpdateThreadRequest = { +threadID: string, +changes: ThreadChanges, +accountPassword?: empty, }; export type BaseNewThreadRequest = { +id?: ?string, +name?: ?string, +description?: ?string, +color?: ?string, +parentThreadID?: ?string, +initialMemberIDs?: ?$ReadOnlyArray, +ghostMemberIDs?: ?$ReadOnlyArray, }; type NewThreadRequest = | { +type: 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12, ...BaseNewThreadRequest, } | { +type: 5, +sourceMessageID: string, ...BaseNewThreadRequest, }; export type ClientNewThreadRequest = { ...NewThreadRequest, +calendarQuery: CalendarQuery, }; export type ServerNewThreadRequest = { ...NewThreadRequest, +calendarQuery?: ?CalendarQuery, }; export type NewThreadResponse = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, +userInfos: UserInfos, +newThreadID: string, }; export type NewThreadResult = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +newMessageInfos: $ReadOnlyArray, +userInfos: UserInfos, +newThreadID: string, }; export type ServerThreadJoinRequest = { +threadID: string, +calendarQuery?: ?CalendarQuery, +inviteLinkSecret?: string, }; export type ClientThreadJoinRequest = { +threadID: string, +calendarQuery: CalendarQuery, +inviteLinkSecret?: string, }; export type ThreadJoinResult = { +updatesResult: { +newUpdates: $ReadOnlyArray, }, +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +userInfos: UserInfos, }; export type ThreadJoinPayload = { +updatesResult: { newUpdates: $ReadOnlyArray, }, +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +userInfos: $ReadOnlyArray, }; export type ThreadFetchMediaResult = { +media: $ReadOnlyArray, }; export type ThreadFetchMediaRequest = { +threadID: string, +limit: number, +offset: number, }; export type SidebarInfo = { +threadInfo: LegacyThreadInfo, +lastUpdatedTime: number, +mostRecentNonLocalMessage: ?string, }; export type ToggleMessagePinRequest = { +messageID: string, +action: 'pin' | 'unpin', }; export type ToggleMessagePinResult = { +newMessageInfos: $ReadOnlyArray, +threadID: string, }; type CreateRoleAction = { +community: string, +name: string, +permissions: $ReadOnlyArray, +action: 'create_role', }; type EditRoleAction = { +community: string, +existingRoleID: string, +name: string, +permissions: $ReadOnlyArray, +action: 'edit_role', }; export type RoleModificationRequest = CreateRoleAction | EditRoleAction; export type RoleModificationResult = { +threadInfo: LegacyRawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type RoleModificationPayload = { +threadInfo: LegacyRawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type RoleDeletionRequest = { +community: string, +roleID: string, }; export type RoleDeletionResult = { +threadInfo: LegacyRawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; export type RoleDeletionPayload = { +threadInfo: LegacyRawThreadInfo, +updatesResult: { +newUpdates: $ReadOnlyArray, }, }; // We can show a max of 3 sidebars inline underneath their parent in the chat // tab. If there are more, we show a button that opens a modal to see the rest export const maxReadSidebars = 3; // We can show a max of 5 sidebars inline underneath their parent // in the chat tab if every one of the displayed sidebars is unread export const maxUnreadSidebars = 5; export type ThreadStoreThreadInfos = LegacyRawThreadInfos; export type ChatMentionCandidates = { +[id: string]: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, }; export type ChatMentionCandidatesObj = { +[id: string]: ChatMentionCandidates, }; export type UserProfileThreadInfo = { +threadInfo: LegacyThreadInfo, +pendingPersonalThreadUserInfo?: UserInfo, }; diff --git a/lib/utils/drawer-utils.react.js b/lib/utils/drawer-utils.react.js index ab053c4f0..ee1b2bb80 100644 --- a/lib/utils/drawer-utils.react.js +++ b/lib/utils/drawer-utils.react.js @@ -1,132 +1,124 @@ // @flow import { values } from './objects.js'; import { threadInFilterList, threadIsChannel } from '../shared/thread-utils.js'; -import type { - MinimallyEncodedResolvedThreadInfo, - MinimallyEncodedThreadInfo, -} from '../types/minimally-encoded-thread-permissions-types.js'; +import type { MinimallyEncodedResolvedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { communitySubthreads } from '../types/thread-types-enum.js'; import type { - LegacyThreadInfo, ResolvedThreadInfo, RawThreadInfo, + ThreadInfo, } from '../types/thread-types.js'; type WritableCommunityDrawerItemData = { - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: ThreadInfo, itemChildren: $ReadOnlyArray>, hasSubchannelsButton: boolean, labelStyle: T, }; export type CommunityDrawerItemData = $ReadOnly< WritableCommunityDrawerItemData, >; function createRecursiveDrawerItemsData( childThreadInfosMap: { - +[id: string]: $ReadOnlyArray< - LegacyThreadInfo | MinimallyEncodedThreadInfo, - >, + +[id: string]: $ReadOnlyArray, }, communities: $ReadOnlyArray< ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, >, 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: T, +function threadHasSubchannels( + threadInfo: ThreadInfo, childThreadInfosMap: { - +[id: string]: $ReadOnlyArray, + +[id: string]: $ReadOnlyArray, }, ): boolean { if (!childThreadInfosMap[threadInfo.id]?.length) { return false; } return childThreadInfosMap[threadInfo.id].some(thread => threadIsChannel(thread), ); } function appendSuffix< T: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, >(chats: $ReadOnlyArray): T[] { const result: T[] = []; const names = new Map(); for (const chat of chats) { 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; } function filterThreadIDsBelongingToCommunity( communityID: string, threadInfosObj: { - +[id: string]: - | LegacyThreadInfo - | RawThreadInfo - | MinimallyEncodedThreadInfo, + +[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, appendSuffix, filterThreadIDsBelongingToCommunity, }; diff --git a/lib/utils/entity-helpers.js b/lib/utils/entity-helpers.js index 58209a59d..8bb8c2074 100644 --- a/lib/utils/entity-helpers.js +++ b/lib/utils/entity-helpers.js @@ -1,147 +1,145 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { ET, useENSNamesForEntityText, entityTextToRawString, } from './entity-text.js'; import type { UseENSNamesOptions } from '../hooks/ens-cache.js'; -import type { - MinimallyEncodedResolvedThreadInfo, - MinimallyEncodedThreadInfo, -} from '../types/minimally-encoded-thread-permissions-types.js'; +import type { MinimallyEncodedResolvedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { LegacyThreadInfo, ResolvedThreadInfo, + ThreadInfo, } from '../types/thread-types.js'; import { values } from '../utils/objects.js'; function useResolvedThreadInfos( - threadInfos: $ReadOnlyArray, + threadInfos: $ReadOnlyArray, options?: ?UseENSNamesOptions, ): $ReadOnlyArray { const entityText = React.useMemo( () => threadInfos.map(threadInfo => threadInfo.uiName), [threadInfos], ); const withENSNames = useENSNamesForEntityText(entityText, options); invariant( withENSNames, 'useENSNamesForEntityText only returns falsey when passed falsey', ); return React.useMemo( () => threadInfos.map((threadInfo, i) => { if (typeof threadInfo.uiName === 'string') { // Flow wants return { ...threadInfo, uiName: threadInfo.uiName } // but that's wasteful and unneeded, so we any-cast here return (threadInfo: any); } const resolvedThreadEntity = withENSNames[i]; // Branching to appease `flow`. if (threadInfo.minimallyEncoded) { return { ...threadInfo, uiName: entityTextToRawString([resolvedThreadEntity]), }; } else { return { ...threadInfo, uiName: entityTextToRawString([resolvedThreadEntity]), }; } }), [threadInfos, withENSNames], ); } function useResolvedOptionalThreadInfos( threadInfos: ?$ReadOnlyArray, ): ?$ReadOnlyArray { const entityText = React.useMemo(() => { if (!threadInfos) { return null; } return threadInfos.map(threadInfo => ET.thread({ display: 'uiName', threadInfo }), ); }, [threadInfos]); const withENSNames = useENSNamesForEntityText(entityText); return React.useMemo(() => { if (!threadInfos) { return threadInfos; } invariant( withENSNames, 'useENSNamesForEntityText only returns falsey when passed falsey', ); return threadInfos.map((threadInfo, i) => { if (typeof threadInfo.uiName === 'string') { // Flow wants return { ...threadInfo, uiName: threadInfo.uiName } // but that's wasteful and unneeded, so we any-cast here return (threadInfo: any); } const resolvedThreadEntity = withENSNames[i]; return { ...threadInfo, uiName: entityTextToRawString([resolvedThreadEntity]), }; }); }, [threadInfos, withENSNames]); } function useResolvedThreadInfosObj( threadInfosObj: { +[id: string]: LegacyThreadInfo }, options?: ?UseENSNamesOptions, ): { +[id: string]: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo } { const threadInfosArray = React.useMemo( () => values(threadInfosObj), [threadInfosObj], ); const resolvedThreadInfosArray = useResolvedThreadInfos( threadInfosArray, options, ); return React.useMemo(() => { const obj: { [string]: ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo, } = {}; for (const resolvedThreadInfo of resolvedThreadInfosArray) { obj[resolvedThreadInfo.id] = resolvedThreadInfo; } return obj; }, [resolvedThreadInfosArray]); } function useResolvedThreadInfo( - threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: ThreadInfo, ): ResolvedThreadInfo | MinimallyEncodedResolvedThreadInfo { const resolutionInput = React.useMemo(() => [threadInfo], [threadInfo]); const [resolvedThreadInfo] = useResolvedThreadInfos(resolutionInput); return resolvedThreadInfo; } function useResolvedOptionalThreadInfo( - threadInfo: ?LegacyThreadInfo | ?MinimallyEncodedThreadInfo, + threadInfo: ?ThreadInfo, ): ?ResolvedThreadInfo | ?MinimallyEncodedResolvedThreadInfo { const resolutionInput = React.useMemo( () => (threadInfo ? [threadInfo] : []), [threadInfo], ); const [resolvedThreadInfo] = useResolvedThreadInfos(resolutionInput); if (!threadInfo) { return threadInfo; } return resolvedThreadInfo; } export { useResolvedThreadInfos, useResolvedOptionalThreadInfos, useResolvedThreadInfosObj, useResolvedThreadInfo, useResolvedOptionalThreadInfo, }; diff --git a/lib/utils/entity-text.js b/lib/utils/entity-text.js index 2375090ef..41a5a4dc7 100644 --- a/lib/utils/entity-text.js +++ b/lib/utils/entity-text.js @@ -1,599 +1,592 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import t, { type TInterface, type TUnion } from 'tcomb'; import type { GetENSNames } from './ens-helpers.js'; import { tID, tShape, tString } from './validation-utils.js'; import { useENSNames, type UseENSNamesOptions } from '../hooks/ens-cache.js'; import { threadNoun } from '../shared/thread-utils.js'; import { stringForUser } from '../shared/user-utils.js'; -import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { type ThreadType, threadTypes, threadTypeValidator, } from '../types/thread-types-enum.js'; -import type { LegacyThreadInfo, RawThreadInfo } from '../types/thread-types.js'; +import type { RawThreadInfo, ThreadInfo } from '../types/thread-types.js'; import { basePluralize } from '../utils/text-utils.js'; export type UserEntity = { +type: 'user', +id: string, +username?: ?string, +isViewer?: ?boolean, +possessive?: ?boolean, // eg. `user's` instead of `user` }; export const userEntityValidator: TInterface = tShape({ type: tString('user'), id: t.String, username: t.maybe(t.String), isViewer: t.maybe(t.Boolean), possessive: t.maybe(t.Boolean), }); // Comments explain how thread name will appear from user4's perspective export type ThreadEntity = | { +type: 'thread', +id: string, +name?: ?string, // displays threadInfo.name if set, or 'user1, user2, and user3' +display: 'uiName', // If uiName is EntityText, then at render time ThreadEntity will be // replaced with a pluralized list of uiName's UserEntities +uiName: $ReadOnlyArray | string, // If name isn't set and uiName is an array with only the viewer, then // just_you_string displays "just you" but viewer_username displays the // viewer's ENS-resolved username. Defaults to just_you_string +ifJustViewer?: 'just_you_string' | 'viewer_username', } | { +type: 'thread', +id: string, +name?: ?string, // displays threadInfo.name if set, or eg. 'this thread' or 'this chat' +display: 'shortName', +threadType?: ?ThreadType, +parentThreadID?: ?string, +alwaysDisplayShortName?: ?boolean, // don't default to name +subchannel?: ?boolean, // short name should be "subchannel" +possessive?: ?boolean, // eg. `this thread's` instead of `this thread` }; export const threadEntityValidator: TUnion = t.union([ tShape({ type: tString('thread'), id: tID, name: t.maybe(t.String), display: tString('uiName'), uiName: t.union([t.list(userEntityValidator), t.String]), ifJustViewer: t.maybe(t.enums.of(['just_you_string', 'viewer_username'])), }), tShape({ type: tString('thread'), id: tID, name: t.maybe(t.String), display: tString('shortName'), threadType: t.maybe(threadTypeValidator), parentThreadID: t.maybe(tID), alwaysDisplayShortName: t.maybe(t.Boolean), subchannel: t.maybe(t.Boolean), possessive: t.maybe(t.Boolean), }), ]); type ColorEntity = { +type: 'color', +hex: string, }; type EntityTextComponent = UserEntity | ThreadEntity | ColorEntity | string; export type EntityText = $ReadOnlyArray; const entityTextFunction = ( strings: $ReadOnlyArray, ...entities: $ReadOnlyArray ) => { const result: EntityTextComponent[] = []; for (let i = 0; i < strings.length; i++) { const str = strings[i]; if (str) { result.push(str); } const entity = entities[i]; if (!entity) { continue; } if (typeof entity === 'string') { const lastResult = result.length > 0 && result[result.length - 1]; if (typeof lastResult === 'string') { result[result.length - 1] = lastResult + entity; } else { result.push(entity); } } else if (Array.isArray(entity)) { const [firstEntity, ...restOfEntity] = entity; const lastResult = result.length > 0 && result[result.length - 1]; if (typeof lastResult === 'string' && typeof firstEntity === 'string') { result[result.length - 1] = lastResult + firstEntity; } else if (firstEntity) { result.push(firstEntity); } result.push(...restOfEntity); } else { result.push(entity); } } return result; }; // defaults to shortName type EntityTextThreadInput = | { +display: 'uiName', - +threadInfo: LegacyThreadInfo | MinimallyEncodedThreadInfo, + +threadInfo: ThreadInfo, } | { +display?: 'shortName', - +threadInfo: - | LegacyThreadInfo - | RawThreadInfo - | MinimallyEncodedThreadInfo, + +threadInfo: RawThreadInfo | ThreadInfo, +subchannel?: ?boolean, +possessive?: ?boolean, } | { +display: 'alwaysDisplayShortName', - +threadInfo: - | LegacyThreadInfo - | RawThreadInfo - | MinimallyEncodedThreadInfo, + +threadInfo: RawThreadInfo | ThreadInfo, +possessive?: ?boolean, } | { +display: 'alwaysDisplayShortName', +threadID: string, +parentThreadID?: ?string, +threadType?: ?ThreadType, +possessive?: ?boolean, }; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return entityTextFunction.thread = (input: EntityTextThreadInput) => { if (input.display === 'uiName') { const { threadInfo } = input; if (typeof threadInfo.uiName !== 'string') { return threadInfo.uiName; } return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'uiName', uiName: threadInfo.uiName, }; } if (input.display === 'alwaysDisplayShortName' && input.threadID) { const { threadID, threadType, parentThreadID, possessive } = input; return { type: 'thread', id: threadID, name: undefined, display: 'shortName', threadType, parentThreadID, alwaysDisplayShortName: true, possessive, }; } else if (input.display === 'alwaysDisplayShortName' && input.threadInfo) { const { threadInfo, possessive } = input; return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'shortName', threadType: threadInfo.type, parentThreadID: threadInfo.parentThreadID, alwaysDisplayShortName: true, possessive, }; } else if (input.display === 'shortName' || !input.display) { const { threadInfo, subchannel, possessive } = input; return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'shortName', threadType: threadInfo.type, parentThreadID: threadInfo.parentThreadID, subchannel, possessive, }; } invariant( false, `ET.thread passed unexpected display type: ${input.display}`, ); }; type EntityTextUserInput = { +userInfo: { +id: string, +username?: ?string, +isViewer?: ?boolean, ... }, +possessive?: ?boolean, }; entityTextFunction.user = (input: EntityTextUserInput) => ({ type: 'user', id: input.userInfo.id, username: input.userInfo.username, isViewer: input.userInfo.isViewer, possessive: input.possessive, }); type EntityTextColorInput = { +hex: string }; entityTextFunction.color = (input: EntityTextColorInput) => ({ type: 'color', hex: input.hex, }); // ET is a JS tag function used in template literals, eg. ET`something` // It allows you to compose raw text and "entities" together type EntityTextFunction = (( strings: $ReadOnlyArray, ...entities: $ReadOnlyArray ) => EntityText) & { +thread: EntityTextThreadInput => ThreadEntity, +user: EntityTextUserInput => UserEntity, +color: EntityTextColorInput => ColorEntity, ... }; const ET: EntityTextFunction = entityTextFunction; type MakePossessiveInput = { +str: string, +isViewer?: ?boolean }; function makePossessive(input: MakePossessiveInput) { if (input.isViewer) { return 'your'; } return `${input.str}’s`; } function getNameForThreadEntity( entity: ThreadEntity, params?: ?EntityTextToRawStringParams, ): string { const { name: userGeneratedName, display } = entity; if (entity.display === 'uiName') { if (userGeneratedName) { return userGeneratedName; } const { uiName } = entity; if (typeof uiName === 'string') { return uiName; } let userEntities = uiName; if (!params?.ignoreViewer) { const viewerFilteredUserEntities = userEntities.filter( innerEntity => !innerEntity.isViewer, ); if (viewerFilteredUserEntities.length > 0) { userEntities = viewerFilteredUserEntities; } else if (entity.ifJustViewer === 'viewer_username') { // We pass ignoreViewer to entityTextToRawString in order // to prevent it from rendering the viewer as "you" params = { ...params, ignoreViewer: true }; } else { return 'just you'; } } const pluralized = pluralizeEntityText( userEntities.map(innerEntity => [innerEntity]), ); return entityTextToRawString(pluralized, params); } invariant( entity.display === 'shortName', `getNameForThreadEntity can't handle thread entity display ${display}`, ); let { name } = entity; if (!name || entity.alwaysDisplayShortName) { const threadType = entity.threadType ?? threadTypes.PERSONAL; const { parentThreadID } = entity; const noun = entity.subchannel ? 'subchannel' : threadNoun(threadType, parentThreadID); if (entity.id === params?.threadID) { const prefixThisThreadNounWith = params?.prefixThisThreadNounWith === 'your' ? 'your' : 'this'; name = `${prefixThisThreadNounWith} ${noun}`; } else { name = `a ${noun}`; } } if (entity.possessive) { name = makePossessive({ str: name }); } return name; } function getNameForUserEntity( entity: UserEntity, ignoreViewer: ?boolean, ): string { const isViewer = entity.isViewer && !ignoreViewer; const entityWithIsViewerIgnored = { ...entity, isViewer }; const str = stringForUser(entityWithIsViewerIgnored); if (!entityWithIsViewerIgnored.possessive) { return str; } return makePossessive({ str, isViewer }); } type EntityTextToRawStringParams = { +threadID?: ?string, +ignoreViewer?: ?boolean, +prefixThisThreadNounWith?: ?('this' | 'your'), }; function entityTextToRawString( entityText: EntityText, params?: ?EntityTextToRawStringParams, ): string { // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return const textParts = entityText.map(entity => { if (typeof entity === 'string') { return entity; } else if (entity.type === 'thread') { return getNameForThreadEntity(entity, params); } else if (entity.type === 'color') { return entity.hex; } else if (entity.type === 'user') { return getNameForUserEntity(entity, params?.ignoreViewer); } else { invariant( false, `entityTextToRawString can't handle entity type ${entity.type}`, ); } }); return textParts.join(''); } type RenderFunctions = { +renderText: ({ +text: string }) => React.Node, +renderThread: ({ +id: string, +name: string }) => React.Node, +renderUser: ({ +userID: string, +usernameText: string }) => React.Node, +renderColor: ({ +hex: string }) => React.Node, }; function entityTextToReact( entityText: EntityText, threadID: string, renderFuncs: RenderFunctions, ): React.Node { const { renderText, renderThread, renderUser, renderColor } = renderFuncs; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return return entityText.map((entity, i) => { const key = `text${i}`; if (typeof entity === 'string') { return ( {renderText({ text: entity })} ); } else if (entity.type === 'thread') { const { id } = entity; const name = getNameForThreadEntity(entity, { threadID }); if (id === threadID) { return name; } else { return ( {renderThread({ id, name })} ); } } else if (entity.type === 'color') { return ( {renderColor({ hex: entity.hex })} ); } else if (entity.type === 'user') { const userID = entity.id; const usernameText = getNameForUserEntity(entity); return ( {renderUser({ userID, usernameText })} ); } else { invariant( false, `entityTextToReact can't handle entity type ${entity.type}`, ); } }); } function pluralizeEntityText( nouns: $ReadOnlyArray, maxNumberOfNouns: number = 3, ): EntityText { return basePluralize( nouns, maxNumberOfNouns, (a: EntityText | string, b: ?EntityText | string) => b ? ET`${a}${b}` : ET`${a}`, ); } type TextEntity = { +type: 'text', +text: string }; type ShadowUserEntity = { +type: 'shadowUser', +username: string, +originalUsername: string, }; type EntityTextComponentAsObject = | UserEntity | ThreadEntity | ColorEntity | TextEntity | ShadowUserEntity; function entityTextToObjects( entityText: EntityText, ): EntityTextComponentAsObject[] { const objs: EntityTextComponentAsObject[] = []; for (const entity of entityText) { if (typeof entity === 'string') { objs.push({ type: 'text', text: entity }); continue; } objs.push(entity); if ( entity.type === 'thread' && entity.display === 'uiName' && typeof entity.uiName !== 'string' ) { for (const innerEntity of entity.uiName) { if (typeof innerEntity === 'string' || innerEntity.type !== 'user') { continue; } const { username } = innerEntity; if (username) { objs.push({ type: 'shadowUser', originalUsername: username, username, }); } } } } return objs; } function entityTextFromObjects( objects: $ReadOnlyArray, ): EntityText { const shadowUserMap = new Map(); for (const obj of objects) { if (obj.type === 'shadowUser' && obj.username !== obj.originalUsername) { shadowUserMap.set(obj.originalUsername, obj.username); } } return objects .map(entity => { if (entity.type === 'text') { return entity.text; } else if (entity.type === 'shadowUser') { return null; } else if ( entity.type === 'thread' && entity.display === 'uiName' && typeof entity.uiName !== 'string' ) { const uiName: UserEntity[] = []; let changeOccurred = false; for (const innerEntity of entity.uiName) { if (typeof innerEntity === 'string' || innerEntity.type !== 'user') { uiName.push(innerEntity); continue; } const { username } = innerEntity; if (!username) { uiName.push(innerEntity); continue; } const ensName = shadowUserMap.get(username); if (!ensName) { uiName.push(innerEntity); continue; } changeOccurred = true; uiName.push({ ...innerEntity, username: ensName, }); } if (!changeOccurred) { return entity; } return { ...entity, uiName, }; } else { return entity; } }) .filter(Boolean); } function useENSNamesForEntityText( entityText: ?EntityText, options?: ?UseENSNamesOptions, ): ?EntityText { const allObjects = React.useMemo( () => (entityText ? entityTextToObjects(entityText) : []), [entityText], ); const objectsWithENSNames = useENSNames(allObjects, options); return React.useMemo( () => entityText ? entityTextFromObjects(objectsWithENSNames) : entityText, [entityText, objectsWithENSNames], ); } function useEntityTextAsString( entityText: ?EntityText, params?: EntityTextToRawStringParams, ): ?string { const withENSNames = useENSNamesForEntityText(entityText); return React.useMemo(() => { if (!withENSNames) { return withENSNames; } return entityTextToRawString(withENSNames, params); }, [withENSNames, params]); } async function getEntityTextAsString( entityText: ?EntityText, getENSNames: ?GetENSNames, params?: EntityTextToRawStringParams, ): Promise { if (!entityText) { return entityText; } let resolvedEntityText = entityText; if (getENSNames) { const allObjects = entityTextToObjects(entityText); const objectsWithENSNames = await getENSNames(allObjects); resolvedEntityText = entityTextFromObjects(objectsWithENSNames); } return entityTextToRawString(resolvedEntityText, params); } export { ET, entityTextToRawString, entityTextToReact, pluralizeEntityText, useENSNamesForEntityText, useEntityTextAsString, getEntityTextAsString, }; diff --git a/lib/utils/role-utils.js b/lib/utils/role-utils.js index 283dc5fad..810fbb188 100644 --- a/lib/utils/role-utils.js +++ b/lib/utils/role-utils.js @@ -1,128 +1,127 @@ // @flow import * as React from 'react'; import { useSelector } from './redux-utils.js'; import { threadInfoSelector } from '../selectors/thread-selectors.js'; -import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { configurableCommunityPermissions, type ThreadRolePermissionsBlob, type UserSurfacedPermission, } from '../types/thread-permission-types.js'; import type { - LegacyThreadInfo, RoleInfo, RelativeMemberInfo, + ThreadInfo, } 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 | MinimallyEncodedThreadInfo, + 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, }; diff --git a/lib/utils/toggle-pin-utils.js b/lib/utils/toggle-pin-utils.js index 79c142191..417105788 100644 --- a/lib/utils/toggle-pin-utils.js +++ b/lib/utils/toggle-pin-utils.js @@ -1,23 +1,22 @@ // @flow import { isInvalidPinSourceForThread } from '../shared/message-utils.js'; import { threadHasPermission } from '../shared/thread-utils.js'; import type { RawMessageInfo, MessageInfo } from '../types/message-types.js'; -import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; -import type { RawThreadInfo, LegacyThreadInfo } from '../types/thread-types.js'; +import type { RawThreadInfo, ThreadInfo } from '../types/thread-types.js'; function canToggleMessagePin( messageInfo: RawMessageInfo | MessageInfo, - threadInfo: RawThreadInfo | LegacyThreadInfo | MinimallyEncodedThreadInfo, + threadInfo: RawThreadInfo | ThreadInfo, ): boolean { const isValidMessage = !isInvalidPinSourceForThread(messageInfo, threadInfo); const hasManagePinsPermission = threadHasPermission( threadInfo, threadPermissions.MANAGE_PINS, ); return isValidMessage && hasManagePinsPermission; } export { canToggleMessagePin };