diff --git a/lib/components/chat-mention-provider.react.js b/lib/components/chat-mention-provider.react.js
index 1c9250a06..1e6987bed 100644
--- a/lib/components/chat-mention-provider.react.js
+++ b/lib/components/chat-mention-provider.react.js
@@ -1,280 +1,279 @@
// @flow
import * as React from 'react';
import genesis from '../facts/genesis.js';
import { threadInfoSelector } from '../selectors/thread-selectors.js';
import SentencePrefixSearchIndex from '../shared/sentence-prefix-search-index.js';
import type {
ResolvedThreadInfo,
ThreadInfo,
} from '../types/minimally-encoded-thread-permissions-types.js';
import { threadTypes } from '../types/thread-types-enum.js';
import type {
ChatMentionCandidate,
ChatMentionCandidatesObj,
- LegacyThreadInfo,
} from '../types/thread-types.js';
import { useResolvedThreadInfosObj } from '../utils/entity-helpers.js';
import { getNameForThreadEntity } from '../utils/entity-text.js';
import { useSelector } from '../utils/redux-utils.js';
type Props = {
+children: React.Node,
};
export type ChatMentionContextType = {
+getChatMentionSearchIndex: (
- threadInfo: LegacyThreadInfo | ThreadInfo,
+ threadInfo: ThreadInfo,
) => SentencePrefixSearchIndex,
+communityThreadIDForGenesisThreads: { +[id: string]: string },
+chatMentionCandidatesObj: ChatMentionCandidatesObj,
};
const emptySearchIndex = new SentencePrefixSearchIndex();
const ChatMentionContext: React.Context =
React.createContext({
getChatMentionSearchIndex: () => emptySearchIndex,
communityThreadIDForGenesisThreads: {},
chatMentionCandidatesObj: {},
});
function ChatMentionContextProvider(props: Props): React.Node {
const { children } = props;
const { communityThreadIDForGenesisThreads, chatMentionCandidatesObj } =
useChatMentionCandidatesObjAndUtils();
const searchIndices = useChatMentionSearchIndex(chatMentionCandidatesObj);
const getChatMentionSearchIndex = React.useCallback(
- (threadInfo: LegacyThreadInfo | ThreadInfo) => {
+ (threadInfo: ThreadInfo) => {
if (threadInfo.community === genesis.id) {
return searchIndices[communityThreadIDForGenesisThreads[threadInfo.id]];
}
return searchIndices[threadInfo.community ?? threadInfo.id];
},
[communityThreadIDForGenesisThreads, searchIndices],
);
const value = React.useMemo(
() => ({
getChatMentionSearchIndex,
communityThreadIDForGenesisThreads,
chatMentionCandidatesObj,
}),
[
getChatMentionSearchIndex,
communityThreadIDForGenesisThreads,
chatMentionCandidatesObj,
],
);
return (
{children}
);
}
function getChatMentionCandidates(
threadInfos: { +[id: string]: ThreadInfo },
resolvedThreadInfos: {
+[id: string]: ResolvedThreadInfo,
},
): {
chatMentionCandidatesObj: ChatMentionCandidatesObj,
communityThreadIDForGenesisThreads: { +[id: string]: string },
} {
const result: {
[string]: {
[string]: ChatMentionCandidate,
},
} = {};
const visitedGenesisThreads = new Set();
const communityThreadIDForGenesisThreads: { [string]: string } = {};
for (const currentThreadID in resolvedThreadInfos) {
const currentResolvedThreadInfo = resolvedThreadInfos[currentThreadID];
const { community: currentThreadCommunity } = currentResolvedThreadInfo;
if (!currentThreadCommunity) {
if (!result[currentThreadID]) {
result[currentThreadID] = {
[currentThreadID]: {
threadInfo: currentResolvedThreadInfo,
rawChatName: threadInfos[currentThreadID].uiName,
},
};
}
continue;
}
if (!result[currentThreadCommunity]) {
result[currentThreadCommunity] = {};
result[currentThreadCommunity][currentThreadCommunity] = {
threadInfo: resolvedThreadInfos[currentThreadCommunity],
rawChatName: threadInfos[currentThreadCommunity].uiName,
};
}
// Handle GENESIS community case: mentioning inside GENESIS should only
// show chats and threads inside the top level that is below GENESIS.
if (
resolvedThreadInfos[currentThreadCommunity].type === threadTypes.GENESIS
) {
if (visitedGenesisThreads.has(currentThreadID)) {
continue;
}
const threadTraversePath = [currentResolvedThreadInfo];
visitedGenesisThreads.add(currentThreadID);
let currentlySelectedThreadID = currentResolvedThreadInfo.parentThreadID;
while (currentlySelectedThreadID) {
const currentlySelectedThreadInfo =
resolvedThreadInfos[currentlySelectedThreadID];
if (
visitedGenesisThreads.has(currentlySelectedThreadID) ||
!currentlySelectedThreadInfo ||
currentlySelectedThreadInfo.type === threadTypes.GENESIS
) {
break;
}
threadTraversePath.push(currentlySelectedThreadInfo);
visitedGenesisThreads.add(currentlySelectedThreadID);
currentlySelectedThreadID = currentlySelectedThreadInfo.parentThreadID;
}
const lastThreadInTraversePath =
threadTraversePath[threadTraversePath.length - 1];
let lastThreadInTraversePathParentID;
if (lastThreadInTraversePath.parentThreadID) {
lastThreadInTraversePathParentID = resolvedThreadInfos[
lastThreadInTraversePath.parentThreadID
]
? lastThreadInTraversePath.parentThreadID
: lastThreadInTraversePath.id;
} else {
lastThreadInTraversePathParentID = lastThreadInTraversePath.id;
}
if (
resolvedThreadInfos[lastThreadInTraversePathParentID].type ===
threadTypes.GENESIS
) {
if (!result[lastThreadInTraversePath.id]) {
result[lastThreadInTraversePath.id] = {};
}
for (const threadInfo of threadTraversePath) {
result[lastThreadInTraversePath.id][threadInfo.id] = {
threadInfo,
rawChatName: threadInfos[threadInfo.id].uiName,
};
communityThreadIDForGenesisThreads[threadInfo.id] =
lastThreadInTraversePath.id;
}
if (
lastThreadInTraversePath.type !== threadTypes.PERSONAL &&
lastThreadInTraversePath.type !== threadTypes.PRIVATE
) {
result[genesis.id][lastThreadInTraversePath.id] = {
threadInfo: lastThreadInTraversePath,
rawChatName: threadInfos[lastThreadInTraversePath.id].uiName,
};
}
} else {
if (
!communityThreadIDForGenesisThreads[lastThreadInTraversePathParentID]
) {
result[lastThreadInTraversePathParentID] = {};
communityThreadIDForGenesisThreads[lastThreadInTraversePathParentID] =
lastThreadInTraversePathParentID;
}
const lastThreadInTraversePathParentCommunityThreadID =
communityThreadIDForGenesisThreads[lastThreadInTraversePathParentID];
for (const threadInfo of threadTraversePath) {
result[lastThreadInTraversePathParentCommunityThreadID][
threadInfo.id
] = {
threadInfo,
rawChatName: threadInfos[threadInfo.id].uiName,
};
communityThreadIDForGenesisThreads[threadInfo.id] =
lastThreadInTraversePathParentCommunityThreadID;
}
}
continue;
}
result[currentThreadCommunity][currentThreadID] = {
threadInfo: currentResolvedThreadInfo,
rawChatName: threadInfos[currentThreadID].uiName,
};
}
return {
chatMentionCandidatesObj: result,
communityThreadIDForGenesisThreads,
};
}
// Without allAtOnce, useChatMentionCandidatesObjAndUtils is very expensive.
// useResolvedThreadInfosObj would trigger its recalculation for each ENS name
// as it streams in, but we would prefer to trigger its recaculation just once
// for every update of the underlying Redux data.
const useResolvedThreadInfosObjOptions = { allAtOnce: true };
function useChatMentionCandidatesObjAndUtils(): {
chatMentionCandidatesObj: ChatMentionCandidatesObj,
resolvedThreadInfos: {
+[id: string]: ResolvedThreadInfo,
},
communityThreadIDForGenesisThreads: { +[id: string]: string },
} {
const threadInfos = useSelector(threadInfoSelector);
const resolvedThreadInfos = useResolvedThreadInfosObj(
threadInfos,
useResolvedThreadInfosObjOptions,
);
const { chatMentionCandidatesObj, communityThreadIDForGenesisThreads } =
React.useMemo(
() => getChatMentionCandidates(threadInfos, resolvedThreadInfos),
[threadInfos, resolvedThreadInfos],
);
return {
chatMentionCandidatesObj,
resolvedThreadInfos,
communityThreadIDForGenesisThreads,
};
}
function useChatMentionSearchIndex(
chatMentionCandidatesObj: ChatMentionCandidatesObj,
): {
+[id: string]: SentencePrefixSearchIndex,
} {
return React.useMemo(() => {
const result: { [string]: SentencePrefixSearchIndex } = {};
for (const communityThreadID in chatMentionCandidatesObj) {
const searchIndex = new SentencePrefixSearchIndex();
const searchIndexEntries = [];
for (const threadID in chatMentionCandidatesObj[communityThreadID]) {
searchIndexEntries.push({
id: threadID,
uiName:
chatMentionCandidatesObj[communityThreadID][threadID].threadInfo
.uiName,
rawChatName:
chatMentionCandidatesObj[communityThreadID][threadID].rawChatName,
});
}
// Sort the keys so that the order of the search result is consistent
searchIndexEntries.sort(({ uiName: uiNameA }, { uiName: uiNameB }) =>
uiNameA.localeCompare(uiNameB),
);
for (const { id, uiName, rawChatName } of searchIndexEntries) {
const names = [uiName];
if (rawChatName) {
typeof rawChatName === 'string'
? names.push(rawChatName)
: names.push(getNameForThreadEntity(rawChatName));
}
searchIndex.addEntry(id, names.join(' '));
}
result[communityThreadID] = searchIndex;
}
return result;
}, [chatMentionCandidatesObj]);
}
export { ChatMentionContextProvider, ChatMentionContext };
diff --git a/lib/hooks/chat-mention-hooks.js b/lib/hooks/chat-mention-hooks.js
index 9183d1cee..64e1db2a6 100644
--- a/lib/hooks/chat-mention-hooks.js
+++ b/lib/hooks/chat-mention-hooks.js
@@ -1,48 +1,45 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import {
ChatMentionContext,
type ChatMentionContextType,
} from '../components/chat-mention-provider.react.js';
import genesis from '../facts/genesis.js';
import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js';
-import type {
- ChatMentionCandidates,
- LegacyThreadInfo,
-} from '../types/thread-types.js';
+import type { ChatMentionCandidates } from '../types/thread-types.js';
function useChatMentionContext(): ChatMentionContextType {
const context = React.useContext(ChatMentionContext);
invariant(context, 'ChatMentionContext not found');
return context;
}
function useThreadChatMentionCandidates(
- threadInfo: LegacyThreadInfo | ThreadInfo,
+ threadInfo: ThreadInfo,
): ChatMentionCandidates {
const { communityThreadIDForGenesisThreads, chatMentionCandidatesObj } =
useChatMentionContext();
return React.useMemo(() => {
const communityID =
threadInfo.community === genesis.id
? communityThreadIDForGenesisThreads[threadInfo.id]
: threadInfo.community ?? threadInfo.id;
const allChatsWithinCommunity = chatMentionCandidatesObj[communityID];
if (!allChatsWithinCommunity) {
return {};
}
const { [threadInfo.id]: _, ...result } = allChatsWithinCommunity;
return result;
}, [
chatMentionCandidatesObj,
communityThreadIDForGenesisThreads,
threadInfo.community,
threadInfo.id,
]);
}
export { useThreadChatMentionCandidates, useChatMentionContext };
diff --git a/lib/hooks/child-threads.js b/lib/hooks/child-threads.js
index 87379b9f8..be24acfcf 100644
--- a/lib/hooks/child-threads.js
+++ b/lib/hooks/child-threads.js
@@ -1,126 +1,125 @@
// @flow
import * as React from 'react';
import {
fetchSingleMostRecentMessagesFromThreadsActionTypes,
useFetchSingleMostRecentMessagesFromThreads,
} from '../actions/message-actions.js';
import {
type ChatThreadItem,
useFilteredChatListData,
} from '../selectors/chat-selectors.js';
import { useGlobalThreadSearchIndex } from '../selectors/nav-selectors.js';
import { childThreadInfos } from '../selectors/thread-selectors.js';
import { threadInChatList } from '../shared/thread-utils.js';
import threadWatcher from '../shared/thread-watcher.js';
import type {
ThreadInfo,
RawThreadInfo,
} from '../types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from '../types/thread-types.js';
import { useDispatchActionPromise } from '../utils/redux-promise-utils.js';
import { useSelector } from '../utils/redux-utils.js';
type ThreadFilter = {
- +predicate?: (thread: LegacyThreadInfo | ThreadInfo) => boolean,
+ +predicate?: (thread: ThreadInfo) => boolean,
+searchText?: string,
};
function useFilteredChildThreads(
threadID: string,
filter?: ThreadFilter,
): $ReadOnlyArray {
const defaultPredicate = React.useCallback(() => true, []);
const { predicate = defaultPredicate, searchText = '' } = filter ?? {};
const childThreads = useSelector(state => childThreadInfos(state)[threadID]);
const subchannelIDs = React.useMemo(() => {
if (!childThreads) {
return new Set();
}
return new Set(
childThreads.filter(predicate).map(threadInfo => threadInfo.id),
);
}, [childThreads, predicate]);
const filterSubchannels = React.useCallback(
- (thread: ?(RawThreadInfo | LegacyThreadInfo | ThreadInfo)) => {
+ (thread: ?(RawThreadInfo | ThreadInfo)) => {
const candidateThreadID = thread?.id;
if (!candidateThreadID) {
return false;
}
return subchannelIDs.has(candidateThreadID);
},
[subchannelIDs],
);
const allSubchannelsList = useFilteredChatListData(filterSubchannels);
const searchIndex = useGlobalThreadSearchIndex();
const searchResultIDs = React.useMemo(
() => searchIndex.getSearchResults(searchText),
[searchIndex, searchText],
);
const searchTextExists = !!searchText.length;
const subchannelIDsNotInChatList = React.useMemo(
() =>
new Set(
allSubchannelsList
.filter(item => !threadInChatList(item.threadInfo))
.map(item => item.threadInfo.id),
),
[allSubchannelsList],
);
React.useEffect(() => {
if (!subchannelIDsNotInChatList.size) {
return undefined;
}
subchannelIDsNotInChatList.forEach(tID => threadWatcher.watchID(tID));
return () =>
subchannelIDsNotInChatList.forEach(tID => threadWatcher.removeID(tID));
}, [subchannelIDsNotInChatList]);
const filteredSubchannelsChatList = React.useMemo(() => {
if (!searchTextExists) {
return allSubchannelsList;
}
return allSubchannelsList.filter(item =>
searchResultIDs.includes(item.threadInfo.id),
);
}, [allSubchannelsList, searchResultIDs, searchTextExists]);
const threadIDsWithNoMessages = React.useMemo(
() =>
new Set(
filteredSubchannelsChatList
.filter(item => !item.mostRecentMessageInfo)
.map(item => item.threadInfo.id),
),
[filteredSubchannelsChatList],
);
const dispatchActionPromise = useDispatchActionPromise();
const fetchSingleMostRecentMessages =
useFetchSingleMostRecentMessagesFromThreads();
React.useEffect(() => {
if (!threadIDsWithNoMessages.size) {
return;
}
void dispatchActionPromise(
fetchSingleMostRecentMessagesFromThreadsActionTypes,
fetchSingleMostRecentMessages(Array.from(threadIDsWithNoMessages)),
);
}, [
threadIDsWithNoMessages,
fetchSingleMostRecentMessages,
dispatchActionPromise,
]);
return filteredSubchannelsChatList;
}
export { useFilteredChildThreads };
diff --git a/lib/hooks/promote-sidebar.react.js b/lib/hooks/promote-sidebar.react.js
index b342548ef..031c85b6a 100644
--- a/lib/hooks/promote-sidebar.react.js
+++ b/lib/hooks/promote-sidebar.react.js
@@ -1,96 +1,94 @@
// @flow
import * as React from 'react';
import {
changeThreadSettingsActionTypes,
useChangeThreadSettings,
} from '../actions/thread-actions.js';
import { createLoadingStatusSelector } from '../selectors/loading-selectors.js';
import { threadInfoSelector } from '../selectors/thread-selectors.js';
import {
threadHasPermission,
threadIsSidebar,
} from '../shared/thread-utils.js';
import type { LoadingStatus } from '../types/loading-types.js';
import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js';
import { threadPermissions } from '../types/thread-permission-types.js';
import { threadTypes } from '../types/thread-types-enum.js';
-import type { LegacyThreadInfo } from '../types/thread-types.js';
import { useDispatchActionPromise } from '../utils/redux-promise-utils.js';
import { useSelector } from '../utils/redux-utils.js';
function canPromoteSidebar(
- sidebarThreadInfo: LegacyThreadInfo | ThreadInfo,
- parentThreadInfo: ?LegacyThreadInfo | ?ThreadInfo,
+ sidebarThreadInfo: ThreadInfo,
+ parentThreadInfo: ?ThreadInfo,
): boolean {
if (!threadIsSidebar(sidebarThreadInfo)) {
return false;
}
const canChangeThreadType = threadHasPermission(
sidebarThreadInfo,
threadPermissions.EDIT_PERMISSIONS,
);
const canCreateSubchannelsInParent = threadHasPermission(
parentThreadInfo,
threadPermissions.CREATE_SUBCHANNELS,
);
return canChangeThreadType && canCreateSubchannelsInParent;
}
type PromoteSidebarType = {
+onPromoteSidebar: () => void,
+loading: LoadingStatus,
+canPromoteSidebar: boolean,
};
function usePromoteSidebar(
- threadInfo: LegacyThreadInfo | ThreadInfo,
+ threadInfo: ThreadInfo,
onError?: () => mixed,
): PromoteSidebarType {
const dispatchActionPromise = useDispatchActionPromise();
const callChangeThreadSettings = useChangeThreadSettings();
const loadingStatusSelector = createLoadingStatusSelector(
changeThreadSettingsActionTypes,
);
const loadingStatus = useSelector(loadingStatusSelector);
const { parentThreadID } = threadInfo;
- const parentThreadInfo: ?LegacyThreadInfo | ?ThreadInfo = useSelector(
- state =>
- parentThreadID ? threadInfoSelector(state)[parentThreadID] : null,
+ const parentThreadInfo: ?ThreadInfo = useSelector(state =>
+ parentThreadID ? threadInfoSelector(state)[parentThreadID] : null,
);
const canPromote = canPromoteSidebar(threadInfo, parentThreadInfo);
const onClick = React.useCallback(() => {
try {
void dispatchActionPromise(
changeThreadSettingsActionTypes,
(async () => {
return await callChangeThreadSettings({
threadID: threadInfo.id,
changes: { type: threadTypes.COMMUNITY_OPEN_SUBTHREAD },
});
})(),
);
} catch (e) {
onError?.();
throw e;
}
}, [threadInfo.id, callChangeThreadSettings, dispatchActionPromise, onError]);
const returnValues = React.useMemo(
() => ({
onPromoteSidebar: onClick,
loading: loadingStatus,
canPromoteSidebar: canPromote,
}),
[onClick, loadingStatus, canPromote],
);
return returnValues;
}
export { usePromoteSidebar, canPromoteSidebar };
diff --git a/lib/hooks/relationship-prompt.js b/lib/hooks/relationship-prompt.js
index 234ce4795..7e255c224 100644
--- a/lib/hooks/relationship-prompt.js
+++ b/lib/hooks/relationship-prompt.js
@@ -1,128 +1,127 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import {
updateRelationships as serverUpdateRelationships,
updateRelationshipsActionTypes,
} from '../actions/relationship-actions.js';
import { getSingleOtherUser } from '../shared/thread-utils.js';
import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js';
import {
type RelationshipAction,
relationshipActions,
} from '../types/relationship-types.js';
-import type { LegacyThreadInfo } from '../types/thread-types.js';
import type { UserInfo } from '../types/user-types.js';
import { useLegacyAshoatKeyserverCall } from '../utils/action-utils.js';
import { useDispatchActionPromise } from '../utils/redux-promise-utils.js';
import { useSelector } from '../utils/redux-utils.js';
type RelationshipCallbacks = {
+blockUser: () => void,
+unblockUser: () => void,
+friendUser: () => void,
+unfriendUser: () => void,
};
type RelationshipPromptData = {
+otherUserInfo: ?UserInfo,
+callbacks: RelationshipCallbacks,
};
function useRelationshipPrompt(
- threadInfo: LegacyThreadInfo | ThreadInfo,
+ threadInfo: ThreadInfo,
onErrorCallback?: () => void,
pendingPersonalThreadUserInfo?: ?UserInfo,
): RelationshipPromptData {
// We're fetching the info from state because we need the most recent
// relationship status. Additionally, member info does not contain info
// about relationship.
const otherUserInfo = useSelector(state => {
const otherUserID =
getSingleOtherUser(threadInfo, state.currentUserInfo?.id) ??
pendingPersonalThreadUserInfo?.id;
const { userInfos } = state.userStore;
return otherUserID && userInfos[otherUserID]
? userInfos[otherUserID]
: pendingPersonalThreadUserInfo;
});
const callbacks = useRelationshipCallbacks(
otherUserInfo?.id,
onErrorCallback,
);
return React.useMemo(
() => ({
otherUserInfo,
callbacks,
}),
[callbacks, otherUserInfo],
);
}
function useRelationshipCallbacks(
otherUserID?: string,
onErrorCallback?: () => void,
): RelationshipCallbacks {
const callUpdateRelationships = useLegacyAshoatKeyserverCall(
serverUpdateRelationships,
);
const updateRelationship = React.useCallback(
async (action: RelationshipAction) => {
try {
invariant(otherUserID, 'Other user info id should be present');
return await callUpdateRelationships({
action,
userIDs: [otherUserID],
});
} catch (e) {
onErrorCallback?.();
throw e;
}
},
[callUpdateRelationships, onErrorCallback, otherUserID],
);
const dispatchActionPromise = useDispatchActionPromise();
const onButtonPress = React.useCallback(
(action: RelationshipAction) => {
void dispatchActionPromise(
updateRelationshipsActionTypes,
updateRelationship(action),
);
},
[dispatchActionPromise, updateRelationship],
);
const blockUser = React.useCallback(
() => onButtonPress(relationshipActions.BLOCK),
[onButtonPress],
);
const unblockUser = React.useCallback(
() => onButtonPress(relationshipActions.UNBLOCK),
[onButtonPress],
);
const friendUser = React.useCallback(
() => onButtonPress(relationshipActions.FRIEND),
[onButtonPress],
);
const unfriendUser = React.useCallback(
() => onButtonPress(relationshipActions.UNFRIEND),
[onButtonPress],
);
return React.useMemo(
() => ({
blockUser,
unblockUser,
friendUser,
unfriendUser,
}),
[blockUser, friendUser, unblockUser, unfriendUser],
);
}
export { useRelationshipPrompt, useRelationshipCallbacks };
diff --git a/lib/hooks/search-threads.js b/lib/hooks/search-threads.js
index f0ef539fc..89f3bd631 100644
--- a/lib/hooks/search-threads.js
+++ b/lib/hooks/search-threads.js
@@ -1,114 +1,114 @@
// @flow
import * as React from 'react';
import {
type ChatThreadItem,
useFilteredChatListData,
} from '../selectors/chat-selectors.js';
import { useThreadSearchIndex } from '../selectors/nav-selectors.js';
import { sidebarInfoSelector } from '../selectors/thread-selectors.js';
import { threadIsChannel } from '../shared/thread-utils.js';
import type { SetState } from '../types/hook-types.js';
import type {
ThreadInfo,
RawThreadInfo,
} from '../types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo, SidebarInfo } from '../types/thread-types.js';
+import type { SidebarInfo } from '../types/thread-types.js';
import { useSelector } from '../utils/redux-utils.js';
export type ThreadSearchState = {
+text: string,
+results: $ReadOnlySet,
};
type SearchThreadsResult = {
+listData: $ReadOnlyArray,
+searchState: ThreadSearchState,
+setSearchState: SetState,
+onChangeSearchInputText: (text: string) => mixed,
+clearQuery: (event: SyntheticEvent) => void,
};
function useSearchThreads(
- threadInfo: LegacyThreadInfo | ThreadInfo,
+ threadInfo: ThreadInfo,
childThreadInfos: $ReadOnlyArray,
): SearchThreadsResult {
const [searchState, setSearchState] = React.useState({
text: '',
results: new Set(),
});
const listData = React.useMemo(() => {
if (!searchState.text) {
return childThreadInfos;
}
return childThreadInfos.filter(thread =>
searchState.results.has(thread.threadInfo.id),
);
}, [childThreadInfos, searchState]);
const justThreadInfos = React.useMemo(
() => childThreadInfos.map(childThreadInfo => childThreadInfo.threadInfo),
[childThreadInfos],
);
const searchIndex = useThreadSearchIndex(justThreadInfos);
const onChangeSearchInputText = React.useCallback(
(text: string) => {
setSearchState({
text,
results: new Set(searchIndex.getSearchResults(text)),
});
},
[searchIndex, setSearchState],
);
const clearQuery = React.useCallback(
(event: SyntheticEvent) => {
event.preventDefault();
setSearchState({ text: '', results: new Set() });
},
[setSearchState],
);
return React.useMemo(
() => ({
listData,
searchState,
setSearchState,
onChangeSearchInputText,
clearQuery,
}),
[
listData,
setSearchState,
searchState,
onChangeSearchInputText,
clearQuery,
],
);
}
function useSearchSidebars(
- threadInfo: LegacyThreadInfo | ThreadInfo,
+ threadInfo: ThreadInfo,
): SearchThreadsResult {
const childThreadInfos = useSelector(
state => sidebarInfoSelector(state)[threadInfo.id] ?? [],
);
return useSearchThreads(threadInfo, childThreadInfos);
}
function useSearchSubchannels(
- threadInfo: LegacyThreadInfo | ThreadInfo,
+ threadInfo: ThreadInfo,
): SearchThreadsResult {
const filterFunc = React.useCallback(
- (thread: ?(LegacyThreadInfo | ThreadInfo | RawThreadInfo)) =>
+ (thread: ?(ThreadInfo | RawThreadInfo)) =>
threadIsChannel(thread) && thread?.parentThreadID === threadInfo.id,
[threadInfo.id],
);
const childThreadInfos = useFilteredChatListData(filterFunc);
return useSearchThreads(threadInfo, childThreadInfos);
}
export { useSearchSubchannels, useSearchSidebars };
diff --git a/lib/hooks/toggle-unread-status.js b/lib/hooks/toggle-unread-status.js
index 831288a6b..f9378e00b 100644
--- a/lib/hooks/toggle-unread-status.js
+++ b/lib/hooks/toggle-unread-status.js
@@ -1,55 +1,54 @@
// @flow
import * as React from 'react';
import {
setThreadUnreadStatusActionTypes,
useSetThreadUnreadStatus,
} from '../actions/activity-actions.js';
import type {
SetThreadUnreadStatusPayload,
SetThreadUnreadStatusRequest,
} from '../types/activity-types.js';
import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from '../types/thread-types.js';
import { useDispatchActionPromise } from '../utils/redux-promise-utils.js';
function useToggleUnreadStatus(
- threadInfo: LegacyThreadInfo | ThreadInfo,
+ threadInfo: ThreadInfo,
mostRecentNonLocalMessage: ?string,
afterAction: () => void,
): () => void {
const dispatchActionPromise = useDispatchActionPromise();
const { currentUser } = threadInfo;
const boundSetThreadUnreadStatus: (
request: SetThreadUnreadStatusRequest,
) => Promise = useSetThreadUnreadStatus();
const toggleUnreadStatus = React.useCallback(() => {
const request = {
threadID: threadInfo.id,
unread: !currentUser.unread,
latestMessage: mostRecentNonLocalMessage,
};
void dispatchActionPromise(
setThreadUnreadStatusActionTypes,
boundSetThreadUnreadStatus(request),
undefined,
({
threadID: threadInfo.id,
unread: !currentUser.unread,
}: { +threadID: string, +unread: boolean }),
);
afterAction();
}, [
threadInfo.id,
currentUser.unread,
mostRecentNonLocalMessage,
dispatchActionPromise,
afterAction,
boundSetThreadUnreadStatus,
]);
return toggleUnreadStatus;
}
export default useToggleUnreadStatus;
diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js
index 92558f6be..f577ed879 100644
--- a/lib/selectors/chat-selectors.js
+++ b/lib/selectors/chat-selectors.js
@@ -1,716 +1,713 @@
// @flow
import invariant from 'invariant';
import _filter from 'lodash/fp/filter.js';
import _flow from 'lodash/fp/flow.js';
import _map from 'lodash/fp/map.js';
import _orderBy from 'lodash/fp/orderBy.js';
import * as React from 'react';
import { createSelector } from 'reselect';
import { createObjectSelector } from 'reselect-map';
import {
sidebarInfoSelector,
threadInfoFromSourceMessageIDSelector,
threadInfoSelector,
} from './thread-selectors.js';
import {
createMessageInfo,
getMostRecentNonLocalMessageID,
messageKey,
robotextForMessageInfo,
sortMessageInfoList,
} from '../shared/message-utils.js';
import {
threadInChatList,
threadIsPending,
threadIsTopLevel,
} from '../shared/thread-utils.js';
import { messageTypes } from '../types/message-types-enum.js';
import {
type ComposableMessageInfo,
isComposableMessageType,
type LocalMessageInfo,
type MessageInfo,
type MessageStore,
type RobotextMessageInfo,
} from '../types/message-types.js';
import type {
ThreadInfo,
RawThreadInfo,
} from '../types/minimally-encoded-thread-permissions-types.js';
import type { BaseAppState } from '../types/redux-types.js';
import { threadTypes } from '../types/thread-types-enum.js';
import {
maxReadSidebars,
maxUnreadSidebars,
type SidebarInfo,
- type LegacyThreadInfo,
} from '../types/thread-types.js';
import type {
AccountUserInfo,
RelativeUserInfo,
UserInfo,
} from '../types/user-types.js';
import { threeDays } from '../utils/date-utils.js';
import type { EntityText } from '../utils/entity-text.js';
import memoize2 from '../utils/memoize.js';
import { useSelector } from '../utils/redux-utils.js';
export type SidebarItem =
| {
...SidebarInfo,
+type: 'sidebar',
}
| {
+type: 'seeMore',
+unread: boolean,
}
| { +type: 'spacer' };
export type ChatThreadItem = {
+type: 'chatThreadItem',
+threadInfo: ThreadInfo,
+mostRecentMessageInfo: ?MessageInfo,
+mostRecentNonLocalMessage: ?string,
+lastUpdatedTime: number,
+lastUpdatedTimeIncludingSidebars: number,
+sidebars: $ReadOnlyArray,
+pendingPersonalThreadUserInfo?: UserInfo,
};
const messageInfoSelector: (state: BaseAppState<>) => {
+[id: string]: ?MessageInfo,
} = createObjectSelector(
(state: BaseAppState<>) => state.messageStore.messages,
(state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id,
(state: BaseAppState<>) => state.userStore.userInfos,
threadInfoSelector,
createMessageInfo,
);
function isEmptyMediaMessage(messageInfo: MessageInfo): boolean {
return (
(messageInfo.type === messageTypes.MULTIMEDIA ||
messageInfo.type === messageTypes.IMAGES) &&
messageInfo.media.length === 0
);
}
function getMostRecentMessageInfo(
- threadInfo: LegacyThreadInfo | ThreadInfo,
+ threadInfo: ThreadInfo,
messageStore: MessageStore,
messages: { +[id: string]: ?MessageInfo },
): ?MessageInfo {
const thread = messageStore.threads[threadInfo.id];
if (!thread) {
return null;
}
for (const messageID of thread.messageIDs) {
const messageInfo = messages[messageID];
if (!messageInfo || isEmptyMediaMessage(messageInfo)) {
continue;
}
return messageInfo;
}
return null;
}
function getLastUpdatedTime(
- threadInfo: LegacyThreadInfo | ThreadInfo,
+ threadInfo: ThreadInfo,
mostRecentMessageInfo: ?MessageInfo,
): number {
return mostRecentMessageInfo
? mostRecentMessageInfo.time
: threadInfo.creationTime;
}
function createChatThreadItem(
threadInfo: ThreadInfo,
messageStore: MessageStore,
messages: { +[id: string]: ?MessageInfo },
sidebarInfos: ?$ReadOnlyArray,
): ChatThreadItem {
const mostRecentMessageInfo = getMostRecentMessageInfo(
threadInfo,
messageStore,
messages,
);
const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID(
threadInfo.id,
messageStore,
);
const lastUpdatedTime = getLastUpdatedTime(threadInfo, mostRecentMessageInfo);
const sidebars = sidebarInfos ?? [];
const allSidebarItems = sidebars.map(sidebarInfo => ({
type: 'sidebar',
...sidebarInfo,
}));
const lastUpdatedTimeIncludingSidebars =
allSidebarItems.length > 0
? Math.max(lastUpdatedTime, allSidebarItems[0].lastUpdatedTime)
: lastUpdatedTime;
const numUnreadSidebars = allSidebarItems.filter(
sidebar => sidebar.threadInfo.currentUser.unread,
).length;
let numReadSidebarsToShow = maxReadSidebars - numUnreadSidebars;
const threeDaysAgo = Date.now() - threeDays;
const sidebarItems: SidebarItem[] = [];
for (const sidebar of allSidebarItems) {
if (sidebarItems.length >= maxUnreadSidebars) {
break;
} else if (sidebar.threadInfo.currentUser.unread) {
sidebarItems.push(sidebar);
} else if (
sidebar.lastUpdatedTime > threeDaysAgo &&
numReadSidebarsToShow > 0
) {
sidebarItems.push(sidebar);
numReadSidebarsToShow--;
}
}
const numReadButRecentSidebars = allSidebarItems.filter(
sidebar =>
!sidebar.threadInfo.currentUser.unread &&
sidebar.lastUpdatedTime > threeDaysAgo,
).length;
if (
sidebarItems.length < numUnreadSidebars + numReadButRecentSidebars ||
(sidebarItems.length < allSidebarItems.length && sidebarItems.length > 0)
) {
sidebarItems.push({
type: 'seeMore',
unread: numUnreadSidebars > maxUnreadSidebars,
});
}
if (sidebarItems.length !== 0) {
sidebarItems.push({
type: 'spacer',
});
}
return {
type: 'chatThreadItem',
threadInfo,
mostRecentMessageInfo,
mostRecentNonLocalMessage,
lastUpdatedTime,
lastUpdatedTimeIncludingSidebars,
sidebars: sidebarItems,
};
}
const chatListData: (state: BaseAppState<>) => $ReadOnlyArray =
createSelector(
threadInfoSelector,
(state: BaseAppState<>) => state.messageStore,
messageInfoSelector,
sidebarInfoSelector,
(
threadInfos: {
+[id: string]: ThreadInfo,
},
messageStore: MessageStore,
messageInfos: { +[id: string]: ?MessageInfo },
sidebarInfos: { +[id: string]: $ReadOnlyArray },
): $ReadOnlyArray =>
getChatThreadItems(
threadInfos,
messageStore,
messageInfos,
sidebarInfos,
threadIsTopLevel,
),
);
function useFlattenedChatListData(): $ReadOnlyArray {
return useFilteredChatListData(threadInChatList);
}
function useFilteredChatListData(
- filterFunction: (
- threadInfo: ?(LegacyThreadInfo | ThreadInfo | RawThreadInfo),
- ) => boolean,
+ filterFunction: (threadInfo: ?(ThreadInfo | RawThreadInfo)) => boolean,
): $ReadOnlyArray {
const threadInfos = useSelector(threadInfoSelector);
const messageInfos = useSelector(messageInfoSelector);
const sidebarInfos = useSelector(sidebarInfoSelector);
const messageStore = useSelector(state => state.messageStore);
return React.useMemo(
() =>
getChatThreadItems(
threadInfos,
messageStore,
messageInfos,
sidebarInfos,
filterFunction,
),
[messageInfos, messageStore, sidebarInfos, filterFunction, threadInfos],
);
}
function getChatThreadItems(
threadInfos: { +[id: string]: ThreadInfo },
messageStore: MessageStore,
messageInfos: { +[id: string]: ?MessageInfo },
sidebarInfos: { +[id: string]: $ReadOnlyArray },
filterFunction: (threadInfo: ?(ThreadInfo | RawThreadInfo)) => boolean,
): $ReadOnlyArray {
return _flow(
_filter(filterFunction),
_map((threadInfo: ThreadInfo): ChatThreadItem =>
createChatThreadItem(
threadInfo,
messageStore,
messageInfos,
sidebarInfos[threadInfo.id],
),
),
_orderBy('lastUpdatedTimeIncludingSidebars')('desc'),
)(threadInfos);
}
export type RobotextChatMessageInfoItem = {
+itemType: 'message',
+messageInfoType: 'robotext',
+messageInfo: RobotextMessageInfo,
+startsConversation: boolean,
+startsCluster: boolean,
endsCluster: boolean,
+robotext: EntityText,
+threadCreatedFromMessage: ?ThreadInfo,
+reactions: ReactionInfo,
};
export type ChatMessageInfoItem =
| RobotextChatMessageInfoItem
| {
+itemType: 'message',
+messageInfoType: 'composable',
+messageInfo: ComposableMessageInfo,
+localMessageInfo: ?LocalMessageInfo,
+startsConversation: boolean,
+startsCluster: boolean,
endsCluster: boolean,
+threadCreatedFromMessage: ?ThreadInfo,
+reactions: ReactionInfo,
+hasBeenEdited: boolean,
+isPinned: boolean,
};
export type ChatMessageItem = { itemType: 'loader' } | ChatMessageInfoItem;
export type ReactionInfo = { +[reaction: string]: MessageReactionInfo };
type MessageReactionInfo = {
+viewerReacted: boolean,
+users: $ReadOnlyArray,
};
type TargetMessageReactions = Map>;
const msInFiveMinutes = 5 * 60 * 1000;
function createChatMessageItems(
threadID: string,
messageStore: MessageStore,
messageInfos: { +[id: string]: ?MessageInfo },
threadInfos: { +[id: string]: ThreadInfo },
threadInfoFromSourceMessageID: {
+[id: string]: ThreadInfo,
},
additionalMessages: $ReadOnlyArray,
viewerID: string,
): ChatMessageItem[] {
const thread = messageStore.threads[threadID];
const threadMessageInfos = (thread?.messageIDs ?? [])
.map((messageID: string) => messageInfos[messageID])
.filter(Boolean);
const messages =
additionalMessages.length > 0
? sortMessageInfoList([...threadMessageInfos, ...additionalMessages])
: threadMessageInfos;
const targetMessageReactionsMap = new Map();
// We need to iterate backwards to put the order of messages in chronological
// order, starting with the oldest. This avoids the scenario where the most
// recent message with the remove_reaction action may try to remove a user
// that hasn't been added to the messageReactionUsersInfoMap, causing it
// to be skipped.
for (let i = messages.length - 1; i >= 0; i--) {
const messageInfo = messages[i];
if (messageInfo.type !== messageTypes.REACTION) {
continue;
}
if (!targetMessageReactionsMap.has(messageInfo.targetMessageID)) {
const reactsMap: TargetMessageReactions = new Map();
targetMessageReactionsMap.set(messageInfo.targetMessageID, reactsMap);
}
const messageReactsMap = targetMessageReactionsMap.get(
messageInfo.targetMessageID,
);
invariant(messageReactsMap, 'messageReactsInfo should be set');
if (!messageReactsMap.has(messageInfo.reaction)) {
const usersInfoMap = new Map();
messageReactsMap.set(messageInfo.reaction, usersInfoMap);
}
const messageReactionUsersInfoMap = messageReactsMap.get(
messageInfo.reaction,
);
invariant(
messageReactionUsersInfoMap,
'messageReactionUsersInfoMap should be set',
);
if (messageInfo.action === 'add_reaction') {
messageReactionUsersInfoMap.set(
messageInfo.creator.id,
messageInfo.creator,
);
} else {
messageReactionUsersInfoMap.delete(messageInfo.creator.id);
}
}
const targetMessageEditMap = new Map();
for (let i = messages.length - 1; i >= 0; i--) {
const messageInfo = messages[i];
if (messageInfo.type !== messageTypes.EDIT_MESSAGE) {
continue;
}
targetMessageEditMap.set(messageInfo.targetMessageID, messageInfo.text);
}
const targetMessagePinStatusMap = new Map();
// Once again, we iterate backwards to put the order of messages in
// chronological order (i.e. oldest to newest) to handle pinned messages.
// This is important because we want to make sure that the most recent pin
// action is the one that is used to determine whether a message
// is pinned or not.
for (let i = messages.length - 1; i >= 0; i--) {
const messageInfo = messages[i];
if (messageInfo.type !== messageTypes.TOGGLE_PIN) {
continue;
}
targetMessagePinStatusMap.set(
messageInfo.targetMessageID,
messageInfo.action === 'pin',
);
}
const chatMessageItems: ChatMessageItem[] = [];
let lastMessageInfo = null;
for (let i = messages.length - 1; i >= 0; i--) {
const messageInfo = messages[i];
if (
messageInfo.type === messageTypes.REACTION ||
messageInfo.type === messageTypes.EDIT_MESSAGE
) {
continue;
}
let originalMessageInfo =
messageInfo.type === messageTypes.SIDEBAR_SOURCE
? messageInfo.sourceMessage
: messageInfo;
if (isEmptyMediaMessage(originalMessageInfo)) {
continue;
}
let hasBeenEdited = false;
if (
originalMessageInfo.type === messageTypes.TEXT &&
originalMessageInfo.id
) {
const newText = targetMessageEditMap.get(originalMessageInfo.id);
if (newText !== undefined) {
hasBeenEdited = true;
originalMessageInfo = {
...originalMessageInfo,
text: newText,
};
}
}
let startsConversation = true;
let startsCluster = true;
if (
lastMessageInfo &&
lastMessageInfo.time + msInFiveMinutes > originalMessageInfo.time
) {
startsConversation = false;
if (
isComposableMessageType(lastMessageInfo.type) &&
isComposableMessageType(originalMessageInfo.type) &&
lastMessageInfo.creator.id === originalMessageInfo.creator.id
) {
startsCluster = false;
}
}
if (startsCluster && chatMessageItems.length > 0) {
const lastMessageItem = chatMessageItems[chatMessageItems.length - 1];
invariant(lastMessageItem.itemType === 'message', 'should be message');
lastMessageItem.endsCluster = true;
}
const threadCreatedFromMessage =
messageInfo.id && threadInfos[threadID]?.type !== threadTypes.SIDEBAR
? threadInfoFromSourceMessageID[messageInfo.id]
: undefined;
const isPinned = !!(
originalMessageInfo.id &&
targetMessagePinStatusMap.get(originalMessageInfo.id)
);
const renderedReactions: ReactionInfo = (() => {
const result: { [string]: MessageReactionInfo } = {};
let messageReactsMap;
if (originalMessageInfo.id) {
messageReactsMap = targetMessageReactionsMap.get(
originalMessageInfo.id,
);
}
if (!messageReactsMap) {
return result;
}
for (const reaction of messageReactsMap.keys()) {
const reactionUsersInfoMap = messageReactsMap.get(reaction);
invariant(reactionUsersInfoMap, 'reactionUsersInfoMap should be set');
if (reactionUsersInfoMap.size === 0) {
continue;
}
const reactionUserInfos = [...reactionUsersInfoMap.values()];
const messageReactionInfo = {
users: reactionUserInfos,
viewerReacted: reactionUsersInfoMap.has(viewerID),
};
result[reaction] = messageReactionInfo;
}
return result;
})();
if (isComposableMessageType(originalMessageInfo.type)) {
// We use these invariants instead of just checking the messageInfo.type
// directly in the conditional above so that isComposableMessageType can
// be the source of truth
invariant(
originalMessageInfo.type === messageTypes.TEXT ||
originalMessageInfo.type === messageTypes.IMAGES ||
originalMessageInfo.type === messageTypes.MULTIMEDIA,
"Flow doesn't understand isComposableMessageType above",
);
const localMessageInfo =
messageStore.local[messageKey(originalMessageInfo)];
chatMessageItems.push({
itemType: 'message',
messageInfoType: 'composable',
messageInfo: originalMessageInfo,
localMessageInfo,
startsConversation,
startsCluster,
endsCluster: false,
threadCreatedFromMessage,
reactions: renderedReactions,
hasBeenEdited,
isPinned,
});
} else {
invariant(
originalMessageInfo.type !== messageTypes.TEXT &&
originalMessageInfo.type !== messageTypes.IMAGES &&
originalMessageInfo.type !== messageTypes.MULTIMEDIA,
"Flow doesn't understand isComposableMessageType above",
);
const threadInfo = threadInfos[threadID];
const parentThreadInfo = threadInfo?.parentThreadID
? threadInfos[threadInfo.parentThreadID]
: null;
const robotext = robotextForMessageInfo(
originalMessageInfo,
threadInfo,
parentThreadInfo,
);
chatMessageItems.push({
itemType: 'message',
messageInfoType: 'robotext',
messageInfo: originalMessageInfo,
startsConversation,
startsCluster,
endsCluster: false,
threadCreatedFromMessage,
robotext,
reactions: renderedReactions,
});
}
lastMessageInfo = originalMessageInfo;
}
if (chatMessageItems.length > 0) {
const lastMessageItem = chatMessageItems[chatMessageItems.length - 1];
invariant(lastMessageItem.itemType === 'message', 'should be message');
lastMessageItem.endsCluster = true;
}
chatMessageItems.reverse();
const hideSpinner = thread ? thread.startReached : threadIsPending(threadID);
if (hideSpinner) {
return chatMessageItems;
}
return [...chatMessageItems, ({ itemType: 'loader' }: ChatMessageItem)];
}
const baseMessageListData = (
threadID: ?string,
additionalMessages: $ReadOnlyArray,
): ((state: BaseAppState<>) => ?(ChatMessageItem[])) =>
createSelector(
(state: BaseAppState<>) => state.messageStore,
messageInfoSelector,
threadInfoSelector,
threadInfoFromSourceMessageIDSelector,
(state: BaseAppState<>) =>
state.currentUserInfo && state.currentUserInfo.id,
(
messageStore: MessageStore,
messageInfos: { +[id: string]: ?MessageInfo },
threadInfos: {
+[id: string]: ThreadInfo,
},
threadInfoFromSourceMessageID: {
+[id: string]: ThreadInfo,
},
viewerID: ?string,
): ?(ChatMessageItem[]) => {
if (!threadID || !viewerID) {
return null;
}
return createChatMessageItems(
threadID,
messageStore,
messageInfos,
threadInfos,
threadInfoFromSourceMessageID,
additionalMessages,
viewerID,
);
},
);
export type MessageListData = ?(ChatMessageItem[]);
const messageListData: (
threadID: ?string,
additionalMessages: $ReadOnlyArray,
) => (state: BaseAppState<>) => MessageListData = memoize2(baseMessageListData);
export type UseMessageListDataArgs = {
+searching: boolean,
+userInfoInputArray: $ReadOnlyArray,
- +threadInfo: ?LegacyThreadInfo | ?ThreadInfo,
+ +threadInfo: ?ThreadInfo,
};
function useMessageListData({
searching,
userInfoInputArray,
threadInfo,
}: UseMessageListDataArgs): MessageListData {
const messageInfos = useSelector(messageInfoSelector);
const containingThread = useSelector(state => {
if (
!threadInfo ||
threadInfo.type !== threadTypes.SIDEBAR ||
!threadInfo.containingThreadID
) {
return null;
}
return state.messageStore.threads[threadInfo.containingThreadID];
});
const pendingSidebarEditMessageInfo = React.useMemo(() => {
const sourceMessageID = threadInfo?.sourceMessageID;
const threadMessageInfos = (containingThread?.messageIDs ?? [])
.map((messageID: string) => messageInfos[messageID])
.filter(Boolean)
.filter(
message =>
message.type === messageTypes.EDIT_MESSAGE &&
message.targetMessageID === sourceMessageID,
);
if (threadMessageInfos.length === 0) {
return null;
}
return threadMessageInfos[0];
}, [threadInfo, containingThread, messageInfos]);
const pendingSidebarSourceMessageInfo = useSelector(state => {
const sourceMessageID = threadInfo?.sourceMessageID;
if (
!threadInfo ||
threadInfo.type !== threadTypes.SIDEBAR ||
!sourceMessageID
) {
return null;
}
const thread = state.messageStore.threads[threadInfo.id];
const shouldSourceBeAdded =
!thread ||
(thread.startReached &&
thread.messageIDs.every(
id => messageInfos[id]?.type !== messageTypes.SIDEBAR_SOURCE,
));
return shouldSourceBeAdded ? messageInfos[sourceMessageID] : null;
});
invariant(
!pendingSidebarSourceMessageInfo ||
pendingSidebarSourceMessageInfo.type !== messageTypes.SIDEBAR_SOURCE,
'sidebars can not be created from sidebar_source message',
);
const additionalMessages = React.useMemo(() => {
if (!pendingSidebarSourceMessageInfo) {
return ([]: MessageInfo[]);
}
const result: MessageInfo[] = [pendingSidebarSourceMessageInfo];
if (pendingSidebarEditMessageInfo) {
result.push(pendingSidebarEditMessageInfo);
}
return result;
}, [pendingSidebarSourceMessageInfo, pendingSidebarEditMessageInfo]);
const boundMessageListData = useSelector(
messageListData(threadInfo?.id, additionalMessages),
);
return React.useMemo(() => {
if (searching && userInfoInputArray.length === 0) {
return [];
}
return boundMessageListData;
}, [searching, userInfoInputArray.length, boundMessageListData]);
}
export {
messageInfoSelector,
createChatThreadItem,
chatListData,
createChatMessageItems,
messageListData,
useFlattenedChatListData,
useFilteredChatListData,
useMessageListData,
};
diff --git a/lib/selectors/nav-selectors.js b/lib/selectors/nav-selectors.js
index a56794b56..a23844eb7 100644
--- a/lib/selectors/nav-selectors.js
+++ b/lib/selectors/nav-selectors.js
@@ -1,217 +1,214 @@
// @flow
import * as React from 'react';
import { createSelector } from 'reselect';
import { useENSNames } from '../hooks/ens-cache.js';
import SearchIndex from '../shared/search-index.js';
import { memberHasAdminPowers } from '../shared/thread-utils.js';
import type { Platform } from '../types/device-types.js';
import {
type CalendarQuery,
defaultCalendarQuery,
} from '../types/entry-types.js';
import type { CalendarFilter } from '../types/filter-types.js';
import type {
ThreadInfo,
RawThreadInfo,
} from '../types/minimally-encoded-thread-permissions-types.js';
import type { BaseNavInfo } from '../types/nav-types.js';
import type { BaseAppState } from '../types/redux-types.js';
-import type {
- LegacyThreadInfo,
- RelativeMemberInfo,
-} from '../types/thread-types';
+import type { RelativeMemberInfo } from '../types/thread-types';
import type { UserInfo } from '../types/user-types.js';
import { getConfig } from '../utils/config.js';
import { values } from '../utils/objects.js';
import { useSelector } from '../utils/redux-utils.js';
function timeUntilCalendarRangeExpiration(
lastUserInteractionCalendar: number,
): ?number {
const inactivityLimit = getConfig().calendarRangeInactivityLimit;
if (inactivityLimit === null || inactivityLimit === undefined) {
return null;
}
return lastUserInteractionCalendar + inactivityLimit - Date.now();
}
function calendarRangeExpired(lastUserInteractionCalendar: number): boolean {
const timeUntil = timeUntilCalendarRangeExpiration(
lastUserInteractionCalendar,
);
if (timeUntil === null || timeUntil === undefined) {
return false;
}
return timeUntil <= 0;
}
const currentCalendarQuery: (
state: BaseAppState<>,
) => (calendarActive: boolean) => CalendarQuery = createSelector(
(state: BaseAppState<>) => state.entryStore.lastUserInteractionCalendar,
(state: BaseAppState<>) => state.navInfo,
(state: BaseAppState<>) => state.calendarFilters,
(
lastUserInteractionCalendar: number,
navInfo: BaseNavInfo,
calendarFilters: $ReadOnlyArray,
) => {
// Return a function since we depend on the time of evaluation
return (calendarActive: boolean, platform: ?Platform): CalendarQuery => {
if (calendarActive) {
return {
startDate: navInfo.startDate,
endDate: navInfo.endDate,
filters: calendarFilters,
};
}
if (calendarRangeExpired(lastUserInteractionCalendar)) {
return defaultCalendarQuery(platform);
}
return {
startDate: navInfo.startDate,
endDate: navInfo.endDate,
filters: calendarFilters,
};
};
},
);
// Without allAtOnce, useThreadSearchIndex and useUserSearchIndex are very
// expensive. useENSNames would trigger their recalculation for each ENS name
// as it streams in, but we would prefer to trigger their recaculation just
// once for every update of the underlying Redux data.
const useENSNamesOptions = { allAtOnce: true };
function useUserSearchIndex(
userInfos: $ReadOnlyArray,
): SearchIndex {
const membersWithENSNames = useENSNames(userInfos, useENSNamesOptions);
const memberMap = React.useMemo(() => {
const result = new Map();
for (const userInfo of membersWithENSNames) {
result.set(userInfo.id, userInfo);
}
return result;
}, [membersWithENSNames]);
return React.useMemo(() => {
const searchIndex = new SearchIndex();
for (const userInfo of userInfos) {
const searchTextArray = [];
const rawUsername = userInfo.username;
if (rawUsername) {
searchTextArray.push(rawUsername);
}
const resolvedUserInfo = memberMap.get(userInfo.id);
const resolvedUsername = resolvedUserInfo?.username;
if (resolvedUsername && resolvedUsername !== rawUsername) {
searchTextArray.push(resolvedUsername);
}
searchIndex.addEntry(userInfo.id, searchTextArray.join(' '));
}
return searchIndex;
}, [userInfos, memberMap]);
}
function useThreadSearchIndex(
- threadInfos: $ReadOnlyArray,
+ threadInfos: $ReadOnlyArray,
): SearchIndex {
const userInfos = useSelector(state => state.userStore.userInfos);
const viewerID = useSelector(
state => state.currentUserInfo && state.currentUserInfo.id,
);
const nonViewerMembers = React.useMemo(() => {
const allMembersOfAllThreads = new Map();
for (const threadInfo of threadInfos) {
for (const member of threadInfo.members) {
const isParentAdmin = memberHasAdminPowers(member);
if (!member.role && !isParentAdmin) {
continue;
}
if (member.id === viewerID) {
continue;
}
if (!allMembersOfAllThreads.has(member.id)) {
const userInfo = userInfos[member.id];
if (userInfo?.username) {
allMembersOfAllThreads.set(member.id, userInfo);
}
}
}
}
return [...allMembersOfAllThreads.values()];
}, [threadInfos, userInfos, viewerID]);
const nonViewerMembersWithENSNames = useENSNames(
nonViewerMembers,
useENSNamesOptions,
);
const memberMap = React.useMemo(() => {
const result = new Map();
for (const userInfo of nonViewerMembersWithENSNames) {
result.set(userInfo.id, userInfo);
}
return result;
}, [nonViewerMembersWithENSNames]);
return React.useMemo(() => {
const searchIndex = new SearchIndex();
for (const threadInfo of threadInfos) {
const searchTextArray = [];
if (threadInfo.name) {
searchTextArray.push(threadInfo.name);
}
if (threadInfo.description) {
searchTextArray.push(threadInfo.description);
}
for (const member of threadInfo.members) {
const isParentAdmin = memberHasAdminPowers(member);
if (!member.role && !isParentAdmin) {
continue;
}
if (member.id === viewerID) {
continue;
}
const userInfo = userInfos[member.id];
const rawUsername = userInfo?.username;
if (rawUsername) {
searchTextArray.push(rawUsername);
}
const resolvedUserInfo = memberMap.get(member.id);
const username = resolvedUserInfo?.username;
if (username && username !== rawUsername) {
searchTextArray.push(username);
}
}
searchIndex.addEntry(threadInfo.id, searchTextArray.join(' '));
}
return searchIndex;
}, [threadInfos, viewerID, userInfos, memberMap]);
}
function useGlobalThreadSearchIndex(): SearchIndex {
const threadInfos = useSelector(state => state.threadStore.threadInfos);
const threadInfosArray = React.useMemo(
() => values(threadInfos),
[threadInfos],
);
return useThreadSearchIndex(threadInfosArray);
}
export {
timeUntilCalendarRangeExpiration,
currentCalendarQuery,
useUserSearchIndex,
useThreadSearchIndex,
useGlobalThreadSearchIndex,
};
diff --git a/lib/shared/avatar-utils.js b/lib/shared/avatar-utils.js
index b0a27c592..657ca6c50 100644
--- a/lib/shared/avatar-utils.js
+++ b/lib/shared/avatar-utils.js
@@ -1,376 +1,367 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import stringHash from 'string-hash';
import { selectedThreadColors } from './color-utils.js';
import { threadOtherMembers } from './thread-utils.js';
import genesis from '../facts/genesis.js';
import { useENSAvatar } from '../hooks/ens-cache.js';
import { threadInfoSelector } from '../selectors/thread-selectors.js';
import { getETHAddressForUserInfo } from '../shared/account-utils.js';
import type {
ClientAvatar,
ClientEmojiAvatar,
GenericUserInfoWithAvatar,
ResolvedClientAvatar,
} from '../types/avatar-types.js';
import type {
ThreadInfo,
RawThreadInfo,
} from '../types/minimally-encoded-thread-permissions-types.js';
import { threadTypes } from '../types/thread-types-enum.js';
-import type {
- LegacyRawThreadInfo,
- LegacyThreadInfo,
-} from '../types/thread-types.js';
+import type { LegacyRawThreadInfo } from '../types/thread-types.js';
import type { UserInfos } from '../types/user-types.js';
import { useSelector } from '../utils/redux-utils.js';
import { ashoatKeyserverID } from '../utils/validation-utils.js';
const defaultAnonymousUserEmojiAvatar: ClientEmojiAvatar = {
color: selectedThreadColors[4],
emoji: '👤',
type: 'emoji',
};
const defaultEmojiAvatars: $ReadOnlyArray = [
{ color: selectedThreadColors[0], emoji: '😀', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '😃', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '😄', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '😁', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '😆', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '🙂', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '😉', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '😊', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '😇', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '🥰', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '😍', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '🤩', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '🥳', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '😝', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '😎', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '🧐', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🥸', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '🤗', type: 'emoji' },
{ color: selectedThreadColors[0], emoji: '😤', type: 'emoji' },
{ color: selectedThreadColors[0], emoji: '🤯', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '🤔', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '🫡', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '🤫', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '😮', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '😲', type: 'emoji' },
{ color: selectedThreadColors[5], emoji: '🤠', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '🤑', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '👩🚀', type: 'emoji' },
{ color: selectedThreadColors[2], emoji: '🥷', type: 'emoji' },
{ color: selectedThreadColors[5], emoji: '👻', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '👾', type: 'emoji' },
{ color: selectedThreadColors[2], emoji: '🤖', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '😺', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '😸', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '😹', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '😻', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '🎩', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '👑', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '🐶', type: 'emoji' },
{ color: selectedThreadColors[0], emoji: '🐱', type: 'emoji' },
{ color: selectedThreadColors[2], emoji: '🐭', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '🐹', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '🐰', type: 'emoji' },
{ color: selectedThreadColors[5], emoji: '🐻', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '🐼', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🐻❄️', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '🐨', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '🐯', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '🦁', type: 'emoji' },
{ color: selectedThreadColors[2], emoji: '🐸', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🐔', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '🐧', type: 'emoji' },
{ color: selectedThreadColors[5], emoji: '🐦', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '🐤', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🦄', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '🐝', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '🦋', type: 'emoji' },
{ color: selectedThreadColors[5], emoji: '🐬', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '🐳', type: 'emoji' },
{ color: selectedThreadColors[2], emoji: '🐋', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '🦈', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '🦭', type: 'emoji' },
{ color: selectedThreadColors[5], emoji: '🐘', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '🦛', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🐐', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '🐓', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '🦃', type: 'emoji' },
{ color: selectedThreadColors[0], emoji: '🦩', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '🦔', type: 'emoji' },
{ color: selectedThreadColors[2], emoji: '🐅', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '🐆', type: 'emoji' },
{ color: selectedThreadColors[5], emoji: '🦓', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '🦒', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '🦘', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🐎', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '🐕', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '🐩', type: 'emoji' },
{ color: selectedThreadColors[0], emoji: '🦮', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '🐈', type: 'emoji' },
{ color: selectedThreadColors[2], emoji: '🦚', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '🦜', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '🦢', type: 'emoji' },
{ color: selectedThreadColors[5], emoji: '🕊️', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '🐇', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🦦', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '🐿️', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '🐉', type: 'emoji' },
{ color: selectedThreadColors[0], emoji: '🌴', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🌱', type: 'emoji' },
{ color: selectedThreadColors[2], emoji: '☘️', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '🍀', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '🍄', type: 'emoji' },
{ color: selectedThreadColors[5], emoji: '🌿', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '🪴', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🍁', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '💐', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '🌷', type: 'emoji' },
{ color: selectedThreadColors[0], emoji: '🌹', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '🌸', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🌻', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '⭐', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '🌟', type: 'emoji' },
{ color: selectedThreadColors[5], emoji: '🍏', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '🍎', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🍐', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '🍊', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '🍋', type: 'emoji' },
{ color: selectedThreadColors[0], emoji: '🍓', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '🫐', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '🍈', type: 'emoji' },
{ color: selectedThreadColors[2], emoji: '🍒', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '🥭', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '🍍', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '🥝', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🍅', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '🥦', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '🥕', type: 'emoji' },
{ color: selectedThreadColors[0], emoji: '🥐', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '🥯', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '🍞', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '🥖', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '🥨', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '🧀', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '🥞', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🧇', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '🥓', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '🍔', type: 'emoji' },
{ color: selectedThreadColors[0], emoji: '🍟', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '🍕', type: 'emoji' },
{ color: selectedThreadColors[2], emoji: '🥗', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '🍝', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '🍜', type: 'emoji' },
{ color: selectedThreadColors[5], emoji: '🍲', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '🍛', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🍣', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '🍱', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '🥟', type: 'emoji' },
{ color: selectedThreadColors[0], emoji: '🍤', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '🍙', type: 'emoji' },
{ color: selectedThreadColors[2], emoji: '🍚', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '🍥', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '🍦', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '🧁', type: 'emoji' },
{ color: selectedThreadColors[5], emoji: '🍭', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🍩', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '🍪', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '☕️', type: 'emoji' },
{ color: selectedThreadColors[0], emoji: '🍵', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '⚽️', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🏀', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '🏈', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '⚾️', type: 'emoji' },
{ color: selectedThreadColors[0], emoji: '🥎', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '🎾', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🏐', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '🏉', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '🎱', type: 'emoji' },
{ color: selectedThreadColors[0], emoji: '🏆', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '🎨', type: 'emoji' },
{ color: selectedThreadColors[2], emoji: '🎤', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '🎧', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '🎼', type: 'emoji' },
{ color: selectedThreadColors[5], emoji: '🎹', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '🥁', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🎷', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '🎺', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '🎸', type: 'emoji' },
{ color: selectedThreadColors[0], emoji: '🪕', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '🎻', type: 'emoji' },
{ color: selectedThreadColors[2], emoji: '🎲', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '♟️', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '🎮', type: 'emoji' },
{ color: selectedThreadColors[5], emoji: '🚗', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '🚙', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🚌', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '🏎️', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '🛻', type: 'emoji' },
{ color: selectedThreadColors[0], emoji: '🚚', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🚛', type: 'emoji' },
{ color: selectedThreadColors[2], emoji: '🚘', type: 'emoji' },
{ color: selectedThreadColors[0], emoji: '🚀', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '🚁', type: 'emoji' },
{ color: selectedThreadColors[5], emoji: '🛶', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '⛵️', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🚤', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '⚓', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '🏰', type: 'emoji' },
{ color: selectedThreadColors[0], emoji: '🎡', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '💎', type: 'emoji' },
{ color: selectedThreadColors[2], emoji: '🔮', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '💈', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '🧸', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '🎊', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '🎉', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🪩', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '🚂', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '🚆', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '🚊', type: 'emoji' },
{ color: selectedThreadColors[1], emoji: '🛰️', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '🏠', type: 'emoji' },
{ color: selectedThreadColors[3], emoji: '⛰️', type: 'emoji' },
{ color: selectedThreadColors[4], emoji: '🏔️', type: 'emoji' },
{ color: selectedThreadColors[5], emoji: '🗻', type: 'emoji' },
{ color: selectedThreadColors[6], emoji: '🏛️', type: 'emoji' },
{ color: selectedThreadColors[7], emoji: '⛩️', type: 'emoji' },
{ color: selectedThreadColors[8], emoji: '🧲', type: 'emoji' },
{ color: selectedThreadColors[9], emoji: '🎁', type: 'emoji' },
];
function getRandomDefaultEmojiAvatar(): ClientEmojiAvatar {
const randomIndex = Math.floor(Math.random() * defaultEmojiAvatars.length);
return defaultEmojiAvatars[randomIndex];
}
function getDefaultAvatar(hashKey: string, color?: string): ClientEmojiAvatar {
let key = hashKey;
if (key.startsWith(`${ashoatKeyserverID}|`)) {
key = key.slice(`${ashoatKeyserverID}|`.length);
}
const avatarIndex = stringHash(key) % defaultEmojiAvatars.length;
return {
...defaultEmojiAvatars[avatarIndex],
color: color ? color : defaultEmojiAvatars[avatarIndex].color,
};
}
function getAvatarForUser(user: ?GenericUserInfoWithAvatar): ClientAvatar {
if (user?.avatar) {
return user.avatar;
}
if (!user?.username) {
return defaultAnonymousUserEmojiAvatar;
}
return getDefaultAvatar(user.username);
}
function getUserAvatarForThread(
- threadInfo:
- | LegacyRawThreadInfo
- | RawThreadInfo
- | LegacyThreadInfo
- | ThreadInfo,
+ threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo,
viewerID: ?string,
userInfos: UserInfos,
): ClientAvatar {
if (threadInfo.type === threadTypes.PRIVATE) {
invariant(viewerID, 'viewerID should be set for PRIVATE threads');
return getAvatarForUser(userInfos[viewerID]);
}
invariant(
threadInfo.type === threadTypes.PERSONAL,
'threadInfo should be a PERSONAL type',
);
const memberInfos = threadOtherMembers(threadInfo.members, viewerID)
.map(member => userInfos[member.id] && userInfos[member.id])
.filter(Boolean);
if (memberInfos.length === 0) {
return defaultAnonymousUserEmojiAvatar;
}
return getAvatarForUser(memberInfos[0]);
}
function getAvatarForThread(
- thread: RawThreadInfo | LegacyThreadInfo | ThreadInfo,
- containingThreadInfo: ?LegacyThreadInfo | ?ThreadInfo,
+ thread: RawThreadInfo | ThreadInfo,
+ containingThreadInfo: ?ThreadInfo,
): ClientAvatar {
if (thread.avatar) {
return thread.avatar;
}
if (containingThreadInfo && containingThreadInfo.id !== genesis.id) {
return containingThreadInfo.avatar
? containingThreadInfo.avatar
: getDefaultAvatar(containingThreadInfo.id, containingThreadInfo.color);
}
return getDefaultAvatar(thread.id, thread.color);
}
-function useAvatarForThread(
- thread: RawThreadInfo | LegacyThreadInfo | ThreadInfo,
-): ClientAvatar {
+function useAvatarForThread(thread: RawThreadInfo | ThreadInfo): ClientAvatar {
const containingThreadID = thread.containingThreadID;
const containingThreadInfo = useSelector(state =>
containingThreadID ? threadInfoSelector(state)[containingThreadID] : null,
);
return getAvatarForThread(thread, containingThreadInfo);
}
function useENSResolvedAvatar(
avatarInfo: ClientAvatar,
userInfo: ?GenericUserInfoWithAvatar,
): ResolvedClientAvatar {
const ethAddress = React.useMemo(
() => getETHAddressForUserInfo(userInfo),
[userInfo],
);
const ensAvatarURI = useENSAvatar(ethAddress);
const resolvedAvatar = React.useMemo(() => {
if (avatarInfo.type !== 'ens') {
return avatarInfo;
}
if (ensAvatarURI) {
return {
type: 'image',
uri: ensAvatarURI,
};
}
return defaultAnonymousUserEmojiAvatar;
}, [ensAvatarURI, avatarInfo]);
return resolvedAvatar;
}
export {
defaultAnonymousUserEmojiAvatar,
defaultEmojiAvatars,
getRandomDefaultEmojiAvatar,
getDefaultAvatar,
getAvatarForUser,
getUserAvatarForThread,
getAvatarForThread,
useAvatarForThread,
useENSResolvedAvatar,
};
diff --git a/lib/shared/edit-messages-utils.js b/lib/shared/edit-messages-utils.js
index 1dde2fab0..fcd604dcb 100644
--- a/lib/shared/edit-messages-utils.js
+++ b/lib/shared/edit-messages-utils.js
@@ -1,89 +1,88 @@
// @flow
import * as React from 'react';
import { threadHasPermission, threadIsPending } from './thread-utils.js';
import {
sendEditMessageActionTypes,
useSendEditMessage,
} from '../actions/message-actions.js';
import type {
ComposableMessageInfo,
RawMessageInfo,
RobotextMessageInfo,
SendEditMessageResult,
} from '../types/message-types';
import { messageTypes } from '../types/message-types-enum.js';
import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js';
import { threadPermissions } from '../types/thread-permission-types.js';
-import type { LegacyThreadInfo } from '../types/thread-types.js';
import { useDispatchActionPromise } from '../utils/redux-promise-utils.js';
import { useSelector } from '../utils/redux-utils.js';
function useEditMessage(): (
messageID: string,
newText: string,
) => Promise {
const callEditMessage = useSendEditMessage();
const dispatchActionPromise = useDispatchActionPromise();
return React.useCallback(
(messageID, newText) => {
const editMessagePromise = (async () => {
const result = await callEditMessage({
targetMessageID: messageID,
text: newText,
});
return ({
newMessageInfos: result.newMessageInfos,
}: { +newMessageInfos: $ReadOnlyArray });
})();
void dispatchActionPromise(
sendEditMessageActionTypes,
editMessagePromise,
);
return editMessagePromise;
},
[dispatchActionPromise, callEditMessage],
);
}
function useCanEditMessage(
- threadInfo: LegacyThreadInfo | ThreadInfo,
+ threadInfo: ThreadInfo,
targetMessageInfo: ComposableMessageInfo | RobotextMessageInfo,
): boolean {
const currentUserInfo = useSelector(state => state.currentUserInfo);
if (targetMessageInfo.type !== messageTypes.TEXT) {
return false;
}
if (!currentUserInfo || !currentUserInfo.id) {
return false;
}
const currentUserId = currentUserInfo.id;
const targetMessageCreatorId = targetMessageInfo.creator.id;
if (currentUserId !== targetMessageCreatorId) {
return false;
}
const hasPermission = threadHasPermission(
threadInfo,
threadPermissions.EDIT_MESSAGE,
);
return hasPermission;
}
function getMessageLabel(hasBeenEdited: ?boolean, threadID: string): ?string {
const isPending = threadIsPending(threadID);
if (hasBeenEdited && !isPending) {
return 'Edited';
}
return null;
}
export { useCanEditMessage, useEditMessage, getMessageLabel };
diff --git a/lib/shared/inline-engagement-utils.js b/lib/shared/inline-engagement-utils.js
index 7cbdf5eae..ebc39a4e7 100644
--- a/lib/shared/inline-engagement-utils.js
+++ b/lib/shared/inline-engagement-utils.js
@@ -1,28 +1,25 @@
// @flow
import type { ReactionInfo } from '../selectors/chat-selectors.js';
import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js';
-import type { LegacyThreadInfo } from '../types/thread-types.js';
-function getInlineEngagementSidebarText(
- threadInfo: ?LegacyThreadInfo | ?ThreadInfo,
-): string {
+function getInlineEngagementSidebarText(threadInfo: ?ThreadInfo): string {
if (!threadInfo) {
return '';
}
const repliesCount = threadInfo.repliesCount || 1;
return `${repliesCount} ${repliesCount > 1 ? 'replies' : 'reply'}`;
}
function reactionsToRawString(reactions: ReactionInfo): string {
const reactionStringParts = [];
for (const reaction in reactions) {
const reactionInfo = reactions[reaction];
reactionStringParts.push(`${reaction}${reactionInfo.users.length}`);
}
return reactionStringParts.join('');
}
export { getInlineEngagementSidebarText, reactionsToRawString };
diff --git a/lib/shared/mention-utils.js b/lib/shared/mention-utils.js
index 060f76a87..31789e23d 100644
--- a/lib/shared/mention-utils.js
+++ b/lib/shared/mention-utils.js
@@ -1,219 +1,218 @@
// @flow
import * as React from 'react';
import { markdownUserMentionRegexString } from './account-utils.js';
import SentencePrefixSearchIndex from './sentence-prefix-search-index.js';
import { stringForUserExplicit } from './user-utils.js';
import { useENSNames } from '../hooks/ens-cache.js';
import { useUserSearchIndex } from '../selectors/nav-selectors.js';
import type {
ResolvedThreadInfo,
ThreadInfo,
} from '../types/minimally-encoded-thread-permissions-types.js';
import { threadTypes } from '../types/thread-types-enum.js';
import type {
ChatMentionCandidates,
- LegacyThreadInfo,
RelativeMemberInfo,
} from '../types/thread-types.js';
import { chatNameMaxLength, idSchemaRegex } from '../utils/validation-utils.js';
export type TypeaheadMatchedStrings = {
+textBeforeAtSymbol: string,
+query: string,
};
export type Selection = {
+start: number,
+end: number,
};
type MentionTypeaheadUserSuggestionItem = {
+type: 'user',
+userInfo: RelativeMemberInfo,
};
type MentionTypeaheadChatSuggestionItem = {
+type: 'chat',
+threadInfo: ResolvedThreadInfo,
};
export type MentionTypeaheadSuggestionItem =
| MentionTypeaheadUserSuggestionItem
| MentionTypeaheadChatSuggestionItem;
export type TypeaheadTooltipActionItem = {
+key: string,
+execute: () => mixed,
+actionButtonContent: SuggestionItemType,
};
// The simple-markdown package already breaks words out for us, and we are
// supposed to only match when the first word of the input matches
const markdownUserMentionRegex: RegExp = new RegExp(
`^(@(${markdownUserMentionRegexString}))\\b`,
);
function isUserMentioned(username: string, text: string): boolean {
return new RegExp(`\\B@${username}\\b`, 'i').test(text);
}
const userMentionsExtractionRegex = new RegExp(
`\\B(@(${markdownUserMentionRegexString}))\\b`,
'g',
);
const chatMentionRegexString = `([^\\\\]|^)(@\\[\\[(${idSchemaRegex}):((.{0,${chatNameMaxLength}}?)(?!\\\\).|^)\\]\\])`;
const chatMentionRegex: RegExp = new RegExp(`^${chatMentionRegexString}`);
const globalChatMentionRegex: RegExp = new RegExp(chatMentionRegexString, 'g');
function encodeChatMentionText(text: string): string {
return text.replace(/]/g, '\\]');
}
function decodeChatMentionText(text: string): string {
return text.replace(/\\]/g, ']');
}
function getRawChatMention(threadInfo: ResolvedThreadInfo): string {
return `@[[${threadInfo.id}:${encodeChatMentionText(threadInfo.uiName)}]]`;
}
function renderChatMentionsWithAltText(text: string): string {
return text.replace(
globalChatMentionRegex,
(...match) => `${match[1]}@${decodeChatMentionText(match[4])}`,
);
}
function extractUserMentionsFromText(text: string): string[] {
const iterator = text.matchAll(userMentionsExtractionRegex);
return [...iterator].map(matches => matches[2]);
}
function getTypeaheadRegexMatches(
text: string,
selection: Selection,
regex: RegExp,
): null | RegExp$matchResult {
if (
selection.start === selection.end &&
(selection.start === text.length || /\s/.test(text[selection.end]))
) {
return text.slice(0, selection.start).match(regex);
}
return null;
}
const useENSNamesOptions = { allAtOnce: true };
function useMentionTypeaheadUserSuggestions(
threadMembers: $ReadOnlyArray,
typeaheadMatchedStrings: ?TypeaheadMatchedStrings,
): $ReadOnlyArray {
const userSearchIndex = useUserSearchIndex(threadMembers);
const resolvedThreadMembers = useENSNames(threadMembers, useENSNamesOptions);
const usernamePrefix: ?string = typeaheadMatchedStrings?.query;
return React.useMemo(() => {
// If typeaheadMatchedStrings is undefined, we want to return no results
if (usernamePrefix === undefined || usernamePrefix === null) {
return [];
}
const userIDs = userSearchIndex.getSearchResults(usernamePrefix);
const usersInThread = resolvedThreadMembers.filter(member => member.role);
return usersInThread
.filter(user => usernamePrefix.length === 0 || userIDs.includes(user.id))
.sort((userA, userB) =>
stringForUserExplicit(userA).localeCompare(
stringForUserExplicit(userB),
),
)
.map(userInfo => ({ type: 'user', userInfo }));
}, [userSearchIndex, resolvedThreadMembers, usernamePrefix]);
}
function useMentionTypeaheadChatSuggestions(
chatSearchIndex: SentencePrefixSearchIndex,
chatMentionCandidates: ChatMentionCandidates,
typeaheadMatchedStrings: ?TypeaheadMatchedStrings,
): $ReadOnlyArray {
const chatNamePrefix: ?string = typeaheadMatchedStrings?.query;
return React.useMemo(() => {
const result = [];
if (chatNamePrefix === undefined || chatNamePrefix === null) {
return result;
}
const threadIDs = chatSearchIndex.getSearchResults(chatNamePrefix);
for (const threadID of threadIDs) {
if (!chatMentionCandidates[threadID]) {
continue;
}
result.push({
type: 'chat',
threadInfo: chatMentionCandidates[threadID].threadInfo,
});
}
return result;
}, [chatSearchIndex, chatMentionCandidates, chatNamePrefix]);
}
function getNewTextAndSelection(
textBeforeAtSymbol: string,
entireText: string,
query: string,
suggestionText: string,
): {
newText: string,
newSelectionStart: number,
} {
const totalMatchLength = textBeforeAtSymbol.length + query.length + 1; // 1 for @ char
let newSuffixText = entireText.slice(totalMatchLength);
newSuffixText = (newSuffixText[0] !== ' ' ? ' ' : '') + newSuffixText;
const newText = textBeforeAtSymbol + suggestionText + newSuffixText;
const newSelectionStart = newText.length - newSuffixText.length + 1;
return { newText, newSelectionStart };
}
function useUserMentionsCandidates(
- threadInfo: LegacyThreadInfo | ThreadInfo,
- parentThreadInfo: ?LegacyThreadInfo | ?ThreadInfo,
+ threadInfo: ThreadInfo,
+ parentThreadInfo: ?ThreadInfo,
): $ReadOnlyArray {
return React.useMemo(() => {
if (threadInfo.type !== threadTypes.SIDEBAR) {
return threadInfo.members;
}
if (parentThreadInfo) {
return parentThreadInfo.members;
}
// This scenario should not occur unless the user logs out while looking at
// a sidebar. In that scenario, the Redux store may be cleared before
// ReactNav finishes transitioning away from the previous screen
return [];
}, [threadInfo, parentThreadInfo]);
}
export {
markdownUserMentionRegex,
isUserMentioned,
extractUserMentionsFromText,
useMentionTypeaheadUserSuggestions,
useMentionTypeaheadChatSuggestions,
getNewTextAndSelection,
getTypeaheadRegexMatches,
useUserMentionsCandidates,
chatMentionRegex,
encodeChatMentionText,
decodeChatMentionText,
getRawChatMention,
renderChatMentionsWithAltText,
};
diff --git a/lib/shared/reaction-utils.js b/lib/shared/reaction-utils.js
index f21ae1de6..961680d12 100644
--- a/lib/shared/reaction-utils.js
+++ b/lib/shared/reaction-utils.js
@@ -1,110 +1,109 @@
// @flow
import _sortBy from 'lodash/fp/sortBy.js';
import * as React from 'react';
import { relationshipBlockedInEitherDirection } from './relationship-utils.js';
import { threadHasPermission } from './thread-utils.js';
import { stringForUserExplicit } from './user-utils.js';
import { useENSNames } from '../hooks/ens-cache.js';
import type { ReactionInfo } from '../selectors/chat-selectors.js';
import type {
ComposableMessageInfo,
RobotextMessageInfo,
} from '../types/message-types.js';
import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js';
import { threadPermissions } from '../types/thread-permission-types.js';
-import type { LegacyThreadInfo } from '../types/thread-types.js';
import { useSelector } from '../utils/redux-utils.js';
function useViewerAlreadySelectedMessageReactions(
reactions: ReactionInfo,
): $ReadOnlyArray {
return React.useMemo(() => {
const alreadySelectedEmojis = [];
for (const reaction in reactions) {
const reactionInfo = reactions[reaction];
if (reactionInfo.viewerReacted) {
alreadySelectedEmojis.push(reaction);
}
}
return alreadySelectedEmojis;
}, [reactions]);
}
export type MessageReactionListInfo = {
+id: string,
+isViewer: boolean,
+reaction: string,
+username: string,
};
function useMessageReactionsList(
reactions: ReactionInfo,
): $ReadOnlyArray {
const withoutENSNames = React.useMemo(() => {
const result = [];
for (const reaction in reactions) {
const reactionInfo = reactions[reaction];
reactionInfo.users.forEach(user => {
result.push({
...user,
username: stringForUserExplicit(user),
reaction,
});
});
}
const sortByNumReactions = (reactionInfo: MessageReactionListInfo) => {
const numOfReactions = reactions[reactionInfo.reaction].users.length;
return numOfReactions ? -numOfReactions : 0;
};
return _sortBy(
([sortByNumReactions, 'username']: $ReadOnlyArray<
((reactionInfo: MessageReactionListInfo) => mixed) | string,
>),
)(result);
}, [reactions]);
return useENSNames(withoutENSNames);
}
function useCanCreateReactionFromMessage(
- threadInfo: LegacyThreadInfo | ThreadInfo,
+ threadInfo: ThreadInfo,
targetMessageInfo: ComposableMessageInfo | RobotextMessageInfo,
): boolean {
const targetMessageCreatorRelationship = useSelector(
state =>
state.userStore.userInfos[targetMessageInfo.creator.id]
?.relationshipStatus,
);
if (
!targetMessageInfo.id ||
threadInfo.sourceMessageID === targetMessageInfo.id
) {
return false;
}
const creatorRelationshipHasBlock =
targetMessageCreatorRelationship &&
relationshipBlockedInEitherDirection(targetMessageCreatorRelationship);
const hasPermission = threadHasPermission(
threadInfo,
threadPermissions.REACT_TO_MESSAGE,
);
return hasPermission && !creatorRelationshipHasBlock;
}
export {
useViewerAlreadySelectedMessageReactions,
useMessageReactionsList,
useCanCreateReactionFromMessage,
};
diff --git a/lib/shared/search-utils.js b/lib/shared/search-utils.js
index 1de0d7f1d..c7af8d084 100644
--- a/lib/shared/search-utils.js
+++ b/lib/shared/search-utils.js
@@ -1,452 +1,451 @@
// @flow
import * as React from 'react';
import { messageID } from './message-utils.js';
import SearchIndex from './search-index.js';
import {
getContainingThreadID,
threadMemberHasPermission,
userIsMember,
} from './thread-utils.js';
import {
searchMessagesActionTypes,
useSearchMessages as useSearchMessagesAction,
} from '../actions/message-actions.js';
import {
searchUsers,
searchUsersActionTypes,
} from '../actions/user-actions.js';
import { ENSCacheContext } from '../components/ens-cache-provider.react.js';
import genesis from '../facts/genesis.js';
import type {
ChatMessageInfoItem,
MessageListData,
} from '../selectors/chat-selectors.js';
import { useUserSearchIndex } from '../selectors/nav-selectors.js';
import { relationshipBlockedInEitherDirection } from '../shared/relationship-utils.js';
import type { MessageInfo, RawMessageInfo } from '../types/message-types.js';
import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js';
import { userRelationshipStatus } from '../types/relationship-types.js';
import { threadPermissions } from '../types/thread-permission-types.js';
import { type ThreadType, threadTypes } from '../types/thread-types-enum.js';
-import type { LegacyThreadInfo } from '../types/thread-types.js';
import type {
AccountUserInfo,
GlobalAccountUserInfo,
UserListItem,
} from '../types/user-types.js';
import { useLegacyAshoatKeyserverCall } from '../utils/action-utils.js';
import { isValidENSName } from '../utils/ens-helpers.js';
import { values } from '../utils/objects.js';
import { useDispatchActionPromise } from '../utils/redux-promise-utils.js';
import { useSelector } from '../utils/redux-utils.js';
const notFriendNotice = 'not friend';
function appendUserInfo({
results,
excludeUserIDs,
userInfo,
parentThreadInfo,
communityThreadInfo,
containingThreadInfo,
}: {
+results: {
[id: string]: {
...AccountUserInfo | GlobalAccountUserInfo,
isMemberOfParentThread: boolean,
isMemberOfContainingThread: boolean,
},
},
+excludeUserIDs: $ReadOnlyArray,
+userInfo: AccountUserInfo | GlobalAccountUserInfo,
- +parentThreadInfo: ?LegacyThreadInfo | ?ThreadInfo,
- +communityThreadInfo: ?LegacyThreadInfo | ?ThreadInfo,
- +containingThreadInfo: ?LegacyThreadInfo | ?ThreadInfo,
+ +parentThreadInfo: ?ThreadInfo,
+ +communityThreadInfo: ?ThreadInfo,
+ +containingThreadInfo: ?ThreadInfo,
}) {
const { id } = userInfo;
if (excludeUserIDs.includes(id) || id in results) {
return;
}
if (
communityThreadInfo &&
!threadMemberHasPermission(
communityThreadInfo,
id,
threadPermissions.KNOW_OF,
)
) {
return;
}
results[id] = {
...userInfo,
isMemberOfParentThread: userIsMember(parentThreadInfo, id),
isMemberOfContainingThread: userIsMember(containingThreadInfo, id),
};
}
function usePotentialMemberItems({
text,
userInfos,
excludeUserIDs,
includeServerSearchUsers,
inputParentThreadInfo,
inputCommunityThreadInfo,
threadType,
}: {
+text: string,
+userInfos: { +[id: string]: AccountUserInfo },
+excludeUserIDs: $ReadOnlyArray,
+includeServerSearchUsers?: $ReadOnlyArray,
- +inputParentThreadInfo?: ?LegacyThreadInfo | ?ThreadInfo,
- +inputCommunityThreadInfo?: ?LegacyThreadInfo | ?ThreadInfo,
+ +inputParentThreadInfo?: ?ThreadInfo,
+ +inputCommunityThreadInfo?: ?ThreadInfo,
+threadType?: ?ThreadType,
}): UserListItem[] {
const memoizedUserInfos = React.useMemo(() => values(userInfos), [userInfos]);
const searchIndex: SearchIndex = useUserSearchIndex(memoizedUserInfos);
const communityThreadInfo = React.useMemo(
() =>
inputCommunityThreadInfo && inputCommunityThreadInfo.id !== genesis.id
? inputCommunityThreadInfo
: null,
[inputCommunityThreadInfo],
);
const parentThreadInfo = React.useMemo(
() =>
inputParentThreadInfo && inputParentThreadInfo.id !== genesis.id
? inputParentThreadInfo
: null,
[inputParentThreadInfo],
);
const containgThreadID = threadType
? getContainingThreadID(parentThreadInfo, threadType)
: null;
const containingThreadInfo = React.useMemo(() => {
if (containgThreadID === parentThreadInfo?.id) {
return parentThreadInfo;
} else if (containgThreadID === communityThreadInfo?.id) {
return communityThreadInfo;
}
return null;
}, [containgThreadID, communityThreadInfo, parentThreadInfo]);
const filteredUserResults = React.useMemo(() => {
const results: {
[id: string]: {
...AccountUserInfo | GlobalAccountUserInfo,
isMemberOfParentThread: boolean,
isMemberOfContainingThread: boolean,
},
} = {};
if (text === '') {
for (const id in userInfos) {
appendUserInfo({
results,
excludeUserIDs,
userInfo: userInfos[id],
parentThreadInfo,
communityThreadInfo,
containingThreadInfo,
});
}
} else {
const ids = searchIndex.getSearchResults(text);
for (const id of ids) {
appendUserInfo({
results,
excludeUserIDs,
userInfo: userInfos[id],
parentThreadInfo,
communityThreadInfo,
containingThreadInfo,
});
}
}
if (includeServerSearchUsers) {
for (const userInfo of includeServerSearchUsers) {
appendUserInfo({
results,
excludeUserIDs,
userInfo,
parentThreadInfo,
communityThreadInfo,
containingThreadInfo,
});
}
}
let userResults = values(results);
if (text === '') {
userResults = userResults.filter(userInfo => {
if (!containingThreadInfo) {
return userInfo.relationshipStatus === userRelationshipStatus.FRIEND;
}
if (!userInfo.isMemberOfContainingThread) {
return false;
}
const { relationshipStatus } = userInfo;
if (!relationshipStatus) {
return true;
}
return !relationshipBlockedInEitherDirection(relationshipStatus);
});
}
return userResults;
}, [
text,
userInfos,
searchIndex,
excludeUserIDs,
includeServerSearchUsers,
parentThreadInfo,
containingThreadInfo,
communityThreadInfo,
]);
const sortedMembers = React.useMemo(() => {
const nonFriends = [];
const blockedUsers = [];
const friends = [];
const containingThreadMembers = [];
const parentThreadMembers = [];
for (const userResult of filteredUserResults) {
const { relationshipStatus } = userResult;
if (
relationshipStatus &&
relationshipBlockedInEitherDirection(relationshipStatus)
) {
blockedUsers.push(userResult);
} else if (userResult.isMemberOfParentThread) {
parentThreadMembers.push(userResult);
} else if (userResult.isMemberOfContainingThread) {
containingThreadMembers.push(userResult);
} else if (relationshipStatus === userRelationshipStatus.FRIEND) {
friends.push(userResult);
} else {
nonFriends.push(userResult);
}
}
const sortedResults = parentThreadMembers
.concat(containingThreadMembers)
.concat(friends)
.concat(nonFriends)
.concat(blockedUsers);
return sortedResults.map(
({
isMemberOfContainingThread,
isMemberOfParentThread,
relationshipStatus,
...result
}) => {
let notice, alert;
const username = result.username;
if (
relationshipStatus &&
relationshipBlockedInEitherDirection(relationshipStatus)
) {
notice = 'user is blocked';
alert = {
title: 'User is blocked',
text:
`Before you add ${username} to this chat, ` +
'you’ll need to unblock them. You can do this from the Block List ' +
'in the Profile tab.',
};
} else if (!isMemberOfContainingThread && containingThreadInfo) {
if (threadType !== threadTypes.SIDEBAR) {
notice = 'not in community';
alert = {
title: 'Not in community',
text: 'You can only add members of the community to this chat',
};
} else {
notice = 'not in parent chat';
alert = {
title: 'Not in parent chat',
text: 'You can only add members of the parent chat to a thread',
};
}
} else if (
!containingThreadInfo &&
relationshipStatus !== userRelationshipStatus.FRIEND
) {
notice = notFriendNotice;
alert = {
title: 'Not a friend',
text:
`Before you add ${username} to this chat, ` +
'you’ll need to send them a friend request. ' +
'You can do this from the Friend List in the Profile tab.',
};
} else if (parentThreadInfo && !isMemberOfParentThread) {
notice = 'not in parent chat';
}
if (notice) {
result = { ...result, notice };
}
if (alert) {
result = { ...result, alert };
}
return result;
},
);
}, [containingThreadInfo, filteredUserResults, parentThreadInfo, threadType]);
return sortedMembers;
}
function useSearchMessages(): (
query: string,
threadID: string,
onResultsReceived: (
messages: $ReadOnlyArray,
endReached: boolean,
queryID: number,
threadID: string,
) => mixed,
queryID: number,
cursor?: ?string,
) => void {
const callSearchMessages = useSearchMessagesAction();
const dispatchActionPromise = useDispatchActionPromise();
return React.useCallback(
(query, threadID, onResultsReceived, queryID, cursor) => {
const searchMessagesPromise = (async () => {
if (query === '') {
onResultsReceived([], true, queryID, threadID);
return;
}
const { messages, endReached } = await callSearchMessages({
query,
threadID,
cursor,
});
onResultsReceived(messages, endReached, queryID, threadID);
})();
void dispatchActionPromise(
searchMessagesActionTypes,
searchMessagesPromise,
);
},
[callSearchMessages, dispatchActionPromise],
);
}
function useForwardLookupSearchText(originalText: string): string {
const cacheContext = React.useContext(ENSCacheContext);
const { ensCache } = cacheContext;
const lowercaseText = originalText.toLowerCase();
const [usernameToSearch, setUsernameToSearch] =
React.useState(lowercaseText);
React.useEffect(() => {
void (async () => {
if (!ensCache || !isValidENSName(lowercaseText)) {
setUsernameToSearch(lowercaseText);
return;
}
const address = await ensCache.getAddressForName(lowercaseText);
if (address) {
setUsernameToSearch(address);
} else {
setUsernameToSearch(lowercaseText);
}
})();
}, [ensCache, lowercaseText]);
return usernameToSearch;
}
function useSearchUsers(
usernameInputText: string,
): $ReadOnlyArray {
const currentUserID = useSelector(
state => state.currentUserInfo && state.currentUserInfo.id,
);
const forwardLookupSearchText = useForwardLookupSearchText(usernameInputText);
const [serverSearchResults, setServerSearchResults] = React.useState<
$ReadOnlyArray,
>([]);
const callSearchUsers = useLegacyAshoatKeyserverCall(searchUsers);
const dispatchActionPromise = useDispatchActionPromise();
React.useEffect(() => {
if (forwardLookupSearchText.length === 0) {
setServerSearchResults([]);
return;
}
const searchUsersPromise = (async () => {
try {
const { userInfos } = await callSearchUsers(forwardLookupSearchText);
setServerSearchResults(
userInfos.filter(({ id }) => id !== currentUserID),
);
} catch (err) {
setServerSearchResults([]);
}
})();
void dispatchActionPromise(searchUsersActionTypes, searchUsersPromise);
}, [
callSearchUsers,
currentUserID,
dispatchActionPromise,
forwardLookupSearchText,
]);
return serverSearchResults;
}
function filterChatMessageInfosForSearch(
chatMessageInfos: MessageListData,
translatedSearchResults: $ReadOnlyArray,
): ?(ChatMessageInfoItem[]) {
if (!chatMessageInfos) {
return null;
}
const idSet = new Set(translatedSearchResults.map(messageID));
const uniqueChatMessageInfoItemsMap = new Map();
for (const item of chatMessageInfos) {
if (item.itemType !== 'message' || item.messageInfoType !== 'composable') {
continue;
}
const id = messageID(item.messageInfo);
if (idSet.has(id)) {
uniqueChatMessageInfoItemsMap.set(id, item);
}
}
const sortedChatMessageInfoItems: ChatMessageInfoItem[] = [];
for (let i = 0; i < translatedSearchResults.length; i++) {
const id = messageID(translatedSearchResults[i]);
const match = uniqueChatMessageInfoItemsMap.get(id);
if (match) {
sortedChatMessageInfoItems.push(match);
}
}
return sortedChatMessageInfoItems;
}
export {
usePotentialMemberItems,
notFriendNotice,
useSearchMessages,
useSearchUsers,
filterChatMessageInfosForSearch,
useForwardLookupSearchText,
};
diff --git a/lib/shared/user-utils.js b/lib/shared/user-utils.js
index 7a962b38e..3bdedb2ee 100644
--- a/lib/shared/user-utils.js
+++ b/lib/shared/user-utils.js
@@ -1,51 +1,48 @@
// @flow
import { memberHasAdminPowers } from './thread-utils.js';
import { useENSNames } from '../hooks/ens-cache.js';
import type {
ThreadInfo,
RawThreadInfo,
} from '../types/minimally-encoded-thread-permissions-types.js';
-import type {
- LegacyThreadInfo,
- ServerThreadInfo,
-} from '../types/thread-types.js';
+import type { ServerThreadInfo } from '../types/thread-types.js';
import type { UserInfo } from '../types/user-types.js';
import { useSelector } from '../utils/redux-utils.js';
function stringForUser(
user: ?{
+username?: ?string,
+isViewer?: ?boolean,
...
},
): string {
if (user?.isViewer) {
return 'you';
}
return stringForUserExplicit(user);
}
function stringForUserExplicit(user: ?{ +username: ?string, ... }): string {
if (user?.username) {
return user.username;
}
return 'anonymous';
}
function useKeyserverAdmin(
- community: LegacyThreadInfo | ThreadInfo | RawThreadInfo | ServerThreadInfo,
+ community: ThreadInfo | RawThreadInfo | ServerThreadInfo,
): ?UserInfo {
const userInfos = useSelector(state => state.userStore.userInfos);
// This hack only works as long as there is only one admin
// Linear task to revert this:
// https://linear.app/comm/issue/ENG-1707/revert-fix-getting-the-keyserver-admin-info
const admin = community.members.find(memberHasAdminPowers);
const adminUserInfo = admin ? userInfos[admin.id] : undefined;
const [adminUserInfoWithENSName] = useENSNames([adminUserInfo]);
return adminUserInfoWithENSName;
}
export { stringForUser, stringForUserExplicit, useKeyserverAdmin };
diff --git a/lib/utils/drawer-utils.react.js b/lib/utils/drawer-utils.react.js
index 25a4f1d48..5c4213432 100644
--- a/lib/utils/drawer-utils.react.js
+++ b/lib/utils/drawer-utils.react.js
@@ -1,126 +1,125 @@
// @flow
import * as React from 'react';
import { values } from './objects.js';
import { threadInFilterList, threadIsChannel } from '../shared/thread-utils.js';
import type {
ResolvedThreadInfo,
ThreadInfo,
RawThreadInfo,
} from '../types/minimally-encoded-thread-permissions-types.js';
import { communitySubthreads } from '../types/thread-types-enum.js';
-import type { LegacyThreadInfo } from '../types/thread-types.js';
type WritableCommunityDrawerItemData = {
threadInfo: ThreadInfo,
itemChildren: $ReadOnlyArray>,
hasSubchannelsButton: boolean,
labelStyle: T,
};
export type CommunityDrawerItemData = $ReadOnly<
WritableCommunityDrawerItemData,
>;
function createRecursiveDrawerItemsData(
childThreadInfosMap: {
+[id: string]: $ReadOnlyArray,
},
communities: $ReadOnlyArray,
labelStyles: $ReadOnlyArray,
maxDepth: number,
): $ReadOnlyArray> {
const result: $ReadOnlyArray<
WritableCommunityDrawerItemData,
> = communities.map(community => ({
threadInfo: community,
itemChildren: [],
labelStyle: labelStyles[0],
hasSubchannelsButton: false,
}));
let queue = result.map(item => [item, 0]);
for (let i = 0; i < queue.length; i++) {
const [item, lvl] = queue[i];
const itemChildThreadInfos = childThreadInfosMap[item.threadInfo.id] ?? [];
if (lvl < maxDepth) {
item.itemChildren = itemChildThreadInfos
.filter(childItem => communitySubthreads.includes(childItem.type))
.map(childItem => ({
threadInfo: childItem,
itemChildren: [],
labelStyle: labelStyles[Math.min(lvl + 1, labelStyles.length - 1)],
hasSubchannelsButton:
lvl + 1 === maxDepth &&
threadHasSubchannels(childItem, childThreadInfosMap),
}));
queue = queue.concat(
item.itemChildren.map(childItem => [childItem, lvl + 1]),
);
}
}
return result;
}
function threadHasSubchannels(
threadInfo: ThreadInfo,
childThreadInfosMap: {
+[id: string]: $ReadOnlyArray,
},
): boolean {
if (!childThreadInfosMap[threadInfo.id]?.length) {
return false;
}
return childThreadInfosMap[threadInfo.id].some(thread =>
threadIsChannel(thread),
);
}
function useAppendCommunitySuffix(
communities: $ReadOnlyArray,
): $ReadOnlyArray {
return React.useMemo(() => {
const result: ResolvedThreadInfo[] = [];
const names = new Map();
for (const chat of communities) {
let name = chat.uiName;
const numberOfOccurrences = names.get(name);
names.set(name, (numberOfOccurrences ?? 0) + 1);
if (numberOfOccurrences) {
name = `${name} (${numberOfOccurrences.toString()})`;
}
// Branching to appease `flow`.
if (chat.minimallyEncoded) {
result.push({ ...chat, uiName: name });
} else {
result.push({ ...chat, uiName: name });
}
}
return result;
}, [communities]);
}
function filterThreadIDsBelongingToCommunity(
communityID: string,
threadInfosObj: {
- +[id: string]: RawThreadInfo | LegacyThreadInfo | ThreadInfo,
+ +[id: string]: RawThreadInfo | ThreadInfo,
},
): $ReadOnlySet {
const threadInfos = values(threadInfosObj);
const threadIDs = threadInfos
.filter(
thread =>
(thread.community === communityID || thread.id === communityID) &&
threadInFilterList(thread),
)
.map(item => item.id);
return new Set(threadIDs);
}
export {
createRecursiveDrawerItemsData,
useAppendCommunitySuffix,
filterThreadIDsBelongingToCommunity,
};
diff --git a/lib/utils/message-pinning-utils.js b/lib/utils/message-pinning-utils.js
index e4d8c658f..0383ee548 100644
--- a/lib/utils/message-pinning-utils.js
+++ b/lib/utils/message-pinning-utils.js
@@ -1,39 +1,32 @@
// @flow
import { isInvalidPinSourceForThread } from '../shared/message-utils.js';
import { threadHasPermission } from '../shared/thread-utils.js';
import type { MessageInfo, RawMessageInfo } from '../types/message-types.js';
import type {
ThreadInfo,
RawThreadInfo,
} from '../types/minimally-encoded-thread-permissions-types.js';
import { threadPermissions } from '../types/thread-permission-types.js';
-import type {
- LegacyRawThreadInfo,
- LegacyThreadInfo,
-} from '../types/thread-types.js';
+import type { LegacyRawThreadInfo } from '../types/thread-types.js';
function canToggleMessagePin(
messageInfo: RawMessageInfo | MessageInfo,
- threadInfo:
- | LegacyRawThreadInfo
- | RawThreadInfo
- | LegacyThreadInfo
- | ThreadInfo,
+ threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo,
): boolean {
const isValidMessage = !isInvalidPinSourceForThread(messageInfo, threadInfo);
const hasManagePinsPermission = threadHasPermission(
threadInfo,
threadPermissions.MANAGE_PINS,
);
return isValidMessage && hasManagePinsPermission;
}
function pinnedMessageCountText(pinnedCount: number): string {
const messageNoun = pinnedCount === 1 ? 'message' : 'messages';
return `${pinnedCount} pinned ${messageNoun}`;
}
export { canToggleMessagePin, pinnedMessageCountText };
diff --git a/lib/utils/role-utils.js b/lib/utils/role-utils.js
index 3bf439409..18cf21316 100644
--- a/lib/utils/role-utils.js
+++ b/lib/utils/role-utils.js
@@ -1,128 +1,124 @@
// @flow
import * as React from 'react';
import { useSelector } from './redux-utils.js';
import { threadInfoSelector } from '../selectors/thread-selectors.js';
import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js';
import {
configurableCommunityPermissions,
type ThreadRolePermissionsBlob,
type UserSurfacedPermission,
} from '../types/thread-permission-types.js';
-import type {
- LegacyThreadInfo,
- RelativeMemberInfo,
- RoleInfo,
-} from '../types/thread-types';
+import type { RelativeMemberInfo, RoleInfo } from '../types/thread-types';
import { threadTypes } from '../types/thread-types-enum.js';
function constructRoleDeletionMessagePrompt(
defaultRoleName: string,
memberCount: number,
): string {
let message;
if (memberCount === 0) {
message = 'Are you sure you want to delete this role?';
} else {
const messageNoun = memberCount === 1 ? 'member' : 'members';
const messageVerb = memberCount === 1 ? 'is' : 'are';
message =
`There ${messageVerb} currently ${memberCount} ${messageNoun} with ` +
`this role. Deleting the role will automatically assign the ` +
`${messageNoun} affected to the ${defaultRoleName} role.`;
}
return message;
}
type RoleDeletableAndEditableStatus = {
+isDeletable: boolean,
+isEditable: boolean,
};
function useRoleDeletableAndEditableStatus(
roleName: string,
defaultRoleID: string,
existingRoleID: string,
): RoleDeletableAndEditableStatus {
return React.useMemo(() => {
const canDelete = roleName !== 'Admins' && defaultRoleID !== existingRoleID;
const canEdit = roleName !== 'Admins';
return {
isDeletable: canDelete,
isEditable: canEdit,
};
}, [roleName, defaultRoleID, existingRoleID]);
}
function useRolesFromCommunityThreadInfo(
- threadInfo: LegacyThreadInfo | ThreadInfo,
+ threadInfo: ThreadInfo,
memberInfos: $ReadOnlyArray,
): $ReadOnlyMap {
// Our in-code system has chat-specific roles, while the
// user-surfaced system has roles only for communities. We retrieve roles
// from the top-level community thread for accuracy, with a rare fallback
// for potential issues reading memberInfos, primarily in GENESIS threads.
// The special case is GENESIS threads, since per prior discussion
// (see context: https://linear.app/comm/issue/ENG-4077/), we don't really
// support roles for it. Also with GENESIS, the list of members are not
// populated in the community root. So in this case to prevent crashing, we
// should just return the role name from the current thread info.
const { community } = threadInfo;
const communityThreadInfo = useSelector(state =>
community ? threadInfoSelector(state)[community] : null,
);
const topMostThreadInfo = communityThreadInfo || threadInfo;
const roleMap = new Map();
if (topMostThreadInfo.type === threadTypes.GENESIS) {
memberInfos.forEach(memberInfo =>
roleMap.set(
memberInfo.id,
memberInfo.role ? threadInfo.roles[memberInfo.role] : null,
),
);
return roleMap;
}
const { members: memberInfosFromTopMostThreadInfo, roles } =
topMostThreadInfo;
memberInfosFromTopMostThreadInfo.forEach(memberInfo => {
roleMap.set(memberInfo.id, memberInfo.role ? roles[memberInfo.role] : null);
});
return roleMap;
}
function toggleUserSurfacedPermission(
rolePermissions: ThreadRolePermissionsBlob,
userSurfacedPermission: UserSurfacedPermission,
): ThreadRolePermissionsBlob {
const userSurfacedPermissionSet = Array.from(
configurableCommunityPermissions[userSurfacedPermission],
);
const currentRolePermissions = { ...rolePermissions };
const roleHasPermission = userSurfacedPermissionSet.every(
permission => currentRolePermissions[permission],
);
if (roleHasPermission) {
for (const permission of userSurfacedPermissionSet) {
delete currentRolePermissions[permission];
}
} else {
for (const permission of userSurfacedPermissionSet) {
currentRolePermissions[permission] = true;
}
}
return currentRolePermissions;
}
export {
constructRoleDeletionMessagePrompt,
useRoleDeletableAndEditableStatus,
useRolesFromCommunityThreadInfo,
toggleUserSurfacedPermission,
};