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}>`);
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(`- ${inlineMarkdownToHtml(olMatch[1])}
`);
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})`;
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 = ``;
insertMarkdown(md);
}
setShowGif(false);
};
return (
{/* picker overlays */}
{showGif && (
setShowGif(false)} />
)}
{showKaomoji && (
setShowKaomoji(false)} />
)}
{showEmoji && (
{ insert(e.emoji); setShowEmoji(false); }}
/>
)}