Something went wrong. Try again.
A Bsky-like frontend using the atprotocol natively.
Something went wrong. Try again.
39 kB · 1147 lines
TSX
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148import { 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<DraftPost[]>(() => { 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<PendingImage[]>) => { 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<string | null>(null);
const [mentionQuery, setMentionQuery] = useState<string | null>(null); const [mentionResults, setMentionResults] = useState<ActorTypeaheadResult[]>([]); const [mentionIndex, setMentionIndex] = useState(0); const [mentionStart, setMentionStart] = useState(-1); const mentionSearchRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [showGifPicker, setShowGifPicker] = useState(false); const [gifQuery, setGifQuery] = useState(''); const [gifResults, setGifResults] = useState<GifResult[]>([]); const [gifLoading, setGifLoading] = useState(false); const gifSearchRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const fileInputRef = useRef<HTMLInputElement>(null); const videoInputRef = useRef<HTMLInputElement>(null);
const [selectionStyles, setSelectionStyles] = useState<DetectedStyles>({ bold: false, italic: false, underline: false, strikethrough: false, stroke: false, fontFamily: 'none', });
const [showFontDropdown, setShowFontDropdown] = useState(false); const fontDropdownRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>) => { 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<HTMLTextAreaElement>) => { 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<HTMLInputElement>) => { 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<HTMLInputElement>) => { 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<unknown> => { 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<string, unknown>, }); } 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 ( <div className="compose-overlay" onClick={(e) => { e.stopPropagation(); e.preventDefault(); onClose(); }}> <div className="compose-content" onClick={(e) => e.stopPropagation()}> <div className="compose-header"> <span className="compose-title">{title}</span>
<button className="compose-close" onClick={(e) => { e.stopPropagation(); e.preventDefault(); onClose(); }}> <FontAwesomeIcon icon={faXmark} /> </button> </div>
<div className="compose-body"> {isReplyMode && replyTo && ( <div className="compose-reply-context"> {replyTo.author.avatar ? ( <img className="compose-reply-avatar" src={replyTo.author.avatar} alt="" /> ) : ( <div className="compose-reply-avatar-ph"> {replyDisplayName.charAt(0).toUpperCase()} </div> )} <div className="compose-reply-context-info"> <span className="compose-reply-context-name"> @{replyTo.author.handle} {replyPronouns && ( <span className="compose-reply-pronouns"> · {replyPronouns}</span> )} </span> <span className="compose-reply-context-text"> {replyTo.record.text.slice(0, 140)}{replyTo.record.text.length > 140 ? '…' : ''} </span> </div> </div> )}
{isQuoteMode && quotePost && ( <div className="compose-quote-preview"> <div className="compose-quote-preview-header"> {quotePost.author.avatar ? ( <img className="compose-quote-preview-avatar" src={quotePost.author.avatar} alt="" /> ) : ( <div className="compose-quote-preview-avatar-ph"> {(quotePost.author.displayName ?? quotePost.author.handle).charAt(0).toUpperCase()} </div> )} <div className="compose-quote-preview-author"> <span className="compose-quote-preview-name"> {quotePost.author.displayName ?? quotePost.author.handle} </span> <span className="compose-quote-preview-handle"> @{quotePost.author.handle} </span> </div> </div> <div className="compose-quote-preview-text"> {quotePost.record.text.slice(0, 200)}{quotePost.record.text.length > 200 ? '…' : ''} </div> </div> )}
{isThreadMode && drafts.length > 1 ? ( <div className="compose-thread-blocks"> {drafts.map((d, i) => ( <div key={d.id} className={`compose-thread-block${i === activeIndex ? ' active' : ''}`}> <div className="compose-thread-block-header"> <span className="compose-thread-block-label">{i + 1}/{drafts.length}</span> {i > 0 && ( <button className="compose-thread-block-remove" onClick={() => removeDraftFromThread(i)} title="Remove this post" > <FontAwesomeIcon icon={faXmark} /> </button> )} </div> {i === activeIndex ? ( <textarea ref={textareaRef} className="compose-textarea" placeholder="What's up?" value={d.text} onChange={handleTextChange} onKeyDown={handleKeyDown} onSelect={handleSelectChange} rows={3} maxLength={MAX_CHARS + 50} /> ) : ( <div className={`compose-thread-block-preview${d.text.trim().length === 0 ? ' empty' : ''}`} onClick={() => setActiveIndex(i)} > {d.text.trim().length > 0 ? d.text : 'Empty post — tap to edit'} </div> )} </div> ))} </div> ) : ( <textarea ref={textareaRef} className="compose-textarea" placeholder={isEditMode ? `Edit your ${label('post')}` : isReplyMode ? `Write your ${label('reply')}` : isQuoteMode ? 'Add a comment' : "What's up?"} value={text} onChange={handleTextChange} onKeyDown={handleKeyDown} onSelect={handleSelectChange} rows={isReplyMode || isEditMode ? 4 : 5} maxLength={MAX_CHARS + 50} /> )}
{mentionQuery !== null && mentionResults.length > 0 && ( <div className="compose-mention-popup"> {mentionResults.map((actor, i) => ( <button key={actor.did} className={`compose-mention-item${i === mentionIndex ? ' active' : ''}`} onMouseDown={(e) => { e.preventDefault(); selectMention(actor); }} onMouseEnter={() => setMentionIndex(i)} > {actor.avatar ? ( <img className="compose-mention-avatar" src={actor.avatar} alt="" /> ) : ( <div className="compose-mention-avatar-ph"> {(actor.displayName || actor.handle)[0].toUpperCase()} </div> )} <div className="compose-mention-info"> <span className="compose-mention-name">{actor.displayName || actor.handle}</span> <span className="compose-mention-handle">@{actor.handle}</span> </div> </button> ))} </div> )}
{isEditMode && editPost?.embed && !hasMedia && ( <div className="compose-edit-embeds"> <div className="compose-edit-embeds-label">Attachments</div> <PostEmbeds embed={editPost.embed} /> </div> )}
<div className={`compose-charcount${charCount > MAX_CHARS ? ' over' : ''}${charCount > MAX_CHARS - 20 ? ' warn' : ''}`}> {charCount}/{MAX_CHARS} </div>
{images.length > 0 && ( <div className="compose-images"> {images.map((img, i) => ( <div key={i} className="compose-image-item"> <img src={img.url} alt={img.alt || `Image ${i + 1}`} /> <button className="compose-image-remove" onClick={() => removeImage(i)}> <FontAwesomeIcon icon={faXmark} /> </button> <input className="compose-image-alt" type="text" placeholder="Alt text..." value={img.alt} onChange={(e) => updateImageAlt(i, e.target.value)} onClick={(e) => e.stopPropagation()} /> </div> ))} </div> )}
{video && ( <div className="compose-video-preview"> <video src={video.url} muted controls preload="metadata" /> <button className="compose-video-remove" onClick={removeVideo}> <FontAwesomeIcon icon={faTrash} /> Remove video </button> </div> )}
{selectedGif && ( <div className="compose-gif-preview"> <img src={selectedGif.preview} alt={selectedGif.title} /> <button className="compose-gif-remove" onClick={removeGif}> <FontAwesomeIcon icon={faXmark} /> Remove GIF </button> </div> )}
{showGifPicker && ( <div className="compose-gif-picker"> <div className="compose-gif-search"> <FontAwesomeIcon icon={faSearch} className="compose-gif-search-icon" /> <input type="text" placeholder="Search GIFs..." value={gifQuery} onChange={(e) => setGifQuery(e.target.value)} autoFocus /> </div> <div className="compose-gif-grid"> {gifLoading && ( <div className="compose-gif-loading"> <FontAwesomeIcon icon={faSpinner} spin /> Searching... </div> )} {gifResults.map((gif) => ( <button key={gif.id} className="compose-gif-item" onClick={() => selectGif(gif)} > <img src={gif.preview} alt={gif.title} loading="lazy" /> </button> ))} {!gifLoading && gifQuery && gifResults.length === 0 && ( <div className="compose-gif-empty">No GIFs found</div> )} {!gifLoading && !gifQuery && ( <div className="compose-gif-empty">Type to search for GIFs</div> )} </div> </div> )} </div>
{error && <div className="compose-error">{error}</div>}
<div className="compose-footer"> <div className="compose-actions"> <div className="compose-format-group"> <button className={`compose-action-btn compose-format-btn${selectionStyles.bold ? ' active' : ''}`} onClick={() => handleFormat('bold')} title="Bold" aria-label="Bold" > <FontAwesomeIcon icon={faBold} /> </button> <button className={`compose-action-btn compose-format-btn${selectionStyles.italic ? ' active' : ''}`} onClick={() => handleFormat('italic')} title="Italic" aria-label="Italic" > <FontAwesomeIcon icon={faItalic} /> </button> <button className={`compose-action-btn compose-format-btn${selectionStyles.underline ? ' active' : ''}`} onClick={() => handleFormat('underline')} title="Underline" aria-label="Underline" > <FontAwesomeIcon icon={faUnderline} /> </button> <button className={`compose-action-btn compose-format-btn${selectionStyles.strikethrough ? ' active' : ''}`} onClick={() => handleFormat('strikethrough')} title="Strikethrough" aria-label="Strikethrough" > <FontAwesomeIcon icon={faStrikethrough} /> </button> <button className={`compose-action-btn compose-format-btn${selectionStyles.stroke ? ' active' : ''}`} onClick={() => handleFormat('stroke')} title="Stroke" aria-label="Stroke" > <FontAwesomeIcon icon={faSquare} /> </button>
<div className="compose-font-dropdown-wrap" ref={fontDropdownRef}> <button className={`compose-action-btn compose-format-btn${selectionStyles.fontFamily !== 'none' ? ' active' : ''}`} onClick={() => setShowFontDropdown((v) => !v)} title="Font style" aria-label="Font style" aria-haspopup="listbox" aria-expanded={showFontDropdown} > <FontAwesomeIcon icon={faWandMagicSparkles} /> <FontAwesomeIcon icon={faCaretDown} className="compose-font-caret" /> </button> {showFontDropdown && ( <div className="compose-font-dropdown" role="listbox"> {([ ['fancy', 'Fancy (Script)', '𝒜𝒷𝒸'], ['fraktur', 'Fraktur (Gothic)', '𝔄𝔟𝔠'], ['doubleStruck', 'Double-struck', '𝔸𝕓𝕔'], ['sansSerif', 'Sans-serif', '𝖠𝖻𝖼'], ['monospace', 'Monospace', '𝙰𝚋𝚌'], ] as const).map(([style, label, preview]) => ( <button key={style} className={`compose-font-option${selectionStyles.fontFamily === styleToFontFamily(style as TextStyle) ? ' active' : ''}`} onClick={() => handleFontSelect(style as TextStyle)} role="option" aria-selected={selectionStyles.fontFamily === styleToFontFamily(style as TextStyle)} > <span className="compose-font-label">{label}</span> <span className="compose-font-preview">{preview}</span> </button> ))} </div> )} </div> </div>
<div className="compose-action-separator" />
<button className="compose-action-btn" onClick={() => fileInputRef.current?.click()} disabled={mediaType === 'video' || mediaType === 'gif' || images.length >= MAX_IMAGES} title="Add images" > <FontAwesomeIcon icon={faImage} /> </button> <input ref={fileInputRef} type="file" accept="image/*" multiple style={{ display: 'none' }} onChange={handleImageSelect} />
<button className="compose-action-btn" onClick={() => videoInputRef.current?.click()} disabled={hasMedia} title="Add video" > <FontAwesomeIcon icon={faVideo} /> </button> <input ref={videoInputRef} type="file" accept="video/mp4,video/webm,video/quicktime" style={{ display: 'none' }} onChange={handleVideoSelect} />
<button className="compose-action-btn" onClick={() => setShowGifPicker(!showGifPicker)} disabled={mediaType === 'video' || mediaType === 'images'} title="Add GIF" > <FontAwesomeIcon icon={faFaceSmile} /> </button>
{/* Add to thread button */} {isThreadMode && ( <button className="compose-action-btn compose-thread-add-btn" onClick={addDraftToThread} title="Add another post to thread" > <FontAwesomeIcon icon={faPlus} /> </button> )} </div>
<button className="compose-submit" onClick={handleSubmit} disabled={!canSubmitThread} > {submitting || uploadingMedia ? uploadingMedia ? 'Uploading...' : (isEditMode ? 'Saving...' : isReplyMode ? `${label('reply', false, true)}ing...` : isQuoteMode ? `${label('post', false, true)}ing...` : `${label('post', false, true)}ing...`) : (isEditMode ? 'Save' : isReplyMode ? label('reply', false, true) : isQuoteMode ? 'Quote' : drafts.length > 1 ? `${label('post', false, true)} thread (${drafts.length})` : label('post', false, true))} </button> </div> </div> </div> );}