feat(matrix-web): add message search functionality

- Add searchMessages method to Matrix store using SDK search API
- Create SearchDialog component with:
  - Search input with keyboard navigation
  - Toggle between "current room" and "all rooms" scope
  - Result list with sender, room name, and timestamp
  - Query highlighting in results
- Add search button to RoomHeader
- Integrate search dialog into chat page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-29 18:41:06 +01:00
parent 9ffbf35f25
commit 5777c76c47
4 changed files with 277 additions and 2 deletions

View file

@ -9,14 +9,16 @@
ShieldCheck,
ShieldWarning,
Users,
MagnifyingGlass,
} from '@manacore/shared-icons';
interface Props {
onMenuClick?: () => void;
onInfoClick?: () => void;
onSearchClick?: () => void;
}
let { onMenuClick, onInfoClick }: Props = $props();
let { onMenuClick, onInfoClick, onSearchClick }: Props = $props();
let room = $derived(matrixStore.currentSimpleRoom);
let cryptoReady = $derived(matrixStore.cryptoReady);
@ -101,6 +103,13 @@
<!-- Actions -->
<div class="flex items-center gap-1">
<button
class="p-2.5 rounded-xl glass-button shadow-sm"
title="Suchen"
onclick={onSearchClick}
>
<MagnifyingGlass class="h-5 w-5 text-muted-foreground" />
</button>
<button
class="hidden sm:flex p-2.5 rounded-xl glass-button shadow-sm disabled:opacity-40"
title="Sprachanruf"

View file

