From e0e64f0f5c652a778ed865127825ed70d319c65c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 8 Aug 2022 19:31:19 -0500 Subject: [PATCH 01/32] 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') { From f4d1cb93cdc3321fd5005e374436f0973ff9fb76 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 8 Aug 2022 19:54:27 -0500 Subject: [PATCH 02/32] Status --> Thread --- app/soapbox/features/status/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index 78a105723..8bced97e4 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -142,13 +142,13 @@ const getDescendantsIds = createSelector([ type DisplayMedia = 'default' | 'hide_all' | 'show_all'; type RouteParams = { statusId: string }; -interface IStatus { +interface IThread { params: RouteParams, onOpenMedia: (media: ImmutableList, index: number) => void, onOpenVideo: (video: AttachmentEntity, time: number) => void, } -const Status: React.FC = (props) => { +const Thread: React.FC = (props) => { const intl = useIntl(); const history = useHistory(); const dispatch = useAppDispatch(); @@ -719,4 +719,4 @@ const Status: React.FC = (props) => { ); }; -export default Status; +export default Thread; From 3ced53a948451020381946fe2be71782afb3f242 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 8 Aug 2022 20:13:43 -0500 Subject: [PATCH 03/32] DetailedStatus: convert to React.FC --- .../status/components/detailed-status.tsx | 282 ++++++++---------- 1 file changed, 125 insertions(+), 157 deletions(-) diff --git a/app/soapbox/features/status/components/detailed-status.tsx b/app/soapbox/features/status/components/detailed-status.tsx index ebdb0be46..79c40346a 100644 --- a/app/soapbox/features/status/components/detailed-status.tsx +++ b/app/soapbox/features/status/components/detailed-status.tsx @@ -1,7 +1,6 @@ import classNames from 'classnames'; -import React from 'react'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { FormattedDate, FormattedMessage, injectIntl, WrappedComponentProps as IntlProps } from 'react-intl'; +import React, { useEffect, useRef, useState } from 'react'; +import { FormattedDate, FormattedMessage, useIntl } from 'react-intl'; import Icon from 'soapbox/components/icon'; import StatusMedia from 'soapbox/components/status-media'; @@ -17,13 +16,14 @@ import StatusInteractionBar from './status-interaction-bar'; import type { List as ImmutableList } from 'immutable'; import type { Attachment as AttachmentEntity, Status as StatusEntity } from 'soapbox/types/entities'; -interface IDetailedStatus extends IntlProps { +interface IDetailedStatus { status: StatusEntity, onOpenMedia: (media: ImmutableList, index: number) => void, onOpenVideo: (media: ImmutableList, start: number) => void, onToggleHidden: (status: StatusEntity) => void, measureHeight: boolean, - onHeightChange: () => void, + /** @deprecated Unused. */ + onHeightChange?: () => void, domain: string, compact: boolean, showMedia: boolean, @@ -31,171 +31,139 @@ interface IDetailedStatus extends IntlProps { onToggleMediaVisibility: () => void, } -interface IDetailedStatusState { - height: number | null, -} +const DetailedStatus: React.FC = ({ + status, + onToggleHidden, + onOpenCompareHistoryModal, + onToggleMediaVisibility, + measureHeight, + showMedia, + compact, +}) => { + const intl = useIntl(); -class DetailedStatus extends ImmutablePureComponent { + const node = useRef(null); + const [height, setHeight] = useState(); - state = { - height: null, + const handleExpandedToggle = () => { + onToggleHidden(status); }; - node: HTMLDivElement | null = null; + const handleOpenCompareHistoryModal = () => { + onOpenCompareHistoryModal(status); + }; - handleExpandedToggle = () => { - this.props.onToggleHidden(this.props.status); - } - - handleOpenCompareHistoryModal = () => { - this.props.onOpenCompareHistoryModal(this.props.status); - } - - _measureHeight(heightJustChanged = false) { - if (this.props.measureHeight && this.node) { - scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 })); - - if (this.props.onHeightChange && heightJustChanged) { - this.props.onHeightChange(); - } + useEffect(() => { + if (measureHeight && node.current) { + scheduleIdleTask(() => { + if (node.current) { + setHeight(Math.ceil(node.current.scrollHeight) + 1); + } + }); } - } + }, [node.current, height]); - setRef: React.RefCallback = c => { - this.node = c; - this._measureHeight(); - } - - componentDidUpdate(prevProps: IDetailedStatus, prevState: IDetailedStatusState) { - this._measureHeight(prevState.height !== this.state.height); - } - - // handleModalLink = e => { - // e.preventDefault(); - // - // let href; - // - // if (e.target.nodeName !== 'A') { - // href = e.target.parentNode.href; - // } else { - // href = e.target.href; - // } - // - // window.open(href, 'soapbox-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes'); - // } - - getActualStatus = () => { - const { status } = this.props; + const getActualStatus = () => { if (!status) return undefined; return status.reblog && typeof status.reblog === 'object' ? status.reblog : status; + }; + + const actualStatus = getActualStatus(); + if (!actualStatus) return null; + const { account } = actualStatus; + if (!account || typeof account !== 'object') return null; + + const outerStyle: React.CSSProperties = { boxSizing: 'border-box' }; + + let statusTypeIcon = null; + + if (measureHeight) { + outerStyle.height = `${height}px`; } - render() { - const status = this.getActualStatus(); - if (!status) return null; - const { account } = status; - if (!account || typeof account !== 'object') return null; + let quote; - const outerStyle: React.CSSProperties = { boxSizing: 'border-box' }; - const { compact } = this.props; - - let statusTypeIcon = null; - - if (this.props.measureHeight) { - outerStyle.height = `${this.state.height}px`; - } - - let quote; - - if (status.quote) { - if (status.pleroma.get('quote_visible', true) === false) { - quote = ( -
-

