2026-05-28 20:04:16 -04:00
import React , { RefObject , useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
2026-06-28 21:54:42 -04:00
import {
Text ,
Box ,
Icon ,
Icons ,
color ,
config ,
Spinner ,
IconButton ,
Line ,
toRem ,
Button ,
2026-07-01 21:19:02 -04:00
Switch ,
Chip ,
2026-06-28 21:54:42 -04:00
} from 'folds' ;
2026-06-28 14:22:08 -04:00
import { useAtom , useAtomValue } from 'jotai' ;
2024-05-31 19:49:46 +05:30
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' ;
2026-06-28 16:47:50 -04:00
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types' ;
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' ;
2026-06-28 16:47:50 -04:00
import { getStateEvent } from '../../utils/room' ;
import { StateEvent } from '../../../types/matrix/room' ;
2026-06-28 14:22:08 -04:00
import {
filterGroupsByMsgType ,
2026-06-28 16:47:50 -04:00
filterGroupsByPinned ,
2026-06-28 14:22:08 -04:00
MessageSearchParams ,
MsgTypeFilter ,
2026-06-28 16:47:50 -04:00
ResultGroup ,
2026-06-28 14:22:08 -04:00
useMessageSearch ,
} from './useMessageSearch' ;
2026-07-01 21:19:02 -04:00
import { LocalSearchResult , useLocalMessageSearch } from './useLocalMessageSearch' ;
import { searchCacheEnabledAtom } from '../../state/searchCacheEnabled' ;
import { clearAll as clearSearchCache } from '../../utils/searchCache' ;
2026-06-28 14:22:08 -04:00
import { addRecentSearch , recentSearchesAtom } from '../../state/recentSearches' ;
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-30 22:22:40 -04:00
fromTs : searchParams.get ( 'fromTs' ) ?? undefined ,
toTs : searchParams.get ( 'toTs' ) ?? undefined ,
2026-06-18 19:14:42 -04:00
containsUrl : searchParams.get ( 'containsUrl' ) ?? undefined ,
2024-05-31 19:49:46 +05:30
}),
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 ,
2026-06-28 21:54:42 -04:00
background : color.SurfaceVariant.Container ,
2026-05-28 20:04:16 -04:00
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 >
2026-06-29 15:44:57 -04:00
< Text size = "T200" priority = "300" >
2026-05-28 20:04:16 -04:00
{ 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 && (
2026-06-29 15:44:57 -04:00
< Text size = "T200" priority = "300" style = {{ flexShrink : 0 }}>
2026-05-28 20:04:16 -04:00
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 >;
2026-06-28 14:22:08 -04:00
// Client-side msgtype post-filter. Kept local — the Matrix search API cannot
// filter by msgtype server-side, so the server request is unaffected.
const [ msgTypeFilters , setMsgTypeFilters ] = useState < MsgTypeFilter [] >([]);
2026-06-28 16:47:50 -04:00
// Client-side "pinned only" post-filter. Narrows displayed results to events
// currently pinned in their room (`m.room.pinned_events`). Server-unaffected.
const [ pinnedOnly , setPinnedOnly ] = useState ( false );
2026-06-28 14:22:08 -04:00
const [ recentSearches , setRecentSearches ] = useAtom ( recentSearchesAtom );
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 ,
2026-05-30 22:22:40 -04:00
fromTs : searchPathSearchParams.fromTs ? Number ( searchPathSearchParams . fromTs ) : undefined ,
toTs : searchPathSearchParams.toTs ? Number ( searchPathSearchParams . toTs ) : undefined ,
2026-06-18 19:14:42 -04:00
containsUrl : searchPathSearchParams.containsUrl === 'true' ? true : undefined ,
2024-05-31 19:49:46 +05:30
};
}, [ 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-07-01 21:19:02 -04:00
// Explicit wipe of the persistent on-disk index, then re-run the merge.
const handleClearSearchCache = useCallback (() => {
clearSearchCache (). then (() => setCacheVersion (( v ) => v + 1 ));
}, []);
2026-05-28 20:04:16 -04:00
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-06-15 00:16:24 -04:00
// term === undefined → no search started
// term === '' → sender-only search (from:user with no body text)
// term === 'foo' → normal text search
2026-06-15 20:50:00 -04:00
const hasActiveSearch = msgSearchParams . term !== undefined || !! msgSearchParams . senders ? . length ;
2026-06-15 00:16:24 -04:00
const senderOnlyMode = ! msgSearchParams . term && !! msgSearchParams . senders ? . length ;
2026-07-01 21:19:02 -04:00
// Run the client-side search whenever inputs change.
2026-06-15 00:16:24 -04:00
// In text-search mode: covers encrypted rooms only (server handles plaintext).
// In sender-only mode: covers all rooms (server has no sender-only search).
2026-07-01 21:19:02 -04:00
// The scan is async because — when the persistent cache is enabled — it also
// reads cached rows from IndexedDB and merges them with the in-memory hits.
// cacheVersion in deps so it re-runs after "Load more" paginates new events;
// searchCacheEnabled so toggling the cache re-runs the merge.
const [ searchCacheEnabled , setSearchCacheEnabled ] = useAtom ( searchCacheEnabledAtom );
const [ localResult , setLocalResult ] = useState < LocalSearchResult | null >( null );
useEffect (() => {
if ( ! hasActiveSearch ) {
setLocalResult ( null );
return undefined ;
}
let cancelled = false ;
searchLocalMessages ({
2026-06-15 00:16:24 -04:00
term : msgSearchParams.term ?? '' ,
2026-05-28 22:07:53 -04:00
roomIds : localSearchRooms ,
senders : msgSearchParams.senders ,
2026-07-01 21:19:02 -04:00
fromTs : msgSearchParams.fromTs ,
toTs : msgSearchParams.toTs ,
}). then (( result ) => {
if ( ! cancelled ) setLocalResult ( result );
2026-05-28 22:07:53 -04:00
});
2026-07-01 21:19:02 -04:00
return () => {
cancelled = true ;
};
2026-05-28 22:07:53 -04:00
}, [
searchLocalMessages ,
localSearchRooms ,
msgSearchParams . term ,
msgSearchParams . senders ,
2026-07-01 21:19:02 -04:00
msgSearchParams . fromTs ,
msgSearchParams . toTs ,
hasActiveSearch ,
2026-05-28 22:07:53 -04:00
cacheVersion ,
2026-07-01 21:19:02 -04:00
searchCacheEnabled ,
2026-05-28 22:07:53 -04:00
]);
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 ,
2026-05-30 22:22:40 -04:00
msgSearchParams . fromTs ,
msgSearchParams . toTs ,
2024-05-31 19:49:46 +05:30
],
queryFn : ({ pageParam }) => searchMessages ( pageParam ),
initialPageParam : '' ,
getNextPageParam : ( lastPage ) => lastPage . nextToken ,
});
2026-06-28 16:47:50 -04:00
// Shared client-side post-filter (msgtype + pinned) applied to BOTH the
// server results and the local/encrypted-cache results, so the filter chips
// narrow the whole UI consistently rather than only the server section.
const applyResultFilters = useCallback (
( allGroups : ResultGroup []) : ResultGroup [] => {
const byMsgType = filterGroupsByMsgType ( allGroups , msgTypeFilters );
if ( ! pinnedOnly ) return byMsgType ;
// Build a per-room pinned-event lookup. Heavy Matrix reads stay here
// (where `mx` is available); the pure helper only consumes the predicate.
const pinnedByRoom = new Map < string , Set < string >>();
const isPinned = ( roomId : string , eventId : string ) : boolean => {
let pinned = pinnedByRoom . get ( roomId );
if ( ! pinned ) {
const room = mx . getRoom ( roomId );
const content = room
? getStateEvent (
room ,
StateEvent . RoomPinnedEvents ,
) ? . getContent < RoomPinnedEventsEventContent >()
: undefined ;
pinned = new Set ( content ? . pinned ?? []);
pinnedByRoom . set ( roomId , pinned );
}
return pinned . has ( eventId );
};
return filterGroupsByPinned ( byMsgType , pinnedOnly , isPinned );
},
[ msgTypeFilters , pinnedOnly , mx ],
);
2026-06-28 14:22:08 -04:00
const groups = useMemo (() => {
const allGroups = data ? . pages . flatMap (( result ) => result . groups ) ?? [];
2026-06-28 16:47:50 -04:00
return applyResultFilters ( allGroups );
}, [ data , applyResultFilters ]);
const localGroups = useMemo (
() => ( localResult ? applyResultFilters ( localResult . groups ) : []),
[ localResult , applyResultFilters ],
);
2024-05-31 19:49:46 +05:30
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 ;
});
2026-06-28 14:22:08 -04:00
setRecentSearches (( prev ) => addRecentSearch ( prev , term ));
};
const handleRecentSearch = ( term : string ) => {
if ( searchInputRef . current ) {
searchInputRef . current . value = term ;
}
handleSearch ( term );
2024-05-31 19:49:46 +05:30
};
2026-06-28 14:22:08 -04:00
const handleToggleMsgTypeFilter = useCallback (( msgType : MsgTypeFilter ) => {
setMsgTypeFilters (( prev ) =>
prev . includes ( msgType ) ? prev . filter (( t ) => t !== msgType ) : [... prev , msgType ],
);
}, []);
2026-06-28 16:47:50 -04:00
const handleTogglePinnedOnly = useCallback (() => {
setPinnedOnly (( prev ) => ! prev );
}, []);
2026-06-28 14:22:08 -04:00
const handleClearRecentSearches = useCallback (() => {
setRecentSearches ([]);
}, [ setRecentSearches ]);
2024-05-31 19:49:46 +05:30
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 ],
);
2026-05-30 22:22:40 -04:00
const handleDateRangeChange = useCallback (
( fromTs? : number , toTs? : number ) => {
setSearchParams (( prevParams ) => {
const p = new URLSearchParams ( prevParams );
p . delete ( 'fromTs' );
p . delete ( 'toTs' );
if ( fromTs ) p . append ( 'fromTs' , String ( fromTs ));
if ( toTs ) p . append ( 'toTs' , String ( toTs ));
return p ;
});
},
[ setSearchParams ],
);
2026-06-18 19:14:42 -04:00
const handleContainsUrlChange = useCallback (
( value? : boolean ) => {
setSearchParams (( prevParams ) => {
const p = new URLSearchParams ( prevParams );
p . delete ( 'containsUrl' );
if ( value ) p . append ( 'containsUrl' , 'true' );
return p ;
});
},
[ setSearchParams ],
);
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 }
2026-06-28 14:22:08 -04:00
recentSearches = { recentSearches }
onRecentSearch = { handleRecentSearch }
onClearRecentSearches = { handleClearRecentSearches }
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 }
2026-05-30 22:22:40 -04:00
fromTs = { msgSearchParams . fromTs }
toTs = { msgSearchParams . toTs }
onDateRangeChange = { handleDateRangeChange }
2026-06-18 19:14:42 -04:00
containsUrl = { msgSearchParams . containsUrl }
onContainsUrlChange = { handleContainsUrlChange }
2026-06-28 14:22:08 -04:00
msgTypeFilters = { msgTypeFilters }
onToggleMsgTypeFilter = { handleToggleMsgTypeFilter }
2026-06-28 16:47:50 -04:00
pinnedOnly = { pinnedOnly }
onTogglePinnedOnly = { handleTogglePinnedOnly }
2024-05-31 19:49:46 +05:30
/>
</ Box >
2026-06-15 00:16:24 -04:00
{ ! hasActiveSearch && (
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"
2026-06-15 00:16:24 -04:00
subTitle = "Find helpful messages in your community by searching with related keywords, or type from:@user to see all messages from someone."
2024-05-31 19:49:46 +05:30
/>
</ PageHeroSection >
2025-05-24 20:07:56 +05:30
</ PageHeroEmpty >
2024-05-31 19:49:46 +05:30
)}
2026-06-15 00:16:24 -04:00
{ hasActiveSearch && ! senderOnlyMode && 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 >
)}
2026-06-15 00:16:24 -04:00
{(( ! senderOnlyMode && msgSearchParams . term && status === 'pending' ) ||
2024-05-31 19:49:46 +05:30
( 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 &&
2026-06-15 00:16:24 -04:00
! senderOnlyMode &&
2026-05-28 20:01:21 -04:00
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-06-15 20:50:00 -04:00
{ localResult &&
2026-06-28 16:47:50 -04:00
( senderOnlyMode ? localGroups . length > 0 : localResult.encryptedRoomsCount > 0 ) && (
2026-06-15 20:50:00 -04:00
< Box direction = "Column" gap = "300" >
< Box direction = "Column" gap = "200" >
< Box alignItems = "Center" gap = "200" >
< Icon size = "200" src = { senderOnlyMode ? Icons.User : Icons.Lock } />
< Text size = "H5" >{ senderOnlyMode ? 'Messages from user' : 'Encrypted Rooms' }</ Text >
{ ! senderOnlyMode && (
2026-06-29 15:44:57 -04:00
< Text size = "T200" priority = "300" >
2026-06-15 20:50:00 -04:00
{ ` ${ localResult . searchedRoomsCount } / ${ localResult . encryptedRoomsCount } cached` }
</ Text >
)}
</ Box >
< Text size = "T300" priority = "300" >
{ senderOnlyMode
? `Showing locally cached messages from this user across all rooms. Open more rooms or load history below to extend coverage.`
2026-06-28 16:47:50 -04:00
: localGroups . length > 0
2026-06-15 20:50:00 -04:00
? `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.` }
</ Text >
2026-07-01 21:19:02 -04:00
< Box
alignItems = "Center"
gap = "200"
style = {{
padding : config.space.S200 ,
background : color.SurfaceVariant.Container ,
borderRadius : config.radii.R300 ,
}}
>
< Switch
variant = "Primary"
value = { searchCacheEnabled }
onChange = { setSearchCacheEnabled }
/>
< Box grow = "Yes" direction = "Column" style = {{ minWidth : 0 }}>
< Text size = "T300" > Persist search index on this device </ Text >
< Text size = "T200" priority = "300" >
Stores decrypted text on this device
</ Text >
</ Box >
{ searchCacheEnabled && (
< Chip
variant = "Secondary"
radii = "Pill"
onClick = { handleClearSearchCache }
before = {< Icon size = "100" src = { Icons . Delete } />}
>
< Text size = "T200" > Clear cached index </ Text >
</ Chip >
)}
</ Box >
2026-06-15 20:50:00 -04:00
< Line size = "300" variant = "Surface" />
2026-05-28 20:01:21 -04:00
</ Box >
2026-06-28 16:47:50 -04:00
{ localGroups . length > 0 && (
2026-06-15 20:50:00 -04:00
< Box direction = "Column" gap = "300" >
2026-06-28 16:47:50 -04:00
{ localGroups . map (( group ) => {
2026-06-15 20:50:00 -04:00
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 >
2026-06-15 20:50:00 -04:00
)}
2026-05-28 20:01:21 -04:00
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 >
);
}