diff --git a/packages/common/src/hooks/useAccessAndRemixSettings.test.ts b/packages/common/src/hooks/useAccessAndRemixSettings.test.ts new file mode 100644 index 00000000000..5d53d0decd9 --- /dev/null +++ b/packages/common/src/hooks/useAccessAndRemixSettings.test.ts @@ -0,0 +1,336 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { + AccessConditionsEthNFTCollection, + Chain, + Collectible, + CollectibleGatedConditions, + CollectibleMediaType, + FollowGatedConditions, + TipGatedConditions, + USDCPurchaseConditions +} from '~/models' + +import { useAccessAndRemixSettings } from './useAccessAndRemixSettings' + +const { mockedUseSelector } = vi.hoisted(() => { + return { mockedUseSelector: vi.fn() } +}) + +vi.mock('react-redux', () => { + return { + useSelector: mockedUseSelector + } +}) + +const mockUseSelector = (mockedState: any) => (selectorFn: any) => + selectorFn(mockedState) + +const mockEthCollectible: Collectible = { + id: '123', + tokenId: '123', + name: 'dank nft', + description: 'dank nft description', + mediaType: CollectibleMediaType.IMAGE, + frameUrl: 'danknft.com/eth/frameUrl', + imageUrl: 'danknft.com/eth/imageUrl', + gifUrl: 'danknft.com/eth/gifUrl', + videoUrl: 'danknft.com/eth/videoUrl', + threeDUrl: 'danknft.com/eth/threeDUrl', + animationUrl: 'danknft.com/eth/animationUrl', + hasAudio: false, + isOwned: true, + dateCreated: '01-01-2020', + dateLastTransferred: '01-01-2020', + chain: Chain.Eth, + permaLink: 'danknft.com/eth/permaLink', + wallet: 'pretendThisIsAWalletAddress', + collectionName: 'dank nft', + collectionSlug: 'dank-nft', + collectionImageUrl: 'danknft.com/img', + assetContractAddress: 'pretendThisIsAnAddress', + externalLink: 'danknft.com/eth/pretendThisIsAnAddress', + standard: 'ERC721' +} + +const mockUSDCGateConditions: USDCPurchaseConditions = { + usdc_purchase: { + price: 100, + splits: { 123: 100 } + } +} + +const mockFollowGateConditions: FollowGatedConditions = { + follow_user_id: 123 +} +const mockTipGateConditions: TipGatedConditions = { + tip_user_id: 123 +} + +const mockCollectibleGateConditions: CollectibleGatedConditions = { + nft_collection: { + ...mockEthCollectible, + address: 'someAddress', + slug: 'slug' + } as AccessConditionsEthNFTCollection +} + +const reduxStateWithoutCollectibles = { + account: { + userId: 123 + }, + collectibles: { + userCollectibles: { 123: { sol: [], eth: [] } }, + solCollections: {} + } +} + +const reduxStateWithCollectibles = { + account: { + userId: 123 + }, + collectibles: { + userCollectibles: { + 123: { sol: [], eth: [mockEthCollectible] } + }, + solCollections: {} + } +} + +describe('useAccessAndRemixSettings', () => { + beforeEach(() => { + mockedUseSelector.mockImplementation( + mockUseSelector(reduxStateWithoutCollectibles) + ) + }) + describe('track upload', () => { + it('should support all options when the user has collectibles', () => { + mockedUseSelector.mockImplementation( + mockUseSelector(reduxStateWithCollectibles) + ) + const actual = useAccessAndRemixSettings({ + isUpload: true, + isRemix: false, + isAlbum: undefined, + initialStreamConditions: null, + isInitiallyUnlisted: false, + isScheduledRelease: false + }) + const expected = { + disableUsdcGate: false, + disableSpecialAccessGate: false, + disableSpecialAccessGateFields: false, + disableCollectibleGate: false, + disableCollectibleGateFields: false, + disableHidden: false + } + expect(actual).toEqual(expected) + }) + it('should disable collectibles if the user has none', () => { + const actual = useAccessAndRemixSettings({ + isUpload: true, + isRemix: false, + isAlbum: undefined, + initialStreamConditions: null, + isInitiallyUnlisted: false, + isScheduledRelease: false + }) + const expected = { + disableUsdcGate: false, + disableSpecialAccessGate: false, + disableSpecialAccessGateFields: false, + disableCollectibleGate: true, + disableCollectibleGateFields: true, + disableHidden: false + } + expect(actual).toEqual(expected) + }) + it('should disable all except hidden for track remixes', () => { + const actual = useAccessAndRemixSettings({ + isUpload: true, + isRemix: true, + isAlbum: undefined, + initialStreamConditions: null, + isInitiallyUnlisted: false, + isScheduledRelease: false + }) + const expected = { + disableUsdcGate: true, + disableSpecialAccessGate: true, + disableSpecialAccessGateFields: true, + disableCollectibleGate: true, + disableCollectibleGateFields: true, + disableHidden: false + } + expect(actual).toEqual(expected) + }) + }) + describe('track edit', () => { + it('public track - should disable all options', () => { + const actual = useAccessAndRemixSettings({ + isUpload: false, + isRemix: false, + isAlbum: undefined, + initialStreamConditions: null, + isInitiallyUnlisted: false, + isScheduledRelease: false + }) + const expected = { + disableUsdcGate: true, + disableSpecialAccessGate: true, + disableSpecialAccessGateFields: true, + disableCollectibleGate: true, + disableCollectibleGateFields: true, + disableHidden: true + } + expect(actual).toEqual(expected) + }) + it('scheduled release - should allow everything except hidden', () => { + mockedUseSelector.mockImplementation( + mockUseSelector(reduxStateWithCollectibles) + ) + const actual = useAccessAndRemixSettings({ + isUpload: false, + isRemix: false, + isAlbum: undefined, + initialStreamConditions: null, + isInitiallyUnlisted: true, + isScheduledRelease: true + }) + const expected = { + disableUsdcGate: false, + disableSpecialAccessGate: false, + disableSpecialAccessGateFields: false, + disableCollectibleGate: false, + disableCollectibleGateFields: false, + disableHidden: true + } + expect(actual).toEqual(expected) + }) + it('follow gated - should disable everything except original parent option & hidden', () => { + const actual = useAccessAndRemixSettings({ + isUpload: false, + isRemix: false, + isAlbum: undefined, + initialStreamConditions: mockFollowGateConditions, + isInitiallyUnlisted: false, + isScheduledRelease: false + }) + const expected = { + disableUsdcGate: true, + disableSpecialAccessGate: false, + disableSpecialAccessGateFields: true, + disableCollectibleGate: true, + disableCollectibleGateFields: true, + disableHidden: true + } + expect(actual).toEqual(expected) + }) + it('follow gated - should disable everything except original parent option & hidden', () => { + const actual = useAccessAndRemixSettings({ + isUpload: false, + isRemix: false, + isAlbum: undefined, + initialStreamConditions: mockTipGateConditions, + isInitiallyUnlisted: false, + isScheduledRelease: false + }) + const expected = { + disableUsdcGate: true, + disableSpecialAccessGate: false, + disableSpecialAccessGateFields: true, + disableCollectibleGate: true, + disableCollectibleGateFields: true, + disableHidden: true + } + expect(actual).toEqual(expected) + }) + it('collectible gated - should disable everything except original parent option & hidden', () => { + mockedUseSelector.mockImplementation( + mockUseSelector(reduxStateWithCollectibles) + ) + const actual = useAccessAndRemixSettings({ + isUpload: false, + isRemix: false, + isAlbum: undefined, + initialStreamConditions: mockCollectibleGateConditions, + isInitiallyUnlisted: false, + isScheduledRelease: false + }) + const expected = { + disableUsdcGate: true, + disableSpecialAccessGate: true, + disableSpecialAccessGateFields: true, + disableCollectibleGate: false, + disableCollectibleGateFields: true, + disableHidden: true + } + expect(actual).toEqual(expected) + }) + it('usdc gated - should disable everything except original parent option & hidden', () => { + mockedUseSelector.mockImplementation( + mockUseSelector(reduxStateWithCollectibles) + ) + const actual = useAccessAndRemixSettings({ + isUpload: false, + isRemix: false, + isAlbum: undefined, + initialStreamConditions: mockUSDCGateConditions, + isInitiallyUnlisted: false, + isScheduledRelease: false + }) + const expected = { + disableUsdcGate: false, + disableSpecialAccessGate: true, + disableSpecialAccessGateFields: true, + disableCollectibleGate: true, + disableCollectibleGateFields: true, + disableHidden: true + } + expect(actual).toEqual(expected) + }) + it('initially hidden - should enable everything', () => { + mockedUseSelector.mockImplementation( + mockUseSelector(reduxStateWithCollectibles) + ) + const actual = useAccessAndRemixSettings({ + isUpload: false, + isRemix: false, + isAlbum: undefined, + initialStreamConditions: null, + isInitiallyUnlisted: true, + isScheduledRelease: false + }) + const expected = { + disableUsdcGate: false, + disableSpecialAccessGate: false, + disableSpecialAccessGateFields: false, + disableCollectibleGate: false, + disableCollectibleGateFields: false, + disableHidden: false + } + expect(actual).toEqual(expected) + }) + }) + describe('album upload', () => { + it('should only allow usdc for album uploads', () => { + const actual = useAccessAndRemixSettings({ + isUpload: true, + isRemix: false, + isAlbum: true, + initialStreamConditions: null, + isInitiallyUnlisted: false, + isScheduledRelease: false + }) + const expected = { + disableUsdcGate: false, + disableSpecialAccessGate: true, + disableSpecialAccessGateFields: true, + disableCollectibleGate: true, + disableCollectibleGateFields: true, + disableHidden: true + } + expect(actual).toEqual(expected) + }) + }) +}) diff --git a/packages/common/src/hooks/useAccessAndRemixSettings.ts b/packages/common/src/hooks/useAccessAndRemixSettings.ts index ad401568aef..069582ef7ad 100644 --- a/packages/common/src/hooks/useAccessAndRemixSettings.ts +++ b/packages/common/src/hooks/useAccessAndRemixSettings.ts @@ -29,20 +29,22 @@ export const useHasNoCollectibles = () => { } /** - * Returns a map of booleans that determine whether to show certain access fields are enabled. + * Returns a map of booleans that determine whether certain access fields are enabled. * This is based on whether the user is uploading a track or editing it. * * 1. Remixes cannot be gated tracks. * 2. During upload, all access options are enabled unless the track is marked as a remix, * in which case only Public and Hidden are enabled. * 3. During edit, rule of thumb is that gated tracks can only be modified to allow broader access. - * This means that gated tracks can only be made public. - * Hidden tracks may be gated or made public. + * This means that gated tracks can only be made public. + * 4. Hidden tracks may be gated or made public. + * + * NOTE: this logic is different from the logic using feature flags. to determine whether options should render or not; just whether or not they should be disabled */ export const useAccessAndRemixSettings = ({ isUpload, isRemix, - isAlbum, + isAlbum = false, initialStreamConditions, isInitiallyUnlisted, isScheduledRelease = false @@ -70,41 +72,48 @@ export const useAccessAndRemixSettings = ({ const isInitiallyHidden = !isUpload && isInitiallyUnlisted - const noUsdcGate = + const disableUsdcGate = isRemix || isInitiallyPublic || isInitiallySpecialAccess || isInitiallyCollectibleGated - const noSpecialAccessGate = + const disableSpecialAccessGate = isAlbum || isRemix || isInitiallyPublic || isInitiallyUsdcGated || isInitiallyCollectibleGated - const noSpecialAccessGateFields = - noSpecialAccessGate || (!isUpload && !isInitiallyHidden) + + // This applies when the parent field is active but we still want to disable sub-options + // used for edit flow to not allow increasing permission strictness + const disableSpecialAccessGateFields = + disableSpecialAccessGate || (!isUpload && !isInitiallyHidden) const hasNoCollectibles = useHasNoCollectibles() - const noCollectibleGate = + + const disableCollectibleGate = isAlbum || isRemix || isInitiallyPublic || isInitiallyUsdcGated || isInitiallySpecialAccess || hasNoCollectibles - const noCollectibleGateFields = - noCollectibleGate || (!isUpload && !isInitiallyHidden) - const noHidden = + // This applies when the parent field is active but we still want to disable sub-options + // used for edit flow to not allow increasing permission strictness + const disableCollectibleGateFields = + disableCollectibleGate || (!isUpload && !isInitiallyHidden) + + const disableHidden = isAlbum || isScheduledRelease || (!isUpload && !isInitiallyUnlisted) return { - noUsdcGate, - noSpecialAccessGate, - noSpecialAccessGateFields, - noCollectibleGate, - noCollectibleGateFields, - noHidden + disableUsdcGate, + disableSpecialAccessGate, + disableSpecialAccessGateFields, + disableCollectibleGate, + disableCollectibleGateFields, + disableHidden } } diff --git a/packages/mobile/src/components/details-tile/DetailsTileNoAccess.tsx b/packages/mobile/src/components/details-tile/DetailsTileNoAccess.tsx index 23ccc528856..3acaf943ef7 100644 --- a/packages/mobile/src/components/details-tile/DetailsTileNoAccess.tsx +++ b/packages/mobile/src/components/details-tile/DetailsTileNoAccess.tsx @@ -13,12 +13,12 @@ import { } from '@audius/common/models' import type { ID, AccessConditions, User } from '@audius/common/models' import { FeatureFlags } from '@audius/common/services' +import type { PurchaseableContentType } from '@audius/common/store' import { usersSocialActions, tippingActions, usePremiumContentPurchaseModal, - gatedContentSelectors, - PurchaseableContentType + gatedContentSelectors } from '@audius/common/store' import { formatPrice } from '@audius/common/utils' import type { ViewStyle } from 'react-native' diff --git a/packages/mobile/src/components/details-tile/DetailsTilePremiumAccess.tsx b/packages/mobile/src/components/details-tile/DetailsTilePremiumAccess.tsx index 0e38bfbba8e..825a1067d43 100644 --- a/packages/mobile/src/components/details-tile/DetailsTilePremiumAccess.tsx +++ b/packages/mobile/src/components/details-tile/DetailsTilePremiumAccess.tsx @@ -4,11 +4,11 @@ import { isContentFollowGated, isContentTipGated } from '@audius/common/models' +import { PurchaseableContentType } from '@audius/common/store' import type { ViewStyle } from 'react-native' import { DetailsTileHasAccess } from './DetailsTileHasAccess' import { DetailsTileNoAccess } from './DetailsTileNoAccess' -import { PurchaseableContentType } from '@audius/common/store' type DetailsTileGatedAccessProps = { trackId: ID diff --git a/packages/mobile/src/components/details-tile/types.ts b/packages/mobile/src/components/details-tile/types.ts index 997e28632e9..f9939b69787 100644 --- a/packages/mobile/src/components/details-tile/types.ts +++ b/packages/mobile/src/components/details-tile/types.ts @@ -8,12 +8,12 @@ import type { User, AccessConditions } from '@audius/common/models' +import type { PurchaseableContentType } from '@audius/common/store' import type { Nullable } from '@audius/common/utils' import type { TextStyle } from 'react-native' import type { ImageProps } from '@audius/harmony-native' import type { GestureResponderHandler } from 'app/types/gesture' -import { PurchaseableContentType } from '@audius/common/store' export type DetailsTileDetail = { icon?: ReactNode diff --git a/packages/mobile/src/components/premium-content-purchase-drawer/PurchaseSummaryTable.tsx b/packages/mobile/src/components/premium-content-purchase-drawer/PurchaseSummaryTable.tsx index 34e6f07359a..3c4406c890d 100644 --- a/packages/mobile/src/components/premium-content-purchase-drawer/PurchaseSummaryTable.tsx +++ b/packages/mobile/src/components/premium-content-purchase-drawer/PurchaseSummaryTable.tsx @@ -1,10 +1,10 @@ +import { PurchaseableContentType } from '@audius/common/store' import { formatPrice } from '@audius/common/utils' import { Text } from 'app/components/core' import { SummaryTable } from '../summary-table' import type { SummaryTableItem } from '../summary-table/SummaryTable' -import { PurchaseableContentType } from '@audius/common/store' const messages = { summary: 'Total', diff --git a/packages/mobile/src/screens/edit-track-screen/screens/AccessAndSaleScreen.tsx b/packages/mobile/src/screens/edit-track-screen/screens/AccessAndSaleScreen.tsx index 389ad20edec..2b84c120f3e 100644 --- a/packages/mobile/src/screens/edit-track-screen/screens/AccessAndSaleScreen.tsx +++ b/packages/mobile/src/screens/edit-track-screen/screens/AccessAndSaleScreen.tsx @@ -125,12 +125,12 @@ export const AccessAndSaleScreen = () => { }, []) const { - noUsdcGate: noUsdcGateOption, - noSpecialAccessGate, - noSpecialAccessGateFields, - noCollectibleGate, - noCollectibleGateFields, - noHidden + disableUsdcGate: disableUsdcGateOption, + disableSpecialAccessGate, + disableSpecialAccessGateFields, + disableCollectibleGate, + disableCollectibleGateFields, + disableHidden } = useAccessAndRemixSettings({ isUpload, isRemix, @@ -139,7 +139,7 @@ export const AccessAndSaleScreen = () => { isScheduledRelease }) - const noUsdcGate = noUsdcGateOption || !isUsdcUploadEnabled + const disableUsdcGate = disableUsdcGateOption || !isUsdcUploadEnabled const [availability, setAvailability] = useState(initialAvailability) @@ -157,23 +157,23 @@ export const AccessAndSaleScreen = () => { ? { label: premiumAvailability, value: premiumAvailability, - disabled: noUsdcGate + disabled: disableUsdcGate } : null, { label: specialAccessAvailability, value: specialAccessAvailability, - disabled: noSpecialAccessGate + disabled: disableSpecialAccessGate }, { label: collectibleGatedAvailability, value: collectibleGatedAvailability, - disabled: noCollectibleGate + disabled: disableCollectibleGate }, { label: hiddenAvailability, value: hiddenAvailability, - disabled: noHidden + disabled: disableHidden } ].filter(removeNullable) @@ -189,8 +189,8 @@ export const AccessAndSaleScreen = () => { items[premiumAvailability] = ( ) @@ -199,8 +199,8 @@ export const AccessAndSaleScreen = () => { items[specialAccessAvailability] = ( ) @@ -208,8 +208,8 @@ export const AccessAndSaleScreen = () => { items[collectibleGatedAvailability] = ( ) @@ -217,7 +217,7 @@ export const AccessAndSaleScreen = () => { items[hiddenAvailability] = ( diff --git a/packages/web/src/pages/upload-page/fields/AccessAndSaleMenuFields.tsx b/packages/web/src/pages/upload-page/fields/AccessAndSaleMenuFields.tsx index 1b215983e2a..42e282033cd 100644 --- a/packages/web/src/pages/upload-page/fields/AccessAndSaleMenuFields.tsx +++ b/packages/web/src/pages/upload-page/fields/AccessAndSaleMenuFields.tsx @@ -90,15 +90,18 @@ export const AccessAndSaleMenuFields = (props: AccesAndSaleMenuFieldsProps) => { name: STREAM_AVAILABILITY_TYPE }) - const { noSpecialAccessGate, noSpecialAccessGateFields, noHidden } = - useAccessAndRemixSettings({ - isUpload: !!isUpload, - isRemix, - isAlbum, - initialStreamConditions: initialStreamConditions ?? null, - isInitiallyUnlisted: !!isInitiallyUnlisted, - isScheduledRelease: !!isScheduledRelease - }) + const { + disableSpecialAccessGate, + disableSpecialAccessGateFields, + disableHidden + } = useAccessAndRemixSettings({ + isUpload: !!isUpload, + isRemix, + isAlbum, + initialStreamConditions: initialStreamConditions ?? null, + isInitiallyUnlisted: !!isInitiallyUnlisted, + isScheduledRelease: !!isScheduledRelease + }) return (
@@ -127,9 +130,9 @@ export const AccessAndSaleMenuFields = (props: AccesAndSaleMenuFieldsProps) => { label={messages.specialAccess} description={messages.specialAccessSubtitle} value={StreamTrackAvailabilityType.SPECIAL_ACCESS} - disabled={noSpecialAccessGate} + disabled={disableSpecialAccessGate} checkedContent={ - + } /> ) : null} @@ -146,7 +149,7 @@ export const AccessAndSaleMenuFields = (props: AccesAndSaleMenuFieldsProps) => { label={messages.hidden} value={StreamTrackAvailabilityType.HIDDEN} description={messages.hiddenSubtitle} - disabled={noHidden} + disabled={disableHidden} // isInitiallyUnlisted is undefined on create // show hint on scheduled releases that are in create or already unlisted hintContent={ diff --git a/packages/web/src/pages/upload-page/fields/stream-availability/collectible-gated/CollectibleGatedRadioField.tsx b/packages/web/src/pages/upload-page/fields/stream-availability/collectible-gated/CollectibleGatedRadioField.tsx index 67c8c8f110d..afbf67a4181 100644 --- a/packages/web/src/pages/upload-page/fields/stream-availability/collectible-gated/CollectibleGatedRadioField.tsx +++ b/packages/web/src/pages/upload-page/fields/stream-availability/collectible-gated/CollectibleGatedRadioField.tsx @@ -34,8 +34,8 @@ export const CollectibleGatedRadioField = ( const hasNoCollectibles = useHasNoCollectibles() const { - noCollectibleGate: disabled, - noCollectibleGateFields: fieldsDisabled + disableCollectibleGate: disabled, + disableCollectibleGateFields: fieldsDisabled } = useAccessAndRemixSettings({ isUpload: !!isUpload, isRemix, diff --git a/packages/web/src/pages/upload-page/fields/stream-availability/usdc-purchase-gated/UsdcPurchaseGatedRadioField.tsx b/packages/web/src/pages/upload-page/fields/stream-availability/usdc-purchase-gated/UsdcPurchaseGatedRadioField.tsx index 12c7c81990b..22c6ea421e0 100644 --- a/packages/web/src/pages/upload-page/fields/stream-availability/usdc-purchase-gated/UsdcPurchaseGatedRadioField.tsx +++ b/packages/web/src/pages/upload-page/fields/stream-availability/usdc-purchase-gated/UsdcPurchaseGatedRadioField.tsx @@ -55,14 +55,14 @@ export const UsdcPurchaseGatedRadioField = ( FeatureFlags.USDC_PURCHASES_UPLOAD ) - const { noUsdcGate } = useAccessAndRemixSettings({ + const { disableUsdcGate } = useAccessAndRemixSettings({ isUpload: !!isUpload, isRemix, isAlbum, initialStreamConditions: initialStreamConditions ?? null, isInitiallyUnlisted: !!isInitiallyUnlisted }) - const disabled = noUsdcGate || !isUsdcUploadEnabled + const disabled = disableUsdcGate || !isUsdcUploadEnabled const helpContent = (