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