mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
4e622a66de
commit
c9f3d8ae47
11 changed files with 1304 additions and 91 deletions
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,14 +101,121 @@
|
|||
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="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="btn btn-ghost btn-xs"
|
||||
onclick={() => {
|
||||
if (editMessage) {
|
||||
onCancelEdit?.();
|
||||
message = '';
|
||||
} else {
|
||||
onCancelReply?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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 -->
|
||||
<button class="btn btn-ghost btn-sm" title="Attach file" disabled>
|
||||
<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">
|
||||
|
|
@ -71,16 +225,21 @@
|
|||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
onblur={stopTyping}
|
||||
placeholder="Write a message..."
|
||||
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="Add emoji"
|
||||
title="Emoji hinzufügen"
|
||||
disabled
|
||||
>
|
||||
<Smile class="h-5 w-5" />
|
||||
|
|
@ -91,8 +250,8 @@
|
|||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={handleSend}
|
||||
disabled={!message.trim()}
|
||||
title="Send message"
|
||||
disabled={!message.trim() || uploading}
|
||||
title={editMessage ? 'Speichern' : 'Senden'}
|
||||
>
|
||||
<Send class="h-5 w-5" />
|
||||
</button>
|
||||
|
|
@ -100,6 +259,11 @@
|
|||
|
||||
<!-- Hint -->
|
||||
<p class="mt-1 text-xs text-base-content/40">
|
||||
Press Enter to send, Shift+Enter for new line
|
||||
{#if editMessage}
|
||||
Enter zum Speichern, Escape zum Abbrechen
|
||||
{:else}
|
||||
Enter zum Senden, Shift+Enter für neue Zeile
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,6 +51,14 @@
|
|||
<MessageSquare class="h-6 w-6 text-primary" />
|
||||
<h1 class="text-xl font-bold">Mana Matrix</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
title="Neuer Chat"
|
||||
onclick={() => (showCreateRoom = true)}
|
||||
>
|
||||
<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" />
|
||||
|
|
@ -40,17 +70,18 @@
|
|||
<li>
|
||||
<a href="/settings">
|
||||
<Settings class="h-4 w-4" />
|
||||
Settings
|
||||
Einstellungen
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<button onclick={handleLogout} class="text-error">
|
||||
<LogOut class="h-4 w-4" />
|
||||
Sign out
|
||||
Abmelden
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- User Info -->
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue