mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:21:08 +02:00
✨ 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:
parent
9ffbf35f25
commit
5777c76c47
4 changed files with 277 additions and 2 deletions
|
|
@ -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"
|
||||
|
|
|
|||
185
apps/matrix/apps/web/src/lib/components/chat/SearchDialog.svelte
Normal file
185
apps/matrix/apps/web/src/lib/components/chat/SearchDialog.svelte
Normal 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}
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue