diff --git a/app/soapbox/components/__tests__/quoted-status.test.tsx b/app/soapbox/components/__tests__/quoted-status.test.tsx new file mode 100644 index 000000000..208a913b2 --- /dev/null +++ b/app/soapbox/components/__tests__/quoted-status.test.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { render, screen, rootState } from '../../jest/test-helpers'; +import { normalizeStatus, normalizeAccount } from '../../normalizers'; +import QuotedStatus from '../quoted-status'; + +describe('', () => { + it('renders content', () => { + const account = normalizeAccount({ + id: '1', + acct: 'alex', + }); + + const status = normalizeStatus({ + id: '1', + account, + content: 'hello world', + contentHtml: 'hello world', + }); + + const state = rootState.setIn(['accounts', '1', account]); + + render(, null, state); + screen.getByText(/hello world/i); + expect(screen.getByTestId('quoted-status')).toHaveTextContent(/hello world/i); + }); +}); diff --git a/app/soapbox/features/status/components/quoted_status.tsx b/app/soapbox/components/quoted-status.tsx similarity index 85% rename from app/soapbox/features/status/components/quoted_status.tsx rename to app/soapbox/components/quoted-status.tsx index bf8069aa8..5d6eb526e 100644 --- a/app/soapbox/features/status/components/quoted_status.tsx +++ b/app/soapbox/components/quoted-status.tsx @@ -1,11 +1,13 @@ import classNames from 'classnames'; -import React from 'react'; +import React, { useState } from 'react'; import { defineMessages, useIntl, FormattedMessage, FormattedList } from 'react-intl'; import { useHistory } from 'react-router-dom'; import StatusMedia from 'soapbox/components/status-media'; import { Stack, Text } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account_container'; +import { useSettings } from 'soapbox/hooks'; +import { defaultMediaVisibility } from 'soapbox/utils/status'; import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities'; @@ -27,6 +29,11 @@ const QuotedStatus: React.FC = ({ status, onCancel, compose }) => const intl = useIntl(); const history = useHistory(); + const settings = useSettings(); + const displayMedia = settings.get('displayMedia'); + + const [showMedia, setShowMedia] = useState(defaultMediaVisibility(status, displayMedia)); + const handleExpandClick = (e: React.MouseEvent) => { if (!status) return; const account = status.account as AccountEntity; @@ -44,6 +51,10 @@ const QuotedStatus: React.FC = ({ status, onCancel, compose }) => } }; + const handleToggleMediaVisibility = () => { + setShowMedia(!showMedia); + }; + const renderReplyMentions = () => { if (!status?.in_reply_to_id) { return null; @@ -113,6 +124,7 @@ const QuotedStatus: React.FC = ({ status, onCancel, compose }) => return ( = ({ status, onCancel, compose }) => dangerouslySetInnerHTML={{ __html: status.contentHtml }} /> - + ); }; diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 2b1325a60..4389b1fc1 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -8,6 +8,7 @@ import { NavLink, withRouter, RouteComponentProps } 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 StatusMedia from './status-media'; import StatusReplyMentions from './status-reply-mentions'; @@ -50,16 +51,6 @@ export const textForScreenReader = (intl: IntlShape, status: StatusEntity, reblo return values.join(', '); }; -export const defaultMediaVisibility = (status: StatusEntity, displayMedia: string): boolean => { - if (!status) return false; - - if (status.reblog && typeof status.reblog === 'object') { - status = status.reblog; - } - - return (displayMedia !== 'hide_all' && !status.sensitive || displayMedia === 'show_all'); -}; - interface IStatus extends RouteComponentProps { id?: string, contextType?: string, @@ -431,7 +422,7 @@ class Status extends ImmutablePureComponent { ); } else { - quote = ; + quote = ; } } diff --git a/app/soapbox/components/ui/form-group/form-group.tsx b/app/soapbox/components/ui/form-group/form-group.tsx index 75517fc79..ec0da73e4 100644 --- a/app/soapbox/components/ui/form-group/form-group.tsx +++ b/app/soapbox/components/ui/form-group/form-group.tsx @@ -8,6 +8,8 @@ import Stack from '../stack/stack'; interface IFormGroup { /** Input label message. */ labelText?: React.ReactNode, + /** Input label tooltip message. */ + labelTitle?: string, /** Input hint message. */ hintText?: React.ReactNode, /** Input errors. */ @@ -16,7 +18,7 @@ interface IFormGroup { /** Input container with label. Renders the child. */ const FormGroup: React.FC = (props) => { - const { children, errors = [], labelText, hintText } = props; + const { children, errors = [], labelText, labelTitle, hintText } = props; const formFieldId: string = useMemo(() => `field-${uuidv4()}`, []); const inputChildren = React.Children.toArray(children); const hasError = errors?.length > 0; @@ -41,6 +43,7 @@ const FormGroup: React.FC = (props) => { htmlFor={formFieldId} data-testid='form-group-label' className='-mt-0.5 block text-sm font-medium text-gray-700 dark:text-gray-400' + title={labelTitle} > {labelText} @@ -74,6 +77,7 @@ const FormGroup: React.FC = (props) => { htmlFor={formFieldId} data-testid='form-group-label' className='block text-sm font-medium text-gray-700 dark:text-gray-400' + title={labelTitle} > {labelText} diff --git a/app/soapbox/features/compose/components/sensitive-button.tsx b/app/soapbox/features/compose/components/sensitive-button.tsx new file mode 100644 index 000000000..e1c7b48ba --- /dev/null +++ b/app/soapbox/features/compose/components/sensitive-button.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; + +import { changeComposeSensitivity } from 'soapbox/actions/compose'; +import { FormGroup, Checkbox } from 'soapbox/components/ui'; +import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; + +const messages = defineMessages({ + marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' }, + unmarked: { id: 'compose_form.sensitive.unmarked', defaultMessage: 'Media is not marked as sensitive' }, +}); + +/** Button to mark own media as sensitive. */ +const SensitiveButton: React.FC = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const active = useAppSelector(state => state.compose.get('sensitive') === true); + const disabled = useAppSelector(state => state.compose.get('spoiler') === true); + + const onClick = () => { + dispatch(changeComposeSensitivity()); + }; + + return ( +
+ } + labelTitle={intl.formatMessage(active ? messages.marked : messages.unmarked)} + > + + +
+ ); +}; + +export default SensitiveButton; diff --git a/app/soapbox/features/compose/components/upload_form.tsx b/app/soapbox/features/compose/components/upload_form.tsx index 00e66030c..ae44d2561 100644 --- a/app/soapbox/features/compose/components/upload_form.tsx +++ b/app/soapbox/features/compose/components/upload_form.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { useAppSelector } from 'soapbox/hooks'; -// import SensitiveButtonContainer from '../containers/sensitive_button_container'; +import SensitiveButton from '../components/sensitive-button'; import UploadProgress from '../components/upload-progress'; import UploadContainer from '../containers/upload_container'; @@ -25,7 +25,7 @@ const UploadForm = () => { ))} - {/* {!mediaIds.isEmpty() && } */} + {!mediaIds.isEmpty() && } ); }; diff --git a/app/soapbox/features/compose/containers/quoted_status_container.js b/app/soapbox/features/compose/containers/quoted_status_container.js deleted file mode 100644 index c15fa1764..000000000 --- a/app/soapbox/features/compose/containers/quoted_status_container.js +++ /dev/null @@ -1,26 +0,0 @@ -import { connect } from 'react-redux'; - -import { cancelQuoteCompose } from 'soapbox/actions/compose'; -import QuotedStatus from 'soapbox/features/status/components/quoted_status'; -import { makeGetStatus } from 'soapbox/selectors'; - -const makeMapStateToProps = () => { - const getStatus = makeGetStatus(); - - const mapStateToProps = state => ({ - status: getStatus(state, { id: state.getIn(['compose', 'quote']) }), - compose: true, - }); - - return mapStateToProps; -}; - -const mapDispatchToProps = dispatch => ({ - - onCancel() { - dispatch(cancelQuoteCompose()); - }, - -}); - -export default connect(makeMapStateToProps, mapDispatchToProps)(QuotedStatus); \ No newline at end of file diff --git a/app/soapbox/features/compose/containers/quoted_status_container.tsx b/app/soapbox/features/compose/containers/quoted_status_container.tsx new file mode 100644 index 000000000..923ee7baa --- /dev/null +++ b/app/soapbox/features/compose/containers/quoted_status_container.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +import { cancelQuoteCompose } from 'soapbox/actions/compose'; +import QuotedStatus from 'soapbox/components/quoted-status'; +import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; +import { makeGetStatus } from 'soapbox/selectors'; + +const getStatus = makeGetStatus(); + +/** QuotedStatus shown in post composer. */ +const QuotedStatusContainer: React.FC = () => { + const dispatch = useAppDispatch(); + const status = useAppSelector(state => getStatus(state, { id: state.compose.get('quote') })); + + const onCancel = () => { + dispatch(cancelQuoteCompose()); + }; + + if (!status) { + return null; + } + + return ( + + ); +}; + +export default QuotedStatusContainer; diff --git a/app/soapbox/features/compose/containers/sensitive_button_container.js b/app/soapbox/features/compose/containers/sensitive_button_container.js deleted file mode 100644 index b28d3c1f7..000000000 --- a/app/soapbox/features/compose/containers/sensitive_button_container.js +++ /dev/null @@ -1,60 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { changeComposeSensitivity } from 'soapbox/actions/compose'; - -const messages = defineMessages({ - marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' }, - unmarked: { id: 'compose_form.sensitive.unmarked', defaultMessage: 'Media is not marked as sensitive' }, -}); - -const mapStateToProps = state => ({ - active: state.getIn(['compose', 'sensitive']), - disabled: state.getIn(['compose', 'spoiler']), -}); - -const mapDispatchToProps = dispatch => ({ - - onClick() { - dispatch(changeComposeSensitivity()); - }, - -}); - -class SensitiveButton extends React.PureComponent { - - static propTypes = { - active: PropTypes.bool, - disabled: PropTypes.bool, - onClick: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - }; - - render() { - const { active, disabled, onClick, intl } = this.props; - - return ( -
- -
- ); - } - -} - -export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton)); diff --git a/app/soapbox/features/status/components/detailed-status.tsx b/app/soapbox/features/status/components/detailed-status.tsx index 203a9836e..640b3d5d2 100644 --- a/app/soapbox/features/status/components/detailed-status.tsx +++ b/app/soapbox/features/status/components/detailed-status.tsx @@ -115,7 +115,7 @@ class DetailedStatus extends ImmutablePureComponent ); } else { - quote = ; + quote = ; } } diff --git a/app/soapbox/features/status/containers/quoted_status_container.js b/app/soapbox/features/status/containers/quoted_status_container.js deleted file mode 100644 index a375b2562..000000000 --- a/app/soapbox/features/status/containers/quoted_status_container.js +++ /dev/null @@ -1,17 +0,0 @@ -import { connect } from 'react-redux'; - -import { makeGetStatus } from 'soapbox/selectors'; - -import QuotedStatus from '../components/quoted_status'; - -const makeMapStateToProps = () => { - const getStatus = makeGetStatus(); - - const mapStateToProps = (state, { statusId }) => ({ - status: getStatus(state, { id: statusId }), - }); - - return mapStateToProps; -}; - -export default connect(makeMapStateToProps)(QuotedStatus); diff --git a/app/soapbox/features/status/containers/quoted_status_container.tsx b/app/soapbox/features/status/containers/quoted_status_container.tsx new file mode 100644 index 000000000..f64ad2255 --- /dev/null +++ b/app/soapbox/features/status/containers/quoted_status_container.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import QuotedStatus from 'soapbox/components/quoted-status'; +import { useAppSelector } from 'soapbox/hooks'; +import { makeGetStatus } from 'soapbox/selectors'; + +const getStatus = makeGetStatus(); + +interface IQuotedStatusContainer { + /** Status ID to the quoted status. */ + statusId: string, +} + +const QuotedStatusContainer: React.FC = ({ statusId }) => { + const status = useAppSelector(state => getStatus(state, { id: statusId })); + + if (!status) { + return null; + } + + return ( + + ); +}; + +export default QuotedStatusContainer; diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index b3c4452e9..7d569190c 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -50,13 +50,14 @@ import { } from 'soapbox/actions/statuses'; import MissingIndicator from 'soapbox/components/missing_indicator'; import ScrollableList from 'soapbox/components/scrollable_list'; -import { textForScreenReader, defaultMediaVisibility } from 'soapbox/components/status'; +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'; import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status'; import PendingStatus from 'soapbox/features/ui/components/pending_status'; import { makeGetStatus } from 'soapbox/selectors'; +import { defaultMediaVisibility } from 'soapbox/utils/status'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen'; diff --git a/app/soapbox/features/ui/components/pending_status.tsx b/app/soapbox/features/ui/components/pending_status.tsx index f4b990fc6..03853008c 100644 --- a/app/soapbox/features/ui/components/pending_status.tsx +++ b/app/soapbox/features/ui/components/pending_status.tsx @@ -83,7 +83,7 @@ const PendingStatus: React.FC = ({ idempotencyKey, className, mu {status.poll && } - {status.quote && } + {status.quote && } {/* TODO */} diff --git a/app/soapbox/utils/__tests__/status-test.js b/app/soapbox/utils/__tests__/status-test.js index 0dcb3e78a..4556382de 100644 --- a/app/soapbox/utils/__tests__/status-test.js +++ b/app/soapbox/utils/__tests__/status-test.js @@ -2,7 +2,10 @@ import { fromJS } from 'immutable'; import { normalizeStatus } from 'soapbox/normalizers/status'; -import { hasIntegerMediaIds } from '../status'; +import { + hasIntegerMediaIds, + defaultMediaVisibility, +} from '../status'; describe('hasIntegerMediaIds()', () => { it('returns true for a Pleroma deleted status', () => { @@ -10,3 +13,24 @@ describe('hasIntegerMediaIds()', () => { expect(hasIntegerMediaIds(status)).toBe(true); }); }); + +describe('defaultMediaVisibility()', () => { + it('returns false with no status', () => { + expect(defaultMediaVisibility(undefined, 'default')).toBe(false); + }); + + it('hides sensitive media by default', () => { + const status = normalizeStatus({ sensitive: true }); + expect(defaultMediaVisibility(status, 'default')).toBe(false); + }); + + it('hides media when displayMedia is hide_all', () => { + const status = normalizeStatus({}); + expect(defaultMediaVisibility(status, 'hide_all')).toBe(false); + }); + + it('shows sensitive media when displayMedia is show_all', () => { + const status = normalizeStatus({ sensitive: true }); + expect(defaultMediaVisibility(status, 'show_all')).toBe(true); + }); +}); diff --git a/app/soapbox/utils/status.ts b/app/soapbox/utils/status.ts index b735fb75d..439edfc02 100644 --- a/app/soapbox/utils/status.ts +++ b/app/soapbox/utils/status.ts @@ -2,6 +2,17 @@ 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 => { + if (!status) return false; + + if (status.reblog && typeof status.reblog === 'object') { + status = status.reblog; + } + + return (displayMedia !== 'hide_all' && !status.sensitive || displayMedia === 'show_all'); +}; + /** Grab the first external link from a status. */ export const getFirstExternalLink = (status: StatusEntity): HTMLAnchorElement | null => { try {