Skip to content

Commit c3152df

Browse files
committed
feat(message-system): add ab testing message
1 parent b3eb721 commit c3152df

File tree

17 files changed

+107
-23
lines changed

17 files changed

+107
-23
lines changed

docs/features/message-system.md

+18-2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ There are multiple ways of displaying message to a user:
2020
- messages on specific places in app (e.g. settings page, banner in account page)
2121
- feature
2222
- disabling some feature with an explanation message
23+
- abTesting
24+
- possibility to use AB testing on features/components
2325

2426
## Implementation
2527

@@ -205,7 +207,7 @@ Structure of config, types and optionality of specific keys can be found in the
205207
- critical (red)
206208
*/
207209
"variant": "warning",
208-
// Options: banner, modal, context, feature
210+
// Options: banner, modal, context, feature, abTesting
209211
"category": "banner",
210212
/*
211213
- Message in language of Suite app is shown to a user.
@@ -260,12 +262,26 @@ Structure of config, types and optionality of specific keys can be found in the
260262
"coins.btc"
261263
]
262264
}
263-
// Used only for feature
265+
// Used only for feature
264266
"feature": [
265267
{
266268
"domain": "coinjoin",
267269
"flag": false
268270
}
271+
],
272+
/*
273+
- Should be used only with category: "abTesting"
274+
- min distribution items are two
275+
*/
276+
"distribution": [
277+
{
278+
"variant": "A",
279+
"percentage": 30
280+
},
281+
{
282+
"variant": "B",
283+
"percentage": 70
284+
}
269285
]
270286
}
271287
}

packages/suite/src/components/suite/banners/MessageSystemBanner.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export const MessageSystemBanner = ({ message }: MessageSystemBannerProps) => {
7979
</Row>
8080
}
8181
>
82-
{content[language] || content.en}
82+
{content?.[language] || content?.en || ''}
8383
</Banner>
8484
);
8585
};

packages/suite/src/hooks/wallet/__fixtures__/useSendForm.ts

+1
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ export const getRootReducer = (selectedAccount = BTC_ACCOUNT, fees = DEFAULT_FEE
315315
context: [],
316316
modal: [],
317317
feature: [],
318+
abTesting: [],
318319
},
319320
dismissedMessages: {},
320321
},

packages/suite/src/middlewares/suite/__tests__/messageSystemMiddleware.test.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,18 @@ describe('Message system middleware', () => {
8888
id: '506b1322-8632-11eb-8dcd-0242ac130004',
8989
category: 'feature',
9090
};
91+
const message5 = {
92+
id: '506b1322-8632-11eb-8dcd-0242ac130005',
93+
category: 'abTesting',
94+
};
9195

9296
// @ts-expect-error: all properties except category and id are not required for testing
9397
jest.spyOn(messageSystemUtils, 'getValidMessages').mockImplementation(() => [
9498
message1,
9599
message2,
96100
message3,
97101
message4,
102+
message5,
98103
]);
99104

100105
const store = initStore(getInitialState(undefined, undefined));
@@ -116,6 +121,7 @@ describe('Message system middleware', () => {
116121
modal: [message2.id, message3.id],
117122
context: [message2.id],
118123
feature: [message4.id],
124+
abTesting: [message5.id],
119125
},
120126
},
121127
]);
@@ -138,7 +144,7 @@ describe('Message system middleware', () => {
138144
},
139145
{
140146
type: messageSystemActions.updateValidMessages.type,
141-
payload: { banner: [], context: [], modal: [], feature: [] },
147+
payload: { banner: [], context: [], modal: [], feature: [], abTesting: [] },
142148
},
143149
]);
144150
});

suite-common/message-system/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@trezor/connect": "workspace:*",
2424
"@trezor/device-utils": "workspace:*",
2525
"@trezor/env-utils": "workspace:*",
26+
"@trezor/type-utils": "workspace:^",
2627
"@trezor/utils": "workspace:*",
2728
"ajv": "^8.17.1",
2829
"fs-extra": "^11.2.0",

