3b641a8d7c
The first chip-row pass touched the campaign page (PostActionBar +
NoteCard). The post-detail page, book feed, and external/Bluesky
content rows still rendered the old spread-out pill toolbar — visible
when clicking any note from /discover, the book index, or a Bluesky
syndication. Bring them in line:
- PostDetailPage.tsx: replace the five remaining inline action rows
(standard post, repost card, zap card, profile detail, vanish event)
with <PostActionBar event={event} onReply onMore />. Drops ~270
lines of duplicated reply/repost/react/share/more JSX and removes
the now-dead handleShare, repostTotal, encodedEventId locals and
the MessageCircle / MoreHorizontal / Share2 / ReactionButton /
RepostMenu / toast / shareOrCopy imports that fed them. Update the
loading skeleton to use chip-shaped placeholders.
- BookFeedItem.tsx: same migration. The component already had a
bespoke action row that mirrored PostActionBar minus the share
button — using PostActionBar restores parity and removes the
hand-rolled zap branch, the canZapAuthor / isZapped / useUserZap
glue, and the MessageCircle / RepostIcon / Zap / formatNumber
imports.
- ExternalContentHeader.tsx + BlueskyPage.tsx: these can't switch to
PostActionBar because the comment/repost handlers publish to
Bluesky's external semantics, not Nostr. Hand-restyle the rows to
the chip aesthetic instead (h-9 px-3 rounded-full, label fallback
on sm+, share/more pushed right with a flex spacer).
- ExternalReactionButton.tsx: mirror the variant: 'pill' | 'chip'
prop added to ReactionButton in the previous commit, so the
external-content rows can opt into the chip look without affecting
ExternalContentPage's sidebar toolbar which still uses the pill.
PodcastDetailContent, MusicDetailContent, and PhotoBottomBar still
use their own deliberate aesthetics (large circular play button with
matching side buttons; immersive lightbox bar). Left alone — they
don't read as Twitter rows.
203 lines
7.2 KiB
TypeScript
203 lines
7.2 KiB
TypeScript
import { useCallback, useRef, useState } from 'react';
|
|
import { Heart } from 'lucide-react';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
import { QuickReactMenu } from '@/components/QuickReactMenu';
|
|
import { ReactionEmoji } from '@/components/CustomEmoji';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import {
|
|
useExternalUserReaction,
|
|
useExternalReactionCount,
|
|
} from '@/hooks/useExternalReactions';
|
|
import { formatNumber } from '@/lib/formatNumber';
|
|
import { impactLight } from '@/lib/haptics';
|
|
import { cn } from '@/lib/utils';
|
|
import type { ExternalContent } from '@/lib/externalContent';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: NIP-73 k tag value
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function getExternalKTag(content: ExternalContent): string {
|
|
switch (content.type) {
|
|
case 'url': return 'web';
|
|
case 'isbn': return 'isbn';
|
|
case 'iso3166': return 'iso3166';
|
|
default: return 'web';
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ExternalReactionButton
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ExternalReactionButtonProps {
|
|
/** Parsed NIP-73 external content. */
|
|
content: ExternalContent;
|
|
/** Icon size class (default "size-5"). */
|
|
iconSize?: string;
|
|
/** Display count from an external source (e.g. Bluesky like count). Falls back to the Nostr reaction count. */
|
|
count?: number;
|
|
/** Extra class names on the trigger button. */
|
|
className?: string;
|
|
/**
|
|
* Visual variant.
|
|
* - `pill` (default): compact icon-pill matching the legacy action bar.
|
|
* - `chip`: rounded chip with a label fallback when there's no count,
|
|
* matching the GoFundMe-style PostActionBar / NoteCard action row.
|
|
*/
|
|
variant?: 'pill' | 'chip';
|
|
}
|
|
|
|
/**
|
|
* A fully-featured reaction button for NIP-73 external content.
|
|
*
|
|
* Includes hover-to-open emoji picker via `QuickReactMenu`, optimistic UI,
|
|
* and displays the user's existing reaction & total count.
|
|
*/
|
|
export function ExternalReactionButton({ content, iconSize = 'size-5', count, className, variant = 'pill' }: ExternalReactionButtonProps) {
|
|
const { user } = useCurrentUser();
|
|
const { mutate: publishEvent } = useNostrPublish();
|
|
const queryClient = useQueryClient();
|
|
const { toast } = useToast();
|
|
const identifier = content.value;
|
|
|
|
const userReactionData = useExternalUserReaction(content);
|
|
const reactionCount = useExternalReactionCount(content);
|
|
|
|
const hasReacted = !!userReactionData;
|
|
const userEmoji = userReactionData?.emoji;
|
|
const userReactionTags = userReactionData?.tags;
|
|
|
|
// Popover state
|
|
const [reactOpen, setReactOpen] = useState(false);
|
|
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const justClosedRef = useRef(false);
|
|
const pickerExpandedRef = useRef(false);
|
|
|
|
const handleMouseEnter = useCallback(() => {
|
|
if (!user) return;
|
|
if (justClosedRef.current) return;
|
|
if (closeTimeoutRef.current) {
|
|
clearTimeout(closeTimeoutRef.current);
|
|
closeTimeoutRef.current = null;
|
|
}
|
|
setReactOpen(true);
|
|
}, [user]);
|
|
|
|
const handleMouseLeave = useCallback(() => {
|
|
if (pickerExpandedRef.current) return;
|
|
closeTimeoutRef.current = setTimeout(() => setReactOpen(false), 150);
|
|
}, []);
|
|
|
|
// Publish kind 17 reaction
|
|
const handleReact = useCallback((emoji: string, emojiTag?: string[]) => {
|
|
if (!user) return;
|
|
impactLight();
|
|
|
|
const tags: string[][] = [
|
|
['k', getExternalKTag(content)],
|
|
['i', identifier],
|
|
];
|
|
if (emojiTag) tags.push(emojiTag);
|
|
|
|
queryClient.setQueryData(['external-user-reaction', identifier], { emoji: emoji || '+', tags });
|
|
queryClient.setQueryData(['external-reaction-count', identifier], (prev: number | undefined) => (prev ?? 0) + 1);
|
|
|
|
publishEvent(
|
|
{
|
|
kind: 17,
|
|
content: emoji,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags,
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
setTimeout(() => {
|
|
queryClient.invalidateQueries({ queryKey: ['external-user-reaction', identifier] });
|
|
queryClient.invalidateQueries({ queryKey: ['external-reaction-count', identifier] });
|
|
}, 3000);
|
|
},
|
|
onError: () => {
|
|
toast({ title: 'Failed to react', variant: 'destructive' });
|
|
queryClient.setQueryData(['external-user-reaction', identifier], null);
|
|
queryClient.setQueryData(['external-reaction-count', identifier], (prev: number | undefined) => Math.max(0, (prev ?? 1) - 1));
|
|
},
|
|
},
|
|
);
|
|
}, [user, content, identifier, publishEvent, queryClient, toast]);
|
|
|
|
return (
|
|
<Popover open={reactOpen} onOpenChange={(open) => {
|
|
if (open && justClosedRef.current) return;
|
|
if (!open) pickerExpandedRef.current = false;
|
|
setReactOpen(open);
|
|
}}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
className={cn(
|
|
'transition-colors',
|
|
variant === 'chip'
|
|
? 'inline-flex items-center gap-2 h-9 px-3 rounded-full text-sm font-medium'
|
|
: 'flex items-center gap-1.5 p-2 rounded-full',
|
|
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;
|
|
if (justClosedRef.current) return;
|
|
setReactOpen((prev) => !prev);
|
|
}}
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={handleMouseLeave}
|
|
>
|
|
{hasReacted && userEmoji ? (
|
|
<span className={cn(iconSize, 'flex items-center justify-center text-base leading-none')}>
|
|
<ReactionEmoji content={userEmoji} tags={userReactionTags} className={iconSize} />
|
|
</span>
|
|
) : (
|
|
<Heart className={iconSize} />
|
|
)}
|
|
{(count ?? reactionCount) > 0 ? (
|
|
<span className={cn('tabular-nums', variant === 'chip' ? '' : 'text-sm')}>
|
|
{formatNumber(count ?? reactionCount)}
|
|
</span>
|
|
) : variant === 'chip' ? (
|
|
<span className="hidden sm:inline">React</span>
|
|
) : null}
|
|
</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={identifier}
|
|
eventPubkey=""
|
|
eventKind={17}
|
|
onExpandChange={(expanded) => { pickerExpandedRef.current = expanded; }}
|
|
onClose={() => {
|
|
pickerExpandedRef.current = false;
|
|
justClosedRef.current = true;
|
|
setReactOpen(false);
|
|
setTimeout(() => { justClosedRef.current = false; }, 300);
|
|
}}
|
|
onReact={handleReact}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|