import clsx from 'clsx'; import { List as ImmutableList } from 'immutable'; import React, { useEffect, useRef, useState } from 'react'; import { defineMessages, useIntl, FormattedList, FormattedMessage } from 'react-intl'; import { Link, useHistory } from 'react-router-dom'; import { mentionCompose, replyCompose } from 'soapbox/actions/compose'; import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions'; import { openModal } from 'soapbox/actions/modals'; import { toggleStatusHidden, unfilterStatus } from 'soapbox/actions/statuses'; import TranslateButton from 'soapbox/components/translate-button'; import AccountContainer from 'soapbox/containers/account-container'; import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container'; import { HotKeys } from 'soapbox/features/ui/components/hotkeys'; import { useAppDispatch, useSettings } from 'soapbox/hooks'; import { defaultMediaVisibility, textForScreenReader, getActualStatus } from 'soapbox/utils/status'; import EventPreview from './event-preview'; import StatusActionBar from './status-action-bar'; import StatusContent from './status-content'; import StatusMedia from './status-media'; import StatusReplyMentions from './status-reply-mentions'; import SensitiveContentOverlay from './statuses/sensitive-content-overlay'; import StatusInfo from './statuses/status-info'; import Tombstone from './tombstone'; import { Card, Icon, Stack, Text } from './ui'; import type { Status as StatusEntity } from 'soapbox/types/entities'; // Defined in components/scrollable-list export type ScrollPosition = { height: number; top: number }; const messages = defineMessages({ reblogged_by: { id: 'status.reblogged_by', defaultMessage: '{name} reposted' }, }); export interface IStatus { id?: string; avatarSize?: number; status: StatusEntity; onClick?: () => void; muted?: boolean; hidden?: boolean; unread?: boolean; onMoveUp?: (statusId: string, featured?: boolean) => void; onMoveDown?: (statusId: string, featured?: boolean) => void; focusable?: boolean; featured?: boolean; hideActionBar?: boolean; hoverable?: boolean; variant?: 'default' | 'rounded' | 'slim'; showGroup?: boolean; accountAction?: React.ReactElement; } const Status: React.FC = (props) => { const { status, accountAction, avatarSize = 42, focusable = true, hoverable = true, onClick, onMoveUp, onMoveDown, muted, hidden, featured, unread, hideActionBar, variant = 'rounded', showGroup = true, } = props; const intl = useIntl(); const history = useHistory(); const dispatch = useAppDispatch(); const settings = useSettings(); const displayMedia = settings.get('displayMedia') as string; const didShowCard = useRef(false); const node = useRef(null); const overlay = useRef(null); const [showMedia, setShowMedia] = useState(defaultMediaVisibility(status, displayMedia)); const [minHeight, setMinHeight] = useState(208); const actualStatus = getActualStatus(status); const isReblog = status.reblog && typeof status.reblog === 'object'; const statusUrl = `/@${actualStatus.account.acct}/posts/${actualStatus.id}`; const group = actualStatus.group; const filtered = (status.filtered.size || actualStatus.filtered.size) > 0; // Track height changes we know about to compensate scrolling. useEffect(() => { didShowCard.current = Boolean(!muted && !hidden && status?.card); }, []); useEffect(() => { setShowMedia(defaultMediaVisibility(status, displayMedia)); }, [status.id]); useEffect(() => { if (overlay.current) { setMinHeight(overlay.current.getBoundingClientRect().height); } }, [overlay.current]); const handleToggleMediaVisibility = (): void => { setShowMedia(!showMedia); }; const handleClick = (e?: React.MouseEvent): void => { e?.stopPropagation(); // If the user is selecting text, don't focus the status. if (getSelection()?.toString().length) { return; } if (!e || !(e.ctrlKey || e.metaKey)) { if (onClick) { onClick(); } else { history.push(statusUrl); } } else { window.open(statusUrl, '_blank'); } }; const handleHotkeyOpenMedia = (e?: KeyboardEvent): void => { const status = actualStatus; const firstAttachment = status.media_attachments.first(); e?.preventDefault(); if (firstAttachment) { if (firstAttachment.type === 'video') { dispatch(openModal('VIDEO', { status, media: firstAttachment, time: 0 })); } else { dispatch(openModal('MEDIA', { status, media: status.media_attachments, index: 0 })); } } }; const handleHotkeyReply = (e?: KeyboardEvent): void => { e?.preventDefault(); dispatch(replyCompose(actualStatus)); }; const handleHotkeyFavourite = (): void => { toggleFavourite(actualStatus); }; const handleHotkeyBoost = (e?: KeyboardEvent): void => { const modalReblog = () => dispatch(toggleReblog(actualStatus)); const boostModal = settings.get('boostModal'); if ((e && e.shiftKey) || !boostModal) { modalReblog(); } else { dispatch(openModal('BOOST', { status: actualStatus, onReblog: modalReblog })); } }; const handleHotkeyMention = (e?: KeyboardEvent): void => { e?.preventDefault(); dispatch(mentionCompose(actualStatus.account)); }; const handleHotkeyOpen = (): void => { history.push(statusUrl); }; const handleHotkeyOpenProfile = (): void => { history.push(`/@${actualStatus.account.acct}`); }; const handleHotkeyMoveUp = (e?: KeyboardEvent): void => { if (onMoveUp) { onMoveUp(status.id, featured); } }; const handleHotkeyMoveDown = (e?: KeyboardEvent): void => { if (onMoveDown) { onMoveDown(status.id, featured); } }; const handleHotkeyToggleHidden = (): void => { dispatch(toggleStatusHidden(actualStatus)); }; const handleHotkeyToggleSensitive = (): void => { handleToggleMediaVisibility(); }; const handleHotkeyReact = (): void => { _expandEmojiSelector(); }; const handleUnfilter = () => dispatch(unfilterStatus(status.filtered.size ? status.id : actualStatus.id)); const _expandEmojiSelector = (): void => { const firstEmoji: HTMLDivElement | null | undefined = node.current?.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); firstEmoji?.focus(); }; const renderStatusInfo = () => { if (isReblog && showGroup && group) { return ( } text={ ), group: ( ), }} /> } /> ); } else if (isReblog) { const accounts = status.accounts || ImmutableList([status.account]); const renderedAccounts = accounts.slice(0, 2).map(account => !!account && ( )).toArray().filter(Boolean); if (accounts.size > 2) { renderedAccounts.push( , ); } return ( } text={ , count: accounts.size, }} /> } /> ); } else if (featured) { return ( } text={ } /> ); } else if (showGroup && group) { return ( } text={ ), }} /> } /> ); } }; if (!status) return null; if (hidden) { return (
<> {actualStatus.account.display_name || actualStatus.account.username} {actualStatus.content}
); } if (filtered && status.showFiltered) { const minHandlers = muted ? undefined : { moveUp: handleHotkeyMoveUp, moveDown: handleHotkeyMoveDown, }; return (
: {status.filtered.join(', ')}. {' '}
); } let rebloggedByText; if (status.reblog && typeof status.reblog === 'object') { rebloggedByText = intl.formatMessage( messages.reblogged_by, { name: status.account.acct }, ); } let quote; if (actualStatus.quote) { if (actualStatus.pleroma.get('quote_visible', true) === false) { quote = (

); } else { quote = ; } } const handlers = muted ? undefined : { reply: handleHotkeyReply, favourite: handleHotkeyFavourite, boost: handleHotkeyBoost, mention: handleHotkeyMention, open: handleHotkeyOpen, openProfile: handleHotkeyOpenProfile, moveUp: handleHotkeyMoveUp, moveDown: handleHotkeyMoveDown, toggleHidden: handleHotkeyToggleHidden, toggleSensitive: handleHotkeyToggleSensitive, openMedia: handleHotkeyOpenMedia, react: handleHotkeyReact, }; const isUnderReview = actualStatus.visibility === 'self'; const isSensitive = actualStatus.hidden; const isSoftDeleted = status.tombstone?.reason === 'deleted'; if (isSoftDeleted) { return ( onMoveUp ? onMoveUp(id) : null} onMoveDown={(id) => onMoveDown ? onMoveDown(id) : null} /> ); } return (
{renderStatusInfo()}
{(isUnderReview || isSensitive) && ( )} {actualStatus.event ? : ( {(quote || actualStatus.card || actualStatus.media_attachments.size > 0) && ( {quote} )} )} {(!hideActionBar && !isUnderReview) && (
)}
); }; export default Status;