Add lazy loading pipeline for reply message previews that checks memory → SQLite → server in order, enabling offline hit and graceful degradation for deleted/inaccessible messages. Includes `getReplyMessage` API endpoint and `jumpToMessageSeq` helper for navigation to referenced messages.
feat(create): extract MomentComposer and LongPostComposer for dual posting modes
Refactor CreatePostScreen shell to delegate content editing to specialized composers based on postMode ('moment' or 'long'), enabling long posts with voting support. Add expandable FAB with mode selection on HomeScreen.
fix(navigation): use navigate instead of push to prevent duplicate chat instances
Replace router.push with router.navigate in notification bootstrap to avoid creating duplicate chat instances when already on the chat screen.
fix(ui): update author badge styling with proper text contrast
Change author badge background to use hex transparency and add dedicated text color for better readability on both light and dark themes.
chore: normalize filename handling in file uploads and improve file segment rendering
Use asset.name as authoritative filename, remove redundant shadows from file cards inside bubbles.
1745 lines
60 KiB
TypeScript
1745 lines
60 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 { formatChatTime } from '@/utils/formatTime';
|
||
import * as ImagePicker from 'expo-image-picker';
|
||
import * as DocumentPicker from 'expo-document-picker';
|
||
import { Linking } from 'react-native';
|
||
import { GroupMemberResponse, MessageSegment, TextSegmentData, ImageSegmentData, FileSegmentData, AtSegmentData, ReplySegmentData, MessageStatus } from '../../../../types/dto';
|
||
import { messageService } from '@/services/message';
|
||
import { uploadService } from '@/services/upload';
|
||
import { ApiError } from '@/services/core';
|
||
// 【新架构】使用 MessageManager
|
||
import { useChat, useGroupTyping, useGroupMuted, messageManager, callStore } from '../../../../stores';
|
||
import { useMessageStore } from '../../../../stores/message';
|
||
import { groupService } from '@/services/message';
|
||
import { userManager } from '../../../../stores/user';
|
||
import { groupManager } from '../../../../stores/group';
|
||
import * as hrefs from '../../../../navigation/hrefs';
|
||
import { firstRouteParam } from '../../../../navigation/paramUtils';
|
||
import {
|
||
GroupMessage,
|
||
PanelType,
|
||
UserRole,
|
||
SenderInfo,
|
||
ChatRouteParams,
|
||
MenuPosition,
|
||
PendingChatAttachment,
|
||
ChatScreenProps,
|
||
} from './types';
|
||
import { TIME_SEPARATOR_INTERVAL, MEMBERS_PAGE_SIZE, MAX_PENDING_CHAT_IMAGES } from './constants';
|
||
import { messageRepository } from '@/database';
|
||
import { useReplyMessage } from './useReplyMessage';
|
||
|
||
interface MentionRange {
|
||
start: number;
|
||
end: number;
|
||
userId: string;
|
||
}
|
||
|
||
function getMentionRanges(
|
||
text: string,
|
||
selectedMentions: string[],
|
||
mentionAll: boolean,
|
||
groupMembers: GroupMemberResponse[]
|
||
): MentionRange[] {
|
||
const ranges: MentionRange[] = [];
|
||
|
||
if (mentionAll) {
|
||
const pattern = /@所有人\s*/g;
|
||
let match: RegExpExecArray | null;
|
||
while ((match = pattern.exec(text)) !== null) {
|
||
ranges.push({ start: match.index, end: match.index + match[0].length, userId: 'all' });
|
||
}
|
||
}
|
||
|
||
for (const userId of selectedMentions) {
|
||
const member = groupMembers.find(m => m.user_id === userId);
|
||
const nickname = member?.nickname || member?.user?.nickname;
|
||
if (!nickname) continue;
|
||
const escapedName = nickname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
const pattern = new RegExp(`@${escapedName}\\s*`, 'g');
|
||
let match: RegExpExecArray | null;
|
||
while ((match = pattern.exec(text)) !== null) {
|
||
ranges.push({ start: match.index, end: match.index + match[0].length, userId });
|
||
}
|
||
}
|
||
|
||
ranges.sort((a, b) => a.start - b.start);
|
||
return ranges;
|
||
}
|
||
|
||
export const useChatScreen = (props?: ChatScreenProps) => {
|
||
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[];
|
||
groupAvatar?: string | string[];
|
||
scrollToSeq?: string | string[];
|
||
}>();
|
||
// 路由参数(动态段 + query);群 ID 勿用 Number(),避免超过 2^53-1 时精度丢失
|
||
// 嵌入式模式下优先使用 props,否则从路由参数获取
|
||
const routeParams = useMemo(() => {
|
||
if (props?.embeddedConversationId) {
|
||
return {
|
||
conversationId: props.embeddedConversationId,
|
||
userId: null as string | null,
|
||
isGroupChat: props.embeddedIsGroupChat ?? false,
|
||
groupId: props.embeddedGroupId ?? null,
|
||
groupName: props.embeddedGroupName ?? null,
|
||
groupAvatar: null as string | null,
|
||
};
|
||
}
|
||
const isGroupFlag = firstRouteParam(rawParams.isGroupChat);
|
||
const scrollToSeqParam = firstRouteParam(rawParams.scrollToSeq);
|
||
return {
|
||
conversationId: firstRouteParam(rawParams.conversationId) ?? null,
|
||
userId: firstRouteParam(rawParams.userId) ?? null,
|
||
isGroupChat: isGroupFlag === '1' || isGroupFlag === 'true',
|
||
groupId: firstRouteParam(rawParams.groupId),
|
||
groupName: firstRouteParam(rawParams.groupName),
|
||
groupAvatar: firstRouteParam(rawParams.groupAvatar),
|
||
scrollToSeq: scrollToSeqParam ? Number(scrollToSeqParam) : null,
|
||
};
|
||
}, [
|
||
props?.embeddedConversationId,
|
||
props?.embeddedIsGroupChat,
|
||
props?.embeddedGroupId,
|
||
props?.embeddedGroupName,
|
||
rawParams.conversationId,
|
||
rawParams.userId,
|
||
rawParams.isGroupChat,
|
||
rawParams.groupId,
|
||
rawParams.groupName,
|
||
rawParams.groupAvatar,
|
||
rawParams.scrollToSeq,
|
||
]);
|
||
|
||
const { conversationId: routeConversationId, userId: routeUserId, isGroupChat, groupId: routeGroupId, groupName: routeGroupName, groupAvatar: routeGroupAvatar, scrollToSeq: routeScrollToSeq } = routeParams;
|
||
|
||
// 当前会话ID(可能在新建私聊会话后动态更新)
|
||
const [conversationId, setConversationId] = useState<string | null>(routeConversationId);
|
||
|
||
// 【新架构】使用 MessageManager 的 hook 获取所有数据
|
||
const {
|
||
messages: messageManagerMessages,
|
||
isLoadingMessages,
|
||
hasMoreMessages,
|
||
loadMoreMessages,
|
||
refreshMessages,
|
||
sendMessage: sendMessageViaManager,
|
||
retrySendMessage: retrySendMessageViaManager,
|
||
isSending: isSendingViaManager,
|
||
markAsRead,
|
||
conversation,
|
||
} = useChat(conversationId);
|
||
|
||
// 统一入口:优先路由参数,其次从 conversation 对象提取(JPUSH 进入时 conversation 异步加载)
|
||
const effectiveGroupId = routeGroupId || (isGroupChat && conversation?.group?.id) || null;
|
||
const effectiveGroupName = routeGroupName || (isGroupChat && conversation?.group?.name) || null;
|
||
const effectiveGroupAvatar = routeGroupAvatar || (isGroupChat && conversation?.group?.avatar) || null;
|
||
|
||
// 本地状态
|
||
const [currentUserId, setCurrentUserId] = useState<string>('');
|
||
|
||
// 【新架构】使用 MessageManager 获取群聊输入状态和禁言状态
|
||
const { typingUsers: groupTypingUsers } = useGroupTyping(effectiveGroupId ? String(effectiveGroupId) : null);
|
||
const { isMuted: isMutedFromManager, setMuted: setMutedStatus } = useGroupMuted(
|
||
effectiveGroupId ? String(effectiveGroupId) : null,
|
||
currentUserId
|
||
);
|
||
|
||
// 本地状态
|
||
const [inputText, setInputText] = useState('');
|
||
const inputTextRef = useRef(inputText);
|
||
useEffect(() => { inputTextRef.current = inputText; }, [inputText]);
|
||
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 [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 prevLatestSeqRef = useRef(0);
|
||
const prevMarkedReadSeqRef = useRef(0);
|
||
const enterMarkedKeyRef = useRef<string>('');
|
||
const suppressAutoFollowRef = useRef(false);
|
||
const isBrowsingHistoryRef = useRef(false);
|
||
const hasShownMessageListRef = useRef(false);
|
||
const historyLoadingLockUntilRef = useRef(0);
|
||
const scrollToSeqRef = useRef<number | null>(null);
|
||
|
||
// 回复消息状态
|
||
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; avatar?: string | null } | null>(null);
|
||
const [groupMembers, setGroupMembers] = useState<GroupMemberResponse[]>([]);
|
||
|
||
// 群名/头像:优先使用路由参数(JPUSH extras),其次从 conversation 对象获取
|
||
useEffect(() => {
|
||
if (!isGroupChat) return;
|
||
const name = effectiveGroupName;
|
||
const avatar = effectiveGroupAvatar;
|
||
if (name || avatar) {
|
||
setGroupInfo(prev => prev ? prev : { name: name || undefined, avatar: avatar || null });
|
||
}
|
||
}, [isGroupChat, effectiveGroupName, effectiveGroupAvatar]);
|
||
|
||
const [currentUserRole, setCurrentUserRole] = useState<UserRole>('member');
|
||
const [mentionQuery, setMentionQuery] = useState<string>('');
|
||
const [selectedMentions, setSelectedMentions] = useState<string[]>([]);
|
||
const [mentionAll, setMentionAll] = useState(false);
|
||
const prevInputTextRef = useRef('');
|
||
|
||
const [isMuted, setIsMuted] = useState(false);
|
||
const [muteAll, setMuteAll] = useState(false);
|
||
|
||
// 路由会话ID变化时同步到本地会话状态,确保订阅会随会话切换
|
||
useEffect(() => {
|
||
setConversationId(routeConversationId);
|
||
}, [routeConversationId]);
|
||
|
||
// 【改造】直接使用 MessageManager 返回的消息
|
||
const messages = useMemo<GroupMessage[]>(() => {
|
||
return messageManagerMessages.map(m => ({
|
||
id: m.id,
|
||
conversation_id: m.conversation_id,
|
||
sender_id: m.sender_id,
|
||
seq: m.seq,
|
||
segments: m.segments || [],
|
||
status: (m.status || 'normal') as MessageStatus,
|
||
category: m.category,
|
||
created_at: m.created_at,
|
||
sender: m.sender,
|
||
is_system_notice: m.is_system_notice,
|
||
notice_content: m.notice_content,
|
||
}));
|
||
}, [messageManagerMessages]);
|
||
|
||
// 对方用户ID(稳定来源):优先路由参数,其次会话参与者,最后本地 otherUser
|
||
const otherUserId = useMemo(() => {
|
||
if (isGroupChat) return null;
|
||
if (routeUserId) return String(routeUserId);
|
||
const participants = conversation?.participants || [];
|
||
if (participants.length > 0) {
|
||
if (currentUserId) {
|
||
const target = participants.find((p: any) => String(p.id) !== String(currentUserId));
|
||
if (target?.id) return String(target.id);
|
||
}
|
||
if (participants.length === 1 && participants[0]?.id) return String(participants[0].id);
|
||
}
|
||
return otherUser?.id ? String(otherUser.id) : null;
|
||
}, [isGroupChat, routeUserId, conversation?.participants, currentUserId, otherUser?.id]);
|
||
|
||
// 私聊陌生人限制:未被对方关注时仅可发送1条文本消息,且禁止图片
|
||
const followRestricted = useMemo(() => {
|
||
if (isGroupChat) return false;
|
||
// 仅使用独立关系状态源,避免 participants 覆盖造成误判
|
||
return isFollowedByOther === false;
|
||
}, [isGroupChat, isFollowedByOther]);
|
||
const myPrivateSentCount = useMemo(() => {
|
||
if (!followRestricted || !currentUserId) return 0;
|
||
return messages.filter(m => String(m.sender_id) === String(currentUserId)).length;
|
||
}, [messages, followRestricted, currentUserId]);
|
||
const canSendFirstPrivateText = !followRestricted || myPrivateSentCount < 1;
|
||
const canSendPrivateImage = !followRestricted;
|
||
const followRestrictionHint = useMemo(() => {
|
||
if (!followRestricted) return null;
|
||
if (myPrivateSentCount >= 1) {
|
||
return '对方未关注你前:仅可发送1条文字消息(已达上限)';
|
||
}
|
||
return '对方未关注你前:仅可发送1条文字消息,且不能发送图片';
|
||
}, [followRestricted, myPrivateSentCount]);
|
||
|
||
// 加载态语义修正:
|
||
// 首屏加载:尚未展示过消息列表 且 无消息 → 显示 loading(避免 FlashList 以空 data 挂载后 inverted 布局异常)
|
||
// 一旦有消息或加载完成(isLoadingMessages 归 false),即解除 loading
|
||
useEffect(() => {
|
||
if (messageManagerMessages.length > 0) {
|
||
hasShownMessageListRef.current = true;
|
||
}
|
||
const shouldShowInitialLoading =
|
||
!hasShownMessageListRef.current && messageManagerMessages.length === 0 && isLoadingMessages;
|
||
setLoading(shouldShowInitialLoading);
|
||
}, [isLoadingMessages, messageManagerMessages.length]);
|
||
|
||
// 【改造】同步 hasMore 状态
|
||
useEffect(() => {
|
||
setHasMoreHistory(hasMoreMessages);
|
||
}, [hasMoreMessages]);
|
||
|
||
// 【改造】同步发送状态
|
||
useEffect(() => {
|
||
setSending(isSendingViaManager);
|
||
}, [isSendingViaManager]);
|
||
|
||
// 【改造】同步禁言状态
|
||
useEffect(() => {
|
||
setIsMuted(isMutedFromManager);
|
||
}, [isMutedFromManager]);
|
||
|
||
// 【改造】从 conversation 对象获取会话详情
|
||
useEffect(() => {
|
||
if (!conversation) return;
|
||
|
||
// 私聊模式:从 conversation.participants 获取对方用户信息
|
||
if (!isGroupChat && conversation.participants) {
|
||
if (!currentUserId) return;
|
||
const currentUserIdStr = String(currentUserId);
|
||
const other = conversation.participants.find((p: any) => p.id !== currentUserIdStr);
|
||
if (other) {
|
||
setOtherUser(prev => ({ ...(prev || {}), ...other }));
|
||
}
|
||
// 获取对方最后阅读位置
|
||
setOtherUserLastReadSeq(conversation.other_last_read_seq || 0);
|
||
}
|
||
}, [conversation, isGroupChat, currentUserId]);
|
||
|
||
// 独立同步私聊对象互关状态,避免 participants 缓存字段不完整或被刷新覆盖
|
||
useEffect(() => {
|
||
if (isGroupChat) return;
|
||
if (!otherUserId) return;
|
||
|
||
let cancelled = false;
|
||
const syncRelationState = async () => {
|
||
try {
|
||
const detail = await userManager.getUserById(String(otherUserId), true);
|
||
if (!detail || cancelled) return;
|
||
setOtherUser(prev => ({ ...(prev || {}), ...detail }));
|
||
if (typeof detail.is_following_me === 'boolean') {
|
||
setIsFollowedByOther(detail.is_following_me);
|
||
} else {
|
||
setIsFollowedByOther(null);
|
||
}
|
||
} catch (error) {
|
||
console.error('同步私聊对象互关状态失败:', error);
|
||
if (!cancelled) {
|
||
setIsFollowedByOther(null);
|
||
}
|
||
}
|
||
};
|
||
|
||
syncRelationState();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [isGroupChat, otherUserId]);
|
||
|
||
// 进入新会话时重置滚动/草稿状态
|
||
useEffect(() => {
|
||
hasInitialAnchorDoneRef.current = false;
|
||
prevLatestSeqRef.current = 0;
|
||
prevMarkedReadSeqRef.current = 0;
|
||
enterMarkedKeyRef.current = '';
|
||
suppressAutoFollowRef.current = false;
|
||
isBrowsingHistoryRef.current = false;
|
||
hasShownMessageListRef.current = false;
|
||
historyLoadingLockUntilRef.current = 0;
|
||
scrollToSeqRef.current = routeScrollToSeq ?? null;
|
||
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
|
||
flatListRef.current?.scrollToOffset({
|
||
offset: 0,
|
||
animated,
|
||
});
|
||
}, [flatListRef, isHistoryLoadingLocked]);
|
||
|
||
const isNearBottom = useCallback(() => {
|
||
const scrollY = scrollPositionRef.current.scrollY || 0;
|
||
// inverted 列表下,越接近 0 越靠近最新消息端
|
||
return scrollY <= 100;
|
||
}, [scrollPositionRef]);
|
||
|
||
// 首屏加载完成后仅锚定一次到最新消息端(有 scrollToSeq 时跳过)
|
||
// 注意:必须在消息首次出现时立刻把 hasInitialAnchorDoneRef 置为 true,
|
||
// 不能再依赖 viewportHeight 等通过 ref 同步的值——否则后续首次 loadMoreHistory
|
||
// 让 messages.length 变化时此 effect 会被再度命中,并以 force=true 调用
|
||
// scrollToLatest 强行把列表拉回最底端,造成"上滑首次加载更多就回底"的问题。
|
||
useEffect(() => {
|
||
if (
|
||
loading ||
|
||
loadingMore ||
|
||
messages.length === 0 ||
|
||
hasInitialAnchorDoneRef.current
|
||
) {
|
||
return;
|
||
}
|
||
hasInitialAnchorDoneRef.current = true;
|
||
if (scrollToSeqRef.current != null) return; // 由下方 scrollToSeq effect 处理
|
||
// inverted FlashList 天然从 offset=0(最新端)开始;
|
||
// 这里仅做一次保险锚定,即使布局尚未就绪也不会有副作用。
|
||
const timer = setTimeout(() => {
|
||
scrollToLatest(false, true, 'initial-anchor');
|
||
}, 0);
|
||
return () => clearTimeout(timer);
|
||
}, [loading, loadingMore, messages.length, scrollToLatest]);
|
||
|
||
// 新消息跟随策略(Telegram/QQ 风格):
|
||
// 仅当"最新端消息 seq 增长"且用户在底部附近时才跟随;
|
||
// 历史加载只会增加旧消息,不会提升 latest seq,因此不会触发回底。
|
||
useEffect(() => {
|
||
const latestSeq = messages.length > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
|
||
const prevLatestSeq = prevLatestSeqRef.current;
|
||
prevLatestSeqRef.current = latestSeq;
|
||
|
||
if (loading || loadingMore) return;
|
||
if (latestSeq <= prevLatestSeq) return;
|
||
if (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked()) return;
|
||
if (!isNearBottom()) return;
|
||
|
||
const timer = setTimeout(() => {
|
||
scrollToLatest(false, false, 'new-message-follow');
|
||
}, 0);
|
||
return () => clearTimeout(timer);
|
||
}, [messages, loading, loadingMore, isNearBottom, scrollToLatest, isHistoryLoadingLocked]);
|
||
|
||
// 获取当前用户信息
|
||
useEffect(() => {
|
||
const fetchCurrentUser = async () => {
|
||
try {
|
||
const currentUserData = await userManager.getCurrentUser();
|
||
if (currentUserData) {
|
||
setCurrentUserId(String(currentUserData.id));
|
||
setCurrentUser(currentUserData);
|
||
}
|
||
} catch (error) {
|
||
console.error('获取当前用户失败:', error);
|
||
}
|
||
};
|
||
fetchCurrentUser();
|
||
}, []);
|
||
|
||
// 群聊模式:获取群组信息和成员列表
|
||
useEffect(() => {
|
||
if (!isGroupChat || !effectiveGroupId) return;
|
||
|
||
const fetchGroupInfo = async () => {
|
||
try {
|
||
const group = await groupManager.getGroup(effectiveGroupId);
|
||
setGroupInfo(group);
|
||
|
||
const membersResponse = await groupManager.getMembers(effectiveGroupId, 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(effectiveGroupId);
|
||
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, effectiveGroupId, 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 + maintainVisibleContentPosition 由列表原生保持位置)
|
||
const loadMoreHistory = useCallback(async () => {
|
||
if (!conversationId || !hasMoreHistory || loadingMore) {
|
||
return;
|
||
}
|
||
|
||
// 历史加载期间禁止"新消息自动跟随到底"
|
||
suppressAutoFollowRef.current = true;
|
||
isBrowsingHistoryRef.current = true;
|
||
historyLoadingLockUntilRef.current = Date.now() + 3000;
|
||
setLoadingMore(true);
|
||
|
||
try {
|
||
await loadMoreMessages();
|
||
} catch (error) {
|
||
console.error('加载历史消息失败:', error);
|
||
} finally {
|
||
setLoadingMore(false);
|
||
// 加载结束后继续保留短暂锁窗,避免布局结算阶段误触自动回底
|
||
historyLoadingLockUntilRef.current = Date.now() + 800;
|
||
}
|
||
}, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages]);
|
||
|
||
// 从搜索结果跳转:滚动到目标 seq
|
||
useEffect(() => {
|
||
const targetSeq = scrollToSeqRef.current;
|
||
if (targetSeq == null || !hasInitialAnchorDoneRef.current) return;
|
||
if (loading || loadingMore) return;
|
||
|
||
const found = messages.find(m => m.seq === targetSeq);
|
||
if (found) {
|
||
scrollToSeqRef.current = null;
|
||
isBrowsingHistoryRef.current = true;
|
||
suppressAutoFollowRef.current = true;
|
||
const ascendingIndex = messages.findIndex(m => m.seq === targetSeq);
|
||
const displayIndex = messages.length - 1 - ascendingIndex;
|
||
setTimeout(() => {
|
||
try {
|
||
flatListRef.current?.scrollToIndex({
|
||
index: displayIndex,
|
||
animated: false,
|
||
viewPosition: 0.5,
|
||
});
|
||
} catch {
|
||
// FlashList 远距离跳转可能失败,降级到 offset 滚动
|
||
flatListRef.current?.scrollToOffset?.({ offset: 0, animated: false });
|
||
}
|
||
}, 50);
|
||
return;
|
||
}
|
||
|
||
if (hasMoreHistory) {
|
||
loadMoreHistory();
|
||
} else {
|
||
scrollToSeqRef.current = null;
|
||
}
|
||
}, [loading, loadingMore, messages, hasMoreHistory, loadMoreHistory]);
|
||
|
||
/**
|
||
* 跳转到指定 seq 的消息(供引用消息点击跳转复用 scrollToSeq 范式)。
|
||
* 设置目标 seq 后由上方 effect 接管:内存命中则 scrollToIndex,
|
||
* 未命中则循环 loadMoreHistory 直到目标进入视窗或历史耗尽。
|
||
* @returns 是否已受理跳转(false 表示无更多历史且不在内存,无法定位)
|
||
*/
|
||
const jumpToMessageSeq = useCallback((seq: number): boolean => {
|
||
if (!hasInitialAnchorDoneRef.current) return false;
|
||
if (!Number.isFinite(seq) || seq <= 0) return false;
|
||
|
||
scrollToSeqRef.current = seq;
|
||
isBrowsingHistoryRef.current = true;
|
||
suppressAutoFollowRef.current = true;
|
||
|
||
// 目标已在内存:直接定位(effect 依赖未变不会自动重跑,这里同步处理命中情况)
|
||
const ascendingIndex = messages.findIndex(m => m.seq === seq);
|
||
if (ascendingIndex >= 0) {
|
||
scrollToSeqRef.current = null;
|
||
const displayIndex = messages.length - 1 - ascendingIndex;
|
||
setTimeout(() => {
|
||
try {
|
||
flatListRef.current?.scrollToIndex({ index: displayIndex, animated: true, viewPosition: 0.5 });
|
||
} catch {
|
||
flatListRef.current?.scrollToOffset?.({ offset: 0, animated: true });
|
||
}
|
||
}, 50);
|
||
return true;
|
||
}
|
||
|
||
// 目标不在内存:kickstart 历史加载循环,由 scrollToSeq effect 接管后续
|
||
if (hasMoreHistory) {
|
||
loadMoreHistory();
|
||
return true;
|
||
}
|
||
// 无更多历史且不在内存,无法定位
|
||
scrollToSeqRef.current = null;
|
||
return false;
|
||
}, [messages, hasMoreHistory, loadMoreHistory, flatListRef]);
|
||
|
||
// 列表内容尺寸变化:仅同步内容高度,锚点补偿由 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;
|
||
// FlashList 在 inverted 模式下 scrollToOffset({ offset: 0 }) 可能无法真正滚到最底部,
|
||
// 因为列表内容高度变化后最小 offset 可能不是 0。先尝试滚到负值确保到底,再补偿回 0。
|
||
flatListRef.current?.scrollToOffset({ offset: -99999, animated: false });
|
||
requestAnimationFrame(() => {
|
||
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||
});
|
||
}, [flatListRef]);
|
||
|
||
const setBrowsingHistory = useCallback((browsing: boolean) => {
|
||
isBrowsingHistoryRef.current = browsing;
|
||
}, []);
|
||
|
||
/**
|
||
* 无条件清除浏览历史锁与自动跟随抑制(动量滚动结束、layout settle、
|
||
* 轻触回到底部等场景使用)。区别于 handleReachLatestEdge,后者受加载锁保护。
|
||
*/
|
||
const clearHistoryLock = useCallback(() => {
|
||
if (isBrowsingHistoryRef.current || suppressAutoFollowRef.current) {
|
||
isBrowsingHistoryRef.current = false;
|
||
suppressAutoFollowRef.current = false;
|
||
}
|
||
}, []);
|
||
|
||
// 进入聊天详情后立即清未读(不依赖滚动位置)
|
||
useEffect(() => {
|
||
if (!conversationId) return;
|
||
if (!conversation) return;
|
||
|
||
const unreadCount = Number(conversation.unread_count || 0);
|
||
if (unreadCount <= 0) return;
|
||
|
||
const latestFromConversation = Number(conversation.last_seq || 0);
|
||
const latestFromMessages = messages.length > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
|
||
const targetSeq = Math.max(latestFromConversation, latestFromMessages);
|
||
if (targetSeq <= 0) return;
|
||
|
||
const markKey = `${conversationId}:${targetSeq}`;
|
||
if (enterMarkedKeyRef.current === markKey) return;
|
||
enterMarkedKeyRef.current = markKey;
|
||
|
||
prevMarkedReadSeqRef.current = Math.max(prevMarkedReadSeqRef.current, targetSeq);
|
||
markAsRead(targetSeq).catch(error => {
|
||
console.error('[ChatScreen] 进入会话清未读失败:', error);
|
||
});
|
||
}, [conversationId, conversation, messages, markAsRead]);
|
||
|
||
// 自动标记已读(QQ/Telegram 风格):
|
||
// 仅当出现更大的 latest seq,且用户在最新端附近时才上报。
|
||
// 历史加载/浏览历史不会触发 read。
|
||
// 注意:不能复用 prevLatestSeqRef 做判定——它被新消息跟随 effect 无条件推进,
|
||
// 会导致这里的去重判断永远成立,标记已读逻辑事实上不会执行。
|
||
// 这里只依赖专属的 prevMarkedReadSeqRef 做去重。
|
||
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;
|
||
|
||
// 避免对同一 seq 重复标记
|
||
if (latestSeq <= prevMarkedReadSeqRef.current) return;
|
||
prevMarkedReadSeqRef.current = latestSeq;
|
||
|
||
markAsRead(latestSeq).catch(error => {
|
||
console.error('[ChatScreen] 自动标记已读失败:', error);
|
||
});
|
||
}, [messages, conversationId, markAsRead, loading, loadingMore, isNearBottom]);
|
||
|
||
// 监听键盘事件
|
||
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 => {
|
||
return formatChatTime(dateString);
|
||
}, []);
|
||
|
||
// 检查是否需要显示时间分隔
|
||
const shouldShowTime = useCallback((index: number): boolean => {
|
||
if (index === 0) return true;
|
||
const currentMsg = messages[index];
|
||
const prevMsg = messages[index - 1];
|
||
const currentDate = new Date(currentMsg.created_at);
|
||
const prevDate = new Date(prevMsg.created_at);
|
||
if (Number.isNaN(currentDate.getTime()) || Number.isNaN(prevDate.getTime())) return false;
|
||
return (currentDate.getTime() - prevDate.getTime()) > TIME_SEPARATOR_INTERVAL;
|
||
}, [messages]);
|
||
|
||
// 处理输入变化,检测@符号及 @mention 整体删除
|
||
const handleInputChange = useCallback((text: string) => {
|
||
const prevText = prevInputTextRef.current;
|
||
|
||
// 检测退格导致 @mention 被部分删除 → 整体补删(setNativeProps 立即生效无卡顿)
|
||
if (text.length < prevText.length && isGroupChat) {
|
||
let deleteStart = -1;
|
||
for (let i = 0; i < prevText.length; i++) {
|
||
if (prevText[i] !== text[i]) {
|
||
deleteStart = i;
|
||
break;
|
||
}
|
||
}
|
||
if (deleteStart === -1) {
|
||
deleteStart = text.length;
|
||
}
|
||
const deleteEnd = deleteStart + (prevText.length - text.length);
|
||
|
||
const mentionRanges = getMentionRanges(prevText, selectedMentions, mentionAll, groupMembers);
|
||
|
||
for (const range of mentionRanges) {
|
||
if (deleteStart < range.end && deleteEnd > range.start) {
|
||
const correctedText = prevText.slice(0, range.start) + prevText.slice(range.end);
|
||
prevInputTextRef.current = correctedText;
|
||
// 立即同步到原生层,避免二次渲染卡顿
|
||
textInputRef.current?.setNativeProps({ text: correctedText });
|
||
setInputText(correctedText);
|
||
|
||
setSelectedMentions(prev => prev.filter(userId => {
|
||
const member = groupMembers.find(m => m.user_id === userId);
|
||
const nickname = member?.nickname || member?.user?.nickname;
|
||
return nickname && correctedText.includes(`@${nickname}`);
|
||
}));
|
||
if (mentionAll && !correctedText.includes('@所有人')) {
|
||
setMentionAll(false);
|
||
}
|
||
|
||
const lastAtIndex = correctedText.lastIndexOf('@');
|
||
if (lastAtIndex !== -1) {
|
||
const textAfterAt = correctedText.slice(lastAtIndex + 1);
|
||
if (!textAfterAt.includes(' ')) {
|
||
setMentionQuery(textAfterAt.toLowerCase());
|
||
setActivePanel('mention');
|
||
} else {
|
||
setActivePanel('none');
|
||
}
|
||
} else {
|
||
setActivePanel('none');
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
prevInputTextRef.current = text;
|
||
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');
|
||
}
|
||
|
||
if (selectedMentions.length > 0) {
|
||
const stillPresent = selectedMentions.filter(userId => {
|
||
const member = groupMembers.find(m => m.user_id === userId);
|
||
const nickname = member?.nickname || member?.user?.nickname;
|
||
if (!nickname) return false;
|
||
const escapedName = nickname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
return new RegExp(`@${escapedName}(\\s|$)`, 'g').test(text);
|
||
});
|
||
if (stillPresent.length !== selectedMentions.length) {
|
||
setSelectedMentions(stillPresent);
|
||
}
|
||
}
|
||
|
||
if (mentionAll && !text.includes('@所有人')) {
|
||
setMentionAll(false);
|
||
}
|
||
}
|
||
}, [isGroupChat, selectedMentions, mentionAll, groupMembers]);
|
||
|
||
// 【改造】获取发送者信息(群聊)- 优先从消息的 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 { getCached: getCachedReply, ensureReplyMessage } = useReplyMessage();
|
||
|
||
// 选择@用户
|
||
const handleSelectMention = useCallback((member: { user_id: string; nickname?: string; user?: { nickname?: string } }) => {
|
||
const currentText = inputTextRef.current;
|
||
const lastAtIndex = currentText.lastIndexOf('@');
|
||
if (lastAtIndex !== -1) {
|
||
const textBeforeAt = currentText.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);
|
||
}, [selectedMentions]);
|
||
|
||
// @所有人
|
||
const handleMentionAll = useCallback(() => {
|
||
const currentText = inputTextRef.current;
|
||
const lastAtIndex = currentText.lastIndexOf('@');
|
||
if (lastAtIndex !== -1) {
|
||
const textBeforeAt = currentText.slice(0, lastAtIndex);
|
||
const newText = `${textBeforeAt}@所有人 `;
|
||
setInputText(newText);
|
||
setMentionAll(true);
|
||
}
|
||
setActivePanel('none');
|
||
setMentionQuery('');
|
||
|
||
setTimeout(() => {
|
||
textInputRef.current?.focus();
|
||
}, 100);
|
||
}, []);
|
||
|
||
/**
|
||
* 构建文本消息的 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
|
||
});
|
||
}
|
||
|
||
// 收集所有 @ 提及在文本中的位置
|
||
const mentionPositions: { start: number; end: number; userId: string }[] = [];
|
||
|
||
if (mentionAll) {
|
||
const pattern = /@所有人\s*/g;
|
||
let match: RegExpExecArray | null;
|
||
while ((match = pattern.exec(text)) !== null) {
|
||
mentionPositions.push({ start: match.index, end: match.index + match[0].length, userId: 'all' });
|
||
}
|
||
}
|
||
|
||
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, '\\$&');
|
||
const pattern = new RegExp(`@${escapedName}\\s*`, 'g');
|
||
let match: RegExpExecArray | null;
|
||
while ((match = pattern.exec(text)) !== null) {
|
||
mentionPositions.push({ start: match.index, end: match.index + match[0].length, userId });
|
||
}
|
||
});
|
||
}
|
||
|
||
mentionPositions.sort((a, b) => a.start - b.start);
|
||
|
||
const seen = new Set<number>();
|
||
const uniquePositions = mentionPositions.filter(p => {
|
||
if (seen.has(p.start)) return false;
|
||
seen.add(p.start);
|
||
return true;
|
||
});
|
||
|
||
let cursor = 0;
|
||
for (const pos of uniquePositions) {
|
||
if (pos.start >= pos.end) continue;
|
||
if (pos.start > cursor) {
|
||
const plainText = text.slice(cursor, pos.start);
|
||
if (plainText) {
|
||
segments.push({ type: 'text', data: { text: plainText } as TextSegmentData });
|
||
}
|
||
}
|
||
segments.push({ type: 'at', data: { user_id: pos.userId } as AtSegmentData });
|
||
cursor = Math.max(cursor, pos.end);
|
||
}
|
||
|
||
if (cursor < text.length) {
|
||
const remaining = text.slice(cursor);
|
||
if (remaining) {
|
||
segments.push({ type: 'text', data: { text: remaining } as TextSegmentData });
|
||
}
|
||
}
|
||
|
||
if (segments.length === 0 || (segments.length === 1 && segments[0].type === 'reply')) {
|
||
segments.push({ type: 'text', data: { text: '' } 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;
|
||
}, []);
|
||
|
||
/**
|
||
* 构建文件消息的 segments 数组
|
||
*/
|
||
const buildFileSegments = useCallback((
|
||
fileUrl: string,
|
||
name: string,
|
||
size?: number,
|
||
mimeType?: string,
|
||
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: 'file',
|
||
data: {
|
||
url: fileUrl,
|
||
name,
|
||
size,
|
||
mime_type: mimeType,
|
||
} as FileSegmentData
|
||
});
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
// 图片需要先上传(这个无法乐观,因为需要URL)
|
||
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);
|
||
}
|
||
}
|
||
|
||
// 构建消息段
|
||
const segments: MessageSegment[] = (trimmedText || replyingTo)
|
||
? [...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;
|
||
}
|
||
|
||
// 乐观更新:立即清空输入框并滚动到底部
|
||
const savedReplyTo = replyingTo;
|
||
setInputText('');
|
||
setSelectedMentions([]);
|
||
setMentionAll(false);
|
||
setReplyingTo(null);
|
||
setPendingAttachments([]);
|
||
|
||
// 立即滚动到底部显示新消息
|
||
setTimeout(() => {
|
||
scrollToLatest(false, false, hasPending ? 'send-mixed' : 'send-text');
|
||
}, 50);
|
||
|
||
// 后台发送消息(私聊与群聊统一走 MessageSendService 的乐观更新):
|
||
// 1. 立即在 store 中插入 pending 临时消息
|
||
// 2. 成功 → 替换为正式消息、更新会话 last_message、写入本地数据库
|
||
// 3. 失败 → 标记为 failed,供用户点击重试
|
||
try {
|
||
await sendMessageViaManager(segments, {
|
||
replyToId: savedReplyTo?.id,
|
||
detailType: isGroupChat ? 'group' : 'private',
|
||
});
|
||
} catch (error) {
|
||
console.error('发送消息失败:', error);
|
||
// MessageSendService 已将消息置为 failed 并在 UI 上显示状态,
|
||
// 这里仅弹窗提示用户(与原行为保持一致)。
|
||
if (isGroupChat) {
|
||
Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试'));
|
||
}
|
||
}
|
||
}, [
|
||
inputText,
|
||
pendingAttachments,
|
||
conversationId,
|
||
isGroupChat,
|
||
sendMessageViaManager,
|
||
isMuted,
|
||
muteAll,
|
||
buildTextSegments,
|
||
replyingTo,
|
||
getSendErrorMessage,
|
||
canSendFirstPrivateText,
|
||
canSendPrivateImage,
|
||
scrollToLatest,
|
||
]);
|
||
|
||
// 重试发送失败的消息(点击「发送失败」气泡时触发)
|
||
// 仅对当前用户自己发送且状态为 failed 的消息生效;
|
||
// 重试期间消息会先变为 pending(发送中),再根据结果转为 normal/failed。
|
||
const handleRetrySend = useCallback(async (message: GroupMessage) => {
|
||
if (!conversationId) return;
|
||
if (message.sender_id !== currentUserId) return;
|
||
if (message.status !== 'failed') return;
|
||
try {
|
||
await retrySendMessageViaManager(message);
|
||
} catch (error) {
|
||
console.error('重试发送消息失败:', error);
|
||
Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试'));
|
||
}
|
||
}, [conversationId, currentUserId, retrySendMessageViaManager, getSendErrorMessage]);
|
||
|
||
// 选择图片(多选进入输入框待发送,点发送时与文字一并发出)
|
||
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 handlePickFile = useCallback(async () => {
|
||
if (!conversationId) {
|
||
setActivePanel('none');
|
||
return;
|
||
}
|
||
|
||
if (isGroupChat && isMuted) {
|
||
Alert.alert('无法发送', muteAll ? '当前群组已开启全员禁言' : '你已被管理员禁言');
|
||
setActivePanel('none');
|
||
return;
|
||
}
|
||
|
||
setActivePanel('none');
|
||
|
||
try {
|
||
const result = await DocumentPicker.getDocumentAsync({
|
||
multiple: false,
|
||
copyToCacheDirectory: true,
|
||
});
|
||
if (result.canceled || !result.assets || result.assets.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const asset = result.assets[0];
|
||
setUploadingAttachments(true);
|
||
|
||
const uploaded = await uploadService.uploadFile(
|
||
{
|
||
uri: asset.uri,
|
||
name: asset.name,
|
||
type: asset.mimeType || undefined,
|
||
},
|
||
'chat'
|
||
);
|
||
|
||
if (!uploaded?.url) {
|
||
Alert.alert('上传失败', getSendErrorMessage(null, '文件上传失败,请重试'));
|
||
return;
|
||
}
|
||
|
||
const segments = buildFileSegments(
|
||
uploaded.url,
|
||
// asset.name 是 DocumentPicker 返回的用户原始文件名(真实名),
|
||
// 优先使用它;uploaded.name 为后端回传的展示名(已与 asset.name 一致)。
|
||
asset.name || uploaded.name || 'file',
|
||
uploaded.size ?? asset.size,
|
||
uploaded.mime_type ?? asset.mimeType,
|
||
replyingTo,
|
||
);
|
||
|
||
setSending(true);
|
||
try {
|
||
if (isGroupChat && effectiveGroupId) {
|
||
await messageService.sendMessageByAction('group', conversationId, segments);
|
||
} else {
|
||
await sendMessageViaManager(segments);
|
||
}
|
||
setReplyingTo(null);
|
||
setTimeout(() => scrollToLatest(false, false, 'send-file'), 100);
|
||
} catch (error) {
|
||
console.error('发送文件消息失败:', error);
|
||
Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试'));
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
} catch (error) {
|
||
console.error('选择文件失败:', error);
|
||
Alert.alert('错误', getSendErrorMessage(error, '选择文件失败'));
|
||
} finally {
|
||
setUploadingAttachments(false);
|
||
}
|
||
}, [
|
||
conversationId,
|
||
isGroupChat,
|
||
isMuted,
|
||
muteAll,
|
||
effectiveGroupId,
|
||
replyingTo,
|
||
buildFileSegments,
|
||
sendMessageViaManager,
|
||
scrollToLatest,
|
||
getSendErrorMessage,
|
||
]);
|
||
|
||
// 处理更多功能
|
||
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':
|
||
handlePickFile();
|
||
break;
|
||
default:
|
||
setActivePanel('none');
|
||
}
|
||
}, [handlePickImage, handleTakePhoto, handlePickFile, 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 && effectiveGroupId) {
|
||
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, effectiveGroupId, 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 处理(撤回事件由 MessageManager 同步状态)
|
||
const handleRecall = useCallback(async (messageId: string) => {
|
||
try {
|
||
await messageService.recallMessage(messageId);
|
||
} catch (error) {
|
||
console.error('撤回消息失败:', error);
|
||
Alert.alert('撤回失败', '无法撤回消息');
|
||
}
|
||
}, []);
|
||
|
||
// 长按消息显示操作菜单
|
||
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);
|
||
await messageRepository.delete(messageId);
|
||
if (conversationId) {
|
||
useMessageStore.getState().removeMessage(conversationId, messageId);
|
||
}
|
||
} catch (error) {
|
||
console.error('删除消息失败:', error);
|
||
Alert.alert('删除失败', '无法删除消息');
|
||
}
|
||
}, [conversationId]);
|
||
|
||
// 清空会话的所有聊天记录
|
||
const handleClearConversation = useCallback(async () => {
|
||
if (!conversationId) return;
|
||
|
||
try {
|
||
setHasMoreHistory(true);
|
||
await messageRepository.clearConversation(conversationId);
|
||
// 同步清空内存消息 + hydrated 标志,保证内存/DB/hydrated 三者一致。
|
||
// 否则 hydrated 残留会让 refreshMessages 走轻量增量同步,清空后仍显示旧消息。
|
||
useMessageStore.getState().clearMessages(conversationId);
|
||
// 刷新消息列表(hydrated 已清,会重新走完整 hydration 管线)
|
||
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 currentText = inputTextRef.current;
|
||
setInputText(currentText + `@${nickname} `);
|
||
|
||
setTimeout(() => {
|
||
textInputRef.current?.focus();
|
||
}, 100);
|
||
}, [isGroupChat, currentUserId, 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 handleDismiss = useCallback(() => {
|
||
Keyboard.dismiss();
|
||
if (activePanel !== 'none') {
|
||
setActivePanel('none');
|
||
}
|
||
}, [activePanel]);
|
||
|
||
// 导航到群组信息或用户资料
|
||
const navigateToInfo = useCallback(() => {
|
||
if (isGroupChat && effectiveGroupId) {
|
||
router.push(hrefs.hrefGroupInfo(effectiveGroupId, conversationId || undefined));
|
||
} else if (otherUser?.id) {
|
||
router.push(hrefs.hrefUserProfile(String(otherUser.id)));
|
||
}
|
||
}, [isGroupChat, effectiveGroupId, otherUser, conversationId]);
|
||
|
||
// 导航到聊天管理页面
|
||
const navigateToChatSettings = useCallback(() => {
|
||
if (isGroupChat && effectiveGroupId) {
|
||
router.push(hrefs.hrefGroupInfo(effectiveGroupId, conversationId || undefined));
|
||
} else if (otherUser?.id && conversationId) {
|
||
router.push(
|
||
hrefs.hrefPrivateChatInfo({
|
||
conversationId,
|
||
userId: String(otherUser.id),
|
||
userName: otherUser.nickname,
|
||
userAvatar: otherUser.avatar,
|
||
})
|
||
);
|
||
}
|
||
}, [isGroupChat, effectiveGroupId, otherUser, conversationId]);
|
||
|
||
return {
|
||
// 状态
|
||
messages,
|
||
conversationId,
|
||
inputText,
|
||
otherUser,
|
||
currentUser,
|
||
currentUserId,
|
||
keyboardHeight,
|
||
loading,
|
||
activePanel,
|
||
sendingImage,
|
||
uploadingAttachments,
|
||
pendingAttachments,
|
||
isComposerBusy,
|
||
replyingTo,
|
||
longPressMenuVisible,
|
||
selectedMessage,
|
||
selectedMessageId,
|
||
menuPosition,
|
||
isGroupChat,
|
||
groupInfo,
|
||
groupMembers,
|
||
currentUserRole,
|
||
mentionQuery,
|
||
isMuted,
|
||
muteAll,
|
||
followRestrictionHint,
|
||
canSendPrivateImage,
|
||
effectiveGroupId,
|
||
effectiveGroupName,
|
||
otherUserLastReadSeq,
|
||
messageMap,
|
||
// 引用消息回填(会话级缓存:内存 → 本地 SQLite → 服务端)
|
||
getCachedReply,
|
||
ensureReplyMessage,
|
||
jumpToMessageSeq,
|
||
loadingMore,
|
||
hasMoreHistory,
|
||
|
||
// Refs
|
||
flatListRef,
|
||
textInputRef,
|
||
scrollPositionRef,
|
||
|
||
// 方法
|
||
formatTime,
|
||
shouldShowTime,
|
||
handleInputChange,
|
||
handleSend,
|
||
handleRetrySend,
|
||
removePendingAttachment,
|
||
handleMoreAction,
|
||
handleInsertEmoji,
|
||
handleSendSticker,
|
||
toggleEmojiPanel,
|
||
toggleMorePanel,
|
||
closePanel,
|
||
handleRecall,
|
||
handleLongPressMessage,
|
||
hideLongPressMenu,
|
||
handleDeleteMessage,
|
||
handleReplyMessage,
|
||
handleCancelReply,
|
||
handleAvatarPress,
|
||
handleAvatarLongPress,
|
||
handleSelectMention,
|
||
handleMentionAll,
|
||
getSenderInfo,
|
||
getTypingHint,
|
||
handleDismiss,
|
||
navigateToInfo,
|
||
navigateToChatSettings,
|
||
loadMoreHistory,
|
||
handleClearConversation,
|
||
handleMessageListContentSizeChange,
|
||
handleReachLatestEdge,
|
||
jumpToLatestMessages,
|
||
setBrowsingHistory,
|
||
clearHistoryLock,
|
||
};
|
||
};
|