From 0ac0dc056973b928a97ff084331c90d245ee277c Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Tue, 17 Feb 2026 01:19:39 -0600 Subject: [PATCH] DRY: merge VineCard into NoteCard, single component for all event kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NoteCard now handles both kind 1 (text notes) and kind 34236 (vines) - Extracted NoteBody (text + images) and VineBody (title + video + hashtags) as internal sub-components within NoteCard - Same avatar, header, action buttons (reply, repost, react, zap, more), more menu, and reply modal shared across all kinds - Addressable events (kind 30000+) use naddr encoding for navigation - Deleted standalone VineCard.tsx โ€” no longer needed - Updated SearchPage and VinesPage to use NoteCard for everything Co-authored-by: shakespeare.diy --- src/components/NoteCard.tsx | 216 +++++++++++++++++++++++++++--------- src/components/VineCard.tsx | 161 --------------------------- src/pages/SearchPage.tsx | 5 +- src/pages/VinesPage.tsx | 22 ++-- 4 files changed, 175 insertions(+), 229 deletions(-) delete mode 100644 src/components/VineCard.tsx diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index b4df4ff6..a5746bcc 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -1,5 +1,5 @@ import { Link, useNavigate } from 'react-router-dom'; -import { MessageCircle, Repeat2, Zap, MoreHorizontal } from 'lucide-react'; +import { MessageCircle, Repeat2, Zap, MoreHorizontal, Play } from 'lucide-react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { NoteContent } from '@/components/NoteContent'; import { ReactionButton } from '@/components/ReactionButton'; @@ -9,7 +9,7 @@ import { genUserName } from '@/lib/genUserName'; import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; import { nip19 } from 'nostr-tools'; -import { useMemo, useState } from 'react'; +import { useMemo, useState, useRef } from 'react'; import type { NostrEvent } from '@nostrify/nostrify'; import { NoteMoreMenu } from '@/components/NoteMoreMenu'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; @@ -33,6 +33,40 @@ function extractImages(content: string): string[] { return matches || []; } +/** Gets a tag value by name. */ +function getTag(tags: string[][], name: string): string | undefined { + return tags.find(([n]) => n === name)?.[1]; +} + +/** Parse imeta tag into structured object for kind 34236. */ +function parseImeta(tags: string[][]): { url?: string; thumbnail?: string } { + const imetaTag = tags.find(([name]) => name === 'imeta'); + if (!imetaTag) return {}; + const result: Record = {}; + for (let i = 1; i < imetaTag.length; i++) { + const part = imetaTag[i]; + const spaceIdx = part.indexOf(' '); + if (spaceIdx === -1) continue; + const key = part.slice(0, spaceIdx); + const value = part.slice(spaceIdx + 1); + if (key === 'url') result.url = value; + else if (key === 'image') result.thumbnail = value; + } + return result; +} + +/** Encodes the NIP-19 identifier for navigating to an event. */ +function encodeEventId(event: NostrEvent): string { + // Addressable events use naddr + if (event.kind >= 30000 && event.kind < 40000) { + const dTag = getTag(event.tags, 'd'); + if (dTag) { + return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag }); + } + } + return nip19.neventEncode({ id: event.id, author: event.pubkey }); +} + export function NoteCard({ event, className }: NoteCardProps) { const navigate = useNavigate(); const author = useAuthor(event.pubkey); @@ -40,15 +74,22 @@ export function NoteCard({ event, className }: NoteCardProps) { const displayName = metadata?.name || genUserName(event.pubkey); const nip05 = metadata?.nip05; const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]); - const neventId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event.id, event.pubkey]); - const images = useMemo(() => extractImages(event.content), [event.content]); + const encodedId = useMemo(() => encodeEventId(event), [event]); const { data: stats } = useEventStats(event.id); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [replyOpen, setReplyOpen] = useState(false); - // Check if content is a reply - const isReply = event.tags.some(([name]) => name === 'e'); - const replyTo = event.tags.find(([name, , , marker]) => name === 'p' && marker !== 'mention'); + const isVine = event.kind === 34236; + + // Kind 1 specific + const images = useMemo(() => isVine ? [] : extractImages(event.content), [event.content, isVine]); + const isReply = !isVine && event.tags.some(([name]) => name === 'e'); + const replyTo = !isVine ? event.tags.find(([name, , , marker]) => name === 'p' && marker !== 'mention') : undefined; + + // Kind 34236 specific + const imeta = useMemo(() => isVine ? parseImeta(event.tags) : undefined, [event.tags, isVine]); + const vineTitle = isVine ? getTag(event.tags, 'title') : undefined; + const hashtags = isVine ? event.tags.filter(([n]) => n === 't').map(([, v]) => v) : []; return (
navigate(`/${neventId}`)} + onClick={() => navigate(`/${encodedId}`)} > - {/* Reply context */} + {/* Reply context (kind 1 only) */} {isReply && replyTo && ( )} @@ -69,7 +110,7 @@ export function NoteCard({ event, className }: NoteCardProps) { - {displayName[0].toUpperCase()} + {displayName[0]?.toUpperCase()} @@ -99,52 +140,24 @@ export function NoteCard({ event, className }: NoteCardProps) { - {/* Text content */} -
- -
- - {/* Image attachments */} - {images.length > 0 && ( -
1 && 'grid grid-cols-2 gap-0.5', - )}> - {images.slice(0, 4).map((url, i) => ( - e.stopPropagation()} - > - - - ))} -
+ {/* Body โ€” kind-specific content */} + {isVine ? ( + + ) : ( + )} - {/* Action buttons */} + {/* Action buttons โ€” shared across all kinds */}
- {/* Reply */} - {/* Repost */} - {/* React */} - {/* Zap */} - {/* More */}
- {/* More menu dialog */} - - {/* Reply compose modal */} @@ -197,6 +201,108 @@ export function NoteCard({ event, className }: NoteCardProps) { ); } +/** Body content for kind 1 text notes. */ +function NoteBody({ event, images }: { event: NostrEvent; images: string[] }) { + return ( + <> +
+ +
+ {images.length > 0 && ( +
1 && 'grid grid-cols-2 gap-0.5', + )}> + {images.slice(0, 4).map((url, i) => ( + e.stopPropagation()} + > + + + ))} +
+ )} + + ); +} + +/** Body content for kind 34236 vine events. */ +function VineBody({ title, imeta, hashtags }: { title?: string; imeta?: { url?: string; thumbnail?: string }; hashtags: string[] }) { + const videoRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + + const handlePlayToggle = (e: React.MouseEvent) => { + e.stopPropagation(); + const video = videoRef.current; + if (!video) return; + if (video.paused) { + video.play(); + setIsPlaying(true); + } else { + video.pause(); + setIsPlaying(false); + } + }; + + return ( + <> + {title && ( +

{title}

+ )} + + {imeta?.url && ( +
+
+ )} + + {hashtags.length > 0 && ( +
+ {hashtags.slice(0, 5).map((tag) => ( + e.stopPropagation()} + > + #{tag} + + ))} +
+ )} + + ); +} + function ReplyContext({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); const name = author.data?.metadata?.name || genUserName(pubkey); diff --git a/src/components/VineCard.tsx b/src/components/VineCard.tsx deleted file mode 100644 index ae7d733a..00000000 --- a/src/components/VineCard.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { Link, useNavigate } from 'react-router-dom'; -import { Play, Heart, MessageCircle, Repeat2, MoreHorizontal } from 'lucide-react'; -import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; -import { useAuthor } from '@/hooks/useAuthor'; -import { genUserName } from '@/lib/genUserName'; -import { timeAgo } from '@/lib/timeAgo'; -import { cn } from '@/lib/utils'; -import { nip19 } from 'nostr-tools'; -import { useMemo, useState, useRef } from 'react'; -import type { NostrEvent } from '@nostrify/nostrify'; - -interface VineCardProps { - event: NostrEvent; - className?: string; -} - -/** Parse imeta tag into a structured object. */ -function parseImeta(tags: string[][]): { url?: string; mime?: string; thumbnail?: string; dim?: string; blurhash?: string } { - const imetaTag = tags.find(([name]) => name === 'imeta'); - if (!imetaTag) return {}; - - const result: Record = {}; - for (let i = 1; i < imetaTag.length; i++) { - const part = imetaTag[i]; - const spaceIdx = part.indexOf(' '); - if (spaceIdx === -1) continue; - const key = part.slice(0, spaceIdx); - const value = part.slice(spaceIdx + 1); - // Map imeta keys - if (key === 'url') result.url = value; - else if (key === 'm') result.mime = value; - else if (key === 'image') result.thumbnail = value; - else if (key === 'dim') result.dim = value; - else if (key === 'blurhash') result.blurhash = value; - } - return result; -} - -function getTag(tags: string[][], name: string): string | undefined { - return tags.find(([n]) => n === name)?.[1]; -} - -export function VineCard({ event, className }: VineCardProps) { - const navigate = useNavigate(); - const author = useAuthor(event.pubkey); - const metadata = author.data?.metadata; - const displayName = metadata?.name || genUserName(event.pubkey); - const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]); - - const imeta = useMemo(() => parseImeta(event.tags), [event.tags]); - const title = getTag(event.tags, 'title'); - const hashtags = event.tags.filter(([n]) => n === 't').map(([, v]) => v); - - const videoRef = useRef(null); - const [isPlaying, setIsPlaying] = useState(false); - - const handlePlayToggle = (e: React.MouseEvent) => { - e.stopPropagation(); - const video = videoRef.current; - if (!video) return; - if (video.paused) { - video.play(); - setIsPlaying(true); - } else { - video.pause(); - setIsPlaying(false); - } - }; - - const neventId = useMemo(() => { - const dTag = getTag(event.tags, 'd'); - if (dTag) { - return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag }); - } - return nip19.neventEncode({ id: event.id, author: event.pubkey }); - }, [event]); - - return ( -
- {/* Author header */} -
- e.stopPropagation()}> - - - - {displayName[0]?.toUpperCase()} - - - -
-
- e.stopPropagation()} - > - {displayName} - - ยท - {timeAgo(event.created_at)} -
- {title && ( -

{title}

- )} -
-
- - {/* Video */} - {imeta.url && ( -
-
- )} - - {/* Hashtags */} - {hashtags.length > 0 && ( -
- {hashtags.slice(0, 5).map((tag) => ( - e.stopPropagation()} - > - #{tag} - - ))} -
- )} - - {/* Spacer */} -
-
- ); -} diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index c9264733..9ff3fb84 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -4,7 +4,6 @@ import { useState, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { MainLayout } from '@/components/MainLayout'; import { NoteCard } from '@/components/NoteCard'; -import { VineCard } from '@/components/VineCard'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -151,9 +150,7 @@ export function SearchPage() { ) : posts.length > 0 ? (
{posts.map((event) => ( - event.kind === 34236 - ? - : + ))}
) : searchQuery.trim() ? ( diff --git a/src/pages/VinesPage.tsx b/src/pages/VinesPage.tsx index ed0a6e28..74c2bfe8 100644 --- a/src/pages/VinesPage.tsx +++ b/src/pages/VinesPage.tsx @@ -2,7 +2,7 @@ import { useSeoMeta } from '@unhead/react'; import { ArrowLeft } from 'lucide-react'; import { Link } from 'react-router-dom'; import { MainLayout } from '@/components/MainLayout'; -import { VineCard } from '@/components/VineCard'; +import { NoteCard } from '@/components/NoteCard'; import { Skeleton } from '@/components/ui/skeleton'; import { useStreamVines } from '@/hooks/useStreamVines'; @@ -35,7 +35,7 @@ export function VinesPage() { ) : vines.length > 0 ? (
{vines.map((event) => ( - + ))}
) : ( @@ -50,19 +50,23 @@ export function VinesPage() { function VineSkeleton() { return ( -
-
- -
+
+
+ +
- +
+ +
+ + + +
- -
); }