this repo has no description
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 ’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 ’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;