Migrate frontend realtime messaging to SSE.
Switch service integrations and screen/store consumers from websocket events to SSE, and ignore generated dist-web artifacts. Made-with: Cursor
This commit is contained in:
@@ -8,7 +8,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { CommonActions } from '@react-navigation/native';
|
||||
import Constants from 'expo-constants';
|
||||
|
||||
|
||||
// 生产地址 https://bbs.littlelan.cn
|
||||
const getBaseUrl = () => {
|
||||
const configuredBaseUrl = Constants.expoConfig?.extra?.apiBaseUrl;
|
||||
if (typeof configuredBaseUrl === 'string' && configuredBaseUrl.trim().length > 0) {
|
||||
@@ -17,16 +17,8 @@ const getBaseUrl = () => {
|
||||
return 'https://bbs.littlelan.cn/api/v1';
|
||||
};
|
||||
|
||||
const getWsUrl = () => {
|
||||
const configuredWsUrl = Constants.expoConfig?.extra?.wsUrl;
|
||||
if (typeof configuredWsUrl === 'string' && configuredWsUrl.trim().length > 0) {
|
||||
return configuredWsUrl;
|
||||
}
|
||||
return 'wss://bbs.littlelan.cn/ws';
|
||||
};
|
||||
|
||||
const BASE_URL = getBaseUrl();
|
||||
const WS_URL = getWsUrl();
|
||||
const SSE_URL = `${BASE_URL.replace(/\/+$/, '')}/realtime/sse`;
|
||||
|
||||
// Token 存储键
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
@@ -187,15 +179,45 @@ class ApiClient {
|
||||
return this.request(method, path, params, body);
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
const data: ApiResponse<T> = await response.json();
|
||||
// 解析响应(兼容非 JSON 返回,避免 SyntaxError 被误判为网络错误)
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
let parsedBody: any = null;
|
||||
let rawText = '';
|
||||
|
||||
// 处理业务错误
|
||||
if (data.code !== 0) {
|
||||
throw new ApiError(data.code, data.message);
|
||||
if (contentType.includes('application/json')) {
|
||||
parsedBody = await response.json();
|
||||
} else {
|
||||
rawText = await response.text();
|
||||
if (rawText) {
|
||||
try {
|
||||
parsedBody = JSON.parse(rawText);
|
||||
} catch {
|
||||
parsedBody = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
// 优先处理标准 API 结构
|
||||
if (parsedBody && typeof parsedBody === 'object' && 'code' in parsedBody) {
|
||||
const data = parsedBody as ApiResponse<T>;
|
||||
if (data.code !== 0) {
|
||||
throw new ApiError(data.code, data.message || '请求失败');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// 非标准结构:先按 HTTP 状态处理失败
|
||||
if (!response.ok) {
|
||||
const fallbackMessage = rawText || response.statusText || `请求失败(${response.status})`;
|
||||
throw new ApiError(response.status, fallbackMessage);
|
||||
}
|
||||
|
||||
// 非标准结构但 HTTP 成功:兜底为成功响应(兼容部分纯文本成功接口)
|
||||
return {
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: (parsedBody as T) ?? (undefined as T),
|
||||
};
|
||||
} catch (error) {
|
||||
// 如果是 ApiError,直接抛出
|
||||
if (error instanceof ApiError) {
|
||||
@@ -312,5 +334,4 @@ class ApiClient {
|
||||
// 导出 API 客户端实例
|
||||
export const api = new ApiClient(BASE_URL);
|
||||
|
||||
// 导出 WebSocket URL
|
||||
export { WS_URL, TOKEN_KEY, REFRESH_TOKEN_KEY };
|
||||
export { SSE_URL, TOKEN_KEY, REFRESH_TOKEN_KEY };
|
||||
|
||||
@@ -12,11 +12,11 @@ import { AppState, AppStateStatus, Platform } from 'react-native';
|
||||
import * as BackgroundFetch from 'expo-background-fetch';
|
||||
import * as TaskManager from 'expo-task-manager';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { websocketService } from './websocketService';
|
||||
import { sseService } from './sseService';
|
||||
|
||||
// 后台任务名称
|
||||
const BACKGROUND_FETCH_TASK = 'background-fetch-keepalive';
|
||||
const WEBSOCKET_KEEPALIVE_TASK = 'websocket-keepalive';
|
||||
const REALTIME_KEEPALIVE_TASK = 'realtime-keepalive';
|
||||
|
||||
// 后台任务间隔(Android 最小 15 分钟,iOS 最小 15 分钟)
|
||||
const BACKGROUND_INTERVAL = 15; // 15 分钟
|
||||
@@ -48,8 +48,8 @@ let appStateSubscription: any = null;
|
||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||
try {
|
||||
// 检查 WebSocket 连接状态
|
||||
if (!websocketService.isConnected()) {
|
||||
await websocketService.connect();
|
||||
if (!sseService.isConnected()) {
|
||||
await sseService.connect();
|
||||
}
|
||||
|
||||
// 返回收到新数据
|
||||
@@ -61,14 +61,14 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||
});
|
||||
|
||||
// WebSocket 保活任务
|
||||
TaskManager.defineTask(WEBSOCKET_KEEPALIVE_TASK, async () => {
|
||||
TaskManager.defineTask(REALTIME_KEEPALIVE_TASK, async () => {
|
||||
try {
|
||||
if (!websocketService.isConnected()) {
|
||||
await websocketService.connect();
|
||||
if (!sseService.isConnected()) {
|
||||
await sseService.connect();
|
||||
}
|
||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||
} catch (error) {
|
||||
console.error('[BackgroundService] WebSocket 保活失败:', error);
|
||||
console.error('[BackgroundService] SSE 保活失败:', error);
|
||||
return BackgroundFetch.BackgroundFetchResult.Failed;
|
||||
}
|
||||
});
|
||||
@@ -175,9 +175,9 @@ async function registerBackgroundTasks(): Promise<void> {
|
||||
}
|
||||
|
||||
// 注册 WebSocket 保活任务
|
||||
const isWsKeepaliveRegistered = await TaskManager.isTaskRegisteredAsync(WEBSOCKET_KEEPALIVE_TASK);
|
||||
const isWsKeepaliveRegistered = await TaskManager.isTaskRegisteredAsync(REALTIME_KEEPALIVE_TASK);
|
||||
if (!isWsKeepaliveRegistered) {
|
||||
await BackgroundFetch.registerTaskAsync(WEBSOCKET_KEEPALIVE_TASK, {
|
||||
await BackgroundFetch.registerTaskAsync(REALTIME_KEEPALIVE_TASK, {
|
||||
minimumInterval: 60, // 1 分钟检查一次
|
||||
stopOnTerminate: false,
|
||||
startOnBoot: true,
|
||||
@@ -194,7 +194,7 @@ async function registerBackgroundTasks(): Promise<void> {
|
||||
async function unregisterBackgroundTasks(): Promise<void> {
|
||||
try {
|
||||
await BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK);
|
||||
await BackgroundFetch.unregisterTaskAsync(WEBSOCKET_KEEPALIVE_TASK);
|
||||
await BackgroundFetch.unregisterTaskAsync(REALTIME_KEEPALIVE_TASK);
|
||||
} catch (error) {
|
||||
console.error('[BackgroundService] 取消后台任务失败:', error);
|
||||
}
|
||||
@@ -212,8 +212,8 @@ function setupAppStateListener(): void {
|
||||
void nextAppState;
|
||||
if (nextAppState === 'active') {
|
||||
// App 回到前台,确保连接
|
||||
if (!websocketService.isConnected()) {
|
||||
websocketService.connect();
|
||||
if (!sseService.isConnected()) {
|
||||
sseService.connect();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -459,9 +459,21 @@ export const deleteMessage = async (messageId: string): Promise<void> => {
|
||||
};
|
||||
|
||||
// 更新消息状态(如撤回)
|
||||
export const updateMessageStatus = async (messageId: string, status: string): Promise<void> => {
|
||||
// clearContent=true 时,会同时清空本地存储的消息内容与 segments,仅保留状态占位
|
||||
export const updateMessageStatus = async (
|
||||
messageId: string,
|
||||
status: string,
|
||||
clearContent: boolean = false
|
||||
): Promise<void> => {
|
||||
await enqueueWrite(async () => {
|
||||
const database = await getDb();
|
||||
if (clearContent) {
|
||||
await database.runAsync(
|
||||
`UPDATE messages SET status = ?, content = '', segments = '[]' WHERE id = ?`,
|
||||
[status, messageId]
|
||||
);
|
||||
return;
|
||||
}
|
||||
await database.runAsync(
|
||||
`UPDATE messages SET status = ? WHERE id = ?`,
|
||||
[status, messageId]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
// API 客户端
|
||||
export { api, WS_URL, TOKEN_KEY, REFRESH_TOKEN_KEY } from './api';
|
||||
export { api, SSE_URL, TOKEN_KEY, REFRESH_TOKEN_KEY } from './api';
|
||||
export type { ApiResponse, PaginatedData, ApiError } from './api';
|
||||
|
||||
// 认证服务
|
||||
@@ -45,8 +45,8 @@ export { pushService, registerDevice, getDevices, unregisterDevice, updateDevice
|
||||
// 投票服务
|
||||
export { voteService } from './voteService';
|
||||
|
||||
// WebSocket 服务
|
||||
export { websocketService } from './websocketService';
|
||||
// SSE 实时服务
|
||||
export { sseService } from './sseService';
|
||||
export type {
|
||||
WSMessage,
|
||||
WSMessageType,
|
||||
@@ -63,7 +63,7 @@ export type {
|
||||
WSGroupMentionMessage,
|
||||
WSGroupReadMessage,
|
||||
WSGroupRecallMessage
|
||||
} from './websocketService';
|
||||
} from './sseService';
|
||||
|
||||
// 系统通知服务
|
||||
export { systemNotificationService, getNotificationTitle } from './systemNotificationService';
|
||||
|
||||
@@ -441,6 +441,16 @@ class MessageService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报输入状态
|
||||
* POST /api/v1/conversations/typing
|
||||
*/
|
||||
async sendTyping(conversationId: string): Promise<void> {
|
||||
await api.post('/conversations/typing', {
|
||||
conversation_id: conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取未读总数
|
||||
* GET /api/v1/conversations/unread/count
|
||||
|
||||
463
src/services/sseService.ts
Normal file
463
src/services/sseService.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
import { AppState, AppStateStatus } from 'react-native';
|
||||
import EventSource from 'react-native-sse';
|
||||
|
||||
import { api, SSE_URL } from './api';
|
||||
import { MessageCategory, SystemMessageType, SystemMessageExtraData, MessageSegment } from '../types/dto';
|
||||
import { systemNotificationService } from './systemNotificationService';
|
||||
import { vibrateOnMessage } from './backgroundService';
|
||||
|
||||
export type WSMessageType =
|
||||
| 'chat'
|
||||
| 'message'
|
||||
| 'read'
|
||||
| 'typing'
|
||||
| 'recall'
|
||||
| 'notification'
|
||||
| 'announcement'
|
||||
| 'group_message'
|
||||
| 'group_typing'
|
||||
| 'group_notice'
|
||||
| 'group_mention'
|
||||
| 'group_read'
|
||||
| 'group_recall'
|
||||
| 'notice'
|
||||
| 'request'
|
||||
| 'meta'
|
||||
| 'private'
|
||||
| 'group'
|
||||
| 'follow'
|
||||
| 'like'
|
||||
| 'comment'
|
||||
| 'heartbeat';
|
||||
|
||||
export interface WSChatMessage {
|
||||
type: 'chat';
|
||||
conversation_id: string;
|
||||
id: string;
|
||||
sender_id: string;
|
||||
seq: number;
|
||||
segments?: MessageSegment[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WSReadMessage {
|
||||
type: 'read';
|
||||
conversation_id: string;
|
||||
user_id: string;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
export interface WSTypingMessage {
|
||||
type: 'typing';
|
||||
conversation_id: string;
|
||||
user_id: string;
|
||||
is_typing: boolean;
|
||||
}
|
||||
|
||||
export interface WSRecallMessage {
|
||||
type: 'recall';
|
||||
conversation_id: string;
|
||||
message_id: string;
|
||||
}
|
||||
|
||||
export interface WSNotificationMessage {
|
||||
type: 'notification';
|
||||
id: number | string;
|
||||
sender_id?: string;
|
||||
receiver_id?: string;
|
||||
content: string;
|
||||
category?: MessageCategory;
|
||||
system_type?: SystemMessageType;
|
||||
extra_data?: SystemMessageExtraData;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WSAnnouncementMessage {
|
||||
type: 'announcement';
|
||||
id: number | string;
|
||||
sender_id?: string;
|
||||
receiver_id?: string;
|
||||
content: string;
|
||||
category?: MessageCategory;
|
||||
system_type?: SystemMessageType;
|
||||
extra_data?: SystemMessageExtraData;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WSGroupMentionMessage {
|
||||
type: 'group_mention';
|
||||
group_id: number | string;
|
||||
conversation_id: string;
|
||||
message_id: string;
|
||||
from_user_id: string;
|
||||
content: string;
|
||||
mention_all: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WSGroupChatMessage {
|
||||
type: 'group_message';
|
||||
conversation_id: string;
|
||||
group_id: number | string;
|
||||
id: string;
|
||||
sender_id: string;
|
||||
seq: number;
|
||||
segments?: MessageSegment[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WSGroupTypingMessage {
|
||||
type: 'group_typing';
|
||||
group_id: number | string;
|
||||
user_id: string;
|
||||
is_typing: boolean;
|
||||
}
|
||||
|
||||
export type GroupNoticeType = 'member_join' | 'member_leave' | 'member_removed' | 'role_changed' | 'muted' | 'unmuted';
|
||||
|
||||
export interface WSGroupNoticeMessage {
|
||||
type: 'group_notice';
|
||||
notice_type: GroupNoticeType;
|
||||
group_id: number | string;
|
||||
data: {
|
||||
user_id?: string;
|
||||
operator_id?: string;
|
||||
role?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
timestamp: number;
|
||||
message_id?: string;
|
||||
seq?: number;
|
||||
}
|
||||
|
||||
export interface WSGroupReadMessage {
|
||||
type: 'group_read';
|
||||
group_id: number | string;
|
||||
conversation_id: string;
|
||||
user_id: string;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
export interface WSGroupRecallMessage {
|
||||
type: 'group_recall';
|
||||
group_id: number | string;
|
||||
conversation_id: string;
|
||||
message_id: string;
|
||||
}
|
||||
|
||||
export type WSMessage =
|
||||
| WSChatMessage
|
||||
| WSReadMessage
|
||||
| WSTypingMessage
|
||||
| WSRecallMessage
|
||||
| WSNotificationMessage
|
||||
| WSAnnouncementMessage
|
||||
| WSGroupChatMessage
|
||||
| WSGroupTypingMessage
|
||||
| WSGroupNoticeMessage
|
||||
| WSGroupMentionMessage
|
||||
| WSGroupReadMessage
|
||||
| WSGroupRecallMessage;
|
||||
|
||||
type MessageHandler<T extends WSMessage = WSMessage> = (message: T) => void;
|
||||
type ConnectionHandler = () => void;
|
||||
|
||||
interface SSEEnvelope {
|
||||
event_id?: number;
|
||||
event?: string;
|
||||
ts?: number;
|
||||
payload?: any;
|
||||
}
|
||||
|
||||
class SSEService {
|
||||
private source: EventSource | null = null;
|
||||
private isConnecting = false;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 20;
|
||||
private reconnectDelay = 3000;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private messageHandlers: Map<WSMessageType, MessageHandler[]> = new Map();
|
||||
private connectionHandlers: ConnectionHandler[] = [];
|
||||
private disconnectionHandlers: ConnectionHandler[] = [];
|
||||
private appStateSubscription: any = null;
|
||||
private lastAppState: AppStateStatus = 'active';
|
||||
private lastEventId = '';
|
||||
|
||||
private toSSEUrl(): string {
|
||||
return `${SSE_URL}?last_event_id=${encodeURIComponent(this.lastEventId)}`;
|
||||
}
|
||||
|
||||
async connect(): Promise<boolean> {
|
||||
if (this.isConnecting || this.isConnected()) return true;
|
||||
this.isConnecting = true;
|
||||
try {
|
||||
const token = await api.getToken();
|
||||
if (!token) {
|
||||
this.isConnecting = false;
|
||||
return false;
|
||||
}
|
||||
const url = this.toSSEUrl();
|
||||
this.source = new EventSource(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
this.source.addEventListener('open', () => {
|
||||
this.isConnecting = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.connectionHandlers.forEach(h => h());
|
||||
});
|
||||
|
||||
this.source.addEventListener('error', () => {
|
||||
this.isConnecting = false;
|
||||
this.disconnectionHandlers.forEach(h => h());
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
|
||||
const events = ['chat_message', 'message_read', 'typing', 'system_notification', 'group_notice', 'message_recall', 'heartbeat'];
|
||||
events.forEach(eventName => {
|
||||
this.source?.addEventListener(eventName as any, (evt: any) => this.handleIncoming(eventName, evt));
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
this.isConnecting = false;
|
||||
this.scheduleReconnect();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private handleIncoming(eventName: string, evt: any): void {
|
||||
const rawData = typeof evt?.data === 'string' ? evt.data : '{}';
|
||||
const lastEventId = evt?.lastEventId;
|
||||
if (lastEventId) {
|
||||
this.lastEventId = String(lastEventId);
|
||||
}
|
||||
let payload: any = {};
|
||||
try {
|
||||
payload = JSON.parse(rawData);
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
console.log('[SSE] 收到消息:', {
|
||||
event: eventName,
|
||||
lastEventId: this.lastEventId,
|
||||
payload,
|
||||
});
|
||||
this.dispatchEvent(eventName, payload);
|
||||
}
|
||||
|
||||
private dispatchEvent(eventName: string, payload: any): void {
|
||||
if (eventName === 'chat_message') {
|
||||
const detailType = payload?.detail_type || 'private';
|
||||
const m = payload?.message || payload;
|
||||
if (detailType === 'group') {
|
||||
const gm: WSGroupChatMessage = {
|
||||
type: 'group_message',
|
||||
conversation_id: m.conversation_id,
|
||||
group_id: m.group_id || '',
|
||||
id: m.id,
|
||||
sender_id: m.sender_id,
|
||||
seq: Number(m.seq || 0),
|
||||
segments: m.segments || [],
|
||||
created_at: m.created_at || new Date().toISOString(),
|
||||
};
|
||||
this.emit('group_message', gm);
|
||||
vibrateOnMessage('group_message').catch(() => {});
|
||||
} else {
|
||||
const cm: WSChatMessage = {
|
||||
type: 'chat',
|
||||
conversation_id: m.conversation_id,
|
||||
id: m.id,
|
||||
sender_id: m.sender_id,
|
||||
seq: Number(m.seq || 0),
|
||||
segments: m.segments || [],
|
||||
created_at: m.created_at || new Date().toISOString(),
|
||||
};
|
||||
this.emit('chat', cm);
|
||||
vibrateOnMessage('chat').catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName === 'message_read') {
|
||||
const detailType = payload?.detail_type || 'private';
|
||||
if (detailType === 'group') {
|
||||
const m: WSGroupReadMessage = {
|
||||
type: 'group_read',
|
||||
group_id: payload.group_id || '',
|
||||
conversation_id: payload.conversation_id,
|
||||
user_id: payload.user_id,
|
||||
seq: Number(payload.seq || 0),
|
||||
};
|
||||
this.emit('group_read', m);
|
||||
} else {
|
||||
const m: WSReadMessage = {
|
||||
type: 'read',
|
||||
conversation_id: payload.conversation_id,
|
||||
user_id: payload.user_id,
|
||||
seq: Number(payload.seq || 0),
|
||||
};
|
||||
this.emit('read', m);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName === 'typing') {
|
||||
const detailType = payload?.detail_type || 'private';
|
||||
if (detailType === 'group') {
|
||||
const m: WSGroupTypingMessage = {
|
||||
type: 'group_typing',
|
||||
group_id: payload.group_id || '',
|
||||
user_id: payload.user_id,
|
||||
is_typing: payload.is_typing !== false,
|
||||
};
|
||||
this.emit('group_typing', m);
|
||||
} else {
|
||||
const m: WSTypingMessage = {
|
||||
type: 'typing',
|
||||
conversation_id: payload.conversation_id,
|
||||
user_id: payload.user_id,
|
||||
is_typing: payload.is_typing !== false,
|
||||
};
|
||||
this.emit('typing', m);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName === 'message_recall') {
|
||||
const detailType = payload?.detail_type || 'private';
|
||||
if (detailType === 'group') {
|
||||
const m: WSGroupRecallMessage = {
|
||||
type: 'group_recall',
|
||||
group_id: payload.group_id || '',
|
||||
conversation_id: payload.conversation_id,
|
||||
message_id: payload.message_id,
|
||||
};
|
||||
this.emit('group_recall', m);
|
||||
} else {
|
||||
const m: WSRecallMessage = {
|
||||
type: 'recall',
|
||||
conversation_id: payload.conversation_id,
|
||||
message_id: payload.message_id,
|
||||
};
|
||||
this.emit('recall', m);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName === 'group_notice') {
|
||||
const m: WSGroupNoticeMessage = {
|
||||
type: 'group_notice',
|
||||
notice_type: payload.notice_type,
|
||||
group_id: payload.group_id,
|
||||
data: payload.data || {},
|
||||
timestamp: payload.timestamp || Date.now(),
|
||||
message_id: payload.message_id,
|
||||
seq: payload.seq,
|
||||
};
|
||||
this.emit('group_notice', m);
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName === 'system_notification') {
|
||||
const m: WSNotificationMessage = {
|
||||
type: 'notification',
|
||||
id: payload.id || '',
|
||||
content: payload.content || '',
|
||||
created_at: payload.created_at || new Date().toISOString(),
|
||||
};
|
||||
this.emit('notification', m);
|
||||
vibrateOnMessage('notification').catch(() => {});
|
||||
systemNotificationService.handleWSMessage(m as any).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
private emit<T extends WSMessageType>(type: T, message: Extract<WSMessage, { type: T }>) {
|
||||
const handlers = this.messageHandlers.get(type) || [];
|
||||
handlers.forEach(h => h(message as WSMessage));
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.source) {
|
||||
this.source.close();
|
||||
this.source = null;
|
||||
}
|
||||
this.stopReconnect();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
|
||||
this.stopReconnect();
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectAttempts += 1;
|
||||
this.connect();
|
||||
}, this.reconnectDelay);
|
||||
}
|
||||
|
||||
private stopReconnect() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.source != null;
|
||||
}
|
||||
|
||||
on<T extends WSMessageType>(type: T, handler: MessageHandler<Extract<WSMessage, { type: T }>>): () => void {
|
||||
const list = this.messageHandlers.get(type) || [];
|
||||
list.push(handler as MessageHandler);
|
||||
this.messageHandlers.set(type, list);
|
||||
return () => {
|
||||
const current = this.messageHandlers.get(type) || [];
|
||||
const idx = current.indexOf(handler as MessageHandler);
|
||||
if (idx >= 0) current.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
onConnect(handler: ConnectionHandler): () => void {
|
||||
this.connectionHandlers.push(handler);
|
||||
return () => {
|
||||
const i = this.connectionHandlers.indexOf(handler);
|
||||
if (i >= 0) this.connectionHandlers.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
onDisconnect(handler: ConnectionHandler): () => void {
|
||||
this.disconnectionHandlers.push(handler);
|
||||
return () => {
|
||||
const i = this.disconnectionHandlers.indexOf(handler);
|
||||
if (i >= 0) this.disconnectionHandlers.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
private setupAppStateListener(): void {
|
||||
if (this.appStateSubscription) return;
|
||||
this.lastAppState = AppState.currentState;
|
||||
this.appStateSubscription = AppState.addEventListener('change', (nextState: AppStateStatus) => {
|
||||
if (this.lastAppState.match(/inactive|background/) && nextState === 'active' && !this.isConnected()) {
|
||||
this.reconnectAttempts = 0;
|
||||
this.connect();
|
||||
}
|
||||
this.lastAppState = nextState;
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<boolean> {
|
||||
this.setupAppStateListener();
|
||||
return this.connect();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.appStateSubscription) {
|
||||
this.appStateSubscription.remove();
|
||||
this.appStateSubscription = null;
|
||||
}
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export const sseService = new SSEService();
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { Platform, AppState, AppStateStatus } from 'react-native';
|
||||
import type { WSChatMessage, WSNotificationMessage, WSAnnouncementMessage } from './websocketService';
|
||||
import type { WSChatMessage, WSNotificationMessage, WSAnnouncementMessage } from './sseService';
|
||||
import { extractTextFromSegments } from '../types/dto';
|
||||
|
||||
// 通知渠道配置
|
||||
@@ -169,9 +169,9 @@ class SystemNotificationService {
|
||||
data: {
|
||||
type: message.type,
|
||||
id: String(message.id),
|
||||
senderId: message.sender_id,
|
||||
receiverId: message.receiver_id,
|
||||
systemType: message.system_type,
|
||||
senderId: message.sender_id || '',
|
||||
receiverId: message.receiver_id || '',
|
||||
systemType: message.system_type || '',
|
||||
extraData: JSON.stringify(message.extra_data || {}),
|
||||
},
|
||||
type,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user