diff --git a/package.json b/package.json index 39ca85ca..866c77fd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg b/src/assets/icons/apps.svg similarity index 100% rename from src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg rename to src/assets/icons/apps.svg diff --git a/src/components/AppLayout/Sidebar/index.tsx b/src/components/AppLayout/Sidebar/index.tsx index ff2b29d3..b01876a8 100644 --- a/src/components/AppLayout/Sidebar/index.tsx +++ b/src/components/AppLayout/Sidebar/index.tsx @@ -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; diff --git a/src/components/AppLayout/Sidebar/useSidebarItems.tsx b/src/components/AppLayout/Sidebar/useSidebarItems.tsx index 4cf8e76b..c31b4cbe 100644 --- a/src/components/AppLayout/Sidebar/useSidebarItems.tsx +++ b/src/components/AppLayout/Sidebar/useSidebarItems.tsx @@ -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 } diff --git a/src/components/AppLayout/index.tsx b/src/components/AppLayout/index.tsx index 63c2c1ba..1426788a 100644 --- a/src/components/AppLayout/index.tsx +++ b/src/components/AppLayout/index.tsx @@ -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}; ` diff --git a/src/components/LoaderContainer/index.tsx b/src/components/LoaderContainer/index.tsx new file mode 100644 index 00000000..3b706e04 --- /dev/null +++ b/src/components/LoaderContainer/index.tsx @@ -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; +` diff --git a/src/logic/safe/utils/safeVersion.ts b/src/logic/safe/utils/safeVersion.ts index 00d9884d..1c085225 100644 --- a/src/logic/safe/utils/safeVersion.ts +++ b/src/logic/safe/utils/safeVersion.ts @@ -36,17 +36,20 @@ export const safeNeedsUpdate = (currentVersion?: string, latestVersion?: string) export const getCurrentSafeVersion = (gnosisSafeInstance: GnosisSafe): Promise => 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 { diff --git a/src/routes/safe/components/Apps/AddAppForm/SubmitButtonStatus.tsx b/src/routes/safe/components/Apps/AddAppForm/SubmitButtonStatus.tsx deleted file mode 100644 index b79a888f..00000000 --- a/src/routes/safe/components/Apps/AddAppForm/SubmitButtonStatus.tsx +++ /dev/null @@ -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 diff --git a/src/routes/safe/components/Apps/assets/addApp.svg b/src/routes/safe/components/Apps/assets/addApp.svg new file mode 100644 index 00000000..76500778 --- /dev/null +++ b/src/routes/safe/components/Apps/assets/addApp.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/routes/safe/components/Apps/AddAppForm/AppAgreement.tsx b/src/routes/safe/components/Apps/components/AddAppForm/AppAgreement.tsx similarity index 100% rename from src/routes/safe/components/Apps/AddAppForm/AppAgreement.tsx rename to src/routes/safe/components/Apps/components/AddAppForm/AppAgreement.tsx diff --git a/src/routes/safe/components/Apps/AddAppForm/AppUrl.tsx b/src/routes/safe/components/Apps/components/AddAppForm/AppUrl.tsx similarity index 100% rename from src/routes/safe/components/Apps/AddAppForm/AppUrl.tsx rename to src/routes/safe/components/Apps/components/AddAppForm/AppUrl.tsx diff --git a/src/routes/safe/components/Apps/components/AddAppForm/FormButtons.tsx b/src/routes/safe/components/Apps/components/AddAppForm/FormButtons.tsx new file mode 100644 index 00000000..fbdcace7 --- /dev/null +++ b/src/routes/safe/components/Apps/components/AddAppForm/FormButtons.tsx @@ -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 ( + <> + + + + + Add + + + + ) +} + +export default FormButtons diff --git a/src/routes/safe/components/Apps/AddAppForm/index.tsx b/src/routes/safe/components/Apps/components/AddAppForm/index.tsx similarity index 53% rename from src/routes/safe/components/Apps/AddAppForm/index.tsx rename to src/routes/safe/components/Apps/components/AddAppForm/index.tsx index 292f522f..0841e27a 100644 --- a/src/routes/safe/components/Apps/AddAppForm/index.tsx +++ b/src/routes/safe/components/Apps/components/AddAppForm/index.tsx @@ -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(APP_INFO) +const AddApp = ({ appList, closeModal }: AddAppProps): ReactElement => { + const [appInfo, setAppInfo] = useState(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 ( - + {() => ( <> - Add custom app - + {/* Fetch app from url and return a SafeApp */} @@ -81,7 +78,7 @@ const AddApp = ({ appList, closeModal, formId, onAppAdded, setIsSubmitDisabled } - + )} diff --git a/src/routes/safe/components/Apps/components/AppCard/index.stories.tsx b/src/routes/safe/components/Apps/components/AppCard/index.stories.tsx new file mode 100644 index 00000000..32020d3e --- /dev/null +++ b/src/routes/safe/components/Apps/components/AppCard/index.stories.tsx @@ -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 => + +export const AddCustomApp = (): React.ReactElement => ( + +) + +export const LoadedApp = (): React.ReactElement => ( + +) diff --git a/src/routes/safe/components/Apps/components/AppCard/index.tsx b/src/routes/safe/components/Apps/components/AppCard/index.tsx new file mode 100644 index 00000000..a28053d0 --- /dev/null +++ b/src/routes/safe/components/Apps/components/AppCard/index.tsx @@ -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): 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 ( + + + + + + + ) + } + + return ( + + + + {name && {name}} + + {description && {description} } + + {buttonText && ( + + )} + + ) +} + +export default AppCard diff --git a/src/routes/safe/components/Apps/components/AppCard/skeleton.tsx b/src/routes/safe/components/Apps/components/AppCard/skeleton.tsx new file mode 100644 index 00000000..92ef8718 --- /dev/null +++ b/src/routes/safe/components/Apps/components/AppCard/skeleton.tsx @@ -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; +` diff --git a/src/routes/safe/components/Apps/components/AppFrame.tsx b/src/routes/safe/components/Apps/components/AppFrame.tsx index 9d021a81..fd186106 100644 --- a/src/routes/safe/components/Apps/components/AppFrame.tsx +++ b/src/routes/safe/components/Apps/components/AppFrame.tsx @@ -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(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(null) + const [confirmTransactionModal, setConfirmTransactionModal] = useState( + INITIAL_CONFIRM_TX_MODAL_STATE, + ) + const [appIsLoading, setAppIsLoading] = useState(true) + const [safeApp, setSafeApp] = useState() + const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false) + const [isAppDeletable, setIsAppDeletable] = useState() + const redirectToBalance = () => history.push(`${SAFELIST_ADDRESS}/${safeAddress}/balances`) - if (!selectedApp) { - return
+ 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(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 ( + + + + ) + } + + if (consentReceived === false) { return } - if (network === 'UNKNOWN' || !granted) { + if (NETWORK_NAME === 'UNKNOWN' || !granted) { return ( - + To use apps, you must be an owner of this Safe - + ) } return ( - - {appIsLoading && ( - - - + + + + {isAppDeletable && ( + + Remove app + + )} + + + + {appIsLoading && ( + + + + )} + + + + + {isRemoveModalOpen && ( + + Remove app + + } + body={This action will remove {safeApp.name} from the interface} + footer={ + + } + onClose={closeRemoveModal} + /> )} - - + ) -}) +} export default AppFrame diff --git a/src/routes/safe/components/Apps/components/AppsList.tsx b/src/routes/safe/components/Apps/components/AppsList.tsx new file mode 100644 index 00000000..0681290b --- /dev/null +++ b/src/routes/safe/components/Apps/components/AppsList.tsx @@ -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(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 ( + + + + ) + } + + return ( + + + {/* TODO: Add navigation breadcrumb. Empty for now to give some top margin */} + + + + + + + + {appList + .filter((a) => a.fetchStatus !== SAFE_APP_FETCH_STATUS.ERROR) + .map((a) => ( + + ))} + + + + + + {isAddAppModalOpen && ( + } + onClose={closeAddAppModal} + /> + )} + + ) +} + +export default AppsList diff --git a/src/routes/safe/components/Apps/components/ManageApps.tsx b/src/routes/safe/components/Apps/components/ManageApps.tsx deleted file mode 100644 index 26ccb494..00000000 --- a/src/routes/safe/components/Apps/components/ManageApps.tsx +++ /dev/null @@ -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 - 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 = ( - - ) - - return ( - <> - - Manage Apps - - {isOpen && ( - - )} - - ) -} - -export default ManageApps diff --git a/src/routes/safe/components/Apps/hooks/useAppList.ts b/src/routes/safe/components/Apps/hooks/useAppList.ts index c2f13c13..e005a4dd 100644 --- a/src/routes/safe/components/Apps/hooks/useAppList.ts +++ b/src/routes/safe/components/Apps/hooks/useAppList.ts @@ -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 -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([]) - const [loadingAppList, setLoadingAppList] = useState(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(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, } } diff --git a/src/routes/safe/components/Apps/hooks/useLegalConsent.ts b/src/routes/safe/components/Apps/hooks/useLegalConsent.ts index ec224c87..c9bbf86a 100644 --- a/src/routes/safe/components/Apps/hooks/useLegalConsent.ts +++ b/src/routes/safe/components/Apps/hooks/useLegalConsent.ts @@ -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(false) +const useLegalConsent = (): { consentReceived: boolean | undefined; onConsentReceipt: () => void } => { + const [consentReceived, setConsentReceived] = useState() useEffect(() => { const checkLegalDisclaimer = async () => { @@ -12,6 +12,8 @@ const useLegalConsent = (): { consentReceived: boolean; onConsentReceipt: () => if (storedConsentReceived) { setConsentReceived(true) + } else { + setConsentReceived(false) } } diff --git a/src/routes/safe/components/Apps/index.tsx b/src/routes/safe/components/Apps/index.tsx index b4c75d03..06d9c7e7 100644 --- a/src/routes/safe/components/Apps/index.tsx +++ b/src/routes/safe/components/Apps/index.tsx @@ -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(true) - const [selectedAppId, setSelectedAppId] = useState() - const [confirmTransactionModal, setConfirmTransactionModal] = useState( - INITIAL_CONFIRM_TX_MODAL_STATE, - ) - const iframeRef = useRef(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 + } else { + return } - - 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 ( - - - - ) - } - - return ( - <> - - - - {enabledApps.length ? ( - - - - - - - - - ) : ( - - No Apps Enabled - - )} - - - - - - ) } export default Apps diff --git a/src/routes/safe/components/Apps/types.d.ts b/src/routes/safe/components/Apps/types.d.ts index db8b49d1..898a84a2 100644 --- a/src/routes/safe/components/Apps/types.d.ts +++ b/src/routes/safe/components/Apps/types.d.ts @@ -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 } diff --git a/src/routes/safe/components/Apps/utils.ts b/src/routes/safe/components/Apps/utils.ts index a293abb9..895618fe 100644 --- a/src/routes/safe/components/Apps/utils.ts +++ b/src/routes/safe/components/Apps/utils.ts @@ -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 = [ // 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 | 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 => { - 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) diff --git a/src/routes/safe/components/assets/AppsIcon.tsx b/src/routes/safe/components/assets/AppsIcon.tsx deleted file mode 100644 index 9bd1639d..00000000 --- a/src/routes/safe/components/assets/AppsIcon.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react' - -export const AppsIcon = () => ( - - - - - - - -) diff --git a/src/routes/safe/container/index.tsx b/src/routes/safe/container/index.tsx index f0008b30..7149a744 100644 --- a/src/routes/safe/container/index.tsx +++ b/src/routes/safe/container/index.tsx @@ -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( - 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 } + if (!featuresEnabled) { + return ( + + + + ) + } + 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(, null) }} /> -