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:
AlacrisDevs
2026-02-07 01:44:06 +02:00
parent e55881b38b
commit d1ce5d0951
62 changed files with 11432 additions and 41 deletions

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { Avatar } from '$lib/components/ui';
import { createDirectMessage } from '$lib/matrix';
import { userPresence } from '$lib/stores/matrix';
import { toasts } from '$lib/stores/ui';
import type { RoomMember } from '$lib/matrix/types';
interface Props {
member: RoomMember;
onClose: () => void;
onStartDM?: (roomId: string) => void;
}
let { member, onClose, onStartDM }: Props = $props();
let isStartingDM = $state(false);
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
const presence = $derived($userPresence.get(member.userId) || 'offline');
const presenceLabel = $derived({
online: { text: 'Online', color: 'text-green-400' },
offline: { text: 'Offline', color: 'text-gray-400' },
unavailable: { text: 'Away', color: 'text-yellow-400' },
}[presence]);
async function handleStartDM() {
isStartingDM = true;
try {
const roomId = await createDirectMessage(member.userId);
toasts.success(`Started DM with ${member.name}`);
onStartDM?.(roomId);
onClose();
} catch (e) {
console.error('Failed to start DM:', e);
toasts.error('Failed to start direct message');
} finally {
isStartingDM = false;
}
}
function getRoleBadge(powerLevel: number): { label: string; color: string; icon: string } | null {
if (powerLevel >= 100) return { label: 'Admin', color: 'bg-red-500/20 text-red-400', icon: '👑' };
if (powerLevel >= 50) return { label: 'Moderator', color: 'bg-blue-500/20 text-blue-400', icon: '🛡️' };
return null;
}
const roleBadge = $derived(getRoleBadge(member.powerLevel));
</script>
<svelte:window onkeydown={handleKeydown} />
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
role="dialog"
aria-modal="true"
aria-labelledby="profile-title"
tabindex="-1"
onclick={onClose}
onkeydown={(e) => e.key === 'Enter' && onClose()}
>
<div
class="bg-dark rounded-2xl w-full max-w-sm mx-4 overflow-hidden"
role="document"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<!-- Header with gradient -->
<div class="h-24 bg-gradient-to-br from-primary/50 to-primary/20 relative">
<button
class="absolute top-3 right-3 w-8 h-8 flex items-center justify-center text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors"
onclick={onClose}
title="Close"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- Avatar -->
<div class="flex justify-center -mt-12 relative z-10">
<div class="ring-4 ring-dark rounded-full">
<Avatar src={member.avatarUrl} name={member.name} size="xl" status={presence === 'online' ? 'online' : presence === 'unavailable' ? 'away' : 'offline'} />
</div>
</div>
<!-- Content -->
<div class="p-6 pt-3 text-center">
<h2 id="profile-title" class="text-xl font-bold text-light">{member.name}</h2>
<p class="text-sm text-light/50 mt-1">{member.userId}</p>
<!-- Status -->
<div class="flex items-center justify-center gap-2 mt-3">
<span class="w-2 h-2 rounded-full {presence === 'online' ? 'bg-green-400' : presence === 'unavailable' ? 'bg-yellow-400' : 'bg-gray-400'}"></span>
<span class="text-sm {presenceLabel.color}">{presenceLabel.text}</span>
</div>
<!-- Role badge -->
{#if roleBadge}
<div class="mt-3">
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs {roleBadge.color}">
{roleBadge.icon} {roleBadge.label}
</span>
</div>
{/if}
<!-- Actions -->
<div class="mt-6 space-y-2">
<button
class="w-full px-4 py-2.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
onclick={handleStartDM}
disabled={isStartingDM}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
{isStartingDM ? 'Starting...' : 'Send Message'}
</button>
</div>
</div>
</div>
</div>