Files
cinny/src/app/features/message-search/MessageSearch.tsx
T
jared 48f9221f1c fix: resolve ESLint no-shadow errors in CallEmbedProvider
The rect variable in the onUp and onTouchEnd closures was shadowing the
outer rect declaration in handlePipMouseDown and handlePipTouchStart.
Renamed inner declarations to savedRect. Also renamed rect → elRect in
handlePipDoubleClick for the same reason.

Removed unused eslint-disable-next-line comment in MessageSearch.tsx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:31:38 -04:00

564 lines
20 KiB
TypeScript

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<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>
);
}
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');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const searchInputRef = useRef<HTMLInputElement>(null) as React.RefObject<HTMLInputElement>;
const scrollTopAnchorRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
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 (
<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}
onSenderAdd={handleSenderAdd}
/>
<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}
selectedSenders={searchParamsSenders}
onSelectedSendersChange={handleSelectedSendersChange}
/>
</Box>
{!msgSearchParams.term && status === 'pending' && (
<PageHeroEmpty>
<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>
</PageHeroEmpty>
)}
{msgSearchParams.term && groups.length === 0 && status === 'success' && (
<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>
)}
</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>
)}
{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>
)}
{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}
legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</VirtualTile>
);
})}
</div>
{isFetchingNextPage && (
<Box justifyContent="Center" alignItems="Center">
<Spinner size="600" variant="Secondary" />
</Box>
)}
</Box>
)}
{localResult && localResult.encryptedRoomsCount > 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>
<Text size="T200" style={{ opacity: 0.55 }}>
{`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`}
</Text>
</Box>
<Text size="T300" priority="300">
{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.`}
</Text>
<Line size="300" variant="Surface" />
</Box>
{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} />
</Box>
)}
{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>
);
}