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 ( + <> +
+
+ {Object.keys(REPORT_API.PARAMETERS['type']).map((x) => { + return ( + updateInput('type', x)} + /> + ); + })} +
+
+
+
+ + ); + + case PAGE_CATEGORY: + if (!input.type) { + return null; + } else { + return ( + <> +
+
+ {__(input.type)} +
+
+
+ {REPORT_API.PARAMETERS['type'][input.type][REPORT_API.CATEGORIES].map((x) => { + return ( + updateInput('category', x)} + /> + ); + })} +
+
+
+
+
+ + ); + } + + case PAGE_INFRINGEMENT_DETAILS: + switch (input.type) { + case REPORT_API.INFRINGES_MY_RIGHTS: + switch (input.category) { + case REPORT_API.COPYRIGHT_ISSUES: + body = ( +
+ updateInput('affected_party', e.target.value)} + blockWrap={false} + > + + + + updateInput('copyright_owner_name', e.target.value)} + /> + updateInput('relationship_to_copyrighted_content', e.target.value)} + charCount={input.relationship_to_copyrighted_content.length} + textAreaMaxLength={FF_MAX_CHARS_REPORT_CONTENT_DETAILS} + /> + updateInput('remove_now', !input.remove_now)} + /> +
+ ); + break; + + case REPORT_API.OTHER_LEGAL_ISSUES: + body = ( +
+ updateInput('reporter_name', e.target.value)} + /> + updateInput('acting_on_behalf_of', e.target.value)} + blockWrap={false} + > + + + + {input.acting_on_behalf_of === REPORT_API.BEHALF_CLIENT && ( + updateInput('client_name', e.target.value)} + /> + )} + updateInput('specific_law', e.target.value)} + /> + updateInput('law_url', e.target.value)} + /> + updateInput('clarification', e.target.value)} + /> +
+ ); + break; + + default: + if (isDev) throw new Error('Unhandled category for DMCA: ' + input.category); + body = null; + break; + } + break; + + default: + body = ( + <> + {includeTimestamp(claim) && ( +
+ { + updateInput('timestamp', e.target.value); + setTimestampInvalid(!isTimestampValid(e.target.value)); + }} + error={timestampInvalid && input.timestamp ? 'Invalid timestamp (e.g. 05:23, 00:23:59)' : ''} + /> +
+ )} +
+ updateInput('additionalDetails', e.target.value)} + charCount={input.additionalDetails.length} + textAreaMaxLength={FF_MAX_CHARS_REPORT_CONTENT_DETAILS} + placeholder={__('Provide additional details')} + /> +
+ + ); + } + + return ( + <> + {body} +
+
+ + ); + + case PAGE_SUBMITTER_DETAILS: + return ( + <> +
+
+ updateInput('email', e.target.value)} + /> + {input.category === REPORT_API.COPYRIGHT_ISSUES && ( + <> + updateInput('additional_email', e.target.value)} + /> + updateInput('phone_number', e.target.value)} + /> + + )} +
+
+ + + +
+
+
+
+ + ); + + case PAGE_SUBMITTER_DETAILS_ADDRESS: + return ( + <> +
+ updateInput('street_address', e.target.value)} + /> + updateInput('city', e.target.value)} + /> + updateInput('state_or_province', e.target.value)} + /> + updateInput('zip_code', e.target.value)} + /> + updateInput('country', e.target.value)} + > + + {COUNTRIES.map((country) => ( + + ))} + +
+
+
+ + ); + + case PAGE_CONFIRM: + return ( + <> +
+
{__('Contact details')}
+
{input.email}
+ {input.type === REPORT_API.INFRINGES_MY_RIGHTS && ( + updateInput('signature', e.target.value)} + /> + )} +
+
{__('Send report?')}
+
+
+ + ); + + case PAGE_SENT: + if (isReporting) { + body = ; + } else if (error) { + body = ( +
+ {error} +
+ ); + } else { + body = ( + <> +
{__('Report submitted')}
+
{__('We will review and respond shortly.')}
+ + ); + } + return ( + <> +
{body}
+
+ {error &&
+ + ); + } + } + + function getClaimPreview(claim: StreamClaim) { + return ( +
+ +
+ ); + } + + return ( +
+ : __('Invalid claim ID')} + /> + + ); +} diff --git a/ui/component/router/view.jsx b/ui/component/router/view.jsx index ee36283f8..2fc7b051a 100644 --- a/ui/component/router/view.jsx +++ b/ui/component/router/view.jsx @@ -13,6 +13,7 @@ import BackupPage from 'page/backup'; import Code2257Page from 'web/page/code2257'; // @endif import ReportPage from 'page/report'; +import ReportContentPage from 'page/reportContent'; import ShowPage from 'page/show'; import PublishPage from 'page/publish'; import DiscoverPage from 'page/discover'; @@ -252,6 +253,7 @@ function AppRouter(props: Props) { + diff --git a/ui/constants/form-field.js b/ui/constants/form-field.js index 2b44de86b..7ca0ece5d 100644 --- a/ui/constants/form-field.js +++ b/ui/constants/form-field.js @@ -1,3 +1,6 @@ export const FF_MAX_CHARS_DEFAULT = 2000; export const FF_MAX_CHARS_IN_COMMENT = 2000; export const FF_MAX_CHARS_IN_DESCRIPTION = 5000; +export const FF_MAX_CHARS_REPORT_CONTENT_DETAILS = 500; +export const FF_MAX_CHARS_REPORT_CONTENT_ADDRESS = 255; +export const FF_MAX_CHARS_REPORT_CONTENT_SHORT = 100; diff --git a/ui/constants/pages.js b/ui/constants/pages.js index 2623f3c2a..5c32f3ca2 100644 --- a/ui/constants/pages.js +++ b/ui/constants/pages.js @@ -29,6 +29,7 @@ exports.UPLOAD = 'upload'; exports.UPLOADS = 'uploads'; exports.GET_CREDITS = 'getcredits'; exports.REPORT = 'report'; +exports.REPORT_CONTENT = 'report_content'; exports.REWARDS = 'rewards'; exports.REWARDS_VERIFY = 'rewards/verify'; exports.REPOST_NEW = 'repost'; diff --git a/ui/page/reportContent/index.js b/ui/page/reportContent/index.js new file mode 100644 index 000000000..44f483487 --- /dev/null +++ b/ui/page/reportContent/index.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import ReportContentPage from './view'; + +const select = (state) => ({}); + +const perform = (dispatch) => ({}); + +export default connect(select, perform)(ReportContentPage); diff --git a/ui/page/reportContent/view.jsx b/ui/page/reportContent/view.jsx new file mode 100644 index 000000000..f2e36f0df --- /dev/null +++ b/ui/page/reportContent/view.jsx @@ -0,0 +1,19 @@ +// @flow +import React from 'react'; +import Page from 'component/page'; +import ReportContent from 'component/reportContent'; + +export default function ReportContentPage(props: any) { + return ( + + + + ); +} diff --git a/ui/scss/component/_content.scss b/ui/scss/component/_content.scss index f348811cc..0960eb395 100644 --- a/ui/scss/component/_content.scss +++ b/ui/scss/component/_content.scss @@ -190,3 +190,7 @@ .content__loading-text { color: var(--color-white); } + +.content__non-clickable { + pointer-events: none; +} diff --git a/ui/scss/component/_main.scss b/ui/scss/component/_main.scss index 2e3d36540..03dbd9a45 100644 --- a/ui/scss/component/_main.scss +++ b/ui/scss/component/_main.scss @@ -266,6 +266,11 @@ max-width: 34rem; } +.main--report-content { + @extend .main--auth-page; + max-width: 40rem; +} + .main--empty { align-self: center; display: flex; diff --git a/ui/scss/component/section.scss b/ui/scss/component/section.scss index 5596bf9be..1dd1aa687 100644 --- a/ui/scss/component/section.scss +++ b/ui/scss/component/section.scss @@ -14,6 +14,19 @@ max-width: 40rem; } +.section--vertical-compact { + fieldset-section, + fieldset-section.radio, + div.checkbox, + div.section { + margin-top: var(--spacing-s); + } + + .channel__selector { + margin-bottom: var(--spacing-s); + } +} + .section--help { border-radius: var(--border-radius); background-color: var(--color-primary-alt);