status.jsx
1 import './status.css'; 2 3 import '@justinribeiro/lite-youtube'; 4 import { 5 ControlledMenu, 6 Menu, 7 MenuDivider, 8 MenuHeader, 9 MenuItem, 10 } from '@szhsin/react-menu'; 11 import { decodeBlurHash } from 'fast-blurhash'; 12 import pThrottle from 'p-throttle'; 13 import { memo } from 'preact/compat'; 14 import { 15 useCallback, 16 useEffect, 17 useMemo, 18 useRef, 19 useState, 20 } from 'preact/hooks'; 21 import { useHotkeys } from 'react-hotkeys-hook'; 22 import { InView } from 'react-intersection-observer'; 23 import { useLongPress } from 'use-long-press'; 24 import { useSnapshot } from 'valtio'; 25 import { snapshot } from 'valtio/vanilla'; 26 27 import AccountBlock from '../components/account-block'; 28 import EmojiText from '../components/emoji-text'; 29 import Loader from '../components/loader'; 30 import MenuConfirm from '../components/menu-confirm'; 31 import Modal from '../components/modal'; 32 import NameText from '../components/name-text'; 33 import Poll from '../components/poll'; 34 import { api } from '../utils/api'; 35 import emojifyText from '../utils/emojify-text'; 36 import enhanceContent from '../utils/enhance-content'; 37 import getTranslateTargetLanguage from '../utils/get-translate-target-language'; 38 import getHTMLText from '../utils/getHTMLText'; 39 import handleContentLinks from '../utils/handle-content-links'; 40 import htmlContentLength from '../utils/html-content-length'; 41 import isMastodonLinkMaybe from '../utils/isMastodonLinkMaybe'; 42 import localeMatch from '../utils/locale-match'; 43 import niceDateTime from '../utils/nice-date-time'; 44 import pmem from '../utils/pmem'; 45 import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding'; 46 import shortenNumber from '../utils/shorten-number'; 47 import showToast from '../utils/show-toast'; 48 import states, { getStatus, saveStatus, statusKey } from '../utils/states'; 49 import statusPeek from '../utils/status-peek'; 50 import store from '../utils/store'; 51 import useTruncated from '../utils/useTruncated'; 52 import visibilityIconsMap from '../utils/visibility-icons-map'; 53 54 import Avatar from './avatar'; 55 import Icon from './icon'; 56 import Link from './link'; 57 import Media from './media'; 58 import { isMediaCaptionLong } from './media'; 59 import MenuLink from './menu-link'; 60 import RelativeTime from './relative-time'; 61 import TranslationBlock from './translation-block'; 62 import { useLocation } from 'react-router-dom'; 63 import { sendVibeEvent, setVibeTagCount, vibeCountDict, cleanContentString, commonwords } from '../utils/vibe-tag'; 64 65 const INLINE_TRANSLATE_LIMIT = 140; 66 const throttle = pThrottle({ 67 limit: 1, 68 interval: 1000, 69 }); 70 71 function fetchAccount(id, masto) { 72 return masto.v1.accounts.$select(id).fetch(); 73 } 74 const memFetchAccount = pmem(fetchAccount); 75 76 const visibilityText = { 77 public: 'Public', 78 unlisted: 'Unlisted', 79 private: 'Followers only', 80 direct: 'Private mention', 81 }; 82 83 let vibeTagCountSet = false; 84 85 function Status({ 86 statusID, 87 status, 88 instance: propInstance, 89 withinContext, 90 size = 'm', 91 skeleton, 92 readOnly, 93 contentTextWeight, 94 enableTranslate, 95 forceTranslate: _forceTranslate, 96 previewMode, 97 allowFilters, 98 onMediaClick, 99 quoted, 100 isGroupStatus, 101 onStatusLinkClick = () => {}, 102 }) { 103 if (skeleton) { 104 return ( 105 <div class="status skeleton"> 106 <Avatar size="xxl" /> 107 <div class="container"> 108 <div class="meta">███ ████████</div> 109 <div class="content-container"> 110 <div class="content"> 111 <p>████ ████████</p> 112 </div> 113 </div> 114 </div> 115 </div> 116 ); 117 } 118 const { masto, instance, authenticated } = api({ instance: propInstance }); 119 const { instance: myLocalInstance } = api(); 120 const { 121 masto: currentMasto 122 } = api(); 123 const sameInstance = instance === myLocalInstance; 124 125 let sKey = statusKey(statusID, instance); 126 const snapStates = useSnapshot(states); 127 if (!status) { 128 status = snapStates.statuses[sKey] || snapStates.statuses[statusID]; 129 sKey = statusKey(status?.id, instance); 130 if (!vibeTagCountSet){ 131 setVibeTagCount(status.id, 'provocative'); 132 setVibeTagCount(status.id, 'positive'); 133 vibeTagCountSet = true; 134 } 135 136 } 137 if (!status) { 138 return null; 139 } 140 141 let shouldHide; 142 143 let provocContentWordDictCheck = localStorage.getItem("provocContentWordDict"); 144 if (!provocContentWordDictCheck) { 145 let placeholder = JSON.stringify({"": 0}); 146 localStorage.setItem("provocContentWordDict", placeholder); 147 } 148 149 let provocContentWordDict = JSON.parse(localStorage.getItem("provocContentWordDict")); 150 let cleanedContent = cleanContentString(status.content); 151 let statusWordArray = cleanedContent; 152 let provocContentWordArray = Object.entries(provocContentWordDict); 153 // Sort the array based on numerical values in descending order 154 155 let sortedArray = provocContentWordArray.sort(function(a, b) { 156 console.log(`a[0] is ${a[0]}, b[0] is ${b[0]}`) 157 return b[1] - a[1]; 158 }); 159 160 let worstWordsArray = sortedArray.slice(0, 15); 161 let worstWordsObj = {}; 162 worstWordsArray.forEach(function(item) { 163 worstWordsObj[item[0]] = item[1]; 164 }); 165 console.log(`Top provocative words: ${worstWordsArray}`); 166 const commonWordsArray = commonwords.map(obj => obj.word); 167 statusWordArray.forEach((word) => { 168 if (commonWordsArray.includes(word)) { 169 console.log(`ignore common word: ${word}`); 170 } else if (worstWordsObj.hasOwnProperty(word)) { 171 shouldHide = true; 172 return; 173 } 174 }); 175 176 const { 177 account: { 178 acct, 179 avatar, 180 avatarStatic, 181 id: accountId, 182 url: accountURL, 183 displayName, 184 username, 185 emojis: accountEmojis, 186 bot, 187 group, 188 }, 189 id, 190 repliesCount, 191 reblogged, 192 reblogsCount, 193 labeledProvocative, 194 labeledProvocativeCount, 195 labeledPositiveVibe, 196 labeledPositiveVibeCount, 197 favourited, 198 favouritesCount, 199 bookmarked, 200 poll, 201 muted, 202 sensitive, 203 spoilerText, 204 visibility, // public, unlisted, private, direct 205 language, 206 editedAt, 207 filtered, 208 card, 209 createdAt, 210 inReplyToId, 211 inReplyToAccountId, 212 content, 213 mentions, 214 mediaAttachments, 215 reblog, 216 uri, 217 url, 218 emojis, 219 // Non-API props 220 _deleted, 221 _pinned, 222 _filtered, 223 } = status; 224 225 // console.debug('RENDER Status', id, status?.account.displayName, quoted); 226 227 const debugHover = (e) => { 228 if (e.shiftKey) { 229 console.log({ 230 ...status, 231 }); 232 } 233 }; 234 235 if ((allowFilters && size !== 'l' && _filtered) || shouldHide) { 236 return ( 237 <FilteredStatus 238 status={status} 239 filterInfo={_filtered} 240 instance={instance} 241 containerProps={{ 242 onMouseEnter: debugHover, 243 }} 244 /> 245 ); 246 } 247 248 const createdAtDate = new Date(createdAt); 249 const editedAtDate = new Date(editedAt); 250 251 const currentAccount = useMemo(() => { 252 return store.session.get('currentAccount'); 253 }, []); 254 const isSelf = useMemo(() => { 255 return currentAccount && currentAccount === accountId; 256 }, [accountId, currentAccount]); 257 258 let inReplyToAccountRef = mentions?.find( 259 (mention) => mention.id === inReplyToAccountId, 260 ); 261 if (!inReplyToAccountRef && inReplyToAccountId === id) { 262 inReplyToAccountRef = { url: accountURL, username, displayName }; 263 } 264 const [inReplyToAccount, setInReplyToAccount] = useState(inReplyToAccountRef); 265 if (!withinContext && !inReplyToAccount && inReplyToAccountId) { 266 const account = states.accounts[inReplyToAccountId]; 267 if (account) { 268 setInReplyToAccount(account); 269 } else { 270 memFetchAccount(inReplyToAccountId, masto) 271 .then((account) => { 272 setInReplyToAccount(account); 273 states.accounts[account.id] = account; 274 }) 275 .catch((e) => {}); 276 } 277 } 278 const mentionSelf = 279 inReplyToAccountId === currentAccount || 280 mentions?.find((mention) => mention.id === currentAccount); 281 282 const readingExpandSpoilers = useMemo(() => { 283 const prefs = store.account.get('preferences') || {}; 284 return !!prefs['reading:expand:spoilers']; 285 }, []); 286 const showSpoiler = 287 previewMode || readingExpandSpoilers || !!snapStates.spoilers[id] || false; 288 289 if (reblog) { 290 // If has statusID, means useItemID (cached in states) 291 292 if (group) { 293 return ( 294 <div class="status-group" onMouseEnter={debugHover}> 295 <div class="status-pre-meta"> 296 <Icon icon="group" size="l" alt="Group" />{' '} 297 <NameText account={status.account} instance={instance} showAvatar /> 298 </div> 299 <Status 300 status={statusID ? null : reblog} 301 statusID={statusID ? reblog.id : null} 302 isGroupStatus={true} 303 instance={instance} 304 size={size} 305 contentTextWeight={contentTextWeight} 306 /> 307 </div> 308 ); 309 } 310 311 return ( 312 <div class="status-reblog" onMouseEnter={debugHover}> 313 <div class="status-pre-meta"> 314 <Icon icon="rocket" size="l" />{' '} 315 <NameText account={status.account} instance={instance} showAvatar />{' '} 316 <span>boosted</span> 317 </div> 318 <Status 319 status={statusID ? null : reblog} 320 statusID={statusID ? reblog.id : null} 321 instance={instance} 322 size={size} 323 contentTextWeight={contentTextWeight} 324 /> 325 </div> 326 ); 327 } 328 329 const isSizeLarge = size === 'l'; 330 331 const [forceTranslate, setForceTranslate] = useState(_forceTranslate); 332 const targetLanguage = getTranslateTargetLanguage(true); 333 const contentTranslationHideLanguages = 334 snapStates.settings.contentTranslationHideLanguages || []; 335 const { contentTranslation, contentTranslationAutoInline } = 336 snapStates.settings; 337 if (!contentTranslation) enableTranslate = false; 338 const inlineTranslate = useMemo(() => { 339 if ( 340 !contentTranslation || 341 !contentTranslationAutoInline || 342 readOnly || 343 (withinContext && !isSizeLarge) || 344 previewMode || 345 spoilerText || 346 sensitive || 347 poll || 348 card || 349 mediaAttachments?.length 350 ) { 351 return false; 352 } 353 const contentLength = htmlContentLength(content); 354 return contentLength > 0 && contentLength <= INLINE_TRANSLATE_LIMIT; 355 }, [ 356 contentTranslation, 357 contentTranslationAutoInline, 358 readOnly, 359 withinContext, 360 isSizeLarge, 361 previewMode, 362 spoilerText, 363 sensitive, 364 poll, 365 card, 366 mediaAttachments, 367 content, 368 ]); 369 370 const [showEdited, setShowEdited] = useState(false); 371 const [showReactions, setShowReactions] = useState(false); 372 373 const spoilerContentRef = useTruncated(); 374 const contentRef = useTruncated(); 375 const mediaContainerRef = useTruncated(); 376 const readMoreText = 'Read more →'; 377 378 379 const statusRef = useRef(null); 380 381 const unauthInteractionErrorMessage = `Sorry, your current logged-in instance can't interact with this post from another instance.`; 382 383 const textWeight = useCallback( 384 () => 385 Math.max( 386 Math.round((spoilerText.length + htmlContentLength(content)) / 140) || 387 1, 388 1, 389 ), 390 [spoilerText, content], 391 ); 392 393 const createdDateText = niceDateTime(createdAtDate); 394 const editedDateText = editedAt && niceDateTime(editedAtDate); 395 396 // Can boost if: 397 // - authenticated AND 398 // - visibility != direct OR 399 // - visibility = private AND isSelf 400 const location = useLocation(); 401 const locationUrl = location.pathname; 402 let canBoost = 403 authenticated && visibility !== 'direct' && visibility !== 'private'; 404 let cantLike = !canBoost 405 let cantReply = !canBoost 406 if (visibility === 'private' && isSelf) { 407 canBoost = true; 408 } 409 410 const replyStatus = () => { 411 // if (!sameInstance || !authenticated) { 412 // return alert(unauthInteractionErrorMessage); 413 // } 414 states.showCompose = { 415 replyToStatus: status, 416 }; 417 }; 418 419 // Check if media has no descriptions 420 const mediaNoDesc = useMemo(() => { 421 return mediaAttachments.some( 422 (attachment) => !attachment.description?.trim?.(), 423 ); 424 }, [mediaAttachments]); 425 426 const vibeLabelProvocative = async () => { 427 const alreadyChecked = localStorage.getItem(status.id); 428 if (!alreadyChecked) { 429 try { 430 states.statuses[sKey] = { 431 ...status, 432 labeledProvocative: !labeledProvocative, 433 labeledProvocativeCount: vibeCountDict['provocative'].length, 434 }; 435 sendVibeEvent(status.id, 'mastodon', 'provocative', cleanedContent); 436 } catch (e) { 437 console.error(e); 438 // Revert optimistism 439 states.statuses[sKey] = status; 440 return false; 441 } 442 } else { 443 return; 444 } 445 }; 446 447 const vibeLabelPositive = async () => { 448 const alreadyChecked = localStorage.getItem(status.id); 449 if (!alreadyChecked) { 450 try { 451 states.statuses[sKey] = { 452 ...status, 453 labeledPositiveVibe: !labeledPositiveVibe, 454 labeledPositiveVibeCount: vibeCountDict['positive'].length, 455 }; 456 sendVibeEvent(status.id, 'mastodon', 'positive', cleanedContent); 457 } catch (e) { 458 console.error(e); 459 // Revert optimistism 460 states.statuses[sKey] = status; 461 return false; 462 } 463 } 464 }; 465 466 const boostStatus = async () => { 467 try { 468 if (!sameInstance) { 469 (async () => { 470 const results = await currentMasto?.v2.search.fetch({ 471 q: status.url, 472 type: 'statuses', 473 resolve: true, 474 limit: 1, 475 }); 476 if (results.statuses.length) { 477 const status = results.statuses[0]; 478 states.statuses[sKey] = { 479 ...status, 480 reblogged: !reblogged, 481 reblogsCount: reblogsCount + (reblogged ? -1 : 1), 482 favouritesCount: favouritesCount, 483 repliesCount: repliesCount, 484 }; 485 if (reblogged) { 486 const newStatus = await currentMasto.v1.statuses.$select(status.id).unreblog(); 487 saveStatus(newStatus, myLocalInstance); 488 return true; 489 } else { 490 const newStatus = await currentMasto.v1.statuses.$select(status.id).reblog(); 491 saveStatus(newStatus, myLocalInstance); 492 return true; 493 } 494 } 495 })(); 496 } else { 497 try { 498 states.statuses[sKey] = { 499 ...status, 500 reblogged: !reblogged, 501 reblogsCount: reblogsCount + (reblogged ? -1 : 1), 502 }; 503 if (reblogged) { 504 const newStatus = await masto.v1.statuses.$select(id).unreblog(); 505 saveStatus(newStatus, instance); 506 return true; 507 } else { 508 const newStatus = await masto.v1.statuses.$select(id).reblog(); 509 saveStatus(newStatus, instance); 510 return true; 511 } 512 } catch (e) { 513 console.error(e); 514 // Revert optimistism 515 states.statuses[sKey] = status; 516 } 517 } 518 519 } catch (e) { 520 console.error(e); 521 // Revert optimistism 522 states.statuses[sKey] = status; 523 return false; 524 } 525 }; 526 const confirmBoostStatus = async () => { 527 if (!sameInstance || !authenticated) { 528 alert(unauthInteractionErrorMessage); 529 return false; 530 } 531 try { 532 // Optimistic 533 states.statuses[sKey] = { 534 ...status, 535 reblogged: !reblogged, 536 reblogsCount: reblogsCount + (reblogged ? -1 : 1), 537 }; 538 if (reblogged) { 539 const newStatus = await masto.v1.statuses.$select(id).unreblog(); 540 saveStatus(newStatus, instance); 541 return true; 542 } else { 543 const newStatus = await masto.v1.statuses.$select(id).reblog(); 544 saveStatus(newStatus, instance); 545 return true; 546 } 547 } catch (e) { 548 console.error(e); 549 // Revert optimistism 550 states.statuses[sKey] = status; 551 return false; 552 } 553 }; 554 555 const favouriteStatus = async (e) => { 556 if (!sameInstance) { 557 (async () => { 558 try { 559 const results = await currentMasto?.v2.search.fetch({ 560 q: status.url, 561 type: 'statuses', 562 resolve: true, 563 limit: 1, 564 }); 565 if (results.statuses.length) { 566 const status = results.statuses[0]; 567 // location.hash = myLocalInstance 568 // ? `/${myLocalInstance}/s/${status.id}` 569 // : `/s/${status.id}`; 570 571 states.statuses[sKey] = { 572 ...status, 573 favourited: !favourited, 574 favouritesCount: favouritesCount + (favourited ? -1 : 1), 575 reblogsCount: reblogsCount, 576 repliesCount: repliesCount 577 }; 578 if (favourited) { 579 const newStatus = await currentMasto.v1.statuses.$select(status.id).unfavourite(); 580 saveStatus(newStatus, myLocalInstance); 581 } else { 582 const newStatus = await currentMasto.v1.statuses.$select(status.id).favourite(); 583 saveStatus(newStatus, myLocalInstance); 584 } 585 586 } else { 587 throw new Error('No results'); 588 } 589 } catch (e) { 590 alert('Error: ' + e); 591 console.error(e); 592 } 593 })(); 594 } else { 595 try { 596 // Optimistic 597 states.statuses[sKey] = { 598 ...status, 599 favourited: !favourited, 600 favouritesCount: favouritesCount + (favourited ? -1 : 1), 601 }; 602 if (favourited) { 603 const newStatus = await masto.v1.statuses.$select(id).unfavourite(); 604 saveStatus(newStatus, instance); 605 } else { 606 const newStatus = await masto.v1.statuses.$select(id).favourite(); 607 saveStatus(newStatus, instance); 608 } 609 } catch (e) { 610 console.error(e); 611 // Revert optimistism 612 states.statuses[sKey] = status; 613 } 614 } 615 }; 616 617 const bookmarkStatus = async () => { 618 if (!sameInstance || !authenticated) { 619 return alert(unauthInteractionErrorMessage); 620 } 621 try { 622 // Optimistic 623 states.statuses[sKey] = { 624 ...status, 625 bookmarked: !bookmarked, 626 }; 627 if (bookmarked) { 628 const newStatus = await masto.v1.statuses.$select(id).unbookmark(); 629 saveStatus(newStatus, instance); 630 } else { 631 const newStatus = await masto.v1.statuses.$select(id).bookmark(); 632 saveStatus(newStatus, instance); 633 } 634 } catch (e) { 635 console.error(e); 636 // Revert optimistism 637 states.statuses[sKey] = status; 638 } 639 }; 640 641 const differentLanguage = 642 !!language && 643 language !== targetLanguage && 644 !localeMatch([language], [targetLanguage]) && 645 !contentTranslationHideLanguages.find( 646 (l) => language === l || localeMatch([language], [l]), 647 ); 648 649 const menuInstanceRef = useRef(); 650 const StatusMenuItems = ( 651 <> 652 {!isSizeLarge && ( 653 <> 654 <MenuHeader> 655 <span class="ib"> 656 <Icon icon={visibilityIconsMap[visibility]} size="s" />{' '} 657 <span>{visibilityText[visibility]}</span> 658 </span>{' '} 659 <span class="ib"> 660 {repliesCount > 0 && ( 661 <span> 662 <Icon icon="reply" alt="Replies" size="s" />{' '} 663 <span>{shortenNumber(repliesCount)}</span> 664 </span> 665 )}{' '} 666 {reblogsCount > 0 && ( 667 <span> 668 <Icon icon="rocket" alt="Boosts" size="s" />{' '} 669 <span>{shortenNumber(reblogsCount)}</span> 670 </span> 671 )}{' '} 672 {favouritesCount > 0 && ( 673 <span> 674 <Icon icon="heart" alt="Favourites" size="s" />{' '} 675 <span>{shortenNumber(favouritesCount)}</span> 676 </span> 677 )} 678 </span> 679 <br /> 680 {createdDateText} 681 </MenuHeader> 682 <MenuLink 683 to={instance ? `/${instance}/s/${id}` : `/s/${id}`} 684 onClick={(e) => { 685 onStatusLinkClick(e, status); 686 }} 687 > 688 <Icon icon="arrow-right" /> 689 <span>View post by @{username || acct}</span> 690 </MenuLink> 691 </> 692 )} 693 {!!editedAt && ( 694 <MenuItem 695 onClick={() => { 696 setShowEdited(id); 697 }} 698 > 699 <Icon icon="history" /> 700 <span> 701 Show Edit History 702 <br /> 703 <small class="more-insignificant">Edited: {editedDateText}</small> 704 </span> 705 </MenuItem> 706 )} 707 {(!isSizeLarge || !!editedAt) && <MenuDivider />} 708 {isSizeLarge && ( 709 <MenuItem onClick={() => setShowReactions(true)}> 710 <Icon icon="react" /> 711 <span> 712 Boosted/Favourited by<span class="more-insignificant">…</span> 713 </span> 714 </MenuItem> 715 )} 716 {!isSizeLarge && sameInstance && ( 717 <> 718 <div class="menu-horizontal"> 719 <MenuConfirm 720 subMenu 721 confirmLabel={ 722 <> 723 <Icon icon="rocket" /> 724 <span>{reblogged ? 'Unboost?' : 'Boost to everyone?'}</span> 725 </> 726 } 727 menuFooter={ 728 mediaNoDesc && 729 !reblogged && ( 730 <div class="footer"> 731 <Icon icon="alert" /> 732 Some media have no descriptions. 733 </div> 734 ) 735 } 736 disabled={!canBoost} 737 onClick={async () => { 738 try { 739 const done = await confirmBoostStatus(); 740 if (!isSizeLarge && done) { 741 showToast( 742 reblogged 743 ? `Unboosted @${username || acct}'s post` 744 : `Boosted @${username || acct}'s post`, 745 ); 746 } 747 } catch (e) {} 748 }} 749 > 750 <Icon 751 icon="rocket" 752 style={{ 753 color: reblogged && 'var(--reblog-color)', 754 }} 755 /> 756 <span>{reblogged ? 'Unboost' : 'Boost…'}</span> 757 </MenuConfirm> 758 <MenuItem 759 onClick={() => { 760 try { 761 favouriteStatus(); 762 if (!isSizeLarge) { 763 showToast( 764 favourited 765 ? `Unfavourited @${username || acct}'s post` 766 : `Favourited @${username || acct}'s post`, 767 ); 768 } 769 } catch (e) {} 770 }} 771 > 772 <Icon 773 icon="heart" 774 style={{ 775 color: favourited && 'var(--favourite-color)', 776 }} 777 /> 778 <span>{favourited ? 'Unfavourite' : 'Favourite'}</span> 779 </MenuItem> 780 </div> 781 <div class="menu-horizontal"> 782 <MenuItem onClick={replyStatus}> 783 <Icon icon="reply" /> 784 <span>Reply</span> 785 </MenuItem> 786 <MenuItem 787 onClick={() => { 788 try { 789 bookmarkStatus(); 790 if (!isSizeLarge) { 791 showToast( 792 bookmarked 793 ? `Unbookmarked @${username || acct}'s post` 794 : `Bookmarked @${username || acct}'s post`, 795 ); 796 } 797 } catch (e) {} 798 }} 799 > 800 <Icon 801 icon="bookmark" 802 style={{ 803 color: bookmarked && 'var(--link-color)', 804 }} 805 /> 806 <span>{bookmarked ? 'Unbookmark' : 'Bookmark'}</span> 807 </MenuItem> 808 </div> 809 </> 810 )} 811 {enableTranslate ? ( 812 <MenuItem 813 disabled={forceTranslate} 814 onClick={() => { 815 setForceTranslate(true); 816 }} 817 > 818 <Icon icon="translate" /> 819 <span>Translate</span> 820 </MenuItem> 821 ) : ( 822 (!language || differentLanguage) && ( 823 <MenuLink 824 to={`${instance ? `/${instance}` : ''}/s/${id}?translate=1`} 825 > 826 <Icon icon="translate" /> 827 <span>Translate</span> 828 </MenuLink> 829 ) 830 )} 831 {((!isSizeLarge && sameInstance) || enableTranslate) && <MenuDivider />} 832 <MenuItem href={url} target="_blank"> 833 <Icon icon="external" /> 834 <small class="menu-double-lines">{nicePostURL(url)}</small> 835 </MenuItem> 836 <div class="menu-horizontal"> 837 <MenuItem 838 onClick={() => { 839 // Copy url to clipboard 840 try { 841 navigator.clipboard.writeText(url); 842 showToast('Link copied'); 843 } catch (e) { 844 console.error(e); 845 showToast('Unable to copy link'); 846 } 847 }} 848 > 849 <Icon icon="link" /> 850 <span>Copy</span> 851 </MenuItem> 852 {navigator?.share && 853 navigator?.canShare?.({ 854 url, 855 }) && ( 856 <MenuItem 857 onClick={() => { 858 try { 859 navigator.share({ 860 url, 861 }); 862 } catch (e) { 863 console.error(e); 864 alert("Sharing doesn't seem to work."); 865 } 866 }} 867 > 868 <Icon icon="share" /> 869 <span>Share…</span> 870 </MenuItem> 871 )} 872 </div> 873 {(isSelf || mentionSelf) && <MenuDivider />} 874 {(isSelf || mentionSelf) && ( 875 <MenuItem 876 onClick={async () => { 877 try { 878 const newStatus = await masto.v1.statuses 879 .$select(id) 880 [muted ? 'unmute' : 'mute'](); 881 saveStatus(newStatus, instance); 882 showToast(muted ? 'Conversation unmuted' : 'Conversation muted'); 883 } catch (e) { 884 console.error(e); 885 showToast( 886 muted 887 ? 'Unable to unmute conversation' 888 : 'Unable to mute conversation', 889 ); 890 } 891 }} 892 > 893 {muted ? ( 894 <> 895 <Icon icon="unmute" /> 896 <span>Unmute conversation</span> 897 </> 898 ) : ( 899 <> 900 <Icon icon="mute" /> 901 <span>Mute conversation</span> 902 </> 903 )} 904 </MenuItem> 905 )} 906 {isSelf && ( 907 <div class="menu-horizontal"> 908 <MenuItem 909 onClick={() => { 910 states.showCompose = { 911 editStatus: status, 912 }; 913 }} 914 > 915 <Icon icon="pencil" /> 916 <span>Edit</span> 917 </MenuItem> 918 {isSizeLarge && ( 919 <MenuConfirm 920 subMenu 921 confirmLabel={ 922 <> 923 <Icon icon="trash" /> 924 <span>Delete this post?</span> 925 </> 926 } 927 menuItemClassName="danger" 928 onClick={() => { 929 // const yes = confirm('Delete this post?'); 930 // if (yes) { 931 (async () => { 932 try { 933 await masto.v1.statuses.$select(id).remove(); 934 const cachedStatus = getStatus(id, instance); 935 cachedStatus._deleted = true; 936 showToast('Deleted'); 937 } catch (e) { 938 console.error(e); 939 showToast('Unable to delete'); 940 } 941 })(); 942 // } 943 }} 944 > 945 <Icon icon="trash" /> 946 <span>Delete…</span> 947 </MenuConfirm> 948 )} 949 </div> 950 )} 951 </> 952 ); 953 954 const contextMenuRef = useRef(); 955 const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); 956 const [contextMenuProps, setContextMenuProps] = useState({}); 957 const isIOS = 958 window.ontouchstart !== undefined && 959 /iPad|iPhone|iPod/.test(navigator.userAgent); 960 // Only iOS/iPadOS browsers don't support contextmenu 961 // Some comments report iPadOS might support contextmenu if a mouse is connected 962 const bindLongPressContext = useLongPress( 963 isIOS 964 ? (e) => { 965 if (e.pointerType === 'mouse') return; 966 // There's 'pen' too, but not sure if contextmenu event would trigger from a pen 967 968 const { clientX, clientY } = e.touches?.[0] || e; 969 // link detection copied from onContextMenu because here it works 970 const link = e.target.closest('a'); 971 if (link && /^https?:\/\//.test(link.getAttribute('href'))) return; 972 e.preventDefault(); 973 setContextMenuProps({ 974 anchorPoint: { 975 x: clientX, 976 y: clientY, 977 }, 978 direction: 'right', 979 }); 980 setIsContextMenuOpen(true); 981 } 982 : null, 983 { 984 threshold: 600, 985 captureEvent: true, 986 detect: 'touch', 987 cancelOnMovement: 2, // true allows movement of up to 25 pixels 988 }, 989 ); 990 991 const showContextMenu = size !== 'l' && !previewMode && !_deleted && !quoted; 992 993 const hotkeysEnabled = !readOnly && !previewMode; 994 const rRef = useHotkeys('r', replyStatus, { 995 enabled: hotkeysEnabled, 996 }); 997 const fRef = useHotkeys( 998 'f', 999 () => { 1000 try { 1001 favouriteStatus(); 1002 if (!isSizeLarge) { 1003 showToast( 1004 favourited 1005 ? `Unfavourited @${username || acct}'s post` 1006 : `Favourited @${username || acct}'s post`, 1007 ); 1008 } 1009 } catch (e) {} 1010 }, 1011 { 1012 enabled: hotkeysEnabled, 1013 }, 1014 ); 1015 const dRef = useHotkeys( 1016 'd', 1017 () => { 1018 try { 1019 bookmarkStatus(); 1020 if (!isSizeLarge) { 1021 showToast( 1022 bookmarked 1023 ? `Unbookmarked @${username || acct}'s post` 1024 : `Bookmarked @${username || acct}'s post`, 1025 ); 1026 } 1027 } catch (e) {} 1028 }, 1029 { 1030 enabled: hotkeysEnabled, 1031 }, 1032 ); 1033 const bRef = useHotkeys( 1034 'shift+b', 1035 () => { 1036 (async () => { 1037 try { 1038 const done = await confirmBoostStatus(); 1039 if (!isSizeLarge && done) { 1040 showToast( 1041 reblogged 1042 ? `Unboosted @${username || acct}'s post` 1043 : `Boosted @${username || acct}'s post`, 1044 ); 1045 } 1046 } catch (e) {} 1047 })(); 1048 }, 1049 { 1050 enabled: hotkeysEnabled && canBoost, 1051 }, 1052 ); 1053 1054 const displayedMediaAttachments = mediaAttachments.slice( 1055 0, 1056 isSizeLarge ? undefined : 4, 1057 ); 1058 const showMultipleMediaCaptions = 1059 mediaAttachments.length > 1 && 1060 displayedMediaAttachments.some( 1061 (media) => !!media.description && !isMediaCaptionLong(media.description), 1062 ); 1063 const captionChildren = useMemo(() => { 1064 if (!showMultipleMediaCaptions) return null; 1065 const attachments = []; 1066 displayedMediaAttachments.forEach((media, i) => { 1067 if (!media.description) return; 1068 const index = attachments.findIndex( 1069 (attachment) => attachment.media.description === media.description, 1070 ); 1071 if (index === -1) { 1072 attachments.push({ 1073 media, 1074 indices: [i], 1075 }); 1076 } else { 1077 attachments[index].indices.push(i); 1078 } 1079 }); 1080 return attachments.map(({ media, indices }) => ( 1081 <div 1082 key={media.id} 1083 data-caption-index={indices.map((i) => i + 1).join(' ')} 1084 onClick={(e) => { 1085 e.preventDefault(); 1086 e.stopPropagation(); 1087 states.showMediaAlt = { 1088 alt: media.description, 1089 lang: language, 1090 }; 1091 }} 1092 title={media.description} 1093 > 1094 <sup>{indices.map((i) => i + 1).join(' ')}</sup> {media.description} 1095 </div> 1096 )); 1097 1098 // return displayedMediaAttachments.map( 1099 // (media, i) => 1100 // !!media.description && ( 1101 // <div 1102 // key={media.id} 1103 // data-caption-index={i + 1} 1104 // onClick={(e) => { 1105 // e.preventDefault(); 1106 // e.stopPropagation(); 1107 // states.showMediaAlt = { 1108 // alt: media.description, 1109 // lang: language, 1110 // }; 1111 // }} 1112 // title={media.description} 1113 // > 1114 // <sup>{i + 1}</sup> {media.description} 1115 // </div> 1116 // ), 1117 // ); 1118 }, [showMultipleMediaCaptions, displayedMediaAttachments, language]); 1119 1120 return ( 1121 <article 1122 ref={(node) => { 1123 statusRef.current = node; 1124 // Use parent node if it's in focus 1125 // Use case: <a><status /></a> 1126 // When navigating (j/k), the <a> is focused instead of <status /> 1127 // Hotkey binding doesn't bubble up thus this hack 1128 const nodeRef = 1129 node?.closest?.( 1130 '.timeline-item, .timeline-item-alt, .status-link, .status-focus', 1131 ) || node; 1132 rRef.current = nodeRef; 1133 fRef.current = nodeRef; 1134 dRef.current = nodeRef; 1135 bRef.current = nodeRef; 1136 }} 1137 tabindex="-1" 1138 class={`status ${ 1139 !withinContext && inReplyToId && inReplyToAccount 1140 ? 'status-reply-to' 1141 : '' 1142 } visibility-${visibility} ${_pinned ? 'status-pinned' : ''} ${ 1143 { 1144 s: 'small', 1145 m: 'medium', 1146 l: 'large', 1147 }[size] 1148 } ${_deleted ? 'status-deleted' : ''} ${quoted ? 'status-card' : ''}`} 1149 onMouseEnter={debugHover} 1150 onContextMenu={(e) => { 1151 // FIXME: this code isn't getting called on Chrome at all? 1152 if (!showContextMenu) return; 1153 if (e.metaKey) return; 1154 // console.log('context menu', e); 1155 const link = e.target.closest('a'); 1156 if (link && /^https?:\/\//.test(link.getAttribute('href'))) return; 1157 e.preventDefault(); 1158 setContextMenuProps({ 1159 anchorPoint: { 1160 x: e.clientX, 1161 y: e.clientY, 1162 }, 1163 direction: 'right', 1164 }); 1165 setIsContextMenuOpen(true); 1166 }} 1167 {...(showContextMenu ? bindLongPressContext() : {})} 1168 > 1169 {showContextMenu && ( 1170 <ControlledMenu 1171 ref={contextMenuRef} 1172 state={isContextMenuOpen ? 'open' : undefined} 1173 {...contextMenuProps} 1174 onClose={(e) => { 1175 setIsContextMenuOpen(false); 1176 // statusRef.current?.focus?.(); 1177 if (e?.reason === 'click') { 1178 statusRef.current?.closest('[tabindex]')?.focus?.(); 1179 } 1180 }} 1181 portal={{ 1182 target: document.body, 1183 }} 1184 containerProps={{ 1185 style: { 1186 // Higher than the backdrop 1187 zIndex: 1001, 1188 }, 1189 onClick: () => { 1190 contextMenuRef.current?.closeMenu?.(); 1191 }, 1192 }} 1193 overflow="auto" 1194 boundingBoxPadding={safeBoundingBoxPadding()} 1195 unmountOnClose 1196 > 1197 {StatusMenuItems} 1198 </ControlledMenu> 1199 )} 1200 {size !== 'l' && ( 1201 <div class="status-badge"> 1202 {reblogged && <Icon class="reblog" icon="rocket" size="s" />} 1203 {favourited && <Icon class="favourite" icon="heart" size="s" />} 1204 {bookmarked && <Icon class="bookmark" icon="bookmark" size="s" />} 1205 {_pinned && <Icon class="pin" icon="pin" size="s" />} 1206 </div> 1207 )} 1208 {size !== 's' && ( 1209 <a 1210 href={accountURL} 1211 tabindex="-1" 1212 // target="_blank" 1213 title={`@${acct}`} 1214 onClick={(e) => { 1215 e.preventDefault(); 1216 e.stopPropagation(); 1217 states.showAccount = { 1218 account: status.account, 1219 instance, 1220 }; 1221 }} 1222 > 1223 <Avatar url={avatarStatic || avatar} size="xxl" squircle={bot} /> 1224 </a> 1225 )} 1226 <div class="container"> 1227 <div class="meta"> 1228 <span class="meta-name"> 1229 <NameText 1230 account={status.account} 1231 instance={instance} 1232 showAvatar={size === 's'} 1233 showAcct={isSizeLarge} 1234 /> 1235 </span> 1236 {/* {inReplyToAccount && !withinContext && size !== 's' && ( 1237 <> 1238 {' '} 1239 <span class="ib"> 1240 <Icon icon="arrow-right" class="arrow" />{' '} 1241 <NameText account={inReplyToAccount} instance={instance} short /> 1242 </span> 1243 </> 1244 )} */} 1245 {/* </span> */}{' '} 1246 {size !== 'l' && 1247 (_deleted ? ( 1248 <span class="status-deleted-tag">Deleted</span> 1249 ) : url && !previewMode && !quoted ? ( 1250 <Link 1251 to={instance ? `/${instance}/s/${id}` : `/s/${id}`} 1252 onClick={(e) => { 1253 e.preventDefault(); 1254 e.stopPropagation(); 1255 onStatusLinkClick?.(e, status); 1256 setContextMenuProps({ 1257 anchorRef: { 1258 current: e.currentTarget, 1259 }, 1260 align: 'end', 1261 direction: 'bottom', 1262 gap: 4, 1263 }); 1264 setIsContextMenuOpen(true); 1265 }} 1266 class={`time ${ 1267 isContextMenuOpen && contextMenuProps?.anchorRef 1268 ? 'is-open' 1269 : '' 1270 }`} 1271 > 1272 <Icon 1273 icon={visibilityIconsMap[visibility]} 1274 alt={visibilityText[visibility]} 1275 size="s" 1276 />{' '} 1277 <RelativeTime datetime={createdAtDate} format="micro" /> 1278 </Link> 1279 ) : ( 1280 // <Menu 1281 // instanceRef={menuInstanceRef} 1282 // portal={{ 1283 // target: document.body, 1284 // }} 1285 // containerProps={{ 1286 // style: { 1287 // // Higher than the backdrop 1288 // zIndex: 1001, 1289 // }, 1290 // onClick: (e) => { 1291 // if (e.target === e.currentTarget) 1292 // menuInstanceRef.current?.closeMenu?.(); 1293 // }, 1294 // }} 1295 // align="end" 1296 // gap={4} 1297 // overflow="auto" 1298 // viewScroll="close" 1299 // boundingBoxPadding="8 8 8 8" 1300 // unmountOnClose 1301 // menuButton={({ open }) => ( 1302 // <Link 1303 // to={instance ? `/${instance}/s/${id}` : `/s/${id}`} 1304 // onClick={(e) => { 1305 // e.preventDefault(); 1306 // e.stopPropagation(); 1307 // onStatusLinkClick?.(e, status); 1308 // }} 1309 // class={`time ${open ? 'is-open' : ''}`} 1310 // > 1311 // <Icon 1312 // icon={visibilityIconsMap[visibility]} 1313 // alt={visibilityText[visibility]} 1314 // size="s" 1315 // />{' '} 1316 // <RelativeTime datetime={createdAtDate} format="micro" /> 1317 // </Link> 1318 // )} 1319 // > 1320 // {StatusMenuItems} 1321 // </Menu> 1322 <span class="time"> 1323 <Icon 1324 icon={visibilityIconsMap[visibility]} 1325 alt={visibilityText[visibility]} 1326 size="s" 1327 />{' '} 1328 <RelativeTime datetime={createdAtDate} format="micro" /> 1329 </span> 1330 ))} 1331 </div> 1332 {visibility === 'direct' && ( 1333 <> 1334 <div class="status-direct-badge">Private mention</div>{' '} 1335 </> 1336 )} 1337 {!withinContext && ( 1338 <> 1339 {(!!inReplyToId && inReplyToAccountId === status.account?.id) || 1340 !!snapStates.statusThreadNumber[sKey] ? ( 1341 <div class="status-thread-badge"> 1342 <Icon icon="thread" size="s" /> 1343 Thread 1344 {snapStates.statusThreadNumber[sKey] 1345 ? ` ${snapStates.statusThreadNumber[sKey]}/X` 1346 : ''} 1347 </div> 1348 ) : ( 1349 !!inReplyToId && 1350 !!inReplyToAccount && 1351 (!!spoilerText || 1352 !mentions.find((mention) => { 1353 return mention.id === inReplyToAccountId; 1354 })) && ( 1355 <div class="status-reply-badge"> 1356 <Icon icon="reply" />{' '} 1357 <NameText 1358 account={inReplyToAccount} 1359 instance={instance} 1360 short 1361 /> 1362 </div> 1363 ) 1364 )} 1365 </> 1366 )} 1367 <div 1368 class={`content-container ${ 1369 spoilerText || sensitive ? 'has-spoiler' : '' 1370 } ${showSpoiler ? 'show-spoiler' : ''}`} 1371 data-content-text-weight={contentTextWeight ? textWeight() : null} 1372 style={ 1373 (isSizeLarge || contentTextWeight) && { 1374 '--content-text-weight': textWeight(), 1375 } 1376 } 1377 > 1378 {!!spoilerText && ( 1379 <> 1380 <div 1381 class="content spoiler-content" 1382 lang={language} 1383 dir="auto" 1384 ref={spoilerContentRef} 1385 data-read-more={readMoreText} 1386 > 1387 <p> 1388 <EmojiText text={spoilerText} emojis={emojis} /> 1389 </p> 1390 </div> 1391 <button 1392 class={`light spoiler ${showSpoiler ? 'spoiling' : ''}`} 1393 type="button" 1394 disabled={readingExpandSpoilers} 1395 onClick={(e) => { 1396 e.preventDefault(); 1397 e.stopPropagation(); 1398 if (showSpoiler) { 1399 delete states.spoilers[id]; 1400 } else { 1401 states.spoilers[id] = true; 1402 } 1403 }} 1404 > 1405 <Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '} 1406 {readingExpandSpoilers 1407 ? 'Content warning' 1408 : showSpoiler 1409 ? 'Show less' 1410 : 'Show more'} 1411 </button> 1412 </> 1413 )} 1414 <div class="content" ref={contentRef} data-read-more={readMoreText}> 1415 <div 1416 lang={language} 1417 dir="auto" 1418 class="inner-content" 1419 onClick={handleContentLinks({ 1420 mentions, 1421 instance, 1422 previewMode, 1423 statusURL: url, 1424 })} 1425 dangerouslySetInnerHTML={{ 1426 __html: enhanceContent(content, { 1427 emojis, 1428 postEnhanceDOM: (dom) => { 1429 // Remove target="_blank" from links 1430 dom 1431 .querySelectorAll('a.u-url[target="_blank"]') 1432 .forEach((a) => { 1433 if (!/http/i.test(a.innerText.trim())) { 1434 a.removeAttribute('target'); 1435 } 1436 }); 1437 if (previewMode) return; 1438 // Unfurl Mastodon links 1439 Array.from( 1440 dom.querySelectorAll( 1441 'a[href]:not(.u-url):not(.mention):not(.hashtag)', 1442 ), 1443 ) 1444 .filter((a) => { 1445 const url = a.href; 1446 const isPostItself = 1447 url === status.url || url === status.uri; 1448 return !isPostItself && isMastodonLinkMaybe(url); 1449 }) 1450 .forEach((a, i) => { 1451 unfurlMastodonLink(myLocalInstance, a.href).then( 1452 (result) => { 1453 if (!result) return; 1454 a.removeAttribute('target'); 1455 if (!sKey) return; 1456 if (!Array.isArray(states.statusQuotes[sKey])) { 1457 states.statusQuotes[sKey] = []; 1458 } 1459 if (!states.statusQuotes[sKey][i]) { 1460 states.statusQuotes[sKey].splice(i, 0, result); 1461 } 1462 }, 1463 ); 1464 }); 1465 }, 1466 }), 1467 }} 1468 /> 1469 <QuoteStatuses id={id} instance={instance} level={quoted} /> 1470 </div> 1471 {!!poll && ( 1472 <Poll 1473 lang={language} 1474 poll={poll} 1475 readOnly={readOnly || !sameInstance || !authenticated} 1476 onUpdate={(newPoll) => { 1477 states.statuses[sKey].poll = newPoll; 1478 }} 1479 refresh={() => { 1480 return masto.v1.polls 1481 .$select(poll.id) 1482 .fetch() 1483 .then((pollResponse) => { 1484 states.statuses[sKey].poll = pollResponse; 1485 }) 1486 .catch((e) => {}); // Silently fail 1487 }} 1488 votePoll={(choices) => { 1489 return masto.v1.polls 1490 .$select(poll.id) 1491 .votes.create({ 1492 choices, 1493 }) 1494 .then((pollResponse) => { 1495 states.statuses[sKey].poll = pollResponse; 1496 }) 1497 .catch((e) => {}); // Silently fail 1498 }} 1499 /> 1500 )} 1501 {(((enableTranslate || inlineTranslate) && 1502 !!content.trim() && 1503 !!getHTMLText(emojifyText(content, emojis)) && 1504 differentLanguage) || 1505 forceTranslate) && ( 1506 <TranslationBlock 1507 forceTranslate={forceTranslate || inlineTranslate} 1508 mini={!isSizeLarge && !withinContext} 1509 sourceLanguage={language} 1510 text={ 1511 (spoilerText ? `${spoilerText}\n\n` : '') + 1512 getHTMLText(content) + 1513 (poll?.options?.length 1514 ? `\n\nPoll:\n${poll.options 1515 .map( 1516 (option) => 1517 `- ${option.title}${ 1518 option.votesCount >= 0 1519 ? ` (${option.votesCount})` 1520 : '' 1521 }`, 1522 ) 1523 .join('\n')}` 1524 : '') 1525 } 1526 /> 1527 )} 1528 {!spoilerText && sensitive && !!mediaAttachments.length && ( 1529 <button 1530 class={`plain spoiler ${showSpoiler ? 'spoiling' : ''}`} 1531 type="button" 1532 onClick={(e) => { 1533 e.preventDefault(); 1534 e.stopPropagation(); 1535 if (showSpoiler) { 1536 delete states.spoilers[id]; 1537 } else { 1538 states.spoilers[id] = true; 1539 } 1540 }} 1541 > 1542 <Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} /> Sensitive 1543 content 1544 </button> 1545 )} 1546 {!!mediaAttachments.length && ( 1547 <MultipleMediaFigure 1548 lang={language} 1549 enabled={showMultipleMediaCaptions} 1550 captionChildren={captionChildren} 1551 > 1552 <div 1553 ref={mediaContainerRef} 1554 class={`media-container media-eq${mediaAttachments.length} ${ 1555 mediaAttachments.length > 2 ? 'media-gt2' : '' 1556 } ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`} 1557 > 1558 {displayedMediaAttachments.map((media, i) => ( 1559 <Media 1560 key={media.id} 1561 media={media} 1562 autoAnimate={isSizeLarge} 1563 showCaption={mediaAttachments.length === 1} 1564 lang={language} 1565 altIndex={ 1566 showMultipleMediaCaptions && !!media.description && i + 1 1567 } 1568 to={`/${instance}/s/${id}?${ 1569 withinContext ? 'media' : 'media-only' 1570 }=${i + 1}`} 1571 onClick={ 1572 onMediaClick 1573 ? (e) => { 1574 onMediaClick(e, i, media, status); 1575 } 1576 : undefined 1577 } 1578 /> 1579 ))} 1580 </div> 1581 </MultipleMediaFigure> 1582 )} 1583 {!!card && 1584 card?.url !== status.url && 1585 card?.url !== status.uri && 1586 /^https/i.test(card?.url) && 1587 !sensitive && 1588 !spoilerText && 1589 !poll && 1590 !mediaAttachments.length && 1591 !snapStates.statusQuotes[sKey] && ( 1592 <Card card={card} instance={myLocalInstance} /> 1593 )} 1594 </div> 1595 {/* {isSizeLarge && ( */} 1596 <> 1597 <div class="extra-meta"> 1598 {_deleted ? ( 1599 <span class="status-deleted-tag">Deleted</span> 1600 ) : ( 1601 <> 1602 <Icon 1603 icon={visibilityIconsMap[visibility]} 1604 alt={visibilityText[visibility]} 1605 />{' '} 1606 <a href={url} target="_blank" rel="noopener noreferrer"> 1607 <time 1608 class="created" 1609 datetime={createdAtDate.toISOString()} 1610 > 1611 {createdDateText} 1612 </time> 1613 </a> 1614 {editedAt && ( 1615 <> 1616 {' '} 1617 • <Icon icon="pencil" alt="Edited" />{' '} 1618 <time 1619 class="edited" 1620 datetime={editedAtDate.toISOString()} 1621 onClick={() => { 1622 setShowEdited(id); 1623 }} 1624 > 1625 {editedDateText} 1626 </time> 1627 </> 1628 )} 1629 </> 1630 )} 1631 </div> 1632 1633 <div class={`actions ${_deleted ? 'disabled' : ''}`}> 1634 <div class="action has-count"> 1635 <VibeTagButton 1636 checked={labeledProvocative} 1637 title={['Bummer']} 1638 alt={['Bummer']} 1639 class={`provocative-button vibetag-button ${localStorage.getItem(status.id) ? 'disabled-vibe-button': ''}`} 1640 icon="provocative" 1641 text="Bummer" 1642 count={labeledProvocativeCount} 1643 onClick={vibeLabelProvocative} 1644 style="color: #e95252" 1645 disabled={localStorage.getItem(status.id)} 1646 /> 1647 </div> 1648 <div class="action has-count"> 1649 <VibeTagButton 1650 checked={labeledPositiveVibe} 1651 title={['Positive']} 1652 alt={['Positive']} 1653 class={`positive-button vibetag-button ${localStorage.getItem(status.id) ? 'disabled-vibe-button': ''}`} 1654 icon="positive" 1655 text="Positive Vibes" 1656 count={labeledPositiveVibeCount} 1657 onClick={vibeLabelPositive} 1658 style="color: #93e952" 1659 disabled={localStorage.getItem(status.id)} 1660 /> 1661 </div> 1662 </div> 1663 1664 1665 <div class={`actions ${_deleted ? 'disabled' : ''}`}> 1666 <div class="action has-count"> 1667 <StatusButton 1668 title="Reply" 1669 alt="Comments" 1670 class="reply-button" 1671 icon="comment" 1672 count={repliesCount} 1673 onClick={replyStatus} 1674 /> 1675 </div> 1676 <div class="action has-count"> 1677 <StatusButton 1678 checked={reblogged} 1679 title={['Boost', 'Unboost']} 1680 alt={['Boost', 'Boosted']} 1681 class="reblog-button" 1682 icon="rocket" 1683 count={reblogsCount} 1684 onClick={boostStatus} 1685 /> 1686 </div> 1687 {/* <MenuConfirm 1688 disabled={!canBoost} 1689 onClick={confirmBoostStatus} 1690 confirmLabel={ 1691 <> 1692 <Icon icon="rocket" /> 1693 <span>{reblogged ? 'Unboost?' : 'Boost to everyone?'}</span> 1694 </> 1695 } 1696 menuFooter={ 1697 mediaNoDesc && 1698 !reblogged && ( 1699 <div class="footer"> 1700 <Icon icon="alert" /> 1701 Some media have no descriptions. 1702 </div> 1703 ) 1704 } 1705 > 1706 <div class="action has-count"> 1707 <StatusButton 1708 checked={reblogged} 1709 title={['Boost', 'Unboost']} 1710 alt={['Boost', 'Boosted']} 1711 class="reblog-button" 1712 icon="rocket" 1713 count={reblogsCount} 1714 // onClick={boostStatus} 1715 disabled={!canBoost} 1716 /> 1717 </div> 1718 </MenuConfirm> */} 1719 <div class="action has-count"> 1720 <StatusButton 1721 checked={favourited} 1722 title={['Favourite', 'Unfavourite']} 1723 alt={['Favourite', 'Favourited']} 1724 class="favourite-button" 1725 icon="heart" 1726 count={favouritesCount} 1727 onClick={favouriteStatus} 1728 /> 1729 </div> 1730 <div class="action"> 1731 <StatusButton 1732 checked={bookmarked} 1733 title={['Bookmark', 'Unbookmark']} 1734 alt={['Bookmark', 'Bookmarked']} 1735 class="bookmark-button" 1736 icon="bookmark" 1737 onClick={bookmarkStatus} 1738 disabled={!canBoost} 1739 /> 1740 </div> 1741 <Menu 1742 portal={{ 1743 target: 1744 document.querySelector('.status-deck') || document.body, 1745 }} 1746 align="end" 1747 gap={4} 1748 overflow="auto" 1749 viewScroll="close" 1750 boundingBoxPadding="8 8 8 8" 1751 menuButton={ 1752 <div class="action"> 1753 <button 1754 type="button" 1755 title="More" 1756 class="plain more-button" 1757 > 1758 <Icon icon="more" size="l" alt="More" /> 1759 </button> 1760 </div> 1761 } 1762 > 1763 {StatusMenuItems} 1764 </Menu> 1765 </div> 1766 </> 1767 {/* )} */} 1768 </div> 1769 {!!showEdited && ( 1770 <Modal 1771 onClick={(e) => { 1772 if (e.target === e.currentTarget) { 1773 setShowEdited(false); 1774 statusRef.current?.focus(); 1775 } 1776 }} 1777 > 1778 <EditedAtModal 1779 statusID={showEdited} 1780 instance={instance} 1781 fetchStatusHistory={() => { 1782 return masto.v1.statuses.$select(showEdited).history.list(); 1783 }} 1784 onClose={() => { 1785 setShowEdited(false); 1786 statusRef.current?.focus(); 1787 }} 1788 /> 1789 </Modal> 1790 )} 1791 {showReactions && ( 1792 <Modal 1793 class="light" 1794 onClick={(e) => { 1795 if (e.target === e.currentTarget) { 1796 setShowReactions(false); 1797 } 1798 }} 1799 > 1800 <ReactionsModal 1801 statusID={id} 1802 instance={instance} 1803 onClose={() => setShowReactions(false)} 1804 /> 1805 </Modal> 1806 )} 1807 </article> 1808 ); 1809 } 1810 1811 function MultipleMediaFigure(props) { 1812 const { enabled, children, lang, captionChildren } = props; 1813 if (!enabled || !captionChildren) return children; 1814 return ( 1815 <figure class="media-figure-multiple"> 1816 {children} 1817 <figcaption lang={lang} dir="auto"> 1818 {captionChildren} 1819 </figcaption> 1820 </figure> 1821 ); 1822 } 1823 1824 function Card({ card, instance }) { 1825 const snapStates = useSnapshot(states); 1826 const { 1827 blurhash, 1828 title, 1829 description, 1830 html, 1831 providerName, 1832 providerUrl, 1833 authorName, 1834 authorUrl, 1835 width, 1836 height, 1837 image, 1838 imageDescription, 1839 url, 1840 type, 1841 embedUrl, 1842 language, 1843 publishedAt, 1844 } = card; 1845 1846 /* type 1847 link = Link OEmbed 1848 photo = Photo OEmbed 1849 video = Video OEmbed 1850 rich = iframe OEmbed. Not currently accepted, so won’t show up in practice. 1851 */ 1852 1853 const hasText = title || providerName || authorName; 1854 const isLandscape = width / height >= 1.2; 1855 const size = isLandscape ? 'large' : ''; 1856 1857 const [cardStatusURL, setCardStatusURL] = useState(null); 1858 // const [cardStatusID, setCardStatusID] = useState(null); 1859 useEffect(() => { 1860 if (hasText && image && isMastodonLinkMaybe(url)) { 1861 unfurlMastodonLink(instance, url).then((result) => { 1862 if (!result) return; 1863 const { id, url } = result; 1864 setCardStatusURL('#' + url); 1865 1866 // NOTE: This is for quote post 1867 // (async () => { 1868 // const { masto } = api({ instance }); 1869 // const status = await masto.v1.statuses.$select(id).fetch(); 1870 // saveStatus(status, instance); 1871 // setCardStatusID(id); 1872 // })(); 1873 }); 1874 } 1875 }, [hasText, image]); 1876 1877 // if (cardStatusID) { 1878 // return ( 1879 // <Status statusID={cardStatusID} instance={instance} size="s" readOnly /> 1880 // ); 1881 // } 1882 1883 if (snapStates.unfurledLinks[url]) return null; 1884 1885 if (hasText && (image || (type === 'photo' && blurhash))) { 1886 const domain = new URL(url).hostname.replace(/^www\./, ''); 1887 let blurhashImage; 1888 if (!image) { 1889 const w = 44; 1890 const h = 44; 1891 const blurhashPixels = decodeBlurHash(blurhash, w, h); 1892 const canvas = document.createElement('canvas'); 1893 canvas.width = w; 1894 canvas.height = h; 1895 const ctx = canvas.getContext('2d'); 1896 const imageData = ctx.createImageData(w, h); 1897 imageData.data.set(blurhashPixels); 1898 ctx.putImageData(imageData, 0, 0); 1899 blurhashImage = canvas.toDataURL(); 1900 } 1901 return ( 1902 <a 1903 href={cardStatusURL || url} 1904 target={cardStatusURL ? null : '_blank'} 1905 rel="nofollow noopener noreferrer" 1906 class={`card link ${blurhashImage ? '' : size}`} 1907 lang={language} 1908 dir="auto" 1909 > 1910 <div class="card-image"> 1911 <img 1912 src={image || blurhashImage} 1913 width={width} 1914 height={height} 1915 loading="lazy" 1916 alt={imageDescription || ''} 1917 onError={(e) => { 1918 try { 1919 e.target.style.display = 'none'; 1920 } catch (e) {} 1921 }} 1922 /> 1923 </div> 1924 <div class="meta-container"> 1925 <p class="meta domain" dir="auto"> 1926 {domain} 1927 </p> 1928 <p class="title" dir="auto"> 1929 {title} 1930 </p> 1931 <p class="meta" dir="auto"> 1932 {description || providerName || authorName} 1933 </p> 1934 </div> 1935 </a> 1936 ); 1937 } else if (type === 'photo') { 1938 return ( 1939 <a 1940 href={url} 1941 target="_blank" 1942 rel="nofollow noopener noreferrer" 1943 class="card photo" 1944 > 1945 <img 1946 src={embedUrl} 1947 width={width} 1948 height={height} 1949 alt={title || description} 1950 loading="lazy" 1951 style={{ 1952 height: 'auto', 1953 aspectRatio: `${width}/${height}`, 1954 }} 1955 /> 1956 </a> 1957 ); 1958 } else if (type === 'video') { 1959 if (/youtube/i.test(providerName)) { 1960 // Get ID from e.g. https://www.youtube.com/watch?v=[VIDEO_ID] 1961 const videoID = url.match(/watch\?v=([^&]+)/)?.[1]; 1962 if (videoID) { 1963 return <lite-youtube videoid={videoID} nocookie></lite-youtube>; 1964 } 1965 } 1966 return ( 1967 <div 1968 class="card video" 1969 style={{ 1970 aspectRatio: `${width}/${height}`, 1971 }} 1972 dangerouslySetInnerHTML={{ __html: html }} 1973 /> 1974 ); 1975 } else if (hasText && !image) { 1976 const domain = new URL(url).hostname.replace(/^www\./, ''); 1977 return ( 1978 <a 1979 href={cardStatusURL || url} 1980 target={cardStatusURL ? null : '_blank'} 1981 rel="nofollow noopener noreferrer" 1982 class={`card link no-image`} 1983 lang={language} 1984 > 1985 <div class="meta-container"> 1986 <p class="meta domain"> 1987 <Icon icon="link" size="s" /> <span>{domain}</span> 1988 </p> 1989 <p class="title">{title}</p> 1990 <p class="meta">{description || providerName || authorName}</p> 1991 </div> 1992 </a> 1993 ); 1994 } 1995 } 1996 1997 function EditedAtModal({ 1998 statusID, 1999 instance, 2000 fetchStatusHistory = () => {}, 2001 onClose, 2002 }) { 2003 const [uiState, setUIState] = useState('default'); 2004 const [editHistory, setEditHistory] = useState([]); 2005 2006 useEffect(() => { 2007 setUIState('loading'); 2008 (async () => { 2009 try { 2010 const editHistory = await fetchStatusHistory(); 2011 console.log(editHistory); 2012 setEditHistory(editHistory); 2013 setUIState('default'); 2014 } catch (e) { 2015 console.error(e); 2016 setUIState('error'); 2017 } 2018 })(); 2019 }, []); 2020 2021 return ( 2022 <div id="edit-history" class="sheet"> 2023 {!!onClose && ( 2024 <button type="button" class="sheet-close" onClick={onClose}> 2025 <Icon icon="x" /> 2026 </button> 2027 )} 2028 <header> 2029 <h2>Edit History</h2> 2030 {uiState === 'error' && <p>Failed to load history</p>} 2031 {uiState === 'loading' && ( 2032 <p> 2033 <Loader abrupt /> Loading… 2034 </p> 2035 )} 2036 </header> 2037 <main tabIndex="-1"> 2038 {editHistory.length > 0 && ( 2039 <ol> 2040 {editHistory.map((status) => { 2041 const { createdAt } = status; 2042 const createdAtDate = new Date(createdAt); 2043 return ( 2044 <li key={createdAt} class="history-item"> 2045 <h3> 2046 <time> 2047 {niceDateTime(createdAtDate, { 2048 formatOpts: { 2049 weekday: 'short', 2050 second: 'numeric', 2051 }, 2052 })} 2053 </time> 2054 </h3> 2055 <Status 2056 status={status} 2057 instance={instance} 2058 size="s" 2059 withinContext 2060 readOnly 2061 previewMode 2062 /> 2063 </li> 2064 ); 2065 })} 2066 </ol> 2067 )} 2068 </main> 2069 </div> 2070 ); 2071 } 2072 2073 const REACTIONS_LIMIT = 80; 2074 function ReactionsModal({ statusID, instance, onClose }) { 2075 const { masto } = api({ instance }); 2076 const [uiState, setUIState] = useState('default'); 2077 const [accounts, setAccounts] = useState([]); 2078 const [showMore, setShowMore] = useState(false); 2079 2080 const reblogIterator = useRef(); 2081 const favouriteIterator = useRef(); 2082 2083 async function fetchAccounts(firstLoad) { 2084 setShowMore(false); 2085 setUIState('loading'); 2086 (async () => { 2087 try { 2088 if (firstLoad) { 2089 reblogIterator.current = masto.v1.statuses 2090 .$select(statusID) 2091 .rebloggedBy.list({ 2092 limit: REACTIONS_LIMIT, 2093 }); 2094 favouriteIterator.current = masto.v1.statuses 2095 .$select(statusID) 2096 .favouritedBy.list({ 2097 limit: REACTIONS_LIMIT, 2098 }); 2099 } 2100 const [{ value: reblogResults }, { value: favouriteResults }] = 2101 await Promise.allSettled([ 2102 reblogIterator.current.next(), 2103 favouriteIterator.current.next(), 2104 ]); 2105 if (reblogResults.value?.length || favouriteResults.value?.length) { 2106 if (reblogResults.value?.length) { 2107 for (const account of reblogResults.value) { 2108 const theAccount = accounts.find((a) => a.id === account.id); 2109 if (!theAccount) { 2110 accounts.push({ 2111 ...account, 2112 _types: ['reblog'], 2113 }); 2114 } else { 2115 theAccount._types.push('reblog'); 2116 } 2117 } 2118 } 2119 if (favouriteResults.value?.length) { 2120 for (const account of favouriteResults.value) { 2121 const theAccount = accounts.find((a) => a.id === account.id); 2122 if (!theAccount) { 2123 accounts.push({ 2124 ...account, 2125 _types: ['favourite'], 2126 }); 2127 } else { 2128 theAccount._types.push('favourite'); 2129 } 2130 } 2131 } 2132 setAccounts(accounts); 2133 setShowMore(!reblogResults.done || !favouriteResults.done); 2134 } else { 2135 setShowMore(false); 2136 } 2137 setUIState('default'); 2138 } catch (e) { 2139 console.error(e); 2140 setUIState('error'); 2141 } 2142 })(); 2143 } 2144 2145 useEffect(() => { 2146 fetchAccounts(true); 2147 }, []); 2148 2149 return ( 2150 <div id="reactions-container" class="sheet"> 2151 {!!onClose && ( 2152 <button type="button" class="sheet-close" onClick={onClose}> 2153 <Icon icon="x" /> 2154 </button> 2155 )} 2156 <header> 2157 <h2>Boosted/Favourited by…</h2> 2158 </header> 2159 <main> 2160 {accounts.length > 0 ? ( 2161 <> 2162 <ul class="reactions-list"> 2163 {accounts.map((account) => { 2164 const { _types } = account; 2165 return ( 2166 <li key={account.id + _types}> 2167 <div class="reactions-block"> 2168 {_types.map((type) => ( 2169 <Icon 2170 icon={ 2171 { 2172 reblog: 'rocket', 2173 favourite: 'heart', 2174 }[type] 2175 } 2176 class={`${type}-icon`} 2177 /> 2178 ))} 2179 </div> 2180 <AccountBlock account={account} instance={instance} /> 2181 </li> 2182 ); 2183 })} 2184 </ul> 2185 {uiState === 'default' ? ( 2186 showMore ? ( 2187 <InView 2188 onChange={(inView) => { 2189 if (inView) { 2190 fetchAccounts(); 2191 } 2192 }} 2193 > 2194 <button 2195 type="button" 2196 class="plain block" 2197 onClick={() => fetchAccounts()} 2198 > 2199 Show more… 2200 </button> 2201 </InView> 2202 ) : ( 2203 <p class="ui-state insignificant">The end.</p> 2204 ) 2205 ) : ( 2206 uiState === 'loading' && ( 2207 <p class="ui-state"> 2208 <Loader abrupt /> 2209 </p> 2210 ) 2211 )} 2212 </> 2213 ) : uiState === 'loading' ? ( 2214 <p class="ui-state"> 2215 <Loader abrupt /> 2216 </p> 2217 ) : uiState === 'error' ? ( 2218 <p class="ui-state">Unable to load accounts</p> 2219 ) : ( 2220 <p class="ui-state insignificant">No one yet.</p> 2221 )} 2222 </main> 2223 </div> 2224 ); 2225 } 2226 2227 function StatusButton({ 2228 checked, 2229 count, 2230 class: className, 2231 title, 2232 alt, 2233 icon, 2234 onClick, 2235 ...props 2236 }) { 2237 if (typeof title === 'string') { 2238 title = [title, title]; 2239 } 2240 if (typeof alt === 'string') { 2241 alt = [alt, alt]; 2242 } 2243 2244 const [buttonTitle, setButtonTitle] = useState(title[0] || ''); 2245 const [iconAlt, setIconAlt] = useState(alt[0] || ''); 2246 2247 useEffect(() => { 2248 if (checked) { 2249 setButtonTitle(title[1] || ''); 2250 setIconAlt(alt[1] || ''); 2251 } else { 2252 setButtonTitle(title[0] || ''); 2253 setIconAlt(alt[0] || ''); 2254 } 2255 }, [checked, title, alt]); 2256 2257 return ( 2258 <button 2259 type="button" 2260 title={buttonTitle} 2261 class={`plain ${className} ${checked ? 'checked' : ''}`} 2262 onClick={(e) => { 2263 if (!onClick) return; 2264 e.preventDefault(); 2265 e.stopPropagation(); 2266 onClick(e); 2267 }} 2268 {...props} 2269 > 2270 <Icon icon={icon} size="l" alt={iconAlt} /> 2271 {!!count && ( 2272 <> 2273 {' '} 2274 <small title={count}>{shortenNumber(count)}</small> 2275 </> 2276 )} 2277 </button> 2278 ); 2279 } 2280 2281 function VibeTagButton({ 2282 checked, 2283 count, 2284 class: className, 2285 title, 2286 alt, 2287 icon, 2288 text, 2289 onClick, 2290 disabled, 2291 ...props 2292 }) { 2293 if (typeof title === 'string') { 2294 title = [title, title]; 2295 } 2296 if (typeof alt === 'string') { 2297 alt = [alt, alt]; 2298 } 2299 2300 const [buttonTitle, setButtonTitle] = useState(title[0] || ''); 2301 const [iconAlt, setIconAlt] = useState(alt[0] || ''); 2302 2303 useEffect(() => { 2304 if (checked) { 2305 setButtonTitle(title[1] || ''); 2306 setIconAlt(alt[1] || ''); 2307 } else { 2308 setButtonTitle(title[0] || ''); 2309 setIconAlt(alt[0] || ''); 2310 } 2311 }, [checked, title, alt]); 2312 2313 return ( 2314 <button 2315 type="button" 2316 title={buttonTitle} 2317 class={`plain ${className} ${checked ? 'checked' : ''}`} 2318 disabled={disabled} 2319 onClick={(e) => { 2320 if (!onClick) return; 2321 e.preventDefault(); 2322 e.stopPropagation(); 2323 onClick(e); 2324 }} 2325 {...props} 2326 >{text + " "} 2327 <Icon icon={icon} size="l" alt={iconAlt} /> 2328 {!!count && count > 0 && ( 2329 <> 2330 {' '} 2331 <small title={count}>{shortenNumber(count)}</small> 2332 </> 2333 )} 2334 </button> 2335 ); 2336 } 2337 2338 export function formatDuration(time) { 2339 if (!time) return; 2340 let hours = Math.floor(time / 3600); 2341 let minutes = Math.floor((time % 3600) / 60); 2342 let seconds = Math.round(time % 60); 2343 2344 if (hours === 0) { 2345 return `${minutes}:${seconds.toString().padStart(2, '0')}`; 2346 } else { 2347 return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds 2348 .toString() 2349 .padStart(2, '0')}`; 2350 } 2351 } 2352 2353 const denylistDomains = /(twitter|github)\.com/i; 2354 const failedUnfurls = {}; 2355 2356 function _unfurlMastodonLink(instance, url) { 2357 const snapStates = snapshot(states); 2358 if (denylistDomains.test(url)) { 2359 return; 2360 } 2361 if (failedUnfurls[url]) { 2362 return; 2363 } 2364 const instanceRegex = new RegExp(instance + '/'); 2365 if (instanceRegex.test(snapStates.unfurledLinks[url]?.url)) { 2366 return Promise.resolve(snapStates.unfurledLinks[url]); 2367 } 2368 console.debug('🦦 Unfurling URL', url); 2369 2370 let remoteInstanceFetch; 2371 let theURL = url; 2372 if (/\/\/elk\.[^\/]+\/[^.]+\.[^.]+/i.test(theURL)) { 2373 // E.g. https://elk.zone/domain.com/@stest/123 -> https://domain.com/@stest/123 2374 theURL = theURL.replace(/elk\.[^\/]+\//i, ''); 2375 } 2376 const urlObj = new URL(theURL); 2377 const domain = urlObj.hostname; 2378 const path = urlObj.pathname; 2379 // Regex /:username/:id, where username = @username or @username@domain, id = number 2380 const statusRegex = /\/@([^@\/]+)@?([^\/]+)?\/(\d+)$/i; 2381 const statusMatch = statusRegex.exec(path); 2382 if (statusMatch) { 2383 const id = statusMatch[3]; 2384 const { masto } = api({ instance: domain }); 2385 remoteInstanceFetch = masto.v1.statuses 2386 .$select(id) 2387 .fetch() 2388 .then((status) => { 2389 if (status?.id) { 2390 return { 2391 status, 2392 instance: domain, 2393 }; 2394 } else { 2395 throw new Error('No results'); 2396 } 2397 }); 2398 } 2399 2400 const { masto } = api({ instance }); 2401 const mastoSearchFetch = masto.v2.search 2402 .fetch({ 2403 q: url, 2404 type: 'statuses', 2405 resolve: true, 2406 limit: 1, 2407 }) 2408 .then((results) => { 2409 if (results.statuses.length > 0) { 2410 const status = results.statuses[0]; 2411 return { 2412 status, 2413 instance, 2414 }; 2415 } else { 2416 throw new Error('No results'); 2417 } 2418 }); 2419 2420 function handleFulfill(result) { 2421 const { status, instance } = result; 2422 const { id } = status; 2423 const selfURL = `/${instance}/s/${id}`; 2424 console.debug('🦦 Unfurled URL', url, id, selfURL); 2425 const data = { 2426 id, 2427 instance, 2428 url: selfURL, 2429 }; 2430 states.unfurledLinks[url] = data; 2431 saveStatus(status, instance, { 2432 skipThreading: true, 2433 }); 2434 return data; 2435 } 2436 function handleCatch(e) { 2437 failedUnfurls[url] = true; 2438 } 2439 2440 if (remoteInstanceFetch) { 2441 return Promise.any([remoteInstanceFetch, mastoSearchFetch]) 2442 .then(handleFulfill) 2443 .catch(handleCatch); 2444 } else { 2445 return mastoSearchFetch.then(handleFulfill).catch(handleCatch); 2446 } 2447 } 2448 2449 function nicePostURL(url) { 2450 if (!url) return; 2451 const urlObj = new URL(url); 2452 const { host, pathname } = urlObj; 2453 const path = pathname.replace(/\/$/, ''); 2454 // split only first slash 2455 const [_, username, restPath] = path.match(/\/(@[^\/]+)\/(.*)/) || []; 2456 return ( 2457 <> 2458 {host} 2459 {username ? ( 2460 <> 2461 /{username} 2462 <wbr /> 2463 <span class="more-insignificant">/{restPath}</span> 2464 </> 2465 ) : ( 2466 <span class="more-insignificant">{path}</span> 2467 )} 2468 </> 2469 ); 2470 } 2471 2472 const unfurlMastodonLink = throttle(_unfurlMastodonLink); 2473 2474 function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) { 2475 const { 2476 account: { avatar, avatarStatic, bot, group }, 2477 createdAt, 2478 visibility, 2479 reblog, 2480 } = status; 2481 const isReblog = !!reblog; 2482 const filterTitleStr = filterInfo?.titlesStr || ''; 2483 const createdAtDate = new Date(createdAt); 2484 const statusPeekText = statusPeek(status.reblog || status); 2485 2486 const [showPeek, setShowPeek] = useState(false); 2487 const bindLongPressPeek = useLongPress( 2488 () => { 2489 setShowPeek(true); 2490 }, 2491 { 2492 threshold: 600, 2493 captureEvent: true, 2494 detect: 'touch', 2495 cancelOnMovement: 2, // true allows movement of up to 25 pixels 2496 }, 2497 ); 2498 2499 const statusPeekRef = useTruncated(); 2500 2501 return ( 2502 <div 2503 class={isReblog ? (group ? 'status-group' : 'status-reblog') : ''} 2504 {...containerProps} 2505 title={statusPeekText} 2506 onContextMenu={(e) => { 2507 e.preventDefault(); 2508 setShowPeek(true); 2509 }} 2510 {...bindLongPressPeek()} 2511 > 2512 <article class="status filtered" tabindex="-1"> 2513 <b 2514 class="status-filtered-badge clickable badge-meta" 2515 title={filterTitleStr} 2516 onClick={(e) => { 2517 e.preventDefault(); 2518 setShowPeek(true); 2519 }} 2520 > 2521 <span>Filtered</span> 2522 <span>{filterTitleStr}</span> 2523 </b>{' '} 2524 <Avatar url={avatarStatic || avatar} squircle={bot} /> 2525 <span class="status-filtered-info"> 2526 <span class="status-filtered-info-1"> 2527 <NameText account={status.account} instance={instance} />{' '} 2528 <Icon 2529 icon={visibilityIconsMap[visibility]} 2530 alt={visibilityText[visibility]} 2531 size="s" 2532 />{' '} 2533 {isReblog ? ( 2534 'boosted' 2535 ) : ( 2536 <RelativeTime datetime={createdAtDate} format="micro" /> 2537 )} 2538 </span> 2539 <span class="status-filtered-info-2"> 2540 {isReblog && ( 2541 <> 2542 <Avatar 2543 url={reblog.account.avatarStatic || reblog.account.avatar} 2544 squircle={bot} 2545 />{' '} 2546 </> 2547 )} 2548 {statusPeekText} 2549 </span> 2550 </span> 2551 </article> 2552 {!!showPeek && ( 2553 <Modal 2554 class="light" 2555 onClick={(e) => { 2556 if (e.target === e.currentTarget) { 2557 setShowPeek(false); 2558 } 2559 }} 2560 > 2561 <div id="filtered-status-peek" class="sheet"> 2562 <button 2563 type="button" 2564 class="sheet-close" 2565 onClick={() => setShowPeek(false)} 2566 > 2567 <Icon icon="x" /> 2568 </button> 2569 <header> 2570 <b class="status-filtered-badge">Filtered</b> {filterTitleStr} 2571 </header> 2572 <main tabIndex="-1"> 2573 <Link 2574 ref={statusPeekRef} 2575 class="status-link" 2576 to={`/${instance}/s/${status.id}`} 2577 onClick={() => { 2578 setShowPeek(false); 2579 }} 2580 data-read-more="Read more →" 2581 > 2582 <Status status={status} instance={instance} size="s" readOnly /> 2583 </Link> 2584 </main> 2585 </div> 2586 </Modal> 2587 )} 2588 </div> 2589 ); 2590 } 2591 2592 const QuoteStatuses = memo(({ id, instance, level = 0 }) => { 2593 if (!id || !instance) return; 2594 const snapStates = useSnapshot(states); 2595 const sKey = statusKey(id, instance); 2596 const quotes = snapStates.statusQuotes[sKey]; 2597 const uniqueQuotes = quotes?.filter( 2598 (q, i, arr) => arr.findIndex((q2) => q2.url === q.url) === i, 2599 ); 2600 2601 if (!uniqueQuotes?.length) return; 2602 if (level > 2) return; 2603 2604 return uniqueQuotes.map((q) => { 2605 return ( 2606 <Link 2607 key={q.instance + q.id} 2608 to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`} 2609 class="status-card-link" 2610 data-read-more="Read more →" 2611 > 2612 <Status 2613 statusID={q.id} 2614 instance={q.instance} 2615 size="s" 2616 quoted={level + 1} 2617 /> 2618 </Link> 2619 ); 2620 }); 2621 }); 2622 2623 export default memo(Status);