Skip to content

Commit dd2110a

Browse files
Merge pull request #11332 from LedgerHQ/LIVE-20867-ledger-live-wont-load-or-is-extremely-laggy-on-mac-windows
LIVE-20867: prevent infinite rendering cycles when purging anonymous notifications (cherry picked from commit cab707b)
1 parent b206043 commit dd2110a

File tree

9 files changed

+226
-128
lines changed

9 files changed

+226
-128
lines changed

.changeset/shy-cats-rush.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"ledger-live-desktop": patch
3+
"@ledgerhq/live-common": patch
4+
---
5+
6+
Fixes infinite loop for untracked users with (partially) expired braze campaigns
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
// Action types
22

33
/** Settings --------- */
4+
export const PURGE_EXPIRED_ANONYMOUS_USER_NOTIFICATIONS =
5+
"settings/purgeExpiredAnonymousUserNotifications";
6+
export const RESET_HIDDEN_NFT_COLLECTIONS = "settings/resetHiddenNftCollections";
7+
export const TOGGLE_MARKET_WIDGET = "settings/toggleMarketWidget";
48
export const TOGGLE_MEMOTAG_INFO = "settings/toggleShouldDisplayMemoTagInfo";
59
export const TOGGLE_MEV = "settings/toggleMEV";
6-
export const TOGGLE_MARKET_WIDGET = "settings/toggleMarketWidget";
7-
export const UPDATE_NFT_COLLECTION_STATUS = "settings/updateNftCollectionStatus";
8-
export const RESET_HIDDEN_NFT_COLLECTIONS = "settings/resetHiddenNftCollections";
910
export const UPDATE_ANONYMOUS_USER_NOTIFICATIONS = "settings/updateAnonymousUserNotifications";
11+
export const UPDATE_NFT_COLLECTION_STATUS = "settings/updateNftCollectionStatus";

