import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Text, Box, Icon, Icons, config, Spinner, IconButton, Line, toRem, Button } 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 { EventTimeline, EventType, Room, SearchOrderBy } from 'matrix-js-sdk'; import { PageHero, PageHeroEmpty, PageHeroSection } from '../../components/page'; 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'; import { useLocalMessageSearch } from './useLocalMessageSearch'; 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, }), [searchParams], ); type EncryptedRoomCachePanelProps = { roomIds: string[]; onLoaded: () => void; }; function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelProps) { const mx = useMatrixClient(); const [loadingRooms, setLoadingRooms] = useState>(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 ( {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 ( {room.name} {msgEvents.length > 0 ? `${msgEvents.length} messages cached ยท oldest: ${new Date(oldest!.getTs()).toLocaleDateString()}` : 'No messages cached yet'} {(canLoadMore || events.length === 0) && ( )} {!canLoadMore && events.length > 0 && ( Fully cached )} ); })} ); } type MessageSearchProps = { defaultRoomsFilterName: string; allowGlobal?: boolean; rooms: string[]; senders?: string[]; scrollRef: RefObject; }; 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'); const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); const searchInputRef = useRef(null) as React.RefObject; const scrollTopAnchorRef = useRef(null) as React.RefObject; const [searchParams, setSearchParams] = useSearchParams(); const searchPathSearchParams = useSearchPathSearchParams(searchParams); const { navigateRoom } = useRoomNavigate(); const searchParamRooms = useMemo(() => { if (searchPathSearchParams.rooms) { const joinedRoomIds = decodeSearchParamValueArray(searchPathSearchParams.rooms).filter( (rId) => allRooms.includes(rId), ); 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); const searchLocalMessages = useLocalMessageSearch(); // Bump this whenever more messages are loaded so localResult re-computes const [cacheVersion, setCacheVersion] = useState(0); const handleCacheLoaded = useCallback(() => setCacheVersion((v) => v + 1), []); // 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. // cacheVersion in deps so it re-runs after "Load more" paginates new events. const localResult = useMemo(() => { if (!msgSearchParams.term) return null; return searchLocalMessages({ term: msgSearchParams.term, roomIds: localSearchRooms, senders: msgSearchParams.senders, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ searchLocalMessages, localSearchRooms, msgSearchParams.term, msgSearchParams.senders, cacheVersion, ]); 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 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], ); 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 ( virtualizer.scrollToOffset(0)} variant="SurfaceVariant" radii="Pill" outlined size="300" aria-label="Scroll to Top" > {!msgSearchParams.term && status === 'pending' && ( } title="Search Messages" subTitle="Find helpful messages in your community by searching with related keywords." /> )} {msgSearchParams.term && groups.length === 0 && status === 'success' && ( No results found for {`"${msgSearchParams.term}"`} {localResult && localResult.encryptedRoomsCount > 0 && localResult.groups.length === 0 && ( {`${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.`} )} )} {((msgSearchParams.term && status === 'pending') || (groups.length > 0 && vItems.length === 0)) && ( {[...Array(8).keys()].map((key) => ( ))} )} {msgSearchParams.term && localResult && localResult.encryptedRoomsCount > 0 && vItems.length > 0 && ( {`${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.`} )} {vItems.length > 0 && ( {`Results for "${msgSearchParams.term}"`}
{vItems.map((vItem) => { const group = groups[vItem.index]; if (!group) return null; const groupRoom = mx.getRoom(group.roomId); if (!groupRoom) return null; return ( ); })}
{isFetchingNextPage && ( )}
)} {localResult && localResult.encryptedRoomsCount > 0 && ( Encrypted Rooms {`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`} {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.`} {localResult.groups.length > 0 && ( {localResult.groups.map((group) => { const groupRoom = mx.getRoom(group.roomId); if (!groupRoom) return null; return ( ); })} )} )} {error && ( {error.name} {error.message} )}
); }