Skip to content

Commit 13944da

Browse files
authored
feat: introduce rudimentary RTL support to the RN SDK (#3522)
## 🎯 Goal This PR addresses a bunch of RTL issues our SDK has had over the last couple of majors and makes sure we have first class support for it. ## Summary This PR fixes a set of RTL specific UI regressions that were affecting several surfaces in the SDK. The issues were not all caused by the same thing. Some were caused by relying on implicit platform mirroring, some by scroll/list containers using the wrong coordinate space in RTL, and some by spacing being applied on the wrong level of a scrollable surface. The result was a mix of broken alignment, unstable animations, blank gallery content, and inconsistent behavior between iOS and Android. This PR makes those behaviors explicit and stabilizes the affected components without changing their public API. ## Problems fixed - Attachment upload preview strip behaved incorrectly in RTL, especially on Android. - Poll creation and poll result modals had broken spacing in RTL, with content appearing pinned to one side. - Poll text alignment was inconsistent between Android and iOS. - Poll inputs inside poll screens/modals were not following the same RTL text alignment behavior as the main composer input. - The image gallery could open in RTL, but images would not render even though gestures still worked. - The thread list item channel name at the top of each thread card was aligned incorrectly in RTL. ## What changed ### RTL attachment preview stability The attachment preview strip now uses a stable coordinate-space approach in RTL so it no longer fights the list/pager/layout system. - stabilized the horizontal preview list so RTL no longer depends on fragile scroll compensation behavior - removed the RTL specific scroll race that was causing inconsistent Android behavior - kept the visual RTL ordering/alignment behavior while avoiding the previous listwide bounce/shift issues - disabled problematic overscroll behavior where needed so native scroll physics stop interfering with the preview strip Unfortunately, I could not at all get RTL to work natively here due to the fact that `reanimated`'s layout animations were completely messing with the underlying `ScrollView`'s width calculations when horizontal. After a couple of hours of debugging I settled with disabling RTL directly and handling all edge cases manually. This was the area with the most platform sensitivity, especially on Android. ### Poll modal spacing and layout fixes The poll modal issue turned out not to be an individual-screen bug. The main problem was that outer padding was being applied on scroll/list wrappers, which behaves badly in Android RTL and can look like horizontal padding is only applied from one side. The fix was to move spacing from outer scroll containers into their content containers for the shared poll modal surfaces. This fixes layout issues in: - create poll - poll results - poll answers list - full option results views - other shared poll modal surfaces using the same pattern As part of that cleanup, earlier safe-area wrapper changes were reverted because they were not the actual fix. ### Poll text alignment on iOS RTL Poll titles, labels, cards, and result text were relying too much on implicit platform behavior. Android happened to look mostly correct, while iOS did not. This PR makes the relevant non-input poll text alignment explicit where needed so the iOS rendering matches the intended RTL layout. It also replaces a few LTR-only spacing rules with logical start-aware spacing where the text/result rows needed it. ### Poll input alignment consistency Poll inputs now follow the same RTL alignment rule as the existing autocomplete composer input instead of using a separate behavior. That means poll inputs now use: - textAlign: I18nManager.isRTL ? 'right' : 'left' This was applied to the poll-specific input surfaces, including modal inputs, so editable text behaves consistently across the SDK. ### Image gallery rendering in RTL The gallery rendering issue was caused by a coordinate-space mismatch. The pager/slide math in the image gallery is LTR-based, but the gallery container was still inheriting RTL layout direction. In practice, that meant the gallery could open and still respond to swipe/ close gestures, while the image slides themselves were effectively laid out off-screen. The fix was to force only the gallery pager strip into LTR coordinates while leaving the rest of the experience intact. This restores image rendering in RTL without changing the gallery gesture model. ### Thread list item alignment The channel name at the top of thread list items now uses explicit alignment so it renders correctly in RTL. This is a narrow change scoped only to that text style. ## Why this approach The common theme across these issues is that implicit RTL handling was not reliable enough for these components. The fixes in this PR intentionally avoid broad “flip everything” logic. Instead, each surface now expresses the behavior it actually needs: - content containers own spacing in scrollable modal surfaces - text components that need explicit alignment get explicit alignment - input components use the same RTL rule as the main composer - animation/pager surfaces that depend on physical coordinates are pinned to an LTR coordinate space when that is what their math assumes That keeps the fixes targeted and avoids introducing new regressions in already. correct layouts. I'm sure there are other edge cases with RTL that I did not manage to notice, however as a first step this should do just fine, especially considering we've never had any RTL support before. ## 🛠 Implementation details <!-- Provide a description of the implementation --> ## 🎨 UI Changes <!-- Add relevant screenshots --> <details> <summary>iOS</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> <details> <summary>Android</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> ## 🧪 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 9bbd89c commit 13944da

31 files changed

Lines changed: 761 additions & 286 deletions

examples/SampleApp/App.tsx

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useEffect, useState } from 'react';
2-
import { DevSettings, LogBox, Platform, StyleSheet, useColorScheme, View } from 'react-native';
2+
import { DevSettings, I18nManager, LogBox, Platform, StyleSheet, useColorScheme, View } from 'react-native';
33
import { createDrawerNavigator } from '@react-navigation/drawer';
44
import { DarkTheme, DefaultTheme, NavigationContainer } from '@react-navigation/native';
55
import { createNativeStackNavigator } from '@react-navigation/native-stack';
@@ -99,6 +99,7 @@ notifee.onBackgroundEvent(async ({ detail, type }) => {
9999
const Drawer = createDrawerNavigator();
100100
const Stack = createNativeStackNavigator<StackNavigatorParamList>();
101101
const UserSelectorStack = createNativeStackNavigator<UserSelectorParamList>();
102+
const RTL_STORAGE_KEY = '@stream-rn-sampleapp-rtl-enabled';
102103

103104
const MessageOverlayBlurBackground = () => {
104105
const {
@@ -131,6 +132,7 @@ const MessageOverlayBlurBackground = () => {
131132

132133
const App = () => {
133134
const { chatClient, isConnecting, loginUser, logout, switchUser } = useChatClient();
135+
const [rtlEnabled, setRtlEnabled] = useState<boolean | undefined>(undefined);
134136
const [messageListImplementation, setMessageListImplementation] = useState<
135137
MessageListImplementationConfigItem['id'] | undefined
136138
>(undefined);
@@ -150,6 +152,15 @@ const App = () => {
150152
const streamChatTheme = useStreamChatTheme();
151153
const streami18n = new Streami18n();
152154

155+
const setRTLEnabled = React.useCallback(async (enabled: boolean) => {
156+
await AsyncStore.setItem(RTL_STORAGE_KEY, enabled);
157+
I18nManager.allowRTL(enabled);
158+
I18nManager.forceRTL(enabled);
159+
I18nManager.swapLeftAndRightInRTL(enabled);
160+
setRtlEnabled(enabled);
161+
DevSettings.reload();
162+
}, []);
163+
153164
useEffect(() => {
154165
const messaging = getMessaging();
155166
const unsubscribeOnNotificationOpen = messaging.onNotificationOpenedApp((remoteMessage) => {
@@ -188,7 +199,21 @@ const App = () => {
188199
}
189200
}
190201
});
191-
const getMessageListConfig = async () => {
202+
const getAppConfig = async () => {
203+
const storedRTLEnabled = await AsyncStore.getItem<boolean>(RTL_STORAGE_KEY, false);
204+
const nextRTLEnabled = !!storedRTLEnabled;
205+
206+
I18nManager.allowRTL(nextRTLEnabled);
207+
I18nManager.forceRTL(nextRTLEnabled);
208+
I18nManager.swapLeftAndRightInRTL(nextRTLEnabled);
209+
210+
if (I18nManager.isRTL !== nextRTLEnabled) {
211+
DevSettings.reload();
212+
return;
213+
}
214+
215+
setRtlEnabled(nextRTLEnabled);
216+
192217
const messageListImplementationStoredValue = await AsyncStore.getItem(
193218
'@stream-rn-sampleapp-messagelist-implementation',
194219
{ id: 'flatlist' },
@@ -223,7 +248,7 @@ const App = () => {
223248
messageOverlayBackdropStoredValue?.value as MessageOverlayBackdropConfigItem['value'],
224249
);
225250
};
226-
getMessageListConfig();
251+
getAppConfig();
227252
return () => {
228253
unsubscribeOnNotificationOpen();
229254
unsubscribeForegroundEvent();
@@ -258,7 +283,7 @@ const App = () => {
258283
});
259284
}, [chatClient]);
260285

261-
if (!messageListImplementation || !messageListMode) {
286+
if (rtlEnabled === undefined || !messageListImplementation || !messageListMode) {
262287
return;
263288
}
264289

@@ -294,6 +319,8 @@ const App = () => {
294319
loginUser,
295320
logout,
296321
switchUser,
322+
rtlEnabled,
323+
setRTLEnabled,
297324
messageListImplementation,
298325
messageInputFloating: messageInputFloating ?? false,
299326
messageListMode,

examples/SampleApp/src/components/MenuDrawer.tsx

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
import React, { useCallback, useEffect, useState } from 'react';
2-
import { Image, StyleSheet, Text, TouchableOpacity, Pressable, View } from 'react-native';
2+
import {
3+
I18nManager,
4+
Image,
5+
Pressable,
6+
StyleSheet,
7+
Switch,
8+
Text,
9+
TouchableOpacity,
10+
View,
11+
} from 'react-native';
312
import { Edit, useTheme } from 'stream-chat-react-native';
413

514
import { useAppContext } from '../context/AppContext';
@@ -31,15 +40,39 @@ export const styles = StyleSheet.create({
3140
paddingHorizontal: 16,
3241
paddingVertical: 12,
3342
},
43+
menuItemContent: {
44+
alignItems: 'center',
45+
flex: 1,
46+
flexDirection: 'row',
47+
},
3448
menuTitle: {
3549
fontSize: 14,
3650
fontWeight: '500',
37-
marginLeft: 16,
51+
marginStart: 16,
52+
},
53+
rtlDescription: {
54+
fontSize: 12,
55+
marginStart: 16,
56+
marginTop: 2,
57+
},
58+
rtlMenuItem: {
59+
justifyContent: 'space-between',
60+
},
61+
rtlMenuItemContent: {
62+
alignItems: 'center',
63+
flex: 1,
64+
flexDirection: 'row',
65+
},
66+
rtlTextContainer: {
67+
flex: 1,
68+
},
69+
rowReverse: {
70+
flexDirection: 'row-reverse',
3871
},
3972
userName: {
4073
fontSize: 16,
4174
fontWeight: '600',
42-
marginLeft: 16,
75+
marginStart: 16,
4376
},
4477
userRow: {
4578
alignItems: 'center',
@@ -51,6 +84,7 @@ export const styles = StyleSheet.create({
5184
export const MenuDrawer = ({ navigation }: DrawerContentComponentProps) => {
5285
const [secretMenuPressCounter, setSecretMenuPressCounter] = useState(0);
5386
const [secretMenuVisible, setSecretMenuVisible] = useState(false);
87+
const isRTL = I18nManager.isRTL;
5488
useTheme();
5589
const { black, grey, white } = useLegacyColors();
5690

@@ -65,7 +99,7 @@ export const MenuDrawer = ({ navigation }: DrawerContentComponentProps) => {
6599
setSecretMenuVisible(false);
66100
}, []);
67101

68-
const { chatClient, logout } = useAppContext();
102+
const { chatClient, logout, rtlEnabled, setRTLEnabled } = useAppContext();
69103

70104
if (!chatClient) {
71105
return null;
@@ -74,7 +108,10 @@ export const MenuDrawer = ({ navigation }: DrawerContentComponentProps) => {
74108
return (
75109
<View style={[styles.container, { backgroundColor: white }]}>
76110
<SafeAreaView style={{ flex: 1 }}>
77-
<Pressable onPress={() => setSecretMenuPressCounter((c) => c + 1)} style={[styles.userRow]}>
111+
<Pressable
112+
onPress={() => setSecretMenuPressCounter((c) => c + 1)}
113+
style={[styles.userRow, isRTL && styles.rowReverse]}
114+
>
78115
<Image
79116
source={{
80117
uri: chatClient.user?.image,
@@ -84,6 +121,7 @@ export const MenuDrawer = ({ navigation }: DrawerContentComponentProps) => {
84121
<Text
85122
style={[
86123
styles.userName,
124+
isRTL && { textAlign: 'right' },
87125
{
88126
color: black,
89127
},
@@ -105,12 +143,13 @@ export const MenuDrawer = ({ navigation }: DrawerContentComponentProps) => {
105143
screen: 'NewDirectMessagingScreen',
106144
})
107145
}
108-
style={styles.menuItem}
146+
style={[styles.menuItem, isRTL && styles.rowReverse]}
109147
>
110148
<Edit height={24} stroke={grey} width={24} />
111149
<Text
112150
style={[
113151
styles.menuTitle,
152+
isRTL && { textAlign: 'right' },
114153
{
115154
color: black,
116155
},
@@ -125,12 +164,13 @@ export const MenuDrawer = ({ navigation }: DrawerContentComponentProps) => {
125164
screen: 'NewGroupChannelAddMemberScreen',
126165
})
127166
}
128-
style={styles.menuItem}
167+
style={[styles.menuItem, isRTL && styles.rowReverse]}
129168
>
130169
<Group height={24} pathFill={grey} width={24} />
131170
<Text
132171
style={[
133172
styles.menuTitle,
173+
isRTL && { textAlign: 'right' },
134174
{
135175
color: black,
136176
},
@@ -139,17 +179,67 @@ export const MenuDrawer = ({ navigation }: DrawerContentComponentProps) => {
139179
New Group
140180
</Text>
141181
</TouchableOpacity>
182+
<View
183+
style={[
184+
styles.menuItem,
185+
styles.rtlMenuItem,
186+
isRTL && styles.rowReverse,
187+
]}
188+
>
189+
<View
190+
style={[
191+
styles.rtlMenuItemContent,
192+
isRTL && styles.rowReverse,
193+
]}
194+
>
195+
<User height={24} pathFill={grey} width={24} />
196+
<View style={styles.rtlTextContainer}>
197+
<Text
198+
style={[
199+
styles.menuTitle,
200+
isRTL && { textAlign: 'right' },
201+
{
202+
color: black,
203+
},
204+
]}
205+
>
206+
RTL Layout
207+
</Text>
208+
<Text
209+
style={[
210+
styles.rtlDescription,
211+
isRTL && { textAlign: 'right' },
212+
{
213+
color: grey,
214+
},
215+
]}
216+
>
217+
Enable RTL layout
218+
</Text>
219+
</View>
220+
</View>
221+
<Switch
222+
onValueChange={setRTLEnabled}
223+
thumbColor={white}
224+
trackColor={{
225+
false: grey,
226+
true: black,
227+
}}
228+
value={rtlEnabled}
229+
/>
230+
</View>
142231
</View>
143232
<TouchableOpacity
144233
onPress={() => {
145234
logout();
146235
}}
147-
style={styles.menuItem}
236+
style={[styles.menuItem, isRTL && styles.rowReverse]}
148237
>
149238
<User height={24} pathFill={grey} width={24} />
150239
<Text
151240
style={[
152241
styles.menuTitle,
242+
isRTL && { textAlign: 'right' },
153243
{
154244
color: black,
155245
},

examples/SampleApp/src/context/AppContext.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ type AppContextType = {
1515
loginUser: (config: LoginConfig) => void;
1616
logout: () => void;
1717
switchUser: (userId?: string) => void;
18+
rtlEnabled: boolean;
19+
setRTLEnabled: (enabled: boolean) => Promise<void>;
1820
messageListImplementation: MessageListImplementationConfigItem['id'];
1921
messageInputFloating: MessageInputFloatingConfigItem['value'];
2022
messageListMode: MessageListModeConfigItem['mode'];

package/src/components/AttachmentPicker/AttachmentPicker.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import dayjs from 'dayjs';
1313
import duration from 'dayjs/plugin/duration';
1414

1515
import { useAttachmentPickerContext } from '../../contexts/attachmentPickerContext/AttachmentPickerContext';
16+
import { useTheme } from '../../contexts/themeContext/ThemeContext';
1617
import { useStableCallback } from '../../hooks';
1718
import { BottomSheet } from '../BottomSheetCompatibility/BottomSheet';
1819
import { KeyboardControllerPackage } from '../KeyboardCompatibleView/KeyboardControllerAvoidingView';
@@ -38,6 +39,9 @@ export const AttachmentPicker = () => {
3839
bottomSheetRef: ref,
3940
disableAttachmentPicker,
4041
} = useAttachmentPickerContext();
42+
const {
43+
theme: { semantics },
44+
} = useTheme();
4145

4246
const [currentIndex, setCurrentIndexInternal] = useState(-1);
4347
const currentIndexRef = useRef<number>(currentIndex);
@@ -105,9 +109,19 @@ export const AttachmentPicker = () => {
105109
});
106110

107111
const animationConfigs = useBottomSheetSpringConfigs(SPRING_CONFIG);
112+
const backgroundStyle = useMemo(
113+
() => ({
114+
backgroundColor: semantics.backgroundCoreElevation1,
115+
borderTopWidth: 0,
116+
elevation: Platform.OS === 'android' ? 0 : undefined,
117+
shadowOpacity: Platform.OS === 'android' ? 0 : undefined,
118+
}),
119+
[semantics.backgroundCoreElevation1],
120+
);
108121

109122
return (
110123
<BottomSheet
124+
backgroundStyle={backgroundStyle}
111125
enablePanDownToClose={false}
112126
enableContentPanningGesture={false}
113127
enableDynamicSizing={false}

package/src/components/ImageGallery/ImageGallery.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,10 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) =>
284284
<Animated.View style={[StyleSheet.absoluteFillObject, containerBackground]} />
285285
<GestureDetector gesture={Gesture.Simultaneous(singleTap, doubleTap, pinch, pan)}>
286286
<Animated.View style={StyleSheet.absoluteFillObject}>
287-
<Animated.View style={[styles.animatedContainer, pagerStyle, pager]}>
287+
<Animated.View
288+
testID='image-gallery-pager'
289+
style={[styles.animatedContainer, pagerStyle, pager]}
290+
>
288291
{assets.map((photo, i) =>
289292
photo.type === FileTypes.Video ? (
290293
<AnimatedGalleryVideo
@@ -399,6 +402,7 @@ export const clamp = (value: number, lowerBound: number, upperBound: number) =>
399402
const styles = StyleSheet.create({
400403
animatedContainer: {
401404
alignItems: 'center',
405+
direction: 'ltr',
402406
flexDirection: 'row',
403407
},
404408
});

package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { useEffect, useState } from 'react';
2+
import { I18nManager, StyleSheet } from 'react-native';
23

34
import type { SharedValue } from 'react-native-reanimated';
45

@@ -79,6 +80,19 @@ const ImageGalleryComponent = (props: ImageGalleryProps & { message: LocalMessag
7980
};
8081

8182
describe('ImageGallery', () => {
83+
const originalIsRTL = I18nManager.isRTL;
84+
85+
const setRTL = (value: boolean) => {
86+
Object.defineProperty(I18nManager, 'isRTL', {
87+
configurable: true,
88+
value,
89+
});
90+
};
91+
92+
afterEach(() => {
93+
setRTL(originalIsRTL);
94+
});
95+
8296
it('render image gallery component', async () => {
8397
render(
8498
<ImageGalleryComponent
@@ -99,4 +113,23 @@ describe('ImageGallery', () => {
99113
expect(screen.queryAllByLabelText('Image Gallery Video')).toHaveLength(1);
100114
});
101115
});
116+
117+
it('keeps the pager in ltr coordinates when rtl is enabled', async () => {
118+
setRTL(true);
119+
120+
render(
121+
<ImageGalleryComponent
122+
message={
123+
generateMessage({
124+
attachments: [generateImageAttachment()],
125+
}) as unknown as LocalMessage
126+
}
127+
/>,
128+
);
129+
130+
await waitFor(() => {
131+
const pagerStyle = StyleSheet.flatten(screen.getByTestId('image-gallery-pager').props.style);
132+
expect(pagerStyle.direction).toBe('ltr');
133+
});
134+
});
102135
});

0 commit comments

Comments
 (0)