import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faXmark, faImage, faVideo, faFaceSmile, faTrash, faSpinner, faSearch, faBold, faItalic, faUnderline, faStrikethrough, faSquare, faWandMagicSparkles, faCaretDown, faPlus, } from '@fortawesome/free-solid-svg-icons'; import { RichText } from '@atproto/api'; import { atprotoClient } from '../api/client'; import type { BlobRef, ActorTypeaheadResult, PostView } from '../api/types'; import { usePostLabels } from '../settings/usePostLabels'; import PostEmbeds from './PostEmbeds'; import { toggleStyle, detectStyles, styleToFontFamily, type TextStyle, type DetectedStyles} from '../utils/unicodeTextStyles'; import './ComposeModal.css'; const MAX_IMAGES = 4; const MAX_CHARS = 300; function graphemeLength(text: string): number { if (typeof Intl !== 'undefined' && Intl.Segmenter) { const seg = new Intl.Segmenter('en', { granularity: 'grapheme' }); let count = 0; for (const _ of seg.segment(text)) count++; return count; } return [...text].length; } interface PendingImage { file: File; url: string; alt: string; blob?: BlobRef; } interface PendingVideo { file: File; url: string; blob?: BlobRef; width: number; height: number; } interface GifResult { id: string; url: string; preview: string; title: string; width: number; height: number; } interface DraftPost { id: number; text: string; images: PendingImage[]; video: PendingVideo | null; selectedGif: GifResult | null; } interface ComposeModalProps { onClose: () => void; onPosted?: () => void; replyTo?: PostView; quotePost?: PostView; editPost?: PostView; } let nextDraftId = 1; function createEmptyDraft(): DraftPost { return { id: nextDraftId++, text: '', images: [], video: null, selectedGif: null, }; } export default function ComposeModal({ onClose, onPosted, replyTo, quotePost, editPost }: ComposeModalProps) { const isEditMode = !!editPost; const { t: label } = usePostLabels(); const [drafts, setDrafts] = useState(() => { const first: DraftPost = { id: nextDraftId++, text: editPost?.record.text ?? '', images: [], video: null, selectedGif: null, }; return [first]; }); const [activeIndex, setActiveIndex] = useState(0); const activeDraft = drafts[activeIndex]; const updateActiveDraft = useCallback((updater: (d: DraftPost) => DraftPost) => { setDrafts((prev) => prev.map((d, i) => (i === activeIndex ? updater(d) : d))); }, [activeIndex]); const text = activeDraft.text; const setText = useCallback((t: string) => updateActiveDraft((d) => ({ ...d, text: t })), [updateActiveDraft]); const images = activeDraft.images; const setImages = useCallback((u: React.SetStateAction) => { setDrafts((prev) => prev.map((d, i) => { if (i !== activeIndex) return d; const next = typeof u === 'function' ? u(d.images) : u; return { ...d, images: next }; })); }, [activeIndex]); const video = activeDraft.video; const setVideo = useCallback((v: PendingVideo | null) => updateActiveDraft((d) => ({ ...d, video: v })), [updateActiveDraft]); const selectedGif = activeDraft.selectedGif; const setSelectedGif = useCallback((g: GifResult | null) => updateActiveDraft((d) => ({ ...d, selectedGif: g })), [updateActiveDraft]); const [submitting, setSubmitting] = useState(false); const [uploadingMedia, setUploadingMedia] = useState(false); const [error, setError] = useState(null); const [mentionQuery, setMentionQuery] = useState(null); const [mentionResults, setMentionResults] = useState([]); const [mentionIndex, setMentionIndex] = useState(0); const [mentionStart, setMentionStart] = useState(-1); const mentionSearchRef = useRef | null>(null); const [showGifPicker, setShowGifPicker] = useState(false); const [gifQuery, setGifQuery] = useState(''); const [gifResults, setGifResults] = useState([]); const [gifLoading, setGifLoading] = useState(false); const gifSearchRef = useRef | null>(null); const textareaRef = useRef(null); const fileInputRef = useRef(null); const videoInputRef = useRef(null); const [selectionStyles, setSelectionStyles] = useState({ bold: false, italic: false, underline: false, strikethrough: false, stroke: false, fontFamily: 'none', }); const [showFontDropdown, setShowFontDropdown] = useState(false); const fontDropdownRef = useRef(null); const isReplyMode = !!replyTo; const isQuoteMode = !!quotePost; const isThreadMode = !isEditMode && !isReplyMode && !isQuoteMode; const title = isEditMode ? `Edit ${label('post')}` : isReplyMode ? label('reply', false, true) : isQuoteMode ? `Quote ${label('post')}` : `New ${label('post')}`; useEffect(() => { document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = ''; }; }, []); useEffect(() => { textareaRef.current?.focus(); }, [activeIndex]); useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (showGifPicker) { setShowGifPicker(false); } else if (showFontDropdown) { setShowFontDropdown(false); } else { onClose(); } } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [onClose, showGifPicker, showFontDropdown]); const detectMention = useCallback((value: string, cursorPos: number) => { const textBeforeCursor = value.slice(0, cursorPos); const atMatch = textBeforeCursor.match(/@([a-zA-Z0-9._-]*)$/); if (atMatch) { const start = cursorPos - atMatch[0].length; const query = atMatch[1]; setMentionStart(start); setMentionQuery(query); setMentionIndex(0); if (mentionSearchRef.current) clearTimeout(mentionSearchRef.current); if (query.length > 0) { mentionSearchRef.current = setTimeout(async () => { try { const resp = await atprotoClient.searchActorsTypeahead(query, 6); setMentionResults(resp.actors); } catch { setMentionResults([]); } }, 200); } else { setMentionResults([]); } } else { setMentionQuery(null); setMentionResults([]); } }, []); const handleTextChange = useCallback((e: React.ChangeEvent) => { const value = e.target.value; setText(value); detectMention(value, e.target.selectionStart); }, [setText, detectMention]); const selectMention = useCallback((actor: ActorTypeaheadResult) => { if (mentionStart < 0) return; const before = text.slice(0, mentionStart); const after = text.slice(text.indexOf('@', mentionStart) !== -1 ? (() => { const rest = text.slice(mentionStart); const match = rest.match(/^@[a-zA-Z0-9._-]*/); return mentionStart + (match ? match[0].length : 0); })() : mentionStart); const insert = `@${actor.handle} `; setText(before + insert + after); setMentionQuery(null); setMentionResults([]); requestAnimationFrame(() => { if (textareaRef.current) { const pos = before.length + insert.length; textareaRef.current.selectionStart = pos; textareaRef.current.selectionEnd = pos; textareaRef.current.focus(); } }); }, [text, mentionStart, setText]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (mentionQuery !== null && mentionResults.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex((i) => Math.min(i + 1, mentionResults.length - 1)); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex((i) => Math.max(i - 1, 0)); return; } if (e.key === 'Tab' || e.key === 'Enter') { e.preventDefault(); selectMention(mentionResults[mentionIndex]); return; } } }, [mentionQuery, mentionResults, mentionIndex, selectMention]); useEffect(() => { if (!showFontDropdown) return; const handler = (e: MouseEvent) => { if (fontDropdownRef.current && !fontDropdownRef.current.contains(e.target as Node)) { setShowFontDropdown(false); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [showFontDropdown]); const handleFormat = useCallback((style: TextStyle) => { const ta = textareaRef.current; if (!ta) return; const selStart = ta.selectionStart; const selEnd = ta.selectionEnd; const hasSelection = selEnd > selStart; const before = text.slice(0, selStart); const selected = hasSelection ? text.slice(selStart, selEnd) : text; const after = hasSelection ? text.slice(selEnd) : ''; const formatted = toggleStyle(selected, style); const newText = before + formatted + after; setText(newText); requestAnimationFrame(() => { if (!textareaRef.current) return; if (hasSelection) { textareaRef.current.selectionStart = selStart; textareaRef.current.selectionEnd = selStart + formatted.length; } else { const pos = before.length + formatted.length; textareaRef.current.selectionStart = pos; textareaRef.current.selectionEnd = pos; } setSelectionStyles(detectStyles(formatted)); textareaRef.current.focus(); }); }, [text, setText]); const handleSelectChange = useCallback(() => { const ta = textareaRef.current; if (!ta) return; const selStart = ta.selectionStart; const selEnd = ta.selectionEnd; if (selEnd > selStart) { setSelectionStyles(detectStyles(text.slice(selStart, selEnd))); } else { setSelectionStyles({ bold: false, italic: false, underline: false, strikethrough: false, stroke: false, fontFamily: 'none' }); } }, [text]); const handleFontSelect = useCallback((style: TextStyle) => { handleFormat(style); setShowFontDropdown(false); }, [handleFormat]); const handleImageSelect = useCallback((e: React.ChangeEvent) => { const files = e.target.files; if (!files) return; const remaining = MAX_IMAGES - images.length; const toAdd = Array.from(files).slice(0, remaining); const newImages: PendingImage[] = toAdd.map((file) => ({ file, url: URL.createObjectURL(file), alt: '', })); setImages((prev) => [...prev, ...newImages]); if (fileInputRef.current) fileInputRef.current.value = ''; }, [images.length, setImages]); const removeImage = useCallback((index: number) => { setImages((prev) => { URL.revokeObjectURL(prev[index].url); return prev.filter((_, i) => i !== index); }); }, [setImages]); const updateImageAlt = useCallback((index: number, alt: string) => { setImages((prev) => prev.map((img, i) => i === index ? { ...img, alt } : img)); }, [setImages]); const handleVideoSelect = useCallback((e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setSelectedGif(null); const url = URL.createObjectURL(file); const vid = document.createElement('video'); vid.preload = 'metadata'; vid.onloadedmetadata = () => { setVideo({ file, url, width: vid.videoWidth || 16, height: vid.videoHeight || 9, }); URL.revokeObjectURL(vid.src); }; vid.src = url; if (videoInputRef.current) videoInputRef.current.value = ''; }, [setVideo, setSelectedGif]); const removeVideo = useCallback(() => { if (video) { URL.revokeObjectURL(video.url); setVideo(null); } }, [video, setVideo]); const searchGifs = useCallback(async (q: string) => { if (!q) return; setGifLoading(true); try { const results = await atprotoClient.searchGifs(q, 20); setGifResults(results); } catch { setGifResults([]); } finally { setGifLoading(false); } }, []); useEffect(() => { if (!showGifPicker) return; if (gifSearchRef.current) clearTimeout(gifSearchRef.current); const q = gifQuery.trim(); if (!q) return; gifSearchRef.current = setTimeout(() => searchGifs(q), 300); return () => { if (gifSearchRef.current) clearTimeout(gifSearchRef.current); }; }, [showGifPicker, gifQuery, searchGifs]); const selectGif = useCallback((gif: GifResult) => { setSelectedGif(gif); setShowGifPicker(false); images.forEach((img) => URL.revokeObjectURL(img.url)); setImages([]); if (video) { URL.revokeObjectURL(video.url); setVideo(null); } }, [images, video, setSelectedGif, setImages, setVideo]); const removeGif = useCallback(() => { setSelectedGif(null); }, [setSelectedGif]); const charCount = useMemo(() => graphemeLength(text), [text]); const hasMedia = images.length > 0 || video !== null || selectedGif !== null; const canSubmit = text.trim().length > 0 && charCount <= MAX_CHARS && !submitting && !uploadingMedia; const canSubmitThread = useMemo(() => { if (!isThreadMode) return canSubmit; return drafts.every((d) => d.text.trim().length > 0 && graphemeLength(d.text) <= MAX_CHARS) && !submitting && !uploadingMedia; }, [isThreadMode, canSubmit, drafts, submitting, uploadingMedia]); const addDraftToThread = useCallback(() => { setDrafts((prev) => { const next = createEmptyDraft(); return [...prev, next]; }); requestAnimationFrame(() => { setActiveIndex((prev) => prev + 1); }); }, []); const removeDraftFromThread = useCallback((index: number) => { setDrafts((prev) => { prev[index].images.forEach((img) => URL.revokeObjectURL(img.url)); if (prev[index].video) URL.revokeObjectURL(prev[index].video.url); const next = prev.filter((_, i) => i !== index); return next; }); setActiveIndex((prev) => { if (prev >= drafts.length - 1) return Math.max(0, prev - 1); return prev; }); }, [drafts.length]); const buildEmbedForDraft = useCallback(async (draft: DraftPost): Promise => { if (draft.images.length > 0) { setUploadingMedia(true); const uploadedImages = []; for (const img of draft.images) { const arrayBuf = await img.file.arrayBuffer(); const result = await atprotoClient.uploadBlob( new Uint8Array(arrayBuf), img.file.type, ); uploadedImages.push({ alt: img.alt, image: result.blob, aspectRatio: { width: 1, height: 1 }, }); } setUploadingMedia(false); return { $type: 'app.bsky.embed.images', images: uploadedImages, }; } else if (draft.video) { setUploadingMedia(true); const arrayBuf = await draft.video.file.arrayBuffer(); const result = await atprotoClient.uploadBlob( new Uint8Array(arrayBuf), draft.video.file.type, ); setUploadingMedia(false); return { $type: 'app.bsky.embed.video', video: result.blob, aspectRatio: { width: draft.video.width, height: draft.video.height, }, }; } else if (draft.selectedGif) { return { $type: 'app.bsky.embed.external', external: { uri: draft.selectedGif.url, title: draft.selectedGif.title || 'GIF', description: '', }, }; } return undefined; }, []); const handleSubmit = useCallback(async () => { if (!canSubmitThread) return; setSubmitting(true); setError(null); try { if (isEditMode && editPost) { const rt = new RichText({ text }); await rt.detectFacets(atprotoClient.api!); let embed: unknown = undefined; if (images.length > 0) { setUploadingMedia(true); const uploadedImages = []; for (const img of images) { const arrayBuf = await img.file.arrayBuffer(); const result = await atprotoClient.uploadBlob( new Uint8Array(arrayBuf), img.file.type, ); uploadedImages.push({ alt: img.alt, image: result.blob, aspectRatio: { width: 1, height: 1 }, }); } setUploadingMedia(false); embed = { $type: 'app.bsky.embed.images', images: uploadedImages, }; } else if (video) { setUploadingMedia(true); const arrayBuf = await video.file.arrayBuffer(); const result = await atprotoClient.uploadBlob( new Uint8Array(arrayBuf), video.file.type, ); setUploadingMedia(false); embed = { $type: 'app.bsky.embed.video', video: result.blob, aspectRatio: { width: video.width, height: video.height, }, }; } else if (selectedGif) { embed = { $type: 'app.bsky.embed.external', external: { uri: selectedGif.url, title: selectedGif.title || 'GIF', description: '', }, }; } await atprotoClient.editPost({ uri: editPost.uri, text: rt.text, facets: rt.facets as unknown[] | undefined, embed: embed ?? editPost.record.embed, originalRecord: editPost.record as unknown as Record, }); } else if (isReplyMode && replyTo) { const draftsToPost = isThreadMode ? drafts : [{ text, images, video, selectedGif, id: 0 }]; let lastUri = replyTo.uri; let lastCid = replyTo.cid; const rootRef = replyTo.record.reply?.root ?? { uri: replyTo.uri, cid: replyTo.cid }; for (const draft of draftsToPost) { const rt = new RichText({ text: draft.text }); await rt.detectFacets(atprotoClient.api!); const embed = await buildEmbedForDraft(draft); const result = await atprotoClient.postReply( rt.text, { uri: lastUri, cid: lastCid } as PostView, rootRef, embed, rt.facets as unknown[] | undefined, ); lastUri = result.uri; lastCid = result.cid; } } else if (isQuoteMode && quotePost) { const rt = new RichText({ text }); await rt.detectFacets(atprotoClient.api!); let embed: unknown = undefined; if (images.length > 0) { setUploadingMedia(true); const uploadedImages = []; for (const img of images) { const arrayBuf = await img.file.arrayBuffer(); const result = await atprotoClient.uploadBlob( new Uint8Array(arrayBuf), img.file.type, ); uploadedImages.push({ alt: img.alt, image: result.blob, aspectRatio: { width: 1, height: 1 }, }); } setUploadingMedia(false); embed = { $type: 'app.bsky.embed.images', images: uploadedImages, }; } else if (video) { setUploadingMedia(true); const arrayBuf = await video.file.arrayBuffer(); const result = await atprotoClient.uploadBlob( new Uint8Array(arrayBuf), video.file.type, ); setUploadingMedia(false); embed = { $type: 'app.bsky.embed.video', video: result.blob, aspectRatio: { width: video.width, height: video.height, }, }; } else if (selectedGif) { embed = { $type: 'app.bsky.embed.external', external: { uri: selectedGif.url, title: selectedGif.title || 'GIF', description: '', }, }; } const quoteEmbed = { $type: 'app.bsky.embed.record', record: { uri: quotePost.uri, cid: quotePost.cid }, }; await atprotoClient.createPost({ text: rt.text, facets: rt.facets as unknown[] | undefined, embed: embed ? { $type: 'app.bsky.embed.recordWithMedia', record: { record: { uri: quotePost.uri, cid: quotePost.cid } }, media: embed, } : quoteEmbed, }); } else { let lastResult: { uri: string; cid: string } | null = null; for (let i = 0; i < drafts.length; i++) { const draft = drafts[i]; const rt = new RichText({ text: draft.text }); await rt.detectFacets(atprotoClient.api!); const embed = await buildEmbedForDraft(draft); if (i === 0) { lastResult = await atprotoClient.createPost({ text: rt.text, facets: rt.facets as unknown[] | undefined, embed, }); } else { lastResult = await atprotoClient.postReply( rt.text, { uri: lastResult!.uri, cid: lastResult!.cid } as PostView, undefined, embed, rt.facets as unknown[] | undefined, ); } } } onPosted?.(); window.dispatchEvent(new CustomEvent('foxsky:post-created', { detail: { type: isEditMode ? 'edit' : isReplyMode ? 'reply' : isQuoteMode ? 'quote' : 'post', replyToUri: isReplyMode && replyTo ? replyTo.uri : undefined, }, })); onClose(); } catch (err) { const msg = err instanceof Error ? err.message : `Failed to ${label('post')}`; setError(msg); } finally { setSubmitting(false); setUploadingMedia(false); } }, [canSubmitThread, text, images, video, selectedGif, onPosted, onClose, isEditMode, isReplyMode, isQuoteMode, isThreadMode, replyTo, quotePost, editPost, drafts, buildEmbedForDraft, label]); const mediaType = selectedGif ? 'gif' : video ? 'video' : images.length > 0 ? 'images' : null; const replyDisplayName = replyTo ? (replyTo.author.displayName ?? replyTo.author.handle) : ''; const replyPronouns = replyTo?.author.pronouns; return (
{ e.stopPropagation(); e.preventDefault(); onClose(); }}>
e.stopPropagation()}>
{title}
{isReplyMode && replyTo && (
{replyTo.author.avatar ? ( ) : (
{replyDisplayName.charAt(0).toUpperCase()}
)}
@{replyTo.author.handle} {replyPronouns && ( Β· {replyPronouns} )} {replyTo.record.text.slice(0, 140)}{replyTo.record.text.length > 140 ? '…' : ''}
)} {isQuoteMode && quotePost && (
{quotePost.author.avatar ? ( ) : (
{(quotePost.author.displayName ?? quotePost.author.handle).charAt(0).toUpperCase()}
)}
{quotePost.author.displayName ?? quotePost.author.handle} @{quotePost.author.handle}
{quotePost.record.text.slice(0, 200)}{quotePost.record.text.length > 200 ? '…' : ''}
)} {isThreadMode && drafts.length > 1 ? (
{drafts.map((d, i) => (
{i + 1}/{drafts.length} {i > 0 && ( )}
{i === activeIndex ? (