feat: integrate Matrix chat (Option 2 - credentials stored in Supabase)
- Add matrix-js-sdk, marked, highlight.js, twemoji, @tanstack/svelte-virtual deps - Copy Matrix core layer: /matrix/, /stores/matrix.ts, /cache/, /services/ - Copy Matrix components: matrix/, message/, chat-layout/, chat-settings/ - Copy UI components: EmojiPicker, Twemoji, ImagePreviewModal, VirtualList - Copy utils: emojiData, twemoji, twemojiGlobal - Replace lucide-svelte with Material Symbols in SyncRecoveryBanner - Extend Avatar with xs size and status indicator prop - Fix ui.ts store conflict: re-export toasts from toast.svelte.ts - Add migration 020_matrix_credentials for storing Matrix tokens per user/org - Add /api/matrix-credentials endpoint (GET/POST/DELETE) - Create [orgSlug]/chat page with Matrix login form + full chat UI - Add Chat to sidebar navigation
This commit is contained in:
@@ -122,6 +122,11 @@
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
href: `/${data.org.slug}/chat`,
|
||||
label: "Chat",
|
||||
icon: "chat",
|
||||
},
|
||||
// Settings requires settings.view or admin role
|
||||
...(canAccess("settings.view")
|
||||
? [
|
||||
|
||||
669
src/routes/[orgSlug]/chat/+page.svelte
Normal file
669
src/routes/[orgSlug]/chat/+page.svelte
Normal file
@@ -0,0 +1,669 @@
|
||||
<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)
|
||||
: [],
|
||||
);
|
||||
|
||||
const filteredRooms = $derived(
|
||||
roomSearchQuery.trim()
|
||||
? $roomSummaries.filter(
|
||||
(room) =>
|
||||
room.name.toLowerCase().includes(roomSearchQuery.toLowerCase()) ||
|
||||
room.topic?.toLowerCase().includes(roomSearchQuery.toLowerCase()),
|
||||
)
|
||||
: $roomSummaries,
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
} 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 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-night rounded-[32px] p-8 w-full max-w-md">
|
||||
<h2 class="font-heading text-h3 text-white mb-2">Connect to Chat</h2>
|
||||
<p class="text-light/50 text-body 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 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={matrixPassword}
|
||||
placeholder="Password"
|
||||
class="w-full bg-dark border border-light/10 rounded-2xl px-4 py-3 text-white font-body text-body 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-12 h-12 border-4 border-primary border-t-transparent rounded-full mx-auto mb-4"
|
||||
></div>
|
||||
<p class="text-light/50">
|
||||
{#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 gap-2 min-h-0">
|
||||
<!-- Chat Sidebar -->
|
||||
<aside class="w-56 bg-night rounded-[32px] flex flex-col overflow-hidden shrink-0">
|
||||
<header class="px-3 py-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-rounded text-light" style="font-size: 20px;">chat</span>
|
||||
<span class="flex-1 font-heading text-light text-base">Messages</span>
|
||||
<button
|
||||
class="text-light hover:text-primary transition-colors"
|
||||
onclick={() => (showStartDMModal = true)}
|
||||
title="New message"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 20px;">add</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Room search -->
|
||||
<div class="px-3 pb-2">
|
||||
<div class="relative">
|
||||
<span
|
||||
class="material-symbols-rounded absolute left-3 top-1/2 -translate-y-1/2 text-light/40"
|
||||
style="font-size: 16px;"
|
||||
>search</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={roomSearchQuery}
|
||||
placeholder="Search rooms..."
|
||||
class="w-full pl-9 pr-3 py-2 bg-dark text-light text-sm rounded-lg border border-light/10 placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Room actions -->
|
||||
<div class="flex items-center justify-between px-3 py-1">
|
||||
<span class="text-xs font-semibold text-light/40 uppercase tracking-wider">
|
||||
Rooms {roomSearchQuery ? `(${filteredRooms.length})` : ""}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
|
||||
onclick={() => (showCreateRoomModal = true)}
|
||||
title="Create room"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 16px;">add_circle</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Room list -->
|
||||
<nav class="flex-1 overflow-y-auto px-2 pb-2">
|
||||
{#if filteredRooms.length === 0}
|
||||
<p class="text-light/40 text-sm text-center py-8">
|
||||
{roomSearchQuery ? "No matching rooms" : "No rooms yet"}
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each filteredRooms as room (room.roomId)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors text-left
|
||||
{$selectedRoomId === room.roomId ? 'bg-primary/20' : 'hover:bg-light/5'}"
|
||||
onclick={() => handleRoomSelect(room.roomId)}
|
||||
>
|
||||
<Avatar src={room.avatarUrl} name={room.name} size="xs" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-bold text-sm text-light truncate block">{room.name}</span>
|
||||
</div>
|
||||
{#if room.unreadCount > 0}
|
||||
<span class="bg-primary text-white text-xs px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
|
||||
{room.unreadCount > 99 ? "99+" : room.unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<!-- User footer -->
|
||||
<footer class="p-3 border-t border-light/10">
|
||||
<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-xs font-medium text-light truncate">{$auth.userId}</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-light/50 hover:text-light p-1 rounded-lg hover:bg-light/10 transition-colors"
|
||||
onclick={handleLogout}
|
||||
title="Disconnect chat"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 18px;">logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<!-- Main Chat Area -->
|
||||
<main class="flex-1 flex flex-col min-h-0 overflow-hidden bg-night rounded-[32px]">
|
||||
{#if $selectedRoomId}
|
||||
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<!-- Room Header -->
|
||||
<header class="h-14 px-5 flex items-center border-b border-light/10">
|
||||
{#each $roomSummaries.filter((r) => r.roomId === $selectedRoomId) as room}
|
||||
<div class="flex items-center gap-3 w-full">
|
||||
<Avatar src={room.avatarUrl} name={room.name} size="sm" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="font-heading text-h5 text-light truncate">{room.name}</h2>
|
||||
<p class="text-xs text-light/50">
|
||||
{room.memberCount} members{room.isEncrypted ? " · Encrypted" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-2 text-light/50 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
onclick={() => (showMessageSearch = !showMessageSearch)}
|
||||
title="Search messages"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 20px;">search</span>
|
||||
</button>
|
||||
<button
|
||||
class="p-2 text-light/50 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
onclick={() => (showRoomInfo = !showRoomInfo)}
|
||||
title="Room info"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 20px;">info</span>
|
||||
</button>
|
||||
<button
|
||||
class="p-2 text-light/50 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
onclick={() => (showMemberList = !showMemberList)}
|
||||
title="Members"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 20px;">group</span>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</header>
|
||||
|
||||
<!-- Message search panel -->
|
||||
{#if showMessageSearch}
|
||||
<div class="border-b border-light/10 p-3 bg-dark/50">
|
||||
<div class="relative">
|
||||
<span class="material-symbols-rounded absolute left-3 top-1/2 -translate-y-1/2 text-light/40" style="font-size: 16px;">search</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={messageSearchQuery}
|
||||
placeholder="Search messages in this room..."
|
||||
class="w-full pl-9 pr-8 py-2 bg-night text-light text-sm rounded-lg border border-light/10 placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
<button
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-light/40 hover:text-light"
|
||||
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-xs text-light/40 mb-2">
|
||||
{messageSearchResults.length} result{messageSearchResults.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
{#each messageSearchResults.slice(0, 20) as result}
|
||||
<button
|
||||
class="w-full text-left px-3 py-2 hover:bg-light/5 rounded transition-colors"
|
||||
onclick={() => { showMessageSearch = false; messageSearchQuery = ""; }}
|
||||
>
|
||||
<p class="text-xs text-primary">{result.senderName}</p>
|
||||
<p class="text-sm text-light truncate">{result.content}</p>
|
||||
<p class="text-xs text-light/30">{new Date(result.timestamp).toLocaleString()}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if messageSearchQuery}
|
||||
<p class="text-sm text-light/40 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/20 border-2 border-dashed border-primary rounded-lg flex items-center justify-center backdrop-blur-sm">
|
||||
<div class="text-center">
|
||||
<span class="material-symbols-rounded text-primary mb-4 block" style="font-size: 64px;">upload_file</span>
|
||||
<p class="text-xl font-semibold text-primary">Drop to upload</p>
|
||||
<p class="text-sm text-light/60 mt-1">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/10 bg-dark/30">
|
||||
<RoomInfoPanel
|
||||
room={currentRoom}
|
||||
members={currentMembers}
|
||||
onClose={() => (showRoomInfo = false)}
|
||||
/>
|
||||
</aside>
|
||||
{/each}
|
||||
{:else if showMemberList}
|
||||
<aside class="w-64 border-l border-light/10 bg-dark/30">
|
||||
<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/40">
|
||||
<span class="material-symbols-rounded mb-4 block" style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300;">chat</span>
|
||||
<h2 class="font-heading text-h4 text-light/50 mb-2">Select a room</h2>
|
||||
<p class="text-body text-light/30">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}
|
||||
90
src/routes/api/matrix-credentials/+server.ts
Normal file
90
src/routes/api/matrix-credentials/+server.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// Cast supabase to any to bypass typed client — matrix_credentials table
|
||||
// was added in migration 020 but types haven't been regenerated yet.
|
||||
// TODO: Remove casts after running `supabase gen types`
|
||||
const db = (supabase: any) => supabase;
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const session = await locals.safeGetSession();
|
||||
if (!session.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const orgId = url.searchParams.get('org_id');
|
||||
if (!orgId) {
|
||||
return json({ error: 'org_id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data, error } = await db(locals.supabase)
|
||||
.from('matrix_credentials')
|
||||
.select('homeserver_url, matrix_user_id, access_token, device_id')
|
||||
.eq('user_id', session.user.id)
|
||||
.eq('org_id', orgId)
|
||||
.single();
|
||||
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
return json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ credentials: data ?? null });
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const session = await locals.safeGetSession();
|
||||
if (!session.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { org_id, homeserver_url, matrix_user_id, access_token, device_id } = body;
|
||||
|
||||
if (!org_id || !homeserver_url || !matrix_user_id || !access_token) {
|
||||
return json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await db(locals.supabase)
|
||||
.from('matrix_credentials')
|
||||
.upsert(
|
||||
{
|
||||
user_id: session.user.id,
|
||||
org_id,
|
||||
homeserver_url,
|
||||
matrix_user_id,
|
||||
access_token,
|
||||
device_id: device_id ?? null,
|
||||
},
|
||||
{ onConflict: 'user_id,org_id' }
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ url, locals }) => {
|
||||
const session = await locals.safeGetSession();
|
||||
if (!session.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const orgId = url.searchParams.get('org_id');
|
||||
if (!orgId) {
|
||||
return json({ error: 'org_id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await db(locals.supabase)
|
||||
.from('matrix_credentials')
|
||||
.delete()
|
||||
.eq('user_id', session.user.id)
|
||||
.eq('org_id', orgId);
|
||||
|
||||
if (error) {
|
||||
return json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
Reference in New Issue
Block a user