feat(search): "Pinned only" filter (composes with msgtype + local results)

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<roomId, Set<eventId>> 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 16:47:50 -04:00
parent da545ba9b9
commit de6cecaffc
3 changed files with 101 additions and 6 deletions
@@ -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<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();
@@ -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<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);
},
[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}
/>
</Box>
@@ -588,7 +637,7 @@ export function MessageSearch({
)}
{localResult &&
(senderOnlyMode ? localResult.groups.length > 0 : localResult.encryptedRoomsCount > 0) && (
(senderOnlyMode ? localGroups.length > 0 : localResult.encryptedRoomsCount > 0) && (
<Box direction="Column" gap="300">
<Box direction="Column" gap="200">
<Box alignItems="Center" gap="200">
@@ -603,15 +652,15 @@ export function MessageSearch({
<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.`
: 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.`}
</Text>
<Line size="300" variant="Surface" />
</Box>
{localResult.groups.length > 0 && (
{localGroups.length > 0 && (
<Box direction="Column" gap="300">
{localResult.groups.map((group) => {
{localGroups.map((group) => {
const groupRoom = mx.getRoom(group.roomId);
if (!groupRoom) return null;
return (
@@ -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({
</Chip>
);
})}
<Chip
variant={pinnedOnly ? 'Success' : 'SurfaceVariant'}
outlined={pinnedOnly}
radii="Pill"
aria-pressed={pinnedOnly}
before={<Icon size="100" src={Icons.Pin} />}
after={
pinnedOnly ? (
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => {
e.stopPropagation();
onTogglePinnedOnly();
}}
/>
) : undefined
}
onClick={onTogglePinnedOnly}
>
<Text size="T200">Pinned</Text>
</Chip>
<DateRangeButton fromTs={fromTs} toTs={toTs} onChange={onDateRangeChange} />
<OrderButton order={order} onChange={onOrderChange} />
</Box>
@@ -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[] = [];