feat(CallFeature): implement call functionality and integrate WebSocket signaling
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 5m2s
Frontend CI / ota-android (push) Successful in 11m28s
Frontend CI / build-android-apk (push) Successful in 1h0m58s

- 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:
lafay
2026-03-27 17:12:19 +08:00
parent db7885086f
commit b19a2ced6f
18 changed files with 2086 additions and 18 deletions

View File

@@ -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) => {