feat(CallFeature): implement call functionality and integrate WebSocket signaling
- Updated app.json to include microphone permissions and background audio support. - Added call-related dependencies in package.json and package-lock.json. - Enhanced ChatScreen to initiate calls and handle call actions. - Introduced call components in the layout for incoming call handling. - Expanded WebSocket service to manage call signaling messages and events. - Refactored authStore to initialize call state on user authentication.
This commit is contained in:
281
src/services/webrtc/WebRTCManager.ts
Normal file
281
src/services/webrtc/WebRTCManager.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
RTCPeerConnection,
|
||||
RTCSessionDescription,
|
||||
RTCIceCandidate,
|
||||
mediaDevices,
|
||||
MediaStream,
|
||||
MediaStreamTrack,
|
||||
} from 'react-native-webrtc';
|
||||
|
||||
export interface ICEServer {
|
||||
urls: string[];
|
||||
username?: string;
|
||||
credential?: string;
|
||||
}
|
||||
|
||||
export type ConnectionState = 'new' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed';
|
||||
|
||||
export type WebRTCManagerEvent =
|
||||
| { type: 'icecandidate'; candidate: RTCIceCandidate | null }
|
||||
| { type: 'connectionstatechange'; state: ConnectionState }
|
||||
| { type: 'remotestream'; stream: MediaStream }
|
||||
| { type: 'error'; error: Error };
|
||||
|
||||
type EventHandler = (event: WebRTCManagerEvent) => void;
|
||||
|
||||
class WebRTCManager {
|
||||
private peerConnection: RTCPeerConnection | null = null;
|
||||
private localStream: MediaStream | null = null;
|
||||
private remoteStream: MediaStream | null = null;
|
||||
private pendingCandidates: RTCIceCandidateInit[] = [];
|
||||
private iceServers: ICEServer[] = [];
|
||||
private eventHandlers: Set<EventHandler> = new Set();
|
||||
private disposed = false;
|
||||
private isInitiator = false;
|
||||
|
||||
async initialize(iceServers: ICEServer[] = []): Promise<void> {
|
||||
if (this.peerConnection) {
|
||||
this.dispose();
|
||||
}
|
||||
this.disposed = false;
|
||||
this.iceServers = iceServers.length > 0
|
||||
? iceServers
|
||||
: [{ urls: ['stun:stun.l.google.com:19302'] }];
|
||||
}
|
||||
|
||||
private buildPeerConnectionConfig(): RTCConfiguration {
|
||||
return {
|
||||
iceServers: this.iceServers.map((server) => ({
|
||||
urls: server.urls,
|
||||
...(server.username ? { username: server.username } : {}),
|
||||
...(server.credential ? { credential: server.credential } : {}),
|
||||
})),
|
||||
iceCandidatePoolSize: 10,
|
||||
};
|
||||
}
|
||||
|
||||
private createPeerConnection(): RTCPeerConnection {
|
||||
const config = this.buildPeerConnectionConfig();
|
||||
const pc = new RTCPeerConnection(config);
|
||||
|
||||
// @ts-ignore - react-native-webrtc uses on* handlers instead of addEventListener
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
this.emit({ type: 'icecandidate', candidate: event.candidate });
|
||||
} else {
|
||||
this.emit({ type: 'icecandidate', candidate: null });
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
const state = pc.iceConnectionState as ConnectionState;
|
||||
this.emit({ type: 'connectionstatechange', state });
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
pc.onconnectionstatechange = () => {
|
||||
const state = pc.connectionState as ConnectionState;
|
||||
this.emit({ type: 'connectionstatechange', state });
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
pc.ontrack = (event) => {
|
||||
if (event.streams && event.streams[0]) {
|
||||
this.remoteStream = event.streams[0];
|
||||
this.emit({ type: 'remotestream', stream: event.streams[0] });
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
pc.onsignalingstatechange = () => {
|
||||
// Could emit state change here if needed
|
||||
};
|
||||
|
||||
return pc;
|
||||
}
|
||||
|
||||
async createLocalStream(voiceOnly = true): Promise<MediaStream> {
|
||||
const constraints = voiceOnly
|
||||
? { audio: true, video: false }
|
||||
: { audio: true, video: { facingMode: 'user', frameRate: 30 } };
|
||||
|
||||
try {
|
||||
// @ts-ignore - react-native-webrtc has different constraint types
|
||||
this.localStream = await mediaDevices.getUserMedia(constraints);
|
||||
return this.localStream;
|
||||
} catch (error) {
|
||||
console.error('[WebRTC] Failed to get local media stream:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async startCall(isInitiator: boolean): Promise<RTCSessionDescriptionInit | null> {
|
||||
if (this.disposed) throw new Error('WebRTCManager has been disposed');
|
||||
if (!this.localStream) throw new Error('Local stream not initialized');
|
||||
|
||||
this.isInitiator = isInitiator;
|
||||
this.peerConnection = this.createPeerConnection();
|
||||
|
||||
// Add local tracks to peer connection
|
||||
for (const track of this.localStream.getTracks()) {
|
||||
this.peerConnection.addTrack(track, this.localStream);
|
||||
}
|
||||
|
||||
if (isInitiator) {
|
||||
const offer = await this.createOffer();
|
||||
return offer;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async createOffer(): Promise<RTCSessionDescriptionInit> {
|
||||
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
|
||||
|
||||
const offerOptions = {
|
||||
offerToReceiveAudio: true,
|
||||
offerToReceiveVideo: false,
|
||||
};
|
||||
|
||||
const offer = await this.peerConnection.createOffer(offerOptions);
|
||||
await this.peerConnection.setLocalDescription(offer);
|
||||
return offer;
|
||||
}
|
||||
|
||||
async createAnswer(): Promise<RTCSessionDescriptionInit> {
|
||||
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
|
||||
|
||||
const answer = await this.peerConnection.createAnswer();
|
||||
await this.peerConnection.setLocalDescription(answer);
|
||||
// Process pending candidates after local description is set
|
||||
await this.processPendingCandidates();
|
||||
return answer;
|
||||
}
|
||||
|
||||
async createAnswerFromOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
|
||||
await this.setRemoteDescription(offer);
|
||||
return this.createAnswer();
|
||||
}
|
||||
|
||||
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
|
||||
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
|
||||
|
||||
if (!description.sdp) {
|
||||
throw new Error('setRemoteDescription: sdp is required');
|
||||
}
|
||||
const desc = new RTCSessionDescription({
|
||||
type: description.type,
|
||||
sdp: description.sdp,
|
||||
});
|
||||
await this.peerConnection.setRemoteDescription(desc);
|
||||
|
||||
// Process pending candidates after remote description is set
|
||||
await this.processPendingCandidates();
|
||||
}
|
||||
|
||||
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
|
||||
if (!this.peerConnection) {
|
||||
this.pendingCandidates.push(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.peerConnection.remoteDescription) {
|
||||
this.pendingCandidates.push(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const iceCandidate = new RTCIceCandidate(candidate);
|
||||
await this.peerConnection.addIceCandidate(iceCandidate);
|
||||
} catch (error) {
|
||||
console.error('[WebRTC] Failed to add ICE candidate:', error);
|
||||
// Still push to pending in case order matters
|
||||
this.pendingCandidates.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
private async processPendingCandidates(): Promise<void> {
|
||||
if (this.pendingCandidates.length === 0) return;
|
||||
if (!this.peerConnection) return;
|
||||
|
||||
const candidates = [...this.pendingCandidates];
|
||||
this.pendingCandidates = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const iceCandidate = new RTCIceCandidate(candidate);
|
||||
await this.peerConnection.addIceCandidate(iceCandidate);
|
||||
} catch (error) {
|
||||
console.error('[WebRTC] Failed to add pending ICE candidate:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMuted(muted: boolean): void {
|
||||
if (!this.localStream) return;
|
||||
const audioTracks = this.localStream.getAudioTracks();
|
||||
audioTracks.forEach((track) => {
|
||||
track.enabled = !muted;
|
||||
});
|
||||
}
|
||||
|
||||
isMuted(): boolean {
|
||||
if (!this.localStream) return false;
|
||||
const audioTracks = this.localStream.getAudioTracks();
|
||||
return audioTracks.some((track) => !track.enabled);
|
||||
}
|
||||
|
||||
getRemoteStream(): MediaStream | null {
|
||||
return this.remoteStream;
|
||||
}
|
||||
|
||||
getLocalStream(): MediaStream | null {
|
||||
return this.localStream;
|
||||
}
|
||||
|
||||
getPeerConnection(): RTCPeerConnection | null {
|
||||
return this.peerConnection;
|
||||
}
|
||||
|
||||
onEvent(handler: EventHandler): () => void {
|
||||
this.eventHandlers.add(handler);
|
||||
return () => {
|
||||
this.eventHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
private emit(event: WebRTCManagerEvent): void {
|
||||
this.eventHandlers.forEach((handler) => {
|
||||
try {
|
||||
handler(event);
|
||||
} catch (error) {
|
||||
console.error('[WebRTC] Event handler error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.eventHandlers.clear();
|
||||
this.pendingCandidates = [];
|
||||
|
||||
if (this.localStream) {
|
||||
this.localStream.getTracks().forEach((track) => track.stop());
|
||||
this.localStream.release();
|
||||
this.localStream = null;
|
||||
}
|
||||
|
||||
if (this.remoteStream) {
|
||||
this.remoteStream.getTracks().forEach((track) => track.stop());
|
||||
this.remoteStream.release();
|
||||
this.remoteStream = null;
|
||||
}
|
||||
|
||||
if (this.peerConnection) {
|
||||
this.peerConnection.close();
|
||||
this.peerConnection = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const webrtcManager = new WebRTCManager();
|
||||
2
src/services/webrtc/index.ts
Normal file
2
src/services/webrtc/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { webrtcManager } from './WebRTCManager';
|
||||
export type { ICEServer, ConnectionState, WebRTCManagerEvent } from './WebRTCManager';
|
||||
@@ -30,7 +30,106 @@ export type WSMessageType =
|
||||
| 'pong'
|
||||
| 'message_sent'
|
||||
| 'message_recalled'
|
||||
| 'error';
|
||||
| 'error'
|
||||
| 'call_incoming'
|
||||
| 'call_accepted'
|
||||
| 'call_rejected'
|
||||
| 'call_busy'
|
||||
| 'call_sdp'
|
||||
| 'call_ice'
|
||||
| 'call_ended'
|
||||
| 'call_peer_muted'
|
||||
| 'call_invited'
|
||||
| 'call_answered_elsewhere';
|
||||
|
||||
export interface WSCallIncomingMessage {
|
||||
type: 'call_incoming';
|
||||
call_id: string;
|
||||
conversation_id: string;
|
||||
caller_id: string;
|
||||
call_type: string;
|
||||
created_at: number;
|
||||
lifetime?: number;
|
||||
ice_servers?: ICEServerConfig[];
|
||||
}
|
||||
|
||||
export interface ICEServerConfig {
|
||||
urls: string[];
|
||||
username?: string;
|
||||
credential?: string;
|
||||
}
|
||||
|
||||
export interface WSCallAcceptedMessage {
|
||||
type: 'call_accepted';
|
||||
call_id: string;
|
||||
started_at: number;
|
||||
ice_servers?: ICEServerConfig[];
|
||||
}
|
||||
|
||||
export interface WSCallRejectedMessage {
|
||||
type: 'call_rejected';
|
||||
call_id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface WSCallBusyMessage {
|
||||
type: 'call_busy';
|
||||
call_id: string;
|
||||
}
|
||||
|
||||
export interface WSCallEndedMessage {
|
||||
type: 'call_ended';
|
||||
call_id: string;
|
||||
ended_by?: string;
|
||||
reason?: string;
|
||||
duration?: number;
|
||||
ended_at?: number;
|
||||
}
|
||||
|
||||
export interface WSCallSDPMessage {
|
||||
type: 'call_sdp';
|
||||
call_id: string;
|
||||
from_id: string;
|
||||
payload: {
|
||||
sdp_type: string;
|
||||
sdp: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WSCallICEMessage {
|
||||
type: 'call_ice';
|
||||
call_id: string;
|
||||
from_id: string;
|
||||
payload: {
|
||||
candidate: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WSCallPeerMutedMessage {
|
||||
type: 'call_peer_muted';
|
||||
call_id: string;
|
||||
user_id: string;
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
export interface WSCallInvitedMessage {
|
||||
type: 'call_invited';
|
||||
call_id: string;
|
||||
conversation_id: string;
|
||||
callee_id: string;
|
||||
}
|
||||
|
||||
export interface WSCallAnsweredElsewhereMessage {
|
||||
type: 'call_answered_elsewhere';
|
||||
call_id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface WSErrorMessage {
|
||||
type: 'error';
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface WSChatMessage {
|
||||
type: 'chat';
|
||||
@@ -166,7 +265,18 @@ export type WSMessage =
|
||||
| WSGroupNoticeMessage
|
||||
| WSGroupMentionMessage
|
||||
| WSGroupReadMessage
|
||||
| WSGroupRecallMessage;
|
||||
| WSGroupRecallMessage
|
||||
| WSCallIncomingMessage
|
||||
| WSCallAcceptedMessage
|
||||
| WSCallRejectedMessage
|
||||
| WSCallBusyMessage
|
||||
| WSCallEndedMessage
|
||||
| WSCallSDPMessage
|
||||
| WSCallICEMessage
|
||||
| WSCallPeerMutedMessage
|
||||
| WSCallInvitedMessage
|
||||
| WSCallAnsweredElsewhereMessage
|
||||
| WSErrorMessage;
|
||||
|
||||
type MessageHandler<T extends WSMessage = WSMessage> = (message: T) => void;
|
||||
type ConnectionHandler = () => void;
|
||||
@@ -273,17 +383,35 @@ class WebSocketService {
|
||||
}
|
||||
|
||||
private handleIncoming(data: string): void {
|
||||
try {
|
||||
const msg: WSServerMessage = JSON.parse(data);
|
||||
|
||||
// 后端 writePump 会将多条消息以 \n 分隔合并发送,需要逐条解析
|
||||
const lines = data.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const msg: WSServerMessage = JSON.parse(trimmed);
|
||||
this.processMessage(msg);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error, 'raw:', trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private processMessage(msg: WSServerMessage): void {
|
||||
// 处理pong响应
|
||||
if (msg.type === 'pong') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理错误消息
|
||||
// 处理错误消息 - emit 到上层处理
|
||||
if (msg.type === 'error') {
|
||||
console.error('WebSocket error:', msg.payload);
|
||||
const err: WSErrorMessage = {
|
||||
type: 'error',
|
||||
code: msg.payload?.code || 'unknown',
|
||||
message: msg.payload?.message || 'Unknown error',
|
||||
};
|
||||
this.emit('error', err);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -306,9 +434,6 @@ class WebSocketService {
|
||||
|
||||
// 分发事件
|
||||
this.dispatchServerMessage(msg);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchServerMessage(msg: WSServerMessage): void {
|
||||
@@ -333,6 +458,37 @@ class WebSocketService {
|
||||
case 'system_notification':
|
||||
this.handleSystemNotification(payload);
|
||||
break;
|
||||
// Call signaling messages
|
||||
case 'call_incoming':
|
||||
this.handleCallIncoming(payload);
|
||||
break;
|
||||
case 'call_accepted':
|
||||
this.handleCallAccepted(payload);
|
||||
break;
|
||||
case 'call_rejected':
|
||||
this.handleCallRejected(payload);
|
||||
break;
|
||||
case 'call_busy':
|
||||
this.handleCallBusy(payload);
|
||||
break;
|
||||
case 'call_sdp':
|
||||
this.handleCallSDP(payload);
|
||||
break;
|
||||
case 'call_ice':
|
||||
this.handleCallICE(payload);
|
||||
break;
|
||||
case 'call_ended':
|
||||
this.handleCallEnded(payload);
|
||||
break;
|
||||
case 'call_peer_muted':
|
||||
this.handleCallPeerMuted(payload);
|
||||
break;
|
||||
case 'call_invited':
|
||||
this.handleCallInvited(payload);
|
||||
break;
|
||||
case 'call_answered_elsewhere':
|
||||
this.handleCallAnsweredElsewhere(payload);
|
||||
break;
|
||||
default:
|
||||
console.log('Unknown message type:', type);
|
||||
}
|
||||
@@ -458,6 +614,152 @@ class WebSocketService {
|
||||
systemNotificationService.handleWSMessage(m as any).catch(() => {});
|
||||
}
|
||||
|
||||
// Call signaling handlers
|
||||
private handleCallIncoming(payload: any): void {
|
||||
const m: WSCallIncomingMessage = {
|
||||
type: 'call_incoming',
|
||||
call_id: payload.call_id,
|
||||
conversation_id: payload.conversation_id,
|
||||
caller_id: payload.caller_id,
|
||||
call_type: payload.call_type,
|
||||
created_at: payload.created_at,
|
||||
lifetime: payload.lifetime,
|
||||
ice_servers: payload.ice_servers,
|
||||
};
|
||||
this.emit('call_incoming', m);
|
||||
}
|
||||
|
||||
private handleCallAccepted(payload: any): void {
|
||||
const m: WSCallAcceptedMessage = {
|
||||
type: 'call_accepted',
|
||||
call_id: payload.call_id,
|
||||
started_at: payload.started_at,
|
||||
ice_servers: payload.ice_servers,
|
||||
};
|
||||
this.emit('call_accepted', m);
|
||||
}
|
||||
|
||||
private handleCallRejected(payload: any): void {
|
||||
const m: WSCallRejectedMessage = {
|
||||
type: 'call_rejected',
|
||||
call_id: payload.call_id,
|
||||
reason: payload.reason,
|
||||
};
|
||||
this.emit('call_rejected', m);
|
||||
}
|
||||
|
||||
private handleCallBusy(payload: any): void {
|
||||
const m: WSCallBusyMessage = {
|
||||
type: 'call_busy',
|
||||
call_id: payload.call_id,
|
||||
};
|
||||
this.emit('call_busy', m);
|
||||
}
|
||||
|
||||
private handleCallSDP(payload: any): void {
|
||||
const m: WSCallSDPMessage = {
|
||||
type: 'call_sdp',
|
||||
call_id: payload.call_id,
|
||||
from_id: payload.from_id,
|
||||
payload: payload.payload,
|
||||
};
|
||||
this.emit('call_sdp', m);
|
||||
}
|
||||
|
||||
private handleCallICE(payload: any): void {
|
||||
const m: WSCallICEMessage = {
|
||||
type: 'call_ice',
|
||||
call_id: payload.call_id,
|
||||
from_id: payload.from_id,
|
||||
payload: payload.payload,
|
||||
};
|
||||
this.emit('call_ice', m);
|
||||
}
|
||||
|
||||
private handleCallEnded(payload: any): void {
|
||||
const m: WSCallEndedMessage = {
|
||||
type: 'call_ended',
|
||||
call_id: payload.call_id,
|
||||
ended_by: payload.ended_by,
|
||||
reason: payload.reason,
|
||||
duration: payload.duration,
|
||||
ended_at: payload.ended_at,
|
||||
};
|
||||
this.emit('call_ended', m);
|
||||
}
|
||||
|
||||
private handleCallPeerMuted(payload: any): void {
|
||||
const m: WSCallPeerMutedMessage = {
|
||||
type: 'call_peer_muted',
|
||||
call_id: payload.call_id,
|
||||
user_id: payload.user_id,
|
||||
muted: payload.muted,
|
||||
};
|
||||
this.emit('call_peer_muted', m);
|
||||
}
|
||||
|
||||
private handleCallInvited(payload: any): void {
|
||||
const m: WSCallInvitedMessage = {
|
||||
type: 'call_invited',
|
||||
call_id: payload.call_id,
|
||||
conversation_id: payload.conversation_id,
|
||||
callee_id: payload.callee_id,
|
||||
};
|
||||
this.emit('call_invited', m);
|
||||
}
|
||||
|
||||
private handleCallAnsweredElsewhere(payload: any): void {
|
||||
const m: WSCallAnsweredElsewhereMessage = {
|
||||
type: 'call_answered_elsewhere',
|
||||
call_id: payload.call_id,
|
||||
reason: payload.reason,
|
||||
};
|
||||
this.emit('call_answered_elsewhere', m);
|
||||
}
|
||||
|
||||
// Call signaling send methods
|
||||
sendCallInvite(conversationId: string, calleeId: string): void {
|
||||
this.sendFireAndForget('call_invite', {
|
||||
conversation_id: conversationId,
|
||||
callee_id: calleeId,
|
||||
});
|
||||
}
|
||||
|
||||
sendCallAnswer(callId: string): void {
|
||||
this.sendFireAndForget('call_answer', { call_id: callId });
|
||||
}
|
||||
|
||||
sendCallReject(callId: string): void {
|
||||
this.sendFireAndForget('call_reject', { call_id: callId });
|
||||
}
|
||||
|
||||
sendCallBusy(callId: string): void {
|
||||
this.sendFireAndForget('call_busy', { call_id: callId });
|
||||
}
|
||||
|
||||
sendCallSDP(callId: string, sdpType: string, sdp: string): void {
|
||||
this.sendFireAndForget('call_sdp', {
|
||||
call_id: callId,
|
||||
sdp_type: sdpType,
|
||||
sdp,
|
||||
});
|
||||
}
|
||||
|
||||
sendCallICE(callId: string, candidate: string): void {
|
||||
this.sendFireAndForget('call_ice', {
|
||||
call_id: callId,
|
||||
candidate,
|
||||
});
|
||||
}
|
||||
|
||||
sendCallEnd(callId: string, reason?: string): void {
|
||||
this.sendFireAndForget('call_end', { call_id: callId, reason });
|
||||
}
|
||||
|
||||
sendCallMute(callId: string, muted: boolean): void {
|
||||
this.sendFireAndForget('call_mute', { call_id: callId, muted });
|
||||
}
|
||||
|
||||
private handleMessageSent(payload: any): void {
|
||||
// 找到对应的发送请求并resolve
|
||||
const pendingMsg = this.pendingMessages.find(p =>
|
||||
@@ -540,6 +842,15 @@ class WebSocketService {
|
||||
return api.post('/messages/delete_msg', { message_id: messageId });
|
||||
}
|
||||
|
||||
// 通过WebSocket发送(fire-and-forget,不等待确认)
|
||||
private sendFireAndForget(type: string, payload: any): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
const message = { type, payload };
|
||||
this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
// 通过WebSocket发送
|
||||
private sendViaWebSocket(type: string, payload: any): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
Reference in New Issue
Block a user