feat: integrate Matrix chat (Option 2 - credentials stored in Supabase)

- Add matrix-js-sdk, marked, highlight.js, twemoji, @tanstack/svelte-virtual deps
- Copy Matrix core layer: /matrix/, /stores/matrix.ts, /cache/, /services/
- Copy Matrix components: matrix/, message/, chat-layout/, chat-settings/
- Copy UI components: EmojiPicker, Twemoji, ImagePreviewModal, VirtualList
- Copy utils: emojiData, twemoji, twemojiGlobal
- Replace lucide-svelte with Material Symbols in SyncRecoveryBanner
- Extend Avatar with xs size and status indicator prop
- Fix ui.ts store conflict: re-export toasts from toast.svelte.ts
- Add migration 020_matrix_credentials for storing Matrix tokens per user/org
- Add /api/matrix-credentials endpoint (GET/POST/DELETE)
- Create [orgSlug]/chat page with Matrix login form + full chat UI
- Add Chat to sidebar navigation
This commit is contained in:
AlacrisDevs
2026-02-07 01:44:06 +02:00
parent e55881b38b
commit d1ce5d0951
62 changed files with 11432 additions and 41 deletions

View File

@@ -0,0 +1,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>