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:
300
src/lib/components/chat-layout/ChatArea.svelte
Normal file
300
src/lib/components/chat-layout/ChatArea.svelte
Normal 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>
|
||||
340
src/lib/components/chat-layout/Sidebar.svelte
Normal file
340
src/lib/components/chat-layout/Sidebar.svelte
Normal 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>
|
||||
2
src/lib/components/chat-layout/index.ts
Normal file
2
src/lib/components/chat-layout/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Sidebar } from './Sidebar.svelte';
|
||||
export { default as ChatArea } from './ChatArea.svelte';
|
||||
346
src/lib/components/chat-settings/UserSettingsModal.svelte
Normal file
346
src/lib/components/chat-settings/UserSettingsModal.svelte
Normal 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}
|
||||
1
src/lib/components/chat-settings/index.ts
Normal file
1
src/lib/components/chat-settings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as UserSettingsModal } from './UserSettingsModal.svelte';
|
||||
103
src/lib/components/matrix/CreateRoomModal.svelte
Normal file
103
src/lib/components/matrix/CreateRoomModal.svelte
Normal 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}
|
||||
174
src/lib/components/matrix/CreateSpaceModal.svelte
Normal file
174
src/lib/components/matrix/CreateSpaceModal.svelte
Normal 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}
|
||||
88
src/lib/components/matrix/EmojiAutocomplete.svelte
Normal file
88
src/lib/components/matrix/EmojiAutocomplete.svelte
Normal 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}
|
||||
32
src/lib/components/matrix/MatrixProvider.svelte
Normal file
32
src/lib/components/matrix/MatrixProvider.svelte
Normal 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()}
|
||||
102
src/lib/components/matrix/MemberList.svelte
Normal file
102
src/lib/components/matrix/MemberList.svelte
Normal 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}
|
||||
91
src/lib/components/matrix/MentionAutocomplete.svelte
Normal file
91
src/lib/components/matrix/MentionAutocomplete.svelte
Normal 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}
|
||||
761
src/lib/components/matrix/MessageInput.svelte
Normal file
761
src/lib/components/matrix/MessageInput.svelte
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.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>
|
||||
478
src/lib/components/matrix/MessageList.svelte
Normal file
478
src/lib/components/matrix/MessageList.svelte
Normal 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>
|
||||
261
src/lib/components/matrix/RoomInfoPanel.svelte
Normal file
261
src/lib/components/matrix/RoomInfoPanel.svelte
Normal 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}
|
||||
187
src/lib/components/matrix/RoomSettingsModal.svelte
Normal file
187
src/lib/components/matrix/RoomSettingsModal.svelte
Normal 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>
|
||||
139
src/lib/components/matrix/StartDMModal.svelte
Normal file
139
src/lib/components/matrix/StartDMModal.svelte
Normal 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>
|
||||
113
src/lib/components/matrix/SyncRecoveryBanner.svelte
Normal file
113
src/lib/components/matrix/SyncRecoveryBanner.svelte
Normal 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}
|
||||
27
src/lib/components/matrix/TypingIndicator.svelte
Normal file
27
src/lib/components/matrix/TypingIndicator.svelte
Normal 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}
|
||||
127
src/lib/components/matrix/UserProfileModal.svelte
Normal file
127
src/lib/components/matrix/UserProfileModal.svelte
Normal 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>
|
||||
12
src/lib/components/matrix/index.ts
Normal file
12
src/lib/components/matrix/index.ts
Normal 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';
|
||||
202
src/lib/components/message/MessageContainer.svelte
Normal file
202
src/lib/components/message/MessageContainer.svelte
Normal 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>
|
||||
10
src/lib/components/message/index.ts
Normal file
10
src/lib/components/message/index.ts
Normal 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';
|
||||
199
src/lib/components/message/parts/MessageActions.svelte
Normal file
199
src/lib/components/message/parts/MessageActions.svelte
Normal 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>
|
||||
27
src/lib/components/message/parts/MessageContent.svelte
Normal file
27
src/lib/components/message/parts/MessageContent.svelte
Normal 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}
|
||||
103
src/lib/components/message/parts/MessageMedia.svelte
Normal file
103
src/lib/components/message/parts/MessageMedia.svelte
Normal 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}
|
||||
117
src/lib/components/message/parts/MessageReactions.svelte
Normal file
117
src/lib/components/message/parts/MessageReactions.svelte
Normal 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}
|
||||
52
src/lib/components/message/parts/MessageReadReceipts.svelte
Normal file
52
src/lib/components/message/parts/MessageReadReceipts.svelte
Normal 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}
|
||||
9
src/lib/components/message/parts/index.ts
Normal file
9
src/lib/components/message/parts/index.ts
Normal 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';
|
||||
13
src/lib/components/message/utils/index.ts
Normal file
13
src/lib/components/message/utils/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Message utilities barrel export
|
||||
*/
|
||||
|
||||
export {
|
||||
renderMarkdown,
|
||||
renderEmojisAsTwemoji,
|
||||
renderMentions,
|
||||
isEmojiOnly,
|
||||
formatTime,
|
||||
formatFullTime,
|
||||
formatFileSize,
|
||||
} from './markdown';
|
||||
168
src/lib/components/message/utils/markdown.ts
Normal file
168
src/lib/components/message/utils/markdown.ts
Normal 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`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
168
src/lib/components/ui/EmojiPicker.svelte
Normal file
168
src/lib/components/ui/EmojiPicker.svelte
Normal 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>
|
||||
63
src/lib/components/ui/ImagePreviewModal.svelte
Normal file
63
src/lib/components/ui/ImagePreviewModal.svelte
Normal 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>
|
||||
21
src/lib/components/ui/Twemoji.svelte
Normal file
21
src/lib/components/ui/Twemoji.svelte
Normal 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"
|
||||
/>
|
||||
114
src/lib/components/ui/VirtualList.svelte
Normal file
114
src/lib/components/ui/VirtualList.svelte
Normal 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>
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user