/ src / components / status.jsx
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                        &bull; <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&hellip;
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&hellip;
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);