From ac061a8d4bff00278e5ae3fc1c199199f3f4c446 Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Thu, 26 Mar 2026 08:43:18 +0100 Subject: [PATCH] Show image attachments as thumbnails instead of raw URLs Uploaded images are now kept in a separate attachments list with thumbnail previews and remove buttons. URLs are appended to note content only at publish time. This keeps the textarea clean for writing and avoids URLs appearing mid-sentence at the cursor position. --- src/components/feed/ComposeBox.tsx | 52 ++++++++++++++++++-- src/components/feed/InlineReplyBox.tsx | 68 +++++++++++++++++++------- 2 files changed, 99 insertions(+), 21 deletions(-) diff --git a/src/components/feed/ComposeBox.tsx b/src/components/feed/ComposeBox.tsx index fc1417d..3b2e9fd 100644 --- a/src/components/feed/ComposeBox.tsx +++ b/src/components/feed/ComposeBox.tsx @@ -16,6 +16,7 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = try { return localStorage.getItem(COMPOSE_DRAFT_KEY) || ""; } catch { return ""; } }); + const [attachments, setAttachments] = useState([]); const [publishing, setPublishing] = useState(false); const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); @@ -42,7 +43,7 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = const charCount = text.length; const warnLimit = charCount > 3500; const overLimit = charCount > 4000; - const canPost = text.trim().length > 0 && !publishing && !uploading; + const canPost = (text.trim().length > 0 || attachments.length > 0) && !publishing && !uploading; // Insert text at the current cursor position in the textarea const insertAtCursor = (str: string) => { @@ -61,13 +62,22 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = } }; + // Add uploaded URL to attachments instead of inserting into text + const addAttachment = (url: string) => { + setAttachments((prev) => [...prev, url]); + }; + + const removeAttachment = (index: number) => { + setAttachments((prev) => prev.filter((_, i) => i !== index)); + }; + // Upload a web File object (from clipboard/drag-drop) const handleImageUpload = async (file: File) => { setUploading(true); setError(null); try { const url = await uploadImage(file); - insertAtCursor(url); + addAttachment(url); } catch (err) { setError(`Image upload failed: ${err}`); } finally { @@ -90,7 +100,7 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = }; const mimeType = mimeMap[ext] || "application/octet-stream"; const url = await uploadBytes(new Uint8Array(bytes), fileName, mimeType); - insertAtCursor(url); + addAttachment(url); } catch (err) { setError(`Upload failed: ${err}`); } finally { @@ -169,7 +179,11 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = setPublishing(true); setError(null); try { - const event = await publishNote(text.trim()); + // Build final content: text + attachment URLs on separate lines + const parts = [text.trim(), ...attachments].filter(Boolean); + const content = parts.join("\n"); + + const event = await publishNote(content); // Inject into feed immediately so the user sees their post if (onNoteInjected) { onNoteInjected(event); @@ -180,6 +194,7 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = }); } setText(""); + setAttachments([]); localStorage.removeItem(COMPOSE_DRAFT_KEY); textareaRef.current?.focus(); onPublished?.(); @@ -225,6 +240,35 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = className="w-full bg-transparent text-text text-[13px] placeholder:text-text-dim resize-none focus:outline-none leading-relaxed" /> + {/* Attachment thumbnails */} + {attachments.length > 0 && ( +
+ {attachments.map((url, i) => ( +
+ {/\.(mp4|webm|mov|ogg|m4v)(\?|$)/i.test(url) ? ( +
+ video +
+ ) : ( + { (e.target as HTMLImageElement).className = "h-16 w-20 rounded-sm border border-border bg-bg-raised"; }} + /> + )} + +
+ ))} +
+ )} + {error && (

{error}

)} diff --git a/src/components/feed/InlineReplyBox.tsx b/src/components/feed/InlineReplyBox.tsx index 315bffa..4b9ddb0 100644 --- a/src/components/feed/InlineReplyBox.tsx +++ b/src/components/feed/InlineReplyBox.tsx @@ -16,6 +16,7 @@ interface InlineReplyBoxProps { export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps) { const [replyText, setReplyText] = useState(""); + const [attachments, setAttachments] = useState([]); const [replying, setReplying] = useState(false); const [replyError, setReplyError] = useState(null); const [replySent, setReplySent] = useState(false); @@ -38,12 +39,20 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps) } }; + const addAttachment = (url: string) => { + setAttachments((prev) => [...prev, url]); + }; + + const removeAttachment = (index: number) => { + setAttachments((prev) => prev.filter((_, i) => i !== index)); + }; + const handleImageUpload = async (file: File) => { setUploading(true); setUploadError(null); try { const url = await uploadImage(file); - insertAtCursor(url); + addAttachment(url); } catch (err) { setUploadError(`Upload failed: ${err}`); } finally { @@ -79,7 +88,7 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps) const ext = fileName.split(".").pop()?.toLowerCase() || ""; const mimeMap: Record = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp" }; const url = await uploadBytes(new Uint8Array(bytes), fileName, mimeMap[ext] || "application/octet-stream"); - insertAtCursor(url); + addAttachment(url); } catch (err) { setUploadError(`Upload failed: ${err}`); } finally { @@ -102,7 +111,7 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps) const ext = fileName.split(".").pop()?.toLowerCase() || ""; const mimeMap: Record = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime" }; const url = await uploadBytes(new Uint8Array(bytes), fileName, mimeMap[ext] || "application/octet-stream"); - insertAtCursor(url); + addAttachment(url); } catch (err) { setUploadError(`Upload failed: ${err}`); } finally { @@ -111,12 +120,17 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps) }; const handleReplySubmit = async () => { - if (!replyText.trim() || replying) return; + if ((!replyText.trim() && attachments.length === 0) || replying) return; setReplying(true); setReplyError(null); try { - await publishReply(replyText.trim(), { id: event.id, pubkey: event.pubkey }, rootEvent); + // Build final content: text + attachment URLs on separate lines + const parts = [replyText.trim(), ...attachments].filter(Boolean); + const content = parts.join("\n"); + + await publishReply(content, { id: event.id, pubkey: event.pubkey }, rootEvent); setReplyText(""); + setAttachments([]); setReplySent(true); adjustReplyCount(1); setTimeout(() => { setReplySent(false); }, 1500); @@ -148,6 +162,36 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps) className="w-full bg-transparent text-text text-[12px] placeholder:text-text-dim resize-none focus:outline-none leading-relaxed" autoFocus /> + + {/* Attachment thumbnails */} + {attachments.length > 0 && ( +
+ {attachments.map((url, i) => ( +
+ {/\.(mp4|webm|mov|ogg|m4v)(\?|$)/i.test(url) ? ( +
+ video +
+ ) : ( + { (e.target as HTMLImageElement).className = "h-12 w-16 rounded-sm border border-border bg-bg-raised"; }} + /> + )} + +
+ ))} +
+ )} + {replyError &&

{replyError}

} {uploadError &&

{uploadError}

}
@@ -175,17 +219,7 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps) {showReplyEmoji && ( { - const ta = replyRef.current; - if (ta) { - const start = ta.selectionStart ?? replyText.length; - const end = ta.selectionEnd ?? replyText.length; - setReplyText(replyText.slice(0, start) + emoji + replyText.slice(end)); - setTimeout(() => { ta.selectionStart = ta.selectionEnd = start + emoji.length; ta.focus(); }, 0); - } else { - setReplyText((t) => t + emoji); - } - }} + onSelect={(emoji) => insertAtCursor(emoji)} onClose={() => setShowReplyEmoji(false)} /> )} @@ -193,7 +227,7 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps) Ctrl+Enter