From ec110d4ef7e154d96ca4d3a8291b2e62bce9fc30 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 28 May 2026 20:04:16 -0400 Subject: [PATCH] feat: add encrypted room cache panel with load/load-more buttons Each encrypted room in scope shows: - Message count and oldest cached date - 'Load messages' if no cache, 'Load more' if more history available - 'Fully cached' label when all history is loaded Clicking load/load-more paginates backwards 100 messages at a time. localResult re-computes via cacheVersion after each load so search results update automatically without re-typing the query. Co-Authored-By: Claude Sonnet 4.6 --- .../features/message-search/MessageSearch.tsx | 167 +++++++++++++++--- 1 file changed, 140 insertions(+), 27 deletions(-) diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index 3bf91759f..21e3cc19f 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -1,10 +1,10 @@ -import React, { RefObject, useEffect, useMemo, useRef } from 'react'; -import { Text, Box, Icon, Icons, config, Spinner, IconButton, Line, toRem } from 'folds'; +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 { SearchOrderBy } from 'matrix-js-sdk'; +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'; @@ -37,6 +37,107 @@ const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSe [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; @@ -98,17 +199,24 @@ export function MessageSearch({ 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 + // Run synchronous client-side search over encrypted rooms immediately. + // cacheVersion in deps so it re-runs after "Load more" paginates new events. + // eslint-disable-next-line react-hooks/exhaustive-deps const localResult = useMemo(() => { if (!msgSearchParams.term) return null; return searchLocalMessages(localSearchRooms, msgSearchParams.term); - }, [searchLocalMessages, localSearchRooms, msgSearchParams.term]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchLocalMessages, localSearchRooms, msgSearchParams.term, cacheVersion]); const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ enabled: !!msgSearchParams.term, @@ -358,7 +466,7 @@ export function MessageSearch({ )} - {localResult && localResult.groups.length > 0 && ( + {localResult && localResult.encryptedRoomsCount > 0 && ( @@ -366,30 +474,35 @@ export function MessageSearch({ Encrypted Rooms - {`Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Only recently viewed messages are available.`} + {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.map((group) => { - const groupRoom = mx.getRoom(group.roomId); - if (!groupRoom) return null; - return ( - - ); - })} - + {localResult.groups.length > 0 && ( + + {localResult.groups.map((group) => { + const groupRoom = mx.getRoom(group.roomId); + if (!groupRoom) return null; + return ( + + ); + })} + + )} + )}