From b433c99795f914533b2955bd618209465ddd7757 Mon Sep 17 00:00:00 2001 From: Matthew Wall Date: Wed, 15 Nov 2023 08:10:35 -0700 Subject: [PATCH] Feature: Add remote promo sheet (#5140) * feat: initial work on generalizing promo sheet checks and remote promo sheet component * more work on remote promo sheets * update queries * Update arc.graphql to expose promoSheet and promoSheetCollection * update campaign checks and consume getPromoSheet query * write promoSheet and promoSheetCollection queries * add remote promo sheet route and name * add a couple mmkv STORAGE_IDS to control whether or not we show promos * add remote promo sheets feature flag * tweak remote promo sheet logic * fix signing remote images * more sanity checks and refetch interval * add RemotePromoSheetProvider and remove unnecessary campaignChecks * tweak checks and add Context/Provider for controlling remote promo sheets * re-enable firstLaunch and hasViewed checks * another sanity check * fix hasNonZeroAssetBalance * update check fns * add campaign storage to @storage model * update fns and remove some unused ones * update arc.graphql query to include priority * update provider and sheet to use @storage * add priority tag to collection query * update check for campaign to use @/storage and abstraction of check-fns to reduce boilerplate * adjust asset check fns * syncronize feature unlocks and campaign checks * add notifications promo and cleanup analytic events * add nft offers promo sheet and cleanup priority logic * fix conflicting nft offers asset type with contentful * replace PromoSheet analytics with v2 * revert graphql arc config change and cleanup local promo sheets * enable i18n in contentful and pass locale through * enable i18n clientside * update language * remove unused campaigns folder and uncomment check * remove unused campaigns folder * fix lint and func name * pass all locales through localized fields * change default colors to hex * add specific address for testing preview purposes * final touches * re-add hasShown check * add isPreviewing actionFn to bypass hasShown check * get color from theme if primary/secondary button has that prop * add network to asset check --- src/App.js | 31 +-- src/analytics/event.ts | 10 + src/campaigns/swapsPromoCampaign.ts | 130 ----------- src/components/PromoSheet.tsx | 12 +- src/components/cards/NFTOffersCard/index.tsx | 2 +- .../remote-promo-sheet/RemotePromoSheet.tsx | 214 ++++++++++++++++++ .../RemotePromoSheetProvider.tsx | 78 +++++++ .../check-fns/hasNftOffers.ts | 15 ++ .../check-fns/hasNonZeroAssetBalance.ts | 38 ++++ .../check-fns/hasNonZeroTotalBalance.ts | 16 ++ .../check-fns/hasSwapTxn.ts | 17 ++ .../remote-promo-sheet/check-fns/index.ts | 7 + .../check-fns/isAfterCampaignLaunch.ts | 5 + .../check-fns/isSelectedWalletReadOnly.ts | 13 ++ .../check-fns/isSpecificAddress.ts | 14 ++ .../remote-promo-sheet/checkForCampaign.ts | 152 +++++++++++++ .../localCampaignChecks.ts} | 16 +- .../notificationsPromoCampaign.ts | 2 +- src/config/experimental.ts | 2 + .../unlockableAppIconCheck.ts | 2 + src/graphql/queries/arc.graphql | 35 +++ src/handlers/walletReadyEvents.ts | 6 +- src/languages/en_US.json | 21 ++ src/model/mmkv.ts | 2 + src/navigation/Routes.android.tsx | 6 +- src/navigation/Routes.ios.tsx | 6 +- src/navigation/routesNames.ts | 1 + .../promoSheet/promoSheetCollectionQuery.ts | 96 ++++++++ src/resources/promoSheet/promoSheetQuery.ts | 79 +++++++ src/resources/reservoir/nftOffersQuery.ts | 129 +++++++---- src/screens/ChangeWalletSheet.tsx | 9 +- src/screens/NFTOffersSheet/index.tsx | 2 +- src/screens/NFTSingleOfferSheet/index.tsx | 2 +- src/screens/NotificationsPromoSheet/index.tsx | 2 +- .../components/SettingsSection.tsx | 1 - src/screens/SwapsPromoSheet.tsx | 84 ------- src/storage/index.ts | 4 +- src/storage/schema.ts | 11 + src/utils/index.ts | 1 + src/utils/resolveFirstRejectLast.ts | 48 ++++ 40 files changed, 1004 insertions(+), 317 deletions(-) delete mode 100644 src/campaigns/swapsPromoCampaign.ts create mode 100644 src/components/remote-promo-sheet/RemotePromoSheet.tsx create mode 100644 src/components/remote-promo-sheet/RemotePromoSheetProvider.tsx create mode 100644 src/components/remote-promo-sheet/check-fns/hasNftOffers.ts create mode 100644 src/components/remote-promo-sheet/check-fns/hasNonZeroAssetBalance.ts create mode 100644 src/components/remote-promo-sheet/check-fns/hasNonZeroTotalBalance.ts create mode 100644 src/components/remote-promo-sheet/check-fns/hasSwapTxn.ts create mode 100644 src/components/remote-promo-sheet/check-fns/index.ts create mode 100644 src/components/remote-promo-sheet/check-fns/isAfterCampaignLaunch.ts create mode 100644 src/components/remote-promo-sheet/check-fns/isSelectedWalletReadOnly.ts create mode 100644 src/components/remote-promo-sheet/check-fns/isSpecificAddress.ts create mode 100644 src/components/remote-promo-sheet/checkForCampaign.ts rename src/{campaigns/campaignChecks.ts => components/remote-promo-sheet/localCampaignChecks.ts} (78%) rename src/{campaigns => components/remote-promo-sheet}/notificationsPromoCampaign.ts (98%) create mode 100644 src/resources/promoSheet/promoSheetCollectionQuery.ts create mode 100644 src/resources/promoSheet/promoSheetQuery.ts delete mode 100644 src/screens/SwapsPromoSheet.tsx create mode 100644 src/utils/resolveFirstRejectLast.ts diff --git a/src/App.js b/src/App.js index 8c84df24dfa..a5c6f9e420d 100644 --- a/src/App.js +++ b/src/App.js @@ -32,10 +32,7 @@ import { Playground } from './design-system/playground/Playground'; import { TransactionType } from './entities'; import appEvents from './handlers/appEvents'; import handleDeeplink from './handlers/deeplinks'; -import { - runFeatureAndCampaignChecks, - runWalletBackupStatusChecks, -} from './handlers/walletReadyEvents'; +import { runWalletBackupStatusChecks } from './handlers/walletReadyEvents'; import { getCachedProviderForNetwork, isHardHat, @@ -85,6 +82,7 @@ import branch from 'react-native-branch'; import { initializeReservoirClient } from '@/resources/reservoir/client'; import { ReviewPromptAction } from '@/storage/schema'; import { handleReviewPromptAction } from '@/utils/reviewAlert'; +import { RemotePromoSheetProvider } from '@/components/remote-promo-sheet/RemotePromoSheetProvider'; if (__DEV__) { reactNativeDisableYellowBox && LogBox.ignoreAllLogs(); @@ -187,15 +185,6 @@ class OldApp extends Component { // Everything we need to do after the wallet is ready goes here logger.info('✅ Wallet ready!'); runWalletBackupStatusChecks(); - - InteractionManager.runAfterInteractions(() => { - setTimeout(() => { - if (IS_TESTING === 'true') { - return; - } - runFeatureAndCampaignChecks(); - }, 2000); - }); } } @@ -284,13 +273,15 @@ class OldApp extends Component { {this.state.initialRoute && ( - - - - + + + + + + )} diff --git a/src/analytics/event.ts b/src/analytics/event.ts index 5af645fe65a..809d161da0c 100644 --- a/src/analytics/event.ts +++ b/src/analytics/event.ts @@ -13,6 +13,8 @@ export const event = { appStateChange: 'State change', analyticsTrackingDisabled: 'analytics_tracking.disabled', analyticsTrackingEnabled: 'analytics_tracking.enabled', + promoSheetShown: 'promo_sheet.shown', + promoSheetDismissed: 'promo_sheet.dismissed', swapSubmitted: 'Submitted Swap', // notification promo sheet was shown notificationsPromoShown: 'notifications_promo.shown', @@ -120,6 +122,14 @@ export type EventProperties = { inputCurrencySymbol: string; outputCurrencySymbol: string; }; + [event.promoSheetShown]: { + campaign: string; + time_viewed: number; + }; + [event.promoSheetDismissed]: { + campaign: string; + time_viewed: number; + }; [event.notificationsPromoShown]: undefined; [event.notificationsPromoPermissionsBlocked]: undefined; [event.notificationsPromoPermissionsGranted]: undefined; diff --git a/src/campaigns/swapsPromoCampaign.ts b/src/campaigns/swapsPromoCampaign.ts deleted file mode 100644 index 0b33ba1b760..00000000000 --- a/src/campaigns/swapsPromoCampaign.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { MMKV } from 'react-native-mmkv'; -import { - Campaign, - CampaignCheckType, - CampaignKey, - GenericCampaignCheckResponse, -} from './campaignChecks'; -import { EthereumAddress, RainbowTransaction } from '@/entities'; -import { Network } from '@/helpers/networkTypes'; -import WalletTypes from '@/helpers/walletTypes'; -import { RainbowWallet } from '@/model/wallet'; -import { Navigation } from '@/navigation'; -import { ethereumUtils, logger } from '@/utils'; -import store from '@/redux/store'; -import Routes from '@/navigation/routesNames'; -import { STORAGE_IDS } from '@/model/mmkv'; -import { RainbowNetworks } from '@/networks'; - -// Rainbow Router -const RAINBOW_ROUTER_ADDRESS: EthereumAddress = - '0x00000000009726632680fb29d3f7a9734e3010e2'; - -const swapsLaunchDate = new Date('2022-07-26'); -const isAfterSwapsLaunch = (tx: RainbowTransaction): boolean => { - if (tx.minedAt) { - const txDate = new Date(tx.minedAt * 1000); - return txDate > swapsLaunchDate; - } - return false; -}; - -const isSwapTx = (tx: RainbowTransaction): boolean => - tx?.to?.toLowerCase() === RAINBOW_ROUTER_ADDRESS; - -const mmkv = new MMKV(); - -export const swapsCampaignAction = async () => { - logger.log('Campaign: Showing Swaps Promo'); - - mmkv.set(CampaignKey.swapsLaunch, true); - setTimeout(() => { - logger.log('triggering swaps promo action'); - - Navigation.handleAction(Routes.SWAPS_PROMO_SHEET, {}); - }, 1000); -}; - -export enum SwapsPromoCampaignExclusion { - noAssets = 'no_assets', - alreadySwapped = 'already_swapped', - wrongNetwork = 'wrong_network', -} - -export const swapsCampaignCheck = async (): Promise< - SwapsPromoCampaignExclusion | GenericCampaignCheckResponse -> => { - const hasShownCampaign = mmkv.getBoolean(CampaignKey.swapsLaunch); - const isFirstLaunch = mmkv.getBoolean(STORAGE_IDS.FIRST_APP_LAUNCH); - - const { - selected: currentWallet, - }: { - selected: RainbowWallet | undefined; - } = store.getState().wallets; - - /** - * stop if: - * there's no wallet - * the current wallet is read only - * the campaign has already been activated - * the user is launching Rainbow for the first time - */ - if ( - !currentWallet || - currentWallet.type === WalletTypes.readOnly || - isFirstLaunch || - hasShownCampaign - ) { - return GenericCampaignCheckResponse.nonstarter; - } - - const { - accountAddress, - network: currentNetwork, - }: { - accountAddress: EthereumAddress; - network: Network; - } = store.getState().settings; - - if (currentNetwork !== Network.mainnet) - return SwapsPromoCampaignExclusion.wrongNetwork; - // transactions are loaded from the current wallet - const { transactions } = store.getState().data; - - const networks: Network[] = RainbowNetworks.filter( - network => network.features.swaps - ).map(network => network.value); - - // check native asset balances on networks that support swaps - let hasBalance = false; - await networks.forEach(async (network: Network) => { - const nativeAsset = await ethereumUtils.getNativeAssetForNetwork( - network, - accountAddress - ); - const balance = Number(nativeAsset?.balance?.amount); - if (balance > 0) { - hasBalance = true; - } - }); - - // if the wallet has no native asset balances then stop - if (!hasBalance) return SwapsPromoCampaignExclusion.noAssets; - - const hasSwapped = !!transactions.filter(isAfterSwapsLaunch).find(isSwapTx); - - // if they have not swapped yet, trigger campaign action - if (!hasSwapped) { - SwapsPromoCampaign.action(); - return GenericCampaignCheckResponse.activated; - } - return SwapsPromoCampaignExclusion.alreadySwapped; -}; - -export const SwapsPromoCampaign: Campaign = { - action: async () => await swapsCampaignAction(), - campaignKey: CampaignKey.swapsLaunch, - check: async () => await swapsCampaignCheck(), - checkType: CampaignCheckType.deviceOrWallet, -}; diff --git a/src/components/PromoSheet.tsx b/src/components/PromoSheet.tsx index 42a404aa8a4..d980c6aba65 100644 --- a/src/components/PromoSheet.tsx +++ b/src/components/PromoSheet.tsx @@ -3,8 +3,8 @@ import { ImageSourcePropType, StatusBar, ImageBackground } from 'react-native'; import LinearGradient from 'react-native-linear-gradient'; import MaskedView from '@react-native-masked-view/masked-view'; import { SheetActionButton, SheetHandle, SlackSheet } from '@/components/sheet'; -import { CampaignKey } from '@/campaigns/campaignChecks'; -import { analytics } from '@/analytics'; +import { CampaignKey } from '@/components/remote-promo-sheet/localCampaignChecks'; +import { analyticsV2 } from '@/analytics'; import { AccentColorProvider, Box, @@ -38,7 +38,7 @@ type PromoSheetProps = { backgroundColor: string; accentColor: string; sheetHandleColor?: string; - campaignKey: CampaignKey; + campaignKey: CampaignKey | string; header: string; subHeader: string; primaryButtonProps: SheetActionButtonProps; @@ -74,7 +74,7 @@ export function PromoSheet({ () => () => { if (!activated) { const timeElapsed = (Date.now() - renderedAt) / 1000; - analytics.track('Dismissed Feature Promo', { + analyticsV2.track(analyticsV2.event.promoSheetDismissed, { campaign: campaignKey, time_viewed: timeElapsed, }); @@ -86,12 +86,12 @@ export function PromoSheet({ const primaryButtonOnPress = useCallback(() => { activate(); const timeElapsed = (Date.now() - renderedAt) / 1000; - analytics.track('Activated Feature Promo Action', { + analyticsV2.track(analyticsV2.event.promoSheetShown, { campaign: campaignKey, time_viewed: timeElapsed, }); primaryButtonProps.onPress(); - }, [activate, campaignKey, primaryButtonProps.onPress, renderedAt]); + }, [activate, campaignKey, primaryButtonProps, renderedAt]); // We are not using `isSmallPhone` from `useDimensions` here as we // want to explicitly set a min height. diff --git a/src/components/cards/NFTOffersCard/index.tsx b/src/components/cards/NFTOffersCard/index.tsx index 64fee7d0cec..22d1ed5aca9 100644 --- a/src/components/cards/NFTOffersCard/index.tsx +++ b/src/components/cards/NFTOffersCard/index.tsx @@ -253,7 +253,7 @@ export const NFTOffersCard = () => { setCanRefresh(false); queryClient.invalidateQueries( nftOffersQueryKey({ - address: accountAddress, + walletAddress: accountAddress, }) ); }} diff --git a/src/components/remote-promo-sheet/RemotePromoSheet.tsx b/src/components/remote-promo-sheet/RemotePromoSheet.tsx new file mode 100644 index 00000000000..bcb3b71efb7 --- /dev/null +++ b/src/components/remote-promo-sheet/RemotePromoSheet.tsx @@ -0,0 +1,214 @@ +import React, { useCallback, useEffect } from 'react'; +import { useRoute, RouteProp } from '@react-navigation/native'; +import { get } from 'lodash'; + +import { useNavigation } from '@/navigation/Navigation'; +import { PromoSheet } from '@/components/PromoSheet'; +import { useTheme } from '@/theme'; +import { CampaignCheckResult } from './checkForCampaign'; +import { usePromoSheetQuery } from '@/resources/promoSheet/promoSheetQuery'; +import { maybeSignUri } from '@/handlers/imgix'; +import { campaigns } from '@/storage'; +import { delay } from '@/utils/delay'; +import { Linking } from 'react-native'; +import Routes from '@/navigation/routesNames'; +import { Language } from '@/languages'; +import { useAccountSettings } from '@/hooks'; + +const DEFAULT_HEADER_HEIGHT = 285; +const DEFAULT_HEADER_WIDTH = 390; + +type RootStackParamList = { + RemotePromoSheet: CampaignCheckResult; +}; + +type Item = { + title: Record; + description: Record; + icon: string; + gradient?: string; +}; + +const enum ButtonType { + Internal = 'Internal', + External = 'External', +} + +const getKeyForLanguage = ( + key: string, + promoSheet: any, + language: Language +) => { + if (!promoSheet) { + return ''; + } + + const objectOrPrimitive = get(promoSheet, key); + if (typeof objectOrPrimitive === 'undefined') { + return ''; + } + + if (objectOrPrimitive[language]) { + return objectOrPrimitive[language]; + } + + return objectOrPrimitive[Language.EN_US] ?? ''; +}; + +export function RemotePromoSheet() { + const { colors } = useTheme(); + const { goBack, navigate } = useNavigation(); + const { params } = useRoute< + RouteProp + >(); + const { campaignId, campaignKey } = params; + const { language } = useAccountSettings(); + + useEffect(() => { + return () => { + campaigns.set(['isCurrentlyShown'], false); + }; + }, []); + + const { data, error } = usePromoSheetQuery( + { + id: campaignId, + }, + { + enabled: !!campaignId, + } + ); + + const getButtonForType = (type: ButtonType) => { + switch (type) { + default: + case ButtonType.Internal: + return () => internalNavigation(); + case ButtonType.External: + return () => externalNavigation(); + } + }; + + const externalNavigation = useCallback(() => { + Linking.openURL(data?.promoSheet?.primaryButtonProps.props.url); + }, []); + + const internalNavigation = useCallback(() => { + goBack(); + + delay(300).then(() => + navigate( + (Routes as any)[data?.promoSheet?.primaryButtonProps.props.route], + { + ...(data?.promoSheet?.primaryButtonProps.props.options || {}), + } + ) + ); + }, [goBack, navigate, data?.promoSheet]); + + if (!data?.promoSheet || error) { + return null; + } + + const { + accentColor: accentColorString, + backgroundColor: backgroundColorString, + sheetHandleColor: sheetHandleColorString, + backgroundImage, + headerImage, + headerImageAspectRatio, + items, + primaryButtonProps, + secondaryButtonProps, + } = data.promoSheet; + + const accentColor = + (colors as { [key: string]: any })[accentColorString as string] ?? + accentColorString; + + const backgroundColor = + (colors as { [key: string]: any })[backgroundColorString as string] ?? + backgroundColorString; + + const sheetHandleColor = + (colors as { [key: string]: any })[sheetHandleColorString as string] ?? + sheetHandleColorString; + + const backgroundSignedImageUrl = backgroundImage?.url + ? maybeSignUri(backgroundImage.url) + : undefined; + + const headerSignedImageUrl = headerImage?.url + ? maybeSignUri(headerImage.url) + : undefined; + + return ( + { + const title = getKeyForLanguage('title', item, language as Language); + const description = getKeyForLanguage( + 'description', + item, + language as Language + ); + + let gradient = undefined; + if (item.gradient) { + gradient = get(colors.gradients, item.gradient); + } + + return { + ...item, + title, + description, + gradient, + }; + })} + /> + ); +} diff --git a/src/components/remote-promo-sheet/RemotePromoSheetProvider.tsx b/src/components/remote-promo-sheet/RemotePromoSheetProvider.tsx new file mode 100644 index 00000000000..f18975e0e40 --- /dev/null +++ b/src/components/remote-promo-sheet/RemotePromoSheetProvider.tsx @@ -0,0 +1,78 @@ +import React, { + useEffect, + createContext, + PropsWithChildren, + useCallback, + useContext, +} from 'react'; +import { IS_TESTING } from 'react-native-dotenv'; +import { InteractionManager } from 'react-native'; +import { noop } from 'lodash'; + +import { REMOTE_PROMO_SHEETS, useExperimentalFlag } from '@/config'; +import { logger } from '@/logger'; +import { campaigns } from '@/storage'; +import { checkForCampaign } from '@/components/remote-promo-sheet/checkForCampaign'; +import { runFeatureUnlockChecks } from '@/handlers/walletReadyEvents'; +import { runLocalCampaignChecks } from './localCampaignChecks'; + +interface WalletReadyContext { + isWalletReady: boolean; + runChecks: () => void; +} + +export const RemotePromoSheetContext = createContext({ + isWalletReady: false, + runChecks: noop, +}); + +type WalletReadyProvider = PropsWithChildren & WalletReadyContext; + +export const RemotePromoSheetProvider = ({ + isWalletReady = false, + children, +}: WalletReadyProvider) => { + const remotePromoSheets = useExperimentalFlag(REMOTE_PROMO_SHEETS); + + const runChecks = useCallback(async () => { + if (!isWalletReady) return; + + InteractionManager.runAfterInteractions(async () => { + setTimeout(async () => { + if (IS_TESTING === 'true') return; + + // Stop checking for promo sheets if the exp. flag is toggled off + if (!remotePromoSheets) { + logger.info('Campaigns: remote promo sheets is disabled'); + return; + } + + const showedFeatureUnlock = await runFeatureUnlockChecks(); + if (showedFeatureUnlock) return; + + const showedLocalPromo = await runLocalCampaignChecks(); + if (showedLocalPromo) return; + + checkForCampaign(); + }, 2_000); + }); + }, [isWalletReady, remotePromoSheets]); + + useEffect(() => { + runChecks(); + + return () => { + campaigns.remove(['lastShownTimestamp']); + campaigns.set(['isCurrentlyShown'], false); + }; + }, [runChecks]); + + return ( + + {children} + + ); +}; + +export const useRemotePromoSheetContext = () => + useContext(RemotePromoSheetContext); diff --git a/src/components/remote-promo-sheet/check-fns/hasNftOffers.ts b/src/components/remote-promo-sheet/check-fns/hasNftOffers.ts new file mode 100644 index 00000000000..0d977b313ee --- /dev/null +++ b/src/components/remote-promo-sheet/check-fns/hasNftOffers.ts @@ -0,0 +1,15 @@ +import store from '@/redux/store'; +import { fetchNftOffers } from '@/resources/reservoir/nftOffersQuery'; + +export async function hasNftOffers(): Promise { + const { accountAddress } = store.getState().settings; + + try { + const data = await fetchNftOffers({ walletAddress: accountAddress }); + if (!data?.nftOffers) return false; + + return data?.nftOffers?.length > 1; + } catch (e) { + return false; + } +} diff --git a/src/components/remote-promo-sheet/check-fns/hasNonZeroAssetBalance.ts b/src/components/remote-promo-sheet/check-fns/hasNonZeroAssetBalance.ts new file mode 100644 index 00000000000..8c3d31f1698 --- /dev/null +++ b/src/components/remote-promo-sheet/check-fns/hasNonZeroAssetBalance.ts @@ -0,0 +1,38 @@ +import store from '@/redux/store'; +import { EthereumAddress } from '@/entities'; +import { ActionFn } from '../checkForCampaign'; +import { fetchUserAssets } from '@/resources/assets/UserAssetsQuery'; +import { Network } from '@/helpers'; + +type props = { + assetAddress: EthereumAddress; + network?: Network; +}; + +export const hasNonZeroAssetBalance: ActionFn = async ({ + assetAddress, + network, +}) => { + const { accountAddress, nativeCurrency } = store.getState().settings; + + const assets = await fetchUserAssets({ + address: accountAddress, + currency: nativeCurrency, + connectedToHardhat: false, + }); + if (!assets || Object.keys(assets).length === 0) return false; + + const desiredAsset = Object.values(assets).find(asset => { + if (!network) { + return asset.uniqueId.toLowerCase() === assetAddress.toLowerCase(); + } + + return ( + asset.uniqueId.toLowerCase() === assetAddress.toLowerCase() && + asset.network === network + ); + }); + if (!desiredAsset) return false; + + return Number(desiredAsset.balance?.amount) > 0; +}; diff --git a/src/components/remote-promo-sheet/check-fns/hasNonZeroTotalBalance.ts b/src/components/remote-promo-sheet/check-fns/hasNonZeroTotalBalance.ts new file mode 100644 index 00000000000..2d0976b21df --- /dev/null +++ b/src/components/remote-promo-sheet/check-fns/hasNonZeroTotalBalance.ts @@ -0,0 +1,16 @@ +import store from '@/redux/store'; +import { fetchUserAssets } from '@/resources/assets/UserAssetsQuery'; + +export const hasNonZeroTotalBalance = async (): Promise => { + const { accountAddress, nativeCurrency } = store.getState().settings; + + const assets = await fetchUserAssets({ + address: accountAddress, + currency: nativeCurrency, + connectedToHardhat: false, + }); + + if (!assets || Object.keys(assets).length === 0) return false; + + return Object.values(assets).some(asset => Number(asset.balance?.amount) > 0); +}; diff --git a/src/components/remote-promo-sheet/check-fns/hasSwapTxn.ts b/src/components/remote-promo-sheet/check-fns/hasSwapTxn.ts new file mode 100644 index 00000000000..be6dcdff593 --- /dev/null +++ b/src/components/remote-promo-sheet/check-fns/hasSwapTxn.ts @@ -0,0 +1,17 @@ +import type { EthereumAddress, RainbowTransaction } from '@/entities'; +import store from '@/redux/store'; + +// Rainbow Router +const RAINBOW_ROUTER_ADDRESS: EthereumAddress = + '0x00000000009726632680fb29d3f7a9734e3010e2'; + +const isSwapTx = (tx: RainbowTransaction): boolean => + tx.to?.toLowerCase() === RAINBOW_ROUTER_ADDRESS; + +export const hasSwapTxn = async (): Promise => { + const { transactions } = store.getState().data; + + if (!transactions.length) return false; + + return !!transactions.find(isSwapTx); +}; diff --git a/src/components/remote-promo-sheet/check-fns/index.ts b/src/components/remote-promo-sheet/check-fns/index.ts new file mode 100644 index 00000000000..5e3c68d2474 --- /dev/null +++ b/src/components/remote-promo-sheet/check-fns/index.ts @@ -0,0 +1,7 @@ +export * from './hasNftOffers'; +export * from './hasNonZeroAssetBalance'; +export * from './hasNonZeroTotalBalance'; +export * from './hasSwapTxn'; +export * from './isAfterCampaignLaunch'; +export * from './isSelectedWalletReadOnly'; +export * from './isSpecificAddress'; diff --git a/src/components/remote-promo-sheet/check-fns/isAfterCampaignLaunch.ts b/src/components/remote-promo-sheet/check-fns/isAfterCampaignLaunch.ts new file mode 100644 index 00000000000..0f858f7e715 --- /dev/null +++ b/src/components/remote-promo-sheet/check-fns/isAfterCampaignLaunch.ts @@ -0,0 +1,5 @@ +import { PromoSheet } from '@/graphql/__generated__/arc'; + +export const isAfterCampaignLaunch = ({ launchDate }: PromoSheet): boolean => { + return new Date() > new Date(launchDate); +}; diff --git a/src/components/remote-promo-sheet/check-fns/isSelectedWalletReadOnly.ts b/src/components/remote-promo-sheet/check-fns/isSelectedWalletReadOnly.ts new file mode 100644 index 00000000000..594399a6f41 --- /dev/null +++ b/src/components/remote-promo-sheet/check-fns/isSelectedWalletReadOnly.ts @@ -0,0 +1,13 @@ +import store from '@/redux/store'; +import WalletTypes from '@/helpers/walletTypes'; + +export const isSelectedWalletReadOnly = (): boolean => { + const { selected } = store.getState().wallets; + + // if no selected wallet, we will treat it as a read-only wallet + if (!selected || selected.type === WalletTypes.readOnly) { + return true; + } + + return false; +}; diff --git a/src/components/remote-promo-sheet/check-fns/isSpecificAddress.ts b/src/components/remote-promo-sheet/check-fns/isSpecificAddress.ts new file mode 100644 index 00000000000..6a4111cce2b --- /dev/null +++ b/src/components/remote-promo-sheet/check-fns/isSpecificAddress.ts @@ -0,0 +1,14 @@ +import store from '@/redux/store'; +import { EthereumAddress } from '@/entities'; +import { ActionFn } from '../checkForCampaign'; + +type props = { + addresses: EthereumAddress[]; +}; + +export const isSpecificAddress: ActionFn = async ({ addresses }) => { + const { accountAddress } = store.getState().settings; + return addresses + .map(address => address.toLowerCase()) + .includes(accountAddress.toLowerCase()); +}; diff --git a/src/components/remote-promo-sheet/checkForCampaign.ts b/src/components/remote-promo-sheet/checkForCampaign.ts new file mode 100644 index 00000000000..76eacbc1d20 --- /dev/null +++ b/src/components/remote-promo-sheet/checkForCampaign.ts @@ -0,0 +1,152 @@ +import { InteractionManager } from 'react-native'; +import { Navigation } from '@/navigation'; +import Routes from '@/navigation/routesNames'; +import { fetchPromoSheetCollection } from '@/resources/promoSheet/promoSheetCollectionQuery'; +import { logger } from '@/logger'; +import { PromoSheet, PromoSheetOrder } from '@/graphql/__generated__/arc'; +import { campaigns, device } from '@/storage'; + +import * as fns from './check-fns'; + +type ActionObj = { + fn: string; + outcome: boolean; + props: object; +}; + +export type ActionFn = (props: T) => boolean | Promise; + +export type CampaignCheckResult = { + campaignId: string; + campaignKey: string; +}; + +const TIMEOUT_BETWEEN_PROMOS = 5 * 60 * 1000; // 5 minutes in milliseconds + +const timeBetweenPromoSheets = () => { + const lastShownTimestamp = campaigns.get(['lastShownTimestamp']); + + if (!lastShownTimestamp) return TIMEOUT_BETWEEN_PROMOS; + + return Date.now() - lastShownTimestamp; +}; + +export const checkForCampaign = async () => { + logger.info('Campaigns: Running Checks'); + if (timeBetweenPromoSheets() < TIMEOUT_BETWEEN_PROMOS) { + logger.info('Campaigns: Time between promos has not exceeded timeout'); + return; + } + + let isCurrentlyShown = campaigns.get(['isCurrentlyShown']); + if (isCurrentlyShown) { + logger.info('Campaigns: Promo sheet is already shown'); + return; + } + + const isReturningUser = device.get(['isReturningUser']); + + if (!isReturningUser) { + logger.info('Campaigns: First launch, not showing promo sheet'); + return; + } + + const { promoSheetCollection } = await fetchPromoSheetCollection({ + order: [PromoSheetOrder.PriorityDesc], + }); + + for (const promo of promoSheetCollection?.items || []) { + if (!promo) continue; + logger.info(`Campaigns: Checking ${promo.sys.id}`); + const result = await shouldPromptCampaign(promo as PromoSheet); + + logger.info(`Campaigns: ${promo.sys.id} will show: ${result}`); + if (result) { + isCurrentlyShown = campaigns.get(['isCurrentlyShown']); + if (!isCurrentlyShown) { + return triggerCampaign(promo as PromoSheet); + } + } + } +}; + +export const triggerCampaign = async ({ + campaignKey, + sys: { id: campaignId }, +}: PromoSheet) => { + logger.info(`Campaigns: Showing ${campaignKey} Promo`); + + setTimeout(() => { + campaigns.set([campaignKey as string], true); + campaigns.set(['isCurrentlyShown'], true); + campaigns.set(['lastShownTimestamp'], Date.now()); + InteractionManager.runAfterInteractions(() => { + Navigation.handleAction(Routes.REMOTE_PROMO_SHEET, { + campaignId, + campaignKey, + }); + }); + }, 1000); +}; + +export const shouldPromptCampaign = async ( + campaign: PromoSheet +): Promise => { + const { + campaignKey, + sys: { id }, + actions, + } = campaign; + + // if we aren't given proper campaign data or actions to check against, exit early here + if (!campaignKey || !id) return false; + + // sanity check to prevent showing a campaign twice to a user or potentially showing a campaign to a fresh user + const hasShown = campaigns.get([campaignKey]); + + logger.info( + `Campaigns: Checking if we should prompt campaign ${campaignKey}` + ); + + const isPreviewing = actions.some( + (action: ActionObj) => action.fn === 'isPreviewing' + ); + + // If the campaign has been viewed already or it's the first app launch, exit early + if (hasShown && !isPreviewing) { + logger.info(`Campaigns: User has already been shown ${campaignKey}`); + return false; + } + + const actionsArray = actions || ([] as ActionObj[]); + let shouldPrompt = true; + + for (const actionObj of actionsArray) { + const { fn, outcome, props = {} } = actionObj; + const action = __INTERNAL_ACTION_CHECKS[fn]; + if (typeof action === 'undefined') { + continue; + } + + logger.info(`Campaigns: Checking action ${fn}`); + const result = await action({ ...props, ...campaign }); + logger.info( + `Campaigns: [${fn}] matches desired outcome: => ${result === outcome}` + ); + + if (result !== outcome) { + shouldPrompt = false; + break; + } + } + + // if all action checks pass, we will show the promo to the user + return shouldPrompt; +}; + +export const __INTERNAL_ACTION_CHECKS: { + [key: string]: ActionFn; +} = Object.keys(fns).reduce((acc, fnKey) => { + acc[fnKey] = fns[fnKey as keyof typeof fns]; + return acc; +}, {} as { [key: string]: ActionFn }); diff --git a/src/campaigns/campaignChecks.ts b/src/components/remote-promo-sheet/localCampaignChecks.ts similarity index 78% rename from src/campaigns/campaignChecks.ts rename to src/components/remote-promo-sheet/localCampaignChecks.ts index b062736d66d..e25a1884063 100644 --- a/src/campaigns/campaignChecks.ts +++ b/src/components/remote-promo-sheet/localCampaignChecks.ts @@ -1,13 +1,8 @@ -import { - SwapsPromoCampaign, - SwapsPromoCampaignExclusion, -} from './swapsPromoCampaign'; import { NotificationsPromoCampaign } from './notificationsPromoCampaign'; import { analytics } from '@/analytics'; import { logger } from '@/utils'; export enum CampaignKey { - swapsLaunch = 'swaps_launch', notificationsLaunch = 'notifications_launch', } @@ -22,9 +17,7 @@ export enum GenericCampaignCheckResponse { nonstarter = 'nonstarter', } -export type CampaignCheckResponse = - | GenericCampaignCheckResponse - | SwapsPromoCampaignExclusion; +export type CampaignCheckResponse = GenericCampaignCheckResponse; export interface Campaign { action(): Promise; // Function to call on activating the campaign @@ -34,12 +27,9 @@ export interface Campaign { } // the ordering of this list is IMPORTANT, this is the order that campaigns will be run -export const activeCampaigns: Campaign[] = [ - SwapsPromoCampaign, - NotificationsPromoCampaign, -]; +export const activeCampaigns: Campaign[] = [NotificationsPromoCampaign]; -export const runCampaignChecks = async (): Promise => { +export const runLocalCampaignChecks = async (): Promise => { logger.log('Campaigns: Running Checks'); for (const campaign of activeCampaigns) { const response = await campaign.check(); diff --git a/src/campaigns/notificationsPromoCampaign.ts b/src/components/remote-promo-sheet/notificationsPromoCampaign.ts similarity index 98% rename from src/campaigns/notificationsPromoCampaign.ts rename to src/components/remote-promo-sheet/notificationsPromoCampaign.ts index ad59f1ca8cd..ad46a99c731 100644 --- a/src/campaigns/notificationsPromoCampaign.ts +++ b/src/components/remote-promo-sheet/notificationsPromoCampaign.ts @@ -4,7 +4,7 @@ import { CampaignCheckType, CampaignKey, GenericCampaignCheckResponse, -} from './campaignChecks'; +} from './localCampaignChecks'; import { RainbowWallet } from '@/model/wallet'; import { Navigation } from '@/navigation'; import { logger } from '@/logger'; diff --git a/src/config/experimental.ts b/src/config/experimental.ts index 21492c7ca8d..150b59374a0 100644 --- a/src/config/experimental.ts +++ b/src/config/experimental.ts @@ -19,6 +19,7 @@ export const OP_REWARDS = '$OP Rewards'; export const DEFI_POSITIONS = 'Defi Positions'; export const NFT_OFFERS = 'NFT Offers'; export const MINTS = 'Mints'; +export const REMOTE_PROMO_SHEETS = 'RemotePromoSheets'; /** * A developer setting that pushes log lines to an array in-memory so that @@ -47,6 +48,7 @@ export const defaultConfig: Record = { [DEFI_POSITIONS]: { settings: true, value: true }, [NFT_OFFERS]: { settings: true, value: true }, [MINTS]: { settings: true, value: false }, + [REMOTE_PROMO_SHEETS]: { settings: true, value: false }, }; const storageKey = 'config'; diff --git a/src/featuresToUnlock/unlockableAppIconCheck.ts b/src/featuresToUnlock/unlockableAppIconCheck.ts index 8abe06bdac4..1fb62662724 100644 --- a/src/featuresToUnlock/unlockableAppIconCheck.ts +++ b/src/featuresToUnlock/unlockableAppIconCheck.ts @@ -6,6 +6,7 @@ import { Navigation } from '@/navigation'; import { logger } from '@/utils'; import Routes from '@/navigation/routesNames'; import { analytics } from '@/analytics'; +import { campaigns } from '@/storage'; const mmkv = new MMKV(); @@ -69,6 +70,7 @@ export const unlockableAppIconCheck = async ( }, 300); }, handleClose: () => { + campaigns.set(['isCurrentlyShown'], false); analytics.track('Dismissed App Icon Unlock', { campaign: key }); }, }); diff --git a/src/graphql/queries/arc.graphql b/src/graphql/queries/arc.graphql index c05e9439b19..9d58ff97137 100644 --- a/src/graphql/queries/arc.graphql +++ b/src/graphql/queries/arc.graphql @@ -202,3 +202,38 @@ query getMintableCollections($walletAddress: String!) { } } } + +query getPromoSheetCollection($order: [PromoSheetOrder]) { + promoSheetCollection(order: $order) { + items { + sys { + id + } + campaignKey + launchDate + actions + priority + } + } +} + +query getPromoSheet($id: String!) { + promoSheet(id: $id) { + accentColor + actions + backgroundColor + backgroundImage { + url + } + header + headerImage { + url + } + headerImageAspectRatio + items + primaryButtonProps + secondaryButtonProps + sheetHandleColor + subHeader + } +} diff --git a/src/handlers/walletReadyEvents.ts b/src/handlers/walletReadyEvents.ts index e7c8bd98c6d..134ed6c7eb3 100644 --- a/src/handlers/walletReadyEvents.ts +++ b/src/handlers/walletReadyEvents.ts @@ -1,7 +1,7 @@ import { IS_TESTING } from 'react-native-dotenv'; import { triggerOnSwipeLayout } from '../navigation/onNavigationStateChange'; import { getKeychainIntegrityState } from './localstorage/globalSettings'; -import { runCampaignChecks } from '@/campaigns/campaignChecks'; +import { runLocalCampaignChecks } from '@/components/remote-promo-sheet/localCampaignChecks'; import { EthereumAddress } from '@/entities'; import WalletBackupStepTypes from '@/helpers/walletBackupStepTypes'; import WalletTypes from '@/helpers/walletTypes'; @@ -124,9 +124,9 @@ export const runFeatureUnlockChecks = async (): Promise => { return false; }; -export const runFeatureAndCampaignChecks = async () => { +export const runFeatureAndLocalCampaignChecks = async () => { const showingFeatureUnlock: boolean = await runFeatureUnlockChecks(); if (!showingFeatureUnlock) { - await runCampaignChecks(); + await runLocalCampaignChecks(); } }; diff --git a/src/languages/en_US.json b/src/languages/en_US.json index 5446923ee39..53106dde7f8 100644 --- a/src/languages/en_US.json +++ b/src/languages/en_US.json @@ -1453,6 +1453,27 @@ "subheader": "INTRODUCING" } }, + "nft_offers_launch": { + "primary_button": { + "has_offers": "View Offers", + "not_has_offers": "Close" + }, + "secondary_button": "Maybe Later", + "info_row_1": { + "title": "All your offers in one place", + "description": "View your offers across all NFT marketplaces." + }, + "info_row_2": { + "title": "Accept from Rainbow", + "description": "Accept offers in a single tap and swap to the token of your choosing." + }, + "info_row_3": { + "title": "Smart sort modes", + "description": "Compare to floor prices, or view your highest or most recent offers." + }, + "header": "Offers", + "subheader": "INTRODUCING" + }, "review": { "alert": { "are_you_enjoying_rainbow": "Are you enjoying Rainbow? 🥰", diff --git a/src/model/mmkv.ts b/src/model/mmkv.ts index f405c2b7a42..557ebcff321 100644 --- a/src/model/mmkv.ts +++ b/src/model/mmkv.ts @@ -11,6 +11,8 @@ export const STORAGE_IDS = { NOTIFICATIONS: 'NOTIFICATIONS', RAINBOW_TOKEN_LIST: 'LEAN_RAINBOW_TOKEN_LIST', SHOWN_SWAP_RESET_WARNING: 'SHOWN_SWAP_RESET_WARNING', + PROMO_CURRENTLY_SHOWN: 'PROMO_CURRENTLY_SHOWN', + LAST_PROMO_SHEET_TIMESTAMP: 'LAST_PROMO_SHEET_TIMESTAMP', }; export const clearAllStorages = () => { diff --git a/src/navigation/Routes.android.tsx b/src/navigation/Routes.android.tsx index 87300caa358..3967add7d59 100644 --- a/src/navigation/Routes.android.tsx +++ b/src/navigation/Routes.android.tsx @@ -23,7 +23,6 @@ import { SendConfirmationSheet } from '../screens/SendConfirmationSheet'; import SendSheet from '../screens/SendSheet'; import ShowcaseSheet from '../screens/ShowcaseSheet'; import SpeedUpAndCancelSheet from '../screens/SpeedUpAndCancelSheet'; -import SwapsPromoSheet from '../screens/SwapsPromoSheet'; import NotificationsPromoSheet from '../screens/NotificationsPromoSheet'; import TransactionConfirmationScreen from '../screens/TransactionConfirmationScreen'; import WalletConnectApprovalSheet from '../screens/WalletConnectApprovalSheet'; @@ -82,6 +81,7 @@ import PoapSheet from '@/screens/mints/PoapSheet'; import { PositionSheet } from '@/screens/positions/PositionSheet'; import MintSheet from '@/screens/mints/MintSheet'; import { MintsSheet } from '@/screens/MintsSheet/MintsSheet'; +import { RemotePromoSheet } from '@/components/remote-promo-sheet/RemotePromoSheet'; const Stack = createStackNavigator(); const OuterStack = createStackNavigator(); @@ -307,8 +307,8 @@ function BSNavigator() { )} + createQueryKey('promoSheetCollection', { order }, { persisterVersion: 1 }); + +type PromoSheetCollectionQueryKey = ReturnType< + typeof promoSheetCollectionQueryKey +>; + +// /////////////////////////////////////////////// +// Query Function + +async function promoSheetCollectionQueryFunction({ + queryKey: [{ order }], +}: QueryFunctionArgs) { + const data = await arcDevClient.getPromoSheetCollection({ order }); + return data; +} + +export type PromoSheetCollectionResult = QueryFunctionResult< + typeof promoSheetCollectionQueryFunction +>; + +// /////////////////////////////////////////////// +// Query Prefetcher + +export async function prefetchPromoSheetCollection( + { order }: PromoSheetCollectionArgs, + config: QueryConfig< + PromoSheetCollectionResult, + Error, + PromoSheetCollectionQueryKey + > = {} +) { + return await queryClient.prefetchQuery( + promoSheetCollectionQueryKey({ order }), + promoSheetCollectionQueryFunction, + config + ); +} + +// /////////////////////////////////////////////// +// Query Fetcher + +export async function fetchPromoSheetCollection({ + order, +}: PromoSheetCollectionArgs) { + return await queryClient.fetchQuery( + promoSheetCollectionQueryKey({ order }), + promoSheetCollectionQueryFunction, + { staleTime: defaultStaleTime } + ); +} + +// /////////////////////////////////////////////// +// Query Hook + +export function usePromoSheetCollectionQuery( + { order }: PromoSheetCollectionArgs = {}, + { + enabled, + refetchInterval = 30_000, + }: { enabled?: boolean; refetchInterval?: number } = {} +) { + return useQuery( + promoSheetCollectionQueryKey({ order }), + promoSheetCollectionQueryFunction, + { + enabled, + staleTime: defaultStaleTime, + refetchInterval, + } + ); +} diff --git a/src/resources/promoSheet/promoSheetQuery.ts b/src/resources/promoSheet/promoSheetQuery.ts new file mode 100644 index 00000000000..5a2fb3afc75 --- /dev/null +++ b/src/resources/promoSheet/promoSheetQuery.ts @@ -0,0 +1,79 @@ +import { useQuery } from '@tanstack/react-query'; + +import { + createQueryKey, + queryClient, + QueryConfig, + QueryFunctionArgs, + QueryFunctionResult, +} from '@/react-query'; + +import { arcDevClient } from '@/graphql'; + +// Set a default stale time of 10 seconds so we don't over-fetch +// (query will serve cached data & invalidate after 10s). +const defaultStaleTime = 60_000; + +export type PromoSheetArgs = { + id: string; +}; + +// /////////////////////////////////////////////// +// Query Key + +const promoSheetQueryKey = ({ id }: PromoSheetArgs) => + createQueryKey('promoSheet', { id }, { persisterVersion: 1 }); + +type PromoSheetQueryKey = ReturnType; + +// /////////////////////////////////////////////// +// Query Function + +async function promoSheetQueryFunction({ + queryKey: [{ id }], +}: QueryFunctionArgs) { + const data = await arcDevClient.getPromoSheet({ id }); + return data; +} + +export type PromoSheetResult = QueryFunctionResult< + typeof promoSheetQueryFunction +>; + +// /////////////////////////////////////////////// +// Query Prefetcher + +export async function prefetchPromoSheet( + { id }: PromoSheetArgs, + config: QueryConfig = {} +) { + return await queryClient.prefetchQuery( + promoSheetQueryKey({ id }), + promoSheetQueryFunction, + config + ); +} + +// /////////////////////////////////////////////// +// Query Fetcher + +export async function fetchPromoSheet({ id }: PromoSheetArgs) { + return await queryClient.fetchQuery( + promoSheetQueryKey({ id }), + promoSheetQueryFunction, + { staleTime: defaultStaleTime } + ); +} + +// /////////////////////////////////////////////// +// Query Hook + +export function usePromoSheetQuery( + { id }: PromoSheetArgs, + { enabled }: { enabled?: boolean } = {} +) { + return useQuery(promoSheetQueryKey({ id }), promoSheetQueryFunction, { + enabled, + staleTime: defaultStaleTime, + }); +} diff --git a/src/resources/reservoir/nftOffersQuery.ts b/src/resources/reservoir/nftOffersQuery.ts index 341d475dc81..e1b9a241f3f 100644 --- a/src/resources/reservoir/nftOffersQuery.ts +++ b/src/resources/reservoir/nftOffersQuery.ts @@ -8,15 +8,94 @@ import { NftOffer, SortCriterion, } from '@/graphql/__generated__/arc'; -import { createQueryKey, queryClient } from '@/react-query'; +import { + QueryFunctionArgs, + QueryFunctionResult, + createQueryKey, + queryClient, +} from '@/react-query'; import { useQuery } from '@tanstack/react-query'; -import { useEffect, useMemo } from 'react'; +import { useEffect } from 'react'; import { useRecoilValue } from 'recoil'; const graphqlClient = IS_PROD ? arcClient : arcDevClient; -export const nftOffersQueryKey = ({ address }: { address: string }) => - createQueryKey('nftOffers', { address }, { persisterVersion: 1 }); +export type NFTOffersArgs = { + walletAddress: string; + sortBy?: SortCriterion; +}; + +function sortNftOffers(nftOffers: NftOffer[], sortCriterion: SortCriterion) { + let sortedOffers; + switch (sortCriterion) { + case SortCriterion.TopBidValue: + sortedOffers = nftOffers + .slice() + .sort((a, b) => b.netAmount.usd - a.netAmount.usd); + break; + case SortCriterion.FloorDifferencePercentage: + sortedOffers = nftOffers + .slice() + .sort( + (a, b) => b.floorDifferencePercentage - a.floorDifferencePercentage + ); + break; + case SortCriterion.DateCreated: + sortedOffers = nftOffers + .slice() + .sort((a, b) => b.createdAt - a.createdAt); + break; + default: + sortedOffers = nftOffers; + } + return sortedOffers; +} + +export const nftOffersQueryKey = ({ + walletAddress, + sortBy = SortCriterion.TopBidValue, +}: NFTOffersArgs) => + createQueryKey( + 'nftOffers', + { walletAddress, sortBy }, + { persisterVersion: 1 } + ); + +type NFTOffersQueryKey = ReturnType; + +async function nftOffersQueryFunction({ + queryKey: [{ walletAddress, sortBy }], +}: QueryFunctionArgs) { + const data = await graphqlClient.getNFTOffers({ + walletAddress, + sortBy, + }); + return data; +} + +export type NftOffersResult = QueryFunctionResult< + typeof nftOffersQueryFunction +>; + +export async function fetchNftOffers({ + walletAddress, + sortBy = SortCriterion.TopBidValue, +}: NFTOffersArgs) { + const data = await graphqlClient.getNFTOffers({ + walletAddress, + // TODO: remove sortBy once the backend supports it + sortBy: SortCriterion.TopBidValue, + }); + + console.log(data); + + if (!data?.nftOffers) { + return null; + } + + const sortedOffers = sortNftOffers(data.nftOffers, sortBy); + return { ...data, nftOffers: sortedOffers }; +} /** * React Query hook that returns the the most profitable `NftOffer` for each NFT owned by the given wallet address. @@ -28,7 +107,7 @@ export function useNFTOffers({ walletAddress }: { walletAddress: string }) { const nftOffersEnabled = useExperimentalFlag(NFT_OFFERS); const sortCriterion = useRecoilValue(nftOffersSortAtom); const queryKey = nftOffersQueryKey({ - address: walletAddress, + walletAddress, }); const query = useQuery( @@ -47,45 +126,11 @@ export function useNFTOffers({ walletAddress }: { walletAddress: string }) { } ); - const sortedByValue = useMemo( - () => - query.data?.nftOffers - ?.slice() - .sort((a, b) => b.netAmount.usd - a.netAmount.usd), - [query.data?.nftOffers] - ); - - const sortedByFloorDifference = useMemo( - () => - query.data?.nftOffers - ?.slice() - .sort( - (a, b) => b.floorDifferencePercentage - a.floorDifferencePercentage - ), - [query.data?.nftOffers] - ); - - const sortedByDate = useMemo( - () => - query.data?.nftOffers?.slice().sort((a, b) => b.createdAt - a.createdAt), - [query.data?.nftOffers] + const sortedOffers = sortNftOffers( + query.data?.nftOffers || [], + sortCriterion ); - let sortedOffers; - switch (sortCriterion) { - case SortCriterion.TopBidValue: - sortedOffers = sortedByValue; - break; - case SortCriterion.FloorDifferencePercentage: - sortedOffers = sortedByFloorDifference; - break; - case SortCriterion.DateCreated: - sortedOffers = sortedByDate; - break; - default: - sortedOffers = query.data?.nftOffers; - } - useEffect(() => { const nftOffers = query.data?.nftOffers ?? []; const totalUSDValue = nftOffers.reduce( diff --git a/src/screens/ChangeWalletSheet.tsx b/src/screens/ChangeWalletSheet.tsx index 9bdf9dde1e1..5da48857731 100644 --- a/src/screens/ChangeWalletSheet.tsx +++ b/src/screens/ChangeWalletSheet.tsx @@ -21,7 +21,7 @@ import { } from '../redux/wallets'; import { analytics, analyticsV2 } from '@/analytics'; import { getExperimetalFlag, HARDWARE_WALLETS } from '@/config'; -import { runCampaignChecks } from '@/campaigns/campaignChecks'; +import { useRemotePromoSheetContext } from '@/components/remote-promo-sheet/RemotePromoSheetProvider'; import { useAccountSettings, useInitializeWallet, @@ -115,6 +115,7 @@ export default function ChangeWalletSheet() { const { params = {} as any } = useRoute(); const { onChangeWallet, watchOnly = false, currentAccountAddress } = params; const { selectedWallet, wallets } = useWallets(); + const { runChecks } = useRemotePromoSheetContext(); const { colors } = useTheme(); const { updateWebProfile } = useWebData(); @@ -171,11 +172,7 @@ export default function ChangeWalletSheet() { goBack(); if (IS_TESTING !== 'true') { - InteractionManager.runAfterInteractions(() => { - setTimeout(async () => { - await runCampaignChecks(); - }, 5000); - }); + runChecks(); } } } catch (e) { diff --git a/src/screens/NFTOffersSheet/index.tsx b/src/screens/NFTOffersSheet/index.tsx index 0615fe8f386..fac04fa5a81 100644 --- a/src/screens/NFTOffersSheet/index.tsx +++ b/src/screens/NFTOffersSheet/index.tsx @@ -210,7 +210,7 @@ export const NFTOffersSheet = () => { ) { queryClient.invalidateQueries({ queryKey: nftOffersQueryKey({ - address: accountAddress, + walletAddress: accountAddress, }), }); } diff --git a/src/screens/NFTSingleOfferSheet/index.tsx b/src/screens/NFTSingleOfferSheet/index.tsx index 5f3c1a2b7f6..69a7688f0e8 100644 --- a/src/screens/NFTSingleOfferSheet/index.tsx +++ b/src/screens/NFTSingleOfferSheet/index.tsx @@ -391,7 +391,7 @@ export function NFTSingleOfferSheet() { // remove offer from cache queryClient.setQueryData( - nftOffersQueryKey({ address: accountAddress }), + nftOffersQueryKey({ walletAddress: accountAddress }), ( cachedData: { nftOffers: NftOffer[] | undefined } | undefined ) => { diff --git a/src/screens/NotificationsPromoSheet/index.tsx b/src/screens/NotificationsPromoSheet/index.tsx index 87f8272532a..4a35d45abf0 100644 --- a/src/screens/NotificationsPromoSheet/index.tsx +++ b/src/screens/NotificationsPromoSheet/index.tsx @@ -3,7 +3,7 @@ import * as perms from 'react-native-permissions'; import useAppState from '@/hooks/useAppState'; import { useNavigation } from '@/navigation/Navigation'; -import { CampaignKey } from '@/campaigns/campaignChecks'; +import { CampaignKey } from '@/components/remote-promo-sheet/localCampaignChecks'; import { PromoSheet } from '@/components/PromoSheet'; import backgroundImage from '@/assets/notificationsPromoSheetBackground.png'; import headerImageIOS from '@/assets/notificationsPromoSheetHeaderIOS.png'; diff --git a/src/screens/SettingsSheet/components/SettingsSection.tsx b/src/screens/SettingsSheet/components/SettingsSection.tsx index 46247804fdb..2a7efa07225 100644 --- a/src/screens/SettingsSheet/components/SettingsSection.tsx +++ b/src/screens/SettingsSheet/components/SettingsSection.tsx @@ -40,7 +40,6 @@ import { } from '@/utils/buildRainbowUrl'; import { getNetworkObj } from '@/networks'; import { handleReviewPromptAction } from '@/utils/reviewAlert'; -import * as ls from '@/storage'; import { ReviewPromptAction } from '@/storage/schema'; const SettingsExternalURLs = { diff --git a/src/screens/SwapsPromoSheet.tsx b/src/screens/SwapsPromoSheet.tsx deleted file mode 100644 index 47b427433ec..00000000000 --- a/src/screens/SwapsPromoSheet.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React, { useCallback } from 'react'; - -import { useNavigation } from '@/navigation/Navigation'; -import { CampaignKey } from '@/campaigns/campaignChecks'; -import { PromoSheet } from '@/components/PromoSheet'; -import { CurrencySelectionTypes, ExchangeModalTypes } from '@/helpers'; -import { useSwapCurrencyHandlers } from '@/hooks'; -import SwapsPromoBackground from '@/assets/swapsPromoBackground.png'; -import SwapsPromoHeader from '@/assets/swapsPromoHeader.png'; -import { delay } from '@/helpers/utilities'; -import Routes from '@/navigation/routesNames'; -import { useTheme } from '@/theme'; -import * as i18n from '@/languages'; - -const HEADER_HEIGHT = 285; -const HEADER_WIDTH = 390; - -export default function SwapsPromoSheet() { - const { colors } = useTheme(); - const { goBack, navigate } = useNavigation(); - const { updateInputCurrency } = useSwapCurrencyHandlers({ - shouldUpdate: false, - type: ExchangeModalTypes.swap, - }); - const translations = i18n.l.promos.swaps_launch; - - const navigateToSwaps = useCallback(() => { - goBack(); - delay(300).then(() => - navigate(Routes.EXCHANGE_MODAL, { - fromDiscover: true, - params: { - fromDiscover: true, - onSelectCurrency: updateInputCurrency, - title: i18n.t(i18n.l.swap.modal_types.swap), - type: CurrencySelectionTypes.input, - }, - screen: Routes.CURRENCY_SELECT_SCREEN, - }) - ); - }, [goBack, navigate, updateInputCurrency]); - - return ( - - ); -} diff --git a/src/storage/index.ts b/src/storage/index.ts index f3672850416..57255e5c1a8 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -1,6 +1,6 @@ import { MMKV } from 'react-native-mmkv'; -import { Account, Device, Review } from '@/storage/schema'; +import { Account, Campaigns, Device, Review } from '@/storage/schema'; import { EthereumAddress } from '@/entities'; import { Network } from '@/networks/types'; @@ -78,3 +78,5 @@ export const account = new Storage<[EthereumAddress, Network], Account>({ }); export const review = new Storage<[], Review>({ id: 'review' }); + +export const campaigns = new Storage<[], Campaigns>({ id: 'campaigns' }); diff --git a/src/storage/schema.ts b/src/storage/schema.ts index b93fc2bf0d8..e0cdf087c14 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -66,3 +66,14 @@ export type Review = { timeOfLastPrompt: number; actions: Action[]; }; + +type CampaignKeys = { + [campaignKey: string]: boolean; +}; + +type CampaignMetadata = { + isCurrentlyShown: boolean; + lastShownTimestamp: number; +}; + +export type Campaigns = CampaignKeys & CampaignMetadata; diff --git a/src/utils/index.ts b/src/utils/index.ts index e54baafd14d..3d3420acbb1 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -50,3 +50,4 @@ export { default as withSpeed } from './withSpeed'; export { default as CoinIcon } from './CoinIcons/CoinIcon'; export { default as FallbackIcon } from './CoinIcons/FallbackIcon'; export { default as getExchangeIconUrl } from './getExchangeIconUrl'; +export { resolveFirstRejectLast } from './resolveFirstRejectLast'; diff --git a/src/utils/resolveFirstRejectLast.ts b/src/utils/resolveFirstRejectLast.ts new file mode 100644 index 00000000000..ca92783b9f0 --- /dev/null +++ b/src/utils/resolveFirstRejectLast.ts @@ -0,0 +1,48 @@ +import { forEach } from 'lodash'; + +/** + * Resolve the first Promise, Reject when all have failed + * + * This method accepts a list of promises and has them + * compete in a horserace to determine which promise can + * resolve first (similar to Promise.race). However, this + * method differs why waiting to reject until ALL promises + * have rejected, rather than waiting for the first. + * + * The return of this method is a promise that either resolves + * to the first promises resolved value, or rejects with an arra + * of errors (with indexes corresponding to the promises). + * + * @param {List} promises list of promises to run + */ +export type Status = { + winner: T | null; + errors: Array; +}; + +export const resolveFirstRejectLast = (promises: Array>) => { + return new Promise((resolve, reject) => { + let errorCount = 0; + const status: Status = { + winner: null, + errors: new Array(promises.length), + }; + forEach(promises, (p, idx) => { + p.then( + resolved => { + if (!status.winner) { + status.winner = resolved; + resolve(resolved); + } + }, + error => { + status.errors[idx] = error; + errorCount += 1; + if (errorCount >= status.errors.length && !status.winner) { + reject(status.errors); + } + } + ); + }); + }); +};