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 { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { EventTimeline, EventType, Room, SearchOrderBy } from 'matrix-js-sdk';
|
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 { PageHero, PageHeroEmpty, PageHeroSection } from '../../components/page';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { _SearchPathSearchParams } from '../../pages/paths';
|
import { _SearchPathSearchParams } from '../../pages/paths';
|
||||||
@@ -18,10 +19,14 @@ import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../
|
|||||||
import { useRooms } from '../../state/hooks/roomList';
|
import { useRooms } from '../../state/hooks/roomList';
|
||||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
|
import { getStateEvent } from '../../utils/room';
|
||||||
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import {
|
import {
|
||||||
filterGroupsByMsgType,
|
filterGroupsByMsgType,
|
||||||
|
filterGroupsByPinned,
|
||||||
MessageSearchParams,
|
MessageSearchParams,
|
||||||
MsgTypeFilter,
|
MsgTypeFilter,
|
||||||
|
ResultGroup,
|
||||||
useMessageSearch,
|
useMessageSearch,
|
||||||
} from './useMessageSearch';
|
} from './useMessageSearch';
|
||||||
import { useLocalMessageSearch } from './useLocalMessageSearch';
|
import { useLocalMessageSearch } from './useLocalMessageSearch';
|
||||||
@@ -177,6 +182,9 @@ export function MessageSearch({
|
|||||||
// Client-side msgtype post-filter. Kept local — the Matrix search API cannot
|
// Client-side msgtype post-filter. Kept local — the Matrix search API cannot
|
||||||
// filter by msgtype server-side, so the server request is unaffected.
|
// filter by msgtype server-side, so the server request is unaffected.
|
||||||
const [msgTypeFilters, setMsgTypeFilters] = useState<MsgTypeFilter[]>([]);
|
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 [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom);
|
||||||
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -269,10 +277,45 @@ export function MessageSearch({
|
|||||||
getNextPageParam: (lastPage) => lastPage.nextToken,
|
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 groups = useMemo(() => {
|
||||||
const allGroups = data?.pages.flatMap((result) => result.groups) ?? [];
|
const allGroups = data?.pages.flatMap((result) => result.groups) ?? [];
|
||||||
return filterGroupsByMsgType(allGroups, msgTypeFilters);
|
return applyResultFilters(allGroups);
|
||||||
}, [data, msgTypeFilters]);
|
}, [data, applyResultFilters]);
|
||||||
|
|
||||||
|
const localGroups = useMemo(
|
||||||
|
() => (localResult ? applyResultFilters(localResult.groups) : []),
|
||||||
|
[localResult, applyResultFilters],
|
||||||
|
);
|
||||||
const highlights = useMemo(() => {
|
const highlights = useMemo(() => {
|
||||||
const mixed = data?.pages.flatMap((result) => result.highlights);
|
const mixed = data?.pages.flatMap((result) => result.highlights);
|
||||||
return Array.from(new Set(mixed));
|
return Array.from(new Set(mixed));
|
||||||
@@ -309,6 +352,10 @@ export function MessageSearch({
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleTogglePinnedOnly = useCallback(() => {
|
||||||
|
setPinnedOnly((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleClearRecentSearches = useCallback(() => {
|
const handleClearRecentSearches = useCallback(() => {
|
||||||
setRecentSearches([]);
|
setRecentSearches([]);
|
||||||
}, [setRecentSearches]);
|
}, [setRecentSearches]);
|
||||||
@@ -463,6 +510,8 @@ export function MessageSearch({
|
|||||||
onContainsUrlChange={handleContainsUrlChange}
|
onContainsUrlChange={handleContainsUrlChange}
|
||||||
msgTypeFilters={msgTypeFilters}
|
msgTypeFilters={msgTypeFilters}
|
||||||
onToggleMsgTypeFilter={handleToggleMsgTypeFilter}
|
onToggleMsgTypeFilter={handleToggleMsgTypeFilter}
|
||||||
|
pinnedOnly={pinnedOnly}
|
||||||
|
onTogglePinnedOnly={handleTogglePinnedOnly}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -588,7 +637,7 @@ export function MessageSearch({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{localResult &&
|
{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="300">
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Box alignItems="Center" gap="200">
|
<Box alignItems="Center" gap="200">
|
||||||
@@ -603,15 +652,15 @@ export function MessageSearch({
|
|||||||
<Text size="T300" priority="300">
|
<Text size="T300" priority="300">
|
||||||
{senderOnlyMode
|
{senderOnlyMode
|
||||||
? `Showing locally cached messages from this user across all rooms. Open more rooms or load history below to extend coverage.`
|
? `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.`
|
? `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.`}
|
: `No matches in your local cache. Load messages below to search further back.`}
|
||||||
</Text>
|
</Text>
|
||||||
<Line size="300" variant="Surface" />
|
<Line size="300" variant="Surface" />
|
||||||
</Box>
|
</Box>
|
||||||
{localResult.groups.length > 0 && (
|
{localGroups.length > 0 && (
|
||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300">
|
||||||
{localResult.groups.map((group) => {
|
{localGroups.map((group) => {
|
||||||
const groupRoom = mx.getRoom(group.roomId);
|
const groupRoom = mx.getRoom(group.roomId);
|
||||||
if (!groupRoom) return null;
|
if (!groupRoom) return null;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -684,6 +684,8 @@ type SearchFiltersProps = {
|
|||||||
onContainsUrlChange: (value?: boolean) => void;
|
onContainsUrlChange: (value?: boolean) => void;
|
||||||
msgTypeFilters: MsgTypeFilter[];
|
msgTypeFilters: MsgTypeFilter[];
|
||||||
onToggleMsgTypeFilter: (msgType: MsgTypeFilter) => void;
|
onToggleMsgTypeFilter: (msgType: MsgTypeFilter) => void;
|
||||||
|
pinnedOnly: boolean;
|
||||||
|
onTogglePinnedOnly: () => void;
|
||||||
};
|
};
|
||||||
export function SearchFilters({
|
export function SearchFilters({
|
||||||
defaultRoomsFilterName,
|
defaultRoomsFilterName,
|
||||||
@@ -704,6 +706,8 @@ export function SearchFilters({
|
|||||||
onContainsUrlChange,
|
onContainsUrlChange,
|
||||||
msgTypeFilters,
|
msgTypeFilters,
|
||||||
onToggleMsgTypeFilter,
|
onToggleMsgTypeFilter,
|
||||||
|
pinnedOnly,
|
||||||
|
onTogglePinnedOnly,
|
||||||
}: SearchFiltersProps) {
|
}: SearchFiltersProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
@@ -835,6 +839,28 @@ export function SearchFilters({
|
|||||||
</Chip>
|
</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} />
|
<DateRangeButton fromTs={fromTs} toTs={toTs} onChange={onDateRangeChange} />
|
||||||
<OrderButton order={order} onChange={onOrderChange} />
|
<OrderButton order={order} onChange={onOrderChange} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -51,6 +51,26 @@ export const filterGroupsByMsgType = (
|
|||||||
.filter((group) => group.items.length > 0);
|
.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 groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||||
const groups: ResultGroup[] = [];
|
const groups: ResultGroup[] = [];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user