fix/ 修复打个招呼计时器问题,修复聊天页输入框文字模式切换到语音的问题

This commit is contained in:
Kevin
2026-04-01 09:40:24 +08:00
parent 37df0d48ac
commit a5473e8fe2
2 changed files with 84 additions and 25 deletions

View File

@@ -13,28 +13,25 @@ import {
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import type {
LayoutChangeEvent,
NativeSyntheticEvent,
TextInputContentSizeChangeEventData,
} from 'react-native';
import type { TextStyle } from 'react-native';
import {
Alert,
Animated,
FlatList,
InteractionManager,
Keyboard,
type LayoutChangeEvent,
type NativeSyntheticEvent,
Platform,
Pressable,
StyleSheet,
Text as RNText,
TextInput,
type TextStyle,
type TextInputContentSizeChangeEventData,
View,
} from 'react-native';
import {
@@ -741,6 +738,7 @@ function ChatInputBar({
cancelRecordingLabel,
disabled,
textInputKey = 0,
textInputRef,
onInputDisplayHeightChange,
inputLineHeight,
textInputStyle,
@@ -771,6 +769,8 @@ function ChatInputBar({
disabled?: boolean;
/** 发送后递增,强制重建 TextInput避免多行高度卡在 4 行 */
textInputKey?: number;
/** 外层显式控制焦点,切语音前先 blur避免键盘占位残留 */
textInputRef?: React.RefObject<TextInput | null>;
/** 文字输入框实际绘制高度变化(单行/多行),供列表滚到底 */
onInputDisplayHeightChange?: (height: number) => void;
/** 与全局 typography 一致的单行行高、最多约 4 行 */
@@ -841,6 +841,7 @@ function ChatInputBar({
<View style={styles.inputCenter}>
<TextInput
key={`chat-input-${textInputKey}`}
ref={textInputRef}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
@@ -1194,6 +1195,7 @@ export default function ConversationScreen() {
const [inputMode, setInputMode] = useState<InputMode>('text');
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
const listRef = useRef<FlatList>(null);
const textInputRef = useRef<TextInput>(null);
/** 底部输入区(含连接提示 + 输入条)高度,用于多行输入增高时把列表滚到底,避免挡住最新消息 */
const composerBlockHeightRef = useRef<number | null>(null);
/** 连接中connecting时点发送排队连上后自动发出 */
@@ -1285,15 +1287,22 @@ export default function ConversationScreen() {
return () => subs.forEach((s) => s.remove());
}, [scrollListToEndAfterComposerLayout]);
/**
* 切到语音时改用 KeyboardController.dismiss与 keyboard-controller 的 Reanimated
* 键盘进度一致;仅用 RN Keyboard.dismiss 时AvoidingView 可能仍保留键盘高度的 padding。
*/
useLayoutEffect(() => {
if (inputMode !== 'voice') return;
setIsKeyboardVisible(false);
void KeyboardController.dismiss();
const handleInputModeToggle = useCallback(() => {
if (inputMode === 'voice') {
setInputMode('text');
return;
}
}, [inputMode]);
const handleSwitchToVoiceMode = useCallback(() => {
/**
* `dismiss()` 在当前库版本里会等 `keyboardDidHide` 后才 resolve。
* 这里不能 `await`,否则事件丢失/延迟时会把模式切换卡住。
* 先切 UI再让键盘异步收起外层 AvoidingView 会在键盘真正隐藏前保持启用。
*/
textInputRef.current?.blur();
setInputMode('voice');
void KeyboardController.dismiss({ animated: false });
}, []);
const onComposerBlockLayout = useCallback(
(e: LayoutChangeEvent) => {
@@ -1508,12 +1517,15 @@ export default function ConversationScreen() {
onChangeText={setInput}
onSend={handleSend}
textInputKey={inputResetKey}
textInputRef={textInputRef}
inputLineHeight={inputLineHeight}
textInputStyle={inputTextStyle}
inputMode={inputMode}
onInputModeToggle={() => {
setInputMode((m) => (m === 'text' ? 'voice' : 'text'));
}}
onInputModeToggle={
inputMode === 'voice'
? handleInputModeToggle
: handleSwitchToVoiceMode
}
onAddPress={() => {}}
onStartRecording={handleStartRecording}
onStopRecording={() => void handleStopRecording()}
@@ -1543,7 +1555,7 @@ export default function ConversationScreen() {
<KeyboardControllerAvoidingView
style={styles.container}
behavior="padding"
enabled={inputMode === 'text'}
enabled={inputMode === 'text' || isKeyboardVisible}
>
{screen}
</KeyboardControllerAvoidingView>

View File

@@ -1,8 +1,15 @@
import { Image } from 'expo-image';
import { router } from 'expo-router';
import React, { useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Alert, Pressable, ScrollView, View } from 'react-native';
import {
Alert,
AppState,
Pressable,
ScrollView,
View,
type AppStateStatus,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import type { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
@@ -227,6 +234,12 @@ function conversationStartedAtMs(item: ConversationListItem): number {
return item.startedAt ?? item.latestMessageTime;
}
function msUntilNextLocalMidnight(nowMs: number): number {
const next = new Date(nowMs);
next.setHours(24, 0, 0, 0);
return Math.max(1, next.getTime() - nowMs);
}
/** 仅复用「当天创建」且尚无用户消息的对话,跨日则新开(一天一次招呼会话) */
function findReusableEmptyConversationId(
items: ConversationListItem[],
@@ -261,8 +274,39 @@ export default function ConversationsScreen() {
const { data: conversations = [], isLoading } = useConversations();
const createConversation = useCreateConversation();
const createOnceGuardRef = useRef(false);
const [nowMs, setNowMs] = useState(() => Date.now());
const isEmpty = conversations.length === 0;
const todayConversation = findTodayConversationToResume(conversations, nowMs);
const showResumeEntry = todayConversation != null;
useEffect(() => {
const timer = setTimeout(
() => {
setNowMs(Date.now());
},
msUntilNextLocalMidnight(nowMs) + 100,
);
return () => {
clearTimeout(timer);
};
}, [nowMs]);
useEffect(() => {
const subscription = AppState.addEventListener(
'change',
(state: AppStateStatus) => {
if (state === 'active') {
setNowMs(Date.now());
}
},
);
return () => {
subscription.remove();
};
}, []);
const handleCreateConversation = () => {
if (createConversation.isPending || createOnceGuardRef.current) {
@@ -308,8 +352,7 @@ export default function ConversationsScreen() {
};
const handleResumeLatestConversation = () => {
const now = Date.now();
const toResume = findTodayConversationToResume(conversations, now);
const toResume = findTodayConversationToResume(conversations, nowMs);
if (toResume) {
router.push(`/(main)/conversation/${toResume.id}`);
return;
@@ -388,10 +431,14 @@ export default function ConversationsScreen() {
className="text-center font-display text-primary"
style={{ borderWidth: 0, fontSize: 28, lineHeight: 38 }}
>
{t('resumeChatTitle')}
{showResumeEntry
? t('resumeChatTitle')
: t('greetingTitle')}
</Text>
<Text className="text-center text-base font-medium leading-6 text-muted-foreground">
{t('resumeChatSubtitle')}
{showResumeEntry
? t('resumeChatSubtitle')
: t('emptyGreetingSubtitle')}
</Text>
</View>
</Pressable>