CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Audit of every rendered time found five families of ad-hoc formatting: the shared Time component + copies of its today/yesterday branch (forwarded header, thread summary, read receipts, device tile, moderation alerts, edit history), locale-default toLocale*String calls that ignored the user's 12/24 h and date-format settings (scheduled tray, reminders, schedule preview, notification snooze, bookmarks, threads list, search cache line, room insights, media gallery), a hard-coded en-US date in the activity log, and three relative-age variants. utils/formatTimestamp.ts now holds the rules — today → time; yesterday / tomorrow → day word + time; last 6 days → weekday + time; older → date + time in dateFormatString — plus autoDate / time / date / dateTime styles, formatDayDivider (full weekday), formatShortAge (room list) and formatRelativeAge (list rows). useTimestampFormatter binds them to the settings. 11 unit tests with an injected 'now'. Visible changes are limited to consistency: 12 h times keep the existing zero-padded hh:mm A; the a11y label and Created-by line use the user's date format instead of a fixed long month. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
796 lines
30 KiB
TypeScript
796 lines
30 KiB
TypeScript
import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Text,
|
|
Box,
|
|
Icon,
|
|
Icons,
|
|
color,
|
|
config,
|
|
Spinner,
|
|
IconButton,
|
|
Line,
|
|
toRem,
|
|
Button,
|
|
Switch,
|
|
Chip,
|
|
} from 'folds';
|
|
import { useAtom, 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 { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
|
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 { getStateEvent } from '../../utils/room';
|
|
import { StateEvent } from '../../../types/matrix/room';
|
|
import {
|
|
filterGroupsByDateRange,
|
|
filterGroupsByMsgType,
|
|
filterGroupsByPinned,
|
|
MessageSearchParams,
|
|
MsgTypeFilter,
|
|
ResultGroup,
|
|
useMessageSearch,
|
|
} from './useMessageSearch';
|
|
import { LocalSearchResult, useLocalMessageSearch } from './useLocalMessageSearch';
|
|
import { searchCacheEnabledAtom } from '../../state/searchCacheEnabled';
|
|
import { clearAll as clearSearchCache } from '../../utils/searchCache';
|
|
import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches';
|
|
import { SearchResultGroup } from './SearchResultGroup';
|
|
import { SearchInput } from './SearchInput';
|
|
import { SearchFilters } from './SearchFilters';
|
|
import { VirtualTile } from '../../components/virtualizer';
|
|
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
|
|
|
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,
|
|
fromTs: searchParams.get('fromTs') ?? undefined,
|
|
toTs: searchParams.get('toTs') ?? undefined,
|
|
containsUrl: searchParams.get('containsUrl') ?? undefined,
|
|
}),
|
|
[searchParams],
|
|
);
|
|
|
|
type EncryptedRoomCachePanelProps = {
|
|
roomIds: string[];
|
|
onLoaded: () => void;
|
|
};
|
|
function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelProps) {
|
|
const mx = useMatrixClient();
|
|
const { format } = useTimestampFormatter();
|
|
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: color.SurfaceVariant.Container,
|
|
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" priority="300">
|
|
{msgEvents.length > 0
|
|
? `${msgEvents.length} messages cached · oldest: ${format(oldest!.getTs(), 'date')}`
|
|
: '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" priority="300" style={{ 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>;
|
|
|
|
// 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[]>([]);
|
|
// 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);
|
|
const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom);
|
|
|
|
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,
|
|
fromTs: searchPathSearchParams.fromTs ? Number(searchPathSearchParams.fromTs) : undefined,
|
|
toTs: searchPathSearchParams.toTs ? Number(searchPathSearchParams.toTs) : undefined,
|
|
containsUrl: searchPathSearchParams.containsUrl === 'true' ? true : undefined,
|
|
};
|
|
}, [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), []);
|
|
// Explicit wipe of the persistent on-disk index, then re-run the merge.
|
|
// After a clear, the active search re-runs (cacheVersion) and would persist
|
|
// the in-memory rows straight back — skip that one write so "Clear cached
|
|
// index" actually leaves the disk empty until the next new search.
|
|
const skipPersistOnceRef = useRef(false);
|
|
const handleClearSearchCache = useCallback(() => {
|
|
clearSearchCache().then(() => {
|
|
skipPersistOnceRef.current = true;
|
|
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],
|
|
);
|
|
|
|
// term === undefined → no search started
|
|
// term === '' → sender-only search (from:user with no body text)
|
|
// term === 'foo' → normal text search
|
|
const hasActiveSearch = msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length;
|
|
const senderOnlyMode = !msgSearchParams.term && !!msgSearchParams.senders?.length;
|
|
|
|
// Run the client-side search whenever inputs change.
|
|
// In text-search mode: covers encrypted rooms only (server handles plaintext).
|
|
// In sender-only mode: covers all rooms (server has no sender-only search).
|
|
// 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;
|
|
const persist = !skipPersistOnceRef.current;
|
|
skipPersistOnceRef.current = false;
|
|
searchLocalMessages({
|
|
term: msgSearchParams.term ?? '',
|
|
roomIds: localSearchRooms,
|
|
senders: msgSearchParams.senders,
|
|
fromTs: msgSearchParams.fromTs,
|
|
toTs: msgSearchParams.toTs,
|
|
persist,
|
|
}).then((result) => {
|
|
if (!cancelled) setLocalResult(result);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [
|
|
searchLocalMessages,
|
|
localSearchRooms,
|
|
msgSearchParams.term,
|
|
msgSearchParams.senders,
|
|
msgSearchParams.fromTs,
|
|
msgSearchParams.toTs,
|
|
hasActiveSearch,
|
|
cacheVersion,
|
|
searchCacheEnabled,
|
|
]);
|
|
|
|
const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
|
|
enabled: !!msgSearchParams.term,
|
|
queryKey: [
|
|
'search',
|
|
msgSearchParams.term,
|
|
msgSearchParams.order,
|
|
msgSearchParams.rooms,
|
|
msgSearchParams.senders,
|
|
msgSearchParams.fromTs,
|
|
msgSearchParams.toTs,
|
|
],
|
|
queryFn: ({ pageParam }) => searchMessages(pageParam),
|
|
initialPageParam: '',
|
|
getNextPageParam: (lastPage) => lastPage.nextToken,
|
|
});
|
|
|
|
// Shared client-side post-filter (date range + 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. The date range must be enforced here because the Matrix
|
|
// search API has no timestamp filter fields (see useMessageSearch.ts); the
|
|
// local/encrypted path already filters in-range before this runs, so this
|
|
// is a no-op there and only actually trims the server section.
|
|
const applyResultFilters = useCallback(
|
|
(allGroups: ResultGroup[]): ResultGroup[] => {
|
|
const inDateRange = filterGroupsByDateRange(
|
|
allGroups,
|
|
msgSearchParams.fromTs,
|
|
msgSearchParams.toTs,
|
|
);
|
|
const byMsgType = filterGroupsByMsgType(inDateRange, 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);
|
|
},
|
|
[msgSearchParams.fromTs, msgSearchParams.toTs, msgTypeFilters, pinnedOnly, mx],
|
|
);
|
|
|
|
const groups = useMemo(() => {
|
|
const allGroups = data?.pages.flatMap((result) => result.groups) ?? [];
|
|
return applyResultFilters(allGroups);
|
|
}, [data, applyResultFilters]);
|
|
|
|
const localGroups = useMemo(
|
|
() => (localResult ? applyResultFilters(localResult.groups) : []),
|
|
[localResult, applyResultFilters],
|
|
);
|
|
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;
|
|
});
|
|
setRecentSearches((prev) => addRecentSearch(prev, term));
|
|
};
|
|
|
|
const handleRecentSearch = (term: string) => {
|
|
if (searchInputRef.current) {
|
|
searchInputRef.current.value = term;
|
|
}
|
|
handleSearch(term);
|
|
};
|
|
|
|
const handleToggleMsgTypeFilter = useCallback((msgType: MsgTypeFilter) => {
|
|
setMsgTypeFilters((prev) =>
|
|
prev.includes(msgType) ? prev.filter((t) => t !== msgType) : [...prev, msgType],
|
|
);
|
|
}, []);
|
|
|
|
const handleTogglePinnedOnly = useCallback(() => {
|
|
setPinnedOnly((prev) => !prev);
|
|
}, []);
|
|
|
|
const handleClearRecentSearches = useCallback(() => {
|
|
setRecentSearches([]);
|
|
}, [setRecentSearches]);
|
|
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 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],
|
|
);
|
|
|
|
const handleContainsUrlChange = useCallback(
|
|
(value?: boolean) => {
|
|
setSearchParams((prevParams) => {
|
|
const p = new URLSearchParams(prevParams);
|
|
p.delete('containsUrl');
|
|
if (value) p.append('containsUrl', 'true');
|
|
return p;
|
|
});
|
|
},
|
|
[setSearchParams],
|
|
);
|
|
|
|
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}
|
|
recentSearches={recentSearches}
|
|
onRecentSearch={handleRecentSearch}
|
|
onClearRecentSearches={handleClearRecentSearches}
|
|
/>
|
|
<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}
|
|
fromTs={msgSearchParams.fromTs}
|
|
toTs={msgSearchParams.toTs}
|
|
onDateRangeChange={handleDateRangeChange}
|
|
containsUrl={msgSearchParams.containsUrl}
|
|
onContainsUrlChange={handleContainsUrlChange}
|
|
msgTypeFilters={msgTypeFilters}
|
|
onToggleMsgTypeFilter={handleToggleMsgTypeFilter}
|
|
pinnedOnly={pinnedOnly}
|
|
onTogglePinnedOnly={handleTogglePinnedOnly}
|
|
/>
|
|
</Box>
|
|
|
|
{!hasActiveSearch && (
|
|
<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, or type from:@user to see all messages from someone."
|
|
/>
|
|
</PageHeroSection>
|
|
</PageHeroEmpty>
|
|
)}
|
|
|
|
{hasActiveSearch && !senderOnlyMode && 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>
|
|
)}
|
|
|
|
{((!senderOnlyMode && 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 &&
|
|
!senderOnlyMode &&
|
|
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 &&
|
|
(senderOnlyMode ? localGroups.length > 0 : localResult.encryptedRoomsCount > 0) && (
|
|
<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 && (
|
|
<Text size="T200" priority="300">
|
|
{`${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.`
|
|
: localGroups.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>
|
|
<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>
|
|
<Line size="300" variant="Surface" />
|
|
</Box>
|
|
{localGroups.length > 0 && (
|
|
<Box direction="Column" gap="300">
|
|
{localGroups.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>
|
|
);
|
|
}
|