import './status.css'; import '@justinribeiro/lite-youtube'; import { ControlledMenu, Menu, MenuDivider, MenuHeader, MenuItem, } from '@szhsin/react-menu'; import { decodeBlurHash, getBlurHashAverageColor } from 'fast-blurhash'; import { shallowEqual } from 'fast-equals'; import prettify from 'html-prettify'; import { memo } from 'preact/compat'; import { useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'preact/hooks'; import punycode from 'punycode'; import { useHotkeys } from 'react-hotkeys-hook'; import { useLongPress } from 'use-long-press'; import { useSnapshot } from 'valtio'; import CustomEmoji from '../components/custom-emoji'; import EmojiText from '../components/emoji-text'; import LazyShazam from '../components/lazy-shazam'; import Loader from '../components/loader'; import Menu2 from '../components/menu2'; import MenuConfirm from '../components/menu-confirm'; import Modal from '../components/modal'; import NameText from '../components/name-text'; import Poll from '../components/poll'; import { api } from '../utils/api'; import emojifyText from '../utils/emojify-text'; import enhanceContent from '../utils/enhance-content'; import FilterContext from '../utils/filter-context'; import { isFiltered } from '../utils/filters'; import getTranslateTargetLanguage from '../utils/get-translate-target-language'; import getHTMLText from '../utils/getHTMLText'; import handleContentLinks from '../utils/handle-content-links'; import htmlContentLength from '../utils/html-content-length'; import isMastodonLinkMaybe from '../utils/isMastodonLinkMaybe'; import localeMatch from '../utils/locale-match'; import niceDateTime from '../utils/nice-date-time'; import openCompose from '../utils/open-compose'; import pmem from '../utils/pmem'; import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding'; import shortenNumber from '../utils/shorten-number'; import showToast from '../utils/show-toast'; import { speak, supportsTTS } from '../utils/speech'; import states, { getStatus, saveStatus, statusKey } from '../utils/states'; import statusPeek from '../utils/status-peek'; import store from '../utils/store'; import unfurlMastodonLink from '../utils/unfurl-link'; import useTruncated from '../utils/useTruncated'; import visibilityIconsMap from '../utils/visibility-icons-map'; import Avatar from './avatar'; import Icon from './icon'; import Link from './link'; import Media from './media'; import { isMediaCaptionLong } from './media'; import MenuLink from './menu-link'; import RelativeTime from './relative-time'; import TranslationBlock from './translation-block'; const SHOW_COMMENT_COUNT_LIMIT = 280; const INLINE_TRANSLATE_LIMIT = 140; function fetchAccount(id, masto) { return masto.v1.accounts.$select(id).fetch(); } const memFetchAccount = pmem(fetchAccount); const visibilityText = { public: 'Public', unlisted: 'Unlisted', private: 'Followers only', direct: 'Private mention', }; const isIOS = window.ontouchstart !== undefined && /iPad|iPhone|iPod/.test(navigator.userAgent); const rtf = new Intl.RelativeTimeFormat(); const REACTIONS_LIMIT = 80; function getPollText(poll) { if (!poll?.options?.length) return ''; return `πŸ“Š:\n${poll.options .map( (option) => `- ${option.title}${ option.votesCount >= 0 ? ` (${option.votesCount})` : '' }`, ) .join('\n')}`; } function getPostText(status) { const { spoilerText, content, poll } = status; return ( (spoilerText ? `${spoilerText}\n\n` : '') + getHTMLText(content) + getPollText(poll) ); } const PostContent = memo( ({ post, instance, previewMode }) => { const { content, emojis, language, mentions, url } = post; return (
{ // Remove target="_blank" from links dom.querySelectorAll('a.u-url[target="_blank"]').forEach((a) => { if (!/http/i.test(a.innerText.trim())) { a.removeAttribute('target'); } }); }, }), }} /> ); }, (oldProps, newProps) => { const { post: oldPost } = oldProps; const { post: newPost } = newProps; return oldPost.content === newPost.content; }, ); function Status({ statusID, status, instance: propInstance, size = 'm', contentTextWeight, readOnly, enableCommentHint, withinContext, skeleton, enableTranslate, forceTranslate: _forceTranslate, previewMode, // allowFilters, onMediaClick, quoted, onStatusLinkClick = () => {}, showFollowedTags, allowContextMenu, showActionsBar, showReplyParent, mediaFirst, }) { if (skeleton) { return (
{!mediaFirst && }
{(size === 's' || mediaFirst) && } β–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
{mediaFirst &&
}

β–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ

); } const { masto, instance, authenticated } = api({ instance: propInstance }); const { instance: currentInstance } = api(); const sameInstance = instance === currentInstance; let sKey = statusKey(statusID || status?.id, instance); const snapStates = useSnapshot(states); if (!status) { status = snapStates.statuses[sKey] || snapStates.statuses[statusID]; sKey = statusKey(status?.id, instance); } if (!status) { return null; } const { account: { acct, avatar, avatarStatic, id: accountId, url: accountURL, displayName, username, emojis: accountEmojis, bot, group, }, id, repliesCount, reblogged, reblogsCount, favourited, favouritesCount, bookmarked, poll, muted, sensitive, spoilerText, visibility, // public, unlisted, private, direct language, editedAt, filtered, card, createdAt, inReplyToId, inReplyToAccountId, content, mentions, mediaAttachments, reblog, uri, url, emojis, tags, // Non-API props _deleted, _pinned, // _filtered, // Non-Mastodon emojiReactions, } = status; // if (!mediaAttachments?.length) mediaFirst = false; const hasMediaAttachments = !!mediaAttachments?.length; if (mediaFirst && hasMediaAttachments) size = 's'; const currentAccount = useMemo(() => { return store.session.get('currentAccount'); }, []); const isSelf = useMemo(() => { return currentAccount && currentAccount === accountId; }, [accountId, currentAccount]); const filterContext = useContext(FilterContext); const filterInfo = !isSelf && !readOnly && !previewMode && isFiltered(filtered, filterContext); if (filterInfo?.action === 'hide') { return null; } console.debug('RENDER Status', id, status?.account.displayName, quoted); const debugHover = (e) => { if (e.shiftKey) { console.log({ ...status, }); } }; if (/*allowFilters && */ size !== 'l' && filterInfo) { return ( ); } const createdAtDate = new Date(createdAt); const editedAtDate = new Date(editedAt); let inReplyToAccountRef = mentions?.find( (mention) => mention.id === inReplyToAccountId, ); if (!inReplyToAccountRef && inReplyToAccountId === id) { inReplyToAccountRef = { url: accountURL, username, displayName }; } const [inReplyToAccount, setInReplyToAccount] = useState(inReplyToAccountRef); if (!withinContext && !inReplyToAccount && inReplyToAccountId) { const account = states.accounts[inReplyToAccountId]; if (account) { setInReplyToAccount(account); } else { memFetchAccount(inReplyToAccountId, masto) .then((account) => { setInReplyToAccount(account); states.accounts[account.id] = account; }) .catch((e) => {}); } } const mentionSelf = inReplyToAccountId === currentAccount || mentions?.find((mention) => mention.id === currentAccount); const readingExpandSpoilers = useMemo(() => { const prefs = store.account.get('preferences') || {}; return !!prefs['reading:expand:spoilers']; }, []); const readingExpandMedia = useMemo(() => { // default | show_all | hide_all // Ignore hide_all because it means hide *ALL* media including non-sensitive ones const prefs = store.account.get('preferences') || {}; return prefs['reading:expand:media'] || 'default'; }, []); // FOR TESTING: // const readingExpandSpoilers = true; // const readingExpandMedia = 'show_all'; const showSpoiler = previewMode || readingExpandSpoilers || !!snapStates.spoilers[id]; const showSpoilerMedia = previewMode || readingExpandMedia === 'show_all' || !!snapStates.spoilersMedia[id]; if (reblog) { // If has statusID, means useItemID (cached in states) if (group) { return (
{' '}
); } return (
{' '} {' '} boosted
); } // Check followedTags if (showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length) { return (
{' '} {snapStates.statusFollowedTags[sKey].slice(0, 3).map((tag) => ( {tag} ))}
); } const isSizeLarge = size === 'l'; const [forceTranslate, setForceTranslate] = useState(_forceTranslate); const targetLanguage = getTranslateTargetLanguage(true); const contentTranslationHideLanguages = snapStates.settings.contentTranslationHideLanguages || []; const { contentTranslation, contentTranslationAutoInline } = snapStates.settings; if (!contentTranslation) enableTranslate = false; const inlineTranslate = useMemo(() => { if ( !contentTranslation || !contentTranslationAutoInline || readOnly || (withinContext && !isSizeLarge) || previewMode || spoilerText || sensitive || poll || card || mediaAttachments?.length ) { return false; } const contentLength = htmlContentLength(content); return contentLength > 0 && contentLength <= INLINE_TRANSLATE_LIMIT; }, [ contentTranslation, contentTranslationAutoInline, readOnly, withinContext, isSizeLarge, previewMode, spoilerText, sensitive, poll, card, mediaAttachments, content, ]); const [showEdited, setShowEdited] = useState(false); const [showEmbed, setShowEmbed] = useState(false); const spoilerContentRef = useTruncated(); const contentRef = useTruncated(); const mediaContainerRef = useTruncated(); const readMoreText = 'Read more β†’'; const statusRef = useRef(null); const unauthInteractionErrorMessage = `Sorry, your current logged-in instance can't interact with this post from another instance.`; const textWeight = useCallback( () => Math.max( Math.round((spoilerText.length + htmlContentLength(content)) / 140) || 1, 1, ), [spoilerText, content], ); const createdDateText = niceDateTime(createdAtDate); const editedDateText = editedAt && niceDateTime(editedAtDate); // Can boost if: // - authenticated AND // - visibility != direct OR // - visibility = private AND isSelf let canBoost = authenticated && visibility !== 'direct' && visibility !== 'private'; if (visibility === 'private' && isSelf) { canBoost = true; } const replyStatus = (e) => { if (!sameInstance || !authenticated) { return alert(unauthInteractionErrorMessage); } // syntheticEvent comes from MenuItem if (e?.shiftKey || e?.syntheticEvent?.shiftKey) { const newWin = openCompose({ replyToStatus: status, }); if (newWin) return; } states.showCompose = { replyToStatus: status, }; }; // Check if media has no descriptions const mediaNoDesc = useMemo(() => { return mediaAttachments.some( (attachment) => !attachment.description?.trim?.(), ); }, [mediaAttachments]); const statusMonthsAgo = useMemo(() => { return Math.floor( (new Date() - createdAtDate) / (1000 * 60 * 60 * 24 * 30), ); }, [createdAtDate]); const boostStatus = async () => { if (!sameInstance || !authenticated) { alert(unauthInteractionErrorMessage); return false; } try { if (!reblogged) { let confirmText = 'Boost this post?'; if (mediaNoDesc) { confirmText += '\n\n⚠️ Some media have no descriptions.'; } const yes = confirm(confirmText); if (!yes) { return false; } } // Optimistic states.statuses[sKey] = { ...status, reblogged: !reblogged, reblogsCount: reblogsCount + (reblogged ? -1 : 1), }; if (reblogged) { const newStatus = await masto.v1.statuses.$select(id).unreblog(); saveStatus(newStatus, instance); return true; } else { const newStatus = await masto.v1.statuses.$select(id).reblog(); saveStatus(newStatus, instance); return true; } } catch (e) { console.error(e); // Revert optimistism states.statuses[sKey] = status; return false; } }; const confirmBoostStatus = async () => { if (!sameInstance || !authenticated) { alert(unauthInteractionErrorMessage); return false; } try { // Optimistic states.statuses[sKey] = { ...status, reblogged: !reblogged, reblogsCount: reblogsCount + (reblogged ? -1 : 1), }; if (reblogged) { const newStatus = await masto.v1.statuses.$select(id).unreblog(); saveStatus(newStatus, instance); } else { const newStatus = await masto.v1.statuses.$select(id).reblog(); saveStatus(newStatus, instance); } return true; } catch (e) { console.error(e); // Revert optimistism states.statuses[sKey] = status; return false; } }; const favouriteStatus = async () => { if (!sameInstance || !authenticated) { alert(unauthInteractionErrorMessage); return false; } try { // Optimistic states.statuses[sKey] = { ...status, favourited: !favourited, favouritesCount: favouritesCount + (favourited ? -1 : 1), }; if (favourited) { const newStatus = await masto.v1.statuses.$select(id).unfavourite(); saveStatus(newStatus, instance); } else { const newStatus = await masto.v1.statuses.$select(id).favourite(); saveStatus(newStatus, instance); } return true; } catch (e) { console.error(e); // Revert optimistism states.statuses[sKey] = status; return false; } }; const favouriteStatusNotify = async () => { try { const done = await favouriteStatus(); if (!isSizeLarge && done) { showToast( favourited ? `Unliked @${username || acct}'s post` : `Liked @${username || acct}'s post`, ); } } catch (e) {} }; const bookmarkStatus = async () => { if (!sameInstance || !authenticated) { alert(unauthInteractionErrorMessage); return false; } try { // Optimistic states.statuses[sKey] = { ...status, bookmarked: !bookmarked, }; if (bookmarked) { const newStatus = await masto.v1.statuses.$select(id).unbookmark(); saveStatus(newStatus, instance); } else { const newStatus = await masto.v1.statuses.$select(id).bookmark(); saveStatus(newStatus, instance); } return true; } catch (e) { console.error(e); // Revert optimistism states.statuses[sKey] = status; return false; } }; const bookmarkStatusNotify = async () => { try { const done = await bookmarkStatus(); if (!isSizeLarge && done) { showToast( bookmarked ? `Unbookmarked @${username || acct}'s post` : `Bookmarked @${username || acct}'s post`, ); } } catch (e) {} }; const differentLanguage = !!language && language !== targetLanguage && !localeMatch([language], [targetLanguage]) && !contentTranslationHideLanguages.find( (l) => language === l || localeMatch([language], [l]), ); const reblogIterator = useRef(); const favouriteIterator = useRef(); async function fetchBoostedLikedByAccounts(firstLoad) { if (firstLoad) { reblogIterator.current = masto.v1.statuses .$select(statusID) .rebloggedBy.list({ limit: REACTIONS_LIMIT, }); favouriteIterator.current = masto.v1.statuses .$select(statusID) .favouritedBy.list({ limit: REACTIONS_LIMIT, }); } const [{ value: reblogResults }, { value: favouriteResults }] = await Promise.allSettled([ reblogIterator.current.next(), favouriteIterator.current.next(), ]); if (reblogResults.value?.length || favouriteResults.value?.length) { const accounts = []; if (reblogResults.value?.length) { accounts.push( ...reblogResults.value.map((a) => { a._types = ['reblog']; return a; }), ); } if (favouriteResults.value?.length) { accounts.push( ...favouriteResults.value.map((a) => { a._types = ['favourite']; return a; }), ); } return { value: accounts, done: reblogResults.done && favouriteResults.done, }; } return { value: [], done: true, }; } const actionsRef = useRef(); const isPublic = ['public', 'unlisted'].includes(visibility); const isPinnable = ['public', 'unlisted', 'private'].includes(visibility); const StatusMenuItems = ( <> {!isSizeLarge && sameInstance && ( <> )} {!isSizeLarge && sameInstance && (isSizeLarge || showActionsBar) && ( )} {(isSizeLarge || showActionsBar) && ( <> { states.showGenericAccounts = { heading: 'Boosted/Liked by…', fetchAccounts: fetchBoostedLikedByAccounts, instance, showReactions: true, postID: sKey, }; }} > Boosted/Liked by… )} {!mediaFirst && ( <> {(enableTranslate || !language || differentLanguage) && ( )} {enableTranslate ? (
{ setForceTranslate(true); }} > Translate {supportsTTS && ( { const postText = getPostText(status); if (postText) { speak(postText, language); } }} > Speak )}
) : ( (!language || differentLanguage) && (
Translate {supportsTTS && ( { const postText = getPostText(status); if (postText) { speak(postText, language); } }} > Speak )}
) )} )} {((!isSizeLarge && sameInstance) || enableTranslate || !language || differentLanguage) && } {!isSizeLarge && ( <> { onStatusLinkClick(e, status); }} > View post by @{username || acct}
{visibilityText[visibility]} β€’ {createdDateText}
)} {!!editedAt && ( <> { setShowEdited(id); }} > Show Edit History
Edited: {editedDateText}
)} {nicePostURL(url)} {isPublic && isSizeLarge && ( { setShowEmbed(true); }} > Embed post )} {(isSelf || mentionSelf) && } {(isSelf || mentionSelf) && ( { try { const newStatus = await masto.v1.statuses .$select(id) [muted ? 'unmute' : 'mute'](); saveStatus(newStatus, instance); showToast(muted ? 'Conversation unmuted' : 'Conversation muted'); } catch (e) { console.error(e); showToast( muted ? 'Unable to unmute conversation' : 'Unable to mute conversation', ); } }} > {muted ? ( <> Unmute conversation ) : ( <> Mute conversation )} )} {isSelf && isPinnable && ( { try { const newStatus = await masto.v1.statuses .$select(id) [_pinned ? 'unpin' : 'pin'](); // saveStatus(newStatus, instance); showToast( _pinned ? 'Post unpinned from profile' : 'Post pinned to profile', ); } catch (e) { console.error(e); showToast( _pinned ? 'Unable to unpin post' : 'Unable to pin post', ); } }} > {_pinned ? ( <> Unpin from profile ) : ( <> Pin to profile )} )} {isSelf && ( )} {!isSelf && isSizeLarge && ( <> { states.showReportModal = { account: status.account, post: status, }; }} > Report post… )} ); const contextMenuRef = useRef(); const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); const [contextMenuProps, setContextMenuProps] = useState({}); const showContextMenu = allowContextMenu || (!isSizeLarge && !previewMode && !_deleted && !quoted); // Only iOS/iPadOS browsers don't support contextmenu // Some comments report iPadOS might support contextmenu if a mouse is connected const bindLongPressContext = useLongPress( isIOS && showContextMenu ? (e) => { if (e.pointerType === 'mouse') return; // There's 'pen' too, but not sure if contextmenu event would trigger from a pen const { clientX, clientY } = e.touches?.[0] || e; // link detection copied from onContextMenu because here it works const link = e.target.closest('a'); if ( link && statusRef.current.contains(link) && !link.getAttribute('href').startsWith('#') ) return; e.preventDefault(); setContextMenuProps({ anchorPoint: { x: clientX, y: clientY, }, direction: 'right', }); setIsContextMenuOpen(true); } : null, { threshold: 600, captureEvent: true, detect: 'touch', cancelOnMovement: 2, // true allows movement of up to 25 pixels }, ); const hotkeysEnabled = !readOnly && !previewMode && !quoted; const rRef = useHotkeys('r, shift+r', replyStatus, { enabled: hotkeysEnabled, }); const fRef = useHotkeys('f, l', favouriteStatusNotify, { enabled: hotkeysEnabled, }); const dRef = useHotkeys('d', bookmarkStatusNotify, { enabled: hotkeysEnabled, }); const bRef = useHotkeys( 'shift+b', () => { (async () => { try { const done = await confirmBoostStatus(); if (!isSizeLarge && done) { showToast( reblogged ? `Unboosted @${username || acct}'s post` : `Boosted @${username || acct}'s post`, ); } } catch (e) {} })(); }, { enabled: hotkeysEnabled && canBoost, }, ); const xRef = useHotkeys('x', (e) => { const activeStatus = document.activeElement.closest( '.status-link, .status-focus', ); if (activeStatus) { const spoilerButton = activeStatus.querySelector( '.spoiler-button:not(.spoiling)', ); if (spoilerButton) { e.stopPropagation(); spoilerButton.click(); } else { const spoilerMediaButton = activeStatus.querySelector( '.spoiler-media-button:not(.spoiling)', ); if (spoilerMediaButton) { e.stopPropagation(); spoilerMediaButton.click(); } } } }); const displayedMediaAttachments = mediaAttachments.slice( 0, isSizeLarge ? undefined : 4, ); const showMultipleMediaCaptions = mediaAttachments.length > 1 && displayedMediaAttachments.some( (media) => !!media.description && !isMediaCaptionLong(media.description), ); const captionChildren = useMemo(() => { if (!showMultipleMediaCaptions) return null; const attachments = []; displayedMediaAttachments.forEach((media, i) => { if (!media.description) return; const index = attachments.findIndex( (attachment) => attachment.media.description === media.description, ); if (index === -1) { attachments.push({ media, indices: [i], }); } else { attachments[index].indices.push(i); } }); return attachments.map(({ media, indices }) => (
i + 1).join(' ')} onClick={(e) => { e.preventDefault(); e.stopPropagation(); states.showMediaAlt = { alt: media.description, lang: language, }; }} title={media.description} > {indices.map((i) => i + 1).join(' ')} {media.description}
)); // return displayedMediaAttachments.map( // (media, i) => // !!media.description && ( //
{ // e.preventDefault(); // e.stopPropagation(); // states.showMediaAlt = { // alt: media.description, // lang: language, // }; // }} // title={media.description} // > // {i + 1} {media.description} //
// ), // ); }, [showMultipleMediaCaptions, displayedMediaAttachments, language]); const isThread = useMemo(() => { return ( (!!inReplyToId && inReplyToAccountId === status.account?.id) || !!snapStates.statusThreadNumber[sKey] ); }, [ inReplyToId, inReplyToAccountId, status.account?.id, snapStates.statusThreadNumber[sKey], ]); const showCommentHint = useMemo(() => { return ( enableCommentHint && !isThread && !withinContext && !inReplyToId && visibility === 'public' && repliesCount > 0 ); }, [ enableCommentHint, isThread, withinContext, inReplyToId, repliesCount, visibility, ]); const showCommentCount = useMemo(() => { if ( card || poll || sensitive || spoilerText || mediaAttachments?.length || isThread || withinContext || inReplyToId || repliesCount <= 0 ) { return false; } const questionRegex = /[??οΌŸοΈ–β“β”β‡βˆβ‰ΒΏβ€½ΨŸ]/; const containsQuestion = questionRegex.test(content); if (!containsQuestion) return false; const contentLength = htmlContentLength(content); if (contentLength > 0 && contentLength <= SHOW_COMMENT_COUNT_LIMIT) { return true; } }, [ card, poll, sensitive, spoilerText, mediaAttachments, reblog, isThread, withinContext, inReplyToId, repliesCount, content, ]); return ( <> {showReplyParent && !!(inReplyToId && inReplyToAccountId) && ( )}
{ statusRef.current = node; // Use parent node if it's in focus // Use case: // When navigating (j/k), the is focused instead of // Hotkey binding doesn't bubble up thus this hack const nodeRef = node?.closest?.( '.timeline-item, .timeline-item-alt, .status-link, .status-focus', ) || node; rRef.current = nodeRef; fRef.current = nodeRef; dRef.current = nodeRef; bRef.current = nodeRef; xRef.current = nodeRef; }} tabindex="-1" class={`status ${ !withinContext && inReplyToId && inReplyToAccount ? 'status-reply-to' : '' } visibility-${visibility} ${_pinned ? 'status-pinned' : ''} ${ { s: 'small', m: 'medium', l: 'large', }[size] } ${_deleted ? 'status-deleted' : ''} ${quoted ? 'status-card' : ''} ${ isContextMenuOpen ? 'status-menu-open' : '' } ${mediaFirst && hasMediaAttachments ? 'status-media-first' : ''}`} onMouseEnter={debugHover} onContextMenu={(e) => { if (!showContextMenu) return; if (e.metaKey) return; // console.log('context menu', e); const link = e.target.closest('a'); if ( link && statusRef.current.contains(link) && !link.getAttribute('href').startsWith('#') ) return; // If there's selected text, don't show custom context menu const selection = window.getSelection?.(); if (selection.toString().length > 0) { const { anchorNode } = selection; if (statusRef.current?.contains(anchorNode)) { return; } } e.preventDefault(); setContextMenuProps({ anchorPoint: { x: e.clientX, y: e.clientY, }, direction: 'right', }); setIsContextMenuOpen(true); }} {...(showContextMenu ? bindLongPressContext() : {})} > {showContextMenu && ( { setIsContextMenuOpen(false); // statusRef.current?.focus?.(); if (e?.reason === 'click') { statusRef.current?.closest('[tabindex]')?.focus?.(); } }} portal={{ target: document.body, }} containerProps={{ style: { // Higher than the backdrop zIndex: 1001, }, onClick: () => { contextMenuRef.current?.closeMenu?.(); }, }} overflow="auto" boundingBoxPadding={safeBoundingBoxPadding()} unmountOnClose > {StatusMenuItems} )} {showActionsBar && size !== 'l' && !previewMode && !readOnly && !_deleted && !quoted && (
)} {size !== 'l' && (
{reblogged && } {favourited && } {bookmarked && } {_pinned && }
)} {size !== 's' && (
{ e.preventDefault(); e.stopPropagation(); states.showAccount = { account: status.account, instance, }; }} > )}
{/* {inReplyToAccount && !withinContext && size !== 's' && ( <> {' '} {' '} )} */} {/* */}{' '} {size !== 'l' && (_deleted ? ( Deleted ) : url && !previewMode && !readOnly && !quoted ? ( { if ( e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.which === 2 ) { return; } e.preventDefault(); e.stopPropagation(); onStatusLinkClick?.(e, status); setContextMenuProps({ anchorRef: { current: e.currentTarget, }, align: 'end', direction: 'bottom', gap: 4, }); setIsContextMenuOpen(true); }} class={`time ${ isContextMenuOpen && contextMenuProps?.anchorRef ? 'is-open' : '' }`} > {showCommentHint && !showCommentCount ? ( ) : ( visibility !== 'public' && visibility !== 'direct' && ( ) )}{' '} {!previewMode && !readOnly && ( )} ) : ( // { // if (e.target === e.currentTarget) // menuInstanceRef.current?.closeMenu?.(); // }, // }} // align="end" // gap={4} // overflow="auto" // viewScroll="close" // boundingBoxPadding="8 8 8 8" // unmountOnClose // menuButton={({ open }) => ( // { // e.preventDefault(); // e.stopPropagation(); // onStatusLinkClick?.(e, status); // }} // class={`time ${open ? 'is-open' : ''}`} // > // {' '} // // // )} // > // {StatusMenuItems} // {visibility !== 'public' && visibility !== 'direct' && ( <> {' '} )} ))}
{visibility === 'direct' && ( <>
Private mention
{' '} )} {!withinContext && ( <> {isThread ? (
Thread {snapStates.statusThreadNumber[sKey] ? ` ${snapStates.statusThreadNumber[sKey]}/X` : ''}
) : ( !!inReplyToId && !!inReplyToAccount && (!!spoilerText || !mentions.find((mention) => { return mention.id === inReplyToAccountId; })) && (
{' '}
) )} )}
{mediaFirst && hasMediaAttachments ? ( <> {(!!spoilerText || !!sensitive) && !readingExpandSpoilers && ( <> {!!spoilerText && ( {' '} )} )} {!!content && (
)} ) : ( <> {!!spoilerText && ( <>

{readingExpandSpoilers || previewMode ? (
Content warning
) : ( )} )} {!!content && (
)} {!!poll && ( { states.statuses[sKey].poll = newPoll; }} refresh={() => { return masto.v1.polls .$select(poll.id) .fetch() .then((pollResponse) => { states.statuses[sKey].poll = pollResponse; }) .catch((e) => {}); // Silently fail }} votePoll={(choices) => { return masto.v1.polls .$select(poll.id) .votes.create({ choices, }) .then((pollResponse) => { states.statuses[sKey].poll = pollResponse; }) .catch((e) => {}); // Silently fail }} /> )} {(((enableTranslate || inlineTranslate) && !!content.trim() && !!getHTMLText(emojifyText(content, emojis)) && differentLanguage) || forceTranslate) && ( )} {!previewMode && sensitive && !!mediaAttachments.length && readingExpandMedia !== 'show_all' && ( )} {!!mediaAttachments.length && (
2 ? 'media-gt2' : ''} ${ mediaAttachments.length > 4 ? 'media-gt4' : '' }`} > {displayedMediaAttachments.map((media, i) => ( { onMediaClick(e, i, media, status); } : undefined } /> ))}
)} {!!card && /^https/i.test(card?.url) && !sensitive && !spoilerText && !poll && !mediaAttachments.length && !snapStates.statusQuotes[sKey] && ( )} )}
{!isSizeLarge && showCommentCount && (
{repliesCount}
)} {isSizeLarge && ( <>
{_deleted ? ( Deleted ) : ( <> {/* */} {visibilityText[visibility]} •{' '} {editedAt && ( <> {' '} • {' '} )} )}
{!!emojiReactions?.length && (
{emojiReactions.map((emojiReaction) => { const { name, count, me, url, staticUrl } = emojiReaction; if (url) { // Some servers return url and staticUrl return ( {' '} {count} ); } const isShortCode = /^:.+?:$/.test(name); if (isShortCode) { const emoji = emojis.find( (e) => e.shortcode === name.replace(/^:/, '').replace(/:$/, ''), ); if (emoji) { return ( {' '} {count} ); } } return ( {name} {count} ); })}
)}
{/*
*/} {reblogged ? 'Unboost' : 'Boost'} } menuExtras={ { states.showCompose = { draftStatus: { status: `\n${url}`, }, }; }} > Quote } menuFooter={ mediaNoDesc && !reblogged && ( ) } >
} > {StatusMenuItems}
)}
{!!showEdited && ( { if (e.target === e.currentTarget) { setShowEdited(false); // statusRef.current?.focus(); } }} > { return masto.v1.statuses.$select(showEdited).history.list(); }} onClose={() => { setShowEdited(false); statusRef.current?.focus(); }} /> )} {!!showEmbed && ( { if (e.target === e.currentTarget) { setShowEmbed(false); } }} > { setShowEmbed(false); }} /> )} ); } function MultipleMediaFigure(props) { const { enabled, children, lang, captionChildren } = props; if (!enabled || !captionChildren) return children; return (
{children}
{captionChildren}
); } function MediaFirstContainer(props) { const { mediaAttachments, language, postID, instance } = props; const moreThanOne = mediaAttachments.length > 1; const carouselRef = useRef(); const [currentIndex, setCurrentIndex] = useState(0); useEffect(() => { let handleScroll = () => { const { clientWidth, scrollLeft } = carouselRef.current; const index = Math.round(scrollLeft / clientWidth); setCurrentIndex(index); }; if (carouselRef.current) { carouselRef.current.addEventListener('scroll', handleScroll, { passive: true, }); } return () => { if (carouselRef.current) { carouselRef.current.removeEventListener('scroll', handleScroll); } }; }, []); return ( <>
{mediaAttachments.map((media, i) => (
))} {moreThanOne && ( )}
{moreThanOne && ( )} ); } function Card({ card, selfReferential, instance }) { const snapStates = useSnapshot(states); const { blurhash, title, description, html, providerName, providerUrl, authorName, authorUrl, width, height, image, imageDescription, url, type, embedUrl, language, publishedAt, } = card; /* type link = Link OEmbed photo = Photo OEmbed video = Video OEmbed rich = iframe OEmbed. Not currently accepted, so won’t show up in practice. */ const hasText = title || providerName || authorName; const isLandscape = width / height >= 1.2; const size = isLandscape ? 'large' : ''; const [cardStatusURL, setCardStatusURL] = useState(null); // const [cardStatusID, setCardStatusID] = useState(null); useEffect(() => { if (hasText && image && !selfReferential && isMastodonLinkMaybe(url)) { unfurlMastodonLink(instance, url).then((result) => { if (!result) return; const { id, url } = result; setCardStatusURL('#' + url); // NOTE: This is for quote post // (async () => { // const { masto } = api({ instance }); // const status = await masto.v1.statuses.$select(id).fetch(); // saveStatus(status, instance); // setCardStatusID(id); // })(); }); } }, [hasText, image, selfReferential]); // if (cardStatusID) { // return ( // // ); // } if (snapStates.unfurledLinks[url]) return null; const hasIframeHTML = /