diff --git a/src/components/ProfileReactionButton.tsx b/src/components/ProfileReactionButton.tsx new file mode 100644 index 00000000..7f709efb --- /dev/null +++ b/src/components/ProfileReactionButton.tsx @@ -0,0 +1,115 @@ +import { useState, useRef, useCallback } from 'react'; +import { SmilePlus } from 'lucide-react'; + +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { QuickReactMenu } from '@/components/QuickReactMenu'; +import { Button } from '@/components/ui/button'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useEmojiUsage } from '@/hooks/useEmojiUsage'; +import { useToast } from '@/hooks/useToast'; +import type { NostrEvent } from '@nostrify/nostrify'; + +interface ProfileReactionButtonProps { + /** The kind 0 metadata event for the profile being reacted to. */ + profileEvent: NostrEvent; + /** Optional extra class names for the trigger button. */ + className?: string; +} + +/** + * Emoji reaction button for user profiles. + * Opens an emoji picker and publishes a kind 7 reaction targeting + * the user's kind 0 profile event with `a`, `e`, and `p` tags. + */ +export function ProfileReactionButton({ profileEvent, className }: ProfileReactionButtonProps) { + const { user } = useCurrentUser(); + const { mutate: publishEvent } = useNostrPublish(); + const { trackEmojiUsage } = useEmojiUsage(); + const { toast } = useToast(); + const [menuOpen, setMenuOpen] = useState(false); + const pickerExpandedRef = useRef(false); + const justClosedRef = useRef(false); + + const handleReact = useCallback((emoji: string, emojiTag?: string[]) => { + if (!user) return; + + trackEmojiUsage(emoji); + + const tags: string[][] = [ + ['e', profileEvent.id], + ['p', profileEvent.pubkey], + ['a', `0:${profileEvent.pubkey}:`], + ['k', '0'], + ]; + if (emojiTag) tags.push(emojiTag); + + publishEvent( + { + kind: 7, + content: emoji, + created_at: Math.floor(Date.now() / 1000), + tags, + }, + { + onSuccess: () => { + toast({ title: 'Reaction sent!' }); + }, + }, + ); + }, [user, profileEvent, publishEvent, trackEmojiUsage, toast]); + + if (!user) return null; + + return ( + { + if (open && justClosedRef.current) return; + if (!open) pickerExpandedRef.current = false; + setMenuOpen(open); + }} + > + + + + e.stopPropagation()} + onOpenAutoFocus={(e) => e.preventDefault()} + > + { + pickerExpandedRef.current = expanded; + }} + onClose={() => { + pickerExpandedRef.current = false; + justClosedRef.current = true; + setMenuOpen(false); + setTimeout(() => { + justClosedRef.current = false; + }, 300); + }} + /> + + + ); +} diff --git a/src/pages/NotificationsPage.tsx b/src/pages/NotificationsPage.tsx index ec04abef..15bce7d3 100644 --- a/src/pages/NotificationsPage.tsx +++ b/src/pages/NotificationsPage.tsx @@ -52,6 +52,7 @@ type NotificationTab = 'all' | 'mentions'; * Falls back to "post" for unknown kinds. */ const NOTIFICATION_KIND_NOUNS: Record = { + 0: 'profile', 1: 'post', 4: 'encrypted message', 6: 'repost', @@ -459,10 +460,12 @@ function ActorLink({ pubkey, name }: { pubkey: string; name: string }) { // Like Notification (single actor) // ────────────────────────────────────── function LikeNotification({ item, isNew }: { item: NotificationItem; isNew: boolean }) { - const noun = getNotificationKindNoun(item.referencedEvent?.kind); + const isProfileReaction = item.referencedEvent?.kind === 0 + || item.event.tags.some(([name, value]) => name === 'a' && value?.startsWith('0:')); + const noun = isProfileReaction ? 'profile' : getNotificationKindNoun(item.referencedEvent?.kind); return ( -
+
- + {!isProfileReaction && } ); } @@ -558,7 +561,9 @@ function ZapNotification({ item, isNew }: { item: NotificationItem; isNew: boole function LikeNotificationGroup({ group }: { group: GroupedNotificationItem }) { // Use the first actor's reaction emoji as the icon const firstEvent = group.actors[0].event; - const noun = getNotificationKindNoun(group.referencedEvent?.kind); + const isProfileReaction = group.referencedEvent?.kind === 0 + || firstEvent.tags.some(([name, value]) => name === 'a' && value?.startsWith('0:')); + const noun = isProfileReaction ? 'profile' : getNotificationKindNoun(group.referencedEvent?.kind); return ( - + {!isProfileReaction && } ); } diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 53e1291c..e87ad1c7 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -22,7 +22,7 @@ import { ProfileRightSidebar } from '@/components/ProfileRightSidebar'; import { NoteCard } from '@/components/NoteCard'; import { ComposeBox } from '@/components/ComposeBox'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; -import { ZapDialog } from '@/components/ZapDialog'; +import { ProfileReactionButton } from '@/components/ProfileReactionButton'; import { ExternalFavicon } from '@/components/ExternalFavicon'; import { Nip05Badge, VerifiedNip05Text } from '@/components/Nip05Badge'; import { useAppContext } from '@/hooks/useAppContext'; @@ -44,7 +44,6 @@ import { ThreadedReplyList } from '@/components/ThreadedReplyList'; import { useNip05Resolve } from '@/hooks/useNip05Resolve'; import { genUserName } from '@/lib/genUserName'; -import { canZap } from '@/lib/canZap'; import { shareOrCopy } from '@/lib/share'; import { openUrl } from '@/lib/downloadFile'; import { EmojifiedText } from '@/components/CustomEmoji'; @@ -2077,13 +2076,9 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; )} - {/* Zap button */} - {!isOwnProfile && authorEvent && canZap(metadata) && ( - - - + {/* Profile reaction button */} + {!isOwnProfile && authorEvent && ( + )} {isOwnProfile ? (