(render: (props: P, ref: any) => any) => (props: P & { ref?: any }) => any;
+ };
+ export = React;
+ export type ReactNode = any;
+ export type ReactElement = any;
+ export type FC = (props: P) => ReactElement | null;
+ export type ComponentProps = any;
+ export type HTMLAttributes = Record;
+ export type DetailedHTMLProps = P & { ref?: any };
+}
+
+declare module 'react-native' {
+ export const Platform: {
+ OS: 'ios' | 'android' | 'macos' | 'windows' | 'web' | string;
+ select: (spec: { ios?: T; android?: T; default?: T }) => T | undefined;
+ };
+
+ export interface ViewProps {
+ style?: any;
+ [key: string]: any;
+ }
+
+ export interface TextProps {
+ children?: React.ReactNode;
+ style?: any;
+ }
+
+ export interface TextInputProps {
+ value?: string;
+ onChangeText?: (text: string) => void;
+ editable?: boolean;
+ multiline?: boolean;
+ placeholder?: string;
+ placeholderTextColor?: string;
+ onSubmitEditing?: (event: any) => void;
+ blurOnSubmit?: boolean;
+ style?: any;
+ autoFocus?: boolean;
+ }
+
+ export interface FlatListProps {
+ data?: readonly ItemT[] | null;
+ renderItem?: (info: { item: ItemT; index: number }) => React.ReactElement | null;
+ keyExtractor?: (item: ItemT, index: number) => string;
+ contentContainerStyle?: any;
+ [key: string]: any;
+ }
+
+ export class FlatList {
+ constructor(props: FlatListProps);
+ }
+
+ export const StyleSheet: {
+ create(styles: T): T;
+ hairlineWidth: number;
+ };
+
+ export const View: (props: ViewProps & { children?: React.ReactNode }) => React.ReactElement;
+ export const Text: (props: TextProps) => React.ReactElement;
+ export const Image: (props: { source: { uri: string }; style?: any }) => React.ReactElement;
+ export const TextInput: (props: TextInputProps) => React.ReactElement;
+ export const TouchableOpacity: (props: any) => React.ReactElement;
+ export const KeyboardAvoidingView: (props: any) => React.ReactElement;
+}
+
+declare module '@openai/chatkit' {
+ export type ChatKitOptions = Record;
+}
+
+declare const process: {
+ env: Record;
+};
diff --git a/packages/chatkit-react-native/src/ui/ChatComposer.tsx b/packages/chatkit-react-native/src/ui/ChatComposer.tsx
new file mode 100644
index 0000000..729241c
--- /dev/null
+++ b/packages/chatkit-react-native/src/ui/ChatComposer.tsx
@@ -0,0 +1,149 @@
+import * as React from 'react';
+import {
+ KeyboardAvoidingView,
+ Platform,
+ StyleSheet,
+ TextInput,
+ TouchableOpacity,
+ View,
+ Text,
+} from 'react-native';
+
+export interface ChatComposerProps {
+ onSend: (message: string) => void | Promise;
+ onAttachPress?: () => void;
+ onVoicePress?: () => void;
+ placeholder?: string;
+ sendLabel?: string;
+ disabled?: boolean;
+ autoFocus?: boolean;
+ style?: React.ComponentProps['style'];
+}
+
+export function ChatComposer({
+ onSend,
+ onAttachPress,
+ onVoicePress,
+ placeholder = 'Message Assistant…',
+ sendLabel = 'Send',
+ disabled = false,
+ autoFocus = false,
+ style,
+}: ChatComposerProps) {
+ const [value, setValue] = React.useState('');
+ const [isSending, setIsSending] = React.useState(false);
+
+ const handleSend = React.useCallback(async () => {
+ if (!value.trim() || disabled || isSending) return;
+ setIsSending(true);
+ try {
+ await onSend(value.trim());
+ setValue('');
+ } finally {
+ setIsSending(false);
+ }
+ }, [disabled, isSending, onSend, value]);
+
+ return (
+
+
+
+ +
+
+
+
+ 🎤
+
+
+ {isSending ? '…' : sendLabel}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ backgroundColor: '#FFFFFF',
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: '#E5E7EB',
+ },
+ inner: {
+ flexDirection: 'row',
+ alignItems: 'flex-end',
+ gap: 8,
+ },
+ iconButton: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: '#F3F4F6',
+ },
+ iconLabel: {
+ fontSize: 20,
+ color: '#4B5563',
+ },
+ input: {
+ flex: 1,
+ maxHeight: 160,
+ minHeight: 40,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ borderRadius: 18,
+ backgroundColor: '#F9FAFB',
+ fontSize: 16,
+ lineHeight: 22,
+ color: '#111827',
+ },
+ sendButton: {
+ borderRadius: 20,
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ backgroundColor: '#111827',
+ },
+ sendButtonDisabled: {
+ backgroundColor: '#D1D5DB',
+ },
+ sendLabel: {
+ color: '#FFFFFF',
+ fontSize: 14,
+ fontWeight: '600',
+ },
+});
diff --git a/packages/chatkit-react-native/src/ui/ChatList.tsx b/packages/chatkit-react-native/src/ui/ChatList.tsx
new file mode 100644
index 0000000..5b670ad
--- /dev/null
+++ b/packages/chatkit-react-native/src/ui/ChatList.tsx
@@ -0,0 +1,171 @@
+import * as React from 'react';
+import { FlatList, StyleSheet, Text, View, type FlatListProps, Image } from 'react-native';
+
+export interface ChatMessage {
+ id: string;
+ role: 'user' | 'assistant' | 'system' | string;
+ content: string;
+ createdAt?: Date | string | number;
+ avatarUri?: string;
+ isStreaming?: boolean;
+}
+
+export interface ChatListProps extends Partial> {
+ messages: ChatMessage[];
+ showTimestamps?: boolean;
+ showAssistantBadge?: boolean;
+ renderMessage?: (item: ChatMessage) => React.ReactElement | null;
+ assistantBadgeLabel?: string;
+}
+
+const DEFAULT_BADGE_LABEL = 'Assistant';
+
+function formatTimestamp(value?: Date | string | number): string | undefined {
+ if (!value) return undefined;
+ const date = value instanceof Date ? value : new Date(value);
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+}
+
+function Bubble({ message, showTimestamps, showAssistantBadge, assistantBadgeLabel }: {
+ message: ChatMessage;
+ showTimestamps?: boolean;
+ showAssistantBadge?: boolean;
+ assistantBadgeLabel?: string;
+}) {
+ const isAssistant = message.role === 'assistant';
+ const timestamp = showTimestamps ? formatTimestamp(message.createdAt) : undefined;
+
+ return (
+
+ {message.avatarUri ? (
+
+ ) : (
+
+ )}
+
+ {showAssistantBadge && isAssistant ? (
+
+ {assistantBadgeLabel ?? DEFAULT_BADGE_LABEL}
+
+ ) : null}
+ {message.content}
+ {message.isStreaming ? : null}
+ {timestamp ? {timestamp} : null}
+
+
+ );
+}
+
+export function ChatList({
+ messages,
+ showAssistantBadge = true,
+ showTimestamps = true,
+ assistantBadgeLabel = DEFAULT_BADGE_LABEL,
+ renderMessage,
+ ...flatListProps
+}: ChatListProps) {
+ const renderItem = React.useCallback['renderItem']>>(
+ ({ item }) => {
+ if (renderMessage) {
+ return renderMessage(item);
+ }
+ return (
+
+ );
+ },
+ [assistantBadgeLabel, renderMessage, showAssistantBadge, showTimestamps],
+ );
+
+ const keyExtractor = React.useCallback((item: ChatMessage) => item.id, []);
+
+ return (
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ paddingHorizontal: 16,
+ paddingVertical: 24,
+ gap: 16,
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ gap: 12,
+ },
+ rowAssistant: {
+ justifyContent: 'flex-start',
+ },
+ rowUser: {
+ justifyContent: 'flex-end',
+ },
+ avatar: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ },
+ avatarPlaceholder: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ backgroundColor: '#E1E4E8',
+ },
+ bubble: {
+ flex: 1,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ borderRadius: 16,
+ backgroundColor: '#F0F0F0',
+ gap: 6,
+ },
+ bubbleAssistant: {
+ backgroundColor: '#EEF2FF',
+ },
+ bubbleUser: {
+ backgroundColor: '#D1FAE5',
+ },
+ content: {
+ fontSize: 16,
+ lineHeight: 22,
+ color: '#1F2937',
+ },
+ typingIndicator: {
+ width: 40,
+ height: 4,
+ borderRadius: 999,
+ backgroundColor: '#A5B4FC',
+ alignSelf: 'flex-start',
+ },
+ timestamp: {
+ fontSize: 12,
+ color: '#6B7280',
+ alignSelf: 'flex-end',
+ },
+ badge: {
+ alignSelf: 'flex-start',
+ paddingHorizontal: 6,
+ paddingVertical: 2,
+ borderRadius: 999,
+ backgroundColor: '#6366F1',
+ },
+ badgeLabel: {
+ fontSize: 10,
+ fontWeight: '600',
+ color: '#FFFFFF',
+ letterSpacing: 0.5,
+ textTransform: 'uppercase',
+ },
+});
diff --git a/packages/chatkit-react-native/src/utils/EventEmitter.ts b/packages/chatkit-react-native/src/utils/EventEmitter.ts
new file mode 100644
index 0000000..832ab75
--- /dev/null
+++ b/packages/chatkit-react-native/src/utils/EventEmitter.ts
@@ -0,0 +1,47 @@
+export type Listener = (...args: T) => void;
+
+export class EventEmitter {
+ private listeners = new Map>();
+
+ on(event: string, listener: Listener) {
+ const set = this.listeners.get(event) ?? new Set();
+ set.add(listener as Listener);
+ this.listeners.set(event, set);
+ return this;
+ }
+
+ off(event: string, listener: Listener) {
+ const set = this.listeners.get(event);
+ if (!set) return this;
+ set.delete(listener as Listener);
+ if (set.size === 0) {
+ this.listeners.delete(event);
+ }
+ return this;
+ }
+
+ once(event: string, listener: Listener) {
+ const wrapper: Listener = ((...args: T) => {
+ this.off(event, wrapper);
+ listener(...args);
+ }) as Listener;
+ return this.on(event, wrapper);
+ }
+
+ emit(event: string, ...args: T) {
+ const set = this.listeners.get(event);
+ if (!set) return false;
+ for (const listener of Array.from(set)) {
+ listener(...args);
+ }
+ return true;
+ }
+
+ removeAllListeners(event?: string) {
+ if (typeof event === 'string') {
+ this.listeners.delete(event);
+ } else {
+ this.listeners.clear();
+ }
+ }
+}
diff --git a/packages/chatkit-react-native/src/voice/useVoiceSession.ts b/packages/chatkit-react-native/src/voice/useVoiceSession.ts
new file mode 100644
index 0000000..d088287
--- /dev/null
+++ b/packages/chatkit-react-native/src/voice/useVoiceSession.ts
@@ -0,0 +1,137 @@
+import * as React from 'react';
+
+type RecordingOptions = import('expo-av').Audio.RecordingOptions;
+type SpeechOptions = import('expo-speech').SpeechOptions;
+
+export interface VoiceSessionOptions {
+ recordingOptions?: RecordingOptions;
+ speechOptions?: SpeechOptions;
+}
+
+export interface VoiceSession {
+ isRecording: boolean;
+ isSpeaking: boolean;
+ transcript: string | null;
+ startRecording: () => Promise;
+ stopRecording: () => Promise;
+ speak: (text: string) => Promise;
+ resetTranscript: () => void;
+}
+
+let expoAudio: typeof import('expo-av');
+let expoSpeech: typeof import('expo-speech');
+
+async function ensureModules() {
+ if (!expoAudio) {
+ try {
+ expoAudio = await import('expo-av');
+ } catch (error) {
+ throw new Error(
+ 'expo-av is required for voice capture. Install it with `expo install expo-av` or `pnpm add expo-av`.',
+ { cause: error },
+ );
+ }
+ }
+ if (!expoSpeech) {
+ try {
+ expoSpeech = await import('expo-speech');
+ } catch (error) {
+ throw new Error(
+ 'expo-speech is required for text-to-speech playback. Install it with `expo install expo-speech` or `pnpm add expo-speech`.',
+ { cause: error },
+ );
+ }
+ }
+}
+
+async function startRecording(options?: RecordingOptions) {
+ await expoAudio.Audio.requestPermissionsAsync();
+ const { recording } = await expoAudio.Audio.Recording.createAsync(options);
+ await recording.startAsync();
+ return recording;
+}
+
+async function stopRecordingInstance(recording: import('expo-av').Audio.Recording | null) {
+ if (!recording) return null;
+ try {
+ await recording.stopAndUnloadAsync();
+ const uri = recording.getURI();
+ return uri ?? null;
+ } finally {
+ }
+}
+
+async function speak(text: string, options?: SpeechOptions) {
+ await new Promise((resolve) => {
+ expoSpeech.speak(text, {
+ ...(options ?? {}),
+ onDone: (...args) => {
+ options?.onDone?.(...args);
+ resolve();
+ },
+ onStopped: (...args) => {
+ options?.onStopped?.(...args);
+ resolve();
+ },
+ onError: (...args) => {
+ options?.onError?.(...args);
+ resolve();
+ },
+ });
+ });
+}
+
+export function useVoiceSession(options: VoiceSessionOptions = {}): VoiceSession {
+ const recordingRef = React.useRef(null);
+ const [isRecording, setIsRecording] = React.useState(false);
+ const [isSpeaking, setIsSpeaking] = React.useState(false);
+ const [transcript, setTranscript] = React.useState(null);
+
+ const start = React.useCallback(async () => {
+ await ensureModules();
+ if (isRecording) return;
+ setIsRecording(true);
+ try {
+ const recording = await startRecording(options.recordingOptions);
+ recordingRef.current = recording;
+ } catch (error) {
+ setIsRecording(false);
+ throw error;
+ }
+ }, [isRecording, options.recordingOptions]);
+
+ const stop = React.useCallback(async () => {
+ await ensureModules();
+ const uri = await stopRecordingInstance(recordingRef.current);
+ recordingRef.current = null;
+ setIsRecording(false);
+ setTranscript(uri);
+ return uri;
+ }, []);
+
+ const play = React.useCallback(
+ async (text: string) => {
+ await ensureModules();
+ if (!text) return;
+ setIsSpeaking(true);
+ try {
+ await speak(text, options.speechOptions);
+ } finally {
+ setIsSpeaking(false);
+ }
+ },
+ [options.speechOptions],
+ );
+
+ const resetTranscript = React.useCallback(() => setTranscript(null), []);
+
+ return {
+ isRecording,
+ isSpeaking,
+ transcript,
+ startRecording: start,
+ stopRecording: stop,
+ speak: play,
+ resetTranscript,
+ };
+}
diff --git a/packages/chatkit-react-native/tsconfig.json b/packages/chatkit-react-native/tsconfig.json
new file mode 100644
index 0000000..8ab3ffa
--- /dev/null
+++ b/packages/chatkit-react-native/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "jsx": "react-native",
+ "outDir": "dist",
+ "rootDir": "src",
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "resolveJsonModule": true,
+ "verbatimModuleSyntax": true,
+ "baseUrl": "."
+ },
+ "include": ["src"]
+}