- 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.
1428 lines
47 KiB
TypeScript
1428 lines
47 KiB
TypeScript
/**
|
||
* 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);
|
||
|
||
// 滚动状态机 refs(Telegram/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,
|
||
};
|
||
};
|