From 489ea76595848cece40167c5f441984e80191b77 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Fri, 16 Sep 2022 11:57:07 +0200 Subject: [PATCH] Feature: Share safe app (#519) * add share button to the compact app card, landing preparations * landing wip * landing page wip * landing page wip * move the details to a separate component * CTA alignment on the share page * wip * working demo flow * wip stuff * add redirect in the open safe flow * safe selector wip * safe selector wip * adjust the CTA for variable height content * fix the test for safe creation watcher * fix deps * add analytics for clicking demo safe * add apps event after safe creation * rename useapp component, remove fallback in chain indi * extract 1 to a constant * allow passing initial state when rendering the test setup * add real fetch to test environment, tests wip * mock usewallet * Safe selector test * increase test timeouts * use copybutton for share button * pass chain shortname to pre-select network * remove undefined check, add client_url for apps request * replace chainId query param with chain * test fix * fix share button width * parallelize tests * hide sidebar on app landing page * switch useLastSafe to default export, use classnames in pagelayout and strict comparison * show spinner when loading chains or router isn't ready * remove ariaLabel prop and use the tooltip text * render whitespace in chainindicator * add newline --- jest.setup.js | 1 + package.json | 9 +- public/images/apps-demo.svg | 12 + .../common/ChainIndicator/index.tsx | 10 +- src/components/common/CopyButton/index.tsx | 11 +- src/components/common/PageLayout/index.tsx | 7 +- .../common/PageLayout/styles.module.css | 4 + .../status/hooks/useWatchSafeCreation.test.ts | 39 +- .../status/hooks/useWatchSafeCreation.ts | 31 +- .../create-safe/status/useSafeCreation.ts | 2 +- src/components/safe-apps/AppCard/index.tsx | 39 +- .../safe-apps/AppCard/styles.module.css | 11 + src/components/safe-apps/AppFrame/index.tsx | 12 +- .../SafeAppLandingPage/AppActions.tsx | 111 ++++ .../SafeAppLandingPage/SafeAppDetails.tsx | 74 +++ .../safe-apps/SafeAppLandingPage/TryDemo.tsx | 21 + .../safe-apps/SafeAppLandingPage/constants.ts | 4 + .../safe-apps/SafeAppLandingPage/index.tsx | 101 +++ .../__tests__/useSimulation.test.ts | 2 +- src/config/constants.ts | 1 + src/config/routes.ts | 5 +- .../safe-apps/useChainFromQueryParams.ts | 35 ++ src/hooks/safe-apps/useRemoteSafeApps.ts | 2 +- src/hooks/safe-apps/useSafeAppFromBackend.ts | 29 + src/hooks/safe-apps/useSafeAppFromManifest.ts | 4 +- src/hooks/safe-apps/useSafeAppUrl.ts | 15 + src/hooks/useLastSafe.ts | 13 + src/pages/index.tsx | 12 +- src/pages/safe/apps.tsx | 9 +- src/pages/share/safe-app.tsx | 37 ++ src/store/index.ts | 5 +- src/store/storeHydrator.tsx | 6 +- src/tests/mocks.ts | 577 ++++++++++++++++++ src/tests/pages/apps-share.test.tsx | 168 +++++ src/tests/test-utils.tsx | 25 +- src/utils/__tests__/url.test.ts | 128 ++++ src/utils/url.ts | 30 +- yarn.lock | 14 +- 38 files changed, 1528 insertions(+), 88 deletions(-) create mode 100644 public/images/apps-demo.svg create mode 100644 src/components/safe-apps/SafeAppLandingPage/AppActions.tsx create mode 100644 src/components/safe-apps/SafeAppLandingPage/SafeAppDetails.tsx create mode 100644 src/components/safe-apps/SafeAppLandingPage/TryDemo.tsx create mode 100644 src/components/safe-apps/SafeAppLandingPage/constants.ts create mode 100644 src/components/safe-apps/SafeAppLandingPage/index.tsx create mode 100644 src/hooks/safe-apps/useChainFromQueryParams.ts create mode 100644 src/hooks/safe-apps/useSafeAppFromBackend.ts create mode 100644 src/hooks/safe-apps/useSafeAppUrl.ts create mode 100644 src/hooks/useLastSafe.ts create mode 100644 src/pages/share/safe-app.tsx create mode 100644 src/tests/mocks.ts create mode 100644 src/tests/pages/apps-share.test.tsx create mode 100644 src/utils/__tests__/url.test.ts diff --git a/jest.setup.js b/jest.setup.js index bc58ae7caa..d0fa162674 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -4,6 +4,7 @@ // Used for __tests__/testing-library.js // Learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom/extend-expect' +import 'whatwg-fetch' jest.mock('@web3-onboard/coinbase', () => jest.fn()) jest.mock('@web3-onboard/fortmatic', () => jest.fn()) diff --git a/package.json b/package.json index a4b6af5c28..aabb50eeee 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "prettier": "prettier -w \"{src,cypress,mocks,scripts}/**/*.{ts,tsx,css,js}\"", "fix": "yarn lint:fix && ts-prune && yarn prettier", "test": "cross-env TZ=CET DEBUG_PRINT_LIMIT=30000 jest", - "test:ci": "yarn test --ci --coverage --json --watchAll=false --testLocationInResults --runInBand --outputFile=jest.results.json", + "test:ci": "yarn test --ci --coverage --json --watchAll=false --testLocationInResults --outputFile=jest.results.json", "cmp": "./scripts/cmp.sh", "routes": "node scripts/generate-routes.js > src/config/routes.ts && prettier -w src/config/routes.ts && cat src/config/routes.ts", "css-vars": "ts-node-esm ./scripts/css-vars.ts > ./src/styles/vars.css && prettier -w src/styles/vars.css", @@ -44,7 +44,7 @@ "@gnosis.pm/safe-deployments": "^1.15.0", "@gnosis.pm/safe-ethers-lib": "^1.5.0", "@gnosis.pm/safe-modules-deployments": "^1.0.0", - "@gnosis.pm/safe-react-gateway-sdk": "^3.3.5", + "@gnosis.pm/safe-react-gateway-sdk": "^3.4.0", "@mui/icons-material": "^5.8.4", "@mui/material": "^5.9.3", "@mui/x-date-pickers": "^5.0.0-beta.6", @@ -66,6 +66,7 @@ "ethereum-blockies-base64": "^1.0.2", "ethers": "^5.6.8", "exponential-backoff": "^3.1.0", + "fuse.js": "^6.6.2", "js-cookie": "^3.0.1", "lodash": "^4.17.21", "next": "12.2.0", @@ -108,7 +109,6 @@ "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-unused-imports": "^2.0.0", - "fuse.js": "^6.6.2", "jest": "^28.1.2", "jest-environment-jsdom": "^28.1.2", "pre-commit": "^1.2.2", @@ -116,6 +116,7 @@ "ts-node": "^10.8.2", "ts-prune": "^0.10.3", "typechain": "^8.0.0", - "typescript": "4.7.4" + "typescript": "4.7.4", + "whatwg-fetch": "3.6.2" } } diff --git a/public/images/apps-demo.svg b/public/images/apps-demo.svg new file mode 100644 index 0000000000..cc79582286 --- /dev/null +++ b/public/images/apps-demo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/components/common/ChainIndicator/index.tsx b/src/components/common/ChainIndicator/index.tsx index 70ed0c1fea..4aab0a4973 100644 --- a/src/components/common/ChainIndicator/index.tsx +++ b/src/components/common/ChainIndicator/index.tsx @@ -9,9 +9,15 @@ type ChainIndicatorProps = { chainId?: string inline?: boolean className?: string + renderWhiteSpaceIfNoChain?: boolean } -const ChainIndicator = ({ chainId, className, inline = false }: ChainIndicatorProps): ReactElement => { +const ChainIndicator = ({ + chainId, + className, + inline = false, + renderWhiteSpaceIfNoChain = true, +}: ChainIndicatorProps): ReactElement | null => { const currentChainId = useChainId() const id = chainId || currentChainId const chainConfig = useAppSelector((state) => selectChainById(state, id)) @@ -26,6 +32,8 @@ const ChainIndicator = ({ chainId, className, inline = false }: ChainIndicatorPr } }, [chainConfig]) + if (!chainConfig?.chainName && !renderWhiteSpaceIfNoChain) return null + return ( {chainConfig?.chainName || ' '} diff --git a/src/components/common/CopyButton/index.tsx b/src/components/common/CopyButton/index.tsx index eddecf3153..90fbcabb42 100644 --- a/src/components/common/CopyButton/index.tsx +++ b/src/components/common/CopyButton/index.tsx @@ -6,12 +6,15 @@ const CopyButton = ({ text, className, children, + initialToolTipText = 'Copy to clipboard', }: { text: string className?: string children?: ReactNode + initialToolTipText?: string + ariaLabel?: string }): ReactElement => { - const [tooltipText, setTooltipText] = useState('Copy to clipboard') + const [tooltipText, setTooltipText] = useState(initialToolTipText) const handleCopy = useCallback( (e: SyntheticEvent) => { @@ -23,12 +26,12 @@ const CopyButton = ({ ) const handleMouseLeave = useCallback(() => { - setTimeout(() => setTooltipText('Copy to clipboard'), 500) - }, []) + setTimeout(() => setTooltipText(initialToolTipText), 500) + }, [initialToolTipText]) return ( - + {children ?? } diff --git a/src/components/common/PageLayout/index.tsx b/src/components/common/PageLayout/index.tsx index 79cf86bdb0..4f8e2613d5 100644 --- a/src/components/common/PageLayout/index.tsx +++ b/src/components/common/PageLayout/index.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, type ReactElement } from 'react' +import cn from 'classnames' import { Drawer } from '@mui/material' import { useRouter } from 'next/router' @@ -7,10 +8,12 @@ import Header from '@/components/common//Header' import css from './styles.module.css' import SafeLoadingError from '../SafeLoadingError' import Footer from '../Footer' +import { AppRoutes } from '@/config/routes' const PageLayout = ({ children }: { children: ReactElement }): ReactElement => { const router = useRouter() const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false) + const hideSidebar = router.pathname === AppRoutes.share.safeApp const onMenuToggle = (): void => { setIsMobileDrawerOpen((prev) => !prev) @@ -29,14 +32,14 @@ const PageLayout = ({ children }: { children: ReactElement }): ReactElement => { {/* Desktop sidebar */} - + {!hideSidebar && } {/* Mobile sidebar */} {sidebar} -
+
{children}
diff --git a/src/components/common/PageLayout/styles.module.css b/src/components/common/PageLayout/styles.module.css index 583472d757..35ceb2c125 100644 --- a/src/components/common/PageLayout/styles.module.css +++ b/src/components/common/PageLayout/styles.module.css @@ -15,6 +15,10 @@ flex-direction: column; } +.mainNoSidebar { + padding-left: 0; +} + .content { flex: 1; position: relative; diff --git a/src/components/create-safe/status/hooks/useWatchSafeCreation.test.ts b/src/components/create-safe/status/hooks/useWatchSafeCreation.test.ts index d11abf592c..20c5ce1346 100644 --- a/src/components/create-safe/status/hooks/useWatchSafeCreation.test.ts +++ b/src/components/create-safe/status/hooks/useWatchSafeCreation.test.ts @@ -1,6 +1,7 @@ import { renderHook } from '@/tests/test-utils' import { SafeCreationStatus } from '@/components/create-safe/status/useSafeCreation' import * as router from 'next/router' +import { NextRouter } from 'next/router' import * as web3 from '@/hooks/wallets/web3' import * as pendingSafe from '@/components/create-safe/status/usePendingSafeCreation' import * as chainIdModule from '@/hooks/useChainId' @@ -8,7 +9,7 @@ import { Web3Provider } from '@ethersproject/providers' import { PendingSafeData } from '@/components/create-safe' import useWatchSafeCreation from '@/components/create-safe/status/hooks/useWatchSafeCreation' import { AppRoutes } from '@/config/routes' -import { NextRouter } from 'next/router' +import { CONFIG_SERVICE_CHAINS } from '@/tests/mocks' describe('useWatchSafeCreation', () => { beforeEach(() => { @@ -32,6 +33,7 @@ describe('useWatchSafeCreation', () => { pendingSafe: { txHash: '0x10' } as PendingSafeData, setPendingSafe: setPendingSafeSpy, setStatus: setStatusSpy, + chainId: '4', }), ) @@ -52,6 +54,7 @@ describe('useWatchSafeCreation', () => { pendingSafe: {} as PendingSafeData, setPendingSafe: setPendingSafeSpy, setStatus: setStatusSpy, + chainId: '4', }), ) @@ -70,6 +73,7 @@ describe('useWatchSafeCreation', () => { pendingSafe: {} as PendingSafeData, setPendingSafe: setPendingSafeSpy, setStatus: setStatusSpy, + chainId: '4', }), ) @@ -89,6 +93,7 @@ describe('useWatchSafeCreation', () => { pendingSafe: {} as PendingSafeData, setPendingSafe: setPendingSafeSpy, setStatus: setStatusSpy, + chainId: '4', }), ) @@ -108,6 +113,7 @@ describe('useWatchSafeCreation', () => { pendingSafe: undefined, setPendingSafe: setPendingSafeSpy, setStatus: setStatusSpy, + chainId: '4', }), ) @@ -115,7 +121,7 @@ describe('useWatchSafeCreation', () => { expect(setPendingSafeSpy).toHaveBeenCalledWith(undefined) }) - it('should navigate to the dashboard on INDEXED', () => { + it('should navigate to the dashboard on INDEXED', async () => { jest.spyOn(chainIdModule, 'default').mockReturnValue('4') const pushMock = jest.fn() jest.spyOn(router, 'useRouter').mockReturnValue({ @@ -128,16 +134,27 @@ describe('useWatchSafeCreation', () => { const setStatusSpy = jest.fn() const setPendingSafeSpy = jest.fn() - renderHook(() => - useWatchSafeCreation({ - status: SafeCreationStatus.INDEXED, - safeAddress: '0x10', - pendingSafe: {} as PendingSafeData, - setPendingSafe: setPendingSafeSpy, - setStatus: setStatusSpy, - }), + renderHook( + () => + useWatchSafeCreation({ + status: SafeCreationStatus.INDEXED, + safeAddress: '0x10', + pendingSafe: {} as PendingSafeData, + setPendingSafe: setPendingSafeSpy, + setStatus: setStatusSpy, + chainId: '4', + }), + { + initialReduxState: { + chains: { + data: CONFIG_SERVICE_CHAINS, + error: undefined, + loading: false, + }, + }, + }, ) - expect(pushMock).toHaveBeenCalledWith({ pathname: AppRoutes.safe.home, query: { safe: '0x10' } }) + expect(pushMock).toHaveBeenCalledWith({ pathname: AppRoutes.safe.home, query: { safe: 'rin:0x10' } }) }) }) diff --git a/src/components/create-safe/status/hooks/useWatchSafeCreation.ts b/src/components/create-safe/status/hooks/useWatchSafeCreation.ts index 2ff43188a2..2ebd365960 100644 --- a/src/components/create-safe/status/hooks/useWatchSafeCreation.ts +++ b/src/components/create-safe/status/hooks/useWatchSafeCreation.ts @@ -4,8 +4,9 @@ import { useRouter } from 'next/router' import { pollSafeInfo } from '@/components/create-safe/status/usePendingSafeCreation' import { AppRoutes } from '@/config/routes' import { SafeCreationStatus } from '@/components/create-safe/status/useSafeCreation' -import useChainId from '@/hooks/useChainId' -import { trackEvent, CREATE_SAFE_EVENTS } from '@/services/analytics' +import { trackEvent, CREATE_SAFE_EVENTS, SAFE_APPS_EVENTS } from '@/services/analytics' +import { useAppSelector } from '@/store' +import { selectChainById } from '@/store/chainsSlice' const useWatchSafeCreation = ({ status, @@ -13,15 +14,17 @@ const useWatchSafeCreation = ({ pendingSafe, setPendingSafe, setStatus, + chainId, }: { status: SafeCreationStatus safeAddress: string | undefined pendingSafe: PendingSafeData | undefined setPendingSafe: Dispatch> setStatus: Dispatch> + chainId: string }) => { const router = useRouter() - const chainId = useChainId() + const chain = useAppSelector((state) => selectChainById(state, chainId)) useEffect(() => { const checkCreatedSafe = async (chainId: string, address: string) => { @@ -35,8 +38,26 @@ const useWatchSafeCreation = ({ if (status === SafeCreationStatus.INDEXED) { trackEvent(CREATE_SAFE_EVENTS.GET_STARTED) + const chainPrefix = chain?.shortName - safeAddress && router.push({ pathname: AppRoutes.safe.home, query: { safe: safeAddress } }) + if (safeAddress && chainPrefix) { + const address = `${chainPrefix}:${safeAddress}` + const redirectUrl = router.query?.safeViewRedirectURL + if (typeof redirectUrl === 'string') { + // We're prepending the safe address directly here because the `router.push` doesn't parse + // The URL for already existing query params + const hasQueryParams = redirectUrl.includes('?') + const appendChar = hasQueryParams ? '&' : '?' + + if (redirectUrl.includes('apps')) { + trackEvent({ ...SAFE_APPS_EVENTS.SHARED_APP_OPEN_AFTER_SAFE_CREATION }) + } + + router.push(redirectUrl + `${appendChar}safe=${address}`) + } else { + router.push({ pathname: AppRoutes.safe.home, query: { safe: address } }) + } + } } if (status === SafeCreationStatus.SUCCESS) { @@ -51,7 +72,7 @@ const useWatchSafeCreation = ({ setPendingSafe((prev) => (prev ? { ...prev, txHash: undefined } : undefined)) } } - }, [router, safeAddress, setPendingSafe, status, pendingSafe, setStatus, chainId]) + }, [router, safeAddress, setPendingSafe, status, pendingSafe, setStatus, chainId, chain]) } export default useWatchSafeCreation diff --git a/src/components/create-safe/status/useSafeCreation.ts b/src/components/create-safe/status/useSafeCreation.ts index c5e208c7b6..0d0204a05c 100644 --- a/src/components/create-safe/status/useSafeCreation.ts +++ b/src/components/create-safe/status/useSafeCreation.ts @@ -128,7 +128,7 @@ export const useSafeCreation = () => { }, [chainId, dispatch, isCreationPending, pendingSafe, provider, safeCreationCallback]) usePendingSafeCreation({ txHash: pendingSafe?.txHash, setStatus }) - useWatchSafeCreation({ status, safeAddress, pendingSafe, setPendingSafe, setStatus }) + useWatchSafeCreation({ status, safeAddress, pendingSafe, setPendingSafe, setStatus, chainId }) useEffect(() => { if ( diff --git a/src/components/safe-apps/AppCard/index.tsx b/src/components/safe-apps/AppCard/index.tsx index 988f2b4705..58a5e91df4 100644 --- a/src/components/safe-apps/AppCard/index.tsx +++ b/src/components/safe-apps/AppCard/index.tsx @@ -8,14 +8,14 @@ import CardHeader from '@mui/material/CardHeader' import IconButton from '@mui/material/IconButton' import Typography from '@mui/material/Typography' import { SafeAppData } from '@gnosis.pm/safe-react-gateway-sdk' -import { SAFE_REACT_URL } from '@/config/constants' -import useChainId from '@/hooks/useChainId' import ShareIcon from '@/public/images/share.svg' +import CopyButton from '@/components/common/CopyButton' import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder' import BookmarkIcon from '@mui/icons-material/Bookmark' import DeleteIcon from '@/public/images/delete.svg' import { AppRoutes } from '@/config/routes' import styles from './styles.module.css' +import { useCurrentChain } from '@/hooks/useChains' export type SafeAppCardVariants = 'default' | 'compact' @@ -32,6 +32,8 @@ type CompactSafeAppCardProps = { url: string pinned?: boolean onPin?: (appId: number) => void + onShareClick?: (event: SyntheticEvent) => void + shareUrl: string } type AppCardContainerProps = { @@ -92,10 +94,17 @@ const AppCardContainer = ({ url, children, variant }: AppCardContainerProps): Re ) } -const CompactAppCard = ({ url, safeApp, onPin, pinned }: CompactSafeAppCardProps): ReactElement => ( +const CompactAppCard = ({ url, safeApp, onPin, pinned, shareUrl }: CompactSafeAppCardProps): ReactElement => (
{`${safeApp.name} + + + {onPin && ( { const router = useRouter() - const chainId = useChainId() + const currentChain = useCurrentChain() - const shareUrl = `${SAFE_REACT_URL}/share/safe-app?appUrl=${safeApp.url}&chainId=${chainId}` + const shareUrl = `${window.location.origin}${AppRoutes.share.safeApp}?appUrl=${safeApp.url}&chain=${currentChain?.shortName}` const url = router.query.safe ? `${AppRoutes.safe.apps}?safe=${router.query.safe}&appUrl=${safeApp.url}` : shareUrl - const onShareClick = (e: SyntheticEvent) => { - e.preventDefault() - e.stopPropagation() - navigator.clipboard.writeText(shareUrl) - } - if (variant === 'compact') { - return + return } return ( @@ -140,15 +143,13 @@ const AppCard = ({ safeApp, pinned, onPin, onDelete, variant = 'default' }: AppC } action={ <> - - + {onPin && ( { const { safe } = useSafeInfo() - const [remoteApps] = useRemoteSafeApps() + const [remoteApp] = useSafeAppFromBackend(appUrl, safe.chainId) const { safeApp: safeAppFromManifest } = useSafeAppFromManifest(appUrl, safe.chainId) const { thirdPartyCookiesDisabled, setThirdPartyCookiesDisabled } = useThirdPartyCookies() const { iframeRef, appIsLoading, isLoadingSlow, setAppIsLoading } = useAppIsLoading() useAppCommunicator(iframeRef, safeAppFromManifest) - const remoteApp = useMemo(() => remoteApps?.find((app: SafeAppData) => app.url === appUrl), [remoteApps, appUrl]) - useEffect(() => { if (!remoteApp) return @@ -61,14 +58,13 @@ const AppFrame = ({ appUrl }: AppFrameProps): ReactElement => {