= (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' })}
+
+
+
+
+
+ )
+}
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={
+
+ }
+ />
+ >
+ )
+}
+
+export default ExtendNames
diff --git a/src/transaction/user/input/ProfileEditor/ProfileEditor-flow.tsx b/src/transaction/user/input/ProfileEditor/ProfileEditor-flow.tsx
new file mode 100644
index 000000000..394f5e64c
--- /dev/null
+++ b/src/transaction/user/input/ProfileEditor/ProfileEditor-flow.tsx
@@ -0,0 +1,426 @@
+/* eslint-disable no-nested-ternary */
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { Control, useWatch } from 'react-hook-form'
+import { useTranslation } from 'react-i18next'
+import styled, { css } from 'styled-components'
+import { match } from 'ts-pattern'
+import { useChainId } from 'wagmi'
+
+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 {
+ getProfileRecordsDiff,
+ isEthAddressRecord,
+ profileEditorFormToProfileRecords,
+ profileToProfileRecords,
+} from '@app/components/pages/profile/[name]/registration/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 { getResolverWrapperAwareness } from '@app/utils/utils'
+
+import ResolverWarningOverlay from './ResolverWarningOverlay'
+import { WrappedAvatarButton } from './WrappedAvatarButton'
+
+const AvatarWrapper = styled.div(
+ () => css`
+ display: flex;
+ justify-content: center;
+ `,
+)
+
+const ButtonContainer = styled.div(
+ ({ theme }) => css`
+ display: flex;
+ justify-content: center;
+ padding-bottom: ${theme.space['4']};
+ `,
+)
+
+const ButtonWrapper = styled.div(({ theme }) => [
+ css`
+ width: ${theme.space.full};
+ `,
+ mq.xs.min(css`
+ width: max-content;
+ `),
+])
+
+type Data = {
+ name?: string
+ resumable?: boolean
+}
+
+export type Props = {
+ name?: string
+ data?: Data
+ onDismiss?: () => void
+} & TransactionDialogPassthrough
+
+const SubmitButton = ({
+ control,
+ previousRecords,
+ disabled: _disabled,
+ canEdit = true,
+ onClick,
+}: {
+ control: Control
+ previousRecords: ProfileRecord[]
+ disabled: boolean
+ canEdit: boolean
+ onClick: () => void
+}) => {
+ const { t } = useTranslation('common')
+
+ // Precompute the records that will be submitted
+ const form = useWatch({ control }) as ProfileEditorForm
+ const currentRecords = profileEditorFormToProfileRecords(form)
+ const recordsDiff = getProfileRecordsDiff(currentRecords, previousRecords)
+
+ const disabled = _disabled || recordsDiff.length === 0
+
+ return canEdit ? (
+
+ ) : (
+
+ )
+}
+
+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),
+ })}
+ />
+ ),
+ )}
+
+
+ }
+ >
+ {t('steps.profile.addMore')}
+
+
+
+
+ {
+ 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
+}
diff --git a/src/transaction/user/input/RevokePermissions/views/GrantExtendExpiryView.tsx b/src/transaction/user/input/RevokePermissions/views/GrantExtendExpiryView.tsx
new file mode 100644
index 000000000..315df636f
--- /dev/null
+++ b/src/transaction/user/input/RevokePermissions/views/GrantExtendExpiryView.tsx
@@ -0,0 +1,29 @@
+import { forwardRef } from 'react'
+import { UseFormRegister } from 'react-hook-form'
+import { useTranslation } from 'react-i18next'
+
+import { CheckboxRow, Dialog } from '@ensdomains/thorin'
+
+import type { FormData, RevokePermissionsDialogContentProps } from '../RevokePermissions-flow'
+
+type Props = {
+ register: UseFormRegister
+} & RevokePermissionsDialogContentProps
+
+export const GrantExtendExpiryView = forwardRef(
+ ({ register, ...dialogContentProps }, ref) => {
+ const { t } = useTranslation('transactionFlow')
+ return (
+ <>
+
+
+
+
+ >
+ )
+ },
+)
diff --git a/src/transaction/user/input/RevokePermissions/views/NameConfirmationWarningView.test.tsx b/src/transaction/user/input/RevokePermissions/views/NameConfirmationWarningView.test.tsx
new file mode 100644
index 000000000..c85dc341b
--- /dev/null
+++ b/src/transaction/user/input/RevokePermissions/views/NameConfirmationWarningView.test.tsx
@@ -0,0 +1,46 @@
+import { fireEvent, render, renderHook, screen } from '@app/test-utils'
+
+import { useState } from 'react'
+import { describe, expect, it } from 'vitest'
+
+import { NameConfirmationWarningView } from './NameConfirmationWarningView'
+import { makeMockIntersectionObserver } from '../../../../../test/mock/makeMockIntersectionObserver'
+
+makeMockIntersectionObserver()
+
+describe('NameConfirmationWarningView', () => {
+ it('should disable if input does not match the name', () => {
+ const { result: hook } = renderHook(() => useState(false))
+
+ const { getByTestId } = render(
+ ,
+ )
+
+ const input = getByTestId('input-name-confirmation')
+
+ fireEvent.change(input, { target: { value: 'smth.eth' } })
+
+ expect(hook.current[0]).toBe(true)
+ })
+ it('should enable if name matches', () => {
+ const { result: hook } = renderHook(() => useState(false))
+
+ const { getByTestId } = render(
+ ,
+ )
+
+ const input = getByTestId('input-name-confirmation')
+
+ fireEvent.change(input, { target: { value: 'test.eth' } })
+
+ expect(hook.current[0]).toBe(false)
+ })
+})
diff --git a/src/transaction/user/input/RevokePermissions/views/NameConfirmationWarningView.tsx b/src/transaction/user/input/RevokePermissions/views/NameConfirmationWarningView.tsx
new file mode 100644
index 000000000..d28cc3645
--- /dev/null
+++ b/src/transaction/user/input/RevokePermissions/views/NameConfirmationWarningView.tsx
@@ -0,0 +1,50 @@
+import { forwardRef } from 'react'
+import { useTranslation } from 'react-i18next'
+
+import { Dialog, Input } from '@ensdomains/thorin'
+
+import { CenterAlignedTypography } from '../components/CenterAlignedTypography'
+import type { RevokePermissionsDialogContentProps } from '../RevokePermissions-flow'
+
+type Props = {
+ expiry: Date
+ name: string
+ setDisabled: (v: boolean) => void
+} & RevokePermissionsDialogContentProps
+
+export const NameConfirmationWarningView = forwardRef(
+ ({ expiry, name, setDisabled, ...dialogContentProps }, ref) => {
+ const { t } = useTranslation('transactionFlow')
+
+ return (
+ <>
+
+
+
+ {t('input.revokePermissions.views.lastWarning.subtitle', {
+ date: Intl.DateTimeFormat(undefined, {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ }).format(expiry),
+ })}
+
+
+ {t('input.revokePermissions.views.lastWarning.message', { name })}
+
+ {
+ if (e.key === 'Enter') e.preventDefault()
+ }}
+ onChange={(e) => {
+ setDisabled(e.currentTarget.value !== name)
+ }}
+ />
+
+ >
+ )
+ },
+)
diff --git a/src/transaction/user/input/RevokePermissions/views/ParentRevokePermissionsView.tsx b/src/transaction/user/input/RevokePermissions/views/ParentRevokePermissionsView.tsx
new file mode 100644
index 000000000..6f51d88e6
--- /dev/null
+++ b/src/transaction/user/input/RevokePermissions/views/ParentRevokePermissionsView.tsx
@@ -0,0 +1,61 @@
+import { forwardRef } from 'react'
+import { Control, UseFormRegister, useWatch } from 'react-hook-form'
+import { useTranslation } from 'react-i18next'
+
+import {
+ ChildFuseKeys,
+ ChildFuseReferenceType,
+ ParentFuseReferenceType,
+} from '@ensdomains/ensjs/utils'
+import { CheckboxRow, Dialog } from '@ensdomains/thorin'
+
+import type { FormData, RevokePermissionsDialogContentProps } from '../RevokePermissions-flow'
+
+type Props = {
+ register: UseFormRegister
+ control: Control
+ unburnedFuses: (ChildFuseReferenceType['Key'] | ParentFuseReferenceType['Key'])[]
+} & RevokePermissionsDialogContentProps
+
+const CHILD_FUSES_WITHOUT_CU: ChildFuseReferenceType['Key'][] = ChildFuseKeys.filter(
+ (fuse) => fuse !== 'CANNOT_UNWRAP',
+)
+
+export const ParentRevokePermissionsView = forwardRef(
+ ({ register, control, unburnedFuses, ...dialogContentProps }, ref) => {
+ const { t } = useTranslation('transactionFlow')
+
+ const unwrapped = useWatch({ control, name: 'childFuses.CANNOT_UNWRAP' })
+
+ const isCEEUnburned = unburnedFuses.includes('CAN_EXTEND_EXPIRY')
+
+ return (
+ <>
+
+
+ {isCEEUnburned && (
+
+ )}
+
+ {CHILD_FUSES_WITHOUT_CU.map((fuse) => (
+
+ ))}
+
+ >
+ )
+ },
+)
diff --git a/src/transaction/user/input/RevokePermissions/views/RevokeChangeFusesView.tsx b/src/transaction/user/input/RevokePermissions/views/RevokeChangeFusesView.tsx
new file mode 100644
index 000000000..1a02b1255
--- /dev/null
+++ b/src/transaction/user/input/RevokePermissions/views/RevokeChangeFusesView.tsx
@@ -0,0 +1,34 @@
+import { forwardRef } from 'react'
+import { UseFormRegister } from 'react-hook-form'
+import { useTranslation } from 'react-i18next'
+
+import { CheckboxRow, Dialog } from '@ensdomains/thorin'
+
+import { CenterAlignedTypography } from '../components/CenterAlignedTypography'
+import type { FormData, RevokePermissionsDialogContentProps } from '../RevokePermissions-flow'
+
+type Props = {
+ register: UseFormRegister
+} & RevokePermissionsDialogContentProps
+
+export const RevokeChangeFusesView = forwardRef(
+ ({ register, ...dialogContentProps }, ref) => {
+ const { t } = useTranslation('transactionFlow')
+
+ return (
+ <>
+
+
+
+ {t('input.revokePermissions.views.revokeChangeFuses.subtitle')}
+
+
+
+ >
+ )
+ },
+)
diff --git a/src/transaction/user/input/RevokePermissions/views/RevokeChangeFusesWarningView.tsx b/src/transaction/user/input/RevokePermissions/views/RevokeChangeFusesWarningView.tsx
new file mode 100644
index 000000000..3a2fe03c7
--- /dev/null
+++ b/src/transaction/user/input/RevokePermissions/views/RevokeChangeFusesWarningView.tsx
@@ -0,0 +1,28 @@
+import { forwardRef } from 'react'
+import { Trans, useTranslation } from 'react-i18next'
+
+import { Dialog } from '@ensdomains/thorin'
+
+import { CenterAlignedTypography } from '../components/CenterAlignedTypography'
+import type { RevokePermissionsDialogContentProps } from '../RevokePermissions-flow'
+
+export const RevokeChangeFusesWarningView = forwardRef<
+ HTMLFormElement,
+ RevokePermissionsDialogContentProps
+>((dialogContentProps, ref) => {
+ const { t } = useTranslation('transactionFlow')
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ )
+})
diff --git a/src/transaction/user/input/RevokePermissions/views/RevokePCCView.tsx b/src/transaction/user/input/RevokePermissions/views/RevokePCCView.tsx
new file mode 100644
index 000000000..34eee9b2e
--- /dev/null
+++ b/src/transaction/user/input/RevokePermissions/views/RevokePCCView.tsx
@@ -0,0 +1,51 @@
+import { forwardRef } from 'react'
+import { UseFormRegister } from 'react-hook-form'
+import { Trans, useTranslation } from 'react-i18next'
+import styled, { css } from 'styled-components'
+import type { Address } from 'viem'
+
+import { CheckboxRow, Dialog, Typography } from '@ensdomains/thorin'
+
+import { usePrimaryNameOrAddress } from '@app/hooks/reverseRecord/usePrimaryNameOrAddress'
+
+import type { FormData, RevokePermissionsDialogContentProps } from '../RevokePermissions-flow'
+
+type Props = {
+ managerAddress: Address
+ register: UseFormRegister
+ onDismiss: () => void
+} & RevokePermissionsDialogContentProps
+
+const CenterAlignedTypography = styled(Typography)(
+ () => css`
+ text-align: center;
+ `,
+)
+
+export const RevokePCCView = forwardRef(
+ ({ managerAddress, register, ...dialogContentProps }, ref) => {
+ const { t } = useTranslation('transactionFlow')
+
+ const { data: { nameOrAddr } = {} } = usePrimaryNameOrAddress({ address: managerAddress })
+
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ )
+ },
+)
diff --git a/src/transaction/user/input/RevokePermissions/views/RevokePermissionsView.tsx b/src/transaction/user/input/RevokePermissions/views/RevokePermissionsView.tsx
new file mode 100644
index 000000000..9cba9c4b5
--- /dev/null
+++ b/src/transaction/user/input/RevokePermissions/views/RevokePermissionsView.tsx
@@ -0,0 +1,61 @@
+import { forwardRef } from 'react'
+import { UseFormRegister } from 'react-hook-form'
+import { useTranslation } from 'react-i18next'
+
+import { ChildFuseKeys, ChildFuseReferenceType } from '@ensdomains/ensjs/utils'
+import { CheckboxRow, Dialog } from '@ensdomains/thorin'
+
+import type { FormData, RevokePermissionsDialogContentProps } from '../RevokePermissions-flow'
+
+type Props = {
+ register: UseFormRegister
+ unburnedFuses: ChildFuseReferenceType['Key'][]
+} & RevokePermissionsDialogContentProps
+
+const CHILD_FUSES_WITHOUT_CU_AND_CBF = ChildFuseKeys.filter(
+ (fuse) => !['CANNOT_UNWRAP', 'CANNOT_BURN_FUSES'].includes(fuse),
+)
+
+export const RevokePermissionsView = forwardRef(
+ ({ register, unburnedFuses, ...dialogContentProps }, ref) => {
+ const { t } = useTranslation('transactionFlow')
+
+ const { burned, unburned } = CHILD_FUSES_WITHOUT_CU_AND_CBF.reduce<{
+ burned: ChildFuseReferenceType['Key'][]
+ unburned: ChildFuseReferenceType['Key'][]
+ }>(
+ (filteredFuses, fuse) => {
+ const isUnburned = unburnedFuses.includes(fuse)
+ if (isUnburned) filteredFuses.unburned.push(fuse)
+ else filteredFuses.burned.push(fuse)
+ return filteredFuses
+ },
+ { burned: [], unburned: [] },
+ )
+
+ return (
+ <>
+
+
+ {unburned.map((fuse) => (
+
+ ))}
+ {burned.map((fuse) => (
+
+ ))}
+
+ >
+ )
+ },
+)
diff --git a/src/transaction/user/input/RevokePermissions/views/RevokeUnwrapView.tsx b/src/transaction/user/input/RevokePermissions/views/RevokeUnwrapView.tsx
new file mode 100644
index 000000000..b9bf2a571
--- /dev/null
+++ b/src/transaction/user/input/RevokePermissions/views/RevokeUnwrapView.tsx
@@ -0,0 +1,39 @@
+import { forwardRef } from 'react'
+import { UseFormRegister } from 'react-hook-form'
+import { useTranslation } from 'react-i18next'
+import styled, { css } from 'styled-components'
+
+import { CheckboxRow, Dialog, Typography } from '@ensdomains/thorin'
+
+import type { FormData, RevokePermissionsDialogContentProps } from '../RevokePermissions-flow'
+
+type Props = {
+ register: UseFormRegister
+} & RevokePermissionsDialogContentProps
+
+const CenterAlignedTypography = styled(Typography)(
+ () => css`
+ text-align: center;
+ `,
+)
+
+export const RevokeUnwrapView = forwardRef(
+ ({ register, ...dialogContetnProps }, ref) => {
+ const { t } = useTranslation('transactionFlow')
+ return (
+ <>
+
+
+
+ {t('input.revokePermissions.views.revokeUnwrap.subtitle')}
+
+
+
+ >
+ )
+ },
+)
diff --git a/src/transaction/user/input/RevokePermissions/views/RevokeWarningView.tsx b/src/transaction/user/input/RevokePermissions/views/RevokeWarningView.tsx
new file mode 100644
index 000000000..07689673b
--- /dev/null
+++ b/src/transaction/user/input/RevokePermissions/views/RevokeWarningView.tsx
@@ -0,0 +1,50 @@
+import { forwardRef } from 'react'
+import { Trans, useTranslation } from 'react-i18next'
+import styled, { css } from 'styled-components'
+
+import { Dialog } from '@ensdomains/thorin'
+
+import { getSupportLink } from '@app/utils/supportLinks'
+
+import { CenterAlignedTypography } from '../components/CenterAlignedTypography'
+import type { RevokePermissionsDialogContentProps } from '../RevokePermissions-flow'
+
+const StyledAnchor = styled.a(
+ ({ theme }) => css`
+ color: ${theme.colors.accent};
+ font-weight: ${theme.fontWeights.bold};
+ `,
+)
+
+export const RevokeWarningView = forwardRef(
+ (dialogContentProps, ref) => {
+ const { t } = useTranslation('transactionFlow')
+
+ return (
+ <>
+
+
+
+ {t('input.revokePermissions.views.revokeWarning.subtitle')}
+
+
+
+ ),
+ }}
+ >
+ {t('input.revokePermissions.views.revokeWarning.subtitle2')}
+
+
+
+ >
+ )
+ },
+)
diff --git a/src/transaction/user/input/RevokePermissions/views/SetExpiryView.tsx b/src/transaction/user/input/RevokePermissions/views/SetExpiryView.tsx
new file mode 100644
index 000000000..e8419f887
--- /dev/null
+++ b/src/transaction/user/input/RevokePermissions/views/SetExpiryView.tsx
@@ -0,0 +1,202 @@
+import { forwardRef } from 'react'
+import {
+ Control,
+ UseFormGetValues,
+ UseFormRegister,
+ useFormState,
+ UseFormTrigger,
+ useWatch,
+} from 'react-hook-form'
+import { Trans, useTranslation } from 'react-i18next'
+import styled, { css } from 'styled-components'
+
+import { Dialog, Input, RadioButton, Typography } from '@ensdomains/thorin'
+
+import { dateTimeLocalToDate, dateToDateTimeLocal, stripDateMs } from '@app/utils/datetime-local'
+
+import { CenterAlignedTypography } from '../components/CenterAlignedTypography'
+import type { FormData, RevokePermissionsDialogContentProps } from '../RevokePermissions-flow'
+
+type Props = {
+ name: string
+ minExpiry?: number
+ maxExpiry: number
+ register: UseFormRegister
+ control: Control
+ getValues: UseFormGetValues
+ trigger: UseFormTrigger
+} & RevokePermissionsDialogContentProps
+
+const DateContainer = styled.div(
+ ({ theme }) => css`
+ padding: ${theme.space['2']} ${theme.space['4']};
+ background: ${theme.colors.greySurface};
+ border-radius: ${theme.space['2']};
+ `,
+)
+
+const CustomExpiryErrorLabel = styled.div(
+ ({ theme }) => css`
+ color: ${theme.colors.red};
+ margin-top: ${theme.space['2']};
+ padding: 0 ${theme.space['2']};
+ font-weight: ${theme.fontWeights.bold};
+ font-size: ${theme.fontSizes.body};
+ line-height: ${theme.lineHeights.body};
+ `,
+)
+
+export const SetExpiryView = forwardRef(
+ (
+ { name, minExpiry, maxExpiry, register, control, getValues, trigger, ...dialogContentProps },
+ ref,
+ ) => {
+ const { t } = useTranslation('transactionFlow')
+
+ const canExtendExpiry = useWatch({ control, name: 'parentFuses.CAN_EXTEND_EXPIRY' })
+ const nameParts = name.split('.')
+ const parentName = nameParts.slice(1).join('.')
+
+ const formState = useFormState({ control, name: 'expiryCustom' })
+ const customErrorLabel = formState.errors.expiryCustom?.message
+
+ const minDate = new Date(Math.max((minExpiry || 0) * 1000, Date.now()))
+ const maxDate = new Date(maxExpiry * 1000)
+
+ const minDateTime = dateToDateTimeLocal(minDate)
+ const maxDateTime = dateToDateTimeLocal(maxDate)
+
+ const maxDateLabel = maxDate.toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ })
+ const maxTimeLabel = maxDate.toLocaleTimeString(undefined, {
+ hour: 'numeric',
+ minute: 'numeric',
+ second: 'numeric',
+ hour12: false,
+ timeZoneName: 'short',
+ })
+
+ const expiryLabel = minDate.toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ })
+ return (
+ <>
+
+
+
+ {canExtendExpiry ? (
+
+ ) : (
+
+ )}
+
+
+ {t('input.revokePermissions.views.setExpiry.options.max')}
+
+ }
+ description={
+
+
+ {maxDateLabel}
+
+
+ {maxTimeLabel}
+
+
+ }
+ {...register('expiryType', {
+ onChange: () => {
+ trigger('expiryCustom')
+ },
+ })}
+ />
+
+ {t('input.revokePermissions.views.setExpiry.options.custom')}
+
+ }
+ description={
+ <>
+ {
+ const expiryType = getValues('expiryType')
+ if (expiryType !== 'custom') return true
+ if (!value) return t('input.revokePermissions.views.setExpiry.error.required')
+ if (value < minDateTime) {
+ const dateLabel = dateTimeLocalToDate(minDateTime).toLocaleDateString(
+ undefined,
+ {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ second: 'numeric',
+ },
+ )
+ return t('input.revokePermissions.views.setExpiry.error.min', {
+ date: dateLabel,
+ })
+ }
+ if (value > maxDateTime) {
+ const dateLabel = dateTimeLocalToDate(maxDateTime).toLocaleDateString(
+ undefined,
+ {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ second: 'numeric',
+ },
+ )
+ return t('input.revokePermissions.views.setExpiry.error.max', {
+ date: dateLabel,
+ })
+ }
+ return true
+ },
+ })}
+ />
+ {customErrorLabel && (
+ {customErrorLabel}
+ )}
+ >
+ }
+ {...register('expiryType')}
+ />
+
+ >
+ )
+ },
+)
diff --git a/src/transaction/user/input/SelectPrimaryName/SelectPrimaryName-flow.tsx b/src/transaction/user/input/SelectPrimaryName/SelectPrimaryName-flow.tsx
new file mode 100644
index 000000000..3eda7ea98
--- /dev/null
+++ b/src/transaction/user/input/SelectPrimaryName/SelectPrimaryName-flow.tsx
@@ -0,0 +1,375 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useRef, useState } from 'react'
+import { useForm, UseFormReturn, useWatch } from 'react-hook-form'
+import { useTranslation } from 'react-i18next'
+import styled, { css } from 'styled-components'
+import { Address, labelhash } from 'viem'
+import { useClient } from 'wagmi'
+
+import { getDecodedName, Name } from '@ensdomains/ensjs/subgraph'
+import { decodeLabelhash, isEncodedLabelhash, saveName } from '@ensdomains/ensjs/utils'
+import { Button, Dialog, Heading, mq, Typography } from '@ensdomains/thorin'
+
+import { DialogFooterWithBorder } from '@app/components/@molecules/DialogComponentVariants/DialogFooterWithBorder'
+import { DialogHeadingWithBorder } from '@app/components/@molecules/DialogComponentVariants/DialogHeadinWithBorder'
+import {
+ NameTableHeader,
+ SortDirection,
+ SortType,
+} from '@app/components/@molecules/NameTableHeader/NameTableHeader'
+import { SpinnerRow } from '@app/components/@molecules/ScrollBoxWithSpinner'
+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 useDebouncedCallback from '@app/hooks/useDebouncedCallback'
+import { useIsWrapped } from '@app/hooks/useIsWrapped'
+import { useProfile } from '@app/hooks/useProfile'
+import { createQueryKey } from '@app/hooks/useQueryOptions'
+import {
+ nameToFormData,
+ UnknownLabelsForm,
+ FormData as UnknownLabelsFormData,
+} from '@app/transaction-flow/input/UnknownLabels/views/UnknownLabelsForm'
+import { TransactionDialogPassthrough } from '@app/transaction-flow/types'
+
+import { TaggedNameItemWithFuseCheck } from './components/TaggedNameItemWithFuseCheck'
+
+const DEFAULT_PAGE_SIZE = 10
+
+export const hasEncodedLabel = (name: string) =>
+ name.split('.').some((label) => isEncodedLabelhash(label))
+
+export const getNameFromUnknownLabels = (
+ name: string,
+ unknownLabels: UnknownLabelsFormData['unknownLabels'],
+) => {
+ const [tld, ...reversedLabels] = name.split('.').reverse()
+ const labels = reversedLabels.reverse()
+ const processedLabels = labels.map((label, index) => {
+ const unknownLabel = unknownLabels.labels[index]
+ if (
+ !!unknownLabel &&
+ isEncodedLabelhash(label) &&
+ decodeLabelhash(label) === unknownLabel.label &&
+ unknownLabel.label === labelhash(unknownLabel.value)
+ )
+ return unknownLabel.value
+ return label
+ })
+ return [...processedLabels, tld].join('.')
+}
+
+type Data = {
+ address: Address
+}
+
+export type Props = {
+ data: Data
+} & TransactionDialogPassthrough
+
+type FormData = {
+ name?: Name
+} & UnknownLabelsFormData
+
+const LoadingContainer = styled.div(
+ ({ theme }) => css`
+ display: flex;
+ flex-direction: column;
+ min-height: ${theme.space['72']};
+ justify-content: center;
+ align-items: center;
+ gap: 0;
+ `,
+)
+
+const NameTableHeaderWrapper = styled.div(({ theme }) => [
+ css`
+ width: calc(100% + 2 * ${theme.space['4']});
+ margin: 0 -${theme.space['4']} -${theme.space['4']};
+ border-bottom: 1px solid ${theme.colors.border};
+ > div {
+ border-bottom: none;
+ }
+ `,
+ mq.sm.min(css`
+ width: calc(100% + 2 * ${theme.space['6']});
+ margin: 0 -${theme.space['6']} -${theme.space['6']};
+ `),
+])
+
+const ErrorContainer = styled.div(
+ ({ theme }) => css`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: ${theme.space['4']};
+ `,
+)
+
+const SelectPrimaryName = ({ data: { address }, dispatch, onDismiss }: Props) => {
+ const { t } = useTranslation('transactionFlow')
+ const formRef = useRef(null)
+ const queryClient = useQueryClient()
+
+ const form = useForm({
+ mode: 'onChange',
+ defaultValues: {
+ name: undefined,
+ unknownLabels: {
+ tld: '',
+ labels: [],
+ },
+ },
+ })
+ const { handleSubmit, control, setValue } = form
+
+ const client = useClient()
+
+ const [view, setView] = useState<'main' | 'decrypt'>('main')
+
+ const [sortType, setSortType] = useState('labelName')
+ const [sortDirection, setSortDirection] = useState('asc')
+ const [searchInput, setSearchInput] = useState('')
+ const [searchQuery, _setSearchQuery] = useState('')
+ const setSearchQuery = useDebouncedCallback(_setSearchQuery, 300, [])
+
+ const currentPrimary = usePrimaryName({ address })
+ const {
+ data: namesData,
+ hasNextPage,
+ fetchNextPage: loadMoreNames,
+ isLoading: isLoadingNames,
+ } = useNamesForAddress({
+ address,
+ orderBy: sortType,
+ orderDirection: sortDirection,
+ filter: {
+ searchString: searchQuery,
+ },
+ pageSize: DEFAULT_PAGE_SIZE,
+ })
+
+ // Filter out the primary name's data
+ const filteredNamesPages =
+ namesData?.pages?.map((page: Name[]) =>
+ page.filter((name: Name) => name?.name !== currentPrimary?.data?.name),
+ ) || []
+
+ const selectedName = useWatch({
+ control,
+ name: 'name',
+ })
+
+ const { data: isWrapped, isLoading: isWrappedLoading } = useIsWrapped({
+ name: selectedName?.name!,
+ enabled: !!selectedName?.name,
+ })
+ const { data: selectedNameProfile } = useProfile({
+ name: selectedName?.name!,
+ enabled: !!selectedName?.name,
+ subgraphEnabled: false,
+ })
+
+ const resolverStatus = useResolverStatus({
+ name: selectedName?.name!,
+ enabled: !!selectedName && !isWrappedLoading,
+ migratedRecordsMatch: { type: 'address', match: { id: 60, value: address } },
+ })
+
+ const getPrimarynameTransactionFlowItem = useGetPrimaryNameTransactionFlowItem({
+ address,
+ isWrapped,
+ profileAddress: selectedNameProfile?.coins.find((c) => c.id === 60)?.value,
+ resolverAddress: selectedNameProfile?.resolverAddress,
+ resolverStatus: resolverStatus.data,
+ })
+
+ const dispatchTransactions = (name: string) => {
+ const transactionFlowItem = getPrimarynameTransactionFlowItem.callBack?.(name)
+ if (!transactionFlowItem) return
+ 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',
+ })
+ return
+ }
+ dispatch({
+ name: 'startFlow',
+ key: 'ChangePrimaryName',
+ payload: transactionFlowItem,
+ })
+ }
+
+ // Checks if name has encoded labels and attempts decrypt them if they exist
+ const validateKey = (input: string) =>
+ createQueryKey({
+ queryDependencyType: 'independent',
+ params: { input },
+ functionName: 'validate',
+ })
+ const { mutate: mutateName, isPending: isMutationLoading } = useMutation({
+ mutationFn: async (data: FormData) => {
+ if (!data.name?.name) throw new Error('no_name')
+
+ let validName = data.name.name
+ if (!hasEncodedLabel(validName)) return validName
+
+ // build name from unkown labels
+ validName = getNameFromUnknownLabels(validName, data.unknownLabels)
+ if (!hasEncodedLabel(validName)) {
+ saveName(validName)
+ queryClient.resetQueries({ queryKey: validateKey(data.name?.name) })
+ return validName
+ }
+
+ // Attempt to decrypt name
+ validName = (await getDecodedName(client, {
+ name: validName,
+ allowIncomplete: true,
+ })) as string
+ if (!hasEncodedLabel(validName)) {
+ saveName(validName)
+ queryClient.resetQueries({ queryKey: validateKey(data.name?.name) })
+ return validName
+ }
+
+ throw new Error('invalid_name')
+ },
+ onSuccess: (name) => {
+ dispatchTransactions(name)
+ },
+ onError: (error, variables) => {
+ if (!(error instanceof Error)) return
+ if (error.message === 'invalid_name') {
+ setValue('unknownLabels', nameToFormData(variables.name?.name || '').unknownLabels)
+ setView('decrypt')
+ }
+ },
+ })
+
+ const onConfirm = () => {
+ formRef.current?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }))
+ }
+
+ const isLoading = isLoadingNames || isMutationLoading
+ const isLoadingName =
+ resolverStatus.isLoading || isWrappedLoading || getPrimarynameTransactionFlowItem.isLoading
+
+ // Show header if more than one page has been loaded, if only one page has been loaded but there is another page, or if there is an active search query
+ const showHeader =
+ (!!namesData && filteredNamesPages.length > 1 && !searchQuery) || hasNextPage || !!searchQuery
+
+ const hasNoEligibleNames =
+ !searchQuery && filteredNamesPages.length === 1 && filteredNamesPages[0].length === 0
+
+ if (isLoading)
+ return (
+
+
+ {t('loading', { ns: 'common' })}
+
+
+
+ )
+
+ return view === 'decrypt' ? (
+ )}
+ ref={formRef}
+ onSubmit={mutateName}
+ onCancel={() => {
+ setValue('unknownLabels', nameToFormData('').unknownLabels)
+ setView('main')
+ }}
+ onConfirm={onConfirm}
+ />
+ ) : (
+ <>
+
+ {showHeader && (
+
+ setSortType(type as SortType)}
+ onSortDirectionChange={setSortDirection}
+ onSearchChange={(search) => {
+ setSearchInput(search)
+ setSearchQuery(search)
+ }}
+ />
+
+ )}
+ mutateName(data))}
+ >
+ {!!namesData && filteredNamesPages[0].length > 0 ? (
+ <>
+ {filteredNamesPages?.map((page: Name[]) =>
+ page.map((name: Name) => (
+ {
+ setValue('name', selectedName?.name === name.name ? undefined : name)
+ }}
+ />
+ )),
+ )}
+ >
+ ) : (
+
+
+ {hasNoEligibleNames
+ ? t('input.selectPrimaryName.errors.noEligibleNames')
+ : t('input.selectPrimaryName.errors.noNamesFound')}
+
+
+ )}
+
+
+ {t('action.cancel', { ns: 'common' })}
+
+ }
+ 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