diff --git a/src/App.js b/src/App.js index c8647410fe8..e2ab6a9e01d 100644 --- a/src/App.js +++ b/src/App.js @@ -53,8 +53,6 @@ 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'; -import { RemoteCardProvider } from '@/components/cards/remote-cards'; import { initializeRemoteConfig } from '@/model/remoteConfig'; import { IS_DEV } from './env'; import { checkIdentifierOnLaunch } from './model/backup'; @@ -147,11 +145,11 @@ class OldApp extends Component { const address = await loadAddress(); if (address) { - InteractionManager.runAfterInteractions(() => { - setTimeout(() => { + setTimeout(() => { + InteractionManager.runAfterInteractions(() => { handleReviewPromptAction(ReviewPromptAction.TimesLaunchedSinceInstall); - }, 10_000); - }); + }); + }, 10_000); checkIdentifierOnLaunch(); } @@ -221,14 +219,10 @@ class OldApp extends Component { {this.state.initialRoute && ( - - - - - - - - + + + + )} diff --git a/src/components/asset-list/RecyclerAssetList2/core/RawRecyclerList.tsx b/src/components/asset-list/RecyclerAssetList2/core/RawRecyclerList.tsx index 6377a32bfe1..3f063c6a23d 100644 --- a/src/components/asset-list/RecyclerAssetList2/core/RawRecyclerList.tsx +++ b/src/components/asset-list/RecyclerAssetList2/core/RawRecyclerList.tsx @@ -15,11 +15,12 @@ import getLayoutProvider from './getLayoutProvider'; import useLayoutItemAnimator from './useLayoutItemAnimator'; import { UniqueAsset } from '@/entities'; import { useRecyclerListViewScrollToTopContext } from '@/navigation/RecyclerListViewScrollToTopContext'; -import { useAccountProfile, useAccountSettings, useCoinListEdited, useCoinListEditOptions, useWallets } from '@/hooks'; +import { useAccountSettings, useCoinListEdited, useCoinListEditOptions, useWallets } from '@/hooks'; import { useNavigation } from '@/navigation'; import { useTheme } from '@/theme'; -import { useRemoteCardContext } from '@/components/cards/remote-cards'; +import { remoteCardsStore } from '@/state/remoteCards/remoteCards'; import { useRoute } from '@react-navigation/native'; +import Routes from '@/navigation/routesNames'; const dataProvider = new DataProvider((r1, r2) => { return r1.uid !== r2.uid; @@ -58,14 +59,14 @@ const RawMemoRecyclerAssetList = React.memo(function RawRecyclerAssetList({ const y = useRecyclerAssetListPosition()!; const { name } = useRoute(); - const { getCardsForPlacement } = useRemoteCardContext(); + const getCardIdsForScreen = remoteCardsStore(state => state.getCardIdsForScreen); const { isReadOnlyWallet } = useWallets(); - const cards = useMemo(() => getCardsForPlacement(name as string), [getCardsForPlacement, name]); + const cardIds = useMemo(() => getCardIdsForScreen(name as keyof typeof Routes), [getCardIdsForScreen, name]); const layoutProvider = useMemo( - () => getLayoutProvider(briefSectionsData, isCoinListEdited, cards, isReadOnlyWallet), - [briefSectionsData, isCoinListEdited, cards, isReadOnlyWallet] + () => getLayoutProvider(briefSectionsData, isCoinListEdited, cardIds, isReadOnlyWallet), + [briefSectionsData, isCoinListEdited, cardIds, isReadOnlyWallet] ); const { accountAddress } = useAccountSettings(); diff --git a/src/components/asset-list/RecyclerAssetList2/core/getLayoutProvider.tsx b/src/components/asset-list/RecyclerAssetList2/core/getLayoutProvider.tsx index 59d071362a1..82c89dd7dfe 100644 --- a/src/components/asset-list/RecyclerAssetList2/core/getLayoutProvider.tsx +++ b/src/components/asset-list/RecyclerAssetList2/core/getLayoutProvider.tsx @@ -31,12 +31,7 @@ class BetterLayoutProvider extends LayoutProvider { } } -const getLayoutProvider = ( - briefSectionsData: BaseCellType[], - isCoinListEdited: boolean, - cards: TrimmedCard[], - isReadOnlyWallet: boolean -) => { +const getLayoutProvider = (briefSectionsData: BaseCellType[], isCoinListEdited: boolean, cardIds: string[], isReadOnlyWallet: boolean) => { const indicesToOverride = []; for (let i = 0; i < briefSectionsData.length; i++) { const val = briefSectionsData[i]; @@ -61,7 +56,7 @@ const getLayoutProvider = ( dim.height = ViewDimensions[type].height; dim.width = ViewDimensions[type].width || dim.width; - if ((type === CellType.REMOTE_CARD_CAROUSEL && !cards.length) || (type === CellType.REMOTE_CARD_CAROUSEL && isReadOnlyWallet)) { + if ((type === CellType.REMOTE_CARD_CAROUSEL && !cardIds.length) || (type === CellType.REMOTE_CARD_CAROUSEL && isReadOnlyWallet)) { dim.height = 0; } } diff --git a/src/components/cards/remote-cards/RemoteCard.tsx b/src/components/cards/remote-cards/RemoteCard.tsx index 6392dfce8bd..44a8e80a528 100644 --- a/src/components/cards/remote-cards/RemoteCard.tsx +++ b/src/components/cards/remote-cards/RemoteCard.tsx @@ -5,7 +5,6 @@ import ConditionalWrap from 'conditional-wrap'; import { Box, Cover, Stack, Text, useForegroundColor } from '@/design-system'; import { ButtonPressAnimation } from '@/components/animations'; -import { useRemoteCardContext } from './RemoteCardProvider'; import { IS_ANDROID, IS_IOS } from '@/env'; import { useNavigation } from '@/navigation'; import { Language } from '@/languages'; @@ -20,6 +19,7 @@ import { FlashList } from '@shopify/flash-list'; import { ButtonPressAnimationTouchEvent } from '@/components/animations/ButtonPressAnimation/types'; import { TrimmedCard } from '@/resources/cards/cardCollectionQuery'; import RemoteSvg from '@/components/svg/RemoteSvg'; +import { remoteCardsStore } from '@/state/remoteCards/remoteCards'; const ICON_SIZE = 40; @@ -58,19 +58,17 @@ const getColorFromString = (color: string | undefined | null) => { }; type RemoteCardProps = { - card: TrimmedCard; - cards: TrimmedCard[]; + id: string; gutterSize: number; - carouselRef: React.RefObject> | null; + carouselRef: React.RefObject> | null; }; -export const RemoteCard: React.FC = ({ card = {} as TrimmedCard, cards, gutterSize, carouselRef }) => { +export const RemoteCard: React.FC = ({ id, gutterSize, carouselRef }) => { const { isDarkMode } = useTheme(); const { navigate } = useNavigation(); const { language } = useAccountSettings(); const { width } = useDimensions(); - const { dismissCard } = useRemoteCardContext(); - + const card = remoteCardsStore(state => state.getCard(id)) ?? ({} as TrimmedCard); const { cardKey, accentColor, backgroundColor, primaryButton, imageIcon } = card; const accent = useForegroundColor(getColorFromString(accentColor)); @@ -95,26 +93,25 @@ export const RemoteCard: React.FC = ({ card = {} as TrimmedCard e.stopPropagation(); } analyticsV2.track(analyticsV2.event.remoteCardDismissed, { - cardKey: cardKey ?? 'unknown-backend-driven-card', + cardKey: cardKey ?? card.sys.id ?? 'unknown-backend-driven-card', }); - const isLastCard = cards.length === 1; + const { cards } = remoteCardsStore.getState(); - dismissCard(card.sys.id); - if (carouselRef?.current) { - const currentCardIdx = cards.findIndex(c => c.cardKey === cardKey); - if (currentCardIdx === -1) return; + const isLastCard = cards.size === 1; + remoteCardsStore.getState().dismissCard(card.sys.id); + if (carouselRef?.current) { // check if this is the last card and don't scroll if so if (isLastCard) return; carouselRef.current.scrollToIndex({ - index: currentCardIdx, + index: Array.from(cards.values()).findIndex(c => c.sys.id === card.sys.id), animated: true, }); } }, - [carouselRef, dismissCard, cards, cardKey, card.sys.id] + [carouselRef, cardKey, card.sys.id] ); const imageForPlatform = () => { @@ -143,7 +140,7 @@ export const RemoteCard: React.FC = ({ card = {} as TrimmedCard } }; - if (!card) { + if (!card || card.dismissed) { return null; } diff --git a/src/components/cards/remote-cards/RemoteCardCarousel.tsx b/src/components/cards/remote-cards/RemoteCardCarousel.tsx index c7b0ba9e7d0..09e3555783c 100644 --- a/src/components/cards/remote-cards/RemoteCardCarousel.tsx +++ b/src/components/cards/remote-cards/RemoteCardCarousel.tsx @@ -1,17 +1,18 @@ -import React, { useMemo, useRef } from 'react'; +import React, { useRef } from 'react'; import { CarouselCard } from '../CarouselCard'; import { useRoute } from '@react-navigation/native'; import { IS_TEST } from '@/env'; -import { useRemoteCardContext, RemoteCard } from '@/components/cards/remote-cards'; +import { RemoteCard } from '@/components/cards/remote-cards'; import { REMOTE_CARDS, getExperimetalFlag } from '@/config'; import { useDimensions, useWallets } from '@/hooks'; import { useRemoteConfig } from '@/model/remoteConfig'; import { FlashList } from '@shopify/flash-list'; -import { TrimmedCard } from '@/resources/cards/cardCollectionQuery'; +import { remoteCardsStore } from '@/state/remoteCards/remoteCards'; +import Routes from '@/navigation/routesNames'; type RenderItemProps = { - item: TrimmedCard; + item: string; index: number; }; @@ -24,35 +25,33 @@ export const getGutterSizeForCardAmount = (amount: number) => { }; export const RemoteCardCarousel = () => { - const carouselRef = useRef>(null); + const carouselRef = useRef>(null); const { name } = useRoute(); const config = useRemoteConfig(); const { isReadOnlyWallet } = useWallets(); - - const remoteCardsEnabled = getExperimetalFlag(REMOTE_CARDS) || config.remote_cards_enabled; - const { getCardsForPlacement } = useRemoteCardContext(); const { width } = useDimensions(); - const data = useMemo(() => getCardsForPlacement(name as string), [getCardsForPlacement, name]); + const remoteCardsEnabled = getExperimetalFlag(REMOTE_CARDS) || config.remote_cards_enabled; + const cardIds = remoteCardsStore(state => state.getCardIdsForScreen(name as keyof typeof Routes)); - const gutterSize = getGutterSizeForCardAmount(data.length); + const gutterSize = getGutterSizeForCardAmount(cardIds.length); const _renderItem = ({ item }: RenderItemProps) => { - return ; + return ; }; - if (isReadOnlyWallet || IS_TEST || !remoteCardsEnabled || !data.length) { + if (isReadOnlyWallet || IS_TEST || !remoteCardsEnabled || !cardIds.length) { return null; } return ( item.cardKey!, + keyExtractor: item => item, placeholder: null, width: width - gutterSize, height: 88, diff --git a/src/components/cards/remote-cards/RemoteCardProvider.tsx b/src/components/cards/remote-cards/RemoteCardProvider.tsx deleted file mode 100644 index f3deca0ffab..00000000000 --- a/src/components/cards/remote-cards/RemoteCardProvider.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { noop } from 'lodash'; -import React, { PropsWithChildren, createContext } from 'react'; -import Routes from '@/navigation/routesNames'; -import { TrimmedCard, TrimmedCards, cardCollectionQueryKey, useCardCollectionQuery } from '@/resources/cards/cardCollectionQuery'; -import * as ls from '@/storage'; -import { queryClient } from '@/react-query'; - -type RoutesWithIndex = typeof Routes & { [key: string]: string }; - -type CardProviderProps = { - initialState?: TrimmedCards; -}; - -type CardContextProps = { - cards: TrimmedCards; - dismissCard: (cardId: string) => void; - getCardsForPlacement: (placement: string) => TrimmedCard[]; -}; - -export const RemoteCardContext = createContext({ - cards: {}, - dismissCard: noop, - getCardsForPlacement: () => [], -}); - -export const RemoteCardProvider: React.FC> = ({ children }) => { - const { data: cards = {} } = useCardCollectionQuery(); - - const dismissCard = (cardId: string) => { - ls.cards.set([cardId], true); - queryClient.setQueryData(cardCollectionQueryKey, (prev: TrimmedCards | undefined) => { - if (!prev) return {}; - const { [cardId]: _, ...rest } = prev; - return rest; - }); - }; - - const getCardsForPlacement = (placement: string) => { - if (!cards) return []; - return Object.values(cards) - .filter(card => card.placement && (Routes as RoutesWithIndex)[card.placement.toString()] === placement) - .sort((a, b) => { - if (a.index === b.index) return 0; - if (a.index === undefined || a.index === null) return 1; - if (b.index === undefined || b.index === null) return -1; - - return a.index - b.index; - }); - }; - - return {children}; -}; - -export const useRemoteCardContext = () => React.useContext(RemoteCardContext); diff --git a/src/components/cards/remote-cards/index.ts b/src/components/cards/remote-cards/index.ts index 42863f365ba..145570c8238 100644 --- a/src/components/cards/remote-cards/index.ts +++ b/src/components/cards/remote-cards/index.ts @@ -1,3 +1,2 @@ -export * from './RemoteCardProvider'; export * from './RemoteCard'; export * from './RemoteCardCarousel'; diff --git a/src/components/remote-promo-sheet/RemotePromoSheet.tsx b/src/components/remote-promo-sheet/RemotePromoSheet.tsx index 1cb92928881..e8b18f5c8d7 100644 --- a/src/components/remote-promo-sheet/RemotePromoSheet.tsx +++ b/src/components/remote-promo-sheet/RemotePromoSheet.tsx @@ -5,23 +5,19 @@ 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'; +import { remotePromoSheetsStore } from '@/state/remotePromoSheets/remotePromoSheets'; +import { RootStackParamList } from '@/navigation/types'; const DEFAULT_HEADER_HEIGHT = 285; const DEFAULT_HEADER_WIDTH = 390; -type RootStackParamList = { - RemotePromoSheet: CampaignCheckResult; -}; - type Item = { title: Record; description: Record; @@ -59,8 +55,15 @@ export function RemotePromoSheet() { const { language } = useAccountSettings(); useEffect(() => { + remotePromoSheetsStore.setState({ + isShown: true, + lastShownTimestamp: Date.now(), + }); + return () => { - campaigns.set(['isCurrentlyShown'], false); + remotePromoSheetsStore.setState({ + isShown: false, + }); }; }, []); @@ -85,7 +88,7 @@ export function RemotePromoSheet() { const externalNavigation = useCallback(() => { Linking.openURL(data?.promoSheet?.primaryButtonProps.props.url); - }, []); + }, [data?.promoSheet?.primaryButtonProps.props.url]); const internalNavigation = useCallback(() => { goBack(); @@ -114,13 +117,10 @@ export function RemotePromoSheet() { } = 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 ( diff --git a/src/components/remote-promo-sheet/RemotePromoSheetProvider.tsx b/src/components/remote-promo-sheet/RemotePromoSheetProvider.tsx deleted file mode 100644 index b0662b1a37f..00000000000 --- a/src/components/remote-promo-sheet/RemotePromoSheetProvider.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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'; -import { useRemoteConfig } from '@/model/remoteConfig'; - -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 { remote_promo_enabled } = useRemoteConfig(); - const remotePromoSheets = useExperimentalFlag(REMOTE_PROMO_SHEETS) || remote_promo_enabled; - - 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/checkForCampaign.ts b/src/components/remote-promo-sheet/checkForCampaign.ts index 53dcb558564..47b5bf34447 100644 --- a/src/components/remote-promo-sheet/checkForCampaign.ts +++ b/src/components/remote-promo-sheet/checkForCampaign.ts @@ -4,9 +4,10 @@ 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 { device } from '@/storage'; import * as fns from './check-fns'; +import { remotePromoSheetsStore } from '@/state/remotePromoSheets/remotePromoSheets'; type ActionObj = { fn: string; @@ -24,30 +25,27 @@ export type CampaignCheckResult = { 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; + const lastShownAt = remotePromoSheetsStore.getState().lastShownTimestamp; + if (!lastShownAt) return TIMEOUT_BETWEEN_PROMOS; + return Date.now() - lastShownAt; }; export const checkForCampaign = async () => { - logger.info('Campaigns: Running Checks'); + logger.debug('Campaigns: Running Checks'); if (timeBetweenPromoSheets() < TIMEOUT_BETWEEN_PROMOS) { - logger.info('Campaigns: Time between promos has not exceeded timeout'); + logger.debug('Campaigns: Time between promos has not exceeded timeout'); return; } - let isCurrentlyShown = campaigns.get(['isCurrentlyShown']); - if (isCurrentlyShown) { - logger.info('Campaigns: Promo sheet is already shown'); + const isShown = remotePromoSheetsStore.getState().isShown; + if (isShown) { + logger.debug('Campaigns: Another remote sheet is currently shown'); return; } const isReturningUser = device.get(['isReturningUser']); - if (!isReturningUser) { - logger.info('Campaigns: First launch, not showing promo sheet'); + logger.debug('Campaigns: First launch, not showing promo sheet'); return; } @@ -57,13 +55,13 @@ export const checkForCampaign = async () => { for (const promo of promoSheetCollection?.items || []) { if (!promo) continue; - logger.info(`Campaigns: Checking ${promo.sys.id}`); + logger.debug(`Campaigns: Checking ${promo.sys.id}`); const result = await shouldPromptCampaign(promo as PromoSheet); - logger.info(`Campaigns: ${promo.sys.id} will show: ${result}`); + logger.debug(`Campaigns: ${promo.sys.id} will show: ${result}`); if (result) { - isCurrentlyShown = campaigns.get(['isCurrentlyShown']); - if (!isCurrentlyShown) { + const isShown = remotePromoSheetsStore.getState().isShown; + if (!isShown) { return triggerCampaign(promo as PromoSheet); } } @@ -71,12 +69,10 @@ export const checkForCampaign = async () => { }; export const triggerCampaign = async ({ campaignKey, sys: { id: campaignId } }: PromoSheet) => { - logger.info(`Campaigns: Showing ${campaignKey} Promo`); + logger.debug(`Campaigns: Showing ${campaignKey} Promo`); setTimeout(() => { - campaigns.set([campaignKey as string], true); - campaigns.set(['isCurrentlyShown'], true); - campaigns.set(['lastShownTimestamp'], Date.now()); + remotePromoSheetsStore.getState().showSheet(campaignId); InteractionManager.runAfterInteractions(() => { Navigation.handleAction(Routes.REMOTE_PROMO_SHEET, { campaignId, @@ -93,23 +89,21 @@ export const shouldPromptCampaign = async (campaign: PromoSheet): Promise action.fn === 'isPreviewing'); + const isPreviewing = actionsArray.some((action: ActionObj) => action.fn === 'isPreviewing'); + const hasShown = remotePromoSheetsStore.getState().getSheet(id)?.hasBeenShown; // 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}`); + logger.debug(`Campaigns: User has already been shown ${campaignKey}`); return false; } - const actionsArray = actions || ([] as ActionObj[]); let shouldPrompt = true; for (const actionObj of actionsArray) { @@ -119,9 +113,9 @@ export const shouldPromptCampaign = async (campaign: PromoSheet): Promise ${result === outcome}`); + logger.debug(`Campaigns: [${fn}] matches desired outcome: => ${result === outcome}`); if (result !== outcome) { shouldPrompt = false; diff --git a/src/components/remote-promo-sheet/localCampaignChecks.ts b/src/components/remote-promo-sheet/localCampaignChecks.ts index e25a1884063..9577f3f1bef 100644 --- a/src/components/remote-promo-sheet/localCampaignChecks.ts +++ b/src/components/remote-promo-sheet/localCampaignChecks.ts @@ -1,6 +1,7 @@ import { NotificationsPromoCampaign } from './notificationsPromoCampaign'; import { analytics } from '@/analytics'; -import { logger } from '@/utils'; +import { logger } from '@/logger'; +import { InteractionManager } from 'react-native'; export enum CampaignKey { notificationsLaunch = 'notifications_launch', @@ -30,22 +31,24 @@ export interface Campaign { export const activeCampaigns: Campaign[] = [NotificationsPromoCampaign]; export const runLocalCampaignChecks = async (): Promise => { - logger.log('Campaigns: Running Checks'); + logger.debug('Campaigns: Running Checks'); for (const campaign of activeCampaigns) { - const response = await campaign.check(); - if (response === GenericCampaignCheckResponse.activated) { - analytics.track('Viewed Feature Promo', { - campaign: campaign.campaignKey, - }); - return true; - } - if (response !== GenericCampaignCheckResponse.nonstarter) { - analytics.track('Excluded from Feature Promo', { - campaign: campaign.campaignKey, - exclusion: response, - type: campaign.checkType, - }); - } + InteractionManager.runAfterInteractions(async () => { + const response = await campaign.check(); + if (response === GenericCampaignCheckResponse.activated) { + analytics.track('Viewed Feature Promo', { + campaign: campaign.campaignKey, + }); + return true; + } + if (response !== GenericCampaignCheckResponse.nonstarter) { + analytics.track('Excluded from Feature Promo', { + campaign: campaign.campaignKey, + exclusion: response, + type: campaign.checkType, + }); + } + }); } return false; }; diff --git a/src/components/remote-promo-sheet/runChecks.ts b/src/components/remote-promo-sheet/runChecks.ts new file mode 100644 index 00000000000..2edc8311f17 --- /dev/null +++ b/src/components/remote-promo-sheet/runChecks.ts @@ -0,0 +1,41 @@ +import { IS_TEST } from '@/env'; +import { runFeatureUnlockChecks } from '@/handlers/walletReadyEvents'; +import { logger } from '@/logger'; +import { runLocalCampaignChecks } from './localCampaignChecks'; +import { checkForCampaign } from './checkForCampaign'; +import { useCallback, useEffect } from 'react'; +import { InteractionManager } from 'react-native'; +import { useRemoteConfig } from '@/model/remoteConfig'; +import { REMOTE_PROMO_SHEETS, useExperimentalFlag } from '@/config'; + +export const useRunChecks = (runChecksOnMount = true) => { + const { remote_promo_enabled } = useRemoteConfig(); + const remotePromoSheets = useExperimentalFlag(REMOTE_PROMO_SHEETS) || remote_promo_enabled; + + const runChecks = useCallback(() => { + InteractionManager.runAfterInteractions(async () => { + if (IS_TEST || !remotePromoSheets) { + logger.debug('Campaigns: remote promo sheets is disabled'); + return; + } + + const showedFeatureUnlock = await runFeatureUnlockChecks(); + if (showedFeatureUnlock) return; + + const showedLocalPromo = await runLocalCampaignChecks(); + if (showedLocalPromo) return; + + checkForCampaign(); + }); + }, [remotePromoSheets]); + + useEffect(() => { + if (runChecksOnMount) { + setTimeout(runChecks, 10_000); + } + }, [runChecks, runChecksOnMount]); + + return { + runChecks, + }; +}; diff --git a/src/featuresToUnlock/unlockableAppIconCheck.ts b/src/featuresToUnlock/unlockableAppIconCheck.ts index 2978fde8673..f2c9b72ea29 100644 --- a/src/featuresToUnlock/unlockableAppIconCheck.ts +++ b/src/featuresToUnlock/unlockableAppIconCheck.ts @@ -1,10 +1,9 @@ import { TokenGateCheckerNetwork, checkIfWalletsOwnNft } from './tokenGatedUtils'; import { EthereumAddress } from '@/entities'; import { Navigation } from '@/navigation'; -import { logger } from '@/utils'; +import { RainbowError, logger } from '@/logger'; import Routes from '@/navigation/routesNames'; import { UnlockableAppIconKey, unlockableAppIcons } from '@/appIcons/appIcons'; -import { Network } from '@/helpers'; import { MMKV } from 'react-native-mmkv'; import { STORAGE_IDS } from '@/model/mmkv'; @@ -23,7 +22,7 @@ export const unlockableAppIconCheck = async (appIconKey: UnlockableAppIconKey, w const handled = unlockableAppIconStorage.getBoolean(appIconKey); - logger.log(`${appIconKey} was handled?`, handled); + logger.debug(`${appIconKey} was handled? ${handled}`); if (handled) return false; @@ -33,13 +32,13 @@ export const unlockableAppIconCheck = async (appIconKey: UnlockableAppIconKey, w (Object.keys(appIcon.unlockingNFTs) as TokenGateCheckerNetwork[]).map(async network => { const nfts = appIcon.unlockingNFTs[network]; if (!nfts) return; - logger.log(`Checking ${appIconKey} on network ${network}`); + logger.debug(`Checking ${appIconKey} on network ${network}`); return await checkIfWalletsOwnNft(nfts, network, walletsToCheck); }) ) ).some(result => !!result); - logger.log(`${appIconKey} check result: ${found}`); + logger.debug(`${appIconKey} check result: ${found}`); // We open the sheet with a setTimeout 1 sec later to make sure we can return first // so we can abort early if we're showing a sheet to prevent 2+ sheets showing at the same time @@ -47,14 +46,14 @@ export const unlockableAppIconCheck = async (appIconKey: UnlockableAppIconKey, w setTimeout(() => { if (found) { unlockableAppIconStorage.set(appIconKey, true); - logger.log('Feature check', appIconKey, 'set to true. Wont show up anymore!'); + logger.debug(`Feature check ${appIconKey} set to true. Wont show up anymore!`); Navigation.handleAction(Routes.APP_ICON_UNLOCK_SHEET, { appIconKey }); return true; } }, 1000); return found; } catch (e) { - logger.log('areOwners blew up', e); + logger.error(new RainbowError('UnlockableAppIconCheck blew up'), { e }); } return false; }; diff --git a/src/handlers/walletReadyEvents.ts b/src/handlers/walletReadyEvents.ts index 92fd12ab2c2..9f02a762c36 100644 --- a/src/handlers/walletReadyEvents.ts +++ b/src/handlers/walletReadyEvents.ts @@ -15,6 +15,7 @@ import Routes from '@/navigation/routesNames'; import { logger } from '@/logger'; import { checkWalletsForBackupStatus } from '@/screens/SettingsSheet/utils'; import walletBackupTypes from '@/helpers/walletBackupTypes'; +import { InteractionManager } from 'react-native'; const BACKUP_SHEET_DELAY_MS = 3000; @@ -106,10 +107,12 @@ export const runFeatureUnlockChecks = async (): Promise => { // short circuits once the first feature is unlocked for (const featureUnlockCheck of featureUnlockChecks) { - const unlockNow = await featureUnlockCheck(walletsToCheck); - if (unlockNow) { - return true; - } + InteractionManager.runAfterInteractions(async () => { + const unlockNow = await featureUnlockCheck(walletsToCheck); + if (unlockNow) { + return true; + } + }); } return false; }; diff --git a/src/migrations/index.ts b/src/migrations/index.ts index b04bd4d7b22..0639f8f5162 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -14,6 +14,7 @@ import { purgeWcConnectionsWithoutAccounts } from './migrations/purgeWcConnectio import { migratePinnedAndHiddenTokenUniqueIds } from './migrations/migratePinnedAndHiddenTokenUniqueIds'; import { migrateUnlockableAppIconStorage } from './migrations/migrateUnlockableAppIconStorage'; import { migratePersistedQueriesToMMKV } from './migrations/migratePersistedQueriesToMMKV'; +import { migrateRemotePromoSheetsToZustand } from './migrations/migrateRemotePromoSheetsToZustand'; /** * Local storage for migrations only. Should not be exported. @@ -39,6 +40,7 @@ const migrations: Migration[] = [ migratePinnedAndHiddenTokenUniqueIds(), migrateUnlockableAppIconStorage(), migratePersistedQueriesToMMKV(), + migrateRemotePromoSheetsToZustand(), ]; /** diff --git a/src/migrations/migrations/migrateRemotePromoSheetsToZustand.ts b/src/migrations/migrations/migrateRemotePromoSheetsToZustand.ts new file mode 100644 index 00000000000..df4e1f41baa --- /dev/null +++ b/src/migrations/migrations/migrateRemotePromoSheetsToZustand.ts @@ -0,0 +1,36 @@ +import { PromoSheetOrder } from '@/graphql/__generated__/arc'; +import { RainbowError, logger } from '@/logger'; +import { fetchPromoSheetCollection } from '@/resources/promoSheet/promoSheetCollectionQuery'; +import { remotePromoSheetsStore } from '@/state/remotePromoSheets/remotePromoSheets'; +import { campaigns } from '@/storage'; +import { Migration, MigrationName } from '@/migrations/types'; + +export function migrateRemotePromoSheetsToZustand(): Migration { + return { + name: MigrationName.migrateRemotePromoSheetsToZustand, + async migrate() { + try { + const remotePromoSheets = await fetchPromoSheetCollection({ order: [PromoSheetOrder.PriorityDesc] }); + + // Update store to have all the sheets + remotePromoSheetsStore.getState().setSheets(remotePromoSheets); + + for (const sheet of remotePromoSheets.promoSheetCollection?.items ?? []) { + if (!sheet?.campaignKey) continue; + + const hasShown = campaigns.get([sheet.campaignKey]); + if (!hasShown) continue; + + remotePromoSheetsStore.getState().setSheet(sheet.sys.id, { + ...sheet, + hasBeenShown: true, + }); + } + } catch (error) { + logger.error(new RainbowError(`Failed to migrate remote promo sheets to zustand`), { + data: error, + }); + } + }, + }; +} diff --git a/src/migrations/types.ts b/src/migrations/types.ts index befc301c6f7..7797a230f51 100644 --- a/src/migrations/types.ts +++ b/src/migrations/types.ts @@ -17,6 +17,7 @@ export enum MigrationName { migratePinnedAndHiddenTokenUniqueIds = 'migration_migratePinnedAndHiddenTokenUniqueIds', migrateUnlockableAppIconStorage = 'migration_migrateUnlockableAppIconStorage', migratePersistedQueriesToMMKV = 'migration_migratePersistedQueriesToMMKV', + migrateRemotePromoSheetsToZustand = 'migration_migrateRemotePromoSheetsToZustand', } export type Migration = { diff --git a/src/model/migrations.ts b/src/model/migrations.ts index 76843c3bd8c..4eb72cf0de7 100644 --- a/src/model/migrations.ts +++ b/src/model/migrations.ts @@ -37,8 +37,6 @@ import { queryClient } from '@/react-query'; import { favoritesQueryKey } from '@/resources/favorites'; import { EthereumAddress, RainbowToken } from '@/entities'; import { getUniqueId } from '@/utils/ethereumUtils'; -import { userAssetsStore } from '@/state/assets/userAssets'; -import { Hex } from 'viem'; export default async function runMigrations() { // get current version diff --git a/src/navigation/types.ts b/src/navigation/types.ts index c908cc6cee3..8a35cda2633 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -4,6 +4,7 @@ import Routes from '@/navigation/routesNames'; import { PortalSheetProps } from '@/screens/Portal'; import { REGISTRATION_MODES } from '@/helpers/ens'; +import { CampaignCheckResult } from '@/components/remote-promo-sheet/checkForCampaign'; export type PartialNavigatorConfigOptions = Pick['Screen']>[0]>, 'options'>; @@ -66,6 +67,7 @@ export type RootStackParamList = { ensName: string; mode: REGISTRATION_MODES; }; + [Routes.REMOTE_PROMO_SHEET]: CampaignCheckResult; [Routes.CHECK_IDENTIFIER_SCREEN]: { onSuccess: () => Promise; onFailure: () => Promise; diff --git a/src/resources/cards/cardCollectionQuery.ts b/src/resources/cards/cardCollectionQuery.ts index c02494623d8..03ce8d5af1e 100644 --- a/src/resources/cards/cardCollectionQuery.ts +++ b/src/resources/cards/cardCollectionQuery.ts @@ -5,10 +5,9 @@ import { createQueryKey, queryClient, QueryConfig, QueryFunctionResult } from '@ import { arcClient } from '@/graphql'; import { Card, GetCardCollectionQuery } from '@/graphql/__generated__/arc'; import { pick } from 'lodash'; -import { IS_PROD } from '@/env'; -import * as ls from '@/storage'; import { useRemoteConfig } from '@/model/remoteConfig'; import { REMOTE_CARDS, useExperimentalFlag } from '@/config'; +import { remoteCardsStore } from '@/state/remoteCards/remoteCards'; export const TRIMMED_CARD_KEYS = [ 'cardKey', @@ -34,6 +33,7 @@ export type TrimmedCard = Pick & { url: string; }[]; }; + dismissed?: boolean; }; export type TrimmedCards = Record; @@ -52,12 +52,10 @@ export const cardCollectionQueryKey = createQueryKey('cardCollection', {}, { per function parseCardCollectionResponse(data: GetCardCollectionQuery) { const newCards = data.cardCollection?.items.reduce((acc, card) => { - if (!card) return acc; + if (!card || !card.cardKey) return acc; - if (IS_PROD) { - const hasDismissed = ls.cards.get([card.sys.id]); - if (hasDismissed) return acc; - } + const storedCard = remoteCardsStore.getState().getCard(card.cardKey); + if (storedCard?.dismissed) return acc; const newCard: TrimmedCard = { ...pick(card, ...TRIMMED_CARD_KEYS), @@ -70,7 +68,7 @@ function parseCardCollectionResponse(data: GetCardCollectionQuery) { }; return { ...acc, - [card.sys.id]: newCard, + [card.cardKey]: newCard, }; }, {} as TrimmedCards); @@ -101,13 +99,15 @@ export async function fetchCardCollection() { // /////////////////////////////////////////////// // Query Hook -export function useCardCollectionQuery() { +export function useCardCollectionQuery(config: QueryConfig = {}) { const { remote_cards_enabled: remoteFlag } = useRemoteConfig(); const localFlag = useExperimentalFlag(REMOTE_CARDS); return useQuery(cardCollectionQueryKey, cardCollectionQueryFunction, { enabled: remoteFlag || localFlag, staleTime: defaultStaleTime, + cacheTime: 1000 * 60 * 60 * 24, // 24 hours refetchInterval: 60_000, + ...config, }); } diff --git a/src/resources/promoSheet/promoSheetCollectionQuery.ts b/src/resources/promoSheet/promoSheetCollectionQuery.ts index 9724a5e886b..3ab7418018f 100644 --- a/src/resources/promoSheet/promoSheetCollectionQuery.ts +++ b/src/resources/promoSheet/promoSheetCollectionQuery.ts @@ -55,11 +55,11 @@ export async function fetchPromoSheetCollection({ order }: PromoSheetCollectionA export function usePromoSheetCollectionQuery( { order }: PromoSheetCollectionArgs = {}, - { enabled, refetchInterval = 30_000 }: { enabled?: boolean; refetchInterval?: number } = {} + config: QueryConfig = {} ) { return useQuery(promoSheetCollectionQueryKey({ order }), promoSheetCollectionQueryFunction, { - enabled, staleTime: defaultStaleTime, - refetchInterval, + refetchInterval: 60_000 * 5, + ...config, }); } diff --git a/src/screens/AppIconUnlockSheet.tsx b/src/screens/AppIconUnlockSheet.tsx index 603318d22d9..0b1163a7c4a 100644 --- a/src/screens/AppIconUnlockSheet.tsx +++ b/src/screens/AppIconUnlockSheet.tsx @@ -12,8 +12,8 @@ import * as i18n from '@/languages'; import { delay } from '@/utils/delay'; import Routes from '@/navigation/routesNames'; import { SheetActionButton } from '@/components/sheet'; -import { campaigns } from '@/storage'; import { analyticsV2 } from '@/analytics'; +import { remotePromoSheetsStore } from '@/state/remotePromoSheets/remotePromoSheets'; import { IS_ANDROID } from '@/env'; const APP_ICON_SIZE = 64; @@ -44,7 +44,7 @@ export default function AppIconUnlockSheet() { useEffect(() => { analyticsV2.track(analyticsV2.event.appIconUnlockSheetViewed, { appIcon: appIconKey }); return () => { - campaigns.set(['isCurrentlyShown'], false); + remotePromoSheetsStore.setState({ isShown: false }); }; }, [appIconKey]); diff --git a/src/screens/ChangeWalletSheet.tsx b/src/screens/ChangeWalletSheet.tsx index 14a0b36ffcc..c8a615701cd 100644 --- a/src/screens/ChangeWalletSheet.tsx +++ b/src/screens/ChangeWalletSheet.tsx @@ -1,4 +1,3 @@ -import { IS_TESTING } from 'react-native-dotenv'; import { useRoute } from '@react-navigation/native'; import lang from 'i18n-js'; import React, { useCallback, useMemo, useState } from 'react'; @@ -12,12 +11,11 @@ import { Centered, Column, Row } from '../components/layout'; import { Sheet, SheetTitle } from '../components/sheet'; import { Text } from '../components/text'; import { removeWalletData } from '../handlers/localstorage/removeWallet'; -import { cleanUpWalletKeys, RainbowWallet } from '../model/wallet'; +import { cleanUpWalletKeys } from '../model/wallet'; import { useNavigation } from '../navigation/Navigation'; import { addressSetSelected, walletsSetSelected, walletsUpdate } from '../redux/wallets'; import { analytics, analyticsV2 } from '@/analytics'; import { getExperimetalFlag, HARDWARE_WALLETS } from '@/config'; -import { useRemotePromoSheetContext } from '@/components/remote-promo-sheet/RemotePromoSheetProvider'; import { useAccountSettings, useInitializeWallet, useWallets, useWalletsWithBalancesAndNames, useWebData } from '@/hooks'; import Routes from '@/navigation/routesNames'; import styled from '@/styled-thing'; @@ -26,6 +24,7 @@ import logger from '@/utils/logger'; import { useTheme } from '@/theme'; import { EthereumAddress } from '@/entities'; import { getNotificationSettingsForWalletWithAddress } from '@/notifications/settings/storage'; +import { useRunChecks } from '@/components/remote-promo-sheet/runChecks'; const deviceHeight = deviceUtils.dimensions.height; const footerHeight = getExperimetalFlag(HARDWARE_WALLETS) ? 100 : 60; @@ -96,7 +95,7 @@ export default function ChangeWalletSheet() { const { params = {} as any } = useRoute(); const { onChangeWallet, watchOnly = false, currentAccountAddress } = params; const { selectedWallet, wallets } = useWallets(); - const { runChecks } = useRemotePromoSheetContext(); + const { runChecks } = useRunChecks(false); const { colors } = useTheme(); const { updateWebProfile } = useWebData(); @@ -145,16 +144,13 @@ export default function ChangeWalletSheet() { initializeWallet(null, null, null, false, false, null, true); if (!fromDeletion) { goBack(); - - if (IS_TESTING !== 'true') { - runChecks(); - } + setTimeout(runChecks, 10_000); } } catch (e) { logger.log('error while switching account', e); } }, - [currentAddress, dispatch, editMode, goBack, initializeWallet, onChangeWallet, wallets, watchOnly] + [currentAddress, dispatch, editMode, goBack, initializeWallet, onChangeWallet, runChecks, wallets, watchOnly] ); const deleteWallet = useCallback( diff --git a/src/screens/WalletScreen/index.tsx b/src/screens/WalletScreen/index.tsx index 1b982d1cace..012fb1738cf 100644 --- a/src/screens/WalletScreen/index.tsx +++ b/src/screens/WalletScreen/index.tsx @@ -29,8 +29,10 @@ import { AppState } from '@/redux/store'; import { addressCopiedToastAtom } from '@/recoil/addressCopiedToastAtom'; import { usePositions } from '@/resources/defi/PositionsQuery'; import styled from '@/styled-thing'; -import { UserAssetsSync } from '@/__swaps__/screens/Swap/components/UserAssetsSync'; import { IS_ANDROID } from '@/env'; +import { RemoteCardsSync } from '@/state/sync/RemoteCardsSync'; +import { RemotePromoSheetSync } from '@/state/sync/RemotePromoSheetSync'; +import { UserAssetsSync } from '@/state/sync/UserAssetsSync'; const WalletPage = styled(Page)({ ...position.sizeAsObject('100%'), @@ -143,6 +145,8 @@ const WalletScreen: React.FC = ({ navigation, route }) => { {/* NOTE: The component below renders null and is solely for keeping react-query and Zustand in sync */} + + ); diff --git a/src/screens/discover/components/DiscoverHome.js b/src/screens/discover/components/DiscoverHome.js index 09f500ee50c..1cf59a2c636 100644 --- a/src/screens/discover/components/DiscoverHome.js +++ b/src/screens/discover/components/DiscoverHome.js @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import useExperimentalFlag, { OP_REWARDS, PROFILES, HARDWARE_WALLETS, MINTS, NFT_OFFERS } from '@rainbow-me/config/experimentalHooks'; import { isTestnetNetwork } from '@/handlers/web3'; -import { Inline, Inset, Stack } from '@/design-system'; +import { Inline, Inset, Stack, Box } from '@/design-system'; import { useAccountSettings, useWallets } from '@/hooks'; import { ENSCreateProfileCard } from '@/components/cards/ENSCreateProfileCard'; import { ENSSearchCard } from '@/components/cards/ENSSearchCard'; @@ -16,14 +16,11 @@ import { NFTOffersCard } from '@/components/cards/NFTOffersCard'; import { MintsCard } from '@/components/cards/MintsCard/MintsCard'; import { FeaturedMintCard } from '@/components/cards/FeaturedMintCard'; import { IS_TEST } from '@/env'; -import { RemoteCardCarousel, useRemoteCardContext } from '@/components/cards/remote-cards'; -import { useRoute } from '@react-navigation/native'; +import { RemoteCardCarousel } from '@/components/cards/remote-cards'; export default function DiscoverHome() { const { profiles_enabled, mints_enabled, op_rewards_enabled } = useRemoteConfig(); const { network } = useAccountSettings(); - const { getCardsForPlacement } = useRemoteCardContext(); - const { name } = useRoute(); const profilesEnabledLocalFlag = useExperimentalFlag(PROFILES); const profilesEnabledRemoteFlag = profiles_enabled; const hardwareWalletsEnabled = useExperimentalFlag(HARDWARE_WALLETS); @@ -38,17 +35,15 @@ export default function DiscoverHome() { const hasHardwareWallets = Object.keys(wallets || {}).filter(key => wallets[key].type === walletTypes.bluetooth).length > 0; - const cards = useMemo(() => getCardsForPlacement(name), [name, getCardsForPlacement]); - return ( {!testNetwork ? ( - + {isProfilesEnabled && } - {!!cards.length && } + {mintsEnabled && ( @@ -67,7 +62,7 @@ export default function DiscoverHome() { - + ) : ( diff --git a/src/screens/points/content/PointsContent.tsx b/src/screens/points/content/PointsContent.tsx index c9122d70ef4..bfe284eba78 100644 --- a/src/screens/points/content/PointsContent.tsx +++ b/src/screens/points/content/PointsContent.tsx @@ -30,9 +30,11 @@ import { InfoCard } from '../components/InfoCard'; import { displayNextDistribution } from '../constants'; import { analyticsV2 } from '@/analytics'; import { useFocusEffect, useRoute } from '@react-navigation/native'; -import { RemoteCardCarousel, useRemoteCardContext } from '@/components/cards/remote-cards'; +import { RemoteCardCarousel } from '@/components/cards/remote-cards'; import { usePoints } from '@/resources/points'; import { GetPointsDataForWalletQuery } from '@/graphql/__generated__/metadataPOST'; +import { remoteCardsStore } from '@/state/remoteCards/remoteCards'; +import Routes from '@/navigation/routesNames'; const InfoCards = ({ points }: { points: GetPointsDataForWalletQuery | undefined }) => { const labelSecondary = useForegroundColor('labelSecondary'); @@ -153,7 +155,7 @@ export default function PointsContent() { const { colors } = useTheme(); const { name } = useRoute(); const { width: deviceWidth } = useDimensions(); - const { getCardsForPlacement } = useRemoteCardContext(); + const getCardIdsForScreen = remoteCardsStore(state => state.getCardIdsForScreen); const { accountAddress, accountENS } = useAccountProfile(); const { setClipboard } = useClipboard(); const { isReadOnlyWallet } = useWallets(); @@ -167,7 +169,7 @@ export default function PointsContent() { walletAddress: accountAddress, }); - const cards = useMemo(() => getCardsForPlacement(name as string), [getCardsForPlacement, name]); + const cardIds = useMemo(() => getCardIdsForScreen(name as keyof typeof Routes), [getCardIdsForScreen, name]); useFocusEffect( useCallback(() => { @@ -286,7 +288,7 @@ export default function PointsContent() { - {!!cards.length && !isReadOnlyWallet && ( + {!!cardIds.length && !isReadOnlyWallet && ( <> diff --git a/src/state/remoteCards/remoteCards.ts b/src/state/remoteCards/remoteCards.ts new file mode 100644 index 00000000000..0cab5ba5359 --- /dev/null +++ b/src/state/remoteCards/remoteCards.ts @@ -0,0 +1,167 @@ +import { RainbowError, logger } from '@/logger'; +import Routes from '@/navigation/routesNames'; +import { queryClient } from '@/react-query'; +import { TrimmedCard, TrimmedCards, cardCollectionQueryKey } from '@/resources/cards/cardCollectionQuery'; +import { createRainbowStore } from '@/state/internal/createRainbowStore'; + +export type CardKey = string; + +export interface RemoteCardsState { + cardsById: Set; + cards: Map; + + setCards: (cards: TrimmedCards) => void; + + getCard: (id: string) => TrimmedCard | undefined; + getCardPlacement: (id: string) => TrimmedCard['placement']; + dismissCard: (id: string) => void; + + getCardIdsForScreen: (screen: keyof typeof Routes) => string[]; +} + +type RoutesWithIndex = typeof Routes & { [key: string]: string }; + +type RemoteCardsStateWithTransforms = Omit, 'cards' | 'cardsById'> & { + cardsById: Array; + cards: Array<[string, TrimmedCard]>; +}; + +function serializeState(state: Partial, version?: number) { + try { + const transformedStateToPersist: RemoteCardsStateWithTransforms = { + ...state, + cardsById: state.cardsById ? Array.from(state.cardsById) : [], + cards: state.cards ? Array.from(state.cards.entries()) : [], + }; + + return JSON.stringify({ + state: transformedStateToPersist, + version, + }); + } catch (error) { + logger.error(new RainbowError('Failed to serialize state for remote cards storage'), { error }); + throw error; + } +} + +function deserializeState(serializedState: string) { + let parsedState: { state: RemoteCardsStateWithTransforms; version: number }; + try { + parsedState = JSON.parse(serializedState); + } catch (error) { + logger.error(new RainbowError('Failed to parse serialized state from remote cards storage'), { error }); + throw error; + } + + const { state, version } = parsedState; + + let cardsByIdData = new Set(); + try { + if (state.cardsById.length) { + cardsByIdData = new Set(state.cardsById); + } + } catch (error) { + logger.error(new RainbowError('Failed to convert cardsById from remote cards storage'), { error }); + throw error; + } + + let cardsData: Map = new Map(); + try { + if (state.cards.length) { + cardsData = new Map(state.cards); + } + } catch (error) { + logger.error(new RainbowError('Failed to convert cards from remote cards storage'), { error }); + throw error; + } + + return { + state: { + ...state, + cardsById: cardsByIdData, + cards: cardsData, + }, + version, + }; +} + +export const remoteCardsStore = createRainbowStore( + (set, get) => ({ + cards: new Map(), + cardsById: new Set(), + + setCards: (cards: TrimmedCards) => { + const cardsData = new Map(); + const validCards = Object.values(cards).filter(card => card.sys.id); + + validCards.forEach(card => { + const existingCard = get().getCard(card.sys.id as string); + if (existingCard) { + cardsData.set(card.sys.id, { ...existingCard, ...card }); + } else { + cardsData.set(card.sys.id, card); + } + }); + + set({ + cards: cardsData, + cardsById: new Set(validCards.map(card => card.sys.id as string)), + }); + }, + + getCard: (id: string) => get().cards.get(id), + getCardPlacement: (id: string) => { + const card = get().getCard(id); + if (!card || !card.placement) { + return undefined; + } + + return (Routes as RoutesWithIndex)[card.placement]; + }, + + dismissCard: (id: string) => + set(state => { + const card = get().getCard(id); + if (!card) { + return state; + } + + const newCard = { ...card, dismissed: true }; + + // NOTE: Also need to update the query data with + queryClient.setQueryData(cardCollectionQueryKey, (oldData: TrimmedCards | undefined = {}) => { + return { + ...oldData, + [id]: newCard, + }; + }); + + // NOTE: This is kinda a hack to immediately dismiss the card from the carousel and not have an empty space + // it will be added back during the next fetch + state.cardsById.delete(id); + + return { + ...state, + cards: new Map(state.cards.set(id, newCard)), + }; + }), + getCardIdsForScreen: (screen: keyof typeof Routes) => { + return Array.from(get().cards.values()) + .filter(card => get().getCardPlacement(card.sys.id) === screen) + .filter(card => !card.dismissed) + .sort((a, b) => { + if (a.index === b.index) return 0; + if (a.index === undefined || a.index === null) return 1; + if (b.index === undefined || b.index === null) return -1; + return a.index - b.index; + }) + .map(card => card.sys.id); + }, + }), + { + storageKey: 'remoteCardsStore', + version: 1, + serializer: serializeState, + deserializer: deserializeState, + } +); diff --git a/src/state/remotePromoSheets/remotePromoSheets.ts b/src/state/remotePromoSheets/remotePromoSheets.ts new file mode 100644 index 00000000000..9e364bbf91e --- /dev/null +++ b/src/state/remotePromoSheets/remotePromoSheets.ts @@ -0,0 +1,166 @@ +import { GetPromoSheetCollectionQuery, PromoSheet } from '@/graphql/__generated__/arc'; +import { RainbowError, logger } from '@/logger'; +import { createRainbowStore } from '@/state/internal/createRainbowStore'; + +export type OmittedPromoSheet = Omit< + PromoSheet, + | 'accentColor' + | 'backgroundColor' + | 'backgroundImage' + | 'contentfulMetadata' + | 'header' + | 'headerImage' + | 'linkedFrom' + | 'primaryButtonProps' + | 'secondaryButtonProps' + | 'sheetHandleColor' + | 'subHeader' + | 'sys' +> & { + sys: { + id: string; + }; + hasBeenShown: boolean; +}; + +export interface RemotePromoSheetsState { + sheetsById: Set; + sheets: Map; + + lastShownTimestamp: number; + isShown: boolean; + + showSheet: (id: string) => void; + + setSheet: (id: string, sheet: OmittedPromoSheet) => void; + setSheets: (data: GetPromoSheetCollectionQuery) => void; + getSheet: (id: string) => OmittedPromoSheet | undefined; +} + +type RemotePromoSheetsStateWithTransforms = Omit, 'sheets' | 'sheetsById'> & { + sheetsById: Array; + sheets: Array<[string, OmittedPromoSheet]>; +}; + +function serializeState(state: Partial, version?: number) { + try { + const transformedStateToPersist: RemotePromoSheetsStateWithTransforms = { + ...state, + sheetsById: state.sheetsById ? Array.from(state.sheetsById) : [], + sheets: state.sheets ? Array.from(state.sheets.entries()) : [], + }; + + return JSON.stringify({ + state: transformedStateToPersist, + version, + }); + } catch (error) { + logger.error(new RainbowError('Failed to serialize state for remote promo sheets storage'), { error }); + throw error; + } +} + +function deserializeState(serializedState: string) { + let parsedState: { state: RemotePromoSheetsStateWithTransforms; version: number }; + try { + parsedState = JSON.parse(serializedState); + } catch (error) { + logger.error(new RainbowError('Failed to parse serialized state from remote promo sheets storage'), { error }); + throw error; + } + + const { state, version } = parsedState; + + let sheetsByIdData = new Set(); + try { + if (state.sheetsById.length) { + sheetsByIdData = new Set(state.sheetsById); + } + } catch (error) { + logger.error(new RainbowError('Failed to convert sheetsById from remote promo sheets storage'), { error }); + throw error; + } + + let sheetsData: Map = new Map(); + try { + if (state.sheets.length) { + sheetsData = new Map(state.sheets); + } + } catch (error) { + logger.error(new RainbowError('Failed to convert sheets from remote promo sheets storage'), { error }); + throw error; + } + + return { + state: { + ...state, + sheetsById: sheetsByIdData, + sheets: sheetsData, + }, + version, + }; +} + +export const remotePromoSheetsStore = createRainbowStore( + (set, get) => ({ + sheets: new Map(), + sheetsById: new Set(), + + lastShownTimestamp: 0, + isShown: false, + + setSheet: (id: string, sheet: OmittedPromoSheet) => { + const newSheets = new Map(get().sheets); + + const existingSheet = get().sheets.get(id); + if (existingSheet) { + newSheets.set(id, { ...existingSheet, ...sheet }); + } else { + newSheets.set(id, sheet); + } + + set({ sheets: newSheets }); + }, + + setSheets: (data: GetPromoSheetCollectionQuery) => { + const sheets = (data.promoSheetCollection?.items ?? []) as OmittedPromoSheet[]; + + const sheetsData = new Map(); + sheets.forEach(sheet => { + const existingSheet = get().sheets.get(sheet.sys.id); + if (existingSheet) { + sheetsData.set(sheet.sys.id, { ...existingSheet, ...sheet }); + } else { + sheetsData.set(sheet.sys.id, sheet); + } + }); + + set({ + sheets: sheetsData, + sheetsById: new Set(sheets.map(sheet => sheet.sys.id)), + }); + }, + + showSheet: (id: string) => { + const sheet = get().sheets.get(id); + if (!sheet) return; + + const newSheets = new Map(get().sheets); + newSheets.set(id, { ...sheet, hasBeenShown: true }); + + set({ + isShown: true, + lastShownTimestamp: Date.now(), + sheets: newSheets, + }); + }, + + getSheet: (id: string) => get().sheets.get(id), + }), + { + storageKey: 'remotePromoSheetsStore', + version: 1, + serializer: serializeState, + deserializer: deserializeState, + } +); diff --git a/src/state/sync/RemoteCardsSync.tsx b/src/state/sync/RemoteCardsSync.tsx new file mode 100644 index 00000000000..8706f397955 --- /dev/null +++ b/src/state/sync/RemoteCardsSync.tsx @@ -0,0 +1,19 @@ +import { TrimmedCards, useCardCollectionQuery } from '@/resources/cards/cardCollectionQuery'; +import { remoteCardsStore } from '../remoteCards/remoteCards'; +import { IS_TEST } from '@/env'; +import { useCallback, memo } from 'react'; + +const RemoteCardsSyncComponent = () => { + const onSuccess = useCallback((data: TrimmedCards) => { + remoteCardsStore.getState().setCards(data); + }, []); + + useCardCollectionQuery({ + onSuccess, + enabled: !IS_TEST, + }); + + return null; +}; + +export const RemoteCardsSync = memo(RemoteCardsSyncComponent, () => true); diff --git a/src/state/sync/RemotePromoSheetSync.tsx b/src/state/sync/RemotePromoSheetSync.tsx new file mode 100644 index 00000000000..e17624cee4e --- /dev/null +++ b/src/state/sync/RemotePromoSheetSync.tsx @@ -0,0 +1,27 @@ +import React, { useCallback } from 'react'; + +import { remotePromoSheetsStore } from '../remotePromoSheets/remotePromoSheets'; +import { usePromoSheetCollectionQuery } from '@/resources/promoSheet/promoSheetCollectionQuery'; +import { GetPromoSheetCollectionQuery, PromoSheetOrder } from '@/graphql/__generated__/arc'; +import { useRunChecks } from '@/components/remote-promo-sheet/runChecks'; +import { IS_TEST } from '@/env'; + +const RemotePromoSheetSyncComponent = () => { + const onSuccess = useCallback((data: GetPromoSheetCollectionQuery) => { + remotePromoSheetsStore.getState().setSheets(data); + }, []); + + usePromoSheetCollectionQuery( + { order: [PromoSheetOrder.PriorityDesc] }, + { + onSuccess, + enabled: !IS_TEST, + } + ); + + useRunChecks(); + + return null; +}; + +export const RemotePromoSheetSync = React.memo(RemotePromoSheetSyncComponent, () => true); diff --git a/src/__swaps__/screens/Swap/components/UserAssetsSync.tsx b/src/state/sync/UserAssetsSync.tsx similarity index 95% rename from src/__swaps__/screens/Swap/components/UserAssetsSync.tsx rename to src/state/sync/UserAssetsSync.tsx index c8784f6b353..636b937c0d0 100644 --- a/src/__swaps__/screens/Swap/components/UserAssetsSync.tsx +++ b/src/state/sync/UserAssetsSync.tsx @@ -6,7 +6,7 @@ import { useSwapsStore } from '@/state/swaps/swapsStore'; import { selectUserAssetsList, selectorFilterByUserChains } from '@/__swaps__/screens/Swap/resources/_selectors/assets'; import { ParsedSearchAsset } from '@/__swaps__/types/assets'; import { ChainId } from '@/__swaps__/types/chains'; -import { useUserAssets } from '../resources/assets'; +import { useUserAssets } from '@/__swaps__/screens/Swap/resources/assets'; export const UserAssetsSync = memo(function UserAssetsSync() { const { accountAddress: currentAddress, nativeCurrency: currentCurrency } = useAccountSettings(); diff --git a/src/storage/index.ts b/src/storage/index.ts index 3ae6a5643ca..824c7288ce7 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -78,6 +78,9 @@ export const pendingTransactions = new Storage<[], { pendingTransactions: Record export const review = new Storage<[], Review>({ id: 'review' }); +/** + * @deprecated - use `remotePromoSheetStore` instead + */ export const campaigns = new Storage<[], Campaigns>({ id: 'campaigns' }); export const cards = new Storage<[], Cards>({ id: 'cards' }); diff --git a/src/utils/reviewAlert.ts b/src/utils/reviewAlert.ts index f54add82121..44b3113b6f8 100644 --- a/src/utils/reviewAlert.ts +++ b/src/utils/reviewAlert.ts @@ -30,7 +30,7 @@ export const numberOfTimesBeforePrompt: { }; export const handleReviewPromptAction = async (action: ReviewPromptAction) => { - logger.info(`handleReviewPromptAction: ${action}`); + logger.debug(`handleReviewPromptAction: ${action}`); if (IS_TESTING === 'true') { return; @@ -53,21 +53,26 @@ export const handleReviewPromptAction = async (action: ReviewPromptAction) => { } const timeOfLastPrompt = ls.review.get(['timeOfLastPrompt']) || 0; - logger.info(`timeOfLastPrompt: ${timeOfLastPrompt}`); + logger.debug(`timeOfLastPrompt: ${timeOfLastPrompt}`); actionToDispatch.numOfTimesDispatched += 1; - logger.info(`numOfTimesDispatched: ${actionToDispatch.numOfTimesDispatched}`); + logger.debug(`numOfTimesDispatched: ${actionToDispatch.numOfTimesDispatched}`); - ls.review.set(['actions'], actions); + const hasReachedAmount = actionToDispatch.numOfTimesDispatched >= numberOfTimesBeforePrompt[action]; - if (actionToDispatch.numOfTimesDispatched >= numberOfTimesBeforePrompt[action] && timeOfLastPrompt + TWO_MONTHS <= Date.now()) { - logger.info(`Prompting for review`); + if (hasReachedAmount) { + // set the numOfTimesDispatched to MAX + actionToDispatch.numOfTimesDispatched = numberOfTimesBeforePrompt[action]; + } + if (hasReachedAmount && timeOfLastPrompt + TWO_MONTHS <= Date.now()) { + logger.debug(`Prompting for review`); actionToDispatch.numOfTimesDispatched = 0; - ls.review.set(['actions'], actions); ls.review.set(['timeOfLastPrompt'], Date.now()); promptForReview(); } + + ls.review.set(['actions'], actions); }; export const promptForReview = async () => {