diff --git a/src/components/Modals/DeployModal/DeployModal.container.ts b/src/components/Modals/DeployModal/DeployModal.container.ts index 55a57da6d..b51299e59 100644 --- a/src/components/Modals/DeployModal/DeployModal.container.ts +++ b/src/components/Modals/DeployModal/DeployModal.container.ts @@ -3,15 +3,17 @@ import { RootState } from 'modules/common/types' import { getCurrentProject } from 'modules/project/selectors' import { getData as getDeployments } from 'modules/deployment/selectors' import { getActivePoolGroup } from 'modules/poolGroup/selectors' +import { getCurrentScene } from 'modules/scene/selectors' +import { getIsWorldsForEnsOwnersEnabled } from 'modules/features/selectors' import { MapStateProps, OwnProps } from './DeployModal.types' import DeployModal from './DeployModal' -import { getCurrentScene } from 'modules/scene/selectors' const mapState = (state: RootState, ownProps: OwnProps): MapStateProps => ({ deployment: getDeployments(state)[ownProps.metadata.projectId], currentPoolGroup: getActivePoolGroup(state), project: getCurrentProject(state), - scene: getCurrentScene(state) + scene: getCurrentScene(state), + isWorldsForEnsOwnersEnabled: getIsWorldsForEnsOwnersEnabled(state) }) export default connect(mapState)(DeployModal) diff --git a/src/components/Modals/DeployModal/DeployModal.tsx b/src/components/Modals/DeployModal/DeployModal.tsx index 8079eaadf..bb6955cb4 100644 --- a/src/components/Modals/DeployModal/DeployModal.tsx +++ b/src/components/Modals/DeployModal/DeployModal.tsx @@ -6,6 +6,7 @@ import Icon from 'components/Icon' import DeployToLand from './DeployToLand' import DeployToPool from './DeployToPool' import DeployToWorld from './DeployToWorld' +import DeployToWorldWorldsForEnsOwners from './DeployToWorld_WorldsForEnsOwnersFeature' import ClearDeployment from './ClearDeployment' import { Props, State, DeployModalView } from './DeployModal.types' import './DeployModal.css' @@ -132,7 +133,7 @@ export default class DeployModal extends React.PureComponent { render() { const { view, deploymentId, claimedName } = this.state - const { name, currentPoolGroup, scene } = this.props + const { name, currentPoolGroup, scene, isWorldsForEnsOwnersEnabled } = this.props if (view === DeployModalView.CLEAR_DEPLOYMENT && deploymentId) { return @@ -156,7 +157,11 @@ export default class DeployModal extends React.PureComponent { } if (view === DeployModalView.DEPLOY_TO_WORLD) { - return + return isWorldsForEnsOwnersEnabled ? ( + + ) : ( + + ) } return this.renderChoiceForm() diff --git a/src/components/Modals/DeployModal/DeployModal.types.ts b/src/components/Modals/DeployModal/DeployModal.types.ts index 21e648194..64d894ad0 100644 --- a/src/components/Modals/DeployModal/DeployModal.types.ts +++ b/src/components/Modals/DeployModal/DeployModal.types.ts @@ -10,6 +10,7 @@ export type Props = ModalProps & { currentPoolGroup: PoolGroup | null project: Project | null scene: Scene | null + isWorldsForEnsOwnersEnabled: boolean } export type State = { @@ -24,7 +25,7 @@ export type Step = { } export type OwnProps = Pick -export type MapStateProps = Pick +export type MapStateProps = Pick export enum DeployModalView { NONE = 'NONE', diff --git a/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/DeployToWorld.container.ts b/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/DeployToWorld.container.ts new file mode 100644 index 000000000..3ca611104 --- /dev/null +++ b/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/DeployToWorld.container.ts @@ -0,0 +1,39 @@ +import { connect } from 'react-redux' +import { push, replace } from 'connected-react-router' +import { RootState } from 'modules/common/types' +import { getCurrentProject } from 'modules/project/selectors' +import { getENSByWallet, getExternalNamesForConnectedWallet } from 'modules/ens/selectors' +import { fetchExternalNamesRequest } from 'modules/ens/actions' +import { deployToWorldRequest } from 'modules/deployment/actions' +import { getCurrentMetrics } from 'modules/scene/selectors' +import { recordMediaRequest } from 'modules/media/actions' +import { getDeploymentsByWorlds, getProgress as getUploadProgress, getError, isLoading } from 'modules/deployment/selectors' +import { Project } from 'modules/project/types' +import { MapDispatch, MapDispatchProps, MapStateProps } from './DeployToWorld.types' + +import DeployToWorld from './DeployToWorld' + +const mapState = (state: RootState): MapStateProps => { + return { + ensList: getENSByWallet(state), + externalNames: getExternalNamesForConnectedWallet(state), + project: getCurrentProject(state) as Project, + metrics: getCurrentMetrics(state), + deployments: getDeploymentsByWorlds(state), + deploymentProgress: getUploadProgress(state), + error: getError(state), + isLoading: isLoading(state) + } +} + +const mapDispatch = (dispatch: MapDispatch): MapDispatchProps => ({ + onPublish: (projectId: string, name: string) => { + return dispatch(deployToWorldRequest(projectId, name)) + }, + onRecord: () => dispatch(recordMediaRequest()), + onNavigate: path => dispatch(push(path)), + onReplace: (path, locationState) => dispatch(replace(path, locationState)), + onFetchExternalNames: () => dispatch(fetchExternalNamesRequest()) +}) + +export default connect(mapState, mapDispatch)(DeployToWorld) diff --git a/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/DeployToWorld.module.css b/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/DeployToWorld.module.css new file mode 100644 index 000000000..3db570958 --- /dev/null +++ b/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/DeployToWorld.module.css @@ -0,0 +1,228 @@ +.modalBody { + width: 750px; + padding: 48px; + display: flex; + flex-direction: column; + gap: 32px; +} + +.modalNavigation { + display: flex; + justify-content: space-between; +} + +.modalNavigation.end { + justify-content: end; +} + +.emptyState { + display: flex; + flex-direction: column; + gap: 30px; +} + +.emptyState .modalHeader h3 { + font-size: 20px; + line-height: 24px; +} + +.modalHeader { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.modalHeader h3 { + font-weight: 600; + font-size: 30px; + line-height: 36px; +} + +.modalHeader span { + font-weight: 400; + font-size: 18px; + line-height: 22px; +} + +.modalBody .actionButton:global(.ui.button) { + height: auto; + width: fit-content; + align-self: flex-end; +} + +.modalBodyState { + display: flex; + flex-direction: column; + align-items: center; +} + +.modalBodyState .description { + text-align: center; + font-size: 14px; + line-height: 20px; +} + +.modalBodyStateActions { + display: flex; + flex-wrap: wrap; + justify-content: center; +} + +.modalBodyStateActionButton { + height: 46px !important; + width: 475px !important; + margin: 8px 32px !important; +} + +.modalForm { + display: flex; + gap: 36px; +} + +.modalFormActions:has(.actionCheckbox):has(.actionButton) { + justify-content: space-between !important; +} + +.actionCheckbox { + display: flex; + align-items: center; +} + +.actionCheckbox div { + margin-right: 8px; +} + +.actionCheckbox :global(.ui.checkbox input[type='checkbox'].hidden) ~ label:hover::before { + border: 2px solid white; +} + +.thumbnail { + width: 240px; + height: 190px; + background-size: cover; + background-position: center; + border: 1px solid lightgray; +} + +.thumbnailInfo { + float: right; + margin: 3px 7px !important; + filter: invert(84%) sepia(8%) saturate(12%) hue-rotate(24deg) brightness(102%) contrast(89%); +} + +.thumbnailInfo:hover { + filter: invert(97%) sepia(97%) saturate(0%) hue-rotate(17deg) brightness(104%) contrast(104%); +} + +.metricsList { + margin-top: 4px !important; +} + +.metricsList div { + margin-left: 4px; + text-transform: capitalize; +} + +.metricsList div::before { + content: '-\00a0'; +} + +.modalForm .worldDetails { + width: 375px; +} + +.nameTypeOption { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.modalForm .worldDetailsDescription { + color: var(--text); + font-size: 14px; + margin-bottom: 18px; + overflow-wrap: break-word; +} + +.modalForm .worldHasContent { + display: flex; + align-items: center; + background-color: rgba(115, 110, 125, 0.16); + border-radius: 4px; + padding: 6px 8px; + font-size: 12px; + line-height: 16px; +} + +.modalForm .worldHasContent :global(.Icon) { + min-width: 24px; +} + +.modalForm .worldHasContent div { + margin-right: 13px; +} + +.navigationButton { + background: transparent; + border: 0; + cursor: pointer; +} + +.navigationButton:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.modalBodyEmptyState .emptyThumbnail { + height: 180px; + width: 180px; + margin-bottom: 10px; + background-position: center; + background-size: contain; + background-repeat: no-repeat; + background-image: url('../../../../images/empty-deploy-to-world.svg'); +} + +.modalBodySuccessState .description { + font-size: 14px; + margin-bottom: 28px; +} + +.modalBodySuccessState .shareUrlFieldInput input { + border: 2px solid var(--text) !important; + padding: 9px 15px !important; + font-size: 15px !important; +} + +.modalBodySuccessState .shareUrlFieldInput i { + opacity: 1 !important; +} + +.shareUrlField { + width: 432px; +} + +.shareUrlField p { + display: none !important; +} + +.modalBodySuccessState .successImage { + height: 150px; + width: 150px; + margin-bottom: 10px; + background-position: center; + background-size: contain; + background-repeat: no-repeat; + background-image: url('../../../../images/save-world-success.svg'); +} + +.failureImage { + height: 150px; + width: 150px; + margin-bottom: 10px; + background-position: center; + background-size: contain; + background-repeat: no-repeat; + background-image: url('../../../../images/scene-error.svg'); +} diff --git a/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/DeployToWorld.tsx b/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/DeployToWorld.tsx new file mode 100644 index 000000000..c574fdd2c --- /dev/null +++ b/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/DeployToWorld.tsx @@ -0,0 +1,456 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import classNames from 'classnames' +import { Button, Field, Icon as DCLIcon, SelectField, Checkbox, Row, Popup, List, DropdownItemProps } from 'decentraland-ui' +import Modal from 'decentraland-dapps/dist/containers/Modal' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { getAnalytics } from 'decentraland-dapps/dist/modules/analytics/utils' +import { config } from 'config' +import { isDevelopment } from 'lib/environment' +import { locations } from 'routing/locations' +import { Deployment } from 'modules/deployment/types' +import { FromParam } from 'modules/location/types' +import CopyToClipboard from 'components/CopyToClipboard/CopyToClipboard' +import Icon from 'components/Icon' +import { InfoIcon } from 'components/InfoIcon' +import { DeployToWorldView, NameType, Props } from './DeployToWorld.types' +import dclImage from './images/dcl.svg' +import ensImage from './images/ens.svg' + +import styles from './DeployToWorld.module.css' + +const EXPLORER_URL = config.get('EXPLORER_URL', '') +const WORLDS_CONTENT_SERVER_URL = config.get('WORLDS_CONTENT_SERVER', '') +const CLAIM_NAME_OPTION = 'claim_name_option' + +export default function DeployToWorld({ + name, + project, + metrics, + ensList, + externalNames, + deployments, + isLoading, + error, + claimedName, + onPublish, + onRecord, + onNavigate, + onReplace, + onClose, + onBack, + onFetchExternalNames +}: Props) { + const analytics = getAnalytics() + + const [view, setView] = useState('') + const [world, setWorld] = useState(claimedName ?? '') + const [nameType, setNameType] = useState(NameType.DCL) + const [loading, setLoading] = useState(false) + const [confirmWorldReplaceContent, setConfirmWorldReplaceContent] = useState(false) + // Ref used to store current world deployment status and validate if the user is trying to deploy the same world + const currentDeployment = useRef() + + const currenWorldLabel = world && ensList.find(ens => ens.subdomain === world)?.name + + useEffect(() => { + if (ensList.length === 0) { + setView(DeployToWorldView.EMPTY) + analytics.track('Publish to World step', { step: DeployToWorldView.EMPTY }) + } else if (!currentDeployment.current) { + setView(DeployToWorldView.FORM) + analytics.track('Publish to World step', { step: DeployToWorldView.FORM }) + onRecord() + } + }, [ensList, onRecord, analytics]) + + useEffect(() => { + if (view === DeployToWorldView.FORM && loading && error) { + setView(DeployToWorldView.ERROR) + setLoading(false) + analytics.track('Publish to World step', { step: DeployToWorldView.ERROR }) + } else if ( + view === DeployToWorldView.FORM && + world && + loading && + (currentDeployment.current ? deployments[world].timestamp > currentDeployment.current.timestamp : !!deployments[world]) + ) { + setView(DeployToWorldView.SUCCESS) + setLoading(false) + analytics.track('Publish to World step', { step: DeployToWorldView.SUCCESS }) + } + }, [view, world, loading, error, deployments, analytics]) + + useEffect(() => { + if (claimedName) { + analytics.track('Publish to World - Minted Name', { name: claimedName }) + } + }, [claimedName, analytics]) + + useEffect(() => { + onFetchExternalNames() + }, [onFetchExternalNames]) + + const handlePublish = useCallback(() => { + if (world) { + onPublish(project.id, world) + setLoading(true) + } + }, [onPublish, project, world]) + + const handleClose = useCallback(() => { + if (view === DeployToWorldView.SUCCESS) { + onNavigate(locations.sceneDetail(project.id)) + } + if (isLoading) { + return + } + onClose() + }, [view, project, isLoading, onClose, onNavigate]) + + const handleClaimName = useCallback(() => { + if (nameType === NameType.DCL) { + const ensUrl = `${locations.claimENS()}?from=${FromParam.DEPLOY_TO_WORLD}&projectId=${project.id}` + analytics.track('Publish to World - Claim Name') + onReplace(ensUrl, { fromParam: FromParam.DEPLOY_TO_WORLD, projectId: project.id }) + } else { + window.open('https://ens.domains/', '_blank', 'norefferer') + } + }, [nameType, project, onReplace, analytics]) + + const handleWorldSelected = useCallback( + (e: React.SyntheticEvent, { value }) => { + if (e.type === 'blur') { + return + } + + if (value === CLAIM_NAME_OPTION) { + handleClaimName() + return + } + + setWorld(value) + setConfirmWorldReplaceContent(false) + currentDeployment.current = deployments[value] + }, + [deployments, handleClaimName] + ) + + const handleNameTypeSelected = useCallback((_, { value }) => { + setWorld('') + setNameType(value) + }, []) + + const handleNavigateToExplorer = () => { + window.open(getExplorerUrl, '_blank,noreferrer') + } + + const handleConfirmWorldReplaceContent = useCallback((_, { checked }) => { + setConfirmWorldReplaceContent(checked) + }, []) + + const getExplorerUrl = useMemo(() => { + if (isDevelopment) { + return `${EXPLORER_URL}/?realm=${WORLDS_CONTENT_SERVER_URL}/world/${world}&NETWORK=sepolia` + } + return `${EXPLORER_URL}/world/${world}` + }, [world]) + + const nameTypeOptions = useMemo( + () => [ + { + text: ( + + dcl logo + {t('deployment_modal.deploy_world.name_type.dcl')} + + ), + value: NameType.DCL + }, + { + text: ( + + ens logo + {t('deployment_modal.deploy_world.name_type.ens')} + + ), + value: NameType.ENS + } + ], + [] + ) + + const worldOptions = useMemo(() => { + const names = nameType === NameType.DCL ? ensList : externalNames + const options: DropdownItemProps[] = names.map(ens => ({ text: ens.name, value: ens.subdomain })) + + if (nameType === NameType.DCL) { + options.push({ + text: ( + + + {t('deployment_modal.deploy_world.claim_name')} + + ), + value: CLAIM_NAME_OPTION + }) + } + + return options + }, [nameType, ensList, externalNames]) + + const getShareInTwitterUrl = () => { + const url = encodeURIComponent(getExplorerUrl) + const text = encodeURIComponent(t('deployment_modal.deploy_world.success.share_in_twitter_text')) + + return `https://twitter.com/intent/tweet?text=${text}&url=${url}` + } + + const renderEmptyState = () => { + return ( +
+
+

{t('deployment_modal.deploy_world.empty_state_title')}

+
+
+
+ + {t('deployment_modal.deploy_world.empty_state_description', { + br: () =>
, + b: (text: string) => {text} + })} +
+
+
+ + +
+
+ ) + } + + const renderSuccessState = () => { + return ( + <> +
+
+

{t('deployment_modal.deploy_world.success.title')}

+ {t('deployment_modal.deploy_world.success.subtitle')} + + } + /> + +
+
+
+ + ) + } + + const renderFailureState = () => { + return ( +
+
+

{t('deployment_modal.deploy_world.failure.title')}

+ {t('deployment_modal.deploy_world.failure.subtitle')} +
+ ) + } + + const renderMetrics = () => { + const { rows, cols } = project.layout + return ( +
+ {t('deployment_modal.deploy_world.scene_information')}: + + + {t('global.size')}: {rows} x {cols} + + + {t('metrics.triangles')}: {metrics.triangles} + + + {t('metrics.materials')}: {metrics.materials} + + + {t('metrics.meshes')}: {metrics.meshes} + + + {t('metrics.bodies')}: {metrics.bodies} + + + {t('metrics.entities')}: {metrics.entities} + + + {t('metrics.textures')}: {metrics.textures} + + +
+ ) + } + + const renderThumbnail = () => { + const thumbnailUrl = project.thumbnail + return ( +
+ } + hideOnScroll={true} + on="hover" + inverted + basic + /> +
+ ) + } + + const renderForm = () => { + const hasWorldContent = !!deployments[world] + return ( + <> +
+

{t('deployment_modal.deploy_world.title')}

+ + {t('deployment_modal.deploy_world.description')} + } + hideOnScroll={true} + on="hover" + inverted + basic + /> + +
+
+ {project?.thumbnail ? renderThumbnail() : null} +
+ + + {world ? ( + <> +

+ {t('deployment_modal.deploy_world.world_url_description', { + br: () =>
, + b: (text: string) => {text}, + world_url: getExplorerUrl + })} +

+ {hasWorldContent ? ( +
+ + {t('deployment_modal.deploy_world.world_has_content', { world: currenWorldLabel })} +
+ ) : null} + + ) : undefined} +
+
+ + {hasWorldContent ? ( +
+ + {t('deployment_modal.deploy_world.confirm_world_replace_content')} +
+ ) : null} + +
+ + ) + } + + const renderStep = () => { + switch (view) { + case DeployToWorldView.FORM: + return renderForm() + case DeployToWorldView.SUCCESS: + return renderSuccessState() + case DeployToWorldView.ERROR: + return renderFailureState() + case DeployToWorldView.EMPTY: + return renderEmptyState() + default: + return null + } + } + + return ( + +
+
+ {view !== DeployToWorldView.SUCCESS && ( + + )} + +
+ {renderStep()} +
+
+ ) +} diff --git a/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/DeployToWorld.types.ts b/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/DeployToWorld.types.ts new file mode 100644 index 000000000..73f49c5d8 --- /dev/null +++ b/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/DeployToWorld.types.ts @@ -0,0 +1,59 @@ +import { Dispatch } from 'redux' +import { CallHistoryMethodAction } from 'connected-react-router' +import { deployToWorldRequest, DeployToWorldRequestAction } from 'modules/deployment/actions' +import { recordMediaRequest, RecordMediaRequestAction } from 'modules/media/actions' +import { ENS } from 'modules/ens/types' +import { FetchExternalNamesRequestAction } from 'modules/ens/actions' +import { Project } from 'modules/project/types' +import { ModelMetrics } from 'modules/models/types' +import { DeploymentState } from 'modules/deployment/reducer' +import { Deployment } from 'modules/deployment/types' +import { DeployToWorldLocationStateProps } from 'modules/location/types' +import { DeployModalMetadata } from '../DeployModal.types' + +export type Props = { + name: string + project: Project + metrics: ModelMetrics + ensList: ENS[] + externalNames: ENS[] + deployments: Record + deploymentProgress: DeploymentState['progress'] + error: string | null + isLoading: boolean + claimedName: string | null + onClose: () => void + onBack: () => void + onPublish: typeof deployToWorldRequest + onRecord: typeof recordMediaRequest + onNavigate: (path: string) => void + onReplace: (path: string, locationState?: DeployToWorldLocationStateProps) => void + onFetchExternalNames: () => void +} + +export type MapStateProps = Pick< + Props, + 'ensList' | 'externalNames' | 'project' | 'metrics' | 'deployments' | 'deploymentProgress' | 'error' | 'isLoading' +> +export type MapDispatchProps = Pick +export type MapDispatch = Dispatch< + DeployToWorldRequestAction | CallHistoryMethodAction | RecordMediaRequestAction | FetchExternalNamesRequestAction +> + +export enum DeployToWorldView { + FORM = 'FORM', + PROGRESS = 'PROGRESS', + SUCCESS = 'SUCCESS', + EMPTY = 'EMPTY', + ERROR = 'ERROR' +} + +export type DeployToWorldModalMetadata = DeployModalMetadata & { + projectId: string + claimedName: string +} + +export enum NameType { + DCL, + ENS +} diff --git a/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/images/dcl.svg b/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/images/dcl.svg new file mode 100644 index 000000000..3255970e2 --- /dev/null +++ b/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/images/dcl.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/images/ens.svg b/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/images/ens.svg new file mode 100644 index 000000000..5be13e004 --- /dev/null +++ b/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/images/ens.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/index.ts b/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/index.ts new file mode 100644 index 000000000..d887d38ab --- /dev/null +++ b/src/components/Modals/DeployModal/DeployToWorld_WorldsForEnsOwnersFeature/index.ts @@ -0,0 +1,3 @@ +import DeployToWorld from './DeployToWorld.container' + +export default DeployToWorld diff --git a/src/modules/deployment/selectors.spec.ts b/src/modules/deployment/selectors.spec.ts index 5e650ced4..d2a40b36e 100644 --- a/src/modules/deployment/selectors.spec.ts +++ b/src/modules/deployment/selectors.spec.ts @@ -32,19 +32,37 @@ describe('when getting deployments by worlds', () => { name: 'my-ens', subdomain: 'my-end.dcl.eth' } + }, + externalNames: { + 'luffy.eth': { + subdomain: 'luffy.eth', + worldStatus: { + healthy: true, + scene: { + entityId: 'deployMyWorld3Id', + baseUrl: 'https://abaseUrl' + } + } + }, + 'zoro.eth': { + subdomain: 'zoro.eth' + } } }, deployment: { data: { deployMyWorldId: { id: 'deployMyWorldId' }, - deployMyWorld2Id: { id: 'deployMyWorld2Id' } + deployMyWorld2Id: { id: 'deployMyWorld2Id' }, + deployMyWorld3Id: { id: 'deployMyWorld3Id' }, + deployMyWorld4Id: { id: 'deployMyWorld4Id' } } } } as unknown as RootState expect(getDeploymentsByWorlds(state)).toEqual({ 'my-world.dcl.eth': { id: 'deployMyWorldId' }, - 'my-world2.dcl.eth': { id: 'deployMyWorld2Id' } + 'my-world2.dcl.eth': { id: 'deployMyWorld2Id' }, + 'luffy.eth': { id: 'deployMyWorld3Id' } }) }) }) diff --git a/src/modules/deployment/selectors.ts b/src/modules/deployment/selectors.ts index 509457e35..0dbdb0144 100644 --- a/src/modules/deployment/selectors.ts +++ b/src/modules/deployment/selectors.ts @@ -5,7 +5,7 @@ import { getCurrentProject, getUserProjects } from 'modules/project/selectors' import { Project } from 'modules/project/types' import { getLandTiles, getDeploymentsByCoord } from 'modules/land/selectors' import { LandTile } from 'modules/land/types' -import { getENSList } from 'modules/ens/selectors' +import { getENSList, getExternalNamesList } from 'modules/ens/selectors' import { ENS, WorldStatus } from 'modules/ens/types' import { idToCoords, coordsToId, emptyColorByRole } from 'modules/land/utils' import { DeploymentState } from './reducer' @@ -119,12 +119,15 @@ export const getEmptyTiles = createSelector>( +export const getDeploymentsByWorlds = createSelector>( getData, getENSList, - (deployments, ensList) => { + getExternalNamesList, + (deployments, ensList, externalNamesList) => { const out: Record = {} - const worlds = ensList.filter(ens => !!ens.worldStatus) + const dclNameWorlds = ensList.filter(ens => !!ens.worldStatus) + const externalNameWorlds = externalNamesList.filter(ens => !!ens.worldStatus) + const worlds = [...dclNameWorlds, ...externalNameWorlds] for (const world of worlds) { out[world.subdomain] = deployments[(world.worldStatus as WorldStatus).scene.entityId] diff --git a/src/modules/ens/reducer.spec.ts b/src/modules/ens/reducer.spec.ts index cac84d5fb..9d66f5453 100644 --- a/src/modules/ens/reducer.spec.ts +++ b/src/modules/ens/reducer.spec.ts @@ -54,7 +54,26 @@ describe('when handling the fetch external names actions', () => { it('should update the external names property in the state', () => { const action = fetchExternalNamesSuccess(owner, names) const newState = ensReducer(state, action) - expect(newState.externalNames[owner]).toEqual(names) + expect(newState.externalNames).toEqual({ + 'name1.eth': { + subdomain: 'name1.eth', + nftOwnerAddress: owner, + name: 'name1.eth', + content: '', + ensOwnerAddress: '', + resolver: '', + tokenId: '' + }, + 'name2.eth': { + subdomain: 'name2.eth', + nftOwnerAddress: owner, + name: 'name2.eth', + content: '', + ensOwnerAddress: '', + resolver: '', + tokenId: '' + } + }) }) it('should remove the fetch external names request action from the loading state', () => { diff --git a/src/modules/ens/reducer.ts b/src/modules/ens/reducer.ts index 0256ca40a..8c49c0fb3 100644 --- a/src/modules/ens/reducer.ts +++ b/src/modules/ens/reducer.ts @@ -65,10 +65,11 @@ import { FETCH_EXTERNAL_NAMES_SUCCESS } from './actions' import { ENS, ENSError, Authorization } from './types' +import { isExternalName } from './utils' export type ENSState = { data: Record - externalNames: Record + externalNames: Record authorizations: Record loading: LoadingState error: ENSError | null @@ -166,9 +167,9 @@ export function ensReducer(state: ENSState = INITIAL_STATE, action: ENSReducerAc } } } - case FETCH_ENS_SUCCESS: - case FETCH_ENS_WORLD_STATUS_SUCCESS: { + case FETCH_ENS_SUCCESS: { const { ens } = action.payload + return { ...state, loading: loadingReducer(state.loading, action), @@ -180,6 +181,37 @@ export function ensReducer(state: ENSState = INITIAL_STATE, action: ENSReducerAc } } } + case FETCH_ENS_WORLD_STATUS_SUCCESS: { + const { ens } = action.payload + + let update: Pick | Pick + + if (isExternalName(ens.subdomain)) { + update = { + externalNames: { + ...state.externalNames, + [ens.subdomain]: { + ...ens + } + } + } + } else { + update = { + data: { + ...state.data, + [ens.subdomain]: { + ...ens + } + } + } + } + + return { + ...state, + loading: loadingReducer(state.loading, action), + ...update + } + } case RECLAIM_NAME_SUCCESS: case CLAIM_NAME_SUCCESS: { const { ens } = action.payload @@ -197,12 +229,30 @@ export function ensReducer(state: ENSState = INITIAL_STATE, action: ENSReducerAc } case FETCH_EXTERNAL_NAMES_SUCCESS: { const { owner, names } = action.payload + + const externalNames: ENS[] = names.map(name => { + return { + subdomain: name, + nftOwnerAddress: owner, + content: '', + ensOwnerAddress: '', + name, + resolver: '', + tokenId: '' + } + }) + + const externalNamesByDomain = externalNames.reduce((obj, ens) => { + obj[ens.subdomain] = ens + return obj + }, {} as Record) + return { ...state, loading: loadingReducer(state.loading, action), externalNames: { ...state.externalNames, - [owner]: names + ...externalNamesByDomain } } } diff --git a/src/modules/ens/sagas.spec.ts b/src/modules/ens/sagas.spec.ts index f9b76e1a8..ecaa7d469 100644 --- a/src/modules/ens/sagas.spec.ts +++ b/src/modules/ens/sagas.spec.ts @@ -10,7 +10,7 @@ import { getAddress } from 'decentraland-dapps/dist/modules/wallet/selectors' import { ethers } from 'ethers' import { CONTROLLER_V2_ADDRESS, ENS_ADDRESS, MANA_ADDRESS, REGISTRAR_ADDRESS } from 'modules/common/contracts' import { DclListsAPI } from 'lib/api/lists' -import { WorldsAPI } from 'lib/api/worlds' +import { WorldInfo, WorldsAPI, content } from 'lib/api/worlds' import { MarketplaceAPI } from 'lib/api/marketplace' import { ENSApi } from 'lib/api/ens' import { getLands } from 'modules/land/selectors' @@ -21,12 +21,16 @@ import { fetchENSAuthorizationRequest, fetchENSListRequest, fetchENSListSuccess, + fetchENSWorldStatusFailure, + fetchENSWorldStatusRequest, + fetchENSWorldStatusSuccess, fetchExternalNamesFailure, fetchExternalNamesRequest, fetchExternalNamesSuccess } from './actions' import { ensSaga } from './sagas' import { ENS, ENSError } from './types' +import { getENSBySubdomain, getExternalNames } from './selectors' jest.mock('@dcl/builder-client') @@ -246,3 +250,115 @@ describe('when handling the fetching of external ens names for an owner', () => }) }) }) + +describe('when handling the fetch ens world status request', () => { + let subdomain: string + let fetchWorldsResult: WorldInfo | null + + describe('when the subdomain provided is a dcl subdomain', () => { + beforeEach(() => { + subdomain = 'name.dcl.eth' + }) + + describe('and getENSBySubdomain returns an ens object', () => { + let getENSBySubdomainResult: ENS + + beforeEach(() => { + getENSBySubdomainResult = { + subdomain + } as ENS + }) + + describe('and fetchWorlds returns null', () => { + beforeEach(() => { + fetchWorldsResult = null + }) + + it('should put an action signaling the success of the fetch ens world status request', async () => { + await expectSaga(ensSaga, builderClient, ensApi) + .provide([ + [select(getENSBySubdomain, subdomain), getENSBySubdomainResult], + [call([content, 'fetchWorld'], subdomain), fetchWorldsResult] + ]) + .put( + fetchENSWorldStatusSuccess({ + ...getENSBySubdomainResult, + worldStatus: null + }) + ) + .dispatch(fetchENSWorldStatusRequest(subdomain)) + .silentRun() + }) + }) + }) + }) + + describe('when the subdomain provided is an external subdomain', () => { + beforeEach(() => { + subdomain = 'name.eth' + }) + + describe('and getExternalNames returns a record with an ens object for the subdomain', () => { + let getExternalNamesResult: ReturnType + + beforeEach(() => { + getExternalNamesResult = { + [subdomain]: { + subdomain + } as ENS + } + }) + + describe('and fetchWorlds returns null', () => { + beforeEach(() => { + fetchWorldsResult = null + }) + + it('should put an action signaling the success of the fetch ens world status request', async () => { + await expectSaga(ensSaga, builderClient, ensApi) + .provide([ + [select(getExternalNames), getExternalNamesResult], + [call([content, 'fetchWorld'], subdomain), fetchWorldsResult] + ]) + .put( + fetchENSWorldStatusSuccess({ + ...getExternalNamesResult[subdomain], + worldStatus: null + }) + ) + .dispatch(fetchENSWorldStatusRequest(subdomain)) + .silentRun() + }) + }) + }) + + describe('and getExternalNames returns a record without an ens object for the subdomain', () => { + let getExternalNamesResult: ReturnType + + beforeEach(() => { + getExternalNamesResult = {} + }) + + describe('and fetchWorlds returns null', () => { + beforeEach(() => { + fetchWorldsResult = null + }) + + it('should put an action signaling the failure of the fetch ens world status request', async () => { + await expectSaga(ensSaga, builderClient, ensApi) + .provide([ + [select(getExternalNames), getExternalNamesResult], + [call([content, 'fetchWorld'], subdomain), fetchWorldsResult] + ]) + .put( + fetchENSWorldStatusFailure({ + message: `ENS ${subdomain} not found in store` + }) + ) + .dispatch(fetchENSWorldStatusRequest(subdomain)) + .silentRun() + }) + }) + }) + }) +}) diff --git a/src/modules/ens/sagas.ts b/src/modules/ens/sagas.ts index aac090a0b..448c611b6 100644 --- a/src/modules/ens/sagas.ts +++ b/src/modules/ens/sagas.ts @@ -74,9 +74,9 @@ import { fetchExternalNamesSuccess, fetchExternalNamesFailure } from './actions' -import { getENSBySubdomain } from './selectors' +import { getENSBySubdomain, getExternalNames } from './selectors' import { ENS, ENSOrigin, ENSError, Authorization } from './types' -import { getDomainFromName } from './utils' +import { getDomainFromName, isExternalName } from './utils' export function* ensSaga(builderClient: BuilderClient, ensApi: ENSApi) { yield takeLatest(FETCH_LANDS_SUCCESS, handleFetchLandsSuccess) @@ -194,8 +194,23 @@ export function* ensSaga(builderClient: BuilderClient, ensApi: ENSApi) { function* handleFetchENSWorldStatusRequest(action: FetchENSWorldStatusRequestAction) { const { subdomain } = action.payload - const ens: ENS = yield select(getENSBySubdomain, subdomain) + try { + let ens: ENS + + if (!isExternalName(subdomain)) { + ens = yield select(getENSBySubdomain, subdomain) + } else { + const externalNames: ReturnType = yield select(getExternalNames) + const maybeEns: ENS | undefined = externalNames[subdomain] + + if (!maybeEns) { + throw new Error(`ENS ${subdomain} not found in store`) + } + + ens = maybeEns + } + let worldStatus = null try { diff --git a/src/modules/ens/selectors.spec.ts b/src/modules/ens/selectors.spec.ts index 2cbe7acb1..ca21def29 100644 --- a/src/modules/ens/selectors.spec.ts +++ b/src/modules/ens/selectors.spec.ts @@ -1,6 +1,6 @@ import { WalletState } from 'decentraland-dapps/dist/modules/wallet/reducer' import { RootState } from 'modules/common/types' -import { getExternalNames, getExternalNamesByWallet } from './selectors' +import { getExternalNames, getExternalNamesForConnectedWallet } from './selectors' import { ENSState } from './reducer' let state: RootState @@ -11,8 +11,19 @@ beforeEach(() => { state = {} as RootState wallet = '0x123' externalNames = { - [wallet]: ['name1.eth', 'name2.eth'] - } + 'name1.eth': { + domain: 'name1.eth', + nftOwnerAddress: wallet + }, + 'name2.eth': { + domain: 'name2.eth', + nftOwnerAddress: wallet + }, + 'name3.eth': { + domain: 'name2.eth', + nftOwnerAddress: '0xOtherWallet' + } + } as unknown as ENSState['externalNames'] }) describe('when getting the external names', () => { @@ -24,7 +35,7 @@ describe('when getting the external names', () => { } as RootState }) - it('should return a record of names by address', () => { + it('should return a record of external names by domain', () => { expect(getExternalNames(state)).toEqual(externalNames) }) }) @@ -45,7 +56,16 @@ describe('when getting the external names by wallet', () => { }) it('should return the names of the wallet', () => { - expect(getExternalNamesByWallet(state)).toEqual(externalNames[wallet]) + expect(getExternalNamesForConnectedWallet(state)).toEqual([ + { + domain: 'name1.eth', + nftOwnerAddress: wallet + }, + { + domain: 'name2.eth', + nftOwnerAddress: wallet + } + ]) }) }) @@ -64,7 +84,7 @@ describe('when getting the external names by wallet', () => { }) it('should return an empty array', () => { - expect(getExternalNamesByWallet(state)).toEqual([]) + expect(getExternalNamesForConnectedWallet(state)).toEqual([]) }) }) @@ -81,7 +101,7 @@ describe('when getting the external names by wallet', () => { }) it('should return an empty array', () => { - expect(getExternalNamesByWallet(state)).toEqual([]) + expect(getExternalNamesForConnectedWallet(state)).toEqual([]) }) }) }) diff --git a/src/modules/ens/selectors.ts b/src/modules/ens/selectors.ts index 336bb84ab..3f048fc41 100644 --- a/src/modules/ens/selectors.ts +++ b/src/modules/ens/selectors.ts @@ -34,11 +34,13 @@ export const getENSByWallet = createSelector isEqual(ens.nftOwnerAddress, address)) ) -export const getExternalNamesByWallet = createSelector, string | undefined, string[]>( - getExternalNames, - getAddress, - (externalNames, address = '') => externalNames[address] ?? [] -) +export const getExternalNamesList = createSelector(getExternalNames, externalNames => { + return Object.values(externalNames) +}) + +export const getExternalNamesForConnectedWallet = createSelector(getExternalNames, getAddress, (externalNames, address = '') => { + return Object.values(externalNames).filter(externalName => isEqual(externalName.nftOwnerAddress, address)) +}) export const getAuthorizationByWallet = createSelector< RootState, diff --git a/src/modules/ens/utils.spec.ts b/src/modules/ens/utils.spec.ts new file mode 100644 index 000000000..969e851c7 --- /dev/null +++ b/src/modules/ens/utils.spec.ts @@ -0,0 +1,25 @@ +import { isExternalName } from './utils' + +describe('when checking if a subdomain is an external subdomain', () => { + let subdomain: string + + describe('when providing a dcl subdomain', () => { + beforeEach(() => { + subdomain = 'name.dcl.eth' + }) + + it('should return false', () => { + expect(isExternalName(subdomain)).toBe(false) + }) + }) + + describe('when providing a non dcl subdomain', () => { + beforeEach(() => { + subdomain = 'name.eth' + }) + + it('should return true', () => { + expect(isExternalName(subdomain)).toBe(true) + }) + }) +}) diff --git a/src/modules/ens/utils.ts b/src/modules/ens/utils.ts index f8fb92a13..3142f45d9 100644 --- a/src/modules/ens/utils.ts +++ b/src/modules/ens/utils.ts @@ -82,3 +82,7 @@ export function isEnoughClaimMana(mana: string) { // we do not yet support the double transaction needed to set the user's allowance to 0 first and then bump it up to wichever number return Number(mana) >= 100 } + +export function isExternalName(subdomain: string) { + return !subdomain.endsWith('dcl.eth') +} diff --git a/src/modules/translation/languages/en.json b/src/modules/translation/languages/en.json index f11902cf4..0c0d1e2a5 100644 --- a/src/modules/translation/languages/en.json +++ b/src/modules/translation/languages/en.json @@ -460,15 +460,19 @@ }, "deploy_world": { "title": "Publish to World", - "description": "Select the NAME that will define the URL:", + "description": "Select the Decentraland NAME or ENS Name that will define the URL", "action": "Publish", "world_label": "World (NAME)", "world_placeholder": "Select a NAME", "back": "Go back to publish scene", "close": "Close publish modal", - "claim_name": "Claim name", + "claim_name": "Claim a new Name", "empty_state_title": "You don't have any available World", "empty_state_description": "Get a free World when you own a NAME.

Each NAME will give you access to one world. You can have as many as you want.", + "name_type": { + "dcl": "NAME", + "ens": "ENS Names" + }, "success": { "title": "Your scene is published!", "subtitle": "You can now explore and play in your World:", diff --git a/src/modules/translation/languages/es.json b/src/modules/translation/languages/es.json index 97ae2eaa0..fe544973b 100644 --- a/src/modules/translation/languages/es.json +++ b/src/modules/translation/languages/es.json @@ -462,7 +462,7 @@ }, "deploy_world": { "title": "Publicar en tu mundo", - "description": "Seleccione el nombre que definirá la URL:", + "description": "Seleccione un nombre Decentraland o un nombre ENS que definirá la URL", "action": "Publicar", "world_label": "Nombre (mundo)", "world_placeholder": "Seleccione un nombre", @@ -471,6 +471,10 @@ "claim_name": "Obtener un nombre", "empty_state_title": "No tiene ningún mundo disponible", "empty_state_description": "Obtenga un mundo gratis cuando tenga un nombre.

Cada nombre le otorga acceso a un mundo. Puedes tener tantos como quieras.", + "name_type": { + "dcl": "Nombre", + "ens": "Nombres de ENS" + }, "success": { "title": "Tu escena ha sido publicada!", "subtitle": "Ahora puedes explorar y jugar en tu Mundo:", diff --git a/src/modules/translation/languages/zh.json b/src/modules/translation/languages/zh.json index 77fad49d5..f4f1d7d63 100644 --- a/src/modules/translation/languages/zh.json +++ b/src/modules/translation/languages/zh.json @@ -456,7 +456,7 @@ }, "deploy_world": { "title": "发布到梦境空间", - "description": "选择将定义URL的名称:", + "description": "选择将定义 URL 的去中心化名称或 ENS 名称", "action": "发布", "world_label": "世界(名称)", "world_placeholder": "选择一个名称", @@ -465,6 +465,10 @@ "claim_name": "索赔名称", "empty_state_title": "您没有任何可用的梦境空间。", "empty_state_description": "当您拥有一个名字时,获得一个免费的梦境空间.

每个名称都会让您进入一个世界。您可以拥有想要的数量。", + "name_type": { + "dcl": "数", + "ens": "ENS 编号" + }, "success": { "title": "您的场景已出版!", "subtitle": "您现在可以在您的世界中探索和玩耍:",