Files
eranos/src/components/QuickReactMenu.tsx
T
Alex Gleason 4f2eb23a62 Replace accent color with foreground/neutral in nav, headings, and emoji picker
The accent (secondary) color is now gold/amber which looks wrong for
active nav states, section titles, and interactive backgrounds. Change
these to use foreground or neutral surface colors instead:

- Left sidebar active items: text-accent -> text-foreground
- Right sidebar headings: text-accent -> text-foreground
- Mobile bottom nav active items: text-accent -> text-foreground
- Mobile explore drawer active items: text-accent -> text-foreground
- Quick react emoji picker: hover:bg-accent -> hover:bg-secondary
- Section labels (Explore, Settings, My Themes): text-accent/70 -> text-muted-foreground
- Search page trending heading and Rising tab: text-accent -> text-foreground/text-primary
- Sidebar theme palette icon: text-accent -> text-primary
2026-02-25 23:22:39 -06:00

163 lines
5.4 KiB
TypeScript

import { useState, useCallback, useMemo } from 'react';
import { MoreHorizontal } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { EmojiPicker } from '@/components/EmojiPicker';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEmojiUsage } from '@/hooks/useEmojiUsage';
import { cn } from '@/lib/utils';
import type { EventStats } from '@/hooks/useTrending';
import type { ResolvedEmoji } from '@/components/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 extra class names. */
className?: string;
}
export function QuickReactMenu({
eventId,
eventPubkey,
eventKind,
onClose,
onExpandChange,
className,
}: QuickReactMenuProps) {
const { user } = useCurrentUser();
const { mutate: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
const { trackEmojiUsage, getTopEmojis } = useEmojiUsage();
const [showFullPicker, setShowFullPicker] = useState(false);
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
// Get user's most-used emojis (or defaults)
const quickEmojis = useMemo(() => getTopEmojis(6), [getTopEmojis]);
const handleEmojiSelect = useCallback((emoji: string) => {
if (!user) return;
// Close the entire popover (don't reset showFullPicker first — that
// causes the quick-select to flash before the popover unmounts).
onClose?.();
// Set selected emoji for optimistic update
setSelectedEmoji(emoji);
// Track emoji usage
trackEmojiUsage(emoji);
// Optimistically update stats cache immediately
const displayEmoji = (emoji === '+' || emoji === '') ? '👍' : emoji;
const resolvedEmoji: ResolvedEmoji = { 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],
});
}
// Store user's own reaction for this event
queryClient.setQueryData<ResolvedEmoji>(['user-reaction', eventId], resolvedEmoji);
// Publish kind 7 reaction
publishEvent(
{
kind: 7,
content: emoji,
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', eventId],
['p', eventPubkey],
['k', String(eventKind)],
],
},
{
onSuccess: () => {
// Delay invalidation so the relay has time to index the new event.
// Without this, the refetch returns stale counts and overwrites
// the optimistic update.
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['event-stats', eventId] });
queryClient.invalidateQueries({ queryKey: ['event-interactions', eventId] });
}, 3000);
},
onError: () => {
// Revert optimistic update on failure
setSelectedEmoji(null);
// Revert stats
if (prevStats) {
queryClient.setQueryData<EventStats>(['event-stats', eventId], prevStats);
}
// Remove user reaction
queryClient.removeQueries({ queryKey: ['user-reaction', eventId] });
},
},
);
}, [user, eventId, eventPubkey, eventKind, publishEvent, queryClient, trackEmojiUsage, onClose]);
if (!user) return null;
if (showFullPicker) {
return (
<div
className={cn('rounded-xl shadow-xl overflow-hidden', className)}
onClick={(e) => e.stopPropagation()}
>
<EmojiPicker onSelect={handleEmojiSelect} />
</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) => (
<button
key={emoji}
onClick={() => handleEmojiSelect(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 ${emoji}`}
>
{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>
);
}