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:
103
src/lib/components/matrix/CreateRoomModal.svelte
Normal file
103
src/lib/components/matrix/CreateRoomModal.svelte
Normal file
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { Button, Input } from "$lib/components/ui";
|
||||
import { createRoom } from "$lib/matrix";
|
||||
import { toasts } from "$lib/stores/ui";
|
||||
import { syncRoomsFromEvent, selectRoom } from "$lib/stores/matrix";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { isOpen, onClose }: Props = $props();
|
||||
|
||||
let roomName = $state("");
|
||||
let isDirect = $state(false);
|
||||
let isCreating = $state(false);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!roomName.trim()) {
|
||||
toasts.error("Please enter a room name");
|
||||
return;
|
||||
}
|
||||
|
||||
isCreating = true;
|
||||
|
||||
try {
|
||||
const result = await createRoom(roomName.trim(), isDirect);
|
||||
toasts.success("Room created!");
|
||||
|
||||
// Add new room to list and select it
|
||||
syncRoomsFromEvent("join", result.room_id);
|
||||
selectRoom(result.room_id);
|
||||
|
||||
// Reset and close
|
||||
roomName = "";
|
||||
isDirect = false;
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
console.error("Failed to create room:", e);
|
||||
toasts.error(e.message || "Failed to create room");
|
||||
} finally {
|
||||
isCreating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
|
||||
onclick={onClose}
|
||||
onkeydown={handleKeyDown}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="document"
|
||||
>
|
||||
<h2 class="text-xl font-semibold text-light mb-4">Create New Room</h2>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
}}
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<Input
|
||||
bind:value={roomName}
|
||||
label="Room Name"
|
||||
placeholder="My awesome room"
|
||||
required
|
||||
/>
|
||||
|
||||
<label class="flex items-center gap-3 text-light cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={isDirect}
|
||||
class="w-4 h-4 rounded border-light/30 bg-night text-primary focus:ring-primary"
|
||||
/>
|
||||
<span>Direct message (private 1:1 chat)</span>
|
||||
</label>
|
||||
|
||||
<div class="flex gap-3 justify-end mt-2">
|
||||
<Button variant="secondary" onclick={onClose} disabled={isCreating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={isCreating} disabled={isCreating}>
|
||||
{isCreating ? "Creating..." : "Create Room"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
174
src/lib/components/matrix/CreateSpaceModal.svelte
Normal file
174
src/lib/components/matrix/CreateSpaceModal.svelte
Normal file
@@ -0,0 +1,174 @@
|
||||
<script lang="ts">
|
||||
import { Button, Input } from "$lib/components/ui";
|
||||
import { createSpace, getSpaces } from "$lib/matrix";
|
||||
import { toasts } from "$lib/stores/ui";
|
||||
import { syncRoomsFromEvent } from "$lib/stores/matrix";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
parentSpaceId?: string | null;
|
||||
}
|
||||
|
||||
let { isOpen, onClose, parentSpaceId = null }: Props = $props();
|
||||
|
||||
let spaceName = $state("");
|
||||
let spaceTopic = $state("");
|
||||
let isPublic = $state(false);
|
||||
let isCreating = $state(false);
|
||||
|
||||
// Get existing spaces for parent selection
|
||||
const existingSpaces = $derived(getSpaces());
|
||||
|
||||
async function handleCreate() {
|
||||
if (!spaceName.trim()) {
|
||||
toasts.error("Please enter a space name");
|
||||
return;
|
||||
}
|
||||
|
||||
isCreating = true;
|
||||
|
||||
try {
|
||||
const result = await createSpace(spaceName.trim(), {
|
||||
topic: spaceTopic.trim() || undefined,
|
||||
isPublic,
|
||||
parentSpaceId: parentSpaceId || undefined,
|
||||
});
|
||||
|
||||
toasts.success("Space created!");
|
||||
|
||||
// Sync the new space
|
||||
syncRoomsFromEvent("join", result.room_id);
|
||||
|
||||
// Reset and close
|
||||
spaceName = "";
|
||||
spaceTopic = "";
|
||||
isPublic = false;
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
console.error("Failed to create space:", e);
|
||||
toasts.error(e.message || "Failed to create space");
|
||||
} finally {
|
||||
isCreating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
|
||||
onclick={onClose}
|
||||
onkeydown={handleKeyDown}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-space-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="document"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="w-12 h-12 rounded-xl bg-primary/20 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9,22 9,12 15,12 15,22" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 id="create-space-title" class="text-xl font-semibold text-light">Create Space</h2>
|
||||
<p class="text-sm text-light/60">Organize your rooms and team</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
}}
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<Input
|
||||
bind:value={spaceName}
|
||||
label="Space Name"
|
||||
placeholder="My Organization"
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label for="space-topic" class="block text-sm font-medium text-light/80 mb-1.5">
|
||||
Description (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="space-topic"
|
||||
bind:value={spaceTopic}
|
||||
placeholder="What is this space for?"
|
||||
rows="2"
|
||||
class="w-full px-4 py-2.5 bg-night text-light rounded-xl border border-light/20
|
||||
placeholder:text-light/40 focus:outline-none focus:border-primary
|
||||
focus:ring-1 focus:ring-primary resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-light/80">Visibility</span>
|
||||
|
||||
<label class="flex items-start gap-3 p-3 rounded-lg border border-light/10 hover:border-light/20 cursor-pointer transition-colors {!isPublic ? 'border-primary bg-primary/5' : ''}">
|
||||
<input
|
||||
type="radio"
|
||||
name="visibility"
|
||||
checked={!isPublic}
|
||||
onchange={() => isPublic = false}
|
||||
class="mt-0.5 w-4 h-4 text-primary focus:ring-primary"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-light font-medium">Private</span>
|
||||
<p class="text-sm text-light/60">Only invited members can join</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start gap-3 p-3 rounded-lg border border-light/10 hover:border-light/20 cursor-pointer transition-colors {isPublic ? 'border-primary bg-primary/5' : ''}">
|
||||
<input
|
||||
type="radio"
|
||||
name="visibility"
|
||||
checked={isPublic}
|
||||
onchange={() => isPublic = true}
|
||||
class="mt-0.5 w-4 h-4 text-primary focus:ring-primary"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-light font-medium">Public</span>
|
||||
<p class="text-sm text-light/60">Anyone can find and join this space</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if parentSpaceId}
|
||||
<div class="px-3 py-2 bg-light/5 rounded-lg text-sm text-light/60">
|
||||
<span class="text-light/40">Creating inside:</span>
|
||||
<span class="text-light ml-1">
|
||||
{existingSpaces.find(s => s.roomId === parentSpaceId)?.name || 'Parent Space'}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3 justify-end mt-2">
|
||||
<Button variant="secondary" onclick={onClose} disabled={isCreating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={isCreating} disabled={isCreating}>
|
||||
{isCreating ? "Creating..." : "Create Space"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
88
src/lib/components/matrix/EmojiAutocomplete.svelte
Normal file
88
src/lib/components/matrix/EmojiAutocomplete.svelte
Normal file
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import Twemoji from '$lib/components/ui/Twemoji.svelte';
|
||||
import { searchEmojis, type EmojiItem } from '$lib/utils/emojiData';
|
||||
|
||||
interface Props {
|
||||
query: string;
|
||||
onSelect: (emoji: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
query,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: Props = $props();
|
||||
|
||||
let selectedIndex = $state(0);
|
||||
|
||||
// Filter emojis based on query
|
||||
const filteredEmojis = $derived(
|
||||
searchEmojis(query).slice(0, 10)
|
||||
);
|
||||
|
||||
// Reset selection when query changes
|
||||
$effect(() => {
|
||||
query;
|
||||
selectedIndex = 0;
|
||||
});
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (filteredEmojis.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex + 1) % filteredEmojis.length;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex - 1 + filteredEmojis.length) % filteredEmojis.length;
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
if (filteredEmojis[selectedIndex]) {
|
||||
onSelect(filteredEmojis[selectedIndex].emoji);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose keyboard handler for parent to call
|
||||
export { handleKeyDown };
|
||||
</script>
|
||||
|
||||
{#if filteredEmojis.length > 0}
|
||||
<div
|
||||
class="absolute z-50 bg-dark border border-light/10 rounded-lg shadow-xl overflow-hidden max-h-64 overflow-y-auto"
|
||||
style="bottom: 100%; left: 0; margin-bottom: 8px; min-width: 280px;"
|
||||
>
|
||||
<div class="p-2 text-xs text-light/50 border-b border-light/10">
|
||||
Emojis matching :{query}
|
||||
</div>
|
||||
{#each filteredEmojis as emoji, i}
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors {i === selectedIndex ? 'bg-primary/20' : 'hover:bg-light/5'}"
|
||||
onclick={() => onSelect(emoji.emoji)}
|
||||
onmouseenter={() => selectedIndex = i}
|
||||
>
|
||||
<div class="w-6 h-6 flex items-center justify-center">
|
||||
<Twemoji emoji={emoji.emoji} size={20} />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-light">:{emoji.names[0]}:</p>
|
||||
{#if emoji.names.length > 1}
|
||||
<p class="text-xs text-light/40 truncate">
|
||||
Also: {emoji.names.slice(1, 4).map(n => `:${n}:`).join(' ')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
32
src/lib/components/matrix/MatrixProvider.svelte
Normal file
32
src/lib/components/matrix/MatrixProvider.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
import { setMatrixContext } from "$lib/matrix/context";
|
||||
import { setupSyncHandlers, removeSyncHandlers } from "$lib/matrix/sync";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { client, children }: Props = $props();
|
||||
|
||||
// Store client reference for cleanup
|
||||
let clientRef = client;
|
||||
|
||||
// Set the context during component initialization
|
||||
setMatrixContext(clientRef);
|
||||
|
||||
// Setup sync handlers when provider mounts
|
||||
onMount(() => {
|
||||
setupSyncHandlers(clientRef);
|
||||
});
|
||||
|
||||
// Cleanup when provider unmounts
|
||||
onDestroy(() => {
|
||||
removeSyncHandlers(clientRef);
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
102
src/lib/components/matrix/MemberList.svelte
Normal file
102
src/lib/components/matrix/MemberList.svelte
Normal file
@@ -0,0 +1,102 @@
|
||||
<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}
|
||||
91
src/lib/components/matrix/MentionAutocomplete.svelte
Normal file
91
src/lib/components/matrix/MentionAutocomplete.svelte
Normal file
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { Avatar } from '$lib/components/ui';
|
||||
import type { RoomMember } from '$lib/matrix/types';
|
||||
|
||||
interface Props {
|
||||
members: RoomMember[];
|
||||
query: string;
|
||||
onSelect: (member: RoomMember) => void;
|
||||
onClose: () => void;
|
||||
position?: { top: number; left: number };
|
||||
}
|
||||
|
||||
let {
|
||||
members,
|
||||
query,
|
||||
onSelect,
|
||||
onClose,
|
||||
position = { top: 0, left: 0 },
|
||||
}: Props = $props();
|
||||
|
||||
let selectedIndex = $state(0);
|
||||
|
||||
// Filter members based on query
|
||||
const filteredMembers = $derived(
|
||||
members
|
||||
.filter(m =>
|
||||
m.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
m.userId.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
.slice(0, 8)
|
||||
);
|
||||
|
||||
// Reset selection when query changes
|
||||
$effect(() => {
|
||||
query;
|
||||
selectedIndex = 0;
|
||||
});
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (filteredMembers.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex + 1) % filteredMembers.length;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex - 1 + filteredMembers.length) % filteredMembers.length;
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
if (filteredMembers[selectedIndex]) {
|
||||
onSelect(filteredMembers[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose keyboard handler for parent to call
|
||||
export { handleKeyDown };
|
||||
</script>
|
||||
|
||||
{#if filteredMembers.length > 0}
|
||||
<div
|
||||
class="absolute z-50 bg-dark border border-light/10 rounded-lg shadow-xl overflow-hidden max-h-64 overflow-y-auto"
|
||||
style="bottom: 100%; left: 0; margin-bottom: 8px; min-width: 250px;"
|
||||
>
|
||||
<div class="p-2 text-xs text-light/50 border-b border-light/10">
|
||||
Members matching @{query}
|
||||
</div>
|
||||
{#each filteredMembers as member, i}
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors {i === selectedIndex ? 'bg-primary/20' : 'hover:bg-light/5'}"
|
||||
onclick={() => onSelect(member)}
|
||||
onmouseenter={() => selectedIndex = i}
|
||||
>
|
||||
<Avatar src={member.avatarUrl} name={member.name} size="sm" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-light truncate">{member.name}</p>
|
||||
<p class="text-xs text-light/40 truncate">{member.userId}</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
761
src/lib/components/matrix/MessageInput.svelte
Normal file
761
src/lib/components/matrix/MessageInput.svelte
Normal file
@@ -0,0 +1,761 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, tick } from "svelte";
|
||||
import {
|
||||
sendMessage,
|
||||
setTyping,
|
||||
uploadFile,
|
||||
sendFileMessage,
|
||||
getRoomMembers,
|
||||
} from "$lib/matrix";
|
||||
import { toasts } from "$lib/stores/ui";
|
||||
import {
|
||||
auth,
|
||||
addPendingMessage,
|
||||
confirmPendingMessage,
|
||||
removePendingMessage,
|
||||
} from "$lib/stores/matrix";
|
||||
import type { Message, RoomMember } from "$lib/matrix/types";
|
||||
import MentionAutocomplete from "./MentionAutocomplete.svelte";
|
||||
import EmojiAutocomplete from "./EmojiAutocomplete.svelte";
|
||||
import EmojiPicker from "$lib/components/ui/EmojiPicker.svelte";
|
||||
import { convertEmojiShortcodes } from "$lib/utils/emojiData";
|
||||
import { getTwemojiUrl } from "$lib/utils/twemoji";
|
||||
|
||||
// Emoji detection regex
|
||||
const emojiRegex =
|
||||
/(\p{Emoji_Presentation}|\p{Emoji}\uFE0F|\p{Extended_Pictographic})/gu;
|
||||
|
||||
// Check if text contains emojis
|
||||
function hasEmoji(text: string): boolean {
|
||||
return emojiRegex.test(text);
|
||||
}
|
||||
|
||||
// Render emojis as Twemoji images for preview
|
||||
function renderEmojiPreview(text: string): string {
|
||||
// Escape HTML first
|
||||
const escaped = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\n/g, "<br>");
|
||||
|
||||
// Replace emojis with Twemoji images
|
||||
return escaped.replace(emojiRegex, (emoji) => {
|
||||
const url = getTwemojiUrl(emoji);
|
||||
return `<img class="inline-block w-5 h-5 align-text-bottom" src="${url}" alt="${emoji}" draggable="false" />`;
|
||||
});
|
||||
}
|
||||
|
||||
interface Props {
|
||||
roomId: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
replyTo?: Message | null;
|
||||
onCancelReply?: () => void;
|
||||
editingMessage?: Message | null;
|
||||
onSaveEdit?: (content: string) => void;
|
||||
onCancelEdit?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
roomId,
|
||||
placeholder = "Send a message...",
|
||||
disabled = false,
|
||||
replyTo = null,
|
||||
onCancelReply,
|
||||
editingMessage = null,
|
||||
onSaveEdit,
|
||||
onCancelEdit,
|
||||
}: Props = $props();
|
||||
|
||||
let message = $state("");
|
||||
let isSending = $state(false);
|
||||
let isUploading = $state(false);
|
||||
let inputRef: HTMLTextAreaElement;
|
||||
let fileInputRef: HTMLInputElement;
|
||||
let typingTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Mention autocomplete state
|
||||
let showMentions = $state(false);
|
||||
let mentionQuery = $state("");
|
||||
let mentionStartIndex = $state(0);
|
||||
let autocompleteRef:
|
||||
| { handleKeyDown: (e: KeyboardEvent) => void }
|
||||
| undefined;
|
||||
|
||||
// Emoji picker state
|
||||
let showEmojiPicker = $state(false);
|
||||
let emojiButtonRef: HTMLButtonElement;
|
||||
|
||||
// Emoji autocomplete state
|
||||
let showEmojiAutocomplete = $state(false);
|
||||
let emojiQuery = $state("");
|
||||
let emojiStartIndex = $state(0);
|
||||
let emojiAutocompleteRef:
|
||||
| { handleKeyDown: (e: KeyboardEvent) => void }
|
||||
| undefined;
|
||||
|
||||
// Get room members for autocomplete
|
||||
const roomMembers = $derived(getRoomMembers(roomId));
|
||||
|
||||
// Cleanup typing timeout on component destroy
|
||||
onDestroy(() => {
|
||||
if (typingTimeout) {
|
||||
clearTimeout(typingTimeout);
|
||||
setTyping(roomId, false).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// Populate message when editing starts
|
||||
$effect(() => {
|
||||
if (editingMessage) {
|
||||
message = editingMessage.content;
|
||||
setTimeout(() => {
|
||||
autoResize();
|
||||
inputRef?.focus();
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-resize textarea
|
||||
function autoResize() {
|
||||
if (!inputRef) return;
|
||||
inputRef.style.height = "auto";
|
||||
inputRef.style.height = Math.min(inputRef.scrollHeight, 200) + "px";
|
||||
}
|
||||
|
||||
// Handle typing indicator
|
||||
function handleTyping() {
|
||||
// Clear existing timeout
|
||||
if (typingTimeout) {
|
||||
clearTimeout(typingTimeout);
|
||||
}
|
||||
|
||||
// Send typing indicator
|
||||
setTyping(roomId, true).catch(console.error);
|
||||
|
||||
// Stop typing after 3 seconds of no input
|
||||
typingTimeout = setTimeout(() => {
|
||||
setTyping(roomId, false).catch(console.error);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Handle input
|
||||
function handleInput() {
|
||||
autoResize();
|
||||
if (message.trim()) {
|
||||
handleTyping();
|
||||
}
|
||||
|
||||
// Auto-convert completed emoji shortcodes like :heart: to actual emojis
|
||||
autoConvertShortcodes();
|
||||
|
||||
// Check for @ mentions and : emoji shortcodes
|
||||
checkForMention();
|
||||
checkForEmoji();
|
||||
}
|
||||
|
||||
// Auto-convert completed emoji shortcodes (e.g., :heart:) to actual emojis
|
||||
function autoConvertShortcodes() {
|
||||
if (!inputRef) return;
|
||||
const cursorPos = inputRef.selectionStart;
|
||||
|
||||
// Look for completed shortcodes like :name:
|
||||
const converted = convertEmojiShortcodes(message);
|
||||
if (converted !== message) {
|
||||
// Calculate cursor offset based on length difference
|
||||
const lengthDiff = message.length - converted.length;
|
||||
message = converted;
|
||||
|
||||
// Restore cursor position (adjusted for shorter string)
|
||||
setTimeout(() => {
|
||||
if (inputRef) {
|
||||
const newPos = Math.max(0, cursorPos - lengthDiff);
|
||||
inputRef.selectionStart = inputRef.selectionEnd = newPos;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is typing an emoji shortcode
|
||||
function checkForEmoji() {
|
||||
if (!inputRef) return;
|
||||
|
||||
const cursorPos = inputRef.selectionStart;
|
||||
const textBeforeCursor = message.slice(0, cursorPos);
|
||||
|
||||
// Find the last : before cursor
|
||||
const lastColonIndex = textBeforeCursor.lastIndexOf(":");
|
||||
|
||||
if (lastColonIndex >= 0) {
|
||||
const textAfterColon = textBeforeCursor.slice(lastColonIndex + 1);
|
||||
// Check if there's a space before : (or it's at start) and no space after, and query is at least 2 chars
|
||||
const charBeforeColon =
|
||||
lastColonIndex > 0 ? message[lastColonIndex - 1] : " ";
|
||||
|
||||
if (
|
||||
(charBeforeColon === " " ||
|
||||
charBeforeColon === "\n" ||
|
||||
lastColonIndex === 0) &&
|
||||
!textAfterColon.includes(" ") &&
|
||||
!textAfterColon.includes(":") &&
|
||||
textAfterColon.length >= 2
|
||||
) {
|
||||
showEmojiAutocomplete = true;
|
||||
emojiQuery = textAfterColon;
|
||||
emojiStartIndex = lastColonIndex;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
showEmojiAutocomplete = false;
|
||||
emojiQuery = "";
|
||||
}
|
||||
|
||||
// Handle emoji selection from autocomplete
|
||||
function handleEmojiSelect(emoji: string) {
|
||||
// Replace :query with the emoji
|
||||
const beforeEmoji = message.slice(0, emojiStartIndex);
|
||||
const afterEmoji = message.slice(emojiStartIndex + emojiQuery.length + 1);
|
||||
message = `${beforeEmoji}${emoji}${afterEmoji}`;
|
||||
|
||||
showEmojiAutocomplete = false;
|
||||
emojiQuery = "";
|
||||
|
||||
// Focus back on textarea
|
||||
inputRef?.focus();
|
||||
}
|
||||
|
||||
// Check if user is typing a mention
|
||||
function checkForMention() {
|
||||
if (!inputRef) return;
|
||||
|
||||
const cursorPos = inputRef.selectionStart;
|
||||
const textBeforeCursor = message.slice(0, cursorPos);
|
||||
|
||||
// Find the last @ before cursor that's not part of a completed mention
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf("@");
|
||||
|
||||
if (lastAtIndex >= 0) {
|
||||
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
|
||||
// Check if there's a space before @ (or it's at start) and no space after
|
||||
const charBeforeAt = lastAtIndex > 0 ? message[lastAtIndex - 1] : " ";
|
||||
|
||||
if (
|
||||
(charBeforeAt === " " || charBeforeAt === "\n" || lastAtIndex === 0) &&
|
||||
!textAfterAt.includes(" ")
|
||||
) {
|
||||
showMentions = true;
|
||||
mentionQuery = textAfterAt;
|
||||
mentionStartIndex = lastAtIndex;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
showMentions = false;
|
||||
mentionQuery = "";
|
||||
}
|
||||
|
||||
// Handle mention selection
|
||||
function handleMentionSelect(member: RoomMember) {
|
||||
// Replace @query with userId (userId already has @ prefix)
|
||||
const beforeMention = message.slice(0, mentionStartIndex);
|
||||
const afterMention = message.slice(
|
||||
mentionStartIndex + mentionQuery.length + 1,
|
||||
);
|
||||
message = `${beforeMention}${member.userId} ${afterMention}`;
|
||||
|
||||
showMentions = false;
|
||||
mentionQuery = "";
|
||||
|
||||
// Focus back on textarea
|
||||
inputRef?.focus();
|
||||
}
|
||||
|
||||
// Handle key press
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// If mention autocomplete is open, let it handle navigation keys
|
||||
if (
|
||||
showMentions &&
|
||||
["ArrowUp", "ArrowDown", "Tab", "Escape"].includes(e.key)
|
||||
) {
|
||||
autocompleteRef?.handleKeyDown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter with mention autocomplete open selects the mention
|
||||
if (showMentions && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
autocompleteRef?.handleKeyDown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// If emoji autocomplete is open, let it handle navigation keys
|
||||
if (
|
||||
showEmojiAutocomplete &&
|
||||
["ArrowUp", "ArrowDown", "Tab", "Escape"].includes(e.key)
|
||||
) {
|
||||
emojiAutocompleteRef?.handleKeyDown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter with emoji autocomplete open selects the emoji
|
||||
if (showEmojiAutocomplete && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
emojiAutocompleteRef?.handleKeyDown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send on Enter (without Shift)
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-continue lists on Shift+Enter or regular Enter with list
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
const cursorPos = inputRef?.selectionStart || 0;
|
||||
const textBefore = message.slice(0, cursorPos);
|
||||
const currentLine = textBefore.split("\n").pop() || "";
|
||||
|
||||
// Check for numbered list (1. 2. etc)
|
||||
const numberedMatch = currentLine.match(/^(\s*)(\d+)\.\s/);
|
||||
if (numberedMatch) {
|
||||
e.preventDefault();
|
||||
const indent = numberedMatch[1];
|
||||
const nextNum = parseInt(numberedMatch[2]) + 1;
|
||||
const newText =
|
||||
message.slice(0, cursorPos) +
|
||||
`\n${indent}${nextNum}. ` +
|
||||
message.slice(cursorPos);
|
||||
message = newText;
|
||||
setTimeout(() => {
|
||||
if (inputRef) {
|
||||
inputRef.selectionStart = inputRef.selectionEnd =
|
||||
cursorPos + indent.length + String(nextNum).length + 4;
|
||||
}
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for bullet list (- or *)
|
||||
const bulletMatch = currentLine.match(/^(\s*)([-*])\s/);
|
||||
if (bulletMatch) {
|
||||
e.preventDefault();
|
||||
const indent = bulletMatch[1];
|
||||
const bullet = bulletMatch[2];
|
||||
const newText =
|
||||
message.slice(0, cursorPos) +
|
||||
`\n${indent}${bullet} ` +
|
||||
message.slice(cursorPos);
|
||||
message = newText;
|
||||
setTimeout(() => {
|
||||
if (inputRef) {
|
||||
inputRef.selectionStart = inputRef.selectionEnd =
|
||||
cursorPos + indent.length + 4;
|
||||
}
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for lettered sub-list (a. b. etc)
|
||||
const letteredMatch = currentLine.match(/^(\s*)([a-z])\.\s/);
|
||||
if (letteredMatch) {
|
||||
e.preventDefault();
|
||||
const indent = letteredMatch[1];
|
||||
const nextLetter = String.fromCharCode(
|
||||
letteredMatch[2].charCodeAt(0) + 1,
|
||||
);
|
||||
const newText =
|
||||
message.slice(0, cursorPos) +
|
||||
`\n${indent}${nextLetter}. ` +
|
||||
message.slice(cursorPos);
|
||||
message = newText;
|
||||
setTimeout(() => {
|
||||
if (inputRef) {
|
||||
inputRef.selectionStart = inputRef.selectionEnd =
|
||||
cursorPos + indent.length + 5;
|
||||
}
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send message or save edit
|
||||
async function handleSend() {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed || isSending || disabled) return;
|
||||
|
||||
// Convert emoji shortcodes like :heart: to actual emojis
|
||||
const processedMessage = convertEmojiShortcodes(trimmed);
|
||||
|
||||
// Handle edit mode
|
||||
if (editingMessage) {
|
||||
if (processedMessage === editingMessage.content) {
|
||||
// No changes, just cancel
|
||||
onCancelEdit?.();
|
||||
message = "";
|
||||
return;
|
||||
}
|
||||
onSaveEdit?.(processedMessage);
|
||||
message = "";
|
||||
if (inputRef) {
|
||||
inputRef.style.height = "auto";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
isSending = true;
|
||||
|
||||
// Clear typing indicator
|
||||
if (typingTimeout) {
|
||||
clearTimeout(typingTimeout);
|
||||
typingTimeout = null;
|
||||
}
|
||||
setTyping(roomId, false).catch(console.error);
|
||||
|
||||
// Create a temporary event ID for the pending message
|
||||
const tempEventId = `pending-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Add pending message immediately (optimistic update)
|
||||
const pendingMessage: Message = {
|
||||
eventId: tempEventId,
|
||||
roomId,
|
||||
sender: $auth.userId || "",
|
||||
senderName: $auth.userId?.split(":")[0]?.replace("@", "") || "You",
|
||||
senderAvatar: null,
|
||||
content: processedMessage,
|
||||
timestamp: Date.now(),
|
||||
type: "text",
|
||||
isEdited: false,
|
||||
isRedacted: false,
|
||||
isPending: true,
|
||||
replyTo: replyTo?.eventId,
|
||||
reactions: new Map(),
|
||||
};
|
||||
|
||||
addPendingMessage(roomId, pendingMessage);
|
||||
message = "";
|
||||
|
||||
// Clear reply
|
||||
onCancelReply?.();
|
||||
|
||||
// Reset textarea height
|
||||
if (inputRef) {
|
||||
inputRef.style.height = "auto";
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sendMessage(
|
||||
roomId,
|
||||
processedMessage,
|
||||
replyTo?.eventId,
|
||||
);
|
||||
// Confirm the pending message with the real event ID
|
||||
if (result?.event_id) {
|
||||
confirmPendingMessage(roomId, tempEventId, result.event_id);
|
||||
} else {
|
||||
// If no event ID returned, just mark as not pending
|
||||
confirmPendingMessage(roomId, tempEventId, tempEventId);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error("Failed to send message:", e);
|
||||
// Remove the pending message on failure
|
||||
removePendingMessage(roomId, tempEventId);
|
||||
toasts.error(e.message || "Failed to send message");
|
||||
} finally {
|
||||
isSending = false;
|
||||
// Refocus after DOM settles from optimistic update
|
||||
await tick();
|
||||
inputRef?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle file selection
|
||||
async function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file || disabled) return;
|
||||
|
||||
// Reset input
|
||||
input.value = "";
|
||||
|
||||
// Check file size (50MB limit)
|
||||
const maxSize = 50 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
toasts.error("File too large. Maximum size is 50MB.");
|
||||
return;
|
||||
}
|
||||
|
||||
isUploading = true;
|
||||
try {
|
||||
toasts.info(`Uploading ${file.name}...`);
|
||||
const contentUri = await uploadFile(file);
|
||||
await sendFileMessage(roomId, file, contentUri);
|
||||
toasts.success("File sent!");
|
||||
} catch (e: any) {
|
||||
console.error("Failed to upload file:", e);
|
||||
toasts.error(e.message || "Failed to upload file");
|
||||
} finally {
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openFilePicker() {
|
||||
fileInputRef?.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="border-t border-light/10">
|
||||
<!-- Edit preview -->
|
||||
{#if editingMessage}
|
||||
<div class="px-4 pt-3 pb-0">
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-2 bg-yellow-500/10 rounded-lg border-l-2 border-yellow-500"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-yellow-400 font-medium">Editing message</p>
|
||||
<p class="text-sm text-light/60 truncate">{editingMessage.content}</p>
|
||||
</div>
|
||||
<button
|
||||
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light rounded transition-colors"
|
||||
onclick={() => {
|
||||
onCancelEdit?.();
|
||||
message = "";
|
||||
}}
|
||||
title="Cancel edit"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Reply preview -->
|
||||
{#if replyTo && !editingMessage}
|
||||
<div class="px-4 pt-3 pb-0">
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-2 bg-light/5 rounded-lg border-l-2 border-primary"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-primary font-medium">
|
||||
Replying to {replyTo.senderName}
|
||||
</p>
|
||||
<p class="text-sm text-light/60 truncate">{replyTo.content}</p>
|
||||
</div>
|
||||
<button
|
||||
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light rounded transition-colors"
|
||||
onclick={() => onCancelReply?.()}
|
||||
title="Cancel reply"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="p-4 flex items-end gap-3">
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
bind:this={fileInputRef}
|
||||
type="file"
|
||||
class="hidden"
|
||||
onchange={handleFileSelect}
|
||||
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.txt,.zip"
|
||||
/>
|
||||
|
||||
<!-- Attachment button -->
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded-full transition-colors shrink-0"
|
||||
class:animate-pulse={isUploading}
|
||||
title="Add attachment"
|
||||
onclick={openFilePicker}
|
||||
disabled={disabled || isUploading}
|
||||
>
|
||||
{#if isUploading}
|
||||
<svg class="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="16" />
|
||||
<line x1="8" y1="12" x2="16" y2="12" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Input area -->
|
||||
<div class="flex-1 relative">
|
||||
<!-- Mention autocomplete -->
|
||||
{#if showMentions}
|
||||
<MentionAutocomplete
|
||||
bind:this={autocompleteRef}
|
||||
members={roomMembers}
|
||||
query={mentionQuery}
|
||||
onSelect={handleMentionSelect}
|
||||
onClose={() => (showMentions = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Emoji autocomplete -->
|
||||
{#if showEmojiAutocomplete}
|
||||
<EmojiAutocomplete
|
||||
bind:this={emojiAutocompleteRef}
|
||||
query={emojiQuery}
|
||||
onSelect={handleEmojiSelect}
|
||||
onClose={() => (showEmojiAutocomplete = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Input wrapper with emoji button inside -->
|
||||
<div class="relative flex items-end">
|
||||
<!-- Emoji preview overlay - shows rendered Twemoji -->
|
||||
{#if message && hasEmoji(message)}
|
||||
<div
|
||||
class="absolute inset-0 pl-4 pr-12 py-3 pointer-events-none overflow-hidden rounded-2xl text-light whitespace-pre-wrap break-words"
|
||||
style="min-height: 48px; max-height: 200px; line-height: 1.5;"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{@html renderEmojiPreview(message)}
|
||||
</div>
|
||||
{/if}
|
||||
<textarea
|
||||
bind:this={inputRef}
|
||||
bind:value={message}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeyDown}
|
||||
{placeholder}
|
||||
disabled={disabled || isSending}
|
||||
rows="1"
|
||||
class="w-full pl-4 pr-12 py-3 bg-dark rounded-2xl border border-light/20
|
||||
placeholder:text-light/40 resize-none overflow-hidden
|
||||
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors {message && hasEmoji(message)
|
||||
? 'text-transparent caret-light'
|
||||
: 'text-light'}"
|
||||
style="min-height: 48px; max-height: 200px;"
|
||||
></textarea>
|
||||
|
||||
<!-- Emoji button inside input -->
|
||||
<button
|
||||
bind:this={emojiButtonRef}
|
||||
type="button"
|
||||
class="absolute right-3 bottom-3 w-6 h-6 flex items-center justify-center text-light/40 hover:text-light transition-colors"
|
||||
onclick={() => (showEmojiPicker = !showEmojiPicker)}
|
||||
title="Add emoji"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
||||
<line x1="9" y1="9" x2="9.01" y2="9" />
|
||||
<line x1="15" y1="9" x2="15.01" y2="9" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Emoji Picker -->
|
||||
{#if showEmojiPicker}
|
||||
<div class="absolute bottom-full right-0 mb-2">
|
||||
<EmojiPicker
|
||||
onSelect={(emoji) => {
|
||||
message += emoji;
|
||||
inputRef?.focus();
|
||||
}}
|
||||
onClose={() => (showEmojiPicker = false)}
|
||||
position={{ x: 0, y: 0 }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Send button -->
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full transition-all shrink-0
|
||||
{message.trim()
|
||||
? 'bg-primary text-white hover:brightness-110'
|
||||
: 'bg-light/10 text-light/30 cursor-not-allowed'}"
|
||||
onclick={handleSend}
|
||||
disabled={!message.trim() || isSending || disabled}
|
||||
title="Send message"
|
||||
>
|
||||
{#if isSending}
|
||||
<svg class="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Character count (optional, show when > 1000) -->
|
||||
{#if message.length > 1000}
|
||||
<div
|
||||
class="text-right text-xs mt-1 {message.length > 4000
|
||||
? 'text-red-400'
|
||||
: 'text-light/40'}"
|
||||
>
|
||||
{message.length} / 4000
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
478
src/lib/components/matrix/MessageList.svelte
Normal file
478
src/lib/components/matrix/MessageList.svelte
Normal file
@@ -0,0 +1,478 @@
|
||||
<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>
|
||||
261
src/lib/components/matrix/RoomInfoPanel.svelte
Normal file
261
src/lib/components/matrix/RoomInfoPanel.svelte
Normal file
@@ -0,0 +1,261 @@
|
||||
<script lang="ts">
|
||||
import { Avatar } from "$lib/components/ui";
|
||||
import RoomSettingsModal from "./RoomSettingsModal.svelte";
|
||||
import {
|
||||
getRoomNotificationLevel,
|
||||
setRoomNotificationLevel,
|
||||
} from "$lib/matrix";
|
||||
import { toasts } from "$lib/stores/ui";
|
||||
import type { RoomSummary, RoomMember } from "$lib/matrix/types";
|
||||
|
||||
interface Props {
|
||||
room: RoomSummary;
|
||||
members: RoomMember[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { room, members, onClose }: Props = $props();
|
||||
|
||||
let showSettings = $state(false);
|
||||
let isMuted = $state(getRoomNotificationLevel(room.roomId) === "mute");
|
||||
let isTogglingMute = $state(false);
|
||||
|
||||
// Group members by role
|
||||
const admins = $derived(members.filter((m) => m.powerLevel >= 100));
|
||||
const moderators = $derived(
|
||||
members.filter((m) => m.powerLevel >= 50 && m.powerLevel < 100),
|
||||
);
|
||||
const regularMembers = $derived(members.filter((m) => m.powerLevel < 50));
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleMute() {
|
||||
isTogglingMute = true;
|
||||
try {
|
||||
const newLevel = isMuted ? "all" : "mute";
|
||||
await setRoomNotificationLevel(room.roomId, newLevel);
|
||||
isMuted = !isMuted;
|
||||
toasts.success(isMuted ? "Room muted" : "Room unmuted");
|
||||
} catch (e) {
|
||||
toasts.error("Failed to change notification settings");
|
||||
} finally {
|
||||
isTogglingMute = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="h-full flex flex-col bg-dark/50">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-light/10 flex items-center justify-between">
|
||||
<h2 class="font-semibold text-light">Room Info</h2>
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded 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>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
<!-- Room Avatar & Name -->
|
||||
<div class="text-center">
|
||||
<div class="flex justify-center mb-3">
|
||||
<Avatar src={room.avatarUrl} name={room.name} size="xl" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-light">{room.name}</h3>
|
||||
{#if room.topic}
|
||||
<p class="text-sm text-light/60 mt-2">{room.topic}</p>
|
||||
{/if}
|
||||
<button
|
||||
class="mt-3 px-4 py-1.5 text-sm text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
onclick={() => (showSettings = true)}
|
||||
>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
|
||||
/>
|
||||
</svg>
|
||||
Edit Settings
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="mt-2 px-4 py-1.5 text-sm rounded-lg transition-colors {isMuted
|
||||
? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
|
||||
: 'text-light/60 hover:text-light hover:bg-light/10'}"
|
||||
onclick={toggleMute}
|
||||
disabled={isTogglingMute}
|
||||
>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{#if isMuted}
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5z" />
|
||||
<line x1="23" y1="9" x2="17" y2="15" />
|
||||
<line x1="17" y1="9" x2="23" y2="15" />
|
||||
</svg>
|
||||
Muted
|
||||
{:else}
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path
|
||||
d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"
|
||||
/>
|
||||
</svg>
|
||||
Notifications On
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Room Stats -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-night rounded-lg p-3 text-center">
|
||||
<p class="text-2xl font-bold text-light">{room.memberCount}</p>
|
||||
<p class="text-xs text-light/50">Members</p>
|
||||
</div>
|
||||
<div class="bg-night rounded-lg p-3 text-center">
|
||||
<p class="text-2xl font-bold text-light">
|
||||
{room.isEncrypted ? "🔒" : "🔓"}
|
||||
</p>
|
||||
<p class="text-xs text-light/50">
|
||||
{room.isEncrypted ? "Encrypted" : "Not Encrypted"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Room Details -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-semibold text-light/40 uppercase tracking-wider">
|
||||
Details
|
||||
</h4>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-light/50">Room ID</span>
|
||||
<span
|
||||
class="text-light font-mono text-xs truncate max-w-[150px]"
|
||||
title={room.roomId}
|
||||
>
|
||||
{room.roomId}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-light/50">Type</span>
|
||||
<span class="text-light"
|
||||
>{room.isDirect ? "Direct Message" : "Room"}</span
|
||||
>
|
||||
</div>
|
||||
{#if room.lastActivity}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-light/50">Last Activity</span>
|
||||
<span class="text-light">{formatDate(room.lastActivity)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members by Role -->
|
||||
{#if admins.length > 0}
|
||||
<div class="space-y-2">
|
||||
<h4
|
||||
class="text-sm font-semibold text-light/40 uppercase tracking-wider"
|
||||
>
|
||||
Admins ({admins.length})
|
||||
</h4>
|
||||
<ul class="space-y-1">
|
||||
{#each admins as member}
|
||||
<li
|
||||
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-light/5"
|
||||
>
|
||||
<Avatar src={member.avatarUrl} name={member.name} size="xs" />
|
||||
<span class="text-sm text-light truncate">{member.name}</span>
|
||||
<span class="ml-auto text-xs text-yellow-400">👑</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if moderators.length > 0}
|
||||
<div class="space-y-2">
|
||||
<h4
|
||||
class="text-sm font-semibold text-light/40 uppercase tracking-wider"
|
||||
>
|
||||
Moderators ({moderators.length})
|
||||
</h4>
|
||||
<ul class="space-y-1">
|
||||
{#each moderators as member}
|
||||
<li
|
||||
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-light/5"
|
||||
>
|
||||
<Avatar src={member.avatarUrl} name={member.name} size="xs" />
|
||||
<span class="text-sm text-light truncate">{member.name}</span>
|
||||
<span class="ml-auto text-xs text-blue-400">🛡️</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-semibold text-light/40 uppercase tracking-wider">
|
||||
Members ({regularMembers.length})
|
||||
</h4>
|
||||
<ul class="space-y-1">
|
||||
{#each regularMembers.slice(0, 20) as member}
|
||||
<li
|
||||
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-light/5"
|
||||
>
|
||||
<Avatar src={member.avatarUrl} name={member.name} size="xs" />
|
||||
<span class="text-sm text-light truncate">{member.name}</span>
|
||||
</li>
|
||||
{/each}
|
||||
{#if regularMembers.length > 20}
|
||||
<li class="text-xs text-light/40 text-center py-2">
|
||||
+{regularMembers.length - 20} more members
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showSettings}
|
||||
<RoomSettingsModal {room} onClose={() => (showSettings = false)} />
|
||||
{/if}
|
||||
187
src/lib/components/matrix/RoomSettingsModal.svelte
Normal file
187
src/lib/components/matrix/RoomSettingsModal.svelte
Normal file
@@ -0,0 +1,187 @@
|
||||
<script lang="ts">
|
||||
import { Avatar } from "$lib/components/ui";
|
||||
import { setRoomName, setRoomTopic, setRoomAvatar } from "$lib/matrix";
|
||||
import { toasts } from "$lib/stores/ui";
|
||||
import { syncRoomsFromEvent } from "$lib/stores/matrix";
|
||||
import type { RoomSummary } from "$lib/matrix/types";
|
||||
|
||||
interface Props {
|
||||
room: RoomSummary;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { room, onClose }: Props = $props();
|
||||
|
||||
let name = $state(room.name);
|
||||
let topic = $state(room.topic || "");
|
||||
let isSaving = $state(false);
|
||||
let avatarFile = $state<File | null>(null);
|
||||
let avatarPreview = $state<string | null>(null);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
|
||||
function handleAvatarChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
avatarFile = file;
|
||||
avatarPreview = URL.createObjectURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
isSaving = true;
|
||||
try {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
if (name !== room.name) {
|
||||
promises.push(setRoomName(room.roomId, name));
|
||||
}
|
||||
|
||||
if (topic !== (room.topic || "")) {
|
||||
promises.push(setRoomTopic(room.roomId, topic));
|
||||
}
|
||||
|
||||
if (avatarFile) {
|
||||
promises.push(setRoomAvatar(room.roomId, avatarFile));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
syncRoomsFromEvent("update", room.roomId);
|
||||
toasts.success("Room settings updated");
|
||||
onClose();
|
||||
} catch (e) {
|
||||
console.error("Failed to update room settings:", e);
|
||||
toasts.error("Failed to update room settings");
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
const hasChanges = $derived(
|
||||
name !== room.name || topic !== (room.topic || "") || avatarFile !== null,
|
||||
);
|
||||
</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="settings-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
|
||||
role="document"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 id="settings-title" class="text-xl font-bold text-light">
|
||||
Room Settings
|
||||
</h2>
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded 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 flex-col items-center mb-6">
|
||||
<div class="relative group">
|
||||
<Avatar src={avatarPreview || room.avatarUrl} {name} size="xl" />
|
||||
<label
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 rounded-full cursor-pointer transition-opacity"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"
|
||||
/>
|
||||
<circle cx="12" cy="13" r="4" />
|
||||
</svg>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onchange={handleAvatarChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-light/40 mt-2">Click to change avatar</p>
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="room-name"
|
||||
class="block text-sm font-medium text-light/60 mb-1"
|
||||
>
|
||||
Room Name
|
||||
</label>
|
||||
<input
|
||||
id="room-name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
class="w-full px-4 py-2 bg-night text-light rounded-lg border border-light/20 placeholder:text-light/40 focus:outline-none focus:border-primary"
|
||||
placeholder="Enter room name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Topic -->
|
||||
<div class="mb-6">
|
||||
<label
|
||||
for="room-topic"
|
||||
class="block text-sm font-medium text-light/60 mb-1"
|
||||
>
|
||||
Topic
|
||||
</label>
|
||||
<textarea
|
||||
id="room-topic"
|
||||
bind:value={topic}
|
||||
rows="3"
|
||||
class="w-full px-4 py-2 bg-night text-light rounded-lg border border-light/20 placeholder:text-light/40 focus:outline-none focus:border-primary resize-none"
|
||||
placeholder="What's this room about?"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="flex-1 px-4 py-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
onclick={onClose}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick={handleSave}
|
||||
disabled={isSaving || !hasChanges}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
139
src/lib/components/matrix/StartDMModal.svelte
Normal file
139
src/lib/components/matrix/StartDMModal.svelte
Normal file
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import { Avatar } from '$lib/components/ui';
|
||||
import { searchUsers, createDirectMessage } from '$lib/matrix';
|
||||
import { toasts } from '$lib/stores/ui';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onDMCreated: (roomId: string) => void;
|
||||
}
|
||||
|
||||
let { onClose, onDMCreated }: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<Array<{ userId: string; displayName: string; avatarUrl: string | null }>>([]);
|
||||
let isSearching = $state(false);
|
||||
let isCreating = $state(false);
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function handleSearch() {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
|
||||
if (!searchQuery.trim()) {
|
||||
searchResults = [];
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async () => {
|
||||
isSearching = true;
|
||||
try {
|
||||
searchResults = await searchUsers(searchQuery);
|
||||
} catch (e) {
|
||||
console.error('Search failed:', e);
|
||||
} finally {
|
||||
isSearching = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function handleStartDM(userId: string) {
|
||||
isCreating = true;
|
||||
try {
|
||||
const roomId = await createDirectMessage(userId);
|
||||
toasts.success('Direct message started!');
|
||||
onDMCreated(roomId);
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
console.error('Failed to create DM:', e);
|
||||
toasts.error(e.message || 'Failed to start direct message');
|
||||
} finally {
|
||||
isCreating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onclick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="document"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-light mb-4">Start a Direct Message</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-light/40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
oninput={handleSearch}
|
||||
placeholder="Search users by name or @user:server"
|
||||
class="w-full pl-9 pr-4 py-3 bg-night text-light rounded-lg border border-light/20 placeholder:text-light/40 focus:outline-none focus:border-primary"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
{#if isSearching}
|
||||
<div class="text-center py-8 text-light/40">
|
||||
<svg class="w-6 h-6 animate-spin mx-auto mb-2" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Searching...
|
||||
</div>
|
||||
{:else if searchResults.length > 0}
|
||||
<ul class="space-y-1">
|
||||
{#each searchResults as user}
|
||||
<li>
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-light/5 transition-colors text-left disabled:opacity-50"
|
||||
onclick={() => handleStartDM(user.userId)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<Avatar src={user.avatarUrl} name={user.displayName} size="sm" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-light font-medium truncate">{user.displayName}</p>
|
||||
<p class="text-xs text-light/40 truncate">{user.userId}</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if searchQuery}
|
||||
<p class="text-center py-8 text-light/40">No users found</p>
|
||||
{:else}
|
||||
<p class="text-center py-8 text-light/40">
|
||||
Search for a user to start a conversation
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
class="px-4 py-2 text-light/60 hover:text-light transition-colors"
|
||||
onclick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
113
src/lib/components/matrix/SyncRecoveryBanner.svelte
Normal file
113
src/lib/components/matrix/SyncRecoveryBanner.svelte
Normal file
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import { syncState, syncError, clearState } from "$lib/stores/matrix";
|
||||
import { clearAllCache } from "$lib/cache";
|
||||
|
||||
interface Props {
|
||||
onHardRefresh?: () => void;
|
||||
}
|
||||
|
||||
let { onHardRefresh }: Props = $props();
|
||||
|
||||
let isRefreshing = $state(false);
|
||||
let dismissed = $state(false);
|
||||
let consecutiveErrors = $state(0);
|
||||
|
||||
// Track consecutive sync errors
|
||||
$effect(() => {
|
||||
if ($syncState === "ERROR") {
|
||||
consecutiveErrors++;
|
||||
} else if ($syncState === "SYNCING" || $syncState === "PREPARED") {
|
||||
consecutiveErrors = 0;
|
||||
dismissed = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Show banner after 3+ consecutive errors
|
||||
const shouldShow = $derived(
|
||||
!dismissed && consecutiveErrors >= 3 && $syncState === "ERROR",
|
||||
);
|
||||
|
||||
async function handleHardRefresh() {
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
// Clear local cache
|
||||
await clearAllCache();
|
||||
|
||||
// Clear in-memory state
|
||||
clearState();
|
||||
|
||||
// Trigger callback for full re-sync
|
||||
onHardRefresh?.();
|
||||
|
||||
// Reload the page for clean state
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("[SyncRecovery] Hard refresh failed:", error);
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
dismissed = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if shouldShow}
|
||||
<div
|
||||
class="fixed top-4 left-1/2 -translate-x-1/2 z-50 max-w-md w-full mx-4
|
||||
bg-red-900/90 backdrop-blur-sm border border-red-500/50
|
||||
rounded-lg shadow-xl p-4 animate-in slide-in-from-top duration-300"
|
||||
role="alert"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<span
|
||||
class="material-symbols-rounded text-red-400 flex-shrink-0 mt-0.5"
|
||||
style="font-size: 20px;">warning</span
|
||||
>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-red-100">Sync Connection Lost</h3>
|
||||
<p class="text-sm text-red-200/80 mt-1">
|
||||
{$syncError ||
|
||||
"Unable to sync with the server. Your messages may be outdated."}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-2 mt-3">
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-1.5 bg-red-600 hover:bg-red-500
|
||||
text-white text-sm font-medium rounded-md transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick={handleHardRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded {isRefreshing
|
||||
? 'animate-spin'
|
||||
: ''}"
|
||||
style="font-size: 16px;">refresh</span
|
||||
>
|
||||
{isRefreshing ? "Refreshing..." : "Hard Refresh"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="px-3 py-1.5 text-red-200 hover:text-white text-sm transition-colors"
|
||||
onclick={handleDismiss}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="text-red-400 hover:text-red-200 transition-colors"
|
||||
onclick={handleDismiss}
|
||||
aria-label="Close"
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 20px;"
|
||||
>close</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
27
src/lib/components/matrix/TypingIndicator.svelte
Normal file
27
src/lib/components/matrix/TypingIndicator.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
userNames: string[];
|
||||
}
|
||||
|
||||
let { userNames }: Props = $props();
|
||||
|
||||
function formatTypingText(names: string[]): string {
|
||||
if (names.length === 0) return '';
|
||||
if (names.length === 1) return `${names[0]} is typing`;
|
||||
if (names.length === 2) return `${names[0]} and ${names[1]} are typing`;
|
||||
if (names.length === 3) return `${names[0]}, ${names[1]}, and ${names[2]} are typing`;
|
||||
return `${names[0]}, ${names[1]}, and ${names.length - 2} others are typing`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if userNames.length > 0}
|
||||
<div class="flex items-center gap-2 px-4 py-2 text-sm text-light/50">
|
||||
<!-- Animated dots -->
|
||||
<div class="flex gap-1">
|
||||
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 0ms"></span>
|
||||
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 150ms"></span>
|
||||
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 300ms"></span>
|
||||
</div>
|
||||
<span>{formatTypingText(userNames)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
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>
|
||||
12
src/lib/components/matrix/index.ts
Normal file
12
src/lib/components/matrix/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { default as MessageList } from './MessageList.svelte';
|
||||
export { default as MessageInput } from './MessageInput.svelte';
|
||||
export { default as TypingIndicator } from './TypingIndicator.svelte';
|
||||
export { default as CreateRoomModal } from './CreateRoomModal.svelte';
|
||||
export { default as CreateSpaceModal } from './CreateSpaceModal.svelte';
|
||||
export { default as MemberList } from './MemberList.svelte';
|
||||
export { default as StartDMModal } from './StartDMModal.svelte';
|
||||
export { default as RoomInfoPanel } from './RoomInfoPanel.svelte';
|
||||
export { default as RoomSettingsModal } from './RoomSettingsModal.svelte';
|
||||
export { default as UserProfileModal } from './UserProfileModal.svelte';
|
||||
export { default as MatrixProvider } from './MatrixProvider.svelte';
|
||||
export { default as SyncRecoveryBanner } from './SyncRecoveryBanner.svelte';
|
||||
Reference in New Issue
Block a user