- 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
103 lines
3.1 KiB
Svelte
103 lines
3.1 KiB
Svelte
<script lang="ts">
|
|
import { Avatar } from "$lib/components/ui";
|
|
import UserProfileModal from "./UserProfileModal.svelte";
|
|
import type { RoomMember } from "$lib/matrix/types";
|
|
import { userPresence } from "$lib/stores/matrix";
|
|
|
|
interface Props {
|
|
members: RoomMember[];
|
|
onMemberClick?: (member: RoomMember) => void;
|
|
onStartDM?: (roomId: string) => void;
|
|
}
|
|
|
|
let { members, onMemberClick, onStartDM }: Props = $props();
|
|
|
|
let selectedMember = $state<RoomMember | null>(null);
|
|
|
|
function handleMemberClick(member: RoomMember) {
|
|
if (onMemberClick) {
|
|
onMemberClick(member);
|
|
} else {
|
|
selectedMember = member;
|
|
}
|
|
}
|
|
|
|
// Sort: online first, then admins, then by name
|
|
const sortedMembers = $derived(
|
|
[...members].sort((a, b) => {
|
|
// Online status first
|
|
const aOnline = $userPresence.get(a.userId) === "online" ? 1 : 0;
|
|
const bOnline = $userPresence.get(b.userId) === "online" ? 1 : 0;
|
|
if (bOnline !== aOnline) return bOnline - aOnline;
|
|
|
|
// Power level descending
|
|
if (b.powerLevel !== a.powerLevel) {
|
|
return b.powerLevel - a.powerLevel;
|
|
}
|
|
// Then alphabetically
|
|
return a.name.localeCompare(b.name);
|
|
}),
|
|
);
|
|
|
|
function getRoleBadge(
|
|
powerLevel: number,
|
|
): { label: string; color: string } | null {
|
|
if (powerLevel >= 100) return { label: "Admin", color: "text-red-400" };
|
|
if (powerLevel >= 50) return { label: "Mod", color: "text-yellow-400" };
|
|
return null;
|
|
}
|
|
|
|
function getPresenceStatus(userId: string): "online" | "offline" | null {
|
|
const presence = $userPresence.get(userId);
|
|
if (presence === "online") return "online";
|
|
if (presence === "offline" || presence === "unavailable") return "offline";
|
|
return null;
|
|
}
|
|
</script>
|
|
|
|
<div class="flex flex-col h-full">
|
|
<header class="p-4 border-b border-light/10">
|
|
<h3 class="font-semibold text-light">Members ({members.length})</h3>
|
|
</header>
|
|
|
|
<div class="flex-1 overflow-y-auto">
|
|
{#each sortedMembers as member}
|
|
<button
|
|
class="w-full flex items-center gap-3 px-4 py-2 hover:bg-light/5 transition-colors text-left"
|
|
onclick={() => handleMemberClick(member)}
|
|
>
|
|
<Avatar
|
|
src={member.avatarUrl}
|
|
name={member.name}
|
|
size="sm"
|
|
status={getPresenceStatus(member.userId)}
|
|
/>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-light truncate">{member.name}</span>
|
|
{#if getRoleBadge(member.powerLevel)}
|
|
{@const badge = getRoleBadge(member.powerLevel)}
|
|
<span class="text-xs {badge?.color}">{badge?.label}</span>
|
|
{/if}
|
|
</div>
|
|
<p class="text-xs text-light/40 truncate">{member.userId}</p>
|
|
</div>
|
|
</button>
|
|
{/each}
|
|
|
|
{#if members.length === 0}
|
|
<div class="p-4 text-center text-light/40">
|
|
<p>No members</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
{#if selectedMember}
|
|
<UserProfileModal
|
|
member={selectedMember}
|
|
onClose={() => (selectedMember = null)}
|
|
{onStartDM}
|
|
/>
|
|
{/if}
|