From e0e64f0f5c652a778ed865127825ed70d319c65c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 8 Aug 2022 19:31:19 -0500 Subject: [PATCH] Thread: convert to functional component --- app/soapbox/features/status/index.tsx | 998 ++++++++++++-------------- app/soapbox/utils/status.ts | 2 +- 2 files changed, 448 insertions(+), 552 deletions(-) diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index 505a0dd24..78a105723 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -1,11 +1,10 @@ import classNames from 'classnames'; import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable'; -import React from 'react'; +import { debounce } from 'lodash'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { HotKeys } from 'react-hotkeys'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage, WrappedComponentProps as IntlComponentProps } from 'react-intl'; -import { connect } from 'react-redux'; -import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { useHistory } from 'react-router-dom'; import { createSelector } from 'reselect'; import { blockAccount } from 'soapbox/actions/accounts'; @@ -37,7 +36,6 @@ import { import { initMuteModal } from 'soapbox/actions/mutes'; import { initReport } from 'soapbox/actions/reports'; import { getSettings } from 'soapbox/actions/settings'; -import { getSoapboxConfig } from 'soapbox/actions/soapbox'; import { muteStatus, unmuteStatus, @@ -57,28 +55,23 @@ import Tombstone from 'soapbox/components/tombstone'; import { Column, Stack } from 'soapbox/components/ui'; import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status'; import PendingStatus from 'soapbox/features/ui/components/pending_status'; +import { useAppDispatch, useAppSelector, useSettings, useSoapboxConfig } from 'soapbox/hooks'; import { makeGetStatus } from 'soapbox/selectors'; import { defaultMediaVisibility } from 'soapbox/utils/status'; -import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen'; - import ActionBar from './components/action-bar'; import DetailedStatus from './components/detailed-status'; import ThreadLoginCta from './components/thread-login-cta'; import ThreadStatus from './components/thread-status'; -import type { AxiosError } from 'axios'; import type { History } from 'history'; import type { VirtuosoHandle } from 'react-virtuoso'; -import type { AnyAction } from 'redux'; -import type { ThunkDispatch } from 'redux-thunk'; import type { RootState } from 'soapbox/store'; import type { Account as AccountEntity, Attachment as AttachmentEntity, Status as StatusEntity, } from 'soapbox/types/entities'; -import type { Me } from 'soapbox/types/soapbox'; const messages = defineMessages({ title: { id: 'status.title', defaultMessage: '@{username}\'s Post' }, @@ -98,59 +91,80 @@ const messages = defineMessages({ blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, }); -const makeMapStateToProps = () => { - const getStatus = makeGetStatus(); +const getStatus = makeGetStatus(); - const getAncestorsIds = createSelector([ - (_: RootState, statusId: string | undefined) => statusId, - (state: RootState) => state.contexts.inReplyTos, - ], (statusId, inReplyTos) => { +const getAncestorsIds = createSelector([ + (_: RootState, statusId: string | undefined) => statusId, + (state: RootState) => state.contexts.inReplyTos, +], (statusId, inReplyTos) => { + let ancestorsIds = ImmutableOrderedSet(); + let id: string | undefined = statusId; + + while (id && !ancestorsIds.includes(id)) { + ancestorsIds = ImmutableOrderedSet([id]).union(ancestorsIds); + id = inReplyTos.get(id); + } + + return ancestorsIds; +}); + +const getDescendantsIds = createSelector([ + (_: RootState, statusId: string) => statusId, + (state: RootState) => state.contexts.replies, +], (statusId, contextReplies) => { + let descendantsIds = ImmutableOrderedSet(); + const ids = [statusId]; + + while (ids.length > 0) { + const id = ids.shift(); + if (!id) break; + + const replies = contextReplies.get(id); + + if (descendantsIds.includes(id)) { + break; + } + + if (statusId !== id) { + descendantsIds = descendantsIds.union([id]); + } + + if (replies) { + replies.reverse().forEach((reply: string) => { + ids.unshift(reply); + }); + } + } + + return descendantsIds; +}); + +type DisplayMedia = 'default' | 'hide_all' | 'show_all'; +type RouteParams = { statusId: string }; + +interface IStatus { + params: RouteParams, + onOpenMedia: (media: ImmutableList, index: number) => void, + onOpenVideo: (video: AttachmentEntity, time: number) => void, +} + +const Status: React.FC = (props) => { + const intl = useIntl(); + const history = useHistory(); + const dispatch = useAppDispatch(); + + const settings = useSettings(); + const soapboxConfig = useSoapboxConfig(); + + const me = useAppSelector(state => state.me); + const status = useAppSelector(state => getStatus(state, { id: props.params.statusId })); + const displayMedia = settings.get('displayMedia') as DisplayMedia; + const allowedEmoji = soapboxConfig.allowedEmoji; + const askReplyConfirmation = useAppSelector(state => state.compose.text.trim().length !== 0); + + const { ancestorsIds, descendantsIds } = useAppSelector(state => { let ancestorsIds = ImmutableOrderedSet(); - let id: string | undefined = statusId; - - while (id && !ancestorsIds.includes(id)) { - ancestorsIds = ImmutableOrderedSet([id]).union(ancestorsIds); - id = inReplyTos.get(id); - } - - return ancestorsIds; - }); - - const getDescendantsIds = createSelector([ - (_: RootState, statusId: string) => statusId, - (state: RootState) => state.contexts.replies, - ], (statusId, contextReplies) => { - let descendantsIds = ImmutableOrderedSet(); - const ids = [statusId]; - - while (ids.length > 0) { - const id = ids.shift(); - if (!id) break; - - const replies = contextReplies.get(id); - - if (descendantsIds.includes(id)) { - break; - } - - if (statusId !== id) { - descendantsIds = descendantsIds.union([id]); - } - - if (replies) { - replies.reverse().forEach((reply: string) => { - ids.unshift(reply); - }); - } - } - - return descendantsIds; - }); - - const mapStateToProps = (state: RootState, props: { params: RouteParams }) => { - const status = getStatus(state, { id: props.params.statusId }); - let ancestorsIds = ImmutableOrderedSet(); - let descendantsIds = ImmutableOrderedSet(); + let descendantsIds = ImmutableOrderedSet(); if (status) { const statusId = status.id; @@ -160,116 +174,72 @@ const makeMapStateToProps = () => { descendantsIds = descendantsIds.delete(statusId).subtract(ancestorsIds); } - const soapbox = getSoapboxConfig(state); - return { status, ancestorsIds, descendantsIds, - askReplyConfirmation: state.compose.text.trim().length !== 0, - me: state.me, - displayMedia: getSettings(state).get('displayMedia'), - allowedEmoji: soapbox.allowedEmoji, }; - }; + }); - return mapStateToProps; -}; + const [showMedia, setShowMedia] = useState(defaultMediaVisibility(status, displayMedia)); + const [emojiSelectorFocused, setEmojiSelectorFocused] = useState(false); + const [isLoaded, setIsLoaded] = useState(!!status); + const [next, setNext] = useState(); -type DisplayMedia = 'default' | 'hide_all' | 'show_all'; -type RouteParams = { statusId: string }; + const node = useRef(null); + const statusRef = useRef(null); + const scroller = useRef(null); -interface IStatus extends RouteComponentProps, IntlComponentProps { - params: RouteParams, - dispatch: ThunkDispatch, - status: StatusEntity, - ancestorsIds: ImmutableOrderedSet, - descendantsIds: ImmutableOrderedSet, - askReplyConfirmation: boolean, - displayMedia: DisplayMedia, - allowedEmoji: ImmutableList, - onOpenMedia: (media: ImmutableList, index: number) => void, - onOpenVideo: (video: AttachmentEntity, time: number) => void, - me: Me, -} - -interface IStatusState { - fullscreen: boolean, - showMedia: boolean, - loadedStatusId?: string, - emojiSelectorFocused: boolean, - isLoaded: boolean, - error?: AxiosError, - next?: string, -} - -class Status extends ImmutablePureComponent { - - state = { - fullscreen: false, - showMedia: defaultMediaVisibility(this.props.status, this.props.displayMedia), - loadedStatusId: undefined, - emojiSelectorFocused: false, - isLoaded: Boolean(this.props.status), - error: undefined, - next: undefined, - }; - - node: HTMLDivElement | null = null; - status: HTMLDivElement | null = null; - scroller: VirtuosoHandle | null = null; - _scrolledIntoView: boolean = false; - - fetchData = async() => { - const { dispatch, params } = this.props; + /** Fetch the status (and context) from the API. */ + const fetchData = async() => { + const { params } = props; const { statusId } = params; const { next } = await dispatch(fetchStatusWithContext(statusId)); - this.setState({ next }); - } + setNext(next); + }; - componentDidMount() { - this.fetchData().then(() => { - this.setState({ isLoaded: true }); + // Load data. + useEffect(() => { + fetchData().then(() => { + setIsLoaded(true); }).catch(error => { - this.setState({ error, isLoaded: true }); + setIsLoaded(true); }); - attachFullscreenListener(this.onFullScreenChange); - } + }, [props.params.statusId]); - handleToggleMediaVisibility = () => { - this.setState({ showMedia: !this.state.showMedia }); - } + const handleToggleMediaVisibility = () => { + setShowMedia(!showMedia); + }; - handleEmojiReactClick = (status: StatusEntity, emoji: string) => { - this.props.dispatch(simpleEmojiReact(status, emoji)); - } + const handleEmojiReactClick = (status: StatusEntity, emoji: string) => { + dispatch(simpleEmojiReact(status, emoji)); + }; - handleFavouriteClick = (status: StatusEntity) => { + const handleFavouriteClick = (status: StatusEntity) => { if (status.favourited) { - this.props.dispatch(unfavourite(status)); + dispatch(unfavourite(status)); } else { - this.props.dispatch(favourite(status)); + dispatch(favourite(status)); } - } + }; - handlePin = (status: StatusEntity) => { + const handlePin = (status: StatusEntity) => { if (status.pinned) { - this.props.dispatch(unpin(status)); + dispatch(unpin(status)); } else { - this.props.dispatch(pin(status)); + dispatch(pin(status)); } - } + }; - handleBookmark = (status: StatusEntity) => { + const handleBookmark = (status: StatusEntity) => { if (status.bookmarked) { - this.props.dispatch(unbookmark(status)); + dispatch(unbookmark(status)); } else { - this.props.dispatch(bookmark(status)); + dispatch(bookmark(status)); } - } + }; - handleReplyClick = (status: StatusEntity) => { - const { askReplyConfirmation, dispatch, intl } = this.props; + const handleReplyClick = (status: StatusEntity) => { if (askReplyConfirmation) { dispatch(openModal('CONFIRM', { message: intl.formatMessage(messages.replyMessage), @@ -279,29 +249,28 @@ class Status extends ImmutablePureComponent { } else { dispatch(replyCompose(status)); } - } + }; - handleModalReblog = (status: StatusEntity) => { - this.props.dispatch(reblog(status)); - } + const handleModalReblog = (status: StatusEntity) => { + dispatch(reblog(status)); + }; - handleReblogClick = (status: StatusEntity, e?: React.MouseEvent) => { - this.props.dispatch((_, getState) => { + const handleReblogClick = (status: StatusEntity, e?: React.MouseEvent) => { + dispatch((_, getState) => { const boostModal = getSettings(getState()).get('boostModal'); if (status.reblogged) { - this.props.dispatch(unreblog(status)); + dispatch(unreblog(status)); } else { if ((e && e.shiftKey) || !boostModal) { - this.handleModalReblog(status); + handleModalReblog(status); } else { - this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog })); + dispatch(openModal('BOOST', { status, onReblog: handleModalReblog })); } } }); - } + }; - handleQuoteClick = (status: StatusEntity) => { - const { askReplyConfirmation, dispatch, intl } = this.props; + const handleQuoteClick = (status: StatusEntity) => { if (askReplyConfirmation) { dispatch(openModal('CONFIRM', { message: intl.formatMessage(messages.replyMessage), @@ -311,12 +280,10 @@ class Status extends ImmutablePureComponent { } else { dispatch(quoteCompose(status)); } - } + }; - handleDeleteClick = (status: StatusEntity, withRedraft = false) => { - const { dispatch, intl } = this.props; - - this.props.dispatch((_, getState) => { + const handleDeleteClick = (status: StatusEntity, withRedraft = false) => { + dispatch((_, getState) => { const deleteModal = getSettings(getState()).get('deleteModal'); if (!deleteModal) { dispatch(deleteStatus(status.id, withRedraft)); @@ -330,82 +297,68 @@ class Status extends ImmutablePureComponent { })); } }); - } - - handleEditClick = (status: StatusEntity) => { - const { dispatch } = this.props; + }; + const handleEditClick = (status: StatusEntity) => { dispatch(editStatus(status.id)); - } + }; - handleDirectClick = (account: AccountEntity) => { - this.props.dispatch(directCompose(account)); - } + const handleDirectClick = (account: AccountEntity) => { + dispatch(directCompose(account)); + }; - handleChatClick = (account: AccountEntity, router: History) => { - this.props.dispatch(launchChat(account.id, router)); - } + const handleChatClick = (account: AccountEntity, router: History) => { + dispatch(launchChat(account.id, router)); + }; - handleMentionClick = (account: AccountEntity) => { - this.props.dispatch(mentionCompose(account)); - } + const handleMentionClick = (account: AccountEntity) => { + dispatch(mentionCompose(account)); + }; - handleOpenMedia = (media: ImmutableList, index: number) => { - this.props.dispatch(openModal('MEDIA', { media, index })); - } + const handleOpenMedia = (media: ImmutableList, index: number) => { + dispatch(openModal('MEDIA', { media, index })); + }; - handleOpenVideo = (media: ImmutableList, time: number) => { - this.props.dispatch(openModal('VIDEO', { media, time })); - } + const handleOpenVideo = (media: ImmutableList, time: number) => { + dispatch(openModal('VIDEO', { media, time })); + }; - handleHotkeyOpenMedia = (e?: KeyboardEvent) => { - const { status, onOpenMedia, onOpenVideo } = this.props; - const firstAttachment = status.media_attachments.get(0); + const handleHotkeyOpenMedia = (e?: KeyboardEvent) => { + const { onOpenMedia, onOpenVideo } = props; + const firstAttachment = status?.media_attachments.get(0); e?.preventDefault(); - if (status.media_attachments.size > 0 && firstAttachment) { + if (status && firstAttachment) { if (firstAttachment.type === 'video') { onOpenVideo(firstAttachment, 0); } else { onOpenMedia(status.media_attachments, 0); } } - } + }; - handleMuteClick = (account: AccountEntity) => { - this.props.dispatch(initMuteModal(account)); - } + const handleMuteClick = (account: AccountEntity) => { + dispatch(initMuteModal(account)); + }; - handleConversationMuteClick = (status: StatusEntity) => { + const handleConversationMuteClick = (status: StatusEntity) => { if (status.muted) { - this.props.dispatch(unmuteStatus(status.id)); + dispatch(unmuteStatus(status.id)); } else { - this.props.dispatch(muteStatus(status.id)); + dispatch(muteStatus(status.id)); } - } + }; - handleToggleHidden = (status: StatusEntity) => { + const handleToggleHidden = (status: StatusEntity) => { if (status.hidden) { - this.props.dispatch(revealStatus(status.id)); + dispatch(revealStatus(status.id)); } else { - this.props.dispatch(hideStatus(status.id)); + dispatch(hideStatus(status.id)); } - } + }; - handleToggleAll = () => { - const { status, ancestorsIds, descendantsIds } = this.props; - const statusIds = [status.id].concat(ancestorsIds.toArray(), descendantsIds.toArray()); - - if (status.hidden) { - this.props.dispatch(revealStatus(statusIds)); - } else { - this.props.dispatch(hideStatus(statusIds)); - } - } - - handleBlockClick = (status: StatusEntity) => { - const { dispatch, intl } = this.props; + const handleBlockClick = (status: StatusEntity) => { const { account } = status; if (!account || typeof account !== 'object') return; @@ -421,134 +374,127 @@ class Status extends ImmutablePureComponent { dispatch(initReport(account, status)); }, })); - } - - handleReport = (status: StatusEntity) => { - this.props.dispatch(initReport(status.account as AccountEntity, status)); - } - - handleEmbed = (status: StatusEntity) => { - this.props.dispatch(openModal('EMBED', { url: status.url })); - } - - handleDeactivateUser = (status: StatusEntity) => { - const { dispatch, intl } = this.props; - dispatch(deactivateUserModal(intl, status.getIn(['account', 'id']) as string)); - } - - handleDeleteUser = (status: StatusEntity) => { - const { dispatch, intl } = this.props; - dispatch(deleteUserModal(intl, status.getIn(['account', 'id']) as string)); - } - - handleToggleStatusSensitivity = (status: StatusEntity) => { - const { dispatch, intl } = this.props; - dispatch(toggleStatusSensitivityModal(intl, status.id, status.sensitive)); - } - - handleDeleteStatus = (status: StatusEntity) => { - const { dispatch, intl } = this.props; - dispatch(deleteStatusModal(intl, status.id)); - } - - handleHotkeyMoveUp = () => { - this.handleMoveUp(this.props.status.id); - } - - handleHotkeyMoveDown = () => { - this.handleMoveDown(this.props.status.id); - } - - handleHotkeyReply = (e?: KeyboardEvent) => { - e?.preventDefault(); - this.handleReplyClick(this.props.status); - } - - handleHotkeyFavourite = () => { - this.handleFavouriteClick(this.props.status); - } - - handleHotkeyBoost = () => { - this.handleReblogClick(this.props.status); - } - - handleHotkeyMention = (e?: KeyboardEvent) => { - e?.preventDefault(); - const { account } = this.props.status; - if (!account || typeof account !== 'object') return; - this.handleMentionClick(account); - } - - handleHotkeyOpenProfile = () => { - this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`); - } - - handleHotkeyToggleHidden = () => { - this.handleToggleHidden(this.props.status); - } - - handleHotkeyToggleSensitive = () => { - this.handleToggleMediaVisibility(); - } - - handleHotkeyReact = () => { - this._expandEmojiSelector(); - } - - handleMoveUp = (id: string) => { - const { status, ancestorsIds, descendantsIds } = this.props; - - if (id === status.id) { - this._selectChild(ancestorsIds.size - 1); - } else { - let index = ImmutableList(ancestorsIds).indexOf(id); - - if (index === -1) { - index = ImmutableList(descendantsIds).indexOf(id); - this._selectChild(ancestorsIds.size + index); - } else { - this._selectChild(index - 1); - } - } - } - - handleMoveDown = (id: string) => { - const { status, ancestorsIds, descendantsIds } = this.props; - - if (id === status.id) { - this._selectChild(ancestorsIds.size + 1); - } else { - let index = ImmutableList(ancestorsIds).indexOf(id); - - if (index === -1) { - index = ImmutableList(descendantsIds).indexOf(id); - this._selectChild(ancestorsIds.size + index + 2); - } else { - this._selectChild(index + 1); - } - } - } - - handleEmojiSelectorExpand: React.EventHandler = e => { - if (e.key === 'Enter') { - this._expandEmojiSelector(); - } - e.preventDefault(); - } - - handleEmojiSelectorUnfocus: React.EventHandler = () => { - this.setState({ emojiSelectorFocused: false }); - } - - _expandEmojiSelector = () => { - if (!this.status) return; - this.setState({ emojiSelectorFocused: true }); - const firstEmoji: HTMLButtonElement | null = this.status.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); - firstEmoji?.focus(); }; - _selectChild(index: number) { - this.scroller?.scrollIntoView({ + const handleReport = (status: StatusEntity) => { + dispatch(initReport(status.account as AccountEntity, status)); + }; + + const handleEmbed = (status: StatusEntity) => { + dispatch(openModal('EMBED', { url: status.url })); + }; + + const handleDeactivateUser = (status: StatusEntity) => { + dispatch(deactivateUserModal(intl, status.getIn(['account', 'id']) as string)); + }; + + const handleDeleteUser = (status: StatusEntity) => { + dispatch(deleteUserModal(intl, status.getIn(['account', 'id']) as string)); + }; + + const handleToggleStatusSensitivity = (status: StatusEntity) => { + dispatch(toggleStatusSensitivityModal(intl, status.id, status.sensitive)); + }; + + const handleDeleteStatus = (status: StatusEntity) => { + dispatch(deleteStatusModal(intl, status.id)); + }; + + const handleHotkeyMoveUp = () => { + handleMoveUp(status!.id); + }; + + const handleHotkeyMoveDown = () => { + handleMoveDown(status!.id); + }; + + const handleHotkeyReply = (e?: KeyboardEvent) => { + e?.preventDefault(); + handleReplyClick(status!); + }; + + const handleHotkeyFavourite = () => { + handleFavouriteClick(status!); + }; + + const handleHotkeyBoost = () => { + handleReblogClick(status!); + }; + + const handleHotkeyMention = (e?: KeyboardEvent) => { + e?.preventDefault(); + const { account } = status!; + if (!account || typeof account !== 'object') return; + handleMentionClick(account); + }; + + const handleHotkeyOpenProfile = () => { + history.push(`/@${status!.getIn(['account', 'acct'])}`); + }; + + const handleHotkeyToggleHidden = () => { + handleToggleHidden(status!); + }; + + const handleHotkeyToggleSensitive = () => { + handleToggleMediaVisibility(); + }; + + const handleHotkeyReact = () => { + _expandEmojiSelector(); + }; + + const handleMoveUp = (id: string) => { + if (id === status?.id) { + _selectChild(ancestorsIds.size - 1); + } else { + let index = ImmutableList(ancestorsIds).indexOf(id); + + if (index === -1) { + index = ImmutableList(descendantsIds).indexOf(id); + _selectChild(ancestorsIds.size + index); + } else { + _selectChild(index - 1); + } + } + }; + + const handleMoveDown = (id: string) => { + if (id === status?.id) { + _selectChild(ancestorsIds.size + 1); + } else { + let index = ImmutableList(ancestorsIds).indexOf(id); + + if (index === -1) { + index = ImmutableList(descendantsIds).indexOf(id); + _selectChild(ancestorsIds.size + index + 2); + } else { + _selectChild(index + 1); + } + } + }; + + const handleEmojiSelectorExpand: React.EventHandler = e => { + if (e.key === 'Enter') { + _expandEmojiSelector(); + } + e.preventDefault(); + }; + + const handleEmojiSelectorUnfocus: React.EventHandler = () => { + setEmojiSelectorFocused(false); + }; + + const _expandEmojiSelector = () => { + if (statusRef.current) { + setEmojiSelectorFocused(true); + const firstEmoji: HTMLButtonElement | null = statusRef.current.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); + firstEmoji?.focus(); + } + }; + + const _selectChild = (index: number) => { + scroller.current?.scrollIntoView({ index, behavior: 'smooth', done: () => { @@ -559,38 +505,32 @@ class Status extends ImmutablePureComponent { } }, }); - } + }; - renderTombstone(id: string) { + const renderTombstone = (id: string) => { return (
); - } - - renderStatus(id: string) { - const { status } = this.props; + }; + const renderStatus = (id: string) => { return ( ); - } + }; - renderPendingStatus(id: string) { - // const { status } = this.props; + const renderPendingStatus = (id: string) => { const idempotencyKey = id.replace(/^末pending-/, ''); return ( @@ -598,229 +538,185 @@ class Status extends ImmutablePureComponent { className='thread__status' key={id} idempotencyKey={idempotencyKey} - // focusedStatusId={status.id} - // onMoveUp={this.handleMoveUp} - // onMoveDown={this.handleMoveDown} - // contextType='thread' /> ); - } + }; - renderChildren(list: ImmutableOrderedSet) { + const renderChildren = (list: ImmutableOrderedSet) => { return list.map(id => { if (id.endsWith('-tombstone')) { - return this.renderTombstone(id); + return renderTombstone(id); } else if (id.startsWith('末pending-')) { - return this.renderPendingStatus(id); + return renderPendingStatus(id); } else { - return this.renderStatus(id); + return renderStatus(id); } }); - } + }; - setRef: React.RefCallback = c => { - this.node = c; - } + // Reset media visibility if status changes. + useEffect(() => { + setShowMedia(defaultMediaVisibility(status, displayMedia)); + }, [status?.id]); - setStatusRef: React.RefCallback = c => { - this.status = c; - } + // Scroll focused status into view when thread updates. + useEffect(() => { + scroller.current?.scrollToIndex({ + index: ancestorsIds.size, + offset: -80, + }); - componentDidUpdate(prevProps: IStatus, prevState: IStatusState) { - const { params, status, displayMedia, ancestorsIds } = this.props; - const { isLoaded } = this.state; + setImmediate(() => statusRef.current?.querySelector('.detailed-status')?.focus()); + }, [props.params.statusId, status?.id, ancestorsIds.size, isLoaded]); - if (params.statusId !== prevProps.params.statusId) { - this.fetchData(); - } + const handleRefresh = () => { + return fetchData(); + }; - if (status && status.id !== prevState.loadedStatusId) { - this.setState({ showMedia: defaultMediaVisibility(status, displayMedia), loadedStatusId: status.id }); - } - - if (params.statusId !== prevProps.params.statusId || status?.id !== prevProps.status?.id || ancestorsIds.size > prevProps.ancestorsIds.size || isLoaded !== prevState.isLoaded) { - this.scroller?.scrollToIndex({ - index: this.props.ancestorsIds.size, - offset: -80, - }); - - setImmediate(() => this.status?.querySelector('.detailed-status')?.focus()); - } - } - - componentWillUnmount() { - detachFullscreenListener(this.onFullScreenChange); - } - - onFullScreenChange = () => { - this.setState({ fullscreen: isFullscreen() }); - } - - handleRefresh = () => { - return this.fetchData(); - } - - handleLoadMore = () => { - const { status } = this.props; - const { next } = this.state; - - if (next) { - this.props.dispatch(fetchNext(status.id, next)).then(({ next }) => { - this.setState({ next }); + const handleLoadMore = useCallback(debounce(() => { + if (next && status) { + dispatch(fetchNext(status.id, next)).then(({ next }) => { + setNext(next); }).catch(() => {}); } - } - - handleOpenCompareHistoryModal = (status: StatusEntity) => { - const { dispatch } = this.props; + }, 300, { leading: true }), [next, status]); + const handleOpenCompareHistoryModal = (status: StatusEntity) => { dispatch(openModal('COMPARE_HISTORY', { statusId: status.id, })); - } + }; - setScrollerRef = (c: VirtuosoHandle) => { - this.scroller = c; - } - - render() { - const { me, status, ancestorsIds, descendantsIds, intl } = this.props; - - const hasAncestors = ancestorsIds && ancestorsIds.size > 0; - const hasDescendants = descendantsIds && descendantsIds.size > 0; - - if (!status && this.state.isLoaded) { - // TODO: handle errors other than 404 with `this.state.error?.response?.status` - return ( - - ); - } else if (!status) { - return ( - - ); - } - - type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void }; - - const handlers: HotkeyHandlers = { - moveUp: this.handleHotkeyMoveUp, - moveDown: this.handleHotkeyMoveDown, - reply: this.handleHotkeyReply, - favourite: this.handleHotkeyFavourite, - boost: this.handleHotkeyBoost, - mention: this.handleHotkeyMention, - openProfile: this.handleHotkeyOpenProfile, - toggleHidden: this.handleHotkeyToggleHidden, - toggleSensitive: this.handleHotkeyToggleSensitive, - openMedia: this.handleHotkeyOpenMedia, - react: this.handleHotkeyReact, - }; - - const username = String(status.getIn(['account', 'acct'])); - const titleMessage = status.visibility === 'direct' ? messages.titleDirect : messages.title; - - const focusedStatus = ( -
- -
- {/* @ts-ignore */} - - -
- - -
-
- - {hasDescendants && ( -
- )} -
- ); - - const children: JSX.Element[] = []; - - if (hasAncestors) { - children.push(...this.renderChildren(ancestorsIds).toArray()); - } - - children.push(focusedStatus); - - if (hasDescendants) { - children.push(...this.renderChildren(descendantsIds).toArray()); - } + const hasAncestors = ancestorsIds.size > 0; + const hasDescendants = descendantsIds.size > 0; + if (!status && isLoaded) { return ( - -
- -
- - - -
- } - initialTopMostItemIndex={ancestorsIds.size} - > - {children} - -
- - {!me && } -
-
-
+ + ); + } else if (!status) { + return ( + ); } -} + type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void }; -const WrappedComponent = withRouter(injectIntl(Status)); -// @ts-ignore -export default connect(makeMapStateToProps)(WrappedComponent); + const handlers: HotkeyHandlers = { + moveUp: handleHotkeyMoveUp, + moveDown: handleHotkeyMoveDown, + reply: handleHotkeyReply, + favourite: handleHotkeyFavourite, + boost: handleHotkeyBoost, + mention: handleHotkeyMention, + openProfile: handleHotkeyOpenProfile, + toggleHidden: handleHotkeyToggleHidden, + toggleSensitive: handleHotkeyToggleSensitive, + openMedia: handleHotkeyOpenMedia, + react: handleHotkeyReact, + }; + + const username = String(status.getIn(['account', 'acct'])); + const titleMessage = status.visibility === 'direct' ? messages.titleDirect : messages.title; + + const focusedStatus = ( +
+ +
+ {/* @ts-ignore */} + + +
+ + +
+
+ + {hasDescendants && ( +
+ )} +
+ ); + + const children: JSX.Element[] = []; + + if (hasAncestors) { + children.push(...renderChildren(ancestorsIds).toArray()); + } + + children.push(focusedStatus); + + if (hasDescendants) { + children.push(...renderChildren(descendantsIds).toArray()); + } + + return ( + +
+ +
+ + + +
+ } + initialTopMostItemIndex={ancestorsIds.size} + > + {children} + +
+ + {!me && } +
+
+
+ ); +}; + +export default Status; diff --git a/app/soapbox/utils/status.ts b/app/soapbox/utils/status.ts index 439edfc02..5c4590066 100644 --- a/app/soapbox/utils/status.ts +++ b/app/soapbox/utils/status.ts @@ -3,7 +3,7 @@ import { isIntegerId } from 'soapbox/utils/numbers'; import type { Status as StatusEntity } from 'soapbox/types/entities'; /** Get the initial visibility of media attachments from user settings. */ -export const defaultMediaVisibility = (status: StatusEntity | undefined, displayMedia: string): boolean => { +export const defaultMediaVisibility = (status: StatusEntity | undefined | null, displayMedia: string): boolean => { if (!status) return false; if (status.reblog && typeof status.reblog === 'object') {