SafeApps new layout (#1600)
* speed-up load time for safe-apps * show loading status until all the apps are loaded * Add App route * Add cards for each safe-app * move logic to AppFrame * add basic skeleton styles * css fixes * fix image sizes * add transition animation to skeleton * remove duplicated code * Modals * refactor skeleton * refactor layout using flexbox * make content clickable in cards * cards container as css-grid * remove fixed width for cards * replace auto by 1fr * remove margin for cards * add card component and remove some css * rename buttons * add margin to app list * refactor useAppList * fix disclaimer always flashing * fix pointer cursor for Add app icon * add styled component keyframe * update safe-react-components * fix margin and card height, icon size * fix margin in iconImg and app name * fix margins on apps container (breadcrumb area) * remove style comment * fix margin on HelpCenter / remove overflow in sidebar * Improve featuresEnabled for sidebar and apps page. Co-authored-by: Agustín Longoni <agustin.longoni@altoros.com> Co-authored-by: Fernando <fernando.greco@gmail.com> Co-authored-by: Daniel Sanchez <daniel.sanchez@gnosis.pm>
This commit is contained in:
parent
0ecd70fdbc
commit
ba680f7158
|
@ -169,7 +169,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gnosis.pm/safe-apps-sdk": "https://github.com/gnosis/safe-apps-sdk.git#3f0689f",
|
"@gnosis.pm/safe-apps-sdk": "https://github.com/gnosis/safe-apps-sdk.git#3f0689f",
|
||||||
"@gnosis.pm/safe-contracts": "1.1.1-dev.2",
|
"@gnosis.pm/safe-contracts": "1.1.1-dev.2",
|
||||||
"@gnosis.pm/safe-react-components": "https://github.com/gnosis/safe-react-components.git#03ff672d6f73366297986d58631f9582fe2ed4a3",
|
"@gnosis.pm/safe-react-components": "https://github.com/gnosis/safe-react-components.git#ff29c3c",
|
||||||
"@gnosis.pm/util-contracts": "2.0.6",
|
"@gnosis.pm/util-contracts": "2.0.6",
|
||||||
"@ledgerhq/hw-transport-node-hid-singleton": "5.30.0",
|
"@ledgerhq/hw-transport-node-hid-singleton": "5.30.0",
|
||||||
"@material-ui/core": "4.11.0",
|
"@material-ui/core": "4.11.0",
|
||||||
|
|
Before Width: | Height: | Size: 690 B After Width: | Height: | Size: 690 B |
|
@ -16,7 +16,7 @@ const HelpContainer = styled.div`
|
||||||
const HelpCenterLink = styled.a`
|
const HelpCenterLink = styled.a`
|
||||||
height: 30px;
|
height: 30px;
|
||||||
width: 166px;
|
width: 166px;
|
||||||
padding: 8px 0 8px 16px;
|
padding: 6px 0 8px 16px;
|
||||||
margin: 14px 0px;
|
margin: 14px 0px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: block;
|
display: block;
|
||||||
|
|
|
@ -19,7 +19,7 @@ const useSidebarItems = (): ListItemType[] => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return useMemo((): ListItemType[] => {
|
return useMemo((): ListItemType[] => {
|
||||||
if (!matchSafe || !matchSafeWithAddress) {
|
if (!matchSafe || !matchSafeWithAddress || !featuresEnabled) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -63,7 +63,7 @@ const useSidebarItems = (): ListItemType[] => {
|
||||||
},
|
},
|
||||||
...safeSidebar,
|
...safeSidebar,
|
||||||
]
|
]
|
||||||
}, [matchSafe, matchSafeWithAction, matchSafeWithAddress, safeAppsEnabled])
|
}, [matchSafe, matchSafeWithAction, matchSafeWithAddress, safeAppsEnabled, featuresEnabled])
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useSidebarItems }
|
export { useSidebarItems }
|
||||||
|
|
|
@ -38,7 +38,7 @@ const SidebarWrapper = styled.aside`
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
||||||
padding: 8px;
|
padding: 8px 8px 0 8px;
|
||||||
background-color: ${({ theme }) => theme.colors.white};
|
background-color: ${({ theme }) => theme.colors.white};
|
||||||
border-right: 2px solid ${({ theme }) => theme.colors.separator};
|
border-right: 2px solid ${({ theme }) => theme.colors.separator};
|
||||||
`
|
`
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
export const LoadingContainer = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
`
|
|
@ -36,17 +36,20 @@ export const safeNeedsUpdate = (currentVersion?: string, latestVersion?: string)
|
||||||
export const getCurrentSafeVersion = (gnosisSafeInstance: GnosisSafe): Promise<string> =>
|
export const getCurrentSafeVersion = (gnosisSafeInstance: GnosisSafe): Promise<string> =>
|
||||||
gnosisSafeInstance.methods.VERSION().call()
|
gnosisSafeInstance.methods.VERSION().call()
|
||||||
|
|
||||||
const checkFeatureEnabledByVersion = (featureConfig: FeatureConfigByVersion, version: string) => {
|
const checkFeatureEnabledByVersion = (featureConfig: FeatureConfigByVersion, version?: string) => {
|
||||||
|
if (!version) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return featureConfig.validVersion ? semverSatisfies(version, featureConfig.validVersion) : true
|
return featureConfig.validVersion ? semverSatisfies(version, featureConfig.validVersion) : true
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enabledFeatures = (version?: string): FEATURES[] => {
|
export const enabledFeatures = (version?: string): FEATURES[] => {
|
||||||
return FEATURES_BY_VERSION.reduce((acc: FEATURES[], feature: Feature) => {
|
return FEATURES_BY_VERSION.reduce((acc, feature: Feature) => {
|
||||||
if (isFeatureEnabled(feature.name) && version && checkFeatureEnabledByVersion(feature, version)) {
|
if (isFeatureEnabled(feature.name) && checkFeatureEnabledByVersion(feature, version)) {
|
||||||
acc.push(feature.name)
|
acc.push(feature.name)
|
||||||
}
|
}
|
||||||
return acc
|
return acc
|
||||||
}, [])
|
}, [] as FEATURES[])
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SafeVersionInfo {
|
interface SafeVersionInfo {
|
||||||
|
|
|
@ -1,27 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import { useFormState } from 'react-final-form'
|
|
||||||
|
|
||||||
import { SafeApp } from 'src/routes/safe/components/Apps/types.d'
|
|
||||||
import { isAppManifestValid } from 'src/routes/safe/components/Apps/utils'
|
|
||||||
|
|
||||||
interface SubmitButtonStatusProps {
|
|
||||||
appInfo: SafeApp
|
|
||||||
onSubmitButtonStatusChange: (disabled: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const SubmitButtonStatus = ({ appInfo, onSubmitButtonStatusChange }: SubmitButtonStatusProps): null => {
|
|
||||||
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,31 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="102" height="92" viewBox="0 0 102 92">
|
||||||
|
<defs>
|
||||||
|
<path id="611coc912a" d="M0.033 0L92.033 0 92.033 92 0.033 92z"/>
|
||||||
|
<path id="vvw5qne11c" d="M0 0.355L21.594 0.355 21.594 21.949 0 21.949z"/>
|
||||||
|
</defs>
|
||||||
|
<g fill="none" fill-rule="evenodd">
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<g transform="translate(-286 -140) translate(286 140) translate(6)">
|
||||||
|
<mask id="vl08viacbb" fill="#fff">
|
||||||
|
<use xlink:href="#611coc912a"/>
|
||||||
|
</mask>
|
||||||
|
<path fill="#F7F5F5" d="M46.033 0c25.404 0 46 20.595 46 46 0 25.404-20.596 46-46 46-25.405 0-46-20.596-46-46 0-25.405 20.595-46 46-46" mask="url(#vl08viacbb)"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#B2B5B2" d="M14.613 32.974h-7.59c-3.867 0-7 3.134-7 7v7.591c0 3.865 3.133 7 7 7h7.59c3.866 0 7-3.135 7-7v-7.59c0-3.867-3.134-7-7-7m0 4c1.654 0 3 1.345 3 3v7.59c0 1.653-1.346 3-3 3h-7.59c-1.655 0-3-1.347-3-3v-7.59c0-1.655 1.345-3 3-3h7.59M37.201 32.974h-.04c-5.95 0-10.775 4.824-10.775 10.774v.041c0 5.952 4.824 10.776 10.774 10.776h.041c5.951 0 10.775-4.824 10.775-10.776v-.04c0-5.95-4.824-10.775-10.775-10.775m0 4c3.735 0 6.775 3.04 6.775 6.815 0 3.736-3.04 6.776-6.816 6.776-3.735 0-6.774-3.04-6.774-6.817 0-3.735 3.04-6.774 6.774-6.774h.041M41.002 59.363H33.41c-3.866 0-7 3.134-7 7v7.59c0 3.867 3.134 7 7 7H41c3.867 0 7-3.133 7-7v-7.59c0-3.866-3.133-7-7-7m0 4c1.655 0 3 1.346 3 3v7.59c0 1.654-1.345 3-3 3h-7.59c-1.654 0-3-1.346-3-3v-7.59c0-1.654 1.346-3 3-3H41M20.924 80.273l-.006.006c-.894.894-2.357.894-3.252 0L.67 63.284c-.894-.895-.894-2.358 0-3.252l.006-.006c.894-.895 2.357-.895 3.252 0L20.924 77.02c.895.895.895 2.357 0 3.252" transform="translate(-286 -140) translate(286 140)"/>
|
||||||
|
<g transform="translate(-286 -140) translate(286 140) translate(0 59)">
|
||||||
|
<mask id="i3d0m5zbyd" fill="#fff">
|
||||||
|
<use xlink:href="#vvw5qne11c"/>
|
||||||
|
</mask>
|
||||||
|
<path fill="#B2B5B2" d="M.67 21.273l.007.006c.894.894 2.357.894 3.252 0L20.924 4.284c.894-.895.894-2.358 0-3.252l-.006-.006c-.894-.895-2.357-.895-3.252 0L.67 18.02c-.895.895-.895 2.357 0 3.252" mask="url(#i3d0m5zbyd)"/>
|
||||||
|
</g>
|
||||||
|
<path stroke="#008C73" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M12.031 15.984L36.031 15.984M24.031 27.984L24.031 3.984" transform="translate(-286 -140) translate(286 140)"/>
|
||||||
|
<path fill="#F7F5F5" d="M48.719 67.994c-3.136 0-5.687-2.552-5.687-5.687V25.68c0-3.135 2.55-5.687 5.687-5.687h44.625c3.136 0 5.688 2.552 5.688 5.687v36.626c0 3.135-2.552 5.687-5.688 5.687H48.719z" transform="translate(-286 -140) translate(286 140)"/>
|
||||||
|
<path fill="#B2B5B2" d="M93.344 17.994H48.719c-4.246 0-7.688 3.44-7.688 7.687v36.626c0 4.246 3.442 7.687 7.688 7.687h44.625c4.245 0 7.687-3.441 7.687-7.687V25.68c0-4.246-3.442-7.687-7.687-7.687m0 4c2.033 0 3.688 1.654 3.688 3.687v36.626c0 2.033-1.655 3.687-3.688 3.687H48.719c-2.034 0-3.688-1.654-3.688-3.687V25.68c0-2.033 1.654-3.687 3.688-3.687h44.625" transform="translate(-286 -140) translate(286 140)"/>
|
||||||
|
<path stroke="#B2B5B2" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M44.566 33.978L97.566 33.978" transform="translate(-286 -140) translate(286 140)"/>
|
||||||
|
<path fill="#B2B5B2" d="M52.032 26.977c0 1.104-.896 2-2 2s-2-.896-2-2 .896-2 2-2 2 .896 2 2M59.007 26.977c0 1.104-.896 2-2 2s-2-.896-2-2 .896-2 2-2 2 .896 2 2M66.024 26.977c0 1.104-.896 2-2 2s-2-.896-2-2 .896-2 2-2 2 .896 2 2" transform="translate(-286 -140) translate(286 140)"/>
|
||||||
|
<path stroke="#B2B5B2" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M66.033 59.977L57.033 50.977 66.033 41.977M76.016 59.977L85.016 50.977 76.016 41.977" transform="translate(-286 -140) translate(286 140)"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.9 KiB |
|
@ -0,0 +1,51 @@
|
||||||
|
import { Button, Divider } from '@gnosis.pm/safe-react-components'
|
||||||
|
import React, { ReactElement, useMemo } from 'react'
|
||||||
|
import { useFormState } from 'react-final-form'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
import GnoButton from 'src/components/layout/Button'
|
||||||
|
import { SafeApp } from 'src/routes/safe/components/Apps/types.d'
|
||||||
|
import { isAppManifestValid } from 'src/routes/safe/components/Apps/utils'
|
||||||
|
|
||||||
|
const StyledDivider = styled(Divider)`
|
||||||
|
margin: 16px -24px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ButtonsContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
`
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
appInfo: SafeApp
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormButtons = ({ appInfo, onCancel }: Props): ReactElement => {
|
||||||
|
const { valid, validating, visited } = useFormState({
|
||||||
|
subscription: { valid: true, validating: true, visited: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSubmitDisabled = useMemo(() => {
|
||||||
|
// if non visited, fields were not evaluated yet. Then, the default value is considered invalid
|
||||||
|
const fieldsVisited = visited?.agreementAccepted && visited?.appUrl
|
||||||
|
|
||||||
|
return validating || !valid || !fieldsVisited || !isAppManifestValid(appInfo)
|
||||||
|
}, [validating, valid, visited, appInfo])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StyledDivider />
|
||||||
|
<ButtonsContainer>
|
||||||
|
<Button size="md" onClick={onCancel} color="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<GnoButton color="primary" variant="contained" type="submit" disabled={isSubmitDisabled}>
|
||||||
|
Add
|
||||||
|
</GnoButton>
|
||||||
|
</ButtonsContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormButtons
|
|
@ -1,19 +1,20 @@
|
||||||
import { Text, TextField } from '@gnosis.pm/safe-react-components'
|
import { TextField } from '@gnosis.pm/safe-react-components'
|
||||||
import React from 'react'
|
import React, { useState, ReactElement } from 'react'
|
||||||
import styled from 'styled-components'
|
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.d'
|
import { SafeApp } from 'src/routes/safe/components/Apps/types.d'
|
||||||
import GnoForm from 'src/components/forms/GnoForm'
|
import GnoForm from 'src/components/forms/GnoForm'
|
||||||
import Img from 'src/components/layout/Img'
|
import Img from 'src/components/layout/Img'
|
||||||
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
|
|
||||||
|
|
||||||
const StyledText = styled(Text)`
|
import AppAgreement from './AppAgreement'
|
||||||
margin-bottom: 19px;
|
import AppUrl, { AppInfoUpdater, appUrlResolver } from './AppUrl'
|
||||||
`
|
import FormButtons from './FormButtons'
|
||||||
|
import { APPS_STORAGE_KEY, getEmptySafeApp } from 'src/routes/safe/components/Apps/utils'
|
||||||
|
import { saveToStorage } from 'src/utils/storage'
|
||||||
|
import { SAFELIST_ADDRESS } from 'src/routes/routes'
|
||||||
|
import { useHistory, useRouteMatch } from 'react-router-dom'
|
||||||
|
|
||||||
|
const FORM_ID = 'add-apps-form'
|
||||||
|
|
||||||
const StyledTextFileAppName = styled(TextField)`
|
const StyledTextFileAppName = styled(TextField)`
|
||||||
&& {
|
&& {
|
||||||
|
@ -39,38 +40,34 @@ const INITIAL_VALUES: AddAppFormValues = {
|
||||||
agreementAccepted: false,
|
agreementAccepted: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
const APP_INFO: SafeApp = {
|
const APP_INFO = getEmptySafeApp()
|
||||||
id: '',
|
|
||||||
url: '',
|
|
||||||
name: '',
|
|
||||||
iconUrl: appsIconSvg,
|
|
||||||
error: false,
|
|
||||||
description: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AddAppProps {
|
interface AddAppProps {
|
||||||
appList: SafeApp[]
|
appList: SafeApp[]
|
||||||
closeModal: () => void
|
closeModal: () => void
|
||||||
formId: string
|
|
||||||
onAppAdded: (app: SafeApp) => void
|
|
||||||
setIsSubmitDisabled: (disabled: boolean) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddApp = ({ appList, closeModal, formId, onAppAdded, setIsSubmitDisabled }: AddAppProps): React.ReactElement => {
|
const AddApp = ({ appList, closeModal }: AddAppProps): ReactElement => {
|
||||||
const [appInfo, setAppInfo] = React.useState<SafeApp>(APP_INFO)
|
const [appInfo, setAppInfo] = useState<SafeApp>(APP_INFO)
|
||||||
|
const history = useHistory()
|
||||||
|
const matchSafeWithAddress = useRouteMatch<{ safeAddress: string }>({ path: `${SAFELIST_ADDRESS}/:safeAddress` })
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
closeModal()
|
const newAppList = [
|
||||||
onAppAdded(appInfo)
|
{ url: appInfo.url, disabled: false },
|
||||||
|
...appList.map(({ url, disabled }) => ({ url, disabled })),
|
||||||
|
]
|
||||||
|
saveToStorage(APPS_STORAGE_KEY, newAppList)
|
||||||
|
const goToApp = `${matchSafeWithAddress?.url}/apps?appUrl=${encodeURI(appInfo.url)}`
|
||||||
|
history.push(goToApp)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GnoForm decorators={[appUrlResolver]} initialValues={INITIAL_VALUES} onSubmit={handleSubmit} testId={formId}>
|
<GnoForm decorators={[appUrlResolver]} initialValues={INITIAL_VALUES} onSubmit={handleSubmit} testId={FORM_ID}>
|
||||||
{() => (
|
{() => (
|
||||||
<>
|
<>
|
||||||
<StyledText size="xl">Add custom app</StyledText>
|
|
||||||
|
|
||||||
<AppUrl appList={appList} />
|
<AppUrl appList={appList} />
|
||||||
|
|
||||||
{/* Fetch app from url and return a SafeApp */}
|
{/* Fetch app from url and return a SafeApp */}
|
||||||
<AppInfoUpdater onAppInfo={setAppInfo} />
|
<AppInfoUpdater onAppInfo={setAppInfo} />
|
||||||
|
|
||||||
|
@ -81,7 +78,7 @@ const AddApp = ({ appList, closeModal, formId, onAppAdded, setIsSubmitDisabled }
|
||||||
|
|
||||||
<AppAgreement />
|
<AppAgreement />
|
||||||
|
|
||||||
<SubmitButtonStatus onSubmitButtonStatusChange={setIsSubmitDisabled} appInfo={appInfo} />
|
<FormButtons appInfo={appInfo} onCancel={closeModal} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</GnoForm>
|
</GnoForm>
|
|
@ -0,0 +1,25 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import AppCard from './index'
|
||||||
|
|
||||||
|
import AddAppIcon from 'src/routes/safe/components/Apps/assets/addApp.svg'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Apps/AppCard',
|
||||||
|
component: AppCard,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Loading = (): React.ReactElement => <AppCard isLoading />
|
||||||
|
|
||||||
|
export const AddCustomApp = (): React.ReactElement => (
|
||||||
|
<AppCard iconUrl={AddAppIcon} onClick={console.log} buttonText="Add custom app" />
|
||||||
|
)
|
||||||
|
|
||||||
|
export const LoadedApp = (): React.ReactElement => (
|
||||||
|
<AppCard
|
||||||
|
iconUrl="https://cryptologos.cc/logos/versions/gnosis-gno-gno-logo-circle.svg?v=007"
|
||||||
|
name="Gnosis"
|
||||||
|
description="Gnosis safe app"
|
||||||
|
onClick={console.log}
|
||||||
|
/>
|
||||||
|
)
|
|
@ -0,0 +1,107 @@
|
||||||
|
import React, { SyntheticEvent } from 'react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
import { fade } from '@material-ui/core/styles/colorManipulator'
|
||||||
|
import { Title, Text, Button, Card } from '@gnosis.pm/safe-react-components'
|
||||||
|
|
||||||
|
import appsIconSvg from 'src/assets/icons/apps.svg'
|
||||||
|
import { AppIconSK, DescriptionSK, TitleSK } from './skeleton'
|
||||||
|
|
||||||
|
const StyledAppCard = styled(Card)`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
box-shadow: 1px 2px 10px 0 ${({ theme }) => fade(theme.colors.shadow.color, 0.18)};
|
||||||
|
height: 232px !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
:hover {
|
||||||
|
box-shadow: 1px 2px 16px 0 ${({ theme }) => fade(theme.colors.shadow.color, 0.35)};
|
||||||
|
transition: box-shadow 0.3s ease-in-out;
|
||||||
|
background-color: ${({ theme }) => theme.colors.background};
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
color: ${({ theme }) => theme.colors.primary};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const IconImg = styled.img<{ size: 'md' | 'lg'; src: string | undefined }>`
|
||||||
|
width: ${({ size }) => (size === 'md' ? '60px' : '102px')};
|
||||||
|
height: ${({ size }) => (size === 'md' ? '60px' : '92px')};
|
||||||
|
margin-top: ${({ size }) => (size === 'md' ? '0' : '-16px')};
|
||||||
|
object-fit: contain;
|
||||||
|
`
|
||||||
|
|
||||||
|
const AppName = styled(Title)`
|
||||||
|
text-align: center;
|
||||||
|
margin: 16px 0 9px 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
const AppDescription = styled(Text)`
|
||||||
|
height: 71px;
|
||||||
|
text-align: center;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const setAppImageFallback = (error: SyntheticEvent<HTMLImageElement, Event>): void => {
|
||||||
|
error.currentTarget.onerror = null
|
||||||
|
error.currentTarget.src = appsIconSvg
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TriggerType {
|
||||||
|
Button,
|
||||||
|
Content,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClick?: () => void
|
||||||
|
isLoading?: boolean
|
||||||
|
className?: string
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
iconUrl?: string
|
||||||
|
iconSize?: 'md' | 'lg'
|
||||||
|
buttonText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppCard = ({
|
||||||
|
isLoading = false,
|
||||||
|
className,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
iconUrl,
|
||||||
|
iconSize = 'md',
|
||||||
|
buttonText,
|
||||||
|
onClick = () => undefined,
|
||||||
|
}: Props): React.ReactElement => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<StyledAppCard className={className}>
|
||||||
|
<AppIconSK />
|
||||||
|
<TitleSK />
|
||||||
|
<DescriptionSK />
|
||||||
|
<DescriptionSK />
|
||||||
|
</StyledAppCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledAppCard className={className} onClick={onClick}>
|
||||||
|
<IconImg alt={`${name || 'App'} Logo`} src={iconUrl} onError={setAppImageFallback} size={iconSize} />
|
||||||
|
|
||||||
|
{name && <AppName size="xs">{name}</AppName>}
|
||||||
|
|
||||||
|
{description && <AppDescription size="lg">{description} </AppDescription>}
|
||||||
|
|
||||||
|
{buttonText && (
|
||||||
|
<Button size="md" color="primary" variant="contained" onClick={onClick}>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</StyledAppCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppCard
|
|
@ -0,0 +1,41 @@
|
||||||
|
import styled, { keyframes } from 'styled-components'
|
||||||
|
|
||||||
|
const gradientSK = keyframes`
|
||||||
|
0% {
|
||||||
|
background-position: 0% 54%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 47%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 54%;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const AppIconSK = styled.div`
|
||||||
|
height: 60px;
|
||||||
|
width: 60px;
|
||||||
|
border-radius: 30px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: lightgrey;
|
||||||
|
background: linear-gradient(84deg, lightgrey, transparent);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
animation: ${gradientSK} 1.5s ease infinite;
|
||||||
|
`
|
||||||
|
export const TitleSK = styled.div`
|
||||||
|
height: 24px;
|
||||||
|
width: 160px;
|
||||||
|
margin: 24px auto;
|
||||||
|
background-color: lightgrey;
|
||||||
|
background: linear-gradient(84deg, lightgrey, transparent);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
animation: ${gradientSK} 1.5s ease infinite;
|
||||||
|
`
|
||||||
|
export const DescriptionSK = styled.div`
|
||||||
|
height: 16px;
|
||||||
|
width: 200px;
|
||||||
|
background-color: lightgrey;
|
||||||
|
background: linear-gradient(84deg, lightgrey, transparent);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
animation: ${gradientSK} 1.5s ease infinite;
|
||||||
|
`
|
|
@ -1,92 +1,289 @@
|
||||||
import React, { forwardRef } from 'react'
|
import React, { useState, useRef, useCallback, useEffect } from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
import { FixedIcon, Loader, Title } from '@gnosis.pm/safe-react-components'
|
import {
|
||||||
import { useHistory } from 'react-router-dom'
|
FixedIcon,
|
||||||
|
Loader,
|
||||||
|
Title,
|
||||||
|
Text,
|
||||||
|
Card,
|
||||||
|
GenericModal,
|
||||||
|
ModalFooterConfirmation,
|
||||||
|
Menu,
|
||||||
|
ButtonLink,
|
||||||
|
} from '@gnosis.pm/safe-react-components'
|
||||||
|
import { useHistory, useRouteMatch } from 'react-router-dom'
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
import {
|
||||||
|
INTERFACE_MESSAGES,
|
||||||
|
Transaction,
|
||||||
|
RequestId,
|
||||||
|
LowercaseNetworks,
|
||||||
|
SendTransactionParams,
|
||||||
|
} from '@gnosis.pm/safe-apps-sdk'
|
||||||
|
|
||||||
|
import {
|
||||||
|
safeEthBalanceSelector,
|
||||||
|
safeParamAddressFromStateSelector,
|
||||||
|
safeNameSelector,
|
||||||
|
} from 'src/logic/safe/store/selectors'
|
||||||
|
import { grantedSelector } from 'src/routes/safe/container/selector'
|
||||||
|
import { getNetworkName } from 'src/config'
|
||||||
import { SAFELIST_ADDRESS } from 'src/routes/routes'
|
import { SAFELIST_ADDRESS } from 'src/routes/routes'
|
||||||
|
import { isSameURL } from 'src/utils/url'
|
||||||
|
import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics'
|
||||||
|
import { loadFromStorage, saveToStorage } from 'src/utils/storage'
|
||||||
|
import { staticAppsList } from 'src/routes/safe/components/Apps/utils'
|
||||||
|
|
||||||
|
import ConfirmTransactionModal from '../components/ConfirmTransactionModal'
|
||||||
|
import { useIframeMessageHandler } from '../hooks/useIframeMessageHandler'
|
||||||
import { useLegalConsent } from '../hooks/useLegalConsent'
|
import { useLegalConsent } from '../hooks/useLegalConsent'
|
||||||
import { SafeApp } from '../types'
|
|
||||||
import LegalDisclaimer from './LegalDisclaimer'
|
import LegalDisclaimer from './LegalDisclaimer'
|
||||||
|
import { APPS_STORAGE_KEY, getAppInfoFromUrl } from '../utils'
|
||||||
|
import { SafeApp, StoredSafeApp } from '../types.d'
|
||||||
|
import { LoadingContainer } from 'src/components/LoaderContainer'
|
||||||
|
|
||||||
const StyledIframe = styled.iframe`
|
const OwnerDisclaimer = styled.div`
|
||||||
padding: 15px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
`
|
|
||||||
|
|
||||||
const LoadingContainer = styled.div`
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
`
|
|
||||||
|
|
||||||
const IframeWrapper = styled.div`
|
|
||||||
position: relative;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
`
|
|
||||||
|
|
||||||
const Centered = styled.div`
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
height: 476px;
|
||||||
`
|
`
|
||||||
|
|
||||||
type AppFrameProps = {
|
const AppWrapper = styled.div`
|
||||||
selectedApp: SafeApp | undefined
|
display: flex;
|
||||||
safeAddress: string
|
flex-direction: column;
|
||||||
network: string
|
height: 100%;
|
||||||
granted: boolean
|
`
|
||||||
appIsLoading: boolean
|
|
||||||
onIframeLoad: () => void
|
const StyledCard = styled(Card)`
|
||||||
|
flex-grow: 1;
|
||||||
|
`
|
||||||
|
|
||||||
|
const StyledIframe = styled.iframe`
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
`
|
||||||
|
|
||||||
|
const Breadcrumb = styled.div`
|
||||||
|
height: 51px;
|
||||||
|
`
|
||||||
|
|
||||||
|
type ConfirmTransactionModalState = {
|
||||||
|
isOpen: boolean
|
||||||
|
txs: Transaction[]
|
||||||
|
requestId?: RequestId
|
||||||
|
params?: SendTransactionParams
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppFrame = forwardRef<HTMLIFrameElement, AppFrameProps>(function AppFrameComponent(
|
type Props = {
|
||||||
{ selectedApp, safeAddress, network, appIsLoading, granted, onIframeLoad },
|
appUrl: string
|
||||||
iframeRef,
|
}
|
||||||
): React.ReactElement {
|
|
||||||
|
const NETWORK_NAME = getNetworkName()
|
||||||
|
|
||||||
|
const INITIAL_CONFIRM_TX_MODAL_STATE: ConfirmTransactionModalState = {
|
||||||
|
isOpen: false,
|
||||||
|
txs: [],
|
||||||
|
requestId: undefined,
|
||||||
|
params: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppFrame = ({ appUrl }: Props): React.ReactElement => {
|
||||||
|
const granted = useSelector(grantedSelector)
|
||||||
|
const safeAddress = useSelector(safeParamAddressFromStateSelector)
|
||||||
|
const ethBalance = useSelector(safeEthBalanceSelector)
|
||||||
|
const safeName = useSelector(safeNameSelector)
|
||||||
|
const { trackEvent } = useAnalytics()
|
||||||
const history = useHistory()
|
const history = useHistory()
|
||||||
const { consentReceived, onConsentReceipt } = useLegalConsent()
|
const { consentReceived, onConsentReceipt } = useLegalConsent()
|
||||||
|
|
||||||
|
const matchSafeWithAddress = useRouteMatch<{ safeAddress: string }>({ path: `${SAFELIST_ADDRESS}/:safeAddress` })
|
||||||
|
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||||
|
const [confirmTransactionModal, setConfirmTransactionModal] = useState<ConfirmTransactionModalState>(
|
||||||
|
INITIAL_CONFIRM_TX_MODAL_STATE,
|
||||||
|
)
|
||||||
|
const [appIsLoading, setAppIsLoading] = useState<boolean>(true)
|
||||||
|
const [safeApp, setSafeApp] = useState<SafeApp | undefined>()
|
||||||
|
const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false)
|
||||||
|
const [isAppDeletable, setIsAppDeletable] = useState<boolean | undefined>()
|
||||||
|
|
||||||
const redirectToBalance = () => history.push(`${SAFELIST_ADDRESS}/${safeAddress}/balances`)
|
const redirectToBalance = () => history.push(`${SAFELIST_ADDRESS}/${safeAddress}/balances`)
|
||||||
|
|
||||||
if (!selectedApp) {
|
const openConfirmationModal = useCallback(
|
||||||
return <div />
|
(txs: Transaction[], params: SendTransactionParams | undefined, requestId: RequestId) =>
|
||||||
|
setConfirmTransactionModal({
|
||||||
|
isOpen: true,
|
||||||
|
txs,
|
||||||
|
requestId,
|
||||||
|
params,
|
||||||
|
}),
|
||||||
|
[setConfirmTransactionModal],
|
||||||
|
)
|
||||||
|
const closeConfirmationModal = useCallback(() => setConfirmTransactionModal(INITIAL_CONFIRM_TX_MODAL_STATE), [
|
||||||
|
setConfirmTransactionModal,
|
||||||
|
])
|
||||||
|
|
||||||
|
const { sendMessageToIframe } = useIframeMessageHandler(
|
||||||
|
safeApp,
|
||||||
|
openConfirmationModal,
|
||||||
|
closeConfirmationModal,
|
||||||
|
iframeRef,
|
||||||
|
)
|
||||||
|
|
||||||
|
const onIframeLoad = useCallback(() => {
|
||||||
|
const iframe = iframeRef.current
|
||||||
|
if (!iframe || !isSameURL(iframe.src, appUrl as string)) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!consentReceived) {
|
setAppIsLoading(false)
|
||||||
|
sendMessageToIframe({
|
||||||
|
messageId: INTERFACE_MESSAGES.ON_SAFE_INFO,
|
||||||
|
data: {
|
||||||
|
safeAddress: safeAddress as string,
|
||||||
|
network: NETWORK_NAME.toLowerCase() as LowercaseNetworks,
|
||||||
|
ethBalance: ethBalance as string,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [ethBalance, safeAddress, appUrl, sendMessageToIframe])
|
||||||
|
|
||||||
|
const onUserTxConfirm = (safeTxHash: string) => {
|
||||||
|
sendMessageToIframe(
|
||||||
|
{ messageId: INTERFACE_MESSAGES.TRANSACTION_CONFIRMED, data: { safeTxHash } },
|
||||||
|
confirmTransactionModal.requestId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTxReject = () => {
|
||||||
|
sendMessageToIframe(
|
||||||
|
{ messageId: INTERFACE_MESSAGES.TRANSACTION_REJECTED, data: {} },
|
||||||
|
confirmTransactionModal.requestId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openRemoveModal = () => setIsRemoveModalOpen(true)
|
||||||
|
|
||||||
|
const closeRemoveModal = () => setIsRemoveModalOpen(false)
|
||||||
|
|
||||||
|
const removeApp = async () => {
|
||||||
|
const persistedAppList = (await loadFromStorage<StoredSafeApp[]>(APPS_STORAGE_KEY)) || []
|
||||||
|
const filteredList = persistedAppList.filter((a) => a.url !== safeApp?.url)
|
||||||
|
saveToStorage(APPS_STORAGE_KEY, filteredList)
|
||||||
|
|
||||||
|
const goToApp = `${matchSafeWithAddress?.url}/apps`
|
||||||
|
history.push(goToApp)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadApp = async () => {
|
||||||
|
const app = await getAppInfoFromUrl(appUrl)
|
||||||
|
|
||||||
|
const existsStaticApp = staticAppsList.some((staticApp) => staticApp.url === app.url)
|
||||||
|
setIsAppDeletable(!existsStaticApp)
|
||||||
|
setSafeApp(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadApp()
|
||||||
|
}, [appUrl])
|
||||||
|
|
||||||
|
//track GA
|
||||||
|
useEffect(() => {
|
||||||
|
if (safeApp) {
|
||||||
|
trackEvent({ category: SAFE_NAVIGATION_EVENT, action: 'Apps', label: safeApp.name })
|
||||||
|
}
|
||||||
|
}, [safeApp, trackEvent])
|
||||||
|
|
||||||
|
if (!appUrl) {
|
||||||
|
throw Error('App url No provided or it is invalid.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!safeApp) {
|
||||||
|
return (
|
||||||
|
<LoadingContainer>
|
||||||
|
<Loader size="md" />
|
||||||
|
</LoadingContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (consentReceived === false) {
|
||||||
return <LegalDisclaimer onCancel={redirectToBalance} onConfirm={onConsentReceipt} />
|
return <LegalDisclaimer onCancel={redirectToBalance} onConfirm={onConsentReceipt} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (network === 'UNKNOWN' || !granted) {
|
if (NETWORK_NAME === 'UNKNOWN' || !granted) {
|
||||||
return (
|
return (
|
||||||
<Centered style={{ height: '476px' }}>
|
<OwnerDisclaimer>
|
||||||
<FixedIcon type="notOwner" />
|
<FixedIcon type="notOwner" />
|
||||||
<Title size="xs">To use apps, you must be an owner of this Safe</Title>
|
<Title size="xs">To use apps, you must be an owner of this Safe</Title>
|
||||||
</Centered>
|
</OwnerDisclaimer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IframeWrapper>
|
<AppWrapper>
|
||||||
|
<Menu>
|
||||||
|
<Breadcrumb />
|
||||||
|
{isAppDeletable && (
|
||||||
|
<ButtonLink color="error" iconType="delete" onClick={openRemoveModal}>
|
||||||
|
Remove app
|
||||||
|
</ButtonLink>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<StyledCard>
|
||||||
{appIsLoading && (
|
{appIsLoading && (
|
||||||
<LoadingContainer>
|
<LoadingContainer>
|
||||||
<Loader size="md" />
|
<Loader size="md" />
|
||||||
</LoadingContainer>
|
</LoadingContainer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<StyledIframe
|
<StyledIframe
|
||||||
frameBorder="0"
|
frameBorder="0"
|
||||||
id={`iframe-${selectedApp.name}`}
|
id={`iframe-${appUrl}`}
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
src={selectedApp.url}
|
src={appUrl}
|
||||||
title={selectedApp.name}
|
title={safeApp.name}
|
||||||
onLoad={onIframeLoad}
|
onLoad={onIframeLoad}
|
||||||
/>
|
/>
|
||||||
</IframeWrapper>
|
</StyledCard>
|
||||||
|
|
||||||
|
{isRemoveModalOpen && (
|
||||||
|
<GenericModal
|
||||||
|
title={
|
||||||
|
<Title size="sm" withoutMargin>
|
||||||
|
Remove app
|
||||||
|
</Title>
|
||||||
|
}
|
||||||
|
body={<Text size="md">This action will remove {safeApp.name} from the interface</Text>}
|
||||||
|
footer={
|
||||||
|
<ModalFooterConfirmation
|
||||||
|
cancelText="Cancel"
|
||||||
|
handleCancel={closeRemoveModal}
|
||||||
|
handleOk={removeApp}
|
||||||
|
okText="Remove"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onClose={closeRemoveModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmTransactionModal
|
||||||
|
isOpen={confirmTransactionModal.isOpen}
|
||||||
|
app={safeApp as SafeApp}
|
||||||
|
safeAddress={safeAddress}
|
||||||
|
ethBalance={ethBalance as string}
|
||||||
|
safeName={safeName as string}
|
||||||
|
txs={confirmTransactionModal.txs}
|
||||||
|
onClose={closeConfirmationModal}
|
||||||
|
onUserConfirm={onUserTxConfirm}
|
||||||
|
params={confirmTransactionModal.params}
|
||||||
|
onTxReject={onTxReject}
|
||||||
|
/>
|
||||||
|
</AppWrapper>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
export default AppFrame
|
export default AppFrame
|
||||||
|
|
|
@ -0,0 +1,126 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import styled, { css } from 'styled-components'
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
import { GenericModal, IconText, Loader, Menu } from '@gnosis.pm/safe-react-components'
|
||||||
|
|
||||||
|
import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
|
||||||
|
import AppCard from 'src/routes/safe/components/Apps/components/AppCard'
|
||||||
|
import AddAppIcon from 'src/routes/safe/components/Apps/assets/addApp.svg'
|
||||||
|
import { useRouteMatch, useHistory } from 'react-router-dom'
|
||||||
|
import { SAFELIST_ADDRESS } from 'src/routes/routes'
|
||||||
|
|
||||||
|
import { useAppList } from '../hooks/useAppList'
|
||||||
|
import { SAFE_APP_FETCH_STATUS, SafeApp } from '../types.d'
|
||||||
|
import AddAppForm from './AddAppForm'
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
`
|
||||||
|
|
||||||
|
const centerCSS = css`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
`
|
||||||
|
|
||||||
|
const LoadingContainer = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
${centerCSS};
|
||||||
|
`
|
||||||
|
|
||||||
|
const CardsWrapper = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(243px, 1fr));
|
||||||
|
column-gap: 20px;
|
||||||
|
row-gap: 20px;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ContentWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-grow: 1;
|
||||||
|
align-items: center;
|
||||||
|
`
|
||||||
|
const Breadcrumb = styled.div`
|
||||||
|
height: 51px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const AppsList = (): React.ReactElement => {
|
||||||
|
const history = useHistory()
|
||||||
|
const matchSafeWithAddress = useRouteMatch<{ safeAddress: string }>({ path: `${SAFELIST_ADDRESS}/:safeAddress` })
|
||||||
|
const safeAddress = useSelector(safeParamAddressFromStateSelector)
|
||||||
|
const { appList } = useAppList()
|
||||||
|
const [isAddAppModalOpen, setIsAddAppModalOpen] = useState<boolean>(false)
|
||||||
|
|
||||||
|
const onAddAppHandler = (url: string) => () => {
|
||||||
|
const goToApp = `${matchSafeWithAddress?.url}/apps?appUrl=${encodeURI(url)}`
|
||||||
|
history.push(goToApp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAddAppModal = () => setIsAddAppModalOpen(true)
|
||||||
|
|
||||||
|
const closeAddAppModal = () => setIsAddAppModalOpen(false)
|
||||||
|
|
||||||
|
const isAppLoading = (app: SafeApp) => SAFE_APP_FETCH_STATUS.LOADING === app.fetchStatus
|
||||||
|
|
||||||
|
if (!appList.length || !safeAddress) {
|
||||||
|
return (
|
||||||
|
<LoadingContainer>
|
||||||
|
<Loader size="md" />
|
||||||
|
</LoadingContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<Menu>
|
||||||
|
{/* TODO: Add navigation breadcrumb. Empty for now to give some top margin */}
|
||||||
|
<Breadcrumb />
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<ContentWrapper>
|
||||||
|
<CardsWrapper>
|
||||||
|
<AppCard iconUrl={AddAppIcon} onClick={openAddAppModal} buttonText="Add custom app" iconSize="lg" />
|
||||||
|
|
||||||
|
{appList
|
||||||
|
.filter((a) => a.fetchStatus !== SAFE_APP_FETCH_STATUS.ERROR)
|
||||||
|
.map((a) => (
|
||||||
|
<AppCard
|
||||||
|
isLoading={isAppLoading(a)}
|
||||||
|
key={a.url}
|
||||||
|
iconUrl={a.iconUrl}
|
||||||
|
name={a.name}
|
||||||
|
description={a.description}
|
||||||
|
onClick={onAddAppHandler(a.url)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CardsWrapper>
|
||||||
|
|
||||||
|
<IconText
|
||||||
|
color="secondary"
|
||||||
|
iconSize="sm"
|
||||||
|
iconType="info"
|
||||||
|
text="These are third-party apps, which means they are not owned, controlled, maintained or audited by Gnosis. Interacting with the apps is at your own risk."
|
||||||
|
textSize="sm"
|
||||||
|
/>
|
||||||
|
</ContentWrapper>
|
||||||
|
|
||||||
|
{isAddAppModalOpen && (
|
||||||
|
<GenericModal
|
||||||
|
title="Add custom app"
|
||||||
|
body={<AddAppForm closeModal={closeAddAppModal} appList={appList} />}
|
||||||
|
onClose={closeAddAppModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppsList
|
|
@ -1,75 +0,0 @@
|
||||||
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 AddAppForm from '../AddAppForm'
|
|
||||||
import { SafeApp } from '../types'
|
|
||||||
|
|
||||||
const FORM_ID = 'add-apps-form'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
appList: Array<SafeApp>
|
|
||||||
onAppAdded: (app: SafeApp) => void
|
|
||||||
onAppToggle: (appId: string, enabled: boolean) => void
|
|
||||||
onAppRemoved: (appId: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
type AppListItem = SafeApp & { checked: boolean }
|
|
||||||
|
|
||||||
const ManageApps = ({ appList, onAppAdded, onAppToggle, onAppRemoved }: Props): React.ReactElement => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
|
||||||
const [isSubmitDisabled, setIsSubmitDisabled] = useState(true)
|
|
||||||
|
|
||||||
const onSubmitForm = () => {
|
|
||||||
// This sucks, but it's the way the docs suggest
|
|
||||||
// https://github.com/final-form/react-final-form/blob/master/docs/faq.md#via-documentgetelementbyid
|
|
||||||
document.querySelectorAll(`[data-testId=${FORM_ID}]`)[0].dispatchEvent(new Event('submit', { cancelable: true }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleOpen = () => setIsOpen(!isOpen)
|
|
||||||
|
|
||||||
const closeModal = () => setIsOpen(false)
|
|
||||||
|
|
||||||
const getItemList = (): AppListItem[] =>
|
|
||||||
appList.map((a) => {
|
|
||||||
return { ...a, checked: !a.disabled }
|
|
||||||
})
|
|
||||||
|
|
||||||
const onItemToggle = (itemId: string, checked: boolean): void => {
|
|
||||||
onAppToggle(itemId, checked)
|
|
||||||
}
|
|
||||||
|
|
||||||
const Form = (
|
|
||||||
<AddAppForm
|
|
||||||
formId={FORM_ID}
|
|
||||||
appList={appList}
|
|
||||||
closeModal={closeModal}
|
|
||||||
onAppAdded={onAppAdded}
|
|
||||||
setIsSubmitDisabled={setIsSubmitDisabled}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ButtonLink color="primary" onClick={toggleOpen}>
|
|
||||||
Manage Apps
|
|
||||||
</ButtonLink>
|
|
||||||
{isOpen && (
|
|
||||||
<ManageListModal
|
|
||||||
addButtonLabel="Add custom app"
|
|
||||||
showDeleteButton
|
|
||||||
defaultIconUrl={appsIconSvg}
|
|
||||||
formBody={Form}
|
|
||||||
isSubmitFormDisabled={isSubmitDisabled}
|
|
||||||
itemList={getItemList()}
|
|
||||||
onClose={closeModal}
|
|
||||||
onItemToggle={onItemToggle}
|
|
||||||
onItemDeleted={onAppRemoved}
|
|
||||||
onSubmitForm={onSubmitForm}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ManageApps
|
|
|
@ -1,141 +1,62 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { loadFromStorage, saveToStorage } from 'src/utils/storage'
|
import { loadFromStorage } from 'src/utils/storage'
|
||||||
import { getAppInfoFromUrl, staticAppsList } from '../utils'
|
import { APPS_STORAGE_KEY, getAppInfoFromUrl, getEmptySafeApp, staticAppsList } from '../utils'
|
||||||
import { SafeApp, StoredSafeApp } from '../types'
|
import { SafeApp, StoredSafeApp, SAFE_APP_FETCH_STATUS } from '../types.d'
|
||||||
import { getNetworkId } from 'src/config'
|
import { getNetworkId } from 'src/config'
|
||||||
|
|
||||||
const APPS_STORAGE_KEY = 'APPS_STORAGE_KEY'
|
|
||||||
|
|
||||||
type onAppToggleHandler = (appId: string, enabled: boolean) => Promise<void>
|
|
||||||
type onAppAddedHandler = (app: SafeApp) => void
|
|
||||||
type onAppRemovedHandler = (appId: string) => void
|
|
||||||
|
|
||||||
type UseAppListReturnType = {
|
type UseAppListReturnType = {
|
||||||
appList: SafeApp[]
|
appList: SafeApp[]
|
||||||
loadingAppList: boolean
|
|
||||||
onAppToggle: onAppToggleHandler
|
|
||||||
onAppAdded: onAppAddedHandler
|
|
||||||
onAppRemoved: onAppRemovedHandler
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const useAppList = (): UseAppListReturnType => {
|
const useAppList = (): UseAppListReturnType => {
|
||||||
const [appList, setAppList] = useState<SafeApp[]>([])
|
const [appList, setAppList] = useState<SafeApp[]>([])
|
||||||
const [loadingAppList, setLoadingAppList] = useState<boolean>(true)
|
|
||||||
|
|
||||||
// Load apps list
|
// Load apps list
|
||||||
|
// for each URL we return a mocked safe-app with a loading status
|
||||||
|
// it was developed to speed up initial page load, otherwise the
|
||||||
|
// app renders a loading until all the safe-apps are fetched.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const fetchAppCallback = (res: SafeApp) => {
|
||||||
|
setAppList((prevStatus) => {
|
||||||
|
const cpPrevStatus = [...prevStatus]
|
||||||
|
const appIndex = cpPrevStatus.findIndex((a) => a.url === res.url)
|
||||||
|
const newStatus = res.error ? SAFE_APP_FETCH_STATUS.ERROR : SAFE_APP_FETCH_STATUS.SUCCESS
|
||||||
|
cpPrevStatus[appIndex] = { ...res, fetchStatus: newStatus }
|
||||||
|
return cpPrevStatus.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const loadApps = async () => {
|
const loadApps = async () => {
|
||||||
// recover apps from storage:
|
// recover apps from storage (third-party apps added by the user)
|
||||||
// * third-party apps added by the user
|
const persistedAppList =
|
||||||
// * disabled status for both static and third-party apps
|
(await loadFromStorage<(StoredSafeApp & { networks?: number[] })[]>(APPS_STORAGE_KEY)) || []
|
||||||
const persistedAppList = (await loadFromStorage<StoredSafeApp[]>(APPS_STORAGE_KEY)) || []
|
|
||||||
let list: (StoredSafeApp & { isDeletable: boolean; networks?: number[] })[] = persistedAppList.map((a) => ({
|
// backward compatibility. In a previous implementation a safe app could be disabled, that state was
|
||||||
...a,
|
// persisted in the storage.
|
||||||
isDeletable: true,
|
const customApps = persistedAppList.filter(
|
||||||
|
(persistedApp) => !staticAppsList.some((staticApp) => staticApp.url === persistedApp.url),
|
||||||
|
)
|
||||||
|
|
||||||
|
const apps: SafeApp[] = [...staticAppsList, ...customApps]
|
||||||
|
// if the app does not expose supported networks, include them. (backward compatible)
|
||||||
|
.filter((app) => (!app.networks ? true : app.networks.includes(getNetworkId())))
|
||||||
|
.map((app) => ({
|
||||||
|
...getEmptySafeApp(),
|
||||||
|
url: app.url.trim(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// merge stored apps with static apps (apps added manually can be deleted by the user)
|
|
||||||
staticAppsList.forEach((staticApp) => {
|
|
||||||
const app = list.find((persistedApp) => persistedApp.url === staticApp.url)
|
|
||||||
if (app) {
|
|
||||||
app.isDeletable = false
|
|
||||||
app.networks = staticApp.networks
|
|
||||||
} else {
|
|
||||||
list.push({ ...staticApp, isDeletable: false })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// filter app by network
|
|
||||||
list = list.filter((app) => {
|
|
||||||
// if the app does not expose supported networks, include them. (backward compatible)
|
|
||||||
if (!app.networks) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return app.networks.includes(getNetworkId())
|
|
||||||
})
|
|
||||||
|
|
||||||
let apps: SafeApp[] = []
|
|
||||||
// using the appURL to recover app info
|
|
||||||
for (let index = 0; index < list.length; index++) {
|
|
||||||
try {
|
|
||||||
const currentApp = list[index]
|
|
||||||
|
|
||||||
const appInfo: SafeApp = await getAppInfoFromUrl(currentApp.url)
|
|
||||||
if (appInfo.error) {
|
|
||||||
throw Error(`There was a problem trying to load app ${currentApp.url}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
appInfo.disabled = Boolean(currentApp.disabled)
|
|
||||||
appInfo.isDeletable = Boolean(currentApp.isDeletable) === undefined ? true : currentApp.isDeletable
|
|
||||||
|
|
||||||
apps.push(appInfo)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
apps = apps.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
|
|
||||||
setAppList(apps)
|
setAppList(apps)
|
||||||
setLoadingAppList(false)
|
|
||||||
|
apps.forEach((app) => getAppInfoFromUrl(app.url).then(fetchAppCallback))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!appList.length) {
|
||||||
loadApps()
|
loadApps()
|
||||||
}, [])
|
|
||||||
|
|
||||||
const onAppToggle: onAppToggleHandler = useCallback(
|
|
||||||
async (appId, enabled) => {
|
|
||||||
// update in-memory list
|
|
||||||
const appListCopy = [...appList]
|
|
||||||
|
|
||||||
const app = appListCopy.find((a) => a.id === appId)
|
|
||||||
if (!app) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
app.disabled = !enabled
|
}, [appList])
|
||||||
|
|
||||||
setAppList(appListCopy)
|
|
||||||
|
|
||||||
// update storage list
|
|
||||||
const listToPersist: StoredSafeApp[] = appListCopy.map(({ url, disabled }) => ({ url, disabled }))
|
|
||||||
saveToStorage(APPS_STORAGE_KEY, listToPersist)
|
|
||||||
},
|
|
||||||
[appList],
|
|
||||||
)
|
|
||||||
|
|
||||||
const onAppAdded: onAppAddedHandler = useCallback(
|
|
||||||
(app) => {
|
|
||||||
const newAppList = [
|
|
||||||
{ url: app.url, disabled: false },
|
|
||||||
...appList.map((a) => ({
|
|
||||||
url: a.url,
|
|
||||||
disabled: a.disabled,
|
|
||||||
})),
|
|
||||||
]
|
|
||||||
saveToStorage(APPS_STORAGE_KEY, newAppList)
|
|
||||||
|
|
||||||
setAppList([...appList, { ...app, isDeletable: true }])
|
|
||||||
},
|
|
||||||
[appList],
|
|
||||||
)
|
|
||||||
|
|
||||||
const onAppRemoved: onAppRemovedHandler = useCallback(
|
|
||||||
(appId) => {
|
|
||||||
const appListCopy = appList.filter((a) => a.id !== appId)
|
|
||||||
|
|
||||||
setAppList(appListCopy)
|
|
||||||
|
|
||||||
const listToPersist: StoredSafeApp[] = appListCopy.map(({ url, disabled }) => ({ url, disabled }))
|
|
||||||
saveToStorage(APPS_STORAGE_KEY, listToPersist)
|
|
||||||
},
|
|
||||||
[appList],
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appList,
|
appList,
|
||||||
loadingAppList,
|
|
||||||
onAppToggle,
|
|
||||||
onAppAdded,
|
|
||||||
onAppRemoved,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,8 +3,8 @@ import { loadFromStorage, saveToStorage } from 'src/utils/storage'
|
||||||
|
|
||||||
const APPS_LEGAL_CONSENT_RECEIVED = 'APPS_LEGAL_CONSENT_RECEIVED'
|
const APPS_LEGAL_CONSENT_RECEIVED = 'APPS_LEGAL_CONSENT_RECEIVED'
|
||||||
|
|
||||||
const useLegalConsent = (): { consentReceived: boolean; onConsentReceipt: () => void } => {
|
const useLegalConsent = (): { consentReceived: boolean | undefined; onConsentReceipt: () => void } => {
|
||||||
const [consentReceived, setConsentReceived] = useState<boolean>(false)
|
const [consentReceived, setConsentReceived] = useState<boolean | undefined>()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkLegalDisclaimer = async () => {
|
const checkLegalDisclaimer = async () => {
|
||||||
|
@ -12,6 +12,8 @@ const useLegalConsent = (): { consentReceived: boolean; onConsentReceipt: () =>
|
||||||
|
|
||||||
if (storedConsentReceived) {
|
if (storedConsentReceived) {
|
||||||
setConsentReceived(true)
|
setConsentReceived(true)
|
||||||
|
} else {
|
||||||
|
setConsentReceived(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,237 +1,23 @@
|
||||||
import React, { useCallback, useEffect, useRef, useState, useMemo } from 'react'
|
import React from 'react'
|
||||||
import {
|
|
||||||
INTERFACE_MESSAGES,
|
|
||||||
Transaction,
|
|
||||||
RequestId,
|
|
||||||
LowercaseNetworks,
|
|
||||||
SendTransactionParams,
|
|
||||||
} from '@gnosis.pm/safe-apps-sdk'
|
|
||||||
import { Card, IconText, Loader, Menu, Title } from '@gnosis.pm/safe-react-components'
|
|
||||||
import { useSelector } from 'react-redux'
|
|
||||||
import styled, { css } from 'styled-components'
|
|
||||||
|
|
||||||
import ManageApps from './components/ManageApps'
|
|
||||||
import AppFrame from './components/AppFrame'
|
import AppFrame from './components/AppFrame'
|
||||||
import { useAppList } from './hooks/useAppList'
|
import AppsList from './components/AppsList'
|
||||||
import { SafeApp } from './types.d'
|
|
||||||
|
|
||||||
import LCL from 'src/components/ListContentLayout'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { grantedSelector } from 'src/routes/safe/container/selector'
|
|
||||||
import {
|
|
||||||
safeEthBalanceSelector,
|
|
||||||
safeParamAddressFromStateSelector,
|
|
||||||
safeNameSelector,
|
|
||||||
} from 'src/logic/safe/store/selectors'
|
|
||||||
import { isSameURL } from 'src/utils/url'
|
|
||||||
import { useIframeMessageHandler } from './hooks/useIframeMessageHandler'
|
|
||||||
import ConfirmTransactionModal from './components/ConfirmTransactionModal'
|
|
||||||
import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics'
|
|
||||||
import { getNetworkName } from 'src/config'
|
|
||||||
|
|
||||||
const centerCSS = css`
|
const useQuery = () => {
|
||||||
display: flex;
|
return new URLSearchParams(useLocation().search)
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
`
|
|
||||||
|
|
||||||
const LoadingContainer = styled.div`
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
${centerCSS};
|
|
||||||
`
|
|
||||||
|
|
||||||
const StyledCard = styled(Card)`
|
|
||||||
margin-bottom: 24px;
|
|
||||||
${centerCSS};
|
|
||||||
`
|
|
||||||
|
|
||||||
const CenteredMT = styled.div`
|
|
||||||
${centerCSS};
|
|
||||||
margin-top: 16px;
|
|
||||||
`
|
|
||||||
|
|
||||||
type ConfirmTransactionModalState = {
|
|
||||||
isOpen: boolean
|
|
||||||
txs: Transaction[]
|
|
||||||
requestId?: RequestId
|
|
||||||
params?: SendTransactionParams
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_CONFIRM_TX_MODAL_STATE: ConfirmTransactionModalState = {
|
|
||||||
isOpen: false,
|
|
||||||
txs: [],
|
|
||||||
requestId: undefined,
|
|
||||||
params: undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
const NETWORK_NAME = getNetworkName()
|
|
||||||
|
|
||||||
const Apps = (): React.ReactElement => {
|
const Apps = (): React.ReactElement => {
|
||||||
const { appList, loadingAppList, onAppToggle, onAppAdded, onAppRemoved } = useAppList()
|
const query = useQuery()
|
||||||
|
const appUrl = query.get('appUrl')
|
||||||
|
|
||||||
const [appIsLoading, setAppIsLoading] = useState<boolean>(true)
|
if (appUrl) {
|
||||||
const [selectedAppId, setSelectedAppId] = useState<string>()
|
return <AppFrame appUrl={appUrl} />
|
||||||
const [confirmTransactionModal, setConfirmTransactionModal] = useState<ConfirmTransactionModalState>(
|
} else {
|
||||||
INITIAL_CONFIRM_TX_MODAL_STATE,
|
return <AppsList />
|
||||||
)
|
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
|
||||||
|
|
||||||
const { trackEvent } = useAnalytics()
|
|
||||||
const granted = useSelector(grantedSelector)
|
|
||||||
const safeAddress = useSelector(safeParamAddressFromStateSelector)
|
|
||||||
const safeName = useSelector(safeNameSelector)
|
|
||||||
const ethBalance = useSelector(safeEthBalanceSelector)
|
|
||||||
|
|
||||||
const openConfirmationModal = useCallback(
|
|
||||||
(txs: Transaction[], params: SendTransactionParams | undefined, requestId: RequestId) =>
|
|
||||||
setConfirmTransactionModal({
|
|
||||||
isOpen: true,
|
|
||||||
txs,
|
|
||||||
requestId,
|
|
||||||
params,
|
|
||||||
}),
|
|
||||||
[setConfirmTransactionModal],
|
|
||||||
)
|
|
||||||
const closeConfirmationModal = useCallback(() => setConfirmTransactionModal(INITIAL_CONFIRM_TX_MODAL_STATE), [
|
|
||||||
setConfirmTransactionModal,
|
|
||||||
])
|
|
||||||
|
|
||||||
const selectedApp = useMemo(() => appList.find((app) => app.id === selectedAppId), [appList, selectedAppId])
|
|
||||||
const enabledApps = useMemo(() => appList.filter((a) => !a.disabled), [appList])
|
|
||||||
const { sendMessageToIframe } = useIframeMessageHandler(
|
|
||||||
selectedApp,
|
|
||||||
openConfirmationModal,
|
|
||||||
closeConfirmationModal,
|
|
||||||
iframeRef,
|
|
||||||
)
|
|
||||||
|
|
||||||
const onUserTxConfirm = (safeTxHash: string) => {
|
|
||||||
sendMessageToIframe(
|
|
||||||
{ messageId: INTERFACE_MESSAGES.TRANSACTION_CONFIRMED, data: { safeTxHash } },
|
|
||||||
confirmTransactionModal.requestId,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onTxReject = () => {
|
|
||||||
sendMessageToIframe(
|
|
||||||
{ messageId: INTERFACE_MESSAGES.TRANSACTION_REJECTED, data: {} },
|
|
||||||
confirmTransactionModal.requestId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSelectApp = useCallback(
|
|
||||||
(appId) => {
|
|
||||||
if (selectedAppId === appId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setAppIsLoading(true)
|
|
||||||
setSelectedAppId(appId)
|
|
||||||
},
|
|
||||||
[selectedAppId],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Auto Select app first App
|
|
||||||
useEffect(() => {
|
|
||||||
const selectFirstEnabledApp = () => {
|
|
||||||
const firstEnabledApp = appList.find((a) => !a.disabled)
|
|
||||||
if (firstEnabledApp) {
|
|
||||||
setSelectedAppId(firstEnabledApp.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialSelect = appList.length && !selectedAppId
|
|
||||||
const currentAppWasDisabled = selectedApp?.disabled
|
|
||||||
if (initialSelect || currentAppWasDisabled) {
|
|
||||||
selectFirstEnabledApp()
|
|
||||||
}
|
|
||||||
}, [appList, selectedApp, selectedAppId, trackEvent])
|
|
||||||
|
|
||||||
// track GA
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedApp) {
|
|
||||||
trackEvent({ category: SAFE_NAVIGATION_EVENT, action: 'Apps', label: selectedApp.name })
|
|
||||||
}
|
|
||||||
}, [selectedApp, trackEvent])
|
|
||||||
|
|
||||||
const handleIframeLoad = useCallback(() => {
|
|
||||||
const iframe = iframeRef.current
|
|
||||||
if (!iframe || !selectedApp || !isSameURL(iframe.src, selectedApp.url)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setAppIsLoading(false)
|
|
||||||
sendMessageToIframe({
|
|
||||||
messageId: INTERFACE_MESSAGES.ON_SAFE_INFO,
|
|
||||||
data: {
|
|
||||||
safeAddress: safeAddress as string,
|
|
||||||
network: NETWORK_NAME.toLowerCase() as LowercaseNetworks,
|
|
||||||
ethBalance: ethBalance as string,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}, [ethBalance, safeAddress, selectedApp, sendMessageToIframe])
|
|
||||||
|
|
||||||
if (loadingAppList || !appList.length || !safeAddress) {
|
|
||||||
return (
|
|
||||||
<LoadingContainer>
|
|
||||||
<Loader size="md" />
|
|
||||||
</LoadingContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Menu>
|
|
||||||
<ManageApps appList={appList} onAppAdded={onAppAdded} onAppToggle={onAppToggle} onAppRemoved={onAppRemoved} />
|
|
||||||
</Menu>
|
|
||||||
{enabledApps.length ? (
|
|
||||||
<LCL.Wrapper>
|
|
||||||
<LCL.Menu>
|
|
||||||
<LCL.List activeItem={selectedAppId} items={enabledApps} onItemClick={onSelectApp} />
|
|
||||||
</LCL.Menu>
|
|
||||||
<LCL.Content>
|
|
||||||
<AppFrame
|
|
||||||
ref={iframeRef}
|
|
||||||
granted={granted}
|
|
||||||
selectedApp={selectedApp}
|
|
||||||
safeAddress={safeAddress}
|
|
||||||
network={NETWORK_NAME}
|
|
||||||
appIsLoading={appIsLoading}
|
|
||||||
onIframeLoad={handleIframeLoad}
|
|
||||||
/>
|
|
||||||
</LCL.Content>
|
|
||||||
</LCL.Wrapper>
|
|
||||||
) : (
|
|
||||||
<StyledCard>
|
|
||||||
<Title size="xs">No Apps Enabled</Title>
|
|
||||||
</StyledCard>
|
|
||||||
)}
|
|
||||||
<CenteredMT>
|
|
||||||
<IconText
|
|
||||||
color="secondary"
|
|
||||||
iconSize="sm"
|
|
||||||
iconType="info"
|
|
||||||
text="
|
|
||||||
These are third-party apps, which means they are not owned, controlled, maintained or audited by Gnosis.
|
|
||||||
Interacting with the apps is at your own risk.
|
|
||||||
Any communication within the Apps is for informational purposes only and must not be construed as investment advice to engage in any transaction."
|
|
||||||
textSize="sm"
|
|
||||||
/>
|
|
||||||
</CenteredMT>
|
|
||||||
<ConfirmTransactionModal
|
|
||||||
isOpen={confirmTransactionModal.isOpen}
|
|
||||||
app={selectedApp as SafeApp}
|
|
||||||
safeAddress={safeAddress}
|
|
||||||
ethBalance={ethBalance as string}
|
|
||||||
safeName={safeName as string}
|
|
||||||
txs={confirmTransactionModal.txs}
|
|
||||||
onClose={closeConfirmationModal}
|
|
||||||
onUserConfirm={onUserTxConfirm}
|
|
||||||
params={confirmTransactionModal.params}
|
|
||||||
onTxReject={onTxReject}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Apps
|
export default Apps
|
||||||
|
|
|
@ -1,15 +1,20 @@
|
||||||
|
export enum SAFE_APP_FETCH_STATUS {
|
||||||
|
LOADING = 'LOADING',
|
||||||
|
SUCCESS = 'SUCCESS',
|
||||||
|
ERROR = 'ERROR',
|
||||||
|
}
|
||||||
|
|
||||||
export type SafeApp = {
|
export type SafeApp = {
|
||||||
id: string
|
id: string
|
||||||
url: string
|
url: string
|
||||||
name: string
|
name: string
|
||||||
iconUrl: string
|
iconUrl: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
isDeletable?: boolean
|
|
||||||
error: boolean
|
|
||||||
description: string
|
description: string
|
||||||
|
error: boolean
|
||||||
|
fetchStatus: SAFE_APP_FETCH_STATUS
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StoredSafeApp = {
|
export type StoredSafeApp = {
|
||||||
url: string
|
url: string
|
||||||
disabled?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import memoize from 'lodash.memoize'
|
import memoize from 'lodash.memoize'
|
||||||
|
|
||||||
import { SafeApp } from './types.d'
|
import { SafeApp, SAFE_APP_FETCH_STATUS } from './types.d'
|
||||||
|
|
||||||
import { getGnosisSafeAppsUrl } from 'src/config'
|
import { getGnosisSafeAppsUrl } from 'src/config'
|
||||||
import { getContentFromENS } from 'src/logic/wallets/getWeb3'
|
import { getContentFromENS } from 'src/logic/wallets/getWeb3'
|
||||||
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
|
import appsIconSvg from 'src/assets/icons/apps.svg'
|
||||||
import { ETHEREUM_NETWORK } from 'src/config/networks/network.d'
|
import { ETHEREUM_NETWORK } from 'src/config/networks/network.d'
|
||||||
|
|
||||||
|
export const APPS_STORAGE_KEY = 'APPS_STORAGE_KEY'
|
||||||
|
|
||||||
const removeLastTrailingSlash = (url) => {
|
const removeLastTrailingSlash = (url) => {
|
||||||
if (url.substr(-1) === '/') {
|
if (url.substr(-1) === '/') {
|
||||||
return url.substr(0, url.length - 1)
|
return url.substr(0, url.length - 1)
|
||||||
|
@ -16,7 +18,12 @@ const removeLastTrailingSlash = (url) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const gnosisAppsUrl = removeLastTrailingSlash(getGnosisSafeAppsUrl())
|
const gnosisAppsUrl = removeLastTrailingSlash(getGnosisSafeAppsUrl())
|
||||||
export const staticAppsList: Array<{ url: string; disabled: boolean; networks: number[] }> = [
|
export type StaticAppInfo = {
|
||||||
|
url: string
|
||||||
|
disabled: boolean
|
||||||
|
networks: number[]
|
||||||
|
}
|
||||||
|
export const staticAppsList: Array<StaticAppInfo> = [
|
||||||
// 1inch
|
// 1inch
|
||||||
{
|
{
|
||||||
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmUDTSghr154kCCGguyA3cbG5HRVd2tQgNR7yD69bcsjm5`,
|
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmUDTSghr154kCCGguyA3cbG5HRVd2tQgNR7yD69bcsjm5`,
|
||||||
|
@ -111,7 +118,7 @@ export const staticAppsList: Array<{ url: string; disabled: boolean; networks: n
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const getAppInfoFromOrigin = (origin: string): Record<string, string> | null => {
|
export const getAppInfoFromOrigin = (origin: string): { url: string; name: string } | null => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(origin)
|
return JSON.parse(origin)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -132,9 +139,25 @@ export const isAppManifestValid = (appInfo: SafeApp): boolean =>
|
||||||
// no `error` (or `error` undefined)
|
// no `error` (or `error` undefined)
|
||||||
!appInfo.error
|
!appInfo.error
|
||||||
|
|
||||||
|
export const getEmptySafeApp = (): SafeApp => {
|
||||||
|
return {
|
||||||
|
id: Math.random().toString(),
|
||||||
|
url: '',
|
||||||
|
name: 'unknown',
|
||||||
|
iconUrl: appsIconSvg,
|
||||||
|
error: false,
|
||||||
|
description: '',
|
||||||
|
fetchStatus: SAFE_APP_FETCH_STATUS.LOADING,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const getAppInfoFromUrl = memoize(
|
export const getAppInfoFromUrl = memoize(
|
||||||
async (appUrl: string): Promise<SafeApp> => {
|
async (appUrl: string): Promise<SafeApp> => {
|
||||||
let res = { id: '', url: appUrl, name: 'unknown', iconUrl: appsIconSvg, error: true, description: '' }
|
let res = {
|
||||||
|
...getEmptySafeApp(),
|
||||||
|
error: true,
|
||||||
|
loadingStatus: SAFE_APP_FETCH_STATUS.ERROR,
|
||||||
|
}
|
||||||
|
|
||||||
if (!appUrl?.length) {
|
if (!appUrl?.length) {
|
||||||
return res
|
return res
|
||||||
|
@ -161,6 +184,7 @@ export const getAppInfoFromUrl = memoize(
|
||||||
...appInfo.data,
|
...appInfo.data,
|
||||||
id: JSON.stringify({ url: res.url, name: appInfo.data.name.substring(0, remainingSpace) }),
|
id: JSON.stringify({ url: res.url, name: appInfo.data.name.substring(0, remainingSpace) }),
|
||||||
error: false,
|
error: false,
|
||||||
|
loadingStatus: SAFE_APP_FETCH_STATUS.SUCCESS,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appInfo.data.iconPath) {
|
if (appInfo.data.iconPath) {
|
||||||
|
@ -196,10 +220,10 @@ export const getIpfsLinkFromEns = memoize(
|
||||||
)
|
)
|
||||||
|
|
||||||
export const uniqueApp = (appList: SafeApp[]) => (url: string): string | undefined => {
|
export const uniqueApp = (appList: SafeApp[]) => (url: string): string | undefined => {
|
||||||
|
const newUrl = new URL(url)
|
||||||
const exists = appList.some((a) => {
|
const exists = appList.some((a) => {
|
||||||
try {
|
try {
|
||||||
const currentUrl = new URL(a.url)
|
const currentUrl = new URL(a.url)
|
||||||
const newUrl = new URL(url)
|
|
||||||
return currentUrl.href === newUrl.href
|
return currentUrl.href === newUrl.href
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('There was a problem trying to validate the URL existence.', error.message)
|
console.error('There was a problem trying to validate the URL existence.', error.message)
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
export const AppsIcon = () => (
|
|
||||||
<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g fill="none" fillRule="evenodd">
|
|
||||||
<path d="M0 0h16v16H0z" />
|
|
||||||
<path
|
|
||||||
className="fill"
|
|
||||||
d="M2 1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zm1 2v2h2V3H3zM10 9h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1zm1 2v2h2v-2h-2zM12 7a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm0-1.9a1.1 1.1 0 1 1 0-2.2 1.1 1.1 0 0 1 0 2.2z"
|
|
||||||
fillRule="nonzero"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
className="fill"
|
|
||||||
d="M2.641 11.999l-1.36-1.361A.96.96 0 0 1 2.637 9.28l1.36 1.36 1.362-1.36a.959.959 0 1 1 1.357 1.357l-1.36 1.36 1.36 1.362a.96.96 0 0 1-1.357 1.358L4 13.356l-1.361 1.362a.962.962 0 0 1-1.358 0 .964.964 0 0 1 0-1.358l1.361-1.361z"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
)
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { GenericModal } from '@gnosis.pm/safe-react-components'
|
import { GenericModal, Loader } from '@gnosis.pm/safe-react-components'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
import { Redirect, Route, Switch, useRouteMatch } from 'react-router-dom'
|
import { Redirect, Route, Switch, useRouteMatch } from 'react-router-dom'
|
||||||
|
@ -6,10 +6,10 @@ import { Redirect, Route, Switch, useRouteMatch } from 'react-router-dom'
|
||||||
import NoSafe from 'src/components/NoSafe'
|
import NoSafe from 'src/components/NoSafe'
|
||||||
import { providerNameSelector } from 'src/logic/wallets/store/selectors'
|
import { providerNameSelector } from 'src/logic/wallets/store/selectors'
|
||||||
import { safeFeaturesEnabledSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
|
import { safeFeaturesEnabledSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
|
||||||
import { AppReduxState } from 'src/store'
|
|
||||||
import { wrapInSuspense } from 'src/utils/wrapInSuspense'
|
import { wrapInSuspense } from 'src/utils/wrapInSuspense'
|
||||||
import { SAFELIST_ADDRESS } from 'src/routes/routes'
|
import { SAFELIST_ADDRESS } from 'src/routes/routes'
|
||||||
import { FEATURES } from 'src/config/networks/network.d'
|
import { FEATURES } from 'src/config/networks/network.d'
|
||||||
|
import { LoadingContainer } from 'src/components/LoaderContainer'
|
||||||
|
|
||||||
export const BALANCES_TAB_BTN_TEST_ID = 'balances-tab-btn'
|
export const BALANCES_TAB_BTN_TEST_ID = 'balances-tab-btn'
|
||||||
export const SETTINGS_TAB_BTN_TEST_ID = 'settings-tab-btn'
|
export const SETTINGS_TAB_BTN_TEST_ID = 'settings-tab-btn'
|
||||||
|
@ -26,6 +26,7 @@ const TxsTable = React.lazy(() => import('src/routes/safe/components/Transaction
|
||||||
const AddressBookTable = React.lazy(() => import('src/routes/safe/components/AddressBook'))
|
const AddressBookTable = React.lazy(() => import('src/routes/safe/components/AddressBook'))
|
||||||
|
|
||||||
const Container = (): React.ReactElement => {
|
const Container = (): React.ReactElement => {
|
||||||
|
const featuresEnabled = useSelector(safeFeaturesEnabledSelector)
|
||||||
const [modal, setModal] = useState({
|
const [modal, setModal] = useState({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
title: null,
|
title: null,
|
||||||
|
@ -36,23 +37,20 @@ const Container = (): React.ReactElement => {
|
||||||
|
|
||||||
const safeAddress = useSelector(safeParamAddressFromStateSelector)
|
const safeAddress = useSelector(safeParamAddressFromStateSelector)
|
||||||
const provider = useSelector(providerNameSelector)
|
const provider = useSelector(providerNameSelector)
|
||||||
const featuresEnabled = useSelector<AppReduxState, FEATURES[] | undefined>(
|
const matchSafeWithAddress = useRouteMatch({ path: `${SAFELIST_ADDRESS}/:safeAddress` })
|
||||||
safeFeaturesEnabledSelector,
|
|
||||||
(left, right) => {
|
|
||||||
if (Array.isArray(left) && Array.isArray(right)) {
|
|
||||||
return JSON.stringify(left) === JSON.stringify(right)
|
|
||||||
}
|
|
||||||
|
|
||||||
return left === right
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const matchSafeWithAddress = useRouteMatch<{ safeAddress: string }>({ path: `${SAFELIST_ADDRESS}/:safeAddress` })
|
|
||||||
const safeAppsEnabled = Boolean(featuresEnabled?.includes(FEATURES.SAFE_APPS))
|
|
||||||
|
|
||||||
if (!safeAddress) {
|
if (!safeAddress) {
|
||||||
return <NoSafe provider={provider} text="Safe not found" />
|
return <NoSafe provider={provider} text="Safe not found" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!featuresEnabled) {
|
||||||
|
return (
|
||||||
|
<LoadingContainer>
|
||||||
|
<Loader size="md" />
|
||||||
|
</LoadingContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const closeGenericModal = () => {
|
const closeGenericModal = () => {
|
||||||
if (modal.onClose) {
|
if (modal.onClose) {
|
||||||
modal.onClose?.()
|
modal.onClose?.()
|
||||||
|
@ -84,13 +82,12 @@ const Container = (): React.ReactElement => {
|
||||||
exact
|
exact
|
||||||
path={`${matchSafeWithAddress?.path}/apps`}
|
path={`${matchSafeWithAddress?.path}/apps`}
|
||||||
render={({ history }) => {
|
render={({ history }) => {
|
||||||
if (!safeAppsEnabled) {
|
if (!featuresEnabled.includes(FEATURES.SAFE_APPS)) {
|
||||||
history.push(`${matchSafeWithAddress?.url}/balances`)
|
history.push(`${matchSafeWithAddress?.url}/balances`)
|
||||||
}
|
}
|
||||||
return wrapInSuspense(<Apps />, null)
|
return wrapInSuspense(<Apps />, null)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
path={`${matchSafeWithAddress?.path}/settings`}
|
path={`${matchSafeWithAddress?.path}/settings`}
|
||||||
|
|
|
@ -1522,9 +1522,9 @@
|
||||||
solc "0.5.14"
|
solc "0.5.14"
|
||||||
truffle "^5.1.21"
|
truffle "^5.1.21"
|
||||||
|
|
||||||
"@gnosis.pm/safe-react-components@https://github.com/gnosis/safe-react-components.git#03ff672d6f73366297986d58631f9582fe2ed4a3":
|
"@gnosis.pm/safe-react-components@https://github.com/gnosis/safe-react-components.git#ff29c3c":
|
||||||
version "0.4.0"
|
version "0.4.0"
|
||||||
resolved "https://github.com/gnosis/safe-react-components.git#03ff672d6f73366297986d58631f9582fe2ed4a3"
|
resolved "https://github.com/gnosis/safe-react-components.git#ff29c3ccfd391142b92edefba0f773aaf16f1799"
|
||||||
dependencies:
|
dependencies:
|
||||||
classnames "^2.2.6"
|
classnames "^2.2.6"
|
||||||
polished "^3.6.7"
|
polished "^3.6.7"
|
||||||
|
|
Loading…
Reference in New Issue