apps/ledger-live-desktop/src/renderer/actions/settings.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { useRefreshAccountsOrdering } from "~/renderer/actions/general";
2626
import { Language, Locale } from "~/config/languages";
2727
import { Layout } from "LLD/features/Collectibles/types/Layouts";
2828
import {
29+
PURGE_EXPIRED_ANONYMOUS_USER_NOTIFICATIONS,
2930
RESET_HIDDEN_NFT_COLLECTIONS,
3031
TOGGLE_MARKET_WIDGET,
3132
TOGGLE_MEMOTAG_INFO,
@@ -390,7 +391,7 @@ export const setDismissedContentCards = (payload: { id: string; timestamp: numbe
390391
payload,
391392
});
392393

393-
export const clearDismissedContentCards = (payload: string[]) => ({
394+
export const clearDismissedContentCards = (payload: { now: Date }) => ({
394395
type: "CLEAR_DISMISSED_CONTENT_CARDS",
395396
payload,
396397
});
@@ -461,9 +462,15 @@ export const toggleShouldDisplayMemoTagInfo = (payload: boolean) => {
461462
};
462463
};
463464

465+
export const purgeExpiredAnonymousUserNotifications = (payload: { now: Date }) => {
466+
return {
467+
type: PURGE_EXPIRED_ANONYMOUS_USER_NOTIFICATIONS,
468+
payload,
469+
};
470+
};
471+
464472
export const updateAnonymousUserNotifications = (payload: {
465473
notifications: SettingsState["anonymousUserNotifications"];
466-
purgeState?: boolean;
467474
}) => {
468475
return {
469476
type: UPDATE_ANONYMOUS_USER_NOTIFICATIONS,
Lines changed: 56 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,36 @@
1-
import { useEffect, useCallback, useRef } from "react";
21
import * as braze from "@braze/web-sdk";
32
import { ClassicCard } from "@braze/web-sdk";
3+
import { generateAnonymousId } from "@ledgerhq/live-common/braze/anonymousUsers";
4+
import { getEnv } from "@ledgerhq/live-env";
5+
import { useCallback, useEffect, useRef } from "react";
6+
import { useDispatch, useSelector } from "react-redux";
47
import { getBrazeConfig } from "~/braze-setup";
8+
import getUser from "~/helpers/user";
59
import {
10+
ActionContentCard,
611
ContentCard as LedgerContentCard,
712
LocationContentCard,
8-
PortfolioContentCard,
913
NotificationContentCard,
1014
Platform,
11-
ActionContentCard,
15+
PortfolioContentCard,
1216
} from "~/types/dynamicContent";
13-
import { useDispatch, useSelector } from "react-redux";
1417
import {
1518
setActionCards,
1619
setDesktopCards,
1720
setNotificationsCards,
1821
setPortfolioCards,
1922
} from "../actions/dynamicContent";
20-
import getUser from "~/helpers/user";
21-
import {
22-
developerModeSelector,
23-
trackingEnabledSelector,
24-
dismissedContentCardsSelector,
25-
anonymousBrazeIdSelector,
26-
anonymousUserNotificationsSelector,
27-
} from "../reducers/settings";
2823
import {
2924
clearDismissedContentCards,
25+
purgeExpiredAnonymousUserNotifications,
3026
setAnonymousBrazeId,
31-
updateAnonymousUserNotifications,
3227
} from "../actions/settings";
33-
import { getEnv } from "@ledgerhq/live-env";
34-
import { getOldCampaignIds, generateAnonymousId } from "@ledgerhq/live-common/braze/anonymousUsers";
28+
import {
29+
anonymousBrazeIdSelector,
30+
developerModeSelector,
31+
dismissedContentCardsSelector,
32+
trackingEnabledSelector,
33+
} from "../reducers/settings";
3534

3635
const getDesktopCards = (elem: braze.ContentCards) =>
3736
elem.cards.filter(card => card.extras?.platform === Platform.Desktop);
@@ -53,92 +52,66 @@ export const compareCards = (a: LedgerContentCard, b: LedgerContentCard) => {
5352
};
5453

5554
export const mapAsActionContentCard = (card: ClassicCard): ActionContentCard => ({
56-
id: String(card.id),
57-
title: card.extras?.title,
55+
created: card.created,
5856
description: card.extras?.description,
59-
location: LocationContentCard.Action,
57+
id: String(card.id),
6058
image: card.extras?.image,
6159
link: card.extras?.link,
62-
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
63-
created: card.created as Date,
60+
location: LocationContentCard.Action,
6461
mainCta: card.extras?.mainCta,
65-
secondaryCta: card.extras?.secondaryCta,
6662
order: parseInt(card.extras?.order) ? parseInt(card.extras?.order) : undefined,
63+
secondaryCta: card.extras?.secondaryCta,
64+
title: card.extras?.title,
6765
});
6866

6967
export const mapAsPortfolioContentCard = (card: ClassicCard): PortfolioContentCard => ({
70-
id: String(card.id),
71-
title: card.extras?.title,
72-
description: card.extras?.description,
68+
created: card.created,
7369
cta: card.extras?.cta,
74-
tag: card.extras?.tag,
75-
location: LocationContentCard.Portfolio,
70+
description: card.extras?.description,
71+
id: String(card.id),
7672
image: card.extras?.image,
77-
url: card.extras?.url,
78-
path: card.extras?.path,
79-
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
80-
created: card.created as Date,
73+
location: LocationContentCard.Portfolio,
8174
order: parseInt(card.extras?.order) ? parseInt(card.extras?.order) : undefined,
75+
path: card.extras?.path,
76+
tag: card.extras?.tag,
77+
title: card.extras?.title,
78+
url: card.extras?.url,
8279
});
8380

8481
export const mapAsNotificationContentCard = (card: ClassicCard): NotificationContentCard => ({
85-
id: String(card.id),
86-
title: card.extras?.title,
82+
created: card.created,
83+
cta: card.extras?.cta,
8784
description: card.extras?.description,
85+
id: String(card.id),
8886
location: LocationContentCard.NotificationCenter,
89-
url: card.extras?.url,
87+
order: parseInt(card.extras?.order) ? parseInt(card.extras?.order) : undefined,
9088
path: card.extras?.path,
91-
cta: card.extras?.cta,
92-
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
93-
created: card.created as Date,
89+
title: card.extras?.title,
90+
url: card.extras?.url,
9491
viewed: card.viewed,
95-
order: parseInt(card.extras?.order) ? parseInt(card.extras?.order) : undefined,
9692
});
9793

94+
/**
95+
* TODO put this effectful logic into a provider instead
96+
*/
9897
export async function useBraze() {
9998
const dispatch = useDispatch();
10099
const devMode = useSelector(developerModeSelector);
101100
const contentCardsDissmissed = useSelector(dismissedContentCardsSelector);
102-
const anonymousUserNotifications = useSelector(anonymousUserNotificationsSelector);
103101
const isTrackedUser = useSelector(trackingEnabledSelector);
104102
const anonymousBrazeId = useRef(useSelector(anonymousBrazeIdSelector));
105103

106104
const initBraze = useCallback(async () => {
107105
const user = await getUser();
108106
const brazeConfig = getBrazeConfig();
109107
const isPlaywright = !!getEnv("PLAYWRIGHT_RUN");
110-
dispatch(clearDismissedContentCards(getOldCampaignIds(contentCardsDissmissed)));
111108

112109
if (!anonymousBrazeId.current) {
113110
anonymousBrazeId.current = generateAnonymousId();
114111
dispatch(setAnonymousBrazeId(anonymousBrazeId.current));
115112
}
116113

117-
/**
118-
* If the user is opt-out from analytics, we need to purge expired notifications persisted in the store/offline storage
119-
*/
120-
if (!isTrackedUser) {
121-
const expiredAnonymousUserNotifications = getOldCampaignIds(anonymousUserNotifications);
122-
if (expiredAnonymousUserNotifications.length) {
123-
const validAnonymousUserNotificationsOnly = Object.keys(anonymousUserNotifications).reduce(
124-
(validNotifications: Record<string, number>, key: string) => {
125-
if (!expiredAnonymousUserNotifications.includes(key)) {
126-
validNotifications[key] = anonymousUserNotifications[key];
127-
}
128-
return validNotifications;
129-
},
130-
{},
131-
);
132-
dispatch(
133-
updateAnonymousUserNotifications({
134-
notifications: validAnonymousUserNotificationsOnly,
135-
purgeState: true,
136-
}),
137-
);
138-
}
139-
}
140-
141-
braze.initialize(brazeConfig.apiKey, {
114+
const isInitialized = braze.initialize(brazeConfig.apiKey, {
142115
baseUrl: brazeConfig.endpoint,
143116
allowUserSuppliedJavascript: true,
144117
enableHtmlInAppMessages: true,
@@ -147,6 +120,11 @@ export async function useBraze() {
147120
appVersion: isTrackedUser ? __APP_VERSION__ : undefined,
148121
});
149122

123+
if (!isInitialized) {
124+
console.warn("Failed to initialize Braze SDK");
125+
return;
126+
}
127+
150128
// If it's playwright, we don't want to fetch content cards
151129
if (isPlaywright) {
152130
return;
@@ -189,16 +167,22 @@ export async function useBraze() {
189167

190168
braze.automaticallyShowInAppMessages();
191169
braze.openSession();
192-
}, [
193-
dispatch,
194-
devMode,
195-
isTrackedUser,
196-
contentCardsDissmissed,
197-
anonymousBrazeId,
198-
anonymousUserNotifications,
199-
]);
170+
}, [dispatch, devMode, isTrackedUser, contentCardsDissmissed, anonymousBrazeId]);
200171

201172
useEffect(() => {
202173
initBraze();
203174
}, [initBraze]);
175+
176+
// TODO should there be an interval to periodically purge dismissed cards?
177+
useEffect(() => {
178+
dispatch(clearDismissedContentCards({ now: new Date() }));
179+
}, [dispatch]);
180+
181+
// TODO should there be an interval to periodically purge old notifications?
182+
useEffect(() => {
183+
// If the user is opt-out from analytics, we need to purge expired notifications persisted in the store/offline storage
184+
if (!isTrackedUser) {
185+
dispatch(purgeExpiredAnonymousUserNotifications({ now: new Date() }));
186+
}
187+
}, [dispatch, isTrackedUser]);
204188
}

apps/ledger-live-desktop/src/renderer/hooks/useNotifications.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export function useNotifications() {
2323
setCachedNotifications(cards);
2424
}, [dispatch, notificationsCards]);
2525

26+
// TODO use date library
2627
function startOfDayTime(date: Date): number {
2728
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
2829
return startOfDate.getTime();
@@ -37,8 +38,7 @@ export function useNotifications() {
3738
const notifsByDay: Record<string, NotificationContentCard[]> = notifs.reduce(
3839
(sum: Record<string, NotificationContentCard[]>, notif: NotificationContentCard) => {
3940
// group by publication date
40-
const k = startOfDayTime(notif.created);
41-
41+
const k = notif.created ? `${startOfDayTime(notif.created)}` : "no-date";
4242
return { ...sum, [`${k}`]: [...(sum[k] || []), notif] };
4343
},
4444
{},

apps/ledger-live-desktop/src/renderer/reducers/settings.test.ts

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22
* @jest-environment jsdom
33
*/
44

5+
import { getBrazeCampaignCutoff } from "@ledgerhq/live-common/braze/anonymousUsers";
6+
import { aDeviceInfoBuilder } from "@ledgerhq/live-common/mock/fixtures/aDeviceInfo";
57
import { DeviceModelId } from "@ledgerhq/types-devices";
6-
import {
8+
import { State } from ".";
9+
import { purgeExpiredAnonymousUserNotifications } from "../actions/settings";
10+
import reducer, {
711
lastSeenDeviceSelector,
8-
INITIAL_STATE as SETTINGS_INITIAL_STATE,
912
localeSelector,
13+
INITIAL_STATE as SETTINGS_INITIAL_STATE,
14+
SettingsState,
1015
} from "./settings";
11-
import { State } from ".";
12-
import { aDeviceInfoBuilder } from "@ledgerhq/live-common/mock/fixtures/aDeviceInfo";
1316

1417
const invalidDeviceModelIds = ["nanoFTS", undefined, "whatever"];
1518
const validDeviceModelIds: DeviceModelId[] = Object.values(DeviceModelId);
@@ -90,3 +93,79 @@ describe("lastSeenDeviceSelector", () => {
9093
expect(localeSelector(state)).toEqual("en-US");
9194
});
9295
});
96+
97+
describe("action: purgeAnonymousUserNotifications", () => {
98+
it("should remove notifications older than cutoff but keep newer ones", () => {
99+
const now = new Date();
100+
const cutoff = getBrazeCampaignCutoff(now);
101+
102+
const oldTimestamp = cutoff - 1;
103+
const newTimestamp = cutoff + 1;
104+
105+
const state: SettingsState = {
106+
...SETTINGS_INITIAL_STATE,
107+
anonymousUserNotifications: {
108+
a: oldTimestamp,
109+
b: newTimestamp,
110+
LNSUpsell: oldTimestamp,
111+
},
112+
};
113+
114+
const newState = reducer(state, purgeExpiredAnonymousUserNotifications({ now }));
115+
116+
expect(newState.anonymousUserNotifications).toEqual({
117+
b: newTimestamp,
118+
LNSUpsell: oldTimestamp,
119+
});
120+
});
121+
122+
it("should keep all notifications if none are expired", () => {
123+
const now = new Date();
124+
const ts = now.getTime() - 1000;
125+
126+
const state: SettingsState = {
127+
...SETTINGS_INITIAL_STATE,
128+
anonymousUserNotifications: {
129+
a: ts,
130+
b: ts,
131+
LNSUpsell: ts,
132+
},
133+
};
134+
135+
const newState = reducer(state, purgeExpiredAnonymousUserNotifications({ now }));
136+
137+
expect(newState).toBe(state);
138+
});
139+
140+
it("should keep LNSUpsell even if expired", () => {
141+
const now = new Date();
142+
const cutoff = getBrazeCampaignCutoff(now);
143+
const expired = cutoff - 1;
144+
145+
const state: SettingsState = {
146+
...SETTINGS_INITIAL_STATE,
147+
anonymousUserNotifications: {
148+
x: expired,
149+
LNSUpsell: expired,
150+
},
151+
};
152+
153+
const newState = reducer(state, purgeExpiredAnonymousUserNotifications({ now }));
154+
155+
expect(newState.anonymousUserNotifications).toEqual({
156+
LNSUpsell: expired,
157+
});
158+
});
159+
160+
it("should return original state if anonymousUserNotifications is empty", () => {
161+
const now = new Date();
162+
const state: SettingsState = {
163+
...SETTINGS_INITIAL_STATE,
164+
anonymousUserNotifications: {},
165+
};
166+
167+
const newState = reducer(state, purgeExpiredAnonymousUserNotifications({ now }));
168+
169+
expect(newState).toBe(state);
170+
});
171+
});

0 commit comments

Comments
 (0)