mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 16:37:06 +02:00
Add emoji reaction picker to chat messages
Implements complete reaction functionality with searchable emoji picker: **UI Enhancements:** - Reactions display horizontally with hidden scrollbar (hide-scrollbar CSS utility) - Messages with many reactions scroll smoothly without visible scrollbar - Inline positioning after timestamp for clean, integrated look **Emoji Picker Dialog:** - Real-time search using FlexSearch (EmojiSearchService) - Quick reaction bar with common emojis (❤️ 👍 🔥 😂 🎉 👀 🤔 💯) - Frequently used section based on localStorage history - Support for both unicode and NIP-30 custom emoji - Grid layout with 48-emoji results - Auto-focus search input for keyboard-first UX **Protocol Implementation:** - Added sendReaction() method to ChatProtocolAdapter base class - NIP-29 groups: kind 7 with e-tag + h-tag (group context) - NIP-53 live chats: kind 7 with e-tag + a-tag (activity context) - NIP-C7 DMs: kind 7 with e-tag + p-tag (partner context) - All reactions include k-tag for reacted event kind - NIP-30 custom emoji support via emoji tags **Context Menu Integration:** - Added "React" action to ChatMessageContextMenu with Smile icon - Opens emoji picker dialog on click - Passes conversation and adapter for protocol-specific reactions - Only shows when conversation and adapter are available **Frequently Used Tracking:** - Stores reaction history in localStorage (grimoire:reaction-history) - Displays top 8 most-used reactions when no search query - Increments count on each reaction sent **Tooltips:** - Show emoji + count + truncated pubkeys - Format: "❤️ 3\nabcd1234..., efgh5678..." - Future enhancement: load profiles for display names Ready for testing! Users can now right-click messages → React → search/pick emoji.
This commit is contained in:
@@ -412,6 +412,8 @@ const MessageItem = memo(function MessageItem({
|
||||
<ChatMessageContextMenu
|
||||
event={message.event}
|
||||
onReply={canReply && onReply ? () => onReply(message.id) : undefined}
|
||||
conversation={conversation}
|
||||
adapter={adapter}
|
||||
>
|
||||
{messageContent}
|
||||
</ChatMessageContextMenu>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { NostrEvent } from "@/types/nostr";
|
||||
import type { Conversation } from "@/types/chat";
|
||||
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -15,20 +17,26 @@ import {
|
||||
ExternalLink,
|
||||
Reply,
|
||||
MessageSquare,
|
||||
Smile,
|
||||
} from "lucide-react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import { JsonViewer } from "@/components/JsonViewer";
|
||||
import { KindBadge } from "@/components/KindBadge";
|
||||
import { EmojiPickerDialog } from "./EmojiPickerDialog";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
import { getSeenRelays } from "applesauce-core/helpers/relays";
|
||||
import { isAddressableKind } from "@/lib/nostr-kinds";
|
||||
import { getEmojiTags } from "@/lib/emoji-helpers";
|
||||
import type { EmojiTag } from "@/lib/emoji-helpers";
|
||||
|
||||
interface ChatMessageContextMenuProps {
|
||||
event: NostrEvent;
|
||||
children: React.ReactNode;
|
||||
onReply?: () => void;
|
||||
conversation?: Conversation;
|
||||
adapter?: ChatProtocolAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,10 +52,16 @@ export function ChatMessageContextMenu({
|
||||
event,
|
||||
children,
|
||||
onReply,
|
||||
conversation,
|
||||
adapter,
|
||||
}: ChatMessageContextMenuProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
const { copy, copied } = useCopy();
|
||||
const [jsonDialogOpen, setJsonDialogOpen] = useState(false);
|
||||
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
|
||||
|
||||
// Extract context emojis from the conversation
|
||||
const contextEmojis = getEmojiTags(event);
|
||||
|
||||
const openEventDetail = () => {
|
||||
let pointer;
|
||||
@@ -105,6 +119,25 @@ export function ChatMessageContextMenu({
|
||||
setJsonDialogOpen(true);
|
||||
};
|
||||
|
||||
const openReactionPicker = () => {
|
||||
setEmojiPickerOpen(true);
|
||||
};
|
||||
|
||||
const handleEmojiSelect = async (emoji: string, customEmoji?: EmojiTag) => {
|
||||
if (!conversation || !adapter) {
|
||||
console.error(
|
||||
"[ChatMessageContextMenu] Cannot send reaction: missing conversation or adapter",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await adapter.sendReaction(conversation, event.id, emoji, customEmoji);
|
||||
} catch (err) {
|
||||
console.error("[ChatMessageContextMenu] Failed to send reaction:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
@@ -131,6 +164,15 @@ export function ChatMessageContextMenu({
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{conversation && adapter && (
|
||||
<>
|
||||
<ContextMenuItem onClick={openReactionPicker}>
|
||||
<Smile className="size-4 mr-2" />
|
||||
React
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem onClick={copyMessageText}>
|
||||
<MessageSquare className="size-4 mr-2" />
|
||||
Copy Text
|
||||
@@ -160,6 +202,14 @@ export function ChatMessageContextMenu({
|
||||
onOpenChange={setJsonDialogOpen}
|
||||
title={`Event ${event.id.slice(0, 8)}... - Raw JSON`}
|
||||
/>
|
||||
{conversation && adapter && (
|
||||
<EmojiPickerDialog
|
||||
open={emojiPickerOpen}
|
||||
onOpenChange={setEmojiPickerOpen}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
contextEmojis={contextEmojis}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
210
src/components/chat/EmojiPickerDialog.tsx
Normal file
210
src/components/chat/EmojiPickerDialog.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search } from "lucide-react";
|
||||
import { EmojiSearchService } from "@/services/emoji-search";
|
||||
import { UNICODE_EMOJIS } from "@/lib/unicode-emojis";
|
||||
import type { EmojiSearchResult } from "@/services/emoji-search";
|
||||
import type { EmojiTag } from "@/lib/emoji-helpers";
|
||||
|
||||
interface EmojiPickerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onEmojiSelect: (emoji: string, customEmoji?: EmojiTag) => void;
|
||||
/** Optional context event to extract custom emoji from */
|
||||
contextEmojis?: EmojiTag[];
|
||||
}
|
||||
|
||||
// Frequently used emojis stored in localStorage
|
||||
const STORAGE_KEY = "grimoire:reaction-history";
|
||||
const QUICK_REACTIONS = ["❤️", "👍", "🔥", "😂", "🎉", "👀", "🤔", "💯"];
|
||||
|
||||
function getReactionHistory(): Record<string, number> {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function updateReactionHistory(emoji: string): void {
|
||||
try {
|
||||
const history = getReactionHistory();
|
||||
history[emoji] = (history[emoji] || 0) + 1;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[EmojiPickerDialog] Failed to update reaction history:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EmojiPickerDialog - Searchable emoji picker for reactions
|
||||
*
|
||||
* Features:
|
||||
* - Real-time search using FlexSearch
|
||||
* - Frequently used emoji at top when no search query
|
||||
* - Quick reaction bar for common emojis
|
||||
* - Supports both unicode and NIP-30 custom emoji
|
||||
* - Tracks usage in localStorage
|
||||
*/
|
||||
export function EmojiPickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onEmojiSelect,
|
||||
contextEmojis = [],
|
||||
}: EmojiPickerDialogProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<EmojiSearchResult[]>([]);
|
||||
const [emojiService] = useState(() => new EmojiSearchService());
|
||||
|
||||
// Initialize emoji service with unicode emojis
|
||||
useEffect(() => {
|
||||
// Load unicode emojis
|
||||
emojiService.addUnicodeEmojis(UNICODE_EMOJIS);
|
||||
|
||||
// Load context emojis (from conversation messages)
|
||||
for (const emoji of contextEmojis) {
|
||||
emojiService.addEmoji(emoji.shortcode, emoji.url, "context");
|
||||
}
|
||||
}, [emojiService, contextEmojis]);
|
||||
|
||||
// Search emojis when query changes
|
||||
useEffect(() => {
|
||||
const search = async () => {
|
||||
const results = await emojiService.search(searchQuery, { limit: 48 });
|
||||
setSearchResults(results);
|
||||
};
|
||||
search();
|
||||
}, [searchQuery, emojiService]);
|
||||
|
||||
// Get frequently used emojis from history
|
||||
const frequentlyUsed = useMemo(() => {
|
||||
if (searchQuery.trim()) return []; // Only show when no search query
|
||||
|
||||
const history = getReactionHistory();
|
||||
return Object.entries(history)
|
||||
.sort((a, b) => b[1] - a[1]) // Sort by count descending
|
||||
.slice(0, 8)
|
||||
.map(([emoji]) => emoji);
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleEmojiClick = (result: EmojiSearchResult) => {
|
||||
if (result.source === "unicode") {
|
||||
// For unicode emoji, the "url" field contains the emoji character
|
||||
onEmojiSelect(result.url);
|
||||
updateReactionHistory(result.url);
|
||||
} else {
|
||||
// For custom emoji, pass the shortcode as content and emoji tag info
|
||||
onEmojiSelect(`:${result.shortcode}:`, {
|
||||
shortcode: result.shortcode,
|
||||
url: result.url,
|
||||
});
|
||||
updateReactionHistory(`:${result.shortcode}:`);
|
||||
}
|
||||
onOpenChange(false);
|
||||
setSearchQuery(""); // Reset search on close
|
||||
};
|
||||
|
||||
const handleQuickReaction = (emoji: string) => {
|
||||
onEmojiSelect(emoji);
|
||||
updateReactionHistory(emoji);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>React with emoji</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Quick reaction bar */}
|
||||
<div className="flex gap-2 pb-3 border-b">
|
||||
{QUICK_REACTIONS.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => handleQuickReaction(emoji)}
|
||||
className="text-2xl hover:scale-125 transition-transform active:scale-110"
|
||||
title={`React with ${emoji}`}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search emojis..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Frequently used section */}
|
||||
{frequentlyUsed.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-2 font-medium">
|
||||
Frequently used
|
||||
</div>
|
||||
<div className="grid grid-cols-8 gap-2">
|
||||
{frequentlyUsed.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => handleQuickReaction(emoji)}
|
||||
className="text-2xl hover:bg-muted rounded p-2 transition-colors"
|
||||
title={emoji}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Emoji grid */}
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{searchResults.length > 0 ? (
|
||||
<div className="grid grid-cols-8 gap-2">
|
||||
{searchResults.map((result) => (
|
||||
<button
|
||||
key={`${result.source}:${result.shortcode}`}
|
||||
onClick={() => handleEmojiClick(result)}
|
||||
className="hover:bg-muted rounded p-2 transition-colors flex items-center justify-center"
|
||||
title={`:${result.shortcode}:`}
|
||||
>
|
||||
{result.source === "unicode" ? (
|
||||
<span className="text-2xl">{result.url}</span>
|
||||
) : (
|
||||
<img
|
||||
src={result.url}
|
||||
alt={`:${result.shortcode}:`}
|
||||
className="size-6"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
No emojis found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -413,3 +413,13 @@ body.animating-layout
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Hide scrollbar utility */
|
||||
.hide-scrollbar {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari/Opera */
|
||||
}
|
||||
|
||||
@@ -126,6 +126,20 @@ export abstract class ChatProtocolAdapter {
|
||||
options?: SendMessageOptions,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Send a reaction (kind 7) to a message
|
||||
* @param conversation - The conversation context
|
||||
* @param messageId - The event ID being reacted to
|
||||
* @param emoji - The reaction emoji (unicode or :shortcode:)
|
||||
* @param customEmoji - Optional NIP-30 custom emoji metadata
|
||||
*/
|
||||
abstract sendReaction(
|
||||
conversation: Conversation,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
customEmoji?: { shortcode: string; url: string },
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get the capabilities of this protocol
|
||||
* Used to determine which UI features to show
|
||||
|
||||
@@ -488,6 +488,52 @@ export class Nip29Adapter extends ChatProtocolAdapter {
|
||||
await publishEventToRelays(event, [relayUrl]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reaction (kind 7) to a message in the group
|
||||
*/
|
||||
async sendReaction(
|
||||
conversation: Conversation,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
customEmoji?: { shortcode: string; url: string },
|
||||
): Promise<void> {
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
const activeSigner = accountManager.active$.value?.signer;
|
||||
|
||||
if (!activePubkey || !activeSigner) {
|
||||
throw new Error("No active account or signer");
|
||||
}
|
||||
|
||||
const groupId = conversation.metadata?.groupId;
|
||||
const relayUrl = conversation.metadata?.relayUrl;
|
||||
|
||||
if (!groupId || !relayUrl) {
|
||||
throw new Error("Group ID and relay URL required");
|
||||
}
|
||||
|
||||
// Create event factory and sign event
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(activeSigner);
|
||||
|
||||
const tags: string[][] = [
|
||||
["e", messageId], // Event being reacted to
|
||||
["h", groupId], // Group context (NIP-29 specific)
|
||||
["k", "9"], // Kind of event being reacted to (group chat message)
|
||||
];
|
||||
|
||||
// Add NIP-30 custom emoji tag if provided
|
||||
if (customEmoji) {
|
||||
tags.push(["emoji", customEmoji.shortcode, customEmoji.url]);
|
||||
}
|
||||
|
||||
// Use kind 7 for reactions
|
||||
const draft = await factory.build({ kind: 7, content: emoji, tags });
|
||||
const event = await factory.sign(draft);
|
||||
|
||||
// Publish only to the group relay
|
||||
await publishEventToRelays(event, [relayUrl]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get protocol capabilities
|
||||
*/
|
||||
|
||||
@@ -469,6 +469,71 @@ export class Nip53Adapter extends ChatProtocolAdapter {
|
||||
await publishEventToRelays(event, relays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reaction (kind 7) to a message in the live activity chat
|
||||
*/
|
||||
async sendReaction(
|
||||
conversation: Conversation,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
customEmoji?: { shortcode: string; url: string },
|
||||
): Promise<void> {
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
const activeSigner = accountManager.active$.value?.signer;
|
||||
|
||||
if (!activePubkey || !activeSigner) {
|
||||
throw new Error("No active account or signer");
|
||||
}
|
||||
|
||||
const activityAddress = conversation.metadata?.activityAddress;
|
||||
const liveActivity = conversation.metadata?.liveActivity as
|
||||
| {
|
||||
relays?: string[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!activityAddress) {
|
||||
throw new Error("Activity address required");
|
||||
}
|
||||
|
||||
const { pubkey, identifier } = activityAddress;
|
||||
const aTagValue = `30311:${pubkey}:${identifier}`;
|
||||
|
||||
// Get relays - use immutable pattern to avoid mutating metadata
|
||||
const relays =
|
||||
liveActivity?.relays && liveActivity.relays.length > 0
|
||||
? liveActivity.relays
|
||||
: conversation.metadata?.relayUrl
|
||||
? [conversation.metadata.relayUrl]
|
||||
: [];
|
||||
|
||||
if (relays.length === 0) {
|
||||
throw new Error("No relays available for sending reaction");
|
||||
}
|
||||
|
||||
// Create event factory and sign event
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(activeSigner);
|
||||
|
||||
const tags: string[][] = [
|
||||
["e", messageId], // Event being reacted to
|
||||
["a", aTagValue, relays[0] || ""], // Activity context (NIP-53 specific)
|
||||
["k", "1311"], // Kind of event being reacted to (live chat message)
|
||||
];
|
||||
|
||||
// Add NIP-30 custom emoji tag if provided
|
||||
if (customEmoji) {
|
||||
tags.push(["emoji", customEmoji.shortcode, customEmoji.url]);
|
||||
}
|
||||
|
||||
// Use kind 7 for reactions
|
||||
const draft = await factory.build({ kind: 7, content: emoji, tags });
|
||||
const event = await factory.sign(draft);
|
||||
|
||||
// Publish to all activity relays
|
||||
await publishEventToRelays(event, relays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get protocol capabilities
|
||||
*/
|
||||
|
||||
@@ -247,6 +247,50 @@ export class NipC7Adapter extends ChatProtocolAdapter {
|
||||
await publishEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reaction (kind 7) to a message
|
||||
*/
|
||||
async sendReaction(
|
||||
conversation: Conversation,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
customEmoji?: { shortcode: string; url: string },
|
||||
): Promise<void> {
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
const activeSigner = accountManager.active$.value?.signer;
|
||||
|
||||
if (!activePubkey || !activeSigner) {
|
||||
throw new Error("No active account or signer");
|
||||
}
|
||||
|
||||
const partner = conversation.participants.find(
|
||||
(p) => p.pubkey !== activePubkey,
|
||||
);
|
||||
if (!partner) {
|
||||
throw new Error("No conversation partner found");
|
||||
}
|
||||
|
||||
// Create event factory and sign event
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(activeSigner);
|
||||
|
||||
const tags: string[][] = [
|
||||
["e", messageId], // Event being reacted to
|
||||
["p", partner.pubkey], // Tag the partner (NIP-C7 context)
|
||||
["k", "9"], // Kind of event being reacted to
|
||||
];
|
||||
|
||||
// Add NIP-30 custom emoji tag if provided
|
||||
if (customEmoji) {
|
||||
tags.push(["emoji", customEmoji.shortcode, customEmoji.url]);
|
||||
}
|
||||
|
||||
// Use kind 7 for reactions
|
||||
const draft = await factory.build({ kind: 7, content: emoji, tags });
|
||||
const event = await factory.sign(draft);
|
||||
await publishEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get protocol capabilities
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user