Files
frontend/src/services/notification/jpushService.ts
lafay b7ce9e3b9a
Some checks failed
Frontend CI / ota-android (push) Successful in 1m27s
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
feat(notification): integrate expo-notifications for permission handling and deep linking
- Add expo-notifications dependency and configure with JPush plugin
- Request notification permission on app launch for iOS/Android
- Handle notification taps to navigate to chat or notifications screen
- Add UI to check and request system notification permission in settings
- Refactor background sync: WebSocket disconnects in background, JPush handles push
- Add checkNotificationPermission() and requestNotificationPermission() to jpushService
2026-04-28 00:20:07 +08:00

348 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Platform, NativeModules } from 'react-native';
import { pushService } from './pushService';
import * as Notifications from 'expo-notifications';
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);
}
}
/**
* 请求通知权限(使用 expo-notifications统一处理 iOS/Android
*/
async requestNotificationPermission(): Promise<boolean> {
if (Platform.OS === 'web') return false;
try {
const { status } = await Notifications.requestPermissionsAsync();
return status === 'granted';
} catch (error) {
console.warn('[JPush] request notification permission failed:', error);
return false;
}
}
/**
* 检查通知权限是否已开启
*/
async checkNotificationPermission(): Promise<boolean> {
if (Platform.OS === 'web') return false;
try {
const { status } = await Notifications.getPermissionsAsync();
return status === 'granted';
} catch {
return false;
}
}
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;