diff --git a/lib/shared/search-utils.js b/lib/shared/search-utils.js index 2a644ab04..cc1817c95 100644 --- a/lib/shared/search-utils.js +++ b/lib/shared/search-utils.js @@ -1,302 +1,303 @@ // @flow import * as React from 'react'; import { useSelector } from 'react-redux'; import SearchIndex from './search-index.js'; import { userIsMember, threadMemberHasPermission, getContainingThreadID, } from './thread-utils.js'; import { searchMessages, searchMessagesActionTypes, } from '../actions/message-actions.js'; import { searchUsers, searchUsersActionTypes, } from '../actions/user-actions.js'; import genesis from '../facts/genesis.js'; import type { RawMessageInfo } from '../types/message-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes } from '../types/thread-types-enum.js'; import { type ThreadInfo } from '../types/thread-types.js'; import type { AccountUserInfo, UserListItem, GlobalAccountUserInfo, } from '../types/user-types.js'; import { useServerCall, useDispatchActionPromise, } from '../utils/action-utils.js'; import { values } from '../utils/objects.js'; const notFriendNotice = 'not friend'; function getPotentialMemberItems({ text, userInfos, searchIndex, excludeUserIDs, includeServerSearchUsers, inputParentThreadInfo, inputCommunityThreadInfo, threadType, }: { +text: string, +userInfos: { +[id: string]: AccountUserInfo }, +searchIndex: SearchIndex, +excludeUserIDs: $ReadOnlyArray, +includeServerSearchUsers?: $ReadOnlyArray, +inputParentThreadInfo?: ?ThreadInfo, +inputCommunityThreadInfo?: ?ThreadInfo, +threadType?: ?ThreadType, }): UserListItem[] { const communityThreadInfo = inputCommunityThreadInfo && inputCommunityThreadInfo.id !== genesis.id ? inputCommunityThreadInfo : null; const parentThreadInfo = inputParentThreadInfo && inputParentThreadInfo.id !== genesis.id ? inputParentThreadInfo : null; const containgThreadID = threadType ? getContainingThreadID(parentThreadInfo, threadType) : null; let containingThreadInfo = null; if (containgThreadID === parentThreadInfo?.id) { containingThreadInfo = parentThreadInfo; } else if (containgThreadID === communityThreadInfo?.id) { containingThreadInfo = communityThreadInfo; } const results: { [id: string]: { ...AccountUserInfo | GlobalAccountUserInfo, isMemberOfParentThread: boolean, isMemberOfContainingThread: boolean, }, } = {}; const appendUserInfo = ( userInfo: AccountUserInfo | GlobalAccountUserInfo, ) => { const { id } = userInfo; if (excludeUserIDs.includes(id) || id in results) { return; } if ( communityThreadInfo && !threadMemberHasPermission( communityThreadInfo, id, threadPermissions.KNOW_OF, ) ) { return; } results[id] = { ...userInfo, isMemberOfParentThread: userIsMember(parentThreadInfo, id), isMemberOfContainingThread: userIsMember(containingThreadInfo, id), }; }; if (text === '') { for (const id in userInfos) { appendUserInfo(userInfos[id]); } } else { const ids = searchIndex.getSearchResults(text); for (const id of ids) { appendUserInfo(userInfos[id]); } } if (includeServerSearchUsers) { for (const userInfo of includeServerSearchUsers) { appendUserInfo(userInfo); } } const blockedRelationshipsStatuses = new Set([ userRelationshipStatus.BLOCKED_BY_VIEWER, userRelationshipStatus.BLOCKED_VIEWER, userRelationshipStatus.BOTH_BLOCKED, ]); let userResults = values(results); if (text === '') { userResults = userResults.filter(userInfo => containingThreadInfo ? userInfo.isMemberOfContainingThread && !blockedRelationshipsStatuses.has(userInfo.relationshipStatus) : userInfo?.relationshipStatus === userRelationshipStatus.FRIEND, ); } const nonFriends = []; const blockedUsers = []; const friends = []; const containingThreadMembers = []; const parentThreadMembers = []; for (const userResult of userResults) { const relationshipStatus = userResult.relationshipStatus; if (blockedRelationshipsStatuses.has(relationshipStatus)) { blockedUsers.push(userResult); } else if (userResult.isMemberOfParentThread) { parentThreadMembers.push(userResult); } else if (userResult.isMemberOfContainingThread) { containingThreadMembers.push(userResult); } else if (relationshipStatus === userRelationshipStatus.FRIEND) { friends.push(userResult); } else { nonFriends.push(userResult); } } const sortedResults = parentThreadMembers .concat(containingThreadMembers) .concat(friends) .concat(nonFriends) .concat(blockedUsers); return sortedResults.map( ({ isMemberOfContainingThread, isMemberOfParentThread, relationshipStatus, ...result }) => { let notice, alert; const username = result.username; if (blockedRelationshipsStatuses.has(relationshipStatus)) { notice = 'user is blocked'; alert = { title: 'User is blocked', text: `Before you add ${username} to this chat, ` + 'you’ll need to unblock them. You can do this from the Block List ' + 'in the Profile tab.', }; } else if (!isMemberOfContainingThread && containingThreadInfo) { if (threadType !== threadTypes.SIDEBAR) { notice = 'not in community'; alert = { title: 'Not in community', text: 'You can only add members of the community to this chat', }; } else { notice = 'not in parent chat'; alert = { title: 'Not in parent chat', text: 'You can only add members of the parent chat to a thread', }; } } else if ( !containingThreadInfo && relationshipStatus !== userRelationshipStatus.FRIEND ) { notice = notFriendNotice; alert = { title: 'Not a friend', text: `Before you add ${username} to this chat, ` + 'you’ll need to send them a friend request. ' + 'You can do this from the Friend List in the Profile tab.', }; } else if (parentThreadInfo && !isMemberOfParentThread) { notice = 'not in parent chat'; } if (notice) { result = { ...result, notice }; } if (alert) { result = { ...result, alert }; } return result; }, ); } function useSearchMessages(): ( query: string, threadID: string, onResultsReceived: ( messages: $ReadOnlyArray, endReached: boolean, + threadID: string, ) => mixed, cursor?: string, ) => void { const callSearchMessages = useServerCall(searchMessages); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( (query, threadID, onResultsReceived, cursor) => { const searchMessagesPromise = (async () => { if (query === '') { - onResultsReceived([], true); + onResultsReceived([], true, threadID); return; } const { messages, endReached } = await callSearchMessages({ query, threadID, cursor, }); - onResultsReceived(messages, endReached); + onResultsReceived(messages, endReached, threadID); })(); dispatchActionPromise(searchMessagesActionTypes, searchMessagesPromise); }, [callSearchMessages, dispatchActionPromise], ); } function useSearchUsers( usernameInputText: string, ): $ReadOnlyArray { const currentUserID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const [serverSearchResults, setServerSearchResults] = React.useState< $ReadOnlyArray, >([]); const callSearchUsers = useServerCall(searchUsers); const dispatchActionPromise = useDispatchActionPromise(); React.useEffect(() => { const searchUsersPromise = (async () => { if (usernameInputText.length === 0) { setServerSearchResults([]); } else { try { const { userInfos } = await callSearchUsers(usernameInputText); setServerSearchResults( userInfos.filter(({ id }) => id !== currentUserID), ); } catch (err) { setServerSearchResults([]); } } })(); dispatchActionPromise(searchUsersActionTypes, searchUsersPromise); }, [ callSearchUsers, currentUserID, dispatchActionPromise, usernameInputText, ]); return serverSearchResults; } export { getPotentialMemberItems, notFriendNotice, useSearchMessages, useSearchUsers, }; diff --git a/web/search/message-search-state-provider.react.js b/web/search/message-search-state-provider.react.js index 5b3e6f0a4..2b48e57b4 100644 --- a/web/search/message-search-state-provider.react.js +++ b/web/search/message-search-state-provider.react.js @@ -1,67 +1,198 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; +import { useSearchMessages } from 'lib/shared/search-utils.js'; +import { messageTypes } from 'lib/types/message-types-enum.js'; +import type { RawMessageInfo } from 'lib/types/message-types.js'; + type MessageSearchState = { +getQuery: (threadID: string) => string, +setQuery: (query: string, threadID: string) => void, +clearQuery: (threadID: string) => void, + +getSearchResults: (threadID: string) => $ReadOnlyArray, + +appendSearchResult: ( + $ReadOnlyArray, + threadID: string, + ) => void, + +getEndReached: (threadID: string) => boolean, + +setEndReached: (threadID: string) => void, + +searchMessages: (threadID: string) => void, }; const MessageSearchContext: React.Context = React.createContext(null); type Props = { +children: React.Node, }; function MessageSearchStateProvider(props: Props): React.Node { - const [queries, setQueries] = React.useState<{ + const queries = React.useRef<{ [threadID: string]: string, }>({}); - const setQuery = React.useCallback( - (query: string, threadID: string) => - setQueries(prevQueries => ({ ...prevQueries, [threadID]: query })), + const [results, setResults] = React.useState<{ + [threadID: string]: $ReadOnlyArray, + }>({}); + + const endsReached = React.useRef(new Set()); + + const lastIDs = React.useRef<{ + [threadID: string]: string, + }>({}); + + const setEndReached = React.useCallback((threadID: string) => { + endsReached.current.add(threadID); + }, []); + + const removeEndReached = React.useCallback( + (threadID: string) => endsReached.current.delete(threadID), [], ); - const clearQuery = React.useCallback( - (threadID: string) => - setQueries(prevQueries => { - const { [threadID]: deleted, ...newState } = prevQueries; - return newState; - }), + const getEndReached = React.useCallback( + (threadID: string) => endsReached.current.has(threadID), [], ); + const appendResult = React.useCallback( + (result: $ReadOnlyArray, threadID: string) => { + const lastMessageID = oldestMessageID(result); + if (lastMessageID) { + lastIDs.current[threadID] = lastMessageID; + } + setResults(prevResults => { + const prevThreadResults = prevResults[threadID] ?? []; + const newThreadResults = [...prevThreadResults, ...result]; + return { ...prevResults, [threadID]: newThreadResults }; + }); + }, + [], + ); + + const clearResults = React.useCallback( + (threadID: string) => { + loading.current = false; + delete lastIDs.current[threadID]; + removeEndReached(threadID); + setResults(prevResults => { + const { [threadID]: deleted, ...newState } = prevResults; + return newState; + }); + }, + [removeEndReached], + ); + + const getResults = React.useCallback( + (threadID: string) => results[threadID] ?? [], + [results], + ); + const getQuery = React.useCallback( - (threadID: string) => queries[threadID] ?? '', - [queries], + (threadID: string) => queries.current[threadID] ?? '', + [], + ); + + const setQuery = React.useCallback( + (query: string, threadID: string) => { + clearResults(threadID); + queries.current[threadID] = query; + }, + [clearResults], + ); + + const clearQuery = React.useCallback( + (threadID: string) => { + clearResults(threadID); + delete queries.current[threadID]; + }, + [clearResults], + ); + + const searchMessagesCall = useSearchMessages(); + + const loading = React.useRef(false); + + const appendResults = React.useCallback( + ( + newMessages: $ReadOnlyArray, + end: boolean, + threadID: string, + ) => { + appendResult(newMessages, threadID); + if (end) { + setEndReached(threadID); + } + loading.current = false; + }, + [appendResult, setEndReached], + ); + + const searchMessages = React.useCallback( + (threadID: string) => { + if (loading.current || endsReached.current.has(threadID)) { + return; + } + loading.current = true; + searchMessagesCall( + queries.current[threadID], + threadID, + appendResults, + lastIDs.current[threadID], + ); + }, + [appendResults, endsReached, searchMessagesCall], ); const state = React.useMemo( () => ({ getQuery, setQuery, clearQuery, + getSearchResults: getResults, + appendSearchResult: appendResult, + getEndReached, + setEndReached, + searchMessages, }), - [getQuery, setQuery, clearQuery], + [ + getQuery, + setQuery, + clearQuery, + getResults, + appendResult, + getEndReached, + setEndReached, + searchMessages, + ], ); return ( {props.children} ); } +function oldestMessageID(data: $ReadOnlyArray) { + if (!data) { + return undefined; + } + for (let i = data.length - 1; i >= 0; i--) { + if (data[i].type === messageTypes.TEXT) { + return data[i].id; + } + } + return undefined; +} + function useMessageSearchContext(): MessageSearchState { const context = React.useContext(MessageSearchContext); invariant(context, 'MessageSearchContext not found'); return context; } export { MessageSearchStateProvider, useMessageSearchContext };