diff --git a/app.json b/app.json index 5529d14..065be4f 100644 --- a/app.json +++ b/app.json @@ -25,7 +25,7 @@ ], "ITSAppUsesNonExemptEncryption": false }, - "bundleIdentifier": "cn.qczlit.withyou" + "bundleIdentifier": "cn.qczlit.weiyou" }, "android": { "adaptiveIcon": { @@ -66,6 +66,7 @@ "favicon": "./assets/favicon.png" }, "plugins": [ + "./plugins/withSigning", "./plugins/withMainActivityConfigChange", [ "./plugins/withForegroundService", diff --git a/app/_layout.tsx b/app/_layout.tsx index e854b2b..6c01cda 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -161,6 +161,7 @@ function NotificationBootstrap() { const conversationType = extras.conversation_type || extras.conversationType; const groupId = extras.group_id || extras.groupId; const groupName = extras.group_name || extras.groupName; + const groupAvatar = extras.group_avatar || extras.groupAvatar; if (conversationId) { const isGroup = conversationType === 'group'; @@ -169,6 +170,7 @@ function NotificationBootstrap() { q.set('isGroupChat', '1'); if (groupId) q.set('groupId', groupId); if (groupName) q.set('groupName', groupName); + if (groupAvatar) q.set('groupAvatar', groupAvatar); } else if (senderId) { q.set('userId', senderId); } diff --git a/assets/android-icon-foreground.png b/assets/android-icon-foreground.png index 89396dd..751751e 100644 Binary files a/assets/android-icon-foreground.png and b/assets/android-icon-foreground.png differ diff --git a/assets/android-icon-monochrome.png b/assets/android-icon-monochrome.png index 89396dd..751751e 100644 Binary files a/assets/android-icon-monochrome.png and b/assets/android-icon-monochrome.png differ diff --git a/plugins/withJPush.js b/plugins/withJPush.js index 6893fde..fee1262 100644 --- a/plugins/withJPush.js +++ b/plugins/withJPush.js @@ -95,7 +95,116 @@ const withJPush = (config, options = {}) => { config = withAppDelegate(config, (config) => { const { contents } = config.modResults; - if (!contents.includes('JPush')) { + if (contents.includes('JPush') || contents.includes('JCoreModule')) { + return config; + } + + const isSwift = contents.includes('class AppDelegate') || contents.includes('@main'); + + if (isSwift) { + let newContents = contents; + + if (!newContents.includes('import JPushRN')) { + if (newContents.includes('import UIKit')) { + newContents = newContents.replace( + /(import UIKit\n)/, + `$1import UserNotifications\n` + ); + } else { + newContents = newContents.replace( + /(internal import Expo\n)/, + `$1import UserNotifications\n` + ); + } + } + + // Swift: Add JPush init in application(_:didFinishLaunchingWithOptions:) + // Match various Swift signatures + const swiftDidFinishPatterns = [ + /public override func application\(\s*_ application: UIApplication,\s*didFinishLaunchingWithOptions launchOptions: \[UIApplication\.LaunchOptionsKey: Any\]\? = nil\s*\) -> Bool \{/, + /func application\(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: \[UIApplication\.LaunchOptionsKey: Any\]\?\?\) -> Bool \{/, + ]; + + let didInsertInit = false; + for (const pattern of swiftDidFinishPatterns) { + if (pattern.test(newContents)) { + newContents = newContents.replace( + pattern, + `$&\n // JPush initialization\n #if DEBUG\n JCoreModule.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: false)\n #else\n JCoreModule.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: true)\n #endif` + ); + didInsertInit = true; + break; + } + } + + // Fallback: find the didFinishLaunchingWithOptions method start and insert after the opening brace + if (!didInsertInit && newContents.includes('didFinishLaunchingWithOptions')) { + const lines = newContents.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes('didFinishLaunchingWithOptions') && lines[i].includes('{')) { + lines.splice(i + 1, 0, + ' // JPush initialization', + ' #if DEBUG', + ` JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: false)`, + ' #else', + ` JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: true)`, + ' #endif', + ' UNUserNotificationCenter.current().delegate = self' + ); + newContents = lines.join('\n'); + didInsertInit = true; + break; + } + } + } + + // Also add delegate = self after regex-based insertion if not already present + if (didInsertInit && !newContents.includes('UNUserNotificationCenter.current().delegate')) { + newContents = newContents.replace( + /(#endif\n)(\s+let delegate)/, + `$1 UNUserNotificationCenter.current().delegate = self\n$2` + ); + } + + // Swift: Add delegate methods before the closing brace of the class + const jpushSwiftMethods = ` + // JPush: Register for remote notifications + override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + JPUSHService.registerDeviceToken(deviceToken) + } + + override func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + NSLog("did fail to register for remote notifications with error: %@", error.localizedDescription) + } + + // JPush: Handle remote notification + override func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + JPUSHService.handleRemoteNotification(userInfo) + completionHandler(.newData) + } + + // UNUserNotificationCenter delegate: foreground notification display + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.alert, .badge, .sound]) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + JPUSHService.handleRemoteNotification(response.notification.request.content.userInfo) + completionHandler() + } +`; + + if (!newContents.includes('JPUSHService.registerDeviceToken')) { + // Find the closing brace of the AppDelegate class + const lastBraceIndex = newContents.lastIndexOf('}'); + if (lastBraceIndex !== -1) { + newContents = newContents.slice(0, lastBraceIndex) + jpushSwiftMethods + '\n' + newContents.slice(lastBraceIndex); + } + } + + config.modResults.contents = newContents; + } else { + // ObjC AppDelegate let newContents = contents; if (!newContents.includes('#import ')) { @@ -116,7 +225,7 @@ const withJPush = (config, options = {}) => { if (didFinishLaunchPattern.test(newContents)) { newContents = newContents.replace( didFinishLaunchPattern, - `$1\n // JPush initialization\n [JCoreModule setupWithOption:launchOptions appKey:@"${appKey}" channel:@"${channel}" apsForProduction:NO];\n` + `$1\n // JPush initialization\n #if DEBUG\n [JCoreModule setupWithOption:launchOptions appKey:@"${appKey}" channel:@"${channel}" apsForProduction:NO];\n #else\n [JCoreModule setupWithOption:launchOptions appKey:@"${appKey}" channel:@"${channel}" apsForProduction:YES];\n #endif\n` ); } @@ -160,7 +269,26 @@ const withJPush = (config, options = {}) => { return config; }); - // 4. iOS: Add required capabilities and permissions to Info.plist + // 4. iOS: Add bridging header for Swift if needed + config = withDangerousMod(config, [ + 'ios', + async (config) => { + const appDelegatePath = path.join(config.modRequest.platformProjectRoot, 'app', 'AppDelegate.swift'); + if (fs.existsSync(appDelegatePath)) { + const bridgingHeaderSearch = path.join(config.modRequest.platformProjectRoot, 'app', 'app-Bridging-Header.h'); + if (fs.existsSync(bridgingHeaderSearch)) { + let bridgingContent = fs.readFileSync(bridgingHeaderSearch, 'utf-8'); + if (!bridgingContent.includes('JCoreModule')) { + bridgingContent += '\n#import "JPUSHService.h"\n#import \n'; + fs.writeFileSync(bridgingHeaderSearch, bridgingContent); + } + } + } + return config; + }, + ]); + + // 5. iOS: Add required capabilities and permissions to Info.plist config = withInfoPlist(config, (config) => { config.modResults.UIBackgroundModes = config.modResults.UIBackgroundModes || []; if (!config.modResults.UIBackgroundModes.includes('remote-notification')) { diff --git a/plugins/withSigning.js b/plugins/withSigning.js new file mode 100644 index 0000000..56ae607 --- /dev/null +++ b/plugins/withSigning.js @@ -0,0 +1,41 @@ +const { withDangerousMod } = require('@expo/config-plugins'); +const fs = require('fs'); +const path = require('path'); + +const withSigning = (config, options = {}) => { + const { teamId = 'X3964MGXHG' } = options; + + config = withDangerousMod(config, [ + 'ios', + async (config) => { + const platformRoot = config.modRequest.platformProjectRoot; + + // 1. Inject CODE_SIGN_STYLE and DEVELOPMENT_TEAM into pbxproj + const pbxprojPath = path.join(platformRoot, 'app.xcodeproj', 'project.pbxproj'); + if (fs.existsSync(pbxprojPath)) { + let contents = fs.readFileSync(pbxprojPath, 'utf-8'); + const entitlementsLine = 'CODE_SIGN_ENTITLEMENTS = app/app.entitlements;'; + const signingBlock = `${entitlementsLine}\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tDEVELOPMENT_TEAM = ${teamId};`; + contents = contents.replaceAll(entitlementsLine, signingBlock); + fs.writeFileSync(pbxprojPath, contents); + } + + // 2. Set aps-environment to production in entitlements + const entitlementsPath = path.join(platformRoot, 'app', 'app.entitlements'); + if (fs.existsSync(entitlementsPath)) { + let entitlements = fs.readFileSync(entitlementsPath, 'utf-8'); + entitlements = entitlements.replace( + /aps-environment<\/key>\s*development<\/string>/, + 'aps-environment\n\tproduction' + ); + fs.writeFileSync(entitlementsPath, entitlements); + } + + return config; + }, + ]); + + return config; +}; + +module.exports = withSigning; \ No newline at end of file diff --git a/src/navigation/hrefs.ts b/src/navigation/hrefs.ts index c960498..dae9ef8 100644 --- a/src/navigation/hrefs.ts +++ b/src/navigation/hrefs.ts @@ -86,13 +86,15 @@ export function hrefChat(params: { isGroupChat?: boolean; groupId?: string; groupName?: string; + groupAvatar?: string; }): string { - const { conversationId, userId, isGroupChat, groupId, groupName } = params; + const { conversationId, userId, isGroupChat, groupId, groupName, groupAvatar } = params; const q = new URLSearchParams(); if (userId) q.set('userId', userId); if (isGroupChat) q.set('isGroupChat', '1'); if (groupId != null && groupId !== '') q.set('groupId', String(groupId)); if (groupName) q.set('groupName', groupName); + if (groupAvatar) q.set('groupAvatar', groupAvatar); const qs = q.toString(); return `/chat/${encodeURIComponent(conversationId)}${qs ? `?${qs}` : ''}`; } diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index a6b1cec..ca0f92d 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -165,8 +165,8 @@ export const ChatScreen: React.FC = (props) => { muteAll, followRestrictionHint, canSendPrivateImage, - routeGroupId, - routeGroupName, + effectiveGroupId, + effectiveGroupName, otherUserLastReadSeq, messageMap, loadingMore, @@ -458,7 +458,7 @@ export const ChatScreen: React.FC = (props) => { isGroupChat={isGroupChat} groupInfo={groupInfo} otherUser={otherUser} - routeGroupName={routeGroupName ?? undefined} + routeGroupName={effectiveGroupName ?? undefined} typingHint={typingHint} onBack={props.onEmbeddedBack ? props.onEmbeddedBack : () => router.back()} onTitlePress={navigateToInfo} @@ -669,7 +669,7 @@ export const ChatScreen: React.FC = (props) => { {/* 群信息侧边栏面板 - 仅大屏幕显示 */} { conversationId: String(conversation.id), groupId: String(conversation.group.id), groupName: conversation.group.name, + groupAvatar: conversation.group.avatar, isGroupChat: true, }) ); diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index 5ebbc81..3266caf 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -96,6 +96,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { isGroupChat?: string | string[]; groupId?: string | string[]; groupName?: string | string[]; + groupAvatar?: string | string[]; }>(); // 路由参数(动态段 + query);群 ID 勿用 Number(),避免超过 2^53-1 时精度丢失 // 嵌入式模式下优先使用 props,否则从路由参数获取 @@ -107,6 +108,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { isGroupChat: props.embeddedIsGroupChat ?? false, groupId: props.embeddedGroupId ?? null, groupName: props.embeddedGroupName ?? null, + groupAvatar: null as string | null, }; } const isGroupFlag = firstRouteParam(rawParams.isGroupChat); @@ -116,6 +118,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { isGroupChat: isGroupFlag === '1' || isGroupFlag === 'true', groupId: firstRouteParam(rawParams.groupId), groupName: firstRouteParam(rawParams.groupName), + groupAvatar: firstRouteParam(rawParams.groupAvatar), }; }, [ props?.embeddedConversationId, @@ -127,9 +130,10 @@ export const useChatScreen = (props?: ChatScreenProps) => { rawParams.isGroupChat, rawParams.groupId, rawParams.groupName, + rawParams.groupAvatar, ]); - const { conversationId: routeConversationId, userId: routeUserId, isGroupChat, groupId: routeGroupId, groupName: routeGroupName } = routeParams; + const { conversationId: routeConversationId, userId: routeUserId, isGroupChat, groupId: routeGroupId, groupName: routeGroupName, groupAvatar: routeGroupAvatar } = routeParams; // 当前会话ID(可能在新建私聊会话后动态更新) const [conversationId, setConversationId] = useState(routeConversationId); @@ -147,13 +151,18 @@ export const useChatScreen = (props?: ChatScreenProps) => { 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(''); // 【新架构】使用 MessageManager 获取群聊输入状态和禁言状态 - const { typingUsers: groupTypingUsers } = useGroupTyping(routeGroupId ? String(routeGroupId) : null); + const { typingUsers: groupTypingUsers } = useGroupTyping(effectiveGroupId ? String(effectiveGroupId) : null); const { isMuted: isMutedFromManager, setMuted: setMutedStatus } = useGroupMuted( - routeGroupId ? String(routeGroupId) : null, + effectiveGroupId ? String(effectiveGroupId) : null, currentUserId ); @@ -202,8 +211,18 @@ export const useChatScreen = (props?: ChatScreenProps) => { const [selectedMessageId, setSelectedMessageId] = useState(null); // 群聊相关状态 - const [groupInfo, setGroupInfo] = useState<{ name?: string; member_count?: number } | null>(null); + const [groupInfo, setGroupInfo] = useState<{ name?: string; member_count?: number; avatar?: string | null } | null>(null); const [groupMembers, setGroupMembers] = useState([]); + + // 群名/头像:优先使用路由参数(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('member'); const [mentionQuery, setMentionQuery] = useState(''); const [selectedMentions, setSelectedMentions] = useState([]); @@ -453,14 +472,14 @@ export const useChatScreen = (props?: ChatScreenProps) => { // 群聊模式:获取群组信息和成员列表 useEffect(() => { - if (!isGroupChat || !routeGroupId) return; + if (!isGroupChat || !effectiveGroupId) return; const fetchGroupInfo = async () => { try { - const group = await groupManager.getGroup(routeGroupId); + const group = await groupManager.getGroup(effectiveGroupId); setGroupInfo(group); - const membersResponse = await groupManager.getMembers(routeGroupId, 1, MEMBERS_PAGE_SIZE); + const membersResponse = await groupManager.getMembers(effectiveGroupId, 1, MEMBERS_PAGE_SIZE); const members = membersResponse.list || []; setGroupMembers(members); @@ -470,7 +489,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { } try { - const myInfo = await groupService.getMyMemberInfo(routeGroupId); + const myInfo = await groupService.getMyMemberInfo(effectiveGroupId); setIsMuted(myInfo.is_muted); setMutedStatus(myInfo.is_muted); setMuteAll(myInfo.mute_all); @@ -508,7 +527,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { if (currentUserId) { fetchGroupInfo(); } - }, [isGroupChat, routeGroupId, currentUserId, setMutedStatus]); + }, [isGroupChat, effectiveGroupId, currentUserId, setMutedStatus]); // 私聊模式:创建或获取会话 useEffect(() => { @@ -1066,7 +1085,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { return; } - if (isGroupChat && routeGroupId) { + if (isGroupChat && effectiveGroupId) { await messageService.sendMessageByAction('group', conversationId, segments); setInputText(''); @@ -1097,7 +1116,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { pendingAttachments, conversationId, isGroupChat, - routeGroupId, + effectiveGroupId, sendMessageViaManager, isMuted, muteAll, @@ -1264,7 +1283,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { try { const segments = buildImageSegments(stickerUrl, stickerUrl, undefined, undefined, replyingTo); - if (isGroupChat && routeGroupId) { + if (isGroupChat && effectiveGroupId) { await messageService.sendMessageByAction('group', conversationId, segments); setReplyingTo(null); } else { @@ -1282,7 +1301,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { } finally { setSendingImage(false); } - }, [conversationId, isGroupChat, routeGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, canSendPrivateImage, scrollToLatest]); + }, [conversationId, isGroupChat, effectiveGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, canSendPrivateImage, scrollToLatest]); // 切换表情面板 const toggleEmojiPanel = useCallback(() => { @@ -1310,7 +1329,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { // 撤回消息 - 现在通过 MessageManager 处理 const handleRecall = useCallback(async (messageId: string) => { try { - if (isGroupChat && routeGroupId) { + if (isGroupChat && effectiveGroupId) { await messageService.recallMessage(messageId); } else { await messageService.recallMessage(messageId); @@ -1320,7 +1339,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { console.error('撤回消息失败:', error); Alert.alert('撤回失败', '无法撤回消息'); } - }, [isGroupChat, routeGroupId, conversationId]); + }, [isGroupChat, effectiveGroupId, conversationId]); // 长按消息显示操作菜单 const handleLongPressMessage = useCallback((message: GroupMessage, position?: MenuPosition) => { @@ -1455,17 +1474,17 @@ export const useChatScreen = (props?: ChatScreenProps) => { // 导航到群组信息或用户资料 const navigateToInfo = useCallback(() => { - if (isGroupChat && routeGroupId) { - router.push(hrefs.hrefGroupInfo(routeGroupId, conversationId || undefined)); + if (isGroupChat && effectiveGroupId) { + router.push(hrefs.hrefGroupInfo(effectiveGroupId, conversationId || undefined)); } else if (otherUser?.id) { router.push(hrefs.hrefUserProfile(String(otherUser.id))); } - }, [isGroupChat, routeGroupId, otherUser, conversationId]); + }, [isGroupChat, effectiveGroupId, otherUser, conversationId]); // 导航到聊天管理页面 const navigateToChatSettings = useCallback(() => { - if (isGroupChat && routeGroupId) { - router.push(hrefs.hrefGroupInfo(routeGroupId, conversationId || undefined)); + if (isGroupChat && effectiveGroupId) { + router.push(hrefs.hrefGroupInfo(effectiveGroupId, conversationId || undefined)); } else if (otherUser?.id && conversationId) { router.push( hrefs.hrefPrivateChatInfo({ @@ -1476,7 +1495,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { }) ); } - }, [isGroupChat, routeGroupId, otherUser, conversationId]); + }, [isGroupChat, effectiveGroupId, otherUser, conversationId]); return { // 状态 @@ -1512,8 +1531,8 @@ export const useChatScreen = (props?: ChatScreenProps) => { muteAll, followRestrictionHint, canSendPrivateImage, - routeGroupId, - routeGroupName, + effectiveGroupId, + effectiveGroupName, otherUserLastReadSeq, messageMap, loadingMore, diff --git a/src/screens/profile/PrivacySettingsScreen.tsx b/src/screens/profile/PrivacySettingsScreen.tsx index 2bae9c3..95c7925 100644 --- a/src/screens/profile/PrivacySettingsScreen.tsx +++ b/src/screens/profile/PrivacySettingsScreen.tsx @@ -333,7 +333,7 @@ export const PrivacySettingsScreen: React.FC = () => { variant="body" style={[ styles.dropdownOptionText, - isActive && styles.dropdownOptionTextActive, + ...(isActive ? [styles.dropdownOptionTextActive] : []), ]} > {option.label} diff --git a/src/screens/profile/SettingsScreen.tsx b/src/screens/profile/SettingsScreen.tsx index c5a3ea1..d13a0ae 100644 --- a/src/screens/profile/SettingsScreen.tsx +++ b/src/screens/profile/SettingsScreen.tsx @@ -408,7 +408,7 @@ export const SettingsScreen: React.FC = () => { i.isThemePicker && openPicker === 'theme') ? 10 : groupIndex }, + { overflow: 'visible', zIndex: group.items.some((i) => 'isThemePicker' in i && i.isThemePicker && openPicker === 'theme') ? 10 : groupIndex }, ]} > diff --git a/src/services/message/messageService.ts b/src/services/message/messageService.ts index b1863c3..2cacf0f 100644 --- a/src/services/message/messageService.ts +++ b/src/services/message/messageService.ts @@ -94,13 +94,6 @@ class MessageService { } } - private async refreshConversationsFromServer( - page = 1, - pageSize = 20 - ): Promise { - return this.requestOffsetConversationsPage(page, pageSize); - } - /** * 远端会话列表单页(offset / cursor 统一入口) * 两种模式均先写 SQLite 再返回,行为与 NetworkRemoteConversationListPagedSource 配套。 @@ -175,50 +168,6 @@ class MessageService { }; } - // ==================== 会话相关 ==================== - - /** - * 获取会话列表 - * GET /api/v1/conversations/list - */ - async getConversations( - page = 1, - pageSize = 20, - forceRefresh = false - ): Promise { - // 如果强制刷新,直接从服务器获取,不使用缓存 - if (!forceRefresh) { - const cachedList = await conversationRepository.getListCache(); - if (cachedList.length > 0) { - // 后台刷新数据,但不阻塞当前返回 - this.refreshConversationsFromServer(page, pageSize).catch(error => { - console.error('后台刷新会话列表失败:', error); - }); - return { - list: cachedList, - total: cachedList.length, - page: 1, - page_size: cachedList.length, - total_pages: 1, - }; - } - } - - // 强制刷新或没有缓存时,直接从服务器获取 - try { - return await this.refreshConversationsFromServer(page, pageSize); - } catch (error) { - console.error('获取会话列表失败:', error); - return { - list: [], - total: 0, - page: 1, - page_size: pageSize, - total_pages: 0, - }; - } - } - /** * 创建私聊会话 * POST /api/v1/conversations @@ -428,98 +377,6 @@ class MessageService { } } - /** - * 发送文本消息(便捷方法) - */ - async sendTextMessage( - conversationId: string, - content: string, - detailType: 'private' | 'group' = 'private' - ): Promise { - return this.sendTextMessageByAction(detailType, conversationId, content); - } - - /** - * 通过 HTTP POST 发送文本消息 - * @param detailType 消息类型: 'private' 私聊, 'group' 群聊 - * @param conversationId 会话ID - * @param text 文本内容 - * @returns 消息响应 - */ - async sendTextMessageByAction( - detailType: 'private' | 'group', - conversationId: string, - text: string - ): Promise { - const message: MessageSegment[] = [ - { - type: 'text', - data: { - text: text, - }, - }, - ]; - return this.sendMessageByAction(detailType, conversationId, message); - } - - /** - * 发送图片消息(便捷方法) - */ - async sendImageMessage( - conversationId: string, - mediaUrl: string, - content?: string, - detailType: 'private' | 'group' = 'private' - ): Promise { - const message: MessageSegment[] = [ - { - type: 'image', - data: { - url: mediaUrl, - }, - }, - ]; - // 如果有文本说明,添加一个文本段 - if (content) { - message.unshift({ - type: 'text', - data: { text: content }, - }); - } - return this.sendMessageByAction(detailType, conversationId, message); - } - - /** - * 发送 segments 格式消息(新版统一格式) - * @param conversationId 会话ID - * @param segments 消息链数组 - * @param detailType 消息类型 - */ - async sendSegmentsMessage( - conversationId: string, - segments: MessageSegment[], - detailType: 'private' | 'group' = 'private' - ): Promise { - return this.sendMessageByAction(detailType, conversationId, segments); - } - - /** - * 发送带回复的 segments 消息 - * @param conversationId 会话ID - * @param segments 消息链数组 - * @param replyToId 被回复消息的ID - * @param detailType 消息类型 - */ - async sendReplySegmentsMessage( - conversationId: string, - segments: MessageSegment[], - replyToId: string, - detailType: 'private' | 'group' = 'private' - ): Promise { - // 使用 reply_to_id 参数发送回复消息 - return this.sendMessageByAction(detailType, conversationId, segments, replyToId); - } - /** * 撤回/删除消息 * 优先使用WebSocket,失败时降级到HTTP @@ -568,6 +425,19 @@ class MessageService { }); } + /** + * 批量标记所有会话已读 + * POST /api/v1/conversations/read-all + */ + async markAllAsRead(conversations: Array<{ id: string; lastSeq: number }>): Promise { + await api.post('/conversations/read-all', { + conversations: conversations.map(c => ({ + conversation_id: c.id, + last_read_seq: Number(c.lastSeq), + })), + }); + } + /** * 上报输入状态 * 优先使用WebSocket,失败时降级到HTTP @@ -598,65 +468,12 @@ class MessageService { } /** - * 获取单个会话未读数 - * GET /api/v1/conversations/unread/count?conversation_id=xxx + * 获取同步元数据(轻量级 seq + 时间,用于增量同步判断) + * GET /api/v1/conversations/sync-data */ - async getConversationUnreadCount( - conversationId: string - ): Promise { - try { - const response = await api.get( - '/conversations/unread/count', - { conversation_id: conversationId } - ); - return response.data; - } catch (error) { - console.error('获取会话未读数失败:', error); - return { - conversation_id: conversationId, - unread_count: 0, - }; - } - } - - // ==================== 系统消息相关 ==================== - - /** - * 获取系统消息列表 - * GET /api/v1/messages/system - * @param limit 返回数量限制 - * @param beforeId 获取此ID之前的消息(用于分页) - */ - async getSystemMessages( - pageSize: number = 20, - page: number = 1 - ): Promise { - try { - const response = await api.get<{ - list: any[]; - total: number; - page: number; - page_size: number; - total_pages: number; - }>('/messages/system', { - page, - page_size: pageSize, - }); - - const data = response.data; - return { - messages: data.list || [], - total: data.total || 0, - has_more: data.page < data.total_pages, - }; - } catch (error) { - console.error('获取系统消息列表失败:', error); - return { - messages: [], - total: 0, - has_more: false, - }; - } + async getSyncData(): Promise> { + const response = await api.get<{ conversations: Array<{ id: string; max_seq: number; last_message_at: string }> }>('/conversations/sync-data'); + return response.data?.conversations || []; } /** @@ -692,41 +509,6 @@ class MessageService { await api.put('/messages/system/read-all'); } - // ==================== 游标分页方法 ==================== - - /** - * 获取消息列表(游标分页) - * GET /api/v1/conversations/:id/messages/cursor - * @param conversationId 会话ID - * @param params 游标分页请求参数 - */ - async getMessagesCursor( - conversationId: string, - params: CursorPaginationRequest = {} - ): Promise> { - try { - const response = await api.get( - `/conversations/${encodeURIComponent(conversationId)}/messages/cursor`, - params - ); - const raw = response.data; - return { - list: Array.isArray(raw?.list) ? raw.list : [], - next_cursor: raw?.next_cursor ?? null, - prev_cursor: raw?.prev_cursor ?? null, - has_more: raw?.has_more ?? false, - }; - } catch (error) { - console.error('获取消息列表失败:', error); - return { - list: [], - next_cursor: null, - prev_cursor: null, - has_more: false, - }; - } - } - /** * 获取系统消息列表(游标分页) * GET /api/v1/messages/system/cursor diff --git a/src/services/notification/jpushService.ts b/src/services/notification/jpushService.ts index a42b7d8..c3226d3 100644 --- a/src/services/notification/jpushService.ts +++ b/src/services/notification/jpushService.ts @@ -26,7 +26,7 @@ try { const Constants = require('expo-constants').default; const extra = Constants?.expoConfig?.extra || Constants?.extra || {}; jpushAppKey = extra.jpushAppKey || ''; - jpushChannel = extra.jpushChannel || 'withyou-default'; + jpushChannel = extra.jpushChannel || 'developer-default'; } else { console.warn('[JPush] native module not linked, skipping'); } diff --git a/src/stores/message/MessageManager.ts b/src/stores/message/MessageManager.ts index 4d94009..c95666b 100644 --- a/src/stores/message/MessageManager.ts +++ b/src/stores/message/MessageManager.ts @@ -1,14 +1,8 @@ /** - * MessageManager - 消息管理核心模块(重构版) - * + * MessageManager - 消息管理核心模块 + * * 作为轻量级协调者,整合各个专注的服务模块 * 直接使用 zustand store 进行状态管理 - * - * 重构说明: - * - 移除了 MessageStateManager 包装层 - * - 直接使用 zustand store - * - 服务层移至 services/ 目录 - * - 移除了 SubscriptionManager,改用 Zustand selector 进行响应式订阅 */ import type { ConversationResponse, MessageResponse, MessageSegment, UserDTO } from '../../types/dto'; @@ -29,7 +23,6 @@ import { useMessageStore, normalizeConversationId, loadPersistedLastSystemMessag // 重新导出类型,保持兼容性 export type { - MessageManagerState, ReadStateRecord, MessageManagerConversationListDeps, } from './types'; @@ -74,6 +67,7 @@ class MessageManager { fetchConversations: (force, source) => this.fetchConversations(force, source), fetchUnreadCount: () => this.fetchUnreadCount(), fetchMessages: (id) => this.fetchMessages(id), + syncBySeq: () => this.syncService.syncBySeq(), } ); @@ -350,6 +344,11 @@ class MessageManager { } const task = (async () => { + // 确保会话数据在 store 中(JPUSH 通知进入时 store 可能还没有此会话) + const store = useMessageStore.getState(); + if (!store.getConversation(normalizedId)) { + await this.fetchConversationDetail(normalizedId); + } await this.fetchMessages(normalizedId); })(); this.activatingConversationTasks.set(normalizedId, task); @@ -380,7 +379,7 @@ class MessageManager { private shouldDeferConversationRefresh(source: string, forceRefresh: boolean): boolean { if (!this.getActiveConversation()) return false; if (!forceRefresh) return false; - return source === 'sse-reconnect' || source === 'prefetch' || source === 'hooks-initial-refresh'; + return source === 'sse-reconnect' || source === 'prefetch'; } } diff --git a/src/stores/message/baseHooks.ts b/src/stores/message/baseHooks.ts index 4638d03..972aa41 100644 --- a/src/stores/message/baseHooks.ts +++ b/src/stores/message/baseHooks.ts @@ -1,14 +1,9 @@ /** * 消息模块 React Hooks - * + * * 提供React组件与消息状态管理的集成 * 所有hooks都基于zustand store的selector机制 * 确保组件能实时获取状态更新 - * - * 重构说明: - * - 移除了 SubscriptionManager,改用 Zustand selector - * - Zustand 会自动处理依赖追踪和组件重渲染 - * - 不需要手动管理订阅/取消订阅 */ import { useState, useEffect, useCallback, useRef } from 'react'; diff --git a/src/stores/message/index.ts b/src/stores/message/index.ts index c202853..d5f0b52 100644 --- a/src/stores/message/index.ts +++ b/src/stores/message/index.ts @@ -1,12 +1,5 @@ /** * 消息模块统一导出 - * - * 重构说明: - * - 移除了 MessageStateManager 包装层 - * - 直接使用 zustand store 进行状态管理 - * - 服务层移至 services/ 目录 - * - 新增 hooks.ts 封装常用逻辑 - * - 移除了 SubscriptionManager,改用 Zustand selector 进行响应式订阅 */ // ==================== 主管理器 ==================== @@ -24,7 +17,6 @@ export type { MessageState, MessageActions, MessageStore } from './store'; // ==================== 类型导出 ==================== // 从 types.ts 导出所有类型 export type { - MessageManagerState, ReadStateRecord, MessageManagerConversationListDeps, HandleNewMessageOptions, diff --git a/src/stores/message/services/ConversationOperations.ts b/src/stores/message/services/ConversationOperations.ts index 3b6c715..73549c8 100644 --- a/src/stores/message/services/ConversationOperations.ts +++ b/src/stores/message/services/ConversationOperations.ts @@ -1,10 +1,6 @@ /** * 会话操作服务 * 处理会话的 CRUD 操作 - * - * 重构说明: - * - 直接使用 zustand store 替代 IMessageStateManager - * - 移除了 SubscriptionManager 调用,Zustand 的 set() 会自动触发组件重渲染 */ import type { ConversationResponse } from '../../../types/dto'; @@ -102,32 +98,6 @@ export class ConversationOperations implements IConversationOperations { ); } - /** - * 增加会话未读数 - */ - incrementUnreadCount(conversationId: string): void { - const store = useMessageStore.getState(); - const conversation = store.getConversation(conversationId); - if (!conversation) return; - - const prevUnreadCount = conversation.unread_count || 0; - const newUnreadCount = prevUnreadCount + 1; - - // 创建新对象而不是直接修改,确保状态一致性 - const updatedConv: ConversationResponse = { - ...conversation, - unread_count: newUnreadCount, - }; - - store.updateConversation(updatedConv); - - const currentUnread = store.getUnreadCount(); - store.setUnreadCount( - currentUnread.total + 1, - currentUnread.system - ); - } - /** * 设置系统消息未读数 */ diff --git a/src/stores/message/services/MessageSendService.ts b/src/stores/message/services/MessageSendService.ts index 4bfe214..cffd26b 100644 --- a/src/stores/message/services/MessageSendService.ts +++ b/src/stores/message/services/MessageSendService.ts @@ -1,10 +1,6 @@ /** * 消息发送服务 * 处理消息发送相关逻辑 - * - * 重构说明: - * - 直接使用 zustand store 替代 IMessageStateManager - * - 移除了 SubscriptionManager 调用,Zustand 的 set() 会自动触发组件重渲染 */ import type { MessageResponse, MessageSegment, ConversationResponse } from '../../../types/dto'; diff --git a/src/stores/message/services/MessageSyncService.ts b/src/stores/message/services/MessageSyncService.ts index 30be7b4..117b99e 100644 --- a/src/stores/message/services/MessageSyncService.ts +++ b/src/stores/message/services/MessageSyncService.ts @@ -1,10 +1,6 @@ /** * 消息同步服务 * 处理消息和会话的服务器同步逻辑 - * - * 重构说明: - * - 直接使用 zustand store 替代 IMessageStateManager - * - 移除了 SubscriptionManager 调用,Zustand 的 set() 会自动触发组件重渲染 */ import type { ConversationResponse, MessageResponse } from '../../../types/dto'; @@ -33,8 +29,8 @@ export class MessageSyncService implements IMessageSyncService { /** 正在加载会话列表下一页 */ private loadingMoreConversations = false; - /** 聊天页活跃期间延迟的会话列表刷新 */ - private deferredConversationRefresh = false; + /** fetchUnreadCount 去重:复用 in-flight promise */ + private fetchUnreadCountPromise: Promise | null = null; constructor( getCurrentUserId: () => string | null, @@ -68,7 +64,6 @@ export class MessageSyncService implements IMessageSyncService { } if (this.shouldDeferConversationRefresh(source, forceRefresh)) { - this.deferredConversationRefresh = true; if (__DEV__) { console.log('[MessageSyncService] defer fetchConversations', { source, @@ -108,15 +103,12 @@ export class MessageSyncService implements IMessageSyncService { newConversations.set(normalizedConv.id, normalizedConv); }); - // 使用 store.setConversations 会自动排序并更新 conversationList - store.setConversations(newConversations); - - // 计算并更新未读数 + // 合并更新:conversations + totalUnread 在同一次 set 中完成,消除中间状态窗口 const totalUnread = Array.from(newConversations.values()).reduce( (sum, conv) => sum + (conv.unread_count || 0), - 0 + 0, ); - store.setUnreadCount(totalUnread, store.getUnreadCount().system); + store.setConversationsWithUnread(newConversations, totalUnread, store.getUnreadCount().system); this.persistConversationListCache(); } catch (error) { @@ -209,8 +201,6 @@ export class MessageSyncService implements IMessageSyncService { if (!hasInMemoryMessages) { try { const localMessages = await messageRepository.getByConversation(conversationId, 20); - const localMaxSeq = await messageRepository.getMaxSeq(conversationId); - baselineMaxSeq = localMaxSeq; if (localMessages.length > 0) { const formattedMessages: MessageResponse[] = localMessages.map(m => ({ @@ -225,7 +215,6 @@ export class MessageSyncService implements IMessageSyncService { store.setMessages(conversationId, formattedMessages); } else { - // 冷启动兜底:先设置空列表 store.setMessages(conversationId, []); } } catch (error) { @@ -233,32 +222,20 @@ export class MessageSyncService implements IMessageSyncService { } } - // 服务端快照 + 增量同步 - try { - const snapshotResp = await messageService.getMessages(conversationId, undefined, undefined, 50); - const snapshotMessages = snapshotResp?.messages || []; - - if (snapshotMessages.length > 0) { - const existingMessages = store.getMessages(conversationId); - const mergedSnapshot = mergeMessagesById(existingMessages, snapshotMessages); - store.setMessages(conversationId, mergedSnapshot); + // 更新 baseline:合并本地DB后重新计算 + const currentMessages = store.getMessages(conversationId); + baselineMaxSeq = currentMessages.reduce((max, m) => Math.max(max, m.seq || 0), 0); - messageRepository.saveMessagesBatch( - MessageMapper.toCachedMessages(snapshotMessages, conversationId) - ).catch(error => { - console.error('[MessageSyncService] 保存快照消息到本地失败:', error); - }); - } - - // 增量补齐 - const snapshotMaxSeq = snapshotMessages.reduce((max, m) => Math.max(max, m.seq || 0), 0); - if (snapshotMaxSeq > baselineMaxSeq) { + // 策略:有本地数据时仅增量同步,无数据时拉快照 + if (baselineMaxSeq > 0) { + // 增量同步:只拉 baselineMaxSeq 之后的消息 + try { const incrementalResp = await messageService.getMessages(conversationId, baselineMaxSeq); const newMessages = incrementalResp?.messages || []; - + if (newMessages.length > 0) { - const existingMessages = store.getMessages(conversationId); - const mergedMessages = mergeMessagesById(existingMessages, newMessages); + const existingMsgs = store.getMessages(conversationId); + const mergedMessages = mergeMessagesById(existingMsgs, newMessages); store.setMessages(conversationId, mergedMessages); messageRepository.saveMessagesBatch( @@ -267,9 +244,27 @@ export class MessageSyncService implements IMessageSyncService { console.error('[MessageSyncService] 保存增量消息到本地失败:', error); }); } + } catch (error) { + console.error('[MessageSyncService] 增量同步失败:', error); + } + } else { + // 冷启动:本地无数据,拉快照 + try { + const snapshotResp = await messageService.getMessages(conversationId, undefined, undefined, 50); + const snapshotMessages = snapshotResp?.messages || []; + + if (snapshotMessages.length > 0) { + store.setMessages(conversationId, snapshotMessages); + + messageRepository.saveMessagesBatch( + MessageMapper.toCachedMessages(snapshotMessages, conversationId) + ).catch(error => { + console.error('[MessageSyncService] 保存快照消息到本地失败:', error); + }); + } + } catch (error) { + console.error('[MessageSyncService] 快照同步失败:', error); } - } catch (error) { - console.error('[MessageSyncService] 快照/增量同步失败:', error); } } else { // 指定了 afterSeq @@ -360,11 +355,23 @@ export class MessageSyncService implements IMessageSyncService { } /** - * 获取未读数 + * 获取未读数(去重:复用 in-flight promise) */ async fetchUnreadCount(): Promise { + if (this.fetchUnreadCountPromise) { + return this.fetchUnreadCountPromise; + } + this.fetchUnreadCountPromise = this.doFetchUnreadCount(); + try { + await this.fetchUnreadCountPromise; + } finally { + this.fetchUnreadCountPromise = null; + } + } + + private async doFetchUnreadCount(): Promise { const store = useMessageStore.getState(); - + try { const [unreadData, systemUnreadData] = await Promise.all([ messageService.getUnreadCount(), @@ -388,28 +395,77 @@ export class MessageSyncService implements IMessageSyncService { } } - // 服务端汇总未读为 0 时,清掉内存中残留的红点 - if (totalUnread === 0) { - const currentConversations = store.getState().conversations; - let anyCleared = false; - const newConversations = new Map(currentConversations); - for (const [cid, conv] of newConversations) { - if ((conv.unread_count || 0) > 0) { - newConversations.set(cid, { ...conv, unread_count: 0 }); - anyCleared = true; - } - } - if (anyCleared) { - // 使用 zustand set 函数正确更新状态 - store.setConversations(newConversations); - this.persistConversationListCache(); - } - } + // 服务端汇总未读为 0:不主动清零本地未读,等 fetchConversations 提供权威数据 + // 避免后端缓存竞态导致的未读数抖动 } catch (error) { console.error('[MessageSyncService] 获取未读数失败:', error); } } + /** + * 基于 seq 的轻量增量同步(用于重连场景,替代全量刷新) + * 返回 true 表示同步成功,false 表示需要退化为全量刷新 + */ + async syncBySeq(): Promise { + try { + const serverItems = await messageService.getSyncData(); + if (!serverItems || serverItems.length === 0) return false; + + const store = useMessageStore.getState(); + const localConversations = store.conversations; + + // 没有本地数据,无法增量同步 + if (localConversations.size === 0) return false; + + // 构建 serverMap + const serverMap = new Map(); + for (const item of serverItems) { + serverMap.set(normalizeConversationId(item.id), item); + } + + // 对比差异 + const staleIds: string[] = []; + for (const [id, conv] of localConversations) { + const serverItem = serverMap.get(id); + if (!serverItem) continue; + const localMaxSeq = Number(conv.last_seq || 0); + const serverMaxSeq = Number(serverItem.max_seq || 0); + if (serverMaxSeq > localMaxSeq) { + staleIds.push(id); + } + } + + // 差异过大(>50%),退化为全量刷新 + if (staleIds.length > localConversations.size * 0.5) { + return false; + } + + // 对有差异的会话更新数据 + if (staleIds.length > 0) { + const detailPromises = staleIds.map(id => + this.fetchConversationDetail(id).catch(() => {}) + ); + await Promise.all(detailPromises); + + // 重新读取 store,避免 stale 引用 + const activeId = useMessageStore.getState().getActiveConversation(); + if (activeId && staleIds.includes(activeId)) { + const localMaxSeq = useMessageStore.getState().getMessages(activeId).reduce( + (max, m) => Math.max(max, m.seq || 0), 0 + ); + if (localMaxSeq > 0) { + await this.fetchMessages(activeId, localMaxSeq); + } + } + } + + return true; + } catch (error) { + console.error('[MessageSyncService] syncBySeq 失败:', error); + return false; + } + } + /** * 检查是否可加载更多会话 */ @@ -487,15 +543,12 @@ export class MessageSyncService implements IMessageSyncService { newConversations.set(normalizedConv.id, normalizedConv); }); - // 使用 store.setConversations 会自动排序并更新 conversationList - store.setConversations(newConversations); - - // 计算并更新未读数 + // 合并更新:conversations + totalUnread 在同一次 set 中完成 const totalUnread = Array.from(newConversations.values()).reduce( (sum, conv) => sum + (conv.unread_count || 0), - 0 + 0, ); - store.setUnreadCount(totalUnread, store.getUnreadCount().system); + store.setConversationsWithUnread(newConversations, totalUnread, store.getUnreadCount().system); return true; } diff --git a/src/stores/message/services/ReadReceiptManager.ts b/src/stores/message/services/ReadReceiptManager.ts index 52b66fb..5e18605 100644 --- a/src/stores/message/services/ReadReceiptManager.ts +++ b/src/stores/message/services/ReadReceiptManager.ts @@ -1,10 +1,6 @@ /** * 已读回执管理器 * 管理消息已读状态的同步和保护 - * - * 重构说明: - * - 直接使用 zustand store 替代 IMessageStateManager - * - 移除了 SubscriptionManager 调用,Zustand 的 set() 会自动触发组件重渲染 */ import type { ConversationResponse } from '../../../types/dto'; @@ -64,18 +60,16 @@ export class ReadReceiptManager implements IReadReceiptManager { lastReadSeq: seq, }); - // 2. 乐观更新本地状态 + // 2. 乐观更新本地状态(原子更新,避免中间渲染状态) const updatedConv: ConversationResponse = { ...conversation, unread_count: 0, my_last_read_seq: Math.max(Number(conversation.my_last_read_seq || 0), seq), }; - - store.updateConversation(updatedConv); - + const currentUnread = store.getUnreadCount(); const newTotalUnread = Math.max(0, currentUnread.total - prevUnreadCount); - store.setUnreadCount(newTotalUnread, currentUnread.system); + store.updateConversationWithUnread(updatedConv, newTotalUnread, currentUnread.system); // 3. 更新本地数据库 messageRepository.markConversationAsRead(normalizedId).catch(console.error); @@ -117,34 +111,52 @@ export class ReadReceiptManager implements IReadReceiptManager { */ async markAllAsRead(): Promise { const store = useMessageStore.getState(); - const conversations = store.conversations; + const conversationsMap = store.conversations; const prevTotalUnread = store.getUnreadCount().total; const currentSystem = store.getUnreadCount().system; - // 乐观更新 - conversations.forEach(conv => { - conv.unread_count = 0; + // 先收集所有有未读数的会话(不变原 Map) + const unreadConversations: Array<{ id: string; lastSeq: number }> = []; + conversationsMap.forEach(conv => { + if ((conv.unread_count || 0) > 0) { + unreadConversations.push({ + id: conv.id, + lastSeq: Number(conv.last_seq || 0), + }); + } }); - - store.setUnreadCount(0, currentSystem); + + if (unreadConversations.length === 0 && prevTotalUnread === 0) return; + + // 乐观更新:创建新 Map,将所有有未读的会话置零(不变原对象) + const newConversations = new Map(conversationsMap); + conversationsMap.forEach((conv, id) => { + if ((conv.unread_count || 0) > 0) { + newConversations.set(id, { ...conv, unread_count: 0 }); + } + }); + store.setConversationsWithUnread(newConversations, 0, currentSystem); try { // 标记系统消息已读 await messageService.markAllSystemMessagesRead(); - // 标记所有会话已读 - const promises: Promise[] = []; - conversations.forEach(conv => { - if ((conv.unread_count || 0) > 0) { - promises.push(messageService.markAsRead(conv.id, conv.last_seq)); + // 批量标记会话已读 + if (unreadConversations.length > 0) { + try { + await messageService.markAllAsRead(unreadConversations); + } catch { + // 降级:逐个调用 + const promises = unreadConversations.map(conv => + messageService.markAsRead(conv.id, conv.lastSeq) + ); + await Promise.all(promises); } - }); - - await Promise.all(promises); + } } catch (error) { console.error('[ReadReceiptManager] 标记所有已读失败:', error); - // 回滚 - store.setUnreadCount(prevTotalUnread, currentSystem); + // 回滚:恢复原始会话和未读数 + store.setConversationsWithUnread(new Map(conversationsMap), prevTotalUnread, currentSystem); } } diff --git a/src/stores/message/services/WSMessageHandler.ts b/src/stores/message/services/WSMessageHandler.ts index 194c9d9..77d1a19 100644 --- a/src/stores/message/services/WSMessageHandler.ts +++ b/src/stores/message/services/WSMessageHandler.ts @@ -1,10 +1,6 @@ /** * WebSocket 消息处理器 * 处理所有来自 WebSocket 的消息事件 - * - * 重构说明: - * - 直接使用 zustand store 替代 IMessageStateManager - * - 移除了 SubscriptionManager 调用,Zustand 的 set() 会自动触发组件重渲染 */ import type { @@ -39,11 +35,15 @@ export class WSMessageHandler implements IWSMessageHandler { private fetchConversationsCallback: (forceRefresh: boolean, source: string) => Promise; private fetchUnreadCountCallback: () => Promise; private fetchMessagesCallback: (conversationId: string) => Promise; + private syncBySeqCallback: () => Promise; private sseUnsubscribe: (() => void) | null = null; private isBootstrapping: boolean = false; private lastReconnectSyncAt: number = 0; + // 系统通知去重(防止重连时重复递增系统未读) + private processedNotificationIds: Set = new Set(); + // 初始化阶段缓冲来自 SSE 的消息事件 private bufferedChatMessages: Array<(WSChatMessage | WSGroupChatMessage) & { _isAck?: boolean }> = []; private bufferedReadReceipts: Array = []; @@ -57,6 +57,7 @@ export class WSMessageHandler implements IWSMessageHandler { fetchConversations: (forceRefresh: boolean, source: string) => Promise; fetchUnreadCount: () => Promise; fetchMessages: (conversationId: string) => Promise; + syncBySeq: () => Promise; } ) { this.deduplication = deduplication; @@ -66,6 +67,7 @@ export class WSMessageHandler implements IWSMessageHandler { this.fetchConversationsCallback = callbacks.fetchConversations; this.fetchUnreadCountCallback = callbacks.fetchUnreadCount; this.fetchMessagesCallback = callbacks.fetchMessages; + this.syncBySeqCallback = callbacks.syncBySeq; } /** @@ -134,6 +136,21 @@ export class WSMessageHandler implements IWSMessageHandler { if (message.is_read) { return; } + + // 按通知 ID 去重,防止重连时重复递增 + const notificationId = (message as any).id || (message as any).message_id; + if (notificationId) { + if (this.processedNotificationIds.has(String(notificationId))) { + return; + } + this.processedNotificationIds.add(String(notificationId)); + // 防止内存泄漏:超过 500 条时淘汰最旧的 100 条 + if (this.processedNotificationIds.size > 500) { + const firstIds = Array.from(this.processedNotificationIds).slice(0, 100); + firstIds.forEach(id => this.processedNotificationIds.delete(id)); + } + } + this.incrementSystemUnread(); if (message.created_at) { const store = useMessageStore.getState(); @@ -154,43 +171,62 @@ export class WSMessageHandler implements IWSMessageHandler { const now = Date.now(); if (now - this.lastReconnectSyncAt > 1500) { this.lastReconnectSyncAt = now; - const activeConversation = useMessageStore.getState().getActiveConversation(); - if (!activeConversation) { - this.fetchConversationsCallback(true, 'sse-reconnect').catch(error => { - console.error('[WSMessageHandler] 连接后同步会话失败:', error); - }); - } else { + // 优先尝试 seq 驱动的轻量同步(内部会为 stale 的活动会话拉取消息) + this.syncBySeqCallback().then(ok => { + if (!ok) { + // 增量同步失败或差异过大,退化为全量刷新 + this.fetchConversationsCallback(true, 'sse-reconnect').catch(error => { + console.error('[WSMessageHandler] 连接后同步会话失败:', error); + }); + // 退化场景下仍需同步活动会话消息 + const activeConv = useMessageStore.getState().getActiveConversation(); + if (activeConv) { + this.fetchMessagesCallback(activeConv).catch(() => {}); + } + } this.fetchUnreadCountCallback().catch(error => { console.error('[WSMessageHandler] 连接后同步未读数失败:', error); }); - } - - if (activeConversation) { - this.fetchMessagesCallback(activeConversation).catch(error => { - console.error('[WSMessageHandler] 连接后同步当前会话消息失败:', error); - }); - } + }).catch(() => { + // syncBySeq 异常,退化为全量 + this.fetchConversationsCallback(true, 'sse-reconnect').catch(() => {}); + const activeConv = useMessageStore.getState().getActiveConversation(); + if (activeConv) { + this.fetchMessagesCallback(activeConv).catch(() => {}); + } + this.fetchUnreadCountCallback().catch(() => {}); + }); } }); // 监听 sync_required 事件,触发增量同步 wsService.on('sync_required', () => { console.log('[WSMessageHandler] 收到 sync_required,开始增量同步'); - const activeConversation = useMessageStore.getState().getActiveConversation(); - this.fetchConversationsCallback(true, 'sync_required').catch(error => { - console.error('[WSMessageHandler] sync_required 同步会话失败:', error); - }); - this.fetchUnreadCountCallback().catch(error => { - console.error('[WSMessageHandler] sync_required 同步未读数失败:', error); - }); - - if (activeConversation) { - this.fetchMessagesCallback(activeConversation).catch(error => { - console.error('[WSMessageHandler] sync_required 同步消息失败:', error); + // 先尝试 seq 驱动的轻量同步(内部会为 stale 的活动会话拉取消息) + this.syncBySeqCallback().then(ok => { + if (!ok) { + this.fetchConversationsCallback(true, 'sync_required').catch(error => { + console.error('[WSMessageHandler] sync_required 同步会话失败:', error); + }); + // 退化场景下仍需同步活动会话消息 + const activeConv = useMessageStore.getState().getActiveConversation(); + if (activeConv) { + this.fetchMessagesCallback(activeConv).catch(() => {}); + } + } + this.fetchUnreadCountCallback().catch(error => { + console.error('[WSMessageHandler] sync_required 同步未读数失败:', error); }); - } + }).catch(() => { + this.fetchConversationsCallback(true, 'sync_required').catch(() => {}); + const activeConv = useMessageStore.getState().getActiveConversation(); + if (activeConv) { + this.fetchMessagesCallback(activeConv).catch(() => {}); + } + this.fetchUnreadCountCallback().catch(() => {}); + }); }); wsService.onDisconnect(() => { @@ -206,6 +242,7 @@ export class WSMessageHandler implements IWSMessageHandler { this.sseUnsubscribe(); this.sseUnsubscribe = null; } + this.processedNotificationIds.clear(); } /** @@ -510,19 +547,7 @@ export class WSMessageHandler implements IWSMessageHandler { } private incrementUnreadCount(conversationId: string): void { - const store = useMessageStore.getState(); - const conversation = store.getConversation(conversationId); - if (conversation) { - const prevUnreadCount = conversation.unread_count || 0; - const updatedConv: ConversationResponse = { - ...conversation, - unread_count: prevUnreadCount + 1, - }; - store.updateConversation(updatedConv); - - const currentUnread = store.getUnreadCount(); - store.setUnreadCount(currentUnread.total + 1, currentUnread.system); - } + useMessageStore.getState().incrementUnreadAtomic(conversationId); } private incrementSystemUnread(): void { diff --git a/src/stores/message/services/index.ts b/src/stores/message/services/index.ts index a0e86b2..47ec3f9 100644 --- a/src/stores/message/services/index.ts +++ b/src/stores/message/services/index.ts @@ -1,7 +1,5 @@ /** * 消息服务层统一导出 - * - * 重构说明:所有服务直接使用 zustand store,移除了 IMessageStateManager 接口依赖 */ // 消息去重服务 diff --git a/src/stores/message/store.ts b/src/stores/message/store.ts index 75b1179..1530c4a 100644 --- a/src/stores/message/store.ts +++ b/src/stores/message/store.ts @@ -1,11 +1,6 @@ /** * 消息状态 Zustand Store * 使用 zustand 进行状态管理,提供响应式状态更新 - * - * 重构说明: - * - 移除了 MessageStateManager 包装层 - * - 直接使用 zustand store 进行状态管理 - * - 移除了 SubscriptionManager,改用 Zustand selector 进行响应式订阅 */ import { create } from 'zustand'; @@ -78,12 +73,14 @@ export interface MessageActions { isNotificationMuted: (conversationId: string) => boolean; // 设置状态 - setConversations: (conversations: Map) => void; + setConversationsWithUnread: ( + conversations: Map, + totalUnread: number, + systemUnread: number, + ) => void; updateConversation: (conversation: ConversationResponse) => void; removeConversation: (conversationId: string) => void; setMessages: (conversationId: string, messages: MessageResponse[]) => void; - addMessage: (conversationId: string, message: MessageResponse) => void; - updateMessage: (conversationId: string, messageId: string, updates: Partial) => void; setUnreadCount: (total: number, system: number) => void; setLastSystemMessageAt: (time: string | null) => void; setSSEConnected: (connected: boolean) => void; @@ -95,6 +92,14 @@ export interface MessageActions { setNotificationMuted: (conversationId: string, notificationMuted: boolean) => void; setInitialized: (initialized: boolean) => void; + // 原子性更新(避免中间渲染状态) + updateConversationWithUnread: ( + conversation: ConversationResponse, + totalUnread: number, + systemUnread: number, + ) => void; + incrementUnreadAtomic: (conversationId: string) => void; + // 重置 reset: () => void; } @@ -240,7 +245,11 @@ export const useMessageStore = create((set, get) => ({ // ==================== 设置状态 ==================== - setConversations: (conversations: Map) => { + setConversationsWithUnread: ( + conversations: Map, + totalUnread: number, + systemUnread: number, + ) => { const conversationList = sortConversationList(conversations); const notificationMutedMap = new Map(); conversations.forEach((conv, id) => { @@ -252,6 +261,8 @@ export const useMessageStore = create((set, get) => ({ conversations, conversationList, notificationMutedMap: new Map([...state.notificationMutedMap, ...notificationMutedMap]), + totalUnreadCount: totalUnread, + systemUnreadCount: systemUnread, })); }, @@ -314,39 +325,6 @@ export const useMessageStore = create((set, get) => ({ }); }, - addMessage: (conversationId: string, message: MessageResponse) => { - const normalizedId = normalizeConversationId(conversationId); - set(state => { - const existing = state.messagesMap.get(normalizedId) || []; - const exists = existing.some(m => String(m.id) === String(message.id)); - - if (exists) { - return state; - } - - const updated = mergeMessagesById(existing, [message]); - const newMessagesMap = new Map(state.messagesMap); - newMessagesMap.set(normalizedId, updated); - return { messagesMap: newMessagesMap }; - }); - }, - - updateMessage: (conversationId: string, messageId: string, updates: Partial) => { - const normalizedId = normalizeConversationId(conversationId); - set(state => { - const messages = state.messagesMap.get(normalizedId); - if (!messages) return state; - - const updated = messages.map(m => - String(m.id) === String(messageId) ? { ...m, ...updates } : m - ); - - const newMessagesMap = new Map(state.messagesMap); - newMessagesMap.set(normalizedId, updated); - return { messagesMap: newMessagesMap }; - }); - }, - setUnreadCount: (total: number, system: number) => { set({ totalUnreadCount: total, systemUnreadCount: system }); }, @@ -413,6 +391,53 @@ export const useMessageStore = create((set, get) => ({ set({ isInitialized: initialized }); }, + updateConversationWithUnread: ( + conversation: ConversationResponse, + totalUnread: number, + systemUnread: number, + ) => { + const id = normalizeConversationId(conversation.id); + set(state => { + const newConversations = new Map(state.conversations); + newConversations.set(id, { ...conversation, id }); + const conversationList = sortConversationList(newConversations); + const newNotificationMutedMap = new Map(state.notificationMutedMap); + if (conversation.notification_muted) { + newNotificationMutedMap.set(id, true); + } else { + newNotificationMutedMap.delete(id); + } + return { + conversations: newConversations, + conversationList, + notificationMutedMap: newNotificationMutedMap, + totalUnreadCount: totalUnread, + systemUnreadCount: systemUnread, + }; + }); + }, + + incrementUnreadAtomic: (conversationId: string) => { + const normalizedId = normalizeConversationId(conversationId); + set(state => { + const conversation = state.conversations.get(normalizedId); + if (!conversation) return state; + + const newConversations = new Map(state.conversations); + newConversations.set(normalizedId, { + ...conversation, + unread_count: (conversation.unread_count || 0) + 1, + }); + const conversationList = sortConversationList(newConversations); + + return { + conversations: newConversations, + conversationList, + totalUnreadCount: state.totalUnreadCount + 1, + }; + }); + }, + // ==================== 重置 ==================== reset: () => { diff --git a/src/stores/message/types.ts b/src/stores/message/types.ts index 62c0c53..992946a 100644 --- a/src/stores/message/types.ts +++ b/src/stores/message/types.ts @@ -1,21 +1,6 @@ import type { ConversationResponse, MessageResponse, UserDTO } from '../../types/dto'; import type { IConversationListPagedSource } from './sources'; -export interface MessageManagerState { - conversations: Map; - conversationList: ConversationResponse[]; - messagesMap: Map; - totalUnreadCount: number; - systemUnreadCount: number; - isWSConnected: boolean; - currentConversationId: string | null; - isLoadingConversations: boolean; - loadingMessagesSet: Set; - isInitialized: boolean; - typingUsersMap: Map; - mutedStatusMap: Map; -} - export interface ReadStateRecord { timestamp: number; version: number; @@ -68,7 +53,6 @@ export interface IConversationOperations { removeConversation(conversationId: string): void; clearConversations(): void; updateUnreadCount(conversationId: string, count: number): void; - incrementUnreadCount(conversationId: string): void; setSystemUnreadCount(count: number): void; incrementSystemUnreadCount(): void; decrementSystemUnreadCount(count?: number): void; @@ -89,7 +73,9 @@ export interface IMessageSyncService { fetchMessages(conversationId: string, afterSeq?: number): Promise; loadMoreMessages(conversationId: string, beforeSeq: number, limit?: number): Promise; fetchUnreadCount(): Promise; + syncBySeq(): Promise; canLoadMoreConversations(): boolean; + restartSources(): void; } export interface IWSMessageHandler {