Implement upload from URL and enhanced reels interface

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-08-25 17:40:50 +00:00
parent d43326944f
commit fc1d5532a8
4 changed files with 639 additions and 70 deletions

View File

@@ -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 (
<div className="py-6 px-6">
<div className="fixed inset-0 h-screen w-screen overflow-hidden z-50">
<ReelFeed />
</div>
);

View File

@@ -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<Record<string, boolean>>({});
const videoRefs = useRef<Record<string, HTMLVideoElement | null>>({});
const [touchStart, setTouchStart] = useState<number | null>(null);
const [touchEnd, setTouchEnd] = useState<number | null>(null);
const [videoEvents, setVideoEvents] = useState<VideoEvent[]>([]);
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<string, boolean> = {};
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<NostrEvent> = {
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 (
<div className="fixed inset-0 bg-black flex items-center justify-center text-white">
<p>Loading videos...</p>
</div>
);
}
return (
<>
<h2>Reel Feed</h2>
{filteredEvents.map((event) => (
// <p key={event.id}>{event.pubkey} posted: {event.content}</p>
<div key={event.id} className="py-6">
<NoteCard key={event.id} pubkey={event.pubkey} text={event.content} eventId={event.id} tags={event.tags} event={event} showViewNoteCardButton={true} />
</div>
<div
className="fixed inset-0 bg-black overflow-hidden"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Navigation indicators */}
<div className="absolute top-1/2 left-6 z-30 transform -translate-y-1/2">
{currentVideoIndex > 0 && (
<button
className="p-2 rounded-full bg-black/20 text-white hover:bg-black/40 transition-colors"
onClick={() => setCurrentVideoIndex(prev => Math.max(0, prev - 1))}
>
<ChevronUp className="h-8 w-8" />
</button>
)}
</div>
<div className="absolute bottom-1/2 left-6 z-30 transform translate-y-1/2">
{currentVideoIndex < videoEvents.length - 1 && (
<button
className="p-2 rounded-full bg-black/20 text-white hover:bg-black/40 transition-colors"
onClick={() => setCurrentVideoIndex(prev => Math.min(videoEvents.length - 1, prev + 1))}
>
<ChevronDown className="h-8 w-8" />
</button>
)}
</div>
{/* Videos */}
{videoEvents.map((video, index) => (
<VideoEventDisplay
key={video.id}
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 */}
<div className="absolute top-4 left-0 right-0 flex justify-center gap-1 px-4 z-30">
{videoEvents.map((_, index) => (
<div
key={index}
className={cn(
"h-1 rounded-full transition-all",
index === currentVideoIndex
? "bg-white w-6"
: "bg-white/40 w-4"
)}
/>
))}
</div>
</div>
);
}
// 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<VideoEventDisplayProps> = ({
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 (
<div
className={cn(
"absolute inset-0 transition-transform duration-300",
index === currentIndex ? "translate-y-0" :
index < currentIndex ? "-translate-y-full" : "translate-y-full"
)}
>
<video
ref={videoRef}
src={video.videoUrl}
poster={video.imageUrl}
className="w-full h-full object-cover"
loop
muted
playsInline
autoPlay={index === currentIndex}
/>
{/* Video info overlay */}
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/70 to-transparent">
<div className="flex items-end justify-between">
<div className="text-white max-w-[80%]">
<div className="flex items-center gap-2 mb-2">
<Link href={profileUrl}>
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center overflow-hidden">
{profileImageSrc ? (
<img src={profileImageSrc} alt={username} className="w-full h-full object-cover" />
) : (
<User className="h-6 w-6 text-white" />
)}
</div>
</Link>
<div>
<Link href={profileUrl}>
<p className="font-bold hover:underline">{username}</p>
</Link>
{video.title && <p className="text-sm font-semibold">{video.title}</p>}
</div>
</div>
<p className="text-sm">{video.description}</p>
</div>
{/* Interaction buttons */}
<div className="flex flex-col items-center gap-4">
<button
className="flex flex-col items-center hover:scale-110 transition-transform"
onClick={toggleLike}
>
<Heart
className={cn(
"h-8 w-8",
isLiked ? "fill-red-500 text-red-500" : "text-white"
)}
/>
<span className="text-white text-xs mt-1">{likesCount}</span>
</button>
<button className="flex flex-col items-center hover:scale-110 transition-transform">
<MessageCircle className="h-8 w-8 text-white" />
<span className="text-white text-xs mt-1">{commentsCount}</span>
</button>
<button className="flex flex-col items-center hover:scale-110 transition-transform">
<Share2 className="h-8 w-8 text-white" />
<span className="text-white text-xs mt-1">{sharesCount}</span>
</button>
</div>
</div>
</div>
</div>
);
};
export default ReelFeed;

View File

@@ -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<HTMLInputElement>) => {
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<File | null> => {
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<HTMLTextAreaElement>) => {
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 = () => {
</div>
<div className="space-y-2">
<Label htmlFor="file">Image</Label>
<div className="border-2 border-dashed rounded-lg p-6 transition-colors hover:border-primary/50 hover:bg-muted/50">
<div className="flex flex-col items-center space-y-4 text-center">
{previewUrl ? (
<div className="w-full rounded-md">
<img
src={previewUrl}
alt="Preview"
/>
</div>
) : (
<ImageIcon className="h-10 w-10 text-muted-foreground" />
)}
<div className="space-y-2">
<div className="text-sm font-medium">
{previewUrl ? "Replace image" : "Add image"}
</div>
<div className="text-xs text-muted-foreground">
Supported formats: JPEG, PNG, WebP
</div>
</div>
<label
htmlFor="file"
className={`relative cursor-pointer rounded-md px-4 py-2 text-sm font-medium ring-offset-background transition-colors
${previewUrl ? 'bg-muted hover:bg-muted/80' : 'bg-primary text-primary-foreground hover:bg-primary/90'}`}
>
{previewUrl ? "Change file" : "Select file"}
<Input
id="file"
name="file"
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleFileChange}
className="sr-only"
/>
</label>
</div>
<Label>Image Upload Method</Label>
<div className="flex space-x-4">
<button
type="button"
onClick={() => 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
</button>
<button
type="button"
onClick={() => 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
</button>
</div>
</div>
{uploadMethod === "file" ? (
<div className="space-y-2">
<Label htmlFor="file">Image</Label>
<div className="border-2 border-dashed rounded-lg p-6 transition-colors hover:border-primary/50 hover:bg-muted/50">
<div className="flex flex-col items-center space-y-4 text-center">
{previewUrl ? (
<div className="w-full rounded-md">
<img
src={previewUrl}
alt="Preview"
/>
</div>
) : (
<ImageIcon className="h-10 w-10 text-muted-foreground" />
)}
<div className="space-y-2">
<div className="text-sm font-medium">
{previewUrl ? "Replace image" : "Add image"}
</div>
<div className="text-xs text-muted-foreground">
Supported formats: JPEG, PNG, WebP
</div>
</div>
<label
htmlFor="file"
className={`relative cursor-pointer rounded-md px-4 py-2 text-sm font-medium ring-offset-background transition-colors
${previewUrl ? 'bg-muted hover:bg-muted/80' : 'bg-primary text-primary-foreground hover:bg-primary/90'}`}
>
{previewUrl ? "Change file" : "Select file"}
<Input
id="file"
name="file"
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleFileChange}
className="sr-only"
/>
</label>
</div>
</div>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="imageUrl">Image URL</Label>
<div className="space-y-4">
<Input
id="imageUrl"
name="imageUrl"
type="url"
placeholder="https://example.com/image.jpg"
value={imageUrl}
onChange={handleUrlChange}
className="w-full"
/>
<div className="text-xs text-muted-foreground">
Enter a direct link to an image (JPEG, PNG, WebP, GIF)
</div>
{previewUrl && (
<div className="border rounded-lg p-4">
<div className="text-sm font-medium mb-2">Preview:</div>
<img
src={previewUrl}
alt="URL Preview"
className="max-w-full h-auto rounded-md"
onError={() => setPreviewUrl("")}
/>
</div>
)}
</div>
</div>
)}
<Separator className="my-4" />
<div className="space-y-4">

View File

@@ -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<string>([
// 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 =>