this repo has no description
at main 1855 lines 65 kB view raw
1import './compose.css'; 2 3import { msg, plural } from '@lingui/core/macro'; 4import { Trans, useLingui } from '@lingui/react/macro'; 5import { MenuDivider, MenuItem } from '@szhsin/react-menu'; 6import { deepEqual } from 'fast-equals'; 7import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; 8import { useHotkeys } from 'react-hotkeys-hook'; 9import stringLength from 'string-length'; 10import { uid } from 'uid/single'; 11import useResizeObserver from 'use-resize-observer'; 12import { useSnapshot } from 'valtio'; 13 14import supportedLanguages from '../data/status-supported-languages'; 15import { api, getPreferences } from '../utils/api'; 16import db from '../utils/db'; 17import localeMatch from '../utils/locale-match'; 18import localeCode2Text from '../utils/localeCode2Text'; 19import mem from '../utils/mem'; 20import openCompose from '../utils/open-compose'; 21import RTF from '../utils/relative-time-format'; 22import showToast from '../utils/show-toast'; 23import states, { saveStatus } from '../utils/states'; 24import store from '../utils/store'; 25import { 26 getCurrentAccount, 27 getCurrentAccountNS, 28 getCurrentInstanceConfiguration, 29} from '../utils/store-utils'; 30import supports from '../utils/supports'; 31import urlRegexObj from '../utils/url-regex'; 32import useCloseWatcher from '../utils/useCloseWatcher'; 33import useInterval from '../utils/useInterval'; 34import visibilityIconsMap from '../utils/visibility-icons-map'; 35import visibilityText from '../utils/visibility-text'; 36 37import AccountBlock from './account-block'; 38// import Avatar from './avatar'; 39import CameraCaptureInput, { 40 supportsCameraCapture, 41} from './camera-capture-input'; 42import CharCountMeter from './char-count-meter'; 43import ComposePoll, { expiryOptions } from './compose-poll'; 44import Textarea from './compose-textarea'; 45import CustomEmojisModal from './custom-emojis-modal'; 46import FilePickerInput from './file-picker-input'; 47import GIFPickerModal from './gif-picker-modal'; 48import Icon from './icon'; 49import Loader from './loader'; 50import MediaAttachment from './media-attachment'; 51import MentionModal from './mention-modal'; 52import Menu2 from './menu2'; 53import Modal from './modal'; 54import ScheduledAtField, { 55 getLocalTimezoneName, 56 MIN_SCHEDULED_AT, 57} from './ScheduledAtField'; 58import Status from './status'; 59import TextExpander from './text-expander'; 60 61const supportedLanguagesMap = supportedLanguages.reduce((acc, l) => { 62 const [code, common, native] = l; 63 acc[code] = { 64 common, 65 native, 66 }; 67 return acc; 68}, {}); 69 70/* NOTES: 71 - Max character limit includes BOTH status text and Content Warning text 72*/ 73 74const expirySeconds = Object.keys(expiryOptions); 75const oneDay = 24 * 60 * 60; 76 77const expiresInFromExpiresAt = (expiresAt) => { 78 if (!expiresAt) return oneDay; 79 const delta = (Date.parse(expiresAt) - Date.now()) / 1000; 80 return expirySeconds.find((s) => s >= delta) || oneDay; 81}; 82 83const DEFAULT_LANG = localeMatch( 84 [new Intl.DateTimeFormat().resolvedOptions().locale, ...navigator.languages], 85 supportedLanguages.map((l) => l[0]), 86 'en', 87); 88 89// https://github.com/mastodon/mastodon/blob/c4a429ed47e85a6bbf0d470a41cc2f64cf120c19/app/javascript/mastodon/features/compose/util/counter.js 90const usernameRegex = /(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/gi; 91const urlPlaceholder = '$2xxxxxxxxxxxxxxxxxxxxxxx'; 92function countableText(inputText) { 93 return inputText 94 .replace(urlRegexObj, urlPlaceholder) 95 .replace(usernameRegex, '$1@$3'); 96} 97 98// const rtf = new Intl.RelativeTimeFormat(); 99const LF = mem((locale) => new Intl.ListFormat(locale || undefined)); 100 101const ADD_LABELS = { 102 camera: msg`Take photo or video`, 103 media: msg`Add media`, 104 customEmoji: msg`Add custom emoji`, 105 gif: msg`Add GIF`, 106 poll: msg`Add poll`, 107 sensitive: msg`Add content warning`, 108 scheduledPost: msg`Schedule post`, 109}; 110 111const DEFAULT_SCHEDULED_AT = Math.max(10 * 60 * 1000, MIN_SCHEDULED_AT); // 10 mins 112 113function Compose({ 114 onClose, 115 replyToStatus, 116 editStatus, 117 draftStatus, 118 standalone, 119 hasOpener, 120}) { 121 const { i18n, _, t } = useLingui(); 122 const rtf = RTF(i18n.locale); 123 const lf = LF(i18n.locale); 124 125 console.warn('RENDER COMPOSER'); 126 const { masto, instance } = api(); 127 const [uiState, setUIState] = useState('default'); 128 const UID = useRef(draftStatus?.uid || uid()); 129 console.log('Compose UID', UID.current); 130 131 const currentAccount = getCurrentAccount(); 132 const currentAccountInfo = currentAccount.info; 133 134 const configuration = getCurrentInstanceConfiguration(); 135 console.log('⚙️ Configuration', configuration); 136 137 const { 138 statuses: { 139 maxCharacters, 140 maxMediaAttachments, // Beware: it can be undefined! 141 charactersReservedPerUrl, 142 } = {}, 143 mediaAttachments: { 144 supportedMimeTypes, 145 imageSizeLimit, 146 imageMatrixLimit, 147 videoSizeLimit, 148 videoMatrixLimit, 149 videoFrameRateLimit, 150 descriptionLimit, 151 } = {}, 152 polls: { 153 maxOptions, 154 maxCharactersPerOption, 155 maxExpiration, 156 minExpiration, 157 } = {}, 158 } = configuration || {}; 159 const supportedImagesVideosTypes = supportedMimeTypes?.filter((mimeType) => 160 /^(image|video)/i.test(mimeType), 161 ); 162 163 const textareaRef = useRef(); 164 const spoilerTextRef = useRef(); 165 166 const [visibility, setVisibility] = useState('public'); 167 const [sensitive, setSensitive] = useState(false); 168 const [sensitiveMedia, setSensitiveMedia] = useState(false); 169 const [language, setLanguage] = useState( 170 store.session.get('currentLanguage') || DEFAULT_LANG, 171 ); 172 const prevLanguage = useRef(language); 173 const [mediaAttachments, setMediaAttachments] = useState([]); 174 const [poll, setPoll] = useState(null); 175 const [scheduledAt, setScheduledAt] = useState(null); 176 177 const prefs = getPreferences(); 178 179 const oninputTextarea = () => { 180 if (!textareaRef.current) return; 181 textareaRef.current.dispatchEvent(new Event('input')); 182 }; 183 const focusTextarea = () => { 184 setTimeout(() => { 185 if (!textareaRef.current) return; 186 // status starts with newline or space, focus on first position 187 if (/^\n|\s/.test(draftStatus?.status)) { 188 textareaRef.current.selectionStart = 0; 189 textareaRef.current.selectionEnd = 0; 190 } 191 console.debug('FOCUS textarea'); 192 textareaRef.current?.focus(); 193 }, 300); 194 }; 195 const insertTextAtCursor = ({ targetElement, text }) => { 196 if (!targetElement) return; 197 198 const { selectionStart, selectionEnd, value } = targetElement; 199 let textBeforeInsert = value.slice(0, selectionStart); 200 201 // Remove zero-width space from end of text 202 textBeforeInsert = textBeforeInsert.replace(/\u200B$/, ''); 203 204 const spaceBeforeInsert = textBeforeInsert 205 ? /[\s\t\n\r]$/.test(textBeforeInsert) 206 ? '' 207 : ' ' 208 : ''; 209 210 const textAfterInsert = value.slice(selectionEnd); 211 const spaceAfterInsert = /^[\s\t\n\r]/.test(textAfterInsert) ? '' : ' '; 212 213 const newText = 214 textBeforeInsert + 215 spaceBeforeInsert + 216 text + 217 spaceAfterInsert + 218 textAfterInsert; 219 220 targetElement.value = newText; 221 targetElement.selectionStart = targetElement.selectionEnd = 222 selectionEnd + text.length + spaceAfterInsert.length; 223 targetElement.focus(); 224 targetElement.dispatchEvent(new Event('input')); 225 }; 226 227 const lastFocusedFieldRef = useRef(null); 228 const lastFocusedEmojiFieldRef = useRef(null); 229 const focusLastFocusedField = () => { 230 setTimeout(() => { 231 if (!lastFocusedFieldRef.current) return; 232 lastFocusedFieldRef.current.focus(); 233 }, 0); 234 }; 235 const composeContainerRef = useRef(null); 236 useEffect(() => { 237 const handleFocus = (e) => { 238 // Toggle focused if in or out if any fields are focused 239 composeContainerRef.current.classList.toggle( 240 'focused', 241 e.type === 'focusin', 242 ); 243 244 const target = e.target; 245 if (target.hasAttribute('data-allow-custom-emoji')) { 246 lastFocusedEmojiFieldRef.current = target; 247 } 248 const isFormElement = ['INPUT', 'BUTTON', 'SELECT', 'TEXTAREA'].includes( 249 target.tagName, 250 ); 251 if (isFormElement) { 252 lastFocusedFieldRef.current = target; 253 } 254 }; 255 256 const composeContainer = composeContainerRef.current; 257 if (composeContainer) { 258 composeContainer.addEventListener('focusin', handleFocus); 259 composeContainer.addEventListener('focusout', handleFocus); 260 } 261 262 return () => { 263 if (composeContainer) { 264 composeContainer.removeEventListener('focusin', handleFocus); 265 composeContainer.removeEventListener('focusout', handleFocus); 266 } 267 }; 268 }, []); 269 270 useEffect(() => { 271 if (replyToStatus) { 272 const { spoilerText, visibility, language, sensitive } = replyToStatus; 273 if (spoilerText && spoilerTextRef.current) { 274 spoilerTextRef.current.value = spoilerText; 275 } 276 const mentions = new Set([ 277 replyToStatus.account.acct, 278 ...replyToStatus.mentions.map((m) => m.acct), 279 ]); 280 const allMentions = [...mentions].filter( 281 (m) => m !== currentAccountInfo.acct, 282 ); 283 if (allMentions.length > 0) { 284 textareaRef.current.value = `${allMentions 285 .map((m) => `@${m}`) 286 .join(' ')} `; 287 oninputTextarea(); 288 } 289 focusTextarea(); 290 setVisibility( 291 visibility === 'public' && prefs['posting:default:visibility'] 292 ? prefs['posting:default:visibility'].toLowerCase() 293 : visibility, 294 ); 295 setLanguage( 296 language || 297 prefs['posting:default:language']?.toLowerCase() || 298 DEFAULT_LANG, 299 ); 300 setSensitive(!!spoilerText); 301 } else if (editStatus) { 302 const { visibility, language, sensitive, poll, mediaAttachments } = 303 editStatus; 304 const composablePoll = !!poll?.options && { 305 ...poll, 306 options: poll.options.map((o) => o?.title || o), 307 expiresIn: poll?.expiresIn || expiresInFromExpiresAt(poll.expiresAt), 308 }; 309 setUIState('loading'); 310 (async () => { 311 try { 312 const statusSource = await masto.v1.statuses 313 .$select(editStatus.id) 314 .source.fetch(); 315 console.log({ statusSource }); 316 const { text, spoilerText } = statusSource; 317 textareaRef.current.value = text; 318 textareaRef.current.dataset.source = text; 319 oninputTextarea(); 320 focusTextarea(); 321 spoilerTextRef.current.value = spoilerText; 322 setVisibility(visibility); 323 setLanguage( 324 language || 325 prefs['posting:default:language']?.toLowerCase() || 326 DEFAULT_LANG, 327 ); 328 setSensitive(sensitive); 329 if (composablePoll) setPoll(composablePoll); 330 setMediaAttachments(mediaAttachments); 331 setUIState('default'); 332 } catch (e) { 333 console.error(e); 334 alert(e?.reason || e); 335 setUIState('error'); 336 } 337 })(); 338 } else { 339 focusTextarea(); 340 console.log('Apply prefs', prefs); 341 if (prefs['posting:default:visibility']) { 342 setVisibility(prefs['posting:default:visibility'].toLowerCase()); 343 } 344 if (prefs['posting:default:language']) { 345 setLanguage(prefs['posting:default:language'].toLowerCase()); 346 } 347 if (prefs['posting:default:sensitive']) { 348 setSensitive(!!prefs['posting:default:sensitive']); 349 } 350 } 351 if (draftStatus) { 352 const { 353 status, 354 spoilerText, 355 visibility, 356 language, 357 sensitive, 358 sensitiveMedia, 359 poll, 360 mediaAttachments, 361 scheduledAt, 362 } = draftStatus; 363 const composablePoll = !!poll?.options && { 364 ...poll, 365 options: poll.options.map((o) => o?.title || o), 366 expiresIn: poll?.expiresIn || expiresInFromExpiresAt(poll.expiresAt), 367 }; 368 textareaRef.current.value = status; 369 oninputTextarea(); 370 focusTextarea(); 371 if (spoilerText) spoilerTextRef.current.value = spoilerText; 372 if (visibility) setVisibility(visibility); 373 setLanguage( 374 language || 375 prefs['posting:default:language']?.toLowerCase() || 376 DEFAULT_LANG, 377 ); 378 if (sensitiveMedia !== null) setSensitiveMedia(sensitiveMedia); 379 if (sensitive !== null) setSensitive(sensitive); 380 if (composablePoll) setPoll(composablePoll); 381 if (mediaAttachments) setMediaAttachments(mediaAttachments); 382 if (scheduledAt) setScheduledAt(scheduledAt); 383 } 384 }, [draftStatus, editStatus, replyToStatus]); 385 386 // focus textarea when state.composerState.minimized turns false 387 const snapStates = useSnapshot(states); 388 useEffect(() => { 389 if (!snapStates.composerState.minimized) { 390 focusTextarea(); 391 } 392 }, [snapStates.composerState.minimized]); 393 394 const formRef = useRef(); 395 396 const beforeUnloadCopy = t`You have unsaved changes. Discard this post?`; 397 const canClose = () => { 398 const { value, dataset } = textareaRef.current; 399 400 // check if loading 401 if (uiState === 'loading') { 402 console.log('canClose', { uiState }); 403 return false; 404 } 405 406 // check for status and media attachments 407 const hasValue = (value || '') 408 .trim() 409 .replace(/^\p{White_Space}+|\p{White_Space}+$/gu, ''); 410 const hasMediaAttachments = mediaAttachments.length > 0; 411 if (!hasValue && !hasMediaAttachments) { 412 console.log('canClose', { value, mediaAttachments }); 413 return true; 414 } 415 416 // check if all media attachments have IDs 417 const hasIDMediaAttachments = 418 mediaAttachments.length > 0 && 419 mediaAttachments.every((media) => media.id); 420 if (hasIDMediaAttachments) { 421 console.log('canClose', { hasIDMediaAttachments }); 422 return true; 423 } 424 425 // check if status contains only "@acct", if replying 426 const isSelf = replyToStatus?.account.id === currentAccountInfo.id; 427 const hasOnlyAcct = 428 replyToStatus && value.trim() === `@${replyToStatus.account.acct}`; 429 // TODO: check for mentions, or maybe just generic "@username<space>", including multiple mentions like "@username1<space>@username2<space>" 430 if (!isSelf && hasOnlyAcct) { 431 console.log('canClose', { isSelf, hasOnlyAcct }); 432 return true; 433 } 434 435 // check if status is same with source 436 const sameWithSource = value === dataset?.source; 437 if (sameWithSource) { 438 console.log('canClose', { sameWithSource }); 439 return true; 440 } 441 442 console.log('canClose', { 443 value, 444 hasMediaAttachments, 445 hasIDMediaAttachments, 446 poll, 447 isSelf, 448 hasOnlyAcct, 449 sameWithSource, 450 uiState, 451 }); 452 453 return false; 454 }; 455 456 const confirmClose = () => { 457 if (!canClose()) { 458 const yes = confirm(beforeUnloadCopy); 459 return yes; 460 } 461 return true; 462 }; 463 464 useEffect(() => { 465 // Show warning if user tries to close window with unsaved changes 466 const handleBeforeUnload = (e) => { 467 if (!canClose()) { 468 e.preventDefault(); 469 e.returnValue = beforeUnloadCopy; 470 } 471 }; 472 window.addEventListener('beforeunload', handleBeforeUnload, { 473 capture: true, 474 }); 475 return () => 476 window.removeEventListener('beforeunload', handleBeforeUnload, { 477 capture: true, 478 }); 479 }, []); 480 481 const getCharCount = () => { 482 const { value } = textareaRef.current; 483 const { value: spoilerText } = spoilerTextRef.current; 484 return stringLength(countableText(value)) + stringLength(spoilerText); 485 }; 486 const updateCharCount = () => { 487 const count = getCharCount(); 488 states.composerCharacterCount = count; 489 }; 490 useEffect(updateCharCount, []); 491 492 const supportsCloseWatcher = window.CloseWatcher; 493 const escDownRef = useRef(false); 494 useHotkeys( 495 'esc', 496 () => { 497 escDownRef.current = true; 498 // This won't be true if this event is already handled and not propagated 🤞 499 }, 500 { 501 enabled: !supportsCloseWatcher, 502 enableOnFormTags: true, 503 useKey: true, 504 ignoreEventWhen: (e) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey, 505 }, 506 ); 507 useHotkeys( 508 'esc', 509 () => { 510 if (!standalone && escDownRef.current && confirmClose()) { 511 onClose(); 512 } 513 escDownRef.current = false; 514 }, 515 { 516 enabled: !supportsCloseWatcher, 517 enableOnFormTags: true, 518 // Use keyup because Esc keydown will close the confirm dialog on Safari 519 keyup: true, 520 ignoreEventWhen: (e) => { 521 const modals = document.querySelectorAll('#modal-container > *'); 522 const hasModal = !!modals; 523 const hasOnlyComposer = 524 modals.length === 1 && modals[0].querySelector('#compose-container'); 525 return ( 526 (hasModal && !hasOnlyComposer) || 527 e.metaKey || 528 e.ctrlKey || 529 e.altKey || 530 e.shiftKey 531 ); 532 }, 533 useKey: true, 534 }, 535 ); 536 useCloseWatcher(() => { 537 if (!standalone && confirmClose()) { 538 onClose(); 539 } 540 }, []); 541 542 const prevBackgroundDraft = useRef({}); 543 const draftKey = () => { 544 const ns = getCurrentAccountNS(); 545 return `${ns}#${UID.current}`; 546 }; 547 const saveUnsavedDraft = () => { 548 // Not enabling this for editing status 549 // I don't think this warrant a draft mode for a status that's already posted 550 // Maybe it could be a big edit change but it should be rare 551 if (editStatus) return; 552 if (states.composerState.minimized) return; 553 const key = draftKey(); 554 const backgroundDraft = { 555 key, 556 replyTo: replyToStatus 557 ? { 558 /* Smaller payload of replyToStatus. Reasons: 559 - No point storing whole thing 560 - Could have media attachments 561 - Could be deleted/edited later 562 */ 563 id: replyToStatus.id, 564 account: { 565 id: replyToStatus.account.id, 566 username: replyToStatus.account.username, 567 acct: replyToStatus.account.acct, 568 }, 569 } 570 : null, 571 draftStatus: { 572 uid: UID.current, 573 status: textareaRef.current.value, 574 spoilerText: spoilerTextRef.current.value, 575 visibility, 576 language, 577 sensitive, 578 sensitiveMedia, 579 poll, 580 mediaAttachments, 581 scheduledAt, 582 }, 583 }; 584 if ( 585 !deepEqual(backgroundDraft, prevBackgroundDraft.current) && 586 !canClose() 587 ) { 588 console.debug('not equal', backgroundDraft, prevBackgroundDraft.current); 589 db.drafts 590 .set(key, { 591 ...backgroundDraft, 592 state: 'unsaved', 593 updatedAt: Date.now(), 594 }) 595 .then(() => { 596 console.debug('DRAFT saved', key, backgroundDraft); 597 }) 598 .catch((e) => { 599 console.error('DRAFT failed', key, e); 600 }); 601 prevBackgroundDraft.current = structuredClone(backgroundDraft); 602 } 603 }; 604 useInterval(saveUnsavedDraft, 5000); // background save every 5s 605 useEffect(() => { 606 saveUnsavedDraft(); 607 // If unmounted, means user discarded the draft 608 // Also means pop-out 🙈, but it's okay because the pop-out will persist the ID and re-create the draft 609 return () => { 610 db.drafts.del(draftKey()); 611 }; 612 }, []); 613 614 useEffect(() => { 615 const handleItems = (e) => { 616 const { items } = e.clipboardData || e.dataTransfer; 617 const files = []; 618 const unsupportedFiles = []; 619 for (let i = 0; i < items.length; i++) { 620 const item = items[i]; 621 if (item.kind === 'file') { 622 const file = item.getAsFile(); 623 if ( 624 supportedMimeTypes !== undefined && 625 !supportedMimeTypes.includes(file.type) 626 ) { 627 unsupportedFiles.push(file); 628 } else { 629 files.push(file); 630 } 631 } 632 } 633 if (unsupportedFiles.length > 0) { 634 alert( 635 plural(unsupportedFiles.length, { 636 one: `File ${unsupportedFiles[0].name} is not supported.`, 637 other: `Files ${lf.format( 638 unsupportedFiles.map((f) => f.name), 639 )} are not supported.`, 640 }), 641 ); 642 } 643 if (files.length > 0 && mediaAttachments.length >= maxMediaAttachments) { 644 alert( 645 plural(maxMediaAttachments, { 646 one: 'You can only attach up to 1 file.', 647 other: 'You can only attach up to # files.', 648 }), 649 ); 650 return; 651 } 652 console.log({ files }); 653 if (files.length > 0) { 654 e.preventDefault(); 655 e.stopPropagation(); 656 // Auto-cut-off files to avoid exceeding maxMediaAttachments 657 let allowedFiles = files; 658 if (maxMediaAttachments !== undefined) { 659 const max = maxMediaAttachments - mediaAttachments.length; 660 allowedFiles = allowedFiles.slice(0, max); 661 if (allowedFiles.length <= 0) { 662 alert( 663 plural(maxMediaAttachments, { 664 one: 'You can only attach up to 1 file.', 665 other: 'You can only attach up to # files.', 666 }), 667 ); 668 return; 669 } 670 } 671 const mediaFiles = allowedFiles.map((file) => ({ 672 file, 673 type: file.type, 674 size: file.size, 675 url: URL.createObjectURL(file), 676 id: null, 677 description: null, 678 })); 679 setMediaAttachments([...mediaAttachments, ...mediaFiles]); 680 } 681 }; 682 window.addEventListener('paste', handleItems); 683 const handleDragover = (e) => { 684 // Prevent default if there's files 685 if (e.dataTransfer.items.length > 0) { 686 e.preventDefault(); 687 e.stopPropagation(); 688 } 689 }; 690 window.addEventListener('dragover', handleDragover); 691 window.addEventListener('drop', handleItems); 692 return () => { 693 window.removeEventListener('paste', handleItems); 694 window.removeEventListener('dragover', handleDragover); 695 window.removeEventListener('drop', handleItems); 696 }; 697 }, [mediaAttachments]); 698 699 const [showMentionPicker, setShowMentionPicker] = useState(false); 700 const [showEmoji2Picker, setShowEmoji2Picker] = useState(false); 701 const [showGIFPicker, setShowGIFPicker] = useState(false); 702 703 const [autoDetectedLanguages, setAutoDetectedLanguages] = useState(null); 704 const [topSupportedLanguages, restSupportedLanguages] = useMemo(() => { 705 const topLanguages = []; 706 const restLanguages = []; 707 const { contentTranslationHideLanguages = [] } = states.settings; 708 supportedLanguages.forEach((l) => { 709 const [code] = l; 710 if ( 711 code === language || 712 code === prevLanguage.current || 713 code === DEFAULT_LANG || 714 contentTranslationHideLanguages.includes(code) || 715 (autoDetectedLanguages?.length && autoDetectedLanguages.includes(code)) 716 ) { 717 topLanguages.push(l); 718 } else { 719 restLanguages.push(l); 720 } 721 }); 722 topLanguages.sort(([codeA, commonA], [codeB, commonB]) => { 723 if (codeA === language) return -1; 724 if (codeB === language) return 1; 725 return commonA.localeCompare(commonB); 726 }); 727 restLanguages.sort(([codeA, commonA], [codeB, commonB]) => 728 commonA.localeCompare(commonB), 729 ); 730 return [topLanguages, restLanguages]; 731 }, [language, autoDetectedLanguages]); 732 733 const replyToStatusMonthsAgo = useMemo( 734 () => 735 !!replyToStatus?.createdAt && 736 Math.floor( 737 (Date.now() - Date.parse(replyToStatus.createdAt)) / 738 (1000 * 60 * 60 * 24 * 30), 739 ), 740 [replyToStatus], 741 ); 742 743 const onMinimize = () => { 744 saveUnsavedDraft(); 745 states.composerState.minimized = true; 746 }; 747 748 const mediaButtonDisabled = 749 uiState === 'loading' || 750 (maxMediaAttachments !== undefined && 751 mediaAttachments.length >= maxMediaAttachments) || 752 !!poll; 753 754 const cwButtonDisabled = uiState === 'loading' || !!sensitive; 755 const onCWButtonClick = () => { 756 setSensitive(true); 757 setTimeout(() => { 758 spoilerTextRef.current?.focus(); 759 }, 0); 760 }; 761 762 // If maxOptions is not defined or defined and is greater than 1, show poll button 763 const showPollButton = maxOptions == null || maxOptions > 1; 764 const pollButtonDisabled = 765 uiState === 'loading' || !!poll || !!mediaAttachments.length; 766 const onPollButtonClick = () => { 767 setPoll({ 768 options: ['', ''], 769 expiresIn: 24 * 60 * 60, // 1 day 770 multiple: false, 771 }); 772 // Focus first choice field 773 setTimeout(() => { 774 composeContainerRef.current 775 ?.querySelector('.poll-choice input[type="text"]') 776 ?.focus(); 777 }, 0); 778 }; 779 780 const highlightLanguageField = 781 language !== prevLanguage.current || 782 (autoDetectedLanguages?.length && 783 !autoDetectedLanguages.includes(language)); 784 const highlightVisibilityField = visibility !== 'public'; 785 786 const addSubToolbarRef = useRef(); 787 const [showAddButton, setShowAddButton] = useState(false); 788 const BUTTON_WIDTH = 42; // roughly one button width 789 useResizeObserver({ 790 ref: addSubToolbarRef, 791 box: 'border-box', 792 onResize: ({ width }) => { 793 // If scrollable, it's truncated 794 const { scrollWidth } = addSubToolbarRef.current; 795 const truncated = scrollWidth > width; 796 const overTruncated = width < BUTTON_WIDTH * 4; 797 setShowAddButton(overTruncated || truncated); 798 addSubToolbarRef.current.hidden = overTruncated; 799 }, 800 }); 801 802 const showScheduledAt = !editStatus; 803 const scheduledAtButtonDisabled = uiState === 'loading' || !!scheduledAt; 804 const onScheduledAtClick = () => { 805 const date = new Date(Date.now() + DEFAULT_SCHEDULED_AT); 806 setScheduledAt(date); 807 }; 808 809 return ( 810 <div id="compose-container-outer" ref={composeContainerRef}> 811 <div 812 id="compose-container" 813 tabIndex={-1} 814 class={standalone ? 'standalone' : ''} 815 > 816 <div class="compose-top"> 817 {currentAccountInfo?.avatarStatic && ( 818 // <Avatar 819 // url={currentAccountInfo.avatarStatic} 820 // size="xl" 821 // alt={currentAccountInfo.username} 822 // squircle={currentAccountInfo?.bot} 823 // /> 824 <AccountBlock 825 account={currentAccountInfo} 826 accountInstance={currentAccount.instanceURL} 827 hideDisplayName 828 useAvatarStatic 829 /> 830 )} 831 {!standalone ? ( 832 <span class="compose-controls"> 833 <button 834 type="button" 835 class="plain4 pop-button" 836 disabled={uiState === 'loading'} 837 onClick={() => { 838 // If there are non-ID media attachments (not yet uploaded), show confirmation dialog because they are not going to be passed to the new window 839 // const containNonIDMediaAttachments = 840 // mediaAttachments.length > 0 && 841 // mediaAttachments.some((media) => !media.id); 842 // if (containNonIDMediaAttachments) { 843 // const yes = confirm( 844 // 'You have media attachments that are not yet uploaded. Opening a new window will discard them and you will need to re-attach them. Are you sure you want to continue?', 845 // ); 846 // if (!yes) { 847 // return; 848 // } 849 // } 850 851 // const mediaAttachmentsWithIDs = mediaAttachments.filter( 852 // (media) => media.id, 853 // ); 854 855 const newWin = openCompose({ 856 editStatus, 857 replyToStatus, 858 draftStatus: { 859 uid: UID.current, 860 status: textareaRef.current.value, 861 spoilerText: spoilerTextRef.current.value, 862 visibility, 863 language, 864 sensitive, 865 poll, 866 mediaAttachments, 867 scheduledAt, 868 }, 869 }); 870 871 if (!newWin) { 872 return; 873 } 874 875 onClose(); 876 }} 877 > 878 <Icon icon="popout" alt={t`Pop out`} /> 879 </button> 880 <button 881 type="button" 882 class="plain4 min-button" 883 onClick={onMinimize} 884 > 885 <Icon icon="minimize" alt={t`Minimize`} /> 886 </button>{' '} 887 <button 888 type="button" 889 class="plain4 close-button" 890 disabled={uiState === 'loading'} 891 onClick={() => { 892 if (confirmClose()) { 893 onClose(); 894 } 895 }} 896 > 897 <Icon icon="x" alt={t`Close`} /> 898 </button> 899 </span> 900 ) : ( 901 hasOpener && ( 902 <button 903 type="button" 904 class="light pop-button" 905 disabled={uiState === 'loading'} 906 onClick={() => { 907 // If there are non-ID media attachments (not yet uploaded), show confirmation dialog because they are not going to be passed to the new window 908 // const containNonIDMediaAttachments = 909 // mediaAttachments.length > 0 && 910 // mediaAttachments.some((media) => !media.id); 911 // if (containNonIDMediaAttachments) { 912 // const yes = confirm( 913 // 'You have media attachments that are not yet uploaded. Opening a new window will discard them and you will need to re-attach them. Are you sure you want to continue?', 914 // ); 915 // if (!yes) { 916 // return; 917 // } 918 // } 919 920 if (!window.opener) { 921 alert(t`Looks like you closed the parent window.`); 922 return; 923 } 924 925 if (window.opener.__STATES__.showCompose) { 926 if (window.opener.__STATES__.composerState?.publishing) { 927 alert( 928 t`Looks like you already have a compose field open in the parent window and currently publishing. Please wait for it to be done and try again later.`, 929 ); 930 return; 931 } 932 933 let confirmText = t`Looks like you already have a compose field open in the parent window. Popping in this window will discard the changes you made in the parent window. Continue?`; 934 const yes = confirm(confirmText); 935 if (!yes) return; 936 } 937 938 // const mediaAttachmentsWithIDs = mediaAttachments.filter( 939 // (media) => media.id, 940 // ); 941 942 onClose({ 943 fn: () => { 944 const passData = { 945 editStatus, 946 replyToStatus, 947 draftStatus: { 948 uid: UID.current, 949 status: textareaRef.current.value, 950 spoilerText: spoilerTextRef.current.value, 951 visibility, 952 language, 953 sensitive, 954 sensitiveMedia, 955 poll, 956 mediaAttachments, 957 scheduledAt, 958 }, 959 }; 960 window.opener.__COMPOSE__ = passData; // Pass it here instead of `showCompose` due to some weird proxy issue again 961 if (window.opener.__STATES__.showCompose) { 962 window.opener.__STATES__.showCompose = false; 963 setTimeout(() => { 964 window.opener.__STATES__.showCompose = true; 965 }, 10); 966 } else { 967 window.opener.__STATES__.showCompose = true; 968 } 969 if (window.opener.__STATES__.composerState.minimized) { 970 // Maximize it 971 window.opener.__STATES__.composerState.minimized = false; 972 } 973 }, 974 }); 975 }} 976 > 977 <Icon icon="popin" alt={t`Pop in`} /> 978 </button> 979 ) 980 )} 981 </div> 982 {!!replyToStatus && ( 983 <div class="status-preview"> 984 <Status status={replyToStatus} size="s" previewMode /> 985 <div class="status-preview-legend reply-to"> 986 {replyToStatusMonthsAgo > 0 ? ( 987 <Trans> 988 Replying to @ 989 {replyToStatus.account.acct || replyToStatus.account.username} 990 &rsquo;s post ( 991 <strong> 992 {rtf.format(-replyToStatusMonthsAgo, 'month')} 993 </strong> 994 ) 995 </Trans> 996 ) : ( 997 <Trans> 998 Replying to @ 999 {replyToStatus.account.acct || replyToStatus.account.username} 1000 &rsquo;s post 1001 </Trans> 1002 )} 1003 </div> 1004 </div> 1005 )} 1006 {!!editStatus && ( 1007 <div class="status-preview"> 1008 <Status status={editStatus} size="s" previewMode /> 1009 <div class="status-preview-legend"> 1010 <Trans>Editing source post</Trans> 1011 </div> 1012 </div> 1013 )} 1014 <form 1015 ref={formRef} 1016 class={`form-visibility-${visibility}`} 1017 style={{ 1018 pointerEvents: uiState === 'loading' ? 'none' : 'auto', 1019 opacity: uiState === 'loading' ? 0.5 : 1, 1020 }} 1021 onClick={() => { 1022 setTimeout(() => { 1023 if (!document.activeElement) { 1024 lastFocusedFieldRef.current?.focus?.(); 1025 } 1026 }, 10); 1027 }} 1028 onKeyDown={(e) => { 1029 if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { 1030 formRef.current.dispatchEvent( 1031 new Event('submit', { cancelable: true }), 1032 ); 1033 } 1034 }} 1035 onSubmit={(e) => { 1036 e.preventDefault(); 1037 1038 const formData = new FormData(e.target); 1039 const entries = Object.fromEntries(formData.entries()); 1040 console.log('ENTRIES', entries); 1041 let { 1042 status, 1043 visibility, 1044 sensitive, 1045 sensitiveMedia, 1046 spoilerText, 1047 scheduledAt, 1048 } = entries; 1049 1050 // Pre-cleanup 1051 // checkboxes return "on" if checked 1052 sensitive = sensitive === 'on'; 1053 sensitiveMedia = sensitiveMedia === 'on'; 1054 1055 // Convert datetime-local input value to RFC3339 Date string value 1056 scheduledAt = scheduledAt 1057 ? new Date(scheduledAt).toISOString() 1058 : undefined; 1059 1060 // Validation 1061 /* Let the backend validate this 1062 if (stringLength(status) > maxCharacters) { 1063 alert(`Status is too long! Max characters: ${maxCharacters}`); 1064 return; 1065 } 1066 if ( 1067 sensitive && 1068 stringLength(status) + stringLength(spoilerText) > maxCharacters 1069 ) { 1070 alert( 1071 `Status and content warning is too long! Max characters: ${maxCharacters}`, 1072 ); 1073 return; 1074 } 1075 */ 1076 if (poll) { 1077 if (poll.options.length < 2) { 1078 alert(t`Poll must have at least 2 options`); 1079 return; 1080 } 1081 if (poll.options.some((option) => option === '')) { 1082 alert(t`Some poll choices are empty`); 1083 return; 1084 } 1085 } 1086 // TODO: check for URLs and use `charactersReservedPerUrl` to calculate max characters 1087 1088 if (mediaAttachments.length > 0) { 1089 // If there are media attachments, check if they have no descriptions 1090 const hasNoDescriptions = mediaAttachments.some( 1091 (media) => !media.description?.trim?.(), 1092 ); 1093 if (hasNoDescriptions) { 1094 const yes = confirm( 1095 t`Some media have no descriptions. Continue?`, 1096 ); 1097 if (!yes) return; 1098 } 1099 } 1100 1101 // Post-cleanup 1102 spoilerText = (sensitive && spoilerText) || undefined; 1103 status = status === '' ? undefined : status; 1104 1105 // states.composerState.minimized = true; 1106 states.composerState.publishing = true; 1107 setUIState('loading'); 1108 (async () => { 1109 try { 1110 console.log('MEDIA ATTACHMENTS', mediaAttachments); 1111 if (mediaAttachments.length > 0) { 1112 // Upload media attachments first 1113 const mediaPromises = mediaAttachments.map((attachment) => { 1114 const { file, description, id } = attachment; 1115 console.log('UPLOADING', attachment); 1116 if (id) { 1117 // If already uploaded 1118 return attachment; 1119 } else { 1120 const params = removeNullUndefined({ 1121 file, 1122 description, 1123 }); 1124 return masto.v2.media.create(params).then((res) => { 1125 if (res.id) { 1126 attachment.id = res.id; 1127 } 1128 return res; 1129 }); 1130 } 1131 }); 1132 const results = await Promise.allSettled(mediaPromises); 1133 1134 // If any failed, return 1135 if ( 1136 results.some((result) => { 1137 return result.status === 'rejected' || !result.value?.id; 1138 }) 1139 ) { 1140 states.composerState.publishing = false; 1141 states.composerState.publishingError = true; 1142 setUIState('error'); 1143 // Alert all the reasons 1144 results.forEach((result) => { 1145 if (result.status === 'rejected') { 1146 console.error(result); 1147 alert(result.reason || t`Attachment #${i} failed`); 1148 } 1149 }); 1150 return; 1151 } 1152 1153 console.log({ results, mediaAttachments }); 1154 } 1155 1156 /* NOTE: 1157 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? 1158 Code: https://github.com/neet/masto.js/blob/dd0d649067b6a2b6e60fbb0a96597c373a255b00/src/serializers/is-object.ts#L2 1159 1160 // TODO: Note above is no longer true in Masto.js v6. Revisit this. 1161 */ 1162 let params = { 1163 status, 1164 // spoilerText, 1165 spoiler_text: spoilerText, 1166 language, 1167 sensitive: sensitive || sensitiveMedia, 1168 poll, 1169 // mediaIds: mediaAttachments.map((attachment) => attachment.id), 1170 media_ids: mediaAttachments.map( 1171 (attachment) => attachment.id, 1172 ), 1173 }; 1174 if (editStatus && supports('@mastodon/edit-media-attributes')) { 1175 params.media_attributes = mediaAttachments.map( 1176 (attachment) => { 1177 return { 1178 id: attachment.id, 1179 description: attachment.description, 1180 // focus 1181 // thumbnail 1182 }; 1183 }, 1184 ); 1185 } else if (!editStatus) { 1186 params.visibility = visibility; 1187 // params.inReplyToId = replyToStatus?.id || undefined; 1188 params.in_reply_to_id = replyToStatus?.id || undefined; 1189 params.scheduled_at = scheduledAt; 1190 } 1191 params = removeNullUndefined(params); 1192 console.log('POST', params); 1193 1194 let newStatus; 1195 if (editStatus) { 1196 newStatus = await masto.v1.statuses 1197 .$select(editStatus.id) 1198 .update(params); 1199 saveStatus(newStatus, instance, { 1200 skipThreading: true, 1201 }); 1202 } else { 1203 try { 1204 newStatus = await masto.v1.statuses.create(params, { 1205 requestInit: { 1206 headers: { 1207 'Idempotency-Key': UID.current, 1208 }, 1209 }, 1210 }); 1211 } catch (_) { 1212 // If idempotency key fails, try again without it 1213 newStatus = await masto.v1.statuses.create(params); 1214 } 1215 } 1216 states.composerState.minimized = false; 1217 states.composerState.publishing = false; 1218 setUIState('default'); 1219 1220 // Close 1221 onClose({ 1222 // type: post, reply, edit 1223 type: editStatus ? 'edit' : replyToStatus ? 'reply' : 'post', 1224 newStatus, 1225 instance, 1226 scheduledAt, 1227 }); 1228 } catch (e) { 1229 states.composerState.publishing = false; 1230 states.composerState.publishingError = true; 1231 console.error(e); 1232 alert(e?.reason || e); 1233 setUIState('error'); 1234 } 1235 })(); 1236 }} 1237 > 1238 <div> 1239 <div class={`compose-cw-container ${sensitive ? '' : 'collapsed'}`}> 1240 <input 1241 type="hidden" 1242 name="sensitive" 1243 value={sensitive ? 'on' : 'off'} 1244 /> 1245 {/* mimic the old checkbox */} 1246 <TextExpander 1247 keys=":" 1248 class="spoiler-text-field-container" 1249 onTrigger={(action) => { 1250 if (action?.name === 'custom-emojis') { 1251 setShowEmoji2Picker({ 1252 targetElement: spoilerTextRef, 1253 defaultSearchTerm: action?.defaultSearchTerm || null, 1254 }); 1255 } 1256 }} 1257 > 1258 <input 1259 ref={spoilerTextRef} 1260 type="text" 1261 name="spoilerText" 1262 placeholder={t`Content warning`} 1263 data-allow-custom-emoji="true" 1264 disabled={uiState === 'loading'} 1265 class="spoiler-text-field" 1266 lang={language} 1267 spellCheck="true" 1268 autocomplete="off" 1269 dir="auto" 1270 onInput={() => { 1271 updateCharCount(); 1272 }} 1273 /> 1274 </TextExpander> 1275 <button 1276 type="button" 1277 class="close-button plain4 small" 1278 onClick={() => { 1279 setSensitive(false); 1280 textareaRef.current.focus(); 1281 }} 1282 > 1283 <Icon icon="x" alt={t`Cancel`} /> 1284 </button> 1285 </div> 1286 <Textarea 1287 ref={textareaRef} 1288 data-allow-custom-emoji="true" 1289 placeholder={ 1290 replyToStatus 1291 ? t`Post your reply` 1292 : editStatus 1293 ? t`Edit your post` 1294 : !!poll 1295 ? t`Ask a question` 1296 : t`What are you doing?` 1297 } 1298 required={mediaAttachments?.length === 0} 1299 disabled={uiState === 'loading'} 1300 lang={language} 1301 onInput={() => { 1302 updateCharCount(); 1303 }} 1304 maxCharacters={maxCharacters} 1305 onTrigger={(action) => { 1306 if (action?.name === 'custom-emojis') { 1307 setShowEmoji2Picker({ 1308 targetElement: lastFocusedEmojiFieldRef, 1309 defaultSearchTerm: action?.defaultSearchTerm || null, 1310 }); 1311 } else if (action?.name === 'mention') { 1312 setShowMentionPicker({ 1313 defaultSearchTerm: action?.defaultSearchTerm || null, 1314 }); 1315 } else if ( 1316 action?.name === 'auto-detect-language' && 1317 action?.languages 1318 ) { 1319 setAutoDetectedLanguages(action.languages); 1320 } 1321 }} 1322 /> 1323 </div> 1324 {mediaAttachments?.length > 0 && ( 1325 <div class="media-attachments"> 1326 {mediaAttachments.map((attachment, i) => { 1327 const { id, file } = attachment; 1328 const fileID = file?.size + file?.type + file?.name; 1329 return ( 1330 <MediaAttachment 1331 key={id || fileID || i} 1332 attachment={attachment} 1333 disabled={uiState === 'loading'} 1334 lang={language} 1335 descriptionLimit={descriptionLimit} 1336 onDescriptionChange={(value) => { 1337 setMediaAttachments((attachments) => { 1338 const newAttachments = [...attachments]; 1339 newAttachments[i] = { 1340 ...newAttachments[i], 1341 description: value, 1342 }; 1343 return newAttachments; 1344 }); 1345 }} 1346 onRemove={() => { 1347 setMediaAttachments((attachments) => { 1348 return attachments.filter((_, j) => j !== i); 1349 }); 1350 }} 1351 /> 1352 ); 1353 })} 1354 <label class="media-sensitive"> 1355 <input 1356 name="sensitiveMedia" 1357 type="checkbox" 1358 checked={sensitiveMedia} 1359 disabled={uiState === 'loading'} 1360 onChange={(e) => { 1361 const sensitiveMedia = e.target.checked; 1362 setSensitiveMedia(sensitiveMedia); 1363 }} 1364 />{' '} 1365 <span> 1366 <Trans>Mark media as sensitive</Trans> 1367 </span>{' '} 1368 <Icon icon={`eye-${sensitiveMedia ? 'close' : 'open'}`} /> 1369 </label> 1370 </div> 1371 )} 1372 {!!poll && ( 1373 <ComposePoll 1374 lang={language} 1375 maxOptions={maxOptions} 1376 maxExpiration={maxExpiration} 1377 minExpiration={minExpiration} 1378 maxCharactersPerOption={maxCharactersPerOption} 1379 poll={poll} 1380 disabled={uiState === 'loading'} 1381 onInput={(poll) => { 1382 if (poll) { 1383 const newPoll = { ...poll }; 1384 setPoll(newPoll); 1385 } else { 1386 setPoll(null); 1387 focusLastFocusedField(); 1388 } 1389 }} 1390 /> 1391 )} 1392 {scheduledAt && ( 1393 <div class="toolbar scheduled-at"> 1394 <span> 1395 <label> 1396 <Trans> 1397 Posting on{' '} 1398 <ScheduledAtField 1399 scheduledAt={scheduledAt} 1400 setScheduledAt={setScheduledAt} 1401 /> 1402 </Trans> 1403 </label>{' '} 1404 <small class="tag insignificant"> 1405 {getLocalTimezoneName()} 1406 </small> 1407 </span> 1408 <button 1409 type="button" 1410 class="plain4 close-button small" 1411 onClick={() => { 1412 setScheduledAt(null); 1413 focusLastFocusedField(); 1414 }} 1415 > 1416 <Icon icon="x" alt={t`Cancel`} /> 1417 </button> 1418 </div> 1419 )} 1420 <div class="toolbar compose-footer"> 1421 <span class="add-toolbar-button-group spacer"> 1422 {showAddButton && ( 1423 <Menu2 1424 portal={{ 1425 target: document.body, 1426 }} 1427 containerProps={{ 1428 style: { 1429 zIndex: 1001, 1430 }, 1431 }} 1432 menuButton={({ open }) => ( 1433 <button 1434 type="button" 1435 class={`toolbar-button add-button ${ 1436 open ? 'active' : '' 1437 }`} 1438 > 1439 <Icon icon="plus" title={t`Add`} /> 1440 </button> 1441 )} 1442 > 1443 {supportsCameraCapture && ( 1444 <MenuItem 1445 disabled={mediaButtonDisabled} 1446 className="compose-menu-add-media" 1447 > 1448 <label class="compose-menu-add-media-field"> 1449 <CameraCaptureInput 1450 hidden 1451 supportedMimeTypes={supportedImagesVideosTypes} 1452 disabled={mediaButtonDisabled} 1453 setMediaAttachments={setMediaAttachments} 1454 /> 1455 </label> 1456 <Icon icon="camera" /> <span>{_(ADD_LABELS.camera)}</span> 1457 </MenuItem> 1458 )} 1459 <MenuItem 1460 disabled={mediaButtonDisabled} 1461 className="compose-menu-add-media" 1462 > 1463 <label class="compose-menu-add-media-field"> 1464 <FilePickerInput 1465 hidden 1466 supportedMimeTypes={supportedMimeTypes} 1467 maxMediaAttachments={maxMediaAttachments} 1468 mediaAttachments={mediaAttachments} 1469 disabled={mediaButtonDisabled} 1470 setMediaAttachments={setMediaAttachments} 1471 /> 1472 </label> 1473 <Icon icon="media" /> <span>{_(ADD_LABELS.media)}</span> 1474 </MenuItem> 1475 <MenuItem 1476 disabled={cwButtonDisabled} 1477 onClick={onCWButtonClick} 1478 > 1479 <Icon icon={`eye-${sensitive ? 'close' : 'open'}`} />{' '} 1480 <span>{_(ADD_LABELS.sensitive)}</span> 1481 </MenuItem> 1482 {showPollButton && ( 1483 <MenuItem 1484 disabled={pollButtonDisabled} 1485 onClick={onPollButtonClick} 1486 > 1487 <Icon icon="poll" /> <span>{_(ADD_LABELS.poll)}</span> 1488 </MenuItem> 1489 )} 1490 <MenuDivider /> 1491 <MenuItem 1492 onClick={() => { 1493 setShowEmoji2Picker({ 1494 targetElement: lastFocusedEmojiFieldRef, 1495 }); 1496 }} 1497 > 1498 <Icon icon="emoji2" />{' '} 1499 <span>{_(ADD_LABELS.customEmoji)}</span> 1500 </MenuItem> 1501 {!!states.settings.composerGIFPicker && ( 1502 <MenuItem 1503 disabled={mediaButtonDisabled} 1504 onClick={() => { 1505 setShowGIFPicker(true); 1506 }} 1507 > 1508 <span class="icon icon-gif" role="img" /> 1509 <span>{_(ADD_LABELS.gif)}</span> 1510 </MenuItem> 1511 )} 1512 {showScheduledAt && ( 1513 <> 1514 <MenuDivider /> 1515 <MenuItem 1516 disabled={scheduledAtButtonDisabled} 1517 onClick={onScheduledAtClick} 1518 > 1519 <Icon icon="schedule" />{' '} 1520 <span>{_(ADD_LABELS.scheduledPost)}</span> 1521 </MenuItem> 1522 </> 1523 )} 1524 </Menu2> 1525 )} 1526 <span class="add-sub-toolbar-button-group" ref={addSubToolbarRef}> 1527 {supportsCameraCapture && ( 1528 <label class="toolbar-button"> 1529 <CameraCaptureInput 1530 supportedMimeTypes={supportedImagesVideosTypes} 1531 mediaAttachments={mediaAttachments} 1532 disabled={mediaButtonDisabled} 1533 setMediaAttachments={setMediaAttachments} 1534 /> 1535 <Icon icon="camera" alt={_(ADD_LABELS.camera)} /> 1536 </label> 1537 )} 1538 <label class="toolbar-button"> 1539 <FilePickerInput 1540 supportedMimeTypes={supportedMimeTypes} 1541 maxMediaAttachments={maxMediaAttachments} 1542 mediaAttachments={mediaAttachments} 1543 disabled={mediaButtonDisabled} 1544 setMediaAttachments={setMediaAttachments} 1545 /> 1546 <Icon icon="media" alt={_(ADD_LABELS.media)} /> 1547 </label> 1548 <button 1549 type="button" 1550 class="toolbar-button" 1551 disabled={cwButtonDisabled} 1552 onClick={onCWButtonClick} 1553 > 1554 <Icon 1555 icon={`eye-${sensitive ? 'close' : 'open'}`} 1556 alt={_(ADD_LABELS.sensitive)} 1557 /> 1558 </button> 1559 {showPollButton && ( 1560 <button 1561 type="button" 1562 class="toolbar-button" 1563 disabled={pollButtonDisabled} 1564 onClick={onPollButtonClick} 1565 > 1566 <Icon icon="poll" alt={_(ADD_LABELS.poll)} /> 1567 </button> 1568 )} 1569 <div class="toolbar-divider" /> 1570 {/* <button 1571 type="button" 1572 class="toolbar-button" 1573 disabled={uiState === 'loading'} 1574 onClick={() => { 1575 setShowMentionPicker(true); 1576 }} 1577 > 1578 <Icon icon="at" /> 1579 </button> */} 1580 <button 1581 type="button" 1582 class="toolbar-button" 1583 disabled={uiState === 'loading'} 1584 onClick={() => { 1585 setShowEmoji2Picker({ 1586 targetElement: lastFocusedEmojiFieldRef, 1587 }); 1588 }} 1589 > 1590 <Icon icon="emoji2" alt={_(ADD_LABELS.customEmoji)} /> 1591 </button> 1592 {!!states.settings.composerGIFPicker && ( 1593 <button 1594 type="button" 1595 class="toolbar-button gif-picker-button" 1596 disabled={mediaButtonDisabled} 1597 onClick={() => { 1598 setShowGIFPicker(true); 1599 }} 1600 > 1601 <span 1602 class="icon icon-gif" 1603 aria-label={_(ADD_LABELS.gif)} 1604 /> 1605 </button> 1606 )} 1607 {showScheduledAt && ( 1608 <> 1609 <div class="toolbar-divider" /> 1610 <button 1611 type="button" 1612 class={`toolbar-button ${scheduledAt ? 'highlight' : ''}`} 1613 disabled={scheduledAtButtonDisabled} 1614 onClick={onScheduledAtClick} 1615 > 1616 <Icon icon="schedule" alt={_(ADD_LABELS.scheduledPost)} /> 1617 </button> 1618 </> 1619 )} 1620 </span> 1621 </span> 1622 {uiState === 'loading' ? ( 1623 <Loader abrupt /> 1624 ) : ( 1625 <CharCountMeter 1626 maxCharacters={maxCharacters} 1627 hidden={uiState === 'loading'} 1628 /> 1629 )} 1630 <label 1631 class={`toolbar-button ${ 1632 highlightLanguageField ? 'highlight' : '' 1633 }`} 1634 > 1635 <span class="icon-text"> 1636 {supportedLanguagesMap[language]?.native} 1637 </span> 1638 <select 1639 name="language" 1640 value={language} 1641 onChange={(e) => { 1642 const { value } = e.target; 1643 setLanguage(value || DEFAULT_LANG); 1644 store.session.set('currentLanguage', value || DEFAULT_LANG); 1645 }} 1646 disabled={uiState === 'loading'} 1647 dir="auto" 1648 > 1649 {topSupportedLanguages.map(([code, common, native]) => { 1650 const commonText = localeCode2Text({ 1651 code, 1652 fallback: common, 1653 }); 1654 const showCommon = commonText !== native; 1655 return ( 1656 <option value={code} key={code}> 1657 {showCommon ? `${native} - ${commonText}` : commonText} 1658 </option> 1659 ); 1660 })} 1661 <hr /> 1662 {restSupportedLanguages.map(([code, common, native]) => { 1663 const commonText = localeCode2Text({ 1664 code, 1665 fallback: common, 1666 }); 1667 const showCommon = commonText !== native; 1668 return ( 1669 <option value={code} key={code}> 1670 {showCommon ? `${native} - ${commonText}` : commonText} 1671 </option> 1672 ); 1673 })} 1674 </select> 1675 </label>{' '} 1676 <label 1677 class={`toolbar-button ${highlightVisibilityField ? 'highlight' : ''}`} 1678 title={_(visibilityText[visibility])} 1679 > 1680 {visibility === 'public' || visibility === 'direct' ? ( 1681 <Icon 1682 icon={visibilityIconsMap[visibility]} 1683 alt={_(visibilityText[visibility])} 1684 /> 1685 ) : ( 1686 <span class="icon-text">{_(visibilityText[visibility])}</span> 1687 )} 1688 <select 1689 name="visibility" 1690 value={visibility} 1691 onChange={(e) => { 1692 setVisibility(e.target.value); 1693 }} 1694 disabled={uiState === 'loading' || !!editStatus} 1695 dir="auto" 1696 > 1697 <option value="public"> 1698 <Trans>Public</Trans> 1699 </option> 1700 {(supports('@pleroma/local-visibility-post') || 1701 supports('@akkoma/local-visibility-post')) && ( 1702 <option value="local"> 1703 <Trans>Local</Trans> 1704 </option> 1705 )} 1706 <option value="unlisted"> 1707 <Trans>Quiet public</Trans> 1708 </option> 1709 <option value="private"> 1710 <Trans>Followers</Trans> 1711 </option> 1712 <option value="direct"> 1713 <Trans>Private mention</Trans> 1714 </option> 1715 </select> 1716 </label>{' '} 1717 <button type="submit" disabled={uiState === 'loading'}> 1718 {scheduledAt 1719 ? t`Schedule` 1720 : replyToStatus 1721 ? t`Reply` 1722 : editStatus 1723 ? t`Update` 1724 : t({ 1725 message: 'Post', 1726 context: 'Submit button in composer', 1727 })} 1728 </button> 1729 </div> 1730 </form> 1731 </div> 1732 {showMentionPicker && ( 1733 <Modal 1734 onClose={() => { 1735 setShowMentionPicker(false); 1736 focusLastFocusedField(); 1737 }} 1738 > 1739 <MentionModal 1740 masto={masto} 1741 instance={instance} 1742 onClose={() => { 1743 setShowMentionPicker(false); 1744 }} 1745 defaultSearchTerm={showMentionPicker?.defaultSearchTerm} 1746 onSelect={(socialAddress) => { 1747 const textarea = textareaRef.current; 1748 if (textarea) { 1749 insertTextAtCursor({ 1750 targetElement: textarea, 1751 text: '@' + socialAddress, 1752 }); 1753 } 1754 }} 1755 /> 1756 </Modal> 1757 )} 1758 {showEmoji2Picker && ( 1759 <Modal 1760 onClose={() => { 1761 setShowEmoji2Picker(false); 1762 focusLastFocusedField(); 1763 }} 1764 > 1765 <CustomEmojisModal 1766 masto={masto} 1767 instance={instance} 1768 onClose={() => { 1769 setShowEmoji2Picker(false); 1770 }} 1771 defaultSearchTerm={showEmoji2Picker?.defaultSearchTerm} 1772 onSelect={(emojiShortcode) => { 1773 const targetElement = 1774 showEmoji2Picker?.targetElement?.current || textareaRef.current; 1775 if (targetElement) { 1776 insertTextAtCursor({ targetElement, text: emojiShortcode }); 1777 } 1778 }} 1779 /> 1780 </Modal> 1781 )} 1782 {showGIFPicker && ( 1783 <Modal 1784 onClose={() => { 1785 setShowGIFPicker(false); 1786 focusLastFocusedField(); 1787 }} 1788 > 1789 <GIFPickerModal 1790 onClose={() => setShowGIFPicker(false)} 1791 onSelect={({ url, type, alt_text }) => { 1792 console.log('GIF URL', url); 1793 if (mediaAttachments.length >= maxMediaAttachments) { 1794 alert( 1795 plural(maxMediaAttachments, { 1796 one: 'You can only attach up to 1 file.', 1797 other: 'You can only attach up to # files.', 1798 }), 1799 ); 1800 return; 1801 } 1802 // Download the GIF and insert it as media attachment 1803 (async () => { 1804 let theToast; 1805 try { 1806 theToast = showToast({ 1807 text: t`Downloading GIF…`, 1808 duration: -1, 1809 }); 1810 const blob = await fetch(url, { 1811 referrerPolicy: 'no-referrer', 1812 }).then((res) => res.blob()); 1813 const file = new File( 1814 [blob], 1815 type === 'video/mp4' ? 'video.mp4' : 'image.gif', 1816 { 1817 type, 1818 }, 1819 ); 1820 const newMediaAttachments = [ 1821 ...mediaAttachments, 1822 { 1823 file, 1824 type, 1825 size: file.size, 1826 id: null, 1827 description: alt_text || '', 1828 }, 1829 ]; 1830 setMediaAttachments(newMediaAttachments); 1831 theToast?.hideToast?.(); 1832 } catch (err) { 1833 console.error(err); 1834 theToast?.hideToast?.(); 1835 showToast(t`Failed to download GIF`); 1836 } 1837 })(); 1838 }} 1839 /> 1840 </Modal> 1841 )} 1842 </div> 1843 ); 1844} 1845 1846function removeNullUndefined(obj) { 1847 for (let key in obj) { 1848 if (obj[key] === null || obj[key] === undefined) { 1849 delete obj[key]; 1850 } 1851 } 1852 return obj; 1853} 1854 1855export default Compose;