@ -0,0 +1,185 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import { MagnifyingGlass, X, CircleNotch, ChatText } from '@manacore/shared-icons';
import { formatDistanceToNow } from 'date-fns';
import { de } from 'date-fns/locale';
interface Props {
open: boolean;
onClose: () => void;
onSelectResult?: (roomId: string, eventId: string) => void;
}
let { open, onClose, onSelectResult }: Props = $props();
let query = $state('');
let searching = $state(false);
let searchResults = $state<
{
eventId: string;
sender: string;
senderName: string;
body: string;
timestamp: number;
roomId: string;
roomName: string;
}[]
>([]);
let searchScope = $state<'room' | 'all'>('room');
let hasSearched = $state(false);
let inputRef: HTMLInputElement;
$effect(() => {
if (open && inputRef) {
setTimeout(() => inputRef?.focus(), 100);
}
if (!open) {
query = '';
searchResults = [];
hasSearched = false;
}
});
async function handleSearch() {
if (!query.trim() || searching) return;
searching = true;
hasSearched = true;
try {
const roomId = searchScope === 'room' ? matrixStore.currentRoomId : undefined;
searchResults = await matrixStore.searchMessages(query, roomId || undefined);
} catch (e) {
console.error('Search failed:', e);
searchResults = [];
} finally {
searching = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
handleSearch();
} else if (e.key === 'Escape') {
onClose();
}
}
function handleSelectResult(result: (typeof searchResults)[0]) {
matrixStore.selectRoom(result.roomId);
onSelectResult?.(result.roomId, result.eventId);
onClose();
}
function formatTime(timestamp: number): string {
return formatDistanceToNow(new Date(timestamp), { addSuffix: true, locale: de });
}
function highlightMatch(text: string, searchTerm: string): string {
if (!searchTerm.trim()) return text;
const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<mark class="bg-yellow-300/50 dark:bg-yellow-500/30 rounded px-0.5">$1</mark>');
}
</script>
{#if open}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 backdrop-blur-sm pt-20 px-4"
onclick={onClose}
onkeydown={handleKeydown}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<!-- Dialog -->
<div
class="w-full max-w-2xl rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl overflow-hidden"
onclick={(e) => e.stopPropagation()}
role="document"
>
<!-- Search Header -->
<div class="flex items-center gap-3 p-4 border-b border-black/10 dark:border-white/10">
<MagnifyingGlass class="h-5 w-5 text-muted-foreground flex-shrink-0" />
<input
bind:this={inputRef}
bind:value={query}
type="text"
placeholder="Nachrichten durchsuchen..."
class="flex-1 bg-transparent outline-none text-lg placeholder:text-muted-foreground"
onkeydown={handleKeydown}
/>
{#if searching}
<CircleNotch class="h-5 w-5 animate-spin text-muted-foreground" />
{/if}
<button
class="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
onclick={onClose}
>
<X class="h-5 w-5" />
</button>
</div>
<!-- Scope Toggle -->
<div class="flex gap-2 px-4 py-2 border-b border-black/5 dark:border-white/5 bg-muted/30">
<button
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
{searchScope === 'room' ? 'bg-primary text-primary-foreground' : 'hover:bg-black/5 dark:hover:bg-white/10'}"
onclick={() => (searchScope = 'room')}
>
Aktueller Raum
</button>
<button
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
{searchScope === 'all' ? 'bg-primary text-primary-foreground' : 'hover:bg-black/5 dark:hover:bg-white/10'}"
onclick={() => (searchScope = 'all')}
>
Alle Räume
</button>
</div>
<!-- Results -->
<div class="max-h-[60vh] overflow-y-auto">
{#if searching}
<div class="flex items-center justify-center gap-2 py-12 text-muted-foreground">
<CircleNotch class="h-5 w-5 animate-spin" />
<span>Suche läuft...</span>
</div>
{:else if searchResults.length > 0}
<div class="divide-y divide-black/5 dark:divide-white/5">
{#each searchResults as result}
<button
class="w-full text-left px-4 py-3 hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
onclick={() => handleSelectResult(result)}
>
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-sm">{result.senderName}</span>
{#if searchScope === 'all'}
<span class="text-xs text-muted-foreground">in {result.roomName}</span>
{/if}
<span class="text-xs text-muted-foreground ml-auto">{formatTime(result.timestamp)}</span>
</div>
<p class="text-sm text-muted-foreground line-clamp-2">
{@html highlightMatch(result.body, query)}
</p>
</button>
{/each}
</div>
{:else if hasSearched && query.trim()}
<div class="flex flex-col items-center justify-center gap-3 py-12 text-muted-foreground">
<ChatText class="h-10 w-10 opacity-50" />
<p>Keine Nachrichten gefunden</p>
<p class="text-sm">Versuche es mit anderen Suchbegriffen</p>
</div>
{:else}
<div class="flex flex-col items-center justify-center gap-3 py-12 text-muted-foreground">
<MagnifyingGlass class="h-10 w-10 opacity-50" />
<p>Gib einen Suchbegriff ein</p>
<p class="text-sm">Drücke Enter zum Suchen</p>
</div>
{/if}
</div>
</div>
</div>
{/if}

View file

@ -803,6 +803,78 @@ class MatrixStore {
}
}
/**
* Search messages in the current room
*/
async searchMessages(
query: string,
roomId?: string
): Promise<
{
eventId: string;
sender: string;
senderName: string;
body: string;
timestamp: number;
roomId: string;
roomName: string;
}[]
> {
if (!this._client || !query.trim()) return [];
const targetRoomId = roomId || this._currentRoomId;
try {
// Use Matrix search API
const searchResult = await this._client.searchRoomEvents({
term: query,
filter: targetRoomId
? {
rooms: [targetRoomId],
}
: undefined,
});
const results: {
eventId: string;
sender: string;
senderName: string;
body: string;
timestamp: number;
roomId: string;
roomName: string;
}[] = [];
// Process search results - cast to any since SDK types are incomplete
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const searchData = searchResult as any;
const searchResults = searchData?.search_categories?.room_events?.results || [];
for (const result of searchResults) {
const event = result.result;
if (!event) continue;
const eventRoomId = event.room_id;
const room = this._client.getRoom(eventRoomId);
const content = event.content as { body?: string };
results.push({
eventId: event.event_id || '',
sender: event.sender || '',
senderName: room?.getMember(event.sender || '')?.name || event.sender || 'Unbekannt',
body: content?.body || '',
timestamp: event.origin_server_ts || 0,
roomId: eventRoomId || '',
roomName: room?.name || 'Unbekannt',
});
}
return results;
} catch (e) {
console.error('Search failed:', e);
return [];
}
}
/**
* Get room members
*/

View file

@ -3,6 +3,7 @@
import { RoomList, RoomHeader, Timeline, MessageInput } from '$lib/components/chat';
import CreateRoomDialog from '$lib/components/chat/CreateRoomDialog.svelte';
import RoomSettingsPanel from '$lib/components/chat/RoomSettingsPanel.svelte';
import SearchDialog from '$lib/components/chat/SearchDialog.svelte';
import { ChatCircle, Plus, Gear } from '@manacore/shared-icons';
import { browser } from '$app/environment';
@ -10,6 +11,7 @@
let sidebarOpen = $state(browser ? window.innerWidth >= 1024 : true);
let showCreateRoom = $state(false);
let showRoomSettings = $state(false);
let showSearch = $state(false);
// Reply/Edit state
let replyTo = $state<SimpleMessage | null>(null);
@ -119,7 +121,11 @@
<main class="flex flex-1 min-h-0 flex-col overflow-hidden bg-background">
{#if matrixStore.currentRoom}
<!-- Room Header -->
<RoomHeader onMenuClick={toggleSidebar} onInfoClick={() => (showRoomSettings = true)} />
<RoomHeader
onMenuClick={toggleSidebar}
onInfoClick={() => (showRoomSettings = true)}
onSearchClick={() => (showSearch = true)}
/>
<!-- Timeline -->
<Timeline onReply={handleReply} onEdit={handleEdit} />
@ -179,3 +185,6 @@
onClose={() => (showCreateRoom = false)}
onCreated={handleRoomCreated}
/>
<!-- Search Dialog -->
<SearchDialog open={showSearch} onClose={() => (showSearch = false)} />