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 };