Skip to content

Commit 58b3dbe

Browse files
authored
fix: advanced theming of message (myMessageTheme) (#3759)
## 🎯 Goal fixes #3755 Allow using light-on-dark for outgoing messages; and dark-on-light for outgoing messages. To achieve this, it's not enough to customize theme tokens; we have to use `myMessageTheme` to provide a completely separate theme for outgoing messages only. This is a preexsting mechanism in the SDK; but had a few small issues preventing working correctly. To verify a new cookbook is added: GetStream/docs-content#1490 ## 🛠 Implementation details There were a few different theming issues here; all fixed in a separate commit in this PR: - The theme merging wiped preexisting custom semantics, causing problems when doing theme overrides on different levels (which is what is used with `myMessageTheme`) - Message list components didn't provide the current scheme (light/dark) when merging the `myMessageTheme` - A various number of components used incmoing semantic tokens instead of reading `isMyMessages` and selecting incoming/outgoing token based on that - Quoted message was styled based on if qouted message was sent by current user; instead of parent message was sent by current user -> this didn't cause a UI issue with the default theme; but visible when using different text colors for incoming/outgoing texts - The default UI visibly changed on three places; all of them are verified by figma (see screenshots later): 1. The SDK unnecessarily changed failed/moderated outgoing messages background to incoming 2. When editing a message the original in the composer was always styled as incoming not outgoing 3. Opening our own poll in the poll modal used an inaccurate color for the vote circle ## 🎨 UI Changes Before - failed outgoing message background changed to incoming: <img height="500" alt="IMG_1870" src="https://github.com/user-attachments/assets/8d9d6cc4-c53d-4908-8595-ccc9f33527e0" /> After - in line with Figma: <img height="500" alt="IMG_1900" src="https://github.com/user-attachments/assets/70d657e2-f259-49ef-ad62-e69210dc16ab" /> Before - editing our own message styled as incmoing in composer: <img height="500" alt="IMG_1867 2" src="https://github.com/user-attachments/assets/ca9635fb-147c-4426-829d-91221491c03a" /> After - in line with Figma: <img height="500" alt="IMG_1868" src="https://github.com/user-attachments/assets/505c6f44-d8ab-4b40-bf93-94391f61f12e" /> Before - poll modal vote circle inaccurate color: <img height="500" alt="IMG_1866 3" src="https://github.com/user-attachments/assets/41f60da0-e27a-471a-b8a1-8eed00c5c489" /> After - in line with Figma: <img height="500" alt="IMG_1869" src="https://github.com/user-attachments/assets/2e13265a-c1a6-4b17-bc6a-3a0c815160d0" /> ## 🧪 Testing <!-- Explain how this change can be tested (or why it can't be tested) --> ## ☑️ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android
1 parent a23c0da commit 58b3dbe

22 files changed

Lines changed: 569 additions & 67 deletions

File tree

‎package/jest-setup.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ jest.mock('react-native/Libraries/Components/RefreshControl/RefreshControl', ()
109109
__esModule: true,
110110
default: require('./__mocks__/RefreshControlMock'),
111111
}));
112+
jest.mock('react-native/Libraries/Utilities/Appearance', () => ({
113+
addChangeListener: () => ({ remove: () => {} }),
114+
getColorScheme: () => 'light',
115+
setColorScheme: () => {},
116+
}));
112117

