import './compose.css'; import '@github/text-expander-element'; import { msg, plural, t, Trans } from '@lingui/macro'; import { useLingui } from '@lingui/react'; import { MenuItem } from '@szhsin/react-menu'; import { deepEqual } from 'fast-equals'; import Fuse from 'fuse.js'; import { forwardRef, memo } from 'preact/compat'; import { useCallback, useEffect, useMemo, useRef, useState, } from 'preact/hooks'; import { useHotkeys } from 'react-hotkeys-hook'; import stringLength from 'string-length'; import { detectAll } from 'tinyld/light'; import { uid } from 'uid/single'; import { useDebouncedCallback, useThrottledCallback } from 'use-debounce'; import { useSnapshot } from 'valtio'; import poweredByGiphyURL from '../assets/powered-by-giphy.svg'; import Menu2 from '../components/menu2'; import supportedLanguages from '../data/status-supported-languages'; import urlRegex from '../data/url-regex'; import { api } from '../utils/api'; import db from '../utils/db'; import emojifyText from '../utils/emojify-text'; import i18nDuration from '../utils/i18n-duration'; import isRTL from '../utils/is-rtl'; import localeMatch from '../utils/locale-match'; import localeCode2Text from '../utils/localeCode2Text'; import mem from '../utils/mem'; import openCompose from '../utils/open-compose'; import pmem from '../utils/pmem'; import prettyBytes from '../utils/pretty-bytes'; import { fetchRelationships } from '../utils/relationships'; import shortenNumber from '../utils/shorten-number'; import showToast from '../utils/show-toast'; import states, { saveStatus } from '../utils/states'; import store from '../utils/store'; import { getCurrentAccount, getCurrentAccountNS, getCurrentInstance, getCurrentInstanceConfiguration, } from '../utils/store-utils'; import supports from '../utils/supports'; import useCloseWatcher from '../utils/useCloseWatcher'; import useInterval from '../utils/useInterval'; import visibilityIconsMap from '../utils/visibility-icons-map'; import AccountBlock from './account-block'; // import Avatar from './avatar'; import Icon from './icon'; import Loader from './loader'; import Modal from './modal'; import Status from './status'; const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL, PHANPY_GIPHY_API_KEY: GIPHY_API_KEY, } = import.meta.env; 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 = { 300: i18nDuration(5, 'minute'), 1_800: i18nDuration(30, 'minute'), 3_600: i18nDuration(1, 'hour'), 21_600: i18nDuration(6, 'hour'), 86_400: i18nDuration(1, 'day'), 259_200: i18nDuration(3, 'day'), 604_800: i18nDuration(1, 'week'), }; const expirySeconds = Object.keys(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'; // Set IntersectionObserver on menu, reposition it because text-expander doesn't handle it const windowMargin = 16; const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const { left, width } = entry.boundingClientRect; const { innerWidth } = window; if (left + width > innerWidth) { const insetInlineStart = isRTL() ? 'right' : 'left'; menu.style[insetInlineStart] = innerWidth - width - windowMargin + 'px'; } } }); }); observer.observe(menu); const DEFAULT_LANG = localeMatch( [new Intl.DateTimeFormat().resolvedOptions().locale, ...navigator.languages], supportedLanguages.map((l) => l[0]), 'en', ); // 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'); } // https://github.com/mastodon/mastodon/blob/c03bd2a238741a012aa4b98dc4902d6cf948ab63/app/models/account.rb#L69 const USERNAME_RE = /[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?/i; const MENTION_RE = new RegExp( `(^|[^=\\/\\w])(@${USERNAME_RE.source}(?:@[\\p{L}\\w.-]+[\\w]+)?)`, 'uig', ); // AI-generated, all other regexes are too complicated const HASHTAG_RE = new RegExp( `(^|[^=\\/\\w])(#[a-z0-9_]+([a-z0-9_.]+[a-z0-9_]+)?)(?![\\/\\w])`, 'ig', ); // https://github.com/mastodon/mastodon/blob/23e32a4b3031d1da8b911e0145d61b4dd47c4f96/app/models/custom_emoji.rb#L31 const SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}'; const SCAN_RE = new RegExp( `(^|[^=\\/\\w])(:${SHORTCODE_RE_FRAGMENT}:)(?=[^A-Za-z0-9_:]|$)`, 'g', ); const segmenter = new Intl.Segmenter(); function escapeHTML(text) { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function highlightText(text, { maxCharacters = Infinity }) { // Exceeded characters limit const { composerCharacterCount } = states; if (composerCharacterCount > maxCharacters) { // Highlight exceeded characters let withinLimitHTML = '', exceedLimitHTML = ''; const htmlSegments = segmenter.segment(text); for (const { segment, index } of htmlSegments) { if (index < maxCharacters) { withinLimitHTML += segment; } else { exceedLimitHTML += segment; } } if (exceedLimitHTML) { exceedLimitHTML = '' + escapeHTML(exceedLimitHTML) + ''; } return escapeHTML(withinLimitHTML) + exceedLimitHTML; } return escapeHTML(text) .replace(urlRegexObj, '$2$3') // URLs .replace(MENTION_RE, '$1$2') // Mentions .replace(HASHTAG_RE, '$1$2') // Hashtags .replace( SCAN_RE, '$1$2', ); // Emoji shortcodes } // const rtf = new Intl.RelativeTimeFormat(); const RTF = mem((locale) => new Intl.RelativeTimeFormat(locale || undefined)); const CUSTOM_EMOJIS_COUNT = 100; function Compose({ onClose, replyToStatus, editStatus, draftStatus, standalone, hasOpener, }) { const { i18n } = useLingui(); const rtf = RTF(i18n.locale); console.warn('RENDER COMPOSER'); const { masto, instance } = api(); const [uiState, setUIState] = useState('default'); const UID = useRef(draftStatus?.uid || uid()); console.log('Compose UID', UID.current); const currentAccount = getCurrentAccount(); const currentAccountInfo = currentAccount.info; const configuration = getCurrentInstanceConfiguration(); console.log('⚙️ Configuration', configuration); 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 prevLanguage = useRef(language); const [mediaAttachments, setMediaAttachments] = useState([]); const [poll, setPoll] = useState(null); const prefs = store.account.get('preferences') || {}; const oninputTextarea = () => { if (!textareaRef.current) return; textareaRef.current.dispatchEvent(new Event('input')); }; const focusTextarea = () => { setTimeout(() => { if (!textareaRef.current) return; // status starts with newline, focus on first position if (draftStatus?.status?.startsWith?.('\n')) { textareaRef.current.selectionStart = 0; textareaRef.current.selectionEnd = 0; } console.debug('FOCUS textarea'); 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 === 'public' && prefs['posting:default:visibility'] ? prefs['posting:default:visibility'].toLowerCase() : visibility, ); setLanguage( language || prefs['posting:default:language']?.toLowerCase() || DEFAULT_LANG, ); setSensitive(sensitive && !!spoilerText); } 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 .$select(editStatus.id) .source.fetch(); 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 || prefs['posting:default:language']?.toLowerCase() || DEFAULT_LANG, ); setSensitive(sensitive); if (composablePoll) setPoll(composablePoll); setMediaAttachments(mediaAttachments); setUIState('default'); } catch (e) { console.error(e); alert(e?.reason || e); setUIState('error'); } })(); } else { focusTextarea(); console.log('Apply prefs', prefs); if (prefs['posting:default:visibility']) { setVisibility(prefs['posting:default:visibility'].toLowerCase()); } if (prefs['posting:default:language']) { setLanguage(prefs['posting:default:language'].toLowerCase()); } if (prefs['posting:default:sensitive']) { setSensitive(!!prefs['posting:default: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(); if (spoilerText) spoilerTextRef.current.value = spoilerText; if (visibility) setVisibility(visibility); setLanguage( language || prefs['posting:default:language']?.toLowerCase() || DEFAULT_LANG, ); if (sensitive !== null) setSensitive(sensitive); if (composablePoll) setPoll(composablePoll); if (mediaAttachments) setMediaAttachments(mediaAttachments); } }, [draftStatus, editStatus, replyToStatus]); const formRef = useRef(); const beforeUnloadCopy = t`You have unsaved changes. 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 hasValue = (value || '') .trim() .replace(/^\p{White_Space}+|\p{White_Space}+$/gu, ''); const hasMediaAttachments = mediaAttachments.length > 0; if (!hasValue && !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 === currentAccountInfo.id; 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 getCharCount = () => { const { value } = textareaRef.current; const { value: spoilerText } = spoilerTextRef.current; return stringLength(countableText(value)) + stringLength(spoilerText); }; const updateCharCount = () => { const count = getCharCount(); states.composerCharacterCount = count; }; useEffect(updateCharCount, []); const supportsCloseWatcher = window.CloseWatcher; const escDownRef = useRef(false); useHotkeys( 'esc', () => { escDownRef.current = true; // This won't be true if this event is already handled and not propagated 🤞 }, { enabled: !supportsCloseWatcher, enableOnFormTags: true, }, ); useHotkeys( 'esc', () => { if (!standalone && escDownRef.current && confirmClose()) { onClose(); } escDownRef.current = false; }, { enabled: !supportsCloseWatcher, enableOnFormTags: true, // Use keyup because Esc keydown will close the confirm dialog on Safari keyup: true, ignoreEventWhen: (e) => { const modals = document.querySelectorAll('#modal-container > *'); const hasModal = !!modals; const hasOnlyComposer = modals.length === 1 && modals[0].querySelector('#compose-container'); return hasModal && !hasOnlyComposer; }, }, ); useCloseWatcher(() => { if (!standalone && confirmClose()) { onClose(); } }, [standalone, confirmClose, onClose]); const prevBackgroundDraft = useRef({}); const draftKey = () => { const ns = getCurrentAccountNS(); return `${ns}#${UID.current}`; }; const saveUnsavedDraft = () => { // Not enabling this for editing status // I don't think this warrant a draft mode for a status that's already posted // Maybe it could be a big edit change but it should be rare if (editStatus) return; if (states.composerState.minimized) return; const key = draftKey(); const backgroundDraft = { key, replyTo: replyToStatus ? { /* Smaller payload of replyToStatus. Reasons: - No point storing whole thing - Could have media attachments - Could be deleted/edited later */ id: replyToStatus.id, account: { id: replyToStatus.account.id, username: replyToStatus.account.username, acct: replyToStatus.account.acct, }, } : null, draftStatus: { uid: UID.current, status: textareaRef.current.value, spoilerText: spoilerTextRef.current.value, visibility, language, sensitive, poll, mediaAttachments, }, }; if ( !deepEqual(backgroundDraft, prevBackgroundDraft.current) && !canClose() ) { console.debug('not equal', backgroundDraft, prevBackgroundDraft.current); db.drafts .set(key, { ...backgroundDraft, state: 'unsaved', updatedAt: Date.now(), }) .then(() => { console.debug('DRAFT saved', key, backgroundDraft); }) .catch((e) => { console.error('DRAFT failed', key, e); }); prevBackgroundDraft.current = structuredClone(backgroundDraft); } }; useInterval(saveUnsavedDraft, 5000); // background save every 5s useEffect(() => { saveUnsavedDraft(); // If unmounted, means user discarded the draft // Also means pop-out 🙈, but it's okay because the pop-out will persist the ID and re-create the draft return () => { db.drafts.del(draftKey()); }; }, []); useEffect(() => { const handleItems = (e) => { const { items } = e.clipboardData || e.dataTransfer; const files = []; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.kind === 'file') { const file = item.getAsFile(); if (file && supportedMimeTypes.includes(file.type)) { files.push(file); } } } if (files.length > 0 && mediaAttachments.length >= maxMediaAttachments) { alert( plural(maxMediaAttachments, { one: 'You can only attach up to 1 file.', other: 'You can only attach up to # files.', }), ); return; } console.log({ files }); if (files.length > 0) { e.preventDefault(); e.stopPropagation(); // Auto-cut-off files to avoid exceeding maxMediaAttachments const max = maxMediaAttachments - mediaAttachments.length; const allowedFiles = files.slice(0, max); if (allowedFiles.length <= 0) { alert( plural(maxMediaAttachments, { one: 'You can only attach up to 1 file.', other: 'You can only attach up to # files.', }), ); return; } const mediaFiles = allowedFiles.map((file) => ({ file, type: file.type, size: file.size, url: URL.createObjectURL(file), id: null, description: null, })); setMediaAttachments([...mediaAttachments, ...mediaFiles]); } }; window.addEventListener('paste', handleItems); const handleDragover = (e) => { // Prevent default if there's files if (e.dataTransfer.items.length > 0) { e.preventDefault(); e.stopPropagation(); } }; window.addEventListener('dragover', handleDragover); window.addEventListener('drop', handleItems); return () => { window.removeEventListener('paste', handleItems); window.removeEventListener('dragover', handleDragover); window.removeEventListener('drop', handleItems); }; }, [mediaAttachments]); const [showMentionPicker, setShowMentionPicker] = useState(false); const [showEmoji2Picker, setShowEmoji2Picker] = useState(false); const [showGIFPicker, setShowGIFPicker] = useState(false); const [autoDetectedLanguages, setAutoDetectedLanguages] = useState(null); const [topSupportedLanguages, restSupportedLanguages] = useMemo(() => { const topLanguages = []; const restLanguages = []; const { contentTranslationHideLanguages = [] } = states.settings; supportedLanguages.forEach((l) => { const [code] = l; if ( code === language || code === prevLanguage.current || code === DEFAULT_LANG || contentTranslationHideLanguages.includes(code) || (autoDetectedLanguages?.length && autoDetectedLanguages.includes(code)) ) { topLanguages.push(l); } else { restLanguages.push(l); } }); topLanguages.sort(([codeA, commonA], [codeB, commonB]) => { if (codeA === language) return -1; if (codeB === language) return 1; return commonA.localeCompare(commonB); }); restLanguages.sort(([codeA, commonA], [codeB, commonB]) => commonA.localeCompare(commonB), ); return [topLanguages, restLanguages]; }, [language, autoDetectedLanguages]); const replyToStatusMonthsAgo = useMemo( () => !!replyToStatus?.createdAt && Math.floor( (Date.now() - new Date(replyToStatus.createdAt)) / (1000 * 60 * 60 * 24 * 30), ), [replyToStatus], ); const onMinimize = () => { saveUnsavedDraft(); states.composerState.minimized = true; }; return (
{currentAccountInfo?.avatarStatic && ( // )} {!standalone ? ( {' '} ) : ( hasOpener && ( ) )}
{!!replyToStatus && (
{replyToStatusMonthsAgo > 0 ? ( Replying to @ {replyToStatus.account.acct || replyToStatus.account.username} ’s post ( {rtf.format(-replyToStatusMonthsAgo, 'month')} ) ) : ( Replying to @ {replyToStatus.account.acct || replyToStatus.account.username} ’s post )}
)} {!!editStatus && (
Editing source post
)}
{ 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(t`Poll must have at least 2 options`); return; } if (poll.options.some((option) => option === '')) { alert(t`Some poll choices are empty`); return; } } // TODO: check for URLs and use `charactersReservedPerUrl` to calculate max characters if (mediaAttachments.length > 0) { // If there are media attachments, check if they have no descriptions const hasNoDescriptions = mediaAttachments.some( (media) => !media.description?.trim?.(), ); if (hasNoDescriptions) { const yes = confirm( t`Some media have no descriptions. Continue?`, ); if (!yes) return; } } // Post-cleanup spoilerText = (sensitive && spoilerText) || undefined; status = status === '' ? undefined : status; // states.composerState.minimized = true; states.composerState.publishing = true; 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.media.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; }) ) { states.composerState.publishing = false; states.composerState.publishingError = true; setUIState('error'); // Alert all the reasons results.forEach((result) => { if (result.status === 'rejected') { console.error(result); alert(result.reason || t`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 // TODO: Note above is no longer true in Masto.js v6. Revisit this. */ 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 && supports('@mastodon/edit-media-attributes')) { params.media_attributes = mediaAttachments.map( (attachment) => { return { id: attachment.id, description: attachment.description, // focus // thumbnail }; }, ); } else 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 .$select(editStatus.id) .update(params); saveStatus(newStatus, instance, { skipThreading: true, }); } else { try { newStatus = await masto.v1.statuses.create(params, { requestInit: { headers: { 'Idempotency-Key': UID.current, }, }, }); } catch (_) { // If idempotency key fails, try again without it newStatus = await masto.v1.statuses.create(params); } } states.composerState.minimized = false; states.composerState.publishing = false; setUIState('default'); // Close onClose({ // type: post, reply, edit type: editStatus ? 'edit' : replyToStatus ? 'reply' : 'post', newStatus, instance, }); } catch (e) { states.composerState.publishing = false; states.composerState.publishingError = true; console.error(e); alert(e?.reason || e); setUIState('error'); } })(); }} >
{ updateCharCount(); }} /> {' '} {' '}
)} ); const toastRef = useRef(null); useEffect(() => { return () => { toastRef.current?.hideToast?.(); }; }, []); const maxErrorToast = useRef(null); const maxErrorText = (err) => { const { type, details } = err; switch (type) { case 'imageSizeLimit': { const { imageSize, imageSizeLimit } = details; return t`File size too large. Uploading might encounter issues. Try reduce the file size from ${prettyBytes( imageSize, )} to ${prettyBytes(imageSizeLimit)} or lower.`; } case 'imageMatrixLimit': { const { imageMatrix, imageMatrixLimit, width, height } = details; const { newWidth, newHeight } = scaleDimension( imageMatrix, imageMatrixLimit, width, height, ); return t`Dimension too large. Uploading might encounter issues. Try reduce dimension from ${i18n.number( width, )}×${i18n.number(height)}px to ${i18n.number(newWidth)}×${i18n.number( newHeight, )}px.`; } case 'videoSizeLimit': { const { videoSize, videoSizeLimit } = details; return t`File size too large. Uploading might encounter issues. Try reduce the file size from ${prettyBytes( videoSize, )} to ${prettyBytes(videoSizeLimit)} or lower.`; } case 'videoMatrixLimit': { const { videoMatrix, videoMatrixLimit, width, height } = details; const { newWidth, newHeight } = scaleDimension( videoMatrix, videoMatrixLimit, width, height, ); return t`Dimension too large. Uploading might encounter issues. Try reduce dimension from ${i18n.number( width, )}×${i18n.number(height)}px to ${i18n.number(newWidth)}×${i18n.number( newHeight, )}px.`; } case 'videoFrameRateLimit': { // Not possible to detect this on client-side for now return t`Frame rate too high. Uploading might encounter issues.`; } } }; return ( <>
{ setShowModal(true); }} > {suffixType === 'image' ? ( { if (!checkMaxError) return; const { naturalWidth, naturalHeight } = e.target; setImageMatrix({ matrix: naturalWidth * naturalHeight, width: naturalWidth, height: naturalHeight, }); }} /> ) : suffixType === 'video' || suffixType === 'gifv' ? (
{descTextarea}
{!!maxError && ( )}
{showModal && ( { setShowModal(false); }} >

{ { image: t`Edit image description`, video: t`Edit video description`, audio: t`Edit audio description`, }[suffixType] }

{suffixType === 'image' ? ( ) : suffixType === 'video' || suffixType === 'gifv' ? (
{descTextarea}
{suffixType === 'image' && /^(png|jpe?g|gif|webp)$/i.test(subtype) && !!states.settings.mediaAltGenerator && !!IMG_ALT_API_URL && ( } > { setUIState('loading'); toastRef.current = showToast({ text: t`Generating description. Please wait…`, duration: -1, }); // POST with multipart (async function () { try { const body = new FormData(); body.append('image', file); const response = await fetch(IMG_ALT_API_URL, { method: 'POST', body, }).then((r) => r.json()); if (response.error) { throw new Error(response.error); } setDescription(response.description); } catch (e) { console.error(e); showToast( e.message ? t`Failed to generate description: ${e.message}` : t`Failed to generate description`, ); } finally { setUIState('default'); toastRef.current?.hideToast?.(); } })(); }} > {lang && lang !== 'en' ? ( Generate description…
(English)
) : ( Generate description… )}
{!!lang && lang !== 'en' && ( { setUIState('loading'); toastRef.current = showToast({ text: t`Generating description. Please wait…`, duration: -1, }); // POST with multipart (async function () { try { const body = new FormData(); body.append('image', file); const params = `?lang=${lang}`; const response = await fetch( IMG_ALT_API_URL + params, { method: 'POST', body, }, ).then((r) => r.json()); if (response.error) { throw new Error(response.error); } setDescription(response.description); } catch (e) { console.error(e); showToast( t`Failed to generate description${ e?.message ? `: ${e.message}` : '' }`, ); } finally { setUIState('default'); toastRef.current?.hideToast?.(); } })(); }} > Generate description…
({localeCode2Text(lang)}){' '} — experimental
)}
)}
)} ); } function Poll({ lang, poll, disabled, onInput = () => {}, maxOptions, maxExpiration, minExpiration, maxCharactersPerOption, }) { const { _ } = useLingui(); 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) + ';'; }); } function removeNullUndefined(obj) { for (let key in obj) { if (obj[key] === null || obj[key] === undefined) { delete obj[key]; } } return obj; } function MentionModal({ onClose = () => {}, onSelect = () => {}, defaultSearchTerm, }) { const { masto } = api(); const [uiState, setUIState] = useState('default'); const [accounts, setAccounts] = useState([]); const [relationshipsMap, setRelationshipsMap] = useState({}); const [selectedIndex, setSelectedIndex] = useState(0); const loadRelationships = async (accounts) => { if (!accounts?.length) return; const relationships = await fetchRelationships(accounts, relationshipsMap); if (relationships) { setRelationshipsMap({ ...relationshipsMap, ...relationships, }); } }; const loadAccounts = (term) => { if (!term) return; setUIState('loading'); (async () => { try { const accounts = await masto.v1.accounts.search.list({ q: term, limit: 40, resolve: false, }); setAccounts(accounts); loadRelationships(accounts); setUIState('default'); } catch (e) { setUIState('error'); console.error(e); } })(); }; const debouncedLoadAccounts = useDebouncedCallback(loadAccounts, 1000); useEffect(() => { loadAccounts(); }, [loadAccounts]); const inputRef = useRef(); useEffect(() => { if (inputRef.current) { inputRef.current.focus(); // Put cursor at the end if (inputRef.current.value) { inputRef.current.selectionStart = inputRef.current.value.length; inputRef.current.selectionEnd = inputRef.current.value.length; } } }, []); useEffect(() => { if (defaultSearchTerm) { loadAccounts(defaultSearchTerm); } }, [defaultSearchTerm]); const selectAccount = (account) => { const socialAddress = account.acct; onSelect(socialAddress); onClose(); }; useHotkeys( 'enter', () => { const selectedAccount = accounts[selectedIndex]; if (selectedAccount) { selectAccount(selectedAccount); } }, { preventDefault: true, enableOnFormTags: ['input'], }, ); const listRef = useRef(); useHotkeys( 'down', () => { if (selectedIndex < accounts.length - 1) { setSelectedIndex(selectedIndex + 1); } else { setSelectedIndex(0); } setTimeout(() => { const selectedItem = listRef.current.querySelector('.selected'); if (selectedItem) { selectedItem.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center', }); } }, 1); }, { preventDefault: true, enableOnFormTags: ['input'], }, ); useHotkeys( 'up', () => { if (selectedIndex > 0) { setSelectedIndex(selectedIndex - 1); } else { setSelectedIndex(accounts.length - 1); } setTimeout(() => { const selectedItem = listRef.current.querySelector('.selected'); if (selectedItem) { selectedItem.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center', }); } }, 1); }, { preventDefault: true, enableOnFormTags: ['input'], }, ); return (
{!!onClose && ( )}
{ e.preventDefault(); debouncedLoadAccounts.flush?.(); // const searchTerm = inputRef.current.value; // debouncedLoadAccounts(searchTerm); }} > { const { value } = e.target; debouncedLoadAccounts(value); }} autocomplete="off" autocorrect="off" autocapitalize="off" spellCheck="false" dir="auto" defaultValue={defaultSearchTerm || ''} />
{accounts?.length > 0 ? (
    {accounts.map((account, i) => { const relationship = relationshipsMap[account.id]; return (
  • ); })}
) : uiState === 'loading' ? (
) : uiState === 'error' ? (

Error loading accounts

) : null}
); } function CustomEmojisModal({ masto, instance, onClose = () => {}, onSelect = () => {}, defaultSearchTerm, }) { const [uiState, setUIState] = useState('default'); const customEmojisList = useRef([]); const [customEmojis, setCustomEmojis] = useState([]); const recentlyUsedCustomEmojis = useMemo( () => store.account.get('recentlyUsedCustomEmojis') || [], ); const searcherRef = useRef(); useEffect(() => { setUIState('loading'); (async () => { try { const [emojis, searcher] = await getCustomEmojis(instance, masto); console.log('emojis', emojis); searcherRef.current = searcher; setCustomEmojis(emojis); setUIState('default'); } catch (e) { setUIState('error'); console.error(e); } })(); }, []); const customEmojisCatList = useMemo(() => { // Group emojis by category const emojisCat = { '--recent--': recentlyUsedCustomEmojis.filter((emoji) => customEmojis.find((e) => e.shortcode === emoji.shortcode), ), }; const othersCat = []; customEmojis.forEach((emoji) => { customEmojisList.current?.push?.(emoji); if (!emoji.category) { othersCat.push(emoji); return; } if (!emojisCat[emoji.category]) { emojisCat[emoji.category] = []; } emojisCat[emoji.category].push(emoji); }); if (othersCat.length) { emojisCat['--others--'] = othersCat; } return emojisCat; }, [customEmojis]); const scrollableRef = useRef(); const [matches, setMatches] = useState(null); const onFind = useCallback( (e) => { const { value } = e.target; if (value) { const results = searcherRef.current?.search(value, { limit: CUSTOM_EMOJIS_COUNT, }); setMatches(results.map((r) => r.item)); scrollableRef.current?.scrollTo?.(0, 0); } else { setMatches(null); } }, [customEmojis], ); useEffect(() => { if (defaultSearchTerm && customEmojis?.length) { onFind({ target: { value: defaultSearchTerm } }); } }, [defaultSearchTerm, onFind, customEmojis]); const onSelectEmoji = useCallback( (emoji) => { onSelect?.(emoji); onClose?.(); queueMicrotask(() => { let recentlyUsedCustomEmojis = store.account.get('recentlyUsedCustomEmojis') || []; const recentlyUsedEmojiIndex = recentlyUsedCustomEmojis.findIndex( (e) => e.shortcode === emoji.shortcode, ); if (recentlyUsedEmojiIndex !== -1) { // Move emoji to index 0 recentlyUsedCustomEmojis.splice(recentlyUsedEmojiIndex, 1); recentlyUsedCustomEmojis.unshift(emoji); } else { recentlyUsedCustomEmojis.unshift(emoji); // Remove unavailable ones recentlyUsedCustomEmojis = recentlyUsedCustomEmojis.filter((e) => customEmojisList.current?.find?.( (emoji) => emoji.shortcode === e.shortcode, ), ); // Limit to 10 recentlyUsedCustomEmojis = recentlyUsedCustomEmojis.slice(0, 10); } // Store back store.account.set('recentlyUsedCustomEmojis', recentlyUsedCustomEmojis); }); }, [onSelect], ); const inputRef = useRef(); useEffect(() => { if (inputRef.current) { inputRef.current.focus(); // Put cursor at the end if (inputRef.current.value) { inputRef.current.selectionStart = inputRef.current.value.length; inputRef.current.selectionEnd = inputRef.current.value.length; } } }, []); return (
{!!onClose && ( )}
Custom emojis {' '} {uiState === 'loading' ? ( ) : ( • {instance} )}
{ e.preventDefault(); const emoji = matches[0]; if (emoji) { onSelectEmoji(`:${emoji.shortcode}:`); } }} >
{matches !== null ? (
    {matches.map((emoji) => (
  • { onSelectEmoji(`:${emoji.shortcode}:`); }} showCode />
  • ))}
) : (
{uiState === 'error' && (

Error loading custom emojis

)} {uiState === 'default' && Object.entries(customEmojisCatList).map( ([category, emojis]) => !!emojis?.length && ( <>
{{ '--recent--': t`Recently used`, '--others--': t`Others`, }[category] || category}
), )}
)}
); } const CustomEmojisList = memo(({ emojis, onSelect }) => { const { i18n } = useLingui(); const [max, setMax] = useState(CUSTOM_EMOJIS_COUNT); const showMore = emojis.length > max; return (
{emojis.slice(0, max).map((emoji) => ( { onSelect(`:${emoji.shortcode}:`); }} /> ))} {showMore && ( )}
); }); const CustomEmojiButton = memo(({ emoji, onClick, showCode }) => { const addEdges = (e) => { // Add edge-left or edge-right class based on self position relative to scrollable parent // If near left edge, add edge-left, if near right edge, add edge-right const buffer = 88; const parent = e.currentTarget.closest('main'); if (parent) { const rect = parent.getBoundingClientRect(); const selfRect = e.currentTarget.getBoundingClientRect(); const targetClassList = e.currentTarget.classList; if (selfRect.left < rect.left + buffer) { targetClassList.add('edge-left'); targetClassList.remove('edge-right'); } else if (selfRect.right > rect.right - buffer) { targetClassList.add('edge-right'); targetClassList.remove('edge-left'); } else { targetClassList.remove('edge-left', 'edge-right'); } } }; return ( ); }); const GIFS_PER_PAGE = 20; function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) { const { i18n } = useLingui(); const [uiState, setUIState] = useState('default'); const [results, setResults] = useState([]); const formRef = useRef(null); const qRef = useRef(null); const currentOffset = useRef(0); const scrollableRef = useRef(null); function fetchGIFs({ offset }) { console.log('fetchGIFs', { offset }); if (!qRef.current?.value) return; setUIState('loading'); scrollableRef.current?.scrollTo?.({ top: 0, left: 0, behavior: 'smooth', }); (async () => { try { const query = { api_key: GIPHY_API_KEY, q: qRef.current.value, rating: 'g', limit: GIFS_PER_PAGE, bundle: 'messaging_non_clips', offset, lang: i18n.locale || 'en', }; const response = await fetch( 'https://api.giphy.com/v1/gifs/search?' + new URLSearchParams(query), { referrerPolicy: 'no-referrer', }, ).then((r) => r.json()); currentOffset.current = response.pagination?.offset || 0; setResults(response); setUIState('results'); } catch (e) { setUIState('error'); console.error(e); } })(); } useEffect(() => { qRef.current?.focus(); }, []); const debouncedOnInput = useDebouncedCallback(() => { fetchGIFs({ offset: 0 }); }, 1000); return (
{!!onClose && ( )}
{ e.preventDefault(); fetchGIFs({ offset: 0 }); }} >
{uiState === 'default' && (

Type to search GIFs

)} {uiState === 'loading' && !results?.data?.length && (
)} {results?.data?.length > 0 ? ( <>
    {results.data.map((gif) => { const { id, images, title, alt_text } = gif; const { fixed_height_small, fixed_height_downsampled, fixed_height, original, } = images; const theImage = fixed_height_small?.url ? fixed_height_small : fixed_height_downsampled?.url ? fixed_height_downsampled : fixed_height; let { url, webp, width, height } = theImage; if (+height > 100) { width = (width / height) * 100; height = 100; } const urlObj = URL.parse(url); const strippedURL = urlObj.origin + urlObj.pathname; let strippedWebP; if (webp) { const webpObj = URL.parse(webp); strippedWebP = webpObj.origin + webpObj.pathname; } return (
  • ); })}

{results.pagination?.offset > 0 && ( )} {results.pagination?.offset + results.pagination?.count < results.pagination?.total_count && ( )}

) : ( uiState === 'results' && (

No results

) )} {uiState === 'error' && (

Error loading GIFs

)}
); } export default Compose;