mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 06:19:41 +02:00
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:
parent
6807543d60
commit
017cb91385
3 changed files with 134 additions and 4 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue