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">
|
<script lang="ts">
|
||||||
import type { SimpleMessage } from '$lib/matrix';
|
import type { SimpleMessage } from '$lib/matrix';
|
||||||
|
import { matrixStore } from '$lib/matrix';
|
||||||
import { format, isToday, isYesterday } from 'date-fns';
|
import { format, isToday, isYesterday } from 'date-fns';
|
||||||
import { de } from 'date-fns/locale';
|
import { de } from 'date-fns/locale';
|
||||||
|
import {
|
||||||
|
Reply,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
MoreHorizontal,
|
||||||
|
Download,
|
||||||
|
FileIcon,
|
||||||
|
Play,
|
||||||
|
Image as ImageIcon,
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: SimpleMessage;
|
message: SimpleMessage;
|
||||||
showAvatar?: boolean;
|
showAvatar?: boolean;
|
||||||
showTimestamp?: 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'));
|
let formattedTime = $derived(format(message.timestamp, 'HH:mm'));
|
||||||
|
|
||||||
|
|
@ -28,6 +45,34 @@
|
||||||
.substring(0, 2)
|
.substring(0, 2)
|
||||||
.toUpperCase()
|
.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>
|
</script>
|
||||||
|
|
||||||
<!-- Date separator -->
|
<!-- Date separator -->
|
||||||
|
|
@ -40,7 +85,14 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Message -->
|
<!-- 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 -->
|
<!-- Avatar Column -->
|
||||||
<div class="w-10 flex-shrink-0">
|
<div class="w-10 flex-shrink-0">
|
||||||
{#if showAvatar && !message.isOwn}
|
{#if showAvatar && !message.isOwn}
|
||||||
|
|
@ -66,9 +118,89 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 -->
|
<!-- Message body -->
|
||||||
<div class="relative">
|
<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>
|
<p class="italic text-base-content/80">* {message.senderName} {message.body}</p>
|
||||||
{:else if message.type === 'm.notice'}
|
{:else if message.type === 'm.notice'}
|
||||||
<p class="text-sm text-base-content/60">{message.body}</p>
|
<p class="text-sm text-base-content/60">{message.body}</p>
|
||||||
|
|
@ -78,12 +210,37 @@
|
||||||
|
|
||||||
<!-- Hover timestamp for grouped messages -->
|
<!-- Hover timestamp for grouped messages -->
|
||||||
{#if !showAvatar}
|
{#if !showAvatar}
|
||||||
<span
|
<span class="absolute -left-12 top-0 hidden text-xs text-base-content/40 group-hover:inline">
|
||||||
class="absolute -left-12 top-0 hidden text-xs text-base-content/40 group-hover:inline"
|
|
||||||
>
|
|
||||||
{formattedTime}
|
{formattedTime}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,56 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { matrixStore } from '$lib/matrix';
|
import { matrixStore, type SimpleMessage } from '$lib/matrix';
|
||||||
import { Send, Paperclip, Smile } from 'lucide-svelte';
|
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 message = $state('');
|
||||||
let textarea: HTMLTextAreaElement;
|
let textarea: HTMLTextAreaElement;
|
||||||
|
let fileInput: HTMLInputElement;
|
||||||
let typingTimeout: ReturnType<typeof setTimeout>;
|
let typingTimeout: ReturnType<typeof setTimeout>;
|
||||||
let isTyping = $state(false);
|
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() {
|
async function handleSend() {
|
||||||
const trimmed = message.trim();
|
const trimmed = message.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
|
|
||||||
const sent = await matrixStore.sendMessage(trimmed);
|
let success = false;
|
||||||
if (sent) {
|
|
||||||
|
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 = '';
|
message = '';
|
||||||
stopTyping();
|
stopTyping();
|
||||||
adjustTextareaHeight();
|
adjustTextareaHeight();
|
||||||
|
|
@ -23,7 +61,7 @@
|
||||||
adjustTextareaHeight();
|
adjustTextareaHeight();
|
||||||
|
|
||||||
// Send typing indicator
|
// Send typing indicator
|
||||||
if (!isTyping) {
|
if (!isTyping && !editMessage) {
|
||||||
isTyping = true;
|
isTyping = true;
|
||||||
matrixStore.sendTyping(true);
|
matrixStore.sendTyping(true);
|
||||||
}
|
}
|
||||||
|
|
@ -47,6 +85,15 @@
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSend();
|
handleSend();
|
||||||
}
|
}
|
||||||
|
// Cancel on Escape
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (editMessage) {
|
||||||
|
onCancelEdit?.();
|
||||||
|
message = '';
|
||||||
|
} else if (replyTo) {
|
||||||
|
onCancelReply?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function adjustTextareaHeight() {
|
function adjustTextareaHeight() {
|
||||||
|
|
@ -54,52 +101,169 @@
|
||||||
textarea.style.height = 'auto';
|
textarea.style.height = 'auto';
|
||||||
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="border-t border-base-300 bg-base-100 p-4">
|
<div class="border-t border-base-300 bg-base-100">
|
||||||
<div class="flex items-end gap-2">
|
<!-- Reply/Edit Preview -->
|
||||||
<!-- Attachment button -->
|
{#if replyTo || editMessage}
|
||||||
<button class="btn btn-ghost btn-sm" title="Attach file" disabled>
|
<div class="flex items-center gap-2 border-b border-base-300 bg-base-200/50 px-4 py-2">
|
||||||
<Paperclip class="h-5 w-5" />
|
<div class="flex-1">
|
||||||
</button>
|
{#if editMessage}
|
||||||
|
<p class="text-xs text-base-content/60">Nachricht bearbeiten</p>
|
||||||
<!-- Text input -->
|
<p class="truncate text-sm">{editMessage.body}</p>
|
||||||
<div class="relative flex-1">
|
{:else if replyTo}
|
||||||
<textarea
|
<p class="text-xs text-base-content/60">
|
||||||
bind:this={textarea}
|
Antwort auf <span class="font-medium">{replyTo.senderName}</span>
|
||||||
bind:value={message}
|
</p>
|
||||||
oninput={handleInput}
|
<p class="truncate text-sm">{replyTo.body}</p>
|
||||||
onkeydown={handleKeydown}
|
{/if}
|
||||||
onblur={stopTyping}
|
</div>
|
||||||
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) -->
|
|
||||||
<button
|
<button
|
||||||
class="absolute bottom-2 right-2 text-base-content/50 hover:text-base-content"
|
class="btn btn-ghost btn-xs"
|
||||||
title="Add emoji"
|
onclick={() => {
|
||||||
disabled
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Send button -->
|
<!-- Hint -->
|
||||||
<button
|
<p class="mt-1 text-xs text-base-content/40">
|
||||||
class="btn btn-primary"
|
{#if editMessage}
|
||||||
onclick={handleSend}
|
Enter zum Speichern, Escape zum Abbrechen
|
||||||
disabled={!message.trim()}
|
{:else}
|
||||||
title="Send message"
|
Enter zum Senden, Shift+Enter für neue Zeile
|
||||||
>
|
{/if}
|
||||||
<Send class="h-5 w-5" />
|
</p>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hint -->
|
|
||||||
<p class="mt-1 text-xs text-base-content/40">
|
|
||||||
Press Enter to send, Shift+Enter for new line
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,10 @@
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onMenuClick?: () => void;
|
onMenuClick?: () => void;
|
||||||
|
onInfoClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onMenuClick }: Props = $props();
|
let { onMenuClick, onInfoClick }: Props = $props();
|
||||||
|
|
||||||
let room = $derived(matrixStore.currentSimpleRoom);
|
let room = $derived(matrixStore.currentSimpleRoom);
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -57,7 +58,7 @@
|
||||||
<button class="btn btn-ghost btn-sm" title="Video call" disabled>
|
<button class="btn btn-ghost btn-sm" title="Video call" disabled>
|
||||||
<Video class="h-5 w-5" />
|
<Video class="h-5 w-5" />
|
||||||
</button>
|
</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" />
|
<Info class="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,12 @@
|
||||||
import RoomItem from './RoomItem.svelte';
|
import RoomItem from './RoomItem.svelte';
|
||||||
import { Search, Plus, Users, MessageCircle } from 'lucide-svelte';
|
import { Search, Plus, Users, MessageCircle } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onCreateRoom?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onCreateRoom }: Props = $props();
|
||||||
|
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
let showDMs = $state(true);
|
let showDMs = $state(true);
|
||||||
|
|
||||||
|
|
@ -68,9 +74,9 @@
|
||||||
|
|
||||||
<!-- New Room Button -->
|
<!-- New Room Button -->
|
||||||
<div class="border-t border-base-300 p-3">
|
<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" />
|
<Plus class="h-4 w-4" />
|
||||||
{showDMs ? 'Start new chat' : 'Join or create room'}
|
{showDMs ? 'Neuen Chat starten' : 'Raum erstellen'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<script lang="ts">
|
||||||
import { matrixStore } from '$lib/matrix';
|
import { matrixStore, type SimpleMessage } from '$lib/matrix';
|
||||||
import Message from './Message.svelte';
|
import Message from './Message.svelte';
|
||||||
import TypingIndicator from './TypingIndicator.svelte';
|
import TypingIndicator from './TypingIndicator.svelte';
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import { Loader2, ArrowDown } from 'lucide-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 container: HTMLDivElement;
|
||||||
let showScrollButton = $state(false);
|
let showScrollButton = $state(false);
|
||||||
let loadingMore = $state(false);
|
let loadingMore = $state(false);
|
||||||
|
|
@ -86,7 +93,7 @@
|
||||||
{@const showAvatar = !prevMessage || prevMessage.sender !== message.sender}
|
{@const showAvatar = !prevMessage || prevMessage.sender !== message.sender}
|
||||||
{@const showTimestamp =
|
{@const showTimestamp =
|
||||||
!prevMessage || message.timestamp - prevMessage.timestamp > 5 * 60 * 1000}
|
!prevMessage || message.timestamp - prevMessage.timestamp > 5 * 60 * 1000}
|
||||||
<Message {message} {showAvatar} {showTimestamp} />
|
<Message {message} {showAvatar} {showTimestamp} {onReply} {onEdit} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex h-full flex-col items-center justify-center text-base-content/50">
|
<div class="flex h-full flex-col items-center justify-center text-base-content/50">
|
||||||
<p>No messages yet</p>
|
<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 Message } from './Message.svelte';
|
||||||
export { default as MessageInput } from './MessageInput.svelte';
|
export { default as MessageInput } from './MessageInput.svelte';
|
||||||
export { default as TypingIndicator } from './TypingIndicator.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
|
// Cleanup
|
||||||
// ─────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -417,18 +675,52 @@ class MatrixStore {
|
||||||
private eventToSimpleMessage(event: MatrixEvent): SimpleMessage {
|
private eventToSimpleMessage(event: MatrixEvent): SimpleMessage {
|
||||||
const content = event.getContent();
|
const content = event.getContent();
|
||||||
const relatesTo = content['m.relates_to'];
|
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 {
|
return {
|
||||||
id: event.getId() || '',
|
id: event.getId() || '',
|
||||||
sender: event.getSender() || '',
|
sender: event.getSender() || '',
|
||||||
senderName: this.getSenderName(event),
|
senderName: this.getSenderName(event),
|
||||||
body: content.body || '',
|
body: isRedacted ? 'Message deleted' : (content.body || ''),
|
||||||
formattedBody: content.formatted_body,
|
formattedBody: content.formatted_body,
|
||||||
timestamp: event.getTs(),
|
timestamp: event.getTs(),
|
||||||
type: content.msgtype || 'm.text',
|
type: msgtype,
|
||||||
isOwn: event.getSender() === this._client?.getUserId(),
|
isOwn: event.getSender() === this._client?.getUserId(),
|
||||||
replyTo: relatesTo?.['m.in_reply_to']?.event_id,
|
replyTo: replyToId,
|
||||||
|
replyToBody,
|
||||||
edited: !!event.replacingEvent(),
|
edited: !!event.replacingEvent(),
|
||||||
|
redacted: isRedacted,
|
||||||
|
media,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,20 @@ export interface MatrixCredentials {
|
||||||
deviceId: string;
|
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
|
* Simplified message for UI rendering
|
||||||
*/
|
*/
|
||||||
|
|
@ -28,7 +42,10 @@ export interface SimpleMessage {
|
||||||
type: MessageType;
|
type: MessageType;
|
||||||
isOwn: boolean;
|
isOwn: boolean;
|
||||||
replyTo?: string;
|
replyTo?: string;
|
||||||
|
replyToBody?: string;
|
||||||
edited?: boolean;
|
edited?: boolean;
|
||||||
|
redacted?: boolean;
|
||||||
|
media?: MediaInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MessageType = 'm.text' | 'm.image' | 'm.file' | 'm.audio' | 'm.video' | 'm.emote' | 'm.notice';
|
export type MessageType = 'm.text' | 'm.image' | 'm.file' | 'm.audio' | 'm.video' | 'm.emote' | 'm.notice';
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
<script lang="ts">
|
<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 { 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 { goto } from '$app/navigation';
|
||||||
import { Settings, LogOut, MessageSquare } from 'lucide-svelte';
|
import { Settings, LogOut, MessageSquare, Plus } from 'lucide-svelte';
|
||||||
|
|
||||||
let sidebarOpen = $state(true);
|
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() {
|
function toggleSidebar() {
|
||||||
sidebarOpen = !sidebarOpen;
|
sidebarOpen = !sidebarOpen;
|
||||||
|
|
@ -14,6 +22,20 @@
|
||||||
matrixStore.logout();
|
matrixStore.logout();
|
||||||
goto('/login');
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-screen overflow-hidden bg-base-100">
|
<div class="flex h-screen overflow-hidden bg-base-100">
|
||||||
|
|
@ -29,27 +51,36 @@
|
||||||
<MessageSquare class="h-6 w-6 text-primary" />
|
<MessageSquare class="h-6 w-6 text-primary" />
|
||||||
<h1 class="text-xl font-bold">Mana Matrix</h1>
|
<h1 class="text-xl font-bold">Mana Matrix</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown dropdown-end">
|
<div class="flex items-center gap-1">
|
||||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-circle">
|
<button
|
||||||
<Settings class="h-5 w-5" />
|
class="btn btn-ghost btn-sm btn-circle"
|
||||||
</button>
|
title="Neuer Chat"
|
||||||
<ul
|
onclick={() => (showCreateRoom = true)}
|
||||||
tabindex="0"
|
|
||||||
class="dropdown-content menu rounded-box z-50 w-52 bg-base-100 p-2 shadow-lg"
|
|
||||||
>
|
>
|
||||||
<li>
|
<Plus class="h-5 w-5" />
|
||||||
<a href="/settings">
|
</button>
|
||||||
<Settings class="h-4 w-4" />
|
<div class="dropdown dropdown-end">
|
||||||
Settings
|
<button tabindex="0" class="btn btn-ghost btn-sm btn-circle">
|
||||||
</a>
|
<Settings class="h-5 w-5" />
|
||||||
</li>
|
</button>
|
||||||
<li>
|
<ul
|
||||||
<button onclick={handleLogout} class="text-error">
|
tabindex="0"
|
||||||
<LogOut class="h-4 w-4" />
|
class="dropdown-content menu rounded-box z-50 w-52 bg-base-100 p-2 shadow-lg"
|
||||||
Sign out
|
>
|
||||||
</button>
|
<li>
|
||||||
</li>
|
<a href="/settings">
|
||||||
</ul>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -58,13 +89,16 @@
|
||||||
<p class="truncate text-sm font-medium">{matrixStore.userId}</p>
|
<p class="truncate text-sm font-medium">{matrixStore.userId}</p>
|
||||||
<p class="flex items-center gap-1 text-xs text-base-content/60">
|
<p class="flex items-center gap-1 text-xs text-base-content/60">
|
||||||
<span class="h-2 w-2 rounded-full bg-success"></span>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Room List -->
|
<!-- Room List -->
|
||||||
<div class="flex-1 overflow-hidden">
|
<div class="flex-1 overflow-hidden">
|
||||||
<RoomList />
|
<RoomList onCreateRoom={() => (showCreateRoom = true)} />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|
@ -72,34 +106,57 @@
|
||||||
<main class="flex flex-1 flex-col overflow-hidden">
|
<main class="flex flex-1 flex-col overflow-hidden">
|
||||||
{#if matrixStore.currentRoom}
|
{#if matrixStore.currentRoom}
|
||||||
<!-- Room Header -->
|
<!-- Room Header -->
|
||||||
<RoomHeader onMenuClick={toggleSidebar} />
|
<RoomHeader
|
||||||
|
onMenuClick={toggleSidebar}
|
||||||
|
onInfoClick={() => (showRoomSettings = true)}
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Timeline -->
|
<!-- Timeline -->
|
||||||
<Timeline />
|
<Timeline {onReply} onEdit={handleEdit} />
|
||||||
|
|
||||||
<!-- Message Input -->
|
<!-- Message Input -->
|
||||||
<MessageInput />
|
<MessageInput
|
||||||
|
{replyTo}
|
||||||
|
{editMessage}
|
||||||
|
onCancelReply={() => (replyTo = null)}
|
||||||
|
onCancelEdit={() => (editMessage = null)}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- No Room Selected -->
|
<!-- No Room Selected -->
|
||||||
<div class="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-base-content/50">
|
<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" />
|
<MessageSquare class="h-16 w-16" />
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<h2 class="text-xl font-semibold text-base-content">Welcome to Mana Matrix</h2>
|
<h2 class="text-xl font-semibold text-base-content">Willkommen bei Mana Matrix</h2>
|
||||||
<p class="mt-2">Select a conversation from the sidebar to start chatting</p>
|
<p class="mt-2">Wähle eine Unterhaltung aus der Seitenleiste oder starte einen neuen Chat</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary mt-4" onclick={() => (showCreateRoom = true)}>
|
||||||
|
<Plus class="h-4 w-4" />
|
||||||
|
Neuer Chat
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="mt-8 flex gap-8 text-center">
|
<div class="mt-8 flex gap-8 text-center">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-3xl font-bold text-base-content">{matrixStore.rooms.length}</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-3xl font-bold text-base-content">{matrixStore.totalUnreadCount}</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Room Settings Panel -->
|
||||||
|
<RoomSettingsPanel open={showRoomSettings} onClose={() => (showRoomSettings = false)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Room Dialog -->
|
||||||
|
<CreateRoomDialog
|
||||||
|
open={showCreateRoom}
|
||||||
|
onClose={() => (showCreateRoom = false)}
|
||||||
|
onCreated={handleRoomCreated}
|
||||||
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue