diff --git a/package.json b/package.json
index 486b016eb8..b01e9b9d7d 100644
--- a/package.json
+++ b/package.json
@@ -82,7 +82,7 @@
"@gnosis.pm/safe-deployments": "^1.8.0",
"@gnosis.pm/safe-modules-deployments": "^1.0.0",
"@gnosis.pm/safe-react-components": "^1.1.2",
- "@gnosis.pm/safe-react-gateway-sdk": "^2.10.2",
+ "@gnosis.pm/safe-react-gateway-sdk": "^2.10.3",
"@gnosis.pm/safe-web3-lib": "^1.0.0",
"@material-ui/core": "^4.12.3",
"@material-ui/icons": "^4.11.0",
diff --git a/src/assets/icons/explore.svg b/src/assets/icons/explore.svg
new file mode 100644
index 0000000000..3a85614508
--- /dev/null
+++ b/src/assets/icons/explore.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/components/AppLayout/Sidebar/useSidebarItems.tsx b/src/components/AppLayout/Sidebar/useSidebarItems.tsx
index 0e8459619d..18a0c623c9 100644
--- a/src/components/AppLayout/Sidebar/useSidebarItems.tsx
+++ b/src/components/AppLayout/Sidebar/useSidebarItems.tsx
@@ -114,6 +114,11 @@ const useSidebarItems = (): ListItemType[] => {
].filter(Boolean)
return [
+ makeEntryItem({
+ label: 'Home',
+ iconType: 'home',
+ href: currentSafeRoutes.DASHBOARD,
+ }),
makeEntryItem({
label: 'Assets',
iconType: 'assets',
diff --git a/src/components/AppLayout/index.tsx b/src/components/AppLayout/index.tsx
index 2db6dd3b89..57e7ad6274 100644
--- a/src/components/AppLayout/index.tsx
+++ b/src/components/AppLayout/index.tsx
@@ -95,7 +95,7 @@ const ContentWrapper = styled.div`
flex-direction: column;
overflow-x: auto;
- padding: 0 16px;
+ padding: 8px 24px;
> :nth-child(1) {
flex-grow: 1;
diff --git a/src/components/Dashboard/FeaturedApps/FeaturedApps.tsx b/src/components/Dashboard/FeaturedApps/FeaturedApps.tsx
new file mode 100644
index 0000000000..ebd14916f1
--- /dev/null
+++ b/src/components/Dashboard/FeaturedApps/FeaturedApps.tsx
@@ -0,0 +1,69 @@
+import { ReactElement, useMemo } from 'react'
+import { useAppList } from 'src/routes/safe/components/Apps/hooks/appList/useAppList'
+import { Text } from '@gnosis.pm/safe-react-components'
+import { Link } from 'react-router-dom'
+import { useSelector } from 'react-redux'
+import { Box, Grid } from '@material-ui/core'
+
+import styled from 'styled-components'
+import { getSafeAppUrl, SafeRouteParams } from 'src/routes/routes'
+import { currentSafe } from 'src/logic/safe/store/selectors'
+import { getShortName } from 'src/config'
+import { Card, WidgetBody, WidgetContainer, WidgetTitle } from 'src/components/Dashboard/styled'
+
+export const FEATURED_APPS_TAG = 'dashboard-widgets'
+
+const StyledImage = styled.img`
+ width: 64px;
+ height: 64px;
+`
+
+const StyledLink = styled(Link)`
+ margin-top: 10px;
+ text-decoration: none;
+`
+
+export const FeaturedApps = (): ReactElement | null => {
+ const { allApps, isLoading } = useAppList()
+ const { address } = useSelector(currentSafe) ?? {}
+ const featuredApps = useMemo(() => allApps.filter((app) => app.tags?.includes(FEATURED_APPS_TAG)), [allApps])
+
+ const routesSlug: SafeRouteParams = {
+ shortName: getShortName(),
+ safeAddress: address,
+ }
+
+ if (!featuredApps.length && !isLoading) return null
+
+ return (
+
+
+ Connect & Transact
+
+ {featuredApps.map((app) => {
+ const appRoute = getSafeAppUrl(app.url, routesSlug)
+ return (
+
+
+
+
+
+
+
+ {app.description}
+
+
+
+ Use {app.name}
+
+
+
+
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/src/components/Dashboard/Overview/Overview.tsx b/src/components/Dashboard/Overview/Overview.tsx
new file mode 100644
index 0000000000..00d94d99b7
--- /dev/null
+++ b/src/components/Dashboard/Overview/Overview.tsx
@@ -0,0 +1,164 @@
+import { ReactElement } from 'react'
+import { useSelector } from 'react-redux'
+import styled from 'styled-components'
+import { Text, Identicon } from '@gnosis.pm/safe-react-components'
+import { useHistory } from 'react-router-dom'
+import { Box, Grid } from '@material-ui/core'
+import { Skeleton } from '@material-ui/lab'
+
+import { currentSafeLoaded, currentSafeWithNames } from 'src/logic/safe/store/selectors'
+import PrefixedEthHashInfo from 'src/components/PrefixedEthHashInfo'
+import { primaryLite, primaryActive, smallFontSize, md, lg } from 'src/theme/variables'
+import NetworkLabel from 'src/components/NetworkLabel/NetworkLabel'
+import { nftLoadedSelector, nftTokensSelector } from 'src/logic/collectibles/store/selectors'
+import { Card, DashboardTitle } from 'src/components/Dashboard/styled'
+import { WidgetBody, WidgetContainer } from 'src/components/Dashboard/styled'
+import Button from 'src/components/layout/Button'
+import { generateSafeRoute, SAFE_ROUTES } from 'src/routes/routes'
+import { currentChainId } from 'src/logic/config/store/selectors'
+import { getChainById } from 'src/config'
+
+const IdenticonContainer = styled.div`
+ position: relative;
+ margin-bottom: ${md};
+`
+
+const SafeThreshold = styled.div`
+ position: absolute;
+ left: -6px;
+ top: -6px;
+ background: ${primaryLite};
+ color: ${primaryActive};
+ font-size: ${smallFontSize};
+ font-weight: bold;
+ border-radius: 100%;
+ padding: 4px;
+ z-index: 2;
+ min-width: 24px;
+ min-height: 24px;
+ box-sizing: border-box;
+`
+
+const StyledText = styled(Text)`
+ margin-top: 8px;
+ font-size: 24px;
+ font-weight: bold;
+`
+
+const NetworkLabelContainer = styled.div`
+ position: absolute;
+ top: ${lg};
+ right: ${lg};
+
+ & span {
+ bottom: auto;
+ }
+`
+
+const ValueSkeleton =
+
+const SkeletonOverview = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Tokens
+
+ {ValueSkeleton}
+
+
+
+ NFTs
+
+ {ValueSkeleton}
+
+
+
+)
+
+const Overview = (): ReactElement => {
+ const { address, name, owners, threshold, balances } = useSelector(currentSafeWithNames)
+ const chainId = useSelector(currentChainId)
+ const { shortName } = getChainById(chainId)
+ const loaded = useSelector(currentSafeLoaded)
+ const nftTokens = useSelector(nftTokensSelector)
+ const nftLoaded = useSelector(nftLoadedSelector)
+ const history = useHistory()
+
+ const handleOpenAssets = (): void => {
+ history.push(generateSafeRoute(SAFE_ROUTES.ASSETS_BALANCES, { safeAddress: address, shortName }))
+ }
+
+ return (
+
+ Dashboard
+
+ {!loaded ? (
+ SkeletonOverview
+ ) : (
+
+
+
+
+
+ {threshold}/{owners.length}
+
+
+
+
+
+ {name}
+
+
+
+
+
+
+
+
+
+
+
+ Tokens
+
+ {balances.length}
+
+
+
+ NFTs
+
+ {nftTokens && {nftLoaded ? nftTokens.length : ValueSkeleton}}
+
+
+
+
+
+
+
+
+ )}
+
+
+ )
+}
+
+export default Overview
diff --git a/src/components/Dashboard/PendingTxs/PendingTxListItem.tsx b/src/components/Dashboard/PendingTxs/PendingTxListItem.tsx
new file mode 100644
index 0000000000..52c1b71652
--- /dev/null
+++ b/src/components/Dashboard/PendingTxs/PendingTxListItem.tsx
@@ -0,0 +1,90 @@
+import { ReactElement } from 'react'
+import { Text } from '@gnosis.pm/safe-react-components'
+import { TransactionSummary } from '@gnosis.pm/safe-react-gateway-sdk'
+import { Link } from 'react-router-dom'
+import ChevronRightIcon from '@material-ui/icons/ChevronRight'
+import styled from 'styled-components'
+
+import { useAssetInfo } from 'src/routes/safe/components/Transactions/TxList/hooks/useAssetInfo'
+import { useKnownAddress } from 'src/routes/safe/components/Transactions/TxList/hooks/useKnownAddress'
+import { useTransactionType } from 'src/routes/safe/components/Transactions/TxList/hooks/useTransactionType'
+import { getTxTo } from 'src/routes/safe/components/Transactions/TxList/utils'
+import { boldFont, grey400, primary200, smallFontSize } from 'src/theme/variables'
+import { isMultisigExecutionInfo } from 'src/logic/safe/store/models/types/gateway.d'
+import Spacer from 'src/components/Spacer'
+import { CustomIconText } from 'src/components/CustomIconText'
+import { TxInfo } from 'src/routes/safe/components/Transactions/TxList/TxCollapsed'
+import { EMPTY_DATA } from 'src/logic/wallets/ethTransactions'
+
+const TransactionToConfirm = styled(Link)`
+ width: 100%;
+ display: grid;
+ align-items: center;
+ grid-template-columns: 36px 1fr 1fr auto;
+ gap: 4px;
+ margin: 0 auto;
+ padding: 8px 24px;
+ text-decoration: none;
+ background-color: ${({ theme }) => theme.colors.white};
+ border: 2px solid ${grey400};
+ color: ${({ theme }) => theme.colors.text};
+ border-radius: 8px;
+ box-sizing: border-box;
+`
+
+const StyledConfirmationsCount = styled.div`
+ padding: 8px 12px;
+ border-radius: 8px;
+ background-color: ${primary200};
+ font-weight: ${boldFont};
+ font-size: ${smallFontSize};
+`
+
+const TxConfirmations = styled.div`
+ display: flex;
+ align-items: center;
+ margin-left: auto;
+
+ & svg {
+ margin-left: 8px;
+ }
+`
+
+type PendingTxType = {
+ transaction: TransactionSummary
+ url: string
+}
+
+const PendingTx = ({ transaction, url }: PendingTxType): ReactElement => {
+ const info = useAssetInfo(transaction.txInfo)
+ const type = useTransactionType(transaction)
+ const toAddress = getTxTo(transaction)
+ const toInfo = useKnownAddress(toAddress)
+
+ return (
+
+
+ {isMultisigExecutionInfo(transaction.executionInfo) && transaction.executionInfo.nonce}
+
+
+ {info ? : }
+
+ {isMultisigExecutionInfo(transaction.executionInfo) ? (
+
+ {`${transaction.executionInfo.confirmationsSubmitted}/${transaction.executionInfo.confirmationsRequired}`}
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
+
+export default PendingTx
diff --git a/src/components/Dashboard/PendingTxs/PendingTxsList.tsx b/src/components/Dashboard/PendingTxs/PendingTxsList.tsx
new file mode 100644
index 0000000000..47ccd40978
--- /dev/null
+++ b/src/components/Dashboard/PendingTxs/PendingTxsList.tsx
@@ -0,0 +1,127 @@
+import { ReactElement, useMemo, useState } from 'react'
+import styled from 'styled-components'
+import { useSelector } from 'react-redux'
+import Skeleton from '@material-ui/lab/Skeleton/Skeleton'
+import { Link } from 'react-router-dom'
+import { Text } from '@gnosis.pm/safe-react-components'
+import ChevronRightIcon from '@material-ui/icons/ChevronRight'
+import { Box } from '@material-ui/core'
+
+import { Transaction } from 'src/logic/safe/store/models/types/gateway.d'
+import { currentChainId } from 'src/logic/config/store/selectors'
+import { generateSafeRoute, SAFE_ROUTES } from 'src/routes/routes'
+import { getChainById } from 'src/config'
+import PendingTxListItem from 'src/components/Dashboard/PendingTxs/PendingTxListItem'
+import { currentSafe } from 'src/logic/safe/store/selectors'
+import { pendingTransactions } from 'src/logic/safe/store/selectors/gatewayTransactions'
+import { Card, WidgetBody, WidgetContainer, WidgetTitle } from 'src/components/Dashboard/styled'
+import { xs } from 'src/theme/variables'
+import NoTransactionsImage from 'src/routes/safe/components/Transactions/TxList/assets/no-transactions.svg'
+import Img from 'src/components/layout/Img'
+
+const SkeletonWrapper = styled.div`
+ border-radius: 8px;
+ overflow: hidden;
+`
+
+const StyledList = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 12px;
+ width: 100%;
+ height: 100%;
+`
+
+const StyledWidgetTitle = styled.div`
+ display: flex;
+ justify-content: space-between;
+`
+
+const StyledLink = styled(Link)`
+ text-decoration: none;
+ color: ${({ theme }) => theme.colors.primary};
+ font-weight: bold;
+ display: flex;
+ align-items: center;
+ gap: ${xs};
+ margin-bottom: 10px;
+ padding-right: 26px;
+`
+
+const EmptyState = (
+
+
+
+ This Safe has no queued transactions
+
+
+)
+
+const PendingTxsList = ({ size = 5 }: { size?: number }): ReactElement | null => {
+ const { address, loaded } = useSelector(currentSafe)
+ const chainId = useSelector(currentChainId)
+ const queueTxns = useSelector(pendingTransactions)
+ const { shortName } = getChainById(chainId)
+ const url = generateSafeRoute(SAFE_ROUTES.TRANSACTIONS_QUEUE, { safeAddress: address, shortName })
+ const [totalQueuedTxs, setTotalQueuedTxs] = useState()
+
+ const queuedTxsToDisplay: Transaction[] = useMemo(() => {
+ if (!queueTxns) return []
+
+ const allQueuedTransactions = Object.values(queueTxns.next).concat(Object.values(queueTxns.queued))
+ setTotalQueuedTxs(allQueuedTransactions.length)
+
+ return (
+ allQueuedTransactions
+ // take the most recent tx in a group of txns with the same nonce
+ .map((group: Transaction[]) => group.reduce((acc, tx) => (tx.timestamp > acc.timestamp ? tx : acc), group[0]))
+ .slice(0, size)
+ )
+ }, [queueTxns, size])
+
+ const LoadingState = useMemo(
+ () => (
+
+ {Array.from(Array(size).keys()).map((key) => (
+
+
+
+ ))}
+
+ ),
+ [size],
+ )
+
+ const ResultState = useMemo(
+ () => (
+
+ {queuedTxsToDisplay.map((transaction) => (
+
+ ))}
+
+ ),
+ [queuedTxsToDisplay, url],
+ )
+
+ const getWidgetBody = () => {
+ if (!loaded) return LoadingState
+ if (!queuedTxsToDisplay.length) return EmptyState
+ return ResultState
+ }
+
+ return (
+
+
+ Transaction Queue {totalQueuedTxs ? ` (${totalQueuedTxs})` : ''}
+
+ View All
+
+
+
+ {getWidgetBody()}
+
+ )
+}
+
+export default PendingTxsList
diff --git a/src/components/Dashboard/SafeApps/Card.tsx b/src/components/Dashboard/SafeApps/Card.tsx
new file mode 100644
index 0000000000..dd06a7f22b
--- /dev/null
+++ b/src/components/Dashboard/SafeApps/Card.tsx
@@ -0,0 +1,97 @@
+import { ReactElement, SyntheticEvent, useCallback } from 'react'
+import styled from 'styled-components'
+import { Text } from '@gnosis.pm/safe-react-components'
+import { Box, IconButton } from '@material-ui/core'
+import { Link, generatePath } from 'react-router-dom'
+import { Icon } from '@gnosis.pm/safe-react-components'
+
+import { GENERIC_APPS_ROUTE } from 'src/routes/routes'
+import { md, lg } from 'src/theme/variables'
+import appsIconSvg from 'src/assets/icons/apps.svg'
+
+export const CARD_HEIGHT = 200
+export const CARD_PADDING = 24
+
+const StyledLink = styled(Link)`
+ display: block;
+ text-decoration: none;
+ color: black;
+ height: 100%;
+ position: relative;
+ background-color: white;
+ border-radius: 8px;
+ padding: ${CARD_PADDING}px;
+ box-sizing: border-box;
+`
+
+const StyledLogo = styled.img`
+ display: block;
+ width: auto;
+ height: 60px;
+ margin-bottom: ${md};
+`
+
+const IconBtn = styled(IconButton)`
+ &.MuiButtonBase-root {
+ position: absolute;
+ top: ${lg};
+ right: ${lg};
+ z-index: 10;
+ padding: 8px;
+ }
+
+ svg {
+ width: 16px;
+ height: 16px;
+ padding-left: 2px;
+ }
+`
+
+type CardProps = {
+ name: string
+ description: string
+ logoUri: string
+ appUri: string
+ isPinned: boolean
+ onPin: () => void
+}
+
+const Card = (props: CardProps): ReactElement => {
+ const appRoute = generatePath(GENERIC_APPS_ROUTE) + `?appUrl=${props.appUri}`
+ const { isPinned, onPin } = props
+
+ const handlePinClick = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault()
+ onPin()
+ },
+ [onPin],
+ )
+
+ const setAppImageFallback = (error: SyntheticEvent): void => {
+ error.currentTarget.onerror = null
+ error.currentTarget.src = appsIconSvg
+ }
+
+ return (
+
+
+
+
+
+ {props.name}
+
+
+
+
+ {props.description}
+
+
+
+ {isPinned ? : }
+
+
+ )
+}
+
+export default Card
diff --git a/src/components/Dashboard/SafeApps/Grid.tsx b/src/components/Dashboard/SafeApps/Grid.tsx
new file mode 100644
index 0000000000..9fb6ec4118
--- /dev/null
+++ b/src/components/Dashboard/SafeApps/Grid.tsx
@@ -0,0 +1,133 @@
+import { ReactElement, useMemo } from 'react'
+import styled from 'styled-components'
+import { Button } from '@gnosis.pm/safe-react-components'
+import { generatePath, Link } from 'react-router-dom'
+import Skeleton from '@material-ui/lab/Skeleton/Skeleton'
+import { Grid } from '@material-ui/core'
+import { sampleSize } from 'lodash'
+
+import { screenSm, screenMd } from 'src/theme/variables'
+import { useAppList } from 'src/routes/safe/components/Apps/hooks/appList/useAppList'
+import { GENERIC_APPS_ROUTE } from 'src/routes/routes'
+import Card, { CARD_HEIGHT, CARD_PADDING } from 'src/components/Dashboard/SafeApps/Card'
+import ExploreIcon from 'src/assets/icons/explore.svg'
+import { SafeApp } from 'src/routes/safe/components/Apps/types'
+import { getAppsUsageData, rankTrackedSafeApps } from 'src/routes/safe/components/Apps/trackAppUsageCount'
+import { FEATURED_APPS_TAG } from 'src/components/Dashboard/FeaturedApps/FeaturedApps'
+import { WidgetTitle, WidgetBody, WidgetContainer } from 'src/components/Dashboard/styled'
+
+const SkeletonWrapper = styled.div`
+ border-radius: 8px;
+ overflow: hidden;
+`
+
+const StyledExplorerButton = styled.div`
+ background-color: white;
+ border-radius: 8px;
+ padding: 24px;
+
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+`
+
+const StyledLink = styled(Link)`
+ text-decoration: none;
+
+ > button {
+ width: 200px;
+ }
+`
+
+const StyledGrid = styled.div`
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ grid-template-rows: repeat(2, 1fr);
+ gap: 24px;
+
+ @media (max-width: ${screenMd}px) {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ @media (max-width: ${screenSm}px) {
+ grid-template-columns: repeat(1, minmax(0, 1fr));
+ }
+`
+
+const SafeAppsGrid = ({ size = 6 }: { size?: number }): ReactElement => {
+ const { allApps, pinnedSafeApps, togglePin, isLoading } = useAppList()
+
+ const displayedApps = useMemo(() => {
+ if (!allApps.length) return []
+ const trackData = getAppsUsageData()
+ const rankedSafeAppIds = rankTrackedSafeApps(trackData)
+ const featuredSafeAppIds = allApps.filter((app) => app.tags?.includes(FEATURED_APPS_TAG)).map((app) => app.id)
+
+ const nonFeaturedApps = allApps.filter((app) => !featuredSafeAppIds.includes(app.id))
+ const nonRankedApps = nonFeaturedApps.filter((app) => !rankedSafeAppIds.includes(app.id))
+
+ const topRankedSafeApps: SafeApp[] = []
+ rankedSafeAppIds.forEach((id) => {
+ const sortedApp = nonFeaturedApps.find((app) => app.id === id)
+ if (sortedApp) topRankedSafeApps.push(sortedApp)
+ })
+
+ // Get random apps that are not ranked and not featured
+ const randomApps = sampleSize(nonRankedApps, size - 1 - topRankedSafeApps.length)
+
+ // Display size - 1 in order to always display the "Explore Safe Apps" card
+ return topRankedSafeApps.concat(randomApps).slice(0, size - 1)
+ }, [allApps, size])
+
+ const path = generatePath(GENERIC_APPS_ROUTE)
+
+ const LoadingState = useMemo(
+ () => (
+
+ {Array.from(Array(size).keys()).map((key) => (
+
+
+
+
+
+ ))}
+
+ ),
+ [size],
+ )
+
+ if (isLoading) return LoadingState
+
+ return (
+
+ Safe Apps
+
+
+ {displayedApps.map((safeApp) => (
+ app.id === safeApp.id)}
+ onPin={() => togglePin(safeApp)}
+ />
+ ))}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default SafeAppsGrid
diff --git a/src/components/Dashboard/styled.tsx b/src/components/Dashboard/styled.tsx
new file mode 100644
index 0000000000..427a91b14a
--- /dev/null
+++ b/src/components/Dashboard/styled.tsx
@@ -0,0 +1,39 @@
+import { lg, black500, extraLargeFontSize, largeFontSize } from 'src/theme/variables'
+import styled from 'styled-components'
+
+export const WidgetContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+`
+
+export const DashboardTitle = styled.h1`
+ color: ${black500};
+ font-size: ${extraLargeFontSize};
+ margin-top: 0;
+`
+
+export const WidgetTitle = styled.h2`
+ color: ${black500};
+ font-size: ${largeFontSize};
+ margin-top: 0;
+`
+
+export const WidgetBody = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ height: 100%;
+`
+
+export const Card = styled.div`
+ background: #fff;
+ padding: ${lg};
+ border-radius: 8px;
+ flex-grow: 1;
+ position: relative;
+
+ & > h2 {
+ margin-top: 0;
+ }
+`
diff --git a/src/components/NetworkLabel/NetworkLabel.tsx b/src/components/NetworkLabel/NetworkLabel.tsx
index 903272dc85..8930c4eb81 100644
--- a/src/components/NetworkLabel/NetworkLabel.tsx
+++ b/src/components/NetworkLabel/NetworkLabel.tsx
@@ -42,7 +42,7 @@ const StyledLabel = styled.span`
color: ${({ textColor }) => textColor ?? fontColor};
cursor: ${({ onClick }) => (onClick ? 'pointer' : 'inherit')};
text-align: center;
- border-radius: 3px;
+ border-radius: 4px;
text-transform: capitalize;
flex-grow: ${({ flexGrow }) => (flexGrow ? 1 : 'initial')};
`
diff --git a/src/components/SafeListSidebar/SafeList/SafeListItem.tsx b/src/components/SafeListSidebar/SafeList/SafeListItem.tsx
index 10e043165e..f823c3f705 100644
--- a/src/components/SafeListSidebar/SafeList/SafeListItem.tsx
+++ b/src/components/SafeListSidebar/SafeList/SafeListItem.tsx
@@ -97,7 +97,7 @@ const SafeListItem = ({
const handleOpenSafe = (): void => {
onSafeClick()
onNetworkSwitch?.()
- history.push(generateSafeRoute(SAFE_ROUTES.ASSETS_BALANCES, routesSlug))
+ history.push(generateSafeRoute(SAFE_ROUTES.DASHBOARD, routesSlug))
}
const handleLoadSafe = (): void => {
diff --git a/src/components/layout/Page/index.module.scss b/src/components/layout/Page/index.module.scss
index a04cdd49c8..237d629ed8 100644
--- a/src/components/layout/Page/index.module.scss
+++ b/src/components/layout/Page/index.module.scss
@@ -4,7 +4,7 @@
display: flex;
flex: 1 0 auto;
flex-direction: column;
- padding: 12px 0 0 0;
+ padding: 16px 0 0 0;
}
.center {
diff --git a/src/logic/collectibles/sources/Gnosis.ts b/src/logic/collectibles/sources/Gnosis.ts
index 8b78c818f1..a78c399ca5 100644
--- a/src/logic/collectibles/sources/Gnosis.ts
+++ b/src/logic/collectibles/sources/Gnosis.ts
@@ -52,9 +52,7 @@ class Gnosis {
static extractNFTAsset = (asset: TokenResult, nftTokens: NFTTokens): NFTAsset => {
const mainAssetAddress = asset.address
- const numberOfTokens = nftTokens.items.filter(({ assetAddress }) =>
- sameAddress(assetAddress, mainAssetAddress),
- ).length
+ const numberOfTokens = nftTokens.filter(({ assetAddress }) => sameAddress(assetAddress, mainAssetAddress)).length
return {
address: mainAssetAddress,
@@ -90,7 +88,7 @@ class Gnosis {
name: token.name || '',
tokenId: token.id,
}))
- return { items, loaded: true }
+ return items
}
/**
diff --git a/src/logic/collectibles/sources/collectibles.d.ts b/src/logic/collectibles/sources/collectibles.d.ts
index de676cc2f2..de215c1469 100644
--- a/src/logic/collectibles/sources/collectibles.d.ts
+++ b/src/logic/collectibles/sources/collectibles.d.ts
@@ -45,10 +45,8 @@ export interface NFTToken {
tokenId: number | string
}
-export type NFTTokens = {
- loaded: boolean
- items: Array
-}
+export type NFTTokens = Array
+export type NFTTokensStore = { items: NFTTokens; loaded: boolean }
export interface Collectibles {
nftAssets: NFTAssets
diff --git a/src/logic/collectibles/store/actions/addCollectibles.ts b/src/logic/collectibles/store/actions/addCollectibles.ts
index c2892ed94c..8ec637c7ff 100644
--- a/src/logic/collectibles/store/actions/addCollectibles.ts
+++ b/src/logic/collectibles/store/actions/addCollectibles.ts
@@ -2,6 +2,7 @@ import { createAction } from 'redux-actions'
export const ADD_NFT_ASSETS = 'ADD_NFT_ASSETS'
export const ADD_NFT_TOKENS = 'ADD_NFT_TOKENS'
+export const SET_NFT_LOADED = 'SET_NFT_LOADED'
export const addNftAssets = createAction(ADD_NFT_ASSETS, (nftAssets) => ({
nftAssets,
@@ -10,3 +11,5 @@ export const addNftAssets = createAction(ADD_NFT_ASSETS, (nftAssets) => ({
export const addNftTokens = createAction(ADD_NFT_TOKENS, (nftTokens) => ({
nftTokens,
}))
+
+export const setNftTokensLoaded = createAction(SET_NFT_LOADED)
diff --git a/src/logic/collectibles/store/actions/fetchCollectibles.ts b/src/logic/collectibles/store/actions/fetchCollectibles.ts
index 9b81dfa60a..711ca52ddf 100644
--- a/src/logic/collectibles/store/actions/fetchCollectibles.ts
+++ b/src/logic/collectibles/store/actions/fetchCollectibles.ts
@@ -1,18 +1,21 @@
import { Dispatch } from 'redux'
import { getConfiguredSource } from 'src/logic/collectibles/sources'
-import { addNftAssets, addNftTokens } from 'src/logic/collectibles/store/actions/addCollectibles'
+import { addNftAssets, addNftTokens, setNftTokensLoaded } from 'src/logic/collectibles/store/actions/addCollectibles'
export const fetchCollectibles =
(safeAddress: string) =>
async (dispatch: Dispatch): Promise => {
+ dispatch(setNftTokensLoaded(false))
try {
const source = getConfiguredSource()
const collectibles = await source.fetchCollectibles(safeAddress)
dispatch(addNftAssets(collectibles.nftAssets))
dispatch(addNftTokens(collectibles.nftTokens))
+ dispatch(setNftTokensLoaded(true))
} catch (error) {
+ dispatch(setNftTokensLoaded(false))
console.log('Error fetching collectibles:', error)
}
}
diff --git a/src/logic/collectibles/store/reducer/collectibles.ts b/src/logic/collectibles/store/reducer/collectibles.ts
index 54538a005e..df3b270547 100644
--- a/src/logic/collectibles/store/reducer/collectibles.ts
+++ b/src/logic/collectibles/store/reducer/collectibles.ts
@@ -1,7 +1,8 @@
import { handleActions } from 'redux-actions'
-import { ADD_NFT_ASSETS, ADD_NFT_TOKENS } from 'src/logic/collectibles/store/actions/addCollectibles'
-import { NFTAssets, NFTTokens } from 'src/logic/collectibles/sources/collectibles'
+import { ADD_NFT_ASSETS, ADD_NFT_TOKENS, SET_NFT_LOADED } from 'src/logic/collectibles/store/actions/addCollectibles'
+import { NFTAssets, NFTTokens, NFTTokensStore } from 'src/logic/collectibles/sources/collectibles'
+import { Action } from 'redux-actions'
export const NFT_ASSETS_REDUCER_ID = 'nftAssets'
export const NFT_TOKENS_REDUCER_ID = 'nftTokens'
@@ -19,15 +20,33 @@ export const nftAssetReducer = handleActions(
{},
)
-type NFTTokensPayload = { nftTokens: NFTTokens }
+type AddNftTokensPayload = { nftTokens: NFTTokens }
+type SetNftLoadedPayload = boolean
+type NFTTokensPayload = AddNftTokensPayload | SetNftLoadedPayload
-export const nftTokensReducer = handleActions(
+export const nftTokensDefaultState: NFTTokensStore = {
+ items: [],
+ loaded: false,
+}
+
+export const nftTokensReducer = handleActions(
{
- [ADD_NFT_TOKENS]: (state, action) => {
+ [ADD_NFT_TOKENS]: (state, action: Action) => {
const { nftTokens } = action.payload
- return nftTokens
+ return {
+ ...state,
+ items: nftTokens,
+ }
+ },
+
+ [SET_NFT_LOADED]: (state, action: Action) => {
+ const loaded = action.payload
+ return {
+ ...state,
+ loaded,
+ }
},
},
- { items: [], loaded: false },
+ nftTokensDefaultState,
)
diff --git a/src/logic/collectibles/store/selectors/index.ts b/src/logic/collectibles/store/selectors/index.ts
index 6582e237e8..1c3dcca896 100644
--- a/src/logic/collectibles/store/selectors/index.ts
+++ b/src/logic/collectibles/store/selectors/index.ts
@@ -1,10 +1,10 @@
import { createSelector } from 'reselect'
-import { NFTAsset, NFTAssets, NFTToken, NFTTokens } from 'src/logic/collectibles/sources/collectibles.d'
+import { NFTAsset, NFTAssets, NFTTokens, NFTTokensStore } from 'src/logic/collectibles/sources/collectibles.d'
import { AppReduxState } from 'src/store'
import { NFT_ASSETS_REDUCER_ID, NFT_TOKENS_REDUCER_ID } from 'src/logic/collectibles/store/reducer/collectibles'
export const nftAssets = (state: AppReduxState): NFTAssets => state[NFT_ASSETS_REDUCER_ID]
-export const nftTokens = (state: AppReduxState): NFTTokens => state[NFT_TOKENS_REDUCER_ID]
+export const nftTokens = (state: AppReduxState): NFTTokensStore => state[NFT_TOKENS_REDUCER_ID]
export const nftAssetsSelector = createSelector(nftAssets, (assets) => assets)
@@ -22,8 +22,9 @@ const nftAssetsAddressFromNftTokensSelector = createSelector(nftTokensSelector,
return Array.from(uniqueAddresses)
})
-export const orderedNFTAssets = createSelector(nftTokensSelector, (userNftTokens): NFTToken[] =>
- userNftTokens.sort((a, b) => a.name.localeCompare(b.name)),
+export const orderedNFTAssets = createSelector(
+ nftTokensSelector,
+ (userNftTokens): NFTTokens => userNftTokens.sort((a, b) => a.name.localeCompare(b.name)),
)
export const nftAssetsFromNftTokensSelector = createSelector(
diff --git a/src/logic/safe/store/actions/fetchSafe.ts b/src/logic/safe/store/actions/fetchSafe.ts
index 45c3c40dc6..8351655f25 100644
--- a/src/logic/safe/store/actions/fetchSafe.ts
+++ b/src/logic/safe/store/actions/fetchSafe.ts
@@ -8,8 +8,7 @@ import { getSafeInfo } from 'src/logic/safe/utils/safeInformation'
import { SafeInfo } from '@gnosis.pm/safe-react-gateway-sdk'
import { checksumAddress } from 'src/utils/checksumAddress'
import { buildSafeOwners, extractRemoteSafeInfo } from './utils'
-import { Errors, logError } from 'src/logic/exceptions/CodedException'
-import { store } from 'src/store'
+import { AppReduxState, store } from 'src/store'
import { currentSafeWithNames } from '../selectors'
import fetchTransactions from './transactions/fetchTransactions'
import { fetchCollectibles } from 'src/logic/collectibles/store/actions/fetchCollectibles'
@@ -54,17 +53,14 @@ export const buildSafe = async (safeAddress: string): Promise =
* @note It's being used by the app when it loads for the first time and for the Safe's data polling
*
* @param {string} safeAddress
+ * @param {boolean} isInitialLoad
*/
export const fetchSafe =
(safeAddress: string, isInitialLoad = false) =>
async (dispatch: Dispatch): Promise> | void> => {
- let address = ''
- try {
- address = checksumAddress(safeAddress)
- } catch (err) {
- logError(Errors._102, safeAddress)
- return
- }
+ const dispatchPromises: ((dispatch: Dispatch, getState: () => AppReduxState) => Promise | void)[] = []
+
+ const address = checksumAddress(safeAddress)
let safeInfo: Partial = {}
let remoteSafeInfo: SafeInfo | null = null
@@ -95,22 +91,24 @@ export const fetchSafe =
const shouldUpdateTxHistory = txHistoryTag !== safeInfo.txHistoryTag
const shouldUpdateTxQueued = txQueuedTag !== safeInfo.txQueuedTag
- dispatch(fetchSafeTokens(address))
+ dispatchPromises.push(dispatch(fetchSafeTokens(address)))
if (shouldUpdateCollectibles || isInitialLoad) {
dispatch(fetchCollectibles(address))
}
if (shouldUpdateTxHistory || shouldUpdateTxQueued || isInitialLoad) {
- dispatch(fetchTransactions(chainId, address))
+ dispatchPromises.push(dispatch(fetchTransactions(chainId, address)))
}
if (isInitialLoad) {
- dispatch(addViewedSafe(address))
+ dispatchPromises.push(dispatch(addViewedSafe(address)))
}
}
const owners = buildSafeOwners(remoteSafeInfo?.owners || [])
- return dispatch(updateSafe({ address, ...safeInfo, owners }))
+ await Promise.all(dispatchPromises)
+
+ return dispatch(updateSafe({ address, ...safeInfo, owners, loaded: true }))
}
diff --git a/src/logic/safe/store/models/safe.ts b/src/logic/safe/store/models/safe.ts
index e4bf58c09f..4004a06233 100644
--- a/src/logic/safe/store/models/safe.ts
+++ b/src/logic/safe/store/models/safe.ts
@@ -33,6 +33,7 @@ export type SafeRecordProps = {
modules?: ModulePair[] | null
spendingLimits?: SpendingLimit[] | null
balances: BalanceRecord[]
+ loaded: boolean
nonce: number
recurringUser?: boolean
currentVersion: string
@@ -58,6 +59,7 @@ const makeSafe = Record({
modules: [],
spendingLimits: [],
balances: [],
+ loaded: false,
nonce: 0,
recurringUser: undefined,
currentVersion: '',
diff --git a/src/logic/safe/store/reducer/safe.ts b/src/logic/safe/store/reducer/safe.ts
index 2731a142e4..7e5b621349 100644
--- a/src/logic/safe/store/reducer/safe.ts
+++ b/src/logic/safe/store/reducer/safe.ts
@@ -18,6 +18,7 @@ export const buildSafe = (storedSafe: SafeRecordProps): SafeRecordProps => {
return {
...storedSafe,
+ loaded: false,
owners,
modules: null,
}
diff --git a/src/logic/safe/store/selectors/index.ts b/src/logic/safe/store/selectors/index.ts
index 135d77fc0c..c09933d6ec 100644
--- a/src/logic/safe/store/selectors/index.ts
+++ b/src/logic/safe/store/selectors/index.ts
@@ -35,6 +35,8 @@ export const currentSafeEthBalance = createSelector(currentSafe, safeFieldSelect
export const currentSafeBalances = createSelector(currentSafe, safeFieldSelector('balances'))
+export const currentSafeLoaded = createSelector(currentSafe, safeFieldSelector('loaded'))
+
export const currentSafeNeedsUpdate = createSelector(currentSafe, safeFieldSelector('needsUpdate'))
export const currentSafeCurrentVersion = createSelector(currentSafe, safeFieldSelector('currentVersion'))
diff --git a/src/logic/safe/utils/__tests__/shouldSafeStoreBeUpdated.test.ts b/src/logic/safe/utils/__tests__/shouldSafeStoreBeUpdated.test.ts
index 1d52796bf3..6b6a60787b 100644
--- a/src/logic/safe/utils/__tests__/shouldSafeStoreBeUpdated.test.ts
+++ b/src/logic/safe/utils/__tests__/shouldSafeStoreBeUpdated.test.ts
@@ -35,6 +35,7 @@ const getMockedOldSafe = ({
{ tokenAddress: mockedActiveTokenAddress1, tokenBalance: '100' },
{ tokenAddress: mockedActiveTokenAddress2, tokenBalance: '10' },
],
+ loaded: true,
nonce: nonce || 2,
recurringUser: recurringUser || false,
currentVersion: currentVersion || 'v1.1.1',
diff --git a/src/routes/CreateSafePage/components/SafeCreationProcess.tsx b/src/routes/CreateSafePage/components/SafeCreationProcess.tsx
index 20fb49612a..f975e2f5ec 100644
--- a/src/routes/CreateSafePage/components/SafeCreationProcess.tsx
+++ b/src/routes/CreateSafePage/components/SafeCreationProcess.tsx
@@ -274,7 +274,7 @@ function SafeCreationProcess(): ReactElement {
const { safeName, safeCreationTxHash, safeAddress } = modalData
history.push({
- pathname: generateSafeRoute(SAFE_ROUTES.ASSETS_BALANCES, {
+ pathname: generateSafeRoute(SAFE_ROUTES.DASHBOARD, {
shortName: getShortName(),
safeAddress,
}),
diff --git a/src/routes/Home/index.tsx b/src/routes/Home/index.tsx
new file mode 100644
index 0000000000..3b00f8ed2c
--- /dev/null
+++ b/src/routes/Home/index.tsx
@@ -0,0 +1,36 @@
+import { ReactElement } from 'react'
+
+import Page from 'src/components/layout/Page'
+import PendingTxsList from 'src/components/Dashboard/PendingTxs/PendingTxsList'
+import Overview from 'src/components/Dashboard/Overview/Overview'
+import SafeAppsGrid from 'src/components/Dashboard/SafeApps/Grid'
+import { FeaturedApps } from 'src/components/Dashboard/FeaturedApps/FeaturedApps'
+import { Box, Grid } from '@material-ui/core'
+
+function Home(): ReactElement {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default Home
diff --git a/src/routes/LoadSafePage/LoadSafePage.test.tsx b/src/routes/LoadSafePage/LoadSafePage.test.tsx
index 70a84cc9db..3c071f8c07 100644
--- a/src/routes/LoadSafePage/LoadSafePage.test.tsx
+++ b/src/routes/LoadSafePage/LoadSafePage.test.tsx
@@ -614,7 +614,7 @@ describe('', () => {
await waitFor(() => {
expect(historyPushSpy).toHaveBeenCalledWith(
- generateSafeRoute(SAFE_ROUTES.ASSETS_BALANCES, {
+ generateSafeRoute(SAFE_ROUTES.DASHBOARD, {
shortName: getShortName(),
safeAddress: validSafeAddress,
}),
diff --git a/src/routes/LoadSafePage/LoadSafePage.tsx b/src/routes/LoadSafePage/LoadSafePage.tsx
index 588895dc7f..f4fa7bf780 100644
--- a/src/routes/LoadSafePage/LoadSafePage.tsx
+++ b/src/routes/LoadSafePage/LoadSafePage.tsx
@@ -120,7 +120,7 @@ function Load(): ReactElement {
// Go to the newly added Safe
history.push(
- generateSafeRoute(SAFE_ROUTES.ASSETS_BALANCES, {
+ generateSafeRoute(SAFE_ROUTES.DASHBOARD, {
shortName: getShortName(),
safeAddress: checksummedAddress,
}),
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index d47eae9d2e..b94d06c35a 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -10,12 +10,13 @@ import {
LOAD_SPECIFIC_SAFE_ROUTE,
OPEN_SAFE_ROUTE,
ADDRESSED_ROUTE,
- SAFE_ROUTES,
WELCOME_ROUTE,
ROOT_ROUTE,
LOAD_SAFE_ROUTE,
getNetworkRootRoutes,
extractSafeAddress,
+ SAFE_ROUTES,
+ GENERIC_APPS_ROUTE,
} from './routes'
import { getShortName } from 'src/config'
import { setChainId } from 'src/logic/config/utils'
@@ -30,7 +31,7 @@ const SafeContainer = React.lazy(() => import('./safe/container'))
const Routes = (): React.ReactElement => {
const location = useLocation()
const { pathname } = location
- const defaultSafe = useSelector(lastViewedSafe)
+ const lastSafe = useSelector(lastViewedSafe)
// Google Tag Manager page tracking
usePageTracking()
@@ -73,7 +74,7 @@ const Routes = (): React.ReactElement => {
exact
path={ROOT_ROUTE}
render={() => {
- if (defaultSafe === null) {
+ if (lastSafe === null) {
return (
@@ -81,12 +82,12 @@ const Routes = (): React.ReactElement => {
)
}
- if (defaultSafe) {
+ if (lastSafe) {
return (
)
@@ -96,6 +97,22 @@ const Routes = (): React.ReactElement => {
}}
/>
+ {/* Redirect /app/apps?appUrl=https://... to that app within the current Safe */}
+ {
+ if (!lastSafe) {
+ return
+ }
+ const redirectPath = generateSafeRoute(SAFE_ROUTES.APPS, {
+ shortName: getShortName(),
+ safeAddress: lastSafe,
+ })
+ return
+ }}
+ />
+
diff --git a/src/routes/routes.ts b/src/routes/routes.ts
index 5584269c79..6c421fa08f 100644
--- a/src/routes/routes.ts
+++ b/src/routes/routes.ts
@@ -43,12 +43,14 @@ export const LOAD_SPECIFIC_SAFE_ROUTE = `/load/:${SAFE_ADDRESS_SLUG}?` // ? = op
export const ROOT_ROUTE = '/'
export const WELCOME_ROUTE = '/welcome'
export const OPEN_SAFE_ROUTE = '/open'
+export const GENERIC_APPS_ROUTE = '/apps'
export const LOAD_SAFE_ROUTE = generatePath(LOAD_SPECIFIC_SAFE_ROUTE) // By providing no slug, we get '/load'
// [SAFE_SECTION_SLUG], [SAFE_SUBSECTION_SLUG] populated safe routes
export const SAFE_ROUTES = {
- ASSETS_BALANCES: `${ADDRESSED_ROUTE}/balances`,
- ASSETS_BALANCES_COLLECTIBLES: `${ADDRESSED_ROUTE}/balances/nfts`,
+ DASHBOARD: `${ADDRESSED_ROUTE}/home`,
+ ASSETS_BALANCES: `${ADDRESSED_ROUTE}/balances`, // [SAFE_SECTION_SLUG] === 'balances'
+ ASSETS_BALANCES_COLLECTIBLES: `${ADDRESSED_ROUTE}/balances/nfts`, // [SAFE_SUBSECTION_SLUG] === 'nfts'
LEGACY_COLLECTIBLES: `${ADDRESSED_ROUTE}/balances/collectibles`,
TRANSACTIONS: `${ADDRESSED_ROUTE}/transactions`,
TRANSACTIONS_HISTORY: `${ADDRESSED_ROUTE}/transactions/history`,
@@ -124,3 +126,7 @@ export const generatePrefixedAddressRoutes = (params: SafeRouteParams): typeof S
{} as typeof STANDARD_SAFE_ROUTES,
)
}
+
+export const getSafeAppUrl = (appUrl: string, routesSlug: SafeRouteParams): string => {
+ return generateSafeRoute(SAFE_ROUTES.APPS, routesSlug) + `?appUrl=${appUrl}`
+}
diff --git a/src/routes/safe/components/Apps/__tests__/trackAppUsageCount.test.ts b/src/routes/safe/components/Apps/__tests__/trackAppUsageCount.test.ts
new file mode 100644
index 0000000000..dedcbc6f7f
--- /dev/null
+++ b/src/routes/safe/components/Apps/__tests__/trackAppUsageCount.test.ts
@@ -0,0 +1,57 @@
+import { AppTrackData, rankTrackedSafeApps } from 'src/routes/safe/components/Apps/trackAppUsageCount'
+
+describe('rankTrackedSafeApps', () => {
+ it('ranks more recent apps higher', () => {
+ const trackedSafeApps: AppTrackData = {
+ '1': {
+ timestamp: 1,
+ txCount: 1,
+ openCount: 1,
+ },
+ '2': {
+ timestamp: 3,
+ txCount: 1,
+ openCount: 1,
+ },
+ '3': {
+ timestamp: 5,
+ txCount: 1,
+ openCount: 1,
+ },
+ '4': {
+ timestamp: 2,
+ txCount: 1,
+ openCount: 1,
+ },
+ }
+ const result = rankTrackedSafeApps(trackedSafeApps)
+ expect(result).toEqual(['3', '2', '4', '1'])
+ })
+
+ it('ranks apps by relevancy', () => {
+ const trackedSafeApps: AppTrackData = {
+ '1': {
+ timestamp: 1,
+ txCount: 1,
+ openCount: 1,
+ },
+ '2': {
+ timestamp: 4,
+ txCount: 4,
+ openCount: 6,
+ },
+ '3': {
+ timestamp: 8,
+ txCount: 3,
+ openCount: 4,
+ },
+ '4': {
+ timestamp: 5,
+ txCount: 2,
+ openCount: 2,
+ },
+ }
+ const result = rankTrackedSafeApps(trackedSafeApps)
+ expect(result).toEqual(['3', '2', '4', '1'])
+ })
+})
diff --git a/src/routes/safe/components/Apps/components/AppFrame.tsx b/src/routes/safe/components/Apps/components/AppFrame.tsx
index 32d8593da0..e3f54182ce 100644
--- a/src/routes/safe/components/Apps/components/AppFrame.tsx
+++ b/src/routes/safe/components/Apps/components/AppFrame.tsx
@@ -21,9 +21,9 @@ import { LoadingContainer } from 'src/components/LoaderContainer/index'
import { SAFE_POLLING_INTERVAL } from 'src/utils/constants'
import { ConfirmTxModal } from './ConfirmTxModal'
import { useIframeMessageHandler } from '../hooks/useIframeMessageHandler'
-import { EMPTY_SAFE_APP, getAppInfoFromUrl, getEmptySafeApp, getLegacyChainName } from '../utils'
-import { SafeApp } from '../types'
import { LegacyMethods, useAppCommunicator } from '../communicator'
+import { SafeApp } from '../types'
+import { EMPTY_SAFE_APP, getAppInfoFromUrl, getEmptySafeApp, getLegacyChainName } from '../utils'
import { fetchTokenCurrenciesBalances } from 'src/logic/safe/api/fetchTokenCurrenciesBalances'
import { fetchSafeTransaction } from 'src/logic/safe/transactions/api/fetchSafeTransaction'
import { logError, Errors } from 'src/logic/exceptions/CodedException'
@@ -37,12 +37,14 @@ import { grantedSelector } from 'src/routes/safe/container/selector'
import { SAFE_APPS_EVENTS } from 'src/utils/events/safeApps'
import { trackEvent } from 'src/utils/googleTagManager'
import { checksumAddress } from 'src/utils/checksumAddress'
+import { useRemoteSafeApps } from 'src/routes/safe/components/Apps/hooks/appList/useRemoteSafeApps'
+import { trackSafeAppOpenCount } from 'src/routes/safe/components/Apps/trackAppUsageCount'
const AppWrapper = styled.div`
display: flex;
flex-direction: column;
- height: 100%;
- margin: 0 -16px;
+ height: calc(100% + 16px);
+ margin: -8px -24px;
`
const StyledCard = styled(Card)`
@@ -102,6 +104,8 @@ const AppFrame = ({ appUrl }: Props): ReactElement => {
const errorTimer = useRef()
const [, setAppLoadError] = useState(false)
const { thirdPartyCookiesDisabled, setThirdPartyCookiesDisabled } = useThirdPartyCookies()
+ const { remoteSafeApps } = useRemoteSafeApps()
+ const currentApp = remoteSafeApps.filter((app) => app.url === appUrl)[0]
const safeAppsRpc = getSafeAppsRpcServiceUrl()
const safeAppWeb3Provider = useMemo(
@@ -134,6 +138,12 @@ const AppFrame = ({ appUrl }: Props): ReactElement => {
}
}, [appIsLoading])
+ useEffect(() => {
+ if (!currentApp) return
+
+ trackSafeAppOpenCount(currentApp.id)
+ }, [currentApp])
+
const openConfirmationModal = useCallback(
(txs: Transaction[], params: TransactionParams | undefined, requestId: RequestId) =>
setConfirmTransactionModal({
@@ -366,6 +376,7 @@ const AppFrame = ({ appUrl }: Props): ReactElement => {
onUserConfirm={onUserTxConfirm}
params={confirmTransactionModal.params}
onTxReject={onTxReject}
+ appId={currentApp?.id}
/>
{
accessControl: {
type: safeAppsGatewaySDK.SafeAppAccessPolicyTypes.NoRestrictions,
},
+ tags: [],
}),
)
@@ -55,6 +56,7 @@ beforeEach(() => {
accessControl: {
type: safeAppsGatewaySDK.SafeAppAccessPolicyTypes.NoRestrictions,
},
+ tags: [],
},
{
id: 3,
@@ -69,6 +71,7 @@ beforeEach(() => {
type: safeAppsGatewaySDK.SafeAppAccessPolicyTypes.DomainAllowlist,
value: ['https://gnosis-safe.io'],
},
+ tags: [],
},
{
id: 14,
@@ -81,6 +84,7 @@ beforeEach(() => {
accessControl: {
type: safeAppsGatewaySDK.SafeAppAccessPolicyTypes.NoRestrictions,
},
+ tags: [],
},
{
id: 24,
@@ -94,6 +98,7 @@ beforeEach(() => {
type: safeAppsGatewaySDK.SafeAppAccessPolicyTypes.DomainAllowlist,
value: ['https://gnosis-safe.io'],
},
+ tags: [],
},
]),
)
diff --git a/src/routes/safe/components/Apps/components/ConfirmTxModal/ConfirmTxModal.test.tsx b/src/routes/safe/components/Apps/components/ConfirmTxModal/ConfirmTxModal.test.tsx
index 88d5147250..d14870cda5 100644
--- a/src/routes/safe/components/Apps/components/ConfirmTxModal/ConfirmTxModal.test.tsx
+++ b/src/routes/safe/components/Apps/components/ConfirmTxModal/ConfirmTxModal.test.tsx
@@ -55,6 +55,7 @@ describe('ConfirmTxModal Component', () => {
onTxReject={jest.fn()}
requestId="1"
app={getEmptySafeApp()}
+ appId="1"
/>,
)
@@ -88,6 +89,7 @@ describe('ConfirmTxModal Component', () => {
onTxReject={jest.fn()}
requestId="1"
app={getEmptySafeApp()}
+ appId="1"
/>,
)
@@ -117,6 +119,7 @@ describe('ConfirmTxModal Component', () => {
onTxReject={jest.fn()}
requestId="1"
app={getEmptySafeApp()}
+ appId="1"
/>,
)
@@ -148,6 +151,7 @@ describe('ConfirmTxModal Component', () => {
onTxReject={jest.fn()}
requestId="1"
app={getEmptySafeApp()}
+ appId="1"
/>,
)
@@ -179,6 +183,7 @@ describe('ConfirmTxModal Component', () => {
onTxReject={jest.fn()}
requestId="1"
app={getEmptySafeApp()}
+ appId="1"
/>,
)
@@ -204,6 +209,7 @@ describe('ConfirmTxModal Component', () => {
onTxReject={jest.fn()}
requestId="1"
app={getEmptySafeApp()}
+ appId="1"
/>,
)
@@ -236,6 +242,7 @@ describe('ConfirmTxModal Component', () => {
onTxReject={jest.fn()}
requestId="1"
app={getEmptySafeApp()}
+ appId="1"
/>,
)
@@ -264,6 +271,7 @@ describe('ConfirmTxModal Component', () => {
onTxReject={jest.fn()}
requestId="1"
app={getEmptySafeApp()}
+ appId="1"
/>,
)
@@ -297,6 +305,7 @@ describe('ConfirmTxModal Component', () => {
onTxReject={jest.fn()}
requestId="1"
app={getEmptySafeApp()}
+ appId="1"
/>,
)
diff --git a/src/routes/safe/components/Apps/components/ConfirmTxModal/ReviewConfirm.tsx b/src/routes/safe/components/Apps/components/ConfirmTxModal/ReviewConfirm.tsx
index 83a8689d51..d82248ea3f 100644
--- a/src/routes/safe/components/Apps/components/ConfirmTxModal/ReviewConfirm.tsx
+++ b/src/routes/safe/components/Apps/components/ConfirmTxModal/ReviewConfirm.tsx
@@ -26,6 +26,7 @@ import { grantedSelector } from 'src/routes/safe/container/selector'
import { TxModalWrapper } from 'src/routes/safe/components/Transactions/helpers/TxModalWrapper'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
+import { trackSafeAppTxCount } from 'src/routes/safe/components/Apps/trackAppUsageCount'
const Container = styled.div`
max-width: 480px;
@@ -68,6 +69,7 @@ export const ReviewConfirm = ({
onClose,
onReject,
requestId,
+ appId,
}: Props): ReactElement => {
const isMultiSend = txs.length > 1
const [decodedData, setDecodedData] = useState()
@@ -112,6 +114,7 @@ export const ReviewConfirm = ({
}
const confirmTransactions = (txParameters: TxParameters, delayExecution: boolean) => {
+ trackSafeAppTxCount(appId)
dispatch(
createTransaction(
{
diff --git a/src/routes/safe/components/Apps/components/ConfirmTxModal/index.tsx b/src/routes/safe/components/Apps/components/ConfirmTxModal/index.tsx
index 79c5ac9650..882b551a1d 100644
--- a/src/routes/safe/components/Apps/components/ConfirmTxModal/index.tsx
+++ b/src/routes/safe/components/Apps/components/ConfirmTxModal/index.tsx
@@ -22,6 +22,7 @@ export type ConfirmTxModalProps = {
onUserConfirm: (safeTxHash: string, requestId: RequestId) => void
onTxReject: (requestId: RequestId) => void
onClose: () => void
+ appId: string
}
const isTxValid = (t: Transaction): boolean => {
diff --git a/src/routes/safe/components/Apps/trackAppUsageCount.ts b/src/routes/safe/components/Apps/trackAppUsageCount.ts
new file mode 100644
index 0000000000..372d90de24
--- /dev/null
+++ b/src/routes/safe/components/Apps/trackAppUsageCount.ts
@@ -0,0 +1,65 @@
+import { SafeApp } from 'src/routes/safe/components/Apps/types'
+import local from 'src/utils/storage/local'
+
+export const APPS_DASHBOARD = 'APPS_DASHBOARD'
+
+const TX_COUNT_WEIGHT = 2
+const OPEN_COUNT_WEIGHT = 1
+const MORE_RECENT_MULTIPLIER = 2
+const LESS_RECENT_MULTIPLIER = 1
+
+export type AppTrackData = {
+ [safeAppId: string]: {
+ timestamp: number
+ openCount: number
+ txCount: number
+ }
+}
+
+export const getAppsUsageData = (): AppTrackData => {
+ return local.getItem(APPS_DASHBOARD) || {}
+}
+
+export const trackSafeAppOpenCount = (id: SafeApp['id']): void => {
+ const trackData = getAppsUsageData()
+ const currentOpenCount = trackData[id]?.openCount || 0
+ const currentTxCount = trackData[id]?.txCount || 0
+
+ local.setItem(APPS_DASHBOARD, {
+ ...trackData,
+ [id]: {
+ timestamp: Date.now(),
+ openCount: currentOpenCount + 1,
+ txCount: currentTxCount,
+ },
+ })
+}
+
+export const trackSafeAppTxCount = (id: SafeApp['id']): void => {
+ const trackData = getAppsUsageData()
+ const currentTxCount = trackData[id]?.txCount || 0
+
+ local.setItem(APPS_DASHBOARD, {
+ ...trackData,
+ // The object contains the openCount when we are creating a transaction
+ [id]: { ...trackData[id], txCount: currentTxCount + 1 },
+ })
+}
+
+export const rankTrackedSafeApps = (apps: AppTrackData): string[] => {
+ const appsMap = Object.entries(apps)
+
+ return appsMap
+ .sort((a, b) => {
+ // The more recently used app gets a bigger score/relevancy multiplier
+ const aTimeMultiplier = a[1].timestamp - b[1].timestamp > 0 ? MORE_RECENT_MULTIPLIER : LESS_RECENT_MULTIPLIER
+ const bTimeMultiplier = b[1].timestamp - a[1].timestamp > 0 ? MORE_RECENT_MULTIPLIER : LESS_RECENT_MULTIPLIER
+
+ // The sorting score is a weighted function where the OPEN_COUNT weights differently than the TX_COUNT
+ const aScore = (TX_COUNT_WEIGHT * a[1].txCount + OPEN_COUNT_WEIGHT * a[1].openCount) * aTimeMultiplier
+ const bScore = (TX_COUNT_WEIGHT * b[1].txCount + OPEN_COUNT_WEIGHT * b[1].openCount) * bTimeMultiplier
+
+ return bScore - aScore
+ })
+ .map((values) => values[0])
+}
diff --git a/src/routes/safe/components/Apps/utils.ts b/src/routes/safe/components/Apps/utils.ts
index e0618d2844..975915021e 100644
--- a/src/routes/safe/components/Apps/utils.ts
+++ b/src/routes/safe/components/Apps/utils.ts
@@ -62,6 +62,7 @@ export const getEmptySafeApp = (url = ''): SafeApp => {
accessControl: {
type: SafeAppAccessPolicyTypes.NoRestrictions,
},
+ tags: [],
}
}
diff --git a/src/routes/safe/components/Settings/RemoveSafeModal/index.tsx b/src/routes/safe/components/Settings/RemoveSafeModal/index.tsx
index dc4cf56090..c79b470e81 100644
--- a/src/routes/safe/components/Settings/RemoveSafeModal/index.tsx
+++ b/src/routes/safe/components/Settings/RemoveSafeModal/index.tsx
@@ -43,7 +43,7 @@ function getDestinationRoute(nextAvailableSafe: SafeRecordProps | undefined) {
shortName,
safeAddress: nextAvailableSafe.address,
}
- return generateSafeRoute(SAFE_ROUTES.ASSETS_BALANCES, routesSlug)
+ return generateSafeRoute(SAFE_ROUTES.DASHBOARD, routesSlug)
}
const RemoveSafeModal = ({ isOpen, onClose }: RemoveSafeModalProps): React.ReactElement => {
diff --git a/src/routes/safe/components/Transactions/TxList/TxCollapsed.tsx b/src/routes/safe/components/Transactions/TxList/TxCollapsed.tsx
index f309e4e81c..ead37c3367 100644
--- a/src/routes/safe/components/Transactions/TxList/TxCollapsed.tsx
+++ b/src/routes/safe/components/Transactions/TxList/TxCollapsed.tsx
@@ -28,8 +28,9 @@ import { getTxTo, isAwaitingExecution } from './utils'
import { userAccountSelector } from 'src/logic/wallets/store/selectors'
import { useKnownAddress } from './hooks/useKnownAddress'
import useTxStatus from 'src/logic/hooks/useTxStatus'
+import Spacer from 'src/components/Spacer'
-const TxInfo = ({ info, name }: { info: AssetInfo; name?: string }) => {
+export const TxInfo = ({ info, name }: { info: AssetInfo; name?: string }): ReactElement | null => {
if (isTokenTransferAsset(info)) {
return
}
@@ -46,7 +47,7 @@ const TxInfo = ({ info, name }: { info: AssetInfo; name?: string }) => {
case 'CHANGE_IMPLEMENTATION':
case 'SET_GUARD':
case 'DELETE_GUARD':
- break
+ return
case 'ENABLE_MODULE':
case 'DISABLE_MODULE':
return (
diff --git a/src/routes/safe/components/Transactions/TxList/styled.tsx b/src/routes/safe/components/Transactions/TxList/styled.tsx
index 27cc3114c7..c8c2778eef 100644
--- a/src/routes/safe/components/Transactions/TxList/styled.tsx
+++ b/src/routes/safe/components/Transactions/TxList/styled.tsx
@@ -481,7 +481,7 @@ export const StyledScrollableBar = styled.div`
`
export const ScrollableTransactionsContainer = styled(StyledScrollableBar)`
- height: calc(100vh - 170px);
+ height: calc(100vh - 218px);
overflow-x: hidden;
overflow-y: auto;
width: 100%;
diff --git a/src/routes/safe/container/index.tsx b/src/routes/safe/container/index.tsx
index 39cdbac9c6..ccf9612ee5 100644
--- a/src/routes/safe/container/index.tsx
+++ b/src/routes/safe/container/index.tsx
@@ -21,6 +21,7 @@ export const ADDRESS_BOOK_TAB_BTN_TEST_ID = 'address-book-tab-btn'
export const SAFE_VIEW_NAME_HEADING_TEST_ID = 'safe-name-heading'
export const TRANSACTIONS_TAB_NEW_BTN_TEST_ID = 'transactions-tab-new-btn'
+const Home = lazy(() => import('src/routes/Home'))
const Apps = lazy(() => import('src/routes/safe/components/Apps'))
const Settings = lazy(() => import('src/routes/safe/components/Settings'))
const Balances = lazy(() => import('src/routes/safe/components/Balances'))
@@ -31,7 +32,7 @@ const Container = (): React.ReactElement => {
const featuresEnabled = useSelector(currentSafeFeaturesEnabled)
const { address, owners } = useSelector(currentSafe)
const addressFromUrl = extractSafeAddress()
- const safeAddress = address || addressFromUrl
+ const safeAddress = addressFromUrl || address
const isSafeLoaded = owners.length > 0
const [hasLoadFailed, setHasLoadFailed] = useState(false)
@@ -89,6 +90,8 @@ const Container = (): React.ReactElement => {
return (
<>
+ wrapInSuspense()} />
+
{/* Legacy redirect */}
{
wrapInSuspense(, null)}
+ render={() => wrapInSuspense()}
/>
{
SAFE_ROUTES.TRANSACTIONS_QUEUE,
SAFE_ROUTES.TRANSACTIONS_SINGULAR,
]}
- render={() => wrapInSuspense(, null)}
+ render={() => wrapInSuspense()}
/>
- wrapInSuspense(, null)} />
+ wrapInSuspense()} />
{
if (!featuresEnabled.includes(FEATURES.SAFE_APPS)) {
history.push(generateSafeRoute(SAFE_ROUTES.ASSETS_BALANCES, extractPrefixedSafeAddress()))
}
- return wrapInSuspense(, null)
+ return wrapInSuspense()
}}
/>
- wrapInSuspense(, null)} />
+ wrapInSuspense()} />
{modal.isOpen && }
diff --git a/src/store/index.ts b/src/store/index.ts
index ae186e1843..58efbcfea0 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -44,7 +44,7 @@ import appearanceReducer, {
initialAppearanceState,
AppearanceState,
} from 'src/logic/appearance/reducer/appearance'
-import { NFTAssets, NFTTokens } from 'src/logic/collectibles/sources/collectibles'
+import { NFTAssets, NFTTokensStore } from 'src/logic/collectibles/sources/collectibles'
import { SafeReducerMap } from 'src/logic/safe/store/reducer/types/safe'
import { LS_NAMESPACE, LS_SEPARATOR } from 'src/utils/constants'
import { ConfigState } from 'src/logic/config/store/reducer/reducer'
@@ -106,7 +106,7 @@ export type AppReduxState = CombinedState<{
[PROVIDER_REDUCER_ID]: ProvidersState
[SAFE_REDUCER_ID]: SafeReducerMap
[NFT_ASSETS_REDUCER_ID]: NFTAssets
- [NFT_TOKENS_REDUCER_ID]: NFTTokens
+ [NFT_TOKENS_REDUCER_ID]: NFTTokensStore
[TOKEN_REDUCER_ID]: TokenState
[GATEWAY_TRANSACTIONS_ID]: GatewayTransactionsState
[PENDING_TRANSACTIONS_ID]: PendingTransactionsState
diff --git a/yarn.lock b/yarn.lock
index a3b8c78064..d2fd49574e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1892,10 +1892,10 @@
dependencies:
cross-fetch "^3.1.5"
-"@gnosis.pm/safe-react-gateway-sdk@^2.10.2":
- version "2.10.2"
- resolved "https://registry.yarnpkg.com/@gnosis.pm/safe-react-gateway-sdk/-/safe-react-gateway-sdk-2.10.2.tgz#25d01ea5c947bc2701535c6cdf059b380276e0f5"
- integrity sha512-9o0JiA3zS5s1fVQa61DB1gBdFgyqA9QxW3y8lTYzX/lWNbfpm2psonyLjBJkETp7RoMFSp30pM4wgPXXT9bjzQ==
+"@gnosis.pm/safe-react-gateway-sdk@^2.10.3":
+ version "2.10.3"
+ resolved "https://registry.yarnpkg.com/@gnosis.pm/safe-react-gateway-sdk/-/safe-react-gateway-sdk-2.10.3.tgz#4537442a78eb0508c483aabcac19296335a77ac3"
+ integrity sha512-ukaLACozdJQb2YGSAZgBUkF4CT9iKVjpnKFCKUnGGghXqp+Yyn9jpdcfFK0VYQJ6ZSwAm40tHtQaN3K9817Bcg==
dependencies:
cross-fetch "^3.1.5"