suite-common/message-system/schema/config.schema.v1.json

+25-9
Original file line numberDiff line numberDiff line change
@@ -214,14 +214,7 @@
214214
"message": {
215215
"title": "Message",
216216
"type": "object",
217-
"required": [
218-
"id",
219-
"priority",
220-
"dismissible",
221-
"variant",
222-
"category",
223-
"content"
224-
],
217+
"required": ["id", "priority", "dismissible", "variant", "category"],
225218
"properties": {
226219
"id": {
227220
"type": "string"
@@ -339,6 +332,29 @@
339332
}
340333
},
341334
"additionalProperties": false
335+
},
336+
"distribution": {
337+
"title": "AB Testing",
338+
"description": "Only used for 'abTesting' category.",
339+
"type": "array",
340+
"minItems": 2,
341+
"items": {
342+
"type": "object",
343+
"required": ["variant", "percentage"],
344+
"properties": {
345+
"variant": {
346+
"type": "string",
347+
"description": "The name of the variant, e.g., 'A' or 'B'"
348+
},
349+
"percentage": {
350+
"type": "number",
351+
"minimum": 0,
352+
"maximum": 100,
353+
"description": "Percentage of users for this variant (0-100)"
354+
}
355+
},
356+
"additionalProperties": false
357+
}
342358
}
343359
},
344360
"additionalProperties": false
@@ -430,7 +446,7 @@
430446
},
431447
"category": {
432448
"type": "string",
433-
"enum": ["banner", "context", "modal", "feature"]
449+
"enum": ["banner", "context", "modal", "feature", "abTesting"]
434450
},
435451
"date-time": {
436452
"description": "ISO 8601 date-time format.",

suite-common/message-system/src/__fixtures__/messageSystemActions.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export const messageId1 = '22e6444d-a586-4593-bc8d-5d013f193eba';
22
export const messageId2 = '469c65a8-8632-11eb-8dcd-0242ac130003';
33
export const messageId3 = '506b1322-8632-11eb-8dcd-0242ac130003';
4+
export const messageId4 = '2d5579ec-a7c2-4c50-9311-c404133c8804';
45

56
/*
67
JWS below is signed config with only mandatory fields:

suite-common/message-system/src/__fixtures__/messageSystemReducer.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const initialState = {
2626
context: [],
2727
modal: [],
2828
feature: [],
29+
abTesting: [],
2930
},
3031
dismissedMessages: {},
3132
};
@@ -109,7 +110,7 @@ export const fixtures = [
109110
actions: [
110111
{
111112
type: messageSystemActions.updateValidMessages.type,
112-
payload: { banner: messageIds, context: [], modal: [], feature: [] },
113+
payload: { banner: messageIds, context: [], modal: [], feature: [], abTesting: [] },
113114
},
114115
],
115116
result: {
@@ -145,12 +146,14 @@ export const fixtures = [
145146
context: false,
146147
modal: true,
147148
feature: false,
149+
abTesting: false,
148150
},
149151
[messageIds[1]]: {
150152
banner: false,
151153
context: true,
152154
modal: false,
153155
feature: false,
156+
abTesting: false,
154157
},
155158
},
156159
},

suite-common/message-system/src/__tests__/messageSystemActions.test.ts

+9
Original file line numberDiff line numberDiff line change
@@ -176,12 +176,14 @@ describe('Message system actions', () => {
176176
const contextMessages = [fixtures.messageId2];
177177
const modalMessages = [fixtures.messageId1];
178178
const featureMessages = [fixtures.messageId3];
179+
const abTestingMessages = [fixtures.messageId4];
179180

180181
const payload = {
181182
banner: [fixtures.messageId1, fixtures.messageId2, fixtures.messageId3],
182183
context: [fixtures.messageId2],
183184
modal: [fixtures.messageId1],
184185
feature: [fixtures.messageId3],
186+
abTesting: [fixtures.messageId4],
185187
};
186188

187189
store.dispatch(messageSystemActions.updateValidMessages(payload));
@@ -198,11 +200,15 @@ describe('Message system actions', () => {
198200
expect(store.getState().messageSystem.validMessages.feature.length).toEqual(
199201
featureMessages.length,
200202
);
203+
expect(store.getState().messageSystem.validMessages.abTesting.length).toEqual(
204+
abTestingMessages.length,
205+
);
201206

202207
expect(store.getState().messageSystem.validMessages.banner).toEqual(bannerMessages);
203208
expect(store.getState().messageSystem.validMessages.context).toEqual(contextMessages);
204209
expect(store.getState().messageSystem.validMessages.modal).toEqual(modalMessages);
205210
expect(store.getState().messageSystem.validMessages.feature).toEqual(featureMessages);
211+
expect(store.getState().messageSystem.validMessages.abTesting).toEqual(abTestingMessages);
206212
});
207213

208214
it('dismissMessage', () => {
@@ -219,6 +225,7 @@ describe('Message system actions', () => {
219225
context: false,
220226
modal: false,
221227
feature: false,
228+
abTesting: false,
222229
});
223230

224231
store.dispatch(
@@ -236,12 +243,14 @@ describe('Message system actions', () => {
236243
context: false,
237244
modal: true,
238245
feature: false,
246+
abTesting: false,
239247
});
240248
expect(store.getState().messageSystem.dismissedMessages[fixtures.messageId2]).toEqual({
241249
banner: false,
242250
context: true,
243251
modal: false,
244252
feature: false,
253+
abTesting: false,
245254
});
246255
});
247256
});

suite-common/message-system/src/messageSystemReducer.ts

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const initialState: MessageSystemState = {
1515
context: [],
1616
modal: [],
1717
feature: [],
18+
abTesting: [],
1819
},
1920
dismissedMessages: {},
2021
};
@@ -32,6 +33,7 @@ const getMessageStateById = (draft: MessageSystemState, id: string): MessageStat
3233
context: false,
3334
modal: false,
3435
feature: false,
36+
abTesting: false,
3537
};
3638
}
3739

suite-common/message-system/src/messageSystemSelectors.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { memoize, memoizeWithArgs } from 'proxy-memoize';
22

33
import { Message, Category } from '@suite-common/suite-types';
4+
import { RequiredKey } from '@trezor/type-utils';
45

56
import { ContextDomain, FeatureDomain, MessageSystemRootState } from './messageSystemTypes';
67

@@ -35,6 +36,7 @@ export const selectActiveBannerMessages = makeSelectActiveMessagesByCategory('ba
3536
export const selectActiveContextMessages = makeSelectActiveMessagesByCategory('context');
3637
export const selectActiveModalMessages = makeSelectActiveMessagesByCategory('modal');
3738
export const selectActiveFeatureMessages = makeSelectActiveMessagesByCategory('feature');
39+
export const selectActiveAbTestingMessages = makeSelectActiveMessagesByCategory('abTesting');
3840

3941
export const selectIsAnyBannerMessageActive = (state: MessageSystemRootState) => {
4042
const activeBannerMessages = selectActiveBannerMessages(state);
@@ -66,7 +68,7 @@ export const selectContextMessageContent = memoizeWithArgs(
6668

6769
return {
6870
...message,
69-
content: message?.content[language] ?? message?.content.en,
71+
content: message?.content?.[language] ?? message?.content?.en,
7072
cta: message?.cta
7173
? {
7274
...message.cta,
@@ -91,7 +93,7 @@ export const selectFeatureMessageContent = memoizeWithArgs(
9193
(state: MessageSystemRootState, domain: FeatureDomain, language: string) => {
9294
const featureMessages = selectFeatureMessage(state, domain);
9395

94-
return featureMessages?.content[language] ?? featureMessages?.content.en;
96+
return featureMessages?.content?.[language] ?? featureMessages?.content?.en;
9597
},
9698
);
9799

@@ -130,7 +132,16 @@ export const selectAllValidMessages = memoize((state: MessageSystemRootState) =>
130132
...validMessages.feature,
131133
...validMessages.modal,
132134
...validMessages.context,
135+
...validMessages.abTesting,
133136
];
134137

135138
return config?.actions.map(a => a.message).filter(m => allValidMessages.includes(m.id)) || [];
136139
});
140+
141+
export const selectAbTestingById = memoizeWithArgs((state: MessageSystemRootState, id: string) => {
142+
const abTests = selectActiveAbTestingMessages(state).filter(
143+
item => item.category === 'abTesting' && item.distribution,
144+
) as RequiredKey<Message, 'distribution'>[];
145+
146+
return abTests.find(abTest => abTest.id === id);
147+
});

suite-common/message-system/src/messageSystemUtils.ts

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const categorizeMessages = (messages: Message[]): ValidMessagesPayload =>
3737
modal: [],
3838
context: [],
3939
feature: [],
40+
abTesting: [],
4041
};
4142

4243
messages.forEach(message => {

suite-common/suite-types/src/messageSystem.ts

+18-2
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,22 @@ export type FirmwareVariant = '*' | 'bitcoin-only' | 'regular';
1616
*/
1717
export type Vendor = '*' | 'trezor.io';
1818
export type Variant = 'info' | 'warning' | 'critical';
19-
export type Category = 'banner' | 'context' | 'modal' | 'feature';
19+
export type Category = 'banner' | 'context' | 'modal' | 'feature' | 'abTesting';
20+
/**
21+
* Only used for 'abTesting' category.
22+
*
23+
* @minItems 2
24+
*/
25+
export type ABTesting = {
26+
/**
27+
* The name of the variant, e.g., 'A' or 'B'
28+
*/
29+
variant: string;
30+
/**
31+
* Percentage of users for this variant (0-100)
32+
*/
33+
percentage: number;
34+
}[];
2035

2136
/**
2237
* JSON schema of the Trezor Suite messaging system.
@@ -101,7 +116,7 @@ export interface Message {
101116
dismissible: boolean;
102117
variant: Variant;
103118
category: Category | Category[];
104-
content: Localization;
119+
content?: Localization;
105120
headline?: Localization;
106121
cta?: CTA;
107122
modal?: Modal;
@@ -110,6 +125,7 @@ export interface Message {
110125
*/
111126
feature?: Feature[];
112127
context?: Context;
128+
distribution?: ABTesting;
113129
}
114130
/**
115131
* A multilingual text localization.

suite-native/message-system/src/components/FeatureMessageScreen.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export const FeatureMessageScreen = () => {
7575
// TODO: We use only English locale in suite-native so far. When the localization to other
7676
// languages is implemented, the language selection logic has to be added here.
7777
const messageTitle = headline?.en;
78-
const messageContent = content.en;
78+
const messageContent = content?.en;
7979
const ctaLabel = cta?.label.en;
8080
const ctaLink = cta?.link;
8181
const isExternalCta = cta?.action === 'external-link';

suite-native/message-system/src/components/MessageBanner.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export const MessageBanner = ({ message }: MessageBannerProps) => {
109109

110110
// TODO: We use only English locale in suite-native so far. When the localization to other
111111
// languages is implemented, the language selection logic has to be added here.
112-
const messageContent = message.content.en;
112+
const messageContent = message.content?.en;
113113

114114
const isMessageDismissible = message.dismissible;
115115

@@ -142,7 +142,7 @@ export const MessageBanner = ({ message }: MessageBannerProps) => {
142142
</Box>
143143
<VStack spacing="sp4" style={applyStyle(messageTextContainerStyle)}>
144144
<Text color="textSubdued" variant="hint">
145-
{messageContent}
145+
{messageContent ?? ''}
146146
</Text>
147147

148148
{message.cta && <MessageLink messageCTA={message.cta} />}

0 commit comments

Comments
 (0)