atagen

malice

public
Star0
Watch0

Commit ba90983cec2e

tarball

atagen <boss@atagen.co>2026-10-25 10:53 UTC

5 files changed+272-2
search: add the message search view ahead of macltok's index

Search gets its own view beside Mentions, reachable from the sidebar and the room header. It offers the current room, the current space and everywhere as scopes, groups hits by room and opens them through the same path as mentions, so thread hits land in the thread pane. The request and hit shapes follow the agreed macltok api behind one parser, and until the core op exists the view reports that search isn't available yet.
Modified file: web/src/App.svelte+50 additions−2 deletions
@@ -8,6 +8,8 @@
88 import PinButton from './lib/PinButton.svelte';
99 import ThreadPane from './lib/ThreadPane.svelte';
1010 import Composer from './lib/Composer.svelte';
11+ import SearchView from './lib/SearchView.svelte';
12+ import type { ScopeOption } from './lib/search-model';
1113 import { releaseStaged, stageFiles as stageAttachments, type StagedAttachment, type StickerHandlers } from './lib/staging';
1214 import RoomThreads from './lib/RoomThreads.svelte';
1315 import { loadPinnedThreads, openThread, showThread, threadsByRoom } from './lib/threads';
@@ -164,6 +166,8 @@
164166 let profileMxc: string | null = null;
165167 let selectedSpaceId: string | null = null;
166168 let mentionsOpen = false;
169+ let searchOpen = false;
170+ let searchScopes: ScopeOption[] = [{ label: 'Everywhere', scope: { kind: 'all' } }];
167171 let spaceChildIds: string[] | null = null;
168172 let spaceChildrenLoading = false;
169173 let spaceChildrenError: string | null = null;
@@ -334,6 +338,8 @@
334338 try {
335339 if (url.searchParams.get('view') === 'mentions') {
336340 selectMentions();
341+ } else if (url.searchParams.get('view') === 'search') {
342+ selectSearch();
337343 } else if (roomId && $rooms.some((room) => room.room_id === roomId && !room.space)) {
338344 selectedSpaceId = spaceId && $rooms.some((room) => room.room_id === spaceId && room.space) ? spaceId : null;
339345 spaceChildrenError = null;
@@ -491,6 +497,7 @@
491497 profile = null;
492498 profileMxc = null;
493499 mentionsOpen = false;
500+ searchOpen = false;
494501 selectedSpaceId = null;
495502 spaceChildIds = null;
496503 spaceChildrenError = null;
@@ -546,6 +553,7 @@
546553
547554 async function selectHome(): Promise<void> {
548555 mentionsOpen = false;
556+ searchOpen = false;
549557 selectedSpaceId = null;
550558 spaceChildIds = null;
551559 spaceChildrenError = null;
@@ -575,10 +583,35 @@
575583 peeking = null;
576584 membersOpen = false;
577585 sidebarOpen = false;
586+ searchOpen = false;
578587 mentionsOpen = true;
579588 recordRoute(null, null, 'mentions');
580589 }
581590
591+ function selectSearch(): void {
592+ const fromRoom = $selectedRoomId ? $rooms.find((r) => r.room_id === $selectedRoomId) ?? null : null;
593+ const options: ScopeOption[] = [];
594+ if (fromRoom) options.push({ label: fromRoom.name, scope: { kind: 'room', room_id: fromRoom.room_id } });
595+ if (selectedSpace && spaceChildIds) {
596+ const roomIds = spaceChildIds.filter((id) => $rooms.some((r) => r.room_id === id && !r.space));
597+ if (roomIds.length) options.push({ label: selectedSpace.name, scope: { kind: 'rooms', room_ids: roomIds } });
598+ }
599+ options.push({ label: 'Everywhere', scope: { kind: 'all' } });
600+ stashDraft();
601+ stopTyping();
602+ selectedRoomId.set(null);
603+ replyingTo = null;
604+ editing = null;
605+ composerRef?.closeFinders();
606+ peeking = null;
607+ membersOpen = false;
608+ sidebarOpen = false;
609+ mentionsOpen = false;
610+ searchScopes = options;
611+ searchOpen = true;
612+ recordRoute(selectedSpaceId, null, 'search');
613+ }
614+
582615 function toggleChannelsPane(): void {
583616 if (window.innerWidth <= 640) sidebarOpen = !sidebarOpen;
584617 else navFolded.update((v) => !v);
@@ -586,6 +619,7 @@
586619
587620 async function selectSpace(spaceId: string): Promise<void> {
588621 mentionsOpen = false;
622+ searchOpen = false;
589623 stashDraft();
590624 stopTyping();
591625 selectedSpaceId = spaceId;
@@ -622,6 +656,7 @@
622656
623657 async function selectRoom(roomId: string, peek = false): Promise<void> {
624658 mentionsOpen = false;
659+ searchOpen = false;
625660 peeking = peek ? roomId : null;
626661 stashDraft();
627662 if (roomId === $selectedRoomId && openReady[roomId]) {
@@ -1863,7 +1898,7 @@
18631898 <MxcAvatar mxc={selectedSpace.avatar} label={selectedSpace.name} size={36} shape="rounded" />
18641899 {/if}
18651900 <div class="head-room-text">
1866- <h1><EmojiText text={mentionsOpen ? 'Mentions' : selectedSpace?.name ?? 'Home'} /></h1>
1901+ <h1><EmojiText text={mentionsOpen ? 'Mentions' : searchOpen ? 'Search' : selectedSpace?.name ?? 'Home'} /></h1>
18671902 </div>
18681903 </div>
18691904 </div>
@@ -1888,6 +1923,9 @@
18881923 {joinBusy ? 'Joining…' : 'Join'}
18891924 </button>
18901925 {/if}
1926+ <button type="button" class="icon-btn" aria-label="Search messages" title="Search messages" on:click={selectSearch}>
1927+ <svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M15 15l5 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
1928+ </button>
18911929 <button
18921930 type="button"
18931931 class="icon-btn"
@@ -2050,6 +2088,14 @@
20502088 </li>
20512089 </ul>
20522090 {/if}
2091+ <ul class="room-list">
2092+ <li>
2093+ <button type="button" class="room-row" class:active={searchOpen} on:click={selectSearch}>
2094+ <svg width="26" height="26" viewBox="0 0 26 26" aria-hidden="true"><circle cx="11.5" cy="11.5" r="6" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M16 16l5 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
2095+ <span class="room-name">Search messages</span>
2096+ </button>
2097+ </li>
2098+ </ul>
20532099 {#if roomsLoading && $rooms.length === 0}
20542100 <p class="side-note">Loading rooms…</p>
20552101 {:else if $rooms.length === 0}
@@ -2294,10 +2340,12 @@
22942340 {/each}
22952341 </div>
22962342 {/if}
2297- {#key mentionsOpen ? 'mentions' : $selectedRoomId ?? 'home'}
2343+ {#key mentionsOpen ? 'mentions' : searchOpen ? 'search' : $selectedRoomId ?? 'home'}
22982344 <div class="chat-view">
22992345 {#if mentionsOpen && $sessionUser}
23002346 <MentionsInbox self={$sessionUser} onLive={(roomId, eventId, threadRoot) => void openLive(roomId, eventId, threadRoot)} />
2347+ {:else if searchOpen}
2348+ <SearchView scopes={searchScopes} self={$sessionUser ?? null} {roomName} onOpen={(roomId, eventId, threadRoot) => void openLive(roomId, eventId, threadRoot)} />
23012349 {:else if !selectedRoom}
23022350 <div class="empty" in:fade={{ duration: motionDuration(180) }}>
23032351 <div class="empty-mark" aria-hidden="true">◈</div>
Added file: web/src/lib/SearchView.svelte+121 additions
@@ -0,0 +1,121 @@
1+<script lang="ts">
2+ import { onMount } from 'svelte';
3+ import EmojiText from './EmojiText.svelte';
4+ import MessageRow from './MessageRow.svelte';
5+ import { searchMessages } from './api';
6+ import { formatDay } from './format';
7+ import { groupByRoom } from './search-model';
8+ import type { ScopeOption, SearchCoverage, SearchHit } from './search-model';
9+
10+ export let scopes: ScopeOption[];
11+ export let initialScope = 0;
12+ export let self: string | null = null;
13+ export let roomName: (roomId: string) => string;
14+ export let onOpen: (roomId: string, eventId: string, threadRoot: string | null) => void;
15+
16+ let input: HTMLInputElement | null = null;
17+ let query = '';
18+ let scopeIndex = Math.min(initialScope, scopes.length - 1);
19+ let hits: SearchHit[] = [];
20+ let coverage: SearchCoverage[] = [];
21+ let next: string | null = null;
22+ let searched = '';
23+ let busy = false;
24+ let error = '';
25+ let generation = 0;
26+
27+ $: groups = groupByRoom(hits);
28+ $: partial = coverage.filter((c) => !c.reached_start);
29+ $: oldest = partial.reduce<number | null>((min, c) => (c.oldest_indexed_ms === null ? min : min === null ? c.oldest_indexed_ms : Math.max(min, c.oldest_indexed_ms)), null);
30+
31+ onMount(() => input?.focus());
32+
33+ async function run(more = false): Promise<void> {
34+ const text = query.trim();
35+ if (!text) return;
36+ const run = ++generation;
37+ busy = true;
38+ error = '';
39+ try {
40+ const page = await searchMessages(text, scopes[scopeIndex].scope, more ? next : null);
41+ if (run !== generation) return;
42+ hits = more ? [...hits, ...page.hits] : page.hits;
43+ coverage = page.coverage;
44+ next = page.next;
45+ searched = text;
46+ } catch (e) {
47+ if (run !== generation) return;
48+ if (!more) hits = [];
49+ error = e instanceof Error ? e.message : 'Search failed';
50+ } finally {
51+ if (run === generation) busy = false;
52+ }
53+ }
54+
55+ function pickScope(index: number): void {
56+ scopeIndex = index;
57+ if (searched) void run();
58+ }
59+</script>
60+
61+<section class="search-view" aria-label="Search messages">
62+ <form class="search-bar" on:submit|preventDefault={() => void run()}>
63+ <input bind:this={input} type="search" bind:value={query} placeholder="Search messages" aria-label="Search messages" />
64+ <button type="submit" class="primary-btn" disabled={busy || !query.trim()}>{busy && !hits.length ? 'Searching…' : 'Search'}</button>
65+ </form>
66+ <div class="scopes" role="radiogroup" aria-label="Search in">
67+ {#each scopes as option, i (option.label)}
68+ <button type="button" role="radio" aria-checked={i === scopeIndex} class:active={i === scopeIndex} on:click={() => pickScope(i)}>
69+ <EmojiText text={option.label} />
70+ </button>
71+ {/each}
72+ </div>
73+ {#if error}
74+ <p class="search-error" role="alert"><EmojiText text={error} /></p>
75+ {/if}
76+ <div class="results">
77+ {#if searched && !busy && !error && hits.length === 0}
78+ <p class="side-note">No messages match “<EmojiText text={searched} />”</p>
79+ {/if}
80+ {#each groups as group (group.room_id)}
81+ <h3 class="result-room"><EmojiText text={roomName(group.room_id)} /></h3>
82+ {#each group.hits as hit (hit.event_id)}
83+ <div
84+ class="result"
85+ role="link"
86+ tabindex="0"
87+ aria-label={`Open message in ${roomName(hit.room_id)}`}
88+ on:click={() => onOpen(hit.room_id, hit.event_id, hit.event.thread_root)}
89+ on:keydown={(e) => e.key === 'Enter' && onOpen(hit.room_id, hit.event_id, hit.event.thread_root)}
90+ >
91+ <MessageRow itemId={`search-${hit.event_id}`} event={hit.event} showSender={true} reacting={false} onReact={() => {}} readOnly={true} showSent={false} {self} />
92+ </div>
93+ {/each}
94+ {/each}
95+ {#if next}
96+ <button type="button" class="ghost-btn more" disabled={busy} on:click={() => void run(true)}>{busy ? 'Loading…' : 'More results'}</button>
97+ {/if}
98+ {#if searched && partial.length}
99+ <p class="coverage">Searched back to {oldest ? formatDay(oldest) : 'recent history'} in {partial.length === 1 ? 'one room' : `${partial.length} rooms`}</p>
100+ {/if}
101+ </div>
102+</section>
103+
104+<style>
105+ .search-view { display: flex; flex-direction: column; min-height: 0; height: 100%; }
106+ .search-bar { display: flex; gap: 8px; padding: 12px 16px 8px; }
107+ .search-bar input { flex: 1; min-width: 0; background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--r-md); color: var(--text); padding: 8px 10px; font: inherit; }
108+ .search-bar input:focus { outline: none; border-color: var(--accent); }
109+ .scopes { display: flex; gap: 6px; padding: 0 16px 10px; border-bottom: 1px solid var(--border); }
110+ .scopes button { border: 1px solid var(--border); background: transparent; color: var(--muted); border-radius: var(--r-sm); padding: 3px 10px; font: inherit; font-size: 12.5px; cursor: pointer; transition: color var(--pop), border-color var(--pop), background var(--pop); }
111+ .scopes button:hover { color: var(--text); }
112+ .scopes button.active { color: var(--text); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
113+ .search-error { margin: 10px 16px 0; color: var(--danger); font-size: 13px; }
114+ .results { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 16px 16px; }
115+ .result-room { margin: 14px 0 4px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
116+ .result { border-radius: var(--r-md); cursor: pointer; }
117+ .result:hover { background: var(--hover-faint); }
118+ .result:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
119+ .more { display: block; margin: 12px auto 0; }
120+ .coverage { margin: 14px 0 0; text-align: center; font-size: 12px; color: var(--muted); }
121+</style>
Modified file: web/src/lib/api.ts+14 additions
@@ -8,10 +8,12 @@ export type { MentionKey, MentionReason, MentionCaptureState, MentionEntry, Ment
88 import { formatBody } from './markdown';
99 import { mentionedUserIds, type Mention } from './mentions';
1010 import { HttpTransport } from './transport-http';
11+import { parseSearchPage, scopeWire, type SearchPage, type SearchScope } from './search-model';
1112 import type { CommandRequest, SendKind, SessionInfo, Transport, VerifyAction } from './transport';
1213 export type { HistoryRequest } from './transport';
1314 import { createDeviceTransport } from 'malice:device-transport';
1415
16+import { ApiError } from './transport';
1517 export { ApiError } from './transport';
1618 export type { SessionInfo, SoftLogout, VerifyAction } from './transport';
1719
@@ -66,6 +68,18 @@ export const retryMentionContext = (room_id: string, event_id: string): Promise<
6668 export const setMentionsRead = (keys: MentionKey[], read: boolean) => transport.setMentionsRead(keys, read);
6769 export const markMentionsReadThrough = (boundary: MentionsBoundary) => transport.markMentionsReadThrough(boundary);
6870 export const recoverMentions = () => transport.recoverMentions();
71+export async function searchMessages(query: string, scope: SearchScope, cursor: string | null = null, limit = 30): Promise<SearchPage> {
72+ let raw: unknown;
73+ try {
74+ raw = await transport.command({ op: 'search', query, scope: scopeWire(scope), cursor, limit });
75+ } catch (e) {
76+ if (e instanceof ApiError && (e.status === 400 || e.status === 422)) throw new ApiError(400, "Search isn't available yet");
77+ throw e;
78+ }
79+ const page = parseSearchPage(raw);
80+ if (!page) throw new ApiError(502, 'Invalid search response');
81+ return page;
82+}
6983 export const startRecentHistory = (room_id: string): Promise<void> => transport.startRecentHistory(room_id);
7084 export const fillHistoryGap = (room_id: string, gap_id: string): Promise<void> => transport.fillHistoryGap(room_id, gap_id);
7185 export const setTyping = (room_id: string, typing: boolean) => transport.setTyping(room_id, typing);
Added file: web/src/lib/search-model.ts+86 additions
@@ -0,0 +1,86 @@
1+import { isRecord } from './guards';
2+import { parseTimelineItem } from './types';
3+import type { TimelineEvent } from './types';
4+
5+export type SearchScope = { kind: 'room'; room_id: string } | { kind: 'rooms'; room_ids: string[] } | { kind: 'all' };
6+
7+export interface ScopeOption {
8+ label: string;
9+ scope: SearchScope;
10+}
11+
12+export interface SearchHit {
13+ room_id: string;
14+ event_id: string;
15+ timestamp_ms: number;
16+ event: TimelineEvent;
17+ ranges: [number, number][];
18+}
19+
20+export interface SearchCoverage {
21+ room_id: string;
22+ oldest_indexed_ms: number | null;
23+ reached_start: boolean;
24+}
25+
26+export interface SearchPage {
27+ hits: SearchHit[];
28+ next: string | null;
29+ coverage: SearchCoverage[];
30+}
31+
32+/** The wire form of a scope, as macltok's externally tagged enum. */
33+export function scopeWire(scope: SearchScope): unknown {
34+ if (scope.kind === 'room') return { Room: scope.room_id };
35+ if (scope.kind === 'rooms') return { Rooms: scope.room_ids };
36+ return 'All';
37+}
38+
39+function parseRanges(raw: unknown): [number, number][] {
40+ if (!Array.isArray(raw)) return [];
41+ return raw.flatMap((range) =>
42+ Array.isArray(range) && typeof range[0] === 'number' && typeof range[1] === 'number' ? [[range[0], range[1]] as [number, number]] : []
43+ );
44+}
45+
46+function parseHit(raw: unknown): SearchHit | null {
47+ if (!isRecord(raw) || typeof raw['room_id'] !== 'string' || typeof raw['event_id'] !== 'string') return null;
48+ const item = parseTimelineItem(raw['preview'] ?? raw['event']);
49+ if (item?.kind !== 'event') return null;
50+ return {
51+ room_id: raw['room_id'],
52+ event_id: raw['event_id'],
53+ timestamp_ms: typeof raw['timestamp_ms'] === 'number' ? raw['timestamp_ms'] : item.event.timestamp_ms,
54+ event: item.event,
55+ ranges: parseRanges(raw['ranges'])
56+ };
57+}
58+
59+function parseCoverage(raw: unknown): SearchCoverage | null {
60+ if (!isRecord(raw) || typeof raw['room_id'] !== 'string') return null;
61+ return {
62+ room_id: raw['room_id'],
63+ oldest_indexed_ms: typeof raw['oldest_indexed_ms'] === 'number' ? raw['oldest_indexed_ms'] : null,
64+ reached_start: raw['reached_start'] === true
65+ };
66+}
67+
68+export function parseSearchPage(raw: unknown): SearchPage | null {
69+ if (!isRecord(raw) || !Array.isArray(raw['hits'])) return null;
70+ return {
71+ hits: raw['hits'].flatMap((hit) => parseHit(hit) ?? []),
72+ next: typeof raw['next'] === 'string' ? raw['next'] : null,
73+ coverage: Array.isArray(raw['coverage']) ? raw['coverage'].flatMap((c) => parseCoverage(c) ?? []) : []
74+ };
75+}
76+
77+/** Hits grouped by room, keeping the order in which each room first appears. */
78+export function groupByRoom(hits: SearchHit[]): { room_id: string; hits: SearchHit[] }[] {
79+ const groups = new Map<string, SearchHit[]>();
80+ for (const hit of hits) {
81+ const list = groups.get(hit.room_id);
82+ if (list) list.push(hit);
83+ else groups.set(hit.room_id, [hit]);
84+ }
85+ return [...groups].map(([room_id, list]) => ({ room_id, hits: list }));
86+}
Modified file: web/src/lib/transport.ts+1 additions
@@ -37,6 +37,7 @@ export type CommandRequest =
3737 | { op: 'set_room_avatar'; room_id: string; mxc: string | null }
3838 | { op: 'mute'; room_id: string; user_id: string; muted: boolean }
3939 | { op: 'notification_keywords' }
40+ | { op: 'search'; query: string; scope: unknown; cursor: string | null; limit: number }
4041 | { op: 'add_notification_keyword' | 'remove_notification_keyword'; keyword: string }
4142 | { op: 'ignore' | 'unignore'; user_id: string }
4243 | { op: 'power_levels'; room_id: string }