feat(matrix-web): add emoji reactions support

- Add MessageReaction type with count, users, and includesMe tracking
- Implement getReactionsForEvent helper to collect m.annotation events
- Add reactToMessage method using m.reaction event type
- Display reactions below message bubbles with toggle support
- Add emoji picker dropdown in message actions (6 quick emojis)
- Clicking existing reaction toggles it on/off

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-29 16:56:10 +01:00
parent 6807543d60
commit 017cb91385
3 changed files with 134 additions and 4 deletions

View file

@ -15,6 +15,7 @@
Image as ImageIcon,
Lock,
Warning,
Smiley,
} from '@manacore/shared-icons';
interface Props {
@ -41,9 +42,18 @@
);
let showActions = $state(false);
let showEmojiPicker = $state(false);
let imageLoading = $state(true);
let imageError = $state(false);
// Quick reaction emojis
const quickEmojis = ['👍', '❤️', '😂', '😮', '😢', '🎉'];
async function handleReaction(emoji: string) {
showEmojiPicker = false;
await matrixStore.reactToMessage(message.id, emoji);
}
// Audio player state
let audioElement: HTMLAudioElement | null = $state(null);
let isPlaying = $state(false);
@ -367,6 +377,25 @@
{/if}
</div>
<!-- Reactions display -->
{#if message.reactions && message.reactions.length > 0}
<div class="flex flex-wrap gap-1 mt-1.5 {message.isOwn ? 'justify-end' : 'justify-start'}">
{#each message.reactions as reaction}
<button
class="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs transition-colors
{reaction.includesMe
? 'bg-primary/20 border border-primary/40 text-primary'
: 'bg-black/5 dark:bg-white/10 border border-black/10 dark:border-white/10 hover:bg-black/10 dark:hover:bg-white/20'}"
title={reaction.users.join(', ')}
onclick={() => handleReaction(reaction.key)}
>
<span>{reaction.key}</span>
<span class="font-medium">{reaction.count}</span>
</button>
{/each}
</div>
{/if}
<!-- Time (shown on hover) -->
<div
class="flex items-center gap-2 mt-1.5 px-1 opacity-0 group-hover:opacity-100 transition-opacity"
@ -378,9 +407,42 @@
{#if showActions && !message.redacted}
<div
class="absolute {message.isOwn
? '-left-24'
: '-right-24'} top-0 flex items-center gap-1 rounded-xl glass p-1.5 shadow-lg"
? '-left-28'
: '-right-28'} top-0 flex items-center gap-1 rounded-xl glass p-1.5 shadow-lg"
>
<!-- Emoji reaction button -->
<div class="relative">
<button
class="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
title="Reaktion"
onclick={() => (showEmojiPicker = !showEmojiPicker)}
>
<Smiley class="h-4 w-4 text-muted-foreground" />
</button>
{#if showEmojiPicker}
<!-- Emoji picker backdrop -->
<button
class="fixed inset-0 z-40"
onclick={() => (showEmojiPicker = false)}
aria-label="Schließen"
></button>
<!-- Emoji picker dropdown -->
<div
class="absolute {message.isOwn
? 'right-0'
: 'left-0'} bottom-full mb-2 z-50 flex gap-1 rounded-xl bg-white dark:bg-zinc-800 border border-black/10 dark:border-white/10 p-2 shadow-xl"
>
{#each quickEmojis as emoji}
<button
class="text-xl hover:scale-125 transition-transform p-1"
onclick={() => handleReaction(emoji)}
>
{emoji}
</button>
{/each}
</div>
{/if}
</div>
<button
class="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
title="Antworten"

View file

@ -6,6 +6,7 @@ import type {
SimpleRoom,
SimpleMessage,
MessageType,
MessageReaction,
RoomMember,
VerificationStatus,
DeviceInfo,
@ -120,7 +121,7 @@ class MatrixStore {
messages = $derived<SimpleMessage[]>(
this._timeline
.filter((e) => e.getType() === 'm.room.message')
.map((e) => this.eventToSimpleMessage(e))
.map((e) => this.eventToSimpleMessage(e, this._timeline))
);
/** Users currently typing in current room */
@ -1219,7 +1220,7 @@ class MatrixStore {
/**
* Convert SDK MatrixEvent to SimpleMessage
*/
private eventToSimpleMessage(event: MatrixEvent): SimpleMessage {
private eventToSimpleMessage(event: MatrixEvent, timeline?: MatrixEvent[]): SimpleMessage {
const content = event.getContent();
const relatesTo = content['m.relates_to'];
const msgtype = content.msgtype || 'm.text';
@ -1254,6 +1255,9 @@ class MatrixStore {
}
}
// Collect reactions for this message
const reactions = this.getReactionsForEvent(event.getId() || '', timeline);
return {
id: event.getId() || '',
sender: event.getSender() || '',
@ -1268,9 +1272,62 @@ class MatrixStore {
edited: !!event.replacingEvent(),
redacted: isRedacted,
media,
reactions: reactions.length > 0 ? reactions : undefined,
};
}
/**
* Get reactions for a specific event
*/
private getReactionsForEvent(eventId: string, timeline?: MatrixEvent[]): MessageReaction[] {
if (!timeline || !eventId) return [];
const myUserId = this._client?.getUserId();
const reactionMap = new Map<string, { users: string[]; senders: Set<string> }>();
// Find all m.reaction events that relate to this event
for (const event of timeline) {
if (event.getType() !== 'm.reaction') continue;
const content = event.getContent();
const relatesTo = content['m.relates_to'];
if (
relatesTo?.rel_type === 'm.annotation' &&
relatesTo?.event_id === eventId &&
relatesTo?.key
) {
const emoji = relatesTo.key;
const sender = event.getSender() || '';
if (!reactionMap.has(emoji)) {
reactionMap.set(emoji, { users: [], senders: new Set() });
}
const entry = reactionMap.get(emoji)!;
// Avoid duplicates from same user
if (!entry.senders.has(sender)) {
entry.senders.add(sender);
entry.users.push(sender);
}
}
}
// Convert to MessageReaction array
const reactions: MessageReaction[] = [];
for (const [key, data] of reactionMap) {
reactions.push({
key,
count: data.users.length,
users: data.users,
includesMe: myUserId ? data.senders.has(myUserId) : false,
});
}
// Sort by count descending
return reactions.sort((a, b) => b.count - a.count);
}
/**
* Get display name for message sender
*/

View file

@ -29,6 +29,16 @@ export interface MediaInfo {
duration?: number; // For audio/video
}
/**
* Reaction on a message
*/
export interface MessageReaction {
key: string; // The emoji
count: number;
users: string[]; // User IDs who reacted
includesMe: boolean;
}
/**
* Simplified message for UI rendering
*/
@ -46,6 +56,7 @@ export interface SimpleMessage {
edited?: boolean;
redacted?: boolean;
media?: MediaInfo;
reactions?: MessageReaction[];
}
export type MessageType =