-
- ); - } else { - quote = ; - } - } - - if (status.visibility === 'direct') { - statusTypeIcon = ; - } else if (status.visibility === 'private') { - statusTypeIcon = ; - } - - return ( -
-
-
- -
- - {/* status.group && ( -
- Posted in {status.getIn(['group', 'title'])} -
- )*/} - - - - - - - - {quote} - - - - -
- {statusTypeIcon} - - - - - - - - - {status.edited_at && ( - <> - {' · '} -
- - - -
- - )} -
-
-
+ if (actualStatus.quote) { + if (actualStatus.pleroma.get('quote_visible', true) === false) { + quote = ( +
+

-
- ); + ); + } else { + quote = ; + } } -} + if (actualStatus.visibility === 'direct') { + statusTypeIcon = ; + } else if (actualStatus.visibility === 'private') { + statusTypeIcon = ; + } -export default injectIntl(DetailedStatus); + return ( +
+
+
+ +
+ + + + + + + + {quote} + + + + +
+ {statusTypeIcon} + + + + + + + + + {actualStatus.edited_at && ( + <> + {' · '} +
+ + + +
+ + )} +
+
+
+
+
+ ); +}; + +export default DetailedStatus; From 748b48f84c56ba8d458944c9cd12e9720332fdb7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 8 Aug 2022 20:22:15 -0500 Subject: [PATCH 04/32] DetailedStatus: clean up unused props --- .../status/components/detailed-status.tsx | 33 +-- .../containers/detailed_status_container.js | 236 ------------------ app/soapbox/features/status/index.tsx | 1 - 3 files changed, 3 insertions(+), 267 deletions(-) delete mode 100644 app/soapbox/features/status/containers/detailed_status_container.js diff --git a/app/soapbox/features/status/components/detailed-status.tsx b/app/soapbox/features/status/components/detailed-status.tsx index 79c40346a..1f890f228 100644 --- a/app/soapbox/features/status/components/detailed-status.tsx +++ b/app/soapbox/features/status/components/detailed-status.tsx @@ -1,5 +1,4 @@ -import classNames from 'classnames'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useRef } from 'react'; import { FormattedDate, FormattedMessage, useIntl } from 'react-intl'; import Icon from 'soapbox/components/icon'; @@ -9,7 +8,6 @@ import StatusContent from 'soapbox/components/status_content'; import { HStack, Text } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account_container'; import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container'; -import scheduleIdleTask from 'soapbox/features/ui/util/schedule_idle_task'; import StatusInteractionBar from './status-interaction-bar'; @@ -21,11 +19,6 @@ interface IDetailedStatus { onOpenMedia: (media: ImmutableList, index: number) => void, onOpenVideo: (media: ImmutableList, start: number) => void, onToggleHidden: (status: StatusEntity) => void, - measureHeight: boolean, - /** @deprecated Unused. */ - onHeightChange?: () => void, - domain: string, - compact: boolean, showMedia: boolean, onOpenCompareHistoryModal: (status: StatusEntity) => void, onToggleMediaVisibility: () => void, @@ -36,14 +29,10 @@ const DetailedStatus: React.FC = ({ onToggleHidden, onOpenCompareHistoryModal, onToggleMediaVisibility, - measureHeight, showMedia, - compact, }) => { const intl = useIntl(); - const node = useRef(null); - const [height, setHeight] = useState(); const handleExpandedToggle = () => { onToggleHidden(status); @@ -53,16 +42,6 @@ const DetailedStatus: React.FC = ({ onOpenCompareHistoryModal(status); }; - useEffect(() => { - if (measureHeight && node.current) { - scheduleIdleTask(() => { - if (node.current) { - setHeight(Math.ceil(node.current.scrollHeight) + 1); - } - }); - } - }, [node.current, height]); - const getActualStatus = () => { if (!status) return undefined; return status.reblog && typeof status.reblog === 'object' ? status.reblog : status; @@ -73,14 +52,8 @@ const DetailedStatus: React.FC = ({ const { account } = actualStatus; if (!account || typeof account !== 'object') return null; - const outerStyle: React.CSSProperties = { boxSizing: 'border-box' }; - let statusTypeIcon = null; - if (measureHeight) { - outerStyle.height = `${height}px`; - } - let quote; if (actualStatus.quote) { @@ -102,8 +75,8 @@ const DetailedStatus: React.FC = ({ } return ( -
-
+
+
{ - const getStatus = makeGetStatus(); - - const mapStateToProps = (state, props) => ({ - status: getStatus(state, props), - domain: state.getIn(['meta', 'domain']), - }); - - return mapStateToProps; -}; - -const mapDispatchToProps = (dispatch, { intl }) => ({ - - onReply(status) { - dispatch((_, getState) => { - const state = getState(); - if (state.getIn(['compose', 'text']).trim().length !== 0) { - dispatch(openModal('CONFIRM', { - message: intl.formatMessage(messages.replyMessage), - confirm: intl.formatMessage(messages.replyConfirm), - onConfirm: () => dispatch(replyCompose(status)), - })); - } else { - dispatch(replyCompose(status)); - } - }); - }, - - onModalReblog(status) { - dispatch(reblog(status)); - }, - - onReblog(status, e) { - dispatch((_, getState) => { - const boostModal = getSettings(getState()).get('boostModal'); - if (status.get('reblogged')) { - dispatch(unreblog(status)); - } else { - if (e.shiftKey || !boostModal) { - this.onModalReblog(status); - } else { - dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); - } - } - }); - }, - - onBookmark(status) { - if (status.get('bookmarked')) { - dispatch(unbookmark(status)); - } else { - dispatch(bookmark(status)); - } - }, - - onFavourite(status) { - if (status.get('favourited')) { - dispatch(unfavourite(status)); - } else { - dispatch(favourite(status)); - } - }, - - onPin(status) { - if (status.get('pinned')) { - dispatch(unpin(status)); - } else { - dispatch(pin(status)); - } - }, - - onEmbed(status) { - dispatch(openModal('EMBED', { - url: status.get('url'), - onError: error => dispatch(showAlertForError(error)), - })); - }, - - onDelete(status, withRedraft = false) { - dispatch((_, getState) => { - const deleteModal = getSettings(getState()).get('deleteModal'); - if (!deleteModal) { - dispatch(deleteStatus(status.get('id'), withRedraft)); - } else { - dispatch(openModal('CONFIRM', { - icon: withRedraft ? require('@tabler/icons/edit.svg') : require('@tabler/icons/trash.svg'), - heading: intl.formatMessage(withRedraft ? messages.redraftHeading : messages.deleteHeading), - message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage), - confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm), - onConfirm: () => dispatch(deleteStatus(status.get('id'), withRedraft)), - })); - } - }); - }, - - onEdit(status) { - dispatch(editStatus(status.get('id'))); - }, - - onDirect(account) { - dispatch(directCompose(account)); - }, - - onChat(account, router) { - dispatch(launchChat(account.get('id'), router)); - }, - - onMention(account) { - dispatch(mentionCompose(account)); - }, - - onOpenMedia(media, index) { - dispatch(openModal('MEDIA', { media, index })); - }, - - onOpenVideo(media, time) { - dispatch(openModal('VIDEO', { media, time })); - }, - - onBlock(status) { - const account = status.get('account'); - dispatch(openModal('CONFIRM', { - icon: require('@tabler/icons/ban.svg'), - heading: , - message: @{account.get('acct')} }} />, - confirm: intl.formatMessage(messages.blockConfirm), - onConfirm: () => dispatch(blockAccount(account.get('id'))), - secondary: intl.formatMessage(messages.blockAndReport), - onSecondary: () => { - dispatch(blockAccount(account.get('id'))); - dispatch(initReport(account, status)); - }, - })); - }, - - onReport(status) { - dispatch(initReport(status.get('account'), status)); - }, - - onMute(account) { - dispatch(initMuteModal(account)); - }, - - onMuteConversation(status) { - if (status.get('muted')) { - dispatch(unmuteStatus(status.get('id'))); - } else { - dispatch(muteStatus(status.get('id'))); - } - }, - - onToggleHidden(status) { - if (status.get('hidden')) { - dispatch(revealStatus(status.get('id'))); - } else { - dispatch(hideStatus(status.get('id'))); - } - }, - - onDeactivateUser(status) { - dispatch(deactivateUserModal(intl, status.getIn(['account', 'id']))); - }, - - onDeleteUser(status) { - dispatch(deleteUserModal(intl, status.getIn(['account', 'id']))); - }, - - onToggleStatusSensitivity(status) { - dispatch(toggleStatusSensitivityModal(intl, status.get('id'), status.get('sensitive'))); - }, - - onDeleteStatus(status) { - dispatch(deleteStatusModal(intl, status.get('id'))); - }, - - onOpenCompareHistoryModal(status) { - dispatch(openModal('COMPARE_HISTORY', { - statusId: status.get('id'), - })); - }, - -}); - -export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus)); diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index 8bced97e4..9ab57ec1f 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -629,7 +629,6 @@ const Thread: React.FC = (props) => { // FIXME: no "reblogged by" text is added for the screen reader aria-label={textForScreenReader(intl, status)} > - {/* @ts-ignore */} Date: Mon, 8 Aug 2022 21:39:08 -0500 Subject: [PATCH 05/32] Status: convert to React.FC --- app/soapbox/components/status.tsx | 696 +++++++++++++----------------- 1 file changed, 300 insertions(+), 396 deletions(-) diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index e3dce38f5..2e2f68a1c 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -1,9 +1,8 @@ import classNames from 'classnames'; -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { HotKeys } from 'react-hotkeys'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { injectIntl, FormattedMessage, IntlShape, defineMessages } from 'react-intl'; -import { NavLink, withRouter, RouteComponentProps } from 'react-router-dom'; +import { useIntl, FormattedMessage, IntlShape, defineMessages } from 'react-intl'; +import { NavLink, useHistory } from 'react-router-dom'; import Icon from 'soapbox/components/icon'; import AccountContainer from 'soapbox/containers/account_container'; @@ -16,7 +15,6 @@ import StatusActionBar from './status_action_bar'; import StatusContent from './status_content'; 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, @@ -51,10 +49,9 @@ export const textForScreenReader = (intl: IntlShape, status: StatusEntity, reblo return values.join(', '); }; -interface IStatus extends RouteComponentProps { +interface IStatus { id?: string, contextType?: string, - intl: IntlShape, status: StatusEntity, account: AccountEntity, otherAccounts: ImmutableList, @@ -88,133 +85,72 @@ interface IStatus extends RouteComponentProps { displayMedia: string, allowedEmoji: ImmutableList, focusable: boolean, - history: History, featured?: boolean, withDismiss?: boolean, hideActionBar?: boolean, hoverable?: boolean, } -interface IStatusState { - showMedia: boolean, - statusId?: string, - emojiSelectorFocused: boolean, -} +const Status: React.FC = (props) => { + const { + status, + focusable = true, + hoverable = true, + onToggleHidden, + displayMedia, + onOpenMedia, + onOpenVideo, + onClick, + onReply, + onFavourite, + onReblog, + onMention, + onMoveUp, + onMoveDown, + muted, + hidden, + featured, + unread, + group, + hideActionBar, + } = props; -class Status extends ImmutablePureComponent { + const intl = useIntl(); + const history = useHistory(); - static defaultProps = { - focusable: true, - hoverable: true, + const didShowCard = useRef(false); + const node = useRef(null); + + const [showMedia, setShowMedia] = useState(defaultMediaVisibility(status, displayMedia)); + const [emojiSelectorFocused, setEmojiSelectorFocused] = useState(false); + + // Track height changes we know about to compensate scrolling. + useEffect(() => { + didShowCard.current = Boolean(!muted && !hidden && status?.card); + }, []); + + useEffect(() => { + setShowMedia(defaultMediaVisibility(status, displayMedia)); + }, [status.id]); + + const handleToggleMediaVisibility = (): void => { + setShowMedia(!showMedia); }; - 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 | null { - if (this.props.getScrollPosition) { - return this.props.getScrollPosition() || null; + const handleClick = (): void => { + if (onClick) { + onClick(); } else { - return null; + history.push(`/@${_properStatus().getIn(['account', 'acct'])}/posts/${_properStatus().id}`); } - } - - 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()); }; - handleHotkeyOpenMedia = (e?: KeyboardEvent): void => { - const { onOpenMedia, onOpenVideo } = this.props; - const status = this._properStatus(); + const handleExpandedToggle = (): void => { + onToggleHidden(_properStatus()); + }; + + const handleHotkeyOpenMedia = (e?: KeyboardEvent): void => { + const status = _properStatus(); const firstAttachment = status.media_attachments.first(); e?.preventDefault(); @@ -226,313 +162,281 @@ class Status extends ImmutablePureComponent { onOpenMedia(status.media_attachments, 0); } } - } + }; - handleHotkeyReply = (e?: KeyboardEvent): void => { + const handleHotkeyReply = (e?: KeyboardEvent): void => { e?.preventDefault(); - this.props.onReply(this._properStatus()); - } + onReply(_properStatus()); + }; - handleHotkeyFavourite = (): void => { - this.props.onFavourite(this._properStatus()); - } + const handleHotkeyFavourite = (): void => { + onFavourite(_properStatus()); + }; - handleHotkeyBoost = (e?: KeyboardEvent): void => { - this.props.onReblog(this._properStatus(), e); - } + const handleHotkeyBoost = (e?: KeyboardEvent): void => { + onReblog(_properStatus(), e); + }; - handleHotkeyMention = (e?: KeyboardEvent): void => { + const handleHotkeyMention = (e?: KeyboardEvent): void => { e?.preventDefault(); - this.props.onMention(this._properStatus().account); - } + onMention(_properStatus().account); + }; - handleHotkeyOpen = (): void => { - this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().id}`); - } + const handleHotkeyOpen = (): void => { + history.push(`/@${_properStatus().getIn(['account', 'acct'])}/posts/${_properStatus().id}`); + }; - handleHotkeyOpenProfile = (): void => { - this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}`); - } + const handleHotkeyOpenProfile = (): void => { + history.push(`/@${_properStatus().getIn(['account', 'acct'])}`); + }; - handleHotkeyMoveUp = (e?: KeyboardEvent): void => { - this.props.onMoveUp(this.props.status.id, this.props.featured); - } + const handleHotkeyMoveUp = (e?: KeyboardEvent): void => { + onMoveUp(status.id, featured); + }; - handleHotkeyMoveDown = (e?: KeyboardEvent): void => { - this.props.onMoveDown(this.props.status.id, this.props.featured); - } + const handleHotkeyMoveDown = (e?: KeyboardEvent): void => { + onMoveDown(status.id, featured); + }; - handleHotkeyToggleHidden = (): void => { - this.props.onToggleHidden(this._properStatus()); - } + const handleHotkeyToggleHidden = (): void => { + onToggleHidden(_properStatus()); + }; - handleHotkeyToggleSensitive = (): void => { - this.handleToggleMediaVisibility(); - } + const handleHotkeyToggleSensitive = (): void => { + handleToggleMediaVisibility(); + }; - handleHotkeyReact = (): void => { - this._expandEmojiSelector(); - } + const handleHotkeyReact = (): void => { + _expandEmojiSelector(); + }; - handleEmojiSelectorExpand: React.EventHandler = e => { - if (e.key === 'Enter') { - this._expandEmojiSelector(); - } - e.preventDefault(); - } + const handleEmojiSelectorUnfocus = (): void => { + setEmojiSelectorFocused(false); + }; - 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'); + const _expandEmojiSelector = (): void => { + setEmojiSelectorFocused(true); + const firstEmoji: HTMLDivElement | null | undefined = node.current?.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); firstEmoji?.focus(); }; - _properStatus(): StatusEntity { - const { status } = this.props; - + const _properStatus = (): StatusEntity => { if (status.reblog && typeof status.reblog === 'object') { return status.reblog; } else { return status; } + }; + + if (!status) return null; + const actualStatus = _properStatus(); + let prepend, rebloggedByText, reblogElement, reblogElementMobile; + + if (hidden) { + return ( +
+ {actualStatus.getIn(['account', 'display_name']) || actualStatus.getIn(['account', 'username'])} + {actualStatus.content} +
+ ); } - handleRef = (c: HTMLDivElement): void => { - this.node = c; - } - - render() { - 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-700 dark:text-gray-600 text-xs font-medium space-x-1 hover:underline' - > - - - - - - , - }} - /> - - - ); - - reblogElementMobile = ( -
- event.stopPropagation()} - className='flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-1 hover:underline' - > - - - - - - , - }} - /> - - -
- ); - - rebloggedByText = intl.formatMessage( - messages.reblogged_by, - { name: String(status.getIn(['account', 'acct'])) }, - ); - - // @ts-ignore what the FUCK - account = status.account; - status = status.reblog; - } - - 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, + if (status.filtered || actualStatus.filtered) { + const minHandlers = muted ? undefined : { + moveUp: handleHotkeyMoveUp, + moveDown: handleHotkeyMoveDown, }; - 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']))} -
- )} - - - - - - - - {poll} - {quote} - - {!this.props.hideActionBar && ( - - )} -
-
+ +
+
); } -} + if (featured) { + prepend = ( +
+ + -export default withRouter(injectIntl(Status)); + + + + +
+ ); + } + + 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-700 dark:text-gray-600 text-xs font-medium space-x-1 hover:underline' + > + + + + + + , + }} + /> + + + ); + + reblogElementMobile = ( +
+ event.stopPropagation()} + className='flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-1 hover:underline' + > + + + + + + , + }} + /> + + +
+ ); + + rebloggedByText = intl.formatMessage( + messages.reblogged_by, + { name: String(status.getIn(['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 statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`; + + return ( + +
history.push(statusUrl)} + role='link' + > + {prepend} + +
+ {reblogElementMobile} + +
+ +
+ +
+ {!group && actualStatus.group && ( +
+ Posted in {String(actualStatus.getIn(['group', 'title']))} +
+ )} + + + + + + + + {quote} + + {!hideActionBar && ( + // @ts-ignore + + )} +
+
+
+
+ ); +}; + +export default Status; From 89390083a9441f76e2276640a17360547eaad07b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 8 Aug 2022 21:42:07 -0500 Subject: [PATCH 06/32] Move textForScreenReader to utils/status --- app/soapbox/components/status.tsx | 24 ++---------------------- app/soapbox/features/status/index.tsx | 3 +-- app/soapbox/utils/status.ts | 22 ++++++++++++++++++++++ 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 2e2f68a1c..94e5033f9 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -1,13 +1,13 @@ import classNames from 'classnames'; import React, { useEffect, useRef, useState } from 'react'; import { HotKeys } from 'react-hotkeys'; -import { useIntl, FormattedMessage, IntlShape, defineMessages } from 'react-intl'; +import { useIntl, FormattedMessage, defineMessages } from 'react-intl'; import { NavLink, useHistory } from 'react-router-dom'; import Icon from 'soapbox/components/icon'; import AccountContainer from 'soapbox/containers/account_container'; import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container'; -import { defaultMediaVisibility } from 'soapbox/utils/status'; +import { defaultMediaVisibility, textForScreenReader } from 'soapbox/utils/status'; import StatusMedia from './status-media'; import StatusReplyMentions from './status-reply-mentions'; @@ -29,26 +29,6 @@ const messages = defineMessages({ reblogged_by: { id: 'status.reblogged_by', defaultMessage: '{name} reposted' }, }); -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(', '); -}; - interface IStatus { id?: string, contextType?: string, diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index 9ab57ec1f..5d00bce07 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -49,7 +49,6 @@ import { import MissingIndicator from 'soapbox/components/missing_indicator'; import PullToRefresh from 'soapbox/components/pull-to-refresh'; import ScrollableList from 'soapbox/components/scrollable_list'; -import { textForScreenReader } from 'soapbox/components/status'; import SubNavigation from 'soapbox/components/sub_navigation'; import Tombstone from 'soapbox/components/tombstone'; import { Column, Stack } from 'soapbox/components/ui'; @@ -57,7 +56,7 @@ import PlaceholderStatus from 'soapbox/features/placeholder/components/placehold 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 { defaultMediaVisibility, textForScreenReader } from 'soapbox/utils/status'; import ActionBar from './components/action-bar'; import DetailedStatus from './components/detailed-status'; diff --git a/app/soapbox/utils/status.ts b/app/soapbox/utils/status.ts index 5c4590066..b0890fb20 100644 --- a/app/soapbox/utils/status.ts +++ b/app/soapbox/utils/status.ts @@ -1,5 +1,6 @@ import { isIntegerId } from 'soapbox/utils/numbers'; +import type { IntlShape } from 'react-intl'; import type { Status as StatusEntity } from 'soapbox/types/entities'; /** Get the initial visibility of media attachments from user settings. */ @@ -36,3 +37,24 @@ export const shouldHaveCard = (status: StatusEntity): boolean => { export const hasIntegerMediaIds = (status: StatusEntity): boolean => { return status.media_attachments.some(({ id }) => isIntegerId(id)); }; + +/** Sanitize status text for use with screen readers. */ +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(', '); +}; From 82d717d8ce5fc58eeb71c44a21c96aa6bdabfb4a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 8 Aug 2022 22:26:30 -0500 Subject: [PATCH 07/32] Move getActualStatus logic to utils --- app/soapbox/components/status.tsx | 8 ++------ .../features/status/components/detailed-status.tsx | 8 ++------ app/soapbox/utils/status.ts | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 94e5033f9..a0a05e8bf 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -7,7 +7,7 @@ import { NavLink, useHistory } from 'react-router-dom'; import Icon from 'soapbox/components/icon'; import AccountContainer from 'soapbox/containers/account_container'; import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container'; -import { defaultMediaVisibility, textForScreenReader } from 'soapbox/utils/status'; +import { defaultMediaVisibility, textForScreenReader, getActualStatus } from 'soapbox/utils/status'; import StatusMedia from './status-media'; import StatusReplyMentions from './status-reply-mentions'; @@ -201,11 +201,7 @@ const Status: React.FC = (props) => { }; const _properStatus = (): StatusEntity => { - if (status.reblog && typeof status.reblog === 'object') { - return status.reblog; - } else { - return status; - } + return getActualStatus(status); }; if (!status) return null; diff --git a/app/soapbox/features/status/components/detailed-status.tsx b/app/soapbox/features/status/components/detailed-status.tsx index 1f890f228..250f81257 100644 --- a/app/soapbox/features/status/components/detailed-status.tsx +++ b/app/soapbox/features/status/components/detailed-status.tsx @@ -8,6 +8,7 @@ import StatusContent from 'soapbox/components/status_content'; import { HStack, Text } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account_container'; import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container'; +import { getActualStatus } from 'soapbox/utils/status'; import StatusInteractionBar from './status-interaction-bar'; @@ -42,12 +43,7 @@ const DetailedStatus: React.FC = ({ onOpenCompareHistoryModal(status); }; - const getActualStatus = () => { - if (!status) return undefined; - return status.reblog && typeof status.reblog === 'object' ? status.reblog : status; - }; - - const actualStatus = getActualStatus(); + const actualStatus = getActualStatus(status); if (!actualStatus) return null; const { account } = actualStatus; if (!account || typeof account !== 'object') return null; diff --git a/app/soapbox/utils/status.ts b/app/soapbox/utils/status.ts index b0890fb20..c78d2fe6c 100644 --- a/app/soapbox/utils/status.ts +++ b/app/soapbox/utils/status.ts @@ -58,3 +58,17 @@ export const textForScreenReader = (intl: IntlShape, status: StatusEntity, reblo return values.join(', '); }; + +/** Get reblogged status if any, otherwise return the original status. */ +// @ts-ignore The type seems right, but TS doesn't like it. +export const getActualStatus: { + (status: StatusEntity): StatusEntity, + (status: undefined): undefined, + (status: null): null, +} = (status) => { + if (status?.reblog && typeof status?.reblog === 'object') { + return status.reblog as StatusEntity; + } else { + return status; + } +}; From 4edd28a08ba98557e32d16f04b3e6f93e5ccda0c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 8 Aug 2022 22:46:09 -0500 Subject: [PATCH 08/32] Clean up _properStatus() nonsense --- app/soapbox/components/status.tsx | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index a0a05e8bf..92094a258 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -104,6 +104,8 @@ const Status: React.FC = (props) => { const [showMedia, setShowMedia] = useState(defaultMediaVisibility(status, displayMedia)); const [emojiSelectorFocused, setEmojiSelectorFocused] = useState(false); + const actualStatus = getActualStatus(status); + // Track height changes we know about to compensate scrolling. useEffect(() => { didShowCard.current = Boolean(!muted && !hidden && status?.card); @@ -121,16 +123,16 @@ const Status: React.FC = (props) => { if (onClick) { onClick(); } else { - history.push(`/@${_properStatus().getIn(['account', 'acct'])}/posts/${_properStatus().id}`); + history.push(`/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`); } }; const handleExpandedToggle = (): void => { - onToggleHidden(_properStatus()); + onToggleHidden(actualStatus); }; const handleHotkeyOpenMedia = (e?: KeyboardEvent): void => { - const status = _properStatus(); + const status = actualStatus; const firstAttachment = status.media_attachments.first(); e?.preventDefault(); @@ -146,28 +148,28 @@ const Status: React.FC = (props) => { const handleHotkeyReply = (e?: KeyboardEvent): void => { e?.preventDefault(); - onReply(_properStatus()); + onReply(actualStatus); }; const handleHotkeyFavourite = (): void => { - onFavourite(_properStatus()); + onFavourite(actualStatus); }; const handleHotkeyBoost = (e?: KeyboardEvent): void => { - onReblog(_properStatus(), e); + onReblog(actualStatus, e); }; const handleHotkeyMention = (e?: KeyboardEvent): void => { e?.preventDefault(); - onMention(_properStatus().account); + onMention(actualStatus.account); }; const handleHotkeyOpen = (): void => { - history.push(`/@${_properStatus().getIn(['account', 'acct'])}/posts/${_properStatus().id}`); + history.push(`/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`); }; const handleHotkeyOpenProfile = (): void => { - history.push(`/@${_properStatus().getIn(['account', 'acct'])}`); + history.push(`/@${actualStatus.getIn(['account', 'acct'])}`); }; const handleHotkeyMoveUp = (e?: KeyboardEvent): void => { @@ -179,7 +181,7 @@ const Status: React.FC = (props) => { }; const handleHotkeyToggleHidden = (): void => { - onToggleHidden(_properStatus()); + onToggleHidden(actualStatus); }; const handleHotkeyToggleSensitive = (): void => { @@ -200,12 +202,7 @@ const Status: React.FC = (props) => { firstEmoji?.focus(); }; - const _properStatus = (): StatusEntity => { - return getActualStatus(status); - }; - if (!status) return null; - const actualStatus = _properStatus(); let prepend, rebloggedByText, reblogElement, reblogElementMobile; if (hidden) { From f87be8ce9adb5c43c4d26f772161bd2ad83eaef7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 8 Aug 2022 23:21:18 -0500 Subject: [PATCH 09/32] Use StatusActionBar for both types of statuses --- app/soapbox/components/status_action_bar.tsx | 6 ++++-- app/soapbox/features/status/index.tsx | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx index c3a3087a3..920929d78 100644 --- a/app/soapbox/components/status_action_bar.tsx +++ b/app/soapbox/components/status_action_bar.tsx @@ -73,6 +73,7 @@ interface IStatusActionBar extends RouteComponentProps { onOpenReblogsModal: (acct: string, statusId: string) => void, onReply: (status: Status) => void, onFavourite: (status: Status) => void, + onEmojiReact: (status: Status, emoji: string) => void, onBookmark: (status: Status) => void, onReblog: (status: Status, e: React.MouseEvent) => void, onQuote: (status: Status) => void, @@ -91,8 +92,8 @@ interface IStatusActionBar extends RouteComponentProps { onDeleteStatus: (status: Status) => void, onMuteConversation: (status: Status) => void, onPin: (status: Status) => void, - withDismiss: boolean, - withGroupAdmin: boolean, + withDismiss?: boolean, + withGroupAdmin?: boolean, intl: IntlShape, me: string | null | false | undefined, isStaff: boolean, @@ -100,6 +101,7 @@ interface IStatusActionBar extends RouteComponentProps { allowedEmoji: ImmutableList, emojiSelectorFocused: boolean, handleEmojiSelectorUnfocus: () => void, + handleEmojiSelectorExpand?: React.EventHandler, features: Features, history: History, dispatch: Dispatch, diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index 5d00bce07..1f8f3d4e0 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -49,6 +49,7 @@ import { import MissingIndicator from 'soapbox/components/missing_indicator'; import PullToRefresh from 'soapbox/components/pull-to-refresh'; import ScrollableList from 'soapbox/components/scrollable_list'; +import StatusActionBar from 'soapbox/components/status_action_bar'; import SubNavigation from 'soapbox/components/sub_navigation'; import Tombstone from 'soapbox/components/tombstone'; import { Column, Stack } from 'soapbox/components/ui'; @@ -58,7 +59,6 @@ import { useAppDispatch, useAppSelector, useSettings, useSoapboxConfig } from 's import { makeGetStatus } from 'soapbox/selectors'; import { defaultMediaVisibility, textForScreenReader } from 'soapbox/utils/status'; -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'; @@ -480,7 +480,7 @@ const Thread: React.FC = (props) => { e.preventDefault(); }; - const handleEmojiSelectorUnfocus: React.EventHandler = () => { + const handleEmojiSelectorUnfocus = () => { setEmojiSelectorFocused(false); }; @@ -640,7 +640,7 @@ const Thread: React.FC = (props) => {
- Date: Tue, 9 Aug 2022 13:46:11 -0500 Subject: [PATCH 10/32] StatusActionBar: convert to React.FC --- app/soapbox/components/status_action_bar.tsx | 690 ++++++++----------- 1 file changed, 276 insertions(+), 414 deletions(-) diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx index 920929d78..6bd69d74e 100644 --- a/app/soapbox/components/status_action_bar.tsx +++ b/app/soapbox/components/status_action_bar.tsx @@ -1,25 +1,19 @@ import { List as ImmutableList } from 'immutable'; import React from 'react'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, IntlShape } from 'react-intl'; -import { connect } from 'react-redux'; -import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import { useHistory } from 'react-router-dom'; -import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts'; import { openModal } from 'soapbox/actions/modals'; import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper'; import StatusActionButton from 'soapbox/components/status-action-button'; import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container'; -import { isUserTouching } from 'soapbox/is_mobile'; +import { useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji_reacts'; -import { getFeatures } from 'soapbox/utils/features'; import type { History } from 'history'; -import type { AnyAction, Dispatch } from 'redux'; import type { Menu } from 'soapbox/components/dropdown_menu'; -import type { RootState } from 'soapbox/store'; import type { Status } from 'soapbox/types/entities'; -import type { Features } from 'soapbox/utils/features'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -67,10 +61,8 @@ const messages = defineMessages({ quotePost: { id: 'status.quote', defaultMessage: 'Quote post' }, }); -interface IStatusActionBar extends RouteComponentProps { +interface IStatusActionBar { status: Status, - onOpenUnauthorizedModal: (modalType?: string) => void, - onOpenReblogsModal: (acct: string, statusId: string) => void, onReply: (status: Status) => void, onFavourite: (status: Status) => void, onEmojiReact: (status: Status, emoji: string) => void, @@ -94,47 +86,56 @@ interface IStatusActionBar extends RouteComponentProps { onPin: (status: Status) => void, withDismiss?: boolean, withGroupAdmin?: boolean, - intl: IntlShape, - me: string | null | false | undefined, - isStaff: boolean, - isAdmin: boolean, allowedEmoji: ImmutableList, emojiSelectorFocused: boolean, handleEmojiSelectorUnfocus: () => void, handleEmojiSelectorExpand?: React.EventHandler, - features: Features, - history: History, - dispatch: Dispatch, } -interface IStatusActionBarState { - emojiSelectorVisible: boolean, -} +const StatusActionBar: React.FC = ({ + status, + onReply, + onFavourite, + allowedEmoji, + onBookmark, + onReblog, + onQuote, + onDelete, + onEdit, + onPin, + onMention, + onDirect, + onChat, + onMute, + onBlock, + onEmbed, + onReport, + onMuteConversation, + onDeactivateUser, + onDeleteUser, + onDeleteStatus, + onToggleStatusSensitivity, + withDismiss, +}) => { + const intl = useIntl(); + const history = useHistory(); + const dispatch = useDispatch(); -class StatusActionBar extends ImmutablePureComponent { + const me = useAppSelector(state => state.me); + const features = useFeatures(); - static defaultProps: Partial = { - isStaff: false, - } + const account = useOwnAccount(); + const isStaff = account ? account.staff : false; + const isAdmin = account ? account.admin : false; - node?: HTMLDivElement = undefined; - - state = { - emojiSelectorVisible: false, - } - - // Avoid checking props that are functions (and whose equality will always - // evaluate to false. See react-immutable-pure-component for usage. - // @ts-ignore: the type checker is wrong. - updateOnProps = [ - 'status', - 'withDismiss', - 'emojiSelectorFocused', - ] - - handleReplyClick: React.MouseEventHandler = (e) => { - const { me, onReply, onOpenUnauthorizedModal, status } = this.props; + const onOpenUnauthorizedModal = (action?: string) => { + dispatch(openModal('UNAUTHORIZED', { + action, + ap_id: status.url, + })); + }; + const handleReplyClick: React.MouseEventHandler = (e) => { if (me) { onReply(status); } else { @@ -142,70 +143,18 @@ class StatusActionBar extends ImmutablePureComponent { + const handleShareClick = () => { navigator.share({ - text: this.props.status.search_index, - url: this.props.status.uri, + text: status.search_index, + url: status.uri, }).catch((e) => { if (e.name !== 'AbortError') console.error(e); }); - } + }; - handleLikeButtonHover: React.EventHandler = () => { - const { features } = this.props; - - if (features.emojiReacts && !isUserTouching()) { - this.setState({ emojiSelectorVisible: true }); - } - } - - handleLikeButtonLeave: React.EventHandler = () => { - const { features } = this.props; - - if (features.emojiReacts && !isUserTouching()) { - this.setState({ emojiSelectorVisible: false }); - } - } - - handleLikeButtonClick: React.EventHandler = (e) => { - const { features } = this.props; - - const reactForStatus = getReactForStatus(this.props.status, this.props.allowedEmoji); - const meEmojiReact = typeof reactForStatus === 'string' ? reactForStatus : '👍'; - - if (features.emojiReacts && isUserTouching()) { - if (this.state.emojiSelectorVisible) { - this.handleReact(meEmojiReact); - } else { - this.setState({ emojiSelectorVisible: true }); - } - } else { - this.handleReact(meEmojiReact); - } - - e.stopPropagation(); - } - - handleReact = (emoji: string): void => { - const { me, dispatch, onOpenUnauthorizedModal, status } = this.props; - if (me) { - dispatch(simpleEmojiReact(status, emoji) as any); - } else { - onOpenUnauthorizedModal('FAVOURITE'); - } - this.setState({ emojiSelectorVisible: false }); - } - - handleReactClick = (emoji: string): React.EventHandler => { - return () => { - this.handleReact(emoji); - }; - } - - handleFavouriteClick: React.EventHandler = (e) => { - const { me, onFavourite, onOpenUnauthorizedModal, status } = this.props; + const handleFavouriteClick: React.EventHandler = (e) => { if (me) { onFavourite(status); } else { @@ -213,15 +162,14 @@ class StatusActionBar extends ImmutablePureComponent = (e) => { + const handleBookmarkClick: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onBookmark(this.props.status); - } + onBookmark(status); + }; - handleReblogClick: React.EventHandler = e => { - const { me, onReblog, onOpenUnauthorizedModal, status } = this.props; + const handleReblogClick: React.EventHandler = e => { e.stopPropagation(); if (me) { @@ -229,83 +177,83 @@ class StatusActionBar extends ImmutablePureComponent = (e) => { + const handleQuoteClick: React.EventHandler = (e) => { e.stopPropagation(); - const { me, onQuote, onOpenUnauthorizedModal, status } = this.props; + if (me) { onQuote(status); } else { onOpenUnauthorizedModal('REBLOG'); } - } + }; - handleDeleteClick: React.EventHandler = (e) => { + const handleDeleteClick: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onDelete(this.props.status); - } + onDelete(status); + }; - handleRedraftClick: React.EventHandler = (e) => { + const handleRedraftClick: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onDelete(this.props.status, true); - } + onDelete(status, true); + }; - handleEditClick: React.EventHandler = () => { - this.props.onEdit(this.props.status); - } + const handleEditClick: React.EventHandler = () => { + onEdit(status); + }; - handlePinClick: React.EventHandler = (e) => { + const handlePinClick: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onPin(this.props.status); - } + onPin(status); + }; - handleMentionClick: React.EventHandler = (e) => { + const handleMentionClick: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onMention(this.props.status.account); - } + onMention(status.account); + }; - handleDirectClick: React.EventHandler = (e) => { + const handleDirectClick: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onDirect(this.props.status.account); - } + onDirect(status.account); + }; - handleChatClick: React.EventHandler = (e) => { + const handleChatClick: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onChat(this.props.status.account, this.props.history); - } + onChat(status.account, history); + }; - handleMuteClick: React.EventHandler = (e) => { + const handleMuteClick: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onMute(this.props.status.account); - } + onMute(status.account); + }; - handleBlockClick: React.EventHandler = (e) => { + const handleBlockClick: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onBlock(this.props.status); - } + onBlock(status); + }; - handleOpen: React.EventHandler = (e) => { + const handleOpen: React.EventHandler = (e) => { e.stopPropagation(); - this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/posts/${this.props.status.id}`); - } + history.push(`/@${status.getIn(['account', 'acct'])}/posts/${status.id}`); + }; - handleEmbed = () => { - this.props.onEmbed(this.props.status); - } + const handleEmbed = () => { + onEmbed(status); + }; - handleReport: React.EventHandler = (e) => { + const handleReport: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onReport(this.props.status); - } + onReport(status); + }; - handleConversationMuteClick: React.EventHandler = (e) => { + const handleConversationMuteClick: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onMuteConversation(this.props.status); - } + onMuteConversation(status); + }; - handleCopy: React.EventHandler = (e) => { - const { url } = this.props.status; + const handleCopy: React.EventHandler = (e) => { + const { url } = status; const textarea = document.createElement('textarea'); e.stopPropagation(); @@ -323,53 +271,29 @@ class StatusActionBar extends ImmutablePureComponent = (e) => { - // const { status } = this.props; - // - // e.stopPropagation(); - // - // this.props.onGroupRemoveAccount(status.getIn(['group', 'id']), status.getIn(['account', 'id'])); - // } - // - // handleGroupRemovePost: React.EventHandler = (e) => { - // const { status } = this.props; - // - // e.stopPropagation(); - // - // this.props.onGroupRemoveStatus(status.getIn(['group', 'id']), status.id); - // } - - handleDeactivateUser: React.EventHandler = (e) => { + const handleDeactivateUser: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onDeactivateUser(this.props.status); - } + onDeactivateUser(status); + }; - handleDeleteUser: React.EventHandler = (e) => { + const handleDeleteUser: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onDeleteUser(this.props.status); - } + onDeleteUser(status); + }; - handleDeleteStatus: React.EventHandler = (e) => { + const handleDeleteStatus: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onDeleteStatus(this.props.status); - } + onDeleteStatus(status); + }; - handleToggleStatusSensitivity: React.EventHandler = (e) => { + const handleToggleStatusSensitivity: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onToggleStatusSensitivity(this.props.status); - } + onToggleStatusSensitivity(status); + }; - handleOpenReblogsModal = () => { - const { me, status, onOpenUnauthorizedModal, onOpenReblogsModal } = this.props; - - if (!me) onOpenUnauthorizedModal(); - else onOpenReblogsModal(String(status.getIn(['account', 'acct'])), status.id); - } - - _makeMenu = (publicStatus: boolean) => { - const { status, intl, withDismiss, me, features, isStaff, isAdmin } = this.props; + const _makeMenu = (publicStatus: boolean) => { const mutingConversation = status.muted; const ownAccount = status.getIn(['account', 'id']) === me; const username = String(status.getIn(['account', 'username'])); @@ -378,21 +302,21 @@ class StatusActionBar extends ImmutablePureComponent { - this.node = c; - } - - componentDidMount() { - document.addEventListener('click', (e) => { - if (this.node && !this.node.contains(e.target as Node)) - this.setState({ emojiSelectorVisible: false }); - }); - } - - render() { - const { status, intl, allowedEmoji, features, me } = this.props; - - const publicStatus = ['public', 'unlisted'].includes(status.visibility); - - const replyCount = status.replies_count; - const reblogCount = status.reblogs_count; - const favouriteCount = status.favourites_count; - - const emojiReactCount = reduceEmoji( - (status.pleroma.get('emoji_reactions') || ImmutableList()) as ImmutableList, - favouriteCount, - status.favourited, - allowedEmoji, - ).reduce((acc, cur) => acc + cur.get('count'), 0); - - const meEmojiReact = getReactForStatus(status, allowedEmoji) as keyof typeof reactMessages | undefined; - - const reactMessages = { - '👍': messages.reactionLike, - '❤️': messages.reactionHeart, - '😆': messages.reactionLaughing, - '😮': messages.reactionOpenMouth, - '😢': messages.reactionCry, - '😩': messages.reactionWeary, - '': messages.favourite, - }; - - const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiReact || ''] || messages.favourite); - - const menu = this._makeMenu(publicStatus); - let reblogIcon = require('@tabler/icons/repeat.svg'); - let replyTitle; - - if (status.visibility === 'direct') { - reblogIcon = require('@tabler/icons/mail.svg'); - } else if (status.visibility === 'private') { - reblogIcon = require('@tabler/icons/lock.svg'); - } - - const reblogMenu = [{ - text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog), - action: this.handleReblogClick, - icon: require('@tabler/icons/repeat.svg'), - }, { - text: intl.formatMessage(messages.quotePost), - action: this.handleQuoteClick, - icon: require('@tabler/icons/quote.svg'), - }]; - - const reblogButton = ( - - ); - - if (!status.in_reply_to_id) { - replyTitle = intl.formatMessage(messages.reply); - } else { - replyTitle = intl.formatMessage(messages.replyAll); - } - - const canShare = ('share' in navigator) && status.visibility === 'public'; - - return ( -
- - - {(features.quotePosts && me) ? ( - - {reblogButton} - - ) : ( - reblogButton - )} - - {features.emojiReacts ? ( - - - - ) : ( - - )} - - {canShare && ( - - )} - - - - -
- ); - } - -} - -const mapStateToProps = (state: RootState) => { - const { me, instance } = state; - const account = state.accounts.get(me); - - return { - me, - isStaff: account ? account.staff : false, - isAdmin: account ? account.admin : false, - features: getFeatures(instance), }; + + const publicStatus = ['public', 'unlisted'].includes(status.visibility); + + const replyCount = status.replies_count; + const reblogCount = status.reblogs_count; + const favouriteCount = status.favourites_count; + + const emojiReactCount = reduceEmoji( + (status.pleroma.get('emoji_reactions') || ImmutableList()) as ImmutableList, + favouriteCount, + status.favourited, + allowedEmoji, + ).reduce((acc, cur) => acc + cur.get('count'), 0); + + const meEmojiReact = getReactForStatus(status, allowedEmoji) as keyof typeof reactMessages | undefined; + + const reactMessages = { + '👍': messages.reactionLike, + '❤️': messages.reactionHeart, + '😆': messages.reactionLaughing, + '😮': messages.reactionOpenMouth, + '😢': messages.reactionCry, + '😩': messages.reactionWeary, + '': messages.favourite, + }; + + const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiReact || ''] || messages.favourite); + + const menu = _makeMenu(publicStatus); + let reblogIcon = require('@tabler/icons/repeat.svg'); + let replyTitle; + + if (status.visibility === 'direct') { + reblogIcon = require('@tabler/icons/mail.svg'); + } else if (status.visibility === 'private') { + reblogIcon = require('@tabler/icons/lock.svg'); + } + + const reblogMenu = [{ + text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog), + action: handleReblogClick, + icon: require('@tabler/icons/repeat.svg'), + }, { + text: intl.formatMessage(messages.quotePost), + action: handleQuoteClick, + icon: require('@tabler/icons/quote.svg'), + }]; + + const reblogButton = ( + + ); + + if (!status.in_reply_to_id) { + replyTitle = intl.formatMessage(messages.reply); + } else { + replyTitle = intl.formatMessage(messages.replyAll); + } + + const canShare = ('share' in navigator) && status.visibility === 'public'; + + return ( +
+ + + {(features.quotePosts && me) ? ( + + {reblogButton} + + ) : ( + reblogButton + )} + + {features.emojiReacts ? ( + + + + ) : ( + + )} + + {canShare && ( + + )} + + + + +
+ ); }; -const mapDispatchToProps = (dispatch: Dispatch, { status }: { status: Status}) => ({ - dispatch, - onOpenUnauthorizedModal(action: AnyAction) { - dispatch(openModal('UNAUTHORIZED', { - action, - ap_id: status.url, - })); - }, - onOpenReblogsModal(username: string, statusId: string) { - dispatch(openModal('REBLOGS', { - username, - statusId, - })); - }, -}); - -const WrappedComponent = withRouter(injectIntl(StatusActionBar)); -// @ts-ignore -export default connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true })(WrappedComponent); +export default StatusActionBar; From 33fbb0f147ccfbb3fe04b9107bcafe1f8002ee04 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 9 Aug 2022 14:34:08 -0500 Subject: [PATCH 11/32] StatusActionBar: move action code directly into component, clean up --- app/soapbox/components/status.tsx | 14 +- app/soapbox/components/status_action_bar.tsx | 219 ++++++++++++------- app/soapbox/features/status/index.tsx | 199 +---------------- 3 files changed, 150 insertions(+), 282 deletions(-) diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 92094a258..b4546336e 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -102,7 +102,6 @@ const Status: React.FC = (props) => { const node = useRef(null); const [showMedia, setShowMedia] = useState(defaultMediaVisibility(status, displayMedia)); - const [emojiSelectorFocused, setEmojiSelectorFocused] = useState(false); const actualStatus = getActualStatus(status); @@ -192,12 +191,7 @@ const Status: React.FC = (props) => { _expandEmojiSelector(); }; - const handleEmojiSelectorUnfocus = (): void => { - setEmojiSelectorFocused(false); - }; - const _expandEmojiSelector = (): void => { - setEmojiSelectorFocused(true); const firstEmoji: HTMLDivElement | null | undefined = node.current?.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); firstEmoji?.focus(); }; @@ -397,13 +391,7 @@ const Status: React.FC = (props) => { {quote} {!hideActionBar && ( - // @ts-ignore - + )}
diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx index 6bd69d74e..278bb3c3f 100644 --- a/app/soapbox/components/status_action_bar.tsx +++ b/app/soapbox/components/status_action_bar.tsx @@ -1,19 +1,26 @@ import { List as ImmutableList } from 'immutable'; import React from 'react'; -import { defineMessages, useIntl } from 'react-intl'; -import { useDispatch } from 'react-redux'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { useHistory } from 'react-router-dom'; +import { blockAccount } from 'soapbox/actions/accounts'; +import { showAlertForError } from 'soapbox/actions/alerts'; +import { launchChat } from 'soapbox/actions/chats'; +import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose'; +import { bookmark, favourite, pin, reblog, unbookmark, unfavourite, unpin, unreblog } from 'soapbox/actions/interactions'; import { openModal } from 'soapbox/actions/modals'; +import { deactivateUserModal, deleteStatusModal, deleteUserModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation'; +import { initMuteModal } from 'soapbox/actions/mutes'; +import { initReport } from 'soapbox/actions/reports'; +import { deleteStatus, editStatus, muteStatus, unmuteStatus } from 'soapbox/actions/statuses'; import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper'; import StatusActionButton from 'soapbox/components/status-action-button'; import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container'; -import { useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks'; import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji_reacts'; -import type { History } from 'history'; import type { Menu } from 'soapbox/components/dropdown_menu'; -import type { Status } from 'soapbox/types/entities'; +import type { Account, Status } from 'soapbox/types/entities'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -59,75 +66,43 @@ const messages = defineMessages({ reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' }, reactionWeary: { id: 'status.reactions.weary', defaultMessage: 'Weary' }, quotePost: { id: 'status.quote', defaultMessage: 'Quote post' }, + deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, + deleteHeading: { id: 'confirmations.delete.heading', defaultMessage: 'Delete post' }, + deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this post?' }, + redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, + redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.' }, + blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, + replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, + redraftHeading: { id: 'confirmations.redraft.heading', defaultMessage: 'Delete & redraft' }, + replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, }); interface IStatusActionBar { status: Status, - onReply: (status: Status) => void, - onFavourite: (status: Status) => void, - onEmojiReact: (status: Status, emoji: string) => void, - onBookmark: (status: Status) => void, - onReblog: (status: Status, e: React.MouseEvent) => void, - onQuote: (status: Status) => void, - onDelete: (status: Status, redraft?: boolean) => void, - onEdit: (status: Status) => void, - onDirect: (account: any) => void, - onChat: (account: any, history: History) => void, - onMention: (account: any) => void, - onMute: (account: any) => void, - onBlock: (status: Status) => void, - onReport: (status: Status) => void, - onEmbed: (status: Status) => void, - onDeactivateUser: (status: Status) => void, - onDeleteUser: (status: Status) => void, - onToggleStatusSensitivity: (status: Status) => void, - onDeleteStatus: (status: Status) => void, - onMuteConversation: (status: Status) => void, - onPin: (status: Status) => void, withDismiss?: boolean, - withGroupAdmin?: boolean, - allowedEmoji: ImmutableList, - emojiSelectorFocused: boolean, - handleEmojiSelectorUnfocus: () => void, - handleEmojiSelectorExpand?: React.EventHandler, } -const StatusActionBar: React.FC = ({ - status, - onReply, - onFavourite, - allowedEmoji, - onBookmark, - onReblog, - onQuote, - onDelete, - onEdit, - onPin, - onMention, - onDirect, - onChat, - onMute, - onBlock, - onEmbed, - onReport, - onMuteConversation, - onDeactivateUser, - onDeleteUser, - onDeleteStatus, - onToggleStatusSensitivity, - withDismiss, -}) => { +const StatusActionBar: React.FC = ({ status, withDismiss = false }) => { const intl = useIntl(); const history = useHistory(); - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const me = useAppSelector(state => state.me); const features = useFeatures(); + const settings = useSettings(); + const soapboxConfig = useSoapboxConfig(); + + const { allowedEmoji } = soapboxConfig; const account = useOwnAccount(); const isStaff = account ? account.staff : false; const isAdmin = account ? account.admin : false; + if (!status) { + return null; + } + const onOpenUnauthorizedModal = (action?: string) => { dispatch(openModal('UNAUTHORIZED', { action, @@ -137,7 +112,18 @@ const StatusActionBar: React.FC = ({ const handleReplyClick: React.MouseEventHandler = (e) => { if (me) { - onReply(status); + dispatch((_, getState) => { + const state = getState(); + if (state.compose.text.trim().length !== 0) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onConfirm: () => dispatch(replyCompose(status)), + })); + } else { + dispatch(replyCompose(status)); + } + }); } else { onOpenUnauthorizedModal('REPLY'); } @@ -156,7 +142,11 @@ const StatusActionBar: React.FC = ({ const handleFavouriteClick: React.EventHandler = (e) => { if (me) { - onFavourite(status); + if (status.get('favourited')) { + dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); + } } else { onOpenUnauthorizedModal('FAVOURITE'); } @@ -166,14 +156,32 @@ const StatusActionBar: React.FC = ({ const handleBookmarkClick: React.EventHandler = (e) => { e.stopPropagation(); - onBookmark(status); + + if (status.get('bookmarked')) { + dispatch(unbookmark(status)); + } else { + dispatch(bookmark(status)); + } + }; + + const modalReblog = () => { + if (status.get('reblogged')) { + dispatch(unreblog(status)); + } else { + dispatch(reblog(status)); + } }; const handleReblogClick: React.EventHandler = e => { e.stopPropagation(); if (me) { - onReblog(status, e); + const boostModal = settings.get('boostModal'); + if ((e && e.shiftKey) || !boostModal) { + modalReblog(); + } else { + dispatch(openModal('BOOST', { status, onReblog: modalReblog })); + } } else { onOpenUnauthorizedModal('REBLOG'); } @@ -183,54 +191,101 @@ const StatusActionBar: React.FC = ({ e.stopPropagation(); if (me) { - onQuote(status); + dispatch((_, getState) => { + const state = getState(); + if (state.compose.text.trim().length !== 0) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onConfirm: () => dispatch(quoteCompose(status)), + })); + } else { + dispatch(quoteCompose(status)); + } + }); } else { onOpenUnauthorizedModal('REBLOG'); } }; + const doDeleteStatus = (withRedraft = false) => { + dispatch((_, getState) => { + const deleteModal = settings.get('deleteModal'); + if (!deleteModal) { + dispatch(deleteStatus(status.id, withRedraft)); + } else { + dispatch(openModal('CONFIRM', { + icon: withRedraft ? require('@tabler/icons/edit.svg') : require('@tabler/icons/trash.svg'), + heading: intl.formatMessage(withRedraft ? messages.redraftHeading : messages.deleteHeading), + message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage), + confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm), + onConfirm: () => dispatch(deleteStatus(status.id, withRedraft)), + })); + } + }); + }; + const handleDeleteClick: React.EventHandler = (e) => { e.stopPropagation(); - onDelete(status); + doDeleteStatus(); }; const handleRedraftClick: React.EventHandler = (e) => { e.stopPropagation(); - onDelete(status, true); + doDeleteStatus(true); }; const handleEditClick: React.EventHandler = () => { - onEdit(status); + dispatch(editStatus(status.id)); }; const handlePinClick: React.EventHandler = (e) => { e.stopPropagation(); - onPin(status); + + if (status.get('pinned')) { + dispatch(unpin(status)); + } else { + dispatch(pin(status)); + } }; const handleMentionClick: React.EventHandler = (e) => { e.stopPropagation(); - onMention(status.account); + dispatch(mentionCompose(status.account as Account)); }; const handleDirectClick: React.EventHandler = (e) => { e.stopPropagation(); - onDirect(status.account); + dispatch(directCompose(status.account as Account)); }; const handleChatClick: React.EventHandler = (e) => { e.stopPropagation(); - onChat(status.account, history); + const account = status.account as Account; + dispatch(launchChat(account.id, history)); }; const handleMuteClick: React.EventHandler = (e) => { e.stopPropagation(); - onMute(status.account); + dispatch(initMuteModal(status.account as Account)); }; const handleBlockClick: React.EventHandler = (e) => { e.stopPropagation(); - onBlock(status); + + const account = status.get('account') as Account; + dispatch(openModal('CONFIRM', { + icon: require('@tabler/icons/ban.svg'), + heading: , + message: @{account.get('acct')} }} />, + confirm: intl.formatMessage(messages.blockConfirm), + onConfirm: () => dispatch(blockAccount(account.id)), + secondary: intl.formatMessage(messages.blockAndReport), + onSecondary: () => { + dispatch(blockAccount(account.id)); + dispatch(initReport(account, status)); + }, + })); }; const handleOpen: React.EventHandler = (e) => { @@ -239,17 +294,25 @@ const StatusActionBar: React.FC = ({ }; const handleEmbed = () => { - onEmbed(status); + dispatch(openModal('EMBED', { + url: status.get('url'), + onError: (error: any) => dispatch(showAlertForError(error)), + })); }; const handleReport: React.EventHandler = (e) => { e.stopPropagation(); - onReport(status); + dispatch(initReport(status.account as Account, status)); }; const handleConversationMuteClick: React.EventHandler = (e) => { e.stopPropagation(); - onMuteConversation(status); + + if (status.get('muted')) { + dispatch(unmuteStatus(status.id)); + } else { + dispatch(muteStatus(status.id)); + } }; const handleCopy: React.EventHandler = (e) => { @@ -275,22 +338,22 @@ const StatusActionBar: React.FC = ({ const handleDeactivateUser: React.EventHandler = (e) => { e.stopPropagation(); - onDeactivateUser(status); + dispatch(deactivateUserModal(intl, status.getIn(['account', 'id']) as string)); }; const handleDeleteUser: React.EventHandler = (e) => { e.stopPropagation(); - onDeleteUser(status); + dispatch(deleteUserModal(intl, status.getIn(['account', 'id']) as string)); }; const handleDeleteStatus: React.EventHandler = (e) => { e.stopPropagation(); - onDeleteStatus(status); + dispatch(deleteStatusModal(intl, status.id)); }; const handleToggleStatusSensitivity: React.EventHandler = (e) => { e.stopPropagation(); - onToggleStatusSensitivity(status); + dispatch(toggleStatusSensitivityModal(intl, status.id, status.sensitive)); }; const _makeMenu = (publicStatus: boolean) => { diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index 1f8f3d4e0..42e4e82bd 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -3,46 +3,25 @@ import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immuta import { debounce } from 'lodash'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { HotKeys } from 'react-hotkeys'; -import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { defineMessages, useIntl } from 'react-intl'; import { useHistory } from 'react-router-dom'; import { createSelector } from 'reselect'; -import { blockAccount } from 'soapbox/actions/accounts'; -import { launchChat } from 'soapbox/actions/chats'; import { replyCompose, mentionCompose, - directCompose, - quoteCompose, } from 'soapbox/actions/compose'; -import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts'; import { favourite, unfavourite, reblog, unreblog, - bookmark, - unbookmark, - pin, - unpin, } from 'soapbox/actions/interactions'; import { openModal } from 'soapbox/actions/modals'; -import { - deactivateUserModal, - deleteUserModal, - deleteStatusModal, - toggleStatusSensitivityModal, -} from 'soapbox/actions/moderation'; -import { initMuteModal } from 'soapbox/actions/mutes'; -import { initReport } from 'soapbox/actions/reports'; import { getSettings } from 'soapbox/actions/settings'; import { - muteStatus, - unmuteStatus, - deleteStatus, hideStatus, revealStatus, - editStatus, fetchStatusWithContext, fetchNext, } from 'soapbox/actions/statuses'; @@ -55,7 +34,7 @@ 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 { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks'; import { makeGetStatus } from 'soapbox/selectors'; import { defaultMediaVisibility, textForScreenReader } from 'soapbox/utils/status'; @@ -63,7 +42,6 @@ import DetailedStatus from './components/detailed-status'; import ThreadLoginCta from './components/thread-login-cta'; import ThreadStatus from './components/thread-status'; -import type { History } from 'history'; import type { VirtuosoHandle } from 'react-virtuoso'; import type { RootState } from 'soapbox/store'; import type { @@ -153,12 +131,10 @@ const Thread: React.FC = (props) => { 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 => { @@ -181,7 +157,6 @@ const Thread: React.FC = (props) => { }); const [showMedia, setShowMedia] = useState(defaultMediaVisibility(status, displayMedia)); - const [emojiSelectorFocused, setEmojiSelectorFocused] = useState(false); const [isLoaded, setIsLoaded] = useState(!!status); const [next, setNext] = useState(); @@ -210,8 +185,11 @@ const Thread: React.FC = (props) => { setShowMedia(!showMedia); }; - const handleEmojiReactClick = (status: StatusEntity, emoji: string) => { - dispatch(simpleEmojiReact(status, emoji)); + const handleHotkeyReact = () => { + if (statusRef.current) { + const firstEmoji: HTMLButtonElement | null = statusRef.current.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); + firstEmoji?.focus(); + } }; const handleFavouriteClick = (status: StatusEntity) => { @@ -222,22 +200,6 @@ const Thread: React.FC = (props) => { } }; - const handlePin = (status: StatusEntity) => { - if (status.pinned) { - dispatch(unpin(status)); - } else { - dispatch(pin(status)); - } - }; - - const handleBookmark = (status: StatusEntity) => { - if (status.bookmarked) { - dispatch(unbookmark(status)); - } else { - dispatch(bookmark(status)); - } - }; - const handleReplyClick = (status: StatusEntity) => { if (askReplyConfirmation) { dispatch(openModal('CONFIRM', { @@ -269,47 +231,6 @@ const Thread: React.FC = (props) => { }); }; - const handleQuoteClick = (status: StatusEntity) => { - if (askReplyConfirmation) { - dispatch(openModal('CONFIRM', { - message: intl.formatMessage(messages.replyMessage), - confirm: intl.formatMessage(messages.replyConfirm), - onConfirm: () => dispatch(quoteCompose(status)), - })); - } else { - dispatch(quoteCompose(status)); - } - }; - - const handleDeleteClick = (status: StatusEntity, withRedraft = false) => { - dispatch((_, getState) => { - const deleteModal = getSettings(getState()).get('deleteModal'); - if (!deleteModal) { - dispatch(deleteStatus(status.id, withRedraft)); - } else { - dispatch(openModal('CONFIRM', { - icon: withRedraft ? require('@tabler/icons/edit.svg') : require('@tabler/icons/trash.svg'), - heading: intl.formatMessage(withRedraft ? messages.redraftHeading : messages.deleteHeading), - message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage), - confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm), - onConfirm: () => dispatch(deleteStatus(status.id, withRedraft)), - })); - } - }); - }; - - const handleEditClick = (status: StatusEntity) => { - dispatch(editStatus(status.id)); - }; - - const handleDirectClick = (account: AccountEntity) => { - dispatch(directCompose(account)); - }; - - const handleChatClick = (account: AccountEntity, router: History) => { - dispatch(launchChat(account.id, router)); - }; - const handleMentionClick = (account: AccountEntity) => { dispatch(mentionCompose(account)); }; @@ -337,18 +258,6 @@ const Thread: React.FC = (props) => { } }; - const handleMuteClick = (account: AccountEntity) => { - dispatch(initMuteModal(account)); - }; - - const handleConversationMuteClick = (status: StatusEntity) => { - if (status.muted) { - dispatch(unmuteStatus(status.id)); - } else { - dispatch(muteStatus(status.id)); - } - }; - const handleToggleHidden = (status: StatusEntity) => { if (status.hidden) { dispatch(revealStatus(status.id)); @@ -357,48 +266,6 @@ const Thread: React.FC = (props) => { } }; - const handleBlockClick = (status: StatusEntity) => { - const { account } = status; - if (!account || typeof account !== 'object') return; - - dispatch(openModal('CONFIRM', { - icon: require('@tabler/icons/ban.svg'), - heading: , - message: @{account.acct} }} />, - confirm: intl.formatMessage(messages.blockConfirm), - onConfirm: () => dispatch(blockAccount(account.id)), - secondary: intl.formatMessage(messages.blockAndReport), - onSecondary: () => { - dispatch(blockAccount(account.id)); - dispatch(initReport(account, status)); - }, - })); - }; - - 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); }; @@ -439,10 +306,6 @@ const Thread: React.FC = (props) => { handleToggleMediaVisibility(); }; - const handleHotkeyReact = () => { - _expandEmojiSelector(); - }; - const handleMoveUp = (id: string) => { if (id === status?.id) { _selectChild(ancestorsIds.size - 1); @@ -473,25 +336,6 @@ const Thread: React.FC = (props) => { } }; - const handleEmojiSelectorExpand: React.EventHandler = e => { - if (e.key === 'Enter') { - _expandEmojiSelector(); - } - e.preventDefault(); - }; - - const handleEmojiSelectorUnfocus = () => { - 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, @@ -640,34 +484,7 @@ const Thread: React.FC = (props) => {
- +
From 522eba4b2563172ab2af1ad044082fdc13676466 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 9 Aug 2022 17:05:16 -0500 Subject: [PATCH 12/32] StatusActionBar: create toggle actions for status interactions --- app/soapbox/actions/interactions.ts | 41 ++++++++++++++++++++ app/soapbox/actions/statuses.ts | 12 +++++- app/soapbox/components/status_action_bar.tsx | 37 ++++-------------- 3 files changed, 59 insertions(+), 31 deletions(-) diff --git a/app/soapbox/actions/interactions.ts b/app/soapbox/actions/interactions.ts index ff449e03c..bfefb41f6 100644 --- a/app/soapbox/actions/interactions.ts +++ b/app/soapbox/actions/interactions.ts @@ -94,6 +94,15 @@ const unreblog = (status: StatusEntity) => }); }; +const toggleReblog = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (status.reblogged) { + dispatch(unreblog(status)); + } else { + dispatch(reblog(status)); + } + }; + const reblogRequest = (status: StatusEntity) => ({ type: REBLOG_REQUEST, status: status, @@ -158,6 +167,16 @@ const unfavourite = (status: StatusEntity) => }); }; +const toggleFavourite = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (status.favourited) { + dispatch(favourite(status)); + } else { + dispatch(unfavourite(status)); + } + }; + + const favouriteRequest = (status: StatusEntity) => ({ type: FAVOURITE_REQUEST, status: status, @@ -222,6 +241,15 @@ const unbookmark = (status: StatusEntity) => }); }; +const toggleBookmark = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (status.bookmarked) { + dispatch(unbookmark(status)); + } else { + dispatch(bookmark(status)); + } + }; + const bookmarkRequest = (status: StatusEntity) => ({ type: BOOKMARK_REQUEST, status: status, @@ -394,6 +422,15 @@ const unpin = (status: StatusEntity) => }); }; +const togglePin = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (status.pinned) { + dispatch(unpin(status)); + } else { + dispatch(pin(status)); + } + }; + const unpinRequest = (status: StatusEntity) => ({ type: UNPIN_REQUEST, status, @@ -488,6 +525,7 @@ export { REMOTE_INTERACTION_FAIL, reblog, unreblog, + toggleReblog, reblogRequest, reblogSuccess, reblogFail, @@ -496,6 +534,7 @@ export { unreblogFail, favourite, unfavourite, + toggleFavourite, favouriteRequest, favouriteSuccess, favouriteFail, @@ -504,6 +543,7 @@ export { unfavouriteFail, bookmark, unbookmark, + toggleBookmark, bookmarkRequest, bookmarkSuccess, bookmarkFail, @@ -530,6 +570,7 @@ export { unpinRequest, unpinSuccess, unpinFail, + togglePin, remoteInteraction, remoteInteractionRequest, remoteInteractionSuccess, diff --git a/app/soapbox/actions/statuses.ts b/app/soapbox/actions/statuses.ts index 9df7f207b..b81dd7ce2 100644 --- a/app/soapbox/actions/statuses.ts +++ b/app/soapbox/actions/statuses.ts @@ -10,7 +10,7 @@ import { openModal } from './modals'; import { deleteFromTimelines } from './timelines'; import type { AppDispatch, RootState } from 'soapbox/store'; -import type { APIEntity } from 'soapbox/types/entities'; +import type { APIEntity, Status } from 'soapbox/types/entities'; const STATUS_CREATE_REQUEST = 'STATUS_CREATE_REQUEST'; const STATUS_CREATE_SUCCESS = 'STATUS_CREATE_SUCCESS'; @@ -266,6 +266,15 @@ const unmuteStatus = (id: string) => }); }; +const toggleMuteStatus = (status: Status) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (status.muted) { + dispatch(unmuteStatus(status.id)); + } else { + dispatch(muteStatus(status.id)); + } + }; + const hideStatus = (ids: string[] | string) => { if (!Array.isArray(ids)) { ids = [ids]; @@ -324,6 +333,7 @@ export { fetchStatusWithContext, muteStatus, unmuteStatus, + toggleMuteStatus, hideStatus, revealStatus, }; diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx index 278bb3c3f..412303417 100644 --- a/app/soapbox/components/status_action_bar.tsx +++ b/app/soapbox/components/status_action_bar.tsx @@ -7,12 +7,12 @@ import { blockAccount } from 'soapbox/actions/accounts'; import { showAlertForError } from 'soapbox/actions/alerts'; import { launchChat } from 'soapbox/actions/chats'; import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose'; -import { bookmark, favourite, pin, reblog, unbookmark, unfavourite, unpin, unreblog } from 'soapbox/actions/interactions'; +import { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions'; import { openModal } from 'soapbox/actions/modals'; import { deactivateUserModal, deleteStatusModal, deleteUserModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation'; import { initMuteModal } from 'soapbox/actions/mutes'; import { initReport } from 'soapbox/actions/reports'; -import { deleteStatus, editStatus, muteStatus, unmuteStatus } from 'soapbox/actions/statuses'; +import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses'; import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper'; import StatusActionButton from 'soapbox/components/status-action-button'; import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container'; @@ -142,11 +142,7 @@ const StatusActionBar: React.FC = ({ status, withDismiss = fal const handleFavouriteClick: React.EventHandler = (e) => { if (me) { - if (status.get('favourited')) { - dispatch(unfavourite(status)); - } else { - dispatch(favourite(status)); - } + toggleFavourite(status); } else { onOpenUnauthorizedModal('FAVOURITE'); } @@ -156,20 +152,11 @@ const StatusActionBar: React.FC = ({ status, withDismiss = fal const handleBookmarkClick: React.EventHandler = (e) => { e.stopPropagation(); - - if (status.get('bookmarked')) { - dispatch(unbookmark(status)); - } else { - dispatch(bookmark(status)); - } + dispatch(toggleBookmark(status)); }; const modalReblog = () => { - if (status.get('reblogged')) { - dispatch(unreblog(status)); - } else { - dispatch(reblog(status)); - } + dispatch(toggleReblog(status)); }; const handleReblogClick: React.EventHandler = e => { @@ -241,12 +228,7 @@ const StatusActionBar: React.FC = ({ status, withDismiss = fal const handlePinClick: React.EventHandler = (e) => { e.stopPropagation(); - - if (status.get('pinned')) { - dispatch(unpin(status)); - } else { - dispatch(pin(status)); - } + dispatch(togglePin(status)); }; const handleMentionClick: React.EventHandler = (e) => { @@ -307,12 +289,7 @@ const StatusActionBar: React.FC = ({ status, withDismiss = fal const handleConversationMuteClick: React.EventHandler = (e) => { e.stopPropagation(); - - if (status.get('muted')) { - dispatch(unmuteStatus(status.id)); - } else { - dispatch(muteStatus(status.id)); - } + dispatch(toggleMuteStatus(status)); }; const handleCopy: React.EventHandler = (e) => { From 4c7491d81d492d92edeeff2dfb750e6f32f9d0fc Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 9 Aug 2022 17:45:01 -0500 Subject: [PATCH 13/32] Strip down StatusContainer, offload actions into Status component itself --- app/soapbox/actions/compose.ts | 17 ++ app/soapbox/actions/statuses.ts | 9 + app/soapbox/components/status.tsx | 94 +++---- app/soapbox/components/status_action_bar.tsx | 5 +- app/soapbox/containers/status_container.js | 270 ------------------- app/soapbox/containers/status_container.tsx | 37 +++ 6 files changed, 102 insertions(+), 330 deletions(-) delete mode 100644 app/soapbox/containers/status_container.js create mode 100644 app/soapbox/containers/status_container.tsx diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts index ad8439307..f33a9fa18 100644 --- a/app/soapbox/actions/compose.ts +++ b/app/soapbox/actions/compose.ts @@ -96,6 +96,8 @@ const messages = defineMessages({ uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, view: { id: 'snackbar.view', defaultMessage: 'View' }, + replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, + replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, }); const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1); @@ -144,6 +146,20 @@ const replyCompose = (status: Status) => dispatch(openModal('COMPOSE')); }; +const replyComposeWithConfirmation = (status: Status, intl: IntlShape) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + if (state.compose.text.trim().length !== 0) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onConfirm: () => dispatch(replyCompose(status)), + })); + } else { + dispatch(replyCompose(status)); + } + }; + const cancelReplyCompose = () => ({ type: COMPOSE_REPLY_CANCEL, }); @@ -739,6 +755,7 @@ export { setComposeToStatus, changeCompose, replyCompose, + replyComposeWithConfirmation, cancelReplyCompose, quoteCompose, cancelQuoteCompose, diff --git a/app/soapbox/actions/statuses.ts b/app/soapbox/actions/statuses.ts index b81dd7ce2..db15e7a21 100644 --- a/app/soapbox/actions/statuses.ts +++ b/app/soapbox/actions/statuses.ts @@ -297,6 +297,14 @@ const revealStatus = (ids: string[] | string) => { }; }; +const toggleStatusHidden = (status: Status) => { + if (status.hidden) { + return revealStatus(status.id); + } else { + return hideStatus(status.id); + } +}; + export { STATUS_CREATE_REQUEST, STATUS_CREATE_SUCCESS, @@ -336,4 +344,5 @@ export { toggleMuteStatus, hideStatus, revealStatus, + toggleStatusHidden, }; diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index b4546336e..4c62766c8 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -4,9 +4,14 @@ import { HotKeys } from 'react-hotkeys'; import { useIntl, FormattedMessage, defineMessages } from 'react-intl'; import { NavLink, useHistory } from 'react-router-dom'; +import { mentionCompose, replyComposeWithConfirmation } from 'soapbox/actions/compose'; +import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions'; +import { openModal } from 'soapbox/actions/modals'; +import { toggleStatusHidden } from 'soapbox/actions/statuses'; import Icon from 'soapbox/components/icon'; import AccountContainer from 'soapbox/containers/account_container'; import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container'; +import { useAppDispatch, useSettings } from 'soapbox/hooks'; import { defaultMediaVisibility, textForScreenReader, getActualStatus } from 'soapbox/utils/status'; import StatusMedia from './status-media'; @@ -15,10 +20,9 @@ import StatusActionBar from './status_action_bar'; import StatusContent from './status_content'; import { HStack, Text } from './ui'; -import type { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import type { Map as ImmutableMap } from 'immutable'; import type { Account as AccountEntity, - Attachment as AttachmentEntity, Status as StatusEntity, } from 'soapbox/types/entities'; @@ -29,44 +33,18 @@ const messages = defineMessages({ reblogged_by: { id: 'status.reblogged_by', defaultMessage: '{name} reposted' }, }); -interface IStatus { +export interface IStatus { id?: string, - contextType?: string, status: StatusEntity, - account: AccountEntity, - otherAccounts: ImmutableList, - onClick: () => void, - onReply: (status: StatusEntity) => void, - onFavourite: (status: StatusEntity) => void, - onReblog: (status: StatusEntity, e?: KeyboardEvent) => void, - onQuote: (status: StatusEntity) => void, - onDelete: (status: StatusEntity) => void, - onEdit: (status: StatusEntity) => void, - onDirect: (status: StatusEntity) => void, - onChat: (status: StatusEntity) => void, - onMention: (account: StatusEntity['account']) => 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?: boolean) => void, - onMoveDown: (statusId: string, featured?: boolean) => void, - getScrollPosition?: () => ScrollPosition | undefined, - updateScrollBottom?: (bottom: number) => void, - group: ImmutableMap, - displayMedia: string, - allowedEmoji: ImmutableList, - focusable: boolean, + onClick?: () => void, + muted?: boolean, + hidden?: boolean, + unread?: boolean, + onMoveUp?: (statusId: string, featured?: boolean) => void, + onMoveDown?: (statusId: string, featured?: boolean) => void, + group?: ImmutableMap, + focusable?: boolean, featured?: boolean, - withDismiss?: boolean, hideActionBar?: boolean, hoverable?: boolean, } @@ -76,15 +54,7 @@ const Status: React.FC = (props) => { status, focusable = true, hoverable = true, - onToggleHidden, - displayMedia, - onOpenMedia, - onOpenVideo, onClick, - onReply, - onFavourite, - onReblog, - onMention, onMoveUp, onMoveDown, muted, @@ -94,10 +64,12 @@ const Status: React.FC = (props) => { group, hideActionBar, } = 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); @@ -127,7 +99,7 @@ const Status: React.FC = (props) => { }; const handleExpandedToggle = (): void => { - onToggleHidden(actualStatus); + dispatch(toggleStatusHidden(actualStatus)); }; const handleHotkeyOpenMedia = (e?: KeyboardEvent): void => { @@ -138,29 +110,35 @@ const Status: React.FC = (props) => { if (firstAttachment) { if (firstAttachment.type === 'video') { - onOpenVideo(firstAttachment, 0); + dispatch(openModal('VIDEO', { media: firstAttachment, time: 0 })); } else { - onOpenMedia(status.media_attachments, 0); + dispatch(openModal('MEDIA', { media: status.media_attachments, index: 0 })); } } }; const handleHotkeyReply = (e?: KeyboardEvent): void => { e?.preventDefault(); - onReply(actualStatus); + dispatch(replyComposeWithConfirmation(actualStatus, intl)); }; const handleHotkeyFavourite = (): void => { - onFavourite(actualStatus); + toggleFavourite(actualStatus); }; const handleHotkeyBoost = (e?: KeyboardEvent): void => { - onReblog(actualStatus, e); + 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(); - onMention(actualStatus.account); + dispatch(mentionCompose(actualStatus.account as AccountEntity)); }; const handleHotkeyOpen = (): void => { @@ -172,15 +150,19 @@ const Status: React.FC = (props) => { }; const handleHotkeyMoveUp = (e?: KeyboardEvent): void => { - onMoveUp(status.id, featured); + if (onMoveUp) { + onMoveUp(status.id, featured); + } }; const handleHotkeyMoveDown = (e?: KeyboardEvent): void => { - onMoveDown(status.id, featured); + if (onMoveDown) { + onMoveDown(status.id, featured); + } }; const handleHotkeyToggleHidden = (): void => { - onToggleHidden(actualStatus); + dispatch(toggleStatusHidden(actualStatus)); }; const handleHotkeyToggleSensitive = (): void => { diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx index 412303417..d8c27e3a3 100644 --- a/app/soapbox/components/status_action_bar.tsx +++ b/app/soapbox/components/status_action_bar.tsx @@ -155,14 +155,11 @@ const StatusActionBar: React.FC = ({ status, withDismiss = fal dispatch(toggleBookmark(status)); }; - const modalReblog = () => { - dispatch(toggleReblog(status)); - }; - const handleReblogClick: React.EventHandler = e => { e.stopPropagation(); if (me) { + const modalReblog = () => dispatch(toggleReblog(status)); const boostModal = settings.get('boostModal'); if ((e && e.shiftKey) || !boostModal) { modalReblog(); diff --git a/app/soapbox/containers/status_container.js b/app/soapbox/containers/status_container.js deleted file mode 100644 index 987774aa7..000000000 --- a/app/soapbox/containers/status_container.js +++ /dev/null @@ -1,270 +0,0 @@ -import React from 'react'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { launchChat } from 'soapbox/actions/chats'; -import { deactivateUserModal, deleteUserModal, deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation'; -import { getSoapboxConfig } from 'soapbox/actions/soapbox'; - -import { blockAccount } from '../actions/accounts'; -import { showAlertForError } from '../actions/alerts'; -import { - replyCompose, - mentionCompose, - directCompose, - quoteCompose, -} from '../actions/compose'; -import { - createRemovedAccount, - groupRemoveStatus, -} from '../actions/groups'; -import { - reblog, - favourite, - unreblog, - unfavourite, - bookmark, - unbookmark, - pin, - unpin, -} from '../actions/interactions'; -import { openModal } from '../actions/modals'; -import { initMuteModal } from '../actions/mutes'; -import { initReport } from '../actions/reports'; -import { getSettings } from '../actions/settings'; -import { - muteStatus, - unmuteStatus, - deleteStatus, - hideStatus, - revealStatus, - editStatus, -} from '../actions/statuses'; -import Status from '../components/status'; -import { makeGetStatus } from '../selectors'; - -const messages = defineMessages({ - deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, - deleteHeading: { id: 'confirmations.delete.heading', defaultMessage: 'Delete post' }, - deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this post?' }, - redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, - redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.' }, - blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, - replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, - redraftHeading: { id: 'confirmations.redraft.heading', defaultMessage: 'Delete & redraft' }, - replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, - blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, -}); - -const makeMapStateToProps = () => { - const getStatus = makeGetStatus(); - - const mapStateToProps = (state, props) => { - const soapbox = getSoapboxConfig(state); - - return { - status: getStatus(state, props), - displayMedia: getSettings(state).get('displayMedia'), - allowedEmoji: soapbox.get('allowedEmoji'), - }; - }; - - return mapStateToProps; -}; - -const mapDispatchToProps = (dispatch, { intl }) => { - function onModalReblog(status) { - if (status.get('reblogged')) { - dispatch(unreblog(status)); - } else { - dispatch(reblog(status)); - } - } - - return { - onReply(status) { - dispatch((_, getState) => { - const state = getState(); - if (state.getIn(['compose', 'text']).trim().length !== 0) { - dispatch(openModal('CONFIRM', { - message: intl.formatMessage(messages.replyMessage), - confirm: intl.formatMessage(messages.replyConfirm), - onConfirm: () => dispatch(replyCompose(status)), - })); - } else { - dispatch(replyCompose(status)); - } - }); - }, - - onModalReblog, - - onReblog(status, e) { - dispatch((_, getState) => { - const boostModal = getSettings(getState()).get('boostModal'); - if ((e && e.shiftKey) || !boostModal) { - onModalReblog(status); - } else { - dispatch(openModal('BOOST', { status, onReblog: onModalReblog })); - } - }); - }, - - onQuote(status) { - dispatch((_, getState) => { - const state = getState(); - if (state.getIn(['compose', 'text']).trim().length !== 0) { - dispatch(openModal('CONFIRM', { - message: intl.formatMessage(messages.replyMessage), - confirm: intl.formatMessage(messages.replyConfirm), - onConfirm: () => dispatch(quoteCompose(status)), - })); - } else { - dispatch(quoteCompose(status)); - } - }); - }, - - onFavourite(status) { - if (status.get('favourited')) { - dispatch(unfavourite(status)); - } else { - dispatch(favourite(status)); - } - }, - - onBookmark(status) { - if (status.get('bookmarked')) { - dispatch(unbookmark(status)); - } else { - dispatch(bookmark(status)); - } - }, - - onPin(status) { - if (status.get('pinned')) { - dispatch(unpin(status)); - } else { - dispatch(pin(status)); - } - }, - - onEmbed(status) { - dispatch(openModal('EMBED', { - url: status.get('url'), - onError: error => dispatch(showAlertForError(error)), - })); - }, - - onDelete(status, withRedraft = false) { - dispatch((_, getState) => { - const deleteModal = getSettings(getState()).get('deleteModal'); - if (!deleteModal) { - dispatch(deleteStatus(status.get('id'), withRedraft)); - } else { - dispatch(openModal('CONFIRM', { - icon: withRedraft ? require('@tabler/icons/edit.svg') : require('@tabler/icons/trash.svg'), - heading: intl.formatMessage(withRedraft ? messages.redraftHeading : messages.deleteHeading), - message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage), - confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm), - onConfirm: () => dispatch(deleteStatus(status.get('id'), withRedraft)), - })); - } - }); - }, - - onEdit(status) { - dispatch(editStatus(status.get('id'))); - }, - - onDirect(account) { - dispatch(directCompose(account)); - }, - - onChat(account, router) { - dispatch(launchChat(account.get('id'), router)); - }, - - onMention(account) { - dispatch(mentionCompose(account)); - }, - - onOpenMedia(media, index) { - dispatch(openModal('MEDIA', { media, index })); - }, - - onOpenVideo(media, time) { - dispatch(openModal('VIDEO', { media, time })); - }, - - onOpenAudio(media, time) { - dispatch(openModal('AUDIO', { media, time })); - }, - - onBlock(status) { - const account = status.get('account'); - dispatch(openModal('CONFIRM', { - icon: require('@tabler/icons/ban.svg'), - heading: , - message: @{account.get('acct')} }} />, - confirm: intl.formatMessage(messages.blockConfirm), - onConfirm: () => dispatch(blockAccount(account.get('id'))), - secondary: intl.formatMessage(messages.blockAndReport), - onSecondary: () => { - dispatch(blockAccount(account.get('id'))); - dispatch(initReport(account, status)); - }, - })); - }, - - onReport(status) { - dispatch(initReport(status.get('account'), status)); - }, - - onMute(account) { - dispatch(initMuteModal(account)); - }, - - onMuteConversation(status) { - if (status.get('muted')) { - dispatch(unmuteStatus(status.get('id'))); - } else { - dispatch(muteStatus(status.get('id'))); - } - }, - - onToggleHidden(status) { - if (status.get('hidden')) { - dispatch(revealStatus(status.get('id'))); - } else { - dispatch(hideStatus(status.get('id'))); - } - }, - - onGroupRemoveAccount(groupId, accountId) { - dispatch(createRemovedAccount(groupId, accountId)); - }, - - onGroupRemoveStatus(groupId, statusId) { - dispatch(groupRemoveStatus(groupId, statusId)); - }, - - onDeactivateUser(status) { - dispatch(deactivateUserModal(intl, status.getIn(['account', 'id']))); - }, - - onDeleteUser(status) { - dispatch(deleteUserModal(intl, status.getIn(['account', 'id']))); - }, - - onDeleteStatus(status) { - dispatch(deleteStatusModal(intl, status.get('id'))); - }, - - onToggleStatusSensitivity(status) { - dispatch(toggleStatusSensitivityModal(intl, status.get('id'), status.get('sensitive'))); - }, - }; -}; - -export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); diff --git a/app/soapbox/containers/status_container.tsx b/app/soapbox/containers/status_container.tsx new file mode 100644 index 000000000..e5ac5014d --- /dev/null +++ b/app/soapbox/containers/status_container.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import Status, { IStatus } from 'soapbox/components/status'; +import { useAppSelector } from 'soapbox/hooks'; +import { makeGetStatus } from 'soapbox/selectors'; + +interface IStatusContainer extends Omit { + id: string, + /** @deprecated Unused. */ + contextType?: any, + /** @deprecated Unused. */ + otherAccounts?: any, + /** @deprecated Unused. */ + withDismiss?: any, + /** @deprecated Unused. */ + getScrollPosition?: any, + /** @deprecated Unused. */ + updateScrollBottom?: any, +} + +const getStatus = makeGetStatus(); + +/** + * Legacy Status wrapper accepting a status ID instead of the full entity. + * @deprecated Use the Status component directly. + */ +const StatusContainer: React.FC = ({ id }) => { + const status = useAppSelector(state => getStatus(state, { id })); + + if (status) { + return ; + } else { + return null; + } +}; + +export default StatusContainer; From 518a8132350e06ef9de64fdececa96446ca18b29 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 9 Aug 2022 18:22:53 -0500 Subject: [PATCH 14/32] status_action_bar --> status-action-bar --- .../components/{status_action_bar.tsx => status-action-bar.tsx} | 0 app/soapbox/components/status.tsx | 2 +- app/soapbox/features/status/index.tsx | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename app/soapbox/components/{status_action_bar.tsx => status-action-bar.tsx} (100%) diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status-action-bar.tsx similarity index 100% rename from app/soapbox/components/status_action_bar.tsx rename to app/soapbox/components/status-action-bar.tsx diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 4c62766c8..447de735f 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -16,7 +16,7 @@ import { defaultMediaVisibility, textForScreenReader, getActualStatus } from 'so import StatusMedia from './status-media'; import StatusReplyMentions from './status-reply-mentions'; -import StatusActionBar from './status_action_bar'; +import StatusActionBar from './status-action-bar'; import StatusContent from './status_content'; import { HStack, Text } from './ui'; diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index 42e4e82bd..29bbcd842 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -28,7 +28,7 @@ import { import MissingIndicator from 'soapbox/components/missing_indicator'; import PullToRefresh from 'soapbox/components/pull-to-refresh'; import ScrollableList from 'soapbox/components/scrollable_list'; -import StatusActionBar from 'soapbox/components/status_action_bar'; +import StatusActionBar from 'soapbox/components/status-action-bar'; import SubNavigation from 'soapbox/components/sub_navigation'; import Tombstone from 'soapbox/components/tombstone'; import { Column, Stack } from 'soapbox/components/ui'; From ca4821abf7c0db73b9596433453d16a7972aa222 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 9 Aug 2022 18:40:33 -0500 Subject: [PATCH 15/32] Nuke ActionBar component --- app/soapbox/components/status-action-bar.tsx | 25 +- .../components/status-action-button.tsx | 60 +- .../features/status/components/action-bar.tsx | 654 ------------------ app/soapbox/features/status/index.tsx | 2 +- 4 files changed, 60 insertions(+), 681 deletions(-) delete mode 100644 app/soapbox/features/status/components/action-bar.tsx diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index d8c27e3a3..76e4c5e83 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -81,9 +81,16 @@ const messages = defineMessages({ interface IStatusActionBar { status: Status, withDismiss?: boolean, + withLabels?: boolean, + expandable?: boolean } -const StatusActionBar: React.FC = ({ status, withDismiss = false }) => { +const StatusActionBar: React.FC = ({ + status, + withDismiss = false, + withLabels = false, + expandable = true, +}) => { const intl = useIntl(); const history = useHistory(); const dispatch = useAppDispatch(); @@ -337,11 +344,13 @@ const StatusActionBar: React.FC = ({ status, withDismiss = fal const menu: Menu = []; - menu.push({ - text: intl.formatMessage(messages.open), - action: handleOpen, - icon: require('@tabler/icons/arrows-vertical.svg'), - }); + if (expandable) { + menu.push({ + text: intl.formatMessage(messages.open), + action: handleOpen, + icon: require('@tabler/icons/arrows-vertical.svg'), + }); + } if (publicStatus) { menu.push({ @@ -562,6 +571,7 @@ const StatusActionBar: React.FC = ({ status, withDismiss = fal active={status.reblogged} onClick={handleReblogClick} count={reblogCount} + text={withLabels ? intl.formatMessage(messages.reblog) : undefined} /> ); @@ -580,6 +590,7 @@ const StatusActionBar: React.FC = ({ status, withDismiss = fal icon={require('@tabler/icons/message-circle-2.svg')} onClick={handleReplyClick} count={replyCount} + text={withLabels ? intl.formatMessage(messages.reply) : undefined} /> {(features.quotePosts && me) ? ( @@ -604,6 +615,7 @@ const StatusActionBar: React.FC = ({ status, withDismiss = fal active={Boolean(meEmojiReact)} count={emojiReactCount} emoji={meEmojiReact} + text={withLabels ? meEmojiTitle : undefined} /> ) : ( @@ -615,6 +627,7 @@ const StatusActionBar: React.FC = ({ status, withDismiss = fal onClick={handleFavouriteClick} active={Boolean(meEmojiReact)} count={favouriteCount} + text={withLabels ? meEmojiTitle : undefined} /> )} diff --git a/app/soapbox/components/status-action-button.tsx b/app/soapbox/components/status-action-button.tsx index 1a625b5a1..157271baf 100644 --- a/app/soapbox/components/status-action-button.tsx +++ b/app/soapbox/components/status-action-button.tsx @@ -32,10 +32,47 @@ interface IStatusActionButton extends React.ButtonHTMLAttributes((props, ref): JSX.Element => { - const { icon, className, iconClassName, active, color, filled = false, count = 0, emoji, ...filteredProps } = props; + const { icon, className, iconClassName, active, color, filled = false, count = 0, emoji, text, ...filteredProps } = props; + + const renderIcon = () => { + if (emoji) { + return ( + + + + ); + } else { + return ( + + ); + } + }; + + const renderText = () => { + if (text) { + return ( + + {text} + + ); + } else if (count) { + return ( + + ); + } + }; return ( ); }); diff --git a/app/soapbox/features/status/components/action-bar.tsx b/app/soapbox/features/status/components/action-bar.tsx deleted file mode 100644 index b0560693c..000000000 --- a/app/soapbox/features/status/components/action-bar.tsx +++ /dev/null @@ -1,654 +0,0 @@ -import classNames from 'classnames'; -import React from 'react'; -import { defineMessages, injectIntl, WrappedComponentProps as IntlComponentProps } from 'react-intl'; -import { connect } from 'react-redux'; -import { withRouter, RouteComponentProps } from 'react-router-dom'; - -import { openModal } from 'soapbox/actions/modals'; -import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper'; -import { HStack, IconButton, Emoji, Text } from 'soapbox/components/ui'; -import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container'; -import { isUserTouching } from 'soapbox/is_mobile'; -import { getReactForStatus } from 'soapbox/utils/emoji_reacts'; -import { getFeatures } from 'soapbox/utils/features'; - -import type { History } from 'history'; -import type { List as ImmutableList } from 'immutable'; -import type { AnyAction } from 'redux'; -import type { ThunkDispatch } from 'redux-thunk'; -import type { Menu } from 'soapbox/components/dropdown_menu'; -import type { RootState } from 'soapbox/store'; -import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities'; - -type Dispatch = ThunkDispatch; - -const messages = defineMessages({ - delete: { id: 'status.delete', defaultMessage: 'Delete' }, - redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, - edit: { id: 'status.edit', defaultMessage: 'Edit' }, - direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, - chat: { id: 'status.chat', defaultMessage: 'Chat with @{name}' }, - mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, - reply: { id: 'status.reply', defaultMessage: 'Reply' }, - reblog: { id: 'status.reblog', defaultMessage: 'Repost' }, - reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' }, - cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' }, - cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' }, - favourite: { id: 'status.favourite', defaultMessage: 'Like' }, - mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' }, - muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, - unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, - block: { id: 'status.block', defaultMessage: 'Block @{name}' }, - report: { id: 'status.report', defaultMessage: 'Report @{name}' }, - share: { id: 'status.share', defaultMessage: 'Share' }, - pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, - unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, - embed: { id: 'status.embed', defaultMessage: 'Embed' }, - admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, - admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' }, - copy: { id: 'status.copy', defaultMessage: 'Copy link to post' }, - bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, - unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' }, - deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' }, - deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' }, - deleteStatus: { id: 'admin.statuses.actions.delete_status', defaultMessage: 'Delete post' }, - markStatusSensitive: { id: 'admin.statuses.actions.mark_status_sensitive', defaultMessage: 'Mark post sensitive' }, - markStatusNotSensitive: { id: 'admin.statuses.actions.mark_status_not_sensitive', defaultMessage: 'Mark post not sensitive' }, - reactionLike: { id: 'status.reactions.like', defaultMessage: 'Like' }, - reactionHeart: { id: 'status.reactions.heart', defaultMessage: 'Love' }, - reactionLaughing: { id: 'status.reactions.laughing', defaultMessage: 'Haha' }, - reactionOpenMouth: { id: 'status.reactions.open_mouth', defaultMessage: 'Wow' }, - reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' }, - reactionWeary: { id: 'status.reactions.weary', defaultMessage: 'Weary' }, - emojiPickerExpand: { id: 'status.reactions_expand', defaultMessage: 'Select emoji' }, - more: { id: 'status.actions.more', defaultMessage: 'More' }, - quotePost: { id: 'status.quote', defaultMessage: 'Quote post' }, -}); - -const mapStateToProps = (state: RootState) => { - const me = state.me; - const account = state.accounts.get(me); - const instance = state.instance; - - return { - me, - isStaff: account ? account.staff : false, - isAdmin: account ? account.admin : false, - features: getFeatures(instance), - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch, { status }: OwnProps) => ({ - onOpenUnauthorizedModal(action: string) { - dispatch(openModal('UNAUTHORIZED', { - action, - ap_id: status.url, - })); - }, -}); - -interface OwnProps { - status: StatusEntity, - onReply: (status: StatusEntity) => void, - onReblog: (status: StatusEntity, e: React.MouseEvent) => void, - onQuote: (status: StatusEntity) => void, - onFavourite: (status: StatusEntity) => void, - onEmojiReact: (status: StatusEntity, emoji: string) => void, - onDelete: (status: StatusEntity, redraft?: boolean) => void, - onEdit: (status: StatusEntity) => void, - onBookmark: (status: StatusEntity) => void, - onDirect: (account: AccountEntity) => void, - onChat: (account: AccountEntity, history: History) => void, - onMention: (account: AccountEntity) => void, - onMute: (account: AccountEntity) => void, - onMuteConversation: (status: StatusEntity) => void, - onBlock: (status: StatusEntity) => void, - onReport: (status: StatusEntity) => void, - onPin: (status: StatusEntity) => void, - onEmbed: (status: StatusEntity) => void, - onDeactivateUser: (status: StatusEntity) => void, - onDeleteUser: (status: StatusEntity) => void, - onDeleteStatus: (status: StatusEntity) => void, - onToggleStatusSensitivity: (status: StatusEntity) => void, - allowedEmoji: ImmutableList, - emojiSelectorFocused: boolean, - handleEmojiSelectorExpand: React.EventHandler, - handleEmojiSelectorUnfocus: React.EventHandler, -} - -type StateProps = ReturnType; -type DispatchProps = ReturnType; - -type IActionBar = OwnProps & StateProps & DispatchProps & RouteComponentProps & IntlComponentProps; - -interface IActionBarState { - emojiSelectorVisible: boolean, - emojiSelectorFocused: boolean, -} - -class ActionBar extends React.PureComponent { - - static defaultProps: Partial = { - isStaff: false, - } - - state = { - emojiSelectorVisible: false, - emojiSelectorFocused: false, - } - - node: HTMLDivElement | null = null; - - handleReplyClick: React.EventHandler = (e) => { - const { me, onReply, onOpenUnauthorizedModal } = this.props; - e.preventDefault(); - - if (me) { - onReply(this.props.status); - } else { - onOpenUnauthorizedModal('REPLY'); - } - } - - handleReblogClick: React.EventHandler = (e) => { - const { me, onReblog, onOpenUnauthorizedModal, status } = this.props; - e.preventDefault(); - - if (me) { - onReblog(status, e); - } else { - onOpenUnauthorizedModal('REBLOG'); - } - } - - handleQuoteClick: React.EventHandler = () => { - const { me, onQuote, onOpenUnauthorizedModal, status } = this.props; - if (me) { - onQuote(status); - } else { - onOpenUnauthorizedModal('REBLOG'); - } - } - - handleBookmarkClick: React.EventHandler = () => { - this.props.onBookmark(this.props.status); - } - - handleFavouriteClick: React.EventHandler = (e) => { - const { me, onFavourite, onOpenUnauthorizedModal, status } = this.props; - - e.preventDefault(); - - if (me) { - onFavourite(status); - } else { - onOpenUnauthorizedModal('FAVOURITE'); - } - } - - handleLikeButtonHover: React.EventHandler = () => { - const { features } = this.props; - - if (features.emojiReacts && !isUserTouching()) { - this.setState({ emojiSelectorVisible: true }); - } - } - - handleLikeButtonLeave: React.EventHandler = () => { - const { features } = this.props; - - if (features.emojiReacts && !isUserTouching()) { - this.setState({ emojiSelectorVisible: false }); - } - } - - handleLikeButtonClick: React.EventHandler = e => { - const { features } = this.props; - const meEmojiReact = getReactForStatus(this.props.status, this.props.allowedEmoji) || '👍'; - - if (features.emojiReacts && isUserTouching()) { - if (this.state.emojiSelectorVisible) { - this.handleReactClick(meEmojiReact)(e); - } else { - this.setState({ emojiSelectorVisible: true }); - } - } else { - this.handleReactClick(meEmojiReact)(e); - } - } - - handleReactClick = (emoji: string): React.EventHandler => { - return () => { - const { me, onEmojiReact, onOpenUnauthorizedModal, status } = this.props; - if (me) { - onEmojiReact(status, emoji); - } else { - onOpenUnauthorizedModal('FAVOURITE'); - } - this.setState({ emojiSelectorVisible: false, emojiSelectorFocused: false }); - }; - } - - handleHotkeyEmoji = () => { - const { emojiSelectorVisible } = this.state; - - this.setState({ emojiSelectorVisible: !emojiSelectorVisible }); - } - - handleDeleteClick: React.EventHandler = () => { - this.props.onDelete(this.props.status); - } - - handleRedraftClick: React.EventHandler = () => { - this.props.onDelete(this.props.status, true); - } - - handleEditClick: React.EventHandler = () => { - this.props.onEdit(this.props.status); - } - - handleDirectClick: React.EventHandler = () => { - const { account } = this.props.status; - if (!account || typeof account !== 'object') return; - this.props.onDirect(account); - } - - handleChatClick: React.EventHandler = () => { - const { account } = this.props.status; - if (!account || typeof account !== 'object') return; - this.props.onChat(account, this.props.history); - } - - handleMentionClick: React.EventHandler = () => { - const { account } = this.props.status; - if (!account || typeof account !== 'object') return; - this.props.onMention(account); - } - - handleMuteClick: React.EventHandler = () => { - const { account } = this.props.status; - if (!account || typeof account !== 'object') return; - this.props.onMute(account); - } - - handleConversationMuteClick: React.EventHandler = () => { - this.props.onMuteConversation(this.props.status); - } - - handleBlockClick: React.EventHandler = () => { - this.props.onBlock(this.props.status); - } - - handleReport = () => { - this.props.onReport(this.props.status); - } - - handlePinClick: React.EventHandler = () => { - this.props.onPin(this.props.status); - } - - handleShare = () => { - navigator.share({ - text: this.props.status.search_index, - url: this.props.status.uri, - }); - } - - handleEmbed = () => { - this.props.onEmbed(this.props.status); - } - - handleCopy = () => { - const url = this.props.status.url; - const textarea = document.createElement('textarea'); - - textarea.textContent = url; - textarea.style.position = 'fixed'; - - document.body.appendChild(textarea); - - try { - textarea.select(); - document.execCommand('copy'); - } catch (e) { - // Do nothing - } finally { - document.body.removeChild(textarea); - } - } - - handleDeactivateUser = () => { - this.props.onDeactivateUser(this.props.status); - } - - handleDeleteUser = () => { - this.props.onDeleteUser(this.props.status); - } - - handleToggleStatusSensitivity = () => { - this.props.onToggleStatusSensitivity(this.props.status); - } - - handleDeleteStatus = () => { - this.props.onDeleteStatus(this.props.status); - } - - setRef: React.RefCallback = c => { - this.node = c; - } - - componentDidMount() { - document.addEventListener('click', e => { - if (this.node && !this.node.contains(e.target as Element)) - this.setState({ emojiSelectorVisible: false, emojiSelectorFocused: false }); - }); - } - - render() { - const { status, intl, me, isStaff, isAdmin, allowedEmoji, features } = this.props; - const ownAccount = status.getIn(['account', 'id']) === me; - const username = String(status.getIn(['account', 'acct'])); - - const publicStatus = ['public', 'unlisted'].includes(status.visibility); - const mutingConversation = status.muted; - - const meEmojiReact = getReactForStatus(status, allowedEmoji) as keyof typeof reactMessages | undefined; - - const reactMessages = { - '👍': messages.reactionLike, - '❤️': messages.reactionHeart, - '😆': messages.reactionLaughing, - '😮': messages.reactionOpenMouth, - '😢': messages.reactionCry, - '😩': messages.reactionWeary, - '': messages.favourite, - }; - - const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiReact || ''] || messages.favourite); - - const menu: Menu = []; - - if (publicStatus) { - menu.push({ - text: intl.formatMessage(messages.copy), - action: this.handleCopy, - icon: require('@tabler/icons/link.svg'), - }); - - if (features.embeds) { - menu.push({ - text: intl.formatMessage(messages.embed), - action: this.handleEmbed, - icon: require('@tabler/icons/share.svg'), - }); - } - } - - if (me) { - if (features.bookmarks) { - menu.push({ - text: intl.formatMessage(status.bookmarked ? messages.unbookmark : messages.bookmark), - action: this.handleBookmarkClick, - icon: status.bookmarked ? require('@tabler/icons/bookmark-off.svg') : require('@tabler/icons/bookmark.svg'), - }); - } - - menu.push(null); - - if (ownAccount) { - if (publicStatus) { - menu.push({ - text: intl.formatMessage(status.pinned ? messages.unpin : messages.pin), - action: this.handlePinClick, - icon: mutingConversation ? require('@tabler/icons/pinned-off.svg') : require('@tabler/icons/pin.svg'), - }); - - menu.push(null); - } else if (status.visibility === 'private') { - menu.push({ - text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog_private), - action: this.handleReblogClick, - icon: require('@tabler/icons/repeat.svg'), - }); - - menu.push(null); - } - - menu.push({ - text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), - action: this.handleConversationMuteClick, - icon: mutingConversation ? require('@tabler/icons/bell.svg') : require('@tabler/icons/bell-off.svg'), - }); - menu.push(null); - menu.push({ - text: intl.formatMessage(messages.delete), - action: this.handleDeleteClick, - icon: require('@tabler/icons/trash.svg'), - destructive: true, - }); - if (features.editStatuses) { - menu.push({ - text: intl.formatMessage(messages.edit), - action: this.handleEditClick, - icon: require('@tabler/icons/edit.svg'), - }); - } else { - menu.push({ - text: intl.formatMessage(messages.redraft), - action: this.handleRedraftClick, - icon: require('@tabler/icons/edit.svg'), - destructive: true, - }); - } - } else { - menu.push({ - text: intl.formatMessage(messages.mention, { name: username }), - action: this.handleMentionClick, - icon: require('@tabler/icons/at.svg'), - }); - - // if (status.getIn(['account', 'pleroma', 'accepts_chat_messages'], false) === true) { - // menu.push({ - // text: intl.formatMessage(messages.chat, { name: username }), - // action: this.handleChatClick, - // icon: require('@tabler/icons/messages.svg'), - // }); - // } else { - // menu.push({ - // text: intl.formatMessage(messages.direct, { name: username }), - // action: this.handleDirectClick, - // icon: require('@tabler/icons/mail.svg'), - // }); - // } - - menu.push(null); - menu.push({ - text: intl.formatMessage(messages.mute, { name: username }), - action: this.handleMuteClick, - icon: require('@tabler/icons/circle-x.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.block, { name: username }), - action: this.handleBlockClick, - icon: require('@tabler/icons/ban.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.report, { name: username }), - action: this.handleReport, - icon: require('@tabler/icons/flag.svg'), - }); - } - - if (isStaff) { - menu.push(null); - - if (isAdmin) { - menu.push({ - text: intl.formatMessage(messages.admin_account, { name: username }), - href: `/pleroma/admin/#/users/${status.getIn(['account', 'id'])}/`, - icon: require('@tabler/icons/gavel.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.admin_status), - href: `/pleroma/admin/#/statuses/${status.id}/`, - icon: require('@tabler/icons/pencil.svg'), - }); - } - - menu.push({ - text: intl.formatMessage(status.sensitive === false ? messages.markStatusSensitive : messages.markStatusNotSensitive), - action: this.handleToggleStatusSensitivity, - icon: require('@tabler/icons/alert-triangle.svg'), - }); - - if (!ownAccount) { - menu.push({ - text: intl.formatMessage(messages.deactivateUser, { name: username }), - action: this.handleDeactivateUser, - icon: require('@tabler/icons/user-off.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.deleteUser, { name: username }), - action: this.handleDeleteUser, - icon: require('@tabler/icons/user-minus.svg'), - destructive: true, - }); - menu.push({ - text: intl.formatMessage(messages.deleteStatus), - action: this.handleDeleteStatus, - icon: require('@tabler/icons/trash.svg'), - destructive: true, - }); - } - } - } - - const canShare = ('share' in navigator) && status.visibility === 'public'; - - let reblogIcon = require('@tabler/icons/repeat.svg'); - - if (status.visibility === 'direct') { - reblogIcon = require('@tabler/icons/mail.svg'); - } else if (status.visibility === 'private') { - reblogIcon = require('@tabler/icons/lock.svg'); - } - - const reblog_disabled = (status.visibility === 'direct' || status.visibility === 'private'); - - const reblogMenu: Menu = [{ - text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog), - action: this.handleReblogClick, - icon: require('@tabler/icons/repeat.svg'), - }, { - text: intl.formatMessage(messages.quotePost), - action: this.handleQuoteClick, - icon: require('@tabler/icons/quote.svg'), - }]; - - const reblogButton = ( - - ); - - return ( - - - - {(features.quotePosts && me) ? ( - - {reblogButton} - - ) : ( - reblogButton - )} - - {features.emojiReacts ? ( - - {meEmojiReact ? ( - - ) : ( - - )} - - ) : ( - - )} - - {canShare && ( - - )} - - - - ); - } - -} - -const WrappedComponent = withRouter(injectIntl(ActionBar)); -export default connect(mapStateToProps, mapDispatchToProps)(WrappedComponent); diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index 29bbcd842..cd38e56b8 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -484,7 +484,7 @@ const Thread: React.FC = (props) => {
- +
From 168cee063607ca2da9a4dedd5bcb04698b0bb9e7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 9 Aug 2022 18:46:16 -0500 Subject: [PATCH 16/32] StatusActionBar: fix styles in Thread --- app/soapbox/components/status-action-bar.tsx | 12 ++++++++++-- app/soapbox/components/status-action-button.tsx | 4 +++- app/soapbox/components/status.tsx | 4 +++- app/soapbox/features/status/index.tsx | 7 ++++++- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index 76e4c5e83..a324fe344 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -1,3 +1,4 @@ +import classNames from 'classnames'; import { List as ImmutableList } from 'immutable'; import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; @@ -82,7 +83,8 @@ interface IStatusActionBar { status: Status, withDismiss?: boolean, withLabels?: boolean, - expandable?: boolean + expandable?: boolean, + space?: 'expand' | 'compact', } const StatusActionBar: React.FC = ({ @@ -90,6 +92,7 @@ const StatusActionBar: React.FC = ({ withDismiss = false, withLabels = false, expandable = true, + space = 'compact', }) => { const intl = useIntl(); const history = useHistory(); @@ -584,7 +587,12 @@ const StatusActionBar: React.FC = ({ const canShare = ('share' in navigator) && status.visibility === 'public'; return ( -
+
= (props) => { {quote} {!hideActionBar && ( - +
+ +
)}
diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index cd38e56b8..baf6b61ae 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -484,7 +484,12 @@ const Thread: React.FC = (props) => {
- +
From d668bb370f9083acabcf605f3b689d0d255366a5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 9 Aug 2022 18:51:01 -0500 Subject: [PATCH 17/32] Fix Like button --- app/soapbox/actions/interactions.ts | 4 ++-- app/soapbox/components/status-action-bar.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/soapbox/actions/interactions.ts b/app/soapbox/actions/interactions.ts index bfefb41f6..70fd93317 100644 --- a/app/soapbox/actions/interactions.ts +++ b/app/soapbox/actions/interactions.ts @@ -170,9 +170,9 @@ const unfavourite = (status: StatusEntity) => const toggleFavourite = (status: StatusEntity) => (dispatch: AppDispatch, getState: () => RootState) => { if (status.favourited) { - dispatch(favourite(status)); - } else { dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); } }; diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index a324fe344..0e6786fb6 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -152,7 +152,7 @@ const StatusActionBar: React.FC = ({ const handleFavouriteClick: React.EventHandler = (e) => { if (me) { - toggleFavourite(status); + dispatch(toggleFavourite(status)); } else { onOpenUnauthorizedModal('FAVOURITE'); } From 56568e2528d4deaeb291164782e62eff7a614924 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 9 Aug 2022 18:58:43 -0500 Subject: [PATCH 18/32] To crush your enemies, see them driven before you, and hear the lamentation of their women --- app/soapbox/components/status.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 2abdc8f5a..37cf7a24b 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -14,9 +14,9 @@ import QuotedStatus from 'soapbox/features/status/containers/quoted_status_conta import { useAppDispatch, useSettings } from 'soapbox/hooks'; import { defaultMediaVisibility, textForScreenReader, getActualStatus } from 'soapbox/utils/status'; +import StatusActionBar from './status-action-bar'; import StatusMedia from './status-media'; import StatusReplyMentions from './status-reply-mentions'; -import StatusActionBar from './status-action-bar'; import StatusContent from './status_content'; import { HStack, Text } from './ui'; From ae0fd07580c3f8032ddf5d5f53a6ebad41974128 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 9 Aug 2022 11:00:22 -0400 Subject: [PATCH 19/32] Use v2 suggestions endpoint for Onboarding --- .../steps/suggested-accounts-step.tsx | 34 ++++---- .../queries/__tests__/suggestions.test.ts | 45 ++++++++++ app/soapbox/queries/suggestions.ts | 82 +++++++++++++++++++ 3 files changed, 141 insertions(+), 20 deletions(-) create mode 100644 app/soapbox/queries/__tests__/suggestions.test.ts create mode 100644 app/soapbox/queries/suggestions.ts diff --git a/app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx b/app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx index a1e8581cd..4df00061a 100644 --- a/app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx +++ b/app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx @@ -1,49 +1,43 @@ import debounce from 'lodash/debounce'; import * as React from 'react'; import { FormattedMessage } from 'react-intl'; -import { useDispatch } from 'react-redux'; -import { fetchSuggestions } from 'soapbox/actions/suggestions'; import ScrollableList from 'soapbox/components/scrollable_list'; import { Button, Card, CardBody, Stack, Text } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account_container'; -import { useAppSelector } from 'soapbox/hooks'; +import useOnboardingSuggestions from 'soapbox/queries/suggestions'; const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => { - const dispatch = useDispatch(); + const { data, fetchNextPage, hasNextPage, isFetching } = useOnboardingSuggestions(); - const suggestions = useAppSelector((state) => state.suggestions.items); - const hasMore = useAppSelector((state) => !!state.suggestions.next); - const isLoading = useAppSelector((state) => state.suggestions.isLoading); const handleLoadMore = debounce(() => { - if (isLoading) { + if (isFetching) { return null; } - return dispatch(fetchSuggestions()); + return fetchNextPage(); }, 300); - React.useEffect(() => { - dispatch(fetchSuggestions({ limit: 20 })); - }, []); - const renderSuggestions = () => { + if (!data) { + return null; + } + return (
- {suggestions.map((suggestion) => ( -
+ {data.map((suggestion) => ( +
, but it isn't - id={suggestion.account} + id={suggestion.account.id} showProfileHoverCard={false} withLinkToProfile={false} /> @@ -65,7 +59,7 @@ const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => { }; const renderBody = () => { - if (suggestions.isEmpty()) { + if (!data || data.length === 0) { return renderEmpty(); } else { return renderSuggestions(); diff --git a/app/soapbox/queries/__tests__/suggestions.test.ts b/app/soapbox/queries/__tests__/suggestions.test.ts new file mode 100644 index 000000000..3a440d984 --- /dev/null +++ b/app/soapbox/queries/__tests__/suggestions.test.ts @@ -0,0 +1,45 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import { mock, queryWrapper, waitFor } from 'soapbox/jest/test-helpers'; + +import useOnboardingSuggestions from '../suggestions'; + +describe('useCarouselAvatars', () => { + describe('with a successul query', () => { + beforeEach(() => { + mock.onGet('/api/v2/suggestions') + .reply(200, [ + { source: 'staff', account: { id: '1', acct: 'a', account_avatar: 'https://example.com/some.jpg' } }, + { source: 'staff', account: { id: '2', acct: 'b', account_avatar: 'https://example.com/some.jpg' } }, + ], { + link: '; rel=\'prev\'', + }); + }); + + it('is successful', async() => { + const { result } = renderHook(() => useOnboardingSuggestions(), { + wrapper: queryWrapper, + }); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.data?.length).toBe(2); + }); + }); + + describe('with an unsuccessul query', () => { + beforeEach(() => { + mock.onGet('/api/v2/suggestions').networkError(); + }); + + it('is successful', async() => { + const { result } = renderHook(() => useOnboardingSuggestions(), { + wrapper: queryWrapper, + }); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.error).toBeDefined(); + }); + }); +}); diff --git a/app/soapbox/queries/suggestions.ts b/app/soapbox/queries/suggestions.ts new file mode 100644 index 000000000..12acfb895 --- /dev/null +++ b/app/soapbox/queries/suggestions.ts @@ -0,0 +1,82 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { fetchRelationships } from 'soapbox/actions/accounts'; +import { importFetchedAccounts } from 'soapbox/actions/importer'; +import { getLinks } from 'soapbox/api'; +import { useAppDispatch } from 'soapbox/hooks'; +import API from 'soapbox/queries/client'; + +type Account = { + acct: string + avatar: string + avatar_static: string + bot: boolean + created_at: string + discoverable: boolean + display_name: string + followers_count: number + following_count: number + group: boolean + header: string + header_static: string + id: string + last_status_at: string + location: string + locked: boolean + note: string + statuses_count: number + url: string + username: string + verified: boolean + website: string +} + +type Suggestion = { + source: 'staff' + account: Account +} + +const getV2Suggestions = async(dispatch: any, pageParam: any): Promise<{ data: Suggestion[], link: string | null, hasMore: boolean }> => { + return dispatch(async() => { + const link = pageParam?.link || '/api/v2/suggestions'; + const response = await API.get(link); + const hasMore = !!response.headers.link; + const nextLink = getLinks(response).refs.find(link => link.rel === 'next')?.uri; + + const accounts = response.data.map(({ account }) => account); + const accountIds = accounts.map((account) => account.id); + dispatch(importFetchedAccounts(accounts)); + dispatch(fetchRelationships(accountIds)); + + return { + data: response.data, + link: nextLink, + hasMore, + }; + }); +}; + +export default function useOnboardingSuggestions() { + const dispatch = useAppDispatch(); + + const result = useInfiniteQuery(['suggestions', 'v2'], ({ pageParam }) => getV2Suggestions(dispatch, pageParam), { + keepPreviousData: true, + getNextPageParam: (config) => { + if (config.hasMore) { + return { link: config.link }; + } + + return undefined; + }, + }); + + const data = result.data?.pages.reduce( + (prev: Suggestion[], curr) => [...prev, ...curr.data], + [], + ); + + return { + ...result, + data, + }; +} From e72476d5770b947d4178278c57c9ed6f28b9dfd8 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 10 Aug 2022 08:46:00 -0400 Subject: [PATCH 20/32] Update suggestions query with new api hook --- .../queries/__tests__/suggestions.test.ts | 33 +++++++++---------- app/soapbox/queries/suggestions.ts | 21 ++++++------ 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/app/soapbox/queries/__tests__/suggestions.test.ts b/app/soapbox/queries/__tests__/suggestions.test.ts index 3a440d984..15977dbb9 100644 --- a/app/soapbox/queries/__tests__/suggestions.test.ts +++ b/app/soapbox/queries/__tests__/suggestions.test.ts @@ -1,25 +1,24 @@ -import { renderHook } from '@testing-library/react-hooks'; - -import { mock, queryWrapper, waitFor } from 'soapbox/jest/test-helpers'; +import { __stub } from 'soapbox/api'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; import useOnboardingSuggestions from '../suggestions'; describe('useCarouselAvatars', () => { describe('with a successul query', () => { beforeEach(() => { - mock.onGet('/api/v2/suggestions') - .reply(200, [ - { source: 'staff', account: { id: '1', acct: 'a', account_avatar: 'https://example.com/some.jpg' } }, - { source: 'staff', account: { id: '2', acct: 'b', account_avatar: 'https://example.com/some.jpg' } }, - ], { - link: '; rel=\'prev\'', - }); + __stub((mock) => { + mock.onGet('/api/v2/suggestions') + .reply(200, [ + { source: 'staff', account: { id: '1', acct: 'a', account_avatar: 'https://example.com/some.jpg' } }, + { source: 'staff', account: { id: '2', acct: 'b', account_avatar: 'https://example.com/some.jpg' } }, + ], { + link: '; rel=\'prev\'', + }); + }); }); it('is successful', async() => { - const { result } = renderHook(() => useOnboardingSuggestions(), { - wrapper: queryWrapper, - }); + const { result } = renderHook(() => useOnboardingSuggestions()); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -29,13 +28,13 @@ describe('useCarouselAvatars', () => { describe('with an unsuccessul query', () => { beforeEach(() => { - mock.onGet('/api/v2/suggestions').networkError(); + __stub((mock) => { + mock.onGet('/api/v2/suggestions').networkError(); + }); }); it('is successful', async() => { - const { result } = renderHook(() => useOnboardingSuggestions(), { - wrapper: queryWrapper, - }); + const { result } = renderHook(() => useOnboardingSuggestions()); await waitFor(() => expect(result.current.isFetching).toBe(false)); diff --git a/app/soapbox/queries/suggestions.ts b/app/soapbox/queries/suggestions.ts index 12acfb895..d5ddf07bf 100644 --- a/app/soapbox/queries/suggestions.ts +++ b/app/soapbox/queries/suggestions.ts @@ -3,8 +3,7 @@ import { useInfiniteQuery } from '@tanstack/react-query'; import { fetchRelationships } from 'soapbox/actions/accounts'; import { importFetchedAccounts } from 'soapbox/actions/importer'; import { getLinks } from 'soapbox/api'; -import { useAppDispatch } from 'soapbox/hooks'; -import API from 'soapbox/queries/client'; +import { useApi, useAppDispatch } from 'soapbox/hooks'; type Account = { acct: string @@ -36,10 +35,14 @@ type Suggestion = { account: Account } -const getV2Suggestions = async(dispatch: any, pageParam: any): Promise<{ data: Suggestion[], link: string | null, hasMore: boolean }> => { - return dispatch(async() => { + +export default function useOnboardingSuggestions() { + const api = useApi(); + const dispatch = useAppDispatch(); + + const getV2Suggestions = async(pageParam: any): Promise<{ data: Suggestion[], link: string | undefined, hasMore: boolean }> => { const link = pageParam?.link || '/api/v2/suggestions'; - const response = await API.get(link); + const response = await api.get(link); const hasMore = !!response.headers.link; const nextLink = getLinks(response).refs.find(link => link.rel === 'next')?.uri; @@ -53,13 +56,9 @@ const getV2Suggestions = async(dispatch: any, pageParam: any): Promise<{ data: S link: nextLink, hasMore, }; - }); -}; + }; -export default function useOnboardingSuggestions() { - const dispatch = useAppDispatch(); - - const result = useInfiniteQuery(['suggestions', 'v2'], ({ pageParam }) => getV2Suggestions(dispatch, pageParam), { + const result = useInfiniteQuery(['suggestions', 'v2'], ({ pageParam }) => getV2Suggestions(pageParam), { keepPreviousData: true, getNextPageParam: (config) => { if (config.hasMore) { From b2530dadd512f2191bce49766b2c2c3cc3bd4b2c Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 10 Aug 2022 09:35:07 -0400 Subject: [PATCH 21/32] Convert trends to React Query --- .../features/ui/components/trends-panel.tsx | 18 +++----- app/soapbox/queries/__tests__/trends.test.ts | 42 +++++++++++++++++++ app/soapbox/queries/trends.ts | 28 +++++++++++++ 3 files changed, 75 insertions(+), 13 deletions(-) create mode 100644 app/soapbox/queries/__tests__/trends.test.ts create mode 100644 app/soapbox/queries/trends.ts diff --git a/app/soapbox/features/ui/components/trends-panel.tsx b/app/soapbox/features/ui/components/trends-panel.tsx index 49b68887d..ee0d8d763 100644 --- a/app/soapbox/features/ui/components/trends-panel.tsx +++ b/app/soapbox/features/ui/components/trends-panel.tsx @@ -1,40 +1,32 @@ import * as React from 'react'; import { FormattedMessage } from 'react-intl'; -import { useDispatch } from 'react-redux'; -import { fetchTrends } from 'soapbox/actions/trends'; import Hashtag from 'soapbox/components/hashtag'; import { Widget } from 'soapbox/components/ui'; -import { useAppSelector } from 'soapbox/hooks'; +import useTrends from 'soapbox/queries/trends'; interface ITrendsPanel { limit: number } const TrendsPanel = ({ limit }: ITrendsPanel) => { - const dispatch = useDispatch(); - - const trends = useAppSelector((state) => state.trends.items); + const { data: trends, isFetching } = useTrends(); const sortedTrends = React.useMemo(() => { - return trends.sort((a, b) => { + return trends?.sort((a, b) => { const num_a = Number(a.getIn(['history', 0, 'accounts'])); const num_b = Number(b.getIn(['history', 0, 'accounts'])); return num_b - num_a; }).slice(0, limit); }, [trends, limit]); - React.useEffect(() => { - dispatch(fetchTrends()); - }, []); - - if (sortedTrends.isEmpty()) { + if (trends?.length === 0 || isFetching) { return null; } return ( }> - {sortedTrends.map((hashtag) => ( + {sortedTrends?.slice(0, limit).map((hashtag) => ( ))} diff --git a/app/soapbox/queries/__tests__/trends.test.ts b/app/soapbox/queries/__tests__/trends.test.ts new file mode 100644 index 000000000..52f319d26 --- /dev/null +++ b/app/soapbox/queries/__tests__/trends.test.ts @@ -0,0 +1,42 @@ +import { __stub } from 'soapbox/api'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; + +import useTrends from '../trends'; + +describe('useTrends', () => { + describe('with a successul query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/trends') + .reply(200, [ + { name: '#golf', url: 'https://example.com' }, + { name: '#tennis', url: 'https://example.com' }, + ]); + }); + }); + + it('is successful', async() => { + const { result } = renderHook(() => useTrends()); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.data?.length).toBe(2); + }); + }); + + describe('with an unsuccessul query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/trends').networkError(); + }); + }); + + it('is successful', async() => { + const { result } = renderHook(() => useTrends()); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.error).toBeDefined(); + }); + }); +}); diff --git a/app/soapbox/queries/trends.ts b/app/soapbox/queries/trends.ts new file mode 100644 index 000000000..8613719b0 --- /dev/null +++ b/app/soapbox/queries/trends.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query'; + +import { fetchTrendsSuccess } from 'soapbox/actions/trends'; +import { useApi, useAppDispatch } from 'soapbox/hooks'; +import { normalizeTag } from 'soapbox/normalizers'; + +import type { Tag } from 'soapbox/types/entities'; + +export default function useTrends() { + const api = useApi(); + const dispatch = useAppDispatch(); + + const getTrends = async() => { + const { data } = await api.get('/api/v1/trends'); + + dispatch(fetchTrendsSuccess(data)); + + const normalizedData = data.map((tag) => normalizeTag(tag)); + return normalizedData; + }; + + const result = useQuery(['trends'], getTrends, { + placeholderData: [], + staleTime: 600000, // 10 minutes + }); + + return result; +} \ No newline at end of file From 968ec3a7d2255444f8a38b1f84e92c67a8ef0bd8 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 10 Aug 2022 10:30:58 -0400 Subject: [PATCH 22/32] Clear React Query cache before each test --- app/soapbox/jest/test-helpers.tsx | 3 +++ app/soapbox/jest/test-setup.ts | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/soapbox/jest/test-helpers.tsx b/app/soapbox/jest/test-helpers.tsx index 0894d4d40..721783879 100644 --- a/app/soapbox/jest/test-helpers.tsx +++ b/app/soapbox/jest/test-helpers.tsx @@ -37,6 +37,8 @@ const queryClient = new QueryClient({ }, defaultOptions: { queries: { + staleTime: 0, + cacheTime: Infinity, retry: false, }, }, @@ -123,4 +125,5 @@ export { rootReducer, mockWindowProperty, createTestStore, + queryClient, }; diff --git a/app/soapbox/jest/test-setup.ts b/app/soapbox/jest/test-setup.ts index 0052388b0..cf24ba3f1 100644 --- a/app/soapbox/jest/test-setup.ts +++ b/app/soapbox/jest/test-setup.ts @@ -2,9 +2,14 @@ import { __clear as clearApiMocks } from '../__mocks__/api'; +import { queryClient } from './test-helpers'; + // API mocking jest.mock('soapbox/api'); -afterEach(() => clearApiMocks()); +afterEach(() => { + clearApiMocks(); + queryClient.clear(); +}); // Mock IndexedDB // https://dev.to/andyhaskell/testing-your-indexeddb-code-with-jest-2o17 From cbe9f47a59270b10b6b31f30cef4f26cd566028c Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 10 Aug 2022 10:31:09 -0400 Subject: [PATCH 23/32] Fix Trends Panel test --- .../__tests__/trends-panel.test.tsx | 131 ++++++++---------- 1 file changed, 58 insertions(+), 73 deletions(-) diff --git a/app/soapbox/features/ui/components/__tests__/trends-panel.test.tsx b/app/soapbox/features/ui/components/__tests__/trends-panel.test.tsx index 487a84bfb..20eb3c5af 100644 --- a/app/soapbox/features/ui/components/__tests__/trends-panel.test.tsx +++ b/app/soapbox/features/ui/components/__tests__/trends-panel.test.tsx @@ -1,85 +1,70 @@ -import { List as ImmutableList, Record as ImmutableRecord } from 'immutable'; import React from 'react'; -import { render, screen } from '../../../../jest/test-helpers'; -import { normalizeTag } from '../../../../normalizers'; +import { __stub } from 'soapbox/api'; + +import { render, screen, waitFor } from '../../../../jest/test-helpers'; import TrendsPanel from '../trends-panel'; describe('', () => { - it('renders trending hashtags', () => { - const store = { - trends: ImmutableRecord({ - items: ImmutableList([ - normalizeTag({ - name: 'hashtag 1', - history: [{ - day: '1652745600', - uses: '294', - accounts: '180', - }], - }), - ]), - isLoading: false, - })(), - }; + describe('with hashtags', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/trends') + .reply(200, [ + { + name: 'hashtag 1', + url: 'https://example.com', + history: [{ + day: '1652745600', + uses: '294', + accounts: '180', + }], + }, + { name: 'hashtag 2', url: 'https://example.com' }, + ]); + }); + }); - render(, undefined, store); - expect(screen.getByTestId('hashtag')).toHaveTextContent(/hashtag 1/i); - expect(screen.getByTestId('hashtag')).toHaveTextContent(/180 people talking/i); - expect(screen.getByTestId('sparklines')).toBeInTheDocument(); + it('renders trending hashtags', async() => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('hashtag')).toHaveTextContent(/hashtag 1/i); + expect(screen.getByTestId('hashtag')).toHaveTextContent(/180 people talking/i); + expect(screen.getByTestId('sparklines')).toBeInTheDocument(); + }); + }); + + it('renders multiple trends', async() => { + render(); + + await waitFor(() => { + expect(screen.queryAllByTestId('hashtag')).toHaveLength(2); + }); + }); + + it('respects the limit prop', async() => { + render(); + + await waitFor(() => { + expect(screen.queryAllByTestId('hashtag')).toHaveLength(1); + }); + }); }); - it('renders multiple trends', () => { - const store = { - trends: ImmutableRecord({ - items: ImmutableList([ - normalizeTag({ - name: 'hashtag 1', - history: ImmutableList([{ accounts: [] }]), - }), - normalizeTag({ - name: 'hashtag 2', - history: ImmutableList([{ accounts: [] }]), - }), - ]), - isLoading: false, - })(), - }; + describe('without hashtags', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/trends').reply(200, []); + }); + }); - render(, undefined, store); - expect(screen.queryAllByTestId('hashtag')).toHaveLength(2); - }); + it('renders empty', async() => { + render(); - it('respects the limit prop', () => { - const store = { - trends: ImmutableRecord({ - items: ImmutableList([ - normalizeTag({ - name: 'hashtag 1', - history: [{ accounts: [] }], - }), - normalizeTag({ - name: 'hashtag 2', - history: [{ accounts: [] }], - }), - ]), - isLoading: false, - })(), - }; - - render(, undefined, store); - expect(screen.queryAllByTestId('hashtag')).toHaveLength(1); - }); - - it('renders empty', () => { - const store = { - trends: ImmutableRecord({ - items: ImmutableList([]), - isLoading: false, - })(), - }; - - render(, undefined, store); - expect(screen.queryAllByTestId('hashtag')).toHaveLength(0); + await waitFor(() => { + expect(screen.queryAllByTestId('hashtag')).toHaveLength(0); + }); + }); }); }); From b377689ed26330e97cb650f740170643eaaf2601 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 10 Aug 2022 10:34:12 -0400 Subject: [PATCH 24/32] Grammar fix --- app/soapbox/queries/__tests__/suggestions.test.ts | 4 ++-- app/soapbox/queries/__tests__/trends.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/soapbox/queries/__tests__/suggestions.test.ts b/app/soapbox/queries/__tests__/suggestions.test.ts index 15977dbb9..f38bf0dbc 100644 --- a/app/soapbox/queries/__tests__/suggestions.test.ts +++ b/app/soapbox/queries/__tests__/suggestions.test.ts @@ -4,7 +4,7 @@ import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; import useOnboardingSuggestions from '../suggestions'; describe('useCarouselAvatars', () => { - describe('with a successul query', () => { + describe('with a successful query', () => { beforeEach(() => { __stub((mock) => { mock.onGet('/api/v2/suggestions') @@ -26,7 +26,7 @@ describe('useCarouselAvatars', () => { }); }); - describe('with an unsuccessul query', () => { + describe('with an unsuccessful query', () => { beforeEach(() => { __stub((mock) => { mock.onGet('/api/v2/suggestions').networkError(); diff --git a/app/soapbox/queries/__tests__/trends.test.ts b/app/soapbox/queries/__tests__/trends.test.ts index 52f319d26..07856cedf 100644 --- a/app/soapbox/queries/__tests__/trends.test.ts +++ b/app/soapbox/queries/__tests__/trends.test.ts @@ -4,7 +4,7 @@ import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; import useTrends from '../trends'; describe('useTrends', () => { - describe('with a successul query', () => { + describe('with a successful query', () => { beforeEach(() => { __stub((mock) => { mock.onGet('/api/v1/trends') @@ -24,7 +24,7 @@ describe('useTrends', () => { }); }); - describe('with an unsuccessul query', () => { + describe('with an unsuccessful query', () => { beforeEach(() => { __stub((mock) => { mock.onGet('/api/v1/trends').networkError(); From 06d1ad2efed8376448880927d2e69fca03cfeebf Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 10 Aug 2022 11:33:23 -0400 Subject: [PATCH 25/32] Remove sort --- app/soapbox/features/ui/components/trends-panel.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/app/soapbox/features/ui/components/trends-panel.tsx b/app/soapbox/features/ui/components/trends-panel.tsx index ee0d8d763..9f582d891 100644 --- a/app/soapbox/features/ui/components/trends-panel.tsx +++ b/app/soapbox/features/ui/components/trends-panel.tsx @@ -12,21 +12,13 @@ interface ITrendsPanel { const TrendsPanel = ({ limit }: ITrendsPanel) => { const { data: trends, isFetching } = useTrends(); - const sortedTrends = React.useMemo(() => { - return trends?.sort((a, b) => { - const num_a = Number(a.getIn(['history', 0, 'accounts'])); - const num_b = Number(b.getIn(['history', 0, 'accounts'])); - return num_b - num_a; - }).slice(0, limit); - }, [trends, limit]); - if (trends?.length === 0 || isFetching) { return null; } return ( }> - {sortedTrends?.slice(0, limit).map((hashtag) => ( + {trends?.slice(0, limit).map((hashtag) => ( ))} From 4d98046627c451d74212f596dca81b79fd2a2ba1 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 10 Aug 2022 13:27:09 -0400 Subject: [PATCH 26/32] Improve types --- app/soapbox/queries/trends.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/soapbox/queries/trends.ts b/app/soapbox/queries/trends.ts index 8613719b0..afe780991 100644 --- a/app/soapbox/queries/trends.ts +++ b/app/soapbox/queries/trends.ts @@ -11,7 +11,7 @@ export default function useTrends() { const dispatch = useAppDispatch(); const getTrends = async() => { - const { data } = await api.get('/api/v1/trends'); + const { data } = await api.get('/api/v1/trends'); dispatch(fetchTrendsSuccess(data)); @@ -19,10 +19,10 @@ export default function useTrends() { return normalizedData; }; - const result = useQuery(['trends'], getTrends, { + const result = useQuery>(['trends'], getTrends, { placeholderData: [], staleTime: 600000, // 10 minutes }); return result; -} \ No newline at end of file +} From 6c03b6ddd34d39116585d5ae7eabe1cbd86458ff Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 10 Aug 2022 14:02:29 -0400 Subject: [PATCH 27/32] fix tests --- app/soapbox/jest/test-setup.ts | 7 +------ app/soapbox/queries/__tests__/trends.test.ts | 6 +++++- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/soapbox/jest/test-setup.ts b/app/soapbox/jest/test-setup.ts index cf24ba3f1..0052388b0 100644 --- a/app/soapbox/jest/test-setup.ts +++ b/app/soapbox/jest/test-setup.ts @@ -2,14 +2,9 @@ import { __clear as clearApiMocks } from '../__mocks__/api'; -import { queryClient } from './test-helpers'; - // API mocking jest.mock('soapbox/api'); -afterEach(() => { - clearApiMocks(); - queryClient.clear(); -}); +afterEach(() => clearApiMocks()); // Mock IndexedDB // https://dev.to/andyhaskell/testing-your-indexeddb-code-with-jest-2o17 diff --git a/app/soapbox/queries/__tests__/trends.test.ts b/app/soapbox/queries/__tests__/trends.test.ts index 07856cedf..784f928cb 100644 --- a/app/soapbox/queries/__tests__/trends.test.ts +++ b/app/soapbox/queries/__tests__/trends.test.ts @@ -1,9 +1,13 @@ import { __stub } from 'soapbox/api'; -import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; +import { queryClient, renderHook, waitFor } from 'soapbox/jest/test-helpers'; import useTrends from '../trends'; describe('useTrends', () => { + beforeEach(() => { + queryClient.clear(); + }); + describe('with a successful query', () => { beforeEach(() => { __stub((mock) => { From 22294b8a6e33c82046fcc4601a2525d24029ff00 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 10 Aug 2022 14:45:58 -0400 Subject: [PATCH 28/32] Fix test --- .../features/ui/components/__tests__/trends-panel.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/soapbox/features/ui/components/__tests__/trends-panel.test.tsx b/app/soapbox/features/ui/components/__tests__/trends-panel.test.tsx index 20eb3c5af..0d203ab9b 100644 --- a/app/soapbox/features/ui/components/__tests__/trends-panel.test.tsx +++ b/app/soapbox/features/ui/components/__tests__/trends-panel.test.tsx @@ -2,10 +2,14 @@ import React from 'react'; import { __stub } from 'soapbox/api'; -import { render, screen, waitFor } from '../../../../jest/test-helpers'; +import { queryClient, render, screen, waitFor } from '../../../../jest/test-helpers'; import TrendsPanel from '../trends-panel'; describe('', () => { + beforeEach(() => { + queryClient.clear(); + }); + describe('with hashtags', () => { beforeEach(() => { __stub((mock) => { From bf5d3b241a98829eb996132fdfe7aa41bd390494 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 11 Aug 2022 14:57:21 -0500 Subject: [PATCH 29/32] SW: don't serve /embed paths --- webpack/production.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpack/production.js b/webpack/production.js index 43792e97c..6e2f6d810 100644 --- a/webpack/production.js +++ b/webpack/production.js @@ -120,7 +120,7 @@ module.exports = merge(sharedConfig, { ]; if (pathname) { - return backendRoutes.some(path => pathname.startsWith(path)); + return backendRoutes.some(path => pathname.startsWith(path)) || pathname.endsWith('/embed'); } else { return false; } From c5d46d1a158c8539603fd326c316c702bc8a6793 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 11 Aug 2022 15:28:42 -0500 Subject: [PATCH 30/32] eslint: disable consistent-return --- .eslintrc.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 7fa666e36..164949e65 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -78,7 +78,6 @@ module.exports = { 'space-infix-ops': 'error', 'space-in-parens': ['error', 'never'], 'keyword-spacing': 'error', - 'consistent-return': 'error', 'dot-notation': 'error', eqeqeq: 'error', indent: ['error', 2, { @@ -278,7 +277,6 @@ module.exports = { files: ['**/*.ts', '**/*.tsx'], rules: { 'no-undef': 'off', // https://stackoverflow.com/a/69155899 - 'consistent-return': 'off', }, parser: '@typescript-eslint/parser', }, From b085073c10763eed70282554fb0380be08f2d6b8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 11 Aug 2022 15:32:27 -0500 Subject: [PATCH 31/32] SW: refactor cacheMap to correctly return a URL --- webpack/production.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/webpack/production.js b/webpack/production.js index 6e2f6d810..9bd16e045 100644 --- a/webpack/production.js +++ b/webpack/production.js @@ -92,7 +92,10 @@ module.exports = merge(sharedConfig, { cacheMaps: [{ // NOTE: This function gets stringified by OfflinePlugin, so don't try // moving it anywhere else or making it depend on anything outside it! - match: ({ pathname }) => { + // https://github.com/NekR/offline-plugin/blob/master/docs/cache-maps.md + match: (url) => { + const { pathname } = url; + const backendRoutes = [ '/.well-known', '/activities', @@ -119,10 +122,8 @@ module.exports = merge(sharedConfig, { '/unsubscribe', ]; - if (pathname) { - return backendRoutes.some(path => pathname.startsWith(path)) || pathname.endsWith('/embed'); - } else { - return false; + if (backendRoutes.some(path => pathname.startsWith(path)) || pathname.endsWith('/embed')) { + return url; } }, requestTypes: ['navigate'], From d9aee6d98edc02920ed0baf986465a51764dc66c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 11 Aug 2022 23:19:40 +0200 Subject: [PATCH 32/32] Fix styles in reply indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/features/compose/components/reply_indicator.tsx | 2 +- app/styles/components/status.scss | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/soapbox/features/compose/components/reply_indicator.tsx b/app/soapbox/features/compose/components/reply_indicator.tsx index 5a6a5f706..3af9c49a8 100644 --- a/app/soapbox/features/compose/components/reply_indicator.tsx +++ b/app/soapbox/features/compose/components/reply_indicator.tsx @@ -43,7 +43,7 @@ const ReplyIndicator: React.FC = ({ status, hideActions, onCanc />