@unif/react-native-chat 0.1.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.
@@ -0,0 +1,435 @@
1
+ /**
2
+ * Sender — 输入发送器
3
+ *
4
+ * 完整状态机:collapsed → expanded → recording
5
+ * 基于 UI 库 Input + ActionSheet + WaveAnimation + Button 构建
6
+ */
7
+
8
+ import React, {useState, useCallback, useRef} from 'react';
9
+ import {
10
+ View,
11
+ Text,
12
+ TouchableOpacity,
13
+ StyleSheet,
14
+ Platform,
15
+ PanResponder,
16
+ type ViewStyle,
17
+ } from 'react-native';
18
+ import {chatTokens} from '../theme/tokens';
19
+
20
+ export interface ActionSheetOption {
21
+ key: string;
22
+ label: string;
23
+ icon?: React.ReactNode;
24
+ }
25
+
26
+ export interface SenderSemanticStyles {
27
+ root?: ViewStyle;
28
+ collapsed?: ViewStyle;
29
+ expanded?: ViewStyle;
30
+ recording?: ViewStyle;
31
+ toolbar?: ViewStyle;
32
+ }
33
+
34
+ export interface SenderProps {
35
+ onSend: (text: string) => void;
36
+ onStop?: () => void;
37
+ onVoiceSend?: (audioUri: string) => void;
38
+ isRequesting?: boolean;
39
+ placeholder?: string;
40
+ maxLength?: number;
41
+ voiceEnabled?: boolean;
42
+ actionsEnabled?: boolean;
43
+ actions?: ActionSheetOption[];
44
+ onActionSelect?: (key: string) => void;
45
+ disabled?: boolean;
46
+ style?: ViewStyle;
47
+ styles?: Partial<SenderSemanticStyles>;
48
+ testID?: string;
49
+ // 渲染注入点(允许使用方注入 UI 库组件)
50
+ renderInput?: (props: {
51
+ value: string;
52
+ onChangeText: (text: string) => void;
53
+ onSubmitEditing: () => void;
54
+ onBlur: () => void;
55
+ toolbar: React.ReactNode;
56
+ autoFocus: boolean;
57
+ }) => React.ReactNode;
58
+ renderActionSheet?: (props: {
59
+ visible: boolean;
60
+ onClose: () => void;
61
+ options: ActionSheetOption[];
62
+ onSelect: (key: string) => void;
63
+ }) => React.ReactNode;
64
+ renderWaveAnimation?: (props: {
65
+ active: boolean;
66
+ color: string;
67
+ }) => React.ReactNode;
68
+ }
69
+
70
+ const CANCEL_THRESHOLD = 80;
71
+
72
+ const Sender: React.FC<SenderProps> = ({
73
+ onSend,
74
+ onStop,
75
+ isRequesting = false,
76
+ placeholder = '发消息',
77
+ maxLength = 2000,
78
+ voiceEnabled = true,
79
+ actionsEnabled = true,
80
+ actions = [],
81
+ onActionSelect,
82
+ disabled = false,
83
+ style,
84
+ styles: semanticStyles,
85
+ testID = 'sender',
86
+ renderInput,
87
+ renderActionSheet,
88
+ renderWaveAnimation,
89
+ }) => {
90
+ const [mode, setMode] = useState<'text' | 'voice'>('text');
91
+ const [expanded, setExpanded] = useState(false);
92
+ const [text, setText] = useState('');
93
+ const [showActions, setShowActions] = useState(false);
94
+ const [isRecording, setIsRecording] = useState(false);
95
+ const [recordingCancelled, setRecordingCancelled] = useState(false);
96
+ const startY = useRef(0);
97
+
98
+ const handleToggleMode = useCallback(() => {
99
+ setMode((prev) => (prev === 'text' ? 'voice' : 'text'));
100
+ setExpanded(false);
101
+ }, []);
102
+
103
+ const handleExpand = useCallback(() => {
104
+ setExpanded(true);
105
+ }, []);
106
+
107
+ const handleCollapse = useCallback(() => {
108
+ if (!text.trim()) {
109
+ setExpanded(false);
110
+ }
111
+ }, [text]);
112
+
113
+ const handleSend = useCallback(() => {
114
+ const trimmed = text.trim();
115
+ if (trimmed && !disabled) {
116
+ onSend(trimmed);
117
+ setText('');
118
+ setExpanded(false);
119
+ }
120
+ }, [text, onSend, disabled]);
121
+
122
+ const handleActionSelect = useCallback(
123
+ (key: string) => {
124
+ setShowActions(false);
125
+ onActionSelect?.(key);
126
+ },
127
+ [onActionSelect]
128
+ );
129
+
130
+ // 录音手势
131
+ const panResponder = useRef(
132
+ PanResponder.create({
133
+ onStartShouldSetPanResponder: () => true,
134
+ onMoveShouldSetPanResponder: () => true,
135
+ onPanResponderGrant: () => {
136
+ startY.current = 0;
137
+ setIsRecording(true);
138
+ setRecordingCancelled(false);
139
+ },
140
+ onPanResponderMove: (_, gestureState) => {
141
+ if (gestureState.dy < -CANCEL_THRESHOLD) {
142
+ setRecordingCancelled(true);
143
+ } else {
144
+ setRecordingCancelled(false);
145
+ }
146
+ },
147
+ onPanResponderRelease: () => {
148
+ setIsRecording(false);
149
+ setRecordingCancelled(false);
150
+ },
151
+ })
152
+ ).current;
153
+
154
+ const hasText = text.trim().length > 0;
155
+
156
+ // === 录音态 ===
157
+ if (isRecording && voiceEnabled) {
158
+ const bgColor = recordingCancelled
159
+ ? chatTokens.colorError
160
+ : chatTokens.colorPrimary;
161
+ const hintText = recordingCancelled ? '松手取消' : '松开发送,上划取消';
162
+
163
+ return (
164
+ <View style={[defaultStyles.container, semanticStyles?.root, style]}>
165
+ <View
166
+ style={[
167
+ defaultStyles.recordingBar,
168
+ {backgroundColor: bgColor},
169
+ semanticStyles?.recording,
170
+ ]}
171
+ {...panResponder.panHandlers}
172
+ testID={`${testID}-recording`}>
173
+ <Text style={defaultStyles.recordingHint}>{hintText}</Text>
174
+ {renderWaveAnimation?.({active: true, color: '#FFFFFF'})}
175
+ </View>
176
+ </View>
177
+ );
178
+ }
179
+
180
+ // === 展开态 ===
181
+ if (expanded && mode === 'text') {
182
+ const toolbar = (
183
+ <View style={[defaultStyles.toolbar, semanticStyles?.toolbar]}>
184
+ {voiceEnabled && (
185
+ <TouchableOpacity
186
+ onPress={handleToggleMode}
187
+ style={defaultStyles.toolButton}
188
+ testID={`${testID}-toggle-mode`}>
189
+ <Text style={defaultStyles.iconText}>🎤</Text>
190
+ </TouchableOpacity>
191
+ )}
192
+
193
+ <View style={defaultStyles.toolbarSpacer} />
194
+
195
+ {actionsEnabled && (
196
+ <TouchableOpacity
197
+ onPress={() => setShowActions(true)}
198
+ style={defaultStyles.toolButton}
199
+ testID={`${testID}-actions`}>
200
+ <Text style={defaultStyles.iconText}>+</Text>
201
+ </TouchableOpacity>
202
+ )}
203
+
204
+ {isRequesting ? (
205
+ <TouchableOpacity
206
+ testID={`${testID}-stop`}
207
+ style={defaultStyles.sendButton}
208
+ onPress={onStop}
209
+ activeOpacity={0.7}>
210
+ <View
211
+ style={[
212
+ defaultStyles.sendCircle,
213
+ {backgroundColor: chatTokens.colorError},
214
+ ]}>
215
+ <Text style={defaultStyles.sendIcon}>⏹</Text>
216
+ </View>
217
+ </TouchableOpacity>
218
+ ) : hasText ? (
219
+ <TouchableOpacity
220
+ testID={`${testID}-send`}
221
+ style={defaultStyles.sendButton}
222
+ onPress={handleSend}
223
+ activeOpacity={0.7}>
224
+ <View style={defaultStyles.sendCircle}>
225
+ <Text style={defaultStyles.sendIcon}>↑</Text>
226
+ </View>
227
+ </TouchableOpacity>
228
+ ) : null}
229
+ </View>
230
+ );
231
+
232
+ return (
233
+ <View style={[defaultStyles.container, semanticStyles?.root, style]}>
234
+ {renderInput ? (
235
+ renderInput({
236
+ value: text,
237
+ onChangeText: setText,
238
+ onSubmitEditing: handleSend,
239
+ onBlur: handleCollapse,
240
+ toolbar,
241
+ autoFocus: true,
242
+ })
243
+ ) : (
244
+ <View style={defaultStyles.expandedFallback}>
245
+ {/* 无 UI 库 Input 时的 fallback: 使用原生 TextInput */}
246
+ <View style={defaultStyles.expandedInputArea}>
247
+ <Text style={defaultStyles.expandedText}>
248
+ {text || placeholder}
249
+ </Text>
250
+ </View>
251
+ {toolbar}
252
+ </View>
253
+ )}
254
+ {renderActionSheet?.({
255
+ visible: showActions,
256
+ onClose: () => setShowActions(false),
257
+ options: actions,
258
+ onSelect: handleActionSelect,
259
+ })}
260
+ </View>
261
+ );
262
+ }
263
+
264
+ // === 收起态 ===
265
+ const isVoice = mode === 'voice';
266
+
267
+ return (
268
+ <View style={[defaultStyles.container, semanticStyles?.root, style]}>
269
+ <View
270
+ style={[defaultStyles.collapsedBar, semanticStyles?.collapsed]}>
271
+ {voiceEnabled && (
272
+ <TouchableOpacity
273
+ onPress={handleToggleMode}
274
+ style={defaultStyles.modeButton}
275
+ testID={`${testID}-toggle-mode`}>
276
+ <Text style={defaultStyles.iconText}>
277
+ {isVoice ? '⌨' : '🎤'}
278
+ </Text>
279
+ </TouchableOpacity>
280
+ )}
281
+
282
+ {isVoice ? (
283
+ <TouchableOpacity
284
+ style={defaultStyles.voiceArea}
285
+ onPressIn={() => {
286
+ startY.current = 0;
287
+ setIsRecording(true);
288
+ setRecordingCancelled(false);
289
+ }}
290
+ onPressOut={() => {
291
+ setIsRecording(false);
292
+ setRecordingCancelled(false);
293
+ }}
294
+ activeOpacity={0.7}
295
+ testID={`${testID}-voice-hold`}>
296
+ <Text style={defaultStyles.voiceText}>按住说话</Text>
297
+ </TouchableOpacity>
298
+ ) : (
299
+ <TouchableOpacity
300
+ style={defaultStyles.placeholderArea}
301
+ onPress={handleExpand}
302
+ activeOpacity={0.7}
303
+ testID={`${testID}-expand`}>
304
+ <Text style={defaultStyles.placeholderText}>{placeholder}</Text>
305
+ </TouchableOpacity>
306
+ )}
307
+
308
+ {actionsEnabled && (
309
+ <TouchableOpacity
310
+ onPress={() => setShowActions(true)}
311
+ style={defaultStyles.plusButton}
312
+ testID={`${testID}-plus`}>
313
+ <Text style={defaultStyles.iconText}>+</Text>
314
+ </TouchableOpacity>
315
+ )}
316
+ </View>
317
+
318
+ {renderActionSheet?.({
319
+ visible: showActions,
320
+ onClose: () => setShowActions(false),
321
+ options: actions,
322
+ onSelect: handleActionSelect,
323
+ })}
324
+ </View>
325
+ );
326
+ };
327
+
328
+ const defaultStyles = StyleSheet.create({
329
+ container: {
330
+ paddingHorizontal: chatTokens.space,
331
+ paddingTop: chatTokens.spaceSm,
332
+ paddingBottom: Platform.OS === 'ios' ? chatTokens.spaceSm : chatTokens.space,
333
+ },
334
+
335
+ // 收起态
336
+ collapsedBar: {
337
+ flexDirection: 'row',
338
+ alignItems: 'center',
339
+ backgroundColor: chatTokens.colorBgElevated,
340
+ borderRadius: chatTokens.radiusXl,
341
+ height: 46,
342
+ paddingHorizontal: 12,
343
+ ...chatTokens.shadow,
344
+ },
345
+ modeButton: {
346
+ padding: 4,
347
+ },
348
+ placeholderArea: {
349
+ flex: 1,
350
+ justifyContent: 'center',
351
+ paddingLeft: 8,
352
+ },
353
+ placeholderText: {
354
+ fontSize: chatTokens.fontSize,
355
+ color: chatTokens.colorTextPlaceholder,
356
+ },
357
+ voiceArea: {
358
+ flex: 1,
359
+ justifyContent: 'center',
360
+ alignItems: 'center',
361
+ },
362
+ voiceText: {
363
+ fontSize: chatTokens.fontSize,
364
+ fontWeight: '500',
365
+ color: chatTokens.colorText,
366
+ },
367
+ plusButton: {
368
+ padding: 4,
369
+ },
370
+
371
+ // 展开态
372
+ expandedFallback: {
373
+ backgroundColor: chatTokens.colorBgElevated,
374
+ borderRadius: chatTokens.radiusMd,
375
+ padding: 12,
376
+ ...chatTokens.shadow,
377
+ },
378
+ expandedInputArea: {
379
+ minHeight: 40,
380
+ maxHeight: 100,
381
+ },
382
+ expandedText: {
383
+ fontSize: chatTokens.fontSize,
384
+ color: chatTokens.colorText,
385
+ },
386
+ toolbar: {
387
+ flexDirection: 'row',
388
+ alignItems: 'center',
389
+ },
390
+ toolButton: {
391
+ padding: 4,
392
+ },
393
+ toolbarSpacer: {
394
+ flex: 1,
395
+ },
396
+ sendButton: {
397
+ marginLeft: 8,
398
+ },
399
+ sendCircle: {
400
+ width: 32,
401
+ height: 32,
402
+ borderRadius: 16,
403
+ backgroundColor: chatTokens.colorPrimary,
404
+ justifyContent: 'center',
405
+ alignItems: 'center',
406
+ },
407
+ sendIcon: {
408
+ color: '#FFFFFF',
409
+ fontSize: 16,
410
+ fontWeight: '600',
411
+ },
412
+
413
+ // 录音态
414
+ recordingBar: {
415
+ flexDirection: 'row',
416
+ alignItems: 'center',
417
+ justifyContent: 'space-between',
418
+ height: 46,
419
+ borderRadius: chatTokens.radiusXl,
420
+ paddingHorizontal: 16,
421
+ },
422
+ recordingHint: {
423
+ fontSize: 14,
424
+ color: '#FFFFFF',
425
+ fontWeight: '500',
426
+ },
427
+
428
+ // 通用
429
+ iconText: {
430
+ fontSize: 22,
431
+ color: chatTokens.colorTextSecondary,
432
+ },
433
+ });
434
+
435
+ export default React.memo(Sender);
@@ -0,0 +1,131 @@
1
+ ---
2
+ title: Sender 输入发送器
3
+ nav:
4
+ title: 组件
5
+ path: /components
6
+ ---
7
+
8
+ # Sender 输入发送器
9
+
10
+ 完整的聊天输入组件,包含文本输入、语音录制、附件面板三种模式。
11
+
12
+ ## 何时使用
13
+
14
+ - 需要完整的聊天输入体验
15
+ - 需要文本/语音双模式切换
16
+ - 需要附件或扩展操作面板
17
+
18
+ ## 状态机
19
+
20
+ ```
21
+ collapsed (收起态)
22
+ ├── 点击输入区 → expanded (展开态)
23
+ ├── 按住说话按钮 → recording (录音态)
24
+ └── 点击 + → ActionSheet
25
+ expanded (展开态)
26
+ ├── 输入文本 + 发送 → collapsed
27
+ └── 失焦且无文本 → collapsed
28
+ recording (录音态)
29
+ ├── 松手 → 发送录音 → collapsed
30
+ └── 上划超过阈值 → 取消录音 → collapsed
31
+ ```
32
+
33
+ ## 代码示例
34
+
35
+ ### 基本用法
36
+
37
+ ```tsx
38
+ import { Sender } from '@unif/react-native-chat';
39
+
40
+ <Sender
41
+ onSend={(text) => console.log('发送:', text)}
42
+ placeholder="输入消息..."
43
+ />
44
+ ```
45
+
46
+ ### 带停止按钮
47
+
48
+ ```tsx
49
+ <Sender
50
+ onSend={handleSend}
51
+ onStop={handleAbort}
52
+ isRequesting={true}
53
+ />
54
+ ```
55
+
56
+ ### 注入 UI 库组件
57
+
58
+ ```tsx
59
+ import { Input, ActionSheet, WaveAnimation } from '@unif/react-native-ui';
60
+
61
+ <Sender
62
+ onSend={handleSend}
63
+ actions={[
64
+ { key: 'camera', label: '拍照' },
65
+ { key: 'album', label: '相册' },
66
+ ]}
67
+ onActionSelect={handleAction}
68
+ renderInput={(props) => (
69
+ <Input
70
+ value={props.value}
71
+ onChangeText={props.onChangeText}
72
+ onSubmitEditing={props.onSubmitEditing}
73
+ toolbar={props.toolbar}
74
+ autoFocus={props.autoFocus}
75
+ />
76
+ )}
77
+ renderActionSheet={(props) => (
78
+ <ActionSheet
79
+ visible={props.visible}
80
+ onClose={props.onClose}
81
+ options={props.options}
82
+ onSelect={props.onSelect}
83
+ />
84
+ )}
85
+ renderWaveAnimation={(props) => (
86
+ <WaveAnimation active={props.active} color={props.color} />
87
+ )}
88
+ />
89
+ ```
90
+
91
+ ## API
92
+
93
+ ### SenderProps
94
+
95
+ | 属性 | 说明 | 类型 | 默认值 |
96
+ |------|------|------|--------|
97
+ | onSend | 发送文本回调 | `(text: string) => void` | - |
98
+ | onStop | 中止请求回调 | `() => void` | - |
99
+ | onVoiceSend | 语音发送回调 | `(audioUri: string) => void` | - |
100
+ | isRequesting | 是否正在请求中 | `boolean` | `false` |
101
+ | placeholder | 占位文字 | `string` | `'发消息'` |
102
+ | maxLength | 最大字数 | `number` | `2000` |
103
+ | voiceEnabled | 启用语音按钮 | `boolean` | `true` |
104
+ | actionsEnabled | 启用 + 按钮 | `boolean` | `true` |
105
+ | actions | 操作面板选项 | `ActionSheetOption[]` | `[]` |
106
+ | onActionSelect | 操作面板选中回调 | `(key: string) => void` | - |
107
+ | disabled | 禁用 | `boolean` | `false` |
108
+ | style | 容器样式 | `ViewStyle` | - |
109
+ | styles | 语义样式 | `Partial<SenderSemanticStyles>` | - |
110
+ | testID | 测试标识 | `string` | `'sender'` |
111
+ | renderInput | 自定义输入框渲染 | `(props) => ReactNode` | - |
112
+ | renderActionSheet | 自定义操作面板渲染 | `(props) => ReactNode` | - |
113
+ | renderWaveAnimation | 自定义波形动画渲染 | `(props) => ReactNode` | - |
114
+
115
+ ### SenderSemanticStyles
116
+
117
+ | 属性 | 说明 | 类型 |
118
+ |------|------|------|
119
+ | root | 外层容器 | `ViewStyle` |
120
+ | collapsed | 收起态 | `ViewStyle` |
121
+ | expanded | 展开态 | `ViewStyle` |
122
+ | recording | 录音态 | `ViewStyle` |
123
+ | toolbar | 工具栏 | `ViewStyle` |
124
+
125
+ ### ActionSheetOption
126
+
127
+ | 属性 | 说明 | 类型 |
128
+ |------|------|------|
129
+ | key | 唯一标识 | `string` |
130
+ | label | 显示文字 | `string` |
131
+ | icon | 图标 | `ReactNode` |
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Chat 专属 Design Tokens
3
+ * 仅定义 Chat 特有的 token,通用 token 从 @unif/react-native-ui 引用
4
+ */
5
+
6
+ export const chatTokens = {
7
+ // 气泡
8
+ colorBgUserMsg: '#E8F0FE',
9
+ colorBgAssistantMsg: 'transparent',
10
+
11
+ // 欢迎页渐变
12
+ colorBgWelcomeStart: '#E8F4FD',
13
+ colorBgWelcomeMid: '#F0E6FF',
14
+ colorBgWelcomeEnd: '#F5F5F5',
15
+
16
+ // 录音
17
+ colorRecording: '#FF3B30',
18
+
19
+ // 通用
20
+ colorPrimary: '#1677FF',
21
+ colorError: '#FF3B30',
22
+ colorText: '#1F2937',
23
+ colorTextSecondary: '#6B7280',
24
+ colorTextPlaceholder: '#9CA3AF',
25
+ colorBgElevated: '#FFFFFF',
26
+ colorBorder: '#E5E7EB',
27
+ colorLink: '#1677FF',
28
+
29
+ // 间距
30
+ spaceXs: 4,
31
+ spaceSm: 8,
32
+ space: 12,
33
+ spaceMd: 16,
34
+
35
+ // 字体
36
+ fontSize: 15,
37
+ fontSizeSm: 13,
38
+ lineHeight: 22,
39
+
40
+ // 圆角
41
+ radiusSm: 6,
42
+ radiusMd: 12,
43
+ radiusXl: 23,
44
+ radiusFull: 999,
45
+
46
+ // 阴影
47
+ shadow: {
48
+ shadowColor: '#000',
49
+ shadowOffset: { width: 0, height: 1 },
50
+ shadowOpacity: 0.05,
51
+ shadowRadius: 3,
52
+ elevation: 2,
53
+ },
54
+ shadowSm: {
55
+ shadowColor: '#000',
56
+ shadowOffset: { width: 0, height: 1 },
57
+ shadowOpacity: 0.03,
58
+ shadowRadius: 2,
59
+ elevation: 1,
60
+ },
61
+ };