Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Respect repo-specific rules. Do not suppress lint rules broadly; justify and sco
- `icons/` — SVG icon components
- `mock-builders/` — test fixtures and fakes (also aliased as `mock-builders` in Jest)
- `native.ts` — native-capability interfaces + `registerNativeHandlers()`
- `ai-components/` — the AI components (markdown, syntax highlighting, charts, AI composer, dictation), published as the separate entry point `stream-chat-react-native/ai-components` (`stream-chat-expo/ai-components`). See [AI components entry point](#ai-components-entry-point).

Use the closest folder's patterns and conventions when editing.

Expand Down Expand Up @@ -97,6 +98,7 @@ cd package && yarn test:typecheck # SDK src + tests + mock-b
# Lint / format
yarn lint # prettier --list-different + eslint --max-warnings 0 + validate-translations
yarn lint-fix # ALWAYS run this before committing
yarn workspace stream-chat-react-native-core validate-entry-isolation # main and ai-components entry points stay apart (part of yarn lint)
yarn eslint <path> # eslint a single path

# Translations
Expand Down Expand Up @@ -185,6 +187,17 @@ Both wrappers are thin. They:

Native code shared by both wrappers lives in `package/shared-native/{ios,android}` and is copied into each wrapper by `shared-native:sync`. **Edit `shared-native/`, never the synced copies.**

### AI components entry point

`package/src/ai-components/` (ported 1:1 from the deprecated `@stream-io/chat-react-native-ai`) is reachable only through the `ai-components` subpath, implemented as physical folders (`package/ai-components/package.json`, `package/{native,expo}-package/ai-components/`) rather than an `exports` map, which would break deep imports. Metro does not tree-shake, so `bin/validate-entry-isolation.js` (run by `yarn lint`) enforces both directions:

- the main entry points must never reach `src/ai-components` or its dependencies (`react-syntax-highlighter`, `@khanacademy/simple-markdown`, `@stream-io/state-store`, `zod`, `victory-native`, Skia);
- the AI entry points may only reach `src/ai-components` plus an explicit allowlist (`src/native.ts`, the native shimmer). The wrappers' `ai-components/index.js` registers the native pieces the AI code needs itself and must never import the wrappers' `src/index.js`.

Do not add AI slots to `DEFAULT_COMPONENTS`; core `require`s the defaults, which would break isolation. Skia and `victory-native` are optional peers (native libraries must be owned by the app); the JS-only AI dependencies are regular dependencies.

Native pieces live in `package/shared-native/`: the Android-only `PerfText` Fabric view (Kotlin plus a C++ shadow node in `shared-native/android/jni`, built through each wrapper's `android/src/main/jni/CMakeLists.txt`, registered with `cmakeListsPath`/`componentDescriptors` in `react-native.config.js`) and the `AIDictation` TurboModule. **The iOS dictation sources are excluded from the pod unless the app opts in** (`$StreamChatEnableDictation = true` in the Podfile, or the `stream-chat-expo` config plugin in `package/expo-package/plugin`), because referencing the Speech framework makes App Store Connect demand a speech recognition usage description from every app. Never declare `RECORD_AUDIO` in a library manifest. The dictation codegen spec is duplicated into each wrapper's `src/native/` (codegen only reads a package's own `jsSrcsDir`); the isolation check keeps the copies identical.

## Critical architectural patterns

- **Memoization:** components use `React.memo()` with custom `areEqual` comparators (not HOCs). Comparators check cheap props before deep message comparison — keep that ordering when extending one.
Expand Down
40 changes: 40 additions & 0 deletions examples/ExpoChatGPTSample/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files

# dependencies
node_modules/

# Expo
.expo/
ios/
android/
dist/
web-build/
expo-env.d.ts

# Native
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision

# Metro
.metro-health-check*

# debug
npm-debug.*
yarn-debug.*
yarn-error.*

# macOS
.DS_Store
*.pem

# local env files
.env*.local

# typescript
*.tsbuildinfo

app-example
30 changes: 30 additions & 0 deletions examples/ExpoChatGPTSample/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# ExpoChatGPTSample

A ChatGPT-style assistant built with [`stream-chat-expo`](../../package/expo-package) and its AI components
(`stream-chat-expo/ai-components`): streamed markdown answers with syntax highlighting, tables and charts, an "AI is
thinking" indicator, and a composer with attachments, a custom actions sheet and speech-to-text.

Ported from the `ExpoChatGPTSample` of the deprecated `@stream-io/chat-react-native-ai` repository
([`GetStream/ai-components-js`](https://github.com/GetStream/ai-components-js)).

## How it works

- The drawer lists the user's conversations; `+` starts a new one.
- A new conversation is created on its first message. Its title is summarized from that message by the sample backend.
- Before sending, the app asks the sample backend to add an AI agent to the channel (`http/requests.ts`). The agent
replies with streamed `ai_generated` messages, rendered by `StreamingMessageView`.

The Stream app, user and backend in `chatConfig.ts` and `http/requests.ts` are Stream's public demo setup. To use your
own, replace them and point `http/requests.ts` at your own AI agent server.

## Running

From the repository root:

```bash
yarn install
yarn workspace expochatgptsample ios # or: android
```

`ios` and `android` sync the SDK's shared native code, run `expo prebuild` and build the app. The native `ios` and
`android` folders are generated and not committed.
32 changes: 32 additions & 0 deletions examples/ExpoChatGPTSample/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"expo": {
"name": "ExpoChatGPTSample",
"slug": "ExpoChatGPTSample",
"scheme": "expochatgptsample",
"ios": {
"bundleIdentifier": "io.getstream.expochatgptsample",
"appleTeamId": "EHV7XZLAHA"
},
"android": {
"permissions": ["android.permission.RECORD_AUDIO"],
"package": "io.getstream.expochatgptsample"
},
"plugins": [
"expo-router",
[
"expo-image-picker",
{
"photosPermission": "The app accesses your photos to let them share with others.",
"cameraPermission": "The app accesses your camera to let you take photos and share with others."
}
],
[
"stream-chat-expo",
{
"dictationMicrophoneUsageDescription": "$(PRODUCT_NAME) would like to access your microphone to capture your voice.",
"dictationSpeechRecognitionUsageDescription": "$(PRODUCT_NAME) would like to access speech recognition to transcribe your voice."
}
]
]
}
}
53 changes: 53 additions & 0 deletions examples/ExpoChatGPTSample/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react';

import { StyleSheet } from 'react-native';

import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';

import { Drawer } from 'expo-router/drawer';

import type { LocalMessage } from 'stream-chat';
import { Chat, OverlayProvider, useCreateChatClient } from 'stream-chat-expo';

import { StreamTheme } from 'stream-chat-expo/ai-components';

import { chatApiKey, chatUserId, chatUserName, chatUserToken } from '../chatConfig';
import { AppProvider } from '../contexts/AppContext';

import { MenuDrawer } from '../screens/MenuDrawer';

const isMessageAIGenerated = (message: LocalMessage) => !!message.ai_generated;

export default function Layout() {
const chatClient = useCreateChatClient({
apiKey: chatApiKey,
tokenOrProvider: chatUserToken,
userData: { id: chatUserId, name: chatUserName },
});

if (!chatClient) {
return null;
}
return (
<SafeAreaProvider>
<AppProvider client={chatClient}>
<StreamTheme>
<GestureHandlerRootView style={styles.container}>
<OverlayProvider>
<Chat client={chatClient} isMessageAIGenerated={isMessageAIGenerated}>
<Drawer drawerContent={MenuDrawer} screenOptions={{ drawerStyle: { width: 300 } }}>
<Drawer.Screen name={'index'} />
</Drawer>
</Chat>
</OverlayProvider>
</GestureHandlerRootView>
</StreamTheme>
</AppProvider>
</SafeAreaProvider>
);
}

const styles = StyleSheet.create({
container: { flex: 1 },
});
6 changes: 6 additions & 0 deletions examples/ExpoChatGPTSample/app/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { ChatContent } from '../screens/ChatContent';

const Page = () => {
return <ChatContent />;
};
export default Page;
12 changes: 12 additions & 0 deletions examples/ExpoChatGPTSample/babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
// Needed by `stream-chat-expo/ai-components`: its `zod` dependency ships `export * as`.
'@babel/plugin-transform-export-namespace-from',
// react-native-worklets/plugin has to be the last one
'react-native-worklets/plugin',
],
};
};
45 changes: 45 additions & 0 deletions examples/ExpoChatGPTSample/bottomSheetOptions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from 'react';
import { Alert } from 'react-native';

import { Copy, Edit, FilePickerIcon, Flag, IconProps, Picture } from 'stream-chat-expo';

// The sheet renders each icon without props, and some SDK icons (e.g. `Flag`, `Copy`) only draw
// with an explicit stroke, so every icon gets the same colour here.
const withStroke = (Icon: React.ComponentType<IconProps>) => {
const StrokedIcon = () => <Icon stroke='#000000' />;
StrokedIcon.displayName = `Stroked(${Icon.displayName ?? Icon.name})`;
return StrokedIcon;
};

export const bottomSheetOptions = [
{
title: 'Create Image',
subtitle: 'Visualize anything',
action: () => Alert.alert('Pressed on Create Image !'),
Icon: withStroke(Picture),
},
{
title: 'Thinking',
subtitle: 'Think longer for better answers',
action: () => Alert.alert('Pressed on Thinking !'),
Icon: withStroke(Flag),
},
{
title: 'Deep research',
subtitle: 'Get a detailed report',
action: () => Alert.alert('Pressed on Deep research !'),
Icon: withStroke(FilePickerIcon),
},
{
title: 'Web search',
subtitle: 'Find real-time news and info',
action: () => Alert.alert('Pressed on Web search !'),
Icon: withStroke(Copy),
},
{
title: 'Study and learn',
subtitle: 'Learn a new concept',
action: () => Alert.alert('Pressed on Study and learn !'),
Icon: withStroke(Edit),
},
];
5 changes: 5 additions & 0 deletions examples/ExpoChatGPTSample/chatConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const chatApiKey = 'zcgvnykxsfm8';
export const chatUserId = 'rn-ai-test-user-3';
export const chatUserToken =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicm4tYWktdGVzdC11c2VyLTMifQ.kOg3zLkzHc-b_5IyckQr-RixbebN-GDs7N3c570oA5A';
export const chatUserName = 'Bob';
42 changes: 42 additions & 0 deletions examples/ExpoChatGPTSample/contexts/AppContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { PropsWithChildren } from 'react';
import React, { useMemo, useState } from 'react';

import type { Channel, StreamChat } from 'stream-chat';

import { chatUserId } from '../chatConfig';

export type AppContextValue = {
channel: Channel | undefined;
setChannel: (channel: Channel) => void;
};

export const AppContext = React.createContext<AppContextValue>({
setChannel: () => {},
channel: undefined,
});

const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-';

export const nanoid = (size: number = 21): string => {
let id = '';
for (let i = 0; i < size; i++) {
const r = Math.floor(Math.random() * ALPHABET.length);
id += ALPHABET[r];
}
return id;
};

export const createChannel = (client: StreamChat) =>
client.channel('messaging', nanoid(), {
members: [chatUserId],
});

export const AppProvider = ({ client, children }: PropsWithChildren<{ client: StreamChat }>) => {
const [channel, setChannel] = useState<Channel>(() => createChannel(client));

const contextValue = useMemo(() => ({ channel, setChannel }), [channel]);

return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>;
};

export const useAppContext = () => React.useContext(AppContext);
42 changes: 42 additions & 0 deletions examples/ExpoChatGPTSample/custom-types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
DefaultAttachmentData,
DefaultChannelData,
DefaultCommandData,
DefaultEventData,
DefaultMemberData,
DefaultMessageData,
DefaultPollData,
DefaultPollOptionData,
DefaultReactionData,
DefaultThreadData,
DefaultUserData,
} from 'stream-chat-expo';

