diff --git a/flow-typed/Claims.js b/flow-typed/Claims.js index 9e14c9ae58..fb68101380 100644 --- a/flow-typed/Claims.js +++ b/flow-typed/Claims.js @@ -62,6 +62,7 @@ declare type ClaimsState = { fetchingMyPurchasedClaims: ?boolean, fetchingMyPurchasedClaimsError: ?string, costInfosById: { [claimId: string]: { cost: number, includesData?: boolean } }, + ageRestrictionAllowedByClaimId: { [claimID: string]: boolean }, }; declare type ClaimSearchResultsInfo = {| diff --git a/static/app-strings.json b/static/app-strings.json index 870496624a..73e74f025e 100644 --- a/static/app-strings.json +++ b/static/app-strings.json @@ -1630,7 +1630,7 @@ "Syncing %total_videos% videos from your channel with %total_subs% subscriptions.": "Syncing %total_videos% videos from your channel with %total_subs% subscriptions.", "Confirm Your Age": "Confirm Your Age", "I confirm I am over 18 years old.": "I confirm I am over 18 years old.", - "This is only for regulatory compliance and the data will not be stored.": "This is only for regulatory compliance and the data will not be stored.", + "This is only for regulatory compliance and the data will be stored in your private settings if you're signed in.": "This is only for regulatory compliance and the data will be stored in your private settings if you're signed in.", "Whoa!": "Whoa!", "You've already claimed your referrer, but we've followed this channel for you.": "You've already claimed your referrer, but we've followed this channel for you.", "You've already claimed your referrer.": "You've already claimed your referrer.", @@ -2953,6 +2953,13 @@ "Receive 1 LBC for inviting a friend, an enemy, a frenemy, or an enefriend. Everyone needs content freedom.": "Receive 1 LBC for inviting a friend, an enemy, a frenemy, or an enefriend. Everyone needs content freedom.", "Receive a credit of at least 0.05 LBC for watching cool stuff at least 3 days during the week.": "Receive a credit of at least 0.05 LBC for watching cool stuff at least 3 days during the week.", "Are you a supermodel or rockstar that received a custom Credits code? Claim it here.": "Are you a supermodel or rockstar that received a custom Credits code? Claim it here.", + "The following content is intended for Mature Audiences aged 18 years and over. Viewer discretion is advised.": "The following content is intended for Mature Audiences aged 18 years and over. Viewer discretion is advised.", + "Add tags that are relevant to your content so those who're looking for it can find it more easily.": "Add tags that are relevant to your content so those who're looking for it can find it more easily.", + "Contains nudity, violence or other allowed 18+ content. See %community_guidelines%": "Contains nudity, violence or other allowed 18+ content. See %community_guidelines%", + "Profile/Cover image contains nudity, violence or other allowed 18+ content. See %community_guidelines%": "Profile/Cover image contains nudity, violence or other allowed 18+ content. See %community_guidelines%", + "Don't obscure age restricted content": "Don't obscure age restricted content", + "Don't blur thumbnails or warn about age restricted content": "Don't blur thumbnails or warn about age restricted content", + "18+": "18+", "--end--": "--end--" } diff --git a/ui/component/channelEdit/view.jsx b/ui/component/channelEdit/view.jsx index 11d9a6c74b..d863a86ef7 100644 --- a/ui/component/channelEdit/view.jsx +++ b/ui/component/channelEdit/view.jsx @@ -21,7 +21,7 @@ import analytics from 'analytics'; import LbcSymbol from 'component/common/lbc-symbol'; import SUPPORTED_LANGUAGES from 'constants/supported_languages'; import WalletSpendableBalanceHelp from 'component/walletSpendableBalanceHelp'; -import { SIMPLE_SITE, THUMBNAIL_CDN_SIZE_LIMIT_BYTES } from 'config'; +import { THUMBNAIL_CDN_SIZE_LIMIT_BYTES } from 'config'; import { sortLanguageMap } from 'util/default-languages'; import ThumbnailBrokenImage from 'component/selectThumbnail/thumbnail-broken.png'; import Gerbil from 'component/channelThumbnail/gerbil.png'; @@ -478,7 +478,6 @@ function ChannelForm(props: Props) { body={
{ @@ -20,6 +24,9 @@ const select = (state, props) => { isResolving: selectIsUriResolving(state, uri), claimsByUri: selectClaimsByUri(state), odyseeMembership: selectOdyseeMembershipForChannelId(state, getChannelIdFromClaim(claim)), + isImagesAgeRestricted: makeSelectTagInClaimOrChannelForUri(props.uri, AGE_RESTRICED_CHANNEL_IMAGES_TAG)(state), + channelIsMine: selectClaimIsMine(state, claim), + isAgeRestrictedContentAllowed: selectIsAgeRestrictedContentAllowed(state), }; }; diff --git a/ui/component/channelThumbnail/view.jsx b/ui/component/channelThumbnail/view.jsx index 981d8f98a0..48a4c6ceea 100644 --- a/ui/component/channelThumbnail/view.jsx +++ b/ui/component/channelThumbnail/view.jsx @@ -31,6 +31,9 @@ type Props = { isChannel?: boolean, odyseeMembership: ?string, tooltipTitle?: string, + isImagesAgeRestricted: boolean, + channelIsMine: boolean, + isAgeRestrictedContentAllowed: boolean, }; function ChannelThumbnail(props: Props) { @@ -55,6 +58,9 @@ function ChannelThumbnail(props: Props) { isChannel, odyseeMembership, tooltipTitle, + isImagesAgeRestricted, + channelIsMine, + isAgeRestrictedContentAllowed, } = props; const [thumbLoadError, setThumbLoadError] = React.useState(ThumbUploadError); const shouldResolve = !isResolving && claim === undefined; @@ -64,6 +70,7 @@ function ChannelThumbnail(props: Props) { const channelThumbnail = thumbnailPreview || thumbnail || defaultAvatar; const isAnimated = channelThumbnail && (channelThumbnail.endsWith('gif') || channelThumbnail.endsWith('webp')); const showThumb = (!obscure && !!thumbnail) || thumbnailPreview; + const shouldBlur = !channelIsMine && isImagesAgeRestricted && !isAgeRestrictedContentAllowed; const badgeProps = React.useMemo(() => { return { @@ -99,6 +106,7 @@ function ChannelThumbnail(props: Props) { { const thumbnailRef = React.useRef(null); useLazyLoading(thumbnailRef, fallback || ''); + const inlineStyle = {}; + if (forceReload) inlineStyle.backgroundImage = 'url(' + String(thumb) + ')'; + return (
{children} diff --git a/ui/component/fileThumbnail/view.jsx b/ui/component/fileThumbnail/view.jsx index 7b0621559f..8d29f0e566 100644 --- a/ui/component/fileThumbnail/view.jsx +++ b/ui/component/fileThumbnail/view.jsx @@ -15,6 +15,7 @@ import FreezeframeWrapper from 'component/common/freezeframe-wrapper'; import classnames from 'classnames'; import Thumb from './internal/thumb'; import PreviewOverlayProtectedContent from '../previewOverlayProtectedContent'; +import PreviewOverlayAgeRestrictedContent from '../previewOverlayAgeRestrictedContent'; const FALLBACK = MISSING_THUMB_DEFAULT ? getThumbnailCdnUrl({ thumbnail: MISSING_THUMB_DEFAULT }) : undefined; @@ -77,6 +78,7 @@ function FileThumbnail(props: Props) { 'media__thumb--small': small, })} > + {children} @@ -110,6 +112,7 @@ function FileThumbnail(props: Props) { className={className} forceReload={forceReload} > + {children} diff --git a/ui/component/fileVisibility/index.js b/ui/component/fileVisibility/index.js index ecf0b98803..8932f38822 100644 --- a/ui/component/fileVisibility/index.js +++ b/ui/component/fileVisibility/index.js @@ -1,11 +1,14 @@ import { connect } from 'react-redux'; import FileVisibility from './view'; -import { selectIsUriUnlisted } from 'redux/selectors/claims'; +import { AGE_RESTRICED_CONTENT_TAG } from 'constants/tags'; + +import { selectIsUriUnlisted, makeSelectTagInClaimOrChannelForUri } from 'redux/selectors/claims'; const select = (state, props) => { return { isUnlisted: selectIsUriUnlisted(state, props.uri), + isAgeRestricted: makeSelectTagInClaimOrChannelForUri(props.uri, AGE_RESTRICED_CONTENT_TAG)(state), }; }; diff --git a/ui/component/fileVisibility/style.scss b/ui/component/fileVisibility/style.scss index caae3ba73c..939554b37b 100644 --- a/ui/component/fileVisibility/style.scss +++ b/ui/component/fileVisibility/style.scss @@ -8,8 +8,14 @@ background-color: var(--color-visibility-label); max-height: 1.3rem; white-space: nowrap; + display: inline; svg { margin-right: var(--spacing-xxs); } } + +.file-visibility-age-restricted { + background-color: var(--color-button-toggle-bg-active); + color: white; +} diff --git a/ui/component/fileVisibility/view.jsx b/ui/component/fileVisibility/view.jsx index 0200336198..f3cc71e743 100644 --- a/ui/component/fileVisibility/view.jsx +++ b/ui/component/fileVisibility/view.jsx @@ -6,24 +6,30 @@ import Icon from 'component/common/icon'; import * as ICONS from 'constants/icons'; type Props = { - uri: ?string, + shownFields: ?Array, // --- internal --- isUnlisted: boolean, + isAgeRestricted: boolean, }; function FileVisibility(props: Props) { - const { isUnlisted } = props; + const { isUnlisted, isAgeRestricted, shownFields } = props; + const showUnlisted = !shownFields ? true : shownFields.includes('unlisted'); + const showAgeRestriced = !shownFields ? true : shownFields.includes('age-restriced'); - if (isUnlisted) { - return ( -
- - {__('unlisted')} -
- ); - } - - return null; + return ( + <> + {isUnlisted && showUnlisted && ( +
+ + {__('unlisted')} +
+ )} + {isAgeRestricted && showAgeRestriced && ( +
{__('18+')}
+ )} + + ); } export default FileVisibility; diff --git a/ui/component/previewOverlayAgeRestrictedContent/index.js b/ui/component/previewOverlayAgeRestrictedContent/index.js new file mode 100644 index 0000000000..0f25dc1247 --- /dev/null +++ b/ui/component/previewOverlayAgeRestrictedContent/index.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux'; + +import { + makeSelectTagInClaimOrChannelForUri, + selectIsAgeRestrictedContentAllowedForClaimId, + selectClaimForUri, + selectClaimIsMine, +} from 'redux/selectors/claims'; + +import { AGE_RESTRICED_CONTENT_TAG } from 'constants/tags'; + +import PreviewOverlayAgeRestrictedContent from './view'; + +const select = (state, props) => { + const { uri } = props; + const claim = selectClaimForUri(state, uri); + const claimId = claim && claim.claim_id; + + return { + uri, + isAgeRestricted: makeSelectTagInClaimOrChannelForUri(uri, AGE_RESTRICED_CONTENT_TAG)(state), + isAgeRestrictedContentAllowed: selectIsAgeRestrictedContentAllowedForClaimId(state, claimId), + isMine: Boolean(selectClaimIsMine(state, claim)), + }; +}; + +export default connect(select, null)(PreviewOverlayAgeRestrictedContent); diff --git a/ui/component/previewOverlayAgeRestrictedContent/view.jsx b/ui/component/previewOverlayAgeRestrictedContent/view.jsx new file mode 100644 index 0000000000..ace3146ff8 --- /dev/null +++ b/ui/component/previewOverlayAgeRestrictedContent/view.jsx @@ -0,0 +1,26 @@ +// @flow +import * as React from 'react'; +import FileVisibility from 'component/fileVisibility'; + +type Props = { + uri: string, + isAgeRestricted: boolean, + isAgeRestrictedContentAllowed: boolean, + isMine: boolean, +}; + +const PreviewOverlayAgeRestrictedContent = (props: Props) => { + const { uri, isAgeRestricted, isAgeRestrictedContentAllowed, isMine } = props; + + if (isAgeRestricted) { + return ( +
+ +
+ ); + } + + return null; +}; + +export default PreviewOverlayAgeRestrictedContent; diff --git a/ui/component/publish/livestream/livestreamForm/view.jsx b/ui/component/publish/livestream/livestreamForm/view.jsx index c866fd020c..d3750be4e4 100644 --- a/ui/component/publish/livestream/livestreamForm/view.jsx +++ b/ui/component/publish/livestream/livestreamForm/view.jsx @@ -9,13 +9,14 @@ import type { DoPublishDesktop } from 'redux/actions/publish'; File upload is carried out in the background by that function. */ -import { SITE_NAME, SIMPLE_SITE } from 'config'; +import { SITE_NAME } from 'config'; import * as ICONS from 'constants/icons'; import React, { useEffect, useState } from 'react'; import { useHistory } from 'react-router'; import Lbry from 'lbry'; import { buildURI, isURIValid, isNameValid } from 'util/lbryURI'; import * as THUMBNAIL_STATUSES from 'constants/thumbnail_upload_statuses'; +import * as TAGS from 'constants/tags'; import { BITRATE } from 'constants/publish'; import Button from 'component/button'; import ChannelSelector from 'component/channelSelector'; @@ -511,15 +512,15 @@ function LivestreamForm(props: Props) { body={
{ const validatedTags = []; diff --git a/ui/component/publish/post/postForm/view.jsx b/ui/component/publish/post/postForm/view.jsx index e5c20607a3..b979473f07 100644 --- a/ui/component/publish/post/postForm/view.jsx +++ b/ui/component/publish/post/postForm/view.jsx @@ -9,11 +9,12 @@ import type { DoPublishDesktop } from 'redux/actions/publish'; File upload is carried out in the background by that function. */ -import { SITE_NAME, SIMPLE_SITE } from 'config'; +import { SITE_NAME } from 'config'; import React, { useEffect } from 'react'; import { buildURI, isURIValid, isNameValid } from 'util/lbryURI'; import { lazyImport } from 'util/lazyImport'; import * as THUMBNAIL_STATUSES from 'constants/thumbnail_upload_statuses'; +import * as TAGS from 'constants/tags'; import Button from 'component/button'; import ChannelSelector from 'component/channelSelector'; import classnames from 'classnames'; @@ -430,15 +431,15 @@ function PostForm(props: Props) { body={
{ const validatedTags = []; diff --git a/ui/component/publish/upload/uploadForm/view.jsx b/ui/component/publish/upload/uploadForm/view.jsx index 47c913e679..ec8eae031c 100644 --- a/ui/component/publish/upload/uploadForm/view.jsx +++ b/ui/component/publish/upload/uploadForm/view.jsx @@ -9,7 +9,7 @@ import type { DoPublishDesktop } from 'redux/actions/publish'; File upload is carried out in the background by that function. */ -import { SITE_NAME, SIMPLE_SITE } from 'config'; +import { SITE_NAME } from 'config'; import React, { useEffect, useState } from 'react'; import { buildURI, isURIValid, isNameValid } from 'util/lbryURI'; import { lazyImport } from 'util/lazyImport'; @@ -34,6 +34,7 @@ import { BITRATE } from 'constants/publish'; import { SOURCE_NONE } from 'constants/publish_sources'; import * as ICONS from 'constants/icons'; +import * as TAGS from 'constants/tags'; import Icon from 'component/common/icon'; const SelectThumbnail = lazyImport(() => import('component/selectThumbnail' /* webpackChunkName: "selectThumbnail" */)); @@ -459,15 +460,15 @@ function UploadForm(props: Props) { body={
{ const validatedTags = []; diff --git a/ui/component/settingContent/index.js b/ui/component/settingContent/index.js index 7540b54942..8af52048e7 100644 --- a/ui/component/settingContent/index.js +++ b/ui/component/settingContent/index.js @@ -2,7 +2,11 @@ import { connect } from 'react-redux'; import * as SETTINGS from 'constants/settings'; import { doOpenModal } from 'redux/actions/app'; import { doSetClientSetting } from 'redux/actions/settings'; -import { selectShowMatureContent, selectClientSetting } from 'redux/selectors/settings'; +import { + selectShowMatureContent, + selectIsAgeRestrictedContentAllowed, + selectClientSetting, +} from 'redux/selectors/settings'; import { selectUserVerifiedEmail } from 'redux/selectors/user'; import SettingContent from './view'; @@ -12,7 +16,9 @@ const select = (state, props) => ({ hideMembersOnlyContent: selectClientSetting(state, SETTINGS.HIDE_MEMBERS_ONLY_CONTENT), hideReposts: selectClientSetting(state, SETTINGS.HIDE_REPOSTS), hideShorts: selectClientSetting(state, SETTINGS.HIDE_SHORTS), + ageConfirmed: selectClientSetting(state, SETTINGS.AGE_CONFIRMED), showNsfw: selectShowMatureContent(state), + isAgeRestrictedContentAllowed: selectIsAgeRestrictedContentAllowed(state), instantPurchaseEnabled: selectClientSetting(state, SETTINGS.INSTANT_PURCHASE_ENABLED), instantPurchaseMax: selectClientSetting(state, SETTINGS.INSTANT_PURCHASE_MAX), enablePublishPreview: selectClientSetting(state, SETTINGS.ENABLE_PUBLISH_PREVIEW), diff --git a/ui/component/settingContent/view.jsx b/ui/component/settingContent/view.jsx index 90758eb7cf..d06b1da456 100644 --- a/ui/component/settingContent/view.jsx +++ b/ui/component/settingContent/view.jsx @@ -25,12 +25,14 @@ type Props = { hideReposts: ?boolean, hideShorts: ?boolean, showNsfw: boolean, + isAgeRestrictedContentAllowed: boolean, + ageConfirmed: boolean, instantPurchaseEnabled: boolean, instantPurchaseMax: Price, enablePublishPreview: boolean, // --- perform --- setClientSetting: (string, boolean | string | number) => void, - openModal: (string) => void, + openModal: (string, props?: { cb: () => void }) => void, }; export default function SettingContent(props: Props) { @@ -40,6 +42,8 @@ export default function SettingContent(props: Props) { hideReposts, hideShorts, showNsfw, + isAgeRestrictedContentAllowed, + ageConfirmed, instantPurchaseEnabled, instantPurchaseMax, enablePublishPreview, @@ -89,6 +93,20 @@ export default function SettingContent(props: Props) { /> + + + setClientSetting(SETTINGS.AGE_RESTRICTED_CONTENT_ALLOWED, !isAgeRestrictedContentAllowed) + } + /> + + {!SIMPLE_SITE && ( <> @@ -97,9 +115,9 @@ export default function SettingContent(props: Props) { name="show_nsfw" checked={showNsfw} onChange={() => - !IS_WEB || showNsfw + !IS_WEB || showNsfw || ageConfirmed ? setClientSetting(SETTINGS.SHOW_MATURE, !showNsfw) - : openModal(MODALS.CONFIRM_AGE) + : openModal(MODALS.CONFIRM_AGE, { cb: () => setClientSetting(SETTINGS.SHOW_MATURE, !showNsfw) }) } /> @@ -183,6 +201,7 @@ const HELP = { HIDE_SHORTS: 'You will not see content under 1min long. Also hides non-video/audio content.', HIDE_FYP: 'You will not see the personal recommendations in the homepage.', SHOW_MATURE: 'Mature content may include nudity, intense sexuality, profanity, or other adult content. By displaying mature content, you are affirming you are of legal age to view mature content in your country or jurisdiction. ', + NO_OBSCURE_AGE_RESTRICTED_CONTENT: "Don't blur thumbnails or warn about age restricted content", MAX_PURCHASE_PRICE: 'This will prevent you from purchasing any content over a certain cost, as a safety measure.', ONLY_CONFIRM_OVER_AMOUNT: '', // [feel redundant. Disable for now] "When this option is chosen, LBRY won't ask you to confirm purchases or tips below your chosen amount.", PUBLISH_PREVIEW: 'Show preview and confirmation dialog before publishing content.', diff --git a/ui/component/tagsSearch/view.jsx b/ui/component/tagsSearch/view.jsx index a9062b8ed9..e936d28631 100644 --- a/ui/component/tagsSearch/view.jsx +++ b/ui/component/tagsSearch/view.jsx @@ -1,5 +1,6 @@ // @flow import React, { useState } from 'react'; +import Button from 'component/button'; import { Form, FormField } from 'component/common/form'; import Tag from 'component/tag'; import { setUnion, setDifference } from 'util/set-operations'; @@ -19,6 +20,8 @@ import { DISABLE_REACTIONS_COMMENTS_TAG, DISABLE_SLIMES_VIDEO_TAG, DISABLE_SLIMES_COMMENTS_TAG, + AGE_RESTRICED_CONTENT_TAG, + AGE_RESTRICED_CHANNEL_IMAGES_TAG, } from 'constants/tags'; import { removeInternalTags } from 'util/tags'; @@ -43,6 +46,7 @@ type Props = { limitShow?: number, user: User, disableControlTags?: boolean, + disabledControlTags?: Array, help?: string, }; @@ -77,6 +81,7 @@ export default function TagsSearch(props: Props) { limitSelect = TAG_FOLLOW_MAX, limitShow = 5, disableControlTags, + disabledControlTags, help, } = props; const [newTag, setNewTag] = useState(''); @@ -127,6 +132,38 @@ export default function TagsSearch(props: Props) { label = __('Disable Dislikes - Content'); } else if (t === DISABLE_SLIMES_COMMENTS_TAG) { label = __('Disable Dislikes - Comments'); + } else if (t === AGE_RESTRICED_CONTENT_TAG) { + label = ( + + ), + }} + > + Contains nudity, violence or other allowed 18+ content. See %community_guidelines% + + ); + } else if (t === AGE_RESTRICED_CHANNEL_IMAGES_TAG) { + label = ( + + ), + }} + > + Profile/Cover image contains nudity, violence or other allowed 18+ content. See %community_guidelines% + + ); } else { label = __( t @@ -283,17 +320,21 @@ export default function TagsSearch(props: Props) { onSelect && ( // onSelect ensures this does not appear on TagFollow - {CONTROL_TAGS.map((t) => ( - te.name === t)} - onChange={() => handleUtilityTagCheckbox(t)} - /> - ))} + {CONTROL_TAGS.map( + (t) => + // $FlowIgnore + !disabledControlTags?.includes(t) && ( + te.name === t)} + onChange={() => handleUtilityTagCheckbox(t)} + /> + ) + )} )} diff --git a/ui/component/tagsSelect/view.jsx b/ui/component/tagsSelect/view.jsx index 5ce798eabe..0d84a7aa8d 100644 --- a/ui/component/tagsSelect/view.jsx +++ b/ui/component/tagsSelect/view.jsx @@ -26,6 +26,7 @@ type Props = { hideHeader?: boolean, limitShow?: number, limitSelect?: number, + disabledControlTags?: Array, }; /* @@ -48,6 +49,7 @@ export default function TagsSelect(props: Props) { label, limitShow, limitSelect, + disabledControlTags, } = props; const [hasClosed, setHasClosed] = usePersistedState('tag-select:has-closed', false); const tagsToDisplay = tagsChosen || followedTags; @@ -102,6 +104,7 @@ export default function TagsSelect(props: Props) { placeholder={placeholder} limitShow={limitShow} limitSelect={limitSelect} + disabledControlTags={disabledControlTags} help={ help !== false && ( diff --git a/ui/constants/action_types.js b/ui/constants/action_types.js index 29b2d8a932..991fc754ad 100644 --- a/ui/constants/action_types.js +++ b/ui/constants/action_types.js @@ -222,6 +222,7 @@ export const PURCHASE_LIST_FAILED = 'PURCHASE_LIST_FAILED'; export const CHECK_IF_PURCHASED_STARTED = 'CHECK_IF_PURCHASED_STARTED'; export const CHECK_IF_PURCHASED_COMPLETED = 'CHECK_IF_PURCHASED_COMPLETED'; export const CHECK_IF_PURCHASED_FAILED = 'CHECK_IF_PURCHASED_FAILED'; +export const ALLOW_AGE_RESTRICTED_CONTENT = 'ALLOW_AGE_RESTRICTED_CONTENT'; export const SHOW_AUTOPLAY_COUNTDOWN = 'CHECK_IF_PURCHASED_FAILED'; diff --git a/ui/constants/settings.js b/ui/constants/settings.js index a2306b5616..bbac516c23 100644 --- a/ui/constants/settings.js +++ b/ui/constants/settings.js @@ -6,6 +6,8 @@ export const EMAIL_COLLECTION_ACKNOWLEDGED = 'email_collection_acknowledged'; export const INVITE_ACKNOWLEDGED = 'invite_acknowledged'; export const LANGUAGE = 'language'; export const SHOW_MATURE = 'show_mature'; +export const AGE_RESTRICTED_CONTENT_ALLOWED = 'age_restricted_content_allowed'; +export const AGE_CONFIRMED = 'age_confirmed'; export const SHOW_ANONYMOUS = 'show_anonymous'; export const SHOW_UNAVAILABLE = 'show_unavailable'; export const INSTANT_PURCHASE_ENABLED = 'instant_purchase_enabled'; diff --git a/ui/constants/shared_preferences.js b/ui/constants/shared_preferences.js index fef1acbd32..136aa6270b 100644 --- a/ui/constants/shared_preferences.js +++ b/ui/constants/shared_preferences.js @@ -17,6 +17,8 @@ export const SDK_SYNC_KEYS = [DAEMON_SETTINGS.LBRYUM_SERVERS, DAEMON_SETTINGS.SH export const CLIENT_SYNC_KEYS = [ SETTINGS.CLOCK_24H, SETTINGS.SHOW_MATURE, + SETTINGS.AGE_RESTRICTED_CONTENT_ALLOWED, + SETTINGS.AGE_CONFIRMED, SETTINGS.HIDE_MEMBERS_ONLY_CONTENT, SETTINGS.HIDE_REPOSTS, SETTINGS.HIDE_SCHEDULED_LIVESTREAMS, diff --git a/ui/constants/tags.js b/ui/constants/tags.js index 7721f2285e..a7e78e33ad 100644 --- a/ui/constants/tags.js +++ b/ui/constants/tags.js @@ -28,6 +28,9 @@ export const DISABLE_SLIMES_ALL_TAG = 'c:disable-slimes-all'; export const DISABLE_SLIMES_VIDEO_TAG = 'c:disable-slimes-video'; export const DISABLE_SLIMES_COMMENTS_TAG = 'c:disable-slimes-comments'; +export const AGE_RESTRICED_CONTENT_TAG = 'c:requires_18+'; +export const AGE_RESTRICED_CHANNEL_IMAGES_TAG = 'c:channel_images_require_18+'; + export const PURCHASE_TAG = 'c:purchase'; export const RENTAL_TAG = 'c:rental'; export const PURCHASE_TAG_OLD = 'purchase:'; @@ -50,6 +53,8 @@ export const SCHEDULED_TAGS = Object.freeze({ // Control tags are special tags that are available to the user in some situations. export const CONTROL_TAGS = [ + AGE_RESTRICED_CONTENT_TAG, + AGE_RESTRICED_CHANNEL_IMAGES_TAG, DISABLE_SUPPORT_TAG, DISABLE_DOWNLOAD_BUTTON_TAG, DISABLE_REACTIONS_VIDEO_TAG, diff --git a/ui/hocs/withStreamClaimRender/index.js b/ui/hocs/withStreamClaimRender/index.js index 4da97dc5b5..309fc4ec58 100644 --- a/ui/hocs/withStreamClaimRender/index.js +++ b/ui/hocs/withStreamClaimRender/index.js @@ -13,6 +13,9 @@ import { selectPendingFiatPaymentForUri, selectSdkFeePendingForUri, selectScheduledStateForUri, + makeSelectTagInClaimOrChannelForUri, + selectClaimIsMine, + selectIsAgeRestrictedContentAllowedForClaimId, // selectClaimWasPurchasedForUri, // selectIsFiatPaidForUri, } from 'redux/selectors/claims'; @@ -30,9 +33,12 @@ import { selectVideoSourceLoadedForUri } from 'redux/selectors/app'; import { doStartFloatingPlayingUri, doClearPlayingUri } from 'redux/actions/content'; import { doFileGetForUri } from 'redux/actions/file'; +import { doAllowAgeRestrictedContent } from 'redux/actions/claims'; import { doCheckIfPurchasedClaimId } from 'redux/actions/stripe'; import { doMembershipMine, doMembershipList } from 'redux/actions/memberships'; +import { AGE_RESTRICED_CONTENT_TAG } from 'constants/tags'; + import withStreamClaimRender from './view'; const select = (state, props) => { @@ -66,8 +72,11 @@ const select = (state, props) => { sdkFeePending: selectSdkFeePendingForUri(state, uri), pendingUnlockedRestrictions: selectPendingUnlockedRestrictionsForUri(state, uri), canViewFile: selectCanViewFileForUri(state, uri), + isAgeRestricted: makeSelectTagInClaimOrChannelForUri(props.uri, AGE_RESTRICED_CONTENT_TAG)(state), + isAgeRestrictedContentAllowed: selectIsAgeRestrictedContentAllowedForClaimId(state, claimId), channelLiveFetched: selectChannelIsLiveFetchedForUri(state, uri), sourceLoaded: selectVideoSourceLoadedForUri(state, uri), + claimIsMine: Boolean(selectClaimIsMine(state, claim)), }; }; @@ -78,6 +87,7 @@ const perform = { doStartFloatingPlayingUri, doMembershipList, doClearPlayingUri, + doAllowAgeRestrictedContent, }; export default (Component) => withRouter(connect(select, perform)(withStreamClaimRender(Component))); diff --git a/ui/hocs/withStreamClaimRender/internal/ageRestrictedContentOverlay/index.js b/ui/hocs/withStreamClaimRender/internal/ageRestrictedContentOverlay/index.js new file mode 100644 index 0000000000..26e2713973 --- /dev/null +++ b/ui/hocs/withStreamClaimRender/internal/ageRestrictedContentOverlay/index.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux'; + +import { selectClaimForUri } from 'redux/selectors/claims'; +import { doAllowAgeRestrictedContent } from 'redux/actions/claims'; + +import AgeRestricedContentOverlay from './view'; + +const select = (state, props) => { + const claim = selectClaimForUri(state, props.uri); + + return { + claimId: claim.claim_id, + }; +}; + +const perform = (dispatch) => ({ + doAllowAgeRestrictedContent: (id) => dispatch(doAllowAgeRestrictedContent(id)), +}); + +export default connect(select, perform)(AgeRestricedContentOverlay); diff --git a/ui/hocs/withStreamClaimRender/internal/ageRestrictedContentOverlay/view.jsx b/ui/hocs/withStreamClaimRender/internal/ageRestrictedContentOverlay/view.jsx new file mode 100644 index 0000000000..d383c5113a --- /dev/null +++ b/ui/hocs/withStreamClaimRender/internal/ageRestrictedContentOverlay/view.jsx @@ -0,0 +1,26 @@ +// @flow +import React from 'react'; +import Button from 'component/button'; + +type Props = { + claimId: string, + doAllowAgeRestrictedContent: (claimId: string) => void, +}; + +const AgeRestricedContentOverlay = (props: Props) => { + const { claimId, doAllowAgeRestrictedContent } = props; + + return ( +
+ + {__( + 'The following content is intended for Mature Audiences aged 18 years and over. Viewer discretion is advised.' + )} + + +
+ ); +}; + +export default AgeRestricedContentOverlay; diff --git a/ui/hocs/withStreamClaimRender/view.jsx b/ui/hocs/withStreamClaimRender/view.jsx index d841195608..27808b4cc2 100644 --- a/ui/hocs/withStreamClaimRender/view.jsx +++ b/ui/hocs/withStreamClaimRender/view.jsx @@ -10,6 +10,7 @@ import FileViewerEmbeddedTitle from 'component/fileViewerEmbeddedTitle'; import ProtectedContentOverlay from './internal/protectedContentOverlay'; import ClaimCoverRender from 'component/claimCoverRender'; import PaidContentOverlay from './internal/paidContentOverlay'; +import AgeRestricedContentOverlay from './internal/ageRestrictedContentOverlay'; import LoadingScreen from 'component/common/loading-screen'; import ScheduledInfo from 'component/scheduledInfo'; import Button from 'component/button'; @@ -48,8 +49,11 @@ type Props = { sdkFeePending: ?boolean, pendingUnlockedRestrictions: ?boolean, canViewFile: ?boolean, + isAgeRestricted: ?boolean, + isAgeRestrictedContentAllowed: ?boolean, channelLiveFetched: boolean, sourceLoaded: boolean, + claimIsMine: boolean, doCheckIfPurchasedClaimId: (claimId: string) => void, doFileGetForUri: (uri: string, opt?: ?FileGetOptions) => void, doMembershipMine: () => void, @@ -96,8 +100,11 @@ const withStreamClaimRender = (StreamClaimComponent: FunctionalComponentParam) = sdkFeePending, pendingUnlockedRestrictions, canViewFile, + isAgeRestricted, + isAgeRestrictedContentAllowed, channelLiveFetched, sourceLoaded, + claimIsMine, doCheckIfPurchasedClaimId, doFileGetForUri, doMembershipMine, @@ -114,6 +121,7 @@ const withStreamClaimRender = (StreamClaimComponent: FunctionalComponentParam) = const [currentStreamingUri, setCurrentStreamingUri] = React.useState(); const [clickProps, setClickProps] = React.useState(); + const requiresAgeConfirmation = isAgeRestricted && !claimIsMine; const { search, href, state: locationState, pathname } = location; const { forceDisableAutoplay } = locationState || {}; const currentUriPlaying = playingUri.uri === uri && claimLinkId === playingUri.sourceId; @@ -146,6 +154,7 @@ const withStreamClaimRender = (StreamClaimComponent: FunctionalComponentParam) = const urlTimeParam = href && href.indexOf('t=') > -1; const autoplayEnabled = !forceDisableAutoplay && + !(requiresAgeConfirmation && !isAgeRestrictedContentAllowed) && (!embedded || (urlParams && urlParams.get('autoplay'))) && (forceAutoplayParam || urlTimeParam || (isLivestreamClaim ? isCurrentClaimLive : autoplay)); @@ -298,11 +307,16 @@ const withStreamClaimRender = (StreamClaimComponent: FunctionalComponentParam) = }, []); // -- Restricted State -- render instead of component, until no longer restricted - if (!canViewFile) { + if (!canViewFile || (requiresAgeConfirmation && !isAgeRestrictedContentAllowed)) { // console.log('doCheckIfPurchasedClaimId: ', doCheckIfPurchasedClaimId) return ( - {pendingFiatPayment || sdkFeePending ? ( + {requiresAgeConfirmation && !isAgeRestrictedContentAllowed ? ( + <> + {embedded && } + + + ) : pendingFiatPayment || sdkFeePending ? ( <> {embedded && } diff --git a/ui/modal/modalConfirmAge/view.jsx b/ui/modal/modalConfirmAge/view.jsx index e569b432ed..3fd9fe84a4 100644 --- a/ui/modal/modalConfirmAge/view.jsx +++ b/ui/modal/modalConfirmAge/view.jsx @@ -7,16 +7,18 @@ import Button from 'component/button'; import { FormField } from 'component/common/form'; type Props = { + cb: () => void, doHideModal: () => void, - doSetClientSetting: (string, any) => void, + doSetClientSetting: (string, any, boolean) => void, }; function ModalAffirmPurchase(props: Props) { - const { doHideModal, doSetClientSetting } = props; + const { cb, doHideModal, doSetClientSetting } = props; const [confirmed, setConfirmed] = React.useState(false); function handleConfirmAge() { - doSetClientSetting(SETTINGS.SHOW_MATURE, true); + doSetClientSetting(SETTINGS.AGE_CONFIRMED, true, true); + cb(); doHideModal(); } @@ -33,7 +35,9 @@ function ModalAffirmPurchase(props: Props) { name="age-confirmation" type="checkbox" label={__('I confirm I am over 18 years old.')} - helper={__('This is only for regulatory compliance and the data will not be stored.')} + helper={__( + "This is only for regulatory compliance and the data will be stored in your private settings if you're signed in." + )} checked={confirmed} onChange={() => setConfirmed(!confirmed)} /> diff --git a/ui/page/claim/internal/claimPageComponent/internal/channelPage/index.js b/ui/page/claim/internal/claimPageComponent/internal/channelPage/index.js index 8f77f8cd39..d81a23757d 100644 --- a/ui/page/claim/internal/claimPageComponent/internal/channelPage/index.js +++ b/ui/page/claim/internal/claimPageComponent/internal/channelPage/index.js @@ -15,11 +15,11 @@ import { selectIsSubscribedForUri } from 'redux/selectors/subscriptions'; import { selectModerationBlockList } from 'redux/selectors/comments'; import { selectMutedChannels } from 'redux/selectors/blocked'; import { doOpenModal } from 'redux/actions/app'; -import { selectLanguage } from 'redux/selectors/settings'; +import { selectLanguage, selectIsAgeRestrictedContentAllowed } from 'redux/selectors/settings'; import { selectOdyseeMembershipForChannelId, selectMembershipMineFetched } from 'redux/selectors/memberships'; import { getThumbnailFromClaim, isClaimNsfw } from 'util/claim'; import { doGetMembershipTiersForChannelClaimId, doMembershipMine } from 'redux/actions/memberships'; -import { PREFERENCE_EMBED } from 'constants/tags'; +import { PREFERENCE_EMBED, AGE_RESTRICED_CHANNEL_IMAGES_TAG } from 'constants/tags'; import ChannelPage from './view'; const select = (state, props) => { @@ -46,6 +46,8 @@ const select = (state, props) => { preferEmbed: makeSelectTagInClaimOrChannelForUri(props.uri, PREFERENCE_EMBED)(state), banState: selectBanStateForUri(state, props.uri), isMature: claim ? isClaimNsfw(claim) : false, + isImagesAgeRestricted: makeSelectTagInClaimOrChannelForUri(props.uri, AGE_RESTRICED_CHANNEL_IMAGES_TAG)(state), + isAgeRestrictedContentAllowed: selectIsAgeRestrictedContentAllowed(state), }; }; diff --git a/ui/page/claim/internal/claimPageComponent/internal/channelPage/view.jsx b/ui/page/claim/internal/claimPageComponent/internal/channelPage/view.jsx index f6909eb07d..7685220149 100644 --- a/ui/page/claim/internal/claimPageComponent/internal/channelPage/view.jsx +++ b/ui/page/claim/internal/claimPageComponent/internal/channelPage/view.jsx @@ -76,6 +76,8 @@ type Props = { preferEmbed: boolean, banState: any, isMature: boolean, + isImagesAgeRestricted: boolean, + isAgeRestrictedContentAllowed: boolean, }; function ChannelPage(props: Props) { @@ -103,6 +105,8 @@ function ChannelPage(props: Props) { preferEmbed, banState, isMature, + isImagesAgeRestricted, + isAgeRestrictedContentAllowed, } = props; const { push, @@ -113,6 +117,8 @@ function ChannelPage(props: Props) { const { claims_in_channel } = meta; const showClaims = Boolean(claims_in_channel) && !preferEmbed && !banState.filtered && !banState.blacklisted; + const shouldBlur = !channelIsMine && isImagesAgeRestricted && !isAgeRestrictedContentAllowed; + const [viewBlockedChannel, setViewBlockedChannel] = React.useState(false); const urlParams = new URLSearchParams(search); @@ -363,7 +369,7 @@ function ChannelPage(props: Props) { return (
diff --git a/ui/page/collection/internal/collectionPublishForm/internal/collectionGeneralTab/view.jsx b/ui/page/collection/internal/collectionPublishForm/internal/collectionGeneralTab/view.jsx index fe0a32a62e..e7ce67cd15 100644 --- a/ui/page/collection/internal/collectionPublishForm/internal/collectionGeneralTab/view.jsx +++ b/ui/page/collection/internal/collectionPublishForm/internal/collectionGeneralTab/view.jsx @@ -1,10 +1,10 @@ // @flow import React from 'react'; import { useHistory } from 'react-router-dom'; -import { SIMPLE_SITE } from 'config'; import { FF_MAX_CHARS_IN_DESCRIPTION } from 'constants/form-field'; import * as THUMBNAIL_STATUSES from 'constants/thumbnail_upload_statuses'; +import * as TAGS from 'constants/tags'; import { COLLECTION_PAGE } from 'constants/urlParams'; import { FormField, FormUrlName } from 'component/common/form'; @@ -155,15 +155,15 @@ function CollectionGeneralTab(props: Props) { body={
{ const validatedTags = []; diff --git a/ui/redux/actions/claims.js b/ui/redux/actions/claims.js index c63c7e6cda..f66fa24068 100644 --- a/ui/redux/actions/claims.js +++ b/ui/redux/actions/claims.js @@ -1107,3 +1107,12 @@ export const doFetchNoSourceClaimsForChannelId = order_by: ['release_time'], }) ); + +export function doAllowAgeRestrictedContent(claimId: string) { + return (dispatch: Dispatch) => { + dispatch({ + type: ACTIONS.ALLOW_AGE_RESTRICTED_CONTENT, + data: { claimId }, + }); + }; +} diff --git a/ui/redux/reducers/claims.js b/ui/redux/reducers/claims.js index 77b91a7326..061a2f0f95 100644 --- a/ui/redux/reducers/claims.js +++ b/ui/redux/reducers/claims.js @@ -68,6 +68,7 @@ const defaultState: ClaimsState = { fetchingMyPurchasedClaims: undefined, fetchingMyPurchasedClaimsError: undefined, costInfosById: {}, + ageRestrictionAllowedByClaimId: {}, }; // **************************************************************************** @@ -1120,6 +1121,15 @@ reducers[ACTIONS.CHECK_IF_PURCHASED_COMPLETED] = (state: ClaimsState, action: an }; }; +reducers[ACTIONS.ALLOW_AGE_RESTRICTED_CONTENT] = (state: ClaimsState, action: any): ClaimsState => { + let ageRestrictionAllowedByClaimId = Object.assign({}, state.ageRestrictionAllowedByClaimId); + ageRestrictionAllowedByClaimId[action.data.claimId] = true; + return { + ...state, + ageRestrictionAllowedByClaimId, + }; +}; + // --- Collection Claims --- reducers[ACTIONS.COLLECTION_CLAIM_ITEMS_RESOLVE_COMPLETE] = (state: ClaimsState, action: any) => { diff --git a/ui/redux/reducers/settings.js b/ui/redux/reducers/settings.js index ab5e308650..8f1a18bc5d 100644 --- a/ui/redux/reducers/settings.js +++ b/ui/redux/reducers/settings.js @@ -66,6 +66,8 @@ const defaultState = { // Content [SETTINGS.SHOW_MATURE]: false, + [SETTINGS.AGE_RESTRICTED_CONTENT_ALLOWED]: false, + [SETTINGS.AGE_CONFIRMED]: false, [SETTINGS.AUTOPLAY_MEDIA]: true, [SETTINGS.FLOATING_PLAYER]: true, [SETTINGS.AUTO_DOWNLOAD]: true, diff --git a/ui/redux/selectors/claims.js b/ui/redux/selectors/claims.js index 6faa863211..3c477d1aa7 100644 --- a/ui/redux/selectors/claims.js +++ b/ui/redux/selectors/claims.js @@ -6,6 +6,7 @@ import { normalizeURI, parseURI, isURIValid, buildURI } from 'util/lbryURI'; import { selectGeoBlockLists } from 'redux/selectors/blocked'; import { selectUserLocale, selectYoutubeChannels } from 'redux/selectors/user'; import { selectSupportsByOutpoint } from 'redux/selectors/wallet'; +import { selectIsAgeRestrictedContentAllowed } from 'redux/selectors/settings'; import { createSelector } from 'reselect'; import { createCachedSelector } from 're-reselect'; import { ODYSEE_CHANNEL } from 'constants/channels'; @@ -55,6 +56,12 @@ export const selectLatestByUri = (state: State) => selectState(state).latestByUr export const selectResolvedCollectionsById = (state: State) => selectState(state).resolvedCollectionsById; export const selectMyCollectionClaimIds = (state: State) => selectState(state).myCollectionClaimIds; +export const selectIsAgeRestrictedContentAllowedForClaimId = (state: State, claimId: string) => { + return ( + Boolean(selectState(state).ageRestrictionAllowedByClaimId[claimId]) || selectIsAgeRestrictedContentAllowed(state) + ); +}; + export const selectMyCollectionClaimsById = createSelector( selectResolvedCollectionsById, selectMyCollectionClaimIds, diff --git a/ui/redux/selectors/settings.js b/ui/redux/selectors/settings.js index 50e14cbfa5..786b3dcdb0 100644 --- a/ui/redux/selectors/settings.js +++ b/ui/redux/selectors/settings.js @@ -41,6 +41,10 @@ export const selectTheme = (state) => { return theme; }; +export const selectIsAgeRestrictedContentAllowed = (state) => { + return selectClientSetting(state, SETTINGS.AGE_RESTRICTED_CONTENT_ALLOWED); +}; + export const selectAutomaticDarkModeEnabled = (state) => selectClientSetting(state, SETTINGS.AUTOMATIC_DARK_MODE_ENABLED); export const selectIsNight = (state) => selectState(state).isNight; diff --git a/ui/scss/component/_channel.scss b/ui/scss/component/_channel.scss index fea279371c..494e83b0a6 100644 --- a/ui/scss/component/_channel.scss +++ b/ui/scss/component/_channel.scss @@ -403,6 +403,10 @@ $actions-z-index: 2; } } +.channel-thumbnail.age-restricted { + filter: blur(5px); +} + .channel-thumbnail { display: flex; position: relative; @@ -1172,6 +1176,13 @@ $actions-z-index: 2; } } +.channel-cover.age-restricted { + &:after { + -webkit-backdrop-filter: blur(100px); + backdrop-filter: blur(100px); + } +} + .channel-cover { position: relative; background-image: linear-gradient(to right, #637ad2, #318794 80%); diff --git a/ui/scss/component/_claim-preview.scss b/ui/scss/component/_claim-preview.scss index 1ebdfc5c43..5466612f78 100644 --- a/ui/scss/component/_claim-preview.scss +++ b/ui/scss/component/_claim-preview.scss @@ -1545,6 +1545,35 @@ margin-top: -3px; } +.age-restricted-content-overlay { + @include font-sans; + + background-color: rgba(0, 0, 0, 0.5); + + position: absolute; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + z-index: 5; + width: 100%; + height: 100%; + -webkit-backdrop-filter: blur(100px); + backdrop-filter: blur(100px); + color: white; + border-radius: var(--border-radius); + + .button { + margin-top: var(--spacing-s); + } + + @media (max-width: $breakpoint-small) { + span { + text-align: center; + } + } +} + .protected-content-overlay { @include font-sans; @@ -2262,6 +2291,20 @@ $claim-preview-progress-bar-height: 5px; } } +.age-restricted-content__wrapper { + position: absolute; + width: 100%; + height: 100%; + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + transition: all 0.2s; +} + +.age-restricted-content__wrapper.no-blur { + -webkit-backdrop-filter: blur(0px); + backdrop-filter: blur(0px); +} + .protected-content__wrapper { position: absolute; display: flex; diff --git a/ui/scss/component/_file-render.scss b/ui/scss/component/_file-render.scss index fb9cc153c2..96f4add492 100644 --- a/ui/scss/component/_file-render.scss +++ b/ui/scss/component/_file-render.scss @@ -1318,7 +1318,8 @@ $control-bar-icon-size: 30px; } .paid-content-overlay, -.protected-content-overlay { +.protected-content-overlay, +.age-restricted-content-overlay { .button { border: 1px solid white; transition: all 0.2s;