Skip to content

Commit

Permalink
feat(llm): 🗒️ log content card impression when 50% of the card is sho…
Browse files Browse the repository at this point in the history
…wn (#8526)

* feat(llm): log category cards impression when 50% of the card is visible

* feat(llm): do not run in view checks when no items is being watched

* feat(llm): log portfolio content cards impression on 50% visibility

* chore: update change log

* feat(llm): log dynamic content card impression when 50% is shown

* feat(llm): log notification impression when 50% is shown

* chore(llm): rename IsInViewContext to InViewContext

* chore(llm): add InViewContextProvider to the AppProviders

* fix(llm): undefined order in null

* feat(lld): log category impression when 50% is shown

* chore(llm): use LLM alias in imports

* feat(llm): trigger segment event on card impression

* fix(llm): rewatch card when `LogContentCardWrapper.id` change

* fix(llm): use mobileCardsSelector to find the card to log the impression

* fix(llm): race condition between interval and inViewStatus

* chore(llm): check "exhaustive-deps" on `useInViewContext`

* chore(llm): remove unnecessary key
  • Loading branch information
thesan authored Dec 3, 2024
1 parent 0096a2c commit fc50509
Show file tree
Hide file tree
Showing 16 changed files with 294 additions and 81 deletions.
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
12 changes: 2 additions & 10 deletions apps/ledger-live-mobile/src/components/Carousel/CarouselCard.tsx
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

0 comments on commit fc50509

Please sign in to comment.