import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, AppState, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, TextInput, View, type ViewStyle, } from 'react-native' import { useFocusEffect } from '@react-navigation/native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useI18n } from '@d3ro/i18n' import { ThemeButton, ThemeText } from '../theme/themed-components' import { useAuth } from '../lib/auth-context' import { useMobilePreferences } from '../lib/preferences-context' import { audioRecorder, type RecordingProgress } from '../lib/audio-recorder' import { ChatServiceError, type ChatErrorCode, type ChatRequestMessage, } from '../features/chat/chat-service' import { AudioPipelineError } from '../features/import/audio-import-types' import { streamTalkResponse } from '../features/talk/llm-stream-service' import { transcribeTalkRecording } from '../features/talk/talk-transcription-service' import { shutdownTalkSpeech, speakTalkText, stopTalkSpeech, TalkTtsError, } from '../features/talk/talk-tts' import { isDeterministicTalkAudioAvailable, synthesizeDeterministicTalkAudio, } from '../features/talk/talk-debug-audio' import ContentReportSheet, { type ContentReportTarget, } from '../components/ContentReportSheet' interface TalkMessage extends ChatRequestMessage { id: string createdAt: number generationId?: string } type TalkPhase = 'idle' | 'recording' | 'transcribing' | 'thinking' | 'speaking' type TalkErrorCode = ChatErrorCode | 'MIC_PERMISSION_DENIED' | 'RECORDING_FAILED' | 'RECORDING_TOO_SHORT' | 'STT_QUOTA_EXCEEDED' | 'STT_FAILED' | 'STT_NO_SPEECH' | 'TTS_UNAVAILABLE' | 'TTS_FAILED' const MIN_RECORDING_MS = 450 function errorTranslationKey(code: TalkErrorCode) { switch (code) { case 'AUTH_REQUIRED': return 'mobile.talk.error.auth' case 'INVALID_REQUEST': return 'mobile.talk.error.invalid' case 'QUOTA_EXCEEDED': return 'mobile.talk.error.quota' case 'MODEL_NOT_ALLOWED': return 'mobile.talk.error.model' case 'PROVIDER_UNAVAILABLE': return 'mobile.talk.error.provider' case 'TIMEOUT': return 'mobile.talk.error.timeout' case 'NETWORK': return 'mobile.talk.error.network' case 'INVALID_RESPONSE': return 'mobile.talk.error.response' case 'CANCELLED': return 'mobile.talk.error.cancelled' case 'MIC_PERMISSION_DENIED': return 'mobile.talk.error.micPermission' case 'RECORDING_FAILED': return 'mobile.talk.error.recording' case 'RECORDING_TOO_SHORT': return 'mobile.talk.error.tooShort' case 'STT_QUOTA_EXCEEDED': return 'mobile.talk.error.sttQuota' case 'STT_FAILED': return 'mobile.talk.error.stt' case 'STT_NO_SPEECH': return 'mobile.talk.error.noSpeech' case 'TTS_UNAVAILABLE': return 'mobile.talk.error.ttsUnavailable' case 'TTS_FAILED': return 'mobile.talk.error.tts' default: return 'mobile.talk.error.server' } } function transcriptionErrorCode(error: unknown): TalkErrorCode { if (!(error instanceof AudioPipelineError)) return 'STT_FAILED' switch (error.code) { case 'auth': return 'AUTH_REQUIRED' case 'cancelled': return 'CANCELLED' case 'quota': return 'STT_QUOTA_EXCEEDED' case 'no-speech': return 'STT_NO_SPEECH' default: return 'STT_FAILED' } } function formatDuration(durationMs: number): string { const seconds = Math.max(0, Math.floor(durationMs / 1000)) return `${Math.floor(seconds / 60).toString().padStart(2, '0')}:${(seconds % 60) .toString() .padStart(2, '0')}` } function phaseTranslationKey(phase: TalkPhase) { switch (phase) { case 'recording': return 'mobile.talk.phase.recording' as const case 'transcribing': return 'mobile.talk.phase.transcribing' as const case 'thinking': return 'mobile.talk.phase.thinking' as const case 'speaking': return 'mobile.talk.phase.speaking' as const default: return 'mobile.talk.phase.idle' as const } } export default function TalkScreen(): React.ReactElement { const insets = useSafeAreaInsets() const { t } = useI18n() const { session } = useAuth() const { palette, preferences } = useMobilePreferences() const scrollRef = useRef(null) const mountedRef = useRef(true) const messagesRef = useRef([]) const phaseRef = useRef('idle') const operationRef = useRef(0) const speechOperationRef = useRef(0) const requestControllerRef = useRef(null) const transcriptionControllerRef = useRef(null) const activeAssistantIdRef = useRef(null) const recordingActiveRef = useRef(false) const recordingStartingRef = useRef(false) const recordingFinishingRef = useRef(false) const recorderCleanupRef = useRef>(Promise.resolve()) const pressHeldRef = useRef(false) const textSendPendingRef = useRef(false) const retryMessagesRef = useRef(null) const retryAutoSpeakRef = useRef(false) const sessionRef = useRef(session) sessionRef.current = session const initialMessage = useMemo(() => ({ id: 'welcome', role: 'assistant', content: t('mobile.talk.greeting'), createdAt: Date.now(), }), [t]) const [messages, setMessages] = useState(() => [initialMessage]) const [input, setInput] = useState('') const [phase, setPhase] = useState('idle') const [recordingProgress, setRecordingProgress] = useState({ durationMs: 0, meteringDb: null, }) const [errorCode, setErrorCode] = useState(null) const [canRetry, setCanRetry] = useState(false) const [speakingMessageId, setSpeakingMessageId] = useState(null) const [reportTarget, setReportTarget] = useState(null) const styles = useMemo(() => createStyles(palette), [palette]) const deterministicAudioAvailable = isDeterministicTalkAudioAvailable() const replaceMessages = useCallback((next: TalkMessage[]): void => { messagesRef.current = next if (mountedRef.current) setMessages(next) }, []) const updateMessages = useCallback((update: (current: TalkMessage[]) => TalkMessage[]): void => { replaceMessages(update(messagesRef.current)) }, [replaceMessages]) const updatePhase = useCallback((next: TalkPhase): void => { phaseRef.current = next if (mountedRef.current) setPhase(next) }, []) const isCurrentOperation = useCallback((operationId: number): boolean => ( mountedRef.current && operationRef.current === operationId ), []) const stopSpeech = useCallback(async (): Promise => { speechOperationRef.current += 1 try { await stopTalkSpeech() } catch { // An unavailable engine has no audio to stop. } if (!mountedRef.current) return setSpeakingMessageId(null) if (phaseRef.current === 'speaking') updatePhase('idle') }, [updatePhase]) const cancelActiveOperation = useCallback((showCancelled: boolean): void => { operationRef.current += 1 pressHeldRef.current = false requestControllerRef.current?.abort() requestControllerRef.current = null transcriptionControllerRef.current?.abort() transcriptionControllerRef.current = null const assistantId = activeAssistantIdRef.current activeAssistantIdRef.current = null if (assistantId !== null) { updateMessages((current) => current.filter((message) => message.id !== assistantId)) } if (recordingStartingRef.current || recordingActiveRef.current || recordingFinishingRef.current) { const cleanup = audioRecorder.cancel().catch(() => undefined) recorderCleanupRef.current = cleanup } recordingStartingRef.current = false recordingActiveRef.current = false recordingFinishingRef.current = false void stopSpeech() if (!mountedRef.current) return setRecordingProgress({ durationMs: 0, meteringDb: null }) setCanRetry(false) retryMessagesRef.current = null updatePhase('idle') if (showCancelled) setErrorCode('CANCELLED') }, [stopSpeech, updateMessages, updatePhase]) useEffect(() => { messagesRef.current = messages }, [messages]) useEffect(() => { mountedRef.current = true const subscription = AppState.addEventListener('change', (state) => { if (state !== 'active' && phaseRef.current !== 'idle') cancelActiveOperation(false) }) return () => { mountedRef.current = false subscription.remove() cancelActiveOperation(false) void shutdownTalkSpeech().catch(() => undefined) } }, [cancelActiveOperation]) useFocusEffect(useCallback(() => ( () => cancelActiveOperation(false) ), [cancelActiveOperation])) const authIdentity = session === null ? 'signed-out' : `${session.user.id}:${session.access_token}` const previousAuthIdentityRef = useRef(authIdentity) useEffect(() => { if (previousAuthIdentityRef.current !== authIdentity) { previousAuthIdentityRef.current = authIdentity cancelActiveOperation(false) } }, [authIdentity, cancelActiveOperation]) useEffect(() => { const timer = setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 0) return () => clearTimeout(timer) }, [messages, phase]) const playSpeech = useCallback(async ( messageId: string, content: string, languageTag: string, ): Promise => { const speechId = ++speechOperationRef.current try { await stopTalkSpeech().catch(() => false) if (!mountedRef.current || speechOperationRef.current !== speechId) return setSpeakingMessageId(messageId) updatePhase('speaking') const result = await speakTalkText({ text: content, languageTag, utteranceId: `talk-${messageId}-${speechId}`, }) if (!mountedRef.current || speechOperationRef.current !== speechId) return if (result.status === 'completed' || result.status === 'stopped') { setSpeakingMessageId(null) updatePhase('idle') } } catch (error) { if (!mountedRef.current || speechOperationRef.current !== speechId) return setSpeakingMessageId(null) updatePhase('idle') setErrorCode(error instanceof TalkTtsError && error.code === 'UNAVAILABLE' ? 'TTS_UNAVAILABLE' : 'TTS_FAILED') } }, [updatePhase]) const runConversation = useCallback(async ( conversation: TalkMessage[], autoSpeak: boolean, operationId: number, ): Promise => { const currentSession = sessionRef.current if (currentSession === null) { if (isCurrentOperation(operationId)) { setErrorCode('AUTH_REQUIRED') setCanRetry(false) updatePhase('idle') } return } const controller = new AbortController() requestControllerRef.current = controller const assistantId = `assistant-${Date.now()}-${operationId}` activeAssistantIdRef.current = assistantId const placeholder: TalkMessage = { id: assistantId, role: 'assistant', content: '', createdAt: Date.now(), } replaceMessages([...conversation, placeholder]) updatePhase('thinking') setErrorCode(null) setCanRetry(false) try { const { text: content, generationId } = await streamTalkResponse( conversation.map(({ role, content: messageContent }) => ({ role, content: messageContent, })), { accessToken: currentSession.access_token, model: preferences.preferredLlmModel, signal: controller.signal, onTextDelta: (_delta, accumulated) => { if (!isCurrentOperation(operationId)) return updateMessages((current) => current.map((message) => ( message.id === assistantId ? { ...message, content: accumulated } : message ))) }, }, ) if (!isCurrentOperation(operationId)) return const latestSession = sessionRef.current if ( latestSession === null || latestSession.user.id !== currentSession.user.id || latestSession.access_token !== currentSession.access_token ) { throw new ChatServiceError('AUTH_REQUIRED', false, 401) } updateMessages((current) => current.map((message) => ( message.id === assistantId ? { ...message, content, generationId } : message ))) activeAssistantIdRef.current = null requestControllerRef.current = null retryMessagesRef.current = null setCanRetry(false) updatePhase('idle') if (autoSpeak) { await playSpeech( assistantId, content, preferences.locale === 'en' ? 'en-US' : 'ko-KR', ) } } catch (error) { if (!isCurrentOperation(operationId)) return const serviceError = error instanceof ChatServiceError ? error : new ChatServiceError('SERVER_ERROR', true) updateMessages((current) => current.filter((message) => message.id !== assistantId)) activeAssistantIdRef.current = null requestControllerRef.current = null retryMessagesRef.current = conversation retryAutoSpeakRef.current = autoSpeak setErrorCode(serviceError.code) setCanRetry(serviceError.retryable) updatePhase('idle') } }, [ isCurrentOperation, playSpeech, preferences.locale, preferences.preferredLlmModel, replaceMessages, updateMessages, updatePhase, ]) const submitMessage = useCallback((content: string, autoSpeak: boolean): void => { const normalized = content.trim() if (normalized.length === 0 || normalized.length > 8_000) return const operationId = ++operationRef.current const userMessage: TalkMessage = { id: `user-${Date.now()}-${operationId}`, role: 'user', content: normalized, createdAt: Date.now(), } const conversation = [...messagesRef.current, userMessage].slice(-40) replaceMessages(conversation) setErrorCode(null) setCanRetry(false) retryMessagesRef.current = null void runConversation(conversation, autoSpeak, operationId) }, [replaceMessages, runConversation]) const finishRecording = useCallback(async (operationId: number): Promise => { if (recordingFinishingRef.current || !recordingActiveRef.current) return recordingFinishingRef.current = true recordingActiveRef.current = false try { const recording = await audioRecorder.stop() if (!isCurrentOperation(operationId)) { await audioRecorder.cleanup(recording) return } if (recording.durationMs < MIN_RECORDING_MS) { await audioRecorder.cleanup(recording) setErrorCode('RECORDING_TOO_SHORT') updatePhase('idle') return } const controller = new AbortController() transcriptionControllerRef.current = controller updatePhase('transcribing') const currentSession = sessionRef.current if (currentSession === null) { await audioRecorder.cleanup(recording) throw new AudioPipelineError('auth', 'Sign in to transcribe speech') } const result = await transcribeTalkRecording(recording, { accessToken: currentSession.access_token, languageCode: preferences.locale, signal: controller.signal, disposeRecording: () => audioRecorder.cleanup(recording), }) transcriptionControllerRef.current = null if (!isCurrentOperation(operationId)) return const latestSession = sessionRef.current if ( latestSession === null || latestSession.user.id !== currentSession.user.id || latestSession.access_token !== currentSession.access_token ) { throw new AudioPipelineError('auth', 'The account changed during transcription') } setInput('') submitMessage(result.text, true) } catch (error) { // 진단: 클라우드/기기 전사의 실제 원인(상태코드·whisper 오류)은 // UI 번역키로 덮이기 전에 로그로 남긴다. Metro/logcat에서 확인 가능. const causeChain: string[] = [] for ( let e: unknown = error; e instanceof Error && causeChain.length < 4; e = (e as Error & { cause?: unknown }).cause ) { causeChain.push(e.message) } console.warn( `[Talk] transcription failed (${error instanceof AudioPipelineError ? error.code : 'unknown'}):`, causeChain.join(' | '), ) if (!isCurrentOperation(operationId)) return transcriptionControllerRef.current = null setErrorCode(transcriptionErrorCode(error)) setCanRetry(false) updatePhase('idle') } finally { recordingFinishingRef.current = false if (mountedRef.current) setRecordingProgress({ durationMs: 0, meteringDb: null }) } }, [isCurrentOperation, preferences.locale, submitMessage, updatePhase]) const beginRecording = useCallback(async (): Promise => { if (recordingStartingRef.current || recordingActiveRef.current) return if (phaseRef.current !== 'idle' && phaseRef.current !== 'speaking') return pressHeldRef.current = true const operationId = ++operationRef.current recordingStartingRef.current = true setErrorCode(null) setCanRetry(false) retryMessagesRef.current = null await stopSpeech() try { await recorderCleanupRef.current if (!isCurrentOperation(operationId)) return const currentSession = sessionRef.current if (currentSession === null) { setErrorCode('AUTH_REQUIRED') return } const granted = await audioRecorder.requestPermission() if (!isCurrentOperation(operationId)) return if (!granted) { setErrorCode('MIC_PERMISSION_DENIED') return } await audioRecorder.start((progress) => { if (isCurrentOperation(operationId)) setRecordingProgress(progress) }) if (!isCurrentOperation(operationId)) { await audioRecorder.cancel().catch(() => undefined) return } recordingActiveRef.current = true updatePhase('recording') if (!pressHeldRef.current) await finishRecording(operationId) } catch { if (isCurrentOperation(operationId)) { setErrorCode('RECORDING_FAILED') updatePhase('idle') } await audioRecorder.cancel().catch(() => undefined) } finally { recordingStartingRef.current = false } }, [finishRecording, isCurrentOperation, stopSpeech, updatePhase]) const releaseRecording = useCallback((): void => { pressHeldRef.current = false if (!recordingActiveRef.current) return void finishRecording(operationRef.current) }, [finishRecording]) const handleSend = useCallback((): void => { const content = input.trim() if (textSendPendingRef.current || content.length === 0 || phaseRef.current === 'recording' || phaseRef.current === 'transcribing' || phaseRef.current === 'thinking') return textSendPendingRef.current = true setInput('') void stopSpeech() .then(() => { if (mountedRef.current) submitMessage(content, false) }) .finally(() => { textSendPendingRef.current = false }) }, [input, stopSpeech, submitMessage]) const handleRetry = useCallback((): void => { const retryMessages = retryMessagesRef.current if (!canRetry || retryMessages === null || phaseRef.current !== 'idle') return const operationId = ++operationRef.current void runConversation(retryMessages, retryAutoSpeakRef.current, operationId) }, [canRetry, runConversation]) const runDeterministicAudioE2E = useCallback(async (): Promise => { if (!deterministicAudioAvailable || phaseRef.current !== 'idle') return const currentSession = sessionRef.current if (currentSession === null) { setErrorCode('AUTH_REQUIRED') return } const operationId = ++operationRef.current setErrorCode(null) setCanRetry(false) await stopSpeech() let fixture: Awaited> | null = null try { fixture = await synthesizeDeterministicTalkAudio({ text: preferences.locale === 'en' ? 'Mobile voice transcription verified' : '모바일 음성 전사 검증', languageTag: preferences.locale === 'en' ? 'en-US' : 'ko-KR', }) if (!isCurrentOperation(operationId)) { await fixture.dispose() return } const controller = new AbortController() transcriptionControllerRef.current = controller updatePhase('transcribing') const result = await transcribeTalkRecording(fixture.recording, { accessToken: currentSession.access_token, languageCode: preferences.locale, signal: controller.signal, disposeRecording: fixture.dispose, }) fixture = null transcriptionControllerRef.current = null if (!isCurrentOperation(operationId)) return const latestSession = sessionRef.current if ( latestSession === null || latestSession.user.id !== currentSession.user.id || latestSession.access_token !== currentSession.access_token ) { throw new AudioPipelineError('auth', 'The account changed during transcription') } submitMessage(result.text, true) } catch (injectionError) { if (fixture !== null) await fixture.dispose().catch(() => undefined) transcriptionControllerRef.current = null if (!isCurrentOperation(operationId)) return setErrorCode(transcriptionErrorCode(injectionError)) setCanRetry(false) updatePhase('idle') } }, [ deterministicAudioAvailable, isCurrentOperation, preferences.locale, stopSpeech, submitMessage, updatePhase, ]) const handleClear = useCallback((): void => { cancelActiveOperation(false) replaceMessages([]) setInput('') setErrorCode(null) setCanRetry(false) }, [cancelActiveOperation, replaceMessages]) const handleSpeechPress = useCallback((message: TalkMessage): void => { if (phaseRef.current !== 'idle' && phaseRef.current !== 'speaking') return if (speakingMessageId === message.id) { void stopSpeech() return } void playSpeech( message.id, message.content, preferences.locale === 'en' ? 'en-US' : 'ko-KR', ) }, [playSpeech, preferences.locale, speakingMessageId, stopSpeech]) const inputBlocked = phase === 'recording' || phase === 'transcribing' || phase === 'thinking' const meterLevel = recordingProgress.meteringDb === null ? 0.15 : Math.max(0.08, Math.min(1, (recordingProgress.meteringDb + 60) / 60)) return ( {t('mobile.talk.title')} {t(phaseTranslationKey(phase))} {t('mobile.talk.clear')} {messages.length === 0 && ( {t('mobile.talk.empty')} )} {messages.map((message) => ( {message.content.length > 0 && ( {message.content} )} {message.content.length === 0 && message.id === activeAssistantIdRef.current && ( )} {message.role === 'user' ? t('mobile.talk.you') : t('mobile.talk.claude')} {message.role === 'assistant' && message.content.length > 0 && message.id !== 'welcome' && ( {message.generationId !== undefined && ( setReportTarget({ sourceType: 'talk_response', generationId: message.generationId!, snapshot: message.content.slice(0, 4_000), })} hitSlop={8} testID={`talk-report-${message.id}`} > {t('mobile.report.action')} )} handleSpeechPress(message)} hitSlop={8} testID={`talk-speech-${message.id}`} > {speakingMessageId === message.id ? '■' : '▶'} )} ))} {(phase === 'transcribing' || phase === 'thinking') && ( {phase === 'transcribing' ? t('mobile.talk.transcribing') : t('mobile.talk.thinking')} cancelActiveOperation(true)} style={styles.inlineCancel} testID="talk-cancel" /> )} {errorCode !== null && ( {t(errorTranslationKey(errorCode))} {canRetry && ( )} )} {phase === 'recording' && ( {t('mobile.talk.recording')} {formatDuration(recordingProgress.durationMs)} {t('mobile.talk.releaseToSend')} )} {deterministicAudioAvailable && ( { void runDeterministicAudioE2E() }} testID="talk-debug-audio-e2e" /> )} { void beginRecording() }} onPressOut={releaseRecording} style={({ pressed }) => [ styles.micButton, (pressed || phase === 'recording') && styles.micButtonActive, (phase === 'transcribing' || phase === 'thinking') && styles.disabledButton, ]} testID="talk-ptt" > [ styles.sendButton, (inputBlocked || input.trim().length === 0) && styles.disabledButton, pressed && styles.pressedButton, ]} testID="talk-send" > {t('mobile.talk.pttHint')} setReportTarget(null)} /> ) } type Palette = ReturnType['palette'] function createStyles(palette: Palette): ReturnType { const bubbleBase: ViewStyle = { maxWidth: '88%', padding: 16, borderRadius: 16, borderWidth: 1, } return StyleSheet.create({ container: { flex: 1, backgroundColor: palette.bg.app }, header: { minHeight: 76, paddingHorizontal: 20, paddingBottom: 12, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderBottomWidth: 1, borderBottomColor: palette.border.default, backgroundColor: palette.bg.sidebar, }, clearButton: { minWidth: 48, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, chatArea: { flex: 1 }, chatContent: { padding: 20, paddingBottom: 28, gap: 14 }, emptyText: { textAlign: 'center', paddingVertical: 48 }, bubble: bubbleBase, assistantBubble: { alignSelf: 'flex-start', backgroundColor: palette.bg.card, borderColor: palette.border.default, borderTopLeftRadius: 4, }, userBubble: { alignSelf: 'flex-end', backgroundColor: palette.accent.dim, borderColor: palette.accent.main, borderTopRightRadius: 4, }, bubbleFooter: { marginTop: 8, minHeight: 20, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 16, }, bubbleLabel: { fontSize: 9, letterSpacing: 1 }, bubbleActions: { flexDirection: 'row', alignItems: 'center', gap: 18 }, busyRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 12 }, inlineCancel: { minHeight: 36, marginLeft: 'auto', paddingVertical: 4 }, errorCard: { backgroundColor: palette.bg.card, borderColor: palette.tag.red, borderWidth: 1, borderRadius: 12, padding: 14, gap: 10, }, retryButton: { alignSelf: 'flex-start', minHeight: 42 }, recordingPanel: { backgroundColor: palette.bg.card, borderTopWidth: 1, borderTopColor: palette.tag.red, paddingHorizontal: 20, paddingVertical: 12, gap: 8, }, recordingStatus: { flexDirection: 'row', alignItems: 'center', gap: 10 }, liveDot: { width: 9, height: 9, borderRadius: 5, backgroundColor: palette.tag.red, }, meterTrack: { height: 4, overflow: 'hidden', borderRadius: 2, backgroundColor: palette.bg.inset, }, meterFill: { height: 4, borderRadius: 2, backgroundColor: palette.accent.main }, inputArea: { backgroundColor: palette.bg.sidebar, borderTopWidth: 1, borderTopColor: palette.border.default, paddingHorizontal: 12, paddingTop: 12, }, inputRow: { flexDirection: 'row', alignItems: 'flex-end', backgroundColor: palette.bg.inset, borderRadius: 12, borderWidth: 1, borderColor: palette.border.default, padding: 8, gap: 8, }, textInput: { flex: 1, minHeight: 40, maxHeight: 120, color: palette.text.primary, fontSize: 14, paddingHorizontal: 8, paddingVertical: 8, }, micButton: { width: 40, height: 40, borderRadius: 20, borderWidth: 1, borderColor: palette.accent.main, justifyContent: 'center', alignItems: 'center', }, micButtonActive: { backgroundColor: palette.accent.main }, sendButton: { width: 40, height: 40, borderRadius: 20, backgroundColor: palette.accent.main, justifyContent: 'center', alignItems: 'center', }, disabledButton: { opacity: 0.4 }, pressedButton: { backgroundColor: palette.accent.pressed }, pttHint: { paddingTop: 8, paddingBottom: 2, textAlign: 'center' }, }) }