diff --git a/lib/components/chat-mention-provider.react.js b/lib/components/chat-mention-provider.react.js
index 2daededbd..2bf89c110 100644
--- a/lib/components/chat-mention-provider.react.js
+++ b/lib/components/chat-mention-provider.react.js
@@ -1,236 +1,237 @@
// @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 { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js';
import { threadTypes } from '../types/thread-types-enum.js';
import type {
ChatMentionCandidates,
ChatMentionCandidatesObj,
ResolvedThreadInfo,
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: ThreadInfo,
+ threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
) => 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: ThreadInfo) => {
+ (threadInfo: ThreadInfo | MinimallyEncodedThreadInfo) => {
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,
}): {
chatMentionCandidatesObj: ChatMentionCandidatesObj,
communityThreadIDForGenesisThreads: { +[id: string]: string },
} {
const result = {};
const visitedGenesisThreads = new Set();
const communityThreadIDForGenesisThreads = {};
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] = {
[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 = {};
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 d33a09856..44c629235 100644
--- a/lib/hooks/chat-mention-hooks.js
+++ b/lib/hooks/chat-mention-hooks.js
@@ -1,47 +1,48 @@
// @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,
ThreadInfo,
} from '../types/thread-types.js';
function useChatMentionContext(): ChatMentionContextType {
const context = React.useContext(ChatMentionContext);
invariant(context, 'ChatMentionContext not found');
return context;
}
function useThreadChatMentionCandidates(
- threadInfo: ThreadInfo,
+ threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
): 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 7bbcbbfda..464c823f2 100644
--- a/lib/hooks/child-threads.js
+++ b/lib/hooks/child-threads.js
@@ -1,116 +1,117 @@
// @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 { ThreadInfo } from '../types/thread-types.js';
import { useDispatchActionPromise } from '../utils/action-utils.js';
import { useSelector } from '../utils/redux-utils.js';
type ThreadFilter = {
- +predicate?: (thread: ThreadInfo) => boolean,
+ +predicate?: (thread: ThreadInfo | MinimallyEncodedThreadInfo) => 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 => subchannelIDs.has(thread?.id),
[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 c38316ce1..d27f16f58 100644
--- a/lib/hooks/promote-sidebar.react.js
+++ b/lib/hooks/promote-sidebar.react.js
@@ -1,94 +1,96 @@
// @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 ThreadInfo } from '../types/thread-types.js';
import { useDispatchActionPromise } from '../utils/action-utils.js';
import { useSelector } from '../utils/redux-utils.js';
function canPromoteSidebar(
- sidebarThreadInfo: ThreadInfo,
- parentThreadInfo: ?ThreadInfo,
+ sidebarThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
+ parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo,
): 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: ThreadInfo,
+ threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
onError?: () => mixed,
): PromoteSidebarType {
const dispatchActionPromise = useDispatchActionPromise();
const callChangeThreadSettings = useChangeThreadSettings();
const loadingStatusSelector = createLoadingStatusSelector(
changeThreadSettingsActionTypes,
);
const loadingStatus = useSelector(loadingStatusSelector);
const { parentThreadID } = threadInfo;
- const parentThreadInfo: ?ThreadInfo = useSelector(state =>
- parentThreadID ? threadInfoSelector(state)[parentThreadID] : null,
- );
+ const parentThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo =
+ 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 6fc04810a..b34db2883 100644
--- a/lib/hooks/relationship-prompt.js
+++ b/lib/hooks/relationship-prompt.js
@@ -1,127 +1,128 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { useSelector } from 'react-redux';
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 { ThreadInfo } from '../types/thread-types.js';
import type { UserInfo } from '../types/user-types.js';
import {
useDispatchActionPromise,
useServerCall,
} from '../utils/action-utils.js';
type RelationshipCallbacks = {
+blockUser: () => void,
+unblockUser: () => void,
+friendUser: () => void,
+unfriendUser: () => void,
};
type RelationshipPromptData = {
+otherUserInfo: ?UserInfo,
+callbacks: RelationshipCallbacks,
};
function useRelationshipPrompt(
- threadInfo: ThreadInfo,
+ threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
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 9540eaef4..b35ab148d 100644
--- a/lib/hooks/search-threads.js
+++ b/lib/hooks/search-threads.js
@@ -1,114 +1,124 @@
// @flow
import * as React from 'react';
import {
type ChatThreadItem,
useFilteredChatListData,
} from '../selectors/chat-selectors.js';
import { useThreadSearchIndex } from '../selectors/nav-selectors.js';
import { sidebarInfoSelector } from '../selectors/thread-selectors.js';
import { threadIsChannel } from '../shared/thread-utils.js';
import type { SetState } from '../types/hook-types.js';
+import type {
+ MinimallyEncodedRawThreadInfo,
+ MinimallyEncodedThreadInfo,
+} from '../types/minimally-encoded-thread-permissions-types.js';
import type {
SidebarInfo,
ThreadInfo,
RawThreadInfo,
} from '../types/thread-types.js';
import { useSelector } from '../utils/redux-utils.js';
export type ThreadSearchState = {
+text: string,
+results: $ReadOnlySet,
};
type SearchThreadsResult = {
+listData: $ReadOnlyArray,
+searchState: ThreadSearchState,
+setSearchState: SetState,
+onChangeSearchInputText: (text: string) => mixed,
+clearQuery: (event: SyntheticEvent) => void,
};
function useSearchThreads(
- threadInfo: ThreadInfo,
+ threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
childThreadInfos: $ReadOnlyArray,
): SearchThreadsResult {
const [searchState, setSearchState] = React.useState({
text: '',
results: new Set(),
});
const listData = React.useMemo(() => {
if (!searchState.text) {
return childThreadInfos;
}
return childThreadInfos.filter(thread =>
searchState.results.has(thread.threadInfo.id),
);
}, [childThreadInfos, searchState]);
const justThreadInfos = React.useMemo(
() => childThreadInfos.map(childThreadInfo => childThreadInfo.threadInfo),
[childThreadInfos],
);
const searchIndex = useThreadSearchIndex(justThreadInfos);
const onChangeSearchInputText = React.useCallback(
(text: string) => {
setSearchState({
text,
results: new Set(searchIndex.getSearchResults(text)),
});
},
[searchIndex, setSearchState],
);
const clearQuery = React.useCallback(
(event: SyntheticEvent) => {
event.preventDefault();
setSearchState({ text: '', results: new Set() });
},
[setSearchState],
);
return React.useMemo(
() => ({
listData,
searchState,
setSearchState,
onChangeSearchInputText,
clearQuery,
}),
[
listData,
setSearchState,
searchState,
onChangeSearchInputText,
clearQuery,
],
);
}
function useSearchSidebars(
- threadInfo: ThreadInfo,
+ threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
): SearchThreadsResult {
const childThreadInfos = useSelector(
state => sidebarInfoSelector(state)[threadInfo.id] ?? [],
);
return useSearchThreads(threadInfo, childThreadInfos);
}
function useSearchSubchannels(
- threadInfo: ThreadInfo,
+ threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
): SearchThreadsResult {
const filterFunc = React.useCallback(
- (thread: ?(ThreadInfo | RawThreadInfo)) =>
- threadIsChannel(thread) && thread?.parentThreadID === threadInfo.id,
+ (
+ thread: ?(
+ | ThreadInfo
+ | RawThreadInfo
+ | MinimallyEncodedThreadInfo
+ | MinimallyEncodedRawThreadInfo
+ ),
+ ) => 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 dad5cf202..3adfeda3e 100644
--- a/lib/hooks/toggle-unread-status.js
+++ b/lib/hooks/toggle-unread-status.js
@@ -1,54 +1,55 @@
// @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 { ThreadInfo } from '../types/thread-types.js';
import { useDispatchActionPromise } from '../utils/action-utils.js';
function useToggleUnreadStatus(
- threadInfo: ThreadInfo,
+ threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
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/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js
index e1e6ad848..201a15bfc 100644
--- a/lib/selectors/chat-selectors.js
+++ b/lib/selectors/chat-selectors.js
@@ -1,699 +1,717 @@
// @flow
import invariant from 'invariant';
import _filter from 'lodash/fp/filter.js';
import _flow from 'lodash/fp/flow.js';
import _map from 'lodash/fp/map.js';
import _orderBy from 'lodash/fp/orderBy.js';
import * as React from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { createObjectSelector } from 'reselect-map';
import {
threadInfoSelector,
sidebarInfoSelector,
threadInfoFromSourceMessageIDSelector,
} from './thread-selectors.js';
import {
messageKey,
robotextForMessageInfo,
createMessageInfo,
getMostRecentNonLocalMessageID,
sortMessageInfoList,
} from '../shared/message-utils.js';
import {
threadIsPending,
threadIsTopLevel,
threadInChatList,
} from '../shared/thread-utils.js';
import { messageTypes } from '../types/message-types-enum.js';
import {
type MessageInfo,
type MessageStore,
type ComposableMessageInfo,
type RobotextMessageInfo,
type LocalMessageInfo,
isComposableMessageType,
} from '../types/message-types.js';
+import type {
+ MinimallyEncodedRawThreadInfo,
+ MinimallyEncodedThreadInfo,
+} from '../types/minimally-encoded-thread-permissions-types.js';
import type { BaseAppState } from '../types/redux-types.js';
import { threadTypes } from '../types/thread-types-enum.js';
import {
type ThreadInfo,
type RawThreadInfo,
type SidebarInfo,
maxReadSidebars,
maxUnreadSidebars,
} from '../types/thread-types.js';
import type {
UserInfo,
AccountUserInfo,
RelativeUserInfo,
} from '../types/user-types.js';
import { threeDays } from '../utils/date-utils.js';
import type { EntityText } from '../utils/entity-text.js';
import memoize2 from '../utils/memoize.js';
export type SidebarItem =
| {
...SidebarInfo,
+type: 'sidebar',
}
| {
+type: 'seeMore',
+unread: boolean,
}
| { +type: 'spacer' };
export type ChatThreadItem = {
+type: 'chatThreadItem',
+threadInfo: ThreadInfo,
+mostRecentMessageInfo: ?MessageInfo,
+mostRecentNonLocalMessage: ?string,
+lastUpdatedTime: number,
+lastUpdatedTimeIncludingSidebars: number,
+sidebars: $ReadOnlyArray,
+pendingPersonalThreadUserInfo?: UserInfo,
};
const messageInfoSelector: (state: BaseAppState<>) => {
+[id: string]: ?MessageInfo,
} = createObjectSelector(
(state: BaseAppState<>) => state.messageStore.messages,
(state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id,
(state: BaseAppState<>) => state.userStore.userInfos,
threadInfoSelector,
createMessageInfo,
);
function isEmptyMediaMessage(messageInfo: MessageInfo): boolean {
return (
(messageInfo.type === messageTypes.MULTIMEDIA ||
messageInfo.type === messageTypes.IMAGES) &&
messageInfo.media.length === 0
);
}
function getMostRecentMessageInfo(
- threadInfo: ThreadInfo,
+ threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
messageStore: MessageStore,
messages: { +[id: string]: ?MessageInfo },
): ?MessageInfo {
const thread = messageStore.threads[threadInfo.id];
if (!thread) {
return null;
}
for (const messageID of thread.messageIDs) {
const messageInfo = messages[messageID];
if (!messageInfo || isEmptyMediaMessage(messageInfo)) {
continue;
}
return messageInfo;
}
return null;
}
function getLastUpdatedTime(
- threadInfo: ThreadInfo,
+ threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
mostRecentMessageInfo: ?MessageInfo,
): number {
return mostRecentMessageInfo
? mostRecentMessageInfo.time
: threadInfo.creationTime;
}
function createChatThreadItem(
threadInfo: ThreadInfo,
messageStore: MessageStore,
messages: { +[id: string]: ?MessageInfo },
sidebarInfos: ?$ReadOnlyArray,
): ChatThreadItem {
const mostRecentMessageInfo = getMostRecentMessageInfo(
threadInfo,
messageStore,
messages,
);
const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID(
threadInfo.id,
messageStore,
);
const lastUpdatedTime = getLastUpdatedTime(threadInfo, mostRecentMessageInfo);
const sidebars = sidebarInfos ?? [];
const allSidebarItems = sidebars.map(sidebarInfo => ({
type: 'sidebar',
...sidebarInfo,
}));
const lastUpdatedTimeIncludingSidebars =
allSidebarItems.length > 0
? Math.max(lastUpdatedTime, allSidebarItems[0].lastUpdatedTime)
: lastUpdatedTime;
const numUnreadSidebars = allSidebarItems.filter(
sidebar => sidebar.threadInfo.currentUser.unread,
).length;
let numReadSidebarsToShow = maxReadSidebars - numUnreadSidebars;
const threeDaysAgo = Date.now() - threeDays;
const sidebarItems = [];
for (const sidebar of allSidebarItems) {
if (sidebarItems.length >= maxUnreadSidebars) {
break;
} else if (sidebar.threadInfo.currentUser.unread) {
sidebarItems.push(sidebar);
} else if (
sidebar.lastUpdatedTime > threeDaysAgo &&
numReadSidebarsToShow > 0
) {
sidebarItems.push(sidebar);
numReadSidebarsToShow--;
}
}
const numReadButRecentSidebars = allSidebarItems.filter(
sidebar =>
!sidebar.threadInfo.currentUser.unread &&
sidebar.lastUpdatedTime > threeDaysAgo,
).length;
if (
sidebarItems.length < numUnreadSidebars + numReadButRecentSidebars ||
(sidebarItems.length < allSidebarItems.length && sidebarItems.length > 0)
) {
sidebarItems.push({
type: 'seeMore',
unread: numUnreadSidebars > maxUnreadSidebars,
});
}
if (sidebarItems.length !== 0) {
sidebarItems.push({
type: 'spacer',
});
}
return {
type: 'chatThreadItem',
threadInfo,
mostRecentMessageInfo,
mostRecentNonLocalMessage,
lastUpdatedTime,
lastUpdatedTimeIncludingSidebars,
sidebars: sidebarItems,
};
}
const chatListData: (state: BaseAppState<>) => $ReadOnlyArray =
createSelector(
threadInfoSelector,
(state: BaseAppState<>) => state.messageStore,
messageInfoSelector,
sidebarInfoSelector,
(
threadInfos: { +[id: string]: ThreadInfo },
messageStore: MessageStore,
messageInfos: { +[id: string]: ?MessageInfo },
sidebarInfos: { +[id: string]: $ReadOnlyArray },
): $ReadOnlyArray =>
getChatThreadItems(
threadInfos,
messageStore,
messageInfos,
sidebarInfos,
threadIsTopLevel,
),
);
function useFlattenedChatListData(): $ReadOnlyArray {
return useFilteredChatListData(threadInChatList);
}
function useFilteredChatListData(
- filterFunction: (threadInfo: ?(ThreadInfo | RawThreadInfo)) => boolean,
+ filterFunction: (
+ threadInfo: ?(
+ | ThreadInfo
+ | RawThreadInfo
+ | MinimallyEncodedThreadInfo
+ | MinimallyEncodedRawThreadInfo
+ ),
+ ) => boolean,
): $ReadOnlyArray {
const threadInfos = useSelector(threadInfoSelector);
const messageInfos = useSelector(messageInfoSelector);
const sidebarInfos = useSelector(sidebarInfoSelector);
const messageStore = useSelector(state => state.messageStore);
return React.useMemo(
() =>
getChatThreadItems(
threadInfos,
messageStore,
messageInfos,
sidebarInfos,
filterFunction,
),
[messageInfos, messageStore, sidebarInfos, filterFunction, threadInfos],
);
}
function getChatThreadItems(
threadInfos: { +[id: string]: ThreadInfo },
messageStore: MessageStore,
messageInfos: { +[id: string]: ?MessageInfo },
sidebarInfos: { +[id: string]: $ReadOnlyArray },
- filterFunction: (threadInfo: ?(ThreadInfo | RawThreadInfo)) => boolean,
+ filterFunction: (
+ threadInfo: ?(
+ | ThreadInfo
+ | RawThreadInfo
+ | MinimallyEncodedThreadInfo
+ | MinimallyEncodedRawThreadInfo
+ ),
+ ) => boolean,
): $ReadOnlyArray {
return _flow(
_filter(filterFunction),
_map((threadInfo: ThreadInfo): ChatThreadItem =>
createChatThreadItem(
threadInfo,
messageStore,
messageInfos,
sidebarInfos[threadInfo.id],
),
),
_orderBy('lastUpdatedTimeIncludingSidebars')('desc'),
)(threadInfos);
}
export type RobotextChatMessageInfoItem = {
+itemType: 'message',
+messageInfoType: 'robotext',
+messageInfo: RobotextMessageInfo,
+startsConversation: boolean,
+startsCluster: boolean,
endsCluster: boolean,
+robotext: EntityText,
+threadCreatedFromMessage: ?ThreadInfo,
+reactions: ReactionInfo,
};
export type ChatMessageInfoItem =
| RobotextChatMessageInfoItem
| {
+itemType: 'message',
+messageInfoType: 'composable',
+messageInfo: ComposableMessageInfo,
+localMessageInfo: ?LocalMessageInfo,
+startsConversation: boolean,
+startsCluster: boolean,
endsCluster: boolean,
+threadCreatedFromMessage: ?ThreadInfo,
+reactions: ReactionInfo,
+hasBeenEdited: boolean,
+isPinned: boolean,
};
export type ChatMessageItem = { itemType: 'loader' } | ChatMessageInfoItem;
export type ReactionInfo = { +[reaction: string]: MessageReactionInfo };
type MessageReactionInfo = {
+viewerReacted: boolean,
+users: $ReadOnlyArray,
};
type TargetMessageReactions = Map>;
const msInFiveMinutes = 5 * 60 * 1000;
function createChatMessageItems(
threadID: string,
messageStore: MessageStore,
messageInfos: { +[id: string]: ?MessageInfo },
threadInfos: { +[id: string]: ThreadInfo },
threadInfoFromSourceMessageID: { +[id: string]: ThreadInfo },
additionalMessages: $ReadOnlyArray,
viewerID: string,
): ChatMessageItem[] {
const thread = messageStore.threads[threadID];
const threadMessageInfos = (thread?.messageIDs ?? [])
.map((messageID: string) => messageInfos[messageID])
.filter(Boolean);
const messages =
additionalMessages.length > 0
? sortMessageInfoList([...threadMessageInfos, ...additionalMessages])
: threadMessageInfos;
const targetMessageReactionsMap = new Map();
// We need to iterate backwards to put the order of messages in chronological
// order, starting with the oldest. This avoids the scenario where the most
// recent message with the remove_reaction action may try to remove a user
// that hasn't been added to the messageReactionUsersInfoMap, causing it
// to be skipped.
for (let i = messages.length - 1; i >= 0; i--) {
const messageInfo = messages[i];
if (messageInfo.type !== messageTypes.REACTION) {
continue;
}
if (!targetMessageReactionsMap.has(messageInfo.targetMessageID)) {
const reactsMap: TargetMessageReactions = new Map();
targetMessageReactionsMap.set(messageInfo.targetMessageID, reactsMap);
}
const messageReactsMap = targetMessageReactionsMap.get(
messageInfo.targetMessageID,
);
invariant(messageReactsMap, 'messageReactsInfo should be set');
if (!messageReactsMap.has(messageInfo.reaction)) {
const usersInfoMap = new Map();
messageReactsMap.set(messageInfo.reaction, usersInfoMap);
}
const messageReactionUsersInfoMap = messageReactsMap.get(
messageInfo.reaction,
);
invariant(
messageReactionUsersInfoMap,
'messageReactionUsersInfoMap should be set',
);
if (messageInfo.action === 'add_reaction') {
messageReactionUsersInfoMap.set(
messageInfo.creator.id,
messageInfo.creator,
);
} else {
messageReactionUsersInfoMap.delete(messageInfo.creator.id);
}
}
const targetMessageEditMap = new Map();
for (let i = messages.length - 1; i >= 0; i--) {
const messageInfo = messages[i];
if (messageInfo.type !== messageTypes.EDIT_MESSAGE) {
continue;
}
targetMessageEditMap.set(messageInfo.targetMessageID, messageInfo.text);
}
const targetMessagePinStatusMap = new Map();
// Once again, we iterate backwards to put the order of messages in
// chronological order (i.e. oldest to newest) to handle pinned messages.
// This is important because we want to make sure that the most recent pin
// action is the one that is used to determine whether a message
// is pinned or not.
for (let i = messages.length - 1; i >= 0; i--) {
const messageInfo = messages[i];
if (messageInfo.type !== messageTypes.TOGGLE_PIN) {
continue;
}
targetMessagePinStatusMap.set(
messageInfo.targetMessageID,
messageInfo.action === 'pin',
);
}
const chatMessageItems = [];
let lastMessageInfo = null;
for (let i = messages.length - 1; i >= 0; i--) {
const messageInfo = messages[i];
if (
messageInfo.type === messageTypes.REACTION ||
messageInfo.type === messageTypes.EDIT_MESSAGE
) {
continue;
}
let originalMessageInfo =
messageInfo.type === messageTypes.SIDEBAR_SOURCE
? messageInfo.sourceMessage
: messageInfo;
if (isEmptyMediaMessage(originalMessageInfo)) {
continue;
}
let hasBeenEdited = false;
if (
originalMessageInfo.type === messageTypes.TEXT &&
originalMessageInfo.id
) {
const newText = targetMessageEditMap.get(originalMessageInfo.id);
if (newText !== undefined) {
hasBeenEdited = true;
originalMessageInfo = {
...originalMessageInfo,
text: newText,
};
}
}
let startsConversation = true;
let startsCluster = true;
if (
lastMessageInfo &&
lastMessageInfo.time + msInFiveMinutes > originalMessageInfo.time
) {
startsConversation = false;
if (
isComposableMessageType(lastMessageInfo.type) &&
isComposableMessageType(originalMessageInfo.type) &&
lastMessageInfo.creator.id === originalMessageInfo.creator.id
) {
startsCluster = false;
}
}
if (startsCluster && chatMessageItems.length > 0) {
const lastMessageItem = chatMessageItems[chatMessageItems.length - 1];
invariant(lastMessageItem.itemType === 'message', 'should be message');
lastMessageItem.endsCluster = true;
}
const threadCreatedFromMessage =
messageInfo.id && threadInfos[threadID]?.type !== threadTypes.SIDEBAR
? threadInfoFromSourceMessageID[messageInfo.id]
: undefined;
const isPinned = !!(
originalMessageInfo.id &&
targetMessagePinStatusMap.get(originalMessageInfo.id)
);
const renderedReactions: ReactionInfo = (() => {
const result = {};
let messageReactsMap;
if (originalMessageInfo.id) {
messageReactsMap = targetMessageReactionsMap.get(
originalMessageInfo.id,
);
}
if (!messageReactsMap) {
return result;
}
for (const reaction of messageReactsMap.keys()) {
const reactionUsersInfoMap = messageReactsMap.get(reaction);
invariant(reactionUsersInfoMap, 'reactionUsersInfoMap should be set');
if (reactionUsersInfoMap.size === 0) {
continue;
}
const reactionUserInfos = [...reactionUsersInfoMap.values()];
const messageReactionInfo = {
users: reactionUserInfos,
viewerReacted: reactionUsersInfoMap.has(viewerID),
};
result[reaction] = messageReactionInfo;
}
return result;
})();
if (isComposableMessageType(originalMessageInfo.type)) {
// We use these invariants instead of just checking the messageInfo.type
// directly in the conditional above so that isComposableMessageType can
// be the source of truth
invariant(
originalMessageInfo.type === messageTypes.TEXT ||
originalMessageInfo.type === messageTypes.IMAGES ||
originalMessageInfo.type === messageTypes.MULTIMEDIA,
"Flow doesn't understand isComposableMessageType above",
);
const localMessageInfo =
messageStore.local[messageKey(originalMessageInfo)];
chatMessageItems.push({
itemType: 'message',
messageInfoType: 'composable',
messageInfo: originalMessageInfo,
localMessageInfo,
startsConversation,
startsCluster,
endsCluster: false,
threadCreatedFromMessage,
reactions: renderedReactions,
hasBeenEdited,
isPinned,
});
} else {
invariant(
originalMessageInfo.type !== messageTypes.TEXT &&
originalMessageInfo.type !== messageTypes.IMAGES &&
originalMessageInfo.type !== messageTypes.MULTIMEDIA,
"Flow doesn't understand isComposableMessageType above",
);
const threadInfo = threadInfos[threadID];
const parentThreadInfo = threadInfo?.parentThreadID
? threadInfos[threadInfo.parentThreadID]
: null;
const robotext = robotextForMessageInfo(
originalMessageInfo,
threadInfo,
parentThreadInfo,
);
chatMessageItems.push({
itemType: 'message',
messageInfoType: 'robotext',
messageInfo: originalMessageInfo,
startsConversation,
startsCluster,
endsCluster: false,
threadCreatedFromMessage,
robotext,
reactions: renderedReactions,
});
}
lastMessageInfo = originalMessageInfo;
}
if (chatMessageItems.length > 0) {
const lastMessageItem = chatMessageItems[chatMessageItems.length - 1];
invariant(lastMessageItem.itemType === 'message', 'should be message');
lastMessageItem.endsCluster = true;
}
chatMessageItems.reverse();
const hideSpinner = thread ? thread.startReached : threadIsPending(threadID);
if (hideSpinner) {
return chatMessageItems;
}
return [...chatMessageItems, ({ itemType: 'loader' }: ChatMessageItem)];
}
const baseMessageListData = (
threadID: ?string,
additionalMessages: $ReadOnlyArray,
) =>
createSelector(
(state: BaseAppState<>) => state.messageStore,
messageInfoSelector,
threadInfoSelector,
threadInfoFromSourceMessageIDSelector,
(state: BaseAppState<>) =>
state.currentUserInfo && state.currentUserInfo.id,
(
messageStore: MessageStore,
messageInfos: { +[id: string]: ?MessageInfo },
threadInfos: { +[id: string]: ThreadInfo },
threadInfoFromSourceMessageID: { +[id: string]: ThreadInfo },
viewerID: ?string,
): ?(ChatMessageItem[]) => {
if (!threadID || !viewerID) {
return null;
}
return createChatMessageItems(
threadID,
messageStore,
messageInfos,
threadInfos,
threadInfoFromSourceMessageID,
additionalMessages,
viewerID,
);
},
);
export type MessageListData = ?(ChatMessageItem[]);
const messageListData: (
threadID: ?string,
additionalMessages: $ReadOnlyArray,
) => (state: BaseAppState<>) => MessageListData = memoize2(baseMessageListData);
export type UseMessageListDataArgs = {
+searching: boolean,
+userInfoInputArray: $ReadOnlyArray,
- +threadInfo: ?ThreadInfo,
+ +threadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo,
};
function useMessageListData({
searching,
userInfoInputArray,
threadInfo,
}: UseMessageListDataArgs): MessageListData {
const messageInfos = useSelector(messageInfoSelector);
const containingThread = useSelector(state => {
if (!threadInfo || threadInfo.type !== threadTypes.SIDEBAR) {
return null;
}
return state.messageStore.threads[threadInfo.containingThreadID];
});
const pendingSidebarEditMessageInfo = React.useMemo(() => {
const sourceMessageID = threadInfo?.sourceMessageID;
const threadMessageInfos = (containingThread?.messageIDs ?? [])
.map((messageID: string) => messageInfos[messageID])
.filter(Boolean)
.filter(
message =>
message.type === messageTypes.EDIT_MESSAGE &&
message.targetMessageID === sourceMessageID,
);
if (threadMessageInfos.length === 0) {
return null;
}
return threadMessageInfos[0];
}, [threadInfo, containingThread, messageInfos]);
const pendingSidebarSourceMessageInfo = useSelector(state => {
const sourceMessageID = threadInfo?.sourceMessageID;
if (
!threadInfo ||
threadInfo.type !== threadTypes.SIDEBAR ||
!sourceMessageID
) {
return null;
}
const thread = state.messageStore.threads[threadInfo.id];
const shouldSourceBeAdded =
!thread ||
(thread.startReached &&
thread.messageIDs.every(
id => messageInfos[id]?.type !== messageTypes.SIDEBAR_SOURCE,
));
return shouldSourceBeAdded ? messageInfos[sourceMessageID] : null;
});
invariant(
!pendingSidebarSourceMessageInfo ||
pendingSidebarSourceMessageInfo.type !== messageTypes.SIDEBAR_SOURCE,
'sidebars can not be created from sidebar_source message',
);
const additionalMessages = React.useMemo(() => {
if (!pendingSidebarSourceMessageInfo) {
return [];
}
const result = [pendingSidebarSourceMessageInfo];
if (pendingSidebarEditMessageInfo) {
result.push(pendingSidebarEditMessageInfo);
}
return result;
}, [pendingSidebarSourceMessageInfo, pendingSidebarEditMessageInfo]);
const boundMessageListData = useSelector(
messageListData(threadInfo?.id, additionalMessages),
);
return React.useMemo(() => {
if (searching && userInfoInputArray.length === 0) {
return [];
}
return boundMessageListData;
}, [searching, userInfoInputArray.length, boundMessageListData]);
}
export {
messageInfoSelector,
createChatThreadItem,
chatListData,
createChatMessageItems,
messageListData,
useFlattenedChatListData,
useFilteredChatListData,
useMessageListData,
};
diff --git a/lib/selectors/nav-selectors.js b/lib/selectors/nav-selectors.js
index dbea4c4dc..ec8dcec4f 100644
--- a/lib/selectors/nav-selectors.js
+++ b/lib/selectors/nav-selectors.js
@@ -1,171 +1,180 @@
// @flow
import * as React from 'react';
import { createSelector } from 'reselect';
import { useENSNames } from '../hooks/ens-cache.js';
import SearchIndex from '../shared/search-index.js';
import { memberHasAdminPowers } from '../shared/thread-utils.js';
import type { Platform } from '../types/device-types.js';
import {
type CalendarQuery,
defaultCalendarQuery,
} from '../types/entry-types.js';
import type { CalendarFilter } from '../types/filter-types.js';
+import type {
+ MinimallyEncodedRawThreadInfo,
+ MinimallyEncodedThreadInfo,
+} from '../types/minimally-encoded-thread-permissions-types.js';
import type { BaseNavInfo } from '../types/nav-types.js';
import type { BaseAppState } from '../types/redux-types.js';
import type { RawThreadInfo, ThreadInfo } from '../types/thread-types.js';
import { getConfig } from '../utils/config.js';
import { values } from '../utils/objects.js';
import { useSelector } from '../utils/redux-utils.js';
function timeUntilCalendarRangeExpiration(
lastUserInteractionCalendar: number,
): ?number {
const inactivityLimit = getConfig().calendarRangeInactivityLimit;
if (inactivityLimit === null || inactivityLimit === undefined) {
return null;
}
return lastUserInteractionCalendar + inactivityLimit - Date.now();
}
function calendarRangeExpired(lastUserInteractionCalendar: number): boolean {
const timeUntil = timeUntilCalendarRangeExpiration(
lastUserInteractionCalendar,
);
if (timeUntil === null || timeUntil === undefined) {
return false;
}
return timeUntil <= 0;
}
const currentCalendarQuery: (
state: BaseAppState<>,
) => (calendarActive: boolean) => CalendarQuery = createSelector(
(state: BaseAppState<>) => state.entryStore.lastUserInteractionCalendar,
(state: BaseAppState<>) => state.navInfo,
(state: BaseAppState<>) => state.calendarFilters,
(
lastUserInteractionCalendar: number,
navInfo: BaseNavInfo,
calendarFilters: $ReadOnlyArray,
) => {
// Return a function since we depend on the time of evaluation
return (calendarActive: boolean, platform: ?Platform): CalendarQuery => {
if (calendarActive) {
return {
startDate: navInfo.startDate,
endDate: navInfo.endDate,
filters: calendarFilters,
};
}
if (calendarRangeExpired(lastUserInteractionCalendar)) {
return defaultCalendarQuery(platform);
}
return {
startDate: navInfo.startDate,
endDate: navInfo.endDate,
filters: calendarFilters,
};
};
},
);
// Without allAtOnce, useThreadSearchIndex is very expensive. useENSNames 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 useENSNamesOptions = { allAtOnce: true };
function useThreadSearchIndex(
- threadInfos: $ReadOnlyArray,
+ threadInfos: $ReadOnlyArray<
+ | RawThreadInfo
+ | ThreadInfo
+ | MinimallyEncodedRawThreadInfo
+ | MinimallyEncodedThreadInfo,
+ >,
): SearchIndex {
const userInfos = useSelector(state => state.userStore.userInfos);
const viewerID = useSelector(
state => state.currentUserInfo && state.currentUserInfo.id,
);
const nonViewerMembers = React.useMemo(() => {
const allMembersOfAllThreads = new Map();
for (const threadInfo of threadInfos) {
for (const member of threadInfo.members) {
const isParentAdmin = memberHasAdminPowers(member);
if (!member.role && !isParentAdmin) {
continue;
}
if (member.id === viewerID) {
continue;
}
if (!allMembersOfAllThreads.has(member.id)) {
const userInfo = userInfos[member.id];
if (userInfo?.username) {
allMembersOfAllThreads.set(member.id, userInfo);
}
}
}
}
return [...allMembersOfAllThreads.values()];
}, [threadInfos, userInfos, viewerID]);
const nonViewerMembersWithENSNames = useENSNames(
nonViewerMembers,
useENSNamesOptions,
);
const memberMap = React.useMemo(() => {
const result = new Map();
for (const userInfo of nonViewerMembersWithENSNames) {
result.set(userInfo.id, userInfo);
}
return result;
}, [nonViewerMembersWithENSNames]);
return React.useMemo(() => {
const searchIndex = new SearchIndex();
for (const threadInfo of threadInfos) {
const searchTextArray = [];
if (threadInfo.name) {
searchTextArray.push(threadInfo.name);
}
if (threadInfo.description) {
searchTextArray.push(threadInfo.description);
}
for (const member of threadInfo.members) {
const isParentAdmin = memberHasAdminPowers(member);
if (!member.role && !isParentAdmin) {
continue;
}
if (member.id === viewerID) {
continue;
}
const userInfo = userInfos[member.id];
const rawUsername = userInfo?.username;
if (rawUsername) {
searchTextArray.push(rawUsername);
}
const resolvedUserInfo = memberMap.get(member.id);
const username = resolvedUserInfo?.username;
if (username && username !== rawUsername) {
searchTextArray.push(username);
}
}
searchIndex.addEntry(threadInfo.id, searchTextArray.join(' '));
}
return searchIndex;
}, [threadInfos, viewerID, userInfos, memberMap]);
}
function useGlobalThreadSearchIndex(): SearchIndex {
const threadInfos = useSelector(state => state.threadStore.threadInfos);
const threadInfosArray = React.useMemo(
() => values(threadInfos),
[threadInfos],
);
return useThreadSearchIndex(threadInfosArray);
}
export {
timeUntilCalendarRangeExpiration,
currentCalendarQuery,
useThreadSearchIndex,
useGlobalThreadSearchIndex,
};
diff --git a/lib/selectors/thread-selectors.js b/lib/selectors/thread-selectors.js
index 9da38029c..56e3c87be 100644
--- a/lib/selectors/thread-selectors.js
+++ b/lib/selectors/thread-selectors.js
@@ -1,518 +1,519 @@
// @flow
import _compact from 'lodash/fp/compact.js';
import _filter from 'lodash/fp/filter.js';
import _flow from 'lodash/fp/flow.js';
import _map from 'lodash/fp/map.js';
import _mapValues from 'lodash/fp/mapValues.js';
import _orderBy from 'lodash/fp/orderBy.js';
import _some from 'lodash/fp/some.js';
import _sortBy from 'lodash/fp/sortBy.js';
import _memoize from 'lodash/memoize.js';
import { createSelector } from 'reselect';
import { createObjectSelector } from 'reselect-map';
import {
filteredThreadIDsSelector,
includeDeletedSelector,
} from './calendar-filter-selectors.js';
import { relativeMemberInfoSelectorForMembersOfThread } from './user-selectors.js';
import genesis from '../facts/genesis.js';
import {
getAvatarForThread,
getRandomDefaultEmojiAvatar,
} from '../shared/avatar-utils.js';
import { createEntryInfo } from '../shared/entry-utils.js';
import { getMostRecentNonLocalMessageID } from '../shared/message-utils.js';
import {
threadInHomeChatList,
threadInBackgroundChatList,
threadInFilterList,
threadInfoFromRawThreadInfo,
threadHasPermission,
threadInChatList,
threadHasAdminRole,
roleIsAdminRole,
threadIsPending,
getPendingThreadID,
} from '../shared/thread-utils.js';
import type { ClientEmojiAvatar } from '../types/avatar-types';
import type { EntryInfo } from '../types/entry-types.js';
import type { MessageStore, RawMessageInfo } from '../types/message-types.js';
+import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js';
import type { BaseAppState } from '../types/redux-types.js';
import { threadPermissions } from '../types/thread-permission-types.js';
import {
threadTypes,
threadTypeIsCommunityRoot,
type ThreadType,
} from '../types/thread-types-enum.js';
import {
type ThreadInfo,
type RawThreadInfo,
type RelativeMemberInfo,
type SidebarInfo,
type RawThreadInfos,
} from '../types/thread-types.js';
import { dateString, dateFromString } from '../utils/date-utils.js';
import { values } from '../utils/objects.js';
const _mapValuesWithKeys = _mapValues.convert({ cap: false });
type ThreadInfoSelectorType = (state: BaseAppState<>) => {
+[id: string]: ThreadInfo,
};
const threadInfoSelector: ThreadInfoSelectorType = createObjectSelector(
(state: BaseAppState<>) => state.threadStore.threadInfos,
(state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id,
(state: BaseAppState<>) => state.userStore.userInfos,
threadInfoFromRawThreadInfo,
);
const communityThreadSelector: (
state: BaseAppState<>,
) => $ReadOnlyArray = createSelector(
threadInfoSelector,
(threadInfos: { +[id: string]: ThreadInfo }) => {
const result = [];
for (const threadID in threadInfos) {
const threadInfo = threadInfos[threadID];
if (!threadTypeIsCommunityRoot(threadInfo.type)) {
continue;
}
result.push(threadInfo);
}
return result;
},
);
const canBeOnScreenThreadInfos: (
state: BaseAppState<>,
) => $ReadOnlyArray = createSelector(
threadInfoSelector,
(threadInfos: { +[id: string]: ThreadInfo }) => {
const result = [];
for (const threadID in threadInfos) {
const threadInfo = threadInfos[threadID];
if (!threadInFilterList(threadInfo)) {
continue;
}
result.push(threadInfo);
}
return result;
},
);
const onScreenThreadInfos: (
state: BaseAppState<>,
) => $ReadOnlyArray = createSelector(
filteredThreadIDsSelector,
canBeOnScreenThreadInfos,
(
inputThreadIDs: ?$ReadOnlySet,
threadInfos: $ReadOnlyArray,
) => {
const threadIDs = inputThreadIDs;
if (!threadIDs) {
return threadInfos;
}
return threadInfos.filter(threadInfo => threadIDs.has(threadInfo.id));
},
);
const onScreenEntryEditableThreadInfos: (
state: BaseAppState<>,
) => $ReadOnlyArray = createSelector(
onScreenThreadInfos,
(threadInfos: $ReadOnlyArray) =>
threadInfos.filter(threadInfo =>
threadHasPermission(threadInfo, threadPermissions.EDIT_ENTRIES),
),
);
const entryInfoSelector: (state: BaseAppState<>) => {
+[id: string]: EntryInfo,
} = createObjectSelector(
(state: BaseAppState<>) => state.entryStore.entryInfos,
(state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id,
(state: BaseAppState<>) => state.userStore.userInfos,
createEntryInfo,
);
// "current" means within startDate/endDate range, not deleted, and in
// onScreenThreadInfos
const currentDaysToEntries: (state: BaseAppState<>) => {
+[dayString: string]: EntryInfo[],
} = createSelector(
entryInfoSelector,
(state: BaseAppState<>) => state.entryStore.daysToEntries,
(state: BaseAppState<>) => state.navInfo.startDate,
(state: BaseAppState<>) => state.navInfo.endDate,
onScreenThreadInfos,
includeDeletedSelector,
(
entryInfos: { +[id: string]: EntryInfo },
daysToEntries: { +[day: string]: string[] },
startDateString: string,
endDateString: string,
onScreen: $ReadOnlyArray,
includeDeleted: boolean,
) => {
const allDaysWithinRange = {},
startDate = dateFromString(startDateString),
endDate = dateFromString(endDateString);
for (
const curDate = startDate;
curDate <= endDate;
curDate.setDate(curDate.getDate() + 1)
) {
allDaysWithinRange[dateString(curDate)] = [];
}
return _mapValuesWithKeys((_: string[], dayString: string) =>
_flow(
_map((entryID: string) => entryInfos[entryID]),
_compact,
_filter(
(entryInfo: EntryInfo) =>
(includeDeleted || !entryInfo.deleted) &&
_some(['id', entryInfo.threadID])(onScreen),
),
_sortBy('creationTime'),
)(daysToEntries[dayString] ? daysToEntries[dayString] : []),
)(allDaysWithinRange);
},
);
const childThreadInfos: (state: BaseAppState<>) => {
+[id: string]: $ReadOnlyArray,
} = createSelector(
threadInfoSelector,
(threadInfos: { +[id: string]: ThreadInfo }) => {
const result = {};
for (const id in threadInfos) {
const threadInfo = threadInfos[id];
const parentThreadID = threadInfo.parentThreadID;
if (parentThreadID === null || parentThreadID === undefined) {
continue;
}
if (result[parentThreadID] === undefined) {
result[parentThreadID] = [];
}
result[parentThreadID].push(threadInfo);
}
return result;
},
);
const containedThreadInfos: (state: BaseAppState<>) => {
+[id: string]: $ReadOnlyArray,
} = createSelector(
threadInfoSelector,
(threadInfos: { +[id: string]: ThreadInfo }) => {
const result = {};
for (const id in threadInfos) {
const threadInfo = threadInfos[id];
const { containingThreadID } = threadInfo;
if (containingThreadID === null || containingThreadID === undefined) {
continue;
}
if (result[containingThreadID] === undefined) {
result[containingThreadID] = [];
}
result[containingThreadID].push(threadInfo);
}
return result;
},
);
function getMostRecentRawMessageInfo(
- threadInfo: ThreadInfo,
+ threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
messageStore: MessageStore,
): ?RawMessageInfo {
const thread = messageStore.threads[threadInfo.id];
if (!thread) {
return null;
}
for (const messageID of thread.messageIDs) {
return messageStore.messages[messageID];
}
return null;
}
const sidebarInfoSelector: (state: BaseAppState<>) => {
+[id: string]: $ReadOnlyArray,
} = createObjectSelector(
childThreadInfos,
(state: BaseAppState<>) => state.messageStore,
(childThreads: $ReadOnlyArray, messageStore: MessageStore) => {
const sidebarInfos = [];
for (const childThreadInfo of childThreads) {
if (
!threadInChatList(childThreadInfo) ||
childThreadInfo.type !== threadTypes.SIDEBAR
) {
continue;
}
const mostRecentRawMessageInfo = getMostRecentRawMessageInfo(
childThreadInfo,
messageStore,
);
const lastUpdatedTime =
mostRecentRawMessageInfo?.time ?? childThreadInfo.creationTime;
const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID(
childThreadInfo.id,
messageStore,
);
sidebarInfos.push({
threadInfo: childThreadInfo,
lastUpdatedTime,
mostRecentNonLocalMessage,
});
}
return _orderBy('lastUpdatedTime')('desc')(sidebarInfos);
},
);
const unreadCount: (state: BaseAppState<>) => number = createSelector(
(state: BaseAppState<>) => state.threadStore.threadInfos,
(threadInfos: RawThreadInfos): number =>
values(threadInfos).filter(
threadInfo =>
threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread,
).length,
);
const unreadBackgroundCount: (state: BaseAppState<>) => number = createSelector(
(state: BaseAppState<>) => state.threadStore.threadInfos,
(threadInfos: RawThreadInfos): number =>
values(threadInfos).filter(
threadInfo =>
threadInBackgroundChatList(threadInfo) && threadInfo.currentUser.unread,
).length,
);
const baseAncestorThreadInfos = (threadID: string) =>
createSelector(
(state: BaseAppState<>) => threadInfoSelector(state),
(threadInfos: {
+[id: string]: ThreadInfo,
}): $ReadOnlyArray => {
const pathComponents: ThreadInfo[] = [];
let node: ?ThreadInfo = threadInfos[threadID];
while (node) {
pathComponents.push(node);
node = node.parentThreadID ? threadInfos[node.parentThreadID] : null;
}
pathComponents.reverse();
return pathComponents;
},
);
const ancestorThreadInfos: (
threadID: string,
) => (state: BaseAppState<>) => $ReadOnlyArray = _memoize(
baseAncestorThreadInfos,
);
const baseOtherUsersButNoOtherAdmins = (threadID: string) =>
createSelector(
(state: BaseAppState<>) => state.threadStore.threadInfos[threadID],
relativeMemberInfoSelectorForMembersOfThread(threadID),
(
threadInfo: ?RawThreadInfo,
members: $ReadOnlyArray,
): boolean => {
if (!threadInfo) {
return false;
}
if (!threadHasAdminRole(threadInfo)) {
return false;
}
let otherUsersExist = false;
let otherAdminsExist = false;
for (const member of members) {
const role = member.role;
if (role === undefined || role === null || member.isViewer) {
continue;
}
otherUsersExist = true;
if (roleIsAdminRole(threadInfo?.roles[role])) {
otherAdminsExist = true;
break;
}
}
return otherUsersExist && !otherAdminsExist;
},
);
const otherUsersButNoOtherAdmins: (
threadID: string,
) => (state: BaseAppState<>) => boolean = _memoize(
baseOtherUsersButNoOtherAdmins,
);
function mostRecentlyReadThread(
messageStore: MessageStore,
threadInfos: RawThreadInfos,
): ?string {
let mostRecent = null;
for (const threadID in threadInfos) {
const threadInfo = threadInfos[threadID];
if (threadInfo.currentUser.unread) {
continue;
}
const threadMessageInfo = messageStore.threads[threadID];
if (!threadMessageInfo) {
continue;
}
const mostRecentMessageTime =
threadMessageInfo.messageIDs.length === 0
? threadInfo.creationTime
: messageStore.messages[threadMessageInfo.messageIDs[0]].time;
if (mostRecent && mostRecent.time >= mostRecentMessageTime) {
continue;
}
const topLevelThreadID =
threadInfo.type === threadTypes.SIDEBAR
? threadInfo.parentThreadID
: threadID;
mostRecent = { threadID: topLevelThreadID, time: mostRecentMessageTime };
}
return mostRecent ? mostRecent.threadID : null;
}
const mostRecentlyReadThreadSelector: (state: BaseAppState<>) => ?string =
createSelector(
(state: BaseAppState<>) => state.messageStore,
(state: BaseAppState<>) => state.threadStore.threadInfos,
mostRecentlyReadThread,
);
const threadInfoFromSourceMessageIDSelector: (state: BaseAppState<>) => {
+[id: string]: ThreadInfo,
} = createSelector(
(state: BaseAppState<>) => state.threadStore.threadInfos,
threadInfoSelector,
(
rawThreadInfos: RawThreadInfos,
threadInfos: { +[id: string]: ThreadInfo },
) => {
const pendingToRealizedThreadIDs =
pendingToRealizedThreadIDsSelector(rawThreadInfos);
const result = {};
for (const realizedID of pendingToRealizedThreadIDs.values()) {
const threadInfo = threadInfos[realizedID];
if (threadInfo && threadInfo.sourceMessageID) {
result[threadInfo.sourceMessageID] = threadInfo;
}
}
return result;
},
);
const pendingToRealizedThreadIDsSelector: (
rawThreadInfos: RawThreadInfos,
) => $ReadOnlyMap = createSelector(
(rawThreadInfos: RawThreadInfos) => rawThreadInfos,
(rawThreadInfos: RawThreadInfos) => {
const result = new Map();
for (const threadID in rawThreadInfos) {
const rawThreadInfo = rawThreadInfos[threadID];
if (
threadIsPending(threadID) ||
(rawThreadInfo.parentThreadID !== genesis.id &&
rawThreadInfo.type !== threadTypes.SIDEBAR)
) {
continue;
}
const actualMemberIDs = rawThreadInfo.members
.filter(member => member.role)
.map(member => member.id);
const pendingThreadID = getPendingThreadID(
rawThreadInfo.type,
actualMemberIDs,
rawThreadInfo.sourceMessageID,
);
const existingResult = result.get(pendingThreadID);
if (
!existingResult ||
rawThreadInfos[existingResult].creationTime > rawThreadInfo.creationTime
) {
result.set(pendingThreadID, threadID);
}
}
return result;
},
);
const baseSavedEmojiAvatarSelectorForThread = (
threadID: string,
containingThreadID: ?string,
) =>
createSelector(
(state: BaseAppState<>) => threadInfoSelector(state)[threadID],
(state: BaseAppState<>) =>
containingThreadID ? threadInfoSelector(state)[containingThreadID] : null,
(threadInfo: ThreadInfo, containingThreadInfo: ?ThreadInfo) => {
return () => {
let threadAvatar = getAvatarForThread(threadInfo, containingThreadInfo);
if (threadAvatar.type !== 'emoji') {
threadAvatar = getRandomDefaultEmojiAvatar();
}
return threadAvatar;
};
},
);
const savedEmojiAvatarSelectorForThread: (
threadID: string,
containingThreadID: ?string,
) => (state: BaseAppState<>) => () => ClientEmojiAvatar = _memoize(
baseSavedEmojiAvatarSelectorForThread,
);
const baseThreadInfosSelectorForThreadType = (threadType: ThreadType) =>
createSelector(
(state: BaseAppState<>) => threadInfoSelector(state),
(threadInfos: {
+[id: string]: ThreadInfo,
}): $ReadOnlyArray => {
const result = [];
for (const threadID in threadInfos) {
const threadInfo = threadInfos[threadID];
if (threadInfo.type === threadType) {
result.push(threadInfo);
}
}
return result;
},
);
const threadInfosSelectorForThreadType: (
threadType: ThreadType,
) => (state: BaseAppState<>) => $ReadOnlyArray = _memoize(
baseThreadInfosSelectorForThreadType,
);
export {
ancestorThreadInfos,
threadInfoSelector,
communityThreadSelector,
onScreenThreadInfos,
onScreenEntryEditableThreadInfos,
entryInfoSelector,
currentDaysToEntries,
childThreadInfos,
containedThreadInfos,
unreadCount,
unreadBackgroundCount,
otherUsersButNoOtherAdmins,
mostRecentlyReadThread,
mostRecentlyReadThreadSelector,
sidebarInfoSelector,
threadInfoFromSourceMessageIDSelector,
pendingToRealizedThreadIDsSelector,
savedEmojiAvatarSelectorForThread,
threadInfosSelectorForThreadType,
};
diff --git a/lib/shared/ancestor-threads.js b/lib/shared/ancestor-threads.js
index 61328c7e1..63df1085b 100644
--- a/lib/shared/ancestor-threads.js
+++ b/lib/shared/ancestor-threads.js
@@ -1,29 +1,30 @@
// @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 ThreadInfo } from '../types/thread-types.js';
import { useSelector } from '../utils/redux-utils.js';
function useAncestorThreads(
- threadInfo: ThreadInfo,
+ threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
): $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 406130120..2bb58275b 100644
--- a/lib/shared/avatar-utils.js
+++ b/lib/shared/avatar-utils.js
@@ -1,363 +1,381 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import stringHash from 'string-hash';
import { selectedThreadColors } from './color-utils.js';
import { threadOtherMembers } from './thread-utils.js';
import genesis from '../facts/genesis.js';
import { useENSAvatar } from '../hooks/ens-cache.js';
import { threadInfoSelector } from '../selectors/thread-selectors.js';
import { getETHAddressForUserInfo } from '../shared/account-utils.js';
import type {
ClientEmojiAvatar,
ClientAvatar,
ResolvedClientAvatar,
GenericUserInfoWithAvatar,
} from '../types/avatar-types.js';
+import type {
+ MinimallyEncodedRawThreadInfo,
+ MinimallyEncodedThreadInfo,
+} from '../types/minimally-encoded-thread-permissions-types.js';
import { threadTypes } from '../types/thread-types-enum.js';
import { type RawThreadInfo, type 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 | ThreadInfo,
+ threadInfo:
+ | RawThreadInfo
+ | ThreadInfo
+ | MinimallyEncodedRawThreadInfo
+ | MinimallyEncodedThreadInfo,
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 | ThreadInfo,
- containingThreadInfo: ?ThreadInfo,
+ thread:
+ | RawThreadInfo
+ | ThreadInfo
+ | MinimallyEncodedThreadInfo
+ | MinimallyEncodedRawThreadInfo,
+ containingThreadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo,
): 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 | ThreadInfo): ClientAvatar {
+function useAvatarForThread(
+ thread:
+ | RawThreadInfo
+ | ThreadInfo
+ | MinimallyEncodedRawThreadInfo
+ | MinimallyEncodedThreadInfo,
+): 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 e6a04e421..385473a9d 100644
--- a/lib/shared/edit-messages-utils.js
+++ b/lib/shared/edit-messages-utils.js
@@ -1,85 +1,86 @@
// @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 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: ThreadInfo,
+ threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
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 2a121bef6..7cfa2a63b 100644
--- a/lib/shared/inline-engagement-utils.js
+++ b/lib/shared/inline-engagement-utils.js
@@ -1,25 +1,28 @@
// @flow
import type { ReactionInfo } from '../selectors/chat-selectors.js';
+import type { MinimallyEncodedThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js';
import type { ThreadInfo } from '../types/thread-types.js';
-function getInlineEngagementSidebarText(threadInfo: ?ThreadInfo): string {
+function getInlineEngagementSidebarText(
+ threadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo,
+): 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/message-utils.js b/lib/shared/message-utils.js
index 2dcdcec4a..9e6a1110d 100644
--- a/lib/shared/message-utils.js
+++ b/lib/shared/message-utils.js
@@ -1,727 +1,735 @@
// @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 {
+ MinimallyEncodedRawThreadInfo,
+ MinimallyEncodedThreadInfo,
+} from '../types/minimally-encoded-thread-permissions-types.js';
import type { RawThreadInfo, ThreadInfo } from '../types/thread-types.js';
import type { UserInfos } from '../types/user-types.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: ?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]: ThreadInfo },
): ?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: 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: 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 | ThreadInfo,
+ threadInfo:
+ | RawThreadInfo
+ | ThreadInfo
+ | MinimallyEncodedRawThreadInfo
+ | MinimallyEncodedThreadInfo,
): 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;
}
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,
};
diff --git a/lib/shared/notif-utils.js b/lib/shared/notif-utils.js
index 54ab706b0..2dd617bc2 100644
--- a/lib/shared/notif-utils.js
+++ b/lib/shared/notif-utils.js
@@ -1,323 +1,324 @@
// @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 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: ThreadInfo,
parentThreadInfo: ?ThreadInfo,
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: ThreadInfo,
): 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: ThreadInfo,
+ +parentThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
+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 = `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: ThreadInfo,
+ +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
+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 = '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: ThreadInfo,
parentThreadInfo: ?ThreadInfo,
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: ThreadInfo,
parentThreadInfo: ?ThreadInfo,
): 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 6e0e17eab..add397ac4 100644
--- a/lib/shared/reaction-utils.js
+++ b/lib/shared/reaction-utils.js
@@ -1,109 +1,110 @@
// @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 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: ThreadInfo,
+ threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
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 c32c8f4b7..4e7137ed4 100644
--- a/lib/shared/search-utils.js
+++ b/lib/shared/search-utils.js
@@ -1,346 +1,347 @@
// @flow
import * as React from 'react';
import { useSelector } from 'react-redux';
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 genesis from '../facts/genesis.js';
import type {
ChatMessageInfoItem,
MessageListData,
} from '../selectors/chat-selectors';
import type { MessageInfo, RawMessageInfo } from '../types/message-types';
import { isComposableMessageType } 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 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 { values } from '../utils/objects.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?: ?ThreadInfo,
- +inputCommunityThreadInfo?: ?ThreadInfo,
+ +inputParentThreadInfo?: ?ThreadInfo | ?MinimallyEncodedThreadInfo,
+ +inputCommunityThreadInfo?: ?ThreadInfo | ?MinimallyEncodedThreadInfo,
+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 =>
containingThreadInfo
? userInfo.isMemberOfContainingThread &&
!blockedRelationshipsStatuses.has(userInfo.relationshipStatus)
: userInfo?.relationshipStatus === userRelationshipStatus.FRIEND,
);
}
const nonFriends = [];
const blockedUsers = [];
const friends = [];
const containingThreadMembers = [];
const parentThreadMembers = [];
for (const userResult of userResults) {
const relationshipStatus = userResult.relationshipStatus;
if (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 (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 useSearchUsers(
usernameInputText: string,
): $ReadOnlyArray {
const currentUserID = useSelector(
state => state.currentUserInfo && state.currentUserInfo.id,
);
const [serverSearchResults, setServerSearchResults] = React.useState<
$ReadOnlyArray,
>([]);
const callSearchUsers = useServerCall(searchUsers);
const dispatchActionPromise = useDispatchActionPromise();
React.useEffect(() => {
const searchUsersPromise = (async () => {
if (usernameInputText.length === 0) {
setServerSearchResults([]);
} else {
try {
const { userInfos } = await callSearchUsers(usernameInputText);
setServerSearchResults(
userInfos.filter(({ id }) => id !== currentUserID),
);
} catch (err) {
setServerSearchResults([]);
}
}
})();
dispatchActionPromise(searchUsersActionTypes, searchUsersPromise);
}, [
callSearchUsers,
currentUserID,
dispatchActionPromise,
usernameInputText,
]);
return serverSearchResults;
}
function filterChatMessageInfosForSearch(
chatMessageInfos: MessageListData,
translatedSearchResults: $ReadOnlyArray,
): ?(ChatMessageInfoItem[]) {
if (!chatMessageInfos) {
return null;
}
const idSet = new Set(translatedSearchResults.map(item => item.id));
const chatMessageInfoItems = chatMessageInfos.filter(
item =>
item.messageInfo &&
idSet.has(item.messageInfo.id) &&
isComposableMessageType(item.messageInfo.type),
);
const uniqueChatMessageInfoItemsMap = new Map();
chatMessageInfoItems.forEach(
item =>
item.messageInfo &&
item.messageInfo.id &&
uniqueChatMessageInfoItemsMap.set(item.messageInfo.id, item),
);
const sortedChatMessageInfoItems = [];
for (let i = 0; i < translatedSearchResults.length; i++) {
sortedChatMessageInfoItems.push(
uniqueChatMessageInfoItemsMap.get(translatedSearchResults[i].id),
);
}
return sortedChatMessageInfoItems.filter(Boolean);
}
export {
getPotentialMemberItems,
notFriendNotice,
useSearchMessages,
useSearchUsers,
filterChatMessageInfosForSearch,
};
diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js
index 3ec2af2f4..7b3f70e79 100644
--- a/lib/shared/thread-utils.js
+++ b/lib/shared/thread-utils.js
@@ -1,1981 +1,1981 @@
// @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 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 {
MinimallyEncodedMemberInfo,
MinimallyEncodedRawThreadInfo,
MinimallyEncodedRelativeMemberInfo,
MinimallyEncodedRoleInfo,
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,
} from '../types/thread-permission-types.js';
import {
type ThreadType,
threadTypes,
threadTypeIsCommunityRoot,
assertThreadType,
} from '../types/thread-types-enum.js';
import {
type RawThreadInfo,
type ThreadInfo,
type MemberInfo,
type ServerThreadInfo,
type RelativeMemberInfo,
type ThreadCurrentUserInfo,
type RoleInfo,
type ServerMemberInfo,
type ClientNewThreadRequest,
type NewThreadResult,
type ChangeThreadSettingsPayload,
type UserProfileThreadInfo,
} 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: ?(
| ThreadInfo
| RawThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo
),
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: ?(
| ThreadInfo
| RawThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo
),
): boolean {
return !!(
threadInfo &&
threadInfo.currentUser.role !== null &&
threadInfo.currentUser.role !== undefined
);
}
function threadIsInHome(
threadInfo: ?(
| ThreadInfo
| RawThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo
),
): boolean {
return !!(threadInfo && threadInfo.currentUser.subscription.home);
}
// Can have messages
function threadInChatList(
threadInfo: ?(
| ThreadInfo
| RawThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo
),
): boolean {
return (
viewerIsMember(threadInfo) &&
threadHasPermission(threadInfo, threadPermissions.VISIBLE)
);
}
function threadIsTopLevel(
threadInfo: ?(
| ThreadInfo
| RawThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo
),
): boolean {
return threadInChatList(threadInfo) && threadIsChannel(threadInfo);
}
function threadIsChannel(
threadInfo: ?(
| ThreadInfo
| RawThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo
),
): boolean {
return !!(threadInfo && threadInfo.type !== threadTypes.SIDEBAR);
}
function threadIsSidebar(
threadInfo: ?(
| ThreadInfo
| RawThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo
),
): boolean {
return threadInfo?.type === threadTypes.SIDEBAR;
}
function threadInBackgroundChatList(
threadInfo: ?(
| ThreadInfo
| RawThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo
),
): boolean {
return threadInChatList(threadInfo) && !threadIsInHome(threadInfo);
}
function threadInHomeChatList(
threadInfo: ?(
| ThreadInfo
| RawThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo
),
): boolean {
return threadInChatList(threadInfo) && threadIsInHome(threadInfo);
}
// Can have Calendar entries,
// does appear as a top-level entity in the thread list
function threadInFilterList(
threadInfo: ?(
| ThreadInfo
| RawThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo
),
): boolean {
return (
threadInChatList(threadInfo) &&
!!threadInfo &&
threadInfo.type !== threadTypes.SIDEBAR
);
}
function userIsMember(
threadInfo: ?(
| ThreadInfo
| RawThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo
),
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<
| MemberInfo
| RelativeMemberInfo
| MinimallyEncodedMemberInfo
| MinimallyEncodedRelativeMemberInfo,
>,
): $ReadOnlyArray {
return memberInfos
.filter(memberInfo => memberInfo.role)
.map(memberInfo => memberInfo.id);
}
function threadOtherMembers<
T:
| MemberInfo
| RelativeMemberInfo
| MinimallyEncodedMemberInfo
| MinimallyEncodedRelativeMemberInfo,
>(memberInfos: $ReadOnlyArray, viewerID: ?string): $ReadOnlyArray {
return memberInfos.filter(
memberInfo => memberInfo.role && memberInfo.id !== viewerID,
);
}
function threadMembersWithoutAddedAshoat<
T:
| ThreadInfo
| RawThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo,
>(threadInfo: T): $PropertyType {
if (threadInfo.community !== genesis.id) {
return threadInfo.members;
}
return threadInfo.members.filter(
member => member.id !== ashoat.id || member.role,
);
}
function threadIsGroupChat(
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
): boolean {
return threadInfo.members.length > 2;
}
function threadOrParentThreadIsGroupChat(
threadInfo:
| RawThreadInfo
| ThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo,
) {
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:
| ThreadInfo
| RawThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo,
viewerID: ?string,
): ?string {
if (!viewerID) {
return undefined;
}
const otherMembers = threadOtherMembers(threadInfo.members, viewerID);
if (otherMembers.length !== 1) {
return undefined;
}
return otherMembers[0].id;
}
function getPendingThreadID(
threadType: ThreadType,
memberIDs: $ReadOnlyArray,
sourceMessageID: ?string,
): string {
const pendingThreadKey = sourceMessageID
? `sidebar/${sourceMessageID}`
: [...memberIDs].sort().join('+');
const pendingThreadTypeString = sourceMessageID ? '' : `type${threadType}/`;
return `pending/${pendingThreadTypeString}${pendingThreadKey}`;
}
type PendingThreadIDContents = {
+threadType: ThreadType,
+memberIDs: $ReadOnlyArray,
+sourceMessageID: ?string,
};
function parsePendingThreadID(
pendingThreadID: string,
): ?PendingThreadIDContents {
const pendingRegex = new RegExp(`^${pendingThreadIDRegex}$`);
const pendingThreadIDMatches = pendingRegex.exec(pendingThreadID);
if (!pendingThreadIDMatches) {
return null;
}
const [threadTypeString, threadKey] = pendingThreadIDMatches[1].split('/');
const threadType =
threadTypeString === 'sidebar'
? threadTypes.SIDEBAR
: assertThreadType(Number(threadTypeString.replace('type', '')));
const memberIDs = threadTypeString === 'sidebar' ? [] : threadKey.split('+');
const sourceMessageID = threadTypeString === 'sidebar' ? threadKey : null;
return {
threadType,
memberIDs,
sourceMessageID,
};
}
type UserIDAndUsername = {
+id: string,
+username: string,
...
};
type CreatePendingThreadArgs = {
+viewerID: string,
+threadType: ThreadType,
+members: $ReadOnlyArray,
+parentThreadInfo?: ?ThreadInfo,
+threadColor?: ?string,
+name?: ?string,
+sourceMessageID?: string,
};
function createPendingThread({
viewerID,
threadType,
members,
parentThreadInfo,
threadColor,
name,
sourceMessageID,
}: CreatePendingThreadArgs): ThreadInfo {
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 = {};
for (const member of members) {
const { id, username } = member;
userInfos[id] = { id, username };
}
return threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, userInfos);
}
type PendingPersonalThread = {
+threadInfo: ThreadInfo,
+pendingPersonalThreadUserInfo: UserInfo,
};
function createPendingPersonalThread(
loggedInUserInfo: LoggedInUserInfo,
userID: string,
username: string,
): PendingPersonalThread {
const pendingPersonalThreadUserInfo = {
id: userID,
username: username,
};
const threadInfo = createPendingThread({
viewerID: loggedInUserInfo.id,
threadType: threadTypes.PERSONAL,
members: [loggedInUserInfo, pendingPersonalThreadUserInfo],
});
return { threadInfo, pendingPersonalThreadUserInfo };
}
function createPendingThreadItem(
loggedInUserInfo: LoggedInUserInfo,
user: UserIDAndUsername,
): ChatThreadItem {
const { threadInfo, pendingPersonalThreadUserInfo } =
createPendingPersonalThread(loggedInUserInfo, user.id, user.username);
return {
type: 'chatThreadItem',
threadInfo,
mostRecentMessageInfo: null,
mostRecentNonLocalMessage: null,
lastUpdatedTime: threadInfo.creationTime,
lastUpdatedTimeIncludingSidebars: threadInfo.creationTime,
sidebars: [],
pendingPersonalThreadUserInfo,
};
}
// Returns map from lowercase username to AccountUserInfo
function memberLowercaseUsernameMap(
members: $ReadOnlyArray<
RelativeMemberInfo | MinimallyEncodedRelativeMemberInfo,
>,
): Map {
const memberMap = new Map();
for (const member of members) {
const { id, role, username } = member;
if (!role || !username) {
continue;
}
memberMap.set(username.toLowerCase(), { id, username });
}
return memberMap;
}
// Returns map from user ID to AccountUserInfo
function extractMentionedMembers(
text: string,
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
): Map {
const memberMap = memberLowercaseUsernameMap(threadInfo.members);
const mentions = extractUserMentionsFromText(text);
const mentionedMembers = new Map();
for (const mention of mentions) {
const userInfo = memberMap.get(mention.toLowerCase());
if (userInfo) {
mentionedMembers.set(userInfo.id, userInfo);
}
}
return mentionedMembers;
}
// When a member of the parent is mentioned in a sidebar,
// they will be automatically added to that sidebar
function extractNewMentionedParentMembers(
messageText: string,
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
parentThreadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
): AccountUserInfo[] {
const mentionedMembersOfParent = extractMentionedMembers(
messageText,
parentThreadInfo,
);
for (const member of threadInfo.members) {
if (member.role) {
mentionedMembersOfParent.delete(member.id);
}
}
return [...mentionedMembersOfParent.values()];
}
type SharedCreatePendingSidebarInput = {
+sourceMessageInfo: ComposableMessageInfo | RobotextMessageInfo,
+parentThreadInfo: ThreadInfo,
+loggedInUserInfo: LoggedInUserInfo,
};
type BaseCreatePendingSidebarInput = {
...SharedCreatePendingSidebarInput,
+messageTitle: string,
};
function baseCreatePendingSidebar(
input: BaseCreatePendingSidebarInput,
): ThreadInfo {
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,
): ThreadInfo {
const {
sourceMessageInfo,
parentThreadInfo,
loggedInUserInfo,
markdownRules,
} = input;
const messageTitleEntityText = getMessageTitle(
sourceMessageInfo,
parentThreadInfo,
parentThreadInfo,
markdownRules,
);
const messageTitle = entityTextToRawString(messageTitleEntityText, {
ignoreViewer: true,
});
return baseCreatePendingSidebar({
sourceMessageInfo,
parentThreadInfo,
messageTitle,
loggedInUserInfo,
});
}
type CreatePendingSidebarInput = {
...SharedCreatePendingSidebarInput,
+markdownRules: ParserRules,
+getENSNames: ?GetENSNames,
};
async function createPendingSidebar(
input: CreatePendingSidebarInput,
): Promise {
const {
sourceMessageInfo,
parentThreadInfo,
loggedInUserInfo,
markdownRules,
getENSNames,
} = input;
const messageTitleEntityText = getMessageTitle(
sourceMessageInfo,
parentThreadInfo,
parentThreadInfo,
markdownRules,
);
const messageTitle = await getEntityTextAsString(
messageTitleEntityText,
getENSNames,
{ ignoreViewer: true },
);
invariant(
messageTitle !== null && messageTitle !== undefined,
'getEntityTextAsString only returns falsey when passed falsey',
);
return baseCreatePendingSidebar({
sourceMessageInfo,
parentThreadInfo,
messageTitle,
loggedInUserInfo,
});
}
function pendingThreadType(numberOfOtherMembers: number): 4 | 6 | 7 {
if (numberOfOtherMembers === 0) {
return threadTypes.PRIVATE;
} else if (numberOfOtherMembers === 1) {
return threadTypes.PERSONAL;
} else {
return threadTypes.LOCAL;
}
}
function threadTypeCanBePending(threadType: ThreadType): boolean {
return (
threadType === threadTypes.PERSONAL ||
threadType === threadTypes.LOCAL ||
threadType === threadTypes.SIDEBAR ||
threadType === threadTypes.PRIVATE
);
}
type CreateRealThreadParameters = {
- +threadInfo: ThreadInfo,
+ +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
+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,
};
function rawThreadInfoFromServerThreadInfo(
serverThreadInfo: ServerThreadInfo,
viewerID: string,
options?: RawThreadInfoOptions,
): ?RawThreadInfo {
const filterThreadEditAvatarPermission =
options?.filterThreadEditAvatarPermission;
const excludePinInfo = options?.excludePinInfo;
const filterManageInviteLinksPermission =
options?.filterManageInviteLinksPermission;
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)),
);
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: ThreadInfo | MinimallyEncodedThreadInfo,
): string | ThreadEntity {
if (threadInfo.name) {
return firstLine(threadInfo.name);
}
let threadMembers:
| $ReadOnlyArray
| $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: RawThreadInfo,
viewerID: ?string,
userInfos: UserInfos,
): ThreadInfo {
let threadInfo: ThreadInfo = {
id: rawThreadInfo.id,
type: rawThreadInfo.type,
name: rawThreadInfo.name,
uiName: '',
description: rawThreadInfo.description,
color: rawThreadInfo.color,
creationTime: rawThreadInfo.creationTime,
parentThreadID: rawThreadInfo.parentThreadID,
containingThreadID: rawThreadInfo.containingThreadID,
community: rawThreadInfo.community,
members: getRelativeMemberInfos(rawThreadInfo, viewerID, userInfos),
roles: rawThreadInfo.roles,
currentUser: getCurrentUser(rawThreadInfo, viewerID, userInfos),
repliesCount: rawThreadInfo.repliesCount,
};
threadInfo = {
...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: RawThreadInfo | ThreadInfo,
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
| ThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo,
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
| ThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo,
viewerID: ?string,
userInfos: UserInfos,
): boolean {
return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos);
}
function threadFrozenDueToViewerBlock(
threadInfo:
| RawThreadInfo
| ThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo,
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
| MinimallyEncodedMemberInfo
| MinimallyEncodedRelativeMemberInfo,
threadInfo:
| ThreadInfo
| RawThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo,
): 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
| MinimallyEncodedMemberInfo
| MinimallyEncodedRelativeMemberInfo,
): boolean {
if (memberInfo.minimallyEncoded) {
return hasPermission(memberInfo.permissions, threadPermissions.CHANGE_ROLE);
}
return !!memberInfo.permissions[threadPermissions.CHANGE_ROLE]?.value;
}
function roleIsAdminRole(
roleInfo: ?RoleInfo | ?MinimallyEncodedRoleInfo,
): boolean {
return !!(roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins');
}
function threadHasAdminRole(
threadInfo: ?(
| RawThreadInfo
| ThreadInfo
| ServerThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo
),
): boolean {
if (!threadInfo) {
return false;
}
return !!_find({ name: 'Admins' })(threadInfo.roles);
}
function threadOrParentThreadHasAdminRole(
threadInfo:
| RawThreadInfo
| ThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo,
) {
return (
threadMembersWithoutAddedAshoat(threadInfo).filter(member =>
memberHasAdminPowers(member),
).length > 0
);
}
function identifyInvalidatedThreads(
updateInfos: $ReadOnlyArray,
): Set {
const invalidated = new Set();
for (const updateInfo of updateInfos) {
if (updateInfo.type === updateTypes.DELETE_THREAD) {
invalidated.add(updateInfo.threadID);
}
}
return invalidated;
}
const permissionsDisabledByBlockArray = [
threadPermissions.VOICED,
threadPermissions.EDIT_ENTRIES,
threadPermissions.EDIT_THREAD_NAME,
threadPermissions.EDIT_THREAD_COLOR,
threadPermissions.EDIT_THREAD_DESCRIPTION,
threadPermissions.CREATE_SUBCHANNELS,
threadPermissions.CREATE_SIDEBARS,
threadPermissions.JOIN_THREAD,
threadPermissions.EDIT_PERMISSIONS,
threadPermissions.ADD_MEMBERS,
threadPermissions.REMOVE_MEMBERS,
];
const permissionsDisabledByBlock: Set = new Set(
permissionsDisabledByBlockArray,
);
const disabledPermissions: ThreadPermissionsInfo =
permissionsDisabledByBlockArray.reduce(
(permissions: ThreadPermissionsInfo, permission: string) => ({
...permissions,
[permission]: { value: false, source: null },
}),
{},
);
// Consider updating itemHeight in native/chat/chat-thread-list.react.js
// if you change this
const emptyItemText: string =
`Background chats are just like normal chats, except they don't ` +
`contribute to your unread count.\n\n` +
`To move a chat over here, switch the “Background” option in its settings.`;
function threadNoun(threadType: ThreadType, parentThreadID: ?string): string {
if (threadType === threadTypes.SIDEBAR) {
return 'thread';
} else if (
threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD &&
parentThreadID === genesis.id
) {
return 'chat';
} else if (
threadType === threadTypes.COMMUNITY_ROOT ||
threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT ||
threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD ||
threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD ||
threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD ||
threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD ||
threadType === threadTypes.GENESIS
) {
return 'channel';
} else {
return 'chat';
}
}
function threadLabel(threadType: ThreadType): string {
if (
threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD ||
threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD
) {
return 'Open';
} else if (threadType === threadTypes.PERSONAL) {
return 'Personal';
} else if (threadType === threadTypes.SIDEBAR) {
return 'Thread';
} else if (threadType === threadTypes.PRIVATE) {
return 'Private';
} else if (
threadType === threadTypes.COMMUNITY_ROOT ||
threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT ||
threadType === threadTypes.GENESIS
) {
return 'Community';
} else {
return 'Secret';
}
}
-function useWatchThread(threadInfo: ?ThreadInfo) {
+function useWatchThread(threadInfo: ?ThreadInfo | ?MinimallyEncodedThreadInfo) {
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,
) => ?ThreadInfo;
function useExistingThreadInfoFinder(
baseThreadInfo: ?ThreadInfo,
): ExistingThreadInfoFinder {
const threadInfos = useSelector(threadInfoSelector);
const loggedInUserInfo = useLoggedInUserInfo();
const userInfos = useSelector(state => state.userStore.userInfos);
const pendingToRealizedThreadIDs = useSelector(state =>
pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos),
);
return React.useCallback(
(params: ExistingThreadInfoFinderParams): ?ThreadInfo => {
if (!baseThreadInfo) {
return null;
}
const realizedThreadInfo = threadInfos[baseThreadInfo.id];
if (realizedThreadInfo) {
return realizedThreadInfo;
}
if (!loggedInUserInfo || !threadIsPending(baseThreadInfo.id)) {
return baseThreadInfo;
}
const viewerID = loggedInUserInfo?.id;
invariant(
threadTypeCanBePending(baseThreadInfo.type),
`ThreadInfo has pending ID ${baseThreadInfo.id}, but type that ` +
`should not be pending ${baseThreadInfo.type}`,
);
const { searching, userInfoInputArray } = params;
const { sourceMessageID } = baseThreadInfo;
const pendingThreadID = searching
? getPendingThreadID(
pendingThreadType(userInfoInputArray.length),
[...userInfoInputArray.map(user => user.id), viewerID],
sourceMessageID,
)
: getPendingThreadID(
baseThreadInfo.type,
baseThreadInfo.members.map(member => member.id),
sourceMessageID,
);
const realizedThreadID = pendingToRealizedThreadIDs.get(pendingThreadID);
if (realizedThreadID && threadInfos[realizedThreadID]) {
return threadInfos[realizedThreadID];
}
const updatedThread = searching
? createPendingThread({
viewerID,
threadType: pendingThreadType(userInfoInputArray.length),
members: [loggedInUserInfo, ...userInfoInputArray],
})
: baseThreadInfo;
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
| ThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo,
memberID: string,
permission: ThreadPermission,
): boolean {
for (const member of threadInfo.members) {
if (member.id !== memberID) {
continue;
}
if (member.minimallyEncoded) {
return hasPermission(member.permissions, permission);
}
return permissionLookup(member.permissions, permission);
}
return false;
}
function useCanCreateSidebarFromMessage(
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
messageInfo: ComposableMessageInfo | RobotextMessageInfo,
): boolean {
const messageCreatorUserInfo = useSelector(
state => state.userStore.userInfos[messageInfo.creator.id],
);
if (
!messageInfo.id ||
threadInfo.sourceMessageID === messageInfo.id ||
isInvalidSidebarSource(messageInfo)
) {
return false;
}
const messageCreatorRelationship = messageCreatorUserInfo?.relationshipStatus;
const creatorRelationshipHasBlock =
messageCreatorRelationship &&
relationshipBlockedInEitherDirection(messageCreatorRelationship);
const hasCreateSidebarsPermission = threadHasPermission(
threadInfo,
threadPermissions.CREATE_SIDEBARS,
);
return hasCreateSidebarsPermission && !creatorRelationshipHasBlock;
}
function useSidebarExistsOrCanBeCreated(
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
messageItem: ChatMessageInfoItem,
): boolean {
const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage(
threadInfo,
messageItem.messageInfo,
);
return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage;
}
function checkIfDefaultMembersAreVoiced(
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
): 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
| ThreadInfo
| MinimallyEncodedRawThreadInfo
| 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
| ThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo,
): ?string {
if (!parentThreadInfo) {
return null;
}
const { id, community, type } = parentThreadInfo;
if (community !== null && community !== undefined) {
return community;
}
if (threadTypeIsCommunityRoot(type)) {
return id;
}
return null;
}
function getThreadListSearchResults(
chatListData: $ReadOnlyArray,
searchText: string,
threadFilter: ThreadInfo => boolean,
threadSearchResults: $ReadOnlySet,
usersSearchResults: $ReadOnlyArray,
loggedInUserInfo: ?LoggedInUserInfo,
): $ReadOnlyArray {
if (!searchText) {
return chatListData.filter(
item =>
threadIsTopLevel(item.threadInfo) && threadFilter(item.threadInfo),
);
}
const privateThreads = [];
const personalThreads = [];
const otherThreads = [];
for (const item of chatListData) {
if (!threadSearchResults.has(item.threadInfo.id)) {
continue;
}
if (item.threadInfo.type === threadTypes.PRIVATE) {
privateThreads.push({ ...item, sidebars: [] });
} else if (item.threadInfo.type === threadTypes.PERSONAL) {
personalThreads.push({ ...item, sidebars: [] });
} else {
otherThreads.push({ ...item, sidebars: [] });
}
}
const chatItems = [...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 searchUsers = React.useCallback(
async (usernamePrefix: string) => {
if (usernamePrefix.length === 0) {
return [];
}
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([]);
const threadSearchIndex = useGlobalThreadSearchIndex();
React.useEffect(() => {
(async () => {
const results = threadSearchIndex.getSearchResults(searchText);
setThreadSearchResults(new Set(results));
const usersResults = await searchUsers(searchText);
setUsersSearchResults(usersResults);
})();
}, [searchText, threadSearchIndex, searchUsers]);
return { threadSearchResults, usersSearchResults };
}
function removeMemberFromThread(
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
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: ThreadInfo | MinimallyEncodedThreadInfo,
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 | MinimallyEncodedRelativeMemberInfo,
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
canEdit: ?boolean = true,
): $ReadOnlyArray<'change_role' | 'remove_user'> {
const role = memberInfo.role;
if (!canEdit || !role) {
return [];
}
const canRemoveMembers = threadHasPermission(
threadInfo,
threadPermissions.REMOVE_MEMBERS,
);
const canChangeRoles = threadHasPermission(
threadInfo,
threadPermissions.CHANGE_ROLE,
);
const result = [];
if (canChangeRoles && memberInfo.username && threadHasAdminRole(threadInfo)) {
result.push('change_role');
}
if (
canRemoveMembers &&
!memberInfo.isViewer &&
(canChangeRoles || threadInfo.roles[role]?.isDefault)
) {
result.push('remove_user');
}
return result;
}
function patchThreadInfoToIncludeMentionedMembersOfParent(
threadInfo: ThreadInfo,
parentThreadInfo: ThreadInfo,
messageText: string,
viewerID: string,
): ThreadInfo {
const members: UserIDAndUsername[] = threadInfo.members
.map(({ id, username }) => (username ? { id, username } : 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
| ThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo,
communityID: string,
): boolean {
return threadInfo.community === communityID || threadInfo.id === communityID;
}
type RoleAndMemberCount = {
[roleName: string]: number,
};
function useRoleMemberCountsForCommunity(
threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
): RoleAndMemberCount {
return React.useMemo(() => {
const roleIDsToNames = {};
Object.keys(threadInfo.roles).forEach(roleID => {
roleIDsToNames[roleID] = threadInfo.roles[roleID].name;
});
const roleNamesToMemberCount: RoleAndMemberCount = {};
threadInfo.members.forEach(({ role: roleID }) => {
invariant(roleID, 'Community member should have a role');
const roleName = roleIDsToNames[roleID];
roleNamesToMemberCount[roleName] =
(roleNamesToMemberCount[roleName] ?? 0) + 1;
});
// For all community roles with no members, add them to the list with 0
Object.keys(roleIDsToNames).forEach(roleName => {
if (roleNamesToMemberCount[roleIDsToNames[roleName]] === undefined) {
roleNamesToMemberCount[roleIDsToNames[roleName]] = 0;
}
});
return roleNamesToMemberCount;
}, [threadInfo]);
}
type RoleUserSurfacedPermissions = {
+[roleName: string]: $ReadOnlySet,
};
// Iterates through the existing roles in the community and 'reverse maps'
// the set of permission literals for each role to user-facing permission enums
// to help pre-populate the permission checkboxes when editing roles.
function useRoleUserSurfacedPermissions(
threadInfo: ThreadInfo,
): RoleUserSurfacedPermissions {
return React.useMemo(() => {
const roleNamesToPermissions = {};
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
| ThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo,
): string {
return threadTypeIsCommunityRoot(threadInfo.type)
? 'community'
: threadNoun(threadInfo.type, threadInfo.parentThreadID);
}
function getThreadsToDeleteText(
threadInfo:
| RawThreadInfo
| ThreadInfo
| MinimallyEncodedRawThreadInfo
| MinimallyEncodedThreadInfo,
): string {
return `${
threadTypeIsCommunityRoot(threadInfo.type)
? 'Subchannels and threads'
: 'Threads'
} within this ${communityOrThreadNoun(threadInfo)}`;
}
function useUserProfileThreadInfo(userInfo: ?UserInfo): ?UserProfileThreadInfo {
const userID = userInfo?.id;
const username = userInfo?.username;
const loggedInUserInfo = useLoggedInUserInfo();
const isViewerProfile = loggedInUserInfo?.id === userID;
const privateThreadInfosSelector = threadInfosSelectorForThreadType(
threadTypes.PRIVATE,
);
const privateThreadInfos = useSelector(privateThreadInfosSelector);
const personalThreadInfosSelector = threadInfosSelectorForThreadType(
threadTypes.PERSONAL,
);
const personalThreadInfos = useSelector(personalThreadInfosSelector);
const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector);
return React.useMemo(() => {
if (!loggedInUserInfo || !userID || !username) {
return null;
}
if (isViewerProfile) {
const privateThreadInfo: ?ThreadInfo = privateThreadInfos[0];
return privateThreadInfo ? { threadInfo: privateThreadInfo } : null;
}
if (usersWithPersonalThread.has(userID)) {
const personalThreadInfo: ?ThreadInfo = personalThreadInfos.find(
threadInfo =>
userID === getSingleOtherUser(threadInfo, loggedInUserInfo.id),
);
return personalThreadInfo ? { threadInfo: personalThreadInfo } : null;
}
const pendingPersonalThreadInfo = createPendingPersonalThread(
loggedInUserInfo,
userID,
username,
);
return pendingPersonalThreadInfo;
}, [
isViewerProfile,
loggedInUserInfo,
personalThreadInfos,
privateThreadInfos,
userID,
username,
usersWithPersonalThread,
]);
}
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 05c245c8f..c9ebf5d0e 100644
--- a/lib/shared/user-utils.js
+++ b/lib/shared/user-utils.js
@@ -1,48 +1,57 @@
// @flow
import { memberHasAdminPowers } from './thread-utils.js';
import { useENSNames } from '../hooks/ens-cache.js';
+import type {
+ MinimallyEncodedRawThreadInfo,
+ MinimallyEncodedThreadInfo,
+} from '../types/minimally-encoded-thread-permissions-types.js';
import type {
RawThreadInfo,
ServerThreadInfo,
ThreadInfo,
} from '../types/thread-types.js';
import type { UserInfo } from '../types/user-types.js';
import { useSelector } from '../utils/redux-utils.js';
function stringForUser(
user: ?{
+username?: ?string,
+isViewer?: ?boolean,
...
},
): string {
if (user?.isViewer) {
return 'you';
}
return stringForUserExplicit(user);
}
function stringForUserExplicit(user: ?{ +username: ?string, ... }): string {
if (user?.username) {
return user.username;
}
return 'anonymous';
}
function useKeyserverAdmin(
- community: ThreadInfo | RawThreadInfo | ServerThreadInfo,
+ community:
+ | ThreadInfo
+ | RawThreadInfo
+ | ServerThreadInfo
+ | MinimallyEncodedThreadInfo
+ | MinimallyEncodedRawThreadInfo,
): ?UserInfo {
const userInfos = useSelector(state => state.userStore.userInfos);
// This hack only works as long as there is only one admin
// Linear task to revert this:
// https://linear.app/comm/issue/ENG-1707/revert-fix-getting-the-keyserver-admin-info
const admin = community.members.find(memberHasAdminPowers);
const adminUserInfo = admin ? userInfos[admin.id] : undefined;
const [adminUserInfoWithENSName] = useENSNames([adminUserInfo]);
return adminUserInfoWithENSName;
}
export { stringForUser, stringForUserExplicit, useKeyserverAdmin };
diff --git a/lib/utils/drawer-utils.react.js b/lib/utils/drawer-utils.react.js
index 0801d0043..8440cacdf 100644
--- a/lib/utils/drawer-utils.react.js
+++ b/lib/utils/drawer-utils.react.js
@@ -1,105 +1,119 @@
// @flow
import { values } from './objects.js';
import { threadInFilterList, threadIsChannel } from '../shared/thread-utils.js';
+import type {
+ MinimallyEncodedRawThreadInfo,
+ MinimallyEncodedThreadInfo,
+} from '../types/minimally-encoded-thread-permissions-types.js';
import { communitySubthreads } from '../types/thread-types-enum.js';
import type {
RawThreadInfo,
ThreadInfo,
ResolvedThreadInfo,
} from '../types/thread-types.js';
export type CommunityDrawerItemData = {
+threadInfo: ThreadInfo,
+itemChildren: $ReadOnlyArray>,
+hasSubchannelsButton: boolean,
+labelStyle: T,
};
function createRecursiveDrawerItemsData(
childThreadInfosMap: { +[id: string]: $ReadOnlyArray },
communities: $ReadOnlyArray,
labelStyles: $ReadOnlyArray,
maxDepth: number,
): $ReadOnlyArray> {
const result = communities.map(community => ({
threadInfo: community,
itemChildren: [],
labelStyle: labelStyles[0],
hasSubchannelsButton: false,
}));
let queue = result.map(item => [item, 0]);
for (let i = 0; i < queue.length; i++) {
const [item, lvl] = queue[i];
const itemChildThreadInfos = childThreadInfosMap[item.threadInfo.id] ?? [];
if (lvl < maxDepth) {
item.itemChildren = itemChildThreadInfos
.filter(childItem => communitySubthreads.includes(childItem.type))
.map(childItem => ({
threadInfo: childItem,
itemChildren: [],
labelStyle: labelStyles[Math.min(lvl + 1, labelStyles.length - 1)],
hasSubchannelsButton:
lvl + 1 === maxDepth &&
threadHasSubchannels(childItem, childThreadInfosMap),
}));
queue = queue.concat(
item.itemChildren.map(childItem => [childItem, lvl + 1]),
);
}
}
return result;
}
function threadHasSubchannels(
- threadInfo: ThreadInfo,
- childThreadInfosMap: { +[id: string]: $ReadOnlyArray },
+ threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
+ childThreadInfosMap: {
+ +[id: string]:
+ | $ReadOnlyArray
+ | $ReadOnlyArray,
+ },
): boolean {
if (!childThreadInfosMap[threadInfo.id]?.length) {
return false;
}
return childThreadInfosMap[threadInfo.id].some(thread =>
threadIsChannel(thread),
);
}
function appendSuffix(
chats: $ReadOnlyArray,
): ResolvedThreadInfo[] {
const result = [];
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()})`;
}
result.push({ ...chat, uiName: name });
}
return result;
}
function filterThreadIDsBelongingToCommunity(
communityID: string,
- threadInfosObj: { +[id: string]: ThreadInfo | RawThreadInfo },
+ threadInfosObj: {
+ +[id: string]:
+ | ThreadInfo
+ | RawThreadInfo
+ | MinimallyEncodedThreadInfo
+ | MinimallyEncodedRawThreadInfo,
+ },
): $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-text.js b/lib/utils/entity-text.js
index 3753f782f..51e8bb89d 100644
--- a/lib/utils/entity-text.js
+++ b/lib/utils/entity-text.js
@@ -1,592 +1,604 @@
// @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 {
+ MinimallyEncodedRawThreadInfo,
+ MinimallyEncodedThreadInfo,
+} from '../types/minimally-encoded-thread-permissions-types.js';
import {
type ThreadType,
threadTypes,
threadTypeValidator,
} from '../types/thread-types-enum.js';
import { type RawThreadInfo, type 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 = [];
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: ThreadInfo,
+ +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
}
| {
+display?: 'shortName',
- +threadInfo: ThreadInfo | RawThreadInfo,
+ +threadInfo:
+ | ThreadInfo
+ | RawThreadInfo
+ | MinimallyEncodedThreadInfo
+ | MinimallyEncodedRawThreadInfo,
+subchannel?: ?boolean,
+possessive?: ?boolean,
}
| {
+display: 'alwaysDisplayShortName',
- +threadInfo: ThreadInfo | RawThreadInfo,
+ +threadInfo:
+ | ThreadInfo
+ | RawThreadInfo
+ | MinimallyEncodedThreadInfo
+ | MinimallyEncodedRawThreadInfo,
+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 = [];
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 = [];
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 fa7234959..f038646fa 100644
--- a/lib/utils/role-utils.js
+++ b/lib/utils/role-utils.js
@@ -1,126 +1,133 @@
// @flow
import * as React from 'react';
import { useSelector } from './redux-utils.js';
import { threadInfoSelector } from '../selectors/thread-selectors.js';
+import type {
+ MinimallyEncodedRelativeMemberInfo,
+ MinimallyEncodedRoleInfo,
+ MinimallyEncodedThreadInfo,
+} from '../types/minimally-encoded-thread-permissions-types.js';
import {
type UserSurfacedPermissionOption,
userSurfacedPermissions,
userSurfacedPermissionOptions,
} from '../types/thread-permission-types.js';
import { type ThreadType, threadTypes } from '../types/thread-types-enum.js';
import type {
ThreadInfo,
RelativeMemberInfo,
RoleInfo,
} from '../types/thread-types.js';
function useFilterPermissionOptionsByThreadType(
threadType: ThreadType,
): $ReadOnlySet {
// If the thread is a community announcement root, we want to allow
// the option to be voiced in the announcement channels. Otherwise,
// we want to remove that option from being configured since this will
// be guaranteed on the keyserver.
const shouldFilterVoicedInAnnouncementChannel =
threadType === threadTypes.COMMUNITY_ROOT;
return React.useMemo(() => {
if (!shouldFilterVoicedInAnnouncementChannel) {
return userSurfacedPermissionOptions;
}
return new Set(
[...userSurfacedPermissionOptions].filter(
option =>
option.userSurfacedPermission !==
userSurfacedPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS,
),
);
}, [shouldFilterVoicedInAnnouncementChannel]);
}
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: ThreadInfo,
- memberInfos: $ReadOnlyArray,
-): $ReadOnlyMap {
+ threadInfo: ThreadInfo | MinimallyEncodedThreadInfo,
+ memberInfos: $ReadOnlyArray<
+ RelativeMemberInfo | MinimallyEncodedRelativeMemberInfo,
+ >,
+): $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;
}
export {
useFilterPermissionOptionsByThreadType,
constructRoleDeletionMessagePrompt,
useRoleDeletableAndEditableStatus,
useRolesFromCommunityThreadInfo,
};
diff --git a/lib/utils/toggle-pin-utils.js b/lib/utils/toggle-pin-utils.js
index 417105788..08a34dedb 100644
--- a/lib/utils/toggle-pin-utils.js
+++ b/lib/utils/toggle-pin-utils.js
@@ -1,22 +1,30 @@
// @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 {
+ MinimallyEncodedRawThreadInfo,
+ MinimallyEncodedThreadInfo,
+} from '../types/minimally-encoded-thread-permissions-types.js';
import { threadPermissions } from '../types/thread-permission-types.js';
import type { RawThreadInfo, ThreadInfo } from '../types/thread-types.js';
function canToggleMessagePin(
messageInfo: RawMessageInfo | MessageInfo,
- threadInfo: RawThreadInfo | ThreadInfo,
+ threadInfo:
+ | RawThreadInfo
+ | ThreadInfo
+ | MinimallyEncodedRawThreadInfo
+ | MinimallyEncodedThreadInfo,
): boolean {
const isValidMessage = !isInvalidPinSourceForThread(messageInfo, threadInfo);
const hasManagePinsPermission = threadHasPermission(
threadInfo,
threadPermissions.MANAGE_PINS,
);
return isValidMessage && hasManagePinsPermission;
}
export { canToggleMessagePin };