Commit ba90983cec2e
tarball5 files changed+272-2
@@ -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
<button18921930
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>@@ -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
+ <div84
+ 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>@@ -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);@@ -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'] === true65
+ };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
+}@@ -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 }