From 00085a5ea21bdca0c0807fe6cc9f99c5a07f198b Mon Sep 17 00:00:00 2001 From: tate Date: Wed, 18 Sep 2024 19:32:28 +1000 Subject: [PATCH 1/4] 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( From e223d54d13e5eeef704b99da7aac083eb2e811b2 Mon Sep 17 00:00:00 2001 From: tate Date: Mon, 23 Sep 2024 17:11:44 +1000 Subject: [PATCH 2/4] checkpoint 12 --- public/locales/en/common.json | 10 +- .../EditResolver/EditResolverForm.tsx | 10 +- .../@molecules/NameListView/NameListView.tsx | 7 +- .../VerificationBadge/VerificationBadge.tsx | 2 +- ...VerificationBadgeAccountTooltipContent.tsx | 2 +- ...erificationBadgeVerifierTooltipContent.tsx | 2 +- src/components/Notifications2.tsx | 74 +- src/components/ProfileSnippet.tsx | 3 +- .../pages/VerificationErrorDialog.tsx | 2 +- .../pages/import/[name]/DnsClaim.tsx | 4 +- .../import/[name]/steps/SelectImportType.tsx | 2 +- src/components/pages/import/[name]/utils.ts | 8 +- .../pages/profile/ProfileButton.tsx | 7 +- .../pages/profile/[name]/Profile.tsx | 9 +- .../profile/[name]/ProfileEmptyBanner.tsx | 2 +- .../registration/steps/Pricing/Pricing.tsx | 613 --------------- .../registration/useMoonpayRegistration.ts | 98 --- .../Miscellaneous/RegistrationDate.tsx | 8 +- .../profile/[name]/tabs/MoreTab/Ownership.tsx | 32 +- .../profile/[name]/tabs/MoreTab/Resolver.tsx | 3 +- .../[name]/tabs/MoreTab/Token/Token.tsx | 12 +- .../tabs/MoreTab/Token/UnwrapButton.tsx | 10 +- .../[name]/tabs/MoreTab/Token/WrapButton.tsx | 45 +- .../ContractSection/ContractSection.tsx | 12 +- .../ExpirySection/hooks/useExpiryActions.tsx | 3 +- .../ExpirySection/hooks/useExpiryDetails.ts | 13 +- .../RolesSection/components/RoleRow.tsx | 11 +- .../hooks/useRoleActions.test.tsx | 4 +- .../RolesSection/hooks/useRoleActions.tsx | 7 +- .../tabs/PermissionsTab/ExpiryPermissions.tsx | 3 +- .../PermissionsTab/NameChangePermissions.tsx | 3 +- .../PermissionsTab/OwnershipPermissions.tsx | 3 +- .../pages/profile/[name]/tabs/RecordsTab.tsx | 3 +- .../pages/profile/[name]/tabs/SubnamesTab.tsx | 3 +- .../pages/profile/settings/DevSection.tsx | 49 +- .../pages/profile/settings/PrimarySection.tsx | 3 +- .../ClearTransactionsDialog.tsx | 2 +- .../TransactionSection/TransactionSection.tsx | 69 +- .../registration => register}/FullInvoice.tsx | 0 .../Registration.tsx | 263 +++---- .../steps/Complete.tsx | 43 +- .../registration => register}/steps/Info.tsx | 42 +- .../register/steps/Pricing/PaymentChoice.tsx | 315 ++++++++ .../steps/Pricing/Pricing.test.tsx | 0 .../pages/register/steps/Pricing/Pricing.tsx | 316 ++++++++ .../steps/Pricing/TemporaryPremium.tsx | 0 .../Profile/AddProfileRecordView.test.tsx | 0 .../steps/Profile/AddProfileRecordView.tsx | 2 +- .../Profile/CustomProfileRecordInput.tsx | 0 .../steps/Profile/DynamicIcon.tsx | 0 .../steps/Profile/Field.tsx | 0 .../steps/Profile/OptionButton.tsx | 0 .../steps/Profile/OptionGroup.tsx | 0 .../steps/Profile/Profile.test.tsx | 0 .../steps/Profile/Profile.tsx | 23 +- .../steps/Profile/ProfileRecordInput.tsx | 0 .../steps/Profile/ProfileRecordTextarea.tsx | 0 .../steps/Profile/WrappedAvatarButton.tsx | 0 .../steps/Profile/profileRecordUtils.test.ts | 0 .../steps/Profile/profileRecordUtils.ts | 0 .../steps/Transactions.tsx | 204 ++--- .../[name]/registration => register}/types.ts | 0 .../useMoonpayRegistration.test.ts | 0 .../pages/register/useMoonpayRegistration.ts | 149 ++++ src/constants/chains.ts | 29 +- src/constants/verification.ts | 2 +- src/hooks/chain/useChainName.ts | 5 +- .../chain/useEstimateGasWithStateOverride.ts | 19 +- .../gasEstimation/useEstimateRegistration.ts | 2 +- .../useProfileActions/useProfileActions.ts | 90 ++- .../index.ts | 26 +- src/hooks/useFaucet.ts | 6 +- src/hooks/useProfileEditorForm.tsx | 2 +- src/hooks/useRegistrationParams.ts | 51 +- src/hooks/useRegistrationReducer.ts | 2 +- src/hooks/useResolverEditor.ts | 8 +- .../useVerificationOAuth.ts | 2 +- .../useVerificationOAuthHandler.ts | 7 +- .../createVerificationTransactionFlow.ts | 27 +- .../utils/dentityHandler.ts | 4 - .../utils/makeAppendVerificationProps.ts | 2 +- src/pages/_app.tsx | 14 +- src/pages/address.tsx | 11 +- src/pages/register.tsx | 2 +- .../TransactionFlowProvider.tsx | 240 ------ src/transaction-flow/TransactionLoader.tsx | 28 - .../input/CreateSubname-flow.tsx | 113 --- .../DeleteEmancipatedSubnameWarning-flow.tsx | 86 -- .../DeleteSubnameNotParentWarning-flow.tsx | 100 --- .../input/EditResolver/EditResolver-flow.tsx | 77 -- .../input/EditRoles/EditRoles-flow.tsx | 139 ---- .../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 - .../input/SendName/SendName-flow.tsx | 153 ---- .../input/SendName/SendName.test.tsx | 117 --- .../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 - .../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-flow/input/index.tsx | 90 --- src/transaction-flow/intro/index.ts | 26 - src/transaction-flow/reducer.test.ts | 120 --- src/transaction-flow/reducer.ts | 199 ----- .../transaction/approveDnsRegistrar.ts | 66 -- .../transaction/approveNameWrapper.ts | 70 -- src/transaction-flow/transaction/burnFuses.ts | 47 -- .../transaction/changePermissions.ts | 114 --- .../transaction/claimDnsName.ts | 30 - .../transaction/commitName.ts | 33 - .../transaction/createSubname.ts | 43 - .../transaction/deleteSubname.ts | 43 - .../transaction/extendNames.ts | 68 -- .../transaction/importDnsName.ts | 30 - src/transaction-flow/transaction/index.ts | 101 --- .../transaction/migrateProfile.ts | 69 -- .../transaction/migrateProfileWithReset.ts | 73 -- .../transaction/registerName.test.ts | 27 - .../transaction/registerName.ts | 50 -- .../transaction/removeVerificationRecord.ts | 52 -- .../transaction/resetPrimaryName.ts | 30 - .../transaction/resetProfile.ts | 39 - .../transaction/resetProfileWithRecords.ts | 66 -- .../transaction/setPrimaryName.ts | 37 - .../transaction/syncManager.ts | 41 - .../transaction/testSendName.ts | 39 - .../transaction/transferController.ts | 42 - .../transaction/transferName.ts | 62 -- .../transaction/transferSubname.ts | 40 - .../transaction/unwrapName.test.ts | 81 -- .../transaction/unwrapName.ts | 42 - .../transaction/updateEthAddress.ts | 61 -- .../transaction/updateProfile.ts | 72 -- .../transaction/updateProfileRecords.ts | 98 --- .../transaction/updateResolver.ts | 45 -- .../transaction/updateVerificationRecord.ts | 52 -- ...akeTransferNameOrSubnameTransactionItem.ts | 64 -- src/transaction-flow/transaction/wrapName.ts | 37 - src/transaction-flow/types.ts | 192 ----- src/transaction/analytics.ts | 12 + src/transaction/components/DisplayItems.tsx | 275 +++++++ .../components/TransactionDialogManager.tsx | 69 +- .../stage/intro/IntroStageModal.tsx | 15 +- .../stage/transaction/ActionButton.tsx | 2 +- .../stage/transaction/BackButton.tsx | 10 +- .../components/stage/transaction/LoadBar.tsx | 42 +- .../transaction/TransactionStageModal.tsx | 57 +- .../components/stage/transaction/query.ts | 68 +- src/transaction/createTransactionListener.ts | 6 +- src/transaction/key.ts | 17 +- src/transaction/slices/createCurrentSlice.ts | 67 ++ src/transaction/slices/createFlowSlice.ts | 376 +++++++++ .../slices/createNotificationSlice.ts | 58 ++ .../slices/createRegistrationFlowSlice.ts | 509 ++++++++++++ .../slices/createTransactionSlice.ts | 226 ++++++ src/transaction/slices/types.ts | 17 + src/transaction/slices/utils.ts | 43 + .../transactionAnalyticsListener.ts | 29 - src/transaction/transactionManager.ts | 70 ++ src/transaction/transactionReceiptListener.ts | 23 +- src/transaction/transactionStore.ts | 352 --------- src/transaction/types.ts | 504 ++++++------ src/transaction/usePreparedDataInput.ts | 18 +- src/transaction/user/input.tsx | 73 +- .../AdvancedEditor/AdvancedEditor-flow.tsx | 29 +- .../CreateSubname-flow.tsx | 32 +- .../DeleteEmancipatedSubnameWarning-flow.tsx | 21 +- .../DeleteSubnameNotParentWarning-flow.tsx | 23 +- .../input/EditResolver/EditResolver-flow.tsx | 24 +- .../user/input/EditRoles/EditRoles-flow.tsx | 27 +- .../user/input/EditRoles/EditRoles.test.tsx | 2 +- .../views/EditRoleView/EditRoleView.tsx | 6 +- .../EditRoleView/views/EditRoleIntroView.tsx | 2 +- .../views/EditRoleResultsView.tsx | 2 +- .../input/ExtendNames/ExtendNames-flow.tsx | 17 +- .../ProfileEditor/ProfileEditor-flow.tsx | 52 +- .../ProfileEditor/ProfileEditor.test.tsx | 2 +- .../ProfileEditor/ResolverWarningOverlay.tsx | 139 ++-- .../ResetPrimaryName-flow.tsx | 25 +- .../RevokePermissions-flow.tsx | 42 +- .../SelectPrimaryName-flow.tsx | 25 +- .../user/input/SendName/SendName-flow.tsx | 15 +- .../user/input/SendName/SendName.test.tsx | 2 +- .../SendName/utils/getSendNameTransactions.ts | 19 +- .../SendName/views/SearchView/SearchView.tsx | 2 +- .../views/SummaryView/SummaryView.tsx | 2 +- .../input/SyncManager/SyncManager-flow.tsx | 27 +- .../UnknownLabels/UnknownLabels-flow.tsx | 48 +- .../VerifyProfile/VerifyProfile-flow.tsx | 7 +- .../input/VerifyProfile/views/DentityView.tsx | 34 +- src/transaction/user/intro.tsx | 64 ++ .../user}/intro/ChangePrimaryName.tsx | 3 +- .../user}/intro/GenericWithDescription.tsx | 0 .../user}/intro/MigrateAndUpdateResolver.tsx | 3 +- .../user}/intro/SyncManager.tsx | 0 .../user}/intro/WrapName.tsx | 0 src/transaction/user/transaction.ts | 36 +- .../user/transaction/__dev_failure.ts | 25 + .../user/transaction/extendNames.ts | 2 +- .../user/transaction/registerName.ts | 5 + .../user/transaction/updateProfileRecords.ts | 2 +- .../user/transaction/updateResolver.ts | 2 +- ...akeTransferNameOrSubnameTransactionItem.ts | 12 +- src/utils/chains/makeLocalhostChainWithEns.ts | 6 + src/utils/getChainName.ts | 10 +- src/utils/query/getSourceChainId.ts | 6 + src/utils/query/wagmi.ts | 5 +- .../records/categoriseProfileTextRecords.ts | 2 +- src/utils/utils.ts | 24 +- src/utils/verification/getVerifierData.ts | 4 +- .../verification/isVerificationProtocol.ts | 2 +- .../labelForVerificationProtocol.ts | 2 +- 281 files changed, 4123 insertions(+), 14938 deletions(-) delete mode 100644 src/components/pages/profile/[name]/registration/steps/Pricing/Pricing.tsx delete mode 100644 src/components/pages/profile/[name]/registration/useMoonpayRegistration.ts rename src/components/pages/{profile/[name]/registration => register}/FullInvoice.tsx (100%) rename src/components/pages/{profile/[name]/registration => register}/Registration.tsx (53%) rename src/components/pages/{profile/[name]/registration => register}/steps/Complete.tsx (86%) rename src/components/pages/{profile/[name]/registration => register}/steps/Info.tsx (74%) create mode 100644 src/components/pages/register/steps/Pricing/PaymentChoice.tsx rename src/components/pages/{profile/[name]/registration => register}/steps/Pricing/Pricing.test.tsx (100%) create mode 100644 src/components/pages/register/steps/Pricing/Pricing.tsx rename src/components/pages/{profile/[name]/registration => register}/steps/Pricing/TemporaryPremium.tsx (100%) rename src/components/pages/{profile/[name]/registration => register}/steps/Profile/AddProfileRecordView.test.tsx (100%) rename src/components/pages/{profile/[name]/registration => register}/steps/Profile/AddProfileRecordView.tsx (99%) rename src/components/pages/{profile/[name]/registration => register}/steps/Profile/CustomProfileRecordInput.tsx (100%) rename src/components/pages/{profile/[name]/registration => register}/steps/Profile/DynamicIcon.tsx (100%) rename src/components/pages/{profile/[name]/registration => register}/steps/Profile/Field.tsx (100%) rename src/components/pages/{profile/[name]/registration => register}/steps/Profile/OptionButton.tsx (100%) rename src/components/pages/{profile/[name]/registration => register}/steps/Profile/OptionGroup.tsx (100%) rename src/components/pages/{profile/[name]/registration => register}/steps/Profile/Profile.test.tsx (100%) rename src/components/pages/{profile/[name]/registration => register}/steps/Profile/Profile.tsx (93%) rename src/components/pages/{profile/[name]/registration => register}/steps/Profile/ProfileRecordInput.tsx (100%) rename src/components/pages/{profile/[name]/registration => register}/steps/Profile/ProfileRecordTextarea.tsx (100%) rename src/components/pages/{profile/[name]/registration => register}/steps/Profile/WrappedAvatarButton.tsx (100%) rename src/components/pages/{profile/[name]/registration => register}/steps/Profile/profileRecordUtils.test.ts (100%) rename src/components/pages/{profile/[name]/registration => register}/steps/Profile/profileRecordUtils.ts (100%) rename src/components/pages/{profile/[name]/registration => register}/steps/Transactions.tsx (60%) rename src/components/pages/{profile/[name]/registration => register}/types.ts (100%) rename src/components/pages/{profile/[name]/registration => register}/useMoonpayRegistration.test.ts (100%) create mode 100644 src/components/pages/register/useMoonpayRegistration.ts delete mode 100644 src/transaction-flow/TransactionFlowProvider.tsx delete mode 100644 src/transaction-flow/TransactionLoader.tsx delete mode 100644 src/transaction-flow/input/CreateSubname-flow.tsx delete mode 100644 src/transaction-flow/input/DeleteEmancipatedSubnameWarning/DeleteEmancipatedSubnameWarning-flow.tsx delete mode 100644 src/transaction-flow/input/DeleteSubnameNotParentWarning/DeleteSubnameNotParentWarning-flow.tsx delete mode 100644 src/transaction-flow/input/EditResolver/EditResolver-flow.tsx delete mode 100644 src/transaction-flow/input/EditRoles/EditRoles-flow.tsx delete mode 100644 src/transaction-flow/input/EditRoles/EditRoles.test.tsx delete mode 100644 src/transaction-flow/input/EditRoles/hooks/useSimpleSearch.ts delete mode 100644 src/transaction-flow/input/EditRoles/views/EditRoleView/EditRoleView.tsx delete mode 100644 src/transaction-flow/input/EditRoles/views/EditRoleView/views/EditRoleIntroView.tsx delete mode 100644 src/transaction-flow/input/EditRoles/views/EditRoleView/views/EditRoleResultsView.tsx delete mode 100644 src/transaction-flow/input/EditRoles/views/MainView/MainView.tsx delete mode 100644 src/transaction-flow/input/EditRoles/views/MainView/components/NoneSetAvatarWithIdentifier.tsx delete mode 100644 src/transaction-flow/input/EditRoles/views/MainView/components/RoleCard.tsx delete mode 100644 src/transaction-flow/input/ExtendNames/ExtendNames-flow.test.tsx delete mode 100644 src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/ProfileEditor-flow.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/ProfileEditor.test.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/ResolverWarningOverlay.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/WrappedAvatarButton.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/components/CenteredTypography.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/components/ContentContainer.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/components/DetailedSwitch.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/components/ProfileBlurb.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/components/SkipButton.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/views/InvalidResolverView.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/views/MigrateProfileSelectorView.tsx.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/views/MigrateProfileWarningView.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/views/MigrateRegistryView.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/views/NoResolverView.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/views/ResetProfileView.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/views/ResolverNotNameWrapperAwareView.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/views/ResolverOutOfDateView.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/views/ResolverOutOfSyncView.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/views/TransferOrResetProfileView.tsx delete mode 100644 src/transaction-flow/input/ProfileEditor/views/UpdateResolverOrResetProfileView.tsx delete mode 100644 src/transaction-flow/input/ResetPrimaryName/ResetPrimaryName-flow.tsx delete mode 100644 src/transaction-flow/input/RevokePermissions/RevokePermissions-flow.tsx delete mode 100644 src/transaction-flow/input/RevokePermissions/RevokePermissions.test.tsx delete mode 100644 src/transaction-flow/input/RevokePermissions/components/CenterAlignedTypography.tsx delete mode 100644 src/transaction-flow/input/RevokePermissions/components/ControlledNextButton.tsx delete mode 100644 src/transaction-flow/input/RevokePermissions/views/GrantExtendExpiryView.tsx delete mode 100644 src/transaction-flow/input/RevokePermissions/views/NameConfirmationWarningView.test.tsx delete mode 100644 src/transaction-flow/input/RevokePermissions/views/NameConfirmationWarningView.tsx delete mode 100644 src/transaction-flow/input/RevokePermissions/views/ParentRevokePermissionsView.tsx delete mode 100644 src/transaction-flow/input/RevokePermissions/views/RevokeChangeFusesView.tsx delete mode 100644 src/transaction-flow/input/RevokePermissions/views/RevokeChangeFusesWarningView.tsx delete mode 100644 src/transaction-flow/input/RevokePermissions/views/RevokePCCView.tsx delete mode 100644 src/transaction-flow/input/RevokePermissions/views/RevokePermissionsView.tsx delete mode 100644 src/transaction-flow/input/RevokePermissions/views/RevokeUnwrapView.tsx delete mode 100644 src/transaction-flow/input/RevokePermissions/views/RevokeWarningView.tsx delete mode 100644 src/transaction-flow/input/RevokePermissions/views/SetExpiryView.tsx delete mode 100644 src/transaction-flow/input/SelectPrimaryName/SelectPrimaryName-flow.tsx delete mode 100644 src/transaction-flow/input/SelectPrimaryName/SelectPrimaryName.test.tsx delete mode 100644 src/transaction-flow/input/SelectPrimaryName/components/TaggedNameItemWithFuseCheck.test.tsx delete mode 100644 src/transaction-flow/input/SelectPrimaryName/components/TaggedNameItemWithFuseCheck.tsx delete mode 100644 src/transaction-flow/input/SendName/SendName-flow.tsx delete mode 100644 src/transaction-flow/input/SendName/SendName.test.tsx delete mode 100644 src/transaction-flow/input/SendName/utils/checkCanSend.ts delete mode 100644 src/transaction-flow/input/SendName/utils/getSendNameTransactions.test.ts delete mode 100644 src/transaction-flow/input/SendName/utils/getSendNameTransactions.ts delete mode 100644 src/transaction-flow/input/SendName/views/CannotSendView.tsx delete mode 100644 src/transaction-flow/input/SendName/views/ConfirmationView.tsx delete mode 100644 src/transaction-flow/input/SendName/views/SearchView/SearchView.tsx delete mode 100644 src/transaction-flow/input/SendName/views/SearchView/components/SearchViewResult.tsx delete mode 100644 src/transaction-flow/input/SendName/views/SearchView/views/SearchViewErrorView.tsx delete mode 100644 src/transaction-flow/input/SendName/views/SearchView/views/SearchViewIntroView.tsx delete mode 100644 src/transaction-flow/input/SendName/views/SearchView/views/SearchViewLoadingView.tsx delete mode 100644 src/transaction-flow/input/SendName/views/SearchView/views/SearchViewNoResultsView.tsx delete mode 100644 src/transaction-flow/input/SendName/views/SearchView/views/SearchViewResultsView.tsx delete mode 100644 src/transaction-flow/input/SendName/views/SummaryView/SummaryView.tsx delete mode 100644 src/transaction-flow/input/SendName/views/SummaryView/components/SummarySection.tsx delete mode 100644 src/transaction-flow/input/SyncManager/SyncManager-flow.tsx delete mode 100644 src/transaction-flow/input/SyncManager/utils/checkCanSyncManager.ts delete mode 100644 src/transaction-flow/input/SyncManager/views/ErrorView.tsx delete mode 100644 src/transaction-flow/input/SyncManager/views/MainView.tsx delete mode 100644 src/transaction-flow/input/UnknownLabels/UnknownLabels-flow.tsx delete mode 100644 src/transaction-flow/input/UnknownLabels/UnknownLabels.test.tsx delete mode 100644 src/transaction-flow/input/UnknownLabels/views/UnknownLabelsForm.tsx delete mode 100644 src/transaction-flow/input/VerifyProfile/VerifyProfile-flow.tsx delete mode 100644 src/transaction-flow/input/VerifyProfile/components/VerificationOptionButton.tsx delete mode 100644 src/transaction-flow/input/VerifyProfile/utils/createDentityUrl.ts delete mode 100644 src/transaction-flow/input/VerifyProfile/views/DentityView.tsx delete mode 100644 src/transaction-flow/input/VerifyProfile/views/VerificationOptionsList.tsx delete mode 100644 src/transaction-flow/input/index.tsx delete mode 100644 src/transaction-flow/intro/index.ts delete mode 100644 src/transaction-flow/reducer.test.ts delete mode 100644 src/transaction-flow/reducer.ts delete mode 100644 src/transaction-flow/transaction/approveDnsRegistrar.ts delete mode 100644 src/transaction-flow/transaction/approveNameWrapper.ts delete mode 100644 src/transaction-flow/transaction/burnFuses.ts delete mode 100644 src/transaction-flow/transaction/changePermissions.ts delete mode 100644 src/transaction-flow/transaction/claimDnsName.ts delete mode 100644 src/transaction-flow/transaction/commitName.ts delete mode 100644 src/transaction-flow/transaction/createSubname.ts delete mode 100644 src/transaction-flow/transaction/deleteSubname.ts delete mode 100644 src/transaction-flow/transaction/extendNames.ts delete mode 100644 src/transaction-flow/transaction/importDnsName.ts delete mode 100644 src/transaction-flow/transaction/index.ts delete mode 100644 src/transaction-flow/transaction/migrateProfile.ts delete mode 100644 src/transaction-flow/transaction/migrateProfileWithReset.ts delete mode 100644 src/transaction-flow/transaction/registerName.test.ts delete mode 100644 src/transaction-flow/transaction/registerName.ts delete mode 100644 src/transaction-flow/transaction/removeVerificationRecord.ts delete mode 100644 src/transaction-flow/transaction/resetPrimaryName.ts delete mode 100644 src/transaction-flow/transaction/resetProfile.ts delete mode 100644 src/transaction-flow/transaction/resetProfileWithRecords.ts delete mode 100644 src/transaction-flow/transaction/setPrimaryName.ts delete mode 100644 src/transaction-flow/transaction/syncManager.ts delete mode 100644 src/transaction-flow/transaction/testSendName.ts delete mode 100644 src/transaction-flow/transaction/transferController.ts delete mode 100644 src/transaction-flow/transaction/transferName.ts delete mode 100644 src/transaction-flow/transaction/transferSubname.ts delete mode 100644 src/transaction-flow/transaction/unwrapName.test.ts delete mode 100644 src/transaction-flow/transaction/unwrapName.ts delete mode 100644 src/transaction-flow/transaction/updateEthAddress.ts delete mode 100644 src/transaction-flow/transaction/updateProfile.ts delete mode 100644 src/transaction-flow/transaction/updateProfileRecords.ts delete mode 100644 src/transaction-flow/transaction/updateResolver.ts delete mode 100644 src/transaction-flow/transaction/updateVerificationRecord.ts delete mode 100644 src/transaction-flow/transaction/utils/makeTransferNameOrSubnameTransactionItem.ts delete mode 100644 src/transaction-flow/transaction/wrapName.ts delete mode 100644 src/transaction-flow/types.ts create mode 100644 src/transaction/analytics.ts create mode 100644 src/transaction/slices/createCurrentSlice.ts create mode 100644 src/transaction/slices/createFlowSlice.ts create mode 100644 src/transaction/slices/createNotificationSlice.ts create mode 100644 src/transaction/slices/createRegistrationFlowSlice.ts create mode 100644 src/transaction/slices/createTransactionSlice.ts create mode 100644 src/transaction/slices/types.ts create mode 100644 src/transaction/slices/utils.ts delete mode 100644 src/transaction/transactionAnalyticsListener.ts create mode 100644 src/transaction/transactionManager.ts delete mode 100644 src/transaction/transactionStore.ts rename src/transaction/user/input/{ => CreateSubname}/CreateSubname-flow.tsx (83%) create mode 100644 src/transaction/user/intro.tsx rename src/{transaction-flow => transaction/user}/intro/ChangePrimaryName.tsx (90%) rename src/{transaction-flow => transaction/user}/intro/GenericWithDescription.tsx (100%) rename src/{transaction-flow => transaction/user}/intro/MigrateAndUpdateResolver.tsx (91%) rename src/{transaction-flow => transaction/user}/intro/SyncManager.tsx (100%) rename src/{transaction-flow => transaction/user}/intro/WrapName.tsx (100%) create mode 100644 src/transaction/user/transaction/__dev_failure.ts create mode 100644 src/utils/query/getSourceChainId.ts diff --git a/public/locales/en/common.json b/public/locales/en/common.json index e2b0f4c2a..986eff0ed 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -282,12 +282,12 @@ "pending": { "regular": "Pending" }, - "confirmed": { + "success": { "regular": "Confirmed", "notifyTitle": "Transaction Successful", "notifyMessage": "Your \"{{action}}\" transaction was successful" }, - "failed": { + "reverted": { "regular": "Failed", "notifyTitle": "Transaction Failure", "notifyMessage": "Your \"{{action}}\" transaction failed and was reverted" @@ -305,7 +305,7 @@ "waitingForWallet": "Waiting for Wallet", "openWallet": "Open Wallet" }, - "sent": { + "pending": { "title": "Transaction Sent", "message": "Your transaction is now in progress, you can close this and come back later.", "progress": { @@ -314,14 +314,14 @@ }, "learn": "Learn about long running transactions" }, - "complete": { + "success": { "title": "Transaction Complete", "message": "Your transaction is now complete!", "progress": { "title": "Done" } }, - "failed": { + "reverted": { "title": "Transaction Failed", "progress": { "title": "Failed" diff --git a/src/components/@molecules/EditResolver/EditResolverForm.tsx b/src/components/@molecules/EditResolver/EditResolverForm.tsx index c7a6f37d2..b5372cd88 100644 --- a/src/components/@molecules/EditResolver/EditResolverForm.tsx +++ b/src/components/@molecules/EditResolver/EditResolverForm.tsx @@ -1,13 +1,13 @@ import { RefObject } from 'react' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' +import { useChainId } from 'wagmi' import { Dialog, RadioButton, Typography } from '@ensdomains/thorin' import { Outlink } from '@app/components/Outlink' -import { useChainName } from '@app/hooks/chain/useChainName' import useResolverEditor from '@app/hooks/useResolverEditor' -import { makeEtherscanLink } from '@app/utils/utils' +import { createEtherscanLink } from '@app/utils/utils' import { DogFood } from '../DogFood' import EditResolverWarnings from './EditResolverWarnings' @@ -48,7 +48,7 @@ type Props = ReturnType & { const EditResolverForm = ({ isResolverAddressLatest, - lastestResolverAddress, + latestResolverAddress, resolverChoice, handleSubmit, register, @@ -63,7 +63,7 @@ const EditResolverForm = ({ resolverWarnings, }: Props) => { const { t } = useTranslation('transactionFlow') - const chainName = useChainName() + const chainId = useChainId() const latestResolverLabel = ( @@ -71,7 +71,7 @@ const EditResolverForm = ({ {t('input.editResolver.latestLabel')} {t('input.editResolver.etherscan')} diff --git a/src/components/@molecules/NameListView/NameListView.tsx b/src/components/@molecules/NameListView/NameListView.tsx index 0aae5fede..f91b52103 100644 --- a/src/components/@molecules/NameListView/NameListView.tsx +++ b/src/components/@molecules/NameListView/NameListView.tsx @@ -21,7 +21,8 @@ import { usePrefetchBlockTimestamp } from '@app/hooks/chain/useBlockTimestamp' import { useNamesForAddress } from '@app/hooks/ensjs/subgraph/useNamesForAddress' import useDebouncedCallback from '@app/hooks/useDebouncedCallback' import { useQueryParameterState } from '@app/hooks/useQueryParameterState' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { useTransactionManager } from '@app/transaction/transactionManager' +import { usePreparedDataInput } from '@app/transaction/usePreparedDataInput' const EmptyDetailContainer = styled.div( ({ theme }) => css` @@ -120,7 +121,7 @@ export const NameListView = ({ address, selfAddress, setError, setLoading }: Nam // eslint-disable-next-line react-hooks/exhaustive-deps }, [isNamesLoading]) - const { usePreparedDataInput, getTransactionFlowStage } = useTransactionFlow() + const getTransactionFlowStage = useTransactionManager((s) => s.getFlowStageOrNull) const showExtendNamesInput = usePreparedDataInput('ExtendNames') const [isIntersecting, setIsIntersecting] = useState(false) @@ -142,7 +143,7 @@ export const NameListView = ({ address, selfAddress, setError, setLoading }: Nam const stage = getTransactionFlowStage(`extend-names-${selectedNames.join('-')}`) useEffect(() => { - if (stage === 'completed') { + if (stage === 'complete') { setSelectedNames([]) setMode('view') } diff --git a/src/components/@molecules/VerificationBadge/VerificationBadge.tsx b/src/components/@molecules/VerificationBadge/VerificationBadge.tsx index eb4a17eca..0c4dfc027 100644 --- a/src/components/@molecules/VerificationBadge/VerificationBadge.tsx +++ b/src/components/@molecules/VerificationBadge/VerificationBadge.tsx @@ -6,7 +6,7 @@ import { AlertSVG, Colors, Tooltip } from '@ensdomains/thorin' import VerifiedPersonSVG from '@app/assets/VerifiedPerson.svg' import VerifiedRecordSVG from '@app/assets/VerifiedRecord.svg' -import { VerificationProtocol } from '@app/transaction-flow/input/VerifyProfile/VerifyProfile-flow' +import { VerificationProtocol } from '@app/transaction/user/VerifyProfile/VerifyProfile-flow' type Color = Extract diff --git a/src/components/@molecules/VerificationBadge/components/VerificationBadgeAccountTooltipContent.tsx b/src/components/@molecules/VerificationBadge/components/VerificationBadgeAccountTooltipContent.tsx index af599e5b0..6d574412f 100644 --- a/src/components/@molecules/VerificationBadge/components/VerificationBadgeAccountTooltipContent.tsx +++ b/src/components/@molecules/VerificationBadge/components/VerificationBadgeAccountTooltipContent.tsx @@ -4,7 +4,7 @@ import { match } from 'ts-pattern' import { Colors, OutlinkSVG, Typography } from '@ensdomains/thorin' import DentitySVG from '@app/assets/verification/Dentity.svg' -import { VerificationProtocol } from '@app/transaction-flow/input/VerifyProfile/VerifyProfile-flow' +import { VerificationProtocol } from '@app/transaction/user/VerifyProfile/VerifyProfile-flow' type Props = { verifiers?: VerificationProtocol[] } diff --git a/src/components/@molecules/VerificationBadge/components/VerificationBadgeVerifierTooltipContent.tsx b/src/components/@molecules/VerificationBadge/components/VerificationBadgeVerifierTooltipContent.tsx index e136862ce..077717bdc 100644 --- a/src/components/@molecules/VerificationBadge/components/VerificationBadgeVerifierTooltipContent.tsx +++ b/src/components/@molecules/VerificationBadge/components/VerificationBadgeVerifierTooltipContent.tsx @@ -5,7 +5,7 @@ import { Colors, Typography } from '@ensdomains/thorin' import VerifiedPersonSVG from '@app/assets/VerifiedPerson.svg' import { SupportOutlink } from '@app/components/@atoms/SupportOutlink' -import { CenteredTypography } from '@app/transaction-flow/input/ProfileEditor/components/CenteredTypography' +import { CenteredTypography } from '@app/transaction/user/input/ProfileEditor/components/CenteredTypography' const Container = styled.div<{ $color: Colors }>( ({ theme, $color }) => css` diff --git a/src/components/Notifications2.tsx b/src/components/Notifications2.tsx index 870c7da9a..27d02a3fa 100644 --- a/src/components/Notifications2.tsx +++ b/src/components/Notifications2.tsx @@ -1,16 +1,13 @@ 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 type { StoredTransaction } from '@app/transaction/slices/createTransactionSlice' +import { useTransactionManager } from '@app/transaction/transactionManager' 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' +import { createEtherscanLink } from '@app/utils/utils' const ButtonContainer = styled.div( ({ theme }) => css` @@ -22,10 +19,7 @@ const ButtonContainer = styled.div( `, ) -type SuccessOrRevertedTransaction = Extract< - LastTransactionChange, - { status: 'success' | 'reverted' } -> +type SuccessOrRevertedTransaction = Extract const Notification = ({ transaction, @@ -38,21 +32,22 @@ const Notification = ({ }) => { const { t } = useTranslation() const breakpoints = useBreakpoint() - const getResumable = useTransactionStore((s) => s.flow.getResumable) - const resumeFlow = useTransactionStore((s) => s.flow.resume) + const isFlowResumable = useTransactionManager((s) => s.isFlowResumable) + const resumeFlow = useTransactionManager((s) => s.resumeFlow) - const resumable = transaction && getResumable(transaction.flowKey) - const chainName = transaction && getChainName(wagmiConfig, { chainId: transaction.chainId }) + const resumable = transaction && isFlowResumable(transaction.flowId) const button = (() => { if (!transaction) return null + + const etherscanLink = createEtherscanLink({ + data: transaction.currentHash, + chainId: transaction.targetChainId, + }) + if (!resumable) return ( - + @@ -61,11 +56,7 @@ const Notification = ({ return ( - + @@ -73,7 +64,7 @@ const Notification = ({ @@ -85,7 +76,7 @@ const Notification = ({ ? { title: t(`transaction.status.${transaction.status}.notifyTitle`), description: t(`transaction.status.${transaction.status}.notifyMessage`, { - action: t(`transaction.description.${transaction.action}`), + action: t(`transaction.description.${transaction.name}`), }), children: button, } @@ -104,34 +95,23 @@ const Notification = ({ } 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 [shouldHide, setShouldHide] = useState(false) + const currentNotification = useTransactionManager((s) => s.currentNotification) + const dismissNotification = useTransactionManager((s) => s.dismissNotification) - const currentTransaction = transactionQueue[0] ?? null + const open = currentNotification !== null && !shouldHide return ( { - setOpen(false) - setTimeout( - () => setTransactionQueue((prev) => [...prev.filter((x) => x !== currentTransaction)]), - 300, - ) + setShouldHide(true) + setTimeout(() => { + dismissNotification() + setShouldHide(false) + }, 300) }} open={open} - transaction={currentTransaction} + transaction={currentNotification} /> ) } diff --git a/src/components/ProfileSnippet.tsx b/src/components/ProfileSnippet.tsx index a478fea35..41edca7b5 100644 --- a/src/components/ProfileSnippet.tsx +++ b/src/components/ProfileSnippet.tsx @@ -9,8 +9,8 @@ import VerifiedPersonSVG from '@app/assets/VerifiedPerson.svg' import { useAbilities } from '@app/hooks/abilities/useAbilities' import { useBeautifiedName } from '@app/hooks/useBeautifiedName' import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' +import { usePreparedDataInput } from '@app/transaction/usePreparedDataInput' -import { useTransactionFlow } from '../transaction-flow/TransactionFlowProvider' import { NameAvatar } from './AvatarWithZorb' const Container = styled.div<{ $banner?: string }>( @@ -189,7 +189,6 @@ export const ProfileSnippet = ({ const router = useRouterWithHistory() const { t } = useTranslation('common') - const { usePreparedDataInput } = useTransactionFlow() const showExtendNamesInput = usePreparedDataInput('ExtendNames') const abilities = useAbilities({ name }) diff --git a/src/components/pages/VerificationErrorDialog.tsx b/src/components/pages/VerificationErrorDialog.tsx index 77ec80c22..1f52cf48b 100644 --- a/src/components/pages/VerificationErrorDialog.tsx +++ b/src/components/pages/VerificationErrorDialog.tsx @@ -2,7 +2,7 @@ import { ComponentProps } from 'react' import { Button, Dialog } from '@ensdomains/thorin' -import { CenteredTypography } from '@app/transaction-flow/input/ProfileEditor/components/CenteredTypography' +import { CenteredTypography } from '@app/transaction/user/input/ProfileEditor/components/CenteredTypography' export type ButtonProps = ComponentProps diff --git a/src/components/pages/import/[name]/DnsClaim.tsx b/src/components/pages/import/[name]/DnsClaim.tsx index f114d8e0d..b480e736a 100644 --- a/src/components/pages/import/[name]/DnsClaim.tsx +++ b/src/components/pages/import/[name]/DnsClaim.tsx @@ -7,7 +7,7 @@ import { useAccount } from 'wagmi' import { useBasicName } from '@app/hooks/useBasicName' import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' import { Content } from '@app/layouts/Content' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { useTransactionManager } from '@app/transaction/transactionManager' import { shouldRedirect } from '@app/utils/shouldRedirect' import { CompleteImport } from './steps/CompleteImport' @@ -52,7 +52,7 @@ export const DnsClaim = () => { const key = `importDnsName-${selected.name}` - const { cleanupFlow } = useTransactionFlow() + const cleanupFlow = useTransactionManager((s) => s.cleanupFlow) useEffect(() => { const handleRouteChange = (e: string) => { diff --git a/src/components/pages/import/[name]/steps/SelectImportType.tsx b/src/components/pages/import/[name]/steps/SelectImportType.tsx index 97ddbee25..df0f20971 100644 --- a/src/components/pages/import/[name]/steps/SelectImportType.tsx +++ b/src/components/pages/import/[name]/steps/SelectImportType.tsx @@ -12,7 +12,7 @@ import { useDnsOffchainStatus } from '@app/hooks/dns/useDnsOffchainStatus' import { useDnsSecEnabled } from '@app/hooks/dns/useDnsSecEnabled' import { useDnsOwner } from '@app/hooks/ensjs/dns/useDnsOwner' import { useResolver } from '@app/hooks/ensjs/public/useResolver' -import { CenteredTypography } from '@app/transaction-flow/input/ProfileEditor/components/CenteredTypography' +import { CenteredTypography } from '@app/transaction/user/ProfileEditor/components/CenteredTypography' import { getSupportLink } from '@app/utils/supportLinks' import { DnsImportActionButton, DnsImportCard, DnsImportHeading } from '../shared' diff --git a/src/components/pages/import/[name]/utils.ts b/src/components/pages/import/[name]/utils.ts index a36320a2d..e2a3e0ca6 100644 --- a/src/components/pages/import/[name]/utils.ts +++ b/src/components/pages/import/[name]/utils.ts @@ -12,7 +12,7 @@ import type { GetDnsImportDataReturnType } from '@ensdomains/ensjs/dns' import { addStateOverride } from '@app/hooks/chain/useEstimateGasWithStateOverride' import type { UseDnsOwnerError } from '@app/hooks/ensjs/dns/useDnsOwner' -import { createTransactionItem } from '@app/transaction-flow/transaction' +import { createUserTransaction } from '@app/transaction/user/transaction' export type DnsNavigationFunction = (direction: 'prev' | 'next') => void @@ -68,17 +68,17 @@ export const createImportTransactionRequests = ({ dnsRegistrarAddress: Address }) => { const createApproveTx = () => - createTransactionItem('approveDnsRegistrar', { + createUserTransaction('approveDnsRegistrar', { address, }) const createClaimTx = () => - createTransactionItem('claimDnsName', { + createUserTransaction('claimDnsName', { name, dnsImportData, address, }) const createImportTx = () => - createTransactionItem('importDnsName', { + createUserTransaction('importDnsName', { name, dnsImportData, }) diff --git a/src/components/pages/profile/ProfileButton.tsx b/src/components/pages/profile/ProfileButton.tsx index 5cac0e0b0..81f0d6790 100644 --- a/src/components/pages/profile/ProfileButton.tsx +++ b/src/components/pages/profile/ProfileButton.tsx @@ -26,11 +26,11 @@ import { VerificationBadge } from '@app/components/@molecules/VerificationBadge/ import { useCoinChain } from '@app/hooks/chain/useCoinChain' import { usePrimaryName } from '@app/hooks/ensjs/public/usePrimaryName' import { getDestination } from '@app/routes' -import { VerificationProtocol } from '@app/transaction-flow/input/VerifyProfile/VerifyProfile-flow' +import { VerificationProtocol } from '@app/transaction/user/VerifyProfile/VerifyProfile-flow' import { useBreakpoint } from '@app/utils/BreakpointProvider' import { getContentHashLink } from '@app/utils/contenthash' import { getSocialData } from '@app/utils/getSocialData' -import { makeEtherscanLink, shortenAddress } from '@app/utils/utils' +import { createEtherscanLink, shortenAddress } from '@app/utils/utils' import { getVerifierData } from '@app/utils/verification/getVerifierData' import { isVerificationProtocol } from '@app/utils/verification/isVerificationProtocol' @@ -247,6 +247,7 @@ export const OwnerProfileButton = ({ }) => { const { t } = useTranslation('common') const breakpoints = useBreakpoint() + const chainId = useChainId() const dataType = useMemo(() => { if (!addressOrNameOrDate) @@ -351,7 +352,7 @@ export const OwnerProfileButton = ({ { icon: , label: 'View on Etherscan', - href: makeEtherscanLink(addressOrNameOrDate, 'mainnet', 'address'), + href: createEtherscanLink({ data: addressOrNameOrDate, chainId, route: 'address' }), }, ] as DropdownItem[]) : []), diff --git a/src/components/pages/profile/[name]/Profile.tsx b/src/components/pages/profile/[name]/Profile.tsx index 68847c87f..198e74db1 100644 --- a/src/components/pages/profile/[name]/Profile.tsx +++ b/src/components/pages/profile/[name]/Profile.tsx @@ -3,14 +3,13 @@ import { useEffect, useMemo } from 'react' import { Trans, useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' import { match } from 'ts-pattern' -import { useAccount } from 'wagmi' +import { useAccount, useChainId } from 'wagmi' import { Banner, CheckCircleSVG, Typography } from '@ensdomains/thorin' import BaseLink from '@app/components/@atoms/BaseLink' import { Outlink } from '@app/components/Outlink' import { useAbilities } from '@app/hooks/abilities/useAbilities' -import { useChainName } from '@app/hooks/chain/useChainName' import { useNameDetails } from '@app/hooks/useNameDetails' import { useProtectedRoute } from '@app/hooks/useProtectedRoute' import { useQueryParameterState } from '@app/hooks/useQueryParameterState' @@ -18,7 +17,7 @@ import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' import { Content, ContentWarning } from '@app/layouts/Content' import { OG_IMAGE_URL } from '@app/utils/constants' import { shouldRedirect } from '@app/utils/shouldRedirect' -import { formatFullExpiry, makeEtherscanLink } from '@app/utils/utils' +import { createEtherscanLink, formatFullExpiry } from '@app/utils/utils' import { ProfileEmptyBanner } from './ProfileEmptyBanner' import MoreTab from './tabs/MoreTab/MoreTab' @@ -224,7 +223,7 @@ const ProfileContent = ({ isSelf, isLoading: parentIsLoading, name }: Props) => const ogImageUrl = `${OG_IMAGE_URL}/name/${normalisedName || name}` - const chainName = useChainName() + const chainId = useChainId() return ( <> @@ -269,7 +268,7 @@ const ProfileContent = ({ isSelf, isLoading: parentIsLoading, name }: Props) => titleExtra: profile?.address ? ( {t('etherscan', { ns: 'common' })} diff --git a/src/components/pages/profile/[name]/ProfileEmptyBanner.tsx b/src/components/pages/profile/[name]/ProfileEmptyBanner.tsx index afe7ed287..0b69f4a67 100644 --- a/src/components/pages/profile/[name]/ProfileEmptyBanner.tsx +++ b/src/components/pages/profile/[name]/ProfileEmptyBanner.tsx @@ -7,7 +7,7 @@ import StarsSVG from '@app/assets/Stars.svg' import { useProfileActions } from '@app/hooks/pages/profile/[name]/profile/useProfileActions/useProfileActions' import { useProfile } from '@app/hooks/useProfile' -import { profileToProfileRecords } from './registration/steps/Profile/profileRecordUtils' +import { profileToProfileRecords } from '../../register/steps/Profile/profileRecordUtils' const Container = styled.div( ({ theme }) => css` diff --git a/src/components/pages/profile/[name]/registration/steps/Pricing/Pricing.tsx b/src/components/pages/profile/[name]/registration/steps/Pricing/Pricing.tsx deleted file mode 100644 index dc0413a6c..000000000 --- a/src/components/pages/profile/[name]/registration/steps/Pricing/Pricing.tsx +++ /dev/null @@ -1,613 +0,0 @@ -import { Dispatch, SetStateAction, useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { usePreviousDistinct } from 'react-use' -import usePrevious from 'react-use/lib/usePrevious' -import styled, { css } from 'styled-components' -import { match, P } from 'ts-pattern' -import type { Address } from 'viem' -import { useBalance } from 'wagmi' -import { GetBalanceData } from 'wagmi/query' - -import { - Button, - Field, - Heading, - Helper, - mq, - RadioButton, - RadioButtonGroup, - Toggle, - Typography, -} from '@ensdomains/thorin' - -import MoonpayLogo from '@app/assets/MoonpayLogo.svg' -import MobileFullWidth from '@app/components/@atoms/MobileFullWidth' -import { RegistrationTimeComparisonBanner } from '@app/components/@atoms/RegistrationTimeComparisonBanner/RegistrationTimeComparisonBanner' -import { Spacer } from '@app/components/@atoms/Spacer' -import { DateSelection } from '@app/components/@molecules/DateSelection/DateSelection' -import { Card } from '@app/components/Card' -import { ConnectButton } from '@app/components/ConnectButton' -import { useAccountSafely } from '@app/hooks/account/useAccountSafely' -import { useContractAddress } from '@app/hooks/chain/useContractAddress' -import { useEstimateFullRegistration } from '@app/hooks/gasEstimation/useEstimateRegistration' -import { useBreakpoint } from '@app/utils/BreakpointProvider' -import { ONE_DAY, ONE_YEAR } from '@app/utils/time' - -import FullInvoice from '../../FullInvoice' -import { - MoonpayTransactionStatus, - PaymentMethod, - RegistrationReducerDataItem, - RegistrationStepData, -} from '../../types' -import { useMoonpayRegistration } from '../../useMoonpayRegistration' -import TemporaryPremium from './TemporaryPremium' - -const StyledCard = styled(Card)( - ({ theme }) => css` - max-width: 780px; - margin: 0 auto; - flex-direction: column; - gap: ${theme.space['4']}; - padding: ${theme.space['4']}; - - ${mq.sm.min(css` - padding: ${theme.space['6']} ${theme.space['18']}; - gap: ${theme.space['6']}; - `)} - `, -) - -const OutlinedContainer = styled.div( - ({ theme }) => css` - width: ${theme.space.full}; - display: grid; - align-items: center; - grid-template-areas: 'title checkbox' 'description description'; - gap: ${theme.space['2']}; - - padding: ${theme.space['4']}; - border-radius: ${theme.radii.large}; - background: ${theme.colors.backgroundSecondary}; - - ${mq.sm.min(css` - grid-template-areas: 'title checkbox' 'description checkbox'; - `)} - `, -) - -const StyledHeading = styled(Heading)( - () => css` - width: 100%; - word-break: break-all; - - @supports (overflow-wrap: anywhere) { - overflow-wrap: anywhere; - word-break: normal; - } - `, -) - -const gridAreaStyle = ({ $name }: { $name: string }) => css` - grid-area: ${$name}; -` - -const moonpayInfoItems = Array.from({ length: 2 }, (_, i) => `steps.info.moonpayItems.${i}`) - -const PaymentChoiceContainer = styled.div` - width: 100%; -` - -const StyledRadioButtonGroup = styled(RadioButtonGroup)( - ({ theme }) => css` - border: 1px solid ${theme.colors.border}; - border-radius: ${theme.radii.large}; - gap: 0; - `, -) - -const StyledRadioButton = styled(RadioButton)`` - -const RadioButtonContainer = styled.div( - ({ theme }) => css` - padding: ${theme.space['4']}; - &:last-child { - border-top: 1px solid ${theme.colors.border}; - } - `, -) - -const StyledTitle = styled(Typography)` - margin-left: 15px; -` - -const RadioLabel = styled(Typography)( - ({ theme }) => css` - margin-right: 10px; - color: ${theme.colors.text}; - `, -) - -const MoonpayContainer = styled.div` - display: flex; - align-items: center; - justify-content: center; - gap: 5px; -` - -const InfoItems = styled.div( - ({ theme }) => css` - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-start; - gap: ${theme.space['4']}; - - ${mq.sm.min(css` - flex-direction: row; - align-items: stretch; - `)} - `, -) - -const InfoItem = styled.div( - ({ theme }) => css` - width: 100%; - - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: ${theme.space['4']}; - - padding: ${theme.space['4']}; - border: 1px solid ${theme.colors.border}; - border-radius: ${theme.radii.large}; - text-align: center; - - & > div:first-of-type { - width: ${theme.space['10']}; - height: ${theme.space['10']}; - display: flex; - align-items: center; - justify-content: center; - font-size: ${theme.fontSizes.extraLarge}; - font-weight: ${theme.fontWeights.bold}; - color: ${theme.colors.backgroundPrimary}; - background: ${theme.colors.accentPrimary}; - border-radius: ${theme.radii.full}; - } - - & > div:last-of-type { - flex-grow: 1; - display: flex; - align-items: center; - justify-content: center; - } - `, -) - -const LabelContainer = styled.div` - display: flex; - flex-wrap: wrap; -` - -const CheckboxWrapper = styled.div( - () => css` - width: 100%; - `, - gridAreaStyle, -) - -const OutlinedContainerDescription = styled(Typography)(gridAreaStyle) - -const OutlinedContainerTitle = styled(Typography)( - ({ theme }) => css` - font-size: ${theme.fontSizes.large}; - font-weight: ${theme.fontWeights.bold}; - white-space: nowrap; - `, - gridAreaStyle, -) - -const EthInnerCheckbox = ({ - address, - hasPrimaryName, - reverseRecord, - setReverseRecord, - started, -}: { - address: string - hasPrimaryName: boolean - reverseRecord: boolean - setReverseRecord: (val: boolean) => void - started: boolean -}) => { - const { t } = useTranslation('register') - const breakpoints = useBreakpoint() - - useEffect(() => { - if (!started) { - setReverseRecord(!hasPrimaryName) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [setReverseRecord]) - - return ( - - - {(ids) => ( - { - e.stopPropagation() - setReverseRecord(e.target.checked) - }} - data-testid="primary-name-toggle" - /> - )} - - - ) -} - -const PaymentChoice = ({ - paymentMethodChoice, - setPaymentMethodChoice, - hasEnoughEth, - hasPendingMoonpayTransaction, - hasFailedMoonpayTransaction, - address, - hasPrimaryName, - reverseRecord, - setReverseRecord, - started, -}: { - paymentMethodChoice: PaymentMethod - setPaymentMethodChoice: Dispatch> - hasEnoughEth: boolean - hasPendingMoonpayTransaction: boolean - hasFailedMoonpayTransaction: boolean - address: string - hasPrimaryName: boolean - reverseRecord: boolean - setReverseRecord: (reverseRecord: boolean) => void - started: boolean -}) => { - const { t } = useTranslation('register') - - return ( - - - {t('steps.info.paymentMethod')} - - - setPaymentMethodChoice(e.target.value as PaymentMethod)} - > - - {t('steps.info.ethereum')}} - name="RadioButtonGroup" - value={PaymentMethod.ethereum} - disabled={hasPendingMoonpayTransaction} - checked={paymentMethodChoice === PaymentMethod.ethereum || undefined} - /> - {paymentMethodChoice === PaymentMethod.ethereum && !hasEnoughEth && ( - <> - - - {t('steps.info.notEnoughEth')} - - - - )} - {paymentMethodChoice === PaymentMethod.ethereum && hasEnoughEth && ( - <> - - - - {t('steps.pricing.primaryName')} - - - - {t('steps.pricing.primaryNameMessage')} - - - - - )} - - - - {t('steps.info.creditOrDebit')} - - ({t('steps.info.additionalFee')}) - - - } - name="RadioButtonGroup" - value={PaymentMethod.moonpay} - checked={paymentMethodChoice === PaymentMethod.moonpay || undefined} - /> - {paymentMethodChoice === PaymentMethod.moonpay && ( - <> - - - {moonpayInfoItems.map((item, idx) => ( - - {idx + 1} - {t(item)} - - ))} - - - {hasFailedMoonpayTransaction && ( - {t('steps.info.failedMoonpayTransaction')} - )} - - - {t('steps.info.poweredBy')} - - - - )} - - - - ) -} - -export type ActionButtonProps = { - address?: Address - hasPendingMoonpayTransaction: boolean - hasFailedMoonpayTransaction: boolean - paymentMethodChoice: PaymentMethod | '' - reverseRecord: boolean - callback: (props: RegistrationStepData['pricing']) => void - initiateMoonpayRegistrationMutation: ReturnType< - typeof useMoonpayRegistration - >['initiateMoonpayRegistrationMutation'] - seconds: number - balance: GetBalanceData | undefined - totalRequiredBalance?: bigint - durationType: 'date' | 'years' -} - -export const ActionButton = (props: ActionButtonProps) => { - const { t } = useTranslation('register') - - return match(props) - .with({ address: P.nullish }, () => ) - .with({ hasPendingMoonpayTransaction: true }, () => ( - - )) - .with({ hasFailedMoonpayTransaction: true, paymentMethodChoice: PaymentMethod.moonpay }, () => ( - - )) - .with( - { paymentMethodChoice: PaymentMethod.moonpay }, - ({ - initiateMoonpayRegistrationMutation, - reverseRecord, - seconds, - paymentMethodChoice, - durationType, - callback, - }) => ( - - ), - ) - .with( - P.when((_props) => typeof _props.balance?.value !== 'bigint' || !_props.totalRequiredBalance), - () => ( - - ), - ) - .with( - P.when( - (_props) => - _props.totalRequiredBalance && - typeof _props.balance?.value === 'bigint' && - _props.balance.value < _props.totalRequiredBalance && - _props.paymentMethodChoice === PaymentMethod.ethereum, - ), - () => ( - - ), - ) - .otherwise(({ reverseRecord, seconds, paymentMethodChoice, durationType, callback }) => ( - - )) -} - -export type PricingProps = { - name: string - gracePeriodEndDate: Date | undefined - beautifiedName: string - - resolverExists: boolean | undefined - callback: (props: RegistrationStepData['pricing']) => void - isPrimaryLoading: boolean - hasPrimaryName: boolean - registrationData: RegistrationReducerDataItem - moonpayTransactionStatus?: MoonpayTransactionStatus - initiateMoonpayRegistrationMutation: ReturnType< - typeof useMoonpayRegistration - >['initiateMoonpayRegistrationMutation'] -} - -const minSeconds = 28 * ONE_DAY - -const Pricing = ({ - name, - gracePeriodEndDate, - beautifiedName, - callback, - isPrimaryLoading, - hasPrimaryName, - registrationData, - resolverExists, - moonpayTransactionStatus, - initiateMoonpayRegistrationMutation, -}: PricingProps) => { - const { t } = useTranslation('register') - - const { address } = useAccountSafely() - const { data: balance } = useBalance({ address }) - const resolverAddress = useContractAddress({ contract: 'ensPublicResolver' }) - - const [seconds, setSeconds] = useState(() => registrationData.seconds ?? ONE_YEAR) - const [durationType, setDurationType] = useState<'date' | 'years'>( - registrationData.durationType ?? 'years', - ) - - const [reverseRecord, setReverseRecord] = useState(() => - registrationData.started ? registrationData.reverseRecord : !hasPrimaryName, - ) - - const hasPendingMoonpayTransaction = moonpayTransactionStatus === 'pending' - const hasFailedMoonpayTransaction = moonpayTransactionStatus === 'failed' - - const previousMoonpayTransactionStatus = usePrevious(moonpayTransactionStatus) - - const [paymentMethodChoice, setPaymentMethodChoice] = useState( - hasPendingMoonpayTransaction ? PaymentMethod.moonpay : PaymentMethod.ethereum, - ) - - // Keep radio button choice up to date - useEffect(() => { - if (moonpayTransactionStatus) { - setPaymentMethodChoice( - hasPendingMoonpayTransaction || hasFailedMoonpayTransaction - ? PaymentMethod.moonpay - : PaymentMethod.ethereum, - ) - } - }, [ - hasFailedMoonpayTransaction, - hasPendingMoonpayTransaction, - moonpayTransactionStatus, - previousMoonpayTransactionStatus, - setPaymentMethodChoice, - ]) - - const fullEstimate = useEstimateFullRegistration({ - name, - registrationData: { - ...registrationData, - reverseRecord, - seconds, - records: [{ key: 'ETH', value: resolverAddress, type: 'addr', group: 'address' }], - clearRecords: resolverExists, - resolverAddress, - }, - }) - - const { hasPremium, premiumFee, gasPrice, yearlyFee, totalDurationBasedFee, estimatedGasFee } = - fullEstimate - const durationRequiredBalance = totalDurationBasedFee ? (totalDurationBasedFee * 110n) / 100n : 0n - const totalRequiredBalance = durationRequiredBalance - ? durationRequiredBalance + (premiumFee || 0n) + (estimatedGasFee || 0n) - : 0n - - const previousYearlyFee = usePreviousDistinct(yearlyFee) || 0n - - const unsafeDisplayYearlyFee = yearlyFee === 0n ? previousYearlyFee : yearlyFee - - const showPaymentChoice = !isPrimaryLoading && address - - const previousEstimatedGasFee = usePreviousDistinct(estimatedGasFee) || 0n - - const unsafeDisplayEstimatedGasFee = - estimatedGasFee === 0n ? previousEstimatedGasFee : estimatedGasFee - - return ( - - {t('heading', { name: beautifiedName })} - - - {hasPremium && gracePeriodEndDate ? ( - - ) : ( - !!unsafeDisplayYearlyFee && - !!unsafeDisplayEstimatedGasFee && - !!gasPrice && ( - - ) - )} - {showPaymentChoice && ( - - )} - - - - - ) -} - -export default Pricing diff --git a/src/components/pages/profile/[name]/registration/useMoonpayRegistration.ts b/src/components/pages/profile/[name]/registration/useMoonpayRegistration.ts deleted file mode 100644 index 769876eae..000000000 --- a/src/components/pages/profile/[name]/registration/useMoonpayRegistration.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { useMutation } from '@tanstack/react-query' -import { useState } from 'react' -import { labelhash } from 'viem' -import { useChainId } from 'wagmi' - -import { useAccountSafely } from '@app/hooks/account/useAccountSafely' -import { useQueryOptions } from '@app/hooks/useQueryOptions' -import useRegistrationReducer from '@app/hooks/useRegistrationReducer' -import { MOONPAY_WORKER_URL } from '@app/utils/constants' -import { useQuery } from '@app/utils/query/useQuery' -import { getLabelFromName } from '@app/utils/utils' - -import { MoonpayTransactionStatus, SelectedItemProperties } from './types' - -export const useMoonpayRegistration = ( - dispatch: ReturnType['dispatch'], - normalisedName: string, - selected: SelectedItemProperties, - item: ReturnType['item'], -) => { - const chainId = useChainId() - const { address } = useAccountSafely() - const [hasMoonpayModal, setHasMoonpayModal] = useState(false) - const [moonpayUrl, setMoonpayUrl] = useState('') - const [isCompleted, setIsCompleted] = useState(false) - const currentExternalTransactionId = item.externalTransactionId - - const initiateMoonpayRegistrationMutation = useMutation({ - mutationFn: async (duration: number = 1) => { - const label = getLabelFromName(normalisedName) - const tokenId = labelhash(label) - - const requestUrl = `${ - MOONPAY_WORKER_URL[chainId] - }/signedurl?tokenId=${tokenId}&name=${encodeURIComponent( - normalisedName, - )}&duration=${duration}&walletAddress=${address}` - const response = await fetch(requestUrl) - const textResponse = await response.text() - setMoonpayUrl(textResponse) - - const params = new URLSearchParams(textResponse) - const externalTransactionId = params.get('externalTransactionId') || '' - - dispatch({ - name: 'setExternalTransactionId', - externalTransactionId, - selected, - }) - setHasMoonpayModal(true) - }, - }) - - const { queryKey } = useQueryOptions({ - params: { externalTransactionId: currentExternalTransactionId }, - functionName: 'getMoonpayStatus', - queryDependencyType: 'standard', - keyOnly: true, - }) - - // Monitor current transaction - const { data: transactionData } = useQuery({ - queryKey, - // TODO: refactor this func and pull query fn out of the hook - queryFn: async ({ queryKey: [{ externalTransactionId }] }) => { - const response = await fetch( - `${MOONPAY_WORKER_URL[chainId]}/transactionInfo?externalTransactionId=${externalTransactionId}`, - ) - const jsonResult = (await response.json()) as Array<{ status: MoonpayTransactionStatus }> - const result = jsonResult?.[0] - - if (result?.status === 'completed' && !isCompleted) { - setIsCompleted(true) - setHasMoonpayModal(false) - dispatch({ - name: 'moonpayTransactionCompleted', - selected, - }) - } - - return result || {} - }, - refetchOnWindowFocus: true, - refetchOnMount: true, - refetchInterval: 1000, - refetchIntervalInBackground: true, - enabled: !!currentExternalTransactionId && !isCompleted, - }) - - return { - moonpayUrl, - initiateMoonpayRegistrationMutation, - hasMoonpayModal, - setHasMoonpayModal, - currentExternalTransactionId, - moonpayTransactionStatus: transactionData?.status as MoonpayTransactionStatus, - } -} diff --git a/src/components/pages/profile/[name]/tabs/MoreTab/Miscellaneous/RegistrationDate.tsx b/src/components/pages/profile/[name]/tabs/MoreTab/Miscellaneous/RegistrationDate.tsx index 3579be60f..a43f58df3 100644 --- a/src/components/pages/profile/[name]/tabs/MoreTab/Miscellaneous/RegistrationDate.tsx +++ b/src/components/pages/profile/[name]/tabs/MoreTab/Miscellaneous/RegistrationDate.tsx @@ -1,10 +1,10 @@ import { useTranslation } from 'react-i18next' +import { useChainId } from 'wagmi' import { OutlinkSVG, Typography } from '@ensdomains/thorin' -import { useChainName } from '@app/hooks/chain/useChainName' import type useRegistrationDate from '@app/hooks/useRegistrationData' -import { formatDateTime, formatExpiry, makeEtherscanLink } from '@app/utils/utils' +import { createEtherscanLink, formatDateTime, formatExpiry } from '@app/utils/utils' import { DateLayout } from './components/DateLayout' @@ -14,7 +14,7 @@ export const RegistrationDate = ({ registrationData: ReturnType['data'] }) => { const { t } = useTranslation('common') - const chainName = useChainName() + const chainId = useChainId() if (!registrationData) return null return ( @@ -23,7 +23,7 @@ export const RegistrationDate = ({ {formatDateTime(registrationData.registrationDate)} diff --git a/src/components/pages/profile/[name]/tabs/MoreTab/Ownership.tsx b/src/components/pages/profile/[name]/tabs/MoreTab/Ownership.tsx index ff1f8c2c0..cbfc7f905 100644 --- a/src/components/pages/profile/[name]/tabs/MoreTab/Ownership.tsx +++ b/src/components/pages/profile/[name]/tabs/MoreTab/Ownership.tsx @@ -16,9 +16,8 @@ import { useDnsImportData } from '@app/hooks/ensjs/dns/useDnsImportData' import { GetDnsOwnerQueryKey, UseDnsOwnerError } from '@app/hooks/ensjs/dns/useDnsOwner' import { usePrimaryName } from '@app/hooks/ensjs/public/usePrimaryName' import { useOwners } from '@app/hooks/useOwners' -import { makeIntroItem } from '@app/transaction-flow/intro' -import { createTransactionItem } from '@app/transaction-flow/transaction' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { useTransactionManager } from '@app/transaction/transactionManager' +import { usePreparedDataInput } from '@app/transaction/usePreparedDataInput' import { OwnerItem } from '@app/types' import { shortenAddress } from '@app/utils/utils' @@ -206,8 +205,8 @@ const DNSOwnerSection = ({ }) => { const { address } = useAccount() const { t } = useTranslation('profile') - const { createTransactionFlow } = useTransactionFlow() const queryClient = useQueryClient() + const startFlow = useTransactionManager((s) => s.startFlow) const canShow = useMemo(() => { let hasMatchingAddress = false @@ -234,17 +233,27 @@ const DNSOwnerSection = ({ const handleSyncManager = () => { const currentManager = owners.find((owner) => owner.label === 'name.manager') - createTransactionFlow(`sync-manager-${name}-${address}`, { + startFlow({ + flowId: `sync-manager-${name}-${address}`, intro: { title: ['tabs.more.ownership.dnsOwnerWarning.syncManager', { ns: 'profile' }], - content: makeIntroItem('SyncManager', { isWrapped, manager: currentManager!.address }), + content: { + name: 'SyncManager', + data: { + isWrapped, + manager: currentManager!.address, + }, + }, }, transactions: [ - createTransactionItem('syncManager', { - address: address!, - name, - dnsImportData: dnsImportData!, - }), + { + name: 'syncManager', + data: { + address: address!, + name, + dnsImportData: dnsImportData!, + }, + }, ], }) } @@ -301,7 +310,6 @@ const Ownership = ({ }) => { const { t } = useTranslation('profile') - const { usePreparedDataInput } = useTransactionFlow() const showSendNameInput = usePreparedDataInput('SendName') const handleSend = () => { showSendNameInput(`send-name-${name}`, { diff --git a/src/components/pages/profile/[name]/tabs/MoreTab/Resolver.tsx b/src/components/pages/profile/[name]/tabs/MoreTab/Resolver.tsx index 881950839..733afab9d 100644 --- a/src/components/pages/profile/[name]/tabs/MoreTab/Resolver.tsx +++ b/src/components/pages/profile/[name]/tabs/MoreTab/Resolver.tsx @@ -8,7 +8,7 @@ import { cacheableComponentStyles } from '@app/components/@atoms/CacheableCompon import { DisabledButtonWithTooltip } from '@app/components/@molecules/DisabledButtonWithTooltip' import RecordItem from '@app/components/RecordItem' import { useResolver } from '@app/hooks/ensjs/public/useResolver' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { usePreparedDataInput } from '@app/transaction/usePreparedDataInput' import { useBreakpoint } from '@app/utils/BreakpointProvider' import { emptyAddress } from '@app/utils/constants' import { useHasGraphError } from '@app/utils/SyncProvider/SyncProvider' @@ -97,7 +97,6 @@ const Resolver = ({ const { data: hasGraphError, isLoading: hasGraphErrorLoading } = useHasGraphError() - const { usePreparedDataInput } = useTransactionFlow() const showEditResolverInput = usePreparedDataInput('EditResolver') const handleEditClick = () => { showEditResolverInput(`resolver-upgrade-${name}`, { diff --git a/src/components/pages/profile/[name]/tabs/MoreTab/Token/Token.tsx b/src/components/pages/profile/[name]/tabs/MoreTab/Token/Token.tsx index 95d72bf51..a555aea80 100644 --- a/src/components/pages/profile/[name]/tabs/MoreTab/Token/Token.tsx +++ b/src/components/pages/profile/[name]/tabs/MoreTab/Token/Token.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' import { labelhash, namehash } from 'viem' +import { useChainId } from 'wagmi' import { mq, Tag, Typography } from '@ensdomains/thorin' @@ -8,9 +9,8 @@ import { CacheableComponent } from '@app/components/@atoms/CacheableComponent' import { NFTWithPlaceholder } from '@app/components/NFTWithPlaceholder' import { Outlink } from '@app/components/Outlink' import RecordItem from '@app/components/RecordItem' -import { useChainName } from '@app/hooks/chain/useChainName' import { useContractAddress } from '@app/hooks/chain/useContractAddress' -import { checkETH2LDFromName, makeEtherscanLink } from '@app/utils/utils' +import { checkETH2LDFromName, createEtherscanLink } from '@app/utils/utils' import { TabWrapper } from '../../../../TabWrapper' @@ -107,7 +107,7 @@ const NftBox = styled(NFTWithPlaceholder)( const Token = ({ name, isWrapped }: Props) => { const { t } = useTranslation('profile') - const networkName = useChainName() + const chainId = useChainId() const nameWrapperAddress = useContractAddress({ contract: 'ensNameWrapper' }) const registrarAddress = useContractAddress({ contract: 'ensBaseRegistrarImplementation' }) @@ -127,7 +127,11 @@ const Token = ({ name, isWrapped }: Props) => { {hasToken ? ( {t('etherscan', { ns: 'common' })} diff --git a/src/components/pages/profile/[name]/tabs/MoreTab/Token/UnwrapButton.tsx b/src/components/pages/profile/[name]/tabs/MoreTab/Token/UnwrapButton.tsx index fedbb1c7e..4b86dde76 100644 --- a/src/components/pages/profile/[name]/tabs/MoreTab/Token/UnwrapButton.tsx +++ b/src/components/pages/profile/[name]/tabs/MoreTab/Token/UnwrapButton.tsx @@ -4,8 +4,7 @@ import { GetOwnerReturnType } from '@ensdomains/ensjs/public' import { useAccountSafely } from '@app/hooks/account/useAccountSafely' import { NameWrapperState } from '@app/hooks/fuses/useFusesStates' -import { createTransactionItem } from '@app/transaction-flow/transaction' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { useTransactionManager } from '@app/transaction/transactionManager' import BaseWrapButton from './BaseWrapButton' @@ -20,10 +19,11 @@ const UnwrapButton = ({ name, ownerData, status, disabled }: Props) => { const { t } = useTranslation('profile') const { address } = useAccountSafely() - const { createTransactionFlow } = useTransactionFlow() + const startFlow = useTransactionManager((s) => s.startFlow) const handleUnwrapClick = () => { - createTransactionFlow(`unwrapName-${name}`, { - transactions: [createTransactionItem('unwrapName', { name })], + startFlow({ + flowId: `unwrapName-${name}`, + transactions: [{ name: 'unwrapName', data: { name } }], }) } diff --git a/src/components/pages/profile/[name]/tabs/MoreTab/Token/WrapButton.tsx b/src/components/pages/profile/[name]/tabs/MoreTab/Token/WrapButton.tsx index f442f69b7..d7d404149 100644 --- a/src/components/pages/profile/[name]/tabs/MoreTab/Token/WrapButton.tsx +++ b/src/components/pages/profile/[name]/tabs/MoreTab/Token/WrapButton.tsx @@ -6,10 +6,10 @@ import { checkIsDecrypted } from '@ensdomains/ensjs/utils' import { useAccountSafely } from '@app/hooks/account/useAccountSafely' import { useResolverStatus } from '@app/hooks/resolver/useResolverStatus' import { useWrapperApprovedForAll } from '@app/hooks/useWrapperApprovedForAll' -import { makeIntroItem } from '@app/transaction-flow/intro' -import { createTransactionItem } from '@app/transaction-flow/transaction' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' -import { GenericTransaction, TransactionFlowItem } from '@app/transaction-flow/types' +import type { FlowInitialiserData } from '@app/transaction/slices/createFlowSlice' +import { useTransactionManager } from '@app/transaction/transactionManager' +import { usePreparedDataInput } from '@app/transaction/usePreparedDataInput' +import { createUserTransaction } from '@app/transaction/user/transaction' import { Profile } from '@app/types' import { useHasGraphError } from '@app/utils/SyncProvider/SyncProvider' @@ -44,59 +44,66 @@ const WrapButton = ({ name, ownerData, profile, canBeWrapped, isManager, isRegis canBeWrapped, }) - const { createTransactionFlow, resumeTransactionFlow, getResumable, usePreparedDataInput } = - useTransactionFlow() + const flowId = `wrapName-${name}` + + const getResumable = useTransactionManager((s) => s.isFlowResumable) + const resumeFlow = useTransactionManager((s) => s.resumeFlow) + const startFlow = useTransactionManager((s) => s.startFlow) + const showUnknownLabelsInput = usePreparedDataInput('UnknownLabels') - const resumable = getResumable(`wrapName-${name}`) + const resumable = getResumable(flowId) const handleWrapClick = () => { if (!hasOwnerData) return - if (resumable) return resumeTransactionFlow(`wrapName-${name}`) + if (resumable) return resumeFlow(flowId) const isManagerAndShouldMigrate = isManager && shouldMigrate const isRegistrantAndShouldMigrate = !isManager && isRegistrant && shouldMigrate const needsApproval = isManager && isSubname && !approvedForAll - const transactions: GenericTransaction[] = [ + const transactions = [ ...(needsApproval ? [ - createTransactionItem('approveNameWrapper', { + createUserTransaction('approveNameWrapper', { address: address!, }), ] : []), ...(isManagerAndShouldMigrate ? [ - createTransactionItem('migrateProfile', { + createUserTransaction('migrateProfile', { name, }), ] : []), - createTransactionItem('wrapName', { + createUserTransaction('wrapName', { name, }), ...(isRegistrantAndShouldMigrate - ? [createTransactionItem('migrateProfile', { name, resolverAddress })] + ? [createUserTransaction('migrateProfile', { name, resolverAddress })] : []), ] - const transactionFlowItem: TransactionFlowItem = { + const flow = { + flowId, transactions, resumable: true, intro: { title: ['details.wrap.startTitle', { ns: 'profile' }], - content: makeIntroItem('WrapName', { name }), + content: { + name: 'WrapName', + data: { name }, + }, }, - } + } satisfies FlowInitialiserData const key = `wrapName-${name}` if (!checkIsDecrypted(name)) return showUnknownLabelsInput(key, { name, - key, - transactionFlowItem, + flow, }) - return createTransactionFlow(key, transactionFlowItem) + return startFlow(flow) } const isLoading = isApprovalLoading || resolverStatus.isLoading || hasGraphErrorLoading diff --git a/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/ContractSection/ContractSection.tsx b/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/ContractSection/ContractSection.tsx index 0e29aeca4..c5d99720b 100644 --- a/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/ContractSection/ContractSection.tsx +++ b/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/ContractSection/ContractSection.tsx @@ -1,12 +1,12 @@ import { useTranslation } from 'react-i18next' +import { useChainId } from 'wagmi' import { Card, Helper, RecordItem } from '@ensdomains/thorin' -import { useChainName } from '@app/hooks/chain/useChainName' import { useContractAddress } from '@app/hooks/chain/useContractAddress' import type { useNameDetails } from '@app/hooks/useNameDetails' import { useBreakpoint } from '@app/utils/BreakpointProvider' -import { makeEtherscanLink } from '@app/utils/utils' +import { createEtherscanLink } from '@app/utils/utils' import { Header } from './components/Header' @@ -17,7 +17,7 @@ type Props = { export const ContractSection = ({ details }: Props) => { const { t } = useTranslation('profile') const address = useContractAddress({ contract: 'ensNameWrapper' }) - const chainName = useChainName() + const chainId = useChainId() const breakpoint = useBreakpoint() const { isLoading } = details @@ -26,7 +26,11 @@ export const ContractSection = ({ details }: Props) => { return (
- + {address} diff --git a/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/ExpirySection/hooks/useExpiryActions.tsx b/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/ExpirySection/hooks/useExpiryActions.tsx index e18995637..a736fb40b 100644 --- a/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/ExpirySection/hooks/useExpiryActions.tsx +++ b/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/ExpirySection/hooks/useExpiryActions.tsx @@ -5,7 +5,7 @@ import { useAccount } from 'wagmi' import { GetOwnerReturnType, GetWrapperDataReturnType } from '@ensdomains/ensjs/public' import { CalendarSVG, FastForwardSVG } from '@ensdomains/thorin' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { usePreparedDataInput } from '@app/transaction/usePreparedDataInput' import { nameLevel } from '@app/utils/name' import type { useExpiryDetails } from './useExpiryDetails' @@ -35,7 +35,6 @@ export const useExpiryActions = ({ }) => { const { t } = useTranslation('common') const { address } = useAccount() - const { usePreparedDataInput } = useTransactionFlow() const showExtendNamesInput = usePreparedDataInput('ExtendNames') // TODO: remove this when we add support for extending wrapped subnames diff --git a/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/ExpirySection/hooks/useExpiryDetails.ts b/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/ExpirySection/hooks/useExpiryDetails.ts index 4bde1eb79..5a5dd8c33 100644 --- a/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/ExpirySection/hooks/useExpiryDetails.ts +++ b/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/ExpirySection/hooks/useExpiryDetails.ts @@ -1,8 +1,8 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { match, P } from 'ts-pattern' +import { useChainId } from 'wagmi' -import { useChainName } from '@app/hooks/chain/useChainName' import { useNameType } from '@app/hooks/nameType/useNameType' import { useBasicName } from '@app/hooks/useBasicName' import type { useNameDetails } from '@app/hooks/useNameDetails' @@ -11,7 +11,7 @@ import { GRACE_PERIOD } from '@app/utils/constants' import { safeDateObj } from '@app/utils/date' import { parentName } from '@app/utils/name' import { getSupportLink } from '@app/utils/supportLinks' -import { checkETH2LDFromName, makeEtherscanLink } from '@app/utils/utils' +import { checkETH2LDFromName, createEtherscanLink } from '@app/utils/utils' type Input = { name: string @@ -42,7 +42,7 @@ export const useExpiryDetails = ({ name, details }: Input, options: Options = {} name: parentName(name), enabled: enabled && !!nameType.data && nameType.data!.includes('subname'), }) - const chainName = useChainName() + const chainId = useChainId() const registrationData = useRegistrationData({ name, enabled: enabled && isETH2LD }) const isLoading = @@ -89,7 +89,10 @@ export const useExpiryDetails = ({ name, details }: Input, options: Options = {} { type: 'registration', date: registrationData?.data?.registrationDate, - link: makeEtherscanLink(registrationData?.data?.transactionHash!, chainName), + link: createEtherscanLink({ + data: registrationData?.data?.transactionHash!, + chainId, + }), }, ] : []), @@ -160,7 +163,7 @@ export const useExpiryDetails = ({ name, details }: Input, options: Options = {} parentData.wrapperData, parentData.expiryDate, registrationData.data, - chainName, + chainId, ], ) diff --git a/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/RolesSection/components/RoleRow.tsx b/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/RolesSection/components/RoleRow.tsx index b96775d79..ac99c16d4 100644 --- a/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/RolesSection/components/RoleRow.tsx +++ b/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/RolesSection/components/RoleRow.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import { useCopyToClipboard } from 'react-use' import styled, { css } from 'styled-components' import { Address } from 'viem' +import { useChainId } from 'wagmi' import { Button, @@ -15,13 +16,12 @@ import { } from '@ensdomains/thorin' import { AvatarWithIdentifier } from '@app/components/@molecules/AvatarWithIdentifier/AvatarWithIdentifier' -import { useChainName } from '@app/hooks/chain/useChainName' import { usePrimaryName } from '@app/hooks/ensjs/public/usePrimaryName' import type { Role } from '@app/hooks/ownership/useRoles/useRoles' import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' import { getDestination } from '@app/routes' import { emptyAddress } from '@app/utils/constants' -import { checkETH2LDFromName, makeEtherscanLink } from '@app/utils/utils' +import { checkETH2LDFromName, createEtherscanLink } from '@app/utils/utils' import { useRoleActions } from '../hooks/useRoleActions' import { RoleTag } from './RoleTag' @@ -69,7 +69,7 @@ export const RoleRow = ({ name, address, roles, actions, isWrapped, isEmancipate const { t } = useTranslation('common') const primary = usePrimaryName({ address: address!, enabled: !!address }) - const networkName = useChainName() + const chainId = useChainId() const [, copy] = useCopyToClipboard() const etherscanAction = useMemo(() => { @@ -80,10 +80,11 @@ export const RoleRow = ({ name, address, roles, actions, isWrapped, isEmancipate if (!hasToken) return null return { label: t('transaction.viewEtherscan', { ns: 'common' }), - onClick: () => window.open(makeEtherscanLink(address!, networkName, 'address'), '_blank'), + onClick: () => + window.open(createEtherscanLink({ data: address!, chainId, route: 'address' }), '_blank'), icon: , } - }, [primary.data?.name, isWrapped, t, address, networkName]) + }, [primary.data?.name, isWrapped, t, address, chainId]) const editRolesAction = actions?.find(({ type, disabled }) => type === 'edit-roles' && !disabled) diff --git a/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/RolesSection/hooks/useRoleActions.test.tsx b/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/RolesSection/hooks/useRoleActions.test.tsx index 5d74e3115..9faa63486 100644 --- a/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/RolesSection/hooks/useRoleActions.test.tsx +++ b/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/RolesSection/hooks/useRoleActions.test.tsx @@ -22,12 +22,12 @@ vi.mock('@app/hooks/account/useAccountSafely', () => ({ })) const mockCheckCanSend = vi.fn() -vi.mock('@app/transaction-flow/input/SendName/utils/checkCanSend', () => ({ +vi.mock('@app/transaction/user/SendName/utils/checkCanSend', () => ({ checkCanSend: () => mockCheckCanSend(), })) const mockCheckCanSyncManager = vi.fn() -vi.mock('@app/transaction-flow/input/SyncManager/utils/checkCanSyncManager', () => ({ +vi.mock('@app/transaction/user/SyncManager/utils/checkCanSyncManager', () => ({ checkCanSyncManager: () => mockCheckCanSyncManager(), })) diff --git a/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/RolesSection/hooks/useRoleActions.tsx b/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/RolesSection/hooks/useRoleActions.tsx index 804434f7c..fc46847ae 100644 --- a/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/RolesSection/hooks/useRoleActions.tsx +++ b/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/RolesSection/hooks/useRoleActions.tsx @@ -17,9 +17,9 @@ import { useNameType } from '@app/hooks/nameType/useNameType' import type { GroupedRoleRecord } from '@app/hooks/ownership/useRoles/useRoles' import { getAvailableRoles } from '@app/hooks/ownership/useRoles/utils/getAvailableRoles' import type { useNameDetails } from '@app/hooks/useNameDetails' -import { checkCanSend } from '@app/transaction-flow/input/SendName/utils/checkCanSend' -import { checkCanSyncManager } from '@app/transaction-flow/input/SyncManager/utils/checkCanSyncManager' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { usePreparedDataInput } from '@app/transaction/usePreparedDataInput' +import { checkCanSend } from '@app/transaction/user/input/SendName/utils/checkCanSend' +import { checkCanSyncManager } from '@app/transaction/user/input/SyncManager/utils/checkCanSyncManager' type Action = Omit & { primary?: boolean @@ -42,7 +42,6 @@ export const useRoleActions = ({ name, roles, details }: Props) => { const abilities = useAbilities({ name }) const queryClient = useQueryClient() - const { usePreparedDataInput } = useTransactionFlow() const showSendNameInput = usePreparedDataInput('SendName') const showEditRolesInput = usePreparedDataInput('EditRoles') const showSyncManagerInput = usePreparedDataInput('SyncManager') diff --git a/src/components/pages/profile/[name]/tabs/PermissionsTab/ExpiryPermissions.tsx b/src/components/pages/profile/[name]/tabs/PermissionsTab/ExpiryPermissions.tsx index 0a515ccf1..6cdec21d5 100644 --- a/src/components/pages/profile/[name]/tabs/PermissionsTab/ExpiryPermissions.tsx +++ b/src/components/pages/profile/[name]/tabs/PermissionsTab/ExpiryPermissions.tsx @@ -6,7 +6,7 @@ import { Button, Typography } from '@ensdomains/thorin' import type { useFusesSetDates } from '@app/hooks/fuses/useFusesSetDates' import type { useFusesStates } from '@app/hooks/fuses/useFusesStates' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { usePreparedDataInput } from '@app/transaction/usePreparedDataInput' import { Section, SectionFooter, SectionItem } from './Section' @@ -44,7 +44,6 @@ export const ExpiryPermissions = ({ parentExpiry, }: Props) => { const { t } = useTranslation('profile') - const { usePreparedDataInput } = useTransactionFlow() const showRevokePermissionsInput = usePreparedDataInput('RevokePermissions') const handleRevokePermissions = () => { diff --git a/src/components/pages/profile/[name]/tabs/PermissionsTab/NameChangePermissions.tsx b/src/components/pages/profile/[name]/tabs/PermissionsTab/NameChangePermissions.tsx index f956bab92..45174f4df 100644 --- a/src/components/pages/profile/[name]/tabs/PermissionsTab/NameChangePermissions.tsx +++ b/src/components/pages/profile/[name]/tabs/PermissionsTab/NameChangePermissions.tsx @@ -8,7 +8,7 @@ import { Button, Typography } from '@ensdomains/thorin' import type { useFusesSetDates } from '@app/hooks/fuses/useFusesSetDates' import type { useFusesStates } from '@app/hooks/fuses/useFusesStates' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { usePreparedDataInput } from '@app/transaction/usePreparedDataInput' import { DisabledButtonWithTooltip } from '../../../../../@molecules/DisabledButtonWithTooltip' import { Section, SectionFooter, SectionItem } from './Section' @@ -86,7 +86,6 @@ export const NameChangePermissions = ({ canEditPermissions, }: Props) => { const { t } = useTranslation('profile') - const { usePreparedDataInput } = useTransactionFlow() const showRevokePermissionsInput = usePreparedDataInput('RevokePermissions') const isParentLocked = parentState === 'locked' || wrapperData?.fuses?.parent.IS_DOT_ETH diff --git a/src/components/pages/profile/[name]/tabs/PermissionsTab/OwnershipPermissions.tsx b/src/components/pages/profile/[name]/tabs/PermissionsTab/OwnershipPermissions.tsx index e9e914fc1..2f07f0d3d 100644 --- a/src/components/pages/profile/[name]/tabs/PermissionsTab/OwnershipPermissions.tsx +++ b/src/components/pages/profile/[name]/tabs/PermissionsTab/OwnershipPermissions.tsx @@ -8,7 +8,7 @@ import { Button, Typography } from '@ensdomains/thorin' import { StyledLink } from '@app/components/@atoms/StyledLink' import type { useFusesSetDates } from '@app/hooks/fuses/useFusesSetDates' import type { useFusesStates } from '@app/hooks/fuses/useFusesStates' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { usePreparedDataInput } from '@app/transaction/usePreparedDataInput' import { Section, SectionFooter, SectionItem, SectionList } from './Section' @@ -50,7 +50,6 @@ export const OwnershipPermissions = ({ }: Props) => { const { t } = useTranslation('profile') - const { usePreparedDataInput } = useTransactionFlow() const showRevokePermissionsInput = usePreparedDataInput('RevokePermissions') const nameParts = name.split('.') diff --git a/src/components/pages/profile/[name]/tabs/RecordsTab.tsx b/src/components/pages/profile/[name]/tabs/RecordsTab.tsx index bc51cfa22..c70549d31 100644 --- a/src/components/pages/profile/[name]/tabs/RecordsTab.tsx +++ b/src/components/pages/profile/[name]/tabs/RecordsTab.tsx @@ -9,7 +9,7 @@ import { cacheableComponentStyles } from '@app/components/@atoms/CacheableCompon import { DisabledButtonWithTooltip } from '@app/components/@molecules/DisabledButtonWithTooltip' import { Outlink } from '@app/components/Outlink' import RecordItem from '@app/components/RecordItem' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { usePreparedDataInput } from '@app/transaction/usePreparedDataInput' import { AddressRecord, Profile, TextRecord } from '@app/types' import { abiDisplayValue } from '@app/utils/abi' import { emptyAddress } from '@app/utils/constants' @@ -153,7 +153,6 @@ export const RecordsTab = ({ } }, [name, chainId, contentHash]) - const { usePreparedDataInput } = useTransactionFlow() const showAdvancedEditorInput = usePreparedDataInput('AdvancedEditor') const handleShowEditor = () => showAdvancedEditorInput(`advanced-editor-${name}`, { name }, { disableBackgroundClick: true }) diff --git a/src/components/pages/profile/[name]/tabs/SubnamesTab.tsx b/src/components/pages/profile/[name]/tabs/SubnamesTab.tsx index 4fd3f918d..076eb1a4a 100644 --- a/src/components/pages/profile/[name]/tabs/SubnamesTab.tsx +++ b/src/components/pages/profile/[name]/tabs/SubnamesTab.tsx @@ -16,7 +16,7 @@ import { Card } from '@app/components/Card' import { Outlink } from '@app/components/Outlink' import { TabWrapper } from '@app/components/pages/profile/TabWrapper' import { useSubnames } from '@app/hooks/ensjs/subgraph/useSubnames' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { usePreparedDataInput } from '@app/transaction/usePreparedDataInput' import { emptyAddress } from '@app/utils/constants' import { getSupportLink } from '@app/utils/supportLinks' @@ -113,7 +113,6 @@ export const SubnamesTab = ({ }) => { const { t } = useTranslation('profile') const { address } = useAccount() - const { usePreparedDataInput } = useTransactionFlow() const showCreateSubnameInput = usePreparedDataInput('CreateSubname') const [sortType, setSortType] = useQueryParameterState< diff --git a/src/components/pages/profile/settings/DevSection.tsx b/src/components/pages/profile/settings/DevSection.tsx index 311683a00..5d542caa9 100644 --- a/src/components/pages/profile/settings/DevSection.tsx +++ b/src/components/pages/profile/settings/DevSection.tsx @@ -6,16 +6,14 @@ import { mine, setAutomine, } from 'viem/actions' -import { Config, useClient, useSendTransaction } from 'wagmi' +import { Config, useClient } from 'wagmi' import { Button } from '@ensdomains/thorin' import { localhostWithEns } from '@app/constants/chains' -import { useAddRecentTransaction } from '@app/hooks/transactions/useAddRecentTransaction' import { useLocalStorage } from '@app/hooks/useLocalStorage' -import { DetailedSwitch } from '@app/transaction-flow/input/ProfileEditor/components/DetailedSwitch' -import { createTransactionItem } from '@app/transaction-flow/transaction' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { useTransactionManager } from '@app/transaction/transactionManager' +import { DetailedSwitch } from '@app/transaction/user/input/ProfileEditor/components/DetailedSwitch' import { SectionContainer } from './Section' @@ -42,39 +40,19 @@ export const DevSection = () => { const client = useClient() const testClient = useMemo(() => ({ ...client, mode: 'anvil' }) as const, [client]) - const addTransaction = useAddRecentTransaction() - const { createTransactionFlow } = useTransactionFlow() - const { sendTransactionAsync } = useSendTransaction() + const startFlow = useTransactionManager((s) => s.startFlow) - const addSuccess = async () => { - const hash = await sendTransactionAsync({ - to: '0x0000000000000000000000000000000000000000', - value: 0n, - gas: 21000n, - }) - addTransaction({ - hash, - action: 'test', - searchRetries: 0, + const sendName = () => { + startFlow({ + flowId: 'dev-sendName', + transactions: [{ name: 'testSendName', data: {} }], }) } - const sendName = async () => { - createTransactionFlow('dev-sendName', { - transactions: [createTransactionItem('testSendName', {})], - }) - } - - const addFailure = async () => { - const hash = await sendTransactionAsync({ - to: '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0', - data: '0x1231237123423423', - gas: 1000000n, - }) - addTransaction({ - hash, - action: 'test', - searchRetries: 0, + const addFailure = () => { + startFlow({ + flowId: 'dev-addFailure', + transactions: [{ name: '__dev_failure', data: {} }], }) } @@ -98,9 +76,8 @@ export const DevSection = () => { return ( - {process.env.NEXT_PUBLIC_PROVIDER && ( + {true && ( <> - diff --git a/src/components/pages/profile/settings/PrimarySection.tsx b/src/components/pages/profile/settings/PrimarySection.tsx index ff4adc7dd..dc6ba993a 100644 --- a/src/components/pages/profile/settings/PrimarySection.tsx +++ b/src/components/pages/profile/settings/PrimarySection.tsx @@ -8,7 +8,7 @@ import { DisabledButtonWithTooltip } from '@app/components/@molecules/DisabledBu import { useAccountSafely } from '@app/hooks/account/useAccountSafely' import { usePrimaryName } from '@app/hooks/ensjs/public/usePrimaryName' import { useBasicName } from '@app/hooks/useBasicName' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { usePreparedDataInput } from '@app/transaction/usePreparedDataInput' import { useHasGraphError } from '@app/utils/SyncProvider/SyncProvider' const SkeletonFiller = styled.div( @@ -133,7 +133,6 @@ export const PrimarySection = () => { const { t } = useTranslation('settings') const { address } = useAccountSafely() - const { usePreparedDataInput } = useTransactionFlow() const showSelectPrimaryNameInput = usePreparedDataInput('SelectPrimaryName') const showResetPrimaryNameInput = usePreparedDataInput('ResetPrimaryName') diff --git a/src/components/pages/profile/settings/TransactionSection/ClearTransactionsDialog.tsx b/src/components/pages/profile/settings/TransactionSection/ClearTransactionsDialog.tsx index 1e1ab1f5e..86d0f750d 100644 --- a/src/components/pages/profile/settings/TransactionSection/ClearTransactionsDialog.tsx +++ b/src/components/pages/profile/settings/TransactionSection/ClearTransactionsDialog.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { Button, Dialog } from '@ensdomains/thorin' -import { CenteredTypography } from '@app/transaction-flow/input/ProfileEditor/components/CenteredTypography' +import { CenteredTypography } from '@app/transaction/user/input/ProfileEditor/components/CenteredTypography' type Props = { onClear: () => void } & Omit, 'children' | 'variant'> diff --git a/src/components/pages/profile/settings/TransactionSection/TransactionSection.tsx b/src/components/pages/profile/settings/TransactionSection/TransactionSection.tsx index 013aa29cf..18db839cb 100644 --- a/src/components/pages/profile/settings/TransactionSection/TransactionSection.tsx +++ b/src/components/pages/profile/settings/TransactionSection/TransactionSection.tsx @@ -1,17 +1,17 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' +import type { Hash } from 'viem' import { Button, mq, Spinner, Typography } from '@ensdomains/thorin' import { Card } from '@app/components/Card' import { Outlink } from '@app/components/Outlink' -import { useChainName } from '@app/hooks/chain/useChainName' -import { useClearRecentTransactions } from '@app/hooks/transactions/useClearRecentTransactions' -import { useRecentTransactions } from '@app/hooks/transactions/useRecentTransactions' import useThrottledCallback from '@app/hooks/useThrottledCallback' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' -import { makeEtherscanLink } from '@app/utils/utils' +import type { StoredTransaction } from '@app/transaction/slices/createTransactionSlice' +import { useTransactionManager } from '@app/transaction/transactionManager' +import type { UserTransaction } from '@app/transaction/user/transaction' +import { createEtherscanLink } from '@app/utils/utils' import { SectionContainer } from '../Section' import { ClearTransactionsDialog } from './ClearTransactionsDialog' @@ -128,37 +128,34 @@ const InfoContainer = styled.div( `, ) -const getTransactionExtraInfo = (action: string, key?: string) => { - if (!key) return '' - if (action === 'registerName' || action === 'commitName') { - return `: ${key.replace(/^(?:register|commit)-(.*)-0x[a-fA-F0-9]{40}$/g, '$1')}` - } - return '' +const getTransactionExtraInfo = (transaction: UserTransaction) => { + if (transaction.name !== 'registerName' && transaction.name !== 'commitName') return '' + return `: ${transaction.data.name}` } export const TransactionSection = () => { const { t: tc } = useTranslation() const { t } = useTranslation('settings') - const chainName = useChainName() - const transactions = useRecentTransactions() - const clearTransactions = useClearRecentTransactions() const [viewAmt, setViewAmt] = useState(5) - const nonRepricedTransactions = transactions.filter((tx) => tx.status !== 'repriced') - - const visibleTransactions = nonRepricedTransactions.slice(0, viewAmt) + const transactions = useTransactionManager((s) => + s + .getAllTransactions() + .filter((tx): tx is Extract => !!tx.currentHash), + ) + const visibleTransactions = transactions.slice(0, viewAmt) - const canClear = useMemo(() => { - return nonRepricedTransactions.length > 0 - }, [nonRepricedTransactions.length]) + const canClear = transactions.length > 0 - const { getResumable, resumeTransactionFlow } = useTransactionFlow() + const clearAll = useTransactionManager((s) => s.clearTransactionsAndFlows) + const isTransactionResumable = useTransactionManager((s) => s.isTransactionResumable) + const resumeFlow = useTransactionManager((s) => s.resumeFlow) const ref = useRef(null) const [height, setHeight] = useState('auto') - const hasViewMore = nonRepricedTransactions.length > viewAmt + const hasViewMore = transactions.length > viewAmt const [width, _setWidth] = useState(0) const setWidth = useThrottledCallback(_setWidth, 300) @@ -177,7 +174,7 @@ export const TransactionSection = () => { useEffect(() => { const _height = ref.current?.getBoundingClientRect().height || 0 setHeight(_height ? `${_height}px` : 'auto') - }, [nonRepricedTransactions.length, hasViewMore, width]) + }, [transactions.length, hasViewMore, width]) const [showClearDialog, setShowClearDialog] = useState(false) @@ -201,15 +198,16 @@ export const TransactionSection = () => { > - {nonRepricedTransactions.length > 0 ? ( + {transactions.length > 0 ? ( <> - {visibleTransactions.map(({ hash, status, action, key }, i) => { - const resumable = key && getResumable(key) + {visibleTransactions.map((transaction) => { + const { currentHash, status, name, flowId } = transaction + const resumable = isTransactionResumable(transaction) return ( {status === 'pending' && ( @@ -217,11 +215,14 @@ export const TransactionSection = () => { )} {`${tc( - `transaction.description.${action}`, - )}${getTransactionExtraInfo(action, key)}`} + `transaction.description.${name}`, + )}${getTransactionExtraInfo(transaction)}`} {tc(`transaction.status.${status}.regular`)} @@ -230,7 +231,7 @@ export const TransactionSection = () => { {resumable && ( - @@ -260,7 +261,7 @@ export const TransactionSection = () => { onClose={() => setShowClearDialog(false)} onDismiss={() => setShowClearDialog(false)} onClear={() => { - clearTransactions() + clearAll() setShowClearDialog(false) setViewAmt(5) }} diff --git a/src/components/pages/profile/[name]/registration/FullInvoice.tsx b/src/components/pages/register/FullInvoice.tsx similarity index 100% rename from src/components/pages/profile/[name]/registration/FullInvoice.tsx rename to src/components/pages/register/FullInvoice.tsx diff --git a/src/components/pages/profile/[name]/registration/Registration.tsx b/src/components/pages/register/Registration.tsx similarity index 53% rename from src/components/pages/profile/[name]/registration/Registration.tsx rename to src/components/pages/register/Registration.tsx index 5b61385e8..79176ad2b 100644 --- a/src/components/pages/profile/[name]/registration/Registration.tsx +++ b/src/components/pages/register/Registration.tsx @@ -1,31 +1,28 @@ import Head from 'next/head' -import { useCallback, useEffect, useMemo } from 'react' +import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' import { match, P } from 'ts-pattern' -import { useAccount, useChainId } from 'wagmi' +import { useAccount } from 'wagmi' import { Dialog, Helper, mq, Typography } from '@ensdomains/thorin' import { BaseLinkWithHistory } from '@app/components/@atoms/BaseLink' import { InnerDialog } from '@app/components/@atoms/InnerDialog' -import { ProfileRecord } from '@app/constants/profileRecordOptions' import { useContractAddress } from '@app/hooks/chain/useContractAddress' import { usePrimaryName } from '@app/hooks/ensjs/public/usePrimaryName' import { useNameDetails } from '@app/hooks/useNameDetails' -import useRegistrationReducer from '@app/hooks/useRegistrationReducer' import { useResolverExists } from '@app/hooks/useResolverExists' import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' import { Content } from '@app/layouts/Content' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' -import { isLabelTooLong, secondsToYears } from '@app/utils/utils' +import { useTransactionManager } from '@app/transaction/transactionManager' +import { isLabelTooLong } from '@app/utils/utils' import Complete from './steps/Complete' import Info from './steps/Info' import Pricing from './steps/Pricing/Pricing' import Profile from './steps/Profile/Profile' import Transactions from './steps/Transactions' -import { BackObj, PaymentMethod, RegistrationStepData } from './types' import { useMoonpayRegistration } from './useMoonpayRegistration' const ViewProfileContainer = styled.div( @@ -106,13 +103,8 @@ const Registration = ({ nameDetails, isLoading }: Props) => { const { t } = useTranslation('register') const router = useRouterWithHistory() - const chainId = useChainId() const { address } = useAccount() const primary = usePrimaryName({ address }) - const selected = useMemo( - () => ({ name: nameDetails.normalisedName, address: address!, chainId }), - [address, chainId, nameDetails.normalisedName], - ) const { normalisedName, beautifiedName = '' } = nameDetails const defaultResolverAddress = useContractAddress({ contract: 'ensPublicResolver' }) const { data: resolverExists, isLoading: resolverExistsLoading } = useResolverExists({ @@ -121,126 +113,122 @@ const Registration = ({ nameDetails, isLoading }: Props) => { }) const labelTooLong = isLabelTooLong(normalisedName) - const { dispatch, item } = useRegistrationReducer(selected) - const step = item.queue[item.stepIndex] - - const keySuffix = `${nameDetails.normalisedName}-${address}` - const commitKey = `commit-${keySuffix}` - const registerKey = `register-${keySuffix}` - const { cleanupFlow } = useTransactionFlow() + const currentRegistrationFlowStep = useTransactionManager((s) => + s.getCurrentRegistrationFlowStep(normalisedName), + ) + const clearRegistrationFlow = useTransactionManager((s) => s.clearRegistrationFlow) + const moonpayUrl = useTransactionManager( + (s) => s.getCurrentRegistrationFlowOrDefault(normalisedName).externalTransactionData?.url, + ) const { - moonpayUrl, initiateMoonpayRegistrationMutation, hasMoonpayModal, setHasMoonpayModal, moonpayTransactionStatus, - } = useMoonpayRegistration(dispatch, normalisedName, selected, item) - - const pricingCallback = ({ - seconds, - reverseRecord, - paymentMethodChoice, - durationType, - }: RegistrationStepData['pricing']) => { - if (paymentMethodChoice === PaymentMethod.moonpay) { - initiateMoonpayRegistrationMutation.mutate(secondsToYears(seconds)) - return - } - dispatch({ - name: 'setPricingData', - payload: { seconds, reverseRecord, durationType }, - selected, - }) - if (!item.queue.includes('profile')) { - // if profile is not in queue, set the default profile data - dispatch({ - name: 'setProfileData', - payload: { - records: [{ key: 'eth', group: 'address', type: 'addr', value: address! }], - clearRecords: resolverExists, - resolverAddress: defaultResolverAddress, - }, - selected, - }) - if (reverseRecord) { - // if reverse record is selected, add the profile step to the queue - dispatch({ - name: 'setQueue', - payload: ['pricing', 'profile', 'info', 'transactions', 'complete'], - selected, - }) - } - } - - // If profile is in queue and reverse record is selected, make sure that eth record is included and is set to address - if (item.queue.includes('profile') && reverseRecord) { - const recordsWithoutEth = item.records.filter((record) => record.key !== 'eth') - const newRecords: ProfileRecord[] = [ - { key: 'eth', group: 'address', type: 'addr', value: address! }, - ...recordsWithoutEth, - ] - dispatch({ name: 'setProfileData', payload: { records: newRecords }, selected }) - } - - dispatch({ name: 'increaseStep', selected }) - } - - const profileCallback = ({ - records, - resolverAddress, - back, - }: RegistrationStepData['profile'] & BackObj) => { - dispatch({ name: 'setProfileData', payload: { records, resolverAddress }, selected }) - dispatch({ name: back ? 'decreaseStep' : 'increaseStep', selected }) - } - - const genericCallback = ({ back }: BackObj) => { - dispatch({ name: back ? 'decreaseStep' : 'increaseStep', selected }) - } - - const transactionsCallback = useCallback( - ({ back, resetSecret }: BackObj & { resetSecret?: boolean }) => { - if (resetSecret) { - dispatch({ name: 'resetSecret', selected }) - } - genericCallback({ back }) - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [selected], - ) - - const infoProfileCallback = () => { - dispatch({ - name: 'setQueue', - payload: ['pricing', 'profile', 'info', 'transactions', 'complete'], - selected, - }) - } - - const onStart = () => { - dispatch({ name: 'setStarted', selected }) - } - - const onComplete = (toProfile: boolean) => { - router.push(toProfile ? `/profile/${normalisedName}` : '/') - } + } = useMoonpayRegistration(normalisedName) + + // const pricingCallback = ({ + // seconds, + // reverseRecord, + // paymentMethodChoice, + // durationType, + // }: RegistrationStepData['pricing']) => { + // if (paymentMethodChoice === PaymentMethod.moonpay) { + // initiateMoonpayRegistrationMutation.mutate(secondsToYears(seconds)) + // return + // } + // dispatch({ + // name: 'setPricingData', + // payload: { seconds, reverseRecord, durationType }, + // selected, + // }) + // if (!item.queue.includes('profile')) { + // // if profile is not in queue, set the default profile data + // dispatch({ + // name: 'setProfileData', + // payload: { + // records: [{ key: 'eth', group: 'address', type: 'addr', value: address! }], + // clearRecords: resolverExists, + // resolverAddress: defaultResolverAddress, + // }, + // selected, + // }) + // if (reverseRecord) { + // // if reverse record is selected, add the profile step to the queue + // dispatch({ + // name: 'setQueue', + // payload: ['pricing', 'profile', 'info', 'transactions', 'complete'], + // selected, + // }) + // } + // } + + // // If profile is in queue and reverse record is selected, make sure that eth record is included and is set to address + // if (item.queue.includes('profile') && reverseRecord) { + // const recordsWithoutEth = item.records.filter((record) => record.key !== 'eth') + // const newRecords: ProfileRecord[] = [ + // { key: 'eth', group: 'address', type: 'addr', value: address! }, + // ...recordsWithoutEth, + // ] + // dispatch({ name: 'setProfileData', payload: { records: newRecords }, selected }) + // } + + // dispatch({ name: 'increaseStep', selected }) + // } + + // const profileCallback = ({ + // records, + // resolverAddress, + // back, + // }: RegistrationStepData['profile'] & BackObj) => { + // dispatch({ name: 'setProfileData', payload: { records, resolverAddress }, selected }) + // dispatch({ name: back ? 'decreaseStep' : 'increaseStep', selected }) + // } + + // const genericCallback = ({ back }: BackObj) => { + // dispatch({ name: back ? 'decreaseStep' : 'increaseStep', selected }) + // } + + // const transactionsCallback = useCallback( + // ({ back, resetSecret }: BackObj & { resetSecret?: boolean }) => { + // if (resetSecret) { + // dispatch({ name: 'resetSecret', selected }) + // } + // genericCallback({ back }) + // }, + // // eslint-disable-next-line react-hooks/exhaustive-deps + // [selected], + // ) + + // const infoProfileCallback = () => { + // dispatch({ + // name: 'setQueue', + // payload: ['pricing', 'profile', 'info', 'transactions', 'complete'], + // selected, + // }) + // } + + // const onStart = () => { + // dispatch({ name: 'setStarted', selected }) + // } + + // const onComplete = (toProfile: boolean) => { + // router.push(toProfile ? `/profile/${normalisedName}` : '/') + // } useEffect(() => { const handleRouteChange = (e: string) => { - if (e !== router.asPath && step === 'complete') { - dispatch({ name: 'clearItem', selected }) - cleanupFlow(commitKey) - cleanupFlow(registerKey) + if (e !== router.asPath && currentRegistrationFlowStep === 'complete') { + clearRegistrationFlow(normalisedName) } } router.events.on('routeChangeComplete', handleRouteChange) return () => { router.events.off('routeChangeComplete', handleRouteChange) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dispatch, step, selected, router.asPath]) + }, [currentRegistrationFlowStep, clearRegistrationFlow, router.asPath]) const onDismissMoonpayModal = () => { if (moonpayTransactionStatus === 'waitingAuthorization') { @@ -257,7 +245,7 @@ const Registration = ({ nameDetails, isLoading }: Props) => { { ), - trailing: match([labelTooLong, step]) + trailing: match([labelTooLong, currentRegistrationFlowStep]) .with([true, P._], () => {t('error.nameTooLong')}) .with([false, 'pricing'], () => ( { beautifiedName={beautifiedName} gracePeriodEndDate={nameDetails.gracePeriodEndDate} resolverExists={resolverExists} - callback={pricingCallback} isPrimaryLoading={primary.isLoading} hasPrimaryName={!!primary.data?.name} - registrationData={item} moonpayTransactionStatus={moonpayTransactionStatus} initiateMoonpayRegistrationMutation={initiateMoonpayRegistrationMutation} /> )) .with([false, 'profile'], () => ( - - )) - .with([false, 'info'], () => ( - - )) - .with([false, 'transactions'], () => ( - + )) + .with([false, 'info'], () => ) + .with([false, 'transactions'], () => ) .with([false, 'complete'], () => ( - + )) .exhaustive(), }} @@ -336,11 +298,6 @@ const Registration = ({ nameDetails, isLoading }: Props) => { {t('steps.info.moonpayModalHeader')} - {chainId === 5 && ( - - {`${t('steps.info.moonpayTestCard')}: 4000 0209 5159 5032, 12/2030, 123`} - - )} css` @@ -151,19 +152,18 @@ const useEthInvoice = ( isMoonpayFlow: boolean, ): { InvoiceFilled?: React.ReactNode; avatarSrc?: string } => { const { t } = useTranslation('register') - const { address } = useAccount() - const keySuffix = `${name}-${address}` - const commitKey = `commit-${keySuffix}` - const registerKey = `register-${keySuffix}` - const { getLatestTransaction } = useTransactionFlow() - const commitTxFlow = getLatestTransaction(commitKey) - const registerTxFlow = getLatestTransaction(registerKey) + const commitTransaction = useTransactionManager((s) => s.getCurrentCommitTransaction(name)) + const registerTransaction = useTransactionManager((s) => s.getCurrentRegisterTransaction(name)) const [avatarSrc, setAvatarSrc] = useState() - const commitReceipt = commitTxFlow?.minedData - const registerReceipt = registerTxFlow?.minedData + const { data: commitReceipt } = useTransactionReceipt({ + hash: commitTransaction?.currentHash ?? undefined, + }) + const { data: registerReceipt } = useTransactionReceipt({ + hash: registerTransaction?.currentHash ?? undefined, + }) const registrationValue = useMemo(() => { if (!registerReceipt) return null @@ -227,14 +227,23 @@ const useEthInvoice = ( type Props = { name: string beautifiedName: string - callback: (toProfile: boolean) => void - isMoonpayFlow: boolean } -const Complete = ({ name, beautifiedName, callback, isMoonpayFlow }: Props) => { +const Complete = ({ name, beautifiedName }: Props) => { const { t } = useTranslation('register') const { width, height } = useWindowSize() - const { InvoiceFilled, avatarSrc } = useEthInvoice(name, isMoonpayFlow) + + const router = useRouterWithHistory() + + const paymentMethod = useTransactionManager( + (s) => s.getCurrentRegistrationFlowOrDefault(name).paymentMethodChoice, + ) + + const onComplete = (toProfile: boolean) => { + router.push(toProfile ? `/profile/${name}` : '/') + } + + const { InvoiceFilled, avatarSrc } = useEthInvoice(name, paymentMethod === 'moonpay') const nameWithColourEmojis = useMemo(() => { const data = tokenise(beautifiedName) @@ -289,12 +298,12 @@ const Complete = ({ name, beautifiedName, callback, isMoonpayFlow }: Props) => { {InvoiceFilled} - - diff --git a/src/components/pages/profile/[name]/registration/steps/Info.tsx b/src/components/pages/register/steps/Info.tsx similarity index 74% rename from src/components/pages/profile/[name]/registration/steps/Info.tsx rename to src/components/pages/register/steps/Info.tsx index b6dc35a0d..0e77630e0 100644 --- a/src/components/pages/profile/[name]/registration/steps/Info.tsx +++ b/src/components/pages/register/steps/Info.tsx @@ -6,9 +6,9 @@ import { Button, Heading, mq, Typography } from '@ensdomains/thorin' import MobileFullWidth from '@app/components/@atoms/MobileFullWidth' import { Card } from '@app/components/Card' import { useEstimateFullRegistration } from '@app/hooks/gasEstimation/useEstimateRegistration' +import { useTransactionManager } from '@app/transaction/transactionManager' import FullInvoice from '../FullInvoice' -import { RegistrationReducerDataItem } from '../types' const StyledCard = styled(Card)( ({ theme }) => css` @@ -97,18 +97,23 @@ const ProfileButton = styled.button( const infoItemArr = Array.from({ length: 3 }, (_, i) => `steps.info.ethItems.${i}`) type Props = { - registrationData: RegistrationReducerDataItem name: string - callback: (data: { back: boolean }) => void - onProfileClick: () => void } -const Info = ({ registrationData, name, callback, onProfileClick }: Props) => { +const Info = ({ name }: Props) => { const { t } = useTranslation('register') + const existingRegistrationData = useTransactionManager((s) => + s.getCurrentRegistrationFlowOrDefault(name), + ) + const setRegistrationFlowQueue = useTransactionManager((s) => s.setRegistrationFlowQueue) + const onRegistrationInfoStepCompleted = useTransactionManager( + (s) => s.onRegistrationInfoStepCompleted, + ) + const estimate = useEstimateFullRegistration({ name, - registrationData, + registrationData: existingRegistrationData, }) return ( @@ -124,8 +129,19 @@ const Info = ({ registrationData, name, callback, onProfileClick }: Props) => { ))} - {!registrationData.queue.includes('profile') && ( - + {!existingRegistrationData.queue.includes('profile') && ( + + setRegistrationFlowQueue(name, [ + 'pricing', + 'profile', + 'info', + 'transactions', + 'complete', + ]) + } + > {t('steps.info.setupProfile')} @@ -133,12 +149,18 @@ const Info = ({ registrationData, name, callback, onProfileClick }: Props) => { )} - - diff --git a/src/components/pages/register/steps/Pricing/PaymentChoice.tsx b/src/components/pages/register/steps/Pricing/PaymentChoice.tsx new file mode 100644 index 000000000..5e65dc419 --- /dev/null +++ b/src/components/pages/register/steps/Pricing/PaymentChoice.tsx @@ -0,0 +1,315 @@ +import { Dispatch, SetStateAction, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' + +import { + Field, + Helper, + mq, + RadioButton, + RadioButtonGroup, + Toggle, + Typography, +} from '@ensdomains/thorin' + +import MoonpayLogo from '@app/assets/MoonpayLogo.svg' +import { Spacer } from '@app/components/@atoms/Spacer' +import type { RegistrationPaymentMethod } from '@app/transaction/slices/createRegistrationFlowSlice' +import { useBreakpoint } from '@app/utils/BreakpointProvider' + +import { type MoonpayTransactionStatus } from '../../types' + +const OutlinedContainer = styled.div( + ({ theme }) => css` + width: ${theme.space.full}; + display: grid; + align-items: center; + grid-template-areas: 'title checkbox' 'description description'; + gap: ${theme.space['2']}; + + padding: ${theme.space['4']}; + border-radius: ${theme.radii.large}; + background: ${theme.colors.backgroundSecondary}; + + ${mq.sm.min(css` + grid-template-areas: 'title checkbox' 'description checkbox'; + `)} + `, +) + +const gridAreaStyle = ({ $name }: { $name: string }) => css` + grid-area: ${$name}; +` + +const moonpayInfoItems = Array.from({ length: 2 }, (_, i) => `steps.info.moonpayItems.${i}`) + +const PaymentChoiceContainer = styled.div` + width: 100%; +` + +const StyledRadioButtonGroup = styled(RadioButtonGroup)( + ({ theme }) => css` + border: 1px solid ${theme.colors.border}; + border-radius: ${theme.radii.large}; + gap: 0; + `, +) + +const StyledRadioButton = styled(RadioButton)`` + +const RadioButtonContainer = styled.div( + ({ theme }) => css` + padding: ${theme.space['4']}; + &:last-child { + border-top: 1px solid ${theme.colors.border}; + } + `, +) + +const StyledTitle = styled(Typography)` + margin-left: 15px; +` + +const RadioLabel = styled(Typography)( + ({ theme }) => css` + margin-right: 10px; + color: ${theme.colors.text}; + `, +) + +const MoonpayContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + gap: 5px; +` + +const InfoItems = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: ${theme.space['4']}; + + ${mq.sm.min(css` + flex-direction: row; + align-items: stretch; + `)} + `, +) + +const InfoItem = styled.div( + ({ theme }) => css` + width: 100%; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: ${theme.space['4']}; + + padding: ${theme.space['4']}; + border: 1px solid ${theme.colors.border}; + border-radius: ${theme.radii.large}; + text-align: center; + + & > div:first-of-type { + width: ${theme.space['10']}; + height: ${theme.space['10']}; + display: flex; + align-items: center; + justify-content: center; + font-size: ${theme.fontSizes.extraLarge}; + font-weight: ${theme.fontWeights.bold}; + color: ${theme.colors.backgroundPrimary}; + background: ${theme.colors.accentPrimary}; + border-radius: ${theme.radii.full}; + } + + & > div:last-of-type { + flex-grow: 1; + display: flex; + align-items: center; + justify-content: center; + } + `, +) + +const LabelContainer = styled.div` + display: flex; + flex-wrap: wrap; +` + +const CheckboxWrapper = styled.div( + () => css` + width: 100%; + `, + gridAreaStyle, +) + +const OutlinedContainerDescription = styled(Typography)(gridAreaStyle) + +const OutlinedContainerTitle = styled(Typography)( + ({ theme }) => css` + font-size: ${theme.fontSizes.large}; + font-weight: ${theme.fontWeights.bold}; + white-space: nowrap; + `, + gridAreaStyle, +) + +const EthInnerCheckbox = ({ + address, + hasPrimaryName, + reverseRecord, + setReverseRecord, + started, +}: { + address: string + hasPrimaryName: boolean + reverseRecord: boolean + setReverseRecord: (val: boolean) => void + started: boolean +}) => { + const { t } = useTranslation('register') + const breakpoints = useBreakpoint() + + useEffect(() => { + if (!started) { + setReverseRecord(!hasPrimaryName) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [setReverseRecord]) + + return ( + + + {(ids) => ( + { + e.stopPropagation() + setReverseRecord(e.target.checked) + }} + data-testid="primary-name-toggle" + /> + )} + + + ) +} + +export const PaymentChoice = ({ + paymentMethodChoice, + setPaymentMethodChoice, + hasEnoughEth, + moonpayTransactionStatus, + address, + hasPrimaryName, + reverseRecord, + setReverseRecord, + started, +}: { + paymentMethodChoice: RegistrationPaymentMethod + setPaymentMethodChoice: Dispatch> + hasEnoughEth: boolean + moonpayTransactionStatus: MoonpayTransactionStatus | undefined + address: string + hasPrimaryName: boolean + reverseRecord: boolean + setReverseRecord: (reverseRecord: boolean) => void + started: boolean +}) => { + const { t } = useTranslation('register') + + return ( + + + {t('steps.info.paymentMethod')} + + + setPaymentMethodChoice(e.target.value as RegistrationPaymentMethod)} + > + + {t('steps.info.ethereum')}} + name="RadioButtonGroup" + value={'ethereum'} + disabled={moonpayTransactionStatus === 'pending'} + checked={paymentMethodChoice === 'ethereum' || undefined} + /> + {paymentMethodChoice === 'ethereum' && !hasEnoughEth && ( + <> + + + {t('steps.info.notEnoughEth')} + + + + )} + {paymentMethodChoice === 'ethereum' && hasEnoughEth && ( + <> + + + + {t('steps.pricing.primaryName')} + + + + {t('steps.pricing.primaryNameMessage')} + + + + + )} + + + + {t('steps.info.creditOrDebit')} + + ({t('steps.info.additionalFee')}) + + + } + name="RadioButtonGroup" + value={'moonpay'} + checked={paymentMethodChoice === 'moonpay' || undefined} + /> + {paymentMethodChoice === 'moonpay' && ( + <> + + + {moonpayInfoItems.map((item, idx) => ( + + {idx + 1} + {t(item)} + + ))} + + + {moonpayTransactionStatus === 'failed' && ( + {t('steps.info.failedMoonpayTransaction')} + )} + + + {t('steps.info.poweredBy')} + + + + )} + + + + ) +} diff --git a/src/components/pages/profile/[name]/registration/steps/Pricing/Pricing.test.tsx b/src/components/pages/register/steps/Pricing/Pricing.test.tsx similarity index 100% rename from src/components/pages/profile/[name]/registration/steps/Pricing/Pricing.test.tsx rename to src/components/pages/register/steps/Pricing/Pricing.test.tsx diff --git a/src/components/pages/register/steps/Pricing/Pricing.tsx b/src/components/pages/register/steps/Pricing/Pricing.tsx new file mode 100644 index 000000000..a219e2150 --- /dev/null +++ b/src/components/pages/register/steps/Pricing/Pricing.tsx @@ -0,0 +1,316 @@ +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { usePreviousDistinct } from 'react-use' +import usePrevious from 'react-use/lib/usePrevious' +import styled, { css } from 'styled-components' +import { match, P } from 'ts-pattern' +import type { Address } from 'viem' +import { useBalance } from 'wagmi' +import { GetBalanceData } from 'wagmi/query' + +import { Button, Heading, mq } from '@ensdomains/thorin' + +import MobileFullWidth from '@app/components/@atoms/MobileFullWidth' +import { RegistrationTimeComparisonBanner } from '@app/components/@atoms/RegistrationTimeComparisonBanner/RegistrationTimeComparisonBanner' +import { DateSelection } from '@app/components/@molecules/DateSelection/DateSelection' +import { Card } from '@app/components/Card' +import { ConnectButton } from '@app/components/ConnectButton' +import { useAccountSafely } from '@app/hooks/account/useAccountSafely' +import { useContractAddress } from '@app/hooks/chain/useContractAddress' +import { useEstimateFullRegistration } from '@app/hooks/gasEstimation/useEstimateRegistration' +import type { + RegistrationDurationType, + RegistrationPaymentMethod, +} from '@app/transaction/slices/createRegistrationFlowSlice' +import { useTransactionManager } from '@app/transaction/transactionManager' +import { ONE_DAY, ONE_YEAR } from '@app/utils/time' + +import FullInvoice from '../../FullInvoice' +import { MoonpayTransactionStatus, PaymentMethod } from '../../types' +import { + useMoonpayRegistration, + type InitiateMoonpayRegistrationMutationResult, +} from '../../useMoonpayRegistration' +import { PaymentChoice } from './PaymentChoice' +import TemporaryPremium from './TemporaryPremium' + +const StyledCard = styled(Card)( + ({ theme }) => css` + max-width: 780px; + margin: 0 auto; + flex-direction: column; + gap: ${theme.space['4']}; + padding: ${theme.space['4']}; + + ${mq.sm.min(css` + padding: ${theme.space['6']} ${theme.space['18']}; + gap: ${theme.space['6']}; + `)} + `, +) + +const StyledHeading = styled(Heading)( + () => css` + width: 100%; + word-break: break-all; + + @supports (overflow-wrap: anywhere) { + overflow-wrap: anywhere; + word-break: normal; + } + `, +) + +const gridAreaStyle = ({ $name }: { $name: string }) => css` + grid-area: ${$name}; +` + +export type ActionButtonProps = { + address?: Address + moonpayTransactionStatus?: MoonpayTransactionStatus + callback: () => void + paymentMethodChoice: RegistrationPaymentMethod + initiateMoonpayRegistrationMutation: InitiateMoonpayRegistrationMutationResult + balance: GetBalanceData | undefined + totalRequiredBalance?: bigint +} + +export const ActionButton = (props: ActionButtonProps) => { + const { t } = useTranslation('register') + + return match(props) + .with({ address: P.nullish }, () => ) + .with({ moonpayTransactionStatus: 'pending' }, () => ( + + )) + .with({ moonpayTransactionStatus: 'failed', paymentMethodChoice: 'moonpay' }, () => ( + + )) + .with( + { paymentMethodChoice: 'moonpay' }, + ({ initiateMoonpayRegistrationMutation, callback, paymentMethodChoice }) => ( + + ), + ) + .with( + P.when((_props) => typeof _props.balance?.value !== 'bigint' || !_props.totalRequiredBalance), + () => ( + + ), + ) + .with( + P.when( + (_props) => + _props.totalRequiredBalance && + typeof _props.balance?.value === 'bigint' && + _props.balance.value < _props.totalRequiredBalance && + _props.paymentMethodChoice === PaymentMethod.ethereum, + ), + () => ( + + ), + ) + .otherwise(({ callback, paymentMethodChoice }) => ( + + )) +} + +export type PricingProps = { + name: string + gracePeriodEndDate: Date | undefined + beautifiedName: string + + resolverExists: boolean | undefined + isPrimaryLoading: boolean + hasPrimaryName: boolean + moonpayTransactionStatus?: MoonpayTransactionStatus + initiateMoonpayRegistrationMutation: ReturnType< + typeof useMoonpayRegistration + >['initiateMoonpayRegistrationMutation'] +} + +const minSeconds = 28 * ONE_DAY + +const Pricing = ({ + name, + gracePeriodEndDate, + beautifiedName, + isPrimaryLoading, + hasPrimaryName, + resolverExists, + moonpayTransactionStatus, + initiateMoonpayRegistrationMutation, +}: PricingProps) => { + const { t } = useTranslation('register') + + const { address } = useAccountSafely() + const { data: balance } = useBalance({ address }) + const resolverAddress = useContractAddress({ contract: 'ensPublicResolver' }) + + const existingRegistrationData = useTransactionManager((s) => + s.getCurrentRegistrationFlowOrDefault(name), + ) + const onRegistrationPricingStepCompleted = useTransactionManager( + (s) => s.onRegistrationPricingStepCompleted, + ) + + const [seconds, setSeconds] = useState(() => existingRegistrationData.seconds ?? ONE_YEAR) + const [durationType, setDurationType] = useState( + existingRegistrationData.durationType ?? 'years', + ) + + const [reverseRecord, setReverseRecord] = useState(() => + existingRegistrationData.isStarted ? existingRegistrationData.reverseRecord : !hasPrimaryName, + ) + + const hasPendingMoonpayTransaction = moonpayTransactionStatus === 'pending' + const hasFailedMoonpayTransaction = moonpayTransactionStatus === 'failed' + + const previousMoonpayTransactionStatus = usePrevious(moonpayTransactionStatus) + + const [paymentMethodChoice, setPaymentMethodChoice] = useState( + hasPendingMoonpayTransaction ? 'moonpay' : 'ethereum', + ) + + const callback = useCallback(() => { + onRegistrationPricingStepCompleted(name, { + durationType, + initiateMoonpayRegistrationMutation, + paymentMethodChoice, + resolverExists: !!resolverExists, + reverseRecord, + seconds, + }) + }, [ + onRegistrationPricingStepCompleted, + name, + durationType, + initiateMoonpayRegistrationMutation, + paymentMethodChoice, + resolverExists, + reverseRecord, + seconds, + ]) + + // Keep radio button choice up to date + useEffect(() => { + if (moonpayTransactionStatus) { + setPaymentMethodChoice( + hasPendingMoonpayTransaction || hasFailedMoonpayTransaction + ? PaymentMethod.moonpay + : PaymentMethod.ethereum, + ) + } + }, [ + hasFailedMoonpayTransaction, + hasPendingMoonpayTransaction, + moonpayTransactionStatus, + previousMoonpayTransactionStatus, + setPaymentMethodChoice, + ]) + + const fullEstimate = useEstimateFullRegistration({ + name, + registrationData: { + ...existingRegistrationData, + reverseRecord, + seconds, + records: [{ key: 'ETH', value: resolverAddress, type: 'addr', group: 'address' }], + clearRecords: resolverExists, + resolverAddress, + }, + }) + + const { hasPremium, premiumFee, gasPrice, yearlyFee, totalDurationBasedFee, estimatedGasFee } = + fullEstimate + const durationRequiredBalance = totalDurationBasedFee ? (totalDurationBasedFee * 110n) / 100n : 0n + const totalRequiredBalance = durationRequiredBalance + ? durationRequiredBalance + (premiumFee || 0n) + (estimatedGasFee || 0n) + : 0n + + const previousYearlyFee = usePreviousDistinct(yearlyFee) || 0n + + const unsafeDisplayYearlyFee = yearlyFee === 0n ? previousYearlyFee : yearlyFee + + const showPaymentChoice = !isPrimaryLoading && address + + const previousEstimatedGasFee = usePreviousDistinct(estimatedGasFee) || 0n + + const unsafeDisplayEstimatedGasFee = + estimatedGasFee === 0n ? previousEstimatedGasFee : estimatedGasFee + + return ( + + {t('heading', { name: beautifiedName })} + + + {hasPremium && gracePeriodEndDate ? ( + + ) : ( + !!unsafeDisplayYearlyFee && + !!unsafeDisplayEstimatedGasFee && + !!gasPrice && ( + + ) + )} + {showPaymentChoice && ( + + )} + + + + + ) +} + +export default Pricing diff --git a/src/components/pages/profile/[name]/registration/steps/Pricing/TemporaryPremium.tsx b/src/components/pages/register/steps/Pricing/TemporaryPremium.tsx similarity index 100% rename from src/components/pages/profile/[name]/registration/steps/Pricing/TemporaryPremium.tsx rename to src/components/pages/register/steps/Pricing/TemporaryPremium.tsx diff --git a/src/components/pages/profile/[name]/registration/steps/Profile/AddProfileRecordView.test.tsx b/src/components/pages/register/steps/Profile/AddProfileRecordView.test.tsx similarity index 100% rename from src/components/pages/profile/[name]/registration/steps/Profile/AddProfileRecordView.test.tsx rename to src/components/pages/register/steps/Profile/AddProfileRecordView.test.tsx diff --git a/src/components/pages/profile/[name]/registration/steps/Profile/AddProfileRecordView.tsx b/src/components/pages/register/steps/Profile/AddProfileRecordView.tsx similarity index 99% rename from src/components/pages/profile/[name]/registration/steps/Profile/AddProfileRecordView.tsx rename to src/components/pages/register/steps/Profile/AddProfileRecordView.tsx index 08b89c3fe..ae851e4fd 100644 --- a/src/components/pages/profile/[name]/registration/steps/Profile/AddProfileRecordView.tsx +++ b/src/components/pages/register/steps/Profile/AddProfileRecordView.tsx @@ -14,9 +14,9 @@ import { ProfileRecord, ProfileRecordGroup, } from '@app/constants/profileRecordOptions' +import useDebouncedCallback from '@app/hooks/useDebouncedCallback' import { ProfileEditorForm } from '@app/hooks/useProfileEditorForm' -import useDebouncedCallback from '../../../../../../../hooks/useDebouncedCallback' import { OptionButton } from './OptionButton' import { OptionGroup } from './OptionGroup' diff --git a/src/components/pages/profile/[name]/registration/steps/Profile/CustomProfileRecordInput.tsx b/src/components/pages/register/steps/Profile/CustomProfileRecordInput.tsx similarity index 100% rename from src/components/pages/profile/[name]/registration/steps/Profile/CustomProfileRecordInput.tsx rename to src/components/pages/register/steps/Profile/CustomProfileRecordInput.tsx diff --git a/src/components/pages/profile/[name]/registration/steps/Profile/DynamicIcon.tsx b/src/components/pages/register/steps/Profile/DynamicIcon.tsx similarity index 100% rename from src/components/pages/profile/[name]/registration/steps/Profile/DynamicIcon.tsx rename to src/components/pages/register/steps/Profile/DynamicIcon.tsx diff --git a/src/components/pages/profile/[name]/registration/steps/Profile/Field.tsx b/src/components/pages/register/steps/Profile/Field.tsx similarity index 100% rename from src/components/pages/profile/[name]/registration/steps/Profile/Field.tsx rename to src/components/pages/register/steps/Profile/Field.tsx diff --git a/src/components/pages/profile/[name]/registration/steps/Profile/OptionButton.tsx b/src/components/pages/register/steps/Profile/OptionButton.tsx similarity index 100% rename from src/components/pages/profile/[name]/registration/steps/Profile/OptionButton.tsx rename to src/components/pages/register/steps/Profile/OptionButton.tsx diff --git a/src/components/pages/profile/[name]/registration/steps/Profile/OptionGroup.tsx b/src/components/pages/register/steps/Profile/OptionGroup.tsx similarity index 100% rename from src/components/pages/profile/[name]/registration/steps/Profile/OptionGroup.tsx rename to src/components/pages/register/steps/Profile/OptionGroup.tsx diff --git a/src/components/pages/profile/[name]/registration/steps/Profile/Profile.test.tsx b/src/components/pages/register/steps/Profile/Profile.test.tsx similarity index 100% rename from src/components/pages/profile/[name]/registration/steps/Profile/Profile.test.tsx rename to src/components/pages/register/steps/Profile/Profile.test.tsx diff --git a/src/components/pages/profile/[name]/registration/steps/Profile/Profile.tsx b/src/components/pages/register/steps/Profile/Profile.tsx similarity index 93% rename from src/components/pages/profile/[name]/registration/steps/Profile/Profile.tsx rename to src/components/pages/register/steps/Profile/Profile.tsx index bbabfbad7..dd0dbc60f 100644 --- a/src/components/pages/profile/[name]/registration/steps/Profile/Profile.tsx +++ b/src/components/pages/register/steps/Profile/Profile.tsx @@ -15,8 +15,8 @@ import { ProfileRecord } from '@app/constants/profileRecordOptions' import { useContractAddress } from '@app/hooks/chain/useContractAddress' import { useLocalStorage } from '@app/hooks/useLocalStorage' import { ProfileEditorForm, useProfileEditorForm } from '@app/hooks/useProfileEditorForm' +import { useTransactionManager } from '@app/transaction/transactionManager' -import { BackObj, RegistrationReducerDataItem, RegistrationStepData } from '../../types' import { AddProfileRecordView } from './AddProfileRecordView' import { CustomProfileRecordInput } from './CustomProfileRecordInput' import { ProfileRecordInput } from './ProfileRecordInput' @@ -110,17 +110,22 @@ type ModalOption = AvatarClickType | 'add-record' | 'clear-eth' | 'public-notice type Props = { name: string - registrationData: RegistrationReducerDataItem resolverExists: boolean | undefined - callback: (data: RegistrationStepData['profile'] & BackObj) => void } -const Profile = ({ name, callback, registrationData, resolverExists }: Props) => { +const Profile = ({ name, resolverExists }: Props) => { const { t } = useTranslation('register') + const existingRegistrationData = useTransactionManager((s) => + s.getCurrentRegistrationFlowOrDefault(name), + ) + const onRegistrationProfileStepCompleted = useTransactionManager( + (s) => s.onRegistrationProfileStepCompleted, + ) + const defaultResolverAddress = useContractAddress({ contract: 'ensPublicResolver' }) const clearRecords = - registrationData.resolverAddress === defaultResolverAddress ? resolverExists : false + existingRegistrationData.resolverAddress === defaultResolverAddress ? resolverExists : false const backRef = useRef(null) const { @@ -140,7 +145,7 @@ const Profile = ({ name, callback, registrationData, resolverExists }: Props) => errorForRecordAtIndex, isDirtyForRecordAtIndex, hasErrors, - } = useProfileEditorForm(registrationData.records) + } = useProfileEditorForm(existingRegistrationData.records) const [isAvatarDropdownOpen, setIsAvatarDropdownOpen] = useState(false) @@ -185,10 +190,10 @@ const Profile = ({ name, callback, registrationData, resolverExists }: Props) => const nativeEvent = e?.nativeEvent as SubmitEvent | undefined const newRecords = profileEditorFormToProfileRecords(data) - callback({ + onRegistrationProfileStepCompleted(name, { records: newRecords, clearRecords, - resolverAddress: registrationData.resolverAddress, + resolverAddress: existingRegistrationData.resolverAddress, back: nativeEvent?.submitter === backRef.current, }) } @@ -302,7 +307,7 @@ const Profile = ({ name, callback, registrationData, resolverExists }: Props) => key={field.id} recordKey={field.key} group={field.group} - disabled={field.key === 'eth' && registrationData.reverseRecord} + disabled={field.key === 'eth' && existingRegistrationData.reverseRecord} label={labelForRecord(field)} secondaryLabel={secondaryLabelForRecord(field)} placeholder={placeholderForRecord(field)} diff --git a/src/components/pages/profile/[name]/registration/steps/Profile/ProfileRecordInput.tsx b/src/components/pages/register/steps/Profile/ProfileRecordInput.tsx similarity index 100% rename from src/components/pages/profile/[name]/registration/steps/Profile/ProfileRecordInput.tsx rename to src/components/pages/register/steps/Profile/ProfileRecordInput.tsx diff --git a/src/components/pages/profile/[name]/registration/steps/Profile/ProfileRecordTextarea.tsx b/src/components/pages/register/steps/Profile/ProfileRecordTextarea.tsx similarity index 100% rename from src/components/pages/profile/[name]/registration/steps/Profile/ProfileRecordTextarea.tsx rename to src/components/pages/register/steps/Profile/ProfileRecordTextarea.tsx diff --git a/src/components/pages/profile/[name]/registration/steps/Profile/WrappedAvatarButton.tsx b/src/components/pages/register/steps/Profile/WrappedAvatarButton.tsx similarity index 100% rename from src/components/pages/profile/[name]/registration/steps/Profile/WrappedAvatarButton.tsx rename to src/components/pages/register/steps/Profile/WrappedAvatarButton.tsx diff --git a/src/components/pages/profile/[name]/registration/steps/Profile/profileRecordUtils.test.ts b/src/components/pages/register/steps/Profile/profileRecordUtils.test.ts similarity index 100% rename from src/components/pages/profile/[name]/registration/steps/Profile/profileRecordUtils.test.ts rename to src/components/pages/register/steps/Profile/profileRecordUtils.test.ts diff --git a/src/components/pages/profile/[name]/registration/steps/Profile/profileRecordUtils.ts b/src/components/pages/register/steps/Profile/profileRecordUtils.ts similarity index 100% rename from src/components/pages/profile/[name]/registration/steps/Profile/profileRecordUtils.ts rename to src/components/pages/register/steps/Profile/profileRecordUtils.ts diff --git a/src/components/pages/profile/[name]/registration/steps/Transactions.tsx b/src/components/pages/register/steps/Transactions.tsx similarity index 60% rename from src/components/pages/profile/[name]/registration/steps/Transactions.tsx rename to src/components/pages/register/steps/Transactions.tsx index 6fa915441..700e4e6ec 100644 --- a/src/components/pages/profile/[name]/registration/steps/Transactions.tsx +++ b/src/components/pages/register/steps/Transactions.tsx @@ -1,25 +1,18 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' import { match, P } from 'ts-pattern' -import { useAccount } from 'wagmi' -import { makeCommitment } from '@ensdomains/ensjs/utils' import { Button, CountdownCircle, Dialog, Heading, mq, Spinner } from '@ensdomains/thorin' import MobileFullWidth from '@app/components/@atoms/MobileFullWidth' import { TextWithTooltip } from '@app/components/@atoms/TextWithTooltip/TextWithTooltip' import { Card } from '@app/components/Card' -import { useExistingCommitment } from '@app/hooks/registration/useExistingCommitment' import { useDurationCountdown } from '@app/hooks/time/useDurationCountdown' -import useRegistrationParams from '@app/hooks/useRegistrationParams' -import { CenteredTypography } from '@app/transaction-flow/input/ProfileEditor/components/CenteredTypography' -import { createTransactionItem } from '@app/transaction-flow/transaction' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { useTransactionManager } from '@app/transaction/transactionManager' +import { CenteredTypography } from '@app/transaction/user/input/ProfileEditor/components/CenteredTypography' import { ONE_DAY } from '@app/utils/time' -import { RegistrationReducerDataItem } from '../types' - const StyledCard = styled(Card)( ({ theme }) => css` max-width: 780px; @@ -89,98 +82,83 @@ const ProgressButton = ({ onClick, label }: { onClick: () => void; label: string type Props = { name: string - registrationData: RegistrationReducerDataItem - callback: (data: { back: boolean; resetSecret?: boolean }) => void - onStart: () => void } -const Transactions = ({ registrationData, name, callback, onStart }: Props) => { +const Transactions = ({ name }: Props) => { const { t } = useTranslation('register') - const { address } = useAccount() - const keySuffix = `${name}-${address}` - const commitKey = `commit-${keySuffix}` - const registerKey = `register-${keySuffix}` - const { getLatestTransaction, createTransactionFlow, resumeTransactionFlow, cleanupFlow } = - useTransactionFlow() - const commitTx = getLatestTransaction(commitKey) - const registerTx = getLatestTransaction(registerKey) + const commitTransaction = useTransactionManager((s) => s.getCurrentCommitTransaction(name)) + const registerTransaction = useTransactionManager((s) => s.getCurrentRegisterTransaction(name)) + const [resetOpen, setResetOpen] = useState(false) - const commitTimestamp = commitTx?.stage === 'complete' ? commitTx?.finaliseTime : undefined + const commitTimestamp = + commitTransaction?.status === 'success' ? commitTransaction?.receipt.timestamp : undefined const [commitComplete, setCommitComplete] = useState( !!commitTimestamp && commitTimestamp + 60000 < Date.now(), ) - const registrationParams = useRegistrationParams({ - name, - owner: address!, - registrationData, - }) - - const commitCouldBeFound = - !commitTx?.stage || commitTx.stage === 'confirm' || commitTx.stage === 'failed' - useExistingCommitment({ - commitment: makeCommitment(registrationParams), - enabled: commitCouldBeFound, - commitKey, - }) - - const makeCommitNameFlow = useCallback(() => { - onStart() - createTransactionFlow(commitKey, { - transactions: [createTransactionItem('commitName', registrationParams)], - requiresManualCleanup: true, - autoClose: true, - resumeLink: `/register/${name}`, - }) - }, [commitKey, createTransactionFlow, name, onStart, registrationParams]) - - const makeRegisterNameFlow = () => { - createTransactionFlow(registerKey, { - transactions: [createTransactionItem('registerName', registrationParams)], - requiresManualCleanup: true, - autoClose: true, - resumeLink: `/register/${name}`, - }) - } - - const showCommitTransaction = () => { - resumeTransactionFlow(commitKey) - } - - const showRegisterTransaction = () => { - resumeTransactionFlow(registerKey) - } + const existingRegistrationData = useTransactionManager((s) => + s.getCurrentRegistrationFlowOrDefault(name), + ) + const resetRegistrationTransactions = useTransactionManager( + (s) => () => s.resetRegistrationTransactions(name), + ) + const startCommitNameTransaction = useTransactionManager( + (s) => () => s.startCommitNameTransaction(name), + ) + const startRegisterNameTransaction = useTransactionManager( + (s) => () => s.startRegisterNameTransaction(name), + ) + const onRegistrationTransactionsStepCompleted = useTransactionManager( + (s) => s.onRegistrationTransactionsStepCompleted, + ) + const resumeCommitNameTransaction = useTransactionManager( + (s) => () => s.resumeCommitNameTransaction(name), + ) + const resumeRegisterNameTransaction = useTransactionManager( + (s) => () => s.resumeRegisterNameTransaction(name), + ) - const resetTransactions = () => { - cleanupFlow(commitKey) - cleanupFlow(registerKey) - callback({ back: true, resetSecret: true }) - setResetOpen(false) - } + // const commitCouldBeFound = + // !commitTransaction?.status || commitTransaction.status === 'empty' || commitTransaction.status === 'waitingForUser' || commitTransaction.status === 'reverted' + // useExistingCommitment({ + // commitment: makeCommitment(registrationParams), + // enabled: commitCouldBeFound, + // commitKey, + // }) - useEffect(() => { - if (!commitTx) { - makeCommitNameFlow() - } - }, [commitTx, makeCommitNameFlow]) + // const makeCommitNameFlow = useCallback(() => { + // onStart() + // createTransactionFlow(commitKey, { + // transactions: [createTransactionItem('commitName', registrationParams)], + // requiresManualCleanup: true, + // autoClose: true, + // resumeLink: `/register/${name}`, + // }) + // }, [commitKey, createTransactionFlow, name, onStart, registrationParams]) - useEffect(() => { - if (registerTx?.stage === 'complete') { - callback({ back: false }) - } - }, [callback, registerTx?.stage]) + // const makeRegisterNameFlow = () => { + // createTransactionFlow(registerKey, { + // transactions: [createTransactionItem('registerName', registrationParams)], + // requiresManualCleanup: true, + // autoClose: true, + // resumeLink: `/register/${name}`, + // }) + // } const NormalBackButton = useMemo( () => ( - ), - [t, callback], + [t, name, onRegistrationTransactionsStepCompleted], ) const ResetBackButton = useMemo( @@ -198,6 +176,27 @@ const Transactions = ({ registrationData, name, callback, onStart }: Props) => { endDate: commitTimestamp ? new Date(commitTimestamp + ONE_DAY * 1000) : undefined, }) + const resetTransactions = () => { + resetRegistrationTransactions() + setResetOpen(false) + } + + if (!commitTransaction) { + startCommitNameTransaction() + } + + useEffect(() => { + if (!commitTransaction) { + startCommitNameTransaction() + } + }, [commitTransaction, startCommitNameTransaction]) + + useEffect(() => { + if (registerTransaction?.status === 'success') { + onRegistrationTransactionsStepCompleted(name, { back: false }) + } + }, [registerTransaction?.status, onRegistrationTransactionsStepCompleted, name]) + return ( setResetOpen(false)}> @@ -228,8 +227,8 @@ const Transactions = ({ registrationData, name, callback, onStart }: Props) => { callback={() => setCommitComplete(true)} /> - {match([commitTx, commitComplete, duration]) - .with([{ stage: 'complete' }, false, P._], () => ( + {match([commitTransaction, commitComplete, duration]) + .with([{ status: 'success' }, false, P._], () => ( { }} /> )) - .with([{ stage: 'complete' }, true, null], () => + .with([{ status: 'success' }, true, null], () => t('steps.transactions.subheading.commitExpired'), ) - .with([{ stage: 'complete' }, true, P.not(P.nullish)], ([, , d]) => + .with([{ status: 'success' }, true, P.not(P.nullish)], ([, , d]) => t('steps.transactions.subheading.commitComplete', { duration: d }), ) - .with([{ stage: 'complete' }, true, P._], () => + .with([{ status: 'success' }, true, P._], () => t('steps.transactions.subheading.commitCompleteNoDuration'), ) .otherwise(() => t('steps.transactions.subheading.default'))} - {match([commitComplete, registerTx, commitTx]) - .with([true, { stage: 'failed' }, P._], () => ( + {match([commitComplete, registerTransaction, commitTransaction]) + .with([true, { status: 'reverted' }, P._], () => ( <> {ResetBackButton} )) - .with([true, { stage: 'sent' }, P._], () => ( + .with([true, { status: 'pending' }, P._], () => ( )) .with([true, P._, P._], () => ( @@ -276,29 +275,33 @@ const Transactions = ({ registrationData, name, callback, onStart }: Props) => { )) - .with([false, P._, { stage: 'failed' }], () => ( + .with([false, P._, { status: 'reverted' }], () => ( <> {NormalBackButton} )) - .with([false, P._, { stage: 'sent' }], () => ( + .with([false, P._, { status: 'pending' }], () => ( )) - .with([false, P._, { stage: 'complete' }], () => ( + .with([false, P._, { status: 'success' }], () => ( <> {ResetBackButton} @@ -311,12 +314,15 @@ const Transactions = ({ registrationData, name, callback, onStart }: Props) => { .otherwise(() => ( <> - - diff --git a/src/components/pages/profile/[name]/registration/types.ts b/src/components/pages/register/types.ts similarity index 100% rename from src/components/pages/profile/[name]/registration/types.ts rename to src/components/pages/register/types.ts diff --git a/src/components/pages/profile/[name]/registration/useMoonpayRegistration.test.ts b/src/components/pages/register/useMoonpayRegistration.test.ts similarity index 100% rename from src/components/pages/profile/[name]/registration/useMoonpayRegistration.test.ts rename to src/components/pages/register/useMoonpayRegistration.test.ts diff --git a/src/components/pages/register/useMoonpayRegistration.ts b/src/components/pages/register/useMoonpayRegistration.ts new file mode 100644 index 000000000..7165b5292 --- /dev/null +++ b/src/components/pages/register/useMoonpayRegistration.ts @@ -0,0 +1,149 @@ +import { + useMutation, + type QueryFunctionContext, + type UseMutationResult, +} from '@tanstack/react-query' +import { useState } from 'react' +import { labelhash, type Address } from 'viem' + +import type { SupportedChain } from '@app/constants/chains' +import { useQueryOptions } from '@app/hooks/useQueryOptions' +import { useTransactionManager } from '@app/transaction/transactionManager' +import type { ConfigWithEns, CreateQueryKey } from '@app/types' +import { MOONPAY_WORKER_URL } from '@app/utils/constants' +import { useQuery } from '@app/utils/query/useQuery' +import { getLabelFromName } from '@app/utils/utils' + +import { MoonpayTransactionStatus } from './types' + +type MoonpayRegistrationMutationParameters = { + duration: number + name: string + chainId: SupportedChain['id'] + address: Address +} + +type MoonpayRegistrationMutationReturnType = { + externalTransactionId: string + moonpayUrl: string +} + +const initiateMoonpayRegistrationMutationFn = async ({ + name, + duration, + chainId, + address, +}: MoonpayRegistrationMutationParameters): Promise => { + const label = getLabelFromName(name) + const tokenId = labelhash(label) + + const requestUrl = `${ + MOONPAY_WORKER_URL[chainId] + }/signedurl?tokenId=${tokenId}&name=${encodeURIComponent( + name, + )}&duration=${duration}&walletAddress=${address}` + + const response = await fetch(requestUrl) + const moonpayUrl = await response.text() + + const params = new URLSearchParams(moonpayUrl) + const externalTransactionId = params.get('externalTransactionId') + + if (!externalTransactionId) throw new Error('No external transaction id found') + + return { externalTransactionId, moonpayUrl } +} + +export type InitiateMoonpayRegistrationMutationResult = UseMutationResult< + MoonpayRegistrationMutationReturnType, + Error, + MoonpayRegistrationMutationParameters, + unknown +> + +type GetMoonpayStatusQueryParameters = { + name: string + externalTransactionId: string +} +type GetMoonpayStatusQueryKey = CreateQueryKey< + GetMoonpayStatusQueryParameters, + 'getMoonpayStatus', + 'standard' +> + +const getMoonpayStatusQueryFn = + (_config: ConfigWithEns) => + async ({ + queryKey: [{ name, externalTransactionId }, chainId, address], + }: QueryFunctionContext) => { + if (!address) throw new Error('No address found') + + const response = await fetch( + `${MOONPAY_WORKER_URL[chainId]}/transactionInfo?externalTransactionId=${externalTransactionId}`, + ) + const jsonResult = (await response.json()) as Array<{ status: MoonpayTransactionStatus }> + const result = jsonResult?.[0] + + if (result?.status === 'completed') { + useTransactionManager.getState().onRegistrationMoonpayTransactionCompleted(name, { + sourceChainId: chainId, + account: address, + }) + } + + return result || {} + } + +export const useMoonpayRegistration = (normalisedName: string) => { + const [hasMoonpayModal, setHasMoonpayModal] = useState(false) + + const currentExternalTransactionId = useTransactionManager( + (s) => s.getCurrentRegistrationFlowOrDefault(normalisedName).externalTransactionData?.id, + ) + const setRegistrationExternalTransactionData = useTransactionManager( + (s) => s.setRegistrationExternalTransactionData, + ) + + const initiateMoonpayRegistrationMutation = useMutation({ + mutationFn: initiateMoonpayRegistrationMutationFn, + onSuccess: (data, variables) => { + setRegistrationExternalTransactionData( + variables.name, + { + id: data.externalTransactionId, + url: data.moonpayUrl, + type: 'moonpay', + }, + { + sourceChainId: variables.chainId, + account: variables.address, + }, + ) + }, + }) + + const { queryKey, queryFn } = useQueryOptions({ + params: { name: normalisedName, externalTransactionId: currentExternalTransactionId! }, + functionName: 'getMoonpayStatus', + queryDependencyType: 'standard', + queryFn: getMoonpayStatusQueryFn, + }) + + // Monitor current transaction + const { data: transactionData } = useQuery({ + queryKey, + queryFn, + refetchOnWindowFocus: true, + refetchOnMount: true, + refetchInterval: 1000, + refetchIntervalInBackground: true, + enabled: !!currentExternalTransactionId, + }) + + return { + initiateMoonpayRegistrationMutation, + hasMoonpayModal, + setHasMoonpayModal, + moonpayTransactionStatus: transactionData?.status as MoonpayTransactionStatus, + } +} diff --git a/src/constants/chains.ts b/src/constants/chains.ts index 3be53d40e..74ad08158 100644 --- a/src/constants/chains.ts +++ b/src/constants/chains.ts @@ -1,5 +1,5 @@ import { holesky } from 'viem/chains' -import { goerli, localhost, mainnet, sepolia } from 'wagmi/chains' +import { localhost, mainnet, sepolia } from 'wagmi/chains' import { addEnsContracts } from '@ensdomains/ensjs' @@ -25,24 +25,41 @@ export const mainnetWithEns = { }, }, } -export const goerliWithEns = addEnsContracts(goerli) export const sepoliaWithEns = addEnsContracts(sepolia) export const holeskyWithEns = addEnsContracts(holesky) export const chainsWithEns = [ mainnetWithEns, - goerliWithEns, sepoliaWithEns, holeskyWithEns, localhostWithEns, ] as const -export const getSupportedChainById = (chainId: number | undefined) => - chainId ? chainsWithEns.find((c) => c.id === chainId) : undefined +export type GetSupportedChainById = Extract< + SupportedChain, + { id: chainId } +> + +export function getSupportedChainById( + chainId: chainId, +): GetSupportedChainById +export function getSupportedChainById(chainId: number | undefined): SupportedChain | undefined +export function getSupportedChainById(chainId: number | undefined) { + if (!chainId) return undefined + return chainsWithEns.find((c) => c.id === chainId) +} + +export type SourceChain = + | typeof mainnetWithEns + | typeof sepoliaWithEns + | typeof holeskyWithEns + | typeof localhostWithEns + +// this will include L2s later +export type TargetChain = SourceChain export type SupportedChain = | typeof mainnetWithEns - | typeof goerliWithEns | typeof sepoliaWithEns | typeof holeskyWithEns | typeof localhostWithEns diff --git a/src/constants/verification.ts b/src/constants/verification.ts index 2ea762961..8a06eddba 100644 --- a/src/constants/verification.ts +++ b/src/constants/verification.ts @@ -1,4 +1,4 @@ -import type { VerificationProtocol } from '@app/transaction-flow/input/VerifyProfile/VerifyProfile-flow' +import type { VerificationProtocol } from '@app/transaction/user/VerifyProfile/VerifyProfile-flow' /** * General Verification Constants diff --git a/src/hooks/chain/useChainName.ts b/src/hooks/chain/useChainName.ts index 2f7f78f47..9703baa8a 100644 --- a/src/hooks/chain/useChainName.ts +++ b/src/hooks/chain/useChainName.ts @@ -1,14 +1,13 @@ import { useMemo } from 'react' -import { useChainId, useConfig } from 'wagmi' +import { useChainId } from 'wagmi' import { getChainName } from '@app/utils/getChainName' export const useChainName = () => { - const config = useConfig() const chainId = useChainId() return useMemo(() => { - return getChainName(config, { chainId }) + return getChainName(chainId) // eslint-disable-next-line react-hooks/exhaustive-deps }, [chainId]) } diff --git a/src/hooks/chain/useEstimateGasWithStateOverride.ts b/src/hooks/chain/useEstimateGasWithStateOverride.ts index 34c23b8e4..30f4cf969 100644 --- a/src/hooks/chain/useEstimateGasWithStateOverride.ts +++ b/src/hooks/chain/useEstimateGasWithStateOverride.ts @@ -21,9 +21,9 @@ import { useConnectorClient } from 'wagmi' import { useQueryOptions } from '@app/hooks/useQueryOptions' import { createTransactionRequest, - TransactionName, - TransactionParameters, -} from '@app/transaction-flow/transaction' + type UserTransactionName, + type UserTransactionParameters, +} from '@app/transaction/user/transaction' import { ConfigWithEns, ConnectorClientWithEns, @@ -77,11 +77,14 @@ type StateOverride = { } type TransactionItem = { - [TName in TransactionName]: Omit, 'client' | 'connectorClient'> & { - name: TName + [name in UserTransactionName]: Omit< + UserTransactionParameters, + 'client' | 'connectorClient' + > & { + name: name stateOverride?: UserStateOverrides } -}[TransactionName] +}[UserTransactionName] type UseEstimateGasWithStateOverrideParameters< TransactionItems extends TransactionItem[] | readonly TransactionItem[], @@ -150,13 +153,13 @@ export const addStateOverride = < stateOverride, }) as Prettify -const estimateIndividualGas = async ({ +const estimateIndividualGas = async ({ data, name, stateOverride, connectorClient, client, -}: { name: TName; stateOverride?: UserStateOverrides } & TransactionParameters) => { +}: { name: name; stateOverride?: UserStateOverrides } & UserTransactionParameters) => { const generatedRequest = await createTransactionRequest({ client, connectorClient, diff --git a/src/hooks/gasEstimation/useEstimateRegistration.ts b/src/hooks/gasEstimation/useEstimateRegistration.ts index 3d969e678..2ca3530c8 100644 --- a/src/hooks/gasEstimation/useEstimateRegistration.ts +++ b/src/hooks/gasEstimation/useEstimateRegistration.ts @@ -3,7 +3,7 @@ import { parseEther } from 'viem' import { makeCommitment } from '@ensdomains/ensjs/utils' -import { RegistrationReducerDataItem } from '@app/components/pages/profile/[name]/registration/types' +import { RegistrationReducerDataItem } from '@app/components/registration/types' import { deriveYearlyFee } from '@app/utils/utils' import { useAccountSafely } from '../account/useAccountSafely' diff --git a/src/hooks/pages/profile/[name]/profile/useProfileActions/useProfileActions.ts b/src/hooks/pages/profile/[name]/profile/useProfileActions/useProfileActions.ts index a998d9f8b..c1056e393 100644 --- a/src/hooks/pages/profile/[name]/profile/useProfileActions/useProfileActions.ts +++ b/src/hooks/pages/profile/[name]/profile/useProfileActions/useProfileActions.ts @@ -13,10 +13,9 @@ import { useWrapperData } from '@app/hooks/ensjs/public/useWrapperData' import { useGetPrimaryNameTransactionFlowItem } from '@app/hooks/primary/useGetPrimaryNameTransactionFlowItem' import { useResolverStatus } from '@app/hooks/resolver/useResolverStatus' import { useProfile } from '@app/hooks/useProfile' -import { makeIntroItem } from '@app/transaction-flow/intro' -import { createTransactionItem } from '@app/transaction-flow/transaction' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' -import { GenericTransaction } from '@app/transaction-flow/types' +import { useTransactionManager } from '@app/transaction/transactionManager' +import { usePreparedDataInput } from '@app/transaction/usePreparedDataInput' +import { createUserTransaction } from '@app/transaction/user/transaction' import { checkAvailablePrimaryName } from '@app/utils/checkAvailablePrimaryName' import { nameParts } from '@app/utils/name' import { useHasGraphError } from '@app/utils/SyncProvider/SyncProvider' @@ -77,7 +76,7 @@ const verificationsButtonTooltip = ({ export const useProfileActions = ({ name, enabled: enabled_ = true }: Props) => { const { t } = useTranslation('profile') - const { createTransactionFlow, usePreparedDataInput } = useTransactionFlow() + const startFlow = useTransactionManager((s) => s.startFlow) const { address } = useAccountSafely() @@ -174,9 +173,9 @@ export const useProfileActions = ({ name, enabled: enabled_ = true }: Props) => }) } - const transactionFlowItem = getPrimaryNameTransactionFlowItem?.callBack?.(name) - if (isAvailablePrimaryName && !!transactionFlowItem) { - const key = `setPrimaryName-${name}-${address}` + const flow = getPrimaryNameTransactionFlowItem?.callBack?.(name) + if (isAvailablePrimaryName && !!flow) { + const flowId = `setPrimaryName-${name}-${address}` actions.push({ label: t('tabs.profile.actions.setAsPrimaryName.label'), tooltipContent: hasGraphError @@ -186,12 +185,11 @@ export const useProfileActions = ({ name, enabled: enabled_ = true }: Props) => loading: hasGraphErrorLoading, onClick: !checkIsDecrypted(name) ? () => - showUnknownLabelsInput(key, { + showUnknownLabelsInput(flowId, { name, - key, - transactionFlowItem, + flow: { ...flow, flowId }, }) - : () => createTransactionFlow(key, transactionFlowItem), + : () => startFlow({ ...flow, flowId }), }) } @@ -227,13 +225,13 @@ export const useProfileActions = ({ name, enabled: enabled_ = true }: Props) => loading: hasGraphErrorLoading, } if (abilities.canDeleteRequiresWrap) { - const transactions: GenericTransaction[] = [ - createTransactionItem('transferSubname', { + const transactions = [ + createUserTransaction('transferSubname', { name, contract: 'nameWrapper', newOwnerAddress: address, }), - createTransactionItem('deleteSubname', { + createUserTransaction('deleteSubname', { contract: 'nameWrapper', name, method: 'setRecord', @@ -242,16 +240,20 @@ export const useProfileActions = ({ name, enabled: enabled_ = true }: Props) => actions.push({ ...base, onClick: () => - createTransactionFlow(`deleteSubname-${name}`, { + startFlow({ + flowId: `deleteSubname-${name}`, transactions, resumable: true, intro: { title: ['intro.multiStepSubnameDelete.title', { ns: 'transactionFlow' }], - content: makeIntroItem('GenericWithDescription', { - description: t('intro.multiStepSubnameDelete.description', { - ns: 'transactionFlow', - }), - }), + content: { + name: 'GenericWithDescription', + data: { + description: t('intro.multiStepSubnameDelete.description', { + ns: 'transactionFlow', + }), + }, + }, }, }), }) @@ -278,13 +280,17 @@ export const useProfileActions = ({ name, enabled: enabled_ = true }: Props) => actions.push({ ...base, onClick: () => - createTransactionFlow(`deleteSubname-${name}`, { + startFlow({ + flowId: `deleteSubname-${name}`, transactions: [ - createTransactionItem('deleteSubname', { - name, - contract: abilities.canDeleteContract!, - method: abilities.canDeleteMethod, - }), + { + name: 'deleteSubname', + data: { + name, + contract: abilities.canDeleteContract!, + method: abilities.canDeleteMethod, + }, + }, ], }), }) @@ -309,13 +315,17 @@ export const useProfileActions = ({ name, enabled: enabled_ = true }: Props) => fullMobileWidth: true, loading: hasGraphErrorLoading, onClick: () => { - createTransactionFlow(`reclaim-${name}`, { + startFlow({ + flowId: `reclaim-${name}`, transactions: [ - createTransactionItem('createSubname', { - contract: 'nameWrapper', - label, - parent, - }), + { + name: 'createSubname', + data: { + contract: 'nameWrapper', + label, + parent, + }, + }, ], }) }, @@ -327,16 +337,18 @@ export const useProfileActions = ({ name, enabled: enabled_ = true }: Props) => }, [ address, isLoading, + ownerData?.owner, + ownerData?.registrant, getPrimaryNameTransactionFlowItem, name, isAvailablePrimaryName, - abilities.canEdit, - abilities.canEditRecords, - abilities.canEditResolver, abilities.canDelete, abilities.canDeleteContract, abilities.canDeleteError, abilities.canReclaim, + abilities.canEdit, + abilities.canEditRecords, + abilities.canEditResolver, abilities.canDeleteRequiresWrap, abilities.isPCCBurned, abilities.isParentOwner, @@ -344,14 +356,12 @@ export const useProfileActions = ({ name, enabled: enabled_ = true }: Props) => t, hasGraphError, hasGraphErrorLoading, - ownerData?.owner, - ownerData?.registrant, + showVerifyProfileInput, showUnknownLabelsInput, - createTransactionFlow, + startFlow, showProfileEditorInput, showDeleteEmancipatedSubnameWarningInput, showDeleteSubnameNotParentWarningInput, - showVerifyProfileInput, ]) return { diff --git a/src/hooks/primary/useGetPrimaryNameTransactionFlowItem/index.ts b/src/hooks/primary/useGetPrimaryNameTransactionFlowItem/index.ts index b0d111947..6c34eed23 100644 --- a/src/hooks/primary/useGetPrimaryNameTransactionFlowItem/index.ts +++ b/src/hooks/primary/useGetPrimaryNameTransactionFlowItem/index.ts @@ -5,9 +5,11 @@ import type { Address } from 'viem' import { useContractAddress } from '@app/hooks/chain/useContractAddress' import type { useResolverStatus } from '@app/hooks/resolver/useResolverStatus' import { useReverseRegistryName } from '@app/hooks/reverseRecord/useReverseRegistryName' -import { makeIntroItem } from '@app/transaction-flow/intro/index' -import { createTransactionItem, TransactionItem } from '@app/transaction-flow/transaction' -import { TransactionIntro } from '@app/transaction-flow/types' +import { createTransactionIntro, type TransactionIntro } from '@app/transaction/user/intro' +import { + createUserTransaction, + type GenericUserTransaction, +} from '@app/transaction/user/transaction' import { emptyAddress } from '@app/utils/constants' import { @@ -51,9 +53,9 @@ export const useGetPrimaryNameTransactionFlowItem = ( return (name: string) => { let introType: IntroType = 'updateEthAddress' const transactions: ( - | TransactionItem<'setPrimaryName'> - | TransactionItem<'updateResolver'> - | TransactionItem<'updateEthAddress'> + | GenericUserTransaction<'setPrimaryName'> + | GenericUserTransaction<'updateResolver'> + | GenericUserTransaction<'updateEthAddress'> )[] = [] if ( @@ -62,7 +64,7 @@ export const useGetPrimaryNameTransactionFlowItem = ( name, }) ) { - transactions.push(createTransactionItem('setPrimaryName', { name, address })) + transactions.push(createUserTransaction('setPrimaryName', { name, address })) } if ( @@ -75,7 +77,7 @@ export const useGetPrimaryNameTransactionFlowItem = ( introType = !resolverAddress || resolverAddress === emptyAddress ? 'noResolver' : 'invalidResolver' transactions.unshift( - createTransactionItem('updateResolver', { + createUserTransaction('updateResolver', { name, contract: isWrapped ? 'nameWrapper' : 'registry', resolverAddress: latestResolverAddress, @@ -92,7 +94,7 @@ export const useGetPrimaryNameTransactionFlowItem = ( }) ) { transactions.unshift( - createTransactionItem('updateEthAddress', { + createUserTransaction('updateEthAddress', { name, address, latestResolver: !resolverStatus?.isAuthorized, @@ -103,13 +105,13 @@ export const useGetPrimaryNameTransactionFlowItem = ( const introItem = transactions.length > 1 ? { - resumeable: true, + resumable: true, intro: { title: [getIntroTranslation(introType, 'title'), { ns: 'transactionFlow' }], - content: makeIntroItem('GenericWithDescription', { + content: createTransactionIntro('GenericWithDescription', { description: t(getIntroTranslation(introType, 'description')), }), - } as TransactionIntro, + } satisfies TransactionIntro, } : {} diff --git a/src/hooks/useFaucet.ts b/src/hooks/useFaucet.ts index 5c9b782d8..59496792e 100644 --- a/src/hooks/useFaucet.ts +++ b/src/hooks/useFaucet.ts @@ -47,11 +47,11 @@ const createEndpoint = (chainName: string) => type QueryKey = CreateQueryKey<{}, 'getFaucetAddress', 'standard'> const getFaucetQueryFn = - (config: ConfigWithEns) => + (_config: ConfigWithEns) => async ({ queryKey: [, chainId, address] }: QueryFunctionContext) => { if (!address) throw new Error('address is required') - const chainName = getChainName(config, { chainId }) + const chainName = getChainName(chainId) const result: JsonRpc<{ eligible: boolean @@ -98,7 +98,7 @@ const useFaucet = () => { const { data, error, isLoading } = useQuery({ ...preparedOptions, - enabled: !!address && (chainName === 'goerli' || chainName === 'sepolia'), + enabled: !!address && chainName === 'sepolia', }) const mutation = useMutation({ diff --git a/src/hooks/useProfileEditorForm.tsx b/src/hooks/useProfileEditorForm.tsx index 69e77dc6a..398421142 100644 --- a/src/hooks/useProfileEditorForm.tsx +++ b/src/hooks/useProfileEditorForm.tsx @@ -5,7 +5,7 @@ import { isEthAddressRecord, profileEditorFormToProfileRecords, profileRecordsToProfileEditorForm, -} from '@app/components/pages/profile/[name]/registration/steps/Profile/profileRecordUtils' +} from '@app/components/pages/register/steps/Profile/profileRecordUtils' import { ProfileRecord, ProfileRecordGroup } from '@app/constants/profileRecordOptions' import { supportedAddresses } from '@app/constants/supportedAddresses' import { AvatarEditorType } from '@app/types' diff --git a/src/hooks/useRegistrationParams.ts b/src/hooks/useRegistrationParams.ts index b64b4ded6..6e1aba218 100644 --- a/src/hooks/useRegistrationParams.ts +++ b/src/hooks/useRegistrationParams.ts @@ -3,14 +3,14 @@ import { Address } from 'viem' import { ChildFuseReferenceType, RegistrationParameters } from '@ensdomains/ensjs/utils' -import { profileRecordsToRecordOptions } from '@app/components/pages/profile/[name]/registration/steps/Profile/profileRecordUtils' -import { RegistrationReducerDataItem } from '@app/components/pages/profile/[name]/registration/types' +import { profileRecordsToRecordOptions } from '@app/components/pages/register/steps/Profile/profileRecordUtils' +import type { StoredRegistrationFlow } from '@app/transaction/slices/createRegistrationFlowSlice' type Props = { name: string owner: Address registrationData: Pick< - RegistrationReducerDataItem, + StoredRegistrationFlow, | 'seconds' | 'resolverAddress' | 'secret' @@ -21,28 +21,33 @@ type Props = { > } +export const getRegistrationParams = ({ + name, + owner, + registrationData, +}: Props): RegistrationParameters => { + return { + name, + owner, + duration: registrationData.seconds, + resolverAddress: registrationData.resolverAddress ?? undefined, + secret: registrationData.secret, + records: profileRecordsToRecordOptions(registrationData.records, registrationData.clearRecords), + fuses: { + named: registrationData.permissions + ? (Object.keys(registrationData.permissions).filter( + (key) => !!registrationData.permissions?.[key as ChildFuseReferenceType['Key']], + ) as ChildFuseReferenceType['Key'][]) + : [], + unnamed: [], + }, + reverseRecord: registrationData.reverseRecord, + } +} + const useRegistrationParams = ({ name, owner, registrationData }: Props) => { const registrationParams: RegistrationParameters = useMemo( - () => ({ - name, - owner, - duration: registrationData.seconds, - resolverAddress: registrationData.resolverAddress, - secret: registrationData.secret, - records: profileRecordsToRecordOptions( - registrationData.records, - registrationData.clearRecords, - ), - fuses: { - named: registrationData.permissions - ? (Object.keys(registrationData.permissions).filter( - (key) => !!registrationData.permissions?.[key as ChildFuseReferenceType['Key']], - ) as ChildFuseReferenceType['Key'][]) - : [], - unnamed: [], - }, - reverseRecord: registrationData.reverseRecord, - }), + () => getRegistrationParams({ name, owner, registrationData }), [owner, name, registrationData], ) diff --git a/src/hooks/useRegistrationReducer.ts b/src/hooks/useRegistrationReducer.ts index f91f6fd5c..cda517aa6 100644 --- a/src/hooks/useRegistrationReducer.ts +++ b/src/hooks/useRegistrationReducer.ts @@ -8,7 +8,7 @@ import { RegistrationReducerData, RegistrationReducerDataItem, SelectedItemProperties, -} from '@app/components/pages/profile/[name]/registration/types' +} from '@app/components/registration/types' import { useLocalStorageReducer } from '@app/hooks/useLocalStorage' import { yearsToSeconds } from '@app/utils/utils' diff --git a/src/hooks/useResolverEditor.ts b/src/hooks/useResolverEditor.ts index c123d36b0..13b04d097 100644 --- a/src/hooks/useResolverEditor.ts +++ b/src/hooks/useResolverEditor.ts @@ -17,8 +17,8 @@ export type Props = { } const useResolverEditor = ({ callback, resolverAddress }: Props) => { - const lastestResolverAddress = useContractAddress({ contract: 'ensPublicResolver' }) - const isResolverAddressLatest = resolverAddress === lastestResolverAddress + const latestResolverAddress = useContractAddress({ contract: 'ensPublicResolver' }) + const isResolverAddressLatest = resolverAddress === latestResolverAddress const { register, formState, handleSubmit, reset, trigger, watch, getFieldState, setValue } = useForm({ @@ -43,7 +43,7 @@ const useResolverEditor = ({ callback, resolverAddress }: Props) => { const { resolverChoice: choice, address } = values let newResolver if (choice === 'latest') { - newResolver = lastestResolverAddress + newResolver = latestResolverAddress } if (choice === 'custom') { newResolver = address @@ -61,7 +61,7 @@ const useResolverEditor = ({ callback, resolverAddress }: Props) => { const hasErrors = Object.keys(formState.errors || {}).length > 0 && resolverChoice === 'custom' return { - lastestResolverAddress, + latestResolverAddress, isResolverAddressLatest, register, handleSubmit: handleSubmit(handleResolverSubmit), diff --git a/src/hooks/verification/useVerificationOAuth/useVerificationOAuth.ts b/src/hooks/verification/useVerificationOAuth/useVerificationOAuth.ts index 3c8d33b5d..fab5f7cd0 100644 --- a/src/hooks/verification/useVerificationOAuth/useVerificationOAuth.ts +++ b/src/hooks/verification/useVerificationOAuth/useVerificationOAuth.ts @@ -5,7 +5,7 @@ import { getOwner, getRecords } from '@ensdomains/ensjs/public' import { VERIFICATION_RECORD_KEY } from '@app/constants/verification' import { useQueryOptions } from '@app/hooks/useQueryOptions' -import { VerificationProtocol } from '@app/transaction-flow/input/VerifyProfile/VerifyProfile-flow' +import { VerificationProtocol } from '@app/transaction/user/VerifyProfile/VerifyProfile-flow' import { ConfigWithEns, CreateQueryKey, QueryConfig } from '@app/types' import { prepareQueryOptions } from '@app/utils/prepareQueryOptions' diff --git a/src/hooks/verification/useVerificationOAuthHandler/useVerificationOAuthHandler.ts b/src/hooks/verification/useVerificationOAuthHandler/useVerificationOAuthHandler.ts index b23a2739a..0d6964e31 100644 --- a/src/hooks/verification/useVerificationOAuthHandler/useVerificationOAuthHandler.ts +++ b/src/hooks/verification/useVerificationOAuthHandler/useVerificationOAuthHandler.ts @@ -6,8 +6,7 @@ import { useAccount } from 'wagmi' import type { VerificationErrorDialogProps } from '@app/components/pages/VerificationErrorDialog' import { DENTITY_ISS } from '@app/constants/verification' import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' -import { VerificationProtocol } from '@app/transaction-flow/input/VerifyProfile/VerifyProfile-flow' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import type { VerificationProtocol } from '@app/transaction/user/input/VerifyProfile/VerifyProfile-flow' import { useVerificationOAuth } from '../useVerificationOAuth/useVerificationOAuth' import { dentityVerificationHandler } from './utils/dentityHandler' @@ -26,11 +25,10 @@ export const useVerificationOAuthHandler = (): UseVerificationOAuthHandlerReturn const iss = searchParams.get('iss') const code = searchParams.get('code') const router = useRouterWithHistory() - const { createTransactionFlow } = useTransactionFlow() const { address: userAddress } = useAccount() - const isReady = !!createTransactionFlow && !!router && !!userAddress && !!iss && !!code + const isReady = !!router && !!userAddress && !!iss && !!code const { data, isLoading, error } = useVerificationOAuth({ verifier: issToVerificationProtocol(iss), @@ -54,7 +52,6 @@ export const useVerificationOAuthHandler = (): UseVerificationOAuthHandlerReturn onClose, onDismiss, router, - createTransactionFlow, }), ) .otherwise(() => undefined) diff --git a/src/hooks/verification/useVerificationOAuthHandler/utils/createVerificationTransactionFlow.ts b/src/hooks/verification/useVerificationOAuthHandler/utils/createVerificationTransactionFlow.ts index 4a2dd12a0..06576dc4f 100644 --- a/src/hooks/verification/useVerificationOAuthHandler/utils/createVerificationTransactionFlow.ts +++ b/src/hooks/verification/useVerificationOAuthHandler/utils/createVerificationTransactionFlow.ts @@ -1,7 +1,6 @@ import { Hash } from 'viem' -import { createTransactionItem } from '@app/transaction-flow/transaction' -import { CreateTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { useTransactionManager } from '@app/transaction/transactionManager' import { UseVerificationOAuthReturnType } from '../../useVerificationOAuth/useVerificationOAuth' @@ -11,7 +10,6 @@ type Props = Pick< > & { userAddress?: Hash router?: any - createTransactionFlow?: CreateTransactionFlow } export const createVerificationTransactionFlow = ({ @@ -19,18 +17,21 @@ export const createVerificationTransactionFlow = ({ verifier, verifiedPresentationUri, resolverAddress, - createTransactionFlow, }: Props) => { - if (!name || !createTransactionFlow || !verifier || !verifiedPresentationUri || !resolverAddress) - return - createTransactionFlow?.(`update-verification-record-${name}`, { + if (!name || !verifier || !verifiedPresentationUri || !resolverAddress) return + + useTransactionManager.getState().startFlow({ + flowId: `update-verification-record-${name}`, transactions: [ - createTransactionItem('updateVerificationRecord', { - name, - verifier, - resolverAddress, - verifiedPresentationUri, - }), + { + name: 'updateVerificationRecord', + data: { + name, + verifier, + resolverAddress, + verifiedPresentationUri, + }, + }, ], }) } diff --git a/src/hooks/verification/useVerificationOAuthHandler/utils/dentityHandler.ts b/src/hooks/verification/useVerificationOAuthHandler/utils/dentityHandler.ts index ff609d27c..d27315c93 100644 --- a/src/hooks/verification/useVerificationOAuthHandler/utils/dentityHandler.ts +++ b/src/hooks/verification/useVerificationOAuthHandler/utils/dentityHandler.ts @@ -7,7 +7,6 @@ import { } from '@app/components/pages/VerificationErrorDialog' import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' import { getDestination } from '@app/routes' -import { CreateTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' import { UseVerificationOAuthReturnType } from '../../useVerificationOAuth/useVerificationOAuth' import { createVerificationTransactionFlow } from './createVerificationTransactionFlow' @@ -37,13 +36,11 @@ export const dentityVerificationHandler = onClose, onDismiss, router, - createTransactionFlow, }: { userAddress?: Hash onClose: () => void onDismiss: () => void router: ReturnType - createTransactionFlow: CreateTransactionFlow }) => (json: UseVerificationOAuthReturnType): VerificationErrorDialogProps => { return match(json) @@ -70,7 +67,6 @@ export const dentityVerificationHandler = resolverAddress, verifier, verifiedPresentationUri, - createTransactionFlow, }) return undefined }, diff --git a/src/hooks/verification/useVerifiedRecords/utils/makeAppendVerificationProps.ts b/src/hooks/verification/useVerifiedRecords/utils/makeAppendVerificationProps.ts index 9172c1a83..1d3650d29 100644 --- a/src/hooks/verification/useVerifiedRecords/utils/makeAppendVerificationProps.ts +++ b/src/hooks/verification/useVerifiedRecords/utils/makeAppendVerificationProps.ts @@ -1,4 +1,4 @@ -import type { VerificationProtocol } from '@app/transaction-flow/input/VerifyProfile/VerifyProfile-flow' +import type { VerificationProtocol } from '@app/transaction/user/VerifyProfile/VerifyProfile-flow' import { NormalisedAccountsRecord } from '@app/utils/records/normaliseProfileAccountsRecord' import type { UseVerifiedRecordsReturnType } from '../useVerifiedRecords' diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 4b27b1d29..a3d5267ef 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -19,8 +19,6 @@ import { TransactionDialogManager } from '@app/transaction/components/Transactio import { setupAnalytics } from '@app/utils/analytics' import { BreakpointProvider } from '@app/utils/BreakpointProvider' import { QueryProviders } from '@app/utils/query/providers' -import { SyncDroppedTransaction } from '@app/utils/SyncProvider/SyncDroppedTransaction' -import { SyncProvider } from '@app/utils/SyncProvider/SyncProvider' import i18n from '../i18n' @@ -151,14 +149,10 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { - - - - - - {getLayout()} - - + + + + {getLayout()} diff --git a/src/pages/address.tsx b/src/pages/address.tsx index c69cc37ce..07ed71b5f 100644 --- a/src/pages/address.tsx +++ b/src/pages/address.tsx @@ -4,17 +4,17 @@ import { ReactElement, useState } from 'react' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' import { Address } from 'viem' +import { useChainId } from 'wagmi' import { NameListView } from '@app/components/@molecules/NameListView/NameListView' import NoProfileSnippet from '@app/components/address/NoProfileSnippet' import { Outlink } from '@app/components/Outlink' import { ProfileSnippet } from '@app/components/ProfileSnippet' -import { useChainName } from '@app/hooks/chain/useChainName' import { usePrimaryProfile } from '@app/hooks/usePrimaryProfile' import { Content } from '@app/layouts/Content' import { ContentGrid } from '@app/layouts/ContentGrid' import { OG_IMAGE_URL } from '@app/utils/constants' -import { makeEtherscanLink, shortenAddress } from '@app/utils/utils' +import { createEtherscanLink, shortenAddress } from '@app/utils/utils' import { useAccountSafely } from '../hooks/account/useAccountSafely' @@ -51,7 +51,7 @@ const Page = () => { const descriptionContent = t('meta.description', { address }) const ogImageUrl = `${OG_IMAGE_URL}/address/${address}` - const chainName = useChainName() + const chainId = useChainId() return ( <> @@ -87,7 +87,10 @@ const Page = () => { ), titleExtra: ( - + {t('etherscan', { ns: 'common' })} ), diff --git a/src/pages/register.tsx b/src/pages/register.tsx index 82d16e95e..081726adf 100644 --- a/src/pages/register.tsx +++ b/src/pages/register.tsx @@ -1,7 +1,7 @@ import { ReactElement } from 'react' import { useAccount, useChainId } from 'wagmi' -import Registration from '@app/components/pages/profile/[name]/registration/Registration' +import Registration from '@app/components/pages/register/Registration' import { useInitial } from '@app/hooks/useInitial' import { useNameDetails } from '@app/hooks/useNameDetails' import { getSelectedIndex } from '@app/hooks/useRegistrationReducer' diff --git a/src/transaction-flow/TransactionFlowProvider.tsx b/src/transaction-flow/TransactionFlowProvider.tsx deleted file mode 100644 index 301ecc0f6..000000000 --- a/src/transaction-flow/TransactionFlowProvider.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import React, { - ComponentProps, - ReactNode, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react' -import { Hash } from 'viem' - -import { useAccountSafely } from '@app/hooks/account/useAccountSafely' -import { useLocalStorageReducer } from '@app/hooks/useLocalStorage' -import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' -import { UpdateCallback, useCallbackOnTransaction } from '@app/utils/SyncProvider/SyncProvider' - -import { TransactionDialogManager } from '../components/@molecules/TransactionDialogManager/TransactionDialogManager' -import { DataInputComponent, DataInputComponents } from './input' -import { helpers, initialState, reducer } from './reducer' -import { GenericTransaction, InternalTransactionFlow, TransactionFlowItem } from './types' - -type ShowDataInput = ( - key: string, - data: ComponentProps['data'], - options?: { - disableBackgroundClick?: boolean - }, -) => void - -type UsePreparedDataInput = (name: C) => ShowDataInput - -export type CreateTransactionFlow = (key: string, flow: TransactionFlowItem) => void - -type ProviderValue = { - usePreparedDataInput: UsePreparedDataInput - createTransactionFlow: CreateTransactionFlow - resumeTransactionFlow: (key: string) => void - getTransactionIndex: (key: string) => number - getResumable: (key: string) => boolean - getTransactionFlowStage: ( - key: string, - ) => 'undefined' | 'input' | 'intro' | 'transaction' | 'completed' - getLatestTransaction: (key: string) => GenericTransaction | undefined - stopCurrentFlow: () => void - cleanupFlow: (key: string) => void - setTransactionHashFromUpdate: (key: string, hash: Hash) => void -} - -const TransactionContext = React.createContext({ - usePreparedDataInput: () => () => {}, - createTransactionFlow: () => {}, - resumeTransactionFlow: () => {}, - getTransactionIndex: () => 0, - getResumable: () => false, - getTransactionFlowStage: () => 'undefined', - getLatestTransaction: () => undefined, - stopCurrentFlow: () => {}, - cleanupFlow: () => {}, - setTransactionHashFromUpdate: () => {}, -}) - -export const TransactionFlowProvider = ({ children }: { children: ReactNode }) => { - const router = useRouterWithHistory() - - const [state, dispatch] = useLocalStorageReducer( - 'tx-flow', - reducer, - initialState, - (current: InternalTransactionFlow) => { - const updatedItems = current.items - const { getCanRemoveItem } = helpers(current) - // eslint-disable-next-line guard-for-in - for (const key in updatedItems) { - const item = updatedItems[key] - if (getCanRemoveItem(item)) { - delete updatedItems[key] - } - } - return { - items: updatedItems, - selectedKey: null, - } - }, - ) - - const getTransactionIndex = useCallback( - (key: string) => state.items[key]?.currentTransaction || 0, - [state.items], - ) - - const getTransactionFlowStage = useCallback( - (key: string) => { - const item = state.items[key] - if (!item) return 'undefined' - if (item.currentFlowStage !== 'transaction') return item.currentFlowStage - const { transactions } = item - if (transactions.length === 0) return 'completed' - const lastTransaction = transactions[transactions.length - 1] - if (lastTransaction.stage === 'complete') return 'completed' - return 'transaction' - }, - [state.items], - ) - - const getTransaction = useCallback( - (key: string) => { - return state.items[key] - }, - [state.items], - ) - - const getResumable = useCallback( - (key: string) => { - const { getSelectedItem, getCanRemoveItem } = helpers({ - selectedKey: key, - items: state.items, - }) - const item = getSelectedItem() - if (!item) return false - if (getCanRemoveItem(item)) return false - return true - }, - [state.items], - ) - - const updateCallback = useCallback( - (transaction) => { - if (transaction.status !== 'pending' && transaction.key) { - dispatch({ - name: 'setTransactionStageFromUpdate', - payload: transaction, - }) - } - }, - [dispatch], - ) - - useCallbackOnTransaction(updateCallback) - - const getLatestTransaction = useCallback( - (key: string) => { - const { getSelectedItem } = helpers({ - selectedKey: key, - items: state.items, - }) - const item = getSelectedItem() - if (!item) return undefined - return item.transactions[item.currentTransaction] - }, - [state.items], - ) - - const setTransactionHashFromUpdate = useCallback( - (key: string, hash: Hash) => { - dispatch({ name: 'setTransactionHashFromUpdate', payload: { key, hash } }) - }, - [dispatch], - ) - - const resumeTransactionFlow = useCallback( - (key: string) => { - dispatch({ name: 'resumeFlowWithCheck', key, payload: { push: router.pushWithHistory } }) - }, - [dispatch, router.pushWithHistory], - ) - - const providerValue: ProviderValue = useMemo(() => { - return { - usePreparedDataInput: (name: C) => { - const { address } = useAccountSafely() - if (address) (DataInputComponents[name] as any).render.preload() - const func: ShowDataInput = (key, data, options = {}) => - dispatch({ - name: 'showDataInput', - payload: { - input: { name, data }, - disableBackgroundClick: options.disableBackgroundClick, - }, - key, - }) - return func - }, - createTransactionFlow: ((key, flow) => - dispatch({ - name: 'startFlow', - key, - payload: flow, - })) as CreateTransactionFlow, - resumeTransactionFlow, - getTransactionIndex, - getTransaction, - getResumable, - getTransactionFlowStage, - getLatestTransaction, - stopCurrentFlow: () => dispatch({ name: 'stopFlow' }), - cleanupFlow: (key: string) => dispatch({ name: 'forceCleanupTransaction', payload: key }), - setTransactionHashFromUpdate, - } - }, [ - dispatch, - resumeTransactionFlow, - getResumable, - getTransactionIndex, - getLatestTransaction, - getTransactionFlowStage, - getTransaction, - setTransactionHashFromUpdate, - ]) - - const [selectedKey, setSelectedKey] = useState(null) - - useEffect(() => { - let timeout: NodeJS.Timeout - if (state.selectedKey) { - setSelectedKey(state.selectedKey) - } else { - timeout = setTimeout(() => { - setSelectedKey((prev) => { - if (prev) dispatch({ name: 'cleanupTransaction', payload: prev }) - return null - }) - }, 350) - } - return () => { - clearTimeout(timeout) - } - }, [state.selectedKey, dispatch]) - - return ( - - {children} - - - ) -} - -export const useTransactionFlow = () => { - const context = useContext(TransactionContext) - return context -} diff --git a/src/transaction-flow/TransactionLoader.tsx b/src/transaction-flow/TransactionLoader.tsx deleted file mode 100644 index 84e619933..000000000 --- a/src/transaction-flow/TransactionLoader.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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-flow/input/CreateSubname-flow.tsx b/src/transaction-flow/input/CreateSubname-flow.tsx deleted file mode 100644 index a025c8aed..000000000 --- a/src/transaction-flow/input/CreateSubname-flow.tsx +++ /dev/null @@ -1,113 +0,0 @@ -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-flow/input/DeleteEmancipatedSubnameWarning/DeleteEmancipatedSubnameWarning-flow.tsx b/src/transaction-flow/input/DeleteEmancipatedSubnameWarning/DeleteEmancipatedSubnameWarning-flow.tsx deleted file mode 100644 index a305a3c90..000000000 --- a/src/transaction-flow/input/DeleteEmancipatedSubnameWarning/DeleteEmancipatedSubnameWarning-flow.tsx +++ /dev/null @@ -1,86 +0,0 @@ -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-flow/input/DeleteSubnameNotParentWarning/DeleteSubnameNotParentWarning-flow.tsx b/src/transaction-flow/input/DeleteSubnameNotParentWarning/DeleteSubnameNotParentWarning-flow.tsx deleted file mode 100644 index 0a2b91ac0..000000000 --- a/src/transaction-flow/input/DeleteSubnameNotParentWarning/DeleteSubnameNotParentWarning-flow.tsx +++ /dev/null @@ -1,100 +0,0 @@ -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-flow/input/EditResolver/EditResolver-flow.tsx b/src/transaction-flow/input/EditResolver/EditResolver-flow.tsx deleted file mode 100644 index 06da8274f..000000000 --- a/src/transaction-flow/input/EditResolver/EditResolver-flow.tsx +++ /dev/null @@ -1,77 +0,0 @@ -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-flow/input/EditRoles/EditRoles-flow.tsx b/src/transaction-flow/input/EditRoles/EditRoles-flow.tsx deleted file mode 100644 index 71c3e982b..000000000 --- a/src/transaction-flow/input/EditRoles/EditRoles-flow.tsx +++ /dev/null @@ -1,139 +0,0 @@ -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-flow/input/EditRoles/EditRoles.test.tsx b/src/transaction-flow/input/EditRoles/EditRoles.test.tsx deleted file mode 100644 index 92209f244..000000000 --- a/src/transaction-flow/input/EditRoles/EditRoles.test.tsx +++ /dev/null @@ -1,243 +0,0 @@ -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-flow/input/EditRoles/hooks/useSimpleSearch.ts b/src/transaction-flow/input/EditRoles/hooks/useSimpleSearch.ts deleted file mode 100644 index 6b8cf107d..000000000 --- a/src/transaction-flow/input/EditRoles/hooks/useSimpleSearch.ts +++ /dev/null @@ -1,112 +0,0 @@ -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-flow/input/EditRoles/views/EditRoleView/EditRoleView.tsx b/src/transaction-flow/input/EditRoles/views/EditRoleView/EditRoleView.tsx deleted file mode 100644 index a021149f6..000000000 --- a/src/transaction-flow/input/EditRoles/views/EditRoleView/EditRoleView.tsx +++ /dev/null @@ -1,116 +0,0 @@ -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-flow/input/EditRoles/views/EditRoleView/views/EditRoleIntroView.tsx b/src/transaction-flow/input/EditRoles/views/EditRoleView/views/EditRoleIntroView.tsx deleted file mode 100644 index 546b64a01..000000000 --- a/src/transaction-flow/input/EditRoles/views/EditRoleView/views/EditRoleIntroView.tsx +++ /dev/null @@ -1,106 +0,0 @@ -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-flow/input/EditRoles/views/EditRoleView/views/EditRoleResultsView.tsx b/src/transaction-flow/input/EditRoles/views/EditRoleView/views/EditRoleResultsView.tsx deleted file mode 100644 index 9eb358b09..000000000 --- a/src/transaction-flow/input/EditRoles/views/EditRoleView/views/EditRoleResultsView.tsx +++ /dev/null @@ -1,45 +0,0 @@ -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-flow/input/EditRoles/views/MainView/MainView.tsx b/src/transaction-flow/input/EditRoles/views/MainView/MainView.tsx deleted file mode 100644 index f1d4f9516..000000000 --- a/src/transaction-flow/input/EditRoles/views/MainView/MainView.tsx +++ /dev/null @@ -1,68 +0,0 @@ -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-flow/input/EditRoles/views/MainView/components/NoneSetAvatarWithIdentifier.tsx b/src/transaction-flow/input/EditRoles/views/MainView/components/NoneSetAvatarWithIdentifier.tsx deleted file mode 100644 index ed886b5dc..000000000 --- a/src/transaction-flow/input/EditRoles/views/MainView/components/NoneSetAvatarWithIdentifier.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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-flow/input/EditRoles/views/MainView/components/RoleCard.tsx b/src/transaction-flow/input/EditRoles/views/MainView/components/RoleCard.tsx deleted file mode 100644 index 2fed90b78..000000000 --- a/src/transaction-flow/input/EditRoles/views/MainView/components/RoleCard.tsx +++ /dev/null @@ -1,134 +0,0 @@ -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-flow/input/ExtendNames/ExtendNames-flow.test.tsx b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.test.tsx deleted file mode 100644 index 606bdbe4a..000000000 --- a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.test.tsx +++ /dev/null @@ -1,182 +0,0 @@ -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-flow/input/ExtendNames/ExtendNames-flow.tsx b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx deleted file mode 100644 index 723d375d6..000000000 --- a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx +++ /dev/null @@ -1,398 +0,0 @@ -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-flow/input/ProfileEditor/ProfileEditor.test.tsx b/src/transaction-flow/input/ProfileEditor/ProfileEditor.test.tsx deleted file mode 100644 index ed1a26542..000000000 --- a/src/transaction-flow/input/ProfileEditor/ProfileEditor.test.tsx +++ /dev/null @@ -1,734 +0,0 @@ -/* 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-flow/input/ProfileEditor/ResolverWarningOverlay.tsx b/src/transaction-flow/input/ProfileEditor/ResolverWarningOverlay.tsx deleted file mode 100644 index 1684bbf29..000000000 --- a/src/transaction-flow/input/ProfileEditor/ResolverWarningOverlay.tsx +++ /dev/null @@ -1,275 +0,0 @@ -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-flow/input/ProfileEditor/WrappedAvatarButton.tsx b/src/transaction-flow/input/ProfileEditor/WrappedAvatarButton.tsx deleted file mode 100644 index 69f17ff0f..000000000 --- a/src/transaction-flow/input/ProfileEditor/WrappedAvatarButton.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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-flow/input/ProfileEditor/components/CenteredTypography.tsx b/src/transaction-flow/input/ProfileEditor/components/CenteredTypography.tsx deleted file mode 100644 index a2b2515d6..000000000 --- a/src/transaction-flow/input/ProfileEditor/components/CenteredTypography.tsx +++ /dev/null @@ -1,9 +0,0 @@ -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-flow/input/ProfileEditor/components/ContentContainer.tsx b/src/transaction-flow/input/ProfileEditor/components/ContentContainer.tsx deleted file mode 100644 index ff5652799..000000000 --- a/src/transaction-flow/input/ProfileEditor/components/ContentContainer.tsx +++ /dev/null @@ -1,9 +0,0 @@ -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-flow/input/ProfileEditor/components/DetailedSwitch.tsx b/src/transaction-flow/input/ProfileEditor/components/DetailedSwitch.tsx deleted file mode 100644 index 73d384d57..000000000 --- a/src/transaction-flow/input/ProfileEditor/components/DetailedSwitch.tsx +++ /dev/null @@ -1,45 +0,0 @@ -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-flow/input/ProfileEditor/components/ProfileBlurb.tsx b/src/transaction-flow/input/ProfileEditor/components/ProfileBlurb.tsx deleted file mode 100644 index 787c9129d..000000000 --- a/src/transaction-flow/input/ProfileEditor/components/ProfileBlurb.tsx +++ /dev/null @@ -1,78 +0,0 @@ -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-flow/input/ProfileEditor/components/SkipButton.tsx b/src/transaction-flow/input/ProfileEditor/components/SkipButton.tsx deleted file mode 100644 index 4961c5ee3..000000000 --- a/src/transaction-flow/input/ProfileEditor/components/SkipButton.tsx +++ /dev/null @@ -1,58 +0,0 @@ -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-flow/input/ProfileEditor/views/InvalidResolverView.tsx b/src/transaction-flow/input/ProfileEditor/views/InvalidResolverView.tsx deleted file mode 100644 index 400164367..000000000 --- a/src/transaction-flow/input/ProfileEditor/views/InvalidResolverView.tsx +++ /dev/null @@ -1,48 +0,0 @@ -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-flow/input/ProfileEditor/views/MigrateProfileSelectorView.tsx.tsx b/src/transaction-flow/input/ProfileEditor/views/MigrateProfileSelectorView.tsx.tsx deleted file mode 100644 index 1d34500a3..000000000 --- a/src/transaction-flow/input/ProfileEditor/views/MigrateProfileSelectorView.tsx.tsx +++ /dev/null @@ -1,144 +0,0 @@ -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-flow/input/ProfileEditor/views/MigrateProfileWarningView.tsx b/src/transaction-flow/input/ProfileEditor/views/MigrateProfileWarningView.tsx deleted file mode 100644 index 74618ef00..000000000 --- a/src/transaction-flow/input/ProfileEditor/views/MigrateProfileWarningView.tsx +++ /dev/null @@ -1,43 +0,0 @@ -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-flow/input/ProfileEditor/views/MigrateRegistryView.tsx b/src/transaction-flow/input/ProfileEditor/views/MigrateRegistryView.tsx deleted file mode 100644 index 7ee1f37a8..000000000 --- a/src/transaction-flow/input/ProfileEditor/views/MigrateRegistryView.tsx +++ /dev/null @@ -1,47 +0,0 @@ -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-flow/input/ProfileEditor/views/NoResolverView.tsx b/src/transaction-flow/input/ProfileEditor/views/NoResolverView.tsx deleted file mode 100644 index d5dab6007..000000000 --- a/src/transaction-flow/input/ProfileEditor/views/NoResolverView.tsx +++ /dev/null @@ -1,48 +0,0 @@ -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-flow/input/ProfileEditor/views/ResetProfileView.tsx b/src/transaction-flow/input/ProfileEditor/views/ResetProfileView.tsx deleted file mode 100644 index d3ec21b61..000000000 --- a/src/transaction-flow/input/ProfileEditor/views/ResetProfileView.tsx +++ /dev/null @@ -1,42 +0,0 @@ -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-flow/input/ProfileEditor/views/ResolverNotNameWrapperAwareView.tsx b/src/transaction-flow/input/ProfileEditor/views/ResolverNotNameWrapperAwareView.tsx deleted file mode 100644 index 0b2c6fdbf..000000000 --- a/src/transaction-flow/input/ProfileEditor/views/ResolverNotNameWrapperAwareView.tsx +++ /dev/null @@ -1,72 +0,0 @@ -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-flow/input/ProfileEditor/views/ResolverOutOfDateView.tsx b/src/transaction-flow/input/ProfileEditor/views/ResolverOutOfDateView.tsx deleted file mode 100644 index 7a407f914..000000000 --- a/src/transaction-flow/input/ProfileEditor/views/ResolverOutOfDateView.tsx +++ /dev/null @@ -1,56 +0,0 @@ -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-flow/input/ProfileEditor/views/ResolverOutOfSyncView.tsx b/src/transaction-flow/input/ProfileEditor/views/ResolverOutOfSyncView.tsx deleted file mode 100644 index 3361dedd4..000000000 --- a/src/transaction-flow/input/ProfileEditor/views/ResolverOutOfSyncView.tsx +++ /dev/null @@ -1,56 +0,0 @@ -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-flow/input/ProfileEditor/views/TransferOrResetProfileView.tsx b/src/transaction-flow/input/ProfileEditor/views/TransferOrResetProfileView.tsx deleted file mode 100644 index 9ff00a551..000000000 --- a/src/transaction-flow/input/ProfileEditor/views/TransferOrResetProfileView.tsx +++ /dev/null @@ -1,59 +0,0 @@ -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-flow/input/ProfileEditor/views/UpdateResolverOrResetProfileView.tsx b/src/transaction-flow/input/ProfileEditor/views/UpdateResolverOrResetProfileView.tsx deleted file mode 100644 index 66f924252..000000000 --- a/src/transaction-flow/input/ProfileEditor/views/UpdateResolverOrResetProfileView.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/** 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-flow/input/ResetPrimaryName/ResetPrimaryName-flow.tsx b/src/transaction-flow/input/ResetPrimaryName/ResetPrimaryName-flow.tsx deleted file mode 100644 index d9aa797c9..000000000 --- a/src/transaction-flow/input/ResetPrimaryName/ResetPrimaryName-flow.tsx +++ /dev/null @@ -1,59 +0,0 @@ -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-flow/input/RevokePermissions/RevokePermissions-flow.tsx b/src/transaction-flow/input/RevokePermissions/RevokePermissions-flow.tsx deleted file mode 100644 index 9e79b1b34..000000000 --- a/src/transaction-flow/input/RevokePermissions/RevokePermissions-flow.tsx +++ /dev/null @@ -1,408 +0,0 @@ -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-flow/input/RevokePermissions/RevokePermissions.test.tsx b/src/transaction-flow/input/RevokePermissions/RevokePermissions.test.tsx deleted file mode 100644 index 54103c074..000000000 --- a/src/transaction-flow/input/RevokePermissions/RevokePermissions.test.tsx +++ /dev/null @@ -1,713 +0,0 @@ -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-flow/input/RevokePermissions/components/CenterAlignedTypography.tsx b/src/transaction-flow/input/RevokePermissions/components/CenterAlignedTypography.tsx deleted file mode 100644 index 7d8f9ba70..000000000 --- a/src/transaction-flow/input/RevokePermissions/components/CenterAlignedTypography.tsx +++ /dev/null @@ -1,9 +0,0 @@ -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-flow/input/RevokePermissions/components/ControlledNextButton.tsx b/src/transaction-flow/input/RevokePermissions/components/ControlledNextButton.tsx deleted file mode 100644 index 2b647867e..000000000 --- a/src/transaction-flow/input/RevokePermissions/components/ControlledNextButton.tsx +++ /dev/null @@ -1,168 +0,0 @@ -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-flow/input/SelectPrimaryName/SelectPrimaryName.test.tsx b/src/transaction-flow/input/SelectPrimaryName/SelectPrimaryName.test.tsx deleted file mode 100644 index 784dcaa86..000000000 --- a/src/transaction-flow/input/SelectPrimaryName/SelectPrimaryName.test.tsx +++ /dev/null @@ -1,330 +0,0 @@ -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-flow/input/SelectPrimaryName/components/TaggedNameItemWithFuseCheck.test.tsx b/src/transaction-flow/input/SelectPrimaryName/components/TaggedNameItemWithFuseCheck.test.tsx deleted file mode 100644 index 572f432e5..000000000 --- a/src/transaction-flow/input/SelectPrimaryName/components/TaggedNameItemWithFuseCheck.test.tsx +++ /dev/null @@ -1,147 +0,0 @@ -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-flow/input/SelectPrimaryName/components/TaggedNameItemWithFuseCheck.tsx b/src/transaction-flow/input/SelectPrimaryName/components/TaggedNameItemWithFuseCheck.tsx deleted file mode 100644 index e1f08ce22..000000000 --- a/src/transaction-flow/input/SelectPrimaryName/components/TaggedNameItemWithFuseCheck.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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-flow/input/SendName/SendName-flow.tsx b/src/transaction-flow/input/SendName/SendName-flow.tsx deleted file mode 100644 index d8b4372ae..000000000 --- a/src/transaction-flow/input/SendName/SendName-flow.tsx +++ /dev/null @@ -1,153 +0,0 @@ -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-flow/input/SendName/SendName.test.tsx b/src/transaction-flow/input/SendName/SendName.test.tsx deleted file mode 100644 index ad7703403..000000000 --- a/src/transaction-flow/input/SendName/SendName.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -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-flow/input/SendName/utils/checkCanSend.ts b/src/transaction-flow/input/SendName/utils/checkCanSend.ts deleted file mode 100644 index d8a22cfe9..000000000 --- a/src/transaction-flow/input/SendName/utils/checkCanSend.ts +++ /dev/null @@ -1,58 +0,0 @@ -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-flow/input/SendName/utils/getSendNameTransactions.test.ts b/src/transaction-flow/input/SendName/utils/getSendNameTransactions.test.ts deleted file mode 100644 index 579648951..000000000 --- a/src/transaction-flow/input/SendName/utils/getSendNameTransactions.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -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-flow/input/SendName/utils/getSendNameTransactions.ts b/src/transaction-flow/input/SendName/utils/getSendNameTransactions.ts deleted file mode 100644 index b721efa9c..000000000 --- a/src/transaction-flow/input/SendName/utils/getSendNameTransactions.ts +++ /dev/null @@ -1,72 +0,0 @@ -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-flow/input/SendName/views/CannotSendView.tsx b/src/transaction-flow/input/SendName/views/CannotSendView.tsx deleted file mode 100644 index 426650215..000000000 --- a/src/transaction-flow/input/SendName/views/CannotSendView.tsx +++ /dev/null @@ -1,31 +0,0 @@ -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-flow/input/SendName/views/ConfirmationView.tsx b/src/transaction-flow/input/SendName/views/ConfirmationView.tsx deleted file mode 100644 index d7b1cadf6..000000000 --- a/src/transaction-flow/input/SendName/views/ConfirmationView.tsx +++ /dev/null @@ -1,102 +0,0 @@ -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-flow/input/SendName/views/SearchView/SearchView.tsx b/src/transaction-flow/input/SendName/views/SearchView/SearchView.tsx deleted file mode 100644 index f56d37850..000000000 --- a/src/transaction-flow/input/SendName/views/SearchView/SearchView.tsx +++ /dev/null @@ -1,92 +0,0 @@ -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-flow/input/SendName/views/SearchView/components/SearchViewResult.tsx b/src/transaction-flow/input/SendName/views/SearchView/components/SearchViewResult.tsx deleted file mode 100644 index 0720e0ab0..000000000 --- a/src/transaction-flow/input/SendName/views/SearchView/components/SearchViewResult.tsx +++ /dev/null @@ -1,97 +0,0 @@ -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-flow/input/SendName/views/SearchView/views/SearchViewErrorView.tsx b/src/transaction-flow/input/SendName/views/SearchView/views/SearchViewErrorView.tsx deleted file mode 100644 index bb3769544..000000000 --- a/src/transaction-flow/input/SendName/views/SearchView/views/SearchViewErrorView.tsx +++ /dev/null @@ -1,46 +0,0 @@ -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-flow/input/SendName/views/SearchView/views/SearchViewIntroView.tsx b/src/transaction-flow/input/SendName/views/SearchView/views/SearchViewIntroView.tsx deleted file mode 100644 index 4940fea4b..000000000 --- a/src/transaction-flow/input/SendName/views/SearchView/views/SearchViewIntroView.tsx +++ /dev/null @@ -1,42 +0,0 @@ -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-flow/input/SendName/views/SearchView/views/SearchViewLoadingView.tsx b/src/transaction-flow/input/SendName/views/SearchView/views/SearchViewLoadingView.tsx deleted file mode 100644 index dd4118815..000000000 --- a/src/transaction-flow/input/SendName/views/SearchView/views/SearchViewLoadingView.tsx +++ /dev/null @@ -1,22 +0,0 @@ -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-flow/input/SendName/views/SearchView/views/SearchViewNoResultsView.tsx b/src/transaction-flow/input/SendName/views/SearchView/views/SearchViewNoResultsView.tsx deleted file mode 100644 index cc5245ed0..000000000 --- a/src/transaction-flow/input/SendName/views/SearchView/views/SearchViewNoResultsView.tsx +++ /dev/null @@ -1,44 +0,0 @@ -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-flow/input/SendName/views/SearchView/views/SearchViewResultsView.tsx b/src/transaction-flow/input/SendName/views/SearchView/views/SearchViewResultsView.tsx deleted file mode 100644 index fe9871f80..000000000 --- a/src/transaction-flow/input/SendName/views/SearchView/views/SearchViewResultsView.tsx +++ /dev/null @@ -1,41 +0,0 @@ -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-flow/input/SendName/views/SummaryView/SummaryView.tsx b/src/transaction-flow/input/SendName/views/SummaryView/SummaryView.tsx deleted file mode 100644 index d848fe0f2..000000000 --- a/src/transaction-flow/input/SendName/views/SummaryView/SummaryView.tsx +++ /dev/null @@ -1,94 +0,0 @@ -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-flow/input/SendName/views/SummaryView/components/SummarySection.tsx b/src/transaction-flow/input/SendName/views/SummaryView/components/SummarySection.tsx deleted file mode 100644 index e168e5931..000000000 --- a/src/transaction-flow/input/SendName/views/SummaryView/components/SummarySection.tsx +++ /dev/null @@ -1,47 +0,0 @@ -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-flow/input/SyncManager/SyncManager-flow.tsx b/src/transaction-flow/input/SyncManager/SyncManager-flow.tsx deleted file mode 100644 index e91a5cf69..000000000 --- a/src/transaction-flow/input/SyncManager/SyncManager-flow.tsx +++ /dev/null @@ -1,126 +0,0 @@ -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-flow/input/SyncManager/utils/checkCanSyncManager.ts b/src/transaction-flow/input/SyncManager/utils/checkCanSyncManager.ts deleted file mode 100644 index 713d44482..000000000 --- a/src/transaction-flow/input/SyncManager/utils/checkCanSyncManager.ts +++ /dev/null @@ -1,53 +0,0 @@ -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-flow/input/SyncManager/views/ErrorView.tsx b/src/transaction-flow/input/SyncManager/views/ErrorView.tsx deleted file mode 100644 index 4b7b61dd0..000000000 --- a/src/transaction-flow/input/SyncManager/views/ErrorView.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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-flow/input/SyncManager/views/MainView.tsx b/src/transaction-flow/input/SyncManager/views/MainView.tsx deleted file mode 100644 index 0ee9dbb81..000000000 --- a/src/transaction-flow/input/SyncManager/views/MainView.tsx +++ /dev/null @@ -1,41 +0,0 @@ -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-flow/input/UnknownLabels/UnknownLabels-flow.tsx b/src/transaction-flow/input/UnknownLabels/UnknownLabels-flow.tsx deleted file mode 100644 index e6fa9d93a..000000000 --- a/src/transaction-flow/input/UnknownLabels/UnknownLabels-flow.tsx +++ /dev/null @@ -1,96 +0,0 @@ -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-flow/input/UnknownLabels/UnknownLabels.test.tsx b/src/transaction-flow/input/UnknownLabels/UnknownLabels.test.tsx deleted file mode 100644 index 396df44db..000000000 --- a/src/transaction-flow/input/UnknownLabels/UnknownLabels.test.tsx +++ /dev/null @@ -1,294 +0,0 @@ -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-flow/input/UnknownLabels/views/UnknownLabelsForm.tsx b/src/transaction-flow/input/UnknownLabels/views/UnknownLabelsForm.tsx deleted file mode 100644 index 3041353c9..000000000 --- a/src/transaction-flow/input/UnknownLabels/views/UnknownLabelsForm.tsx +++ /dev/null @@ -1,171 +0,0 @@ -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-flow/input/VerifyProfile/VerifyProfile-flow.tsx b/src/transaction-flow/input/VerifyProfile/VerifyProfile-flow.tsx deleted file mode 100644 index a3fd6eedc..000000000 --- a/src/transaction-flow/input/VerifyProfile/VerifyProfile-flow.tsx +++ /dev/null @@ -1,78 +0,0 @@ -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-flow/input/VerifyProfile/components/VerificationOptionButton.tsx b/src/transaction-flow/input/VerifyProfile/components/VerificationOptionButton.tsx deleted file mode 100644 index b3e039843..000000000 --- a/src/transaction-flow/input/VerifyProfile/components/VerificationOptionButton.tsx +++ /dev/null @@ -1,72 +0,0 @@ -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-flow/input/VerifyProfile/utils/createDentityUrl.ts b/src/transaction-flow/input/VerifyProfile/utils/createDentityUrl.ts deleted file mode 100644 index 3d754a64b..000000000 --- a/src/transaction-flow/input/VerifyProfile/utils/createDentityUrl.ts +++ /dev/null @@ -1,25 +0,0 @@ -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-flow/input/VerifyProfile/views/DentityView.tsx b/src/transaction-flow/input/VerifyProfile/views/DentityView.tsx deleted file mode 100644 index 768702948..000000000 --- a/src/transaction-flow/input/VerifyProfile/views/DentityView.tsx +++ /dev/null @@ -1,127 +0,0 @@ -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-flow/input/VerifyProfile/views/VerificationOptionsList.tsx b/src/transaction-flow/input/VerifyProfile/views/VerificationOptionsList.tsx deleted file mode 100644 index 85f167bbb..000000000 --- a/src/transaction-flow/input/VerifyProfile/views/VerificationOptionsList.tsx +++ /dev/null @@ -1,91 +0,0 @@ -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-flow/input/index.tsx b/src/transaction-flow/input/index.tsx deleted file mode 100644 index 4981b2402..000000000 --- a/src/transaction-flow/input/index.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import dynamic from 'next/dynamic' -import { useContext, useEffect } from 'react' - -import DynamicLoadingContext from '@app/components/@molecules/TransactionDialogManager/DynamicLoadingContext' - -import TransactionLoader from '../TransactionLoader' -import type { Props as AdvancedEditorProps } from './AdvancedEditor/AdvancedEditor-flow' -import type { Props as CreateSubnameProps } from './CreateSubname-flow' -import type { Props as DeleteEmancipatedSubnameWarningProps } from './DeleteEmancipatedSubnameWarning/DeleteEmancipatedSubnameWarning-flow' -import type { Props as DeleteSubnameNotParentWarningProps } from './DeleteSubnameNotParentWarning/DeleteSubnameNotParentWarning-flow' -import type { Props as EditResolverProps } from './EditResolver/EditResolver-flow' -import type { Props as EditRolesProps } from './EditRoles/EditRoles-flow' -import type { Props as ExtendNamesProps } from './ExtendNames/ExtendNames-flow' -import type { Props as ProfileEditorProps } from './ProfileEditor/ProfileEditor-flow' -import type { Props as ResetPrimaryNameProps } from './ResetPrimaryName/ResetPrimaryName-flow' -import type { Props as RevokePermissionsProps } from './RevokePermissions/RevokePermissions-flow' -import type { Props as SelectPrimaryNameProps } from './SelectPrimaryName/SelectPrimaryName-flow' -import type { Props as SendNameProps } from './SendName/SendName-flow' -import type { Props as SyncManagerProps } from './SyncManager/SyncManager-flow' -import type { Props as UnknownLabelsProps } from './UnknownLabels/UnknownLabels-flow' -import type { Props as VerifyProfileProps } from './VerifyProfile/VerifyProfile-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') -const CreateSubname = dynamicHelper('CreateSubname') -const DeleteEmancipatedSubnameWarning = dynamicHelper( - 'DeleteEmancipatedSubnameWarning/DeleteEmancipatedSubnameWarning', -) -const DeleteSubnameNotParentWarning = dynamicHelper( - 'DeleteSubnameNotParentWarning/DeleteSubnameNotParentWarning', -) -const EditResolver = dynamicHelper('EditResolver/EditResolver') -const EditRoles = dynamicHelper('EditRoles/EditRoles') -const ExtendNames = dynamicHelper('ExtendNames/ExtendNames') -const ProfileEditor = dynamicHelper('ProfileEditor/ProfileEditor') -const ResetPrimaryName = dynamicHelper('ResetPrimaryName/ResetPrimaryName') -const RevokePermissions = dynamicHelper( - 'RevokePermissions/RevokePermissions', -) -const SelectPrimaryName = dynamicHelper( - 'SelectPrimaryName/SelectPrimaryName', -) -const SendName = dynamicHelper('SendName/SendName') -const SyncManager = dynamicHelper('SyncManager/SyncManager') -const UnknownLabels = dynamicHelper('UnknownLabels/UnknownLabels') -const VerifyProfile = dynamicHelper('VerifyProfile/VerifyProfile') - -export const DataInputComponents = { - AdvancedEditor, - CreateSubname, - DeleteEmancipatedSubnameWarning, - DeleteSubnameNotParentWarning, - EditResolver, - EditRoles, - ExtendNames, - ProfileEditor, - ResetPrimaryName, - RevokePermissions, - SelectPrimaryName, - SendName, - SyncManager, - UnknownLabels, - VerifyProfile, -} - -export type DataInputName = keyof typeof DataInputComponents - -export type DataInputComponent = typeof DataInputComponents diff --git a/src/transaction-flow/intro/index.ts b/src/transaction-flow/intro/index.ts deleted file mode 100644 index 5f1de3691..000000000 --- a/src/transaction-flow/intro/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ComponentProps } from 'react' - -import { ChangePrimaryName } from './ChangePrimaryName' -import { GenericWithDescription } from './GenericWithDescription' -import { MigrateAndUpdateResolver } from './MigrateAndUpdateResolver' -import { SyncManager } from './SyncManager' -import { WrapName } from './WrapName' - -export const intros = { - WrapName, - MigrateAndUpdateResolver, - SyncManager, - ChangePrimaryName, - GenericWithDescription, -} - -export type IntroComponent = typeof intros -export type IntroComponentName = keyof IntroComponent - -export const makeIntroItem = ( - name: I, - data: ComponentProps, -) => ({ - name, - data, -}) diff --git a/src/transaction-flow/reducer.test.ts b/src/transaction-flow/reducer.test.ts deleted file mode 100644 index 32e32ef98..000000000 --- a/src/transaction-flow/reducer.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { reducer } from './reducer' -import { InternalTransactionFlow, TransactionFlowAction } from './types' - -describe('reducer', () => { - it('should not break if resumeFlowWithCheck is called with item', () => { - const mockPush = vi.fn() - const action = { - name: 'resumeFlowWithCheck', - key: 'key', - payload: { - push: mockPush, - }, - } as TransactionFlowAction - const draft = { - items: { - key: { - resumeLink: 'resumeLink', - transactions: [{ hash: 'hash', stage: 'complete' }], - }, - }, - } as any - reducer(draft, action) - expect(mockPush).toHaveBeenCalled() - }) - it('should break if resumeFlowWithCheck is called wihout item', () => { - const mockPush = vi.fn() - const action = { - name: 'resumeFlowWithCheck', - key: 'key', - payload: { - push: mockPush, - }, - } as TransactionFlowAction - const draft = { - items: { - otherKey: { - resumeLink: 'resumeLink', - transactions: [{ hash: 'hash', stage: 'complete' }], - }, - }, - } as any - reducer(draft, action) - expect(mockPush).not.toHaveBeenCalled() - }) - it('should not break if resumeFlow is called with item', () => { - const mockPush = vi.fn() - const action = { - name: 'resumeFlow', - key: 'key', - payload: { - push: mockPush, - }, - } as TransactionFlowAction - const draft = { - selectedKey: '', - items: { - key: { - intro: true, - currentFlowStage: '', - }, - }, - } as any - reducer(draft, action) - expect(draft.selectedKey).toEqual('key') - }) - it('should break if resumeFlow is called wihout item', () => { - const mockPush = vi.fn() - const action = { - name: 'resumeFlow', - key: 'key', - payload: { - push: mockPush, - }, - } as TransactionFlowAction - const draft = { - selectedKey: '', - items: { - otherkey: { - intro: true, - currentFlowStage: '', - }, - }, - } as any - reducer(draft, action) - expect(draft.selectedKey).toEqual('') - }) - it('should update existing transaction item for repriced transaction', () => { - const action: TransactionFlowAction = { - name: 'setTransactionStageFromUpdate', - payload: { - hash: 'hash' as any, - key: 'key', - action: 'action', - status: 'repriced', - minedData: { - timestamp: 1000, - } as any, - newHash: 'newHash' as any, - searchRetries: 0, - }, - } - const draft: InternalTransactionFlow = { - selectedKey: '', - items: { - key: { - transactions: [{ name: 'testSendName', hash: 'hash' as any, stage: 'sent', data: {} }], - currentTransaction: 0, - currentFlowStage: 'transaction', - }, - }, - } - reducer(draft, action) - - const transaction = draft.items.key.transactions[0] - expect(transaction.hash).toEqual('newHash') - expect(transaction.stage).toEqual('sent') - }) -}) diff --git a/src/transaction-flow/reducer.ts b/src/transaction-flow/reducer.ts deleted file mode 100644 index 585dbf0c5..000000000 --- a/src/transaction-flow/reducer.ts +++ /dev/null @@ -1,199 +0,0 @@ -/* eslint-disable default-case */ - -/* eslint-disable no-param-reassign */ -import { - InternalTransactionFlow, - InternalTransactionFlowItem, - TransactionFlowAction, - TransactionFlowStage, -} from './types' - -export const initialState: InternalTransactionFlow = { - selectedKey: null, - items: {}, -} - -export const helpers = (draft: InternalTransactionFlow) => { - const getSelectedItem = () => draft.items[draft.selectedKey!] - const getCurrentTransaction = (item: InternalTransactionFlowItem) => - item.transactions[item.currentTransaction] - const getAllTransactionsComplete = (item: InternalTransactionFlowItem) => - item.transactions.every(({ hash, stage }) => hash && stage === 'complete') - const getNoTransactionsStarted = (item: InternalTransactionFlowItem) => - item.transactions.every(({ stage }) => !stage || stage === 'confirm') - const getCanRemoveItem = (item: InternalTransactionFlowItem) => - item.requiresManualCleanup - ? false - : !item.transactions || - !item.resumable || - getAllTransactionsComplete(item) || - getNoTransactionsStarted(item) - - return { - getSelectedItem, - getCurrentTransaction, - getAllTransactionsComplete, - getCanRemoveItem, - } -} - -export const reducer = (draft: InternalTransactionFlow, action: TransactionFlowAction) => { - const { getSelectedItem, getCurrentTransaction, getAllTransactionsComplete } = helpers(draft) - - switch (action.name) { - case 'showDataInput': { - draft.items[action.key] = { - currentFlowStage: 'input', - currentTransaction: 0, - input: action.payload.input, - disableBackgroundClick: action.payload.disableBackgroundClick || undefined, - transactions: [], - } - draft.selectedKey = action.key - break - } - case 'startFlow': { - let currentFlowStage: TransactionFlowStage = 'transaction' - if (action.payload.intro) { - currentFlowStage = 'intro' - } - if (action.payload.input) { - currentFlowStage = 'input' - } - draft.items[action.key] = { - ...action.payload, - currentTransaction: 0, - currentFlowStage, - } - draft.selectedKey = action.key - break - } - case 'resumeFlowWithCheck': { - const { - key, - payload: { push }, - } = action - const item = draft.items[key] - if (!item) break // item no longer exists because transactions were completed - if (item.resumeLink && getAllTransactionsComplete(item)) { - push(item.resumeLink) - break - } - // falls through - } - case 'resumeFlow': { - const { key } = action - const item = draft.items[key] - if (!item) break // item no longer exists because transactions were completed - if (item.intro) { - item.currentFlowStage = 'intro' - } - draft.items[key] = item - draft.selectedKey = key - break - } - case 'setTransactions': { - getSelectedItem().transactions = action.payload - break - } - case 'setFlowStage': { - getSelectedItem().currentFlowStage = action.payload - break - } - case 'stopFlow': { - draft.selectedKey = null - break - } - case 'setFailedTransaction': { - if (!action.payload.key) { - console.error('No key provided for setFailedTransaction') - break - } - const transaction = draft.items[action.payload.key].transactions.find( - (x) => x.hash === action.payload.hash, - ) - if (!transaction) { - console.error('No transaction found for setFailedTransaction') - break - } - transaction.stage = 'failed' - break - } - case 'incrementTransaction': { - getSelectedItem().currentTransaction += 1 - break - } - case 'resetTransactionStep': { - getSelectedItem().currentTransaction = 0 - break - } - case 'setTransactionStage': { - const selectedItem = getSelectedItem() - if (!selectedItem) break - const currentTransaction = getCurrentTransaction(selectedItem) - - currentTransaction.stage = action.payload - break - } - case 'setTransactionHash': { - const { hash, key } = action.payload - const selectedItem = draft.items[key] - if (!selectedItem) break - const currentTransaction = getCurrentTransaction(selectedItem) - - currentTransaction.hash = hash - currentTransaction.stage = 'sent' - currentTransaction.sendTime = Date.now() - break - } - case 'setTransactionHashFromUpdate': { - const { hash, key } = action.payload - const selectedItem = draft.items[key!] - if (!selectedItem) break - const currentTransaction = getCurrentTransaction(selectedItem) || selectedItem.transactions[0] - currentTransaction.hash = hash - currentTransaction.stage = 'sent' - currentTransaction.sendTime = Date.now() - break - } - case 'setTransactionStageFromUpdate': { - const { hash, key, status, minedData, newHash } = action.payload - - const selectedItem = draft.items[key!] - if (!selectedItem) break - const transaction = selectedItem.transactions.find((x) => x.hash === hash) - - if (transaction) { - if (status === 'repriced') { - transaction.hash = newHash - transaction.stage = 'sent' - break - } - const stage = status === 'confirmed' ? 'complete' : 'failed' - transaction.stage = stage - transaction.minedData = minedData - transaction.finaliseTime = minedData?.timestamp - if ( - key === draft.selectedKey && - selectedItem.autoClose && - getAllTransactionsComplete(selectedItem) - ) { - draft.selectedKey = null - } - } - break - } - case 'forceCleanupTransaction': - case 'cleanupTransaction': { - const selectedItem = draft.items[action.payload] - if ( - selectedItem && - (!selectedItem.requiresManualCleanup || action.name === 'forceCleanupTransaction') && - (!selectedItem.resumable || getAllTransactionsComplete(selectedItem)) - ) { - delete draft.items[action.payload] - } - break - } - } -} diff --git a/src/transaction-flow/transaction/approveDnsRegistrar.ts b/src/transaction-flow/transaction/approveDnsRegistrar.ts deleted file mode 100644 index 61df14fb7..000000000 --- a/src/transaction-flow/transaction/approveDnsRegistrar.ts +++ /dev/null @@ -1,66 +0,0 @@ -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-flow/transaction/approveNameWrapper.ts b/src/transaction-flow/transaction/approveNameWrapper.ts deleted file mode 100644 index 2c07ec544..000000000 --- a/src/transaction-flow/transaction/approveNameWrapper.ts +++ /dev/null @@ -1,70 +0,0 @@ -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-flow/transaction/burnFuses.ts b/src/transaction-flow/transaction/burnFuses.ts deleted file mode 100644 index cb7c5e15a..000000000 --- a/src/transaction-flow/transaction/burnFuses.ts +++ /dev/null @@ -1,47 +0,0 @@ -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-flow/transaction/changePermissions.ts b/src/transaction-flow/transaction/changePermissions.ts deleted file mode 100644 index 7f4f05252..000000000 --- a/src/transaction-flow/transaction/changePermissions.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* 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-flow/transaction/claimDnsName.ts b/src/transaction-flow/transaction/claimDnsName.ts deleted file mode 100644 index 372903d55..000000000 --- a/src/transaction-flow/transaction/claimDnsName.ts +++ /dev/null @@ -1,30 +0,0 @@ -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-flow/transaction/commitName.ts b/src/transaction-flow/transaction/commitName.ts deleted file mode 100644 index b3cb4167a..000000000 --- a/src/transaction-flow/transaction/commitName.ts +++ /dev/null @@ -1,33 +0,0 @@ -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-flow/transaction/createSubname.ts b/src/transaction-flow/transaction/createSubname.ts deleted file mode 100644 index 5110d42bc..000000000 --- a/src/transaction-flow/transaction/createSubname.ts +++ /dev/null @@ -1,43 +0,0 @@ -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-flow/transaction/deleteSubname.ts b/src/transaction-flow/transaction/deleteSubname.ts deleted file mode 100644 index 9ee04a12a..000000000 --- a/src/transaction-flow/transaction/deleteSubname.ts +++ /dev/null @@ -1,43 +0,0 @@ -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-flow/transaction/extendNames.ts b/src/transaction-flow/transaction/extendNames.ts deleted file mode 100644 index ac2ff598a..000000000 --- a/src/transaction-flow/transaction/extendNames.ts +++ /dev/null @@ -1,68 +0,0 @@ -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-flow/transaction/importDnsName.ts b/src/transaction-flow/transaction/importDnsName.ts deleted file mode 100644 index 63982b51f..000000000 --- a/src/transaction-flow/transaction/importDnsName.ts +++ /dev/null @@ -1,30 +0,0 @@ -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-flow/transaction/index.ts b/src/transaction-flow/transaction/index.ts deleted file mode 100644 index 8f63e348e..000000000 --- a/src/transaction-flow/transaction/index.ts +++ /dev/null @@ -1,101 +0,0 @@ -import approveDnsRegistrar from './approveDnsRegistrar' -import approveNameWrapper from './approveNameWrapper' -import burnFuses from './burnFuses' -import changePermissions from './changePermissions' -import claimDnsName from './claimDnsName' -import commitName from './commitName' -import createSubname from './createSubname' -import deleteSubname from './deleteSubname' -import extendNames from './extendNames' -import importDnsName from './importDnsName' -import migrateProfile from './migrateProfile' -import migrateProfileWithReset from './migrateProfileWithReset' -import registerName from './registerName' -import removeVerificationRecord from './removeVerificationRecord' -import resetPrimaryName from './resetPrimaryName' -import resetProfile from './resetProfile' -import resetProfileWithRecords from './resetProfileWithRecords' -import setPrimaryName from './setPrimaryName' -import syncManager from './syncManager' -import testSendName from './testSendName' -import transferController from './transferController' -import transferName from './transferName' -import transferSubname from './transferSubname' -import unwrapName from './unwrapName' -import updateEthAddress from './updateEthAddress' -import updateProfile from './updateProfile' -import updateProfileRecords from './updateProfileRecords' -import updateResolver from './updateResolver' -import updateVerificationRecord from './updateVerificationRecord' -import wrapName from './wrapName' - -export const transactions = { - 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 Transaction = typeof transactions -export type TransactionName = keyof Transaction - -export type TransactionParameters = Parameters< - Transaction[name]['transaction'] ->[0] - -export type TransactionData = TransactionParameters['data'] - -export type TransactionReturnType = ReturnType< - Transaction[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 transactions[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-flow/transaction/migrateProfile.ts b/src/transaction-flow/transaction/migrateProfile.ts deleted file mode 100644 index f9f811712..000000000 --- a/src/transaction-flow/transaction/migrateProfile.ts +++ /dev/null @@ -1,69 +0,0 @@ -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-flow/transaction/migrateProfileWithReset.ts b/src/transaction-flow/transaction/migrateProfileWithReset.ts deleted file mode 100644 index d6c9bebfe..000000000 --- a/src/transaction-flow/transaction/migrateProfileWithReset.ts +++ /dev/null @@ -1,73 +0,0 @@ -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-flow/transaction/registerName.test.ts b/src/transaction-flow/transaction/registerName.test.ts deleted file mode 100644 index d0e8ed825..000000000 --- a/src/transaction-flow/transaction/registerName.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -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-flow/transaction/registerName.ts b/src/transaction-flow/transaction/registerName.ts deleted file mode 100644 index 3a1d95dfb..000000000 --- a/src/transaction-flow/transaction/registerName.ts +++ /dev/null @@ -1,50 +0,0 @@ -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-flow/transaction/removeVerificationRecord.ts b/src/transaction-flow/transaction/removeVerificationRecord.ts deleted file mode 100644 index 13722b22e..000000000 --- a/src/transaction-flow/transaction/removeVerificationRecord.ts +++ /dev/null @@ -1,52 +0,0 @@ -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-flow/transaction/resetPrimaryName.ts b/src/transaction-flow/transaction/resetPrimaryName.ts deleted file mode 100644 index e68869ae0..000000000 --- a/src/transaction-flow/transaction/resetPrimaryName.ts +++ /dev/null @@ -1,30 +0,0 @@ -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-flow/transaction/resetProfile.ts b/src/transaction-flow/transaction/resetProfile.ts deleted file mode 100644 index 25d5b8d8a..000000000 --- a/src/transaction-flow/transaction/resetProfile.ts +++ /dev/null @@ -1,39 +0,0 @@ -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-flow/transaction/resetProfileWithRecords.ts b/src/transaction-flow/transaction/resetProfileWithRecords.ts deleted file mode 100644 index 15d170a77..000000000 --- a/src/transaction-flow/transaction/resetProfileWithRecords.ts +++ /dev/null @@ -1,66 +0,0 @@ -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-flow/transaction/setPrimaryName.ts b/src/transaction-flow/transaction/setPrimaryName.ts deleted file mode 100644 index 85ba21dc9..000000000 --- a/src/transaction-flow/transaction/setPrimaryName.ts +++ /dev/null @@ -1,37 +0,0 @@ -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-flow/transaction/syncManager.ts b/src/transaction-flow/transaction/syncManager.ts deleted file mode 100644 index accfc98fa..000000000 --- a/src/transaction-flow/transaction/syncManager.ts +++ /dev/null @@ -1,41 +0,0 @@ -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-flow/transaction/testSendName.ts b/src/transaction-flow/transaction/testSendName.ts deleted file mode 100644 index 7e18386c3..000000000 --- a/src/transaction-flow/transaction/testSendName.ts +++ /dev/null @@ -1,39 +0,0 @@ -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-flow/transaction/transferController.ts b/src/transaction-flow/transaction/transferController.ts deleted file mode 100644 index 07e8e0cd4..000000000 --- a/src/transaction-flow/transaction/transferController.ts +++ /dev/null @@ -1,42 +0,0 @@ -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-flow/transaction/transferName.ts b/src/transaction-flow/transaction/transferName.ts deleted file mode 100644 index 389f3ad2f..000000000 --- a/src/transaction-flow/transaction/transferName.ts +++ /dev/null @@ -1,62 +0,0 @@ -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-flow/transaction/transferSubname.ts b/src/transaction-flow/transaction/transferSubname.ts deleted file mode 100644 index e0fda6f77..000000000 --- a/src/transaction-flow/transaction/transferSubname.ts +++ /dev/null @@ -1,40 +0,0 @@ -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-flow/transaction/unwrapName.test.ts b/src/transaction-flow/transaction/unwrapName.test.ts deleted file mode 100644 index 0b6160397..000000000 --- a/src/transaction-flow/transaction/unwrapName.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -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-flow/transaction/unwrapName.ts b/src/transaction-flow/transaction/unwrapName.ts deleted file mode 100644 index 2db7f80c7..000000000 --- a/src/transaction-flow/transaction/unwrapName.ts +++ /dev/null @@ -1,42 +0,0 @@ -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-flow/transaction/updateEthAddress.ts b/src/transaction-flow/transaction/updateEthAddress.ts deleted file mode 100644 index 7859d4701..000000000 --- a/src/transaction-flow/transaction/updateEthAddress.ts +++ /dev/null @@ -1,61 +0,0 @@ -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-flow/transaction/updateProfile.ts b/src/transaction-flow/transaction/updateProfile.ts deleted file mode 100644 index 0401017db..000000000 --- a/src/transaction-flow/transaction/updateProfile.ts +++ /dev/null @@ -1,72 +0,0 @@ -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 '../../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-flow/transaction/updateProfileRecords.ts b/src/transaction-flow/transaction/updateProfileRecords.ts deleted file mode 100644 index 93d55e9ca..000000000 --- a/src/transaction-flow/transaction/updateProfileRecords.ts +++ /dev/null @@ -1,98 +0,0 @@ -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-flow/transaction/updateResolver.ts b/src/transaction-flow/transaction/updateResolver.ts deleted file mode 100644 index e43fa39cd..000000000 --- a/src/transaction-flow/transaction/updateResolver.ts +++ /dev/null @@ -1,45 +0,0 @@ -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-flow/transaction/updateVerificationRecord.ts b/src/transaction-flow/transaction/updateVerificationRecord.ts deleted file mode 100644 index 269ee16ed..000000000 --- a/src/transaction-flow/transaction/updateVerificationRecord.ts +++ /dev/null @@ -1,52 +0,0 @@ -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-flow/transaction/utils/makeTransferNameOrSubnameTransactionItem.ts b/src/transaction-flow/transaction/utils/makeTransferNameOrSubnameTransactionItem.ts deleted file mode 100644 index 99f7086ae..000000000 --- a/src/transaction-flow/transaction/utils/makeTransferNameOrSubnameTransactionItem.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { match, P } from 'ts-pattern' -import { Address } from 'viem' - -import type { useAbilities } from '@app/hooks/abilities/useAbilities' - -import { createTransactionItem, TransactionItem } from '..' - -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-flow/transaction/wrapName.ts b/src/transaction-flow/transaction/wrapName.ts deleted file mode 100644 index 735343527..000000000 --- a/src/transaction-flow/transaction/wrapName.ts +++ /dev/null @@ -1,37 +0,0 @@ -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/transaction-flow/types.ts b/src/transaction-flow/types.ts deleted file mode 100644 index 1bc0adabd..000000000 --- a/src/transaction-flow/types.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { TOptions } from 'i18next' -import { ComponentProps, Dispatch, ReactNode } from 'react' -import { Hash } from 'viem' - -import { Button, Dialog, Helper } from '@ensdomains/thorin' - -import { Transaction } from '@app/hooks/transactions/transactionStore' -import { MinedData, TransactionDisplayItem } from '@app/types' - -import type { DataInputComponent, DataInputName } from './input' -import type { IntroComponentName } from './intro' -import type { TransactionData, TransactionName } from './transaction' - -export type TransactionFlowStage = 'input' | 'intro' | 'transaction' - -export type TransactionStage = 'confirm' | 'sent' | 'complete' | 'failed' - -export type GenericDataInput< - name extends DataInputName = DataInputName, - data extends ComponentProps = ComponentProps, -> = { - name: name - data: data -} - -export type GenericTransaction< - TName extends TransactionName = TransactionName, - TData extends TransactionData = TransactionData, -> = { - name: TName - data: TData - hash?: Hash - sendTime?: number - finaliseTime?: number - stage?: TransactionStage - minedData?: MinedData -} - -type GenericIntro = { - name: IntroComponentName - data: any -} - -type StoredTranslationReference = [key: string, options?: TOptions] - -export type TransactionIntro = { - title: StoredTranslationReference - leadingLabel?: StoredTranslationReference - trailingLabel?: StoredTranslationReference - content: GenericIntro -} - -export type TransactionFlowItem = { - input?: GenericDataInput - intro?: TransactionIntro - transactions: readonly GenericTransaction[] | GenericTransaction[] - resumable?: boolean - requiresManualCleanup?: boolean - autoClose?: boolean - resumeLink?: string - disableBackgroundClick?: boolean -} - -export type BaseInternalTransactionFlowItem = TransactionFlowItem & { - currentTransaction: number - currentFlowStage: TransactionFlowStage -} - -export type InternalTransactionFlowItem = - | BaseInternalTransactionFlowItem - | (BaseInternalTransactionFlowItem & { - currentFlowStage: 'input' - input: GenericDataInput - }) - -export type InternalTransactionFlow = { - selectedKey: string | null - items: { [key: string]: InternalTransactionFlowItem } -} - -export type TransactionFlowAction = - | { - name: 'showDataInput' - payload: { - input: GenericDataInput - disableBackgroundClick?: boolean - } - key: string - } - | { - name: 'startFlow' - payload: TransactionFlowItem - key: string - } - | { - name: 'resumeFlow' - key: string - } - | { - name: 'resumeFlowWithCheck' - key: string - payload: { - push: (path: string) => void - } - } - | { - name: 'setTransactions' - payload: { - [key in TransactionName]: { - name: key - data: TransactionData - } - }[TransactionName][] - } - | { - name: 'setFlowStage' - payload: TransactionFlowStage - } - | { - name: 'stopFlow' - } - | { - name: 'setTransactionStage' - payload: TransactionStage - } - | { - name: 'setTransactionHash' - payload: { hash: Hash; key: string } - } - | { - name: 'setTransactionHashFromUpdate' - payload: { hash: Hash; key: string } - } - | { - name: 'incrementTransaction' - } - | { - name: 'cleanupTransaction' - payload: string - } - | { - name: 'forceCleanupTransaction' - payload: string - } - | { - name: 'setTransactionStageFromUpdate' - payload: Transaction - } - | { - name: 'resetTransactionStep' - } - | { - name: 'setFailedTransaction' - payload: Transaction - } - -export type TransactionDialogProps = ComponentProps & { - variant: 'actionable' - children: () => ReactNode - leading: ComponentProps - trailing: ComponentProps -} - -export type TransactionDialogPassthrough = { - dispatch: Dispatch - onDismiss: () => void - transactionIds?: string[] -} - -export type ManagedDialogProps = { - dispatch: Dispatch - onDismiss: () => void - transaction: GenericTransaction - actionName: string - txKey: string | null - currentStep: number - stepCount: number - displayItems: TransactionDisplayItem[] - helper?: ComponentProps - backToInput: boolean -} - -export type GetUniqueTransactionParameters = Pick & { - transaction: Pick -} - -export type UniqueTransaction = { - key: string - step: number - name: TName - data: TransactionData -} diff --git a/src/transaction/analytics.ts b/src/transaction/analytics.ts new file mode 100644 index 000000000..2a0497831 --- /dev/null +++ b/src/transaction/analytics.ts @@ -0,0 +1,12 @@ +import { trackEvent } from '@app/utils/analytics' + +import type { StoredTransaction } from './slices/createTransactionSlice' + +export const onTransactionUpdateAnalytics = ( + transaction: Extract, +) => { + if (!transaction) return + if (transaction.name === 'registerName') trackEvent('register', transaction.targetChainId) + else if (transaction.name === 'commitName') trackEvent('commit', transaction.targetChainId) + else if (transaction.name === 'extendNames') trackEvent('renew', transaction.targetChainId) +} diff --git a/src/transaction/components/DisplayItems.tsx b/src/transaction/components/DisplayItems.tsx index e69de29bb..20d967577 100644 --- a/src/transaction/components/DisplayItems.tsx +++ b/src/transaction/components/DisplayItems.tsx @@ -0,0 +1,275 @@ +import { useMemo } from 'react' +import { TFunction, useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' +import { Address } from 'viem' + +import { Typography } from '@ensdomains/thorin' + +import { AvatarWithZorb, NameAvatar } from '@app/components/AvatarWithZorb' +import { usePrimaryName } from '@app/hooks/ensjs/public/usePrimaryName' +import { useBeautifiedName } from '@app/hooks/useBeautifiedName' +import { TransactionDisplayItem } from '@app/types' +import { shortenAddress } from '@app/utils/utils' + +const Container = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: column; + align-items: center; + justify-content: stretch; + width: ${theme.space.full}; + gap: ${theme.space['2']}; + `, +) + +const DisplayItemContainer = styled.div<{ $shrink?: boolean; $fade?: boolean }>( + ({ theme, $shrink, $fade }) => css` + display: grid; + grid-template-columns: 0.5fr 2fr; + align-items: center; + border-radius: ${theme.radii.extraLarge}; + border: ${theme.borderWidths.px} ${theme.borderStyles.solid} ${theme.colors.border}; + min-height: ${theme.space['14']}; + padding: ${theme.space['2']} ${theme.space['5']}; + width: ${theme.space.full}; + + ${$shrink && + css` + min-height: ${theme.space['12']}; + div { + margin-top: 0; + align-self: center; + } + `} + ${$fade && + css` + opacity: 0.5; + background-color: ${theme.colors.backgroundSecondary}; + `} + `, +) + +const DisplayItemLabel = styled(Typography)( + ({ theme }) => css` + color: ${theme.colors.textSecondary}; + justify-self: flex-start; + `, +) + +const AvatarWrapper = styled.div( + ({ theme }) => css` + width: ${theme.space['7']}; + min-width: ${theme.space['7']}; + height: ${theme.space['7']}; + `, +) + +const ValueWithAvatarContainer = styled.div( + ({ theme }) => css` + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; + gap: ${theme.space['4']}; + `, +) + +const InnerValueWrapper = styled.div( + () => css` + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: center; + text-align: right; + `, +) + +const ValueTypography = styled(Typography)( + ({ theme }) => css` + overflow-wrap: anywhere; + text-align: right; + margin-left: ${theme.space['2']}; + `, +) + +const AddressSubtitle = styled(Typography)( + ({ theme }) => css` + color: ${theme.colors.textSecondary}; + font-weight: ${theme.fontWeights.bold}; + `, +) + +const AddressValue = ({ value }: { value: string }) => { + const primary = usePrimaryName({ address: value as Address }) + + const AddressTypography = useMemo( + () => + primary.data?.name ? ( + {shortenAddress(value)} + ) : ( + {shortenAddress(value)} + ), + [primary.data?.name, value], + ) + + return ( + + + {primary.data?.name && ( + + {primary.data?.beautifiedName} + + )} + {AddressTypography} + + + + + + ) +} + +const NameValue = ({ value }: { value: string }) => { + const beautifiedName = useBeautifiedName(value) + + return ( + + {beautifiedName} + + + + + ) +} + +const SubnameValue = ({ value }: { value: string }) => { + const [label, ...parentParts] = value.split('.') + const parent = parentParts.join('.') + + return ( + +

+ {label}. + {parent} +
+ + + + + ) +} + +const ListContainer = styled.div( + () => css` + display: flex; + flex-direction: column; + text-align: right; + `, +) + +const ListValue = ({ value }: { value: string[] }) => { + return ( + + {value.map((val, idx) => { + const isLast = idx === value.length - 1 + const key = idx + if (idx === 0) { + return ( + + {val} + + ) + } + return {`${val}${!isLast ? ',' : ''}`} + })} + + ) +} + +const RecordsContainer = styled.div( + () => css` + display: flex; + flex-direction: column; + text-align: right; + gap: 0.5rem; + margin-left: 0.5rem; + overflow: hidden; + `, +) + +const RecordContainer = styled.div( + () => css` + display: flex; + flex-direction: column; + `, +) + +const RecordsValue = ({ value }: { value: [string, string | undefined][] }) => { + return ( + + {value.map(([key, val]) => ( + + + + {key} + {!!val && ':'} + {' '} + {!!val && val} + + + ))} + + ) +} + +const DisplayItemValue = (props: Omit) => { + const { value, type } = props as TransactionDisplayItem + if (type === 'address') { + return + } + if (type === 'name') { + return + } + if (type === 'subname') { + return + } + if (type === 'list') { + return + } + if (type === 'records') { + return + } + return {value} +} + +export const DisplayItem = ({ + label, + value, + type, + shrink, + fade, + useRawLabel, + t, +}: TransactionDisplayItem & { t: TFunction }) => { + return ( + + + {useRawLabel ? label : t(`transaction.itemLabel.${label}`)} + + + + ) +} + +export const DisplayItems = ({ displayItems }: { displayItems: TransactionDisplayItem[] }) => { + const { t } = useTranslation() + + if (!displayItems || !displayItems.length) return null + + return {displayItems.map((props) => DisplayItem({ ...props, t }))} +} diff --git a/src/transaction/components/TransactionDialogManager.tsx b/src/transaction/components/TransactionDialogManager.tsx index e5f573d10..887814d33 100644 --- a/src/transaction/components/TransactionDialogManager.tsx +++ b/src/transaction/components/TransactionDialogManager.tsx @@ -6,43 +6,62 @@ 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 type { StoredFlow } from '../slices/createFlowSlice' +import type { StoredTransaction } from '../slices/createTransactionSlice' +import type { AllSlices } from '../slices/types' +import { useTransactionManager } from '../transactionManager' +import { + transactionInputComponents, + type GenericTransactionInput, + type TransactionInputName, +} from '../user/input' +import type { TransactionIntro } from '../user/intro' import { userTransactions } from '../user/transaction' import { IntroStageModal } from './stage/intro/IntroStageModal' import { TransactionStageModal } from './stage/transaction/TransactionStageModal' export type TransactionDialogPassthrough = { onDismiss: () => void + setTransactions: AllSlices['setCurrentFlowTransactions'] + setStage: AllSlices['setCurrentFlowStage'] transactions?: StoredTransaction[] } -const InputContent = ({ +const InputContent = ({ flow, }: { - flow: StoredFlow & { input: GenericDataInput } + flow: StoredFlow & { input: GenericTransactionInput } }) => { - const transactions = useTransactionStore((s) => s.flow.current.getTransactions()) - const onDismiss = useTransactionStore((s) => s.flow.current.stop) - const Component = DataInputComponents[flow.input.name] as ComponentType< + const transactions = useTransactionManager((s) => s.getFlowTransactions(flow.flowId)) + const onDismiss = useTransactionManager((s) => s.stopCurrentFlow) + const setTransactions = useTransactionManager((s) => s.setCurrentFlowTransactions) + const setStage = useTransactionManager((s) => s.setCurrentFlowStage) + const Component = transactionInputComponents[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 transactions = useTransactionManager((s) => s.getFlowTransactions(flow.flowId)) + const setFlowStage = useTransactionManager((s) => s.setCurrentFlowStage) + const onDismiss = useTransactionManager((s) => s.stopCurrentFlow) - const currentTransaction = transactions[flow.currentTransaction] + const currentTransaction = transactions[flow.currentTransactionIndex] const currentStep = - currentTransaction.status === 'success' ? flow.currentTransaction + 1 : flow.currentTransaction + currentTransaction.status === 'success' + ? flow.currentTransactionIndex + 1 + : flow.currentTransactionIndex const stepStatus = currentTransaction.status === 'pending' || currentTransaction.status === 'reverted' ? 'inProgress' @@ -52,7 +71,7 @@ const IntroContent = ({ flow }: { flow: StoredFlow & { intro: TransactionIntro } setFlowStage({ stage: 'transaction' })} + onSuccess={() => setFlowStage('transaction')} {...{ ...flow.intro, onDismiss, @@ -64,9 +83,9 @@ const IntroContent = ({ flow }: { flow: StoredFlow & { intro: TransactionIntro } 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 transactions = useTransactionManager((s) => s.getFlowTransactions(flow.flowId)) + const onDismiss = useTransactionManager((s) => s.stopCurrentFlow) + const currentTransaction = transactions[flow.currentTransactionIndex] const userTransaction = userTransactions[currentTransaction.name] const displayItems = userTransaction.displayItems(currentTransaction.data as never, t) @@ -74,7 +93,7 @@ const TransactionContent = ({ flow }: { flow: StoredFlow }) => { return ( { if (!flow) return null if (flow.input && flow.currentStage === 'input') - return }} /> + 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) + const { flow, isPrevious } = useTransactionManager((s) => s.getCurrentOrPreviousFlow()) + const stopFlow = useTransactionManager((s) => s.stopCurrentFlow) + const attemptDismiss = useTransactionManager((s) => s.attemptCurrentFlowDismiss) return ( ({ transactions, onSuccess, currentStep, @@ -17,7 +20,7 @@ export const IntroStageModal = ({ title, trailingLabel, stepStatus, -}: TransactionIntro & { +}: TransactionIntro & { transactions: | { name: string @@ -53,13 +56,11 @@ export const IntroStageModal = ({ const txCount = transactions.length - const Content = intros[content.name] - return ( <> - + {txCount > 1 && ( { const { t } = useTranslation() - const setStage = useTransactionStore((s) => s.flow.current.setStage) - const resetTransactionIndex = useTransactionStore((s) => s.flow.current.resetTransactionIndex) + const setStage = useTransactionManager((s) => s.setCurrentFlowStage) + const resetTransactionIndex = useTransactionManager((s) => s.resetCurrentFlowTransactionIndex) if (!backToInput) return null if (status === 'waitingForUser' || status === 'pending' || status === 'success') return null const handleBackToInput = () => { - setStage({ stage: 'input' }) + setStage('input') resetTransactionIndex() } diff --git a/src/transaction/components/stage/transaction/LoadBar.tsx b/src/transaction/components/stage/transaction/LoadBar.tsx index 56c6b828a..1e8c4563f 100644 --- a/src/transaction/components/stage/transaction/LoadBar.tsx +++ b/src/transaction/components/stage/transaction/LoadBar.tsx @@ -7,8 +7,8 @@ import { CrossCircleSVG, QuestionCircleSVG, Spinner, Typography } from '@ensdoma 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' + +import type { DialogStatus } from './TransactionStageModal' const BarContainer = styled.div( ({ theme }) => css` @@ -20,7 +20,7 @@ const BarContainer = styled.div( `, ) -const Bar = styled.div<{ $status: Status }>( +const Bar = styled.div<{ $status: Exclude }>( ({ theme, $status }) => css` width: ${theme.space.full}; height: ${theme.space['9']}; @@ -34,11 +34,11 @@ const Bar = styled.div<{ $status: Status }>( --bar-color: ${theme.colors.blue}; - ${$status === 'complete' && + ${$status === 'success' && css` --bar-color: ${theme.colors.green}; `} - ${$status === 'failed' && + ${$status === 'reverted' && css` --bar-color: ${theme.colors.red}; `} @@ -82,8 +82,6 @@ const MessageTypography = styled(Typography)( `, ) -type Status = Omit - const BarPrefix = styled.div( ({ theme }) => css` padding: ${theme.space['2']} ${theme.space['4']}; @@ -134,10 +132,10 @@ const InnerBar = styled.div( ) export const LoadBar = ({ - status, + dialogStatus, sendTime, }: { - status: StoredTransactionStatus + dialogStatus: Exclude sendTime: number | undefined }) => { const { t } = useTranslation() @@ -163,16 +161,16 @@ export const LoadBar = ({ }, [intervalFunc]) const message = useMemo(() => { - if (status === 'success') { - return t('transaction.dialog.complete.message') + if (dialogStatus === 'success') { + return t('transaction.dialog.success.message') } - if (status === 'reverted') { + if (dialogStatus === 'reverted') { return null } - return t('transaction.dialog.sent.message') - }, [status, t]) + return t('transaction.dialog.pending.message') + }, [dialogStatus, t]) - const isTakingLongerThanExpected = status === 'pending' && progress === 100 + const isTakingLongerThanExpected = dialogStatus === 'pending' && progress === 100 const progressMessage = useMemo(() => { if (isTakingLongerThanExpected) { @@ -190,34 +188,34 @@ export const LoadBar = ({ }, [isTakingLongerThanExpected, t]) const EndElement = useMemo(() => { - if (status === 'success') { + if (dialogStatus === 'success') { return } - if (status === 'reverted') { + if (dialogStatus === 'reverted') { return } if (progress !== 100) { return } return - }, [progress, status]) + }, [progress, dialogStatus]) return ( <> - + {t( isTakingLongerThanExpected - ? 'transaction.dialog.sent.progress.message' - : `transaction.dialog.${status}.progress.title`, + ? 'transaction.dialog.pending.progress.message' + : `transaction.dialog.${dialogStatus}.progress.title`, )} {EndElement} diff --git a/src/transaction/components/stage/transaction/TransactionStageModal.tsx b/src/transaction/components/stage/transaction/TransactionStageModal.tsx index 7d4e4bbd7..5903646fa 100644 --- a/src/transaction/components/stage/transaction/TransactionStageModal.tsx +++ b/src/transaction/components/stage/transaction/TransactionStageModal.tsx @@ -8,15 +8,17 @@ 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 type { + GenericStoredTransaction, + StoredTransactionStatus, +} from '@app/transaction/slices/createTransactionSlice' +import { useTransactionManager } from '@app/transaction/transactionManager' +import type { UserTransactionName } 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 { createEtherscanLink } from '@app/utils/utils' import { DisplayItems } from '../../DisplayItems' import { TransactionModalActionButton } from './ActionButton' @@ -50,7 +52,7 @@ function useCreateSubnameRedirect( }, [shouldTrigger, subdomain]) } -type TransactionStageModalProps = { +type TransactionStageModalProps = { currentTransactionIndex: number transactionCount: number transaction: GenericStoredTransaction @@ -59,17 +61,18 @@ type TransactionStageModalProps onDismiss: () => void } +export type DialogStatus = Exclude | 'confirm' + const MiddleContent = ({ - status, + dialogStatus, sendTime, }: { - status: StoredTransactionStatus + dialogStatus: DialogStatus sendTime: number | undefined }) => { const { t } = useTranslation() - if (status !== 'empty' && status !== 'waitingForUser') - return + if (dialogStatus !== 'confirm') return return ( <> @@ -79,7 +82,7 @@ const MiddleContent = ({ ) } -export const TransactionStageModal = ({ +export const TransactionStageModal = ({ currentTransactionIndex, transactionCount, transaction, @@ -88,9 +91,8 @@ export const TransactionStageModal = ) => { const { t } = useTranslation() - const chainName = useChainName() - const incrementTransaction = useTransactionStore((s) => s.flow.current.incrementTransaction) + const incrementTransaction = useTransactionManager((s) => s.incrementCurrentFlowTransactionIndex) const { transactionError, @@ -121,7 +123,11 @@ export const TransactionStageModal = + console.log(transaction.status) + + const dialogStatus = (() => { + switch (transaction.status) { + case 'empty': + case 'waitingForUser': + return 'confirm' + default: + return transaction.status + } + })() + return ( <> - + - + {upperError && {t(upperError)}} {FilledDisplayItems} {transaction.currentHash && ( - + {t('transaction.viewEtherscan')} )} diff --git a/src/transaction/components/stage/transaction/query.ts b/src/transaction/components/stage/transaction/query.ts index 1dae46f21..1c88fc147 100644 --- a/src/transaction/components/stage/transaction/query.ts +++ b/src/transaction/components/stage/transaction/query.ts @@ -4,17 +4,17 @@ 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 { SupportedChain, type TargetChain } from '@app/constants/chains' import type { GenericStoredTransaction, StoredTransactionIdentifiers, StoredTransactionStatus, -} from '@app/transaction/types' +} from '@app/transaction/slices/createTransactionSlice' +import { useTransactionManager } from '@app/transaction/transactionManager' import { createTransactionRequest, - type TransactionData, - type TransactionName, + type UserTransactionData, + type UserTransactionName, } from '@app/transaction/user/transaction' import { BasicTransactionRequest, @@ -35,17 +35,19 @@ type AccessListResponse = { gasUsed: Hex } -type TransactionIdentifiersWithData = +type TransactionIdentifiersWithData = StoredTransactionIdentifiers & { name: name - data: TransactionData + data: UserTransactionData } -export const getTransactionIdentifiersWithData = ( +export const getTransactionIdentifiersWithData = < + name extends UserTransactionName = UserTransactionName, +>( transaction: GenericStoredTransaction, ): TransactionIdentifiersWithData => { - const { chainId, account, transactionId, flowId, name, data } = transaction - return { chainId, account, transactionId, flowId, name, data } + const { sourceChainId, targetChainId, account, transactionId, flowId, name, data } = transaction + return { sourceChainId, targetChainId, account, transactionId, flowId, name, data } } export const transactionMutateHandler = @@ -57,10 +59,10 @@ export const transactionMutateHandler = isSafeApp: CheckIsSafeAppReturnType }) => (request: SendTransactionVariables) => { - useTransactionStore.getState().transaction.setSubmission(transactionIdentifiers, { + useTransactionManager.getState().setTransactionSubmission(transactionIdentifiers, { input: request.data!, nonce: request.nonce!, - timestamp: Math.floor(Date.now() / 1000), + timestamp: Date.now(), transactionType: isSafeApp ? 'safe' : 'standard', }) } @@ -68,10 +70,13 @@ export const transactionMutateHandler = export const transactionSuccessHandler = (transactionIdentifiers: StoredTransactionIdentifiers) => async (transactionHash: SendTransactionReturnType) => { - useTransactionStore.getState().transaction.setHash(transactionIdentifiers, transactionHash) + useTransactionManager.getState().setTransactionHash(transactionIdentifiers, transactionHash) } -export const registrationGasFeeModifier = (gasLimit: bigint, transactionName: TransactionName) => +export const registrationGasFeeModifier = ( + gasLimit: bigint, + transactionName: UserTransactionName, +) => // this addition is arbitrary, something to do with a gas refund but not 100% sure transactionName === 'registerName' ? gasLimit + 5000n : gasLimit @@ -86,7 +91,7 @@ export const calculateGasLimit = async ({ connectorClient: ConnectorClientWithEns isSafeApp: boolean txWithZeroGas: BasicTransactionRequest - transactionName: TransactionName + transactionName: UserTransactionName }) => { if (isSafeApp) { const accessListResponse = await client.request<{ @@ -122,8 +127,8 @@ export const calculateGasLimit = async ({ } } -type CreateTransactionRequestQueryKey = CreateQueryKey< - TransactionIdentifiersWithData, +type CreateTransactionRequestQueryKey = CreateQueryKey< + TransactionIdentifiersWithData, 'createTransactionRequest', 'standard' > @@ -137,13 +142,13 @@ export const createTransactionRequestQueryFn = connectorClient: ConnectorClientWithEns | undefined isSafeApp: CheckIsSafeAppReturnType | undefined }) => - async ({ - queryKey: [params, chainId, address], - }: QueryFunctionContext) => { - const client = config.getClient({ chainId }) + async ({ + queryKey: [params], + }: QueryFunctionContext>) => { + const client = config.getClient({ chainId: params.targetChainId }) if (!connectorClient) throw new Error('connectorClient is required') - if (connectorClient.account.address !== address) + if (connectorClient.account.address !== params.account) throw new Error('address does not match connector') const transactionRequest = await createTransactionRequest({ @@ -167,13 +172,16 @@ export const createTransactionRequestQueryFn = transactionName: params.name, }) + const prepareParameters = + params.name === '__dev_failure' ? [] : (['fees', 'nonce', 'type'] as const) + const request = await prepareTransactionRequest(client, { to: transactionRequest.to, accessList, account: connectorClient.account, data: transactionRequest.data, gas: gasLimit, - parameters: ['fees', 'nonce', 'type'], + parameters: prepareParameters, ...('value' in transactionRequest ? { value: transactionRequest.value } : {}), }) @@ -182,12 +190,18 @@ export const createTransactionRequestQueryFn = chain: request.chain!, to: request.to!, gas: request.gas!, - chainId, + chainId: params.targetChainId, } } +type GetTransactionErrorParameters = { + hash: Hash | null + status: StoredTransactionStatus | undefined + targetChainId: TargetChain['id'] +} + type GetTransactionErrorQueryKey = CreateQueryKey< - { hash: Hash | null; status: StoredTransactionStatus | undefined }, + GetTransactionErrorParameters, 'getTransactionError', 'standard' > @@ -195,10 +209,10 @@ type GetTransactionErrorQueryKey = CreateQueryKey< export const getTransactionErrorQueryFn = (config: ConfigWithEns) => async ({ - queryKey: [{ hash, status }, chainId], + queryKey: [{ hash, status, targetChainId }], }: QueryFunctionContext) => { if (!hash || status !== 'reverted') return null - const client = config.getClient({ chainId }) + const client = config.getClient({ chainId: targetChainId }) const failedTransactionData = await getTransaction(client, { hash }) try { await call(client, failedTransactionData as CallParameters) diff --git a/src/transaction/createTransactionListener.ts b/src/transaction/createTransactionListener.ts index df397b098..30ca83c73 100644 --- a/src/transaction/createTransactionListener.ts +++ b/src/transaction/createTransactionListener.ts @@ -1,7 +1,7 @@ -import type { TransactionStore } from './types' +import type { AllSlices } from './slices/types' export type TransactionStoreListener = [ - selector: (state: TransactionStore) => selected, + selector: (state: AllSlices) => selected, listener: (selectedState: selected, previousSelectedState: selected) => void, options?: { equalityFn?: (a: selected, b: selected) => boolean @@ -10,7 +10,7 @@ export type TransactionStoreListener = [ ] export const createTransactionListener = ( - selector: (state: TransactionStore) => selected, + selector: (state: AllSlices) => selected, listener: (selectedState: selected, previousSelectedState: selected) => void, options?: { equalityFn?: (a: selected, b: selected) => boolean diff --git a/src/transaction/key.ts b/src/transaction/key.ts index 55d2a9afb..797a2e1e4 100644 --- a/src/transaction/key.ts +++ b/src/transaction/key.ts @@ -1,11 +1,16 @@ -import type { FlowKey, StoredFlow, StoredTransaction, TransactionKey } from './types' +import type { FlowKey, StoredFlow } from './slices/createFlowSlice' +import type { StoredTransaction, TransactionKey } from './slices/createTransactionSlice' -export const getFlowKey = (flow: Pick): FlowKey => - JSON.stringify([flow.flowId, flow.chainId, flow.account]) as FlowKey +export const getFlowKey = ( + flow: Pick, +): FlowKey => JSON.stringify([flow.flowId, flow.sourceChainId, flow.account]) as FlowKey export const getTransactionKey = ({ transactionId, flowId, - chainId, + sourceChainId, account, -}: Pick): TransactionKey => - JSON.stringify([transactionId, flowId, chainId, account]) as TransactionKey +}: Pick< + StoredTransaction, + 'transactionId' | 'flowId' | 'sourceChainId' | 'account' +>): TransactionKey => + JSON.stringify([transactionId, flowId, sourceChainId, account]) as TransactionKey diff --git a/src/transaction/slices/createCurrentSlice.ts b/src/transaction/slices/createCurrentSlice.ts new file mode 100644 index 000000000..8bd1c3faa --- /dev/null +++ b/src/transaction/slices/createCurrentSlice.ts @@ -0,0 +1,67 @@ +/* eslint-disable no-param-reassign */ +import { getAccount, watchChainId } from '@wagmi/core' +import type { Address } from 'viem' +import type { StateCreator } from 'zustand' + +import type { SourceChain, SupportedChain } from '@app/constants/chains' +import { getSourceChainId } from '@app/utils/query/getSourceChainId' +import { wagmiConfig } from '@app/utils/query/wagmi' + +import type { FlowId } from './createFlowSlice' +import type { AllSlices, MiddlewareArray } from './types' + +export type CurrentSlice = { + current: { + sourceChainId: SourceChain['id'] | null + account: Address | null + flowId: FlowId | null + _previousFlowId: FlowId | null + } + onChainIdUpdate: (chainId: SupportedChain['id']) => void + onAccountUpdate: (account: Address | undefined) => void + _hasHydrated: boolean + _setHasHydrated: (hasHydrated: boolean) => void +} + +export const createCurrentSlice: StateCreator = ( + set, +) => { + const onChainIdUpdate = (chainId: SupportedChain['id']) => + set((state) => { + const oldSourceChainId = state.current.sourceChainId + const newSourceChainId = getSourceChainId(chainId) + if (oldSourceChainId !== newSourceChainId) { + state.current.sourceChainId = newSourceChainId + state.current.flowId = null + state.clearNotifications() + } + }) + + const onAccountUpdate = (account: Address | undefined) => + set((state) => { + state.current.account = account ?? null + state.current.flowId = null + state.clearNotifications() + }) + + wagmiConfig.subscribe(() => getAccount(wagmiConfig).address, onAccountUpdate) + + watchChainId(wagmiConfig, { + onChange: onChainIdUpdate, + }) + return { + current: { + sourceChainId: null, + account: null, + flowId: null, + _previousFlowId: null, + }, + onChainIdUpdate, + onAccountUpdate, + _hasHydrated: false, + _setHasHydrated: (hasHydrated) => + set((state) => { + state._hasHydrated = hasHydrated + }), + } +} diff --git a/src/transaction/slices/createFlowSlice.ts b/src/transaction/slices/createFlowSlice.ts new file mode 100644 index 000000000..1311f3f20 --- /dev/null +++ b/src/transaction/slices/createFlowSlice.ts @@ -0,0 +1,376 @@ +/* eslint-disable no-param-reassign */ + +import type { WritableDraft } from 'immer/dist/internal' +import type { Address } from 'viem' +import type { StateCreator } from 'zustand' + +import type { SourceChain } from '@app/constants/chains' + +import { getFlowKey, getTransactionKey } from '../key' +import type { TransactionStoreIdentifiers } from '../types' +import type { GenericTransactionInput, TransactionInput } from '../user/input' +import type { TransactionIntro } from '../user/intro' +import type { UserTransaction } from '../user/transaction' +import type { + StoredTransaction, + StoredTransactionList, + TransactionId, +} from './createTransactionSlice' +import type { AllSlices, MiddlewareArray } from './types' +import { getIdentifiers } from './utils' + +export type FlowId = string +export type FlowKey = `["${FlowId}",${SourceChain['id']},"${Address}"]` +export type TransactionFlowStage = 'input' | 'intro' | 'transaction' + +export type StoredFlow = TransactionStoreIdentifiers & { + flowId: FlowId + transactionIds: TransactionId[] + currentTransactionIndex: number + currentStage: TransactionFlowStage + input?: TransactionInput + intro?: TransactionIntro + resumable?: boolean + requiresManualCleanup?: boolean + autoClose?: boolean + resumeLink?: string + disableBackgroundClick?: boolean +} + +export type FlowInitialiserData = Omit< + StoredFlow, + 'currentStage' | 'currentTransactionIndex' | 'transactionIds' | keyof TransactionStoreIdentifiers +> & { + transactions: UserTransaction[] +} + +export type FlowSlice = { + flows: Map + + /* ID-specific Flow */ + /* Getters */ + getFlowOrNull: ( + flowId: FlowId, + identifiersOverride?: TransactionStoreIdentifiers, + ) => StoredFlow | null + getFlowStageOrNull: ( + flowId: FlowId, + identifiersOverride?: TransactionStoreIdentifiers, + ) => TransactionFlowStage | 'complete' | null + getFlowTransactions: ( + flowId: FlowId, + identifiersOverride?: TransactionStoreIdentifiers, + ) => StoredTransactionList + getFlowTransactionsOrNull: ( + flowId: FlowId, + identifiersOverride?: TransactionStoreIdentifiers, + ) => StoredTransactionList | null + isFlowResumable: (flowId: FlowId, identifiersOverride?: TransactionStoreIdentifiers) => boolean + /* Setters */ + showFlowInput: ( + flowId: FlowId, + { + input, + disableBackgroundClick, + }: { input: GenericTransactionInput; disableBackgroundClick?: boolean }, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + startFlow: (flow: FlowInitialiserData, identifiersOverride?: TransactionStoreIdentifiers) => void + resumeFlow: (flowId: FlowId, identifiersOverride?: TransactionStoreIdentifiers) => void + resumeFlowWithCheck: ( + flowId: FlowId, + { push }: { push: (path: string) => void }, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + cleanupFlow: (flowId: FlowId, identifiersOverride?: TransactionStoreIdentifiers) => void + cleanupFlowUnsafe: (flowId: FlowId, identifiersOverride?: TransactionStoreIdentifiers) => void + setFlowTransactions: ( + flowId: FlowId, + transactions: UserTransaction[], + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + + /* Current Flow */ + /* Getters */ + getCurrentFlowTransactions: () => StoredTransactionList + getCurrentOrPreviousFlow: () => { flow: StoredFlow | null; isPrevious: boolean } + /* Setters */ + setCurrentFlowTransactions: (transactions: UserTransaction[]) => void + setCurrentFlowStage: (stage: TransactionFlowStage) => void + stopCurrentFlow: () => void + attemptCurrentFlowDismiss: () => void + incrementCurrentFlowTransactionIndex: () => void + resetCurrentFlowTransactionIndex: () => void +} + +const getCurrentFlow = (state: AllSlices) => { + const { account, sourceChainId, flowId } = state.current + if (!flowId) throw new Error('No flowId found') + if (!account) throw new Error('No account found') + if (!sourceChainId) throw new Error('No sourceChainId found') + const flowKey = getFlowKey({ flowId, sourceChainId, account }) + const flow = state.flows.get(flowKey) + if (!flow) throw new Error('No flow found') + return flow +} +export const getCurrentFlowOrNull = (state: AllSlices) => { + const { account, sourceChainId, flowId } = state.current + if (!account || !sourceChainId || !flowId) return null + const flowKey = getFlowKey({ flowId, sourceChainId, account }) + return state.flows.get(flowKey) ?? null +} + +export const getAllTransactionsComplete = (state: AllSlices, flow: StoredFlow) => { + const identifiers = { + account: flow.account, + sourceChainId: flow.sourceChainId, + flowId: flow.flowId, + } + return flow.transactionIds.every((transactionId) => { + const transactionKey = getTransactionKey({ transactionId, ...identifiers }) + const transaction = state.transactions.get(transactionKey) + return transaction?.status === 'success' + }) +} + +export const getNoTransactionsStarted = (state: AllSlices, flow: StoredFlow) => { + const identifiers = { + account: flow.account, + sourceChainId: flow.sourceChainId, + flowId: flow.flowId, + } + return flow.transactionIds.every((transactionId) => { + const transactionKey = getTransactionKey({ transactionId, ...identifiers }) + const transaction = state.transactions.get(transactionKey) + return transaction?.status === 'empty' + }) +} + +export const getCanRemoveFlow = (state: AllSlices, flow: StoredFlow) => { + if (flow.requiresManualCleanup) return false + if (!flow.transactionIds || flow.transactionIds.length === 0) return true + if (!flow.resumable) return true + + if (getAllTransactionsComplete(state, flow)) return true + return getNoTransactionsStarted(state, flow) +} + +export const createFlowSlice: StateCreator = ( + set, + get, +) => ({ + flows: new Map(), + getFlowOrNull: (flowId, identifiersOverride) => { + const state = get() + const identifiers = getIdentifiers(state, identifiersOverride) + const flowKey = getFlowKey({ flowId, ...identifiers }) + return state.flows.get(flowKey) ?? null + }, + getFlowStageOrNull: (flowId, identifiersOverride) => { + const state = get() + const flow = state.getFlowOrNull(flowId, identifiersOverride) + if (!flow) return null + if (flow.currentStage !== 'transaction') return flow.currentStage + const transactions = state.getFlowTransactions(flow.flowId, flow) + if (transactions.length === 0) return 'complete' + const lastTransaction = transactions[transactions.length - 1] + if (lastTransaction.status === 'success') return 'complete' + return 'transaction' + }, + getFlowTransactionsOrNull: (flowId, identifiersOverride) => { + const state = get() + const flow = state.getFlowOrNull(flowId, identifiersOverride) + if (!flow) return null + return state.getFlowTransactions(flow.flowId, flow) + }, + getFlowTransactions: (flowId, identifiersOverride) => { + const state = get() + const flow = state.getFlowOrNull(flowId, identifiersOverride) + if (!flow) throw new Error('No flow found') + return flow.transactionIds.map((transactionId) => { + const transactionKey = getTransactionKey({ transactionId, ...flow }) + const transaction = state.transactions.get(transactionKey) + if (!transaction) throw new Error('No transaction found') + return transaction + }) + }, + isFlowResumable: (flowId, identifiersOverride) => { + const state = get() + const flow = state.getFlowOrNull(flowId, identifiersOverride) + if (!flow) return false + if (getCanRemoveFlow(state, flow)) return false + return true + }, + showFlowInput: (flowId, { input, disableBackgroundClick }, identifiersOverride) => + set((mutable) => { + const identifiers = getIdentifiers(mutable, identifiersOverride) + const flowKey = getFlowKey({ flowId, ...identifiers }) + mutable.flows.set(flowKey, { + ...identifiers, + flowId, + currentStage: 'input', + currentTransactionIndex: 0, + transactionIds: [], + input: input as WritableDraft, + disableBackgroundClick, + }) + mutable.current.flowId = flowId + }), + startFlow: ({ transactions, ...flow }, identifiersOverride) => { + const { flowId } = flow + const state = get() + 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 + })() + set((mutable) => { + mutable.flows.set(flowKey, { + ...flow, + ...identifiers, + transactionIds: [], + flowId, + currentTransactionIndex: 0, + currentStage, + } as WritableDraft) + mutable.current.flowId = flowId + }) + state.setFlowTransactions(flowId, transactions, identifiers) + }, + resumeFlow: (flowId, identifiersOverride) => + set((mutable) => { + const identifiers = getIdentifiers(mutable, identifiersOverride) + const flowKey = getFlowKey({ flowId, ...identifiers }) + const flow = mutable.flows.get(flowKey) + // item no longer exists because transactions were completed + if (!flow) return + if (flow.intro) flow.currentStage = 'intro' + mutable.current.flowId = flowId + }), + resumeFlowWithCheck: (flowId, { push }, identifiersOverride) => { + const state = get() + const identifiers = getIdentifiers(state, identifiersOverride) + const flowKey = getFlowKey({ flowId, ...identifiers }) + const flow = state.flows.get(flowKey) + if (!flow) return + if (flow.resumeLink && getAllTransactionsComplete(state, flow)) { + push(flow.resumeLink) + return + } + state.resumeFlow(flowId, identifiers) + }, + cleanupFlowUnsafe: (flowId, identifiersOverride) => + set((mutable) => { + const identifiers = getIdentifiers(mutable, identifiersOverride) + const flowKey = getFlowKey({ flowId, ...identifiers }) + const flow = mutable.flows.get(flowKey) + + if (!flow) return + + for (const transaction of Object.values(mutable.transactions)) { + if ( + transaction?.flowId === flowId && + identifiers.account === transaction.account && + identifiers.sourceChainId === transaction.sourceChainId + ) { + mutable.transactions.delete(getTransactionKey(transaction)) + } + } + + mutable.flows.delete(flowKey) + }), + cleanupFlow: (flowId, identifiersOverride) => { + const state = get() + const flow = state.getFlowOrNull(flowId, identifiersOverride) + if (!flow) return + if (flow.requiresManualCleanup) return + if (flow.resumable) return + if (!getAllTransactionsComplete(state, flow)) return + state.cleanupFlowUnsafe(flowId, identifiersOverride) + }, + setFlowTransactions: (flowId, transactions, identifiersOverride) => + set((mutable) => { + const identifiers = getIdentifiers(mutable, identifiersOverride) + const flowKey = getFlowKey({ flowId, ...identifiers }) + const flow = mutable.flows.get(flowKey) + if (!flow) throw new Error('No flow found') + flow.transactionIds = [] + for (let i = 0; i < transactions.length; i += 1) { + const transaction = transactions[i] + const transactionId = `${transaction.name}-${i}` as const + flow.transactionIds.push(transactionId) + const transactionKey = getTransactionKey({ transactionId, ...flow }) + mutable.transactions.set(transactionKey, { + ...transaction, + targetChainId: transaction.targetChainId ?? flow.sourceChainId, + flowId: flow.flowId, + transactionId, + sourceChainId: flow.sourceChainId, + account: flow.account, + currentHash: null, + status: 'empty', + transactionType: null, + } as WritableDraft) + } + }), + getCurrentFlowTransactions: () => { + const state = get() + const flow = getCurrentFlowOrNull(state) + if (!flow) return [] + return state.getFlowTransactions(flow.flowId, flow) + }, + getCurrentOrPreviousFlow: () => { + const state = get() + const { account, sourceChainId, flowId: flowId_, _previousFlowId } = state.current + if (!account || !sourceChainId) return { flow: null, isPrevious: false } + + const isPrevious = !flowId_ && !!_previousFlowId + const flowId = isPrevious ? _previousFlowId : flowId_ ?? '' + const flowKey = getFlowKey({ account, sourceChainId, flowId }) + const flow = state.flows.get(flowKey) + if (!flow) return { flow: null, isPrevious: false } + return { flow, isPrevious } + }, + setCurrentFlowTransactions: (transactions) => { + const state = get() + const flow = getCurrentFlow(state) + state.setFlowTransactions(flow.flowId, transactions, flow) + }, + setCurrentFlowStage: (stage) => + set((mutable) => { + const flow = getCurrentFlow(mutable) + flow.currentStage = stage + }), + stopCurrentFlow: () => { + const flow = getCurrentFlow(get()) + set((mutable) => { + mutable.current._previousFlowId = flow.flowId + mutable.current.flowId = null + }) + setTimeout(() => { + get().cleanupFlow(flow.flowId, flow) + set((mutable) => { + mutable.current._previousFlowId = null + }) + }, 350) + }, + attemptCurrentFlowDismiss: () => { + const state = get() + const flow = getCurrentFlowOrNull(state) + if (!flow) return + if (flow.disableBackgroundClick && flow.currentStage === 'input') return + return state.stopCurrentFlow() + }, + incrementCurrentFlowTransactionIndex: () => + set((mutable) => { + const flow = getCurrentFlow(mutable) + flow.currentTransactionIndex += 1 + }), + resetCurrentFlowTransactionIndex: () => + set((mutable) => { + const flow = getCurrentFlow(mutable) + flow.currentTransactionIndex = 0 + }), +}) diff --git a/src/transaction/slices/createNotificationSlice.ts b/src/transaction/slices/createNotificationSlice.ts new file mode 100644 index 000000000..e9a5e4d9e --- /dev/null +++ b/src/transaction/slices/createNotificationSlice.ts @@ -0,0 +1,58 @@ +/* eslint-disable no-param-reassign */ +import type { WritableDraft } from 'immer/dist/internal' +import type { StateCreator } from 'zustand' + +import { onTransactionUpdateAnalytics } from '../analytics' +import type { StoredTransaction } from './createTransactionSlice' +import type { AllSlices, MiddlewareArray } from './types' +import { getIdentifiersOrNull } from './utils' + +type SuccessOrRevertedTransaction = Extract + +export type NotificationSlice = { + notificationBacklog: SuccessOrRevertedTransaction[] + currentNotification: SuccessOrRevertedTransaction | null + transactionDidUpdate: (transaction: StoredTransaction) => void + dismissNotification: () => void + clearNotifications: () => void +} + +export const createNotificationSlice: StateCreator< + AllSlices, + MiddlewareArray, + [], + NotificationSlice +> = (set) => ({ + notificationBacklog: [], + currentNotification: null, + transactionDidUpdate: (transaction) => + set((mutable) => { + const identifiers = getIdentifiersOrNull(mutable, undefined) + if (!identifiers) return + if (transaction.status !== 'success' && transaction.status !== 'reverted') return + if (transaction.status === 'success') onTransactionUpdateAnalytics(transaction) + if (mutable.currentNotification) { + mutable.notificationBacklog.push({ + ...transaction, + } as WritableDraft) + return + } + + mutable.currentNotification = transaction as WritableDraft + }), + dismissNotification: () => + set((mutable) => { + if (mutable.notificationBacklog.length > 0) { + mutable.currentNotification = + mutable.notificationBacklog.pop() as WritableDraft + return + } + + mutable.currentNotification = null + }), + clearNotifications: () => + set((mutable) => { + mutable.notificationBacklog = [] + mutable.currentNotification = null + }), +}) diff --git a/src/transaction/slices/createRegistrationFlowSlice.ts b/src/transaction/slices/createRegistrationFlowSlice.ts new file mode 100644 index 000000000..cddfac965 --- /dev/null +++ b/src/transaction/slices/createRegistrationFlowSlice.ts @@ -0,0 +1,509 @@ +import { zeroAddress, type Address, type Hex } from 'viem' +import type { StateCreator } from 'zustand' + +import { randomSecret } from '@ensdomains/ensjs/utils' + +import { childFuseObj } from '@app/components/@molecules/BurnFuses/BurnFusesContent' +import type { InitiateMoonpayRegistrationMutationResult } from '@app/components/pages/register/useMoonpayRegistration' +import { mainnetWithEns, type SourceChain } from '@app/constants/chains' +import type { ProfileRecord } from '@app/constants/profileRecordOptions' +import { getRegistrationParams } from '@app/hooks/useRegistrationParams' +import type { CurrentChildFuses } from '@app/types' +import { getSupportedChainContractAddress } from '@app/utils/getSupportedChainContractAddress' +import { wagmiConfig } from '@app/utils/query/wagmi' +import { secondsToYears, yearsToSeconds } from '@app/utils/time' + +import { getFlowKey } from '../key' +import type { TransactionStoreIdentifiers } from '../types' +import type { StoredTransaction } from './createTransactionSlice' +import type { AllSlices, MiddlewareArray } from './types' +import { getIdentifiers } from './utils' + +export type RegistrationFlowStep = 'pricing' | 'profile' | 'info' | 'transactions' | 'complete' +export type RegistrationPaymentMethod = 'ethereum' | 'moonpay' +export type RegistrationDurationType = 'date' | 'years' + +type RegistrationName = string +type RegistrationFlowKey = `["${RegistrationName}",${SourceChain['id']},"${Address}"]` + +type RegistrationPricingStepData = { + seconds: number + reverseRecord: boolean + paymentMethodChoice: RegistrationPaymentMethod + durationType: RegistrationDurationType +} + +type RegistrationProfileStepData = { + records: ProfileRecord[] + resolverAddress: Address | null + clearRecords?: boolean + permissions?: CurrentChildFuses +} + +type RegistrationTransactionsStepData = { + secret: Hex + isStarted: boolean +} + +type RegistrationFlowData = RegistrationPricingStepData & + RegistrationProfileStepData & + RegistrationTransactionsStepData + +type RegistrationFlowIdentifiers = TransactionStoreIdentifiers & { + name: RegistrationName +} + +type MoonpayExternalTransactionData = { + type: 'moonpay' + id: string + url: string +} + +export type StoredRegistrationFlow = RegistrationFlowIdentifiers & + Required & { + stepIndex: number + queue: RegistrationFlowStep[] + externalTransactionData: MoonpayExternalTransactionData | null + } + +export type RegistrationFlowSlice = { + registrationFlows: Map + + getCurrentRegistrationFlowOrDefault: ( + name: RegistrationName, + identifiersOverride?: TransactionStoreIdentifiers, + ) => StoredRegistrationFlow + getCurrentRegistrationFlowStep: ( + name: RegistrationName, + identifiersOverride?: TransactionStoreIdentifiers, + ) => RegistrationFlowStep + getCurrentCommitTransaction: ( + name: RegistrationName, + identifiersOverride?: TransactionStoreIdentifiers, + ) => StoredTransaction | null + getCurrentRegisterTransaction: ( + name: RegistrationName, + identifiersOverride?: TransactionStoreIdentifiers, + ) => StoredTransaction | null + + increaseRegistrationFlowStep: ( + name: RegistrationName, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + decreaseRegistrationFlowStep: ( + name: RegistrationName, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + setRegistrationFlowQueue: ( + name: RegistrationName, + queue: RegistrationFlowStep[], + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + setRegistrationPricingData: ( + name: RegistrationName, + pricingData: RegistrationPricingStepData, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + setRegistrationProfileData: ( + name: RegistrationName, + profileData: Partial, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + setRegistrationTransactionsData: ( + name: RegistrationName, + transactionsData: RegistrationTransactionsStepData, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + clearRegistrationFlow: ( + name: RegistrationName, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + setRegistrationFlowStarted: ( + name: RegistrationName, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + resetRegistrationFlow: ( + name: RegistrationName, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + resetRegistrationSecret: ( + name: RegistrationName, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + setRegistrationExternalTransactionData: ( + name: RegistrationName, + externalTransactionData: MoonpayExternalTransactionData, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + onRegistrationMoonpayTransactionCompleted: ( + name: RegistrationName, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + traverseRegistrationFlow: ( + name: RegistrationName, + data: { back: boolean }, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + createRegistrationFlow: ( + name: RegistrationName, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + + onRegistrationPricingStepCompleted: ( + name: RegistrationName, + data: RegistrationPricingStepData & { + resolverExists: boolean + initiateMoonpayRegistrationMutation: InitiateMoonpayRegistrationMutationResult + }, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + onRegistrationProfileStepCompleted: ( + name: RegistrationName, + data: RegistrationProfileStepData & { back: boolean }, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + onRegistrationInfoStepCompleted: ( + name: RegistrationName, + data: { back: boolean }, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + onRegistrationTransactionsStepCompleted: ( + name: RegistrationName, + data: { back: boolean; resetSecret?: boolean }, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + + startCommitNameTransaction: ( + name: RegistrationName, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + startRegisterNameTransaction: ( + name: RegistrationName, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + resumeCommitNameTransaction: ( + name: RegistrationName, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + resumeRegisterNameTransaction: ( + name: RegistrationName, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void + resetRegistrationTransactions: ( + name: RegistrationName, + identifiersOverride?: TransactionStoreIdentifiers, + ) => void +} + +const getIdentifiersWithDefault = ( + state: AllSlices, + identifiersOverride?: TransactionStoreIdentifiers, +) => { + const { account, sourceChainId } = identifiersOverride ?? state.current + return { account: account ?? zeroAddress, sourceChainId: sourceChainId ?? mainnetWithEns.id } +} + +const getCurrentRegistrationFlow = ( + name: RegistrationName, + state: AllSlices, + identifiersOverride?: TransactionStoreIdentifiers, +) => { + const { account, sourceChainId } = getIdentifiers(state, identifiersOverride) + + const registrationFlowKey = getFlowKey({ flowId: name, sourceChainId, account }) + const registrationFlow = state.registrationFlows.get(registrationFlowKey) + if (!registrationFlow) throw new Error('No registration flow found') + return registrationFlow +} + +const createDefaultRegistrationFlowData = ( + identifiers: RegistrationFlowIdentifiers, +): StoredRegistrationFlow => ({ + stepIndex: 0, + queue: ['pricing', 'info', 'transactions', 'complete'], + seconds: yearsToSeconds(1), + reverseRecord: false, + records: [], + resolverAddress: '0x', + permissions: childFuseObj, + secret: randomSecret({ platformDomain: 'enslabs.eth', campaign: 3 }), + isStarted: false, + paymentMethodChoice: 'ethereum', + externalTransactionData: null, + durationType: 'years', + clearRecords: false, + ...identifiers, +}) + +const getCommitTransactionFlowId = (name: RegistrationName) => `commit-${name}` +const getRegisterTransactionFlowId = (name: RegistrationName) => `register-${name}` + +export const createRegistrationFlowSlice: StateCreator< + AllSlices, + MiddlewareArray, + [], + RegistrationFlowSlice +> = (set, get) => ({ + registrationFlows: new Map(), + getCurrentRegistrationFlowOrDefault: (name, identifiersOverride) => { + const state = get() + const identifiers = getIdentifiersWithDefault(state, identifiersOverride) + const registrationFlowKey = getFlowKey({ flowId: name, ...identifiers }) + return ( + state.registrationFlows.get(registrationFlowKey) ?? + createDefaultRegistrationFlowData({ name, ...identifiers }) + ) + }, + getCurrentRegistrationFlowStep: (name, identifiersOverride) => { + const state = get() + const currentRegistrationFlow = state.getCurrentRegistrationFlowOrDefault( + name, + identifiersOverride, + ) + console.log('step:', currentRegistrationFlow.queue[currentRegistrationFlow.stepIndex]) + return currentRegistrationFlow.queue[currentRegistrationFlow.stepIndex] + }, + getCurrentCommitTransaction: (name, identifiersOverride) => { + const state = get() + const identifiers = getIdentifiers(state, identifiersOverride) + const flowId = getCommitTransactionFlowId(name) + const transactions = state.getFlowTransactionsOrNull(flowId, identifiers) + return transactions?.[0] ?? null + }, + getCurrentRegisterTransaction: (name, identifiersOverride) => { + const state = get() + const identifiers = getIdentifiers(state, identifiersOverride) + const flowId = getRegisterTransactionFlowId(name) + const transactions = state.getFlowTransactionsOrNull(flowId, identifiers) + return transactions?.[0] ?? null + }, + increaseRegistrationFlowStep: (name, identifiersOverride) => + set((state) => { + const registrationFlow = getCurrentRegistrationFlow(name, state, identifiersOverride) + registrationFlow.stepIndex += 1 + }), + decreaseRegistrationFlowStep: (name, identifiersOverride) => + set((state) => { + const registrationFlow = getCurrentRegistrationFlow(name, state, identifiersOverride) + registrationFlow.stepIndex -= 1 + }), + setRegistrationFlowQueue: (name, queue, identifiersOverride) => + set((state) => { + const registrationFlow = getCurrentRegistrationFlow(name, state, identifiersOverride) + registrationFlow.queue = queue + }), + setRegistrationPricingData: (name, pricingData, identifiersOverride) => + set((state) => { + const registrationFlow = getCurrentRegistrationFlow(name, state, identifiersOverride) + registrationFlow.seconds = pricingData.seconds + registrationFlow.reverseRecord = pricingData.reverseRecord + registrationFlow.paymentMethodChoice = pricingData.paymentMethodChoice + registrationFlow.durationType = pricingData.durationType + }), + setRegistrationProfileData: (name, profileData, identifiersOverride) => + set((state) => { + const registrationFlow = getCurrentRegistrationFlow(name, state, identifiersOverride) + if (profileData.records) registrationFlow.records = profileData.records + if (profileData.permissions) registrationFlow.permissions = profileData.permissions + if (profileData.resolverAddress) + registrationFlow.resolverAddress = profileData.resolverAddress + }), + setRegistrationTransactionsData: (name, transactionsData, identifiersOverride) => + set((state) => { + const registrationFlow = getCurrentRegistrationFlow(name, state, identifiersOverride) + registrationFlow.secret = transactionsData.secret + registrationFlow.isStarted = transactionsData.isStarted + }), + clearRegistrationFlow: (name, identifiersOverride) => + set((state) => { + const identifiers = getIdentifiersWithDefault(state, identifiersOverride) + const registrationFlowKey = getFlowKey({ flowId: name, ...identifiers }) + state.registrationFlows.delete(registrationFlowKey) + state.cleanupFlowUnsafe(getCommitTransactionFlowId(name), identifiers) + state.cleanupFlowUnsafe(getRegisterTransactionFlowId(name), identifiers) + }), + setRegistrationFlowStarted: (name, identifiersOverride) => + set((state) => { + const registrationFlow = getCurrentRegistrationFlow(name, state, identifiersOverride) + registrationFlow.isStarted = true + }), + resetRegistrationFlow: (name, identifiersOverride) => + set((state) => { + const identifiers = getIdentifiersWithDefault(state, identifiersOverride) + const registrationFlowKey = getFlowKey({ flowId: name, ...identifiers }) + state.registrationFlows.set( + registrationFlowKey, + createDefaultRegistrationFlowData({ name, ...identifiers }), + ) + }), + resetRegistrationSecret: (name, identifiersOverride) => + set((state) => { + const registrationFlow = getCurrentRegistrationFlow(name, state, identifiersOverride) + registrationFlow.secret = randomSecret({ platformDomain: 'enslabs.eth', campaign: 3 }) + }), + setRegistrationExternalTransactionData: (name, externalTransactionData, identifiersOverride) => + set((state) => { + const registrationFlow = getCurrentRegistrationFlow(name, state, identifiersOverride) + registrationFlow.externalTransactionData = externalTransactionData + }), + onRegistrationMoonpayTransactionCompleted: (name, identifiersOverride) => + set((state) => { + const registrationFlow = getCurrentRegistrationFlow(name, state, identifiersOverride) + registrationFlow.externalTransactionData = null + registrationFlow.stepIndex = registrationFlow.queue.findIndex((step) => step === 'complete') + }), + traverseRegistrationFlow: (name, data, identifiersOverride) => { + const state = get() + if (data.back) state.decreaseRegistrationFlowStep(name, identifiersOverride) + else state.increaseRegistrationFlowStep(name, identifiersOverride) + }, + createRegistrationFlow: (name, identifiersOverride) => + set((state) => { + const identifiers = getIdentifiers(state, identifiersOverride) + const registrationFlowKey = getFlowKey({ flowId: name, ...identifiers }) + state.registrationFlows.set( + registrationFlowKey, + createDefaultRegistrationFlowData({ name, ...identifiers }), + ) + }), + onRegistrationPricingStepCompleted: (name, data, identifiersOverride) => { + const state = get() + const identifiers = getIdentifiers(state, identifiersOverride) + if (data.paymentMethodChoice === 'moonpay') { + data.initiateMoonpayRegistrationMutation.mutate({ + address: identifiers.account, + chainId: identifiers.sourceChainId, + duration: secondsToYears(data.seconds), + name, + }) + return + } + state.createRegistrationFlow(name, identifiers) + state.setRegistrationPricingData(name, data, identifiers) + let currentRegistrationFlow = state.getCurrentRegistrationFlowOrDefault(name, identifiers) + if (!currentRegistrationFlow.queue.includes('profile')) { + // if profile is not in queue, set the default profile data + const defaultResolverAddress = getSupportedChainContractAddress({ + client: wagmiConfig.getClient({ chainId: identifiers.sourceChainId }), + contract: 'ensPublicResolver', + }) + + state.setRegistrationProfileData(name, { + records: [{ key: 'eth', group: 'address', type: 'addr', value: identifiers.account }], + clearRecords: data.resolverExists, + resolverAddress: defaultResolverAddress, + }) + + if (data.reverseRecord) { + // if reverse record is selected, add the profile step to the queue + state.setRegistrationFlowQueue( + name, + ['pricing', 'profile', 'info', 'transactions', 'complete'], + identifiers, + ) + } + } + + currentRegistrationFlow = state.getCurrentRegistrationFlowOrDefault(name, identifiers) + // if profile is in queue and reverse record is selected, make sure that eth record is included and is set to address + if (currentRegistrationFlow.queue.includes('profile') && data.reverseRecord) { + const recordsWithoutEth = currentRegistrationFlow.records.filter( + (record) => record.key !== 'eth', + ) + const newRecords: ProfileRecord[] = [ + { key: 'eth', group: 'address', type: 'addr', value: identifiers.account }, + ...recordsWithoutEth, + ] + state.setRegistrationProfileData(name, { records: newRecords }, identifiers) + } + + state.increaseRegistrationFlowStep(name, identifiers) + }, + onRegistrationProfileStepCompleted: (name, data, identifiersOverride) => { + const state = get() + state.setRegistrationProfileData( + name, + { records: data.records, resolverAddress: data.resolverAddress }, + identifiersOverride, + ) + state.traverseRegistrationFlow(name, data, identifiersOverride) + }, + onRegistrationInfoStepCompleted: (name, data, identifiersOverride) => { + const state = get() + state.traverseRegistrationFlow(name, data, identifiersOverride) + }, + onRegistrationTransactionsStepCompleted: (name, data, identifiersOverride) => { + const state = get() + if (data.resetSecret) state.resetRegistrationSecret(name, identifiersOverride) + state.traverseRegistrationFlow(name, data, identifiersOverride) + }, + startCommitNameTransaction: (name, identifiersOverride) => { + const state = get() + state.setRegistrationFlowStarted(name, identifiersOverride) + + const identifiers = getIdentifiers(state, identifiersOverride) + const currentRegistrationFlow = state.getCurrentRegistrationFlowOrDefault( + name, + identifiersOverride, + ) + const registrationParams = getRegistrationParams({ + name, + owner: identifiers.account, + registrationData: currentRegistrationFlow, + }) + + const flowId = getCommitTransactionFlowId(name) + state.startFlow({ + flowId, + transactions: [ + { + name: 'commitName', + data: registrationParams, + }, + ], + requiresManualCleanup: true, + autoClose: true, + resumeLink: `/register/${name}`, + }) + }, + startRegisterNameTransaction: (name, identifiersOverride) => { + const state = get() + const identifiers = getIdentifiers(state, identifiersOverride) + const currentRegistrationFlow = state.getCurrentRegistrationFlowOrDefault(name, identifiers) + const registrationParams = getRegistrationParams({ + name, + owner: identifiers.account, + registrationData: currentRegistrationFlow, + }) + + const flowId = getRegisterTransactionFlowId(name) + state.startFlow({ + flowId, + transactions: [ + { + name: 'registerName', + data: registrationParams, + }, + ], + requiresManualCleanup: true, + autoClose: true, + resumeLink: `/register/${name}`, + }) + }, + resumeCommitNameTransaction: (name, identifiersOverride) => { + const state = get() + state.resumeFlow(getCommitTransactionFlowId(name), identifiersOverride) + }, + resumeRegisterNameTransaction: (name, identifiersOverride) => { + const state = get() + state.resumeFlow(getRegisterTransactionFlowId(name), identifiersOverride) + }, + resetRegistrationTransactions: (name, identifiersOverride) => { + const state = get() + const identifiers = getIdentifiers(state, identifiersOverride) + state.cleanupFlowUnsafe(getCommitTransactionFlowId(name), identifiers) + state.cleanupFlowUnsafe(getRegisterTransactionFlowId(name), identifiers) + state.resetRegistrationSecret(name, identifiers) + state.traverseRegistrationFlow(name, { back: true }, identifiers) + }, +}) diff --git a/src/transaction/slices/createTransactionSlice.ts b/src/transaction/slices/createTransactionSlice.ts new file mode 100644 index 000000000..390386b64 --- /dev/null +++ b/src/transaction/slices/createTransactionSlice.ts @@ -0,0 +1,226 @@ +import type { Address, Hash, Hex } from 'viem' +import type { StateCreator } from 'zustand' + +import type { SourceChain, TargetChain } from '@app/constants/chains' + +import type { TransactionStoreIdentifiers } from '../types' +import type { UserTransactionData, UserTransactionName } from '../user/transaction' +import { + getAllTransactionsComplete, + getCanRemoveFlow, + getCurrentFlowOrNull, + type FlowId, +} from './createFlowSlice' +import type { AllSlices, MiddlewareArray } from './types' +import { compareFlow, getIdentifiersOrNull, getStoredTransaction } from './utils' + +type TransactionIndex = number +export type TransactionId = `${UserTransactionName}-${TransactionIndex}` +export type TransactionKey = `["${TransactionId}","${FlowId}",${SourceChain['id']},"${Address}"]` +export type StoredTransactionStatus = + | 'empty' + | 'waitingForUser' + | 'pending' + | 'success' + | 'reverted' +export type StoredTransactionType = 'standard' | 'safe' + +type TransactionSubmission = { + input: Hex + timestamp: number + nonce: number +} + +type EmptyStoredTransaction = { + status: 'empty' + currentHash: null + transactionType: null + transaction?: never + receipt?: never + search?: never +} + +type WaitingForUserStoredTransaction = { + status: 'waitingForUser' + currentHash: null + transactionType: StoredTransactionType + transaction: TransactionSubmission + receipt?: never +} + +type PendingStoredTransaction = { + status: 'pending' + currentHash: Hash + transactionType: StoredTransactionType +} + +type SuccessStoredTransaction = { + status: 'success' + currentHash: Hash + transactionType: StoredTransactionType + receipt: ReceiptData +} + +type RevertedStoredTransaction = { + status: 'reverted' + currentHash: Hash + transactionType: StoredTransactionType + receipt: ReceiptData +} + +export type StoredTransactionIdentifiers = TransactionStoreIdentifiers & { + targetChainId: TargetChain['id'] + flowId: FlowId + transactionId: TransactionId +} + +type ReceiptData = { + // TODO(tate): idk what we need from this yet + timestamp: number +} + +export type GenericStoredTransaction< + name extends UserTransactionName = UserTransactionName, + status extends StoredTransactionStatus = StoredTransactionStatus, +> = StoredTransactionIdentifiers & { + name: name + data: UserTransactionData + status: status + currentHash: Hash | null + transactionType: StoredTransactionType | null + + submission?: + | TransactionSubmission + | { + timestamp: number + } + receipt?: ReceiptData + search?: { + retries: number + status: 'searching' | 'found' + } +} & ( + | EmptyStoredTransaction + | WaitingForUserStoredTransaction + | PendingStoredTransaction + | SuccessStoredTransaction + | RevertedStoredTransaction + ) + +export type StoredTransaction< + status extends StoredTransactionStatus = StoredTransactionStatus, + other = {}, +> = { + [action in UserTransactionName]: GenericStoredTransaction & other +}[UserTransactionName] + +export type StoredTransactionList< + status extends StoredTransactionStatus = StoredTransactionStatus, +> = StoredTransaction[] + +export type TransactionSlice = { + transactions: Map + getTransactionsByStatus: ( + status: status, + ) => StoredTransaction[] + getAllTransactions: () => StoredTransaction[] + isTransactionResumable: (transaction: StoredTransaction) => boolean + setTransactionStatus: ( + identifiers: StoredTransactionIdentifiers, + status: StoredTransactionStatus, + ) => void + setTransactionReceipt: (identifiers: StoredTransactionIdentifiers, receipt: ReceiptData) => void + setTransactionHash: (identifiers: StoredTransactionIdentifiers, hash: Hash) => void + setTransactionSubmission: ( + identifiers: StoredTransactionIdentifiers, + submission: TransactionSubmission & Pick, + ) => void + clearTransactionsAndFlows: () => void +} + +export const createTransactionSlice: StateCreator< + AllSlices, + MiddlewareArray, + [], + TransactionSlice +> = (set, get) => ({ + transactions: new Map(), + getTransactionsByStatus: (status: status) => { + const state = get() + const identifiers = getIdentifiersOrNull(state, undefined) + if (!identifiers) return [] + return Array.from(state.transactions.values()).filter( + (t): t is StoredTransaction => + !!t && + t.status === status && + t.sourceChainId === identifiers.sourceChainId && + t.account === identifiers.account, + ) + }, + getAllTransactions: () => { + const state = get() + const identifiers = getIdentifiersOrNull(state, undefined) + if (!identifiers) return [] + return Array.from(state.transactions.values()).filter( + (t): t is StoredTransaction => + !!t && t.sourceChainId === identifiers.sourceChainId && t.account === identifiers.account, + ) + }, + isTransactionResumable: (transaction) => { + const state = get() + const flow = state.getFlowOrNull(transaction.flowId, transaction) + if (!flow) return false + if (getCanRemoveFlow(state, flow)) return false + const transactionIndex = flow.transactionIds.indexOf(transaction.transactionId) + return transactionIndex === flow.currentTransactionIndex + }, + setTransactionStatus: (identifiers, status) => { + set((mutable) => { + const transaction = getStoredTransaction(mutable, identifiers) + transaction.status = status + }) + const state = get() + const transaction = getStoredTransaction(state, identifiers) + state.transactionDidUpdate(transaction) + + const flow = state.getFlowOrNull(transaction.flowId, transaction) + if (!flow) return + if (!flow.autoClose) return + if (!getAllTransactionsComplete(state, flow)) return + + const currentFlow = getCurrentFlowOrNull(state) + if (!currentFlow) return + const isEqual = compareFlow(currentFlow, flow) + if (!isEqual) return + state.stopCurrentFlow() + }, + setTransactionReceipt: (identifiers, receipt) => + set((mutable) => { + const transaction = getStoredTransaction(mutable, identifiers) + transaction.receipt = receipt + }), + setTransactionHash: (identifiers, hash) => { + set((mutable) => { + const transaction = getStoredTransaction(mutable, identifiers) + transaction.currentHash = hash + }) + const state = get() + const transaction = getStoredTransaction(state, identifiers) + if (transaction.status === 'empty' || transaction.status === 'waitingForUser') + state.setTransactionStatus(identifiers, 'pending') + }, + setTransactionSubmission: (identifiers, { transactionType, ...submission }) => { + set((mutable) => { + const transaction = getStoredTransaction(mutable, identifiers) + transaction.submission = submission + transaction.transactionType = transactionType + }) + const state = get() + state.setTransactionStatus(identifiers, 'waitingForUser') + }, + clearTransactionsAndFlows: () => + set((mutable) => { + mutable.transactions.clear() + mutable.flows.clear() + }), +}) diff --git a/src/transaction/slices/types.ts b/src/transaction/slices/types.ts new file mode 100644 index 000000000..4c1ce322b --- /dev/null +++ b/src/transaction/slices/types.ts @@ -0,0 +1,17 @@ +import type { CurrentSlice } from './createCurrentSlice' +import type { FlowSlice } from './createFlowSlice' +import type { NotificationSlice } from './createNotificationSlice' +import type { RegistrationFlowSlice } from './createRegistrationFlowSlice' +import type { TransactionSlice } from './createTransactionSlice' + +export type AllSlices = FlowSlice & + CurrentSlice & + TransactionSlice & + NotificationSlice & + RegistrationFlowSlice + +export type MiddlewareArray = [ + ['zustand/persist', unknown], + ['zustand/subscribeWithSelector', never], + ['zustand/immer', never], +] diff --git a/src/transaction/slices/utils.ts b/src/transaction/slices/utils.ts new file mode 100644 index 000000000..88f29ced8 --- /dev/null +++ b/src/transaction/slices/utils.ts @@ -0,0 +1,43 @@ +import { getTransactionKey } from '../key' +import type { TransactionStoreIdentifiers } from '../types' +import type { CurrentSlice } from './createCurrentSlice' +import type { StoredFlow } from './createFlowSlice' +import type { StoredTransactionIdentifiers } from './createTransactionSlice' +import type { AllSlices } from './types' + +export const getIdentifiersOrNull = ( + state: CurrentSlice, + identifiersOverride: TransactionStoreIdentifiers | undefined, +) => { + const { account, sourceChainId } = identifiersOverride ?? state.current + if (!account) return null + if (!sourceChainId) return null + return { account, sourceChainId } +} + +export const getIdentifiers = ( + state: CurrentSlice, + identifiersOverride: TransactionStoreIdentifiers | undefined, +) => { + const { account, sourceChainId } = identifiersOverride ?? state.current + if (!account) throw new Error('No account found') + if (!sourceChainId) throw new Error('No sourceChainId found') + return { account, sourceChainId } +} + +export const getStoredTransaction = ( + state: AllSlices, + identifiers: StoredTransactionIdentifiers, +) => { + const transactionKey = getTransactionKey(identifiers) + const transaction = state.transactions.get(transactionKey) + if (!transaction) throw new Error('No transaction found') + return transaction +} + +export const compareFlow = (a: StoredFlow, b: StoredFlow) => { + if (a.flowId !== b.flowId) return false + if (a.account !== b.account) return false + if (a.sourceChainId !== b.sourceChainId) return false + return true +} diff --git a/src/transaction/transactionAnalyticsListener.ts b/src/transaction/transactionAnalyticsListener.ts deleted file mode 100644 index b2d56c00d..000000000 --- a/src/transaction/transactionAnalyticsListener.ts +++ /dev/null @@ -1,29 +0,0 @@ -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/transactionManager.ts b/src/transaction/transactionManager.ts new file mode 100644 index 000000000..dfaa0e8ba --- /dev/null +++ b/src/transaction/transactionManager.ts @@ -0,0 +1,70 @@ +import { del as idbDel, get as idbGet, set as idbSet } from 'idb-keyval' +import { enableMapSet } from 'immer' +import { persist, subscribeWithSelector } from 'zustand/middleware' +import { immer } from 'zustand/middleware/immer' +import { createWithEqualityFn } from 'zustand/traditional' +import { shallow } from 'zustand/vanilla/shallow' + +import { createCurrentSlice } from './slices/createCurrentSlice' +import { createFlowSlice } from './slices/createFlowSlice' +import { createNotificationSlice } from './slices/createNotificationSlice' +import { createRegistrationFlowSlice } from './slices/createRegistrationFlowSlice' +import { createTransactionSlice } from './slices/createTransactionSlice' +import type { AllSlices } from './slices/types' +import { transactionReceiptListener } from './transactionReceiptListener' + +// export const useTransactionStore = create< +// AllSlices, +// [ +// ['zustand/persist', unknown], +// ['zustand/subscribeWithSelector', never], +// ['zustand/immer', never], +// ], +// [], +// >()((...a) => ({ +// ...createCurrentSlice(...a), +// ...createFlowSlice(...a), +// ...createTransactionSlice(...a), +// ...createNotificationSlice(...a), +// })) + +enableMapSet() + +export const useTransactionManager = createWithEqualityFn()( + persist( + subscribeWithSelector( + immer((...a) => ({ + ...createCurrentSlice(...a), + ...createFlowSlice(...a), + ...createTransactionSlice(...a), + ...createNotificationSlice(...a), + ...createRegistrationFlowSlice(...a), + })), + ), + { + name: 'transaction-data', + storage: { + getItem: async (name) => { + const value = await idbGet(name) + return value ?? null + }, + setItem: idbSet, + removeItem: idbDel, + }, + onRehydrateStorage: (state) => { + return () => state._setHasHydrated(true) + }, + skipHydration: typeof window === 'undefined', + partialize: (state) => + ({ + flows: state.flows, + transactions: state.transactions, + }) as Pick, + }, + ), + shallow, +) + +export type UseTransactionManager = typeof useTransactionManager + +useTransactionManager.subscribe(...transactionReceiptListener(useTransactionManager)) diff --git a/src/transaction/transactionReceiptListener.ts b/src/transaction/transactionReceiptListener.ts index a40993bed..676a23982 100644 --- a/src/transaction/transactionReceiptListener.ts +++ b/src/transaction/transactionReceiptListener.ts @@ -6,47 +6,48 @@ 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' +import type { StoredTransactionList } from './slices/createTransactionSlice' +import type { UseTransactionManager } from './transactionManager' const transactionRequestCache = new Map>() const blockRequestCache = new Map>() const listenForTransaction = async ( - store: UseTransactionStore, - transaction: TransactionList<'pending'>[number], + store: UseTransactionManager, + transaction: StoredTransactionList<'pending'>[number], ) => { const receipt = await waitForTransaction(wagmiConfig, { confirmations: 1, hash: transaction.currentHash, isSafeTx: transaction.transactionType === 'safe', - chainId: transaction.chainId, + chainId: transaction.targetChainId, onReplaced: (replacedTransaction) => { if (replacedTransaction.reason !== 'repriced') return - store.getState().transaction.setHash(transaction, replacedTransaction.transaction.hash) + store.getState().setTransactionHash(transaction, replacedTransaction.transaction.hash) }, }) const { status, blockHash } = receipt let blockRequest = blockRequestCache.get(blockHash) if (!blockRequest) { - const client = wagmiConfig.getClient({ chainId: transaction.chainId }) + const client = wagmiConfig.getClient({ chainId: transaction.targetChainId }) 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 { timestamp } = await blockRequest + store.getState().setTransactionReceipt(transaction, { timestamp: Number(timestamp) * 1000 }) + store.getState().setTransactionStatus(transaction, status) const transactionKey = getTransactionKey(transaction) transactionRequestCache.delete(transactionKey) } -export const transactionReceiptListener = (store: UseTransactionStore) => +export const transactionReceiptListener = (store: UseTransactionManager) => createTransactionListener( - (s) => s.transaction.getByStatus('pending'), + (s) => s.getTransactionsByStatus('pending'), (pendingTransactions) => { for (const tx of pendingTransactions) { const transactionKey = getTransactionKey(tx) diff --git a/src/transaction/transactionStore.ts b/src/transaction/transactionStore.ts deleted file mode 100644 index 03e9086e9..000000000 --- a/src/transaction/transactionStore.ts +++ /dev/null @@ -1,352 +0,0 @@ -/* 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 index 853be856c..58cebedb4 100644 --- a/src/transaction/types.ts +++ b/src/transaction/types.ts @@ -1,248 +1,292 @@ -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' +// 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 { Address } from 'viem' + +import type { SourceChain } from '@app/constants/chains' + +// import type { SourceChain, TargetChain } from '@app/constants/chains' +// import type { IntroComponentName } from '@app/transaction-flow/intro' + +// import type { DataInputComponent, DataInputName } from './user/input' +// import type { IntroComponent } from './user/intro' +// import type { TransactionData, TransactionItemUnion, 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'] + sourceChainId: SourceChain['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 -} +// export type FlowId = string +// export type FlowKey = `["${FlowId}",${SourceChain['id']},"${Address}"]` +// type TransactionIndex = number +// export type TransactionId = `${TransactionName}-${TransactionIndex}` +// export type TransactionKey = `["${TransactionId}","${FlowId}",${SourceChain['id']},"${Address}"]` -type GenericIntro< - name extends IntroComponentName = IntroComponentName, - // TODO(tate): add correct type for data - data extends {} = {}, -> = { - name: name - data: data -} +// export type GenericDataInput< +// name extends DataInputName = DataInputName, +// data extends ComponentProps = ComponentProps, +// > = { +// name: name +// data: data +// } +// export type DataInput = { +// [name in DataInputName]: GenericDataInput +// }[DataInputName] -type StoredTranslationReference = [key: string, options?: TOptions] +// type GenericDataIntro< +// name extends IntroComponentName = IntroComponentName, +// data extends ComponentProps = ComponentProps, +// > = { +// name: name +// data: data +// } -export type TransactionIntro = { - title: StoredTranslationReference - leadingLabel?: StoredTranslationReference - trailingLabel?: StoredTranslationReference - content: GenericIntro -} +// export type DataIntro = { +// [name in IntroComponentName]: GenericDataIntro +// }[IntroComponentName] -type EmptyStoredTransaction = { - status: 'empty' - currentHash: null - transactionType: null - transaction?: never - receipt?: never - search?: never -} +// type StoredTranslationReference = [key: string, options?: TOptions] -type WaitingForUserStoredTransaction = { - status: 'waitingForUser' - currentHash: null - transactionType: StoredTransactionType - transaction: { - input: Hex - timestamp: number - nonce: number - } - receipt?: never -} +// export type TransactionIntro = { +// title: StoredTranslationReference +// leadingLabel?: StoredTranslationReference +// trailingLabel?: StoredTranslationReference +// content: DataIntro +// } -type PendingStoredTransaction = { - status: 'pending' - currentHash: Hash - transactionType: StoredTransactionType -} +// type EmptyStoredTransaction = { +// status: 'empty' +// currentHash: null +// transactionType: null +// transaction?: never +// receipt?: never +// search?: never +// } -type SuccessStoredTransaction = { - status: 'success' - currentHash: Hash - transactionType: StoredTransactionType -} +// type WaitingForUserStoredTransaction = { +// status: 'waitingForUser' +// currentHash: null +// transactionType: StoredTransactionType +// transaction: { +// input: Hex +// timestamp: number +// nonce: number +// } +// receipt?: never +// } -type RevertedStoredTransaction = { - status: 'reverted' - currentHash: Hash - transactionType: StoredTransactionType -} +// type PendingStoredTransaction = { +// status: 'pending' +// currentHash: Hash +// transactionType: StoredTransactionType +// } -export type StoredTransactionIdentifiers = TransactionStoreIdentifiers & { - flowId: FlowId - transactionId: TransactionId -} +// type SuccessStoredTransaction = { +// status: 'success' +// currentHash: Hash +// transactionType: StoredTransactionType +// } -type TransactionSubmission = { - input: Hex - timestamp: number - nonce: number -} +// type RevertedStoredTransaction = { +// status: 'reverted' +// currentHash: Hash +// transactionType: StoredTransactionType +// } -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 StoredTransactionIdentifiers = TransactionStoreIdentifiers & { +// targetChainId: TargetChain['id'] +// flowId: FlowId +// transactionId: TransactionId +// } -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 - } -} +// type TransactionSubmission = { +// input: Hex +// timestamp: number +// nonce: number +// } -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 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: TransactionId[] +// currentTransactionIndex: number +// currentStage: TransactionFlowStage +// input?: DataInput +// intro?: TransactionIntro +// resumable?: boolean +// requiresManualCleanup?: boolean +// autoClose?: boolean +// resumeLink?: string +// disableBackgroundClick?: boolean +// } + +// export type FlowInitialiserData = Omit< +// StoredFlow, +// 'currentStage' | 'currentTransactionIndex' | 'transactionIds' | keyof TransactionStoreIdentifiers +// > & { +// transactions: TransactionItemUnion[] +// } + +// export type LastTransactionChange = StoredTransaction + +// export type TransactionStoreData = { +// flows: { +// [flowKey: FlowKey]: StoredFlow | undefined +// } +// transactions: { +// [transactionKey: TransactionKey]: StoredTransaction | undefined +// } +// current: { +// flowId: string | null +// sourceChainId: SourceChain['id'] | null +// account: Address | null +// _previousFlowId: string | null +// } +// lastTransactionChange: LastTransactionChange | null +// _hasHydrated: boolean +// } + +// 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: FlowId, +// { +// input, +// disableBackgroundClick, +// }: { input: GenericDataInput; disableBackgroundClick?: boolean }, +// identifiersOverride?: TransactionStoreIdentifiers, +// ) => void +// start: (flow: FlowInitialiserData, identifiersOverride?: TransactionStoreIdentifiers) => void +// resume: (flowId: FlowId, identifiersOverride?: TransactionStoreIdentifiers) => void +// resumeWithCheck: ( +// flowId: FlowId, +// { push }: { push: (path: string) => void }, +// identifiersOverride?: TransactionStoreIdentifiers, +// ) => void +// getResumable: (flowId: FlowId, identifiersOverride?: TransactionStoreIdentifiers) => boolean +// cleanupUnsafe: (flowId: FlowId, identifiersOverride?: TransactionStoreIdentifiers) => void +// cleanup: (flowId: FlowId, identifiersOverride?: TransactionStoreIdentifiers) => void +// setTransactions: ( +// flowId: FlowId, +// transactions: TransactionItemUnion[], +// identifiersOverride?: TransactionStoreIdentifiers, +// ) => void +// getTransactions: ( +// flowId: FlowId, +// identifiersOverride?: TransactionStoreIdentifiers, +// ) => TransactionList +// getStageOrNull: ( +// flowId: FlowId, +// identifiersOverride?: TransactionStoreIdentifiers, +// ) => TransactionFlowStage | 'complete' | null +// getFlowOrNull: ( +// flowId: FlowId, +// identifiersOverride?: TransactionStoreIdentifiers, +// ) => StoredFlow | null +// current: { +// setTransactions: (transactions: TransactionItemUnion[]) => 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 +// getResumable: (transaction: StoredTransaction) => boolean +// } +// clear: () => void +// _setHasHydrated: (hasHydrated: boolean) => void +// } + +// export type NotificationQueueFunctions = { +// notificationQueue: { +// add: (transaction: StoredTransaction) => void +// consume: () => StoredTransaction | null +// } +// } + +// type NotificationQueueData = { +// notificationQueue: TransactionId[] +// } + +// type PlainTransactionStore = { +// _internal: TransactionStoreData +// } & TransactionStoreFunctions + +// type PlainNotificationQueue = { +// _internal: NotificationQueueData +// } & NotificationQueueFunctions -export type TransactionStore = { - _internal: TransactionStoreData -} & TransactionStoreFunctions +// export type TransactionStore = PlainTransactionStore & PlainNotificationQueue diff --git a/src/transaction/usePreparedDataInput.ts b/src/transaction/usePreparedDataInput.ts index ca19ca551..588001695 100644 --- a/src/transaction/usePreparedDataInput.ts +++ b/src/transaction/usePreparedDataInput.ts @@ -1,21 +1,25 @@ import type { ComponentProps } from 'react' import { useAccount } from 'wagmi' -import { useTransactionStore } from './transactionStore' -import { DataInputComponents, type DataInputComponent, type DataInputName } from './user/input' +import { useTransactionManager } from './transactionManager' +import { + transactionInputComponents, + type TransactionInputComponent, + type TransactionInputName, +} from './user/input' -type ShowDataInput = ( +type ShowDataInput = ( flowId: string, - data: ComponentProps['data'], + data: ComponentProps['data'], options?: { disableBackgroundClick?: boolean }, ) => void -export const usePreparedDataInput = (name: name) => { - const showInput = useTransactionStore((s) => s.flow.showInput) +export const usePreparedDataInput = (name: name) => { + const showInput = useTransactionManager((s) => s.showFlowInput) const { address } = useAccount() - if (address) (DataInputComponents[name] as any).render.preload() + if (address) (transactionInputComponents[name] as any).render.preload() const func: ShowDataInput = (flowId, data, options) => showInput(flowId, { diff --git a/src/transaction/user/input.tsx b/src/transaction/user/input.tsx index 238f4a67b..ac34d7e96 100644 --- a/src/transaction/user/input.tsx +++ b/src/transaction/user/input.tsx @@ -1,10 +1,24 @@ import dynamic from 'next/dynamic' -import { useContext, useEffect } from 'react' +import { useContext, useEffect, type ComponentProps } 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' +import type { Props as CreateSubnameProps } from './input/CreateSubname/CreateSubname-flow' +import type { Props as DeleteEmancipatedSubnameWarningProps } from './input/DeleteEmancipatedSubnameWarning/DeleteEmancipatedSubnameWarning-flow' +import type { Props as DeleteSubnameNotParentWarningProps } from './input/DeleteSubnameNotParentWarning/DeleteSubnameNotParentWarning-flow' +import type { Props as EditResolverProps } from './input/EditResolver/EditResolver-flow' +import type { Props as EditRolesProps } from './input/EditRoles/EditRoles-flow' +import type { Props as ExtendNamesProps } from './input/ExtendNames/ExtendNames-flow' +import type { Props as ProfileEditorProps } from './input/ProfileEditor/ProfileEditor-flow' +import type { Props as ResetPrimaryNameProps } from './input/ResetPrimaryName/ResetPrimaryName-flow' +import type { Props as RevokePermissionsProps } from './input/RevokePermissions/RevokePermissions-flow' +import type { Props as SelectPrimaryNameProps } from './input/SelectPrimaryName/SelectPrimaryName-flow' +import type { Props as SendNameProps } from './input/SendName/SendName-flow' +import type { Props as SyncManagerProps } from './input/SyncManager/SyncManager-flow' +import type { Props as UnknownLabelsProps } from './input/UnknownLabels/UnknownLabels-flow' +import type { Props as VerifyProfileProps } from './input/VerifyProfile/VerifyProfile-flow' // Lazily load input components as needed const dynamicHelper = (name: string) => @@ -13,7 +27,7 @@ const dynamicHelper = (name: string) => import( /* webpackMode: "lazy" */ /* webpackExclude: /\.test.tsx$/ */ - `./${name}-flow` + `./input/${name}-flow` ), { loading: () => { @@ -30,11 +44,60 @@ const dynamicHelper = (name: string) => ) const AdvancedEditor = dynamicHelper('AdvancedEditor/AdvancedEditor') +const CreateSubname = dynamicHelper('CreateSubname/CreateSubname') +const DeleteEmancipatedSubnameWarning = dynamicHelper( + 'DeleteEmancipatedSubnameWarning/DeleteEmancipatedSubnameWarning', +) +const DeleteSubnameNotParentWarning = dynamicHelper( + 'DeleteSubnameNotParentWarning/DeleteSubnameNotParentWarning', +) +const EditResolver = dynamicHelper('EditResolver/EditResolver') +const EditRoles = dynamicHelper('EditRoles/EditRoles') +const ExtendNames = dynamicHelper('ExtendNames/ExtendNames') +const ProfileEditor = dynamicHelper('ProfileEditor/ProfileEditor') +const ResetPrimaryName = dynamicHelper('ResetPrimaryName/ResetPrimaryName') +const RevokePermissions = dynamicHelper( + 'RevokePermissions/RevokePermissions', +) +const SelectPrimaryName = dynamicHelper( + 'SelectPrimaryName/SelectPrimaryName', +) +const SendName = dynamicHelper('SendName/SendName') +const SyncManager = dynamicHelper('SyncManager/SyncManager') +const UnknownLabels = dynamicHelper('UnknownLabels/UnknownLabels') +const VerifyProfile = dynamicHelper('VerifyProfile/VerifyProfile') -export const DataInputComponents = { +export const transactionInputComponents = { AdvancedEditor, + CreateSubname, + DeleteEmancipatedSubnameWarning, + DeleteSubnameNotParentWarning, + EditResolver, + EditRoles, + ExtendNames, + ProfileEditor, + ResetPrimaryName, + RevokePermissions, + SelectPrimaryName, + SendName, + SyncManager, + UnknownLabels, + VerifyProfile, } -export type DataInputName = keyof typeof DataInputComponents +export type TransactionInputName = keyof typeof transactionInputComponents -export type DataInputComponent = typeof DataInputComponents +export type TransactionInputComponent = typeof transactionInputComponents + +export type GenericTransactionInput< + name extends TransactionInputName = TransactionInputName, + data extends ComponentProps = ComponentProps< + TransactionInputComponent[name] + >, +> = { + name: name + data: data +} +export type TransactionInput = { + [name in TransactionInputName]: GenericTransactionInput +}[TransactionInputName] diff --git a/src/transaction/user/input/AdvancedEditor/AdvancedEditor-flow.tsx b/src/transaction/user/input/AdvancedEditor/AdvancedEditor-flow.tsx index 9cf662d8f..a6f2cb76a 100644 --- a/src/transaction/user/input/AdvancedEditor/AdvancedEditor-flow.tsx +++ b/src/transaction/user/input/AdvancedEditor/AdvancedEditor-flow.tsx @@ -10,12 +10,10 @@ 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 { useTransactionStore } from '@app/transaction/transactionStore' -import type { StoredTransaction } from '@app/transaction/types' +import type { StoredTransaction } from '@app/transaction/slices/createTransactionSlice' import { Profile } from '@app/types' import type { TransactionDialogPassthrough } from '../../../components/TransactionDialogManager' -import { createTransactionItem } from '../../transaction' const NameContainer = styled.div(({ theme }) => [ css` @@ -64,15 +62,19 @@ export type Props = { onDismiss?: () => void } & TransactionDialogPassthrough -const AdvancedEditor = ({ data, transactions = [], onDismiss }: Props) => { +const AdvancedEditor = ({ + data, + transactions = [], + onDismiss, + setTransactions, + setStage, +}: Props) => { const { t } = useTranslation('profile') const name = data?.name || '' const transaction = transactions.find( (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) @@ -87,13 +89,16 @@ const AdvancedEditor = ({ data, transactions = [], onDismiss }: Props) => { const handleCreateTransaction = useCallback( (records: RecordOptions) => { setTransactions([ - createTransactionItem('updateProfile', { - name, - resolverAddress: fetchedProfile!.resolverAddress!, - records, - }), + { + name: 'updateProfile', + data: { + name, + resolverAddress: fetchedProfile!.resolverAddress!, + records, + }, + }, ]) - setStage({ stage: 'transaction' }) + setStage('transaction') }, [fetchedProfile, setTransactions, setStage, name], ) diff --git a/src/transaction/user/input/CreateSubname-flow.tsx b/src/transaction/user/input/CreateSubname/CreateSubname-flow.tsx similarity index 83% rename from src/transaction/user/input/CreateSubname-flow.tsx rename to src/transaction/user/input/CreateSubname/CreateSubname-flow.tsx index a025c8aed..780bea278 100644 --- a/src/transaction/user/input/CreateSubname-flow.tsx +++ b/src/transaction/user/input/CreateSubname/CreateSubname-flow.tsx @@ -6,10 +6,8 @@ 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' +import { useValidateSubnameLabel } from '@app/hooks/useValidateSubnameLabel' +import type { TransactionDialogPassthrough } from '@app/transaction/components/TransactionDialogManager' type Data = { parent: string @@ -29,7 +27,12 @@ const ParentLabel = styled.div( `, ) -const CreateSubname = ({ data: { parent, isWrapped }, dispatch, onDismiss }: Props) => { +const CreateSubname = ({ + data: { parent, isWrapped }, + onDismiss, + setStage, + setTransactions, +}: Props) => { const { t } = useTranslation('profile') const [label, setLabel] = useState('') @@ -48,20 +51,17 @@ const CreateSubname = ({ data: { parent, isWrapped }, dispatch, onDismiss }: Pro const isLoading = isUseValidateSubnameLabelLoading || !isLabelsInsync const handleSubmit = () => { - dispatch({ - name: 'setTransactions', - payload: [ - createTransactionItem('createSubname', { + setTransactions([ + { + name: 'createSubname', + data: { contract: isWrapped ? 'nameWrapper' : 'registry', label, parent, - }), - ], - }) - dispatch({ - name: 'setFlowStage', - payload: 'transaction', - }) + }, + }, + ]) + setStage('transaction') } return ( diff --git a/src/transaction/user/input/DeleteEmancipatedSubnameWarning/DeleteEmancipatedSubnameWarning-flow.tsx b/src/transaction/user/input/DeleteEmancipatedSubnameWarning/DeleteEmancipatedSubnameWarning-flow.tsx index a305a3c90..cf70ccd85 100644 --- a/src/transaction/user/input/DeleteEmancipatedSubnameWarning/DeleteEmancipatedSubnameWarning-flow.tsx +++ b/src/transaction/user/input/DeleteEmancipatedSubnameWarning/DeleteEmancipatedSubnameWarning-flow.tsx @@ -4,9 +4,8 @@ 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 type { TransactionDialogPassthrough } from '@app/transaction/components/TransactionDialogManager' -import { createTransactionItem } from '../../transaction/index' import { CenterAlignedTypography } from '../RevokePermissions/components/CenterAlignedTypography' const MessageContainer = styled(CenterAlignedTypography)(({ theme }) => [ @@ -27,7 +26,7 @@ export type Props = { data: Data } & TransactionDialogPassthrough -const DeleteEmancipatedSubnameWarning = ({ data, dispatch, onDismiss }: Props) => { +const DeleteEmancipatedSubnameWarning = ({ data, onDismiss, setTransactions, setStage }: Props) => { const { t } = useTranslation('transactionFlow') const { data: wrapperData, isLoading } = useWrapperData({ name: data.name }) @@ -41,17 +40,17 @@ const DeleteEmancipatedSubnameWarning = ({ data, dispatch, onDismiss }: Props) = const expiryLabel = expiryStr ? ` (${expiryStr})` : '' const handleDelete = () => { - dispatch({ - name: 'setTransactions', - payload: [ - createTransactionItem('deleteSubname', { + setTransactions([ + { + name: 'deleteSubname', + data: { name: data.name, contract: 'nameWrapper', method: 'setRecord', - }), - ], - }) - dispatch({ name: 'setFlowStage', payload: 'transaction' }) + }, + }, + ]) + setStage('transaction') } return ( diff --git a/src/transaction/user/input/DeleteSubnameNotParentWarning/DeleteSubnameNotParentWarning-flow.tsx b/src/transaction/user/input/DeleteSubnameNotParentWarning/DeleteSubnameNotParentWarning-flow.tsx index 0a2b91ac0..3218135c2 100644 --- a/src/transaction/user/input/DeleteSubnameNotParentWarning/DeleteSubnameNotParentWarning-flow.tsx +++ b/src/transaction/user/input/DeleteSubnameNotParentWarning/DeleteSubnameNotParentWarning-flow.tsx @@ -6,9 +6,8 @@ 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 type { TransactionDialogPassthrough } from '@app/transaction/components/TransactionDialogManager' +import TransactionLoader from '@app/transaction/components/TransactionLoader' import { parentName } from '@app/utils/name' import { CenterAlignedTypography } from '../RevokePermissions/components/CenterAlignedTypography' @@ -22,7 +21,7 @@ export type Props = { data: Data } & TransactionDialogPassthrough -const DeleteSubnameNotParentWarning = ({ data, dispatch, onDismiss }: Props) => { +const DeleteSubnameNotParentWarning = ({ data, onDismiss, setTransactions, setStage }: Props) => { const { t } = useTranslation('transactionFlow') const { @@ -46,17 +45,17 @@ const DeleteSubnameNotParentWarning = ({ data, dispatch, onDismiss }: Props) => const isLoading = parentBasicLoading || parentPrimaryLoading const handleDelete = () => { - dispatch({ - name: 'setTransactions', - payload: [ - createTransactionItem('deleteSubname', { + setTransactions([ + { + name: 'deleteSubname', + data: { name: data.name, contract: data.contract, method: 'setRecord', - }), - ], - }) - dispatch({ name: 'setFlowStage', payload: 'transaction' }) + }, + }, + ]) + setStage('transaction') } if (isLoading) return diff --git a/src/transaction/user/input/EditResolver/EditResolver-flow.tsx b/src/transaction/user/input/EditResolver/EditResolver-flow.tsx index 06da8274f..35d6ec74b 100644 --- a/src/transaction/user/input/EditResolver/EditResolver-flow.tsx +++ b/src/transaction/user/input/EditResolver/EditResolver-flow.tsx @@ -8,9 +8,7 @@ import EditResolverForm from '@app/components/@molecules/EditResolver/EditResolv 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' +import type { TransactionDialogPassthrough } from '@app/transaction/components/TransactionDialogManager' type Data = { name: string @@ -20,7 +18,7 @@ export type Props = { data: Data } & TransactionDialogPassthrough -export const EditResolver = ({ data, dispatch, onDismiss }: Props) => { +export const EditResolver = ({ data, onDismiss, setTransactions, setStage }: Props) => { const { t } = useTranslation('transactionFlow') const { name } = data @@ -32,19 +30,19 @@ export const EditResolver = ({ data, dispatch, onDismiss }: Props) => { const handleCreateTransaction = useCallback( (newResolver: Address) => { - dispatch({ - name: 'setTransactions', - payload: [ - createTransactionItem('updateResolver', { + setTransactions([ + { + name: 'updateResolver', + data: { name, contract: isWrapped ? 'nameWrapper' : 'registry', resolverAddress: newResolver, - }), - ], - }) - dispatch({ name: 'setFlowStage', payload: 'transaction' }) + }, + }, + ]) + setStage('transaction') }, - [dispatch, name, isWrapped], + [setTransactions, setStage, name, isWrapped], ) const editResolverForm = useResolverEditor({ resolverAddress, callback: handleCreateTransaction }) diff --git a/src/transaction/user/input/EditRoles/EditRoles-flow.tsx b/src/transaction/user/input/EditRoles/EditRoles-flow.tsx index 71c3e982b..df3dcef17 100644 --- a/src/transaction/user/input/EditRoles/EditRoles-flow.tsx +++ b/src/transaction/user/input/EditRoles/EditRoles-flow.tsx @@ -8,10 +8,10 @@ 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 type { TransactionDialogPassthrough } from '@app/transaction/components/TransactionDialogManager' +import { createUserTransaction, type GenericUserTransaction } from '../../transaction' +import { makeTransferNameOrSubnameTransactionItem } from '../../transaction/utils/makeTransferNameOrSubnameTransactionItem' import { EditRoleView } from './views/EditRoleView/EditRoleView' import { MainView } from './views/MainView/MainView' @@ -27,7 +27,7 @@ export type Props = { data: Data } & TransactionDialogPassthrough -const EditRoles = ({ data: { name }, dispatch, onDismiss }: Props) => { +const EditRoles = ({ data: { name }, onDismiss, setTransactions, setStage }: Props) => { const [selectedRoleIndex, setSelectedRoleIndex] = useState(null) const roles = useRoles(name) @@ -73,7 +73,7 @@ const EditRoles = ({ data: { name }, dispatch, onDismiss }: Props) => { ) const transactions = [ dirtyValues['eth-record'] - ? createTransactionItem('updateEthAddress', { name, address: dirtyValues['eth-record'] }) + ? createUserTransaction('updateEthAddress', { name, address: dirtyValues['eth-record'] }) : null, dirtyValues.manager ? makeTransferNameOrSubnameTransactionItem({ @@ -97,20 +97,13 @@ const EditRoles = ({ data: { name }, dispatch, onDismiss }: Props) => { ( t, ): t is - | TransactionItem<'transferName'> - | TransactionItem<'transferSubname'> - | TransactionItem<'updateEthAddress'> => !!t, + | GenericUserTransaction<'transferName'> + | GenericUserTransaction<'transferSubname'> + | GenericUserTransaction<'updateEthAddress'> => !!t, ) - dispatch({ - name: 'setTransactions', - payload: transactions, - }) - - dispatch({ - name: 'setFlowStage', - payload: 'transaction', - }) + setTransactions(transactions) + setStage('transaction') } return ( diff --git a/src/transaction/user/input/EditRoles/EditRoles.test.tsx b/src/transaction/user/input/EditRoles/EditRoles.test.tsx index 92209f244..829b85f5d 100644 --- a/src/transaction/user/input/EditRoles/EditRoles.test.tsx +++ b/src/transaction/user/input/EditRoles/EditRoles.test.tsx @@ -67,7 +67,7 @@ vi.mock('@app/hooks/abilities/useAbilities', () => ({ })) let searchData: any[] = [] -vi.mock('@app/transaction-flow/input/EditRoles/hooks/useSimpleSearch.ts', () => ({ +vi.mock('@app/transaction/user/EditRoles/hooks/useSimpleSearch.ts', () => ({ useSimpleSearch: () => ({ mutate: (query: string) => { searchData = [{ name: `${query}.eth`, address: `0x${query}` }] diff --git a/src/transaction/user/input/EditRoles/views/EditRoleView/EditRoleView.tsx b/src/transaction/user/input/EditRoles/views/EditRoleView/EditRoleView.tsx index a021149f6..d95e41ba4 100644 --- a/src/transaction/user/input/EditRoles/views/EditRoleView/EditRoleView.tsx +++ b/src/transaction/user/input/EditRoles/views/EditRoleView/EditRoleView.tsx @@ -7,9 +7,9 @@ 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 { SearchViewErrorView } from '@app/transaction/user/input/SendName/views/SearchView/views/SearchViewErrorView' +import { SearchViewLoadingView } from '@app/transaction/user/input/SendName/views/SearchView/views/SearchViewLoadingView' +import { SearchViewNoResultsView } from '@app/transaction/user/input/SendName/views/SearchView/views/SearchViewNoResultsView' import type { EditRolesForm } from '../../EditRoles-flow' import { useSimpleSearch } from '../../hooks/useSimpleSearch' diff --git a/src/transaction/user/input/EditRoles/views/EditRoleView/views/EditRoleIntroView.tsx b/src/transaction/user/input/EditRoles/views/EditRoleView/views/EditRoleIntroView.tsx index 546b64a01..fdb191e07 100644 --- a/src/transaction/user/input/EditRoles/views/EditRoleView/views/EditRoleIntroView.tsx +++ b/src/transaction/user/input/EditRoles/views/EditRoleView/views/EditRoleIntroView.tsx @@ -7,7 +7,7 @@ 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 { SearchViewIntroView } from '@app/transaction/user/input/SendName/views/SearchView/views/SearchViewIntroView' import { emptyAddress } from '@app/utils/constants' const SHOW_REMOVE_ROLES: Role[] = ['eth-record'] diff --git a/src/transaction/user/input/EditRoles/views/EditRoleView/views/EditRoleResultsView.tsx b/src/transaction/user/input/EditRoles/views/EditRoleView/views/EditRoleResultsView.tsx index 9eb358b09..d1056e632 100644 --- a/src/transaction/user/input/EditRoles/views/EditRoleView/views/EditRoleResultsView.tsx +++ b/src/transaction/user/input/EditRoles/views/EditRoleView/views/EditRoleResultsView.tsx @@ -2,7 +2,7 @@ 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 { SearchViewResult } from '@app/transaction/user/input/SendName/views/SearchView/components/SearchViewResult' import type { useSimpleSearch } from '../../../hooks/useSimpleSearch' diff --git a/src/transaction/user/input/ExtendNames/ExtendNames-flow.tsx b/src/transaction/user/input/ExtendNames/ExtendNames-flow.tsx index 723d375d6..8f730c428 100644 --- a/src/transaction/user/input/ExtendNames/ExtendNames-flow.tsx +++ b/src/transaction/user/input/ExtendNames/ExtendNames-flow.tsx @@ -10,6 +10,8 @@ import { Avatar, Button, CurrencyToggle, Dialog, Helper, Typography } from '@ens import { CacheableComponent } from '@app/components/@atoms/CacheableComponent' import { makeCurrencyDisplay } from '@app/components/@atoms/CurrencyText/CurrencyText' +import { ShortExpiry } from '@app/components/@atoms/ExpiryComponents/ExpiryComponents' +import GasDisplay from '@app/components/@atoms/GasDisplay' 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' @@ -20,16 +22,14 @@ 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 type { TransactionDialogPassthrough } from '@app/transaction/components/TransactionDialogManager' 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' +import { createUserTransaction } from '../../transaction' type View = 'name-list' | 'no-ownership-warning' | 'registration' @@ -170,7 +170,7 @@ export type Props = { const minSeconds = ONE_DAY -const ExtendNames = ({ data: { names, isSelf }, dispatch, onDismiss }: Props) => { +const ExtendNames = ({ data: { names, isSelf }, onDismiss, setTransactions, setStage }: Props) => { const { t } = useTranslation(['transactionFlow', 'common']) const { data: ethPrice } = useEthPrice() @@ -219,7 +219,7 @@ const ExtendNames = ({ data: { names, isSelf }, dispatch, onDismiss }: Props) => const extendedDate = expiryDate ? new Date(expiryDate.getTime() + seconds * 1000) : undefined const transactions = [ - createTransactionItem('extendNames', { + createUserTransaction('extendNames', { names, duration: seconds, startDateTimestamp: expiryDate?.getTime(), @@ -302,8 +302,9 @@ const ExtendNames = ({ data: { names, isSelf }, dispatch, onDismiss }: Props) => disabled: !!estimateGasLimitError, onClick: () => { if (!totalRentFee) return - dispatch({ name: 'setTransactions', payload: transactions }) - dispatch({ name: 'setFlowStage', payload: 'transaction' }) + console.log('setting stage + transactions') + setTransactions(transactions) + setStage('transaction') }, children: t('action.next', { ns: 'common' }), })) diff --git a/src/transaction/user/input/ProfileEditor/ProfileEditor-flow.tsx b/src/transaction/user/input/ProfileEditor/ProfileEditor-flow.tsx index 394f5e64c..b1bf8dcf7 100644 --- a/src/transaction/user/input/ProfileEditor/ProfileEditor-flow.tsx +++ b/src/transaction/user/input/ProfileEditor/ProfileEditor-flow.tsx @@ -10,25 +10,25 @@ import { Button, Dialog, mq, PlusSVG } from '@ensdomains/thorin' import { DisabledButtonWithTooltip } from '@app/components/@molecules/DisabledButtonWithTooltip' import { AvatarViewManager } from '@app/components/@molecules/ProfileEditor/Avatar/AvatarViewManager' -import { AddProfileRecordView } from '@app/components/pages/profile/[name]/registration/steps/Profile/AddProfileRecordView' -import { CustomProfileRecordInput } from '@app/components/pages/profile/[name]/registration/steps/Profile/CustomProfileRecordInput' -import { ProfileRecordInput } from '@app/components/pages/profile/[name]/registration/steps/Profile/ProfileRecordInput' -import { ProfileRecordTextarea } from '@app/components/pages/profile/[name]/registration/steps/Profile/ProfileRecordTextarea' +import { AddProfileRecordView } from '@app/components/pages/register/steps/Profile/AddProfileRecordView' +import { CustomProfileRecordInput } from '@app/components/pages/register/steps/Profile/CustomProfileRecordInput' +import { ProfileRecordInput } from '@app/components/pages/register/steps/Profile/ProfileRecordInput' +import { ProfileRecordTextarea } from '@app/components/pages/register/steps/Profile/ProfileRecordTextarea' import { getProfileRecordsDiff, isEthAddressRecord, profileEditorFormToProfileRecords, profileToProfileRecords, -} from '@app/components/pages/profile/[name]/registration/steps/Profile/profileRecordUtils' +} from '@app/components/pages/register/steps/Profile/profileRecordUtils' import { ProfileRecord } from '@app/constants/profileRecordOptions' 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 { ProfileEditorForm, useProfileEditorForm } from '@app/hooks/useProfileEditorForm' -import { createTransactionItem, TransactionItem } from '@app/transaction-flow/transaction' -import TransactionLoader from '@app/transaction-flow/TransactionLoader' -import type { TransactionDialogPassthrough } from '@app/transaction-flow/types' +import type { TransactionDialogPassthrough } from '@app/transaction/components/TransactionDialogManager' +import TransactionLoader from '@app/transaction/components/TransactionLoader' +import type { GenericStoredTransaction } from '@app/transaction/slices/createTransactionSlice' import { getResolverWrapperAwareness } from '@app/utils/utils' import ResolverWarningOverlay from './ResolverWarningOverlay' @@ -109,7 +109,13 @@ const SubmitButton = ({ ) } -const ProfileEditor = ({ data = {}, transactions = [], dispatch, onDismiss }: Props) => { +const ProfileEditor = ({ + data = {}, + transactions = [], + onDismiss, + setTransactions, + setStage, +}: Props) => { const { t } = useTranslation('register') const formRef = useRef(null) @@ -149,8 +155,9 @@ const ProfileEditor = ({ data = {}, transactions = [], dispatch, onDismiss }: Pr useEffect(() => { const updateProfileRecordsWithTransactionData = () => { const transaction = transactions.find( - (item: TransactionItem) => item.name === 'updateProfileRecords', - ) as TransactionItem<'updateProfileRecords'> + (item): item is GenericStoredTransaction<'updateProfileRecords'> => + item.name === 'updateProfileRecords', + ) if (!transaction) return const updatedRecords: ProfileRecord[] = transaction?.data?.records || [] updatedRecords.forEach((record) => { @@ -188,21 +195,21 @@ const ProfileEditor = ({ data = {}, transactions = [], dispatch, onDismiss }: Pr async (form: ProfileEditorForm) => { const records = profileEditorFormToProfileRecords(form) if (!profile?.resolverAddress) return - dispatch({ - name: 'setTransactions', - payload: [ - createTransactionItem('updateProfileRecords', { + setTransactions([ + { + name: 'updateProfileRecords', + data: { name, resolverAddress: profile.resolverAddress, records, previousRecords: existingRecords, clearRecords: false, - }), - ], - }) - dispatch({ name: 'setFlowStage', payload: 'transaction' }) + }, + }, + ]) + setStage('transaction') }, - [profile, name, existingRecords, dispatch], + [profile, name, existingRecords, setTransactions, setStage], ) const [avatarSrc, setAvatarSrc] = useState() @@ -385,8 +392,9 @@ const ProfileEditor = ({ data = {}, transactions = [], dispatch, onDismiss }: Pr hasMigratedProfile={resolverStatus.data?.hasMigratedProfile} latestResolverAddress={resolverAddress!} oldResolverAddress={profile?.resolverAddress!} - dispatch={dispatch} - onDismiss={() => dispatch({ name: 'stopFlow' })} + onDismiss={onDismiss} + setTransactions={setTransactions} + setStage={setStage} onDismissOverlay={() => setView('editor')} /> )) diff --git a/src/transaction/user/input/ProfileEditor/ProfileEditor.test.tsx b/src/transaction/user/input/ProfileEditor/ProfileEditor.test.tsx index ed1a26542..f1a8c4303 100644 --- a/src/transaction/user/input/ProfileEditor/ProfileEditor.test.tsx +++ b/src/transaction/user/input/ProfileEditor/ProfileEditor.test.tsx @@ -100,7 +100,7 @@ vi.mock('@app/utils/BreakpointProvider') vi.mock('@app/transaction-flow/TransactionFlowProvider') -vi.mock('@app/transaction-flow/input/ProfileEditor/components/ProfileBlurb', () => ({ +vi.mock('@app/transaction/user/ProfileEditor/components/ProfileBlurb', () => ({ ProfileBlurb: () =>
Profile Blurb
, })) diff --git a/src/transaction/user/input/ProfileEditor/ResolverWarningOverlay.tsx b/src/transaction/user/input/ProfileEditor/ResolverWarningOverlay.tsx index 1684bbf29..1c8f5091d 100644 --- a/src/transaction/user/input/ProfileEditor/ResolverWarningOverlay.tsx +++ b/src/transaction/user/input/ProfileEditor/ResolverWarningOverlay.tsx @@ -3,9 +3,8 @@ 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 type { TransactionDialogPassthrough } from '@app/transaction/components/TransactionDialogManager' +import { useTransactionManager } from '@app/transaction/transactionManager' import { InvalidResolverView } from './views/InvalidResolverView' import { MigrateProfileSelectorView } from './views/MigrateProfileSelectorView.tsx' @@ -54,12 +53,14 @@ const ResolverWarningOverlay = ({ hasOldRegistry = false, latestResolverAddress, oldResolverAddress, - dispatch, onDismiss, onDismissOverlay, + setStage, + setTransactions, }: Props) => { const { t } = useTranslation('transactionFlow') const [selectedProfile, setSelectedProfile] = useState('latest') + const startFlow = useTransactionManager((s) => s.startFlow) const flow: View[] = useMemo(() => { if (hasOldRegistry) return ['migrateRegistry'] @@ -101,99 +102,111 @@ const ResolverWarningOverlay = ({ } const handleUpdateResolver = () => { - dispatch({ - name: 'setTransactions', - payload: [ - createTransactionItem('updateResolver', { + setTransactions([ + { + name: 'updateResolver', + data: { name, contract: isWrapped ? 'nameWrapper' : 'registry', resolverAddress: latestResolverAddress, - }), - ], - }) - dispatch({ - name: 'setFlowStage', - payload: 'transaction', - }) + }, + }, + ]) + setStage('transaction') } const handleMigrateProfile = () => { - dispatch({ - name: 'startFlow', - key: `migrate-profile-${name}`, - payload: { - intro: { - title: ['input.profileEditor.intro.migrateProfile.title', { ns: 'transactionFlow' }], - content: makeIntroItem('GenericWithDescription', { + startFlow({ + flowId: `migrate-profile-${name}`, + intro: { + title: ['input.profileEditor.intro.migrateProfile.title', { ns: 'transactionFlow' }], + content: { + name: 'GenericWithDescription', + data: { description: t('input.profileEditor.intro.migrateProfile.description'), - }), + }, }, - transactions: [ - createTransactionItem('migrateProfile', { + }, + transactions: [ + { + name: 'migrateProfile', + data: { name, - }), - createTransactionItem('updateResolver', { + }, + }, + { + name: 'updateResolver', + data: { 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', { + startFlow({ + flowId: `reset-profile-${name}`, + intro: { + title: ['input.profileEditor.intro.resetProfile.title', { ns: 'transactionFlow' }], + content: { + name: 'GenericWithDescription', + data: { description: t('input.profileEditor.intro.resetProfile.description'), - }), + }, }, - transactions: [ - createTransactionItem('resetProfile', { + }, + transactions: [ + { + name: 'resetProfile', + data: { name, resolverAddress: latestResolverAddress, - }), - createTransactionItem('updateResolver', { + }, + }, + { + name: 'updateResolver', + data: { 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', { + startFlow({ + flowId: `migrate-profile-with-reset-${name}`, + intro: { + title: ['input.profileEditor.intro.migrateCurrentProfile.title', { ns: 'transactionFlow' }], + content: { + name: 'GenericWithDescription', + data: { description: t('input.profileEditor.intro.migrateCurrentProfile.description'), - }), + }, }, - transactions: [ - createTransactionItem('migrateProfileWithReset', { + }, + transactions: [ + { + name: 'migrateProfileWithReset', + data: { name, resolverAddress: oldResolverAddress, - }), - createTransactionItem('updateResolver', { + }, + }, + { + name: 'updateResolver', + data: { name, contract: isWrapped ? 'nameWrapper' : 'registry', resolverAddress: latestResolverAddress, - }), - ], - }, + }, + }, + ], }) } diff --git a/src/transaction/user/input/ResetPrimaryName/ResetPrimaryName-flow.tsx b/src/transaction/user/input/ResetPrimaryName/ResetPrimaryName-flow.tsx index d9aa797c9..285e22566 100644 --- a/src/transaction/user/input/ResetPrimaryName/ResetPrimaryName-flow.tsx +++ b/src/transaction/user/input/ResetPrimaryName/ResetPrimaryName-flow.tsx @@ -3,8 +3,8 @@ import type { Address } from 'viem' import { Button, Dialog } from '@ensdomains/thorin' -import { createTransactionItem } from '../../transaction' -import { TransactionDialogPassthrough } from '../../types' +import type { TransactionDialogPassthrough } from '@app/transaction/components/TransactionDialogManager' + import { CenteredTypography } from '../ProfileEditor/components/CenteredTypography' type Data = { @@ -16,22 +16,17 @@ export type Props = { data: Data } & TransactionDialogPassthrough -const ResetPrimaryName = ({ data: { address }, dispatch, onDismiss }: Props) => { +const ResetPrimaryName = ({ data: { address }, setTransactions, setStage, onDismiss }: Props) => { const { t } = useTranslation('transactionFlow') const handleSubmit = async () => { - dispatch({ - name: 'setTransactions', - payload: [ - createTransactionItem('resetPrimaryName', { - address, - }), - ], - }) - dispatch({ - name: 'setFlowStage', - payload: 'transaction', - }) + setTransactions([ + { + name: 'resetPrimaryName', + data: { address }, + }, + ]) + setStage('transaction') } return ( diff --git a/src/transaction/user/input/RevokePermissions/RevokePermissions-flow.tsx b/src/transaction/user/input/RevokePermissions/RevokePermissions-flow.tsx index 9e79b1b34..3e42cc9ab 100644 --- a/src/transaction/user/input/RevokePermissions/RevokePermissions-flow.tsx +++ b/src/transaction/user/input/RevokePermissions/RevokePermissions-flow.tsx @@ -1,4 +1,4 @@ -import { ComponentProps, Dispatch, useMemo, useRef, useState } from 'react' +import { ComponentProps, useMemo, useRef, useState } from 'react' import { useForm, useWatch } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { match } from 'ts-pattern' @@ -13,9 +13,8 @@ import { 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 type { TransactionDialogPassthrough } from '@app/transaction/components/TransactionDialogManager' +import type changePermissions from '@app/transaction/user/transaction/changePermissions' import { ExtractTransactionData } from '@app/types' import { dateTimeLocalToDate, dateToDateTimeLocal } from '@app/utils/datetime-local' @@ -78,8 +77,6 @@ export type RevokePermissionsDialogContentProps = ComponentProps void - dispatch: Dispatch } & TransactionDialogPassthrough export type View = @@ -178,7 +175,7 @@ const getIntialValueForCurrentIndex = (flow: View[], transactionData?: Transacti return flow.length - 1 } -const RevokePermissions = ({ data, transactions, onDismiss, dispatch }: Props) => { +const RevokePermissions = ({ data, transactions, onDismiss, setTransactions, setStage }: Props) => { const { name, flowType, @@ -279,10 +276,10 @@ const RevokePermissions = ({ data, transactions, onDismiss, dispatch }: Props) = ? Math.floor(dateTimeLocalToDate(form.expiryCustom).getTime() / 1000) : undefined - dispatch({ - name: 'setTransactions', - payload: [ - createTransactionItem('changePermissions', { + setTransactions([ + { + name: 'changePermissions', + data: { name, contract: 'setChildFuses', fuses: { @@ -290,23 +287,22 @@ const RevokePermissions = ({ data, transactions, onDismiss, dispatch }: Props) = child: childNamedFuses, }, expiry: form.expiryType === 'max' ? maxExpiry : customExpiry, - }), - ], - }) + }, + }, + ]) } else { - dispatch({ - name: 'setTransactions', - payload: [ - createTransactionItem('changePermissions', { + setTransactions([ + { + name: 'changePermissions', + data: { name, contract: 'setFuses', fuses: childNamedFuses, - }), - ], - }) + }, + }, + ]) } - - dispatch({ name: 'setFlowStage', payload: 'transaction' }) + setStage('transaction') } const [isDisabled, setDisabled] = useState(true) diff --git a/src/transaction/user/input/SelectPrimaryName/SelectPrimaryName-flow.tsx b/src/transaction/user/input/SelectPrimaryName/SelectPrimaryName-flow.tsx index 3eda7ea98..0f0b5fe98 100644 --- a/src/transaction/user/input/SelectPrimaryName/SelectPrimaryName-flow.tsx +++ b/src/transaction/user/input/SelectPrimaryName/SelectPrimaryName-flow.tsx @@ -26,12 +26,13 @@ import useDebouncedCallback from '@app/hooks/useDebouncedCallback' import { useIsWrapped } from '@app/hooks/useIsWrapped' import { useProfile } from '@app/hooks/useProfile' import { createQueryKey } from '@app/hooks/useQueryOptions' +import type { TransactionDialogPassthrough } from '@app/transaction/components/TransactionDialogManager' +import { useTransactionManager } from '@app/transaction/transactionManager' import { nameToFormData, UnknownLabelsForm, FormData as UnknownLabelsFormData, -} from '@app/transaction-flow/input/UnknownLabels/views/UnknownLabelsForm' -import { TransactionDialogPassthrough } from '@app/transaction-flow/types' +} from '@app/transaction/user/input/UnknownLabels/views/UnknownLabelsForm' import { TaggedNameItemWithFuseCheck } from './components/TaggedNameItemWithFuseCheck' @@ -107,10 +108,11 @@ const ErrorContainer = styled.div( `, ) -const SelectPrimaryName = ({ data: { address }, dispatch, onDismiss }: Props) => { +const SelectPrimaryName = ({ data: { address }, onDismiss, setTransactions, setStage }: Props) => { const { t } = useTranslation('transactionFlow') const formRef = useRef(null) const queryClient = useQueryClient() + const startFlow = useTransactionManager((s) => s.startFlow) const form = useForm({ mode: 'onChange', @@ -191,20 +193,13 @@ const SelectPrimaryName = ({ data: { address }, dispatch, onDismiss }: Props) => const transactionCount = transactionFlowItem.transactions.length if (transactionCount === 1) { // TODO: Fix typescript transactions error - dispatch({ - name: 'setTransactions', - payload: transactionFlowItem.transactions as any[], - }) - dispatch({ - name: 'setFlowStage', - payload: 'transaction', - }) + setTransactions(transactionFlowItem.transactions) + setStage('transaction') return } - dispatch({ - name: 'startFlow', - key: 'ChangePrimaryName', - payload: transactionFlowItem, + startFlow({ + flowId: 'ChangePrimaryName', + ...transactionFlowItem, }) } diff --git a/src/transaction/user/input/SendName/SendName-flow.tsx b/src/transaction/user/input/SendName/SendName-flow.tsx index d8b4372ae..9b82f8a7d 100644 --- a/src/transaction/user/input/SendName/SendName-flow.tsx +++ b/src/transaction/user/input/SendName/SendName-flow.tsx @@ -10,7 +10,7 @@ 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 type { TransactionDialogPassthrough } from '@app/transaction/components/TransactionDialogManager' import { checkCanSend, senderRole } from './utils/checkCanSend' import { getSendNameTransactions } from './utils/getSendNameTransactions' @@ -38,7 +38,7 @@ export type Props = { data: Data } & TransactionDialogPassthrough -const SendName = ({ data: { name }, dispatch, onDismiss }: Props) => { +const SendName = ({ data: { name }, onDismiss, setStage, setTransactions }: Props) => { const account = useAccountSafely() const abilities = useAbilities({ name }) const nameType = useNameType(name) @@ -107,15 +107,8 @@ const SendName = ({ data: { name }, dispatch, onDismiss }: Props) => { if (_transactions.length === 0) return - dispatch({ - name: 'setTransactions', - payload: _transactions, - }) - - dispatch({ - name: 'setFlowStage', - payload: 'transaction', - }) + setTransactions(_transactions) + setStage('transaction') } const canSend = checkCanSend({ abilities: abilities.data, nameType: nameType.data }) diff --git a/src/transaction/user/input/SendName/SendName.test.tsx b/src/transaction/user/input/SendName/SendName.test.tsx index ad7703403..440df5c77 100644 --- a/src/transaction/user/input/SendName/SendName.test.tsx +++ b/src/transaction/user/input/SendName/SendName.test.tsx @@ -66,7 +66,7 @@ vi.mock('@app/hooks/abilities/useAbilities', () => ({ })) let searchData: any[] = [] -vi.mock('@app/transaction-flow/input/EditRoles/hooks/useSimpleSearch.ts', () => ({ +vi.mock('@app/transaction/user/EditRoles/hooks/useSimpleSearch.ts', () => ({ useSimpleSearch: () => ({ mutate: (query: string) => { searchData = [{ name: `${query}.eth`, address: `0x${query}` }] diff --git a/src/transaction/user/input/SendName/utils/getSendNameTransactions.ts b/src/transaction/user/input/SendName/utils/getSendNameTransactions.ts index b721efa9c..e8013197d 100644 --- a/src/transaction/user/input/SendName/utils/getSendNameTransactions.ts +++ b/src/transaction/user/input/SendName/utils/getSendNameTransactions.ts @@ -1,8 +1,11 @@ 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 { + createUserTransaction, + type GenericUserTransaction, +} from '@app/transaction/user/transaction' +import { makeTransferNameOrSubnameTransactionItem } from '@app/transaction/user/transaction/utils/makeTransferNameOrSubnameTransactionItem' import type { SendNameForm } from '../SendName-flow' @@ -29,10 +32,10 @@ export const getSendNameTransactions = ({ const _transactions = [ setEthRecordOnly - ? createTransactionItem('updateEthAddress', { name, address: recipient }) + ? createUserTransaction('updateEthAddress', { name, address: recipient }) : null, setEthRecordAndResetProfile && resolverAddress - ? createTransactionItem('resetProfileWithRecords', { + ? createUserTransaction('resetProfileWithRecords', { name, records: { coins: [{ coin: 'ETH', value: recipient }], @@ -62,10 +65,10 @@ export const getSendNameTransactions = ({ ( transaction, ): transaction is - | TransactionItem<'transferName'> - | TransactionItem<'transferSubname'> - | TransactionItem<'updateEthAddress'> - | TransactionItem<'resetProfileWithRecords'> => !!transaction, + | GenericUserTransaction<'transferName'> + | GenericUserTransaction<'transferSubname'> + | GenericUserTransaction<'updateEthAddress'> + | GenericUserTransaction<'resetProfileWithRecords'> => !!transaction, ) return _transactions as NonNullable<(typeof _transactions)[number]>[] diff --git a/src/transaction/user/input/SendName/views/SearchView/SearchView.tsx b/src/transaction/user/input/SendName/views/SearchView/SearchView.tsx index f56d37850..425c5e895 100644 --- a/src/transaction/user/input/SendName/views/SearchView/SearchView.tsx +++ b/src/transaction/user/input/SendName/views/SearchView/SearchView.tsx @@ -8,7 +8,7 @@ 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 { useSimpleSearch } from '@app/transaction/user/input/EditRoles/hooks/useSimpleSearch' import type { SendNameForm } from '../../SendName-flow' import { SearchViewErrorView } from './views/SearchViewErrorView' diff --git a/src/transaction/user/input/SendName/views/SummaryView/SummaryView.tsx b/src/transaction/user/input/SendName/views/SummaryView/SummaryView.tsx index d848fe0f2..2453a37d3 100644 --- a/src/transaction/user/input/SendName/views/SummaryView/SummaryView.tsx +++ b/src/transaction/user/input/SendName/views/SummaryView/SummaryView.tsx @@ -6,7 +6,7 @@ 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 TransactionLoader from '@app/transaction/components/TransactionLoader' import { DetailedSwitch } from '../../../ProfileEditor/components/DetailedSwitch' import type { SendNameForm } from '../../SendName-flow' diff --git a/src/transaction/user/input/SyncManager/SyncManager-flow.tsx b/src/transaction/user/input/SyncManager/SyncManager-flow.tsx index e91a5cf69..5e5aa946b 100644 --- a/src/transaction/user/input/SyncManager/SyncManager-flow.tsx +++ b/src/transaction/user/input/SyncManager/SyncManager-flow.tsx @@ -7,13 +7,13 @@ 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 { usePrimaryNameOrAddress } from '@app/hooks/reverseRecord/usePrimaryNameOrAddress' 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 type { TransactionDialogPassthrough } from '@app/transaction/components/TransactionDialogManager' +import TransactionLoader from '@app/transaction/components/TransactionLoader' -import { usePrimaryNameOrAddress } from '../../../hooks/reverseRecord/usePrimaryNameOrAddress' +import { createUserTransaction, type GenericUserTransaction } from '../../transaction' +import { makeTransferNameOrSubnameTransactionItem } from '../../transaction/utils/makeTransferNameOrSubnameTransactionItem' import { checkCanSyncManager } from './utils/checkCanSyncManager' import { ErrorView } from './views/ErrorView' import { MainView } from './views/MainView' @@ -26,7 +26,7 @@ export type Props = { data: Data } & TransactionDialogPassthrough -const SyncManager = ({ data: { name }, dispatch, onDismiss }: Props) => { +const SyncManager = ({ data: { name }, onDismiss, setStage, setTransactions }: Props) => { const { t } = useTranslation('transactionFlow') const account = useAccountSafely() @@ -71,7 +71,7 @@ const SyncManager = ({ data: { name }, dispatch, onDismiss }: Props) => { const onClickNext = () => { const transactions = [ canSyncDNS - ? createTransactionItem('syncManager', { + ? createUserTransaction('syncManager', { name, address: account.address!, dnsImportData: dnsImportData.data!, @@ -90,18 +90,15 @@ const SyncManager = ({ data: { name }, dispatch, onDismiss }: Props) => { ( transaction, ): transaction is - | TransactionItem<'syncManager'> - | TransactionItem<'transferName'> - | TransactionItem<'transferSubname'> => !!transaction, + | GenericUserTransaction<'syncManager'> + | GenericUserTransaction<'transferName'> + | GenericUserTransaction<'transferSubname'> => !!transaction, ) if (transactions.length !== 1) return - dispatch({ - name: 'setTransactions', - payload: transactions, - }) - dispatch({ name: 'setFlowStage', payload: 'transaction' }) + setTransactions(transactions) + setStage('transaction') } return ( diff --git a/src/transaction/user/input/UnknownLabels/UnknownLabels-flow.tsx b/src/transaction/user/input/UnknownLabels/UnknownLabels-flow.tsx index e6fa9d93a..2cd59dbfb 100644 --- a/src/transaction/user/input/UnknownLabels/UnknownLabels-flow.tsx +++ b/src/transaction/user/input/UnknownLabels/UnknownLabels-flow.tsx @@ -5,26 +5,27 @@ import { useForm } from 'react-hook-form' import { saveName } from '@ensdomains/ensjs/utils' import { useQueryOptions } from '@app/hooks/useQueryOptions' +import type { TransactionDialogPassthrough } from '@app/transaction/components/TransactionDialogManager' +import type { FlowInitialiserData } from '@app/transaction/slices/createFlowSlice' +import { useTransactionManager } from '@app/transaction/transactionManager' -import { TransactionDialogPassthrough, TransactionFlowItem } from '../../types' +import type { TransactionIntro } from '../../intro' +import type { UserTransaction } from '../../transaction' import { FormData, nameToFormData, UnknownLabelsForm } from './views/UnknownLabelsForm' type Data = { name: string - key: string - transactionFlowItem: TransactionFlowItem + flow: Pick } export type Props = { data: Data } & TransactionDialogPassthrough -const UnknownLabels = ({ - data: { name, key, transactionFlowItem }, - dispatch, - onDismiss, -}: Props) => { +const UnknownLabels = ({ data: { name, flow }, onDismiss }: Props) => { const queryClient = useQueryClient() + const getTransactions = useTransactionManager((s) => s.getFlowTransactions) + const startFlow = useTransactionManager((s) => s.startFlow) const formRef = useRef(null) @@ -51,34 +52,37 @@ const UnknownLabels = ({ saveName(newName) - const { transactions, intro } = transactionFlowItem + const { flowId, intro } = flow + const transactions = getTransactions(flow.flowId) - const newKey = key.replace(name, newName) + const newFlowId = flowId.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 + ) as UserTransaction[] + + const newIntro = ( + intro && + typeof intro.content.data === 'object' && + intro.content.data && + 'name' in intro.content.data && + intro.content.data?.name ? { ...intro, content: { ...intro.content, data: { ...intro.content.data, name: newName } }, } : intro + ) as TransactionIntro queryClient.resetQueries({ queryKey: validateKey, exact: true }) - dispatch({ - name: 'startFlow', - key: newKey, - payload: { - ...transactionFlowItem, - transactions: newTransactions, - intro: newIntro as any, - }, + startFlow({ + ...flow, + flowId: newFlowId, + transactions: newTransactions, + intro: newIntro, }) } diff --git a/src/transaction/user/input/VerifyProfile/VerifyProfile-flow.tsx b/src/transaction/user/input/VerifyProfile/VerifyProfile-flow.tsx index a3fd6eedc..3d6a5da2c 100644 --- a/src/transaction/user/input/VerifyProfile/VerifyProfile-flow.tsx +++ b/src/transaction/user/input/VerifyProfile/VerifyProfile-flow.tsx @@ -5,7 +5,7 @@ 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 type { TransactionDialogPassthrough } from '@app/transaction/components/TransactionDialogManager' import { SearchViewLoadingView } from '../SendName/views/SearchView/views/SearchViewLoadingView' import { DentityView } from './views/DentityView' @@ -23,7 +23,7 @@ export type Props = { data: Data } & TransactionDialogPassthrough -const VerifyProfile = ({ data: { name }, dispatch, onDismiss }: Props) => { +const VerifyProfile = ({ data: { name }, onDismiss, setStage, setTransactions }: Props) => { const [protocol, setProtocol] = useState(null) const { data: profile, isLoading: isProfileLoading } = useProfile({ name }) @@ -59,7 +59,8 @@ const VerifyProfile = ({ data: { name }, dispatch, onDismiss }: Props) => { address={_address!} resolverAddress={_resolverAddress!} verified={!!verificationData?.some(({ issuer }) => issuer === 'dentity')} - dispatch={dispatch} + setStage={setStage} + setTransactions={setTransactions} onBack={() => setProtocol(null)} /> ), diff --git a/src/transaction/user/input/VerifyProfile/views/DentityView.tsx b/src/transaction/user/input/VerifyProfile/views/DentityView.tsx index 768702948..de9c78e68 100644 --- a/src/transaction/user/input/VerifyProfile/views/DentityView.tsx +++ b/src/transaction/user/input/VerifyProfile/views/DentityView.tsx @@ -1,4 +1,3 @@ -import { Dispatch } from 'react' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' import { Hash } from 'viem' @@ -6,8 +5,7 @@ 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 type { TransactionDialogPassthrough } from '@app/transaction/components/TransactionDialogManager' import { CenteredTypography } from '../../ProfileEditor/components/CenteredTypography' import { createDentityAuthUrl } from '../utils/createDentityUrl' @@ -53,41 +51,35 @@ export const DentityView = ({ verified, resolverAddress, onBack, - dispatch, + setStage, + setTransactions, }: { name: string address: Hash verified: boolean resolverAddress: Hash onBack?: () => void - dispatch: Dispatch -}) => { +} & Omit) => { const { t } = useTranslation('transactionFlow') // Clear transactions before going back const onBackAndCleanup = () => { - dispatch({ - name: 'setTransactions', - payload: [], - }) + setTransactions([]) onBack?.() } const onRemoveVerification = () => { - dispatch({ - name: 'setTransactions', - payload: [ - createTransactionItem('removeVerificationRecord', { + setTransactions([ + { + name: 'removeVerificationRecord', + data: { name, verifier: 'dentity', resolverAddress, - }), - ], - }) - dispatch({ - name: 'setFlowStage', - payload: 'transaction', - }) + }, + }, + ]) + setStage('transaction') } return ( diff --git a/src/transaction/user/intro.tsx b/src/transaction/user/intro.tsx new file mode 100644 index 000000000..76ba3e69e --- /dev/null +++ b/src/transaction/user/intro.tsx @@ -0,0 +1,64 @@ +import type { TOptions } from 'i18next' +import { ComponentProps } from 'react' + +import { ChangePrimaryName } from './intro/ChangePrimaryName' +import { GenericWithDescription } from './intro/GenericWithDescription' +import { MigrateAndUpdateResolver } from './intro/MigrateAndUpdateResolver' +import { SyncManager } from './intro/SyncManager' +import { WrapName } from './intro/WrapName' + +export const transactionIntroComponents = { + WrapName, + MigrateAndUpdateResolver, + SyncManager, + ChangePrimaryName, + GenericWithDescription, +} + +export type TransactionIntroComponent = typeof transactionIntroComponents +export type TransactionIntroComponentName = keyof TransactionIntroComponent + +export type TransactionIntroComponentParameters = + unknown extends ComponentProps + ? undefined + : ComponentProps + +export const createTransactionIntro = ( + name: name, + data: ComponentProps, +) => ({ + name, + data, +}) + +export const AnyTransactionIntro = ({ + name, + data, +}: GenericTransactionIntro) => { + const Content = transactionIntroComponents[name] + return +} + +type GenericTransactionIntro< + name extends TransactionIntroComponentName = TransactionIntroComponentName, + data extends + TransactionIntroComponentParameters = TransactionIntroComponentParameters, +> = { + name: name + data: data +} + +export type TransactionIntroContent = { + [name in TransactionIntroComponentName]: GenericTransactionIntro +}[TransactionIntroComponentName] + +type StoredTranslationReference = [key: string, options?: TOptions] + +export type TransactionIntro< + name extends TransactionIntroComponentName = TransactionIntroComponentName, +> = { + title: StoredTranslationReference + leadingLabel?: StoredTranslationReference + trailingLabel?: StoredTranslationReference + content: GenericTransactionIntro +} diff --git a/src/transaction-flow/intro/ChangePrimaryName.tsx b/src/transaction/user/intro/ChangePrimaryName.tsx similarity index 90% rename from src/transaction-flow/intro/ChangePrimaryName.tsx rename to src/transaction/user/intro/ChangePrimaryName.tsx index 212ab1934..8c3d503a5 100644 --- a/src/transaction-flow/intro/ChangePrimaryName.tsx +++ b/src/transaction/user/intro/ChangePrimaryName.tsx @@ -17,7 +17,8 @@ const DescriptionWrapper = styled(Typography)( `, ) -export const ChangePrimaryName = () => { +// eslint-disable-next-line no-empty-pattern +export const ChangePrimaryName = ({}: {}) => { const { t } = useTranslation('profile') return ( diff --git a/src/transaction-flow/intro/GenericWithDescription.tsx b/src/transaction/user/intro/GenericWithDescription.tsx similarity index 100% rename from src/transaction-flow/intro/GenericWithDescription.tsx rename to src/transaction/user/intro/GenericWithDescription.tsx diff --git a/src/transaction-flow/intro/MigrateAndUpdateResolver.tsx b/src/transaction/user/intro/MigrateAndUpdateResolver.tsx similarity index 91% rename from src/transaction-flow/intro/MigrateAndUpdateResolver.tsx rename to src/transaction/user/intro/MigrateAndUpdateResolver.tsx index 6fd3ecc67..2833696c9 100644 --- a/src/transaction-flow/intro/MigrateAndUpdateResolver.tsx +++ b/src/transaction/user/intro/MigrateAndUpdateResolver.tsx @@ -19,7 +19,8 @@ const DescriptionWrapper = styled(Typography)( `, ) -export const MigrateAndUpdateResolver = () => { +// eslint-disable-next-line no-empty-pattern +export const MigrateAndUpdateResolver = ({}: {}) => { const { t } = useTranslation('transactionFlow') return ( <> diff --git a/src/transaction-flow/intro/SyncManager.tsx b/src/transaction/user/intro/SyncManager.tsx similarity index 100% rename from src/transaction-flow/intro/SyncManager.tsx rename to src/transaction/user/intro/SyncManager.tsx diff --git a/src/transaction-flow/intro/WrapName.tsx b/src/transaction/user/intro/WrapName.tsx similarity index 100% rename from src/transaction-flow/intro/WrapName.tsx rename to src/transaction/user/intro/WrapName.tsx diff --git a/src/transaction/user/transaction.ts b/src/transaction/user/transaction.ts index 5d3509770..bcf83471b 100644 --- a/src/transaction/user/transaction.ts +++ b/src/transaction/user/transaction.ts @@ -1,3 +1,7 @@ +import type { TargetChain } from '@app/constants/chains' + +// eslint-disable-next-line @typescript-eslint/naming-convention +import __dev_failure from './transaction/__dev_failure' import approveDnsRegistrar from './transaction/approveDnsRegistrar' import approveNameWrapper from './transaction/approveNameWrapper' import burnFuses from './transaction/burnFuses' @@ -60,42 +64,46 @@ export const userTransactions = { wrapName, updateVerificationRecord, removeVerificationRecord, + // eslint-disable-next-line @typescript-eslint/naming-convention + __dev_failure, } export type UserTransactionObject = typeof userTransactions -export type TransactionName = keyof UserTransactionObject +export type UserTransactionName = keyof UserTransactionObject -export type TransactionParameters = Parameters< +export type UserTransactionParameters = Parameters< UserTransactionObject[name]['transaction'] >[0] -export type TransactionData = TransactionParameters['data'] +export type UserTransactionData = + UserTransactionParameters['data'] -export type TransactionReturnType = ReturnType< +export type UserTransactionReturnType = ReturnType< UserTransactionObject[name]['transaction'] > -export const createTransactionItem = ( +export const createUserTransaction = ( name: name, - data: TransactionData, + data: UserTransactionData, ) => ({ name, data, }) -export const createTransactionRequest = ({ +export const createTransactionRequest = ({ name, ...rest -}: { name: name } & TransactionParameters): TransactionReturnType => { +}: { name: name } & UserTransactionParameters): UserTransactionReturnType => { // i think this has to be any :( - return userTransactions[name].transaction({ ...rest } as any) as TransactionReturnType + return userTransactions[name].transaction({ ...rest } as any) as UserTransactionReturnType } -export type TransactionItem = { +export type GenericUserTransaction = { name: name - data: TransactionData + data: UserTransactionData + targetChainId?: TargetChain['id'] } -export type TransactionItemUnion = { - [name in TransactionName]: TransactionItem -}[TransactionName] +export type UserTransaction = { + [name in UserTransactionName]: GenericUserTransaction +}[UserTransactionName] diff --git a/src/transaction/user/transaction/__dev_failure.ts b/src/transaction/user/transaction/__dev_failure.ts new file mode 100644 index 000000000..70c386cfc --- /dev/null +++ b/src/transaction/user/transaction/__dev_failure.ts @@ -0,0 +1,25 @@ +import { Transaction, TransactionDisplayItem, type TransactionFunctionParameters } from '@app/types' + +type Data = {} + +// eslint-disable-next-line no-empty-pattern +const displayItems = ({}: Data): TransactionDisplayItem[] => [ + { + label: 'action', + value: '__dev_failure', + }, + { + label: 'info', + value: 'DO NOT USE', + }, +] + +// eslint-disable-next-line no-empty-pattern +const transaction = async ({}: TransactionFunctionParameters) => { + return { + to: '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0', + data: '0x1231237123423423', + } as const +} + +export default { displayItems, transaction } satisfies Transaction diff --git a/src/transaction/user/transaction/extendNames.ts b/src/transaction/user/transaction/extendNames.ts index ac2ff598a..9a3cd1520 100644 --- a/src/transaction/user/transaction/extendNames.ts +++ b/src/transaction/user/transaction/extendNames.ts @@ -5,7 +5,7 @@ import { renewNames } from '@ensdomains/ensjs/wallet' import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' -import { calculateValueWithBuffer, formatDurationOfDates } from '../../utils/utils' +import { calculateValueWithBuffer, formatDurationOfDates } from '../../../utils/utils' type Data = { names: string[] diff --git a/src/transaction/user/transaction/registerName.ts b/src/transaction/user/transaction/registerName.ts index 3a1d95dfb..e0d9065c8 100644 --- a/src/transaction/user/transaction/registerName.ts +++ b/src/transaction/user/transaction/registerName.ts @@ -41,6 +41,11 @@ const transaction = async ({ const value = price.base + price.premium const valueWithBuffer = calculateValueWithBuffer(value) + console.log('registerName transaction', { + ...data, + value: valueWithBuffer, + }) + return registerName.makeFunctionData(connectorClient, { ...data, value: valueWithBuffer, diff --git a/src/transaction/user/transaction/updateProfileRecords.ts b/src/transaction/user/transaction/updateProfileRecords.ts index 93d55e9ca..b378cca7b 100644 --- a/src/transaction/user/transaction/updateProfileRecords.ts +++ b/src/transaction/user/transaction/updateProfileRecords.ts @@ -7,7 +7,7 @@ import { getProfileRecordsDiff, profileRecordsToRecordOptions, profileRecordsToRecordOptionsWithDeleteAbiArray, -} from '@app/components/pages/profile/[name]/registration/steps/Profile/profileRecordUtils' +} from '@app/components/pages/register/steps/Profile/profileRecordUtils' import { ProfileRecord } from '@app/constants/profileRecordOptions' import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' import { recordOptionsToToupleList } from '@app/utils/records' diff --git a/src/transaction/user/transaction/updateResolver.ts b/src/transaction/user/transaction/updateResolver.ts index e43fa39cd..28f350a68 100644 --- a/src/transaction/user/transaction/updateResolver.ts +++ b/src/transaction/user/transaction/updateResolver.ts @@ -5,7 +5,7 @@ import { setResolver } from '@ensdomains/ensjs/wallet' import { Transaction, TransactionDisplayItem, TransactionFunctionParameters } from '@app/types' -import { shortenAddress } from '../../utils/utils' +import { shortenAddress } from '../../../utils/utils' type Data = { name: string diff --git a/src/transaction/user/transaction/utils/makeTransferNameOrSubnameTransactionItem.ts b/src/transaction/user/transaction/utils/makeTransferNameOrSubnameTransactionItem.ts index 67e68bddd..341c6cfa6 100644 --- a/src/transaction/user/transaction/utils/makeTransferNameOrSubnameTransactionItem.ts +++ b/src/transaction/user/transaction/utils/makeTransferNameOrSubnameTransactionItem.ts @@ -3,7 +3,7 @@ import { Address } from 'viem' import type { useAbilities } from '@app/hooks/abilities/useAbilities' -import { createTransactionItem, TransactionItem } from '../../transaction' +import { createUserTransaction, UserTransaction } from '../../transaction' type MakeTransferNameOrSubnameTransactionItemParams = { name: string @@ -19,7 +19,7 @@ export const makeTransferNameOrSubnameTransactionItem = ({ sendType, isOwnerOrManager, abilities, -}: MakeTransferNameOrSubnameTransactionItemParams): TransactionItem | null => { +}: MakeTransferNameOrSubnameTransactionItemParams): UserTransaction | null => { return ( match([ isOwnerOrManager, @@ -27,7 +27,7 @@ export const makeTransferNameOrSubnameTransactionItem = ({ abilities?.sendNameFunctionCallDetails?.[sendType]?.contract, ]) .with([true, 'sendOwner', P.not(P.nullish)], ([, , contract]) => - createTransactionItem('transferName', { + createUserTransaction('transferName', { name, newOwnerAddress, sendType: 'sendOwner', @@ -35,7 +35,7 @@ export const makeTransferNameOrSubnameTransactionItem = ({ }), ) .with([true, 'sendManager', 'registrar'], () => - createTransactionItem('transferName', { + createUserTransaction('transferName', { name, newOwnerAddress, sendType: 'sendManager', @@ -44,7 +44,7 @@ export const makeTransferNameOrSubnameTransactionItem = ({ }), ) .with([true, 'sendManager', P.union('registry', 'nameWrapper')], ([, , contract]) => - createTransactionItem('transferName', { + createUserTransaction('transferName', { name, newOwnerAddress, sendType: 'sendManager', @@ -53,7 +53,7 @@ export const makeTransferNameOrSubnameTransactionItem = ({ ) // A parent name can only transfer the manager .with([false, 'sendManager', P.union('registry', 'nameWrapper')], ([, , contract]) => - createTransactionItem('transferSubname', { + createUserTransaction('transferSubname', { name, newOwnerAddress, contract, diff --git a/src/utils/chains/makeLocalhostChainWithEns.ts b/src/utils/chains/makeLocalhostChainWithEns.ts index 295e9c1e8..43bc1d837 100644 --- a/src/utils/chains/makeLocalhostChainWithEns.ts +++ b/src/utils/chains/makeLocalhostChainWithEns.ts @@ -8,6 +8,12 @@ export const makeLocalhostChainWithEns = ( ) => { return { ...localhost, + blockExplorers: { + default: { + name: 'Etherscan', + url: 'https://dummy.etherscan.io', + }, + }, contracts: { ...localhost.contracts, ensRegistry: { diff --git a/src/utils/getChainName.ts b/src/utils/getChainName.ts index 960c9cda9..de5219648 100644 --- a/src/utils/getChainName.ts +++ b/src/utils/getChainName.ts @@ -1,7 +1,9 @@ -import { Config } from 'wagmi' +import { getSupportedChainById, type SupportedChain } from '@app/constants/chains' -export const getChainName = (config: Config, { chainId }: { chainId: number }) => { +export type ChainName = Lowercase> | 'mainnet' + +export const getChainName = (chainId: SupportedChain['id'] | undefined): ChainName => { if (chainId === 1 || !chainId) return 'mainnet' - const chainName = config.getClient({ chainId }).chain.name - return chainName.toLowerCase() + const chain = getSupportedChainById(chainId)! + return chain.name.toLowerCase() as ChainName } diff --git a/src/utils/query/getSourceChainId.ts b/src/utils/query/getSourceChainId.ts new file mode 100644 index 000000000..04914bee6 --- /dev/null +++ b/src/utils/query/getSourceChainId.ts @@ -0,0 +1,6 @@ +import { getSupportedChainById, type SourceChain, type TargetChain } from '@app/constants/chains' + +export const getSourceChainId = (targetChainId: TargetChain['id']): SourceChain['id'] => { + const chain = getSupportedChainById(targetChainId)! + return (chain.sourceId ?? chain.id) as SourceChain['id'] +} diff --git a/src/utils/query/wagmi.ts b/src/utils/query/wagmi.ts index 4799fefbc..014a82c6d 100644 --- a/src/utils/query/wagmi.ts +++ b/src/utils/query/wagmi.ts @@ -1,11 +1,10 @@ import { createClient, type FallbackTransport, type HttpTransport, type Transport } from 'viem' import { createConfig, createStorage, fallback, http } from 'wagmi' -import { goerli, holesky, localhost, mainnet, sepolia } from 'wagmi/chains' +import { holesky, localhost, mainnet, sepolia } from 'wagmi/chains' import { ccipRequest } from '@ensdomains/ensjs/utils' import { - goerliWithEns, holeskyWithEns, localhostWithEns, mainnetWithEns, @@ -83,7 +82,6 @@ const localStorageWithInvertMiddleware = (): Storage | undefined => { const chains = [ ...(isLocalProvider ? ([localhostWithEns] as const) : ([] as const)), mainnetWithEns, - goerliWithEns, sepoliaWithEns, holeskyWithEns, ] as const @@ -99,7 +97,6 @@ const transports = { })), [mainnet.id]: initialiseTransports('mainnet', [infuraUrl, cloudflareUrl, tenderlyUrl]), [sepolia.id]: initialiseTransports('sepolia', [infuraUrl, cloudflareUrl, tenderlyUrl]), - [goerli.id]: initialiseTransports('goerli', [infuraUrl, cloudflareUrl, tenderlyUrl]), [holesky.id]: initialiseTransports('holesky', [tenderlyUrl]), } as const diff --git a/src/utils/records/categoriseProfileTextRecords.ts b/src/utils/records/categoriseProfileTextRecords.ts index a1a7c3481..d9460b9d6 100644 --- a/src/utils/records/categoriseProfileTextRecords.ts +++ b/src/utils/records/categoriseProfileTextRecords.ts @@ -9,7 +9,7 @@ import { supportedSocialRecordKeys, } from '@app/constants/supportedSocialRecordKeys' import { VERIFICATION_RECORD_KEY } from '@app/constants/verification' -import { VerificationProtocol } from '@app/transaction-flow/input/VerifyProfile/VerifyProfile-flow' +import { VerificationProtocol } from '@app/transaction/user/VerifyProfile/VerifyProfile-flow' import { contentHashToString } from '../contenthash' import { diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 7dccb6c6c..e6ae06b60 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -5,6 +5,11 @@ import { Eth2ldName } from '@ensdomains/ensjs/dist/types/types' import { GetPriceReturnType } from '@ensdomains/ensjs/public' import { DecodedFuses } from '@ensdomains/ensjs/utils' +import { + getSupportedChainById, + type GetSupportedChainById, + type SupportedChain, +} from '@app/constants/chains' import { KNOWN_RESOLVER_DATA } from '@app/constants/resolverAddressData' import { CURRENCY_FLUCTUATION_BUFFER_PERCENTAGE } from './constants' @@ -85,8 +90,23 @@ export const formatDurationOfDates = ({ return durationStrings.join(', ') + postFix } -export const makeEtherscanLink = (data: string, network?: string, route: string = 'tx') => - `https://${!network || network === 'mainnet' ? '' : `${network}.`}etherscan.io/${route}/${data}` +export const createEtherscanLink = < + const data extends string, + const chainId extends SupportedChain['id'], + const route extends string | undefined = 'tx', +>({ + data, + chainId, + route = 'tx', +}: { + data: data + chainId: chainId + route?: route +}) => { + const chain = getSupportedChainById(chainId) + const baseUrl = chain.blockExplorers.default.url + return `${baseUrl}/${route}/${data}` as `${GetSupportedChainById['blockExplorers']['default']['url']}/${route}/${data}` +} export const isBrowser = !!( typeof window !== 'undefined' && diff --git a/src/utils/verification/getVerifierData.ts b/src/utils/verification/getVerifierData.ts index caf237dae..99eed0cc5 100644 --- a/src/utils/verification/getVerifierData.ts +++ b/src/utils/verification/getVerifierData.ts @@ -1,5 +1,5 @@ -import { createDentityPublicProfileUrl } from '@app/transaction-flow/input/VerifyProfile/utils/createDentityUrl' -import { VerificationProtocol } from '@app/transaction-flow/input/VerifyProfile/VerifyProfile-flow' +import { createDentityPublicProfileUrl } from '@app/transaction/user/input/VerifyProfile/utils/createDentityUrl' +import type { VerificationProtocol } from '@app/transaction/user/input/VerifyProfile/VerifyProfile-flow' export const getVerifierData = (key: VerificationProtocol, value: string) => { switch (key) { diff --git a/src/utils/verification/isVerificationProtocol.ts b/src/utils/verification/isVerificationProtocol.ts index 4e4a77fdb..68ad3d581 100644 --- a/src/utils/verification/isVerificationProtocol.ts +++ b/src/utils/verification/isVerificationProtocol.ts @@ -1,5 +1,5 @@ import { VERIFICATION_PROTOCOLS } from '@app/constants/verification' -import { VerificationProtocol } from '@app/transaction-flow/input/VerifyProfile/VerifyProfile-flow' +import { VerificationProtocol } from '@app/transaction/user/VerifyProfile/VerifyProfile-flow' export const isVerificationProtocol = (value: string): value is VerificationProtocol => { return VERIFICATION_PROTOCOLS.includes(value as VerificationProtocol) diff --git a/src/utils/verification/labelForVerificationProtocol.ts b/src/utils/verification/labelForVerificationProtocol.ts index b673b833e..d9d8adbe6 100644 --- a/src/utils/verification/labelForVerificationProtocol.ts +++ b/src/utils/verification/labelForVerificationProtocol.ts @@ -1,4 +1,4 @@ -import type { VerificationProtocol } from '@app/transaction-flow/input/VerifyProfile/VerifyProfile-flow' +import type { VerificationProtocol } from '@app/transaction/user/VerifyProfile/VerifyProfile-flow' export const labelForVerificationProtocol = (protocol: VerificationProtocol) => { if (protocol === 'dentity') return 'dentity.com' From 01d75f22c038e85a706744c3537c5821e600b13e Mon Sep 17 00:00:00 2001 From: tate Date: Tue, 24 Sep 2024 15:30:45 +1000 Subject: [PATCH 3/4] registration fixes --- src/components/Notifications2.tsx | 7 +- .../pages/register/Registration.tsx | 8 +- .../register/steps/Pricing/PaymentChoice.tsx | 4 +- .../pages/register/steps/Pricing/Pricing.tsx | 9 +- .../registration/useExistingCommitment.ts | 294 ------------------ .../transaction/TransactionStageModal.tsx | 2 - .../createTransactionListener.ts | 2 +- .../listeners/existingCommitListener.ts | 254 +++++++++++++++ .../transactionReceiptListener.ts | 8 +- .../utils/getBlockMetadataByTimestamp.ts | 0 src/transaction/slices/createCurrentSlice.ts | 4 +- .../slices/createRegistrationFlowSlice.ts | 29 +- .../slices/createTransactionSlice.ts | 23 +- src/transaction/transactionManager.ts | 22 +- .../input/ExtendNames/ExtendNames-flow.tsx | 1 - .../user/transaction/registerName.ts | 5 - src/utils/query/wagmi.ts | 2 +- 17 files changed, 320 insertions(+), 354 deletions(-) delete mode 100644 src/hooks/registration/useExistingCommitment.ts rename src/transaction/{ => listeners}/createTransactionListener.ts (92%) create mode 100644 src/transaction/listeners/existingCommitListener.ts rename src/transaction/{ => listeners}/transactionReceiptListener.ts (87%) rename src/{hooks/registration => transaction/listeners}/utils/getBlockMetadataByTimestamp.ts (100%) diff --git a/src/components/Notifications2.tsx b/src/components/Notifications2.tsx index 27d02a3fa..3b145b6a4 100644 --- a/src/components/Notifications2.tsx +++ b/src/components/Notifications2.tsx @@ -4,6 +4,7 @@ import styled, { css } from 'styled-components' import { Button, Toast } from '@ensdomains/thorin' +import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' import type { StoredTransaction } from '@app/transaction/slices/createTransactionSlice' import { useTransactionManager } from '@app/transaction/transactionManager' import { useBreakpoint } from '@app/utils/BreakpointProvider' @@ -31,9 +32,11 @@ const Notification = ({ open: boolean }) => { const { t } = useTranslation() + const router = useRouterWithHistory() const breakpoints = useBreakpoint() + const isFlowResumable = useTransactionManager((s) => s.isFlowResumable) - const resumeFlow = useTransactionManager((s) => s.resumeFlow) + const resumeFlow = useTransactionManager((s) => s.resumeFlowWithCheck) const resumable = transaction && isFlowResumable(transaction.flowId) @@ -64,7 +67,7 @@ const Notification = ({ diff --git a/src/components/pages/register/Registration.tsx b/src/components/pages/register/Registration.tsx index 79176ad2b..766713222 100644 --- a/src/components/pages/register/Registration.tsx +++ b/src/components/pages/register/Registration.tsx @@ -228,7 +228,13 @@ const Registration = ({ nameDetails, isLoading }: Props) => { return () => { router.events.off('routeChangeComplete', handleRouteChange) } - }, [currentRegistrationFlowStep, clearRegistrationFlow, router.asPath]) + }, [ + currentRegistrationFlowStep, + clearRegistrationFlow, + router.asPath, + router.events, + normalisedName, + ]) const onDismissMoonpayModal = () => { if (moonpayTransactionStatus === 'waitingAuthorization') { diff --git a/src/components/pages/register/steps/Pricing/PaymentChoice.tsx b/src/components/pages/register/steps/Pricing/PaymentChoice.tsx index 5e65dc419..0505f727d 100644 --- a/src/components/pages/register/steps/Pricing/PaymentChoice.tsx +++ b/src/components/pages/register/steps/Pricing/PaymentChoice.tsx @@ -241,7 +241,7 @@ export const PaymentChoice = ({ data-testid="payment-choice-ethereum" label={{t('steps.info.ethereum')}} name="RadioButtonGroup" - value={'ethereum'} + value="ethereum" disabled={moonpayTransactionStatus === 'pending'} checked={paymentMethodChoice === 'ethereum' || undefined} /> @@ -283,7 +283,7 @@ export const PaymentChoice = ({ } name="RadioButtonGroup" - value={'moonpay'} + value="moonpay" checked={paymentMethodChoice === 'moonpay' || undefined} /> {paymentMethodChoice === 'moonpay' && ( diff --git a/src/components/pages/register/steps/Pricing/Pricing.tsx b/src/components/pages/register/steps/Pricing/Pricing.tsx index a219e2150..1ea98ae00 100644 --- a/src/components/pages/register/steps/Pricing/Pricing.tsx +++ b/src/components/pages/register/steps/Pricing/Pricing.tsx @@ -7,6 +7,7 @@ import { match, P } from 'ts-pattern' import type { Address } from 'viem' import { useBalance } from 'wagmi' import { GetBalanceData } from 'wagmi/query' +import { useShallow } from 'zustand/react/shallow' import { Button, Heading, mq } from '@ensdomains/thorin' @@ -61,10 +62,6 @@ const StyledHeading = styled(Heading)( `, ) -const gridAreaStyle = ({ $name }: { $name: string }) => css` - grid-area: ${$name}; -` - export type ActionButtonProps = { address?: Address moonpayTransactionStatus?: MoonpayTransactionStatus @@ -164,8 +161,8 @@ const Pricing = ({ const { data: balance } = useBalance({ address }) const resolverAddress = useContractAddress({ contract: 'ensPublicResolver' }) - const existingRegistrationData = useTransactionManager((s) => - s.getCurrentRegistrationFlowOrDefault(name), + const existingRegistrationData = useTransactionManager( + useShallow((s) => s.getCurrentRegistrationFlowOrDefault(name)), ) const onRegistrationPricingStepCompleted = useTransactionManager( (s) => s.onRegistrationPricingStepCompleted, diff --git a/src/hooks/registration/useExistingCommitment.ts b/src/hooks/registration/useExistingCommitment.ts deleted file mode 100644 index 5af4e5f8a..000000000 --- a/src/hooks/registration/useExistingCommitment.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { QueryFunctionContext, useQuery } from '@tanstack/react-query' -import { - decodeFunctionData, - encodeFunctionData, - getAddress, - Hash, - Hex, - toFunctionSelector, -} from 'viem' -import { getBlock, getTransactionReceipt, readContract } from 'viem/actions' - -import { - ethRegistrarControllerCommitmentsSnippet, - ethRegistrarControllerCommitSnippet, - getChainContractAddress, -} from '@ensdomains/ensjs/contracts' - -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' -import { ConfigWithEns, CreateQueryKey, QueryConfig } from '@app/types' -import { getIsCachedData } from '@app/utils/getIsCachedData' -import { prepareQueryOptions } from '@app/utils/prepareQueryOptions' - -import { useInvalidateOnBlock } from '../chain/useInvalidateOnBlock' -import { useAddRecentTransaction } from '../transactions/useAddRecentTransaction' -import { useIsSafeApp } from '../useIsSafeApp' -import { useQueryOptions } from '../useQueryOptions' -import { getBlockMetadataByTimestamp } from './utils/getBlockMetadataByTimestamp' - -type UseExistingCommitmentParameters = { - commitment?: Hex - commitKey?: string -} - -type UseExistingCommitmentInternalParameters = { - setTransactionHashFromUpdate: (key: string, hash: Hash) => void - addRecentTransaction: ReturnType - isSafeTx: boolean -} - -type UseExistingCommitmentReturnType = - | { - status: 'transactionExists' - timestamp: number - } - | { - status: 'commitmentExists' - timestamp: number - } - | { - status: 'commitmentExpired' - timestamp: number - } - | null - -type UseExistingCommitmentConfig = QueryConfig - -type QueryKey = CreateQueryKey< - TParams, - 'getExistingCommitment', - 'standard' -> - -const maxCommitmentAgeSnippet = [ - { - inputs: [], - name: 'maxCommitmentAge', - outputs: [ - { - internalType: 'uint256', - name: '', - type: 'uint256', - }, - ], - stateMutability: 'view', - type: 'function', - }, -] as const - -const getCurrentBlockTimestampSnippet = [ - { - inputs: [], - name: 'getCurrentBlockTimestamp', - outputs: [ - { - internalType: 'uint256', - name: 'timestamp', - type: 'uint256', - }, - ], - stateMutability: 'view', - type: 'function', - }, -] as const - -const execTransactionSnippet = [ - { - inputs: [ - { internalType: 'address', name: 'to', type: 'address' }, - { internalType: 'uint256', name: 'value', type: 'uint256' }, - { internalType: 'bytes', name: 'data', type: 'bytes' }, - { - internalType: 'enum Enum.Operation', - name: 'operation', - type: 'uint8', - }, - { internalType: 'uint256', name: 'safeTxGas', type: 'uint256' }, - { internalType: 'uint256', name: 'baseGas', type: 'uint256' }, - { internalType: 'uint256', name: 'gasPrice', type: 'uint256' }, - { internalType: 'address', name: 'gasToken', type: 'address' }, - { - internalType: 'address payable', - name: 'refundReceiver', - type: 'address', - }, - { internalType: 'bytes', name: 'signatures', type: 'bytes' }, - ], - name: 'execTransaction', - outputs: [{ internalType: 'bool', name: 'success', type: 'bool' }], - stateMutability: 'payable', - type: 'function', - }, -] as const - -const getExistingCommitmentQueryFn = - (config: ConfigWithEns) => - ({ - addRecentTransaction, - setTransactionHashFromUpdate, - isSafeTx, - }: UseExistingCommitmentInternalParameters) => - async ({ - queryKey: [{ commitment, commitKey }, chainId, address], - }: QueryFunctionContext>): Promise => { - if (!commitment) throw new Error('commitment is required') - if (!commitKey) throw new Error('commitKey is required') - if (!address) throw new Error('address is required') - - const client = config.getClient({ chainId }) - const ethRegistrarControllerAddress = getChainContractAddress({ - client, - contract: 'ensEthRegistrarController', - }) - const multicall3Address = getChainContractAddress({ - client, - contract: 'multicall3', - }) - - const [commitmentTimestamp, maxCommitmentAge, blockTimestamp] = await Promise.all([ - readContract(client, { - abi: ethRegistrarControllerCommitmentsSnippet, - address: ethRegistrarControllerAddress, - functionName: 'commitments', - args: [commitment], - }), - readContract(client, { - abi: maxCommitmentAgeSnippet, - address: ethRegistrarControllerAddress, - functionName: 'maxCommitmentAge', - }), - readContract(client, { - abi: getCurrentBlockTimestampSnippet, - address: multicall3Address, - functionName: 'getCurrentBlockTimestamp', - }), - ]) - if (!commitmentTimestamp || commitmentTimestamp === 0n) return null - - const commitmentAge = blockTimestamp - commitmentTimestamp - const commitmentTimestampNumber = Number(commitmentTimestamp) - const existsFailure = () => - ({ status: 'commitmentExists', timestamp: commitmentTimestampNumber }) as const - - if (commitmentAge > maxCommitmentAge) - return { status: 'commitmentExpired', timestamp: commitmentTimestampNumber } as const - - const blockMetadata = await getBlockMetadataByTimestamp(client, { - timestamp: commitmentTimestamp, - }) - if (!blockMetadata.ok) return existsFailure() - - const blockData = await getBlock(client, { - blockHash: blockMetadata.data.hash, - includeTransactions: true, - }).catch(() => null) - if (!blockData) return existsFailure() - - const inputData = encodeFunctionData({ - abi: ethRegistrarControllerCommitSnippet, - args: [commitment], - functionName: 'commit', - }) - - const transaction = (() => { - const checksummedAddress = getAddress(address) - const checksummedEthRegistrarControllerAddress = getAddress(ethRegistrarControllerAddress) - if (isSafeTx) { - const execTransactionFunctionSelector = toFunctionSelector(execTransactionSnippet[0]) - const foundTransaction = blockData.transactions.find((t) => { - // safe transaction gets sent to the safe contract itself - if (!t.to || getAddress(t.to) !== checksummedAddress) return false - if (!t.input.startsWith(execTransactionFunctionSelector)) return false - const { args: safeTxData } = decodeFunctionData({ - abi: execTransactionSnippet, - data: t.input, - }) - if (getAddress(safeTxData[0]) !== checksummedEthRegistrarControllerAddress) return false - if (getAddress(safeTxData[2]) !== inputData) return false - return true - }) - return foundTransaction - } - const foundTransaction = blockData.transactions.find((t) => { - if (getAddress(t.from) !== checksummedAddress) return false - if (!t.to || getAddress(t.to) !== checksummedEthRegistrarControllerAddress) return false - if (t.input !== inputData) return false - return true - }) - return foundTransaction - })() - - if (!transaction) return existsFailure() - - const transactionReceipt = await getTransactionReceipt(client, { - hash: transaction.hash, - }) - - if (transactionReceipt.status !== 'success') return existsFailure() - - setTransactionHashFromUpdate(commitKey, transaction.hash) - addRecentTransaction({ - ...transaction, - hash: transaction.hash, - action: 'commitName', - key: commitKey, - input: inputData, - timestamp: commitmentTimestampNumber, - isSafeTx, - searchRetries: 0, - }) - - return { - status: 'transactionExists', - timestamp: commitmentTimestampNumber, - } as const - } - -export const useExistingCommitment = ({ - // config - enabled = true, - gcTime, - staleTime, - scopeKey, - // params - ...params -}: TParams & UseExistingCommitmentConfig) => { - const initialOptions = useQueryOptions({ - params, - scopeKey, - functionName: 'getExistingCommitment', - queryDependencyType: 'standard', - queryFn: getExistingCommitmentQueryFn, - }) - - const addRecentTransaction = useAddRecentTransaction() - const { setTransactionHashFromUpdate } = useTransactionFlow() - const { data: isSafeApp, isLoading: isSafeAppLoading } = useIsSafeApp() - - if (process.env.NODE_ENV === 'development' || process.env.NEXT_PUBLIC_ETH_NODE === 'anvil') - console.log('commit is:', params.commitment) - - const preparedOptions = prepareQueryOptions({ - queryKey: initialOptions.queryKey, - queryFn: initialOptions.queryFn({ - addRecentTransaction, - setTransactionHashFromUpdate, - isSafeTx: !!isSafeApp, - }), - enabled: enabled && !!params.commitment && !isSafeAppLoading, - gcTime, - staleTime, - }) - - useInvalidateOnBlock({ - enabled: preparedOptions.enabled, - queryKey: preparedOptions.queryKey, - }) - - const query = useQuery(preparedOptions) - - return { - ...query, - isCachedData: getIsCachedData(query), - } -} diff --git a/src/transaction/components/stage/transaction/TransactionStageModal.tsx b/src/transaction/components/stage/transaction/TransactionStageModal.tsx index 5903646fa..c1b6d2c98 100644 --- a/src/transaction/components/stage/transaction/TransactionStageModal.tsx +++ b/src/transaction/components/stage/transaction/TransactionStageModal.tsx @@ -176,8 +176,6 @@ export const TransactionStageModal = - console.log(transaction.status) - const dialogStatus = (() => { switch (transaction.status) { case 'empty': diff --git a/src/transaction/createTransactionListener.ts b/src/transaction/listeners/createTransactionListener.ts similarity index 92% rename from src/transaction/createTransactionListener.ts rename to src/transaction/listeners/createTransactionListener.ts index 30ca83c73..ca6f2bcbc 100644 --- a/src/transaction/createTransactionListener.ts +++ b/src/transaction/listeners/createTransactionListener.ts @@ -1,4 +1,4 @@ -import type { AllSlices } from './slices/types' +import type { AllSlices } from '../slices/types' export type TransactionStoreListener = [ selector: (state: AllSlices) => selected, diff --git a/src/transaction/listeners/existingCommitListener.ts b/src/transaction/listeners/existingCommitListener.ts new file mode 100644 index 000000000..c0c46ef0a --- /dev/null +++ b/src/transaction/listeners/existingCommitListener.ts @@ -0,0 +1,254 @@ +import { decodeFunctionData, encodeFunctionData, getAddress, toFunctionSelector } from 'viem' +import { getBlock, readContract } from 'viem/actions' + +import { + ethRegistrarControllerCommitmentsSnippet, + ethRegistrarControllerCommitSnippet, + getChainContractAddress, +} from '@ensdomains/ensjs/contracts' +import { makeCommitment } from '@ensdomains/ensjs/utils' + +import type { ClientWithEns } from '@app/types' +import { wagmiConfig } from '@app/utils/query/wagmi' + +import { getTransactionKey } from '../key' +import type { StoredTransactionResult } from '../slices/createTransactionSlice' +import type { UseTransactionManager } from '../transactionManager' +import { createTransactionListener } from './createTransactionListener' +import { getBlockMetadataByTimestamp } from './utils/getBlockMetadataByTimestamp' + +const commitSearchCache = new Map>() + +type SearchableCommitTransaction = Extract< + StoredTransactionResult<'empty' | 'pending' | 'waitingForUser'>, + { name: 'commitName' } +> + +const maxCommitmentAgeSnippet = [ + { + inputs: [], + name: 'maxCommitmentAge', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const + +const getCurrentBlockTimestampSnippet = [ + { + inputs: [], + name: 'getCurrentBlockTimestamp', + outputs: [ + { + internalType: 'uint256', + name: 'timestamp', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const + +const execTransactionSnippet = [ + { + inputs: [ + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'value', type: 'uint256' }, + { internalType: 'bytes', name: 'data', type: 'bytes' }, + { + internalType: 'enum Enum.Operation', + name: 'operation', + type: 'uint8', + }, + { internalType: 'uint256', name: 'safeTxGas', type: 'uint256' }, + { internalType: 'uint256', name: 'baseGas', type: 'uint256' }, + { internalType: 'uint256', name: 'gasPrice', type: 'uint256' }, + { internalType: 'address', name: 'gasToken', type: 'address' }, + { + internalType: 'address payable', + name: 'refundReceiver', + type: 'address', + }, + { internalType: 'bytes', name: 'signatures', type: 'bytes' }, + ], + name: 'execTransaction', + outputs: [{ internalType: 'bool', name: 'success', type: 'bool' }], + stateMutability: 'payable', + type: 'function', + }, +] as const + +const attemptCommitSearch = async ( + client: ClientWithEns, + storedTransaction: SearchableCommitTransaction, +) => { + const { data: commitmentData, transactionType, account } = storedTransaction + const commitment = makeCommitment(commitmentData) + + const ethRegistrarControllerAddress = getChainContractAddress({ + client, + contract: 'ensEthRegistrarController', + }) + const multicall3Address = getChainContractAddress({ + client, + contract: 'multicall3', + }) + + const [commitmentTimestamp, maxCommitmentAge, blockTimestamp] = await Promise.all([ + readContract(client, { + abi: ethRegistrarControllerCommitmentsSnippet, + address: ethRegistrarControllerAddress, + functionName: 'commitments', + args: [commitment], + }), + readContract(client, { + abi: maxCommitmentAgeSnippet, + address: ethRegistrarControllerAddress, + functionName: 'maxCommitmentAge', + }), + readContract(client, { + abi: getCurrentBlockTimestampSnippet, + address: multicall3Address, + functionName: 'getCurrentBlockTimestamp', + }), + ]) + + if (!commitmentTimestamp || commitmentTimestamp === 0n) + return { + status: 'notFound', + currentHash: null, + timestamp: 0, + } as const + + const commitmentAge = blockTimestamp - commitmentTimestamp + const commitmentTimestampNumber = Number(commitmentTimestamp) + + if (commitmentAge > maxCommitmentAge) + return { + status: 'commitmentExpired', + currentHash: null, + timestamp: commitmentTimestampNumber * 1000, + } as const + + const existsEarlyEscape = () => + ({ + status: 'commitmentExists', + currentHash: null, + timestamp: commitmentTimestampNumber * 1000, + }) as const + + const blockMetadata = await getBlockMetadataByTimestamp(client, { + timestamp: commitmentTimestamp, + }) + if (!blockMetadata.ok) return existsEarlyEscape() + + const blockData = await getBlock(client, { + blockHash: blockMetadata.data.hash, + includeTransactions: true, + }).catch(() => null) + if (!blockData) return existsEarlyEscape() + + const inputData = encodeFunctionData({ + abi: ethRegistrarControllerCommitSnippet, + args: [commitment], + functionName: 'commit', + }) + + const transaction = (() => { + const checksummedAddress = getAddress(account) + const checksummedEthRegistrarControllerAddress = getAddress(ethRegistrarControllerAddress) + if (transactionType === 'safe') { + const execTransactionFunctionSelector = toFunctionSelector(execTransactionSnippet[0]) + const foundTransaction = blockData.transactions.find((t) => { + // safe transaction gets sent to the safe contract itself + if (!t.to || getAddress(t.to) !== checksummedAddress) return false + if (!t.input.startsWith(execTransactionFunctionSelector)) return false + const { args: safeTxData } = decodeFunctionData({ + abi: execTransactionSnippet, + data: t.input, + }) + if (getAddress(safeTxData[0]) !== checksummedEthRegistrarControllerAddress) return false + if (getAddress(safeTxData[2]) !== inputData) return false + return true + }) + return foundTransaction + } + const foundTransaction = blockData.transactions.find((t) => { + if (getAddress(t.from) !== checksummedAddress) return false + if (!t.to || getAddress(t.to) !== checksummedEthRegistrarControllerAddress) return false + if (t.input !== inputData) return false + return true + }) + return foundTransaction + })() + + if (!transaction) return existsEarlyEscape() + + return { + status: 'commitmentFound', + currentHash: transaction.hash, + timestamp: commitmentTimestampNumber * 1000, + } as const +} + +const listenForExistingCommit = async ( + store: UseTransactionManager, + transaction: SearchableCommitTransaction, +) => { + const client = wagmiConfig.getClient({ chainId: transaction.targetChainId }) + + const cleanup = () => { + commitSearchCache.delete(getTransactionKey(transaction)) + } + + for (;;) { + /* eslint-disable no-await-in-loop */ + const result = await attemptCommitSearch(client, transaction) + const state = store.getState() + // ensure transaction wasn't already found + if (state.getTransaction(transaction)?.status === 'success') return cleanup() + + switch (result.status) { + case 'commitmentFound': + state.setTransactionHash(transaction, result.currentHash) + // eslint-disable-next-line no-fallthrough + case 'commitmentExpired': + case 'commitmentExists': + state.setTransactionStatus(transaction, 'success') + state.setTransactionReceipt(transaction, { timestamp: result.timestamp }) + return cleanup() + default: + await new Promise((resolve) => { + setTimeout(resolve, 10_000) + }) + break + } + /* eslint-enable no-await-in-loop */ + } +} + +export const existingCommitListener = (store: UseTransactionManager) => + createTransactionListener( + (s) => + s + .getTransactionsByStatus(['empty', 'pending', 'waitingForUser']) + .filter((t): t is SearchableCommitTransaction => t.name === 'commitName'), + (applicableTransactions) => { + for (const tx of applicableTransactions) { + const transactionKey = getTransactionKey(tx) + const existingSearchPromise = commitSearchCache.get(transactionKey) + // eslint-disable-next-line no-continue + if (existingSearchPromise) continue + + const searchPromise = listenForExistingCommit(store, tx) + commitSearchCache.set(transactionKey, searchPromise) + } + }, + ) diff --git a/src/transaction/transactionReceiptListener.ts b/src/transaction/listeners/transactionReceiptListener.ts similarity index 87% rename from src/transaction/transactionReceiptListener.ts rename to src/transaction/listeners/transactionReceiptListener.ts index 676a23982..487df9bcb 100644 --- a/src/transaction/transactionReceiptListener.ts +++ b/src/transaction/listeners/transactionReceiptListener.ts @@ -4,10 +4,10 @@ import { getBlock } from 'viem/actions' import { waitForTransaction } from '@app/hooks/transactions/waitForTransaction' import { wagmiConfig } from '@app/utils/query/wagmi' +import { getTransactionKey } from '../key' +import type { StoredTransactionList } from '../slices/createTransactionSlice' +import type { UseTransactionManager } from '../transactionManager' import { createTransactionListener } from './createTransactionListener' -import { getTransactionKey } from './key' -import type { StoredTransactionList } from './slices/createTransactionSlice' -import type { UseTransactionManager } from './transactionManager' const transactionRequestCache = new Map>() const blockRequestCache = new Map>() @@ -35,8 +35,6 @@ const listenForTransaction = async ( 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().setTransactionReceipt(transaction, { timestamp: Number(timestamp) * 1000 }) store.getState().setTransactionStatus(transaction, status) diff --git a/src/hooks/registration/utils/getBlockMetadataByTimestamp.ts b/src/transaction/listeners/utils/getBlockMetadataByTimestamp.ts similarity index 100% rename from src/hooks/registration/utils/getBlockMetadataByTimestamp.ts rename to src/transaction/listeners/utils/getBlockMetadataByTimestamp.ts diff --git a/src/transaction/slices/createCurrentSlice.ts b/src/transaction/slices/createCurrentSlice.ts index 8bd1c3faa..426f60f85 100644 --- a/src/transaction/slices/createCurrentSlice.ts +++ b/src/transaction/slices/createCurrentSlice.ts @@ -1,5 +1,5 @@ /* eslint-disable no-param-reassign */ -import { getAccount, watchChainId } from '@wagmi/core' +import { getAccount, getChainId, watchChainId } from '@wagmi/core' import type { Address } from 'viem' import type { StateCreator } from 'zustand' @@ -62,6 +62,8 @@ export const createCurrentSlice: StateCreator set((state) => { state._hasHydrated = hasHydrated + onChainIdUpdate(getChainId(wagmiConfig)) + onAccountUpdate(getAccount(wagmiConfig).address) }), } } diff --git a/src/transaction/slices/createRegistrationFlowSlice.ts b/src/transaction/slices/createRegistrationFlowSlice.ts index cddfac965..4e821a335 100644 --- a/src/transaction/slices/createRegistrationFlowSlice.ts +++ b/src/transaction/slices/createRegistrationFlowSlice.ts @@ -1,4 +1,4 @@ -import { zeroAddress, type Address, type Hex } from 'viem' +import { zeroAddress, zeroHash, type Address, type Hex } from 'viem' import type { StateCreator } from 'zustand' import { randomSecret } from '@ensdomains/ensjs/utils' @@ -69,6 +69,10 @@ export type StoredRegistrationFlow = RegistrationFlowIdentifiers & export type RegistrationFlowSlice = { registrationFlows: Map + getRegistrationFlow: ( + name: RegistrationName, + identifiersOverride?: TransactionStoreIdentifiers, + ) => StoredRegistrationFlow | null getCurrentRegistrationFlowOrDefault: ( name: RegistrationName, identifiersOverride?: TransactionStoreIdentifiers, @@ -226,7 +230,7 @@ const createDefaultRegistrationFlowData = ( records: [], resolverAddress: '0x', permissions: childFuseObj, - secret: randomSecret({ platformDomain: 'enslabs.eth', campaign: 3 }), + secret: zeroHash, isStarted: false, paymentMethodChoice: 'ethereum', externalTransactionData: null, @@ -245,14 +249,18 @@ export const createRegistrationFlowSlice: StateCreator< RegistrationFlowSlice > = (set, get) => ({ registrationFlows: new Map(), + getRegistrationFlow: (name, identifiersOverride) => { + const state = get() + const identifiers = getIdentifiers(state, identifiersOverride) + const registrationFlowKey = getFlowKey({ flowId: name, ...identifiers }) + return state.registrationFlows.get(registrationFlowKey) ?? null + }, getCurrentRegistrationFlowOrDefault: (name, identifiersOverride) => { const state = get() const identifiers = getIdentifiersWithDefault(state, identifiersOverride) const registrationFlowKey = getFlowKey({ flowId: name, ...identifiers }) - return ( - state.registrationFlows.get(registrationFlowKey) ?? - createDefaultRegistrationFlowData({ name, ...identifiers }) - ) + const existing = state.registrationFlows.get(registrationFlowKey) + return existing ?? createDefaultRegistrationFlowData({ name, ...identifiers }) }, getCurrentRegistrationFlowStep: (name, identifiersOverride) => { const state = get() @@ -260,7 +268,6 @@ export const createRegistrationFlowSlice: StateCreator< name, identifiersOverride, ) - console.log('step:', currentRegistrationFlow.queue[currentRegistrationFlow.stepIndex]) return currentRegistrationFlow.queue[currentRegistrationFlow.stepIndex] }, getCurrentCommitTransaction: (name, identifiersOverride) => { @@ -361,10 +368,10 @@ export const createRegistrationFlowSlice: StateCreator< set((state) => { const identifiers = getIdentifiers(state, identifiersOverride) const registrationFlowKey = getFlowKey({ flowId: name, ...identifiers }) - state.registrationFlows.set( - registrationFlowKey, - createDefaultRegistrationFlowData({ name, ...identifiers }), - ) + state.registrationFlows.set(registrationFlowKey, { + ...createDefaultRegistrationFlowData({ name, ...identifiers }), + secret: randomSecret({ platformDomain: 'enslabs.eth', campaign: 3 }), + }) }), onRegistrationPricingStepCompleted: (name, data, identifiersOverride) => { const state = get() diff --git a/src/transaction/slices/createTransactionSlice.ts b/src/transaction/slices/createTransactionSlice.ts index 390386b64..6f9257be7 100644 --- a/src/transaction/slices/createTransactionSlice.ts +++ b/src/transaction/slices/createTransactionSlice.ts @@ -3,6 +3,7 @@ import type { StateCreator } from 'zustand' import type { SourceChain, TargetChain } from '@app/constants/chains' +import { getTransactionKey } from '../key' import type { TransactionStoreIdentifiers } from '../types' import type { UserTransactionData, UserTransactionName } from '../user/transaction' import { @@ -118,11 +119,16 @@ export type StoredTransactionList< status extends StoredTransactionStatus = StoredTransactionStatus, > = StoredTransaction[] +export type StoredTransactionResult< + status extends StoredTransactionStatus | StoredTransactionStatus[], +> = StoredTransaction + export type TransactionSlice = { transactions: Map - getTransactionsByStatus: ( + getTransaction: (identifiers: StoredTransactionIdentifiers) => StoredTransaction | null + getTransactionsByStatus: ( status: status, - ) => StoredTransaction[] + ) => StoredTransactionResult[] getAllTransactions: () => StoredTransaction[] isTransactionResumable: (transaction: StoredTransaction) => boolean setTransactionStatus: ( @@ -145,14 +151,21 @@ export const createTransactionSlice: StateCreator< TransactionSlice > = (set, get) => ({ transactions: new Map(), - getTransactionsByStatus: (status: status) => { + getTransaction: (identifiers) => { + const state = get() + return state.transactions.get(getTransactionKey(identifiers)) ?? null + }, + getTransactionsByStatus: ( + status: status, + ) => { const state = get() const identifiers = getIdentifiersOrNull(state, undefined) + const statusArray: StoredTransactionStatus[] = Array.isArray(status) ? status : [status] if (!identifiers) return [] return Array.from(state.transactions.values()).filter( - (t): t is StoredTransaction => + (t): t is StoredTransactionResult => !!t && - t.status === status && + statusArray.includes(t.status) && t.sourceChainId === identifiers.sourceChainId && t.account === identifiers.account, ) diff --git a/src/transaction/transactionManager.ts b/src/transaction/transactionManager.ts index dfaa0e8ba..17c0af60e 100644 --- a/src/transaction/transactionManager.ts +++ b/src/transaction/transactionManager.ts @@ -5,28 +5,14 @@ import { immer } from 'zustand/middleware/immer' import { createWithEqualityFn } from 'zustand/traditional' import { shallow } from 'zustand/vanilla/shallow' +import { existingCommitListener } from './listeners/existingCommitListener' +import { transactionReceiptListener } from './listeners/transactionReceiptListener' import { createCurrentSlice } from './slices/createCurrentSlice' import { createFlowSlice } from './slices/createFlowSlice' import { createNotificationSlice } from './slices/createNotificationSlice' import { createRegistrationFlowSlice } from './slices/createRegistrationFlowSlice' import { createTransactionSlice } from './slices/createTransactionSlice' import type { AllSlices } from './slices/types' -import { transactionReceiptListener } from './transactionReceiptListener' - -// export const useTransactionStore = create< -// AllSlices, -// [ -// ['zustand/persist', unknown], -// ['zustand/subscribeWithSelector', never], -// ['zustand/immer', never], -// ], -// [], -// >()((...a) => ({ -// ...createCurrentSlice(...a), -// ...createFlowSlice(...a), -// ...createTransactionSlice(...a), -// ...createNotificationSlice(...a), -// })) enableMapSet() @@ -59,7 +45,8 @@ export const useTransactionManager = createWithEqualityFn()( ({ flows: state.flows, transactions: state.transactions, - }) as Pick, + registrationFlows: state.registrationFlows, + }) as Pick, }, ), shallow, @@ -68,3 +55,4 @@ export const useTransactionManager = createWithEqualityFn()( export type UseTransactionManager = typeof useTransactionManager useTransactionManager.subscribe(...transactionReceiptListener(useTransactionManager)) +useTransactionManager.subscribe(...existingCommitListener(useTransactionManager)) diff --git a/src/transaction/user/input/ExtendNames/ExtendNames-flow.tsx b/src/transaction/user/input/ExtendNames/ExtendNames-flow.tsx index 8f730c428..9ca491013 100644 --- a/src/transaction/user/input/ExtendNames/ExtendNames-flow.tsx +++ b/src/transaction/user/input/ExtendNames/ExtendNames-flow.tsx @@ -302,7 +302,6 @@ const ExtendNames = ({ data: { names, isSelf }, onDismiss, setTransactions, setS disabled: !!estimateGasLimitError, onClick: () => { if (!totalRentFee) return - console.log('setting stage + transactions') setTransactions(transactions) setStage('transaction') }, diff --git a/src/transaction/user/transaction/registerName.ts b/src/transaction/user/transaction/registerName.ts index e0d9065c8..3a1d95dfb 100644 --- a/src/transaction/user/transaction/registerName.ts +++ b/src/transaction/user/transaction/registerName.ts @@ -41,11 +41,6 @@ const transaction = async ({ const value = price.base + price.premium const valueWithBuffer = calculateValueWithBuffer(value) - console.log('registerName transaction', { - ...data, - value: valueWithBuffer, - }) - return registerName.makeFunctionData(connectorClient, { ...data, value: valueWithBuffer, diff --git a/src/utils/query/wagmi.ts b/src/utils/query/wagmi.ts index 014a82c6d..31506d256 100644 --- a/src/utils/query/wagmi.ts +++ b/src/utils/query/wagmi.ts @@ -97,7 +97,7 @@ const transports = { })), [mainnet.id]: initialiseTransports('mainnet', [infuraUrl, cloudflareUrl, tenderlyUrl]), [sepolia.id]: initialiseTransports('sepolia', [infuraUrl, cloudflareUrl, tenderlyUrl]), - [holesky.id]: initialiseTransports('holesky', [tenderlyUrl]), + [holesky.id]: initialiseTransports('holesky', [infuraUrl, tenderlyUrl]), } as const const wagmiConfig_ = createConfig({ From 1a179af0d7b830bbbe33c5edafbdb7f2b5be32e3 Mon Sep 17 00:00:00 2001 From: tate Date: Tue, 24 Sep 2024 15:46:40 +1000 Subject: [PATCH 4/4] small build fixes --- .../verification/DynamicVerificationIcon.tsx | 2 +- src/components/@molecules/DogFood.tsx | 3 +- src/components/@molecules/FaucetBanner.tsx | 6 +- .../DisplayItems.test.tsx | 86 --- .../TransactionDialogManager/DisplayItems.tsx | 275 --------- .../DynamicLoadingContext.tsx | 5 - .../InputComponentWrapper.test.tsx | 272 --------- .../InputComponentWrapper.tsx | 226 ------- .../TransactionDialogManager.test.tsx | 32 - .../TransactionDialogManager.tsx | 151 ----- .../TransactionDialogManager/stage/Intro.tsx | 89 --- .../stage/TransactionStageModal.test.tsx | 541 ----------------- .../stage/TransactionStageModal.tsx | 568 ------------------ .../TransactionDialogManager/stage/query.ts | 249 -------- .../VerificationBadge/VerificationBadge.tsx | 2 +- ...VerificationBadgeAccountTooltipContent.tsx | 2 +- src/components/Notifications.tsx | 119 ---- .../pages/profile/ProfileButton.tsx | 2 +- .../pages/register/steps/Pricing/Pricing.tsx | 2 +- src/constants/verification.ts | 2 +- .../gasEstimation/useEstimateRegistration.ts | 4 +- src/hooks/useEthPrice.ts | 11 +- src/hooks/useRegistrationReducer.ts | 169 ------ .../useVerificationOAuth.ts | 2 +- .../utils/makeAppendVerificationProps.ts | 2 +- src/pages/register.tsx | 24 +- .../transaction/useManagedTransaction.ts | 6 +- src/transaction/user/input.tsx | 3 +- src/utils/analytics.ts | 2 +- .../records/categoriseProfileTextRecords.ts | 2 +- .../verification/isVerificationProtocol.ts | 2 +- .../labelForVerificationProtocol.ts | 2 +- tsconfig.json | 4 +- 33 files changed, 36 insertions(+), 2831 deletions(-) delete mode 100644 src/components/@molecules/TransactionDialogManager/DisplayItems.test.tsx delete mode 100644 src/components/@molecules/TransactionDialogManager/DisplayItems.tsx delete mode 100644 src/components/@molecules/TransactionDialogManager/DynamicLoadingContext.tsx delete mode 100644 src/components/@molecules/TransactionDialogManager/InputComponentWrapper.test.tsx delete mode 100644 src/components/@molecules/TransactionDialogManager/InputComponentWrapper.tsx delete mode 100644 src/components/@molecules/TransactionDialogManager/TransactionDialogManager.test.tsx delete mode 100644 src/components/@molecules/TransactionDialogManager/TransactionDialogManager.tsx delete mode 100644 src/components/@molecules/TransactionDialogManager/stage/Intro.tsx delete mode 100644 src/components/@molecules/TransactionDialogManager/stage/TransactionStageModal.test.tsx delete mode 100644 src/components/@molecules/TransactionDialogManager/stage/TransactionStageModal.tsx delete mode 100644 src/components/@molecules/TransactionDialogManager/stage/query.ts delete mode 100644 src/components/Notifications.tsx delete mode 100644 src/hooks/useRegistrationReducer.ts diff --git a/src/assets/verification/DynamicVerificationIcon.tsx b/src/assets/verification/DynamicVerificationIcon.tsx index 653cdf10a..803e318de 100644 --- a/src/assets/verification/DynamicVerificationIcon.tsx +++ b/src/assets/verification/DynamicVerificationIcon.tsx @@ -1,6 +1,6 @@ import dynamic from 'next/dynamic' -import { VerificationProtocol } from '../../transaction-flow/input/VerifyProfile/VerifyProfile-flow' +import { VerificationProtocol } from '@app/transaction/user/input/VerifyProfile/VerifyProfile-flow' export const verificationIconTypes: { [key in VerificationProtocol]: any diff --git a/src/components/@molecules/DogFood.tsx b/src/components/@molecules/DogFood.tsx index e3c2505eb..9207f883b 100644 --- a/src/components/@molecules/DogFood.tsx +++ b/src/components/@molecules/DogFood.tsx @@ -12,8 +12,7 @@ import { Spacer } from '@app/components/@atoms/Spacer' import { useAddressRecord } from '@app/hooks/ensjs/public/useAddressRecord' import useDebouncedCallback from '@app/hooks/useDebouncedCallback' import { createQueryKey } from '@app/hooks/useQueryOptions' - -import { DisplayItems } from './TransactionDialogManager/DisplayItems' +import { DisplayItems } from '@app/transaction/components/DisplayItems' const InnerContainer = styled.div(() => [ css` diff --git a/src/components/@molecules/FaucetBanner.tsx b/src/components/@molecules/FaucetBanner.tsx index f6f9a3646..afccf2d56 100644 --- a/src/components/@molecules/FaucetBanner.tsx +++ b/src/components/@molecules/FaucetBanner.tsx @@ -20,8 +20,7 @@ import { import { useAccountSafely } from '@app/hooks/account/useAccountSafely' import { useChainName } from '@app/hooks/chain/useChainName' import useFaucet from '@app/hooks/useFaucet' - -import { DisplayItems } from './TransactionDialogManager/DisplayItems' +import { DisplayItems } from '@app/transaction/components/DisplayItems' const BannerWrapper = styled.div( () => css` @@ -72,8 +71,7 @@ const FaucetBanner = () => { closeDialog() }, [chainName, address]) - if ((chainName !== 'goerli' && chainName !== 'sepolia') || !isReady || isLoading || !data) - return null + if (chainName !== 'sepolia' || !isReady || isLoading || !data) return null const BannerComponent = ( diff --git a/src/components/@molecules/TransactionDialogManager/DisplayItems.test.tsx b/src/components/@molecules/TransactionDialogManager/DisplayItems.test.tsx deleted file mode 100644 index b31444895..000000000 --- a/src/components/@molecules/TransactionDialogManager/DisplayItems.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { mockFunction, render, screen } from '@app/test-utils' - -import { describe, expect, it, vi } from 'vitest' - -import { usePrimaryName } from '@app/hooks/ensjs/public/usePrimaryName' -import { TransactionDisplayItem } from '@app/types' - -import { DisplayItems } from './DisplayItems' - -vi.mock('@app/hooks/ensjs/public/usePrimaryName') - -const mockUsePrimaryName = mockFunction(usePrimaryName) - -const genericItem: TransactionDisplayItem = { - label: 'GenericItem', - value: 'GenericValue', -} - -const addressItem: TransactionDisplayItem = { - label: 'AddressItem', - value: '0x1234567890123456789012345678901234567890', - type: 'address', -} - -const nameItem: TransactionDisplayItem = { - label: 'NameItem', - value: 'test.eth', - type: 'name', -} - -describe('DisplayItems', () => { - it('should show a generic item', () => { - render() - expect(screen.getByText('transaction.itemLabel.GenericItem')).toBeVisible() - expect(screen.getByText('GenericValue')).toBeVisible() - }) - it('should show the raw label', () => { - render() - expect(screen.getByText('GenericItem')).toBeVisible() - expect(screen.getByText('GenericValue')).toBeVisible() - }) - it('should show an address item and primary name', () => { - mockUsePrimaryName.mockReturnValue({ - data: { - name: 'test.eth', - beautifiedName: 'test.eth', - }, - isLoading: false, - status: 'success', - }) - render() - expect(screen.getByText('transaction.itemLabel.AddressItem')).toBeVisible() - expect(screen.getByText('0x123...67890')).toBeVisible() - expect(screen.getByText('test.eth')).toBeVisible() - }) - it('should show an address item and no primary name', () => { - mockUsePrimaryName.mockReturnValue({ - data: { name: undefined, beautifiedName: undefined }, - isLoading: false, - status: 'success', - }) - render() - expect(screen.getByText('transaction.itemLabel.AddressItem')).toBeVisible() - expect(screen.getByText('0x123...67890')).toBeVisible() - expect(screen.queryByText('test.eth')).not.toBeInTheDocument() - }) - it('should show a name item', () => { - render() - expect(screen.getByText('transaction.itemLabel.NameItem')).toBeVisible() - expect(screen.getByText('test.eth')).toBeVisible() - }) - it('should render multiple items', () => { - mockUsePrimaryName.mockReturnValue({ - data: { name: undefined, beautifiedName: undefined }, - isLoading: false, - status: 'success', - }) - render() - expect(screen.getByText('transaction.itemLabel.AddressItem')).toBeVisible() - expect(screen.getByText('0x123...67890')).toBeVisible() - expect(screen.getByText('transaction.itemLabel.NameItem')).toBeVisible() - expect(screen.getByText('test.eth')).toBeVisible() - expect(screen.getByText('transaction.itemLabel.GenericItem')).toBeVisible() - expect(screen.getByText('GenericValue')).toBeVisible() - }) -}) diff --git a/src/components/@molecules/TransactionDialogManager/DisplayItems.tsx b/src/components/@molecules/TransactionDialogManager/DisplayItems.tsx deleted file mode 100644 index 20d967577..000000000 --- a/src/components/@molecules/TransactionDialogManager/DisplayItems.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import { useMemo } from 'react' -import { TFunction, useTranslation } from 'react-i18next' -import styled, { css } from 'styled-components' -import { Address } from 'viem' - -import { Typography } from '@ensdomains/thorin' - -import { AvatarWithZorb, NameAvatar } from '@app/components/AvatarWithZorb' -import { usePrimaryName } from '@app/hooks/ensjs/public/usePrimaryName' -import { useBeautifiedName } from '@app/hooks/useBeautifiedName' -import { TransactionDisplayItem } from '@app/types' -import { shortenAddress } from '@app/utils/utils' - -const Container = styled.div( - ({ theme }) => css` - display: flex; - flex-direction: column; - align-items: center; - justify-content: stretch; - width: ${theme.space.full}; - gap: ${theme.space['2']}; - `, -) - -const DisplayItemContainer = styled.div<{ $shrink?: boolean; $fade?: boolean }>( - ({ theme, $shrink, $fade }) => css` - display: grid; - grid-template-columns: 0.5fr 2fr; - align-items: center; - border-radius: ${theme.radii.extraLarge}; - border: ${theme.borderWidths.px} ${theme.borderStyles.solid} ${theme.colors.border}; - min-height: ${theme.space['14']}; - padding: ${theme.space['2']} ${theme.space['5']}; - width: ${theme.space.full}; - - ${$shrink && - css` - min-height: ${theme.space['12']}; - div { - margin-top: 0; - align-self: center; - } - `} - ${$fade && - css` - opacity: 0.5; - background-color: ${theme.colors.backgroundSecondary}; - `} - `, -) - -const DisplayItemLabel = styled(Typography)( - ({ theme }) => css` - color: ${theme.colors.textSecondary}; - justify-self: flex-start; - `, -) - -const AvatarWrapper = styled.div( - ({ theme }) => css` - width: ${theme.space['7']}; - min-width: ${theme.space['7']}; - height: ${theme.space['7']}; - `, -) - -const ValueWithAvatarContainer = styled.div( - ({ theme }) => css` - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-end; - gap: ${theme.space['4']}; - `, -) - -const InnerValueWrapper = styled.div( - () => css` - display: flex; - flex-direction: column; - align-items: flex-end; - justify-content: center; - text-align: right; - `, -) - -const ValueTypography = styled(Typography)( - ({ theme }) => css` - overflow-wrap: anywhere; - text-align: right; - margin-left: ${theme.space['2']}; - `, -) - -const AddressSubtitle = styled(Typography)( - ({ theme }) => css` - color: ${theme.colors.textSecondary}; - font-weight: ${theme.fontWeights.bold}; - `, -) - -const AddressValue = ({ value }: { value: string }) => { - const primary = usePrimaryName({ address: value as Address }) - - const AddressTypography = useMemo( - () => - primary.data?.name ? ( - {shortenAddress(value)} - ) : ( - {shortenAddress(value)} - ), - [primary.data?.name, value], - ) - - return ( - - - {primary.data?.name && ( - - {primary.data?.beautifiedName} - - )} - {AddressTypography} - - - - - - ) -} - -const NameValue = ({ value }: { value: string }) => { - const beautifiedName = useBeautifiedName(value) - - return ( - - {beautifiedName} - - - - - ) -} - -const SubnameValue = ({ value }: { value: string }) => { - const [label, ...parentParts] = value.split('.') - const parent = parentParts.join('.') - - return ( - -
- {label}. - {parent} -
- - - -
- ) -} - -const ListContainer = styled.div( - () => css` - display: flex; - flex-direction: column; - text-align: right; - `, -) - -const ListValue = ({ value }: { value: string[] }) => { - return ( - - {value.map((val, idx) => { - const isLast = idx === value.length - 1 - const key = idx - if (idx === 0) { - return ( - - {val} - - ) - } - return {`${val}${!isLast ? ',' : ''}`} - })} - - ) -} - -const RecordsContainer = styled.div( - () => css` - display: flex; - flex-direction: column; - text-align: right; - gap: 0.5rem; - margin-left: 0.5rem; - overflow: hidden; - `, -) - -const RecordContainer = styled.div( - () => css` - display: flex; - flex-direction: column; - `, -) - -const RecordsValue = ({ value }: { value: [string, string | undefined][] }) => { - return ( - - {value.map(([key, val]) => ( - - - - {key} - {!!val && ':'} - {' '} - {!!val && val} - - - ))} - - ) -} - -const DisplayItemValue = (props: Omit) => { - const { value, type } = props as TransactionDisplayItem - if (type === 'address') { - return - } - if (type === 'name') { - return - } - if (type === 'subname') { - return - } - if (type === 'list') { - return - } - if (type === 'records') { - return - } - return {value} -} - -export const DisplayItem = ({ - label, - value, - type, - shrink, - fade, - useRawLabel, - t, -}: TransactionDisplayItem & { t: TFunction }) => { - return ( - - - {useRawLabel ? label : t(`transaction.itemLabel.${label}`)} - - - - ) -} - -export const DisplayItems = ({ displayItems }: { displayItems: TransactionDisplayItem[] }) => { - const { t } = useTranslation() - - if (!displayItems || !displayItems.length) return null - - return {displayItems.map((props) => DisplayItem({ ...props, t }))} -} diff --git a/src/components/@molecules/TransactionDialogManager/DynamicLoadingContext.tsx b/src/components/@molecules/TransactionDialogManager/DynamicLoadingContext.tsx deleted file mode 100644 index 79fc35bc2..000000000 --- a/src/components/@molecules/TransactionDialogManager/DynamicLoadingContext.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { createContext, Dispatch, SetStateAction } from 'react' - -const DynamicLoadingContext = createContext>>(() => {}) - -export default DynamicLoadingContext diff --git a/src/components/@molecules/TransactionDialogManager/InputComponentWrapper.test.tsx b/src/components/@molecules/TransactionDialogManager/InputComponentWrapper.test.tsx deleted file mode 100644 index dfaa985ea..000000000 --- a/src/components/@molecules/TransactionDialogManager/InputComponentWrapper.test.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { act, render, screen, waitFor } from '@app/test-utils' - -import { QueryClientProvider } from '@tanstack/react-query' -import { useQuery } from '@app/utils/query/useQuery' - -import { ReactNode, useContext, useEffect } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { WagmiProvider } from 'wagmi' - -import { queryClientWithRefetch as queryClient } from '@app/utils/query/reactQuery' -import { wagmiConfig } from '@app/utils/query/wagmi' - -import DynamicLoadingContext from './DynamicLoadingContext' -import InputComponentWrapper from './InputComponentWrapper' - -const cache = queryClient.getQueryCache() -queryClient.setDefaultOptions({ - queries: { - refetchOnWindowFocus: true, - refetchInterval: 1000 * 60, - staleTime: 1000 * 120, - meta: { - isRefetchQuery: true, - }, - refetchOnMount: 'always', - }, -}) - -const ComponentHelper = ({ children }: { children: ReactNode }) => { - return ( -
-
- - - {children} - - -
-
- ) -} - -const mockObserve = vi.fn() -const mockDisconnect = vi.fn() - -const ComponentWithHook = ({ timeout }: { timeout: number }) => { - useQuery({ - queryKey: ['test', '123'], - queryFn: () => - new Promise((resolve) => { - setTimeout(() => resolve('value-updated'), timeout) - }), - }) - return
-} - -const ComponentLoading = () => { - const setLoading = useContext(DynamicLoadingContext) - useEffect(() => { - setLoading(true) - return () => setLoading(false) - }, [setLoading]) - - return
-} - -describe('', () => { - let mutationObserverCb: () => void - beforeEach(() => { - ;(global.MutationObserver as any) = class { - constructor(cb: any) { - mutationObserverCb = cb - } - - observe = mockObserve - - disconnect = mockDisconnect - } - }) - it('should render children', () => { - render( - -
- , - ) - expect(screen.getByTestId('test')).toBeVisible() - }) - it('should set all queries with no observers to idle', () => { - queryClient.setQueryData(['test', '123'], 'value') - queryClient.setQueryData(['test', '456'], 'value') - const item1 = cache.get('["test","123"]')! - const item2 = cache.get('["test","456"]')! - item1.setState({ ...item1.state, fetchStatus: 'fetching' }) - item2.setState({ ...item2.state, fetchStatus: 'fetching' }) - render( - -
- , - ) - expect(item1.state.fetchStatus).toBe('idle') - expect(item2.state.fetchStatus).toBe('idle') - }) - it('should add cacheable-component class to modal card on mount', async () => { - render( - -
- , - ) - mutationObserverCb() - await waitFor(() => { - expect(screen.getByTestId('modal-card')).toHaveClass('cacheable-component') - }) - }) - it('should add cacheable-component-cached class to modal card when cached data exists', async () => { - queryClient.setQueryData(['test', '123'], 'value') - render( - - - , - ) - mutationObserverCb() - await waitFor(() => { - expect(screen.getByTestId('modal-card')).toHaveClass('cacheable-component-cached') - }) - }) - it('should show spinner after data is cached for 3 seconds', async () => { - vi.useFakeTimers() - queryClient.setQueryData(['test', '123'], 'value') - render( - - - , - ) - mutationObserverCb() - act(() => { - vi.advanceTimersByTime(3000) - }) - await waitFor(() => { - expect(screen.getByTestId('modal-card')).toHaveClass('cacheable-component-cached') - expect(screen.getByTestId('spinner-overlay')).toBeVisible() - }) - }) - it('should not show spinner if componentLoading is true', async () => { - vi.useFakeTimers() - render( - - - , - ) - mutationObserverCb() - act(() => { - vi.advanceTimersByTime(3000) - }) - await waitFor(() => { - expect(screen.getByTestId('modal-card')).toHaveClass('cacheable-component') - expect(screen.queryByTestId('spinner-overlay')).toBeNull() - }) - }) - it('should remove cacheable-component-cached class from modal once data is refetched', async () => { - vi.useFakeTimers() - queryClient.setQueryData(['test', '123'], 'value') - render( - - - , - ) - mutationObserverCb() - await waitFor(() => { - expect(screen.getByTestId('modal-card')).toHaveClass('cacheable-component-cached') - }) - act(() => { - vi.advanceTimersByTime(3000) - }) - await waitFor(() => { - expect(screen.getByTestId('spinner-overlay')).toBeVisible() - }) - act(() => { - vi.advanceTimersByTime(2000) - }) - await waitFor(() => { - expect(screen.getByTestId('modal-card')).not.toHaveClass('cacheable-component-cached') - expect(screen.queryByTestId('spinner-overlay')).toBeNull() - }) - }) - it('should remove cacheable-component class from modal card on unmount', async () => { - render(
) - const { unmount } = render( - - - -
- - - , - ) - mutationObserverCb() - await waitFor(() => { - expect(screen.getByTestId('modal-card')).toHaveClass('cacheable-component') - }) - unmount() - await waitFor(() => { - expect(screen.getByTestId('modal-card')).not.toHaveClass('cacheable-component') - }) - }) - it('should not initially wait for queries to be fetched if there are no queries', async () => { - render( - -
- , - ) - mutationObserverCb() - await waitFor(() => { - expect(screen.getByTestId('modal-card')).toHaveClass('cacheable-component') - expect(screen.getByTestId('modal-card')).not.toHaveClass('cacheable-component-cached') - }) - }) - it('should add cacheable-component-cached class if there are stale queries', async () => { - vi.useFakeTimers() - render( - - - , - ) - mutationObserverCb() - act(() => { - vi.advanceTimersByTime(100) - }) - await waitFor(() => { - expect(screen.getByTestId('modal-card')).toHaveClass('cacheable-component') - expect(screen.getByTestId('modal-card')).not.toHaveClass('cacheable-component-cached') - }) - const item1 = cache.get('["test","123"]')! - act(() => { - item1.setState({ ...item1.state, dataUpdatedAt: Date.now() - 1000 * 240 }) - vi.advanceTimersByTime(5000) - }) - await waitFor(() => { - expect(screen.getByTestId('modal-card')).toHaveClass('cacheable-component-cached') - }) - }) - it('should remove cacheable-component-cached class once stale queries are refetched', async () => { - vi.useFakeTimers() - render( - - - , - ) - mutationObserverCb() - act(() => { - vi.advanceTimersByTime(100) - }) - await waitFor(() => { - expect(screen.getByTestId('modal-card')).toHaveClass('cacheable-component') - expect(screen.getByTestId('modal-card')).not.toHaveClass('cacheable-component-cached') - }) - const item1 = cache.get('["test","123"]')! - act(() => { - item1.setState({ ...item1.state, dataUpdatedAt: Date.now() - 1000 * 240 }) - vi.advanceTimersByTime(5000) - }) - await waitFor(() => { - expect(screen.getByTestId('modal-card')).toHaveClass('cacheable-component-cached') - }) - act(() => { - // remaining time for refetch interval - vi.advanceTimersByTime(1000 * 55) - }) - await waitFor(() => { - expect(screen.getByTestId('modal-card')).not.toHaveClass('cacheable-component-cached') - }) - }) -}) diff --git a/src/components/@molecules/TransactionDialogManager/InputComponentWrapper.tsx b/src/components/@molecules/TransactionDialogManager/InputComponentWrapper.tsx deleted file mode 100644 index a21121a10..000000000 --- a/src/components/@molecules/TransactionDialogManager/InputComponentWrapper.tsx +++ /dev/null @@ -1,226 +0,0 @@ -/* eslint-disable default-case */ - -/* eslint-disable no-param-reassign */ -import { useQueryClient } from '@tanstack/react-query' -import { ReactNode, useEffect, useRef, useState } from 'react' -import styled, { css } from 'styled-components' - -import { Spinner } from '@ensdomains/thorin' - -import DynamicLoadingContext from './DynamicLoadingContext' - -const SpinnerOverlay = styled.div( - () => css` - z-index: 1; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - justify-content: center; - align-items: center; - `, -) - -const getModalCard = () => document.querySelector('.modal') - -const InputComponentWrapper = ({ children }: { children: ReactNode }) => { - const queryClient = useQueryClient() - - const [isCached, _setIsCached] = useState(false) - const [componentLoading, setComponentLoading] = useState(false) - const [showSpinner, setShowSpinner] = useState(false) - - const cachedRef = useRef(false) - - const setIsCached = (cached: boolean) => { - _setIsCached(cached) - cachedRef.current = cached - } - - useEffect(() => { - const cache = queryClient.getQueryCache() - const externalQueries = cache - .getAll() - .filter((q) => q.state.fetchStatus === 'fetching' && !q.getObserversCount()) - - if (externalQueries.length > 0) { - for (const eq of externalQueries) { - eq.setState({ ...eq.state, fetchStatus: 'idle' }) - } - } - }, [queryClient]) - - // hook for detecting when all queries have been refetched on mount generically - // also handles stale queries - useEffect(() => { - let staleCheckInterval: NodeJS.Timeout | undefined - // this can be either the first cache subscription OR the stale check interval subscription - let unsubscribe: (() => void) | undefined - - // if the component is loading, don't do anything - if (!componentLoading) { - const cache = queryClient.getQueryCache() - - const makeStaleCheckInterval = () => { - clearInterval(staleCheckInterval) - // poll for stale queries - staleCheckInterval = setInterval(() => { - // queries must be: - // refetch queries (under the input component) - // enabled - // active - // stale - // and have been updated more than staleTime ago (isStale() doesn't always work for some reason) - const staleQueries = cache.getAll().filter((q) => { - const { enabled } = q.options as any - return ( - q.meta?.isRefetchQuery && - (typeof enabled === 'undefined' || enabled) && - q.isActive() && - q.isStale() && - Date.now() > - q.state.dataUpdatedAt + queryClient.getDefaultOptions().queries!.staleTime! - ) - }) - // if there are stale queries, stop polling, set isCached to true, and subscribe to the cache - if (staleQueries.length > 0) { - clearInterval(staleCheckInterval) - setIsCached(true) - unsubscribe = cache.subscribe((query) => { - // only care about updated queries - if (query.type === 'updated') { - const staleQueryIndex = staleQueries.findIndex( - (q) => q?.queryHash === query.query.queryHash, - ) - const queryState = query.query.state - if ( - staleQueryIndex !== -1 && - queryState.fetchStatus === 'idle' && - // assume that errored queries are handled by the component - (queryState.status === 'success' || queryState.status === 'error') - ) { - // if stale query exists in staleQueries and is updated, delete it from staleQueries - delete staleQueries[staleQueryIndex] - // if all stale queries have been updated, set isCached to false, unsubscribe from cache, and start polling again - if (staleQueries.every((q) => q === undefined)) { - setIsCached(false) - unsubscribe!() - makeStaleCheckInterval() - } - } - } - }) - } - }, 5000) // poll every 5 seconds - } - - const getFetchingQueries = () => - cache.getAll().filter((q) => q.state.fetchStatus === 'fetching' && q.meta?.isRefetchQuery) - const fetchedKeys: string[] = [] - - // if there are any queries to fetch, run the cache subscription - if (getFetchingQueries().length !== 0) { - setIsCached(true) - unsubscribe = cache.subscribe((query) => { - // only care about updated queries - if (query.type === 'updated') { - const queryState = query.query.state - if ( - queryState.fetchStatus === 'idle' && - // assume that errored queries are handled by the component - (queryState.status === 'success' || queryState.status === 'error') - ) { - // if query is updated, add it to fetchedKeys - fetchedKeys.push(query.query.queryHash) - // if all queries are updated, set isCached to false, unsubscribe from cache, and start polling for stale queries - const stillToFetch = getFetchingQueries() - if (stillToFetch.length === 0) { - setIsCached(false) - unsubscribe!() - makeStaleCheckInterval() - } - } - } - }) - } else { - // if there are no queries, assume there is no initial data needed and start polling for stale queries - setIsCached(false) - makeStaleCheckInterval() - } - } - - return () => { - clearInterval(staleCheckInterval) - unsubscribe?.() - } - }, [componentLoading, queryClient]) - - // hook for detecting when the modal card is mounted - // and adding the cacheable-component class to it - // this is needed because of the way the modal is rendered - useEffect(() => { - const observer = new MutationObserver(() => { - const element = getModalCard() - if (element) { - element.classList.add('cacheable-component') - if (cachedRef.current) { - element.classList.add('cacheable-component-cached') - } - observer.disconnect() - } - }) - observer.observe(document.body, { - childList: true, - subtree: true, - }) - return () => { - observer.disconnect() - const modalCard = getModalCard() - modalCard?.classList.remove('cacheable-component') - } - }, []) - - useEffect(() => { - const modalCard = getModalCard() - if (isCached) { - modalCard?.classList.add('cacheable-component-cached') - } else { - modalCard?.classList.remove('cacheable-component-cached') - } - return () => { - modalCard?.classList.remove('cacheable-component-cached') - } - }, [isCached]) - - // hook for showing the spinner after 3 seconds - // uses isMounted to prevent the spinner from showing up on top of the TransactionLoader spinner - useEffect(() => { - let timeout: NodeJS.Timeout | undefined - if (isCached && !componentLoading) { - timeout = setTimeout(() => { - setShowSpinner(true) - }, 3000) - } else { - clearTimeout(timeout) - setShowSpinner(false) - } - return () => { - clearTimeout(timeout) - } - }, [componentLoading, isCached]) - - return ( - - {showSpinner && ( - - - - )} - {children} - - ) -} - -export default InputComponentWrapper diff --git a/src/components/@molecules/TransactionDialogManager/TransactionDialogManager.test.tsx b/src/components/@molecules/TransactionDialogManager/TransactionDialogManager.test.tsx deleted file mode 100644 index 724b5efa1..000000000 --- a/src/components/@molecules/TransactionDialogManager/TransactionDialogManager.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { mockFunction, renderHook } from '@app/test-utils' - -import { describe, expect, it, vi } from 'vitest' -import { useAccount } from 'wagmi' - -import { useResetSelectedKey } from './TransactionDialogManager' - -vi.mock('wagmi') - -const mockUseAccount = mockFunction(useAccount) - -describe('useResetSelectedKey', () => { - it('should stopFlow if account changes', async () => { - const dispatch = vi.fn() - mockUseAccount.mockReturnValue({ address: '0xAddress' }) - const { rerender } = renderHook(() => useResetSelectedKey(dispatch)) - mockUseAccount.mockReturnValue({ address: '0xOtherAddress' }) - rerender() - expect(dispatch).toHaveBeenCalledWith({ - name: 'stopFlow', - }) - }) - - it('should not call stopFlow if account stays the same', () => { - const dispatch = vi.fn() - mockUseAccount.mockReturnValue({ address: '0xAddress' }) - const { rerender } = renderHook(() => useResetSelectedKey(dispatch)) - mockUseAccount.mockReturnValue({ address: '0xAddress' }) - rerender() - expect(dispatch).not.toHaveBeenCalled() - }) -}) diff --git a/src/components/@molecules/TransactionDialogManager/TransactionDialogManager.tsx b/src/components/@molecules/TransactionDialogManager/TransactionDialogManager.tsx deleted file mode 100644 index 5ba1ea14e..000000000 --- a/src/components/@molecules/TransactionDialogManager/TransactionDialogManager.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { QueryClientProvider } from '@tanstack/react-query' -import { Dispatch, useCallback, useEffect, useMemo } from 'react' -import { useTranslation } from 'react-i18next' -import usePrevious from 'react-use/lib/usePrevious' -import { match, P } from 'ts-pattern' -import { useAccount, useChainId } from 'wagmi' - -import { Dialog } from '@ensdomains/thorin' - -import { transactions } from '@app/transaction-flow/transaction' -import { queryClientWithRefetch } from '@app/utils/query/reactQuery' - -import { DataInputComponents } from '../../../transaction-flow/input' -import { InternalTransactionFlow, TransactionFlowAction } from '../../../transaction-flow/types' -import { IntroStageModal } from './stage/Intro' -import { TransactionStageModal } from './stage/TransactionStageModal' - -export const useResetSelectedKey = (dispatch: any) => { - const { address } = useAccount() - const chainId = useChainId() - - const prevAddress = usePrevious(address) - const prevChainId = usePrevious(chainId) - - useEffect(() => { - if (prevChainId && prevChainId !== chainId) { - dispatch({ - name: 'stopFlow', - }) - } - }, [prevChainId, chainId, dispatch]) - - useEffect(() => { - if (prevAddress && prevAddress !== address) { - dispatch({ - name: 'stopFlow', - }) - } - }, [prevAddress, address, dispatch]) -} - -export const TransactionDialogManager = ({ - state, - dispatch, - selectedKey, -}: { - state: InternalTransactionFlow - dispatch: Dispatch - selectedKey: string | null -}) => { - const { t } = useTranslation() - const selectedItem = useMemo( - () => (selectedKey ? state.items[selectedKey] : null), - [selectedKey, state.items], - ) - - useResetSelectedKey(dispatch) - - const onDismiss = useCallback(() => { - dispatch({ name: 'stopFlow' }) - }, [dispatch]) - - const onDismissDialog = useCallback(() => { - if (selectedItem?.disableBackgroundClick && selectedItem?.currentFlowStage === 'input') return - dispatch({ - name: 'stopFlow', - }) - }, [dispatch, selectedItem?.disableBackgroundClick, selectedItem?.currentFlowStage]) - - return ( - - {match([selectedKey, selectedItem]) - .with( - [P.not(P.nullish), { input: P.not(P.nullish), currentFlowStage: 'input' }], - // eslint-disable-next-line @typescript-eslint/no-unused-vars - ([_, _selectedItem]) => { - const Component = DataInputComponents[_selectedItem.input.name] - return ( - - - - ) - }, - ) - .with( - [P.not(P.nullish), { intro: P.not(P.nullish), currentFlowStage: 'intro' }], - // eslint-disable-next-line @typescript-eslint/no-unused-vars - ([_, _selectedItem]) => { - const currentTx = _selectedItem.transactions[_selectedItem.currentTransaction] - const currentStep = - currentTx.stage === 'complete' - ? _selectedItem.currentTransaction + 1 - : _selectedItem.currentTransaction - - const stepStatus = - currentTx.stage === 'sent' || currentTx.stage === 'failed' - ? 'inProgress' - : 'notStarted' - - return ( - dispatch({ name: 'setFlowStage', payload: 'transaction' })} - {...{ - ..._selectedItem.intro, - onDismiss, - transactions: _selectedItem.transactions, - }} - /> - ) - }, - ) - .otherwise(([_selectedKey, _selectedItem]) => { - if (!_selectedKey || !_selectedItem) return null - const transactionItem = _selectedItem.transactions[_selectedItem.currentTransaction] - const transaction = transactions[transactionItem.name] - - return ( - - ) - })} - - ) -} diff --git a/src/components/@molecules/TransactionDialogManager/stage/Intro.tsx b/src/components/@molecules/TransactionDialogManager/stage/Intro.tsx deleted file mode 100644 index 363ad7996..000000000 --- a/src/components/@molecules/TransactionDialogManager/stage/Intro.tsx +++ /dev/null @@ -1,89 +0,0 @@ -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/components/@molecules/TransactionDialogManager/stage/TransactionStageModal.test.tsx b/src/components/@molecules/TransactionDialogManager/stage/TransactionStageModal.test.tsx deleted file mode 100644 index e1a30690b..000000000 --- a/src/components/@molecules/TransactionDialogManager/stage/TransactionStageModal.test.tsx +++ /dev/null @@ -1,541 +0,0 @@ -/* eslint-disable no-promise-executor-return */ -import { act, fireEvent, mockFunction, render, screen, userEvent, waitFor } from '@app/test-utils' - -import type { MockedFunctionDeep } from '@vitest/spy' -import { ComponentProps } from 'react' -import { Account, TransactionRequest } from 'viem' -import { estimateGas, prepareTransactionRequest } from 'viem/actions' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useClient, useConnectorClient, useSendTransaction } from 'wagmi' - -import { useAccountSafely } from '@app/hooks/account/useAccountSafely' -import { useChainName } from '@app/hooks/chain/useChainName' -import { useAddRecentTransaction } from '@app/hooks/transactions/useAddRecentTransaction' -import { useRecentTransactions } from '@app/hooks/transactions/useRecentTransactions' -import { useIsSafeApp } from '@app/hooks/useIsSafeApp' -import { GenericTransaction } from '@app/transaction-flow/types' -import { checkIsSafeApp } from '@app/utils/safe' - -import { useMockedUseQueryOptions } from '../../../../../test/mock/useMockedUseQueryOptions' -import { calculateGasLimit, transactionSuccessHandler } from './query' -import { handleBackToInput, TransactionStageModal } from './TransactionStageModal' -import { makeMockIntersectionObserver } from '../../../../../test/mock/makeMockIntersectionObserver' - -vi.mock('@app/hooks/account/useAccountSafely') -vi.mock('@app/hooks/chain/useChainName') -vi.mock('@app/hooks/useIsSafeApp') -vi.mock('@app/hooks/transactions/useAddRecentTransaction') -vi.mock('@app/hooks/transactions/useRecentTransactions') -vi.mock('@app/hooks/chain/useInvalidateOnBlock') -vi.mock('@app/utils/safe') - -vi.mock('wagmi') -vi.mock('viem/actions') - -const mockTransactionRequest: TransactionRequest = { - data: '0x1896f70a516f53deb2dac3f055f1db1fbd64c12640aa29059477103c3ef28806f15929250000000000000000000000004976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41', - to: '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0', - from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', - gas: 0x798an, -} -const mockTransaction: GenericTransaction = { - name: 'updateResolver', - data: { - name: 'other-registrant.eth', - contract: 'registry', - resolverAddress: '0x4976fb03C32e5B8cfe2b6cCB31c09Ba78EBaBa41', - oldResolverAddress: '0x1613beB3B2C4f22Ee086B2b38C1476A3cE7f78E8', - }, -} - -makeMockIntersectionObserver() - -vi.mock('@app/transaction-flow/transaction', () => { - const originalModule = vi.importActual('@app/transaction-flow/transaction') - return { - __esModule: true, - ...originalModule, - createTransactionRequest: () => mockTransactionRequest, - } -}) - -const mockClient = { - request: vi.fn(), -} - -const mockUseClient = mockFunction(useClient) -const mockUseConnectorClient = mockFunction(useConnectorClient) - -const mockEstimateGas = mockFunction(estimateGas) -const mockPrepareTransactionRequest = prepareTransactionRequest as MockedFunctionDeep< - typeof prepareTransactionRequest -> - -const mockUseIsSafeApp = mockFunction(useIsSafeApp) -const mockUseAddRecentTransaction = mockFunction(useAddRecentTransaction) -const mockUseRecentTransactions = mockFunction(useRecentTransactions) -const mockUseAccountSafely = mockFunction(useAccountSafely) -const mockUseChainName = mockFunction(useChainName) -const mockUseSendTransaction = mockFunction(useSendTransaction) -const mockCheckIsSafeApp = checkIsSafeApp as MockedFunctionDeep - -const mockOnDismiss = vi.fn() -const mockDispatch = vi.fn() - -const ComponentWithDefaultProps = ({ - currentStep = 0, - stepCount = 1, - actionName = 'test', - displayItems = [], - transaction = {} as any, -}: Partial>) => ( - -) - -const renderHelper = async (props: Partial> = {}) => { - const renderValue = render() - await waitFor(() => expect(screen.getByTestId('transaction-modal-inner')).toBeVisible(), { - timeout: 350, - }) - return renderValue -} - -const clickRequest = async () => { - await waitFor(() => expect(screen.getByTestId('transaction-modal-confirm-button')).toBeEnabled()) - await userEvent.click(screen.getByTestId('transaction-modal-confirm-button')) -} - -describe('TransactionStageModal', () => { - mockUseRecentTransactions.mockReturnValue([]) - mockUseSendTransaction.mockReturnValue({}) - - beforeEach(() => { - mockUseClient.mockReturnValue({}) - useMockedUseQueryOptions({ - chainId: 1, - address: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', - client: mockClient, - }) - mockUseConnectorClient.mockReturnValue({ - data: { account: { address: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' } }, - }) - mockUseIsSafeApp.mockReturnValue({ data: false }) - mockEstimateGas.mockReset() - mockPrepareTransactionRequest.mockReset() - // passthrough for the transaction request - mockPrepareTransactionRequest.mockImplementation( - async (_, { parameters: _parameters, account, ...data }) => - ({ ...data, from: (account as Account).address }) as any, - ) - mockUseAccountSafely.mockReturnValue({ address: '0x1234' }) - mockUseChainName.mockReturnValue('ethereum') - mockUseRecentTransactions.mockReturnValue([ - { - status: 'pending', - hash: '0x123', - action: 'test', - key: 'test', - }, - ]) - }) - - it('should render on open', async () => { - await renderHelper() - expect(screen.getByText('transaction.dialog.confirm.title')).toBeVisible() - }) - it('should render display items', async () => { - await renderHelper({ - displayItems: [ - { - label: 'GenericItem', - value: 'GenericValue', - }, - ], - }) - expect(screen.getByText('transaction.itemLabel.GenericItem')).toBeVisible() - expect(screen.getByText('GenericValue')).toBeVisible() - }) - - it('should not render steps if there is only 1 step', async () => { - await renderHelper() - expect(screen.queryByTestId('step-container')).not.toBeInTheDocument() - }) - it('should render steps if there are multiple steps', async () => { - await renderHelper({ stepCount: 2 }) - expect(screen.getByTestId('step-container')).toBeVisible() - }) - describe('stage', () => { - describe('confirm', () => { - it('should show confirm button as disabled if gas is not estimated', async () => { - await renderHelper() - await waitFor(() => - expect(screen.getByTestId('transaction-modal-confirm-button')).toBeDisabled(), - ) - }) - it('should show confirm button as disabled if a unique identifier is undefined', async () => { - mockUseIsSafeApp.mockReturnValue({ data: false }) - mockEstimateGas.mockResolvedValue(1n) - mockUseSendTransaction.mockReturnValue({ - sendTransaction: () => Promise.resolve(), - }) - mockUseAccountSafely.mockReturnValue({ address: undefined }) - await renderHelper({ transaction: mockTransaction }) - await waitFor(() => - expect(screen.getByTestId('transaction-modal-confirm-button')).toBeDisabled(), - ) - }) - it('should disable confirm button and re-estimate gas if a unique identifier is changed', async () => { - mockEstimateGas.mockResolvedValue(1n) - mockUseIsSafeApp.mockReturnValue({ data: false }) - mockUseSendTransaction.mockReturnValue({ - sendTransaction: () => Promise.resolve(), - }) - const { rerender } = await renderHelper({ transaction: mockTransaction }) - await waitFor(() => - expect(screen.getByTestId('transaction-modal-confirm-button')).toBeEnabled(), - ) - expect(mockEstimateGas).toHaveBeenCalledTimes(1) - mockEstimateGas.mockReset() - rerender( - , - ) - expect(screen.getByTestId('transaction-modal-confirm-button')).toBeDisabled() - await waitFor(() => - expect(screen.getByTestId('transaction-modal-confirm-button')).toBeEnabled(), - ) - expect(mockEstimateGas).toHaveBeenCalledTimes(1) - }) - it('should only show confirm button as enabled if gas is estimated and sendTransaction func is defined', async () => { - mockEstimateGas.mockResolvedValue(1n) - mockUseSendTransaction.mockReturnValue({ - sendTransaction: () => Promise.resolve(), - }) - await renderHelper({ transaction: mockTransaction }) - await waitFor(() => - expect(screen.getByTestId('transaction-modal-confirm-button')).toBeEnabled(), - ) - }) - it('should run set sendTransaction on action click', async () => { - mockEstimateGas.mockResolvedValue(1n) - const mockSendTransaction = vi.fn() - mockUseSendTransaction.mockReturnValue({ - sendTransaction: mockSendTransaction, - }) - mockSendTransaction.mockResolvedValue({ - hash: '0x0', - }) - await renderHelper({ transaction: mockTransaction }) - await clickRequest() - expect(mockSendTransaction).toHaveBeenCalled() - }) - it('should show the waiting for wallet button if the transaction is loading', async () => { - mockEstimateGas.mockResolvedValue(1n) - const mockSendTransaction = vi.fn() - mockUseSendTransaction.mockReturnValue({ - sendTransaction: mockSendTransaction, - isPending: true, - }) - mockSendTransaction.mockImplementation(async () => new Promise(() => {})) - await renderHelper({ transaction: mockTransaction }) - await waitFor(() => - expect(screen.getByTestId('transaction-modal-confirm-button')).toBeDisabled(), - ) - }) - it('should show the error message and reenable button if there is an error', async () => { - const mockSendTransaction = vi.fn() - mockUseSendTransaction.mockReturnValue({ - sendTransaction: mockSendTransaction, - error: new Error('error123') as any, - }) - await renderHelper({ transaction: mockTransaction }) - await clickRequest() - await waitFor(() => expect(screen.getByText('error123')).toBeVisible()) - await waitFor(() => - expect(screen.getByTestId('transaction-modal-confirm-button')).toBeEnabled(), - ) - }) - it('should pass the request to send transaction', async () => { - mockUseIsSafeApp.mockReturnValue({ data: false }) - mockEstimateGas.mockResolvedValue(1n) - const mockSendTransaction = vi.fn() - mockUseSendTransaction.mockReturnValue({ - sendTransaction: mockSendTransaction, - }) - await renderHelper({ transaction: mockTransaction }) - await clickRequest() - await waitFor(() => - expect(mockSendTransaction.mock.lastCall![0]!).toStrictEqual( - expect.objectContaining({ - ...mockTransactionRequest, - gas: 1n, - accessList: undefined, - }), - ), - ) - }) - it('should add to recent transactions and run dispatch from success callback', async () => { - const mockAddTransaction = vi.fn() - mockUseAddRecentTransaction.mockReturnValue(mockAddTransaction) - mockCheckIsSafeApp.mockResolvedValue(false) - await renderHelper({ transaction: mockTransaction }) - await waitFor(() => - expect(mockUseSendTransaction.mock.lastCall![0]!.mutation!.onSuccess).toBeDefined(), - ) - await mockUseSendTransaction.mock.lastCall![0]!.mutation!.onSuccess!('0x123', {} as any, {}) - expect(mockAddTransaction).toBeCalledWith( - expect.objectContaining({ - hash: '0x123', - action: 'test', - isSafeTx: false, - key: 'test', - }), - ) - expect(mockDispatch).toBeCalledWith({ - name: 'setTransactionHash', - payload: { hash: '0x123', key: 'test'}, - }) - }) - it('should add to recent transactions and run dispatch from success callback when isSafeTx', async () => { - const mockAddTransaction = vi.fn() - mockUseIsSafeApp.mockReturnValue({ data: 'iframe' }) - mockUseAddRecentTransaction.mockReturnValue(mockAddTransaction) - mockCheckIsSafeApp.mockResolvedValue('iframe') - await renderHelper({ transaction: mockTransaction }) - await waitFor(() => - expect(mockUseSendTransaction.mock.lastCall![0]!.mutation!.onSuccess).toBeDefined(), - ) - await mockUseSendTransaction.mock.lastCall![0]!.mutation!.onSuccess!('0x123', {} as any, {}) - expect(mockAddTransaction).toBeCalledWith( - expect.objectContaining({ - hash: '0x123', - action: 'test', - isSafeTx: true, - key: 'test', - }), - ) - expect(mockDispatch).toBeCalledWith({ - name: 'setTransactionHash', - payload: { hash: '0x123', key: 'test'}, - }) - }) - }) - describe('sent', () => { - it('should show load bar', async () => { - await renderHelper({ - transaction: { ...mockTransaction, hash: '0x123', sendTime: Date.now(), stage: 'sent' }, - }) - await waitFor(() => expect(screen.getByTestId('load-bar-container')).toBeVisible()) - }) - it('should call onDismiss on close', async () => { - await renderHelper({ - transaction: { ...mockTransaction, hash: '0x123', sendTime: Date.now(), stage: 'sent' }, - }) - fireEvent.click(screen.getByTestId('transaction-modal-sent-button')) - expect(mockOnDismiss).toHaveBeenCalled() - }) - it('should show message if transaction is taking a long time', async () => { - await renderHelper({ - transaction: { - ...mockTransaction, - hash: '0x123', - sendTime: Date.now() - 45000, - stage: 'sent', - }, - }) - expect(screen.getByText('transaction.dialog.sent.progress.message')).toBeVisible() - expect(screen.getByText('transaction.dialog.sent.learn')).toBeVisible() - }) - }) - describe('complete', () => { - it('should call onDismiss on close', async () => { - await renderHelper({ - transaction: { - ...mockTransaction, - hash: '0x123', - sendTime: Date.now(), - stage: 'complete', - }, - }) - fireEvent.click(screen.getByTestId('transaction-modal-complete-button')) - expect(mockOnDismiss).toHaveBeenCalled() - }) - }) - describe('failed', () => { - it('should show try again button', async () => { - await renderHelper({ transaction: { ...mockTransaction, hash: '0x123', stage: 'failed' } }) - expect(screen.getByTestId('transaction-modal-failed-button')).toBeVisible() - }) - it('should run sendTransaction on action click', async () => { - mockEstimateGas.mockResolvedValue(1n) - const mockSendTransaction = vi.fn() - mockUseSendTransaction.mockReturnValue({ - sendTransaction: mockSendTransaction, - }) - mockSendTransaction.mockResolvedValue({ - hash: '0x0', - }) - await renderHelper({ transaction: { ...mockTransaction, hash: '0x123', stage: 'failed' } }) - await waitFor(() => - expect(screen.getByTestId('transaction-modal-failed-button')).toBeEnabled(), - ) - await act(async () => { - fireEvent.click(screen.getByTestId('transaction-modal-failed-button')) - }) - expect(mockSendTransaction).toHaveBeenCalled() - }) - }) - }) -}) - -describe('handleBackToInput', () => { - it('should reset the transaction step', () => { - handleBackToInput(mockDispatch)() - expect(mockDispatch).toBeCalledWith({ - name: 'resetTransactionStep', - }) - }) -}) - -describe('transactionSuccessHandler', () => { - it('should add recent transaction data', async () => { - const mockAddRecentTransaction = vi.fn() - - transactionSuccessHandler({ - actionName: 'actionName', - txKey: 'txKey', - request: {} as any, - addRecentTransaction: mockAddRecentTransaction, - dispatch: vi.fn(), - isSafeApp: false, - client: { request: vi.fn(async () => ({ testKey: 'testVal' })) } as any, - connectorClient: { data: { account: { address: '0x1234' } }, request: vi.fn() } as any, - })('0xhash') - - await waitFor(() => - expect(mockAddRecentTransaction).toBeCalledWith( - expect.objectContaining({ testKey: 'testVal' }), - ), - ) - }) - it('should dispatch the correct action', async () => { - const mockDispatch = vi.fn() - - transactionSuccessHandler({ - actionName: 'actionName', - txKey: 'txKey', - request: {} as any, - addRecentTransaction: vi.fn(), - dispatch: mockDispatch, - isSafeApp: false, - client: { request: vi.fn(async () => ({ testKey: 'testVal' })) } as any, - connectorClient: { data: { account: { address: '0x1234' } }, request: vi.fn() } as any, - })('0xhash') - - await waitFor(() => - expect(mockDispatch).toBeCalledWith( - expect.objectContaining({ name: 'setTransactionHash', payload: { hash: '0xhash', key: 'txKey'} }), - ), - ) - }) - it('should handle a failed call to getTransaction', async () => { - const mockAddRecentTransaction = vi.fn() - - transactionSuccessHandler({ - actionName: 'actionName', - txKey: 'txKey', - request: {} as any, - addRecentTransaction: mockAddRecentTransaction, - dispatch: vi.fn(), - isSafeApp: false, - client: { request: vi.fn(async () => Promise.reject(new Error('Error'))) } as any, - connectorClient: { data: { account: { address: '0x1234' } }, request: vi.fn() } as any, - })('0xhash') - - await waitFor(() => expect(mockAddRecentTransaction).toBeCalled()) - }) -}) - -describe('calculateGasLimit', () => { - const mockConnectorClient = { - account: { - address: '0x1234', - }, - } - const mockTxWithZeroGas = { - to: '0x1234567890123456789012345678901234567890', - value: 0n, - data: '0x12345678', - } as const - const mockTransactionName = 'registerName' - const mockIsSafeApp = false - - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should calculate gas limit for non-safe apps', async () => { - mockEstimateGas.mockResolvedValueOnce(100000n) - const result = await calculateGasLimit({ - isSafeApp: mockIsSafeApp, - txWithZeroGas: mockTxWithZeroGas, - transactionName: mockTransactionName, - client: mockClient as any, - connectorClient: mockConnectorClient as any, - }) - expect(result.gasLimit).toEqual(105000n) - expect(result.accessList).toBeUndefined() - expect(mockEstimateGas).toHaveBeenCalledWith(mockClient, { - ...mockTxWithZeroGas, - account: mockConnectorClient.account, - }) - }) - - it('should calculate gas limit for safe apps', async () => { - const mockAccessListResponse = { - gasUsed: '0x64', - accessList: [ - { - address: '0x1234567890123456789012345678901234567890', - storageKeys: ['0x1234567890123456789012345678901234567890123456789012345678901234'], - }, - ], - } - mockClient.request.mockResolvedValueOnce(mockAccessListResponse) - const result = await calculateGasLimit({ - isSafeApp: true, - txWithZeroGas: mockTxWithZeroGas, - transactionName: mockTransactionName, - client: mockClient as any, - connectorClient: mockConnectorClient as any, - }) - expect(result.gasLimit).toEqual(5100n) - expect(result.accessList).toEqual(mockAccessListResponse.accessList) - expect(mockClient.request).toHaveBeenCalledWith({ - method: 'eth_createAccessList', - params: [ - { - from: mockConnectorClient.account.address, - ...mockTxWithZeroGas, - value: '0x0', - }, - 'latest', - ], - }) - }) -}) diff --git a/src/components/@molecules/TransactionDialogManager/stage/TransactionStageModal.tsx b/src/components/@molecules/TransactionDialogManager/stage/TransactionStageModal.tsx deleted file mode 100644 index 27ed8fcd9..000000000 --- a/src/components/@molecules/TransactionDialogManager/stage/TransactionStageModal.tsx +++ /dev/null @@ -1,568 +0,0 @@ -import { queryOptions } from '@tanstack/react-query' -import { Dispatch, useCallback, useEffect, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import styled, { css } from 'styled-components' -import { BaseError } from 'viem' -import { useClient, useConnectorClient, useSendTransaction } from 'wagmi' - -import { - Button, - CrossCircleSVG, - Dialog, - Helper, - QuestionCircleSVG, - Spinner, - Typography, -} from '@ensdomains/thorin' - -import AeroplaneSVG from '@app/assets/Aeroplane.svg' -import CircleTickSVG from '@app/assets/CircleTick.svg' -import WalletSVG from '@app/assets/Wallet.svg' -import { Outlink } from '@app/components/Outlink' -import { useChainName } from '@app/hooks/chain/useChainName' -import { useInvalidateOnBlock } from '@app/hooks/chain/useInvalidateOnBlock' -import { useAddRecentTransaction } from '@app/hooks/transactions/useAddRecentTransaction' -import { useRecentTransactions } from '@app/hooks/transactions/useRecentTransactions' -import { useIsSafeApp } from '@app/hooks/useIsSafeApp' -import { useQueryOptions } from '@app/hooks/useQueryOptions' -import { - ManagedDialogProps, - TransactionFlowAction, - TransactionStage, -} from '@app/transaction-flow/types' -import { ConfigWithEns, TransactionDisplayItem } from '@app/types' -import { getReadableError } from '@app/utils/errors' -import { getIsCachedData } from '@app/utils/getIsCachedData' -import { useQuery } from '@app/utils/query/useQuery' -import { makeEtherscanLink } from '@app/utils/utils' - -import { DisplayItems } from '../DisplayItems' -import { - createTransactionRequestQueryFn, - getTransactionErrorQueryFn, - getUniqueTransaction, - transactionSuccessHandler, -} from './query' - -const BarContainer = styled.div( - ({ theme }) => css` - width: ${theme.space.full}; - display: flex; - flex-direction: column; - align-items: center; - gap: ${theme.space['2']}; - `, -) - -const WalletIcon = styled.svg( - ({ theme }) => css` - width: ${theme.space['12']}; - `, -) - -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: Status; 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 === 'complete') { - return t('transaction.dialog.complete.message') - } - if (status === 'failed') { - return null - } - return t('transaction.dialog.sent.message') - }, [status, t]) - - const isTakingLongerThanExpected = status === 'sent' && progress === 100 - - const progressMessage = useMemo(() => { - if (isTakingLongerThanExpected) { - return ( - - {t('transaction.dialog.sent.learn')} - - ) - } - return null - }, [isTakingLongerThanExpected, t]) - - const EndElement = useMemo(() => { - if (status === 'complete') { - return - } - if (status === 'failed') { - 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}} - - ) -} - -export const handleBackToInput = (dispatch: Dispatch) => () => { - dispatch({ name: 'setFlowStage', payload: 'input' }) - dispatch({ name: 'resetTransactionStep' }) -} - -function useCreateSubnameRedirect( - shouldTrigger: boolean, - subdomain?: TransactionDisplayItem['value'], -) { - useEffect(() => { - if (shouldTrigger && typeof subdomain === 'string') { - setTimeout(() => { - window.location.href = `/${subdomain}` - }, 1000) - } - }, [shouldTrigger, subdomain]) -} - -export const TransactionStageModal = ({ - actionName, - currentStep, - displayItems, - helper, - dispatch, - stepCount, - transaction, - txKey, - onDismiss, - backToInput, -}: ManagedDialogProps) => { - const { t } = useTranslation() - const chainName = useChainName() - - const { data: isSafeApp, isLoading: safeAppStatusLoading } = useIsSafeApp() - const { data: connectorClient } = useConnectorClient() - const client = useClient() - - const addRecentTransaction = useAddRecentTransaction() - - const stage = transaction.stage || 'confirm' - const recentTransactions = useRecentTransactions() - const transactionStatus = useMemo( - () => recentTransactions.find((tx) => tx.hash === transaction.hash)?.status, - [recentTransactions, transaction.hash], - ) - - const uniqueTxIdentifiers = useMemo( - () => - getUniqueTransaction({ - txKey, - currentStep, - transaction, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [txKey, currentStep, transaction?.name, transaction?.data], - ) - - // 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(uniqueTxIdentifiers).every((val) => typeof val === 'number' || !!val), - [uniqueTxIdentifiers], - ) - - const canEnableTransactionRequest = useMemo( - () => - !!transaction && - !!connectorClient?.account && - !safeAppStatusLoading && - !(stage === 'sent' || stage === 'complete') && - isUniquenessDefined, - [transaction, connectorClient?.account, safeAppStatusLoading, stage, isUniquenessDefined], - ) - - const initialOptions = useQueryOptions({ - params: uniqueTxIdentifiers, - 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: { - onSuccess: transactionSuccessHandler({ - client, - connectorClient: connectorClient!, - actionName, - txKey, - request, - addRecentTransaction, - dispatch, - isSafeApp, - }), - }, - }) - - useCreateSubnameRedirect( - stage === 'complete' && currentStep + 1 === stepCount, - displayItems.find((i) => i.label === 'subname' && i.type === 'name')?.value, - ) - - const FilledDisplayItems = useMemo( - () => , - [displayItems], - ) - - const MiddleContent = useMemo(() => { - if (stage !== 'confirm') { - return - } - return ( - <> - - {t('transaction.dialog.confirm.message')} - - ) - }, [stage, t, transaction.sendTime]) - - const HelperContent = useMemo(() => { - if (!helper) return null - return - }, [helper]) - - const ActionButton = useMemo(() => { - if (stage === 'complete') { - const final = currentStep + 1 === stepCount - - if (final) { - return ( - - ) - } - return ( - - ) - } - if (stage === 'failed') { - return ( - - ) - } - if (stage === 'sent') { - return ( - - ) - } - if (transactionLoading) { - return ( - - ) - } - return ( - - ) - }, [ - canEnableTransactionRequest, - currentStep, - dispatch, - onDismiss, - requestError, - requestLoading, - sendTransaction, - stage, - stepCount, - t, - transactionLoading, - request, - isTransactionRequestCachedData, - ]) - - const stepStatus = useMemo(() => { - if (stage === 'complete') { - return 'completed' - } - return 'inProgress' - }, [stage]) - - const initialErrorOptions = useQueryOptions({ - params: { hash: transaction.hash, status: transactionStatus }, - functionName: 'getTransactionError', - queryDependencyType: 'standard', - queryFn: getTransactionErrorQueryFn, - }) - - const preparedErrorOptions = queryOptions({ - queryKey: initialErrorOptions.queryKey, - queryFn: initialErrorOptions.queryFn, - }) - - const { data: upperError } = useQuery({ - ...preparedErrorOptions, - enabled: !!transaction && !!transaction.hash && transactionStatus === 'failed', - }) - - const lowerError = useMemo(() => { - if (stage === 'complete' || stage === 'sent') 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, stage, transactionError, requestError]) - - return ( - <> - - - {MiddleContent} - {upperError && {t(upperError)}} - {FilledDisplayItems} - {HelperContent} - {transaction.hash && ( - - {t('transaction.viewEtherscan')} - - )} - {lowerError && {lowerError}} - - 1 ? stepCount : undefined} - stepStatus={stepStatus} - trailing={ActionButton} - leading={ - backToInput && - !(stage === 'sent' || stage === 'complete') && ( - - ) - } - /> - - ) -} diff --git a/src/components/@molecules/TransactionDialogManager/stage/query.ts b/src/components/@molecules/TransactionDialogManager/stage/query.ts deleted file mode 100644 index 25a0910a8..000000000 --- a/src/components/@molecules/TransactionDialogManager/stage/query.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { QueryFunctionContext } from '@tanstack/react-query' -import { CallParameters, SendTransactionReturnType } from '@wagmi/core' -import { Dispatch } from 'react' -import { - Address, - BlockTag, - Hash, - Hex, - PrepareTransactionRequestRequest, - toHex, - Transaction, - TransactionRequest, -} from 'viem' -import { call, estimateGas, getTransaction, prepareTransactionRequest } from 'viem/actions' - -import { SupportedChain } from '@app/constants/chains' -import { TransactionStatus } from '@app/hooks/transactions/transactionStore' -import { useAddRecentTransaction } from '@app/hooks/transactions/useAddRecentTransaction' -import { useIsSafeApp } from '@app/hooks/useIsSafeApp' -import { createTransactionRequest, TransactionName } from '@app/transaction-flow/transaction' -import { - GetUniqueTransactionParameters, - ManagedDialogProps, - TransactionFlowAction, - UniqueTransaction, -} from '@app/transaction-flow/types' -import { - BasicTransactionRequest, - ClientWithEns, - ConfigWithEns, - ConnectorClientWithEns, - CreateQueryKey, -} from '@app/types' -import { getReadableError } from '@app/utils/errors' -import { CheckIsSafeAppReturnType } from '@app/utils/safe' - -type AccessListResponse = { - accessList: { - address: Address - storageKeys: Hex[] - }[] - gasUsed: Hex -} - -export const getUniqueTransaction = ({ - txKey, - currentStep, - transaction, -}: GetUniqueTransactionParameters): UniqueTransaction => ({ - key: txKey!, - step: currentStep, - name: transaction.name, - data: transaction.data, -}) - -export const transactionSuccessHandler = - ({ - client, - connectorClient, - actionName, - txKey, - request, - addRecentTransaction, - dispatch, - isSafeApp, - }: { - client: ClientWithEns - connectorClient: ConnectorClientWithEns - actionName: ManagedDialogProps['actionName'] - txKey: string | null - request: PrepareTransactionRequestRequest | undefined - addRecentTransaction: ReturnType - dispatch: Dispatch - isSafeApp: ReturnType['data'] - }) => - async (tx: SendTransactionReturnType) => { - let transactionData: Transaction | null = null - try { - // If using private mempool, this won't error, will return null - transactionData = await connectorClient.request<{ - Method: 'eth_getTransactionByHash' - Parameters: [hash: Hash] - ReturnType: Transaction | null - }>({ method: 'eth_getTransactionByHash', params: [tx] }) - } catch (e) { - // this is expected to fail in most cases - } - - if (!transactionData) { - try { - transactionData = await client.request({ - method: 'eth_getTransactionByHash', - params: [tx], - }) - } catch (e) { - console.error('Failed to get transaction info') - } - } - - addRecentTransaction({ - ...transactionData, - hash: tx, - action: actionName, - key: txKey!, - input: request?.data, - timestamp: Math.floor(Date.now() / 1000), - isSafeTx: !!isSafeApp, - searchRetries: 0, - }) - dispatch({ name: 'setTransactionHash', payload: { hash: tx, key: txKey! } }) - } - -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< - UniqueTransaction, - '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 | undefined; status: Exclude | undefined }, - 'getTransactionError', - 'standard' -> - -export const getTransactionErrorQueryFn = - (config: ConfigWithEns) => - async ({ - queryKey: [{ hash, status }, chainId], - }: QueryFunctionContext) => { - if (!hash || status !== 'failed') 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/components/@molecules/VerificationBadge/VerificationBadge.tsx b/src/components/@molecules/VerificationBadge/VerificationBadge.tsx index 0c4dfc027..9f136d4ea 100644 --- a/src/components/@molecules/VerificationBadge/VerificationBadge.tsx +++ b/src/components/@molecules/VerificationBadge/VerificationBadge.tsx @@ -6,7 +6,7 @@ import { AlertSVG, Colors, Tooltip } from '@ensdomains/thorin' import VerifiedPersonSVG from '@app/assets/VerifiedPerson.svg' import VerifiedRecordSVG from '@app/assets/VerifiedRecord.svg' -import { VerificationProtocol } from '@app/transaction/user/VerifyProfile/VerifyProfile-flow' +import { VerificationProtocol } from '@app/transaction/user/input/VerifyProfile/VerifyProfile-flow' type Color = Extract diff --git a/src/components/@molecules/VerificationBadge/components/VerificationBadgeAccountTooltipContent.tsx b/src/components/@molecules/VerificationBadge/components/VerificationBadgeAccountTooltipContent.tsx index 6d574412f..fc274f72e 100644 --- a/src/components/@molecules/VerificationBadge/components/VerificationBadgeAccountTooltipContent.tsx +++ b/src/components/@molecules/VerificationBadge/components/VerificationBadgeAccountTooltipContent.tsx @@ -4,7 +4,7 @@ import { match } from 'ts-pattern' import { Colors, OutlinkSVG, Typography } from '@ensdomains/thorin' import DentitySVG from '@app/assets/verification/Dentity.svg' -import { VerificationProtocol } from '@app/transaction/user/VerifyProfile/VerifyProfile-flow' +import { VerificationProtocol } from '@app/transaction/user/input/VerifyProfile/VerifyProfile-flow' type Props = { verifiers?: VerificationProtocol[] } diff --git a/src/components/Notifications.tsx b/src/components/Notifications.tsx deleted file mode 100644 index 811050a68..000000000 --- a/src/components/Notifications.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import styled, { css } from 'styled-components' - -import { Button, Toast } from '@ensdomains/thorin' - -import { useChainName } from '@app/hooks/chain/useChainName' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' -import { useBreakpoint } from '@app/utils/BreakpointProvider' -import { UpdateCallback, useCallbackOnTransaction } from '@app/utils/SyncProvider/SyncProvider' -import { makeEtherscanLink } from '@app/utils/utils' - -import { trackEvent } from '../utils/analytics' - -type Notification = { - title: string - description?: string - children?: React.ReactNode -} - -const ButtonContainer = styled.div( - ({ theme }) => css` - display: flex; - flex-direction: row; - align-items: center; - justify-content: stretch; - gap: ${theme.space['2']}; - `, -) - -export const Notifications = () => { - const { t } = useTranslation() - const breakpoints = useBreakpoint() - - const chainName = useChainName() - - const [open, setOpen] = useState(false) - - const { resumeTransactionFlow, getResumable } = useTransactionFlow() - - const [notificationQueue, setNotificationQueue] = useState([]) - const currentNotification = notificationQueue[0] - - const updateCallback = useCallback( - ({ action, key, status, hash }) => { - if (status === 'pending' || status === 'repriced') return - if (status === 'confirmed') { - switch (action) { - case 'registerName': - trackEvent('register', chainName) - break - case 'commitName': - trackEvent('commit', chainName) - break - case 'extendNames': - trackEvent('renew', chainName) - break - default: - break - } - } - const resumable = key && getResumable(key) - const item = { - title: t(`transaction.status.${status}.notifyTitle`), - description: t(`transaction.status.${status}.notifyMessage`, { - action: t(`transaction.description.${action}`), - }), - children: resumable ? ( - - - - - - - ) : ( - - - - ), - } - - setNotificationQueue((queue) => [...queue, item]) - }, - [chainName, getResumable, resumeTransactionFlow, t], - ) - - useCallbackOnTransaction(updateCallback) - - useEffect(() => { - if (currentNotification) { - setOpen(true) - } - }, [currentNotification]) - - return ( - { - setOpen(false) - setTimeout( - () => setNotificationQueue((prev) => [...prev.filter((x) => x !== currentNotification)]), - 300, - ) - }} - open={open} - variant={breakpoints.sm ? 'desktop' : 'touch'} - {...currentNotification} - /> - ) -} diff --git a/src/components/pages/profile/ProfileButton.tsx b/src/components/pages/profile/ProfileButton.tsx index 81f0d6790..d60941d5f 100644 --- a/src/components/pages/profile/ProfileButton.tsx +++ b/src/components/pages/profile/ProfileButton.tsx @@ -26,7 +26,7 @@ import { VerificationBadge } from '@app/components/@molecules/VerificationBadge/ import { useCoinChain } from '@app/hooks/chain/useCoinChain' import { usePrimaryName } from '@app/hooks/ensjs/public/usePrimaryName' import { getDestination } from '@app/routes' -import { VerificationProtocol } from '@app/transaction/user/VerifyProfile/VerifyProfile-flow' +import { VerificationProtocol } from '@app/transaction/user/input/VerifyProfile/VerifyProfile-flow' import { useBreakpoint } from '@app/utils/BreakpointProvider' import { getContentHashLink } from '@app/utils/contenthash' import { getSocialData } from '@app/utils/getSocialData' diff --git a/src/components/pages/register/steps/Pricing/Pricing.tsx b/src/components/pages/register/steps/Pricing/Pricing.tsx index 1ea98ae00..65eb8a1ab 100644 --- a/src/components/pages/register/steps/Pricing/Pricing.tsx +++ b/src/components/pages/register/steps/Pricing/Pricing.tsx @@ -230,7 +230,7 @@ const Pricing = ({ reverseRecord, seconds, records: [{ key: 'ETH', value: resolverAddress, type: 'addr', group: 'address' }], - clearRecords: resolverExists, + clearRecords: resolverExists ?? false, resolverAddress, }, }) diff --git a/src/constants/verification.ts b/src/constants/verification.ts index 8a06eddba..b244aa714 100644 --- a/src/constants/verification.ts +++ b/src/constants/verification.ts @@ -1,4 +1,4 @@ -import type { VerificationProtocol } from '@app/transaction/user/VerifyProfile/VerifyProfile-flow' +import type { VerificationProtocol } from '@app/transaction/user/input/VerifyProfile/VerifyProfile-flow' /** * General Verification Constants diff --git a/src/hooks/gasEstimation/useEstimateRegistration.ts b/src/hooks/gasEstimation/useEstimateRegistration.ts index 2ca3530c8..78294b168 100644 --- a/src/hooks/gasEstimation/useEstimateRegistration.ts +++ b/src/hooks/gasEstimation/useEstimateRegistration.ts @@ -3,7 +3,7 @@ import { parseEther } from 'viem' import { makeCommitment } from '@ensdomains/ensjs/utils' -import { RegistrationReducerDataItem } from '@app/components/registration/types' +import type { StoredRegistrationFlow } from '@app/transaction/slices/createRegistrationFlowSlice' import { deriveYearlyFee } from '@app/utils/utils' import { useAccountSafely } from '../account/useAccountSafely' @@ -15,7 +15,7 @@ import { usePrice } from '../ensjs/public/usePrice' import useRegistrationParams from '../useRegistrationParams' type UseEstimateFullRegistrationParameters = { - registrationData: RegistrationReducerDataItem + registrationData: StoredRegistrationFlow name: string } diff --git a/src/hooks/useEthPrice.ts b/src/hooks/useEthPrice.ts index 4fd8cef70..a4c81090c 100644 --- a/src/hooks/useEthPrice.ts +++ b/src/hooks/useEthPrice.ts @@ -1,21 +1,16 @@ import { Address } from 'viem' -import { useChainId, useReadContract } from 'wagmi' -import { goerli } from 'wagmi/chains' +import { useReadContract } from 'wagmi' import { useAddressRecord } from './ensjs/public/useAddressRecord' const ORACLE_ENS = 'eth-usd.data.eth' -const ORACLE_GOERLI = '0xD4a33860578De61DBAbDc8BFdb98FD742fA7028e' as const export const useEthPrice = () => { - const chainId = useChainId() - - const { data: address_ } = useAddressRecord({ + const { data: addressResult } = useAddressRecord({ name: ORACLE_ENS, - enabled: chainId !== goerli.id, }) - const address = chainId === 5 ? ORACLE_GOERLI : (address_?.value as Address) || undefined + const address = (addressResult?.value as Address) ?? undefined return useReadContract({ abi: [ diff --git a/src/hooks/useRegistrationReducer.ts b/src/hooks/useRegistrationReducer.ts deleted file mode 100644 index cda517aa6..000000000 --- a/src/hooks/useRegistrationReducer.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { useChainId } from 'wagmi' - -import { randomSecret } from '@ensdomains/ensjs/utils' - -import { childFuseObj } from '@app/components/@molecules/BurnFuses/BurnFusesContent' -import { - RegistrationReducerAction, - RegistrationReducerData, - RegistrationReducerDataItem, - SelectedItemProperties, -} from '@app/components/registration/types' -import { useLocalStorageReducer } from '@app/hooks/useLocalStorage' -import { yearsToSeconds } from '@app/utils/utils' - -const REGISTRATION_REDUCER_DATA_ITEM_VERSION = 3 - -const defaultData: RegistrationReducerDataItem = { - stepIndex: 0, - queue: ['pricing', 'info', 'transactions', 'complete'], - seconds: yearsToSeconds(1), - reverseRecord: false, - records: [], - clearRecords: false, - resolverAddress: '0x', - permissions: childFuseObj, - secret: '0x', - started: false, - address: '0x', - name: '', - isMoonpayFlow: false, - externalTransactionId: '', - chainId: 1, - durationType: 'years', - version: REGISTRATION_REDUCER_DATA_ITEM_VERSION, -} - -const isBrowser = !!( - typeof window !== 'undefined' && - window.document && - window.document.createElement -) - -const makeDefaultData = (selected: SelectedItemProperties): RegistrationReducerDataItem => ({ - stepIndex: 0, - queue: ['pricing', 'info', 'transactions', 'complete'], - seconds: yearsToSeconds(1), - reverseRecord: false, - records: [], - resolverAddress: '0x', - permissions: childFuseObj, - secret: randomSecret({ platformDomain: 'enslabs.eth', campaign: 3 }), - started: false, - isMoonpayFlow: false, - externalTransactionId: '', - version: REGISTRATION_REDUCER_DATA_ITEM_VERSION, - durationType: 'years', - ...selected, -}) - -export const getSelectedIndex = ( - state: RegistrationReducerData, - selected: SelectedItemProperties, -) => - state.items.findIndex( - (x) => - x.address === selected.address && - x.name === selected.name && - x.chainId === selected.chainId && - x.version === REGISTRATION_REDUCER_DATA_ITEM_VERSION, - ) - -/* eslint-disable no-param-reassign */ -const reducer = (state: RegistrationReducerData, action: RegistrationReducerAction) => { - let selectedItemInx = getSelectedIndex(state, action.selected) - - if (!isBrowser) return state - - if (selectedItemInx === -1) { - selectedItemInx = state.items.push(makeDefaultData(action.selected)) - 1 - } - - const item = state.items[selectedItemInx] - - switch (action.name) { - case 'clearItem': { - state.items.splice(selectedItemInx, 1) - break - } - case 'resetItem': { - state.items[selectedItemInx] = makeDefaultData(action.selected) - break - } - case 'resetSecret': { - item.secret = randomSecret() - break - } - case 'setQueue': { - item.queue = action.payload - break - } - case 'decreaseStep': { - item.stepIndex -= 1 - break - } - case 'increaseStep': { - item.stepIndex += 1 - break - } - case 'setPricingData': { - item.seconds = action.payload.seconds - item.reverseRecord = action.payload.reverseRecord - item.durationType = action.payload.durationType - break - } - case 'setTransactionsData': { - item.secret = action.payload.secret - item.started = action.payload.started - break - } - case 'setStarted': { - item.started = true - break - } - case 'setProfileData': { - if (action.payload.records) item.records = action.payload.records - if (action.payload.permissions) item.permissions = action.payload.permissions - if (action.payload.resolverAddress) item.resolverAddress = action.payload.resolverAddress - break - } - case 'setExternalTransactionId': { - item.isMoonpayFlow = true - item.externalTransactionId = action.externalTransactionId - break - } - case 'moonpayTransactionCompleted': { - item.externalTransactionId = '' - item.stepIndex = item.queue.findIndex((step) => step === 'complete') - break - } - // no default - } - return state -} -/* eslint-enable no-param-reassign */ - -const useRegistrationReducer = ({ - address, - name, -}: { - address: string | undefined - name: string -}) => { - const chainId = useChainId() - const selected = { address: address!, name, chainId } as const - const [state, dispatch] = useLocalStorageReducer< - RegistrationReducerData, - RegistrationReducerAction - >('registration-status', reducer, { items: [] }) - - let item = defaultData - if (isBrowser) { - const itemIndex = getSelectedIndex(state, selected) - item = itemIndex === -1 ? makeDefaultData(selected) : state.items[itemIndex] - } - - return { state, dispatch, item, selected } -} - -export default useRegistrationReducer diff --git a/src/hooks/verification/useVerificationOAuth/useVerificationOAuth.ts b/src/hooks/verification/useVerificationOAuth/useVerificationOAuth.ts index fab5f7cd0..7fccdefa4 100644 --- a/src/hooks/verification/useVerificationOAuth/useVerificationOAuth.ts +++ b/src/hooks/verification/useVerificationOAuth/useVerificationOAuth.ts @@ -5,7 +5,7 @@ import { getOwner, getRecords } from '@ensdomains/ensjs/public' import { VERIFICATION_RECORD_KEY } from '@app/constants/verification' import { useQueryOptions } from '@app/hooks/useQueryOptions' -import { VerificationProtocol } from '@app/transaction/user/VerifyProfile/VerifyProfile-flow' +import { VerificationProtocol } from '@app/transaction/user/input/VerifyProfile/VerifyProfile-flow' import { ConfigWithEns, CreateQueryKey, QueryConfig } from '@app/types' import { prepareQueryOptions } from '@app/utils/prepareQueryOptions' diff --git a/src/hooks/verification/useVerifiedRecords/utils/makeAppendVerificationProps.ts b/src/hooks/verification/useVerifiedRecords/utils/makeAppendVerificationProps.ts index 1d3650d29..b343bbc32 100644 --- a/src/hooks/verification/useVerifiedRecords/utils/makeAppendVerificationProps.ts +++ b/src/hooks/verification/useVerifiedRecords/utils/makeAppendVerificationProps.ts @@ -1,4 +1,4 @@ -import type { VerificationProtocol } from '@app/transaction/user/VerifyProfile/VerifyProfile-flow' +import type { VerificationProtocol } from '@app/transaction/user/input/VerifyProfile/VerifyProfile-flow' import { NormalisedAccountsRecord } from '@app/utils/records/normaliseProfileAccountsRecord' import type { UseVerifiedRecordsReturnType } from '../useVerifiedRecords' diff --git a/src/pages/register.tsx b/src/pages/register.tsx index 081726adf..e1f4b30e5 100644 --- a/src/pages/register.tsx +++ b/src/pages/register.tsx @@ -4,9 +4,9 @@ import { useAccount, useChainId } from 'wagmi' import Registration from '@app/components/pages/register/Registration' import { useInitial } from '@app/hooks/useInitial' import { useNameDetails } from '@app/hooks/useNameDetails' -import { getSelectedIndex } from '@app/hooks/useRegistrationReducer' import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' import { ContentGrid } from '@app/layouts/ContentGrid' +import { useTransactionManager } from '@app/transaction/transactionManager' export default function Page() { const router = useRouterWithHistory() @@ -22,27 +22,21 @@ export default function Page() { const nameDetails = useNameDetails({ name }) const { isLoading: detailsLoading, registrationStatus } = nameDetails + const getCurrentRegistrationFlowStep = useTransactionManager( + (s) => s.getCurrentRegistrationFlowStep, + ) + const isLoading = detailsLoading || initial if (!isLoading && registrationStatus !== 'available' && registrationStatus !== 'premium') { let redirect = true if (nameDetails.ownerData?.owner === address && !!address) { - const registrationData = JSON.parse( - localStorage.getItem('registration-status') || '{"items":[]}', - ) - const index = getSelectedIndex(registrationData, { - address: address!, - name: nameDetails.normalisedName, - chainId, + const step = getCurrentRegistrationFlowStep(nameDetails.normalisedName, { + account: address!, + sourceChainId: chainId, }) - if (index !== -1) { - const { stepIndex, queue } = registrationData.items[index] - const step = queue[stepIndex] - if (step === 'transactions' || step === 'complete') { - redirect = false - } - } + if (step === 'transactions' || step === 'complete') redirect = false } if (redirect) { diff --git a/src/transaction/components/stage/transaction/useManagedTransaction.ts b/src/transaction/components/stage/transaction/useManagedTransaction.ts index 86add5eb6..b8a9c517e 100644 --- a/src/transaction/components/stage/transaction/useManagedTransaction.ts +++ b/src/transaction/components/stage/transaction/useManagedTransaction.ts @@ -5,8 +5,8 @@ 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 { GenericStoredTransaction } from '@app/transaction/slices/createTransactionSlice' +import type { UserTransactionName } from '@app/transaction/user/transaction' import type { ConfigWithEns } from '@app/types' import { getIsCachedData } from '@app/utils/getIsCachedData' @@ -17,7 +17,7 @@ import { transactionSuccessHandler, } from './query' -export const useManagedTransaction = ( +export const useManagedTransaction = ( transaction: GenericStoredTransaction, ) => { const { data: isSafeApp, isLoading: safeAppStatusLoading } = useIsSafeApp() diff --git a/src/transaction/user/input.tsx b/src/transaction/user/input.tsx index ac34d7e96..7ad2bd4a0 100644 --- a/src/transaction/user/input.tsx +++ b/src/transaction/user/input.tsx @@ -1,8 +1,7 @@ import dynamic from 'next/dynamic' import { useContext, useEffect, type ComponentProps } from 'react' -import DynamicLoadingContext from '@app/components/@molecules/TransactionDialogManager/DynamicLoadingContext' - +import DynamicLoadingContext from '../components/DynamicLoadingContext' import TransactionLoader from '../components/TransactionLoader' import type { Props as AdvancedEditorProps } from './input/AdvancedEditor/AdvancedEditor-flow' import type { Props as CreateSubnameProps } from './input/CreateSubname/CreateSubname-flow' diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index d0ad3f5ee..f9516d00f 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -1,6 +1,6 @@ import { mainnet } from 'viem/chains' -import type { SupportedChain } from '@ensdomains/ensjs/contracts' +import type { SupportedChain } from '@app/constants/chains' declare global { interface Window { diff --git a/src/utils/records/categoriseProfileTextRecords.ts b/src/utils/records/categoriseProfileTextRecords.ts index d9460b9d6..dee167d4a 100644 --- a/src/utils/records/categoriseProfileTextRecords.ts +++ b/src/utils/records/categoriseProfileTextRecords.ts @@ -9,7 +9,7 @@ import { supportedSocialRecordKeys, } from '@app/constants/supportedSocialRecordKeys' import { VERIFICATION_RECORD_KEY } from '@app/constants/verification' -import { VerificationProtocol } from '@app/transaction/user/VerifyProfile/VerifyProfile-flow' +import { VerificationProtocol } from '@app/transaction/user/input/VerifyProfile/VerifyProfile-flow' import { contentHashToString } from '../contenthash' import { diff --git a/src/utils/verification/isVerificationProtocol.ts b/src/utils/verification/isVerificationProtocol.ts index 68ad3d581..b6a52b371 100644 --- a/src/utils/verification/isVerificationProtocol.ts +++ b/src/utils/verification/isVerificationProtocol.ts @@ -1,5 +1,5 @@ import { VERIFICATION_PROTOCOLS } from '@app/constants/verification' -import { VerificationProtocol } from '@app/transaction/user/VerifyProfile/VerifyProfile-flow' +import { VerificationProtocol } from '@app/transaction/user/input/VerifyProfile/VerifyProfile-flow' export const isVerificationProtocol = (value: string): value is VerificationProtocol => { return VERIFICATION_PROTOCOLS.includes(value as VerificationProtocol) diff --git a/src/utils/verification/labelForVerificationProtocol.ts b/src/utils/verification/labelForVerificationProtocol.ts index d9d8adbe6..8829645f3 100644 --- a/src/utils/verification/labelForVerificationProtocol.ts +++ b/src/utils/verification/labelForVerificationProtocol.ts @@ -1,4 +1,4 @@ -import type { VerificationProtocol } from '@app/transaction/user/VerifyProfile/VerifyProfile-flow' +import type { VerificationProtocol } from '@app/transaction/user/input/VerifyProfile/VerifyProfile-flow' export const labelForVerificationProtocol = (protocol: VerificationProtocol) => { if (protocol === 'dentity') return 'dentity.com' diff --git a/tsconfig.json b/tsconfig.json index f00fa1841..481985697 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -70,6 +70,8 @@ "**/*.ignore.tsx", "functions/**/*", "deploy/**/*", + "**/*.test.ts", + "**/*.test.tsx", "hardhat.config.ts" ] -} \ No newline at end of file +}