From 924b042c84c742975fb90f406dd4aeede8f63f3a Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 27 Apr 2022 10:11:21 -0400 Subject: [PATCH] Improve reporting modal --- .eslintrc.js | Bin 6268 -> 6342 bytes app/soapbox/actions/reports.js | Bin 2452 -> 2444 bytes app/soapbox/actions/rules.ts | 31 +++ .../components/ui/form-group/form-group.tsx | 4 +- app/soapbox/components/ui/modal/modal.tsx | 9 +- app/soapbox/components/ui/text/text.tsx | 4 +- .../components/ui/textarea/textarea.tsx | 2 +- .../report/components/status_check_box.js | Bin 2964 -> 2978 bytes .../modals/report-modal/report-modal.tsx | 133 ++++++++++++ .../report-modal/steps/confirmation-step.tsx | 26 +++ .../report-modal/steps/other-actions-step.tsx | 180 +++++++++++++++++ .../modals/report-modal/steps/reason-step.tsx | 189 ++++++++++++++++++ .../features/ui/components/report_modal.tsx | 129 ------------ .../features/ui/util/async-components.ts | 2 +- app/soapbox/features/verification/index.tsx | 7 +- app/soapbox/reducers/index.ts | 2 + app/soapbox/reducers/reports.js | Bin 2234 -> 2362 bytes app/soapbox/reducers/rules.ts | 31 +++ app/soapbox/utils/features.ts | 5 + 19 files changed, 613 insertions(+), 141 deletions(-) create mode 100644 app/soapbox/actions/rules.ts create mode 100644 app/soapbox/features/ui/components/modals/report-modal/report-modal.tsx create mode 100644 app/soapbox/features/ui/components/modals/report-modal/steps/confirmation-step.tsx create mode 100644 app/soapbox/features/ui/components/modals/report-modal/steps/other-actions-step.tsx create mode 100644 app/soapbox/features/ui/components/modals/report-modal/steps/reason-step.tsx delete mode 100644 app/soapbox/features/ui/components/report_modal.tsx create mode 100644 app/soapbox/reducers/rules.ts diff --git a/.eslintrc.js b/.eslintrc.js index bb111e2f45267de89b306bee30f7e098e752bd5e..bce34ca612aad836c31f7025084f6bdb48c86ad7 100644 GIT binary patch delta 54 zcmexkaLjN+8l$L!g1SRVWkG6ja#3bMiEe6fPG(-o=0rv|b}se&v@~@cE(HaJ$^H_e Jo5MKN1ONp{5UT(H delta 21 dcmX?R_{U&F8sp{$Mn?9@OF0!cU*h<}4*+2=2x$NS diff --git a/app/soapbox/actions/reports.js b/app/soapbox/actions/reports.js index 8b7c55644cddd0a858256cf9706652194e765563..9a23b51f447a6214f2e2daa9aeee6ea4ef61ed71 100644 GIT binary patch delta 156 zcmbOt+#@_eccY~$)8q_BUfG~fAJ=$i4@WNi3dq+_F;O@#8#AA zQd*R^*^1SgaqP^E8T5iwpAeic=MA?G&oH z6u=-Qv$!C!BsoI^tj#w+B{4@sQ_~tKJK2#{(jvGtIXShs7_L<>C9xzCBCnuOtEra^ z)|*;XlwSlg6>i$(I(G5Nax5}HnrHGQwiMQ))Pnq?$^Y0DCv!0J@#=tW2aA>HOjcx( K++4>#mk|JmGCZXK diff --git a/app/soapbox/actions/rules.ts b/app/soapbox/actions/rules.ts new file mode 100644 index 0000000000..1e2c29eea8 --- /dev/null +++ b/app/soapbox/actions/rules.ts @@ -0,0 +1,31 @@ +import api from '../api'; + +import type { Rule } from 'soapbox/reducers/rules'; + +const RULES_FETCH_REQUEST = 'RULES_FETCH_REQUEST'; +const RULES_FETCH_SUCCESS = 'RULES_FETCH_SUCCESS'; + +type RulesFetchRequestAction = { + type: typeof RULES_FETCH_REQUEST +} + +type RulesFetchRequestSuccessAction = { + type: typeof RULES_FETCH_SUCCESS + payload: Rule[] +} + +export type RulesActions = RulesFetchRequestAction | RulesFetchRequestSuccessAction + +const fetchRules = () => (dispatch: React.Dispatch, getState: any) => { + dispatch({ type: RULES_FETCH_REQUEST }); + + return api(getState) + .get('/api/v1/instance/rules') + .then((response) => dispatch({ type: RULES_FETCH_SUCCESS, payload: response.data })); +}; + +export { + fetchRules, + RULES_FETCH_REQUEST, + RULES_FETCH_SUCCESS, +}; diff --git a/app/soapbox/components/ui/form-group/form-group.tsx b/app/soapbox/components/ui/form-group/form-group.tsx index 193f2db9d5..fe78d46159 100644 --- a/app/soapbox/components/ui/form-group/form-group.tsx +++ b/app/soapbox/components/ui/form-group/form-group.tsx @@ -3,9 +3,9 @@ import { v4 as uuidv4 } from 'uuid'; interface IFormGroup { /** Input label message. */ - hintText?: React.ReactNode, + hintText?: string | React.ReactNode, /** Input hint message. */ - labelText: React.ReactNode, + labelText: string | React.ReactNode, /** Input errors. */ errors?: string[] } diff --git a/app/soapbox/components/ui/modal/modal.tsx b/app/soapbox/components/ui/modal/modal.tsx index 29ca9578b2..ab714b93c4 100644 --- a/app/soapbox/components/ui/modal/modal.tsx +++ b/app/soapbox/components/ui/modal/modal.tsx @@ -29,6 +29,8 @@ interface IModal { secondaryAction?: () => void, /** Secondary button text. */ secondaryText?: string, + /** Don't focus the "confirm" button on mount. */ + skipFocus?: boolean, /** Title text for the modal. */ title: string | React.ReactNode, } @@ -45,16 +47,17 @@ const Modal: React.FC = ({ onClose, secondaryAction, secondaryText, + skipFocus = false, title, }) => { const intl = useIntl(); const buttonRef = React.useRef(null); React.useEffect(() => { - if (buttonRef?.current) { + if (buttonRef?.current && !skipFocus) { buttonRef.current.focus(); } - }, [buttonRef]); + }, [skipFocus, buttonRef]); return (
@@ -89,7 +92,7 @@ const Modal: React.FC = ({ theme='ghost' onClick={cancelAction} > - {cancelText} + {cancelText || 'Cancel'} )}
diff --git a/app/soapbox/components/ui/text/text.tsx b/app/soapbox/components/ui/text/text.tsx index 4b68f3c9c0..9c7c8ddae2 100644 --- a/app/soapbox/components/ui/text/text.tsx +++ b/app/soapbox/components/ui/text/text.tsx @@ -8,7 +8,7 @@ type Alignments = 'left' | 'center' | 'right' type TrackingSizes = 'normal' | 'wide' type TransformProperties = 'uppercase' | 'normal' type Families = 'sans' | 'mono' -type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' +type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label' const themes = { default: 'text-gray-900 dark:text-gray-100', @@ -66,6 +66,8 @@ interface IText extends Pick, 'danger className?: string, /** Typeface of the text. */ family?: Families, + /** The "for" attribute specifies which form element a label is bound to. */ + htmlFor?: string, /** Font size of the text. */ size?: Sizes, /** HTML element name of the outer element. */ diff --git a/app/soapbox/components/ui/textarea/textarea.tsx b/app/soapbox/components/ui/textarea/textarea.tsx index dc903eaf35..fbd8ba86f0 100644 --- a/app/soapbox/components/ui/textarea/textarea.tsx +++ b/app/soapbox/components/ui/textarea/textarea.tsx @@ -1,7 +1,7 @@ import classNames from 'classnames'; import React from 'react'; -interface ITextarea extends Pick, 'maxLength' | 'onChange' | 'required'> { +interface ITextarea extends Pick, 'maxLength' | 'onChange' | 'required' | 'disabled'> { /** Put the cursor into the input on mount. */ autoFocus?: boolean, /** The initial text in the input. */ diff --git a/app/soapbox/features/report/components/status_check_box.js b/app/soapbox/features/report/components/status_check_box.js index b74253f96bb0c4816a422689db32714a7f4be0ac..ebee7a3eeda38d0b7e34babf8318f78c0ab5133a 100644 GIT binary patch delta 24 fcmbOtzDRsSIyX;da(-U1ZFO2=PI2nwRBn9$ZK4RI delta 11 ScmZ1^K1F;(I``yKZe0KwG6Tl| diff --git a/app/soapbox/features/ui/components/modals/report-modal/report-modal.tsx b/app/soapbox/features/ui/components/modals/report-modal/report-modal.tsx new file mode 100644 index 0000000000..9213fc60c0 --- /dev/null +++ b/app/soapbox/features/ui/components/modals/report-modal/report-modal.tsx @@ -0,0 +1,133 @@ +import { AxiosError } from 'axios'; +import { Set as ImmutableSet } from 'immutable'; +import React, { useEffect, useMemo, useState } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { blockAccount } from 'soapbox/actions/accounts'; +import { submitReport, cancelReport, submitReportSuccess, submitReportFail } from 'soapbox/actions/reports'; +import { expandAccountTimeline } from 'soapbox/actions/timelines'; +import { Modal } from 'soapbox/components/ui'; +import { useAccount, useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +import ConfirmationStep from './steps/confirmation-step'; +import OtherActionsStep from './steps/other-actions-step'; +import ReasonStep from './steps/reason-step'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' }, + submit: { id: 'report.submit', defaultMessage: 'Submit' }, +}); + +enum Steps { + ONE = 'ONE', + TWO = 'TWO', + THREE = 'THREE', +} + +const reportSteps = { + ONE: ReasonStep, + TWO: OtherActionsStep, + THREE: ConfirmationStep, +}; + +interface IReportModal { + onClose: () => void +} + +const ReportModal = ({ onClose }: IReportModal) => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + + const accountId = useAppSelector((state) => state.reports.getIn(['new', 'account_id']) as string); + const account = useAccount(accountId); + + const isBlocked = useAppSelector((state) => state.reports.getIn(['new', 'block']) as boolean); + const isSubmitting = useAppSelector((state) => state.reports.getIn(['new', 'isSubmitting']) as boolean); + const rules = useAppSelector((state) => state.rules.items); + const ruleId = useAppSelector((state) => state.reports.getIn(['new', 'rule_id']) as string); + const selectedStatusIds = useAppSelector((state) => state.reports.getIn(['new', 'status_ids']) as ImmutableSet); + + const shouldRequireRule = rules.length > 0; + + const [currentStep, setCurrentStep] = useState(Steps.ONE); + + const handleSubmit = () => { + dispatch(submitReport()) + .then(() => setCurrentStep(Steps.THREE)) + .catch((error: AxiosError) => dispatch(submitReportFail(error))); + + if (isBlocked && account) { + dispatch(blockAccount(account.id)); + } + }; + + const handleClose = () => { + dispatch(cancelReport()); + onClose(); + }; + + const handleNextStep = () => { + switch (currentStep) { + case Steps.ONE: + setCurrentStep(Steps.TWO); + break; + case Steps.TWO: + handleSubmit(); + break; + case Steps.THREE: + dispatch(submitReportSuccess()); + onClose(); + break; + default: + break; + } + }; + + const confirmationText = useMemo(() => { + switch (currentStep) { + case Steps.TWO: + return intl.formatMessage(messages.submit); + case Steps.THREE: + return 'Done'; + default: + return 'Next'; + } + }, [currentStep]); + + const isConfirmationButtonDisabled = useMemo(() => { + if (currentStep === Steps.THREE) { + return false; + } + + return isSubmitting || (shouldRequireRule && !ruleId) || selectedStatusIds.size === 0; + }, [currentStep, isSubmitting, shouldRequireRule, ruleId, selectedStatusIds.size]); + + useEffect(() => { + if (account) { + dispatch(expandAccountTimeline(account.id, { withReplies: true, maxId: null })); + } + }, [account]); + + if (!account) { + return null; + } + + const StepToRender = reportSteps[currentStep]; + + return ( + @{account.acct} }} />} + onClose={handleClose} + cancelAction={onClose} + confirmationAction={handleNextStep} + confirmationText={confirmationText} + confirmationDisabled={isConfirmationButtonDisabled} + skipFocus + > + + + ); +}; + +export default ReportModal; diff --git a/app/soapbox/features/ui/components/modals/report-modal/steps/confirmation-step.tsx b/app/soapbox/features/ui/components/modals/report-modal/steps/confirmation-step.tsx new file mode 100644 index 0000000000..836624de42 --- /dev/null +++ b/app/soapbox/features/ui/components/modals/report-modal/steps/confirmation-step.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { Stack, Text } from 'soapbox/components/ui'; + +import type { ReducerAccount } from 'soapbox/reducers/accounts'; + +interface IOtherActionsStep { + account: ReducerAccount +} + +const ConfirmationStep = ({ account }: IOtherActionsStep) => { + return ( + + + Thanks for submitting your report. + + + + If we find that this account is violating the TRUTH Terms of Service we + will take further action on the matter. + + + ); +}; + +export default ConfirmationStep; diff --git a/app/soapbox/features/ui/components/modals/report-modal/steps/other-actions-step.tsx b/app/soapbox/features/ui/components/modals/report-modal/steps/other-actions-step.tsx new file mode 100644 index 0000000000..a2231995a9 --- /dev/null +++ b/app/soapbox/features/ui/components/modals/report-modal/steps/other-actions-step.tsx @@ -0,0 +1,180 @@ +import { OrderedSet, Set as ImmutableSet } from 'immutable'; +import React, { useCallback, useEffect, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import Toggle from 'react-toggle'; + +import { changeReportBlock, changeReportForward } from 'soapbox/actions/reports'; +import { fetchRules } from 'soapbox/actions/rules'; +import AttachmentThumbs from 'soapbox/components/attachment_thumbs'; +import StatusContent from 'soapbox/components/status_content'; +import { Button, FormGroup, HStack, Stack, Text } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account_container'; +import StatusCheckBox from 'soapbox/features/report/containers/status_check_box_container'; +import { useAppSelector, useFeatures } from 'soapbox/hooks'; +import { isRemote, getDomain } from 'soapbox/utils/accounts'; + +import type { ReducerAccount } from 'soapbox/reducers/accounts'; + +const SelectedStatus = ({ statusId }: { statusId: string }) => { + const status = useAppSelector((state) => state.statuses.get(statusId)); + + if (!status) { + return null; + } + + return ( + + + + + + {status.get('media_attachments').size > 0 && ( + + )} + + ); +}; + +interface IOtherActionsStep { + account: ReducerAccount +} + +const OtherActionsStep = ({ account }: IOtherActionsStep) => { + const dispatch = useDispatch(); + const features = useFeatures(); + + const selectedStatusIds = useAppSelector((state) => state.reports.getIn(['new', 'status_ids']) as ImmutableSet); + const statusIds = useAppSelector((state) => OrderedSet(state.timelines.getIn([`account:${account.id}:with_replies`, 'items'])).union(state.reports.getIn(['new', 'status_ids']) as Iterable) as OrderedSet); + const isBlocked = useAppSelector((state) => state.reports.getIn(['new', 'block']) as boolean); + const isForward = useAppSelector((state) => state.reports.getIn(['reports', 'forward']) as boolean); + const canForward = isRemote(account as any) && features.federating; + const isSubmitting = useAppSelector((state) => state.reports.getIn(['new', 'isSubmitting']) as boolean); + + const [showAdditionalStatuses, setShowAdditionalStatuses] = useState(false); + + const renderSelectedStatuses = useCallback(() => { + switch (selectedStatusIds.size) { + case 0: + return ( +
+ You have removed all statuses from being selected. +
+ ); + default: + return ; + } + }, [selectedStatusIds.size]); + + const handleBlockChange = (event: React.ChangeEvent) => { + dispatch(changeReportBlock(event.target.checked)); + }; + + const handleForwardChange = (event: React.ChangeEvent) => { + dispatch(changeReportForward(event.target.checked)); + }; + + useEffect(() => { + dispatch(fetchRules()); + }, []); + + return ( + + {renderSelectedStatuses()} + + {!features.reportMultipleStatuses && ( + + Include other statuses? + + + {showAdditionalStatuses ? ( + +
+ {statusIds.map((statusId) => )} +
+ +
+ +
+
+ ) : ( + + )} +
+
+ )} + + + Further actions: + + } + > + + + + + + + + + + {canForward && ( + } + > + + + + + + + + + )} + +
+ ); +}; + +export default OtherActionsStep; diff --git a/app/soapbox/features/ui/components/modals/report-modal/steps/reason-step.tsx b/app/soapbox/features/ui/components/modals/report-modal/steps/reason-step.tsx new file mode 100644 index 0000000000..e8a6d07ef9 --- /dev/null +++ b/app/soapbox/features/ui/components/modals/report-modal/steps/reason-step.tsx @@ -0,0 +1,189 @@ +import classNames from 'classnames'; +import { Set as ImmutableSet } from 'immutable'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { changeReportComment, changeReportRule } from 'soapbox/actions/reports'; +import { fetchRules } from 'soapbox/actions/rules'; +import AttachmentThumbs from 'soapbox/components/attachment_thumbs'; +import StatusContent from 'soapbox/components/status_content'; +import { FormGroup, Stack, Text, Textarea } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account_container'; +import { useAppSelector } from 'soapbox/hooks'; + +import type { ReducerAccount } from 'soapbox/reducers/accounts'; + +const messages = defineMessages({ + placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' }, +}); + +const SelectedStatus = ({ statusId }: { statusId: string }) => { + const status = useAppSelector((state) => state.statuses.get(statusId)); + + if (!status) { + return null; + } + + return ( + + + + + + {status.get('media_attachments').size > 0 && ( + + )} + + ); +}; + +interface IReasonStep { + account: ReducerAccount +} + +const ReasonStep = (_props: IReasonStep) => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const rulesListRef = useRef(null); + + const [isNearBottom, setNearBottom] = useState(false); + const [isNearTop, setNearTop] = useState(true); + + const selectedStatusIds = useAppSelector((state) => state.reports.getIn(['new', 'status_ids']) as ImmutableSet); + const comment = useAppSelector((state) => state.reports.getIn(['new', 'comment']) as string); + const rules = useAppSelector((state) => state.rules.items); + const ruleId = useAppSelector((state) => state.reports.getIn(['new', 'rule_id']) as boolean); + + const renderSelectedStatuses = useCallback(() => { + switch (selectedStatusIds.size) { + case 0: + return ( +
+ You have removed all statuses from being selected. +
+ ); + default: + return ; + } + }, [selectedStatusIds.size]); + + const handleCommentChange = (event: React.ChangeEvent) => { + dispatch(changeReportComment(event.target.value)); + }; + + const handleRulesScrolling = () => { + if (rulesListRef.current) { + const { scrollTop, scrollHeight, clientHeight } = rulesListRef.current; + + if (scrollTop + clientHeight > scrollHeight - 24) { + setNearBottom(true); + } else { + setNearBottom(false); + } + + if (scrollTop < 24) { + setNearTop(true); + } else { + setNearTop(false); + } + } + }; + + useEffect(() => { + dispatch(fetchRules()); + }, []); + + return ( + + {renderSelectedStatuses()} + + + Reason for reporting + +
+
+ {rules.map((rule, idx) => { + const isSelected = String(ruleId) === rule.id; + + return ( + + ); + })} +
+ +
+
+
+ + + +