diff --git a/app/soapbox/components/dropdown_menu.tsx b/app/soapbox/components/dropdown_menu.tsx index d3efa6865..8114ca7dc 100644 --- a/app/soapbox/components/dropdown_menu.tsx +++ b/app/soapbox/components/dropdown_menu.tsx @@ -16,7 +16,7 @@ const listenerOptions = supportsPassiveEvents ? { passive: true } : false; let id = 0; export interface MenuItem { - action: React.EventHandler, + action?: React.EventHandler, middleClick?: React.EventHandler, text: string, href?: string, diff --git a/app/soapbox/components/pull-to-refresh.tsx b/app/soapbox/components/pull-to-refresh.tsx index fdc8b26cf..6ef199f3c 100644 --- a/app/soapbox/components/pull-to-refresh.tsx +++ b/app/soapbox/components/pull-to-refresh.tsx @@ -4,7 +4,6 @@ import PTRComponent from 'react-simple-pull-to-refresh'; import { Spinner } from 'soapbox/components/ui'; interface IPullToRefresh { - children: JSX.Element & React.ReactNode, onRefresh?: () => Promise } @@ -12,7 +11,7 @@ interface IPullToRefresh { * PullToRefresh: * Wrapper around a third-party PTR component with Soapbox defaults. */ -const PullToRefresh = ({ children, onRefresh, ...rest }: IPullToRefresh) => { +const PullToRefresh: React.FC = ({ children, onRefresh, ...rest }): JSX.Element => { const handleRefresh = () => { if (onRefresh) { return onRefresh(); @@ -33,7 +32,8 @@ const PullToRefresh = ({ children, onRefresh, ...rest }: IPullToRefresh) => { resistance={2} {...rest} > - {children} + {/* This thing really wants a single JSX element as its child (TypeScript), so wrap it in one */} + <>{children} ); }; diff --git a/app/soapbox/features/status/components/action_bar.js b/app/soapbox/features/status/components/action-bar.tsx similarity index 72% rename from app/soapbox/features/status/components/action_bar.js rename to app/soapbox/features/status/components/action-bar.tsx index 929a20b53..a230fc5cd 100644 --- a/app/soapbox/features/status/components/action_bar.js +++ b/app/soapbox/features/status/components/action-bar.tsx @@ -1,20 +1,27 @@ import classNames from 'classnames'; -import PropTypes from 'prop-types'; import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, WrappedComponentProps as IntlComponentProps } from 'react-intl'; import { connect } from 'react-redux'; -import { withRouter } from 'react-router-dom'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; import { isUserTouching } from 'soapbox/is_mobile'; import { getReactForStatus } from 'soapbox/utils/emoji_reacts'; import { getFeatures } from 'soapbox/utils/features'; -import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types'; import { openModal } from '../../../actions/modals'; import { HStack, IconButton } from '../../../components/ui'; import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; +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' }, @@ -57,10 +64,10 @@ const messages = defineMessages({ quotePost: { id: 'status.quote', defaultMessage: 'Quote post' }, }); -const mapStateToProps = state => { - const me = state.get('me'); - const account = state.getIn(['accounts', me]); - const instance = state.get('instance'); +const mapStateToProps = (state: RootState) => { + const me = state.me; + const account = state.accounts.get(me as any); + const instance = state.instance; return { me, @@ -70,54 +77,56 @@ const mapStateToProps = state => { }; }; -const mapDispatchToProps = (dispatch, { status }) => ({ - onOpenUnauthorizedModal(action) { +const mapDispatchToProps = (dispatch: Dispatch, { status }: OwnProps) => ({ + onOpenUnauthorizedModal(action: string) { dispatch(openModal('UNAUTHORIZED', { action, - ap_id: status.get('url'), + ap_id: status.url, })); }, }); -@withRouter -class ActionBar extends React.PureComponent { +interface OwnProps { + status: StatusEntity, + onReply: (status: StatusEntity) => void, + onReblog: (status: StatusEntity, e: React.MouseEvent) => void, + onQuote: (status: StatusEntity, history: History) => void, + onFavourite: (status: StatusEntity) => void, + onEmojiReact: (status: StatusEntity, emoji: string) => void, + onDelete: (status: StatusEntity, history: History, redraft?: boolean) => void, + onBookmark: (status: StatusEntity) => void, + onDirect: (account: AccountEntity, history: History) => void, + onChat: (account: AccountEntity, history: History) => void, + onMention: (account: AccountEntity, history: History) => 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, +} - static propTypes = { - status: ImmutablePropTypes.record.isRequired, - onReply: PropTypes.func.isRequired, - onReblog: PropTypes.func.isRequired, - onQuote: PropTypes.func.isRequired, - onFavourite: PropTypes.func.isRequired, - onEmojiReact: PropTypes.func.isRequired, - onDelete: PropTypes.func.isRequired, - onBookmark: PropTypes.func, - onDirect: PropTypes.func.isRequired, - onChat: PropTypes.func, - onMention: PropTypes.func.isRequired, - onMute: PropTypes.func, - onMuteConversation: PropTypes.func, - onBlock: PropTypes.func, - onReport: PropTypes.func, - onPin: PropTypes.func, - onEmbed: PropTypes.func, - onDeactivateUser: PropTypes.func, - onDeleteUser: PropTypes.func, - onDeleteStatus: PropTypes.func, - onToggleStatusSensitivity: PropTypes.func, - intl: PropTypes.object.isRequired, - onOpenUnauthorizedModal: PropTypes.func.isRequired, - me: SoapboxPropTypes.me, - isStaff: PropTypes.bool.isRequired, - isAdmin: PropTypes.bool.isRequired, - allowedEmoji: ImmutablePropTypes.list, - emojiSelectorFocused: PropTypes.bool, - handleEmojiSelectorExpand: PropTypes.func.isRequired, - handleEmojiSelectorUnfocus: PropTypes.func.isRequired, - features: PropTypes.object.isRequired, - history: PropTypes.object, - }; +type StateProps = ReturnType; +type DispatchProps = ReturnType; - static defaultProps = { +type IActionBar = OwnProps & StateProps & DispatchProps & RouteComponentProps & IntlComponentProps; + +interface IActionBarState { + emojiSelectorVisible: boolean, + emojiSelectorFocused: boolean, +} + +class ActionBar extends React.PureComponent { + + static defaultProps: Partial = { isStaff: false, } @@ -126,7 +135,9 @@ class ActionBar extends React.PureComponent { emojiSelectorFocused: false, } - handleReplyClick = (e) => { + node: HTMLDivElement | null = null; + + handleReplyClick: React.EventHandler = (e) => { const { me, onReply, onOpenUnauthorizedModal } = this.props; e.preventDefault(); @@ -137,7 +148,7 @@ class ActionBar extends React.PureComponent { } } - handleReblogClick = (e) => { + handleReblogClick: React.EventHandler = (e) => { const { me, onReblog, onOpenUnauthorizedModal, status } = this.props; e.preventDefault(); @@ -148,7 +159,7 @@ class ActionBar extends React.PureComponent { } } - handleQuoteClick = () => { + handleQuoteClick: React.EventHandler = () => { const { me, onQuote, onOpenUnauthorizedModal, status } = this.props; if (me) { onQuote(status, this.props.history); @@ -157,12 +168,12 @@ class ActionBar extends React.PureComponent { } } - handleBookmarkClick = () => { + handleBookmarkClick: React.EventHandler = () => { this.props.onBookmark(this.props.status); } - handleFavouriteClick = (e) => { - const { me, onFavourite, onOpenUnauthorizedModal } = this.props; + handleFavouriteClick: React.EventHandler = (e) => { + const { me, onFavourite, onOpenUnauthorizedModal, status } = this.props; e.preventDefault(); @@ -173,7 +184,7 @@ class ActionBar extends React.PureComponent { } } - handleLikeButtonHover = e => { + handleLikeButtonHover: React.EventHandler = () => { const { features } = this.props; if (features.emojiReacts && !isUserTouching()) { @@ -181,7 +192,7 @@ class ActionBar extends React.PureComponent { } } - handleLikeButtonLeave = e => { + handleLikeButtonLeave: React.EventHandler = () => { const { features } = this.props; if (features.emojiReacts && !isUserTouching()) { @@ -189,23 +200,23 @@ class ActionBar extends React.PureComponent { } } - handleLikeButtonClick = e => { + 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)(); + this.handleReactClick(meEmojiReact)(e); } else { this.setState({ emojiSelectorVisible: true }); } } else { - this.handleReactClick(meEmojiReact)(); + this.handleReactClick(meEmojiReact)(e); } } - handleReactClick = emoji => { - return e => { + handleReactClick = (emoji: string): React.EventHandler => { + return () => { const { me, onEmojiReact, onOpenUnauthorizedModal, status } = this.props; if (me) { onEmojiReact(status, emoji); @@ -222,35 +233,43 @@ class ActionBar extends React.PureComponent { this.setState({ emojiSelectorVisible: !emojiSelectorVisible }); } - handleDeleteClick = () => { + handleDeleteClick: React.EventHandler = () => { this.props.onDelete(this.props.status, this.props.history); } - handleRedraftClick = () => { + handleRedraftClick: React.EventHandler = () => { this.props.onDelete(this.props.status, this.props.history, true); } - handleDirectClick = () => { - this.props.onDirect(this.props.status.get('account'), this.props.history); + handleDirectClick: React.EventHandler = () => { + const { account } = this.props.status; + if (!account || typeof account !== 'object') return; + this.props.onDirect(account, this.props.history); } - handleChatClick = () => { - this.props.onChat(this.props.status.get('account'), this.props.history); + handleChatClick: React.EventHandler = () => { + const { account } = this.props.status; + if (!account || typeof account !== 'object') return; + this.props.onChat(account, this.props.history); } - handleMentionClick = () => { - this.props.onMention(this.props.status.get('account'), this.props.history); + handleMentionClick: React.EventHandler = () => { + const { account } = this.props.status; + if (!account || typeof account !== 'object') return; + this.props.onMention(account, this.props.history); } - handleMuteClick = () => { - this.props.onMute(this.props.status.get('account')); + handleMuteClick: React.EventHandler = () => { + const { account } = this.props.status; + if (!account || typeof account !== 'object') return; + this.props.onMute(account); } - handleConversationMuteClick = () => { + handleConversationMuteClick: React.EventHandler = () => { this.props.onMuteConversation(this.props.status); } - handleBlockClick = () => { + handleBlockClick: React.EventHandler = () => { this.props.onBlock(this.props.status); } @@ -258,14 +277,14 @@ class ActionBar extends React.PureComponent { this.props.onReport(this.props.status); } - handlePinClick = () => { + handlePinClick: React.EventHandler = () => { this.props.onPin(this.props.status); } handleShare = () => { navigator.share({ - text: this.props.status.get('search_index'), - url: this.props.status.get('url'), + text: this.props.status.search_index, + url: this.props.status.url, }); } @@ -274,7 +293,7 @@ class ActionBar extends React.PureComponent { } handleCopy = () => { - const url = this.props.status.get('url'); + const url = this.props.status.url; const textarea = document.createElement('textarea'); textarea.textContent = url; @@ -308,13 +327,13 @@ class ActionBar extends React.PureComponent { this.props.onDeleteStatus(this.props.status); } - setRef = c => { + setRef: React.RefCallback = c => { this.node = c; } componentDidMount() { document.addEventListener('click', e => { - if (this.node && !this.node.contains(e.target)) + if (this.node && !this.node.contains(e.target as Element)) this.setState({ emojiSelectorVisible: false, emojiSelectorFocused: false }); }); } @@ -322,20 +341,25 @@ class ActionBar extends React.PureComponent { 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.get('visibility')); - const mutingConversation = status.get('muted'); - const meEmojiReact = getReactForStatus(status, allowedEmoji); - const meEmojiTitle = intl.formatMessage({ + 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, - }[meEmojiReact] || messages.favourite); + }; - const menu = []; + const meEmojiTitle = intl.formatMessage(meEmojiReact ? reactMessages[meEmojiReact] : messages.favourite); + + const menu: Menu = []; if (publicStatus) { menu.push({ @@ -364,12 +388,12 @@ class ActionBar extends React.PureComponent { if (ownAccount) { if (publicStatus) { menu.push({ - text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), + text: intl.formatMessage(status.pinned ? messages.unpin : messages.pin), action: this.handlePinClick, icon: require(mutingConversation ? '@tabler/icons/icons/pinned-off.svg' : '@tabler/icons/icons/pin.svg'), }); } else { - if (status.get('visibility') === 'private') { + if (status.visibility === 'private') { menu.push({ text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), action: this.handleReblogClick, @@ -399,20 +423,20 @@ class ActionBar extends React.PureComponent { }); } else { menu.push({ - text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), + text: intl.formatMessage(messages.mention, { name: username }), action: this.handleMentionClick, icon: require('@tabler/icons/icons/at.svg'), }); // if (status.getIn(['account', 'pleroma', 'accepts_chat_messages'], false) === true) { // menu.push({ - // text: intl.formatMessage(messages.chat, { name: status.getIn(['account', 'username']) }), + // text: intl.formatMessage(messages.chat, { name: username }), // action: this.handleChatClick, // icon: require('@tabler/icons/icons/messages.svg'), // }); // } else { // menu.push({ - // text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), + // text: intl.formatMessage(messages.direct, { name: username }), // action: this.handleDirectClick, // icon: require('@tabler/icons/icons/mail.svg'), // }); @@ -420,17 +444,17 @@ class ActionBar extends React.PureComponent { menu.push(null); menu.push({ - text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), + text: intl.formatMessage(messages.mute, { name: username }), action: this.handleMuteClick, icon: require('@tabler/icons/icons/circle-x.svg'), }); menu.push({ - text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), + text: intl.formatMessage(messages.block, { name: username }), action: this.handleBlockClick, icon: require('@tabler/icons/icons/ban.svg'), }); menu.push({ - text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), + text: intl.formatMessage(messages.report, { name: username }), action: this.handleReport, icon: require('@tabler/icons/icons/flag.svg'), }); @@ -441,13 +465,13 @@ class ActionBar extends React.PureComponent { if (isAdmin) { menu.push({ - text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), + text: intl.formatMessage(messages.admin_account, { name: username }), href: `/pleroma/admin/#/users/${status.getIn(['account', 'id'])}/`, icon: require('@tabler/icons/icons/gavel.svg'), }); menu.push({ text: intl.formatMessage(messages.admin_status), - href: `/pleroma/admin/#/statuses/${status.get('id')}/`, + href: `/pleroma/admin/#/statuses/${status.id}/`, icon: require('@tabler/icons/icons/pencil.svg'), }); } @@ -460,12 +484,12 @@ class ActionBar extends React.PureComponent { if (!ownAccount) { menu.push({ - text: intl.formatMessage(messages.deactivateUser, { name: status.getIn(['account', 'username']) }), + text: intl.formatMessage(messages.deactivateUser, { name: username }), action: this.handleDeactivateUser, icon: require('@tabler/icons/icons/user-off.svg'), }); menu.push({ - text: intl.formatMessage(messages.deleteUser, { name: status.getIn(['account', 'username']) }), + text: intl.formatMessage(messages.deleteUser, { name: username }), action: this.handleDeleteUser, icon: require('@tabler/icons/icons/user-minus.svg'), destructive: true, @@ -496,7 +520,7 @@ class ActionBar extends React.PureComponent { let reblogButton; if (me && features.quotePosts) { - const reblogMenu = [ + const reblogMenu: Menu = [ { text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog), action: this.handleReblogClick, @@ -517,7 +541,6 @@ class ActionBar extends React.PureComponent { pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} src={reblogIcon} - direction='right' text={intl.formatMessage(messages.reblog)} onShiftClick={this.handleReblogClick} /> @@ -577,7 +600,6 @@ class ActionBar extends React.PureComponent { @@ -586,5 +608,5 @@ class ActionBar extends React.PureComponent { } -export default injectIntl( - connect(mapStateToProps, mapDispatchToProps)(ActionBar)); +const WrappedComponent = withRouter(injectIntl(ActionBar)); +export default connect(mapStateToProps, mapDispatchToProps)(WrappedComponent); diff --git a/app/soapbox/features/status/index.js b/app/soapbox/features/status/index.tsx similarity index 69% rename from app/soapbox/features/status/index.js rename to app/soapbox/features/status/index.tsx index 5392bc1bf..06d0e27ca 100644 --- a/app/soapbox/features/status/index.js +++ b/app/soapbox/features/status/index.tsx @@ -1,13 +1,11 @@ import classNames from 'classnames'; import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable'; -import PropTypes from 'prop-types'; import React from 'react'; import { HotKeys } from 'react-hotkeys'; -import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage, WrappedComponentProps as IntlComponentProps } from 'react-intl'; import { connect } from 'react-redux'; -import { withRouter } from 'react-router-dom'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; import { createSelector } from 'reselect'; import { launchChat } from 'soapbox/actions/chats'; @@ -58,10 +56,20 @@ import { textForScreenReader, defaultMediaVisibility } from '../../components/st import { makeGetStatus } from '../../selectors'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen'; -import ActionBar from './components/action_bar'; +import ActionBar from './components/action-bar'; import DetailedStatus from './components/detailed_status'; import ThreadStatus from './components/thread_status'; +import type { History } from 'history'; +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'; + const messages = defineMessages({ title: { id: 'status.title', defaultMessage: '@{username}\'s Post' }, titleDirect: { id: 'status.title_direct', defaultMessage: 'Direct message' }, @@ -84,8 +92,8 @@ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); const getAncestorsIds = createSelector([ - (_, { id }) => id, - state => state.getIn(['contexts', 'inReplyTos']), + (_: RootState, statusId: string) => statusId, + (state: RootState) => state.contexts.get('inReplyTos'), ], (statusId, inReplyTos) => { let ancestorsIds = ImmutableOrderedSet(); let id = statusId; @@ -99,8 +107,8 @@ const makeMapStateToProps = () => { }); const getDescendantsIds = createSelector([ - (_, { id }) => id, - state => state.getIn(['contexts', 'replies']), + (_: RootState, statusId: string) => statusId, + (state: RootState) => state.contexts.get('replies'), ], (statusId, contextReplies) => { let descendantsIds = ImmutableOrderedSet(); const ids = [statusId]; @@ -118,7 +126,7 @@ const makeMapStateToProps = () => { } if (replies) { - replies.reverse().forEach(reply => { + replies.reverse().forEach((reply: string) => { ids.unshift(reply); }); } @@ -127,15 +135,15 @@ const makeMapStateToProps = () => { return descendantsIds; }); - const mapStateToProps = (state, props) => { + const mapStateToProps = (state: RootState, props: { params: RouteParams }) => { const status = getStatus(state, { id: props.params.statusId }); let ancestorsIds = ImmutableOrderedSet(); let descendantsIds = ImmutableOrderedSet(); if (status) { - const statusId = status.get('id'); - ancestorsIds = getAncestorsIds(state, { id: state.getIn(['contexts', 'inReplyTos', statusId]) }); - descendantsIds = getDescendantsIds(state, { id: statusId }); + const statusId = status.id; + ancestorsIds = getAncestorsIds(state, state.contexts.getIn(['inReplyTos', statusId])); + descendantsIds = getDescendantsIds(state, statusId); ancestorsIds = ancestorsIds.delete(statusId).subtract(descendantsIds); descendantsIds = descendantsIds.delete(statusId).subtract(ancestorsIds); } @@ -146,34 +154,40 @@ const makeMapStateToProps = () => { status, ancestorsIds, descendantsIds, - askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0, - domain: state.getIn(['meta', 'domain']), - me: state.get('me'), + askReplyConfirmation: state.compose.get('text', '').trim().length !== 0, + me: state.me, displayMedia: getSettings(state).get('displayMedia'), - allowedEmoji: soapbox.get('allowedEmoji'), + allowedEmoji: soapbox.allowedEmoji, }; }; return mapStateToProps; }; -export default @connect(makeMapStateToProps) -@injectIntl -@withRouter -class Status extends ImmutablePureComponent { +type DisplayMedia = 'default' | 'hide_all' | 'show_all'; +type RouteParams = { statusId: string }; - static propTypes = { - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - status: ImmutablePropTypes.record, - ancestorsIds: ImmutablePropTypes.orderedSet, - descendantsIds: ImmutablePropTypes.orderedSet, - intl: PropTypes.object.isRequired, - askReplyConfirmation: PropTypes.bool, - domain: PropTypes.string, - displayMedia: PropTypes.string, - history: PropTypes.object, - }; +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, +} + +interface IStatusState { + fullscreen: boolean, + showMedia: boolean, + loadedStatusId?: string, + emojiSelectorFocused: boolean, +} + +class Status extends ImmutablePureComponent { state = { fullscreen: false, @@ -182,6 +196,10 @@ class Status extends ImmutablePureComponent { emojiSelectorFocused: false, }; + node: HTMLDivElement | null = null; + status: HTMLDivElement | null = null; + _scrolledIntoView: boolean = false; + fetchData = () => { const { dispatch, params } = this.props; const { statusId } = params; @@ -198,35 +216,35 @@ class Status extends ImmutablePureComponent { this.setState({ showMedia: !this.state.showMedia }); } - handleEmojiReactClick = (status, emoji) => { + handleEmojiReactClick = (status: StatusEntity, emoji: string) => { this.props.dispatch(simpleEmojiReact(status, emoji)); } - handleFavouriteClick = (status) => { - if (status.get('favourited')) { + handleFavouriteClick = (status: StatusEntity) => { + if (status.favourited) { this.props.dispatch(unfavourite(status)); } else { this.props.dispatch(favourite(status)); } } - handlePin = (status) => { - if (status.get('pinned')) { + handlePin = (status: StatusEntity) => { + if (status.pinned) { this.props.dispatch(unpin(status)); } else { this.props.dispatch(pin(status)); } } - handleBookmark = (status) => { - if (status.get('bookmarked')) { + handleBookmark = (status: StatusEntity) => { + if (status.bookmarked) { this.props.dispatch(unbookmark(status)); } else { this.props.dispatch(bookmark(status)); } } - handleReplyClick = (status) => { + handleReplyClick = (status: StatusEntity) => { const { askReplyConfirmation, dispatch, intl } = this.props; if (askReplyConfirmation) { dispatch(openModal('CONFIRM', { @@ -239,14 +257,14 @@ class Status extends ImmutablePureComponent { } } - handleModalReblog = (status) => { + handleModalReblog = (status: StatusEntity) => { this.props.dispatch(reblog(status)); } - handleReblogClick = (status, e) => { + handleReblogClick = (status: StatusEntity, e?: React.MouseEvent) => { this.props.dispatch((_, getState) => { const boostModal = getSettings(getState()).get('boostModal'); - if (status.get('reblogged')) { + if (status.reblogged) { this.props.dispatch(unreblog(status)); } else { if ((e && e.shiftKey) || !boostModal) { @@ -258,7 +276,7 @@ class Status extends ImmutablePureComponent { }); } - handleQuoteClick = (status, e) => { + handleQuoteClick = (status: StatusEntity) => { const { askReplyConfirmation, dispatch, intl } = this.props; if (askReplyConfirmation) { dispatch(openModal('CONFIRM', { @@ -271,147 +289,148 @@ class Status extends ImmutablePureComponent { } } - handleDeleteClick = (status, history, withRedraft = false) => { + handleDeleteClick = (status: StatusEntity, history: History, withRedraft = false) => { const { dispatch, intl } = this.props; this.props.dispatch((_, getState) => { const deleteModal = getSettings(getState()).get('deleteModal'); if (!deleteModal) { - dispatch(deleteStatus(status.get('id'), history, withRedraft)); + dispatch(deleteStatus(status.id, history, withRedraft)); } else { dispatch(openModal('CONFIRM', { icon: withRedraft ? require('@tabler/icons/icons/edit.svg') : require('@tabler/icons/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'), history, withRedraft)), + onConfirm: () => dispatch(deleteStatus(status.id, history, withRedraft)), })); } }); } - handleDirectClick = (account, router) => { + handleDirectClick = (account: AccountEntity, router: History) => { this.props.dispatch(directCompose(account, router)); } - handleChatClick = (account, router) => { - this.props.dispatch(launchChat(account.get('id'), router)); + handleChatClick = (account: AccountEntity, router: History) => { + this.props.dispatch(launchChat(account.id, router)); } - handleMentionClick = (account, router) => { + handleMentionClick = (account: AccountEntity, router: History) => { this.props.dispatch(mentionCompose(account, router)); } - handleOpenMedia = (media, index) => { + handleOpenMedia = (media: ImmutableList, index: number) => { this.props.dispatch(openModal('MEDIA', { media, index })); } - handleOpenVideo = (media, time) => { + handleOpenVideo = (media: ImmutableList, time: number) => { this.props.dispatch(openModal('VIDEO', { media, time })); } - handleHotkeyOpenMedia = e => { - const { onOpenMedia, onOpenVideo } = this.props; - const status = this._properStatus(); + handleHotkeyOpenMedia = (e?: KeyboardEvent) => { + const { status, onOpenMedia, onOpenVideo } = this.props; + const firstAttachment = status.media_attachments.get(0); - e.preventDefault(); + e?.preventDefault(); - if (status.get('media_attachments').size > 0) { - if (status.getIn(['media_attachments', 0, 'type']) === 'video') { - onOpenVideo(status.getIn(['media_attachments', 0]), 0); + if (status.media_attachments.size > 0 && firstAttachment) { + if (firstAttachment.type === 'video') { + onOpenVideo(firstAttachment, 0); } else { - onOpenMedia(status.get('media_attachments'), 0); + onOpenMedia(status.media_attachments, 0); } } } - handleMuteClick = (account) => { + handleMuteClick = (account: AccountEntity) => { this.props.dispatch(initMuteModal(account)); } - handleConversationMuteClick = (status) => { - if (status.get('muted')) { - this.props.dispatch(unmuteStatus(status.get('id'))); + handleConversationMuteClick = (status: StatusEntity) => { + if (status.muted) { + this.props.dispatch(unmuteStatus(status.id)); } else { - this.props.dispatch(muteStatus(status.get('id'))); + this.props.dispatch(muteStatus(status.id)); } } - handleToggleHidden = (status) => { - if (status.get('hidden')) { - this.props.dispatch(revealStatus(status.get('id'))); + handleToggleHidden = (status: StatusEntity) => { + if (status.hidden) { + this.props.dispatch(revealStatus(status.id)); } else { - this.props.dispatch(hideStatus(status.get('id'))); + this.props.dispatch(hideStatus(status.id)); } } handleToggleAll = () => { const { status, ancestorsIds, descendantsIds } = this.props; - const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS()); + const statusIds = [status.id].concat(ancestorsIds.toArray(), descendantsIds.toArray()); - if (status.get('hidden')) { + if (status.hidden) { this.props.dispatch(revealStatus(statusIds)); } else { this.props.dispatch(hideStatus(statusIds)); } } - handleBlockClick = (status) => { + handleBlockClick = (status: StatusEntity) => { const { dispatch, intl } = this.props; - const account = status.get('account'); + const { account } = status; + if (!account || typeof account !== 'object') return; dispatch(openModal('CONFIRM', { icon: require('@tabler/icons/icons/ban.svg'), - heading: , - message: @{account.get('acct')} }} />, + heading: , + message: @{account.acct} }} />, confirm: intl.formatMessage(messages.blockConfirm), - onConfirm: () => dispatch(blockAccount(account.get('id'))), + onConfirm: () => dispatch(blockAccount(account.id)), secondary: intl.formatMessage(messages.blockAndReport), onSecondary: () => { - dispatch(blockAccount(account.get('id'))); + dispatch(blockAccount(account.id)); dispatch(initReport(account, status)); }, })); } - handleReport = (status) => { - this.props.dispatch(initReport(status.get('account'), status)); + handleReport = (status: StatusEntity) => { + this.props.dispatch(initReport(status.account, status)); } - handleEmbed = (status) => { - this.props.dispatch(openModal('EMBED', { url: status.get('url') })); + handleEmbed = (status: StatusEntity) => { + this.props.dispatch(openModal('EMBED', { url: status.url })); } - handleDeactivateUser = (status) => { + handleDeactivateUser = (status: StatusEntity) => { const { dispatch, intl } = this.props; dispatch(deactivateUserModal(intl, status.getIn(['account', 'id']))); } - handleDeleteUser = (status) => { + handleDeleteUser = (status: StatusEntity) => { const { dispatch, intl } = this.props; dispatch(deleteUserModal(intl, status.getIn(['account', 'id']))); } - handleToggleStatusSensitivity = (status) => { + handleToggleStatusSensitivity = (status: StatusEntity) => { const { dispatch, intl } = this.props; - dispatch(toggleStatusSensitivityModal(intl, status.get('id'), status.get('sensitive'))); + dispatch(toggleStatusSensitivityModal(intl, status.id, status.sensitive)); } - handleDeleteStatus = (status) => { + handleDeleteStatus = (status: StatusEntity) => { const { dispatch, intl } = this.props; - dispatch(deleteStatusModal(intl, status.get('id'))); + dispatch(deleteStatusModal(intl, status.id)); } handleHotkeyMoveUp = () => { - this.handleMoveUp(this.props.status.get('id')); + this.handleMoveUp(this.props.status.id); } handleHotkeyMoveDown = () => { - this.handleMoveDown(this.props.status.get('id')); + this.handleMoveDown(this.props.status.id); } - handleHotkeyReply = e => { - e.preventDefault(); + handleHotkeyReply = (e?: KeyboardEvent) => { + e?.preventDefault(); this.handleReplyClick(this.props.status); } @@ -423,9 +442,11 @@ class Status extends ImmutablePureComponent { this.handleReblogClick(this.props.status); } - handleHotkeyMention = e => { - e.preventDefault(); - this.handleMentionClick(this.props.status.get('account')); + handleHotkeyMention = (e?: KeyboardEvent) => { + e?.preventDefault(); + const { account } = this.props.status; + if (!account || typeof account !== 'object') return; + this.handleMentionClick(account, this.props.history); } handleHotkeyOpenProfile = () => { @@ -444,10 +465,10 @@ class Status extends ImmutablePureComponent { this._expandEmojiSelector(); } - handleMoveUp = id => { + handleMoveUp = (id: string) => { const { status, ancestorsIds, descendantsIds } = this.props; - if (id === status.get('id')) { + if (id === status.id) { this._selectChild(ancestorsIds.size - 1, true); } else { let index = ImmutableList(ancestorsIds).indexOf(id); @@ -461,10 +482,10 @@ class Status extends ImmutablePureComponent { } } - handleMoveDown = id => { + handleMoveDown = (id: string) => { const { status, ancestorsIds, descendantsIds } = this.props; - if (id === status.get('id')) { + if (id === status.id) { this._selectChild(ancestorsIds.size + 1, false); } else { let index = ImmutableList(ancestorsIds).indexOf(id); @@ -478,26 +499,28 @@ class Status extends ImmutablePureComponent { } } - handleEmojiSelectorExpand = e => { + handleEmojiSelectorExpand: React.EventHandler = e => { if (e.key === 'Enter') { this._expandEmojiSelector(); } e.preventDefault(); } - handleEmojiSelectorUnfocus = () => { + handleEmojiSelectorUnfocus: React.EventHandler = () => { this.setState({ emojiSelectorFocused: false }); } _expandEmojiSelector = () => { + if (!this.status) return; this.setState({ emojiSelectorFocused: true }); - const firstEmoji = this.status.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); - firstEmoji.focus(); + const firstEmoji: HTMLButtonElement | null = this.status.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); + firstEmoji?.focus(); }; - _selectChild(index, align_top) { + _selectChild(index: number, align_top: boolean) { const container = this.node; - const element = container.querySelectorAll('.focusable')[index]; + if (!container) return; + const element = container.querySelectorAll('.focusable')[index] as HTMLButtonElement; if (element) { if (align_top && container.scrollTop > element.offsetTop) { @@ -509,7 +532,7 @@ class Status extends ImmutablePureComponent { } } - renderTombstone(id) { + renderTombstone(id: string) { return (

@@ -517,14 +540,14 @@ class Status extends ImmutablePureComponent { ); } - renderStatus(id) { + renderStatus(id: string) { const { status } = this.props; return ( ) { return list.map(id => { if (id.endsWith('-tombstone')) { return this.renderTombstone(id); @@ -560,16 +584,16 @@ class Status extends ImmutablePureComponent { }); } - setRef = c => { + setRef: React.RefCallback = c => { this.node = c; } - setStatusRef = c => { + setStatusRef: React.RefCallback = c => { this.status = c; } - componentDidUpdate(prevProps, prevState) { - const { params, status } = this.props; + componentDidUpdate(prevProps: IStatus, prevState: IStatusState) { + const { params, status, displayMedia } = this.props; const { ancestorsIds } = prevProps; if (params.statusId !== prevProps.params.statusId) { @@ -577,8 +601,8 @@ class Status extends ImmutablePureComponent { this.fetchData(); } - if (status && status.get('id') !== prevState.loadedStatusId) { - this.setState({ showMedia: defaultMediaVisibility(status), loadedStatusId: status.get('id') }); + if (status && status.id !== prevState.loadedStatusId) { + this.setState({ showMedia: defaultMediaVisibility(status, displayMedia), loadedStatusId: status.id }); } if (this._scrolledIntoView) { @@ -589,7 +613,7 @@ class Status extends ImmutablePureComponent { const element = this.node.querySelector('.detailed-status'); window.requestAnimationFrame(() => { - element.scrollIntoView(true); + element?.scrollIntoView(true); }); this._scrolledIntoView = true; } @@ -609,7 +633,7 @@ class Status extends ImmutablePureComponent { render() { let ancestors, descendants; - const { status, ancestorsIds, descendantsIds, intl, domain } = this.props; + const { status, ancestorsIds, descendantsIds, intl } = this.props; if (status === null) { return ( @@ -625,7 +649,9 @@ class Status extends ImmutablePureComponent { descendants = this.renderChildren(descendantsIds); } - const handlers = { + type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void }; + + const handlers: HotkeyHandlers = { moveUp: this.handleHotkeyMoveUp, moveDown: this.handleHotkeyMoveDown, reply: this.handleHotkeyReply, @@ -639,8 +665,8 @@ class Status extends ImmutablePureComponent { react: this.handleHotkeyReact, }; - const username = status.getIn(['account', 'acct']); - const titleMessage = status && status.get('visibility') === 'direct' ? messages.titleDirect : messages.title; + const username = String(status.getIn(['account', 'acct'])); + const titleMessage = status.visibility === 'direct' ? messages.titleDirect : messages.title; return ( @@ -656,13 +682,18 @@ class Status extends ImmutablePureComponent {
-
+
@@ -713,3 +744,7 @@ class Status extends ImmutablePureComponent { } } + +const WrappedComponent = withRouter(injectIntl(Status)); +// @ts-ignore +export default connect(makeMapStateToProps)(WrappedComponent); diff --git a/app/soapbox/selectors/index.ts b/app/soapbox/selectors/index.ts index f0ba336b3..ddac93b24 100644 --- a/app/soapbox/selectors/index.ts +++ b/app/soapbox/selectors/index.ts @@ -98,12 +98,12 @@ const toServerSideType = (columnType: string): string => { } }; -type FilterContext = { contextType: string }; +type FilterContext = { contextType?: string }; -export const getFilters = (state: RootState, { contextType }: FilterContext) => { +export const getFilters = (state: RootState, query: FilterContext) => { return state.filters.filter((filter): boolean => { - return contextType - && filter.get('context').includes(toServerSideType(contextType)) + return query?.contextType + && filter.get('context').includes(toServerSideType(query.contextType)) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > new Date().getTime()); }); @@ -132,7 +132,7 @@ export const regexFromFilters = (filters: ImmutableList { return createSelector(