Replace zap button with emoji reaction button on user profiles

Add ProfileReactionButton component that opens an emoji picker to send
kind 7 reactions to a user's profile with a, e, and p tags. Update
notifications to display 'reacted to your profile' for profile reactions
and skip rendering the referenced card for kind 0 events.
This commit is contained in:
Alex Gleason
2026-03-30 11:06:32 -05:00
parent 67e8c23020
commit d12e75ae5c
3 changed files with 129 additions and 14 deletions
+115
View File
@@ -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 (
<Popover
open={menuOpen}
onOpenChange={(open) => {
if (open && justClosedRef.current) return;
if (!open) pickerExpandedRef.current = false;
setMenuOpen(open);
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
className={className ?? 'rounded-full size-10'}
title="React to this profile"
onClick={(e) => {
e.stopPropagation();
if (justClosedRef.current) return;
setMenuOpen((prev) => !prev);
}}
>
<SmilePlus className="size-5" />
</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()}
>
<QuickReactMenu
eventId={profileEvent.id}
eventPubkey={profileEvent.pubkey}
eventKind={0}
onReact={handleReact}
onExpandChange={(expanded) => {
pickerExpandedRef.current = expanded;
}}
onClose={() => {
pickerExpandedRef.current = false;
justClosedRef.current = true;
setMenuOpen(false);
setTimeout(() => {
justClosedRef.current = false;
}, 300);
}}
/>
</PopoverContent>
</Popover>
);
}
+10 -5
View File
@@ -52,6 +52,7 @@ type NotificationTab = 'all' | 'mentions';
* Falls back to "post" for unknown kinds.
*/
const NOTIFICATION_KIND_NOUNS: Record<number, string> = {
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 (
<NotificationWrapper isNew={isNew}>
<div className="px-4 pt-3">
<div className={cn('px-4 pt-3', isProfileReaction && 'pb-3')}>
<NotificationHeader
actorPubkey={item.event.pubkey}
icon={
@@ -473,7 +476,7 @@ function LikeNotification({ item, isNew }: { item: NotificationItem; isNew: bool
action={`reacted to your ${noun}`}
/>
</div>
<ReferencedNoteCard item={item} />
{!isProfileReaction && <ReferencedNoteCard item={item} />}
</NotificationWrapper>
);
}
@@ -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 (
<NotificationWrapper isNew={group.isNew}>
<GroupHeader
@@ -570,7 +575,7 @@ function LikeNotificationGroup({ group }: { group: GroupedNotificationItem }) {
}
action={`reacted to your ${noun}`}
/>
<ReferencedNoteCard item={group.actors[0]} />
{!isProfileReaction && <ReferencedNoteCard item={group.actors[0]} />}
</NotificationWrapper>
);
}
+4 -9
View File
@@ -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 };
<Share2 className="size-5" />
</Button>
)}
{/* Zap button */}
{!isOwnProfile && authorEvent && canZap(metadata) && (
<ZapDialog target={authorEvent}>
<Button variant="outline" size="icon" className="rounded-full size-10" title="Zap this user">
<Zap className="size-5" />
</Button>
</ZapDialog>
{/* Profile reaction button */}
{!isOwnProfile && authorEvent && (
<ProfileReactionButton profileEvent={authorEvent} />
)}
{isOwnProfile ? (
<Link to="/settings/profile">