feat(notification): migrate from expo-notifications to JPush push service
Some checks failed
Frontend CI / build-and-push-web (push) Has started running
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled

Replace expo-notifications with JPush for push notifications across the app.
This includes adding JPush native module dependencies, configuring the JPush
plugin in app.json, implementing jpushService wrapper, and updating notification
services to use JPush APIs. Also adds device registration on token refresh for
both mobile and web platforms.

BREAKING CHANGE: expo-notifications removed in favor of JPush for push notifications
This commit is contained in:
lafay
2026-04-27 23:21:08 +08:00
parent 0e4306c3ac
commit ba89b3dc94
13 changed files with 690 additions and 163 deletions

View File

@@ -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"
}

View File

@@ -1,16 +1,80 @@
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<string> {
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 <Redirect href="/login" />;
}

View File

@@ -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<AppStateStatus>(AppState.currentState);
const notificationResponseListener = useRef<Notifications.EventSubscription | null>(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();
};
};

59
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

155
plugins/withJPush.js Normal file
View File

@@ -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 <UserNotifications/UserNotifications.h>')) {
newContents = newContents.replace(
/(#import "AppDelegate.h")/,
`$1\n#import <UserNotifications/UserNotifications.h>`
);
}
if (!newContents.includes('#import <jcore-react-native/JCoreModule.h>')) {
newContents = newContents.replace(
/(#import "AppDelegate.h")/,
`$1\n#import <jcore-react-native/JCoreModule.h>`
);
}
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;

View File

@@ -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<ViewMode>('list');
const [latestCapsules, setLatestCapsules] = useState<LatestCapsule[]>([{ id: '', name: '全部' }]);
const [activeCapsuleId, setActiveCapsuleId] = useState('');

View File

@@ -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<T>(path: string, params?: Record<string, any>): Promise<ApiResponse<T>> {
return this.request<T>('GET', path, params);

View File

@@ -15,3 +15,5 @@ export {
export type { NotificationPrefs } from './notificationPreferences';
export { pushService, registerDevice, getDevices, unregisterDevice, updateDeviceToken, getPushRecords } from './pushService';
export { jpushService } from './jpushService';

View File

@@ -0,0 +1,318 @@
import { Platform, NativeModules } from 'react-native';
import { pushService } from './pushService';
type JPushNotificationMessage = {
messageID: string;
title: string;
content: string;
extras: Record<string, string>;
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<boolean> | null = null;
private notificationCallback: JPushCallback | null = null;
private connectResolved = false;
private listenersRegistered = false;
async initialize(): Promise<boolean> {
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<boolean> {
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<string | null> {
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<void> {
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<string> {
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<void> {
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<string, string>;
}): 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;

View File

@@ -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<NotificationPrefs> {
try {
const [vibrationRaw, pushRaw, soundRaw] = await Promise.all([
@@ -77,18 +69,7 @@ export async function setSoundPreference(enabled: boolean): Promise<void> {
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.
}

View File

@@ -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<AppNotificationType, string> = {
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<string, string | number | object>;
type: AppNotificationType;
}): Promise<string | null> {
if (!this.isInitialized) {
await this.initialize();
}
try {
// 使用 scheduleNotificationAsync 立即显示通知
// 配合 setNotificationHandler 确保前台也能显示
if (!getNotificationPreferencesSync().pushEnabled) {
return null;
}
const { vibrationEnabled } = getNotificationPreferencesSync();
// 构建通知内容
const content: Notifications.NotificationContentInput = {
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<string, string> = {};
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,
body: options.body,
data: options.data as Record<string, string | number | object> | undefined,
// 显式设置震动(仅 Android 生效,且尊重「消息震动」开关)
...(Platform.OS === 'android' && vibrationEnabled
? { vibrationPattern: [0, 300, 200, 300] }
: {}),
};
const notificationId = await Notifications.scheduleNotificationAsync({
content,
trigger: null, // null 表示立即显示
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<void> {
await Notifications.dismissAllNotificationsAsync();
jpushService.clearAllNotifications();
if (Platform.OS === 'web' && 'Notification' in window) {
// No programmatic way to clear browser notifications
}
}
async clearBadge(): Promise<void> {
await Notifications.setBadgeCountAsync(0);
jpushService.setBadge(0);
}
async setBadgeCount(count: number): Promise<void> {
await Notifications.setBadgeCountAsync(count);
jpushService.setBadge(count);
}
cleanup(): void {

View File

@@ -5,6 +5,7 @@
"paths": {
"@/*": ["./src/*"]
},
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true