Photos overlay: vine-style layout with swipe, comments modal, and shared action buttons
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* CommentsModal — a centered rounded modal for displaying and composing
|
||||
* comments/replies on any Nostr event.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
return tags.find(([n]) => n === name)?.[1];
|
||||
}
|
||||
|
||||
// ── data hook ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useEventComments(event: NostrEvent | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
const aTag = event
|
||||
? `${event.kind}:${event.pubkey}:${getTag(event.tags, 'd') ?? ''}`
|
||||
: undefined;
|
||||
|
||||
return useQuery<NostrEvent[]>({
|
||||
queryKey: ['event-comments', aTag ?? event?.id ?? ''],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!event) return [];
|
||||
const abort = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
|
||||
const filter =
|
||||
event.kind >= 30000 && event.kind < 40000 && aTag
|
||||
? { kinds: [1111, 1244], '#A': [aTag], limit: 80 }
|
||||
: { kinds: [1, 1111], '#e': [event.id], limit: 80 };
|
||||
const events = await nostr.query([filter], { signal: abort });
|
||||
const seen = new Set<string>();
|
||||
return events
|
||||
.filter((e) => { if (seen.has(e.id)) return false; seen.add(e.id); return true; })
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
},
|
||||
enabled: !!event,
|
||||
staleTime: 15_000,
|
||||
refetchInterval: 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
// ── comment row ───────────────────────────────────────────────────────────────
|
||||
|
||||
function CommentRow({ event }: { event: NostrEvent }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2.5 px-4 py-2.5 hover:bg-muted/30 transition-colors">
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link to={profileUrl} className="shrink-0">
|
||||
{author.isLoading ? (
|
||||
<Skeleton className="size-7 rounded-full" />
|
||||
) : (
|
||||
<Avatar className="size-7">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="text-[10px] bg-primary/20 text-primary">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-1.5 mb-0.5">
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link to={profileUrl} className="text-xs font-semibold hover:underline truncate max-w-[140px]">
|
||||
{displayName}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">{timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-foreground/90 leading-relaxed break-words line-clamp-4">
|
||||
{event.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentSkeleton() {
|
||||
return (
|
||||
<div className="flex gap-2.5 px-4 py-2.5">
|
||||
<Skeleton className="size-7 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── modal ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CommentsModalProps {
|
||||
event: NostrEvent | undefined;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CommentsSheet({ event, open, onClose }: CommentsModalProps) {
|
||||
const { data: rawComments = [], isLoading } = useEventComments(open ? event : undefined);
|
||||
|
||||
const comments = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return rawComments.filter((e) => seen.has(e.id) ? false : (seen.add(e.id), true));
|
||||
}, [rawComments]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm animate-in fade-in-0 duration-200"
|
||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||
/>
|
||||
|
||||
{/* Modal — centered, rounded */}
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
|
||||
<div
|
||||
className="pointer-events-auto w-full max-w-lg max-h-[80vh] flex flex-col bg-background/90 backdrop-blur-md rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 fade-in-0 duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 shrink-0">
|
||||
<h3 className="font-semibold text-sm">Comments</h3>
|
||||
<button
|
||||
className="p-1.5 rounded-full hover:bg-secondary transition-colors text-muted-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="size-4" strokeWidth={4} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Compose — top */}
|
||||
{event && (
|
||||
<div className="shrink-0 -mb-px overflow-hidden">
|
||||
<ComposeBox replyTo={event} compact placeholder="Add a comment…" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comment list */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto divide-y divide-border/50">
|
||||
{isLoading ? (
|
||||
<div className="py-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => <CommentSkeleton key={i} />)}
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<p className="text-sm text-muted-foreground">No comments yet. Be the first!</p>
|
||||
</div>
|
||||
) : (
|
||||
comments.map((reply) => <CommentRow key={reply.id} event={reply} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -128,7 +128,7 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
|
||||
/>
|
||||
|
||||
{/* Bottom sheet — sits above the bottom nav bar */}
|
||||
<div className="fixed left-0 right-0 z-50 sidebar:hidden animate-in slide-in-from-bottom-4 duration-200"
|
||||
<div className="fixed left-0 right-0 z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200"
|
||||
style={{ bottom: 'calc(3.5rem + env(safe-area-inset-bottom, 0px))' }}
|
||||
>
|
||||
{/* Results list — reversed so closest to input = most relevant */}
|
||||
|
||||
@@ -541,7 +541,7 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden',
|
||||
'px-4 py-3 rounded-lg hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
@@ -622,7 +622,8 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden',
|
||||
'px-4 py-3 rounded-lg hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden',
|
||||
(isReply || isComment) && 'border-b border-border',
|
||||
className,
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
|
||||
@@ -19,6 +19,8 @@ interface ReactionButtonProps {
|
||||
reactionCount?: number;
|
||||
/** Optional extra class names. */
|
||||
className?: string;
|
||||
/** Show a filled heart icon instead of outline. */
|
||||
filledHeart?: boolean;
|
||||
}
|
||||
|
||||
export function ReactionButton({
|
||||
@@ -27,6 +29,7 @@ export function ReactionButton({
|
||||
eventKind,
|
||||
reactionCount = 0,
|
||||
className,
|
||||
filledHeart = false,
|
||||
}: ReactionButtonProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
@@ -67,10 +70,9 @@ export function ReactionButton({
|
||||
<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',
|
||||
'text-muted-foreground hover:text-pink-500 hover:bg-pink-500/10',
|
||||
className,
|
||||
hasReacted && 'text-pink-500',
|
||||
)}
|
||||
title="React"
|
||||
onClick={(e) => {
|
||||
@@ -82,9 +84,11 @@ export function ReactionButton({
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{hasReacted && userReaction ? (
|
||||
<span className="size-6 flex items-center justify-center leading-none">
|
||||
<RenderResolvedEmoji emoji={userReaction} className="inline-block h-6 w-6" />
|
||||
{filledHeart ? (
|
||||
<Heart className="size-6" fill={hasReacted ? 'currentColor' : 'none'} />
|
||||
) : hasReacted && userReaction ? (
|
||||
<span className="size-5 flex items-center justify-center leading-none">
|
||||
<RenderResolvedEmoji emoji={userReaction} className="inline-block h-5 w-5" />
|
||||
</span>
|
||||
) : (
|
||||
<Heart className="size-5" />
|
||||
|
||||
+288
-20
@@ -9,7 +9,9 @@
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, Camera, ChevronLeft, ChevronRight, X, Images } from 'lucide-react';
|
||||
import { ArrowLeft, Camera, ChevronLeft, ChevronRight, X, Images, MessageCircle, Zap, MoreHorizontal } from 'lucide-react';
|
||||
import { ReactionButton } from '@/components/ReactionButton';
|
||||
import { CommentsSheet } from '@/components/CommentsSheet';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
@@ -19,16 +21,30 @@ import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { useFeed } from '@/hooks/useFeed';
|
||||
import { useInfiniteHotFeed } from '@/hooks/useTrending';
|
||||
import { useInfiniteHotFeed, useEventStats } from '@/hooks/useTrending';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { KindInfoButton } from '@/components/KindInfoButton';
|
||||
import { sidebarItemIcon } from '@/lib/sidebarItems';
|
||||
import { getExtraKindDef } from '@/lib/extraKinds';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FeedItem } from '@/lib/feedUtils';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useOpenPost } from '@/hooks/useOpenPost';
|
||||
import { useBlossomFallback } from '@/hooks/useBlossomFallback';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { canZap } from '@/lib/canZap';
|
||||
import { ZapDialog } from '@/components/ZapDialog';
|
||||
import { RepostMenu } from '@/components/RepostMenu';
|
||||
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
|
||||
import { RepostIcon } from '@/components/icons/RepostIcon';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
const PHOTO_KIND = 20;
|
||||
const photosDef = getExtraKindDef('photos')!;
|
||||
@@ -122,6 +138,177 @@ function PhotoGridThumb({ event, onClick }: { event: NostrEvent; onClick: () =>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Photo overlay card ────────────────────────────────────────────────────────
|
||||
|
||||
/** Single image panel with blossom fallback. */
|
||||
function PhotoSlide({ photo, active }: { photo: PhotoImeta; active: boolean }) {
|
||||
const { src, onError } = useBlossomFallback(photo.url);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const dim = photo.dim?.split('x');
|
||||
const w = dim?.[0] ? parseInt(dim[0]) : undefined;
|
||||
const h = dim?.[1] ? parseInt(dim[1]) : undefined;
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
{/* Blurhash placeholder */}
|
||||
{photo.blurhash && !loaded && (
|
||||
<Blurhash
|
||||
hash={photo.blurhash}
|
||||
width={32}
|
||||
height={32}
|
||||
resolutionX={32}
|
||||
resolutionY={32}
|
||||
punch={1}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
|
||||
/>
|
||||
)}
|
||||
{active && (
|
||||
<img
|
||||
src={src}
|
||||
alt={photo.alt ?? ''}
|
||||
width={w}
|
||||
height={h}
|
||||
className={cn('absolute inset-0 w-full h-full object-cover transition-opacity duration-300', loaded ? 'opacity-100' : 'opacity-0')}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={onError}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function encodeEvent(event: NostrEvent): string {
|
||||
if (event.kind >= 30000 && event.kind < 40000) {
|
||||
const d = event.tags.find(([n]) => n === 'd')?.[1];
|
||||
if (d) return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: d });
|
||||
}
|
||||
return nip19.neventEncode({ id: event.id, author: event.pubkey });
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Vine-style photo card for the overlay: image fills all available height,
|
||||
* author + caption + actions in a compact strip below.
|
||||
*/
|
||||
function PhotoCard({ event, onCommentClick }: { event: NostrEvent; onCommentClick: () => void }) {
|
||||
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 encodedId = useMemo(() => encodeEvent(event), [event]);
|
||||
const { data: stats } = useEventStats(event.id);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const canZapAuthor = user && canZap(metadata);
|
||||
|
||||
const photos = useMemo(() => parsePhotoImeta(event.tags), [event.tags]);
|
||||
const [photoIndex, setPhotoIndex] = useState(0);
|
||||
const currentPhoto = photos[photoIndex] ?? photos[0];
|
||||
|
||||
const { onClick: openPost } = useOpenPost(`/${encodedId}`);
|
||||
|
||||
if (!currentPhoto) return null;
|
||||
|
||||
return (
|
||||
<div className="group relative w-full h-full" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Image fills everything */}
|
||||
<PhotoSlide photo={currentPhoto} active={true} />
|
||||
|
||||
{/* Multi-image prev/next */}
|
||||
{photos.length > 1 && photoIndex > 0 && (
|
||||
<button
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 p-1.5 rounded-full bg-black/50 text-white backdrop-blur-sm z-10"
|
||||
onClick={(e) => { e.stopPropagation(); setPhotoIndex((i) => i - 1); }}
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
{photos.length > 1 && photoIndex < photos.length - 1 && (
|
||||
<button
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-full bg-black/50 text-white backdrop-blur-sm z-10"
|
||||
onClick={(e) => { e.stopPropagation(); setPhotoIndex((i) => i + 1); }}
|
||||
>
|
||||
<ChevronRight className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Meta overlay — always visible on mobile, shown on hover on desktop */}
|
||||
<div className="absolute inset-x-0 bottom-0 z-10 opacity-100 sidebar:opacity-0 sidebar:group-hover:opacity-100 transition-opacity duration-200 pb-[calc(3.5rem+env(safe-area-inset-bottom,0px))] sidebar:pb-0">
|
||||
{/* Gradient scrim */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent pointer-events-none" />
|
||||
|
||||
<div className="relative flex items-center gap-1 px-3 pt-10 pb-3 max-w-xl mx-auto">
|
||||
{/* Avatar + name */}
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link to={profileUrl} onClick={(e) => e.stopPropagation()} 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} onClick={(e) => e.stopPropagation()} 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={(e) => { e.stopPropagation(); 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={(e) => { e.stopPropagation(); setMoreMenuOpen(true); }}
|
||||
>
|
||||
<MoreHorizontal className="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Detail overlay ────────────────────────────────────────────────────────────
|
||||
|
||||
function PhotoDetailOverlay({
|
||||
@@ -137,10 +324,35 @@ function PhotoDetailOverlay({
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const event = events[index];
|
||||
const hasPrev = index > 0;
|
||||
const hasNext = index < events.length - 1;
|
||||
const [commentsEvent, setCommentsEvent] = useState<NostrEvent | null>(null);
|
||||
|
||||
// The strip is a flex row; we translate it so the current card is centred.
|
||||
// During a drag we mutate the style directly (no React re-render) for
|
||||
// zero-jank 1:1 finger tracking, then snap/commit on release.
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
const touchStartY = useRef<number | null>(null);
|
||||
const lockedAxis = useRef<'h' | 'v' | null>(null);
|
||||
|
||||
// Centred offset for a given index (each card is 100vw wide)
|
||||
const offsetFor = (i: number) => -i * 100;
|
||||
|
||||
// Apply transform without transition (live drag)
|
||||
const setOffset = useCallback((pct: number, animated: boolean) => {
|
||||
const el = stripRef.current;
|
||||
if (!el) return;
|
||||
el.style.transition = animated ? 'transform 0.3s cubic-bezier(0.25,0.46,0.45,0.94)' : 'none';
|
||||
el.style.transform = `translateX(${pct}vw)`;
|
||||
}, []);
|
||||
|
||||
// Snap strip to current index whenever index changes
|
||||
useEffect(() => {
|
||||
setOffset(offsetFor(index), true);
|
||||
}, [index, setOffset]);
|
||||
|
||||
// Keyboard nav
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
@@ -151,25 +363,62 @@ function PhotoDetailOverlay({
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [onClose, onPrev, onNext, hasPrev, hasNext]);
|
||||
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
const handleTouchStart = (e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; };
|
||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||
if (touchStartX.current === null) return;
|
||||
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
||||
if (Math.abs(dx) > 50) {
|
||||
if (dx < 0 && hasNext) onNext();
|
||||
if (dx > 0 && hasPrev) onPrev();
|
||||
}
|
||||
touchStartX.current = null;
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
lockedAxis.current = null;
|
||||
// Disable transition while dragging
|
||||
setOffset(offsetFor(index), false);
|
||||
};
|
||||
|
||||
if (!event) return null;
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (touchStartX.current === null || touchStartY.current === null) return;
|
||||
const dx = e.touches[0].clientX - touchStartX.current;
|
||||
const dy = e.touches[0].clientY - touchStartY.current;
|
||||
|
||||
if (!lockedAxis.current) {
|
||||
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
|
||||
lockedAxis.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v';
|
||||
}
|
||||
if (lockedAxis.current !== 'h') return;
|
||||
|
||||
e.preventDefault();
|
||||
const base = offsetFor(index);
|
||||
const dxVw = (dx / window.innerWidth) * 100;
|
||||
// Resist at edges
|
||||
const atEdge = (dx > 0 && !hasPrev) || (dx < 0 && !hasNext);
|
||||
setOffset(base + (atEdge ? dxVw * 0.15 : dxVw), false);
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||
if (touchStartX.current === null || lockedAxis.current !== 'h') {
|
||||
touchStartX.current = null;
|
||||
lockedAxis.current = null;
|
||||
setOffset(offsetFor(index), true);
|
||||
return;
|
||||
}
|
||||
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
||||
touchStartX.current = null;
|
||||
lockedAxis.current = null;
|
||||
|
||||
const threshold = window.innerWidth * 0.3;
|
||||
if (dx < -threshold && hasNext) {
|
||||
// Snap to next — index update will re-snap strip
|
||||
onNext();
|
||||
} else if (dx > threshold && hasPrev) {
|
||||
onPrev();
|
||||
} else {
|
||||
// Snap back to current
|
||||
setOffset(offsetFor(index), true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center"
|
||||
className="fixed inset-0 z-40 bg-black/80 backdrop-blur-sm overflow-hidden"
|
||||
onClick={onClose}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<button
|
||||
@@ -182,7 +431,7 @@ function PhotoDetailOverlay({
|
||||
|
||||
{hasPrev && (
|
||||
<button
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-black/50 hover:bg-black/70 text-white transition-colors"
|
||||
className="hidden sm:block absolute left-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-black/50 hover:bg-black/70 text-white transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onPrev(); }}
|
||||
aria-label="Previous"
|
||||
>
|
||||
@@ -191,7 +440,7 @@ function PhotoDetailOverlay({
|
||||
)}
|
||||
{hasNext && (
|
||||
<button
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-black/50 hover:bg-black/70 text-white transition-colors"
|
||||
className="hidden sm:block absolute right-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-black/50 hover:bg-black/70 text-white transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onNext(); }}
|
||||
aria-label="Next"
|
||||
>
|
||||
@@ -199,12 +448,31 @@ function PhotoDetailOverlay({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Strip: one 100vw slot per event, all rendered side-by-side */}
|
||||
<div
|
||||
className="w-full max-w-xl max-h-[90vh] overflow-y-auto rounded-2xl bg-background shadow-2xl mx-4"
|
||||
ref={stripRef}
|
||||
className="flex h-full will-change-transform"
|
||||
style={{ transform: `translateX(${offsetFor(index)}vw)` }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<NoteCard event={event} />
|
||||
{events.map((ev, i) => {
|
||||
// Only render neighbours to save memory
|
||||
if (Math.abs(i - index) > 1) {
|
||||
return <div key={ev.id} className="w-screen h-full shrink-0" />;
|
||||
}
|
||||
return (
|
||||
<div key={ev.id} className="w-screen h-full shrink-0">
|
||||
<PhotoCard event={ev} onCommentClick={() => setCommentsEvent(ev)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<CommentsSheet
|
||||
event={commentsEvent ?? undefined}
|
||||
open={!!commentsEvent}
|
||||
onClose={() => setCommentsEvent(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+11
-38
@@ -41,7 +41,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
import { CommentsSheet } from '@/components/CommentsSheet';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { canZap } from '@/lib/canZap';
|
||||
@@ -273,7 +273,7 @@ function CommentSkeleton() {
|
||||
|
||||
// ─── VineHeartButton ─────────────────────────────────────────────────────────
|
||||
|
||||
export function VineHeartButton({ event, label }: { event: NostrEvent; label?: string }) {
|
||||
export function VineHeartButton({ event, label, noBackground }: { event: NostrEvent; label?: string; noBackground?: boolean }) {
|
||||
const { user } = useCurrentUser();
|
||||
const userReaction = useUserReaction(event.id);
|
||||
const { mutate: publishEvent } = useNostrPublish();
|
||||
@@ -289,7 +289,8 @@ export function VineHeartButton({ event, label }: { event: NostrEvent; label?: s
|
||||
<VineActionButton label={label}>
|
||||
<button
|
||||
className={cn(
|
||||
'size-11 rounded-full flex items-center justify-center transition-colors backdrop-blur-sm bg-black/20 hover:bg-white/10',
|
||||
'size-11 rounded-full flex items-center justify-center transition-colors backdrop-blur-sm',
|
||||
!noBackground && 'bg-black/20 hover:bg-white/10',
|
||||
hasReacted ? 'text-pink-500' : 'text-white hover:text-pink-400',
|
||||
)}
|
||||
onClick={handleClick}
|
||||
@@ -598,11 +599,7 @@ export function VinesFeedPage() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleCommentClick = useCallback(() => {
|
||||
if (window.innerWidth >= 1280) {
|
||||
setReplyOpen(true);
|
||||
} else {
|
||||
setCommentsOpen(true);
|
||||
}
|
||||
setCommentsOpen(true);
|
||||
}, []);
|
||||
|
||||
useSeoMeta({
|
||||
@@ -765,36 +762,12 @@ export function VinesFeedPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Mobile comments panel — full overlay, xl:hidden ─────────── */}
|
||||
{commentsOpen && (
|
||||
<div className="xl:hidden fixed inset-x-0 top-12 bottom-0 z-30 flex flex-col bg-background/80 backdrop-blur-md overflow-hidden">
|
||||
{/* Compose with back button baked in above it */}
|
||||
{activeVine && (
|
||||
<div className="border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-1 px-4 pt-2">
|
||||
<button
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setCommentsOpen(false)}
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<ComposeBox replyTo={activeVine} compact placeholder="Add a comment…" />
|
||||
</div>
|
||||
)}
|
||||
<VinesCommentsContent activeVine={activeVine} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Reply modal ──────────────────────────────────────────────── */}
|
||||
{activeVine && (
|
||||
<ReplyComposeModal
|
||||
event={activeVine}
|
||||
open={replyOpen}
|
||||
onOpenChange={setReplyOpen}
|
||||
/>
|
||||
)}
|
||||
{/* ── Comments sheet ───────────────────────────────────────────── */}
|
||||
<CommentsSheet
|
||||
event={activeVine}
|
||||
open={commentsOpen}
|
||||
onClose={() => setCommentsOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user