35422ec6bb
- Add true optimistic updates for reactions and reposts via setQueryData instead of only invalidating queries after publish - Track user's own reaction per event and display their emoji in place of the Heart icon across feed, post details, and notifications - Strip whitespace around block embeds and skipped media URLs to eliminate blank lines caused by whitespace-pre-wrap - Only render link preview cards for standalone URLs; inline URLs in sentences render as plain clickable links - Remove border-b separators and use fixed-height object-cover on preview images to eliminate gaps - Tighten padding on EmbeddedNote and EmbeddedNaddr cards - Use fixed-height object-cover for ImageGallery grid images - Unify RepostMenu to use Popover on all screen sizes instead of Drawer on mobile
102 lines
3.1 KiB
TypeScript
102 lines
3.1 KiB
TypeScript
import { useState, useRef, useCallback } from 'react';
|
|
import { Heart } from 'lucide-react';
|
|
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
import { QuickReactMenu } from '@/components/QuickReactMenu';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useUserReaction } from '@/hooks/useUserReaction';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface ReactionButtonProps {
|
|
/** 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;
|
|
/** Current reaction count from stats. */
|
|
reactionCount?: number;
|
|
/** Optional extra class names. */
|
|
className?: string;
|
|
}
|
|
|
|
export function ReactionButton({
|
|
eventId,
|
|
eventPubkey,
|
|
eventKind,
|
|
reactionCount = 0,
|
|
className,
|
|
}: ReactionButtonProps) {
|
|
const { user } = useCurrentUser();
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const userReaction = useUserReaction(eventId);
|
|
|
|
const hasReacted = !!userReaction;
|
|
|
|
const handleMouseEnter = useCallback(() => {
|
|
if (!user) return;
|
|
// Clear any pending close timeout
|
|
if (closeTimeoutRef.current) {
|
|
clearTimeout(closeTimeoutRef.current);
|
|
closeTimeoutRef.current = null;
|
|
}
|
|
setMenuOpen(true);
|
|
}, [user]);
|
|
|
|
const handleMouseLeave = useCallback(() => {
|
|
// Delay closing to allow user to move to the menu
|
|
closeTimeoutRef.current = setTimeout(() => {
|
|
setMenuOpen(false);
|
|
}, 150);
|
|
}, []);
|
|
|
|
return (
|
|
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
className={cn(
|
|
'flex items-center gap-1.5 p-2 rounded-full transition-colors',
|
|
hasReacted
|
|
? 'text-pink-500'
|
|
: 'text-muted-foreground hover:text-pink-500 hover:bg-pink-500/10',
|
|
className,
|
|
)}
|
|
title="React"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (!user) return;
|
|
setMenuOpen((prev) => !prev);
|
|
}}
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={handleMouseLeave}
|
|
>
|
|
{hasReacted ? (
|
|
<span className="size-5 flex items-center justify-center text-base leading-none">{userReaction}</span>
|
|
) : (
|
|
<Heart className="size-5" />
|
|
)}
|
|
{reactionCount > 0 && (
|
|
<span className={cn('text-sm tabular-nums', hasReacted && 'text-pink-500')}>{reactionCount}</span>
|
|
)}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="w-auto p-0 border-0 bg-transparent shadow-none"
|
|
side="top"
|
|
align="start"
|
|
onClick={(e) => e.stopPropagation()}
|
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={handleMouseLeave}
|
|
>
|
|
<QuickReactMenu
|
|
eventId={eventId}
|
|
eventPubkey={eventPubkey}
|
|
eventKind={eventKind}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|