Squash merge feat/expo-app: app-expo, .cursor, workflows, package.json, .husky; remove app-android, app-ios, react-app
This commit is contained in:
967
app-expo/src/app/(main)/conversation/[id].tsx
Normal file
967
app-expo/src/app/(main)/conversation/[id].tsx
Normal file
@@ -0,0 +1,967 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { Mic, Pause, Play, PlusCircle, Type, X } from 'lucide-react-native';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Animated,
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { ScreenHeader } from '@/components/screen-header';
|
||||
import { useThemeColors } from '@/hooks/use-theme-colors';
|
||||
import { useMessages, useRealtimeSession } from '@/features/conversation/hooks';
|
||||
import type { MessageItem } from '@/features/conversation/types';
|
||||
import { audioFocus } from '@/core/audio/audio-focus';
|
||||
import { useRecorder } from '@/features/voice/hooks/use-recorder';
|
||||
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
|
||||
|
||||
// Life-Echo chat colors (from HTML reference)
|
||||
const CHAT_COLORS = {
|
||||
background: '#F8F9FA',
|
||||
surface: '#fbf8fd',
|
||||
surfaceContainer: '#f5f3f7',
|
||||
primary: '#8177A6',
|
||||
primaryBorder: '#49416c',
|
||||
onPrimary: '#ffffff',
|
||||
onSurface: '#1b1b1f',
|
||||
onSurfaceVariant: '#8D8C90',
|
||||
outline: '#cac4cf',
|
||||
secondaryContainer: '#edd4ff',
|
||||
primaryFixed: '#e7deff',
|
||||
errorRed: '#ba1a1a',
|
||||
};
|
||||
|
||||
/** 后端用 [SPLIT] 分隔多条消息,需按块渲染为多个气泡 */
|
||||
const SPLIT_DELIMITER = '[SPLIT]';
|
||||
|
||||
function splitContent(content: string): string[] {
|
||||
return content
|
||||
.split(SPLIT_DELIMITER)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
const AGENT_AVATAR =
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuC2cLdqTitOjFBnGTNsoJGh45K9zvV89vxB0EEHh0DT-YlbiOVyTuSmni5SO2eDOMC1HrW8cuhj29_KJJJFMV8aEPLHCwQgGA006Q9EfoDtEHP4GnzB4SEnT8bjCk6lQwjgh2Qp5dvq8oQTU5aQnKXtqOTAIIw1I3GWYlKimGa7Zqw4lnax84XRRGeYp6wj_1B9WsXuDvY7mNHIN7y_Tw0-TbRK1MGMHgd_nMNEqoqUCX6VAaDF2BjLB7BQnzIql8_8mw0TPaw1R3wO';
|
||||
const USER_AVATAR =
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuAMCjDBVhsUUXRAz9AGYejbTGoEYhzyiggYt_QIFqHCc3odRcBPNRhsE2Klg7gOeOV9V_qOy5qPqjU0GmpfgjGAWKGXZCizwRVz96N0n1IFMx4JH7QwV81zQsaVvCdJct_uABUBEawhncvQcbl0jUt_EUlNgzB-gIgUS_oLlT1TtRb8S5s7sAqwLRdGBa61yxL1X1iSWSFIn5N-WPIDs_vpCgS47q9SQjkT1q7VKvPzHzTiGF1bwVvjB7Bl2JgtaIUj6rkwlLbPG6xb';
|
||||
|
||||
type InputMode = 'text' | 'voice';
|
||||
|
||||
/** 展平消息列表:assistant 消息按 [SPLIT] 拆成多条,每条一个 listKey */
|
||||
function flattenMessagesForList(
|
||||
messages: MessageItem[],
|
||||
): (MessageItem & { listKey: string })[] {
|
||||
const result: (MessageItem & { listKey: string })[] = [];
|
||||
for (const msg of messages) {
|
||||
if (msg.senderType === 'user') {
|
||||
result.push({ ...msg, listKey: msg.id });
|
||||
} else {
|
||||
const parts = splitContent(msg.content);
|
||||
if (parts.length > 1) {
|
||||
parts.forEach((part, i) => {
|
||||
result.push({
|
||||
...msg,
|
||||
content: part,
|
||||
listKey: `${msg.id}_part_${i}`,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
result.push({ ...msg, listKey: msg.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function MessageBubble({
|
||||
item,
|
||||
agentName,
|
||||
meLabel,
|
||||
}: {
|
||||
item: MessageItem;
|
||||
agentName: string;
|
||||
meLabel: string;
|
||||
}) {
|
||||
const isUser = item.senderType === 'user';
|
||||
const isVoice = item.messageType === 'voice';
|
||||
|
||||
return (
|
||||
<View style={[styles.messageRow, isUser && styles.messageRowReverse]}>
|
||||
<View
|
||||
style={[
|
||||
styles.avatarWrapper,
|
||||
isUser ? styles.avatarWrapperUser : styles.avatarWrapperAgent,
|
||||
]}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: isUser ? USER_AVATAR : AGENT_AVATAR }}
|
||||
style={styles.avatarImage}
|
||||
alt={isUser ? meLabel : agentName}
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.bubbleColumn, isUser && styles.bubbleColumnEnd]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.nickname,
|
||||
isUser ? styles.nicknameUser : styles.nicknameAgent,
|
||||
]}
|
||||
>
|
||||
{isUser ? meLabel : agentName}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.bubble,
|
||||
isUser ? styles.bubbleUser : styles.bubbleAgent,
|
||||
]}
|
||||
>
|
||||
{isVoice ? (
|
||||
<VoiceMessageBubble
|
||||
durationSeconds={item.durationSeconds ?? 0}
|
||||
audioUri={item.audioUri}
|
||||
isUser={isUser}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
selectable
|
||||
style={[
|
||||
styles.bubbleText,
|
||||
isUser ? styles.bubbleTextUser : styles.bubbleTextAgent,
|
||||
]}
|
||||
>
|
||||
{item.content}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamingBubbles({
|
||||
streamingText,
|
||||
isComplete,
|
||||
agentName,
|
||||
}: {
|
||||
streamingText: string;
|
||||
isComplete: boolean;
|
||||
agentName: string;
|
||||
}) {
|
||||
const parts = splitContent(streamingText);
|
||||
const completedParts = parts.length > 1 ? parts.slice(0, -1) : [];
|
||||
const streamingPart =
|
||||
parts.length > 1 ? parts[parts.length - 1]! : (parts[0] ?? streamingText);
|
||||
|
||||
return (
|
||||
<View>
|
||||
{completedParts.map((part, i) => (
|
||||
<View
|
||||
key={`streaming_complete_${i}`}
|
||||
style={[styles.messageRow, styles.streamingRow]}
|
||||
>
|
||||
<View style={[styles.avatarWrapper, styles.avatarWrapperAgent]}>
|
||||
<Image
|
||||
source={{ uri: AGENT_AVATAR }}
|
||||
style={styles.avatarImage}
|
||||
alt={agentName}
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.bubbleColumn]}>
|
||||
<Text style={[styles.nickname, styles.nicknameAgent]}>
|
||||
{agentName}
|
||||
</Text>
|
||||
<View style={[styles.bubble, styles.bubbleAgent]}>
|
||||
<Text
|
||||
selectable
|
||||
style={[styles.bubbleText, styles.bubbleTextAgent]}
|
||||
>
|
||||
{part}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
<View style={[styles.messageRow, styles.streamingRow]}>
|
||||
<View style={[styles.avatarWrapper, styles.avatarWrapperAgent]}>
|
||||
<Image
|
||||
source={{ uri: AGENT_AVATAR }}
|
||||
style={styles.avatarImage}
|
||||
alt={agentName}
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.bubbleColumn]}>
|
||||
<Text style={[styles.nickname, styles.nicknameAgent]}>
|
||||
{agentName}
|
||||
</Text>
|
||||
<View style={[styles.bubble, styles.bubbleAgent]}>
|
||||
<Text
|
||||
selectable
|
||||
style={[styles.bubbleText, styles.bubbleTextAgent]}
|
||||
>
|
||||
{streamingPart}
|
||||
{!isComplete && '▌'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRecordingDuration(seconds: number): string {
|
||||
const s = Math.max(0, Math.floor(seconds));
|
||||
const mins = Math.floor(s / 60);
|
||||
const secs = s % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function VoiceMessageBubble({
|
||||
durationSeconds,
|
||||
audioUri,
|
||||
isUser,
|
||||
}: {
|
||||
durationSeconds: number;
|
||||
audioUri?: string;
|
||||
isUser: boolean;
|
||||
}) {
|
||||
const player = useAudioPlayer(null);
|
||||
const status = useAudioPlayerStatus(player);
|
||||
|
||||
useEffect(() => {
|
||||
const { playing, currentTime, duration } = status;
|
||||
const finished = !playing && duration > 0 && currentTime >= duration - 0.05;
|
||||
if (finished) {
|
||||
void audioFocus.release();
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const handlePlayPause = useCallback(async () => {
|
||||
if (!audioUri) return;
|
||||
if (status.playing) {
|
||||
player.pause();
|
||||
} else {
|
||||
const acquired = await audioFocus.acquireForPlayback();
|
||||
if (!acquired) return;
|
||||
player.replace(audioUri);
|
||||
player.play();
|
||||
}
|
||||
}, [audioUri, player, status.playing]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.voiceMessageBubble,
|
||||
isUser ? styles.voiceMessageBubbleUser : styles.voiceMessageBubbleAgent,
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
onPress={handlePlayPause}
|
||||
style={({ pressed }) => [
|
||||
styles.voicePlayButton,
|
||||
pressed && { opacity: 0.7 },
|
||||
]}
|
||||
disabled={!audioUri}
|
||||
accessibilityLabel={status.playing ? 'Pause' : 'Play'}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Icon
|
||||
as={status.playing ? Pause : Play}
|
||||
size={24}
|
||||
color={isUser ? CHAT_COLORS.onSurface : CHAT_COLORS.onPrimary}
|
||||
/>
|
||||
</Pressable>
|
||||
<Text
|
||||
style={[
|
||||
styles.voiceDurationText,
|
||||
isUser ? styles.voiceDurationTextUser : styles.voiceDurationTextAgent,
|
||||
]}
|
||||
>
|
||||
{formatRecordingDuration(durationSeconds)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function VoiceRecordButton({
|
||||
isRecording,
|
||||
recordingDuration,
|
||||
onPress,
|
||||
tapToStartLabel,
|
||||
tapToEndLabel,
|
||||
enabled,
|
||||
}: {
|
||||
isRecording: boolean;
|
||||
recordingDuration: number;
|
||||
onPress: () => void;
|
||||
tapToStartLabel: string;
|
||||
tapToEndLabel: string;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const pulseAnim = useRef(new Animated.Value(0.85)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRecording) return;
|
||||
const anim = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.15,
|
||||
duration: 450,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 0.85,
|
||||
duration: 450,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
anim.start();
|
||||
return () => anim.stop();
|
||||
}, [isRecording, pulseAnim]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.voiceRecordCenter,
|
||||
isRecording && styles.voiceRecordCenterRecording,
|
||||
]}
|
||||
disabled={!enabled}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.voiceRecordLabel,
|
||||
!enabled && styles.voiceRecordLabelDisabled,
|
||||
isRecording && styles.voiceRecordLabelRecording,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{isRecording ? tapToEndLabel : tapToStartLabel}
|
||||
</Text>
|
||||
{isRecording && (
|
||||
<View style={styles.voiceRecordPill}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.voiceRecordPulseDot,
|
||||
{ transform: [{ scale: pulseAnim }] },
|
||||
]}
|
||||
/>
|
||||
{/* TODO: Duration number centering still broken on Android */}
|
||||
<View style={styles.voiceRecordDurationWrap}>
|
||||
<Text style={styles.voiceRecordDuration}>
|
||||
{formatRecordingDuration(recordingDuration)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatInputBar({
|
||||
value,
|
||||
onChangeText,
|
||||
onSend,
|
||||
inputMode,
|
||||
onInputModeToggle,
|
||||
onAddPress,
|
||||
onStartRecording,
|
||||
onStopRecording,
|
||||
onCancelRecording,
|
||||
isRecording,
|
||||
recordingDuration,
|
||||
placeholder,
|
||||
placeholderVoice,
|
||||
addMoreLabel,
|
||||
sendLabel,
|
||||
switchToVoiceLabel,
|
||||
switchToTextLabel,
|
||||
tapToStartLabel,
|
||||
tapToEndLabel,
|
||||
cancelRecordingLabel,
|
||||
disabled,
|
||||
}: {
|
||||
value: string;
|
||||
onChangeText: (v: string) => void;
|
||||
onSend: () => void;
|
||||
inputMode: InputMode;
|
||||
onInputModeToggle: () => void;
|
||||
onAddPress: () => void;
|
||||
onStartRecording: () => void;
|
||||
onStopRecording: () => void;
|
||||
onCancelRecording: () => void;
|
||||
isRecording: boolean;
|
||||
recordingDuration: number;
|
||||
placeholder: string;
|
||||
placeholderVoice: string;
|
||||
addMoreLabel: string;
|
||||
sendLabel: string;
|
||||
switchToVoiceLabel: string;
|
||||
switchToTextLabel: string;
|
||||
tapToStartLabel: string;
|
||||
tapToEndLabel: string;
|
||||
cancelRecordingLabel: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const colors = useThemeColors();
|
||||
const hasText = value.trim().length > 0;
|
||||
const showSend = inputMode === 'text' && hasText;
|
||||
|
||||
// 右侧按钮:文字模式有内容->发送;语音模式录音中->取消;否则->更多
|
||||
const trailingAction = showSend
|
||||
? 'send'
|
||||
: inputMode === 'voice' && isRecording
|
||||
? 'cancel'
|
||||
: 'add';
|
||||
|
||||
return (
|
||||
<View style={styles.inputBar}>
|
||||
{/* 左侧:麦克风/键盘切换 */}
|
||||
<Pressable
|
||||
onPress={onInputModeToggle}
|
||||
style={({ pressed }) => [
|
||||
styles.iconButton,
|
||||
pressed && styles.iconButtonPressed,
|
||||
]}
|
||||
disabled={disabled && !isRecording}
|
||||
accessibilityLabel={
|
||||
inputMode === 'text' ? switchToVoiceLabel : switchToTextLabel
|
||||
}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Icon
|
||||
as={inputMode === 'text' ? Mic : Type}
|
||||
size={22}
|
||||
color={
|
||||
disabled && !isRecording
|
||||
? 'rgba(141, 140, 144, 0.56)'
|
||||
: CHAT_COLORS.onSurfaceVariant
|
||||
}
|
||||
/>
|
||||
</Pressable>
|
||||
{/* 中间:文字输入 或 语音录制按钮 */}
|
||||
{inputMode === 'text' ? (
|
||||
<View style={styles.inputCenter}>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={CHAT_COLORS.onSurfaceVariant}
|
||||
style={styles.textInput}
|
||||
multiline
|
||||
scrollEnabled
|
||||
maxLength={2000}
|
||||
editable={!disabled}
|
||||
onSubmitEditing={onSend}
|
||||
returnKeyType="send"
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.inputCenterFlex}>
|
||||
<VoiceRecordButton
|
||||
isRecording={isRecording}
|
||||
recordingDuration={recordingDuration}
|
||||
onPress={isRecording ? onStopRecording : onStartRecording}
|
||||
tapToStartLabel={tapToStartLabel}
|
||||
tapToEndLabel={tapToEndLabel}
|
||||
enabled={!disabled}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{/* 右侧:发送 / 取消录音 / 更多 */}
|
||||
{trailingAction === 'send' ? (
|
||||
<Pressable
|
||||
onPress={onSend}
|
||||
style={({ pressed }) => [
|
||||
styles.sendButton,
|
||||
{ backgroundColor: colors.primary },
|
||||
disabled && styles.sendButtonDisabled,
|
||||
pressed && !disabled && styles.sendButtonPressed,
|
||||
]}
|
||||
disabled={disabled}
|
||||
accessibilityLabel={sendLabel}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text
|
||||
style={[styles.sendButtonText, { color: colors.primaryForeground }]}
|
||||
>
|
||||
{sendLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : trailingAction === 'cancel' ? (
|
||||
<Pressable
|
||||
onPress={onCancelRecording}
|
||||
style={({ pressed }) => [
|
||||
styles.iconButton,
|
||||
pressed && styles.iconButtonPressed,
|
||||
]}
|
||||
accessibilityLabel={cancelRecordingLabel}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Icon as={X} size={22} color={CHAT_COLORS.errorRed} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={onAddPress}
|
||||
style={({ pressed }) => [
|
||||
styles.iconButton,
|
||||
pressed && styles.iconButtonPressed,
|
||||
]}
|
||||
disabled={disabled && !isRecording}
|
||||
accessibilityLabel={addMoreLabel}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Icon
|
||||
as={PlusCircle}
|
||||
size={22}
|
||||
color={
|
||||
disabled && !isRecording
|
||||
? 'rgba(141, 140, 144, 0.56)'
|
||||
: CHAT_COLORS.onSurfaceVariant
|
||||
}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConversationScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation('conversation');
|
||||
const { t: tApp } = useTranslation('app');
|
||||
const { data: messages } = useMessages(id);
|
||||
const { connectionState, streamingMessage, sendText, sendVoiceMessage } =
|
||||
useRealtimeSession({
|
||||
conversationId: id,
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const handleRecordingComplete = useCallback(
|
||||
(uri: string, durationMs: number) => {
|
||||
void sendVoiceMessage(uri, durationMs);
|
||||
},
|
||||
[sendVoiceMessage],
|
||||
);
|
||||
|
||||
const {
|
||||
status: recorderStatus,
|
||||
durationMs: recordingDurationMs,
|
||||
start: startRecording,
|
||||
stop: stopRecording,
|
||||
cancel: cancelRecording,
|
||||
} = useRecorder(handleRecordingComplete);
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const [inputMode, setInputMode] = useState<InputMode>('text');
|
||||
const listRef = useRef<FlatList>(null);
|
||||
|
||||
const isRecording = recorderStatus === 'recording';
|
||||
const recordingDuration = Math.floor(recordingDurationMs / 1000);
|
||||
|
||||
const handleStartRecording = useCallback(async () => {
|
||||
const ok = await startRecording();
|
||||
if (!ok) {
|
||||
Alert.alert(t('recordingPermissionDenied'));
|
||||
}
|
||||
}, [startRecording, t]);
|
||||
|
||||
const handleSend = () => {
|
||||
const text = input.trim();
|
||||
if (!text) return;
|
||||
sendText(text);
|
||||
setInput('');
|
||||
};
|
||||
|
||||
const connectionLabel =
|
||||
connectionState === 'connected'
|
||||
? t('connectionConnected')
|
||||
: connectionState === 'connecting'
|
||||
? t('connectionConnecting')
|
||||
: t('connectionDisconnected');
|
||||
|
||||
const keyboardOffset = Platform.OS === 'ios' ? insets.top + 56 : 0;
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={keyboardOffset}
|
||||
>
|
||||
<View style={styles.column}>
|
||||
<ScreenHeader
|
||||
variant="chat"
|
||||
title={
|
||||
<View style={styles.headerTitleBlock}>
|
||||
<Text style={styles.headerTitle}>{tApp('name')}</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
connectionState === 'connected' &&
|
||||
styles.statusBadgeConnected,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusBadgeText,
|
||||
connectionState === 'connected' &&
|
||||
styles.statusBadgeTextConnected,
|
||||
]}
|
||||
>
|
||||
{connectionLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
backAccessibilityLabel={t('chatTitle')}
|
||||
/>
|
||||
|
||||
{/* Message list - flex 1, takes remaining space */}
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
data={flattenMessagesForList(messages ?? [])}
|
||||
keyExtractor={(item) => item.listKey}
|
||||
renderItem={({ item }) => (
|
||||
<MessageBubble
|
||||
item={item}
|
||||
agentName={t('agentName')}
|
||||
meLabel={t('me')}
|
||||
/>
|
||||
)}
|
||||
onContentSizeChange={() =>
|
||||
listRef.current?.scrollToEnd({ animated: true })
|
||||
}
|
||||
ListFooterComponent={
|
||||
streamingMessage ? (
|
||||
<StreamingBubbles
|
||||
streamingText={streamingMessage.text}
|
||||
isComplete={streamingMessage.isComplete}
|
||||
agentName={t('agentName')}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{/* WeChat-style input bar - 贴在底部,非浮空 */}
|
||||
<View
|
||||
style={[
|
||||
styles.inputBarWrapper,
|
||||
{
|
||||
paddingBottom: insets.bottom,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ChatInputBar
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
onSend={handleSend}
|
||||
inputMode={inputMode}
|
||||
onInputModeToggle={() =>
|
||||
setInputMode((m) => (m === 'text' ? 'voice' : 'text'))
|
||||
}
|
||||
onAddPress={() => {}}
|
||||
onStartRecording={handleStartRecording}
|
||||
onStopRecording={() => void stopRecording()}
|
||||
onCancelRecording={() => void cancelRecording()}
|
||||
isRecording={isRecording}
|
||||
recordingDuration={recordingDuration}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
placeholderVoice={t('inputPlaceholderVoice')}
|
||||
addMoreLabel={t('addMore')}
|
||||
sendLabel={t('send')}
|
||||
switchToVoiceLabel={t('switchToVoice')}
|
||||
switchToTextLabel={t('switchToText')}
|
||||
tapToStartLabel={t('tapToStartRecording')}
|
||||
tapToEndLabel={t('tapToEndRecording')}
|
||||
cancelRecordingLabel={t('cancelRecording')}
|
||||
disabled={connectionState !== 'connected'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: CHAT_COLORS.background,
|
||||
},
|
||||
column: {
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
},
|
||||
headerTitleBlock: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: CHAT_COLORS.primary,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(141, 140, 144, 0.2)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(141, 140, 144, 0.3)',
|
||||
},
|
||||
statusBadgeConnected: {
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.15)',
|
||||
borderColor: 'rgba(34, 197, 94, 0.3)',
|
||||
},
|
||||
statusBadgeText: {
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
color: CHAT_COLORS.onSurfaceVariant,
|
||||
},
|
||||
statusBadgeTextConnected: {
|
||||
color: '#16a34a',
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
messageRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
marginBottom: 16,
|
||||
},
|
||||
messageRowReverse: {
|
||||
flexDirection: 'row-reverse',
|
||||
},
|
||||
streamingRow: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
avatarWrapper: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
flexShrink: 0,
|
||||
borderWidth: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
avatarWrapperAgent: {
|
||||
backgroundColor: CHAT_COLORS.secondaryContainer,
|
||||
borderColor: CHAT_COLORS.primary,
|
||||
},
|
||||
avatarWrapperUser: {
|
||||
backgroundColor: CHAT_COLORS.primaryFixed,
|
||||
borderColor: CHAT_COLORS.onSurfaceVariant,
|
||||
},
|
||||
avatarImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
bubbleColumn: {
|
||||
maxWidth: '80%',
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
bubbleColumnEnd: {
|
||||
alignItems: 'flex-end',
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
nickname: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
marginBottom: 2,
|
||||
},
|
||||
nicknameAgent: {
|
||||
color: CHAT_COLORS.primary,
|
||||
},
|
||||
nicknameUser: {
|
||||
color: CHAT_COLORS.onSurfaceVariant,
|
||||
},
|
||||
bubble: {
|
||||
padding: 12,
|
||||
borderWidth: 2,
|
||||
shadowOffset: { width: 4, height: 4 },
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: 0,
|
||||
},
|
||||
bubbleAgent: {
|
||||
backgroundColor: CHAT_COLORS.primary,
|
||||
borderColor: CHAT_COLORS.primaryBorder,
|
||||
shadowColor: CHAT_COLORS.primaryBorder,
|
||||
},
|
||||
bubbleUser: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderColor: CHAT_COLORS.onSurfaceVariant,
|
||||
shadowColor: CHAT_COLORS.outline,
|
||||
},
|
||||
bubbleText: {
|
||||
fontSize: 22,
|
||||
lineHeight: 34,
|
||||
fontWeight: '400',
|
||||
},
|
||||
bubbleTextAgent: {
|
||||
color: CHAT_COLORS.onPrimary,
|
||||
},
|
||||
bubbleTextUser: {
|
||||
color: CHAT_COLORS.onSurface,
|
||||
},
|
||||
voiceMessageBubble: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
voiceMessageBubbleUser: {},
|
||||
voiceMessageBubbleAgent: {},
|
||||
voicePlayButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
},
|
||||
voiceDurationText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '500',
|
||||
},
|
||||
voiceDurationTextUser: {
|
||||
color: CHAT_COLORS.onSurface,
|
||||
},
|
||||
voiceDurationTextAgent: {
|
||||
color: CHAT_COLORS.onPrimary,
|
||||
},
|
||||
inputBarWrapper: {
|
||||
backgroundColor: CHAT_COLORS.surface,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
inputBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
iconButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
iconButtonPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
inputCenter: {
|
||||
flex: 1,
|
||||
minHeight: 48,
|
||||
borderRadius: 16,
|
||||
backgroundColor: 'rgba(141, 140, 144, 0.14)',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 11,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
inputCenterFlex: {
|
||||
flex: 1,
|
||||
minHeight: 48,
|
||||
},
|
||||
voiceRecordCenter: {
|
||||
flex: 1,
|
||||
minHeight: 48,
|
||||
borderRadius: 16,
|
||||
backgroundColor: 'rgba(141, 140, 144, 0.14)',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 11,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
voiceRecordCenterRecording: {
|
||||
backgroundColor: 'rgba(141, 140, 144, 0.20)',
|
||||
},
|
||||
voiceRecordLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: 'rgba(27, 27, 31, 0.72)',
|
||||
flex: 1,
|
||||
},
|
||||
voiceRecordLabelRecording: {
|
||||
color: 'rgba(27, 27, 31, 0.92)',
|
||||
},
|
||||
voiceRecordLabelDisabled: {
|
||||
color: 'rgba(141, 140, 144, 0.68)',
|
||||
},
|
||||
voiceRecordPill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
},
|
||||
voiceRecordPulseDot: {
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 3.5,
|
||||
backgroundColor: CHAT_COLORS.errorRed,
|
||||
},
|
||||
voiceRecordDurationWrap: {
|
||||
width: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
voiceRecordDuration: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(27, 27, 31, 0.86)',
|
||||
},
|
||||
textInput: {
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
color: CHAT_COLORS.onSurface,
|
||||
padding: 0,
|
||||
maxHeight: 88, // 4 lines * 22 lineHeight
|
||||
},
|
||||
sendButton: {
|
||||
height: 44,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 11,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
sendButtonPressed: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
sendButtonDisabled: {
|
||||
opacity: 0.45,
|
||||
},
|
||||
sendButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user