diff --git a/src/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection.tsx b/src/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection.tsx index 2faf63c285..eb45258ca2 100644 --- a/src/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection.tsx +++ b/src/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection.tsx @@ -31,6 +31,7 @@ const SafeAppsDashboardSection = () => { onBookmarkSafeApp={togglePin} isBookmarked={pinnedSafeAppIds.has(rankedSafeApp.id)} onClickSafeApp={() => openPreviewDrawer(rankedSafeApp)} + openPreviewDrawer={openPreviewDrawer} /> ))} diff --git a/src/components/safe-apps/SafeAppActionButtons/index.tsx b/src/components/safe-apps/SafeAppActionButtons/index.tsx index d695f5f4cc..73a7935ca6 100644 --- a/src/components/safe-apps/SafeAppActionButtons/index.tsx +++ b/src/components/safe-apps/SafeAppActionButtons/index.tsx @@ -11,12 +11,14 @@ import ShareIcon from '@/public/images/common/share.svg' import BookmarkIcon from '@/public/images/apps/bookmark.svg' import BookmarkedIcon from '@/public/images/apps/bookmarked.svg' import DeleteIcon from '@/public/images/common/delete.svg' +import InfoIcon from '@/public/images/notifications/info.svg' type SafeAppActionButtonsProps = { safeApp: SafeAppData isBookmarked?: boolean onBookmarkSafeApp?: (safeAppId: number) => void removeCustomApp?: (safeApp: SafeAppData) => void + openPreviewDrawer?: (safeApp: SafeAppData) => void } const SafeAppActionButtons = ({ @@ -24,6 +26,7 @@ const SafeAppActionButtons = ({ isBookmarked, onBookmarkSafeApp, removeCustomApp, + openPreviewDrawer, }: SafeAppActionButtonsProps) => { const isCustomApp = safeApp.id < 1 const shareSafeAppUrl = useShareSafeAppUrl(safeApp.url) @@ -35,6 +38,20 @@ const SafeAppActionButtons = ({ return ( + {/* Open the preview drawer */} + {openPreviewDrawer && ( + { + event.preventDefault() + event.stopPropagation() + openPreviewDrawer(safeApp) + }} + > + + + )} + {/* Copy share Safe App url button */} void removeCustomApp?: (safeApp: SafeAppData) => void + openPreviewDrawer?: (safeApp: SafeAppData) => void } const SafeAppCard = ({ @@ -42,6 +44,7 @@ const SafeAppCard = ({ isBookmarked, onBookmarkSafeApp, removeCustomApp, + openPreviewDrawer, }: SafeAppCardProps) => { const router = useRouter() @@ -58,6 +61,7 @@ const SafeAppCard = ({ onBookmarkSafeApp={onBookmarkSafeApp} removeCustomApp={removeCustomApp} onClickSafeApp={onClickSafeApp} + openPreviewDrawer={openPreviewDrawer} /> ) } @@ -71,6 +75,7 @@ const SafeAppCard = ({ onBookmarkSafeApp={onBookmarkSafeApp} removeCustomApp={removeCustomApp} onClickSafeApp={onClickSafeApp} + openPreviewDrawer={openPreviewDrawer} /> ) } @@ -93,6 +98,7 @@ type SafeAppCardViewProps = { isBookmarked?: boolean onBookmarkSafeApp?: (safeAppId: number) => void removeCustomApp?: (safeApp: SafeAppData) => void + openPreviewDrawer?: (safeApp: SafeAppData) => void } const SafeAppCardGridView = ({ @@ -102,9 +108,10 @@ const SafeAppCardGridView = ({ isBookmarked, onBookmarkSafeApp, removeCustomApp, + openPreviewDrawer, }: SafeAppCardViewProps) => { return ( - + {/* Safe App Header */} } @@ -157,9 +165,10 @@ const SafeAppCardListView = ({ isBookmarked, onBookmarkSafeApp, removeCustomApp, + openPreviewDrawer, }: SafeAppCardViewProps) => { return ( - +
@@ -184,6 +193,7 @@ const SafeAppCardListView = ({ isBookmarked={isBookmarked} onBookmarkSafeApp={onBookmarkSafeApp} removeCustomApp={removeCustomApp} + openPreviewDrawer={openPreviewDrawer} /> @@ -194,6 +204,7 @@ const SafeAppCardListView = ({ type SafeAppCardContainerProps = { onClickSafeApp?: () => void + safeApp?: SafeAppData safeAppUrl: string children: ReactNode height?: string @@ -202,15 +213,18 @@ type SafeAppCardContainerProps = { export const SafeAppCardContainer = ({ children, + safeApp, safeAppUrl, onClickSafeApp, height, className, }: SafeAppCardContainerProps) => { + const { openedSafeAppIds } = useOpenedSafeApps() + const handleClickSafeApp = (event: SyntheticEvent) => { - if (onClickSafeApp) { + if (safeApp && !openedSafeAppIds.includes(safeApp.id)) { event.preventDefault() - onClickSafeApp() + onClickSafeApp?.() } } diff --git a/src/components/safe-apps/SafeAppList/index.tsx b/src/components/safe-apps/SafeAppList/index.tsx index 7210818862..bd3fef8275 100644 --- a/src/components/safe-apps/SafeAppList/index.tsx +++ b/src/components/safe-apps/SafeAppList/index.tsx @@ -106,6 +106,7 @@ const SafeAppList = ({ onBookmarkSafeApp={onBookmarkSafeApp} removeCustomApp={removeCustomApp} onClickSafeApp={handleSafeAppClick(safeApp)} + openPreviewDrawer={openPreviewDrawer} /> ))} diff --git a/src/components/safe-apps/SafeAppPreviewDrawer/index.tsx b/src/components/safe-apps/SafeAppPreviewDrawer/index.tsx index de67676f3c..3c118aee04 100644 --- a/src/components/safe-apps/SafeAppPreviewDrawer/index.tsx +++ b/src/components/safe-apps/SafeAppPreviewDrawer/index.tsx @@ -16,6 +16,7 @@ import SafeAppActionButtons from '@/components/safe-apps/SafeAppActionButtons' import SafeAppTags from '@/components/safe-apps/SafeAppTags' import SafeAppSocialLinksCard from '@/components/safe-apps/SafeAppSocialLinksCard' import CloseIcon from '@/public/images/common/close.svg' +import { useOpenedSafeApps } from '@/hooks/safe-apps/useOpenedSafeApps' import css from './styles.module.css' type SafeAppPreviewDrawerProps = { @@ -27,9 +28,16 @@ type SafeAppPreviewDrawerProps = { } const SafeAppPreviewDrawer = ({ isOpen, safeApp, isBookmarked, onClose, onBookmark }: SafeAppPreviewDrawerProps) => { + const { markSafeAppOpened } = useOpenedSafeApps() const router = useRouter() const safeAppUrl = getSafeAppUrl(router, safeApp?.url || '') + const onOpenSafe = () => { + if (safeApp) { + markSafeAppOpened(safeApp.id) + } + } + return ( @@ -82,7 +90,15 @@ const SafeAppPreviewDrawer = ({ isOpen, safeApp, isBookmarked, onClose, onBookma {/* Open Safe App button */} - diff --git a/src/hooks/safe-apps/useOpenedSafeApps.ts b/src/hooks/safe-apps/useOpenedSafeApps.ts new file mode 100644 index 0000000000..153b2044db --- /dev/null +++ b/src/hooks/safe-apps/useOpenedSafeApps.ts @@ -0,0 +1,28 @@ +import { useCallback } from 'react' +import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk' + +import useChainId from '@/hooks/useChainId' +import { useAppDispatch, useAppSelector } from '@/store' +import { selectOpened, markOpened } from '@/store/safeAppsSlice' + +type ReturnType = { + openedSafeAppIds: Array + markSafeAppOpened: (id: SafeAppData['id']) => void +} + +// Return the ids of Safe Apps previously opened by the user +export const useOpenedSafeApps = (): ReturnType => { + const chainId = useChainId() + + const dispatch = useAppDispatch() + const openedSafeAppIds = useAppSelector((state) => selectOpened(state, chainId)) + + const markSafeAppOpened = useCallback( + (id: SafeAppData['id']) => { + dispatch(markOpened({ id, chainId })) + }, + [dispatch, chainId], + ) + + return { openedSafeAppIds, markSafeAppOpened } +} diff --git a/src/store/__tests__/safeAppsSlice.test.ts b/src/store/__tests__/safeAppsSlice.test.ts new file mode 100644 index 0000000000..e7ce94984f --- /dev/null +++ b/src/store/__tests__/safeAppsSlice.test.ts @@ -0,0 +1,178 @@ +import { markOpened, safeAppsSlice, setPinned } from '../safeAppsSlice' +import type { SafeAppsState } from '../safeAppsSlice' + +describe('safeAppsSlice', () => { + const safeAppId1 = 1 + const safeAppId2 = 2 + const safeAppId3 = 3 + + describe('pinned', () => { + it('sets pinned apps', () => { + // Empty state + const initialState1: SafeAppsState = {} + const state1 = safeAppsSlice.reducer( + initialState1, + setPinned({ + chainId: '1', + pinned: [safeAppId1], + }), + ) + expect(state1).toStrictEqual({ + ['1']: { + pinned: [safeAppId1], + opened: [], + }, + }) + + // State if only pinned existed + const initialState2: SafeAppsState = { + // @ts-ignore + '5': { + pinned: [safeAppId1, safeAppId2], + }, + } + const state2 = safeAppsSlice.reducer( + initialState2, + setPinned({ + chainId: '5', + pinned: [safeAppId3], + }), + ) + expect(state2).toStrictEqual({ + ['5']: { + pinned: [safeAppId3], + }, + }) + + // State if only opened existed + const initialState3: SafeAppsState = { + // @ts-ignore + '100': { + opened: [safeAppId1, safeAppId2], + }, + } + const state3 = safeAppsSlice.reducer( + initialState3, + setPinned({ + chainId: '100', + pinned: [safeAppId1, safeAppId2, safeAppId3], + }), + ) + expect(state3).toStrictEqual({ + ['100']: { + pinned: [safeAppId1, safeAppId2, safeAppId3], + opened: [safeAppId1, safeAppId2], + }, + }) + }) + + it('should not pin duplicates', () => { + // Existing state + const initialState: SafeAppsState = { + // @ts-ignore + '5': { + pinned: [safeAppId1, safeAppId2], + opened: [], + }, + } + const state = safeAppsSlice.reducer( + initialState, + setPinned({ + chainId: '5', + pinned: [safeAppId1, safeAppId2], + }), + ) + expect(state).toStrictEqual({ + ['5']: { + pinned: [safeAppId1, safeAppId2], + opened: [], + }, + }) + }) + }) + + describe('opened', () => { + it('marks an app open', () => { + // Empty state + const initialState1: SafeAppsState = {} + const state1 = safeAppsSlice.reducer( + initialState1, + markOpened({ + chainId: '1', + id: safeAppId1, + }), + ) + expect(state1).toStrictEqual({ + ['1']: { + pinned: [], + opened: [safeAppId1], + }, + }) + + // State if only pinned existed + const initialState2: SafeAppsState = { + // @ts-ignore + '5': { + pinned: [safeAppId1, safeAppId2], + }, + } + const state2 = safeAppsSlice.reducer( + initialState2, + markOpened({ + chainId: '5', + id: safeAppId2, + }), + ) + expect(state2).toStrictEqual({ + ['5']: { + pinned: [safeAppId1, safeAppId2], + opened: [safeAppId2], + }, + }) + + // State if only opened existed + const initialState3: SafeAppsState = { + // @ts-ignore + '100': { + opened: [safeAppId1, safeAppId2], + }, + } + const state3 = safeAppsSlice.reducer( + initialState3, + markOpened({ + chainId: '100', + id: safeAppId3, + }), + ) + expect(state3).toStrictEqual({ + ['100']: { + opened: [safeAppId1, safeAppId2, safeAppId3], + }, + }) + }) + + it('should not mark duplicates open', () => { + // Existing state + const initialState: SafeAppsState = { + // @ts-ignore + '5': { + pinned: [safeAppId1, safeAppId2], + opened: [], + }, + } + const state = safeAppsSlice.reducer( + initialState, + markOpened({ + chainId: '5', + id: safeAppId2, + }), + ) + expect(state).toStrictEqual({ + ['5']: { + pinned: [safeAppId1, safeAppId2], + opened: [safeAppId2], + }, + }) + }) + }) +}) diff --git a/src/store/safeAppsSlice.ts b/src/store/safeAppsSlice.ts index a73cf7604c..689438c2a6 100644 --- a/src/store/safeAppsSlice.ts +++ b/src/store/safeAppsSlice.ts @@ -1,10 +1,12 @@ import type { PayloadAction } from '@reduxjs/toolkit' import { createSelector } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' +import type { SafeAppData } from '@safe-global/safe-gateway-typescript-sdk' import type { RootState } from '@/store' type SafeAppsPerChain = { - pinned: number[] + pinned: Array + opened: Array } export type SafeAppsState = { @@ -19,26 +21,50 @@ export const safeAppsSlice = createSlice({ reducers: { setPinned: (state, { payload }: PayloadAction<{ chainId: string; pinned: SafeAppsPerChain['pinned'] }>) => { const { pinned, chainId } = payload - state[chainId] ??= { pinned: [] } + + // Initialise chain-specific state + state[chainId] ??= { pinned: [], opened: [] } + // If apps were opened before any were pinned, no pinned array exists + state[chainId].pinned ??= [] + state[chainId].pinned = pinned }, - setSafeApps: (state, { payload }: PayloadAction) => { + markOpened: (state, { payload }: PayloadAction<{ chainId: string; id: SafeAppData['id'] }>) => { + const { id, chainId } = payload + + // Initialise chain-specific state + state[chainId] ??= { pinned: [], opened: [] } + // If apps were pinned before any were opened, no opened array exists + state[chainId].opened ??= [] + + if (!state[chainId].opened.includes(id)) { + state[chainId].opened.push(id) + } + }, + setSafeApps: (_, { payload }: PayloadAction) => { // We must return as we are overwriting the entire state return payload }, }, }) -export const { setPinned } = safeAppsSlice.actions +export const { setPinned, markOpened } = safeAppsSlice.actions export const selectSafeApps = (state: RootState): SafeAppsState => { return state[safeAppsSlice.name] } -export const selectPinned = createSelector( +const selectSafeAppsPerChain = createSelector( [selectSafeApps, (_: RootState, chainId: string) => chainId], - (safeApps, chainId): SafeAppsPerChain['pinned'] => { - const perChain = safeApps[chainId] - return perChain?.pinned || [] + (safeApps, chainId) => { + return safeApps[chainId] }, ) + +export const selectPinned = createSelector([selectSafeAppsPerChain], (safeAppsPerChain) => { + return safeAppsPerChain?.pinned || [] +}) + +export const selectOpened = createSelector([selectSafeAppsPerChain], (safeAppsPerChain) => { + return safeAppsPerChain?.opened || [] +})