mirror of
https://github.com/hoornet/vega.git
synced 2026-06-29 21:52:08 -07:00
e30e42971e
Polish: - ArticleEditor: sentence case on all buttons (← Drafts, Write, Preview, Zen, Meta, Publish, ← Back, New draft) - EditProfileForm: Save profile / Saving… / Saved ✓ - ImageField: Upload / Uploading… - RelaysView: Add, Publish list, Remove dead, Discover relays (sentence case) - InlineReplyBox: attachment remove x → ×, aria-labels on remove/attach/emoji Bug fix: - V4VIndicator: add userDisabledRef so manually turning off streaming prevents auto-start from re-engaging on subsequent play/pause/seek events for the same episode; resets automatically on new episode - V4VIndicator: remaining amber-* colors → zap theme token
242 lines
9.2 KiB
TypeScript
242 lines
9.2 KiB
TypeScript
import { useState, useRef } from "react";
|
||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||
import { publishReply } from "../../lib/nostr";
|
||
import { uploadImage, uploadBytes } from "../../lib/upload";
|
||
import { useAutoResize } from "../../hooks/useAutoResize";
|
||
import { useReplyCount } from "../../hooks/useReplyCount";
|
||
import { EmojiPicker } from "../shared/EmojiPicker";
|
||
import { open } from "@tauri-apps/plugin-dialog";
|
||
import { readFile } from "@tauri-apps/plugin-fs";
|
||
|
||
interface InlineReplyBoxProps {
|
||
event: NDKEvent;
|
||
name: string;
|
||
rootEvent?: { id: string; pubkey: string };
|
||
}
|
||
|
||
export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps) {
|
||
const [replyText, setReplyText] = useState("");
|
||
const [attachments, setAttachments] = useState<string[]>([]);
|
||
const [replying, setReplying] = useState(false);
|
||
const [replyError, setReplyError] = useState<string | null>(null);
|
||
const [replySent, setReplySent] = useState(false);
|
||
const [showReplyEmoji, setShowReplyEmoji] = useState(false);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||
const autoResize = useAutoResize(2, 8);
|
||
const replyRef = useRef<HTMLTextAreaElement>(null);
|
||
const [, adjustReplyCount] = useReplyCount(event.id);
|
||
|
||
const insertAtCursor = (str: string) => {
|
||
const ta = replyRef.current;
|
||
if (ta) {
|
||
const start = ta.selectionStart ?? replyText.length;
|
||
const end = ta.selectionEnd ?? replyText.length;
|
||
setReplyText(replyText.slice(0, start) + str + replyText.slice(end));
|
||
setTimeout(() => { ta.selectionStart = ta.selectionEnd = start + str.length; ta.focus(); }, 0);
|
||
} else {
|
||
setReplyText((t) => t + str);
|
||
}
|
||
};
|
||
|
||
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);
|
||
addAttachment(url);
|
||
} catch (err) {
|
||
setUploadError(`Upload failed: ${err}`);
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
};
|
||
|
||
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||
const fileFromFiles = Array.from(e.clipboardData.files).find((f) => f.type.startsWith("image/"));
|
||
if (fileFromFiles) {
|
||
e.preventDefault();
|
||
handleImageUpload(fileFromFiles);
|
||
return;
|
||
}
|
||
const items = Array.from(e.clipboardData.items ?? []);
|
||
const imageItem = items.find((item) => item.type.startsWith("image/"));
|
||
if (imageItem) {
|
||
const file = imageItem.getAsFile();
|
||
if (file) {
|
||
e.preventDefault();
|
||
handleImageUpload(file);
|
||
return;
|
||
}
|
||
}
|
||
const pastedText = e.clipboardData.getData("text/plain");
|
||
if (pastedText && /\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov)$/i.test(pastedText.trim()) && /^(\/|[A-Z]:\\)/.test(pastedText.trim())) {
|
||
e.preventDefault();
|
||
setUploading(true);
|
||
setUploadError(null);
|
||
try {
|
||
const bytes = await readFile(pastedText.trim());
|
||
const fileName = pastedText.trim().split(/[\\/]/).pop() || "file";
|
||
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||
const mimeMap: Record<string, string> = { 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");
|
||
addAttachment(url);
|
||
} catch (err) {
|
||
setUploadError(`Upload failed: ${err}`);
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleFilePicker = async () => {
|
||
try {
|
||
const selected = await open({
|
||
multiple: false,
|
||
filters: [{ name: "Media", extensions: ["jpg", "jpeg", "png", "gif", "webp", "svg", "mp4", "webm", "mov"] }],
|
||
});
|
||
if (!selected) return;
|
||
setUploading(true);
|
||
setUploadError(null);
|
||
const bytes = await readFile(selected);
|
||
const fileName = selected.split(/[\\/]/).pop() || "file";
|
||
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||
const mimeMap: Record<string, string> = { 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");
|
||
addAttachment(url);
|
||
} catch (err) {
|
||
setUploadError(`Upload failed: ${err}`);
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
};
|
||
|
||
const handleReplySubmit = async () => {
|
||
if ((!replyText.trim() && attachments.length === 0) || replying) return;
|
||
setReplying(true);
|
||
setReplyError(null);
|
||
try {
|
||
// 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);
|
||
} catch (err) {
|
||
setReplyError(`Failed: ${err}`);
|
||
} finally {
|
||
setReplying(false);
|
||
}
|
||
};
|
||
|
||
const handleReplyKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleReplySubmit();
|
||
if (e.key === "Escape") {
|
||
// Parent controls visibility — just blur
|
||
replyRef.current?.blur();
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="mt-2 border-l-2 border-border pl-3">
|
||
<textarea
|
||
ref={replyRef}
|
||
value={replyText}
|
||
onChange={(e) => { setReplyText(e.target.value); autoResize(e); }}
|
||
onKeyDown={handleReplyKeyDown}
|
||
onPaste={handlePaste}
|
||
placeholder={`Reply to ${name}…`}
|
||
rows={2}
|
||
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 && (
|
||
<div className="flex flex-wrap gap-2 mb-1">
|
||
{attachments.map((url, i) => (
|
||
<div key={i} className="relative group">
|
||
{/\.(mp4|webm|mov|ogg|m4v)(\?|$)/i.test(url) ? (
|
||
<div className="h-12 w-16 rounded-sm border border-border bg-bg-raised flex items-center justify-center text-text-dim text-[9px]">
|
||
video
|
||
</div>
|
||
) : (
|
||
<img
|
||
src={url}
|
||
alt="Attachment preview"
|
||
className="h-12 w-auto rounded-sm border border-border object-cover"
|
||
onError={(e) => { (e.target as HTMLImageElement).className = "h-12 w-16 rounded-sm border border-border bg-bg-raised"; }}
|
||
/>
|
||
)}
|
||
<button
|
||
onClick={() => removeAttachment(i)}
|
||
className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-danger text-accent-text text-[10px] rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||
title="Remove"
|
||
aria-label="Remove attachment"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{replyError && <p className="text-danger text-[10px] mb-1">{replyError}</p>}
|
||
{uploadError && <p className="text-danger text-[10px] mb-1">{uploadError}</p>}
|
||
<div className="flex items-center justify-end gap-2 mt-1">
|
||
{uploading && (
|
||
<span className="inline-flex items-center gap-1 text-text-dim text-[10px]">
|
||
<span className="w-3 h-3 border border-accent border-t-transparent rounded-full animate-spin" />
|
||
uploading…
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={handleFilePicker}
|
||
disabled={uploading}
|
||
title="Attach image or video"
|
||
aria-label="Attach image or video"
|
||
className="text-text-dim hover:text-text text-[16px] transition-colors disabled:opacity-30"
|
||
>
|
||
+
|
||
</button>
|
||
<div className="relative">
|
||
<button
|
||
onClick={() => setShowReplyEmoji((v) => !v)}
|
||
title="Insert emoji"
|
||
aria-label="Insert emoji"
|
||
className="text-text-dim hover:text-text text-[16px] transition-colors"
|
||
>
|
||
☺
|
||
</button>
|
||
{showReplyEmoji && (
|
||
<EmojiPicker
|
||
onSelect={(emoji) => insertAtCursor(emoji)}
|
||
onClose={() => setShowReplyEmoji(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
<span className="text-text-dim text-[10px]">Ctrl+Enter</span>
|
||
<button
|
||
onClick={handleReplySubmit}
|
||
disabled={(!replyText.trim() && attachments.length === 0) || replying || uploading}
|
||
className="px-2 py-0.5 text-[10px] bg-accent hover:bg-accent-hover text-accent-text transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
>
|
||
{replySent ? "replied ✓" : replying ? "posting…" : "reply"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|