Files
cinny/src/app/features/message-search/useMessageSearch.ts
T
jaredandClaude Opus 4.8 de6cecaffc 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>
2026-06-28 16:47:50 -04:00

169 lines
4.6 KiB
TypeScript

import {
IEventWithRoomId,
IResultContext,
ISearchRequestBody,
ISearchResponse,
ISearchResult,
SearchOrderBy,
} from 'matrix-js-sdk';
import { useCallback } from 'react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
export type ResultItem = {
rank: number;
event: IEventWithRoomId;
context: IResultContext;
};
export type ResultGroup = {
roomId: string;
items: ResultItem[];
};
export type SearchResult = {
nextToken?: string;
highlights: string[];
groups: ResultGroup[];
};
// Client-side msgtype post-filter. The Matrix search API cannot filter by
// msgtype server-side, so this is applied to already-returned results.
export type MsgTypeFilter = 'm.image' | 'm.file' | 'm.video';
/**
* Filter result groups to items whose event msgtype is in `msgTypes` (OR/union).
* Empty/absent filter returns groups unchanged. Now-empty groups are dropped.
*/
export const filterGroupsByMsgType = (
groups: ResultGroup[],
msgTypes: MsgTypeFilter[],
): ResultGroup[] => {
if (msgTypes.length === 0) return groups;
const allowed = new Set<string>(msgTypes);
return groups
.map((group) => ({
...group,
items: group.items.filter((item) => {
const msgtype = item.event.content?.msgtype;
return typeof msgtype === 'string' && allowed.has(msgtype);
}),
}))
.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[] = [];
results.forEach((item) => {
const roomId = item.result.room_id;
const resultItem: ResultItem = {
rank: item.rank,
event: item.result,
context: item.context,
};
const lastAddedGroup: ResultGroup | undefined = groups[groups.length - 1];
if (lastAddedGroup && roomId === lastAddedGroup.roomId) {
lastAddedGroup.items.push(resultItem);
return;
}
groups.push({
roomId,
items: [resultItem],
});
});
return groups;
};
const parseSearchResult = (result: ISearchResponse): SearchResult => {
const roomEvents = result.search_categories.room_events;
const searchResult: SearchResult = {
nextToken: roomEvents?.next_batch,
highlights: roomEvents?.highlights ?? [],
groups: groupSearchResult(roomEvents?.results ?? []),
};
return searchResult;
};
export type MessageSearchParams = {
term?: string;
order?: string;
rooms?: string[];
senders?: string[];
fromTs?: number;
toTs?: number;
containsUrl?: boolean;
};
export const useMessageSearch = (params: MessageSearchParams) => {
const mx = useMatrixClient();
const { term, order, rooms, senders, fromTs, toTs, containsUrl } = params;
const searchMessages = useCallback(
async (nextBatch?: string) => {
if (!term)
return {
highlights: [],
groups: [],
};
const limit = 50;
const requestBody: ISearchRequestBody = {
search_categories: {
room_events: {
event_context: {
before_limit: 0,
after_limit: 0,
include_profile: false,
},
filter: {
limit,
rooms,
senders,
// from_ts / to_ts and contains_url are valid Matrix spec fields not yet in SDK types
...(fromTs !== undefined && { from_ts: fromTs }),
...(toTs !== undefined && { to_ts: toTs }),
...(containsUrl !== undefined && { contains_url: containsUrl }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
include_state: false,
order_by: order as SearchOrderBy.Recent,
search_term: term,
},
},
};
const r = await mx.search({
body: requestBody,
next_batch: nextBatch === '' ? undefined : nextBatch,
});
return parseSearchResult(r);
},
[mx, term, order, rooms, senders, fromTs, toTs, containsUrl],
);
return searchMessages;
};