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