Files
root-org/src/routes/[orgSlug]/chat/+page.svelte
AlacrisDevs 819d5b876a ui: overhaul files, kanban, calendar, settings, chat modules
- FileBrowser: modernize breadcrumbs, toolbar, list/grid items, empty states
- KanbanColumn: remove fixed height, border-based styling, compact header
- KanbanCard: cleaner border styling, smaller tags, compact footer
- Calendar: compact nav bar, border grid, today circle indicator, day view empty state
- DocumentViewer: remove bg-night rounded-[32px], border-b header pattern
- Settings tags: inline border/rounded-xl cards, icon action buttons
- Chat: create +layout.svelte with PageHeader, overhaul sidebar and main area
- Chat i18n: add nav_chat, chat_title, chat_subtitle keys (en + et)

svelte-check: 0 errors, vitest: 112/112 passed
2026-02-07 11:03:58 +02:00

795 lines
26 KiB
Svelte

<script lang="ts">
import { onMount, getContext } from "svelte";
import { browser } from "$app/environment";
import { page } from "$app/state";
import { Avatar, Button, Input, Modal } from "$lib/components/ui";
import {
MessageList,
MessageInput,
TypingIndicator,
CreateRoomModal,
MemberList,
StartDMModal,
RoomInfoPanel,
MatrixProvider,
} from "$lib/components/matrix";
import type { MatrixClient } from "matrix-js-sdk";
import {
initMatrixClient,
setupSyncHandlers,
logout as matrixLogout,
editMessage,
deleteMessage,
loadMoreMessages,
getRoomMembers,
searchMessagesLocal,
uploadFile,
sendFileMessage,
type LoginCredentials,
} from "$lib/matrix";
import {
auth,
syncState,
roomSummaries,
selectedRoomId,
selectRoom,
clearState,
currentMessages,
currentTyping,
loadRoomMessages,
} from "$lib/stores/matrix";
import { reactionService } from "$lib/services";
import { toasts } from "$lib/stores/toast.svelte";
import { initCache, cleanupCache } from "$lib/cache";
import { clearBlobUrlCache } from "$lib/cache/mediaCache";
import type { Message } from "$lib/matrix/types";
import type { SupabaseClient } from "@supabase/supabase-js";
const supabase = getContext<SupabaseClient>("supabase");
let data = $derived(page.data);
// Matrix state
let matrixClient = $state<MatrixClient | null>(null);
let isInitializing = $state(true);
let showMatrixLogin = $state(false);
// Matrix login form
let matrixHomeserver = $state("https://matrix.org");
let matrixUsername = $state("");
let matrixPassword = $state("");
let isLoggingIn = $state(false);
// Chat UI state
let showCreateRoomModal = $state(false);
let showStartDMModal = $state(false);
let replyToMessage = $state<Message | null>(null);
let editingMsg = $state<Message | null>(null);
let isLoadingMore = $state(false);
let showMemberList = $state(false);
let showRoomInfo = $state(false);
let roomSearchQuery = $state("");
let showMessageSearch = $state(false);
let messageSearchQuery = $state("");
let isDraggingFile = $state(false);
let isUploadingDrop = $state(false);
const messageSearchResults = $derived(
messageSearchQuery.trim() && $selectedRoomId
? searchMessagesLocal($selectedRoomId, messageSearchQuery)
: [],
);
// All non-space rooms (exclude Space entries themselves from the list)
const allRooms = $derived(
$roomSummaries.filter((r) => !r.isSpace),
);
// Org rooms: rooms that belong to any Space
const orgRooms = $derived(
allRooms.filter((r) => r.parentSpaceId && !r.isDirect),
);
// DMs: direct messages (not tied to org)
const dmRooms = $derived(
allRooms.filter((r) => r.isDirect),
);
// Other rooms: not in a space and not a DM
const otherRooms = $derived(
allRooms.filter((r) => !r.parentSpaceId && !r.isDirect),
);
// Apply search filter across all sections
const filterBySearch = (rooms: typeof allRooms) =>
roomSearchQuery.trim()
? rooms.filter(
(room) =>
room.name.toLowerCase().includes(roomSearchQuery.toLowerCase()) ||
room.topic?.toLowerCase().includes(roomSearchQuery.toLowerCase()),
)
: rooms;
const filteredOrgRooms = $derived(filterBySearch(orgRooms));
const filteredDmRooms = $derived(filterBySearch(dmRooms));
const filteredOtherRooms = $derived(filterBySearch(otherRooms));
const currentMembers = $derived(
$selectedRoomId ? getRoomMembers($selectedRoomId) : [],
);
onMount(async () => {
if (!browser) return;
try {
await initCache();
await cleanupCache(7 * 24 * 60 * 60 * 1000);
} catch (e) {
console.warn("Cache initialization failed:", e);
}
// Try to load credentials from Supabase
try {
const res = await fetch(`/api/matrix-credentials?org_id=${data.org.id}`);
const result = await res.json();
if (result.credentials) {
await initFromCredentials({
homeserverUrl: result.credentials.homeserver_url,
userId: result.credentials.matrix_user_id,
accessToken: result.credentials.access_token,
deviceId: result.credentials.device_id,
});
} else {
// No stored credentials — show login form
showMatrixLogin = true;
isInitializing = false;
}
} catch (e) {
console.error("Failed to load Matrix credentials:", e);
showMatrixLogin = true;
isInitializing = false;
}
});
async function initFromCredentials(credentials: LoginCredentials) {
try {
const client = await initMatrixClient(credentials);
matrixClient = client;
setupSyncHandlers(client);
auth.set({
isLoggedIn: true,
userId: credentials.userId,
homeserverUrl: credentials.homeserverUrl,
accessToken: credentials.accessToken,
deviceId: credentials.deviceId || null,
});
// Check if org has a Matrix Space, auto-create if not
await ensureOrgSpace(credentials);
} catch (e: unknown) {
console.error("Failed to init Matrix client:", e);
toasts.error("Failed to connect to chat. Please re-login.");
showMatrixLogin = true;
} finally {
isInitializing = false;
}
}
async function ensureOrgSpace(credentials: LoginCredentials) {
try {
const spaceRes = await fetch(`/api/matrix-space?org_id=${data.org.id}`);
const spaceResult = await spaceRes.json();
if (!spaceResult.spaceId) {
// No Space yet — create one using the user's credentials
const createRes = await fetch("/api/matrix-space", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_id: data.org.id,
action: "create",
homeserver_url: credentials.homeserverUrl,
access_token: credentials.accessToken,
org_name: data.org.name,
}),
});
const createResult = await createRes.json();
if (createResult.spaceId) {
toasts.success(`Organization space created`);
}
}
} catch (e) {
console.warn("Failed to ensure org space:", e);
}
}
async function handleMatrixLogin() {
if (!matrixUsername.trim() || !matrixPassword.trim()) {
toasts.error("Please enter username and password");
return;
}
isLoggingIn = true;
try {
const { loginWithPassword } = await import("$lib/matrix");
const credentials = await loginWithPassword({
homeserverUrl: matrixHomeserver,
username: matrixUsername.trim(),
password: matrixPassword,
});
// Save to Supabase
await fetch("/api/matrix-credentials", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_id: data.org.id,
homeserver_url: credentials.homeserverUrl,
matrix_user_id: credentials.userId,
access_token: credentials.accessToken,
device_id: credentials.deviceId,
}),
});
showMatrixLogin = false;
await initFromCredentials(credentials);
toasts.success("Connected to chat!");
} catch (e: any) {
toasts.error(e.message || "Login failed");
} finally {
isLoggingIn = false;
}
}
async function handleLogout() {
try {
await matrixLogout();
} catch {}
clearState();
clearBlobUrlCache();
// Remove from Supabase
await fetch(`/api/matrix-credentials?org_id=${data.org.id}`, {
method: "DELETE",
});
matrixClient = null;
showMatrixLogin = true;
auth.set({
isLoggedIn: false,
userId: null,
homeserverUrl: null,
accessToken: null,
deviceId: null,
});
}
function handleRoomSelect(roomId: string) {
selectRoom(roomId);
}
async function handleReact(messageId: string, emoji: string) {
if (!$selectedRoomId || !$auth.userId) return;
try {
await reactionService.add($selectedRoomId, messageId, emoji, $auth.userId);
} catch (e) {
const error = e as { message?: string };
toasts.error(error.message || "Failed to add reaction");
}
}
async function handleToggleReaction(
messageId: string,
emoji: string,
reactionEventId: string | null,
) {
if (!$selectedRoomId || !$auth.userId) return;
try {
await reactionService.toggle(
$selectedRoomId,
messageId,
emoji,
$auth.userId,
reactionEventId,
);
} catch (e) {
const error = e as { message?: string };
toasts.error(error.message || "Failed to toggle reaction");
}
}
function handleEditMessage(message: Message) {
editingMsg = message;
}
async function handleSaveEdit(newContent: string) {
if (!$selectedRoomId || !editingMsg) return;
try {
await editMessage($selectedRoomId, editingMsg.eventId, newContent);
editingMsg = null;
toasts.success("Message edited");
} catch (e: any) {
toasts.error(e.message || "Failed to edit message");
}
}
function cancelEdit() {
editingMsg = null;
}
async function handleDeleteMessage(messageId: string) {
if (!$selectedRoomId) return;
if (!confirm("Delete this message?")) return;
try {
await deleteMessage($selectedRoomId, messageId);
toasts.success("Message deleted");
} catch (e: any) {
toasts.error(e.message || "Failed to delete message");
}
}
function handleReply(message: Message) {
replyToMessage = message;
}
function cancelReply() {
replyToMessage = null;
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
if (e.dataTransfer?.types.includes("Files")) isDraggingFile = true;
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
isDraggingFile = false;
}
async function handleDrop(e: DragEvent) {
e.preventDefault();
isDraggingFile = false;
if (!$selectedRoomId || isUploadingDrop) return;
const files = e.dataTransfer?.files;
if (!files || files.length === 0) return;
const file = files[0];
if (file.size > 50 * 1024 * 1024) {
toasts.error("File too large. Maximum size is 50MB.");
return;
}
isUploadingDrop = true;
try {
toasts.info(`Uploading ${file.name}...`);
const contentUri = await uploadFile(file);
await sendFileMessage($selectedRoomId, file, contentUri);
toasts.success("File sent!");
} catch (e: any) {
toasts.error(e.message || "Failed to upload file");
} finally {
isUploadingDrop = false;
}
}
async function handleLoadMore() {
if (!$selectedRoomId || isLoadingMore) return;
isLoadingMore = true;
try {
const result = await loadMoreMessages($selectedRoomId);
loadRoomMessages($selectedRoomId);
if (!result.hasMore) toasts.info("No more messages to load");
} catch (e: any) {
console.error("Failed to load more messages:", e);
} finally {
isLoadingMore = false;
}
}
</script>
<!-- Matrix Login Modal -->
{#if showMatrixLogin}
<div class="h-full flex items-center justify-center">
<div class="bg-dark/30 border border-light/5 rounded-xl p-8 w-full max-w-md">
<h2 class="font-heading text-body text-white mb-1">Connect to Chat</h2>
<p class="text-body-sm text-light/50 mb-6">
Enter your Matrix credentials to enable messaging.
</p>
<div class="space-y-4">
<Input
label="Homeserver URL"
bind:value={matrixHomeserver}
placeholder="https://matrix.org"
/>
<Input
label="Username"
bind:value={matrixUsername}
placeholder="@user:matrix.org"
/>
<div>
<label class="block text-body-sm font-body text-light/60 mb-1">Password</label>
<input
type="password"
bind:value={matrixPassword}
placeholder="Password"
class="w-full bg-dark border border-light/10 rounded-xl px-3 py-2 text-white font-body text-body-sm placeholder:text-light/30 focus:outline-none focus:border-primary"
onkeydown={(e) => {
if (e.key === "Enter") handleMatrixLogin();
}}
/>
</div>
<Button
variant="primary"
fullWidth
onclick={handleMatrixLogin}
disabled={isLoggingIn}
>
{isLoggingIn ? "Connecting..." : "Connect"}
</Button>
</div>
</div>
</div>
<!-- Loading state -->
{:else if isInitializing || ($syncState !== "PREPARED" && $syncState !== "SYNCING")}
<div class="h-full flex items-center justify-center">
<div class="text-center">
<div
class="animate-spin w-10 h-10 border-3 border-primary border-t-transparent rounded-full mx-auto mb-4"
></div>
<p class="text-body-sm text-light/40">
{#if isInitializing}
Connecting to Matrix...
{:else if $syncState === "CATCHUP"}
Catching up on messages...
{:else if $syncState === "RECONNECTING"}
Reconnecting...
{:else if $syncState === "ERROR"}
Connection error, retrying...
{:else}
Syncing...
{/if}
</p>
</div>
</div>
<!-- Main Chat UI -->
{:else if matrixClient}
<MatrixProvider client={matrixClient}>
{#snippet children()}
<div class="h-full flex min-h-0">
<!-- Chat Sidebar -->
<aside class="w-56 border-r border-light/5 flex flex-col overflow-hidden shrink-0">
<div class="flex items-center gap-2 px-3 py-2.5 border-b border-light/5">
<span class="flex-1 font-heading text-body-sm text-white">Messages</span>
<button
class="p-1 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={() => (showStartDMModal = true)}
title="New message"
>
<span class="material-symbols-rounded" style="font-size: 18px;">add</span>
</button>
</div>
<!-- Room search -->
<div class="px-2 py-2">
<div class="relative">
<span
class="material-symbols-rounded absolute left-2.5 top-1/2 -translate-y-1/2 text-light/30"
style="font-size: 16px;"
>search</span>
<input
type="text"
bind:value={roomSearchQuery}
placeholder="Search..."
class="w-full pl-8 pr-3 py-1.5 bg-dark/50 text-white text-[12px] rounded-lg border border-light/5 placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
</div>
<!-- Room list (sectioned) -->
<nav class="flex-1 overflow-y-auto px-1.5 pb-2">
{#if allRooms.length === 0}
<p class="text-light/30 text-[12px] text-center py-8">
{roomSearchQuery ? "No matching rooms" : "No rooms yet"}
</p>
{:else}
<!-- Org / Space Rooms -->
{#if filteredOrgRooms.length > 0}
<div class="mb-1.5">
<div class="flex items-center justify-between px-2 py-1">
<span class="text-[10px] font-body text-light/30 uppercase tracking-wider">Organization</span>
<button
class="p-0.5 text-light/30 hover:text-white hover:bg-dark/50 rounded transition-colors"
onclick={() => (showCreateRoomModal = true)}
title="Create room"
>
<span class="material-symbols-rounded" style="font-size: 14px;">add</span>
</button>
</div>
<ul class="flex flex-col gap-0.5">
{#each filteredOrgRooms as room (room.roomId)}
<li>
<button
class="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg transition-colors text-left
{$selectedRoomId === room.roomId ? 'bg-primary/10 text-white' : 'text-light/60 hover:bg-dark/50 hover:text-white'}"
onclick={() => handleRoomSelect(room.roomId)}
>
<Avatar src={room.avatarUrl} name={room.name} size="xs" />
<div class="flex-1 min-w-0">
<span class="text-[12px] font-body truncate block">{room.name}</span>
</div>
{#if room.unreadCount > 0}
<span class="bg-primary text-background text-[10px] px-1.5 py-0.5 rounded-full min-w-[16px] text-center font-bold">
{room.unreadCount > 99 ? "99+" : room.unreadCount}
</span>
{/if}
</button>
</li>
{/each}
</ul>
</div>
{/if}
<!-- Direct Messages -->
{#if filteredDmRooms.length > 0}
<div class="mb-1.5">
<div class="flex items-center justify-between px-2 py-1">
<span class="text-[10px] font-body text-light/30 uppercase tracking-wider">Direct Messages</span>
<button
class="p-0.5 text-light/30 hover:text-white hover:bg-dark/50 rounded transition-colors"
onclick={() => (showStartDMModal = true)}
title="New DM"
>
<span class="material-symbols-rounded" style="font-size: 14px;">add</span>
</button>
</div>
<ul class="flex flex-col gap-0.5">
{#each filteredDmRooms as room (room.roomId)}
<li>
<button
class="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg transition-colors text-left
{$selectedRoomId === room.roomId ? 'bg-primary/10 text-white' : 'text-light/60 hover:bg-dark/50 hover:text-white'}"
onclick={() => handleRoomSelect(room.roomId)}
>
<Avatar src={room.avatarUrl} name={room.name} size="xs" />
<div class="flex-1 min-w-0">
<span class="text-[12px] font-body truncate block">{room.name}</span>
</div>
{#if room.unreadCount > 0}
<span class="bg-primary text-background text-[10px] px-1.5 py-0.5 rounded-full min-w-[16px] text-center font-bold">
{room.unreadCount > 99 ? "99+" : room.unreadCount}
</span>
{/if}
</button>
</li>
{/each}
</ul>
</div>
{/if}
<!-- Other Rooms (not in a space, not DMs) -->
{#if filteredOtherRooms.length > 0}
<div class="mb-1.5">
<div class="flex items-center justify-between px-2 py-1">
<span class="text-[10px] font-body text-light/30 uppercase tracking-wider">Rooms</span>
<button
class="p-0.5 text-light/30 hover:text-white hover:bg-dark/50 rounded transition-colors"
onclick={() => (showCreateRoomModal = true)}
title="Create room"
>
<span class="material-symbols-rounded" style="font-size: 14px;">add</span>
</button>
</div>
<ul class="flex flex-col gap-0.5">
{#each filteredOtherRooms as room (room.roomId)}
<li>
<button
class="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg transition-colors text-left
{$selectedRoomId === room.roomId ? 'bg-primary/10 text-white' : 'text-light/60 hover:bg-dark/50 hover:text-white'}"
onclick={() => handleRoomSelect(room.roomId)}
>
<Avatar src={room.avatarUrl} name={room.name} size="xs" />
<div class="flex-1 min-w-0">
<span class="text-[12px] font-body truncate block">{room.name}</span>
</div>
{#if room.unreadCount > 0}
<span class="bg-primary text-background text-[10px] px-1.5 py-0.5 rounded-full min-w-[16px] text-center font-bold">
{room.unreadCount > 99 ? "99+" : room.unreadCount}
</span>
{/if}
</button>
</li>
{/each}
</ul>
</div>
{/if}
{/if}
</nav>
<!-- User footer -->
<div class="px-2 py-2 border-t border-light/5">
<div class="flex items-center gap-2">
<Avatar name={$auth.userId || "User"} size="xs" status="online" />
<div class="flex-1 min-w-0">
<p class="text-[11px] text-light/50 truncate">{$auth.userId}</p>
</div>
<button
class="p-1 text-light/30 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={handleLogout}
title="Disconnect chat"
>
<span class="material-symbols-rounded" style="font-size: 16px;">logout</span>
</button>
</div>
</div>
</aside>
<!-- Main Chat Area -->
<main class="flex-1 flex flex-col min-h-0 overflow-hidden">
{#if $selectedRoomId}
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<!-- Room Header -->
<div class="px-4 py-2.5 flex items-center border-b border-light/5 shrink-0">
{#each $roomSummaries.filter((r) => r.roomId === $selectedRoomId) as room}
<div class="flex items-center gap-2.5 w-full">
<Avatar src={room.avatarUrl} name={room.name} size="sm" />
<div class="flex-1 min-w-0">
<h2 class="font-heading text-body-sm text-white truncate">{room.name}</h2>
<p class="text-[11px] text-light/40">
{room.memberCount} members{room.isEncrypted ? " · Encrypted" : ""}
</p>
</div>
<button
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={() => (showMessageSearch = !showMessageSearch)}
title="Search messages"
>
<span class="material-symbols-rounded" style="font-size: 18px;">search</span>
</button>
<button
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={() => (showRoomInfo = !showRoomInfo)}
title="Room info"
>
<span class="material-symbols-rounded" style="font-size: 18px;">info</span>
</button>
<button
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={() => (showMemberList = !showMemberList)}
title="Members"
>
<span class="material-symbols-rounded" style="font-size: 18px;">group</span>
</button>
</div>
{/each}
</div>
<!-- Message search panel -->
{#if showMessageSearch}
<div class="border-b border-light/5 px-4 py-2.5">
<div class="relative">
<span class="material-symbols-rounded absolute left-2.5 top-1/2 -translate-y-1/2 text-light/30" style="font-size: 16px;">search</span>
<input
type="text"
bind:value={messageSearchQuery}
placeholder="Search messages..."
class="w-full pl-8 pr-8 py-1.5 bg-dark/50 text-white text-[12px] rounded-lg border border-light/5 placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
<button
class="absolute right-2 top-1/2 -translate-y-1/2 text-light/30 hover:text-white"
onclick={() => { showMessageSearch = false; messageSearchQuery = ""; }}
>
<span class="material-symbols-rounded" style="font-size: 16px;">close</span>
</button>
</div>
{#if messageSearchQuery && messageSearchResults.length > 0}
<div class="mt-2 max-h-48 overflow-y-auto">
<p class="text-[11px] text-light/30 mb-1.5">
{messageSearchResults.length} result{messageSearchResults.length !== 1 ? "s" : ""}
</p>
{#each messageSearchResults.slice(0, 20) as result}
<button
class="w-full text-left px-3 py-1.5 hover:bg-dark/50 rounded-lg transition-colors"
onclick={() => { showMessageSearch = false; messageSearchQuery = ""; }}
>
<p class="text-[11px] text-primary">{result.senderName}</p>
<p class="text-body-sm text-white truncate">{result.content}</p>
<p class="text-[10px] text-light/30">{new Date(result.timestamp).toLocaleString()}</p>
</button>
{/each}
</div>
{:else if messageSearchQuery}
<p class="text-body-sm text-light/30 mt-2">No results found</p>
{/if}
</div>
{/if}
<!-- Messages area with drag-drop -->
<div
class="flex-1 flex min-h-0 overflow-hidden relative"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
role="region"
>
{#if isDraggingFile}
<div class="absolute inset-0 z-50 bg-primary/10 border-2 border-dashed border-primary rounded-xl flex items-center justify-center backdrop-blur-sm">
<div class="text-center">
<span class="material-symbols-rounded text-primary mb-3 block" style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;">upload_file</span>
<p class="text-body-sm font-heading text-primary">Drop to upload</p>
<p class="text-[12px] text-light/40 mt-0.5">Release to send file</p>
</div>
</div>
{/if}
<!-- Messages column -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<MessageList
messages={$currentMessages}
onReact={handleReact}
onToggleReaction={handleToggleReaction}
onEdit={handleEditMessage}
onDelete={handleDeleteMessage}
onReply={handleReply}
onLoadMore={handleLoadMore}
isLoading={isLoadingMore}
/>
<TypingIndicator userNames={$currentTyping} />
<MessageInput
roomId={$selectedRoomId}
replyTo={replyToMessage}
onCancelReply={cancelReply}
editingMessage={editingMsg}
onSaveEdit={handleSaveEdit}
onCancelEdit={cancelEdit}
/>
</div>
<!-- Side panels -->
{#if showRoomInfo}
{#each $roomSummaries.filter((r) => r.roomId === $selectedRoomId) as currentRoom}
<aside class="w-72 border-l border-light/5">
<RoomInfoPanel
room={currentRoom}
members={currentMembers}
onClose={() => (showRoomInfo = false)}
/>
</aside>
{/each}
{:else if showMemberList}
<aside class="w-64 border-l border-light/5">
<MemberList members={currentMembers} />
</aside>
{/if}
</div>
</div>
{:else}
<!-- No room selected -->
<div class="flex-1 flex items-center justify-center">
<div class="text-center text-light/30">
<span class="material-symbols-rounded mb-3 block" style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;">chat</span>
<p class="text-body-sm text-light/40 mb-1">Select a room</p>
<p class="text-[12px] text-light/20">Choose a conversation to start chatting</p>
</div>
</div>
{/if}
</main>
</div>
{/snippet}
</MatrixProvider>
{/if}
<!-- Modals -->
<CreateRoomModal isOpen={showCreateRoomModal} onClose={() => (showCreateRoomModal = false)} />
{#if showStartDMModal}
<StartDMModal
onClose={() => (showStartDMModal = false)}
onDMCreated={(roomId) => handleRoomSelect(roomId)}
/>
{/if}