declare module 'stream-chat' {
// Module augmentation: the empty interfaces are how the default data types are applied.
/* eslint-disable @typescript-eslint/no-empty-object-type */

interface CustomAttachmentData extends DefaultAttachmentData {}

interface CustomChannelData extends DefaultChannelData {}

interface CustomCommandData extends DefaultCommandData {}

interface CustomEventData extends DefaultEventData {}

interface CustomMemberData extends DefaultMemberData {}

interface CustomUserData extends DefaultUserData {}

interface CustomMessageData extends DefaultMessageData {
ai_generated?: boolean;
}

interface CustomPollOptionData extends DefaultPollOptionData {}

interface CustomPollData extends DefaultPollData {}

interface CustomReactionData extends DefaultReactionData {}

interface CustomThreadData extends DefaultThreadData {}
}
19 changes: 19 additions & 0 deletions examples/ExpoChatGPTSample/http/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export const post = async (url: string, data: unknown = {}) => {
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});

if (!response.ok) {
throw new Error(`An HTTP Error has occurred. Status: ${response.status}`);
}

return await response.json();
} catch (error) {
console.error('Error:', error);
}
};
12 changes: 12 additions & 0 deletions examples/ExpoChatGPTSample/http/requests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { post } from './api';

export const startAI = (channelId: string) =>
post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/start-ai-agent', {
channel_id: channelId,
});
export const stopAI = (channelId: string) =>
post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/stop-ai-agent', {
channel_id: channelId,
});
export const summarize = (text: string) =>
post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/summarize', { text });
Loading
Loading