From 1336a6201af3f80db0893552e81895a6bbd1ce65 Mon Sep 17 00:00:00 2001 From: Inokentii Mazhara Date: Thu, 22 Feb 2024 12:17:56 +0200 Subject: [PATCH 01/11] TW-1307 Integrate Hypelab ads --- .env.dist | 4 + .../activity-group-item.styles.ts | 1 + .../activity-groups-list.styles.ts | 9 +- .../activity-groups-list.tsx | 25 ++- .../generic-promotion-item/index.tsx | 121 ++++++++++++++ .../generic-promotion-item/styles.ts | 33 ++++ src/components/image-promotion-view/index.tsx | 58 +++++++ .../image-promotion-view/selectors.ts | 5 + src/components/image-promotion-view/styles.ts | 39 +++++ .../new-hypelab-promotion/index.tsx | 149 ++++++++++++++++++ .../new-hypelab-promotion/styles.ts | 32 ++++ .../new-optimal-promotion/index.tsx | 117 ++++++++++++++ .../new-optimal-promotion/styles.ts | 10 ++ .../optimal-promotion-item.tsx | 94 ----------- .../optimal-promotion-variant.enum.ts | 4 - .../promotion-item.selectors.ts | 3 - .../promotion-item/promotion-item.styles.ts | 60 ------- .../promotion-item/promotion-item.tsx | 82 ---------- .../text-promotion-item.tsx | 80 ---------- src/components/text-promotion-view/index.tsx | 107 +++++++++++++ .../selectors.ts} | 2 +- .../styles.ts} | 15 +- src/enums/ad-frame-message-type.enum.ts | 6 + src/enums/promotion-variant.enum.ts | 4 + ...sable-promotion-after-confirmation.hook.ts | 8 +- .../promotion-carousel-item.styles.ts | 10 +- .../promotion-carousel-item.tsx | 26 ++- .../promotion-carousel.styles.ts | 5 +- .../promotion-carousel/promotion-carousel.tsx | 8 +- .../top-tokens-table.styles.ts | 3 +- .../top-coins-table/top-tokens-table.tsx | 16 +- src/screens/notifications/notifications.tsx | 7 +- src/screens/wallet/token-list/token-list.tsx | 11 +- .../partners-promotion-selectors.ts | 1 + src/types/promotion.ts | 33 ++++ src/utils/env.utils.ts | 4 + 36 files changed, 814 insertions(+), 378 deletions(-) create mode 100644 src/components/generic-promotion-item/index.tsx create mode 100644 src/components/generic-promotion-item/styles.ts create mode 100644 src/components/image-promotion-view/index.tsx create mode 100644 src/components/image-promotion-view/selectors.ts create mode 100644 src/components/image-promotion-view/styles.ts create mode 100644 src/components/new-hypelab-promotion/index.tsx create mode 100644 src/components/new-hypelab-promotion/styles.ts create mode 100644 src/components/new-optimal-promotion/index.tsx create mode 100644 src/components/new-optimal-promotion/styles.ts delete mode 100644 src/components/optimal-promotion-item/optimal-promotion-item.tsx delete mode 100644 src/components/optimal-promotion-item/optimal-promotion-variant.enum.ts delete mode 100644 src/components/promotion-item/promotion-item.selectors.ts delete mode 100644 src/components/promotion-item/promotion-item.styles.ts delete mode 100644 src/components/promotion-item/promotion-item.tsx delete mode 100644 src/components/text-promotion-item/text-promotion-item.tsx create mode 100644 src/components/text-promotion-view/index.tsx rename src/components/{text-promotion-item/text-promotion-item.selectors.ts => text-promotion-view/selectors.ts} (72%) rename src/components/{text-promotion-item/text-promotion-item.styles.ts => text-promotion-view/styles.ts} (80%) create mode 100644 src/enums/ad-frame-message-type.enum.ts create mode 100644 src/enums/promotion-variant.enum.ts create mode 100644 src/types/promotion.ts diff --git a/.env.dist b/.env.dist index 71aada79c..5fe2b69a1 100644 --- a/.env.dist +++ b/.env.dist @@ -19,3 +19,7 @@ TEZOS_DEXES_API_URL=wss://dexes-api-mainnet.stage.madfish.xyz TEMPLE_WALLET_ROUTE3_AUTH_TOKEN= DYNAMIC_LINKS_DOMAIN_URI_PREFIX=https://templenft.page.link + +HYPELAB_AD_FRAME_URL= +HYPELAB_SMALL_PLACEMENT_SLUG= +HYPELAB_NATIVE_PLACEMENT_SLUG= diff --git a/src/components/activity-groups-list/activity-group-item/activity-group-item.styles.ts b/src/components/activity-groups-list/activity-group-item/activity-group-item.styles.ts index f063a6efb..bc2ec5c38 100644 --- a/src/components/activity-groups-list/activity-group-item/activity-group-item.styles.ts +++ b/src/components/activity-groups-list/activity-group-item/activity-group-item.styles.ts @@ -3,6 +3,7 @@ import { formatSize } from '../../../styles/format-size'; export const useActivityGroupItemStyles = createUseStyles(({ colors }) => ({ container: { + marginLeft: formatSize(16), borderBottomColor: colors.lines, borderBottomWidth: formatSize(0.5), paddingRight: formatSize(16) diff --git a/src/components/activity-groups-list/activity-groups-list.styles.ts b/src/components/activity-groups-list/activity-groups-list.styles.ts index 3ccfa68e6..6598f7bd3 100644 --- a/src/components/activity-groups-list/activity-groups-list.styles.ts +++ b/src/components/activity-groups-list/activity-groups-list.styles.ts @@ -5,21 +5,22 @@ export const useActivityGroupsListStyles = createUseStyles(({ colors, typography contentContainer: { flex: 1, paddingTop: formatSize(8), - paddingBottom: formatSize(16), - paddingLeft: formatSize(16) + paddingBottom: formatSize(16) }, adContainer: { paddingBottom: formatSize(12), - paddingHorizontal: formatSize(16) + paddingRight: formatSize(16) }, sectionHeaderText: { ...typography.numbersMedium13, color: colors.gray2, backgroundColor: colors.pageBG, - paddingVertical: formatSize(4) + paddingVertical: formatSize(4), + marginLeft: formatSize(16) }, promotionItemWrapper: { paddingVertical: formatSize(12), + marginLeft: formatSize(16), borderBottomWidth: formatSize(0.5), borderBottomColor: colors.lines }, diff --git a/src/components/activity-groups-list/activity-groups-list.tsx b/src/components/activity-groups-list/activity-groups-list.tsx index 55437da85..b38ef8caf 100644 --- a/src/components/activity-groups-list/activity-groups-list.tsx +++ b/src/components/activity-groups-list/activity-groups-list.tsx @@ -3,9 +3,10 @@ import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, Text, View } from 'react-native'; import { DataPlaceholder } from 'src/components/data-placeholder/data-placeholder'; -import { OptimalPromotionItem } from 'src/components/optimal-promotion-item/optimal-promotion-item'; +import { GenericPromotionItem } from 'src/components/generic-promotion-item'; import { RefreshControl } from 'src/components/refresh-control/refresh-control'; import { emptyFn } from 'src/config/general'; +import { useAdTemporaryHiding } from 'src/hooks/use-ad-temporary-hiding.hook'; import { useFakeRefreshControlProps } from 'src/hooks/use-fake-refresh-control-props.hook'; import { usePartnersPromoLoad } from 'src/hooks/use-partners-promo'; import { ActivityGroup, emptyActivity } from 'src/interfaces/activity.interface'; @@ -19,7 +20,6 @@ import { useActivityGroupsListStyles } from './activity-groups-list.styles'; type ListItem = string | ActivityGroup; -const keyExtractor = (item: ListItem) => (typeof item === 'string' ? item : item[0].hash); const getItemType = (item: ListItem) => (typeof item === 'string' ? 'sectionHeader' : 'row'); const ListEmptyComponent = ; @@ -45,11 +45,25 @@ export const ActivityGroupsList: FC = ({ const styles = useActivityGroupsListStyles(); const partnersPromotionEnabled = useIsPartnersPromoEnabledSelector(); + const { isHiddenTemporarily } = useAdTemporaryHiding(PROMOTION_ID); const fakeRefreshControlProps = useFakeRefreshControlProps(); const [endIsReached, setEndIsReached] = useState(false); const [loadingEnded, setLoadingEnded] = useState(!isLoading); const [promotionErrorOccurred, setPromotionErrorOccurred] = useState(false); - const shouldShowPromotion = partnersPromotionEnabled && !promotionErrorOccurred; + const shouldShowPromotion = partnersPromotionEnabled && !promotionErrorOccurred && !isHiddenTemporarily; + + const keyExtractor = useCallback( + (item: ListItem, index: number) => { + const keyRoot = typeof item === 'string' ? item : item[0].hash; + + if (index === 1 && shouldShowPromotion) { + return `${keyRoot}-with-promotion`; + } + + return keyRoot; + }, + [shouldShowPromotion] + ); useEffect(() => { if (!isLoading) { @@ -93,12 +107,11 @@ export const ActivityGroupsList: FC = ({ const Promotion = useMemo( () => ( - ), diff --git a/src/components/generic-promotion-item/index.tsx b/src/components/generic-promotion-item/index.tsx new file mode 100644 index 000000000..66289326a --- /dev/null +++ b/src/components/generic-promotion-item/index.tsx @@ -0,0 +1,121 @@ +import { useIsFocused } from '@react-navigation/native'; +import React, { memo, useCallback, useEffect, useState } from 'react'; +import { StyleProp, View, ViewStyle } from 'react-native'; + +import { isAndroid } from 'src/config/system'; +import { useAdTemporaryHiding } from 'src/hooks/use-ad-temporary-hiding.hook'; +import { TestIdProps } from 'src/interfaces/test-id.props'; +import { useIsPartnersPromoEnabledSelector } from 'src/store/partners-promotion/partners-promotion-selectors'; + +import { PromotionVariantEnum } from '../../enums/promotion-variant.enum'; +import { ActivityIndicator } from '../activity-indicator'; +import { NewHypelabPromotion } from '../new-hypelab-promotion'; +import { NewOptimalPromotion } from '../new-optimal-promotion'; + +import { useGenericPromotionItemStyles } from './styles'; + +interface Props extends TestIdProps { + id: string; + style?: StyleProp; + shouldShowCloseButton?: boolean; + shouldTryHypelabAd?: boolean; + variant?: PromotionVariantEnum; + onError?: EmptyFn; +} + +export const GenericPromotionItem = memo( + ({ + id, + style, + shouldShowCloseButton = true, + variant = PromotionVariantEnum.Image, + shouldTryHypelabAd = true, + onError, + ...testIDProps + }) => { + const isImageAd = variant === PromotionVariantEnum.Image; + const styles = useGenericPromotionItemStyles(); + const partnersPromotionEnabled = useIsPartnersPromoEnabledSelector(); + const { isHiddenTemporarily, hidePromotion } = useAdTemporaryHiding(id); + const isFocused = useIsFocused(); + + const [adsState, setAdsState] = useState({ + shouldUseOptimalAd: true, + adError: false, + adIsReady: false + }); + const { adError, shouldUseOptimalAd, adIsReady } = adsState; + + useEffect(() => { + if (!isFocused) { + setAdsState({ + shouldUseOptimalAd: true, + adError: false, + adIsReady: false + }); + } + }, [isFocused]); + + const handleAdError = useCallback(() => { + setAdsState(prevState => ({ ...prevState, adError: true })); + onError && onError(); + }, [onError]); + + const handleOptimalError = useCallback(() => { + if (!shouldTryHypelabAd) { + handleAdError(); + } + setAdsState(prevState => ({ ...prevState, shouldUseOptimalAd: false })); + }, [handleAdError, shouldTryHypelabAd]); + const handleHypelabError = useCallback(() => { + handleAdError(); + }, [handleAdError]); + + const handleAdReady = useCallback(() => { + setAdsState(prevState => ({ ...prevState, adIsReady: true })); + }, []); + + if (!partnersPromotionEnabled || adError || isHiddenTemporarily) { + return null; + } + + return ( + + {shouldUseOptimalAd && isFocused && ( + + )} + {!shouldUseOptimalAd && shouldTryHypelabAd && isFocused && ( + + )} + {!adIsReady && ( + + + + )} + + ); + } +); diff --git a/src/components/generic-promotion-item/styles.ts b/src/components/generic-promotion-item/styles.ts new file mode 100644 index 000000000..8e150d10c --- /dev/null +++ b/src/components/generic-promotion-item/styles.ts @@ -0,0 +1,33 @@ +import { black } from 'src/config/styles'; +import { createUseStylesMemoized } from 'src/styles/create-use-styles'; +import { formatSize } from 'src/styles/format-size'; +import { generateShadow } from 'src/styles/generate-shadow'; + +export const useGenericPromotionItemStyles = createUseStylesMemoized(({ colors }) => ({ + androidContainer: { + overflow: 'hidden' + }, + container: { + position: 'relative', + backgroundColor: colors.cardBG, + borderRadius: formatSize(10), + ...generateShadow(1, black) + }, + textAdLoadingContainer: { + minHeight: formatSize(80) + }, + imgAdLoadingContainer: { + minHeight: formatSize(112) + }, + loaderContainer: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + backgroundColor: colors.cardBG, + justifyContent: 'center', + alignItems: 'center', + borderRadius: formatSize(10) + } +})); diff --git a/src/components/image-promotion-view/index.tsx b/src/components/image-promotion-view/index.tsx new file mode 100644 index 000000000..18f2ad4c3 --- /dev/null +++ b/src/components/image-promotion-view/index.tsx @@ -0,0 +1,58 @@ +import React, { useCallback, memo, PropsWithChildren } from 'react'; +import { View } from 'react-native'; + +import { TestIdProps } from 'src/interfaces/test-id.props'; +import { formatSize } from 'src/styles/format-size'; +import { useColors } from 'src/styles/use-colors'; +import { openUrl } from 'src/utils/linking'; + +import { Bage } from '../bage/bage'; +import { Icon } from '../icon/icon'; +import { IconNameEnum } from '../icon/icon-name.enum'; +import { TouchableWithAnalytics } from '../touchable-with-analytics'; + +import { PromotionItemSelectors } from './selectors'; +import { useImagePromotionViewStyles } from './styles'; + +interface ImagePromotionViewProps extends TestIdProps { + href: string; + isVisible: boolean; + shouldShowCloseButton: boolean; + shouldShowAdBage: boolean; + onClose: EmptyFn; +} + +export const ImagePromotionView = memo>( + ({ children, href, isVisible, shouldShowCloseButton, shouldShowAdBage, onClose, ...testIDProps }) => { + const colors = useColors(); + const styles = useImagePromotionViewStyles(); + + const openLink = useCallback(() => openUrl(href), [href]); + + return ( + + {children} + + {shouldShowAdBage && ( + + + + )} + + {shouldShowCloseButton && ( + + + + )} + + ); + } +); diff --git a/src/components/image-promotion-view/selectors.ts b/src/components/image-promotion-view/selectors.ts new file mode 100644 index 000000000..a97d8b966 --- /dev/null +++ b/src/components/image-promotion-view/selectors.ts @@ -0,0 +1,5 @@ +export enum PromotionItemSelectors { + closeButton = 'Promotion Item/Close Button', + cancelButton = 'Promotion Item/Cancel Button', + disableButton = 'Promotion Item/Disable Button' +} diff --git a/src/components/image-promotion-view/styles.ts b/src/components/image-promotion-view/styles.ts new file mode 100644 index 000000000..4299d46bd --- /dev/null +++ b/src/components/image-promotion-view/styles.ts @@ -0,0 +1,39 @@ +import { black } from 'src/config/styles'; +import { createUseStylesMemoized } from 'src/styles/create-use-styles'; +import { formatSize } from 'src/styles/format-size'; +import { generateShadow } from 'src/styles/generate-shadow'; + +export const useImagePromotionViewStyles = createUseStylesMemoized(({ colors }) => ({ + container: { + justifyContent: 'center', + alignItems: 'center', + height: formatSize(112), + position: 'relative' + }, + invisible: { + display: 'none' + }, + bageContainer: { + position: 'absolute', + top: 0, + left: 0, + zIndex: 2, + overflow: 'hidden', + backgroundColor: colors.blue, + borderTopLeftRadius: formatSize(10), + borderBottomRightRadius: formatSize(10) + }, + closeButton: { + position: 'absolute', + top: formatSize(8), + right: formatSize(10), + width: formatSize(24), + height: formatSize(24), + borderRadius: formatSize(12), + zIndex: 2, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.white, + ...generateShadow(1, black) + } +})); diff --git a/src/components/new-hypelab-promotion/index.tsx b/src/components/new-hypelab-promotion/index.tsx new file mode 100644 index 000000000..45f7a5bc7 --- /dev/null +++ b/src/components/new-hypelab-promotion/index.tsx @@ -0,0 +1,149 @@ +import React, { FC, useCallback, useMemo, useState } from 'react'; +import { View } from 'react-native'; +import { WebView, WebViewMessageEvent } from 'react-native-webview'; + +import { AdFrameMessageType } from 'src/enums/ad-frame-message-type.enum'; +import { PromotionVariantEnum } from 'src/enums/promotion-variant.enum'; +import { ThemesEnum } from 'src/interfaces/theme.enum'; +import { useThemeSelector } from 'src/store/settings/settings-selectors'; +import { formatSize } from 'src/styles/format-size'; +import { useColors } from 'src/styles/use-colors'; +import { AdFrameMessage, SingleProviderPromotionProps } from 'src/types/promotion'; +import { AnalyticsEventCategory } from 'src/utils/analytics/analytics-event.enum'; +import { useAnalytics } from 'src/utils/analytics/use-analytics.hook'; +import { HYPELAB_AD_FRAME_URL, HYPELAB_NATIVE_PLACEMENT_SLUG, HYPELAB_SMALL_PLACEMENT_SLUG } from 'src/utils/env.utils'; +import { useTimeout } from 'src/utils/hooks'; +import { isString } from 'src/utils/is-string'; +import { openUrl } from 'src/utils/linking'; + +import { Icon } from '../icon/icon'; +import { IconNameEnum } from '../icon/icon-name.enum'; +import { ImagePromotionView } from '../image-promotion-view'; +import { TextPromotionItemSelectors } from '../text-promotion-view/selectors'; +import { TouchableWithAnalytics } from '../touchable-with-analytics'; + +import { useNewHypelabPromotionStyles } from './styles'; + +export const NewHypelabPromotion: FC = ({ + variant, + isVisible, + shouldShowCloseButton, + onClose, + onReady, + onError, + ...testIDProps +}) => { + const { testID, testIDProperties } = testIDProps; + const isImageAd = variant === PromotionVariantEnum.Image; + const colors = useColors(); + const styles = useNewHypelabPromotionStyles(); + const theme = useThemeSelector(); + const { trackEvent } = useAnalytics(); + const [adFrameAspectRatio, setAdFrameAspectRatio] = useState(359 / 80); + const [adHref, setAdHref] = useState(); + const adFrameSource = useMemo(() => { + const placementSlug = isImageAd ? HYPELAB_SMALL_PLACEMENT_SLUG : HYPELAB_NATIVE_PLACEMENT_SLUG; + const origin = theme === ThemesEnum.dark ? 'mobile-dark' : 'mobile-light'; + const size = isImageAd ? { w: '320', h: '50' } : { w: '359', h: '80' }; + const searchParams = new URLSearchParams({ + p: placementSlug, + o: origin, + vw: formatSize(Number(size.w)).toString(), + ...size + }); + + return { uri: `${HYPELAB_AD_FRAME_URL}/?${searchParams.toString()}` }; + }, [isImageAd, theme]); + + useTimeout( + () => { + if (!isString(adHref)) { + onError(); + } + }, + 30000, + [adHref, onError] + ); + + const handleAdFrameError = useCallback(() => { + onError(); + }, [onError]); + + const handleAdFrameMessage = useCallback( + (e: WebViewMessageEvent) => { + try { + const message: AdFrameMessage = JSON.parse(e.nativeEvent.data); + + switch (message.type) { + case AdFrameMessageType.Resize: + setAdFrameAspectRatio(message.width / message.height); + break; + case AdFrameMessageType.Ready: + setAdHref(message.ad.cta_url); + onReady(); + break; + case AdFrameMessageType.Error: + onError(); + break; + case AdFrameMessageType.Click: + if (isString(adHref)) { + trackEvent(testID, AnalyticsEventCategory.ButtonPress, testIDProperties); + openUrl(adHref); + } + } + } catch (err) { + console.error(err); + } + }, + [adHref, onError, onReady, testID, testIDProperties, trackEvent] + ); + + if (isImageAd) { + return ( + + + + + + ); + } + + return ( + + + {shouldShowCloseButton && ( + + + + )} + + ); +}; diff --git a/src/components/new-hypelab-promotion/styles.ts b/src/components/new-hypelab-promotion/styles.ts new file mode 100644 index 000000000..d4ed0bd5a --- /dev/null +++ b/src/components/new-hypelab-promotion/styles.ts @@ -0,0 +1,32 @@ +import { createUseStylesMemoized } from 'src/styles/create-use-styles'; +import { formatSize } from 'src/styles/format-size'; + +export const useNewHypelabPromotionStyles = createUseStylesMemoized(() => ({ + imageAdFrameWrapper: { + width: formatSize(320), + height: formatSize(50), + borderRadius: formatSize(8), + overflow: 'hidden' + }, + imageAdFrame: { + width: '100%', + borderRadius: formatSize(8) + }, + closeButton: { + position: 'absolute', + top: formatSize(6), + right: formatSize(6), + padding: formatSize(6) + }, + textAdFrameContainer: { + width: '100%', + position: 'relative' + }, + textAdFrame: { + width: '100%', + borderRadius: formatSize(10) + }, + invisible: { + opacity: 0 + } +})); diff --git a/src/components/new-optimal-promotion/index.tsx b/src/components/new-optimal-promotion/index.tsx new file mode 100644 index 000000000..f51a26f19 --- /dev/null +++ b/src/components/new-optimal-promotion/index.tsx @@ -0,0 +1,117 @@ +import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; +import FastImage from 'react-native-fast-image'; + +import { PromotionVariantEnum } from 'src/enums/promotion-variant.enum'; +import { + usePartnersPromoErrorSelector, + usePartnersPromoLoadingSelector, + usePartnersPromoSelector +} from 'src/store/partners-promotion/partners-promotion-selectors'; +import { SingleProviderPromotionProps } from 'src/types/promotion'; +import { useTimeout } from 'src/utils/hooks'; +import { isDefined } from 'src/utils/is-defined'; +import { useIsEmptyPromotion } from 'src/utils/optimal.utils'; + +import { ImagePromotionView } from '../image-promotion-view'; +import { TextPromotionView } from '../text-promotion-view'; + +import { useNewOptimalPromotionStyles } from './styles'; + +export const NewOptimalPromotion: FC = ({ + variant, + isVisible, + shouldShowCloseButton, + onClose, + onReady, + onError, + ...testIDProps +}) => { + const isImageAd = variant === PromotionVariantEnum.Image; + const styles = useNewOptimalPromotionStyles(); + const promo = usePartnersPromoSelector(); + const isLoading = usePartnersPromoLoadingSelector(); + const errorFromStore = usePartnersPromoErrorSelector(); + const [isImageBroken, setIsImageBroken] = useState(false); + const [wasLoading, setWasLoading] = useState(false); + const [shouldPreventShowingPrevAd, setShouldPreventShowingPrevAd] = useState(true); + const [adViewIsReady, setAdViewIsReady] = useState(isImageAd); + const prevIsLoadingRef = useRef(isLoading); + const promotionIsEmpty = useIsEmptyPromotion(promo); + const apiQueryFailed = (isDefined(errorFromStore) || promotionIsEmpty) && wasLoading; + const adIsNotLikelyToLoad = (isDefined(errorFromStore) || promotionIsEmpty) && !isLoading; + + useTimeout( + () => { + if (adIsNotLikelyToLoad) { + onError(); + } + }, + 2000, + [adIsNotLikelyToLoad, onError] + ); + + useEffect(() => { + if (wasLoading) { + setShouldPreventShowingPrevAd(false); + } + }, [wasLoading]); + useTimeout(() => setShouldPreventShowingPrevAd(false), 2000, []); + + useEffect(() => { + if (!isLoading && prevIsLoadingRef.current) { + setWasLoading(true); + } + prevIsLoadingRef.current = isLoading; + }, [isLoading]); + + useEffect(() => { + if (apiQueryFailed) { + onError(); + } else if (!promotionIsEmpty && !shouldPreventShowingPrevAd && adViewIsReady) { + onReady(); + } + }, [apiQueryFailed, onError, onReady, promotionIsEmpty, shouldPreventShowingPrevAd, adViewIsReady]); + + const onImageError = useCallback(() => { + setIsImageBroken(true); + onError(); + }, [onError]); + + const handleTextPromotionReady = useCallback(() => setAdViewIsReady(true), []); + + if (isDefined(errorFromStore) || promotionIsEmpty || isImageBroken || shouldPreventShowingPrevAd) { + return null; + } + + const { link: href, image: imageSrc, copy } = promo; + const { headline, content } = copy; + + if (isImageAd) { + return ( + + + + ); + } + + return ( + + ); +}; diff --git a/src/components/new-optimal-promotion/styles.ts b/src/components/new-optimal-promotion/styles.ts new file mode 100644 index 000000000..fb29e2d87 --- /dev/null +++ b/src/components/new-optimal-promotion/styles.ts @@ -0,0 +1,10 @@ +import { createUseStylesMemoized } from 'src/styles/create-use-styles'; +import { formatSize } from 'src/styles/format-size'; + +export const useNewOptimalPromotionStyles = createUseStylesMemoized(() => ({ + bannerImage: { + height: formatSize(112), + width: formatSize(343), + borderRadius: formatSize(10) + } +})); diff --git a/src/components/optimal-promotion-item/optimal-promotion-item.tsx b/src/components/optimal-promotion-item/optimal-promotion-item.tsx deleted file mode 100644 index c96af5e3d..000000000 --- a/src/components/optimal-promotion-item/optimal-promotion-item.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, { FC, useEffect, useRef, useState } from 'react'; -import { StyleProp, ViewStyle } from 'react-native'; - -import { PromotionItem } from 'src/components/promotion-item/promotion-item'; -import { EmptyFn } from 'src/config/general'; -import { useAdTemporaryHiding } from 'src/hooks/use-ad-temporary-hiding.hook'; -import { TestIdProps } from 'src/interfaces/test-id.props'; -import { - useIsPartnersPromoEnabledSelector, - usePartnersPromoLoadingSelector, - usePartnersPromoSelector -} from 'src/store/partners-promotion/partners-promotion-selectors'; -import { useTimeout } from 'src/utils/hooks'; -import { useIsEmptyPromotion } from 'src/utils/optimal.utils'; - -import { TextPromotionItem } from '../text-promotion-item/text-promotion-item'; - -import { OptimalPromotionVariantEnum } from './optimal-promotion-variant.enum'; - -interface Props extends TestIdProps { - id: string; - style?: StyleProp; - shouldShowCloseButton?: boolean; - variant?: OptimalPromotionVariantEnum; - onImageError?: EmptyFn; - onEmptyPromotionReceived?: EmptyFn; -} - -export const OptimalPromotionItem: FC = ({ - testID, - id, - style, - shouldShowCloseButton = true, - variant = OptimalPromotionVariantEnum.Image, - onImageError, - onEmptyPromotionReceived -}) => { - const partnersPromotion = usePartnersPromoSelector(); - const partnersPromotionLoading = usePartnersPromoLoadingSelector(); - const partnersPromotionEnabled = useIsPartnersPromoEnabledSelector(); - const { isHiddenTemporarily, hidePromotion } = useAdTemporaryHiding(id); - const prevIsLoadingRef = useRef(partnersPromotionLoading); - const [shouldPreventShowingPrevAd, setShouldPreventShowingPrevAd] = useState(true); - - const promotionIsEmpty = useIsEmptyPromotion(partnersPromotion); - - useEffect(() => { - if (prevIsLoadingRef.current && !partnersPromotionLoading) { - setShouldPreventShowingPrevAd(false); - } - }, [partnersPromotionLoading]); - useTimeout(() => setShouldPreventShowingPrevAd(false), 2000); - - useEffect(() => { - if (partnersPromotionEnabled && onEmptyPromotionReceived && promotionIsEmpty && !shouldPreventShowingPrevAd) { - onEmptyPromotionReceived(); - } - }, [partnersPromotionEnabled, onEmptyPromotionReceived, promotionIsEmpty, shouldPreventShowingPrevAd]); - - if (!partnersPromotionEnabled || promotionIsEmpty || isHiddenTemporarily) { - return null; - } - - if (variant === OptimalPromotionVariantEnum.Text) { - return ( - - ); - } - - return ( - - ); -}; diff --git a/src/components/optimal-promotion-item/optimal-promotion-variant.enum.ts b/src/components/optimal-promotion-item/optimal-promotion-variant.enum.ts deleted file mode 100644 index bb3ce3716..000000000 --- a/src/components/optimal-promotion-item/optimal-promotion-variant.enum.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum OptimalPromotionVariantEnum { - Image = 'Image', - Text = 'Text' -} diff --git a/src/components/promotion-item/promotion-item.selectors.ts b/src/components/promotion-item/promotion-item.selectors.ts deleted file mode 100644 index b86f18d48..000000000 --- a/src/components/promotion-item/promotion-item.selectors.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum PromotionItemSelectors { - closeButton = 'Promotion Item/Close Button' -} diff --git a/src/components/promotion-item/promotion-item.styles.ts b/src/components/promotion-item/promotion-item.styles.ts deleted file mode 100644 index c513b18bc..000000000 --- a/src/components/promotion-item/promotion-item.styles.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { black } from 'src/config/styles'; -import { createUseStyles } from 'src/styles/create-use-styles'; -import { formatSize, formatTextSize } from 'src/styles/format-size'; -import { generateShadow } from 'src/styles/generate-shadow'; - -export const usePromotionItemStyles = createUseStyles(({ colors, typography }) => ({ - container: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - backgroundColor: colors.cardBG - }, - loaderContainer: { - backgroundColor: colors.cardBG, - justifyContent: 'center', - alignItems: 'center', - height: formatSize(112), - width: formatSize(343), - borderRadius: formatSize(10), - ...generateShadow(1, black) - }, - rewardContainer: { - position: 'relative' - }, - bannerImage: { - height: formatSize(112), - width: formatSize(343), - borderRadius: formatSize(10) - }, - bageContainer: { - position: 'absolute', - top: 0, - left: 0, - zIndex: 2, - overflow: 'hidden', - backgroundColor: colors.blue, - borderTopLeftRadius: formatSize(10), - borderBottomRightRadius: formatSize(10) - }, - text: { - ...typography.caption13Semibold, - color: 'white', - lineHeight: formatTextSize(18), - letterSpacing: formatSize(-0.08), - paddingHorizontal: formatSize(12) - }, - closeButton: { - position: 'absolute', - top: formatSize(8), - right: formatSize(10), - width: formatSize(24), - height: formatSize(24), - borderRadius: formatSize(12), - zIndex: 2, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: colors.white, - ...generateShadow(1, black) - } -})); diff --git a/src/components/promotion-item/promotion-item.tsx b/src/components/promotion-item/promotion-item.tsx deleted file mode 100644 index cac3646f9..000000000 --- a/src/components/promotion-item/promotion-item.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React, { FC, memo } from 'react'; -import { View, StyleProp, ViewStyle, ActivityIndicator } from 'react-native'; -import FastImage, { Source } from 'react-native-fast-image'; -import { SvgUri } from 'react-native-svg'; - -import { Bage } from 'src/components/bage/bage'; -import { Icon } from 'src/components/icon/icon'; -import { IconNameEnum } from 'src/components/icon/icon-name.enum'; -import { TouchableWithAnalytics } from 'src/components/touchable-with-analytics'; -import { TestIdProps } from 'src/interfaces/test-id.props'; -import { formatSize } from 'src/styles/format-size'; -import { useColors } from 'src/styles/use-colors'; -import { openUrl } from 'src/utils/linking'; - -import { PromotionItemSelectors } from './promotion-item.selectors'; -import { usePromotionItemStyles } from './promotion-item.styles'; - -interface Props extends TestIdProps { - source: Source | string; - link: string; - loading?: boolean; - shouldShowAdBage?: boolean; - shouldShowCloseButton?: boolean; - style?: StyleProp; - onCloseButtonClick?: () => void; - onImageError?: () => void; -} - -export const PromotionItem: FC = memo( - ({ - testID, - source, - link, - loading = false, - shouldShowAdBage = false, - shouldShowCloseButton = false, - style, - onCloseButtonClick, - onImageError - }) => { - const colors = useColors(); - const styles = usePromotionItemStyles(); - - return ( - openUrl(link)}> - {loading ? ( - - - - ) : ( - - {shouldShowAdBage && ( - - - - )} - {shouldShowCloseButton && ( - - - - )} - {typeof source === 'string' ? ( - - ) : ( - - )} - - )} - - ); - } -); diff --git a/src/components/text-promotion-item/text-promotion-item.tsx b/src/components/text-promotion-item/text-promotion-item.tsx deleted file mode 100644 index 2cbcdd441..000000000 --- a/src/components/text-promotion-item/text-promotion-item.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React, { FC, memo } from 'react'; -import { ActivityIndicator, StyleProp, Text, View, ViewStyle } from 'react-native'; -import FastImage from 'react-native-fast-image'; - -import { emptyFn } from 'src/config/general'; -import { TestIdProps } from 'src/interfaces/test-id.props'; -import { formatSize } from 'src/styles/format-size'; -import { useColors } from 'src/styles/use-colors'; -import { openUrl } from 'src/utils/linking'; - -import { Icon } from '../icon/icon'; -import { IconNameEnum } from '../icon/icon-name.enum'; -import { TouchableWithAnalytics } from '../touchable-with-analytics'; - -import { TextPromotionItemSelectors } from './text-promotion-item.selectors'; -import { useTextPromotionItemStyles } from './text-promotion-item.styles'; - -interface Props extends TestIdProps { - content: string; - headline: string; - imageUri: string; - link: string; - loading?: boolean; - shouldShowCloseButton: boolean; - style?: StyleProp; - onClose?: () => void; - onImageError?: () => void; -} - -export const TextPromotionItem: FC = memo( - ({ - content, - headline, - imageUri, - link, - loading = false, - shouldShowCloseButton, - style, - testID, - onClose = emptyFn, - onImageError - }) => { - const colors = useColors(); - const styles = useTextPromotionItemStyles(); - - return ( - openUrl(link)}> - {loading ? ( - - - - ) : ( - <> - - - - - - {headline} - - AD - - - {content} - - {shouldShowCloseButton && ( - - - - )} - - )} - - ); - } -); diff --git a/src/components/text-promotion-view/index.tsx b/src/components/text-promotion-view/index.tsx new file mode 100644 index 000000000..f53eb80e9 --- /dev/null +++ b/src/components/text-promotion-view/index.tsx @@ -0,0 +1,107 @@ +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { Text, View } from 'react-native'; +import FastImage from 'react-native-fast-image'; + +import { TestIdProps } from 'src/interfaces/test-id.props'; +import { formatSize } from 'src/styles/format-size'; +import { useColors } from 'src/styles/use-colors'; +import { openUrl } from 'src/utils/linking'; + +import { Icon } from '../icon/icon'; +import { IconNameEnum } from '../icon/icon-name.enum'; +import { TouchableWithAnalytics } from '../touchable-with-analytics'; + +import { TextPromotionItemSelectors } from './selectors'; +import { useTextPromotionViewStyles } from './styles'; + +interface TextPromotionViewProps extends TestIdProps { + href: string; + isVisible: boolean; + imageSrc: string; + headline: string; + contentText?: string; + shouldShowCloseButton: boolean; + onImageError: EmptyFn; + onClose: EmptyFn; + onReady: EmptyFn; +} + +export const TextPromotionView = memo( + ({ + href, + isVisible, + imageSrc, + headline, + contentText, + shouldShowCloseButton, + onImageError, + onClose, + onReady, + ...testIDProps + }) => { + const styles = useTextPromotionViewStyles(); + const colors = useColors(); + const headlineRef = useRef(null); + const headlineTextRef = useRef(null); + const onReadyWasCalled = useRef(false); + const [truncatedContentText, setTruncatedContentText] = useState(''); + + useEffect(() => { + const headlineTextElement = headlineTextRef.current; + + if (headlineTextElement && headlineRef.current) { + headlineTextElement.measureLayout(headlineRef.current, (_, _2, _3, height) => { + const maxContentTextLength = height > formatSize(19) ? 40 : 80; + const textToTruncate = contentText ?? ''; + + setTruncatedContentText( + textToTruncate.length > maxContentTextLength + ? `${textToTruncate.slice(0, maxContentTextLength)}...` + : textToTruncate + ); + }); + } + }, [contentText]); + + useEffect(() => { + if (truncatedContentText && !onReadyWasCalled.current) { + onReady(); + onReadyWasCalled.current = true; + } + }, [truncatedContentText, onReady]); + + const openLink = useCallback(() => openUrl(href), [href]); + + return ( + + + + + + + + {headline} + + + AD + + + {truncatedContentText ? {truncatedContentText} : null} + + {shouldShowCloseButton && ( + + + + )} + + ); + } +); diff --git a/src/components/text-promotion-item/text-promotion-item.selectors.ts b/src/components/text-promotion-view/selectors.ts similarity index 72% rename from src/components/text-promotion-item/text-promotion-item.selectors.ts rename to src/components/text-promotion-view/selectors.ts index 936e0f473..0afe95d58 100644 --- a/src/components/text-promotion-item/text-promotion-item.selectors.ts +++ b/src/components/text-promotion-view/selectors.ts @@ -1,5 +1,5 @@ export enum TextPromotionItemSelectors { closeButton = 'Text Promotion Item/Close Button', cancelButton = 'Text Promotion Item/Cancel Button', - disablelButton = 'Text Promotion Item/Disable Button' + disableButton = 'Text Promotion Item/Disable Button' } diff --git a/src/components/text-promotion-item/text-promotion-item.styles.ts b/src/components/text-promotion-view/styles.ts similarity index 80% rename from src/components/text-promotion-item/text-promotion-item.styles.ts rename to src/components/text-promotion-view/styles.ts index cdf344cfe..ee7b4755e 100644 --- a/src/components/text-promotion-item/text-promotion-item.styles.ts +++ b/src/components/text-promotion-view/styles.ts @@ -1,9 +1,9 @@ import { black } from 'src/config/styles'; -import { createUseStyles } from 'src/styles/create-use-styles'; +import { createUseStylesMemoized } from 'src/styles/create-use-styles'; import { formatSize } from 'src/styles/format-size'; import { generateShadow } from 'src/styles/generate-shadow'; -export const useTextPromotionItemStyles = createUseStyles(({ colors, typography }) => ({ +export const useTextPromotionViewStyles = createUseStylesMemoized(({ colors, typography }) => ({ container: { paddingHorizontal: formatSize(7.5), paddingVertical: formatSize(12), @@ -13,6 +13,9 @@ export const useTextPromotionItemStyles = createUseStyles(({ colors, typography position: 'relative', ...generateShadow(1, black) }, + invisible: { + opacity: 0 + }, imageContainer: { marginRight: formatSize(9.5), borderRadius: formatSize(16.5), @@ -59,13 +62,5 @@ export const useTextPromotionItemStyles = createUseStyles(({ colors, typography top: formatSize(6), right: formatSize(6), padding: formatSize(6) - }, - loaderContainer: { - backgroundColor: colors.cardBG, - width: formatSize(359), - height: formatSize(80), - borderRadius: formatSize(10), - justifyContent: 'center', - alignItems: 'center' } })); diff --git a/src/enums/ad-frame-message-type.enum.ts b/src/enums/ad-frame-message-type.enum.ts new file mode 100644 index 000000000..a2f9d8c5b --- /dev/null +++ b/src/enums/ad-frame-message-type.enum.ts @@ -0,0 +1,6 @@ +export enum AdFrameMessageType { + Resize = 'resize', + Ready = 'ready', + Click = 'click', + Error = 'error' +} diff --git a/src/enums/promotion-variant.enum.ts b/src/enums/promotion-variant.enum.ts new file mode 100644 index 000000000..01a7a4d81 --- /dev/null +++ b/src/enums/promotion-variant.enum.ts @@ -0,0 +1,4 @@ +export enum PromotionVariantEnum { + Text = 'Text', + Image = 'Image' +} diff --git a/src/hooks/use-disable-promotion-after-confirmation.hook.ts b/src/hooks/use-disable-promotion-after-confirmation.hook.ts index 1be74aebd..5f13c223b 100644 --- a/src/hooks/use-disable-promotion-after-confirmation.hook.ts +++ b/src/hooks/use-disable-promotion-after-confirmation.hook.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import { Alert } from 'react-native'; import { useDispatch } from 'react-redux'; -import { TextPromotionItemSelectors } from 'src/components/text-promotion-item/text-promotion-item.selectors'; +import { PromotionItemSelectors } from 'src/components/image-promotion-view/selectors'; import { togglePartnersPromotionAction } from 'src/store/partners-promotion/partners-promotion-actions'; import { AnalyticsEventCategory } from 'src/utils/analytics/analytics-event.enum'; import { useAnalytics } from 'src/utils/analytics/use-analytics.hook'; @@ -45,20 +45,20 @@ export const usePromotionAfterConfirmation = () => { { text: 'Cancel', style: 'cancel', - onPress: () => trackEvent(TextPromotionItemSelectors.cancelButton, AnalyticsEventCategory.ButtonPress) + onPress: () => trackEvent(PromotionItemSelectors.cancelButton, AnalyticsEventCategory.ButtonPress) }, { text: 'Disable', style: 'destructive', onPress: () => { - trackEvent(TextPromotionItemSelectors.disablelButton, AnalyticsEventCategory.ButtonPress); + trackEvent(PromotionItemSelectors.disableButton, AnalyticsEventCategory.ButtonPress); dispatch(togglePartnersPromotionAction(false)); dispatch(setAdsBannerVisibilityAction(false)); } } ] ), - [dispatch] + [dispatch, trackEvent] ); return { enablePromotion, disablePromotion }; diff --git a/src/screens/d-apps/promotion-carousel/promotion-carousel-item/promotion-carousel-item.styles.ts b/src/screens/d-apps/promotion-carousel/promotion-carousel-item/promotion-carousel-item.styles.ts index 54fb67107..f171c0598 100644 --- a/src/screens/d-apps/promotion-carousel/promotion-carousel-item/promotion-carousel-item.styles.ts +++ b/src/screens/d-apps/promotion-carousel/promotion-carousel-item/promotion-carousel-item.styles.ts @@ -1,7 +1,13 @@ -import { createUseStyles } from '../../../../styles/create-use-styles'; +import { createUseStylesMemoized } from 'src/styles/create-use-styles'; +import { formatSize } from 'src/styles/format-size'; -export const usePromotionCarouselItemStyles = createUseStyles(({ colors }) => ({ +export const usePromotionCarouselItemStyles = createUseStylesMemoized(({ colors }) => ({ container: { backgroundColor: colors.pageBG + }, + bannerImage: { + height: formatSize(112), + width: formatSize(343), + borderRadius: formatSize(10) } })); diff --git a/src/screens/d-apps/promotion-carousel/promotion-carousel-item/promotion-carousel-item.tsx b/src/screens/d-apps/promotion-carousel/promotion-carousel-item/promotion-carousel-item.tsx index 2f0f0409a..cd03bb808 100644 --- a/src/screens/d-apps/promotion-carousel/promotion-carousel-item/promotion-carousel-item.tsx +++ b/src/screens/d-apps/promotion-carousel/promotion-carousel-item/promotion-carousel-item.tsx @@ -1,7 +1,10 @@ +import { noop } from 'lodash-es'; import React, { FC, memo } from 'react'; -import { Source } from 'react-native-fast-image'; +import FastImage, { Source } from 'react-native-fast-image'; +import { SvgUri } from 'react-native-svg'; -import { PromotionItem } from 'src/components/promotion-item/promotion-item'; +import { ImagePromotionView } from 'src/components/image-promotion-view'; +import { formatSize } from 'src/styles/format-size'; import { usePromotionCarouselItemStyles } from './promotion-carousel-item.styles'; @@ -12,8 +15,23 @@ interface Props { shouldShowAdBage?: boolean; } -export const PromotionCarouselItem: FC = memo(props => { +export const PromotionCarouselItem: FC = memo(({ source, link, testID, shouldShowAdBage = false }) => { const styles = usePromotionCarouselItemStyles(); - return ; + return ( + + {typeof source === 'string' ? ( + + ) : ( + + )} + + ); }); diff --git a/src/screens/d-apps/promotion-carousel/promotion-carousel.styles.ts b/src/screens/d-apps/promotion-carousel/promotion-carousel.styles.ts index 19b51f78d..6e5581dc3 100644 --- a/src/screens/d-apps/promotion-carousel/promotion-carousel.styles.ts +++ b/src/screens/d-apps/promotion-carousel/promotion-carousel.styles.ts @@ -7,6 +7,9 @@ export const usePromotionCarouselStyles = createUseStyles(({ colors }) => ({ }, promotionItem: { backgroundColor: colors.pageBG, - width: '100%' + marginLeft: formatSize(16), + width: formatSize(343), + shadowOpacity: 0, + elevation: 0 } })); diff --git a/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx b/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx index bf3179a39..adaf93df9 100644 --- a/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx +++ b/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx @@ -4,7 +4,7 @@ import Carousel from 'react-native-reanimated-carousel'; import type { ILayoutConfig } from 'react-native-reanimated-carousel/lib/typescript/layouts/parallax'; import type { CarouselRenderItemInfo } from 'react-native-reanimated-carousel/lib/typescript/types'; -import { OptimalPromotionItem } from 'src/components/optimal-promotion-item/optimal-promotion-item'; +import { GenericPromotionItem } from 'src/components/generic-promotion-item'; import { useLayoutSizes } from 'src/hooks/use-layout-sizes.hook'; import { useIsPartnersPromoShown, usePartnersPromoLoad } from 'src/hooks/use-partners-promo'; import { useActivePromotionSelector } from 'src/store/advertising/advertising-selectors'; @@ -42,13 +42,13 @@ export const PromotionCarousel = () => { if (partnersPromoShown && !promotionErrorOccurred) { result.unshift( - setPromotionErrorOccurred(true)} - onEmptyPromotionReceived={() => setPromotionErrorOccurred(true)} + shouldTryHypelabAd={false} + onError={() => setPromotionErrorOccurred(true)} /> ); } diff --git a/src/screens/market/top-coins-table/top-tokens-table.styles.ts b/src/screens/market/top-coins-table/top-tokens-table.styles.ts index 9f9f03d4d..9b61a0d53 100644 --- a/src/screens/market/top-coins-table/top-tokens-table.styles.ts +++ b/src/screens/market/top-coins-table/top-tokens-table.styles.ts @@ -29,9 +29,10 @@ export const useTopTokensTableStyles = createUseStyles(({ colors, typography }) name: { textAlign: 'center' }, - promotion: { + promotionWrapper: { backgroundColor: colors.pageBG, paddingVertical: formatSize(12), + paddingHorizontal: formatSize(16), borderBottomWidth: formatSize(1), borderColor: colors.lines } diff --git a/src/screens/market/top-coins-table/top-tokens-table.tsx b/src/screens/market/top-coins-table/top-tokens-table.tsx index 00d46859e..2d77874d4 100644 --- a/src/screens/market/top-coins-table/top-tokens-table.tsx +++ b/src/screens/market/top-coins-table/top-tokens-table.tsx @@ -3,7 +3,7 @@ import { ListRenderItem, RefreshControl, Text, View } from 'react-native'; import { SwipeListView } from 'react-native-swipe-list-view'; import { DataPlaceholder } from 'src/components/data-placeholder/data-placeholder'; -import { OptimalPromotionItem } from 'src/components/optimal-promotion-item/optimal-promotion-item'; +import { GenericPromotionItem } from 'src/components/generic-promotion-item'; import { useFakeRefreshControlProps } from 'src/hooks/use-fake-refresh-control-props.hook'; import { useFilteredMarketTokens } from 'src/hooks/use-filtered-market-tokens.hook'; import { MarketToken } from 'src/store/market/market.interfaces'; @@ -56,13 +56,13 @@ export const TopTokensTable = () => { return ( {!promotionErrorOccurred && ( - setPromotionErrorOccurred(true)} - onEmptyPromotionReceived={() => setPromotionErrorOccurred(true)} - /> + + setPromotionErrorOccurred(true)} + /> + )} { <> {partnersPromoShown && !promotionErrorOccurred && ( <> - diff --git a/src/screens/wallet/token-list/token-list.tsx b/src/screens/wallet/token-list/token-list.tsx index 7fa18348e..03b2e9c55 100644 --- a/src/screens/wallet/token-list/token-list.tsx +++ b/src/screens/wallet/token-list/token-list.tsx @@ -7,15 +7,15 @@ import { AcceptAdsBanner } from 'src/components/accept-ads-banner/accept-ads-ban import { Checkbox } from 'src/components/checkbox/checkbox'; import { DataPlaceholder } from 'src/components/data-placeholder/data-placeholder'; import { Divider } from 'src/components/divider/divider'; +import { GenericPromotionItem } from 'src/components/generic-promotion-item'; import { HorizontalBorder } from 'src/components/horizontal-border'; import { IconNameEnum } from 'src/components/icon/icon-name.enum'; import { TouchableIcon } from 'src/components/icon/touchable-icon/touchable-icon'; import { InAppUpdateBanner } from 'src/components/in-app-update-banner/in-app-update-banner'; -import { OptimalPromotionItem } from 'src/components/optimal-promotion-item/optimal-promotion-item'; -import { OptimalPromotionVariantEnum } from 'src/components/optimal-promotion-item/optimal-promotion-variant.enum'; import { RefreshControl } from 'src/components/refresh-control/refresh-control'; import { Search } from 'src/components/search/search'; import { isAndroid } from 'src/config/system'; +import { PromotionVariantEnum } from 'src/enums/promotion-variant.enum'; import { useFakeRefreshControlProps } from 'src/hooks/use-fake-refresh-control-props.hook'; import { useFilteredAssetsList } from 'src/hooks/use-filtered-assets-list.hook'; import { useNetworkInfo } from 'src/hooks/use-network-info.hook'; @@ -159,13 +159,12 @@ export const TokensList = memo(() => { return ( - setPromotionErrorOccurred(true)} - onImageError={() => setPromotionErrorOccurred(true)} + onError={() => setPromotionErrorOccurred(true)} /> diff --git a/src/store/partners-promotion/partners-promotion-selectors.ts b/src/store/partners-promotion/partners-promotion-selectors.ts index d95e07e41..cc4ab956d 100644 --- a/src/store/partners-promotion/partners-promotion-selectors.ts +++ b/src/store/partners-promotion/partners-promotion-selectors.ts @@ -2,6 +2,7 @@ import { useSelector } from '../selector'; export const usePartnersPromoSelector = () => useSelector(state => state.partnersPromotion.promotion.data); export const usePartnersPromoLoadingSelector = () => useSelector(state => state.partnersPromotion.promotion.isLoading); +export const usePartnersPromoErrorSelector = () => useSelector(state => state.partnersPromotion.promotion.error); export const useIsPartnersPromoEnabledSelector = () => useSelector(state => state.partnersPromotion.isEnabled); export const usePromotionHidingTimestampSelector = (id: string) => useSelector(({ partnersPromotion }) => partnersPromotion.promotionHidingTimestamps[id] ?? 0); diff --git a/src/types/promotion.ts b/src/types/promotion.ts new file mode 100644 index 000000000..b475e2950 --- /dev/null +++ b/src/types/promotion.ts @@ -0,0 +1,33 @@ +import { AdFrameMessageType } from 'src/enums/ad-frame-message-type.enum'; +import { PromotionVariantEnum } from 'src/enums/promotion-variant.enum'; +import { TestIdProps } from 'src/interfaces/test-id.props'; + +export interface SingleProviderPromotionProps extends TestIdProps { + variant: PromotionVariantEnum; + isVisible: boolean; + shouldShowCloseButton: boolean; + onClose: EmptyFn; + onReady: EmptyFn; + onError: EmptyFn; +} + +interface AdFrameMessageBase { + type: AdFrameMessageType; +} + +interface ResizeAdMessage extends AdFrameMessageBase { + type: AdFrameMessageType.Resize; + width: number; + height: number; +} + +interface ReadyAdMessage extends AdFrameMessageBase { + type: AdFrameMessageType.Ready; + ad: { cta_url: string }; +} + +interface OtherAdMessage extends AdFrameMessageBase { + type: Exclude; +} + +export type AdFrameMessage = ResizeAdMessage | ReadyAdMessage | OtherAdMessage; diff --git a/src/utils/env.utils.ts b/src/utils/env.utils.ts index 50a51bf76..5b7281e2f 100644 --- a/src/utils/env.utils.ts +++ b/src/utils/env.utils.ts @@ -30,3 +30,7 @@ export const TEMPLE_WALLET_ROUTE3_AUTH_TOKEN = getEnv('TEMPLE_WALLET_ROUTE3_AUTH export const DYNAMIC_LINKS_DOMAIN_URI_PREFIX = getEnv('DYNAMIC_LINKS_DOMAIN_URI_PREFIX'); export const APK_BUILD_ID = getEnv('APK_BUILD_ID'); + +export const HYPELAB_AD_FRAME_URL = getEnv('HYPELAB_AD_FRAME_URL'); +export const HYPELAB_SMALL_PLACEMENT_SLUG = getEnv('HYPELAB_SMALL_PLACEMENT_SLUG'); +export const HYPELAB_NATIVE_PLACEMENT_SLUG = getEnv('HYPELAB_NATIVE_PLACEMENT_SLUG'); From 937787571db16b3715fe486561feb7c7e16c1a1e Mon Sep 17 00:00:00 2001 From: Inokentii Mazhara Date: Mon, 26 Feb 2024 16:37:35 +0200 Subject: [PATCH 02/11] TW-1307 Add analytics for new ads --- .../activity-groups-list.tsx | 43 ++++- .../generic-promotion-item/index.tsx | 68 +++++--- src/enums/promotion-provider.enum.ts | 4 + src/hooks/use-element-is-seen.hook.ts | 35 +++++ src/hooks/use-internal-ads-analytics.hook.ts | 113 ++++++++++++++ .../use-intersection-observation.hook.ts | 147 ++++++++++++++++++ src/screens/activity/activity.tsx | 1 + .../promotion-carousel/promotion-carousel.tsx | 42 ++++- .../top-coins-table/top-tokens-table.tsx | 5 + src/screens/notifications/notifications.tsx | 4 + .../tezos-token-history.tsx | 7 +- src/screens/token-screen/token-screen.tsx | 1 + src/screens/wallet/token-list/token-list.tsx | 23 ++- src/utils/get-intersection-ratio.ts | 44 ++++++ 14 files changed, 497 insertions(+), 40 deletions(-) create mode 100644 src/enums/promotion-provider.enum.ts create mode 100644 src/hooks/use-element-is-seen.hook.ts create mode 100644 src/hooks/use-internal-ads-analytics.hook.ts create mode 100644 src/hooks/use-intersection-observation.hook.ts create mode 100644 src/utils/get-intersection-ratio.ts diff --git a/src/components/activity-groups-list/activity-groups-list.tsx b/src/components/activity-groups-list/activity-groups-list.tsx index b38ef8caf..39c112e3d 100644 --- a/src/components/activity-groups-list/activity-groups-list.tsx +++ b/src/components/activity-groups-list/activity-groups-list.tsx @@ -1,6 +1,6 @@ import { FlashList, ListRenderItem } from '@shopify/flash-list'; import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; -import { ActivityIndicator, Text, View } from 'react-native'; +import { ActivityIndicator, LayoutChangeEvent, Text, View } from 'react-native'; import { DataPlaceholder } from 'src/components/data-placeholder/data-placeholder'; import { GenericPromotionItem } from 'src/components/generic-promotion-item'; @@ -8,6 +8,7 @@ import { RefreshControl } from 'src/components/refresh-control/refresh-control'; import { emptyFn } from 'src/config/general'; import { useAdTemporaryHiding } from 'src/hooks/use-ad-temporary-hiding.hook'; import { useFakeRefreshControlProps } from 'src/hooks/use-fake-refresh-control-props.hook'; +import { useInternalAdsAnalytics } from 'src/hooks/use-internal-ads-analytics.hook'; import { usePartnersPromoLoad } from 'src/hooks/use-partners-promo'; import { ActivityGroup, emptyActivity } from 'src/interfaces/activity.interface'; import { useIsPartnersPromoEnabledSelector } from 'src/store/partners-promotion/partners-promotion-selectors'; @@ -32,13 +33,15 @@ interface Props { isAllLoaded?: boolean; isLoading?: boolean; handleUpdate?: () => void; + pageName: string; } export const ActivityGroupsList: FC = ({ activityGroups, isAllLoaded = false, isLoading = false, - handleUpdate = emptyFn + handleUpdate = emptyFn, + pageName }) => { usePartnersPromoLoad(PROMOTION_ID); @@ -52,6 +55,15 @@ export const ActivityGroupsList: FC = ({ const [promotionErrorOccurred, setPromotionErrorOccurred] = useState(false); const shouldShowPromotion = partnersPromotionEnabled && !promotionErrorOccurred && !isHiddenTemporarily; + const { + onListScroll, + onInsideScrollAdLayout, + onListLayoutChange, + onOutsideOfScrollAdLayout, + onAdLoad, + resetAdState + } = useInternalAdsAnalytics(pageName); + const keyExtractor = useCallback( (item: ListItem, index: number) => { const keyRoot = typeof item === 'string' ? item : item[0].hash; @@ -77,7 +89,7 @@ export const ActivityGroupsList: FC = ({ }, [handleUpdate]); useEffect(() => setEndIsReached(false), [activityGroups]); - const onOptimalPromotionError = useCallback(() => setPromotionErrorOccurred(true), []); + const handlePromotionError = useCallback(() => setPromotionErrorOccurred(true), []); const sections = useMemo(() => { const result: ListItem[] = []; @@ -103,19 +115,34 @@ export const ActivityGroupsList: FC = ({ return result; }, [activityGroups]); + const shouldRenderList = sections.length > 0; + + useEffect(() => resetAdState(), [resetAdState, shouldRenderList]); + + const handlePromotionLayout = useCallback( + (e: LayoutChangeEvent) => { + if (shouldRenderList) { + onInsideScrollAdLayout(e); + } else { + onOutsideOfScrollAdLayout(e); + } + }, + [onInsideScrollAdLayout, onOutsideOfScrollAdLayout, shouldRenderList] + ); const Promotion = useMemo( () => ( - + ), - [onOptimalPromotionError, styles] + [styles, handlePromotionLayout, handlePromotionError, onAdLoad] ); const renderItem: ListRenderItem = useCallback( @@ -142,7 +169,7 @@ export const ActivityGroupsList: FC = ({ [shouldRenderAdditionalLoader, styles.additionalLoader] ); - if (sections.length > 0) { + if (shouldRenderList) { return ( = ({ stickyHeaderIndices={stickyHeaderIndices} onEndReachedThreshold={0.01} onEndReached={handleEndReached} + onLayout={onListLayoutChange} + onScroll={onListScroll} keyExtractor={keyExtractor} renderItem={renderItem} estimatedItemSize={AVERAGE_ITEM_HEIGHT} diff --git a/src/components/generic-promotion-item/index.tsx b/src/components/generic-promotion-item/index.tsx index 66289326a..717bc31e9 100644 --- a/src/components/generic-promotion-item/index.tsx +++ b/src/components/generic-promotion-item/index.tsx @@ -1,8 +1,9 @@ import { useIsFocused } from '@react-navigation/native'; -import React, { memo, useCallback, useEffect, useState } from 'react'; -import { StyleProp, View, ViewStyle } from 'react-native'; +import React, { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; +import { StyleProp, View, ViewProps, ViewStyle } from 'react-native'; import { isAndroid } from 'src/config/system'; +import { PromotionProviderEnum } from 'src/enums/promotion-provider.enum'; import { useAdTemporaryHiding } from 'src/hooks/use-ad-temporary-hiding.hook'; import { TestIdProps } from 'src/interfaces/test-id.props'; import { useIsPartnersPromoEnabledSelector } from 'src/store/partners-promotion/partners-promotion-selectors'; @@ -21,18 +22,25 @@ interface Props extends TestIdProps { shouldTryHypelabAd?: boolean; variant?: PromotionVariantEnum; onError?: EmptyFn; + onLoad?: SyncFn; + onLayout?: ViewProps['onLayout']; } -export const GenericPromotionItem = memo( - ({ - id, - style, - shouldShowCloseButton = true, - variant = PromotionVariantEnum.Image, - shouldTryHypelabAd = true, - onError, - ...testIDProps - }) => { +export const GenericPromotionItem = forwardRef( + ( + { + id, + style, + shouldShowCloseButton = true, + variant = PromotionVariantEnum.Image, + shouldTryHypelabAd = true, + onError, + onLoad, + onLayout, + ...testIDProps + }, + ref + ) => { const isImageAd = variant === PromotionVariantEnum.Image; const styles = useGenericPromotionItemStyles(); const partnersPromotionEnabled = useIsPartnersPromoEnabledSelector(); @@ -40,16 +48,16 @@ export const GenericPromotionItem = memo( const isFocused = useIsFocused(); const [adsState, setAdsState] = useState({ - shouldUseOptimalAd: true, + currentProvider: PromotionProviderEnum.Optimal, adError: false, adIsReady: false }); - const { adError, shouldUseOptimalAd, adIsReady } = adsState; + const { adError, currentProvider, adIsReady } = adsState; useEffect(() => { if (!isFocused) { setAdsState({ - shouldUseOptimalAd: true, + currentProvider: PromotionProviderEnum.Optimal, adError: false, adIsReady: false }); @@ -65,15 +73,27 @@ export const GenericPromotionItem = memo( if (!shouldTryHypelabAd) { handleAdError(); } - setAdsState(prevState => ({ ...prevState, shouldUseOptimalAd: false })); + setAdsState(prevState => ({ ...prevState, currentProvider: PromotionProviderEnum.HypeLab })); }, [handleAdError, shouldTryHypelabAd]); const handleHypelabError = useCallback(() => { handleAdError(); }, [handleAdError]); - const handleAdReady = useCallback(() => { - setAdsState(prevState => ({ ...prevState, adIsReady: true })); - }, []); + const handleAdReadyFactory = useCallback( + (provider: PromotionProviderEnum) => () => { + setAdsState(prevState => ({ ...prevState, adIsReady: true, currentProvider: provider })); + onLoad && onLoad(provider); + }, + [onLoad] + ); + const handleOptimalAdReady = useMemo( + () => handleAdReadyFactory(PromotionProviderEnum.Optimal), + [handleAdReadyFactory] + ); + const handleHypelabAdReady = useMemo( + () => handleAdReadyFactory(PromotionProviderEnum.HypeLab), + [handleAdReadyFactory] + ); if (!partnersPromotionEnabled || adError || isHiddenTemporarily) { return null; @@ -87,26 +107,28 @@ export const GenericPromotionItem = memo( !adIsReady && (isImageAd ? styles.imgAdLoadingContainer : styles.textAdLoadingContainer), style ]} + ref={ref} + onLayout={onLayout} > - {shouldUseOptimalAd && isFocused && ( + {currentProvider === PromotionProviderEnum.Optimal && isFocused && ( )} - {!shouldUseOptimalAd && shouldTryHypelabAd && isFocused && ( + {currentProvider === PromotionProviderEnum.HypeLab && shouldTryHypelabAd && isFocused && ( )} diff --git a/src/enums/promotion-provider.enum.ts b/src/enums/promotion-provider.enum.ts new file mode 100644 index 000000000..07a5c0c42 --- /dev/null +++ b/src/enums/promotion-provider.enum.ts @@ -0,0 +1,4 @@ +export enum PromotionProviderEnum { + Optimal = 'Optimal', + HypeLab = 'HypeLab' +} diff --git a/src/hooks/use-element-is-seen.hook.ts b/src/hooks/use-element-is-seen.hook.ts new file mode 100644 index 000000000..5109811e5 --- /dev/null +++ b/src/hooks/use-element-is-seen.hook.ts @@ -0,0 +1,35 @@ +import { useIsFocused } from '@react-navigation/native'; +import { useEffect, useRef, useState } from 'react'; + +/** + * @param isVisible Indicates whether the element is visible right now, assuming that the screen is focused. + * @param seenTimeout If the element becomes visible and stays visible for this amount of time, it is considered seen. + * @param shouldResetOnScreenBlur If true, the seen state will be reset when the element becomes invisible. + */ +export const useElementIsSeen = (isVisible: boolean, seenTimeout: number, shouldResetOnScreenBlur = true) => { + const isFocused = useIsFocused(); + const [isSeen, setIsSeen] = useState(false); + const isVisibleRef = useRef(isVisible); + + useEffect(() => { + if (shouldResetOnScreenBlur && !isFocused) { + setIsSeen(false); + } + }, [isFocused, shouldResetOnScreenBlur]); + + useEffect(() => { + isVisibleRef.current = isVisible && isFocused; + + if (isVisible && isFocused) { + const timeout = setTimeout(() => { + if (isVisibleRef.current) { + setIsSeen(true); + } + }, seenTimeout); + + return () => clearTimeout(timeout); + } + }, [isFocused, isVisible, seenTimeout]); + + return isSeen; +}; diff --git a/src/hooks/use-internal-ads-analytics.hook.ts b/src/hooks/use-internal-ads-analytics.hook.ts new file mode 100644 index 000000000..ffb473e02 --- /dev/null +++ b/src/hooks/use-internal-ads-analytics.hook.ts @@ -0,0 +1,113 @@ +import { throttle } from 'lodash-es'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { LayoutChangeEvent } from 'react-native'; + +import { PromotionProviderEnum } from 'src/enums/promotion-provider.enum'; +import { AnalyticsEventCategory } from 'src/utils/analytics/analytics-event.enum'; +import { useAnalytics } from 'src/utils/analytics/use-analytics.hook'; +import { getIntersectionRatio } from 'src/utils/get-intersection-ratio'; +import { isDefined } from 'src/utils/is-defined'; + +import { useElementIsSeen } from './use-element-is-seen.hook'; +import { DEFAULT_INTERSECTION_THRESHOLD, Refs, useIntersectionObservation } from './use-intersection-observation.hook'; + +const DEFAULT_OUTSIDE_OF_SCROLL_AD_OFFSET = { x: 0, y: 0 }; + +/** + * This hook sends `Internal Ads Activity` once after the ad is seen + * @param page Page name + * @param refs If specified, the measurement of the ad element will be relative to `parent`; otherwise, the data from + * events will be used + * @param outsideOfScrollAdOffset Specify this value if the ad is not in a scroll view but has offset which is not + * detected by measuring layout + * @returns Callbacks for the ad element and the list which contains it if applicable + */ +export const useInternalAdsAnalytics = ( + page: string, + refs?: Refs, + outsideOfScrollAdOffset = DEFAULT_OUTSIDE_OF_SCROLL_AD_OFFSET, + seenTimeout = 200 +) => { + const { trackEvent } = useAnalytics(); + const [adAreaIsVisible, setAdAreaIsVisible] = useState(false); + const [loadedPromotionProvider, setLoadedPromotionProvider] = useState(); + const adIsSeen = useElementIsSeen(adAreaIsVisible && isDefined(loadedPromotionProvider), seenTimeout); + const prevAdIsSeenRef = useRef(adIsSeen); + + useEffect(() => { + if (adIsSeen && !prevAdIsSeenRef.current) { + trackEvent('Internal Ads Activity', AnalyticsEventCategory.General, { + page, + provider: loadedPromotionProvider + }); + } + prevAdIsSeenRef.current = adIsSeen; + }, [adIsSeen, trackEvent, loadedPromotionProvider, page]); + + const refreshOutsideOfScrollAdVisible = useCallback(() => { + const { x: offsetX, y: offsetY } = outsideOfScrollAdOffset; + const element = refs?.element.current; + const parent = refs?.parent.current; + + if (!element || !parent) { + return; + } + + element.measureLayout( + parent, + (x, y, width, height) => { + parent.measure((_, _2, parentWidth, parentHeight) => { + setAdAreaIsVisible( + getIntersectionRatio( + { width: parentWidth, height: parentHeight }, + { x: x + offsetX, y: y + offsetY, width, height } + ) >= DEFAULT_INTERSECTION_THRESHOLD + ); + }); + }, + () => { + console.error('Failed to measure layout of the ad element relatively to the parent'); + } + ); + }, [refs, outsideOfScrollAdOffset]); + + const handleOutsideOfScrollAdOffset = useMemo( + () => throttle(() => refreshOutsideOfScrollAdVisible(), 100, { leading: false, trailing: true }), + [refreshOutsideOfScrollAdVisible] + ); + useEffect(handleOutsideOfScrollAdOffset, [handleOutsideOfScrollAdOffset]); + + const onOutsideOfScrollAdLayout = useCallback( + (e: LayoutChangeEvent) => { + e.persist(); + const element = refs?.element.current; + const parent = refs?.parent.current; + if (element && parent) { + refreshOutsideOfScrollAdVisible(); + } else if (!refs) { + setAdAreaIsVisible(true); + } + }, + [refreshOutsideOfScrollAdVisible, refs] + ); + + const resetAdState = useCallback(() => { + setAdAreaIsVisible(false); + setLoadedPromotionProvider(undefined); + }, []); + + const { + onListScroll, + onElementLayoutChange: onInsideScrollAdLayout, + onListLayoutChange + } = useIntersectionObservation(setAdAreaIsVisible, refs); + + return { + onListScroll, + onInsideScrollAdLayout, + onListLayoutChange, + onOutsideOfScrollAdLayout, + onAdLoad: setLoadedPromotionProvider, + resetAdState + }; +}; diff --git a/src/hooks/use-intersection-observation.hook.ts b/src/hooks/use-intersection-observation.hook.ts new file mode 100644 index 000000000..01b3e2616 --- /dev/null +++ b/src/hooks/use-intersection-observation.hook.ts @@ -0,0 +1,147 @@ +import { throttle } from 'lodash-es'; +import { MutableRefObject, useCallback, useMemo, useRef } from 'react'; +import { LayoutChangeEvent, LayoutRectangle, NativeScrollEvent, NativeSyntheticEvent, View } from 'react-native'; + +import { getIntersectionRatio } from 'src/utils/get-intersection-ratio'; +import { isDefined } from 'src/utils/is-defined'; + +export interface Refs { + /** The parent of the element or the list which contains the element */ + parent: MutableRefObject; + element: MutableRefObject; +} + +export const DEFAULT_INTERSECTION_THRESHOLD = 0.5; + +/** + * A hook for observing the intersection of a component inside a list. `react-native-intersection-observer` exists + * for similar purposes but it is incompatible with our components. + * @param onIntersectChange A callback to be called when the intersection state changes + * @param refs If specified, the measurement of the element will be relative to `parent`; otherwise, the data from + * events will be used + * @param threshold The part of the child element area that should be visible for the intersection to be considered + * @returns Callbacks for the list and the element + */ +export const useIntersectionObservation = ( + onIntersectChange: (value: boolean) => void, + refs?: Refs, + threshold = DEFAULT_INTERSECTION_THRESHOLD +) => { + const lastLayoutRectangleRef = useRef(); + const lastNativeScrollConfigRef = useRef>(); + const lastIsIntersectedRef = useRef(); + + const refreshIsIntersected = useCallback(() => { + const layoutRectangleWithoutScroll = lastLayoutRectangleRef.current; + const nativeScrollConfig = lastNativeScrollConfigRef.current; + + if (!isDefined(layoutRectangleWithoutScroll) || !isDefined(nativeScrollConfig)) { + return; + } + + const listLayoutMeasurement = nativeScrollConfig.layoutMeasurement; + // TODO: add logic for contentInset + const layoutRectangle = { + x: layoutRectangleWithoutScroll.x - nativeScrollConfig.contentOffset.x, + y: layoutRectangleWithoutScroll.y - nativeScrollConfig.contentOffset.y, + width: layoutRectangleWithoutScroll.width, + height: layoutRectangleWithoutScroll.height + }; + const isIntersected = getIntersectionRatio(listLayoutMeasurement, layoutRectangle) >= threshold; + if (lastIsIntersectedRef.current !== isIntersected) { + lastIsIntersectedRef.current = isIntersected; + onIntersectChange(isIntersected); + } + }, [onIntersectChange, threshold]); + const refreshIsIntersectedWithMeasurements = useCallback(() => { + if (!refs) { + return; + } + + const parent = refs.parent.current; + const element = refs.element.current; + + if (!parent || !element) { + return; + } + + element.measureLayout( + parent, + (x, y, width, height) => { + lastLayoutRectangleRef.current = { x, y, width, height }; + + refreshIsIntersected(); + }, + () => { + console.error('Failed to measure element layout relatively to the parent'); + } + ); + }, [refreshIsIntersected, refs]); + + const handleNativeScrollEvent = useMemo( + () => + throttle( + (e: NativeScrollEvent | null) => { + if (!isDefined(e)) { + return; + } + + lastNativeScrollConfigRef.current = e; + if (refs) { + refreshIsIntersectedWithMeasurements(); + } else { + refreshIsIntersected(); + } + }, + 100, + { leading: false, trailing: true } + ), + [refreshIsIntersected, refreshIsIntersectedWithMeasurements, refs] + ); + + const onListScroll = useCallback( + (e: NativeSyntheticEvent) => { + e.persist(); + handleNativeScrollEvent(e.nativeEvent); + }, + [handleNativeScrollEvent] + ); + + const onElementLayoutChange = useCallback( + (e: LayoutChangeEvent) => { + e.persist(); + if (refs) { + refreshIsIntersectedWithMeasurements(); + } else { + lastLayoutRectangleRef.current = e.nativeEvent.layout; + refreshIsIntersected(); + } + }, + [refreshIsIntersected, refreshIsIntersectedWithMeasurements, refs] + ); + + const onListLayoutChange = useCallback( + (e: LayoutChangeEvent) => { + e.persist(); + + if (isDefined(lastNativeScrollConfigRef.current)) { + lastNativeScrollConfigRef.current.layoutMeasurement = e.nativeEvent.layout; + } else { + lastNativeScrollConfigRef.current = { + layoutMeasurement: e.nativeEvent.layout, + contentOffset: { x: 0, y: 0 }, + contentInset: { top: 0, left: 0, bottom: 0, right: 0 } + }; + } + + if (refs) { + refreshIsIntersectedWithMeasurements(); + } else { + refreshIsIntersected(); + } + }, + [refreshIsIntersected, refreshIsIntersectedWithMeasurements, refs] + ); + + return { onListScroll, onElementLayoutChange, onListLayoutChange }; +}; diff --git a/src/screens/activity/activity.tsx b/src/screens/activity/activity.tsx index 337ba746a..3aa348882 100644 --- a/src/screens/activity/activity.tsx +++ b/src/screens/activity/activity.tsx @@ -16,6 +16,7 @@ export const Activity = () => { activityGroups={activities} isAllLoaded={isAllLoaded} isLoading={isLoading} + pageName="Activity" /> ); }; diff --git a/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx b/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx index adaf93df9..a0da39a54 100644 --- a/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx +++ b/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx @@ -1,10 +1,12 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import { throttle } from 'lodash-es'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import { View } from 'react-native'; import Carousel from 'react-native-reanimated-carousel'; import type { ILayoutConfig } from 'react-native-reanimated-carousel/lib/typescript/layouts/parallax'; import type { CarouselRenderItemInfo } from 'react-native-reanimated-carousel/lib/typescript/types'; import { GenericPromotionItem } from 'src/components/generic-promotion-item'; +import { useInternalAdsAnalytics } from 'src/hooks/use-internal-ads-analytics.hook'; import { useLayoutSizes } from 'src/hooks/use-layout-sizes.hook'; import { useIsPartnersPromoShown, usePartnersPromoLoad } from 'src/hooks/use-partners-promo'; import { useActivePromotionSelector } from 'src/store/advertising/advertising-selectors'; @@ -21,8 +23,13 @@ const PROMOTION_ID = 'carousel-promotion'; export const PromotionCarousel = () => { const activePromotion = useActivePromotionSelector(); const styles = usePromotionCarouselStyles(); + const [promotionOffset, setPromotionOffset] = useState({ x: 0, y: 0 }); const [promotionErrorOccurred, setPromotionErrorOccurred] = useState(false); const partnersPromoShown = useIsPartnersPromoShown(PROMOTION_ID); + const layoutRef = useRef(null); + const adRef = useRef(null); + const refs = useMemo(() => ({ parent: layoutRef, element: adRef }), [layoutRef, adRef]); + const { onOutsideOfScrollAdLayout, onAdLoad } = useInternalAdsAnalytics('DApps', refs, promotionOffset, 500); usePartnersPromoLoad(PROMOTION_ID); @@ -47,14 +54,17 @@ export const PromotionCarousel = () => { testID={PromotionCarouselSelectors.optimalPromotionBanner} shouldShowCloseButton={false} style={styles.promotionItem} - shouldTryHypelabAd={false} + // shouldTryHypelabAd={false} + ref={adRef} onError={() => setPromotionErrorOccurred(true)} + onLoad={onAdLoad} + onLayout={onOutsideOfScrollAdLayout} /> ); } return result; - }, [activePromotion, partnersPromoShown, promotionErrorOccurred, styles]); + }, [activePromotion, onAdLoad, onOutsideOfScrollAdLayout, partnersPromoShown, promotionErrorOccurred, styles]); const height = formatSize(112); const { layoutWidth, handleLayout } = useLayoutSizes(); @@ -69,12 +79,35 @@ export const PromotionCarousel = () => { [] ); + const handleProgressChange = useMemo( + () => + throttle( + (offsetProgress: number, absoluteProgress: number) => { + let actualOffset = offsetProgress; + if (absoluteProgress > 1) { + const offsetPerSlide = Math.abs(offsetProgress / absoluteProgress); + const totalLength = offsetPerSlide * data.length; + const pivotX = totalLength / 2; + if (offsetProgress > pivotX) { + actualOffset = offsetProgress - totalLength; + } else if (offsetProgress < -pivotX) { + actualOffset = offsetProgress + totalLength; + } + } + setPromotionOffset({ x: actualOffset, y: 0 }); + }, + 100, + { leading: false, trailing: true } + ), + [data.length] + ); + const renderItem = useCallback((info: CarouselRenderItemInfo) => info.item, []); const style = useMemo(() => [styles.container, { height }], [styles.container, height]); return ( - + {flooredWidth > 0 ? ( { height={height} scrollAnimationDuration={1200} renderItem={renderItem} + onProgressChange={handleProgressChange} /> ) : null} diff --git a/src/screens/market/top-coins-table/top-tokens-table.tsx b/src/screens/market/top-coins-table/top-tokens-table.tsx index 2d77874d4..4b386d9ba 100644 --- a/src/screens/market/top-coins-table/top-tokens-table.tsx +++ b/src/screens/market/top-coins-table/top-tokens-table.tsx @@ -6,6 +6,7 @@ import { DataPlaceholder } from 'src/components/data-placeholder/data-placeholde import { GenericPromotionItem } from 'src/components/generic-promotion-item'; import { useFakeRefreshControlProps } from 'src/hooks/use-fake-refresh-control-props.hook'; import { useFilteredMarketTokens } from 'src/hooks/use-filtered-market-tokens.hook'; +import { useInternalAdsAnalytics } from 'src/hooks/use-internal-ads-analytics.hook'; import { MarketToken } from 'src/store/market/market.interfaces'; import { formatSize } from 'src/styles/format-size'; @@ -34,6 +35,8 @@ export const TopTokensTable = () => { const ref = useRef>(null); const [promotionErrorOccurred, setPromotionErrorOccurred] = useState(false); + const { onOutsideOfScrollAdLayout, onAdLoad } = useInternalAdsAnalytics('Market'); + const fakeRefreshControlProps = useFakeRefreshControlProps(); const listEmptyComponent = @@ -60,7 +63,9 @@ export const TopTokensTable = () => { setPromotionErrorOccurred(true)} + onLayout={onOutsideOfScrollAdLayout} /> )} diff --git a/src/screens/notifications/notifications.tsx b/src/screens/notifications/notifications.tsx index ebbaf29ee..e2ca97915 100644 --- a/src/screens/notifications/notifications.tsx +++ b/src/screens/notifications/notifications.tsx @@ -5,6 +5,7 @@ import { useDispatch } from 'react-redux'; import { DataPlaceholder } from 'src/components/data-placeholder/data-placeholder'; import { GenericPromotionItem } from 'src/components/generic-promotion-item'; import { HorizontalBorder } from 'src/components/horizontal-border'; +import { useInternalAdsAnalytics } from 'src/hooks/use-internal-ads-analytics.hook'; import { useIsPartnersPromoShown, usePartnersPromoLoad } from 'src/hooks/use-partners-promo'; import { NotificationInterface } from 'src/interfaces/notification.interface'; import { ScreensEnum } from 'src/navigator/enums/screens.enum'; @@ -34,6 +35,7 @@ export const Notifications = () => { const [promotionErrorOccurred, setPromotionErrorOccurred] = useState(false); usePartnersPromoLoad(PROMOTION_ID); + const { onOutsideOfScrollAdLayout, onAdLoad } = useInternalAdsAnalytics('Notifications'); const handlePromotionItemError = useCallback(() => setPromotionErrorOccurred(true), []); @@ -54,6 +56,8 @@ export const Notifications = () => { testID={NotificationsSelectors.promotion} style={NotificationsStyles.ads} onError={handlePromotionItemError} + onLayout={onOutsideOfScrollAdLayout} + onLoad={onAdLoad} /> diff --git a/src/screens/tezos-token-screen/tezos-token-history/tezos-token-history.tsx b/src/screens/tezos-token-screen/tezos-token-history/tezos-token-history.tsx index 024709a4c..44de32e1d 100644 --- a/src/screens/tezos-token-screen/tezos-token-history/tezos-token-history.tsx +++ b/src/screens/tezos-token-screen/tezos-token-history/tezos-token-history.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { ActivityGroupsList } from '../../../components/activity-groups-list/activity-groups-list'; -import { useContractActivity } from '../../../hooks/use-contract-activity'; -import { TEZ_TOKEN_SLUG } from '../../../token/data/tokens-metadata'; +import { ActivityGroupsList } from 'src/components/activity-groups-list/activity-groups-list'; +import { useContractActivity } from 'src/hooks/use-contract-activity'; +import { TEZ_TOKEN_SLUG } from 'src/token/data/tokens-metadata'; export const TezosTokenHistory = () => { const { activities, handleUpdate, isAllLoaded, isLoading } = useContractActivity(TEZ_TOKEN_SLUG); @@ -13,6 +13,7 @@ export const TezosTokenHistory = () => { activityGroups={activities} isAllLoaded={isAllLoaded} isLoading={isLoading} + pageName="Token page" /> ); }; diff --git a/src/screens/token-screen/token-screen.tsx b/src/screens/token-screen/token-screen.tsx index 528432428..a1aab8415 100644 --- a/src/screens/token-screen/token-screen.tsx +++ b/src/screens/token-screen/token-screen.tsx @@ -75,6 +75,7 @@ export const TokenScreen = () => { activityGroups={activities} isAllLoaded={isAllLoaded} isLoading={isLoading} + pageName="Token page" /> } infoComponent={} diff --git a/src/screens/wallet/token-list/token-list.tsx b/src/screens/wallet/token-list/token-list.tsx index 03b2e9c55..25a6eeba8 100644 --- a/src/screens/wallet/token-list/token-list.tsx +++ b/src/screens/wallet/token-list/token-list.tsx @@ -18,6 +18,7 @@ import { isAndroid } from 'src/config/system'; import { PromotionVariantEnum } from 'src/enums/promotion-variant.enum'; import { useFakeRefreshControlProps } from 'src/hooks/use-fake-refresh-control-props.hook'; import { useFilteredAssetsList } from 'src/hooks/use-filtered-assets-list.hook'; +import { useInternalAdsAnalytics } from 'src/hooks/use-internal-ads-analytics.hook'; import { useNetworkInfo } from 'src/hooks/use-network-info.hook'; import { useIsPartnersPromoShown } from 'src/hooks/use-partners-promo'; import { ScreensEnum } from 'src/navigator/enums/screens.enum'; @@ -74,6 +75,9 @@ export const TokensList = memo(() => { const [promotionErrorOccurred, setPromotionErrorOccurred] = useState(false); const flashListRef = useRef>(null); + const adListItemRef = useRef(null); + const flashListWrapperRef = useRef(null); + const refs = useMemo(() => ({ parent: flashListWrapperRef, element: adListItemRef }), []); const fakeRefreshControlProps = useFakeRefreshControlProps(); @@ -87,6 +91,11 @@ export const TokensList = memo(() => { const partnersPromoShown = useIsPartnersPromoShown(PROMOTION_ID); const { isTezosNode } = useNetworkInfo(); + const { onListScroll, onInsideScrollAdLayout, onListLayoutChange, onAdLoad } = useInternalAdsAnalytics( + 'Home page', + refs + ); + const handleHideZeroBalanceChange = useCallback((value: boolean) => { dispatch(setZeroBalancesShown(value)); trackEvent(WalletSelectors.hideZeroBalancesCheckbox, AnalyticsEventCategory.ButtonPress); @@ -157,7 +166,7 @@ export const TokensList = memo(() => { ({ item }) => { if (item === AD_PLACEHOLDER) { return ( - + { style={styles.promotionItem} testID={WalletSelectors.promotion} onError={() => setPromotionErrorOccurred(true)} + onLoad={onAdLoad} /> @@ -184,7 +194,7 @@ export const TokensList = memo(() => { return ; }, - [apyRates, styles] + [apyRates, onAdLoad, onInsideScrollAdLayout, styles] ); useEffect(() => void flashListRef.current?.scrollToOffset({ animated: true, offset: 0 }), [publicKeyHash]); @@ -230,7 +240,12 @@ export const TokensList = memo(() => { ) : null} - + { estimatedItemSize={FLOORED_ITEM_HEIGHT} ListEmptyComponent={ListEmptyComponent} refreshControl={refreshControl} + onScroll={onListScroll} + onLayout={onListLayoutChange} /> diff --git a/src/utils/get-intersection-ratio.ts b/src/utils/get-intersection-ratio.ts new file mode 100644 index 000000000..1461292b5 --- /dev/null +++ b/src/utils/get-intersection-ratio.ts @@ -0,0 +1,44 @@ +import { clamp } from 'lodash-es'; +import { LayoutRectangle } from 'react-native'; + +interface ParentSize { + width: number; + height: number; +} + +/** + * @param parentSize Size of the parent element + * @param elementRect Layout rectangle of the element relatively to the parent + * @returns Ratio of intersection area to the element area; for 0*0 elements, 1 if the element is inside the parent and + * 0 otherwise; for x*0 and 0*x elements, returns the ratio of the intersection non-zero dimension to the respective + * element dimension + */ +export const getIntersectionRatio = (parentSize: ParentSize, elementRect: LayoutRectangle) => { + const { width: elementWidth, height: elementHeight, x: elementLeft, y: elementTop } = elementRect; + const { width: parentWidth, height: parentHeight } = parentSize; + const elementArea = elementWidth * elementHeight; + + if (elementWidth === 0 && elementHeight === 0) { + const elementIsInside = + elementLeft < parentWidth && elementLeft >= 0 && elementTop < parentHeight && elementTop >= 0; + + return elementIsInside ? 1 : 0; + } + + const elementRight = elementLeft + elementWidth; + const elementBottom = elementTop + elementHeight; + const intersectionTop = clamp(elementTop, 0, parentHeight); + const intersectionBottom = clamp(elementBottom, 0, parentHeight); + const intersectionLeft = clamp(elementLeft, 0, parentWidth); + const intersectionRight = clamp(elementRight, 0, parentWidth); + const intersectionWidth = intersectionRight - intersectionLeft; + const intersectionHeight = intersectionBottom - intersectionTop; + + if (elementArea === 0) { + return elementWidth === 0 ? intersectionHeight / elementHeight : intersectionWidth / elementWidth; + } + + const intersectionArea = intersectionHeight * intersectionWidth; + + return intersectionArea / elementArea; +}; From b326bec083748a3c0eb18dc87bd3ba240ce92496 Mon Sep 17 00:00:00 2001 From: Inokentii Mazhara Date: Mon, 26 Feb 2024 17:34:20 +0200 Subject: [PATCH 03/11] TW-1307 Add new env variables to Github actions --- .github/workflows/apk-build.yml | 3 +++ .github/workflows/fastlane-build.yml | 3 +++ .github/workflows/secrets-setup/action.yml | 3 +++ .github/workflows/testapp-build.yml | 3 +++ src/screens/d-apps/promotion-carousel/promotion-carousel.tsx | 2 +- 5 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/apk-build.yml b/.github/workflows/apk-build.yml index c279e4b20..2c47960ce 100644 --- a/.github/workflows/apk-build.yml +++ b/.github/workflows/apk-build.yml @@ -48,6 +48,9 @@ jobs: FIREBASE_GOOGLE_SERVICE_ANDROID: ${{ secrets.FIREBASE_GOOGLE_SERVICE_ANDROID }} FIREBASE_GOOGLE_SERVICE_IOS: ${{ secrets.FIREBASE_GOOGLE_SERVICE_IOS }} DYNAMIC_LINKS_DOMAIN_URI_PREFIX: ${{ secrets.DYNAMIC_LINKS_DOMAIN_URI_PREFIX }} + HYPELAB_AD_FRAME_URL: ${{ vars.HYPELAB_AD_FRAME_URL }} + HYPELAB_SMALL_PLACEMENT_SLUG: ${{ vars.HYPELAB_SMALL_PLACEMENT_SLUG }} + HYPELAB_NATIVE_PLACEMENT_SLUG: ${{ vars.HYPELAB_NATIVE_PLACEMENT_SLUG }} APPSTORE_AUTHKEY: ${{ secrets.APPSTORE_AUTHKEY }} GOOGLE_PLAY_AUTHKEY: ${{ secrets.GOOGLE_PLAY_AUTHKEY }} KEYSTORE_KEY: ${{ secrets.KEYSTORE_KEY }} diff --git a/.github/workflows/fastlane-build.yml b/.github/workflows/fastlane-build.yml index 66a964a27..84ca026a4 100644 --- a/.github/workflows/fastlane-build.yml +++ b/.github/workflows/fastlane-build.yml @@ -44,6 +44,9 @@ jobs: FIREBASE_GOOGLE_SERVICE_ANDROID: ${{ secrets.FIREBASE_GOOGLE_SERVICE_ANDROID }} FIREBASE_GOOGLE_SERVICE_IOS: ${{ secrets.FIREBASE_GOOGLE_SERVICE_IOS }} DYNAMIC_LINKS_DOMAIN_URI_PREFIX: ${{ secrets.DYNAMIC_LINKS_DOMAIN_URI_PREFIX }} + HYPELAB_AD_FRAME_URL: ${{ vars.HYPELAB_AD_FRAME_URL }} + HYPELAB_SMALL_PLACEMENT_SLUG: ${{ vars.HYPELAB_SMALL_PLACEMENT_SLUG }} + HYPELAB_NATIVE_PLACEMENT_SLUG: ${{ vars.HYPELAB_NATIVE_PLACEMENT_SLUG }} APPSTORE_AUTHKEY: ${{ secrets.APPSTORE_AUTHKEY }} GOOGLE_PLAY_AUTHKEY: ${{ secrets.GOOGLE_PLAY_AUTHKEY }} KEYSTORE_KEY: ${{ secrets.KEYSTORE_KEY }} diff --git a/.github/workflows/secrets-setup/action.yml b/.github/workflows/secrets-setup/action.yml index 4a763c3d1..4139c94b1 100644 --- a/.github/workflows/secrets-setup/action.yml +++ b/.github/workflows/secrets-setup/action.yml @@ -29,6 +29,9 @@ runs: TEMPLE_WALLET_ROUTE3_AUTH_TOKEN=${{ inputs.TEMPLE_WALLET_ROUTE3_AUTH_TOKEN }} DYNAMIC_LINKS_DOMAIN_URI_PREFIX=${{ inputs.DYNAMIC_LINKS_DOMAIN_URI_PREFIX }} + HYPELAB_AD_FRAME_URL=${{ inputs.HYPELAB_AD_FRAME_URL }} + HYPELAB_SMALL_PLACEMENT_SLUG=${{ inputs.HYPELAB_SMALL_PLACEMENT_SLUG }} + HYPELAB_NATIVE_PLACEMENT_SLUG=${{ inputs.HYPELAB_NATIVE_PLACEMENT_SLUG }} APK_BUILD_ID=${{ inputs.APK_BUILD_ID }} EOF diff --git a/.github/workflows/testapp-build.yml b/.github/workflows/testapp-build.yml index 9f44d1769..574fb9983 100644 --- a/.github/workflows/testapp-build.yml +++ b/.github/workflows/testapp-build.yml @@ -54,6 +54,9 @@ jobs: FIREBASE_GOOGLE_SERVICE_ANDROID: ${{ secrets.FIREBASE_GOOGLE_SERVICE_ANDROID }} FIREBASE_GOOGLE_SERVICE_IOS: ${{ secrets.FIREBASE_GOOGLE_SERVICE_IOS }} DYNAMIC_LINKS_DOMAIN_URI_PREFIX: ${{ secrets.DYNAMIC_LINKS_DOMAIN_URI_PREFIX }} + HYPELAB_AD_FRAME_URL: ${{ vars.HYPELAB_AD_FRAME_URL }} + HYPELAB_SMALL_PLACEMENT_SLUG: ${{ vars.HYPELAB_SMALL_PLACEMENT_SLUG }} + HYPELAB_NATIVE_PLACEMENT_SLUG: ${{ vars.HYPELAB_NATIVE_PLACEMENT_SLUG }} APPSTORE_AUTHKEY: ${{ secrets.APPSTORE_AUTHKEY }} GOOGLE_PLAY_AUTHKEY: ${{ secrets.GOOGLE_PLAY_AUTHKEY }} KEYSTORE_KEY: ${{ secrets.KEYSTORE_KEY }} diff --git a/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx b/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx index a0da39a54..20e4ccbdf 100644 --- a/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx +++ b/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx @@ -54,7 +54,7 @@ export const PromotionCarousel = () => { testID={PromotionCarouselSelectors.optimalPromotionBanner} shouldShowCloseButton={false} style={styles.promotionItem} - // shouldTryHypelabAd={false} + shouldTryHypelabAd={false} ref={adRef} onError={() => setPromotionErrorOccurred(true)} onLoad={onAdLoad} From a5a6f2d7d81c25b7a1b69e37efa639dcf1ebb1ed Mon Sep 17 00:00:00 2001 From: Inokentii Mazhara Date: Mon, 26 Feb 2024 17:44:42 +0200 Subject: [PATCH 04/11] TW-1307 Rename some components --- .../activity-groups-list/activity-groups-list.tsx | 4 ++-- .../index.tsx | 6 +++--- .../styles.ts | 2 +- .../index.tsx | 6 +++--- .../styles.ts | 2 +- .../index.tsx | 14 +++++++------- .../styles.ts | 2 +- .../promotion-carousel/promotion-carousel.tsx | 4 ++-- .../market/top-coins-table/top-tokens-table.tsx | 4 ++-- src/screens/notifications/notifications.tsx | 4 ++-- src/screens/wallet/token-list/token-list.tsx | 4 ++-- 11 files changed, 26 insertions(+), 26 deletions(-) rename src/components/{new-hypelab-promotion => hypelab-promotion}/index.tsx (96%) rename src/components/{new-hypelab-promotion => hypelab-promotion}/styles.ts (89%) rename src/components/{new-optimal-promotion => optimal-promotion}/index.tsx (95%) rename src/components/{new-optimal-promotion => optimal-promotion}/styles.ts (75%) rename src/components/{generic-promotion-item => promotion-item}/index.tsx (92%) rename src/components/{generic-promotion-item => promotion-item}/styles.ts (90%) diff --git a/src/components/activity-groups-list/activity-groups-list.tsx b/src/components/activity-groups-list/activity-groups-list.tsx index 39c112e3d..756d3843e 100644 --- a/src/components/activity-groups-list/activity-groups-list.tsx +++ b/src/components/activity-groups-list/activity-groups-list.tsx @@ -3,7 +3,7 @@ import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, LayoutChangeEvent, Text, View } from 'react-native'; import { DataPlaceholder } from 'src/components/data-placeholder/data-placeholder'; -import { GenericPromotionItem } from 'src/components/generic-promotion-item'; +import { PromotionItem } from 'src/components/promotion-item'; import { RefreshControl } from 'src/components/refresh-control/refresh-control'; import { emptyFn } from 'src/config/general'; import { useAdTemporaryHiding } from 'src/hooks/use-ad-temporary-hiding.hook'; @@ -133,7 +133,7 @@ export const ActivityGroupsList: FC = ({ const Promotion = useMemo( () => ( - = ({ +export const HypelabPromotion: FC = ({ variant, isVisible, shouldShowCloseButton, @@ -36,7 +36,7 @@ export const NewHypelabPromotion: FC = ({ const { testID, testIDProperties } = testIDProps; const isImageAd = variant === PromotionVariantEnum.Image; const colors = useColors(); - const styles = useNewHypelabPromotionStyles(); + const styles = useHypelabPromotionStyles(); const theme = useThemeSelector(); const { trackEvent } = useAnalytics(); const [adFrameAspectRatio, setAdFrameAspectRatio] = useState(359 / 80); diff --git a/src/components/new-hypelab-promotion/styles.ts b/src/components/hypelab-promotion/styles.ts similarity index 89% rename from src/components/new-hypelab-promotion/styles.ts rename to src/components/hypelab-promotion/styles.ts index d4ed0bd5a..f43c06be9 100644 --- a/src/components/new-hypelab-promotion/styles.ts +++ b/src/components/hypelab-promotion/styles.ts @@ -1,7 +1,7 @@ import { createUseStylesMemoized } from 'src/styles/create-use-styles'; import { formatSize } from 'src/styles/format-size'; -export const useNewHypelabPromotionStyles = createUseStylesMemoized(() => ({ +export const useHypelabPromotionStyles = createUseStylesMemoized(() => ({ imageAdFrameWrapper: { width: formatSize(320), height: formatSize(50), diff --git a/src/components/new-optimal-promotion/index.tsx b/src/components/optimal-promotion/index.tsx similarity index 95% rename from src/components/new-optimal-promotion/index.tsx rename to src/components/optimal-promotion/index.tsx index f51a26f19..30afa5a08 100644 --- a/src/components/new-optimal-promotion/index.tsx +++ b/src/components/optimal-promotion/index.tsx @@ -15,9 +15,9 @@ import { useIsEmptyPromotion } from 'src/utils/optimal.utils'; import { ImagePromotionView } from '../image-promotion-view'; import { TextPromotionView } from '../text-promotion-view'; -import { useNewOptimalPromotionStyles } from './styles'; +import { useOptimalPromotionStyles } from './styles'; -export const NewOptimalPromotion: FC = ({ +export const OptimalPromotion: FC = ({ variant, isVisible, shouldShowCloseButton, @@ -27,7 +27,7 @@ export const NewOptimalPromotion: FC = ({ ...testIDProps }) => { const isImageAd = variant === PromotionVariantEnum.Image; - const styles = useNewOptimalPromotionStyles(); + const styles = useOptimalPromotionStyles(); const promo = usePartnersPromoSelector(); const isLoading = usePartnersPromoLoadingSelector(); const errorFromStore = usePartnersPromoErrorSelector(); diff --git a/src/components/new-optimal-promotion/styles.ts b/src/components/optimal-promotion/styles.ts similarity index 75% rename from src/components/new-optimal-promotion/styles.ts rename to src/components/optimal-promotion/styles.ts index fb29e2d87..57b7603e1 100644 --- a/src/components/new-optimal-promotion/styles.ts +++ b/src/components/optimal-promotion/styles.ts @@ -1,7 +1,7 @@ import { createUseStylesMemoized } from 'src/styles/create-use-styles'; import { formatSize } from 'src/styles/format-size'; -export const useNewOptimalPromotionStyles = createUseStylesMemoized(() => ({ +export const useOptimalPromotionStyles = createUseStylesMemoized(() => ({ bannerImage: { height: formatSize(112), width: formatSize(343), diff --git a/src/components/generic-promotion-item/index.tsx b/src/components/promotion-item/index.tsx similarity index 92% rename from src/components/generic-promotion-item/index.tsx rename to src/components/promotion-item/index.tsx index 717bc31e9..a8e16a466 100644 --- a/src/components/generic-promotion-item/index.tsx +++ b/src/components/promotion-item/index.tsx @@ -10,10 +10,10 @@ import { useIsPartnersPromoEnabledSelector } from 'src/store/partners-promotion/ import { PromotionVariantEnum } from '../../enums/promotion-variant.enum'; import { ActivityIndicator } from '../activity-indicator'; -import { NewHypelabPromotion } from '../new-hypelab-promotion'; -import { NewOptimalPromotion } from '../new-optimal-promotion'; +import { HypelabPromotion } from '../hypelab-promotion'; +import { OptimalPromotion } from '../optimal-promotion'; -import { useGenericPromotionItemStyles } from './styles'; +import { usePromotionItemStyles } from './styles'; interface Props extends TestIdProps { id: string; @@ -26,7 +26,7 @@ interface Props extends TestIdProps { onLayout?: ViewProps['onLayout']; } -export const GenericPromotionItem = forwardRef( +export const PromotionItem = forwardRef( ( { id, @@ -42,7 +42,7 @@ export const GenericPromotionItem = forwardRef( ref ) => { const isImageAd = variant === PromotionVariantEnum.Image; - const styles = useGenericPromotionItemStyles(); + const styles = usePromotionItemStyles(); const partnersPromotionEnabled = useIsPartnersPromoEnabledSelector(); const { isHiddenTemporarily, hidePromotion } = useAdTemporaryHiding(id); const isFocused = useIsFocused(); @@ -111,7 +111,7 @@ export const GenericPromotionItem = forwardRef( onLayout={onLayout} > {currentProvider === PromotionProviderEnum.Optimal && isFocused && ( - ( /> )} {currentProvider === PromotionProviderEnum.HypeLab && shouldTryHypelabAd && isFocused && ( - ({ +export const usePromotionItemStyles = createUseStylesMemoized(({ colors }) => ({ androidContainer: { overflow: 'hidden' }, diff --git a/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx b/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx index 20e4ccbdf..cf1ad5b46 100644 --- a/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx +++ b/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx @@ -5,7 +5,7 @@ import Carousel from 'react-native-reanimated-carousel'; import type { ILayoutConfig } from 'react-native-reanimated-carousel/lib/typescript/layouts/parallax'; import type { CarouselRenderItemInfo } from 'react-native-reanimated-carousel/lib/typescript/types'; -import { GenericPromotionItem } from 'src/components/generic-promotion-item'; +import { PromotionItem } from 'src/components/promotion-item'; import { useInternalAdsAnalytics } from 'src/hooks/use-internal-ads-analytics.hook'; import { useLayoutSizes } from 'src/hooks/use-layout-sizes.hook'; import { useIsPartnersPromoShown, usePartnersPromoLoad } from 'src/hooks/use-partners-promo'; @@ -49,7 +49,7 @@ export const PromotionCarousel = () => { if (partnersPromoShown && !promotionErrorOccurred) { result.unshift( - { {!promotionErrorOccurred && ( - { <> {partnersPromoShown && !promotionErrorOccurred && ( <> - { return ( - Date: Mon, 26 Feb 2024 18:09:08 +0200 Subject: [PATCH 05/11] TW-1307 Other refactoring --- src/components/hypelab-promotion/index.tsx | 11 +++++------ src/components/image-promotion-view/index.tsx | 9 ++++----- src/components/optimal-promotion/index.tsx | 5 ++--- src/components/promotion-item/index.tsx | 13 +++++-------- .../market/top-coins-table/top-tokens-table.tsx | 4 +++- src/screens/wallet/token-list/token-list.tsx | 3 ++- 6 files changed, 21 insertions(+), 24 deletions(-) diff --git a/src/components/hypelab-promotion/index.tsx b/src/components/hypelab-promotion/index.tsx index 6d549ecaf..259c0e155 100644 --- a/src/components/hypelab-promotion/index.tsx +++ b/src/components/hypelab-promotion/index.tsx @@ -2,6 +2,11 @@ import React, { FC, useCallback, useMemo, useState } from 'react'; import { View } from 'react-native'; import { WebView, WebViewMessageEvent } from 'react-native-webview'; +import { Icon } from 'src/components/icon/icon'; +import { IconNameEnum } from 'src/components/icon/icon-name.enum'; +import { ImagePromotionView } from 'src/components/image-promotion-view'; +import { TextPromotionItemSelectors } from 'src/components/text-promotion-view/selectors'; +import { TouchableWithAnalytics } from 'src/components/touchable-with-analytics'; import { AdFrameMessageType } from 'src/enums/ad-frame-message-type.enum'; import { PromotionVariantEnum } from 'src/enums/promotion-variant.enum'; import { ThemesEnum } from 'src/interfaces/theme.enum'; @@ -16,12 +21,6 @@ import { useTimeout } from 'src/utils/hooks'; import { isString } from 'src/utils/is-string'; import { openUrl } from 'src/utils/linking'; -import { Icon } from '../icon/icon'; -import { IconNameEnum } from '../icon/icon-name.enum'; -import { ImagePromotionView } from '../image-promotion-view'; -import { TextPromotionItemSelectors } from '../text-promotion-view/selectors'; -import { TouchableWithAnalytics } from '../touchable-with-analytics'; - import { useHypelabPromotionStyles } from './styles'; export const HypelabPromotion: FC = ({ diff --git a/src/components/image-promotion-view/index.tsx b/src/components/image-promotion-view/index.tsx index 18f2ad4c3..5311f9867 100644 --- a/src/components/image-promotion-view/index.tsx +++ b/src/components/image-promotion-view/index.tsx @@ -1,16 +1,15 @@ import React, { useCallback, memo, PropsWithChildren } from 'react'; import { View } from 'react-native'; +import { Bage } from 'src/components/bage/bage'; +import { Icon } from 'src/components/icon/icon'; +import { IconNameEnum } from 'src/components/icon/icon-name.enum'; +import { TouchableWithAnalytics } from 'src/components/touchable-with-analytics'; import { TestIdProps } from 'src/interfaces/test-id.props'; import { formatSize } from 'src/styles/format-size'; import { useColors } from 'src/styles/use-colors'; import { openUrl } from 'src/utils/linking'; -import { Bage } from '../bage/bage'; -import { Icon } from '../icon/icon'; -import { IconNameEnum } from '../icon/icon-name.enum'; -import { TouchableWithAnalytics } from '../touchable-with-analytics'; - import { PromotionItemSelectors } from './selectors'; import { useImagePromotionViewStyles } from './styles'; diff --git a/src/components/optimal-promotion/index.tsx b/src/components/optimal-promotion/index.tsx index 30afa5a08..16715ae1f 100644 --- a/src/components/optimal-promotion/index.tsx +++ b/src/components/optimal-promotion/index.tsx @@ -1,6 +1,8 @@ import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; import FastImage from 'react-native-fast-image'; +import { ImagePromotionView } from 'src/components/image-promotion-view'; +import { TextPromotionView } from 'src/components/text-promotion-view'; import { PromotionVariantEnum } from 'src/enums/promotion-variant.enum'; import { usePartnersPromoErrorSelector, @@ -12,9 +14,6 @@ import { useTimeout } from 'src/utils/hooks'; import { isDefined } from 'src/utils/is-defined'; import { useIsEmptyPromotion } from 'src/utils/optimal.utils'; -import { ImagePromotionView } from '../image-promotion-view'; -import { TextPromotionView } from '../text-promotion-view'; - import { useOptimalPromotionStyles } from './styles'; export const OptimalPromotion: FC = ({ diff --git a/src/components/promotion-item/index.tsx b/src/components/promotion-item/index.tsx index a8e16a466..9cecbef54 100644 --- a/src/components/promotion-item/index.tsx +++ b/src/components/promotion-item/index.tsx @@ -2,17 +2,16 @@ import { useIsFocused } from '@react-navigation/native'; import React, { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; import { StyleProp, View, ViewProps, ViewStyle } from 'react-native'; +import { ActivityIndicator } from 'src/components/activity-indicator'; +import { HypelabPromotion } from 'src/components/hypelab-promotion'; +import { OptimalPromotion } from 'src/components/optimal-promotion'; import { isAndroid } from 'src/config/system'; import { PromotionProviderEnum } from 'src/enums/promotion-provider.enum'; +import { PromotionVariantEnum } from 'src/enums/promotion-variant.enum'; import { useAdTemporaryHiding } from 'src/hooks/use-ad-temporary-hiding.hook'; import { TestIdProps } from 'src/interfaces/test-id.props'; import { useIsPartnersPromoEnabledSelector } from 'src/store/partners-promotion/partners-promotion-selectors'; -import { PromotionVariantEnum } from '../../enums/promotion-variant.enum'; -import { ActivityIndicator } from '../activity-indicator'; -import { HypelabPromotion } from '../hypelab-promotion'; -import { OptimalPromotion } from '../optimal-promotion'; - import { usePromotionItemStyles } from './styles'; interface Props extends TestIdProps { @@ -75,9 +74,7 @@ export const PromotionItem = forwardRef( } setAdsState(prevState => ({ ...prevState, currentProvider: PromotionProviderEnum.HypeLab })); }, [handleAdError, shouldTryHypelabAd]); - const handleHypelabError = useCallback(() => { - handleAdError(); - }, [handleAdError]); + const handleHypelabError = useCallback(() => handleAdError(), [handleAdError]); const handleAdReadyFactory = useCallback( (provider: PromotionProviderEnum) => () => { diff --git a/src/screens/market/top-coins-table/top-tokens-table.tsx b/src/screens/market/top-coins-table/top-tokens-table.tsx index d5a491219..401b86d6b 100644 --- a/src/screens/market/top-coins-table/top-tokens-table.tsx +++ b/src/screens/market/top-coins-table/top-tokens-table.tsx @@ -51,6 +51,8 @@ export const TopTokensTable = () => { const closeAllOpenRows = useCallback(() => ref.current?.closeAllOpenRows(), []); + const handlePromotionError = useCallback(() => setPromotionErrorOccurred(true), []); + const handleSelectorChangeAndSwipeClose = (index: number) => { handleSelectorChange(index); closeAllOpenRows(); @@ -64,7 +66,7 @@ export const TopTokensTable = () => { id={PROMOTION_ID} testID={MarketSelectors.promotion} onLoad={onAdLoad} - onError={() => setPromotionErrorOccurred(true)} + onError={handlePromotionError} onLayout={onOutsideOfScrollAdLayout} /> diff --git a/src/screens/wallet/token-list/token-list.tsx b/src/screens/wallet/token-list/token-list.tsx index 6f9154ea5..0cc8aaf41 100644 --- a/src/screens/wallet/token-list/token-list.tsx +++ b/src/screens/wallet/token-list/token-list.tsx @@ -161,6 +161,7 @@ export const TokensList = memo(() => { ]); const handleLayout = useCallback((event: LayoutChangeEvent) => setListHeight(event.nativeEvent.layout.height), []); + const handlePromotionError = useCallback(() => setPromotionErrorOccurred(true), []); const renderItem: ListRenderItem = useCallback( ({ item }) => { @@ -173,7 +174,7 @@ export const TokensList = memo(() => { variant={PromotionVariantEnum.Text} style={styles.promotionItem} testID={WalletSelectors.promotion} - onError={() => setPromotionErrorOccurred(true)} + onError={handlePromotionError} onLoad={onAdLoad} /> From 76ba8d972397657e652d653a33955ba000aab096 Mon Sep 17 00:00:00 2001 From: Inokentii Mazhara Date: Tue, 27 Feb 2024 19:07:04 +0200 Subject: [PATCH 06/11] TW-1307 Use SWR instead of Redux to fetch Optimal ads --- .../activity-groups-list.tsx | 3 - src/components/hypelab-promotion/index.tsx | 8 +- src/components/optimal-promotion/index.tsx | 76 +++++++++---------- src/components/promotion-item/index.tsx | 21 +++++ src/hooks/use-element-is-seen.hook.ts | 20 ++++- src/hooks/use-internal-ads-analytics.hook.ts | 15 +++- src/hooks/use-partners-promo.ts | 35 --------- .../promotion-carousel/promotion-carousel.tsx | 4 +- src/screens/market/market.tsx | 5 +- .../top-coins-table/top-tokens-table.tsx | 1 + src/screens/notifications/notifications.tsx | 3 +- src/screens/token-screen/token-screen.tsx | 5 +- src/screens/wallet/token-list/token-list.tsx | 5 +- src/store/index.ts | 2 - .../partners-promotion-actions.ts | 8 -- .../partners-promotion-epics.ts | 27 ------- .../partners-promotion-reducers.ts | 20 +---- .../partners-promotion-selectors.ts | 3 - .../partners-promotion-state.mock.ts | 23 ------ .../partners-promotion-state.ts | 9 --- src/utils/optimal.utils.ts | 11 +-- 21 files changed, 99 insertions(+), 205 deletions(-) delete mode 100644 src/store/partners-promotion/partners-promotion-epics.ts diff --git a/src/components/activity-groups-list/activity-groups-list.tsx b/src/components/activity-groups-list/activity-groups-list.tsx index 756d3843e..ae6da0deb 100644 --- a/src/components/activity-groups-list/activity-groups-list.tsx +++ b/src/components/activity-groups-list/activity-groups-list.tsx @@ -9,7 +9,6 @@ import { emptyFn } from 'src/config/general'; import { useAdTemporaryHiding } from 'src/hooks/use-ad-temporary-hiding.hook'; import { useFakeRefreshControlProps } from 'src/hooks/use-fake-refresh-control-props.hook'; import { useInternalAdsAnalytics } from 'src/hooks/use-internal-ads-analytics.hook'; -import { usePartnersPromoLoad } from 'src/hooks/use-partners-promo'; import { ActivityGroup, emptyActivity } from 'src/interfaces/activity.interface'; import { useIsPartnersPromoEnabledSelector } from 'src/store/partners-promotion/partners-promotion-selectors'; import { isTheSameDay, isToday, isYesterday } from 'src/utils/date.utils'; @@ -43,8 +42,6 @@ export const ActivityGroupsList: FC = ({ handleUpdate = emptyFn, pageName }) => { - usePartnersPromoLoad(PROMOTION_ID); - const styles = useActivityGroupsListStyles(); const partnersPromotionEnabled = useIsPartnersPromoEnabledSelector(); diff --git a/src/components/hypelab-promotion/index.tsx b/src/components/hypelab-promotion/index.tsx index 259c0e155..f8a91e890 100644 --- a/src/components/hypelab-promotion/index.tsx +++ b/src/components/hypelab-promotion/index.tsx @@ -64,10 +64,6 @@ export const HypelabPromotion: FC = ({ [adHref, onError] ); - const handleAdFrameError = useCallback(() => { - onError(); - }, [onError]); - const handleAdFrameMessage = useCallback( (e: WebViewMessageEvent) => { try { @@ -111,7 +107,7 @@ export const HypelabPromotion: FC = ({ = ({ source={adFrameSource} containerStyle={styles.textAdFrame} style={[styles.textAdFrame, { aspectRatio: adFrameAspectRatio }]} - onError={handleAdFrameError} + onError={onError} onMessage={handleAdFrameMessage} webviewDebuggingEnabled={__DEV__} scrollEnabled={false} diff --git a/src/components/optimal-promotion/index.tsx b/src/components/optimal-promotion/index.tsx index 16715ae1f..98ceea7df 100644 --- a/src/components/optimal-promotion/index.tsx +++ b/src/components/optimal-promotion/index.tsx @@ -1,25 +1,27 @@ import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; import FastImage from 'react-native-fast-image'; +import useSWR from 'swr'; import { ImagePromotionView } from 'src/components/image-promotion-view'; import { TextPromotionView } from 'src/components/text-promotion-view'; +// import { PROMO_SYNC_INTERVAL } from 'src/config/fixed-times'; import { PromotionVariantEnum } from 'src/enums/promotion-variant.enum'; -import { - usePartnersPromoErrorSelector, - usePartnersPromoLoadingSelector, - usePartnersPromoSelector -} from 'src/store/partners-promotion/partners-promotion-selectors'; +import { useCurrentAccountPkhSelector } from 'src/store/wallet/wallet-selectors'; import { SingleProviderPromotionProps } from 'src/types/promotion'; -import { useTimeout } from 'src/utils/hooks'; import { isDefined } from 'src/utils/is-defined'; -import { useIsEmptyPromotion } from 'src/utils/optimal.utils'; +import { getOptimalPromotion, OptimalPromotionAdType, useIsEmptyPromotion } from 'src/utils/optimal.utils'; import { useOptimalPromotionStyles } from './styles'; -export const OptimalPromotion: FC = ({ +interface SelfRefreshingPromotionProps { + shouldRefreshAd: boolean; +} + +export const OptimalPromotion: FC = ({ variant, isVisible, shouldShowCloseButton, + // shouldRefreshAd, onClose, onReady, onError, @@ -27,49 +29,41 @@ export const OptimalPromotion: FC = ({ }) => { const isImageAd = variant === PromotionVariantEnum.Image; const styles = useOptimalPromotionStyles(); - const promo = usePartnersPromoSelector(); - const isLoading = usePartnersPromoLoadingSelector(); - const errorFromStore = usePartnersPromoErrorSelector(); - const [isImageBroken, setIsImageBroken] = useState(false); - const [wasLoading, setWasLoading] = useState(false); - const [shouldPreventShowingPrevAd, setShouldPreventShowingPrevAd] = useState(true); - const [adViewIsReady, setAdViewIsReady] = useState(isImageAd); - const prevIsLoadingRef = useRef(isLoading); - const promotionIsEmpty = useIsEmptyPromotion(promo); - const apiQueryFailed = (isDefined(errorFromStore) || promotionIsEmpty) && wasLoading; - const adIsNotLikelyToLoad = (isDefined(errorFromStore) || promotionIsEmpty) && !isLoading; + const accountPkh = useCurrentAccountPkhSelector(); - useTimeout( - () => { - if (adIsNotLikelyToLoad) { - onError(); - } - }, - 2000, - [adIsNotLikelyToLoad, onError] + const localGetOptimalPromotion = useCallback( + () => getOptimalPromotion(isImageAd ? OptimalPromotionAdType.TwMobile : OptimalPromotionAdType.TwToken, accountPkh), + [accountPkh, isImageAd] ); + const { + data: promo, + error, + isValidating, + mutate + } = useSWR(['optimal-promotion', accountPkh, isImageAd], localGetOptimalPromotion, { + // refreshInterval: shouldRefreshAd ? PROMO_SYNC_INTERVAL : undefined + }); + const prevIsValidatingRef = useRef(isValidating); - useEffect(() => { - if (wasLoading) { - setShouldPreventShowingPrevAd(false); - } - }, [wasLoading]); - useTimeout(() => setShouldPreventShowingPrevAd(false), 2000, []); + const [isImageBroken, setIsImageBroken] = useState(false); + const [adViewIsReady, setAdViewIsReady] = useState(isImageAd); + const promotionIsEmpty = useIsEmptyPromotion(promo); useEffect(() => { - if (!isLoading && prevIsLoadingRef.current) { - setWasLoading(true); + if (!isValidating) { + mutate(); } - prevIsLoadingRef.current = isLoading; - }, [isLoading]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { - if (apiQueryFailed) { + if (isDefined(error) || promotionIsEmpty) { onError(); - } else if (!promotionIsEmpty && !shouldPreventShowingPrevAd && adViewIsReady) { + } else if (!promotionIsEmpty && promo && adViewIsReady) { onReady(); } - }, [apiQueryFailed, onError, onReady, promotionIsEmpty, shouldPreventShowingPrevAd, adViewIsReady]); + prevIsValidatingRef.current = isValidating; + }, [onError, onReady, promotionIsEmpty, adViewIsReady, error, promo, isValidating]); const onImageError = useCallback(() => { setIsImageBroken(true); @@ -78,7 +72,7 @@ export const OptimalPromotion: FC = ({ const handleTextPromotionReady = useCallback(() => setAdViewIsReady(true), []); - if (isDefined(errorFromStore) || promotionIsEmpty || isImageBroken || shouldPreventShowingPrevAd) { + if (isDefined(error) || promotionIsEmpty || isImageBroken || !promo) { return null; } diff --git a/src/components/promotion-item/index.tsx b/src/components/promotion-item/index.tsx index 9cecbef54..8c8e71d38 100644 --- a/src/components/promotion-item/index.tsx +++ b/src/components/promotion-item/index.tsx @@ -5,10 +5,12 @@ import { StyleProp, View, ViewProps, ViewStyle } from 'react-native'; import { ActivityIndicator } from 'src/components/activity-indicator'; import { HypelabPromotion } from 'src/components/hypelab-promotion'; import { OptimalPromotion } from 'src/components/optimal-promotion'; +import { PROMO_SYNC_INTERVAL } from 'src/config/fixed-times'; import { isAndroid } from 'src/config/system'; import { PromotionProviderEnum } from 'src/enums/promotion-provider.enum'; import { PromotionVariantEnum } from 'src/enums/promotion-variant.enum'; import { useAdTemporaryHiding } from 'src/hooks/use-ad-temporary-hiding.hook'; +import { useAuthorisedInterval } from 'src/hooks/use-authed-interval'; import { TestIdProps } from 'src/interfaces/test-id.props'; import { useIsPartnersPromoEnabledSelector } from 'src/store/partners-promotion/partners-promotion-selectors'; @@ -17,6 +19,7 @@ import { usePromotionItemStyles } from './styles'; interface Props extends TestIdProps { id: string; style?: StyleProp; + shouldRefreshAd?: boolean; shouldShowCloseButton?: boolean; shouldTryHypelabAd?: boolean; variant?: PromotionVariantEnum; @@ -30,6 +33,7 @@ export const PromotionItem = forwardRef( { id, style, + shouldRefreshAd = false, shouldShowCloseButton = true, variant = PromotionVariantEnum.Image, shouldTryHypelabAd = true, @@ -63,6 +67,22 @@ export const PromotionItem = forwardRef( } }, [isFocused]); + useAuthorisedInterval( + () => { + if (!shouldRefreshAd || !partnersPromotionEnabled || isHiddenTemporarily) { + return; + } + + setAdsState({ + currentProvider: PromotionProviderEnum.Optimal, + adError: false, + adIsReady: false + }); + }, + PROMO_SYNC_INTERVAL, + [partnersPromotionEnabled, isHiddenTemporarily, shouldRefreshAd] + ); + const handleAdError = useCallback(() => { setAdsState(prevState => ({ ...prevState, adError: true })); onError && onError(); @@ -113,6 +133,7 @@ export const PromotionItem = forwardRef( variant={variant} isVisible={adIsReady} shouldShowCloseButton={shouldShowCloseButton} + shouldRefreshAd={shouldRefreshAd} onClose={hidePromotion} onReady={handleOptimalAdReady} onError={handleOptimalError} diff --git a/src/hooks/use-element-is-seen.hook.ts b/src/hooks/use-element-is-seen.hook.ts index 5109811e5..99fd9c2c9 100644 --- a/src/hooks/use-element-is-seen.hook.ts +++ b/src/hooks/use-element-is-seen.hook.ts @@ -1,5 +1,5 @@ import { useIsFocused } from '@react-navigation/native'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; /** * @param isVisible Indicates whether the element is visible right now, assuming that the screen is focused. @@ -9,6 +9,7 @@ import { useEffect, useRef, useState } from 'react'; export const useElementIsSeen = (isVisible: boolean, seenTimeout: number, shouldResetOnScreenBlur = true) => { const isFocused = useIsFocused(); const [isSeen, setIsSeen] = useState(false); + const resetWasCalledRef = useRef(false); const isVisibleRef = useRef(isVisible); useEffect(() => { @@ -17,7 +18,7 @@ export const useElementIsSeen = (isVisible: boolean, seenTimeout: number, should } }, [isFocused, shouldResetOnScreenBlur]); - useEffect(() => { + const updateIsSeen = useCallback(() => { isVisibleRef.current = isVisible && isFocused; if (isVisible && isFocused) { @@ -31,5 +32,18 @@ export const useElementIsSeen = (isVisible: boolean, seenTimeout: number, should } }, [isFocused, isVisible, seenTimeout]); - return isSeen; + const resetIsSeen = useCallback(() => { + setIsSeen(false); + resetWasCalledRef.current = true; + }, []); + + useEffect(updateIsSeen, [updateIsSeen]); + useEffect(() => { + if (resetWasCalledRef.current) { + resetWasCalledRef.current = false; + updateIsSeen(); + } + }, [updateIsSeen, isSeen]); + + return { isSeen, resetIsSeen }; }; diff --git a/src/hooks/use-internal-ads-analytics.hook.ts b/src/hooks/use-internal-ads-analytics.hook.ts index ffb473e02..9bfd3842c 100644 --- a/src/hooks/use-internal-ads-analytics.hook.ts +++ b/src/hooks/use-internal-ads-analytics.hook.ts @@ -31,7 +31,10 @@ export const useInternalAdsAnalytics = ( const { trackEvent } = useAnalytics(); const [adAreaIsVisible, setAdAreaIsVisible] = useState(false); const [loadedPromotionProvider, setLoadedPromotionProvider] = useState(); - const adIsSeen = useElementIsSeen(adAreaIsVisible && isDefined(loadedPromotionProvider), seenTimeout); + const { isSeen: adIsSeen, resetIsSeen: resetAdIsSeen } = useElementIsSeen( + adAreaIsVisible && isDefined(loadedPromotionProvider), + seenTimeout + ); const prevAdIsSeenRef = useRef(adIsSeen); useEffect(() => { @@ -96,6 +99,14 @@ export const useInternalAdsAnalytics = ( setLoadedPromotionProvider(undefined); }, []); + const onAdLoad = useCallback( + (provider: PromotionProviderEnum) => { + resetAdIsSeen(); + setLoadedPromotionProvider(provider); + }, + [setLoadedPromotionProvider, resetAdIsSeen] + ); + const { onListScroll, onElementLayoutChange: onInsideScrollAdLayout, @@ -107,7 +118,7 @@ export const useInternalAdsAnalytics = ( onInsideScrollAdLayout, onListLayoutChange, onOutsideOfScrollAdLayout, - onAdLoad: setLoadedPromotionProvider, + onAdLoad, resetAdState }; }; diff --git a/src/hooks/use-partners-promo.ts b/src/hooks/use-partners-promo.ts index 2e554cd72..96b7d2f8c 100644 --- a/src/hooks/use-partners-promo.ts +++ b/src/hooks/use-partners-promo.ts @@ -1,14 +1,7 @@ -import { useEffect } from 'react'; -import { useDispatch } from 'react-redux'; - -import { PROMO_SYNC_INTERVAL } from 'src/config/fixed-times'; -import { loadPartnersPromoActions } from 'src/store/partners-promotion/partners-promotion-actions'; import { useIsPartnersPromoEnabledSelector } from 'src/store/partners-promotion/partners-promotion-selectors'; import { useIsEnabledAdsBannerSelector } from 'src/store/settings/settings-selectors'; -import { OptimalPromotionAdType } from 'src/utils/optimal.utils'; import { useAdTemporaryHiding } from './use-ad-temporary-hiding.hook'; -import { useAuthorisedInterval } from './use-authed-interval'; export const useIsPartnersPromoShown = (id: string) => { const areAdsNotEnabled = useIsEnabledAdsBannerSelector(); @@ -17,31 +10,3 @@ export const useIsPartnersPromoShown = (id: string) => { return isEnabled && !areAdsNotEnabled && !isHiddenTemporarily; }; - -export const usePartnersPromoLoad = (id: string) => { - const promoIsShown = useIsPartnersPromoShown(id); - - const dispatch = useDispatch(); - - useEffect(() => { - if (promoIsShown) { - dispatch(loadPartnersPromoActions.submit(OptimalPromotionAdType.TwMobile)); - } - }, [dispatch, promoIsShown]); -}; - -export const usePartnersPromoSync = (id: string) => { - const promoIsShown = useIsPartnersPromoShown(id); - - const dispatch = useDispatch(); - - useAuthorisedInterval( - () => { - if (promoIsShown) { - dispatch(loadPartnersPromoActions.submit(OptimalPromotionAdType.TwMobile)); - } - }, - PROMO_SYNC_INTERVAL, - [promoIsShown] - ); -}; diff --git a/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx b/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx index cf1ad5b46..56cd0e377 100644 --- a/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx +++ b/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx @@ -8,7 +8,7 @@ import type { CarouselRenderItemInfo } from 'react-native-reanimated-carousel/li import { PromotionItem } from 'src/components/promotion-item'; import { useInternalAdsAnalytics } from 'src/hooks/use-internal-ads-analytics.hook'; import { useLayoutSizes } from 'src/hooks/use-layout-sizes.hook'; -import { useIsPartnersPromoShown, usePartnersPromoLoad } from 'src/hooks/use-partners-promo'; +import { useIsPartnersPromoShown } from 'src/hooks/use-partners-promo'; import { useActivePromotionSelector } from 'src/store/advertising/advertising-selectors'; import { formatSize } from 'src/styles/format-size'; import { isDefined } from 'src/utils/is-defined'; @@ -31,8 +31,6 @@ export const PromotionCarousel = () => { const refs = useMemo(() => ({ parent: layoutRef, element: adRef }), [layoutRef, adRef]); const { onOutsideOfScrollAdLayout, onAdLoad } = useInternalAdsAnalytics('DApps', refs, promotionOffset, 500); - usePartnersPromoLoad(PROMOTION_ID); - const data = useMemo>(() => { const result = [...COMMON_PROMOTION_CAROUSEL_DATA]; diff --git a/src/screens/market/market.tsx b/src/screens/market/market.tsx index 946db7a4f..74719eef3 100644 --- a/src/screens/market/market.tsx +++ b/src/screens/market/market.tsx @@ -4,21 +4,18 @@ import { useDispatch } from 'react-redux'; import { HeaderCard } from 'src/components/header-card/header-card'; import { MARKET_SYNC_INTERVAL } from 'src/config/fixed-times'; import { useAuthorisedInterval } from 'src/hooks/use-authed-interval'; -import { usePartnersPromoSync } from 'src/hooks/use-partners-promo'; import { ScreensEnum } from 'src/navigator/enums/screens.enum'; import { loadMarketTokensSlugsActions } from 'src/store/market/market-actions'; import { usePageAnalytic } from 'src/utils/analytics/use-analytics.hook'; import { TezosInfo } from './tezos-info/tezos-info'; -import { PROMOTION_ID, TopTokensTable } from './top-coins-table/top-tokens-table'; +import { TopTokensTable } from './top-coins-table/top-tokens-table'; export const Market = () => { const dispatch = useDispatch(); useAuthorisedInterval(() => dispatch(loadMarketTokensSlugsActions.submit()), MARKET_SYNC_INTERVAL); - usePartnersPromoSync(PROMOTION_ID); - usePageAnalytic(ScreensEnum.Market); return ( diff --git a/src/screens/market/top-coins-table/top-tokens-table.tsx b/src/screens/market/top-coins-table/top-tokens-table.tsx index 401b86d6b..5e31750b4 100644 --- a/src/screens/market/top-coins-table/top-tokens-table.tsx +++ b/src/screens/market/top-coins-table/top-tokens-table.tsx @@ -64,6 +64,7 @@ export const TopTokensTable = () => { { const partnersPromoShown = useIsPartnersPromoShown(PROMOTION_ID); const [promotionErrorOccurred, setPromotionErrorOccurred] = useState(false); - usePartnersPromoLoad(PROMOTION_ID); const { onOutsideOfScrollAdLayout, onAdLoad } = useInternalAdsAnalytics('Notifications'); const handlePromotionItemError = useCallback(() => setPromotionErrorOccurred(true), []); diff --git a/src/screens/token-screen/token-screen.tsx b/src/screens/token-screen/token-screen.tsx index a1aab8415..bf4348cba 100644 --- a/src/screens/token-screen/token-screen.tsx +++ b/src/screens/token-screen/token-screen.tsx @@ -2,7 +2,7 @@ import { RouteProp, useRoute } from '@react-navigation/native'; import React, { useEffect, useMemo } from 'react'; import { useDispatch } from 'react-redux'; -import { ActivityGroupsList, PROMOTION_ID } from 'src/components/activity-groups-list/activity-groups-list'; +import { ActivityGroupsList } from 'src/components/activity-groups-list/activity-groups-list'; import { HeaderTokenInfo } from 'src/components/header/header-token-info/header-token-info'; import { useNavigationSetOptions } from 'src/components/header/use-navigation-set-options.hook'; import { HeaderCard } from 'src/components/header-card/header-card'; @@ -11,7 +11,6 @@ import { PublicKeyHashText } from 'src/components/public-key-hash-text/public-ke import { TokenEquityValue } from 'src/components/token-equity-value/token-equity-value'; import { TokenScreenContentContainer } from 'src/components/token-screen-content-container/token-screen-content-container'; import { useContractActivity } from 'src/hooks/use-contract-activity'; -import { usePartnersPromoLoad } from 'src/hooks/use-partners-promo'; import { ScreensEnum, ScreensParamList } from 'src/navigator/enums/screens.enum'; import { highPriorityLoadTokenBalanceAction } from 'src/store/wallet/wallet-actions'; import { useCurrentAccountPkhSelector } from 'src/store/wallet/wallet-selectors'; @@ -50,8 +49,6 @@ export const TokenScreen = () => { ); }, []); - usePartnersPromoLoad(PROMOTION_ID); - const { activities, handleUpdate, isAllLoaded, isLoading } = useContractActivity(getTokenSlug(initialToken)); useNavigationSetOptions({ headerTitle: () => }, [token]); diff --git a/src/screens/wallet/token-list/token-list.tsx b/src/screens/wallet/token-list/token-list.tsx index 0cc8aaf41..adef42ea0 100644 --- a/src/screens/wallet/token-list/token-list.tsx +++ b/src/screens/wallet/token-list/token-list.tsx @@ -25,7 +25,6 @@ import { ScreensEnum } from 'src/navigator/enums/screens.enum'; import { useNavigation } from 'src/navigator/hooks/use-navigation.hook'; import { loadAdvertisingPromotionActions } from 'src/store/advertising/advertising-actions'; import { useTokensApyRatesSelector } from 'src/store/d-apps/d-apps-selectors'; -import { loadPartnersPromoActions } from 'src/store/partners-promotion/partners-promotion-actions'; import { setZeroBalancesShown } from 'src/store/settings/settings-actions'; import { useHideZeroBalancesSelector, @@ -40,7 +39,6 @@ import { getTokenSlug } from 'src/token/utils/token.utils'; import { AnalyticsEventCategory } from 'src/utils/analytics/analytics-event.enum'; import { useAnalytics } from 'src/utils/analytics/use-analytics.hook'; import { useAccountTkeyToken, useCurrentAccountTokens } from 'src/utils/assets/hooks'; -import { OptimalPromotionAdType } from 'src/utils/optimal.utils'; import { useTezosTokenOfCurrentAccount } from 'src/utils/wallet.utils'; import { WalletSelectors } from '../wallet.selectors'; @@ -104,7 +102,6 @@ export const TokensList = memo(() => { useEffect(() => { const listener = () => { if (partnersPromoShown) { - dispatch(loadPartnersPromoActions.submit(OptimalPromotionAdType.TwToken)); setPromotionErrorOccurred(false); } }; @@ -195,7 +192,7 @@ export const TokensList = memo(() => { return ; }, - [apyRates, onAdLoad, onInsideScrollAdLayout, styles] + [apyRates, handlePromotionError, onAdLoad, onInsideScrollAdLayout, styles] ); useEffect(() => void flashListRef.current?.scrollToOffset({ animated: true, offset: 0 }), [publicKeyHash]); diff --git a/src/store/index.ts b/src/store/index.ts index ba308d53b..b6ca563ea 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -12,7 +12,6 @@ import { exolixEpics } from './exolix/exolix-epics'; import { farmsEpics } from './farms/epics'; import { marketEpics } from './market/market-epics'; import { notificationsEpics } from './notifications/notifications-epics'; -import { partnersPromotionEpics } from './partners-promotion/partners-promotion-epics'; import { rootStateEpics } from './root-state.epics'; import { savingsEpics } from './savings/epics'; import { securityEpics } from './security/security-epics'; @@ -38,7 +37,6 @@ export const { store, persistor } = createStore( contactsEpics, collectionsEpics, buyWithCreditCardEpics, - partnersPromotionEpics, abTestingEpics, collectiblesEpics, farmsEpics, diff --git a/src/store/partners-promotion/partners-promotion-actions.ts b/src/store/partners-promotion/partners-promotion-actions.ts index ae890ccb3..d6dc0612f 100644 --- a/src/store/partners-promotion/partners-promotion-actions.ts +++ b/src/store/partners-promotion/partners-promotion-actions.ts @@ -1,18 +1,10 @@ import { createAction } from '@reduxjs/toolkit'; -import { OptimalPromotionAdType, OptimalPromotionType } from 'src/utils/optimal.utils'; - -import { createActions } from '../create-actions'; - interface HidePromotionActionPayload { id: string; timestamp: number; } -export const loadPartnersPromoActions = createActions( - 'partnersPromo/LOAD_PARTNERS_PROMOTION' -); - export const togglePartnersPromotionAction = createAction('partnersPromo/SET_IS_PROMOTION_ENABLED'); export const hidePromotionAction = createAction('partnersPromo/PROMOTION_HIDING'); diff --git a/src/store/partners-promotion/partners-promotion-epics.ts b/src/store/partners-promotion/partners-promotion-epics.ts deleted file mode 100644 index 862b19b62..000000000 --- a/src/store/partners-promotion/partners-promotion-epics.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { combineEpics, Epic } from 'redux-observable'; -import { from, of } from 'rxjs'; -import { catchError, map, switchMap } from 'rxjs/operators'; -import { Action } from 'ts-action'; -import { ofType, toPayload } from 'ts-action-operators'; - -import { getOptimalPromotion } from 'src/utils/optimal.utils'; -import { withSelectedAccount } from 'src/utils/wallet.utils'; - -import { RootState } from '../types'; - -import { loadPartnersPromoActions } from './partners-promotion-actions'; - -const loadPartnersPromotionEpic: Epic = (action$, state$) => - action$.pipe( - ofType(loadPartnersPromoActions.submit), - toPayload(), - withSelectedAccount(state$), - switchMap(([payload, account]) => - from(getOptimalPromotion(payload, account.publicKeyHash)).pipe( - map(optimalPromotion => loadPartnersPromoActions.success(optimalPromotion)), - catchError(error => of(loadPartnersPromoActions.fail(error.message))) - ) - ) - ); - -export const partnersPromotionEpics = combineEpics(loadPartnersPromotionEpic); diff --git a/src/store/partners-promotion/partners-promotion-reducers.ts b/src/store/partners-promotion/partners-promotion-reducers.ts index 105be6862..b49141560 100644 --- a/src/store/partners-promotion/partners-promotion-reducers.ts +++ b/src/store/partners-promotion/partners-promotion-reducers.ts @@ -2,28 +2,10 @@ import { createReducer } from '@reduxjs/toolkit'; import { AD_HIDING_TIMEOUT } from 'src/utils/optimal.utils'; -import { createEntity } from '../create-entity'; - -import { - hidePromotionAction, - loadPartnersPromoActions, - togglePartnersPromotionAction -} from './partners-promotion-actions'; +import { hidePromotionAction, togglePartnersPromotionAction } from './partners-promotion-actions'; import { partnersPromotionInitialState } from './partners-promotion-state'; export const partnersPromotionReducers = createReducer(partnersPromotionInitialState, builder => { - builder.addCase(loadPartnersPromoActions.submit, state => ({ - ...state, - promotion: createEntity(state.promotion.data, true) - })); - builder.addCase(loadPartnersPromoActions.success, (state, { payload }) => ({ - ...state, - promotion: createEntity(payload, false) - })); - builder.addCase(loadPartnersPromoActions.fail, (state, { payload }) => ({ - ...state, - promotion: createEntity(state.promotion.data, false, payload) - })); builder.addCase(togglePartnersPromotionAction, (state, { payload }) => ({ ...state, isEnabled: payload, diff --git a/src/store/partners-promotion/partners-promotion-selectors.ts b/src/store/partners-promotion/partners-promotion-selectors.ts index cc4ab956d..8e4c61f3d 100644 --- a/src/store/partners-promotion/partners-promotion-selectors.ts +++ b/src/store/partners-promotion/partners-promotion-selectors.ts @@ -1,8 +1,5 @@ import { useSelector } from '../selector'; -export const usePartnersPromoSelector = () => useSelector(state => state.partnersPromotion.promotion.data); -export const usePartnersPromoLoadingSelector = () => useSelector(state => state.partnersPromotion.promotion.isLoading); -export const usePartnersPromoErrorSelector = () => useSelector(state => state.partnersPromotion.promotion.error); export const useIsPartnersPromoEnabledSelector = () => useSelector(state => state.partnersPromotion.isEnabled); export const usePromotionHidingTimestampSelector = (id: string) => useSelector(({ partnersPromotion }) => partnersPromotion.promotionHidingTimestamps[id] ?? 0); diff --git a/src/store/partners-promotion/partners-promotion-state.mock.ts b/src/store/partners-promotion/partners-promotion-state.mock.ts index ccadca842..36aeb4cab 100644 --- a/src/store/partners-promotion/partners-promotion-state.mock.ts +++ b/src/store/partners-promotion/partners-promotion-state.mock.ts @@ -1,29 +1,6 @@ -import { createEntity } from 'src/store/create-entity'; - import type { PartnersPromotionState } from './partners-promotion-state'; -export const mockPartnersPromotion = { - body: '', - campaign_type: '', - copy: { - headline: '', - cta: '', - content: '' - }, - display_type: '', - div_id: '', - html: [], - id: '', - image: '', - link: '', - nonce: '', - text: '', - view_time_url: '', - view_url: '' -}; - export const mockPartnersPromotionState: PartnersPromotionState = { - promotion: createEntity(mockPartnersPromotion), isEnabled: false, promotionHidingTimestamps: {} }; diff --git a/src/store/partners-promotion/partners-promotion-state.ts b/src/store/partners-promotion/partners-promotion-state.ts index e27ef67cb..56ec7fa0f 100644 --- a/src/store/partners-promotion/partners-promotion-state.ts +++ b/src/store/partners-promotion/partners-promotion-state.ts @@ -1,18 +1,9 @@ -import { createEntity } from 'src/store/create-entity'; -import { OptimalPromotionType } from 'src/utils/optimal.utils'; - -import { LoadableEntityState } from '../types'; - -import { mockPartnersPromotion } from './partners-promotion-state.mock'; - export interface PartnersPromotionState { - promotion: LoadableEntityState; isEnabled: boolean; promotionHidingTimestamps: Record; } export const partnersPromotionInitialState: PartnersPromotionState = { - promotion: createEntity(mockPartnersPromotion), isEnabled: false, promotionHidingTimestamps: {} }; diff --git a/src/utils/optimal.utils.ts b/src/utils/optimal.utils.ts index c8451ff5b..0251cf2dd 100644 --- a/src/utils/optimal.utils.ts +++ b/src/utils/optimal.utils.ts @@ -1,10 +1,9 @@ -import { isEqual } from 'lodash-es'; import { useMemo } from 'react'; -import { mockPartnersPromotion } from 'src/store/partners-promotion/partners-promotion-state.mock'; - import { optimalApi } from '../api.service'; +import { isDefined } from './is-defined'; + export const AD_HIDING_TIMEOUT = 12 * 3600 * 1000; export enum OptimalPromotionAdType { @@ -35,11 +34,9 @@ type NormalPromotion = { export type OptimalPromotionType = EmptyPromotion | NormalPromotion; -export function useIsEmptyPromotion(promotion: OptimalPromotionType): promotion is EmptyPromotion { +export function useIsEmptyPromotion(promotion: OptimalPromotionType | nullish): promotion is EmptyPromotion { return useMemo( - () => - !('link' in promotion && 'image' in promotion && 'copy' in promotion) || - isEqual(mockPartnersPromotion, promotion), + () => isDefined(promotion) && !('link' in promotion && 'image' in promotion && 'copy' in promotion), [promotion] ); } From 8ddafa07da737d6a4f9985e57896c6b6e82abe49 Mon Sep 17 00:00:00 2001 From: Inokentii Mazhara Date: Wed, 28 Feb 2024 19:51:14 +0200 Subject: [PATCH 07/11] TW-1307 Refactoring according to comments --- .../activity-groups-list.tsx | 48 +++++--- src/components/hypelab-promotion/index.tsx | 13 ++- src/components/optimal-promotion/index.tsx | 6 +- src/hooks/use-element-is-seen.hook.ts | 21 ++-- src/hooks/use-internal-ads-analytics.hook.ts | 109 ++++-------------- ... => use-list-element-intersection.hook.ts} | 55 +++++---- .../use-outside-of-list-intersection.hook.ts | 72 ++++++++++++ .../promotion-carousel/promotion-carousel.tsx | 50 +++----- .../top-coins-table/top-tokens-table.tsx | 11 +- src/screens/notifications/notifications.tsx | 11 +- src/screens/wallet/token-list/token-list.tsx | 11 +- src/types/intersection-hook-ref.ts | 4 + src/utils/assets/hooks/tokens.ts | 2 +- src/utils/get-intersection-ratio.ts | 2 +- 14 files changed, 227 insertions(+), 188 deletions(-) rename src/hooks/{use-intersection-observation.hook.ts => use-list-element-intersection.hook.ts} (76%) create mode 100644 src/hooks/use-outside-of-list-intersection.hook.ts create mode 100644 src/types/intersection-hook-ref.ts diff --git a/src/components/activity-groups-list/activity-groups-list.tsx b/src/components/activity-groups-list/activity-groups-list.tsx index ae6da0deb..501ab6899 100644 --- a/src/components/activity-groups-list/activity-groups-list.tsx +++ b/src/components/activity-groups-list/activity-groups-list.tsx @@ -1,5 +1,5 @@ import { FlashList, ListRenderItem } from '@shopify/flash-list'; -import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, LayoutChangeEvent, Text, View } from 'react-native'; import { DataPlaceholder } from 'src/components/data-placeholder/data-placeholder'; @@ -9,6 +9,8 @@ import { emptyFn } from 'src/config/general'; import { useAdTemporaryHiding } from 'src/hooks/use-ad-temporary-hiding.hook'; import { useFakeRefreshControlProps } from 'src/hooks/use-fake-refresh-control-props.hook'; import { useInternalAdsAnalytics } from 'src/hooks/use-internal-ads-analytics.hook'; +import { useListElementIntersection } from 'src/hooks/use-list-element-intersection.hook'; +import { useOutsideOfListIntersection } from 'src/hooks/use-outside-of-list-intersection.hook'; import { ActivityGroup, emptyActivity } from 'src/interfaces/activity.interface'; import { useIsPartnersPromoEnabledSelector } from 'src/store/partners-promotion/partners-promotion-selectors'; import { isTheSameDay, isToday, isYesterday } from 'src/utils/date.utils'; @@ -52,15 +54,6 @@ export const ActivityGroupsList: FC = ({ const [promotionErrorOccurred, setPromotionErrorOccurred] = useState(false); const shouldShowPromotion = partnersPromotionEnabled && !promotionErrorOccurred && !isHiddenTemporarily; - const { - onListScroll, - onInsideScrollAdLayout, - onListLayoutChange, - onOutsideOfScrollAdLayout, - onAdLoad, - resetAdState - } = useInternalAdsAnalytics(pageName); - const keyExtractor = useCallback( (item: ListItem, index: number) => { const keyRoot = typeof item === 'string' ? item : item[0].hash; @@ -114,17 +107,37 @@ export const ActivityGroupsList: FC = ({ }, [activityGroups]); const shouldRenderList = sections.length > 0; - useEffect(() => resetAdState(), [resetAdState, shouldRenderList]); + const adRef = useRef(null); + const separateAdParentRef = useRef(null); + + const { onAdLoad, resetAdState, onIsVisible } = useInternalAdsAnalytics(pageName); + const { + onListScroll, + onElementLayoutChange: onListAdLayoutChange, + onListLayoutChange, + onUnmount: onListAdUnmount + } = useListElementIntersection(onIsVisible); + const { onElementOrParentLayout: onSeparateAdOrParentLayout, onUnmount: onSeparateAdUnmount } = + useOutsideOfListIntersection(separateAdParentRef, adRef, onIsVisible); + + useEffect(() => { + if (shouldRenderList) { + onSeparateAdUnmount(); + } else { + onListAdUnmount(); + } + resetAdState(); + }, [onListAdUnmount, onSeparateAdUnmount, resetAdState, shouldRenderList]); const handlePromotionLayout = useCallback( (e: LayoutChangeEvent) => { if (shouldRenderList) { - onInsideScrollAdLayout(e); + onListAdLayoutChange(e); } else { - onOutsideOfScrollAdLayout(e); + onSeparateAdOrParentLayout(); } }, - [onInsideScrollAdLayout, onOutsideOfScrollAdLayout, shouldRenderList] + [onListAdLayoutChange, onSeparateAdOrParentLayout, shouldRenderList] ); const Promotion = useMemo( @@ -134,6 +147,7 @@ export const ActivityGroupsList: FC = ({ id={PROMOTION_ID} style={styles.promotionItem} testID={ActivityGroupsListSelectors.promotion} + ref={adRef} onError={handlePromotionError} onLoad={onAdLoad} /> @@ -189,7 +203,11 @@ export const ActivityGroupsList: FC = ({ return ( <> - {shouldShowPromotion && {Promotion}} + {shouldShowPromotion && ( + + {Promotion} + + )} {loadingEnded ? ( ListEmptyComponent diff --git a/src/components/hypelab-promotion/index.tsx b/src/components/hypelab-promotion/index.tsx index f8a91e890..264f24bbc 100644 --- a/src/components/hypelab-promotion/index.tsx +++ b/src/components/hypelab-promotion/index.tsx @@ -18,11 +18,14 @@ import { AnalyticsEventCategory } from 'src/utils/analytics/analytics-event.enum import { useAnalytics } from 'src/utils/analytics/use-analytics.hook'; import { HYPELAB_AD_FRAME_URL, HYPELAB_NATIVE_PLACEMENT_SLUG, HYPELAB_SMALL_PLACEMENT_SLUG } from 'src/utils/env.utils'; import { useTimeout } from 'src/utils/hooks'; +import { isDefined } from 'src/utils/is-defined'; import { isString } from 'src/utils/is-string'; import { openUrl } from 'src/utils/linking'; import { useHypelabPromotionStyles } from './styles'; +const AD_CONTENT_RELATED_URL_SEARCH_PARAMS = ['campaign_slug', 'creative_set_slug', 'placement_slug']; + export const HypelabPromotion: FC = ({ variant, isVisible, @@ -74,8 +77,16 @@ export const HypelabPromotion: FC = ({ setAdFrameAspectRatio(message.width / message.height); break; case AdFrameMessageType.Ready: + const prevAdHrefSearchParams = isDefined(adHref) ? new URL(adHref).searchParams : new URLSearchParams(); + const newAdHrefSearchParams = new URL(message.ad.cta_url).searchParams; setAdHref(message.ad.cta_url); - onReady(); + if ( + AD_CONTENT_RELATED_URL_SEARCH_PARAMS.some( + paramName => prevAdHrefSearchParams.get(paramName) !== newAdHrefSearchParams.get(paramName) + ) + ) { + onReady(); + } break; case AdFrameMessageType.Error: onError(); diff --git a/src/components/optimal-promotion/index.tsx b/src/components/optimal-promotion/index.tsx index 98ceea7df..9c253caec 100644 --- a/src/components/optimal-promotion/index.tsx +++ b/src/components/optimal-promotion/index.tsx @@ -4,7 +4,6 @@ import useSWR from 'swr'; import { ImagePromotionView } from 'src/components/image-promotion-view'; import { TextPromotionView } from 'src/components/text-promotion-view'; -// import { PROMO_SYNC_INTERVAL } from 'src/config/fixed-times'; import { PromotionVariantEnum } from 'src/enums/promotion-variant.enum'; import { useCurrentAccountPkhSelector } from 'src/store/wallet/wallet-selectors'; import { SingleProviderPromotionProps } from 'src/types/promotion'; @@ -21,7 +20,6 @@ export const OptimalPromotion: FC { +export const useElementIsSeen = (isVisible: boolean, seenTimeoutDuration: number, shouldResetOnScreenBlur = true) => { const isFocused = useIsFocused(); const [isSeen, setIsSeen] = useState(false); const resetWasCalledRef = useRef(false); const isVisibleRef = useRef(isVisible); + const seenTimeoutRef = useRef(); useEffect(() => { if (shouldResetOnScreenBlur && !isFocused) { @@ -18,19 +21,23 @@ export const useElementIsSeen = (isVisible: boolean, seenTimeout: number, should } }, [isFocused, shouldResetOnScreenBlur]); + const clearSeenTimeout = useCallback(() => { + void (isDefined(seenTimeoutRef.current) && clearTimeout(seenTimeoutRef.current)); + }, []); + const updateIsSeen = useCallback(() => { isVisibleRef.current = isVisible && isFocused; if (isVisible && isFocused) { - const timeout = setTimeout(() => { + seenTimeoutRef.current = setTimeout(() => { if (isVisibleRef.current) { setIsSeen(true); } - }, seenTimeout); + }, seenTimeoutDuration); - return () => clearTimeout(timeout); + return () => clearSeenTimeout(); } - }, [isFocused, isVisible, seenTimeout]); + }, [isFocused, isVisible, seenTimeoutDuration, clearSeenTimeout]); const resetIsSeen = useCallback(() => { setIsSeen(false); @@ -45,5 +52,5 @@ export const useElementIsSeen = (isVisible: boolean, seenTimeout: number, should } }, [updateIsSeen, isSeen]); - return { isSeen, resetIsSeen }; + return { isSeen, resetIsSeen, clearSeenTimeout }; }; diff --git a/src/hooks/use-internal-ads-analytics.hook.ts b/src/hooks/use-internal-ads-analytics.hook.ts index 9bfd3842c..8fb27d194 100644 --- a/src/hooks/use-internal-ads-analytics.hook.ts +++ b/src/hooks/use-internal-ads-analytics.hook.ts @@ -1,40 +1,29 @@ -import { throttle } from 'lodash-es'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { LayoutChangeEvent } from 'react-native'; +import { useIsFocused } from '@react-navigation/native'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { PromotionProviderEnum } from 'src/enums/promotion-provider.enum'; import { AnalyticsEventCategory } from 'src/utils/analytics/analytics-event.enum'; import { useAnalytics } from 'src/utils/analytics/use-analytics.hook'; -import { getIntersectionRatio } from 'src/utils/get-intersection-ratio'; import { isDefined } from 'src/utils/is-defined'; import { useElementIsSeen } from './use-element-is-seen.hook'; -import { DEFAULT_INTERSECTION_THRESHOLD, Refs, useIntersectionObservation } from './use-intersection-observation.hook'; - -const DEFAULT_OUTSIDE_OF_SCROLL_AD_OFFSET = { x: 0, y: 0 }; /** - * This hook sends `Internal Ads Activity` once after the ad is seen + * This hook sends `Internal Ads Activity` after the ad is seen * @param page Page name - * @param refs If specified, the measurement of the ad element will be relative to `parent`; otherwise, the data from - * events will be used - * @param outsideOfScrollAdOffset Specify this value if the ad is not in a scroll view but has offset which is not - * detected by measuring layout - * @returns Callbacks for the ad element and the list which contains it if applicable + * @param initialAdAreaIsVisible Whether the ad area is initially visible assuming that the screen is focused + * @param seenTimeout If the element becomes visible and stays visible for this amount of time, it is considered seen. */ -export const useInternalAdsAnalytics = ( - page: string, - refs?: Refs, - outsideOfScrollAdOffset = DEFAULT_OUTSIDE_OF_SCROLL_AD_OFFSET, - seenTimeout = 200 -) => { +export const useInternalAdsAnalytics = (page: string, initialAdAreaIsVisible = false, seenTimeout = 200) => { + const isFocused = useIsFocused(); const { trackEvent } = useAnalytics(); - const [adAreaIsVisible, setAdAreaIsVisible] = useState(false); + const [adAreaIsVisible, setAdAreaIsVisible] = useState(initialAdAreaIsVisible); const [loadedPromotionProvider, setLoadedPromotionProvider] = useState(); - const { isSeen: adIsSeen, resetIsSeen: resetAdIsSeen } = useElementIsSeen( - adAreaIsVisible && isDefined(loadedPromotionProvider), - seenTimeout - ); + const { + isSeen: adIsSeen, + resetIsSeen: resetAdIsSeen, + clearSeenTimeout + } = useElementIsSeen(adAreaIsVisible && isDefined(loadedPromotionProvider), seenTimeout); const prevAdIsSeenRef = useRef(adIsSeen); useEffect(() => { @@ -47,58 +36,21 @@ export const useInternalAdsAnalytics = ( prevAdIsSeenRef.current = adIsSeen; }, [adIsSeen, trackEvent, loadedPromotionProvider, page]); - const refreshOutsideOfScrollAdVisible = useCallback(() => { - const { x: offsetX, y: offsetY } = outsideOfScrollAdOffset; - const element = refs?.element.current; - const parent = refs?.parent.current; + useEffect(() => void (!isFocused && setLoadedPromotionProvider(undefined)), [isFocused]); - if (!element || !parent) { - return; - } - - element.measureLayout( - parent, - (x, y, width, height) => { - parent.measure((_, _2, parentWidth, parentHeight) => { - setAdAreaIsVisible( - getIntersectionRatio( - { width: parentWidth, height: parentHeight }, - { x: x + offsetX, y: y + offsetY, width, height } - ) >= DEFAULT_INTERSECTION_THRESHOLD - ); - }); - }, - () => { - console.error('Failed to measure layout of the ad element relatively to the parent'); - } - ); - }, [refs, outsideOfScrollAdOffset]); - - const handleOutsideOfScrollAdOffset = useMemo( - () => throttle(() => refreshOutsideOfScrollAdVisible(), 100, { leading: false, trailing: true }), - [refreshOutsideOfScrollAdVisible] - ); - useEffect(handleOutsideOfScrollAdOffset, [handleOutsideOfScrollAdOffset]); + const resetAdState = useCallback(() => { + setLoadedPromotionProvider(undefined); + setAdAreaIsVisible(initialAdAreaIsVisible); + }, [initialAdAreaIsVisible]); - const onOutsideOfScrollAdLayout = useCallback( - (e: LayoutChangeEvent) => { - e.persist(); - const element = refs?.element.current; - const parent = refs?.parent.current; - if (element && parent) { - refreshOutsideOfScrollAdVisible(); - } else if (!refs) { - setAdAreaIsVisible(true); - } + const onIsVisible = useCallback( + (value: boolean) => { + void (!value && clearSeenTimeout()); + setAdAreaIsVisible(value); }, - [refreshOutsideOfScrollAdVisible, refs] + [clearSeenTimeout] ); - const resetAdState = useCallback(() => { - setAdAreaIsVisible(false); - setLoadedPromotionProvider(undefined); - }, []); - const onAdLoad = useCallback( (provider: PromotionProviderEnum) => { resetAdIsSeen(); @@ -107,18 +59,5 @@ export const useInternalAdsAnalytics = ( [setLoadedPromotionProvider, resetAdIsSeen] ); - const { - onListScroll, - onElementLayoutChange: onInsideScrollAdLayout, - onListLayoutChange - } = useIntersectionObservation(setAdAreaIsVisible, refs); - - return { - onListScroll, - onInsideScrollAdLayout, - onListLayoutChange, - onOutsideOfScrollAdLayout, - onAdLoad, - resetAdState - }; + return { onAdLoad, resetAdState, onIsVisible }; }; diff --git a/src/hooks/use-intersection-observation.hook.ts b/src/hooks/use-list-element-intersection.hook.ts similarity index 76% rename from src/hooks/use-intersection-observation.hook.ts rename to src/hooks/use-list-element-intersection.hook.ts index 01b3e2616..77f570b2a 100644 --- a/src/hooks/use-intersection-observation.hook.ts +++ b/src/hooks/use-list-element-intersection.hook.ts @@ -1,35 +1,39 @@ -import { throttle } from 'lodash-es'; -import { MutableRefObject, useCallback, useMemo, useRef } from 'react'; -import { LayoutChangeEvent, LayoutRectangle, NativeScrollEvent, NativeSyntheticEvent, View } from 'react-native'; +import { noop, throttle } from 'lodash-es'; +import { useCallback, useMemo, useRef } from 'react'; +import { LayoutChangeEvent, LayoutRectangle, NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; +import { IntersectionHookRef } from 'src/types/intersection-hook-ref'; import { getIntersectionRatio } from 'src/utils/get-intersection-ratio'; import { isDefined } from 'src/utils/is-defined'; -export interface Refs { - /** The parent of the element or the list which contains the element */ - parent: MutableRefObject; - element: MutableRefObject; -} +const DEFAULT_INTERSECTION_THRESHOLD = 0.5; -export const DEFAULT_INTERSECTION_THRESHOLD = 0.5; +interface IntersectionHookRefs { + parent: IntersectionHookRef; + element: IntersectionHookRef; +} /** * A hook for observing the intersection of a component inside a list. `react-native-intersection-observer` exists * for similar purposes but it is incompatible with our components. * @param onIntersectChange A callback to be called when the intersection state changes * @param refs If specified, the measurement of the element will be relative to `parent`; otherwise, the data from - * events will be used + * events will be used. Do without it whenever possible * @param threshold The part of the child element area that should be visible for the intersection to be considered - * @returns Callbacks for the list and the element + * @returns Callbacks for the list and the element and current intersection state */ -export const useIntersectionObservation = ( - onIntersectChange: (value: boolean) => void, - refs?: Refs, +export const useListElementIntersection = ( + onIntersectChange: (value: boolean) => void = noop, + refs?: IntersectionHookRefs, threshold = DEFAULT_INTERSECTION_THRESHOLD ) => { const lastLayoutRectangleRef = useRef(); const lastNativeScrollConfigRef = useRef>(); - const lastIsIntersectedRef = useRef(); + const isIntersectedRef = useRef(); + + const onUnmount = useCallback(() => { + isIntersectedRef.current = false; + }, []); const refreshIsIntersected = useCallback(() => { const layoutRectangleWithoutScroll = lastLayoutRectangleRef.current; @@ -47,10 +51,10 @@ export const useIntersectionObservation = ( width: layoutRectangleWithoutScroll.width, height: layoutRectangleWithoutScroll.height }; - const isIntersected = getIntersectionRatio(listLayoutMeasurement, layoutRectangle) >= threshold; - if (lastIsIntersectedRef.current !== isIntersected) { - lastIsIntersectedRef.current = isIntersected; - onIntersectChange(isIntersected); + const newIsIntersected = getIntersectionRatio(listLayoutMeasurement, layoutRectangle) >= threshold; + if (isIntersectedRef.current !== newIsIntersected) { + isIntersectedRef.current = newIsIntersected; + onIntersectChange(newIsIntersected); } }, [onIntersectChange, threshold]); const refreshIsIntersectedWithMeasurements = useCallback(() => { @@ -72,9 +76,7 @@ export const useIntersectionObservation = ( refreshIsIntersected(); }, - () => { - console.error('Failed to measure element layout relatively to the parent'); - } + () => console.error('Failed to measure element layout relatively to the parent') ); }, [refreshIsIntersected, refs]); @@ -93,7 +95,7 @@ export const useIntersectionObservation = ( refreshIsIntersected(); } }, - 100, + 10, { leading: false, trailing: true } ), [refreshIsIntersected, refreshIsIntersectedWithMeasurements, refs] @@ -143,5 +145,10 @@ export const useIntersectionObservation = ( [refreshIsIntersected, refreshIsIntersectedWithMeasurements, refs] ); - return { onListScroll, onElementLayoutChange, onListLayoutChange }; + return { + onListScroll, + onElementLayoutChange, + onListLayoutChange, + onUnmount + }; }; diff --git a/src/hooks/use-outside-of-list-intersection.hook.ts b/src/hooks/use-outside-of-list-intersection.hook.ts new file mode 100644 index 000000000..eb294a4e7 --- /dev/null +++ b/src/hooks/use-outside-of-list-intersection.hook.ts @@ -0,0 +1,72 @@ +import { noop } from 'lodash-es'; +import { useCallback, useRef, useState } from 'react'; +import { Dimensions, LayoutRectangle } from 'react-native'; + +import { IntersectionHookRef } from 'src/types/intersection-hook-ref'; +import { ParentSize, getIntersectionRatio } from 'src/utils/get-intersection-ratio'; + +const DEFAULT_INTERSECTION_THRESHOLD = 0.5; + +/** + * A hook for observing the intersection of a component with its parent or app window. + * @param parentRef If specified, intersection with the element, which is available by this ref, is observed; + * otherwise, intersection with app window is observed + * @param elementRef Reference to the element + * @param onIntersectChange A callback to be called when the intersection state changes + * @param threshold The part of the child element area that should be visible for the intersection to be considered + * @returns Callbacks for elements and current intersection state + */ +export const useOutsideOfListIntersection = ( + parentRef: IntersectionHookRef | undefined, + elementRef: IntersectionHookRef, + onIntersectChange: (value: boolean) => void = noop, + threshold = DEFAULT_INTERSECTION_THRESHOLD +) => { + const [isIntersected, setIsIntersected] = useState(false); + const firstTimeRef = useRef(true); + + const onUnmount = useCallback(() => { + firstTimeRef.current = true; + setIsIntersected(false); + }, []); + + const onElementOrParentLayout = useCallback(() => { + const element = elementRef.current; + const parent = parentRef?.current; + + const handleNewDimensions = (parentSize: ParentSize, elementRect: LayoutRectangle) => { + const newIsIntersected = getIntersectionRatio(parentSize, elementRect) >= threshold; + if (isIntersected !== newIsIntersected || firstTimeRef.current) { + firstTimeRef.current = false; + setIsIntersected(newIsIntersected); + onIntersectChange(newIsIntersected); + } + }; + + if (element && !parentRef) { + element.measureInWindow((x, y, width, height) => { + handleNewDimensions(Dimensions.get('window'), { x, y, width, height }); + }); + + return; + } + + if (!element || !parent) { + return; + } + + element.measureLayout( + parent, + (x, y, width, height) => { + parent.measure((_, _2, parentWidth, parentHeight) => { + handleNewDimensions({ width: parentWidth, height: parentHeight }, { x, y, width, height }); + }); + }, + () => { + console.error('Failed to measure layout of the ad element relatively to the parent'); + } + ); + }, [isIntersected, onIntersectChange, elementRef, parentRef, threshold]); + + return { isIntersected, onElementOrParentLayout, onUnmount }; +}; diff --git a/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx b/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx index 56cd0e377..6b1478981 100644 --- a/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx +++ b/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx @@ -1,5 +1,4 @@ -import { throttle } from 'lodash-es'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { View } from 'react-native'; import Carousel from 'react-native-reanimated-carousel'; import type { ILayoutConfig } from 'react-native-reanimated-carousel/lib/typescript/layouts/parallax'; @@ -23,13 +22,10 @@ const PROMOTION_ID = 'carousel-promotion'; export const PromotionCarousel = () => { const activePromotion = useActivePromotionSelector(); const styles = usePromotionCarouselStyles(); - const [promotionOffset, setPromotionOffset] = useState({ x: 0, y: 0 }); const [promotionErrorOccurred, setPromotionErrorOccurred] = useState(false); const partnersPromoShown = useIsPartnersPromoShown(PROMOTION_ID); - const layoutRef = useRef(null); - const adRef = useRef(null); - const refs = useMemo(() => ({ parent: layoutRef, element: adRef }), [layoutRef, adRef]); - const { onOutsideOfScrollAdLayout, onAdLoad } = useInternalAdsAnalytics('DApps', refs, promotionOffset, 500); + const shouldShowPartnersPromotion = partnersPromoShown && !promotionErrorOccurred; + const { onAdLoad, onIsVisible } = useInternalAdsAnalytics('DApps', true, 500); const data = useMemo>(() => { const result = [...COMMON_PROMOTION_CAROUSEL_DATA]; @@ -45,24 +41,27 @@ export const PromotionCarousel = () => { ); } - if (partnersPromoShown && !promotionErrorOccurred) { + if (shouldShowPartnersPromotion) { result.unshift( setPromotionErrorOccurred(true)} onLoad={onAdLoad} - onLayout={onOutsideOfScrollAdLayout} /> ); } return result; - }, [activePromotion, onAdLoad, onOutsideOfScrollAdLayout, partnersPromoShown, promotionErrorOccurred, styles]); + }, [activePromotion, onAdLoad, shouldShowPartnersPromotion, styles]); + + const handleSnapToItem = useCallback( + (index: number) => onIsVisible(shouldShowPartnersPromotion && index === 0), + [onIsVisible, shouldShowPartnersPromotion] + ); const height = formatSize(112); const { layoutWidth, handleLayout } = useLayoutSizes(); @@ -77,35 +76,12 @@ export const PromotionCarousel = () => { [] ); - const handleProgressChange = useMemo( - () => - throttle( - (offsetProgress: number, absoluteProgress: number) => { - let actualOffset = offsetProgress; - if (absoluteProgress > 1) { - const offsetPerSlide = Math.abs(offsetProgress / absoluteProgress); - const totalLength = offsetPerSlide * data.length; - const pivotX = totalLength / 2; - if (offsetProgress > pivotX) { - actualOffset = offsetProgress - totalLength; - } else if (offsetProgress < -pivotX) { - actualOffset = offsetProgress + totalLength; - } - } - setPromotionOffset({ x: actualOffset, y: 0 }); - }, - 100, - { leading: false, trailing: true } - ), - [data.length] - ); - const renderItem = useCallback((info: CarouselRenderItemInfo) => info.item, []); const style = useMemo(() => [styles.container, { height }], [styles.container, height]); return ( - + {flooredWidth > 0 ? ( { height={height} scrollAnimationDuration={1200} renderItem={renderItem} - onProgressChange={handleProgressChange} + onSnapToItem={handleSnapToItem} /> ) : null} diff --git a/src/screens/market/top-coins-table/top-tokens-table.tsx b/src/screens/market/top-coins-table/top-tokens-table.tsx index 5e31750b4..b82532a95 100644 --- a/src/screens/market/top-coins-table/top-tokens-table.tsx +++ b/src/screens/market/top-coins-table/top-tokens-table.tsx @@ -7,6 +7,7 @@ import { PromotionItem } from 'src/components/promotion-item'; import { useFakeRefreshControlProps } from 'src/hooks/use-fake-refresh-control-props.hook'; import { useFilteredMarketTokens } from 'src/hooks/use-filtered-market-tokens.hook'; import { useInternalAdsAnalytics } from 'src/hooks/use-internal-ads-analytics.hook'; +import { useOutsideOfListIntersection } from 'src/hooks/use-outside-of-list-intersection.hook'; import { MarketToken } from 'src/store/market/market.interfaces'; import { formatSize } from 'src/styles/format-size'; @@ -35,7 +36,10 @@ export const TopTokensTable = () => { const ref = useRef>(null); const [promotionErrorOccurred, setPromotionErrorOccurred] = useState(false); - const { onOutsideOfScrollAdLayout, onAdLoad } = useInternalAdsAnalytics('Market'); + const adRef = useRef(null); + const adParentRef = useRef(null); + const { onAdLoad, onIsVisible } = useInternalAdsAnalytics('Market', false); + const { onElementOrParentLayout } = useOutsideOfListIntersection(adParentRef, adRef, onIsVisible); const fakeRefreshControlProps = useFakeRefreshControlProps(); @@ -61,14 +65,15 @@ export const TopTokensTable = () => { return ( {!promotionErrorOccurred && ( - + )} diff --git a/src/screens/notifications/notifications.tsx b/src/screens/notifications/notifications.tsx index e3e266113..2598dd796 100644 --- a/src/screens/notifications/notifications.tsx +++ b/src/screens/notifications/notifications.tsx @@ -1,11 +1,13 @@ import { FlashList, ListRenderItem } from '@shopify/flash-list'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { View } from 'react-native'; import { useDispatch } from 'react-redux'; import { DataPlaceholder } from 'src/components/data-placeholder/data-placeholder'; import { HorizontalBorder } from 'src/components/horizontal-border'; import { PromotionItem } from 'src/components/promotion-item'; import { useInternalAdsAnalytics } from 'src/hooks/use-internal-ads-analytics.hook'; +import { useOutsideOfListIntersection } from 'src/hooks/use-outside-of-list-intersection.hook'; import { useIsPartnersPromoShown } from 'src/hooks/use-partners-promo'; import { NotificationInterface } from 'src/interfaces/notification.interface'; import { ScreensEnum } from 'src/navigator/enums/screens.enum'; @@ -34,7 +36,9 @@ export const Notifications = () => { const partnersPromoShown = useIsPartnersPromoShown(PROMOTION_ID); const [promotionErrorOccurred, setPromotionErrorOccurred] = useState(false); - const { onOutsideOfScrollAdLayout, onAdLoad } = useInternalAdsAnalytics('Notifications'); + const adRef = useRef(null); + const { onAdLoad, onIsVisible } = useInternalAdsAnalytics('Notifications'); + const { onElementOrParentLayout } = useOutsideOfListIntersection(undefined, adRef, onIsVisible); const handlePromotionItemError = useCallback(() => setPromotionErrorOccurred(true), []); @@ -54,8 +58,9 @@ export const Notifications = () => { id={PROMOTION_ID} testID={NotificationsSelectors.promotion} style={NotificationsStyles.ads} + ref={adRef} onError={handlePromotionItemError} - onLayout={onOutsideOfScrollAdLayout} + onLayout={onElementOrParentLayout} onLoad={onAdLoad} /> diff --git a/src/screens/wallet/token-list/token-list.tsx b/src/screens/wallet/token-list/token-list.tsx index adef42ea0..329749b2f 100644 --- a/src/screens/wallet/token-list/token-list.tsx +++ b/src/screens/wallet/token-list/token-list.tsx @@ -19,6 +19,7 @@ import { PromotionVariantEnum } from 'src/enums/promotion-variant.enum'; import { useFakeRefreshControlProps } from 'src/hooks/use-fake-refresh-control-props.hook'; import { useFilteredAssetsList } from 'src/hooks/use-filtered-assets-list.hook'; import { useInternalAdsAnalytics } from 'src/hooks/use-internal-ads-analytics.hook'; +import { useListElementIntersection } from 'src/hooks/use-list-element-intersection.hook'; import { useNetworkInfo } from 'src/hooks/use-network-info.hook'; import { useIsPartnersPromoShown } from 'src/hooks/use-partners-promo'; import { ScreensEnum } from 'src/navigator/enums/screens.enum'; @@ -89,10 +90,8 @@ export const TokensList = memo(() => { const partnersPromoShown = useIsPartnersPromoShown(PROMOTION_ID); const { isTezosNode } = useNetworkInfo(); - const { onListScroll, onInsideScrollAdLayout, onListLayoutChange, onAdLoad } = useInternalAdsAnalytics( - 'Home page', - refs - ); + const { onAdLoad, onIsVisible } = useInternalAdsAnalytics('Home page'); + const { onListScroll, onElementLayoutChange, onListLayoutChange } = useListElementIntersection(onIsVisible, refs); const handleHideZeroBalanceChange = useCallback((value: boolean) => { dispatch(setZeroBalancesShown(value)); @@ -164,7 +163,7 @@ export const TokensList = memo(() => { ({ item }) => { if (item === AD_PLACEHOLDER) { return ( - + { return ; }, - [apyRates, handlePromotionError, onAdLoad, onInsideScrollAdLayout, styles] + [apyRates, handlePromotionError, onAdLoad, onElementLayoutChange, styles] ); useEffect(() => void flashListRef.current?.scrollToOffset({ animated: true, offset: 0 }), [publicKeyHash]); diff --git a/src/types/intersection-hook-ref.ts b/src/types/intersection-hook-ref.ts new file mode 100644 index 000000000..b551900de --- /dev/null +++ b/src/types/intersection-hook-ref.ts @@ -0,0 +1,4 @@ +import { MutableRefObject } from 'react'; +import { View } from 'react-native'; + +export type IntersectionHookRef = MutableRefObject; diff --git a/src/utils/assets/hooks/tokens.ts b/src/utils/assets/hooks/tokens.ts index 6ab15ced4..4e3722906 100644 --- a/src/utils/assets/hooks/tokens.ts +++ b/src/utils/assets/hooks/tokens.ts @@ -51,7 +51,7 @@ export const useAccountTokenBySlug = (slug: string): UsableAccountAsset | undefi const token = accountTokens.find(t => t.slug === slug); if (!metadata || !token) { - console.warn(`Token for slug '${slug}' is not ready`); + // console.warn(`Token for slug '${slug}' is not ready`); return undefined; } diff --git a/src/utils/get-intersection-ratio.ts b/src/utils/get-intersection-ratio.ts index 1461292b5..4f16eb1ef 100644 --- a/src/utils/get-intersection-ratio.ts +++ b/src/utils/get-intersection-ratio.ts @@ -1,7 +1,7 @@ import { clamp } from 'lodash-es'; import { LayoutRectangle } from 'react-native'; -interface ParentSize { +export interface ParentSize { width: number; height: number; } From 1043355e010a1da0b06d3ffb59fa587311bcde8d Mon Sep 17 00:00:00 2001 From: Inokentii Mazhara Date: Thu, 29 Feb 2024 11:28:57 +0200 Subject: [PATCH 08/11] TW-1307 Remove unused exports --- src/components/activity-groups-list/activity-groups-list.tsx | 2 +- src/screens/market/top-coins-table/top-tokens-table.tsx | 2 +- src/utils/optimal.utils.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/activity-groups-list/activity-groups-list.tsx b/src/components/activity-groups-list/activity-groups-list.tsx index 501ab6899..ae5383b28 100644 --- a/src/components/activity-groups-list/activity-groups-list.tsx +++ b/src/components/activity-groups-list/activity-groups-list.tsx @@ -27,7 +27,7 @@ const getItemType = (item: ListItem) => (typeof item === 'string' ? 'sectionHead const ListEmptyComponent = ; const AVERAGE_ITEM_HEIGHT = 150; -export const PROMOTION_ID = 'activities-promotion'; +const PROMOTION_ID = 'activities-promotion'; interface Props { activityGroups: ActivityGroup[]; diff --git a/src/screens/market/top-coins-table/top-tokens-table.tsx b/src/screens/market/top-coins-table/top-tokens-table.tsx index b82532a95..0bc501a79 100644 --- a/src/screens/market/top-coins-table/top-tokens-table.tsx +++ b/src/screens/market/top-coins-table/top-tokens-table.tsx @@ -21,7 +21,7 @@ import { useTopTokensTableStyles } from './top-tokens-table.styles'; const renderItem: ListRenderItem = ({ item }) => ; const keyExtractor = (item: MarketToken) => item.id; -export const PROMOTION_ID = 'market-promotion'; +const PROMOTION_ID = 'market-promotion'; export const TopTokensTable = () => { const styles = useTopTokensTableStyles(); diff --git a/src/utils/optimal.utils.ts b/src/utils/optimal.utils.ts index 0251cf2dd..6a0ff7e00 100644 --- a/src/utils/optimal.utils.ts +++ b/src/utils/optimal.utils.ts @@ -32,7 +32,7 @@ type NormalPromotion = { view_url: string; }; -export type OptimalPromotionType = EmptyPromotion | NormalPromotion; +type OptimalPromotionType = EmptyPromotion | NormalPromotion; export function useIsEmptyPromotion(promotion: OptimalPromotionType | nullish): promotion is EmptyPromotion { return useMemo( From 066c5343344a6c2b0854410f373863f83eb00796 Mon Sep 17 00:00:00 2001 From: Inokentii Mazhara Date: Thu, 29 Feb 2024 11:52:57 +0200 Subject: [PATCH 09/11] TW-1307 Revert commenting out a warning --- src/utils/assets/hooks/tokens.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/assets/hooks/tokens.ts b/src/utils/assets/hooks/tokens.ts index 4e3722906..6ab15ced4 100644 --- a/src/utils/assets/hooks/tokens.ts +++ b/src/utils/assets/hooks/tokens.ts @@ -51,7 +51,7 @@ export const useAccountTokenBySlug = (slug: string): UsableAccountAsset | undefi const token = accountTokens.find(t => t.slug === slug); if (!metadata || !token) { - // console.warn(`Token for slug '${slug}' is not ready`); + console.warn(`Token for slug '${slug}' is not ready`); return undefined; } From beeefdb6db02b701e471b13d469bdc608158c685 Mon Sep 17 00:00:00 2001 From: Inokentii Mazhara Date: Thu, 29 Feb 2024 12:58:38 +0200 Subject: [PATCH 10/11] TW-1307 Additional changes according to the comments --- src/components/optimal-promotion/index.tsx | 6 +++--- .../d-apps/promotion-carousel/promotion-carousel.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/optimal-promotion/index.tsx b/src/components/optimal-promotion/index.tsx index 9c253caec..45d062d97 100644 --- a/src/components/optimal-promotion/index.tsx +++ b/src/components/optimal-promotion/index.tsx @@ -7,6 +7,7 @@ import { TextPromotionView } from 'src/components/text-promotion-view'; import { PromotionVariantEnum } from 'src/enums/promotion-variant.enum'; import { useCurrentAccountPkhSelector } from 'src/store/wallet/wallet-selectors'; import { SingleProviderPromotionProps } from 'src/types/promotion'; +import { useDidMount } from 'src/utils/hooks/use-did-mount'; import { isDefined } from 'src/utils/is-defined'; import { getOptimalPromotion, OptimalPromotionAdType, useIsEmptyPromotion } from 'src/utils/optimal.utils'; @@ -45,12 +46,11 @@ export const OptimalPromotion: FC { + useDidMount(() => { if (!isValidating) { mutate(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }); useEffect(() => { if (isDefined(error) || promotionIsEmpty) { diff --git a/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx b/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx index 6b1478981..bf33b3115 100644 --- a/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx +++ b/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx @@ -48,7 +48,7 @@ export const PromotionCarousel = () => { testID={PromotionCarouselSelectors.optimalPromotionBanner} shouldShowCloseButton={false} style={styles.promotionItem} - shouldTryHypelabAd + shouldTryHypelabAd={false} onError={() => setPromotionErrorOccurred(true)} onLoad={onAdLoad} /> From 279096d28864652e09a64b63fb46c823d03ec460 Mon Sep 17 00:00:00 2001 From: Inokentii Mazhara Date: Fri, 1 Mar 2024 16:07:25 +0200 Subject: [PATCH 11/11] TW-1307 Fix the bugs that are specified in the task --- .../activity-groups-list.styles.ts | 4 +- .../activity-groups-list.tsx | 4 +- src/components/hypelab-promotion/index.tsx | 73 ++++++++++++------- .../promotion-carousel.styles.ts | 6 +- .../promotion-carousel/promotion-carousel.tsx | 20 ++--- 5 files changed, 65 insertions(+), 42 deletions(-) diff --git a/src/components/activity-groups-list/activity-groups-list.styles.ts b/src/components/activity-groups-list/activity-groups-list.styles.ts index 6598f7bd3..f9e7e2d03 100644 --- a/src/components/activity-groups-list/activity-groups-list.styles.ts +++ b/src/components/activity-groups-list/activity-groups-list.styles.ts @@ -36,7 +36,7 @@ export const useActivityGroupsListStyles = createUseStyles(({ colors, typography additionalLoader: { paddingTop: formatSize(16) }, - promotionItem: { - width: formatSize(343) + listPromotionItem: { + marginRight: formatSize(16) } })); diff --git a/src/components/activity-groups-list/activity-groups-list.tsx b/src/components/activity-groups-list/activity-groups-list.tsx index ae5383b28..a7c1d37f0 100644 --- a/src/components/activity-groups-list/activity-groups-list.tsx +++ b/src/components/activity-groups-list/activity-groups-list.tsx @@ -145,7 +145,7 @@ export const ActivityGroupsList: FC = ({ = ({ /> ), - [styles, handlePromotionLayout, handlePromotionError, onAdLoad] + [styles, shouldRenderList, handlePromotionLayout, handlePromotionError, onAdLoad] ); const renderItem: ListRenderItem = useCallback( diff --git a/src/components/hypelab-promotion/index.tsx b/src/components/hypelab-promotion/index.tsx index 264f24bbc..0b22d5981 100644 --- a/src/components/hypelab-promotion/index.tsx +++ b/src/components/hypelab-promotion/index.tsx @@ -1,5 +1,5 @@ -import React, { FC, useCallback, useMemo, useState } from 'react'; -import { View } from 'react-native'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { LayoutChangeEvent, LayoutRectangle, View } from 'react-native'; import { WebView, WebViewMessageEvent } from 'react-native-webview'; import { Icon } from 'src/components/icon/icon'; @@ -7,6 +7,7 @@ import { IconNameEnum } from 'src/components/icon/icon-name.enum'; import { ImagePromotionView } from 'src/components/image-promotion-view'; import { TextPromotionItemSelectors } from 'src/components/text-promotion-view/selectors'; import { TouchableWithAnalytics } from 'src/components/touchable-with-analytics'; +import { layoutScale } from 'src/config/styles'; import { AdFrameMessageType } from 'src/enums/ad-frame-message-type.enum'; import { PromotionVariantEnum } from 'src/enums/promotion-variant.enum'; import { ThemesEnum } from 'src/interfaces/theme.enum'; @@ -41,21 +42,42 @@ export const HypelabPromotion: FC = ({ const styles = useHypelabPromotionStyles(); const theme = useThemeSelector(); const { trackEvent } = useAnalytics(); - const [adFrameAspectRatio, setAdFrameAspectRatio] = useState(359 / 80); const [adHref, setAdHref] = useState(); + + const [layoutRect, setLayoutRect] = useState(); + const initialSize = useMemo(() => { + if (isImageAd) { + return { w: 320, h: 50 }; + } + + return layoutRect ? { w: Math.round(layoutRect.width / layoutScale), h: 80 } : undefined; + }, [isImageAd, layoutRect]); + const [size, setSize] = useState(initialSize); + useEffect(() => void (initialSize && setSize(prevSize => prevSize ?? initialSize)), [initialSize]); + const adFrameSource = useMemo(() => { const placementSlug = isImageAd ? HYPELAB_SMALL_PLACEMENT_SLUG : HYPELAB_NATIVE_PLACEMENT_SLUG; const origin = theme === ThemesEnum.dark ? 'mobile-dark' : 'mobile-light'; - const size = isImageAd ? { w: '320', h: '50' } : { w: '359', h: '80' }; + + if (!initialSize) { + return undefined; + } + const searchParams = new URLSearchParams({ p: placementSlug, o: origin, - vw: formatSize(Number(size.w)).toString(), - ...size + vw: formatSize(Number(initialSize.w)).toString(), + w: Number(initialSize.w).toString(), + h: Number(initialSize.h).toString() }); return { uri: `${HYPELAB_AD_FRAME_URL}/?${searchParams.toString()}` }; - }, [isImageAd, theme]); + }, [isImageAd, initialSize, theme]); + + const handleMainLayout = useCallback((e: LayoutChangeEvent) => { + e.persist(); + setLayoutRect(e.nativeEvent.layout); + }, []); useTimeout( () => { @@ -74,7 +96,7 @@ export const HypelabPromotion: FC = ({ switch (message.type) { case AdFrameMessageType.Resize: - setAdFrameAspectRatio(message.width / message.height); + setSize({ w: Math.round(message.width / layoutScale), h: Math.round(message.height / layoutScale) }); break; case AdFrameMessageType.Ready: const prevAdHrefSearchParams = isDefined(adHref) ? new URL(adHref).searchParams : new URLSearchParams(); @@ -104,6 +126,16 @@ export const HypelabPromotion: FC = ({ [adHref, onError, onReady, testID, testIDProperties, trackEvent] ); + const webViewCommonProps = { + source: adFrameSource, + onError: onError, + onMessage: handleAdFrameMessage, + webviewDebuggingEnabled: __DEV__, + scrollEnabled: false, + scalesPageToFit: false, + textZoom: 100 + }; + if (isImageAd) { return ( = ({ {...testIDProps} > - + ); } return ( - - + + {adFrameSource && size && ( + + )} {shouldShowCloseButton && ( ({ container: { marginVertical: formatSize(12) }, + promotionItemWrapper: { + width: '100%', + justifyContent: 'center', + alignItems: 'center' + }, promotionItem: { backgroundColor: colors.pageBG, - marginLeft: formatSize(16), width: formatSize(343), shadowOpacity: 0, elevation: 0 diff --git a/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx b/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx index bf33b3115..6eb1cd72f 100644 --- a/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx +++ b/src/screens/d-apps/promotion-carousel/promotion-carousel.tsx @@ -43,15 +43,17 @@ export const PromotionCarousel = () => { if (shouldShowPartnersPromotion) { result.unshift( - setPromotionErrorOccurred(true)} - onLoad={onAdLoad} - /> + + setPromotionErrorOccurred(true)} + onLoad={onAdLoad} + /> + ); }