Files
frontend/src/screens/message/components/ChatScreen/useChatScreen.ts

1425 lines
47 KiB
TypeScript
Raw Normal View History

/**
* 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';
2026-04-13 01:30:37 +08:00
import { messageService } from '@/services/message';
import { uploadService } from '@/services/upload';
import { ApiError } from '@/services/core';
// 【新架构】使用 MessageManager
import { useChat, useGroupTyping, useGroupMuted, messageManager, callStore } from '../../../../stores';
2026-04-13 01:30:37 +08:00
import { groupService } from '@/services/message';
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 { messageRepository } from '@/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.sender,
is_system_notice: m.is_system_notice,
notice_content: m.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.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.is_following_me === 'boolean') {
setIsFollowedByOther(detail.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 || isBrowsingHistoryRef.current || isHistoryLoadingLocked()) return;
if (!isNearBottom()) return;
const timer = setTimeout(() => {
scrollToLatest(false, false, 'new-message-follow');
}, 0);
return () => clearTimeout(timer);
}, [messages, loading, loadingMore, isNearBottom, scrollToLatest, isHistoryLoadingLocked]);
// 获取当前用户信息
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.unread_count || 0);
if (unreadCount <= 0) return;
const latestFromConversation = Number(conversation.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 messageRepository.delete(messageId);
} catch (error) {
console.error('删除消息失败:', error);
Alert.alert('删除失败', '无法删除消息');
}
}, [messages]);
// 清空会话的所有聊天记录
const handleClearConversation = useCallback(async () => {
if (!conversationId) return;
try {
setLastSeq(0);
setFirstSeq(0);
setHasMoreHistory(true);
await messageRepository.clearConversation(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(() => {
Keyboard.dismiss();
if (activePanel !== 'none') {
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,
};
};