diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e47f5273..bbd2f81f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Japanese, Afrikaans, Filipino, Thai and Vietnamese language support ([#5684](https://github.com/lbryio/lbry-desktop/issues/5684)) - Highlight comments made by content owner _community pr!_ ([#5744](https://github.com/lbryio/lbry-desktop/pull/5744)) +- Ability to report infringing content directly from the application ([#5808](https://github.com/lbryio/lbry-desktop/pull/5808)) ### Changed diff --git a/static/app-strings.json b/static/app-strings.json index 38248743e..62a2b72e2 100644 --- a/static/app-strings.json +++ b/static/app-strings.json @@ -1784,6 +1784,75 @@ "Credits sent": "Credits sent", "Unresolved": "Unresolved", "Received amount did not match order code %chargeCode%. Contact hello@lbry.com to resolve the payment.": "Received amount did not match order code %chargeCode%. Contact hello@lbry.com to resolve the payment.", + "publish": "publish", + "Sexual content": "Sexual content", + "Violent or repulsive content": "Violent or repulsive content", + "Hateful or abusive content": "Hateful or abusive content", + "Harmful or dangerous acts": "Harmful or dangerous acts", + "Child abuse": "Child abuse", + "Promotes terrorism": "Promotes terrorism", + "Spam or misleading": "Spam or misleading", + "Infringes my rights": "Infringes my rights", + "Next": "Next", + "Graphic sexual activity": "Graphic sexual activity", + "Nudity": "Nudity", + "Suggestive, but without nudity": "Suggestive, but without nudity", + "Content involving minors": "Content involving minors", + "Sexual Abusive title or description": "Sexual Abusive title or description", + "Other sexual content": "Other sexual content", + "Timestamp": "Timestamp", + "Provide additional details": "Provide additional details", + "e.g. John Doe": "e.g. John Doe", + "e.g. john@example.com": "e.g. john@example.com", + "e.g. satoshi@example.com": "e.g. satoshi@example.com", + "Your channel": "Your channel", + "Set to \"Anonymous\" if you do not want to associate your channel in this report.": "Set to \"Anonymous\" if you do not want to associate your channel in this report.", + "Send report?": "Send report?", + "Send Report": "Send Report", + "Contact details": "Contact details", + "Report submitted": "Report submitted", + "We will review and respond shortly.": "We will review and respond shortly.", + "Adults fighting": "Adults fighting", + "Physical attack": "Physical attack", + "Youth violence": "Youth violence", + "Animal abuse": "Animal abuse", + "Promotes hatred or violence": "Promotes hatred or violence", + "Bullying": "Bullying", + "Hateful Abusive title or description": "Hateful Abusive title or description", + "Pharmaceutical or drug abuse": "Pharmaceutical or drug abuse", + "Abuse of fire or explosives": "Abuse of fire or explosives", + "Suicide or self injury": "Suicide or self injury", + "Other dangerous acts": "Other dangerous acts", + "Mass advertising": "Mass advertising", + "Pharmaceutical drugs for sale": "Pharmaceutical drugs for sale", + "Misleading text": "Misleading text", + "Misleading thumbnail": "Misleading thumbnail", + "Scams or fraud": "Scams or fraud", + "Copyright issue": "Copyright issue", + "Other legal issue": "Other legal issue", + "Affected party": "Affected party", + "Myself": "Myself", + "My company, organization, or client": "My company, organization, or client", + "Copyright owner name": "Copyright owner name", + "Relationship to copyrighted content": "Relationship to copyrighted content", + "(between 10 to 500 characters)": "(between 10 to 500 characters)", + "Remove now": "Remove now", + "Additional email (optional)": "Additional email (optional)", + "Phone number": "Phone number", + "Address": "Address", + "City": "City", + "State/province": "State/province", + "Zip code": "Zip code", + "Signature": "Signature", + "Your name": "Your name", + "Acting on behalf of": "Acting on behalf of", + "Self": "Self", + "Client": "Client", + "Title of specific law": "Title of specific law", + "Specific law": "Specific law", + "Law URL": "Law URL", + "Clarification": "Clarification", + "Client name": "Client name", "We apologize for this inconvenience, but have added this additional step to prevent abuse. Users on VPN or shared connections will continue to see this message and are not eligible for Rewards.": "We apologize for this inconvenience, but have added this additional step to prevent abuse. Users on VPN or shared connections will continue to see this message and are not eligible for Rewards.", "Help LBRY Save Crypto": "Help LBRY Save Crypto", "The US government is attempting to destroy the cryptocurrency industry. Can you help?": "The US government is attempting to destroy the cryptocurrency industry. Can you help?", diff --git a/ui/component/reportContent/index.js b/ui/component/reportContent/index.js new file mode 100644 index 000000000..3851769b0 --- /dev/null +++ b/ui/component/reportContent/index.js @@ -0,0 +1,29 @@ +import { connect } from 'react-redux'; +import { doReportContent } from 'redux/actions/reportContent'; +import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app'; +import { selectIsReportingContent, selectReportContentError } from 'redux/selectors/reportContent'; +import { makeSelectClaimForClaimId, doClaimSearch } from 'lbry-redux'; +import { withRouter } from 'react-router'; +import ReportContent from './view'; + +const select = (state, props) => { + const { search } = props.location; + const urlParams = new URLSearchParams(search); + const claimId = urlParams.get('claimId'); + + return { + isReporting: selectIsReportingContent(state), + error: selectReportContentError(state), + activeChannelClaim: selectActiveChannelClaim(state), + incognito: selectIncognito(state), + claimId: claimId, + claim: makeSelectClaimForClaimId(claimId)(state), + }; +}; + +const perform = (dispatch) => ({ + doClaimSearch: (options) => dispatch(doClaimSearch(options)), + doReportContent: (category, params) => dispatch(doReportContent(category, params)), +}); + +export default withRouter(connect(select, perform)(ReportContent)); diff --git a/ui/component/reportContent/view.jsx b/ui/component/reportContent/view.jsx new file mode 100644 index 000000000..bfab63195 --- /dev/null +++ b/ui/component/reportContent/view.jsx @@ -0,0 +1,727 @@ +// @flow +import React from 'react'; +import Button from 'component/button'; +import { Form, FormField } from 'component/common/form'; +import Card from 'component/common/card'; +import ClaimPreview from 'component/claimPreview'; +import ChannelSelector from 'component/channelSelector'; +import Spinner from 'component/spinner'; +import ErrorText from 'component/common/error-text'; +import Icon from 'component/common/icon'; +import { COUNTRIES } from 'util/country'; +import { EMAIL_REGEX } from 'constants/email'; +import { + FF_MAX_CHARS_REPORT_CONTENT_DETAILS, + FF_MAX_CHARS_REPORT_CONTENT_SHORT, + FF_MAX_CHARS_REPORT_CONTENT_ADDRESS, +} from 'constants/form-field'; +import * as REPORT_API from 'constants/report_content'; +import * as ICONS from 'constants/icons'; +import { useHistory } from 'react-router-dom'; + +const PAGE_TYPE = 'page--type'; +const PAGE_CATEGORY = 'page--category'; +const PAGE_INFRINGEMENT_DETAILS = 'page--infringement-details'; +const PAGE_SUBMITTER_DETAILS = 'page--submitter-details'; +const PAGE_SUBMITTER_DETAILS_ADDRESS = 'page--submitter-details-address'; +const PAGE_CONFIRM = 'page--confirm'; +const PAGE_SENT = 'page--sent'; + +const isDev = process.env.NODE_ENV !== 'production'; + +const DEFAULT_INPUT_DATA = { + // page: string, + type: '', + category: '', + timestamp: '', + additionalDetails: '', + email: '', + additional_email: '', + phone_number: '', + country: '', + street_address: '', + city: '', + state_or_province: '', + zip_code: '', + signature: '', + reporter_name: '', + acting_on_behalf_of: REPORT_API.BEHALF_SELF, + client_name: '', + specific_law: '', + law_url: '', + clarification: '', + affected_party: REPORT_API.PARTY_SELF, + copyright_owner_name: '', + relationship_to_copyrighted_content: '', + remove_now: true, +}; + +type Props = { + claimId: string, + claim: StreamClaim, + isReporting: boolean, + error: string, + activeChannelClaim: ?ChannelClaim, + incognito: boolean, + doClaimSearch: (any) => void, + doReportContent: (string, string) => void, +}; + +export default function ReportContent(props: Props) { + const { isReporting, error, activeChannelClaim, incognito, claimId, claim, doClaimSearch, doReportContent } = props; + + const [input, setInput] = React.useState({ ...DEFAULT_INPUT_DATA }); + const [page, setPage] = React.useState(PAGE_TYPE); + const [timestampInvalid, setTimestampInvalid] = React.useState(false); + const [isResolvingClaim, setIsResolvingClaim] = React.useState(false); + const { goBack } = useHistory(); + + // Resolve claim if URL is entered directly or if page is reloaded. + React.useEffect(() => { + if (!claim) { + setIsResolvingClaim(true); + doClaimSearch({ + page_size: 20, + page: 1, + no_totals: true, + claim_ids: [claimId], + }); + } + }, [claim, claimId]); + + // On mount, pause player and get the timestamp, if applicable. + React.useEffect(() => { + if (window.player) { + window.player.pause(); + + const seconds = window.player.currentTime(); + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor((seconds % 3600) % 60); + + const str = (n) => n.toLocaleString('en-US', { minimumIntegerDigits: 2, useGrouping: false }); + updateInput('timestamp', str(h) + ':' + str(m) + ':' + str(s)); + } + }, []); + + React.useEffect(() => { + let timer; + if (isResolvingClaim) { + timer = setTimeout(() => { + setIsResolvingClaim(false); + }, 3000); + } + return () => clearTimeout(timer); + }, [isResolvingClaim]); + + function onSubmit() { + if (!claim) { + if (isDev) throw new Error('ReportContent::onSubmit -- null claim'); + return; + } + + const pushParam = (params, label, value, encode = true) => { + if (encode) { + params.push(`${label}=${encodeURIComponent(value)}`); + } else { + params.push(`${label}=${value}`); + } + }; + + const params = []; + pushParam(params, 'primary_email', input.email); + pushParam(params, 'claim_id', claim.claim_id, false); + pushParam(params, 'transaction_id', claim.txid, false); + pushParam(params, 'vout', claim.nout.toString(), false); + + if (!incognito && activeChannelClaim) { + pushParam(params, 'channel_name', activeChannelClaim.name); + pushParam(params, 'channel_claim_id', activeChannelClaim.claim_id, false); + } + + switch (input.type) { + case REPORT_API.INFRINGES_MY_RIGHTS: + pushParam(params, 'signature', input.signature); + switch (input.category) { + case REPORT_API.COPYRIGHT_ISSUES: + pushParam(params, 'additional_email', input.additional_email); + pushParam(params, 'phone_number', input.phone_number); + pushParam(params, 'country', input.country); + pushParam(params, 'street_address', input.street_address); + pushParam(params, 'city', input.city); + pushParam(params, 'state_or_province', input.state_or_province); + pushParam(params, 'zip_code', input.zip_code); + pushParam(params, 'affected_party', input.affected_party); + pushParam(params, 'copyright_owner_name', input.copyright_owner_name); + pushParam(params, 'relationship_to_copyrighted_content', input.relationship_to_copyrighted_content); + pushParam(params, 'remove_now', input.remove_now.toString(), false); + break; + + case REPORT_API.OTHER_LEGAL_ISSUES: + pushParam(params, 'reporter_name', input.reporter_name); + pushParam(params, 'acting_on_behalf_of', input.acting_on_behalf_of); + pushParam(params, 'specific_law', input.specific_law); + pushParam(params, 'law_url', input.law_url); + pushParam(params, 'clarification', input.clarification); + if (input.acting_on_behalf_of === REPORT_API.BEHALF_CLIENT) { + pushParam(params, 'client_name', input.client_name); + } + break; + } + break; + + default: + pushParam(params, 'type', input.type); + pushParam(params, 'category', input.category); + pushParam(params, 'additional_details', input.additionalDetails); + if (includeTimestamp(claim)) { + pushParam(params, 'timestamp', input.timestamp); + } + break; + } + + doReportContent(input.category, params.join('&')); + } + + function updateInput(field: string, value: any) { + let newInput = input; + + if (isDev && newInput[field] === undefined) { + throw new Error('Unexpected field: ' + field); + } + + newInput[field] = value; + if (field === 'type') { + newInput['category'] = ''; + } + setInput({ ...newInput }); + } + + function isInfringementDetailsValid(type: string, category: string) { + switch (type) { + case REPORT_API.INFRINGES_MY_RIGHTS: + switch (category) { + case REPORT_API.COPYRIGHT_ISSUES: + return ( + input.affected_party && + input.copyright_owner_name && + input.relationship_to_copyrighted_content && + input.relationship_to_copyrighted_content.length > REPORT_API.RELATIONSHIP_FIELD_MIN_WIDTH + ); + case REPORT_API.OTHER_LEGAL_ISSUES: + return ( + input.reporter_name && + input.specific_law && + input.law_url && + input.acting_on_behalf_of && + (input.acting_on_behalf_of === REPORT_API.BEHALF_SELF || input.client_name) && + input.clarification + ); + default: + return false; + } + default: + return (!includeTimestamp(claim) || isTimestampValid(input.timestamp)) && input.additionalDetails; + } + } + + function isSubmitterDetailsValid(type: string, category: string) { + if (category === REPORT_API.COPYRIGHT_ISSUES) { + return ( + input.email.match(EMAIL_REGEX) && + (!input.additional_email || input.additional_email.match(EMAIL_REGEX)) && + input.phone_number + ); + } + + return input.email.match(EMAIL_REGEX); + } + + function isSubmitterDetailsAddressValid() { + return input.street_address && input.city && input.state_or_province && input.zip_code && input.country; + } + + function isTimestampValid(timestamp: string) { + if (timestamp === '0') { + return true; + } + + const length = timestamp.length; + if (length <= 4) { + return timestamp.match(/\d:[0-5]\d/); + } else if (length <= 5) { + return timestamp.match(/[0-5]\d:[0-5]\d/); + } else if (length <= 8) { + return timestamp.match(/\d{1,2}:[0-5]\d:\d\d/); + } else { + return false; + } + } + + function includeTimestamp(claim: StreamClaim) { + return ( + claim.value_type === 'stream' && (claim.value.stream_type === 'video' || claim.value.stream_type === 'audio') + ); + } + + function getActionElem() { + let body; + + switch (page) { + case PAGE_TYPE: + return ( + <> +