this repo has no description
at hotfix/infinite-loop-intersection-observer 2193 lines 74 kB view raw
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 &rsquo;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;