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:
nicolas 2020-11-26 14:40:29 -03:00 committed by GitHub
parent 0ecd70fdbc
commit ba680f7158
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 788 additions and 586 deletions

View File

@ -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",

View File

Before

Width:  |  Height:  |  Size: 690 B

After

Width:  |  Height:  |  Size: 690 B

View File

@ -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;

View File

@ -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 }

View File

@ -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};
` `

View File

@ -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;
`

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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}
/>
)

View File

@ -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

View File

@ -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;
`

View File

@ -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
}
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,
)
} }
if (!consentReceived) { 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>
{appIsLoading && ( <Menu>
<LoadingContainer> <Breadcrumb />
<Loader size="md" /> {isAppDeletable && (
</LoadingContainer> <ButtonLink color="error" iconType="delete" onClick={openRemoveModal}>
Remove app
</ButtonLink>
)}
</Menu>
<StyledCard>
{appIsLoading && (
<LoadingContainer>
<Loader size="md" />
</LoadingContainer>
)}
<StyledIframe
frameBorder="0"
id={`iframe-${appUrl}`}
ref={iframeRef}
src={appUrl}
title={safeApp.name}
onLoad={onIframeLoad}
/>
</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}
/>
)} )}
<StyledIframe
frameBorder="0" <ConfirmTransactionModal
id={`iframe-${selectedApp.name}`} isOpen={confirmTransactionModal.isOpen}
ref={iframeRef} app={safeApp as SafeApp}
src={selectedApp.url} safeAddress={safeAddress}
title={selectedApp.name} ethBalance={ethBalance as string}
onLoad={onIframeLoad} safeName={safeName as string}
txs={confirmTransactionModal.txs}
onClose={closeConfirmationModal}
onUserConfirm={onUserTxConfirm}
params={confirmTransactionModal.params}
onTxReject={onTxReject}
/> />
</IframeWrapper> </AppWrapper>
) )
}) }
export default AppFrame export default AppFrame

View File

@ -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

View File

@ -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

View File

@ -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 loadApps = async () => { const fetchAppCallback = (res: SafeApp) => {
// recover apps from storage: setAppList((prevStatus) => {
// * third-party apps added by the user const cpPrevStatus = [...prevStatus]
// * disabled status for both static and third-party apps const appIndex = cpPrevStatus.findIndex((a) => a.url === res.url)
const persistedAppList = (await loadFromStorage<StoredSafeApp[]>(APPS_STORAGE_KEY)) || [] const newStatus = res.error ? SAFE_APP_FETCH_STATUS.ERROR : SAFE_APP_FETCH_STATUS.SUCCESS
let list: (StoredSafeApp & { isDeletable: boolean; networks?: number[] })[] = persistedAppList.map((a) => ({ cpPrevStatus[appIndex] = { ...res, fetchStatus: newStatus }
...a, return cpPrevStatus.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
isDeletable: true,
}))
// 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)
setLoadingAppList(false)
} }
loadApps() const loadApps = async () => {
}, []) // recover apps from storage (third-party apps added by the user)
const persistedAppList =
(await loadFromStorage<(StoredSafeApp & { networks?: number[] })[]>(APPS_STORAGE_KEY)) || []
const onAppToggle: onAppToggleHandler = useCallback( // backward compatibility. In a previous implementation a safe app could be disabled, that state was
async (appId, enabled) => { // persisted in the storage.
// update in-memory list const customApps = persistedAppList.filter(
const appListCopy = [...appList] (persistedApp) => !staticAppsList.some((staticApp) => staticApp.url === persistedApp.url),
)
const app = appListCopy.find((a) => a.id === appId) const apps: SafeApp[] = [...staticAppsList, ...customApps]
if (!app) { // if the app does not expose supported networks, include them. (backward compatible)
return .filter((app) => (!app.networks ? true : app.networks.includes(getNetworkId())))
} .map((app) => ({
app.disabled = !enabled ...getEmptySafeApp(),
url: app.url.trim(),
}))
setAppList(appListCopy) setAppList(apps)
// update storage list apps.forEach((app) => getAppInfoFromUrl(app.url).then(fetchAppCallback))
const listToPersist: StoredSafeApp[] = appListCopy.map(({ url, disabled }) => ({ url, disabled })) }
saveToStorage(APPS_STORAGE_KEY, listToPersist)
},
[appList],
)
const onAppAdded: onAppAddedHandler = useCallback( if (!appList.length) {
(app) => { loadApps()
const newAppList = [ }
{ url: app.url, disabled: false }, }, [appList])
...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,
} }
} }

View File

@ -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)
} }
} }

View File

@ -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

View File

@ -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
} }

View File

@ -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)

View File

@ -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>
)

View File

@ -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`}

View File

@ -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"