import './status.css'; import { getBlurHashAverageColor } from 'fast-blurhash'; import mem from 'mem'; import { useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'preact/hooks'; import { useHotkeys } from 'react-hotkeys-hook'; import { InView } from 'react-intersection-observer'; import 'swiped-events'; import useResizeObserver from 'use-resize-observer'; import { useSnapshot } from 'valtio'; import Loader from '../components/loader'; import Modal from '../components/modal'; import NameText from '../components/name-text'; import enhanceContent from '../utils/enhance-content'; import htmlContentLength from '../utils/html-content-length'; import shortenNumber from '../utils/shorten-number'; import states from '../utils/states'; import store from '../utils/store'; import useDebouncedCallback from '../utils/useDebouncedCallback'; import visibilityIconsMap from '../utils/visibility-icons-map'; import Avatar from './avatar'; import Icon from './icon'; import RelativeTime from './relative-time'; function fetchAccount(id) { return masto.v1.accounts.fetch(id); } const memFetchAccount = mem(fetchAccount); function Status({ statusID, status, withinContext, size = 'm', skeleton, readOnly, }) { if (skeleton) { return (
███ ████████████

████ ████████████

); } const snapStates = useSnapshot(states); if (!status) { status = snapStates.statuses.get(statusID); } if (!status) { return null; } const { account: { acct, avatar, avatarStatic, id: accountId, url, displayName, username, emojis: accountEmojis, }, 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, emojis, _deleted, } = status; const createdAtDate = new Date(createdAt); const editedAtDate = new Date(editedAt); const isSelf = useMemo(() => { const currentAccount = store.session.get('currentAccount'); return currentAccount && currentAccount === accountId; }, [accountId]); let inReplyToAccountRef = mentions?.find( (mention) => mention.id === inReplyToAccountId, ); if (!inReplyToAccountRef && inReplyToAccountId === id) { inReplyToAccountRef = { url, username, displayName }; } const [inReplyToAccount, setInReplyToAccount] = useState(inReplyToAccountRef); if (!withinContext && !inReplyToAccount && inReplyToAccountId) { const account = states.accounts.get(inReplyToAccountId); if (account) { setInReplyToAccount(account); } else { memFetchAccount(inReplyToAccountId) .then((account) => { setInReplyToAccount(account); states.accounts.set(account.id, account); }) .catch((e) => {}); } } const showSpoiler = snapStates.spoilers.has(id) || false; const debugHover = (e) => { if (e.shiftKey) { console.log(status); } }; const [showMediaModal, setShowMediaModal] = useState(false); if (reblog) { return (
{' '} boosted
); } const [showEdited, setShowEdited] = useState(false); const currentYear = new Date().getFullYear(); const spoilerContentRef = useRef(null); useResizeObserver({ ref: spoilerContentRef, onResize: () => { if (spoilerContentRef.current) { const { scrollHeight, clientHeight } = spoilerContentRef.current; spoilerContentRef.current.classList.toggle( 'truncated', scrollHeight > clientHeight, ); } }, }); const contentRef = useRef(null); useResizeObserver({ ref: contentRef, onResize: () => { if (contentRef.current) { const { scrollHeight, clientHeight } = contentRef.current; contentRef.current.classList.toggle( 'truncated', scrollHeight > clientHeight, ); } }, }); const readMoreText = 'Read more →'; const statusRef = useRef(null); return (
{size !== 'l' && (
{reblogged && } {favourited && } {bookmarked && }
)} {size !== 's' && ( { e.preventDefault(); e.stopPropagation(); states.showAccount = status.account; }} > )}
{/* */} {/* {inReplyToAccount && !withinContext && size !== 's' && ( <> {' '} {' '} )} */} {/* */}{' '} {size !== 'l' && (uri ? ( {' '} ) : ( {' '} ))}
{!!inReplyToId && !!inReplyToAccountId && !withinContext && size !== 's' && ( <> {inReplyToAccountId === status.account.id ? (
Thread
) : ( !!inReplyToAccount && (!!spoilerText || !mentions.find((mention) => { return mention.id === inReplyToAccountId; })) && (
{' '}
) )} )}
{!!spoilerText && sensitive && ( <>

{spoilerText}

)}
{ let { target } = e; if (target.parentNode.tagName.toLowerCase() === 'a') { target = target.parentNode; } if ( target.tagName.toLowerCase() === 'a' && target.classList.contains('u-url') ) { e.preventDefault(); e.stopPropagation(); const username = ( target.querySelector('span') || target ).innerText .trim() .replace(/^@/, ''); const url = target.getAttribute('href'); const mention = mentions.find( (mention) => mention.username === username || mention.acct === username || mention.url === url, ); if (mention) { states.showAccount = mention.acct; } else { const href = target.getAttribute('href'); states.showAccount = href; } } }} dangerouslySetInnerHTML={{ __html: enhanceContent(content, { emojis, postEnhanceDOM: (dom) => { dom .querySelectorAll('a.u-url[target="_blank"]') .forEach((a) => { // Remove target="_blank" from links a.removeAttribute('target'); }); }, }), }} /> {!!poll && ( { states.statuses.get(id).poll = newPoll; }} /> )} {!spoilerText && sensitive && !!mediaAttachments.length && ( )} {!!mediaAttachments.length && (
2 ? 'media-gt2' : '' } ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`} > {mediaAttachments .slice(0, size === 'l' ? undefined : 4) .map((media, i) => ( { e.preventDefault(); e.stopPropagation(); setShowMediaModal(i); }} /> ))}
)} {!!card && !sensitive && !spoilerText && !poll && !mediaAttachments.length && }
{size === 'l' && ( <>
{' '} {editedAt && ( <> {' '} • {' '} )}
{ states.showCompose = { replyToStatus: status, }; }} />
{/* TODO: if visibility = private, only can reblog own statuses */} {visibility !== 'direct' && (
{ try { if (!reblogged) { const yes = confirm( 'Are you sure that you want to boost this post?', ); if (!yes) { return; } } // Optimistic states.statuses.set(id, { ...status, reblogged: !reblogged, reblogsCount: reblogsCount + (reblogged ? -1 : 1), }); if (reblogged) { const newStatus = await masto.v1.statuses.unreblog( id, ); states.statuses.set(newStatus.id, newStatus); } else { const newStatus = await masto.v1.statuses.reblog(id); states.statuses.set(newStatus.id, newStatus); states.statuses.set( newStatus.reblog.id, newStatus.reblog, ); } } catch (e) { console.error(e); // Revert optimistism states.statuses.set(id, status); } }} />
)}
{ try { // Optimistic states.statuses.set(statusID, { ...status, favourited: !favourited, favouritesCount: favouritesCount + (favourited ? -1 : 1), }); if (favourited) { const newStatus = await masto.v1.statuses.unfavourite( id, ); states.statuses.set(newStatus.id, newStatus); } else { const newStatus = await masto.v1.statuses.favourite(id); states.statuses.set(newStatus.id, newStatus); } } catch (e) { console.error(e); // Revert optimistism states.statuses.set(statusID, status); } }} />
{ try { // Optimistic states.statuses.set(statusID, { ...status, bookmarked: !bookmarked, }); if (bookmarked) { const newStatus = await masto.v1.statuses.unbookmark( id, ); states.statuses.set(newStatus.id, newStatus); } else { const newStatus = await masto.v1.statuses.bookmark(id); states.statuses.set(newStatus.id, newStatus); } } catch (e) { console.error(e); // Revert optimistism states.statuses.set(statusID, status); } }} />
{isSelf && ( {isSelf && (
  • )}
    )}
    )}
    {showMediaModal !== false && ( { setShowMediaModal(false); }} /> )} {!!showEdited && ( { if (e.target === e.currentTarget) { setShowEdited(false); statusRef.current?.focus(); } }} > { setShowEdited(false); statusRef.current?.focus(); }} /> )}
    ); } /* Media type === unknown = unsupported or unrecognized file type image = Static image gifv = Looping, soundless animation video = Video clip audio = Audio track */ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) { const { blurhash, description, meta, previewUrl, remoteUrl, url, type } = media; const { original, small, focus } = meta || {}; const width = showOriginal ? original?.width : small?.width; const height = showOriginal ? original?.height : small?.height; const mediaURL = showOriginal ? url : previewUrl; const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null; const videoRef = useRef(); let focalBackgroundPosition; if (focus) { // Convert focal point to CSS background position // Formula from jquery-focuspoint // x = -1, y = 1 => 0% 0% // x = 0, y = 0 => 50% 50% // x = 1, y = -1 => 100% 100% const x = ((focus.x + 1) / 2) * 100; const y = ((1 - focus.y) / 2) * 100; focalBackgroundPosition = `${x.toFixed(0)}% ${y.toFixed(0)}%`; } if (type === 'image' || (type === 'unknown' && previewUrl && url)) { // Note: type: unknown might not have width/height return (
    {description}
    ); } else if (type === 'gifv' || type === 'video') { const shortDuration = original.duration < 31; const isGIF = type === 'gifv' && shortDuration; // If GIF is too long, treat it as a video const loopable = original.duration < 61; const formattedDuration = formatDuration(original.duration); const hoverAnimate = !showOriginal && !autoAnimate && isGIF; const autoGIFAnimate = !showOriginal && autoAnimate && isGIF; return (
    { if (hoverAnimate) { try { videoRef.current.pause(); } catch (e) {} } onClick(e); }} onMouseEnter={() => { if (hoverAnimate) { try { videoRef.current.play(); } catch (e) {} } }} onMouseLeave={() => { if (hoverAnimate) { try { videoRef.current.pause(); } catch (e) {} } }} > {showOriginal || autoGIFAnimate ? (
    `, }} /> ) : isGIF ? (
    ); } else if (type === 'audio') { return (
    ); } } function Card({ card }) { const { blurhash, title, description, html, providerName, authorName, width, height, image, url, type, embedUrl, } = 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' : ''; if (hasText && image) { const domain = new URL(url).hostname.replace(/^www\./, ''); return (
    { try { e.target.style.display = 'none'; } catch (e) {} }} />

    {domain}

    {description || providerName || authorName}

    ); } else if (type === 'photo') { return ( {title ); } else if (type === 'video') { return (
    ); } } function Poll({ poll, lang, readOnly, onUpdate = () => {} }) { const [uiState, setUIState] = useState('default'); const { expired, expiresAt, id, multiple, options, ownVotes, voted, votersCount, votesCount, } = poll; const expiresAtDate = !!expiresAt && new Date(expiresAt); // Update poll at point of expiry useEffect(() => { let timeout; if (!expired && expiresAtDate) { const ms = expiresAtDate.getTime() - Date.now() + 1; // +1 to give it a little buffer if (ms > 0) { timeout = setTimeout(() => { setUIState('loading'); (async () => { try { const pollResponse = await masto.v1.polls.fetch(id); onUpdate(pollResponse); } catch (e) { // Silent fail } setUIState('default'); })(); }, ms); } } return () => { clearTimeout(timeout); }; }, [expired, expiresAtDate]); const pollVotesCount = votersCount || votesCount; let roundPrecision = 0; if (pollVotesCount <= 1000) { roundPrecision = 0; } else if (pollVotesCount <= 10000) { roundPrecision = 1; } else if (pollVotesCount <= 100000) { roundPrecision = 2; } return (
    {voted || expired ? ( options.map((option, i) => { const { title, votesCount: optionVotesCount } = option; const percentage = pollVotesCount ? ((optionVotesCount / pollVotesCount) * 100).toFixed( roundPrecision, ) : 0; // check if current poll choice is the leading one const isLeading = optionVotesCount > 0 && optionVotesCount === Math.max(...options.map((o) => o.votesCount)); return (
    {title} {voted && ownVotes.includes(i) && ( <> {' '} )}
    {percentage}%
    ); }) ) : (
    { e.preventDefault(); const form = e.target; const formData = new FormData(form); const votes = []; formData.forEach((value, key) => { if (key === 'poll') { votes.push(value); } }); console.log(votes); setUIState('loading'); const pollResponse = await masto.v1.polls.vote(id, { choices: votes, }); console.log(pollResponse); onUpdate(pollResponse); setUIState('default'); }} > {options.map((option, i) => { const { title } = option; return (
    ); })} {!readOnly && ( )}
    )} {!readOnly && (

    {!expired && ( <> {' '} •{' '} )} {shortenNumber(votersCount)}{' '} {votersCount === 1 ? 'voter' : 'voters'} {votersCount !== votesCount && ( <> {' '} • {shortenNumber(votesCount)} {' '} vote {votesCount === 1 ? '' : 's'} )}{' '} • {expired ? 'Ended' : 'Ending'}{' '} {!!expiresAtDate && }

    )}
    ); } function EditedAtModal({ statusID, onClose = () => {} }) { const [uiState, setUIState] = useState('default'); const [editHistory, setEditHistory] = useState([]); useEffect(() => { setUIState('loading'); (async () => { try { const editHistory = await masto.v1.statuses.listHistory(statusID); console.log(editHistory); setEditHistory(editHistory); setUIState('default'); } catch (e) { console.error(e); setUIState('error'); } })(); }, []); const currentYear = new Date().getFullYear(); return (
    {/* */}

    Edit History

    {uiState === 'error' &&

    Failed to load history

    } {uiState === 'loading' && (

    Loading…

    )}
    {editHistory.length > 0 && (
      {editHistory.map((status) => { const { createdAt } = status; const createdAtDate = new Date(createdAt); return (
    1. ); })}
    )}
    ); } function StatusButton({ checked, count, class: className, title, alt, icon, onClick, ...props }) { if (typeof title === 'string') { title = [title, title]; } if (typeof alt === 'string') { alt = [alt, alt]; } const [buttonTitle, setButtonTitle] = useState(title[0] || ''); const [iconAlt, setIconAlt] = useState(alt[0] || ''); useEffect(() => { if (checked) { setButtonTitle(title[1] || ''); setIconAlt(alt[1] || ''); } else { setButtonTitle(title[0] || ''); setIconAlt(alt[0] || ''); } }, [checked, title, alt]); return ( ); } function Carousel({ mediaAttachments, index = 0, onClose = () => {} }) { const carouselRef = useRef(null); const [currentIndex, setCurrentIndex] = useState(index); const carouselFocusItem = useRef(null); useLayoutEffect(() => { carouselFocusItem.current?.node?.scrollIntoView(); }, []); useLayoutEffect(() => { carouselFocusItem.current?.node?.scrollIntoView({ behavior: 'smooth', }); }, [currentIndex]); const onSnap = useDebouncedCallback((inView, i) => { if (inView) { setCurrentIndex(i); } }, 100); const [showControls, setShowControls] = useState(true); useEffect(() => { let handleSwipe = () => { onClose(); }; if (carouselRef.current) { carouselRef.current.addEventListener('swiped-down', handleSwipe); } return () => { if (carouselRef.current) { carouselRef.current.removeEventListener('swiped-down', handleSwipe); } }; }, []); useHotkeys('esc', onClose, [onClose]); return ( <> {mediaAttachments?.length > 1 && ( )} ); } function formatDuration(time) { if (!time) return; let hours = Math.floor(time / 3600); let minutes = Math.floor((time % 3600) / 60); let seconds = Math.round(time % 60); if (hours === 0) { return `${minutes}:${seconds.toString().padStart(2, '0')}`; } else { return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds .toString() .padStart(2, '0')}`; } } export default Status;