Files
root-org/src/lib/components/matrix/MessageList.svelte
AlacrisDevs d1ce5d0951 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
2026-02-07 01:44:06 +02:00

479 lines
15 KiB
Svelte

<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>