import './compose.css'; import '@github/text-expander-element'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import stringLength from 'string-length'; import supportedLanguages from '../data/status-supported-languages'; import urlRegex from '../data/url-regex'; import emojifyText from '../utils/emojify-text'; import openCompose from '../utils/open-compose'; 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'; const supportedLanguagesMap = supportedLanguages.reduce((acc, l) => { const [code, common, native] = l; acc[code] = { common, native, }; return acc; }, {}); /* NOTES: - Max character limit includes BOTH status text and Content Warning text */ const expiryOptions = { '5 minutes': 5 * 60, '30 minutes': 30 * 60, '1 hour': 60 * 60, '6 hours': 6 * 60 * 60, '1 day': 24 * 60 * 60, '3 days': 3 * 24 * 60 * 60, '7 days': 7 * 24 * 60 * 60, }; const expirySeconds = Object.values(expiryOptions); const oneDay = 24 * 60 * 60; const expiresInFromExpiresAt = (expiresAt) => { if (!expiresAt) return oneDay; const delta = (new Date(expiresAt).getTime() - Date.now()) / 1000; return expirySeconds.find((s) => s >= delta) || oneDay; }; const menu = document.createElement('ul'); menu.role = 'listbox'; menu.className = 'text-expander-menu'; const DEFAULT_LANG = 'en'; function Compose({ onClose, replyToStatus, editStatus, draftStatus, standalone, hasOpener, }) { 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(() => { try { 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; } catch (e) { console.error(e); alert('Failed to load instance configuration. Please try again.'); // Temporary fix for corrupted data store.local.del('instances'); location.reload(); return {}; } }, []); const { statuses: { maxCharacters, maxMediaAttachments, charactersReservedPerUrl }, mediaAttachments: { supportedMimeTypes, imageSizeLimit, imageMatrixLimit, videoSizeLimit, videoMatrixLimit, videoFrameRateLimit, }, polls: { maxOptions, maxCharactersPerOption, maxExpiration, minExpiration }, } = configuration; const textareaRef = useRef(); const spoilerTextRef = useRef(); const [visibility, setVisibility] = useState('public'); const [sensitive, setSensitive] = useState(false); const [language, setLanguage] = useState( store.session.get('currentLanguage') || DEFAULT_LANG, ); const [mediaAttachments, setMediaAttachments] = useState([]); const [poll, setPoll] = useState(null); const customEmojis = useRef(); useEffect(() => { (async () => { try { const emojis = await masto.v1.customEmojis.list(); console.log({ emojis }); customEmojis.current = emojis; } catch (e) { // silent fail console.error(e); } })(); }, []); const oninputTextarea = () => { if (!textareaRef.current) return; textareaRef.current.dispatchEvent(new Event('input')); }; const focusTextarea = () => { setTimeout(() => { console.log('focusing'); textareaRef.current?.focus(); }, 300); }; useEffect(() => { if (replyToStatus) { const { spoilerText, visibility, language, sensitive } = replyToStatus; if (spoilerText && spoilerTextRef.current) { spoilerTextRef.current.value = spoilerText; } const mentions = new Set([ replyToStatus.account.acct, ...replyToStatus.mentions.map((m) => m.acct), ]); const allMentions = [...mentions].filter( (m) => m !== currentAccountInfo.acct, ); if (allMentions.length > 0) { textareaRef.current.value = `${allMentions .map((m) => `@${m}`) .join(' ')} `; oninputTextarea(); } focusTextarea(); setVisibility(visibility); setLanguage(language || DEFAULT_LANG); setSensitive(sensitive); } if (draftStatus) { const { status, spoilerText, visibility, language, sensitive, poll, mediaAttachments, } = draftStatus; const composablePoll = !!poll?.options && { ...poll, options: poll.options.map((o) => o?.title || o), expiresIn: poll?.expiresIn || expiresInFromExpiresAt(poll.expiresAt), }; textareaRef.current.value = status; oninputTextarea(); focusTextarea(); spoilerTextRef.current.value = spoilerText; setVisibility(visibility); setLanguage(language || DEFAULT_LANG); setSensitive(sensitive); setPoll(composablePoll); setMediaAttachments(mediaAttachments); } else if (editStatus) { const { visibility, language, sensitive, poll, mediaAttachments } = editStatus; const composablePoll = !!poll?.options && { ...poll, options: poll.options.map((o) => o?.title || o), expiresIn: poll?.expiresIn || expiresInFromExpiresAt(poll.expiresAt), }; setUIState('loading'); (async () => { try { const statusSource = await masto.v1.statuses.fetchSource( editStatus.id, ); console.log({ statusSource }); const { text, spoilerText } = statusSource; textareaRef.current.value = text; textareaRef.current.dataset.source = text; oninputTextarea(); focusTextarea(); spoilerTextRef.current.value = spoilerText; setVisibility(visibility); setLanguage(language || DEFAULT_LANG); setSensitive(sensitive); setPoll(composablePoll); setMediaAttachments(mediaAttachments); setUIState('default'); } catch (e) { console.error(e); alert(e?.reason || e); setUIState('error'); } })(); } else { focusTextarea(); } }, [draftStatus, editStatus, replyToStatus]); 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; } if (key === ':') { // const emojis = customEmojis.current.filter((emoji) => // emoji.shortcode.startsWith(text), // ); const emojis = filterShortcodes(customEmojis.current, text); let html = ''; emojis.forEach((emoji) => { const { shortcode, url } = emoji; html += `
  • :${encodeHTML(shortcode)}:
  • `; }); // console.log({ emojis, html }); menu.innerHTML = html; provide( Promise.resolve({ matched: emojis.length > 0, fragment: menu, }), ); return; } const type = { '@': 'accounts', '#': 'hashtags', }[key]; provide( new Promise((resolve) => { const searchResults = masto.v2.search({ type, q: text, limit: 5, }); searchResults.then((value) => { if (text !== textExpanderTextRef.current) { return; } console.log({ value, type, v: value[type] }); const results = value[type]; console.log('RESULTS', value, results); let html = ''; results.forEach((result) => { const { name, avatarStatic, displayName, username, acct, emojis, } = result; const displayNameWithEmoji = emojifyText(displayName, emojis); // const item = menuItem.cloneNode(); if (acct) { html += `
  • ${displayNameWithEmoji || username}
    @${encodeHTML(acct)}
  • `; } else { html += `
  • #${encodeHTML(name)}
  • `; } menu.innerHTML = html; }); 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; if (key === ':') { e.detail.value = `:${item.dataset.value}:`; } else { e.detail.value = `${key}${item.dataset.value}`; } }); } }, []); const formRef = useRef(); const beforeUnloadCopy = 'You have unsaved changes. Are you sure you want to discard this post?'; const canClose = () => { const { value, dataset } = textareaRef.current; // check if loading if (uiState === 'loading') { console.log('canClose', { uiState }); return false; } // check for status and media attachments const hasMediaAttachments = mediaAttachments.length > 0; if (!value && !hasMediaAttachments) { console.log('canClose', { value, mediaAttachments }); return true; } // check if all media attachments have IDs const hasIDMediaAttachments = mediaAttachments.length > 0 && mediaAttachments.every((media) => media.id); if (hasIDMediaAttachments) { console.log('canClose', { hasIDMediaAttachments }); return true; } // check if status contains only "@acct", if replying const isSelf = replyToStatus?.account.id === currentAccount; const hasOnlyAcct = replyToStatus && value.trim() === `@${replyToStatus.account.acct}`; // TODO: check for mentions, or maybe just generic "@username", including multiple mentions like "@username1@username2" if (!isSelf && hasOnlyAcct) { console.log('canClose', { isSelf, hasOnlyAcct }); return true; } // check if status is same with source const sameWithSource = value === dataset?.source; if (sameWithSource) { console.log('canClose', { sameWithSource }); return true; } console.log('canClose', { value, hasMediaAttachments, hasIDMediaAttachments, poll, isSelf, hasOnlyAcct, sameWithSource, uiState, }); return false; }; const confirmClose = () => { if (!canClose()) { 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, }); }, []); const [charCount, setCharCount] = useState( textareaRef.current?.value?.length + spoilerTextRef.current?.value?.length || 0, ); const leftChars = maxCharacters - charCount; const getCharCount = () => { const { value } = textareaRef.current; const { value: spoilerText } = spoilerTextRef.current; return stringLength(countableText(value)) + stringLength(spoilerText); }; const updateCharCount = () => { setCharCount(getCharCount()); }; return (
    {currentAccountInfo?.avatarStatic && ( )} {!standalone ? ( {' '} ) : ( hasOpener && ( ) )}
    {!!replyToStatus && (
    Replying to @ {replyToStatus.account.acct || replyToStatus.account.username} ’s status
    )} {!!editStatus && (
    Editing source status
    )}
    { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { formRef.current.dispatchEvent( new Event('submit', { cancelable: true }), ); } }} onSubmit={(e) => { 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 /* Let the backend validate this 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; } */ if (poll) { if (poll.options.length < 2) { alert('Poll must have at least 2 options'); return; } if (poll.options.some((option) => option === '')) { alert('Some poll choices are empty'); 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, id } = attachment; console.log('UPLOADING', attachment); if (id) { // If already uploaded return attachment; } else { const params = removeNullUndefined({ file, description, }); return masto.v2.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') { console.error(result); alert(result.reason || `Attachment #${i} failed`); } }); return; } console.log({ results, mediaAttachments }); } /* NOTE: Using snakecase here because masto.js's `isObject` returns false for `params`, ONLY happens when opening in pop-out window. This is maybe due to `window.masto` variable being passed from the parent window. The check that failed is `x.constructor === Object`, so maybe the `Object` in new window is different than parent window's? Code: https://github.com/neet/masto.js/blob/dd0d649067b6a2b6e60fbb0a96597c373a255b00/src/serializers/is-object.ts#L2 */ let params = { status, // spoilerText, spoiler_text: spoilerText, language, sensitive, poll, // mediaIds: mediaAttachments.map((attachment) => attachment.id), media_ids: mediaAttachments.map((attachment) => attachment.id), }; if (!editStatus) { params.visibility = visibility; // params.inReplyToId = replyToStatus?.id || undefined; params.in_reply_to_id = replyToStatus?.id || undefined; } params = removeNullUndefined(params); console.log('POST', params); let newStatus; if (editStatus) { newStatus = await masto.v1.statuses.update( editStatus.id, params, ); } else { newStatus = await masto.v1.statuses.create(params); } setUIState('default'); // Close onClose({ newStatus, }); } catch (e) { console.error(e); alert(e?.reason || e); setUIState('error'); } })(); }} >
    { updateCharCount(); }} /> {' '} {' '}
    {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); }); }} /> ); })}
    )} {!!poll && ( { if (poll) { const newPoll = { ...poll }; setPoll(newPoll); } else { setPoll(null); } }} /> )}
    {' '} {' '}
    {uiState === 'loading' && }{' '} {uiState !== 'loading' && charCount > maxCharacters / 2 && ( <> {' '} )} {' '}
    ); } 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}

    ) : ( )}
    ); } function Poll({ poll, disabled, onInput = () => {}, maxOptions, maxExpiration, minExpiration, maxCharactersPerOption, }) { const { options, expiresIn, multiple } = poll; return (
    {options.map((option, i) => (
    { const { value } = e.target; options[i] = value; onInput(poll); }} />
    ))}
    {' '}
    ); } function filterShortcodes(emojis, searchTerm) { searchTerm = searchTerm.toLowerCase(); // Return an array of shortcodes that start with or contain the search term, sorted by relevance and limited to the first 5 return emojis .sort((a, b) => { let aLower = a.shortcode.toLowerCase(); let bLower = b.shortcode.toLowerCase(); let aStartsWith = aLower.startsWith(searchTerm); let bStartsWith = bLower.startsWith(searchTerm); let aContains = aLower.includes(searchTerm); let bContains = bLower.includes(searchTerm); let bothStartWith = aStartsWith && bStartsWith; let bothContain = aContains && bContains; return bothStartWith ? a.length - b.length : aStartsWith ? -1 : bStartsWith ? 1 : bothContain ? a.length - b.length : aContains ? -1 : bContains ? 1 : 0; }) .slice(0, 5); } function encodeHTML(str) { return str.replace(/[&<>"']/g, function (char) { return '&#' + char.charCodeAt(0) + ';'; }); } // https://github.com/mastodon/mastodon/blob/c4a429ed47e85a6bbf0d470a41cc2f64cf120c19/app/javascript/mastodon/features/compose/util/counter.js const urlRegexObj = new RegExp(urlRegex.source, urlRegex.flags); const usernameRegex = /(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/gi; const urlPlaceholder = '$2xxxxxxxxxxxxxxxxxxxxxxx'; function countableText(inputText) { return inputText .replace(urlRegexObj, urlPlaceholder) .replace(usernameRegex, '$1@$3'); } function removeNullUndefined(obj) { for (let key in obj) { if (obj[key] === null || obj[key] === undefined) { delete obj[key]; } } return obj; } export default Compose;