diff --git a/app.json b/app.json index 7827cea..46c5972 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/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 dac4abe..1f55cf5 100644 --- a/plugins/withJPush.js +++ b/plugins/withJPush.js @@ -74,7 +74,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 ')) { @@ -95,7 +204,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` ); } @@ -139,7 +248,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/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/services/MessageSyncService.ts b/src/stores/message/services/MessageSyncService.ts index 30be7b4..b4155af 100644 --- a/src/stores/message/services/MessageSyncService.ts +++ b/src/stores/message/services/MessageSyncService.ts @@ -35,6 +35,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, @@ -108,15 +110,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) { @@ -360,11 +359,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,21 +399,28 @@ export class MessageSyncService implements IMessageSyncService { } } - // 服务端汇总未读为 0 时,清掉内存中残留的红点 + // 服务端汇总未读为 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; + const localSum = Array.from(store.getState().conversations.values()) + .reduce((sum, conv) => sum + (conv.unread_count || 0), 0); + if (localSum > 0) { + // 本地有未读但服务端返回 0 → 可能是后端缓存竞态,保留本地状态 + // 下一次 fetchConversations 会提供权威的 per-conversation 数据 + } else { + // 本地也无未读,确认清零 + 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) { + store.setConversations(newConversations); + this.persistConversationListCache(); } - } - if (anyCleared) { - // 使用 zustand set 函数正确更新状态 - store.setConversations(newConversations); - this.persistConversationListCache(); } } } catch (error) { @@ -487,15 +505,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/store.ts b/src/stores/message/store.ts index 75b1179..868e2cf 100644 --- a/src/stores/message/store.ts +++ b/src/stores/message/store.ts @@ -79,6 +79,11 @@ export interface MessageActions { // 设置状态 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; @@ -255,6 +260,27 @@ export const useMessageStore = create((set, get) => ({ })); }, + setConversationsWithUnread: ( + conversations: Map, + totalUnread: number, + systemUnread: number, + ) => { + const conversationList = sortConversationList(conversations); + const notificationMutedMap = new Map(); + conversations.forEach((conv, id) => { + if (conv.notification_muted) { + notificationMutedMap.set(id, true); + } + }); + set(state => ({ + conversations, + conversationList, + notificationMutedMap: new Map([...state.notificationMutedMap, ...notificationMutedMap]), + totalUnreadCount: totalUnread, + systemUnreadCount: systemUnread, + })); + }, + updateConversation: (conversation: ConversationResponse) => { const id = normalizeConversationId(conversation.id); set(state => {