diff --git a/components/ReelFeed.tsx b/components/ReelFeed.tsx index f94be88..ea1fc70 100644 --- a/components/ReelFeed.tsx +++ b/components/ReelFeed.tsx @@ -1,72 +1,158 @@ import { useEffect, useRef, useState } from "react"; -import { useNostrEvents, dateToUnix } from "nostr-react"; +import { useNostrEvents, useNostr, dateToUnix } from "nostr-react"; import { ChevronUp, ChevronDown, Heart, MessageCircle, Share2, User } from "lucide-react"; import { cn } from "@/lib/utils"; +import { nip19, Event as NostrEvent } from "nostr-tools"; +import { useProfile } from "nostr-react"; +import Link from "next/link"; +import { signEvent } from "@/utils/utils"; +import { toast } from "@/components/ui/use-toast"; -// Dummy video data for initial development -const dummyVideos = [ - { - id: "1", - url: "https://sample-videos.com/video123/mp4/720/big_buck_bunny_720p_1mb.mp4", - username: "@user1", - description: "This is an amazing video #nostr #lumina", - likes: 1243, - comments: 89, - shares: 32 - }, - { - id: "2", - url: "https://storage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", - username: "@user2", - description: "Check out this cool Nostr project! #coding", - likes: 853, - comments: 42, - shares: 21 - }, - { - id: "3", - url: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4", - username: "@user3", - description: "Learning about zaps and NIP-57 #bitcoin #lightning", - likes: 2103, - comments: 156, - shares: 78 - }, - { - id: "4", - url: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4", - username: "@user4", - description: "Exploring new Nostr clients #tutorial", - likes: 543, - comments: 37, - shares: 19 - }, - { - id: "5", - url: "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4", - username: "@user5", - description: "Decentralized social media is the future #nostr #web3", - likes: 932, - comments: 64, - shares: 41 - } -]; +// Define interface for NIP-71 video event +interface VideoEvent { + id: string; + pubkey: string; + created_at: number; + title: string; + description: string; + videoUrl: string; + imageUrl: string; + duration?: number; + dimensions?: { width: number; height: number }; + mimeType?: string; +} const ReelFeed: React.FC = () => { - const now = useRef(new Date()); const [currentVideoIndex, setCurrentVideoIndex] = useState(0); const [isLiked, setIsLiked] = useState>({}); const videoRefs = useRef>({}); const [touchStart, setTouchStart] = useState(null); const [touchEnd, setTouchEnd] = useState(null); + const [videoEvents, setVideoEvents] = useState([]); + const { publish } = useNostr(); - // Fetch Nostr events - commented out for now while we use dummy data + // Fetch NIP-71 kind 22 (short video) events const { events } = useNostrEvents({ filter: { - kinds: [1063], + kinds: [22], // NIP-71 short videos + limit: 20, }, }); + // Track reactions to update UI accordingly + const { events: reactions } = useNostrEvents({ + filter: { + kinds: [7], // Reaction events + '#e': videoEvents.map(v => v.id), + }, + }); + + // Update liked status based on fetched reactions + useEffect(() => { + if (!reactions) return; + + // Check local storage for current user pubkey + const storedPubkey = typeof window !== 'undefined' ? localStorage.getItem('pubkey') : null; + if (!storedPubkey) return; + + // Update liked status for each video + const likedStatus: Record = {}; + + reactions.forEach(reaction => { + // Only count reactions from the current user + if (reaction.pubkey === storedPubkey) { + // Find the target event id + const eventTag = reaction.tags.find(tag => tag[0] === 'e'); + if (eventTag && eventTag[1]) { + likedStatus[eventTag[1]] = true; + } + } + }); + + setIsLiked(likedStatus); + }, [reactions]); + + // Parse NIP-71 events + useEffect(() => { + if (!events || events.length === 0) return; + + const parsedEvents: VideoEvent[] = events + .map(event => { + try { + // Find title tag + const titleTag = event.tags.find(tag => tag[0] === "title"); + const title = titleTag ? titleTag[1] : "Untitled Video"; + + // Find duration tag + const durationTag = event.tags.find(tag => tag[0] === "duration"); + const duration = durationTag ? parseInt(durationTag[1]) : undefined; + + // Extract video data from imeta tags + const imetaTags = event.tags.filter(tag => tag[0] === "imeta"); + if (imetaTags.length === 0) return null; + + // Find the first valid imeta tag with a video URL + let videoUrl = ""; + let imageUrl = ""; + let dimensions = undefined; + let mimeType = undefined; + + for (const imeta of imetaTags) { + // Parse dimension info + const dimInfo = imeta.find(item => item.startsWith("dim ")); + if (dimInfo) { + const [width, height] = dimInfo.replace("dim ", "").split("x").map(Number); + dimensions = { width, height }; + } + + // Parse mime type + const mInfo = imeta.find(item => item.startsWith("m ")); + if (mInfo) { + mimeType = mInfo.replace("m ", ""); + } + + // Check if it's a video mime type + if (mimeType && mimeType.startsWith("video/")) { + // Get video URL + const urlInfo = imeta.find(item => item.startsWith("url ")); + if (urlInfo) { + videoUrl = urlInfo.replace("url ", ""); + } + + // Get image preview URL + const imageInfo = imeta.find(item => item.startsWith("image ")); + if (imageInfo) { + imageUrl = imageInfo.replace("image ", ""); + } + + if (videoUrl) break; // Found a valid video URL + } + } + + if (!videoUrl) return null; // Skip if no valid video URL found + + return { + id: event.id, + pubkey: event.pubkey, + created_at: event.created_at, + title, + description: event.content, + videoUrl, + imageUrl, + duration, + dimensions, + mimeType + }; + } catch (error) { + console.error("Error parsing video event:", error); + return null; + } + }) + .filter(Boolean) as VideoEvent[]; // Filter out null values + + setVideoEvents(parsedEvents); + }, [events]); + // Touch handlers for swiping const handleTouchStart = (e: React.TouchEvent) => { setTouchStart(e.targetTouches[0].clientY); @@ -83,7 +169,7 @@ const ReelFeed: React.FC = () => { const isUpSwipe = distance > 50; const isDownSwipe = distance < -50; - if (isUpSwipe && currentVideoIndex < dummyVideos.length - 1) { + if (isUpSwipe && currentVideoIndex < videoEvents.length - 1) { setCurrentVideoIndex(prev => prev + 1); } else if (isDownSwipe && currentVideoIndex > 0) { setCurrentVideoIndex(prev => prev - 1); @@ -95,24 +181,86 @@ const ReelFeed: React.FC = () => { // Play current video and pause others useEffect(() => { + if (videoEvents.length === 0) return; + Object.entries(videoRefs.current).forEach(([id, videoElement]) => { if (videoElement) { - if (id === dummyVideos[currentVideoIndex].id) { + if (id === videoEvents[currentVideoIndex]?.id) { videoElement.play().catch(err => console.error("Error playing video:", err)); } else { videoElement.pause(); } } }); - }, [currentVideoIndex]); + }, [currentVideoIndex, videoEvents]); - const toggleLike = (id: string) => { - setIsLiked(prev => ({ - ...prev, - [id]: !prev[id] - })); + // Toggle like and send a Nostr reaction event + const toggleLike = async (id: string) => { + // Check if user is logged in + const loginType = typeof window !== 'undefined' ? localStorage.getItem('loginType') : null; + + if (!loginType) { + toast({ + title: "Login required", + description: "Please login to like videos", + variant: "destructive" + }); + return; + } + + // Create a reaction event + const eventToSend: Partial = { + kind: 7, + content: isLiked[id] ? '' : '+', // Empty content to unlike, + to like + tags: [ + ['e', id], // Reference to the video event + ['k', '22'] // Specify that we're reacting to a kind 22 event + ], + created_at: dateToUnix(), + }; + + try { + // Sign and publish the event + const signedEvent = await signEvent(loginType, eventToSend as NostrEvent); + + if (signedEvent) { + publish(signedEvent); + + // Update UI immediately + setIsLiked(prev => ({ + ...prev, + [id]: !prev[id] + })); + + toast({ + title: isLiked[id] ? "Unliked" : "Liked", + description: `Successfully ${isLiked[id] ? 'removed like from' : 'liked'} the video`, + }); + } else { + toast({ + title: "Error", + description: "Failed to sign reaction event", + variant: "destructive" + }); + } + } catch (error) { + console.error("Error sending reaction:", error); + toast({ + title: "Error", + description: "Failed to send reaction", + variant: "destructive" + }); + } }; + if (videoEvents.length === 0) { + return ( +
+

