Squash merge feat/expo-app: app-expo, .cursor, workflows, package.json, .husky; remove app-android, app-ios, react-app

This commit is contained in:
Kevin
2026-03-19 01:12:17 +08:00
parent 9e4f301ab9
commit b4f4369b7d
544 changed files with 23707 additions and 67151 deletions

View 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',
},
});