Files
frontend/src/stores/message/hooks.ts
lafay be77a9d04c
All checks were successful
Frontend CI / ota (android) (push) Successful in 3m24s
Frontend CI / ota (ios) (push) Successful in 3m22s
Frontend CI / build-and-push-web (push) Successful in 21m12s
Frontend CI / build-android-apk (push) Successful in 41m40s
feat(messaging): implement optimistic updates with retry for failed messages
- Add pending/failed message status to track send state
- Implement optimistic UI: show message immediately, update on server response
- Add retry functionality for failed messages via tap-to-retry
- Change send button from icon to text "发送" for clarity
- Add MessageSendService with temp ID generation to prevent collisions

feat(notification): add JPush notification deduplication and clear on tap

- Deduplicate notificationArrived events from dual JPush/vendor channels
- Clear all notifications on tap (QQ-style behavior)

feat(post): add client-side idempotency key for post creation

- Generate client_request_id per publish intent to prevent duplicates on retry
- Pass idempotency key through to postService and voteService
2026-06-26 17:01:09 +08:00

665 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MessageManager React Hooks
*
* 提供React组件与MessageManager的集成
* 所有hooks都基于Zustand selector机制
* 确保组件能实时获取状态更新
*
* 重构说明:
* - 移除了手动订阅机制,改用 Zustand selector
* - Zustand 会自动处理依赖追踪和组件重渲染
* - 不需要手动管理订阅/取消订阅
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useFocusEffect } from 'expo-router';
import { useShallow } from 'zustand/react/shallow';
import { ConversationResponse, MessageResponse, MessageSegment } from '../../types/dto';
import { messageManager } from './MessageManager';
import { useMessageStore } from './store';
import { useUnreadCountQuery } from '../../hooks/useUnreadCount';
const EMPTY_MESSAGES: MessageResponse[] = [];
// ==================== useConversations - 获取会话列表 ====================
interface UseConversationsReturn {
conversations: ConversationResponse[];
isLoading: boolean;
refresh: () => Promise<void>;
loadMore: () => Promise<void>;
hasMore: boolean;
}
/**
* 获取会话列表
* 使用 Zustand selector 自动订阅状态变化
*/
export function useConversations(): UseConversationsReturn {
// 使用 Zustand selector 直接订阅状态
const conversations = useMessageStore(state => state.conversationList);
const isLoading = useMessageStore(state => state.isLoadingConversations);
const [hasMore, setHasMore] = useState(() => messageManager.canLoadMoreConversations());
useEffect(() => {
// 初始化时确保 MessageManager 已初始化
messageManager.initialize().catch(error => {
console.error('[useConversations] 初始化失败:', error);
});
// 仅在首次初始化时做一次冷启动兜底刷新,避免每次挂载都触发
const store = useMessageStore.getState();
if (!store.isInitialized) {
const coldStartSyncTimer = setTimeout(() => {
messageManager.refreshConversations(true, 'hooks-initial-refresh').catch(error => {
console.error('[useConversations] 冷启动强制刷新失败:', error);
});
}, 1200);
return () => {
clearTimeout(coldStartSyncTimer);
};
}
}, []);
// 监听 hasMore 变化
useEffect(() => {
setHasMore(messageManager.canLoadMoreConversations());
}, [conversations]);
const refresh = useCallback(async () => {
await messageManager.refreshConversations(true, 'hooks-manual-refresh');
}, []);
const loadMore = useCallback(async () => {
await messageManager.loadMoreConversations();
}, []);
return {
conversations,
isLoading,
refresh,
loadMore,
hasMore,
};
}
// ==================== useMessages - 获取指定会话的消息 ====================
interface UseMessagesReturn {
messages: MessageResponse[];
isLoading: boolean;
hasMore: boolean;
loadMore: () => Promise<void>;
refresh: () => Promise<void>;
}
/**
* 获取指定会话的消息
* 使用 Zustand selector 自动订阅消息变化
*
* 核心修复:
* 1. useFocusEffect屏幕重新获得焦点时强制同步消息解决"需要退出重进才能看到新消息"的问题
* 2. 引用计数清理 isLoadingMessages仅在最后一个订阅者卸载时清除 loading 锁,
* 避免 StrictMode 双调用或快速切换会话时,第一次 fetch 还没完成就被清锁
* 3. 重入时 forceSync相同 conversationId 重复进入时强制拉取最新消息
*/
// 记录每个 conversationId 上的活跃订阅数,仅在归零时清锁
const loadingLockRefCount: Map<string, number> = new Map();
export function useMessages(conversationId: string | null): UseMessagesReturn {
const normalizedConversationId = conversationId ? String(conversationId) : null;
// 使用 useShallow 避免每次返回新数组引用导致的无限循环
const messages = useMessageStore(
useShallow(state => {
if (!normalizedConversationId) return EMPTY_MESSAGES;
return state.messagesMap.get(normalizedConversationId) ?? EMPTY_MESSAGES;
})
);
const isLoadingMessages = useMessageStore(state =>
normalizedConversationId ? state.loadingMessagesSet.has(normalizedConversationId) : false
);
const [hasMore, setHasMore] = useState(true);
const loadMoreInFlightRef = useRef(false);
// 追踪上次激活的 conversationId避免同一会话重复激活时不必要的 forceSync
const lastActivatedIdRef = useRef<string | null>(null);
// 追踪 useEffect 激活时间,与 useFocusEffect 去重
const lastActivatedAtRef = useRef<number>(0);
useEffect(() => {
if (!normalizedConversationId) {
return;
}
// 引用计数 +1
const prevCount = loadingLockRefCount.get(normalizedConversationId) ?? 0;
loadingLockRefCount.set(normalizedConversationId, prevCount + 1);
lastActivatedAtRef.current = Date.now();
const isReentry = lastActivatedIdRef.current === normalizedConversationId;
// 架构入口:激活会话并完成初始化/同步
// 重入同一会话时 forceSync确保拿到最新消息
messageManager.activateConversation(normalizedConversationId, { forceSync: isReentry }).catch(error => {
console.error('[useMessages] 激活会话失败:', error);
});
lastActivatedIdRef.current = normalizedConversationId;
return () => {
// 引用计数 -1仅在最后一个订阅者卸载时清活动会话 + 清 loading 锁
const nextCount = (loadingLockRefCount.get(normalizedConversationId) ?? 1) - 1;
if (nextCount <= 0) {
loadingLockRefCount.delete(normalizedConversationId);
if (messageManager.getActiveConversation() === normalizedConversationId) {
messageManager.setActiveConversation(null);
}
useMessageStore.getState().setLoadingMessages(normalizedConversationId, false);
} else {
loadingLockRefCount.set(normalizedConversationId, nextCount);
}
};
}, [normalizedConversationId]);
// 屏幕重新获得焦点时,同步活动会话的最新消息
useFocusEffect(
useCallback(() => {
if (!normalizedConversationId) return;
// 如果刚被 useEffect 激活500ms 内),跳过焦点同步,避免重复
if (Date.now() - lastActivatedAtRef.current < 500) return;
// 先清除可能残留的 loading 锁,防止上次退出时未正常清理
useMessageStore.getState().setLoadingMessages(normalizedConversationId, false);
// 增量同步最新消息(从当前内存最高 seq 开始拉取)
const currentMessages = useMessageStore.getState().getMessages(normalizedConversationId);
const maxSeq = currentMessages.reduce((max, m) => Math.max(max, m.seq || 0), 0);
if (maxSeq > 0) {
messageManager.fetchMessages(normalizedConversationId, maxSeq).catch(error => {
console.error('[useMessages] 焦点同步消息失败:', error);
});
} else {
messageManager.fetchMessages(normalizedConversationId).catch(error => {
console.error('[useMessages] 焦点同步消息(冷启动)失败:', error);
});
}
}, [normalizedConversationId])
);
const loadMore = useCallback(async () => {
if (!conversationId || !hasMore || loadMoreInFlightRef.current) return;
const currentMessages = messageManager.getMessages(conversationId);
if (currentMessages.length === 0) return;
// 消息数组在 MessageManager 内保持 seq 升序,首项即最早消息
const minSeq = currentMessages[0]?.seq ?? Math.min(...currentMessages.map(m => m.seq));
loadMoreInFlightRef.current = true;
const loadedMessages = await messageManager.loadMoreMessages(conversationId, minSeq, 20);
loadMoreInFlightRef.current = false;
// 如果没有加载到新消息,说明没有更多了
if (loadedMessages.length === 0) {
setHasMore(false);
}
}, [conversationId, hasMore]);
const refresh = useCallback(async () => {
if (!conversationId) return;
await messageManager.fetchMessages(conversationId);
setHasMore(true);
}, [conversationId]);
return {
messages,
isLoading: isLoadingMessages,
hasMore,
loadMore,
refresh,
};
}
// ==================== useSendMessage - 发送消息 ====================
interface UseSendMessageReturn {
sendMessage: (segments: MessageSegment[], options?: { replyToId?: string; detailType?: 'private' | 'group' }) => Promise<MessageResponse | null>;
retrySendMessage: (failedMessage: MessageResponse) => Promise<MessageResponse | null>;
isSending: boolean;
}
/**
* 发送消息
*/
export function useSendMessage(conversationId: string | null): UseSendMessageReturn {
const [isSending, setIsSending] = useState(false);
const sendMessage = useCallback(
async (segments: MessageSegment[], options?: { replyToId?: string; detailType?: 'private' | 'group' }): Promise<MessageResponse | null> => {
if (!conversationId) return null;
setIsSending(true);
try {
const message = await messageManager.sendMessage(conversationId, segments, options);
return message;
} finally {
setIsSending(false);
}
},
[conversationId]
);
const retrySendMessage = useCallback(
async (failedMessage: MessageResponse): Promise<MessageResponse | null> => {
if (!conversationId) return null;
setIsSending(true);
try {
const message = await messageManager.retrySendMessage(conversationId, failedMessage);
return message;
} finally {
setIsSending(false);
}
},
[conversationId]
);
return {
sendMessage,
retrySendMessage,
isSending,
};
}
// ==================== useMarkAsRead - 标记已读 ====================
interface UseMarkAsReadReturn {
markAsRead: (seq: number) => Promise<void>;
markAllAsRead: () => Promise<void>;
isMarking: boolean;
}
/**
* 标记已读
*/
export function useMarkAsRead(conversationId: string | null): UseMarkAsReadReturn {
const [isMarking, setIsMarking] = useState(false);
const markAsRead = useCallback(
async (seq: number) => {
if (!conversationId) return;
setIsMarking(true);
try {
await messageManager.markAsRead(conversationId, seq);
} finally {
setIsMarking(false);
}
},
[conversationId]
);
const markAllAsRead = useCallback(async () => {
setIsMarking(true);
try {
await messageManager.markAllAsRead();
} finally {
setIsMarking(false);
}
}, []);
return {
markAsRead,
markAllAsRead,
isMarking,
};
}
// ==================== useUnreadCount - 获取未读数 ====================
interface UseUnreadCountReturn {
totalUnreadCount: number;
systemUnreadCount: number;
}
/**
* 获取未读消息数
* 使用 Zustand selector 直接订阅未读数变化
*/
export function useUnreadCount(): UseUnreadCountReturn {
// Use React Query for fetching (caching + auto-refetch), Zustand selectors for reading
useUnreadCountQuery();
const totalUnreadCount = useMessageStore(state => state.totalUnreadCount);
const systemUnreadCount = useMessageStore(state => state.systemUnreadCount);
return {
totalUnreadCount,
systemUnreadCount,
};
}
// ==================== useTotalUnreadCount - 获取总未读数(包含系统消息) ====================
/**
* 获取总未读消息数(会话未读 + 系统消息未读)
* 使用 Zustand selector 直接订阅
*/
export function useTotalUnreadCount(): number {
// Use React Query for fetching, Zustand selectors for reading
useUnreadCountQuery();
const totalUnreadCount = useMessageStore(state => state.totalUnreadCount);
const systemUnreadCount = useMessageStore(state => state.systemUnreadCount);
return totalUnreadCount + systemUnreadCount;
}
// ==================== useConversation - 获取单个会话信息 ====================
interface UseConversationReturn {
conversation: ConversationResponse | null;
isLoading: boolean;
refresh: () => Promise<void>;
}
/**
* 获取单个会话信息
* 使用 Zustand selector 直接订阅会话变化
*/
export function useConversation(conversationId: string | null): UseConversationReturn {
// 使用 useShallow 避免每次返回新对象引用导致的无限循环
const conversation = useMessageStore(
useShallow(state =>
conversationId ? (state.conversations.get(conversationId) || null) : null
)
);
const [isLoading, setIsLoading] = useState(false);
const refresh = useCallback(async () => {
if (!conversationId) return;
setIsLoading(true);
await messageManager.fetchConversationDetail(conversationId);
setIsLoading(false);
}, [conversationId]);
return {
conversation,
isLoading,
refresh,
};
}
// ==================== useMessageManager - 通用状态 ====================
interface UseMessageManagerReturn {
isConnected: boolean;
isInitialized: boolean;
}
/**
* 通用MessageManager状态
* 使用 Zustand selector 直接订阅连接状态
*/
export function useMessageManager(): UseMessageManagerReturn {
// 使用 Zustand selector 直接订阅状态
const isConnected = useMessageStore(state => state.isWSConnected);
const isInitialized = useMessageStore(state => state.isInitialized);
useEffect(() => {
// 初始化
messageManager.initialize().catch(error => {
console.error('[useMessageManager] 初始化失败:', error);
});
}, []);
return {
isConnected,
isInitialized,
};
}
// ==================== useCreateConversation - 创建会话 ====================
interface UseCreateConversationReturn {
createConversation: (userId: string) => Promise<ConversationResponse | null>;
isCreating: boolean;
}
/**
* 创建私聊会话
*/
export function useCreateConversation(): UseCreateConversationReturn {
const [isCreating, setIsCreating] = useState(false);
const createConversation = useCallback(async (userId: string): Promise<ConversationResponse | null> => {
setIsCreating(true);
try {
const conversation = await messageManager.createConversation(userId);
return conversation;
} finally {
setIsCreating(false);
}
}, []);
return {
createConversation,
isCreating,
};
}
// ==================== useUpdateConversation - 更新会话 ====================
interface UseUpdateConversationReturn {
updateConversation: (conversationId: string, updates: Partial<ConversationResponse>) => void;
}
/**
* 本地更新会话信息
*/
export function useUpdateConversation(): UseUpdateConversationReturn {
const updateConversation = useCallback((conversationId: string, updates: Partial<ConversationResponse>) => {
messageManager.updateConversation(conversationId, updates);
}, []);
return {
updateConversation,
};
}
// ==================== useSystemUnreadCount - 系统消息未读数操作 ====================
interface UseSystemUnreadCountReturn {
setSystemUnreadCount: (count: number) => void;
incrementSystemUnreadCount: () => void;
decrementSystemUnreadCount: (count?: number) => void;
}
/**
* 系统消息未读数操作
*/
export function useSystemUnreadCount(): UseSystemUnreadCountReturn {
const setSystemUnreadCount = useCallback((count: number) => {
messageManager.setSystemUnreadCount(count);
}, []);
const incrementSystemUnreadCount = useCallback(() => {
messageManager.incrementSystemUnreadCount();
}, []);
const decrementSystemUnreadCount = useCallback((count?: number) => {
messageManager.decrementSystemUnreadCount(count);
}, []);
return {
setSystemUnreadCount,
incrementSystemUnreadCount,
decrementSystemUnreadCount,
};
}
// ==================== useMessageListRefresh - MessageListScreen焦点刷新 ====================
interface UseMessageListRefreshReturn {
refresh: () => Promise<void>;
isRefreshing: boolean;
}
/**
* MessageListScreen焦点刷新Hook
*/
export function useMessageListRefresh(): UseMessageListRefreshReturn {
const [isRefreshing, setIsRefreshing] = useState(false);
const lastRefreshTimeRef = useRef<number>(0);
const refresh = useCallback(async () => {
// 防止重复刷新最小间隔500ms
const now = Date.now();
if (now - lastRefreshTimeRef.current < 500) {
return;
}
lastRefreshTimeRef.current = now;
setIsRefreshing(true);
try {
await messageManager.refreshConversations(true, 'hooks-load-more-fallback');
} catch (error) {
console.error('[useMessageListRefresh] 刷新失败:', error);
} finally {
setIsRefreshing(false);
}
}, []);
return {
refresh,
isRefreshing,
};
}
// ==================== 便捷hooks组合 ====================
/**
* ChatScreen专用Hook组合
*/
interface UseChatReturn {
// 消息相关
messages: MessageResponse[];
isLoadingMessages: boolean;
hasMoreMessages: boolean;
loadMoreMessages: () => Promise<void>;
refreshMessages: () => Promise<void>;
// 发送消息
sendMessage: (segments: MessageSegment[], options?: { replyToId?: string; detailType?: 'private' | 'group' }) => Promise<MessageResponse | null>;
retrySendMessage: (failedMessage: MessageResponse) => Promise<MessageResponse | null>;
isSending: boolean;
// 已读相关
markAsRead: (seq: number) => Promise<void>;
// 会话信息
conversation: ConversationResponse | null;
}
export function useChat(conversationId: string | null): UseChatReturn {
const { messages, isLoading: isLoadingMessages, hasMore: hasMoreMessages, loadMore, refresh } = useMessages(conversationId);
const { sendMessage, retrySendMessage, isSending } = useSendMessage(conversationId);
const { markAsRead } = useMarkAsRead(conversationId);
const { conversation } = useConversation(conversationId);
return {
messages,
isLoadingMessages,
hasMoreMessages,
loadMoreMessages: loadMore,
refreshMessages: refresh,
sendMessage,
retrySendMessage,
isSending,
markAsRead,
conversation,
};
}
// ==================== useGroupTyping - 群聊输入状态 ====================
interface UseGroupTypingReturn {
typingUsers: string[];
}
/**
* 获取群聊输入状态
* 使用 Zustand selector 直接订阅
*/
export function useGroupTyping(groupId: string | null): UseGroupTypingReturn {
// 使用 useShallow 避免每次返回新数组引用导致的无限循环
const typingUsers = useMessageStore(
useShallow(state =>
groupId ? (state.typingUsersMap.get(groupId) || []) : []
)
);
return { typingUsers };
}
// ==================== useGroupMuted - 群聊禁言状态 ====================
interface UseGroupMutedReturn {
isMuted: boolean;
setMuted: (muted: boolean) => void;
}
/**
* 获取群聊禁言状态
* 使用 Zustand selector 直接订阅
*/
export function useGroupMuted(groupId: string | null, currentUserId?: string): UseGroupMutedReturn {
// 使用 Zustand selector 直接订阅禁言状态
const isMuted = useMessageStore(state =>
groupId ? (state.mutedStatusMap.get(groupId) || false) : false
);
const setMuted = useCallback((muted: boolean) => {
if (groupId) {
messageManager.setMutedStatus(groupId, muted);
}
}, [groupId]);
return { isMuted, setMuted };
}
/**
* MessageListScreen专用Hook组合
*/
interface UseMessageListReturn {
conversations: ConversationResponse[];
isLoading: boolean;
refresh: () => Promise<void>;
loadMore: () => Promise<void>;
hasMore: boolean;
totalUnreadCount: number;
systemUnreadCount: number;
markAllAsRead: () => Promise<void>;
}
export function useMessageList(): UseMessageListReturn {
const { conversations, isLoading, refresh, loadMore, hasMore } = useConversations();
const { totalUnreadCount, systemUnreadCount } = useUnreadCount();
const { markAllAsRead } = useMarkAsRead(null);
return {
conversations,
isLoading,
refresh,
loadMore,
hasMore,
totalUnreadCount,
systemUnreadCount,
markAllAsRead,
};
}