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 type {
NativeSyntheticEvent,
TextInputContentSizeChangeEventData,
} from 'react-native';
import {
Alert,
Animated,
FlatList,
InteractionManager,
Keyboard,
KeyboardAvoidingView,
Platform,
Pressable,
StyleSheet,
Text as RNText,
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 { isVoiceMessage } from '@/features/conversation/types';
import { usePlayer } from '@/features/voice/hooks/use-player';
import { useRecorder } from '@/features/voice/hooks/use-recorder';
// 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]';
/** 聊天输入框:单行内容高度与最多约 4 行(与 lineHeight 对齐) */
const CHAT_INPUT_LINE_H = 22;
const CHAT_INPUT_MAX_H = CHAT_INPUT_LINE_H * 4;
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,
currentPlaybackUri,
playbackIsPlaying,
onPlayVoiceExclusive,
onPausePlayback,
}: {
item: MessageItem;
agentName: string;
meLabel: string;
currentPlaybackUri: string | null;
playbackIsPlaying: boolean;
onPlayVoiceExclusive: (uri: string) => void;
onPausePlayback: () => void;
}) {
const isUser = item.senderType === 'user';
const isVoice = isVoiceMessage(item);
return (
{isUser ? meLabel : agentName}
{isVoice ? (
{
if (!item.audioUri) return;
if (playbackIsPlaying && currentPlaybackUri === item.audioUri) {
onPausePlayback();
} else {
onPlayVoiceExclusive(item.audioUri);
}
}}
/>
) : (
{item.content}
)}
);
}
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 (
{completedParts.map((part, i) => (
{agentName}
{part}
))}
{agentName}
{streamingPart}
{!isComplete && '▌'}
);
}
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,
isPlaying,
onPlayPress,
}: {
durationSeconds: number;
audioUri?: string;
isUser: boolean;
isPlaying: boolean;
onPlayPress: () => void;
}) {
const handlePlayPause = useCallback(() => {
onPlayPress();
}, [onPlayPress]);
return (
[
styles.voicePlayButton,
pressed && { opacity: 0.7 },
]}
disabled={!audioUri}
accessibilityLabel={isPlaying ? 'Pause' : 'Play'}
accessibilityRole="button"
>
{formatRecordingDuration(durationSeconds)}
);
}
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 (
{isRecording ? (
{tapToEndLabel}
) : (
{tapToStartLabel}
)}
{isRecording && (
{formatRecordingDuration(recordingDuration)}
)}
);
}
function ChatInputBar({
value,
onChangeText,
onSend,
inputMode,
onInputModeToggle,
onAddPress,
onStartRecording,
onStopRecording,
onCancelRecording,
isRecording,
recordingDuration,
placeholder,
placeholderVoice,
addMoreLabel,
sendLabel,
switchToVoiceLabel,
switchToTextLabel,
tapToStartLabel,
tapToEndLabel,
cancelRecordingLabel,
disabled,
textInputKey = 0,
}: {
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;
/** 发送后递增,强制重建 TextInput,避免多行高度卡在 4 行 */
textInputKey?: number;
}) {
const colors = useThemeColors();
const [textHeight, setTextHeight] = useState(CHAT_INPUT_LINE_H);
/** 空串时立即单行高度,避免仅依赖 state 时发送后仍沿用 4 行测量值 */
const inputDisplayHeight = value === '' ? CHAT_INPUT_LINE_H : textHeight;
useEffect(() => {
if (value === '') {
setTextHeight(CHAT_INPUT_LINE_H);
}
}, [value]);
const onInputContentSizeChange = useCallback(
(e: NativeSyntheticEvent) => {
const h = e.nativeEvent.contentSize.height;
const next = Math.min(Math.max(h, CHAT_INPUT_LINE_H), CHAT_INPUT_MAX_H);
setTextHeight(next);
},
[],
);
const hasText = value.trim().length > 0;
const showSend = inputMode === 'text' && hasText;
// 右侧按钮:文字模式有内容->发送;语音模式录音中->取消;否则->更多
const trailingAction = showSend
? 'send'
: inputMode === 'voice' && isRecording
? 'cancel'
: 'add';
return (
{/* 左侧:麦克风/键盘切换 */}
[
styles.iconButton,
pressed && styles.iconButtonPressed,
]}
disabled={disabled && !isRecording}
accessibilityLabel={
inputMode === 'text' ? switchToVoiceLabel : switchToTextLabel
}
accessibilityRole="button"
>
{/* 中间:文字输入 或 语音录制按钮 */}
{inputMode === 'text' ? (
) : (
)}
{/* 右侧:发送 / 取消录音 / 更多 */}
{trailingAction === 'send' ? (
[
styles.sendButton,
{ backgroundColor: colors.primary },
disabled && styles.sendButtonDisabled,
pressed && !disabled && styles.sendButtonPressed,
]}
disabled={disabled}
accessibilityLabel={sendLabel}
accessibilityRole="button"
>
{sendLabel}
) : trailingAction === 'cancel' ? (
[
styles.iconButton,
pressed && styles.iconButtonPressed,
]}
accessibilityLabel={cancelRecordingLabel}
accessibilityRole="button"
>
) : (
[
styles.iconButton,
pressed && styles.iconButtonPressed,
]}
disabled={disabled && !isRecording}
accessibilityLabel={addMoreLabel}
accessibilityRole="button"
>
)}
);
}
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 {
enqueue,
enqueueExclusive,
stop,
status: playerStatus,
currentSource,
} = usePlayer();
const handleTtsSegment = useCallback(
(p: { audioBase64?: string; audioUrl?: string }) => {
if (p.audioBase64) {
void enqueue({
uri: `data:audio/mp3;base64,${p.audioBase64}`,
label: 'TTS',
});
} else if (p.audioUrl) {
void enqueue({ uri: p.audioUrl, label: 'TTS' });
}
},
[enqueue],
);
const handlePlayVoiceExclusive = useCallback(
(uri: string) => {
void enqueueExclusive({ uri, label: 'voice' });
},
[enqueueExclusive],
);
const handlePausePlayback = useCallback(() => {
void stop();
}, [stop]);
const { connectionState, streamingMessage, sendText, sendVoiceMessage } =
useRealtimeSession({
conversationId: id ?? '',
enabled: !!id,
onTtsSegment: handleTtsSegment,
});
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 [inputResetKey, setInputResetKey] = useState(0);
const [inputMode, setInputMode] = useState('text');
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
const listRef = useRef(null);
useEffect(() => {
const onShow = (e: { endCoordinates: { height: number } }) => {
setIsKeyboardVisible(true);
setKeyboardHeight(e.endCoordinates.height);
InteractionManager.runAfterInteractions(() => {
listRef.current?.scrollToEnd({ animated: true });
});
};
const onHide = () => {
setIsKeyboardVisible(false);
setKeyboardHeight(0);
};
// iOS:Will* 与系统动画同步;KeyboardAvoidingView 在 iOS 上易与 safe area 叠出缝(见 RN #52626)
const showEvt =
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvt =
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const subShow = Keyboard.addListener(showEvt, onShow);
const subHide = Keyboard.addListener(hideEvt, onHide);
return () => {
subShow.remove();
subHide.remove();
};
}, [insets.bottom]);
const flattenedData = flattenMessagesForList(messages ?? []);
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('');
setInputResetKey((k) => k + 1);
};
const connectionLabel =
connectionState === 'connected'
? t('connectionConnected')
: connectionState === 'connecting'
? t('connectionConnecting')
: t('connectionDisconnected');
/** iOS:用键盘高度直接顶起根布局,替代 KAV(避免与 safe area 叠出缝,见 RN #52626) */
const keyboardLift =
Platform.OS === 'ios' && inputMode === 'text' && isKeyboardVisible
? keyboardHeight
: 0;
const androidKavOn =
Platform.OS === 'android' && inputMode === 'text' && isKeyboardVisible;
const composerZeroBottomInset = isKeyboardVisible && inputMode === 'text';
const screen = (
{tApp('name')}
{connectionLabel}
}
backAccessibilityLabel={t('chatTitle')}
/>
{/* Message list - flex 1, takes remaining space */}
item.listKey}
renderItem={({ item }) => (
)}
onContentSizeChange={() =>
InteractionManager.runAfterInteractions(() => {
listRef.current?.scrollToEnd({ animated: true });
})
}
ListFooterComponent={
streamingMessage ? (
) : null
}
/>
{/* WeChat-style input bar - 贴在底部,非浮空 */}
{
setInputMode((m) => {
if (m === 'text') {
Keyboard.dismiss();
}
return 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'}
/>
);
if (Platform.OS === 'ios') {
return (
{screen}
);
}
return (
{screen}
);
}
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: {
flexGrow: 1,
paddingHorizontal: 12,
paddingTop: 16,
paddingBottom: 12,
},
messageRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10,
marginBottom: 16,
overflow: 'visible',
},
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: 'flex-start',
},
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,
},
voiceRecordLabelCenter: {
textAlign: 'center',
},
voiceRecordLabelCenterWrap: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
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,
lineHeight: 12,
color: 'rgba(27, 27, 31, 0.86)',
textAlign: 'center',
},
voiceRecordDurationAndroid: {
textAlignVertical: 'center',
} as const,
textInput: {
fontSize: 16,
lineHeight: CHAT_INPUT_LINE_H,
color: CHAT_COLORS.onSurface,
padding: 0,
maxHeight: CHAT_INPUT_MAX_H,
width: '100%',
},
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',
},
});