2026-05-28 20:04:16 -04:00
import React , { RefObject , useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
import { Text , Box , Icon , Icons , config , Spinner , IconButton , Line , toRem , Button } from 'folds' ;
2024-05-31 19:49:46 +05:30
import { useAtomValue } from 'jotai' ;
import { useVirtualizer } from '@tanstack/react-virtual' ;
import { useInfiniteQuery } from '@tanstack/react-query' ;
import { useSearchParams } from 'react-router-dom' ;
2026-05-28 20:04:16 -04:00
import { EventTimeline , EventType , Room , 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
) ;
2026-05-28 20:04:16 -04:00
type EncryptedRoomCachePanelProps = {
roomIds : string [ ] ;
onLoaded : ( ) = > void ;
} ;
function EncryptedRoomCachePanel ( { roomIds , onLoaded } : EncryptedRoomCachePanelProps ) {
const mx = useMatrixClient ( ) ;
const [ loadingRooms , setLoadingRooms ] = useState < Set < string > > ( new Set ( ) ) ;
const encryptedRooms = useMemo (
( ) = >
roomIds
. map ( ( id ) = > mx . getRoom ( id ) )
. filter (
( room ) : room is Room = >
! ! room && ! ! room . currentState . getStateEvents ( EventType . RoomEncryption , '' ) ,
) ,
[ mx , roomIds ] ,
) ;
const handleLoad = useCallback (
async ( roomId : string ) = > {
const room = mx . getRoom ( roomId ) ;
if ( ! room ) return ;
setLoadingRooms ( ( prev ) = > new Set ( [ . . . prev , roomId ] ) ) ;
try {
await mx . paginateEventTimeline ( room . getLiveTimeline ( ) , {
backwards : true ,
limit : 100 ,
} ) ;
onLoaded ( ) ;
} catch {
// ignore — room may have no more history or be rate-limited
} finally {
setLoadingRooms ( ( prev ) = > {
const next = new Set ( prev ) ;
next . delete ( roomId ) ;
return next ;
} ) ;
}
} ,
[ mx , onLoaded ] ,
) ;
if ( encryptedRooms . length === 0 ) return null ;
return (
< Box direction = "Column" gap = "100" >
{ encryptedRooms . map ( ( room ) = > {
const events = room . getLiveTimeline ( ) . getEvents ( ) ;
const msgEvents = events . filter ( ( e ) = > ! e . isDecryptionFailure ( ) && ! e . isRedacted ( ) ) ;
const oldest = msgEvents . length > 0 ? msgEvents [ 0 ] : null ;
const canLoadMore = ! ! room . getLiveTimeline ( ) . getPaginationToken ( EventTimeline . BACKWARDS ) ;
const isLoading = loadingRooms . has ( room . roomId ) ;
return (
< Box
key = { room . roomId }
alignItems = "Center"
gap = "200"
style = { {
padding : config.space.S200 ,
background : 'var(--bg-surface-variant)' ,
borderRadius : config.radii.R300 ,
} }
>
< Icon size = "100" src = { Icons . Lock } style = { { flexShrink : 0 , opacity : 0.6 } } / >
< Box grow = "Yes" direction = "Column" style = { { minWidth : 0 } } >
< Text size = "T300" truncate >
{ room . name }
< / Text >
< Text size = "T200" style = { { opacity : 0.55 } } >
{ msgEvents . length > 0
? ` ${ msgEvents . length } messages cached · oldest: ${ new Date ( oldest ! . getTs ( ) ) . toLocaleDateString ( ) } `
: 'No messages cached yet' }
< / Text >
< / Box >
{ ( canLoadMore || events . length === 0 ) && (
< Button
size = "300"
variant = "Secondary"
fill = "Soft"
radii = "300"
onClick = { ( ) = > handleLoad ( room . roomId ) }
disabled = { isLoading }
before = { isLoading ? < Spinner size = "100" variant = "Secondary" / > : undefined }
>
< Text size = "B300" > { events . length === 0 ? 'Load messages' : 'Load more' } < / Text >
< / Button >
) }
{ ! canLoadMore && events . length > 0 && (
< Text size = "T200" style = { { opacity : 0.5 , flexShrink : 0 } } >
Fully cached
< / Text >
) }
< / Box >
) ;
} ) }
< / Box >
) ;
}
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 ( ) ;
2026-05-28 20:04:16 -04:00
// Bump this whenever more messages are loaded so localResult re-computes
const [ cacheVersion , setCacheVersion ] = useState ( 0 ) ;
const handleCacheLoaded = useCallback ( ( ) = > setCacheVersion ( ( v ) = > v + 1 ) , [ ] ) ;
2026-05-28 20:01:21 -04:00
// 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 ] ,
) ;
2026-05-28 20:04:16 -04:00
// Run synchronous client-side search over encrypted rooms immediately.
// cacheVersion in deps so it re-runs after "Load more" paginates new events.
2026-05-28 20:01:21 -04:00
const localResult = useMemo ( ( ) = > {
if ( ! msgSearchParams . term ) return null ;
2026-05-28 22:07:53 -04:00
return searchLocalMessages ( {
term : msgSearchParams.term ,
roomIds : localSearchRooms ,
senders : msgSearchParams.senders ,
} ) ;
2026-05-28 20:04:16 -04:00
// eslint-disable-next-line react-hooks/exhaustive-deps
2026-05-28 22:07:53 -04:00
} , [
searchLocalMessages ,
localSearchRooms ,
msgSearchParams . term ,
msgSearchParams . senders ,
cacheVersion ,
] ) ;
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 ;
} ) ;
} ;
2026-05-28 22:07:53 -04:00
const handleSelectedSendersChange = useCallback (
( newSenders? : string [ ] ) = > {
setSearchParams ( ( prevParams ) = > {
const p = new URLSearchParams ( prevParams ) ;
p . delete ( 'senders' ) ;
if ( newSenders && newSenders . length > 0 ) {
p . append ( 'senders' , encodeSearchParamValueArray ( newSenders ) ) ;
}
return p ;
} ) ;
} ,
[ setSearchParams ] ,
) ;
const handleSenderAdd = useCallback (
( userId : string ) = > {
const current = searchParamsSenders ? ? [ ] ;
if ( current . includes ( userId ) ) return ;
handleSelectedSendersChange ( [ . . . current , userId ] ) ;
} ,
[ searchParamsSenders , handleSelectedSendersChange ] ,
) ;
2024-05-31 19:49:46 +05:30
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 }
2026-05-28 22:07:53 -04:00
onSenderAdd = { handleSenderAdd }
2024-05-31 19:49:46 +05:30
/ >
< 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 }
2026-05-28 22:07:53 -04:00
selectedSenders = { searchParamsSenders }
onSelectedSendersChange = { handleSelectedSendersChange }
2024-05-31 19:49:46 +05:30
/ >
< / 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:04:16 -04:00
{ localResult && localResult . encryptedRoomsCount > 0 && (
2026-05-28 20:01:21 -04:00
< 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 >
2026-05-30 17:13:54 -04:00
< Text size = "T200" style = { { opacity : 0.55 } } >
{ ` ${ localResult . searchedRoomsCount } / ${ localResult . encryptedRoomsCount } cached ` }
< / Text >
2026-05-28 20:01:21 -04:00
< / Box >
< Text size = "T300" priority = "300" >
2026-05-28 20:04:16 -04:00
{ localResult . groups . length > 0
? ` Showing locally cached messages from ${ localResult . searchedRoomsCount } encrypted room ${ localResult . searchedRoomsCount !== 1 ? 's' : '' } . Load more history below to extend coverage. `
: ` No matches in your local cache. Load messages below to search further back. ` }
2026-05-28 20:01:21 -04:00
< / Text >
< Line size = "300" variant = "Surface" / >
< / Box >
2026-05-28 20:04:16 -04:00
{ localResult . groups . length > 0 && (
< 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 >
) }
< EncryptedRoomCachePanel roomIds = { localSearchRooms } onLoaded = { handleCacheLoaded } / >
2026-05-28 20:01:21 -04:00
< / 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 >
) ;
}