diff --git a/web/modals/search/message-search-modal.css b/web/modals/search/message-search-modal.css new file mode 100644 index 000000000..c41780569 --- /dev/null +++ b/web/modals/search/message-search-modal.css @@ -0,0 +1,14 @@ +.content { + overflow-y: scroll; + padding: 0 10px; +} + +.content > * { + margin-bottom: 16px; +} + +.loading { + text-align: center; + margin-bottom: 8px; + margin-top: 24px; +} diff --git a/web/modals/search/message-search-modal.react.js b/web/modals/search/message-search-modal.react.js new file mode 100644 index 000000000..6aafa6624 --- /dev/null +++ b/web/modals/search/message-search-modal.react.js @@ -0,0 +1,216 @@ +// @flow + +import * as React from 'react'; + +import { useModalContext } from 'lib/components/modal-provider.react.js'; +import { + type ChatMessageItem, + messageListData, +} from 'lib/selectors/chat-selectors.js'; +import { + createMessageInfo, + modifyItemForResultScreen, +} 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 { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; + +import css from './message-search-modal.css'; +import { useTooltipContext } from '../../chat/tooltip-provider.js'; +import MessageResult from '../../components/message-result.react.js'; +import LoadingIndicator from '../../loading-indicator.react.js'; +import { useSelector } from '../../redux/redux-utils.js'; +import SearchModal from '../search-modal.react.js'; + +type ContentProps = { + +query: string, + +threadInfo: ThreadInfo, +}; + +function MessageSearchModalContent(props: ContentProps): React.Node { + const { query, threadInfo } = props; + + const [lastID, setLastID] = React.useState(); + const [searchResults, setSearchResults] = React.useState([]); + const [endReached, setEndReached] = React.useState(false); + + const appendSearchResults = React.useCallback( + (newMessages: $ReadOnlyArray, end: boolean) => { + setSearchResults(oldMessages => [...oldMessages, ...newMessages]); + setEndReached(end); + }, + [], + ); + + React.useEffect(() => { + setSearchResults([]); + setLastID(undefined); + setEndReached(false); + }, [query]); + + const searchMessages = useSearchMessages(); + + React.useEffect( + () => searchMessages(query, threadInfo.id, appendSearchResults, 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 []; + } + + 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), + ); + } + + return sortedChatMessageInfoItems.filter(Boolean); + }, [chatMessageInfos, translatedSearchResults]); + + const modifiedItems = React.useMemo( + () => filteredChatMessageInfos.map(item => modifyItemForResultScreen(item)), + [filteredChatMessageInfos], + ); + + const renderItem = React.useCallback( + item => ( + + ), + [threadInfo], + ); + + const messages = React.useMemo( + () => modifiedItems.map(item => renderItem(item)), + [modifiedItems, renderItem], + ); + + const messageContainer = React.useRef(null); + + const messageContainerRef = (msgContainer: ?HTMLDivElement) => { + messageContainer.current = msgContainer; + messageContainer.current?.addEventListener('scroll', onScroll); + }; + + const { clearTooltip } = useTooltipContext(); + + const possiblyLoadMoreMessages = React.useCallback(() => { + if (!messageContainer.current) { + return; + } + + const loaderTopOffset = 32; + const { scrollTop, scrollHeight, clientHeight } = messageContainer.current; + if ( + endReached || + Math.abs(scrollTop) + clientHeight + loaderTopOffset < scrollHeight + ) { + return; + } + setLastID(modifiedItems ? oldestMessageID(modifiedItems) : undefined); + }, [endReached, modifiedItems]); + + const onScroll = React.useCallback(() => { + if (!messageContainer.current) { + return; + } + clearTooltip(); + possiblyLoadMoreMessages(); + }, [clearTooltip, possiblyLoadMoreMessages]); + + const loader = React.useMemo(() => { + if (endReached) { + return null; + } + return ( +
+ +
+ ); + }, [endReached]); + + return ( +
+ {messages} + {loader} +
+ ); +} + +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; +} + +type Props = { + +threadInfo: ThreadInfo, +}; + +function MessageSearchModal(props: Props): React.Node { + const { threadInfo } = props; + const { popModal } = useModalContext(); + + const renderModalContent = React.useCallback( + (searchText: string) => ( + + ), + [threadInfo], + ); + + const { uiName } = useResolvedThreadInfo(threadInfo); + const searchPlaceholder = `Searching in ${uiName}`; + + return ( + + {renderModalContent} + + ); +} + +export default MessageSearchModal;