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:
AlacrisDevs
2026-02-07 01:44:06 +02:00
parent e55881b38b
commit d1ce5d0951
62 changed files with 11432 additions and 41 deletions

View File

@@ -0,0 +1,300 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import {
MessageList,
MessageInput,
TypingIndicator,
MemberList,
RoomInfoPanel,
} from "$lib/components/matrix";
import type { Message, RoomSummary, RoomMember } from "$lib/matrix/types";
interface Props {
room: RoomSummary | null;
messages: Message[];
typingUsers: string[];
members: RoomMember[];
roomId: string;
replyToMessage: Message | null;
editingMessage: Message | null;
isLoadingMore: boolean;
onReact: (messageId: string, emoji: string) => void;
onEdit: (message: Message) => void;
onDelete: (messageId: string) => void;
onReply: (message: Message) => void;
onCancelReply: () => void;
onSaveEdit: (content: string) => void;
onCancelEdit: () => void;
onLoadMore: () => void;
onDragOver: (e: DragEvent) => void;
onDragLeave: (e: DragEvent) => void;
onDrop: (e: DragEvent) => void;
isDraggingFile: boolean;
}
let {
room,
messages,
typingUsers,
members,
roomId,
replyToMessage,
editingMessage,
isLoadingMore,
onReact,
onEdit,
onDelete,
onReply,
onCancelReply,
onSaveEdit,
onCancelEdit,
onLoadMore,
onDragOver,
onDragLeave,
onDrop,
isDraggingFile,
}: Props = $props();
let showMessageSearch = $state(false);
let messageSearchQuery = $state("");
let showRoomInfo = $state(false);
let showMemberList = $state(false);
// Simple local search (could be moved to a prop if needed)
const messageSearchResults = $derived(
messageSearchQuery.trim()
? messages.filter((m) =>
m.content.toLowerCase().includes(messageSearchQuery.toLowerCase()),
)
: [],
);
</script>
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<!-- Room Header -->
{#if room}
<header
class="h-16 px-6 flex items-center border-b border-light/10 bg-dark/50"
>
<div class="flex items-center gap-3">
<Avatar src={room.avatarUrl} name={room.name} size="md" />
<div>
<div class="flex items-center gap-2">
<h2 class="font-semibold text-light">{room.name}</h2>
{#if room.isEncrypted}
<span class="text-green-400" title="End-to-end encrypted">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path
d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"
/>
</svg>
</span>
{/if}
</div>
<p class="text-xs text-light/50">
{room.memberCount}
{room.memberCount === 1 ? "member" : "members"}{room.isEncrypted
? " • Encrypted"
: ""}
</p>
</div>
<!-- Search button -->
<button
class="ml-auto w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded transition-colors"
onclick={() => (showMessageSearch = !showMessageSearch)}
title="Search messages"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
</button>
<!-- Room info toggle button -->
<button
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded transition-colors"
onclick={() => (showRoomInfo = !showRoomInfo)}
title="Room info"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</button>
<!-- Member list toggle button -->
<button
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded transition-colors"
onclick={() => (showMemberList = !showMemberList)}
title="Toggle member list"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</button>
</div>
</header>
{/if}
<!-- Message search panel -->
{#if showMessageSearch}
<div class="border-b border-light/10 p-3 bg-dark/50">
<div class="relative">
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-light/40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<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 = "";
}}
title="Close search"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</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}
<!-- Main content area with optional member panel -->
<div
class="flex-1 flex min-h-0 overflow-hidden relative"
ondragover={onDragOver}
ondragleave={onDragLeave}
ondrop={onDrop}
role="region"
>
<!-- Drag overlay -->
{#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">
<svg
class="w-16 h-16 mx-auto mb-4 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<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}
onReact={(msgId, emoji) => onReact(msgId, emoji)}
{onEdit}
{onDelete}
{onReply}
{onLoadMore}
isLoading={isLoadingMore}
/>
<TypingIndicator userNames={typingUsers} />
<MessageInput
{roomId}
replyTo={replyToMessage}
{onCancelReply}
{editingMessage}
{onSaveEdit}
{onCancelEdit}
/>
</div>
<!-- Side panels -->
{#if showRoomInfo && room}
<aside class="w-72 border-l border-light/10 bg-dark/30">
<RoomInfoPanel
{room}
{members}
onClose={() => (showRoomInfo = false)}
/>
</aside>
{:else if showMemberList}
<aside class="w-64 border-l border-light/10 bg-dark/30">
<MemberList {members} />
</aside>
{/if}
</div>
</div>

View File

@@ -0,0 +1,340 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import type { RoomSummary } from "$lib/matrix/types";
import type { AuthState } from "$lib/stores/matrix";
interface Props {
rooms: RoomSummary[];
selectedRoomId: string | null;
syncState: string;
auth: AuthState;
onRoomSelect: (roomId: string) => void;
onCreateRoom: () => void;
onCreateSpace: () => void;
onStartDM: () => void;
onLogout: () => void;
onOpenSettings?: () => void;
}
let {
rooms,
selectedRoomId,
syncState,
auth,
onRoomSelect,
onCreateRoom,
onCreateSpace,
onStartDM,
onLogout,
onOpenSettings,
}: Props = $props();
let searchQuery = $state("");
let expandedSpaces = $state<Set<string>>(new Set());
// Filter rooms based on search
const filteredRooms = $derived(
searchQuery.trim()
? rooms.filter(
(room) =>
room.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
room.topic?.toLowerCase().includes(searchQuery.toLowerCase()),
)
: rooms,
);
// Get spaces (organizations)
const spaces = $derived(filteredRooms.filter((room) => room.isSpace));
// Get rooms belonging to each space
const roomsBySpace = $derived(() => {
const map = new Map<string, RoomSummary[]>();
spaces.forEach((space) => {
map.set(
space.roomId,
filteredRooms.filter(
(room) => !room.isSpace && room.parentSpaceId === space.roomId,
),
);
});
return map;
});
// Get orphan rooms (messages) - rooms not belonging to any space and not spaces themselves
const orphanRooms = $derived(
filteredRooms.filter((room) => !room.isSpace && !room.parentSpaceId),
);
const isConnected = $derived(
syncState === "SYNCING" || syncState === "PREPARED",
);
function toggleSpace(spaceId: string) {
expandedSpaces = new Set(expandedSpaces);
if (expandedSpaces.has(spaceId)) {
expandedSpaces.delete(spaceId);
} else {
expandedSpaces.add(spaceId);
}
}
</script>
<aside class="w-64 bg-dark flex flex-col border-r border-light/10">
<!-- Header -->
<header class="p-4 border-b border-light/10">
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary">Root</h1>
<span
class="text-xs px-2 py-1 rounded-full {isConnected
? 'bg-green-500/20 text-green-400'
: 'bg-yellow-500/20 text-yellow-400'}"
>
{isConnected ? "Connected" : syncState}
</span>
</div>
</header>
<!-- Room List -->
<nav class="flex-1 overflow-y-auto p-2">
<!-- Search input -->
<div class="px-2 pb-2">
<div class="relative">
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-light/40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
bind:value={searchQuery}
placeholder="Search rooms..."
class="w-full pl-9 pr-3 py-2 bg-night text-light text-sm rounded-lg border border-light/10 placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
{#if searchQuery}
<button
class="absolute right-2 top-1/2 -translate-y-1/2 text-light/40 hover:text-light"
onclick={() => (searchQuery = "")}
title="Clear search"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/if}
</div>
</div>
<!-- Organizations (Spaces) Section -->
<div class="mb-4">
<div class="flex items-center justify-between px-2 py-2">
<span
class="text-xs font-semibold text-light/40 uppercase tracking-wider"
>
Spaces
</span>
<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={onCreateSpace}
title="Create space"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</div>
{#if spaces.length > 0}
<ul class="flex flex-col gap-1">
{#each spaces as space (space.roomId)}
<li>
<button
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors text-left
{selectedRoomId === space.roomId
? 'bg-primary/20 text-primary'
: 'text-light hover:bg-light/5'}"
onclick={() => toggleSpace(space.roomId)}
>
<svg
class="w-4 h-4 transition-transform {expandedSpaces.has(
space.roomId,
)
? 'rotate-90'
: ''}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="9,18 15,12 9,6" />
</svg>
<Avatar src={space.avatarUrl} name={space.name} size="sm" />
<span class="font-medium truncate flex-1">{space.name}</span>
</button>
<!-- Child rooms of this space -->
{#if expandedSpaces.has(space.roomId)}
<ul class="ml-6 mt-1 flex flex-col gap-1">
{#each roomsBySpace().get(space.roomId) || [] as room (room.roomId)}
<li>
<button
class="w-full flex items-center gap-3 px-3 py-1.5 rounded-lg transition-colors text-left text-sm
{selectedRoomId === room.roomId
? 'bg-primary/20 text-primary'
: 'text-light/80 hover:bg-light/5'}"
onclick={() => onRoomSelect(room.roomId)}
>
<span class="text-light/40">#</span>
<span class="truncate flex-1">{room.name}</span>
{#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}
</li>
{/each}
</ul>
{:else}
<p class="text-light/30 text-xs text-center py-2 px-2">
No spaces yet. Create one to organize your rooms.
</p>
{/if}
</div>
<!-- Messages (Orphan Rooms) Section -->
<div class="flex items-center justify-between px-2 py-2">
<span
class="text-xs font-semibold text-light/40 uppercase tracking-wider"
>
Messages {searchQuery ? `(${orphanRooms.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={onStartDM}
title="Start direct message"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
/>
</svg>
</button>
<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={onCreateRoom}
title="Create room"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</div>
</div>
{#if orphanRooms.length === 0 && spaces.length === 0}
<p class="text-light/40 text-sm text-center py-8">
{searchQuery ? "No matching rooms" : "No rooms yet"}
</p>
{:else if orphanRooms.length > 0}
<ul class="flex flex-col gap-1">
{#each orphanRooms as room (room.roomId)}
<li>
<button
class="w-full flex items-center gap-3 px-3 py-3 rounded-lg transition-colors text-left
{selectedRoomId === room.roomId
? 'bg-primary/20 text-primary'
: 'text-light hover:bg-light/5'}"
onclick={() => onRoomSelect(room.roomId)}
>
<Avatar src={room.avatarUrl} name={room.name} size="md" />
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<span class="font-medium truncate">{room.name}</span>
{#if room.unreadCount > 0}
<span
class="bg-primary text-white text-xs px-1.5 py-0.5 rounded-full min-w-[20px] text-center"
>
{room.unreadCount > 99 ? "99+" : room.unreadCount}
</span>
{/if}
</div>
{#if room.lastMessage}
<p class="text-xs text-light/40 truncate">
{room.lastMessage.senderName}: {room.lastMessage.content}
</p>
{/if}
</div>
</button>
</li>
{/each}
</ul>
{/if}
</nav>
<!-- User Section -->
<footer class="p-4 border-t border-light/10">
<div class="flex items-center gap-3">
<Avatar name={auth.userId || "User"} size="sm" status="online" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-light truncate">
{auth.userId}
</p>
</div>
<button
class="text-light/50 hover:text-light p-2 rounded-lg hover:bg-light/10 transition-colors"
onclick={onLogout}
title="Logout"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16,17 21,12 16,7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
</div>
</footer>
</aside>

View File

@@ -0,0 +1,2 @@
export { default as Sidebar } from './Sidebar.svelte';
export { default as ChatArea } from './ChatArea.svelte';

View File

@@ -0,0 +1,346 @@
<script lang="ts">
import {
theme,
isDarkMode,
primaryColor,
PRESET_COLORS,
} from "$lib/stores/theme";
import { auth } from "$lib/stores/matrix";
import { getClient } from "$lib/matrix/client";
import { Avatar, Button, Input } from "$lib/components/ui";
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
// User profile state
let displayName = $state("");
let activeTab = $state<"profile" | "appearance" | "security">("profile");
let saving = $state(false);
let error = $state("");
// Derived values
const currentUserId = $derived($auth.userId || "@user");
const dark = $derived($isDarkMode);
const currentPrimary = $derived($primaryColor);
// Load user profile on open
$effect(() => {
if (open && currentUserId && currentUserId !== "@user") {
const client = getClient();
if (client) {
const user = client.getUser(currentUserId);
if (user) {
displayName = user.displayName || "";
}
}
}
});
function handleClose() {
onClose();
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
handleClose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
handleClose();
}
}
async function handleSaveProfile() {
if (!displayName.trim()) return;
saving = true;
error = "";
try {
const client = getClient();
if (client) {
await client.setDisplayName(displayName.trim());
}
handleClose();
} catch (e: unknown) {
error = e instanceof Error ? e.message : "Failed to save profile";
} finally {
saving = false;
}
}
function handleColorSelect(color: string) {
theme.setPrimaryColor(color);
}
</script>
{#if open}
<div
class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
role="dialog"
aria-modal="true"
aria-label="User Settings"
tabindex="-1"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
>
<div
class="bg-night rounded-[24px] w-[90vw] max-w-[480px] max-h-[85vh] overflow-hidden flex flex-col"
role="document"
>
<!-- Header -->
<div
class="flex items-center justify-between p-5 border-b border-light/10"
>
<h2 class="text-xl font-heading text-light">Settings</h2>
<button
class="flex items-center justify-center size-8 rounded-full hover:bg-light/10 transition-colors"
onclick={handleClose}
aria-label="Close settings"
>
<svg
class="w-5 h-5 text-light"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- Tabs -->
<div class="flex border-b border-light/10">
<button
class="flex-1 py-3 px-4 text-sm font-bold transition-colors {activeTab ===
'profile'
? 'text-primary bg-primary/10 border-b-2 border-primary'
: 'text-light hover:bg-light/5'}"
onclick={() => (activeTab = "profile")}
>
Profile
</button>
<button
class="flex-1 py-3 px-4 text-sm font-bold transition-colors {activeTab ===
'appearance'
? 'text-primary bg-primary/10 border-b-2 border-primary'
: 'text-light hover:bg-light/5'}"
onclick={() => (activeTab = "appearance")}
>
Appearance
</button>
<button
class="flex-1 py-3 px-4 text-sm font-bold transition-colors {activeTab ===
'security'
? 'text-primary bg-primary/10 border-b-2 border-primary'
: 'text-light hover:bg-light/5'}"
onclick={() => (activeTab = "security")}
>
Security
</button>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-5">
{#if activeTab === "profile"}
<!-- Profile Tab -->
<div class="flex flex-col gap-6">
<!-- Avatar Section -->
<div class="flex flex-col items-center gap-3">
<Avatar name={displayName || currentUserId} size="xl" />
<p class="text-text-muted text-sm">{currentUserId}</p>
{#if error}
<p class="text-error text-sm">{error}</p>
{/if}
</div>
<!-- Profile Fields -->
<div class="flex flex-col gap-4">
<Input
label="Display Name"
bind:value={displayName}
placeholder="Your display name"
/>
</div>
</div>
{:else if activeTab === "appearance"}
<!-- Appearance Tab -->
<div class="flex flex-col gap-6">
<!-- Theme Mode -->
<div class="flex flex-col gap-3">
<h3 class="text-light font-bold text-sm">Theme Mode</h3>
<div class="flex gap-3">
<button
class="flex-1 flex flex-col items-center gap-2 p-4 rounded-xl border-2 cursor-pointer transition-all {!dark
? 'border-primary bg-primary/10'
: 'border-light/10 bg-light/5 hover:bg-light/10'}"
onclick={() => theme.setMode("light")}
>
<svg
class="w-6 h-6 {!dark ? 'text-primary' : 'text-light'}"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"
/>
</svg>
<span
class="text-sm font-bold {!dark
? 'text-primary'
: 'text-light'}">Light</span
>
</button>
<button
class="flex-1 flex flex-col items-center gap-2 p-4 rounded-xl border-2 cursor-pointer transition-all {dark
? 'border-primary bg-primary/10'
: 'border-light/10 bg-light/5 hover:bg-light/10'}"
onclick={() => theme.setMode("dark")}
>
<svg
class="w-6 h-6 {dark ? 'text-primary' : 'text-light'}"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-2.98 0-5.4-2.42-5.4-5.4 0-1.81.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1z"
/>
</svg>
<span
class="text-sm font-bold {dark
? 'text-primary'
: 'text-light'}">Dark</span
>
</button>
</div>
</div>
<!-- Accent Color -->
<div class="flex flex-col gap-3">
<h3 class="text-light font-bold text-sm">Accent Color</h3>
<div class="grid grid-cols-6 gap-3">
{#each PRESET_COLORS as color (color.primary)}
<button
class="size-10 rounded-full cursor-pointer border-2 transition-all hover:scale-110 flex items-center justify-center {currentPrimary ===
color.primary
? 'border-white ring-2 ring-white/30'
: 'border-transparent'}"
style="background-color: {color.primary}"
title={color.name}
onclick={() => handleColorSelect(color.primary)}
>
{#if currentPrimary === color.primary}
<svg
class="w-4 h-4 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
>
<polyline points="20,6 9,17 4,12" />
</svg>
{/if}
</button>
{/each}
</div>
</div>
<!-- Custom Color -->
<div class="flex flex-col gap-2">
<label class="text-text-muted text-sm" for="custom-color"
>Custom Color</label
>
<div class="flex gap-3 items-center">
<input
id="custom-color"
type="color"
value={currentPrimary}
onchange={(e) => handleColorSelect(e.currentTarget.value)}
class="size-10 rounded-lg cursor-pointer border-none"
/>
<div class="flex-1">
<Input
value={currentPrimary}
placeholder="#00A3E0"
oninput={(e) =>
handleColorSelect((e.target as HTMLInputElement).value)}
/>
</div>
</div>
</div>
</div>
{:else if activeTab === "security"}
<!-- Security Tab -->
<div class="flex flex-col gap-6">
<!-- Device Info -->
<div class="flex flex-col gap-3">
<h3 class="text-light font-bold text-sm">This Device</h3>
<div class="flex flex-col gap-2 p-4 bg-dark/50 rounded-xl">
<div class="flex items-center justify-between">
<span class="text-text-muted text-sm">Device ID</span>
<code class="text-light text-sm bg-night px-2 py-1 rounded">
{$auth.deviceId || "Unknown"}
</code>
</div>
<div class="flex items-center justify-between">
<span class="text-text-muted text-sm">User ID</span>
<code class="text-light text-sm bg-night px-2 py-1 rounded">
{currentUserId}
</code>
</div>
<div class="flex items-center justify-between">
<span class="text-text-muted text-sm">Homeserver</span>
<code
class="text-light text-sm bg-night px-2 py-1 rounded truncate max-w-[200px]"
>
{$auth.homeserverUrl || "Unknown"}
</code>
</div>
</div>
</div>
<!-- Encryption Status -->
<div class="flex flex-col gap-3">
<h3 class="text-light font-bold text-sm">
End-to-End Encryption
</h3>
<div class="flex items-center gap-3 p-4 bg-dark/50 rounded-xl">
<svg
class="w-6 h-6 text-success"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z"
/>
</svg>
<div class="flex-1">
<p class="text-light font-medium">Encryption Enabled</p>
<p class="text-text-muted text-sm">
Your messages are end-to-end encrypted
</p>
</div>
</div>
</div>
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 p-5 border-t border-light/10">
<Button variant="secondary" onclick={handleClose}>Cancel</Button>
<Button onclick={handleSaveProfile} loading={saving}
>Save Changes</Button
>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1 @@
export { default as UserSettingsModal } from './UserSettingsModal.svelte';

View File

@@ -0,0 +1,103 @@
<script lang="ts">
import { Button, Input } from "$lib/components/ui";
import { createRoom } from "$lib/matrix";
import { toasts } from "$lib/stores/ui";
import { syncRoomsFromEvent, selectRoom } from "$lib/stores/matrix";
interface Props {
isOpen: boolean;
onClose: () => void;
}
let { isOpen, onClose }: Props = $props();
let roomName = $state("");
let isDirect = $state(false);
let isCreating = $state(false);
async function handleCreate() {
if (!roomName.trim()) {
toasts.error("Please enter a room name");
return;
}
isCreating = true;
try {
const result = await createRoom(roomName.trim(), isDirect);
toasts.success("Room created!");
// Add new room to list and select it
syncRoomsFromEvent("join", result.room_id);
selectRoom(result.room_id);
// Reset and close
roomName = "";
isDirect = false;
onClose();
} catch (e: any) {
console.error("Failed to create room:", e);
toasts.error(e.message || "Failed to create room");
} finally {
isCreating = false;
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
}
}
</script>
{#if isOpen}
<div
class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
onclick={onClose}
onkeydown={handleKeyDown}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
onclick={(e) => e.stopPropagation()}
role="document"
>
<h2 class="text-xl font-semibold text-light mb-4">Create New Room</h2>
<form
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
class="flex flex-col gap-4"
>
<Input
bind:value={roomName}
label="Room Name"
placeholder="My awesome room"
required
/>
<label class="flex items-center gap-3 text-light cursor-pointer">
<input
type="checkbox"
bind:checked={isDirect}
class="w-4 h-4 rounded border-light/30 bg-night text-primary focus:ring-primary"
/>
<span>Direct message (private 1:1 chat)</span>
</label>
<div class="flex gap-3 justify-end mt-2">
<Button variant="secondary" onclick={onClose} disabled={isCreating}>
Cancel
</Button>
<Button type="submit" loading={isCreating} disabled={isCreating}>
{isCreating ? "Creating..." : "Create Room"}
</Button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -0,0 +1,174 @@
<script lang="ts">
import { Button, Input } from "$lib/components/ui";
import { createSpace, getSpaces } from "$lib/matrix";
import { toasts } from "$lib/stores/ui";
import { syncRoomsFromEvent } from "$lib/stores/matrix";
interface Props {
isOpen: boolean;
onClose: () => void;
parentSpaceId?: string | null;
}
let { isOpen, onClose, parentSpaceId = null }: Props = $props();
let spaceName = $state("");
let spaceTopic = $state("");
let isPublic = $state(false);
let isCreating = $state(false);
// Get existing spaces for parent selection
const existingSpaces = $derived(getSpaces());
async function handleCreate() {
if (!spaceName.trim()) {
toasts.error("Please enter a space name");
return;
}
isCreating = true;
try {
const result = await createSpace(spaceName.trim(), {
topic: spaceTopic.trim() || undefined,
isPublic,
parentSpaceId: parentSpaceId || undefined,
});
toasts.success("Space created!");
// Sync the new space
syncRoomsFromEvent("join", result.room_id);
// Reset and close
spaceName = "";
spaceTopic = "";
isPublic = false;
onClose();
} catch (e: any) {
console.error("Failed to create space:", e);
toasts.error(e.message || "Failed to create space");
} finally {
isCreating = false;
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
}
}
</script>
{#if isOpen}
<div
class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
onclick={onClose}
onkeydown={handleKeyDown}
role="dialog"
aria-modal="true"
aria-labelledby="create-space-title"
tabindex="-1"
>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="document"
>
<div class="flex items-center gap-3 mb-6">
<div class="w-12 h-12 rounded-xl bg-primary/20 flex items-center justify-center">
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9,22 9,12 15,12 15,22" />
</svg>
</div>
<div>
<h2 id="create-space-title" class="text-xl font-semibold text-light">Create Space</h2>
<p class="text-sm text-light/60">Organize your rooms and team</p>
</div>
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
class="flex flex-col gap-4"
>
<Input
bind:value={spaceName}
label="Space Name"
placeholder="My Organization"
required
/>
<div>
<label for="space-topic" class="block text-sm font-medium text-light/80 mb-1.5">
Description (optional)
</label>
<textarea
id="space-topic"
bind:value={spaceTopic}
placeholder="What is this space for?"
rows="2"
class="w-full px-4 py-2.5 bg-night text-light rounded-xl border border-light/20
placeholder:text-light/40 focus:outline-none focus:border-primary
focus:ring-1 focus:ring-primary resize-none"
></textarea>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-light/80">Visibility</span>
<label class="flex items-start gap-3 p-3 rounded-lg border border-light/10 hover:border-light/20 cursor-pointer transition-colors {!isPublic ? 'border-primary bg-primary/5' : ''}">
<input
type="radio"
name="visibility"
checked={!isPublic}
onchange={() => isPublic = false}
class="mt-0.5 w-4 h-4 text-primary focus:ring-primary"
/>
<div>
<span class="text-light font-medium">Private</span>
<p class="text-sm text-light/60">Only invited members can join</p>
</div>
</label>
<label class="flex items-start gap-3 p-3 rounded-lg border border-light/10 hover:border-light/20 cursor-pointer transition-colors {isPublic ? 'border-primary bg-primary/5' : ''}">
<input
type="radio"
name="visibility"
checked={isPublic}
onchange={() => isPublic = true}
class="mt-0.5 w-4 h-4 text-primary focus:ring-primary"
/>
<div>
<span class="text-light font-medium">Public</span>
<p class="text-sm text-light/60">Anyone can find and join this space</p>
</div>
</label>
</div>
{#if parentSpaceId}
<div class="px-3 py-2 bg-light/5 rounded-lg text-sm text-light/60">
<span class="text-light/40">Creating inside:</span>
<span class="text-light ml-1">
{existingSpaces.find(s => s.roomId === parentSpaceId)?.name || 'Parent Space'}
</span>
</div>
{/if}
<div class="flex gap-3 justify-end mt-2">
<Button variant="secondary" onclick={onClose} disabled={isCreating}>
Cancel
</Button>
<Button type="submit" loading={isCreating} disabled={isCreating}>
{isCreating ? "Creating..." : "Create Space"}
</Button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import Twemoji from '$lib/components/ui/Twemoji.svelte';
import { searchEmojis, type EmojiItem } from '$lib/utils/emojiData';
interface Props {
query: string;
onSelect: (emoji: string) => void;
onClose: () => void;
}
let {
query,
onSelect,
onClose,
}: Props = $props();
let selectedIndex = $state(0);
// Filter emojis based on query
const filteredEmojis = $derived(
searchEmojis(query).slice(0, 10)
);
// Reset selection when query changes
$effect(() => {
query;
selectedIndex = 0;
});
function handleKeyDown(e: KeyboardEvent) {
if (filteredEmojis.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
selectedIndex = (selectedIndex + 1) % filteredEmojis.length;
break;
case 'ArrowUp':
e.preventDefault();
selectedIndex = (selectedIndex - 1 + filteredEmojis.length) % filteredEmojis.length;
break;
case 'Enter':
case 'Tab':
e.preventDefault();
if (filteredEmojis[selectedIndex]) {
onSelect(filteredEmojis[selectedIndex].emoji);
}
break;
case 'Escape':
e.preventDefault();
onClose();
break;
}
}
// Expose keyboard handler for parent to call
export { handleKeyDown };
</script>
{#if filteredEmojis.length > 0}
<div
class="absolute z-50 bg-dark border border-light/10 rounded-lg shadow-xl overflow-hidden max-h-64 overflow-y-auto"
style="bottom: 100%; left: 0; margin-bottom: 8px; min-width: 280px;"
>
<div class="p-2 text-xs text-light/50 border-b border-light/10">
Emojis matching :{query}
</div>
{#each filteredEmojis as emoji, i}
<button
class="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors {i === selectedIndex ? 'bg-primary/20' : 'hover:bg-light/5'}"
onclick={() => onSelect(emoji.emoji)}
onmouseenter={() => selectedIndex = i}
>
<div class="w-6 h-6 flex items-center justify-center">
<Twemoji emoji={emoji.emoji} size={20} />
</div>
<div class="flex-1 min-w-0">
<p class="text-light">:{emoji.names[0]}:</p>
{#if emoji.names.length > 1}
<p class="text-xs text-light/40 truncate">
Also: {emoji.names.slice(1, 4).map(n => `:${n}:`).join(' ')}
</p>
{/if}
</div>
</button>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import type { Snippet } from "svelte";
import type { MatrixClient } from "matrix-js-sdk";
import { setMatrixContext } from "$lib/matrix/context";
import { setupSyncHandlers, removeSyncHandlers } from "$lib/matrix/sync";
interface Props {
client: MatrixClient;
children: Snippet;
}
let { client, children }: Props = $props();
// Store client reference for cleanup
let clientRef = client;
// Set the context during component initialization
setMatrixContext(clientRef);
// Setup sync handlers when provider mounts
onMount(() => {
setupSyncHandlers(clientRef);
});
// Cleanup when provider unmounts
onDestroy(() => {
removeSyncHandlers(clientRef);
});
</script>
{@render children()}

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import UserProfileModal from "./UserProfileModal.svelte";
import type { RoomMember } from "$lib/matrix/types";
import { userPresence } from "$lib/stores/matrix";
interface Props {
members: RoomMember[];
onMemberClick?: (member: RoomMember) => void;
onStartDM?: (roomId: string) => void;
}
let { members, onMemberClick, onStartDM }: Props = $props();
let selectedMember = $state<RoomMember | null>(null);
function handleMemberClick(member: RoomMember) {
if (onMemberClick) {
onMemberClick(member);
} else {
selectedMember = member;
}
}
// Sort: online first, then admins, then by name
const sortedMembers = $derived(
[...members].sort((a, b) => {
// Online status first
const aOnline = $userPresence.get(a.userId) === "online" ? 1 : 0;
const bOnline = $userPresence.get(b.userId) === "online" ? 1 : 0;
if (bOnline !== aOnline) return bOnline - aOnline;
// Power level descending
if (b.powerLevel !== a.powerLevel) {
return b.powerLevel - a.powerLevel;
}
// Then alphabetically
return a.name.localeCompare(b.name);
}),
);
function getRoleBadge(
powerLevel: number,
): { label: string; color: string } | null {
if (powerLevel >= 100) return { label: "Admin", color: "text-red-400" };
if (powerLevel >= 50) return { label: "Mod", color: "text-yellow-400" };
return null;
}
function getPresenceStatus(userId: string): "online" | "offline" | null {
const presence = $userPresence.get(userId);
if (presence === "online") return "online";
if (presence === "offline" || presence === "unavailable") return "offline";
return null;
}
</script>
<div class="flex flex-col h-full">
<header class="p-4 border-b border-light/10">
<h3 class="font-semibold text-light">Members ({members.length})</h3>
</header>
<div class="flex-1 overflow-y-auto">
{#each sortedMembers as member}
<button
class="w-full flex items-center gap-3 px-4 py-2 hover:bg-light/5 transition-colors text-left"
onclick={() => handleMemberClick(member)}
>
<Avatar
src={member.avatarUrl}
name={member.name}
size="sm"
status={getPresenceStatus(member.userId)}
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-light truncate">{member.name}</span>
{#if getRoleBadge(member.powerLevel)}
{@const badge = getRoleBadge(member.powerLevel)}
<span class="text-xs {badge?.color}">{badge?.label}</span>
{/if}
</div>
<p class="text-xs text-light/40 truncate">{member.userId}</p>
</div>
</button>
{/each}
{#if members.length === 0}
<div class="p-4 text-center text-light/40">
<p>No members</p>
</div>
{/if}
</div>
</div>
{#if selectedMember}
<UserProfileModal
member={selectedMember}
onClose={() => (selectedMember = null)}
{onStartDM}
/>
{/if}

View File

@@ -0,0 +1,91 @@
<script lang="ts">
import { Avatar } from '$lib/components/ui';
import type { RoomMember } from '$lib/matrix/types';
interface Props {
members: RoomMember[];
query: string;
onSelect: (member: RoomMember) => void;
onClose: () => void;
position?: { top: number; left: number };
}
let {
members,
query,
onSelect,
onClose,
position = { top: 0, left: 0 },
}: Props = $props();
let selectedIndex = $state(0);
// Filter members based on query
const filteredMembers = $derived(
members
.filter(m =>
m.name.toLowerCase().includes(query.toLowerCase()) ||
m.userId.toLowerCase().includes(query.toLowerCase())
)
.slice(0, 8)
);
// Reset selection when query changes
$effect(() => {
query;
selectedIndex = 0;
});
function handleKeyDown(e: KeyboardEvent) {
if (filteredMembers.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
selectedIndex = (selectedIndex + 1) % filteredMembers.length;
break;
case 'ArrowUp':
e.preventDefault();
selectedIndex = (selectedIndex - 1 + filteredMembers.length) % filteredMembers.length;
break;
case 'Enter':
case 'Tab':
e.preventDefault();
if (filteredMembers[selectedIndex]) {
onSelect(filteredMembers[selectedIndex]);
}
break;
case 'Escape':
e.preventDefault();
onClose();
break;
}
}
// Expose keyboard handler for parent to call
export { handleKeyDown };
</script>
{#if filteredMembers.length > 0}
<div
class="absolute z-50 bg-dark border border-light/10 rounded-lg shadow-xl overflow-hidden max-h-64 overflow-y-auto"
style="bottom: 100%; left: 0; margin-bottom: 8px; min-width: 250px;"
>
<div class="p-2 text-xs text-light/50 border-b border-light/10">
Members matching @{query}
</div>
{#each filteredMembers as member, i}
<button
class="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors {i === selectedIndex ? 'bg-primary/20' : 'hover:bg-light/5'}"
onclick={() => onSelect(member)}
onmouseenter={() => selectedIndex = i}
>
<Avatar src={member.avatarUrl} name={member.name} size="sm" />
<div class="flex-1 min-w-0">
<p class="text-light truncate">{member.name}</p>
<p class="text-xs text-light/40 truncate">{member.userId}</p>
</div>
</button>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,761 @@
<script lang="ts">
import { onDestroy, tick } from "svelte";
import {
sendMessage,
setTyping,
uploadFile,
sendFileMessage,
getRoomMembers,
} from "$lib/matrix";
import { toasts } from "$lib/stores/ui";
import {
auth,
addPendingMessage,
confirmPendingMessage,
removePendingMessage,
} from "$lib/stores/matrix";
import type { Message, RoomMember } from "$lib/matrix/types";
import MentionAutocomplete from "./MentionAutocomplete.svelte";
import EmojiAutocomplete from "./EmojiAutocomplete.svelte";
import EmojiPicker from "$lib/components/ui/EmojiPicker.svelte";
import { convertEmojiShortcodes } from "$lib/utils/emojiData";
import { getTwemojiUrl } from "$lib/utils/twemoji";
// Emoji detection regex
const emojiRegex =
/(\p{Emoji_Presentation}|\p{Emoji}\uFE0F|\p{Extended_Pictographic})/gu;
// Check if text contains emojis
function hasEmoji(text: string): boolean {
return emojiRegex.test(text);
}
// Render emojis as Twemoji images for preview
function renderEmojiPreview(text: string): string {
// Escape HTML first
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\n/g, "<br>");
// Replace emojis with Twemoji images
return escaped.replace(emojiRegex, (emoji) => {
const url = getTwemojiUrl(emoji);
return `<img class="inline-block w-5 h-5 align-text-bottom" src="${url}" alt="${emoji}" draggable="false" />`;
});
}
interface Props {
roomId: string;
placeholder?: string;
disabled?: boolean;
replyTo?: Message | null;
onCancelReply?: () => void;
editingMessage?: Message | null;
onSaveEdit?: (content: string) => void;
onCancelEdit?: () => void;
}
let {
roomId,
placeholder = "Send a message...",
disabled = false,
replyTo = null,
onCancelReply,
editingMessage = null,
onSaveEdit,
onCancelEdit,
}: Props = $props();
let message = $state("");
let isSending = $state(false);
let isUploading = $state(false);
let inputRef: HTMLTextAreaElement;
let fileInputRef: HTMLInputElement;
let typingTimeout: ReturnType<typeof setTimeout> | null = null;
// Mention autocomplete state
let showMentions = $state(false);
let mentionQuery = $state("");
let mentionStartIndex = $state(0);
let autocompleteRef:
| { handleKeyDown: (e: KeyboardEvent) => void }
| undefined;
// Emoji picker state
let showEmojiPicker = $state(false);
let emojiButtonRef: HTMLButtonElement;
// Emoji autocomplete state
let showEmojiAutocomplete = $state(false);
let emojiQuery = $state("");
let emojiStartIndex = $state(0);
let emojiAutocompleteRef:
| { handleKeyDown: (e: KeyboardEvent) => void }
| undefined;
// Get room members for autocomplete
const roomMembers = $derived(getRoomMembers(roomId));
// Cleanup typing timeout on component destroy
onDestroy(() => {
if (typingTimeout) {
clearTimeout(typingTimeout);
setTyping(roomId, false).catch(() => {});
}
});
// Populate message when editing starts
$effect(() => {
if (editingMessage) {
message = editingMessage.content;
setTimeout(() => {
autoResize();
inputRef?.focus();
}, 0);
}
});
// Auto-resize textarea
function autoResize() {
if (!inputRef) return;
inputRef.style.height = "auto";
inputRef.style.height = Math.min(inputRef.scrollHeight, 200) + "px";
}
// Handle typing indicator
function handleTyping() {
// Clear existing timeout
if (typingTimeout) {
clearTimeout(typingTimeout);
}
// Send typing indicator
setTyping(roomId, true).catch(console.error);
// Stop typing after 3 seconds of no input
typingTimeout = setTimeout(() => {
setTyping(roomId, false).catch(console.error);
}, 3000);
}
// Handle input
function handleInput() {
autoResize();
if (message.trim()) {
handleTyping();
}
// Auto-convert completed emoji shortcodes like :heart: to actual emojis
autoConvertShortcodes();
// Check for @ mentions and : emoji shortcodes
checkForMention();
checkForEmoji();
}
// Auto-convert completed emoji shortcodes (e.g., :heart:) to actual emojis
function autoConvertShortcodes() {
if (!inputRef) return;
const cursorPos = inputRef.selectionStart;
// Look for completed shortcodes like :name:
const converted = convertEmojiShortcodes(message);
if (converted !== message) {
// Calculate cursor offset based on length difference
const lengthDiff = message.length - converted.length;
message = converted;
// Restore cursor position (adjusted for shorter string)
setTimeout(() => {
if (inputRef) {
const newPos = Math.max(0, cursorPos - lengthDiff);
inputRef.selectionStart = inputRef.selectionEnd = newPos;
}
}, 0);
}
}
// Check if user is typing an emoji shortcode
function checkForEmoji() {
if (!inputRef) return;
const cursorPos = inputRef.selectionStart;
const textBeforeCursor = message.slice(0, cursorPos);
// Find the last : before cursor
const lastColonIndex = textBeforeCursor.lastIndexOf(":");
if (lastColonIndex >= 0) {
const textAfterColon = textBeforeCursor.slice(lastColonIndex + 1);
// Check if there's a space before : (or it's at start) and no space after, and query is at least 2 chars
const charBeforeColon =
lastColonIndex > 0 ? message[lastColonIndex - 1] : " ";
if (
(charBeforeColon === " " ||
charBeforeColon === "\n" ||
lastColonIndex === 0) &&
!textAfterColon.includes(" ") &&
!textAfterColon.includes(":") &&
textAfterColon.length >= 2
) {
showEmojiAutocomplete = true;
emojiQuery = textAfterColon;
emojiStartIndex = lastColonIndex;
return;
}
}
showEmojiAutocomplete = false;
emojiQuery = "";
}
// Handle emoji selection from autocomplete
function handleEmojiSelect(emoji: string) {
// Replace :query with the emoji
const beforeEmoji = message.slice(0, emojiStartIndex);
const afterEmoji = message.slice(emojiStartIndex + emojiQuery.length + 1);
message = `${beforeEmoji}${emoji}${afterEmoji}`;
showEmojiAutocomplete = false;
emojiQuery = "";
// Focus back on textarea
inputRef?.focus();
}
// Check if user is typing a mention
function checkForMention() {
if (!inputRef) return;
const cursorPos = inputRef.selectionStart;
const textBeforeCursor = message.slice(0, cursorPos);
// Find the last @ before cursor that's not part of a completed mention
const lastAtIndex = textBeforeCursor.lastIndexOf("@");
if (lastAtIndex >= 0) {
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
// Check if there's a space before @ (or it's at start) and no space after
const charBeforeAt = lastAtIndex > 0 ? message[lastAtIndex - 1] : " ";
if (
(charBeforeAt === " " || charBeforeAt === "\n" || lastAtIndex === 0) &&
!textAfterAt.includes(" ")
) {
showMentions = true;
mentionQuery = textAfterAt;
mentionStartIndex = lastAtIndex;
return;
}
}
showMentions = false;
mentionQuery = "";
}
// Handle mention selection
function handleMentionSelect(member: RoomMember) {
// Replace @query with userId (userId already has @ prefix)
const beforeMention = message.slice(0, mentionStartIndex);
const afterMention = message.slice(
mentionStartIndex + mentionQuery.length + 1,
);
message = `${beforeMention}${member.userId} ${afterMention}`;
showMentions = false;
mentionQuery = "";
// Focus back on textarea
inputRef?.focus();
}
// Handle key press
function handleKeyDown(e: KeyboardEvent) {
// If mention autocomplete is open, let it handle navigation keys
if (
showMentions &&
["ArrowUp", "ArrowDown", "Tab", "Escape"].includes(e.key)
) {
autocompleteRef?.handleKeyDown(e);
return;
}
// Enter with mention autocomplete open selects the mention
if (showMentions && e.key === "Enter") {
e.preventDefault();
autocompleteRef?.handleKeyDown(e);
return;
}
// If emoji autocomplete is open, let it handle navigation keys
if (
showEmojiAutocomplete &&
["ArrowUp", "ArrowDown", "Tab", "Escape"].includes(e.key)
) {
emojiAutocompleteRef?.handleKeyDown(e);
return;
}
// Enter with emoji autocomplete open selects the emoji
if (showEmojiAutocomplete && e.key === "Enter") {
e.preventDefault();
emojiAutocompleteRef?.handleKeyDown(e);
return;
}
// Send on Enter (without Shift)
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
return;
}
// Auto-continue lists on Shift+Enter or regular Enter with list
if (e.key === "Enter" && e.shiftKey) {
const cursorPos = inputRef?.selectionStart || 0;
const textBefore = message.slice(0, cursorPos);
const currentLine = textBefore.split("\n").pop() || "";
// Check for numbered list (1. 2. etc)
const numberedMatch = currentLine.match(/^(\s*)(\d+)\.\s/);
if (numberedMatch) {
e.preventDefault();
const indent = numberedMatch[1];
const nextNum = parseInt(numberedMatch[2]) + 1;
const newText =
message.slice(0, cursorPos) +
`\n${indent}${nextNum}. ` +
message.slice(cursorPos);
message = newText;
setTimeout(() => {
if (inputRef) {
inputRef.selectionStart = inputRef.selectionEnd =
cursorPos + indent.length + String(nextNum).length + 4;
}
}, 0);
return;
}
// Check for bullet list (- or *)
const bulletMatch = currentLine.match(/^(\s*)([-*])\s/);
if (bulletMatch) {
e.preventDefault();
const indent = bulletMatch[1];
const bullet = bulletMatch[2];
const newText =
message.slice(0, cursorPos) +
`\n${indent}${bullet} ` +
message.slice(cursorPos);
message = newText;
setTimeout(() => {
if (inputRef) {
inputRef.selectionStart = inputRef.selectionEnd =
cursorPos + indent.length + 4;
}
}, 0);
return;
}
// Check for lettered sub-list (a. b. etc)
const letteredMatch = currentLine.match(/^(\s*)([a-z])\.\s/);
if (letteredMatch) {
e.preventDefault();
const indent = letteredMatch[1];
const nextLetter = String.fromCharCode(
letteredMatch[2].charCodeAt(0) + 1,
);
const newText =
message.slice(0, cursorPos) +
`\n${indent}${nextLetter}. ` +
message.slice(cursorPos);
message = newText;
setTimeout(() => {
if (inputRef) {
inputRef.selectionStart = inputRef.selectionEnd =
cursorPos + indent.length + 5;
}
}, 0);
return;
}
}
}
// Send message or save edit
async function handleSend() {
const trimmed = message.trim();
if (!trimmed || isSending || disabled) return;
// Convert emoji shortcodes like :heart: to actual emojis
const processedMessage = convertEmojiShortcodes(trimmed);
// Handle edit mode
if (editingMessage) {
if (processedMessage === editingMessage.content) {
// No changes, just cancel
onCancelEdit?.();
message = "";
return;
}
onSaveEdit?.(processedMessage);
message = "";
if (inputRef) {
inputRef.style.height = "auto";
}
return;
}
isSending = true;
// Clear typing indicator
if (typingTimeout) {
clearTimeout(typingTimeout);
typingTimeout = null;
}
setTyping(roomId, false).catch(console.error);
// Create a temporary event ID for the pending message
const tempEventId = `pending-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Add pending message immediately (optimistic update)
const pendingMessage: Message = {
eventId: tempEventId,
roomId,
sender: $auth.userId || "",
senderName: $auth.userId?.split(":")[0]?.replace("@", "") || "You",
senderAvatar: null,
content: processedMessage,
timestamp: Date.now(),
type: "text",
isEdited: false,
isRedacted: false,
isPending: true,
replyTo: replyTo?.eventId,
reactions: new Map(),
};
addPendingMessage(roomId, pendingMessage);
message = "";
// Clear reply
onCancelReply?.();
// Reset textarea height
if (inputRef) {
inputRef.style.height = "auto";
}
try {
const result = await sendMessage(
roomId,
processedMessage,
replyTo?.eventId,
);
// Confirm the pending message with the real event ID
if (result?.event_id) {
confirmPendingMessage(roomId, tempEventId, result.event_id);
} else {
// If no event ID returned, just mark as not pending
confirmPendingMessage(roomId, tempEventId, tempEventId);
}
} catch (e: any) {
console.error("Failed to send message:", e);
// Remove the pending message on failure
removePendingMessage(roomId, tempEventId);
toasts.error(e.message || "Failed to send message");
} finally {
isSending = false;
// Refocus after DOM settles from optimistic update
await tick();
inputRef?.focus();
}
}
// Handle file selection
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || disabled) return;
// Reset input
input.value = "";
// Check file size (50MB limit)
const maxSize = 50 * 1024 * 1024;
if (file.size > maxSize) {
toasts.error("File too large. Maximum size is 50MB.");
return;
}
isUploading = true;
try {
toasts.info(`Uploading ${file.name}...`);
const contentUri = await uploadFile(file);
await sendFileMessage(roomId, file, contentUri);
toasts.success("File sent!");
} catch (e: any) {
console.error("Failed to upload file:", e);
toasts.error(e.message || "Failed to upload file");
} finally {
isUploading = false;
}
}
function openFilePicker() {
fileInputRef?.click();
}
</script>
<div class="border-t border-light/10">
<!-- Edit preview -->
{#if editingMessage}
<div class="px-4 pt-3 pb-0">
<div
class="flex items-center gap-2 px-3 py-2 bg-yellow-500/10 rounded-lg border-l-2 border-yellow-500"
>
<div class="flex-1 min-w-0">
<p class="text-xs text-yellow-400 font-medium">Editing message</p>
<p class="text-sm text-light/60 truncate">{editingMessage.content}</p>
</div>
<button
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light rounded transition-colors"
onclick={() => {
onCancelEdit?.();
message = "";
}}
title="Cancel edit"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
{/if}
<!-- Reply preview -->
{#if replyTo && !editingMessage}
<div class="px-4 pt-3 pb-0">
<div
class="flex items-center gap-2 px-3 py-2 bg-light/5 rounded-lg border-l-2 border-primary"
>
<div class="flex-1 min-w-0">
<p class="text-xs text-primary font-medium">
Replying to {replyTo.senderName}
</p>
<p class="text-sm text-light/60 truncate">{replyTo.content}</p>
</div>
<button
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light rounded transition-colors"
onclick={() => onCancelReply?.()}
title="Cancel reply"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
{/if}
<div class="p-4 flex items-end gap-3">
<!-- Hidden file input -->
<input
bind:this={fileInputRef}
type="file"
class="hidden"
onchange={handleFileSelect}
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.txt,.zip"
/>
<!-- Attachment button -->
<button
class="w-10 h-10 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded-full transition-colors shrink-0"
class:animate-pulse={isUploading}
title="Add attachment"
onclick={openFilePicker}
disabled={disabled || isUploading}
>
{#if isUploading}
<svg class="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
{:else}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="16" />
<line x1="8" y1="12" x2="16" y2="12" />
</svg>
{/if}
</button>
<!-- Input area -->
<div class="flex-1 relative">
<!-- Mention autocomplete -->
{#if showMentions}
<MentionAutocomplete
bind:this={autocompleteRef}
members={roomMembers}
query={mentionQuery}
onSelect={handleMentionSelect}
onClose={() => (showMentions = false)}
/>
{/if}
<!-- Emoji autocomplete -->
{#if showEmojiAutocomplete}
<EmojiAutocomplete
bind:this={emojiAutocompleteRef}
query={emojiQuery}
onSelect={handleEmojiSelect}
onClose={() => (showEmojiAutocomplete = false)}
/>
{/if}
<!-- Input wrapper with emoji button inside -->
<div class="relative flex items-end">
<!-- Emoji preview overlay - shows rendered Twemoji -->
{#if message && hasEmoji(message)}
<div
class="absolute inset-0 pl-4 pr-12 py-3 pointer-events-none overflow-hidden rounded-2xl text-light whitespace-pre-wrap break-words"
style="min-height: 48px; max-height: 200px; line-height: 1.5;"
aria-hidden="true"
>
{@html renderEmojiPreview(message)}
</div>
{/if}
<textarea
bind:this={inputRef}
bind:value={message}
oninput={handleInput}
onkeydown={handleKeyDown}
{placeholder}
disabled={disabled || isSending}
rows="1"
class="w-full pl-4 pr-12 py-3 bg-dark rounded-2xl border border-light/20
placeholder:text-light/40 resize-none overflow-hidden
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors {message && hasEmoji(message)
? 'text-transparent caret-light'
: 'text-light'}"
style="min-height: 48px; max-height: 200px;"
></textarea>
<!-- Emoji button inside input -->
<button
bind:this={emojiButtonRef}
type="button"
class="absolute right-3 bottom-3 w-6 h-6 flex items-center justify-center text-light/40 hover:text-light transition-colors"
onclick={() => (showEmojiPicker = !showEmojiPicker)}
title="Add emoji"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</svg>
</button>
</div>
<!-- Emoji Picker -->
{#if showEmojiPicker}
<div class="absolute bottom-full right-0 mb-2">
<EmojiPicker
onSelect={(emoji) => {
message += emoji;
inputRef?.focus();
}}
onClose={() => (showEmojiPicker = false)}
position={{ x: 0, y: 0 }}
/>
</div>
{/if}
</div>
<!-- Send button -->
<button
class="w-10 h-10 flex items-center justify-center rounded-full transition-all shrink-0
{message.trim()
? 'bg-primary text-white hover:brightness-110'
: 'bg-light/10 text-light/30 cursor-not-allowed'}"
onclick={handleSend}
disabled={!message.trim() || isSending || disabled}
title="Send message"
>
{#if isSending}
<svg class="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
{:else}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
{/if}
</button>
</div>
<!-- Character count (optional, show when > 1000) -->
{#if message.length > 1000}
<div
class="text-right text-xs mt-1 {message.length > 4000
? 'text-red-400'
: 'text-light/40'}"
>
{message.length} / 4000
</div>
{/if}
</div>

View File

@@ -0,0 +1,478 @@
<script lang="ts">
import { onMount, tick, untrack } from "svelte";
import { createVirtualizer, elementScroll } from "@tanstack/svelte-virtual";
import type { SvelteVirtualizer } from "@tanstack/svelte-virtual";
import { MessageContainer } from "$lib/components/message";
import type { Message as MessageType } from "$lib/matrix/types";
import { auth } from "$lib/stores/matrix";
interface Props {
messages: MessageType[];
onReact?: (messageId: string, emoji: string) => void;
onToggleReaction?: (
messageId: string,
emoji: string,
reactionEventId: string | null,
) => void;
onEdit?: (message: MessageType) => void;
onDelete?: (messageId: string) => void;
onReply?: (message: MessageType) => void;
onLoadMore?: () => void;
isLoading?: boolean;
enableVirtualization?: boolean;
}
let {
messages,
onReact,
onToggleReaction,
onEdit,
onDelete,
onReply,
onLoadMore,
isLoading = false,
enableVirtualization = false, // Disabled until we find a Svelte 5-compatible solution
}: Props = $props();
let containerRef: HTMLDivElement | undefined = $state();
let shouldAutoScroll = $state(true);
let previousMessageCount = $state(0);
// Filter out deleted/redacted messages (hide them like Discord)
const allVisibleMessages = $derived(messages.filter((m) => !m.isRedacted));
// Virtualizer state - managed via subscription
let virtualizer = $state<SvelteVirtualizer<HTMLDivElement, Element> | null>(
null,
);
let virtualizerCleanup: (() => void) | null = null;
// Estimate size based on message type
function estimateSize(index: number): number {
const msg = allVisibleMessages[index];
if (!msg) return 80;
if (msg.type === "image") return 300;
if (msg.type === "video") return 350;
if (msg.type === "file" || msg.type === "audio") return 100;
const lines = Math.ceil((msg.content?.length || 0) / 60);
return Math.max(60, Math.min(lines * 24 + 40, 400));
}
// Create/update virtualizer when container or messages change
$effect(() => {
if (
!containerRef ||
!enableVirtualization ||
allVisibleMessages.length === 0
) {
virtualizer = null;
return;
}
// Clean up previous subscription
if (virtualizerCleanup) {
virtualizerCleanup();
virtualizerCleanup = null;
}
// Create new virtualizer store
const store = createVirtualizer({
count: allVisibleMessages.length,
getScrollElement: () => containerRef!,
estimateSize,
overscan: 5,
getItemKey: (index) => allVisibleMessages[index]?.eventId ?? index,
scrollToFn: elementScroll,
});
// Subscribe to store updates
virtualizerCleanup = store.subscribe((v) => {
virtualizer = v;
});
// Cleanup on effect re-run or component destroy
return () => {
if (virtualizerCleanup) {
virtualizerCleanup();
virtualizerCleanup = null;
}
};
});
// Get virtual items for rendering (reactive to virtualizer changes)
const virtualItems = $derived(virtualizer?.getVirtualItems() ?? []);
const totalSize = $derived(virtualizer?.getTotalSize() ?? 0);
/**
* Svelte action for dynamic height measurement
* Re-measures when images/media finish loading
*/
function measureRow(node: HTMLElement, index: number) {
function measure() {
if (virtualizer) {
virtualizer.measureElement(node);
}
}
// Initial measurement
measure();
// Re-measure when images load
const images = node.querySelectorAll("img");
const imageHandlers: Array<() => void> = [];
images.forEach((img) => {
if (!img.complete) {
const handler = () => measure();
img.addEventListener("load", handler, { once: true });
img.addEventListener("error", handler, { once: true });
imageHandlers.push(() => {
img.removeEventListener("load", handler);
img.removeEventListener("error", handler);
});
}
});
// Re-measure when videos load metadata
const videos = node.querySelectorAll("video");
const videoHandlers: Array<() => void> = [];
videos.forEach((video) => {
if (video.readyState < 1) {
const handler = () => measure();
video.addEventListener("loadedmetadata", handler, { once: true });
videoHandlers.push(() =>
video.removeEventListener("loadedmetadata", handler),
);
}
});
return {
update(newIndex: number) {
// Re-measure on update
measure();
},
destroy() {
// Cleanup listeners
imageHandlers.forEach((cleanup) => cleanup());
videoHandlers.forEach((cleanup) => cleanup());
},
};
}
// Track if we're currently loading to prevent scroll jumps
let isLoadingMore = $state(false);
let scrollTopBeforeLoad = $state(0);
let scrollHeightBeforeLoad = $state(0);
// Check if we should auto-scroll and load more
function handleScroll() {
if (!containerRef) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef;
// Check if at bottom for auto-scroll
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
shouldAutoScroll = distanceToBottom < 100;
// Check if at top to load more messages (with debounce via isLoadingMore)
if (scrollTop < 100 && onLoadMore && !isLoading && !isLoadingMore) {
// Save scroll position before loading
isLoadingMore = true;
scrollTopBeforeLoad = scrollTop;
scrollHeightBeforeLoad = scrollHeight;
onLoadMore();
}
}
// Restore scroll position after loading older messages
$effect(() => {
if (!isLoading && isLoadingMore && containerRef) {
// Loading finished - restore scroll position
tick().then(() => {
if (containerRef) {
const newScrollHeight = containerRef.scrollHeight;
const addedHeight = newScrollHeight - scrollHeightBeforeLoad;
// Adjust scroll to maintain visual position
containerRef.scrollTop = scrollTopBeforeLoad + addedHeight;
}
isLoadingMore = false;
});
}
});
// Scroll to bottom
async function scrollToBottom(force = false) {
if (!containerRef) return;
if (force || shouldAutoScroll) {
await tick();
containerRef.scrollTop = containerRef.scrollHeight;
}
}
// Auto-scroll when new messages arrive (only if at bottom)
$effect(() => {
const count = allVisibleMessages.length;
if (count > previousMessageCount) {
if (shouldAutoScroll || previousMessageCount === 0) {
// User is at bottom or first load - scroll to new messages
scrollToBottom(true);
}
// If user is scrolled up, scroll anchoring handles it
}
previousMessageCount = count;
});
// Initial scroll to bottom
onMount(() => {
tick().then(() => {
scrollToBottom(true);
});
});
// Check if message should be grouped with previous
function shouldGroup(
current: MessageType,
previous: MessageType | null,
): boolean {
if (!previous) return false;
if (current.sender !== previous.sender) return false;
// Group if within 5 minutes
const timeDiff = current.timestamp - previous.timestamp;
return timeDiff < 5 * 60 * 1000;
}
// Check if we need a date separator
function needsDateSeparator(
current: MessageType,
previous: MessageType | null,
): boolean {
if (!previous) return true;
const currentDate = new Date(current.timestamp).toDateString();
const previousDate = new Date(previous.timestamp).toDateString();
return currentDate !== previousDate;
}
function formatDateSeparator(timestamp: number): string {
const date = new Date(timestamp);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return "Today";
} else if (date.toDateString() === yesterday.toDateString()) {
return "Yesterday";
} else {
return date.toLocaleDateString([], {
weekday: "long",
month: "long",
day: "numeric",
year:
date.getFullYear() !== today.getFullYear() ? "numeric" : undefined,
});
}
}
// Get reply preview for a message
function getReplyPreview(replyToId: string): {
senderName: string;
content: string;
senderAvatar: string | null;
hasAttachment: boolean;
} | null {
const replyMessage = messages.find((m) => m.eventId === replyToId);
if (!replyMessage) return null;
const hasAttachment = ["image", "video", "audio", "file"].includes(
replyMessage.type,
);
let content = replyMessage.content;
if (hasAttachment && !content) {
content =
replyMessage.type === "image"
? "Click to see attachment"
: replyMessage.type === "video"
? "Video"
: replyMessage.type === "audio"
? "Audio"
: "File";
}
return {
senderName: replyMessage.senderName,
senderAvatar: replyMessage.senderAvatar,
content: content.slice(0, 50) + (content.length > 50 ? "..." : ""),
hasAttachment,
};
}
// Scroll to a specific message
function scrollToMessage(eventId: string) {
const element = document.getElementById(`message-${eventId}`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center" });
// Highlight briefly
element.classList.add("bg-primary/20");
setTimeout(() => element.classList.remove("bg-primary/20"), 2000);
}
}
</script>
<div class="relative flex-1 min-h-0">
<div
bind:this={containerRef}
class="h-full overflow-y-auto bg-night"
onscroll={handleScroll}
>
<!-- Load more button -->
{#if onLoadMore}
<div class="flex justify-center py-4">
<button
class="text-sm text-primary hover:underline disabled:opacity-50"
onclick={() => onLoadMore?.()}
disabled={isLoading}
>
{isLoading ? "Loading..." : "Load older messages"}
</button>
</div>
{/if}
<!-- Messages -->
{#if allVisibleMessages.length === 0}
<div
class="flex flex-col items-center justify-center h-full text-light/40"
>
<svg
class="w-16 h-16 mb-4 opacity-50"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
/>
</svg>
<p class="text-lg">No messages yet</p>
<p class="text-sm">Be the first to send a message!</p>
</div>
{:else if virtualizer && enableVirtualization}
<!-- TanStack Virtual: True DOM recycling -->
<div class="relative w-full" style="height: {totalSize}px;">
{#each virtualItems as virtualRow (virtualRow.key)}
{@const message = allVisibleMessages[virtualRow.index]}
{@const previousMessage =
virtualRow.index > 0
? allVisibleMessages[virtualRow.index - 1]
: null}
{@const isGrouped = shouldGroup(message, previousMessage)}
{@const showDateSeparator = needsDateSeparator(
message,
previousMessage,
)}
<div
class="absolute top-0 left-0 w-full"
style="transform: translateY({virtualRow.start}px);"
data-index={virtualRow.index}
use:measureRow={virtualRow.index}
>
<!-- Date separator -->
{#if showDateSeparator}
<div class="flex items-center gap-4 px-4 py-2 my-2">
<div class="flex-1 h-px bg-light/10"></div>
<span class="text-xs text-light/40 font-medium">
{formatDateSeparator(message.timestamp)}
</span>
<div class="flex-1 h-px bg-light/10"></div>
</div>
{/if}
<MessageContainer
{message}
{isGrouped}
isOwnMessage={message.sender === $auth.userId}
currentUserId={$auth.userId || ""}
onReact={(emoji: string) => onReact?.(message.eventId, emoji)}
onToggleReaction={(
emoji: string,
reactionEventId: string | null,
) => onToggleReaction?.(message.eventId, emoji, reactionEventId)}
onEdit={() => onEdit?.(message)}
onDelete={() => onDelete?.(message.eventId)}
onReply={() => onReply?.(message)}
onScrollToMessage={scrollToMessage}
replyPreview={message.replyTo
? getReplyPreview(message.replyTo)
: null}
/>
</div>
{/each}
</div>
{:else}
<!-- Fallback: Non-virtualized rendering for small lists -->
<div class="py-4">
{#each allVisibleMessages as message, i (message.eventId)}
{@const previousMessage = i > 0 ? allVisibleMessages[i - 1] : null}
{@const isGrouped = shouldGroup(message, previousMessage)}
{@const showDateSeparator = needsDateSeparator(
message,
previousMessage,
)}
<!-- Date separator -->
{#if showDateSeparator}
<div class="flex items-center gap-4 px-4 py-2 my-2">
<div class="flex-1 h-px bg-light/10"></div>
<span class="text-xs text-light/40 font-medium">
{formatDateSeparator(message.timestamp)}
</span>
<div class="flex-1 h-px bg-light/10"></div>
</div>
{/if}
<MessageContainer
{message}
{isGrouped}
isOwnMessage={message.sender === $auth.userId}
currentUserId={$auth.userId || ""}
onReact={(emoji: string) => onReact?.(message.eventId, emoji)}
onToggleReaction={(emoji: string, reactionEventId: string | null) =>
onToggleReaction?.(message.eventId, emoji, reactionEventId)}
onEdit={() => onEdit?.(message)}
onDelete={() => onDelete?.(message.eventId)}
onReply={() => onReply?.(message)}
onScrollToMessage={scrollToMessage}
replyPreview={message.replyTo
? getReplyPreview(message.replyTo)
: null}
/>
{/each}
</div>
{/if}
</div>
<!-- Scroll to bottom button -->
{#if !shouldAutoScroll && allVisibleMessages.length > 0}
<button
class="absolute bottom-4 right-4 p-3 bg-primary text-white rounded-full shadow-lg
hover:bg-primary/90 transition-all transform hover:scale-105
animate-in fade-in slide-in-from-bottom-2 duration-200"
onclick={() => scrollToBottom(true)}
title="Scroll to bottom"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6,9 12,15 18,9" />
</svg>
</button>
{/if}
</div>

View File

@@ -0,0 +1,261 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import RoomSettingsModal from "./RoomSettingsModal.svelte";
import {
getRoomNotificationLevel,
setRoomNotificationLevel,
} from "$lib/matrix";
import { toasts } from "$lib/stores/ui";
import type { RoomSummary, RoomMember } from "$lib/matrix/types";
interface Props {
room: RoomSummary;
members: RoomMember[];
onClose: () => void;
}
let { room, members, onClose }: Props = $props();
let showSettings = $state(false);
let isMuted = $state(getRoomNotificationLevel(room.roomId) === "mute");
let isTogglingMute = $state(false);
// Group members by role
const admins = $derived(members.filter((m) => m.powerLevel >= 100));
const moderators = $derived(
members.filter((m) => m.powerLevel >= 50 && m.powerLevel < 100),
);
const regularMembers = $derived(members.filter((m) => m.powerLevel < 50));
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
}
async function toggleMute() {
isTogglingMute = true;
try {
const newLevel = isMuted ? "all" : "mute";
await setRoomNotificationLevel(room.roomId, newLevel);
isMuted = !isMuted;
toasts.success(isMuted ? "Room muted" : "Room unmuted");
} catch (e) {
toasts.error("Failed to change notification settings");
} finally {
isTogglingMute = false;
}
}
</script>
<div class="h-full flex flex-col bg-dark/50">
<!-- Header -->
<div class="p-4 border-b border-light/10 flex items-center justify-between">
<h2 class="font-semibold text-light">Room Info</h2>
<button
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded transition-colors"
onclick={onClose}
title="Close"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-4 space-y-6">
<!-- Room Avatar & Name -->
<div class="text-center">
<div class="flex justify-center mb-3">
<Avatar src={room.avatarUrl} name={room.name} size="xl" />
</div>
<h3 class="text-xl font-bold text-light">{room.name}</h3>
{#if room.topic}
<p class="text-sm text-light/60 mt-2">{room.topic}</p>
{/if}
<button
class="mt-3 px-4 py-1.5 text-sm text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
onclick={() => (showSettings = true)}
>
<span class="inline-flex items-center gap-1">
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
Edit Settings
</span>
</button>
<button
class="mt-2 px-4 py-1.5 text-sm rounded-lg transition-colors {isMuted
? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
: 'text-light/60 hover:text-light hover:bg-light/10'}"
onclick={toggleMute}
disabled={isTogglingMute}
>
<span class="inline-flex items-center gap-1">
{#if isMuted}
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 5L6 9H2v6h4l5 4V5z" />
<line x1="23" y1="9" x2="17" y2="15" />
<line x1="17" y1="9" x2="23" y2="15" />
</svg>
Muted
{:else}
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path
d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"
/>
</svg>
Notifications On
{/if}
</span>
</button>
</div>
<!-- Room Stats -->
<div class="grid grid-cols-2 gap-3">
<div class="bg-night rounded-lg p-3 text-center">
<p class="text-2xl font-bold text-light">{room.memberCount}</p>
<p class="text-xs text-light/50">Members</p>
</div>
<div class="bg-night rounded-lg p-3 text-center">
<p class="text-2xl font-bold text-light">
{room.isEncrypted ? "🔒" : "🔓"}
</p>
<p class="text-xs text-light/50">
{room.isEncrypted ? "Encrypted" : "Not Encrypted"}
</p>
</div>
</div>
<!-- Room Details -->
<div class="space-y-3">
<h4 class="text-sm font-semibold text-light/40 uppercase tracking-wider">
Details
</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-light/50">Room ID</span>
<span
class="text-light font-mono text-xs truncate max-w-[150px]"
title={room.roomId}
>
{room.roomId}
</span>
</div>
<div class="flex justify-between">
<span class="text-light/50">Type</span>
<span class="text-light"
>{room.isDirect ? "Direct Message" : "Room"}</span
>
</div>
{#if room.lastActivity}
<div class="flex justify-between">
<span class="text-light/50">Last Activity</span>
<span class="text-light">{formatDate(room.lastActivity)}</span>
</div>
{/if}
</div>
</div>
<!-- Members by Role -->
{#if admins.length > 0}
<div class="space-y-2">
<h4
class="text-sm font-semibold text-light/40 uppercase tracking-wider"
>
Admins ({admins.length})
</h4>
<ul class="space-y-1">
{#each admins as member}
<li
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-light/5"
>
<Avatar src={member.avatarUrl} name={member.name} size="xs" />
<span class="text-sm text-light truncate">{member.name}</span>
<span class="ml-auto text-xs text-yellow-400">👑</span>
</li>
{/each}
</ul>
</div>
{/if}
{#if moderators.length > 0}
<div class="space-y-2">
<h4
class="text-sm font-semibold text-light/40 uppercase tracking-wider"
>
Moderators ({moderators.length})
</h4>
<ul class="space-y-1">
{#each moderators as member}
<li
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-light/5"
>
<Avatar src={member.avatarUrl} name={member.name} size="xs" />
<span class="text-sm text-light truncate">{member.name}</span>
<span class="ml-auto text-xs text-blue-400">🛡️</span>
</li>
{/each}
</ul>
</div>
{/if}
<div class="space-y-2">
<h4 class="text-sm font-semibold text-light/40 uppercase tracking-wider">
Members ({regularMembers.length})
</h4>
<ul class="space-y-1">
{#each regularMembers.slice(0, 20) as member}
<li
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-light/5"
>
<Avatar src={member.avatarUrl} name={member.name} size="xs" />
<span class="text-sm text-light truncate">{member.name}</span>
</li>
{/each}
{#if regularMembers.length > 20}
<li class="text-xs text-light/40 text-center py-2">
+{regularMembers.length - 20} more members
</li>
{/if}
</ul>
</div>
</div>
</div>
{#if showSettings}
<RoomSettingsModal {room} onClose={() => (showSettings = false)} />
{/if}

View File

@@ -0,0 +1,187 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import { setRoomName, setRoomTopic, setRoomAvatar } from "$lib/matrix";
import { toasts } from "$lib/stores/ui";
import { syncRoomsFromEvent } from "$lib/stores/matrix";
import type { RoomSummary } from "$lib/matrix/types";
interface Props {
room: RoomSummary;
onClose: () => void;
}
let { room, onClose }: Props = $props();
let name = $state(room.name);
let topic = $state(room.topic || "");
let isSaving = $state(false);
let avatarFile = $state<File | null>(null);
let avatarPreview = $state<string | null>(null);
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
function handleAvatarChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
avatarFile = file;
avatarPreview = URL.createObjectURL(file);
}
}
async function handleSave() {
isSaving = true;
try {
const promises: Promise<void>[] = [];
if (name !== room.name) {
promises.push(setRoomName(room.roomId, name));
}
if (topic !== (room.topic || "")) {
promises.push(setRoomTopic(room.roomId, topic));
}
if (avatarFile) {
promises.push(setRoomAvatar(room.roomId, avatarFile));
}
await Promise.all(promises);
syncRoomsFromEvent("update", room.roomId);
toasts.success("Room settings updated");
onClose();
} catch (e) {
console.error("Failed to update room settings:", e);
toasts.error("Failed to update room settings");
} finally {
isSaving = false;
}
}
const hasChanges = $derived(
name !== room.name || topic !== (room.topic || "") || avatarFile !== null,
);
</script>
<svelte:window onkeydown={handleKeydown} />
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
role="dialog"
aria-modal="true"
aria-labelledby="settings-title"
tabindex="-1"
>
<div
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
role="document"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<div class="flex items-center justify-between mb-6">
<h2 id="settings-title" class="text-xl font-bold text-light">
Room Settings
</h2>
<button
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded transition-colors"
onclick={onClose}
title="Close"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- Avatar -->
<div class="flex flex-col items-center mb-6">
<div class="relative group">
<Avatar src={avatarPreview || room.avatarUrl} {name} size="xl" />
<label
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 rounded-full cursor-pointer transition-opacity"
>
<svg
class="w-6 h-6 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"
/>
<circle cx="12" cy="13" r="4" />
</svg>
<input
type="file"
accept="image/*"
class="hidden"
onchange={handleAvatarChange}
/>
</label>
</div>
<p class="text-xs text-light/40 mt-2">Click to change avatar</p>
</div>
<!-- Name -->
<div class="mb-4">
<label
for="room-name"
class="block text-sm font-medium text-light/60 mb-1"
>
Room Name
</label>
<input
id="room-name"
type="text"
bind:value={name}
class="w-full px-4 py-2 bg-night text-light rounded-lg border border-light/20 placeholder:text-light/40 focus:outline-none focus:border-primary"
placeholder="Enter room name"
/>
</div>
<!-- Topic -->
<div class="mb-6">
<label
for="room-topic"
class="block text-sm font-medium text-light/60 mb-1"
>
Topic
</label>
<textarea
id="room-topic"
bind:value={topic}
rows="3"
class="w-full px-4 py-2 bg-night text-light rounded-lg border border-light/20 placeholder:text-light/40 focus:outline-none focus:border-primary resize-none"
placeholder="What's this room about?"
></textarea>
</div>
<!-- Actions -->
<div class="flex gap-3">
<button
class="flex-1 px-4 py-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
onclick={onClose}
disabled={isSaving}
>
Cancel
</button>
<button
class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={handleSave}
disabled={isSaving || !hasChanges}
>
{isSaving ? "Saving..." : "Save Changes"}
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,139 @@
<script lang="ts">
import { Avatar } from '$lib/components/ui';
import { searchUsers, createDirectMessage } from '$lib/matrix';
import { toasts } from '$lib/stores/ui';
interface Props {
onClose: () => void;
onDMCreated: (roomId: string) => void;
}
let { onClose, onDMCreated }: Props = $props();
let searchQuery = $state('');
let searchResults = $state<Array<{ userId: string; displayName: string; avatarUrl: string | null }>>([]);
let isSearching = $state(false);
let isCreating = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
function handleSearch() {
if (searchTimeout) clearTimeout(searchTimeout);
if (!searchQuery.trim()) {
searchResults = [];
return;
}
searchTimeout = setTimeout(async () => {
isSearching = true;
try {
searchResults = await searchUsers(searchQuery);
} catch (e) {
console.error('Search failed:', e);
} finally {
isSearching = false;
}
}, 300);
}
async function handleStartDM(userId: string) {
isCreating = true;
try {
const roomId = await createDirectMessage(userId);
toasts.success('Direct message started!');
onDMCreated(roomId);
onClose();
} catch (e: any) {
console.error('Failed to create DM:', e);
toasts.error(e.message || 'Failed to start direct message');
} finally {
isCreating = false;
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeyDown} />
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onclick={onClose}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
onclick={(e) => e.stopPropagation()}
role="document"
>
<h2 class="text-xl font-bold text-light mb-4">Start a Direct Message</h2>
<div class="mb-4">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-light/40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
bind:value={searchQuery}
oninput={handleSearch}
placeholder="Search users by name or @user:server"
class="w-full pl-9 pr-4 py-3 bg-night text-light rounded-lg border border-light/20 placeholder:text-light/40 focus:outline-none focus:border-primary"
autofocus
/>
</div>
</div>
<div class="max-h-64 overflow-y-auto">
{#if isSearching}
<div class="text-center py-8 text-light/40">
<svg class="w-6 h-6 animate-spin mx-auto mb-2" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Searching...
</div>
{:else if searchResults.length > 0}
<ul class="space-y-1">
{#each searchResults as user}
<li>
<button
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-light/5 transition-colors text-left disabled:opacity-50"
onclick={() => handleStartDM(user.userId)}
disabled={isCreating}
>
<Avatar src={user.avatarUrl} name={user.displayName} size="sm" />
<div class="flex-1 min-w-0">
<p class="text-light font-medium truncate">{user.displayName}</p>
<p class="text-xs text-light/40 truncate">{user.userId}</p>
</div>
</button>
</li>
{/each}
</ul>
{:else if searchQuery}
<p class="text-center py-8 text-light/40">No users found</p>
{:else}
<p class="text-center py-8 text-light/40">
Search for a user to start a conversation
</p>
{/if}
</div>
<div class="flex justify-end gap-3 mt-6">
<button
class="px-4 py-2 text-light/60 hover:text-light transition-colors"
onclick={onClose}
>
Cancel
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { syncState, syncError, clearState } from "$lib/stores/matrix";
import { clearAllCache } from "$lib/cache";
interface Props {
onHardRefresh?: () => void;
}
let { onHardRefresh }: Props = $props();
let isRefreshing = $state(false);
let dismissed = $state(false);
let consecutiveErrors = $state(0);
// Track consecutive sync errors
$effect(() => {
if ($syncState === "ERROR") {
consecutiveErrors++;
} else if ($syncState === "SYNCING" || $syncState === "PREPARED") {
consecutiveErrors = 0;
dismissed = false;
}
});
// Show banner after 3+ consecutive errors
const shouldShow = $derived(
!dismissed && consecutiveErrors >= 3 && $syncState === "ERROR",
);
async function handleHardRefresh() {
isRefreshing = true;
try {
// Clear local cache
await clearAllCache();
// Clear in-memory state
clearState();
// Trigger callback for full re-sync
onHardRefresh?.();
// Reload the page for clean state
window.location.reload();
} catch (error) {
console.error("[SyncRecovery] Hard refresh failed:", error);
isRefreshing = false;
}
}
function handleDismiss() {
dismissed = true;
}
</script>
{#if shouldShow}
<div
class="fixed top-4 left-1/2 -translate-x-1/2 z-50 max-w-md w-full mx-4
bg-red-900/90 backdrop-blur-sm border border-red-500/50
rounded-lg shadow-xl p-4 animate-in slide-in-from-top duration-300"
role="alert"
>
<div class="flex items-start gap-3">
<span
class="material-symbols-rounded text-red-400 flex-shrink-0 mt-0.5"
style="font-size: 20px;">warning</span
>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-red-100">Sync Connection Lost</h3>
<p class="text-sm text-red-200/80 mt-1">
{$syncError ||
"Unable to sync with the server. Your messages may be outdated."}
</p>
<div class="flex items-center gap-2 mt-3">
<button
class="flex items-center gap-2 px-3 py-1.5 bg-red-600 hover:bg-red-500
text-white text-sm font-medium rounded-md transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
onclick={handleHardRefresh}
disabled={isRefreshing}
>
<span
class="material-symbols-rounded {isRefreshing
? 'animate-spin'
: ''}"
style="font-size: 16px;">refresh</span
>
{isRefreshing ? "Refreshing..." : "Hard Refresh"}
</button>
<button
class="px-3 py-1.5 text-red-200 hover:text-white text-sm transition-colors"
onclick={handleDismiss}
>
Dismiss
</button>
</div>
</div>
<button
class="text-red-400 hover:text-red-200 transition-colors"
onclick={handleDismiss}
aria-label="Close"
>
<span class="material-symbols-rounded" style="font-size: 20px;"
>close</span
>
</button>
</div>
</div>
{/if}

View File

@@ -0,0 +1,27 @@
<script lang="ts">
interface Props {
userNames: string[];
}
let { userNames }: Props = $props();
function formatTypingText(names: string[]): string {
if (names.length === 0) return '';
if (names.length === 1) return `${names[0]} is typing`;
if (names.length === 2) return `${names[0]} and ${names[1]} are typing`;
if (names.length === 3) return `${names[0]}, ${names[1]}, and ${names[2]} are typing`;
return `${names[0]}, ${names[1]}, and ${names.length - 2} others are typing`;
}
</script>
{#if userNames.length > 0}
<div class="flex items-center gap-2 px-4 py-2 text-sm text-light/50">
<!-- Animated dots -->
<div class="flex gap-1">
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 0ms"></span>
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 150ms"></span>
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 300ms"></span>
</div>
<span>{formatTypingText(userNames)}</span>
</div>
{/if}

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { Avatar } from '$lib/components/ui';
import { createDirectMessage } from '$lib/matrix';
import { userPresence } from '$lib/stores/matrix';
import { toasts } from '$lib/stores/ui';
import type { RoomMember } from '$lib/matrix/types';
interface Props {
member: RoomMember;
onClose: () => void;
onStartDM?: (roomId: string) => void;
}
let { member, onClose, onStartDM }: Props = $props();
let isStartingDM = $state(false);
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
const presence = $derived($userPresence.get(member.userId) || 'offline');
const presenceLabel = $derived({
online: { text: 'Online', color: 'text-green-400' },
offline: { text: 'Offline', color: 'text-gray-400' },
unavailable: { text: 'Away', color: 'text-yellow-400' },
}[presence]);
async function handleStartDM() {
isStartingDM = true;
try {
const roomId = await createDirectMessage(member.userId);
toasts.success(`Started DM with ${member.name}`);
onStartDM?.(roomId);
onClose();
} catch (e) {
console.error('Failed to start DM:', e);
toasts.error('Failed to start direct message');
} finally {
isStartingDM = false;
}
}
function getRoleBadge(powerLevel: number): { label: string; color: string; icon: string } | null {
if (powerLevel >= 100) return { label: 'Admin', color: 'bg-red-500/20 text-red-400', icon: '👑' };
if (powerLevel >= 50) return { label: 'Moderator', color: 'bg-blue-500/20 text-blue-400', icon: '🛡️' };
return null;
}
const roleBadge = $derived(getRoleBadge(member.powerLevel));
</script>
<svelte:window onkeydown={handleKeydown} />
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
role="dialog"
aria-modal="true"
aria-labelledby="profile-title"
tabindex="-1"
onclick={onClose}
onkeydown={(e) => e.key === 'Enter' && onClose()}
>
<div
class="bg-dark rounded-2xl w-full max-w-sm mx-4 overflow-hidden"
role="document"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<!-- Header with gradient -->
<div class="h-24 bg-gradient-to-br from-primary/50 to-primary/20 relative">
<button
class="absolute top-3 right-3 w-8 h-8 flex items-center justify-center text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors"
onclick={onClose}
title="Close"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- Avatar -->
<div class="flex justify-center -mt-12 relative z-10">
<div class="ring-4 ring-dark rounded-full">
<Avatar src={member.avatarUrl} name={member.name} size="xl" status={presence === 'online' ? 'online' : presence === 'unavailable' ? 'away' : 'offline'} />
</div>
</div>
<!-- Content -->
<div class="p-6 pt-3 text-center">
<h2 id="profile-title" class="text-xl font-bold text-light">{member.name}</h2>
<p class="text-sm text-light/50 mt-1">{member.userId}</p>
<!-- Status -->
<div class="flex items-center justify-center gap-2 mt-3">
<span class="w-2 h-2 rounded-full {presence === 'online' ? 'bg-green-400' : presence === 'unavailable' ? 'bg-yellow-400' : 'bg-gray-400'}"></span>
<span class="text-sm {presenceLabel.color}">{presenceLabel.text}</span>
</div>
<!-- Role badge -->
{#if roleBadge}
<div class="mt-3">
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs {roleBadge.color}">
{roleBadge.icon} {roleBadge.label}
</span>
</div>
{/if}
<!-- Actions -->
<div class="mt-6 space-y-2">
<button
class="w-full px-4 py-2.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
onclick={handleStartDM}
disabled={isStartingDM}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
{isStartingDM ? 'Starting...' : 'Send Message'}
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,12 @@
export { default as MessageList } from './MessageList.svelte';
export { default as MessageInput } from './MessageInput.svelte';
export { default as TypingIndicator } from './TypingIndicator.svelte';
export { default as CreateRoomModal } from './CreateRoomModal.svelte';
export { default as CreateSpaceModal } from './CreateSpaceModal.svelte';
export { default as MemberList } from './MemberList.svelte';
export { default as StartDMModal } from './StartDMModal.svelte';
export { default as RoomInfoPanel } from './RoomInfoPanel.svelte';
export { default as RoomSettingsModal } from './RoomSettingsModal.svelte';
export { default as UserProfileModal } from './UserProfileModal.svelte';
export { default as MatrixProvider } from './MatrixProvider.svelte';
export { default as SyncRecoveryBanner } from './SyncRecoveryBanner.svelte';

View File

@@ -0,0 +1,202 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import { getReadReceiptsForEvent } from "$lib/matrix";
import type { Message } from "$lib/matrix/types";
import { formatTime } from "./utils";
import {
MessageContent,
MessageMedia,
MessageReactions,
MessageActions,
MessageReadReceipts,
} from "./parts";
interface ReplyPreview {
senderName: string;
content: string;
senderAvatar: string | null;
hasAttachment: boolean;
}
interface Props {
message: Message;
isGrouped?: boolean;
isOwnMessage?: boolean;
isPinned?: boolean;
currentUserId?: string;
replyPreview?: ReplyPreview | null;
onReact?: (emoji: string) => void;
onToggleReaction?: (emoji: string, reactionEventId: string | null) => void;
onEdit?: () => void;
onDelete?: () => void;
onReply?: () => void;
onPin?: () => void;
onScrollToMessage?: (eventId: string) => void;
}
let {
message,
isGrouped = false,
isOwnMessage = false,
isPinned = false,
currentUserId = "",
replyPreview = null,
onReact,
onToggleReaction,
onEdit,
onDelete,
onReply,
onPin,
onScrollToMessage,
}: Props = $props();
let showActions = $state(false);
// Get read receipts for own messages
const readReceipts = $derived(
isOwnMessage
? getReadReceiptsForEvent(message.roomId, message.eventId)
: [],
);
// Check if message has media
const hasMedia = $derived(
["image", "video", "audio", "file"].includes(message.type) && message.media,
);
</script>
<div
class="group relative px-4 py-0.5 hover:bg-light/5 transition-colors {message.isPending
? 'opacity-50'
: ''}"
onmouseenter={() => (showActions = true)}
onmouseleave={() => (showActions = false)}
role="article"
id="message-{message.eventId}"
>
<!-- Reply preview -->
{#if replyPreview && message.replyTo}
<button
class="flex items-center gap-1.5 ml-14 mt-1 text-xs hover:opacity-80 transition-opacity cursor-pointer"
onclick={() => onScrollToMessage?.(message.replyTo!)}
>
<div class="flex items-center gap-1.5">
<div class="flex shrink-0">
<Avatar
src={replyPreview.senderAvatar}
name={replyPreview.senderName}
size="xs"
/>
</div>
<span class="text-light/70 font-medium">{replyPreview.senderName}</span>
</div>
<span class="text-light/50 truncate max-w-xs">
{#if replyPreview.hasAttachment}
<svg
class="w-3 h-3 inline mr-0.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21,15 16,10 5,21" />
</svg>
{/if}
{replyPreview.content}
</span>
</button>
{/if}
{#if isGrouped}
<!-- Grouped message (same sender, close in time) -->
<div class="flex gap-4">
<div class="w-10 shrink-0 flex items-center justify-center">
<span
class="text-[10px] text-light/30 opacity-0 group-hover:opacity-100 transition-opacity"
>
{formatTime(message.timestamp)}
</span>
</div>
<div class="flex-1 min-w-0">
{#if hasMedia && message.media}
<MessageMedia
type={message.type as "image" | "video" | "audio" | "file"}
media={message.media}
altText={message.content}
/>
{:else}
<MessageContent
content={message.content}
isEdited={message.isEdited}
isRedacted={message.isRedacted}
/>
{/if}
</div>
</div>
{:else}
<!-- Full message with avatar - mt-4 creates gap between message groups -->
<div class="flex gap-4 mt-4 first:mt-0">
<div class="w-10 shrink-0">
<Avatar
src={message.senderAvatar}
name={message.senderName}
size="md"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2 mb-0.5">
<span class="font-semibold text-light hover:underline cursor-pointer">
{message.senderName}
</span>
<span class="text-xs text-light/40">
{formatTime(message.timestamp)}
</span>
</div>
{#if hasMedia && message.media}
<MessageMedia
type={message.type as "image" | "video" | "audio" | "file"}
media={message.media}
altText={message.content}
/>
{:else}
<MessageContent
content={message.content}
isEdited={message.isEdited}
isRedacted={message.isRedacted}
/>
{/if}
</div>
</div>
{/if}
<!-- Reactions -->
<MessageReactions
reactions={message.reactions}
{currentUserId}
isRedacted={message.isRedacted}
{onReact}
{onToggleReaction}
/>
<!-- Read receipts (own messages only) -->
{#if isOwnMessage}
<MessageReadReceipts receipts={readReceipts} />
{/if}
<!-- Action buttons (show on hover) -->
{#if showActions && !message.isRedacted}
<MessageActions
{isOwnMessage}
{isPinned}
messageContent={message.content}
messageEventId={message.eventId}
{onReact}
{onReply}
{onEdit}
{onDelete}
{onPin}
/>
{/if}
</div>

View File

@@ -0,0 +1,10 @@
/**
* Message module barrel export
*
* This module provides modular message components following
* single responsibility principle.
*/
export { default as MessageContainer } from './MessageContainer.svelte';
export * from './parts';
export * from './utils';

View File

@@ -0,0 +1,199 @@
<script lang="ts">
import Twemoji from '$lib/components/ui/Twemoji.svelte';
import EmojiPicker from '$lib/components/ui/EmojiPicker.svelte';
interface Props {
isOwnMessage?: boolean;
isPinned?: boolean;
messageContent: string;
messageEventId: string;
onReact?: (emoji: string) => void;
onReply?: () => void;
onEdit?: () => void;
onDelete?: () => void;
onPin?: () => void;
}
let {
isOwnMessage = false,
isPinned = false,
messageContent,
messageEventId,
onReact,
onReply,
onEdit,
onDelete,
onPin,
}: Props = $props();
const quickReactions = ['👍', '❤️', '😂'];
let showEmojiPicker = $state(false);
let showContextMenu = $state(false);
let menuPosition = $state({ x: 0, y: 0 });
function openContextMenu(e: MouseEvent) {
const button = e.currentTarget as HTMLElement;
const rect = button.getBoundingClientRect();
const menuHeight = 200;
const viewportHeight = window.innerHeight;
let y = rect.bottom + 4;
if (y + menuHeight > viewportHeight) {
y = rect.top - menuHeight - 4;
}
menuPosition = { x: rect.right - 180, y: Math.max(8, y) };
showContextMenu = !showContextMenu;
showEmojiPicker = false;
}
function openEmojiPicker(e: MouseEvent) {
const button = e.currentTarget as HTMLElement;
const rect = button.getBoundingClientRect();
const menuHeight = 150;
const viewportHeight = window.innerHeight;
let y = rect.bottom + 4;
if (y + menuHeight > viewportHeight) {
y = rect.top - menuHeight - 4;
}
menuPosition = { x: rect.right - 220, y: Math.max(8, y) };
showEmojiPicker = !showEmojiPicker;
showContextMenu = false;
}
</script>
<div class="absolute right-4 -top-3 flex items-center gap-0.5 bg-dark border border-light/20 rounded-lg shadow-lg p-0.5">
<!-- Quick reactions -->
{#each quickReactions as emoji}
<button
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors"
onclick={() => onReact?.(emoji)}
title="React with {emoji}"
>
<Twemoji {emoji} size={18} />
</button>
{/each}
<!-- Emoji picker -->
<div class="relative">
<button
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
onclick={openEmojiPicker}
title="Add reaction"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</svg>
</button>
{#if showEmojiPicker}
<EmojiPicker
position={menuPosition}
onSelect={(emoji) => onReact?.(emoji)}
onClose={() => (showEmojiPicker = false)}
/>
{/if}
</div>
<div class="w-px h-6 bg-light/20 mx-0.5"></div>
<!-- Reply button -->
<button
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
onclick={() => onReply?.()}
title="Reply"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9,17 4,12 9,7" />
<path d="M20,18 v-2 a4,4 0 0,0 -4,-4 H4" />
</svg>
</button>
<!-- Edit button (own messages only) -->
{#if isOwnMessage}
<button
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
onclick={() => onEdit?.()}
title="Edit"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
{/if}
<!-- Context menu -->
<div class="relative">
<button
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
onclick={openContextMenu}
title="More options"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
<circle cx="5" cy="12" r="1" />
</svg>
</button>
{#if showContextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed bg-dark border border-light/20 rounded-lg shadow-xl py-1 z-[100] min-w-[180px]"
style="left: {menuPosition.x}px; top: {menuPosition.y}px;"
onclick={(e) => e.stopPropagation()}
>
<button
class="w-full px-3 py-2 text-left text-sm text-light/80 hover:bg-light/10 flex items-center gap-2"
onclick={() => { onPin?.(); showContextMenu = false; }}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill={isPinned ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2">
<path d="M12 2L12 12M12 12L8 8M12 12L16 8" transform="rotate(45 12 12)" />
<line x1="5" y1="12" x2="19" y2="12" transform="rotate(45 12 12)" />
</svg>
{isPinned ? 'Unpin' : 'Pin'} message
</button>
<button
class="w-full px-3 py-2 text-left text-sm text-light/80 hover:bg-light/10 flex items-center gap-2"
onclick={() => { navigator.clipboard.writeText(messageContent); showContextMenu = false; }}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Copy text
</button>
<button
class="w-full px-3 py-2 text-left text-sm text-light/80 hover:bg-light/10 flex items-center gap-2"
onclick={() => { navigator.clipboard.writeText(messageEventId); showContextMenu = false; }}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
Copy message ID
</button>
{#if isOwnMessage}
<div class="h-px bg-light/10 my-1"></div>
<button
class="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-red-500/10 flex items-center gap-2"
onclick={() => { onDelete?.(); showContextMenu = false; }}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3,6 5,6 21,6" />
<path d="M19,6 v14 a2,2 0 0,1 -2,2 H7 a2,2 0 0,1 -2,-2 V6 m3,0 V4 a2,2 0 0,1 2,-2 h4 a2,2 0 0,1 2,2 v2" />
</svg>
Delete message
</button>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { renderMarkdown, isEmojiOnly } from '../utils';
interface Props {
content: string;
isEdited?: boolean;
isRedacted?: boolean;
}
let { content, isEdited = false, isRedacted = false }: Props = $props();
const emojiOnly = $derived(isEmojiOnly(content));
const renderedContent = $derived(renderMarkdown(content));
</script>
{#if isRedacted}
<p class="text-light break-words italic text-light/40">
This message was deleted
</p>
{:else}
<span class="text-light break-words {emojiOnly ? 'emoji-only' : 'prose'}">
{@html renderedContent}
</span>
{#if isEdited}
<span class="text-xs text-light/40 ml-1 whitespace-nowrap">(edited)</span>
{/if}
{/if}

View File

@@ -0,0 +1,103 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import ImagePreviewModal from '$lib/components/ui/ImagePreviewModal.svelte';
import { getAuthenticatedMediaUrl } from '$lib/matrix';
import { formatFileSize } from '../utils';
import type { MediaInfo } from '$lib/matrix/types';
interface Props {
type: 'image' | 'video' | 'audio' | 'file';
media: MediaInfo;
altText?: string;
}
let { type, media, altText = '' }: Props = $props();
let mediaUrl = $state<string | null>(null);
let isLoading = $state(true);
let showPreview = $state(false);
// Load authenticated media URL
$effect(() => {
if (media?.url) {
isLoading = true;
getAuthenticatedMediaUrl(media.url)
.then((url) => {
mediaUrl = url;
isLoading = false;
})
.catch(() => {
mediaUrl = media?.httpUrl || null;
isLoading = false;
});
}
});
// Cleanup blob URLs
onDestroy(() => {
if (mediaUrl?.startsWith('blob:')) {
URL.revokeObjectURL(mediaUrl);
}
});
</script>
{#if type === 'image'}
{#if isLoading}
<div class="w-48 h-32 bg-dark/50 rounded-lg animate-pulse flex items-center justify-center">
<span class="text-light/30 text-sm">Loading...</span>
</div>
{:else if mediaUrl}
<button
class="block max-w-md cursor-pointer"
onclick={() => (showPreview = true)}
>
<img
src={mediaUrl}
alt={altText}
class="rounded-lg max-h-80 object-contain bg-dark/50 hover:opacity-90 transition-opacity"
style="max-width: 100%;"
/>
</button>
{/if}
{:else if type === 'video' && mediaUrl}
<video
src={mediaUrl}
controls
class="rounded-lg max-w-md max-h-80 bg-dark/50"
>
<track kind="captions" />
</video>
{:else if type === 'audio' && mediaUrl}
<audio src={mediaUrl} controls class="w-full max-w-md">
<track kind="captions" />
</audio>
{:else if type === 'file'}
<a
href={mediaUrl || '#'}
download={media.filename}
class="flex items-center gap-3 px-4 py-3 bg-dark/50 rounded-lg hover:bg-dark/70 transition-colors max-w-sm"
>
<svg
class="w-8 h-8 text-primary shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
</svg>
<div class="min-w-0">
<p class="text-light truncate">{media.filename || altText}</p>
<p class="text-xs text-light/50">{formatFileSize(media.size)}</p>
</div>
</a>
{/if}
{#if showPreview && mediaUrl}
<ImagePreviewModal
src={mediaUrl}
alt={altText}
onClose={() => (showPreview = false)}
/>
{/if}

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import Twemoji from "$lib/components/ui/Twemoji.svelte";
import EmojiPicker from "$lib/components/ui/EmojiPicker.svelte";
interface Props {
reactions: Map<string, Map<string, string>>; // emoji -> userId -> reactionEventId
currentUserId: string;
isRedacted?: boolean;
onReact?: (emoji: string) => void;
onToggleReaction?: (emoji: string, reactionEventId: string | null) => void;
}
let {
reactions,
currentUserId,
isRedacted = false,
onReact,
onToggleReaction,
}: Props = $props();
let showPicker = $state(false);
// Track recently changed reactions for animation
let animatingReactions = $state<Set<string>>(new Set());
/**
* Get the reaction event ID if current user has reacted with this emoji
* O(1) access using nested Map structure
*/
function getUserReactionEventId(emoji: string): string | null {
const userMap = reactions.get(emoji);
if (!userMap) return null;
return userMap.get(currentUserId) ?? null;
}
/**
* Check if a reaction event ID indicates a pending (optimistic) reaction
*/
function isPendingReaction(eventId: string | null): boolean {
return eventId?.startsWith("~pending-") ?? false;
}
function handleClick(emoji: string) {
const reactionEventId = getUserReactionEventId(emoji);
// Trigger animation
animatingReactions.add(emoji);
setTimeout(() => {
animatingReactions = new Set(
[...animatingReactions].filter((e) => e !== emoji),
);
}, 300);
onToggleReaction?.(emoji, reactionEventId);
}
</script>
{#if reactions.size > 0}
<div class="flex flex-wrap items-center gap-1 mt-1 ml-14">
{#each [...reactions.entries()] as [emoji, userMap]}
{@const hasReacted = userMap.has(currentUserId)}
{@const reactionEventId = getUserReactionEventId(emoji)}
{@const isPending = isPendingReaction(reactionEventId)}
{@const isAnimating = animatingReactions.has(emoji)}
<button
class="reaction-badge flex items-center gap-1 px-2 py-0.5 rounded-full text-sm transition-all duration-200
{hasReacted
? 'bg-primary/20 text-primary border border-primary/30 hover:bg-primary/30'
: 'bg-light/10 hover:bg-light/20 text-light/60'}
{isPending ? 'opacity-70 animate-pulse' : ''}
{isAnimating ? 'scale-125' : 'scale-100'}"
onclick={() => handleClick(emoji)}
title={hasReacted ? "Remove reaction" : "Add reaction"}
>
<Twemoji {emoji} size={16} />
<span>{userMap.size}</span>
</button>
{/each}
<!-- Add reaction button -->
{#if !isRedacted}
<div class="relative">
<button
class="flex items-center justify-center w-7 h-7 rounded-full bg-light/5 hover:bg-light/10 text-light/40 hover:text-light/60 transition-colors"
onclick={() => (showPicker = !showPicker)}
title="Add reaction"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</svg>
</button>
{#if showPicker}
<div class="absolute bottom-full left-0 mb-2 z-50">
<EmojiPicker
onSelect={(emoji) => {
onReact?.(emoji);
showPicker = false;
}}
onClose={() => (showPicker = false)}
position={{ x: 0, y: 0 }}
/>
</div>
{/if}
</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,52 @@
<script lang="ts">
interface ReadReceipt {
userId: string;
name: string;
avatarUrl: string | null;
timestamp?: number;
}
interface Props {
receipts: ReadReceipt[];
}
let { receipts }: Props = $props();
</script>
{#if receipts.length > 0}
<div
class="flex items-center gap-1 mt-1 ml-14"
title="Read by {receipts.map((r) => r.name).join(', ')}"
>
<span class="text-xs text-light/40 mr-1">Read by</span>
<div class="flex -space-x-1">
{#each receipts.slice(0, 5) as reader}
<div
class="w-4 h-4 rounded-full bg-dark border border-night overflow-hidden"
title={reader.name}
>
{#if reader.avatarUrl}
<img
src={reader.avatarUrl}
alt={reader.name}
class="w-full h-full object-cover"
/>
{:else}
<div
class="w-full h-full bg-primary/50 flex items-center justify-center text-[8px] text-white"
>
{reader.name[0]?.toUpperCase()}
</div>
{/if}
</div>
{/each}
{#if receipts.length > 5}
<div
class="w-4 h-4 rounded-full bg-light/20 border border-night flex items-center justify-center text-[8px] text-light"
>
+{receipts.length - 5}
</div>
{/if}
</div>
</div>
{/if}

View File

@@ -0,0 +1,9 @@
/**
* Message parts barrel export
*/
export { default as MessageContent } from './MessageContent.svelte';
export { default as MessageMedia } from './MessageMedia.svelte';
export { default as MessageReactions } from './MessageReactions.svelte';
export { default as MessageActions } from './MessageActions.svelte';
export { default as MessageReadReceipts } from './MessageReadReceipts.svelte';

View File

@@ -0,0 +1,13 @@
/**
* Message utilities barrel export
*/
export {
renderMarkdown,
renderEmojisAsTwemoji,
renderMentions,
isEmojiOnly,
formatTime,
formatFullTime,
formatFileSize,
} from './markdown';

View File

@@ -0,0 +1,168 @@
/**
* Markdown rendering utilities for messages
* Extracted from Message.svelte for reusability and testability
*/
import { marked } from 'marked';
import hljs from 'highlight.js';
import { getTwemojiUrl } from '$lib/utils/twemoji';
// Configure marked for safe rendering
marked.setOptions({
breaks: true,
gfm: true,
});
// Custom renderer for code blocks with syntax highlighting
const renderer = new marked.Renderer();
renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext';
const highlighted = hljs.highlight(text, { language }).value;
return `<pre><code class="hljs language-${language}">${highlighted}</code></pre>`;
};
// LRU Cache for memoization (prevents memory leaks)
class LRUCache<K, V> {
private cache = new Map<K, V>();
constructor(private maxSize: number) { }
get(key: K): V | undefined {
const value = this.cache.get(key);
if (value !== undefined) {
// Move to end (most recently used)
this.cache.delete(key);
this.cache.set(key, value);
}
return value;
}
set(key: K, value: V): void {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.maxSize) {
// Delete oldest entry
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) this.cache.delete(firstKey);
}
this.cache.set(key, value);
}
clear(): void {
this.cache.clear();
}
}
const markdownCache = new LRUCache<string, string>(200);
/**
* Convert emoji characters to Twemoji images
*/
export function renderEmojisAsTwemoji(text: string): string {
const emojiRegex = /(\p{Emoji_Presentation}|\p{Emoji}\uFE0F|\p{Extended_Pictographic})/gu;
return text.replace(emojiRegex, (emoji) => {
const url = getTwemojiUrl(emoji);
return `<img class="twemoji-inline" src="${url}" alt="${emoji}" draggable="false" />`;
});
}
/**
* Render @mentions as styled buttons
*/
export function renderMentions(text: string): string {
// Replace @userId mentions with styled spans
let result = text.replace(
/@([a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g,
(match, userId) => {
const displayName = userId.split(':')[0];
return `<button class="mention-ping" data-user-id="@${userId}" onclick="window.dispatchEvent(new CustomEvent('show-user-profile', { detail: '@${userId}' }))">@${displayName}</button>`;
}
);
// Handle @everyone and @here mentions
result = result.replace(
/@(everyone|here|room)\b/gi,
'<span class="mention-ping mention-everyone">@$1</span>'
);
return result;
}
/**
* Render markdown content with memoization
*/
export function renderMarkdown(text: string): string {
// Check cache first
const cached = markdownCache.get(text);
if (cached) return cached;
// First handle mentions
let processed = renderMentions(text);
// Don't render markdown if it looks like plain text
const hasMarkdown = /[*_`#\[\]!|]/.test(text);
if (!hasMarkdown) {
processed = renderEmojisAsTwemoji(processed);
markdownCache.set(text, processed);
return processed;
}
try {
let result = marked.parse(processed, { async: false, renderer }) as string;
result = renderEmojisAsTwemoji(result);
markdownCache.set(text, result);
return result;
} catch {
const fallback = renderEmojisAsTwemoji(processed);
markdownCache.set(text, fallback);
return fallback;
}
}
/**
* Check if message is emoji-only
*/
export function isEmojiOnly(text: string): boolean {
const emojiRegex = /^[\s\p{Emoji_Presentation}\p{Emoji}\uFE0F\u200D]*$/u;
const hasEmoji = /\p{Emoji_Presentation}|\p{Emoji}\uFE0F/u.test(text);
return emojiRegex.test(text) && hasEmoji && text.trim().length > 0;
}
/**
* Format timestamp for display
*/
export function formatTime(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
/**
* Format timestamp for full display
*/
export function formatFullTime(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
return date.toLocaleDateString([], {
month: 'short',
day: 'numeric',
});
}
}
/**
* Format file size for display
*/
export 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`;
}

View File

@@ -2,34 +2,62 @@
interface Props {
name: string;
src?: string | null;
size?: "sm" | "md" | "lg" | "xl";
size?: "xs" | "sm" | "md" | "lg" | "xl";
status?: "online" | "offline" | "away" | "dnd" | null;
}
let { name, src = null, size = "md" }: Props = $props();
let { name, src = null, size = "md", status = null }: Props = $props();
const initial = $derived(name ? name[0].toUpperCase() : "?");
const sizes = {
xs: { box: "w-6 h-6", text: "text-[10px]", radius: "rounded-[12px]" },
sm: { box: "w-8 h-8", text: "text-body", radius: "rounded-[16px]" },
md: { box: "w-12 h-12", text: "text-h3", radius: "rounded-[24px]" },
lg: { box: "w-16 h-16", text: "text-h2", radius: "rounded-[32px]" },
xl: { box: "w-24 h-24", text: "text-h1", radius: "rounded-[48px]" },
};
const statusSizes: Record<string, string> = {
xs: "w-2 h-2",
sm: "w-2.5 h-2.5",
md: "w-3 h-3",
lg: "w-3.5 h-3.5",
xl: "w-4 h-4",
};
const statusColors: Record<string, string> = {
online: "bg-success",
offline: "bg-light/30",
away: "bg-warning",
dnd: "bg-error",
};
</script>
{#if src}
<img
{src}
alt={name}
class="{sizes[size].box} {sizes[size].radius} object-cover shrink-0"
/>
{:else}
<div
class="{sizes[size].box} {sizes[size]
.radius} bg-primary flex items-center justify-center shrink-0"
>
<span class="font-heading {sizes[size].text} text-night leading-none">
{initial}
</span>
</div>
{/if}
<div class="relative inline-block shrink-0">
{#if src}
<img
{src}
alt={name}
class="{sizes[size].box} {sizes[size].radius} object-cover shrink-0"
/>
{:else}
<div
class="{sizes[size].box} {sizes[size]
.radius} bg-primary flex items-center justify-center shrink-0"
>
<span
class="font-heading {sizes[size].text} text-night leading-none"
>
{initial}
</span>
</div>
{/if}
{#if status}
<div
class="absolute bottom-0 right-0 rounded-full border-2 border-night {statusSizes[
size
]} {statusColors[status]}"
></div>
{/if}
</div>

View File

@@ -0,0 +1,168 @@
<script lang="ts">
import Twemoji from "./Twemoji.svelte";
import {
emojiData,
searchEmojis,
getEmojisByCategory,
} from "$lib/utils/emojiData";
interface Props {
onSelect: (emoji: string) => void;
onClose: () => void;
position?: { x: number; y: number };
}
let { onSelect, onClose, position = { x: 0, y: 0 } }: Props = $props();
let searchQuery = $state("");
let activeCategory = $state("frequent");
let pickerRef: HTMLDivElement | null = $state(null);
let adjustedPosition = $state({ x: 0, y: 0 });
// Initialize position on first render
$effect(() => {
adjustedPosition = { x: position.x, y: position.y };
});
// Adjust position to stay within viewport
$effect(() => {
if (pickerRef) {
const rect = pickerRef.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let newX = position.x;
let newY = position.y;
// Adjust horizontal position
if (newX + rect.width > viewportWidth - 10) {
newX = viewportWidth - rect.width - 10;
}
if (newX < 10) newX = 10;
// Adjust vertical position
if (newY + rect.height > viewportHeight - 10) {
newY = position.y - rect.height - 40; // Position above the button
}
if (newY < 10) newY = 10;
adjustedPosition = { x: newX, y: newY };
}
});
// Emoji categories
const categories = [
{ id: "frequent", icon: "🕐", name: "Frequently Used" },
{ id: "smileys", icon: "😀", name: "Smileys & Emotion" },
{ id: "people", icon: "👋", name: "People & Body" },
{ id: "nature", icon: "🐻", name: "Animals & Nature" },
{ id: "food", icon: "🍕", name: "Food & Drink" },
{ id: "activities", icon: "⚽", name: "Activities" },
{ id: "travel", icon: "🚗", name: "Travel & Places" },
{ id: "objects", icon: "💡", name: "Objects" },
{ id: "symbols", icon: "❤️", name: "Symbols" },
];
// Frequently used emojis
const frequentEmojis = [
"👍",
"❤️",
"😂",
"🔥",
"👀",
"🙌",
"💯",
"✅",
"❌",
"🎉",
"😮",
"😢",
];
const filteredEmojis = $derived(() => {
if (searchQuery) {
// Search using emoji names
return searchEmojis(searchQuery).map((e) => e.emoji);
}
if (activeCategory === "frequent") {
return frequentEmojis;
}
// Get emojis from the data file by category
return getEmojisByCategory(activeCategory).map((e) => e.emoji);
});
function handleSelect(emoji: string) {
onSelect(emoji);
onClose();
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={pickerRef}
class="fixed bg-dark border border-light/20 rounded-xl shadow-2xl z-[100] w-[352px] overflow-hidden"
style="left: {adjustedPosition.x}px; top: {adjustedPosition.y}px;"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-label="Emoji picker"
tabindex="-1"
>
<!-- Search bar -->
<div class="p-2 border-b border-light/10">
<div class="relative">
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-light/40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
bind:value={searchQuery}
placeholder="Find the perfect emoji"
class="w-full bg-night/50 border border-light/10 rounded-lg pl-10 pr-4 py-2 text-sm text-light placeholder:text-light/40 focus:outline-none focus:border-primary/50"
/>
</div>
</div>
<!-- Category tabs -->
<div class="flex border-b border-light/10 px-1">
{#each categories as category}
<button
class="p-2 hover:bg-light/5 rounded transition-colors {activeCategory ===
category.id
? 'bg-light/10'
: ''}"
onclick={() => {
activeCategory = category.id;
searchQuery = "";
}}
title={category.name}
>
<Twemoji emoji={category.icon} size={18} />
</button>
{/each}
</div>
<!-- Emoji grid -->
<div class="h-[200px] overflow-y-auto p-2">
<div class="text-xs text-light/50 font-medium mb-2 px-1">
{categories.find((c) => c.id === activeCategory)?.name || "Emojis"}
</div>
<div class="grid grid-cols-8 gap-0.5">
{#each filteredEmojis() as emoji}
<button
class="w-9 h-9 flex items-center justify-center hover:bg-light/10 rounded transition-colors"
onclick={() => handleSelect(emoji)}
>
<Twemoji {emoji} size={22} />
</button>
{/each}
</div>
</div>
</div>

View File

@@ -0,0 +1,63 @@
<script lang="ts">
interface Props {
src: string;
alt?: string;
onClose: () => void;
}
let { src, alt = "", onClose }: Props = $props();
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeyDown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/90 backdrop-blur-sm"
onclick={handleBackdropClick}
>
<!-- Close button -->
<button
class="absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full bg-light/10 hover:bg-light/20 transition-colors text-light"
onclick={onClose}
>
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
<!-- Image container -->
<div class="max-w-[90vw] max-h-[90vh] flex items-center justify-center">
<img
{src}
{alt}
class="max-w-full max-h-[90vh] object-contain rounded-lg shadow-2xl"
/>
</div>
<!-- Open in new tab button -->
<a
href={src}
target="_blank"
rel="noopener noreferrer"
class="absolute bottom-4 right-4 px-4 py-2 rounded-lg bg-light/10 hover:bg-light/20 transition-colors text-light text-sm flex items-center gap-2"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
Open Original
</a>
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { getTwemojiUrl } from '$lib/utils/twemoji';
interface Props {
emoji: string;
size?: number;
class?: string;
}
let { emoji, size = 20, class: className = '' }: Props = $props();
const url = $derived(getTwemojiUrl(emoji));
</script>
<img
src={url}
alt={emoji}
class="inline-block align-text-bottom {className}"
style="width: {size}px; height: {size}px;"
draggable="false"
/>

View File

@@ -0,0 +1,114 @@
<script lang="ts" generics="T">
import { onMount, tick } from "svelte";
interface Props {
items: T[];
itemHeight: number;
overscan?: number;
containerClass?: string;
getKey: (item: T, index: number) => string;
children: import("svelte").Snippet<[T, number]>;
onScrollTop?: () => void;
onScrollBottom?: () => void;
}
let {
items,
itemHeight,
overscan = 5,
containerClass = "",
getKey,
children,
onScrollTop,
onScrollBottom,
}: Props = $props();
let containerRef: HTMLDivElement | null = $state(null);
let scrollTop = $state(0);
let containerHeight = $state(0);
// Calculate visible range
const visibleRange = $derived(() => {
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const visibleCount = Math.ceil(containerHeight / itemHeight) + overscan * 2;
const endIndex = Math.min(items.length, startIndex + visibleCount);
return { startIndex, endIndex };
});
// Get visible items with their indices
const visibleItems = $derived(() => {
const { startIndex, endIndex } = visibleRange();
return items.slice(startIndex, endIndex).map((item, i) => ({
item,
index: startIndex + i,
}));
});
// Total height of the list
const totalHeight = $derived(items.length * itemHeight);
// Offset for visible items
const offsetY = $derived(visibleRange().startIndex * itemHeight);
function handleScroll(e: Event) {
const target = e.target as HTMLDivElement;
scrollTop = target.scrollTop;
// Check for scroll to top (load more)
if (target.scrollTop < 100 && onScrollTop) {
onScrollTop();
}
// Check for scroll to bottom
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
if (distanceToBottom < 100 && onScrollBottom) {
onScrollBottom();
}
}
function updateContainerHeight() {
if (containerRef) {
containerHeight = containerRef.clientHeight;
}
}
onMount(() => {
updateContainerHeight();
const resizeObserver = new ResizeObserver(updateContainerHeight);
if (containerRef) {
resizeObserver.observe(containerRef);
}
return () => resizeObserver.disconnect();
});
// Scroll to bottom
export async function scrollToBottom() {
await tick();
if (containerRef) {
containerRef.scrollTop = containerRef.scrollHeight;
}
}
// Scroll to specific index
export function scrollToIndex(index: number) {
if (containerRef) {
containerRef.scrollTop = index * itemHeight;
}
}
</script>
<div
bind:this={containerRef}
class="overflow-y-auto {containerClass}"
onscroll={handleScroll}
>
<div style="height: {totalHeight}px; position: relative;">
<div style="transform: translateY({offsetY}px);">
{#each visibleItems() as { item, index } (getKey(item, index))}
<div style="height: {itemHeight}px;">
{@render children(item, index)}
</div>
{/each}
</div>
</div>
</div>

View File

@@ -26,3 +26,7 @@ export { default as Icon } from './Icon.svelte';
export { default as AssigneePicker } from './AssigneePicker.svelte';
export { default as ContextMenu } from './ContextMenu.svelte';
export { default as PageSkeleton } from './PageSkeleton.svelte';
export { default as ImagePreviewModal } from './ImagePreviewModal.svelte';
export { default as Twemoji } from './Twemoji.svelte';
export { default as EmojiPicker } from './EmojiPicker.svelte';
export { default as VirtualList } from './VirtualList.svelte';