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:
127
src/lib/components/matrix/UserProfileModal.svelte
Normal file
127
src/lib/components/matrix/UserProfileModal.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user