mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 00:17:02 +02:00
Add subtle inline reactions to chat messages
Implements NIP-25 reaction display for chat messages with per-message lazy loading: - Created MessageReactions component that independently loads kind 7 reactions for each message using EventStore timeline queries - Displays reactions as tiny inline badges in bottom-right corner (doesn't affect message height) - Aggregates reactions by emoji with deduplication by pubkey - Supports both unicode emoji and NIP-30 custom emoji with images - Shows reaction count next to each emoji - Integrated into both regular user messages and zap messages in ChatViewer - Reactions load reactively - new reactions appear automatically via EventStore observables No "+" button for adding reactions yet - this is display-only for now. Works with NIP-29 groups and will work with any chat protocol that uses kind 7 reactions with e-tags.
This commit is contained in:
@@ -37,6 +37,7 @@ import Timestamp from "./Timestamp";
|
||||
import { ReplyPreview } from "./chat/ReplyPreview";
|
||||
import { MembersDropdown } from "./chat/MembersDropdown";
|
||||
import { RelaysDropdown } from "./chat/RelaysDropdown";
|
||||
import { MessageReactions } from "./chat/MessageReactions";
|
||||
import { StatusBadge } from "./live/StatusBadge";
|
||||
import { ChatMessageContextMenu } from "./chat/ChatMessageContextMenu";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
@@ -285,7 +286,7 @@ const MessageItem = memo(function MessageItem({
|
||||
(CHAT_KINDS as readonly number[]).includes(replyEvent.kind);
|
||||
|
||||
return (
|
||||
<div className="pl-2 my-1">
|
||||
<div className="pl-2 my-1 relative">
|
||||
<div
|
||||
className="p-[1px] rounded"
|
||||
style={{
|
||||
@@ -332,13 +333,18 @@ const MessageItem = memo(function MessageItem({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Reactions display - lazy loaded per message */}
|
||||
<MessageReactions
|
||||
messageId={message.id}
|
||||
relayUrl={conversation.metadata?.relayUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular user messages - wrap in context menu if event exists
|
||||
const messageContent = (
|
||||
<div className="group flex items-start hover:bg-muted/50 px-3">
|
||||
<div className="group relative flex items-start hover:bg-muted/50 px-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserName pubkey={message.author} className="font-semibold text-sm" />
|
||||
@@ -373,6 +379,11 @@ const MessageItem = memo(function MessageItem({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Reactions display - lazy loaded per message */}
|
||||
<MessageReactions
|
||||
messageId={message.id}
|
||||
relayUrl={conversation.metadata?.relayUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
121
src/components/chat/MessageReactions.tsx
Normal file
121
src/components/chat/MessageReactions.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useMemo } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import eventStore from "@/services/event-store";
|
||||
import { EMOJI_SHORTCODE_REGEX } from "@/lib/emoji-helpers";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
|
||||
interface MessageReactionsProps {
|
||||
messageId: string;
|
||||
/** Relay URL for fetching reactions (NIP-29 group relay) */
|
||||
relayUrl?: string;
|
||||
}
|
||||
|
||||
interface ReactionSummary {
|
||||
emoji: string;
|
||||
count: number;
|
||||
pubkeys: string[];
|
||||
customEmoji?: {
|
||||
shortcode: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MessageReactions - Lazy loads and displays reactions for a single message
|
||||
*
|
||||
* Loads kind 7 (reaction) events that reference the messageId via e-tag.
|
||||
* Aggregates by emoji and displays as tiny inline badges in bottom-right corner.
|
||||
*
|
||||
* Uses EventStore timeline for reactive updates - new reactions appear automatically.
|
||||
*/
|
||||
export function MessageReactions({
|
||||
messageId,
|
||||
relayUrl,
|
||||
}: MessageReactionsProps) {
|
||||
// Load reactions for this message from EventStore
|
||||
// Filter: kind 7, e-tag pointing to messageId
|
||||
const reactions = use$(
|
||||
() =>
|
||||
eventStore.timeline({
|
||||
kinds: [7],
|
||||
"#e": [messageId],
|
||||
}),
|
||||
[messageId],
|
||||
);
|
||||
|
||||
// Aggregate reactions by emoji
|
||||
const aggregated = useMemo(() => {
|
||||
if (!reactions || reactions.length === 0) return [];
|
||||
|
||||
const map = new Map<string, ReactionSummary>();
|
||||
|
||||
for (const reaction of reactions) {
|
||||
const content = reaction.content || "❤️";
|
||||
|
||||
// Check for NIP-30 custom emoji tags
|
||||
const emojiTag = reaction.tags.find((t) => t[0] === "emoji");
|
||||
let customEmoji: { shortcode: string; url: string } | undefined;
|
||||
|
||||
if (emojiTag && emojiTag[1] && emojiTag[2]) {
|
||||
customEmoji = {
|
||||
shortcode: emojiTag[1],
|
||||
url: emojiTag[2],
|
||||
};
|
||||
}
|
||||
|
||||
// Parse content for custom emoji shortcodes
|
||||
const match = content.match(EMOJI_SHORTCODE_REGEX);
|
||||
const emojiKey =
|
||||
match && customEmoji ? `:${customEmoji.shortcode}:` : content;
|
||||
|
||||
const existing = map.get(emojiKey);
|
||||
|
||||
if (existing) {
|
||||
// Deduplicate by pubkey (one reaction per user per emoji)
|
||||
if (!existing.pubkeys.includes(reaction.pubkey)) {
|
||||
existing.count++;
|
||||
existing.pubkeys.push(reaction.pubkey);
|
||||
}
|
||||
} else {
|
||||
map.set(emojiKey, {
|
||||
emoji: content,
|
||||
count: 1,
|
||||
pubkeys: [reaction.pubkey],
|
||||
customEmoji,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by count descending, then by emoji alphabetically
|
||||
return Array.from(map.values()).sort((a, b) => {
|
||||
if (b.count !== a.count) return b.count - a.count;
|
||||
return a.emoji.localeCompare(b.emoji);
|
||||
});
|
||||
}, [reactions]);
|
||||
|
||||
// Don't render if no reactions
|
||||
if (aggregated.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-0.5 right-1 flex gap-0.5">
|
||||
{aggregated.map((reaction) => (
|
||||
<span
|
||||
key={reaction.customEmoji?.shortcode || reaction.emoji}
|
||||
className="inline-flex items-center gap-0.5 px-1 rounded bg-muted/80 text-[10px] leading-tight"
|
||||
title={`${reaction.count} reaction${reaction.count > 1 ? "s" : ""}`}
|
||||
>
|
||||
{reaction.customEmoji ? (
|
||||
<img
|
||||
src={reaction.customEmoji.url}
|
||||
alt={`:${reaction.customEmoji.shortcode}:`}
|
||||
className="size-3 inline-block"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs">{reaction.emoji}</span>
|
||||
)}
|
||||
<span className="text-muted-foreground">{reaction.count}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.6.3"}
|
||||
{"root":["./vite.config.ts"],"errors":true,"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user