@cryterion/expo-chat-ui 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -0
- package/package.json +64 -0
- package/src/components/Chat.tsx +60 -0
- package/src/components/ChatInput.tsx +118 -0
- package/src/components/MessageBubble.tsx +113 -0
- package/src/components/MessageList.tsx +180 -0
- package/src/index.ts +26 -0
- package/src/theme/defaultTheme.ts +87 -0
- package/src/types.ts +137 -0
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# @tovia/chat-ui
|
|
2
|
+
|
|
3
|
+
A reusable chat UI component for Expo + React Native applications.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @tovia/chat-ui
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Basic Usage
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { Chat, ChatMessage } from '@tovia/chat-ui';
|
|
17
|
+
|
|
18
|
+
function ChatScreen() {
|
|
19
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
20
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
21
|
+
|
|
22
|
+
const handleSend = async (text: string) => {
|
|
23
|
+
// Add user message
|
|
24
|
+
const userMessage: ChatMessage = {
|
|
25
|
+
id: `user-${Date.now()}`,
|
|
26
|
+
role: 'user',
|
|
27
|
+
content: text,
|
|
28
|
+
timestamp: Date.now(),
|
|
29
|
+
};
|
|
30
|
+
setMessages(prev => [...prev, userMessage]);
|
|
31
|
+
|
|
32
|
+
// Your AI/backend logic here...
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Chat
|
|
37
|
+
messages={messages}
|
|
38
|
+
onSend={handleSend}
|
|
39
|
+
isLoading={isLoading}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### With Custom Theme
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
import { Chat, ChatTheme } from '@tovia/chat-ui';
|
|
49
|
+
|
|
50
|
+
const customTheme: Partial<ChatTheme> = {
|
|
51
|
+
colors: {
|
|
52
|
+
primary: '#007AFF',
|
|
53
|
+
userBubble: '#007AFF',
|
|
54
|
+
assistantBubble: '#E5E5EA',
|
|
55
|
+
userText: '#FFFFFF',
|
|
56
|
+
assistantText: '#000000',
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function ChatScreen() {
|
|
61
|
+
return (
|
|
62
|
+
<Chat
|
|
63
|
+
messages={messages}
|
|
64
|
+
onSend={handleSend}
|
|
65
|
+
theme={customTheme}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Using Individual Components
|
|
72
|
+
|
|
73
|
+
You can also use the individual building blocks:
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
import { MessageList, ChatInput, MessageBubble } from '@tovia/chat-ui';
|
|
77
|
+
|
|
78
|
+
function CustomChat() {
|
|
79
|
+
return (
|
|
80
|
+
<View style={{ flex: 1 }}>
|
|
81
|
+
<MessageList messages={messages} isLoading={isLoading} />
|
|
82
|
+
<ChatInput onSend={handleSend} />
|
|
83
|
+
</View>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## API
|
|
89
|
+
|
|
90
|
+
### `<Chat />` Props
|
|
91
|
+
|
|
92
|
+
| Prop | Type | Required | Description |
|
|
93
|
+
|------|------|----------|-------------|
|
|
94
|
+
| `messages` | `ChatMessage[]` | Yes | Array of messages to display |
|
|
95
|
+
| `onSend` | `(text: string) => void \| Promise<void>` | Yes | Callback when user sends a message |
|
|
96
|
+
| `isLoading` | `boolean` | No | Shows typing indicator when true |
|
|
97
|
+
| `disabled` | `boolean` | No | Disables input when true |
|
|
98
|
+
| `theme` | `Partial<ChatTheme>` | No | Custom theme colors |
|
|
99
|
+
| `renderEmptyState` | `ReactNode \| () => ReactNode` | No | Custom empty state |
|
|
100
|
+
| `onCopyMessage` | `(message: ChatMessage) => void` | No | Custom copy handler |
|
|
101
|
+
|
|
102
|
+
### `ChatMessage` Type
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
interface ChatMessage {
|
|
106
|
+
id: string;
|
|
107
|
+
role: 'user' | 'assistant' | 'system';
|
|
108
|
+
content: string;
|
|
109
|
+
timestamp: number | Date;
|
|
110
|
+
meta?: Record<string, unknown>;
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### `ChatTheme` Type
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
interface ChatTheme {
|
|
118
|
+
mode: 'light' | 'dark';
|
|
119
|
+
colors: {
|
|
120
|
+
primary: string;
|
|
121
|
+
background: string;
|
|
122
|
+
inputBackground: string;
|
|
123
|
+
userBubble: string;
|
|
124
|
+
assistantBubble: string;
|
|
125
|
+
userText: string;
|
|
126
|
+
assistantText: string;
|
|
127
|
+
text: string;
|
|
128
|
+
textMuted: string;
|
|
129
|
+
placeholder: string;
|
|
130
|
+
border: string;
|
|
131
|
+
sendButtonDisabled: string;
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cryterion/expo-chat-ui",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A reusable chat UI component for Expo + React Native applications",
|
|
5
|
+
"author": "cryterion",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mphassani/expo-chat-ui.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/mphassani/expo-chat-ui/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/mphassani/expo-chat-ui#readme",
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"main": "src/index.ts",
|
|
19
|
+
"module": "src/index.ts",
|
|
20
|
+
"types": "src/index.ts",
|
|
21
|
+
"react-native": "src/index.ts",
|
|
22
|
+
"source": "src/index.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"import": "./src/index.ts",
|
|
26
|
+
"require": "./src/index.ts",
|
|
27
|
+
"types": "./src/index.ts"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"src"
|
|
32
|
+
],
|
|
33
|
+
"sideEffects": false,
|
|
34
|
+
"scripts": {
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"prepublishOnly": "npm run typecheck"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"react-native",
|
|
40
|
+
"expo",
|
|
41
|
+
"chat",
|
|
42
|
+
"ui",
|
|
43
|
+
"component",
|
|
44
|
+
"messaging",
|
|
45
|
+
"conversation",
|
|
46
|
+
"chatbot"
|
|
47
|
+
],
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"react": ">=18.0.0",
|
|
50
|
+
"react-native": ">=0.72.0",
|
|
51
|
+
"expo": ">=50.0.0",
|
|
52
|
+
"@expo/vector-icons": ">=14.0.0",
|
|
53
|
+
"expo-clipboard": ">=7.0.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@expo/vector-icons": "^14.1.0",
|
|
57
|
+
"@types/react": "^18.2.79",
|
|
58
|
+
"expo": "^50.0.0",
|
|
59
|
+
"expo-clipboard": "^7.0.0",
|
|
60
|
+
"react": "18.2.0",
|
|
61
|
+
"react-native": "0.73.6",
|
|
62
|
+
"typescript": "~5.9.3"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { StyleSheet, View } from 'react-native';
|
|
3
|
+
import { mergeTheme } from '../theme/defaultTheme';
|
|
4
|
+
import { ChatProps } from '../types';
|
|
5
|
+
import { ChatInput } from './ChatInput';
|
|
6
|
+
import { MessageList } from './MessageList';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Main Chat component that combines MessageList and ChatInput
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```tsx
|
|
13
|
+
* <Chat
|
|
14
|
+
* messages={messages}
|
|
15
|
+
* onSend={handleSend}
|
|
16
|
+
* isLoading={isLoading}
|
|
17
|
+
* theme={{ mode: 'dark' }}
|
|
18
|
+
* />
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function Chat({
|
|
22
|
+
messages,
|
|
23
|
+
onSend,
|
|
24
|
+
isLoading,
|
|
25
|
+
disabled,
|
|
26
|
+
theme: themeProp,
|
|
27
|
+
renderEmptyState,
|
|
28
|
+
onCopyMessage,
|
|
29
|
+
placeholder,
|
|
30
|
+
emptyStateTitle,
|
|
31
|
+
emptyStateSubtitle,
|
|
32
|
+
}: ChatProps) {
|
|
33
|
+
const theme = mergeTheme(themeProp);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
|
|
37
|
+
<MessageList
|
|
38
|
+
messages={messages}
|
|
39
|
+
isLoading={isLoading}
|
|
40
|
+
theme={theme}
|
|
41
|
+
renderEmptyState={renderEmptyState}
|
|
42
|
+
onCopyMessage={onCopyMessage}
|
|
43
|
+
emptyStateTitle={emptyStateTitle}
|
|
44
|
+
emptyStateSubtitle={emptyStateSubtitle}
|
|
45
|
+
/>
|
|
46
|
+
<ChatInput
|
|
47
|
+
onSend={onSend}
|
|
48
|
+
disabled={disabled || isLoading}
|
|
49
|
+
theme={theme}
|
|
50
|
+
placeholder={placeholder}
|
|
51
|
+
/>
|
|
52
|
+
</View>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const styles = StyleSheet.create({
|
|
57
|
+
container: {
|
|
58
|
+
flex: 1,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Ionicons } from '@expo/vector-icons';
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
KeyboardAvoidingView,
|
|
5
|
+
Platform,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
TextInput,
|
|
8
|
+
TouchableOpacity,
|
|
9
|
+
View,
|
|
10
|
+
} from 'react-native';
|
|
11
|
+
import { ChatInputProps } from '../types';
|
|
12
|
+
|
|
13
|
+
export function ChatInput({
|
|
14
|
+
onSend,
|
|
15
|
+
disabled,
|
|
16
|
+
theme,
|
|
17
|
+
placeholder = 'Type a message...',
|
|
18
|
+
}: ChatInputProps) {
|
|
19
|
+
const [text, setText] = useState('');
|
|
20
|
+
const { colors } = theme;
|
|
21
|
+
|
|
22
|
+
const handleSend = () => {
|
|
23
|
+
const trimmedText = text.trim();
|
|
24
|
+
if (trimmedText && !disabled) {
|
|
25
|
+
onSend(trimmedText);
|
|
26
|
+
setText('');
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const canSend = text.trim().length > 0 && !disabled;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<KeyboardAvoidingView
|
|
34
|
+
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
35
|
+
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
|
|
36
|
+
>
|
|
37
|
+
<View
|
|
38
|
+
style={[
|
|
39
|
+
styles.container,
|
|
40
|
+
{
|
|
41
|
+
backgroundColor: colors.background,
|
|
42
|
+
borderTopColor: colors.border,
|
|
43
|
+
},
|
|
44
|
+
]}
|
|
45
|
+
>
|
|
46
|
+
<View
|
|
47
|
+
style={[
|
|
48
|
+
styles.inputContainer,
|
|
49
|
+
{ backgroundColor: colors.inputBackground },
|
|
50
|
+
]}
|
|
51
|
+
>
|
|
52
|
+
<TextInput
|
|
53
|
+
style={[styles.input, { color: colors.text }]}
|
|
54
|
+
placeholder={placeholder}
|
|
55
|
+
placeholderTextColor={colors.placeholder}
|
|
56
|
+
value={text}
|
|
57
|
+
onChangeText={setText}
|
|
58
|
+
multiline
|
|
59
|
+
maxLength={2000}
|
|
60
|
+
editable={!disabled}
|
|
61
|
+
onSubmitEditing={handleSend}
|
|
62
|
+
blurOnSubmit={false}
|
|
63
|
+
/>
|
|
64
|
+
<TouchableOpacity
|
|
65
|
+
style={[
|
|
66
|
+
styles.sendButton,
|
|
67
|
+
{
|
|
68
|
+
backgroundColor: canSend
|
|
69
|
+
? colors.primary
|
|
70
|
+
: colors.sendButtonDisabled,
|
|
71
|
+
},
|
|
72
|
+
]}
|
|
73
|
+
onPress={handleSend}
|
|
74
|
+
disabled={!canSend}
|
|
75
|
+
activeOpacity={0.7}
|
|
76
|
+
>
|
|
77
|
+
<Ionicons name="send" size={18} color="#FFFFFF" />
|
|
78
|
+
</TouchableOpacity>
|
|
79
|
+
</View>
|
|
80
|
+
</View>
|
|
81
|
+
</KeyboardAvoidingView>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const styles = StyleSheet.create({
|
|
86
|
+
container: {
|
|
87
|
+
paddingHorizontal: 16,
|
|
88
|
+
paddingVertical: 12,
|
|
89
|
+
borderTopWidth: StyleSheet.hairlineWidth,
|
|
90
|
+
},
|
|
91
|
+
inputContainer: {
|
|
92
|
+
flexDirection: 'row',
|
|
93
|
+
alignItems: 'flex-end',
|
|
94
|
+
borderRadius: 24,
|
|
95
|
+
paddingLeft: 16,
|
|
96
|
+
paddingRight: 6,
|
|
97
|
+
paddingVertical: 6,
|
|
98
|
+
shadowColor: '#000',
|
|
99
|
+
shadowOffset: { width: 0, height: 1 },
|
|
100
|
+
shadowOpacity: 0.1,
|
|
101
|
+
shadowRadius: 2,
|
|
102
|
+
elevation: 2,
|
|
103
|
+
},
|
|
104
|
+
input: {
|
|
105
|
+
flex: 1,
|
|
106
|
+
fontSize: 16,
|
|
107
|
+
maxHeight: 100,
|
|
108
|
+
paddingVertical: 8,
|
|
109
|
+
paddingRight: 8,
|
|
110
|
+
},
|
|
111
|
+
sendButton: {
|
|
112
|
+
width: 36,
|
|
113
|
+
height: 36,
|
|
114
|
+
borderRadius: 18,
|
|
115
|
+
alignItems: 'center',
|
|
116
|
+
justifyContent: 'center',
|
|
117
|
+
},
|
|
118
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Ionicons } from '@expo/vector-icons';
|
|
2
|
+
import * as Clipboard from 'expo-clipboard';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
5
|
+
import { ChatMessage, MessageBubbleProps } from '../types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Format timestamp to time string (e.g., "2:30 PM")
|
|
9
|
+
*/
|
|
10
|
+
function formatTime(timestamp: number | Date): string {
|
|
11
|
+
const date = typeof timestamp === 'number' ? new Date(timestamp) : timestamp;
|
|
12
|
+
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Default copy to clipboard implementation
|
|
17
|
+
*/
|
|
18
|
+
async function defaultCopyToClipboard(message: ChatMessage): Promise<void> {
|
|
19
|
+
await Clipboard.setStringAsync(message.content);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function MessageBubble({ message, theme, onCopy }: MessageBubbleProps) {
|
|
23
|
+
const isUser = message.role === 'user';
|
|
24
|
+
const { colors } = theme;
|
|
25
|
+
|
|
26
|
+
const backgroundColor = isUser ? colors.userBubble : colors.assistantBubble;
|
|
27
|
+
const textColor = isUser ? colors.userText : colors.assistantText;
|
|
28
|
+
const mutedColor = isUser ? colors.userBubbleMuted : colors.assistantBubbleMuted;
|
|
29
|
+
|
|
30
|
+
const handleCopy = () => {
|
|
31
|
+
if (onCopy) {
|
|
32
|
+
onCopy(message);
|
|
33
|
+
} else {
|
|
34
|
+
defaultCopyToClipboard(message);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<View
|
|
40
|
+
style={[
|
|
41
|
+
styles.container,
|
|
42
|
+
isUser ? styles.userContainer : styles.assistantContainer,
|
|
43
|
+
]}
|
|
44
|
+
>
|
|
45
|
+
<View
|
|
46
|
+
style={[
|
|
47
|
+
styles.bubble,
|
|
48
|
+
{ backgroundColor },
|
|
49
|
+
isUser ? styles.userBubble : styles.assistantBubble,
|
|
50
|
+
]}
|
|
51
|
+
>
|
|
52
|
+
<Text style={[styles.messageText, { color: textColor }]}>
|
|
53
|
+
{message.content}
|
|
54
|
+
</Text>
|
|
55
|
+
<View style={styles.footer}>
|
|
56
|
+
<Text style={[styles.timestamp, { color: mutedColor }]}>
|
|
57
|
+
{formatTime(message.timestamp)}
|
|
58
|
+
</Text>
|
|
59
|
+
<TouchableOpacity
|
|
60
|
+
style={styles.copyButton}
|
|
61
|
+
onPress={handleCopy}
|
|
62
|
+
activeOpacity={0.7}
|
|
63
|
+
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
64
|
+
>
|
|
65
|
+
<Ionicons name="copy-outline" size={14} color={mutedColor} />
|
|
66
|
+
</TouchableOpacity>
|
|
67
|
+
</View>
|
|
68
|
+
</View>
|
|
69
|
+
</View>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const styles = StyleSheet.create({
|
|
74
|
+
container: {
|
|
75
|
+
paddingHorizontal: 16,
|
|
76
|
+
paddingVertical: 4,
|
|
77
|
+
},
|
|
78
|
+
userContainer: {
|
|
79
|
+
alignItems: 'flex-end',
|
|
80
|
+
},
|
|
81
|
+
assistantContainer: {
|
|
82
|
+
alignItems: 'flex-start',
|
|
83
|
+
},
|
|
84
|
+
bubble: {
|
|
85
|
+
maxWidth: '80%',
|
|
86
|
+
paddingHorizontal: 16,
|
|
87
|
+
paddingVertical: 10,
|
|
88
|
+
borderRadius: 20,
|
|
89
|
+
},
|
|
90
|
+
userBubble: {
|
|
91
|
+
borderBottomRightRadius: 4,
|
|
92
|
+
},
|
|
93
|
+
assistantBubble: {
|
|
94
|
+
borderBottomLeftRadius: 4,
|
|
95
|
+
},
|
|
96
|
+
messageText: {
|
|
97
|
+
fontSize: 16,
|
|
98
|
+
lineHeight: 22,
|
|
99
|
+
},
|
|
100
|
+
footer: {
|
|
101
|
+
flexDirection: 'row',
|
|
102
|
+
alignItems: 'center',
|
|
103
|
+
justifyContent: 'flex-end',
|
|
104
|
+
marginTop: 4,
|
|
105
|
+
gap: 8,
|
|
106
|
+
},
|
|
107
|
+
timestamp: {
|
|
108
|
+
fontSize: 11,
|
|
109
|
+
},
|
|
110
|
+
copyButton: {
|
|
111
|
+
padding: 2,
|
|
112
|
+
},
|
|
113
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { Ionicons } from '@expo/vector-icons';
|
|
2
|
+
import React, { useEffect, useRef } from 'react';
|
|
3
|
+
import { FlatList, StyleSheet, Text, View } from 'react-native';
|
|
4
|
+
import { ChatMessage, MessageListProps } from '../types';
|
|
5
|
+
import { MessageBubble } from './MessageBubble';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Default empty state component
|
|
9
|
+
*/
|
|
10
|
+
function DefaultEmptyState({
|
|
11
|
+
title,
|
|
12
|
+
subtitle,
|
|
13
|
+
iconColor,
|
|
14
|
+
textColor,
|
|
15
|
+
textMutedColor,
|
|
16
|
+
}: {
|
|
17
|
+
title: string;
|
|
18
|
+
subtitle: string;
|
|
19
|
+
iconColor: string;
|
|
20
|
+
textColor: string;
|
|
21
|
+
textMutedColor: string;
|
|
22
|
+
}) {
|
|
23
|
+
return (
|
|
24
|
+
<View style={styles.emptyContainer}>
|
|
25
|
+
<Ionicons name="chatbubbles-outline" size={64} color={iconColor} />
|
|
26
|
+
<Text style={[styles.emptyTitle, { color: textColor }]}>{title}</Text>
|
|
27
|
+
<Text style={[styles.emptySubtitle, { color: textMutedColor }]}>
|
|
28
|
+
{subtitle}
|
|
29
|
+
</Text>
|
|
30
|
+
</View>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Typing indicator component
|
|
36
|
+
*/
|
|
37
|
+
function TypingIndicator({ bubbleColor, dotColor }: { bubbleColor: string; dotColor: string }) {
|
|
38
|
+
return (
|
|
39
|
+
<View style={styles.loadingContainer}>
|
|
40
|
+
<View style={[styles.typingIndicator, { backgroundColor: bubbleColor }]}>
|
|
41
|
+
<View style={[styles.dot, styles.dot1, { backgroundColor: dotColor }]} />
|
|
42
|
+
<View style={[styles.dot, styles.dot2, { backgroundColor: dotColor }]} />
|
|
43
|
+
<View style={[styles.dot, styles.dot3, { backgroundColor: dotColor }]} />
|
|
44
|
+
</View>
|
|
45
|
+
</View>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function MessageList({
|
|
50
|
+
messages,
|
|
51
|
+
isLoading,
|
|
52
|
+
theme,
|
|
53
|
+
renderEmptyState,
|
|
54
|
+
onCopyMessage,
|
|
55
|
+
emptyStateTitle = 'Start a conversation',
|
|
56
|
+
emptyStateSubtitle = 'Type a message below to get started',
|
|
57
|
+
}: MessageListProps) {
|
|
58
|
+
const flatListRef = useRef<FlatList<ChatMessage>>(null);
|
|
59
|
+
const { colors } = theme;
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (messages.length > 0 && flatListRef.current) {
|
|
63
|
+
setTimeout(() => {
|
|
64
|
+
flatListRef.current?.scrollToEnd({ animated: true });
|
|
65
|
+
}, 100);
|
|
66
|
+
}
|
|
67
|
+
}, [messages.length]);
|
|
68
|
+
|
|
69
|
+
const renderItem = ({ item }: { item: ChatMessage }) => (
|
|
70
|
+
<MessageBubble message={item} theme={theme} onCopy={onCopyMessage} />
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const renderEmptyStateComponent = () => {
|
|
74
|
+
if (renderEmptyState) {
|
|
75
|
+
if (typeof renderEmptyState === 'function') {
|
|
76
|
+
return renderEmptyState();
|
|
77
|
+
}
|
|
78
|
+
return renderEmptyState;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<DefaultEmptyState
|
|
83
|
+
title={emptyStateTitle}
|
|
84
|
+
subtitle={emptyStateSubtitle}
|
|
85
|
+
iconColor={colors.text}
|
|
86
|
+
textColor={colors.text}
|
|
87
|
+
textMutedColor={colors.textMuted}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const renderLoadingIndicator = () => {
|
|
93
|
+
if (!isLoading) return null;
|
|
94
|
+
return (
|
|
95
|
+
<TypingIndicator
|
|
96
|
+
bubbleColor={colors.loadingBubble}
|
|
97
|
+
dotColor={colors.loadingDots}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Using key to force remount when transitioning between empty/non-empty states
|
|
103
|
+
// This fixes the scroll size not resetting when clearing messages
|
|
104
|
+
const listKey = messages.length === 0 ? 'empty' : 'populated';
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
|
108
|
+
<FlatList
|
|
109
|
+
key={listKey}
|
|
110
|
+
ref={flatListRef}
|
|
111
|
+
data={messages}
|
|
112
|
+
renderItem={renderItem}
|
|
113
|
+
keyExtractor={(item: ChatMessage) => item.id}
|
|
114
|
+
contentContainerStyle={[
|
|
115
|
+
styles.listContent,
|
|
116
|
+
messages.length === 0 && styles.emptyListContent,
|
|
117
|
+
]}
|
|
118
|
+
ListEmptyComponent={renderEmptyStateComponent}
|
|
119
|
+
ListFooterComponent={renderLoadingIndicator}
|
|
120
|
+
showsVerticalScrollIndicator={false}
|
|
121
|
+
/>
|
|
122
|
+
</View>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const styles = StyleSheet.create({
|
|
127
|
+
container: {
|
|
128
|
+
flex: 1,
|
|
129
|
+
},
|
|
130
|
+
listContent: {
|
|
131
|
+
paddingVertical: 16,
|
|
132
|
+
},
|
|
133
|
+
emptyListContent: {
|
|
134
|
+
flex: 1,
|
|
135
|
+
justifyContent: 'center',
|
|
136
|
+
},
|
|
137
|
+
emptyContainer: {
|
|
138
|
+
alignItems: 'center',
|
|
139
|
+
paddingHorizontal: 32,
|
|
140
|
+
},
|
|
141
|
+
emptyTitle: {
|
|
142
|
+
fontSize: 20,
|
|
143
|
+
fontWeight: '600',
|
|
144
|
+
marginTop: 16,
|
|
145
|
+
marginBottom: 8,
|
|
146
|
+
textAlign: 'center',
|
|
147
|
+
},
|
|
148
|
+
emptySubtitle: {
|
|
149
|
+
fontSize: 16,
|
|
150
|
+
textAlign: 'center',
|
|
151
|
+
},
|
|
152
|
+
loadingContainer: {
|
|
153
|
+
paddingHorizontal: 16,
|
|
154
|
+
paddingVertical: 8,
|
|
155
|
+
alignItems: 'flex-start',
|
|
156
|
+
},
|
|
157
|
+
typingIndicator: {
|
|
158
|
+
flexDirection: 'row',
|
|
159
|
+
alignItems: 'center',
|
|
160
|
+
paddingHorizontal: 16,
|
|
161
|
+
paddingVertical: 12,
|
|
162
|
+
borderRadius: 20,
|
|
163
|
+
borderBottomLeftRadius: 4,
|
|
164
|
+
},
|
|
165
|
+
dot: {
|
|
166
|
+
width: 8,
|
|
167
|
+
height: 8,
|
|
168
|
+
borderRadius: 4,
|
|
169
|
+
marginHorizontal: 2,
|
|
170
|
+
},
|
|
171
|
+
dot1: {
|
|
172
|
+
opacity: 0.4,
|
|
173
|
+
},
|
|
174
|
+
dot2: {
|
|
175
|
+
opacity: 0.6,
|
|
176
|
+
},
|
|
177
|
+
dot3: {
|
|
178
|
+
opacity: 0.8,
|
|
179
|
+
},
|
|
180
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Types
|
|
2
|
+
export type {
|
|
3
|
+
ChatMessage,
|
|
4
|
+
ChatTheme,
|
|
5
|
+
ChatThemeColors,
|
|
6
|
+
ChatProps,
|
|
7
|
+
MessageBubbleProps,
|
|
8
|
+
MessageListProps,
|
|
9
|
+
ChatInputProps,
|
|
10
|
+
} from './types';
|
|
11
|
+
|
|
12
|
+
// Components
|
|
13
|
+
export { Chat } from './components/Chat';
|
|
14
|
+
export { ChatInput } from './components/ChatInput';
|
|
15
|
+
export { MessageBubble } from './components/MessageBubble';
|
|
16
|
+
export { MessageList } from './components/MessageList';
|
|
17
|
+
|
|
18
|
+
// Theme utilities
|
|
19
|
+
export {
|
|
20
|
+
lightTheme,
|
|
21
|
+
darkTheme,
|
|
22
|
+
lightColors,
|
|
23
|
+
darkColors,
|
|
24
|
+
getDefaultTheme,
|
|
25
|
+
mergeTheme,
|
|
26
|
+
} from './theme/defaultTheme';
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { ChatTheme, ChatThemeColors } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default light theme colors
|
|
5
|
+
* Based on the original Tovia app theme
|
|
6
|
+
*/
|
|
7
|
+
export const lightColors: ChatThemeColors = {
|
|
8
|
+
primary: '#FF8360',
|
|
9
|
+
background: '#EFF1ED',
|
|
10
|
+
inputBackground: '#FFFFFF',
|
|
11
|
+
userBubble: '#FF8360',
|
|
12
|
+
assistantBubble: '#E8E4D3',
|
|
13
|
+
userText: '#FFFFFF',
|
|
14
|
+
assistantText: '#2A2B2A',
|
|
15
|
+
text: '#2A2B2A',
|
|
16
|
+
textMuted: 'rgba(0, 0, 0, 0.5)',
|
|
17
|
+
placeholder: '#999999',
|
|
18
|
+
border: 'rgba(0, 0, 0, 0.1)',
|
|
19
|
+
sendButtonDisabled: '#CCCCCC',
|
|
20
|
+
userBubbleMuted: 'rgba(255, 255, 255, 0.7)',
|
|
21
|
+
assistantBubbleMuted: 'rgba(0, 0, 0, 0.4)',
|
|
22
|
+
loadingBubble: '#E8E4D3',
|
|
23
|
+
loadingDots: '#888888',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Default dark theme colors
|
|
28
|
+
*/
|
|
29
|
+
export const darkColors: ChatThemeColors = {
|
|
30
|
+
primary: '#FF8360',
|
|
31
|
+
background: '#2A2B2A',
|
|
32
|
+
inputBackground: '#3A3B3A',
|
|
33
|
+
userBubble: '#FF8360',
|
|
34
|
+
assistantBubble: '#3A3B3A',
|
|
35
|
+
userText: '#FFFFFF',
|
|
36
|
+
assistantText: '#F8F4E3',
|
|
37
|
+
text: '#F8F4E3',
|
|
38
|
+
textMuted: 'rgba(255, 255, 255, 0.5)',
|
|
39
|
+
placeholder: '#666666',
|
|
40
|
+
border: 'rgba(255, 255, 255, 0.1)',
|
|
41
|
+
sendButtonDisabled: '#666666',
|
|
42
|
+
userBubbleMuted: 'rgba(255, 255, 255, 0.7)',
|
|
43
|
+
assistantBubbleMuted: 'rgba(255, 255, 255, 0.5)',
|
|
44
|
+
loadingBubble: '#3A3B3A',
|
|
45
|
+
loadingDots: '#888888',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Default light theme
|
|
50
|
+
*/
|
|
51
|
+
export const lightTheme: ChatTheme = {
|
|
52
|
+
mode: 'light',
|
|
53
|
+
colors: lightColors,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Default dark theme
|
|
58
|
+
*/
|
|
59
|
+
export const darkTheme: ChatTheme = {
|
|
60
|
+
mode: 'dark',
|
|
61
|
+
colors: darkColors,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get the default theme based on mode
|
|
66
|
+
*/
|
|
67
|
+
export function getDefaultTheme(mode: 'light' | 'dark' = 'light'): ChatTheme {
|
|
68
|
+
return mode === 'dark' ? darkTheme : lightTheme;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Merge a partial theme with the default theme
|
|
73
|
+
*/
|
|
74
|
+
export function mergeTheme(
|
|
75
|
+
partial?: Partial<ChatTheme> & { colors?: Partial<ChatThemeColors> }
|
|
76
|
+
): ChatTheme {
|
|
77
|
+
const mode = partial?.mode ?? 'light';
|
|
78
|
+
const defaultTheme = getDefaultTheme(mode);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
mode,
|
|
82
|
+
colors: {
|
|
83
|
+
...defaultTheme.colors,
|
|
84
|
+
...(partial?.colors ?? {}),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Represents a single chat message
|
|
5
|
+
*/
|
|
6
|
+
export interface ChatMessage {
|
|
7
|
+
/** Unique identifier for the message */
|
|
8
|
+
id: string;
|
|
9
|
+
/** Role of the message sender */
|
|
10
|
+
role: 'user' | 'assistant' | 'system';
|
|
11
|
+
/** Text content of the message */
|
|
12
|
+
content: string;
|
|
13
|
+
/** Timestamp of when the message was sent (ms since epoch or Date) */
|
|
14
|
+
timestamp: number | Date;
|
|
15
|
+
/** Optional metadata for extending message data */
|
|
16
|
+
meta?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Theme colors for the chat UI
|
|
21
|
+
*/
|
|
22
|
+
export interface ChatThemeColors {
|
|
23
|
+
/** Primary/accent color (used for send button, etc.) */
|
|
24
|
+
primary: string;
|
|
25
|
+
/** Main background color */
|
|
26
|
+
background: string;
|
|
27
|
+
/** Input field background */
|
|
28
|
+
inputBackground: string;
|
|
29
|
+
/** User message bubble background */
|
|
30
|
+
userBubble: string;
|
|
31
|
+
/** Assistant message bubble background */
|
|
32
|
+
assistantBubble: string;
|
|
33
|
+
/** Text color inside user bubbles */
|
|
34
|
+
userText: string;
|
|
35
|
+
/** Text color inside assistant bubbles */
|
|
36
|
+
assistantText: string;
|
|
37
|
+
/** Primary text color */
|
|
38
|
+
text: string;
|
|
39
|
+
/** Muted/secondary text color */
|
|
40
|
+
textMuted: string;
|
|
41
|
+
/** Placeholder text color */
|
|
42
|
+
placeholder: string;
|
|
43
|
+
/** Border color */
|
|
44
|
+
border: string;
|
|
45
|
+
/** Disabled send button color */
|
|
46
|
+
sendButtonDisabled: string;
|
|
47
|
+
/** Muted color for icons/timestamps in user bubbles */
|
|
48
|
+
userBubbleMuted: string;
|
|
49
|
+
/** Muted color for icons/timestamps in assistant bubbles */
|
|
50
|
+
assistantBubbleMuted: string;
|
|
51
|
+
/** Loading indicator bubble background */
|
|
52
|
+
loadingBubble: string;
|
|
53
|
+
/** Loading indicator dots color */
|
|
54
|
+
loadingDots: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Complete theme configuration
|
|
59
|
+
*/
|
|
60
|
+
export interface ChatTheme {
|
|
61
|
+
/** Color mode */
|
|
62
|
+
mode: 'light' | 'dark';
|
|
63
|
+
/** Theme colors */
|
|
64
|
+
colors: ChatThemeColors;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Props for the main Chat component
|
|
69
|
+
*/
|
|
70
|
+
export interface ChatProps {
|
|
71
|
+
/** Array of messages to display */
|
|
72
|
+
messages: ChatMessage[];
|
|
73
|
+
/** Callback when user sends a message */
|
|
74
|
+
onSend: (text: string) => void | Promise<void>;
|
|
75
|
+
/** Shows typing indicator when true */
|
|
76
|
+
isLoading?: boolean;
|
|
77
|
+
/** Disables input when true */
|
|
78
|
+
disabled?: boolean;
|
|
79
|
+
/** Custom theme (merged with defaults based on mode) */
|
|
80
|
+
theme?: Partial<ChatTheme> & { colors?: Partial<ChatThemeColors> };
|
|
81
|
+
/** Custom empty state renderer */
|
|
82
|
+
renderEmptyState?: ReactNode | (() => ReactNode);
|
|
83
|
+
/** Custom copy message handler (defaults to expo-clipboard) */
|
|
84
|
+
onCopyMessage?: (message: ChatMessage) => void;
|
|
85
|
+
/** Input placeholder text */
|
|
86
|
+
placeholder?: string;
|
|
87
|
+
/** Empty state title */
|
|
88
|
+
emptyStateTitle?: string;
|
|
89
|
+
/** Empty state subtitle */
|
|
90
|
+
emptyStateSubtitle?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Props for MessageBubble component
|
|
95
|
+
*/
|
|
96
|
+
export interface MessageBubbleProps {
|
|
97
|
+
/** The message to display */
|
|
98
|
+
message: ChatMessage;
|
|
99
|
+
/** Theme colors */
|
|
100
|
+
theme: ChatTheme;
|
|
101
|
+
/** Custom copy handler */
|
|
102
|
+
onCopy?: (message: ChatMessage) => void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Props for MessageList component
|
|
107
|
+
*/
|
|
108
|
+
export interface MessageListProps {
|
|
109
|
+
/** Array of messages to display */
|
|
110
|
+
messages: ChatMessage[];
|
|
111
|
+
/** Shows typing indicator when true */
|
|
112
|
+
isLoading?: boolean;
|
|
113
|
+
/** Theme configuration */
|
|
114
|
+
theme: ChatTheme;
|
|
115
|
+
/** Custom empty state renderer */
|
|
116
|
+
renderEmptyState?: ReactNode | (() => ReactNode);
|
|
117
|
+
/** Custom copy handler */
|
|
118
|
+
onCopyMessage?: (message: ChatMessage) => void;
|
|
119
|
+
/** Empty state title */
|
|
120
|
+
emptyStateTitle?: string;
|
|
121
|
+
/** Empty state subtitle */
|
|
122
|
+
emptyStateSubtitle?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Props for ChatInput component
|
|
127
|
+
*/
|
|
128
|
+
export interface ChatInputProps {
|
|
129
|
+
/** Callback when user sends a message */
|
|
130
|
+
onSend: (text: string) => void | Promise<void>;
|
|
131
|
+
/** Disables input when true */
|
|
132
|
+
disabled?: boolean;
|
|
133
|
+
/** Theme configuration */
|
|
134
|
+
theme: ChatTheme;
|
|
135
|
+
/** Placeholder text */
|
|
136
|
+
placeholder?: string;
|
|
137
|
+
}
|