Files
eranos/src/components/QuickReactMenu.tsx
T
Alex Gleason 12a4966b84 Add haptic feedback to emoji reaction selection in QuickReactMenu
The popover emoji picker (both quick presets and full picker) was
publishing reactions internally without triggering haptic feedback.
Add impactLight() at the top of publishReaction() so every emoji
selection path gets tactile feedback.
2026-04-14 11:29:22 -05:00

244 lines
8.3 KiB
TypeScript

import { useState, useCallback, useMemo } from 'react';
import { MoreHorizontal } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { CustomEmojiImg } from '@/components/CustomEmoji';
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 { impactLight } from '@/lib/haptics';
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.
* The optional second argument is an emoji tag `['emoji', shortcode, url]` for custom emojis.
*/
onReact?: (emoji: string, emojiTag?: 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;
impactLight();
// Close the entire popover
onClose?.();
// Set selected emoji for optimistic update
setSelectedEmoji(emoji);
// Track emoji usage for quick-react bar ordering
trackEmojiUsage(emoji);
// If a custom handler is provided, delegate to it
if (onReact) {
onReact(emoji, emojiTag);
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 focus:outline-none hover:bg-secondary hover:scale-110 active:scale-95',
selectedEmoji === emoji && 'bg-secondary scale-110',
)}
title={`React with ${isCustom ? shortcode : emoji}`}
>
{customUrl ? (
<CustomEmojiImg
name={shortcode ?? emoji}
url={customUrl}
className="size-6 object-contain"
/>
) : (
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 focus:outline-none hover:bg-secondary hover:text-foreground hover:scale-110 active:scale-95"
title="More reactions"
>
<MoreHorizontal className="size-5" />
</button>
</div>
);
}