242 lines
8.0 KiB
TypeScript
242 lines
8.0 KiB
TypeScript
import { useState, useCallback, useMemo } from 'react';
|
|
import { MoreHorizontal } from 'lucide-react';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
|
|
import { EmojiPicker, type EmojiSelection } from '@/components/EmojiPicker';
|
|
import { isCustomEmoji } from '@/lib/customEmoji';
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useEmojiUsage } from '@/hooks/useEmojiUsage';
|
|
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
|
|
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
|
import { cn } from '@/lib/utils';
|
|
import type { EventStats } from '@/hooks/useTrending';
|
|
import type { ResolvedEmoji } from '@/lib/customEmoji';
|
|
|
|
interface QuickReactMenuProps {
|
|
/** The event ID being reacted to. */
|
|
eventId: string;
|
|
/** The pubkey of the event author. */
|
|
eventPubkey: string;
|
|
/** The kind number of the event being reacted to. */
|
|
eventKind: number;
|
|
/** Called after an emoji is selected so the parent can close the popover. */
|
|
onClose?: () => void;
|
|
/** Called when the full picker is opened/closed so the parent can lock the popover open. */
|
|
onExpandChange?: (expanded: boolean) => void;
|
|
/**
|
|
* Optional custom handler called when an emoji is selected.
|
|
* When provided, this replaces the default kind 7 publish behavior.
|
|
*/
|
|
onReact?: (emoji: string) => void;
|
|
/** Optional extra class names. */
|
|
className?: string;
|
|
}
|
|
|
|
export function QuickReactMenu({
|
|
eventId,
|
|
eventPubkey,
|
|
eventKind,
|
|
onClose,
|
|
onExpandChange,
|
|
onReact,
|
|
className,
|
|
}: QuickReactMenuProps) {
|
|
const { user } = useCurrentUser();
|
|
const { mutate: publishEvent } = useNostrPublish();
|
|
const queryClient = useQueryClient();
|
|
const { trackEmojiUsage, getTopEmojis } = useEmojiUsage();
|
|
const { feedSettings } = useFeedSettings();
|
|
const { emojis: allCustomEmojis } = useCustomEmojis();
|
|
const customEmojisEnabled = feedSettings.showCustomEmojis !== false;
|
|
const customEmojis = useMemo(() => customEmojisEnabled ? allCustomEmojis : [], [customEmojisEnabled, allCustomEmojis]);
|
|
|
|
const [showFullPicker, setShowFullPicker] = useState(false);
|
|
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
|
|
|
|
// Build a lookup map from shortcode -> url for custom emojis
|
|
const customEmojiMap = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
for (const e of customEmojis) {
|
|
map.set(e.shortcode, e.url);
|
|
}
|
|
return map;
|
|
}, [customEmojis]);
|
|
|
|
// Get user's most-used emojis (or defaults), filtering out
|
|
// custom emoji shortcodes that are no longer in the user's collection
|
|
const quickEmojis = useMemo(() => {
|
|
const top = getTopEmojis(8);
|
|
return top
|
|
.filter((emoji) => {
|
|
if (!isCustomEmoji(emoji)) return true;
|
|
const shortcode = emoji.slice(1, -1);
|
|
return customEmojiMap.has(shortcode);
|
|
})
|
|
.slice(0, 6);
|
|
}, [getTopEmojis, customEmojiMap]);
|
|
|
|
/** Publish a reaction with a native Unicode emoji string. */
|
|
const publishReaction = useCallback((emoji: string, emojiTag?: [string, string, string]) => {
|
|
if (!user) return;
|
|
|
|
// Close the entire popover
|
|
onClose?.();
|
|
|
|
// Set selected emoji for optimistic update
|
|
setSelectedEmoji(emoji);
|
|
|
|
// Track emoji usage (only for native emojis)
|
|
if (!emojiTag) trackEmojiUsage(emoji);
|
|
|
|
// If a custom handler is provided, delegate to it
|
|
if (onReact) {
|
|
onReact(emoji);
|
|
return;
|
|
}
|
|
|
|
// Optimistic update
|
|
const displayEmoji = (emoji === '+' || emoji === '') ? '👍' : emoji;
|
|
const resolvedEmoji: ResolvedEmoji = emojiTag
|
|
? { content: displayEmoji, url: emojiTag[2], name: emojiTag[1] }
|
|
: { content: displayEmoji };
|
|
const prevStats = queryClient.getQueryData<EventStats>(['event-stats', eventId]);
|
|
if (prevStats) {
|
|
queryClient.setQueryData<EventStats>(['event-stats', eventId], {
|
|
...prevStats,
|
|
reactions: prevStats.reactions + 1,
|
|
reactionEmojis: prevStats.reactionEmojis.some((e) => e.content === displayEmoji)
|
|
? prevStats.reactionEmojis
|
|
: [...prevStats.reactionEmojis, resolvedEmoji],
|
|
});
|
|
}
|
|
|
|
queryClient.setQueryData<ResolvedEmoji>(['user-reaction', eventId], resolvedEmoji);
|
|
|
|
// Build tags
|
|
const tags: string[][] = [
|
|
['e', eventId],
|
|
['p', eventPubkey],
|
|
['k', String(eventKind)],
|
|
];
|
|
if (emojiTag) tags.push(emojiTag);
|
|
|
|
// Publish kind 7 reaction
|
|
publishEvent(
|
|
{
|
|
kind: 7,
|
|
content: emoji,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags,
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
setTimeout(() => {
|
|
queryClient.invalidateQueries({ queryKey: ['event-stats', eventId] });
|
|
queryClient.invalidateQueries({ queryKey: ['event-interactions', eventId] });
|
|
}, 3000);
|
|
},
|
|
onError: () => {
|
|
setSelectedEmoji(null);
|
|
if (prevStats) {
|
|
queryClient.setQueryData<EventStats>(['event-stats', eventId], prevStats);
|
|
}
|
|
queryClient.removeQueries({ queryKey: ['user-reaction', eventId] });
|
|
},
|
|
},
|
|
);
|
|
}, [user, eventId, eventPubkey, eventKind, onReact, publishEvent, queryClient, trackEmojiUsage, onClose]);
|
|
|
|
/** Handle selection from the quick buttons (native or custom emoji). */
|
|
const handleQuickSelect = useCallback((emoji: string) => {
|
|
if (isCustomEmoji(emoji)) {
|
|
const shortcode = emoji.slice(1, -1);
|
|
const url = customEmojiMap.get(shortcode);
|
|
if (url) {
|
|
publishReaction(emoji, ['emoji', shortcode, url]);
|
|
return;
|
|
}
|
|
}
|
|
publishReaction(emoji);
|
|
}, [publishReaction, customEmojiMap]);
|
|
|
|
/** Handle selection from the full EmojiPicker (native or custom). */
|
|
const handlePickerSelect = useCallback((selection: EmojiSelection) => {
|
|
if (selection.type === 'native') {
|
|
publishReaction(selection.emoji);
|
|
} else {
|
|
// Custom NIP-30 emoji — content is :shortcode:, with emoji tag
|
|
publishReaction(
|
|
`:${selection.shortcode}:`,
|
|
['emoji', selection.shortcode, selection.url],
|
|
);
|
|
}
|
|
}, [publishReaction]);
|
|
|
|
if (!user) return null;
|
|
|
|
if (showFullPicker) {
|
|
return (
|
|
<div
|
|
className={cn('rounded-xl shadow-xl overflow-hidden', className)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<EmojiPicker customEmojis={customEmojis} onSelect={handlePickerSelect} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'inline-flex items-center gap-1 p-1.5 rounded-full bg-popover border border-border shadow-lg',
|
|
className,
|
|
)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Quick emoji buttons */}
|
|
{quickEmojis.map((emoji) => {
|
|
const isCustom = isCustomEmoji(emoji);
|
|
const shortcode = isCustom ? emoji.slice(1, -1) : undefined;
|
|
const customUrl = shortcode ? customEmojiMap.get(shortcode) : undefined;
|
|
|
|
return (
|
|
<button
|
|
key={emoji}
|
|
onClick={() => handleQuickSelect(emoji)}
|
|
className={cn(
|
|
'flex items-center justify-center size-9 rounded-full text-xl transition-all hover:bg-secondary hover:scale-110 active:scale-95',
|
|
selectedEmoji === emoji && 'bg-secondary scale-110',
|
|
)}
|
|
title={`React with ${isCustom ? shortcode : emoji}`}
|
|
>
|
|
{customUrl ? (
|
|
<img
|
|
src={customUrl}
|
|
alt={emoji}
|
|
className="size-6 object-contain"
|
|
loading="lazy"
|
|
decoding="async"
|
|
/>
|
|
) : (
|
|
emoji
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
|
|
{/* More button to show full picker */}
|
|
<button
|
|
onClick={() => {
|
|
setShowFullPicker(true);
|
|
onExpandChange?.(true);
|
|
}}
|
|
className="flex items-center justify-center size-9 rounded-full text-muted-foreground transition-all hover:bg-secondary hover:text-foreground hover:scale-110 active:scale-95"
|
|
title="More reactions"
|
|
>
|
|
<MoreHorizontal className="size-5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|