this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 2983 lines 93 kB view raw
1import './status.css'; 2 3import { msg, plural } from '@lingui/core/macro'; 4import { Trans, useLingui } from '@lingui/react/macro'; 5import { ControlledMenu, MenuDivider, MenuItem } from '@szhsin/react-menu'; 6import { shallowEqual } from 'fast-equals'; 7import pThrottle from 'p-throttle'; 8import { Fragment } from 'preact'; 9import { memo } from 'preact/compat'; 10import { 11 useCallback, 12 useContext, 13 useEffect, 14 useMemo, 15 useReducer, 16 useRef, 17 useState, 18} from 'preact/hooks'; 19import punycode from 'punycode/'; 20import { useHotkeys } from 'react-hotkeys-hook'; 21import { useLongPress } from 'use-long-press'; 22import { useSnapshot } from 'valtio'; 23 24import { api, getPreferences } from '../utils/api'; 25import { langDetector } from '../utils/browser-translator'; 26import FilterContext from '../utils/filter-context'; 27import { isFiltered } from '../utils/filters'; 28import getTranslateTargetLanguage from '../utils/get-translate-target-language'; 29import getHTMLText from '../utils/getHTMLText'; 30import htmlContentLength from '../utils/html-content-length'; 31import localeMatch from '../utils/locale-match'; 32import niceDateTime from '../utils/nice-date-time'; 33import openCompose from '../utils/open-compose'; 34import pmem from '../utils/pmem'; 35import RTF from '../utils/relative-time-format'; 36import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding'; 37import shortenNumber from '../utils/shorten-number'; 38import showCompose from '../utils/show-compose'; 39import showToast from '../utils/show-toast'; 40import { speak, supportsTTS } from '../utils/speech'; 41import states, { getStatus, saveStatus, statusKey } from '../utils/states'; 42import statusPeek from '../utils/status-peek'; 43import { getCurrentAccID, getCurrentAccountID } from '../utils/store-utils'; 44import supports from '../utils/supports'; 45import useTruncated from '../utils/useTruncated'; 46import visibilityIconsMap from '../utils/visibility-icons-map'; 47import visibilityText from '../utils/visibility-text'; 48 49import Avatar from './avatar'; 50import CustomEmoji from './custom-emoji'; 51import EmojiText from './emoji-text'; 52import Icon from './icon'; 53import LazyShazam from './lazy-shazam'; 54import Link from './link'; 55import Loader from './loader'; 56import MathBlock from './math-block'; 57import Media, { isMediaCaptionLong } from './media'; 58import MediaFirstContainer from './media-first-container'; 59import MenuConfirm from './menu-confirm'; 60import MenuLink from './menu-link'; 61import Menu2 from './menu2'; 62import Modal from './modal'; 63import MultipleMediaFigure from './multiple-media-figure'; 64import NameText from './name-text'; 65import Poll from './poll'; 66import PostContent from './post-content'; 67import PostEmbedModal from './post-embed-modal'; 68import RelativeTime from './relative-time'; 69import StatusButton from './status-button'; 70import StatusCard from './status-card'; 71import StatusCompact from './status-compact'; 72import ThreadBadge from './thread-badge'; 73import TranslationBlock from './translation-block'; 74 75const SHOW_COMMENT_COUNT_LIMIT = 280; 76const INLINE_TRANSLATE_LIMIT = 140; 77 78const throttle = pThrottle({ 79 limit: 1, 80 interval: 1000, 81}); 82function fetchAccount(id, masto) { 83 return masto.v1.accounts.$select(id).fetch(); 84} 85const memFetchAccount = pmem(throttle(fetchAccount)); 86 87const isIOS = 88 window.ontouchstart !== undefined && 89 /iPad|iPhone|iPod/.test(navigator.userAgent); 90 91const REACTIONS_LIMIT = 80; 92 93function getPollText(poll) { 94 if (!poll?.options?.length) return ''; 95 return `📊:\n${poll.options 96 .map( 97 (option) => 98 `- ${option.title}${ 99 option.votesCount >= 0 ? ` (${option.votesCount})` : '' 100 }`, 101 ) 102 .join('\n')}`; 103} 104function getPostText(status, opts) { 105 const { maskCustomEmojis, maskURLs } = opts || {}; 106 const { spoilerText, poll, emojis } = status; 107 let { content } = status; 108 if (maskCustomEmojis && emojis?.length) { 109 const emojisRegex = new RegExp( 110 `:(${emojis.map((e) => e.shortcode).join('|')}):`, 111 'g', 112 ); 113 content = content.replace(emojisRegex, '⬚'); 114 } 115 return ( 116 (spoilerText ? `${spoilerText}\n\n` : '') + 117 getHTMLText(content, { 118 preProcess: 119 maskURLs && 120 ((dom) => { 121 // Remove links that contains text that starts with https?:// 122 for (const a of dom.querySelectorAll('a')) { 123 const text = a.innerText.trim(); 124 if (/^https?:\/\//i.test(text)) { 125 a.replaceWith('«🔗»'); 126 } 127 } 128 }), 129 }) + 130 getPollText(poll) 131 ); 132} 133 134function forgivingQSA(selectors = [], dom = document) { 135 // Run QSA for list of selectors 136 // If a selector return invalid selector error, try the next one 137 for (const selector of selectors) { 138 try { 139 return dom.querySelectorAll(selector); 140 } catch (e) {} 141 } 142 return []; 143} 144 145function isTranslateble(content, emojis) { 146 if (!content) return false; 147 // Remove custom emojis 148 if (emojis?.length) { 149 const emojisRegex = new RegExp( 150 `:(${emojis.map((e) => e.shortcode).join('|')}):`, 151 'g', 152 ); 153 content = content.replace(emojisRegex, ''); 154 } 155 content = content.trim(); 156 if (!content) return false; 157 const text = getHTMLText(content, { 158 preProcess: (dom) => { 159 // Remove .mention, pre, code, a:has(.invisible) 160 for (const a of forgivingQSA( 161 ['.mention, pre, code, a:has(.invisible)', '.mention, pre, code'], 162 dom, 163 )) { 164 a.remove(); 165 } 166 }, 167 }); 168 return !!text; 169} 170 171function getHTMLTextForDetectLang(content, emojis) { 172 if (emojis?.length) { 173 const emojisRegex = new RegExp( 174 `:(${emojis.map((e) => e.shortcode).join('|')}):`, 175 'g', 176 ); 177 content = content.replace(emojisRegex, ''); 178 } 179 180 return getHTMLText(content, { 181 preProcess: (dom) => { 182 // Remove anything that can skew the language detection 183 184 // Remove .mention, .hashtag, pre, code, a:has(.invisible) 185 for (const a of forgivingQSA( 186 [ 187 '.mention, .hashtag, pre, code, a:has(.invisible)', 188 '.mention, .hashtag, pre, code', 189 ], 190 dom, 191 )) { 192 a.remove(); 193 } 194 195 // Remove links that contains text that starts with https?:// 196 for (const a of dom.querySelectorAll('a')) { 197 const text = a.innerText.trim(); 198 if (text.startsWith('https://') || text.startsWith('http://')) { 199 a.remove(); 200 } 201 } 202 }, 203 }); 204} 205 206const SIZE_CLASS = { 207 s: 'small', 208 m: 'medium', 209 l: 'large', 210}; 211 212const detectLang = pmem(async (text) => { 213 text = text?.trim(); 214 215 // Ref: https://github.com/komodojp/tinyld/blob/develop/docs/benchmark.md 216 // 500 should be enough for now, also the default max chars for Mastodon 217 if (text?.length > 500) { 218 return null; 219 } 220 221 if (langDetector) { 222 const langs = await langDetector.detect(text); 223 console.groupCollapsed( 224 '💬 DETECTLANG BROWSER', 225 langs.slice(0, 3).map((l) => l.detectedLanguage), 226 ); 227 console.log(text, langs.slice(0, 3)); 228 console.groupEnd(); 229 const lang = langs[0]; 230 if (lang?.detectedLanguage && lang?.confidence > 0.5) { 231 return lang.detectedLanguage; 232 } 233 } 234 235 const { detectAll } = await import('tinyld/light'); 236 const langs = detectAll(text); 237 console.groupCollapsed( 238 '💬 DETECTLANG TINYLD', 239 langs.slice(0, 3).map((l) => l.lang), 240 ); 241 console.log(text, langs.slice(0, 3)); 242 console.groupEnd(); 243 const lang = langs[0]; 244 if (lang?.lang && lang?.accuracy > 0.5) { 245 // If > 50% accurate, use it 246 // It can be accurate if < 50% but better be safe 247 // Though > 50% also can be inaccurate 🤷‍♂️ 248 return lang.lang; 249 } 250 return null; 251}); 252 253const readMoreText = msg`Read more →`; 254 255// All this work just to make sure this only lazy-run once 256// Because first run is slow due to intl-localematcher 257const DIFFERENT_LANG_CHECK = {}; 258const diffLangCheckCacheKey = (l, hls) => `${l}:${hls.join('|')}`; 259const checkDifferentLanguage = ( 260 language, 261 contentTranslationHideLanguages = [], 262) => { 263 if (!language) return false; 264 const cacheKey = diffLangCheckCacheKey( 265 language, 266 contentTranslationHideLanguages, 267 ); 268 const targetLanguage = getTranslateTargetLanguage(true); 269 const different = 270 language !== targetLanguage && 271 !localeMatch([language], [targetLanguage]) && 272 !contentTranslationHideLanguages.find( 273 (l) => language === l || localeMatch([language], [l]), 274 ); 275 if (different) { 276 DIFFERENT_LANG_CHECK[cacheKey] = true; 277 } 278 return different; 279}; 280 281function Status({ 282 statusID, 283 status, 284 instance: propInstance, 285 size = 'm', 286 contentTextWeight, 287 readOnly, 288 enableCommentHint, 289 withinContext, 290 skeleton, 291 enableTranslate, 292 forceTranslate: _forceTranslate, 293 previewMode, 294 allowFilters, 295 onMediaClick, 296 quoted, 297 onStatusLinkClick = () => {}, 298 showFollowedTags, 299 allowContextMenu, 300 showActionsBar, 301 showReplyParent, 302 mediaFirst, 303}) { 304 const { _, t, i18n } = useLingui(); 305 const rtf = RTF(i18n.locale); 306 307 if (skeleton) { 308 return ( 309 <div 310 class={`status skeleton ${ 311 mediaFirst ? 'status-media-first small' : '' 312 }`} 313 > 314 {!mediaFirst && <Avatar size="xxl" />} 315 <div class="container"> 316 <div class="meta"> 317 {(size === 's' || mediaFirst) && <Avatar size="m" />} 318 </div> 319 <div class="content-container"> 320 {mediaFirst && <div class="media-first-container" />} 321 <div class={`content ${mediaFirst ? 'media-first-content' : ''}`}> 322 <p> </p> 323 </div> 324 </div> 325 </div> 326 </div> 327 ); 328 } 329 const { masto, instance, authenticated } = api({ instance: propInstance }); 330 const { instance: currentInstance } = api(); 331 const sameInstance = instance === currentInstance; 332 333 let sKey = statusKey(statusID || status?.id, instance); 334 const snapStates = useSnapshot(states); 335 if (!status) { 336 status = snapStates.statuses[sKey] || snapStates.statuses[statusID]; 337 sKey = statusKey(status?.id, instance); 338 } 339 if (!status) { 340 return null; 341 } 342 343 const { 344 account: { 345 acct, 346 avatar, 347 avatarStatic, 348 id: accountId, 349 url: accountURL, 350 displayName, 351 username, 352 emojis: accountEmojis, 353 bot, 354 group, 355 } = {}, 356 id, 357 repliesCount, 358 reblogged, 359 reblogsCount, 360 favourited, 361 favouritesCount, 362 bookmarked, 363 poll, 364 muted, 365 sensitive, 366 spoilerText, 367 visibility, // public, unlisted, private, direct 368 language: _language, 369 editedAt, 370 filtered, 371 card, 372 createdAt, 373 inReplyToId, 374 inReplyToAccountId, 375 content, 376 mentions, 377 mediaAttachments = [], 378 reblog, 379 uri, 380 url, 381 emojis, 382 tags, 383 pinned, 384 // Non-API props 385 _deleted, 386 _pinned, 387 // _filtered, 388 // Non-Mastodon 389 emojiReactions, 390 } = status; 391 392 const [languageAutoDetected, setLanguageAutoDetected] = useState(null); 393 useEffect(() => { 394 if (!content) return; 395 if (_language) return; 396 if (languageAutoDetected) return; 397 let timer; 398 timer = setTimeout(async () => { 399 let detected = await detectLang( 400 getHTMLTextForDetectLang(content, emojis), 401 ); 402 setLanguageAutoDetected(detected); 403 }, 1000); 404 return () => clearTimeout(timer); 405 }, [content, _language]); 406 const language = _language || languageAutoDetected; 407 408 // if (!mediaAttachments?.length) mediaFirst = false; 409 const hasMediaAttachments = !!mediaAttachments?.length; 410 if (mediaFirst && hasMediaAttachments) size = 's'; 411 412 const currentAccount = getCurrentAccID(); 413 const isSelf = useMemo(() => { 414 return currentAccount && currentAccount === accountId; 415 }, [accountId, currentAccount]); 416 417 const filterContext = useContext(FilterContext); 418 const filterInfo = 419 !isSelf && 420 ((!readOnly && !previewMode) || allowFilters) && 421 isFiltered(filtered, filterContext); 422 423 if (filterInfo?.action === 'hide') { 424 return null; 425 } 426 427 console.debug('RENDER Status', id, status?.account?.displayName, quoted); 428 429 const debugHover = (e) => { 430 if (e.shiftKey) { 431 console.log({ 432 ...status, 433 }); 434 } 435 }; 436 437 if ( 438 (allowFilters || size !== 'l') && 439 filterInfo && 440 filterInfo.action !== 'blur' 441 ) { 442 return ( 443 <FilteredStatus 444 status={status} 445 filterInfo={filterInfo} 446 instance={instance} 447 containerProps={{ 448 onMouseEnter: debugHover, 449 }} 450 showFollowedTags 451 quoted={quoted} 452 /> 453 ); 454 } 455 456 const createdAtDate = new Date(createdAt); 457 const editedAtDate = new Date(editedAt); 458 459 let inReplyToAccountRef = mentions?.find( 460 (mention) => mention.id === inReplyToAccountId, 461 ); 462 if (!inReplyToAccountRef && inReplyToAccountId === id) { 463 inReplyToAccountRef = { url: accountURL, username, displayName }; 464 } 465 const [inReplyToAccount, setInReplyToAccount] = useState(inReplyToAccountRef); 466 if (!withinContext && !inReplyToAccount && inReplyToAccountId) { 467 const account = states.accounts[inReplyToAccountId]; 468 if (account) { 469 setInReplyToAccount(account); 470 } else { 471 memFetchAccount(inReplyToAccountId, masto) 472 .then((account) => { 473 setInReplyToAccount(account); 474 states.accounts[account.id] = account; 475 }) 476 .catch((e) => {}); 477 } 478 } 479 const mentionSelf = 480 inReplyToAccountId === currentAccount || 481 mentions?.find((mention) => mention.id === currentAccount); 482 483 const prefs = getPreferences(); 484 const readingExpandSpoilers = !!prefs['reading:expand:spoilers']; 485 486 // default | show_all | hide_all 487 // Ignore hide_all because it means hide *ALL* media including non-sensitive ones 488 const readingExpandMedia = 489 prefs['reading:expand:media']?.toLowerCase() || 'default'; 490 491 // FOR TESTING: 492 // const readingExpandSpoilers = true; 493 // const readingExpandMedia = 'show_all'; 494 const showSpoiler = 495 previewMode || readingExpandSpoilers || !!snapStates.spoilers[id]; 496 const showSpoilerMedia = 497 previewMode || 498 (readingExpandMedia === 'show_all' && filterInfo?.action !== 'blur') || 499 !!snapStates.spoilersMedia[id]; 500 501 if (reblog) { 502 // If has statusID, means useItemID (cached in states) 503 504 if (group) { 505 return ( 506 <div 507 data-state-post-id={sKey} 508 class="status-group" 509 onMouseEnter={debugHover} 510 > 511 <div class="status-pre-meta"> 512 <Icon icon="group" size="l" alt={t`Group`} />{' '} 513 <NameText account={status.account} instance={instance} showAvatar /> 514 </div> 515 <Status 516 status={statusID ? null : reblog} 517 statusID={statusID ? reblog.id : null} 518 instance={instance} 519 size={size} 520 contentTextWeight={contentTextWeight} 521 readOnly={readOnly} 522 mediaFirst={mediaFirst} 523 /> 524 </div> 525 ); 526 } 527 528 return ( 529 <div 530 data-state-post-id={sKey} 531 class="status-reblog" 532 onMouseEnter={debugHover} 533 > 534 <div class="status-pre-meta"> 535 <Icon icon="rocket" size="l" />{' '} 536 <Trans> 537 <NameText account={status.account} instance={instance} showAvatar />{' '} 538 <span>boosted</span> 539 </Trans> 540 </div> 541 <Status 542 status={statusID ? null : reblog} 543 statusID={statusID ? reblog.id : null} 544 instance={instance} 545 size={size} 546 contentTextWeight={contentTextWeight} 547 readOnly={readOnly} 548 enableCommentHint 549 mediaFirst={mediaFirst} 550 /> 551 </div> 552 ); 553 } 554 555 // Check followedTags 556 const FollowedTagsParent = useCallback( 557 ({ children }) => ( 558 <div 559 data-state-post-id={sKey} 560 class="status-followed-tags" 561 onMouseEnter={debugHover} 562 > 563 <div class="status-pre-meta"> 564 <Icon icon="hashtag" size="l" />{' '} 565 {snapStates.statusFollowedTags[sKey].slice(0, 3).map((tag) => ( 566 <Link 567 key={tag} 568 to={instance ? `/${instance}/t/${tag}` : `/t/${tag}`} 569 class="status-followed-tag-item" 570 > 571 {tag} 572 </Link> 573 ))} 574 </div> 575 {children} 576 </div> 577 ), 578 [sKey, instance, snapStates.statusFollowedTags[sKey]], 579 ); 580 const StatusParent = 581 showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length 582 ? FollowedTagsParent 583 : Fragment; 584 585 const isSizeLarge = size === 'l'; 586 587 const [forceTranslate, setForceTranslate] = useState(_forceTranslate); 588 // const targetLanguage = getTranslateTargetLanguage(true); 589 // const contentTranslationHideLanguages = 590 // snapStates.settings.contentTranslationHideLanguages || []; 591 const { contentTranslation, contentTranslationAutoInline } = 592 snapStates.settings; 593 if (!contentTranslation) enableTranslate = false; 594 const inlineTranslate = useMemo(() => { 595 if ( 596 !contentTranslation || 597 !contentTranslationAutoInline || 598 readOnly || 599 (withinContext && !isSizeLarge) || 600 previewMode || 601 spoilerText || 602 sensitive || 603 poll || 604 card /*|| 605 mediaAttachments?.length*/ 606 ) { 607 return false; 608 } 609 const contentLength = htmlContentLength(content); 610 return contentLength > 0 && contentLength <= INLINE_TRANSLATE_LIMIT; 611 }, [ 612 contentTranslation, 613 contentTranslationAutoInline, 614 readOnly, 615 withinContext, 616 isSizeLarge, 617 previewMode, 618 spoilerText, 619 sensitive, 620 poll, 621 card, 622 mediaAttachments, 623 content, 624 ]); 625 626 const [showEdited, setShowEdited] = useState(false); 627 const [showEmbed, setShowEmbed] = useState(false); 628 629 const spoilerContentRef = useTruncated(); 630 const contentRef = useTruncated(); 631 const mediaContainerRef = useTruncated(); 632 633 const statusRef = useRef(null); 634 const [reloadPostContentCount, reloadPostContent] = useReducer( 635 (c) => c + 1, 636 0, 637 ); 638 639 const unauthInteractionErrorMessage = t`Sorry, your current logged-in instance can't interact with this post from another instance.`; 640 641 const textWeight = useCallback( 642 () => 643 Math.max( 644 Math.round( 645 ((spoilerText?.length || 0) + htmlContentLength(content)) / 140, 646 ) || 1, 647 1, 648 ), 649 [spoilerText, content], 650 ); 651 652 const createdDateText = createdAt && niceDateTime(createdAtDate); 653 const editedDateText = editedAt && niceDateTime(editedAtDate); 654 655 // Can boost if: 656 // - authenticated AND 657 // - visibility != direct OR 658 // - visibility = private AND isSelf 659 let canBoost = 660 authenticated && visibility !== 'direct' && visibility !== 'private'; 661 if (visibility === 'private' && isSelf) { 662 canBoost = true; 663 } 664 665 const replyStatus = (e) => { 666 if (!sameInstance || !authenticated) { 667 return alert(unauthInteractionErrorMessage); 668 } 669 // syntheticEvent comes from MenuItem 670 if (e?.shiftKey || e?.syntheticEvent?.shiftKey) { 671 const newWin = openCompose({ 672 replyToStatus: status, 673 }); 674 if (newWin) return; 675 } 676 showCompose({ 677 replyToStatus: status, 678 }); 679 }; 680 681 // Check if media has no descriptions 682 const mediaNoDesc = useMemo(() => { 683 return mediaAttachments.some( 684 (attachment) => !attachment.description?.trim?.(), 685 ); 686 }, [mediaAttachments]); 687 688 const statusMonthsAgo = useMemo(() => { 689 return Math.floor( 690 (new Date() - createdAtDate) / (1000 * 60 * 60 * 24 * 30), 691 ); 692 }, [createdAtDate]); 693 694 // const boostStatus = async () => { 695 // if (!sameInstance || !authenticated) { 696 // alert(unauthInteractionErrorMessage); 697 // return false; 698 // } 699 // try { 700 // if (!reblogged) { 701 // let confirmText = 'Boost this post?'; 702 // if (mediaNoDesc) { 703 // confirmText += '\n\n⚠️ Some media have no descriptions.'; 704 // } 705 // const yes = confirm(confirmText); 706 // if (!yes) { 707 // return false; 708 // } 709 // } 710 // // Optimistic 711 // states.statuses[sKey] = { 712 // ...status, 713 // reblogged: !reblogged, 714 // reblogsCount: reblogsCount + (reblogged ? -1 : 1), 715 // }; 716 // if (reblogged) { 717 // const newStatus = await masto.v1.statuses.$select(id).unreblog(); 718 // saveStatus(newStatus, instance); 719 // return true; 720 // } else { 721 // const newStatus = await masto.v1.statuses.$select(id).reblog(); 722 // saveStatus(newStatus, instance); 723 // return true; 724 // } 725 // } catch (e) { 726 // console.error(e); 727 // // Revert optimistism 728 // states.statuses[sKey] = status; 729 // return false; 730 // } 731 // }; 732 const confirmBoostStatus = async () => { 733 if (!sameInstance || !authenticated) { 734 alert(unauthInteractionErrorMessage); 735 return false; 736 } 737 try { 738 // Optimistic 739 states.statuses[sKey] = { 740 ...status, 741 reblogged: !reblogged, 742 reblogsCount: reblogsCount + (reblogged ? -1 : 1), 743 }; 744 if (reblogged) { 745 const newStatus = await masto.v1.statuses.$select(id).unreblog(); 746 saveStatus(newStatus, instance); 747 } else { 748 const newStatus = await masto.v1.statuses.$select(id).reblog(); 749 saveStatus(newStatus, instance); 750 } 751 return true; 752 } catch (e) { 753 console.error(e); 754 // Revert optimistism 755 states.statuses[sKey] = status; 756 return false; 757 } 758 }; 759 760 const favouriteStatus = async () => { 761 if (!sameInstance || !authenticated) { 762 alert(unauthInteractionErrorMessage); 763 return false; 764 } 765 try { 766 // Optimistic 767 states.statuses[sKey] = { 768 ...status, 769 favourited: !favourited, 770 favouritesCount: favouritesCount + (favourited ? -1 : 1), 771 }; 772 if (favourited) { 773 const newStatus = await masto.v1.statuses.$select(id).unfavourite(); 774 saveStatus(newStatus, instance); 775 } else { 776 const newStatus = await masto.v1.statuses.$select(id).favourite(); 777 saveStatus(newStatus, instance); 778 } 779 return true; 780 } catch (e) { 781 console.error(e); 782 // Revert optimistism 783 states.statuses[sKey] = status; 784 return false; 785 } 786 }; 787 const favouriteStatusNotify = async () => { 788 try { 789 const done = await favouriteStatus(); 790 if (!isSizeLarge && done) { 791 showToast( 792 favourited 793 ? t`Unliked @${username || acct}'s post` 794 : t`Liked @${username || acct}'s post`, 795 ); 796 } 797 } catch (e) {} 798 }; 799 800 const bookmarkStatus = async () => { 801 if (!supports('@mastodon/post-bookmark')) return; 802 if (!sameInstance || !authenticated) { 803 alert(unauthInteractionErrorMessage); 804 return false; 805 } 806 try { 807 // Optimistic 808 states.statuses[sKey] = { 809 ...status, 810 bookmarked: !bookmarked, 811 }; 812 if (bookmarked) { 813 const newStatus = await masto.v1.statuses.$select(id).unbookmark(); 814 saveStatus(newStatus, instance); 815 } else { 816 const newStatus = await masto.v1.statuses.$select(id).bookmark(); 817 saveStatus(newStatus, instance); 818 } 819 return true; 820 } catch (e) { 821 console.error(e); 822 // Revert optimistism 823 states.statuses[sKey] = status; 824 return false; 825 } 826 }; 827 const bookmarkStatusNotify = async () => { 828 try { 829 const done = await bookmarkStatus(); 830 if (!isSizeLarge && done) { 831 showToast( 832 bookmarked 833 ? t`Unbookmarked @${username || acct}'s post` 834 : t`Bookmarked @${username || acct}'s post`, 835 ); 836 } 837 } catch (e) {} 838 }; 839 840 // const differentLanguage = 841 // !!language && 842 // language !== targetLanguage && 843 // !localeMatch([language], [targetLanguage]) && 844 // !contentTranslationHideLanguages.find( 845 // (l) => language === l || localeMatch([language], [l]), 846 // ); 847 const contentTranslationHideLanguages = 848 snapStates.settings.contentTranslationHideLanguages || []; 849 const [differentLanguage, setDifferentLanguage] = useState( 850 DIFFERENT_LANG_CHECK[ 851 diffLangCheckCacheKey(language, contentTranslationHideLanguages) 852 ], 853 ); 854 useEffect(() => { 855 if (!language || differentLanguage) { 856 return; 857 } 858 if ( 859 !differentLanguage && 860 DIFFERENT_LANG_CHECK[ 861 diffLangCheckCacheKey(language, contentTranslationHideLanguages) 862 ] 863 ) { 864 setDifferentLanguage(true); 865 return; 866 } 867 let timeout = setTimeout(() => { 868 const different = checkDifferentLanguage( 869 language, 870 contentTranslationHideLanguages, 871 ); 872 if (different) setDifferentLanguage(different); 873 }, 100); 874 return () => clearTimeout(timeout); 875 }, [language, differentLanguage]); 876 877 const reblogIterator = useRef(); 878 const favouriteIterator = useRef(); 879 async function fetchBoostedLikedByAccounts(firstLoad) { 880 if (firstLoad) { 881 reblogIterator.current = masto.v1.statuses 882 .$select(statusID) 883 .rebloggedBy.list({ 884 limit: REACTIONS_LIMIT, 885 }) 886 .values(); 887 favouriteIterator.current = masto.v1.statuses 888 .$select(statusID) 889 .favouritedBy.list({ 890 limit: REACTIONS_LIMIT, 891 }) 892 .values(); 893 } 894 const [{ value: reblogResults }, { value: favouriteResults }] = 895 await Promise.allSettled([ 896 reblogIterator.current.next(), 897 favouriteIterator.current.next(), 898 ]); 899 if (reblogResults.value?.length || favouriteResults.value?.length) { 900 const accounts = []; 901 if (reblogResults.value?.length) { 902 accounts.push( 903 ...reblogResults.value.map((a) => { 904 a._types = ['reblog']; 905 return a; 906 }), 907 ); 908 } 909 if (favouriteResults.value?.length) { 910 accounts.push( 911 ...favouriteResults.value.map((a) => { 912 a._types = ['favourite']; 913 return a; 914 }), 915 ); 916 } 917 return { 918 value: accounts, 919 done: reblogResults.done && favouriteResults.done, 920 }; 921 } 922 return { 923 value: [], 924 done: true, 925 }; 926 } 927 928 const actionsRef = useRef(); 929 const isPublic = ['public', 'unlisted'].includes(visibility); 930 const isPinnable = ['public', 'unlisted', 'private'].includes(visibility); 931 const menuFooter = 932 mediaNoDesc && !reblogged ? ( 933 <div class="footer"> 934 <Icon icon="alert" /> 935 <Trans>Some media have no descriptions.</Trans> 936 </div> 937 ) : ( 938 statusMonthsAgo >= 3 && ( 939 <div class="footer"> 940 <Icon icon="info" /> 941 <span> 942 <Trans> 943 Old post (<strong>{rtf.format(-statusMonthsAgo, 'month')}</strong> 944 ) 945 </Trans> 946 </span> 947 </div> 948 ) 949 ); 950 const StatusMenuItems = ( 951 <> 952 {!isSizeLarge && sameInstance && ( 953 <> 954 <div class="menu-control-group-horizontal status-menu"> 955 <MenuItem onClick={replyStatus}> 956 <Icon icon="comment" /> 957 <span> 958 {repliesCount > 0 ? shortenNumber(repliesCount) : t`Reply`} 959 </span> 960 </MenuItem> 961 <MenuConfirm 962 subMenu 963 confirmLabel={ 964 <> 965 <Icon icon="rocket" /> 966 <span>{reblogged ? t`Unboost` : t`Boost`}</span> 967 </> 968 } 969 className={`menu-reblog ${reblogged ? 'checked' : ''}`} 970 menuExtras={ 971 <MenuItem 972 onClick={() => { 973 showCompose({ 974 draftStatus: { 975 status: `\n${url}`, 976 }, 977 }); 978 }} 979 > 980 <Icon icon="quote" /> 981 <span> 982 <Trans>Quote</Trans> 983 </span> 984 </MenuItem> 985 } 986 menuFooter={menuFooter} 987 disabled={!canBoost} 988 onClick={async () => { 989 try { 990 const done = await confirmBoostStatus(); 991 if (!isSizeLarge && done) { 992 showToast( 993 reblogged 994 ? t`Unboosted @${username || acct}'s post` 995 : t`Boosted @${username || acct}'s post`, 996 ); 997 } 998 } catch (e) {} 999 }} 1000 > 1001 <Icon icon="rocket" /> 1002 <span> 1003 {reblogsCount > 0 1004 ? shortenNumber(reblogsCount) 1005 : reblogged 1006 ? t`Unboost` 1007 : t`Boost…`} 1008 </span> 1009 </MenuConfirm> 1010 <MenuItem 1011 onClick={favouriteStatusNotify} 1012 className={`menu-favourite ${favourited ? 'checked' : ''}`} 1013 > 1014 <Icon icon="heart" /> 1015 <span> 1016 {favouritesCount > 0 1017 ? shortenNumber(favouritesCount) 1018 : favourited 1019 ? t`Unlike` 1020 : t`Like`} 1021 </span> 1022 </MenuItem> 1023 {supports('@mastodon/post-bookmark') && ( 1024 <MenuItem 1025 onClick={bookmarkStatusNotify} 1026 className={`menu-bookmark ${bookmarked ? 'checked' : ''}`} 1027 > 1028 <Icon icon="bookmark" /> 1029 <span>{bookmarked ? t`Unbookmark` : t`Bookmark`}</span> 1030 </MenuItem> 1031 )} 1032 </div> 1033 </> 1034 )} 1035 {!isSizeLarge && sameInstance && (isSizeLarge || showActionsBar) && ( 1036 <MenuDivider /> 1037 )} 1038 {(isSizeLarge || showActionsBar) && ( 1039 <> 1040 <MenuItem 1041 onClick={() => { 1042 states.showGenericAccounts = { 1043 heading: t`Boosted/Liked by…`, 1044 fetchAccounts: fetchBoostedLikedByAccounts, 1045 instance, 1046 showReactions: true, 1047 postID: sKey, 1048 }; 1049 }} 1050 > 1051 <Icon icon="react" /> 1052 <span> 1053 <Trans>Boosted/Liked by</Trans> 1054 </span> 1055 </MenuItem> 1056 </> 1057 )} 1058 {(isSizeLarge || 1059 (!mediaFirst && 1060 (enableTranslate || !language || differentLanguage))) && ( 1061 <MenuDivider /> 1062 )} 1063 {!mediaFirst && (enableTranslate || !language || differentLanguage) && ( 1064 <div class={supportsTTS ? 'menu-horizontal' : ''}> 1065 {enableTranslate ? ( 1066 <MenuItem 1067 disabled={forceTranslate} 1068 onClick={() => setForceTranslate(true)} 1069 > 1070 <Icon icon="translate" /> 1071 <span> 1072 <Trans>Translate</Trans> 1073 </span> 1074 </MenuItem> 1075 ) : ( 1076 <MenuLink 1077 to={`${instance ? `/${instance}` : ''}/s/${id}?translate=1`} 1078 > 1079 <Icon icon="translate" /> 1080 <span> 1081 <Trans>Translate</Trans> 1082 </span> 1083 </MenuLink> 1084 )} 1085 {supportsTTS && ( 1086 <MenuItem 1087 onClick={() => { 1088 try { 1089 const postText = getPostText(status); 1090 if (postText) { 1091 speak(postText, language); 1092 } 1093 } catch (error) { 1094 console.error('Failed to speak text:', error); 1095 } 1096 }} 1097 > 1098 <Icon icon="speak" /> 1099 <span> 1100 <Trans>Speak</Trans> 1101 </span> 1102 </MenuItem> 1103 )} 1104 </div> 1105 )} 1106 {isSizeLarge && ( 1107 <MenuItem 1108 onClick={() => { 1109 try { 1110 const postText = getPostText(status); 1111 navigator.clipboard.writeText(postText); 1112 showToast(t`Post text copied`); 1113 } catch (e) { 1114 console.error(e); 1115 showToast(t`Unable to copy post text`); 1116 } 1117 }} 1118 > 1119 <Icon icon="clipboard" /> 1120 <span> 1121 <Trans>Copy post text</Trans> 1122 </span> 1123 </MenuItem> 1124 )} 1125 {((!isSizeLarge && sameInstance) || 1126 enableTranslate || 1127 !language || 1128 differentLanguage) && <MenuDivider />} 1129 {!isSizeLarge && ( 1130 <> 1131 <MenuLink 1132 to={instance ? `/${instance}/s/${id}` : `/s/${id}`} 1133 onClick={(e) => { 1134 onStatusLinkClick(e, status); 1135 }} 1136 > 1137 <Icon icon="arrows-right" /> 1138 <small> 1139 <Trans> 1140 View post by{' '} 1141 <span class="bidi-isolate">@{username || acct}</span> 1142 </Trans> 1143 <br /> 1144 <span class="more-insignificant"> 1145 {_(visibilityText[visibility])} {createdDateText} 1146 </span> 1147 </small> 1148 </MenuLink> 1149 </> 1150 )} 1151 {!!editedAt && ( 1152 <> 1153 <MenuItem 1154 onClick={() => { 1155 setShowEdited(id); 1156 }} 1157 > 1158 <Icon icon="history" /> 1159 <small> 1160 <Trans>Show Edit History</Trans> 1161 <br /> 1162 <span class="more-insignificant"> 1163 <Trans>Edited: {editedDateText}</Trans> 1164 </span> 1165 </small> 1166 </MenuItem> 1167 </> 1168 )} 1169 <MenuItem href={url} target="_blank"> 1170 <Icon icon="external" /> 1171 <small 1172 class="menu-double-lines" 1173 style={{ 1174 maxWidth: '16em', 1175 }} 1176 > 1177 {nicePostURL(url)} 1178 </small> 1179 </MenuItem> 1180 <div class="menu-horizontal"> 1181 <MenuItem 1182 onClick={() => { 1183 // Copy url to clipboard 1184 try { 1185 navigator.clipboard.writeText(url); 1186 showToast(t`Link copied`); 1187 } catch (e) { 1188 console.error(e); 1189 showToast(t`Unable to copy link`); 1190 } 1191 }} 1192 > 1193 <Icon icon="link" /> 1194 <span> 1195 <Trans>Copy</Trans> 1196 </span> 1197 </MenuItem> 1198 {isPublic && 1199 navigator?.share && 1200 navigator?.canShare?.({ 1201 url, 1202 }) && ( 1203 <MenuItem 1204 onClick={() => { 1205 try { 1206 navigator.share({ 1207 url, 1208 }); 1209 } catch (e) { 1210 console.error(e); 1211 alert(t`Sharing doesn't seem to work.`); 1212 } 1213 }} 1214 > 1215 <Icon icon="share" /> 1216 <span> 1217 <Trans>Share</Trans> 1218 </span> 1219 </MenuItem> 1220 )} 1221 </div> 1222 {isPublic && isSizeLarge && ( 1223 <MenuItem 1224 onClick={() => { 1225 setShowEmbed(true); 1226 }} 1227 > 1228 <Icon icon="code" /> 1229 <span> 1230 <Trans>Embed post</Trans> 1231 </span> 1232 </MenuItem> 1233 )} 1234 {(isSelf || mentionSelf) && <MenuDivider />} 1235 {(isSelf || mentionSelf) && ( 1236 <MenuItem 1237 onClick={async () => { 1238 try { 1239 const newStatus = await masto.v1.statuses 1240 .$select(id) 1241 [muted ? 'unmute' : 'mute'](); 1242 saveStatus(newStatus, instance); 1243 showToast( 1244 muted ? t`Conversation unmuted` : t`Conversation muted`, 1245 ); 1246 } catch (e) { 1247 console.error(e); 1248 showToast( 1249 muted 1250 ? t`Unable to unmute conversation` 1251 : t`Unable to mute conversation`, 1252 ); 1253 } 1254 }} 1255 > 1256 {muted ? ( 1257 <> 1258 <Icon icon="unmute" /> 1259 <span> 1260 <Trans>Unmute conversation</Trans> 1261 </span> 1262 </> 1263 ) : ( 1264 <> 1265 <Icon icon="mute" /> 1266 <span> 1267 <Trans>Mute conversation</Trans> 1268 </span> 1269 </> 1270 )} 1271 </MenuItem> 1272 )} 1273 {isSelf && isPinnable && ( 1274 <MenuItem 1275 onClick={async () => { 1276 try { 1277 const newStatus = await masto.v1.statuses 1278 .$select(id) 1279 [pinned ? 'unpin' : 'pin'](); 1280 saveStatus(newStatus, instance); 1281 showToast( 1282 pinned 1283 ? t`Post unpinned from profile` 1284 : t`Post pinned to profile`, 1285 ); 1286 } catch (e) { 1287 console.error(e); 1288 showToast( 1289 pinned ? t`Unable to unpin post` : t`Unable to pin post`, 1290 ); 1291 } 1292 }} 1293 > 1294 {pinned ? ( 1295 <> 1296 <Icon icon="unpin" /> 1297 <span> 1298 <Trans>Unpin from profile</Trans> 1299 </span> 1300 </> 1301 ) : ( 1302 <> 1303 <Icon icon="pin" /> 1304 <span> 1305 <Trans>Pin to profile</Trans> 1306 </span> 1307 </> 1308 )} 1309 </MenuItem> 1310 )} 1311 {isSelf && ( 1312 <div class="menu-horizontal"> 1313 {supports('@mastodon/post-edit') && ( 1314 <MenuItem 1315 onClick={() => { 1316 showCompose({ 1317 editStatus: status, 1318 }); 1319 }} 1320 > 1321 <Icon icon="pencil" /> 1322 <span> 1323 <Trans>Edit</Trans> 1324 </span> 1325 </MenuItem> 1326 )} 1327 {isSizeLarge && ( 1328 <MenuConfirm 1329 subMenu 1330 confirmLabel={ 1331 <> 1332 <Icon icon="trash" /> 1333 <span> 1334 <Trans>Delete this post?</Trans> 1335 </span> 1336 </> 1337 } 1338 itemProps={{ 1339 className: 'danger', 1340 }} 1341 menuItemClassName="danger" 1342 onClick={() => { 1343 // const yes = confirm('Delete this post?'); 1344 // if (yes) { 1345 (async () => { 1346 try { 1347 await masto.v1.statuses.$select(id).remove(); 1348 const cachedStatus = getStatus(id, instance); 1349 cachedStatus._deleted = true; 1350 showToast(t`Post deleted`); 1351 } catch (e) { 1352 console.error(e); 1353 showToast(t`Unable to delete post`); 1354 } 1355 })(); 1356 // } 1357 }} 1358 > 1359 <Icon icon="trash" /> 1360 <span> 1361 <Trans>Delete</Trans> 1362 </span> 1363 </MenuConfirm> 1364 )} 1365 </div> 1366 )} 1367 {!isSelf && isSizeLarge && ( 1368 <> 1369 <MenuDivider /> 1370 <MenuItem 1371 className="danger" 1372 onClick={() => { 1373 states.showReportModal = { 1374 account: status.account, 1375 post: status, 1376 }; 1377 }} 1378 > 1379 <Icon icon="flag" /> 1380 <span> 1381 <Trans>Report post</Trans> 1382 </span> 1383 </MenuItem> 1384 </> 1385 )} 1386 </> 1387 ); 1388 1389 const contextMenuRef = useRef(); 1390 const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); 1391 const [contextMenuProps, setContextMenuProps] = useState({}); 1392 1393 const showContextMenu = 1394 allowContextMenu || (!isSizeLarge && !previewMode && !_deleted && !quoted); 1395 1396 // Only iOS/iPadOS browsers don't support contextmenu 1397 // Some comments report iPadOS might support contextmenu if a mouse is connected 1398 const bindLongPressContext = useLongPress( 1399 isIOS && showContextMenu 1400 ? (e) => { 1401 if (e.pointerType === 'mouse') return; 1402 // There's 'pen' too, but not sure if contextmenu event would trigger from a pen 1403 1404 const { clientX, clientY } = e.touches?.[0] || e; 1405 // link detection copied from onContextMenu because here it works 1406 const link = e.target.closest('a'); 1407 if ( 1408 link && 1409 statusRef.current.contains(link) && 1410 !link.getAttribute('href').startsWith('#') 1411 ) 1412 return; 1413 e.preventDefault(); 1414 setContextMenuProps({ 1415 anchorPoint: { 1416 x: clientX, 1417 y: clientY, 1418 }, 1419 direction: 'right', 1420 }); 1421 setIsContextMenuOpen(true); 1422 } 1423 : null, 1424 { 1425 threshold: 600, 1426 captureEvent: true, 1427 detect: 'touch', 1428 cancelOnMovement: 2, // true allows movement of up to 25 pixels 1429 }, 1430 ); 1431 1432 const hotkeysEnabled = !readOnly && !previewMode && !quoted; 1433 const rRef = useHotkeys('r, shift+r', replyStatus, { 1434 enabled: hotkeysEnabled, 1435 useKey: true, 1436 ignoreEventWhen: (e) => e.metaKey || e.ctrlKey || e.altKey, 1437 }); 1438 const fRef = useHotkeys('f, l', favouriteStatusNotify, { 1439 enabled: hotkeysEnabled, 1440 ignoreEventWhen: (e) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey, 1441 useKey: true, 1442 }); 1443 const dRef = useHotkeys('d', bookmarkStatusNotify, { 1444 enabled: hotkeysEnabled, 1445 useKey: true, 1446 ignoreEventWhen: (e) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey, 1447 }); 1448 const bRef = useHotkeys( 1449 'shift+b', 1450 (e) => { 1451 // Need shiftKey check due to useKey: true 1452 if (!e.shiftKey) return; 1453 1454 (async () => { 1455 try { 1456 const done = await confirmBoostStatus(); 1457 if (!isSizeLarge && done) { 1458 showToast( 1459 reblogged 1460 ? t`Unboosted @${username || acct}'s post` 1461 : t`Boosted @${username || acct}'s post`, 1462 ); 1463 } 1464 } catch (e) {} 1465 })(); 1466 }, 1467 { 1468 enabled: hotkeysEnabled && canBoost, 1469 useKey: true, 1470 ignoreEventWhen: (e) => e.metaKey || e.ctrlKey || e.altKey, 1471 }, 1472 ); 1473 const xRef = useHotkeys( 1474 'x', 1475 (e) => { 1476 const activeStatus = document.activeElement.closest( 1477 '.status-link, .status-focus', 1478 ); 1479 if (activeStatus) { 1480 const spoilerButton = activeStatus.querySelector( 1481 '.spoiler-button:not(.spoiling)', 1482 ); 1483 if (spoilerButton) { 1484 e.stopPropagation(); 1485 spoilerButton.click(); 1486 } else { 1487 const spoilerMediaButton = activeStatus.querySelector( 1488 '.spoiler-media-button:not(.spoiling)', 1489 ); 1490 if (spoilerMediaButton) { 1491 e.stopPropagation(); 1492 spoilerMediaButton.click(); 1493 } 1494 } 1495 } 1496 }, 1497 { 1498 useKey: true, 1499 ignoreEventWhen: (e) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey, 1500 }, 1501 ); 1502 1503 const displayedMediaAttachments = mediaAttachments.slice( 1504 0, 1505 isSizeLarge ? undefined : 4, 1506 ); 1507 const showMultipleMediaCaptions = 1508 mediaAttachments.length > 1 && 1509 displayedMediaAttachments.some( 1510 (media) => !!media.description && !isMediaCaptionLong(media.description), 1511 ); 1512 const captionChildren = useMemo(() => { 1513 if (!showMultipleMediaCaptions) return null; 1514 const attachments = []; 1515 displayedMediaAttachments.forEach((media, i) => { 1516 if (!media.description) return; 1517 const index = attachments.findIndex( 1518 (attachment) => attachment.media.description === media.description, 1519 ); 1520 if (index === -1) { 1521 attachments.push({ 1522 media, 1523 indices: [i], 1524 }); 1525 } else { 1526 attachments[index].indices.push(i); 1527 } 1528 }); 1529 return attachments.map(({ media, indices }) => ( 1530 <div 1531 key={media.id} 1532 data-caption-index={indices.map((i) => i + 1).join(' ')} 1533 onClick={(e) => { 1534 e.preventDefault(); 1535 e.stopPropagation(); 1536 states.showMediaAlt = { 1537 alt: media.description, 1538 lang: language, 1539 }; 1540 }} 1541 title={media.description} 1542 > 1543 <sup>{indices.map((i) => i + 1).join(' ')}</sup> {media.description} 1544 </div> 1545 )); 1546 1547 // return displayedMediaAttachments.map( 1548 // (media, i) => 1549 // !!media.description && ( 1550 // <div 1551 // key={media.id} 1552 // data-caption-index={i + 1} 1553 // onClick={(e) => { 1554 // e.preventDefault(); 1555 // e.stopPropagation(); 1556 // states.showMediaAlt = { 1557 // alt: media.description, 1558 // lang: language, 1559 // }; 1560 // }} 1561 // title={media.description} 1562 // > 1563 // <sup>{i + 1}</sup> {media.description} 1564 // </div> 1565 // ), 1566 // ); 1567 }, [showMultipleMediaCaptions, displayedMediaAttachments, language]); 1568 1569 const isThread = useMemo(() => { 1570 return ( 1571 (!!inReplyToId && inReplyToAccountId === status.account?.id) || 1572 !!snapStates.statusThreadNumber[sKey] 1573 ); 1574 }, [ 1575 inReplyToId, 1576 inReplyToAccountId, 1577 status.account?.id, 1578 snapStates.statusThreadNumber[sKey], 1579 ]); 1580 1581 const showCommentHint = useMemo(() => { 1582 return ( 1583 enableCommentHint && 1584 !isThread && 1585 !withinContext && 1586 !inReplyToId && 1587 visibility === 'public' && 1588 repliesCount > 0 1589 ); 1590 }, [ 1591 enableCommentHint, 1592 isThread, 1593 withinContext, 1594 inReplyToId, 1595 repliesCount, 1596 visibility, 1597 ]); 1598 const showCommentCount = useMemo(() => { 1599 if ( 1600 card || 1601 poll || 1602 sensitive || 1603 spoilerText || 1604 mediaAttachments?.length || 1605 isThread || 1606 withinContext || 1607 inReplyToId || 1608 repliesCount <= 0 1609 ) { 1610 return false; 1611 } 1612 const questionRegex = /[???︖❓❔⁇⁈⁉¿‽؟]/; 1613 const containsQuestion = questionRegex.test(content); 1614 if (!containsQuestion) return false; 1615 const contentLength = htmlContentLength(content); 1616 if (contentLength > 0 && contentLength <= SHOW_COMMENT_COUNT_LIMIT) { 1617 return true; 1618 } 1619 }, [ 1620 card, 1621 poll, 1622 sensitive, 1623 spoilerText, 1624 mediaAttachments, 1625 reblog, 1626 isThread, 1627 withinContext, 1628 inReplyToId, 1629 repliesCount, 1630 content, 1631 ]); 1632 1633 return ( 1634 <StatusParent> 1635 {showReplyParent && !!(inReplyToId && inReplyToAccountId) && ( 1636 <StatusCompact sKey={sKey} /> 1637 )} 1638 <article 1639 data-state-post-id={sKey} 1640 ref={(node) => { 1641 statusRef.current = node; 1642 // Use parent node if it's in focus 1643 // Use case: <a><status /></a> 1644 // When navigating (j/k), the <a> is focused instead of <status /> 1645 // Hotkey binding doesn't bubble up thus this hack 1646 const nodeRef = 1647 node?.closest?.( 1648 '.timeline-item, .timeline-item-alt, .status-link, .status-focus', 1649 ) || node; 1650 rRef.current = nodeRef; 1651 fRef.current = nodeRef; 1652 dRef.current = nodeRef; 1653 bRef.current = nodeRef; 1654 xRef.current = nodeRef; 1655 }} 1656 tabindex="-1" 1657 class={`status ${ 1658 !withinContext && inReplyToId && inReplyToAccount 1659 ? 'status-reply-to' 1660 : '' 1661 } visibility-${visibility} ${_pinned ? 'status-pinned' : ''} ${ 1662 SIZE_CLASS[size] 1663 } ${_deleted ? 'status-deleted' : ''} ${quoted ? 'status-card' : ''} ${ 1664 isContextMenuOpen ? 'status-menu-open' : '' 1665 } ${mediaFirst && hasMediaAttachments ? 'status-media-first' : ''}`} 1666 onMouseEnter={debugHover} 1667 onContextMenu={(e) => { 1668 if (!showContextMenu) return; 1669 if (e.metaKey) return; 1670 // console.log('context menu', e); 1671 const link = e.target.closest('a'); 1672 if ( 1673 link && 1674 statusRef.current.contains(link) && 1675 !link.getAttribute('href').startsWith('#') 1676 ) 1677 return; 1678 1679 // If there's selected text, don't show custom context menu 1680 const selection = window.getSelection?.(); 1681 if (selection.toString().length > 0) { 1682 const { anchorNode } = selection; 1683 if (statusRef.current?.contains(anchorNode)) { 1684 return; 1685 } 1686 } 1687 e.preventDefault(); 1688 setContextMenuProps({ 1689 anchorPoint: { 1690 x: e.clientX, 1691 y: e.clientY, 1692 }, 1693 direction: 'right', 1694 }); 1695 setIsContextMenuOpen(true); 1696 }} 1697 {...(showContextMenu ? bindLongPressContext() : {})} 1698 > 1699 {showContextMenu && ( 1700 <ControlledMenu 1701 ref={contextMenuRef} 1702 state={isContextMenuOpen ? 'open' : undefined} 1703 {...contextMenuProps} 1704 onClose={(e) => { 1705 setIsContextMenuOpen(false); 1706 // statusRef.current?.focus?.(); 1707 if (e?.reason === 'click') { 1708 statusRef.current?.closest('[tabindex]')?.focus?.(); 1709 } 1710 }} 1711 portal={{ 1712 target: document.body, 1713 }} 1714 containerProps={{ 1715 style: { 1716 // Higher than the backdrop 1717 zIndex: 1001, 1718 }, 1719 onClick: () => { 1720 contextMenuRef.current?.closeMenu?.(); 1721 }, 1722 }} 1723 overflow="auto" 1724 boundingBoxPadding={safeBoundingBoxPadding()} 1725 unmountOnClose 1726 > 1727 {StatusMenuItems} 1728 </ControlledMenu> 1729 )} 1730 {showActionsBar && 1731 size !== 'l' && 1732 !previewMode && 1733 !readOnly && 1734 !_deleted && 1735 !quoted && ( 1736 <div 1737 class={`status-actions ${ 1738 isContextMenuOpen === 'actions-bar' ? 'open' : '' 1739 }`} 1740 ref={actionsRef} 1741 > 1742 <StatusButton 1743 size="s" 1744 title={t`Reply`} 1745 alt={t`Reply`} 1746 class="reply-button" 1747 icon="comment" 1748 iconSize="m" 1749 onClick={replyStatus} 1750 /> 1751 <StatusButton 1752 size="s" 1753 checked={favourited} 1754 title={[t`Like`, t`Unlike`]} 1755 alt={[t`Like`, t`Liked`]} 1756 class="favourite-button" 1757 icon="heart" 1758 iconSize="m" 1759 count={favouritesCount} 1760 onClick={favouriteStatusNotify} 1761 /> 1762 <button 1763 type="button" 1764 title={t`More`} 1765 class="plain more-button" 1766 onClick={(e) => { 1767 e.preventDefault(); 1768 e.stopPropagation(); 1769 setContextMenuProps({ 1770 anchorRef: { 1771 current: e.currentTarget, 1772 }, 1773 align: 'start', 1774 direction: 'left', 1775 gap: 0, 1776 shift: -8, 1777 }); 1778 setIsContextMenuOpen('actions-bar'); 1779 }} 1780 > 1781 <Icon icon="more2" size="m" alt={t`More`} /> 1782 </button> 1783 </div> 1784 )} 1785 {size !== 'l' && ( 1786 <div class="status-badge"> 1787 {reblogged && ( 1788 <Icon class="reblog" icon="rocket" size="s" alt={t`Boosted`} /> 1789 )} 1790 {favourited && ( 1791 <Icon class="favourite" icon="heart" size="s" alt={t`Liked`} /> 1792 )} 1793 {bookmarked && ( 1794 <Icon 1795 class="bookmark" 1796 icon="bookmark" 1797 size="s" 1798 alt={t`Bookmarked`} 1799 /> 1800 )} 1801 {_pinned && ( 1802 <Icon class="pin" icon="pin" size="s" alt={t`Pinned`} /> 1803 )} 1804 </div> 1805 )} 1806 {size !== 's' && ( 1807 <a 1808 href={accountURL} 1809 tabindex="-1" 1810 // target="_blank" 1811 title={`@${acct}`} 1812 onClick={(e) => { 1813 e.preventDefault(); 1814 e.stopPropagation(); 1815 states.showAccount = { 1816 account: status.account, 1817 instance, 1818 }; 1819 }} 1820 > 1821 <Avatar url={avatarStatic || avatar} size="xxl" squircle={bot} /> 1822 </a> 1823 )} 1824 <div class="container"> 1825 {!!(status.account || createdAt) && ( 1826 <div class="meta"> 1827 <span class="meta-name"> 1828 <NameText 1829 account={status.account} 1830 instance={instance} 1831 showAvatar={size === 's'} 1832 showAcct={isSizeLarge} 1833 /> 1834 </span> 1835 {withinContext && isThread && ( 1836 <ThreadBadge 1837 showIcon={isSizeLarge} 1838 index={snapStates.statusThreadNumber[sKey]} 1839 /> 1840 )} 1841 {/* {inReplyToAccount && !withinContext && size !== 's' && ( 1842 <> 1843 {' '} 1844 <span class="ib"> 1845 <Icon icon="arrow-right" class="arrow" />{' '} 1846 <NameText account={inReplyToAccount} instance={instance} short /> 1847 </span> 1848 </> 1849 )} */} 1850 {/* </span> */}{' '} 1851 {size !== 'l' && 1852 (_deleted ? ( 1853 <span class="status-deleted-tag"> 1854 <Trans>Deleted</Trans> 1855 </span> 1856 ) : url && !previewMode && !readOnly && !quoted ? ( 1857 <Link 1858 to={instance ? `/${instance}/s/${id}` : `/s/${id}`} 1859 onClick={(e) => { 1860 if ( 1861 e.metaKey || 1862 e.ctrlKey || 1863 e.shiftKey || 1864 e.altKey || 1865 e.which === 2 1866 ) { 1867 return; 1868 } 1869 e.preventDefault(); 1870 e.stopPropagation(); 1871 onStatusLinkClick?.(e, status); 1872 setContextMenuProps({ 1873 anchorRef: { 1874 current: e.currentTarget, 1875 }, 1876 align: 'end', 1877 direction: 'bottom', 1878 gap: 4, 1879 }); 1880 setIsContextMenuOpen(true); 1881 }} 1882 class={`time ${ 1883 isContextMenuOpen && contextMenuProps?.anchorRef 1884 ? 'is-open' 1885 : '' 1886 }`} 1887 > 1888 {showCommentHint && !showCommentCount ? ( 1889 <Icon 1890 icon="comment2" 1891 size="s" 1892 // alt={`${repliesCount} ${ 1893 // repliesCount === 1 ? 'reply' : 'replies' 1894 // }`} 1895 alt={plural(repliesCount, { 1896 one: '# reply', 1897 other: '# replies', 1898 })} 1899 /> 1900 ) : ( 1901 visibility !== 'public' && 1902 visibility !== 'direct' && ( 1903 <Icon 1904 icon={visibilityIconsMap[visibility]} 1905 alt={_(visibilityText[visibility])} 1906 size="s" 1907 /> 1908 ) 1909 )}{' '} 1910 <RelativeTime datetime={createdAtDate} format="micro" /> 1911 {!previewMode && !readOnly && ( 1912 <Icon icon="more2" class="more" alt={t`More`} /> 1913 )} 1914 </Link> 1915 ) : ( 1916 // <Menu 1917 // instanceRef={menuInstanceRef} 1918 // portal={{ 1919 // target: document.body, 1920 // }} 1921 // containerProps={{ 1922 // style: { 1923 // // Higher than the backdrop 1924 // zIndex: 1001, 1925 // }, 1926 // onClick: (e) => { 1927 // if (e.target === e.currentTarget) 1928 // menuInstanceRef.current?.closeMenu?.(); 1929 // }, 1930 // }} 1931 // align="end" 1932 // gap={4} 1933 // overflow="auto" 1934 // viewScroll="close" 1935 // boundingBoxPadding="8 8 8 8" 1936 // unmountOnClose 1937 // menuButton={({ open }) => ( 1938 // <Link 1939 // to={instance ? `/${instance}/s/${id}` : `/s/${id}`} 1940 // onClick={(e) => { 1941 // e.preventDefault(); 1942 // e.stopPropagation(); 1943 // onStatusLinkClick?.(e, status); 1944 // }} 1945 // class={`time ${open ? 'is-open' : ''}`} 1946 // > 1947 // <Icon 1948 // icon={visibilityIconsMap[visibility]} 1949 // alt={visibilityText[visibility]} 1950 // size="s" 1951 // />{' '} 1952 // <RelativeTime datetime={createdAtDate} format="micro" /> 1953 // </Link> 1954 // )} 1955 // > 1956 // {StatusMenuItems} 1957 // </Menu> 1958 <span class="time"> 1959 {visibility !== 'public' && visibility !== 'direct' && ( 1960 <> 1961 <Icon 1962 icon={visibilityIconsMap[visibility]} 1963 alt={_(visibilityText[visibility])} 1964 size="s" 1965 />{' '} 1966 </> 1967 )} 1968 <RelativeTime datetime={createdAtDate} format="micro" /> 1969 </span> 1970 ))} 1971 </div> 1972 )} 1973 {visibility === 'direct' && ( 1974 <> 1975 <div class="status-direct-badge"> 1976 <Trans>Private mention</Trans> 1977 </div>{' '} 1978 </> 1979 )} 1980 {!withinContext && ( 1981 <> 1982 {isThread ? ( 1983 <ThreadBadge 1984 showIcon 1985 showText 1986 index={snapStates.statusThreadNumber[sKey]} 1987 /> 1988 ) : ( 1989 !!inReplyToId && 1990 !!inReplyToAccount && 1991 (!!spoilerText || 1992 !mentions.find((mention) => { 1993 return mention.id === inReplyToAccountId; 1994 })) && ( 1995 <div class="status-reply-badge"> 1996 <Icon icon="reply" />{' '} 1997 <NameText 1998 account={inReplyToAccount} 1999 instance={instance} 2000 short 2001 /> 2002 </div> 2003 ) 2004 )} 2005 </> 2006 )} 2007 <div 2008 class={`content-container ${ 2009 spoilerText || sensitive || filterInfo?.action === 'blur' 2010 ? 'has-spoiler' 2011 : '' 2012 } ${showSpoiler ? 'show-spoiler' : ''} ${ 2013 showSpoilerMedia ? 'show-media' : '' 2014 }`} 2015 data-content-text-weight={contentTextWeight ? textWeight() : null} 2016 style={ 2017 (isSizeLarge || contentTextWeight) && { 2018 '--content-text-weight': textWeight(), 2019 } 2020 } 2021 > 2022 {mediaFirst && hasMediaAttachments ? ( 2023 <> 2024 {(!!spoilerText || !!sensitive) && !readingExpandSpoilers && ( 2025 <> 2026 {!!spoilerText && ( 2027 <span 2028 class="spoiler-content media-first-spoiler-content" 2029 lang={language} 2030 dir="auto" 2031 ref={spoilerContentRef} 2032 data-read-more={_(readMoreText)} 2033 > 2034 <EmojiText text={spoilerText} emojis={emojis} />{' '} 2035 </span> 2036 )} 2037 <button 2038 class={`light spoiler-button media-first-spoiler-button ${ 2039 showSpoiler ? 'spoiling' : '' 2040 }`} 2041 type="button" 2042 onClick={(e) => { 2043 e.preventDefault(); 2044 e.stopPropagation(); 2045 if (showSpoiler) { 2046 delete states.spoilers[id]; 2047 if (!readingExpandSpoilers) { 2048 delete states.spoilersMedia[id]; 2049 } 2050 } else { 2051 states.spoilers[id] = true; 2052 if (!readingExpandSpoilers) { 2053 states.spoilersMedia[id] = true; 2054 } 2055 } 2056 }} 2057 > 2058 <Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '} 2059 {showSpoiler ? t`Show less` : t`Show content`} 2060 </button> 2061 </> 2062 )} 2063 <MediaFirstContainer 2064 mediaAttachments={mediaAttachments} 2065 language={language} 2066 postID={id} 2067 instance={instance} 2068 /> 2069 {!!content && ( 2070 <div class="media-first-content content" ref={contentRef}> 2071 <PostContent 2072 post={status} 2073 instance={instance} 2074 previewMode={previewMode} 2075 /> 2076 </div> 2077 )} 2078 </> 2079 ) : ( 2080 <> 2081 {!!spoilerText && ( 2082 <> 2083 <div 2084 class="content spoiler-content" 2085 lang={language} 2086 dir="auto" 2087 ref={spoilerContentRef} 2088 data-read-more={_(readMoreText)} 2089 > 2090 <p> 2091 <EmojiText text={spoilerText} emojis={emojis} /> 2092 </p> 2093 </div> 2094 {readingExpandSpoilers || previewMode ? ( 2095 <div class="spoiler-divider"> 2096 <Icon icon="eye-open" /> <Trans>Content warning</Trans> 2097 </div> 2098 ) : ( 2099 <button 2100 class={`light spoiler-button ${ 2101 showSpoiler ? 'spoiling' : '' 2102 }`} 2103 type="button" 2104 onClick={(e) => { 2105 e.preventDefault(); 2106 e.stopPropagation(); 2107 if (showSpoiler) { 2108 delete states.spoilers[id]; 2109 if (!readingExpandSpoilers) { 2110 delete states.spoilersMedia[id]; 2111 } 2112 } else { 2113 states.spoilers[id] = true; 2114 if (!readingExpandSpoilers) { 2115 states.spoilersMedia[id] = true; 2116 } 2117 } 2118 }} 2119 > 2120 <Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '} 2121 {showSpoiler ? t`Show less` : t`Show content`} 2122 </button> 2123 )} 2124 </> 2125 )} 2126 {!!content && ( 2127 <div 2128 class="content" 2129 ref={contentRef} 2130 data-read-more={_(readMoreText)} 2131 inert={!!spoilerText && !showSpoiler ? true : undefined} 2132 > 2133 <PostContent 2134 key={reloadPostContentCount} 2135 post={status} 2136 instance={instance} 2137 previewMode={previewMode} 2138 /> 2139 <QuoteStatuses id={id} instance={instance} level={quoted} /> 2140 </div> 2141 )} 2142 {!!content && ( 2143 <MathBlock 2144 content={content} 2145 contentRef={contentRef} 2146 onRevert={reloadPostContent} 2147 /> 2148 )} 2149 {!!poll && ( 2150 <Poll 2151 lang={language} 2152 poll={poll} 2153 readOnly={readOnly || !sameInstance || !authenticated} 2154 onUpdate={(newPoll) => { 2155 states.statuses[sKey].poll = newPoll; 2156 }} 2157 refresh={() => { 2158 return masto.v1.polls 2159 .$select(poll.id) 2160 .fetch() 2161 .then((pollResponse) => { 2162 states.statuses[sKey].poll = pollResponse; 2163 }) 2164 .catch((e) => {}); // Silently fail 2165 }} 2166 votePoll={(choices) => { 2167 return masto.v1.polls 2168 .$select(poll.id) 2169 .votes.create({ 2170 choices, 2171 }) 2172 .then((pollResponse) => { 2173 states.statuses[sKey].poll = pollResponse; 2174 }) 2175 .catch((e) => {}); // Silently fail 2176 }} 2177 /> 2178 )} 2179 {(((enableTranslate || inlineTranslate) && 2180 isTranslateble(content, emojis) && 2181 differentLanguage) || 2182 forceTranslate) && ( 2183 <TranslationBlock 2184 forceTranslate={forceTranslate || inlineTranslate} 2185 mini={!isSizeLarge && !withinContext} 2186 sourceLanguage={language} 2187 autoDetected={languageAutoDetected} 2188 text={getPostText(status, { 2189 maskCustomEmojis: true, 2190 maskURLs: true, 2191 })} 2192 /> 2193 )} 2194 {!previewMode && 2195 (sensitive || filterInfo?.action === 'blur') && 2196 !!mediaAttachments.length && 2197 (readingExpandMedia !== 'show_all' || 2198 filterInfo?.action === 'blur') && ( 2199 <button 2200 class={`plain spoiler-media-button ${ 2201 showSpoilerMedia ? 'spoiling' : '' 2202 }`} 2203 type="button" 2204 hidden={!readingExpandSpoilers && !!spoilerText} 2205 onClick={(e) => { 2206 e.preventDefault(); 2207 e.stopPropagation(); 2208 if (showSpoilerMedia) { 2209 delete states.spoilersMedia[id]; 2210 } else { 2211 states.spoilersMedia[id] = true; 2212 } 2213 }} 2214 > 2215 <Icon 2216 icon={showSpoilerMedia ? 'eye-open' : 'eye-close'} 2217 />{' '} 2218 <span> 2219 {filterInfo?.action === 'blur' && ( 2220 <small> 2221 <Trans>Filtered: {filterInfo?.titlesStr}</Trans> 2222 <br /> 2223 </small> 2224 )} 2225 {showSpoilerMedia ? t`Show less` : t`Show media`} 2226 </span> 2227 </button> 2228 )} 2229 {!!mediaAttachments.length && 2230 (mediaAttachments.length > 1 && 2231 (isSizeLarge || (withinContext && size === 'm')) ? ( 2232 <div class="media-large-container"> 2233 {mediaAttachments.map((media, i) => ( 2234 <div key={media.id} class={`media-container media-eq1`}> 2235 <Media 2236 media={media} 2237 autoAnimate 2238 showCaption 2239 allowLongerCaption={!content || isSizeLarge} 2240 lang={language} 2241 to={`/${instance}/s/${id}?${ 2242 withinContext ? 'media' : 'media-only' 2243 }=${i + 1}`} 2244 onClick={ 2245 onMediaClick 2246 ? (e) => onMediaClick(e, i, media, status) 2247 : undefined 2248 } 2249 /> 2250 </div> 2251 ))} 2252 </div> 2253 ) : ( 2254 <MultipleMediaFigure 2255 lang={language} 2256 enabled={showMultipleMediaCaptions} 2257 captionChildren={captionChildren} 2258 > 2259 <div 2260 ref={mediaContainerRef} 2261 class={`media-container media-eq${ 2262 mediaAttachments.length 2263 } ${mediaAttachments.length > 2 ? 'media-gt2' : ''} ${ 2264 mediaAttachments.length > 4 ? 'media-gt4' : '' 2265 }`} 2266 > 2267 {displayedMediaAttachments.map((media, i) => ( 2268 <Media 2269 key={media.id} 2270 media={media} 2271 autoAnimate={isSizeLarge} 2272 showCaption={mediaAttachments.length === 1} 2273 allowLongerCaption={ 2274 !content && mediaAttachments.length === 1 2275 } 2276 lang={language} 2277 altIndex={ 2278 showMultipleMediaCaptions && 2279 !!media.description && 2280 i + 1 2281 } 2282 to={`/${instance}/s/${id}?${ 2283 withinContext ? 'media' : 'media-only' 2284 }=${i + 1}`} 2285 onClick={ 2286 onMediaClick 2287 ? (e) => { 2288 onMediaClick(e, i, media, status); 2289 } 2290 : undefined 2291 } 2292 checkAspectRatio={mediaAttachments.length === 1} 2293 /> 2294 ))} 2295 </div> 2296 </MultipleMediaFigure> 2297 ))} 2298 {!!card && 2299 /^https/i.test(card?.url) && 2300 !sensitive && 2301 !spoilerText && 2302 !poll && 2303 !mediaAttachments.length && 2304 !snapStates.statusQuotes[sKey] && ( 2305 <StatusCard 2306 card={card} 2307 selfReferential={ 2308 card?.url === status.url || card?.url === status.uri 2309 } 2310 selfAuthor={card?.authors?.some( 2311 (a) => a.account?.url === accountURL, 2312 )} 2313 instance={currentInstance} 2314 /> 2315 )} 2316 </> 2317 )} 2318 </div> 2319 {!isSizeLarge && showCommentCount && ( 2320 <div class="content-comment-hint insignificant"> 2321 <Icon icon="comment2" alt={t`Replies`} /> {repliesCount} 2322 </div> 2323 )} 2324 {isSizeLarge && ( 2325 <> 2326 <div class="extra-meta"> 2327 {_deleted ? ( 2328 <span class="status-deleted-tag"> 2329 <Trans>Deleted</Trans> 2330 </span> 2331 ) : ( 2332 <> 2333 {/* <Icon 2334 icon={visibilityIconsMap[visibility]} 2335 alt={visibilityText[visibility]} 2336 /> */} 2337 <span>{_(visibilityText[visibility])}</span> &bull;{' '} 2338 <a href={url} target="_blank" rel="noopener"> 2339 { 2340 // within a day 2341 Date.now() - createdAtDate.getTime() < 86400000 && ( 2342 <> 2343 <RelativeTime 2344 datetime={createdAtDate} 2345 format="micro" 2346 />{' '} 2347 ‒{' '} 2348 </> 2349 ) 2350 } 2351 {!!createdAt && ( 2352 <time 2353 class="created" 2354 datetime={createdAtDate.toISOString()} 2355 title={createdAtDate.toLocaleString()} 2356 > 2357 {createdDateText} 2358 </time> 2359 )} 2360 </a> 2361 {editedAt && ( 2362 <> 2363 {' '} 2364 &bull; <Icon icon="pencil" alt={t`Edited`} />{' '} 2365 <time 2366 tabIndex="0" 2367 class="edited" 2368 datetime={editedAtDate.toISOString()} 2369 onClick={() => { 2370 setShowEdited(id); 2371 }} 2372 > 2373 {editedDateText} 2374 </time> 2375 </> 2376 )} 2377 </> 2378 )} 2379 </div> 2380 {!!emojiReactions?.length && ( 2381 <div class="emoji-reactions"> 2382 {emojiReactions.map((emojiReaction) => { 2383 const { name, count, me, url, staticUrl } = emojiReaction; 2384 if (url) { 2385 // Some servers return url and staticUrl 2386 return ( 2387 <span 2388 class={`emoji-reaction tag ${ 2389 me ? '' : 'insignificant' 2390 }`} 2391 > 2392 <CustomEmoji 2393 alt={name} 2394 url={url} 2395 staticUrl={staticUrl} 2396 />{' '} 2397 {count} 2398 </span> 2399 ); 2400 } 2401 const isShortCode = /^:.+?:$/.test(name); 2402 if (isShortCode) { 2403 const emoji = emojis.find( 2404 (e) => 2405 e.shortcode === 2406 name.replace(/^:/, '').replace(/:$/, ''), 2407 ); 2408 if (emoji) { 2409 return ( 2410 <span 2411 class={`emoji-reaction tag ${ 2412 me ? '' : 'insignificant' 2413 }`} 2414 > 2415 <CustomEmoji 2416 alt={name} 2417 url={emoji.url} 2418 staticUrl={emoji.staticUrl} 2419 />{' '} 2420 {count} 2421 </span> 2422 ); 2423 } 2424 } 2425 return ( 2426 <span 2427 class={`emoji-reaction tag ${ 2428 me ? '' : 'insignificant' 2429 }`} 2430 > 2431 {name} {count} 2432 </span> 2433 ); 2434 })} 2435 </div> 2436 )} 2437 <div class={`actions ${_deleted ? 'disabled' : ''}`}> 2438 <div class="action has-count"> 2439 <StatusButton 2440 title={t`Reply`} 2441 alt={t`Comments`} 2442 class="reply-button" 2443 icon="comment" 2444 count={repliesCount} 2445 onClick={replyStatus} 2446 /> 2447 </div> 2448 {/* <div class="action has-count"> 2449 <StatusButton 2450 checked={reblogged} 2451 title={['Boost', 'Unboost']} 2452 alt={['Boost', 'Boosted']} 2453 class="reblog-button" 2454 icon="rocket" 2455 count={reblogsCount} 2456 onClick={boostStatus} 2457 disabled={!canBoost} 2458 /> 2459 </div> */} 2460 <div class="action has-count"> 2461 <MenuConfirm 2462 disabled={!canBoost} 2463 onClick={confirmBoostStatus} 2464 confirmLabel={ 2465 <> 2466 <Icon icon="rocket" /> 2467 <span>{reblogged ? t`Unboost` : t`Boost`}</span> 2468 </> 2469 } 2470 menuExtras={ 2471 <MenuItem 2472 onClick={() => { 2473 showCompose({ 2474 draftStatus: { 2475 status: `\n${url}`, 2476 }, 2477 }); 2478 }} 2479 > 2480 <Icon icon="quote" /> 2481 <span> 2482 <Trans>Quote</Trans> 2483 </span> 2484 </MenuItem> 2485 } 2486 menuFooter={menuFooter} 2487 > 2488 <StatusButton 2489 checked={reblogged} 2490 title={[t`Boost`, t`Unboost`]} 2491 alt={[t`Boost`, t`Boosted`]} 2492 class="reblog-button" 2493 icon="rocket" 2494 count={reblogsCount} 2495 // onClick={boostStatus} 2496 disabled={!canBoost} 2497 /> 2498 </MenuConfirm> 2499 </div> 2500 <div class="action has-count"> 2501 <StatusButton 2502 checked={favourited} 2503 title={[t`Like`, t`Unlike`]} 2504 alt={[t`Like`, t`Liked`]} 2505 class="favourite-button" 2506 icon="heart" 2507 count={favouritesCount} 2508 onClick={favouriteStatus} 2509 /> 2510 </div> 2511 {supports('@mastodon/post-bookmark') && ( 2512 <div class="action"> 2513 <StatusButton 2514 checked={bookmarked} 2515 title={[t`Bookmark`, t`Unbookmark`]} 2516 alt={[t`Bookmark`, t`Bookmarked`]} 2517 class="bookmark-button" 2518 icon="bookmark" 2519 onClick={bookmarkStatus} 2520 /> 2521 </div> 2522 )} 2523 <Menu2 2524 portal={{ 2525 target: 2526 document.querySelector('.status-deck') || document.body, 2527 }} 2528 align="end" 2529 gap={4} 2530 overflow="auto" 2531 viewScroll="close" 2532 menuButton={ 2533 <div class="action"> 2534 <button 2535 type="button" 2536 title={t`More`} 2537 class="plain more-button" 2538 > 2539 <Icon icon="more" size="l" alt={t`More`} /> 2540 </button> 2541 </div> 2542 } 2543 > 2544 {StatusMenuItems}{' '} 2545 </Menu2> 2546 </div> 2547 </> 2548 )} 2549 </div> 2550 {!!showEdited && ( 2551 <Modal 2552 onClick={(e) => { 2553 if (e.target === e.currentTarget) { 2554 setShowEdited(false); 2555 // statusRef.current?.focus(); 2556 } 2557 }} 2558 > 2559 <EditedAtModal 2560 statusID={showEdited} 2561 instance={instance} 2562 fetchStatusHistory={() => { 2563 return masto.v1.statuses.$select(showEdited).history.list(); 2564 }} 2565 onClose={() => { 2566 setShowEdited(false); 2567 statusRef.current?.focus(); 2568 }} 2569 /> 2570 </Modal> 2571 )} 2572 {!!showEmbed && ( 2573 <Modal 2574 onClick={(e) => { 2575 if (e.target === e.currentTarget) { 2576 setShowEmbed(false); 2577 } 2578 }} 2579 > 2580 <PostEmbedModal 2581 post={status} 2582 instance={instance} 2583 onClose={() => { 2584 setShowEmbed(false); 2585 }} 2586 /> 2587 </Modal> 2588 )} 2589 </article> 2590 </StatusParent> 2591 ); 2592} 2593 2594function nicePostURL(url) { 2595 if (!url) return; 2596 const urlObj = URL.parse(url); 2597 if (!urlObj) return; 2598 const { host, pathname } = urlObj; 2599 const path = pathname.replace(/\/$/, ''); 2600 // split only first slash 2601 const [_, username, restPath] = path.match(/\/(@[^\/]+)\/(.*)/) || []; 2602 return ( 2603 <> 2604 {punycode.toUnicode(host)} 2605 {username ? ( 2606 <> 2607 /{username} 2608 <wbr /> 2609 <span class="more-insignificant">/{restPath}</span> 2610 </> 2611 ) : ( 2612 <span class="more-insignificant">{path}</span> 2613 )} 2614 </> 2615 ); 2616} 2617 2618const handledUnfulfilledStates = [ 2619 'deleted', 2620 'unauthorized', 2621 'pending', 2622 'rejected', 2623 'revoked', 2624]; 2625const unfulfilledText = { 2626 filterHidden: msg`Post hidden by your filters`, 2627 pending: msg`Post pending`, 2628 deleted: msg`Post unavailable`, 2629 unauthorized: msg`Post unavailable`, 2630 rejected: msg`Post unavailable`, 2631 revoked: msg`Post unavailable`, 2632}; 2633 2634const QuoteStatuses = memo(({ id, instance, level = 0 }) => { 2635 if (!id || !instance) return; 2636 const { _ } = useLingui(); 2637 const snapStates = useSnapshot(states); 2638 const sKey = statusKey(id, instance); 2639 const quotes = snapStates.statusQuotes[sKey]; 2640 const uniqueQuotes = quotes?.filter( 2641 (q, i, arr) => arr.findIndex((q2) => q2.url === q.url) === i, 2642 ); 2643 2644 if (!uniqueQuotes?.length) return; 2645 if (level > 2) return; 2646 2647 const filterContext = useContext(FilterContext); 2648 const currentAccount = getCurrentAccountID(); 2649 2650 return uniqueQuotes.map((q) => { 2651 let unfulfilledState; 2652 2653 const quoteStatus = snapStates.statuses[statusKey(q.id, q.instance)]; 2654 if (quoteStatus) { 2655 const isSelf = 2656 currentAccount && currentAccount === quoteStatus.account?.id; 2657 const filterInfo = 2658 !isSelf && isFiltered(quoteStatus.filtered, filterContext); 2659 2660 if (filterInfo?.action === 'hide') { 2661 unfulfilledState = 'filterHidden'; 2662 } 2663 } 2664 2665 if (!unfulfilledState) { 2666 unfulfilledState = handledUnfulfilledStates.find( 2667 (state) => q.state === state, 2668 ); 2669 } 2670 2671 if (unfulfilledState) { 2672 return ( 2673 <div 2674 class={`status-card-unfulfilled ${ 2675 unfulfilledState === 'filterHidden' ? 'status-card-ghost' : '' 2676 }`} 2677 > 2678 <Icon icon="quote" /> 2679 <i>{_(unfulfilledText[unfulfilledState])}</i> 2680 </div> 2681 ); 2682 } 2683 2684 const Parent = q.native ? Fragment : LazyShazam; 2685 return ( 2686 <Parent id={q.instance + q.id} key={q.instance + q.id}> 2687 <Link 2688 key={q.instance + q.id} 2689 to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`} 2690 class={`status-card-link ${q.native ? 'quote-post-native' : ''}`} 2691 data-read-more={_(readMoreText)} 2692 > 2693 <Status 2694 statusID={q.id} 2695 instance={q.instance} 2696 size="s" 2697 quoted={level + 1} 2698 enableCommentHint 2699 /> 2700 </Link> 2701 </Parent> 2702 ); 2703 }); 2704}); 2705 2706function EditedAtModal({ 2707 statusID, 2708 instance, 2709 fetchStatusHistory = () => {}, 2710 onClose, 2711}) { 2712 const { t } = useLingui(); 2713 const [uiState, setUIState] = useState('default'); 2714 const [editHistory, setEditHistory] = useState([]); 2715 2716 useEffect(() => { 2717 setUIState('loading'); 2718 (async () => { 2719 try { 2720 const editHistory = await fetchStatusHistory(); 2721 console.log(editHistory); 2722 setEditHistory(editHistory); 2723 setUIState('default'); 2724 } catch (e) { 2725 console.error(e); 2726 setUIState('error'); 2727 } 2728 })(); 2729 }, []); 2730 2731 return ( 2732 <div id="edit-history" class="sheet"> 2733 {!!onClose && ( 2734 <button type="button" class="sheet-close" onClick={onClose}> 2735 <Icon icon="x" alt={t`Close`} /> 2736 </button> 2737 )} 2738 <header> 2739 <h2> 2740 <Trans>Edit History</Trans> 2741 </h2> 2742 {uiState === 'error' && ( 2743 <p> 2744 <Trans>Failed to load history</Trans> 2745 </p> 2746 )} 2747 {uiState === 'loading' && ( 2748 <p> 2749 <Loader abrupt /> <Trans>Loading…</Trans> 2750 </p> 2751 )} 2752 </header> 2753 <main tabIndex="-1"> 2754 {editHistory.length > 0 && ( 2755 <ol> 2756 {editHistory.map((status) => { 2757 const { createdAt } = status; 2758 const createdAtDate = new Date(createdAt); 2759 return ( 2760 <li key={createdAt} class="history-item"> 2761 <h3> 2762 <time> 2763 {niceDateTime(createdAtDate, { 2764 formatOpts: { 2765 weekday: 'short', 2766 second: 'numeric', 2767 }, 2768 })} 2769 </time> 2770 </h3> 2771 <Status 2772 status={status} 2773 instance={instance} 2774 size="s" 2775 withinContext 2776 readOnly 2777 previewMode 2778 /> 2779 </li> 2780 ); 2781 })} 2782 </ol> 2783 )} 2784 </main> 2785 </div> 2786 ); 2787} 2788 2789function FilteredStatus({ 2790 status, 2791 filterInfo, 2792 instance, 2793 containerProps = {}, 2794 showFollowedTags, 2795 quoted, 2796}) { 2797 const { _, t } = useLingui(); 2798 const snapStates = useSnapshot(states); 2799 const { 2800 id: statusID, 2801 account: { avatar, avatarStatic, bot, group }, 2802 createdAt, 2803 visibility, 2804 reblog, 2805 } = status; 2806 const isReblog = !!reblog; 2807 const filterTitleStr = filterInfo?.titlesStr || ''; 2808 const createdAtDate = new Date(createdAt); 2809 const statusPeekText = statusPeek(status.reblog || status); 2810 2811 const [showPeek, setShowPeek] = useState(false); 2812 const bindLongPressPeek = useLongPress( 2813 () => { 2814 setShowPeek(true); 2815 }, 2816 { 2817 threshold: 600, 2818 captureEvent: true, 2819 detect: 'touch', 2820 cancelOnMovement: 2, // true allows movement of up to 25 pixels 2821 }, 2822 ); 2823 2824 const statusPeekRef = useTruncated(); 2825 const sKey = statusKey(status.id, instance); 2826 const ssKey = 2827 statusKey(status.id, instance) + 2828 ' ' + 2829 (statusKey(reblog?.id, instance) || ''); 2830 2831 const actualStatusID = reblog?.id || statusID; 2832 const url = instance 2833 ? `/${instance}/s/${actualStatusID}` 2834 : `/s/${actualStatusID}`; 2835 const isFollowedTags = 2836 showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length; 2837 2838 return ( 2839 <div 2840 class={`${ 2841 quoted 2842 ? '' 2843 : isReblog 2844 ? group 2845 ? 'status-group' 2846 : 'status-reblog' 2847 : isFollowedTags 2848 ? 'status-followed-tags' 2849 : '' 2850 } visibility-${visibility}`} 2851 {...containerProps} 2852 // title={statusPeekText} 2853 onContextMenu={(e) => { 2854 e.preventDefault(); 2855 setShowPeek(true); 2856 }} 2857 {...bindLongPressPeek()} 2858 > 2859 <article 2860 data-state-post-id={ssKey} 2861 class={`status filtered ${quoted ? 'status-card' : ''}`} 2862 tabindex="-1" 2863 > 2864 <b 2865 class="status-filtered-badge clickable badge-meta" 2866 title={filterTitleStr} 2867 onClick={(e) => { 2868 e.preventDefault(); 2869 setShowPeek(true); 2870 }} 2871 > 2872 <span> 2873 <Trans>Filtered</Trans> 2874 </span> 2875 <span>{filterTitleStr}</span> 2876 </b>{' '} 2877 <Avatar url={avatarStatic || avatar} squircle={bot} /> 2878 <span class="status-filtered-info"> 2879 <span class="status-filtered-info-1"> 2880 {isReblog ? ( 2881 <Trans comment="[Name] [Visibility icon] boosted"> 2882 <NameText account={status.account} instance={instance} />{' '} 2883 <Icon 2884 icon={visibilityIconsMap[visibility]} 2885 alt={_(visibilityText[visibility])} 2886 size="s" 2887 />{' '} 2888 boosted 2889 </Trans> 2890 ) : isFollowedTags ? ( 2891 <> 2892 <NameText account={status.account} instance={instance} />{' '} 2893 <Icon 2894 icon={visibilityIconsMap[visibility]} 2895 alt={_(visibilityText[visibility])} 2896 size="s" 2897 />{' '} 2898 <span> 2899 {snapStates.statusFollowedTags[sKey] 2900 .slice(0, 3) 2901 .map((tag) => ( 2902 <span key={tag} class="status-followed-tag-item"> 2903 #{tag} 2904 </span> 2905 ))} 2906 </span> 2907 </> 2908 ) : ( 2909 <> 2910 <NameText account={status.account} instance={instance} />{' '} 2911 <Icon 2912 icon={visibilityIconsMap[visibility]} 2913 alt={_(visibilityText[visibility])} 2914 size="s" 2915 />{' '} 2916 <RelativeTime datetime={createdAtDate} format="micro" /> 2917 </> 2918 )} 2919 </span> 2920 <span class="status-filtered-info-2"> 2921 {isReblog && ( 2922 <> 2923 <Avatar 2924 url={reblog.account.avatarStatic || reblog.account.avatar} 2925 squircle={bot} 2926 />{' '} 2927 </> 2928 )} 2929 {statusPeekText} 2930 </span> 2931 </span> 2932 </article> 2933 {!!showPeek && ( 2934 <Modal 2935 onClick={(e) => { 2936 if (e.target === e.currentTarget) { 2937 setShowPeek(false); 2938 } 2939 }} 2940 > 2941 <div id="filtered-status-peek" class="sheet"> 2942 <button 2943 type="button" 2944 class="sheet-close" 2945 onClick={() => setShowPeek(false)} 2946 > 2947 <Icon icon="x" alt={t`Close`} /> 2948 </button> 2949 <header> 2950 <b class="status-filtered-badge"> 2951 <Trans>Filtered</Trans> 2952 </b>{' '} 2953 {filterTitleStr} 2954 </header> 2955 <main tabIndex="-1"> 2956 <Link 2957 ref={statusPeekRef} 2958 class="status-link" 2959 to={url} 2960 onClick={() => { 2961 setShowPeek(false); 2962 }} 2963 data-read-more={_(readMoreText)} 2964 > 2965 <Status status={status} instance={instance} size="s" readOnly /> 2966 </Link> 2967 </main> 2968 </div> 2969 </Modal> 2970 )} 2971 </div> 2972 ); 2973} 2974 2975export default memo(Status, (oldProps, newProps) => { 2976 // Shallow equal all props except 'status' 2977 // This will be pure static until status ID changes 2978 const { status, ...restOldProps } = oldProps; 2979 const { status: newStatus, ...restNewProps } = newProps; 2980 return ( 2981 status?.id === newStatus?.id && shallowEqual(restOldProps, restNewProps) 2982 ); 2983});