Changeset View
Changeset View
Standalone View
Standalone View
web/modals/search/message-search-modal.react.js
// @flow | // @flow | ||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { useModalContext } from 'lib/components/modal-provider.react.js'; | import { useModalContext } from 'lib/components/modal-provider.react.js'; | ||||
import { type ChatMessageItem } from 'lib/selectors/chat-selectors.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 type { ThreadInfo } from 'lib/types/thread-types.js'; | ||||
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; | import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; | ||||
import css from './message-search-modal.css'; | import css from './message-search-modal.css'; | ||||
import { useParseSearchResults } from './message-search-utils.react.js'; | import { useParseSearchResults } from './message-search-utils.react.js'; | ||||
import { useTooltipContext } from '../../chat/tooltip-provider.js'; | import { useTooltipContext } from '../../chat/tooltip-provider.js'; | ||||
import Button from '../../components/button.react.js'; | import Button from '../../components/button.react.js'; | ||||
import MessageResult from '../../components/message-result.react.js'; | import MessageResult from '../../components/message-result.react.js'; | ||||
import Search from '../../components/search.react.js'; | import Search from '../../components/search.react.js'; | ||||
import LoadingIndicator from '../../loading-indicator.react.js'; | import LoadingIndicator from '../../loading-indicator.react.js'; | ||||
import { useMessageSearchContext } from '../../search/message-search-state-provider.react.js'; | import { useMessageSearchContext } from '../../search/message-search-state-provider.react.js'; | ||||
import Modal from '../modal.react.js'; | import Modal from '../modal.react.js'; | ||||
type ContentProps = { | type ContentProps = { | ||||
+threadInfo: ThreadInfo, | +threadInfo: ThreadInfo, | ||||
}; | }; | ||||
function MessageSearchModal(props: ContentProps): React.Node { | function MessageSearchModal(props: ContentProps): React.Node { | ||||
const { threadInfo } = props; | const { threadInfo } = props; | ||||
const [lastID, setLastID] = React.useState(); | const { | ||||
const [searchResults, setSearchResults] = React.useState([]); | getQuery, | ||||
const [endReached, setEndReached] = React.useState(false); | setQuery, | ||||
clearQuery, | |||||
searchMessages, | |||||
getSearchResults, | |||||
getEndReached, | |||||
} = useMessageSearchContext(); | |||||
const [input, setInput] = React.useState(getQuery(threadInfo.id)); | |||||
const onPressSearch = React.useCallback(() => { | |||||
setQuery(input, threadInfo.id); | |||||
searchMessages(threadInfo.id); | |||||
}, [setQuery, input, searchMessages, threadInfo.id]); | |||||
const { getQuery, setQuery, clearQuery } = useMessageSearchContext(); | const onKeyDown = React.useCallback( | ||||
event => { | |||||
const query = React.useMemo( | if (event.key === 'Enter') { | ||||
() => getQuery(threadInfo.id), | onPressSearch(); | ||||
[getQuery, threadInfo.id], | } | ||||
}, | |||||
[onPressSearch], | |||||
); | ); | ||||
const appendSearchResults = React.useCallback( | const modifiedItems = useParseSearchResults( | ||||
(newMessages: $ReadOnlyArray<RawMessageInfo>, end: boolean) => { | threadInfo, | ||||
setSearchResults(oldMessages => [...oldMessages, ...newMessages]); | getSearchResults(threadInfo.id), | ||||
setEndReached(end); | |||||
}, | |||||
[], | |||||
); | ); | ||||
React.useEffect(() => { | const { clearTooltip } = useTooltipContext(); | ||||
setSearchResults([]); | |||||
setLastID(undefined); | |||||
setEndReached(false); | |||||
}, [query]); | |||||
const searchMessages = useSearchMessages(); | const messageContainer = React.useRef(null); | ||||
React.useEffect( | const possiblyLoadMoreMessages = React.useCallback(() => { | ||||
() => searchMessages(query, threadInfo.id, appendSearchResults, lastID), | if (!messageContainer.current) { | ||||
[appendSearchResults, lastID, query, searchMessages, threadInfo.id], | return; | ||||
); | } | ||||
const modifiedItems = useParseSearchResults(threadInfo, searchResults); | const loaderTopOffset = 32; | ||||
const { scrollTop, scrollHeight, clientHeight } = messageContainer.current; | |||||
if (Math.abs(scrollTop) + clientHeight + loaderTopOffset < scrollHeight) { | |||||
return; | |||||
} | |||||
searchMessages(threadInfo.id); | |||||
}, [searchMessages, threadInfo.id]); | |||||
const onScroll = React.useCallback(() => { | |||||
clearTooltip(); | |||||
possiblyLoadMoreMessages(); | |||||
}, [clearTooltip, possiblyLoadMoreMessages]); | |||||
const renderItem = React.useCallback( | const renderItem = React.useCallback( | ||||
item => ( | item => ( | ||||
<MessageResult | <MessageResult | ||||
key={item.messageInfo.id} | key={item.messageInfo.id} | ||||
item={item} | item={item} | ||||
threadInfo={threadInfo} | threadInfo={threadInfo} | ||||
scrollable={false} | scrollable={false} | ||||
/> | /> | ||||
), | ), | ||||
[threadInfo], | [threadInfo], | ||||
); | ); | ||||
const messages = React.useMemo( | const messages = React.useMemo( | ||||
() => modifiedItems.map(item => renderItem(item)), | () => modifiedItems.map(item => renderItem(item)), | ||||
[modifiedItems, renderItem], | [modifiedItems, renderItem], | ||||
); | ); | ||||
const messageContainer = React.useRef(null); | const endReached = getEndReached(threadInfo.id); | ||||
const query = getQuery(threadInfo.id); | |||||
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 footer = React.useMemo(() => { | const footer = React.useMemo(() => { | ||||
if (query === '') { | if (query === '') { | ||||
return ( | return ( | ||||
<div className={css.footer}>Your search results will appear here</div> | <div className={css.footer}>Your search results will appear here</div> | ||||
); | ); | ||||
} | } | ||||
if (!endReached) { | if (!endReached) { | ||||
return ( | return ( | ||||
<div key="search-loader" className={css.loading}> | <div key="search-loader" className={css.loading}> | ||||
<LoadingIndicator status="loading" size="medium" color="white" /> | <LoadingIndicator status="loading" size="medium" color="white" /> | ||||
</div> | </div> | ||||
); | ); | ||||
} | } | ||||
if (modifiedItems.length > 0) { | if (modifiedItems.length > 0) { | ||||
return <div className={css.footer}>End of results</div>; | return <div className={css.footer}>End of results</div>; | ||||
} | } | ||||
return ( | return ( | ||||
<div className={css.footer}> | <div className={css.footer}> | ||||
No results. Please try using different keywords to refine your search | No results. Please try using different keywords to refine your search | ||||
</div> | </div> | ||||
); | ); | ||||
}, [query, endReached, modifiedItems.length]); | }, [query, endReached, modifiedItems.length]); | ||||
const [input, setInput] = React.useState(query); | const { uiName } = useResolvedThreadInfo(threadInfo); | ||||
const searchPlaceholder = `Searching in ${uiName}`; | |||||
const onPressSearch = React.useCallback( | const { popModal } = useModalContext(); | ||||
() => setQuery(input, threadInfo.id), | |||||
[setQuery, input, threadInfo.id], | |||||
); | |||||
const clearQueryWrapper = React.useCallback( | const clearQueryWrapper = React.useCallback( | ||||
() => clearQuery(threadInfo.id), | () => clearQuery(threadInfo.id), | ||||
[clearQuery, threadInfo.id], | [clearQuery, threadInfo.id], | ||||
); | ); | ||||
const onKeyDown = React.useCallback( | |||||
event => { | |||||
if (event.key === 'Enter') { | |||||
onPressSearch(); | |||||
} | |||||
}, | |||||
[onPressSearch], | |||||
); | |||||
const { uiName } = useResolvedThreadInfo(threadInfo); | |||||
const searchPlaceholder = `Searching in ${uiName}`; | |||||
const { popModal } = useModalContext(); | |||||
return ( | return ( | ||||
<Modal name="Search Message" onClose={popModal} size="large"> | <Modal name="Search Message" onClose={popModal} size="large"> | ||||
<div className={css.container}> | <div className={css.container}> | ||||
<div className={css.header}> | <div className={css.header}> | ||||
<Search | <Search | ||||
onChangeText={setInput} | onChangeText={setInput} | ||||
searchText={input} | searchText={input} | ||||
placeholder={searchPlaceholder} | placeholder={searchPlaceholder} | ||||
onClearText={clearQueryWrapper} | onClearText={clearQueryWrapper} | ||||
onKeyDown={onKeyDown} | onKeyDown={onKeyDown} | ||||
/> | /> | ||||
<Button | <Button | ||||
onClick={onPressSearch} | onClick={onPressSearch} | ||||
variant="filled" | variant="filled" | ||||
className={css.button} | className={css.button} | ||||
> | > | ||||
Search | Search | ||||
</Button> | </Button> | ||||
</div> | </div> | ||||
<div className={css.content} ref={messageContainerRef}> | <div className={css.content} ref={messageContainer} onScroll={onScroll}> | ||||
{messages} | {messages} | ||||
{footer} | {footer} | ||||
</div> | </div> | ||||
</div> | </div> | ||||
</Modal> | </Modal> | ||||
); | ); | ||||
} | } | ||||
function oldestMessageID(data: $ReadOnlyArray<ChatMessageItem>) { | |||||
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; | |||||
} | |||||
export default MessageSearchModal; | export default MessageSearchModal; |