2024-05-31 19:49:46 +05:30
import React , { RefObject , useEffect , useMemo , useRef } from 'react' ;
import { Text , Box , Icon , Icons , config , Spinner , IconButton , Line , toRem } from 'folds' ;
import { useAtomValue } from 'jotai' ;
import { useVirtualizer } from '@tanstack/react-virtual' ;
import { useInfiniteQuery } from '@tanstack/react-query' ;
import { useSearchParams } from 'react-router-dom' ;
import { SearchOrderBy } from 'matrix-js-sdk' ;
2025-05-24 20:07:56 +05:30
import { PageHero , PageHeroEmpty , PageHeroSection } from '../../components/page' ;
2024-05-31 19:49:46 +05:30
import { useMatrixClient } from '../../hooks/useMatrixClient' ;
import { _SearchPathSearchParams } from '../../pages/paths' ;
import { useSetting } from '../../state/hooks/settings' ;
import { settingsAtom } from '../../state/settings' ;
import { SequenceCard } from '../../components/sequence-card' ;
import { useRoomNavigate } from '../../hooks/useRoomNavigate' ;
import { ScrollTopContainer } from '../../components/scroll-top-container' ;
import { ContainerColor } from '../../styles/ContainerColor.css' ;
import { decodeSearchParamValueArray , encodeSearchParamValueArray } from '../../pages/pathUtils' ;
import { useRooms } from '../../state/hooks/roomList' ;
import { allRoomsAtom } from '../../state/room-list/roomList' ;
import { mDirectAtom } from '../../state/mDirectList' ;
import { MessageSearchParams , useMessageSearch } from './useMessageSearch' ;
2026-05-28 20:01:21 -04:00
import { useLocalMessageSearch } from './useLocalMessageSearch' ;
2024-05-31 19:49:46 +05:30
import { SearchResultGroup } from './SearchResultGroup' ;
import { SearchInput } from './SearchInput' ;
import { SearchFilters } from './SearchFilters' ;
import { VirtualTile } from '../../components/virtualizer' ;
const useSearchPathSearchParams = ( searchParams : URLSearchParams ) : _SearchPathSearchParams = >
useMemo (
( ) = > ( {
global : searchParams . get ( 'global' ) ? ? undefined ,
term : searchParams.get ( 'term' ) ? ? undefined ,
order : searchParams.get ( 'order' ) ? ? undefined ,
rooms : searchParams.get ( 'rooms' ) ? ? undefined ,
senders : searchParams.get ( 'senders' ) ? ? undefined ,
} ) ,
2026-05-21 23:30:50 -04:00
[ searchParams ] ,
2024-05-31 19:49:46 +05:30
) ;
type MessageSearchProps = {
defaultRoomsFilterName : string ;
allowGlobal? : boolean ;
rooms : string [ ] ;
senders? : string [ ] ;
scrollRef : RefObject < HTMLDivElement > ;
} ;
export function MessageSearch ( {
defaultRoomsFilterName ,
allowGlobal ,
rooms ,
senders ,
scrollRef ,
} : MessageSearchProps ) {
const mx = useMatrixClient ( ) ;
const mDirects = useAtomValue ( mDirectAtom ) ;
const allRooms = useRooms ( mx , allRoomsAtom , mDirects ) ;
const [ mediaAutoLoad ] = useSetting ( settingsAtom , 'mediaAutoLoad' ) ;
const [ urlPreview ] = useSetting ( settingsAtom , 'urlPreview' ) ;
2025-03-23 22:09:29 +11:00
const [ legacyUsernameColor ] = useSetting ( settingsAtom , 'legacyUsernameColor' ) ;
2025-07-27 15:13:00 +03:00
const [ hour24Clock ] = useSetting ( settingsAtom , 'hour24Clock' ) ;
const [ dateFormatString ] = useSetting ( settingsAtom , 'dateFormatString' ) ;
2026-05-22 13:24:07 -04:00
const searchInputRef = useRef < HTMLInputElement > ( null ) as React . RefObject < HTMLInputElement > ;
const scrollTopAnchorRef = useRef < HTMLDivElement > ( null ) as React . RefObject < HTMLDivElement > ;
2024-05-31 19:49:46 +05:30
const [ searchParams , setSearchParams ] = useSearchParams ( ) ;
const searchPathSearchParams = useSearchPathSearchParams ( searchParams ) ;
const { navigateRoom } = useRoomNavigate ( ) ;
const searchParamRooms = useMemo ( ( ) = > {
if ( searchPathSearchParams . rooms ) {
const joinedRoomIds = decodeSearchParamValueArray ( searchPathSearchParams . rooms ) . filter (
2026-05-21 23:30:50 -04:00
( rId ) = > allRooms . includes ( rId ) ,
2024-05-31 19:49:46 +05:30
) ;
return joinedRoomIds ;
}
return undefined ;
} , [ allRooms , searchPathSearchParams . rooms ] ) ;
const searchParamsSenders = useMemo ( ( ) = > {
if ( searchPathSearchParams . senders ) {
return decodeSearchParamValueArray ( searchPathSearchParams . senders ) ;
}
return undefined ;
} , [ searchPathSearchParams . senders ] ) ;
const msgSearchParams : MessageSearchParams = useMemo ( ( ) = > {
const isGlobal = searchPathSearchParams . global === 'true' ;
const defaultRooms = isGlobal ? undefined : rooms ;
return {
term : searchPathSearchParams.term ,
order : searchPathSearchParams.order ? ? SearchOrderBy . Recent ,
rooms : searchParamRooms ? ? defaultRooms ,
senders : searchParamsSenders ? ? senders ,
} ;
} , [ searchPathSearchParams , searchParamRooms , searchParamsSenders , rooms , senders ] ) ;
const searchMessages = useMessageSearch ( msgSearchParams ) ;
2026-05-28 20:01:21 -04:00
const searchLocalMessages = useLocalMessageSearch ( ) ;
// The rooms actually in scope for this search (mirrors server-side logic)
const localSearchRooms = useMemo (
( ) = > msgSearchParams . rooms ? ? ( searchPathSearchParams . global === 'true' ? allRooms : rooms ) ,
[ msgSearchParams . rooms , searchPathSearchParams . global , allRooms , rooms ] ,
) ;
// Run synchronous client-side search over encrypted rooms immediately
const localResult = useMemo ( ( ) = > {
if ( ! msgSearchParams . term ) return null ;
return searchLocalMessages ( localSearchRooms , msgSearchParams . term ) ;
} , [ searchLocalMessages , localSearchRooms , msgSearchParams . term ] ) ;
2024-05-31 19:49:46 +05:30
const { status , data , error , fetchNextPage , hasNextPage , isFetchingNextPage } = useInfiniteQuery ( {
enabled : ! ! msgSearchParams . term ,
queryKey : [
'search' ,
msgSearchParams . term ,
msgSearchParams . order ,
msgSearchParams . rooms ,
msgSearchParams . senders ,
] ,
queryFn : ( { pageParam } ) = > searchMessages ( pageParam ) ,
initialPageParam : '' ,
getNextPageParam : ( lastPage ) = > lastPage . nextToken ,
} ) ;
const groups = useMemo ( ( ) = > data ? . pages . flatMap ( ( result ) = > result . groups ) ? ? [ ] , [ data ] ) ;
const highlights = useMemo ( ( ) = > {
const mixed = data ? . pages . flatMap ( ( result ) = > result . highlights ) ;
return Array . from ( new Set ( mixed ) ) ;
} , [ data ] ) ;
const virtualizer = useVirtualizer ( {
count : groups.length ,
getScrollElement : ( ) = > scrollRef . current ,
estimateSize : ( ) = > 40 ,
overscan : 1 ,
} ) ;
const vItems = virtualizer . getVirtualItems ( ) ;
const handleSearch = ( term : string ) = > {
setSearchParams ( ( prevParams ) = > {
const newParams = new URLSearchParams ( prevParams ) ;
newParams . delete ( 'term' ) ;
newParams . append ( 'term' , term ) ;
return newParams ;
} ) ;
} ;
const handleSearchClear = ( ) = > {
if ( searchInputRef . current ) {
searchInputRef . current . value = '' ;
}
setSearchParams ( ( prevParams ) = > {
const newParams = new URLSearchParams ( prevParams ) ;
newParams . delete ( 'term' ) ;
return newParams ;
} ) ;
} ;
const handleSelectedRoomsChange = ( selectedRooms? : string [ ] ) = > {
setSearchParams ( ( prevParams ) = > {
const newParams = new URLSearchParams ( prevParams ) ;
newParams . delete ( 'rooms' ) ;
if ( selectedRooms && selectedRooms . length > 0 ) {
newParams . append ( 'rooms' , encodeSearchParamValueArray ( selectedRooms ) ) ;
}
return newParams ;
} ) ;
} ;
const handleGlobalChange = ( global ? : boolean ) = > {
setSearchParams ( ( prevParams ) = > {
const newParams = new URLSearchParams ( prevParams ) ;
newParams . delete ( 'global' ) ;
if ( global ) {
newParams . append ( 'global' , 'true' ) ;
}
return newParams ;
} ) ;
} ;
const handleOrderChange = ( order? : string ) = > {
setSearchParams ( ( prevParams ) = > {
const newParams = new URLSearchParams ( prevParams ) ;
newParams . delete ( 'order' ) ;
if ( order ) {
newParams . append ( 'order' , order ) ;
}
return newParams ;
} ) ;
} ;
const lastVItem = vItems [ vItems . length - 1 ] ;
const lastVItemIndex : number | undefined = lastVItem ? . index ;
const lastGroupIndex = groups . length - 1 ;
useEffect ( ( ) = > {
if (
lastGroupIndex > - 1 &&
lastGroupIndex === lastVItemIndex &&
! isFetchingNextPage &&
hasNextPage
) {
fetchNextPage ( ) ;
}
} , [ lastVItemIndex , lastGroupIndex , fetchNextPage , isFetchingNextPage , hasNextPage ] ) ;
return (
< Box direction = "Column" gap = "700" >
< ScrollTopContainer scrollRef = { scrollRef } anchorRef = { scrollTopAnchorRef } >
< IconButton
onClick = { ( ) = > virtualizer . scrollToOffset ( 0 ) }
variant = "SurfaceVariant"
radii = "Pill"
outlined
size = "300"
aria-label = "Scroll to Top"
>
< Icon src = { Icons . ChevronTop } size = "300" / >
< / IconButton >
< / ScrollTopContainer >
< Box ref = { scrollTopAnchorRef } direction = "Column" gap = "300" >
< SearchInput
active = { ! ! msgSearchParams . term }
loading = { status === 'pending' }
searchInputRef = { searchInputRef }
onSearch = { handleSearch }
onReset = { handleSearchClear }
/ >
< SearchFilters
defaultRoomsFilterName = { defaultRoomsFilterName }
allowGlobal = { allowGlobal }
roomList = { searchPathSearchParams . global === 'true' ? allRooms : rooms }
selectedRooms = { searchParamRooms }
onSelectedRoomsChange = { handleSelectedRoomsChange }
global = { searchPathSearchParams . global === 'true' }
onGlobalChange = { handleGlobalChange }
order = { msgSearchParams . order }
onOrderChange = { handleOrderChange }
/ >
< / Box >
{ ! msgSearchParams . term && status === 'pending' && (
2025-05-24 20:07:56 +05:30
< PageHeroEmpty >
2024-05-31 19:49:46 +05:30
< PageHeroSection >
< PageHero
icon = { < Icon size = "600" src = { Icons . Message } / > }
title = "Search Messages"
subTitle = "Find helpful messages in your community by searching with related keywords."
/ >
< / PageHeroSection >
2025-05-24 20:07:56 +05:30
< / PageHeroEmpty >
2024-05-31 19:49:46 +05:30
) }
{ msgSearchParams . term && groups . length === 0 && status === 'success' && (
2026-05-28 20:01:21 -04:00
< Box direction = "Column" gap = "200" >
< Box
className = { ContainerColor ( { variant : 'Warning' } ) }
style = { { padding : config.space.S300 , borderRadius : config.radii.R400 } }
alignItems = "Center"
gap = "200"
>
< Icon size = "200" src = { Icons . Info } / >
< Text >
No results found for < b > { ` " ${ msgSearchParams . term } " ` } < / b >
< / Text >
< / Box >
{ localResult &&
localResult . encryptedRoomsCount > 0 &&
localResult . groups . length === 0 && (
< Box
className = { ContainerColor ( { variant : 'Surface' } ) }
style = { { padding : config.space.S300 , borderRadius : config.radii.R400 } }
alignItems = "Center"
gap = "200"
>
< Icon size = "200" src = { Icons . Lock } / >
< Text size = "T300" priority = "300" >
{ ` ${ localResult . encryptedRoomsCount } encrypted room ${ localResult . encryptedRoomsCount !== 1 ? 's' : '' } in scope — the server cannot search E2EE messages. ` }
{ localResult . searchedRoomsCount > 0
? ` No matches found in your locally cached messages from ${ localResult . searchedRoomsCount } room ${ localResult . searchedRoomsCount !== 1 ? 's' : '' } . `
: ` Open those rooms to cache messages locally, then search again. ` }
< / Text >
< / Box >
) }
2024-05-31 19:49:46 +05:30
< / Box >
) }
{ ( ( msgSearchParams . term && status === 'pending' ) ||
( groups . length > 0 && vItems . length === 0 ) ) && (
< Box direction = "Column" gap = "100" >
{ [ . . . Array ( 8 ) . keys ( ) ] . map ( ( key ) = > (
< SequenceCard variant = "SurfaceVariant" key = { key } style = { { minHeight : toRem ( 80 ) } } / >
) ) }
< / Box >
) }
2026-05-28 20:01:21 -04:00
{ msgSearchParams . term &&
localResult &&
localResult . encryptedRoomsCount > 0 &&
vItems . length > 0 && (
< Box
className = { ContainerColor ( { variant : 'Surface' } ) }
style = { { padding : config.space.S300 , borderRadius : config.radii.R400 } }
alignItems = "Center"
gap = "200"
>
< Icon size = "200" src = { Icons . Lock } / >
< Text size = "T300" priority = "300" >
{ ` ${ localResult . encryptedRoomsCount } encrypted room ${ localResult . encryptedRoomsCount !== 1 ? 's' : '' } in scope — server results are from unencrypted rooms only. Encrypted room results appear below from your local cache. ` }
< / Text >
< / Box >
) }
2024-05-31 19:49:46 +05:30
{ vItems . length > 0 && (
< Box direction = "Column" gap = "300" >
< Box direction = "Column" gap = "200" >
< Text size = "H5" > { ` Results for " ${ msgSearchParams . term } " ` } < / Text >
< Line size = "300" variant = "Surface" / >
< / Box >
< div
style = { {
position : 'relative' ,
height : virtualizer.getTotalSize ( ) ,
} }
>
{ vItems . map ( ( vItem ) = > {
const group = groups [ vItem . index ] ;
if ( ! group ) return null ;
const groupRoom = mx . getRoom ( group . roomId ) ;
if ( ! groupRoom ) return null ;
return (
< VirtualTile
virtualItem = { vItem }
style = { { paddingBottom : config.space.S500 } }
ref = { virtualizer . measureElement }
key = { vItem . index }
>
< SearchResultGroup
room = { groupRoom }
highlights = { highlights }
items = { group . items }
mediaAutoLoad = { mediaAutoLoad }
urlPreview = { urlPreview }
onOpen = { navigateRoom }
2025-03-23 22:09:29 +11:00
legacyUsernameColor = { legacyUsernameColor || mDirects . has ( groupRoom . roomId ) }
2025-07-27 15:13:00 +03:00
hour24Clock = { hour24Clock }
dateFormatString = { dateFormatString }
2024-05-31 19:49:46 +05:30
/ >
< / VirtualTile >
) ;
} ) }
< / div >
{ isFetchingNextPage && (
< Box justifyContent = "Center" alignItems = "Center" >
< Spinner size = "600" variant = "Secondary" / >
< / Box >
) }
< / Box >
) }
2026-05-28 20:01:21 -04:00
{ localResult && localResult . groups . length > 0 && (
< Box direction = "Column" gap = "300" >
< Box direction = "Column" gap = "200" >
< Box alignItems = "Center" gap = "200" >
< Icon size = "200" src = { Icons . Lock } / >
< Text size = "H5" > Encrypted Rooms < / Text >
< / Box >
< Text size = "T300" priority = "300" >
{ ` Showing locally cached messages from ${ localResult . searchedRoomsCount } encrypted room ${ localResult . searchedRoomsCount !== 1 ? 's' : '' } . Only recently viewed messages are available. ` }
< / Text >
< Line size = "300" variant = "Surface" / >
< / Box >
< Box direction = "Column" gap = "300" >
{ localResult . groups . map ( ( group ) = > {
const groupRoom = mx . getRoom ( group . roomId ) ;
if ( ! groupRoom ) return null ;
return (
< SearchResultGroup
key = { group . roomId }
room = { groupRoom }
highlights = { [ msgSearchParams . term ? ? '' ] }
items = { group . items }
mediaAutoLoad = { mediaAutoLoad }
urlPreview = { urlPreview }
onOpen = { navigateRoom }
legacyUsernameColor = { legacyUsernameColor || mDirects . has ( groupRoom . roomId ) }
hour24Clock = { hour24Clock }
dateFormatString = { dateFormatString }
/ >
) ;
} ) }
< / Box >
< / Box >
) }
2024-05-31 19:49:46 +05:30
{ error && (
< Box
className = { ContainerColor ( { variant : 'Critical' } ) }
style = { {
padding : config.space.S300 ,
borderRadius : config.radii.R400 ,
} }
direction = "Column"
gap = "200"
>
< Text size = "L400" > { error . name } < / Text >
< Text size = "T300" > { error . message } < / Text >
< / Box >
) }
< / Box >
) ;
}