From fc1d5532a88fad3b50d8d0f0a70cd68d86610ace Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:40:50 +0000 Subject: [PATCH] Implement upload from URL and enhanced reels interface Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> --- app/reel/page.tsx | 29 +- components/ReelFeed.tsx | 472 +++++++++++++++++++++++++++++++-- components/UploadComponent.tsx | 203 +++++++++++--- utils/utils.ts | 5 + 4 files changed, 639 insertions(+), 70 deletions(-) diff --git a/app/reel/page.tsx b/app/reel/page.tsx index cae1399..dc422dd 100644 --- a/app/reel/page.tsx +++ b/app/reel/page.tsx @@ -6,10 +6,37 @@ import { useEffect } from "react"; export default function ReelPage() { useEffect(() => { document.title = `Reels | LUMINA`; + // Prevent scrolling on this page for a full-screen experience + document.body.style.overflow = 'hidden'; + + // Hide the header and bottom bar when on the reel page + const topNav = document.querySelector('nav'); + const bottomBar = document.querySelector('.fixed.bottom-0'); + + if (topNav) { + (topNav as HTMLElement).style.display = 'none'; + } + + if (bottomBar) { + (bottomBar as HTMLElement).style.display = 'none'; + } + + return () => { + // Restore scrolling and show navigation elements when leaving the page + document.body.style.overflow = ''; + + if (topNav) { + (topNav as HTMLElement).style.display = ''; + } + + if (bottomBar) { + (bottomBar as HTMLElement).style.display = ''; + } + }; }, []); return ( -
+
); diff --git a/components/ReelFeed.tsx b/components/ReelFeed.tsx index 8490427..fa1df5b 100644 --- a/components/ReelFeed.tsx +++ b/components/ReelFeed.tsx @@ -1,40 +1,460 @@ -import { useRef } from "react"; -import { useNostrEvents, dateToUnix } from "nostr-react"; -import NoteCard from './NoteCard'; +import { useEffect, useRef, useState } from "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 { blacklistPubkeys, signEvent } from "@/utils/utils"; +import { toast } from "@/components/ui/use-toast"; + +// 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()); // Make sure current time isn't re-rendered - - const { events } = useNostrEvents({ + 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 [loadMoreCounter, setLoadMoreCounter] = useState(1); // Counter to trigger loading more events + const { publish } = useNostr(); + + // Fetch NIP-71 kind 22 (short video) events with increased limit + const { events: rawEvents } = useNostrEvents({ filter: { - // since: dateToUnix(now.current), // all new events from now - // since: 0, - // limit: 100, - kinds: [1063], + kinds: [22], // NIP-71 short videos + limit: 50 * loadMoreCounter, // Increase limit based on counter }, }); - // const filteredEvents = events.filter((event) => event.content.includes(".jpg")); - // filter events with regex that checks for png, jpg, or gif - let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|jpeg)/g)?.[0]); + // Filter out events from blacklisted pubkeys + const events = rawEvents?.filter(event => { + const isBlacklisted = blacklistPubkeys.has(event.pubkey); + return !isBlacklisted; + }) || []; - // now filter all events with a tag[0] == t and tag[1] == nsfw - // filteredEvents = filteredEvents.filter((event) => event.tags.map((tag) => tag[0] == "t" && tag[1] == "nsfw")); - filteredEvents = filteredEvents.filter((event) => !event.tags.some((tag) => { return tag[0] == 't' && tag[1] == 'nsfw'})); - // filter out all replies - filteredEvents = filteredEvents.filter((event) => !event.tags.some((tag) => { return tag[0] == 'e' })); + // Load more events if we don't have enough after filtering + useEffect(() => { + // Check if we have enough events after filtering + if (events.length < 20 && rawEvents && rawEvents.length > 0 && + // Make sure we're not in an infinite loop by checking if we have more events to load + rawEvents.length >= 50 * (loadMoreCounter - 1)) { + // Only increase counter if we actually received events but need more + setLoadMoreCounter(prev => prev + 1); + } + }, [events, rawEvents, loadMoreCounter]); + + // 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); + }; + + const handleTouchMove = (e: React.TouchEvent) => { + setTouchEnd(e.targetTouches[0].clientY); + }; + + const handleTouchEnd = () => { + if (!touchStart || !touchEnd) return; + + const distance = touchStart - touchEnd; + const isUpSwipe = distance > 50; + const isDownSwipe = distance < -50; + + if (isUpSwipe && currentVideoIndex < videoEvents.length - 1) { + setCurrentVideoIndex(prev => prev + 1); + } else if (isDownSwipe && currentVideoIndex > 0) { + setCurrentVideoIndex(prev => prev - 1); + } + + setTouchStart(null); + setTouchEnd(null); + }; + + // Play current video and pause others + useEffect(() => { + if (videoEvents.length === 0) return; + + Object.entries(videoRefs.current).forEach(([id, videoElement]) => { + if (videoElement) { + if (id === videoEvents[currentVideoIndex]?.id) { + videoElement.play().catch(err => console.error("Error playing video:", err)); + } else { + videoElement.pause(); + } + } + }); + }, [currentVideoIndex, videoEvents]); + + // 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 ( - <> -

Reel Feed

- {filteredEvents.map((event) => ( - //

{event.pubkey} posted: {event.content}

-
- -
+
+ {/* Navigation indicators */} +
+ {currentVideoIndex > 0 && ( + + )} +
+
+ {currentVideoIndex < videoEvents.length - 1 && ( + + )} +
+ + {/* Videos */} + {videoEvents.map((video, index) => ( + videoRefs.current[video.id] = el} + isLiked={!!isLiked[video.id]} + toggleLike={() => toggleLike(video.id)} + reactionCount={countReactionsForEvent(reactions, video.id)} + /> ))} - + + {/* Progress indicators */} +
+ {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 diff --git a/components/UploadComponent.tsx b/components/UploadComponent.tsx index 94c400d..4302e70 100644 --- a/components/UploadComponent.tsx +++ b/components/UploadComponent.tsx @@ -111,6 +111,8 @@ const UploadComponent: React.FC = () => { const [shouldFetch, setShouldFetch] = useState(false) const [serverChoice, setServerChoice] = useState("blossom.band") const [enableNip89, setEnableNip89] = useState(false) + const [uploadMethod, setUploadMethod] = useState<"file" | "url">("file") + const [imageUrl, setImageUrl] = useState("") const { events, isLoading: isNoteLoading } = useNostrEvents({ filter: shouldFetch @@ -166,6 +168,44 @@ const UploadComponent: React.FC = () => { } } + const handleUrlChange = (event: ChangeEvent) => { + const url = event.target.value + setImageUrl(url) + + // Set preview if URL looks like an image + if (url && (url.match(/\.(jpeg|jpg|gif|png|webp)$/i) || url.includes("imgur.com") || url.includes("image"))) { + setPreviewUrl(url) + } else { + setPreviewUrl("") + } + } + + const fetchFileFromUrl = async (url: string): Promise => { + try { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.statusText}`) + } + + const blob = await response.blob() + + // Check if it's an image + if (!blob.type.startsWith('image/')) { + throw new Error('URL does not point to a valid image') + } + + // Create a file from the blob + const filename = url.split('/').pop() || 'image' + const file = new File([blob], filename, { type: blob.type }) + + return file + } catch (error) { + console.error('Error fetching file from URL:', error) + alert(`Error fetching image from URL: ${error}`) + return null + } + } + const handleTextChange = (event: ChangeEvent) => { const { value } = event.target @@ -194,14 +234,32 @@ const UploadComponent: React.FC = () => { const formData = new FormData(event.currentTarget) const desc = formData.get("description") as string - let file = formData.get("file") as File + let file: File | null = null + + // Handle file upload vs URL upload + if (uploadMethod === "file") { + file = formData.get("file") as File + if (file && !file.size) { + file = null + } + } else if (uploadMethod === "url") { + const url = imageUrl.trim() + if (url) { + file = await fetchFileFromUrl(url) + if (!file) { + setIsLoading(false) + return + } + } + } + let sha256 = "" let finalNoteContent = desc let finalFileUrl = "" console.log("File:", file) - if (!desc && !file.size) { - alert("Please enter a description and/or upload a file") + if (!desc && !file) { + alert("Please enter a description and/or upload a file or provide an image URL") setIsLoading(false) return } @@ -288,7 +346,7 @@ const UploadComponent: React.FC = () => { } } - if (finalFileUrl) { + if (finalFileUrl && file) { const image = new Image() image.src = URL.createObjectURL(file) await new Promise((resolve) => { @@ -384,48 +442,107 @@ const UploadComponent: React.FC = () => {
- -
-
- {previewUrl ? ( -
- Preview -
- ) : ( - - )} - -
-
- {previewUrl ? "Replace image" : "Add image"} -
-
- Supported formats: JPEG, PNG, WebP -
-
- - -
+ +
+ +
+ {uploadMethod === "file" ? ( +
+ +
+
+ {previewUrl ? ( +
+ Preview +
+ ) : ( + + )} + +
+
+ {previewUrl ? "Replace image" : "Add image"} +
+
+ Supported formats: JPEG, PNG, WebP +
+
+ + +
+
+
+ ) : ( +
+ +
+ +
+ Enter a direct link to an image (JPEG, PNG, WebP, GIF) +
+ {previewUrl && ( +
+
Preview:
+ URL Preview setPreviewUrl("")} + /> +
+ )} +
+
+ )} +
diff --git a/utils/utils.ts b/utils/utils.ts index c406b09..0b3ce8e 100644 --- a/utils/utils.ts +++ b/utils/utils.ts @@ -2,6 +2,11 @@ import { Event as NostrEvent, finalizeEvent} from "nostr-tools"; import { hexToBytes } from "@noble/hashes/utils" import { signEventWithBunker } from "./bunkerUtils"; +// Simple blacklist for pubkeys (can be expanded later) +export const blacklistPubkeys = new Set([ + // Add any blacklisted pubkeys here if needed +]); + // Check if the event has nsfw or sexy tags export function hasNsfwContent(tags: string[][]): boolean { return tags.some(tag =>