import './status.css'; import { getBlurHashAverageColor } from 'fast-blurhash'; import mem from 'mem'; import { useEffect, useRef, useState } from 'preact/hooks'; import { useSnapshot } from 'valtio'; import Modal from '../components/modal'; import NameText from '../components/name-text'; import enhanceContent from '../utils/enhance-content'; import shortenNumber from '../utils/shorten-number'; import states from '../utils/states'; import visibilityIconsMap from '../utils/visibility-icons-map'; import Avatar from './avatar'; import Icon from './icon'; /* 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, 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') { return (
{description}
); } else if (type === 'gifv' || type === 'video') { // 20 seconds, treat as a gif const isGIF = type === 'gifv' && original.duration <= 20; return (
{ if (isGIF) { try { videoRef.current?.play(); } catch (e) {} } }} onMouseLeave={() => { if (isGIF) { try { videoRef.current?.pause(); } catch (e) {} } }} > {showOriginal ? ( ) : 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; if (hasText && image) { const domain = new URL(url).hostname; return ( { this.style.display = 'none'; }} />

{providerName || authorName}

{domain}

); } else if (type === 'photo') { return ( {title ); } else if (type === 'video') { return (
); } } function Poll({ poll }) { const [pollSnapshot, setPollSnapshot] = useState(poll); const [uiState, setUIState] = useState('default'); const { expired, expiresAt, id, multiple, options, ownVotes, voted, votersCount, votesCount, } = pollSnapshot; const expiresAtDate = new Date(expiresAt); return (
{voted || expired ? ( options.map((option, i) => { const { title, votesCount: optionVotesCount } = option; const percentage = Math.round((optionVotesCount / votesCount) * 100); 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.poll.vote(id, { choices: votes, }); console.log(pollResponse); setPollSnapshot(pollResponse); setUIState('default'); }} style={{ pointerEvents: uiState === 'loading' ? 'none' : 'auto', opacity: uiState === 'loading' ? 0.5 : 1, }} > {options.map((option, i) => { const { title } = option; return (
); })}
)}

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

); } function fetchAccount(id) { return masto.accounts.fetch(id); } const memFetchAccount = mem(fetchAccount); function Status({ statusID, status, withinContext, size = 'm', skeleton }) { 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, inReplyToAccountId, content, mentions, mediaAttachments, reblog, uri, emojis, } = status; const createdAtDate = new Date(createdAt); 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, setShowSpoiler] = useState(false); const debugHover = (e) => { if (e.shiftKey) { console.log(status); } }; const [showMediaModal, setShowMediaModal] = useState(false); const carouselFocusItem = useRef(null); const prevShowMediaModal = useRef(showMediaModal); useEffect(() => { if (showMediaModal !== false) { carouselFocusItem.current?.scrollIntoView({ behavior: prevShowMediaModal.current === false ? 'auto' : 'smooth', }); } prevShowMediaModal.current = showMediaModal; }, [showMediaModal]); if (reblog) { return (
{' '} boosted
); } const [actionsUIState, setActionsUIState] = useState(null); // boost-loading, favourite-loading, bookmark-loading return (
{size !== 's' && ( { e.preventDefault(); e.stopPropagation(); states.showAccount = status.account; }} > )}
{inReplyToAccount && !withinContext && size !== 's' && ( <> {' '} {' '} )} {' '} {' '} {createdAtDate.toLocaleString()}
{!!spoilerText && sensitive && ( <>

{spoilerText}

)}
{ let { target } = e; if (target.parentNode.tagName.toLowerCase() === 'a') { target = target.parentNode; } console.log('click', target, e); if ( target.tagName.toLowerCase() === 'a' && target.classList.contains('u-url') ) { e.preventDefault(); e.stopPropagation(); const username = target.querySelector('span'); const mention = mentions.find( (mention) => mention.username === username?.innerText.trim(), ); if (mention) { states.showAccount = mention.acct; } else { const href = target.getAttribute('href'); states.showAccount = href; } } }} dangerouslySetInnerHTML={{ __html: enhanceContent(content, { emojis, }), }} /> {!!poll && } {!spoilerText && sensitive && !!mediaAttachments.length && ( )} {!!mediaAttachments.length && size !== 's' && (
{mediaAttachments.map((media, i) => ( { e.preventDefault(); e.stopPropagation(); setShowMediaModal(i); }} /> ))}
)} {!!card && (size === 'l' || (size === 'm' && !poll && !mediaAttachments.length)) && ( )}
{size === 'l' && (
{/* TODO: if visibility = private, only can reblog own statuses */} {visibility !== 'direct' && ( )}
)}
{showMediaModal !== false && ( {mediaAttachments?.length > 1 && ( )} )}
); } export default Status;