113118
jest.mock('@shopify/flash-list', () => ({
114119
FlashList: undefined,

‎package/src/components/Attachment/Attachment.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ const useAudioAttachmentStyles = () => {
257257
: semantics.chatBorderOnChatIncoming,
258258
},
259259
durationText: {
260-
color: semantics.chatTextIncoming,
260+
color: isMyMessage ? semantics.chatTextOutgoing : semantics.chatTextIncoming,
261261
fontWeight: primitives.typographyFontWeightSemiBold,
262262
},
263263
leftContainer: {

‎package/src/components/Attachment/Audio/PlayPauseButton.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ const useStyles = () => {
6565
justifyContent: 'center',
6666
alignItems: 'center',
6767
borderWidth: 1,
68-
borderColor: semantics.chatBorderOnChatIncoming,
68+
borderColor: semantics.controlPlaybackToggleBorder,
6969
},
7070
});
7171
}, [semantics]);
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import React from 'react';
2+
import { StyleSheet } from 'react-native';
3+
4+
import { render, screen } from '@testing-library/react-native';
5+
6+
import { mergeThemes, ThemeProvider } from '../../../../contexts/themeContext/ThemeContext';
7+
import { PlayPauseButton } from '../PlayPauseButton';
8+
9+
describe('PlayPauseButton', () => {
10+
const lightTheme = mergeThemes({ scheme: 'light' });
11+
12+
const renderButton = (containerStyle?: Record<string, unknown>) =>
13+
render(
14+
<ThemeProvider>
15+
<PlayPauseButton
16+
containerStyle={containerStyle}
17+
isPlaying={false}
18+
onPress={jest.fn()}
19+
testID='play-pause-button'
20+
/>
21+
</ThemeProvider>,
22+
);
23+
24+
const borderColorOf = () => {
25+
const style = screen.getByTestId('play-pause-button').props.style;
26+
return StyleSheet.flatten(typeof style === 'function' ? style({ pressed: false }) : style)
27+
?.borderColor;
28+
};
29+
30+
// This control also renders in the composer's attachment previews, which are
31+
// not a message bubble - so it must not reach for a chat side token.
32+
it('defaults to the neutral playback toggle border, not a chat bubble border', () => {
33+
renderButton();
34+
35+
expect(borderColorOf()).toBe(lightTheme.semantics.controlPlaybackToggleBorder);
36+
expect(borderColorOf()).not.toBe(lightTheme.semantics.chatBorderOnChatIncoming);
37+
});
38+
39+
it('lets in-bubble callers override the border with the resolved chat side', () => {
40+
renderButton({ borderColor: lightTheme.semantics.chatBorderOnChatOutgoing });
41+
42+
expect(borderColorOf()).toBe(lightTheme.semantics.chatBorderOnChatOutgoing);
43+
});
44+
});

