Files
banban/banban-mini/src/pages/chat/detail/index.tsx
T

849 lines
28 KiB
TypeScript

import { getParticipantArtwork } from '@/utils/artwork'
import { getPageStyle } from '@/utils/page-style'
import { View, Text, ScrollView, Image } from '@tarojs/components'
import { useEffect, useRef, useState } from 'react'
import Taro, { useRouter } from '@tarojs/taro'
import {
getMessages,
getCharacter,
sendParentVoiceMessage,
ChatMessage,
Character,
ConversationSource,
PeerKind,
} from '../../../services/chat'
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import { usePageShare } from '@/utils/page-share'
import './index.scss'
function normalizePeerKind(value?: string): PeerKind {
if (value === 'child' || value === 'parent' || value === 'ai') return value
return 'ai'
}
function normalizeSource(value?: string): ConversationSource {
return value === 'im' ? 'im' : 'ai'
}
function normalizeNumericId(value?: string): number | null {
const normalized = Number(value)
if (!Number.isFinite(normalized) || normalized <= 0) return null
return normalized
}
function formatParticipantLabel(message: ChatMessage): string {
const senderType = message.senderType || 'unknown'
const senderId = message.senderId || '--'
const name = message.senderName || ''
if (senderType === 'parent') {
return name === '我' ? '我' : name || '家长'
}
if (senderType === 'child') {
return name || `宝贝ID ${senderId}`
}
if (senderType === 'device') {
return name || `设备ID ${senderId}`
}
if (senderType === 'ai') {
return name || 'AI'
}
return `${senderType} ${senderId}`
}
function formatAudioDuration(durationMs?: number | null): string {
const totalSeconds = Math.max(1, Math.round((durationMs || 0) / 1000))
return `${totalSeconds}s`
}
type VoiceRuntimeInfo = {
platform: string
system: string
brand: string
model: string
version: string
SDKVersion: string
isIOS: boolean
isAndroid: boolean
isHarmony: boolean
canUseRecorderManager: boolean
canUseInnerAudioContext: boolean
supportsMp3Record: boolean
}
type RecordProfile = {
format: 'mp3' | 'aac'
mimeType: string
sampleRate: number
encodeBitRate: number
}
const DEFAULT_RECORD_PROFILE: RecordProfile = {
format: 'mp3',
mimeType: 'audio/mpeg',
sampleRate: 16000,
encodeBitRate: 96000,
}
const COMPAT_RECORD_PROFILE: RecordProfile = {
format: 'aac',
mimeType: 'audio/aac',
sampleRate: 44100,
encodeBitRate: 192000,
}
const FALLBACK_RUNTIME_INFO: VoiceRuntimeInfo = {
platform: '',
system: '',
brand: '',
model: '',
version: '',
SDKVersion: '',
isIOS: false,
isAndroid: false,
isHarmony: false,
canUseRecorderManager: false,
canUseInnerAudioContext: false,
supportsMp3Record: true,
}
const CHAT_BOTTOM_ANCHOR_ID = 'chat-bottom'
const RECORD_LONG_PRESS_DELAY_MS = 350
type LoadChatOptions = {
showLoading?: boolean
scrollToBottom?: 'jump' | 'animate' | false
}
function compareVersion(left: string, right: string): number {
const leftParts = left.split('.').map((item) => Number(item) || 0)
const rightParts = right.split('.').map((item) => Number(item) || 0)
const length = Math.max(leftParts.length, rightParts.length)
for (let index = 0; index < length; index += 1) {
const diff = (leftParts[index] || 0) - (rightParts[index] || 0)
if (diff !== 0) return diff
}
return 0
}
function getCanIUse(apiName: string): boolean {
try {
if (typeof Taro.canIUse !== 'function') return false
return Boolean(Taro.canIUse(apiName))
} catch {
return false
}
}
function readVoiceRuntimeInfo(): VoiceRuntimeInfo {
let systemInfo: Record<string, any> = {}
try {
systemInfo = Taro.getSystemInfoSync() as Record<string, any>
} catch (error) {
console.warn('[voice-runtime] getSystemInfoSync failed', error)
}
const platform = String(systemInfo.platform || '').toLowerCase()
const system = String(systemInfo.system || '')
const brand = String(systemInfo.brand || '')
const model = String(systemInfo.model || '')
const version = String(systemInfo.version || '')
const SDKVersion = String(systemInfo.SDKVersion || '')
const systemText = `${system} ${brand} ${model}`.toLowerCase()
const isHarmony = systemText.includes('harmony') || systemText.includes('openharmony') || systemText.includes('鸿蒙')
const isIOS = platform === 'ios' || systemText.includes('ios')
const isAndroid = platform === 'android' || systemText.includes('android') || isHarmony
return {
platform,
system,
brand,
model,
version,
SDKVersion,
isIOS,
isAndroid,
isHarmony,
canUseRecorderManager: typeof Taro.getRecorderManager === 'function',
canUseInnerAudioContext: typeof Taro.createInnerAudioContext === 'function',
supportsMp3Record: !SDKVersion || compareVersion(SDKVersion, '2.6.0') >= 0,
}
}
function getPreferredRecordProfile(runtime: VoiceRuntimeInfo): RecordProfile {
if (runtime.isIOS || runtime.isHarmony || !runtime.supportsMp3Record) return COMPAT_RECORD_PROFILE
return DEFAULT_RECORD_PROFILE
}
function getAudioUrlDebugInfo(rawUrl: string) {
const protocolMatch = rawUrl.match(/^([a-z]+):\/\//i)
let host = ''
try {
const match = rawUrl.match(/^[a-z]+:\/\/([^/?#]+)/i)
host = match?.[1] || ''
} catch {
host = ''
}
return {
rawUrl,
protocol: protocolMatch?.[1]?.toLowerCase() || '',
host,
isHttp: rawUrl.startsWith('http://'),
isHttps: rawUrl.startsWith('https://'),
}
}
export default function ChatDetail() {
usePageShare()
const systemBanner = useSystemBanner()
const router = useRouter()
const params = router.params
const initialConversationId = normalizeNumericId(params.id) || 0
const conversationSource = normalizeSource(params.source)
const conversationPeerKind = normalizePeerKind(params.peerKind)
const decodedRoleKey = decodeURIComponent(params.roleKey || '')
const decodedName = decodeURIComponent(params.name || '玩伴')
const decodedConversationTypeName = decodeURIComponent(params.conversationTypeName || '')
const decodedPeerId = decodeURIComponent(params.peerId || '')
const decodedDeviceId = decodeURIComponent(params.deviceId || '')
const decodedChildName = decodeURIComponent(params.childName || '')
const childId = normalizeNumericId(params.childId)
const parentUserId = normalizeNumericId(params.parentUserId)
const canSend = params.canSend === '1'
const [activeConversationId, setActiveConversationId] = useState(initialConversationId)
const [character, setCharacter] = useState<Character>({
id: initialConversationId,
name: decodedName,
description: '交流记录',
icon: '🤖',
source: conversationSource,
peerKind: conversationPeerKind,
conversationTypeName: decodedConversationTypeName,
childId,
childName: decodedChildName,
peerId: decodedPeerId,
parentUserId,
deviceId: decodedDeviceId,
channelLabel: '--',
canSend,
})
const [messages, setMessages] = useState<ChatMessage[]>([])
const [loading, setLoading] = useState(true)
const [sending, setSending] = useState(false)
const [recording, setRecording] = useState(false)
const [playingMessageId, setPlayingMessageId] = useState<number | null>(null)
const [recordHint, setRecordHint] = useState('长按开始留言')
const [scrollTarget, setScrollTarget] = useState('')
const [scrollWithAnimation, setScrollWithAnimation] = useState(false)
const audioContextRef = useRef<Taro.InnerAudioContext | null>(null)
const recorderManagerRef = useRef<Taro.RecorderManager | null>(null)
const activeConversationIdRef = useRef(initialConversationId)
const latestLoadChatDataRef = useRef<(conversationId: number, options?: LoadChatOptions) => Promise<void>>(
async () => {}
)
const recordingChildIdRef = useRef<number | null>(null)
const recordProfileRef = useRef<RecordProfile>(DEFAULT_RECORD_PROFILE)
const runtimeInfoRef = useRef<VoiceRuntimeInfo>(FALLBACK_RUNTIME_INFO)
const recordStartedAtRef = useRef<number>(0)
const playingMessageIdRef = useRef<number | null>(null)
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const recordStartTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const hasLoadedChatRef = useRef(false)
const skipNextAutoLoadRef = useRef(false)
const recordPressedRef = useRef(false)
const recordStartPendingRef = useRef(false)
const recordingActiveRef = useRef(false)
const scrollToBottom = (animated: boolean) => {
if (scrollTimerRef.current) {
clearTimeout(scrollTimerRef.current)
}
setScrollWithAnimation(animated)
setScrollTarget('')
scrollTimerRef.current = setTimeout(() => {
setScrollWithAnimation(animated)
setScrollTarget(CHAT_BOTTOM_ANCHOR_ID)
scrollTimerRef.current = null
}, 80)
}
useEffect(() => {
runtimeInfoRef.current = readVoiceRuntimeInfo()
recordProfileRef.current = getPreferredRecordProfile(runtimeInfoRef.current)
console.info('[voice-runtime] detected', {
...runtimeInfoRef.current,
preferredRecordProfile: recordProfileRef.current,
canIUse: {
getRecorderManager: getCanIUse('getRecorderManager'),
createInnerAudioContext: getCanIUse('createInnerAudioContext'),
uploadFile: getCanIUse('uploadFile'),
},
})
}, [])
useEffect(() => {
if (typeof Taro.createInnerAudioContext !== 'function') {
console.warn('[audio] InnerAudioContext unavailable');
return;
}
// 关键1: 忽略 iOS 静音开关
Taro.setInnerAudioOption({ obeyMuteSwitch: false });
// 关键2: 使用 useWebAudioImplement 尝试绕过原生问题
const audioContext = Taro.createInnerAudioContext({
useWebAudioImplement: true, // 基础库 2.19.0+
});
audioContextRef.current = audioContext;
audioContext.onEnded(() => {
setPlayingMessageId(null);
});
audioContext.onStop(() => {
setPlayingMessageId(null);
});
audioContext.onError((error) => {
console.error('[audio] error detail:', JSON.stringify(error));
setPlayingMessageId(null);
Taro.showToast({ title: `播放失败: ${error.errMsg || '错误'}`, icon: 'none' });
});
audioContext.onPlay(() => {
console.info('[audio] play event', { playingMessageId: playingMessageIdRef.current, src: audioContext.src })
})
audioContext.onWaiting(() => {
console.info('[audio] waiting', { playingMessageId: playingMessageIdRef.current, src: audioContext.src })
})
audioContext.onCanplay(() => {
console.info('[audio] canplay', { playingMessageId: playingMessageIdRef.current, src: audioContext.src })
})
return () => {
audioContext.destroy()
audioContextRef.current = null
}
}, [])
useEffect(() => {
return () => {
if (scrollTimerRef.current) {
clearTimeout(scrollTimerRef.current)
scrollTimerRef.current = null
}
if (recordStartTimerRef.current) {
clearTimeout(recordStartTimerRef.current)
recordStartTimerRef.current = null
}
}
}, [])
useEffect(() => {
activeConversationIdRef.current = activeConversationId
}, [activeConversationId])
useEffect(() => {
playingMessageIdRef.current = playingMessageId
}, [playingMessageId])
useEffect(() => {
if (skipNextAutoLoadRef.current) {
skipNextAutoLoadRef.current = false
return
}
void loadChatData(activeConversationId)
}, [activeConversationId, conversationSource, conversationPeerKind])
const loadChatData = async (conversationId: number, options: LoadChatOptions = {}) => {
const shouldShowLoading = options.showLoading ?? !hasLoadedChatRef.current
if (shouldShowLoading) {
setLoading(true)
}
try {
const char = await getCharacter({
conversationId,
source: conversationSource,
peerKind: conversationPeerKind,
name: decodedName,
roleKey: decodedRoleKey,
conversationTypeName: decodedConversationTypeName,
childId,
childName: decodedChildName,
peerId: decodedPeerId,
parentUserId,
deviceId: decodedDeviceId,
canSend,
})
if (char) {
setCharacter(char)
Taro.setNavigationBarTitle({ title: char.name })
}
const msgs = await getMessages(conversationId, conversationSource, decodedConversationTypeName)
setMessages(msgs)
const scrollMode = options.scrollToBottom ?? (!hasLoadedChatRef.current ? 'jump' : false)
if (msgs.length > 0 && scrollMode) {
scrollToBottom(scrollMode === 'animate')
}
hasLoadedChatRef.current = true
} catch (error: any) {
console.error('[chat-detail] load failed:', error)
Taro.showToast({
title: error?.message || '加载失败,请稍后重试',
icon: 'none',
})
} finally {
setLoading(false)
}
}
latestLoadChatDataRef.current = loadChatData
useEffect(() => {
if (typeof Taro.getRecorderManager !== 'function') {
console.warn('[record] RecorderManager unavailable', runtimeInfoRef.current)
return
}
const recorderManager = Taro.getRecorderManager()
recorderManagerRef.current = recorderManager
recorderManager.onStart(() => {
recordingActiveRef.current = true
recordStartPendingRef.current = false
console.info('[record] started', {
profile: recordProfileRef.current,
runtime: runtimeInfoRef.current,
})
})
recorderManager.onError((error) => {
recordingChildIdRef.current = null
recordStartedAtRef.current = 0
recordStartPendingRef.current = false
recordingActiveRef.current = false
setRecording(false)
setRecordHint('长按开始留言')
console.error('[record] error', {
error,
profile: recordProfileRef.current,
runtime: runtimeInfoRef.current,
})
Taro.showToast({ title: '录音失败,请重试', icon: 'none' })
})
recorderManager.onStop(async (result) => {
const currentChildId = recordingChildIdRef.current
const startedAt = recordStartedAtRef.current
const fallbackDurationMs = startedAt > 0 ? Date.now() - startedAt : 0
recordingChildIdRef.current = null
recordStartedAtRef.current = 0
recordStartPendingRef.current = false
recordingActiveRef.current = false
setRecording(false)
setRecordHint('长按开始留言')
console.info('[record] stopped', {
childId: currentChildId,
tempFilePath: result.tempFilePath,
duration: result.duration,
fileSize: result.fileSize,
fallbackDurationMs,
profile: recordProfileRef.current,
runtime: runtimeInfoRef.current,
})
if (!currentChildId) return
if (!result.tempFilePath) {
console.warn('[record] missing tempFilePath', { result })
Taro.showToast({ title: '录音文件不存在', icon: 'none' })
return
}
const durationMs = Math.max(0, Number(result.duration || 0) || fallbackDurationMs)
if (durationMs < 800) {
console.warn('[record] too short', {
durationMs,
resultDuration: result.duration,
fallbackDurationMs,
})
Taro.showToast({ title: '留言太短,请重试', icon: 'none' })
return
}
setSending(true)
try {
const profile = recordProfileRef.current
const response = await sendParentVoiceMessage(currentChildId, {
filePath: result.tempFilePath,
durationMs,
audioFormat: profile.format,
mimeType: profile.mimeType,
runtime: runtimeInfoRef.current,
})
console.info('[record] uploaded', {
childId: currentChildId,
conversationId: response.conversation_id,
messageId: response.message?.id,
})
if (response.conversation_id && response.conversation_id !== activeConversationIdRef.current) {
skipNextAutoLoadRef.current = true
setActiveConversationId(response.conversation_id)
await latestLoadChatDataRef.current(response.conversation_id, {
showLoading: false,
scrollToBottom: 'animate',
})
} else {
await latestLoadChatDataRef.current(activeConversationIdRef.current, {
showLoading: false,
scrollToBottom: 'animate',
})
}
Taro.showToast({ title: '留言已发送', icon: 'success' })
} catch (error: any) {
console.error('[chat-detail] voice send failed:', error)
Taro.showToast({
title: error?.message || '留言发送失败,请重试',
icon: 'none',
})
} finally {
setSending(false)
}
})
return () => {
recorderManagerRef.current = null
}
}, [])
const handleBack = () => {
Taro.navigateBack()
}
const ensureRecordPermission = async (): Promise<boolean> => {
try {
const settings = await Taro.getSetting()
const granted = Boolean(settings.authSetting['scope.record'])
console.info('[record] permission status', {
granted,
runtime: runtimeInfoRef.current,
})
if (granted) return true
await Taro.authorize({ scope: 'scope.record' })
console.info('[record] permission granted by authorize')
return true
} catch (error) {
console.warn('[record] permission denied or authorize failed', {
error,
runtime: runtimeInfoRef.current,
})
const modal = await Taro.showModal({
title: '需要录音权限',
content: '请允许小程序使用麦克风后再留言',
confirmText: '去设置',
})
if (modal.confirm) {
console.info('[record] openSetting requested')
await Taro.openSetting()
}
return false
}
}
const resetRecordStartTimer = () => {
if (recordStartTimerRef.current) {
clearTimeout(recordStartTimerRef.current)
recordStartTimerRef.current = null
}
}
const startRecording = async () => {
if (sending || recordingActiveRef.current || recordStartPendingRef.current) return
if (!character.canSend || !character.childId) {
Taro.showToast({ title: '当前会话不支持发送', icon: 'none' })
return
}
const recorderManager = recorderManagerRef.current
if (!recorderManager) {
Taro.showToast({ title: '当前环境不支持录音', icon: 'none' })
return
}
recordStartPendingRef.current = true
const hasPermission = await ensureRecordPermission()
if (!recordPressedRef.current) {
recordStartPendingRef.current = false
console.info('[record] start skipped: touch already ended')
return
}
if (!hasPermission) {
recordStartPendingRef.current = false
return
}
const runtime = readVoiceRuntimeInfo()
const profile = getPreferredRecordProfile(runtime)
runtimeInfoRef.current = runtime
recordProfileRef.current = profile
recordingChildIdRef.current = character.childId
recordStartedAtRef.current = Date.now()
recordingActiveRef.current = true
setRecording(true)
setRecordHint('松开发送留言')
console.info('[record] start request', {
childId: character.childId,
profile,
runtime,
})
try {
recorderManager.start({
duration: 60000,
format: profile.format,
numberOfChannels: 1,
sampleRate: profile.sampleRate,
encodeBitRate: profile.encodeBitRate,
})
} catch (error: any) {
recordingChildIdRef.current = null
recordStartedAtRef.current = 0
recordStartPendingRef.current = false
recordingActiveRef.current = false
setRecording(false)
setRecordHint('长按开始留言')
console.error('[record] start failed:', {
error,
profile,
runtime,
})
Taro.showToast({
title: '无法开始录音,请重试',
icon: 'none',
})
}
}
const handleRecordTouchStart = () => {
if (sending || recordingActiveRef.current || recordStartPendingRef.current) return
recordPressedRef.current = true
resetRecordStartTimer()
setRecordHint('继续按住开始录音')
console.info('[record] touch start', {
delayMs: RECORD_LONG_PRESS_DELAY_MS,
runtime: runtimeInfoRef.current,
})
recordStartTimerRef.current = setTimeout(() => {
recordStartTimerRef.current = null
if (!recordPressedRef.current) return
console.info('[record] long press threshold reached')
void startRecording()
}, RECORD_LONG_PRESS_DELAY_MS)
}
const handleRecordStop = () => {
recordPressedRef.current = false
resetRecordStartTimer()
console.info('[record] touch end', {
active: recordingActiveRef.current,
pending: recordStartPendingRef.current,
elapsedMs: recordStartedAtRef.current > 0 ? Date.now() - recordStartedAtRef.current : 0,
})
if (recordStartPendingRef.current && !recordingActiveRef.current) {
setRecordHint('长按开始留言')
return
}
if (!recordingActiveRef.current) {
setRecordHint('长按开始留言')
return
}
recordingActiveRef.current = false
const recorderManager = recorderManagerRef.current
if (!recorderManager) {
console.warn('[record] stop skipped: recorder unavailable')
recordingChildIdRef.current = null
recordStartedAtRef.current = 0
setRecording(false)
setRecordHint('长按开始留言')
return
}
setRecordHint('正在处理留言...')
console.info('[record] stop request', {
elapsedMs: recordStartedAtRef.current > 0 ? Date.now() - recordStartedAtRef.current : 0,
})
try {
recorderManager.stop()
} catch (error) {
recordingChildIdRef.current = null
recordStartedAtRef.current = 0
recordStartPendingRef.current = false
setRecording(false)
setRecordHint('长按开始留言')
console.error('[record] stop failed', {
error,
profile: recordProfileRef.current,
runtime: runtimeInfoRef.current,
})
Taro.showToast({ title: '录音停止失败,请重试', icon: 'none' })
}
}
const handlePlayAudio = (message: ChatMessage) => {
if (message.contentType !== 2 || !message.mediaUrl) {
Taro.showToast({ title: '语音地址不存在', icon: 'none' })
return
}
const urlInfo = getAudioUrlDebugInfo(message.mediaUrl)
console.info('[audio] play request', {
messageId: message.id,
conversationId: message.conversationId,
contentType: message.contentType,
mediaDurationMs: message.mediaDurationMs,
mediaMimeType: message.mediaMimeType,
url: urlInfo,
runtime: runtimeInfoRef.current,
})
if (urlInfo.isHttp) {
console.warn('[audio] http url may fail in experience/release builds', urlInfo)
}
const audioContext = audioContextRef.current
if (!audioContext) {
console.warn('[audio] play skipped: audio context unavailable', runtimeInfoRef.current)
Taro.showToast({ title: '播放器未就绪', icon: 'none' })
return
}
if (playingMessageId === message.id) {
console.info('[audio] stop current', { messageId: message.id })
audioContext.stop()
setPlayingMessageId(null)
return
}
audioContext.stop()
audioContext.src = message.mediaUrl
audioContext.autoplay = true
audioContext.play()
console.info('[audio] play invoked', {
messageId: message.id,
srcRaw: message.mediaUrl,
src: audioContext.src,
})
setPlayingMessageId(message.id)
}
const renderMessageBody = (msg: ChatMessage) => {
if (msg.contentType === 2 && msg.mediaUrl) {
const isPlaying = playingMessageId === msg.id
return (
<View className={`audio-bubble ${isPlaying ? 'playing' : ''}`} onClick={() => handlePlayAudio(msg)}>
<Text className='audio-bubble-icon'>{isPlaying ? '[]' : '>'}</Text>
<Text className='audio-bubble-duration'>{formatAudioDuration(msg.mediaDurationMs)}</Text>
</View>
)
}
return <Text>{msg.content}</Text>
}
return (
<View className='chat-detail-page' style={getPageStyle(true)}>
<View className='chat-header'>
<View className='back-btn' onClick={handleBack}>
<Image src={require('../../../assets/tab-icons/arrow-left.png')} className='back-arrow-img' />
</View>
<View className='character-info'>
<View className='character-avatar'>
<Image className='chat-avatar-art' src={getParticipantArtwork(character.peerKind, decodedRoleKey)} mode='aspectFit' />
</View>
<View className='character-text'>
<Text className='character-name'>{character.name}</Text>
<Text className='character-desc'>{character.description}</Text>
</View>
</View>
</View>
<ScrollView
className='chat-list'
scrollY
enhanced
showScrollbar={false}
scrollWithAnimation={scrollWithAnimation}
scrollIntoView={scrollTarget}
>
{loading ? (
<View className='empty-chat'>
<Text className='empty-chat-text'>加载中...</Text>
</View>
) : messages.length === 0 ? (
<View className='empty-chat'>
<Text className='empty-chat-text'>
{character.canSend ? '还没有留言,长按发送第一条吧' : '这个会话还没有消息'}
</Text>
</View>
) : (
messages.map((msg, index) => (
<View key={msg.id}>
{(index === 0 || messages[index - 1].time !== msg.time) && (
<View className='time-divider'>
<Text>{msg.time}</Text>
</View>
)}
{msg.type === 'user' ? (
<View className='message-item user'>
<View className='message-main'>
<Text className='message-meta'>{formatParticipantLabel(msg)}</Text>
<View className='message-bubble'>
{renderMessageBody(msg)}
</View>
</View>
<View className='user-avatar'>
<Image className='chat-avatar-art' src={getParticipantArtwork(msg.senderType)} mode='aspectFit' />
</View>
</View>
) : (
<View className='message-item peer'>
<View className='peer-avatar'>
<Image className='chat-avatar-art' src={getParticipantArtwork(msg.senderType || character.peerKind, decodedRoleKey)} mode='aspectFit' />
</View>
<View className='message-main'>
<Text className='message-meta'>{formatParticipantLabel(msg)}</Text>
<View className='message-bubble'>
{renderMessageBody(msg)}
</View>
</View>
</View>
)}
</View>
))
)}
<View id={CHAT_BOTTOM_ANCHOR_ID} />
</ScrollView>
{character.canSend && character.childId && (
<View className='composer'>
<View className='composer-tip'>
<Text>{sending ? '正在发送留言...' : recordHint}</Text>
</View>
<View
className={`composer-record ${recording ? 'recording' : ''} ${sending ? 'disabled' : ''}`}
onTouchStart={handleRecordTouchStart}
onTouchEnd={handleRecordStop}
onTouchCancel={handleRecordStop}
>
<Text className='composer-record-text'>
{sending ? '发送中...' : recording ? '松开发送' : '按住留言'}
</Text>
</View>
</View>
)}
{systemBanner}
</View>
)
}