diff --git a/package.json b/package.json index 76da2899..40f5fa81 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/routes/safe/components/Apps/AddAppForm.tsx b/src/routes/safe/components/Apps/AddAppForm.tsx deleted file mode 100644 index a422faa2..00000000 --- a/src/routes/safe/components/Apps/AddAppForm.tsx +++ /dev/null @@ -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 - closeModal: () => void - onAppAdded: (app: SafeApp) => void - setIsSubmitDisabled: (status: boolean) => void -} - -const AddAppForm = ({ appList, formId, closeModal, onAppAdded, setIsSubmitDisabled }: Props) => { - const [appInfo, setAppInfo] = useState(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 ( - - {() => ( - <> - Add custom app - - - - Token image - - - - - - - This app is not a Gnosis product and I agree to use this app
at my own risk. -

- } - name="agreed" - type="checkbox" - validate={required} - /> - - )} -
- ) -} - -export default AddAppForm diff --git a/src/routes/safe/components/Apps/AddAppForm/AppAgreement.tsx b/src/routes/safe/components/Apps/AddAppForm/AppAgreement.tsx new file mode 100644 index 00000000..cc1ba29a --- /dev/null +++ b/src/routes/safe/components/Apps/AddAppForm/AppAgreement.tsx @@ -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 ( + + This app is not a Gnosis product and I agree to use this app +
+ at my own risk. + + } + name="agreementAccepted" + type="checkbox" + validate={validate} + /> + ) +} + +export default AppAgreement diff --git a/src/routes/safe/components/Apps/AddAppForm/AppUrl.tsx b/src/routes/safe/components/Apps/AddAppForm/AppUrl.tsx new file mode 100644 index 00000000..dfd2a7fd --- /dev/null +++ b/src/routes/safe/components/Apps/AddAppForm/AppUrl.tsx @@ -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 => { + 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 ( + + ) +} + +export default AppUrl diff --git a/src/routes/safe/components/Apps/AddAppForm/SubmitButtonStatus.tsx b/src/routes/safe/components/Apps/AddAppForm/SubmitButtonStatus.tsx new file mode 100644 index 00000000..97a209f8 --- /dev/null +++ b/src/routes/safe/components/Apps/AddAppForm/SubmitButtonStatus.tsx @@ -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 diff --git a/src/routes/safe/components/Apps/AddAppForm/index.tsx b/src/routes/safe/components/Apps/AddAppForm/index.tsx new file mode 100644 index 00000000..cfcbdf6f --- /dev/null +++ b/src/routes/safe/components/Apps/AddAppForm/index.tsx @@ -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(APP_INFO) + + const handleSubmit = () => { + closeModal() + onAppAdded(appInfo) + } + + return ( + + {() => ( + <> + Add custom app + + + + + + Token image + {}} /> + + + + + + + )} + + ) +} + +export default AddApp diff --git a/src/routes/safe/components/Apps/ManageApps.tsx b/src/routes/safe/components/Apps/ManageApps.tsx index b8928516..404d2e12 100644 --- a/src/routes/safe/components/Apps/ManageApps.tsx +++ b/src/routes/safe/components/Apps/ManageApps.tsx @@ -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 - 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 = ( + + ) return ( <> - + Manage Apps - + {isOpen && ( - } + formBody={Form} isSubmitFormDisabled={isSubmitDisabled} itemList={getItemList()} onClose={closeModal} diff --git a/src/routes/safe/components/Apps/index.tsx b/src/routes/safe/components/Apps/index.tsx index 456ea9b5..afea1955 100644 --- a/src/routes/safe/components/Apps/index.tsx +++ b/src/routes/safe/components/Apps/index.tsx @@ -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}`) } diff --git a/src/routes/safe/components/Apps/types.d.ts b/src/routes/safe/components/Apps/types.d.ts index 8a8ead9b..ab285046 100644 --- a/src/routes/safe/components/Apps/types.d.ts +++ b/src/routes/safe/components/Apps/types.d.ts @@ -5,6 +5,7 @@ export type SafeApp = { iconUrl: string disabled?: boolean error: boolean + description: string } export type StoredSafeApp = { diff --git a/src/routes/safe/components/Apps/utils.ts b/src/routes/safe/components/Apps/utils.ts index ccf77cdb..37233d21 100644 --- a/src/routes/safe/components/Apps/utils.ts +++ b/src/routes/safe/components/Apps/utils.ts @@ -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 | null => { try { return JSON.parse(origin) } catch (error) { @@ -40,49 +43,91 @@ export const getAppInfoFromOrigin = (origin) => { } } -export const getAppInfoFromUrl = async (appUrl?: string): Promise => { - 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 => { + 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 => { + 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 } diff --git a/src/routes/safe/components/Transactions/TxsTable/TxType/index.tsx b/src/routes/safe/components/Transactions/TxsTable/TxType/index.tsx index 487f628a..1bb36781 100644 --- a/src/routes/safe/components/Transactions/TxsTable/TxType/index.tsx +++ b/src/routes/safe/components/Transactions/TxsTable/TxType/index.tsx @@ -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() + const [appInfo, setAppInfo] = useState() 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) } diff --git a/src/routes/safe/container/hooks/useDebounce.tsx b/src/routes/safe/container/hooks/useDebounce.tsx new file mode 100644 index 00000000..e9abbb43 --- /dev/null +++ b/src/routes/safe/container/hooks/useDebounce.tsx @@ -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 = unknown>( + callback: T, + delay = 0, + options: DebounceOptions, +): T & { cancel: () => void } => useCallback(debounce(callback, delay, options), [callback, delay, options]) + +export const useDebounce = (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 +} diff --git a/yarn.lock b/yarn.lock index cc015c98..5228fdda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"