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 (
+
+ );
+ }
return (
- <>
- Reel Feed
- {filteredEvents.map((event) => (
- // {event.pubkey} posted: {event.content}
-
-
-
+
+ {/* Navigation indicators */}
+
+ {currentVideoIndex > 0 && (
+ setCurrentVideoIndex(prev => Math.max(0, prev - 1))}
+ >
+
+
+ )}
+
+
+ {currentVideoIndex < videoEvents.length - 1 && (
+ setCurrentVideoIndex(prev => Math.min(videoEvents.length - 1, prev + 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 (
+
+
+
+ {/* Video info overlay */}
+
+
+
+
+
+
+ {profileImageSrc ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
{username}
+
+ {video.title &&
{video.title}
}
+
+
+
{video.description}
+
+
+ {/* Interaction buttons */}
+
+
+
+ {likesCount}
+
+
+
+ {commentsCount}
+
+
+
+ {sharesCount}
+
+
+
+
+
+ );
+};
+
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 = () => {
-
Image
-
-
- {previewUrl ? (
-
-
-
- ) : (
-
- )}
-
-
-
- {previewUrl ? "Replace image" : "Add image"}
-
-
- Supported formats: JPEG, PNG, WebP
-
-
-
-
- {previewUrl ? "Change file" : "Select file"}
-
-
-
+
Image Upload Method
+
+ setUploadMethod("file")}
+ className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
+ uploadMethod === "file"
+ ? "bg-primary text-primary-foreground"
+ : "bg-muted hover:bg-muted/80"
+ }`}
+ >
+ Upload File
+
+ setUploadMethod("url")}
+ className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
+ uploadMethod === "url"
+ ? "bg-primary text-primary-foreground"
+ : "bg-muted hover:bg-muted/80"
+ }`}
+ >
+ From URL
+
+ {uploadMethod === "file" ? (
+
+
Image
+
+
+ {previewUrl ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {previewUrl ? "Replace image" : "Add image"}
+
+
+ Supported formats: JPEG, PNG, WebP
+
+
+
+
+ {previewUrl ? "Change file" : "Select file"}
+
+
+
+
+
+ ) : (
+
+
Image URL
+
+
+
+ Enter a direct link to an image (JPEG, PNG, WebP, GIF)
+
+ {previewUrl && (
+
+
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 =>