diff --git a/package-lock.json b/package-lock.json index 076cc9e..db23446 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,9 @@ "expo-task-manager": "~55.0.9", "expo-updates": "^55.0.12", "expo-video": "^55.0.10", + "katex": "^0.16.42", + "markdown-it": "^14.1.1", + "prismjs": "^1.30.0", "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.2", @@ -50,6 +53,7 @@ "react-native-screens": "~4.23.0", "react-native-sse": "^1.2.1", "react-native-web": "^0.21.0", + "react-native-webview": "13.16.0", "react-native-worklets": "0.7.2", "zod": "^4.3.6", "zustand": "^5.0.11" @@ -5266,6 +5270,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", @@ -7611,6 +7627,31 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/katex": { + "version": "0.16.42", + "resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.42.tgz", + "integrity": "sha512-sZ4jqyEXfHTLEFK+qsFYToa3UZ0rtFcPGwKpyiRYh2NJn8obPWOQ+/u7ux0F6CAU/y78+Mksh1YkxTPXTh47TQ==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmmirror.com/kleur/-/kleur-3.0.3.tgz", @@ -7930,6 +7971,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", @@ -8170,6 +8220,23 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, "node_modules/marky": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/marky/-/marky-1.3.0.tgz", @@ -8185,6 +8252,12 @@ "node": ">= 0.4" } }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz", @@ -9236,6 +9309,15 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmmirror.com/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/proc-log": { "version": "4.2.0", "resolved": "https://registry.npmmirror.com/proc-log/-/proc-log-4.2.0.tgz", @@ -9282,6 +9364,15 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz", @@ -9698,6 +9789,20 @@ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "license": "MIT" }, + "node_modules/react-native-webview": { + "version": "13.16.0", + "resolved": "https://registry.npmmirror.com/react-native-webview/-/react-native-webview-13.16.0.tgz", + "integrity": "sha512-Nh13xKZWW35C0dbOskD7OX01nQQavOzHbCw9XoZmar4eXCo7AvrYJ0jlUfRVVIJzqINxHlpECYLdmAdFsl9xDA==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "invariant": "2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-worklets": { "version": "0.7.2", "resolved": "https://registry.npmmirror.com/react-native-worklets/-/react-native-worklets-0.7.2.tgz", @@ -11197,6 +11302,12 @@ "node": "*" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz", diff --git a/package.json b/package.json index cd26ced..aa21d88 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,9 @@ "expo-task-manager": "~55.0.9", "expo-updates": "^55.0.12", "expo-video": "^55.0.10", + "katex": "^0.16.42", + "markdown-it": "^14.1.1", + "prismjs": "^1.30.0", "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.2", @@ -56,6 +59,7 @@ "react-native-screens": "~4.23.0", "react-native-sse": "^1.2.1", "react-native-web": "^0.21.0", + "react-native-webview": "13.16.0", "react-native-worklets": "0.7.2", "zod": "^4.3.6", "zustand": "^5.0.11" diff --git a/src/services/sseService.ts b/src/services/sseService.ts index e0f7abf..64bbe44 100644 --- a/src/services/sseService.ts +++ b/src/services/sseService.ts @@ -4,8 +4,6 @@ import EventSource from 'react-native-sse'; import { api, SSE_URL } from './api'; import { MessageCategory, SystemMessageType, SystemMessageExtraData, MessageSegment } from '../types/dto'; import { systemNotificationService } from './systemNotificationService'; -import { getNotificationPreferencesSync } from './notificationPreferences'; -import { vibrateOnMessage } from './messageVibrationService'; export type WSMessageType = | 'chat' @@ -280,7 +278,6 @@ class SSEService { created_at: m.created_at || new Date().toISOString(), }; this.emit('group_message', gm); - vibrateOnMessage('group_message').catch(() => {}); } else { const cm: WSChatMessage = { type: 'chat', @@ -292,7 +289,6 @@ class SSEService { created_at: m.created_at || new Date().toISOString(), }; this.emit('chat', cm); - vibrateOnMessage('chat').catch(() => {}); } return; } @@ -385,9 +381,6 @@ class SSEService { created_at: payload.created_at || new Date().toISOString(), }; this.emit('notification', m); - if (getNotificationPreferencesSync().pushEnabled) { - vibrateOnMessage('notification').catch(() => {}); - } systemNotificationService.handleWSMessage(m as any).catch(() => {}); } } diff --git a/src/services/systemNotificationService.ts b/src/services/systemNotificationService.ts index 81fc223..2467fce 100644 --- a/src/services/systemNotificationService.ts +++ b/src/services/systemNotificationService.ts @@ -9,6 +9,7 @@ import { Platform, AppState, AppStateStatus } from 'react-native'; import type { WSChatMessage, WSNotificationMessage, WSAnnouncementMessage } from './sseService'; import { extractTextFromSegments } from '../types/dto'; import { getNotificationPreferencesSync } from './notificationPreferences'; +import { vibrateOnMessage } from './messageVibrationService'; // 通知渠道配置 const CHANNEL_ID = 'default'; @@ -185,6 +186,8 @@ class SystemNotificationService { } else { const notifMsg = message as WSNotificationMessage | WSAnnouncementMessage; void notifMsg; + // 系统通知只在后台显示,因此震动也在该分支触发 + vibrateOnMessage('notification').catch(() => {}); await this.showWSNotification(notifMsg); } } diff --git a/src/stores/messageManager.ts b/src/stores/messageManager.ts index acc872d..6e7cbac 100644 --- a/src/stores/messageManager.ts +++ b/src/stores/messageManager.ts @@ -48,6 +48,7 @@ import { saveGroupsCache, } from '../services/database'; import { api } from '../services/api'; +import { vibrateOnMessage } from '../services/messageVibrationService'; import { useAuthStore } from './authStore'; import { type IConversationListPagedSource, @@ -186,6 +187,14 @@ class MessageManager { */ private readStateVersion: number = 0; + // 初始化阶段缓冲来自 SSE 的消息事件:在已拉取服务器权威状态(会话/未读)前 + // 不把 SSE 事件直接用于震动/未读增量,避免竞态导致“闪一下又消失” + private bufferedChatMessages: Array<(WSChatMessage | WSGroupChatMessage) & { _isAck?: boolean }> = []; + private bufferedReadReceipts: Array = []; + + // 用于覆盖“初始化完成但仍在刷新服务器权威状态”的过渡窗口 + private isBootstrapping: boolean = false; + constructor(deps?: MessageManagerConversationListDeps) { const pageSize = deps?.remoteListPageSize ?? CONVERSATION_LIST_PAGE_SIZE; this.remoteConversationListSource = @@ -467,6 +476,8 @@ class MessageManager { return; } + this.isBootstrapping = true; + // 2. 初始化SSE监听 this.initSSEListeners(); @@ -478,6 +489,11 @@ class MessageManager { this.state.isInitialized = true; + // 初始化窗口期内缓冲的 SSE 事件:刷新为服务器权威,避免“未读闪一下又消失” + await this.flushBufferedSSEEvents(); + + this.isBootstrapping = false; + this.notifySubscribers({ type: 'conversations_updated', payload: { conversations: this.state.conversationList }, @@ -490,6 +506,7 @@ class MessageManager { } } catch (error) { console.error('[MessageManager] 初始化失败:', error); + this.isBootstrapping = false; this.notifySubscribers({ type: 'error', payload: { error, context: 'initialize' }, @@ -505,6 +522,52 @@ class MessageManager { } } + /** + * 初始化完成后,处理在初始化阶段被缓冲的 SSE 事件。 + * + * 架构目标: + * 1) 初始化阶段不把 SSE 用于未读增量/震动(避免竞态“先加后清”) + * 2) 初始化完成后,用服务器权威状态刷新会话/未读,再把事件用于消息内容展示 + */ + private async flushBufferedSSEEvents(): Promise { + // 为了覆盖“刷新服务器权威状态过程中又到了一些 SSE”这种窗口,这里做一个有上限的循环清空缓冲。 + // 如果在线流持续不断,会产生更多轮次;因此使用上限避免长时间阻塞初始化。 + const MAX_FLUSH_ITERATIONS = 3; + for (let i = 0; i < MAX_FLUSH_ITERATIONS; i++) { + const hasBuffered = this.bufferedChatMessages.length > 0 || this.bufferedReadReceipts.length > 0; + if (!hasBuffered) return; + + const chatEvents = this.bufferedChatMessages; + const readEvents = this.bufferedReadReceipts; + this.bufferedChatMessages = []; + this.bufferedReadReceipts = []; + + // 先把消息内容灌进内存(用于 ChatScreen 如果正好激活能立刻看到) + // 不在这里修改 unread_count / 不触发震动 / 不做自动标记已读。 + for (const m of chatEvents) { + await this.handleNewMessage(m, { + suppressUnreadIncrement: true, + suppressVibration: true, + suppressConversationUpdates: true, + suppressAutoMarkAsRead: true, + }); + } + + // 已读回执目前在本仓库实现为空(handleReadReceipt / handleGroupReadReceipt 是 no-op),保留扩展位 + for (const r of readEvents) { + if (r.type === 'read') { + this.handleReadReceipt(r); + } else { + this.handleGroupReadReceipt(r as WSGroupReadMessage); + } + } + + // 使用服务器权威刷新会话与未读,避免初始化窗口期内漏算/重复算 + await this.fetchConversations(true, 'sse-buffer-flush'); + await this.fetchUnreadCount(); + } + } + destroy(): void { if (this.sseUnsubscribe) { this.sseUnsubscribe(); @@ -529,21 +592,37 @@ class MessageManager { // 监听私聊消息 sseService.on('chat', (message: WSChatMessage) => { + if (!this.state.isInitialized || this.isBootstrapping) { + this.bufferedChatMessages.push(message); + return; + } this.handleNewMessage(message); }); // 监听群聊消息 sseService.on('group_message', (message: WSGroupChatMessage) => { + if (!this.state.isInitialized || this.isBootstrapping) { + this.bufferedChatMessages.push(message); + return; + } this.handleNewMessage(message); }); // 监听私聊已读回执 sseService.on('read', (message: WSReadMessage) => { + if (!this.state.isInitialized || this.isBootstrapping) { + this.bufferedReadReceipts.push(message); + return; + } this.handleReadReceipt(message); }); // 监听群聊已读回执 sseService.on('group_read', (message: WSGroupReadMessage) => { + if (!this.state.isInitialized || this.isBootstrapping) { + this.bufferedReadReceipts.push(message); + return; + } this.handleGroupReadReceipt(message); }); @@ -784,10 +863,22 @@ class MessageManager { * 处理新消息(SSE推送) * 这是核心方法,必须立即处理并同步到所有订阅者 */ - private async handleNewMessage(message: (WSChatMessage | WSGroupChatMessage) & { _isAck?: boolean }): Promise { + private async handleNewMessage( + message: (WSChatMessage | WSGroupChatMessage) & { _isAck?: boolean }, + options?: { + suppressUnreadIncrement?: boolean; + suppressVibration?: boolean; + suppressConversationUpdates?: boolean; + suppressAutoMarkAsRead?: boolean; + } + ): Promise { const { conversation_id, id, sender_id, seq, segments, created_at, _isAck } = message; const normalizedConversationId = this.normalizeConversationId(conversation_id); const currentUserId = this.getCurrentUserId(); + const suppressUnreadIncrement = options?.suppressUnreadIncrement ?? false; + const suppressVibration = options?.suppressVibration ?? false; + const suppressConversationUpdates = options?.suppressConversationUpdates ?? false; + const suppressAutoMarkAsRead = options?.suppressAutoMarkAsRead ?? false; // 1. 消息去重检查 - 防止ACK消息和正常消息重复处理 if (this.isMessageProcessed(id)) { @@ -855,24 +946,32 @@ class MessageManager { }); } - // 4. 更新会话信息 - this.updateConversationWithNewMessage(normalizedConversationId, newMessage, created_at); + // 4. 更新会话信息(初始化缓冲刷新阶段可选择跳过,避免会话未读/排序竞态) + if (!suppressConversationUpdates) { + this.updateConversationWithNewMessage(normalizedConversationId, newMessage, created_at); + } // 5. 更新未读数(如果不是当前用户发送且不是当前活动会话) const isCurrentUserMessage = sender_id === currentUserId; const isActiveConversation = normalizedConversationId === this.state.currentConversationId; - // 确保当前用户发送的消息不会增加未读数 - // 同时确保currentUserId有效(避免undefined === undefined的情况) - // ACK消息(发送确认)也不应该增加未读数 - const shouldIncrementUnread = !isCurrentUserMessage && !isActiveConversation && !!currentUserId && !_isAck; + const shouldIncrementUnread = + !suppressUnreadIncrement && + !isCurrentUserMessage && + !isActiveConversation && + !!currentUserId && + !_isAck; if (shouldIncrementUnread) { + if (!suppressVibration) { + const vibrationType = message.type === 'group_message' ? 'group_message' : 'chat'; + vibrateOnMessage(vibrationType).catch(() => {}); + } this.incrementUnreadCount(normalizedConversationId); } // 6. 如果是当前活动会话,自动标记已读 - if (isActiveConversation && !isCurrentUserMessage) { + if (isActiveConversation && !isCurrentUserMessage && !suppressAutoMarkAsRead) { this.markAsRead(normalizedConversationId, seq); }