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,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';