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:
114
src/lib/components/ui/VirtualList.svelte
Normal file
114
src/lib/components/ui/VirtualList.svelte
Normal file
@@ -0,0 +1,114 @@
|
||||
<script lang="ts" generics="T">
|
||||
import { onMount, tick } from "svelte";
|
||||
|
||||
interface Props {
|
||||
items: T[];
|
||||
itemHeight: number;
|
||||
overscan?: number;
|
||||
containerClass?: string;
|
||||
getKey: (item: T, index: number) => string;
|
||||
children: import("svelte").Snippet<[T, number]>;
|
||||
onScrollTop?: () => void;
|
||||
onScrollBottom?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
itemHeight,
|
||||
overscan = 5,
|
||||
containerClass = "",
|
||||
getKey,
|
||||
children,
|
||||
onScrollTop,
|
||||
onScrollBottom,
|
||||
}: Props = $props();
|
||||
|
||||
let containerRef: HTMLDivElement | null = $state(null);
|
||||
let scrollTop = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
|
||||
// Calculate visible range
|
||||
const visibleRange = $derived(() => {
|
||||
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
|
||||
const visibleCount = Math.ceil(containerHeight / itemHeight) + overscan * 2;
|
||||
const endIndex = Math.min(items.length, startIndex + visibleCount);
|
||||
return { startIndex, endIndex };
|
||||
});
|
||||
|
||||
// Get visible items with their indices
|
||||
const visibleItems = $derived(() => {
|
||||
const { startIndex, endIndex } = visibleRange();
|
||||
return items.slice(startIndex, endIndex).map((item, i) => ({
|
||||
item,
|
||||
index: startIndex + i,
|
||||
}));
|
||||
});
|
||||
|
||||
// Total height of the list
|
||||
const totalHeight = $derived(items.length * itemHeight);
|
||||
|
||||
// Offset for visible items
|
||||
const offsetY = $derived(visibleRange().startIndex * itemHeight);
|
||||
|
||||
function handleScroll(e: Event) {
|
||||
const target = e.target as HTMLDivElement;
|
||||
scrollTop = target.scrollTop;
|
||||
|
||||
// Check for scroll to top (load more)
|
||||
if (target.scrollTop < 100 && onScrollTop) {
|
||||
onScrollTop();
|
||||
}
|
||||
|
||||
// Check for scroll to bottom
|
||||
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||
if (distanceToBottom < 100 && onScrollBottom) {
|
||||
onScrollBottom();
|
||||
}
|
||||
}
|
||||
|
||||
function updateContainerHeight() {
|
||||
if (containerRef) {
|
||||
containerHeight = containerRef.clientHeight;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
updateContainerHeight();
|
||||
const resizeObserver = new ResizeObserver(updateContainerHeight);
|
||||
if (containerRef) {
|
||||
resizeObserver.observe(containerRef);
|
||||
}
|
||||
return () => resizeObserver.disconnect();
|
||||
});
|
||||
|
||||
// Scroll to bottom
|
||||
export async function scrollToBottom() {
|
||||
await tick();
|
||||
if (containerRef) {
|
||||
containerRef.scrollTop = containerRef.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to specific index
|
||||
export function scrollToIndex(index: number) {
|
||||
if (containerRef) {
|
||||
containerRef.scrollTop = index * itemHeight;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={containerRef}
|
||||
class="overflow-y-auto {containerClass}"
|
||||
onscroll={handleScroll}
|
||||
>
|
||||
<div style="height: {totalHeight}px; position: relative;">
|
||||
<div style="transform: translateY({offsetY}px);">
|
||||
{#each visibleItems() as { item, index } (getKey(item, index))}
|
||||
<div style="height: {itemHeight}px;">
|
||||
{@render children(item, index)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user