Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(llm): ✨ log content card impression when 50% of the card is shown #8576

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/large-books-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"live-mobile": minor
---

Log content card impression when 50% of the card is shown
7 changes: 6 additions & 1 deletion apps/ledger-live-mobile/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,12 @@ module.exports = {
"react/jsx-key": "warn", // TODO: delete to make it an error when we are ready
"react/prop-types": "off",
"react-hooks/rules-of-hooks": "error", // Checks rules of Hooks
"react-hooks/exhaustive-deps": "error", // Checks effect dependencies
"react-hooks/exhaustive-deps": [
"error", // Checks effect dependencies
{
additionalHooks: "useInViewContext",
},
],
"jsx-a11y/no-autofocus": "off",
"jsx-a11y/anchor-is-valid": [
"error",
Expand Down
3 changes: 2 additions & 1 deletion apps/ledger-live-mobile/src/AppProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import SnackbarContainer from "~/screens/NotificationCenter/Snackbar/SnackbarCon
import PostOnboardingProviderWrapped from "~/logic/postOnboarding/PostOnboardingProviderWrapped";
import { CounterValuesStateRaw } from "@ledgerhq/live-countervalues/types";
import { CountervaluesMarketcap } from "@ledgerhq/live-countervalues-react/index";
import { InViewContextProvider } from "LLM/contexts/InViewContext";
import { WalletSyncProvider } from "LLM/features/WalletSync/components/WalletSyncContext";
import { AppDataStorageProvider } from "~/hooks/storageProvider/useAppDataStorage";

Expand All @@ -37,7 +38,7 @@ function AppProviders({ initialCountervalues, children }: AppProvidersProps) {
<NotificationsProvider>
<SnackbarContainer />
<NftMetadataProvider getCurrencyBridge={getCurrencyBridge}>
{children}
<InViewContextProvider>{children}</InViewContextProvider>
</NftMetadataProvider>
</NotificationsProvider>
</ToastProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { memo, useCallback, useEffect } from "react";
import React, { memo, useCallback } from "react";
import { Linking } from "react-native";
import { Flex, FullBackgroundCard } from "@ledgerhq/native-ui";
import { useTheme } from "styled-components/native";
Expand All @@ -15,15 +15,7 @@ type CarouselCardProps = {

const CarouselCard = ({ id, cardProps, index, width }: CarouselCardProps) => {
const { theme } = useTheme();
const { logClickCard, logImpressionCard, dismissCard, trackContentCardEvent } =
useDynamicContent();

useEffect(() => {
if (cardProps) {
// Notify Braze that the card has been displayed to the user
logImpressionCard(cardProps.id);
}
}, [cardProps, logImpressionCard]);
const { logClickCard, dismissCard, trackContentCardEvent } = useDynamicContent();

const onPress = useCallback(() => {
if (!cardProps) return;
Expand Down
11 changes: 4 additions & 7 deletions apps/ledger-live-mobile/src/components/Carousel/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { memo, useMemo, useCallback, useRef, useState } from "react";
import { NativeScrollEvent, NativeSyntheticEvent, ScrollView } from "react-native";
import LogContentCardWrapper from "LLM/features/DynamicContent/components/LogContentCardWrapper";
import useDynamicContent from "~/dynamicContent/useDynamicContent";
import { width } from "~/helpers/normalizeSize";
import CarouselCard from "./CarouselCard";
Expand Down Expand Up @@ -48,13 +49,9 @@ const Carousel = () => {
decelerationRate={"fast"}
>
{walletCardsDisplayed.map((cardProps, index) => (
<CarouselCard
key={cardProps.id + index}
id={cardProps.id}
cardProps={cardProps}
index={index}
width={cardsWidth}
/>
<LogContentCardWrapper key={cardProps.id + index} id={cardProps.id}>
<CarouselCard id={cardProps.id} cardProps={cardProps} index={index} width={cardsWidth} />
</LogContentCardWrapper>
))}
</ScrollView>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React, { useRef, useState } from "react";
import React, { useCallback, useRef, useState } from "react";
import {
FlatList,
ListRenderItemInfo,
NativeScrollEvent,
NativeSyntheticEvent,
View,
ViewToken,
useWindowDimensions,
} from "react-native";
import Animated, { Layout, SlideInRight } from "react-native-reanimated";
Expand All @@ -13,6 +14,8 @@ import { ContentLayoutBuilder } from "~/contentCards/layouts/utils";
import Pagination from "./pagination";
import { ContentCardItem } from "~/contentCards/cards/types";
import { WidthFactor } from "~/contentCards/layouts/types";
import useDynamicContent from "~/dynamicContent/useDynamicContent";
import { useInViewContext } from "LLM/contexts/InViewContext";

type Props = {
styles?: {
Expand Down Expand Up @@ -52,8 +55,30 @@ const Carousel = ContentLayoutBuilder<Props>(({ items, styles: _styles = default
if (newIndex !== carouselIndex) setCarouselIndex(newIndex);
};

const viewRef = useRef<View>(null);
const isInViewRef = useRef(false);
const visibleCardsRef = useRef<string[]>([]);
const { logImpressionCard } = useDynamicContent();
useInViewContext(
({ isInView }) => {
isInViewRef.current = isInView;
if (isInView) visibleCardsRef.current.forEach(logImpressionCard);
},
[logImpressionCard],
viewRef,
);
const handleViewableItemsChanged = useCallback(
({ viewableItems }: { viewableItems: ViewToken<ContentCardItem>[] }) => {
const visibleCards = viewableItems.map(({ item }) => item.props.metadata.id);
const newlyVisibleCards = visibleCards.filter(id => !visibleCardsRef.current.includes(id));
visibleCardsRef.current = visibleCards;
if (isInViewRef.current) newlyVisibleCards.forEach(logImpressionCard);
},
[logImpressionCard],
);

return (
<View style={{ flex: 1, gap: 8 }}>
<View ref={viewRef} style={{ flex: 1, gap: 8 }}>
<FlatList
horizontal
ref={carouselRef}
Expand All @@ -67,6 +92,8 @@ const Carousel = ContentLayoutBuilder<Props>(({ items, styles: _styles = default
contentContainerStyle={{
paddingHorizontal: isFullWidth ? separatorWidth : separatorWidth / 2,
}}
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
onViewableItemsChanged={handleViewableItemsChanged}
data={items}
ItemSeparatorComponent={() => <View style={{ width: separatorWidth / 2 }} />}
renderItem={({ item }: ListRenderItemInfo<ContentCardItem>) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Flex } from "@ledgerhq/native-ui";
import { WidthFactor } from "~/contentCards/layouts/types";
import { useItemStyle as getItemStyle } from "./utils";
import { ContentCardsType } from "~/dynamicContent/types";
import LogContentCardWrapper from "LLM/features/DynamicContent/components/LogContentCardWrapper";

type Props = {
styles?: {
Expand Down Expand Up @@ -43,9 +44,11 @@ const Grid = ContentLayoutBuilder<Props>(({ items, styles: _styles = defaultStyl
>
{items.map((item, index) => {
return (
<Flex key={item.props.metadata.id} style={{ width: cardWidth }}>
<item.component {...item.props} itemStyle={getItemStyle(index, items.length)} />
</Flex>
<LogContentCardWrapper key={item.props.metadata.id} id={item.props.metadata.id}>
<Flex style={{ width: cardWidth }}>
<item.component {...item.props} itemStyle={getItemStyle(index, items.length)} />
</Flex>
</LogContentCardWrapper>
);
})}
</Flex>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React from "react";
import { Linking } from "react-native";
import HorizontalCard from "../../contentCards/cards/horizontal";
import {
Expand All @@ -25,6 +25,7 @@ import { ContentCardsType } from "../types";
import Grid from "~/contentCards/layouts/grid";
import VerticalCard from "~/contentCards/cards/vertical";
import HeroCard from "~/contentCards/cards/hero";
import LogContentCardWrapper from "LLM/features/DynamicContent/components/LogContentCardWrapper";

// TODO : Better type to remove any (maybe use AnyContentCard)
/* eslint-disable @typescript-eslint/no-explicit-any */
Expand Down Expand Up @@ -67,15 +68,7 @@ type LayoutProps = {
};

const Layout = ({ category, cards }: LayoutProps) => {
const { logClickCard, dismissCard, trackContentCardEvent, logImpressionCard } =
useDynamicContent();

useEffect(() => {
logImpressionCard(category.id);
for (const card of cards) {
logImpressionCard(card.id);
}
}, [cards, category.id, logImpressionCard]);
const { logClickCard, dismissCard, trackContentCardEvent } = useDynamicContent();

const onCardCick = (card: AnyContentCard) => {
trackContentCardEvent("contentcard_clicked", {
Expand Down Expand Up @@ -108,7 +101,10 @@ const Layout = ({ category, cards }: LayoutProps) => {
};

const contentCardsType = contentCardsTypes[category.cardsType];
const cardsMapped = cards.map(card => contentCardsType.mappingFunction(card));
const cardsMapped = cards
.map(card => contentCardsType.mappingFunction(card))
.filter(card => card);

const cardsSorted = (cardsMapped as AnyContentCard[]).sort(compareCards);

const items = cardsSorted.map(card =>
Expand Down Expand Up @@ -142,11 +138,19 @@ const Layout = ({ category, cards }: LayoutProps) => {
}}
/>
);

case ContentCardsLayout.grid:
return <Grid items={items} styles={{ widthFactor: cardsSorted[0].gridWidthFactor }} />;

case ContentCardsLayout.unique:
default:
return <Flex mx={6}>{items[0].component(items[0].props)}</Flex>;
default: {
const item = items[0];
return (
<LogContentCardWrapper id={item.props.metadata.id}>
<Flex mx={6}>{item.component(item.props)}</Flex>
</LogContentCardWrapper>
);
}
}
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react";
import { Flex } from "@ledgerhq/native-ui";
import LogContentCardWrapper from "LLM/features/DynamicContent/components/LogContentCardWrapper";
import { CategoryContentCard, BrazeContentCard } from "../types";
import Header from "./Header";
import Layout from "./Layout";
Expand All @@ -9,8 +10,8 @@ type Props = {
categoryContentCards: BrazeContentCard[];
};

const ContentCardsCategory = ({ category, categoryContentCards }: Props) => {
return (
const ContentCardsCategory = ({ category, categoryContentCards }: Props) => (
<LogContentCardWrapper id={category.id}>
<Flex>
<Header
title={category.title}
Expand All @@ -21,7 +22,7 @@ const ContentCardsCategory = ({ category, categoryContentCards }: Props) => {
/>
<Layout category={category} cards={categoryContentCards} />
</Flex>
);
};
</LogContentCardWrapper>
);

export default ContentCardsCategory;
20 changes: 16 additions & 4 deletions apps/ledger-live-mobile/src/dynamicContent/brazeContentCard.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { useCallback } from "react";
import Braze from "@braze/react-native-sdk";
import { trackingEnabledSelector } from "../reducers/settings";
import { useCallback, useRef } from "react";
import { useSelector, useDispatch } from "react-redux";
import { setDismissedContentCard } from "../actions/settings";
import { track } from "~/analytics";
import { setDismissedContentCard } from "~/actions/settings";
import { trackingEnabledSelector } from "~/reducers/settings";
import { mobileCardsSelector } from "~/reducers/dynamicContent";

export const useBrazeContentCard = () => {
const isTrackedUser = useSelector(trackingEnabledSelector);
const mobileCards = useSelector(mobileCardsSelector);
const mobileCardRef = useRef(mobileCards);
const dispatch = useDispatch();

const logDismissCard = useCallback(
Expand All @@ -22,7 +26,15 @@ export const useBrazeContentCard = () => {
);

const logImpressionCard = useCallback(
(cardId: string) => isTrackedUser && Braze.logContentCardImpression(cardId),
(cardId: string) => {
if (!isTrackedUser) return;

Braze.logContentCardImpression(cardId);

const card = mobileCardRef.current.find(card => card.id === cardId);
if (!card) return;
track("contentcard_impression", { ...card.extras, page: card.extras.location });
},
[isTrackedUser],
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import React, {
createContext,
DependencyList,
type ReactNode,
type RefObject,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { Dimensions, type View } from "react-native";
import { concatMap, from, interval } from "rxjs";
import type { InViewOptions, InViewContext, InViewEntry, WatchedItem } from "./types";
import { inViewStatus } from "./utils";

const InViewContext = createContext<InViewContext>({});

export function useInViewContext(
onInViewUpdate: (entry: InViewEntry) => void,
deps: DependencyList = [],
target: RefObject<View>,
) {
const { addWatchedItem, removeWatchedItem } = useContext(InViewContext);
const onInViewUpdateCb = useCallback(onInViewUpdate, deps); // eslint-disable-line react-hooks/exhaustive-deps

useEffect(() => {
const item = { target, onInViewUpdate: onInViewUpdateCb };
addWatchedItem?.(item);
return () => removeWatchedItem?.(item);
}, [target, onInViewUpdateCb, addWatchedItem, removeWatchedItem]);
}

export function InViewContextProvider({
inViewThreshold = 0.5,
outOfViewThreshold = 0,
interval: intervalDuration = 200,
children,
}: InViewOptions & { children: ReactNode }) {
const items = useRef<WatchedItem[]>([]);
const [hasItems, setHasItems] = useState(false);

const addWatchedItem = useCallback((item: WatchedItem) => {
if (items.current.length === 0) setHasItems(true);
items.current.push(item);
}, []);
const removeWatchedItem = useCallback((item: WatchedItem) => {
const index = items.current.indexOf(item);
if (index === -1) return;
items.current.splice(index, 1);
if (items.current.length === 0) setHasItems(false);
}, []);

const watchedItem = useRef(new WeakMap<WatchedItem, boolean>());

useEffect(() => {
if (!hasItems) return;

const window = Dimensions.get("window");
const observer = interval(intervalDuration).pipe(
concatMap(() =>
from(
Promise.all(
items.current.map(async item => {
const threshold = watchedItem.current.get(item)
? outOfViewThreshold
: inViewThreshold;

const entry = await inViewStatus(item.target, threshold, window);
return { item, entry };
}),
),
),
),
);

const subscription = observer.subscribe(res => {
res.forEach(({ item, entry }) => {
if (entry.isInView === watchedItem.current.get(item)) return;
watchedItem.current.set(item, entry.isInView);
item.onInViewUpdate(entry);
});
});
return () => subscription.unsubscribe();
}, [hasItems, inViewThreshold, outOfViewThreshold, intervalDuration]);

return (
<InViewContext.Provider value={{ addWatchedItem, removeWatchedItem }}>
{children}
</InViewContext.Provider>
);
}
Loading
Loading