import './compose.css'; import '@github/text-expander-element'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import stringLength from 'string-length'; import emojifyText from '../utils/emojify-text'; import store from '../utils/store'; import visibilityIconsMap from '../utils/visibility-icons-map'; import Avatar from './avatar'; import Icon from './icon'; import Loader from './loader'; import Status from './status'; /* NOTES: - Max character limit includes BOTH status text and Content Warning text */ export default ({ onClose, replyToStatus, editStatus }) => { const [uiState, setUIState] = useState('default'); const accounts = store.local.getJSON('accounts'); const currentAccount = store.session.get('currentAccount'); const currentAccountInfo = accounts.find( (a) => a.info.id === currentAccount, ).info; const configuration = useMemo(() => { const instances = store.local.getJSON('instances'); const currentInstance = accounts.find( (a) => a.info.id === currentAccount, ).instanceURL; const config = instances[currentInstance].configuration; console.log(config); return config; }, []); const { statuses: { maxCharacters, maxMediaAttachments, charactersReservedPerUrl }, mediaAttachments: { supportedMimeTypes, imageSizeLimit, imageMatrixLimit, videoSizeLimit, videoMatrixLimit, videoFrameRateLimit, }, polls: { maxOptions, maxCharactersPerOption, maxExpiration, minExpiration }, } = configuration; const textareaRef = useRef(); const [visibility, setVisibility] = useState( replyToStatus?.visibility || 'public', ); const [sensitive, setSensitive] = useState(replyToStatus?.sensitive || false); const spoilerTextRef = useRef(); useEffect(() => { let timer = setTimeout(() => { const spoilerText = replyToStatus?.spoilerText; if (spoilerText && spoilerTextRef.current) { spoilerTextRef.current.value = spoilerText; spoilerTextRef.current.focus(); } else { textareaRef.current?.focus(); } }, 0); return () => clearTimeout(timer); }, []); useEffect(() => { if (editStatus) { const { visibility, sensitive, mediaAttachments } = editStatus; setUIState('loading'); (async () => { try { const statusSource = await masto.statuses.fetchSource(editStatus.id); console.log({ statusSource }); const { text, spoilerText } = statusSource; textareaRef.current.value = text; textareaRef.current.dataset.source = text; spoilerTextRef.current.value = spoilerText; setVisibility(visibility); setSensitive(sensitive); setMediaAttachments(mediaAttachments); setUIState('default'); } catch (e) { console.error(e); alert(e?.reason || e); setUIState('error'); } })(); } }, [editStatus]); const textExpanderRef = useRef(); const textExpanderTextRef = useRef(''); useEffect(() => { if (textExpanderRef.current) { const handleChange = (e) => { console.log('text-expander-change', e); const { key, provide, text } = e.detail; textExpanderTextRef.current = text; if (text === '') { provide( Promise.resolve({ matched: false, }), ); return; } const type = { '@': 'accounts', '#': 'hashtags', }[key]; provide( new Promise((resolve) => { const resultsIterator = masto.search({ type, q: text, limit: 5, }); resultsIterator.next().then(({ value }) => { if (text !== textExpanderTextRef.current) { return; } const results = value[type]; console.log('RESULTS', value, results); const menu = document.createElement('ul'); menu.role = 'listbox'; menu.className = 'text-expander-menu'; results.forEach((result) => { const { name, avatarStatic, displayName, username, acct, emojis, } = result; const displayNameWithEmoji = emojifyText(displayName, emojis); const item = document.createElement('li'); item.setAttribute('role', 'option'); if (acct) { item.dataset.value = acct; // Want to use here, but will need to render to string 😅 item.innerHTML = ` ${displayNameWithEmoji || username}
@${acct}
`; } else { item.dataset.value = name; item.innerHTML = ` #${name} `; } menu.appendChild(item); }); console.log('MENU', results, menu); resolve({ matched: results.length > 0, fragment: menu, }); }); }), ); }; textExpanderRef.current.addEventListener( 'text-expander-change', handleChange, ); textExpanderRef.current.addEventListener('text-expander-value', (e) => { const { key, item } = e.detail; e.detail.value = key + item.dataset.value; }); } }, []); const [mediaAttachments, setMediaAttachments] = useState([]); const formRef = useRef(); const beforeUnloadCopy = 'You have unsaved changes. Are you sure you want to discard this post?'; const canClose = () => { // check for status or mediaAttachments const { value, dataset } = textareaRef.current; const containNonIDMediaAttachments = mediaAttachments.length > 0 && mediaAttachments.some((media) => !media.id); if ((value && value !== dataset?.source) || containNonIDMediaAttachments) { const yes = confirm(beforeUnloadCopy); return yes; } return true; }; useEffect(() => { // Show warning if user tries to close window with unsaved changes const handleBeforeUnload = (e) => { if (!canClose()) { e.preventDefault(); e.returnValue = beforeUnloadCopy; } }; window.addEventListener('beforeunload', handleBeforeUnload, { capture: true, }); return () => window.removeEventListener('beforeunload', handleBeforeUnload, { capture: true, }); }, []); return (
{currentAccountInfo?.avatarStatic && ( )}
{!!replyToStatus && (
)}
{ e.preventDefault(); const formData = new FormData(e.target); const entries = Object.fromEntries(formData.entries()); console.log('ENTRIES', entries); let { status, visibility, sensitive, spoilerText } = entries; // Pre-cleanup sensitive = sensitive === 'on'; // checkboxes return "on" if checked // Validation if (stringLength(status) > maxCharacters) { alert(`Status is too long! Max characters: ${maxCharacters}`); return; } if ( sensitive && stringLength(status) + stringLength(spoilerText) > maxCharacters ) { alert( `Status and content warning is too long! Max characters: ${maxCharacters}`, ); return; } // TODO: check for URLs and use `charactersReservedPerUrl` to calculate max characters // Post-cleanup spoilerText = (sensitive && spoilerText) || undefined; status = status === '' ? undefined : status; setUIState('loading'); (async () => { try { console.log('MEDIA ATTACHMENTS', mediaAttachments); if (mediaAttachments.length > 0) { // Upload media attachments first const mediaPromises = mediaAttachments.map((attachment) => { const { file, description, sourceDescription, id } = attachment; console.log('UPLOADING', attachment); if (id) { // If already uploaded return attachment; } else { const params = { file, description, }; return masto.mediaAttachments.create(params).then((res) => { if (res.id) { attachment.id = res.id; } return res; }); } }); const results = await Promise.allSettled(mediaPromises); // If any failed, return if ( results.some((result) => { return result.status === 'rejected' || !result.value?.id; }) ) { setUIState('error'); // Alert all the reasons results.forEach((result) => { if (result.status === 'rejected') { alert(result.reason || `Attachment #${i} failed`); } }); return; } console.log({ results, mediaAttachments }); } const params = { status, spoilerText, sensitive, mediaIds: mediaAttachments.map((attachment) => attachment.id), }; if (!editStatus) { params.visibility = visibility; params.inReplyToId = replyToStatus?.id || undefined; } console.log('POST', params); let newStatus; if (editStatus) { newStatus = await masto.statuses.update(editStatus.id, params); } else { newStatus = await masto.statuses.create(params); } setUIState('default'); // Close onClose({ newStatus, }); } catch (e) { console.error(e); alert(e?.reason || e); setUIState('error'); } })(); }} >
{' '} {' '}
{mediaAttachments.length > 0 && (
{mediaAttachments.map((attachment, i) => { const { id } = attachment; return ( { setMediaAttachments((attachments) => { const newAttachments = [...attachments]; newAttachments[i].description = value; return newAttachments; }); }} onRemove={() => { setMediaAttachments((attachments) => { return attachments.filter((_, j) => j !== i); }); }} /> ); })}
)}
{uiState === 'loading' && }{' '}
); }; function MediaAttachment({ attachment, disabled, onDescriptionChange = () => {}, onRemove = () => {}, }) { const { url, type, id, description } = attachment; const suffixType = type.split('/')[0]; return (
{suffixType === 'image' ? ( ) : suffixType === 'video' || suffixType === 'gifv' ? (
{!!id ? (
Uploaded

{description || No description}

) : ( )}
); }