‎package/src/components/Attachment/UrlPreview/URLPreview.tsx‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,6 @@ const URLPreviewWithContext = (props: URLPreviewPropsWithContext) => {
6868

6969
const { icons } = useComponentsContext();
7070

71-
const {
72-
theme: { semantics },
73-
} = useTheme();
74-
7571
const { image_url, og_scrape_url, text, thumb_url, title, type } = attachment;
7672

7773
const {
@@ -156,7 +152,7 @@ const URLPreviewWithContext = (props: URLPreviewPropsWithContext) => {
156152
</Text>
157153
) : null}
158154
<View style={[styles.linkPreview, linkPreview, stylesProp.linkPreview]}>
159-
<icons.Link height={12} width={12} stroke={semantics.chatTextIncoming} />
155+
<icons.Link height={12} width={12} stroke={styles.linkPreviewText.color} />
160156
<Text
161157
numberOfLines={1}
162158
style={[styles.linkPreviewText, linkPreviewText, stylesProp.linkPreviewText]}
@@ -267,13 +263,13 @@ const useStyles = () => {
267263
padding: primitives.spacingSm,
268264
},
269265
title: {
270-
color: semantics.chatTextIncoming,
266+
color: isMyMessage ? semantics.chatTextOutgoing : semantics.chatTextIncoming,
271267
fontSize: primitives.typographyFontSizeSm,
272268
fontWeight: primitives.typographyFontWeightSemiBold,
273269
lineHeight: primitives.typographyLineHeightTight,
274270
},
275271
description: {
276-
color: semantics.chatTextIncoming,
272+
color: isMyMessage ? semantics.chatTextOutgoing : semantics.chatTextIncoming,
277273
fontSize: primitives.typographyFontSizeXs,
278274
fontWeight: primitives.typographyFontWeightRegular,
279275
lineHeight: primitives.typographyLineHeightTight,
@@ -284,7 +280,7 @@ const useStyles = () => {
284280
gap: primitives.spacingXxs,
285281
},
286282
linkPreviewText: {
287-
color: semantics.chatTextIncoming,
283+
color: isMyMessage ? semantics.chatTextOutgoing : semantics.chatTextIncoming,
288284
fontSize: primitives.typographyFontSizeXs,
289285
fontWeight: primitives.typographyFontWeightRegular,
290286
lineHeight: primitives.typographyLineHeightTight,

‎package/src/components/Attachment/UrlPreview/URLPreviewCompact.tsx‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,6 @@ const URLPreviewCompactWithContext = (props: URLPreviewCompactPropsWithContext)
6464

6565
const { icons } = useComponentsContext();
6666

67-
const {
68-
theme: { semantics },
69-
} = useTheme();
70-
7167
const { image_url, og_scrape_url, text, thumb_url, title: titleText, type } = attachment;
7268

7369
const {
@@ -158,7 +154,7 @@ const URLPreviewCompactWithContext = (props: URLPreviewCompactPropsWithContext)
158154
</Text>
159155
) : null}
160156
<View style={[styles.linkPreview, linkPreview, stylesProp.linkPreview]}>
161-
<icons.Link height={12} width={12} stroke={semantics.chatTextIncoming} />
157+
<icons.Link height={12} width={12} stroke={styles.linkPreviewText.color} />
162158
<Text
163159
numberOfLines={1}
164160
style={[styles.linkPreviewText, linkPreviewText, stylesProp.linkPreviewText]}
@@ -271,13 +267,13 @@ const useStyles = () => {
271267
flexShrink: 1,
272268
},
273269
title: {
274-
color: semantics.chatTextIncoming,
270+
color: isMyMessage ? semantics.chatTextOutgoing : semantics.chatTextIncoming,
275271
fontSize: primitives.typographyFontSizeSm,
276272
fontWeight: primitives.typographyFontWeightSemiBold,
277273
lineHeight: primitives.typographyLineHeightTight,
278274
},
279275
description: {
280-
color: semantics.chatTextIncoming,
276+
color: isMyMessage ? semantics.chatTextOutgoing : semantics.chatTextIncoming,
281277
fontSize: primitives.typographyFontSizeXs,
282278
fontWeight: primitives.typographyFontWeightRegular,
283279
lineHeight: primitives.typographyLineHeightTight,
@@ -288,7 +284,7 @@ const useStyles = () => {
288284
gap: primitives.spacingXxs,
289285
},
290286
linkPreviewText: {
291-
color: semantics.chatTextIncoming,
287+
color: isMyMessage ? semantics.chatTextOutgoing : semantics.chatTextIncoming,
292288
fontSize: primitives.typographyFontSizeXs,
293289
fontWeight: primitives.typographyFontWeightRegular,
294290
lineHeight: primitives.typographyLineHeightTight,
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import React from 'react';
2+
import { StyleSheet } from 'react-native';
3+
4+
import { cleanup, render, screen, waitFor } from '@testing-library/react-native';
5+
6+
import type { MessageContextValue } from '../../../../contexts/messageContext/MessageContext';
7+
import { MessageProvider } from '../../../../contexts/messageContext/MessageContext';
8+
import type { MessagesContextValue } from '../../../../contexts/messagesContext/MessagesContext';
9+
import { MessagesProvider } from '../../../../contexts/messagesContext/MessagesContext';
10+
import { mergeThemes, ThemeProvider } from '../../../../contexts/themeContext/ThemeContext';
11+
import { generateCardAttachment } from '../../../../mock-builders/generator/attachment';
12+
import { generateMessage } from '../../../../mock-builders/generator/message';
13+
import { URLPreview } from '../URLPreview';
14+
import { URLPreviewCompact } from '../URLPreviewCompact';
15+
16+
const lightTheme = mergeThemes({ scheme: 'light' });
17+
18+
/**
19+
* Both URL preview variants resolve every text colour from the same
20+
* incoming/outgoing pair, so the assertions are shared.
21+
*/
22+
describe.each([
23+
['URLPreview', URLPreview],
24+
['URLPreviewCompact', URLPreviewCompact],
25+
])('%s chat text side', (_name, Component) => {
26+
const renderPreview = (messageContextValue: Partial<MessageContextValue> = {}) => {
27+
const attachment = generateCardAttachment({ title: 'A title', text: 'A description' });
28+
29+
return render(
30+
<ThemeProvider>
31+
<MessagesProvider value={{} as unknown as MessagesContextValue}>
32+
<MessageProvider
33+
value={
34+
{
35+
message: generateMessage(),
36+
...messageContextValue,
37+
} as unknown as MessageContextValue
38+
}
39+
>
40+
<Component attachment={attachment} />
41+
</MessageProvider>
42+
</MessagesProvider>
43+
</ThemeProvider>,
44+
);
45+
};
46+
47+
const colorOf = (text: string) => StyleSheet.flatten(screen.getByText(text).props.style)?.color;
48+
49+
afterEach(cleanup);
50+
51+
it('uses the outgoing chat text colour on our own message', async () => {
52+
renderPreview({ isMyMessage: true });
53+
54+
await waitFor(() => {
55+
expect(colorOf('A title')).toBe(lightTheme.semantics.chatTextOutgoing);
56+
});
57+
expect(colorOf('A description')).toBe(lightTheme.semantics.chatTextOutgoing);
58+
});
59+
60+
it("uses the incoming chat text colour on another user's message", async () => {
61+
renderPreview({ isMyMessage: false });
62+
63+
await waitFor(() => {
64+
expect(colorOf('A title')).toBe(lightTheme.semantics.chatTextIncoming);
65+
});
66+
expect(colorOf('A description')).toBe(lightTheme.semantics.chatTextIncoming);
67+
});
68+
69+
it('uses the outgoing attachment background on our own message', async () => {
70+
renderPreview({ isMyMessage: true });
71+
72+
await waitFor(() => {
73+
expect(StyleSheet.flatten(screen.getByTestId('card-attachment').props.style)).toEqual(
74+
expect.objectContaining({
75+
backgroundColor: lightTheme.semantics.chatBgAttachmentOutgoing,
76+
}),
77+
);
78+
});
79+
});
80+
81+
it('strokes the link icon with the same colour as the link text', async () => {
82+
renderPreview({ isMyMessage: true });
83+
84+
// The URL text and its leading icon must not disagree on the side.
85+
await waitFor(() => {
86+
expect(screen.getByTestId('card-attachment')).toBeTruthy();
87+
});
88+
89+
const linkIcon = screen.UNSAFE_getByProps({ stroke: lightTheme.semantics.chatTextOutgoing });
90+
expect(linkIcon).toBeTruthy();
91+
});
92+
});

‎package/src/components/Attachment/__tests__/Attachment.test.tsx‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,54 @@ describe('Attachment', () => {
218218
isSoundPackageAvailable.mockReturnValue(false);
219219
});
220220

221+
it('uses the outgoing audio player background on our own message', async () => {
222+
const { isSoundPackageAvailable } = require('../../../native');
223+
isSoundPackageAvailable.mockReturnValue(true);
224+
const attachment = generateAudioAttachment({ duration: 10, waveform_data: [0.2, 0.6] });
225+
const message = generateMessage({ attachments: [attachment, generateAudioAttachment()] });
226+
227+
const { getByLabelText } = render(
228+
getAttachmentComponent(
229+
{ attachment },
230+
{ isMyMessage: true, message, messageHasOnlySingleAttachment: false },
231+
),
232+
);
233+
234+
await waitFor(() => {
235+
const style = StyleSheet.flatten(getByLabelText('audio-attachment-preview').props.style);
236+
expect(style.backgroundColor).toBe(lightTheme.semantics.chatBgAttachmentOutgoing);
237+
});
238+
isSoundPackageAvailable.mockReturnValue(false);
239+
});
240+
241+
it('resolves the audio duration label colour from the message side', async () => {
242+
const { isSoundPackageAvailable } = require('../../../native');
243+
isSoundPackageAvailable.mockReturnValue(true);
244+
const attachment = generateAudioAttachment({ duration: 10, waveform_data: [0.2, 0.6] });
245+
const message = generateMessage({ attachments: [attachment] });
246+
247+
const { getByLabelText } = render(
248+
getAttachmentComponent(
249+
{ attachment },
250+
{ isMyMessage: true, message, messageHasOnlySingleAttachment: false },
251+
),
252+
);
253+
254+
// NOTE: `StableDurationLabel` applies its own `visibleStyle` last, which
255+
// always sets `color` from the playback state - so this asserts the wiring
256+
// reaching the label, not the final rendered pixel. See the reserve label.
257+
await waitFor(() => {
258+
expect(getByLabelText('Progress Duration').props.style).toEqual(
259+
expect.arrayContaining([
260+
expect.arrayContaining([
261+
expect.objectContaining({ color: lightTheme.semantics.chatTextOutgoing }),
262+
]),
263+
]),
264+
);
265+
});
266+
isSoundPackageAvailable.mockReturnValue(false);
267+
});
268+
221269
it('should render UrlPreview component if attachment has title_link or og_scrape_url', async () => {
222270
const attachment = generateImageAttachment({
223271
og_scrape_url: uuidv4(),

‎package/src/components/Message/MessageItemView/MessageItemView.tsx‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,6 @@ const MessageItemViewWithContext = (props: MessageItemViewPropsWithContext) => {
171171

172172
const {
173173
isMessageErrorType,
174-
isMessageReceivedOrErrorType,
175174
isMessageTypeDeleted,
176175
isVeryLastMessage,
177176
messageGroupedSingleOrBottom,
@@ -200,7 +199,7 @@ const MessageItemViewWithContext = (props: MessageItemViewPropsWithContext) => {
200199
backgroundColor = 'transparent';
201200
} else if (hasStandaloneGiphyOrImgur) {
202201
backgroundColor = 'transparent';
203-
} else if (isMessageReceivedOrErrorType) {
202+
} else if (!isMyMessage) {
204203
backgroundColor = semantics.chatBgIncoming;
205204
}
206205

‎package/src/components/Message/MessageItemView/__tests__/MessageTextContainer.test.tsx‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import React from 'react';
2-
import { Text } from 'react-native';
2+
import { StyleSheet, Text } from 'react-native';
33

44
import { cleanup, render, waitFor } from '@testing-library/react-native';
55

66
import { WithComponents } from '../../../../contexts/componentsContext/ComponentsContext';
77
import { OverlayProvider } from '../../../../contexts/overlayContext/OverlayProvider';
8-
import { ThemeProvider } from '../../../../contexts/themeContext/ThemeContext';
8+
import { mergeThemes, ThemeProvider } from '../../../../contexts/themeContext/ThemeContext';
99
import { defaultTheme } from '../../../../contexts/themeContext/utils/theme';
1010
import { getOrCreateChannelApi } from '../../../../mock-builders/api/getOrCreateChannel';
1111
import { useMockedApis } from '../../../../mock-builders/api/useMockedApis';
@@ -22,6 +22,8 @@ import { MessageList } from '../../../MessageList/MessageList';
2222
import { MessageTextContainer } from '../MessageTextContainer';
2323

2424
describe('MessageTextContainer', () => {
25+
const lightTheme = mergeThemes({ scheme: 'light' });
26+
2527
afterEach(cleanup);
2628

2729
it('should render message text container', async () => {
@@ -110,4 +112,20 @@ describe('MessageTextContainer', () => {
110112
expect(getByText(message.i18n.no_text)).toBeTruthy();
111113
});
112114
});
115+
116+
it('renders our own message text with the outgoing chat text colour', async () => {
117+
const message = generateMessage({ user: generateStaticUser(1) });
118+
119+
const { getByText } = render(
120+
<ThemeProvider>
121+
<MessageTextContainer isMyMessage message={message} />
122+
</ThemeProvider>,
123+
);
124+
125+
await waitFor(() => {
126+
expect(StyleSheet.flatten(getByText(message.text as string).props.style)?.color).toBe(
127+
lightTheme.semantics.chatTextOutgoing,
128+
);
129+
});
130+
});
113131
});

0 commit comments

Comments
 (0)