- 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
479 lines
15 KiB
Svelte
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>
|