2026-03-09 21:29:03 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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 { useRoute, RouteProp, useNavigation } from '@react-navigation/native';
|
|
|
|
|
|
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 } from '../../../../stores';
|
|
|
|
|
|
import { groupService } from '../../../../services/groupService';
|
|
|
|
|
|
import { userManager } from '../../../../stores/userManager';
|
|
|
|
|
|
import { groupManager } from '../../../../stores/groupManager';
|
|
|
|
|
|
import { RootStackParamList } from '../../../../navigation/types';
|
2026-03-18 14:52:58 +08:00
|
|
|
|
import { navigationService } from '../../../../infrastructure/navigation/navigationService';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
import {
|
|
|
|
|
|
GroupMessage,
|
|
|
|
|
|
PanelType,
|
|
|
|
|
|
UserRole,
|
|
|
|
|
|
SenderInfo,
|
|
|
|
|
|
ChatRouteParams,
|
|
|
|
|
|
MenuPosition,
|
|
|
|
|
|
} from './types';
|
|
|
|
|
|
import { TIME_SEPARATOR_INTERVAL, MEMBERS_PAGE_SIZE } from './constants';
|
|
|
|
|
|
import {
|
|
|
|
|
|
deleteMessage as deleteMessageFromDb,
|
|
|
|
|
|
clearConversationMessages,
|
|
|
|
|
|
} from '../../../../services/database';
|
|
|
|
|
|
|
|
|
|
|
|
type ChatRouteProp = RouteProp<RootStackParamList, 'Chat'>;
|
|
|
|
|
|
|
|
|
|
|
|
export const useChatScreen = () => {
|
|
|
|
|
|
const getSendErrorMessage = useCallback((error: unknown, fallback: string) => {
|
|
|
|
|
|
if (error instanceof ApiError && error.message) {
|
|
|
|
|
|
return error.message;
|
|
|
|
|
|
}
|
|
|
|
|
|
return fallback;
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const route = useRoute<ChatRouteProp>();
|
|
|
|
|
|
const navigation = useNavigation();
|
|
|
|
|
|
|
|
|
|
|
|
// 路由参数
|
|
|
|
|
|
const routeParams = useMemo(() => ({
|
|
|
|
|
|
conversationId: route.params?.conversationId || null,
|
|
|
|
|
|
userId: route.params?.userId || null,
|
|
|
|
|
|
isGroupChat: route.params?.isGroupChat || false,
|
|
|
|
|
|
groupId: route.params?.groupId,
|
|
|
|
|
|
groupName: route.params?.groupName,
|
|
|
|
|
|
}), [route.params?.conversationId, route.params?.userId, route.params?.isGroupChat, route.params?.groupId, route.params?.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 [loadingMore, setLoadingMore] = useState(false);
|
|
|
|
|
|
const [hasMoreHistory, setHasMoreHistory] = useState(true);
|
|
|
|
|
|
const flatListRef = useRef<any>(null);
|
|
|
|
|
|
const textInputRef = useRef<any>(null);
|
|
|
|
|
|
|
2026-03-23 23:06:19 +08:00
|
|
|
|
// 滚动状态机 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);
|
2026-03-24 01:19:09 +08:00
|
|
|
|
const enterMarkedKeyRef = useRef<string>('');
|
2026-03-23 23:06:19 +08:00
|
|
|
|
const isProgrammaticScrollRef = useRef(false);
|
|
|
|
|
|
const suppressAutoFollowRef = useRef(false);
|
|
|
|
|
|
const isBrowsingHistoryRef = useRef(false);
|
2026-03-24 04:23:13 +08:00
|
|
|
|
const hasShownMessageListRef = useRef(false);
|
|
|
|
|
|
const historyLoadingLockUntilRef = useRef(0);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 回复消息状态
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-03-23 23:06:19 +08:00
|
|
|
|
// 加载态语义修正:
|
|
|
|
|
|
// isLoadingMessages 在分页加载历史时也会短暂为 true。
|
|
|
|
|
|
// 若直接驱动 ChatScreen 的 loading,会导致消息列表被卸载重挂载,触发“回到底部”。
|
2026-03-24 04:23:13 +08:00
|
|
|
|
// 这里只在“首屏且尚未展示过消息列表”时展示 loading 占位。
|
2026-03-09 21:29:03 +08:00
|
|
|
|
useEffect(() => {
|
2026-03-24 04:23:13 +08:00
|
|
|
|
if (messageManagerMessages.length > 0) {
|
|
|
|
|
|
hasShownMessageListRef.current = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
const shouldShowInitialLoading =
|
|
|
|
|
|
!hasShownMessageListRef.current &&
|
|
|
|
|
|
isLoadingMessages &&
|
|
|
|
|
|
messageManagerMessages.length === 0;
|
|
|
|
|
|
setLoading(shouldShowInitialLoading);
|
2026-03-23 23:06:19 +08:00
|
|
|
|
}, [isLoadingMessages, messageManagerMessages.length]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 【改造】同步 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]);
|
|
|
|
|
|
|
2026-03-23 23:06:19 +08:00
|
|
|
|
// 进入新会话时重置滚动状态
|
2026-03-09 21:29:03 +08:00
|
|
|
|
useEffect(() => {
|
2026-03-23 23:06:19 +08:00
|
|
|
|
hasInitialAnchorDoneRef.current = false;
|
|
|
|
|
|
prevMessageCountRef.current = 0;
|
|
|
|
|
|
prevLatestSeqRef.current = 0;
|
|
|
|
|
|
prevMarkedReadSeqRef.current = 0;
|
2026-03-24 01:19:09 +08:00
|
|
|
|
enterMarkedKeyRef.current = '';
|
2026-03-23 23:06:19 +08:00
|
|
|
|
suppressAutoFollowRef.current = false;
|
|
|
|
|
|
isBrowsingHistoryRef.current = false;
|
2026-03-24 04:23:13 +08:00
|
|
|
|
hasShownMessageListRef.current = false;
|
|
|
|
|
|
historyLoadingLockUntilRef.current = 0;
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}, [conversationId]);
|
|
|
|
|
|
|
2026-03-24 04:23:13 +08:00
|
|
|
|
const isHistoryLoadingLocked = useCallback(() => {
|
|
|
|
|
|
return Date.now() < historyLoadingLockUntilRef.current;
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-03-23 23:06:19 +08:00
|
|
|
|
const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => {
|
2026-03-24 04:23:13 +08:00
|
|
|
|
if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked())) return;
|
2026-03-23 23:06:19 +08:00
|
|
|
|
// inverted 列表下,最新消息端对应 offset=0
|
|
|
|
|
|
isProgrammaticScrollRef.current = true;
|
|
|
|
|
|
flatListRef.current?.scrollToOffset({
|
|
|
|
|
|
offset: 0,
|
|
|
|
|
|
animated,
|
|
|
|
|
|
});
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
isProgrammaticScrollRef.current = false;
|
|
|
|
|
|
}, animated ? 220 : 32);
|
2026-03-24 04:23:13 +08:00
|
|
|
|
}, [flatListRef, isHistoryLoadingLocked]);
|
2026-03-23 23:06:19 +08:00
|
|
|
|
|
|
|
|
|
|
const isNearBottom = useCallback(() => {
|
|
|
|
|
|
const scrollY = scrollPositionRef.current.scrollY || 0;
|
|
|
|
|
|
// inverted 列表下,越接近 0 越靠近最新消息端
|
|
|
|
|
|
return scrollY <= 100;
|
|
|
|
|
|
}, [scrollPositionRef]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
2026-03-23 23:06:19 +08:00
|
|
|
|
// 首屏加载完成后仅锚定一次到最新消息端
|
2026-03-09 21:29:03 +08:00
|
|
|
|
useEffect(() => {
|
2026-03-23 23:06:19 +08:00
|
|
|
|
if (
|
|
|
|
|
|
loading ||
|
|
|
|
|
|
loadingMore ||
|
|
|
|
|
|
messages.length === 0 ||
|
|
|
|
|
|
hasInitialAnchorDoneRef.current ||
|
|
|
|
|
|
scrollPositionRef.current.viewportHeight <= 0
|
|
|
|
|
|
) {
|
|
|
|
|
|
return;
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
2026-03-23 23:06:19 +08:00
|
|
|
|
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]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 获取当前用户信息
|
|
|
|
|
|
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) {
|
|
|
|
|
|
console.error('获取群组信息失败:', error);
|
|
|
|
|
|
Alert.alert('错误', '无法获取群组信息');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-03-23 23:06:19 +08:00
|
|
|
|
// 加载更多历史消息(inverted 下保持阅读锚点)
|
2026-03-09 21:29:03 +08:00
|
|
|
|
const loadMoreHistory = useCallback(async () => {
|
|
|
|
|
|
if (!conversationId || !hasMoreHistory || loadingMore) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 23:06:19 +08:00
|
|
|
|
// 历史加载期间禁止“新消息自动跟随到底”
|
|
|
|
|
|
suppressAutoFollowRef.current = true;
|
2026-03-24 04:23:13 +08:00
|
|
|
|
isBrowsingHistoryRef.current = true;
|
|
|
|
|
|
historyLoadingLockUntilRef.current = Date.now() + 3000;
|
2026-03-20 19:28:42 +08:00
|
|
|
|
|
|
|
|
|
|
// 保存加载前的滚动位置和内容高度
|
|
|
|
|
|
const scrollYBefore = scrollPositionRef.current.scrollY;
|
|
|
|
|
|
const contentHeightBefore = scrollPositionRef.current.contentHeight;
|
2026-03-09 21:29:03 +08:00
|
|
|
|
setLoadingMore(true);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await loadMoreMessages();
|
|
|
|
|
|
|
|
|
|
|
|
// 更新 firstSeq
|
|
|
|
|
|
if (messages.length > 0) {
|
|
|
|
|
|
const minSeq = Math.min(...messages.map(m => m.seq));
|
|
|
|
|
|
setFirstSeq(minSeq);
|
|
|
|
|
|
}
|
2026-03-20 19:28:42 +08:00
|
|
|
|
|
2026-03-23 23:06:19 +08:00
|
|
|
|
// inverted + maintainVisibleContentPosition 下由列表原生保持位置
|
|
|
|
|
|
// 不做手动 scrollToOffset,避免与原生锚点冲突导致回到底部
|
2026-03-09 21:29:03 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('加载历史消息失败:', error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingMore(false);
|
2026-03-24 04:23:13 +08:00
|
|
|
|
// 加载结束后继续保留短暂锁窗,避免布局结算阶段误触自动回底
|
|
|
|
|
|
historyLoadingLockUntilRef.current = Date.now() + 800;
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
}, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]);
|
|
|
|
|
|
|
2026-03-23 23:06:19 +08:00
|
|
|
|
// 列表内容尺寸变化:仅同步内容高度,锚点补偿由 loadMoreHistory 统一处理
|
2026-03-09 21:29:03 +08:00
|
|
|
|
const handleMessageListContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
|
|
|
|
|
|
scrollPositionRef.current.contentHeight = contentHeight;
|
2026-03-23 23:06:19 +08:00
|
|
|
|
}, []);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
2026-03-23 23:06:19 +08:00
|
|
|
|
const handleReachLatestEdge = useCallback(() => {
|
2026-03-24 04:23:13 +08:00
|
|
|
|
if (isHistoryLoadingLocked()) return;
|
2026-03-23 23:06:19 +08:00
|
|
|
|
suppressAutoFollowRef.current = false;
|
|
|
|
|
|
isBrowsingHistoryRef.current = false;
|
2026-03-24 04:23:13 +08:00
|
|
|
|
}, [isHistoryLoadingLocked]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
2026-03-23 23:06:19 +08:00
|
|
|
|
const setBrowsingHistory = useCallback((browsing: boolean) => {
|
|
|
|
|
|
isBrowsingHistoryRef.current = browsing;
|
|
|
|
|
|
}, []);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
2026-03-24 01:19:09 +08:00
|
|
|
|
// 进入聊天详情后立即清未读(不依赖滚动位置)
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-03-23 23:06:19 +08:00
|
|
|
|
// 自动标记已读(QQ/Telegram 风格):
|
|
|
|
|
|
// 仅当出现更大的 latest seq,且用户在最新端附近时才上报。
|
|
|
|
|
|
// 历史加载/浏览历史不会触发 read。
|
2026-03-09 21:29:03 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!conversationId || messages.length === 0) return;
|
2026-03-23 23:06:19 +08:00
|
|
|
|
if (loading || loadingMore) return;
|
|
|
|
|
|
if (suppressAutoFollowRef.current || isBrowsingHistoryRef.current) return;
|
|
|
|
|
|
if (!isNearBottom()) return;
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
2026-03-23 23:06:19 +08:00
|
|
|
|
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]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 使用 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();
|
|
|
|
|
|
};
|
2026-03-24 01:19:09 +08:00
|
|
|
|
}, []);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 格式化时间
|
|
|
|
|
|
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();
|
|
|
|
|
|
if (!trimmedText || !conversationId) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (isGroupChat && isMuted) {
|
|
|
|
|
|
if (muteAll) {
|
|
|
|
|
|
Alert.alert('无法发送', '当前群组已开启全员禁言');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Alert.alert('无法发送', '你已被管理员禁言');
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!isGroupChat && !canSendFirstPrivateText) {
|
|
|
|
|
|
Alert.alert('无法发送', '对方未关注你前,仅允许发送一条消息');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setSending(true);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const segments = buildTextSegments(trimmedText, replyingTo);
|
|
|
|
|
|
|
|
|
|
|
|
if (isGroupChat && routeGroupId) {
|
2026-03-10 12:58:23 +08:00
|
|
|
|
await messageService.sendMessageByAction('group', conversationId, segments);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
setInputText('');
|
|
|
|
|
|
setSelectedMentions([]);
|
|
|
|
|
|
setMentionAll(false);
|
|
|
|
|
|
setReplyingTo(null);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 【新架构】私聊消息通过 MessageManager 发送
|
|
|
|
|
|
await sendMessageViaManager(segments);
|
|
|
|
|
|
setInputText('');
|
|
|
|
|
|
setReplyingTo(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
2026-03-23 23:06:19 +08:00
|
|
|
|
scrollToLatest(false, false, 'send-text');
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}, 100);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('发送消息失败:', error);
|
|
|
|
|
|
Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试'));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSending(false);
|
|
|
|
|
|
}
|
2026-03-23 23:06:19 +08:00
|
|
|
|
}, [inputText, conversationId, isGroupChat, routeGroupId, selectedMentions, mentionAll, sendMessageViaManager, isMuted, muteAll, buildTextSegments, replyingTo, getSendErrorMessage, canSendFirstPrivateText, scrollToLatest]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 发送图片消息
|
|
|
|
|
|
const handleSendImage = useCallback(async (imageUri: string, mimeType?: 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 uploadResult = await uploadService.uploadChatImage({ uri: imageUri, type: mimeType });
|
|
|
|
|
|
|
|
|
|
|
|
if (!uploadResult) {
|
|
|
|
|
|
Alert.alert('上传失败', '图片上传失败,请重试');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const segments = buildImageSegments(uploadResult.url, uploadResult.url, undefined, undefined, replyingTo);
|
|
|
|
|
|
|
|
|
|
|
|
if (isGroupChat && routeGroupId) {
|
2026-03-10 12:58:23 +08:00
|
|
|
|
await messageService.sendMessageByAction('group', conversationId, segments);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
setReplyingTo(null);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 【新架构】私聊图片通过 MessageManager 发送
|
|
|
|
|
|
await sendMessageViaManager(segments);
|
|
|
|
|
|
setReplyingTo(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
2026-03-23 23:06:19 +08:00
|
|
|
|
scrollToLatest(false, false, 'send-image');
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}, 100);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('发送图片失败:', error);
|
|
|
|
|
|
Alert.alert('发送失败', getSendErrorMessage(error, '图片发送失败,请重试'));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSendingImage(false);
|
|
|
|
|
|
}
|
2026-03-23 23:06:19 +08:00
|
|
|
|
}, [conversationId, isGroupChat, routeGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, getSendErrorMessage, canSendPrivateImage, scrollToLatest]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 选择图片
|
|
|
|
|
|
const handlePickImage = useCallback(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
|
|
|
|
|
|
|
|
|
|
if (status !== 'granted') {
|
|
|
|
|
|
Alert.alert('权限不足', '需要访问相册权限才能选择图片');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const result = await ImagePicker.launchImageLibraryAsync({
|
|
|
|
|
|
mediaTypes: 'images',
|
|
|
|
|
|
allowsEditing: false,
|
|
|
|
|
|
quality: 1,
|
|
|
|
|
|
allowsMultipleSelection: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!result.canceled && result.assets && result.assets.length > 0) {
|
|
|
|
|
|
const asset = result.assets[0];
|
|
|
|
|
|
await handleSendImage(asset.uri, asset.mimeType ?? undefined);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('选择图片失败:', error);
|
|
|
|
|
|
Alert.alert('错误', '选择图片失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setActivePanel('none');
|
|
|
|
|
|
}, [handleSendImage]);
|
|
|
|
|
|
|
|
|
|
|
|
// 拍摄照片
|
|
|
|
|
|
const handleTakePhoto = useCallback(async () => {
|
|
|
|
|
|
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];
|
|
|
|
|
|
await handleSendImage(asset.uri, asset.mimeType ?? undefined);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('拍摄照片失败:', error);
|
|
|
|
|
|
Alert.alert('错误', '拍摄照片失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setActivePanel('none');
|
|
|
|
|
|
}, [handleSendImage]);
|
|
|
|
|
|
|
|
|
|
|
|
// 处理更多功能
|
|
|
|
|
|
const handleMoreAction = useCallback((actionId: string) => {
|
|
|
|
|
|
switch (actionId) {
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
|
|
|
|
|
// 插入表情
|
|
|
|
|
|
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) {
|
2026-03-10 12:58:23 +08:00
|
|
|
|
await messageService.sendMessageByAction('group', conversationId, segments);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
setReplyingTo(null);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 【新架构】私聊表情通过 MessageManager 发送
|
|
|
|
|
|
await sendMessageViaManager(segments);
|
|
|
|
|
|
setReplyingTo(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
2026-03-23 23:06:19 +08:00
|
|
|
|
scrollToLatest(false, false, 'send-sticker');
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}, 100);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('发送自定义表情失败:', error);
|
|
|
|
|
|
Alert.alert('发送失败', '自定义表情发送失败,请重试');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSendingImage(false);
|
|
|
|
|
|
}
|
2026-03-23 23:06:19 +08:00
|
|
|
|
}, [conversationId, isGroupChat, routeGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, canSendPrivateImage, scrollToLatest]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 切换表情面板
|
|
|
|
|
|
const toggleEmojiPanel = useCallback(() => {
|
|
|
|
|
|
Keyboard.dismiss();
|
|
|
|
|
|
setActivePanel(prev => {
|
|
|
|
|
|
const newPanel = prev === 'emoji' ? 'none' : 'emoji';
|
|
|
|
|
|
return newPanel;
|
|
|
|
|
|
});
|
2026-03-24 01:19:09 +08:00
|
|
|
|
}, []);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 切换更多功能面板
|
|
|
|
|
|
const toggleMorePanel = useCallback(() => {
|
|
|
|
|
|
Keyboard.dismiss();
|
|
|
|
|
|
setActivePanel(prev => {
|
|
|
|
|
|
const newPanel = prev === 'more' ? 'none' : 'more';
|
|
|
|
|
|
return newPanel;
|
|
|
|
|
|
});
|
2026-03-24 01:19:09 +08:00
|
|
|
|
}, []);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 关闭面板
|
|
|
|
|
|
const closePanel = useCallback(() => {
|
|
|
|
|
|
setActivePanel('none');
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// 撤回消息 - 现在通过 MessageManager 处理
|
|
|
|
|
|
const handleRecall = useCallback(async (messageId: string) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (isGroupChat && routeGroupId) {
|
2026-03-10 12:58:23 +08:00
|
|
|
|
await messageService.recallMessage(messageId);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
} 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) {
|
2026-03-18 14:52:58 +08:00
|
|
|
|
navigationService.navigate('UserProfile', { userId: String(userId) });
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
2026-03-18 14:52:58 +08:00
|
|
|
|
}, [currentUserId]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 长按头像@用户
|
|
|
|
|
|
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) {
|
|
|
|
|
|
navigation.navigate('GroupInfo' as any, {
|
|
|
|
|
|
groupId: routeGroupId,
|
|
|
|
|
|
conversationId: conversationId || undefined,
|
|
|
|
|
|
});
|
|
|
|
|
|
} else if (otherUser?.id) {
|
2026-03-18 14:52:58 +08:00
|
|
|
|
navigationService.navigate('UserProfile', { userId: String(otherUser.id) });
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
}, [navigation, isGroupChat, routeGroupId, otherUser]);
|
|
|
|
|
|
|
|
|
|
|
|
// 导航到聊天管理页面
|
|
|
|
|
|
const navigateToChatSettings = useCallback(() => {
|
|
|
|
|
|
if (isGroupChat && routeGroupId) {
|
|
|
|
|
|
navigation.navigate('GroupInfo' as any, {
|
|
|
|
|
|
groupId: routeGroupId,
|
|
|
|
|
|
conversationId: conversationId || undefined,
|
|
|
|
|
|
});
|
|
|
|
|
|
} else if (otherUser?.id && conversationId) {
|
|
|
|
|
|
navigation.navigate('PrivateChatInfo' as any, {
|
|
|
|
|
|
conversationId: conversationId,
|
|
|
|
|
|
userId: String(otherUser.id),
|
|
|
|
|
|
userName: otherUser.nickname,
|
|
|
|
|
|
userAvatar: otherUser.avatar,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [navigation, isGroupChat, routeGroupId, otherUser, conversationId]);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
// 状态
|
|
|
|
|
|
messages,
|
|
|
|
|
|
conversationId,
|
|
|
|
|
|
inputText,
|
|
|
|
|
|
otherUser,
|
|
|
|
|
|
currentUser,
|
|
|
|
|
|
currentUserId,
|
|
|
|
|
|
keyboardHeight,
|
|
|
|
|
|
loading,
|
|
|
|
|
|
sending,
|
|
|
|
|
|
activePanel,
|
|
|
|
|
|
sendingImage,
|
|
|
|
|
|
replyingTo,
|
|
|
|
|
|
longPressMenuVisible,
|
|
|
|
|
|
selectedMessage,
|
|
|
|
|
|
selectedMessageId,
|
2026-03-24 04:23:13 +08:00
|
|
|
|
setSelectedMessageId,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
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,
|
|
|
|
|
|
handleSendImage,
|
|
|
|
|
|
handlePickImage,
|
|
|
|
|
|
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,
|
2026-03-23 23:06:19 +08:00
|
|
|
|
handleReachLatestEdge,
|
|
|
|
|
|
setBrowsingHistory,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
};
|
|
|
|
|
|
};
|