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

fix: add offline support for anonymous targeted user via braze notifications #8603

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
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/curvy-parents-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ledger-live-desktop": minor
---

Support anonymous user based braze campaign notification management.
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import React, { useRef, useEffect, useMemo } from "react";
import * as braze from "@braze/web-sdk";
import { useSelector } from "react-redux";
import { trackingEnabledSelector } from "~/renderer/reducers/settings";
import { useDispatch, useSelector } from "react-redux";
import {
anonymousUserNotificationsSelector,
trackingEnabledSelector,
} from "~/renderer/reducers/settings";
import { track } from "~/renderer/analytics/segment";
import { Box } from "@ledgerhq/react-ui";
import { updateAnonymousUserNotifications } from "~/renderer/actions/settings";

interface LogContentCardWrapperProps {
id: string;
Expand All @@ -12,6 +16,7 @@ interface LogContentCardWrapperProps {
}

const PERCENTAGE_OF_CARD_VISIBLE = 0.5;
const OFFLINE_SEEN_DELAY = 2e3;

const LogContentCardWrapper: React.FC<LogContentCardWrapperProps> = ({
id,
Expand All @@ -20,24 +25,42 @@ const LogContentCardWrapper: React.FC<LogContentCardWrapperProps> = ({
}) => {
const ref = useRef<HTMLDivElement>(null);
const isTrackedUser = useSelector(trackingEnabledSelector);
const anonymousUserNotifications = useSelector(anonymousUserNotificationsSelector);
const dispatch = useDispatch();

const currentCard = useMemo(() => {
const cards = braze.getCachedContentCards().cards;
return cards.find(card => card.id === id);
}, [id]);

useEffect(() => {
if (!currentCard || !isTrackedUser) return;
if (!currentCard) return;

const intersectionObserver = new IntersectionObserver(
([entry]) => {
if (entry.intersectionRatio > PERCENTAGE_OF_CARD_VISIBLE) {
braze.logContentCardImpressions([currentCard]);
track("contentcard_impression", {
id: currentCard.id,
...currentCard.extras,
...additionalProps,
});
if (isTrackedUser) {
braze.logContentCardImpressions([currentCard]);
track("contentcard_impression", {
id: currentCard.id,
...currentCard.extras,
...additionalProps,
});
} else if (
anonymousUserNotifications[currentCard.id as string] !==
currentCard?.expiresAt?.getTime()
) {
// support new campaign or resumed campaign with the same id + different expiration date targeting anonymous users
setTimeout(() => {
Copy link
Member

Choose a reason for hiding this comment

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

Why do we need to use a timeout, please?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

so , this timeout is used to wait a little bit before a ui change to mark the content card read (as if it's an api request) , Lucas told me that before the work on this feature it was 3 seconds. It's a time laps to avoid immediate disappear of the dot. Initially i just put 500 ms (like in the video) so it's can disappear in a reasonable time. But i should confirm with Anthony.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

simply it will take 3 seconds so that the seen status will be considered for the offline notifications.

Copy link
Contributor

Choose a reason for hiding this comment

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

Actually it was 2 seconds my bad here is the code used before :

const { notificationsCards, logNotificationImpression, groupNotifications, onClickNotif } =
    useNotifications();

  const timeoutByUUID = useRef<Record<string, NodeJS.Timeout>>({});
  const handleInViewNotif = useCallback(
    (visible: boolean, uuid: keyof typeof timeoutByUUID.current) => {
      const timeouts = timeoutByUUID.current;

      if (notificationsCards.find(n => !n.viewed && n.id === uuid) && visible && !timeouts[uuid]) {
        timeouts[uuid] = setTimeout(() => {
          logNotificationImpression(uuid);
          delete timeouts[uuid];
        }, 2000);
      }
      if (!visible && timeouts[uuid]) {
        clearTimeout(timeouts[uuid]);
        delete timeouts[uuid];
      }
    },
    [logNotificationImpression, notificationsCards],
  );

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ok i'll change it ! so that we keep the same look & feel of a real api call , rather than suddenly/fast delete them

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

dispatch(
updateAnonymousUserNotifications({
notifications: {
[currentCard.id as string]: currentCard?.expiresAt?.getTime() as number,
},
}),
);
}, OFFLINE_SEEN_DELAY);
}
}
},
{ threshold: PERCENTAGE_OF_CARD_VISIBLE },
Expand All @@ -54,7 +77,7 @@ const LogContentCardWrapper: React.FC<LogContentCardWrapperProps> = ({
intersectionObserver.unobserve(currentRef);
}
};
}, [currentCard, isTrackedUser, additionalProps]);
}, [currentCard, isTrackedUser, additionalProps, dispatch, anonymousUserNotifications]);

return (
<Box width="100%" ref={ref}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export const TOGGLE_MEMOTAG_INFO = "settings/toggleShouldDisplayMemoTagInfo";
export const TOGGLE_MEV = "settings/toggleMEV";
export const TOGGLE_MARKET_WIDGET = "settings/toggleMarketWidget";
export const UPDATE_NFT_COLLECTION_STATUS = "settings/updateNftCollectionStatus";
export const UPDATE_ANONYMOUS_USER_NOTIFICATIONS = "settings/updateAnonymousUserNotifications";
11 changes: 11 additions & 0 deletions apps/ledger-live-desktop/src/renderer/actions/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
TOGGLE_MARKET_WIDGET,
TOGGLE_MEMOTAG_INFO,
TOGGLE_MEV,
UPDATE_ANONYMOUS_USER_NOTIFICATIONS,
UPDATE_NFT_COLLECTION_STATUS,
} from "./constants";
import { BlockchainsType } from "@ledgerhq/live-nft/supported";
Expand Down Expand Up @@ -454,3 +455,13 @@ export const toggleShouldDisplayMemoTagInfo = (payload: boolean) => {
payload,
};
};

export const updateAnonymousUserNotifications = (payload: {
notifications: Record<string, string | number>;
purgeState?: boolean;
}) => {
return {
type: UPDATE_ANONYMOUS_USER_NOTIFICATIONS,
payload,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,6 @@ const Separator = styled.div`

export function AnnouncementPanel() {
const { notificationsCards, groupNotifications, onClickNotif } = useNotifications();

const groups = useMemo(
() => groupNotifications(notificationsCards),
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down
41 changes: 39 additions & 2 deletions apps/ledger-live-desktop/src/renderer/hooks/useBraze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@ import {
trackingEnabledSelector,
dismissedContentCardsSelector,
anonymousBrazeIdSelector,
anonymousUserNotificationsSelector,
} from "../reducers/settings";
import { clearDismissedContentCards, setAnonymousBrazeId } from "../actions/settings";
import {
clearDismissedContentCards,
setAnonymousBrazeId,
updateAnonymousUserNotifications,
} from "../actions/settings";
import { getEnv } from "@ledgerhq/live-env";
import { getOldCampaignIds, generateAnonymousId } from "@ledgerhq/live-common/braze/anonymousUsers";

Expand Down Expand Up @@ -90,6 +95,7 @@ export async function useBraze() {
const dispatch = useDispatch();
const devMode = useSelector(developerModeSelector);
const contentCardsDissmissed = useSelector(dismissedContentCardsSelector);
const anonymousUserNotifications = useSelector(anonymousUserNotificationsSelector);
const isTrackedUser = useSelector(trackingEnabledSelector);
const anonymousBrazeId = useRef(useSelector(anonymousBrazeIdSelector));

Expand All @@ -104,6 +110,30 @@ export async function useBraze() {
dispatch(setAnonymousBrazeId(anonymousBrazeId.current));
}

/**
* If the user is opt-out from analytics, we need to purge expired notifications persisted in the store/offline storage
*/
if (!isTrackedUser) {
const expiredAnonymousUserNotifications = getOldCampaignIds(anonymousUserNotifications);
if (expiredAnonymousUserNotifications.length) {
const validAnonymousUserNotificationsOnly = Object.keys(anonymousUserNotifications).reduce(
(validNotifications: Record<string, string | number>, key: string) => {
if (!expiredAnonymousUserNotifications.includes(key)) {
validNotifications[key] = anonymousUserNotifications[key];
}
return validNotifications;
},
{},
);
dispatch(
updateAnonymousUserNotifications({
notifications: validAnonymousUserNotificationsOnly,
purgeState: true,
}),
);
}
}

braze.initialize(brazeConfig.apiKey, {
baseUrl: brazeConfig.endpoint,
allowUserSuppliedJavascript: true,
Expand Down Expand Up @@ -150,7 +180,14 @@ export async function useBraze() {

braze.automaticallyShowInAppMessages();
braze.openSession();
}, [dispatch, devMode, isTrackedUser, contentCardsDissmissed, anonymousBrazeId]);
}, [
dispatch,
devMode,
isTrackedUser,
contentCardsDissmissed,
anonymousBrazeId,
anonymousUserNotifications,
]);

useEffect(() => {
initBraze();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
PortfolioContentCard,
} from "~/types/dynamicContent";
import { Handlers } from "./types";
import { SettingsState, trackingEnabledSelector } from "./settings";
import { State } from ".";

export type DynamicContentState = {
portfolioCards: PortfolioContentCard[];
Expand Down Expand Up @@ -61,8 +63,18 @@ export const portfolioContentCardSelector = (state: { dynamicContent: DynamicCon
export const actionContentCardSelector = (state: { dynamicContent: DynamicContentState }) =>
state.dynamicContent.actionCards;

export const notificationsContentCardSelector = (state: { dynamicContent: DynamicContentState }) =>
state.dynamicContent.notificationsCards;
export const notificationsContentCardSelector = (state: {
dynamicContent: DynamicContentState;
settings: SettingsState;
}) => {
const { settings, dynamicContent } = state;
return dynamicContent.notificationsCards.map(n => ({
...n,
viewed: trackingEnabledSelector(state as State)
? n.viewed
: !!settings.anonymousUserNotifications[n.id],
}));
};

// Exporting reducer

Expand Down
16 changes: 16 additions & 0 deletions apps/ledger-live-desktop/src/renderer/reducers/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
TOGGLE_MARKET_WIDGET,
TOGGLE_MEV,
UPDATE_NFT_COLLECTION_STATUS,
UPDATE_ANONYMOUS_USER_NOTIFICATIONS,
} from "../actions/constants";
import { BlockchainsType, SupportedBlockchainsType } from "@ledgerhq/live-nft/supported";
import { NftStatus } from "@ledgerhq/live-nft/types";
Expand Down Expand Up @@ -134,6 +135,7 @@ export type SettingsState = {
onboardingUseCase: OnboardingUseCase | null;
lastOnboardedDevice: Device | null;
alwaysShowMemoTagInfo: boolean;
anonymousUserNotifications: Record<string, number>;
};

export const getInitialLanguageAndLocale = (): { language: Language; locale: Locale } => {
Expand Down Expand Up @@ -239,6 +241,7 @@ export const INITIAL_STATE: SettingsState = {
onboardingUseCase: null,
lastOnboardedDevice: null,
alwaysShowMemoTagInfo: true,
anonymousUserNotifications: {},
};

/* Handlers */
Expand Down Expand Up @@ -311,6 +314,10 @@ type HandlersPayloads = {
[TOGGLE_MEV]: boolean;
[TOGGLE_MEMOTAG_INFO]: boolean;
[TOGGLE_MARKET_WIDGET]: boolean;
[UPDATE_ANONYMOUS_USER_NOTIFICATIONS]: {
notifications: Record<string, number>;
purgeState?: boolean;
};
};
type SettingsHandlers<PreciseKey = true> = Handlers<SettingsState, HandlersPayloads, PreciseKey>;

Expand Down Expand Up @@ -556,6 +563,13 @@ const handlers: SettingsHandlers = {
...state,
alwaysShowMemoTagInfo: payload,
}),
[UPDATE_ANONYMOUS_USER_NOTIFICATIONS]: (state: SettingsState, { payload }) => ({
...state,
anonymousUserNotifications: {
...(!payload.purgeState && state.anonymousUserNotifications),
...payload.notifications,
},
}),
};

export default handleActions<SettingsState, HandlersPayloads[keyof HandlersPayloads]>(
Expand Down Expand Up @@ -912,3 +926,5 @@ export const marketPerformanceWidgetSelector = (state: State) =>
export const alwaysShowMemoTagInfoSelector = (state: State) => state.settings.alwaysShowMemoTagInfo;
export const nftCollectionsStatusByNetworkSelector = (state: State) =>
state.settings.nftCollectionsStatusByNetwork;
export const anonymousUserNotificationsSelector = (state: State) =>
state.settings.anonymousUserNotifications;
Loading