import './status.css'; import { ControlledMenu, Menu, MenuDivider, MenuHeader, MenuItem, } from '@szhsin/react-menu'; import mem from 'mem'; import pThrottle from 'p-throttle'; import { memo } from 'preact/compat'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import 'swiped-events'; import { useLongPress } from 'use-long-press'; 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 { api } from '../utils/api'; import enhanceContent from '../utils/enhance-content'; import getTranslateTargetLanguage from '../utils/get-translate-target-language'; import handleContentLinks from '../utils/handle-content-links'; import htmlContentLength from '../utils/html-content-length'; import niceDateTime from '../utils/nice-date-time'; import shortenNumber from '../utils/shorten-number'; import showToast from '../utils/show-toast'; import states, { saveStatus, statusKey } from '../utils/states'; import store from '../utils/store'; import visibilityIconsMap from '../utils/visibility-icons-map'; import Avatar from './avatar'; import Icon from './icon'; import Link from './link'; import Media from './media'; import MenuLink from './MenuLink'; import RelativeTime from './relative-time'; import TranslationBlock from './translation-block'; const throttle = pThrottle({ limit: 1, interval: 1000, }); function fetchAccount(id, masto) { try { return masto.v1.accounts.fetch(id); } catch (e) { return Promise.reject(e); } } const memFetchAccount = mem(fetchAccount); const visibilityText = { public: 'Public', unlisted: 'Unlisted', private: 'Followers only', direct: 'Direct', }; function Status({ statusID, status, instance: propInstance, withinContext, size = 'm', skeleton, readOnly, contentTextWeight, enableTranslate, previewMode, }) { if (skeleton) { return (
███ ████████

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

); } const { masto, instance, authenticated } = api({ instance: propInstance }); const { instance: currentInstance } = api(); const sameInstance = instance === currentInstance; const sKey = statusKey(statusID, instance); const snapStates = useSnapshot(states); if (!status) { status = snapStates.statuses[sKey] || snapStates.statuses[statusID]; } if (!status) { return null; } const { account: { acct, avatar, avatarStatic, id: accountId, url: accountURL, 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, url, emojis, // Non-API props _deleted, _pinned, } = status; console.debug('RENDER Status', id, status?.account.displayName); 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: 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 showSpoiler = !!snapStates.spoilers[id] || false; const debugHover = (e) => { if (e.shiftKey) { console.log(status); } }; if (reblog) { return (
{' '} {' '} boosted
); } const [forceTranslate, setForceTranslate] = useState(false); const targetLanguage = getTranslateTargetLanguage(true); if (!snapStates.settings.contentTranslation) enableTranslate = false; const [showEdited, setShowEdited] = useState(false); 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); const unauthInteractionErrorMessage = `Sorry, your current logged-in instance can't interact with this status from another instance.`; const textWeight = () => Math.max( Math.round((spoilerText.length + htmlContentLength(content)) / 140) || 1, 1, ); const createdDateText = niceDateTime(createdAtDate); const editedDateText = editedAt && niceDateTime(editedAtDate); const isSizeLarge = size === 'l'; // 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 = () => { if (!sameInstance || !authenticated) { return alert(unauthInteractionErrorMessage); } states.showCompose = { replyToStatus: status, }; }; const boostStatus = async () => { if (!sameInstance || !authenticated) { return alert(unauthInteractionErrorMessage); } try { if (!reblogged) { // Check if media has no descriptions const hasNoDescriptions = mediaAttachments.some( (attachment) => !attachment.description?.trim?.(), ); let confirmText = 'Boost this post?'; if (hasNoDescriptions) { confirmText += '\n\n⚠️ Some media have no descriptions.'; } const yes = confirm(confirmText); if (!yes) { return; } } // Optimistic states.statuses[sKey] = { ...status, reblogged: !reblogged, reblogsCount: reblogsCount + (reblogged ? -1 : 1), }; if (reblogged) { const newStatus = await masto.v1.statuses.unreblog(id); saveStatus(newStatus, instance); } else { const newStatus = await masto.v1.statuses.reblog(id); saveStatus(newStatus, instance); } } catch (e) { console.error(e); // Revert optimistism states.statuses[sKey] = status; } }; const favouriteStatus = async () => { if (!sameInstance || !authenticated) { return alert(unauthInteractionErrorMessage); } try { // Optimistic states.statuses[sKey] = { ...status, favourited: !favourited, favouritesCount: favouritesCount + (favourited ? -1 : 1), }; if (favourited) { const newStatus = await masto.v1.statuses.unfavourite(id); saveStatus(newStatus, instance); } else { const newStatus = await masto.v1.statuses.favourite(id); saveStatus(newStatus, instance); } } catch (e) { console.error(e); // Revert optimistism states.statuses[sKey] = status; } }; const bookmarkStatus = async () => { if (!sameInstance || !authenticated) { return alert(unauthInteractionErrorMessage); } try { // Optimistic states.statuses[sKey] = { ...status, bookmarked: !bookmarked, }; if (bookmarked) { const newStatus = await masto.v1.statuses.unbookmark(id); saveStatus(newStatus, instance); } else { const newStatus = await masto.v1.statuses.bookmark(id); saveStatus(newStatus, instance); } } catch (e) { console.error(e); // Revert optimistism states.statuses[sKey] = status; } }; const menuInstanceRef = useRef(); const StatusMenuItems = ( <> {!isSizeLarge && ( <> {' '} {visibilityText[visibility]} {' '} {repliesCount > 0 && ( {' '} {shortenNumber(repliesCount)} )}{' '} {reblogsCount > 0 && ( {' '} {shortenNumber(reblogsCount)} )}{' '} {favouritesCount > 0 && ( {' '} {shortenNumber(favouritesCount)} )}
{createdDateText}
View post by @{username || acct} )} {!!editedAt && ( { setShowEdited(id); }} > Show Edit History
Edited: {editedDateText}
)} {(!isSizeLarge || !!editedAt) && } {!isSizeLarge && sameInstance && ( <> Reply {canBoost && ( { try { await boostStatus(); if (!isSizeLarge) showToast(reblogged ? 'Unboosted' : 'Boosted'); } catch (e) {} }} > {reblogged ? 'Unboost' : 'Boost…'} )} { try { favouriteStatus(); if (!isSizeLarge) showToast(favourited ? 'Unfavourited' : 'Favourited'); } catch (e) {} }} > {favourited ? 'Unfavourite' : 'Favourite'} { try { bookmarkStatus(); if (!isSizeLarge) showToast(bookmarked ? 'Unbookmarked' : 'Bookmarked'); } catch (e) {} }} > {bookmarked ? 'Unbookmark' : 'Bookmark'} )} {enableTranslate && ( { setForceTranslate(true); }} > Translate )} {((!isSizeLarge && sameInstance) || enableTranslate) && } {nicePostURL(url)} {isSelf && ( <> { states.showCompose = { editStatus: status, }; }} > Edit )} ); const contextMenuRef = useRef(); const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); const [contextMenuAnchorPoint, setContextMenuAnchorPoint] = useState({ x: 0, y: 0, }); const bindLongPress = useLongPress( (e) => { const { clientX, clientY } = e.touches?.[0] || e; setContextMenuAnchorPoint({ x: clientX, y: clientY, }); setIsContextMenuOpen(true); }, { captureEvent: true, detect: 'touch', cancelOnMovement: true, }, ); return (
{ if (size === 'l') return; if (e.metaKey) return; if (previewMode) return; // console.log('context menu', e); const link = e.target.closest('a'); if (link && /^https?:\/\//.test(link.getAttribute('href'))) return; e.preventDefault(); setContextMenuAnchorPoint({ x: e.clientX, y: e.clientY, }); setIsContextMenuOpen(true); }} {...bindLongPress()} > {size !== 'l' && ( setIsContextMenuOpen(false)} portal={{ target: document.body, }} containerProps={{ style: { // Higher than the backdrop zIndex: 1001, }, onClick: () => { contextMenuRef.current?.closeMenu?.(); }, }} overflow="auto" boundingBoxPadding={safeBoundingBoxPadding()} unmountOnClose > {StatusMenuItems} )} {size !== 'l' && (
{reblogged && } {favourited && } {bookmarked && } {_pinned && }
)} {size !== 's' && ( { e.preventDefault(); e.stopPropagation(); states.showAccount = { account: status.account, instance, }; }} > )}
{/* */} {/* {inReplyToAccount && !withinContext && size !== 's' && ( <> {' '} {' '} )} */} {/* */}{' '} {size !== 'l' && (url && !previewMode ? ( { menuInstanceRef.current?.closeMenu?.(); }, }} align="end" offsetY={4} overflow="auto" viewScroll="close" boundingBoxPadding="8 8 8 8" unmountOnClose menuButton={({ open }) => ( { e.preventDefault(); e.stopPropagation(); }} class={`time ${open ? 'is-open' : ''}`} > {' '} )} > {StatusMenuItems} ) : ( {' '} ))}
{!withinContext && ( <> {inReplyToAccountId === status.account?.id || !!snapStates.statusThreadNumber[sKey] ? (
Thread {snapStates.statusThreadNumber[sKey] ? ` ${snapStates.statusThreadNumber[sKey]}/X` : ''}
) : ( !!inReplyToId && !!inReplyToAccount && (!!spoilerText || !mentions.find((mention) => { return mention.id === inReplyToAccountId; })) && (
{' '}
) )} )}
{!!spoilerText && ( <>

{spoilerText}

)}
{ // Remove target="_blank" from links dom .querySelectorAll('a.u-url[target="_blank"]') .forEach((a) => { if (!/http/i.test(a.innerText.trim())) { a.removeAttribute('target'); } }); if (previewMode) return; // Unfurl Mastodon links dom .querySelectorAll( 'a[href]:not(.u-url):not(.mention):not(.hashtag)', ) .forEach((a) => { if (isMastodonLinkMaybe(a.href)) { unfurlMastodonLink(currentInstance, a.href).then(() => { a.removeAttribute('target'); }); } }); }, }), }} /> {!!poll && ( { states.statuses[sKey].poll = newPoll; }} refresh={() => { return masto.v1.polls .fetch(poll.id) .then((pollResponse) => { states.statuses[sKey].poll = pollResponse; }) .catch((e) => {}); // Silently fail }} votePoll={(choices) => { return masto.v1.polls .vote(poll.id, { choices, }) .then((pollResponse) => { states.statuses[sKey].poll = pollResponse; }) .catch((e) => {}); // Silently fail }} /> )} {((enableTranslate && !!content.trim() && language && language !== targetLanguage) || forceTranslate) && ( `- ${option.title}`) .join('\n')}` : '') } /> )} {!spoilerText && sensitive && !!mediaAttachments.length && ( )} {!!mediaAttachments.length && (
2 ? 'media-gt2' : '' } ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`} > {mediaAttachments .slice(0, isSizeLarge ? undefined : 4) .map((media, i) => ( { e.preventDefault(); e.stopPropagation(); states.showMediaModal = { mediaAttachments, index: i, instance, statusID: readOnly ? null : id, }; }} /> ))}
)} {!!card && !sensitive && !spoilerText && !poll && !mediaAttachments.length && ( )}
{isSizeLarge && ( <>
{' '} {editedAt && ( <> {' '} • {' '} )}
{canBoost && (
)}
} > {StatusMenuItems}
)}
{!!showEdited && ( { if (e.target === e.currentTarget) { setShowEdited(false); statusRef.current?.focus(); } }} > { return masto.v1.statuses.listHistory(showEdited); }} onClose={() => { setShowEdited(false); statusRef.current?.focus(); }} /> )}
); } function Card({ card, instance }) { 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' : ''; const [cardStatusURL, setCardStatusURL] = useState(null); // const [cardStatusID, setCardStatusID] = useState(null); useEffect(() => { if (hasText && image && 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.fetch(id); // saveStatus(status, instance); // setCardStatusID(id); // })(); }); } }, [hasText, image]); // if (cardStatusID) { // return ( // // ); // } if (hasText && image) { const domain = new URL(url).hostname.replace(/^www\./, ''); return (
{ try { e.target.style.display = 'none'; } catch (e) {} }} />

{domain}

{title}

{description || providerName || authorName}

); } else if (type === 'photo') { return ( {title ); } else if (type === 'video') { return (
); } } function Poll({ poll, lang, readOnly, refresh = () => {}, votePoll = () => {}, }) { 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 () => { await refresh(); 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 choices = []; formData.forEach((value, key) => { if (key === 'poll') { choices.push(value); } }); if (!choices.length) return; setUIState('loading'); await votePoll(choices); setUIState('default'); }} > {options.map((option, i) => { const { title } = option; return (
); })} {!readOnly && ( )}
)} {!readOnly && (

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

)}
); } function EditedAtModal({ statusID, instance, fetchStatusHistory = () => {}, onClose = () => {}, }) { const [uiState, setUIState] = useState('default'); const [editHistory, setEditHistory] = useState([]); useEffect(() => { setUIState('loading'); (async () => { try { const editHistory = await fetchStatusHistory(); console.log(editHistory); setEditHistory(editHistory); setUIState('default'); } catch (e) { console.error(e); setUIState('error'); } })(); }, []); 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 ( ); } export 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')}`; } } function isMastodonLinkMaybe(url) { return /^https:\/\/.*\/\d+$/i.test(url); } const denylistDomains = /(twitter|github)\.com/i; const failedUnfurls = {}; function _unfurlMastodonLink(instance, url) { if (denylistDomains.test(url)) { return; } if (failedUnfurls[url]) { return; } const instanceRegex = new RegExp(instance + '/'); if (instanceRegex.test(states.unfurledLinks[url]?.url)) { return Promise.resolve(states.unfurledLinks[url]); } console.debug('🦦 Unfurling URL', url); let remoteInstanceFetch; const urlObj = new URL(url); const domain = urlObj.hostname; const path = urlObj.pathname; // Regex /:username/:id, where username = @username or @username@domain, id = number const statusRegex = /\/@([^@\/]+)@?([^\/]+)?\/(\d+)$/i; const statusMatch = statusRegex.exec(path); if (statusMatch) { const id = statusMatch[3]; const { masto } = api({ instance: domain }); remoteInstanceFetch = masto.v1.statuses .fetch(id) .then((status) => { if (status?.id) { const statusURL = `/${domain}/s/${id}`; const result = { id, url: statusURL, }; console.debug('🦦 Unfurled URL', url, id, statusURL); states.unfurledLinks[url] = result; return result; } else { failedUnfurls[url] = true; throw new Error('No results'); } }) .catch((e) => { failedUnfurls[url] = true; }); } const { masto } = api({ instance }); const mastoSearchFetch = masto.v2 .search({ q: url, type: 'statuses', resolve: true, limit: 1, }) .then((results) => { if (results.statuses.length > 0) { const status = results.statuses[0]; const { id } = status; const statusURL = `/${instance}/s/${id}`; const result = { id, url: statusURL, }; console.debug('🦦 Unfurled URL', url, id, statusURL); states.unfurledLinks[url] = result; return result; } else { failedUnfurls[url] = true; throw new Error('No results'); } }) .catch((e) => { failedUnfurls[url] = true; // console.warn(e); // Silently fail }); return Promise.any([remoteInstanceFetch, mastoSearchFetch]); } function nicePostURL(url) { if (!url) return; const urlObj = new URL(url); const { host, pathname } = urlObj; const path = pathname.replace(/\/$/, ''); // split only first slash const [_, username, restPath] = path.match(/\/(@[^\/]+)\/(.*)/) || []; return ( <> {host} {username ? ( <> /{username} /{restPath} ) : ( {path} )} ); } const unfurlMastodonLink = throttle(_unfurlMastodonLink); const div = document.createElement('div'); function getHTMLText(html) { if (!html) return 0; div.innerHTML = html .replace(/<\/p>/g, '

\n\n') .replace(/<\/li>/g, '\n'); div.querySelectorAll('br').forEach((br) => { br.replaceWith('\n'); }); return div.innerText.replace(/[\r\n]{3,}/g, '\n\n').trim(); } const root = document.documentElement; const defaultBoundingBoxPadding = 8; function safeBoundingBoxPadding() { // Get safe area inset variables from root const style = getComputedStyle(root); const safeAreaInsetTop = style.getPropertyValue('--sai-top'); const safeAreaInsetRight = style.getPropertyValue('--sai-right'); const safeAreaInsetBottom = style.getPropertyValue('--sai-bottom'); const safeAreaInsetLeft = style.getPropertyValue('--sai-left'); const str = [ safeAreaInsetTop, safeAreaInsetRight, safeAreaInsetBottom, safeAreaInsetLeft, ] .map((v) => parseInt(v, 10) || defaultBoundingBoxPadding) .join(' '); // console.log(str); return str; } export default memo(Status);