From d1a20f04f7e4c46d8edff45fb8be7580c504dfe5 Mon Sep 17 00:00:00 2001 From: katspaugh <381895+katspaugh@users.noreply.github.com> Date: Tue, 26 Apr 2022 10:27:38 +0200 Subject: [PATCH] [Epic] Dashboard (#3696) * Epic: Dashboard * feat: Add Safe route for dashboard (#3759) * feat: Add safe specific dashboard route * fix: Redirect user to Dashboard when adding/loading/removing a safe * fix: Remove old dashboard route * fix: Move Dashboard component inside SafeContainer to access transactions * fix: Remove null fallback for wrapInSuspense, remove unnecessary React reference * Dashboard: create/add safe widgets (#3763) * feat: pending transactions widget (#3757) * feat: pending transactions widget * refactor: split PendingTxs component * fix: code review remarks * fix: change selectedSafe type * fix: rename loading state variable in useOwnerSafes * feat: fetch Redux queued txs in the Dashboard * fix: revert changes in useOwnerSafes * fix: use Skeleton component from MUI * fix: return a spacer component instead of null for some TxInfo * style: tweak the UI of the Skeleton component and PendingTxListItem * fix: fetch store from inside the widget Co-authored-by: Aaron Cook * fix: move location of PendingTxs files * fix: parametrize MAX_TXS_DISPLAY from the Dashboard component * fix: display one queued transaction per nonce * Refactor how txns are mapped Co-authored-by: Aaron Cook Co-authored-by: katspaugh <381895+katspaugh@users.noreply.github.com> * fix: move PendingTxs components to Dashboard folder * feat: Add Home button to sidebar navigation (#3788) * feat: Dashboard Safe Apps (#3738) * feat: Add Safe route for dashboard (#3759) * feat: Add safe specific dashboard route * fix: Redirect user to Dashboard when adding/loading/removing a safe * fix: Remove old dashboard route * fix: Move Dashboard component inside SafeContainer to access transactions * fix: Remove null fallback for wrapInSuspense, remove unnecessary React reference * feat: add harcoded WC app * Add a redirect for Safe Apps + Bookmark handler * feat: display "official" apps after pinned apps * Add "Explore" Card * refactor: extract official app idss to enum * fix: render after safe apps info response * fix: import explore icon as module * fix: remove duplicated safe apps * fix: memoize safeApps data * fix: change useMemo dependency * fix: move related data inside the same function. Use hook isLoading. * fix: GENERIC_APPS_ROUTE route * feat: track timestamp when opening safeApp * feat: track openingCount when opening safeApp * feat: track txCount when creating a transaction from ReviewConfirm * fix: keep previous data when tracking on opening * fix: Adds ranking function for tracked safe apps * fix: unify rankTrackedSafeApps input types * fix: change localstorage prefered module * feat: display top ranked apps * fix: track opening SafeApp in a separate hook * fix: move app count tracking to a separate module * chore: Add comments to the sorting formula * chore: move app usage related functions to the same file * fix: improve the setItem in the LS logic * feat: add Skeleton Cards * fix: remove breadcrumbs in Dashboard * feat: always display the "Explore" card * rename safe app tracking methods. extract card dimensions to constants * include random apps to fill the gaps in the widget * prop drill the safeApp number id * code comments clean up * fix: make logo height fix to align SafeApp name Co-authored-by: Usame Algan <5880855+usame-algan@users.noreply.github.com> Co-authored-by: katspaugh <381895+katspaugh@users.noreply.github.com> Co-authored-by: Usame Algan * feat: Add Overview widget for dashboard (#3786) * feat: Add Safe route for dashboard (#3759) * feat: Add safe specific dashboard route * fix: Redirect user to Dashboard when adding/loading/removing a safe * fix: Remove old dashboard route * fix: Move Dashboard component inside SafeContainer to access transactions * fix: Remove null fallback for wrapInSuspense, remove unnecessary React reference * feat: Add Overview widget for dashboard * fix: Remove Load Safe Button * fix: Add loading state for safe record, show skeleton ui for overview widget * fix: Reset safe loading state when switching safes * fix: Reset nft token loaded state when switching safes * fix: await all dispatches in fetchSafe before setting loaded state, add skeletons * fix: Split up loading state and dont reset it for nfts anymore * fix: dispatch array type * fix: Split nft token actions, reset nft loaded state before they are refetched * feat: Featured Apps Widget (#3789) * feat: Add featured apps widget to dashboard * refactor: Extract getSafeAppUrl * fix: Filter apps by tags * fix: Update gateway sdk package and remove old type * fix: Update gateway sdk package and remove old type * refactor: Extract featured apps const * fix: Check for tags if they exist * feat: Dashboard grid layout (#3795) * wip layout * feat: Adjust Dashboard layout * fix: remove leftover prop * fix: NFT route, style container spacings * style: Adjust Overview widget style * style: Remove row, col from featured apps widget, adjust spacings * Add total transactions to sign to the PendingTxs widget title * Add view all Link * tune spacing in TxPendingListItem * fix: Remove hardcoded featured app ids, use lodash sampleSize to get random apps, adjust grid layout * fix: React prop errors * style: Adjust overview skeleton container size * style: Adjust pending txs spacing * tweaks in grid layout * fix: Update comment Co-authored-by: Diogo Soares * style: Adjust font-size and bookmark icon size for safe apps grid, adjust empty state for pending txs widget * Chore: rm unused dashboard widgets (#3805) * fix: show last queued in grouped transactions * fix: Dashboard styles (#3806) * fix: exclude featured safe apps from top ranked * fix overlap in featured apps * style: Pending txs widget height * style: switch bookmark icons to src * style: vertical scroll in transactions view adjusted for new spacings * fix: Remove pinned apps local state * style: Hide featured apps widget if none exist * fix: Set app fallback image * fix: Adjust widget titles, revert app layout spacing * style: Adjust app frame margin for larger spaces Co-authored-by: Usame Algan * style: Adjust app frame height for new spacing * style: Adjust layout padding to 24px instead of 40px * fix: lint Co-authored-by: Usame Algan <5880855+usame-algan@users.noreply.github.com> Co-authored-by: Diogo Soares <32431609+DiogoSoaress@users.noreply.github.com> Co-authored-by: Aaron Cook Co-authored-by: Diogo Soares Co-authored-by: Usame Algan --- package.json | 2 +- src/assets/icons/explore.svg | 5 + .../AppLayout/Sidebar/useSidebarItems.tsx | 5 + src/components/AppLayout/index.tsx | 2 +- .../Dashboard/FeaturedApps/FeaturedApps.tsx | 69 ++++++++ .../Dashboard/Overview/Overview.tsx | 164 ++++++++++++++++++ .../PendingTxs/PendingTxListItem.tsx | 90 ++++++++++ .../Dashboard/PendingTxs/PendingTxsList.tsx | 127 ++++++++++++++ src/components/Dashboard/SafeApps/Card.tsx | 97 +++++++++++ src/components/Dashboard/SafeApps/Grid.tsx | 133 ++++++++++++++ src/components/Dashboard/styled.tsx | 39 +++++ src/components/NetworkLabel/NetworkLabel.tsx | 2 +- .../SafeListSidebar/SafeList/SafeListItem.tsx | 2 +- src/components/layout/Page/index.module.scss | 2 +- src/logic/collectibles/sources/Gnosis.ts | 6 +- .../collectibles/sources/collectibles.d.ts | 6 +- .../store/actions/addCollectibles.ts | 3 + .../store/actions/fetchCollectibles.ts | 5 +- .../store/reducer/collectibles.ts | 33 +++- .../collectibles/store/selectors/index.ts | 9 +- src/logic/safe/store/actions/fetchSafe.ts | 24 ++- src/logic/safe/store/models/safe.ts | 2 + src/logic/safe/store/reducer/safe.ts | 1 + src/logic/safe/store/selectors/index.ts | 2 + .../shouldSafeStoreBeUpdated.test.ts | 1 + .../components/SafeCreationProcess.tsx | 2 +- src/routes/Home/index.tsx | 36 ++++ src/routes/LoadSafePage/LoadSafePage.test.tsx | 2 +- src/routes/LoadSafePage/LoadSafePage.tsx | 2 +- src/routes/index.tsx | 29 +++- src/routes/routes.ts | 10 +- .../Apps/__tests__/trackAppUsageCount.test.ts | 57 ++++++ .../components/Apps/components/AppFrame.tsx | 19 +- .../Apps/components/AppsList.test.tsx | 5 + .../ConfirmTxModal/ConfirmTxModal.test.tsx | 9 + .../ConfirmTxModal/ReviewConfirm.tsx | 3 + .../Apps/components/ConfirmTxModal/index.tsx | 1 + .../components/Apps/trackAppUsageCount.ts | 65 +++++++ src/routes/safe/components/Apps/utils.ts | 1 + .../Settings/RemoveSafeModal/index.tsx | 2 +- .../Transactions/TxList/TxCollapsed.tsx | 5 +- .../components/Transactions/TxList/styled.tsx | 2 +- src/routes/safe/container/index.tsx | 15 +- src/store/index.ts | 4 +- yarn.lock | 8 +- 45 files changed, 1039 insertions(+), 69 deletions(-) create mode 100644 src/assets/icons/explore.svg create mode 100644 src/components/Dashboard/FeaturedApps/FeaturedApps.tsx create mode 100644 src/components/Dashboard/Overview/Overview.tsx create mode 100644 src/components/Dashboard/PendingTxs/PendingTxListItem.tsx create mode 100644 src/components/Dashboard/PendingTxs/PendingTxsList.tsx create mode 100644 src/components/Dashboard/SafeApps/Card.tsx create mode 100644 src/components/Dashboard/SafeApps/Grid.tsx create mode 100644 src/components/Dashboard/styled.tsx create mode 100644 src/routes/Home/index.tsx create mode 100644 src/routes/safe/components/Apps/__tests__/trackAppUsageCount.test.ts create mode 100644 src/routes/safe/components/Apps/trackAppUsageCount.ts 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 = ( + + + No Transactions yet + 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)} + /> + ))} + + Explore Safe Apps + + + + + + + + ) +} + +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"