237 lines
6.9 KiB
JavaScript
237 lines
6.9 KiB
JavaScript
import { appConfig } from '@/config';
|
|||
|
|
import { HttpImClient } from './httpImClient';
|
||
|
|
|
||
|
|
const HEARTBEAT_INTERVAL_MS = 25000;
|
||
|
|
const RECONNECT_MIN_MS = 1000;
|
||
|
|
const RECONNECT_MAX_MS = 15000;
|
||
|
|
|
||
|
|
function buildWsUrl() {
|
||
|
|
if (appConfig.wsUrl) {
|
||
|
|
return appConfig.wsUrl;
|
||
|
|
}
|
||
|
|
const protocol = appConfig.useWss ? 'wss' : 'ws';
|
||
|
|
const host = appConfig.imServerHost || window.location.hostname;
|
||
|
|
const port = appConfig.imRoutePort ? `:${appConfig.imRoutePort}` : '';
|
||
|
|
const path = appConfig.wsPath?.startsWith('/') ? appConfig.wsPath : `/${appConfig.wsPath || 'ws'}`;
|
||
|
|
return `${protocol}://${host}${port}${path}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeConversationId(payload) {
|
||
|
|
const target = payload.target || payload.to || payload.receiver || payload.conversationTarget || payload.from || payload.sender;
|
||
|
|
return payload.conversationId || (target ? `single-${target}` : '');
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeIncomingMessage(payload, currentUserId) {
|
||
|
|
const item = payload.message || payload;
|
||
|
|
const from = item.from || item.sender || item.fromUser || '';
|
||
|
|
const target = item.target || item.to || item.receiver || '';
|
||
|
|
const direction = from && String(from) === String(currentUserId) ? 'out' : 'in';
|
||
|
|
const conversationId = item.conversationId || normalizeConversationId({
|
||
|
|
...item,
|
||
|
|
target: direction === 'out' ? target : from
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!conversationId) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const status = item.status || item.deliveryStatus || 'sent';
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: item.id || item.messageId || item.messageUid || `${Date.now()}${Math.random().toString(16).slice(2)}`,
|
||
|
|
messageUid: item.messageUid || item.uid || item.messageId || '',
|
||
|
|
conversationId,
|
||
|
|
from,
|
||
|
|
target,
|
||
|
|
direction,
|
||
|
|
type: item.type || 'text',
|
||
|
|
text: item.text || item.content || item.searchableContent || '',
|
||
|
|
status,
|
||
|
|
deliveryStatus: item.deliveryStatus || (direction === 'out' ? status : ''),
|
||
|
|
timestamp: Number(item.timestamp || item.serverTimestamp || Date.now()),
|
||
|
|
displayTimestamp: Number(item.displayTimestamp || item.timestamp || item.serverTimestamp || Date.now()),
|
||
|
|
raw: item
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export class LongConnectionImClient extends HttpImClient {
|
||
|
|
constructor() {
|
||
|
|
super();
|
||
|
|
this.socket = null;
|
||
|
|
this.wsUrl = buildWsUrl();
|
||
|
|
this.heartbeatTimer = null;
|
||
|
|
this.reconnectTimer = null;
|
||
|
|
this.reconnectAttempts = 0;
|
||
|
|
this.manualClose = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
getSdkVersion() {
|
||
|
|
return 'JC-LC/0.1';
|
||
|
|
}
|
||
|
|
|
||
|
|
shouldUseMessagePolling() {
|
||
|
|
return !this.connected;
|
||
|
|
}
|
||
|
|
|
||
|
|
async ensureSdk() {
|
||
|
|
if (typeof WebSocket === 'undefined') {
|
||
|
|
throw new Error('当前浏览器不支持 WebSocket 长连接');
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
connect(userId, token) {
|
||
|
|
this.currentUser = { userId, token };
|
||
|
|
this.manualClose = false;
|
||
|
|
this.openSocket();
|
||
|
|
}
|
||
|
|
|
||
|
|
disconnect() {
|
||
|
|
this.manualClose = true;
|
||
|
|
this.clearReconnectTimer();
|
||
|
|
this.clearHeartbeatTimer();
|
||
|
|
if (this.socket) {
|
||
|
|
this.socket.close();
|
||
|
|
this.socket = null;
|
||
|
|
}
|
||
|
|
super.disconnect();
|
||
|
|
}
|
||
|
|
|
||
|
|
openSocket() {
|
||
|
|
if (this.socket?.readyState === WebSocket.OPEN) {
|
||
|
|
this.sendAuthFrame();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (this.socket?.readyState === WebSocket.CONNECTING) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接连接中' });
|
||
|
|
|
||
|
|
try {
|
||
|
|
this.socket = new WebSocket(this.wsUrl);
|
||
|
|
this.socket.binaryType = 'arraybuffer';
|
||
|
|
} catch (error) {
|
||
|
|
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接创建失败,使用HTTP补偿' });
|
||
|
|
this.scheduleReconnect();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.socket.onopen = () => {
|
||
|
|
this.connected = true;
|
||
|
|
this.reconnectAttempts = 0;
|
||
|
|
this.emit('connectionStatusChanged', { connected: true, statusText: '长连接已连接' });
|
||
|
|
this.sendAuthFrame();
|
||
|
|
this.startHeartbeat();
|
||
|
|
this.refreshConversationList();
|
||
|
|
};
|
||
|
|
|
||
|
|
this.socket.onmessage = event => {
|
||
|
|
this.handleFrame(event.data);
|
||
|
|
};
|
||
|
|
|
||
|
|
this.socket.onerror = () => {
|
||
|
|
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接异常,使用HTTP补偿' });
|
||
|
|
};
|
||
|
|
|
||
|
|
this.socket.onclose = () => {
|
||
|
|
this.connected = false;
|
||
|
|
this.clearHeartbeatTimer();
|
||
|
|
this.socket = null;
|
||
|
|
if (this.manualClose) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接重连中' });
|
||
|
|
this.scheduleReconnect();
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
sendAuthFrame() {
|
||
|
|
this.sendFrame({
|
||
|
|
type: 'auth',
|
||
|
|
userId: this.currentUser?.userId || '',
|
||
|
|
token: this.currentUser?.token || '',
|
||
|
|
clientId: this.getClientId(),
|
||
|
|
platform: appConfig.platform,
|
||
|
|
timestamp: Date.now()
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
sendFrame(frame) {
|
||
|
|
if (this.socket?.readyState !== WebSocket.OPEN) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
this.socket.send(JSON.stringify(frame));
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
handleFrame(rawData) {
|
||
|
|
if (rawData instanceof ArrayBuffer) {
|
||
|
|
this.emit('rawFrameReceived', rawData);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let payload;
|
||
|
|
try {
|
||
|
|
payload = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
|
||
|
|
} catch (error) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const type = payload.type || payload.event || payload.cmd || '';
|
||
|
|
if (type === 'pong' || type === 'auth_ok' || type === 'connected') {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (type === 'contactsChanged') {
|
||
|
|
this.emit('contactsChanged', payload.contacts || []);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (type === 'conversationsChanged') {
|
||
|
|
this.emit('conversationsChanged', payload.conversations || []);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (type === 'messages' && Array.isArray(payload.messages)) {
|
||
|
|
payload.messages
|
||
|
|
.map(message => normalizeIncomingMessage(message, this.currentUser?.userId))
|
||
|
|
.filter(Boolean)
|
||
|
|
.forEach(message => this.emit('messageReceived', message));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const message = normalizeIncomingMessage(payload, this.currentUser?.userId);
|
||
|
|
if (message) {
|
||
|
|
this.emit(message.direction === 'out' ? 'messageStatusChanged' : 'messageReceived', message);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
startHeartbeat() {
|
||
|
|
this.clearHeartbeatTimer();
|
||
|
|
this.heartbeatTimer = window.setInterval(() => {
|
||
|
|
this.sendFrame({ type: 'ping', timestamp: Date.now() });
|
||
|
|
}, HEARTBEAT_INTERVAL_MS);
|
||
|
|
}
|
||
|
|
|
||
|
|
clearHeartbeatTimer() {
|
||
|
|
if (this.heartbeatTimer) {
|
||
|
|
window.clearInterval(this.heartbeatTimer);
|
||
|
|
this.heartbeatTimer = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
scheduleReconnect() {
|
||
|
|
this.clearReconnectTimer();
|
||
|
|
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * 2 ** this.reconnectAttempts);
|
||
|
|
this.reconnectAttempts += 1;
|
||
|
|
this.reconnectTimer = window.setTimeout(() => {
|
||
|
|
this.openSocket();
|
||
|
|
}, delay);
|
||
|
|
}
|
||
|
|
|
||
|
|
clearReconnectTimer() {
|
||
|
|
if (this.reconnectTimer) {
|
||
|
|
window.clearTimeout(this.reconnectTimer);
|
||
|
|
this.reconnectTimer = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|