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:
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>
|
||||
Reference in New Issue
Block a user