Files
frontend/src/screens/message/components/ChatScreen/useChatScreen.ts
lafay f6176c945b
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 3m13s
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled
feat(CallScreen): enhance video call functionality and UI updates
- Integrated video call support by adding local and remote video stream handling.
- Implemented state management for video track detection in both local and peer streams.
- Updated UI to conditionally render video components based on the presence of video tracks.
- Added video toggle functionality to enable or disable local video during calls.
- Enhanced incoming call modal to display call type (voice or video).
- Refactored call store to manage call types and video state more effectively.
2026-03-28 00:56:52 +08:00

1428 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ChatScreen 自定义 Hook
* 管理聊天的所有状态和逻辑
*
* 【重构后】完全依赖 MessageManager 架构
* - 消息数据通过 MessageManager 管理
* - 会话详情通过 useChat 返回的 conversation 获取
* - 发送者信息通过 MessageManager 维护
* - 输入状态、禁言状态通过 MessageManager 管理
* - 解决竞态条件和状态同步问题
*/
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import {
Keyboard,
Platform,
KeyboardEvent,
Alert,
} from 'react-native';
import { useLocalSearchParams, router } from 'expo-router';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import * as ImagePicker from 'expo-image-picker';
import { GroupMemberResponse, MessageSegment, TextSegmentData, ImageSegmentData, AtSegmentData, ReplySegmentData, MessageStatus } from '../../../../types/dto';
import { messageService } from '../../../../services/messageService';
import { uploadService } from '../../../../services/uploadService';
import { ApiError } from '../../../../services/api';
// 【新架构】使用 MessageManager
import { useChat, useGroupTyping, useGroupMuted, messageManager, callStore } from '../../../../stores';
import { groupService } from '../../../../services/groupService';
import { userManager } from '../../../../stores/userManager';
import { groupManager } from '../../../../stores/groupManager';
import * as hrefs from '../../../../navigation/hrefs';
import { firstRouteParam } from '../../../../navigation/paramUtils';
import {
GroupMessage,
PanelType,
UserRole,
SenderInfo,
ChatRouteParams,
MenuPosition,
PendingChatAttachment,
} from './types';
import { TIME_SEPARATOR_INTERVAL, MEMBERS_PAGE_SIZE, MAX_PENDING_CHAT_IMAGES } from './constants';
import {
deleteMessage as deleteMessageFromDb,
clearConversationMessages,
} from '../../../../services/database';
export const useChatScreen = () => {
const getSendErrorMessage = useCallback((error: unknown, fallback: string) => {
if (error instanceof ApiError && error.message) {
return error.message;
}
return fallback;
}, []);
const rawParams = useLocalSearchParams<{
conversationId?: string | string[];
userId?: string | string[];
isGroupChat?: string | string[];
groupId?: string | string[];
groupName?: string | string[];
}>();
// 路由参数(动态段 + query群 ID 勿用 Number(),避免超过 2^53-1 时精度丢失
const routeParams = useMemo(() => {
const isGroupFlag = firstRouteParam(rawParams.isGroupChat);
return {
conversationId: firstRouteParam(rawParams.conversationId) ?? null,
userId: firstRouteParam(rawParams.userId) ?? null,
isGroupChat: isGroupFlag === '1' || isGroupFlag === 'true',
groupId: firstRouteParam(rawParams.groupId),
groupName: firstRouteParam(rawParams.groupName),
};
}, [
rawParams.conversationId,
rawParams.userId,
rawParams.isGroupChat,
rawParams.groupId,
rawParams.groupName,
]);
const { conversationId: routeConversationId, userId: routeUserId, isGroupChat, groupId: routeGroupId, groupName: routeGroupName } = routeParams;
// 当前会话ID可能在新建私聊会话后动态更新
const [conversationId, setConversationId] = useState<string | null>(routeConversationId);
// 【新架构】使用 MessageManager 的 hook 获取所有数据
const {
messages: messageManagerMessages,
isLoadingMessages,
hasMoreMessages,
loadMoreMessages,
refreshMessages,
sendMessage: sendMessageViaManager,
isSending: isSendingViaManager,
markAsRead,
conversation,
} = useChat(conversationId);
// 本地状态
const [currentUserId, setCurrentUserId] = useState<string>('');
// 【新架构】使用 MessageManager 获取群聊输入状态和禁言状态
const { typingUsers: groupTypingUsers } = useGroupTyping(routeGroupId ? String(routeGroupId) : null);
const { isMuted: isMutedFromManager, setMuted: setMutedStatus } = useGroupMuted(
routeGroupId ? String(routeGroupId) : null,
currentUserId
);
// 本地状态
const [inputText, setInputText] = useState('');
const [otherUser, setOtherUser] = useState<{ id?: string; nickname?: string; avatar?: string | null } | null>(null);
const [isFollowedByOther, setIsFollowedByOther] = useState<boolean | null>(null);
const [currentUser, setCurrentUser] = useState<{ id?: string; nickname?: string; avatar?: string | null } | null>(null);
const [keyboardHeight, setKeyboardHeight] = useState(0);
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [lastSeq, setLastSeq] = useState<number>(0);
const [firstSeq, setFirstSeq] = useState<number>(0);
const [otherUserLastReadSeq, setOtherUserLastReadSeq] = useState<number>(0);
const [activePanel, setActivePanel] = useState<PanelType>('none');
const [sendingImage, setSendingImage] = useState(false);
const [uploadingAttachments, setUploadingAttachments] = useState(false);
const [pendingAttachments, setPendingAttachments] = useState<PendingChatAttachment[]>([]);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMoreHistory, setHasMoreHistory] = useState(true);
const flatListRef = useRef<any>(null);
const textInputRef = useRef<any>(null);
// 滚动状态机 refsTelegram/Element 风格:底部粘附 + 阅读锚点)
const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0, viewportHeight: 0 });
const hasInitialAnchorDoneRef = useRef(false);
const prevMessageCountRef = useRef(0);
const prevLatestSeqRef = useRef(0);
const prevMarkedReadSeqRef = useRef(0);
const enterMarkedKeyRef = useRef<string>('');
const isProgrammaticScrollRef = useRef(false);
const suppressAutoFollowRef = useRef(false);
const isBrowsingHistoryRef = useRef(false);
const hasShownMessageListRef = useRef(false);
const historyLoadingLockUntilRef = useRef(0);
// 回复消息状态
const [replyingTo, setReplyingTo] = useState<GroupMessage | null>(null);
// 长按菜单状态
const [longPressMenuVisible, setLongPressMenuVisible] = useState(false);
const [selectedMessage, setSelectedMessage] = useState<GroupMessage | null>(null);
const [menuPosition, setMenuPosition] = useState<MenuPosition | undefined>(undefined);
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(null);
// 群聊相关状态
const [groupInfo, setGroupInfo] = useState<{ name?: string; member_count?: number } | null>(null);
const [groupMembers, setGroupMembers] = useState<GroupMemberResponse[]>([]);
const [currentUserRole, setCurrentUserRole] = useState<UserRole>('member');
const [mentionQuery, setMentionQuery] = useState<string>('');
const [selectedMentions, setSelectedMentions] = useState<string[]>([]);
const [mentionAll, setMentionAll] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [muteAll, setMuteAll] = useState(false);
// 路由会话ID变化时同步到本地会话状态确保订阅会随会话切换
useEffect(() => {
setConversationId(routeConversationId);
}, [routeConversationId]);
// 【改造】直接使用 MessageManager 返回的消息
const messages = useMemo<GroupMessage[]>(() => {
return messageManagerMessages.map(m => ({
id: m.id,
conversation_id: m.conversation_id,
sender_id: m.sender_id,
seq: m.seq,
segments: m.segments || [],
status: (m.status || 'normal') as MessageStatus,
category: m.category,
created_at: m.created_at,
sender: (m as any).sender,
is_system_notice: (m as any).is_system_notice,
notice_content: (m as any).notice_content,
}));
}, [messageManagerMessages]);
// 对方用户ID稳定来源优先路由参数其次会话参与者最后本地 otherUser
const otherUserId = useMemo(() => {
if (isGroupChat) return null;
if (routeUserId) return String(routeUserId);
const participants = conversation?.participants || [];
if (participants.length > 0) {
if (currentUserId) {
const target = participants.find((p: any) => String(p.id) !== String(currentUserId));
if (target?.id) return String(target.id);
}
if (participants.length === 1 && participants[0]?.id) return String(participants[0].id);
}
return otherUser?.id ? String(otherUser.id) : null;
}, [isGroupChat, routeUserId, conversation?.participants, currentUserId, otherUser?.id]);
// 私聊陌生人限制未被对方关注时仅可发送1条文本消息且禁止图片
const followRestricted = useMemo(() => {
if (isGroupChat) return false;
// 仅使用独立关系状态源,避免 participants 覆盖造成误判
return isFollowedByOther === false;
}, [isGroupChat, isFollowedByOther]);
const myPrivateSentCount = useMemo(() => {
if (!followRestricted || !currentUserId) return 0;
return messages.filter(m => String(m.sender_id) === String(currentUserId)).length;
}, [messages, followRestricted, currentUserId]);
const canSendFirstPrivateText = !followRestricted || myPrivateSentCount < 1;
const canSendPrivateImage = !followRestricted;
const followRestrictionHint = useMemo(() => {
if (!followRestricted) return null;
if (myPrivateSentCount >= 1) {
return '对方未关注你前仅可发送1条文字消息已达上限';
}
return '对方未关注你前仅可发送1条文字消息且不能发送图片';
}, [followRestricted, myPrivateSentCount]);
// 加载态语义修正:
// isLoadingMessages 在分页加载历史时也会短暂为 true。
// 若直接驱动 ChatScreen 的 loading会导致消息列表被卸载重挂载触发“回到底部”。
// 这里只在“首屏且尚未展示过消息列表”时展示 loading 占位。
useEffect(() => {
if (messageManagerMessages.length > 0) {
hasShownMessageListRef.current = true;
}
const shouldShowInitialLoading =
!hasShownMessageListRef.current &&
isLoadingMessages &&
messageManagerMessages.length === 0;
setLoading(shouldShowInitialLoading);
}, [isLoadingMessages, messageManagerMessages.length]);
// 【改造】同步 hasMore 状态
useEffect(() => {
setHasMoreHistory(hasMoreMessages);
}, [hasMoreMessages]);
// 【改造】同步发送状态
useEffect(() => {
setSending(isSendingViaManager);
}, [isSendingViaManager]);
// 【改造】同步禁言状态
useEffect(() => {
setIsMuted(isMutedFromManager);
}, [isMutedFromManager]);
// 【改造】从 conversation 对象获取会话详情
useEffect(() => {
if (!conversation) return;
// 私聊模式:从 conversation.participants 获取对方用户信息
if (!isGroupChat && conversation.participants) {
if (!currentUserId) return;
const currentUserIdStr = String(currentUserId);
const other = conversation.participants.find((p: any) => p.id !== currentUserIdStr);
if (other) {
setOtherUser(prev => ({ ...(prev || {}), ...other }));
}
// 获取对方最后阅读位置
setOtherUserLastReadSeq((conversation as any).other_last_read_seq || 0);
}
}, [conversation, isGroupChat, currentUserId]);
// 独立同步私聊对象互关状态,避免 participants 缓存字段不完整或被刷新覆盖
useEffect(() => {
if (isGroupChat) return;
if (!otherUserId) return;
let cancelled = false;
const syncRelationState = async () => {
try {
const detail = await userManager.getUserById(String(otherUserId), true);
if (!detail || cancelled) return;
setOtherUser(prev => ({ ...(prev || {}), ...detail }));
if (typeof (detail as any).is_following_me === 'boolean') {
setIsFollowedByOther((detail as any).is_following_me);
} else {
setIsFollowedByOther(null);
}
} catch (error) {
console.error('同步私聊对象互关状态失败:', error);
if (!cancelled) {
setIsFollowedByOther(null);
}
}
};
syncRelationState();
return () => {
cancelled = true;
};
}, [isGroupChat, otherUserId]);
// 进入新会话时重置滚动状态
useEffect(() => {
hasInitialAnchorDoneRef.current = false;
prevMessageCountRef.current = 0;
prevLatestSeqRef.current = 0;
prevMarkedReadSeqRef.current = 0;
enterMarkedKeyRef.current = '';
suppressAutoFollowRef.current = false;
isBrowsingHistoryRef.current = false;
hasShownMessageListRef.current = false;
historyLoadingLockUntilRef.current = 0;
}, [conversationId]);
useEffect(() => {
setPendingAttachments([]);
}, [conversationId]);
const removePendingAttachment = useCallback((id: string) => {
setPendingAttachments(prev => prev.filter(p => p.id !== id));
}, []);
const isComposerBusy = useMemo(
() => sending || sendingImage || uploadingAttachments,
[sending, sendingImage, uploadingAttachments]
);
const isHistoryLoadingLocked = useCallback(() => {
return Date.now() < historyLoadingLockUntilRef.current;
}, []);
const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => {
if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked())) return;
// inverted 列表下,最新消息端对应 offset=0
isProgrammaticScrollRef.current = true;
flatListRef.current?.scrollToOffset({
offset: 0,
animated,
});
setTimeout(() => {
isProgrammaticScrollRef.current = false;
}, animated ? 220 : 32);
}, [flatListRef, isHistoryLoadingLocked]);
const isNearBottom = useCallback(() => {
const scrollY = scrollPositionRef.current.scrollY || 0;
// inverted 列表下,越接近 0 越靠近最新消息端
return scrollY <= 100;
}, [scrollPositionRef]);
// 首屏加载完成后仅锚定一次到最新消息端
useEffect(() => {
if (
loading ||
loadingMore ||
messages.length === 0 ||
hasInitialAnchorDoneRef.current ||
scrollPositionRef.current.viewportHeight <= 0
) {
return;
}
hasInitialAnchorDoneRef.current = true;
const timer = setTimeout(() => {
scrollToLatest(false, true, 'initial-anchor');
}, 0);
return () => clearTimeout(timer);
}, [loading, loadingMore, messages.length, scrollToLatest]);
// 新消息跟随策略Telegram/QQ 风格):
// 仅当“最新端消息 seq 增长”且用户在底部附近时才跟随;
// 历史加载只会增加旧消息,不会提升 latest seq因此不会触发回底。
useEffect(() => {
const currentCount = messages.length;
const latestSeq = currentCount > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
const prevLatestSeq = prevLatestSeqRef.current;
prevMessageCountRef.current = currentCount;
prevLatestSeqRef.current = latestSeq;
if (loading || loadingMore) return;
if (latestSeq <= prevLatestSeq) return;
if (suppressAutoFollowRef.current) return;
if (!isNearBottom()) return;
const timer = setTimeout(() => {
scrollToLatest(false, false, 'new-message-follow');
}, 0);
return () => clearTimeout(timer);
}, [messages, loading, loadingMore, isNearBottom, scrollToLatest]);
// 获取当前用户信息
useEffect(() => {
const fetchCurrentUser = async () => {
try {
const currentUserData = await userManager.getCurrentUser();
if (currentUserData) {
setCurrentUserId(String(currentUserData.id));
setCurrentUser(currentUserData);
}
} catch (error) {
console.error('获取当前用户失败:', error);
}
};
fetchCurrentUser();
}, []);
// 群聊模式:获取群组信息和成员列表
useEffect(() => {
if (!isGroupChat || !routeGroupId) return;
const fetchGroupInfo = async () => {
try {
const group = await groupManager.getGroup(routeGroupId);
setGroupInfo(group);
const membersResponse = await groupManager.getMembers(routeGroupId, 1, MEMBERS_PAGE_SIZE);
const members = membersResponse.list || [];
setGroupMembers(members);
const myMember = members.find((m: any) => m.user_id === currentUserId);
if (myMember) {
setCurrentUserRole(myMember.role);
}
try {
const myInfo = await groupService.getMyMemberInfo(routeGroupId);
setIsMuted(myInfo.is_muted);
setMutedStatus(myInfo.is_muted);
setMuteAll(myInfo.mute_all);
} catch (error) {
console.error('获取成员信息失败:', error);
}
} catch (error) {
const isGroupNotFound =
error instanceof ApiError &&
(error.code === 404 ||
error.message === '群组不存在' ||
error.message.includes('群组不存在'));
if (isGroupNotFound) {
Alert.alert('提示', '该群组不存在或已解散', [
{
text: '确定',
onPress: () => {
if (router.canGoBack()) {
router.back();
} else {
router.replace(hrefs.hrefMessages());
}
},
},
]);
return;
}
console.error('获取群组信息失败:', error);
Alert.alert('错误', getSendErrorMessage(error, '无法获取群组信息'));
}
};
if (currentUserId) {
fetchGroupInfo();
}
}, [isGroupChat, routeGroupId, currentUserId, setMutedStatus]);
// 私聊模式:创建或获取会话
useEffect(() => {
if (isGroupChat) return;
let isCancelled = false;
const createOrGetConversation = async () => {
if (conversationId) return;
if (!routeUserId) return;
try {
setLoading(true);
const response = await messageService.createConversation(routeUserId);
if (isCancelled) return;
setConversationId(response.id);
if (response.participants && response.participants.length > 0) {
const other = response.participants.find((p: any) => p.id !== currentUserId);
if (other) {
setOtherUser(other);
}
}
} catch (error) {
console.error('创建会话失败:', error);
Alert.alert('错误', '无法创建会话');
} finally {
if (!isCancelled) {
setLoading(false);
}
}
};
createOrGetConversation();
return () => {
isCancelled = true;
};
}, [routeUserId, conversationId, currentUserId, isGroupChat]);
// 加载更多历史消息inverted 下保持阅读锚点)
const loadMoreHistory = useCallback(async () => {
if (!conversationId || !hasMoreHistory || loadingMore) {
return;
}
// 历史加载期间禁止“新消息自动跟随到底”
suppressAutoFollowRef.current = true;
isBrowsingHistoryRef.current = true;
historyLoadingLockUntilRef.current = Date.now() + 3000;
// 保存加载前的滚动位置和内容高度
const scrollYBefore = scrollPositionRef.current.scrollY;
const contentHeightBefore = scrollPositionRef.current.contentHeight;
setLoadingMore(true);
try {
await loadMoreMessages();
// 更新 firstSeq
if (messages.length > 0) {
const minSeq = Math.min(...messages.map(m => m.seq));
setFirstSeq(minSeq);
}
// inverted + maintainVisibleContentPosition 下由列表原生保持位置
// 不做手动 scrollToOffset避免与原生锚点冲突导致回到底部
} catch (error) {
console.error('加载历史消息失败:', error);
} finally {
setLoadingMore(false);
// 加载结束后继续保留短暂锁窗,避免布局结算阶段误触自动回底
historyLoadingLockUntilRef.current = Date.now() + 800;
}
}, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]);
// 列表内容尺寸变化:仅同步内容高度,锚点补偿由 loadMoreHistory 统一处理
const handleMessageListContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
scrollPositionRef.current.contentHeight = contentHeight;
}, []);
const handleReachLatestEdge = useCallback(() => {
if (isHistoryLoadingLocked()) return;
suppressAutoFollowRef.current = false;
isBrowsingHistoryRef.current = false;
}, [isHistoryLoadingLocked]);
/** 用户点击「回到底部」解除浏览历史锁并滚到最新消息端inverted 下 offset=0 */
const jumpToLatestMessages = useCallback(() => {
suppressAutoFollowRef.current = false;
isBrowsingHistoryRef.current = false;
scrollToLatest(true, true, 'jump-latest-button');
}, [scrollToLatest]);
const setBrowsingHistory = useCallback((browsing: boolean) => {
isBrowsingHistoryRef.current = browsing;
}, []);
// 进入聊天详情后立即清未读(不依赖滚动位置)
useEffect(() => {
if (!conversationId) return;
if (!conversation) return;
const unreadCount = Number((conversation as any).unread_count || 0);
if (unreadCount <= 0) return;
const latestFromConversation = Number((conversation as any).last_seq || 0);
const latestFromMessages = messages.length > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
const targetSeq = Math.max(latestFromConversation, latestFromMessages);
if (targetSeq <= 0) return;
const markKey = `${conversationId}:${targetSeq}`;
if (enterMarkedKeyRef.current === markKey) return;
enterMarkedKeyRef.current = markKey;
prevMarkedReadSeqRef.current = Math.max(prevMarkedReadSeqRef.current, targetSeq);
markAsRead(targetSeq).catch(error => {
console.error('[ChatScreen] 进入会话清未读失败:', error);
});
}, [conversationId, conversation, messages, markAsRead]);
// 自动标记已读QQ/Telegram 风格):
// 仅当出现更大的 latest seq且用户在最新端附近时才上报。
// 历史加载/浏览历史不会触发 read。
useEffect(() => {
if (!conversationId || messages.length === 0) return;
if (loading || loadingMore) return;
if (suppressAutoFollowRef.current || isBrowsingHistoryRef.current) return;
if (!isNearBottom()) return;
const latestSeq = Math.max(...messages.map(m => m.seq), 0);
if (latestSeq <= 0) return;
// 没有新增最新消息,不重复上报
if (latestSeq <= prevLatestSeqRef.current) return;
prevLatestSeqRef.current = latestSeq;
// 避免对同一 seq 重复标记
if (latestSeq <= prevMarkedReadSeqRef.current) return;
prevMarkedReadSeqRef.current = latestSeq;
markAsRead(latestSeq).catch(error => {
console.error('[ChatScreen] 自动标记已读失败:', error);
});
}, [messages, conversationId, markAsRead, loading, loadingMore, isNearBottom]);
// 使用 ref 存储 groupMembers
const groupMembersRef = useRef(groupMembers);
useEffect(() => {
groupMembersRef.current = groupMembers;
}, [groupMembers]);
// 使用 ref 存储 currentUserId
const currentUserIdRef = useRef(currentUserId);
useEffect(() => {
currentUserIdRef.current = currentUserId;
}, [currentUserId]);
// 监听键盘事件
useEffect(() => {
const keyboardWillShow = (e: KeyboardEvent) => {
setKeyboardHeight(e.endCoordinates.height);
setActivePanel(prev => prev === 'mention' ? 'mention' : 'none');
};
const keyboardWillHide = () => {
setKeyboardHeight(0);
};
const showSubscription = Keyboard.addListener(
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
keyboardWillShow
);
const hideSubscription = Keyboard.addListener(
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
keyboardWillHide
);
return () => {
showSubscription.remove();
hideSubscription.remove();
};
}, []);
// 格式化时间
const formatTime = useCallback((dateString: string): string => {
try {
const date = new Date(dateString);
const now = new Date();
const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60);
if (diffInHours < 24 && date.getDate() === now.getDate()) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
return formatDistanceToNow(date, {
addSuffix: true,
locale: zhCN,
});
} catch {
return '';
}
}, []);
// 检查是否需要显示时间分隔
const shouldShowTime = useCallback((index: number): boolean => {
if (index === 0) return true;
const currentMsg = messages[index];
const prevMsg = messages[index - 1];
const currentTime = new Date(currentMsg.created_at).getTime();
const prevTime = new Date(prevMsg.created_at).getTime();
return (currentTime - prevTime) > TIME_SEPARATOR_INTERVAL;
}, [messages]);
// 处理输入变化,检测@符号
const handleInputChange = useCallback((text: string) => {
setInputText(text);
if (isGroupChat) {
const lastAtIndex = text.lastIndexOf('@');
if (lastAtIndex !== -1) {
const textAfterAt = text.slice(lastAtIndex + 1);
if (!textAfterAt.includes(' ')) {
setMentionQuery(textAfterAt.toLowerCase());
setActivePanel('mention');
} else {
setActivePanel('none');
}
} else {
setActivePanel('none');
}
}
}, [isGroupChat]);
// 【改造】获取发送者信息(群聊)- 优先从消息的 sender 字段获取
const getSenderInfo = useCallback((senderId: string): SenderInfo => {
if (senderId === currentUserId) {
return {
nickname: currentUser?.nickname || '我',
avatar: currentUser?.avatar || null,
userId: currentUserId,
};
}
// 优先从消息列表中查找 sender 信息
const messageWithSender = messages.find(m => m.sender_id === senderId && m.sender);
if (messageWithSender?.sender) {
return {
nickname: messageWithSender.sender.nickname || messageWithSender.sender.username || '用户',
avatar: messageWithSender.sender.avatar || null,
userId: senderId,
};
}
// 兜底:从群成员列表查找
const member = groupMembers.find(m => m.user_id === senderId);
if (member) {
return {
nickname: member.nickname || member.user?.nickname || '用户',
avatar: member.user?.avatar || null,
userId: member.user_id,
};
}
return {
nickname: '用户',
avatar: null,
userId: senderId,
};
}, [currentUserId, currentUser, messages, groupMembers]);
// 创建消息映射(用于查找被回复的消息)
const messageMap = useMemo(() => {
const map = new Map<string, GroupMessage>();
messages.forEach(msg => {
map.set(String(msg.id), msg);
});
return map;
}, [messages]);
// 选择@用户
const handleSelectMention = useCallback((member: { user_id: string; nickname?: string; user?: { nickname?: string } }) => {
const lastAtIndex = inputText.lastIndexOf('@');
if (lastAtIndex !== -1) {
const textBeforeAt = inputText.slice(0, lastAtIndex);
const displayName = member.nickname || member.user?.nickname || '用户';
const newText = `${textBeforeAt}@${displayName} `;
setInputText(newText);
if (!selectedMentions.includes(member.user_id)) {
setSelectedMentions(prev => [...prev, member.user_id]);
}
}
setActivePanel('none');
setMentionQuery('');
setTimeout(() => {
textInputRef.current?.focus();
}, 100);
}, [inputText, selectedMentions]);
// @所有人
const handleMentionAll = useCallback(() => {
const lastAtIndex = inputText.lastIndexOf('@');
if (lastAtIndex !== -1) {
const textBeforeAt = inputText.slice(0, lastAtIndex);
const newText = `${textBeforeAt}@所有人 `;
setInputText(newText);
setMentionAll(true);
}
setActivePanel('none');
setMentionQuery('');
setTimeout(() => {
textInputRef.current?.focus();
}, 100);
}, [inputText]);
/**
* 构建文本消息的 segments 数组
*/
const buildTextSegments = useCallback((text: string, replyToMessage?: GroupMessage | null): MessageSegment[] => {
const segments: MessageSegment[] = [];
if (replyToMessage) {
segments.push({
type: 'reply',
data: { id: replyToMessage.id, seq: replyToMessage.seq } as ReplySegmentData
});
}
if (mentionAll) {
segments.push({
type: 'at',
data: { user_id: 'all' } as AtSegmentData
});
}
if (selectedMentions.length > 0) {
selectedMentions.forEach(userId => {
segments.push({
type: 'at',
data: { user_id: userId } as AtSegmentData
});
});
}
let strippedText = text;
if (mentionAll) {
strippedText = strippedText.replace(/@所有人\s*/g, '');
}
if (selectedMentions.length > 0) {
selectedMentions.forEach(userId => {
const member = groupMembers.find(m => m.user_id === userId);
const nickname = member?.nickname || member?.user?.nickname || '用户';
const escapedName = nickname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
strippedText = strippedText.replace(new RegExp(`@${escapedName}\s*`, 'g'), '');
});
}
strippedText = strippedText.trim();
if (strippedText) {
segments.push({
type: 'text',
data: { text: strippedText } as TextSegmentData
});
}
return segments;
}, [mentionAll, selectedMentions, groupMembers]);
/**
* 构建图片消息的 segments 数组
*/
const buildImageSegments = useCallback((imageUrl: string, thumbnailUrl?: string, width?: number, height?: number, replyToMessage?: GroupMessage | null): MessageSegment[] => {
const segments: MessageSegment[] = [];
if (replyToMessage) {
segments.push({
type: 'reply',
data: { id: replyToMessage.id, seq: replyToMessage.seq } as ReplySegmentData
});
}
segments.push({
type: 'image',
data: {
url: imageUrl,
thumbnail_url: thumbnailUrl || imageUrl,
width: width,
height: height,
} as ImageSegmentData
});
return segments;
}, []);
// 【新架构】发送消息(支持纯文字、纯多图、图文同条)
const handleSend = useCallback(async () => {
const trimmedText = inputText.trim();
const hasPending = pendingAttachments.length > 0;
if ((!trimmedText && !hasPending) || !conversationId) return;
if (isGroupChat && isMuted) {
if (muteAll) {
Alert.alert('无法发送', '当前群组已开启全员禁言');
} else {
Alert.alert('无法发送', '你已被管理员禁言');
}
return;
}
if (!isGroupChat) {
if (trimmedText && !canSendFirstPrivateText) {
Alert.alert('无法发送', '对方未关注你前,仅允许发送一条消息');
return;
}
if (hasPending && !canSendPrivateImage) {
Alert.alert('无法发送', '对方未关注你前,暂不支持发送图片');
return;
}
}
const uploadedUrls: string[] = [];
if (hasPending) {
setUploadingAttachments(true);
try {
const results = await Promise.all(
pendingAttachments.map(p =>
uploadService.uploadChatImage({ uri: p.uri, type: p.mimeType })
)
);
for (let i = 0; i < results.length; i++) {
const r = results[i];
if (!r?.url) {
Alert.alert('上传失败', `${i + 1} 张图片上传失败,请重试`);
return;
}
uploadedUrls.push(r.url);
}
} catch (error) {
console.error('上传图片失败:', error);
Alert.alert('上传失败', getSendErrorMessage(error, '图片上传失败,请重试'));
return;
} finally {
setUploadingAttachments(false);
}
}
setSending(true);
try {
const segments: MessageSegment[] = [...buildTextSegments(trimmedText, replyingTo)];
for (const url of uploadedUrls) {
segments.push({
type: 'image',
data: {
url,
thumbnail_url: url,
} as ImageSegmentData,
});
}
if (segments.length === 0) {
Alert.alert('无法发送', '消息内容为空');
return;
}
if (isGroupChat && routeGroupId) {
await messageService.sendMessageByAction('group', conversationId, segments);
setInputText('');
setSelectedMentions([]);
setMentionAll(false);
setReplyingTo(null);
setPendingAttachments([]);
} else {
await sendMessageViaManager(segments);
setInputText('');
setSelectedMentions([]);
setMentionAll(false);
setReplyingTo(null);
setPendingAttachments([]);
}
setTimeout(() => {
scrollToLatest(false, false, hasPending ? 'send-mixed' : 'send-text');
}, 100);
} catch (error) {
console.error('发送消息失败:', error);
Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试'));
} finally {
setSending(false);
}
}, [
inputText,
pendingAttachments,
conversationId,
isGroupChat,
routeGroupId,
sendMessageViaManager,
isMuted,
muteAll,
buildTextSegments,
replyingTo,
getSendErrorMessage,
canSendFirstPrivateText,
canSendPrivateImage,
scrollToLatest,
]);
// 选择图片(多选进入输入框待发送,点发送时与文字一并发出)
const handlePickImage = useCallback(async () => {
if (pendingAttachments.length >= MAX_PENDING_CHAT_IMAGES) {
Alert.alert('提示', `最多添加 ${MAX_PENDING_CHAT_IMAGES} 张图片`);
setActivePanel('none');
return;
}
try {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('权限不足', '需要访问相册权限才能选择图片');
return;
}
const remaining = MAX_PENDING_CHAT_IMAGES - pendingAttachments.length;
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: 'images',
allowsEditing: false,
quality: 1,
allowsMultipleSelection: true,
selectionLimit: remaining,
});
if (!result.canceled && result.assets && result.assets.length > 0) {
const newItems: PendingChatAttachment[] = result.assets.map((asset, i) => ({
id: `${Date.now()}-${i}-${Math.random().toString(36).slice(2, 9)}`,
uri: asset.uri,
mimeType: asset.mimeType ?? undefined,
}));
setPendingAttachments(prev => [...prev, ...newItems].slice(0, MAX_PENDING_CHAT_IMAGES));
}
} catch (error) {
console.error('选择图片失败:', error);
Alert.alert('错误', '选择图片失败');
}
setActivePanel('none');
}, [pendingAttachments.length]);
// 拍摄照片(加入待发送队列)
const handleTakePhoto = useCallback(async () => {
if (pendingAttachments.length >= MAX_PENDING_CHAT_IMAGES) {
Alert.alert('提示', `最多添加 ${MAX_PENDING_CHAT_IMAGES} 张图片`);
setActivePanel('none');
return;
}
try {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert('权限不足', '需要相机权限才能拍摄照片');
return;
}
const result = await ImagePicker.launchCameraAsync({
mediaTypes: 'images',
allowsEditing: false,
quality: 0.8,
});
if (!result.canceled && result.assets && result.assets.length > 0) {
const asset = result.assets[0];
setPendingAttachments(prev =>
prev.length >= MAX_PENDING_CHAT_IMAGES
? prev
: [
...prev,
{
id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
uri: asset.uri,
mimeType: asset.mimeType ?? undefined,
},
]
);
}
} catch (error) {
console.error('拍摄照片失败:', error);
Alert.alert('错误', '拍摄照片失败');
}
setActivePanel('none');
}, [pendingAttachments.length]);
// 处理更多功能
const handleMoreAction = useCallback((actionId: string) => {
switch (actionId) {
case 'voice_call':
if (!isGroupChat && otherUser?.id && conversationId) {
callStore.getState().startCall(conversationId, otherUser.id, {
nickname: otherUser.nickname,
avatar: otherUser.avatar,
}, 'voice');
}
setActivePanel('none');
break;
case 'video_call':
if (!isGroupChat && otherUser?.id && conversationId) {
callStore.getState().startCall(conversationId, otherUser.id, {
nickname: otherUser.nickname,
avatar: otherUser.avatar,
}, 'video');
}
setActivePanel('none');
break;
case 'image':
handlePickImage();
break;
case 'camera':
handleTakePhoto();
break;
case 'file':
Alert.alert('提示', '文件功能即将上线');
setActivePanel('none');
break;
case 'location':
Alert.alert('提示', '位置功能即将上线');
setActivePanel('none');
break;
default:
setActivePanel('none');
}
}, [handlePickImage, handleTakePhoto, isGroupChat, otherUser, conversationId]);
// 插入表情
const handleInsertEmoji = useCallback((emoji: string) => {
setInputText(prev => prev + emoji);
}, []);
// 发送自定义表情
const handleSendSticker = useCallback(async (stickerUrl: string) => {
if (!conversationId) return;
if (isGroupChat && isMuted) {
if (muteAll) {
Alert.alert('无法发送', '当前群组已开启全员禁言');
} else {
Alert.alert('无法发送', '你已被管理员禁言');
}
return;
}
if (!isGroupChat && !canSendPrivateImage) {
Alert.alert('无法发送', '对方未关注你前,暂不支持发送图片');
return;
}
setSendingImage(true);
try {
const segments = buildImageSegments(stickerUrl, stickerUrl, undefined, undefined, replyingTo);
if (isGroupChat && routeGroupId) {
await messageService.sendMessageByAction('group', conversationId, segments);
setReplyingTo(null);
} else {
// 【新架构】私聊表情通过 MessageManager 发送
await sendMessageViaManager(segments);
setReplyingTo(null);
}
setTimeout(() => {
scrollToLatest(false, false, 'send-sticker');
}, 100);
} catch (error) {
console.error('发送自定义表情失败:', error);
Alert.alert('发送失败', '自定义表情发送失败,请重试');
} finally {
setSendingImage(false);
}
}, [conversationId, isGroupChat, routeGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, canSendPrivateImage, scrollToLatest]);
// 切换表情面板
const toggleEmojiPanel = useCallback(() => {
Keyboard.dismiss();
setActivePanel(prev => {
const newPanel = prev === 'emoji' ? 'none' : 'emoji';
return newPanel;
});
}, []);
// 切换更多功能面板
const toggleMorePanel = useCallback(() => {
Keyboard.dismiss();
setActivePanel(prev => {
const newPanel = prev === 'more' ? 'none' : 'more';
return newPanel;
});
}, []);
// 关闭面板
const closePanel = useCallback(() => {
setActivePanel('none');
}, []);
// 撤回消息 - 现在通过 MessageManager 处理
const handleRecall = useCallback(async (messageId: string) => {
try {
if (isGroupChat && routeGroupId) {
await messageService.recallMessage(messageId);
} else {
await messageService.recallMessage(messageId);
}
// 不需要手动更新状态MessageManager 会处理撤回事件
} catch (error) {
console.error('撤回消息失败:', error);
Alert.alert('撤回失败', '无法撤回消息');
}
}, [isGroupChat, routeGroupId, conversationId]);
// 长按消息显示操作菜单
const handleLongPressMessage = useCallback((message: GroupMessage, position?: MenuPosition) => {
const isSystemNotice = message.is_system_notice || message.category === 'notification';
if (message.status === 'recalled' || isSystemNotice) return;
setSelectedMessage(message);
setSelectedMessageId(String(message.id));
setMenuPosition(position);
setLongPressMenuVisible(true);
}, []);
// 隐藏长按菜单
const hideLongPressMenu = useCallback(() => {
setLongPressMenuVisible(false);
setSelectedMessage(null);
setSelectedMessageId(null);
setMenuPosition(undefined);
}, []);
// 删除消息
const handleDeleteMessage = useCallback(async (messageId: string) => {
try {
await messageService.deleteMessage(messageId);
// 从本地状态移除MessageManager 不会自动处理删除)
const updatedMessages = messages.filter(m => m.id !== messageId);
// 注意:这里我们依赖 MessageManager 的数据,所以需要通过其他方式刷新
// 暂时只删除本地数据库
await deleteMessageFromDb(messageId);
} catch (error) {
console.error('删除消息失败:', error);
Alert.alert('删除失败', '无法删除消息');
}
}, [messages]);
// 清空会话的所有聊天记录
const handleClearConversation = useCallback(async () => {
if (!conversationId) return;
try {
setLastSeq(0);
setFirstSeq(0);
setHasMoreHistory(true);
await clearConversationMessages(conversationId);
// 刷新消息列表
await refreshMessages();
} catch (error) {
console.error('清空会话失败:', error);
Alert.alert('清空失败', '无法清空聊天记录');
}
}, [conversationId, refreshMessages]);
// 回复消息
const handleReplyMessage = useCallback((message: GroupMessage) => {
setReplyingTo(message);
textInputRef.current?.focus();
hideLongPressMenu();
}, [hideLongPressMenu]);
// 取消回复
const handleCancelReply = useCallback(() => {
setReplyingTo(null);
}, []);
// 点击头像跳转到用户主页
const handleAvatarPress = useCallback((userId: string) => {
if (userId && userId !== currentUserId) {
router.push(hrefs.hrefUserProfile(String(userId)));
}
}, [currentUserId]);
// 长按头像@用户
const handleAvatarLongPress = useCallback((senderId: string, nickname: string) => {
if (!isGroupChat || senderId === currentUserId) return;
if (!selectedMentions.includes(senderId)) {
setSelectedMentions(prev => [...prev, senderId]);
}
const newText = inputText + `@${nickname} `;
setInputText(newText);
setTimeout(() => {
textInputRef.current?.focus();
}, 100);
}, [isGroupChat, currentUserId, inputText, selectedMentions]);
// 获取正在输入提示文本 - 现在从 MessageManager 获取
const getTypingHint = useCallback((): string | null => {
if (groupTypingUsers.length === 0) return null;
const typingNames = groupTypingUsers.map(userId => {
const member = groupMembers.find(m => m.user_id === userId);
return member?.nickname || member?.user?.nickname || '有人';
});
if (typingNames.length === 1) {
return `${typingNames[0]} 正在输入...`;
} else if (typingNames.length <= 3) {
return `${typingNames.join('、')} 正在输入...`;
} else {
return `${typingNames.slice(0, 3).join('、')}${groupTypingUsers.length} 人正在输入...`;
}
}, [groupTypingUsers, groupMembers]);
// 计算底部面板高度
const getPanelHeight = useCallback(() => {
if (activePanel === 'none') return 0;
if (activePanel === 'mention') return 250;
return 350;
}, [activePanel]);
// 计算输入框的bottom值
const getInputBottom = useCallback(() => {
if (keyboardHeight > 0) return keyboardHeight;
if (activePanel !== 'none') return getPanelHeight();
return 0;
}, [keyboardHeight, activePanel, getPanelHeight]);
// 计算消息列表的底部padding
const getListPaddingBottom = useCallback(() => {
if (activePanel !== 'none' && keyboardHeight === 0) return getPanelHeight();
return 0;
}, [keyboardHeight, activePanel, getPanelHeight]);
// 关闭所有面板
const handleDismiss = useCallback(() => {
if (activePanel !== 'none') {
Keyboard.dismiss();
setActivePanel('none');
}
}, [activePanel]);
// 导航到群组信息或用户资料
const navigateToInfo = useCallback(() => {
if (isGroupChat && routeGroupId) {
router.push(hrefs.hrefGroupInfo(routeGroupId, conversationId || undefined));
} else if (otherUser?.id) {
router.push(hrefs.hrefUserProfile(String(otherUser.id)));
}
}, [isGroupChat, routeGroupId, otherUser, conversationId]);
// 导航到聊天管理页面
const navigateToChatSettings = useCallback(() => {
if (isGroupChat && routeGroupId) {
router.push(hrefs.hrefGroupInfo(routeGroupId, conversationId || undefined));
} else if (otherUser?.id && conversationId) {
router.push(
hrefs.hrefPrivateChatInfo({
conversationId,
userId: String(otherUser.id),
userName: otherUser.nickname,
userAvatar: otherUser.avatar,
})
);
}
}, [isGroupChat, routeGroupId, otherUser, conversationId]);
return {
// 状态
messages,
conversationId,
inputText,
otherUser,
currentUser,
currentUserId,
keyboardHeight,
loading,
sending,
activePanel,
sendingImage,
uploadingAttachments,
pendingAttachments,
isComposerBusy,
replyingTo,
longPressMenuVisible,
selectedMessage,
selectedMessageId,
setSelectedMessageId,
menuPosition,
isGroupChat,
groupInfo,
groupMembers,
// 【改造】使用 MessageManager 的输入状态
typingUsers: groupTypingUsers,
currentUserRole,
mentionQuery,
selectedMentions,
isMuted,
muteAll,
followRestrictionHint,
canSendPrivateImage,
routeGroupId,
routeGroupName,
otherUserLastReadSeq,
messageMap,
loadingMore,
hasMoreHistory,
// Refs
flatListRef,
textInputRef,
scrollPositionRef,
// 方法
formatTime,
shouldShowTime,
handleInputChange,
handleSend,
handlePickImage,
removePendingAttachment,
handleTakePhoto,
handleMoreAction,
handleInsertEmoji,
handleSendSticker,
toggleEmojiPanel,
toggleMorePanel,
closePanel,
handleRecall,
handleLongPressMessage,
hideLongPressMenu,
handleDeleteMessage,
handleReplyMessage,
handleCancelReply,
handleAvatarPress,
handleAvatarLongPress,
handleSelectMention,
handleMentionAll,
getSenderInfo,
getTypingHint,
getPanelHeight,
getInputBottom,
getListPaddingBottom,
handleDismiss,
navigateToInfo,
navigateToChatSettings,
loadMoreHistory,
handleClearConversation,
handleMessageListContentSizeChange,
handleReachLatestEdge,
jumpToLatestMessages,
setBrowsingHistory,
};
};