From 00085a5ea21bdca0c0807fe6cc9f99c5a07f198b Mon Sep 17 00:00:00 2001 From: tate Date: Wed, 18 Sep 2024 19:32:28 +1000 Subject: [PATCH] feat(wip): transaction manager v2 --- .eslintrc.json | 5 + .github/workflows/knip.yaml | 2 +- .github/workflows/pages-deployment.yaml | 2 +- .github/workflows/test.yaml | 35 +- package.json | 5 +- pnpm-lock.yaml | 30 +- src/components/Notifications2.tsx | 137 ++++ src/pages/_app.tsx | 17 +- src/transaction-flow/transaction/index.ts | 30 +- src/transaction-flow/types.ts | 15 +- src/transaction/components/DisplayItems.tsx | 0 .../components/DynamicLoadingContext.tsx | 5 + .../components/TransactionDialogManager.tsx | 111 +++ .../components/TransactionLoader.tsx | 28 + .../stage/intro/IntroStageModal.tsx | 89 +++ .../stage/transaction/ActionButton.tsx | 107 +++ .../stage/transaction/BackButton.tsx | 33 + .../components/stage/transaction/LoadBar.tsx | 230 ++++++ .../transaction/TransactionStageModal.tsx | 196 +++++ .../components/stage/transaction/query.ts | 210 +++++ .../transaction/useManagedTransaction.ts | 95 +++ src/transaction/createTransactionListener.ts | 21 + src/transaction/key.ts | 11 + .../transactionAnalyticsListener.ts | 29 + src/transaction/transactionReceiptListener.ts | 61 ++ src/transaction/transactionStore.ts | 352 +++++++++ src/transaction/types.ts | 248 ++++++ src/transaction/usePreparedDataInput.ts | 30 + src/transaction/user/input.tsx | 40 + .../AdvancedEditor/AdvancedEditor-flow.tsx | 37 +- .../AdvancedEditor/AdvancedEditor.test.tsx | 0 .../user/input/CreateSubname-flow.tsx | 113 +++ .../DeleteEmancipatedSubnameWarning-flow.tsx | 86 ++ .../DeleteSubnameNotParentWarning-flow.tsx | 100 +++ .../input/EditResolver/EditResolver-flow.tsx | 77 ++ .../user/input/EditRoles/EditRoles-flow.tsx | 139 ++++ .../user/input/EditRoles/EditRoles.test.tsx | 243 ++++++ .../input/EditRoles/hooks/useSimpleSearch.ts | 112 +++ .../views/EditRoleView/EditRoleView.tsx | 116 +++ .../EditRoleView/views/EditRoleIntroView.tsx | 106 +++ .../views/EditRoleResultsView.tsx | 45 ++ .../EditRoles/views/MainView/MainView.tsx | 68 ++ .../NoneSetAvatarWithIdentifier.tsx | 55 ++ .../views/MainView/components/RoleCard.tsx | 134 ++++ .../ExtendNames/ExtendNames-flow.test.tsx | 182 +++++ .../input/ExtendNames/ExtendNames-flow.tsx | 398 ++++++++++ .../ProfileEditor/ProfileEditor-flow.tsx | 426 ++++++++++ .../ProfileEditor/ProfileEditor.test.tsx | 734 ++++++++++++++++++ .../ProfileEditor/ResolverWarningOverlay.tsx | 275 +++++++ .../ProfileEditor/WrappedAvatarButton.tsx | 26 + .../components/CenteredTypography.tsx | 9 + .../components/ContentContainer.tsx | 9 + .../components/DetailedSwitch.tsx | 45 ++ .../ProfileEditor/components/ProfileBlurb.tsx | 78 ++ .../ProfileEditor/components/SkipButton.tsx | 58 ++ .../views/InvalidResolverView.tsx | 48 ++ .../views/MigrateProfileSelectorView.tsx.tsx | 144 ++++ .../views/MigrateProfileWarningView.tsx | 43 + .../views/MigrateRegistryView.tsx | 47 ++ .../ProfileEditor/views/NoResolverView.tsx | 48 ++ .../ProfileEditor/views/ResetProfileView.tsx | 42 + .../views/ResolverNotNameWrapperAwareView.tsx | 72 ++ .../views/ResolverOutOfDateView.tsx | 56 ++ .../views/ResolverOutOfSyncView.tsx | 56 ++ .../views/TransferOrResetProfileView.tsx | 59 ++ .../UpdateResolverOrResetProfileView.tsx | 60 ++ .../ResetPrimaryName-flow.tsx | 59 ++ .../RevokePermissions-flow.tsx | 408 ++++++++++ .../RevokePermissions.test.tsx | 713 +++++++++++++++++ .../components/CenterAlignedTypography.tsx | 9 + .../components/ControlledNextButton.tsx | 168 ++++ .../views/GrantExtendExpiryView.tsx | 29 + .../NameConfirmationWarningView.test.tsx | 46 ++ .../views/NameConfirmationWarningView.tsx | 50 ++ .../views/ParentRevokePermissionsView.tsx | 61 ++ .../views/RevokeChangeFusesView.tsx | 34 + .../views/RevokeChangeFusesWarningView.tsx | 28 + .../RevokePermissions/views/RevokePCCView.tsx | 51 ++ .../views/RevokePermissionsView.tsx | 61 ++ .../views/RevokeUnwrapView.tsx | 39 + .../views/RevokeWarningView.tsx | 50 ++ .../RevokePermissions/views/SetExpiryView.tsx | 202 +++++ .../SelectPrimaryName-flow.tsx | 375 +++++++++ .../SelectPrimaryName.test.tsx | 330 ++++++++ .../TaggedNameItemWithFuseCheck.test.tsx | 147 ++++ .../TaggedNameItemWithFuseCheck.tsx | 21 + .../user/input/SendName/SendName-flow.tsx | 153 ++++ .../user/input/SendName/SendName.test.tsx | 117 +++ .../user/input/SendName/utils/checkCanSend.ts | 58 ++ .../utils/getSendNameTransactions.test.ts | 253 ++++++ .../SendName/utils/getSendNameTransactions.ts | 72 ++ .../input/SendName/views/CannotSendView.tsx | 31 + .../input/SendName/views/ConfirmationView.tsx | 102 +++ .../SendName/views/SearchView/SearchView.tsx | 92 +++ .../components/SearchViewResult.tsx | 97 +++ .../SearchView/views/SearchViewErrorView.tsx | 46 ++ .../SearchView/views/SearchViewIntroView.tsx | 42 + .../views/SearchViewLoadingView.tsx | 22 + .../views/SearchViewNoResultsView.tsx | 44 ++ .../views/SearchViewResultsView.tsx | 41 + .../views/SummaryView/SummaryView.tsx | 94 +++ .../SummaryView/components/SummarySection.tsx | 47 ++ .../input/SyncManager/SyncManager-flow.tsx | 126 +++ .../SyncManager/utils/checkCanSyncManager.ts | 53 ++ .../input/SyncManager/views/ErrorView.tsx | 27 + .../user/input/SyncManager/views/MainView.tsx | 41 + .../UnknownLabels/UnknownLabels-flow.tsx | 96 +++ .../UnknownLabels/UnknownLabels.test.tsx | 294 +++++++ .../UnknownLabels/views/UnknownLabelsForm.tsx | 171 ++++ .../VerifyProfile/VerifyProfile-flow.tsx | 78 ++ .../components/VerificationOptionButton.tsx | 72 ++ .../VerifyProfile/utils/createDentityUrl.ts | 25 + .../input/VerifyProfile/views/DentityView.tsx | 127 +++ .../views/VerificationOptionsList.tsx | 91 +++ src/transaction/user/transaction.ts | 101 +++ .../user/transaction/approveDnsRegistrar.ts | 66 ++ .../user/transaction/approveNameWrapper.ts | 70 ++ src/transaction/user/transaction/burnFuses.ts | 47 ++ .../user/transaction/changePermissions.ts | 114 +++ .../user/transaction/claimDnsName.ts | 30 + .../user/transaction/commitName.ts | 33 + .../user/transaction/createSubname.ts | 43 + .../user/transaction/deleteSubname.ts | 43 + .../user/transaction/extendNames.ts | 68 ++ .../user/transaction/importDnsName.ts | 30 + .../user/transaction/migrateProfile.ts | 69 ++ .../transaction/migrateProfileWithReset.ts | 73 ++ .../user/transaction/registerName.test.ts | 27 + .../user/transaction/registerName.ts | 50 ++ .../transaction/removeVerificationRecord.ts | 52 ++ .../user/transaction/resetPrimaryName.ts | 30 + .../user/transaction/resetProfile.ts | 39 + .../transaction/resetProfileWithRecords.ts | 66 ++ .../user/transaction/setPrimaryName.ts | 37 + .../user/transaction/syncManager.ts | 41 + .../user/transaction/testSendName.ts | 39 + .../user/transaction/transferController.ts | 42 + .../user/transaction/transferName.ts | 62 ++ .../user/transaction/transferSubname.ts | 40 + .../user/transaction/unwrapName.test.ts | 81 ++ .../user/transaction/unwrapName.ts | 42 + .../user/transaction/updateEthAddress.ts | 61 ++ .../user/transaction/updateProfile.ts | 71 ++ .../user/transaction/updateProfileRecords.ts | 98 +++ .../user/transaction/updateResolver.ts | 45 ++ .../transaction/updateVerificationRecord.ts | 52 ++ ...akeTransferNameOrSubnameTransactionItem.ts | 64 ++ src/transaction/user/transaction/wrapName.ts | 37 + src/types/index.ts | 1 - src/utils/analytics.ts | 14 +- 150 files changed, 13729 insertions(+), 62 deletions(-) create mode 100644 src/components/Notifications2.tsx create mode 100644 src/transaction/components/DisplayItems.tsx create mode 100644 src/transaction/components/DynamicLoadingContext.tsx create mode 100644 src/transaction/components/TransactionDialogManager.tsx create mode 100644 src/transaction/components/TransactionLoader.tsx create mode 100644 src/transaction/components/stage/intro/IntroStageModal.tsx create mode 100644 src/transaction/components/stage/transaction/ActionButton.tsx create mode 100644 src/transaction/components/stage/transaction/BackButton.tsx create mode 100644 src/transaction/components/stage/transaction/LoadBar.tsx create mode 100644 src/transaction/components/stage/transaction/TransactionStageModal.tsx create mode 100644 src/transaction/components/stage/transaction/query.ts create mode 100644 src/transaction/components/stage/transaction/useManagedTransaction.ts create mode 100644 src/transaction/createTransactionListener.ts create mode 100644 src/transaction/key.ts create mode 100644 src/transaction/transactionAnalyticsListener.ts create mode 100644 src/transaction/transactionReceiptListener.ts create mode 100644 src/transaction/transactionStore.ts create mode 100644 src/transaction/types.ts create mode 100644 src/transaction/usePreparedDataInput.ts create mode 100644 src/transaction/user/input.tsx rename src/{transaction-flow => transaction/user}/input/AdvancedEditor/AdvancedEditor-flow.tsx (78%) rename src/{transaction-flow => transaction/user}/input/AdvancedEditor/AdvancedEditor.test.tsx (100%) create mode 100644 src/transaction/user/input/CreateSubname-flow.tsx create mode 100644 src/transaction/user/input/DeleteEmancipatedSubnameWarning/DeleteEmancipatedSubnameWarning-flow.tsx create mode 100644 src/transaction/user/input/DeleteSubnameNotParentWarning/DeleteSubnameNotParentWarning-flow.tsx create mode 100644 src/transaction/user/input/EditResolver/EditResolver-flow.tsx create mode 100644 src/transaction/user/input/EditRoles/EditRoles-flow.tsx create mode 100644 src/transaction/user/input/EditRoles/EditRoles.test.tsx create mode 100644 src/transaction/user/input/EditRoles/hooks/useSimpleSearch.ts create mode 100644 src/transaction/user/input/EditRoles/views/EditRoleView/EditRoleView.tsx create mode 100644 src/transaction/user/input/EditRoles/views/EditRoleView/views/EditRoleIntroView.tsx create mode 100644 src/transaction/user/input/EditRoles/views/EditRoleView/views/EditRoleResultsView.tsx create mode 100644 src/transaction/user/input/EditRoles/views/MainView/MainView.tsx create mode 100644 src/transaction/user/input/EditRoles/views/MainView/components/NoneSetAvatarWithIdentifier.tsx create mode 100644 src/transaction/user/input/EditRoles/views/MainView/components/RoleCard.tsx create mode 100644 src/transaction/user/input/ExtendNames/ExtendNames-flow.test.tsx create mode 100644 src/transaction/user/input/ExtendNames/ExtendNames-flow.tsx create mode 100644 src/transaction/user/input/ProfileEditor/ProfileEditor-flow.tsx create mode 100644 src/transaction/user/input/ProfileEditor/ProfileEditor.test.tsx create mode 100644 src/transaction/user/input/ProfileEditor/ResolverWarningOverlay.tsx create mode 100644 src/transaction/user/input/ProfileEditor/WrappedAvatarButton.tsx create mode 100644 src/transaction/user/input/ProfileEditor/components/CenteredTypography.tsx create mode 100644 src/transaction/user/input/ProfileEditor/components/ContentContainer.tsx create mode 100644 src/transaction/user/input/ProfileEditor/components/DetailedSwitch.tsx create mode 100644 src/transaction/user/input/ProfileEditor/components/ProfileBlurb.tsx create mode 100644 src/transaction/user/input/ProfileEditor/components/SkipButton.tsx create mode 100644 src/transaction/user/input/ProfileEditor/views/InvalidResolverView.tsx create mode 100644 src/transaction/user/input/ProfileEditor/views/MigrateProfileSelectorView.tsx.tsx create mode 100644 src/transaction/user/input/ProfileEditor/views/MigrateProfileWarningView.tsx create mode 100644 src/transaction/user/input/ProfileEditor/views/MigrateRegistryView.tsx create mode 100644 src/transaction/user/input/ProfileEditor/views/NoResolverView.tsx create mode 100644 src/transaction/user/input/ProfileEditor/views/ResetProfileView.tsx create mode 100644 src/transaction/user/input/ProfileEditor/views/ResolverNotNameWrapperAwareView.tsx create mode 100644 src/transaction/user/input/ProfileEditor/views/ResolverOutOfDateView.tsx create mode 100644 src/transaction/user/input/ProfileEditor/views/ResolverOutOfSyncView.tsx create mode 100644 src/transaction/user/input/ProfileEditor/views/TransferOrResetProfileView.tsx create mode 100644 src/transaction/user/input/ProfileEditor/views/UpdateResolverOrResetProfileView.tsx create mode 100644 src/transaction/user/input/ResetPrimaryName/ResetPrimaryName-flow.tsx create mode 100644 src/transaction/user/input/RevokePermissions/RevokePermissions-flow.tsx create mode 100644 src/transaction/user/input/RevokePermissions/RevokePermissions.test.tsx create mode 100644 src/transaction/user/input/RevokePermissions/components/CenterAlignedTypography.tsx create mode 100644 src/transaction/user/input/RevokePermissions/components/ControlledNextButton.tsx create mode 100644 src/transaction/user/input/RevokePermissions/views/GrantExtendExpiryView.tsx create mode 100644 src/transaction/user/input/RevokePermissions/views/NameConfirmationWarningView.test.tsx create mode 100644 src/transaction/user/input/RevokePermissions/views/NameConfirmationWarningView.tsx create mode 100644 src/transaction/user/input/RevokePermissions/views/ParentRevokePermissionsView.tsx create mode 100644 src/transaction/user/input/RevokePermissions/views/RevokeChangeFusesView.tsx create mode 100644 src/transaction/user/input/RevokePermissions/views/RevokeChangeFusesWarningView.tsx create mode 100644 src/transaction/user/input/RevokePermissions/views/RevokePCCView.tsx create mode 100644 src/transaction/user/input/RevokePermissions/views/RevokePermissionsView.tsx create mode 100644 src/transaction/user/input/RevokePermissions/views/RevokeUnwrapView.tsx create mode 100644 src/transaction/user/input/RevokePermissions/views/RevokeWarningView.tsx create mode 100644 src/transaction/user/input/RevokePermissions/views/SetExpiryView.tsx create mode 100644 src/transaction/user/input/SelectPrimaryName/SelectPrimaryName-flow.tsx create mode 100644 src/transaction/user/input/SelectPrimaryName/SelectPrimaryName.test.tsx create mode 100644 src/transaction/user/input/SelectPrimaryName/components/TaggedNameItemWithFuseCheck.test.tsx create mode 100644 src/transaction/user/input/SelectPrimaryName/components/TaggedNameItemWithFuseCheck.tsx create mode 100644 src/transaction/user/input/SendName/SendName-flow.tsx create mode 100644 src/transaction/user/input/SendName/SendName.test.tsx create mode 100644 src/transaction/user/input/SendName/utils/checkCanSend.ts create mode 100644 src/transaction/user/input/SendName/utils/getSendNameTransactions.test.ts create mode 100644 src/transaction/user/input/SendName/utils/getSendNameTransactions.ts create mode 100644 src/transaction/user/input/SendName/views/CannotSendView.tsx create mode 100644 src/transaction/user/input/SendName/views/ConfirmationView.tsx create mode 100644 src/transaction/user/input/SendName/views/SearchView/SearchView.tsx create mode 100644 src/transaction/user/input/SendName/views/SearchView/components/SearchViewResult.tsx create mode 100644 src/transaction/user/input/SendName/views/SearchView/views/SearchViewErrorView.tsx create mode 100644 src/transaction/user/input/SendName/views/SearchView/views/SearchViewIntroView.tsx create mode 100644 src/transaction/user/input/SendName/views/SearchView/views/SearchViewLoadingView.tsx create mode 100644 src/transaction/user/input/SendName/views/SearchView/views/SearchViewNoResultsView.tsx create mode 100644 src/transaction/user/input/SendName/views/SearchView/views/SearchViewResultsView.tsx create mode 100644 src/transaction/user/input/SendName/views/SummaryView/SummaryView.tsx create mode 100644 src/transaction/user/input/SendName/views/SummaryView/components/SummarySection.tsx create mode 100644 src/transaction/user/input/SyncManager/SyncManager-flow.tsx create mode 100644 src/transaction/user/input/SyncManager/utils/checkCanSyncManager.ts create mode 100644 src/transaction/user/input/SyncManager/views/ErrorView.tsx create mode 100644 src/transaction/user/input/SyncManager/views/MainView.tsx create mode 100644 src/transaction/user/input/UnknownLabels/UnknownLabels-flow.tsx create mode 100644 src/transaction/user/input/UnknownLabels/UnknownLabels.test.tsx create mode 100644 src/transaction/user/input/UnknownLabels/views/UnknownLabelsForm.tsx create mode 100644 src/transaction/user/input/VerifyProfile/VerifyProfile-flow.tsx create mode 100644 src/transaction/user/input/VerifyProfile/components/VerificationOptionButton.tsx create mode 100644 src/transaction/user/input/VerifyProfile/utils/createDentityUrl.ts create mode 100644 src/transaction/user/input/VerifyProfile/views/DentityView.tsx create mode 100644 src/transaction/user/input/VerifyProfile/views/VerificationOptionsList.tsx create mode 100644 src/transaction/user/transaction.ts create mode 100644 src/transaction/user/transaction/approveDnsRegistrar.ts create mode 100644 src/transaction/user/transaction/approveNameWrapper.ts create mode 100644 src/transaction/user/transaction/burnFuses.ts create mode 100644 src/transaction/user/transaction/changePermissions.ts create mode 100644 src/transaction/user/transaction/claimDnsName.ts create mode 100644 src/transaction/user/transaction/commitName.ts create mode 100644 src/transaction/user/transaction/createSubname.ts create mode 100644 src/transaction/user/transaction/deleteSubname.ts create mode 100644 src/transaction/user/transaction/extendNames.ts create mode 100644 src/transaction/user/transaction/importDnsName.ts create mode 100644 src/transaction/user/transaction/migrateProfile.ts create mode 100644 src/transaction/user/transaction/migrateProfileWithReset.ts create mode 100644 src/transaction/user/transaction/registerName.test.ts create mode 100644 src/transaction/user/transaction/registerName.ts create mode 100644 src/transaction/user/transaction/removeVerificationRecord.ts create mode 100644 src/transaction/user/transaction/resetPrimaryName.ts create mode 100644 src/transaction/user/transaction/resetProfile.ts create mode 100644 src/transaction/user/transaction/resetProfileWithRecords.ts create mode 100644 src/transaction/user/transaction/setPrimaryName.ts create mode 100644 src/transaction/user/transaction/syncManager.ts create mode 100644 src/transaction/user/transaction/testSendName.ts create mode 100644 src/transaction/user/transaction/transferController.ts create mode 100644 src/transaction/user/transaction/transferName.ts create mode 100644 src/transaction/user/transaction/transferSubname.ts create mode 100644 src/transaction/user/transaction/unwrapName.test.ts create mode 100644 src/transaction/user/transaction/unwrapName.ts create mode 100644 src/transaction/user/transaction/updateEthAddress.ts create mode 100644 src/transaction/user/transaction/updateProfile.ts create mode 100644 src/transaction/user/transaction/updateProfileRecords.ts create mode 100644 src/transaction/user/transaction/updateResolver.ts create mode 100644 src/transaction/user/transaction/updateVerificationRecord.ts create mode 100644 src/transaction/user/transaction/utils/makeTransferNameOrSubnameTransactionItem.ts create mode 100644 src/transaction/user/transaction/wrapName.ts diff --git a/.eslintrc.json b/.eslintrc.json index 03cf4589d..62c4f30a8 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -85,8 +85,13 @@ { "selector": "typeLike", "format": ["PascalCase"] + }, + { + "selector": "typeParameter", + "format": ["PascalCase", "camelCase"] } ], + "@typescript-eslint/no-redeclare": "off", "radix": "off", "consistent-return": "off", "jsx-a11y/anchor-is-valid": "off", diff --git a/.github/workflows/knip.yaml b/.github/workflows/knip.yaml index 50fe208ac..4d2aa9e18 100644 --- a/.github/workflows/knip.yaml +++ b/.github/workflows/knip.yaml @@ -1,6 +1,6 @@ name: Knip -on: [push] +on: [] jobs: knip: diff --git a/.github/workflows/pages-deployment.yaml b/.github/workflows/pages-deployment.yaml index b96c3d2e4..c72757a14 100644 --- a/.github/workflows/pages-deployment.yaml +++ b/.github/workflows/pages-deployment.yaml @@ -4,7 +4,7 @@ env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} NEXT_PUBLIC_INTERCOM_ID: re9q5yti -on: [push] +on: [] jobs: yalc_check: runs-on: ubuntu-latest diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index ac27c8c28..fdc0f36bf 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,6 +1,6 @@ name: Test -on: [push] +on: [] env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -97,7 +97,38 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29] + shard: + [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + ] steps: - uses: actions/checkout@v3 diff --git a/package.json b/package.json index 4067cf45b..927f44db8 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "i18next-browser-languagedetector": "^6.1.5", "i18next-http-backend": "^1.4.1", "idb-keyval": "^6.2.1", - "immer": "^9.0.15", + "immer": "^9.0.21", "intl-segmenter-polyfill": "^0.4.4", "iso-639-1": "^2.1.15", "lodash": "^4.17.21", @@ -97,7 +97,8 @@ "ts-pattern": "^4.2.2", "use-immer": "^0.7.0", "viem": "2.19.4", - "wagmi": "2.12.4" + "wagmi": "2.12.4", + "zustand": "5.0.0-rc.2" }, "peerDependencies": { "react": "*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 820b63458..9d6baef39 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,7 +108,7 @@ importers: specifier: ^6.2.1 version: 6.2.1 immer: - specifier: ^9.0.15 + specifier: ^9.0.21 version: 9.0.21 intl-segmenter-polyfill: specifier: ^0.4.4 @@ -173,6 +173,9 @@ importers: wagmi: specifier: 2.12.4 version: 2.12.4(@tanstack/query-core@5.22.2)(@tanstack/react-query@5.22.2(react@18.3.1))(@types/react@18.2.21)(bufferutil@4.0.8)(encoding@0.1.13)(immer@9.0.21)(react-dom@18.3.1(react@18.3.1))(react-native@0.74.1(@babel/core@7.24.6)(@babel/preset-env@7.24.6(@babel/core@7.24.6))(@types/react@18.2.21)(bufferutil@4.0.8)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(rollup@2.78.0)(typescript@5.4.5)(utf-8-validate@5.0.10)(viem@2.19.4(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8) + zustand: + specifier: 5.0.0-rc.2 + version: 5.0.0-rc.2(@types/react@18.2.21)(immer@9.0.21)(react@18.3.1)(use-sync-external-store@1.2.0(react@18.3.1)) devDependencies: '@adraffy/ens-normalize': specifier: ^1.10.1 @@ -10426,6 +10429,24 @@ packages: react: optional: true + zustand@5.0.0-rc.2: + resolution: {integrity: sha512-o2Nwuvnk8vQBX7CcHL8WfFkZNJdxB/VKeWw0tNglw8p4cypsZ3tRT7rTRTDNeUPFS0qaMBRSKe+fVwL5xpcE3A==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: ^18.2.0 + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@adobe/css-tools@4.3.3': {} @@ -22967,3 +22988,10 @@ snapshots: '@types/react': 18.2.21 immer: 9.0.21 react: 18.3.1 + + zustand@5.0.0-rc.2(@types/react@18.2.21)(immer@9.0.21)(react@18.3.1)(use-sync-external-store@1.2.0(react@18.3.1)): + optionalDependencies: + '@types/react': 18.2.21 + immer: 9.0.21 + react: 18.3.1 + use-sync-external-store: 1.2.0(react@18.3.1) diff --git a/src/components/Notifications2.tsx b/src/components/Notifications2.tsx new file mode 100644 index 000000000..870c7da9a --- /dev/null +++ b/src/components/Notifications2.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { usePreviousDistinct } from 'react-use' +import styled, { css } from 'styled-components' + +import { Button, Toast } from '@ensdomains/thorin' + +import { useTransactionStore } from '@app/transaction-flow/new/TransactionStore' +import type { LastTransactionChange } from '@app/transaction/types' +import { useBreakpoint } from '@app/utils/BreakpointProvider' +import { getChainName } from '@app/utils/getChainName' +import { wagmiConfig } from '@app/utils/query/wagmi' +import { makeEtherscanLink } from '@app/utils/utils' + +const ButtonContainer = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: row; + align-items: center; + justify-content: stretch; + gap: ${theme.space['2']}; + `, +) + +type SuccessOrRevertedTransaction = Extract< + LastTransactionChange, + { status: 'success' | 'reverted' } +> + +const Notification = ({ + transaction, + onClose, + open, +}: { + transaction: SuccessOrRevertedTransaction | null + onClose: () => void + open: boolean +}) => { + const { t } = useTranslation() + const breakpoints = useBreakpoint() + const getResumable = useTransactionStore((s) => s.flow.getResumable) + const resumeFlow = useTransactionStore((s) => s.flow.resume) + + const resumable = transaction && getResumable(transaction.flowKey) + const chainName = transaction && getChainName(wagmiConfig, { chainId: transaction.chainId }) + + const button = (() => { + if (!transaction) return null + if (!resumable) + return ( + + + + ) + + return ( + + + + + + + ) + })() + + const toastProps = transaction + ? { + title: t(`transaction.status.${transaction.status}.notifyTitle`), + description: t(`transaction.status.${transaction.status}.notifyMessage`, { + action: t(`transaction.description.${transaction.action}`), + }), + children: button, + } + : { + title: '', + } + + return ( + + ) +} + +export const Notifications = () => { + const [open, setOpen] = useState(false) + const [transactionQueue, setTransactionQueue] = useState([]) + const lastTransaction = useTransactionStore((s) => { + const tx = s.transaction.getLastTransactionChange() + if (!tx) return null + if (tx.status !== 'success' && tx.status !== 'reverted') return null + return tx + }) + + const prevLastTransaction = usePreviousDistinct(lastTransaction) + + if (lastTransaction && prevLastTransaction !== lastTransaction) { + setTransactionQueue((q) => [...q, lastTransaction]) + } + + const currentTransaction = transactionQueue[0] ?? null + + return ( + { + setOpen(false) + setTimeout( + () => setTransactionQueue((prev) => [...prev.filter((x) => x !== currentTransaction)]), + 300, + ) + }} + open={open} + transaction={currentTransaction} + /> + ) +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 3c5d9dfb9..4b27b1d29 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -11,11 +11,11 @@ import { createGlobalStyle, keyframes, ThemeProvider } from 'styled-components' import { ThorinGlobalStyles, lightTheme as thorinLightTheme } from '@ensdomains/thorin' -import { Notifications } from '@app/components/Notifications' +import { Notifications } from '@app/components/Notifications2' import { TestnetWarning } from '@app/components/TestnetWarning' import { TransactionStoreProvider } from '@app/hooks/transactions/TransactionStoreContext' import { Basic } from '@app/layouts/Basic' -import { TransactionFlowProvider } from '@app/transaction-flow/TransactionFlowProvider' +import { TransactionDialogManager } from '@app/transaction/components/TransactionDialogManager' import { setupAnalytics } from '@app/utils/analytics' import { BreakpointProvider } from '@app/utils/BreakpointProvider' import { QueryProviders } from '@app/utils/query/providers' @@ -152,13 +152,12 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { - - - - - {getLayout()} - - + + + + + {getLayout()} + diff --git a/src/transaction-flow/transaction/index.ts b/src/transaction-flow/transaction/index.ts index 55211a5e5..8f63e348e 100644 --- a/src/transaction-flow/transaction/index.ts +++ b/src/transaction-flow/transaction/index.ts @@ -65,37 +65,37 @@ export const transactions = { export type Transaction = typeof transactions export type TransactionName = keyof Transaction -export type TransactionParameters = Parameters< - Transaction[T]['transaction'] +export type TransactionParameters = Parameters< + Transaction[name]['transaction'] >[0] -export type TransactionData = TransactionParameters['data'] +export type TransactionData = TransactionParameters['data'] -export type TransactionReturnType = ReturnType< - Transaction[T]['transaction'] +export type TransactionReturnType = ReturnType< + Transaction[name]['transaction'] > -export const createTransactionItem = ( - name: T, - data: TransactionData, +export const createTransactionItem = ( + name: name, + data: TransactionData, ) => ({ name, data, }) -export const createTransactionRequest = ({ +export const createTransactionRequest = ({ name, ...rest -}: { name: TName } & TransactionParameters): TransactionReturnType => { +}: { name: name } & TransactionParameters): TransactionReturnType => { // i think this has to be any :( - return transactions[name].transaction({ ...rest } as any) as TransactionReturnType + return transactions[name].transaction({ ...rest } as any) as TransactionReturnType } -export type TransactionItem = { - name: TName - data: TransactionData +export type TransactionItem = { + name: name + data: TransactionData } export type TransactionItemUnion = { - [TName in TransactionName]: TransactionItem + [name in TransactionName]: TransactionItem }[TransactionName] diff --git a/src/transaction-flow/types.ts b/src/transaction-flow/types.ts index ceed4874a..1bc0adabd 100644 --- a/src/transaction-flow/types.ts +++ b/src/transaction-flow/types.ts @@ -7,17 +7,20 @@ import { Button, Dialog, Helper } from '@ensdomains/thorin' import { Transaction } from '@app/hooks/transactions/transactionStore' import { MinedData, TransactionDisplayItem } from '@app/types' -import type { DataInputComponent } from './input' +import type { DataInputComponent, DataInputName } from './input' import type { IntroComponentName } from './intro' -import type { TransactionData, TransactionItem, TransactionName } from './transaction' +import type { TransactionData, TransactionName } from './transaction' export type TransactionFlowStage = 'input' | 'intro' | 'transaction' export type TransactionStage = 'confirm' | 'sent' | 'complete' | 'failed' -type GenericDataInput = { - name: keyof DataInputComponent - data: any +export type GenericDataInput< + name extends DataInputName = DataInputName, + data extends ComponentProps = ComponentProps, +> = { + name: name + data: data } export type GenericTransaction< @@ -161,7 +164,7 @@ export type TransactionDialogProps = ComponentProps & { export type TransactionDialogPassthrough = { dispatch: Dispatch onDismiss: () => void - transactions?: readonly TransactionItem[] | TransactionItem[] + transactionIds?: string[] } export type ManagedDialogProps = { diff --git a/src/transaction/components/DisplayItems.tsx b/src/transaction/components/DisplayItems.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/transaction/components/DynamicLoadingContext.tsx b/src/transaction/components/DynamicLoadingContext.tsx new file mode 100644 index 000000000..79fc35bc2 --- /dev/null +++ b/src/transaction/components/DynamicLoadingContext.tsx @@ -0,0 +1,5 @@ +import { createContext, Dispatch, SetStateAction } from 'react' + +const DynamicLoadingContext = createContext>>(() => {}) + +export default DynamicLoadingContext diff --git a/src/transaction/components/TransactionDialogManager.tsx b/src/transaction/components/TransactionDialogManager.tsx new file mode 100644 index 000000000..e5f573d10 --- /dev/null +++ b/src/transaction/components/TransactionDialogManager.tsx @@ -0,0 +1,111 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { type ComponentType } from 'react' +import { useTranslation } from 'react-i18next' + +import { Dialog } from '@ensdomains/thorin' + +import { queryClientWithRefetch } from '@app/utils/query/reactQuery' + +import { useTransactionStore } from '../transactionStore' +import type { GenericDataInput, StoredFlow, StoredTransaction, TransactionIntro } from '../types' +import { DataInputComponents, type DataInputName } from '../user/input' +import { userTransactions } from '../user/transaction' +import { IntroStageModal } from './stage/intro/IntroStageModal' +import { TransactionStageModal } from './stage/transaction/TransactionStageModal' + +export type TransactionDialogPassthrough = { + onDismiss: () => void + transactions?: StoredTransaction[] +} + +const InputContent = ({ + flow, +}: { + flow: StoredFlow & { input: GenericDataInput } +}) => { + const transactions = useTransactionStore((s) => s.flow.current.getTransactions()) + const onDismiss = useTransactionStore((s) => s.flow.current.stop) + const Component = DataInputComponents[flow.input.name] as ComponentType< + { data: any } & TransactionDialogPassthrough + > + return ( + + + + ) +} + +const IntroContent = ({ flow }: { flow: StoredFlow & { intro: TransactionIntro } }) => { + const transactions = useTransactionStore((s) => s.flow.current.getTransactions()) + const setFlowStage = useTransactionStore((s) => s.flow.current.setStage) + const onDismiss = useTransactionStore((s) => s.flow.current.stop) + + const currentTransaction = transactions[flow.currentTransaction] + const currentStep = + currentTransaction.status === 'success' ? flow.currentTransaction + 1 : flow.currentTransaction + const stepStatus = + currentTransaction.status === 'pending' || currentTransaction.status === 'reverted' + ? 'inProgress' + : 'notStarted' + + return ( + setFlowStage({ stage: 'transaction' })} + {...{ + ...flow.intro, + onDismiss, + transactions, + }} + /> + ) +} + +const TransactionContent = ({ flow }: { flow: StoredFlow }) => { + const { t } = useTranslation() + const transactions = useTransactionStore((s) => s.flow.current.getTransactions()) + const onDismiss = useTransactionStore((s) => s.flow.current.stop) + const currentTransaction = transactions[flow.currentTransaction] + const userTransaction = userTransactions[currentTransaction.name] + + const displayItems = userTransaction.displayItems(currentTransaction.data as never, t) + + return ( + + ) +} + +const Content = ({ flow }: { flow: StoredFlow | null }) => { + if (!flow) return null + + if (flow.input && flow.currentStage === 'input') + return }} /> + if (flow.intro && flow.currentStage === 'intro') + return + return +} + +export const TransactionDialogManager = () => { + const { flow, isPrevious } = useTransactionStore((s) => s.flow.current.selectedOrPrevious()) + const stopFlow = useTransactionStore((s) => s.flow.current.stop) + const attemptDismiss = useTransactionStore((s) => s.flow.current.attemptDismiss) + + return ( + + + + ) +} diff --git a/src/transaction/components/TransactionLoader.tsx b/src/transaction/components/TransactionLoader.tsx new file mode 100644 index 000000000..84e619933 --- /dev/null +++ b/src/transaction/components/TransactionLoader.tsx @@ -0,0 +1,28 @@ +import styled, { css } from 'styled-components' + +import { mq, Spinner } from '@ensdomains/thorin' + +const Container = styled.div( + ({ theme }) => css` + display: flex; + justify-content: center; + align-items: center; + padding: ${theme.space[4]}; + width: 100%; + + ${mq.sm.min(css` + width: calc(80vw - 2 * ${theme.space['6']}); + max-width: ${theme.space['128']}; + `)} + `, +) + +const TransactionLoader = ({ isComponentLoader }: { isComponentLoader?: boolean }) => { + return ( + + + + ) +} + +export default TransactionLoader diff --git a/src/transaction/components/stage/intro/IntroStageModal.tsx b/src/transaction/components/stage/intro/IntroStageModal.tsx new file mode 100644 index 000000000..5a9ec0b25 --- /dev/null +++ b/src/transaction/components/stage/intro/IntroStageModal.tsx @@ -0,0 +1,89 @@ +import { useTranslation } from 'react-i18next' + +import { Button, Dialog } from '@ensdomains/thorin' + +import { intros } from '@app/transaction-flow/intro' +import { TransactionIntro } from '@app/transaction-flow/types' +import { TransactionDisplayItemSingle } from '@app/types' + +import { DisplayItems } from '../../DisplayItems' + +export const IntroStageModal = ({ + transactions, + onSuccess, + currentStep, + onDismiss, + content, + title, + trailingLabel, + stepStatus, +}: TransactionIntro & { + transactions: + | { + name: string + }[] + | readonly { name: string }[] + stepStatus: 'inProgress' | 'notStarted' | 'completed' + currentStep: number + onDismiss: () => void + onSuccess: () => void +}) => { + const { t } = useTranslation() + + const tLabel = + currentStep > 0 + ? t('transaction.dialog.intro.trailingButtonResume') + : t('transaction.dialog.intro.trailingButton') + + const LeadingButton = ( + + ) + + const TrailingButton = ( + + ) + + const txCount = transactions.length + + const Content = intros[content.name] + + return ( + <> + + + + {txCount > 1 && ( + + ({ + fade: currentStep > index, + shrink: true, + label: t('transaction.dialog.intro.step', { step: index + 1 }), + value: t(`transaction.description.${name}`), + useRawLabel: true, + }) as TransactionDisplayItemSingle, + ) || [] + } + /> + )} + + 1 ? txCount : undefined} + stepStatus={stepStatus} + trailing={TrailingButton} + leading={LeadingButton} + /> + + ) +} diff --git a/src/transaction/components/stage/transaction/ActionButton.tsx b/src/transaction/components/stage/transaction/ActionButton.tsx new file mode 100644 index 000000000..9003d0c66 --- /dev/null +++ b/src/transaction/components/stage/transaction/ActionButton.tsx @@ -0,0 +1,107 @@ +import { useTranslation } from 'react-i18next' + +import { Button, Spinner } from '@ensdomains/thorin' + +import type { StoredTransactionStatus } from '@app/transaction/types' + +type TransactionModalActionButtonProps = { + status: StoredTransactionStatus + currentTransactionIndex: number + transactionCount: number + onDismiss: () => void + sendTransaction: () => void + incrementTransaction: () => void + canEnableTransactionRequest: boolean + requestLoading: boolean + requestExists: boolean + transactionLoading: boolean + isTransactionRequestCachedData: boolean + requestErrorExists: boolean +} + +export const TransactionModalActionButton = ({ + status, + currentTransactionIndex, + transactionCount, + onDismiss, + sendTransaction, + incrementTransaction, + canEnableTransactionRequest, + requestLoading, + requestExists, + transactionLoading, + isTransactionRequestCachedData, + requestErrorExists, +}: TransactionModalActionButtonProps) => { + const { t } = useTranslation() + + if (status === 'success') { + const final = currentTransactionIndex + 1 === transactionCount + + if (final) { + return ( + + ) + } + return ( + + ) + } + if (status === 'reverted') { + return ( + + ) + } + if (status === 'pending') { + return ( + + ) + } + if (transactionLoading) { + return ( + + ) + } + return ( + + ) +} diff --git a/src/transaction/components/stage/transaction/BackButton.tsx b/src/transaction/components/stage/transaction/BackButton.tsx new file mode 100644 index 000000000..62ef4d5ce --- /dev/null +++ b/src/transaction/components/stage/transaction/BackButton.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from 'react-i18next' + +import { Button } from '@ensdomains/thorin' + +import { useTransactionStore } from '@app/transaction/transactionStore' +import type { StoredTransactionStatus } from '@app/transaction/types' + +export const BackButton = ({ + status, + backToInput, +}: { + status: StoredTransactionStatus + backToInput: boolean +}) => { + const { t } = useTranslation() + const setStage = useTransactionStore((s) => s.flow.current.setStage) + const resetTransactionIndex = useTransactionStore((s) => s.flow.current.resetTransactionIndex) + + if (!backToInput) return null + + if (status === 'waitingForUser' || status === 'pending' || status === 'success') return null + + const handleBackToInput = () => { + setStage({ stage: 'input' }) + resetTransactionIndex() + } + + return ( + + ) +} diff --git a/src/transaction/components/stage/transaction/LoadBar.tsx b/src/transaction/components/stage/transaction/LoadBar.tsx new file mode 100644 index 000000000..56c6b828a --- /dev/null +++ b/src/transaction/components/stage/transaction/LoadBar.tsx @@ -0,0 +1,230 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' + +import { CrossCircleSVG, QuestionCircleSVG, Spinner, Typography } from '@ensdomains/thorin' + +import AeroplaneSVG from '@app/assets/Aeroplane.svg' +import CircleTickSVG from '@app/assets/CircleTick.svg' +import { Outlink } from '@app/components/Outlink' +import { TransactionStage } from '@app/transaction-flow/types' +import type { StoredTransactionStatus } from '@app/transaction/types' + +const BarContainer = styled.div( + ({ theme }) => css` + width: ${theme.space.full}; + display: flex; + flex-direction: column; + align-items: center; + gap: ${theme.space['2']}; + `, +) + +const Bar = styled.div<{ $status: Status }>( + ({ theme, $status }) => css` + width: ${theme.space.full}; + height: ${theme.space['9']}; + border-radius: ${theme.radii.full}; + background-color: ${theme.colors.blueSurface}; + overflow: hidden; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + + --bar-color: ${theme.colors.blue}; + + ${$status === 'complete' && + css` + --bar-color: ${theme.colors.green}; + `} + ${$status === 'failed' && + css` + --bar-color: ${theme.colors.red}; + `} + `, +) + +const BarTypography = styled(Typography)( + ({ theme }) => css` + color: ${theme.colors.background}; + font-weight: ${theme.fontWeights.bold}; + `, +) + +const ProgressTypography = styled(Typography)( + ({ theme }) => css` + color: ${theme.colors.accent}; + font-weight: ${theme.fontWeights.bold}; + text-align: center; + `, +) + +const AeroplaneIcon = styled.svg( + ({ theme }) => css` + width: ${theme.space['4']}; + height: ${theme.space['4']}; + color: ${theme.colors.background}; + `, +) + +const CircleIcon = styled.svg( + ({ theme }) => css` + width: ${theme.space['6']}; + height: ${theme.space['6']}; + color: ${theme.colors.background}; + `, +) + +const MessageTypography = styled(Typography)( + () => css` + text-align: center; + `, +) + +type Status = Omit + +const BarPrefix = styled.div( + ({ theme }) => css` + padding: ${theme.space['2']} ${theme.space['4']}; + width: min-content; + white-space: nowrap; + height: ${theme.space['9']}; + margin-right: -1px; + + border-radius: ${theme.radii.full}; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + background-color: var(--bar-color); + `, +) + +const InnerBar = styled.div( + ({ theme }) => css` + padding: ${theme.space['2']} ${theme.space['4']}; + height: ${theme.space['9']}; + + border-radius: ${theme.radii.full}; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + + transition: width 1s linear; + &.progress-complete { + width: 100% !important; + padding-right: ${theme.space['2']}; + transition: width 0.5s ease-in-out; + } + + background-color: var(--bar-color); + + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; + + position: relative; + + & > svg { + position: absolute; + right: ${theme.space['2']}; + top: 50%; + transform: translateY(-50%); + } + `, +) + +export const LoadBar = ({ + status, + sendTime, +}: { + status: StoredTransactionStatus + sendTime: number | undefined +}) => { + const { t } = useTranslation() + + const time = useMemo(() => ({ start: sendTime || Date.now(), ms: 45000 }), [sendTime]) + const [{ progress }, setProgress] = useState({ progress: 0, timeLeft: 45 }) + + const intervalFunc = useCallback( + (interval?: NodeJS.Timeout) => { + const timeElapsed = Date.now() - time.start + const _timeLeft = time.ms - timeElapsed + const _progress = Math.min((timeElapsed / (timeElapsed + _timeLeft)) * 100, 100) + setProgress({ timeLeft: Math.floor(_timeLeft / 1000), progress: _progress }) + if (_progress === 100) clearInterval(interval) + }, + [time.ms, time.start], + ) + + useEffect(() => { + intervalFunc() + const interval = setInterval(intervalFunc, 1000) + return () => clearInterval(interval) + }, [intervalFunc]) + + const message = useMemo(() => { + if (status === 'success') { + return t('transaction.dialog.complete.message') + } + if (status === 'reverted') { + return null + } + return t('transaction.dialog.sent.message') + }, [status, t]) + + const isTakingLongerThanExpected = status === 'pending' && progress === 100 + + const progressMessage = useMemo(() => { + if (isTakingLongerThanExpected) { + return ( + + {t('transaction.dialog.sent.learn')} + + ) + } + return null + }, [isTakingLongerThanExpected, t]) + + const EndElement = useMemo(() => { + if (status === 'success') { + return + } + if (status === 'reverted') { + return + } + if (progress !== 100) { + return + } + return + }, [progress, status]) + + return ( + <> + + + + + {t( + isTakingLongerThanExpected + ? 'transaction.dialog.sent.progress.message' + : `transaction.dialog.${status}.progress.title`, + )} + + + + {EndElement} + + + {progressMessage && {progressMessage}} + + {message && {message}} + + ) +} diff --git a/src/transaction/components/stage/transaction/TransactionStageModal.tsx b/src/transaction/components/stage/transaction/TransactionStageModal.tsx new file mode 100644 index 000000000..7d4e4bbd7 --- /dev/null +++ b/src/transaction/components/stage/transaction/TransactionStageModal.tsx @@ -0,0 +1,196 @@ +import { queryOptions } from '@tanstack/react-query' +import { useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' +import { BaseError } from 'viem' + +import { Dialog, Helper, Typography } from '@ensdomains/thorin' + +import WalletSVG from '@app/assets/Wallet.svg' +import { Outlink } from '@app/components/Outlink' +import { useChainName } from '@app/hooks/chain/useChainName' +import { useQueryOptions } from '@app/hooks/useQueryOptions' +import { useTransactionStore } from '@app/transaction/transactionStore' +import type { GenericStoredTransaction, StoredTransactionStatus } from '@app/transaction/types' +import type { TransactionName } from '@app/transaction/user/transaction' +import { TransactionDisplayItem } from '@app/types' +import { getReadableError } from '@app/utils/errors' +import { useQuery } from '@app/utils/query/useQuery' +import { makeEtherscanLink } from '@app/utils/utils' + +import { DisplayItems } from '../../DisplayItems' +import { TransactionModalActionButton } from './ActionButton' +import { BackButton } from './BackButton' +import { LoadBar } from './LoadBar' +import { getTransactionErrorQueryFn } from './query' +import { useManagedTransaction } from './useManagedTransaction' + +const WalletIcon = styled.svg( + ({ theme }) => css` + width: ${theme.space['12']}; + `, +) + +const MessageTypography = styled(Typography)( + () => css` + text-align: center; + `, +) + +function useCreateSubnameRedirect( + shouldTrigger: boolean, + subdomain?: TransactionDisplayItem['value'], +) { + useEffect(() => { + if (shouldTrigger && typeof subdomain === 'string') { + setTimeout(() => { + window.location.href = `/${subdomain}` + }, 1000) + } + }, [shouldTrigger, subdomain]) +} + +type TransactionStageModalProps = { + currentTransactionIndex: number + transactionCount: number + transaction: GenericStoredTransaction + displayItems: TransactionDisplayItem[] + backToInput: boolean + onDismiss: () => void +} + +const MiddleContent = ({ + status, + sendTime, +}: { + status: StoredTransactionStatus + sendTime: number | undefined +}) => { + const { t } = useTranslation() + + if (status !== 'empty' && status !== 'waitingForUser') + return + + return ( + <> + + {t('transaction.dialog.confirm.message')} + + ) +} + +export const TransactionStageModal = ({ + currentTransactionIndex, + transactionCount, + transaction, + displayItems, + backToInput, + onDismiss, +}: TransactionStageModalProps) => { + const { t } = useTranslation() + const chainName = useChainName() + + const incrementTransaction = useTransactionStore((s) => s.flow.current.incrementTransaction) + + const { + transactionError, + requestError, + canEnableTransactionRequest, + isTransactionRequestCachedData, + request, + requestLoading, + sendTransaction, + transactionLoading, + } = useManagedTransaction(transaction) + + useCreateSubnameRedirect( + transaction.status === 'success' && currentTransactionIndex + 1 === transactionCount, + displayItems.find((i) => i.label === 'subname' && i.type === 'name')?.value, + ) + + const FilledDisplayItems = useMemo( + () => , + [displayItems], + ) + + const stepStatus = useMemo(() => { + if (transaction.status === 'success') { + return 'completed' + } + return 'inProgress' + }, [transaction.status]) + + const initialErrorOptions = useQueryOptions({ + params: { hash: transaction.currentHash, status: transaction.status }, + functionName: 'getTransactionError', + queryDependencyType: 'standard', + queryFn: getTransactionErrorQueryFn, + }) + + const preparedErrorOptions = queryOptions({ + queryKey: initialErrorOptions.queryKey, + queryFn: initialErrorOptions.queryFn, + }) + + const { data: upperError } = useQuery({ + ...preparedErrorOptions, + enabled: !!transaction && !!transaction.currentHash && transaction.status === 'reverted', + }) + + const lowerError = useMemo(() => { + if (transaction.status === 'success') return null + if (transaction.status === 'pending') return null + if (transaction.status === 'waitingForUser') return null + const err = transactionError || requestError + if (!err) return null + if (!(err instanceof BaseError)) { + if ('message' in err) return err.message + return t('transaction.error.unknown') + } + const readableError = getReadableError(err) + return readableError || err.shortMessage + }, [t, transaction.status, transactionError, requestError]) + + const actionButton = ( + sendTransaction(request!)} + status={transaction.status} + transactionLoading={transactionLoading} + /> + ) + + const backButton = + + return ( + <> + + + + {upperError && {t(upperError)}} + {FilledDisplayItems} + {transaction.currentHash && ( + + {t('transaction.viewEtherscan')} + + )} + {lowerError && {lowerError}} + + 1 ? transactionCount : undefined} + stepStatus={stepStatus} + leading={backButton} + trailing={actionButton} + /> + + ) +} diff --git a/src/transaction/components/stage/transaction/query.ts b/src/transaction/components/stage/transaction/query.ts new file mode 100644 index 000000000..1dae46f21 --- /dev/null +++ b/src/transaction/components/stage/transaction/query.ts @@ -0,0 +1,210 @@ +import { QueryFunctionContext } from '@tanstack/react-query' +import { CallParameters, SendTransactionReturnType } from '@wagmi/core' +import { Address, BlockTag, Hash, Hex, toHex, TransactionRequest } from 'viem' +import { call, estimateGas, getTransaction, prepareTransactionRequest } from 'viem/actions' +import type { SendTransactionVariables } from 'wagmi/query' + +import { SupportedChain } from '@app/constants/chains' +import { useTransactionStore } from '@app/transaction/transactionStore' +import type { + GenericStoredTransaction, + StoredTransactionIdentifiers, + StoredTransactionStatus, +} from '@app/transaction/types' +import { + createTransactionRequest, + type TransactionData, + type TransactionName, +} from '@app/transaction/user/transaction' +import { + BasicTransactionRequest, + ClientWithEns, + ConfigWithEns, + ConnectorClientWithEns, + CreateQueryKey, +} from '@app/types' +import { getReadableError } from '@app/utils/errors' +import type { wagmiConfig } from '@app/utils/query/wagmi' +import { CheckIsSafeAppReturnType } from '@app/utils/safe' + +type AccessListResponse = { + accessList: { + address: Address + storageKeys: Hex[] + }[] + gasUsed: Hex +} + +type TransactionIdentifiersWithData = + StoredTransactionIdentifiers & { + name: name + data: TransactionData + } + +export const getTransactionIdentifiersWithData = ( + transaction: GenericStoredTransaction, +): TransactionIdentifiersWithData => { + const { chainId, account, transactionId, flowId, name, data } = transaction + return { chainId, account, transactionId, flowId, name, data } +} + +export const transactionMutateHandler = + ({ + transactionIdentifiers, + isSafeApp, + }: { + transactionIdentifiers: StoredTransactionIdentifiers + isSafeApp: CheckIsSafeAppReturnType + }) => + (request: SendTransactionVariables) => { + useTransactionStore.getState().transaction.setSubmission(transactionIdentifiers, { + input: request.data!, + nonce: request.nonce!, + timestamp: Math.floor(Date.now() / 1000), + transactionType: isSafeApp ? 'safe' : 'standard', + }) + } + +export const transactionSuccessHandler = + (transactionIdentifiers: StoredTransactionIdentifiers) => + async (transactionHash: SendTransactionReturnType) => { + useTransactionStore.getState().transaction.setHash(transactionIdentifiers, transactionHash) + } + +export const registrationGasFeeModifier = (gasLimit: bigint, transactionName: TransactionName) => + // this addition is arbitrary, something to do with a gas refund but not 100% sure + transactionName === 'registerName' ? gasLimit + 5000n : gasLimit + +export const calculateGasLimit = async ({ + client, + connectorClient, + isSafeApp, + txWithZeroGas, + transactionName, +}: { + client: ClientWithEns + connectorClient: ConnectorClientWithEns + isSafeApp: boolean + txWithZeroGas: BasicTransactionRequest + transactionName: TransactionName +}) => { + if (isSafeApp) { + const accessListResponse = await client.request<{ + Method: 'eth_createAccessList' + Parameters: [tx: TransactionRequest, blockTag: BlockTag] + ReturnType: AccessListResponse + }>({ + method: 'eth_createAccessList', + params: [ + { + to: txWithZeroGas.to, + data: txWithZeroGas.data, + from: connectorClient.account!.address, + value: toHex(txWithZeroGas.value ? txWithZeroGas.value + 1000000n : 0n), + }, + 'latest', + ], + }) + + return { + gasLimit: registrationGasFeeModifier(BigInt(accessListResponse.gasUsed), transactionName), + accessList: accessListResponse.accessList, + } + } + + const gasEstimate = await estimateGas(client, { + ...txWithZeroGas, + account: connectorClient.account!, + }) + return { + gasLimit: registrationGasFeeModifier(gasEstimate, transactionName), + accessList: undefined, + } +} + +type CreateTransactionRequestQueryKey = CreateQueryKey< + TransactionIdentifiersWithData, + 'createTransactionRequest', + 'standard' +> + +export const createTransactionRequestQueryFn = + (config: ConfigWithEns) => + ({ + connectorClient, + isSafeApp, + }: { + connectorClient: ConnectorClientWithEns | undefined + isSafeApp: CheckIsSafeAppReturnType | undefined + }) => + async ({ + queryKey: [params, chainId, address], + }: QueryFunctionContext) => { + const client = config.getClient({ chainId }) + + if (!connectorClient) throw new Error('connectorClient is required') + if (connectorClient.account.address !== address) + throw new Error('address does not match connector') + + const transactionRequest = await createTransactionRequest({ + name: params.name, + data: params.data, + connectorClient, + client, + }) + + const txWithZeroGas = { + ...transactionRequest, + maxFeePerGas: 0n, + maxPriorityFeePerGas: 0n, + } + + const { gasLimit, accessList } = await calculateGasLimit({ + client, + connectorClient, + isSafeApp: !!isSafeApp, + txWithZeroGas, + transactionName: params.name, + }) + + const request = await prepareTransactionRequest(client, { + to: transactionRequest.to, + accessList, + account: connectorClient.account, + data: transactionRequest.data, + gas: gasLimit, + parameters: ['fees', 'nonce', 'type'], + ...('value' in transactionRequest ? { value: transactionRequest.value } : {}), + }) + + return { + ...request, + chain: request.chain!, + to: request.to!, + gas: request.gas!, + chainId, + } + } + +type GetTransactionErrorQueryKey = CreateQueryKey< + { hash: Hash | null; status: StoredTransactionStatus | undefined }, + 'getTransactionError', + 'standard' +> + +export const getTransactionErrorQueryFn = + (config: ConfigWithEns) => + async ({ + queryKey: [{ hash, status }, chainId], + }: QueryFunctionContext) => { + if (!hash || status !== 'reverted') return null + const client = config.getClient({ chainId }) + const failedTransactionData = await getTransaction(client, { hash }) + try { + await call(client, failedTransactionData as CallParameters) + // TODO: better errors for this + return 'transaction.dialog.error.gasLimit' + } catch (err: unknown) { + return getReadableError(err) + } + } diff --git a/src/transaction/components/stage/transaction/useManagedTransaction.ts b/src/transaction/components/stage/transaction/useManagedTransaction.ts new file mode 100644 index 000000000..86add5eb6 --- /dev/null +++ b/src/transaction/components/stage/transaction/useManagedTransaction.ts @@ -0,0 +1,95 @@ +import { queryOptions, useQuery } from '@tanstack/react-query' +import { useMemo } from 'react' +import { useConnectorClient, useSendTransaction } from 'wagmi' + +import { useInvalidateOnBlock } from '@app/hooks/chain/useInvalidateOnBlock' +import { useIsSafeApp } from '@app/hooks/useIsSafeApp' +import { useQueryOptions } from '@app/hooks/useQueryOptions' +import type { GenericStoredTransaction } from '@app/transaction/types' +import type { TransactionName } from '@app/transaction/user/transaction' +import type { ConfigWithEns } from '@app/types' +import { getIsCachedData } from '@app/utils/getIsCachedData' + +import { + createTransactionRequestQueryFn, + getTransactionIdentifiersWithData, + transactionMutateHandler, + transactionSuccessHandler, +} from './query' + +export const useManagedTransaction = ( + transaction: GenericStoredTransaction, +) => { + const { data: isSafeApp, isLoading: safeAppStatusLoading } = useIsSafeApp() + const { data: connectorClient } = useConnectorClient() + + const transactionIdentifiers = useMemo( + () => getTransactionIdentifiersWithData(transaction), + [transaction], + ) + + // if not all unique identifiers are defined, there could be incorrect cached data + const isUniquenessDefined = useMemo( + // number check is for if step = 0 + () => Object.values(transactionIdentifiers).every((val) => typeof val === 'number' || !!val), + [transactionIdentifiers], + ) + + const canEnableTransactionRequest = useMemo( + () => + !!transaction && + !!connectorClient?.account && + !safeAppStatusLoading && + !(transaction.status === 'pending' || transaction.status === 'success') && + isUniquenessDefined, + [transaction, connectorClient?.account, safeAppStatusLoading, isUniquenessDefined], + ) + + const initialOptions = useQueryOptions({ + params: transactionIdentifiers, + functionName: 'createTransactionRequest', + queryDependencyType: 'standard', + queryFn: createTransactionRequestQueryFn, + }) + + const preparedOptions = queryOptions({ + queryKey: initialOptions.queryKey, + queryFn: initialOptions.queryFn({ connectorClient, isSafeApp }), + }) + + const transactionRequestQuery = useQuery({ + ...preparedOptions, + enabled: canEnableTransactionRequest, + refetchOnMount: 'always', + }) + + const { data: request, isLoading: requestLoading, error: requestError } = transactionRequestQuery + const isTransactionRequestCachedData = getIsCachedData(transactionRequestQuery) + + useInvalidateOnBlock({ + enabled: canEnableTransactionRequest && process.env.NEXT_PUBLIC_ETH_NODE !== 'anvil', + queryKey: preparedOptions.queryKey, + }) + + const { + isPending: transactionLoading, + error: transactionError, + sendTransaction, + } = useSendTransaction({ + mutation: { + onMutate: transactionMutateHandler({ transactionIdentifiers, isSafeApp: isSafeApp! }), + onSuccess: transactionSuccessHandler(transactionIdentifiers), + }, + }) + + return { + request, + requestLoading, + requestError, + isTransactionRequestCachedData, + canEnableTransactionRequest, + transactionLoading, + transactionError, + sendTransaction, + } +} diff --git a/src/transaction/createTransactionListener.ts b/src/transaction/createTransactionListener.ts new file mode 100644 index 000000000..df397b098 --- /dev/null +++ b/src/transaction/createTransactionListener.ts @@ -0,0 +1,21 @@ +import type { TransactionStore } from './types' + +export type TransactionStoreListener = [ + selector: (state: TransactionStore) => selected, + listener: (selectedState: selected, previousSelectedState: selected) => void, + options?: { + equalityFn?: (a: selected, b: selected) => boolean + fireImmediately?: boolean + }, +] + +export const createTransactionListener = ( + selector: (state: TransactionStore) => selected, + listener: (selectedState: selected, previousSelectedState: selected) => void, + options?: { + equalityFn?: (a: selected, b: selected) => boolean + fireImmediately?: boolean + }, +): TransactionStoreListener => { + return [selector, listener, options] +} diff --git a/src/transaction/key.ts b/src/transaction/key.ts new file mode 100644 index 000000000..55d2a9afb --- /dev/null +++ b/src/transaction/key.ts @@ -0,0 +1,11 @@ +import type { FlowKey, StoredFlow, StoredTransaction, TransactionKey } from './types' + +export const getFlowKey = (flow: Pick): FlowKey => + JSON.stringify([flow.flowId, flow.chainId, flow.account]) as FlowKey +export const getTransactionKey = ({ + transactionId, + flowId, + chainId, + account, +}: Pick): TransactionKey => + JSON.stringify([transactionId, flowId, chainId, account]) as TransactionKey diff --git a/src/transaction/transactionAnalyticsListener.ts b/src/transaction/transactionAnalyticsListener.ts new file mode 100644 index 000000000..b2d56c00d --- /dev/null +++ b/src/transaction/transactionAnalyticsListener.ts @@ -0,0 +1,29 @@ +import { trackEvent } from '@app/utils/analytics' + +import { createTransactionListener } from './createTransactionListener' +import type { LastTransactionChange } from './types' + +export const transactionAnalyticsListener = createTransactionListener( + ( + s, + ): Extract< + LastTransactionChange, + { status: 'success'; name: 'registerName' | 'commitName' | 'extendNames' } + > | null => { + const lastChange = s._internal.lastTransactionChange + if (!lastChange) return null + if (lastChange.status !== 'success') return null + + if (lastChange.name === 'registerName') return lastChange + if (lastChange.name === 'commitName') return lastChange + if (lastChange.name === 'extendNames') return lastChange + + return null + }, + (transaction) => { + if (!transaction) return + if (transaction.name === 'registerName') trackEvent('register', transaction.chainId) + else if (transaction.name === 'commitName') trackEvent('commit', transaction.chainId) + else if (transaction.name === 'extendNames') trackEvent('renew', transaction.chainId) + }, +) diff --git a/src/transaction/transactionReceiptListener.ts b/src/transaction/transactionReceiptListener.ts new file mode 100644 index 000000000..a40993bed --- /dev/null +++ b/src/transaction/transactionReceiptListener.ts @@ -0,0 +1,61 @@ +import type { Block, Hash } from 'viem' +import { getBlock } from 'viem/actions' + +import { waitForTransaction } from '@app/hooks/transactions/waitForTransaction' +import { wagmiConfig } from '@app/utils/query/wagmi' + +import { createTransactionListener } from './createTransactionListener' +import { getTransactionKey } from './key' +import { type UseTransactionStore } from './transactionStore' +import type { TransactionList } from './types' + +const transactionRequestCache = new Map>() +const blockRequestCache = new Map>() + +const listenForTransaction = async ( + store: UseTransactionStore, + transaction: TransactionList<'pending'>[number], +) => { + const receipt = await waitForTransaction(wagmiConfig, { + confirmations: 1, + hash: transaction.currentHash, + isSafeTx: transaction.transactionType === 'safe', + chainId: transaction.chainId, + onReplaced: (replacedTransaction) => { + if (replacedTransaction.reason !== 'repriced') return + store.getState().transaction.setHash(transaction, replacedTransaction.transaction.hash) + }, + }) + + const { status, blockHash } = receipt + let blockRequest = blockRequestCache.get(blockHash) + if (!blockRequest) { + const client = wagmiConfig.getClient({ chainId: transaction.chainId }) + blockRequest = getBlock(client, { blockHash }) + blockRequestCache.set(blockHash, blockRequest) + } + + // TODO(tate): figure out if timestamp is needed + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { timestamp: _ } = await blockRequest + store.getState().transaction.setStatus(transaction, status) + + const transactionKey = getTransactionKey(transaction) + transactionRequestCache.delete(transactionKey) +} + +export const transactionReceiptListener = (store: UseTransactionStore) => + createTransactionListener( + (s) => s.transaction.getByStatus('pending'), + (pendingTransactions) => { + for (const tx of pendingTransactions) { + const transactionKey = getTransactionKey(tx) + const existingRequest = transactionRequestCache.get(transactionKey) + // eslint-disable-next-line no-continue + if (existingRequest) continue + + const requestPromise = listenForTransaction(store, tx) + transactionRequestCache.set(transactionKey, requestPromise) + } + }, + ) diff --git a/src/transaction/transactionStore.ts b/src/transaction/transactionStore.ts new file mode 100644 index 000000000..03e9086e9 --- /dev/null +++ b/src/transaction/transactionStore.ts @@ -0,0 +1,352 @@ +/* eslint-disable no-param-reassign */ + +import { watchAccount, watchChainId } from '@wagmi/core' +import { del as idbDel, get as idbGet, set as idbSet } from 'idb-keyval' +import { WritableDraft } from 'immer/dist/internal' +import { create, StateCreator } from 'zustand' +import { persist, StorageValue, subscribeWithSelector } from 'zustand/middleware' +import { immer } from 'zustand/middleware/immer' + +import { parse, stringify } from '@app/utils/query/persist' +import { wagmiConfig } from '@app/utils/query/wagmi' + +import { getFlowKey, getTransactionKey } from './key' +import { transactionAnalyticsListener } from './transactionAnalyticsListener' +import { transactionReceiptListener } from './transactionReceiptListener' +import type { + StoredTransaction, + StoredTransactionStatus, + TransactionStore, + TransactionStoreIdentifiers, +} from './types' + +const getIdentifiers = ( + state: TransactionStore, + identifiersOverride: TransactionStoreIdentifiers | undefined, +) => { + const { account, chainId } = identifiersOverride ?? state._internal.current + if (!account) throw new Error('No account found') + if (!chainId) throw new Error('No chainId found') + return { account, chainId } +} + +const getCurrentFlow = (state: TransactionStore) => { + const { account, chainId, flowId } = state._internal.current + if (!flowId) throw new Error('No flowId found') + if (!account) throw new Error('No account found') + if (!chainId) throw new Error('No chainId found') + const flowKey = getFlowKey({ flowId, chainId, account }) + const flow = state._internal.flows[flowKey] + if (!flow) throw new Error('No flow found') + return flow +} +const getCurrentFlowOrNull = (state: TransactionStore) => { + const { account, chainId, flowId } = state._internal.current + if (!account || !chainId || !flowId) return null + const flowKey = getFlowKey({ flowId, chainId, account }) + return state._internal.flows[flowKey] ?? null +} + +const initialiser: StateCreator< + TransactionStore, + [ + ['zustand/persist', unknown], + ['zustand/subscribeWithSelector', never], + ['zustand/immer', never], + ], + [], + TransactionStore +> = (set, get) => ({ + _internal: { + flows: {}, + transactions: {}, + current: { + account: null, + chainId: null, + flowId: null, + _previousFlowId: null, + }, + lastTransactionChange: null, + }, + flow: { + helpers: { + getAllTransactionsComplete: (flow) => { + const state = get() + const identifiers = { + account: flow.account, + chainId: flow.chainId, + flowId: flow.flowId, + } + return flow.transactionIds.every((transactionId) => { + const transactionKey = getTransactionKey({ transactionId, ...identifiers }) + const transaction = state._internal.transactions[transactionKey] + return transaction?.status === 'success' + }) + }, + getNoTransactionsStarted: (flow) => { + const state = get() + const identifiers = { + account: flow.account, + chainId: flow.chainId, + flowId: flow.flowId, + } + return flow.transactionIds.every((transactionId) => { + const transactionKey = getTransactionKey({ transactionId, ...identifiers }) + const transaction = state._internal.transactions[transactionKey] + return transaction?.status === 'empty' + }) + }, + getCanRemoveFlow: (flow) => { + if (flow.requiresManualCleanup) return false + if (!flow.transactionIds || flow.transactionIds.length === 0) return true + if (!flow.resumable) return true + + const { helpers } = get().flow + if (helpers.getAllTransactionsComplete(flow)) return true + return helpers.getNoTransactionsStarted(flow) + }, + }, + showInput: (flowId, { input, disableBackgroundClick }, identifiersOverride) => + set((state) => { + const identifiers = getIdentifiers(state, identifiersOverride) + const flowKey = getFlowKey({ flowId, ...identifiers }) + state._internal.flows[flowKey] = { + ...identifiers, + flowId, + currentStage: 'input', + currentTransaction: 0, + transactionIds: [], + input: input as WritableDraft, + disableBackgroundClick, + } + state._internal.current.flowId = flowId + }), + start: (flowId, flow, identifiersOverride) => + set((state) => { + const identifiers = getIdentifiers(state, identifiersOverride) + const flowKey = getFlowKey({ flowId, ...identifiers }) + const currentStage = (() => { + if (flow.intro) return 'intro' as const + if (flow.input) return 'input' as const + return 'transaction' as const + })() + state._internal.flows[flowKey] = { + ...(flow as WritableDraft), + ...identifiers, + flowId, + currentTransaction: 0, + currentStage, + } + state._internal.current.flowId = flowId + }), + resume: (flowId, identifiersOverride) => + set((state) => { + const identifiers = getIdentifiers(state, identifiersOverride) + const flowKey = getFlowKey({ flowId, ...identifiers }) + const flow = state._internal.flows[flowKey] + // item no longer exists because transactions were completed + if (!flow) return + if (flow.intro) flow.currentStage = 'intro' + state._internal.current.flowId = flowId + }), + resumeWithCheck: (flowId, { push }, identifiersOverride) => + set((state) => { + const identifiers = getIdentifiers(state, identifiersOverride) + const flowKey = getFlowKey({ flowId, ...identifiers }) + const flow = state._internal.flows[flowKey] + // item no longer exists because transactions were completed + if (!flow) return + if (flow.resumeLink && state.flow.helpers.getAllTransactionsComplete(flow)) { + push(flow.resumeLink) + return + } + state.flow.resume(flowId, identifiers) + }), + cleanup: (flowId, identifiersOverride) => + set((state) => { + const identifiers = getIdentifiers(state, identifiersOverride) + const flowKey = getFlowKey({ flowId, ...identifiers }) + delete state._internal.flows[flowKey] + }), + getResumable: (flowId, identifiersOverride) => { + const state = get() + const identifiers = getIdentifiers(state, identifiersOverride) + const flowKey = getFlowKey({ flowId, ...identifiers }) + const flow = state._internal.flows[flowKey] + if (!flow) return false + if (state.flow.helpers.getCanRemoveFlow(flow)) return false + return true + }, + current: { + setTransactions: (transactions) => + set((state) => { + const flow = getCurrentFlow(state) + flow.transactionIds = [] + for (let i = 0; i < transactions.length; i += 1) { + const transaction = transactions[i] + const transactionId = `${transaction.name}-${i}` + flow.transactionIds.push(transactionId) + const transactionKey = getTransactionKey({ transactionId, ...flow }) + state._internal.transactions[transactionKey] = { + ...(transaction as WritableDraft), + flowId: flow.flowId, + transactionId, + chainId: flow.chainId, + account: flow.account, + currentHash: null, + status: 'empty', + transactionType: null, + } + } + }), + setStage: ({ stage }) => + set((state) => { + const flow = getCurrentFlow(state) + flow.currentStage = stage + }), + stop: () => + set((state) => { + const flow = getCurrentFlow(state) + state._internal.current._previousFlowId = flow.flowId + state._internal.current.flowId = null + setTimeout(() => { + state._internal.current._previousFlowId = null + state.flow.cleanup(flow.flowId) + }, 350) + }), + incrementTransaction: () => + set((state) => { + const flow = getCurrentFlow(state) + flow.currentTransaction += 1 + }), + resetTransactionIndex: () => + set((state) => { + const flow = getCurrentFlow(state) + flow.currentTransaction = 0 + }), + selectedOrPrevious: () => { + const state = get() + const { account, chainId, flowId: flowId_, _previousFlowId } = state._internal.current + if (!account || !chainId) return { flow: null, isPrevious: false } + + const isPrevious = !flowId_ && !!_previousFlowId + const flowId = isPrevious ? _previousFlowId : flowId_ ?? '' + const flowKey = getFlowKey({ account, chainId, flowId }) + const flow = state._internal.flows[flowKey] + if (!flow) return { flow: null, isPrevious: false } + return { flow, isPrevious } + }, + attemptDismiss: () => { + const state = get() + const flow = getCurrentFlowOrNull(state) + if (!flow) return + if (flow.disableBackgroundClick && flow.currentStage === 'input') return + return state.flow.current.stop() + }, + getTransactions: () => { + const state = get() + const flow = getCurrentFlowOrNull(state) + if (!flow) return [] + return flow.transactionIds.map((transactionId) => { + const transactionKey = getTransactionKey({ transactionId, ...flow }) + const transaction = state._internal.transactions[transactionKey] + if (!transaction) throw new Error('No transaction found') + return transaction + }) + }, + }, + }, + transaction: { + setStatus: (identifiers, status) => + set((state) => { + const transactionKey = getTransactionKey(identifiers) + const transaction = state._internal.transactions[transactionKey] + if (!transaction) throw new Error('No transaction found') + transaction.status = status + // important: set lastTransactionChange for transaction update consumers + state._internal.lastTransactionChange = transaction + }), + setHash: (identifiers, hash) => + set((state) => { + const transactionKey = getTransactionKey(identifiers) + const transaction = state._internal.transactions[transactionKey] + if (!transaction) throw new Error('No transaction found') + transaction.currentHash = hash + if (transaction.status === 'empty') state.transaction.setStatus(identifiers, 'pending') + // don't set lastTransactionChange for hash update since nothing else is updated + }), + setSubmission: (identifiers, submission) => + set((state) => { + const transactionKey = getTransactionKey(identifiers) + const transaction = state._internal.transactions[transactionKey] + if (!transaction) throw new Error('No transaction found') + transaction.submission = { + input: submission.input, + timestamp: submission.timestamp, + nonce: submission.nonce, + } + transaction.transactionType = submission.transactionType + transaction.status = 'waitingForUser' + }), + getAll: () => { + const state = get() + const identifiers = getIdentifiers(state, undefined) + return Object.values(state._internal.transactions).filter( + (x): x is StoredTransaction => + !!x && x.chainId === identifiers.chainId && x.account === identifiers.account, + ) + }, + getByStatus: (status: status) => { + const state = get() + const identifiers = getIdentifiers(state, undefined) + return Object.values(state._internal.transactions).filter( + (x): x is StoredTransaction => + !!x && + x.status === status && + x.chainId === identifiers.chainId && + x.account === identifiers.account, + ) + }, + }, +}) + +export const useTransactionStore = create( + persist(subscribeWithSelector(immer(initialiser)), { + name: 'transaction-data', + storage: { + getItem: async (name) => { + const value = await idbGet(name) + return value ? parse>(value) : null + }, + setItem: async (name, value) => { + const stringValue = stringify(value) + await idbSet(name, stringValue) + }, + removeItem: async (name) => { + await idbDel(name) + }, + }, + }), +) + +export type UseTransactionStore = typeof useTransactionStore + +useTransactionStore.subscribe(...transactionReceiptListener(useTransactionStore)) +useTransactionStore.subscribe(...transactionAnalyticsListener) + +watchAccount(wagmiConfig, { + onChange: (account) => { + useTransactionStore.setState((state) => { + state._internal.current.account = account.address ?? null + state._internal.current.flowId = null + }) + }, +}) +watchChainId(wagmiConfig, { + onChange: (chainId) => { + useTransactionStore.setState((state) => { + state._internal.current.chainId = chainId + state._internal.current.flowId = null + }) + }, +}) diff --git a/src/transaction/types.ts b/src/transaction/types.ts new file mode 100644 index 000000000..853be856c --- /dev/null +++ b/src/transaction/types.ts @@ -0,0 +1,248 @@ +import type { TOptions } from 'i18next' +import type { WritableDraft } from 'immer/dist/internal' +import type { ComponentProps } from 'react' +import type { Address, Hash, Hex } from 'viem' + +import type { SupportedChain } from '@app/constants/chains' +import type { IntroComponentName } from '@app/transaction-flow/intro' + +import type { DataInputComponent, DataInputName } from './user/input' +import type { TransactionData, TransactionName } from './user/transaction' + +export type TransactionFlowStage = 'input' | 'intro' | 'transaction' +export type StoredTransactionStatus = + | 'empty' + | 'waitingForUser' + | 'pending' + | 'success' + | 'reverted' +export type StoredTransactionType = 'standard' | 'safe' + +export type TransactionStoreIdentifiers = { + chainId: SupportedChain['id'] + account: Address +} +export type FlowId = string +export type FlowKey = `["${FlowId}",${SupportedChain['id']},"${Address}"]` +export type TransactionId = string +export type TransactionKey = + `["${TransactionId}","${FlowKey}",${SupportedChain['id']},"${Address}"]` + +export type GenericDataInput< + name extends DataInputName = DataInputName, + data extends ComponentProps = ComponentProps, +> = { + name: name + data: data +} + +type GenericIntro< + name extends IntroComponentName = IntroComponentName, + // TODO(tate): add correct type for data + data extends {} = {}, +> = { + name: name + data: data +} + +type StoredTranslationReference = [key: string, options?: TOptions] + +export type TransactionIntro = { + title: StoredTranslationReference + leadingLabel?: StoredTranslationReference + trailingLabel?: StoredTranslationReference + content: GenericIntro +} + +type EmptyStoredTransaction = { + status: 'empty' + currentHash: null + transactionType: null + transaction?: never + receipt?: never + search?: never +} + +type WaitingForUserStoredTransaction = { + status: 'waitingForUser' + currentHash: null + transactionType: StoredTransactionType + transaction: { + input: Hex + timestamp: number + nonce: number + } + receipt?: never +} + +type PendingStoredTransaction = { + status: 'pending' + currentHash: Hash + transactionType: StoredTransactionType +} + +type SuccessStoredTransaction = { + status: 'success' + currentHash: Hash + transactionType: StoredTransactionType +} + +type RevertedStoredTransaction = { + status: 'reverted' + currentHash: Hash + transactionType: StoredTransactionType +} + +export type StoredTransactionIdentifiers = TransactionStoreIdentifiers & { + flowId: FlowId + transactionId: TransactionId +} + +type TransactionSubmission = { + input: Hex + timestamp: number + nonce: number +} + +export type GenericStoredTransaction< + name extends TransactionName = TransactionName, + status extends StoredTransactionStatus = StoredTransactionStatus, +> = StoredTransactionIdentifiers & { + name: name + data: TransactionData + status: status + currentHash: Hash | null + transactionType: StoredTransactionType | null + + submission?: + | { + input: Hex + timestamp: number + nonce: number + } + | { + timestamp: number + } + receipt?: { + // TODO(tate): idk what we need from this yet + } + search?: { + retries: number + status: 'searching' | 'found' + } +} & ( + | EmptyStoredTransaction + | WaitingForUserStoredTransaction + | PendingStoredTransaction + | SuccessStoredTransaction + | RevertedStoredTransaction + ) + +export type StoredTransaction< + status extends StoredTransactionStatus = StoredTransactionStatus, + other = {}, +> = { + [action in TransactionName]: GenericStoredTransaction & other +}[TransactionName] + +export type StoredFlow = TransactionStoreIdentifiers & { + flowId: FlowId + transactionIds: string[] + currentTransaction: number + currentStage: TransactionFlowStage + input?: GenericDataInput + intro?: TransactionIntro + resumable?: boolean + requiresManualCleanup?: boolean + autoClose?: boolean + resumeLink?: string + disableBackgroundClick?: boolean +} + +export type LastTransactionChange = StoredTransaction + +export type TransactionStoreData = { + flows: { + [flowKey: FlowKey]: StoredFlow | undefined + } + transactions: { + [transactionKey: TransactionKey]: StoredTransaction | undefined + } + lastTransactionChange: LastTransactionChange | null + current: { + flowId: string | null + chainId: SupportedChain['id'] | null + account: Address | null + _previousFlowId: string | null + } +} + +export type WritableTransactionStoreData = WritableDraft + +export type TransactionList = + StoredTransaction[] + +export type TransactionStoreFunctions = { + flow: { + helpers: { + getAllTransactionsComplete: (flow: StoredFlow) => boolean + getCanRemoveFlow: (flow: StoredFlow) => boolean + getNoTransactionsStarted: (flow: StoredFlow) => boolean + } + showInput: ( + flowId: string, + { + input, + disableBackgroundClick, + }: { input: GenericDataInput; disableBackgroundClick?: boolean }, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + start: ( + flowId: string, + flow: Omit< + StoredFlow, + 'currentStage' | 'currentTransaction' | keyof TransactionStoreIdentifiers + >, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + resume: (flowId: string, identifiersOverride?: TransactionStoreIdentifiers) => void + resumeWithCheck: ( + flowId: string, + { push }: { push: (path: string) => void }, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + getResumable: (flowId: string, identifiersOverride?: TransactionStoreIdentifiers) => boolean + cleanup: (flowId: string, identifiersOverride?: TransactionStoreIdentifiers) => void + current: { + setTransactions: ( + transactions: { + [name in TransactionName]: { + name: name + data: TransactionData + } + }[TransactionName][], + ) => void + setStage: ({ stage }: { stage: TransactionFlowStage }) => void + stop: () => void + selectedOrPrevious: () => { flow: StoredFlow | null; isPrevious: boolean } + attemptDismiss: () => void + incrementTransaction: () => void + resetTransactionIndex: () => void + getTransactions: () => TransactionList + } + } + transaction: { + setStatus: (identifiers: StoredTransactionIdentifiers, status: StoredTransactionStatus) => void + setHash: (identifiers: StoredTransactionIdentifiers, hash: Hash) => void + setSubmission: ( + identifiers: StoredTransactionIdentifiers, + submission: TransactionSubmission & Pick, + ) => void + getByStatus: (status: status) => TransactionList + getAll: () => TransactionList + } +} + +export type TransactionStore = { + _internal: TransactionStoreData +} & TransactionStoreFunctions diff --git a/src/transaction/usePreparedDataInput.ts b/src/transaction/usePreparedDataInput.ts new file mode 100644 index 000000000..ca19ca551 --- /dev/null +++ b/src/transaction/usePreparedDataInput.ts @@ -0,0 +1,30 @@ +import type { ComponentProps } from 'react' +import { useAccount } from 'wagmi' + +import { useTransactionStore } from './transactionStore' +import { DataInputComponents, type DataInputComponent, type DataInputName } from './user/input' + +type ShowDataInput = ( + flowId: string, + data: ComponentProps['data'], + options?: { + disableBackgroundClick?: boolean + }, +) => void + +export const usePreparedDataInput = (name: name) => { + const showInput = useTransactionStore((s) => s.flow.showInput) + const { address } = useAccount() + if (address) (DataInputComponents[name] as any).render.preload() + + const func: ShowDataInput = (flowId, data, options) => + showInput(flowId, { + input: { + name, + data: data as never, + }, + disableBackgroundClick: options?.disableBackgroundClick, + }) + + return func +} diff --git a/src/transaction/user/input.tsx b/src/transaction/user/input.tsx new file mode 100644 index 000000000..238f4a67b --- /dev/null +++ b/src/transaction/user/input.tsx @@ -0,0 +1,40 @@ +import dynamic from 'next/dynamic' +import { useContext, useEffect } from 'react' + +import DynamicLoadingContext from '@app/components/@molecules/TransactionDialogManager/DynamicLoadingContext' + +import TransactionLoader from '../components/TransactionLoader' +import type { Props as AdvancedEditorProps } from './input/AdvancedEditor/AdvancedEditor-flow' + +// Lazily load input components as needed +const dynamicHelper = (name: string) => + dynamic

( + () => + import( + /* webpackMode: "lazy" */ + /* webpackExclude: /\.test.tsx$/ */ + `./${name}-flow` + ), + { + loading: () => { + /* eslint-disable react-hooks/rules-of-hooks */ + const setLoading = useContext(DynamicLoadingContext) + useEffect(() => { + setLoading(true) + return () => setLoading(false) + }, [setLoading]) + return + /* eslint-enable react-hooks/rules-of-hooks */ + }, + }, + ) + +const AdvancedEditor = dynamicHelper('AdvancedEditor/AdvancedEditor') + +export const DataInputComponents = { + AdvancedEditor, +} + +export type DataInputName = keyof typeof DataInputComponents + +export type DataInputComponent = typeof DataInputComponents diff --git a/src/transaction-flow/input/AdvancedEditor/AdvancedEditor-flow.tsx b/src/transaction/user/input/AdvancedEditor/AdvancedEditor-flow.tsx similarity index 78% rename from src/transaction-flow/input/AdvancedEditor/AdvancedEditor-flow.tsx rename to src/transaction/user/input/AdvancedEditor/AdvancedEditor-flow.tsx index b7cbc4d59..9cf662d8f 100644 --- a/src/transaction-flow/input/AdvancedEditor/AdvancedEditor-flow.tsx +++ b/src/transaction/user/input/AdvancedEditor/AdvancedEditor-flow.tsx @@ -10,10 +10,13 @@ import AdvancedEditorTabContent from '@app/components/@molecules/AdvancedEditor/ import AdvancedEditorTabs from '@app/components/@molecules/AdvancedEditor/AdvancedEditorTabs' import useAdvancedEditor from '@app/hooks/useAdvancedEditor' import { useProfile } from '@app/hooks/useProfile' -import { createTransactionItem, TransactionItem } from '@app/transaction-flow/transaction' -import type { TransactionDialogPassthrough } from '@app/transaction-flow/types' +import { useTransactionStore } from '@app/transaction/transactionStore' +import type { StoredTransaction } from '@app/transaction/types' import { Profile } from '@app/types' +import type { TransactionDialogPassthrough } from '../../../components/TransactionDialogManager' +import { createTransactionItem } from '../../transaction' + const NameContainer = styled.div(({ theme }) => [ css` display: block; @@ -61,12 +64,15 @@ export type Props = { onDismiss?: () => void } & TransactionDialogPassthrough -const AdvancedEditor = ({ data, transactions = [], dispatch, onDismiss }: Props) => { +const AdvancedEditor = ({ data, transactions = [], onDismiss }: Props) => { const { t } = useTranslation('profile') const name = data?.name || '' const transaction = transactions.find( - (item: TransactionItem) => item.name === 'updateProfile', - ) as TransactionItem<'updateProfile'> + (item: StoredTransaction): item is Extract => + item.name === 'updateProfile', + ) + const setTransactions = useTransactionStore((s) => s.flow.current.setTransactions) + const setStage = useTransactionStore((s) => s.flow.current.setStage) const { data: fetchedProfile, isLoading: isProfileLoading } = useProfile({ name }) const [profile, setProfile] = useState(undefined) @@ -80,19 +86,16 @@ const AdvancedEditor = ({ data, transactions = [], dispatch, onDismiss }: Props) const handleCreateTransaction = useCallback( (records: RecordOptions) => { - dispatch({ - name: 'setTransactions', - payload: [ - createTransactionItem('updateProfile', { - name, - resolverAddress: fetchedProfile!.resolverAddress!, - records, - }), - ], - }) - dispatch({ name: 'setFlowStage', payload: 'transaction' }) + setTransactions([ + createTransactionItem('updateProfile', { + name, + resolverAddress: fetchedProfile!.resolverAddress!, + records, + }), + ]) + setStage({ stage: 'transaction' }) }, - [fetchedProfile, dispatch, name], + [fetchedProfile, setTransactions, setStage, name], ) const advancedEditorForm = useAdvancedEditor({ diff --git a/src/transaction-flow/input/AdvancedEditor/AdvancedEditor.test.tsx b/src/transaction/user/input/AdvancedEditor/AdvancedEditor.test.tsx similarity index 100% rename from src/transaction-flow/input/AdvancedEditor/AdvancedEditor.test.tsx rename to src/transaction/user/input/AdvancedEditor/AdvancedEditor.test.tsx diff --git a/src/transaction/user/input/CreateSubname-flow.tsx b/src/transaction/user/input/CreateSubname-flow.tsx new file mode 100644 index 000000000..a025c8aed --- /dev/null +++ b/src/transaction/user/input/CreateSubname-flow.tsx @@ -0,0 +1,113 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' + +import { validateName } from '@ensdomains/ensjs/utils' +import { Button, Dialog, Input } from '@ensdomains/thorin' + +import useDebouncedCallback from '@app/hooks/useDebouncedCallback' + +import { useValidateSubnameLabel } from '../../hooks/useValidateSubnameLabel' +import { createTransactionItem } from '../transaction' +import { TransactionDialogPassthrough } from '../types' + +type Data = { + parent: string + isWrapped: boolean +} + +export type Props = { + data: Data +} & TransactionDialogPassthrough + +const ParentLabel = styled.div( + ({ theme }) => css` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: ${theme.space['48']}; + `, +) + +const CreateSubname = ({ data: { parent, isWrapped }, dispatch, onDismiss }: Props) => { + const { t } = useTranslation('profile') + + const [label, setLabel] = useState('') + const [_label, _setLabel] = useState('') + + const debouncedSetLabel = useDebouncedCallback(setLabel, 500) + + const { + valid, + error, + expiryLabel, + isLoading: isUseValidateSubnameLabelLoading, + } = useValidateSubnameLabel({ name: parent, label, isWrapped }) + + const isLabelsInsync = label === _label + const isLoading = isUseValidateSubnameLabelLoading || !isLabelsInsync + + const handleSubmit = () => { + dispatch({ + name: 'setTransactions', + payload: [ + createTransactionItem('createSubname', { + contract: isWrapped ? 'nameWrapper' : 'registry', + label, + parent, + }), + ], + }) + dispatch({ + name: 'setFlowStage', + payload: 'transaction', + }) + } + + return ( + <> + + + .{parent}} + value={_label} + onChange={(e) => { + try { + const normalised = validateName(e.target.value) + _setLabel(normalised) + debouncedSetLabel(normalised) + } catch { + _setLabel(e.target.value) + debouncedSetLabel(e.target.value) + } + }} + error={ + error + ? t(`details.tabs.subnames.addSubname.dialog.error.${error}`, { date: expiryLabel }) + : undefined + } + /> + + + {t('action.cancel', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} + +export default CreateSubname diff --git a/src/transaction/user/input/DeleteEmancipatedSubnameWarning/DeleteEmancipatedSubnameWarning-flow.tsx b/src/transaction/user/input/DeleteEmancipatedSubnameWarning/DeleteEmancipatedSubnameWarning-flow.tsx new file mode 100644 index 000000000..a305a3c90 --- /dev/null +++ b/src/transaction/user/input/DeleteEmancipatedSubnameWarning/DeleteEmancipatedSubnameWarning-flow.tsx @@ -0,0 +1,86 @@ +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' + +import { Button, Dialog, mq } from '@ensdomains/thorin' + +import { useWrapperData } from '@app/hooks/ensjs/public/useWrapperData' +import { TransactionDialogPassthrough } from '@app/transaction-flow/types' + +import { createTransactionItem } from '../../transaction/index' +import { CenterAlignedTypography } from '../RevokePermissions/components/CenterAlignedTypography' + +const MessageContainer = styled(CenterAlignedTypography)(({ theme }) => [ + css` + width: 100%; + `, + mq.sm.min(css` + width: calc(80vw - 2 * ${theme.space['6']}); + max-width: ${theme.space['128']}; + `), +]) + +type Data = { + name: string +} + +export type Props = { + data: Data +} & TransactionDialogPassthrough + +const DeleteEmancipatedSubnameWarning = ({ data, dispatch, onDismiss }: Props) => { + const { t } = useTranslation('transactionFlow') + + const { data: wrapperData, isLoading } = useWrapperData({ name: data.name }) + const expiryStr = wrapperData?.expiry?.date + ? wrapperData.expiry.date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + : undefined + const expiryLabel = expiryStr ? ` (${expiryStr})` : '' + + const handleDelete = () => { + dispatch({ + name: 'setTransactions', + payload: [ + createTransactionItem('deleteSubname', { + name: data.name, + contract: 'nameWrapper', + method: 'setRecord', + }), + ], + }) + dispatch({ name: 'setFlowStage', payload: 'transaction' }) + } + + return ( + <> + + + + {t('input.deleteEmancipatedSubnameWarning.message', { date: expiryLabel })} + + + + {t('action.cancel', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} + +export default DeleteEmancipatedSubnameWarning diff --git a/src/transaction/user/input/DeleteSubnameNotParentWarning/DeleteSubnameNotParentWarning-flow.tsx b/src/transaction/user/input/DeleteSubnameNotParentWarning/DeleteSubnameNotParentWarning-flow.tsx new file mode 100644 index 000000000..0a2b91ac0 --- /dev/null +++ b/src/transaction/user/input/DeleteSubnameNotParentWarning/DeleteSubnameNotParentWarning-flow.tsx @@ -0,0 +1,100 @@ +import { Trans, useTranslation } from 'react-i18next' +import { Address } from 'viem' + +import { Button, Dialog } from '@ensdomains/thorin' + +import { usePrimaryNameOrAddress } from '@app/hooks/reverseRecord/usePrimaryNameOrAddress' +import { useNameDetails } from '@app/hooks/useNameDetails' +import { useOwners } from '@app/hooks/useOwners' +import { createTransactionItem } from '@app/transaction-flow/transaction' +import TransactionLoader from '@app/transaction-flow/TransactionLoader' +import { TransactionDialogPassthrough } from '@app/transaction-flow/types' +import { parentName } from '@app/utils/name' + +import { CenterAlignedTypography } from '../RevokePermissions/components/CenterAlignedTypography' + +type Data = { + name: string + contract: 'registry' | 'nameWrapper' +} + +export type Props = { + data: Data +} & TransactionDialogPassthrough + +const DeleteSubnameNotParentWarning = ({ data, dispatch, onDismiss }: Props) => { + const { t } = useTranslation('transactionFlow') + + const { + ownerData: parentOwnerData, + wrapperData: parentWrapperData, + dnsOwner, + isLoading: parentBasicLoading, + } = useNameDetails({ name: parentName(data.name) }) + + const [ownerTarget] = useOwners({ + ownerData: parentOwnerData!, + wrapperData: parentWrapperData!, + dnsOwner, + }) + const { data: parentPrimaryOrAddress, isLoading: parentPrimaryLoading } = usePrimaryNameOrAddress( + { + address: ownerTarget?.address as Address, + enabled: !!ownerTarget, + }, + ) + const isLoading = parentBasicLoading || parentPrimaryLoading + + const handleDelete = () => { + dispatch({ + name: 'setTransactions', + payload: [ + createTransactionItem('deleteSubname', { + name: data.name, + contract: data.contract, + method: 'setRecord', + }), + ], + }) + dispatch({ name: 'setFlowStage', payload: 'transaction' }) + } + + if (isLoading) return + + return ( + <> + + + + }} + values={{ + ownershipTerm: t(ownerTarget.label, { ns: 'common' }).toLocaleLowerCase(), + parentOwner: parentPrimaryOrAddress.nameOrAddr, + }} + /> + + + + {t('action.cancel', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} + +export default DeleteSubnameNotParentWarning diff --git a/src/transaction/user/input/EditResolver/EditResolver-flow.tsx b/src/transaction/user/input/EditResolver/EditResolver-flow.tsx new file mode 100644 index 000000000..06da8274f --- /dev/null +++ b/src/transaction/user/input/EditResolver/EditResolver-flow.tsx @@ -0,0 +1,77 @@ +import { useCallback, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { Address } from 'viem' + +import { Button, Dialog } from '@ensdomains/thorin' + +import EditResolverForm from '@app/components/@molecules/EditResolver/EditResolverForm' +import { useIsWrapped } from '@app/hooks/useIsWrapped' +import { useProfile } from '@app/hooks/useProfile' +import useResolverEditor from '@app/hooks/useResolverEditor' +import { TransactionDialogPassthrough } from '@app/transaction-flow/types' + +import { createTransactionItem } from '../../transaction' + +type Data = { + name: string +} + +export type Props = { + data: Data +} & TransactionDialogPassthrough + +export const EditResolver = ({ data, dispatch, onDismiss }: Props) => { + const { t } = useTranslation('transactionFlow') + + const { name } = data + const { data: isWrapped } = useIsWrapped({ name }) + const formRef = useRef(null) + + const { data: profile = { resolverAddress: '' } } = useProfile({ name: name as string }) + const { resolverAddress } = profile + + const handleCreateTransaction = useCallback( + (newResolver: Address) => { + dispatch({ + name: 'setTransactions', + payload: [ + createTransactionItem('updateResolver', { + name, + contract: isWrapped ? 'nameWrapper' : 'registry', + resolverAddress: newResolver, + }), + ], + }) + dispatch({ name: 'setFlowStage', payload: 'transaction' }) + }, + [dispatch, name, isWrapped], + ) + + const editResolverForm = useResolverEditor({ resolverAddress, callback: handleCreateTransaction }) + const { hasErrors } = editResolverForm + + const handleSubmitForm = () => { + formRef.current?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })) + } + + return ( + <> + + + + {t('action.cancel', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} + +export default EditResolver diff --git a/src/transaction/user/input/EditRoles/EditRoles-flow.tsx b/src/transaction/user/input/EditRoles/EditRoles-flow.tsx new file mode 100644 index 000000000..71c3e982b --- /dev/null +++ b/src/transaction/user/input/EditRoles/EditRoles-flow.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { match, P } from 'ts-pattern' +import { Address } from 'viem' + +import { useAbilities } from '@app/hooks/abilities/useAbilities' +import { useAccountSafely } from '@app/hooks/account/useAccountSafely' +import useRoles, { Role, RoleRecord } from '@app/hooks/ownership/useRoles/useRoles' +import { getAvailableRoles } from '@app/hooks/ownership/useRoles/utils/getAvailableRoles' +import { useBasicName } from '@app/hooks/useBasicName' +import { createTransactionItem, TransactionItem } from '@app/transaction-flow/transaction' +import { makeTransferNameOrSubnameTransactionItem } from '@app/transaction-flow/transaction/utils/makeTransferNameOrSubnameTransactionItem' +import { TransactionDialogPassthrough } from '@app/transaction-flow/types' + +import { EditRoleView } from './views/EditRoleView/EditRoleView' +import { MainView } from './views/MainView/MainView' + +export type EditRolesForm = { + roles: RoleRecord[] +} + +type Data = { + name: string +} + +export type Props = { + data: Data +} & TransactionDialogPassthrough + +const EditRoles = ({ data: { name }, dispatch, onDismiss }: Props) => { + const [selectedRoleIndex, setSelectedRoleIndex] = useState(null) + + const roles = useRoles(name) + const abilities = useAbilities({ name }) + const basic = useBasicName({ name }) + const account = useAccountSafely() + const isLoading = roles.isLoading || abilities.isLoading || basic.isLoading + + const form = useForm({ + defaultValues: { + roles: [], + }, + }) + + // Set form data when data is loaded and prevent reload on modal refresh + const [isLoaded, setIsLoaded] = useState(false) + useEffect(() => { + if (roles.data && abilities.data && !isLoading && !isLoaded) { + const availableRoles = getAvailableRoles({ + roles: roles.data, + abilities: abilities.data, + }) + form.reset({ roles: availableRoles }) + setIsLoaded(true) + } + }, [isLoading, roles.data, abilities.data, form, isLoaded]) + + const onSubmit = () => { + const dirtyValues = form + .getValues('roles') + .filter((_, i) => { + return form.getFieldState(`roles.${i}.address`)?.isDirty + }) + .reduce<{ [key in Role]?: Address }>((acc, { role, address }) => { + return { + ...acc, + [role]: address, + } + }, {}) + + const isOwnerOrManager = [basic.ownerData?.owner, basic.ownerData?.registrant].includes( + account.address, + ) + const transactions = [ + dirtyValues['eth-record'] + ? createTransactionItem('updateEthAddress', { name, address: dirtyValues['eth-record'] }) + : null, + dirtyValues.manager + ? makeTransferNameOrSubnameTransactionItem({ + name, + newOwnerAddress: dirtyValues.manager, + sendType: 'sendManager', + isOwnerOrManager, + abilities: abilities.data, + }) + : null, + dirtyValues.owner + ? makeTransferNameOrSubnameTransactionItem({ + name, + newOwnerAddress: dirtyValues.owner, + sendType: 'sendOwner', + isOwnerOrManager, + abilities: abilities.data, + }) + : null, + ].filter( + ( + t, + ): t is + | TransactionItem<'transferName'> + | TransactionItem<'transferSubname'> + | TransactionItem<'updateEthAddress'> => !!t, + ) + + dispatch({ + name: 'setTransactions', + payload: transactions, + }) + + dispatch({ + name: 'setFlowStage', + payload: 'transaction', + }) + } + + return ( + + {match(selectedRoleIndex) + .with(P.number, (index) => ( + { + form.trigger() + setSelectedRoleIndex(null) + }} + /> + )) + .otherwise(() => ( + setSelectedRoleIndex(index)} + onCancel={onDismiss} + onSubmit={form.handleSubmit(onSubmit)} + /> + ))} + + ) +} + +export default EditRoles diff --git a/src/transaction/user/input/EditRoles/EditRoles.test.tsx b/src/transaction/user/input/EditRoles/EditRoles.test.tsx new file mode 100644 index 000000000..92209f244 --- /dev/null +++ b/src/transaction/user/input/EditRoles/EditRoles.test.tsx @@ -0,0 +1,243 @@ +import { render, screen, userEvent, waitFor, within } from '@app/test-utils' + +import { beforeAll, describe, expect, it, vi } from 'vitest' + +import EditRoles from './EditRoles-flow' + +vi.mock('@app/hooks/account/useAccountSafely', () => ({ + useAccountSafely: () => ({ address: '0xowner' }), +})) + +vi.mock('@app/hooks/useBasicName', () => ({ + useBasicName: () => ({ + ownerData: { + owner: '0xmanager', + registrant: '0xowner', + }, + isLoading: false, + }), +})) + +vi.mock('@app/hooks/ownership/useRoles/useRoles', () => ({ + default: () => ({ + data: [ + { + role: 'owner', + address: '0xowner', + }, + { + role: 'manager', + address: '0xmanager', + }, + { + role: 'eth-record', + address: '0xeth-record', + }, + { + role: 'parent-owner', + address: '0xparent-address', + }, + { + role: 'dns-owner', + address: '0xdns-owner', + }, + ], + isLoading: false, + }), +})) + +vi.mock('@app/hooks/abilities/useAbilities', () => ({ + useAbilities: () => ({ + data: { + canSendOwner: true, + canSendManager: true, + canEditRecords: true, + sendNameFunctionCallDetails: { + sendManager: { + contract: 'registrar', + method: 'reclaim', + }, + sendOwner: { + contract: 'contract', + }, + }, + }, + isLoading: false, + }), +})) + +let searchData: any[] = [] +vi.mock('@app/transaction-flow/input/EditRoles/hooks/useSimpleSearch.ts', () => ({ + useSimpleSearch: () => ({ + mutate: (query: string) => { + searchData = [{ name: `${query}.eth`, address: `0x${query}` }] + }, + data: searchData, + isLoading: false, + isSuccess: true, + }), +})) + +vi.mock('@app/components/@molecules/AvatarWithIdentifier/AvatarWithIdentifier', () => ({ + AvatarWithIdentifier: ({ name, address }: any) => ( +

+ {name} + {address} +
+ ), +})) + +const mockDispatch = vi.fn() + +beforeAll(() => { + const spyiedScroll = vi.spyOn(window, 'scroll') + spyiedScroll.mockImplementation(() => {}) + window.IntersectionObserver = vi.fn().mockReturnValue({ + observe: () => null, + unobserve: () => null, + disconnect: () => null, + }) +}) + +describe('EditRoles', () => { + it('should dispatch a transaction for each role changed', async () => { + render( {}} />) + await userEvent.click( + within(screen.getByTestId('role-card-owner')).getByTestId('role-card-change-button'), + ) + await userEvent.type(screen.getByTestId('edit-roles-search-input'), 'nick') + await waitFor(() => { + expect(screen.getByTestId('search-result-0xnick')).toBeVisible() + }) + await userEvent.click(screen.getByTestId('search-result-0xnick')) + + await userEvent.click( + within(screen.getByTestId('role-card-manager')).getByTestId('role-card-change-button'), + ) + await userEvent.type(screen.getByTestId('edit-roles-search-input'), 'nick') + await waitFor(() => { + expect(screen.getByTestId('search-result-0xnick')).toBeVisible() + }) + await userEvent.click(screen.getByTestId('search-result-0xnick')) + + await userEvent.click( + within(screen.getByTestId('role-card-eth-record')).getByTestId('role-card-change-button'), + ) + await userEvent.type(screen.getByTestId('edit-roles-search-input'), 'nick') + await waitFor(() => { + expect(screen.getByTestId('search-result-0xnick')).toBeVisible() + }) + await userEvent.click(screen.getByTestId('search-result-0xnick')) + await waitFor(() => { + expect(screen.getByTestId('edit-roles-save-button')).toBeVisible() + }) + await userEvent.click(screen.getByTestId('edit-roles-save-button')) + expect(mockDispatch).toHaveBeenCalledWith({ + name: 'setTransactions', + payload: [ + { + data: { + address: '0xnick', + name: 'test.eth', + }, + name: 'updateEthAddress', + }, + { + data: { + contract: 'registrar', + name: 'test.eth', + newOwnerAddress: '0xnick', + reclaim: true, + sendType: 'sendManager', + }, + name: 'transferName', + }, + { + data: { + contract: 'contract', + name: 'test.eth', + newOwnerAddress: '0xnick', + sendType: 'sendOwner', + }, + name: 'transferName', + }, + ], + }) + }) + + it('should not be able to set a role to the existing address', async () => { + render( {}} />) + await userEvent.click( + within(screen.getByTestId('role-card-owner')).getByTestId('role-card-change-button'), + ) + await userEvent.type(screen.getByTestId('edit-roles-search-input'), 'owner') + await waitFor(() => { + expect(screen.getByTestId('search-result-0xowner')).toBeDisabled() + }) + await userEvent.click(screen.getByRole('button', { name: 'action.cancel' })) + + await userEvent.click( + within(screen.getByTestId('role-card-manager')).getByTestId('role-card-change-button'), + ) + await userEvent.type(screen.getByTestId('edit-roles-search-input'), 'manager') + await waitFor(() => { + expect(screen.getByTestId('search-result-0xmanager')).toBeDisabled() + }) + await userEvent.click(screen.getByRole('button', { name: 'action.cancel' })) + + await userEvent.click( + within(screen.getByTestId('role-card-eth-record')).getByTestId('role-card-change-button'), + ) + await userEvent.type(screen.getByTestId('edit-roles-search-input'), 'eth-record') + await waitFor(() => { + expect(screen.getByTestId('search-result-0xeth-record')).toBeDisabled() + }) + await userEvent.click(screen.getByRole('button', { name: 'action.cancel' })) + }) + + it('should show shortcuts for setting to self or setting to 0x0', async () => { + render( {}} />) + // Change owner first + await userEvent.click( + within(screen.getByTestId('role-card-owner')).getByTestId('role-card-change-button'), + ) + await userEvent.type(screen.getByTestId('edit-roles-search-input'), 'dave') + await waitFor(() => { + expect(screen.getByTestId('search-result-0xdave')).toBeVisible() + }) + await userEvent.click(screen.getByTestId('search-result-0xdave')) + + // Change owner should not have any shortcuts + await userEvent.click( + within(screen.getByTestId('role-card-owner')).getByTestId('role-card-change-button'), + ) + expect(screen.queryByTestId('edit-roles-set-to-self-button')).toEqual(null) + expect(screen.queryByRole('button', { name: 'action.remove' })).toEqual(null) + await userEvent.click(screen.getByRole('button', { name: 'action.cancel' })) + + // Manager set to self + await userEvent.click( + within(screen.getByTestId('role-card-manager')).getByTestId('role-card-change-button'), + ) + await userEvent.click(screen.getByTestId('edit-roles-set-to-self-button')) + expect(within(screen.getByTestId('role-card-manager')).getByText('0xowner')).toBeVisible() + + // Eth-record set to self + await userEvent.click( + within(screen.getByTestId('role-card-eth-record')).getByTestId('role-card-change-button'), + ) + await userEvent.click(screen.getByTestId('edit-roles-set-to-self-button')) + expect(within(screen.getByTestId('role-card-eth-record')).getByText('0xowner')).toBeVisible() + + // Eth-record remove + await userEvent.click( + within(screen.getByTestId('role-card-eth-record')).getByTestId('role-card-change-button'), + ) + await userEvent.click(screen.getByRole('button', { name: 'action.remove' })) + expect( + within(screen.getByTestId('role-card-eth-record')).getByText( + 'input.editRoles.views.main.noneSet', + ), + ).toBeVisible() + }) +}) diff --git a/src/transaction/user/input/EditRoles/hooks/useSimpleSearch.ts b/src/transaction/user/input/EditRoles/hooks/useSimpleSearch.ts new file mode 100644 index 000000000..6b8cf107d --- /dev/null +++ b/src/transaction/user/input/EditRoles/hooks/useSimpleSearch.ts @@ -0,0 +1,112 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' +import { Address, isAddress } from 'viem' +import { useChainId, useConfig } from 'wagmi' + +import { getAddressRecord, getName } from '@ensdomains/ensjs/public' +import { normalise } from '@ensdomains/ensjs/utils' + +import useDebouncedCallback from '@app/hooks/useDebouncedCallback' +import { ClientWithEns } from '@app/types' + +type Result = { name?: string; address: Address } +type Options = { cache?: boolean } + +type QueryByNameParams = { + name: string +} + +const queryByName = async ( + client: ClientWithEns, + { name }: QueryByNameParams, +): Promise => { + try { + const normalisedName = normalise(name) + const record = await getAddressRecord(client, { name: normalisedName }) + const address = record?.value as Address + if (!address) throw new Error('No address found') + return { + name: normalisedName, + address, + } + } catch { + return null + } +} + +type QueryByAddressParams = { address: Address } + +const queryByAddress = async ( + client: ClientWithEns, + { address }: QueryByAddressParams, +): Promise => { + try { + const name = await getName(client, { address }) + return { + name: name?.name, + address, + } + } catch { + return null + } +} + +const createQueryKeyWithChain = (chainId: number) => (query: string) => [ + 'simpleSearch', + chainId, + query, +] + +export const useSimpleSearch = (options: Options = {}) => { + const cache = options.cache ?? true + + const queryClient = useQueryClient() + const chainId = useChainId() + const createQueryKey = createQueryKeyWithChain(chainId) + const config = useConfig() + + useEffect(() => { + return () => { + queryClient.removeQueries({ queryKey: ['simpleSearch'], exact: false }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const { mutate, isPending, ...rest } = useMutation({ + mutationFn: async (query: string) => { + if (query.length < 3) throw new Error('Query too short') + if (cache) { + const cachedData = queryClient.getQueryData(createQueryKey(query)) + if (cachedData) return cachedData + } + const client = config.getClient({ chainId }) + const results = await Promise.allSettled([ + queryByName(client, { name: query }), + ...(isAddress(query) ? [queryByAddress(client, { address: query })] : []), + ]) + const filteredData = results + .filter>( + (item): item is PromiseFulfilledResult => + item.status === 'fulfilled' && !!item.value, + ) + .map((item) => item.value) + .reduce((acc, cur) => { + return { + ...acc, + [cur.address]: cur, + } + }, {}) + return Object.values(filteredData) as Result[] + }, + onSuccess: (data, variables) => { + queryClient.setQueryData(createQueryKey(variables), data) + }, + }) + const debouncedMutate = useDebouncedCallback(mutate, 500) + + return { + ...rest, + mutate: debouncedMutate, + isLoading: isPending || !chainId, + } +} diff --git a/src/transaction/user/input/EditRoles/views/EditRoleView/EditRoleView.tsx b/src/transaction/user/input/EditRoles/views/EditRoleView/EditRoleView.tsx new file mode 100644 index 000000000..a021149f6 --- /dev/null +++ b/src/transaction/user/input/EditRoles/views/EditRoleView/EditRoleView.tsx @@ -0,0 +1,116 @@ +import { useState } from 'react' +import { useFieldArray, useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' +import { match, P } from 'ts-pattern' + +import { Button, Dialog, Input, MagnifyingGlassSimpleSVG, mq } from '@ensdomains/thorin' + +import { DialogFooterWithBorder } from '@app/components/@molecules/DialogComponentVariants/DialogFooterWithBorder' +import { SearchViewErrorView } from '@app/transaction-flow/input/SendName/views/SearchView/views/SearchViewErrorView' +import { SearchViewLoadingView } from '@app/transaction-flow/input/SendName/views/SearchView/views/SearchViewLoadingView' +import { SearchViewNoResultsView } from '@app/transaction-flow/input/SendName/views/SearchView/views/SearchViewNoResultsView' + +import type { EditRolesForm } from '../../EditRoles-flow' +import { useSimpleSearch } from '../../hooks/useSimpleSearch' +import { EditRoleIntroView } from './views/EditRoleIntroView' +import { EditRoleResultsView } from './views/EditRoleResultsView' + +const InputWrapper = styled.div(({ theme }) => [ + css` + flex: 0; + width: 100%; + display: flex; + flex-direction: column; + margin-bottom: -${theme.space['4']}; + `, + mq.sm.min(css` + margin-bottom: -${theme.space['6']}; + `), +]) + +type Props = { + index: number + onBack: () => void +} + +export const EditRoleView = ({ index, onBack }: Props) => { + const { t } = useTranslation('transactionFlow') + + const [query, setQuery] = useState('') + const search = useSimpleSearch() + + const { control } = useFormContext() + const { fields: roles, update } = useFieldArray({ control, name: 'roles' }) + const currentRole = roles[index] + + return ( + <> + + + } + clearable + value={query} + placeholder={t('input.sendName.views.search.placeholder')} + onChange={(e) => { + const newQuery = e.currentTarget.value + setQuery(newQuery) + if (newQuery.length < 3) return + search.mutate(newQuery) + }} + /> + + + {match([query, search]) + .with([P._, { isError: true }], () => ) + .with([P.when((s) => s.length < 3), P._], () => ( + { + onBack() + update(index, newRole) + }} + /> + )) + .with([P._, { isSuccess: false }], () => ) + .with( + [P._, { isSuccess: true, data: P.when((d) => !!d && d.length > 0) }], + ([, { data }]) => ( + { + onBack() + update(index, newRole) + }} + /> + ), + ) + .with([P._, { isSuccess: true, data: P.when((d) => !d || d.length === 0) }], () => ( + + )) + .otherwise(() => null)} + + + {t('action.cancel', { ns: 'common' })} + + } + /> + + ) +} diff --git a/src/transaction/user/input/EditRoles/views/EditRoleView/views/EditRoleIntroView.tsx b/src/transaction/user/input/EditRoles/views/EditRoleView/views/EditRoleIntroView.tsx new file mode 100644 index 000000000..546b64a01 --- /dev/null +++ b/src/transaction/user/input/EditRoles/views/EditRoleView/views/EditRoleIntroView.tsx @@ -0,0 +1,106 @@ +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' +import { Address } from 'viem' + +import { Button, mq } from '@ensdomains/thorin' + +import { AvatarWithIdentifier } from '@app/components/@molecules/AvatarWithIdentifier/AvatarWithIdentifier' +import { useAccountSafely } from '@app/hooks/account/useAccountSafely' +import type { Role } from '@app/hooks/ownership/useRoles/useRoles' +import { SearchViewIntroView } from '@app/transaction-flow/input/SendName/views/SearchView/views/SearchViewIntroView' +import { emptyAddress } from '@app/utils/constants' + +const SHOW_REMOVE_ROLES: Role[] = ['eth-record'] +const SHOW_SET_TO_SELF_ROLES: Role[] = ['manager', 'eth-record'] + +const Row = styled.div(({ theme }) => [ + css` + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: ${theme.space['4']}; + padding: ${theme.space['4']}; + border-bottom: 1px solid ${theme.colors.border}; + + > *:first-child { + flex: 1; + } + + > *:last-child { + flex: 0 0 ${theme.space['24']}; + } + `, + mq.sm.min(css` + padding: ${theme.space['4']} ${theme.space['6']}; + `), +]) + +const Container = styled.div( + ({ theme }) => css` + width: 100%; + height: 100%; + min-height: ${theme.space['40']}; + `, +) + +type Props = { + role: Role + address?: Address | null + onSelect: (role: { role: Role; address: Address }) => void +} + +export const EditRoleIntroView = ({ role, address, onSelect }: Props) => { + const { t } = useTranslation('transactionFlow') + const account = useAccountSafely() + + const showRemove = SHOW_REMOVE_ROLES.includes(role) && !!address && address !== emptyAddress + const showSetToSelf = SHOW_SET_TO_SELF_ROLES.includes(role) && account.address !== address + const showIntro = showRemove || showSetToSelf + + if (!account.address) return null + return ( + + {showIntro ? ( + <> + {showRemove && ( + + + + + )} + {showSetToSelf && ( + + + + + )} + + ) : ( + + )} + + ) +} diff --git a/src/transaction/user/input/EditRoles/views/EditRoleView/views/EditRoleResultsView.tsx b/src/transaction/user/input/EditRoles/views/EditRoleView/views/EditRoleResultsView.tsx new file mode 100644 index 000000000..9eb358b09 --- /dev/null +++ b/src/transaction/user/input/EditRoles/views/EditRoleView/views/EditRoleResultsView.tsx @@ -0,0 +1,45 @@ +import styled, { css } from 'styled-components' +import { Address } from 'viem' + +import { RoleRecord, type Role } from '@app/hooks/ownership/useRoles/useRoles' +import { SearchViewResult } from '@app/transaction-flow/input/SendName/views/SearchView/components/SearchViewResult' + +import type { useSimpleSearch } from '../../../hooks/useSimpleSearch' + +const Container = styled.div( + ({ theme }) => css` + width: 100%; + height: 100%; + min-height: ${theme.space['40']}; + display: flex; + flex-direction: column; + `, +) + +type Props = { + role: Role + roles: RoleRecord[] + results: ReturnType['data'] + onSelect: (role: { role: Role; address: Address }) => void +} + +export const EditRoleResultsView = ({ role, roles, onSelect, results = [] }: Props) => { + return ( + + {results.map(({ name, address }) => { + return ( + { + onSelect({ role, address }) + }} + /> + ) + })} + + ) +} diff --git a/src/transaction/user/input/EditRoles/views/MainView/MainView.tsx b/src/transaction/user/input/EditRoles/views/MainView/MainView.tsx new file mode 100644 index 000000000..f1d4f9516 --- /dev/null +++ b/src/transaction/user/input/EditRoles/views/MainView/MainView.tsx @@ -0,0 +1,68 @@ +import { useRef } from 'react' +import { useFieldArray, useFormContext, useFormState } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { Button, Dialog } from '@ensdomains/thorin' + +import { DialogFooterWithBorder } from '@app/components/@molecules/DialogComponentVariants/DialogFooterWithBorder' +import { DialogHeadingWithBorder } from '@app/components/@molecules/DialogComponentVariants/DialogHeadinWithBorder' + +import type { EditRolesForm } from '../../EditRoles-flow' +import { RoleCard } from './components/RoleCard' + +type Props = { + onSelectIndex: (index: number) => void + onCancel: () => void + onSubmit: () => void +} + +export const MainView = ({ onSelectIndex, onCancel, onSubmit }: Props) => { + const { t } = useTranslation() + const { control } = useFormContext() + const { fields: roles } = useFieldArray({ control, name: 'roles' }) + const formState = useFormState({ control, name: 'roles' }) + + const ref = useRef(null) + + // Bug in react-hook-form where isDirty is not always update when using field array. + // Manually handle the check instead. + const isDirty = !!formState.dirtyFields?.roles?.some((role) => !!role.address) + + return ( + <> + + +
+ {roles.map((role, index) => ( + onSelectIndex?.(index)} + /> + ))} +
+ + onCancel()}> + {t('action.cancel')} + + } + trailing={ + + } + /> + + ) +} diff --git a/src/transaction/user/input/EditRoles/views/MainView/components/NoneSetAvatarWithIdentifier.tsx b/src/transaction/user/input/EditRoles/views/MainView/components/NoneSetAvatarWithIdentifier.tsx new file mode 100644 index 000000000..ed886b5dc --- /dev/null +++ b/src/transaction/user/input/EditRoles/views/MainView/components/NoneSetAvatarWithIdentifier.tsx @@ -0,0 +1,55 @@ +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' + +import { mq, Space, Typography } from '@ensdomains/thorin' + +import { QuerySpace } from '@app/types' + +const Wrapper = styled.div<{ $size?: QuerySpace; $dirty?: boolean }>( + ({ theme, $size, $dirty }) => css` + background: ${$dirty ? theme.colors.greenLight : theme.colors.border}; + border-radius: ${theme.radii.full}; + + ${typeof $size === 'object' && + css` + width: ${theme.space[$size.min]}; + height: ${theme.space[$size.min]}; + `} + ${typeof $size !== 'object' + ? css` + width: ${$size ? theme.space[$size] : theme.space.full}; + height: ${$size ? theme.space[$size] : theme.space.full}; + ` + : Object.entries($size) + .filter(([key]) => key !== 'min') + .map(([key, value]) => + mq[key as keyof typeof mq].min(css` + width: ${theme.space[value as Space]}; + height: ${theme.space[value as Space]}; + `), + )} + `, +) + +const Container = styled.div( + ({ theme }) => css` + display: flex; + align-items: center; + gap: ${theme.space[2]}; + `, +) + +type Props = { + dirty?: boolean + size?: QuerySpace +} + +export const NoneSetAvatarWithIdentifier = ({ dirty = false, size = '10' }: Props) => { + const { t } = useTranslation('transactionFlow') + return ( + + + {t('input.editRoles.views.main.noneSet')} + + ) +} diff --git a/src/transaction/user/input/EditRoles/views/MainView/components/RoleCard.tsx b/src/transaction/user/input/EditRoles/views/MainView/components/RoleCard.tsx new file mode 100644 index 000000000..2fed90b78 --- /dev/null +++ b/src/transaction/user/input/EditRoles/views/MainView/components/RoleCard.tsx @@ -0,0 +1,134 @@ +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' +import { Address } from 'viem' + +import { RightArrowSVG, Typography } from '@ensdomains/thorin' + +import { AvatarWithIdentifier } from '@app/components/@molecules/AvatarWithIdentifier/AvatarWithIdentifier' +import type { Role } from '@app/hooks/ownership/useRoles/useRoles' +import { emptyAddress } from '@app/utils/constants' + +import { NoneSetAvatarWithIdentifier } from './NoneSetAvatarWithIdentifier' + +const InfoContainer = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: column; + gap: ${theme.space[2]}; + `, +) + +const Title = styled(Typography)( + () => css` + ::first-letter { + text-transform: capitalize; + } + `, +) + +const Divider = styled.div( + ({ theme }) => css` + border-bottom: 1px solid ${theme.colors.border}; + margin: 0 -${theme.space['4']}; + `, +) + +const Footer = styled.button( + () => css` + display: flex; + justify-content: space-between; + align-items: center; + `, +) + +const FooterRight = styled.div( + ({ theme }) => css` + display: flex; + align-items: center; + gap: ${theme.space['2']}; + color: ${theme.colors.accent}; + `, +) + +const Container = styled.div<{ $dirty?: boolean }>( + ({ theme, $dirty }) => css` + display: flex; + position: relative; + flex-direction: column; + gap: ${theme.space[4]}; + padding: ${theme.space[4]}; + border: 1px solid ${theme.colors.border}; + border-radius: ${theme.radii.large}; + width: ${theme.space.full}; + + ${$dirty && + css` + border: 1px solid ${theme.colors.greenLight}; + background: ${theme.colors.greenSurface}; + + ${Divider} { + border-bottom: 1px solid ${theme.colors.greenLight}; + } + + ::after { + content: ''; + display: block; + position: absolute; + background: ${theme.colors.green}; + width: ${theme.space[4]}; + height: ${theme.space[4]}; + border: 2px solid ${theme.colors.background}; + border-radius: 50%; + top: -${theme.space[2]}; + right: -${theme.space[2]}; + } + `} + `, +) + +type Props = { + address?: Address | null + role: Role + dirty?: boolean + onClick?: () => void +} + +export const RoleCard = ({ address, role, dirty, onClick }: Props) => { + const { t } = useTranslation('transactionFlow') + + const isAddressEmpty = !address || address === emptyAddress + return ( + + + {t(`roles.${role}.title`, { ns: 'common' })} + + {t(`roles.${role}.description`, { ns: 'common' })} + + + +
+ {isAddressEmpty ? ( + <> + + + + {t('action.add', { ns: 'common' })} + + + + + ) : ( + <> + + + + {t('action.change', { ns: 'common' })} + + + + + )} +
+
+ ) +} diff --git a/src/transaction/user/input/ExtendNames/ExtendNames-flow.test.tsx b/src/transaction/user/input/ExtendNames/ExtendNames-flow.test.tsx new file mode 100644 index 000000000..606bdbe4a --- /dev/null +++ b/src/transaction/user/input/ExtendNames/ExtendNames-flow.test.tsx @@ -0,0 +1,182 @@ +import { mockFunction, render, screen, userEvent, waitFor } from '@app/test-utils' + +import { describe, expect, it, vi } from 'vitest' + +import { useEstimateGasWithStateOverride } from '@app/hooks/chain/useEstimateGasWithStateOverride' +import { usePrice } from '@app/hooks/ensjs/public/usePrice' + +import ExtendNames from './ExtendNames-flow' +import { makeMockIntersectionObserver } from '../../../../test/mock/makeMockIntersectionObserver' + +vi.mock('@app/hooks/chain/useEstimateGasWithStateOverride') +vi.mock('@app/hooks/ensjs/public/usePrice') + +const mockUseEstimateGasWithStateOverride = mockFunction(useEstimateGasWithStateOverride) +const mockUsePrice = mockFunction(usePrice) + +vi.mock('@ensdomains/thorin', async () => { + const originalModule = await vi.importActual('@ensdomains/thorin') + return { + ...originalModule, + ScrollBox: vi.fn(({ children }) => children), + } +}) +vi.mock('@app/components/@atoms/Invoice/Invoice', async () => { + const originalModule = await vi.importActual('@app/components/@atoms/Invoice/Invoice') + return { + ...originalModule, + Invoice: vi.fn(() =>
Invoice
), + } +}) +vi.mock( + '@app/components/@atoms/RegistrationTimeComparisonBanner/RegistrationTimeComparisonBanner', + async () => { + const originalModule = await vi.importActual( + '@app/components/@atoms/RegistrationTimeComparisonBanner/RegistrationTimeComparisonBanner', + ) + return { + ...originalModule, + RegistrationTimeComparisonBanner: vi.fn(() =>
RegistrationTimeComparisonBanner
), + } + }, +) + +makeMockIntersectionObserver() + +describe('Extendnames', () => { + mockUseEstimateGasWithStateOverride.mockReturnValue({ + data: { gasEstimate: 21000n, gasCost: 100n }, + gasPrice: 100n, + error: null, + isLoading: false, + }) + mockUsePrice.mockReturnValue({ + data: { + base: 100n, + premium: 0n, + }, + isLoading: false, + }) + it('should render', async () => { + render( + null, onDismiss: () => null }} + />, + ) + }) + it('should go directly to registration if isSelf is true and names.length is 1', () => { + render( + null, + onDismiss: () => null, + }} + />, + ) + expect(screen.getByText('RegistrationTimeComparisonBanner')).toBeVisible() + }) + it('should show warning message before registration if isSelf is false and names.length is 1', async () => { + render( + null, + onDismiss: () => null, + }} + />, + ) + expect(screen.getByText('input.extendNames.ownershipWarning.description.1')).toBeVisible() + await userEvent.click(screen.getByRole('button', { name: 'action.understand' })) + await waitFor(() => expect(screen.getByText('RegistrationTimeComparisonBanner')).toBeVisible()) + }) + it('should show a list of names before registration if isSelf is true and names.length is greater than 1', async () => { + render( + null, + onDismiss: () => null, + }} + />, + ) + expect(screen.getByTestId('extend-names-names-list')).toBeVisible() + await userEvent.click(screen.getByRole('button', { name: 'action.next' })) + await waitFor(() => expect(screen.getByText('RegistrationTimeComparisonBanner')).toBeVisible()) + }) + it('should show a warning then a list of names before registration if isSelf is false and names.length is greater than 1', async () => { + render( + null, + onDismiss: () => null, + }} + />, + ) + expect(screen.getByText('input.extendNames.ownershipWarning.description.2')).toBeVisible() + await userEvent.click(screen.getByRole('button', { name: 'action.understand' })) + expect(screen.getByTestId('extend-names-names-list')).toBeVisible() + await userEvent.click(screen.getByRole('button', { name: 'action.next' })) + await waitFor(() => expect(screen.getByText('RegistrationTimeComparisonBanner')).toBeVisible()) + }) + it('should have RegistrationTimeComparisonBanner greyed out if gas limit estimation is still loading', () => { + mockUseEstimateGasWithStateOverride.mockReturnValueOnce({ + data: { gasEstimate: 21000n, gasCost: 100n }, + gasPrice: 100n, + error: null, + isLoading: true, + }) + render( + null, + onDismiss: () => null, + }} + />, + ) + const optionBar = screen.getByText('RegistrationTimeComparisonBanner') + const { parentElement } = optionBar + expect(parentElement).toHaveStyle('opacity: 0.5') + }) + it('should have Invoice greyed out if gas limit estimation is still loading', () => { + mockUseEstimateGasWithStateOverride.mockReturnValueOnce({ + data: { gasEstimate: 21000n, gasCost: 100n }, + gasPrice: 100n, + error: null, + isLoading: true, + }) + render( + null, + onDismiss: () => null, + }} + />, + ) + const optionBar = screen.getByText('Invoice') + const { parentElement } = optionBar + expect(parentElement).toHaveStyle('opacity: 0.5') + }) + it('should disabled next button if gas limit estimation is still loading', () => { + mockUseEstimateGasWithStateOverride.mockReturnValueOnce({ + data: { gasEstimate: 21000n, gasCost: 100n }, + gasPrice: 100n, + error: null, + isLoading: true, + }) + render( + null, + onDismiss: () => null, + }} + />, + ) + const trailingButton = screen.getByTestId('extend-names-confirm') + expect(trailingButton).toHaveAttribute('disabled') + }) +}) diff --git a/src/transaction/user/input/ExtendNames/ExtendNames-flow.tsx b/src/transaction/user/input/ExtendNames/ExtendNames-flow.tsx new file mode 100644 index 000000000..723d375d6 --- /dev/null +++ b/src/transaction/user/input/ExtendNames/ExtendNames-flow.tsx @@ -0,0 +1,398 @@ +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { usePreviousDistinct } from 'react-use' +import styled, { css } from 'styled-components' +import { match, P } from 'ts-pattern' +import { parseEther } from 'viem' +import { useAccount, useBalance, useEnsAvatar } from 'wagmi' + +import { Avatar, Button, CurrencyToggle, Dialog, Helper, Typography } from '@ensdomains/thorin' + +import { CacheableComponent } from '@app/components/@atoms/CacheableComponent' +import { makeCurrencyDisplay } from '@app/components/@atoms/CurrencyText/CurrencyText' +import { Invoice, InvoiceItem } from '@app/components/@atoms/Invoice/Invoice' +import { PlusMinusControl } from '@app/components/@atoms/PlusMinusControl/PlusMinusControl' +import { RegistrationTimeComparisonBanner } from '@app/components/@atoms/RegistrationTimeComparisonBanner/RegistrationTimeComparisonBanner' +import { StyledName } from '@app/components/@atoms/StyledName/StyledName' +import { DateSelection } from '@app/components/@molecules/DateSelection/DateSelection' +import { useEstimateGasWithStateOverride } from '@app/hooks/chain/useEstimateGasWithStateOverride' +import { useExpiry } from '@app/hooks/ensjs/public/useExpiry' +import { usePrice } from '@app/hooks/ensjs/public/usePrice' +import { useEthPrice } from '@app/hooks/useEthPrice' +import { useZorb } from '@app/hooks/useZorb' +import { createTransactionItem } from '@app/transaction-flow/transaction' +import { TransactionDialogPassthrough } from '@app/transaction-flow/types' +import { CURRENCY_FLUCTUATION_BUFFER_PERCENTAGE } from '@app/utils/constants' +import { ensAvatarConfig } from '@app/utils/query/ipfsGateway' +import { ONE_DAY, ONE_YEAR, secondsToYears, yearsToSeconds } from '@app/utils/time' +import useUserConfig from '@app/utils/useUserConfig' +import { deriveYearlyFee, formatDurationOfDates } from '@app/utils/utils' + +import { ShortExpiry } from '../../../components/@atoms/ExpiryComponents/ExpiryComponents' +import GasDisplay from '../../../components/@atoms/GasDisplay' + +type View = 'name-list' | 'no-ownership-warning' | 'registration' + +const PlusMinusWrapper = styled.div( + () => css` + width: 100%; + overflow: hidden; + display: flex; + `, +) + +const OptionBar = styled(CacheableComponent)( + () => css` + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + `, +) + +const NamesListItemContainer = styled.div( + ({ theme }) => css` + width: 100%; + display: flex; + align-items: center; + gap: ${theme.space['2']}; + height: ${theme.space['16']}; + border: 1px solid ${theme.colors.border}; + border-radius: ${theme.radii.full}; + padding: ${theme.space['2']}; + padding-right: ${theme.space['5']}; + `, +) + +const NamesListItemAvatarWrapper = styled.div( + ({ theme }) => css` + position: relative; + width: ${theme.space['12']}; + height: ${theme.space['12']}; + `, +) + +const NamesListItemContent = styled.div( + () => css` + flex: 1; + position: relative; + overflow: hidden; + `, +) + +const NamesListItemTitle = styled.div( + ({ theme }) => css` + font-size: ${theme.space['5.5']}; + background: 'red'; + `, +) + +const NamesListItemSubtitle = styled.div( + ({ theme }) => css` + font-weight: ${theme.fontWeights.normal}; + font-size: ${theme.space['3.5']}; + line-height: 1.43; + color: ${theme.colors.textTertiary}; + `, +) + +const GasEstimationCacheableComponent = styled(CacheableComponent)( + ({ theme }) => css` + width: 100%; + gap: ${theme.space['4']}; + display: flex; + flex-direction: column; + `, +) + +const CenteredMessage = styled(Typography)( + () => css` + text-align: center; + `, +) + +const NamesListItem = ({ name }: { name: string }) => { + const { data: avatar } = useEnsAvatar({ ...ensAvatarConfig, name }) + const zorb = useZorb(name, 'name') + const { data: expiry, isLoading: isExpiryLoading } = useExpiry({ name }) + + if (isExpiryLoading) return null + return ( + + + + + + + + + {expiry?.expiry && ( + + + + )} + + + ) +} + +const NamesListContainer = styled.div( + ({ theme }) => css` + width: 100%; + display: flex; + flex-direction: column; + gap: ${theme.space['2']}; + `, +) + +type NamesListProps = { + names: string[] +} + +const NamesList = ({ names }: NamesListProps) => { + return ( + + {names.map((name) => ( + + ))} + + ) +} + +type Data = { + names: string[] + isSelf?: boolean +} + +export type Props = { + data: Data +} & TransactionDialogPassthrough + +const minSeconds = ONE_DAY + +const ExtendNames = ({ data: { names, isSelf }, dispatch, onDismiss }: Props) => { + const { t } = useTranslation(['transactionFlow', 'common']) + const { data: ethPrice } = useEthPrice() + + const { address } = useAccount() + const { data: balance } = useBalance({ + address, + }) + + const flow: View[] = useMemo( + () => + match([names.length, isSelf]) + .with([P.when((length) => length > 1), true], () => ['name-list', 'registration'] as View[]) + .with( + [P.when((length) => length > 1), P._], + () => ['no-ownership-warning', 'name-list', 'registration'] as View[], + ) + .with([P._, true], () => ['registration'] as View[]) + .otherwise(() => ['no-ownership-warning', 'registration'] as View[]), + [names.length, isSelf], + ) + const [viewIdx, setViewIdx] = useState(0) + const incrementView = () => setViewIdx(() => Math.min(flow.length - 1, viewIdx + 1)) + const decrementView = () => (viewIdx <= 0 ? onDismiss() : setViewIdx(viewIdx - 1)) + const view = flow[viewIdx] + + const [seconds, setSeconds] = useState(ONE_YEAR) + const [durationType, setDurationType] = useState<'years' | 'date'>('years') + + const years = secondsToYears(seconds) + + const { userConfig, setCurrency } = useUserConfig() + const currencyDisplay = userConfig.currency === 'fiat' ? userConfig.fiat : 'eth' + + const { data: priceData, isLoading: isPriceLoading } = usePrice({ + nameOrNames: names, + duration: seconds, + }) + + const totalRentFee = priceData ? priceData.base + priceData.premium : 0n + const yearlyFee = priceData?.base ? deriveYearlyFee({ duration: seconds, price: priceData }) : 0n + const previousYearlyFee = usePreviousDistinct(yearlyFee) || 0n + const unsafeDisplayYearlyFee = yearlyFee !== 0n ? yearlyFee : previousYearlyFee + const isShowingPreviousYearlyFee = yearlyFee === 0n && previousYearlyFee > 0n + const { data: expiryData } = useExpiry({ enabled: names.length === 1, name: names[0] }) + const expiryDate = expiryData?.expiry?.date + const extendedDate = expiryDate ? new Date(expiryDate.getTime() + seconds * 1000) : undefined + + const transactions = [ + createTransactionItem('extendNames', { + names, + duration: seconds, + startDateTimestamp: expiryDate?.getTime(), + displayPrice: makeCurrencyDisplay({ + eth: totalRentFee, + ethPrice, + bufferPercentage: CURRENCY_FLUCTUATION_BUFFER_PERCENTAGE, + currency: userConfig.currency === 'fiat' ? 'usd' : 'eth', + }), + }), + ] + + const { + data: { gasEstimate: estimatedGasLimit, gasCost: transactionFee }, + error: estimateGasLimitError, + isLoading: isEstimateGasLoading, + gasPrice, + } = useEstimateGasWithStateOverride({ + transactions: [ + { + name: 'extendNames', + data: { + duration: seconds, + names, + startDateTimestamp: expiryDate?.getTime(), + }, + stateOverride: [ + { + address: address!, + // the value will only be used if totalRentFee is defined, dw + balance: totalRentFee ? totalRentFee + parseEther('10') : 0n, + }, + ], + }, + ], + enabled: !!totalRentFee, + }) + + const previousTransactionFee = usePreviousDistinct(transactionFee) || 0n + + const unsafeDisplayTransactionFee = + transactionFee !== 0n ? transactionFee : previousTransactionFee + const isShowingPreviousTransactionFee = transactionFee === 0n && previousTransactionFee > 0n + + const items: InvoiceItem[] = [ + { + label: t('input.extendNames.invoice.extension', { + time: formatDurationOfDates({ startDate: expiryDate, endDate: extendedDate, t }), + }), + value: totalRentFee, + bufferPercentage: CURRENCY_FLUCTUATION_BUFFER_PERCENTAGE, + }, + { + label: t('input.extendNames.invoice.transaction'), + value: transactionFee, + bufferPercentage: CURRENCY_FLUCTUATION_BUFFER_PERCENTAGE, + }, + ] + + const { title, alert } = match(view) + .with('no-ownership-warning', () => ({ + title: t('input.extendNames.ownershipWarning.title', { count: names.length }), + alert: 'warning' as const, + })) + .otherwise(() => ({ + title: t('input.extendNames.title', { count: names.length }), + alert: undefined, + })) + + const trailingButtonProps = match(view) + .with('name-list', () => ({ + onClick: incrementView, + children: t('action.next', { ns: 'common' }), + })) + .with('no-ownership-warning', () => ({ + onClick: incrementView, + children: t('action.understand', { ns: 'common' }), + })) + .otherwise(() => ({ + disabled: !!estimateGasLimitError, + onClick: () => { + if (!totalRentFee) return + dispatch({ name: 'setTransactions', payload: transactions }) + dispatch({ name: 'setFlowStage', payload: 'transaction' }) + }, + children: t('action.next', { ns: 'common' }), + })) + + return ( + <> + + + {match(view) + .with('name-list', () => ) + .with('no-ownership-warning', () => ( + + {t('input.extendNames.ownershipWarning.description', { count: names.length })} + + )) + .otherwise(() => ( + <> + + {names.length === 1 ? ( + + ) : ( + { + const newYears = parseInt(e.target.value) + if (!Number.isNaN(newYears)) setSeconds(yearsToSeconds(newYears)) + }} + /> + )} + + + + setCurrency(e.target.checked ? 'fiat' : 'eth')} + data-testid="extend-names-currency-toggle" + /> + + + + {(!!estimateGasLimitError || + (!!estimatedGasLimit && + !!balance?.value && + balance.value < estimatedGasLimit)) && ( + {t('input.extendNames.gasLimitError')} + )} + {!!unsafeDisplayYearlyFee && !!unsafeDisplayTransactionFee && ( + + )} + + + ))} + + + {t(viewIdx === 0 ? 'action.cancel' : 'action.back', { ns: 'common' })} + + } + trailing={ + + ) : ( + + ) +} + +const ProfileEditor = ({ data = {}, transactions = [], dispatch, onDismiss }: Props) => { + const { t } = useTranslation('register') + + const formRef = useRef(null) + const [view, setView] = useState<'editor' | 'upload' | 'nft' | 'addRecord' | 'warning'>('editor') + + const { name = '', resumable = false } = data + + const { data: profile, isLoading: isProfileLoading } = useProfile({ name }) + const { data: isWrapped = false, isLoading: isWrappedLoading } = useIsWrapped({ name }) + const isLoading = isProfileLoading || isWrappedLoading + + const existingRecords = profileToProfileRecords(profile) + + const { + records: profileRecords, + register, + trigger, + control, + handleSubmit, + addRecords, + updateRecord, + removeRecordAtIndex, + updateRecordAtIndex, + removeRecordByGroupAndKey, + setAvatar, + labelForRecord, + secondaryLabelForRecord, + placeholderForRecord, + validatorForRecord, + errorForRecordAtIndex, + isDirtyForRecordAtIndex, + hasErrors, + } = useProfileEditorForm(existingRecords) + + // Update profile records if transaction data exists + const [isRecordsUpdated, setIsRecordsUpdated] = useState(false) + useEffect(() => { + const updateProfileRecordsWithTransactionData = () => { + const transaction = transactions.find( + (item: TransactionItem) => item.name === 'updateProfileRecords', + ) as TransactionItem<'updateProfileRecords'> + if (!transaction) return + const updatedRecords: ProfileRecord[] = transaction?.data?.records || [] + updatedRecords.forEach((record) => { + if (record.key === 'avatar' && record.group === 'media') { + setAvatar(record.value) + } else { + updateRecord(record) + } + }) + existingRecords.forEach((record) => { + const updatedRecord = updatedRecords.find( + (r) => r.group === record.group && r.key === record.key, + ) + if (!updatedRecord) { + removeRecordByGroupAndKey(record.group, record.key) + } + }) + } + if (!isLoading) { + updateProfileRecordsWithTransactionData() + setIsRecordsUpdated(true) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoading, transactions, setIsRecordsUpdated, isRecordsUpdated]) + + const resolverAddress = useContractAddress({ contract: 'ensPublicResolver' }) + + const resolverStatus = useResolverStatus({ + name, + }) + + const chainId = useChainId() + + const handleCreateTransaction = useCallback( + async (form: ProfileEditorForm) => { + const records = profileEditorFormToProfileRecords(form) + if (!profile?.resolverAddress) return + dispatch({ + name: 'setTransactions', + payload: [ + createTransactionItem('updateProfileRecords', { + name, + resolverAddress: profile.resolverAddress, + records, + previousRecords: existingRecords, + clearRecords: false, + }), + ], + }) + dispatch({ name: 'setFlowStage', payload: 'transaction' }) + }, + [profile, name, existingRecords, dispatch], + ) + + const [avatarSrc, setAvatarSrc] = useState() + const [avatarFile, setAvatarFile] = useState() + + useEffect(() => { + if ( + !resolverStatus.isLoading && + !resolverStatus.data?.hasLatestResolver && + transactions.length === 0 + ) { + setView('warning') + } + }, [resolverStatus.isLoading, resolverStatus.data?.hasLatestResolver, transactions.length]) + + useEffect(() => { + if (!isProfileLoading && profile?.isMigrated === false) { + setView('warning') + } + }, [isProfileLoading, profile?.isMigrated]) + + const handleDeleteRecord = (record: ProfileRecord, index: number) => { + removeRecordAtIndex(index) + process.nextTick(() => trigger()) + } + + const handleShowAddRecordModal = () => { + setView('addRecord') + } + + const canEditRecordsWhenWrapped = match(isWrapped) + .with(true, () => + getResolverWrapperAwareness({ chainId, resolverAddress: profile?.resolverAddress }), + ) + .otherwise(() => true) + + if (isLoading || resolverStatus.isLoading || !isRecordsUpdated) return + + return ( + <> + {match(view) + .with('editor', () => ( + <> + + { + handleCreateTransaction(_data) + })} + alwaysShowDividers={{ bottom: true }} + > + + setView(option)} + onAvatarChange={(avatar) => setAvatar(avatar)} + onAvatarFileChange={(file) => setAvatarFile(file)} + onAvatarSrcChange={(src) => setAvatarSrc(src)} + /> + + {profileRecords.map((field, index) => + field.group === 'custom' ? ( + { + handleDeleteRecord(field, index) + }} + /> + ) : field.key === 'description' ? ( + { + handleDeleteRecord(field, index) + }} + {...register(`records.${index}.value`, { + validate: validatorForRecord(field), + })} + /> + ) : ( + { + if (isEthAddressRecord(field)) { + updateRecordAtIndex(index, { ...field, value: '' }) + } else { + handleDeleteRecord(field, index) + } + }} + {...register(`records.${index}.value`, { + validate: validatorForRecord(field), + })} + /> + ), + )} + + + + + + + { + onDismiss?.() + // dispatch({ name: 'stopFlow' }) + }} + > + {t('action.cancel', { ns: 'common' })} + + } + trailing={ + + formRef.current?.dispatchEvent( + new Event('submit', { cancelable: true, bubbles: true }), + ) + } + /> + } + /> + + )) + .with('addRecord', () => ( + { + addRecords(newRecords) + setView('editor') + }} + onClose={() => setView('editor')} + /> + )) + .with('warning', () => ( + dispatch({ name: 'stopFlow' })} + onDismissOverlay={() => setView('editor')} + /> + )) + .with('upload', () => ( + setView('editor')} + type="upload" + handleSubmit={(type: 'upload' | 'nft', uri: string, display?: string) => { + setAvatar(uri) + setAvatarSrc(display) + setView('editor') + trigger() + }} + /> + )) + .with('nft', () => ( + setView('editor')} + type="nft" + handleSubmit={(type: 'upload' | 'nft', uri: string, display?: string) => { + setAvatar(uri) + setAvatarSrc(display) + setView('editor') + trigger() + }} + /> + )) + .exhaustive()} + + ) +} + +export default ProfileEditor diff --git a/src/transaction/user/input/ProfileEditor/ProfileEditor.test.tsx b/src/transaction/user/input/ProfileEditor/ProfileEditor.test.tsx new file mode 100644 index 000000000..ed1a26542 --- /dev/null +++ b/src/transaction/user/input/ProfileEditor/ProfileEditor.test.tsx @@ -0,0 +1,734 @@ +/* eslint-disable no-await-in-loop */ +import { cleanup, mockFunction, render, screen, userEvent, waitFor, within } from '@app/test-utils' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useEnsAvatar } from 'wagmi' + +import ensjsPackage from '@app/../node_modules/@ensdomains/ensjs/package.json' +import appPackage from '@app/../package.json' +import { useContractAddress } from '@app/hooks/chain/useContractAddress' +import { useResolverStatus } from '@app/hooks/resolver/useResolverStatus' +import { useIsWrapped } from '@app/hooks/useIsWrapped' +import { useProfile } from '@app/hooks/useProfile' +import { useBreakpoint } from '@app/utils/BreakpointProvider' + +import ProfileEditor from './ProfileEditor-flow' + +vi.mock('wagmi') + +const mockProfileData = { + data: { + address: '0x70643CB203137b9b9eE19deA56080CD2BA01dBFd' as const, + contentHash: null, + texts: [ + { + key: 'email', + value: 'test@ens.domains', + }, + { + key: 'url', + value: 'https://ens.domains', + }, + { + key: 'avatar', + value: 'https://example.xyz/avatar/test.jpg', + }, + { + key: 'com.discord', + value: 'test', + }, + { + key: 'com.reddit', + value: 'https://www.reddit.com/user/test/', + }, + { + key: 'com.twitter', + value: 'https://twitter.com/test', + }, + { + key: 'org.telegram', + value: '@test', + }, + { + key: 'com.linkedin.com', + value: 'https://www.linkedin.com/in/test/', + }, + { + key: 'xyz.lensfrens', + value: 'https://www.lensfrens.xyz/test.lens', + }, + ], + coins: [ + { + id: 60, + name: 'ETH', + value: '0xb794f5ea0ba39494ce839613fffba74279579268', + }, + { + id: 0, + name: 'BTC', + value: '1JnJvEBykLcGHYxCZVWgDGDm7pkK3EBHwB', + }, + { + id: 3030, + name: 'HBAR', + value: '0.0.123123', + }, + { + id: 501, + name: 'SOL', + value: 'HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH', + }, + ], + resolverAddress: '0x0' as const, + isMigrated: true, + createdAt: { + date: new Date('1630553876'), + value: 1630553876, + }, + }, + isLoading: false, +} + +vi.mock('@app/hooks/chain/useContractAddress') + +vi.mock('@app/hooks/resolver/useResolverStatus') +vi.mock('@app/hooks/useProfile') +vi.mock('@app/hooks/useIsWrapped') + +vi.mock('@app/utils/BreakpointProvider') + +vi.mock('@app/transaction-flow/TransactionFlowProvider') + +vi.mock('@app/transaction-flow/input/ProfileEditor/components/ProfileBlurb', () => ({ + ProfileBlurb: () =>
Profile Blurb
, +})) + +const mockUseBreakpoint = mockFunction(useBreakpoint) +const mockUseContractAddress = mockFunction(useContractAddress) +const mockUseResolverStatus = mockFunction(useResolverStatus) +const mockUseProfile = mockFunction(useProfile) +const mockUseIsWrapped = mockFunction(useIsWrapped) +const mockUseEnsAvatar = mockFunction(useEnsAvatar) + +const mockDispatch = vi.fn() + +export function setupIntersectionObserverMock({ + root = null, + rootMargin = '', + thresholds = [], + disconnect = () => null, + observe = () => null, + takeRecords = () => [], + unobserve = () => null, +} = {}): void { + class MockIntersectionObserver implements IntersectionObserver { + readonly root: Element | null = root + + readonly rootMargin: string = rootMargin + + readonly thresholds: ReadonlyArray = thresholds + + disconnect: () => void = disconnect + + observe: (target: Element) => void = observe + + takeRecords: () => IntersectionObserverEntry[] = takeRecords + + unobserve: (target: Element) => void = unobserve + } + + Object.defineProperty(window, 'IntersectionObserver', { + writable: true, + configurable: true, + value: MockIntersectionObserver, + }) + + Object.defineProperty(global, 'IntersectionObserver', { + writable: true, + configurable: true, + value: MockIntersectionObserver, + }) +} + +const makeResolverStatus = (keys?: string[], isLoading = false) => ({ + data: { + hasResolver: false, + hasLatestResolver: false, + isAuthorized: false, + hasValidResolver: false, + hasProfile: true, + hasMigratedProfile: false, + isMigratedProfileEqual: false, + isNameWrapperAware: false, + ...(keys || []).reduce((acc, key) => { + return { + ...acc, + [key]: true, + } + }, {}), + }, + isLoading, +}) + +beforeEach(() => { + setupIntersectionObserverMock() +}) + +describe('ProfileEditor', () => { + beforeEach(() => { + mockUseProfile.mockReturnValue(mockProfileData) + mockUseIsWrapped.mockReturnValue({ data: false, isLoading: false }) + + mockUseBreakpoint.mockReturnValue({ + xs: true, + sm: false, + md: false, + lg: false, + xl: false, + }) + + window.scroll = vi.fn() as () => void + + // @ts-ignore + mockUseContractAddress.mockReturnValue('0x0') + + mockUseResolverStatus.mockReturnValue( + makeResolverStatus(['hasResolver', 'hasLatestResolver', 'hasValidResolver']), + ) + + mockUseEnsAvatar.mockReturnValue({ + data: 'avatar', + isLoading: false, + }) + }) + + afterEach(() => { + cleanup() + vi.resetAllMocks() + }) + + it('should have use the same version of address-encoder as ensjs', () => { + expect(appPackage.dependencies['@ensdomains/address-encoder']).toEqual( + ensjsPackage.dependencies['@ensdomains/address-encoder'], + ) + }) + + it('should render', async () => { + render( + {}} />, + ) + await waitFor(() => { + expect(screen.getByTestId('profile-editor')).toBeVisible() + }) + }) +}) + +describe('ResolverWarningOverlay', () => { + const makeUpdateResolverDispatch = (contract = 'registry') => ({ + name: 'setTransactions', + payload: [ + { + data: { + contract, + name: 'test.eth', + resolverAddress: '0x123', + }, + name: 'updateResolver', + }, + ], + }) + + const makeMigrateProfileDispatch = (contract = 'registry') => ({ + key: 'migrate-profile-test.eth', + name: 'startFlow', + payload: { + intro: { + content: { + data: { + description: 'input.profileEditor.intro.migrateProfile.description', + }, + name: 'GenericWithDescription', + }, + title: [ + 'input.profileEditor.intro.migrateProfile.title', + { + ns: 'transactionFlow', + }, + ], + }, + transactions: [ + { + data: { + name: 'test.eth', + }, + name: 'migrateProfile', + }, + { + data: { + contract, + name: 'test.eth', + resolverAddress: '0x123', + }, + name: 'updateResolver', + }, + ], + }, + }) + + const RESET_RESOLVER_DISPATCH = { + key: 'reset-profile-test.eth', + name: 'startFlow', + payload: { + intro: { + content: { + data: { + description: 'input.profileEditor.intro.resetProfile.description', + }, + name: 'GenericWithDescription', + }, + title: [ + 'input.profileEditor.intro.resetProfile.title', + { + ns: 'transactionFlow', + }, + ], + }, + transactions: [ + { + data: { + name: 'test.eth', + resolverAddress: '0x123', + }, + name: 'resetProfile', + }, + { + data: { + contract: 'registry', + name: 'test.eth', + resolverAddress: '0x123', + }, + name: 'updateResolver', + }, + ], + }, + } + + const MIGRATE_CURRENT_PROFILE_DISPATCH = { + key: 'migrate-profile-with-reset-test.eth', + name: 'startFlow', + payload: { + intro: { + content: { + data: { + description: 'input.profileEditor.intro.migrateCurrentProfile.description', + }, + name: 'GenericWithDescription', + }, + title: [ + 'input.profileEditor.intro.migrateCurrentProfile.title', + { + ns: 'transactionFlow', + }, + ], + }, + transactions: [ + { + data: { + name: 'test.eth', + resolverAddress: '0x0', + }, + name: 'migrateProfileWithReset', + }, + { + data: { + contract: 'registry', + name: 'test.eth', + resolverAddress: '0x123', + }, + name: 'updateResolver', + }, + ], + }, + } + + beforeEach(() => { + mockUseProfile.mockReturnValue(mockProfileData) + // @ts-ignore + mockUseContractAddress.mockReturnValue('0x123') + mockUseIsWrapped.mockReturnValue({ data: false, isLoading: false }) + mockUseEnsAvatar.mockReturnValue({ + data: 'avatar', + isLoading: false, + }) + mockDispatch.mockClear() + }) + + describe('No Resolver', () => { + beforeEach(() => { + mockUseResolverStatus.mockReturnValue(makeResolverStatus([])) + }) + + it('should dispatch update resolver', async () => { + render( + {}} />, + ) + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.noResolver.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith(makeUpdateResolverDispatch()) + }) + }) + }) + + describe('Resolver not name wrapper aware', () => { + beforeEach(() => { + mockUseIsWrapped.mockReturnValue({ data: true, isLoading: false }) + mockUseResolverStatus.mockReturnValue(makeResolverStatus(['hasResolver', 'hasValidResolver'])) + }) + + it('should be able to migrate profile', async () => { + render( + {}} />, + ) + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.resolverNotNameWrapperAware.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith(makeMigrateProfileDispatch('nameWrapper')) + }) + }) + + it('should be able to update resolver', async () => { + render( + {}} />, + ) + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.resolverNotNameWrapperAware.title'), + ).toBeVisible() + }) + + const switchEl = screen.getByTestId('detailed-switch') + const toggle = within(switchEl).getByRole('checkbox') + await userEvent.click(toggle) + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith(makeUpdateResolverDispatch('nameWrapper')) + }) + }) + }) + + describe('Invalid Resolver', () => { + beforeEach(() => { + mockUseResolverStatus.mockReturnValue(makeResolverStatus(['hasResolver'])) + }) + + it('should dispatch update resolver', async () => { + render( + {}} />, + ) + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.invalidResolver.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith(makeUpdateResolverDispatch()) + }) + }) + }) + + describe('Resolver out of date', () => { + beforeEach(() => { + mockUseResolverStatus.mockReturnValue( + makeResolverStatus(['hasResolver', 'hasValidResolver', 'isAuthorized']), + ) + }) + + it('should be able to go to profile editor', async () => { + render( + {}} />, + ) + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.resolverOutOfDate.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('warning-overlay-skip-button')) + + await waitFor(() => { + expect(screen.getByTestId('profile-editor')).toBeVisible() + }) + }) + + it('should be able to migrate profile and resolver', async () => { + render( + {}} />, + ) + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.resolverOutOfDate.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.transferOrResetProfile.title'), + ).toBeVisible() + }) + + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith(makeMigrateProfileDispatch()) + }) + }) + + it('should be able to update resolver', async () => { + render( + {}} />, + ) + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.resolverOutOfDate.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.transferOrResetProfile.title'), + ).toBeVisible() + }) + + const switchEl = screen.getByTestId('detailed-switch') + const toggle = within(switchEl).getByRole('checkbox') + await userEvent.click(toggle) + + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith(makeUpdateResolverDispatch()) + }) + }) + }) + + describe('Resolver out of sync ( profiles do not match )', () => { + beforeEach(() => { + mockUseResolverStatus.mockReturnValue( + makeResolverStatus([ + 'hasResolver', + 'hasValidResolver', + 'isAuthorized', + 'hasMigratedProfile', + ]), + ) + }) + + it('should be able to go to profile editor', async () => { + render( + {}} />, + ) + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.resolverOutOfSync.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('warning-overlay-skip-button')) + + await waitFor(() => { + expect(screen.getByTestId('profile-editor')).toBeVisible() + }) + }) + + it('should be able to update resolver', async () => { + render( + {}} />, + ) + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.resolverOutOfSync.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + + // Select latest profile + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.migrateProfileSelector.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('migrate-profile-selector-latest')) + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith(makeUpdateResolverDispatch()) + }) + }) + + it('should be able to migrate current profile', async () => { + render( + {}} />, + ) + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.resolverOutOfSync.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + + // select migrate current profile + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.migrateProfileSelector.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('migrate-profile-selector-current')) + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + + // migrate profile warning + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.migrateProfileWarning.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith(MIGRATE_CURRENT_PROFILE_DISPATCH) + }) + }) + + it('should be able to reset profile', async () => { + render( + {}} />, + ) + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.resolverOutOfSync.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + + // Select reset option + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.migrateProfileSelector.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('migrate-profile-selector-reset')) + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + + // Reset profile view + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.resetProfile.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith(RESET_RESOLVER_DISPATCH) + }) + }) + }) + + describe('Resolver out of sync ( profiles match )', () => { + beforeEach(() => { + mockUseResolverStatus.mockReturnValue( + makeResolverStatus([ + 'hasResolver', + 'hasValidResolver', + 'isAuthorized', + 'hasMigratedProfile', + 'isMigratedProfileEqual', + ]), + ) + }) + + it('should be able to go to profile editor', async () => { + render( + {}} />, + ) + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.resolverOutOfSync.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('warning-overlay-skip-button')) + + await waitFor(() => { + expect(screen.getByTestId('profile-editor')).toBeVisible() + }) + }) + + it('should be able to update resolver', async () => { + render( + {}} />, + ) + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.resolverOutOfSync.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + + // Select latest profile + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.updateResolverOrResetProfile.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith(makeUpdateResolverDispatch()) + }) + }) + + it('should be able to reset profile', async () => { + render( + {}} />, + ) + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.resolverOutOfSync.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + + // Select reset option + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.updateResolverOrResetProfile.title'), + ).toBeVisible() + }) + const switchEl = screen.getByTestId('detailed-switch') + const toggle = within(switchEl).getByRole('checkbox') + await userEvent.click(toggle) + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + + // Reset profile view + await waitFor(() => { + expect( + screen.getByText('input.profileEditor.warningOverlay.resetProfile.title'), + ).toBeVisible() + }) + await userEvent.click(screen.getByTestId('warning-overlay-next-button')) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith(RESET_RESOLVER_DISPATCH) + }) + }) + }) +}) diff --git a/src/transaction/user/input/ProfileEditor/ResolverWarningOverlay.tsx b/src/transaction/user/input/ProfileEditor/ResolverWarningOverlay.tsx new file mode 100644 index 000000000..1684bbf29 --- /dev/null +++ b/src/transaction/user/input/ProfileEditor/ResolverWarningOverlay.tsx @@ -0,0 +1,275 @@ +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Address } from 'viem' + +import { useResolverStatus } from '@app/hooks/resolver/useResolverStatus' +import { makeIntroItem } from '@app/transaction-flow/intro' +import { createTransactionItem } from '@app/transaction-flow/transaction' +import { TransactionDialogPassthrough } from '@app/transaction-flow/types' + +import { InvalidResolverView } from './views/InvalidResolverView' +import { MigrateProfileSelectorView } from './views/MigrateProfileSelectorView.tsx' +import { MigrateProfileWarningView } from './views/MigrateProfileWarningView' +import { MigrateRegistryView } from './views/MigrateRegistryView' +import { NoResolverView } from './views/NoResolverView' +import { ResetProfileView } from './views/ResetProfileView' +import { ResolverNotNameWrapperAwareView } from './views/ResolverNotNameWrapperAwareView' +import { ResolverOutOfDateView } from './views/ResolverOutOfDateView' +import { ResolverOutOfSyncView } from './views/ResolverOutOfSyncView' +import { TransferOrResetProfileView } from './views/TransferOrResetProfileView' +import { UpdateResolverOrResetProfileView } from './views/UpdateResolverOrResetProfileView' + +export type SelectedProfile = 'latest' | 'current' | 'reset' + +type Props = { + name: string + isWrapped: boolean + resumable?: boolean + hasOldRegistry?: boolean + hasMigratedProfile?: boolean + hasNoResolver?: boolean + latestResolverAddress: Address + oldResolverAddress: Address + status: ReturnType['data'] + onDismissOverlay: () => void +} & TransactionDialogPassthrough + +type View = + | 'invalidResolver' + | 'migrateProfileSelector' + | 'migrateProfileWarning' + | 'migrateRegistry' + | 'noResolver' + | 'resetProfile' + | 'resolverNotNameWrapperAware' + | 'resolverOutOfDate' + | 'resolverOutOfSync' + | 'transferOrResetProfile' + | 'updateResolverOrResetProfile' + +const ResolverWarningOverlay = ({ + name, + status, + isWrapped, + hasOldRegistry = false, + latestResolverAddress, + oldResolverAddress, + dispatch, + onDismiss, + onDismissOverlay, +}: Props) => { + const { t } = useTranslation('transactionFlow') + const [selectedProfile, setSelectedProfile] = useState('latest') + + const flow: View[] = useMemo(() => { + if (hasOldRegistry) return ['migrateRegistry'] + if (!status?.hasResolver) return ['noResolver'] + if (!status?.hasValidResolver) return ['invalidResolver'] + if (!status?.isNameWrapperAware && isWrapped) return ['resolverNotNameWrapperAware'] + if (!status?.isAuthorized) return ['invalidResolver'] + if (status?.hasMigratedProfile && status.isMigratedProfileEqual) + return ['resolverOutOfSync', 'updateResolverOrResetProfile', 'resetProfile'] + if (status?.hasMigratedProfile) + return [ + 'resolverOutOfSync', + 'migrateProfileSelector', + ...(selectedProfile === 'current' + ? (['migrateProfileWarning'] as View[]) + : (['resetProfile'] as View[])), + ] + return ['resolverOutOfDate', 'transferOrResetProfile'] + }, [ + hasOldRegistry, + isWrapped, + status?.hasResolver, + status?.isNameWrapperAware, + status?.hasValidResolver, + status?.isAuthorized, + status?.hasMigratedProfile, + status?.isMigratedProfileEqual, + selectedProfile, + ]) + const [index, setIndex] = useState(0) + const view = flow[index] + + const onIncrement = () => { + if (flow[index + 1]) setIndex(index + 1) + } + + const onDecrement = () => { + if (flow[index - 1]) setIndex(index - 1) + } + + const handleUpdateResolver = () => { + dispatch({ + name: 'setTransactions', + payload: [ + createTransactionItem('updateResolver', { + name, + contract: isWrapped ? 'nameWrapper' : 'registry', + resolverAddress: latestResolverAddress, + }), + ], + }) + dispatch({ + name: 'setFlowStage', + payload: 'transaction', + }) + } + + const handleMigrateProfile = () => { + dispatch({ + name: 'startFlow', + key: `migrate-profile-${name}`, + payload: { + intro: { + title: ['input.profileEditor.intro.migrateProfile.title', { ns: 'transactionFlow' }], + content: makeIntroItem('GenericWithDescription', { + description: t('input.profileEditor.intro.migrateProfile.description'), + }), + }, + transactions: [ + createTransactionItem('migrateProfile', { + name, + }), + createTransactionItem('updateResolver', { + name, + contract: isWrapped ? 'nameWrapper' : 'registry', + resolverAddress: latestResolverAddress, + }), + ], + }, + }) + } + + const handleResetProfile = () => { + dispatch({ + name: 'startFlow', + key: `reset-profile-${name}`, + payload: { + intro: { + title: ['input.profileEditor.intro.resetProfile.title', { ns: 'transactionFlow' }], + content: makeIntroItem('GenericWithDescription', { + description: t('input.profileEditor.intro.resetProfile.description'), + }), + }, + transactions: [ + createTransactionItem('resetProfile', { + name, + resolverAddress: latestResolverAddress, + }), + createTransactionItem('updateResolver', { + name, + contract: isWrapped ? 'nameWrapper' : 'registry', + resolverAddress: latestResolverAddress, + }), + ], + }, + }) + } + + const handleMigrateCurrentProfileToLatest = async () => { + dispatch({ + name: 'startFlow', + key: `migrate-profile-with-reset-${name}`, + payload: { + intro: { + title: [ + 'input.profileEditor.intro.migrateCurrentProfile.title', + { ns: 'transactionFlow' }, + ], + content: makeIntroItem('GenericWithDescription', { + description: t('input.profileEditor.intro.migrateCurrentProfile.description'), + }), + }, + transactions: [ + createTransactionItem('migrateProfileWithReset', { + name, + resolverAddress: oldResolverAddress, + }), + createTransactionItem('updateResolver', { + name, + contract: isWrapped ? 'nameWrapper' : 'registry', + resolverAddress: latestResolverAddress, + }), + ], + }, + }) + } + + const viewsMap: { [key in View]: any } = { + migrateRegistry: , + invalidResolver: , + migrateProfileSelector: ( + { + if (selectedProfile === 'latest') handleUpdateResolver() + else onIncrement() + }} + /> + ), + migrateProfileWarning: ( + + ), + noResolver: , + resetProfile: , + resolverNotNameWrapperAware: ( + { + if (selectedProfile === 'reset' || !status?.hasProfile) handleUpdateResolver() + else handleMigrateProfile() + }} + /> + ), + resolverOutOfDate: ( + + ), + resolverOutOfSync: ( + + ), + transferOrResetProfile: ( + { + if (selectedProfile === 'reset') handleUpdateResolver() + else handleMigrateProfile() + }} + /> + ), + updateResolverOrResetProfile: ( + { + if (selectedProfile === 'reset') onIncrement() + else handleUpdateResolver() + }} + /> + ), + } + + return viewsMap[view] +} + +export default ResolverWarningOverlay diff --git a/src/transaction/user/input/ProfileEditor/WrappedAvatarButton.tsx b/src/transaction/user/input/ProfileEditor/WrappedAvatarButton.tsx new file mode 100644 index 000000000..69f17ff0f --- /dev/null +++ b/src/transaction/user/input/ProfileEditor/WrappedAvatarButton.tsx @@ -0,0 +1,26 @@ +import { ComponentProps } from 'react' +import { Control, useFormState } from 'react-hook-form' +import { useEnsAvatar } from 'wagmi' + +import AvatarButton from '@app/components/@molecules/ProfileEditor/Avatar/AvatarButton' +import { ProfileEditorForm } from '@app/hooks/useProfileEditorForm' +import { ensAvatarConfig } from '@app/utils/query/ipfsGateway' + +type Props = { + name: string + control: Control +} & Omit, 'validated'> + +export const WrappedAvatarButton = ({ control, name, src, ...props }: Props) => { + const { data: avatar } = useEnsAvatar({ ...ensAvatarConfig, name }) + const formState = useFormState({ + control, + name: 'avatar', + }) + const isValidated = !!src || !!avatar + const isDirty = !!formState.dirtyFields.avatar + const currentOrUpdatedSrc = isDirty ? src : (avatar as string | undefined) + return ( + + ) +} diff --git a/src/transaction/user/input/ProfileEditor/components/CenteredTypography.tsx b/src/transaction/user/input/ProfileEditor/components/CenteredTypography.tsx new file mode 100644 index 000000000..a2b2515d6 --- /dev/null +++ b/src/transaction/user/input/ProfileEditor/components/CenteredTypography.tsx @@ -0,0 +1,9 @@ +import styled, { css } from 'styled-components' + +import { Typography } from '@ensdomains/thorin' + +export const CenteredTypography = styled(Typography)( + () => css` + text-align: center; + `, +) diff --git a/src/transaction/user/input/ProfileEditor/components/ContentContainer.tsx b/src/transaction/user/input/ProfileEditor/components/ContentContainer.tsx new file mode 100644 index 000000000..ff5652799 --- /dev/null +++ b/src/transaction/user/input/ProfileEditor/components/ContentContainer.tsx @@ -0,0 +1,9 @@ +import styled, { css } from 'styled-components' + +export const ContentContainer = styled.div( + () => css` + display: flex; + flex-direction: column; + align-items: center; + `, +) diff --git a/src/transaction/user/input/ProfileEditor/components/DetailedSwitch.tsx b/src/transaction/user/input/ProfileEditor/components/DetailedSwitch.tsx new file mode 100644 index 000000000..73d384d57 --- /dev/null +++ b/src/transaction/user/input/ProfileEditor/components/DetailedSwitch.tsx @@ -0,0 +1,45 @@ +import { ComponentProps, forwardRef } from 'react' +import styled, { css } from 'styled-components' + +import { Toggle, Typography } from '@ensdomains/thorin' + +const Container = styled.div( + ({ theme }) => css` + display: flex; + align-items: center; + width: 100%; + gap: ${theme.space['4']}; + padding: ${theme.space['4']}; + border-radius: ${theme.radii.large}; + border: 1px solid ${theme.colors.border}; + `, +) + +const ContentContainer = styled.div( + ({ theme }) => css` + flex: 1; + flex-direction: column; + gap: ${theme.space['1']}; + `, +) + +type ToggleProps = ComponentProps + +type Props = { + title?: string + description?: string +} & ToggleProps + +export const DetailedSwitch = forwardRef( + ({ title, description, ...toggleProps }, ref) => { + return ( + + + {title && {title}}{' '} + {description && {description}} + + + + ) + }, +) diff --git a/src/transaction/user/input/ProfileEditor/components/ProfileBlurb.tsx b/src/transaction/user/input/ProfileEditor/components/ProfileBlurb.tsx new file mode 100644 index 000000000..787c9129d --- /dev/null +++ b/src/transaction/user/input/ProfileEditor/components/ProfileBlurb.tsx @@ -0,0 +1,78 @@ +import styled, { css } from 'styled-components' +import { Address } from 'viem' + +import { Avatar, Typography } from '@ensdomains/thorin' + +import { useAvatarFromRecord } from '@app/hooks/useAvatarFromRecord' +import { useProfile } from '@app/hooks/useProfile' +import { useZorb } from '@app/hooks/useZorb' +import { Profile } from '@app/types' + +const Container = styled.div( + ({ theme }) => css` + width: 100%; + display: flex; + align-items: center; + border: 1px solid ${theme.colors.border}; + border-radius: ${theme.radii['2xLarge']}; + padding: ${theme.space['4']}; + gap: ${theme.space['4']}; + `, +) + +const AvatarWrapper = styled.div( + ({ theme }) => css` + flex: 0 0 ${theme.space['20']}; + width: ${theme.space['20']}; + height: ${theme.space['20']}; + border-radius: ${theme.radii.full}; + overflow: hidden; + `, +) + +const InfoContainer = styled.div( + () => css` + flex: 1; + display: flex; + flex-direction: column; + `, +) + +type Props = { + name: string + resolverAddress: Address +} + +const getTextRecordByKey = (profile: Profile | undefined, key: string) => { + return profile?.texts?.find(({ key: recordKey }: { key: string | number }) => recordKey === key) + ?.value +} + +export const ProfileBlurb = ({ name, resolverAddress }: Props) => { + const { data: profile } = useProfile({ name, resolverAddress }) + const avatarRecord = getTextRecordByKey(profile, 'avatar') + const { avatar } = useAvatarFromRecord(avatarRecord) + const zorb = useZorb(name, 'name') + + const nickname = getTextRecordByKey(profile, 'name') + const description = getTextRecordByKey(profile, 'description') + const url = getTextRecordByKey(profile, 'url') + + return ( + + + + + + {name} + {nickname && {nickname}} + {description && {description}} + {url && ( + + {url} + + )} + + + ) +} diff --git a/src/transaction/user/input/ProfileEditor/components/SkipButton.tsx b/src/transaction/user/input/ProfileEditor/components/SkipButton.tsx new file mode 100644 index 000000000..4961c5ee3 --- /dev/null +++ b/src/transaction/user/input/ProfileEditor/components/SkipButton.tsx @@ -0,0 +1,58 @@ +import styled, { css } from 'styled-components' + +import { RightArrowSVG, Typography } from '@ensdomains/thorin' + +const Container = styled.button( + ({ theme }) => css` + background-color: ${theme.colors.yellowSurface}; + display: flex; + padding: ${theme.space['4']}; + gap: ${theme.space['4']}; + width: 100%; + border-radius: ${theme.radii.large}; + cursor: pointer; + transition: all 0.2s ease-in-out; + + &:hover { + background-color: ${theme.colors.yellowLight}; + transform: translateY(-1px); + } + `, +) + +const StyledTypography = styled(Typography)( + () => css` + flex: 1; + text-align: left; + `, +) + +const SkipLabel = styled.div( + ({ theme }) => css` + color: ${theme.colors.yellowDim}; + display: flex; + align-items: center; + gap: ${theme.space['2']}; + padding: ${theme.space['2']}; + `, +) + +type Props = { + description: string + actionLabel?: string + onClick?: () => void +} + +export const SkipButton = ({ description, actionLabel = 'Skip', onClick, ...props }: Props) => { + return ( + + {description} + + + {actionLabel} + + + + + ) +} diff --git a/src/transaction/user/input/ProfileEditor/views/InvalidResolverView.tsx b/src/transaction/user/input/ProfileEditor/views/InvalidResolverView.tsx new file mode 100644 index 000000000..400164367 --- /dev/null +++ b/src/transaction/user/input/ProfileEditor/views/InvalidResolverView.tsx @@ -0,0 +1,48 @@ +import { useTranslation } from 'react-i18next' + +import { Button, Dialog } from '@ensdomains/thorin' + +import { Outlink } from '@app/components/Outlink' +import { getSupportLink } from '@app/utils/supportLinks' + +import { CenteredTypography } from '../components/CenteredTypography' + +type Props = { + onConfirm?: () => void + onCancel?: () => void +} +export const InvalidResolverView = ({ onConfirm, onCancel }: Props) => { + const { t } = useTranslation('transactionFlow') + return ( + <> + + + + {t('input.profileEditor.warningOverlay.invalidResolver.subtitle')} + + + {t('input.profileEditor.warningOverlay.action.learnMoreResolvers')} + + + + {t('action.cancel', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} diff --git a/src/transaction/user/input/ProfileEditor/views/MigrateProfileSelectorView.tsx.tsx b/src/transaction/user/input/ProfileEditor/views/MigrateProfileSelectorView.tsx.tsx new file mode 100644 index 000000000..1d34500a3 --- /dev/null +++ b/src/transaction/user/input/ProfileEditor/views/MigrateProfileSelectorView.tsx.tsx @@ -0,0 +1,144 @@ +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' +import { Address } from 'viem' + +import { Button, Dialog, RadioButton, Typography } from '@ensdomains/thorin' + +import { CenteredTypography } from '../components/CenteredTypography' +import { ProfileBlurb } from '../components/ProfileBlurb' +import type { SelectedProfile } from '../ResolverWarningOverlay' + +const RadioGroupContainer = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: column; + gap: ${theme.space['6']}; + width: ${theme.space.full}; + `, +) + +const RadioLabelContainer = styled.div( + ({ theme }) => css` + width: 100%; + display: flex; + flex-direction: column; + gap: ${theme.space['2']}; + `, +) + +const RadioInfoContainer = styled.div( + () => css` + display: flex; + flex-direction: column; + `, +) + +type Props = { + name: string + currentResolverAddress: Address + latestResolverAddress: Address + hasCurrentProfile: boolean + selected: SelectedProfile + onChangeSelected: (selected: SelectedProfile) => void + onNext: () => void + onBack: () => void +} +export const MigrateProfileSelectorView = ({ + name, + currentResolverAddress, + latestResolverAddress, + hasCurrentProfile, + selected, + onChangeSelected, + onNext, + onBack, +}: Props) => { + const { t } = useTranslation('transactionFlow') + return ( + <> + + + + {t('input.profileEditor.warningOverlay.migrateProfileSelector.subtitle')} + + + + + + {t('input.profileEditor.warningOverlay.migrateProfileSelector.option.latest')} + + + + + } + name="resolver-option" + value="latest" + checked={selected === 'latest'} + onChange={() => onChangeSelected('latest')} + /> + {hasCurrentProfile && ( + + + + {t( + 'input.profileEditor.warningOverlay.migrateProfileSelector.option.current', + )} + + + + + } + name="resolver-option" + value="current" + checked={selected === 'current'} + onChange={() => onChangeSelected('current')} + /> + )} + + + {t('input.profileEditor.warningOverlay.migrateProfileSelector.option.reset')} + + + {t( + 'input.profileEditor.warningOverlay.migrateProfileSelector.option.resetSubtitle', + )} + + + } + name="resolver-option" + value="reset" + checked={selected === 'reset'} + onChange={() => onChangeSelected('reset')} + /> + + + + {t('action.back', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} diff --git a/src/transaction/user/input/ProfileEditor/views/MigrateProfileWarningView.tsx b/src/transaction/user/input/ProfileEditor/views/MigrateProfileWarningView.tsx new file mode 100644 index 000000000..74618ef00 --- /dev/null +++ b/src/transaction/user/input/ProfileEditor/views/MigrateProfileWarningView.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from 'react-i18next' + +import { Button, Dialog } from '@ensdomains/thorin' + +import { CenteredTypography } from '../components/CenteredTypography' + +type Props = { + onBack: () => void + onNext: () => void +} + +export const MigrateProfileWarningView = ({ onNext, onBack }: Props) => { + const { t } = useTranslation('transactionFlow') + return ( + <> + + + + {t('input.profileEditor.warningOverlay.migrateProfileWarning.subtitle')} + + + + {t('action.back', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} diff --git a/src/transaction/user/input/ProfileEditor/views/MigrateRegistryView.tsx b/src/transaction/user/input/ProfileEditor/views/MigrateRegistryView.tsx new file mode 100644 index 000000000..7ee1f37a8 --- /dev/null +++ b/src/transaction/user/input/ProfileEditor/views/MigrateRegistryView.tsx @@ -0,0 +1,47 @@ +import { useTranslation } from 'react-i18next' + +import { Button, Dialog } from '@ensdomains/thorin' + +import { CenteredTypography } from '../components/CenteredTypography' + +type Props = { + name: string + onCancel?: () => void +} +export const MigrateRegistryView = ({ name, onCancel }: Props) => { + const { t } = useTranslation('transactionFlow') + return ( + <> + + + + {t('input.profileEditor.warningOverlay.migrateRegistry.subtitle')} + + + + {t('action.cancel', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} diff --git a/src/transaction/user/input/ProfileEditor/views/NoResolverView.tsx b/src/transaction/user/input/ProfileEditor/views/NoResolverView.tsx new file mode 100644 index 000000000..d5dab6007 --- /dev/null +++ b/src/transaction/user/input/ProfileEditor/views/NoResolverView.tsx @@ -0,0 +1,48 @@ +import { useTranslation } from 'react-i18next' + +import { Button, Dialog } from '@ensdomains/thorin' + +import { Outlink } from '@app/components/Outlink' +import { getSupportLink } from '@app/utils/supportLinks' + +import { CenteredTypography } from '../components/CenteredTypography' + +type Props = { + onConfirm: () => void + onCancel: () => void +} +export const NoResolverView = ({ onConfirm, onCancel }: Props) => { + const { t } = useTranslation('transactionFlow') + return ( + <> + + + + {t('input.profileEditor.warningOverlay.noResolver.subtitle')} + + + {t('input.profileEditor.warningOverlay.action.learnMoreResolvers')} + + + + {t('action.cancel', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} diff --git a/src/transaction/user/input/ProfileEditor/views/ResetProfileView.tsx b/src/transaction/user/input/ProfileEditor/views/ResetProfileView.tsx new file mode 100644 index 000000000..d3ec21b61 --- /dev/null +++ b/src/transaction/user/input/ProfileEditor/views/ResetProfileView.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from 'react-i18next' + +import { Button, Dialog } from '@ensdomains/thorin' + +import { CenteredTypography } from '../components/CenteredTypography' + +type Props = { + onBack: () => void + onNext: () => void +} +export const ResetProfileView = ({ onNext, onBack }: Props) => { + const { t } = useTranslation('transactionFlow') + return ( + <> + + + + {t('input.profileEditor.warningOverlay.resetProfile.subtitle')} + + + + {t('action.back', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} diff --git a/src/transaction/user/input/ProfileEditor/views/ResolverNotNameWrapperAwareView.tsx b/src/transaction/user/input/ProfileEditor/views/ResolverNotNameWrapperAwareView.tsx new file mode 100644 index 000000000..0b2c6fdbf --- /dev/null +++ b/src/transaction/user/input/ProfileEditor/views/ResolverNotNameWrapperAwareView.tsx @@ -0,0 +1,72 @@ +import { useTranslation } from 'react-i18next' + +import { Button, Dialog } from '@ensdomains/thorin' + +import { Outlink } from '@app/components/Outlink' +import { getSupportLink } from '@app/utils/supportLinks' + +import { CenteredTypography } from '../components/CenteredTypography' +import { ContentContainer } from '../components/ContentContainer' +import { DetailedSwitch } from '../components/DetailedSwitch' +import type { SelectedProfile } from '../ResolverWarningOverlay' + +type Props = { + selected: SelectedProfile + hasProfile: boolean + onChangeSelected: (selected: SelectedProfile) => void + onCancel: () => void + onNext: () => void +} +export const ResolverNotNameWrapperAwareView = ({ + selected, + hasProfile, + onChangeSelected, + onNext, + onCancel, +}: Props) => { + const { t } = useTranslation('transactionFlow') + return ( + <> + + + + + {t('input.profileEditor.warningOverlay.resolverNotNameWrapperAware.subtitle')} + + + {t('input.profileEditor.warningOverlay.action.learnMoreResolvers')} + + + {hasProfile && ( + onChangeSelected(e.target.checked ? 'latest' : 'reset')} + /> + )} + + + {t('action.cancel', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} diff --git a/src/transaction/user/input/ProfileEditor/views/ResolverOutOfDateView.tsx b/src/transaction/user/input/ProfileEditor/views/ResolverOutOfDateView.tsx new file mode 100644 index 000000000..7a407f914 --- /dev/null +++ b/src/transaction/user/input/ProfileEditor/views/ResolverOutOfDateView.tsx @@ -0,0 +1,56 @@ +import { useTranslation } from 'react-i18next' + +import { Button, Dialog } from '@ensdomains/thorin' + +import { Outlink } from '@app/components/Outlink' +import { getSupportLink } from '@app/utils/supportLinks' + +import { CenteredTypography } from '../components/CenteredTypography' +import { SkipButton } from '../components/SkipButton' + +type Props = { + onConfirm?: () => void + onCancel?: () => void + onSkip?: () => void +} +export const ResolverOutOfDateView = ({ onConfirm, onCancel, onSkip }: Props) => { + const { t } = useTranslation('transactionFlow') + return ( + <> + + + + {t('input.profileEditor.warningOverlay.resolverOutOfDate.subtitle')} + + + {t('input.profileEditor.warningOverlay.action.learnMoreResolvers')} + + + + + {t('action.cancel', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} diff --git a/src/transaction/user/input/ProfileEditor/views/ResolverOutOfSyncView.tsx b/src/transaction/user/input/ProfileEditor/views/ResolverOutOfSyncView.tsx new file mode 100644 index 000000000..3361dedd4 --- /dev/null +++ b/src/transaction/user/input/ProfileEditor/views/ResolverOutOfSyncView.tsx @@ -0,0 +1,56 @@ +import { useTranslation } from 'react-i18next' + +import { Button, Dialog } from '@ensdomains/thorin' + +import { Outlink } from '@app/components/Outlink' +import { getSupportLink } from '@app/utils/supportLinks' + +import { CenteredTypography } from '../components/CenteredTypography' +import { SkipButton } from '../components/SkipButton' + +type Props = { + onNext: () => void + onCancel: () => void + onSkip: () => void +} +export const ResolverOutOfSyncView = ({ onNext, onCancel, onSkip }: Props) => { + const { t } = useTranslation('transactionFlow') + return ( + <> + + + + {t('input.profileEditor.warningOverlay.resolverOutOfSync.subtitle')} + + + {t('input.profileEditor.warningOverlay.action.learnMoreResolvers')} + + + + + {t('action.cancel', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} diff --git a/src/transaction/user/input/ProfileEditor/views/TransferOrResetProfileView.tsx b/src/transaction/user/input/ProfileEditor/views/TransferOrResetProfileView.tsx new file mode 100644 index 000000000..9ff00a551 --- /dev/null +++ b/src/transaction/user/input/ProfileEditor/views/TransferOrResetProfileView.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from 'react-i18next' + +import { Button, Dialog } from '@ensdomains/thorin' + +import { CenteredTypography } from '../components/CenteredTypography' +import { DetailedSwitch } from '../components/DetailedSwitch' +import type { SelectedProfile } from '../ResolverWarningOverlay' + +type Props = { + selected: SelectedProfile + onChangeSelected: (selected: SelectedProfile) => void + onNext: () => void + onBack: () => void +} +export const TransferOrResetProfileView = ({ + selected, + onChangeSelected, + onNext, + onBack, +}: Props) => { + const { t } = useTranslation('transactionFlow') + + return ( + <> + + + + {t('input.profileEditor.warningOverlay.transferOrResetProfile.subtitle')} + + onChangeSelected(e.currentTarget.checked ? 'latest' : 'reset')} + /> + + + {t('action.back', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} diff --git a/src/transaction/user/input/ProfileEditor/views/UpdateResolverOrResetProfileView.tsx b/src/transaction/user/input/ProfileEditor/views/UpdateResolverOrResetProfileView.tsx new file mode 100644 index 000000000..66f924252 --- /dev/null +++ b/src/transaction/user/input/ProfileEditor/views/UpdateResolverOrResetProfileView.tsx @@ -0,0 +1,60 @@ +/** This is when the current resolver and latest resolver have matching records */ +import { useTranslation } from 'react-i18next' + +import { Button, Dialog } from '@ensdomains/thorin' + +import { CenteredTypography } from '../components/CenteredTypography' +import { DetailedSwitch } from '../components/DetailedSwitch' +import type { SelectedProfile } from '../ResolverWarningOverlay' + +type Props = { + selected: SelectedProfile + onChangeSelected: (selected: SelectedProfile) => void + onNext: () => void + onBack: () => void +} + +export const UpdateResolverOrResetProfileView = ({ + selected, + onChangeSelected, + onNext, + onBack, +}: Props) => { + const { t } = useTranslation('transactionFlow') + return ( + <> + + + + {t('input.profileEditor.warningOverlay.updateResolverOrResetProfile.subtitle')} + + onChangeSelected(e.currentTarget.checked ? 'latest' : 'reset')} + title={t('input.profileEditor.warningOverlay.updateResolverOrResetProfile.toggle.title')} + description={t( + 'input.profileEditor.warningOverlay.updateResolverOrResetProfile.toggle.subtitle', + )} + /> + + + {t('action.back', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} diff --git a/src/transaction/user/input/ResetPrimaryName/ResetPrimaryName-flow.tsx b/src/transaction/user/input/ResetPrimaryName/ResetPrimaryName-flow.tsx new file mode 100644 index 000000000..d9aa797c9 --- /dev/null +++ b/src/transaction/user/input/ResetPrimaryName/ResetPrimaryName-flow.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from 'react-i18next' +import type { Address } from 'viem' + +import { Button, Dialog } from '@ensdomains/thorin' + +import { createTransactionItem } from '../../transaction' +import { TransactionDialogPassthrough } from '../../types' +import { CenteredTypography } from '../ProfileEditor/components/CenteredTypography' + +type Data = { + address: Address + name: string +} + +export type Props = { + data: Data +} & TransactionDialogPassthrough + +const ResetPrimaryName = ({ data: { address }, dispatch, onDismiss }: Props) => { + const { t } = useTranslation('transactionFlow') + + const handleSubmit = async () => { + dispatch({ + name: 'setTransactions', + payload: [ + createTransactionItem('resetPrimaryName', { + address, + }), + ], + }) + dispatch({ + name: 'setFlowStage', + payload: 'transaction', + }) + } + + return ( + <> + + + {t('input.resetPrimaryName.description')} + + + {t('action.cancel', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} + +export default ResetPrimaryName diff --git a/src/transaction/user/input/RevokePermissions/RevokePermissions-flow.tsx b/src/transaction/user/input/RevokePermissions/RevokePermissions-flow.tsx new file mode 100644 index 000000000..9e79b1b34 --- /dev/null +++ b/src/transaction/user/input/RevokePermissions/RevokePermissions-flow.tsx @@ -0,0 +1,408 @@ +import { ComponentProps, Dispatch, useMemo, useRef, useState } from 'react' +import { useForm, useWatch } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { match } from 'ts-pattern' +import { Address } from 'viem' + +import { + ChildFuseKeys, + ChildFuseReferenceType, + ParentFuseKeys, + ParentFuseReferenceType, +} from '@ensdomains/ensjs/utils' +import { Button, Dialog } from '@ensdomains/thorin' + +import { useExpiry } from '@app/hooks/ensjs/public/useExpiry' +import { createTransactionItem } from '@app/transaction-flow/transaction' +import type changePermissions from '@app/transaction-flow/transaction/changePermissions' +import { TransactionDialogPassthrough, TransactionFlowAction } from '@app/transaction-flow/types' +import { ExtractTransactionData } from '@app/types' +import { dateTimeLocalToDate, dateToDateTimeLocal } from '@app/utils/datetime-local' + +import { ControlledNextButton } from './components/ControlledNextButton' +import { GrantExtendExpiryView } from './views/GrantExtendExpiryView' +import { NameConfirmationWarningView } from './views/NameConfirmationWarningView' +import { ParentRevokePermissionsView } from './views/ParentRevokePermissionsView' +import { RevokeChangeFusesView } from './views/RevokeChangeFusesView' +import { RevokeChangeFusesWarningView } from './views/RevokeChangeFusesWarningView' +import { RevokePCCView } from './views/RevokePCCView' +import { RevokePermissionsView } from './views/RevokePermissionsView' +import { RevokeUnwrapView } from './views/RevokeUnwrapView' +import { RevokeWarningView } from './views/RevokeWarningView' +import { SetExpiryView } from './views/SetExpiryView' + +export type FlowType = + | 'revoke-pcc' + | 'revoke-permissions' + | 'revoke-change-fuses' + | 'grant-extend-expiry' + | 'revoke-change-fuses' + +type CurrentParentFuses = { + [key in ParentFuseReferenceType['Key']]: boolean +} + +type CurrentChildFuses = { + [key in ChildFuseReferenceType['Key']]: boolean +} + +export type FormData = { + parentFuses: CurrentParentFuses + childFuses: CurrentChildFuses + expiry?: number + expiryType?: 'max' | 'custom' + expiryCustom?: string +} + +type FlowWithExpiry = { + flowType: 'revoke-pcc' | 'grant-extend-expiry' + minExpiry?: number + maxExpiry: number +} + +type FlowWithoutExpiry = { + flowType: 'revoke-permissions' | 'revoke-change-fuses' | 'revoke-permissions' + minExpiry?: never + maxExpiry?: never +} + +type Data = { + name: string + flowType: FlowType + owner: Address + parentFuses: CurrentParentFuses + childFuses: CurrentChildFuses +} & (FlowWithExpiry | FlowWithoutExpiry) + +export type RevokePermissionsDialogContentProps = ComponentProps + +export type Props = { + data: Data + onDismiss: () => void + dispatch: Dispatch +} & TransactionDialogPassthrough + +export type View = + | 'revokeWarning' + | 'revokePCC' + | 'grantExtendExpiry' + | 'setExpiry' + | 'revokeUnwrap' + | 'parentRevokePermissions' + | 'revokePermissions' + | 'revokeChangeFuses' + | 'revokeChangeFusesWarning' + | 'lastWarning' + +type TransactionData = ExtractTransactionData + +/** + * Gets default values for useForm as well as populating data from + */ +const getFormDataDefaultValues = (data: Data, transactionData?: TransactionData): FormData => { + let parentFuseEntries = ParentFuseKeys.map((fuse) => [fuse, !!data.parentFuses[fuse]]) as [ + ParentFuseReferenceType['Key'], + boolean, + ][] + let childFuseEntries = ChildFuseKeys.map((fuse) => [fuse, !!data.childFuses[fuse]]) as [ + ChildFuseReferenceType['Key'], + boolean, + ][] + const expiry = data.maxExpiry + let expiryType: FormData['expiryType'] = 'max' + let expiryCustom = dateToDateTimeLocal( + new Date( + // set default to min + 1 day if min is larger than current time + // otherwise set to current time + 1 day + // max value is the maximum expiry + Math.min( + Math.max((data.minExpiry || 0) * 1000, Date.now()) + 60 * 60 * 24 * 1000, + data.maxExpiry ? data.maxExpiry * 1000 : Infinity, + ), + ), + true, + ) + + if (transactionData?.contract === 'setChildFuses') { + parentFuseEntries = parentFuseEntries.map(([fuse, value]) => [ + fuse, + value || !!transactionData?.fuses.parent?.includes(fuse), + ]) + childFuseEntries = childFuseEntries.map(([fuse, value]) => [ + fuse, + value || !!transactionData?.fuses.child?.includes(fuse), + ]) + } + if ( + transactionData?.contract === 'setChildFuses' && + transactionData.expiry && + transactionData.expiry !== expiry + ) { + expiryType = 'custom' + expiryCustom = dateToDateTimeLocal(new Date(transactionData.expiry * 1000), true) + } + if (transactionData?.contract === 'setFuses') { + childFuseEntries = childFuseEntries.map(([fuse, value]) => [ + fuse, + value || !!transactionData.fuses.includes(fuse), + ]) + } + return { + parentFuses: Object.fromEntries(parentFuseEntries) as { + [key in ParentFuseReferenceType['Key']]: boolean + }, + childFuses: Object.fromEntries(childFuseEntries) as { + [key in ChildFuseReferenceType['Key']]: boolean + }, + expiry, + expiryType, + expiryCustom, + } +} + +/** + * When returning from a transaction we need to check if the flow includes `revokeChangeFusesWarning` + * When moving forward this is handled by the next button to avoid unnecessary rerenders. + */ +const getIntialValueForCurrentIndex = (flow: View[], transactionData?: TransactionData): number => { + if (!transactionData) return 0 + const childFuses = + transactionData.contract === 'setChildFuses' + ? transactionData.fuses.child + : transactionData.fuses + if ( + flow[flow.length - 1] === 'revokeChangeFusesWarning' && + !childFuses.includes('CANNOT_BURN_FUSES') + ) + return flow.length - 2 + return flow.length - 1 +} + +const RevokePermissions = ({ data, transactions, onDismiss, dispatch }: Props) => { + const { + name, + flowType, + owner, + parentFuses: initialParentFuses, + childFuses: initialChildFuses, + minExpiry, + maxExpiry, + } = data + + const formRef = useRef(null) + const { t } = useTranslation('transactionFlow') + + const { data: expiry } = useExpiry({ name }) + + const transactionData: any = transactions?.find((tx: any) => tx.name === 'changePermissions') + ?.data as TransactionData | undefined + + const { register, control, handleSubmit, getValues, trigger, formState } = useForm({ + mode: 'onChange', + defaultValues: getFormDataDefaultValues(data, transactionData), + }) + + const isCustomExpiryValid = formState.errors.expiryCustom === undefined + + const [parentFuses, childFuses] = useWatch({ control, name: ['parentFuses', 'childFuses'] }) + + const unburnedFuses = useMemo(() => { + return Object.entries({ ...initialParentFuses, ...initialChildFuses }) + .filter(([, value]) => value === false) + .map(([key]) => key) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) as (ParentFuseReferenceType['Key'] | ChildFuseReferenceType['Key'])[] + + /** The user flow depending on */ + const flow = useMemo(() => { + const isSubname = name.split('.').length > 2 + const isMinExpiryAtLeastEqualToMaxExpiry = + isSubname && !!minExpiry && !!maxExpiry && minExpiry >= maxExpiry + + switch (flowType) { + case 'revoke-pcc': { + return [ + 'revokeWarning', + 'revokePCC', + ...(!isMinExpiryAtLeastEqualToMaxExpiry ? ['setExpiry'] : []), + 'parentRevokePermissions', + ...(childFuses.CANNOT_UNWRAP && childFuses.CANNOT_BURN_FUSES + ? ['revokeChangeFusesWarning'] + : []), + 'lastWarning', + ] + } + case 'grant-extend-expiry': { + return [ + 'revokeWarning', + 'grantExtendExpiry', + ...(!isMinExpiryAtLeastEqualToMaxExpiry ? ['setExpiry'] : []), + ] + } + case 'revoke-permissions': { + return [ + 'revokeWarning', + ...(initialChildFuses.CANNOT_UNWRAP ? [] : ['revokeUnwrap']), + 'revokePermissions', + 'lastWarning', + ] + } + case 'revoke-change-fuses': { + return ['revokeWarning', 'revokeChangeFuses', 'revokeChangeFusesWarning', 'lastWarning'] + } + default: { + return [] + } + } + }, [name, flowType, minExpiry, maxExpiry, childFuses, initialChildFuses]) as View[] + + const [currentIndex, setCurrentIndex] = useState( + getIntialValueForCurrentIndex(flow, transactionData), + ) + const view = flow[currentIndex] + + const onDecrementIndex = () => { + if (flow[currentIndex - 1]) setCurrentIndex(currentIndex - 1) + else onDismiss?.() + } + + const onSubmit = (form: FormData) => { + // Only allow childfuses to be burned if CU is burned + const childNamedFuses = form.childFuses.CANNOT_UNWRAP + ? ChildFuseKeys.filter((fuse) => unburnedFuses.includes(fuse) && form.childFuses[fuse]) + : [] + + if (['revoke-pcc', 'grant-extend-expiry'].includes(flowType)) { + const parentNamedFuses = ParentFuseKeys.filter((fuse) => form.parentFuses[fuse]) + + const customExpiry = form.expiryCustom + ? Math.floor(dateTimeLocalToDate(form.expiryCustom).getTime() / 1000) + : undefined + + dispatch({ + name: 'setTransactions', + payload: [ + createTransactionItem('changePermissions', { + name, + contract: 'setChildFuses', + fuses: { + parent: parentNamedFuses, + child: childNamedFuses, + }, + expiry: form.expiryType === 'max' ? maxExpiry : customExpiry, + }), + ], + }) + } else { + dispatch({ + name: 'setTransactions', + payload: [ + createTransactionItem('changePermissions', { + name, + contract: 'setFuses', + fuses: childNamedFuses, + }), + ], + }) + } + + dispatch({ name: 'setFlowStage', payload: 'transaction' }) + } + + const [isDisabled, setDisabled] = useState(true) + + const dialogContentProps: RevokePermissionsDialogContentProps = { + as: 'form', + ref: formRef, + onSubmit: handleSubmit(onSubmit), + } + + return ( + <> + {match(view) + .with('revokeWarning', () => ) + .with('revokePCC', () => ( + + )) + .with('grantExtendExpiry', () => ( + + )) + .with('setExpiry', () => ( + + )) + .with('revokeUnwrap', () => ( + + )) + .with('parentRevokePermissions', () => ( + + )) + .with('revokePermissions', () => ( + + )) + .with('lastWarning', () => ( + + )) + .with('revokeChangeFuses', () => ( + + )) + .with('revokeChangeFusesWarning', () => ( + + )) + .exhaustive()} + + {currentIndex === 0 + ? t('action.cancel', { ns: 'common' }) + : t('action.back', { ns: 'common' })} + + } + trailing={ + = flow.length - 1} + onIncrement={() => { + setCurrentIndex((index) => index + 1) + }} + onSubmit={() => { + formRef.current?.dispatchEvent( + new Event('submit', { cancelable: true, bubbles: true }), + ) + }} + /> + } + /> + + ) +} + +export default RevokePermissions diff --git a/src/transaction/user/input/RevokePermissions/RevokePermissions.test.tsx b/src/transaction/user/input/RevokePermissions/RevokePermissions.test.tsx new file mode 100644 index 000000000..54103c074 --- /dev/null +++ b/src/transaction/user/input/RevokePermissions/RevokePermissions.test.tsx @@ -0,0 +1,713 @@ +import { fireEvent, mockFunction, render, screen, userEvent, waitFor } from '@app/test-utils' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { usePrimaryName } from '@app/hooks/ensjs/public/usePrimaryName' +import { createTransactionItem } from '@app/transaction-flow/transaction' +import { DeepPartial } from '@app/types' + +import RevokePermissions, { Props } from './RevokePermissions-flow' +import { makeMockIntersectionObserver } from '../../../../test/mock/makeMockIntersectionObserver' + +vi.mock('@app/hooks/ensjs/public/usePrimaryName') + +vi.spyOn(Date, 'now').mockImplementation(() => new Date('2023-01-01').getTime()) + +const mockUsePrimaryName = mockFunction(usePrimaryName) + +const mockDispatch = vi.fn() +const mockOnDismiss = vi.fn() + +makeMockIntersectionObserver() + +type Data = Props['data'] +const makeData = (overrides: DeepPartial = {}) => { + const defaultData = { + name: 'test.eth', + flowType: 'revoke-pcc', + owner: '0x1234', + parentFuses: { + PARENT_CANNOT_CONTROL: false, + CAN_EXTEND_EXPIRY: false, + }, + childFuses: { + CANNOT_UNWRAP: false, + CANNOT_CREATE_SUBDOMAIN: false, + CANNOT_TRANSFER: false, + CANNOT_SET_RESOLVER: false, + CANNOT_SET_TTL: false, + CANNOT_BURN_FUSES: false, + }, + minExpiry: 0, + maxExpiry: 0, + } + const { parentFuses = {}, childFuses = {}, ...data } = overrides + return { + ...defaultData, + ...data, + parentFuses: { + ...defaultData.parentFuses, + ...parentFuses, + }, + childFuses: { + ...defaultData.childFuses, + ...childFuses, + }, + } as Data +} + +beforeEach(() => { + mockUsePrimaryName.mockReturnValue({ data: null, isLoading: false }) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('RevokePermissions', () => { + describe('revoke-pcc', () => { + it('should call dispatch when flow is finished', async () => { + render( + , + ) + + const nextButton = screen.getByTestId('permissions-next-button') + + // warning screen + expect( + screen.getByText('input.revokePermissions.views.revokeWarning.subtitle2'), + ).toBeInTheDocument() + expect(nextButton).toHaveTextContent('action.understand') + await userEvent.click(nextButton) + + // pcc view + const pccCheckbox = screen.getByTestId('checkbox-pcc') + await waitFor(() => { + expect(pccCheckbox).toBeInTheDocument() + expect(pccCheckbox).not.toBeChecked() + expect(nextButton).toBeDisabled() + }) + await userEvent.click(pccCheckbox) + await waitFor(() => { + expect(pccCheckbox).toBeChecked() + expect(nextButton).not.toBeDisabled() + }) + await userEvent.click(nextButton) + + // set expiry view + const maxRadio = screen.getByTestId('radio-max') + const customRadio = screen.getByTestId('radio-custom') + await waitFor(() => { + expect(maxRadio).toBeChecked() + expect(customRadio).not.toBeChecked() + }) + await userEvent.click(nextButton) + + // parent revoke permissions + const fusesToBurn = [ + 'CAN_EXTEND_EXPIRY', + 'CANNOT_UNWRAP', + 'CANNOT_CREATE_SUBDOMAIN', + 'CANNOT_TRANSFER', + 'CANNOT_SET_RESOLVER', + 'CANNOT_SET_TTL', + 'CANNOT_BURN_FUSES', + ] + for (const fuse of fusesToBurn) { + // eslint-disable-next-line no-await-in-loop + await userEvent.click(screen.getByTestId(`checkbox-${fuse}`)) + } + await waitFor(() => { + expect(nextButton).toHaveTextContent('input.revokePermissions.action.revoke7') + }) + await userEvent.click(nextButton) + + // burn fuses warning + await waitFor(() => { + expect( + screen.getByText('input.revokePermissions.views.revokeChangeFusesWarning.title'), + ).toBeInTheDocument() + }) + + await userEvent.click(nextButton) + + const nameConfirmation = screen.getByTestId('input-name-confirmation') + + fireEvent.change(nameConfirmation, { target: { value: 'sub.test.eth' } }) + + await userEvent.click(nextButton) + + await waitFor(() => { + expect(mockDispatch).toBeCalledWith({ + name: 'setTransactions', + payload: [ + createTransactionItem('changePermissions', { + name: 'sub.test.eth', + contract: 'setChildFuses', + fuses: { + parent: ['PARENT_CANNOT_CONTROL', 'CAN_EXTEND_EXPIRY'], + child: [ + 'CANNOT_UNWRAP', + 'CANNOT_BURN_FUSES', + 'CANNOT_TRANSFER', + 'CANNOT_SET_RESOLVER', + 'CANNOT_SET_TTL', + 'CANNOT_CREATE_SUBDOMAIN', + ], + }, + expiry: 1675238574, + }), + ], + }) + }) + }) + + it('should not show SetExpiryView if minExpiry and maxExpiry are equal', async () => { + render( + , + ) + + const nextButton = screen.getByTestId('permissions-next-button') + + // warning screen + await userEvent.click(nextButton) + + // pcc view + const pccCheckbox = screen.getByTestId('checkbox-pcc') + await userEvent.click(pccCheckbox) + await waitFor(() => { + expect(pccCheckbox).toBeChecked() + expect(nextButton).not.toBeDisabled() + }) + await userEvent.click(nextButton) + + // set expiry view + const maxRadio = screen.queryByTestId('radio-max') + const customRadio = screen.queryByTestId('radio-custom') + await waitFor(() => { + expect(maxRadio).toBeNull() + expect(customRadio).toBeNull() + }) + }) + + it('should filter out child fuses if CANNOT_UNWRAP is checked', async () => { + render( + , + ) + + const nextButton = screen.getByTestId('permissions-next-button') + + // warning screen + await userEvent.click(nextButton) + + // pcc view + const pccCheckbox = screen.getByTestId('checkbox-pcc') + await userEvent.click(pccCheckbox) + await userEvent.click(nextButton) + + // set expiry view + await userEvent.click(nextButton) + + // parent revoke permissions + const fusesToBurn = [ + 'CAN_EXTEND_EXPIRY', + 'CANNOT_UNWRAP', + 'CANNOT_CREATE_SUBDOMAIN', + 'CANNOT_TRANSFER', + 'CANNOT_SET_RESOLVER', + 'CANNOT_SET_TTL', + 'CANNOT_BURN_FUSES', + ] + for (const fuse of fusesToBurn) { + // eslint-disable-next-line no-await-in-loop + await userEvent.click(screen.getByTestId(`checkbox-${fuse}`)) + } + await userEvent.click(screen.getByTestId('checkbox-CANNOT_UNWRAP')) + await userEvent.click(nextButton) + + await userEvent.click(nextButton) + + const nameConfirmation = screen.getByTestId('input-name-confirmation') + + fireEvent.change(nameConfirmation, { target: { value: 'sub.test.eth' } }) + + await userEvent.click(nextButton) + + await waitFor(() => { + expect(mockDispatch).toBeCalledWith({ + name: 'setTransactions', + payload: [ + createTransactionItem('changePermissions', { + name: 'sub.test.eth', + contract: 'setChildFuses', + fuses: { + parent: ['PARENT_CANNOT_CONTROL', 'CAN_EXTEND_EXPIRY'], + child: [], + }, + expiry: 1675238574, + }), + ], + }) + }) + }) + }) + + describe('grant-extend-expiry', () => { + it('should call dispatch when flow is finished', async () => { + render( + , + ) + + const nextButton = screen.getByTestId('permissions-next-button') + + // warning screen + expect( + screen.getByText('input.revokePermissions.views.revokeWarning.subtitle2'), + ).toBeInTheDocument() + expect(nextButton).toHaveTextContent('action.understand') + await userEvent.click(nextButton) + + // extend expiry view + const extendExpiryCheckbox = screen.getByTestId('checkbox-CAN_EXTEND_EXPIRY') + await waitFor(() => { + expect(extendExpiryCheckbox).toBeInTheDocument() + expect(extendExpiryCheckbox).not.toBeChecked() + expect(nextButton).toBeDisabled() + }) + await userEvent.click(extendExpiryCheckbox) + await waitFor(() => { + expect(extendExpiryCheckbox).toBeChecked() + expect(nextButton).not.toBeDisabled() + }) + await userEvent.click(nextButton) + + // set expiry view + const maxRadio = screen.getByTestId('radio-max') + const customRadio = screen.getByTestId('radio-custom') + + await waitFor(() => { + expect(maxRadio).toBeChecked() + expect(customRadio).not.toBeChecked() + }) + + await userEvent.click(customRadio) + + await waitFor(() => { + expect(maxRadio).not.toBeChecked() + expect(customRadio).toBeChecked() + expect(nextButton).not.toBeDisabled() + }) + + await userEvent.click(nextButton) + + await waitFor(() => { + expect(mockDispatch).toBeCalledWith({ + name: 'setTransactions', + payload: [ + createTransactionItem('changePermissions', { + name: 'sub.test.eth', + contract: 'setChildFuses', + fuses: { + parent: ['CAN_EXTEND_EXPIRY'], + child: [], + }, + expiry: Math.floor(new Date('2023-01-02').getTime() / 1000), + }), + ], + }) + }) + }) + + it('should not show SetExpiryView if minExpiry and maxExpiry are equal', async () => { + render( + , + ) + + const nextButton = screen.getByTestId('permissions-next-button') + + // warning screen + expect( + screen.getByText('input.revokePermissions.views.revokeWarning.subtitle2'), + ).toBeInTheDocument() + expect(nextButton).toHaveTextContent('action.understand') + await userEvent.click(nextButton) + + // extend expiry view + const extendExpiryCheckbox = screen.getByTestId('checkbox-CAN_EXTEND_EXPIRY') + await waitFor(() => { + expect(extendExpiryCheckbox).toBeInTheDocument() + expect(extendExpiryCheckbox).not.toBeChecked() + expect(nextButton).toBeDisabled() + }) + await userEvent.click(extendExpiryCheckbox) + await waitFor(() => { + expect(extendExpiryCheckbox).toBeChecked() + expect(nextButton).not.toBeDisabled() + }) + await userEvent.click(nextButton) + + await waitFor(() => { + expect(mockDispatch).toBeCalledWith({ + name: 'setTransactions', + payload: [ + createTransactionItem('changePermissions', { + name: 'sub.test.eth', + contract: 'setChildFuses', + fuses: { + parent: ['CAN_EXTEND_EXPIRY'], + child: [], + }, + expiry: 1675238574, + }), + ], + }) + }) + }) + }) + + describe('revoke-permissions', () => { + it('should call dispatch when flow is finished', async () => { + render( + , + ) + + const nextButton = screen.getByTestId('permissions-next-button') + + // warning screen + expect( + screen.getByText('input.revokePermissions.views.revokeWarning.subtitle2'), + ).toBeInTheDocument() + expect(nextButton).toHaveTextContent('action.understand') + await userEvent.click(nextButton) + + // pcc view + const unwrapCheckbox = screen.getByTestId('checkbox-CANNOT_UNWRAP') + await waitFor(() => { + expect(unwrapCheckbox).toBeInTheDocument() + expect(unwrapCheckbox).not.toBeChecked() + expect(nextButton).toBeDisabled() + }) + await userEvent.click(unwrapCheckbox) + await waitFor(() => { + expect(unwrapCheckbox).toBeChecked() + expect(nextButton).not.toBeDisabled() + }) + await userEvent.click(nextButton) + + // revoke permissions + const fusesToBurn = [ + 'CANNOT_CREATE_SUBDOMAIN', + 'CANNOT_TRANSFER', + 'CANNOT_SET_RESOLVER', + 'CANNOT_SET_TTL', + ] + for (const fuse of fusesToBurn) { + // eslint-disable-next-line no-await-in-loop + await userEvent.click(screen.getByTestId(`checkbox-${fuse}`)) + } + await waitFor(() => { + expect(nextButton).toHaveTextContent('input.revokePermissions.action.revoke4') + }) + await userEvent.click(nextButton) + + const nameConfirmation = screen.getByTestId('input-name-confirmation') + + fireEvent.change(nameConfirmation, { target: { value: 'sub.test.eth' } }) + + await userEvent.click(nextButton) + + await waitFor(() => { + expect(mockDispatch).toBeCalledWith({ + name: 'setTransactions', + payload: [ + createTransactionItem('changePermissions', { + name: 'sub.test.eth', + contract: 'setFuses', + fuses: [ + 'CANNOT_UNWRAP', + 'CANNOT_TRANSFER', + 'CANNOT_SET_RESOLVER', + 'CANNOT_SET_TTL', + 'CANNOT_CREATE_SUBDOMAIN', + ], + }), + ], + }) + }) + }) + + it('should skip unwrap view if it already burned', async () => { + render( + , + ) + + const nextButton = screen.getByTestId('permissions-next-button') + + // warning screen + expect( + screen.getByText('input.revokePermissions.views.revokeWarning.subtitle2'), + ).toBeInTheDocument() + expect(nextButton).toHaveTextContent('action.understand') + await userEvent.click(nextButton) + + // revoke permissions + const fusesToBurn = [ + 'CANNOT_CREATE_SUBDOMAIN', + 'CANNOT_TRANSFER', + 'CANNOT_SET_RESOLVER', + 'CANNOT_SET_TTL', + ] + for (const fuse of fusesToBurn) { + // eslint-disable-next-line no-await-in-loop + await userEvent.click(screen.getByTestId(`checkbox-${fuse}`)) + } + await waitFor(() => { + expect(nextButton).toHaveTextContent('input.revokePermissions.action.revoke4') + }) + await userEvent.click(nextButton) + + const nameConfirmation = screen.getByTestId('input-name-confirmation') + + fireEvent.change(nameConfirmation, { target: { value: 'sub.test.eth' } }) + + await userEvent.click(nextButton) + + await waitFor(() => { + expect(mockDispatch).toBeCalledWith({ + name: 'setTransactions', + payload: [ + createTransactionItem('changePermissions', { + name: 'sub.test.eth', + contract: 'setFuses', + fuses: [ + 'CANNOT_TRANSFER', + 'CANNOT_SET_RESOLVER', + 'CANNOT_SET_TTL', + 'CANNOT_CREATE_SUBDOMAIN', + ], + }), + ], + }) + }) + }) + + it('should disable checkboxes that are already burned', async () => { + render( + , + ) + + const nextButton = screen.getByTestId('permissions-next-button') + + // warning screen + expect( + screen.getByText('input.revokePermissions.views.revokeWarning.subtitle2'), + ).toBeInTheDocument() + expect(nextButton).toHaveTextContent('action.understand') + await userEvent.click(nextButton) + + // revoke permissions + const fusesToBurn = [ + 'CANNOT_CREATE_SUBDOMAIN', + 'CANNOT_TRANSFER', + 'CANNOT_SET_RESOLVER', + 'CANNOT_SET_TTL', + ] + for (const fuse of fusesToBurn) { + // eslint-disable-next-line no-await-in-loop + await userEvent.click(screen.getByTestId(`checkbox-${fuse}`)) + } + await waitFor(() => { + expect(screen.getByTestId(`checkbox-CANNOT_CREATE_SUBDOMAIN`)).toBeDisabled() + expect(screen.getByTestId(`checkbox-CANNOT_TRANSFER`)).toBeDisabled() + expect(nextButton).toHaveTextContent('input.revokePermissions.action.revoke2') + }) + await userEvent.click(nextButton) + + const nameConfirmation = screen.getByTestId('input-name-confirmation') + + fireEvent.change(nameConfirmation, { target: { value: 'sub.test.eth' } }) + + await userEvent.click(nextButton) + + await waitFor(() => { + expect(mockDispatch).toBeCalledWith({ + name: 'setTransactions', + payload: [ + createTransactionItem('changePermissions', { + name: 'sub.test.eth', + contract: 'setFuses', + fuses: ['CANNOT_SET_RESOLVER', 'CANNOT_SET_TTL'], + }), + ], + }) + }) + }) + }) + + describe('revoke-change-fuses', () => { + it('should call dispatch when flow is finished', async () => { + render( + , + ) + + const nextButton = screen.getByTestId('permissions-next-button') + + // warning screen + expect( + screen.getByText('input.revokePermissions.views.revokeWarning.subtitle2'), + ).toBeInTheDocument() + expect(nextButton).toHaveTextContent('action.understand') + await userEvent.click(nextButton) + + // change permissions view + const burnFusesCheckbox = screen.getByTestId('checkbox-CANNOT_BURN_FUSES') + await waitFor(() => { + expect(burnFusesCheckbox).toBeInTheDocument() + expect(burnFusesCheckbox).not.toBeChecked() + expect(nextButton).toBeDisabled() + }) + await userEvent.click(burnFusesCheckbox) + await waitFor(() => { + expect(burnFusesCheckbox).toBeChecked() + expect(nextButton).not.toBeDisabled() + }) + await userEvent.click(nextButton) + + // burn warning permissions + await waitFor(() => { + expect( + screen.getByText('input.revokePermissions.views.revokeChangeFusesWarning.title'), + ).toBeInTheDocument() + }) + await userEvent.click(nextButton) + + const nameConfirmation = screen.getByTestId('input-name-confirmation') + + fireEvent.change(nameConfirmation, { target: { value: 'sub.test.eth' } }) + + await userEvent.click(nextButton) + + await waitFor(() => { + expect(mockDispatch).toBeCalledWith({ + name: 'setTransactions', + payload: [ + createTransactionItem('changePermissions', { + name: 'sub.test.eth', + contract: 'setFuses', + fuses: ['CANNOT_BURN_FUSES'], + }), + ], + }) + }) + }) + }) +}) diff --git a/src/transaction/user/input/RevokePermissions/components/CenterAlignedTypography.tsx b/src/transaction/user/input/RevokePermissions/components/CenterAlignedTypography.tsx new file mode 100644 index 000000000..7d8f9ba70 --- /dev/null +++ b/src/transaction/user/input/RevokePermissions/components/CenterAlignedTypography.tsx @@ -0,0 +1,9 @@ +import styled, { css } from 'styled-components' + +import { Typography } from '@ensdomains/thorin' + +export const CenterAlignedTypography = styled(Typography)( + () => css` + text-align: center; + `, +) diff --git a/src/transaction/user/input/RevokePermissions/components/ControlledNextButton.tsx b/src/transaction/user/input/RevokePermissions/components/ControlledNextButton.tsx new file mode 100644 index 000000000..2b647867e --- /dev/null +++ b/src/transaction/user/input/RevokePermissions/components/ControlledNextButton.tsx @@ -0,0 +1,168 @@ +import { ComponentProps, useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +import { Button } from '@ensdomains/thorin' + +import { AnyFuseKey, CurrentChildFuses, CurrentParentFuses } from '@app/types' + +import type { View } from '../RevokePermissions-flow' + +export const ControlledNextButton = ({ + view, + isLastView, + unburnedFuses, + onIncrement, + onSubmit, + disabled, + parentFuses, + childFuses, + isCustomExpiryValid, +}: { + view: View + isLastView: boolean + parentFuses: CurrentParentFuses + childFuses: CurrentChildFuses + unburnedFuses: AnyFuseKey[] + onIncrement: () => void + onSubmit: () => void + disabled?: boolean + isCustomExpiryValid: boolean +}) => { + const { t } = useTranslation('transactionFlow') + + /** + * Fuses that have burned during this flow. Must breakdown the fuses individually for useMemo to + * work properly. + */ + const fusesBurnedDuringFlow = useMemo(() => { + const allFuses: { [key in AnyFuseKey]: boolean } = { + PARENT_CANNOT_CONTROL: parentFuses.PARENT_CANNOT_CONTROL, + CAN_EXTEND_EXPIRY: parentFuses.CAN_EXTEND_EXPIRY, + CANNOT_UNWRAP: childFuses.CANNOT_UNWRAP, + CANNOT_CREATE_SUBDOMAIN: childFuses.CANNOT_CREATE_SUBDOMAIN, + CANNOT_TRANSFER: childFuses.CANNOT_TRANSFER, + CANNOT_SET_RESOLVER: childFuses.CANNOT_SET_RESOLVER, + CANNOT_SET_TTL: childFuses.CANNOT_SET_TTL, + CANNOT_APPROVE: childFuses.CANNOT_APPROVE, + CANNOT_BURN_FUSES: childFuses.CANNOT_BURN_FUSES, + } + const allFuseKeys = Object.keys(allFuses) as AnyFuseKey[] + const burnedFuses = allFuseKeys.filter((fuse) => allFuses[fuse]) + return burnedFuses.filter((fuse) => unburnedFuses.includes(fuse)) + }, [ + parentFuses.PARENT_CANNOT_CONTROL, + parentFuses.CAN_EXTEND_EXPIRY, + childFuses.CANNOT_UNWRAP, + childFuses.CANNOT_CREATE_SUBDOMAIN, + childFuses.CANNOT_TRANSFER, + childFuses.CANNOT_SET_RESOLVER, + childFuses.CANNOT_SET_TTL, + childFuses.CANNOT_APPROVE, + childFuses.CANNOT_BURN_FUSES, + unburnedFuses, + ]) + + const props: ComponentProps = useMemo(() => { + const defaultProps: ComponentProps = { + disabled: false, + color: 'accent', + count: 0, + onClick: isLastView ? onSubmit : onIncrement, + children: t('action.next', { ns: 'common' }), + } + + switch (view) { + case 'revokeWarning': + return { + ...defaultProps, + color: 'red', + children: t('action.understand', { ns: 'common' }), + } + case 'revokePCC': + return { + ...defaultProps, + disabled: parentFuses.PARENT_CANNOT_CONTROL === false, + } + case 'grantExtendExpiry': + return { + ...defaultProps, + disabled: parentFuses.CAN_EXTEND_EXPIRY === false, + } + case 'setExpiry': { + return { + ...defaultProps, + disabled: !isCustomExpiryValid, + } + } + case 'revokeUnwrap': + return { + ...defaultProps, + disabled: childFuses.CANNOT_UNWRAP === false, + } + case 'parentRevokePermissions': { + const burnedParentFuses = parentFuses.CAN_EXTEND_EXPIRY ? 1 : 0 + const count = childFuses.CANNOT_UNWRAP + ? fusesBurnedDuringFlow.length - 1 + : burnedParentFuses + return { + ...defaultProps, + count, + disabled: fusesBurnedDuringFlow.length === 0, + onClick: onIncrement, + children: + count === 0 + ? t('action.skip', { ns: 'common' }) + : t('input.revokePermissions.action.revoke'), + } + } + case 'revokePermissions': { + const flowIncludesCannotUnwrap = unburnedFuses.includes('CANNOT_UNWRAP') + const count = flowIncludesCannotUnwrap + ? fusesBurnedDuringFlow.length - 1 + : fusesBurnedDuringFlow.length + const buttonTitle = + flowIncludesCannotUnwrap && fusesBurnedDuringFlow.length === 1 + ? t('action.skip', { ns: 'common' }) + : t('input.revokePermissions.action.revoke') + return { + ...defaultProps, + count, + disabled: fusesBurnedDuringFlow.length === 0, + onClick: onIncrement, + children: buttonTitle, + } + } + case 'lastWarning': + return { + ...defaultProps, + onClick: onSubmit, + children: t('action.confirm', { ns: 'common' }), + colorStyle: 'redPrimary', + disabled, + } + case 'revokeChangeFuses': + return { + ...defaultProps, + disabled: childFuses.CANNOT_BURN_FUSES === false, + } + case 'revokeChangeFusesWarning': + return { + ...defaultProps, + onClick: onIncrement, + } + default: + return defaultProps + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + view, + parentFuses, + childFuses, + unburnedFuses, + fusesBurnedDuringFlow, + isCustomExpiryValid, + disabled, + ]) + + return + } + trailing={ + + } + /> + + ) +} + +export default SelectPrimaryName diff --git a/src/transaction/user/input/SelectPrimaryName/SelectPrimaryName.test.tsx b/src/transaction/user/input/SelectPrimaryName/SelectPrimaryName.test.tsx new file mode 100644 index 000000000..784dcaa86 --- /dev/null +++ b/src/transaction/user/input/SelectPrimaryName/SelectPrimaryName.test.tsx @@ -0,0 +1,330 @@ +import { mockFunction, render, screen, userEvent, waitFor } from '@app/test-utils' + +import { labelhash } from 'viem' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { getDecodedName } from '@ensdomains/ensjs/subgraph' +import { decodeLabelhash } from '@ensdomains/ensjs/utils' + +import { usePrimaryName } from '@app/hooks/ensjs/public/usePrimaryName' +import { useNamesForAddress } from '@app/hooks/ensjs/subgraph/useNamesForAddress' +import { useGetPrimaryNameTransactionFlowItem } from '@app/hooks/primary/useGetPrimaryNameTransactionFlowItem' +import { useResolverStatus } from '@app/hooks/resolver/useResolverStatus' +import { useIsWrapped } from '@app/hooks/useIsWrapped' +import { useProfile } from '@app/hooks/useProfile' +import { createTransactionItem } from '@app/transaction-flow/transaction' + +import SelectPrimaryName, { + getNameFromUnknownLabels, + hasEncodedLabel, +} from './SelectPrimaryName-flow' + +const encodeLabel = (label: string) => `[${labelhash(label).slice(2)}]` + +vi.mock('@tanstack/react-query', async () => ({ + ...(await vi.importActual('@tanstack/react-query')), + useQueryClient: vi.fn().mockReturnValue({ + resetQueries: vi.fn(), + }), +})) + +vi.mock('@app/components/@atoms/NameDetailItem/TaggedNameItem', () => ({ + TaggedNameItem: ({ name, ...props }: any) =>
{name}
, +})) + +vi.mock('@ensdomains/ensjs/subgraph') + +vi.mock('@app/hooks/ensjs/subgraph/useNamesForAddress') +vi.mock('@app/hooks/resolver/useResolverStatus') +vi.mock('@app/hooks/useIsWrapped') +vi.mock('@app/hooks/useProfile') +vi.mock('@app/hooks/primary/useGetPrimaryNameTransactionFlowItem') +vi.mock('@app/hooks/ensjs/public/usePrimaryName') + +const mockGetDecodedName = mockFunction(getDecodedName) +const mockUsePrimaryName = mockFunction(usePrimaryName) +mockGetDecodedName.mockImplementation((_: any, { name }) => Promise.resolve(name)) + +const makeName = (index: number, overwrites?: any) => ({ + name: `test${index}.eth`, + id: `0x${index}`, + ...overwrites, +}) +const mockUseNamesForAddress = mockFunction(useNamesForAddress) +mockUseNamesForAddress.mockReturnValue({ + data: { + pages: [ + new Array(5) + .fill(0) + .map((_, i) => makeName(i)) + .flat(), + ], + }, + isLoading: false, +}) + +const mockUseResolverStatus = mockFunction(useResolverStatus) +mockUseResolverStatus.mockReturnValue({ + data: { + isAuthorized: true, + }, + isLoading: false, +}) + +const mockUseIsWrapped = mockFunction(useIsWrapped) +mockUseIsWrapped.mockReturnValue({ + data: false, + isLoading: false, +}) + +const mockUseProfile = mockFunction(useProfile) +mockUseProfile.mockReturnValue({ + data: { + coins: [], + texts: [], + resolverAddress: '0xresolver', + }, + isLoading: false, +}) + +const mockUseGetPrimaryNameTransactionItem = mockFunction(useGetPrimaryNameTransactionFlowItem) +mockUseGetPrimaryNameTransactionItem.mockReturnValue({ + callBack: () => ({ + transactions: [createTransactionItem('setPrimaryName', { name: 'test.eth', address: '0x123' })], + }), + isLoading: false, +}) + +const mockDispatch = vi.fn() + +window.IntersectionObserver = vi.fn().mockReturnValue({ + observe: vi.fn(), + disconnect: vi.fn(), +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('hasEncodedLabel', () => { + it('should return true if an encoded label exists', () => { + expect(hasEncodedLabel(`${encodeLabel('test')}.eth`)).toBe(true) + }) + + it('should return false if an encoded label does not exist', () => { + expect(hasEncodedLabel('test.test.test.eth')).toBe(false) + }) +}) + +describe('getNameFromUnknownLabels', () => { + it('should return the name if no encoded label exists', () => { + expect(getNameFromUnknownLabels('test.test.eth', { labels: [], tld: '' })).toBe('test.test.eth') + }) + + it('should return the decoded name if encoded label exists', () => { + expect( + getNameFromUnknownLabels( + `${encodeLabel('test1')}.${encodeLabel('test2')}.${encodeLabel('test3')}.eth`, + { + labels: [ + { label: decodeLabelhash(encodeLabel('test1')), value: 'test1', disabled: false }, + { label: decodeLabelhash(encodeLabel('test2')), value: 'test2', disabled: false }, + { label: decodeLabelhash(encodeLabel('test3')), value: 'test3', disabled: false }, + ], + tld: 'eth', + }, + ), + ).toBe('test1.test2.test3.eth') + }) + + it('should skip unknown labels if they do not match the original labels', () => { + expect( + getNameFromUnknownLabels( + `${encodeLabel('test1')}.${encodeLabel('test2')}.${encodeLabel('test3')}.eth`, + { + labels: [ + { label: decodeLabelhash(encodeLabel('test2')), value: 'test2', disabled: false }, + { label: decodeLabelhash(encodeLabel('test2')), value: 'test2', disabled: false }, + { label: decodeLabelhash(encodeLabel('test2')), value: 'test2', disabled: false }, + ], + tld: 'eth', + }, + ), + ).toBe(`${encodeLabel('test1')}.test2.${encodeLabel('test3')}.eth`) + }) + + it('should be able to handle mixed encoded and decoded names', () => { + expect( + getNameFromUnknownLabels(`${encodeLabel('test1')}.test2.${encodeLabel('test3')}.eth`, { + labels: [ + { label: decodeLabelhash(encodeLabel('test2')), value: 'test2', disabled: false }, + { label: 'test2', value: 'test2', disabled: true }, + { label: decodeLabelhash(encodeLabel('test2')), value: 'test2', disabled: false }, + ], + tld: 'eth', + }), + ).toBe(`${encodeLabel('test1')}.test2.${encodeLabel('test3')}.eth`) + }) +}) + +describe('SelectPrimaryName', () => { + it('should show loading if data hook is loading', async () => { + mockUseNamesForAddress.mockReturnValueOnce({ + data: undefined, + isLoading: true, + }) + render( + {}} + />, + ) + await waitFor(() => expect(screen.getByText('loading')).toBeInTheDocument()) + }) + + it('should show no name message if data returns an empty array', async () => { + mockUseNamesForAddress.mockReturnValueOnce({ + data: { + pages: [[]], + }, + isLoading: false, + }) + render( + {}} onDismiss={() => {}} />, + ) + await waitFor(() => + expect( + screen.getByText('input.selectPrimaryName.errors.noEligibleNames'), + ).toBeInTheDocument(), + ) + }) + + it('should show names', async () => { + render( + {}} onDismiss={() => {}} />, + ) + await waitFor(() => { + expect(screen.getByText('test1.eth')).toBeInTheDocument() + expect(screen.getByText('test2.eth')).toBeInTheDocument() + expect(screen.getByText('test3.eth')).toBeInTheDocument() + }) + }) + + it('should not show primary name in list', async () => { + mockUsePrimaryName.mockReturnValue({ + data: { + name: 'test2.eth', + beautifiedName: 'test2.eth', + }, + isLoading: false, + status: 'success', + }) + render( + {}} onDismiss={() => {}} />, + ) + await waitFor(() => { + expect(screen.getByText('test1.eth')).toBeInTheDocument() + expect(screen.queryByText('test2.eth')).not.toBeInTheDocument() + expect(screen.getByText('test3.eth')).toBeInTheDocument() + }) + }) + + it('should only enable next button if name selected', async () => { + render( + {}} onDismiss={() => {}} />, + ) + expect(screen.getByTestId('primary-next')).toBeDisabled() + await userEvent.click(screen.getByText('test1.eth')) + await waitFor(() => expect(screen.getByTestId('primary-next')).not.toBeDisabled()) + }) + + it('should call dispatch if name is selected and next is clicked', async () => { + render( + {}} + />, + ) + await userEvent.click(screen.getByText('test1.eth')) + await userEvent.click(screen.getByTestId('primary-next')) + await waitFor(() => expect(mockDispatch).toBeCalled()) + }) + + it('should call dispatch if encrpyted name can be decrypted', async () => { + mockUseNamesForAddress.mockReturnValueOnce({ + data: { + pages: [ + [ + ...new Array(5).fill(0).map((_, i) => makeName(i)), + { + name: `${encodeLabel('test')}.eth`, + id: '0xhash', + }, + ], + ], + }, + isLoading: false, + }) + mockGetDecodedName.mockReturnValueOnce(Promise.resolve('test.eth')) + render( + {}} + />, + ) + await userEvent.click(screen.getByText(`${encodeLabel('test')}.eth`)) + await userEvent.click(screen.getByTestId('primary-next')) + expect(mockDispatch).toHaveBeenCalled() + }) + + it('should be able to decrpyt name and dispatch', async () => { + mockUseNamesForAddress.mockReturnValue({ + data: { + pages: [ + [ + ...new Array(3).fill(0).map((_, i) => makeName(i)), + { + name: `${encodeLabel('test')}.eth`, + id: '0xhash', + }, + ], + ], + }, + isLoading: false, + }) + mockGetDecodedName.mockReturnValueOnce(Promise.resolve(`${encodeLabel('test')}.eth`)) + render( + {}} + />, + ) + expect(screen.getByTestId('primary-next')).toBeDisabled() + await userEvent.click(screen.getByText(`${encodeLabel('test')}.eth`)) + await waitFor(() => expect(screen.getByTestId('primary-next')).not.toBeDisabled()) + await userEvent.click(screen.getByTestId('primary-next')) + await waitFor(() => expect(screen.getByTestId('unknown-labels-form')).toBeInTheDocument()) + await userEvent.type(screen.getByTestId(`unknown-label-input-${labelhash('test')}`), 'test') + await waitFor(() => expect(screen.getByTestId('unknown-labels-confirm')).not.toBeDisabled()) + await userEvent.click(screen.getByTestId('unknown-labels-confirm')) + expect(mockDispatch).toHaveBeenCalled() + expect(mockDispatch.mock.calls[0][0].payload[0]).toMatchInlineSnapshot( + { + data: { name: 'test.eth' }, + }, + ` + { + "data": { + "address": "0x123", + "name": "test.eth", + }, + "name": "setPrimaryName", + } + `, + ) + }) +}) diff --git a/src/transaction/user/input/SelectPrimaryName/components/TaggedNameItemWithFuseCheck.test.tsx b/src/transaction/user/input/SelectPrimaryName/components/TaggedNameItemWithFuseCheck.test.tsx new file mode 100644 index 000000000..572f432e5 --- /dev/null +++ b/src/transaction/user/input/SelectPrimaryName/components/TaggedNameItemWithFuseCheck.test.tsx @@ -0,0 +1,147 @@ +import { mockFunction, render, screen } from '@app/test-utils' + +import { describe, expect, it, vi } from 'vitest' + +import { useResolverStatus } from '@app/hooks/resolver/useResolverStatus' + +import { TaggedNameItemWithFuseCheck } from './TaggedNameItemWithFuseCheck' + +vi.mock('@app/hooks/resolver/useResolverStatus') + +vi.mock('@app/components/@atoms/NameDetailItem/TaggedNameItem', () => ({ + TaggedNameItem: ({ name }: any) =>
{name}
, +})) + +const mockUseResolverStatus = mockFunction(useResolverStatus) +mockUseResolverStatus.mockReturnValue({ + data: { + isAuthorized: true, + }, + isLoading: false, +}) + +const baseProps: any = { + name: 'test.eth', + relation: { + resolvedAddress: true, + wrappedOwner: false, + }, + fuses: {}, +} + +describe('TaggedNameItemWithFuseCheck', () => { + it('should render a tagged name item with mock data', () => { + render() + expect(screen.getByText('test.eth')).toBeVisible() + }) + + it('should not render a tagged name item with mock data', () => { + mockUseResolverStatus.mockReturnValueOnce({ + data: { + isAuthorized: false, + }, + isLoading: false, + }) + render( + , + ) + expect(screen.queryByText('test.eth')).toBe(null) + }) + + it('should render a tagged name item if isAuthorized is true', () => { + mockUseResolverStatus.mockReturnValue({ + data: { + isAuthorized: true, + }, + isLoading: false, + }) + render( + , + ) + expect(screen.getByText('test.eth')).toBeVisible() + }) + + it('should render a tagged name item if isResolvedAddress is true', () => { + mockUseResolverStatus.mockReturnValueOnce({ + data: { + isAuthorized: false, + }, + isLoading: false, + }) + render( + , + ) + expect(screen.getByText('test.eth')).toBeInTheDocument() + }) + + it('should render a tagged name item if isWrappedOwner is false', () => { + mockUseResolverStatus.mockReturnValueOnce({ + data: { + isAuthorized: false, + }, + isLoading: false, + }) + render( + , + ) + expect(screen.getByText('test.eth')).toBeVisible() + }) + + it('should render a tagged name item if CANNOT_SET_RESOLVER is false', () => { + mockUseResolverStatus.mockReturnValueOnce({ + data: { + isAuthorized: false, + }, + isLoading: false, + }) + render( + , + ) + expect(screen.getByText('test.eth')).toBeVisible() + }) +}) diff --git a/src/transaction/user/input/SelectPrimaryName/components/TaggedNameItemWithFuseCheck.tsx b/src/transaction/user/input/SelectPrimaryName/components/TaggedNameItemWithFuseCheck.tsx new file mode 100644 index 000000000..e1f08ce22 --- /dev/null +++ b/src/transaction/user/input/SelectPrimaryName/components/TaggedNameItemWithFuseCheck.tsx @@ -0,0 +1,21 @@ +import { ComponentProps, useMemo } from 'react' + +import { TaggedNameItem } from '@app/components/@atoms/NameDetailItem/TaggedNameItem' +import { useResolverStatus } from '@app/hooks/resolver/useResolverStatus' + +type Props = ComponentProps +export const TaggedNameItemWithFuseCheck = (props: Props) => { + const { relation, fuses, name } = props + const skip = + relation?.resolvedAddress || !relation?.wrappedOwner || !fuses?.child.CANNOT_SET_RESOLVER + + const resolverStatus = useResolverStatus({ name: name!, enabled: !skip }) + + const isFuseCheckSuccess = useMemo(() => { + if (skip) return true + return resolverStatus.data?.isAuthorized ?? false + }, [skip, resolverStatus.data]) + + if (isFuseCheckSuccess) return + return null +} diff --git a/src/transaction/user/input/SendName/SendName-flow.tsx b/src/transaction/user/input/SendName/SendName-flow.tsx new file mode 100644 index 000000000..d8b4372ae --- /dev/null +++ b/src/transaction/user/input/SendName/SendName-flow.tsx @@ -0,0 +1,153 @@ +import { useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { match, P } from 'ts-pattern' +import { Address } from 'viem' + +import { useAbilities } from '@app/hooks/abilities/useAbilities' +import { useAccountSafely } from '@app/hooks/account/useAccountSafely' +import { useResolver } from '@app/hooks/ensjs/public/useResolver' +import { useNameType } from '@app/hooks/nameType/useNameType' +import useRoles from '@app/hooks/ownership/useRoles/useRoles' +import { useBasicName } from '@app/hooks/useBasicName' +import { useResolverHasInterfaces } from '@app/hooks/useResolverHasInterfaces' +import { TransactionDialogPassthrough } from '@app/transaction-flow/types' + +import { checkCanSend, senderRole } from './utils/checkCanSend' +import { getSendNameTransactions } from './utils/getSendNameTransactions' +import { CannotSendView } from './views/CannotSendView' +import { ConfirmationView } from './views/ConfirmationView' +import { SearchView } from './views/SearchView/SearchView' +import { SummaryView } from './views/SummaryView/SummaryView' + +export type SendNameForm = { + query: '' + recipient: Address | undefined + transactions: { + sendOwner: boolean + sendManager: boolean + setEthRecord: boolean + resetProfile: boolean + } +} + +type Data = { + name: string +} + +export type Props = { + data: Data +} & TransactionDialogPassthrough + +const SendName = ({ data: { name }, dispatch, onDismiss }: Props) => { + const account = useAccountSafely() + const abilities = useAbilities({ name }) + const nameType = useNameType(name) + const basic = useBasicName({ name }) + const roles = useRoles(name) + const resolver = useResolver({ name }) + const resolverSupport = useResolverHasInterfaces({ + interfaceNames: ['VersionableResolver'], + resolverAddress: resolver.data as Address, + enabled: !!resolver.data, + }) + const _senderRole = senderRole(nameType.data) + + const flow = ['search', 'summary', 'confirmation'] as const + const [viewIndex, setViewIndex] = useState(0) + const view = flow[viewIndex] + const onNext = () => setViewIndex((i) => Math.min(i + 1, flow.length - 1)) + const onBack = () => setViewIndex((i) => Math.max(i - 1, 0)) + + const form = useForm({ + defaultValues: { + query: '', + recipient: undefined, + transactions: { + sendOwner: false, + sendManager: false, + setEthRecord: false, + resetProfile: false, + }, + }, + }) + const { setValue } = form + + const onSelect = (recipient: Address) => { + if (!recipient) return + const currentOwner = roles.data?.find((role) => role.role === 'owner')?.address + const currentManager = roles.data?.find((role) => role.role === 'manager')?.address + const currentEthRecord = roles.data?.find((role) => role.role === 'eth-record')?.address + + setValue('recipient', recipient) + setValue('transactions', { + sendOwner: + abilities.data.canSendOwner && recipient.toLowerCase() !== currentOwner?.toLowerCase(), + sendManager: + abilities.data.canSendManager && recipient.toLowerCase() !== currentManager?.toLowerCase(), + setEthRecord: + abilities.data.canEditRecords && + recipient.toLowerCase() !== currentEthRecord?.toLowerCase(), + resetProfile: false, + }) + onNext() + } + + const onSubmit = ({ recipient, transactions }: SendNameForm) => { + const isOwnerOrManager = + account.address === basic.ownerData?.owner || basic.ownerData?.registrant === account.address + + const _transactions = getSendNameTransactions({ + name, + recipient, + transactions, + isOwnerOrManager, + abilities: abilities.data, + resolverAddress: resolver.data, + }) + + if (_transactions.length === 0) return + + dispatch({ + name: 'setTransactions', + payload: _transactions, + }) + + dispatch({ + name: 'setFlowStage', + payload: 'transaction', + }) + } + + const canSend = checkCanSend({ abilities: abilities.data, nameType: nameType.data }) + const canResetProfile = + abilities.data.canEditRecords && !!resolverSupport.data?.every((i) => !!i) && !!resolver.data + + return ( + + {match([canSend, view]) + .with([false, P._], () => ) + .with([true, 'confirmation'], () => ( + + )) + .with([true, 'summary'], () => ( + + )) + .with([true, 'search'], () => ( + + )) + .exhaustive()} + + ) +} + +export default SendName diff --git a/src/transaction/user/input/SendName/SendName.test.tsx b/src/transaction/user/input/SendName/SendName.test.tsx new file mode 100644 index 000000000..ad7703403 --- /dev/null +++ b/src/transaction/user/input/SendName/SendName.test.tsx @@ -0,0 +1,117 @@ +import { render, screen, userEvent } from '@app/test-utils' + +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +import SendName from './SendName-flow' + +vi.mock('@app/hooks/account/useAccountSafely', () => ({ + useAccountSafely: () => ({ address: '0xowner' }), +})) + +vi.mock('@app/hooks/useBasicName', () => ({ + useBasicName: () => ({ + ownerData: { + owner: '0xmanager', + registrant: '0xowner', + }, + isLoading: false, + }), +})) + +vi.mock('@app/hooks/ownership/useRoles/useRoles', () => ({ + default: () => ({ + data: [ + { + role: 'owner', + address: '0xowner', + }, + { + role: 'manager', + address: '0xmanager', + }, + { + role: 'eth-record', + address: '0xeth-record', + }, + { + role: 'parent-owner', + address: '0xparent-address', + }, + { + role: 'dns-owner', + address: '0xdns-owner', + }, + ], + isLoading: false, + }), +})) + +vi.mock('@app/hooks/abilities/useAbilities', () => ({ + useAbilities: () => ({ + data: { + canSendOwner: true, + canSendManager: true, + canEditRecords: true, + sendNameFunctionCallDetails: { + sendManager: { + contract: 'contract', + }, + sendOwner: { + contract: 'contract', + }, + }, + }, + isLoading: false, + }), +})) + +let searchData: any[] = [] +vi.mock('@app/transaction-flow/input/EditRoles/hooks/useSimpleSearch.ts', () => ({ + useSimpleSearch: () => ({ + mutate: (query: string) => { + searchData = [{ name: `${query}.eth`, address: `0x${query}` }] + }, + data: searchData, + isLoading: false, + isSuccess: true, + }), +})) + +vi.mock('@app/components/@molecules/AvatarWithIdentifier/AvatarWithIdentifier', () => ({ + AvatarWithIdentifier: ({ name, address }: any) => ( +
+ {name} + {address} +
+ ), +})) + +const mockDispatch = vi.fn() + +beforeAll(() => { + const spyiedScroll = vi.spyOn(window, 'scroll') + spyiedScroll.mockImplementation(() => {}) + window.IntersectionObserver = vi.fn().mockReturnValue({ + observe: () => null, + unobserve: () => null, + disconnect: () => null, + }) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('SendName', () => { + it('should render', async () => { + render( {}} />) + await userEvent.type(screen.getByTestId('send-name-search-input'), 'nick') + await userEvent.click(screen.getByTestId('search-result-0xnick')) + }) + + it('should disable the row if it is the current send role ', async () => { + render( {}} />) + await userEvent.type(screen.getByTestId('send-name-search-input'), 'owner') + expect(screen.getByTestId('search-result-0xowner')).toBeDisabled() + }) +}) diff --git a/src/transaction/user/input/SendName/utils/checkCanSend.ts b/src/transaction/user/input/SendName/utils/checkCanSend.ts new file mode 100644 index 000000000..d8a22cfe9 --- /dev/null +++ b/src/transaction/user/input/SendName/utils/checkCanSend.ts @@ -0,0 +1,58 @@ +import { match, P } from 'ts-pattern' + +import { useAbilities } from '@app/hooks/abilities/useAbilities' +import { useNameType } from '@app/hooks/nameType/useNameType' + +export const senderRole = (nameType: ReturnType['data']) => { + return match(nameType) + .with( + P.union( + 'eth-unwrapped-2ld', + 'eth-emancipated-2ld', + 'eth-locked-2ld', + 'eth-emancipated-subname', + 'eth-locked-subname', + 'dns-emancipated-2ld', + 'dns-locked-2ld', + 'dns-emancipated-subname', + 'dns-locked-subname', + ), + () => 'owner' as const, + ) + .with( + P.union( + 'eth-unwrapped-subname', + 'eth-wrapped-subname', + 'eth-pcc-expired-subname', + 'dns-unwrapped-subname', + 'dns-wrapped-subname', + 'dns-pcc-expired-subname', + ), + () => 'manager' as const, + ) + .with( + P.union( + 'dns-unwrapped-2ld', + 'dns-wrapped-2ld', + 'eth-emancipated-2ld:grace-period', + 'eth-locked-2ld:grace-period', + 'eth-unwrapped-2ld:grace-period', + ), + () => null, + ) + .with(P.union(P.nullish, 'root', 'tld'), () => null) + .exhaustive() +} + +export const checkCanSend = ({ + abilities, + nameType, +}: { + abilities: ReturnType['data'] + nameType: ReturnType['data'] +}) => { + const role = senderRole(nameType) + if (role === 'manager' && !!abilities?.canSendManager) return true + if (role === 'owner' && !!abilities?.canSendOwner) return true + return false +} diff --git a/src/transaction/user/input/SendName/utils/getSendNameTransactions.test.ts b/src/transaction/user/input/SendName/utils/getSendNameTransactions.test.ts new file mode 100644 index 000000000..579648951 --- /dev/null +++ b/src/transaction/user/input/SendName/utils/getSendNameTransactions.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from 'vitest' + +import { createTransactionItem } from '@app/transaction-flow/transaction' + +import { getSendNameTransactions } from './getSendNameTransactions' + +describe('getSendNameTransactions', () => { + it('should return 3 transactions (resetProfileWithRecords, transferName, transferName) if setEthRecord, resetProfile, sendManager and sendOwner is true', () => { + expect( + getSendNameTransactions({ + name: 'test.eth', + recipient: '0xrecipient', + transactions: { + setEthRecord: true, + resetProfile: true, + sendManager: true, + sendOwner: true, + }, + abilities: { + sendNameFunctionCallDetails: { + sendOwner: { + contract: 'registry', + method: 'safeTransferFrom', + }, + sendManager: { + contract: 'registrar', + method: 'reclaim', + }, + }, + } as any, + isOwnerOrManager: true, + resolverAddress: '0xresolver', + }), + ).toEqual([ + createTransactionItem('resetProfileWithRecords', { + name: 'test.eth', + records: { coins: [{ coin: 'ETH', value: '0xrecipient' }] }, + resolverAddress: '0xresolver', + }), + createTransactionItem('transferName', { + name: 'test.eth', + newOwnerAddress: '0xrecipient', + sendType: 'sendManager', + contract: 'registrar', + reclaim: true, + }), + createTransactionItem('transferName', { + name: 'test.eth', + newOwnerAddress: '0xrecipient', + sendType: 'sendOwner', + contract: 'registry', + }), + ]) + }) + + it('should return 3 transactions (resetProfileWithRecords, transferName, transferName) if setEthRecord, resetProfile, sendManager and sendOwner is true', () => { + expect( + getSendNameTransactions({ + name: 'test.eth', + recipient: '0xrecipient', + transactions: { + setEthRecord: false, + resetProfile: true, + sendManager: true, + sendOwner: true, + }, + abilities: { + sendNameFunctionCallDetails: { + sendOwner: { + contract: 'registry', + method: 'safeTransferFrom', + }, + sendManager: { + contract: 'registrar', + method: 'safeTransferFrom', + }, + }, + } as any, + isOwnerOrManager: true, + resolverAddress: '0xresolver', + }), + ).toEqual([ + createTransactionItem('resetProfileWithRecords', { + name: 'test.eth', + records: { coins: [{ coin: 'ETH', value: '0xrecipient' }] }, + resolverAddress: '0xresolver', + }), + createTransactionItem('transferName', { + name: 'test.eth', + newOwnerAddress: '0xrecipient', + sendType: 'sendManager', + contract: 'registrar', + reclaim: false, + }), + createTransactionItem('transferName', { + name: 'test.eth', + newOwnerAddress: '0xrecipient', + sendType: 'sendOwner', + contract: 'registry', + }), + ]) + }) + + it('should return 3 transactions (updateEthAddress, transferName, transferName) if resetProfile, sendManager and sendOwner is true', () => { + expect( + getSendNameTransactions({ + name: 'test.eth', + recipient: '0xrecipient', + transactions: { + setEthRecord: true, + resetProfile: false, + sendManager: true, + sendOwner: true, + }, + abilities: { + sendNameFunctionCallDetails: { + sendOwner: { + contract: 'registry', + method: 'safeTransferFrom', + }, + sendManager: { + contract: 'registrar', + method: 'reclaim', + }, + }, + } as any, + isOwnerOrManager: true, + resolverAddress: '0xresolver', + }), + ).toEqual([ + createTransactionItem('updateEthAddress', { name: 'test.eth', address: '0xrecipient' }), + createTransactionItem('transferName', { + name: 'test.eth', + newOwnerAddress: '0xrecipient', + sendType: 'sendManager', + contract: 'registrar', + reclaim: true, + }), + createTransactionItem('transferName', { + name: 'test.eth', + newOwnerAddress: '0xrecipient', + sendType: 'sendOwner', + contract: 'registry', + }), + ]) + }) + + it('should return 2 transactions (transferName, transferName) if sendManager and sendOwner is true', () => { + expect( + getSendNameTransactions({ + name: 'test.eth', + recipient: '0xrecipient', + transactions: { + setEthRecord: false, + resetProfile: false, + sendManager: true, + sendOwner: true, + }, + abilities: { + sendNameFunctionCallDetails: { + sendOwner: { + contract: 'registry', + method: 'safeTransferFrom', + }, + sendManager: { + contract: 'registrar', + method: 'reclaim', + }, + }, + } as any, + isOwnerOrManager: true, + resolverAddress: '0xresolver', + }), + ).toEqual([ + createTransactionItem('transferName', { + name: 'test.eth', + newOwnerAddress: '0xrecipient', + sendType: 'sendManager', + contract: 'registrar', + reclaim: true, + }), + createTransactionItem('transferName', { + name: 'test.eth', + newOwnerAddress: '0xrecipient', + sendType: 'sendOwner', + contract: 'registry', + }), + ]) + }) + + it('should return 2 transactions (transferSubname, transferSubname) if sendManager and sendOwner is true and isOwnerOrManager is false', () => { + expect( + getSendNameTransactions({ + name: 'test.eth', + recipient: '0xrecipient', + transactions: { + setEthRecord: false, + resetProfile: false, + sendManager: true, + sendOwner: true, + }, + abilities: { + sendNameFunctionCallDetails: { + sendOwner: { + contract: 'registry', + method: 'safeTransferFrom', + }, + sendManager: { + contract: 'registrar', + method: 'reclaim', + }, + }, + } as any, + isOwnerOrManager: true, + resolverAddress: '0xresolver', + }), + ).toEqual([ + createTransactionItem('transferName', { + name: 'test.eth', + newOwnerAddress: '0xrecipient', + sendType: 'sendManager', + contract: 'registrar', + reclaim: true, + }), + createTransactionItem('transferName', { + name: 'test.eth', + newOwnerAddress: '0xrecipient', + sendType: 'sendOwner', + contract: 'registry', + }), + ]) + }) + + it('should return 0 transactions if sendManager and sendOwner is true but abilities.sendNameFunctionCallDetails is undefined', () => { + expect( + getSendNameTransactions({ + name: 'test.eth', + recipient: '0xrecipient', + transactions: { + setEthRecord: false, + resetProfile: false, + sendManager: true, + sendOwner: true, + }, + abilities: { + sendNameFunctionCallDetails: undefined, + } as any, + isOwnerOrManager: true, + resolverAddress: '0xresolver', + }), + ).toEqual([]) + }) +}) diff --git a/src/transaction/user/input/SendName/utils/getSendNameTransactions.ts b/src/transaction/user/input/SendName/utils/getSendNameTransactions.ts new file mode 100644 index 000000000..b721efa9c --- /dev/null +++ b/src/transaction/user/input/SendName/utils/getSendNameTransactions.ts @@ -0,0 +1,72 @@ +import { Address } from 'viem' + +import type { useAbilities } from '@app/hooks/abilities/useAbilities' +import { createTransactionItem, TransactionItem } from '@app/transaction-flow/transaction' +import { makeTransferNameOrSubnameTransactionItem } from '@app/transaction-flow/transaction/utils/makeTransferNameOrSubnameTransactionItem' + +import type { SendNameForm } from '../SendName-flow' + +export const getSendNameTransactions = ({ + name, + recipient, + transactions, + abilities, + isOwnerOrManager, + resolverAddress, +}: { + name: string + recipient: SendNameForm['recipient'] + transactions: SendNameForm['transactions'] + abilities: ReturnType['data'] + isOwnerOrManager: boolean + resolverAddress?: Address | null +}) => { + if (!recipient) return [] + + const setEthRecordOnly = transactions.setEthRecord && !transactions.resetProfile + // Anytime you reset the profile you will need to set the eth record as well + const setEthRecordAndResetProfile = transactions.resetProfile + + const _transactions = [ + setEthRecordOnly + ? createTransactionItem('updateEthAddress', { name, address: recipient }) + : null, + setEthRecordAndResetProfile && resolverAddress + ? createTransactionItem('resetProfileWithRecords', { + name, + records: { + coins: [{ coin: 'ETH', value: recipient }], + }, + resolverAddress, + }) + : null, + transactions.sendManager + ? makeTransferNameOrSubnameTransactionItem({ + name, + newOwnerAddress: recipient, + sendType: 'sendManager', + isOwnerOrManager, + abilities, + }) + : null, + transactions.sendOwner + ? makeTransferNameOrSubnameTransactionItem({ + name, + newOwnerAddress: recipient, + sendType: 'sendOwner', + isOwnerOrManager, + abilities, + }) + : null, + ].filter( + ( + transaction, + ): transaction is + | TransactionItem<'transferName'> + | TransactionItem<'transferSubname'> + | TransactionItem<'updateEthAddress'> + | TransactionItem<'resetProfileWithRecords'> => !!transaction, + ) + + return _transactions as NonNullable<(typeof _transactions)[number]>[] +} diff --git a/src/transaction/user/input/SendName/views/CannotSendView.tsx b/src/transaction/user/input/SendName/views/CannotSendView.tsx new file mode 100644 index 000000000..426650215 --- /dev/null +++ b/src/transaction/user/input/SendName/views/CannotSendView.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' + +import { Button, Dialog, Typography } from '@ensdomains/thorin' + +const CenteredTypography = styled(Typography)( + () => css` + text-align: center; + `, +) + +type Props = { + onDismiss: () => void +} + +export const CannotSendView = ({ onDismiss }: Props) => { + const { t } = useTranslation('transactionFlow') + return ( + <> + + {t('input.sendName.views.error.description')} + + {t('action.cancel', { ns: 'common' })} + + } + /> + + ) +} diff --git a/src/transaction/user/input/SendName/views/ConfirmationView.tsx b/src/transaction/user/input/SendName/views/ConfirmationView.tsx new file mode 100644 index 000000000..d7b1cadf6 --- /dev/null +++ b/src/transaction/user/input/SendName/views/ConfirmationView.tsx @@ -0,0 +1,102 @@ +import { useRef } from 'react' +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' + +import { Button, Dialog, OutlinkSVG, QuestionSVG, Typography } from '@ensdomains/thorin' + +import { getSupportLink } from '@app/utils/supportLinks' + +const CenteredTypography = styled(Typography)( + () => css` + text-align: center; + `, +) + +const Link = styled.a( + ({ theme }) => css` + display: flex; + align-items: center; + gap: ${theme.space[1]}; + `, +) + +const IconWrapper = styled.div( + ({ theme }) => css` + display: flex; + justify-content: center; + align-items: center; + position: relative; + width: ${theme.space[5]}; + height: ${theme.space[5]}; + background-color: ${theme.colors.indigo}; + color: ${theme.colors.background}; + border-radius: ${theme.radii.full}; + + svg { + width: 60%; + height: 60%; + } + `, +) + +const OutlinkWrapper = styled.div( + ({ theme }) => css` + width: ${theme.space[3]}; + height: ${theme.space[3]}; + color: ${theme.colors.indigo}; + `, +) + +type Props = { + onSubmit: () => void + onBack: () => void +} + +export const ConfirmationView = ({ onSubmit, onBack }: Props) => { + const { t } = useTranslation('transactionFlow') + const link = getSupportLink('sendingNames') + const formRef = useRef(null) + return ( + <> + + + + {t('input.sendName.views.confirmation.description')} + + + {t('input.sendName.views.confirmation.warning')} + + {link && ( + + + + + + {t('input.sendName.views.confirmation.learnMore')} + + + + )} + + + {t('action.back', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} diff --git a/src/transaction/user/input/SendName/views/SearchView/SearchView.tsx b/src/transaction/user/input/SendName/views/SearchView/SearchView.tsx new file mode 100644 index 000000000..f56d37850 --- /dev/null +++ b/src/transaction/user/input/SendName/views/SearchView/SearchView.tsx @@ -0,0 +1,92 @@ +import { useEffect } from 'react' +import { useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { match, P } from 'ts-pattern' +import { Address } from 'viem' + +import { Button, Dialog, MagnifyingGlassSimpleSVG } from '@ensdomains/thorin' + +import { DialogFooterWithBorder } from '@app/components/@molecules/DialogComponentVariants/DialogFooterWithBorder' +import { DialogInput } from '@app/components/@molecules/DialogComponentVariants/DialogInput' +import { useSimpleSearch } from '@app/transaction-flow/input/EditRoles/hooks/useSimpleSearch' + +import type { SendNameForm } from '../../SendName-flow' +import { SearchViewErrorView } from './views/SearchViewErrorView' +import { SearchViewIntroView } from './views/SearchViewIntroView' +import { SearchViewLoadingView } from './views/SearchViewLoadingView' +import { SearchViewNoResultsView } from './views/SearchViewNoResultsView' +import { SearchViewResultsView } from './views/SearchViewResultsView' + +type Props = { + name: string + senderRole?: 'owner' | 'manager' | null + onSelect: (address: Address) => void + onCancel: () => void +} + +export const SearchView = ({ name, senderRole, onCancel, onSelect }: Props) => { + const { t } = useTranslation('transactionFlow') + const { register, watch, setValue } = useFormContext() + const query = watch('query') + const search = useSimpleSearch() + + // Set search results when coming back from summary view + useEffect(() => { + if (query.length > 2) search.mutate(query) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + <> + + } + clearable + {...register('query', { + onChange: (e) => { + const newQuery = e.currentTarget.value + if (newQuery.length < 3) return + search.mutate(newQuery) + }, + })} + placeholder={t('input.sendName.views.search.placeholder')} + onClickAction={() => { + setValue('query', '') + }} + /> + + {match([query, search]) + .with([P._, { isError: true }], () => ) + .with([P.when((s: string) => !s || s.length < 3), P._], () => ) + .with([P._, { isSuccess: false }], () => ) + .with( + [P._, { isSuccess: true, data: P.when((d) => !!d && d.length > 0) }], + ([, { data }]) => ( + + ), + ) + .with([P._, { isSuccess: true, data: P.when((d) => !d || d.length === 0) }], () => ( + + )) + .otherwise(() => null)} + + + {t('action.cancel', { ns: 'common' })} + + } + /> + + ) +} diff --git a/src/transaction/user/input/SendName/views/SearchView/components/SearchViewResult.tsx b/src/transaction/user/input/SendName/views/SearchView/components/SearchViewResult.tsx new file mode 100644 index 000000000..0720e0ab0 --- /dev/null +++ b/src/transaction/user/input/SendName/views/SearchView/components/SearchViewResult.tsx @@ -0,0 +1,97 @@ +import { ButtonHTMLAttributes, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' +import { Address } from 'viem' + +import { mq, Tag } from '@ensdomains/thorin' + +import { AvatarWithIdentifier } from '@app/components/@molecules/AvatarWithIdentifier/AvatarWithIdentifier' +import type { Role, RoleRecord } from '@app/hooks/ownership/useRoles/useRoles' + +const LeftContainer = styled.div(() => css``) + +const RightContainer = styled.div( + ({ theme }) => css` + display: flex; + align-items: center; + flex-flow: row wrap; + gap: ${theme.space[2]}; + `, +) + +const TagText = styled.span( + () => css` + ::first-letter { + text-transform: capitalize; + } + `, +) + +const Container = styled.button(({ theme }) => [ + css` + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: ${theme.space[4]}; + gap: ${theme.space[6]}; + border-bottom: 1px solid ${theme.colors.border}; + transition: background-color 0.3s ease; + + :hover { + background-color: ${theme.colors.accentSurface}; + } + + :disabled { + background-color: ${theme.colors.greySurface}; + ${LeftContainer} { + opacity: 0.5; + } + } + `, + mq.sm.min(css` + padding: ${theme.space[4]} ${theme.space[6]}; + `), +]) + +type Props = { + name?: string + address: Address + excludeRole?: Role | null + roles: RoleRecord[] +} & Omit, 'children'> + +export const SearchViewResult = ({ address, name, excludeRole: role, roles, ...props }: Props) => { + const { t } = useTranslation('transactionFlow') + const markers = useMemo(() => { + const userRoles = roles.filter((r) => r.address?.toLowerCase() === address.toLowerCase()) + const hasRole = userRoles.some((r) => r.role === role) + const primaryRole = userRoles[0] + return { userRoles, hasRole, primaryRole } + }, [roles, role, address]) + + return ( + + + + + {markers.primaryRole && ( + + + {t(`roles.${markers.primaryRole?.role}.title`, { ns: 'common' })} + + + )} + + ) +} diff --git a/src/transaction/user/input/SendName/views/SearchView/views/SearchViewErrorView.tsx b/src/transaction/user/input/SendName/views/SearchView/views/SearchViewErrorView.tsx new file mode 100644 index 000000000..bb3769544 --- /dev/null +++ b/src/transaction/user/input/SendName/views/SearchView/views/SearchViewErrorView.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' + +import { AlertSVG, Typography } from '@ensdomains/thorin' + +const Container = styled.div( + ({ theme }) => css` + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + min-height: ${theme.space['40']}; + `, +) + +const Message = styled.div( + ({ theme }) => css` + color: ${theme.colors.red}; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: ${theme.space[2]}; + max-width: ${theme.space['44']}; + text-align: center; + svg { + width: ${theme.space[5]}; + height: ${theme.space[5]}; + } + `, +) + +export const SearchViewErrorView = () => { + const { t } = useTranslation('transactionFlow') + return ( + + + + + {t('input.sendName.views.search.views.error.message')} + + + + ) +} diff --git a/src/transaction/user/input/SendName/views/SearchView/views/SearchViewIntroView.tsx b/src/transaction/user/input/SendName/views/SearchView/views/SearchViewIntroView.tsx new file mode 100644 index 000000000..4940fea4b --- /dev/null +++ b/src/transaction/user/input/SendName/views/SearchView/views/SearchViewIntroView.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' + +import { MagnifyingGlassSVG, Typography } from '@ensdomains/thorin' + +const Container = styled.div( + ({ theme }) => css` + width: 100%; + height: 100%; + min-height: ${theme.space['40']}; + display: flex; + align-items: center; + justify-content: center; + `, +) + +const Message = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + gap: ${theme.space[2]}; + align-items: center; + color: ${theme.colors.accent}; + width: ${theme.space[40]}; + `, +) + +export const SearchViewIntroView = () => { + const { t } = useTranslation('transactionFlow') + return ( + + + + + {t('input.sendName.views.search.views.intro.message')} + + + + ) +} diff --git a/src/transaction/user/input/SendName/views/SearchView/views/SearchViewLoadingView.tsx b/src/transaction/user/input/SendName/views/SearchView/views/SearchViewLoadingView.tsx new file mode 100644 index 000000000..dd4118815 --- /dev/null +++ b/src/transaction/user/input/SendName/views/SearchView/views/SearchViewLoadingView.tsx @@ -0,0 +1,22 @@ +import styled, { css } from 'styled-components' + +import { Spinner } from '@ensdomains/thorin' + +const Container = styled.div( + ({ theme }) => css` + width: 100%; + height: 100%; + min-height: ${theme.space['40']}; + display: flex; + align-items: center; + justify-content: center; + `, +) + +export const SearchViewLoadingView = () => { + return ( + + + + ) +} diff --git a/src/transaction/user/input/SendName/views/SearchView/views/SearchViewNoResultsView.tsx b/src/transaction/user/input/SendName/views/SearchView/views/SearchViewNoResultsView.tsx new file mode 100644 index 000000000..cc5245ed0 --- /dev/null +++ b/src/transaction/user/input/SendName/views/SearchView/views/SearchViewNoResultsView.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' + +import { AlertSVG, Typography } from '@ensdomains/thorin' + +const Container = styled.div( + ({ theme }) => css` + width: 100%; + height: 100%; + min-height: ${theme.space['40']}; + display: flex; + align-items: center; + justify-content: center; + `, +) + +const Message = styled.div( + ({ theme }) => css` + color: ${theme.colors.yellow}; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: ${theme.space[2]}; + svg { + width: ${theme.space[5]}; + height: ${theme.space[5]}; + } + `, +) + +export const SearchViewNoResultsView = () => { + const { t } = useTranslation('transactionFlow') + return ( + + + + + {t('input.sendName.views.search.views.noResults.message')} + + + + ) +} diff --git a/src/transaction/user/input/SendName/views/SearchView/views/SearchViewResultsView.tsx b/src/transaction/user/input/SendName/views/SearchView/views/SearchViewResultsView.tsx new file mode 100644 index 000000000..fe9871f80 --- /dev/null +++ b/src/transaction/user/input/SendName/views/SearchView/views/SearchViewResultsView.tsx @@ -0,0 +1,41 @@ +import styled, { css } from 'styled-components' +import { Address } from 'viem' + +import useRoles from '@app/hooks/ownership/useRoles/useRoles' + +import { SearchViewResult } from '../components/SearchViewResult' + +const Container = styled.div( + ({ theme }) => css` + width: 100%; + height: 100%; + min-height: ${theme.space['40']}; + display: flex; + flex-direction: column; + `, +) + +type Props = { + name: string + results: any[] + senderRole?: 'owner' | 'manager' | null + onSelect: (address: Address) => void +} + +export const SearchViewResultsView = ({ name, results, senderRole, onSelect }: Props) => { + const roles = useRoles(name) + return ( + + {results.map((result) => ( + onSelect(result.address)} + /> + ))} + + ) +} diff --git a/src/transaction/user/input/SendName/views/SummaryView/SummaryView.tsx b/src/transaction/user/input/SendName/views/SummaryView/SummaryView.tsx new file mode 100644 index 000000000..d848fe0f2 --- /dev/null +++ b/src/transaction/user/input/SendName/views/SummaryView/SummaryView.tsx @@ -0,0 +1,94 @@ +import { useFormContext, useWatch } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' + +import { Button, Dialog, Field } from '@ensdomains/thorin' + +import { AvatarWithIdentifier } from '@app/components/@molecules/AvatarWithIdentifier/AvatarWithIdentifier' +import { useExpiry } from '@app/hooks/ensjs/public/useExpiry' +import TransactionLoader from '@app/transaction-flow/TransactionLoader' + +import { DetailedSwitch } from '../../../ProfileEditor/components/DetailedSwitch' +import type { SendNameForm } from '../../SendName-flow' +import { SummarySection } from './components/SummarySection' + +const NameContainer = styled.div( + ({ theme }) => css` + padding: ${theme.space[2]}; + border: 1px solid ${theme.colors.border}; + border-radius: ${theme.radii.large}; + `, +) + +type Props = { + name: string + canResetProfile?: boolean + onNext: () => void + onBack: () => void +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const SummaryView = ({ name, canResetProfile, onNext, onBack }: Props) => { + const { t } = useTranslation('transactionFlow') + const { control, register } = useFormContext() + const recipient = useWatch({ control, name: 'recipient' }) + const expiry = useExpiry({ name }) + const expiryLabel = expiry.data?.expiry?.date + ? t('input.sendName.views.summary.fields.name.expires', { + date: expiry.data?.expiry?.date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }), + }) + : undefined + + const isLoading = expiry.isLoading || !recipient + if (isLoading) return + return ( + <> + + + + + + + + + + + + + {canResetProfile && ( + + + + )} + + + + {t('action.back', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) +} diff --git a/src/transaction/user/input/SendName/views/SummaryView/components/SummarySection.tsx b/src/transaction/user/input/SendName/views/SummaryView/components/SummarySection.tsx new file mode 100644 index 000000000..e168e5931 --- /dev/null +++ b/src/transaction/user/input/SendName/views/SummaryView/components/SummarySection.tsx @@ -0,0 +1,47 @@ +import { useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { ExpandableSection } from '@app/components/@atoms/ExpandableSection/ExpandableSection' +import { shortenAddress } from '@app/utils/utils' + +import type { SendNameForm } from '../../../SendName-flow' + +export const SummarySection = () => { + const { t } = useTranslation('transactionFlow') + const { watch } = useFormContext() + const recipient = watch('recipient') + const transactions = watch('transactions') + const shortenedAddress = shortenAddress(recipient) + return ( + + {transactions.sendOwner && ( +
+ {t('input.sendName.views.summary.fields.summary.updates.role', { + role: 'Owner', + address: shortenedAddress, + })} +
+ )} + {transactions.sendManager && ( +
+ {t('input.sendName.views.summary.fields.summary.updates.role', { + role: 'Manager', + address: shortenedAddress, + })} +
+ )} + {transactions.setEthRecord && ( +
+ {t('input.sendName.views.summary.fields.summary.updates.eth-record', { + address: shortenedAddress, + })} +
+ )} + {transactions.resetProfile && ( +
+ {t('input.sendName.views.summary.fields.summary.remove.profile')} +
+ )} +
+ ) +} diff --git a/src/transaction/user/input/SyncManager/SyncManager-flow.tsx b/src/transaction/user/input/SyncManager/SyncManager-flow.tsx new file mode 100644 index 000000000..e91a5cf69 --- /dev/null +++ b/src/transaction/user/input/SyncManager/SyncManager-flow.tsx @@ -0,0 +1,126 @@ +import { useTranslation } from 'react-i18next' +import { match, P } from 'ts-pattern' + +import { Dialog } from '@ensdomains/thorin' + +import { useAbilities } from '@app/hooks/abilities/useAbilities' +import { useAccountSafely } from '@app/hooks/account/useAccountSafely' +import { useDnsImportData } from '@app/hooks/ensjs/dns/useDnsImportData' +import { useNameType } from '@app/hooks/nameType/useNameType' +import { useNameDetails } from '@app/hooks/useNameDetails' +import { createTransactionItem, TransactionItem } from '@app/transaction-flow/transaction' +import { makeTransferNameOrSubnameTransactionItem } from '@app/transaction-flow/transaction/utils/makeTransferNameOrSubnameTransactionItem' +import TransactionLoader from '@app/transaction-flow/TransactionLoader' +import { TransactionDialogPassthrough } from '@app/transaction-flow/types' + +import { usePrimaryNameOrAddress } from '../../../hooks/reverseRecord/usePrimaryNameOrAddress' +import { checkCanSyncManager } from './utils/checkCanSyncManager' +import { ErrorView } from './views/ErrorView' +import { MainView } from './views/MainView' + +type Data = { + name: string +} + +export type Props = { + data: Data +} & TransactionDialogPassthrough + +const SyncManager = ({ data: { name }, dispatch, onDismiss }: Props) => { + const { t } = useTranslation('transactionFlow') + + const account = useAccountSafely() + const details = useNameDetails({ name }) + const nameType = useNameType(name) + const abilities = useAbilities({ name }) + const primaryNameOrAddress = usePrimaryNameOrAddress({ + address: details?.ownerData?.owner!, + shortenedAddressLength: 5, + enabled: !!details?.ownerData?.owner, + }) + + const baseCanSynManager = checkCanSyncManager({ + address: account.address, + nameType: nameType.data, + registrant: details.ownerData?.registrant, + owner: details.ownerData?.owner, + dnsOwner: details.dnsOwner, + }) + + const syncType = nameType.data?.startsWith('dns') ? 'dns' : 'eth' + const needsProof = nameType.data?.startsWith('dns') || !baseCanSynManager + const dnsImportData = useDnsImportData({ name, enabled: needsProof }) + + const canSyncEth = + baseCanSynManager && + syncType === 'eth' && + !!abilities.data?.sendNameFunctionCallDetails?.sendManager?.contract + const canSyncDNS = baseCanSynManager && syncType === 'dns' && !!dnsImportData.data + const canSyncManager = canSyncEth || canSyncDNS + + const isLoading = + !account || + details.isLoading || + abilities.isLoading || + nameType.isLoading || + primaryNameOrAddress.isLoading || + dnsImportData.isLoading + + const showWarning = nameType.data === 'dns-wrapped-2ld' + + const onClickNext = () => { + const transactions = [ + canSyncDNS + ? createTransactionItem('syncManager', { + name, + address: account.address!, + dnsImportData: dnsImportData.data!, + }) + : null, + canSyncEth && account.address + ? makeTransferNameOrSubnameTransactionItem({ + name, + newOwnerAddress: account.address, + sendType: 'sendManager', + isOwnerOrManager: true, + abilities: abilities.data!, + }) + : null, + ].filter( + ( + transaction, + ): transaction is + | TransactionItem<'syncManager'> + | TransactionItem<'transferName'> + | TransactionItem<'transferSubname'> => !!transaction, + ) + + if (transactions.length !== 1) return + + dispatch({ + name: 'setTransactions', + payload: transactions, + }) + dispatch({ name: 'setFlowStage', payload: 'transaction' }) + } + + return ( + <> + + {match([isLoading, canSyncManager]) + .with([true, P._], () => ) + .with([false, true], () => ( + + )) + .with([false, false], () => ) + .otherwise(() => null)} + + ) +} + +export default SyncManager diff --git a/src/transaction/user/input/SyncManager/utils/checkCanSyncManager.ts b/src/transaction/user/input/SyncManager/utils/checkCanSyncManager.ts new file mode 100644 index 000000000..713d44482 --- /dev/null +++ b/src/transaction/user/input/SyncManager/utils/checkCanSyncManager.ts @@ -0,0 +1,53 @@ +import { match, P } from 'ts-pattern' +import { Address } from 'viem' + +import type { NameType } from '@app/hooks/nameType/getNameType' + +export const checkCanSyncManager = ({ + address, + nameType, + registrant, + owner, + dnsOwner, +}: { + address?: Address | null + nameType?: NameType | null + registrant?: Address | null + owner?: Address | null + dnsOwner?: Address | null +}) => { + return match(nameType) + .with( + P.union('eth-unwrapped-2ld', 'eth-unwrapped-2ld:grace-period'), + () => registrant === address && owner !== address, + ) + .with( + P.union('dns-unwrapped-2ld', 'dns-wrapped-2ld'), + () => dnsOwner === address && owner !== address, + ) + .with( + P.union( + P.nullish, + 'root', + 'tld', + 'eth-emancipated-2ld', + 'eth-emancipated-2ld:grace-period', + 'eth-locked-2ld', + 'eth-locked-2ld:grace-period', + 'eth-unwrapped-subname', + 'eth-wrapped-subname', + 'eth-emancipated-subname', + 'eth-locked-subname', + 'eth-pcc-expired-subname', + 'dns-locked-2ld', + 'dns-emancipated-2ld', + 'dns-unwrapped-subname', + 'dns-wrapped-subname', + 'dns-emancipated-subname', + 'dns-locked-subname', + 'dns-pcc-expired-subname', + ), + () => false, + ) + .exhaustive() +} diff --git a/src/transaction/user/input/SyncManager/views/ErrorView.tsx b/src/transaction/user/input/SyncManager/views/ErrorView.tsx new file mode 100644 index 000000000..4b7b61dd0 --- /dev/null +++ b/src/transaction/user/input/SyncManager/views/ErrorView.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from 'react-i18next' + +import { Button, Dialog } from '@ensdomains/thorin' + +import { SearchViewErrorView } from '../../SendName/views/SearchView/views/SearchViewErrorView' + +type Props = { + onCancel: () => void +} + +export const ErrorView = ({ onCancel }: Props) => { + const { t } = useTranslation('transactionFlow') + return ( + <> + + + + + {t('action.cancel', { ns: 'common' })} + + } + /> + + ) +} diff --git a/src/transaction/user/input/SyncManager/views/MainView.tsx b/src/transaction/user/input/SyncManager/views/MainView.tsx new file mode 100644 index 000000000..0ee9dbb81 --- /dev/null +++ b/src/transaction/user/input/SyncManager/views/MainView.tsx @@ -0,0 +1,41 @@ +import { Trans, useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' + +import { Button, Dialog, Helper, Typography } from '@ensdomains/thorin' + +const Description = styled.div( + () => css` + text-align: center; + `, +) + +type Props = { + manager: string + showWarning: boolean + onCancel: () => void + onConfirm: () => void +} + +export const MainView = ({ manager, showWarning, onCancel, onConfirm }: Props) => { + const { t } = useTranslation('transactionFlow') + return ( + <> + + + + + + + {showWarning && {t('input.syncManager.warning')}} + + + {t('action.cancel', { ns: 'common' })} + + } + trailing={} + /> + + ) +} diff --git a/src/transaction/user/input/UnknownLabels/UnknownLabels-flow.tsx b/src/transaction/user/input/UnknownLabels/UnknownLabels-flow.tsx new file mode 100644 index 000000000..e6fa9d93a --- /dev/null +++ b/src/transaction/user/input/UnknownLabels/UnknownLabels-flow.tsx @@ -0,0 +1,96 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useRef } from 'react' +import { useForm } from 'react-hook-form' + +import { saveName } from '@ensdomains/ensjs/utils' + +import { useQueryOptions } from '@app/hooks/useQueryOptions' + +import { TransactionDialogPassthrough, TransactionFlowItem } from '../../types' +import { FormData, nameToFormData, UnknownLabelsForm } from './views/UnknownLabelsForm' + +type Data = { + name: string + key: string + transactionFlowItem: TransactionFlowItem +} + +export type Props = { + data: Data +} & TransactionDialogPassthrough + +const UnknownLabels = ({ + data: { name, key, transactionFlowItem }, + dispatch, + onDismiss, +}: Props) => { + const queryClient = useQueryClient() + + const formRef = useRef(null) + + const form = useForm({ + mode: 'onChange', + defaultValues: nameToFormData(name), + }) + + const onConfirm = () => { + formRef.current?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })) + } + + const { queryKey: validateKey } = useQueryOptions({ + params: { input: name }, + functionName: 'validate', + queryDependencyType: 'independent', + keyOnly: true, + }) + const onSubmit = (data: FormData) => { + const newName = [ + ...data.unknownLabels.labels.map((label) => label.value), + data.unknownLabels.tld, + ].join('.') + + saveName(newName) + + const { transactions, intro } = transactionFlowItem + + const newKey = key.replace(name, newName) + + const newTransactions = transactions.map((tx) => + typeof tx.data === 'object' && 'name' in tx.data && tx.data.name + ? { ...tx, data: { ...tx.data, name: newName } } + : tx, + ) + + const newIntro = + intro && typeof intro.content.data === 'object' && intro.content.data.name + ? { + ...intro, + content: { ...intro.content, data: { ...intro.content.data, name: newName } }, + } + : intro + + queryClient.resetQueries({ queryKey: validateKey, exact: true }) + + dispatch({ + name: 'startFlow', + key: newKey, + payload: { + ...transactionFlowItem, + transactions: newTransactions, + intro: newIntro as any, + }, + }) + } + + return ( + + ) +} + +export default UnknownLabels diff --git a/src/transaction/user/input/UnknownLabels/UnknownLabels.test.tsx b/src/transaction/user/input/UnknownLabels/UnknownLabels.test.tsx new file mode 100644 index 000000000..396df44db --- /dev/null +++ b/src/transaction/user/input/UnknownLabels/UnknownLabels.test.tsx @@ -0,0 +1,294 @@ +import { render, screen, userEvent } from '@app/test-utils' + +import { ComponentProps } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { encodeLabelhash } from '@ensdomains/ensjs/utils' + +import UnknownLabels from './UnknownLabels-flow' +import { makeMockIntersectionObserver } from '../../../../test/mock/makeMockIntersectionObserver' + +const mockDispatch = vi.fn() +const mockOnDismiss = vi.fn() + +const labels = { + test: '0x9c22ff5f21f0b81b113e63f7db6da94fedef11b2119b4088b89664fb9a3cb658', + sub: '0xfa1ea47215815692a5f1391cff19abbaf694c82fb2151a4c351b6c0eeaaf317b', +} + +const encodeLabel = (str: string) => { + try { + return encodeLabelhash(str) + } catch { + return str + } +} + +const renderHelper = (data: Omit['data'], 'key'>) => { + const newData = { + ...data, + key: 'test', + name: data.name + .split('.') + .map((label) => encodeLabel(label)) + .join('.'), + } + return render() +} + +makeMockIntersectionObserver() + +describe('UnknownLabels', () => { + beforeEach(() => { + mockDispatch.mockClear() + }) + it('should render', () => { + renderHelper({ + name: `${labels.sub}.test123.eth`, + transactionFlowItem: { + transactions: [], + }, + }) + expect(screen.getByText('input.unknownLabels.title')).toBeVisible() + }) + it('should render inputs for all labels', () => { + renderHelper({ + name: `cool.${labels.sub}.nice.${labels.test}.test123.eth`, + transactionFlowItem: { + transactions: [], + }, + }) + expect(screen.getByTestId('unknown-label-input-cool')).toBeVisible() + expect(screen.getByTestId(`unknown-label-input-${labels.sub}`)).toBeVisible() + expect(screen.getByTestId('unknown-label-input-nice')).toBeVisible() + expect(screen.getByTestId(`unknown-label-input-${labels.test}`)).toBeVisible() + expect(screen.getByTestId('unknown-label-input-test123')).toBeVisible() + }) + it('should only allow inputs for unknown labels', () => { + renderHelper({ + name: `${labels.sub}.test123.eth`, + transactionFlowItem: { + transactions: [], + }, + }) + expect(screen.getByText('input.unknownLabels.title')).toBeVisible() + expect(screen.getByTestId(`unknown-label-input-${labels.sub}`)).toBeEnabled() + expect(screen.getByTestId('unknown-label-input-test123')).toBeDisabled() + }) + describe('should throw error if', () => { + let input: HTMLElement + beforeEach(async () => { + renderHelper({ + name: `${labels.sub}.test123.eth`, + transactionFlowItem: { + transactions: [], + }, + }) + input = screen.getByTestId(`unknown-label-input-${labels.sub}`) + await userEvent.click(input) + }) + + it('label input is empty', async () => { + await userEvent.type(input, 'aaa') + await userEvent.clear(input) + expect(screen.getByText('Label is required')).toBeVisible() + }) + it('label input is too long', async () => { + await userEvent.type(input, 'a'.repeat(512)) + expect(screen.getByText('Label is too long')).toBeVisible() + }) + it('label input is invalid', async () => { + await userEvent.type(input, '.') + expect(screen.getByText('Invalid label')).toBeVisible() + }) + it('label input does not match hash', async () => { + await userEvent.type(input, 'aaa') + expect(screen.getByText('Label is incorrect')).toBeVisible() + }) + }) + it('should only allow inputs for unknown labels where there are known labels in between them', () => { + renderHelper({ + name: `cool.${labels.sub}.nice.${labels.test}.test123.eth`, + transactionFlowItem: { + transactions: [], + }, + }) + expect(screen.getByTestId('unknown-label-input-cool')).toBeDisabled() + expect(screen.getByTestId(`unknown-label-input-${labels.sub}`)).toBeEnabled() + expect(screen.getByTestId('unknown-label-input-nice')).toBeDisabled() + expect(screen.getByTestId(`unknown-label-input-${labels.test}`)).toBeEnabled() + expect(screen.getByTestId('unknown-label-input-test123')).toBeDisabled() + }) + it('should show TLD on last input as suffix', () => { + renderHelper({ + name: `${labels.sub}.test123.eth`, + transactionFlowItem: { + transactions: [], + }, + }) + expect( + screen.getByTestId(`unknown-label-input-test123`).parentElement!.querySelector('label'), + ).toHaveTextContent('.eth') + }) + it('should not allow submit when inputs are empty', () => { + renderHelper({ + name: `cool.${labels.sub}.nice.${labels.test}.test123.eth`, + transactionFlowItem: { + transactions: [], + }, + }) + expect(screen.getByTestId('unknown-labels-confirm')).toBeDisabled() + }) + it('should not allow submit when inputs have errors', async () => { + renderHelper({ + name: `cool.${labels.sub}.nice.${labels.test}.test123.eth`, + transactionFlowItem: { + transactions: [], + }, + }) + + await userEvent.type(screen.getByTestId(`unknown-label-input-${labels.sub}`), 'aaa') + await userEvent.type(screen.getByTestId(`unknown-label-input-${labels.test}`), 'aaa') + + expect(screen.getByTestId('unknown-labels-confirm')).toBeDisabled() + }) + it('should allow submit when inputs are filled and valid', async () => { + renderHelper({ + name: `cool.${labels.sub}.nice.${labels.test}.test123.eth`, + transactionFlowItem: { + transactions: [], + }, + }) + + await userEvent.type(screen.getByTestId(`unknown-label-input-${labels.sub}`), 'sub') + await userEvent.type(screen.getByTestId(`unknown-label-input-${labels.test}`), 'test') + + expect(screen.getByTestId('unknown-labels-confirm')).toBeEnabled() + }) + it('should replace all unknown label names in transactions array with the new ones', async () => { + renderHelper({ + name: `cool.${labels.sub}.nice.${labels.test}.test123.eth`, + transactionFlowItem: { + transactions: [ + { + name: 'approveNameWrapper', + data: { + address: '0x123', + }, + }, + { + name: 'migrateProfile', + data: { + name: `cool.${labels.sub}.nice.${labels.test}.test123.eth`, + }, + }, + { + name: 'wrapName', + data: { + name: `cool.${labels.sub}.nice.${labels.test}.test123.eth`, + }, + }, + ], + }, + }) + + await userEvent.type(screen.getByTestId(`unknown-label-input-${labels.sub}`), 'sub') + await userEvent.type(screen.getByTestId(`unknown-label-input-${labels.test}`), 'test') + + expect(screen.getByTestId('unknown-labels-confirm')).toBeEnabled() + await userEvent.click(screen.getByTestId('unknown-labels-confirm')) + + expect(mockDispatch).toHaveBeenCalledWith({ + name: 'startFlow', + key: 'test', + payload: { + transactions: [ + { + name: 'approveNameWrapper', + data: { + address: '0x123', + }, + }, + { + name: 'migrateProfile', + data: { + name: `cool.sub.nice.test.test123.eth`, + }, + }, + { + name: 'wrapName', + data: { + name: `cool.sub.nice.test.test123.eth`, + }, + }, + ], + }, + }) + }) + it('should replace name in intro with new name', async () => { + renderHelper({ + name: `cool.${labels.sub}.nice.${labels.test}.test123.eth`, + transactionFlowItem: { + transactions: [], + intro: { + title: ['test'], + content: { + name: 'WrapName', + data: { + name: `cool.${labels.sub}.nice.${labels.test}.test123.eth`, + }, + }, + }, + }, + }) + + await userEvent.type(screen.getByTestId(`unknown-label-input-${labels.sub}`), 'sub') + await userEvent.type(screen.getByTestId(`unknown-label-input-${labels.test}`), 'test') + + expect(screen.getByTestId('unknown-labels-confirm')).toBeEnabled() + await userEvent.click(screen.getByTestId('unknown-labels-confirm')) + + expect(mockDispatch).toHaveBeenCalledWith({ + name: 'startFlow', + key: 'test', + payload: { + transactions: [], + intro: { + title: ['test'], + content: { + name: 'WrapName', + data: { + name: `cool.sub.nice.test.test123.eth`, + }, + }, + }, + }, + }) + }) + it('should pass through all other transaction item props', async () => { + renderHelper({ + name: `cool.${labels.sub}.nice.${labels.test}.test123.eth`, + transactionFlowItem: { + transactions: [], + resumable: true, + resumeLink: 'test123', + }, + }) + + await userEvent.type(screen.getByTestId(`unknown-label-input-${labels.sub}`), 'sub') + await userEvent.type(screen.getByTestId(`unknown-label-input-${labels.test}`), 'test') + + expect(screen.getByTestId('unknown-labels-confirm')).toBeEnabled() + await userEvent.click(screen.getByTestId('unknown-labels-confirm')) + + expect(mockDispatch).toHaveBeenCalledWith({ + name: 'startFlow', + key: 'test', + payload: { + transactions: [], + resumable: true, + resumeLink: 'test123', + }, + }) + }) +}) diff --git a/src/transaction/user/input/UnknownLabels/views/UnknownLabelsForm.tsx b/src/transaction/user/input/UnknownLabels/views/UnknownLabelsForm.tsx new file mode 100644 index 000000000..3041353c9 --- /dev/null +++ b/src/transaction/user/input/UnknownLabels/views/UnknownLabelsForm.tsx @@ -0,0 +1,171 @@ +import { forwardRef } from 'react' +import { useFieldArray, UseFormReturn } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' +import { labelhash } from 'viem' + +import { decodeLabelhash, isEncodedLabelhash, validateName } from '@ensdomains/ensjs/utils' +import { Button, Dialog, Input } from '@ensdomains/thorin' + +import { isLabelTooLong } from '@app/utils/utils' + +import { CenteredTypography } from '../../ProfileEditor/components/CenteredTypography' + +const LabelsContainer = styled.form( + ({ theme }) => css` + display: flex; + flex-direction: column; + justify-content: center; + align-items: stretch; + gap: ${theme.space['1']}; + width: ${theme.space.full}; + + & > div > div > label { + visibility: hidden; + display: none; + } + `, +) + +type Label = { + label: string + value: string + disabled: boolean +} + +export type FormData = { + unknownLabels: { + tld: string + labels: Label[] + } +} + +type Props = UseFormReturn & { + onSubmit: (data: FormData) => void + onConfirm: () => void + onCancel: () => void +} + +export const nameToFormData = (name: string = '') => { + const labels = name.split('.') + const tld = labels.pop() || '' + return { + unknownLabels: { + tld, + labels: labels.map((label) => { + if (isEncodedLabelhash(label)) { + return { + label: decodeLabelhash(label), + value: '', + disabled: false, + } + } + return { + label, + value: label, + disabled: true, + } + }), + }, + } +} + +const validateLabel = (hash: string) => (label: string) => { + if (!label) { + return 'Label is required' + } + if (isLabelTooLong(label)) { + return 'Label is too long' + } + try { + if (!validateName(label) || label.indexOf('.') !== -1) throw new Error() + } catch { + return 'Invalid label' + } + if (hash !== labelhash(label)) { + return 'Label is incorrect' + } + return true +} + +export const UnknownLabelsForm = forwardRef( + ( + { + register, + formState, + control, + handleSubmit, + getFieldState, + getValues, + onSubmit, + onConfirm, + onCancel, + }, + ref, + ) => { + const { t } = useTranslation('transactionFlow') + + const { fields: labels } = useFieldArray({ + control, + name: 'unknownLabels.labels', + }) + + const unknownLabelsCount = getValues('unknownLabels.labels').filter( + ({ disabled }) => !disabled, + ).length + const dirtyLabelsCount = + formState.dirtyFields.unknownLabels?.labels?.filter(({ value }) => value).length || 0 + + const hasErrors = Object.keys(formState.errors).length > 0 + const isComplete = dirtyLabelsCount === unknownLabelsCount + const canConfirm = !hasErrors && isComplete + + return ( + <> + + + {t('input.unknownLabels.subtitle')} + + {labels.map(({ label, value, disabled }, inx) => ( + + ))} + + + + {t('action.cancel', { ns: 'common' })} + + } + trailing={ + + } + /> + + ) + }, +) diff --git a/src/transaction/user/input/VerifyProfile/VerifyProfile-flow.tsx b/src/transaction/user/input/VerifyProfile/VerifyProfile-flow.tsx new file mode 100644 index 000000000..a3fd6eedc --- /dev/null +++ b/src/transaction/user/input/VerifyProfile/VerifyProfile-flow.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react' +import { match, P } from 'ts-pattern' + +import { VERIFICATION_RECORD_KEY } from '@app/constants/verification' +import { useOwner } from '@app/hooks/ensjs/public/useOwner' +import { useProfile } from '@app/hooks/useProfile' +import { useVerifiedRecords } from '@app/hooks/verification/useVerifiedRecords/useVerifiedRecords' +import { TransactionDialogPassthrough } from '@app/transaction-flow/types' + +import { SearchViewLoadingView } from '../SendName/views/SearchView/views/SearchViewLoadingView' +import { DentityView } from './views/DentityView' +import { VerificationOptionsList } from './views/VerificationOptionsList' + +const VERIFICATION_PROTOCOLS = ['dentity'] as const + +export type VerificationProtocol = (typeof VERIFICATION_PROTOCOLS)[number] + +type Data = { + name: string +} + +export type Props = { + data: Data +} & TransactionDialogPassthrough + +const VerifyProfile = ({ data: { name }, dispatch, onDismiss }: Props) => { + const [protocol, setProtocol] = useState(null) + const { data: profile, isLoading: isProfileLoading } = useProfile({ name }) + + const { data: ownerData, isLoading: isOwnerLoading } = useOwner({ name }) + const ownerAddress = ownerData?.registrant ?? ownerData?.owner + + const { data: verificationData, isLoading: isVerificationLoading } = useVerifiedRecords({ + verificationsRecord: profile?.texts?.find(({ key }) => key === VERIFICATION_RECORD_KEY)?.value, + }) + + const isLoading = isProfileLoading || isVerificationLoading || isOwnerLoading + + return ( + <> + {match({ + protocol, + name, + address: ownerAddress, + resolverAddress: profile?.resolverAddress, + isLoading, + }) + .with({ isLoading: true }, () => ) + .with( + { + protocol: 'dentity', + name: P.not(P.nullish), + address: P.not(P.nullish), + resolverAddress: P.not(P.nullish), + }, + ({ name: _name, address: _address, resolverAddress: _resolverAddress }) => ( + issuer === 'dentity')} + dispatch={dispatch} + onBack={() => setProtocol(null)} + /> + ), + ) + .otherwise(() => ( + + ))} + + ) +} + +export default VerifyProfile diff --git a/src/transaction/user/input/VerifyProfile/components/VerificationOptionButton.tsx b/src/transaction/user/input/VerifyProfile/components/VerificationOptionButton.tsx new file mode 100644 index 000000000..b3e039843 --- /dev/null +++ b/src/transaction/user/input/VerifyProfile/components/VerificationOptionButton.tsx @@ -0,0 +1,72 @@ +import { ComponentPropsWithRef, ReactNode } from 'react' +import styled, { css } from 'styled-components' + +import { RightArrowSVG, Tag, Typography } from '@ensdomains/thorin' + +type Props = ComponentPropsWithRef<'button'> & { icon: ReactNode; verified: boolean } + +const Container = styled.button( + ({ theme }) => css` + display: flex; + align-items: center; + width: ${theme.space.full}; + overflow: hidden; + border: 1px solid ${theme.colors.border}; + border-radius: ${theme.radii.large}; + padding: ${theme.space['4']}; + gap: ${theme.space['4']}; + background-color: ${theme.colors.background}; + cursor: pointer; + transition: + background-color 0.2s, + transform 0.2s; + + &:hover { + background-color: ${theme.colors.backgroundSecondary}; + transform: translateY(-1px); + } + `, +) + +const IconWrapper = styled.div( + ({ theme }) => css` + svg { + display: block; + width: ${theme.space['8']}; + height: ${theme.space['8']}; + } + `, +) + +const Label = styled.div( + () => css` + flex: 1; + overflow: hidden; + text-align: left; + `, +) + +const ArrowWrapper = styled.div( + ({ theme }) => css` + color: ${theme.colors.accentPrimary}; + `, +) + +export const VerificationOptionButton = ({ icon, children, verified, ...props }: Props) => { + return ( + + {icon && {icon}} + + {verified && ( + + Added + + )} + + + + + ) +} diff --git a/src/transaction/user/input/VerifyProfile/utils/createDentityUrl.ts b/src/transaction/user/input/VerifyProfile/utils/createDentityUrl.ts new file mode 100644 index 000000000..3d754a64b --- /dev/null +++ b/src/transaction/user/input/VerifyProfile/utils/createDentityUrl.ts @@ -0,0 +1,25 @@ +import { Hash } from 'viem' + +import { + DENTITY_BASE_ENDPOINT, + DENTITY_CLIENT_ID, + DENTITY_REDIRECT_URI, +} from '@app/constants/verification' + +export const createDentityAuthUrl = ({ name, address }: { name: string; address: Hash }) => { + const url = new URL(`${DENTITY_BASE_ENDPOINT}/oidc/auth`) + url.searchParams.set('client_id', DENTITY_CLIENT_ID) + url.searchParams.set('redirect_uri', DENTITY_REDIRECT_URI) + url.searchParams.set('scope', 'openid federated_token') + url.searchParams.set('response_type', 'code') + url.searchParams.set('ens_name', name) + url.searchParams.set('eth_address', address) + url.searchParams.set('page', 'ens') + return url.toString() +} + +export const createDentityPublicProfileUrl = ({ name }: { name: string }) => { + const url = new URL(`${DENTITY_BASE_ENDPOINT}/oidc/ens/${name}`) + url.searchParams.set('cid', DENTITY_CLIENT_ID) + return url.toString() +} diff --git a/src/transaction/user/input/VerifyProfile/views/DentityView.tsx b/src/transaction/user/input/VerifyProfile/views/DentityView.tsx new file mode 100644 index 000000000..768702948 --- /dev/null +++ b/src/transaction/user/input/VerifyProfile/views/DentityView.tsx @@ -0,0 +1,127 @@ +import { Dispatch } from 'react' +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' +import { Hash } from 'viem' + +import { Button, Dialog, Helper, Typography } from '@ensdomains/thorin' + +import TrashSVG from '@app/assets/Trash.svg' +import { createTransactionItem } from '@app/transaction-flow/transaction' +import { TransactionFlowAction } from '@app/transaction-flow/types' + +import { CenteredTypography } from '../../ProfileEditor/components/CenteredTypography' +import { createDentityAuthUrl } from '../utils/createDentityUrl' + +const DeleteButton = styled.button( + ({ theme }) => css` + display: flex; + justify-content: center; + align-items: center; + gap: ${theme.space['2']}; + padding: ${theme.space['3']}; + margin: -${theme.space['3']} 0 0 0; + + color: ${theme.colors.redPrimary}; + transition: + color 0.2s, + transform 0.2s; + cursor: pointer; + + svg { + width: ${theme.space['4']}; + height: ${theme.space['4']}; + display: block; + } + + &:hover { + color: ${theme.colors.redBright}; + transform: translateY(-1px); + } + `, +) + +const FooterWrapper = styled.div( + () => css` + margin-top: -12px; + width: 100%; + `, +) + +export const DentityView = ({ + name, + address, + verified, + resolverAddress, + onBack, + dispatch, +}: { + name: string + address: Hash + verified: boolean + resolverAddress: Hash + onBack?: () => void + dispatch: Dispatch +}) => { + const { t } = useTranslation('transactionFlow') + + // Clear transactions before going back + const onBackAndCleanup = () => { + dispatch({ + name: 'setTransactions', + payload: [], + }) + onBack?.() + } + + const onRemoveVerification = () => { + dispatch({ + name: 'setTransactions', + payload: [ + createTransactionItem('removeVerificationRecord', { + name, + verifier: 'dentity', + resolverAddress, + }), + ], + }) + dispatch({ + name: 'setFlowStage', + payload: 'transaction', + }) + } + + return ( + <> + + + {t('input.verifyProfile.dentity.description')} + {t('input.verifyProfile.dentity.helper')} + {verified && ( + + + + {t('input.verifyProfile.dentity.remove')} + + + )} + + + + {t('action.back', { ns: 'common' })} + + } + trailing={ + + } + /> + + + ) +} diff --git a/src/transaction/user/input/VerifyProfile/views/VerificationOptionsList.tsx b/src/transaction/user/input/VerifyProfile/views/VerificationOptionsList.tsx new file mode 100644 index 000000000..85f167bbb --- /dev/null +++ b/src/transaction/user/input/VerifyProfile/views/VerificationOptionsList.tsx @@ -0,0 +1,91 @@ +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' + +import { Button, Dialog } from '@ensdomains/thorin' + +import DentitySVG from '@app/assets/verification/Dentity.svg' +import VerifiedRecordSVG from '@app/assets/VerifiedRecord.svg' +import type { useVerifiedRecords } from '@app/hooks/verification/useVerifiedRecords/useVerifiedRecords' + +import { CenteredTypography } from '../../ProfileEditor/components/CenteredTypography' +import { VerificationOptionButton } from '../components/VerificationOptionButton' +import type { VerificationProtocol } from '../VerifyProfile-flow' + +type VerificationOption = { + label: string + value: VerificationProtocol + icon: JSX.Element +} + +const VERIFICATION_OPTIONS: VerificationOption[] = [ + { + label: 'Dentity', + value: 'dentity', + icon: , + }, +] + +const IconWrapper = styled.div( + ({ theme }) => css` + svg { + color: ${theme.colors.accent}; + width: ${theme.space['16']}; + height: ${theme.space['16']}; + display: block; + } + `, +) + +const OptionsList = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: column; + gap: ${theme.space['2']}; + width: 100%; + overflow: hidden; + padding-top: ${theme.space.px}; + margin-top: -${theme.space.px}; + `, +) + +export const VerificationOptionsList = ({ + verificationData, + onSelect, + onDismiss, +}: { + verificationData?: ReturnType['data'] + onSelect: (protocol: VerificationProtocol) => void + onDismiss?: () => void +}) => { + const { t } = useTranslation('transactionFlow') + return ( + <> + + + + + + {t('input.verifyProfile.list.message')} + + {VERIFICATION_OPTIONS.map(({ label, value, icon }) => ( + issuer === 'dentity')} + icon={icon} + onClick={() => onSelect?.(value)} + > + {label} + + ))} + + + + {t('action.close', { ns: 'common' })} + + } + /> + + ) +} diff --git a/src/transaction/user/transaction.ts b/src/transaction/user/transaction.ts new file mode 100644 index 000000000..5d3509770 --- /dev/null +++ b/src/transaction/user/transaction.ts @@ -0,0 +1,101 @@ +import approveDnsRegistrar from './transaction/approveDnsRegistrar' +import approveNameWrapper from './transaction/approveNameWrapper' +import burnFuses from './transaction/burnFuses' +import changePermissions from './transaction/changePermissions' +import claimDnsName from './transaction/claimDnsName' +import commitName from './transaction/commitName' +import createSubname from './transaction/createSubname' +import deleteSubname from './transaction/deleteSubname' +import extendNames from './transaction/extendNames' +import importDnsName from './transaction/importDnsName' +import migrateProfile from './transaction/migrateProfile' +import migrateProfileWithReset from './transaction/migrateProfileWithReset' +import registerName from './transaction/registerName' +import removeVerificationRecord from './transaction/removeVerificationRecord' +import resetPrimaryName from './transaction/resetPrimaryName' +import resetProfile from './transaction/resetProfile' +import resetProfileWithRecords from './transaction/resetProfileWithRecords' +import setPrimaryName from './transaction/setPrimaryName' +import syncManager from './transaction/syncManager' +import testSendName from './transaction/testSendName' +import transferController from './transaction/transferController' +import transferName from './transaction/transferName' +import transferSubname from './transaction/transferSubname' +import unwrapName from './transaction/unwrapName' +import updateEthAddress from './transaction/updateEthAddress' +import updateProfile from './transaction/updateProfile' +import updateProfileRecords from './transaction/updateProfileRecords' +import updateResolver from './transaction/updateResolver' +import updateVerificationRecord from './transaction/updateVerificationRecord' +import wrapName from './transaction/wrapName' + +export const userTransactions = { + approveDnsRegistrar, + approveNameWrapper, + burnFuses, + changePermissions, + claimDnsName, + commitName, + createSubname, + deleteSubname, + extendNames, + importDnsName, + migrateProfile, + migrateProfileWithReset, + registerName, + resetPrimaryName, + resetProfile, + resetProfileWithRecords, + setPrimaryName, + syncManager, + testSendName, + transferController, + transferName, + transferSubname, + unwrapName, + updateEthAddress, + updateProfile, + updateProfileRecords, + updateResolver, + wrapName, + updateVerificationRecord, + removeVerificationRecord, +} + +export type UserTransactionObject = typeof userTransactions +export type TransactionName = keyof UserTransactionObject + +export type TransactionParameters = Parameters< + UserTransactionObject[name]['transaction'] +>[0] + +export type TransactionData = TransactionParameters['data'] + +export type TransactionReturnType = ReturnType< + UserTransactionObject[name]['transaction'] +> + +export const createTransactionItem = ( + name: name, + data: TransactionData, +) => ({ + name, + data, +}) + +export const createTransactionRequest = ({ + name, + ...rest +}: { name: name } & TransactionParameters): TransactionReturnType => { + // i think this has to be any :( + return userTransactions[name].transaction({ ...rest } as any) as TransactionReturnType +} + +export type TransactionItem = { + name: name + data: TransactionData +} + +export type TransactionItemUnion = { + [name in TransactionName]: TransactionItem +}[TransactionName] diff --git a/src/transaction/user/transaction/approveDnsRegistrar.ts b/src/transaction/user/transaction/approveDnsRegistrar.ts new file mode 100644 index 000000000..61df14fb7 --- /dev/null +++ b/src/transaction/user/transaction/approveDnsRegistrar.ts @@ -0,0 +1,66 @@ +import type { TFunction } from 'react-i18next' +import { Address, encodeFunctionData } from 'viem' + +import { getChainContractAddress } from '@ensdomains/ensjs/contracts' + +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = { address: Address } + +const displayItems = ( + { address }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'address', + value: address, + type: 'address', + }, + { + label: 'action', + value: t('transaction.description.approveDnsRegistrar'), + }, +] + +const publicResolverSetApprovalForAllSnippet = [ + { + constant: false, + inputs: [ + { + name: 'operator', + type: 'address', + }, + { + name: 'approved', + type: 'bool', + }, + ], + name: 'setApprovalForAll', + outputs: [], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, +] as const + +const transaction = async ({ client }: TransactionFunctionParameters) => { + return { + to: getChainContractAddress({ + client, + contract: 'ensPublicResolver', + }), + data: encodeFunctionData({ + abi: publicResolverSetApprovalForAllSnippet, + functionName: 'setApprovalForAll', + args: [ + getChainContractAddress({ + client, + contract: 'ensDnsRegistrar', + }), + true, + ], + }), + } +} + +export default { displayItems, transaction } satisfies Transaction diff --git a/src/transaction/user/transaction/approveNameWrapper.ts b/src/transaction/user/transaction/approveNameWrapper.ts new file mode 100644 index 000000000..2c07ec544 --- /dev/null +++ b/src/transaction/user/transaction/approveNameWrapper.ts @@ -0,0 +1,70 @@ +import type { TFunction } from 'react-i18next' +import { Address, encodeFunctionData } from 'viem' + +import { getChainContractAddress } from '@ensdomains/ensjs/contracts' + +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = { address: Address } + +const displayItems = ( + { address }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'address', + value: address, + type: 'address', + }, + { + label: 'action', + value: t('transaction.description.approveNameWrapper'), + }, + { + label: 'info', + value: t('transaction.info.approveNameWrapper'), + }, +] + +const registrySetApprovalForAllSnippet = [ + { + constant: false, + inputs: [ + { + name: 'operator', + type: 'address', + }, + { + name: 'approved', + type: 'bool', + }, + ], + name: 'setApprovalForAll', + outputs: [], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, +] as const + +const transaction = async ({ client }: TransactionFunctionParameters) => { + return { + to: getChainContractAddress({ + client, + contract: 'ensRegistry', + }), + data: encodeFunctionData({ + abi: registrySetApprovalForAllSnippet, + functionName: 'setApprovalForAll', + args: [ + getChainContractAddress({ + client, + contract: 'ensNameWrapper', + }), + true, + ], + }), + } +} + +export default { displayItems, transaction } satisfies Transaction diff --git a/src/transaction/user/transaction/burnFuses.ts b/src/transaction/user/transaction/burnFuses.ts new file mode 100644 index 000000000..cb7c5e15a --- /dev/null +++ b/src/transaction/user/transaction/burnFuses.ts @@ -0,0 +1,47 @@ +import type { TFunction } from 'react-i18next' + +import { EncodeChildFusesInputObject } from '@ensdomains/ensjs/utils' +import { setFuses } from '@ensdomains/ensjs/wallet' + +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = { + name: string + permissions: string[] + selectedFuses: NonNullable +} + +const displayItems = ( + { name, permissions }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'action', + value: t('transaction.description.burnFuses') as string, + }, + { + label: 'info', + value: ['Permissions to be burned:', ...permissions], + type: 'list', + }, +] + +const transaction = ({ connectorClient, data }: TransactionFunctionParameters) => { + return setFuses.makeFunctionData(connectorClient, { + name: data.name, + fuses: { + named: data.selectedFuses, + }, + }) +} + +export default { + displayItems, + transaction, + backToInput: true, +} satisfies Transaction diff --git a/src/transaction/user/transaction/changePermissions.ts b/src/transaction/user/transaction/changePermissions.ts new file mode 100644 index 000000000..7f4f05252 --- /dev/null +++ b/src/transaction/user/transaction/changePermissions.ts @@ -0,0 +1,114 @@ +/* eslint-disable @typescript-eslint/no-redeclare */ +import type { TFunction } from 'react-i18next' + +import { ChildFuseReferenceType, ParentFuseReferenceType } from '@ensdomains/ensjs/utils' +import { setChildFuses, setFuses } from '@ensdomains/ensjs/wallet' + +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type WithSetChildFuses = { + contract: 'setChildFuses' + fuses: { + parent: ParentFuseReferenceType['Key'][] + child: ChildFuseReferenceType['Key'][] + } + expiry?: number +} + +type WithSetFuses = { + contract: 'setFuses' + fuses: ChildFuseReferenceType['Key'][] +} + +type Data = { + name: string + contract: 'setChildFuses' | 'setFuses' +} & (WithSetChildFuses | WithSetFuses) + +const displayItems = ( + { name, contract, fuses, ...data }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => { + const parentFuses = contract === 'setChildFuses' ? fuses.parent : [] + const expiry = contract === 'setChildFuses' ? (data as WithSetChildFuses).expiry : 0 + const childFuses = contract === 'setChildFuses' ? fuses.child : fuses + + const parentInfoItems = parentFuses.map((fuse) => { + switch (fuse) { + case 'PARENT_CANNOT_CONTROL': + return [t('transaction.info.fuses.PARENT_CANNOT_CONTROL'), undefined] + case 'CAN_EXTEND_EXPIRY': { + return [t('transaction.info.fuses.grant'), t('transaction.info.fuses.CAN_EXTEND_EXPIRY')] + } + default: + return null + } + }) + + const setExpiryInfoItem = expiry + ? [ + t('transaction.info.fuses.setExpiry'), + new Date(expiry * 1000).toLocaleDateString(undefined, { + month: 'short', + year: 'numeric', + day: 'numeric', + }), + ] + : null + + const childInfoItems = childFuses.map((fuse) => [ + t('transaction.info.fuses.revoke'), + t(`transaction.info.fuses.${fuse}`), + ]) + + const infoItemValue = [...parentInfoItems, setExpiryInfoItem, ...childInfoItems].filter( + (item) => !!item, + ) as [string, string | undefined][] + + return [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'action', + value: t('transaction.description.changePermissions') as string, + }, + { + label: 'info', + value: infoItemValue, + type: 'records', + }, + ] +} + +const transaction = ({ connectorClient, data }: TransactionFunctionParameters) => { + const { contract } = data + if (contract === 'setChildFuses') { + return setChildFuses.makeFunctionData(connectorClient, { + name: data.name, + fuses: { + parent: { + named: data.fuses.parent, + }, + child: { + named: data.fuses.child, + }, + }, + expiry: data.expiry, + }) + } + return setFuses.makeFunctionData(connectorClient, { + name: data.name, + fuses: { + named: data.fuses, + }, + }) +} + +export default { + displayItems, + transaction, + backToInput: true, +} satisfies Transaction diff --git a/src/transaction/user/transaction/claimDnsName.ts b/src/transaction/user/transaction/claimDnsName.ts new file mode 100644 index 000000000..372903d55 --- /dev/null +++ b/src/transaction/user/transaction/claimDnsName.ts @@ -0,0 +1,30 @@ +import type { TFunction } from 'react-i18next' + +import { importDnsName, ImportDnsNameDataParameters } from '@ensdomains/ensjs/dns' + +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = Omit, 'resolverAddress'> + +const displayItems = ( + { name }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'action', + value: t('transaction.description.claimDnsName'), + }, +] + +const transaction = ({ connectorClient, data }: TransactionFunctionParameters) => + importDnsName.makeFunctionData(connectorClient, data) + +export default { + displayItems, + transaction, +} satisfies Transaction diff --git a/src/transaction/user/transaction/commitName.ts b/src/transaction/user/transaction/commitName.ts new file mode 100644 index 000000000..b3cb4167a --- /dev/null +++ b/src/transaction/user/transaction/commitName.ts @@ -0,0 +1,33 @@ +import type { TFunction } from 'react-i18next' + +import { RegistrationParameters } from '@ensdomains/ensjs/utils' +import { commitName } from '@ensdomains/ensjs/wallet' + +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = RegistrationParameters & { name: string } + +const displayItems = ( + { name }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'action', + value: t('transaction.description.commitName'), + }, + { + label: 'info', + value: t('transaction.info.commitName'), + }, +] + +const transaction = async ({ connectorClient, data }: TransactionFunctionParameters) => { + return commitName.makeFunctionData(connectorClient, data) +} + +export default { displayItems, transaction } satisfies Transaction diff --git a/src/transaction/user/transaction/createSubname.ts b/src/transaction/user/transaction/createSubname.ts new file mode 100644 index 000000000..5110d42bc --- /dev/null +++ b/src/transaction/user/transaction/createSubname.ts @@ -0,0 +1,43 @@ +import type { TFunction } from 'react-i18next' + +import { createSubname } from '@ensdomains/ensjs/wallet' + +import type { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = { + parent: string + label: string + contract: 'nameWrapper' | 'registry' +} + +const displayItems = ( + { parent, label }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'name', + value: parent, + type: 'name', + }, + { + label: 'action', + value: t(`transaction.description.createSubname`), + }, + { + label: 'subname', + value: `${label}.${parent}`, + type: 'name', + }, +] + +const transaction = async ({ connectorClient, data }: TransactionFunctionParameters) => + createSubname.makeFunctionData(connectorClient, { + name: `${data.label}.${data.parent}`, + owner: connectorClient.account.address, + contract: data.contract, + }) + +export default { + displayItems, + transaction, +} satisfies Transaction diff --git a/src/transaction/user/transaction/deleteSubname.ts b/src/transaction/user/transaction/deleteSubname.ts new file mode 100644 index 000000000..9ee04a12a --- /dev/null +++ b/src/transaction/user/transaction/deleteSubname.ts @@ -0,0 +1,43 @@ +import type { TFunction } from 'react-i18next' + +import { deleteSubname } from '@ensdomains/ensjs/wallet' + +import type { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = { + name: string + contract: 'nameWrapper' | 'registry' + method?: 'setSubnodeOwner' | 'setRecord' +} + +const displayItems = ( + { name }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'subname', + value: name, + type: 'subname', + }, + { + label: 'action', + value: t(`transaction.description.deleteSubname`), + }, + { + label: 'info', + value: [t('action.delete'), name], + type: 'list', + }, +] + +const transaction = async ({ connectorClient, data }: TransactionFunctionParameters) => + deleteSubname.makeFunctionData(connectorClient, { + name: data.name, + contract: data.contract, + asOwner: data.method === 'setRecord', + }) + +export default { + displayItems, + transaction, +} satisfies Transaction diff --git a/src/transaction/user/transaction/extendNames.ts b/src/transaction/user/transaction/extendNames.ts new file mode 100644 index 000000000..ac2ff598a --- /dev/null +++ b/src/transaction/user/transaction/extendNames.ts @@ -0,0 +1,68 @@ +import type { TFunction } from 'react-i18next' + +import { getPrice } from '@ensdomains/ensjs/public' +import { renewNames } from '@ensdomains/ensjs/wallet' + +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +import { calculateValueWithBuffer, formatDurationOfDates } from '../../utils/utils' + +type Data = { + names: string[] + duration: number + startDateTimestamp?: number + displayPrice?: string +} + +const displayItems = ( + { names, duration, startDateTimestamp, displayPrice }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => { + return [ + { + label: 'name', + value: names.length > 1 ? `${names.length} names` : names[0], + type: names.length > 1 ? undefined : 'name', + }, + { + label: 'action', + value: t('transaction.extendNames.actionValue', { ns: 'transactionFlow' }), + }, + { + label: 'duration', + value: formatDurationOfDates({ + startDate: startDateTimestamp ? new Date(startDateTimestamp) : undefined, + endDate: startDateTimestamp ? new Date(startDateTimestamp + duration * 1000) : undefined, + t, + }), + }, + { + label: 'cost', + value: t('transaction.extendNames.costValue', { + ns: 'transactionFlow', + value: displayPrice, + }), + }, + ] +} + +const transaction = async ({ + client, + connectorClient, + data, +}: TransactionFunctionParameters) => { + const { names, duration } = data + const price = await getPrice(client, { + nameOrNames: names, + duration, + }) + if (!price) throw new Error('No price found') + + const priceWithBuffer = calculateValueWithBuffer(price.base) + return renewNames.makeFunctionData(connectorClient, { + nameOrNames: names, + duration, + value: priceWithBuffer, + }) +} +export default { transaction, displayItems } satisfies Transaction diff --git a/src/transaction/user/transaction/importDnsName.ts b/src/transaction/user/transaction/importDnsName.ts new file mode 100644 index 000000000..63982b51f --- /dev/null +++ b/src/transaction/user/transaction/importDnsName.ts @@ -0,0 +1,30 @@ +import type { TFunction } from 'react-i18next' + +import { importDnsName, ImportDnsNameDataParameters } from '@ensdomains/ensjs/dns' + +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = Omit + +const displayItems = ( + { name }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'action', + value: t('transaction.description.importDnsName'), + }, +] + +const transaction = ({ connectorClient, data }: TransactionFunctionParameters) => + importDnsName.makeFunctionData(connectorClient, data) + +export default { + displayItems, + transaction, +} satisfies Transaction diff --git a/src/transaction/user/transaction/migrateProfile.ts b/src/transaction/user/transaction/migrateProfile.ts new file mode 100644 index 000000000..f9f811712 --- /dev/null +++ b/src/transaction/user/transaction/migrateProfile.ts @@ -0,0 +1,69 @@ +import type { TFunction } from 'react-i18next' +import type { Address } from 'viem' + +import { getChainContractAddress } from '@ensdomains/ensjs/contracts' +import { getRecords } from '@ensdomains/ensjs/public' +import { getSubgraphRecords } from '@ensdomains/ensjs/subgraph' +import { setRecords } from '@ensdomains/ensjs/wallet' + +import type { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' +import { profileRecordsToKeyValue } from '@app/utils/records' + +type Data = { + name: string + resolverAddress?: Address +} + +const displayItems = ( + { name }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'action', + value: t(`transaction.description.migrateProfile`), + }, + { + label: 'info', + value: t(`transaction.info.migrateProfile`), + }, +] + +const transaction = async ({ + client, + connectorClient, + data, +}: TransactionFunctionParameters) => { + const subgraphRecords = await getSubgraphRecords(client, data) + if (!subgraphRecords) throw new Error('No subgraph records found') + const profile = await getRecords(connectorClient, { + name: data.name, + texts: subgraphRecords.texts, + coins: subgraphRecords.coins, + abi: true, + contentHash: true, + resolver: data.resolverAddress + ? { + address: data.resolverAddress, + fallbackOnly: false, + } + : undefined, + }) + const resolverAddress = getChainContractAddress({ + client, + contract: 'ensPublicResolver', + }) + if (!profile) throw new Error('No profile found') + const records = await profileRecordsToKeyValue(profile) + return setRecords.makeFunctionData(connectorClient, { + name: data.name, + resolverAddress, + ...records, + }) +} + +export default { displayItems, transaction } satisfies Transaction diff --git a/src/transaction/user/transaction/migrateProfileWithReset.ts b/src/transaction/user/transaction/migrateProfileWithReset.ts new file mode 100644 index 000000000..d6c9bebfe --- /dev/null +++ b/src/transaction/user/transaction/migrateProfileWithReset.ts @@ -0,0 +1,73 @@ +import type { TFunction } from 'react-i18next' +import { Address } from 'viem' + +import { getChainContractAddress } from '@ensdomains/ensjs/contracts' +import { getRecords } from '@ensdomains/ensjs/public' +import { getSubgraphRecords } from '@ensdomains/ensjs/subgraph' +import { setRecords } from '@ensdomains/ensjs/wallet' + +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' +import { profileRecordsToKeyValue } from '@app/utils/records' + +type Data = { + name: string + resolverAddress: Address +} + +const displayItems = ({ name }: Data, t: TFunction): TransactionDisplayItem[] => { + return [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'action', + value: t('transaction.description.migrateProfileWithReset'), + }, + { + label: 'info', + value: t('transaction.info.migrateProfileWithReset'), + }, + ] +} + +const transaction = async ({ + client, + connectorClient, + data, +}: TransactionFunctionParameters) => { + const { name, resolverAddress } = data + const subgraphRecords = await getSubgraphRecords(client, { + name, + resolverAddress, + }) + const profile = await getRecords(client, { + name, + texts: subgraphRecords?.texts || [], + coins: subgraphRecords?.coins || [], + abi: true, + contentHash: true, + resolver: resolverAddress + ? { + address: resolverAddress, + fallbackOnly: false, + } + : undefined, + }) + + const profileRecords = await profileRecordsToKeyValue(profile) + const latestResolverAddress = getChainContractAddress({ + client, + contract: 'ensPublicResolver', + }) + + return setRecords.makeFunctionData(connectorClient, { + name: data.name, + ...profileRecords, + clearRecords: true, + resolverAddress: latestResolverAddress, + }) +} + +export default { displayItems, transaction } satisfies Transaction diff --git a/src/transaction/user/transaction/registerName.test.ts b/src/transaction/user/transaction/registerName.test.ts new file mode 100644 index 000000000..d0e8ed825 --- /dev/null +++ b/src/transaction/user/transaction/registerName.test.ts @@ -0,0 +1,27 @@ +import { mockFunction } from '@app/test-utils' + +import { expect, it, vi } from 'vitest' + +import { getPrice } from '@ensdomains/ensjs/public' +import { registerName } from '@ensdomains/ensjs/wallet' + +import registerNameFlowTransaction from './registerName' + +vi.mock('@ensdomains/ensjs/public') +vi.mock('@ensdomains/ensjs/wallet') + +const mockGetPrice = mockFunction(getPrice) +const mockRegisterName = mockFunction(registerName.makeFunctionData) + +mockGetPrice.mockImplementation(async () => ({ base: 100n, premium: 0n })) +mockRegisterName.mockImplementation((...args: any[]) => args as any) + +it('adds a 2% value buffer to the transaction from the real price', async () => { + const result = (await registerNameFlowTransaction.transaction({ + client: {} as any, + connectorClient: { walletClient: true } as any, + data: { name: 'test.eth' } as any, + })) as unknown as [{ walletClient: true }, { name: string; value: bigint }] + const data = result[1] + expect(data.value).toEqual(102n) +}) diff --git a/src/transaction/user/transaction/registerName.ts b/src/transaction/user/transaction/registerName.ts new file mode 100644 index 000000000..3a1d95dfb --- /dev/null +++ b/src/transaction/user/transaction/registerName.ts @@ -0,0 +1,50 @@ +import type { TFunction } from 'react-i18next' + +import { getPrice } from '@ensdomains/ensjs/public' +import { RegistrationParameters } from '@ensdomains/ensjs/utils' +import { registerName } from '@ensdomains/ensjs/wallet' + +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' +import { calculateValueWithBuffer, formatDurationOfDates } from '@app/utils/utils' + +type Data = RegistrationParameters +const now = Math.floor(Date.now()) +const displayItems = ( + { name, duration }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'action', + value: t('transaction.description.registerName'), + }, + { + label: 'duration', + value: formatDurationOfDates({ + startDate: new Date(), + endDate: new Date(now + duration * 1000), + t, + }), + }, +] + +const transaction = async ({ + client, + connectorClient, + data, +}: TransactionFunctionParameters) => { + const price = await getPrice(client, { nameOrNames: data.name, duration: data.duration }) + const value = price.base + price.premium + const valueWithBuffer = calculateValueWithBuffer(value) + + return registerName.makeFunctionData(connectorClient, { + ...data, + value: valueWithBuffer, + }) +} + +export default { displayItems, transaction } satisfies Transaction diff --git a/src/transaction/user/transaction/removeVerificationRecord.ts b/src/transaction/user/transaction/removeVerificationRecord.ts new file mode 100644 index 000000000..13722b22e --- /dev/null +++ b/src/transaction/user/transaction/removeVerificationRecord.ts @@ -0,0 +1,52 @@ +import type { TFunction } from 'i18next' +import { Address } from 'viem' + +import { setTextRecord } from '@ensdomains/ensjs/wallet' + +import { VERIFICATION_RECORD_KEY } from '@app/constants/verification' +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' +import { labelForVerificationProtocol } from '@app/utils/verification/labelForVerificationProtocol' + +import type { VerificationProtocol } from '../input/VerifyProfile/VerifyProfile-flow' + +type Data = { + name: string + resolverAddress: Address + verifier: VerificationProtocol +} + +const displayItems = ({ name, verifier }: Data, t: TFunction): TransactionDisplayItem[] => { + return [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'action', + value: t('transaction.description.removeRecord'), + }, + { + label: 'record', + value: labelForVerificationProtocol(verifier), + }, + ] +} + +// TODO: Implement a function that identifies the url for the issuer and only removes that uri + +const transaction = async ({ connectorClient, data }: TransactionFunctionParameters) => { + const { name, resolverAddress } = data + + return setTextRecord.makeFunctionData(connectorClient, { + name, + key: VERIFICATION_RECORD_KEY, + value: '', + resolverAddress, + }) +} +export default { + displayItems, + transaction, + backToInput: true, +} satisfies Transaction diff --git a/src/transaction/user/transaction/resetPrimaryName.ts b/src/transaction/user/transaction/resetPrimaryName.ts new file mode 100644 index 000000000..e68869ae0 --- /dev/null +++ b/src/transaction/user/transaction/resetPrimaryName.ts @@ -0,0 +1,30 @@ +import type { TFunction } from 'react-i18next' +import { Address } from 'viem' + +import { setPrimaryName } from '@ensdomains/ensjs/wallet' + +import type { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = { + address: Address +} + +const displayItems = ( + { address }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'address', + value: address, + type: 'address', + }, + { + label: 'action', + value: t(`transaction.description.resetPrimaryName`), + }, +] + +const transaction = async ({ connectorClient }: TransactionFunctionParameters) => + setPrimaryName.makeFunctionData(connectorClient, { name: '' }) + +export default { displayItems, transaction } satisfies Transaction diff --git a/src/transaction/user/transaction/resetProfile.ts b/src/transaction/user/transaction/resetProfile.ts new file mode 100644 index 000000000..25d5b8d8a --- /dev/null +++ b/src/transaction/user/transaction/resetProfile.ts @@ -0,0 +1,39 @@ +import type { TFunction } from 'i18next' +import type { Address } from 'viem' + +import { clearRecords } from '@ensdomains/ensjs/wallet' + +import type { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = { + name: string + resolverAddress: Address +} + +const displayItems = ({ name, resolverAddress }: Data, t: TFunction): TransactionDisplayItem[] => { + return [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'action', + value: t('transaction.description.clearRecords'), + }, + { + label: 'resolver', + type: 'address', + value: resolverAddress, + }, + ] +} + +const transaction = ({ connectorClient, data }: TransactionFunctionParameters) => { + return clearRecords.makeFunctionData(connectorClient, data) +} + +export default { + displayItems, + transaction, +} satisfies Transaction diff --git a/src/transaction/user/transaction/resetProfileWithRecords.ts b/src/transaction/user/transaction/resetProfileWithRecords.ts new file mode 100644 index 000000000..15d170a77 --- /dev/null +++ b/src/transaction/user/transaction/resetProfileWithRecords.ts @@ -0,0 +1,66 @@ +import { TFunction } from 'i18next' +import { match, P } from 'ts-pattern' +import type { Address } from 'viem' + +import { RecordOptions } from '@ensdomains/ensjs/utils' +import { setRecords } from '@ensdomains/ensjs/wallet' + +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' +import { recordOptionsToToupleList } from '@app/utils/records' + +type Data = { + name: string + resolverAddress: Address + records: RecordOptions +} + +const displayItems = ({ name, records }: Data, t: TFunction): TransactionDisplayItem[] => { + const recordsList = recordOptionsToToupleList(records) + const recordsItem = match(recordsList.length) + .with( + P.when((length) => length > 3), + (length) => [ + { + label: 'update', + value: t('transaction.itemValue.records', { count: length }), + } as TransactionDisplayItem, + ], + ) + .with( + P.when((length) => length > 0), + () => [ + { + label: 'records', + value: recordsList, + type: 'records', + } as TransactionDisplayItem, + ], + ) + .otherwise(() => []) + return [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'action', + value: t('transaction.description.resetProfileWithRecords'), + }, + ...recordsItem, + ] +} + +const transaction = ({ connectorClient, data }: TransactionFunctionParameters) => { + return setRecords.makeFunctionData(connectorClient, { + name: data.name, + ...data.records, + clearRecords: true, + resolverAddress: data.resolverAddress, + }) +} + +export default { + displayItems, + transaction, +} as Transaction diff --git a/src/transaction/user/transaction/setPrimaryName.ts b/src/transaction/user/transaction/setPrimaryName.ts new file mode 100644 index 000000000..85ba21dc9 --- /dev/null +++ b/src/transaction/user/transaction/setPrimaryName.ts @@ -0,0 +1,37 @@ +import type { TFunction } from 'react-i18next' +import type { Address } from 'viem' + +import { setPrimaryName } from '@ensdomains/ensjs/wallet' + +import type { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = { + name: string + address: Address +} + +const displayItems = ( + { address, name }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'info', + value: t(`transaction.info.setPrimaryName`), + }, + { + label: 'address', + value: address, + type: 'address', + }, +] + +const transaction = async ({ connectorClient, data }: TransactionFunctionParameters) => { + return setPrimaryName.makeFunctionData(connectorClient, { name: data.name }) +} + +export default { displayItems, transaction } satisfies Transaction diff --git a/src/transaction/user/transaction/syncManager.ts b/src/transaction/user/transaction/syncManager.ts new file mode 100644 index 000000000..accfc98fa --- /dev/null +++ b/src/transaction/user/transaction/syncManager.ts @@ -0,0 +1,41 @@ +import type { TFunction } from 'react-i18next' +import { Address } from 'viem' + +import { GetDnsImportDataReturnType, importDnsName } from '@ensdomains/ensjs/dns' + +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = { + name: string + address: Address + dnsImportData: GetDnsImportDataReturnType +} + +const displayItems = ( + { name, address }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'action', + value: t('transaction.description.syncManager'), + }, + { + label: 'address', + value: address, + type: 'address', + }, +] + +const transaction = ({ connectorClient, data }: TransactionFunctionParameters) => { + return importDnsName.makeFunctionData(connectorClient, data) +} + +export default { + displayItems, + transaction, +} satisfies Transaction diff --git a/src/transaction/user/transaction/testSendName.ts b/src/transaction/user/transaction/testSendName.ts new file mode 100644 index 000000000..7e18386c3 --- /dev/null +++ b/src/transaction/user/transaction/testSendName.ts @@ -0,0 +1,39 @@ +import type { TFunction } from 'react-i18next' + +import type { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = {} + +const displayItems = ( + // eslint-disable-next-line no-empty-pattern + {}: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'action', + value: t(`transaction.description.testSendName`), + }, + { + label: 'info', + value: t(`transaction.info.testSendName`), + }, + { + label: 'to', + value: '0x3F45BcB2DFBdF0AD173A9DfEe3b932aa2a31CeB3', + type: 'address', + }, + { + label: 'name', + value: 'taytems.eth', + type: 'name', + }, +] + +// eslint-disable-next-line no-empty-pattern +const transaction = async ({}: TransactionFunctionParameters) => + ({ + to: '0x0000000000000000000000000000000000000000', + data: '0x', + }) as const + +export default { displayItems, transaction } satisfies Transaction diff --git a/src/transaction/user/transaction/transferController.ts b/src/transaction/user/transaction/transferController.ts new file mode 100644 index 000000000..07e8e0cd4 --- /dev/null +++ b/src/transaction/user/transaction/transferController.ts @@ -0,0 +1,42 @@ +import type { TFunction } from 'react-i18next' +import type { Address } from 'viem' + +import { transferName } from '@ensdomains/ensjs/wallet' + +import type { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = { + name: string + newOwnerAddress: Address + isOwner: boolean +} + +const displayItems = ( + { name }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'action', + value: t('details.sendName.transferController', { ns: 'profile' }), + }, +] + +const transaction = ({ connectorClient, data }: TransactionFunctionParameters) => { + return transferName.makeFunctionData(connectorClient, { + name: data.name, + contract: 'registry', + newOwnerAddress: data.newOwnerAddress, + asParent: !data.isOwner, + }) +} + +export default { + displayItems, + transaction, + backToInput: true, +} satisfies Transaction diff --git a/src/transaction/user/transaction/transferName.ts b/src/transaction/user/transaction/transferName.ts new file mode 100644 index 000000000..389f3ad2f --- /dev/null +++ b/src/transaction/user/transaction/transferName.ts @@ -0,0 +1,62 @@ +import type { TFunction } from 'react-i18next' +import type { Address } from 'viem' + +import { transferName } from '@ensdomains/ensjs/wallet' + +import type { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type RegistrarData = { + contract: 'registrar' + reclaim?: boolean +} + +type OtherData = { + contract: 'registry' | 'nameWrapper' + reclaim?: never +} + +export type Data = { + name: string + newOwnerAddress: Address + contract: 'registry' | 'registrar' | 'nameWrapper' + sendType: 'sendManager' | 'sendOwner' + reclaim?: boolean +} & (RegistrarData | OtherData) + +const displayItems = ( + { name, sendType, newOwnerAddress }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'action', + value: t(`name.${sendType}`), + }, + { + label: 'to', + type: 'address', + value: newOwnerAddress, + }, +] + +const transaction = ({ connectorClient, data }: TransactionFunctionParameters) => { + return transferName.makeFunctionData( + connectorClient, + data.contract === 'registrar' + ? data + : { + ...data, + asParent: false, + }, + ) +} + +export default { + displayItems, + transaction, + backToInput: true, +} satisfies Transaction diff --git a/src/transaction/user/transaction/transferSubname.ts b/src/transaction/user/transaction/transferSubname.ts new file mode 100644 index 000000000..e0fda6f77 --- /dev/null +++ b/src/transaction/user/transaction/transferSubname.ts @@ -0,0 +1,40 @@ +import type { TFunction } from 'react-i18next' +import type { Address } from 'viem' + +import { transferName } from '@ensdomains/ensjs/wallet' + +import type { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +export type Data = { + name: string + contract: 'registry' | 'nameWrapper' + newOwnerAddress: Address +} + +const displayItems = ( + { name }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'action', + value: t('details.sendName.transferSubname', { ns: 'profile' }), + }, +] + +const transaction = ({ connectorClient, data }: TransactionFunctionParameters) => { + return transferName.makeFunctionData(connectorClient, { + ...data, + asParent: true, + }) +} + +export default { + displayItems, + transaction, + backToInput: true, +} satisfies Transaction diff --git a/src/transaction/user/transaction/unwrapName.test.ts b/src/transaction/user/transaction/unwrapName.test.ts new file mode 100644 index 000000000..0b6160397 --- /dev/null +++ b/src/transaction/user/transaction/unwrapName.test.ts @@ -0,0 +1,81 @@ +import { mockFunction } from '@app/test-utils' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { unwrapName } from '@ensdomains/ensjs/wallet' + +import { ClientWithEns, ConnectorClientWithEns } from '@app/types' + +import unwrapNameFlowTransaction from './unwrapName' + +vi.mock('wagmi') + +vi.mock('@ensdomains/ensjs/wallet') + +const mockUnwrapName = mockFunction(unwrapName.makeFunctionData) + +describe('unwrapName', () => { + const name = 'myname.eth' + const data = { name } + + describe('displayItems', () => { + it('returns the correct display items', () => { + const t = (key: string) => key + const items = unwrapNameFlowTransaction.displayItems(data, t) + expect(items).toEqual([ + { + label: 'action', + value: 'transaction.description.unwrapName', + }, + { + label: 'name', + value: name, + type: 'name', + }, + ]) + }) + }) + + describe('transaction', () => { + const address = '0x123' + const connectorClient = { account: { address } } as unknown as ConnectorClientWithEns + const client = {} as unknown as ClientWithEns + + afterEach(() => { + vi.clearAllMocks() + }) + + it('should provide controller and registrant when name is an eth 2ld', async () => { + await unwrapNameFlowTransaction.transaction({ + client, + connectorClient, + data: { name: 'test.eth' }, + }) + expect(mockUnwrapName).toHaveBeenCalledWith( + connectorClient, + expect.objectContaining({ + name: 'test.eth', + newOwnerAddress: address, + newRegistrantAddress: address, + }), + ) + }) + + it('should not provide registrant when name is not an eth 2ld', async () => { + const subname = 'sub.test.eth' + const dataWithSubname = { name: subname } + await unwrapNameFlowTransaction.transaction({ + client, + connectorClient, + data: dataWithSubname, + }) + expect(mockUnwrapName).toHaveBeenCalledWith( + connectorClient, + expect.objectContaining({ + name: 'sub.test.eth', + newOwnerAddress: address, + }), + ) + }) + }) +}) diff --git a/src/transaction/user/transaction/unwrapName.ts b/src/transaction/user/transaction/unwrapName.ts new file mode 100644 index 000000000..2db7f80c7 --- /dev/null +++ b/src/transaction/user/transaction/unwrapName.ts @@ -0,0 +1,42 @@ +import type { TFunction } from 'react-i18next' + +import { unwrapName } from '@ensdomains/ensjs/wallet' + +import type { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' +import { checkETH2LDFromName } from '@app/utils/utils' + +type Data = { + name: string +} + +const displayItems = ( + { name }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'action', + value: t(`transaction.description.unwrapName`), + }, + { + label: 'name', + value: name, + type: 'name', + }, +] + +const transaction = async ({ connectorClient, data }: TransactionFunctionParameters) => { + const { address } = connectorClient.account + + if (checkETH2LDFromName(data.name)) + return unwrapName.makeFunctionData(connectorClient, { + name: data.name, + newOwnerAddress: address, + newRegistrantAddress: address, + }) + return unwrapName.makeFunctionData(connectorClient, { + name: data.name, + newOwnerAddress: address, + }) +} + +export default { displayItems, transaction } satisfies Transaction diff --git a/src/transaction/user/transaction/updateEthAddress.ts b/src/transaction/user/transaction/updateEthAddress.ts new file mode 100644 index 000000000..7859d4701 --- /dev/null +++ b/src/transaction/user/transaction/updateEthAddress.ts @@ -0,0 +1,61 @@ +import type { TFunction } from 'react-i18next' +import { Address, getAddress } from 'viem' + +import { getChainContractAddress } from '@ensdomains/ensjs/contracts' +import { getResolver } from '@ensdomains/ensjs/public' +import { setAddressRecord } from '@ensdomains/ensjs/wallet' + +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = { + name: string + address: Address + latestResolver?: boolean +} + +const displayItems = ( + { name, address, latestResolver }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'info', + value: latestResolver + ? t(`transaction.info.updateEthAddressOnLatestResolver`) + : t(`transaction.info.updateEthAddress`), + }, + { + label: 'address', + value: address, + type: 'address', + }, +] + +const transaction = async ({ + client, + connectorClient, + data, +}: TransactionFunctionParameters) => { + const resolverAddress = data?.latestResolver + ? getChainContractAddress({ client, contract: 'ensPublicResolver' }) + : await getResolver(client, { name: data.name }) + if (!resolverAddress) throw new Error('No resolver found') + let address + try { + address = getAddress(data.address) + } catch (e) { + throw new Error('Invalid address') + } + return setAddressRecord.makeFunctionData(connectorClient, { + name: data.name, + resolverAddress, + coin: 'eth', + value: address, + }) +} + +export default { displayItems, transaction } satisfies Transaction diff --git a/src/transaction/user/transaction/updateProfile.ts b/src/transaction/user/transaction/updateProfile.ts new file mode 100644 index 000000000..f9e8bbdfb --- /dev/null +++ b/src/transaction/user/transaction/updateProfile.ts @@ -0,0 +1,71 @@ +import type { TFunction } from 'i18next' +import type { Address } from 'viem' + +import type { RecordOptions } from '@ensdomains/ensjs/utils' +import { setRecords } from '@ensdomains/ensjs/wallet' + +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' +import { recordOptionsToToupleList } from '@app/utils/records' + +type Data = { + name: string + resolverAddress: Address + records: RecordOptions +} + +const displayItems = ({ name, records }: Data, t: TFunction): TransactionDisplayItem[] => { + const action = records.clearRecords + ? { + label: 'action', + value: t('transaction.description.clearRecords'), + } + : { + label: 'action', + value: t('transaction.description.updateRecords'), + } + + const recordsList = recordOptionsToToupleList(records) + + /* eslint-disable no-nested-ternary */ + const recordsItem = + recordsList.length > 3 + ? [ + { + label: 'update', + value: t('transaction.itemValue.records', { count: recordsList.length }), + } as TransactionDisplayItem, + ] + : recordsList.length > 0 + ? [ + { + label: 'update', + value: recordsList, + type: 'records', + } as TransactionDisplayItem, + ] + : [] + /* eslint-enable no-nested-ternary */ + + return [ + { + label: 'name', + value: name, + type: 'name', + }, + action, + ...recordsItem, + ] +} + +const transaction = ({ connectorClient, data }: TransactionFunctionParameters) => { + return setRecords.makeFunctionData(connectorClient, { + name: data.name, + resolverAddress: data.resolverAddress, + ...data.records, + }) +} +export default { + displayItems, + transaction, + backToInput: true, +} satisfies Transaction diff --git a/src/transaction/user/transaction/updateProfileRecords.ts b/src/transaction/user/transaction/updateProfileRecords.ts new file mode 100644 index 000000000..93d55e9ca --- /dev/null +++ b/src/transaction/user/transaction/updateProfileRecords.ts @@ -0,0 +1,98 @@ +import type { TFunction } from 'i18next' +import { Address } from 'viem' + +import { setRecords } from '@ensdomains/ensjs/wallet' + +import { + getProfileRecordsDiff, + profileRecordsToRecordOptions, + profileRecordsToRecordOptionsWithDeleteAbiArray, +} from '@app/components/pages/profile/[name]/registration/steps/Profile/profileRecordUtils' +import { ProfileRecord } from '@app/constants/profileRecordOptions' +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' +import { recordOptionsToToupleList } from '@app/utils/records' + +type Data = { + name: string + resolverAddress: Address + records: ProfileRecord[] + previousRecords?: ProfileRecord[] + clearRecords: boolean +} + +const displayItems = ( + { name, records, previousRecords = [], clearRecords }: Data, + t: TFunction, +): TransactionDisplayItem[] => { + const submitRecords = getProfileRecordsDiff(records, previousRecords) + const recordOptions = profileRecordsToRecordOptions(submitRecords, clearRecords) + + const action = clearRecords + ? { + label: 'action', + value: t('transaction.description.clearRecords'), + } + : { + label: 'action', + value: t('transaction.description.updateProfile'), + } + + const recordsList = recordOptionsToToupleList( + recordOptions, + t('action.delete', { ns: 'common' }).toLocaleLowerCase(), + ) + + /* eslint-disable no-nested-ternary */ + const recordsItem = + recordsList.length > 3 + ? [ + { + label: 'update', + value: t('transaction.itemValue.records', { count: recordsList.length }), + } as TransactionDisplayItem, + ] + : recordsList.length > 0 + ? [ + { + label: 'update', + value: recordsList, + type: 'records', + } as TransactionDisplayItem, + ] + : [] + /* eslint-enable no-nested-ternary */ + + return [ + { + label: 'name', + value: name, + type: 'name', + }, + action, + ...recordsItem, + ] +} + +const transaction = async ({ + client, + connectorClient, + data, +}: TransactionFunctionParameters) => { + const { name, resolverAddress, records, previousRecords = [], clearRecords } = data + const submitRecords = getProfileRecordsDiff(records, previousRecords) + const recordOptions = await profileRecordsToRecordOptionsWithDeleteAbiArray(client, { + name, + profileRecords: submitRecords, + clearRecords, + }) + return setRecords.makeFunctionData(connectorClient, { + name, + resolverAddress, + ...recordOptions, + }) +} +export default { + displayItems, + transaction, + backToInput: true, +} satisfies Transaction diff --git a/src/transaction/user/transaction/updateResolver.ts b/src/transaction/user/transaction/updateResolver.ts new file mode 100644 index 000000000..e43fa39cd --- /dev/null +++ b/src/transaction/user/transaction/updateResolver.ts @@ -0,0 +1,45 @@ +import type { TFunction } from 'react-i18next' +import { Address } from 'viem' + +import { setResolver } from '@ensdomains/ensjs/wallet' + +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +import { shortenAddress } from '../../utils/utils' + +type Data = { + name: string + contract: 'registry' | 'nameWrapper' + resolverAddress: Address + oldResolverAddress?: Address +} + +const displayItems = ( + { name, resolverAddress }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'action', + value: t(`transaction.description.updateResolver`), + }, + { + label: 'info', + value: [t(`transaction.info.updateResolver`), shortenAddress(resolverAddress)], + type: 'list', + }, +] + +const transaction = ({ connectorClient, data }: TransactionFunctionParameters) => { + return setResolver.makeFunctionData(connectorClient, { + name: data.name, + contract: data.contract, + resolverAddress: data.resolverAddress, + }) +} + +export default { displayItems, transaction } satisfies Transaction diff --git a/src/transaction/user/transaction/updateVerificationRecord.ts b/src/transaction/user/transaction/updateVerificationRecord.ts new file mode 100644 index 000000000..269ee16ed --- /dev/null +++ b/src/transaction/user/transaction/updateVerificationRecord.ts @@ -0,0 +1,52 @@ +import type { TFunction } from 'i18next' +import { Address } from 'viem' + +import { setTextRecord } from '@ensdomains/ensjs/wallet' + +import { VERIFICATION_RECORD_KEY } from '@app/constants/verification' +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' +import { labelForVerificationProtocol } from '@app/utils/verification/labelForVerificationProtocol' + +import type { VerificationProtocol } from '../input/VerifyProfile/VerifyProfile-flow' + +type Data = { + name: string + resolverAddress: Address + verifier: VerificationProtocol + verifiedPresentationUri: string +} + +const displayItems = ({ name, verifier }: Data, t: TFunction): TransactionDisplayItem[] => { + return [ + { + label: 'name', + value: name, + type: 'name', + }, + { + label: 'action', + value: t('transaction.description.updateRecord'), + }, + { + label: 'record', + value: labelForVerificationProtocol(verifier), + }, + ] +} + +// TODO: Implement a function that identifies the url for the issuer and only updates that uri + +const transaction = async ({ connectorClient, data }: TransactionFunctionParameters) => { + const { name, resolverAddress, verifiedPresentationUri } = data + + return setTextRecord.makeFunctionData(connectorClient, { + name, + key: VERIFICATION_RECORD_KEY, + value: JSON.stringify([verifiedPresentationUri]), + resolverAddress, + }) +} +export default { + displayItems, + transaction, +} satisfies Transaction diff --git a/src/transaction/user/transaction/utils/makeTransferNameOrSubnameTransactionItem.ts b/src/transaction/user/transaction/utils/makeTransferNameOrSubnameTransactionItem.ts new file mode 100644 index 000000000..67e68bddd --- /dev/null +++ b/src/transaction/user/transaction/utils/makeTransferNameOrSubnameTransactionItem.ts @@ -0,0 +1,64 @@ +import { match, P } from 'ts-pattern' +import { Address } from 'viem' + +import type { useAbilities } from '@app/hooks/abilities/useAbilities' + +import { createTransactionItem, TransactionItem } from '../../transaction' + +type MakeTransferNameOrSubnameTransactionItemParams = { + name: string + newOwnerAddress: Address + sendType: 'sendManager' | 'sendOwner' + isOwnerOrManager: boolean + abilities: ReturnType['data'] +} + +export const makeTransferNameOrSubnameTransactionItem = ({ + name, + newOwnerAddress, + sendType, + isOwnerOrManager, + abilities, +}: MakeTransferNameOrSubnameTransactionItemParams): TransactionItem | null => { + return ( + match([ + isOwnerOrManager, + sendType, + abilities?.sendNameFunctionCallDetails?.[sendType]?.contract, + ]) + .with([true, 'sendOwner', P.not(P.nullish)], ([, , contract]) => + createTransactionItem('transferName', { + name, + newOwnerAddress, + sendType: 'sendOwner', + contract, + }), + ) + .with([true, 'sendManager', 'registrar'], () => + createTransactionItem('transferName', { + name, + newOwnerAddress, + sendType: 'sendManager', + contract: 'registrar', + reclaim: abilities?.sendNameFunctionCallDetails?.sendManager?.method === 'reclaim', + }), + ) + .with([true, 'sendManager', P.union('registry', 'nameWrapper')], ([, , contract]) => + createTransactionItem('transferName', { + name, + newOwnerAddress, + sendType: 'sendManager', + contract, + }), + ) + // A parent name can only transfer the manager + .with([false, 'sendManager', P.union('registry', 'nameWrapper')], ([, , contract]) => + createTransactionItem('transferSubname', { + name, + newOwnerAddress, + contract, + }), + ) + .otherwise(() => null) + ) +} diff --git a/src/transaction/user/transaction/wrapName.ts b/src/transaction/user/transaction/wrapName.ts new file mode 100644 index 000000000..735343527 --- /dev/null +++ b/src/transaction/user/transaction/wrapName.ts @@ -0,0 +1,37 @@ +import type { TFunction } from 'react-i18next' + +import { wrapName } from '@ensdomains/ensjs/wallet' + +import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' + +type Data = { + name: string +} + +const displayItems = ( + { name }: Data, + t: TFunction<'translation', undefined>, +): TransactionDisplayItem[] => [ + { + label: 'action', + value: t(`transaction.description.wrapName`), + }, + { + label: 'info', + value: t(`transaction.info.wrapName`), + }, + { + label: 'name', + value: name, + type: 'name', + }, +] + +const transaction = async ({ connectorClient, data }: TransactionFunctionParameters) => { + return wrapName.makeFunctionData(connectorClient, { + name: data.name, + newOwnerAddress: connectorClient.account.address, + }) +} + +export default { displayItems, transaction } satisfies Transaction diff --git a/src/types/index.ts b/src/types/index.ts index 7c15146bd..6d371d5ed 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -100,7 +100,6 @@ export interface Transaction { transaction: ( params: TransactionFunctionParameters, ) => Promise | BasicTransactionRequest - helper?: (data: TData, t: TFunction<'translation', undefined>) => undefined | HelperProps backToInput?: boolean } diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index 279bf7cde..d0ad3f5ee 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -1,3 +1,7 @@ +import { mainnet } from 'viem/chains' + +import type { SupportedChain } from '@ensdomains/ensjs/contracts' + declare global { interface Window { plausible: any @@ -10,10 +14,6 @@ function isProduction() { } } -function isMainnet(chain: string) { - return chain === 'mainnet' -} - export function setUtm() { if (typeof window !== 'undefined') { const urlParams = new URLSearchParams(window.location.search) @@ -32,7 +32,7 @@ export const setupAnalytics = () => { setUtm() } -export const trackEvent = async (type: string, chain: string) => { +export const trackEvent = async (type: string, chainId: SupportedChain['id']) => { const referrer = getUtm() function track() { if (typeof window !== 'undefined' && window.plausible) { @@ -43,8 +43,8 @@ export const trackEvent = async (type: string, chain: string) => { }) } } - console.log('Event triggering', type, chain) - if (isProduction() && isMainnet(chain)) { + console.log('Event triggering', type, chainId) + if (isProduction() && chainId === mainnet.id) { track() } else { console.log(