import classNames from 'classnames'; import React from 'react'; import { HotKeys } from 'react-hotkeys'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { injectIntl, FormattedMessage, IntlShape } from 'react-intl'; import { NavLink, withRouter, RouteComponentProps } from 'react-router-dom'; import Icon from 'soapbox/components/icon'; import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder_card'; import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container'; import AccountContainer from '../containers/account_container'; import Card from '../features/status/components/card'; import Bundle from '../features/ui/components/bundle'; import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; import AttachmentThumbs from './attachment_thumbs'; import StatusActionBar from './status_action_bar'; import StatusContent from './status_content'; import StatusReplyMentions from './status_reply_mentions'; import { HStack, Text } from './ui'; import type { History } from 'history'; import type { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import type { Account as AccountEntity, Attachment as AttachmentEntity, Status as StatusEntity, } from 'soapbox/types/entities'; // Defined in components/scrollable_list export type ScrollPosition = { height: number, top: number }; export const textForScreenReader = (intl: IntlShape, status: StatusEntity, rebloggedByText?: string): string => { const { account } = status; if (!account || typeof account !== 'object') return ''; const displayName = account.display_name; const values = [ displayName.length === 0 ? account.acct.split('@')[0] : displayName, status.spoiler_text && status.hidden ? status.spoiler_text : status.search_index.slice(status.spoiler_text.length), intl.formatDate(status.created_at, { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }), status.getIn(['account', 'acct']), ]; if (rebloggedByText) { values.push(rebloggedByText); } return values.join(', '); }; export const defaultMediaVisibility = (status: StatusEntity, displayMedia: string): boolean => { if (!status) return false; if (status.reblog && typeof status.reblog === 'object') { status = status.reblog; } return (displayMedia !== 'hide_all' && !status.sensitive || displayMedia === 'show_all'); }; interface IStatus extends RouteComponentProps { intl: IntlShape, status: StatusEntity, account: AccountEntity, otherAccounts: ImmutableList, onClick: () => void, onReply: (status: StatusEntity, history: History) => void, onFavourite: (status: StatusEntity) => void, onReblog: (status: StatusEntity, e?: KeyboardEvent) => void, onQuote: (status: StatusEntity) => void, onDelete: (status: StatusEntity) => void, onDirect: (status: StatusEntity) => void, onChat: (status: StatusEntity) => void, onMention: (account: StatusEntity['account'], history: History) => void, onPin: (status: StatusEntity) => void, onOpenMedia: (media: ImmutableList, index: number) => void, onOpenVideo: (media: ImmutableMap | AttachmentEntity, startTime: number) => void, onOpenAudio: (media: ImmutableMap, startTime: number) => void, onBlock: (status: StatusEntity) => void, onEmbed: (status: StatusEntity) => void, onHeightChange: (status: StatusEntity) => void, onToggleHidden: (status: StatusEntity) => void, onShowHoverProfileCard: (status: StatusEntity) => void, muted: boolean, hidden: boolean, unread: boolean, onMoveUp: (statusId: string, featured?: string) => void, onMoveDown: (statusId: string, featured?: string) => void, getScrollPosition?: () => ScrollPosition | undefined, updateScrollBottom?: (bottom: number) => void, cacheMediaWidth: () => void, cachedMediaWidth: number, group: ImmutableMap, displayMedia: string, allowedEmoji: ImmutableList, focusable: boolean, history: History, featured?: string, } interface IStatusState { showMedia: boolean, statusId?: string, emojiSelectorFocused: boolean, mediaWrapperWidth?: number, } class Status extends ImmutablePureComponent { static defaultProps = { focusable: true, }; didShowCard = false; node?: HTMLDivElement = undefined; height?: number = undefined; // Avoid checking props that are functions (and whose equality will always // evaluate to false. See react-immutable-pure-component for usage. updateOnProps: any[] = [ 'status', 'account', 'muted', 'hidden', ]; state: IStatusState = { showMedia: defaultMediaVisibility(this.props.status, this.props.displayMedia), statusId: undefined, emojiSelectorFocused: false, }; // Track height changes we know about to compensate scrolling componentDidMount(): void { this.didShowCard = Boolean(!this.props.muted && !this.props.hidden && this.props.status && this.props.status.card); } getSnapshotBeforeUpdate(): ScrollPosition | undefined { if (this.props.getScrollPosition) { return this.props.getScrollPosition(); } else { return undefined; } } static getDerivedStateFromProps(nextProps: IStatus, prevState: IStatusState) { if (nextProps.status && nextProps.status.id !== prevState.statusId) { return { showMedia: defaultMediaVisibility(nextProps.status, nextProps.displayMedia), statusId: nextProps.status.id, }; } else { return null; } } // Compensate height changes componentDidUpdate(_prevProps: IStatus, _prevState: IStatusState, snapshot?: ScrollPosition): void { const doShowCard: boolean = Boolean(!this.props.muted && !this.props.hidden && this.props.status && this.props.status.card); if (doShowCard && !this.didShowCard) { this.didShowCard = true; if (snapshot && this.props.updateScrollBottom) { if (this.node && this.node.offsetTop < snapshot.top) { this.props.updateScrollBottom(snapshot.height - snapshot.top); } } } } componentWillUnmount(): void { // FIXME: Run this code only when a status is being deleted. // // const { getScrollPosition, updateScrollBottom } = this.props; // // if (this.node && getScrollPosition && updateScrollBottom) { // const position = getScrollPosition(); // if (position && this.node.offsetTop < position.top) { // requestAnimationFrame(() => { // updateScrollBottom(position.height - position.top); // }); // } // } } handleToggleMediaVisibility = (): void => { this.setState({ showMedia: !this.state.showMedia }); } handleClick = (): void => { if (this.props.onClick) { this.props.onClick(); return; } if (!this.props.history) { return; } this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().id}`); } handleExpandClick: React.EventHandler = (e) => { if (e.button === 0) { if (!this.props.history) { return; } this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().id}`); } } handleExpandedToggle = (): void => { this.props.onToggleHidden(this._properStatus()); }; renderLoadingMediaGallery(): JSX.Element { return
; } renderLoadingVideoPlayer(): JSX.Element { return
; } renderLoadingAudioPlayer(): JSX.Element { return
; } handleOpenVideo = (media: ImmutableMap, startTime: number): void => { this.props.onOpenVideo(media, startTime); } handleOpenAudio = (media: ImmutableMap, startTime: number): void => { this.props.onOpenAudio(media, startTime); } handleHotkeyOpenMedia = (e?: KeyboardEvent): void => { const { onOpenMedia, onOpenVideo } = this.props; const status = this._properStatus(); const firstAttachment = status.media_attachments.first(); e?.preventDefault(); if (firstAttachment) { if (firstAttachment.type === 'video') { onOpenVideo(firstAttachment, 0); } else { onOpenMedia(status.media_attachments, 0); } } } handleHotkeyReply = (e?: KeyboardEvent): void => { e?.preventDefault(); this.props.onReply(this._properStatus(), this.props.history); } handleHotkeyFavourite = (): void => { this.props.onFavourite(this._properStatus()); } handleHotkeyBoost = (e?: KeyboardEvent): void => { this.props.onReblog(this._properStatus(), e); } handleHotkeyMention = (e?: KeyboardEvent): void => { e?.preventDefault(); this.props.onMention(this._properStatus().account, this.props.history); } handleHotkeyOpen = (): void => { this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().id}`); } handleHotkeyOpenProfile = (): void => { this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}`); } handleHotkeyMoveUp = (e?: KeyboardEvent): void => { // FIXME: what's going on here? // this.props.onMoveUp(this.props.status.id, e?.target?.getAttribute('data-featured')); } handleHotkeyMoveDown = (e?: KeyboardEvent): void => { // FIXME: what's going on here? // this.props.onMoveDown(this.props.status.id, e?.target?.getAttribute('data-featured')); } handleHotkeyToggleHidden = (): void => { this.props.onToggleHidden(this._properStatus()); } handleHotkeyToggleSensitive = (): void => { this.handleToggleMediaVisibility(); } handleHotkeyReact = (): void => { this._expandEmojiSelector(); } handleEmojiSelectorExpand: React.EventHandler = e => { if (e.key === 'Enter') { this._expandEmojiSelector(); } e.preventDefault(); } handleEmojiSelectorUnfocus = (): void => { this.setState({ emojiSelectorFocused: false }); } _expandEmojiSelector = (): void => { this.setState({ emojiSelectorFocused: true }); const firstEmoji: HTMLDivElement | null | undefined = this.node?.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); firstEmoji?.focus(); }; _properStatus(): StatusEntity { const { status } = this.props; if (status.reblog && typeof status.reblog === 'object') { return status.reblog; } else { return status; } } handleRef = (c: HTMLDivElement): void => { this.node = c; } setRef = (c: HTMLDivElement): void => { if (c) { this.setState({ mediaWrapperWidth: c.offsetWidth }); } } render() { let media = null; const poll = null; let prepend, rebloggedByText, reblogElement, reblogElementMobile; const { intl, hidden, featured, unread, group } = this.props; // FIXME: why does this need to reassign status and account?? let { status, account, ...other } = this.props; // eslint-disable-line prefer-const if (!status) return null; if (hidden) { return (
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} {status.content}
); } if (status.filtered || status.getIn(['reblog', 'filtered'])) { const minHandlers = this.props.muted ? undefined : { moveUp: this.handleHotkeyMoveUp, moveDown: this.handleHotkeyMoveDown, }; return (
); } if (featured) { prepend = (
); } if (status.reblog && typeof status.reblog === 'object') { const displayNameHtml = { __html: String(status.getIn(['account', 'display_name_html'])) }; reblogElement = ( event.stopPropagation()} className='hidden sm:flex items-center text-gray-500 text-xs font-medium space-x-1 hover:underline' > , }} /> ); reblogElementMobile = (
event.stopPropagation()} className='flex items-center text-gray-500 text-xs font-medium space-x-1 hover:underline' > , }} />
); rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} reposted', }, { name: String(status.getIn(['account', 'acct'])), }); // @ts-ignore what the FUCK account = status.account; status = status.reblog; } const size = status.media_attachments.size; const firstAttachment = status.media_attachments.first(); if (size > 0 && firstAttachment) { if (this.props.muted) { media = ( ); } else if (size === 1 && firstAttachment.type === 'video') { const video = firstAttachment; if (video.external_video_id && status.card) { const { mediaWrapperWidth } = this.state; const getHeight = (): number => { const width = Number(video.meta.getIn(['original', 'width'])); const height = Number(video.meta.getIn(['original', 'height'])); return Number(mediaWrapperWidth) / (width / height); }; const height = getHeight(); media = (
); } else { media = ( {(Component: any) => ( )} ); } } else if (size === 1 && firstAttachment.type === 'audio') { const attachment = firstAttachment; media = ( {(Component: any) => ( )} ); } else { media = ( {(Component: any) => ( )} ); } } else if (status.spoiler_text.length === 0 && !status.quote && status.card) { media = ( ); } else if (status.expectsCard) { media = ( ); } let quote; if (status.quote) { if (status.pleroma.get('quote_visible', true) === false) { quote = (

); } else { quote = ; } } const handlers = this.props.muted ? undefined : { reply: this.handleHotkeyReply, favourite: this.handleHotkeyFavourite, boost: this.handleHotkeyBoost, mention: this.handleHotkeyMention, open: this.handleHotkeyOpen, openProfile: this.handleHotkeyOpenProfile, moveUp: this.handleHotkeyMoveUp, moveDown: this.handleHotkeyMoveDown, toggleHidden: this.handleHotkeyToggleHidden, toggleSensitive: this.handleHotkeyToggleSensitive, openMedia: this.handleHotkeyOpenMedia, react: this.handleHotkeyReact, }; const statusUrl = `/@${status.getIn(['account', 'acct'])}/posts/${status.id}`; // const favicon = status.getIn(['account', 'pleroma', 'favicon']); // const domain = getDomain(status.account); return (
this.props.history.push(statusUrl)} role='link' > {prepend} {reblogElementMobile}
{!group && status.group && (
Posted in {String(status.getIn(['group', 'title']))}
)} {media} {poll} {quote}
); } } export default withRouter(injectIntl(Status));