diff --git a/lib/shared/search-utils.js b/lib/shared/search-utils.js index cc1817c95..3030b2b25 100644 --- a/lib/shared/search-utils.js +++ b/lib/shared/search-utils.js @@ -1,303 +1,305 @@ // @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, + queryID: number, threadID: string, ) => mixed, + queryID: number, cursor?: string, ) => void { const callSearchMessages = useServerCall(searchMessages); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( - (query, threadID, onResultsReceived, cursor) => { + (query, threadID, onResultsReceived, queryID, cursor) => { const searchMessagesPromise = (async () => { if (query === '') { - onResultsReceived([], true, threadID); + onResultsReceived([], true, queryID, threadID); return; } const { messages, endReached } = await callSearchMessages({ query, threadID, cursor, }); - onResultsReceived(messages, endReached, threadID); + onResultsReceived(messages, endReached, queryID, threadID); })(); dispatchActionPromise(searchMessagesActionTypes, searchMessagesPromise); }, [callSearchMessages, dispatchActionPromise], ); } function useSearchUsers( usernameInputText: string, ): $ReadOnlyArray { const currentUserID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const [serverSearchResults, setServerSearchResults] = React.useState< $ReadOnlyArray, >([]); const callSearchUsers = useServerCall(searchUsers); const dispatchActionPromise = useDispatchActionPromise(); React.useEffect(() => { const searchUsersPromise = (async () => { if (usernameInputText.length === 0) { setServerSearchResults([]); } else { try { const { userInfos } = await callSearchUsers(usernameInputText); setServerSearchResults( userInfos.filter(({ id }) => id !== currentUserID), ); } catch (err) { setServerSearchResults([]); } } })(); dispatchActionPromise(searchUsersActionTypes, searchUsersPromise); }, [ callSearchUsers, currentUserID, dispatchActionPromise, usernameInputText, ]); return serverSearchResults; } export { getPotentialMemberItems, notFriendNotice, useSearchMessages, useSearchUsers, }; diff --git a/native/search/message-search.react.js b/native/search/message-search.react.js index d39ca880c..68e6bf4cb 100644 --- a/native/search/message-search.react.js +++ b/native/search/message-search.react.js @@ -1,229 +1,244 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; import { messageListData } from 'lib/selectors/chat-selectors.js'; import { createMessageInfo } from 'lib/shared/message-utils.js'; import { useSearchMessages } from 'lib/shared/search-utils.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import SearchFooter from './search-footer.react.js'; import { MessageSearchContext } from './search-provider.react.js'; import { useHeightMeasurer } from '../chat/chat-context.js'; import type { ChatNavigationProp } from '../chat/chat.react.js'; import { MessageListContextProvider } from '../chat/message-list-types.js'; import MessageResult from '../chat/message-result.react.js'; import ListLoadingIndicator from '../components/list-loading-indicator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; export type MessageSearchParams = { +threadInfo: ThreadInfo, }; export type MessageSearchProps = { +navigation: ChatNavigationProp<'MessageSearch'>, +route: NavigationRoute<'MessageSearch'>, }; function MessageSearch(props: MessageSearchProps): React.Node { const searchContext = React.useContext(MessageSearchContext); invariant(searchContext, 'searchContext should be set'); const { query, clearQuery } = searchContext; const { threadInfo } = props.route.params; React.useEffect(() => { return props.navigation.addListener('beforeRemove', clearQuery); }, [props.navigation, clearQuery]); const [lastID, setLastID] = React.useState(); const [searchResults, setSearchResults] = React.useState([]); const [endReached, setEndReached] = React.useState(false); const appendSearchResults = React.useCallback( - (newMessages: $ReadOnlyArray, end: boolean) => { + ( + newMessages: $ReadOnlyArray, + end: boolean, + queryID: number, + ) => { + if (queryID !== queryIDRef.current) { + return; + } setSearchResults(oldMessages => [...oldMessages, ...newMessages]); setEndReached(end); }, [], ); const searchMessages = useSearchMessages(); + const queryIDRef = React.useRef(0); + React.useEffect(() => { setSearchResults([]); setLastID(undefined); setEndReached(false); }, [query, searchMessages]); - React.useEffect( - () => searchMessages(query, threadInfo.id, appendSearchResults, lastID), - [appendSearchResults, lastID, query, searchMessages, threadInfo.id], - ); + React.useEffect(() => { + queryIDRef.current += 1; + searchMessages( + query, + threadInfo.id, + appendSearchResults, + queryIDRef.current, + lastID, + ); + }, [appendSearchResults, lastID, query, searchMessages, threadInfo.id]); const userInfos = useSelector(state => state.userStore.userInfos); const translatedSearchResults = React.useMemo(() => { const threadInfos = { [threadInfo.id]: threadInfo }; return searchResults .map(rawMessageInfo => createMessageInfo(rawMessageInfo, null, userInfos, threadInfos), ) .filter(Boolean); }, [searchResults, threadInfo, userInfos]); const chatMessageInfos = useSelector( messageListData(threadInfo.id, translatedSearchResults), ); const filteredChatMessageInfos = React.useMemo(() => { if (!chatMessageInfos) { return null; } const idSet = new Set(translatedSearchResults.map(item => item.id)); const chatMessageInfoItems = chatMessageInfos.filter( item => item.messageInfo && idSet.has(item.messageInfo.id), ); const uniqueChatMessageInfoItemsMap = new Map(); chatMessageInfoItems.forEach( item => item.messageInfo && item.messageInfo.id && uniqueChatMessageInfoItemsMap.set(item.messageInfo.id, item), ); const sortedChatMessageInfoItems = []; for (let i = 0; i < translatedSearchResults.length; i++) { sortedChatMessageInfoItems.push( uniqueChatMessageInfoItemsMap.get(translatedSearchResults[i].id), ); } if (!endReached) { sortedChatMessageInfoItems.push({ itemType: 'loader' }); } return sortedChatMessageInfoItems.filter(Boolean); }, [chatMessageInfos, endReached, translatedSearchResults]); const [measuredMessages, setMeasuredMessages] = React.useState([]); const measureMessages = useHeightMeasurer(); const measureCallback = React.useCallback( (listDataWithHeights: $ReadOnlyArray) => { setMeasuredMessages(listDataWithHeights); }, [setMeasuredMessages], ); React.useEffect(() => { measureMessages(filteredChatMessageInfos, threadInfo, measureCallback); }, [filteredChatMessageInfos, measureCallback, measureMessages, threadInfo]); const [messageVerticalBounds, setMessageVerticalBounds] = React.useState(); const scrollViewContainerRef = React.useRef(); const onLayout = React.useCallback(() => { scrollViewContainerRef.current?.measure( (x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } setMessageVerticalBounds({ height, y: pageY }); }, ); }, []); const renderItem = React.useCallback( ({ item }) => { if (item.itemType === 'loader') { return ; } return ( ); }, [messageVerticalBounds, props.navigation, props.route, threadInfo], ); const footer = React.useMemo(() => { if (query === '') { return ; } if (!endReached) { return null; } if (measuredMessages.length > 0) { return ; } const text = 'No results. Please try using different keywords to refine your search'; return ; }, [query, endReached, measuredMessages.length]); const onEndOfLoadedMessagesReached = React.useCallback(() => { if (endReached) { return; } setLastID(oldestMessageID(measuredMessages)); }, [endReached, measuredMessages, setLastID]); const styles = useStyles(unboundStyles); return ( ); } function oldestMessageID(data: $ReadOnlyArray) { for (let i = data.length - 1; i >= 0; i--) { if (data[i].itemType === 'message' && data[i].messageInfo.id) { return data[i].messageInfo.id; } } return undefined; } const unboundStyles = { content: { height: '100%', backgroundColor: 'panelBackground', }, }; export default MessageSearch; diff --git a/web/search/message-search-state-provider.react.js b/web/search/message-search-state-provider.react.js index 2b48e57b4..7e29efddc 100644 --- a/web/search/message-search-state-provider.react.js +++ b/web/search/message-search-state-provider.react.js @@ -1,198 +1,206 @@ // @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 = React.useRef<{ [threadID: string]: string, }>({}); 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 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.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 queryIDRef = React.useRef(0); const appendResults = React.useCallback( ( newMessages: $ReadOnlyArray, end: boolean, + queryID: number, threadID: string, ) => { + if (queryID !== queryIDRef.current) { + return; + } + 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; } + queryIDRef.current += 1; loading.current = true; searchMessagesCall( queries.current[threadID], threadID, appendResults, + queryIDRef.current, lastIDs.current[threadID], ); }, [appendResults, endsReached, searchMessagesCall], ); const state = React.useMemo( () => ({ getQuery, setQuery, clearQuery, getSearchResults: getResults, appendSearchResult: appendResult, getEndReached, setEndReached, searchMessages, }), [ 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 };