From 5ae233ff62e75c3dbed14b3553c4b271b4fa3bca Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Sat, 28 Mar 2026 16:30:21 -0500 Subject: [PATCH] Add dedicated photo upload flow for NIP-68 kind 20 events Enable the floating compose button on the photos page with a camera icon. Clicking it opens a new PhotoComposeModal with image upload, title, caption, alt text, and content warning support. Publishes kind 20 picture events per the NIP-68 specification. --- src/components/PhotoComposeModal.tsx | 490 +++++++++++++++++++++++++++ src/pages/PhotosFeedPage.tsx | 117 ++++--- 2 files changed, 556 insertions(+), 51 deletions(-) create mode 100644 src/components/PhotoComposeModal.tsx diff --git a/src/components/PhotoComposeModal.tsx b/src/components/PhotoComposeModal.tsx new file mode 100644 index 00000000..98411bfd --- /dev/null +++ b/src/components/PhotoComposeModal.tsx @@ -0,0 +1,490 @@ +import { useCallback, useRef, useState } from 'react'; +import { AlertTriangle, ImagePlus, Loader2, X } from 'lucide-react'; +import { encode as blurhashEncode } from 'blurhash'; + +import { + Dialog, + DialogContent, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useUploadFile } from '@/hooks/useUploadFile'; +import { useQueryClient } from '@tanstack/react-query'; +import { useToast } from '@/hooks/useToast'; +import { useAppContext } from '@/hooks/useAppContext'; +import { resizeImage } from '@/lib/resizeImage'; +import { cn } from '@/lib/utils'; + +const MAX_CAPTION_CHARS = 2000; + +/** Uploaded image with its metadata and NIP-94 tags. */ +interface UploadedImage { + /** Display URL for preview. */ + url: string; + /** NIP-94 tags from the upload (url, m, size, etc.). */ + tags: string[][]; + /** Image dimensions string, e.g. "1920x1080". */ + dim?: string; + /** BlurHash for loading previews. */ + blurhash?: string; + /** Alt text for accessibility. */ + alt: string; +} + +/** + * Compute image dimensions and blurhash for a File. + * Returns empty values for non-image files or on failure. + */ +async function getImageMeta(file: File): Promise<{ dim?: string; blurhash?: string }> { + if (!file.type.startsWith('image/')) return {}; + try { + const url = URL.createObjectURL(file); + try { + const img = await new Promise((resolve, reject) => { + const el = new Image(); + el.onload = () => resolve(el); + el.onerror = reject; + el.src = url; + }); + + const naturalWidth = img.naturalWidth; + const naturalHeight = img.naturalHeight; + if (!naturalWidth || !naturalHeight) return {}; + + const dim = `${naturalWidth}x${naturalHeight}`; + + const SAMPLE_W = 64; + const scale = SAMPLE_W / naturalWidth; + const sampleW = SAMPLE_W; + const sampleH = Math.max(1, Math.round(naturalHeight * scale)); + + const canvas = document.createElement('canvas'); + canvas.width = sampleW; + canvas.height = sampleH; + const ctx = canvas.getContext('2d'); + if (!ctx) return { dim }; + + ctx.drawImage(img, 0, 0, sampleW, sampleH); + const { data } = ctx.getImageData(0, 0, sampleW, sampleH); + + const blurhash = blurhashEncode(data, sampleW, sampleH, 4, 3); + return { dim, blurhash }; + } finally { + URL.revokeObjectURL(url); + } + } catch { + return {}; + } +} + +interface PhotoComposeModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +export function PhotoComposeModal({ open, onOpenChange, onSuccess }: PhotoComposeModalProps) { + const { user } = useCurrentUser(); + const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish(); + const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); + const queryClient = useQueryClient(); + const { toast } = useToast(); + const { config } = useAppContext(); + const imageQuality = config.imageQuality; + + const [images, setImages] = useState([]); + const [title, setTitle] = useState(''); + const [caption, setCaption] = useState(''); + const [cwEnabled, setCwEnabled] = useState(false); + const [cwText, setCwText] = useState(''); + const fileInputRef = useRef(null); + + const charCount = caption.length; + const canPublish = images.length > 0 && title.trim().length > 0 && !isUploading && !isPublishing; + + const resetForm = useCallback(() => { + setImages([]); + setTitle(''); + setCaption(''); + setCwEnabled(false); + setCwText(''); + }, []); + + const handleFileUpload = useCallback(async (file: File) => { + if (!file.type.startsWith('image/')) { + toast({ title: 'Invalid file', description: 'Only image files are allowed.', variant: 'destructive' }); + return; + } + + try { + let uploadableFile: File; + let resizedDim: string | undefined; + + if (imageQuality === 'compressed') { + const resized = await resizeImage(file); + uploadableFile = resized.file; + resizedDim = resized.dimensions; + } else { + uploadableFile = file; + } + + const tags = await uploadFile(uploadableFile); + const [[, url]] = tags; + + // Compute dim + blurhash + let dim = resizedDim; + let blurhash: string | undefined; + + if (!dim) { + const meta = await getImageMeta(uploadableFile); + dim = meta.dim; + blurhash = meta.blurhash; + } else { + const meta = await getImageMeta(uploadableFile); + blurhash = meta.blurhash; + } + + setImages((prev) => [...prev, { + url, + tags, + dim, + blurhash, + alt: '', + }]); + } catch { + toast({ title: 'Upload failed', description: 'Could not upload image.', variant: 'destructive' }); + } + }, [uploadFile, toast, imageQuality]); + + const handleRemoveImage = useCallback((index: number) => { + setImages((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const handleAltChange = useCallback((index: number, alt: string) => { + setImages((prev) => prev.map((img, i) => i === index ? { ...img, alt } : img)); + }, []); + + const handlePaste = useCallback(async (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.type.startsWith('image/')) { + e.preventDefault(); + const file = item.getAsFile(); + if (file) { + await handleFileUpload(file); + } + break; + } + } + }, [handleFileUpload]); + + const handleSubmit = async () => { + if (!canPublish || !user) return; + + try { + const tags: string[][] = []; + + // Title tag (required by NIP-68) + tags.push(['title', title.trim()]); + + // Build imeta tags for each uploaded image + for (const img of images) { + const imetaFields: string[] = [ + `url ${img.url}`, + ]; + + // Add mime type from upload tags + const mimeTag = img.tags.find(t => t[0] === 'm'); + if (mimeTag) { + imetaFields.push(`m ${mimeTag[1]}`); + } + + if (img.dim) { + imetaFields.push(`dim ${img.dim}`); + } + if (img.blurhash) { + imetaFields.push(`blurhash ${img.blurhash}`); + } + if (img.alt.trim()) { + imetaFields.push(`alt ${img.alt.trim()}`); + } + + // Add hash if present in upload tags + const hashTag = img.tags.find(t => t[0] === 'x'); + if (hashTag) { + imetaFields.push(`x ${hashTag[1]}`); + } + + tags.push(['imeta', ...imetaFields]); + } + + // Extract hashtags from caption + const captionText = caption.trim(); + const hashtagMatches = captionText.match(/#\w+/g); + if (hashtagMatches) { + for (const tag of hashtagMatches) { + tags.push(['t', tag.slice(1).toLowerCase()]); + } + } + + // Content warning + if (cwEnabled) { + tags.push(['content-warning', cwText || '']); + tags.push(['L', 'content-warning']); + if (cwText) { + tags.push(['l', cwText, 'content-warning']); + } + } + + // NIP-31 alt tag for clients that don't support kind 20 + tags.push(['alt', `Photo: ${title.trim()}`]); + + await createEvent({ + kind: 20, + content: captionText, + tags, + }); + + // Invalidate feeds to show the new photo + queryClient.invalidateQueries({ queryKey: ['feed'] }); + queryClient.invalidateQueries({ queryKey: ['trending'] }); + + toast({ title: 'Photo published!', description: 'Your photo has been shared.' }); + resetForm(); + onOpenChange(false); + onSuccess?.(); + } catch { + toast({ title: 'Error', description: 'Failed to publish photo.', variant: 'destructive' }); + } + }; + + return ( + + + {/* Header */} +
+ + New photo + + +
+ + {/* Scrollable body */} +
+
+ {/* Image upload area */} + {images.length === 0 ? ( + + ) : ( +
+ {/* Image previews */} +
+ {images.map((img, index) => ( +
+ {img.alt + {/* Remove button */} + + {/* Alt text input overlay */} +
+ handleAltChange(index, e.target.value)} + placeholder="Alt text (accessibility)" + className="w-full bg-black/30 backdrop-blur-sm text-white placeholder:text-white/50 text-xs rounded-lg px-2.5 py-1.5 outline-none focus:ring-1 focus:ring-white/40" + /> +
+
+ ))} +
+ + {/* Add more button */} + +
+ )} + + {/* Hidden file input */} + { + const files = e.target.files; + if (files) { + Array.from(files).forEach((file) => handleFileUpload(file)); + } + e.target.value = ''; + }} + /> + + {/* Title field */} +
+ + setTitle(e.target.value)} + placeholder="Give your photo a title" + maxLength={200} + className="bg-secondary/40 border-0 rounded-lg focus-visible:ring-1 focus-visible:ring-primary/40" + /> +
+ + {/* Caption field */} +
+ +