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:
@@ -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[] = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user