feat(matrix-web): add @mention autocomplete, message forwarding, and improved typing indicator

@mention autocomplete:
- Detect @ symbol while typing and show user picker
- Search room members by name or user ID
- Arrow key navigation and Enter/Tab to select
- Insert display name into message

Message forwarding:
- Add forward button to message actions
- ForwardMessageDialog with room selection
- Multi-select support for forwarding to multiple rooms
- Search and filter rooms

Typing indicator improvements:
- Show user avatars (stacked, up to 3)
- Improved visual design with rounded pill for dots
- Better spacing and alignment

Also adds:
- sendMessageToRoom() store method for forwarding
This commit is contained in:
Till-JS 2026-01-29 22:54:00 +01:00
parent 60cc0be10b
commit 7b2ac78032
7 changed files with 408 additions and 14 deletions

View file

@ -0,0 +1,173 @@
<script lang="ts">
import { matrixStore, type SimpleMessage, type SimpleRoom } from '$lib/matrix';
import { X, MagnifyingGlass, PaperPlaneTilt, User, Users } from '@manacore/shared-icons';
interface Props {
open: boolean;
message: SimpleMessage | null;
onClose: () => void;
}
let { open, message, onClose }: Props = $props();
let search = $state('');
let sending = $state(false);
let selectedRooms = $state<Set<string>>(new Set());
// Filter rooms by search
let filteredRooms = $derived(
matrixStore.rooms
.filter(
(room) =>
room.membership === 'join' &&
room.id !== matrixStore.currentRoomId &&
room.name.toLowerCase().includes(search.toLowerCase())
)
.slice(0, 20)
);
function toggleRoom(roomId: string) {
const newSet = new Set(selectedRooms);
if (newSet.has(roomId)) {
newSet.delete(roomId);
} else {
newSet.add(roomId);
}
selectedRooms = newSet;
}
async function handleForward() {
if (!message || selectedRooms.size === 0) return;
sending = true;
// Forward to each selected room
for (const roomId of selectedRooms) {
// Create forward message with quote
const forwardText = `> ${message.senderName}: ${message.body}\n\nWeitergeleitete Nachricht`;
await matrixStore.sendMessageToRoom(roomId, forwardText);
}
sending = false;
selectedRooms = new Set();
search = '';
onClose();
}
function handleClose() {
selectedRooms = new Set();
search = '';
onClose();
}
</script>
{#if open && message}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
>
<!-- Dialog -->
<div
class="w-full max-w-md rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl overflow-hidden"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-black/10 dark:border-white/10 px-4 py-3">
<h2 class="text-lg font-semibold">Nachricht weiterleiten</h2>
<button
class="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
onclick={handleClose}
>
<X class="h-5 w-5" />
</button>
</div>
<!-- Message Preview -->
<div class="px-4 py-3 bg-black/5 dark:bg-white/5 border-b border-black/5 dark:border-white/5">
<p class="text-xs text-muted-foreground mb-1">Von {message.senderName}</p>
<p class="text-sm line-clamp-3">{message.body}</p>
</div>
<!-- Search -->
<div class="p-4 border-b border-black/5 dark:border-white/5">
<div class="relative">
<MagnifyingGlass class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
bind:value={search}
placeholder="Chat suchen..."
class="w-full pl-10 pr-4 py-2.5 rounded-xl bg-black/5 dark:bg-white/10 border border-black/10 dark:border-white/10
text-sm focus:outline-none focus:ring-2 focus:ring-violet-500"
/>
</div>
</div>
<!-- Room List -->
<div class="max-h-64 overflow-y-auto">
{#if filteredRooms.length === 0}
<p class="px-4 py-8 text-center text-muted-foreground">Keine Chats gefunden</p>
{:else}
{#each filteredRooms as room (room.id)}
<button
class="flex items-center gap-3 w-full px-4 py-3 transition-colors text-left
{selectedRooms.has(room.id) ? 'bg-violet-500/10' : 'hover:bg-black/5 dark:hover:bg-white/5'}"
onclick={() => toggleRoom(room.id)}
>
<!-- Checkbox -->
<div
class="w-5 h-5 rounded-md border-2 flex items-center justify-center transition-colors
{selectedRooms.has(room.id) ? 'bg-violet-500 border-violet-500' : 'border-black/20 dark:border-white/20'}"
>
{#if selectedRooms.has(room.id)}
<svg class="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
{/if}
</div>
<!-- Avatar -->
<div
class="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0
bg-gradient-to-br from-violet-500 to-purple-600 text-white"
>
{#if room.avatar}
<img src={room.avatar} alt={room.name} class="w-10 h-10 rounded-full object-cover" />
{:else if room.isDirect}
<User class="w-5 h-5" />
{:else}
<Users class="w-5 h-5" />
{/if}
</div>
<!-- Room info -->
<div class="flex-1 min-w-0">
<p class="font-medium truncate">{room.name}</p>
<p class="text-xs text-muted-foreground">
{room.isDirect ? 'Direktnachricht' : `${room.memberCount} Mitglieder`}
</p>
</div>
</button>
{/each}
{/if}
</div>
<!-- Footer -->
<div class="flex items-center justify-between border-t border-black/10 dark:border-white/10 px-4 py-3">
<p class="text-sm text-muted-foreground">
{selectedRooms.size} ausgewählt
</p>
<button
class="flex items-center gap-2 px-4 py-2 rounded-xl bg-violet-500 hover:bg-violet-600 text-white font-medium transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
disabled={selectedRooms.size === 0 || sending}
onclick={handleForward}
>
<PaperPlaneTilt class="h-4 w-4" weight="bold" />
{sending ? 'Sende...' : 'Weiterleiten'}
</button>
</div>
</div>
</div>
{/if}

View file

@ -5,6 +5,7 @@
import { de } from 'date-fns/locale';
import {
ArrowBendUpLeft,
ArrowBendUpRight,
PencilSimple,
Trash,
DotsThree,
@ -27,6 +28,7 @@
showEncryptionBadge?: boolean;
onReply?: (message: SimpleMessage) => void;
onEdit?: (message: SimpleMessage) => void;
onForward?: (message: SimpleMessage) => void;
}
let {
@ -36,6 +38,7 @@
showEncryptionBadge = false,
onReply,
onEdit,
onForward,
}: Props = $props();
// Check if message is a decryption error (body starts with "Unable to decrypt:")
@ -623,6 +626,13 @@
>
<ArrowBendUpLeft class="h-4 w-4 text-muted-foreground" />
</button>
<button
class="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
title="Weiterleiten"
onclick={() => onForward?.(message)}
>
<ArrowBendUpRight class="h-4 w-4 text-muted-foreground" />
</button>
{#if message.isOwn && message.type === 'm.text'}
<button
class="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { matrixStore, type SimpleMessage } from '$lib/matrix';
import { matrixStore, type SimpleMessage, type RoomMember } from '$lib/matrix';
import {
PaperPlaneTilt,
Paperclip,
@ -10,6 +10,7 @@
CircleNotch,
Microphone,
Stop,
User,
} from '@manacore/shared-icons';
interface Props {
@ -37,6 +38,13 @@
let audioChunks: Blob[] = [];
let recordingInterval: ReturnType<typeof setInterval> | null = null;
// @mention autocomplete state
let showMentionPicker = $state(false);
let mentionQuery = $state('');
let mentionStartPos = $state(0);
let mentionResults = $state<RoomMember[]>([]);
let selectedMentionIndex = $state(0);
// Set message content when editing
$effect(() => {
if (editMessage) {
@ -87,6 +95,76 @@
// Reset typing timeout
clearTimeout(typingTimeout);
typingTimeout = setTimeout(stopTyping, 3000);
// Check for @mention trigger
checkForMention();
}
function checkForMention() {
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const textBeforeCursor = message.slice(0, cursorPos);
// Find the last @ before cursor
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
if (lastAtIndex !== -1) {
// Check if there's a space before @ (or it's at the start)
const charBefore = lastAtIndex > 0 ? textBeforeCursor[lastAtIndex - 1] : ' ';
if (charBefore === ' ' || charBefore === '\n' || lastAtIndex === 0) {
const query = textBeforeCursor.slice(lastAtIndex + 1);
// No space in the query = still typing the mention
if (!query.includes(' ') && query.length <= 50) {
mentionStartPos = lastAtIndex;
mentionQuery = query;
showMentionPicker = true;
updateMentionResults(query);
return;
}
}
}
// Close mention picker if conditions not met
showMentionPicker = false;
mentionQuery = '';
}
function updateMentionResults(query: string) {
const members = matrixStore.getRoomMembers();
const lowerQuery = query.toLowerCase();
// Filter members by display name or user ID
mentionResults = members
.filter(
(m) =>
m.membership === 'join' &&
(m.displayName.toLowerCase().includes(lowerQuery) ||
m.userId.toLowerCase().includes(lowerQuery))
)
.slice(0, 6); // Limit to 6 results
selectedMentionIndex = 0;
}
function insertMention(member: RoomMember) {
const beforeMention = message.slice(0, mentionStartPos);
const afterMention = message.slice(textarea.selectionStart);
// Insert pill format: @displayName (the actual Matrix pill is sent as formatted HTML)
const mentionText = `@${member.displayName} `;
message = beforeMention + mentionText + afterMention;
// Close picker
showMentionPicker = false;
mentionQuery = '';
// Focus and set cursor position
setTimeout(() => {
textarea.focus();
const newPos = mentionStartPos + mentionText.length;
textarea.setSelectionRange(newPos, newPos);
}, 0);
}
function stopTyping() {
@ -98,6 +176,31 @@
}
function handleKeydown(e: KeyboardEvent) {
// Handle mention picker navigation
if (showMentionPicker && mentionResults.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedMentionIndex = (selectedMentionIndex + 1) % mentionResults.length;
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
selectedMentionIndex =
selectedMentionIndex === 0 ? mentionResults.length - 1 : selectedMentionIndex - 1;
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
insertMention(mentionResults[selectedMentionIndex]);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
showMentionPicker = false;
return;
}
}
// Send on Enter (without Shift)
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@ -308,6 +411,44 @@
</div>
{/if}
<!-- @Mention Picker -->
{#if showMentionPicker && mentionResults.length > 0}
<div
class="mb-2 rounded-xl bg-white dark:bg-zinc-800 border border-black/10 dark:border-white/10 shadow-xl overflow-hidden"
>
<div class="px-3 py-1.5 text-xs text-muted-foreground border-b border-black/5 dark:border-white/5">
Erwähne jemanden
</div>
{#each mentionResults as member, i}
<button
class="flex items-center gap-3 w-full px-3 py-2 transition-colors text-left
{i === selectedMentionIndex ? 'bg-violet-500/10 dark:bg-violet-500/20' : 'hover:bg-black/5 dark:hover:bg-white/5'}"
onclick={() => insertMention(member)}
>
<!-- Avatar -->
{#if member.avatarUrl}
<img
src={member.avatarUrl}
alt={member.displayName}
class="w-8 h-8 rounded-full object-cover"
/>
{:else}
<div
class="w-8 h-8 rounded-full bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center"
>
<User class="w-4 h-4 text-white" />
</div>
{/if}
<!-- Name and ID -->
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">{member.displayName}</p>
<p class="text-xs text-muted-foreground truncate">{member.userId}</p>
</div>
</button>
{/each}
</div>
{/if}
<!-- Input Area -->
<div
class="flex items-end gap-2 rounded-2xl bg-white/80 dark:bg-white/10 backdrop-blur-xl border border-black/5 dark:border-white/10 p-2 shadow-lg"

View file

@ -8,9 +8,10 @@
interface Props {
onReply?: (message: SimpleMessage) => void;
onEdit?: (message: SimpleMessage) => void;
onForward?: (message: SimpleMessage) => void;
}
let { onReply, onEdit }: Props = $props();
let { onReply, onEdit, onForward }: Props = $props();
// Check if current room is encrypted
let isRoomEncrypted = $derived(matrixStore.currentSimpleRoom?.isEncrypted ?? false);
@ -105,6 +106,7 @@
showEncryptionBadge={isRoomEncrypted}
{onReply}
{onEdit}
{onForward}
/>
{:else}
<div class="flex h-full flex-col items-center justify-center text-base-content/50">

View file

@ -1,10 +1,25 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import { User } from '@manacore/shared-icons';
interface Props {
users: string[];
}
let { users }: Props = $props();
// Get full user info from room members
let typingUsers = $derived(() => {
const members = matrixStore.getRoomMembers();
return users.map((name) => {
const member = members.find((m) => m.displayName === name);
return {
name,
avatarUrl: member?.avatarUrl,
};
});
});
let text = $derived(() => {
if (users.length === 0) return '';
if (users.length === 1) return `${users[0]} tippt...`;
@ -14,16 +29,36 @@
</script>
{#if users.length > 0}
<div class="flex items-center gap-2 px-4 py-2 text-sm text-base-content/60">
<div class="flex items-center gap-3 px-4 py-2">
<!-- User avatars (stacked) -->
<div class="flex -space-x-2">
{#each typingUsers().slice(0, 3) as user, i}
{#if user.avatarUrl}
<img
src={user.avatarUrl}
alt={user.name}
class="w-6 h-6 rounded-full border-2 border-white dark:border-zinc-900 object-cover"
style="z-index: {3 - i}"
/>
{:else}
<div
class="w-6 h-6 rounded-full border-2 border-white dark:border-zinc-900 bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center"
style="z-index: {3 - i}"
>
<User class="w-3 h-3 text-white" />
</div>
{/if}
{/each}
</div>
<!-- Animated dots -->
<span class="flex gap-1">
<span class="h-2 w-2 animate-bounce rounded-full bg-base-content/40 [animation-delay:0ms]"
></span>
<span class="h-2 w-2 animate-bounce rounded-full bg-base-content/40 [animation-delay:150ms]"
></span>
<span class="h-2 w-2 animate-bounce rounded-full bg-base-content/40 [animation-delay:300ms]"
></span>
</span>
<span>{text()}</span>
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-black/5 dark:bg-white/10">
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]"></span>
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms]"></span>
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:300ms]"></span>
</div>
<!-- Text -->
<span class="text-sm text-muted-foreground">{text()}</span>
</div>
{/if}

View file

@ -569,6 +569,21 @@ class MatrixStore {
}
}
/**
* Send a message to a specific room (for forwarding)
*/
async sendMessageToRoom(roomId: string, body: string): Promise<boolean> {
if (!this._client) return false;
try {
await this._client.sendTextMessage(roomId, body);
return true;
} catch (err) {
this._error = err instanceof Error ? err.message : 'Failed to send message';
return false;
}
}
/**
* Send typing indicator
*/

View file

@ -4,6 +4,7 @@
import CreateRoomDialog from '$lib/components/chat/CreateRoomDialog.svelte';
import RoomSettingsPanel from '$lib/components/chat/RoomSettingsPanel.svelte';
import SearchDialog from '$lib/components/chat/SearchDialog.svelte';
import ForwardMessageDialog from '$lib/components/chat/ForwardMessageDialog.svelte';
import { CallView, IncomingCallDialog } from '$lib/components/call';
import { ChatCircle, Plus, Gear } from '@manacore/shared-icons';
import { browser } from '$app/environment';
@ -17,10 +18,12 @@
let showCreateRoom = $state(false);
let showRoomSettings = $state(false);
let showSearch = $state(false);
let showForward = $state(false);
// Reply/Edit state
// Reply/Edit/Forward state
let replyTo = $state<SimpleMessage | null>(null);
let editMessage = $state<SimpleMessage | null>(null);
let forwardMessage = $state<SimpleMessage | null>(null);
// Check if mobile
let isMobile = $state(browser ? window.innerWidth < 1024 : false);
@ -71,6 +74,11 @@
editMessage = message;
}
function handleForward(message: SimpleMessage) {
forwardMessage = message;
showForward = true;
}
function handleRoomCreated(roomId: string) {
matrixStore.selectRoom(roomId);
}
@ -174,7 +182,7 @@
/>
<!-- Timeline -->
<Timeline onReply={handleReply} onEdit={handleEdit} />
<Timeline onReply={handleReply} onEdit={handleEdit} onForward={handleForward} />
<!-- Message Input -->
<MessageInput
@ -244,3 +252,13 @@
{#if incomingCall && !activeCall}
<IncomingCallDialog call={incomingCall} onAnswer={handleCallAnswer} onReject={handleCallReject} />
{/if}
<!-- Forward Message Dialog -->
<ForwardMessageDialog
open={showForward}
message={forwardMessage}
onClose={() => {
showForward = false;
forwardMessage = null;
}}
/>