feat(search): has:image/file/video filters + recent searches

- Add three msgtype toggle chips (Images/Files/Video) to the search filter
  bar, mirroring the existing "Has link" chip. The Matrix search API can't
  filter by msgtype server-side, so results are post-filtered client-side
  (union match on event.content.msgtype, dropping now-empty groups); the
  server request is unchanged. Visible count may be lower than the server
  total — inherent to client-side filtering.
- Recent searches: last 10 distinct terms persisted via a new
  state/recentSearches.ts (atomWithStorage, error-safe, mirrors
  scheduledMessages). Shown as clickable chips when the search input is
  focused + empty, with a Clear affordance; clicking re-runs the search.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 14:22:08 -04:00
co-authored by Claude Opus 4.8
parent 4fbbd9680b
commit 1ee0f0b57a
5 changed files with 216 additions and 4 deletions
@@ -26,6 +26,31 @@ export type SearchResult = {
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);
};
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
const groups: ResultGroup[] = [];