import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from "react"; import { GiphyPicker, type Gif } from "./GiphyPicker"; import { EmojiPicker as KaomojiPicker } from "./EmojiPicker"; import Picker, { Theme } from "emoji-picker-react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faBold, faItalic, faStrikethrough, faCode, faEyeSlash, faFilm, faSmile, faGrin, faPaperPlane, faPlus, faParagraph, faAlignLeft, faListUl, faListOl, faQuoteRight, faLink, faHeading, } from "@fortawesome/free-solid-svg-icons"; // ponytail: mirrors backend allowlist; real guard is server-side content-type detection const SAFE_EXTS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".mp3", ".wav", ".ogg", ".flac", ".mp4", ".webm", ".mov", ".pdf", ".txt", ".md", ".json", ".csv", ".zip", ".tar", ".gz"]); // ponytail: minimal round-trip converters for the formatting our toolbar produces function htmlToMarkdown(html: string): string { let md = html; // code blocks:
...
md = md.replace(/]*>]*>([\s\S]*?)<\/code><\/pre>/gi, (_, code) => { const inner = code.replace(//gi, "\n").replace(/<[^>]*>/g, ""); return "\n```\n" + inner + "\n```\n"; }); // links md = md.replace(/]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, "[$2]($1)"); // headings md = md.replace(/]*>(.*?)<\/h1>/gi, "\n# $1\n"); md = md.replace(/]*>(.*?)<\/h2>/gi, "\n## $1\n"); md = md.replace(/]*>(.*?)<\/h3>/gi, "\n### $1\n"); // blockquotes: handle nested
inside md = md.replace(/]*>([\s\S]*?)<\/blockquote>/gi, (_, inner) => { const lines = inner.replace(//gi, "\n").replace(/<[^>]*>/g, "").split("\n"); return "\n" + lines.map((l: string) => "> " + l.trim()).join("\n") + "\n"; }); // unordered lists md = md.replace(/]*>([\s\S]*?)<\/ul>/gi, (_, inner) => { const items = inner.match(/]*>([\s\S]*?)<\/li>/gi) || []; return "\n" + items.map((li: string) => "- " + li.replace(/<\/?li[^>]*>/gi, "").replace(//gi, " ").replace(/<[^>]*>/g, "").trim()).join("\n") + "\n"; }); // ordered lists md = md.replace(/]*>([\s\S]*?)<\/ol>/gi, (_, inner) => { const items = inner.match(/]*>([\s\S]*?)<\/li>/gi) || []; return "\n" + items.map((li: string, i: number) => `${i + 1}. ` + li.replace(/<\/?li[^>]*>/gi, "").replace(//gi, " ").replace(/<[^>]*>/g, "").trim()).join("\n") + "\n"; }); // remaining block elements → newlines md = md.replace(/]*>/gi, "\n").replace(/<\/div>/gi, ""); md = md.replace(//gi, "\n"); md = md.replace(/]*>/gi, "").replace(/<\/p>/gi, "\n"); // inline formatting (innermost first) md = md.replace(/]*>(.*?)<\/span>/gi, "||$1||"); md = md.replace(/<(?:b|strong)>(.*?)<\/(?:b|strong)>/gi, "**$1**"); md = md.replace(/<(?:i|em)>(.*?)<\/(?:i|em)>/gi, "*$1*"); md = md.replace(/<(?:s|strike|del)>(.*?)<\/(?:s|strike|del)>/gi, "~~$1~~"); md = md.replace(/(.*?)<\/code>/gi, "`$1`"); // strip remaining tags md = md.replace(/<[^>]*>/g, ""); // decode entities md = md.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); // collapse ≥3 consecutive newlines md = md.replace(/\n{3,}/g, "\n\n"); return md.trim(); } function markdownToHtml(md: string): string { // split into lines, process block structures const lines = md.split("\n"); const result: string[] = []; let inCodeBlock = false; let codeBuf: string[] = []; let inList: "ul" | "ol" | null = null; const flushList = () => { if (inList) { result.push(``); inList = null; } }; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // code block fences if (/^```/.test(line.trim())) { if (inCodeBlock) { result.push(`
${escapeHtml(codeBuf.join("\n"))}
`); codeBuf = []; inCodeBlock = false; } else { flushList(); inCodeBlock = true; } continue; } if (inCodeBlock) { codeBuf.push(line); continue; } // headings const hMatch = line.match(/^(#{1,6})\s+(.+)/); if (hMatch) { flushList(); const level = hMatch[1].length; result.push(`${inlineMarkdownToHtml(hMatch[2])}`); continue; } // blockquote if (/^>\s?/.test(line)) { flushList(); const content = line.replace(/^>\s?/, ""); result.push(`
${inlineMarkdownToHtml(content)}
`); continue; } // unordered list const ulMatch = line.match(/^[-*+]\s+(.+)/); if (ulMatch) { if (inList !== "ul") { flushList(); result.push("
    "); inList = "ul"; } result.push(`
  • ${inlineMarkdownToHtml(ulMatch[1])}
  • `); continue; } // ordered list const olMatch = line.match(/^\d+\.\s+(.+)/); if (olMatch) { if (inList !== "ol") { flushList(); result.push("
      "); inList = "ol"; } result.push(`
    1. ${inlineMarkdownToHtml(olMatch[1])}
    2. `); continue; } // blank line: flush list if (line.trim() === "") { flushList(); result.push("
      "); continue; } // regular paragraph flushList(); result.push(`
      ${inlineMarkdownToHtml(line)}
      `); } flushList(); if (inCodeBlock) result.push(`
      ${escapeHtml(codeBuf.join("\n"))}
      `); return result.join(""); } function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } function inlineMarkdownToHtml(text: string): string { let html = escapeHtml(text); // links [text](url) html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '
      $1'); html = html.replace(/\|\|(.+?)\|\|/g, '$1'); html = html.replace(/\*\*(.+?)\*\*/g, "$1"); html = html.replace(/\*(.+?)\*/g, "$1"); html = html.replace(/~~(.+?)~~/g, "$1"); html = html.replace(/`(.+?)`/g, "$1"); return html; } type EditorMode = "md" | "rt"; interface MessageInputProps { value: string; onChange: (value: string) => void; onSubmit: () => void; onPaste?: (e: React.ClipboardEvent) => void; onGifSelect?: (gif: Gif) => void; placeholder?: string; disabled?: boolean; } export const MessageInput = forwardRef(function MessageInput({ value, onChange, onSubmit, onPaste, onGifSelect, placeholder = "Message...", disabled = false, }, ref) { const textareaRef = useRef(null); const richRef = useRef(null); useImperativeHandle(ref, () => textareaRef.current!, []); const [showGif, setShowGif] = useState(false); const [showKaomoji, setShowKaomoji] = useState(false); const [showEmoji, setShowEmoji] = useState(false); const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [isDragOver, setIsDragOver] = useState(false); const [mode, setMode] = useState("md"); const fileInputRef = useRef(null); const dragCounterRef = useRef(0); const richSyncing = useRef(false); // sync rich text div when value changes externally useEffect(() => { if (mode === "rt" && richRef.current && !richSyncing.current) { richRef.current.innerHTML = markdownToHtml(value); } }, [value, mode]); // --- textarea helper --- const wrapTextarea = useCallback((prefix: string, suffix: string, defaultText = "text") => { const ta = textareaRef.current; if (!ta) return; const start = ta.selectionStart ?? 0; const end = ta.selectionEnd ?? 0; const selected = ta.value.slice(start, end) || defaultText; const result = ta.value.slice(0, start) + prefix + selected + suffix + ta.value.slice(end); onChange(result); requestAnimationFrame(() => { ta.focus(); ta.setSelectionRange(start + prefix.length, start + prefix.length + selected.length); }); }, [onChange]); // --- inline formatting --- const execInline = useCallback((prefix: string, suffix: string) => { if (mode === "rt") { document.execCommand("styleWithCSS", false, "false"); if (prefix === "**") document.execCommand("bold"); else if (prefix === "*") document.execCommand("italic"); else if (prefix === "~~") document.execCommand("strikeThrough"); else if (prefix === "`") { const sel = window.getSelection(); if (sel && !sel.isCollapsed) { const range = sel.getRangeAt(0); const code = document.createElement("code"); code.textContent = range.toString(); range.deleteContents(); range.insertNode(code); } } else if (prefix === "||") { const sel = window.getSelection(); if (sel && !sel.isCollapsed) { const range = sel.getRangeAt(0); const span = document.createElement("span"); span.className = "spoiler"; span.textContent = range.toString(); range.deleteContents(); range.insertNode(span); } } richRef.current?.focus(); } else { wrapTextarea(prefix, suffix); } }, [mode, wrapTextarea]); // --- block-level formatting (rich text uses execCommand, markdown wraps) --- const execBlock = useCallback((cmd: string) => { if (mode === "rt") { if (cmd === "ul") document.execCommand("insertUnorderedList"); else if (cmd === "ol") document.execCommand("insertOrderedList"); else if (cmd === "blockquote") { // execCommand quote is finicky; wrap selected blocks manually const sel = window.getSelection(); if (sel && !sel.isCollapsed) { document.execCommand("formatBlock", false, "
      "); } else { // insert empty blockquote const bq = document.createElement("blockquote"); bq.innerHTML = "
      "; richRef.current?.appendChild(bq); } } else if (cmd === "h1") document.execCommand("formatBlock", false, "

      "); else if (cmd === "h2") document.execCommand("formatBlock", false, "

      "); else if (cmd === "h3") document.execCommand("formatBlock", false, "

      "); richRef.current?.focus(); } else { switch (cmd) { case "ul": wrapTextarea("- ", ""); break; case "ol": wrapTextarea("1. ", ""); break; case "blockquote": { const ta = textareaRef.current; if (!ta) return; const start = ta.selectionStart ?? 0; const end = ta.selectionEnd ?? 0; const text = ta.value.slice(start, end) || ta.value; const lines = text.split("\n").map((l) => "> " + l).join("\n"); onChange(lines); requestAnimationFrame(() => ta.focus()); break; } case "h1": wrapTextarea("# ", "", "Heading 1"); break; case "h2": wrapTextarea("## ", "", "Heading 2"); break; case "h3": wrapTextarea("### ", "", "Heading 3"); break; } } }, [mode, onChange, wrapTextarea]); // --- link insertion --- const execLink = useCallback(() => { const url = prompt("Enter URL:", "https://"); if (!url) return; if (mode === "rt") { const sel = window.getSelection(); if (sel && !sel.isCollapsed) { document.execCommand("createLink", false, url); } else { const a = document.createElement("a"); a.href = url; a.target = "_blank"; a.rel = "noreferrer"; a.textContent = url; richRef.current?.appendChild(a); } richRef.current?.focus(); } else { const ta = textareaRef.current; if (!ta) return; const start = ta.selectionStart ?? 0; const end = ta.selectionEnd ?? 0; const selected = ta.value.slice(start, end) || url; const md = `[${selected}](${url})`; const result = ta.value.slice(0, start) + md + ta.value.slice(end); onChange(result); requestAnimationFrame(() => { ta.focus(); ta.setSelectionRange(start + md.length, start + md.length); }); } }, [mode, onChange]); // --- insert text/emoji at cursor --- const insert = useCallback((text: string) => { if (mode === "rt" && richRef.current) { richRef.current.focus(); document.execCommand("insertText", false, text); } else { onChange(value + text); textareaRef.current?.focus(); } }, [mode, value, onChange]); const insertMarkdown = useCallback((md: string) => { if (mode === "rt" && richRef.current) { richRef.current.focus(); const html = markdownToHtml(md); const sel = window.getSelection(); if (sel && sel.rangeCount > 0) { const range = sel.getRangeAt(0); range.deleteContents(); const frag = range.createContextualFragment(html); range.insertNode(frag); range.collapse(false); sel.removeAllRanges(); sel.addRange(range); } } else { onChange(value + (value && !value.endsWith(" ") ? " " : "") + md); textareaRef.current?.focus(); } }, [mode, value, onChange]); // --- file upload --- const validateFile = useCallback((file: File): string | null => { const ext = file.name.slice(file.name.lastIndexOf(".")).toLowerCase(); if (!SAFE_EXTS.has(ext)) return `File type "${ext}" not allowed.`; if (file.size > 1024 * 1024 * 1024) return "File too large (max 1 GB)."; return null; }, []); const uploadFile = useCallback((file: File) => { const err = validateFile(file); if (err) { alert(err); return; } setUploading(true); setUploadProgress(0); const formData = new FormData(); formData.append("file", file); const xhr = new XMLHttpRequest(); xhr.upload.onprogress = (e) => { if (e.lengthComputable) setUploadProgress(Math.round((e.loaded / e.total) * 100)); }; xhr.onload = () => { setUploading(false); if (xhr.status >= 200 && xhr.status < 300) { const data = JSON.parse(xhr.responseText); const isImage = /\.(jpg|jpeg|png|gif|webp)$/i.test(file.name); const md = isImage ? `![${file.name}](${data.url})` : `[${file.name}](${data.url})`; insertMarkdown(md); } else { const msg = (() => { try { return JSON.parse(xhr.responseText).error; } catch { return "Upload failed"; } })(); alert(msg); } }; xhr.onerror = () => { setUploading(false); alert("Upload failed"); }; xhr.open("POST", "/api/v1/upload"); xhr.withCredentials = true; xhr.send(formData); }, [validateFile, insertMarkdown]); const handleFileInput = useCallback((e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) uploadFile(file); e.target.value = ""; }, [uploadFile]); // --- drag-and-drop --- useEffect(() => { const el = mode === "rt" ? richRef.current : textareaRef.current; if (!el) return; const onDragEnter = (e: Event) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current++; if (dragCounterRef.current === 1) setIsDragOver(true); }; const onDragOver = (e: Event) => { e.preventDefault(); }; const onDragLeave = (e: Event) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current--; if (dragCounterRef.current === 0) setIsDragOver(false); }; const onDrop = (e: Event) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); dragCounterRef.current = 0; const file = (e as DragEvent).dataTransfer?.files?.[0]; if (file) uploadFile(file); }; el.addEventListener("dragenter", onDragEnter); el.addEventListener("dragover", onDragOver); el.addEventListener("dragleave", onDragLeave); el.addEventListener("drop", onDrop); return () => { el.removeEventListener("dragenter", onDragEnter); el.removeEventListener("dragover", onDragOver); el.removeEventListener("dragleave", onDragLeave); el.removeEventListener("drop", onDrop); }; }, [uploadFile, mode]); // --- Ctrl+E kaomoji --- useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.ctrlKey && e.key === "e" && !disabled) { e.preventDefault(); setShowKaomoji((p) => !p); setShowEmoji(false); setShowGif(false); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [disabled]); const handleSubmit = useCallback( (e: React.FormEvent) => { e.preventDefault(); let finalValue = value; if (mode === "rt" && richRef.current) { finalValue = htmlToMarkdown(richRef.current.innerHTML); } if (!disabled && finalValue.trim()) { if (mode === "rt") onChange(finalValue); onSubmit(); } }, [disabled, value, mode, onChange, onSubmit], ); const handleRichInput = useCallback(() => { if (!richRef.current) return; richSyncing.current = true; const md = htmlToMarkdown(richRef.current.innerHTML); onChange(md); requestAnimationFrame(() => { richSyncing.current = false; }); }, [onChange]); const toggleMode = useCallback(() => { setMode((prev) => { const next: EditorMode = prev === "md" ? "rt" : "md"; if (next === "rt") { requestAnimationFrame(() => { if (richRef.current) { richRef.current.innerHTML = markdownToHtml(value || ""); } }); } return next; }); }, [value]); const toggleGif = () => { setShowGif((p) => !p); setShowKaomoji(false); setShowEmoji(false); }; const toggleEmoji = () => { setShowEmoji((p) => !p); setShowKaomoji(false); setShowGif(false); }; const toggleKaomoji = () => { setShowKaomoji((p) => !p); setShowEmoji(false); setShowGif(false); }; const handleGif = (gif: Gif) => { if (onGifSelect) { onGifSelect(gif); } else { const md = `![${gif.title || "GIF"}](${gif.images.fixed_height.url})`; insertMarkdown(md); } setShowGif(false); }; return (
      {/* picker overlays */} {showGif && (
      setShowGif(false)} />
      )} {showKaomoji && ( setShowKaomoji(false)} /> )} {showEmoji && (
      { insert(e.emoji); setShowEmoji(false); }} />
      )}
      {mode === "md" ? (