this repo has no description
1import './compose.css';
2
3import '@github/text-expander-element';
4import { MenuItem } from '@szhsin/react-menu';
5import equal from 'fast-deep-equal';
6import { forwardRef } from 'preact/compat';
7import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
8import { useHotkeys } from 'react-hotkeys-hook';
9import { substring } from 'runes2';
10import stringLength from 'string-length';
11import { uid } from 'uid/single';
12import { useDebouncedCallback, useThrottledCallback } from 'use-debounce';
13import { useSnapshot } from 'valtio';
14
15import Menu2 from '../components/menu2';
16import supportedLanguages from '../data/status-supported-languages';
17import urlRegex from '../data/url-regex';
18import { api } from '../utils/api';
19import db from '../utils/db';
20import emojifyText from '../utils/emojify-text';
21import localeMatch from '../utils/locale-match';
22import openCompose from '../utils/open-compose';
23import shortenNumber from '../utils/shorten-number';
24import showToast from '../utils/show-toast';
25import states, { saveStatus } from '../utils/states';
26import store from '../utils/store';
27import {
28 getCurrentAccount,
29 getCurrentAccountNS,
30 getCurrentInstance,
31 getCurrentInstanceConfiguration,
32} from '../utils/store-utils';
33import supports from '../utils/supports';
34import useCloseWatcher from '../utils/useCloseWatcher';
35import useInterval from '../utils/useInterval';
36import visibilityIconsMap from '../utils/visibility-icons-map';
37
38import AccountBlock from './account-block';
39// import Avatar from './avatar';
40import Icon from './icon';
41import Loader from './loader';
42import Modal from './modal';
43import Status from './status';
44
45const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env;
46
47const supportedLanguagesMap = supportedLanguages.reduce((acc, l) => {
48 const [code, common, native] = l;
49 acc[code] = {
50 common,
51 native,
52 };
53 return acc;
54}, {});
55
56/* NOTES:
57 - Max character limit includes BOTH status text and Content Warning text
58*/
59
60const expiryOptions = {
61 '5 minutes': 5 * 60,
62 '30 minutes': 30 * 60,
63 '1 hour': 60 * 60,
64 '6 hours': 6 * 60 * 60,
65 '12 hours': 12 * 60 * 60,
66 '1 day': 24 * 60 * 60,
67 '3 days': 3 * 24 * 60 * 60,
68 '7 days': 7 * 24 * 60 * 60,
69};
70const expirySeconds = Object.values(expiryOptions);
71const oneDay = 24 * 60 * 60;
72
73const expiresInFromExpiresAt = (expiresAt) => {
74 if (!expiresAt) return oneDay;
75 const delta = (new Date(expiresAt).getTime() - Date.now()) / 1000;
76 return expirySeconds.find((s) => s >= delta) || oneDay;
77};
78
79const menu = document.createElement('ul');
80menu.role = 'listbox';
81menu.className = 'text-expander-menu';
82
83// Set IntersectionObserver on menu, reposition it because text-expander doesn't handle it
84const windowMargin = 16;
85const observer = new IntersectionObserver((entries) => {
86 entries.forEach((entry) => {
87 if (entry.isIntersecting) {
88 const { left, width } = entry.boundingClientRect;
89 const { innerWidth } = window;
90 if (left + width > innerWidth) {
91 menu.style.left = innerWidth - width - windowMargin + 'px';
92 }
93 }
94 });
95});
96observer.observe(menu);
97
98const DEFAULT_LANG = localeMatch(
99 [new Intl.DateTimeFormat().resolvedOptions().locale, ...navigator.languages],
100 supportedLanguages.map((l) => l[0]),
101 'en',
102);
103
104// https://github.com/mastodon/mastodon/blob/c4a429ed47e85a6bbf0d470a41cc2f64cf120c19/app/javascript/mastodon/features/compose/util/counter.js
105const urlRegexObj = new RegExp(urlRegex.source, urlRegex.flags);
106const usernameRegex = /(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/gi;
107const urlPlaceholder = '$2xxxxxxxxxxxxxxxxxxxxxxx';
108function countableText(inputText) {
109 return inputText
110 .replace(urlRegexObj, urlPlaceholder)
111 .replace(usernameRegex, '$1@$3');
112}
113
114// https://github.com/mastodon/mastodon/blob/c03bd2a238741a012aa4b98dc4902d6cf948ab63/app/models/account.rb#L69
115const USERNAME_RE = /[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?/i;
116const MENTION_RE = new RegExp(
117 `(^|[^=\\/\\w])(@${USERNAME_RE.source}(?:@[\\p{L}\\w.-]+[\\w]+)?)`,
118 'uig',
119);
120
121// AI-generated, all other regexes are too complicated
122const HASHTAG_RE = new RegExp(
123 `(^|[^=\\/\\w])(#[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?)(?![\\/\\w])`,
124 'ig',
125);
126
127// https://github.com/mastodon/mastodon/blob/23e32a4b3031d1da8b911e0145d61b4dd47c4f96/app/models/custom_emoji.rb#L31
128const SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}';
129const SCAN_RE = new RegExp(
130 `([^A-Za-z0-9_:\\n]|^)(:${SHORTCODE_RE_FRAGMENT}:)(?=[^A-Za-z0-9_:]|$)`,
131 'g',
132);
133
134function highlightText(text, { maxCharacters = Infinity }) {
135 // Accept text string, return formatted HTML string
136 let html = text;
137 // Exceeded characters limit
138 const { composerCharacterCount } = states;
139 let leftoverHTML = '';
140 if (composerCharacterCount > maxCharacters) {
141 // NOTE: runes2 substring considers surrogate pairs
142 // const leftoverCount = composerCharacterCount - maxCharacters;
143 // Highlight exceeded characters
144 leftoverHTML =
145 '<mark class="compose-highlight-exceeded">' +
146 // html.slice(-leftoverCount) +
147 substring(html, maxCharacters) +
148 '</mark>';
149 // html = html.slice(0, -leftoverCount);
150 html = substring(html, 0, maxCharacters);
151 return html + leftoverHTML;
152 }
153
154 return html
155 .replace(urlRegexObj, '$2<mark class="compose-highlight-url">$3</mark>') // URLs
156 .replace(MENTION_RE, '$1<mark class="compose-highlight-mention">$2</mark>') // Mentions
157 .replace(HASHTAG_RE, '$1<mark class="compose-highlight-hashtag">$2</mark>') // Hashtags
158 .replace(
159 SCAN_RE,
160 '$1<mark class="compose-highlight-emoji-shortcode">$2</mark>',
161 ); // Emoji shortcodes
162}
163
164function Compose({
165 onClose,
166 replyToStatus,
167 editStatus,
168 draftStatus,
169 standalone,
170 hasOpener,
171}) {
172 console.warn('RENDER COMPOSER');
173 const { masto, instance } = api();
174 const [uiState, setUIState] = useState('default');
175 const UID = useRef(draftStatus?.uid || uid());
176 console.log('Compose UID', UID.current);
177
178 const currentAccount = getCurrentAccount();
179 const currentAccountInfo = currentAccount.info;
180
181 const configuration = getCurrentInstanceConfiguration();
182 console.log('⚙️ Configuration', configuration);
183
184 const {
185 statuses: {
186 maxCharacters,
187 maxMediaAttachments,
188 charactersReservedPerUrl,
189 } = {},
190 mediaAttachments: {
191 supportedMimeTypes = [],
192 imageSizeLimit,
193 imageMatrixLimit,
194 videoSizeLimit,
195 videoMatrixLimit,
196 videoFrameRateLimit,
197 } = {},
198 polls: {
199 maxOptions,
200 maxCharactersPerOption,
201 maxExpiration,
202 minExpiration,
203 } = {},
204 } = configuration || {};
205
206 const textareaRef = useRef();
207 const spoilerTextRef = useRef();
208 const [visibility, setVisibility] = useState('public');
209 const [sensitive, setSensitive] = useState(false);
210 const [language, setLanguage] = useState(
211 store.session.get('currentLanguage') || DEFAULT_LANG,
212 );
213 const prevLanguage = useRef(language);
214 const [mediaAttachments, setMediaAttachments] = useState([]);
215 const [poll, setPoll] = useState(null);
216
217 const prefs = store.account.get('preferences') || {};
218
219 const oninputTextarea = () => {
220 if (!textareaRef.current) return;
221 textareaRef.current.dispatchEvent(new Event('input'));
222 };
223 const focusTextarea = () => {
224 setTimeout(() => {
225 console.debug('FOCUS textarea');
226 textareaRef.current?.focus();
227 }, 300);
228 };
229
230 useEffect(() => {
231 if (replyToStatus) {
232 const { spoilerText, visibility, language, sensitive } = replyToStatus;
233 if (spoilerText && spoilerTextRef.current) {
234 spoilerTextRef.current.value = spoilerText;
235 }
236 const mentions = new Set([
237 replyToStatus.account.acct,
238 ...replyToStatus.mentions.map((m) => m.acct),
239 ]);
240 const allMentions = [...mentions].filter(
241 (m) => m !== currentAccountInfo.acct,
242 );
243 if (allMentions.length > 0) {
244 textareaRef.current.value = `${allMentions
245 .map((m) => `@${m}`)
246 .join(' ')} `;
247 oninputTextarea();
248 }
249 focusTextarea();
250 setVisibility(
251 visibility === 'public' && prefs['posting:default:visibility']
252 ? prefs['posting:default:visibility']
253 : visibility,
254 );
255 setLanguage(language || prefs.postingDefaultLanguage || DEFAULT_LANG);
256 setSensitive(sensitive && !!spoilerText);
257 } else if (editStatus) {
258 const { visibility, language, sensitive, poll, mediaAttachments } =
259 editStatus;
260 const composablePoll = !!poll?.options && {
261 ...poll,
262 options: poll.options.map((o) => o?.title || o),
263 expiresIn: poll?.expiresIn || expiresInFromExpiresAt(poll.expiresAt),
264 };
265 setUIState('loading');
266 (async () => {
267 try {
268 const statusSource = await masto.v1.statuses
269 .$select(editStatus.id)
270 .source.fetch();
271 console.log({ statusSource });
272 const { text, spoilerText } = statusSource;
273 textareaRef.current.value = text;
274 textareaRef.current.dataset.source = text;
275 oninputTextarea();
276 focusTextarea();
277 spoilerTextRef.current.value = spoilerText;
278 setVisibility(visibility);
279 setLanguage(language || presf.postingDefaultLanguage || DEFAULT_LANG);
280 setSensitive(sensitive);
281 setPoll(composablePoll);
282 setMediaAttachments(mediaAttachments);
283 setUIState('default');
284 } catch (e) {
285 console.error(e);
286 alert(e?.reason || e);
287 setUIState('error');
288 }
289 })();
290 } else {
291 focusTextarea();
292 console.log('Apply prefs', prefs);
293 if (prefs['posting:default:visibility']) {
294 setVisibility(prefs['posting:default:visibility']);
295 }
296 if (prefs['posting:default:language']) {
297 setLanguage(prefs['posting:default:language']);
298 }
299 if (prefs['posting:default:sensitive']) {
300 setSensitive(prefs['posting:default:sensitive']);
301 }
302 }
303 if (draftStatus) {
304 const {
305 status,
306 spoilerText,
307 visibility,
308 language,
309 sensitive,
310 poll,
311 mediaAttachments,
312 } = draftStatus;
313 const composablePoll = !!poll?.options && {
314 ...poll,
315 options: poll.options.map((o) => o?.title || o),
316 expiresIn: poll?.expiresIn || expiresInFromExpiresAt(poll.expiresAt),
317 };
318 textareaRef.current.value = status;
319 oninputTextarea();
320 focusTextarea();
321 if (spoilerText) spoilerTextRef.current.value = spoilerText;
322 if (visibility) setVisibility(visibility);
323 setLanguage(language || prefs.postingDefaultLanguage || DEFAULT_LANG);
324 if (sensitive !== null) setSensitive(sensitive);
325 if (composablePoll) setPoll(composablePoll);
326 if (mediaAttachments) setMediaAttachments(mediaAttachments);
327 }
328 }, [draftStatus, editStatus, replyToStatus]);
329
330 const formRef = useRef();
331
332 const beforeUnloadCopy = 'You have unsaved changes. Discard this post?';
333 const canClose = () => {
334 const { value, dataset } = textareaRef.current;
335
336 // check if loading
337 if (uiState === 'loading') {
338 console.log('canClose', { uiState });
339 return false;
340 }
341
342 // check for status and media attachments
343 const hasMediaAttachments = mediaAttachments.length > 0;
344 if (!value && !hasMediaAttachments) {
345 console.log('canClose', { value, mediaAttachments });
346 return true;
347 }
348
349 // check if all media attachments have IDs
350 const hasIDMediaAttachments =
351 mediaAttachments.length > 0 &&
352 mediaAttachments.every((media) => media.id);
353 if (hasIDMediaAttachments) {
354 console.log('canClose', { hasIDMediaAttachments });
355 return true;
356 }
357
358 // check if status contains only "@acct", if replying
359 const isSelf = replyToStatus?.account.id === currentAccountInfo.id;
360 const hasOnlyAcct =
361 replyToStatus && value.trim() === `@${replyToStatus.account.acct}`;
362 // TODO: check for mentions, or maybe just generic "@username<space>", including multiple mentions like "@username1<space>@username2<space>"
363 if (!isSelf && hasOnlyAcct) {
364 console.log('canClose', { isSelf, hasOnlyAcct });
365 return true;
366 }
367
368 // check if status is same with source
369 const sameWithSource = value === dataset?.source;
370 if (sameWithSource) {
371 console.log('canClose', { sameWithSource });
372 return true;
373 }
374
375 console.log('canClose', {
376 value,
377 hasMediaAttachments,
378 hasIDMediaAttachments,
379 poll,
380 isSelf,
381 hasOnlyAcct,
382 sameWithSource,
383 uiState,
384 });
385
386 return false;
387 };
388
389 const confirmClose = () => {
390 if (!canClose()) {
391 const yes = confirm(beforeUnloadCopy);
392 return yes;
393 }
394 return true;
395 };
396
397 useEffect(() => {
398 // Show warning if user tries to close window with unsaved changes
399 const handleBeforeUnload = (e) => {
400 if (!canClose()) {
401 e.preventDefault();
402 e.returnValue = beforeUnloadCopy;
403 }
404 };
405 window.addEventListener('beforeunload', handleBeforeUnload, {
406 capture: true,
407 });
408 return () =>
409 window.removeEventListener('beforeunload', handleBeforeUnload, {
410 capture: true,
411 });
412 }, []);
413
414 const getCharCount = () => {
415 const { value } = textareaRef.current;
416 const { value: spoilerText } = spoilerTextRef.current;
417 return stringLength(countableText(value)) + stringLength(spoilerText);
418 };
419 const updateCharCount = () => {
420 const count = getCharCount();
421 states.composerCharacterCount = count;
422 };
423 useEffect(updateCharCount, []);
424
425 const supportsCloseWatcher = window.CloseWatcher;
426 const escDownRef = useRef(false);
427 useHotkeys(
428 'esc',
429 () => {
430 escDownRef.current = true;
431 // This won't be true if this event is already handled and not propagated 🤞
432 },
433 {
434 enabled: !supportsCloseWatcher,
435 enableOnFormTags: true,
436 },
437 );
438 useHotkeys(
439 'esc',
440 () => {
441 if (!standalone && escDownRef.current && confirmClose()) {
442 onClose();
443 }
444 escDownRef.current = false;
445 },
446 {
447 enabled: !supportsCloseWatcher,
448 enableOnFormTags: true,
449 // Use keyup because Esc keydown will close the confirm dialog on Safari
450 keyup: true,
451 ignoreEventWhen: (e) => {
452 const modals = document.querySelectorAll('#modal-container > *');
453 const hasModal = !!modals;
454 const hasOnlyComposer =
455 modals.length === 1 && modals[0].querySelector('#compose-container');
456 return hasModal && !hasOnlyComposer;
457 },
458 },
459 );
460 useCloseWatcher(() => {
461 if (!standalone && confirmClose()) {
462 onClose();
463 }
464 }, [standalone, confirmClose, onClose]);
465
466 const prevBackgroundDraft = useRef({});
467 const draftKey = () => {
468 const ns = getCurrentAccountNS();
469 return `${ns}#${UID.current}`;
470 };
471 const saveUnsavedDraft = () => {
472 // Not enabling this for editing status
473 // I don't think this warrant a draft mode for a status that's already posted
474 // Maybe it could be a big edit change but it should be rare
475 if (editStatus) return;
476 const key = draftKey();
477 const backgroundDraft = {
478 key,
479 replyTo: replyToStatus
480 ? {
481 /* Smaller payload of replyToStatus. Reasons:
482 - No point storing whole thing
483 - Could have media attachments
484 - Could be deleted/edited later
485 */
486 id: replyToStatus.id,
487 account: {
488 id: replyToStatus.account.id,
489 username: replyToStatus.account.username,
490 acct: replyToStatus.account.acct,
491 },
492 }
493 : null,
494 draftStatus: {
495 uid: UID.current,
496 status: textareaRef.current.value,
497 spoilerText: spoilerTextRef.current.value,
498 visibility,
499 language,
500 sensitive,
501 poll,
502 mediaAttachments,
503 },
504 };
505 if (!equal(backgroundDraft, prevBackgroundDraft.current) && !canClose()) {
506 console.debug('not equal', backgroundDraft, prevBackgroundDraft.current);
507 db.drafts
508 .set(key, {
509 ...backgroundDraft,
510 state: 'unsaved',
511 updatedAt: Date.now(),
512 })
513 .then(() => {
514 console.debug('DRAFT saved', key, backgroundDraft);
515 })
516 .catch((e) => {
517 console.error('DRAFT failed', key, e);
518 });
519 prevBackgroundDraft.current = structuredClone(backgroundDraft);
520 }
521 };
522 useInterval(saveUnsavedDraft, 5000); // background save every 5s
523 useEffect(() => {
524 saveUnsavedDraft();
525 // If unmounted, means user discarded the draft
526 // Also means pop-out 🙈, but it's okay because the pop-out will persist the ID and re-create the draft
527 return () => {
528 db.drafts.del(draftKey());
529 };
530 }, []);
531
532 useEffect(() => {
533 const handleItems = (e) => {
534 const { items } = e.clipboardData || e.dataTransfer;
535 const files = [];
536 for (let i = 0; i < items.length; i++) {
537 const item = items[i];
538 if (item.kind === 'file') {
539 const file = item.getAsFile();
540 if (file && supportedMimeTypes.includes(file.type)) {
541 files.push(file);
542 }
543 }
544 }
545 if (files.length > 0 && mediaAttachments.length >= maxMediaAttachments) {
546 alert(`You can only attach up to ${maxMediaAttachments} files.`);
547 return;
548 }
549 console.log({ files });
550 if (files.length > 0) {
551 e.preventDefault();
552 e.stopPropagation();
553 // Auto-cut-off files to avoid exceeding maxMediaAttachments
554 const max = maxMediaAttachments - mediaAttachments.length;
555 const allowedFiles = files.slice(0, max);
556 if (allowedFiles.length <= 0) {
557 alert(`You can only attach up to ${maxMediaAttachments} files.`);
558 return;
559 }
560 const mediaFiles = allowedFiles.map((file) => ({
561 file,
562 type: file.type,
563 size: file.size,
564 url: URL.createObjectURL(file),
565 id: null,
566 description: null,
567 }));
568 setMediaAttachments([...mediaAttachments, ...mediaFiles]);
569 }
570 };
571 window.addEventListener('paste', handleItems);
572 const handleDragover = (e) => {
573 // Prevent default if there's files
574 if (e.dataTransfer.items.length > 0) {
575 e.preventDefault();
576 e.stopPropagation();
577 }
578 };
579 window.addEventListener('dragover', handleDragover);
580 window.addEventListener('drop', handleItems);
581 return () => {
582 window.removeEventListener('paste', handleItems);
583 window.removeEventListener('dragover', handleDragover);
584 window.removeEventListener('drop', handleItems);
585 };
586 }, [mediaAttachments]);
587
588 const [showEmoji2Picker, setShowEmoji2Picker] = useState(false);
589
590 const [topSupportedLanguages, restSupportedLanguages] = useMemo(() => {
591 const topLanguages = [];
592 const restLanguages = [];
593 const { contentTranslationHideLanguages = [] } = states.settings;
594 supportedLanguages.forEach((l) => {
595 const [code] = l;
596 if (
597 code === language ||
598 code === prevLanguage.current ||
599 code === DEFAULT_LANG ||
600 contentTranslationHideLanguages.includes(code)
601 ) {
602 topLanguages.push(l);
603 } else {
604 restLanguages.push(l);
605 }
606 });
607 topLanguages.sort(([codeA, commonA], [codeB, commonB]) => {
608 if (codeA === language) return -1;
609 if (codeB === language) return 1;
610 return commonA.localeCompare(commonB);
611 });
612 restLanguages.sort(([codeA, commonA], [codeB, commonB]) =>
613 commonA.localeCompare(commonB),
614 );
615 return [topLanguages, restLanguages];
616 }, [language]);
617
618 return (
619 <div id="compose-container-outer">
620 <div id="compose-container" class={standalone ? 'standalone' : ''}>
621 <div class="compose-top">
622 {currentAccountInfo?.avatarStatic && (
623 // <Avatar
624 // url={currentAccountInfo.avatarStatic}
625 // size="xl"
626 // alt={currentAccountInfo.username}
627 // squircle={currentAccountInfo?.bot}
628 // />
629 <AccountBlock
630 account={currentAccountInfo}
631 accountInstance={currentAccount.instanceURL}
632 hideDisplayName
633 useAvatarStatic
634 />
635 )}
636 {!standalone ? (
637 <span>
638 <button
639 type="button"
640 class="light pop-button"
641 disabled={uiState === 'loading'}
642 onClick={() => {
643 // 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
644 // const containNonIDMediaAttachments =
645 // mediaAttachments.length > 0 &&
646 // mediaAttachments.some((media) => !media.id);
647 // if (containNonIDMediaAttachments) {
648 // const yes = confirm(
649 // '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?',
650 // );
651 // if (!yes) {
652 // return;
653 // }
654 // }
655
656 // const mediaAttachmentsWithIDs = mediaAttachments.filter(
657 // (media) => media.id,
658 // );
659
660 const newWin = openCompose({
661 editStatus,
662 replyToStatus,
663 draftStatus: {
664 uid: UID.current,
665 status: textareaRef.current.value,
666 spoilerText: spoilerTextRef.current.value,
667 visibility,
668 language,
669 sensitive,
670 poll,
671 mediaAttachments,
672 },
673 });
674
675 if (!newWin) {
676 return;
677 }
678
679 onClose();
680 }}
681 >
682 <Icon icon="popout" alt="Pop out" />
683 </button>{' '}
684 <button
685 type="button"
686 class="light close-button"
687 disabled={uiState === 'loading'}
688 onClick={() => {
689 if (confirmClose()) {
690 onClose();
691 }
692 }}
693 >
694 <Icon icon="x" />
695 </button>
696 </span>
697 ) : (
698 hasOpener && (
699 <button
700 type="button"
701 class="light pop-button"
702 disabled={uiState === 'loading'}
703 onClick={() => {
704 // 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
705 // const containNonIDMediaAttachments =
706 // mediaAttachments.length > 0 &&
707 // mediaAttachments.some((media) => !media.id);
708 // if (containNonIDMediaAttachments) {
709 // const yes = confirm(
710 // '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?',
711 // );
712 // if (!yes) {
713 // return;
714 // }
715 // }
716
717 if (!window.opener) {
718 alert('Looks like you closed the parent window.');
719 return;
720 }
721
722 if (window.opener.__STATES__.showCompose) {
723 const yes = confirm(
724 '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?',
725 );
726 if (!yes) return;
727 }
728
729 // const mediaAttachmentsWithIDs = mediaAttachments.filter(
730 // (media) => media.id,
731 // );
732
733 onClose({
734 fn: () => {
735 const passData = {
736 editStatus,
737 replyToStatus,
738 draftStatus: {
739 uid: UID.current,
740 status: textareaRef.current.value,
741 spoilerText: spoilerTextRef.current.value,
742 visibility,
743 language,
744 sensitive,
745 poll,
746 mediaAttachments,
747 },
748 };
749 window.opener.__COMPOSE__ = passData; // Pass it here instead of `showCompose` due to some weird proxy issue again
750 window.opener.__STATES__.showCompose = true;
751 },
752 });
753 }}
754 >
755 <Icon icon="popin" alt="Pop in" />
756 </button>
757 )
758 )}
759 </div>
760 {!!replyToStatus && (
761 <div class="status-preview">
762 <Status status={replyToStatus} size="s" previewMode />
763 <div class="status-preview-legend reply-to">
764 Replying to @
765 {replyToStatus.account.acct || replyToStatus.account.username}
766 ’s post
767 </div>
768 </div>
769 )}
770 {!!editStatus && (
771 <div class="status-preview">
772 <Status status={editStatus} size="s" previewMode />
773 <div class="status-preview-legend">Editing source post</div>
774 </div>
775 )}
776 <form
777 ref={formRef}
778 class={`form-visibility-${visibility}`}
779 style={{
780 pointerEvents: uiState === 'loading' ? 'none' : 'auto',
781 opacity: uiState === 'loading' ? 0.5 : 1,
782 }}
783 onKeyDown={(e) => {
784 if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
785 formRef.current.dispatchEvent(
786 new Event('submit', { cancelable: true }),
787 );
788 }
789 }}
790 onSubmit={(e) => {
791 e.preventDefault();
792
793 const formData = new FormData(e.target);
794 const entries = Object.fromEntries(formData.entries());
795 console.log('ENTRIES', entries);
796 let { status, visibility, sensitive, spoilerText } = entries;
797
798 // Pre-cleanup
799 sensitive = sensitive === 'on'; // checkboxes return "on" if checked
800
801 // Validation
802 /* Let the backend validate this
803 if (stringLength(status) > maxCharacters) {
804 alert(`Status is too long! Max characters: ${maxCharacters}`);
805 return;
806 }
807 if (
808 sensitive &&
809 stringLength(status) + stringLength(spoilerText) > maxCharacters
810 ) {
811 alert(
812 `Status and content warning is too long! Max characters: ${maxCharacters}`,
813 );
814 return;
815 }
816 */
817 if (poll) {
818 if (poll.options.length < 2) {
819 alert('Poll must have at least 2 options');
820 return;
821 }
822 if (poll.options.some((option) => option === '')) {
823 alert('Some poll choices are empty');
824 return;
825 }
826 }
827 // TODO: check for URLs and use `charactersReservedPerUrl` to calculate max characters
828
829 if (mediaAttachments.length > 0) {
830 // If there are media attachments, check if they have no descriptions
831 const hasNoDescriptions = mediaAttachments.some(
832 (media) => !media.description?.trim?.(),
833 );
834 if (hasNoDescriptions) {
835 const yes = confirm(
836 'Some media have no descriptions. Continue?',
837 );
838 if (!yes) return;
839 }
840 }
841
842 // Post-cleanup
843 spoilerText = (sensitive && spoilerText) || undefined;
844 status = status === '' ? undefined : status;
845
846 setUIState('loading');
847 (async () => {
848 try {
849 console.log('MEDIA ATTACHMENTS', mediaAttachments);
850 if (mediaAttachments.length > 0) {
851 // Upload media attachments first
852 const mediaPromises = mediaAttachments.map((attachment) => {
853 const { file, description, id } = attachment;
854 console.log('UPLOADING', attachment);
855 if (id) {
856 // If already uploaded
857 return attachment;
858 } else {
859 const params = removeNullUndefined({
860 file,
861 description,
862 });
863 return masto.v2.media.create(params).then((res) => {
864 if (res.id) {
865 attachment.id = res.id;
866 }
867 return res;
868 });
869 }
870 });
871 const results = await Promise.allSettled(mediaPromises);
872
873 // If any failed, return
874 if (
875 results.some((result) => {
876 return result.status === 'rejected' || !result.value?.id;
877 })
878 ) {
879 setUIState('error');
880 // Alert all the reasons
881 results.forEach((result) => {
882 if (result.status === 'rejected') {
883 console.error(result);
884 alert(result.reason || `Attachment #${i} failed`);
885 }
886 });
887 return;
888 }
889
890 console.log({ results, mediaAttachments });
891 }
892
893 /* NOTE:
894 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?
895 Code: https://github.com/neet/masto.js/blob/dd0d649067b6a2b6e60fbb0a96597c373a255b00/src/serializers/is-object.ts#L2
896
897 // TODO: Note above is no longer true in Masto.js v6. Revisit this.
898 */
899 let params = {
900 status,
901 // spoilerText,
902 spoiler_text: spoilerText,
903 language,
904 sensitive,
905 poll,
906 // mediaIds: mediaAttachments.map((attachment) => attachment.id),
907 media_ids: mediaAttachments.map(
908 (attachment) => attachment.id,
909 ),
910 };
911 if (editStatus && supports('@mastodon/edit-media-attributes')) {
912 params.media_attributes = mediaAttachments.map(
913 (attachment) => {
914 return {
915 id: attachment.id,
916 description: attachment.description,
917 // focus
918 // thumbnail
919 };
920 },
921 );
922 } else if (!editStatus) {
923 params.visibility = visibility;
924 // params.inReplyToId = replyToStatus?.id || undefined;
925 params.in_reply_to_id = replyToStatus?.id || undefined;
926 }
927 params = removeNullUndefined(params);
928 console.log('POST', params);
929
930 let newStatus;
931 if (editStatus) {
932 newStatus = await masto.v1.statuses
933 .$select(editStatus.id)
934 .update(params);
935 saveStatus(newStatus, instance, {
936 skipThreading: true,
937 });
938 } else {
939 try {
940 newStatus = await masto.v1.statuses.create(params, {
941 idempotencyKey: UID.current,
942 });
943 } catch (_) {
944 // If idempotency key fails, try again without it
945 newStatus = await masto.v1.statuses.create(params);
946 }
947 }
948 setUIState('default');
949
950 // Close
951 onClose({
952 // type: post, reply, edit
953 type: editStatus ? 'edit' : replyToStatus ? 'reply' : 'post',
954 newStatus,
955 instance,
956 });
957 } catch (e) {
958 console.error(e);
959 alert(e?.reason || e);
960 setUIState('error');
961 }
962 })();
963 }}
964 >
965 <div class="toolbar stretch">
966 <input
967 ref={spoilerTextRef}
968 type="text"
969 name="spoilerText"
970 placeholder="Content warning"
971 disabled={uiState === 'loading'}
972 class="spoiler-text-field"
973 lang={language}
974 spellCheck="true"
975 dir="auto"
976 style={{
977 opacity: sensitive ? 1 : 0,
978 pointerEvents: sensitive ? 'auto' : 'none',
979 }}
980 onInput={() => {
981 updateCharCount();
982 }}
983 />
984 <label
985 class={`toolbar-button ${sensitive ? 'highlight' : ''}`}
986 title="Content warning or sensitive media"
987 >
988 <input
989 name="sensitive"
990 type="checkbox"
991 checked={sensitive}
992 disabled={uiState === 'loading'}
993 onChange={(e) => {
994 const sensitive = e.target.checked;
995 setSensitive(sensitive);
996 if (sensitive) {
997 spoilerTextRef.current?.focus();
998 } else {
999 textareaRef.current?.focus();
1000 }
1001 }}
1002 />
1003 <Icon icon={`eye-${sensitive ? 'close' : 'open'}`} />
1004 </label>{' '}
1005 <label
1006 class={`toolbar-button ${
1007 visibility !== 'public' && !sensitive ? 'show-field' : ''
1008 } ${visibility !== 'public' ? 'highlight' : ''}`}
1009 title={`Visibility: ${visibility}`}
1010 >
1011 <Icon icon={visibilityIconsMap[visibility]} alt={visibility} />
1012 <select
1013 name="visibility"
1014 value={visibility}
1015 onChange={(e) => {
1016 setVisibility(e.target.value);
1017 }}
1018 disabled={uiState === 'loading' || !!editStatus}
1019 >
1020 <option value="public">
1021 Public <Icon icon="earth" />
1022 </option>
1023 <option value="unlisted">Unlisted</option>
1024 <option value="private">Followers only</option>
1025 <option value="direct">Private mention</option>
1026 </select>
1027 </label>{' '}
1028 </div>
1029 <Textarea
1030 ref={textareaRef}
1031 placeholder={
1032 replyToStatus
1033 ? 'Post your reply'
1034 : editStatus
1035 ? 'Edit your post'
1036 : 'What are you doing?'
1037 }
1038 required={mediaAttachments?.length === 0}
1039 disabled={uiState === 'loading'}
1040 lang={language}
1041 onInput={() => {
1042 updateCharCount();
1043 }}
1044 maxCharacters={maxCharacters}
1045 performSearch={(params) => {
1046 const { type, q, limit } = params;
1047 if (type === 'accounts') {
1048 return masto.v1.accounts.search.list({
1049 q,
1050 limit,
1051 resolve: false,
1052 });
1053 }
1054 return masto.v2.search.fetch(params);
1055 }}
1056 />
1057 {mediaAttachments?.length > 0 && (
1058 <div class="media-attachments">
1059 {mediaAttachments.map((attachment, i) => {
1060 const { id, file } = attachment;
1061 const fileID = file?.size + file?.type + file?.name;
1062 return (
1063 <MediaAttachment
1064 key={id || fileID || i}
1065 attachment={attachment}
1066 disabled={uiState === 'loading'}
1067 lang={language}
1068 onDescriptionChange={(value) => {
1069 setMediaAttachments((attachments) => {
1070 const newAttachments = [...attachments];
1071 newAttachments[i].description = value;
1072 return newAttachments;
1073 });
1074 }}
1075 onRemove={() => {
1076 setMediaAttachments((attachments) => {
1077 return attachments.filter((_, j) => j !== i);
1078 });
1079 }}
1080 />
1081 );
1082 })}
1083 <label class="media-sensitive">
1084 <input
1085 name="sensitive"
1086 type="checkbox"
1087 checked={sensitive}
1088 disabled={uiState === 'loading'}
1089 onChange={(e) => {
1090 const sensitive = e.target.checked;
1091 setSensitive(sensitive);
1092 }}
1093 />{' '}
1094 <span>Mark media as sensitive</span>{' '}
1095 <Icon icon={`eye-${sensitive ? 'close' : 'open'}`} />
1096 </label>
1097 </div>
1098 )}
1099 {!!poll && (
1100 <Poll
1101 lang={language}
1102 maxOptions={maxOptions}
1103 maxExpiration={maxExpiration}
1104 minExpiration={minExpiration}
1105 maxCharactersPerOption={maxCharactersPerOption}
1106 poll={poll}
1107 disabled={uiState === 'loading'}
1108 onInput={(poll) => {
1109 if (poll) {
1110 const newPoll = { ...poll };
1111 setPoll(newPoll);
1112 } else {
1113 setPoll(null);
1114 }
1115 }}
1116 />
1117 )}
1118 <div
1119 class="toolbar wrap"
1120 style={{
1121 justifyContent: 'flex-end',
1122 }}
1123 >
1124 <span>
1125 <label class="toolbar-button">
1126 <input
1127 type="file"
1128 accept={supportedMimeTypes.join(',')}
1129 multiple={mediaAttachments.length < maxMediaAttachments - 1}
1130 disabled={
1131 uiState === 'loading' ||
1132 mediaAttachments.length >= maxMediaAttachments ||
1133 !!poll
1134 }
1135 onChange={(e) => {
1136 const files = e.target.files;
1137 if (!files) return;
1138
1139 const mediaFiles = Array.from(files).map((file) => ({
1140 file,
1141 type: file.type,
1142 size: file.size,
1143 url: URL.createObjectURL(file),
1144 id: null, // indicate uploaded state
1145 description: null,
1146 }));
1147 console.log('MEDIA ATTACHMENTS', files, mediaFiles);
1148
1149 // Validate max media attachments
1150 if (
1151 mediaAttachments.length + mediaFiles.length >
1152 maxMediaAttachments
1153 ) {
1154 alert(
1155 `You can only attach up to ${maxMediaAttachments} files.`,
1156 );
1157 } else {
1158 setMediaAttachments((attachments) => {
1159 return attachments.concat(mediaFiles);
1160 });
1161 }
1162 // Reset
1163 e.target.value = '';
1164 }}
1165 />
1166 <Icon icon="attachment" />
1167 </label>{' '}
1168 <button
1169 type="button"
1170 class="toolbar-button"
1171 disabled={
1172 uiState === 'loading' || !!poll || !!mediaAttachments.length
1173 }
1174 onClick={() => {
1175 setPoll({
1176 options: ['', ''],
1177 expiresIn: 24 * 60 * 60, // 1 day
1178 multiple: false,
1179 });
1180 }}
1181 >
1182 <Icon icon="poll" alt="Add poll" />
1183 </button>{' '}
1184 <button
1185 type="button"
1186 class="toolbar-button"
1187 disabled={uiState === 'loading'}
1188 onClick={() => {
1189 setShowEmoji2Picker(true);
1190 }}
1191 >
1192 <Icon icon="emoji2" />
1193 </button>
1194 </span>
1195 <div class="spacer" />
1196 {uiState === 'loading' ? (
1197 <Loader abrupt />
1198 ) : (
1199 <CharCountMeter
1200 maxCharacters={maxCharacters}
1201 hidden={uiState === 'loading'}
1202 />
1203 )}
1204 <label
1205 class={`toolbar-button ${
1206 language !== prevLanguage.current ? 'highlight' : ''
1207 }`}
1208 >
1209 <span class="icon-text">
1210 {supportedLanguagesMap[language]?.native}
1211 </span>
1212 <select
1213 name="language"
1214 value={language}
1215 onChange={(e) => {
1216 const { value } = e.target;
1217 setLanguage(value || DEFAULT_LANG);
1218 store.session.set('currentLanguage', value || DEFAULT_LANG);
1219 }}
1220 disabled={uiState === 'loading'}
1221 >
1222 {topSupportedLanguages.map(([code, common, native]) => (
1223 <option value={code} key={code}>
1224 {common} ({native})
1225 </option>
1226 ))}
1227 <hr />
1228 {restSupportedLanguages.map(([code, common, native]) => (
1229 <option value={code} key={code}>
1230 {common} ({native})
1231 </option>
1232 ))}
1233 </select>
1234 </label>{' '}
1235 <button
1236 type="submit"
1237 class="large"
1238 disabled={uiState === 'loading'}
1239 >
1240 {replyToStatus ? 'Reply' : editStatus ? 'Update' : 'Post'}
1241 </button>
1242 </div>
1243 </form>
1244 </div>
1245 {showEmoji2Picker && (
1246 <Modal
1247 class="light"
1248 onClick={(e) => {
1249 if (e.target === e.currentTarget) {
1250 setShowEmoji2Picker(false);
1251 }
1252 }}
1253 >
1254 <CustomEmojisModal
1255 masto={masto}
1256 instance={instance}
1257 onClose={() => {
1258 setShowEmoji2Picker(false);
1259 }}
1260 onSelect={(emoji) => {
1261 const emojiWithSpace = ` ${emoji} `;
1262 const textarea = textareaRef.current;
1263 if (!textarea) return;
1264 const { selectionStart, selectionEnd } = textarea;
1265 const text = textarea.value;
1266 const newText =
1267 text.slice(0, selectionStart) +
1268 emojiWithSpace +
1269 text.slice(selectionEnd);
1270 textarea.value = newText;
1271 textarea.selectionStart = textarea.selectionEnd =
1272 selectionEnd + emojiWithSpace.length;
1273 textarea.focus();
1274 textarea.dispatchEvent(new Event('input'));
1275 }}
1276 />
1277 </Modal>
1278 )}
1279 </div>
1280 );
1281}
1282
1283function autoResizeTextarea(textarea) {
1284 if (!textarea) return;
1285 const { value, offsetHeight, scrollHeight, clientHeight } = textarea;
1286 if (offsetHeight < window.innerHeight) {
1287 // NOTE: This check is needed because the offsetHeight return 50000 (really large number) on first render
1288 // No idea why it does that, will re-investigate in far future
1289 const offset = offsetHeight - clientHeight;
1290 const height = value ? scrollHeight + offset + 'px' : null;
1291 textarea.style.height = height;
1292 }
1293}
1294
1295const Textarea = forwardRef((props, ref) => {
1296 const { masto } = api();
1297 const [text, setText] = useState(ref.current?.value || '');
1298 const { maxCharacters, performSearch = () => {}, ...textareaProps } = props;
1299 // const snapStates = useSnapshot(states);
1300 // const charCount = snapStates.composerCharacterCount;
1301
1302 const customEmojis = useRef();
1303 useEffect(() => {
1304 (async () => {
1305 try {
1306 const emojis = await masto.v1.customEmojis.list();
1307 console.log({ emojis });
1308 customEmojis.current = emojis;
1309 } catch (e) {
1310 // silent fail
1311 console.error(e);
1312 }
1313 })();
1314 }, []);
1315
1316 const textExpanderRef = useRef();
1317 const textExpanderTextRef = useRef('');
1318 useEffect(() => {
1319 let handleChange, handleValue, handleCommited;
1320 if (textExpanderRef.current) {
1321 handleChange = (e) => {
1322 // console.log('text-expander-change', e);
1323 const { key, provide, text } = e.detail;
1324 textExpanderTextRef.current = text;
1325
1326 if (text === '') {
1327 provide(
1328 Promise.resolve({
1329 matched: false,
1330 }),
1331 );
1332 return;
1333 }
1334
1335 if (key === ':') {
1336 // const emojis = customEmojis.current.filter((emoji) =>
1337 // emoji.shortcode.startsWith(text),
1338 // );
1339 const emojis = filterShortcodes(customEmojis.current, text);
1340 let html = '';
1341 emojis.forEach((emoji) => {
1342 const { shortcode, url } = emoji;
1343 html += `
1344 <li role="option" data-value="${encodeHTML(shortcode)}">
1345 <img src="${encodeHTML(
1346 url,
1347 )}" width="16" height="16" alt="" loading="lazy" />
1348 :${encodeHTML(shortcode)}:
1349 </li>`;
1350 });
1351 // console.log({ emojis, html });
1352 menu.innerHTML = html;
1353 provide(
1354 Promise.resolve({
1355 matched: emojis.length > 0,
1356 fragment: menu,
1357 }),
1358 );
1359 return;
1360 }
1361
1362 const type = {
1363 '@': 'accounts',
1364 '#': 'hashtags',
1365 }[key];
1366 provide(
1367 new Promise((resolve) => {
1368 const searchResults = performSearch({
1369 type,
1370 q: text,
1371 limit: 5,
1372 });
1373 searchResults.then((value) => {
1374 if (text !== textExpanderTextRef.current) {
1375 return;
1376 }
1377 console.log({ value, type, v: value[type] });
1378 const results = value[type] || value;
1379 console.log('RESULTS', value, results);
1380 let html = '';
1381 results.forEach((result) => {
1382 const {
1383 name,
1384 avatarStatic,
1385 displayName,
1386 username,
1387 acct,
1388 emojis,
1389 history,
1390 } = result;
1391 const displayNameWithEmoji = emojifyText(displayName, emojis);
1392 // const item = menuItem.cloneNode();
1393 if (acct) {
1394 html += `
1395 <li role="option" data-value="${encodeHTML(acct)}">
1396 <span class="avatar">
1397 <img src="${encodeHTML(
1398 avatarStatic,
1399 )}" width="16" height="16" alt="" loading="lazy" />
1400 </span>
1401 <span>
1402 <b>${displayNameWithEmoji || username}</b>
1403 <br>@${encodeHTML(acct)}
1404 </span>
1405 </li>
1406 `;
1407 } else {
1408 const total = history?.reduce?.(
1409 (acc, cur) => acc + +cur.uses,
1410 0,
1411 );
1412 html += `
1413 <li role="option" data-value="${encodeHTML(name)}">
1414 <span class="grow">#<b>${encodeHTML(name)}</b></span>
1415 ${
1416 total
1417 ? `<span class="count">${shortenNumber(total)}</span>`
1418 : ''
1419 }
1420 </li>
1421 `;
1422 }
1423 menu.innerHTML = html;
1424 });
1425 console.log('MENU', results, menu);
1426 resolve({
1427 matched: results.length > 0,
1428 fragment: menu,
1429 });
1430 });
1431 }),
1432 );
1433 };
1434
1435 textExpanderRef.current.addEventListener(
1436 'text-expander-change',
1437 handleChange,
1438 );
1439
1440 handleValue = (e) => {
1441 const { key, item } = e.detail;
1442 if (key === ':') {
1443 e.detail.value = `:${item.dataset.value}:`;
1444 } else {
1445 e.detail.value = `${key}${item.dataset.value}`;
1446 }
1447 };
1448
1449 textExpanderRef.current.addEventListener(
1450 'text-expander-value',
1451 handleValue,
1452 );
1453
1454 handleCommited = (e) => {
1455 const { input } = e.detail;
1456 setText(input.value);
1457 // fire input event
1458 if (ref.current) {
1459 const event = new Event('input', { bubbles: true });
1460 ref.current.dispatchEvent(event);
1461 }
1462 };
1463
1464 textExpanderRef.current.addEventListener(
1465 'text-expander-committed',
1466 handleCommited,
1467 );
1468 }
1469
1470 return () => {
1471 if (textExpanderRef.current) {
1472 textExpanderRef.current.removeEventListener(
1473 'text-expander-change',
1474 handleChange,
1475 );
1476 textExpanderRef.current.removeEventListener(
1477 'text-expander-value',
1478 handleValue,
1479 );
1480 textExpanderRef.current.removeEventListener(
1481 'text-expander-committed',
1482 handleCommited,
1483 );
1484 }
1485 };
1486 }, []);
1487
1488 useEffect(() => {
1489 // Resize observer for textarea
1490 const textarea = ref.current;
1491 if (!textarea) return;
1492 const resizeObserver = new ResizeObserver(() => {
1493 // Get height of textarea, set height to textExpander
1494 if (textExpanderRef.current) {
1495 const { height } = textarea.getBoundingClientRect();
1496 textExpanderRef.current.style.height = height + 'px';
1497 }
1498 });
1499 resizeObserver.observe(textarea);
1500 }, []);
1501
1502 const slowHighlightPerf = useRef(0); // increment if slow
1503 const composeHighlightRef = useRef();
1504 const throttleHighlightText = useThrottledCallback((text) => {
1505 if (!composeHighlightRef.current) return;
1506 if (slowHighlightPerf.current > 3) {
1507 // After 3 times of lag, disable highlighting
1508 composeHighlightRef.current.innerHTML = '';
1509 composeHighlightRef.current = null; // Destroy the whole thing
1510 throttleHighlightText?.cancel?.();
1511 return;
1512 }
1513 let start;
1514 let end;
1515 if (slowHighlightPerf.current <= 3) start = Date.now();
1516 composeHighlightRef.current.innerHTML =
1517 highlightText(text, {
1518 maxCharacters,
1519 }) + '\n';
1520 if (slowHighlightPerf.current <= 3) end = Date.now();
1521 console.debug('HIGHLIGHT PERF', { start, end, diff: end - start });
1522 if (start && end && end - start > 50) {
1523 // if slow, increment
1524 slowHighlightPerf.current++;
1525 }
1526 // Newline to prevent multiple line breaks at the end from being collapsed, no idea why
1527 }, 500);
1528
1529 return (
1530 <text-expander
1531 ref={textExpanderRef}
1532 keys="@ # :"
1533 class="compose-field-container"
1534 >
1535 <textarea
1536 class="compose-field"
1537 autoCapitalize="sentences"
1538 autoComplete="on"
1539 autoCorrect="on"
1540 spellCheck="true"
1541 dir="auto"
1542 rows="6"
1543 cols="50"
1544 {...textareaProps}
1545 ref={ref}
1546 name="status"
1547 value={text}
1548 onKeyDown={(e) => {
1549 // Get line before cursor position after pressing 'Enter'
1550 const { key, target } = e;
1551 if (key === 'Enter') {
1552 try {
1553 const { value, selectionStart } = target;
1554 const textBeforeCursor = value.slice(0, selectionStart);
1555 const lastLine = textBeforeCursor.split('\n').slice(-1)[0];
1556 if (lastLine) {
1557 // If line starts with "- " or "12. "
1558 if (/^\s*(-|\d+\.)\s/.test(lastLine)) {
1559 // insert "- " at cursor position
1560 const [_, preSpaces, bullet, postSpaces, anything] =
1561 lastLine.match(/^(\s*)(-|\d+\.)(\s+)(.+)?/) || [];
1562 if (anything) {
1563 e.preventDefault();
1564 const [number] = bullet.match(/\d+/) || [];
1565 const newBullet = number ? `${+number + 1}.` : '-';
1566 const text = `\n${preSpaces}${newBullet}${postSpaces}`;
1567 target.setRangeText(text, selectionStart, selectionStart);
1568 const pos = selectionStart + text.length;
1569 target.setSelectionRange(pos, pos);
1570 } else {
1571 // trim the line before the cursor, then insert new line
1572 const pos = selectionStart - lastLine.length;
1573 target.setRangeText('', pos, selectionStart);
1574 }
1575 autoResizeTextarea(target);
1576 target.dispatchEvent(new Event('input'));
1577 }
1578 }
1579 } catch (e) {
1580 // silent fail
1581 console.error(e);
1582 }
1583 }
1584 if (composeHighlightRef.current) {
1585 composeHighlightRef.current.scrollTop = target.scrollTop;
1586 }
1587 }}
1588 onInput={(e) => {
1589 const { target } = e;
1590 const text = target.value;
1591 setText(text);
1592 autoResizeTextarea(target);
1593 props.onInput?.(e);
1594 throttleHighlightText(text);
1595 }}
1596 style={{
1597 width: '100%',
1598 height: '4em',
1599 // '--text-weight': (1 + charCount / 140).toFixed(1) || 1,
1600 }}
1601 onScroll={(e) => {
1602 if (composeHighlightRef.current) {
1603 const { scrollTop } = e.target;
1604 composeHighlightRef.current.scrollTop = scrollTop;
1605 }
1606 }}
1607 />
1608 <div
1609 ref={composeHighlightRef}
1610 class="compose-highlight"
1611 aria-hidden="true"
1612 />
1613 </text-expander>
1614 );
1615});
1616
1617function CharCountMeter({ maxCharacters = 500, hidden }) {
1618 const snapStates = useSnapshot(states);
1619 const charCount = snapStates.composerCharacterCount;
1620 const leftChars = maxCharacters - charCount;
1621 if (hidden) {
1622 return <meter class="donut" hidden />;
1623 }
1624 return (
1625 <meter
1626 class={`donut ${
1627 leftChars <= -10
1628 ? 'explode'
1629 : leftChars <= 0
1630 ? 'danger'
1631 : leftChars <= 20
1632 ? 'warning'
1633 : ''
1634 }`}
1635 value={charCount}
1636 max={maxCharacters}
1637 data-left={leftChars}
1638 title={`${leftChars}/${maxCharacters}`}
1639 style={{
1640 '--percentage': (charCount / maxCharacters) * 100,
1641 }}
1642 />
1643 );
1644}
1645
1646function MediaAttachment({
1647 attachment,
1648 disabled,
1649 lang,
1650 onDescriptionChange = () => {},
1651 onRemove = () => {},
1652}) {
1653 const [uiState, setUIState] = useState('default');
1654 const supportsEdit = supports('@mastodon/edit-media-attributes');
1655 const { type, id, file } = attachment;
1656 const url = useMemo(
1657 () => (file ? URL.createObjectURL(file) : attachment.url),
1658 [file, attachment.url],
1659 );
1660 console.log({ attachment });
1661 const [description, setDescription] = useState(attachment.description);
1662 const [suffixType, subtype] = type.split('/');
1663 const debouncedOnDescriptionChange = useDebouncedCallback(
1664 onDescriptionChange,
1665 250,
1666 );
1667
1668 const [showModal, setShowModal] = useState(false);
1669 const textareaRef = useRef(null);
1670 useEffect(() => {
1671 let timer;
1672 if (showModal && textareaRef.current) {
1673 timer = setTimeout(() => {
1674 textareaRef.current.focus();
1675 }, 100);
1676 }
1677 return () => {
1678 clearTimeout(timer);
1679 };
1680 }, [showModal]);
1681
1682 const descTextarea = (
1683 <>
1684 {!!id && !supportsEdit ? (
1685 <div class="media-desc">
1686 <span class="tag">Uploaded</span>
1687 <p title={description}>
1688 {attachment.description || <i>No description</i>}
1689 </p>
1690 </div>
1691 ) : (
1692 <textarea
1693 ref={textareaRef}
1694 value={description || ''}
1695 lang={lang}
1696 placeholder={
1697 {
1698 image: 'Image description',
1699 video: 'Video description',
1700 audio: 'Audio description',
1701 }[suffixType]
1702 }
1703 autoCapitalize="sentences"
1704 autoComplete="on"
1705 autoCorrect="on"
1706 spellCheck="true"
1707 dir="auto"
1708 disabled={disabled || uiState === 'loading'}
1709 class={uiState === 'loading' ? 'loading' : ''}
1710 maxlength="1500" // Not unicode-aware :(
1711 // TODO: Un-hard-code this maxlength, ref: https://github.com/mastodon/mastodon/blob/b59fb28e90bc21d6fd1a6bafd13cfbd81ab5be54/app/models/media_attachment.rb#L39
1712 onInput={(e) => {
1713 const { value } = e.target;
1714 setDescription(value);
1715 debouncedOnDescriptionChange(value);
1716 }}
1717 ></textarea>
1718 )}
1719 </>
1720 );
1721
1722 const toastRef = useRef(null);
1723 useEffect(() => {
1724 return () => {
1725 toastRef.current?.hideToast?.();
1726 };
1727 }, []);
1728
1729 return (
1730 <>
1731 <div class="media-attachment">
1732 <div
1733 class="media-preview"
1734 tabIndex="0"
1735 onClick={() => {
1736 setShowModal(true);
1737 }}
1738 >
1739 {suffixType === 'image' ? (
1740 <img src={url} alt="" />
1741 ) : suffixType === 'video' || suffixType === 'gifv' ? (
1742 <video src={url} playsinline muted />
1743 ) : suffixType === 'audio' ? (
1744 <audio src={url} controls />
1745 ) : null}
1746 </div>
1747 {descTextarea}
1748 <div class="media-aside">
1749 <button
1750 type="button"
1751 class="plain close-button"
1752 disabled={disabled}
1753 onClick={onRemove}
1754 >
1755 <Icon icon="x" />
1756 </button>
1757 </div>
1758 </div>
1759 {showModal && (
1760 <Modal
1761 class="light"
1762 onClick={(e) => {
1763 if (e.target === e.currentTarget) {
1764 setShowModal(false);
1765 }
1766 }}
1767 >
1768 <div id="media-sheet" class="sheet sheet-max">
1769 <button
1770 type="button"
1771 class="sheet-close"
1772 onClick={() => {
1773 setShowModal(false);
1774 }}
1775 >
1776 <Icon icon="x" />
1777 </button>
1778 <header>
1779 <h2>
1780 {
1781 {
1782 image: 'Edit image description',
1783 video: 'Edit video description',
1784 audio: 'Edit audio description',
1785 }[suffixType]
1786 }
1787 </h2>
1788 </header>
1789 <main tabIndex="-1">
1790 <div class="media-preview">
1791 {suffixType === 'image' ? (
1792 <img src={url} alt="" />
1793 ) : suffixType === 'video' || suffixType === 'gifv' ? (
1794 <video src={url} playsinline controls />
1795 ) : suffixType === 'audio' ? (
1796 <audio src={url} controls />
1797 ) : null}
1798 </div>
1799 <div class="media-form">
1800 {descTextarea}
1801 <footer>
1802 {suffixType === 'image' &&
1803 /^(png|jpe?g|gif|webp)$/i.test(subtype) &&
1804 !!states.settings.mediaAltGenerator &&
1805 !!IMG_ALT_API_URL && (
1806 <Menu2
1807 portal={{
1808 target: document.body,
1809 }}
1810 containerProps={{
1811 style: {
1812 zIndex: 1001,
1813 },
1814 }}
1815 align="center"
1816 position="anchor"
1817 overflow="auto"
1818 menuButton={
1819 <button type="button" title="More" class="plain">
1820 <Icon icon="more" size="l" alt="More" />
1821 </button>
1822 }
1823 >
1824 <MenuItem
1825 disabled={uiState === 'loading'}
1826 onClick={() => {
1827 setUIState('loading');
1828 toastRef.current = showToast({
1829 text: 'Generating description. Please wait...',
1830 duration: -1,
1831 });
1832 // POST with multipart
1833 (async function () {
1834 try {
1835 const body = new FormData();
1836 body.append('image', file);
1837 const response = await fetch(IMG_ALT_API_URL, {
1838 method: 'POST',
1839 body,
1840 }).then((r) => r.json());
1841 setDescription(response.description);
1842 } catch (e) {
1843 console.error(e);
1844 showToast('Failed to generate description');
1845 } finally {
1846 setUIState('default');
1847 toastRef.current?.hideToast?.();
1848 }
1849 })();
1850 }}
1851 >
1852 <Icon icon="sparkles2" />
1853 <span>Generate description…</span>
1854 </MenuItem>
1855 </Menu2>
1856 )}
1857 <button
1858 type="button"
1859 class="light block"
1860 onClick={() => {
1861 setShowModal(false);
1862 }}
1863 disabled={uiState === 'loading'}
1864 >
1865 Done
1866 </button>
1867 </footer>
1868 </div>
1869 </main>
1870 </div>
1871 </Modal>
1872 )}
1873 </>
1874 );
1875}
1876
1877function Poll({
1878 lang,
1879 poll,
1880 disabled,
1881 onInput = () => {},
1882 maxOptions,
1883 maxExpiration,
1884 minExpiration,
1885 maxCharactersPerOption,
1886}) {
1887 const { options, expiresIn, multiple } = poll;
1888
1889 return (
1890 <div class={`poll ${multiple ? 'multiple' : ''}`}>
1891 <div class="poll-choices">
1892 {options.map((option, i) => (
1893 <div class="poll-choice" key={i}>
1894 <input
1895 required
1896 type="text"
1897 value={option}
1898 disabled={disabled}
1899 maxlength={maxCharactersPerOption}
1900 placeholder={`Choice ${i + 1}`}
1901 lang={lang}
1902 spellCheck="true"
1903 dir="auto"
1904 onInput={(e) => {
1905 const { value } = e.target;
1906 options[i] = value;
1907 onInput(poll);
1908 }}
1909 />
1910 <button
1911 type="button"
1912 class="plain2 poll-button"
1913 disabled={disabled || options.length <= 1}
1914 onClick={() => {
1915 options.splice(i, 1);
1916 onInput(poll);
1917 }}
1918 >
1919 <Icon icon="x" size="s" />
1920 </button>
1921 </div>
1922 ))}
1923 </div>
1924 <div class="poll-toolbar">
1925 <button
1926 type="button"
1927 class="plain2 poll-button"
1928 disabled={disabled || options.length >= maxOptions}
1929 onClick={() => {
1930 options.push('');
1931 onInput(poll);
1932 }}
1933 >
1934 +
1935 </button>{' '}
1936 <label class="multiple-choices">
1937 <input
1938 type="checkbox"
1939 checked={multiple}
1940 disabled={disabled}
1941 onChange={(e) => {
1942 const { checked } = e.target;
1943 poll.multiple = checked;
1944 onInput(poll);
1945 }}
1946 />{' '}
1947 Multiple choices
1948 </label>
1949 <label class="expires-in">
1950 Duration{' '}
1951 <select
1952 value={expiresIn}
1953 disabled={disabled}
1954 onChange={(e) => {
1955 const { value } = e.target;
1956 poll.expiresIn = value;
1957 onInput(poll);
1958 }}
1959 >
1960 {Object.entries(expiryOptions)
1961 .filter(([label, value]) => {
1962 return value >= minExpiration && value <= maxExpiration;
1963 })
1964 .map(([label, value]) => (
1965 <option value={value} key={value}>
1966 {label}
1967 </option>
1968 ))}
1969 </select>
1970 </label>
1971 </div>
1972 <div class="poll-toolbar">
1973 <button
1974 type="button"
1975 class="plain remove-poll-button"
1976 disabled={disabled}
1977 onClick={() => {
1978 onInput(null);
1979 }}
1980 >
1981 Remove poll
1982 </button>
1983 </div>
1984 </div>
1985 );
1986}
1987
1988function filterShortcodes(emojis, searchTerm) {
1989 searchTerm = searchTerm.toLowerCase();
1990
1991 // Return an array of shortcodes that start with or contain the search term, sorted by relevance and limited to the first 5
1992 return emojis
1993 .sort((a, b) => {
1994 let aLower = a.shortcode.toLowerCase();
1995 let bLower = b.shortcode.toLowerCase();
1996
1997 let aStartsWith = aLower.startsWith(searchTerm);
1998 let bStartsWith = bLower.startsWith(searchTerm);
1999 let aContains = aLower.includes(searchTerm);
2000 let bContains = bLower.includes(searchTerm);
2001 let bothStartWith = aStartsWith && bStartsWith;
2002 let bothContain = aContains && bContains;
2003
2004 return bothStartWith
2005 ? a.length - b.length
2006 : aStartsWith
2007 ? -1
2008 : bStartsWith
2009 ? 1
2010 : bothContain
2011 ? a.length - b.length
2012 : aContains
2013 ? -1
2014 : bContains
2015 ? 1
2016 : 0;
2017 })
2018 .slice(0, 5);
2019}
2020
2021function encodeHTML(str) {
2022 return str.replace(/[&<>"']/g, function (char) {
2023 return '&#' + char.charCodeAt(0) + ';';
2024 });
2025}
2026
2027function removeNullUndefined(obj) {
2028 for (let key in obj) {
2029 if (obj[key] === null || obj[key] === undefined) {
2030 delete obj[key];
2031 }
2032 }
2033 return obj;
2034}
2035
2036function CustomEmojisModal({
2037 masto,
2038 instance,
2039 onClose = () => {},
2040 onSelect = () => {},
2041}) {
2042 const [uiState, setUIState] = useState('default');
2043 const customEmojisList = useRef([]);
2044 const [customEmojis, setCustomEmojis] = useState({});
2045 const recentlyUsedCustomEmojis = useMemo(
2046 () => store.account.get('recentlyUsedCustomEmojis') || [],
2047 );
2048 useEffect(() => {
2049 setUIState('loading');
2050 (async () => {
2051 try {
2052 const emojis = await masto.v1.customEmojis.list();
2053 // Group emojis by category
2054 const emojisCat = {
2055 '--recent--': recentlyUsedCustomEmojis.filter((emoji) =>
2056 emojis.find((e) => e.shortcode === emoji.shortcode),
2057 ),
2058 };
2059 const othersCat = [];
2060 emojis.forEach((emoji) => {
2061 if (!emoji.visibleInPicker) return;
2062 customEmojisList.current?.push?.(emoji);
2063 if (!emoji.category) {
2064 othersCat.push(emoji);
2065 return;
2066 }
2067 if (!emojisCat[emoji.category]) {
2068 emojisCat[emoji.category] = [];
2069 }
2070 emojisCat[emoji.category].push(emoji);
2071 });
2072 if (othersCat.length) {
2073 emojisCat['--others--'] = othersCat;
2074 }
2075 setCustomEmojis(emojisCat);
2076 setUIState('default');
2077 } catch (e) {
2078 setUIState('error');
2079 console.error(e);
2080 }
2081 })();
2082 }, []);
2083
2084 return (
2085 <div id="custom-emojis-sheet" class="sheet">
2086 {!!onClose && (
2087 <button type="button" class="sheet-close" onClick={onClose}>
2088 <Icon icon="x" />
2089 </button>
2090 )}
2091 <header>
2092 <b>Custom emojis</b>{' '}
2093 {uiState === 'loading' ? (
2094 <Loader />
2095 ) : (
2096 <small class="insignificant"> • {instance}</small>
2097 )}
2098 </header>
2099 <main>
2100 <div class="custom-emojis-list">
2101 {uiState === 'error' && (
2102 <div class="ui-state">
2103 <p>Error loading custom emojis</p>
2104 </div>
2105 )}
2106 {uiState === 'default' &&
2107 Object.entries(customEmojis).map(
2108 ([category, emojis]) =>
2109 !!emojis?.length && (
2110 <>
2111 <div class="section-header">
2112 {{
2113 '--recent--': 'Recently used',
2114 '--others--': 'Others',
2115 }[category] || category}
2116 </div>
2117 <section>
2118 {emojis.map((emoji) => (
2119 <button
2120 key={emoji}
2121 type="button"
2122 class="plain4"
2123 onClick={() => {
2124 onClose();
2125 requestAnimationFrame(() => {
2126 onSelect(`:${emoji.shortcode}:`);
2127 });
2128 let recentlyUsedCustomEmojis =
2129 store.account.get('recentlyUsedCustomEmojis') ||
2130 [];
2131 const recentlyUsedEmojiIndex =
2132 recentlyUsedCustomEmojis.findIndex(
2133 (e) => e.shortcode === emoji.shortcode,
2134 );
2135 if (recentlyUsedEmojiIndex !== -1) {
2136 // Move emoji to index 0
2137 recentlyUsedCustomEmojis.splice(
2138 recentlyUsedEmojiIndex,
2139 1,
2140 );
2141 recentlyUsedCustomEmojis.unshift(emoji);
2142 } else {
2143 recentlyUsedCustomEmojis.unshift(emoji);
2144 // Remove unavailable ones
2145 recentlyUsedCustomEmojis =
2146 recentlyUsedCustomEmojis.filter((e) =>
2147 customEmojisList.current?.find?.(
2148 (emoji) => emoji.shortcode === e.shortcode,
2149 ),
2150 );
2151 // Limit to 10
2152 recentlyUsedCustomEmojis =
2153 recentlyUsedCustomEmojis.slice(0, 10);
2154 }
2155
2156 // Store back
2157 store.account.set(
2158 'recentlyUsedCustomEmojis',
2159 recentlyUsedCustomEmojis,
2160 );
2161 }}
2162 title={`:${emoji.shortcode}:`}
2163 >
2164 <picture>
2165 {!!emoji.staticUrl && (
2166 <source
2167 srcset={emoji.staticUrl}
2168 media="(prefers-reduced-motion: reduce)"
2169 />
2170 )}
2171 <img
2172 class="shortcode-emoji"
2173 src={emoji.url || emoji.staticUrl}
2174 alt={emoji.shortcode}
2175 width="16"
2176 height="16"
2177 loading="lazy"
2178 decoding="async"
2179 />
2180 </picture>
2181 </button>
2182 ))}
2183 </section>
2184 </>
2185 ),
2186 )}
2187 </div>
2188 </main>
2189 </div>
2190 );
2191}
2192
2193export default Compose;