Loading videos...

+
+ ); + } + return (
{ )}
- {currentVideoIndex < dummyVideos.length - 1 && ( + {currentVideoIndex < videoEvents.length - 1 && ( @@ -143,69 +291,22 @@ const ReelFeed: React.FC = () => {
{/* Videos */} - {dummyVideos.map((video, index) => ( -
( + -
+ video={video} + index={index} + currentIndex={currentVideoIndex} + videoRef={el => videoRefs.current[video.id] = el} + isLiked={!!isLiked[video.id]} + toggleLike={() => toggleLike(video.id)} + reactionCount={countReactionsForEvent(reactions, video.id)} + /> ))} {/* Progress indicators */}
- {dummyVideos.map((_, index) => ( + {videoEvents.map((_, index) => (
{ ); } +// Helper function to count reactions for a specific event +function countReactionsForEvent(reactions: NostrEvent[], eventId: string): number { + if (!reactions) return 0; + + return reactions.filter(reaction => { + const eventTag = reaction.tags.find(tag => tag[0] === 'e'); + return eventTag && eventTag[1] === eventId && reaction.content !== ''; + }).length; +} + +interface VideoEventDisplayProps { + video: VideoEvent; + index: number; + currentIndex: number; + videoRef: (el: HTMLVideoElement | null) => void; + isLiked: boolean; + toggleLike: () => void; + reactionCount: number; +} + +const VideoEventDisplay: React.FC = ({ + video, + index, + currentIndex, + videoRef, + isLiked, + toggleLike, + reactionCount +}) => { + const { data: userData } = useProfile({ + pubkey: video.pubkey, + }); + + const username = userData?.name || userData?.display_name || + `${nip19.npubEncode(video.pubkey).slice(0, 8)}...`; + + const profileImageSrc = userData?.picture || `https://robohash.org/${video.pubkey}`; + const npub = nip19.npubEncode(video.pubkey); + const profileUrl = `/profile/${npub}`; + + // Use real reaction counts + const likesCount = reactionCount; + const commentsCount = 0; // Could be implemented by fetching kind 1 events that reference this video + const sharesCount = 0; // Could be implemented by tracking reposts + + return ( +
+
+ ); +}; + export default ReelFeed; \ No newline at end of file