Files
eranos/src/components/PhotoBottomBar.tsx
T
Chad Curtis ad0efdff60 fix(lightbox): smoother swipe and consistent top/bottom padding
Swipe: replace React state-driven translation (caused re-renders + jarring
snap-back) with direct DOM mutation via ref. Axis is locked on first
significant movement; at edges resistance is 20% rubber-band. On release,
if past the 30% threshold the next/prev fires and the position resets
without animation; otherwise it springs back with a single cubic-bezier
transition.

Bottom bar: matches top bar rhythm (px-4 py-3 + safe-area-bottom).
Gradient scrim is now a fixed h-32 pseudo-element that extends upward from
the bottom of the bar, independent of the content height, so it fades
cleanly over the image without needing extra pt-10 padding on the strip.
2026-03-02 17:40:42 -06:00

138 lines
5.9 KiB
TypeScript

/**
* PhotoBottomBar — author info + reaction strip rendered at the bottom of
* the media Lightbox. Sits flush with the device's safe-area edge; no extra
* clearance is needed because the Lightbox is a fixed full-screen overlay
* that covers the bottom navigation bar entirely.
*/
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { MessageCircle, Zap, MoreHorizontal } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ReactionButton } from '@/components/ReactionButton';
import { RepostMenu } from '@/components/RepostMenu';
import { ZapDialog } from '@/components/ZapDialog';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { CommentsSheet } from '@/components/CommentsSheet';
import { RepostIcon } from '@/components/icons/RepostIcon';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useEventStats } from '@/hooks/useTrending';
import { getDisplayName } from '@/lib/getDisplayName';
import { genUserName } from '@/lib/genUserName';
import { canZap } from '@/lib/canZap';
function formatSats(sats: number): string {
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
if (sats >= 1_000) return `${(sats / 1_000).toFixed(1).replace(/\.0$/, '')}K`;
return sats.toString();
}
interface PhotoBottomBarProps {
event: NostrEvent;
onCommentClick: () => void;
/** The event currently open in CommentsSheet (managed by the parent). */
commentsEvent?: NostrEvent | null;
/** Called when CommentsSheet wants to close. */
onCommentsClose?: () => void;
}
export function PhotoBottomBar({ event, onCommentClick, commentsEvent, onCommentsClose }: PhotoBottomBarProps) {
const { user } = useCurrentUser();
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey) ?? genUserName(event.pubkey);
const profileUrl = useProfileUrl(event.pubkey, metadata);
const { data: stats } = useEventStats(event.id);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const canZapAuthor = user && canZap(metadata);
return (
<>
{/* Action strip — mirrors top bar: px-4 py-3 + safe-area */}
<div className="relative safe-area-bottom">
{/* Gradient scrim: tall enough to fade nicely over the image above the bar */}
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-black/70 to-transparent pointer-events-none" />
<div className="relative flex items-center gap-1 px-4 py-3 max-w-xl mx-auto">
{/* Avatar + name */}
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="shrink-0">
<Avatar className="size-7">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-white/20 text-white text-xs">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="font-semibold text-sm text-white hover:underline truncate mr-1">
{displayName}
</Link>
</ProfileHoverCard>
{/* Actions */}
<div className="flex items-center gap-3 ml-auto shrink-0">
<ReactionButton
eventId={event.id}
eventPubkey={event.pubkey}
eventKind={event.kind}
reactionCount={stats?.reactions}
filledHeart
className="text-white hover:text-pink-400 hover:bg-white/10 p-0 [&_svg]:size-6"
/>
<button
className="flex items-center gap-1.5 text-white hover:text-blue-400 transition-colors"
onClick={onCommentClick}
>
<MessageCircle className="size-6" />
{!!stats?.replies && <span className="text-sm tabular-nums drop-shadow">{stats.replies}</span>}
</button>
<RepostMenu event={event}>
{(isReposted: boolean) => (
<button className={`flex items-center gap-1.5 transition-colors ${isReposted ? 'text-accent' : 'text-white hover:text-accent'}`}>
<RepostIcon className="size-6" />
{!!((stats?.reposts ?? 0) + (stats?.quotes ?? 0)) && (
<span className="text-sm tabular-nums drop-shadow">{(stats?.reposts ?? 0) + (stats?.quotes ?? 0)}</span>
)}
</button>
)}
</RepostMenu>
{canZapAuthor && (
<ZapDialog target={event}>
<button className="flex items-center gap-1.5 text-white hover:text-amber-400 transition-colors">
<Zap className="size-6" />
{!!stats?.zapAmount && <span className="text-sm tabular-nums drop-shadow">{formatSats(stats.zapAmount)}</span>}
</button>
</ZapDialog>
)}
<button
className="text-white/80 hover:text-white transition-colors"
onClick={() => setMoreMenuOpen(true)}
>
<MoreHorizontal className="size-6" />
</button>
</div>
</div>
</div>
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
{/* CommentsSheet is rendered here so it sits inside the fixed overlay stacking context */}
<CommentsSheet
event={commentsEvent ?? undefined}
open={!!commentsEvent}
onClose={onCommentsClose ?? (() => {})}
/>
</>
);
}