- 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
795 lines
26 KiB
Svelte
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}
|