From de6cecaffca34631af8a277b6a6085e3088a76dd Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 28 Jun 2026 16:47:50 -0400 Subject: [PATCH] feat(search): "Pinned only" filter (composes with msgtype + local results) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Pinned" toggle chip that narrows results to messages currently in their room's m.room.pinned_events. Client-side post-filter mirroring the has:image/file/video pattern: a pure filterGroupsByPinned(groups, enabled, isPinned) helper consumes a predicate; MessageSearch builds a per-room Map> from StateEvent.RoomPinnedEvents. Review fix: the msgtype + pinned filters are now applied to BOTH the server results AND the encrypted/local-cache results (via a shared applyResultFilters useCallback), so the chips narrow the whole UI consistently — previously the local/E2EE section bypassed them. Co-Authored-By: Claude Opus 4.8 --- .../features/message-search/MessageSearch.tsx | 61 +++++++++++++++++-- .../features/message-search/SearchFilters.tsx | 26 ++++++++ .../message-search/useMessageSearch.ts | 20 ++++++ 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index 5782409a4..e5a7590bf 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -5,6 +5,7 @@ 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'; @@ -18,10 +19,14 @@ import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../ 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 { filterGroupsByMsgType, + filterGroupsByPinned, MessageSearchParams, MsgTypeFilter, + ResultGroup, useMessageSearch, } from './useMessageSearch'; import { useLocalMessageSearch } from './useLocalMessageSearch'; @@ -177,6 +182,9 @@ export function MessageSearch({ // 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([]); + // 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(); @@ -269,10 +277,45 @@ export function MessageSearch({ getNextPageParam: (lastPage) => lastPage.nextToken, }); + // 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>(); + 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() + : undefined; + pinned = new Set(content?.pinned ?? []); + pinnedByRoom.set(roomId, pinned); + } + return pinned.has(eventId); + }; + return filterGroupsByPinned(byMsgType, pinnedOnly, isPinned); + }, + [msgTypeFilters, pinnedOnly, mx], + ); + const groups = useMemo(() => { const allGroups = data?.pages.flatMap((result) => result.groups) ?? []; - return filterGroupsByMsgType(allGroups, msgTypeFilters); - }, [data, msgTypeFilters]); + 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)); @@ -309,6 +352,10 @@ export function MessageSearch({ ); }, []); + const handleTogglePinnedOnly = useCallback(() => { + setPinnedOnly((prev) => !prev); + }, []); + const handleClearRecentSearches = useCallback(() => { setRecentSearches([]); }, [setRecentSearches]); @@ -463,6 +510,8 @@ export function MessageSearch({ onContainsUrlChange={handleContainsUrlChange} msgTypeFilters={msgTypeFilters} onToggleMsgTypeFilter={handleToggleMsgTypeFilter} + pinnedOnly={pinnedOnly} + onTogglePinnedOnly={handleTogglePinnedOnly} /> @@ -588,7 +637,7 @@ export function MessageSearch({ )} {localResult && - (senderOnlyMode ? localResult.groups.length > 0 : localResult.encryptedRoomsCount > 0) && ( + (senderOnlyMode ? localGroups.length > 0 : localResult.encryptedRoomsCount > 0) && ( @@ -603,15 +652,15 @@ export function MessageSearch({ {senderOnlyMode ? `Showing locally cached messages from this user across all rooms. Open more rooms or load history below to extend coverage.` - : localResult.groups.length > 0 + : 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.`} - {localResult.groups.length > 0 && ( + {localGroups.length > 0 && ( - {localResult.groups.map((group) => { + {localGroups.map((group) => { const groupRoom = mx.getRoom(group.roomId); if (!groupRoom) return null; return ( diff --git a/src/app/features/message-search/SearchFilters.tsx b/src/app/features/message-search/SearchFilters.tsx index 284982d5e..ca5ffbb53 100644 --- a/src/app/features/message-search/SearchFilters.tsx +++ b/src/app/features/message-search/SearchFilters.tsx @@ -684,6 +684,8 @@ type SearchFiltersProps = { onContainsUrlChange: (value?: boolean) => void; msgTypeFilters: MsgTypeFilter[]; onToggleMsgTypeFilter: (msgType: MsgTypeFilter) => void; + pinnedOnly: boolean; + onTogglePinnedOnly: () => void; }; export function SearchFilters({ defaultRoomsFilterName, @@ -704,6 +706,8 @@ export function SearchFilters({ onContainsUrlChange, msgTypeFilters, onToggleMsgTypeFilter, + pinnedOnly, + onTogglePinnedOnly, }: SearchFiltersProps) { const mx = useMatrixClient(); @@ -835,6 +839,28 @@ export function SearchFilters({ ); })} + } + after={ + pinnedOnly ? ( + { + e.stopPropagation(); + onTogglePinnedOnly(); + }} + /> + ) : undefined + } + onClick={onTogglePinnedOnly} + > + Pinned + diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index 7c7826fc5..7e7e0644c 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -51,6 +51,26 @@ export const filterGroupsByMsgType = ( .filter((group) => group.items.length > 0); }; +/** + * Filter result groups to items whose event is currently pinned in its room. + * `isPinned(roomId, eventId)` returns whether the event is in the room's + * `m.room.pinned_events` set. When `enabled` is false, groups are returned + * unchanged. Now-empty groups are dropped. + */ +export const filterGroupsByPinned = ( + groups: ResultGroup[], + enabled: boolean, + isPinned: (roomId: string, eventId: string) => boolean, +): ResultGroup[] => { + if (!enabled) return groups; + return groups + .map((group) => ({ + ...group, + items: group.items.filter((item) => isPinned(group.roomId, item.event.event_id)), + })) + .filter((group) => group.items.length > 0); +}; + const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => { const groups: ResultGroup[] = [];