Fix: debounce fetch apps (#1021)
* Fix: debounce fetch apps * refactor: fix AddAppForm name and add missing types * add `use-lodash-debounce` hook to test debounce functionality I'm planning to remove this dependency, as it requires to also install `lodash.debounce`. I prefer to implement it ad-hoc. * refactor AddAppForm to use the observable pattern * memoize `getAppInfoFromUrl` to prevent requesting the same information over and over * prevent requesting data if url is not valid * remove logging * prevent validating form before visiting the fields * refactor AddAppForm reorganize code * fix: change `any` to `unknown` * fix: `uitls.ts` types and imports * refactor: rename `isSubmitDisabled` to `onSubmitButtonStatusChange` prop * refactor: rename `agreement` to `agreementAccepted` also, moved `initialValues` to a constant `INITIAL_VALUES` outside the component * refactor: reimplement `useDebounce` hook in-app * refactor: extract app manifest verification to a helper function also fixed types * fix: prevent accessing `contentWindow` if `iframe` is `null` * fix: `getAppInfoFromOrigin` return type also, removed the expected type for the `getAppInfoFromOrigin` calls as it is inferred Co-authored-by: fernandomg <fernando.greco@gmail.com> Co-authored-by: Mikhail Mikheev <mmvsha73@gmail.com>
This commit is contained in:
parent
e80b4574bf
commit
251da319a5
|
@ -191,6 +191,7 @@
|
|||
"immortal-db": "^1.0.3",
|
||||
"immutable": "^4.0.0-rc.12",
|
||||
"js-cookie": "^2.2.1",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.memoize": "^4.1.2",
|
||||
"material-ui-search-bar": "^1.0.0-beta.13",
|
||||
"notistack": "https://github.com/gnosis/notistack.git#v0.9.4",
|
||||
|
|
|
@ -1,205 +0,0 @@
|
|||
import { Checkbox, Text, TextField } from '@gnosis.pm/safe-react-components'
|
||||
import memoize from 'lodash.memoize'
|
||||
import React, { useState } from 'react'
|
||||
import { FormSpy } from 'react-final-form'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Field from 'src/components/forms/Field'
|
||||
import DebounceValidationField from 'src/components/forms/Field/DebounceValidationField'
|
||||
import GnoForm from 'src/components/forms/GnoForm'
|
||||
import { required } from 'src/components/forms/validator'
|
||||
import Img from 'src/components/layout/Img'
|
||||
import { getContentFromENS } from 'src/logic/wallets/getWeb3'
|
||||
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
|
||||
import { isValid as isURLValid } from 'src/utils/url'
|
||||
|
||||
import { getAppInfoFromUrl } from './utils'
|
||||
import { SafeApp } from './types'
|
||||
|
||||
const APP_INFO: SafeApp = {
|
||||
id: undefined,
|
||||
url: '',
|
||||
name: '',
|
||||
iconUrl: appsIconSvg,
|
||||
error: false,
|
||||
}
|
||||
|
||||
const StyledText = styled(Text)`
|
||||
margin-bottom: 19px;
|
||||
`
|
||||
|
||||
const StyledTextFileAppName = styled(TextField)`
|
||||
&& {
|
||||
width: 335px;
|
||||
}
|
||||
`
|
||||
|
||||
const AppInfo = styled.div`
|
||||
margin: 36px 0 24px 0;
|
||||
|
||||
img {
|
||||
margin-right: 10px;
|
||||
}
|
||||
`
|
||||
|
||||
const StyledCheckbox = styled(Checkbox)`
|
||||
margin: 0;
|
||||
`
|
||||
|
||||
const uniqueAppValidator = memoize((appList, value) => {
|
||||
const exists = appList.some((a) => {
|
||||
try {
|
||||
const currentUrl = new URL(a.url)
|
||||
const newUrl = new URL(value)
|
||||
return currentUrl.href === newUrl.href
|
||||
} catch (error) {
|
||||
return 'There was a problem trying to validate the URL existence.'
|
||||
}
|
||||
})
|
||||
return exists ? 'This app is already registered.' : undefined
|
||||
})
|
||||
|
||||
const getIpfsLinkFromEns = memoize(async (name) => {
|
||||
try {
|
||||
const content = await getContentFromENS(name)
|
||||
if (content && content.protocolType === 'ipfs') {
|
||||
return `${process.env.REACT_APP_IPFS_GATEWAY}/${content.decoded}/`
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
const getUrlFromFormValue = memoize(async (value: string) => {
|
||||
const isUrlValid = isURLValid(value)
|
||||
let ensContent
|
||||
if (!isUrlValid) {
|
||||
ensContent = await getIpfsLinkFromEns(value)
|
||||
}
|
||||
|
||||
if (!isUrlValid && ensContent === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return isUrlValid ? value : ensContent
|
||||
})
|
||||
|
||||
const curriedSafeAppValidator = memoize((appList) => async (value: string) => {
|
||||
const url = await getUrlFromFormValue(value)
|
||||
|
||||
if (!url) {
|
||||
return 'Provide a valid url or ENS name.'
|
||||
}
|
||||
|
||||
const appExistsRes = uniqueAppValidator(appList, url)
|
||||
if (appExistsRes) {
|
||||
return appExistsRes
|
||||
}
|
||||
|
||||
const appInfo = await getAppInfoFromUrl(url)
|
||||
if (appInfo.error) {
|
||||
return 'This is not a valid Safe app.'
|
||||
}
|
||||
})
|
||||
|
||||
const composeValidatorsApps = (...validators) => (value, values, meta) => {
|
||||
if (!meta.modified) {
|
||||
return
|
||||
}
|
||||
return validators.reduce((error, validator) => error || validator(value), undefined)
|
||||
}
|
||||
|
||||
type Props = {
|
||||
formId: string
|
||||
appList: Array<SafeApp>
|
||||
closeModal: () => void
|
||||
onAppAdded: (app: SafeApp) => void
|
||||
setIsSubmitDisabled: (status: boolean) => void
|
||||
}
|
||||
|
||||
const AddAppForm = ({ appList, formId, closeModal, onAppAdded, setIsSubmitDisabled }: Props) => {
|
||||
const [appInfo, setAppInfo] = useState<SafeApp>(APP_INFO)
|
||||
const safeAppValidator = curriedSafeAppValidator(appList)
|
||||
|
||||
const onFormStatusChange = async ({ pristine, valid, validating, values, errors }) => {
|
||||
if (!pristine) {
|
||||
setIsSubmitDisabled(validating || !valid)
|
||||
}
|
||||
|
||||
if (validating) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!values.appUrl || !values.appUrl.length || errors.appUrl) {
|
||||
setAppInfo(APP_INFO)
|
||||
return
|
||||
}
|
||||
|
||||
const url = await getUrlFromFormValue(values.appUrl)
|
||||
const appInfo = await getAppInfoFromUrl(url)
|
||||
setAppInfo({ ...appInfo })
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
closeModal()
|
||||
onAppAdded(appInfo)
|
||||
}
|
||||
|
||||
const onTextFieldChange = () => {}
|
||||
|
||||
return (
|
||||
<GnoForm
|
||||
initialValues={{
|
||||
appUrl: '',
|
||||
agreed: false,
|
||||
}}
|
||||
// submit is triggered from ManageApps Component
|
||||
onSubmit={handleSubmit}
|
||||
testId={formId}
|
||||
>
|
||||
{() => (
|
||||
<>
|
||||
<StyledText size="xl">Add custom app</StyledText>
|
||||
<DebounceValidationField
|
||||
component={TextField}
|
||||
label="App URL"
|
||||
name="appUrl"
|
||||
placeholder="App URL"
|
||||
type="text"
|
||||
validate={composeValidatorsApps(required, safeAppValidator)}
|
||||
/>
|
||||
|
||||
<AppInfo>
|
||||
<Img alt="Token image" height={55} src={appInfo.iconUrl} />
|
||||
<StyledTextFileAppName label="App name" readOnly value={appInfo.name} onChange={onTextFieldChange} />
|
||||
</AppInfo>
|
||||
|
||||
<FormSpy
|
||||
onChange={onFormStatusChange}
|
||||
subscription={{
|
||||
values: true,
|
||||
valid: true,
|
||||
errors: true,
|
||||
pristine: true,
|
||||
validating: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Field
|
||||
component={StyledCheckbox}
|
||||
label={
|
||||
<p>
|
||||
This app is not a Gnosis product and I agree to use this app <br /> at my own risk.
|
||||
</p>
|
||||
}
|
||||
name="agreed"
|
||||
type="checkbox"
|
||||
validate={required}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</GnoForm>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddAppForm
|
|
@ -0,0 +1,36 @@
|
|||
import { Checkbox, Text } from '@gnosis.pm/safe-react-components'
|
||||
import React from 'react'
|
||||
import { useFormState } from 'react-final-form'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { required } from 'src/components/forms/validator'
|
||||
import Field from 'src/components/forms/Field'
|
||||
|
||||
const StyledCheckbox = styled(Checkbox)`
|
||||
margin: 0;
|
||||
`
|
||||
|
||||
const AppAgreement = (): React.ReactElement => {
|
||||
const { visited } = useFormState({ subscription: { visited: true } })
|
||||
|
||||
// trick to prevent having the field validated by default. Not sure why this happens in this form
|
||||
const validate = !visited.agreementAccepted ? undefined : required
|
||||
|
||||
return (
|
||||
<Field
|
||||
component={StyledCheckbox}
|
||||
label={
|
||||
<Text size="xl">
|
||||
This app is not a Gnosis product and I agree to use this app
|
||||
<br />
|
||||
at my own risk.
|
||||
</Text>
|
||||
}
|
||||
name="agreementAccepted"
|
||||
type="checkbox"
|
||||
validate={validate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppAgreement
|
|
@ -0,0 +1,62 @@
|
|||
import { TextField } from '@gnosis.pm/safe-react-components'
|
||||
import createDecorator from 'final-form-calculate'
|
||||
import React from 'react'
|
||||
import { useField, useFormState } from 'react-final-form'
|
||||
|
||||
import { SafeApp } from 'src/routes/safe/components/Apps/types'
|
||||
import { getAppInfoFromUrl, getIpfsLinkFromEns, uniqueApp } from 'src/routes/safe/components/Apps/utils'
|
||||
import { composeValidators, required } from 'src/components/forms/validator'
|
||||
import Field from 'src/components/forms/Field'
|
||||
import { isValid as isURLValid } from 'src/utils/url'
|
||||
import { isValidEnsName } from 'src/logic/wallets/ethAddresses'
|
||||
import { useDebounce } from 'src/routes/safe/container/hooks/useDebounce'
|
||||
|
||||
const validateUrl = (url: string): string | undefined => (isURLValid(url) ? undefined : 'Invalid URL')
|
||||
|
||||
export const appUrlResolver = createDecorator({
|
||||
field: 'appUrl',
|
||||
updates: {
|
||||
appUrl: async (appUrl: string): Promise<string | undefined> => {
|
||||
const ensContent = !isURLValid(appUrl) && isValidEnsName(appUrl) && (await getIpfsLinkFromEns(appUrl))
|
||||
|
||||
if (ensContent) {
|
||||
return ensContent
|
||||
}
|
||||
|
||||
return appUrl
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const AppInfoUpdater = ({ onAppInfo }: { onAppInfo: (appInfo: SafeApp) => void }): React.ReactElement => {
|
||||
const {
|
||||
input: { value: appUrl },
|
||||
} = useField('appUrl', { subscription: { value: true } })
|
||||
const debouncedValue = useDebounce(appUrl, 500)
|
||||
|
||||
React.useEffect(() => {
|
||||
const updateAppInfo = async () => {
|
||||
const appInfo = await getAppInfoFromUrl(debouncedValue)
|
||||
onAppInfo({ ...appInfo })
|
||||
}
|
||||
|
||||
if (isURLValid(debouncedValue)) {
|
||||
updateAppInfo()
|
||||
}
|
||||
}, [debouncedValue, onAppInfo])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const AppUrl = ({ appList }: { appList: SafeApp[] }): React.ReactElement => {
|
||||
const { visited } = useFormState({ subscription: { visited: true } })
|
||||
|
||||
// trick to prevent having the field validated by default. Not sure why this happens in this form
|
||||
const validate = !visited.appUrl ? undefined : composeValidators(required, validateUrl, uniqueApp(appList))
|
||||
|
||||
return (
|
||||
<Field label="App URL" name="appUrl" placeholder="App URL" type="text" component={TextField} validate={validate} />
|
||||
)
|
||||
}
|
||||
|
||||
export default AppUrl
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react'
|
||||
import { useFormState } from 'react-final-form'
|
||||
|
||||
import { SafeApp } from 'src/routes/safe/components/Apps/types'
|
||||
import { isAppManifestValid } from 'src/routes/safe/components/Apps/utils'
|
||||
|
||||
interface SubmitButtonStatusProps {
|
||||
appInfo: SafeApp
|
||||
onSubmitButtonStatusChange: (disabled: boolean) => void
|
||||
}
|
||||
|
||||
const SubmitButtonStatus = ({ appInfo, onSubmitButtonStatusChange }: SubmitButtonStatusProps): React.ReactElement => {
|
||||
const { valid, validating, visited } = useFormState({
|
||||
subscription: { valid: true, validating: true, visited: true },
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
// if non visited, fields were not evaluated yet. Then, the default value is considered invalid
|
||||
const fieldsVisited = visited.agreementAccepted && visited.appUrl
|
||||
|
||||
onSubmitButtonStatusChange(validating || !valid || !fieldsVisited || !isAppManifestValid(appInfo))
|
||||
}, [validating, valid, visited, onSubmitButtonStatusChange, appInfo])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default SubmitButtonStatus
|
|
@ -0,0 +1,90 @@
|
|||
import { Text, TextField } from '@gnosis.pm/safe-react-components'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import AppAgreement from './AppAgreement'
|
||||
import AppUrl, { AppInfoUpdater, appUrlResolver } from './AppUrl'
|
||||
import SubmitButtonStatus from './SubmitButtonStatus'
|
||||
|
||||
import { SafeApp } from 'src/routes/safe/components/Apps/types'
|
||||
import GnoForm from 'src/components/forms/GnoForm'
|
||||
import Img from 'src/components/layout/Img'
|
||||
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
|
||||
|
||||
const StyledText = styled(Text)`
|
||||
margin-bottom: 19px;
|
||||
`
|
||||
|
||||
const StyledTextFileAppName = styled(TextField)`
|
||||
&& {
|
||||
width: 335px;
|
||||
}
|
||||
`
|
||||
|
||||
const AppInfo = styled.div`
|
||||
margin: 36px 0 24px 0;
|
||||
|
||||
img {
|
||||
margin-right: 10px;
|
||||
}
|
||||
`
|
||||
|
||||
export interface AddAppFormValues {
|
||||
appUrl: string
|
||||
agreementAccepted: boolean
|
||||
}
|
||||
|
||||
const INITIAL_VALUES: AddAppFormValues = {
|
||||
appUrl: '',
|
||||
agreementAccepted: false,
|
||||
}
|
||||
|
||||
const APP_INFO: SafeApp = {
|
||||
id: undefined,
|
||||
url: '',
|
||||
name: '',
|
||||
iconUrl: appsIconSvg,
|
||||
error: false,
|
||||
description: '',
|
||||
}
|
||||
|
||||
interface AddAppProps {
|
||||
appList: SafeApp[]
|
||||
closeModal: () => void
|
||||
formId: string
|
||||
onAppAdded: (app: SafeApp) => void
|
||||
setIsSubmitDisabled: (disabled: boolean) => void
|
||||
}
|
||||
|
||||
const AddApp = ({ appList, closeModal, formId, onAppAdded, setIsSubmitDisabled }: AddAppProps): React.ReactElement => {
|
||||
const [appInfo, setAppInfo] = React.useState<SafeApp>(APP_INFO)
|
||||
|
||||
const handleSubmit = () => {
|
||||
closeModal()
|
||||
onAppAdded(appInfo)
|
||||
}
|
||||
|
||||
return (
|
||||
<GnoForm decorators={[appUrlResolver]} initialValues={INITIAL_VALUES} onSubmit={handleSubmit} testId={formId}>
|
||||
{() => (
|
||||
<>
|
||||
<StyledText size="xl">Add custom app</StyledText>
|
||||
|
||||
<AppUrl appList={appList} />
|
||||
<AppInfoUpdater onAppInfo={setAppInfo} />
|
||||
|
||||
<AppInfo>
|
||||
<Img alt="Token image" height={55} src={appInfo.iconUrl} />
|
||||
<StyledTextFileAppName label="App name" readOnly value={appInfo.name} onChange={() => {}} />
|
||||
</AppInfo>
|
||||
|
||||
<AppAgreement />
|
||||
|
||||
<SubmitButtonStatus onSubmitButtonStatusChange={setIsSubmitDisabled} appInfo={appInfo} />
|
||||
</>
|
||||
)}
|
||||
</GnoForm>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddApp
|
|
@ -2,18 +2,18 @@ import { ButtonLink, ManageListModal } from '@gnosis.pm/safe-react-components'
|
|||
import React, { useState } from 'react'
|
||||
|
||||
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
|
||||
import AddAppFrom from './AddAppForm'
|
||||
import AddAppForm from './AddAppForm'
|
||||
import { SafeApp } from './types'
|
||||
|
||||
const FORM_ID = 'add-apps-form'
|
||||
|
||||
type Props = {
|
||||
appList: Array<SafeApp>
|
||||
onAppAdded: (app: any) => void
|
||||
onAppAdded: (app: SafeApp) => void
|
||||
onAppToggle: (appId: string, enabled: boolean) => void
|
||||
}
|
||||
|
||||
const ManageApps = ({ appList, onAppAdded, onAppToggle }: Props) => {
|
||||
const ManageApps = ({ appList, onAppAdded, onAppToggle }: Props): React.ReactElement => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isSubmitDisabled, setIsSubmitDisabled] = useState(true)
|
||||
|
||||
|
@ -32,30 +32,30 @@ const ManageApps = ({ appList, onAppAdded, onAppToggle }: Props) => {
|
|||
return { ...a, checked: !a.disabled }
|
||||
})
|
||||
|
||||
const onItemToggle = (itemId, checked) => {
|
||||
const onItemToggle = (itemId: string, checked: boolean): void => {
|
||||
onAppToggle(itemId, checked)
|
||||
}
|
||||
|
||||
const ButtonLinkAux: any = ButtonLink
|
||||
const Form = (
|
||||
<AddAppForm
|
||||
formId={FORM_ID}
|
||||
appList={appList}
|
||||
closeModal={closeModal}
|
||||
onAppAdded={onAppAdded}
|
||||
setIsSubmitDisabled={setIsSubmitDisabled}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ButtonLinkAux color="primary" onClick={toggleOpen as any}>
|
||||
<ButtonLink color="primary" onClick={toggleOpen}>
|
||||
Manage Apps
|
||||
</ButtonLinkAux>
|
||||
</ButtonLink>
|
||||
{isOpen && (
|
||||
<ManageListModal
|
||||
addButtonLabel="Add custom app"
|
||||
defaultIconUrl={appsIconSvg}
|
||||
formBody={
|
||||
<AddAppFrom
|
||||
formId={FORM_ID}
|
||||
appList={appList}
|
||||
closeModal={closeModal}
|
||||
onAppAdded={onAppAdded}
|
||||
setIsSubmitDisabled={setIsSubmitDisabled}
|
||||
/>
|
||||
}
|
||||
formBody={Form}
|
||||
isSubmitFormDisabled={isSubmitDisabled}
|
||||
itemList={getItemList()}
|
||||
onClose={closeModal}
|
||||
|
|
|
@ -338,7 +338,7 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
|
|||
try {
|
||||
const currentApp = list[index]
|
||||
|
||||
const appInfo: any = await getAppInfoFromUrl(currentApp.url)
|
||||
const appInfo: SafeApp = await getAppInfoFromUrl(currentApp.url)
|
||||
if (appInfo.error) {
|
||||
throw Error(`There was a problem trying to load app ${currentApp.url}`)
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ export type SafeApp = {
|
|||
iconUrl: string
|
||||
disabled?: boolean
|
||||
error: boolean
|
||||
description: string
|
||||
}
|
||||
|
||||
export type StoredSafeApp = {
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
import axios from 'axios'
|
||||
import memoize from 'lodash.memoize'
|
||||
|
||||
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
|
||||
import { getGnosisSafeAppsUrl } from 'src/config/index'
|
||||
import { SafeApp } from './types'
|
||||
|
||||
import { getGnosisSafeAppsUrl } from 'src/config/index'
|
||||
import { getContentFromENS } from 'src/logic/wallets/getWeb3'
|
||||
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
|
||||
|
||||
const removeLastTrailingSlash = (url) => {
|
||||
if (url.substr(-1) === '/') {
|
||||
return url.substr(0, url.length - 1)
|
||||
|
@ -31,7 +34,7 @@ export const staticAppsList: Array<{ url: string; disabled: boolean }> = [
|
|||
{ url: `${gnosisAppsUrl}/tx-builder`, disabled: false },
|
||||
]
|
||||
|
||||
export const getAppInfoFromOrigin = (origin) => {
|
||||
export const getAppInfoFromOrigin = (origin: string): Record<string, string> | null => {
|
||||
try {
|
||||
return JSON.parse(origin)
|
||||
} catch (error) {
|
||||
|
@ -40,49 +43,91 @@ export const getAppInfoFromOrigin = (origin) => {
|
|||
}
|
||||
}
|
||||
|
||||
export const getAppInfoFromUrl = async (appUrl?: string): Promise<SafeApp> => {
|
||||
let res = { id: undefined, url: appUrl, name: 'unknown', iconUrl: appsIconSvg, error: true }
|
||||
export const isAppManifestValid = (appInfo: SafeApp): boolean =>
|
||||
// `appInfo` exists and `name` exists
|
||||
!!appInfo?.name &&
|
||||
// if `name` exists is not 'unknown'
|
||||
appInfo.name !== 'unknown' &&
|
||||
// `description` exists
|
||||
!!appInfo.description &&
|
||||
// `url` exists
|
||||
!!appInfo.url &&
|
||||
// no `error` (or `error` undefined)
|
||||
!appInfo.error
|
||||
|
||||
if (!appUrl?.length) {
|
||||
return res
|
||||
}
|
||||
export const getAppInfoFromUrl = memoize(
|
||||
async (appUrl?: string): Promise<SafeApp> => {
|
||||
let res = { id: undefined, url: appUrl, name: 'unknown', iconUrl: appsIconSvg, error: true, description: '' }
|
||||
|
||||
res.url = appUrl.trim()
|
||||
const noTrailingSlashUrl = removeLastTrailingSlash(res.url)
|
||||
|
||||
try {
|
||||
const appInfo = await axios.get(`${noTrailingSlashUrl}/manifest.json`, { timeout: 5_000 })
|
||||
|
||||
// verify imported app fulfil safe requirements
|
||||
if (!appInfo || !appInfo.data || !appInfo.data.name || !appInfo.data.description) {
|
||||
throw Error('The app does not fulfil the structure required.')
|
||||
if (!appUrl?.length) {
|
||||
return res
|
||||
}
|
||||
|
||||
// the DB origin field has a limit of 100 characters
|
||||
const originFieldSize = 100
|
||||
const jsonDataLength = 20
|
||||
const remainingSpace = originFieldSize - res.url.length - jsonDataLength
|
||||
res.url = appUrl.trim()
|
||||
const noTrailingSlashUrl = removeLastTrailingSlash(res.url)
|
||||
|
||||
res = {
|
||||
...res,
|
||||
...appInfo.data,
|
||||
id: JSON.stringify({ url: res.url, name: appInfo.data.name.substring(0, remainingSpace) }),
|
||||
error: false,
|
||||
}
|
||||
try {
|
||||
const appInfo = await axios.get(`${noTrailingSlashUrl}/manifest.json`, { timeout: 5_000 })
|
||||
|
||||
if (appInfo.data.iconPath) {
|
||||
try {
|
||||
const iconInfo = await axios.get(`${noTrailingSlashUrl}/${appInfo.data.iconPath}`, { timeout: 1000 * 10 })
|
||||
if (/image\/\w/gm.test(iconInfo.headers['content-type'])) {
|
||||
res.iconUrl = `${noTrailingSlashUrl}/${appInfo.data.iconPath}`
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`It was not possible to fetch icon from app ${res.url}`)
|
||||
// verify imported app fulfil safe requirements
|
||||
if (!appInfo?.data || isAppManifestValid(appInfo.data)) {
|
||||
throw Error('The app does not fulfil the structure required.')
|
||||
}
|
||||
|
||||
// the DB origin field has a limit of 100 characters
|
||||
const originFieldSize = 100
|
||||
const jsonDataLength = 20
|
||||
const remainingSpace = originFieldSize - res.url.length - jsonDataLength
|
||||
|
||||
res = {
|
||||
...res,
|
||||
...appInfo.data,
|
||||
id: JSON.stringify({ url: res.url, name: appInfo.data.name.substring(0, remainingSpace) }),
|
||||
error: false,
|
||||
}
|
||||
|
||||
if (appInfo.data.iconPath) {
|
||||
try {
|
||||
const iconInfo = await axios.get(`${noTrailingSlashUrl}/${appInfo.data.iconPath}`, { timeout: 1000 * 10 })
|
||||
if (/image\/\w/gm.test(iconInfo.headers['content-type'])) {
|
||||
res.iconUrl = `${noTrailingSlashUrl}/${appInfo.data.iconPath}`
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`It was not possible to fetch icon from app ${res.url}`)
|
||||
}
|
||||
}
|
||||
return res
|
||||
} catch (error) {
|
||||
console.error(`It was not possible to fetch app from ${res.url}: ${error.message}`)
|
||||
return res
|
||||
}
|
||||
return res
|
||||
} catch (error) {
|
||||
console.error(`It was not possible to fetch app from ${res.url}: ${error.message}`)
|
||||
return res
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const getIpfsLinkFromEns = memoize(
|
||||
async (name: string): Promise<string | undefined> => {
|
||||
try {
|
||||
const content = await getContentFromENS(name)
|
||||
if (content && content.protocolType === 'ipfs') {
|
||||
return `${process.env.REACT_APP_IPFS_GATEWAY}/${content.decoded}/`
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const uniqueApp = (appList: SafeApp[]) => (url: string): string | undefined => {
|
||||
const exists = appList.some((a) => {
|
||||
try {
|
||||
const currentUrl = new URL(a.url)
|
||||
const newUrl = new URL(url)
|
||||
return currentUrl.href === newUrl.href
|
||||
} catch (error) {
|
||||
console.error('There was a problem trying to validate the URL existence.', error.message)
|
||||
return false
|
||||
}
|
||||
})
|
||||
return exists ? 'This app is already registered.' : undefined
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import SettingsTxIcon from './assets/settings.svg'
|
|||
|
||||
import CustomIconText from 'src/components/CustomIconText'
|
||||
import { getAppInfoFromOrigin, getAppInfoFromUrl } from 'src/routes/safe/components/Apps/utils'
|
||||
import { SafeApp } from 'src/routes/safe/components/Apps/types'
|
||||
|
||||
const typeToIcon = {
|
||||
outgoing: OutgoingTxIcon,
|
||||
|
@ -33,20 +34,28 @@ const typeToLabel = {
|
|||
upgrade: 'Contract Upgrade',
|
||||
}
|
||||
|
||||
const TxType = ({ origin, txType }: any) => {
|
||||
interface TxTypeProps {
|
||||
origin?: string
|
||||
txType: keyof typeof typeToLabel
|
||||
}
|
||||
|
||||
const TxType = ({ origin, txType }: TxTypeProps): React.ReactElement => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [appInfo, setAppInfo] = useState<any>()
|
||||
const [appInfo, setAppInfo] = useState<SafeApp>()
|
||||
const [forceCustom, setForceCustom] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const getAppInfo = async () => {
|
||||
const parsedOrigin = getAppInfoFromOrigin(origin)
|
||||
|
||||
if (!parsedOrigin) {
|
||||
setForceCustom(true)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const appInfo = await getAppInfoFromUrl(parsedOrigin.url)
|
||||
|
||||
setAppInfo(appInfo)
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
import debounce from 'lodash.debounce'
|
||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
|
||||
/*
|
||||
This code snippet is copied from https://github.com/gnbaron/use-lodash-debounce
|
||||
with the sole intention to be able to tweak it if is needed and prevent from having
|
||||
a new dependency for something relatively trivial
|
||||
*/
|
||||
|
||||
interface DebounceOptions {
|
||||
leading: boolean
|
||||
maxWait: number
|
||||
trailing: boolean
|
||||
}
|
||||
|
||||
export const useDebouncedCallback = <T extends (...args: unknown[]) => unknown>(
|
||||
callback: T,
|
||||
delay = 0,
|
||||
options: DebounceOptions,
|
||||
): T & { cancel: () => void } => useCallback(debounce(callback, delay, options), [callback, delay, options])
|
||||
|
||||
export const useDebounce = <T extends unknown>(value: T, delay = 0, options?: DebounceOptions): T => {
|
||||
const previousValue = useRef(value)
|
||||
const [current, setCurrent] = useState(value)
|
||||
const debouncedCallback = useDebouncedCallback((value: T) => setCurrent(value), delay, options)
|
||||
|
||||
useEffect(() => {
|
||||
// does trigger the debounce timer initially
|
||||
if (value !== previousValue.current) {
|
||||
debouncedCallback(value)
|
||||
previousValue.current = value
|
||||
// cancel the debounced callback on clean up
|
||||
return debouncedCallback.cancel
|
||||
}
|
||||
}, [debouncedCallback, value])
|
||||
|
||||
return current
|
||||
}
|
|
@ -10933,6 +10933,11 @@ lodash._reinterpolate@^3.0.0:
|
|||
resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
|
||||
integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=
|
||||
|
||||
lodash.debounce@^4.0.8:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
||||
integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
|
||||
|
||||
lodash.defaults@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
|
||||
|
|
Loading…
Reference in New Issue