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": {
|
||||
"@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-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",
|
||||
"@ledgerhq/hw-transport-node-hid-singleton": "5.30.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`
|
||||
height: 30px;
|
||||
width: 166px;
|
||||
padding: 8px 0 8px 16px;
|
||||
padding: 6px 0 8px 16px;
|
||||
margin: 14px 0px;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
|
|
|
@ -19,7 +19,7 @@ const useSidebarItems = (): ListItemType[] => {
|
|||
}
|
||||
|
||||
return useMemo((): ListItemType[] => {
|
||||
if (!matchSafe || !matchSafeWithAddress) {
|
||||
if (!matchSafe || !matchSafeWithAddress || !featuresEnabled) {
|
||||
return []
|
||||
}
|
||||
|
||||
|
@ -63,7 +63,7 @@ const useSidebarItems = (): ListItemType[] => {
|
|||
},
|
||||
...safeSidebar,
|
||||
]
|
||||
}, [matchSafe, matchSafeWithAction, matchSafeWithAddress, safeAppsEnabled])
|
||||
}, [matchSafe, matchSafeWithAction, matchSafeWithAddress, safeAppsEnabled, featuresEnabled])
|
||||
}
|
||||
|
||||
export { useSidebarItems }
|
||||
|
|
|
@ -38,7 +38,7 @@ const SidebarWrapper = styled.aside`
|
|||
flex-direction: column;
|
||||
z-index: 1;
|
||||
|
||||
padding: 8px;
|
||||
padding: 8px 8px 0 8px;
|
||||
background-color: ${({ theme }) => theme.colors.white};
|
||||
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> =>
|
||||
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
|
||||
}
|
||||
|
||||
export const enabledFeatures = (version?: string): FEATURES[] => {
|
||||
return FEATURES_BY_VERSION.reduce((acc: FEATURES[], feature: Feature) => {
|
||||
if (isFeatureEnabled(feature.name) && version && checkFeatureEnabledByVersion(feature, version)) {
|
||||
return FEATURES_BY_VERSION.reduce((acc, feature: Feature) => {
|
||||
if (isFeatureEnabled(feature.name) && checkFeatureEnabledByVersion(feature, version)) {
|
||||
acc.push(feature.name)
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
}, [] as FEATURES[])
|
||||
}
|
||||
|
||||
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 React from 'react'
|
||||
import { TextField } from '@gnosis.pm/safe-react-components'
|
||||
import React, { useState, ReactElement } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import AppAgreement from './AppAgreement'
|
||||
import AppUrl, { AppInfoUpdater, appUrlResolver } from './AppUrl'
|
||||
import SubmitButtonStatus from './SubmitButtonStatus'
|
||||
|
||||
import { SafeApp } from 'src/routes/safe/components/Apps/types.d'
|
||||
import GnoForm from 'src/components/forms/GnoForm'
|
||||
import Img from 'src/components/layout/Img'
|
||||
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
|
||||
|
||||
const StyledText = styled(Text)`
|
||||
margin-bottom: 19px;
|
||||
`
|
||||
import AppAgreement from './AppAgreement'
|
||||
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)`
|
||||
&& {
|
||||
|
@ -39,38 +40,34 @@ const INITIAL_VALUES: AddAppFormValues = {
|
|||
agreementAccepted: false,
|
||||
}
|
||||
|
||||
const APP_INFO: SafeApp = {
|
||||
id: '',
|
||||
url: '',
|
||||
name: '',
|
||||
iconUrl: appsIconSvg,
|
||||
error: false,
|
||||
description: '',
|
||||
}
|
||||
const APP_INFO = getEmptySafeApp()
|
||||
|
||||
interface AddAppProps {
|
||||
appList: SafeApp[]
|
||||
closeModal: () => void
|
||||
formId: string
|
||||
onAppAdded: (app: SafeApp) => void
|
||||
setIsSubmitDisabled: (disabled: boolean) => void
|
||||
}
|
||||
|
||||
const AddApp = ({ appList, closeModal, formId, onAppAdded, setIsSubmitDisabled }: AddAppProps): React.ReactElement => {
|
||||
const [appInfo, setAppInfo] = React.useState<SafeApp>(APP_INFO)
|
||||
const AddApp = ({ appList, closeModal }: AddAppProps): ReactElement => {
|
||||
const [appInfo, setAppInfo] = useState<SafeApp>(APP_INFO)
|
||||
const history = useHistory()
|
||||
const matchSafeWithAddress = useRouteMatch<{ safeAddress: string }>({ path: `${SAFELIST_ADDRESS}/:safeAddress` })
|
||||
|
||||
const handleSubmit = () => {
|
||||
closeModal()
|
||||
onAppAdded(appInfo)
|
||||
const newAppList = [
|
||||
{ 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 (
|
||||
<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} />
|
||||
|
||||
{/* Fetch app from url and return a SafeApp */}
|
||||
<AppInfoUpdater onAppInfo={setAppInfo} />
|
||||
|
||||
|
@ -81,7 +78,7 @@ const AddApp = ({ appList, closeModal, formId, onAppAdded, setIsSubmitDisabled }
|
|||
|
||||
<AppAgreement />
|
||||
|
||||
<SubmitButtonStatus onSubmitButtonStatusChange={setIsSubmitDisabled} appInfo={appInfo} />
|
||||
<FormButtons appInfo={appInfo} onCancel={closeModal} />
|
||||
</>
|
||||
)}
|
||||
</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 { FixedIcon, Loader, Title } from '@gnosis.pm/safe-react-components'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import {
|
||||
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 { 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 { SafeApp } from '../types'
|
||||
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`
|
||||
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`
|
||||
const OwnerDisclaimer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
height: 476px;
|
||||
`
|
||||
|
||||
type AppFrameProps = {
|
||||
selectedApp: SafeApp | undefined
|
||||
safeAddress: string
|
||||
network: string
|
||||
granted: boolean
|
||||
appIsLoading: boolean
|
||||
onIframeLoad: () => void
|
||||
const AppWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
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(
|
||||
{ selectedApp, safeAddress, network, appIsLoading, granted, onIframeLoad },
|
||||
iframeRef,
|
||||
): React.ReactElement {
|
||||
type Props = {
|
||||
appUrl: string
|
||||
}
|
||||
|
||||
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 { 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`)
|
||||
|
||||
if (!selectedApp) {
|
||||
return <div />
|
||||
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 { 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} />
|
||||
}
|
||||
|
||||
if (network === 'UNKNOWN' || !granted) {
|
||||
if (NETWORK_NAME === 'UNKNOWN' || !granted) {
|
||||
return (
|
||||
<Centered style={{ height: '476px' }}>
|
||||
<OwnerDisclaimer>
|
||||
<FixedIcon type="notOwner" />
|
||||
<Title size="xs">To use apps, you must be an owner of this Safe</Title>
|
||||
</Centered>
|
||||
</OwnerDisclaimer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<IframeWrapper>
|
||||
{appIsLoading && (
|
||||
<LoadingContainer>
|
||||
<Loader size="md" />
|
||||
</LoadingContainer>
|
||||
<AppWrapper>
|
||||
<Menu>
|
||||
<Breadcrumb />
|
||||
{isAppDeletable && (
|
||||
<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"
|
||||
id={`iframe-${selectedApp.name}`}
|
||||
ref={iframeRef}
|
||||
src={selectedApp.url}
|
||||
title={selectedApp.name}
|
||||
onLoad={onIframeLoad}
|
||||
|
||||
<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}
|
||||
/>
|
||||
</IframeWrapper>
|
||||
</AppWrapper>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
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 { loadFromStorage, saveToStorage } from 'src/utils/storage'
|
||||
import { getAppInfoFromUrl, staticAppsList } from '../utils'
|
||||
import { SafeApp, StoredSafeApp } from '../types'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { loadFromStorage } from 'src/utils/storage'
|
||||
import { APPS_STORAGE_KEY, getAppInfoFromUrl, getEmptySafeApp, staticAppsList } from '../utils'
|
||||
import { SafeApp, StoredSafeApp, SAFE_APP_FETCH_STATUS } from '../types.d'
|
||||
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 = {
|
||||
appList: SafeApp[]
|
||||
loadingAppList: boolean
|
||||
onAppToggle: onAppToggleHandler
|
||||
onAppAdded: onAppAddedHandler
|
||||
onAppRemoved: onAppRemovedHandler
|
||||
}
|
||||
|
||||
const useAppList = (): UseAppListReturnType => {
|
||||
const [appList, setAppList] = useState<SafeApp[]>([])
|
||||
const [loadingAppList, setLoadingAppList] = useState<boolean>(true)
|
||||
|
||||
// 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(() => {
|
||||
const loadApps = async () => {
|
||||
// recover apps from storage:
|
||||
// * third-party apps added by the user
|
||||
// * disabled status for both static and third-party apps
|
||||
const persistedAppList = (await loadFromStorage<StoredSafeApp[]>(APPS_STORAGE_KEY)) || []
|
||||
let list: (StoredSafeApp & { isDeletable: boolean; networks?: number[] })[] = persistedAppList.map((a) => ({
|
||||
...a,
|
||||
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 })
|
||||
}
|
||||
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()))
|
||||
})
|
||||
|
||||
// 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(
|
||||
async (appId, enabled) => {
|
||||
// update in-memory list
|
||||
const appListCopy = [...appList]
|
||||
// backward compatibility. In a previous implementation a safe app could be disabled, that state was
|
||||
// persisted in the storage.
|
||||
const customApps = persistedAppList.filter(
|
||||
(persistedApp) => !staticAppsList.some((staticApp) => staticApp.url === persistedApp.url),
|
||||
)
|
||||
|
||||
const app = appListCopy.find((a) => a.id === appId)
|
||||
if (!app) {
|
||||
return
|
||||
}
|
||||
app.disabled = !enabled
|
||||
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(),
|
||||
}))
|
||||
|
||||
setAppList(appListCopy)
|
||||
setAppList(apps)
|
||||
|
||||
// update storage list
|
||||
const listToPersist: StoredSafeApp[] = appListCopy.map(({ url, disabled }) => ({ url, disabled }))
|
||||
saveToStorage(APPS_STORAGE_KEY, listToPersist)
|
||||
},
|
||||
[appList],
|
||||
)
|
||||
apps.forEach((app) => getAppInfoFromUrl(app.url).then(fetchAppCallback))
|
||||
}
|
||||
|
||||
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],
|
||||
)
|
||||
if (!appList.length) {
|
||||
loadApps()
|
||||
}
|
||||
}, [appList])
|
||||
|
||||
return {
|
||||
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 useLegalConsent = (): { consentReceived: boolean; onConsentReceipt: () => void } => {
|
||||
const [consentReceived, setConsentReceived] = useState<boolean>(false)
|
||||
const useLegalConsent = (): { consentReceived: boolean | undefined; onConsentReceipt: () => void } => {
|
||||
const [consentReceived, setConsentReceived] = useState<boolean | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
const checkLegalDisclaimer = async () => {
|
||||
|
@ -12,6 +12,8 @@ const useLegalConsent = (): { consentReceived: boolean; onConsentReceipt: () =>
|
|||
|
||||
if (storedConsentReceived) {
|
||||
setConsentReceived(true)
|
||||
} else {
|
||||
setConsentReceived(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,237 +1,23 @@
|
|||
import React, { useCallback, useEffect, useRef, useState, useMemo } 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 React from 'react'
|
||||
|
||||
import ManageApps from './components/ManageApps'
|
||||
import AppFrame from './components/AppFrame'
|
||||
import { useAppList } from './hooks/useAppList'
|
||||
import { SafeApp } from './types.d'
|
||||
import AppsList from './components/AppsList'
|
||||
|
||||
import LCL from 'src/components/ListContentLayout'
|
||||
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'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
const centerCSS = css`
|
||||
display: flex;
|
||||
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 useQuery = () => {
|
||||
return new URLSearchParams(useLocation().search)
|
||||
}
|
||||
|
||||
const INITIAL_CONFIRM_TX_MODAL_STATE: ConfirmTransactionModalState = {
|
||||
isOpen: false,
|
||||
txs: [],
|
||||
requestId: undefined,
|
||||
params: undefined,
|
||||
}
|
||||
|
||||
const NETWORK_NAME = getNetworkName()
|
||||
|
||||
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)
|
||||
const [selectedAppId, setSelectedAppId] = useState<string>()
|
||||
const [confirmTransactionModal, setConfirmTransactionModal] = useState<ConfirmTransactionModalState>(
|
||||
INITIAL_CONFIRM_TX_MODAL_STATE,
|
||||
)
|
||||
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,
|
||||
)
|
||||
if (appUrl) {
|
||||
return <AppFrame appUrl={appUrl} />
|
||||
} else {
|
||||
return <AppsList />
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
@ -1,15 +1,20 @@
|
|||
export enum SAFE_APP_FETCH_STATUS {
|
||||
LOADING = 'LOADING',
|
||||
SUCCESS = 'SUCCESS',
|
||||
ERROR = 'ERROR',
|
||||
}
|
||||
|
||||
export type SafeApp = {
|
||||
id: string
|
||||
url: string
|
||||
name: string
|
||||
iconUrl: string
|
||||
disabled?: boolean
|
||||
isDeletable?: boolean
|
||||
error: boolean
|
||||
description: string
|
||||
error: boolean
|
||||
fetchStatus: SAFE_APP_FETCH_STATUS
|
||||
}
|
||||
|
||||
export type StoredSafeApp = {
|
||||
url: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import axios from 'axios'
|
||||
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 { 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'
|
||||
|
||||
export const APPS_STORAGE_KEY = 'APPS_STORAGE_KEY'
|
||||
|
||||
const removeLastTrailingSlash = (url) => {
|
||||
if (url.substr(-1) === '/') {
|
||||
return url.substr(0, url.length - 1)
|
||||
|
@ -16,7 +18,12 @@ const removeLastTrailingSlash = (url) => {
|
|||
}
|
||||
|
||||
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
|
||||
{
|
||||
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 {
|
||||
return JSON.parse(origin)
|
||||
} catch (error) {
|
||||
|
@ -132,9 +139,25 @@ export const isAppManifestValid = (appInfo: SafeApp): boolean =>
|
|||
// no `error` (or `error` undefined)
|
||||
!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(
|
||||
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) {
|
||||
return res
|
||||
|
@ -161,6 +184,7 @@ export const getAppInfoFromUrl = memoize(
|
|||
...appInfo.data,
|
||||
id: JSON.stringify({ url: res.url, name: appInfo.data.name.substring(0, remainingSpace) }),
|
||||
error: false,
|
||||
loadingStatus: SAFE_APP_FETCH_STATUS.SUCCESS,
|
||||
}
|
||||
|
||||
if (appInfo.data.iconPath) {
|
||||
|
@ -196,10 +220,10 @@ export const getIpfsLinkFromEns = memoize(
|
|||
)
|
||||
|
||||
export const uniqueApp = (appList: SafeApp[]) => (url: string): string | undefined => {
|
||||
const newUrl = new URL(url)
|
||||
const exists = appList.some((a) => {
|
||||
try {
|
||||
const currentUrl = new URL(a.url)
|
||||
const newUrl = new URL(url)
|
||||
return currentUrl.href === newUrl.href
|
||||
} catch (error) {
|
||||
console.error('There was a problem trying to validate the URL existence.', error.message)
|
||||
|
|
|
@ -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 { useSelector } from 'react-redux'
|
||||
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 { providerNameSelector } from 'src/logic/wallets/store/selectors'
|
||||
import { safeFeaturesEnabledSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
|
||||
import { AppReduxState } from 'src/store'
|
||||
import { wrapInSuspense } from 'src/utils/wrapInSuspense'
|
||||
import { SAFELIST_ADDRESS } from 'src/routes/routes'
|
||||
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 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 Container = (): React.ReactElement => {
|
||||
const featuresEnabled = useSelector(safeFeaturesEnabledSelector)
|
||||
const [modal, setModal] = useState({
|
||||
isOpen: false,
|
||||
title: null,
|
||||
|
@ -36,23 +37,20 @@ const Container = (): React.ReactElement => {
|
|||
|
||||
const safeAddress = useSelector(safeParamAddressFromStateSelector)
|
||||
const provider = useSelector(providerNameSelector)
|
||||
const featuresEnabled = useSelector<AppReduxState, FEATURES[] | undefined>(
|
||||
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))
|
||||
const matchSafeWithAddress = useRouteMatch({ path: `${SAFELIST_ADDRESS}/:safeAddress` })
|
||||
|
||||
if (!safeAddress) {
|
||||
return <NoSafe provider={provider} text="Safe not found" />
|
||||
}
|
||||
|
||||
if (!featuresEnabled) {
|
||||
return (
|
||||
<LoadingContainer>
|
||||
<Loader size="md" />
|
||||
</LoadingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const closeGenericModal = () => {
|
||||
if (modal.onClose) {
|
||||
modal.onClose?.()
|
||||
|
@ -84,13 +82,12 @@ const Container = (): React.ReactElement => {
|
|||
exact
|
||||
path={`${matchSafeWithAddress?.path}/apps`}
|
||||
render={({ history }) => {
|
||||
if (!safeAppsEnabled) {
|
||||
if (!featuresEnabled.includes(FEATURES.SAFE_APPS)) {
|
||||
history.push(`${matchSafeWithAddress?.url}/balances`)
|
||||
}
|
||||
return wrapInSuspense(<Apps />, null)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Route
|
||||
exact
|
||||
path={`${matchSafeWithAddress?.path}/settings`}
|
||||
|
|
|
@ -1522,9 +1522,9 @@
|
|||
solc "0.5.14"
|
||||
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"
|
||||
resolved "https://github.com/gnosis/safe-react-components.git#03ff672d6f73366297986d58631f9582fe2ed4a3"
|
||||
resolved "https://github.com/gnosis/safe-react-components.git#ff29c3ccfd391142b92edefba0f773aaf16f1799"
|
||||
dependencies:
|
||||
classnames "^2.2.6"
|
||||
polished "^3.6.7"
|
||||
|
|
Loading…
Reference in New Issue