Skip to content

Commit 6a80d73

Browse files
authored
feat: theming support & modernized AITypingIndicatorView (#9)
This PR mainly introduces theming support for the React Native SDK. It also takes care of: - Renaming the exported components to be in line with the other SDKs - Implementing a modernized `AITypingIndicatorView` with a shimmer effect (that can be included anywhere) - Quality of life improvements of the sample app - Exports the speech-to-text button as a standalone component - Exports some missing services/contexts from the SDK
1 parent 11314d5 commit 6a80d73

File tree

39 files changed

+1378
-794
lines changed

39 files changed

+1378
-794
lines changed

examples/ReactNativeChatGPTSample/App.tsx

Lines changed: 101 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
View,
1212
Text,
1313
Alert,
14-
Dimensions,
1514
} from 'react-native';
1615
import {
1716
SafeAreaProvider,
@@ -22,7 +21,6 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
2221
import React, { useCallback, useMemo } from 'react';
2322
import {
2423
AIStates,
25-
AITypingIndicatorView,
2624
Channel,
2725
ChannelList,
2826
ChannelPreviewMessengerProps,
@@ -37,7 +35,6 @@ import {
3735
MessageList,
3836
MessageProps,
3937
OverlayProvider,
40-
ThemeProvider,
4138
useAIState,
4239
useChannelContext,
4340
useChannelsContext,
@@ -47,6 +44,8 @@ import {
4744
useMessageInputContext,
4845
useStableCallback,
4946
useTheme,
47+
ThemeProvider,
48+
isLocalUrl,
5049
} from 'stream-chat-react-native';
5150
import { AppProvider, useAppContext } from './contexts/AppContext.tsx';
5251
import {
@@ -62,9 +61,11 @@ import {
6261
} from 'stream-chat';
6362
import { startAI } from './http/requests.ts';
6463
import {
65-
MarkdownRichText,
66-
AIMessageComposer,
67-
AIMessageComposerProps,
64+
StreamingMessageView,
65+
ComposerView,
66+
StreamTheme,
67+
AITypingIndicatorView,
68+
type ComposerViewProps,
6869
} from '@stream-io/ai-components-react-native';
6970
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
7071

@@ -88,19 +89,21 @@ function App() {
8889
return (
8990
<SafeAreaProvider>
9091
<AppProvider client={chatClient}>
91-
<GestureHandlerRootView style={{ flex: 1 }}>
92-
<OverlayProvider value={{ style: chatTheme }}>
93-
<Chat
94-
client={chatClient}
95-
isMessageAIGenerated={isMessageAIGenerated}
96-
enableOfflineSupport={false}
97-
>
98-
<NavigationContainer>
99-
<DrawerNavigator />
100-
</NavigationContainer>
101-
</Chat>
102-
</OverlayProvider>
103-
</GestureHandlerRootView>
92+
<StreamTheme>
93+
<GestureHandlerRootView style={{ flex: 1 }}>
94+
<OverlayProvider value={{ style: chatTheme }}>
95+
<Chat
96+
client={chatClient}
97+
isMessageAIGenerated={isMessageAIGenerated}
98+
enableOfflineSupport={false}
99+
>
100+
<NavigationContainer>
101+
<DrawerNavigator />
102+
</NavigationContainer>
103+
</Chat>
104+
</OverlayProvider>
105+
</GestureHandlerRootView>
106+
</StreamTheme>
104107
</AppProvider>
105108
</SafeAreaProvider>
106109
);
@@ -177,21 +180,18 @@ const DrawerNavigator = () => (
177180
</Drawer.Navigator>
178181
);
179182

183+
const additionalFlatListProps = {
184+
maintainVisibleContentPosition: {
185+
minIndexForVisible: 0,
186+
autoscrollToTopThreshold: 0,
187+
},
188+
ListHeaderComponent: null,
189+
};
190+
180191
const AppContent = () => {
181192
const { channel } = useAppContext();
182193
const { bottom } = useSafeAreaInsets();
183194

184-
const safeAreaInsets = useSafeAreaInsets();
185-
const insets = useMemo(
186-
() => ({
187-
...safeAreaInsets,
188-
bottom:
189-
safeAreaInsets.bottom +
190-
(Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) * 2 : 0),
191-
}),
192-
[safeAreaInsets],
193-
);
194-
195195
const preSendMessageRequest = useStableCallback(async ({ localMessage }) => {
196196
if (!channel) {
197197
return;
@@ -201,6 +201,7 @@ const AppContent = () => {
201201
await channel.watch({
202202
created_by_id: localMessage.user_id,
203203
});
204+
await channel.update({ name: localMessage.text });
204205
}
205206

206207
if (
@@ -229,7 +230,7 @@ const AppContent = () => {
229230
initializeOnMount={false}
230231
// @ts-expect-error This will be fixed upstream, the type is wrong
231232
preSendMessageRequest={preSendMessageRequest}
232-
StreamingMessageView={StreamingMessageView}
233+
StreamingMessageView={CustomStreamingMessageView}
233234
Message={CustomMessage}
234235
enableSwipeToReply={false}
235236
EmptyStateIndicator={EmptyStateIndicator}
@@ -238,15 +239,8 @@ const AppContent = () => {
238239
MessageAvatar={RenderNull}
239240
MessageFooter={RenderNull}
240241
>
241-
<MessageList
242-
additionalFlatListProps={{
243-
maintainVisibleContentPosition: {
244-
minIndexForVisible: 0,
245-
autoscrollToTopThreshold: 0,
246-
},
247-
}}
248-
/>
249-
<AITypingIndicatorView />
242+
<MessageList additionalFlatListProps={additionalFlatListProps} />
243+
<AIThinkingIndicatorView />
250244
<MessageComposerAI bottomSheetOptions={bottomSheetOptions} />
251245
</Channel>
252246
</Animated.View>
@@ -286,13 +280,58 @@ const bottomSheetOptions = [
286280
},
287281
];
288282

283+
const AIThinkingIndicatorView = () => {
284+
const { channel } = useChannelContext();
285+
const { aiState } = useAIState(channel);
286+
287+
const allowedStates = {
288+
[AIStates.Thinking]: 'Thinking about the question...',
289+
[AIStates.Generating]: 'Generating a response...',
290+
[AIStates.ExternalSources]: 'Checking external sources...',
291+
};
292+
293+
if (aiState === AIStates.Idle || aiState === AIStates.Error) {
294+
return null;
295+
}
296+
297+
return (
298+
<View
299+
style={{
300+
paddingHorizontal: 24,
301+
paddingVertical: 12,
302+
}}
303+
>
304+
<AITypingIndicatorView text={allowedStates[aiState]} />
305+
</View>
306+
);
307+
};
308+
289309
const CustomMessage = (props: MessageProps) => {
290310
const { theme } = useTheme();
291-
const isFromBot = props.message.ai_generated;
311+
const { message } = props;
312+
const isFromBot = message.ai_generated;
313+
const hasPendingAttachments = useMemo(
314+
() =>
315+
(message.attachments ?? []).some(
316+
(attachment) =>
317+
(attachment.image_url && isLocalUrl(attachment.image_url)) ||
318+
(attachment.asset_url && isLocalUrl(attachment.asset_url)),
319+
),
320+
[message.attachments],
321+
);
292322

293323
const modifiedTheme = useMemo(() => {
294324
if (!isFromBot) {
295-
return theme;
325+
return mergeThemes({
326+
theme,
327+
style: {
328+
messageSimple: {
329+
wrapper: {
330+
opacity: hasPendingAttachments ? 0.5 : 1,
331+
},
332+
},
333+
},
334+
});
296335
}
297336

298337
return mergeThemes({
@@ -310,27 +349,26 @@ const CustomMessage = (props: MessageProps) => {
310349
},
311350
},
312351
});
313-
}, [theme, isFromBot]);
352+
}, [theme, isFromBot, hasPendingAttachments]);
353+
314354
return (
315355
<ThemeProvider mergedStyle={modifiedTheme}>
316356
<Message {...props} />
317357
</ThemeProvider>
318358
);
319359
};
320360

321-
const w = Dimensions.get('window').width - 32;
322-
323-
const StreamingMessageView = () => {
361+
const CustomStreamingMessageView = () => {
324362
const { message } = useMessageContext();
325363
return (
326-
<View style={{ width: w, paddingLeft: 16 }}>
327-
<MarkdownRichText text={message.text ?? ''} />
364+
<View style={{ width: '100%', paddingHorizontal: 16 }}>
365+
<StreamingMessageView text={message.text ?? ''} />
328366
</View>
329367
);
330368
};
331369

332370
const MessageComposerAI = (
333-
props: Pick<AIMessageComposerProps, 'bottomSheetOptions'>,
371+
props: Pick<ComposerViewProps, 'bottomSheetOptions'>,
334372
) => {
335373
const messageComposer = useMessageComposer();
336374
const { sendMessage } = useMessageInputContext();
@@ -342,10 +380,22 @@ const MessageComposerAI = (
342380
() => channel?.stopAIResponse(),
343381
[channel],
344382
);
383+
345384
const isGenerating = [AIStates.Thinking, AIStates.Generating].includes(
346385
aiState,
347386
);
348387

388+
const safeAreaInsets = useSafeAreaInsets();
389+
const insets = useMemo(
390+
() => ({
391+
...safeAreaInsets,
392+
bottom:
393+
safeAreaInsets.bottom +
394+
(Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) * 2 : 0),
395+
}),
396+
[safeAreaInsets],
397+
);
398+
349399
const serializeToMessage = useStableCallback(
350400
async ({ text, attachments }: { text: string; attachments?: any[] }) => {
351401
messageComposer.textComposer.setText(text);
@@ -363,8 +413,9 @@ const MessageComposerAI = (
363413
);
364414

365415
return (
366-
<AIMessageComposer
416+
<ComposerView
367417
{...props}
418+
bottomSheetInsets={insets}
368419
onSendMessage={serializeToMessage}
369420
isGenerating={isGenerating}
370421
stopGenerating={stopGenerating}

examples/ReactNativeChatGPTSample/contexts/AppContext.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { PropsWithChildren, ReactNode, useMemo, useState } from 'react';
1+
import React, { PropsWithChildren, useMemo, useState } from 'react';
22
import { Channel, StreamChat } from 'stream-chat';
33
import { chatUserId } from '../chatConfig.ts';
44

@@ -12,8 +12,6 @@ export const AppContext = React.createContext<AppContextValue>({
1212
channel: undefined,
1313
});
1414

15-
// nanoid.ts
16-
1715
// Same alphabet nanoid uses (URL-safe)
1816
const ALPHABET =
1917
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-';
Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import { post } from './api.ts';
22

3-
// export const startAI = async (channelId: string) =>
4-
// post('http://localhost:3000/start-ai-agent', { channel_id: channelId });
5-
// export const stopAI = async (channelId: string) =>
6-
// post('http://localhost:3000/stop-ai-agent', { channel_id: channelId });
7-
83
export const startAI = async (channelId: string) =>
9-
post('https://stream-nodejs-ai-e5d85ed5ce6f.herokuapp.com/start-ai-agent', {
10-
channel_id: channelId,
11-
});
4+
post('http://192.168.1.218:3000/start-ai-agent', { channel_id: channelId });
125
export const stopAI = async (channelId: string) =>
13-
post('https://stream-nodejs-ai-e5d85ed5ce6f.herokuapp.com/stop-ai-agent', {
14-
channel_id: channelId,
15-
});
6+
post('http://192.168.1.218:3000/stop-ai-agent', { channel_id: channelId });
7+
8+
// export const startAI = async (channelId: string) =>
9+
// post('https://stream-nodejs-ai-e5d85ed5ce6f.herokuapp.com/start-ai-agent', {
10+
// channel_id: channelId,
11+
// });
12+
// export const stopAI = async (channelId: string) =>
13+
// post('https://stream-nodejs-ai-e5d85ed5ce6f.herokuapp.com/stop-ai-agent', {
14+
// channel_id: channelId,
15+
// });

examples/ReactNativeChatGPTSample/ios/ReactNativeChatGPTSample/Info.plist

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@
3838
<key>RCTNewArchEnabled</key>
3939
<true/>
4040
<key>NSPhotoLibraryUsageDescription</key>
41-
<string>$(PRODUCT_NAME) would like access to your photo gallery to share image in a message.</string>
41+
<string>$(PRODUCT_NAME) would like access to your photo gallery to share an image in a message.</string>
4242
<key>NSCameraUsageDescription</key>
43-
<string>$(PRODUCT_NAME) would like to use your camera to share image in a message.</string>
43+
<string>$(PRODUCT_NAME) would like to use your camera to share an image in a message.</string>
4444
<key>NSMicrophoneUsageDescription</key>
45-
<string>We need access to your microphone to capture your voice.</string>
45+
<string>$(PRODUCT_NAME) would like to access your microphone to capture your voice.</string>
4646
<key>NSSpeechRecognitionUsageDescription</key>
47-
<string>We need access to speech recognition to transcribe your voice.</string>
47+
<string>$(PRODUCT_NAME) would like to access speech recognition to transcribe your voice.</string>
4848
<key>UILaunchStoryboardName</key>
4949
<string>LaunchScreen</string>
5050
<key>UIRequiredDeviceCapabilities</key>

packages/react-native-sdk/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,14 @@
8686
"vitest": "catalog:"
8787
},
8888
"peerDependencies": {
89-
"expo": ">=51.0.0",
89+
"expo": ">=52.0.0",
9090
"react": ">=17.0.0",
91-
"react-native": ">=0.73.0",
91+
"react-native": ">=0.76.0",
9292
"react-native-gesture-handler": ">=2.18.0",
9393
"react-native-reanimated": ">=3.16.0",
9494
"react-native-svg": ">=15.8.0",
95-
"react-syntax-highlighter": ">=15.0.0",
95+
"victory-native": ">=41.0.0",
96+
"@shopify/react-native-skia": ">=2.0.0",
9697
"react-native-image-picker": ">=7.1.2",
9798
"@react-native-clipboard/clipboard": ">=1.14.1",
9899
"expo-image-picker": "*",
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { ShimmeringView } from './components';
2+
import { StyleSheet, Text } from 'react-native';
3+
import { useTheme } from './contexts';
4+
import { useMemo } from 'react';
5+
6+
export const AITypingIndicatorView = ({ text }: { text: string }) => {
7+
const {
8+
theme: {
9+
colors: { shimmer },
10+
},
11+
} = useTheme();
12+
const styles = useStyles();
13+
14+
return (
15+
<ShimmeringView shimmerColor={shimmer}>
16+
<Text style={styles.text}>{text}</Text>
17+
</ShimmeringView>
18+
);
19+
};
20+
21+
const useStyles = () => {
22+
const { theme } = useTheme();
23+
24+
return useMemo(
25+
() =>
26+
StyleSheet.create({
27+
text: {
28+
fontSize: 16,
29+
color: theme.colors.grey_dark,
30+
},
31+
}),
32+
[theme],
33+
);
34+
};

0 commit comments

Comments
 (0)