diff --git a/app.json b/app.json index b70695d..dcb8021 100644 --- a/app.json +++ b/app.json @@ -35,6 +35,7 @@ "monochromeImage": "./assets/android-icon-monochrome.png" }, "predictiveBackGestureEnabled": false, + "jsEngine": "hermes", "package": "cn.qczlit.withyou", "versionCode": 7, "permissions": [ @@ -55,7 +56,9 @@ "android.permission.ACCESS_MEDIA_LOCATION", "android.permission.READ_MEDIA_IMAGES", "android.permission.READ_MEDIA_VIDEO", - "android.permission.READ_MEDIA_AUDIO" + "android.permission.READ_MEDIA_AUDIO", + "android.permission.ACCESS_NETWORK_STATE", + "android.permission.ACCESS_WIFI_STATE" ] }, "web": { @@ -109,7 +112,7 @@ "isAccessMediaLocationEnabled": true } ], - [ +[ "expo-image-picker", { "photosPermission": "允许威友访问您的照片以选择图片", @@ -138,13 +141,21 @@ "proguardRules": "-keep class com.google.android.exoplayer2.** { *; }" } ], - "expo-font" + "expo-font", + [ + "./plugins/withJPush", + { + "appKey": "2cbe4da12e2c24aa3dca4610", + "channel": "developer-default" + } + ] ], - "jsEngine": "hermes", "extra": { "eas": { "projectId": "65540196-d37d-437b-8496-227df0317069" - } + }, + "jpushAppKey": "2cbe4da12e2c24aa3dca4610", + "jpushChannel": "developer-default" }, "owner": "qojo" } diff --git a/app/(app)/_layout.tsx b/app/(app)/_layout.tsx index 00991db..1178c13 100644 --- a/app/(app)/_layout.tsx +++ b/app/(app)/_layout.tsx @@ -1,19 +1,83 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; +import { Platform } from 'react-native'; import { Redirect } from 'expo-router'; import { AppRouteStack } from '../../src/app-navigation/AppRouteStack'; import { messageManager, useAuthStore } from '../../src/stores'; +import { jpushService } from '../../src/services/notification/jpushService'; + +function getDeviceType(): 'ios' | 'android' | 'web' { + switch (Platform.OS) { + case 'ios': return 'ios'; + case 'android': return 'android'; + default: return 'web'; + } +} + +async function getOrCreateDeviceID(): Promise { + try { + const AsyncStorage = require('@react-native-async-storage/async-storage').default; + const DEVICE_ID_KEY = 'withyou_device_id'; + let deviceId = await AsyncStorage.getItem(DEVICE_ID_KEY); + if (deviceId) return deviceId; + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 8); + deviceId = `${Platform.OS}_${timestamp}_${random}`; + await AsyncStorage.setItem(DEVICE_ID_KEY, deviceId); + return deviceId; + } catch { + return `${Platform.OS}_${Date.now()}`; + } +} export default function AppLayout() { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const userID = useAuthStore((s) => s.currentUser?.id); + const deviceRegistered = useRef(false); useEffect(() => { messageManager.initialize(); }, []); + useEffect(() => { + if (!isAuthenticated || !userID) return; + + if (!deviceRegistered.current) { + deviceRegistered.current = true; + + if (Platform.OS === 'web') { + // Web: register device without push_token (JPush not available on web) + getOrCreateDeviceID().then((deviceId) => { + const { pushService } = require('../../src/services/notification/pushService'); + pushService.registerDevice({ + device_id: deviceId, + device_type: 'web', + device_name: 'Web Browser', + }).catch((err: any) => { + console.warn('[Push] web device register failed:', err?.message); + }); + }); + } else { + // Mobile: JPush handles registration internally in initialize() + jpushService.initialize().then((ok) => { + if (ok && userID) { + jpushService.setAliasForUser(userID); + } + }); + } + } + + return () => { + if (!isAuthenticated) { + deviceRegistered.current = false; + jpushService.cleanup(); + } + }; + }, [isAuthenticated, userID]); + if (!isAuthenticated) { return ; } return ; -} +} \ No newline at end of file diff --git a/app/_layout.tsx b/app/_layout.tsx index 431e2bc..0f913ef 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -6,7 +6,6 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { PaperProvider } from 'react-native-paper'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import * as Notifications from 'expo-notifications'; import * as SystemUI from 'expo-system-ui'; import { useFonts } from 'expo-font'; @@ -27,6 +26,7 @@ import { installAlertOverride } from '@/services/ui'; import { useAuthStore } from '../src/stores'; import { checkForAPKUpdate } from '@/services/platform'; import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call'; +import { jpushService } from '@/services/notification/jpushService'; registerNotificationPresentationHandler(); @@ -128,7 +128,6 @@ function SessionGate({ children }: { children: React.ReactNode }) { function NotificationBootstrap() { const appState = useRef(AppState.currentState); - const notificationResponseListener = useRef(null); useEffect(() => { const initNotifications = async () => { @@ -146,22 +145,13 @@ function NotificationBootstrap() { appState.current = nextAppState; }); - notificationResponseListener.current = Notifications.addNotificationResponseReceivedListener( - (response) => { - void response.notification.request.content.data; - } - ); - - const notificationReceivedSubscription = Notifications.addNotificationReceivedListener( - (notification) => { - void notification; - } - ); + // Listen for JPush notification taps + jpushService.onNotification((message) => { + console.log('[NotificationBootstrap] JPush notification tapped:', message); + }); return () => { subscription.remove(); - notificationResponseListener.current?.remove(); - notificationReceivedSubscription.remove(); }; }; diff --git a/package-lock.json b/package-lock.json index 113ee3f..e9c11c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "with_you", - "version": "0.0.1", + "version": "0.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "with_you", - "version": "0.0.1", + "version": "0.0.2", "dependencies": { "@expo/vector-icons": "^15.1.1", "@react-native-async-storage/async-storage": "^2.2.0", @@ -31,7 +31,6 @@ "expo-intent-launcher": "~55.0.9", "expo-linear-gradient": "^55.0.8", "expo-media-library": "~55.0.9", - "expo-notifications": "^55.0.10", "expo-router": "^55.0.4", "expo-sqlite": "~55.0.10", "expo-status-bar": "~55.0.4", @@ -39,6 +38,8 @@ "expo-task-manager": "~55.0.9", "expo-updates": "^55.0.12", "expo-video": "^55.0.10", + "jcore-react-native": "^2.3.5", + "jpush-react-native": "^3.2.6", "katex": "^0.16.42", "markdown-it": "^14.1.1", "prismjs": "^1.30.0", @@ -4357,12 +4358,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/badgin": { - "version": "1.2.3", - "resolved": "https://registry.npmmirror.com/badgin/-/badgin-1.2.3.tgz", - "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", - "license": "MIT" - }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", @@ -5522,15 +5517,6 @@ } } }, - "node_modules/expo-application": { - "version": "55.0.10", - "resolved": "https://registry.npmmirror.com/expo-application/-/expo-application-55.0.10.tgz", - "integrity": "sha512-5ccf+S6hsQz+doi907TOJxKzV5AKgAgw004z4FoDWSoGhfab0LUPg6uyvOspuU4cbNvqw8EAy08hZbVO8nKc9Q==", - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, "node_modules/expo-asset": { "version": "55.0.10", "resolved": "https://registry.npmmirror.com/expo-asset/-/expo-asset-55.0.10.tgz", @@ -5845,24 +5831,6 @@ "react-native": "*" } }, - "node_modules/expo-notifications": { - "version": "55.0.13", - "resolved": "https://registry.npmmirror.com/expo-notifications/-/expo-notifications-55.0.13.tgz", - "integrity": "sha512-vbtSBcMkYtNTO+6WKdeOzysOqvtmiq/sQrUKJpYcB75m9hBFcAfI2klpXdUiGg5kMr/ygBmFENSolQt1B9QY8A==", - "license": "MIT", - "dependencies": { - "@expo/image-utils": "^0.8.12", - "abort-controller": "^3.0.0", - "badgin": "^1.1.5", - "expo-application": "~55.0.10", - "expo-constants": "~55.0.8" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, "node_modules/expo-router": { "version": "55.0.7", "resolved": "https://registry.npmmirror.com/expo-router/-/expo-router-55.0.7.tgz", @@ -7353,6 +7321,15 @@ "node": ">=8" } }, + "node_modules/jcore-react-native": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/jcore-react-native/-/jcore-react-native-2.3.5.tgz", + "integrity": "sha512-rxkDPGiSyNVHjNibUGeqFaV07PbMqKZnoHJa0srt002QGUH4/9K4Ux2tK0EkqHLFvGFsJqGWwjaxEqQIPvoPTA==", + "license": "ISC", + "peerDependencies": { + "react-native": ">= 0.60" + } + }, "node_modules/jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmmirror.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -7558,6 +7535,16 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/jpush-react-native": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/jpush-react-native/-/jpush-react-native-3.2.6.tgz", + "integrity": "sha512-dnrdfwsZcAYsQLknVMRMFml/f5cDUFlVKaqpgN41au+g2lxYvPyY7v15XZbNiMdnAjzzVgPhmUW8kuptkLtDXw==", + "license": "ISC", + "peerDependencies": { + "jcore-react-native": ">= 1.9.3", + "react-native": ">= 0.50" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 8cf4af2..cf642d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "with_you", - "version": "0.0.1", + "version": "0.0.2", "main": "expo-router/entry", "scripts": { "start": "expo start", @@ -37,7 +37,6 @@ "expo-intent-launcher": "~55.0.9", "expo-linear-gradient": "^55.0.8", "expo-media-library": "~55.0.9", - "expo-notifications": "^55.0.10", "expo-router": "^55.0.4", "expo-sqlite": "~55.0.10", "expo-status-bar": "~55.0.4", @@ -45,6 +44,8 @@ "expo-task-manager": "~55.0.9", "expo-updates": "^55.0.12", "expo-video": "^55.0.10", + "jcore-react-native": "^2.3.5", + "jpush-react-native": "^3.2.6", "katex": "^0.16.42", "markdown-it": "^14.1.1", "prismjs": "^1.30.0", diff --git a/plugins/withJPush.js b/plugins/withJPush.js new file mode 100644 index 0000000..dac4abe --- /dev/null +++ b/plugins/withJPush.js @@ -0,0 +1,155 @@ +const { + withAndroidManifest, + withDangerousMod, + withAppDelegate, + withInfoPlist, + withAppBuildGradle, +} = require('@expo/config-plugins'); +const fs = require('fs'); +const path = require('path'); + +const withJPush = (config, options = {}) => { + const { appKey, channel = 'developer-default' } = options; + + if (!appKey) { + throw new Error('JPush appKey is required in plugin options'); + } + + // 1. Android: Inject manifestPlaceholders into app/build.gradle + config = withAppBuildGradle(config, (config) => { + const block = '\n manifestPlaceholders = [\n' + + ' JPUSH_APPKEY: "' + appKey + '",\n' + + ' JPUSH_CHANNEL: "' + channel + '"\n' + + ' ]'; + + const contents = config.modResults.contents; + + if (contents.includes('manifestPlaceholders')) { + config.modResults.contents = contents.replace( + /manifestPlaceholders\s*=\s*\[[\s\S]*?\]/, + block.trim() + ); + } else if (contents.includes('REACT_NATIVE_RELEASE_LEVEL')) { + const lines = contents.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes('REACT_NATIVE_RELEASE_LEVEL')) { + lines.splice(i + 1, 0, block); + break; + } + } + config.modResults.contents = lines.join('\n'); + } + + return config; + }); + + // 2. Android: Add JPush metadata and permissions to AndroidManifest + config = withAndroidManifest(config, (config) => { + const manifest = config.modResults; + + if (!manifest.manifest['uses-permission']) { + manifest.manifest['uses-permission'] = []; + } + + const requiredPermissions = [ + 'android.permission.RECEIVE_USER_PRESENT', + 'android.permission.POST_NOTIFICATIONS', + ]; + + requiredPermissions.forEach((permission) => { + const exists = manifest.manifest['uses-permission'].find( + (p) => p.$['android:name'] === permission + ); + if (!exists) { + manifest.manifest['uses-permission'].push({ + $: { 'android:name': permission }, + }); + } + }); + + return config; + }); + + // 3. iOS: Add JPush initialization to AppDelegate + config = withAppDelegate(config, (config) => { + const { contents } = config.modResults; + + if (!contents.includes('JPush')) { + let newContents = contents; + + if (!newContents.includes('#import ')) { + newContents = newContents.replace( + /(#import "AppDelegate.h")/, + `$1\n#import ` + ); + } + + if (!newContents.includes('#import ')) { + newContents = newContents.replace( + /(#import "AppDelegate.h")/, + `$1\n#import ` + ); + } + + const didFinishLaunchPattern = /(- \(BOOL\)application:\(UIApplication \*\)application didFinishLaunchingWithOptions:\(NSDictionary \*\)launchOptions\s*\{)/; + if (didFinishLaunchPattern.test(newContents)) { + newContents = newContents.replace( + didFinishLaunchPattern, + `$1\n // JPush initialization\n [JCoreModule setupWithOption:launchOptions appKey:@"${appKey}" channel:@"${channel}" apsForProduction:NO];\n` + ); + } + + const jpushMethods = ` +// JPush: Register for remote notifications +- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken { + [JCoreModule registerDeviceToken:deviceToken]; +} + +- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error { + NSLog(@"did fail to register for remote notifications with error: %@", error); +} + +// JPush: Handle remote notification +- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler { + [JCoreModule didReceiveRemoteNotification:userInfo]; + completionHandler(UIBackgroundFetchResultNewData); +} + +// UNUserNotificationCenter delegate +- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler { + completionHandler(UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionBadge | UNNotificationPresentationOptionSound); +} + +- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler { + [JCoreModule didReceiveRemoteNotification:response.notification.request.content.userInfo]; + completionHandler(); +} +`; + + if (!newContents.includes('JCoreModule registerDeviceToken')) { + newContents = newContents.replace( + /@end\s*$/, + `${jpushMethods}\n@end\n` + ); + } + + config.modResults.contents = newContents; + } + + return config; + }); + + // 4. 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')) { + config.modResults.UIBackgroundModes.push('remote-notification'); + } + + return config; + }); + + return config; +}; + +module.exports = withJPush; \ No newline at end of file diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index b96a35c..3f3c79f 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -240,7 +240,7 @@ export const HomeScreen: React.FC = () => { const styles = useMemo(() => createHomeStyles(colors, responsivePadding), [colors, responsivePadding]); const statusBarStyle = resolvedScheme === 'dark' ? 'light-content' : 'dark-content'; - const [sortIndex, setSortIndex] = useState(0); + const [sortIndex, setSortIndex] = useState(2); const [viewMode, setViewMode] = useState('list'); const [latestCapsules, setLatestCapsules] = useState([{ id: '', name: '全部' }]); const [activeCapsuleId, setActiveCapsuleId] = useState(''); diff --git a/src/services/core/api.ts b/src/services/core/api.ts index 79c2ae5..5d66cd8 100644 --- a/src/services/core/api.ts +++ b/src/services/core/api.ts @@ -356,10 +356,10 @@ class ApiClient { const data = await response.json(); if (data.code === 0 && data.data?.token) { await this.setToken(data.data.token); - // 后端返回refresh_token (snake_case),兼容两种命名 if (data.data.refresh_token || data.data.refreshToken) { await this.setRefreshToken(data.data.refresh_token || data.data.refreshToken); } + this.registerDeviceOnRefresh(); return true; } @@ -370,6 +370,52 @@ class ApiClient { } } + // 冷启动/刷新token后自动注册设备,方便存量设备接入 + private registerDeviceOnRefresh(): void { + (async () => { + try { + const { pushService } = await import('../notification/pushService'); + const { jpushService } = await import('../notification/jpushService'); + const AsyncStorage = (await import('@react-native-async-storage/async-storage')).default; + + const DEVICE_ID_KEY = 'withyou_device_id'; + let deviceId = await AsyncStorage.getItem(DEVICE_ID_KEY); + + if (Platform.OS === 'web') { + if (!deviceId) { + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 8); + deviceId = `web_${timestamp}_${random}`; + await AsyncStorage.setItem(DEVICE_ID_KEY, deviceId); + } + await pushService.registerDevice({ + device_id: deviceId, + device_type: 'web', + device_name: 'Web Browser', + }); + } else { + // Mobile: use JPush registration ID if available + const regID = jpushService.getRegistrationIDValue(); + if (!deviceId) { + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 8); + deviceId = `${Platform.OS}_${timestamp}_${random}`; + await AsyncStorage.setItem(DEVICE_ID_KEY, deviceId); + } + await pushService.registerDevice({ + device_id: deviceId, + device_type: Platform.OS === 'ios' ? 'ios' : 'android', + ...(regID ? { push_token: regID } : {}), + device_name: `${Platform.OS === 'ios' ? 'iOS' : 'Android'} Device`, + }); + } + console.log('[API] device registered on token refresh'); + } catch (error) { + console.warn('[API] device register on refresh failed:', error); + } + })(); + } + // GET 请求 async get(path: string, params?: Record): Promise> { return this.request('GET', path, params); diff --git a/src/services/notification/index.ts b/src/services/notification/index.ts index f0527a6..e39ae59 100644 --- a/src/services/notification/index.ts +++ b/src/services/notification/index.ts @@ -14,4 +14,6 @@ export { } from './notificationPreferences'; export type { NotificationPrefs } from './notificationPreferences'; -export { pushService, registerDevice, getDevices, unregisterDevice, updateDeviceToken, getPushRecords } from './pushService'; \ No newline at end of file +export { pushService, registerDevice, getDevices, unregisterDevice, updateDeviceToken, getPushRecords } from './pushService'; + +export { jpushService } from './jpushService'; \ No newline at end of file diff --git a/src/services/notification/jpushService.ts b/src/services/notification/jpushService.ts new file mode 100644 index 0000000..a2e8f50 --- /dev/null +++ b/src/services/notification/jpushService.ts @@ -0,0 +1,318 @@ +import { Platform, NativeModules } from 'react-native'; +import { pushService } from './pushService'; + +type JPushNotificationMessage = { + messageID: string; + title: string; + content: string; + extras: Record; + notificationType?: string; + notificationEventType?: 'notificationArrived' | 'notificationOpened'; +}; + +type JPushCallback = (message: JPushNotificationMessage) => void; + +let JPush: typeof import('jpush-react-native').default | null = null; +let jpushAppKey = ''; +let jpushChannel = 'developer-default'; + +try { + const nativeModule = NativeModules.JPushModule; + if (nativeModule) { + JPush = require('jpush-react-native').default; + const Constants = require('expo-constants').default; + const extra = Constants?.expoConfig?.extra || Constants?.extra || {}; + jpushAppKey = extra.jpushAppKey || ''; + jpushChannel = extra.jpushChannel || 'withyou-default'; + } else { + console.warn('[JPush] native module not linked, skipping'); + } +} catch { + console.warn('[JPush] jpush-react-native not available'); +} + +function getAuthStore() { + return require('@/stores').useAuthStore; +} + +class JPushService { + private registrationID: string | null = null; + private isInitialized = false; + private initPromise: Promise | null = null; + private notificationCallback: JPushCallback | null = null; + private connectResolved = false; + private listenersRegistered = false; + + async initialize(): Promise { + if (this.isInitialized) return true; + if (Platform.OS === 'web') return false; + if (!JPush) return false; + if (this.initPromise) return this.initPromise; + + this.initPromise = this._doInit(); + return this.initPromise; + } + + private _registerListeners(): void { + if (this.listenersRegistered) return; + this.listenersRegistered = true; + + JPush!.addConnectEventListener((result: { connectEnable: boolean }) => { + console.log('[JPush] connect event:', result.connectEnable); + if (result.connectEnable && !this.connectResolved) { + this.connectResolved = true; + this._onConnected(); + } + }); + + JPush!.addNotificationListener((result: any) => { + console.log('[JPush] notification:', result); + if (this.notificationCallback) { + this.notificationCallback({ + messageID: result.messageID || '', + title: result.title || '', + content: result.content || '', + extras: result.extras || {}, + notificationType: result.extras?.notification_type, + notificationEventType: result.notificationEventType, + }); + } + }); + + JPush!.addCustomMessageListener((result: any) => { + console.log('[JPush] custom message:', result); + }); + + JPush!.addLocalNotificationListener((result: any) => { + console.log('[JPush] local notification:', result); + }); + + JPush!.addTagAliasListener((result: any) => { + console.log('[JPush] tag/alias result:', result); + }); + + JPush!.addCommandEventListener((result: any) => { + console.log('[JPush] command result:', result); + }); + } + + private async _doInit(): Promise { + try { + JPush!.setLoggerEnable(true); + + // Register listeners only once (idempotent guard) + this._registerListeners(); + + // 初始化 SDK + JPush!.init({ + appKey: jpushAppKey, + channel: jpushChannel, + production: !__DEV__, + }); + + console.log('[JPush] init called, waiting for registration...'); + + // 轮询获取 RegistrationID,间隔逐渐增大 + const regID = await this._pollRegistrationID(); + if (regID) { + this.registrationID = regID; + this.isInitialized = true; + console.log('[JPush] initialized with registrationID:', regID); + await this.registerDeviceToServer(); + return true; + } + + // 即使没拿到 registrationID,也标记为已初始化 + // connect 事件回来后会自动获取 + this.isInitialized = true; + console.warn('[JPush] init done but no registrationID yet, will retry on connect'); + return true; + } catch (error) { + console.error('[JPush] initialization failed:', error); + this.initPromise = null; + return false; + } + } + + private _onConnected(): void { + if (this.registrationID) return; // Already have registrationID, skip + console.log('[JPush] connected, fetching registrationID...'); + this._fetchRegistrationID(); + } + + private _fetchRegistrationID(): void { + if (!JPush) return; + JPush.getRegistrationID((result: any) => { + console.log('[JPush] getRegistrationID result:', JSON.stringify(result)); + const regID = result?.registerID || result?.registrationID; + if (regID) { + this.registrationID = regID; + this.isInitialized = true; + console.log('[JPush] got registrationID:', regID); + this.registerDeviceToServer(); + } else { + console.warn('[JPush] getRegistrationID returned empty'); + } + }); + } + + private _pollRegistrationID(): Promise { + return new Promise((resolve) => { + // 轮询策略: 2s, 4s, 6s, 8s, 10s (总计 30s) + const delays = [2000, 4000, 6000, 8000, 10000]; + let attempt = 0; + + const tryFetch = () => { + if (!JPush) { + resolve(null); + return; + } + + JPush.getRegistrationID((result: any) => { + const regID = result?.registerID || result?.registrationID; + console.log(`[JPush] poll attempt ${attempt + 1}, regID:`, regID || '(empty)'); + + if (regID) { + resolve(regID); + return; + } + + attempt++; + if (attempt < delays.length) { + setTimeout(tryFetch, delays[attempt] - (attempt > 0 ? delays[attempt - 1] : 0)); + } else { + resolve(null); + } + }); + }; + + setTimeout(tryFetch, delays[0]); + }); + } + + async registerDeviceToServer(): Promise { + if (!this.registrationID) return; + + const useAuthStore = getAuthStore(); + const { isAuthenticated } = useAuthStore.getState(); + if (!isAuthenticated) return; + + try { + const deviceType = Platform.OS === 'ios' ? 'ios' : 'android'; + const deviceID = await this.getDeviceID(); + + await pushService.registerDevice({ + device_id: deviceID, + device_type: deviceType as 'ios' | 'android', + push_token: this.registrationID, + device_name: `${Platform.OS === 'ios' ? 'iOS' : 'Android'} Device`, + }); + + console.log('[JPush] device registered to server'); + } catch (error) { + console.error('[JPush] failed to register device to server:', error); + } + } + + private async getDeviceID(): Promise { + try { + const AsyncStorage = require('@react-native-async-storage/async-storage').default; + const DEVICE_ID_KEY = 'withyou_device_id'; + let deviceId = await AsyncStorage.getItem(DEVICE_ID_KEY); + if (deviceId) return deviceId; + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 8); + deviceId = `${Platform.OS}_${timestamp}_${random}`; + await AsyncStorage.setItem(DEVICE_ID_KEY, deviceId); + return deviceId; + } catch { + return `${Platform.OS}_${Date.now()}`; + } + } + + onNotification(callback: JPushCallback): void { + this.notificationCallback = callback; + } + + setTags(tags: string[]): void { + if (!JPush || !this.isInitialized) return; + try { + JPush.addTags({ sequence: Date.now(), tags }); + } catch (error) { + console.error('[JPush] setTags failed:', error); + } + } + + setAlias(alias: string): void { + if (!JPush || !this.isInitialized) return; + try { + JPush.setAlias({ sequence: Date.now(), alias }); + } catch (error) { + console.error('[JPush] setAlias failed:', error); + } + } + + async setAliasForUser(userID: string): Promise { + this.setAlias(userID); + } + + deleteAlias(): void { + if (!JPush || !this.isInitialized) return; + try { + JPush.deleteAlias({ sequence: Date.now() }); + } catch (error) { + console.error('[JPush] deleteAlias failed:', error); + } + } + + getRegistrationIDValue(): string | null { + return this.registrationID; + } + + isJPushAvailable(): boolean { + return this.isInitialized && this.registrationID !== null; + } + + addLocalNotification(params: { + messageID: string; + title: string; + content: string; + extras: Record; + }): void { + if (!JPush || !this.isInitialized) return; + try { + JPush.addLocalNotification(params); + } catch (error) { + console.error('[JPush] addLocalNotification failed:', error); + } + } + + clearAllNotifications(): void { + if (!JPush || !this.isInitialized) return; + try { + JPush.clearAllNotifications(); + } catch (error) { + console.error('[JPush] clearAllNotifications failed:', error); + } + } + + setBadge(badge: number): void { + if (!JPush || !this.isInitialized || Platform.OS !== 'ios') return; + try { + JPush.setBadge({ badge, appBadge: badge }); + } catch (error) { + console.error('[JPush] setBadge failed:', error); + } + } + + cleanup(): void { + this.isInitialized = false; + this.registrationID = null; + this.notificationCallback = null; + this.initPromise = null; + this.connectResolved = false; + } +} + +export const jpushService = new JPushService(); +export default jpushService; \ No newline at end of file diff --git a/src/services/notification/notificationPreferences.ts b/src/services/notification/notificationPreferences.ts index c199941..8dd9fb1 100644 --- a/src/services/notification/notificationPreferences.ts +++ b/src/services/notification/notificationPreferences.ts @@ -1,14 +1,7 @@ -/** - * 通知相关本地偏好(推送开关、提示音、震动) - * 启动时 loadNotificationPreferences 会同步到内存,供 SSE / 系统通知 / Handler 读取 - */ - import AsyncStorage from '@react-native-async-storage/async-storage'; -import * as Notifications from 'expo-notifications'; import { setVibrationEnabled } from '../platform/messageVibrationService'; export const NOTIFICATION_PREF_KEYS = { - /** 与历史版本保持一致 */ vibration: 'vibration_enabled', push: 'notification_push_enabled', sound: 'notification_sound_enabled', @@ -41,7 +34,6 @@ function parseBool(raw: string | null, fallback: boolean): boolean { } } -/** 应用启动时调用:从 AsyncStorage 恢复并写入震动服务内存态 */ export async function loadNotificationPreferences(): Promise { try { const [vibrationRaw, pushRaw, soundRaw] = await Promise.all([ @@ -77,18 +69,7 @@ export async function setSoundPreference(enabled: boolean): Promise { await AsyncStorage.setItem(NOTIFICATION_PREF_KEYS.sound, JSON.stringify(enabled)); } -/** 注册 expo-notifications 展示策略(读取内存缓存,切换开关后立即生效) */ export function registerNotificationPresentationHandler(): void { - Notifications.setNotificationHandler({ - handleNotification: async () => { - const p = getNotificationPreferencesSync(); - return { - shouldShowAlert: p.pushEnabled, - shouldPlaySound: p.soundEnabled, - shouldSetBadge: true, - shouldShowBanner: p.pushEnabled, - shouldShowList: p.pushEnabled, - }; - }, - }); + // JPush handles notification presentation natively. + // Preferences are read from getNotificationPreferencesSync() when needed. } diff --git a/src/services/notification/systemNotificationService.ts b/src/services/notification/systemNotificationService.ts index bc5b386..04c1d27 100644 --- a/src/services/notification/systemNotificationService.ts +++ b/src/services/notification/systemNotificationService.ts @@ -1,10 +1,3 @@ -/** - * 系统通知服务 - * 使用 expo-notifications 处理原生系统通知 - * 支持本地通知,类似 QQ 在通知栏显示消息 - */ - -import * as Notifications from 'expo-notifications'; import { Platform, AppState, AppStateStatus } from 'react-native'; import type { WSChatMessage, WSNotificationMessage, WSAnnouncementMessage } from '../core/wsService'; import { extractTextFromSegments } from '@/types/dto'; @@ -12,13 +5,8 @@ import { getNotificationPreferencesSync } from './notificationPreferences'; import { vibrateOnMessage } from '../platform/messageVibrationService'; import { useMessageStore } from '@/stores/message/store'; import { normalizeConversationId } from '@/stores/message/store'; +import { jpushService } from './jpushService'; -// 通知渠道配置 -const CHANNEL_ID = 'default'; -const CHANNEL_NAME = '默认通知'; -const CHANNEL_DESCRIPTION = '接收系统消息、互动通知等'; - -// 通知类型 export type AppNotificationType = | 'like_post' | 'like_comment' @@ -33,7 +21,6 @@ export type AppNotificationType = | 'announce' | 'chat'; -// 获取通知标题 export function getNotificationTitle(type: AppNotificationType, operatorName?: string): string { const titles: Record = { like_post: '有人赞了你的帖子', @@ -64,41 +51,13 @@ class SystemNotificationService { return true; } - try { - const { status: existingStatus } = await Notifications.getPermissionsAsync(); - let finalStatus = existingStatus; + this.currentAppState = AppState.currentState; + this.appStateSubscription = AppState.addEventListener('change', (nextAppState) => { + this.currentAppState = nextAppState; + }); - if (existingStatus !== 'granted') { - const { status } = await Notifications.requestPermissionsAsync(); - finalStatus = status; - } - - if (finalStatus !== 'granted') { - return false; - } - - if (Platform.OS === 'android') { - await Notifications.setNotificationChannelAsync(CHANNEL_ID, { - name: CHANNEL_NAME, - importance: Notifications.AndroidImportance.HIGH, // HIGH 优先级足以触发震动 - vibrationPattern: [0, 300, 200, 300], // 等待0ms,震动300ms,停止200ms,震动300ms - lightColor: '#FF6B35', - enableVibrate: true, // 启用震动 - enableLights: true, // 启用呼吸灯 - }); - } - - this.currentAppState = AppState.currentState; - this.appStateSubscription = AppState.addEventListener('change', (nextAppState) => { - this.currentAppState = nextAppState; - }); - - this.isInitialized = true; - return true; - } catch (error) { - console.error('[SystemNotification] 初始化失败:', error); - return false; - } + this.isInitialized = true; + return true; } async showNotification(options: { @@ -107,34 +66,47 @@ class SystemNotificationService { data?: Record; type: AppNotificationType; }): Promise { - if (!this.isInitialized) { - await this.initialize(); - } - try { - // 使用 scheduleNotificationAsync 立即显示通知 - // 配合 setNotificationHandler 确保前台也能显示 - - const { vibrationEnabled } = getNotificationPreferencesSync(); - // 构建通知内容 - const content: Notifications.NotificationContentInput = { - title: options.title, - body: options.body, - data: options.data as Record | undefined, - // 显式设置震动(仅 Android 生效,且尊重「消息震动」开关) - ...(Platform.OS === 'android' && vibrationEnabled - ? { vibrationPattern: [0, 300, 200, 300] } - : {}), - }; + if (!getNotificationPreferencesSync().pushEnabled) { + return null; + } - const notificationId = await Notifications.scheduleNotificationAsync({ - content, - trigger: null, // null 表示立即显示 + if (Platform.OS === 'web') { + // Web: use browser Notification API + if ('Notification' in window) { + const permission = await Notification.requestPermission(); + if (permission === 'granted') { + const notification = new Notification(options.title, { + body: options.body, + data: options.data, + }); + return notification.tag || null; + } + } + return null; + } + + // Mobile: use JPush local notification + const extras: Record = {}; + if (options.data) { + for (const [key, value] of Object.entries(options.data)) { + extras[key] = String(value); + } + } + extras['notification_type'] = options.type; + + const notificationId = `notif_${options.type}_${Date.now()}`; + + jpushService.addLocalNotification({ + messageID: notificationId, + title: options.title, + content: options.body, + extras, }); return notificationId; } catch (error) { - console.error('[SystemNotification] 显示通知失败:', error); + console.error('[SystemNotification] show notification failed:', error); return null; } } @@ -204,9 +176,8 @@ class SystemNotificationService { } } - // 仅在后台时显示通知,前台时不显示(用户正在使用应用,可以直接看到消息) + // Only show local notification when app is in background if (this.currentAppState !== 'active') { - // 判断是否是聊天消息(通过 segments 字段) if ('segments' in message) { const chatMsg = message as WSChatMessage; const conversationId = normalizeConversationId(chatMsg.conversation_id); @@ -214,12 +185,9 @@ class SystemNotificationService { if (isNotificationMuted) { return; } - void chatMsg; await this.showChatNotification(chatMsg); } else { const notifMsg = message as WSNotificationMessage | WSAnnouncementMessage; - void notifMsg; - // 系统通知只在后台显示,因此震动也在该分支触发 vibrateOnMessage('notification').catch(() => {}); await this.showWSNotification(notifMsg); } @@ -227,15 +195,18 @@ class SystemNotificationService { } async clearAllNotifications(): Promise { - await Notifications.dismissAllNotificationsAsync(); + jpushService.clearAllNotifications(); + if (Platform.OS === 'web' && 'Notification' in window) { + // No programmatic way to clear browser notifications + } } async clearBadge(): Promise { - await Notifications.setBadgeCountAsync(0); + jpushService.setBadge(0); } async setBadgeCount(count: number): Promise { - await Notifications.setBadgeCountAsync(count); + jpushService.setBadge(count); } cleanup(): void { @@ -251,4 +222,4 @@ class SystemNotificationService { } } -export const systemNotificationService = new SystemNotificationService(); +export const systemNotificationService = new SystemNotificationService(); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index f17d546..fa4382f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "paths": { "@/*": ["./src/*"] }, + "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "resolveJsonModule": true