feat(matrix): add Phase 2 features

File Upload & Media:
- Send images, videos, audio, and files
- Display media in timeline with thumbnails
- Download links for files
- Upload progress indicator

Message Actions:
- Reply to messages with quote preview
- Edit own text messages
- Delete (redact) own messages
- React with emoji (store only, UI pending)

Room Management:
- Create room dialog (DM or group)
- User search and invite
- Room settings panel with member list
- Leave room functionality

UI Improvements:
- German translations
- Message hover actions
- Reply/Edit preview in input
- Date separators in timeline

https://claude.ai/code/session_01RUrt2qN1D3nVh9HcGpwoby
This commit is contained in:
Claude 2026-01-28 23:54:24 +00:00
parent 4e622a66de
commit c9f3d8ae47
No known key found for this signature in database
11 changed files with 1304 additions and 91 deletions

View file

@ -0,0 +1,285 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import { X, Users, MessageCircle, Lock, Globe, Loader2 } from 'lucide-svelte';
interface Props {
open: boolean;
onClose: () => void;
onCreated?: (roomId: string) => void;
}
let { open, onClose, onCreated }: Props = $props();
let name = $state('');
let topic = $state('');
let isPrivate = $state(true);
let isDirect = $state(false);
let inviteUserId = $state('');
let loading = $state(false);
let error = $state<string | null>(null);
// User search
let searchQuery = $state('');
let searchResults = $state<{ userId: string; displayName?: string; avatarUrl?: string }[]>([]);
let selectedUsers = $state<{ userId: string; displayName?: string }[]>([]);
let searching = $state(false);
let searchTimeout: ReturnType<typeof setTimeout>;
function handleSearchInput() {
clearTimeout(searchTimeout);
if (searchQuery.trim().length < 2) {
searchResults = [];
return;
}
searchTimeout = setTimeout(async () => {
searching = true;
searchResults = await matrixStore.searchUsers(searchQuery);
searching = false;
}, 300);
}
function selectUser(user: { userId: string; displayName?: string }) {
if (!selectedUsers.find((u) => u.userId === user.userId)) {
selectedUsers = [...selectedUsers, user];
}
searchQuery = '';
searchResults = [];
}
function removeUser(userId: string) {
selectedUsers = selectedUsers.filter((u) => u.userId !== userId);
}
async function handleCreate() {
if (!name.trim() && !isDirect) {
error = 'Bitte gib einen Namen ein';
return;
}
if (isDirect && selectedUsers.length === 0) {
error = 'Bitte wähle mindestens einen Benutzer';
return;
}
loading = true;
error = null;
const roomId = await matrixStore.createRoom({
name: isDirect ? undefined : name.trim(),
topic: topic.trim() || undefined,
isDirect,
invite: selectedUsers.map((u) => u.userId),
});
loading = false;
if (roomId) {
onCreated?.(roomId);
resetForm();
onClose();
} else {
error = matrixStore.error || 'Raum konnte nicht erstellt werden';
}
}
function resetForm() {
name = '';
topic = '';
isPrivate = true;
isDirect = false;
searchQuery = '';
searchResults = [];
selectedUsers = [];
error = null;
}
function handleClose() {
resetForm();
onClose();
}
</script>
{#if open}
<!-- Backdrop -->
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onclick={handleClose}>
<!-- Dialog -->
<div
class="w-full max-w-md rounded-xl bg-base-100 shadow-xl"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-base-300 px-6 py-4">
<h2 class="text-xl font-semibold">Neuer Chat</h2>
<button class="btn btn-ghost btn-sm btn-circle" onclick={handleClose}>
<X class="h-5 w-5" />
</button>
</div>
<!-- Content -->
<div class="space-y-4 px-6 py-4">
<!-- Type Selection -->
<div class="flex gap-2">
<button
class="btn flex-1"
class:btn-primary={isDirect}
class:btn-ghost={!isDirect}
onclick={() => (isDirect = true)}
>
<MessageCircle class="h-4 w-4" />
Direktnachricht
</button>
<button
class="btn flex-1"
class:btn-primary={!isDirect}
class:btn-ghost={isDirect}
onclick={() => (isDirect = false)}
>
<Users class="h-4 w-4" />
Gruppenraum
</button>
</div>
<!-- Room Name (only for groups) -->
{#if !isDirect}
<div class="form-control">
<label class="label" for="room-name">
<span class="label-text">Raumname</span>
</label>
<input
id="room-name"
type="text"
bind:value={name}
class="input input-bordered"
placeholder="z.B. Team Chat"
/>
</div>
<div class="form-control">
<label class="label" for="room-topic">
<span class="label-text">Beschreibung (optional)</span>
</label>
<input
id="room-topic"
type="text"
bind:value={topic}
class="input input-bordered"
placeholder="Worum geht es in diesem Raum?"
/>
</div>
<!-- Privacy -->
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text flex items-center gap-2">
{#if isPrivate}
<Lock class="h-4 w-4" />
Privater Raum
{:else}
<Globe class="h-4 w-4" />
Öffentlicher Raum
{/if}
</span>
<input
type="checkbox"
class="toggle"
bind:checked={isPrivate}
/>
</label>
<p class="text-xs text-base-content/60 ml-1">
{isPrivate
? 'Nur eingeladene Benutzer können beitreten'
: 'Jeder kann diesen Raum finden und beitreten'}
</p>
</div>
{/if}
<!-- User Search -->
<div class="form-control">
<label class="label" for="user-search">
<span class="label-text">
{isDirect ? 'Mit wem möchtest du chatten?' : 'Benutzer einladen (optional)'}
</span>
</label>
<div class="relative">
<input
id="user-search"
type="text"
bind:value={searchQuery}
oninput={handleSearchInput}
class="input input-bordered w-full"
placeholder="@benutzer:server.de oder Name"
/>
{#if searching}
<Loader2 class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin" />
{/if}
</div>
<!-- Search Results -->
{#if searchResults.length > 0}
<ul class="menu mt-2 max-h-40 overflow-y-auto rounded-lg bg-base-200 p-2">
{#each searchResults as user}
<li>
<button
class="flex items-center gap-2"
onclick={() => selectUser(user)}
>
<div class="avatar placeholder">
<div class="w-8 rounded-full bg-neutral text-neutral-content">
{#if user.avatarUrl}
<img src={user.avatarUrl} alt="" />
{:else}
<span class="text-xs">{user.displayName?.[0] || user.userId[1]}</span>
{/if}
</div>
</div>
<div class="flex-1 text-left">
<p class="font-medium">{user.displayName || user.userId}</p>
{#if user.displayName}
<p class="text-xs text-base-content/60">{user.userId}</p>
{/if}
</div>
</button>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Selected Users -->
{#if selectedUsers.length > 0}
<div class="flex flex-wrap gap-2">
{#each selectedUsers as user}
<span class="badge badge-lg gap-1">
{user.displayName || user.userId}
<button onclick={() => removeUser(user.userId)}>
<X class="h-3 w-3" />
</button>
</span>
{/each}
</div>
{/if}
<!-- Error -->
{#if error}
<div class="alert alert-error">
<span>{error}</span>
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex justify-end gap-2 border-t border-base-300 px-6 py-4">
<button class="btn btn-ghost" onclick={handleClose}>Abbrechen</button>
<button class="btn btn-primary" onclick={handleCreate} disabled={loading}>
{#if loading}
<Loader2 class="h-4 w-4 animate-spin" />
{/if}
{isDirect ? 'Chat starten' : 'Raum erstellen'}
</button>
</div>
</div>
</div>
{/if}

View file

@ -1,15 +1,32 @@
<script lang="ts">
import type { SimpleMessage } from '$lib/matrix';
import { matrixStore } from '$lib/matrix';
import { format, isToday, isYesterday } from 'date-fns';
import { de } from 'date-fns/locale';
import {
Reply,
Pencil,
Trash2,
MoreHorizontal,
Download,
FileIcon,
Play,
Image as ImageIcon,
} from 'lucide-svelte';
interface Props {
message: SimpleMessage;
showAvatar?: boolean;
showTimestamp?: boolean;
onReply?: (message: SimpleMessage) => void;
onEdit?: (message: SimpleMessage) => void;
}
let { message, showAvatar = true, showTimestamp = false }: Props = $props();
let { message, showAvatar = true, showTimestamp = false, onReply, onEdit }: Props = $props();
let showActions = $state(false);
let imageLoading = $state(true);
let imageError = $state(false);
let formattedTime = $derived(format(message.timestamp, 'HH:mm'));
@ -28,6 +45,34 @@
.substring(0, 2)
.toUpperCase()
);
// Get media URL for display
let mediaUrl = $derived(
message.media?.mxcUrl ? matrixStore.getMediaUrl(message.media.mxcUrl) : null
);
let thumbnailUrl = $derived(
message.media?.thumbnailUrl
? matrixStore.getMediaUrl(message.media.thumbnailUrl)
: message.media?.mxcUrl
? matrixStore.getMediaUrl(message.media.mxcUrl, 400, 400)
: null
);
// Format file size
function formatFileSize(bytes?: number): string {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// Handle message deletion
async function handleDelete() {
if (confirm('Nachricht wirklich löschen?')) {
await matrixStore.deleteMessage(message.id);
}
}
</script>
<!-- Date separator -->
@ -40,7 +85,14 @@
{/if}
<!-- Message -->
<div class="group flex gap-3 rounded-lg px-2 py-1 hover:bg-base-200/50" class:mt-2={showAvatar}>
<div
class="group relative flex gap-3 rounded-lg px-2 py-1 hover:bg-base-200/50"
class:mt-2={showAvatar}
class:opacity-50={message.redacted}
role="article"
onmouseenter={() => (showActions = true)}
onmouseleave={() => (showActions = false)}
>
<!-- Avatar Column -->
<div class="w-10 flex-shrink-0">
{#if showAvatar && !message.isOwn}
@ -66,9 +118,89 @@
</div>
{/if}
<!-- Reply preview -->
{#if message.replyTo && message.replyToBody}
<div
class="mb-1 flex items-center gap-2 rounded border-l-2 border-primary/50 bg-base-200 px-2 py-1 text-sm"
>
<Reply class="h-3 w-3 flex-shrink-0 text-base-content/50" />
<span class="truncate text-base-content/60">{message.replyToBody}</span>
</div>
{/if}
<!-- Message body -->
<div class="relative">
{#if message.type === 'm.emote'}
{#if message.redacted}
<p class="italic text-base-content/50">Nachricht wurde gelöscht</p>
{:else if message.type === 'm.image' && thumbnailUrl}
<!-- Image message -->
<div class="relative max-w-sm">
{#if imageLoading}
<div class="flex h-48 w-full items-center justify-center rounded-lg bg-base-200">
<ImageIcon class="h-8 w-8 animate-pulse text-base-content/30" />
</div>
{/if}
{#if imageError}
<div class="flex h-32 w-full items-center justify-center rounded-lg bg-base-200">
<p class="text-sm text-base-content/50">Bild konnte nicht geladen werden</p>
</div>
{:else}
<img
src={thumbnailUrl}
alt={message.body}
class="max-h-80 cursor-pointer rounded-lg object-contain"
class:hidden={imageLoading}
onload={() => (imageLoading = false)}
onerror={() => {
imageLoading = false;
imageError = true;
}}
onclick={() => mediaUrl && window.open(mediaUrl, '_blank')}
/>
{/if}
</div>
{:else if message.type === 'm.video' && thumbnailUrl}
<!-- Video message -->
<div class="relative max-w-sm">
<div class="group/video relative">
<img src={thumbnailUrl} alt={message.body} class="rounded-lg" />
<div
class="absolute inset-0 flex items-center justify-center bg-black/30 opacity-0 transition-opacity group-hover/video:opacity-100"
>
<Play class="h-12 w-12 text-white" />
</div>
</div>
{#if message.media?.duration}
<span class="absolute bottom-2 right-2 rounded bg-black/60 px-1 text-xs text-white">
{Math.floor(message.media.duration / 60)}:{(message.media.duration % 60)
.toString()
.padStart(2, '0')}
</span>
{/if}
</div>
{:else if message.type === 'm.file' || message.type === 'm.audio'}
<!-- File/Audio message -->
<a
href={mediaUrl}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 rounded-lg border border-base-300 bg-base-200 p-3 transition-colors hover:bg-base-300"
>
<div class="rounded-lg bg-primary/10 p-2">
<FileIcon class="h-6 w-6 text-primary" />
</div>
<div class="min-w-0 flex-1">
<p class="truncate font-medium">{message.media?.filename || message.body}</p>
<p class="text-sm text-base-content/60">
{formatFileSize(message.media?.size)}
{#if message.media?.mimetype}
{message.media.mimetype.split('/')[1]?.toUpperCase()}
{/if}
</p>
</div>
<Download class="h-5 w-5 flex-shrink-0 text-base-content/50" />
</a>
{:else if message.type === 'm.emote'}
<p class="italic text-base-content/80">* {message.senderName} {message.body}</p>
{:else if message.type === 'm.notice'}
<p class="text-sm text-base-content/60">{message.body}</p>
@ -78,12 +210,37 @@
<!-- Hover timestamp for grouped messages -->
{#if !showAvatar}
<span
class="absolute -left-12 top-0 hidden text-xs text-base-content/40 group-hover:inline"
>
<span class="absolute -left-12 top-0 hidden text-xs text-base-content/40 group-hover:inline">
{formattedTime}
</span>
{/if}
</div>
</div>
<!-- Message actions (hover) -->
{#if showActions && !message.redacted}
<div class="absolute -top-2 right-2 flex items-center gap-1 rounded-lg border border-base-300 bg-base-100 p-1 shadow-sm">
<button
class="btn btn-ghost btn-xs"
title="Antworten"
onclick={() => onReply?.(message)}
>
<Reply class="h-4 w-4" />
</button>
{#if message.isOwn && message.type === 'm.text'}
<button
class="btn btn-ghost btn-xs"
title="Bearbeiten"
onclick={() => onEdit?.(message)}
>
<Pencil class="h-4 w-4" />
</button>
{/if}
{#if message.isOwn}
<button class="btn btn-ghost btn-xs text-error" title="Löschen" onclick={handleDelete}>
<Trash2 class="h-4 w-4" />
</button>
{/if}
</div>
{/if}
</div>

View file

@ -1,18 +1,56 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import { Send, Paperclip, Smile } from 'lucide-svelte';
import { matrixStore, type SimpleMessage } from '$lib/matrix';
import { Send, Paperclip, Smile, X, Image, File, Loader2 } from 'lucide-svelte';
interface Props {
replyTo?: SimpleMessage | null;
editMessage?: SimpleMessage | null;
onCancelReply?: () => void;
onCancelEdit?: () => void;
}
let { replyTo = null, editMessage = null, onCancelReply, onCancelEdit }: Props = $props();
let message = $state('');
let textarea: HTMLTextAreaElement;
let fileInput: HTMLInputElement;
let typingTimeout: ReturnType<typeof setTimeout>;
let isTyping = $state(false);
let uploading = $state(false);
let uploadProgress = $state(0);
// Set message content when editing
$effect(() => {
if (editMessage) {
message = editMessage.body;
textarea?.focus();
}
});
async function handleSend() {
const trimmed = message.trim();
if (!trimmed) return;
const sent = await matrixStore.sendMessage(trimmed);
if (sent) {
let success = false;
if (editMessage) {
// Edit existing message
success = await matrixStore.editMessage(editMessage.id, trimmed);
if (success) {
onCancelEdit?.();
}
} else if (replyTo) {
// Reply to message
success = await matrixStore.replyToMessage(replyTo.id, trimmed);
if (success) {
onCancelReply?.();
}
} else {
// Normal message
success = await matrixStore.sendMessage(trimmed);
}
if (success) {
message = '';
stopTyping();
adjustTextareaHeight();
@ -23,7 +61,7 @@
adjustTextareaHeight();
// Send typing indicator
if (!isTyping) {
if (!isTyping && !editMessage) {
isTyping = true;
matrixStore.sendTyping(true);
}
@ -47,6 +85,15 @@
e.preventDefault();
handleSend();
}
// Cancel on Escape
if (e.key === 'Escape') {
if (editMessage) {
onCancelEdit?.();
message = '';
} else if (replyTo) {
onCancelReply?.();
}
}
}
function adjustTextareaHeight() {
@ -54,52 +101,169 @@
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
}
function openFilePicker() {
fileInput?.click();
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
uploading = true;
uploadProgress = 0;
const success = await matrixStore.sendFile(file, (progress) => {
uploadProgress = progress;
});
uploading = false;
uploadProgress = 0;
input.value = ''; // Reset input
if (!success) {
// Show error toast or notification
console.error('Failed to upload file');
}
}
</script>
<div class="border-t border-base-300 bg-base-100 p-4">
<div class="flex items-end gap-2">
<!-- Attachment button -->
<button class="btn btn-ghost btn-sm" title="Attach file" disabled>
<Paperclip class="h-5 w-5" />
</button>
<!-- Text input -->
<div class="relative flex-1">
<textarea
bind:this={textarea}
bind:value={message}
oninput={handleInput}
onkeydown={handleKeydown}
onblur={stopTyping}
placeholder="Write a message..."
rows="1"
class="textarea textarea-bordered w-full resize-none pr-10"
style="max-height: 200px; min-height: 48px;"
></textarea>
<!-- Emoji button (inside textarea) -->
<div class="border-t border-base-300 bg-base-100">
<!-- Reply/Edit Preview -->
{#if replyTo || editMessage}
<div class="flex items-center gap-2 border-b border-base-300 bg-base-200/50 px-4 py-2">
<div class="flex-1">
{#if editMessage}
<p class="text-xs text-base-content/60">Nachricht bearbeiten</p>
<p class="truncate text-sm">{editMessage.body}</p>
{:else if replyTo}
<p class="text-xs text-base-content/60">
Antwort auf <span class="font-medium">{replyTo.senderName}</span>
</p>
<p class="truncate text-sm">{replyTo.body}</p>
{/if}
</div>
<button
class="absolute bottom-2 right-2 text-base-content/50 hover:text-base-content"
title="Add emoji"
disabled
class="btn btn-ghost btn-xs"
onclick={() => {
if (editMessage) {
onCancelEdit?.();
message = '';
} else {
onCancelReply?.();
}
}}
>
<Smile class="h-5 w-5" />
<X class="h-4 w-4" />
</button>
</div>
{/if}
<!-- Upload Progress -->
{#if uploading}
<div class="flex items-center gap-3 px-4 py-2">
<Loader2 class="h-5 w-5 animate-spin text-primary" />
<div class="flex-1">
<div class="h-2 overflow-hidden rounded-full bg-base-300">
<div
class="h-full bg-primary transition-all duration-300"
style="width: {uploadProgress}%"
></div>
</div>
</div>
<span class="text-sm text-base-content/60">{uploadProgress}%</span>
</div>
{/if}
<!-- Input Area -->
<div class="p-4">
<div class="flex items-end gap-2">
<!-- Attachment button -->
<div class="dropdown dropdown-top">
<button
tabindex="0"
class="btn btn-ghost btn-sm"
title="Datei anhängen"
disabled={uploading}
>
<Paperclip class="h-5 w-5" />
</button>
<ul
tabindex="0"
class="dropdown-content menu rounded-box z-50 w-48 bg-base-100 p-2 shadow-lg"
>
<li>
<button onclick={openFilePicker}>
<Image class="h-4 w-4" />
Bild oder Video
</button>
</li>
<li>
<button onclick={openFilePicker}>
<File class="h-4 w-4" />
Datei
</button>
</li>
</ul>
</div>
<!-- Hidden file input -->
<input
bind:this={fileInput}
type="file"
class="hidden"
accept="*/*"
onchange={handleFileSelect}
/>
<!-- Text input -->
<div class="relative flex-1">
<textarea
bind:this={textarea}
bind:value={message}
oninput={handleInput}
onkeydown={handleKeydown}
onblur={stopTyping}
placeholder={editMessage
? 'Nachricht bearbeiten...'
: replyTo
? 'Antwort schreiben...'
: 'Nachricht schreiben...'}
rows="1"
class="textarea textarea-bordered w-full resize-none pr-10"
style="max-height: 200px; min-height: 48px;"
disabled={uploading}
></textarea>
<!-- Emoji button (inside textarea) -->
<button
class="absolute bottom-2 right-2 text-base-content/50 hover:text-base-content"
title="Emoji hinzufügen"
disabled
>
<Smile class="h-5 w-5" />
</button>
</div>
<!-- Send button -->
<button
class="btn btn-primary"
onclick={handleSend}
disabled={!message.trim() || uploading}
title={editMessage ? 'Speichern' : 'Senden'}
>
<Send class="h-5 w-5" />
</button>
</div>
<!-- Send button -->
<button
class="btn btn-primary"
onclick={handleSend}
disabled={!message.trim()}
title="Send message"
>
<Send class="h-5 w-5" />
</button>
<!-- Hint -->
<p class="mt-1 text-xs text-base-content/40">
{#if editMessage}
Enter zum Speichern, Escape zum Abbrechen
{:else}
Enter zum Senden, Shift+Enter für neue Zeile
{/if}
</p>
</div>
<!-- Hint -->
<p class="mt-1 text-xs text-base-content/40">
Press Enter to send, Shift+Enter for new line
</p>
</div>

View file

@ -4,9 +4,10 @@
interface Props {
onMenuClick?: () => void;
onInfoClick?: () => void;
}
let { onMenuClick }: Props = $props();
let { onMenuClick, onInfoClick }: Props = $props();
let room = $derived(matrixStore.currentSimpleRoom);
</script>
@ -57,7 +58,7 @@
<button class="btn btn-ghost btn-sm" title="Video call" disabled>
<Video class="h-5 w-5" />
</button>
<button class="btn btn-ghost btn-sm" title="Room info">
<button class="btn btn-ghost btn-sm" title="Room info" onclick={onInfoClick}>
<Info class="h-5 w-5" />
</button>
</div>

View file

@ -3,6 +3,12 @@
import RoomItem from './RoomItem.svelte';
import { Search, Plus, Users, MessageCircle } from 'lucide-svelte';
interface Props {
onCreateRoom?: () => void;
}
let { onCreateRoom }: Props = $props();
let search = $state('');
let showDMs = $state(true);
@ -68,9 +74,9 @@
<!-- New Room Button -->
<div class="border-t border-base-300 p-3">
<button class="btn btn-ghost btn-sm w-full justify-start">
<button class="btn btn-ghost btn-sm w-full justify-start" onclick={onCreateRoom}>
<Plus class="h-4 w-4" />
{showDMs ? 'Start new chat' : 'Join or create room'}
{showDMs ? 'Neuen Chat starten' : 'Raum erstellen'}
</button>
</div>
</div>

View file

@ -0,0 +1,225 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import {
X,
Users,
Settings,
UserPlus,
LogOut,
Crown,
Shield,
Bell,
BellOff,
Loader2,
} from 'lucide-svelte';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
let activeTab = $state<'members' | 'settings'>('members');
let inviteQuery = $state('');
let searchResults = $state<{ userId: string; displayName?: string; avatarUrl?: string }[]>([]);
let searching = $state(false);
let inviting = $state(false);
let searchTimeout: ReturnType<typeof setTimeout>;
let room = $derived(matrixStore.currentSimpleRoom);
let members = $derived(matrixStore.getRoomMembers());
function handleSearchInput() {
clearTimeout(searchTimeout);
if (inviteQuery.trim().length < 2) {
searchResults = [];
return;
}
searchTimeout = setTimeout(async () => {
searching = true;
const results = await matrixStore.searchUsers(inviteQuery);
// Filter out existing members
const memberIds = new Set(members.map((m) => m.userId));
searchResults = results.filter((r) => !memberIds.has(r.userId));
searching = false;
}, 300);
}
async function inviteUser(userId: string) {
if (!matrixStore.currentRoomId) return;
inviting = true;
const success = await matrixStore.inviteUser(matrixStore.currentRoomId, userId);
inviting = false;
if (success) {
inviteQuery = '';
searchResults = [];
}
}
async function leaveRoom() {
if (!matrixStore.currentRoomId) return;
if (!confirm('Möchtest du diesen Raum wirklich verlassen?')) return;
const success = await matrixStore.leaveRoom(matrixStore.currentRoomId);
if (success) {
onClose();
}
}
function getPowerLevelIcon(level: number) {
if (level >= 100) return Crown;
if (level >= 50) return Shield;
return null;
}
</script>
{#if open && room}
<!-- Slide-in Panel -->
<div class="fixed inset-y-0 right-0 z-40 flex w-80 flex-col border-l border-base-300 bg-base-100 shadow-xl">
<!-- Header -->
<header class="flex items-center justify-between border-b border-base-300 px-4 py-3">
<h2 class="font-semibold">Raum-Details</h2>
<button class="btn btn-ghost btn-sm btn-circle" onclick={onClose}>
<X class="h-5 w-5" />
</button>
</header>
<!-- Room Info -->
<div class="border-b border-base-300 p-4 text-center">
<div class="avatar placeholder mx-auto mb-3">
<div class="w-20 rounded-full bg-neutral text-neutral-content">
{#if room.avatar}
<img src={room.avatar} alt={room.name} />
{:else}
<span class="text-2xl">{room.name.charAt(0).toUpperCase()}</span>
{/if}
</div>
</div>
<h3 class="text-lg font-semibold">{room.name}</h3>
{#if room.topic}
<p class="mt-1 text-sm text-base-content/60">{room.topic}</p>
{/if}
<p class="mt-2 text-xs text-base-content/50">
{room.memberCount} Mitglieder
{#if room.isEncrypted}
• Verschlüsselt
{/if}
</p>
</div>
<!-- Tabs -->
<div class="tabs tabs-bordered">
<button
class="tab flex-1"
class:tab-active={activeTab === 'members'}
onclick={() => (activeTab = 'members')}
>
<Users class="mr-1 h-4 w-4" />
Mitglieder
</button>
<button
class="tab flex-1"
class:tab-active={activeTab === 'settings'}
onclick={() => (activeTab = 'settings')}
>
<Settings class="mr-1 h-4 w-4" />
Einstellungen
</button>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto">
{#if activeTab === 'members'}
<!-- Invite User -->
<div class="border-b border-base-300 p-3">
<div class="relative">
<input
type="text"
bind:value={inviteQuery}
oninput={handleSearchInput}
class="input input-bordered input-sm w-full"
placeholder="Benutzer einladen..."
/>
{#if searching}
<Loader2 class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin" />
{/if}
</div>
<!-- Search Results -->
{#if searchResults.length > 0}
<ul class="menu mt-2 rounded-lg bg-base-200 p-1">
{#each searchResults as user}
<li>
<button
class="flex items-center gap-2 py-1"
onclick={() => inviteUser(user.userId)}
disabled={inviting}
>
<div class="avatar placeholder">
<div class="w-6 rounded-full bg-neutral text-neutral-content">
<span class="text-xs">{user.displayName?.[0] || '?'}</span>
</div>
</div>
<span class="flex-1 truncate text-sm">
{user.displayName || user.userId}
</span>
<UserPlus class="h-4 w-4 text-primary" />
</button>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Member List -->
<ul class="menu p-2">
{#each members as member}
{@const PowerIcon = getPowerLevelIcon(member.powerLevel)}
<li>
<div class="flex items-center gap-2 py-1">
<div class="avatar placeholder">
<div class="w-8 rounded-full bg-neutral text-neutral-content">
{#if member.avatarUrl}
<img src={member.avatarUrl} alt="" />
{:else}
<span class="text-xs">
{member.displayName.charAt(0).toUpperCase()}
</span>
{/if}
</div>
</div>
<div class="flex-1 min-w-0">
<p class="truncate font-medium">{member.displayName}</p>
<p class="truncate text-xs text-base-content/50">{member.userId}</p>
</div>
{#if PowerIcon}
<PowerIcon class="h-4 w-4 text-warning" />
{/if}
</div>
</li>
{/each}
</ul>
{:else}
<!-- Settings -->
<div class="space-y-2 p-3">
<!-- Notifications -->
<button class="btn btn-ghost w-full justify-start">
<Bell class="h-4 w-4" />
Benachrichtigungen
<span class="ml-auto badge badge-sm">An</span>
</button>
<!-- Leave Room -->
<button class="btn btn-ghost w-full justify-start text-error" onclick={leaveRoom}>
<LogOut class="h-4 w-4" />
Raum verlassen
</button>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -1,10 +1,17 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import { matrixStore, type SimpleMessage } from '$lib/matrix';
import Message from './Message.svelte';
import TypingIndicator from './TypingIndicator.svelte';
import { onMount, tick } from 'svelte';
import { Loader2, ArrowDown } from 'lucide-svelte';
interface Props {
onReply?: (message: SimpleMessage) => void;
onEdit?: (message: SimpleMessage) => void;
}
let { onReply, onEdit }: Props = $props();
let container: HTMLDivElement;
let showScrollButton = $state(false);
let loadingMore = $state(false);
@ -86,7 +93,7 @@
{@const showAvatar = !prevMessage || prevMessage.sender !== message.sender}
{@const showTimestamp =
!prevMessage || message.timestamp - prevMessage.timestamp > 5 * 60 * 1000}
<Message {message} {showAvatar} {showTimestamp} />
<Message {message} {showAvatar} {showTimestamp} {onReply} {onEdit} />
{:else}
<div class="flex h-full flex-col items-center justify-center text-base-content/50">
<p>No messages yet</p>

View file

@ -5,3 +5,5 @@ export { default as Timeline } from './Timeline.svelte';
export { default as Message } from './Message.svelte';
export { default as MessageInput } from './MessageInput.svelte';
export { default as TypingIndicator } from './TypingIndicator.svelte';
export { default as CreateRoomDialog } from './CreateRoomDialog.svelte';
export { default as RoomSettingsPanel } from './RoomSettingsPanel.svelte';

View file

@ -353,6 +353,264 @@ class MatrixStore {
}
}
/**
* Send a file/image to current room
*/
async sendFile(
file: File,
onProgress?: (progress: number) => void
): Promise<boolean> {
if (!this._client || !this._currentRoomId) return false;
try {
// Upload to Matrix media repo
const uploadResponse = await this._client.uploadContent(file, {
progressHandler: (progress) => {
if (onProgress) {
onProgress(Math.round((progress.loaded / progress.total) * 100));
}
},
});
const mxcUrl = uploadResponse.content_uri;
// Determine message type based on MIME type
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
const isAudio = file.type.startsWith('audio/');
let msgtype = 'm.file';
if (isImage) msgtype = 'm.image';
if (isVideo) msgtype = 'm.video';
if (isAudio) msgtype = 'm.audio';
// Build content based on type
const content: Record<string, unknown> = {
msgtype,
body: file.name,
filename: file.name,
info: {
mimetype: file.type,
size: file.size,
},
url: mxcUrl,
};
// Add dimensions for images
if (isImage) {
const dimensions = await this.getImageDimensions(file);
if (dimensions) {
(content.info as Record<string, unknown>).w = dimensions.width;
(content.info as Record<string, unknown>).h = dimensions.height;
}
}
await this._client.sendMessage(this._currentRoomId, content);
return true;
} catch (err) {
this._error = err instanceof Error ? err.message : 'Failed to send file';
return false;
}
}
/**
* Get image dimensions
*/
private getImageDimensions(file: File): Promise<{ width: number; height: number } | null> {
return new Promise((resolve) => {
if (!file.type.startsWith('image/')) {
resolve(null);
return;
}
const img = new Image();
img.onload = () => {
resolve({ width: img.width, height: img.height });
URL.revokeObjectURL(img.src);
};
img.onerror = () => resolve(null);
img.src = URL.createObjectURL(file);
});
}
/**
* Get HTTP URL for Matrix media (mxc:// URLs)
*/
getMediaUrl(mxcUrl: string, width?: number, height?: number): string | null {
if (!this._client || !mxcUrl?.startsWith('mxc://')) return null;
if (width && height) {
return this._client.mxcUrlToHttp(mxcUrl, width, height, 'scale') || null;
}
return this._client.mxcUrlToHttp(mxcUrl) || null;
}
/**
* Reply to a message
*/
async replyToMessage(eventId: string, body: string): Promise<boolean> {
if (!this._client || !this._currentRoomId) return false;
const room = this._client.getRoom(this._currentRoomId);
const originalEvent = room?.findEventById(eventId);
if (!originalEvent) return false;
try {
const content = {
msgtype: 'm.text',
body: `> <${originalEvent.getSender()}> ${originalEvent.getContent().body}\n\n${body}`,
format: 'org.matrix.custom.html',
formatted_body: `<mx-reply><blockquote><a href="https://matrix.to/#/${this._currentRoomId}/${eventId}">In reply to</a> <a href="https://matrix.to/#/${originalEvent.getSender()}">${originalEvent.getSender()}</a><br>${originalEvent.getContent().body}</blockquote></mx-reply>${body}`,
'm.relates_to': {
'm.in_reply_to': {
event_id: eventId,
},
},
};
await this._client.sendMessage(this._currentRoomId, content);
return true;
} catch (err) {
this._error = err instanceof Error ? err.message : 'Failed to send reply';
return false;
}
}
/**
* Edit a message
*/
async editMessage(eventId: string, newBody: string): Promise<boolean> {
if (!this._client || !this._currentRoomId) return false;
try {
const content = {
msgtype: 'm.text',
body: `* ${newBody}`,
'm.new_content': {
msgtype: 'm.text',
body: newBody,
},
'm.relates_to': {
rel_type: 'm.replace',
event_id: eventId,
},
};
await this._client.sendMessage(this._currentRoomId, content);
return true;
} catch (err) {
this._error = err instanceof Error ? err.message : 'Failed to edit message';
return false;
}
}
/**
* Delete (redact) a message
*/
async deleteMessage(eventId: string, reason?: string): Promise<boolean> {
if (!this._client || !this._currentRoomId) return false;
try {
await this._client.redactEvent(this._currentRoomId, eventId, undefined, { reason });
return true;
} catch (err) {
this._error = err instanceof Error ? err.message : 'Failed to delete message';
return false;
}
}
/**
* React to a message with an emoji
*/
async reactToMessage(eventId: string, emoji: string): Promise<boolean> {
if (!this._client || !this._currentRoomId) return false;
try {
await this._client.sendEvent(this._currentRoomId, 'm.reaction', {
'm.relates_to': {
rel_type: 'm.annotation',
event_id: eventId,
key: emoji,
},
});
return true;
} catch (err) {
this._error = err instanceof Error ? err.message : 'Failed to react';
return false;
}
}
// ─────────────────────────────────────────────────────────
// User Actions
// ─────────────────────────────────────────────────────────
/**
* Invite a user to a room
*/
async inviteUser(roomId: string, userId: string): Promise<boolean> {
if (!this._client) return false;
try {
await this._client.invite(roomId, userId);
return true;
} catch (err) {
this._error = err instanceof Error ? err.message : 'Failed to invite user';
return false;
}
}
/**
* Kick a user from a room
*/
async kickUser(roomId: string, userId: string, reason?: string): Promise<boolean> {
if (!this._client) return false;
try {
await this._client.kick(roomId, userId, reason);
return true;
} catch (err) {
this._error = err instanceof Error ? err.message : 'Failed to kick user';
return false;
}
}
/**
* Search for users by name or ID
*/
async searchUsers(query: string, limit = 10): Promise<{ userId: string; displayName?: string; avatarUrl?: string }[]> {
if (!this._client || !query.trim()) return [];
try {
const result = await this._client.searchUserDirectory({ term: query, limit });
return result.results.map((user) => ({
userId: user.user_id,
displayName: user.display_name,
avatarUrl: user.avatar_url ? this.getMediaUrl(user.avatar_url, 40, 40) || undefined : undefined,
}));
} catch {
return [];
}
}
/**
* Get room members
*/
getRoomMembers(roomId?: string): RoomMember[] {
const id = roomId || this._currentRoomId;
if (!this._client || !id) return [];
const room = this._client.getRoom(id);
if (!room) return [];
return room.getMembersWithMembership('join').map((member) => ({
userId: member.userId,
displayName: member.name || member.userId,
avatarUrl: member.getAvatarUrl(this._client!.baseUrl, 40, 40, 'scale', false, false) || undefined,
membership: member.membership as RoomMember['membership'],
powerLevel: room.getMemberPowerLevel(member.userId),
}));
}
// ─────────────────────────────────────────────────────────
// Cleanup
// ─────────────────────────────────────────────────────────
@ -417,18 +675,52 @@ class MatrixStore {
private eventToSimpleMessage(event: MatrixEvent): SimpleMessage {
const content = event.getContent();
const relatesTo = content['m.relates_to'];
const msgtype = content.msgtype || 'm.text';
// Check if message was redacted
const isRedacted = event.isRedacted();
// Extract media info for file/image/video/audio messages
let media: SimpleMessage['media'] = undefined;
if (['m.image', 'm.file', 'm.video', 'm.audio'].includes(msgtype) && content.url) {
const info = content.info || {};
media = {
mxcUrl: content.url,
mimetype: info.mimetype,
size: info.size,
width: info.w,
height: info.h,
filename: content.filename || content.body,
thumbnailUrl: info.thumbnail_url,
duration: info.duration,
};
}
// Get reply-to body if this is a reply
let replyToBody: string | undefined;
const replyToId = relatesTo?.['m.in_reply_to']?.event_id;
if (replyToId) {
const room = this._client?.getRoom(event.getRoomId() || '');
const replyEvent = room?.findEventById(replyToId);
if (replyEvent) {
replyToBody = replyEvent.getContent().body;
}
}
return {
id: event.getId() || '',
sender: event.getSender() || '',
senderName: this.getSenderName(event),
body: content.body || '',
body: isRedacted ? 'Message deleted' : (content.body || ''),
formattedBody: content.formatted_body,
timestamp: event.getTs(),
type: content.msgtype || 'm.text',
type: msgtype,
isOwn: event.getSender() === this._client?.getUserId(),
replyTo: relatesTo?.['m.in_reply_to']?.event_id,
replyTo: replyToId,
replyToBody,
edited: !!event.replacingEvent(),
redacted: isRedacted,
media,
};
}

View file

@ -15,6 +15,20 @@ export interface MatrixCredentials {
deviceId: string;
}
/**
* Media info for files/images
*/
export interface MediaInfo {
mxcUrl: string;
mimetype?: string;
size?: number;
width?: number;
height?: number;
filename?: string;
thumbnailUrl?: string;
duration?: number; // For audio/video
}
/**
* Simplified message for UI rendering
*/
@ -28,7 +42,10 @@ export interface SimpleMessage {
type: MessageType;
isOwn: boolean;
replyTo?: string;
replyToBody?: string;
edited?: boolean;
redacted?: boolean;
media?: MediaInfo;
}
export type MessageType = 'm.text' | 'm.image' | 'm.file' | 'm.audio' | 'm.video' | 'm.emote' | 'm.notice';

View file

@ -1,10 +1,18 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import { matrixStore, type SimpleMessage } from '$lib/matrix';
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 { goto } from '$app/navigation';
import { Settings, LogOut, MessageSquare } from 'lucide-svelte';
import { Settings, LogOut, MessageSquare, Plus } from 'lucide-svelte';
let sidebarOpen = $state(true);
let showCreateRoom = $state(false);
let showRoomSettings = $state(false);
// Reply/Edit state
let replyTo = $state<SimpleMessage | null>(null);
let editMessage = $state<SimpleMessage | null>(null);
function toggleSidebar() {
sidebarOpen = !sidebarOpen;
@ -14,6 +22,20 @@
matrixStore.logout();
goto('/login');
}
function handleReply(message: SimpleMessage) {
editMessage = null;
replyTo = message;
}
function handleEdit(message: SimpleMessage) {
replyTo = null;
editMessage = message;
}
function handleRoomCreated(roomId: string) {
matrixStore.selectRoom(roomId);
}
</script>
<div class="flex h-screen overflow-hidden bg-base-100">
@ -29,27 +51,36 @@
<MessageSquare class="h-6 w-6 text-primary" />
<h1 class="text-xl font-bold">Mana Matrix</h1>
</div>
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-sm btn-circle">
<Settings class="h-5 w-5" />
</button>
<ul
tabindex="0"
class="dropdown-content menu rounded-box z-50 w-52 bg-base-100 p-2 shadow-lg"
<div class="flex items-center gap-1">
<button
class="btn btn-ghost btn-sm btn-circle"
title="Neuer Chat"
onclick={() => (showCreateRoom = true)}
>
<li>
<a href="/settings">
<Settings class="h-4 w-4" />
Settings
</a>
</li>
<li>
<button onclick={handleLogout} class="text-error">
<LogOut class="h-4 w-4" />
Sign out
</button>
</li>
</ul>
<Plus class="h-5 w-5" />
</button>
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-sm btn-circle">
<Settings class="h-5 w-5" />
</button>
<ul
tabindex="0"
class="dropdown-content menu rounded-box z-50 w-52 bg-base-100 p-2 shadow-lg"
>
<li>
<a href="/settings">
<Settings class="h-4 w-4" />
Einstellungen
</a>
</li>
<li>
<button onclick={handleLogout} class="text-error">
<LogOut class="h-4 w-4" />
Abmelden
</button>
</li>
</ul>
</div>
</div>
</header>
@ -58,13 +89,16 @@
<p class="truncate text-sm font-medium">{matrixStore.userId}</p>
<p class="flex items-center gap-1 text-xs text-base-content/60">
<span class="h-2 w-2 rounded-full bg-success"></span>
{matrixStore.syncState === 'SYNCING' ? 'Connected' : matrixStore.syncState}
{matrixStore.syncState === 'SYNCING' ? 'Verbunden' : matrixStore.syncState}
{#if matrixStore.totalUnreadCount > 0}
<span class="ml-auto badge badge-primary badge-xs">{matrixStore.totalUnreadCount}</span>
{/if}
</p>
</div>
<!-- Room List -->
<div class="flex-1 overflow-hidden">
<RoomList />
<RoomList onCreateRoom={() => (showCreateRoom = true)} />
</div>
</aside>
@ -72,34 +106,57 @@
<main class="flex flex-1 flex-col overflow-hidden">
{#if matrixStore.currentRoom}
<!-- Room Header -->
<RoomHeader onMenuClick={toggleSidebar} />
<RoomHeader
onMenuClick={toggleSidebar}
onInfoClick={() => (showRoomSettings = true)}
/>
<!-- Timeline -->
<Timeline />
<Timeline {onReply} onEdit={handleEdit} />
<!-- Message Input -->
<MessageInput />
<MessageInput
{replyTo}
{editMessage}
onCancelReply={() => (replyTo = null)}
onCancelEdit={() => (editMessage = null)}
/>
{:else}
<!-- No Room Selected -->
<div class="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-base-content/50">
<MessageSquare class="h-16 w-16" />
<div class="text-center">
<h2 class="text-xl font-semibold text-base-content">Welcome to Mana Matrix</h2>
<p class="mt-2">Select a conversation from the sidebar to start chatting</p>
<h2 class="text-xl font-semibold text-base-content">Willkommen bei Mana Matrix</h2>
<p class="mt-2">Wähle eine Unterhaltung aus der Seitenleiste oder starte einen neuen Chat</p>
</div>
<button class="btn btn-primary mt-4" onclick={() => (showCreateRoom = true)}>
<Plus class="h-4 w-4" />
Neuer Chat
</button>
<!-- Stats -->
<div class="mt-8 flex gap-8 text-center">
<div>
<p class="text-3xl font-bold text-base-content">{matrixStore.rooms.length}</p>
<p class="text-sm">Rooms</p>
<p class="text-sm">Räume</p>
</div>
<div>
<p class="text-3xl font-bold text-base-content">{matrixStore.totalUnreadCount}</p>
<p class="text-sm">Unread</p>
<p class="text-sm">Ungelesen</p>
</div>
</div>
</div>
{/if}
</main>
<!-- Room Settings Panel -->
<RoomSettingsPanel open={showRoomSettings} onClose={() => (showRoomSettings = false)} />
</div>
<!-- Create Room Dialog -->
<CreateRoomDialog
open={showCreateRoom}
onClose={() => (showCreateRoom = false)}
onCreated={handleRoomCreated}
/>