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 #8526

Merged
merged 17 commits into from
Dec 3, 2024
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is the bypass really necessary ? We should avoid that

Copy link
Contributor Author

@thesan thesan Dec 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be missing another posibility but here's how I see it:

The way I see not to bypass the react-hooks/exhaustive-deps would be to go:
From version 1

// Declaration
function useInViewContext(
  target: RefObject<View>,
  onInViewUpdate: (entry: InViewEntry) => void,
  deps: DependencyList = [],
)
...
// Usage
useInViewContext(target, ({ isInView }) => {
  if (isInView) logImpressionCard(id);
}, [id]);

To version 2

// Declaration
function useInViewContext(
  target: RefObject<View>,
  onInViewUpdate: (entry: InViewEntry) => void
)
...
// Usage
useInViewContext(target, useCallback(({ isInView }) => {
  if (isInView) logImpressionCard(id);
}, [id]));

The problem I have with version 2 is that the type signature doesn't make it clear onInViewUpdate is a dependency which means writing this would be problematic:

useInViewContext(target, ({ isInView }) => {
  if (isInView) logImpressionCard(id);
});

(because the internal useEffect will rerender on every re-render).

In react we are used to pass functions to hooks directly (without wrapping them in useCallback). So I think that here forwarding the dependencies to the hook is better to avoid mistakes. However it means that the variable used to forward the dependencies can't be statically analized by react-hooks/exhaustive-deps which makes the bypass necessary.

That said I could add useInViewContext to the config rules."react-hooks/exhaustive-deps".additionalHooks. So I'll have to add logImpressionCard to all the deps.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: d444580


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