Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/spicy-vans-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@rocket.chat/ui-voip': minor
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---

Improves handling of errors during voice calls
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -5995,7 +5995,9 @@
"unable-to-get-file": "Unable to get file",
"Unable_to_load_active_connections": "Unable to load active connections",
"Unable_to_complete_call": "Unable to complete call",
"Unable_to_complete_call__code": "Unable to complete call. Error code [{{statusCode}}]",
"Unable_to_make_calls_while_another_is_ongoing": "Unable to make calls while another call is ongoing",
"Unable_to_negotiate_call_params": "Unable to negotiate call params.",
"Unassigned": "Unassigned",
"Unassign_extension": "Unassign extension",
"unauthorized": "Not authorized",
Expand Down Expand Up @@ -6782,6 +6784,8 @@
"Sidebar_Sections_Order": "Sidebar sections order",
"Sidebar_Sections_Order_Description": "Select the categories in your preferred order",
"Incoming_Calls": "Incoming calls",
"Incoming_voice_call_canceled_suddenly": "An Incoming Voice Call was canceled suddenly.",
"Incoming_voice_call_canceled_user_not_registered": "An Incoming Voice Call was canceled due to an unexpected error.",
"Advanced_settings": "Advanced settings",
"Security_and_permissions": "Security and permissions",
"Security_and_privacy": "Security and privacy",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ it('should properly render unknown error calls', async () => {
const session = createMockVoipErrorSession({ error: { status: -1, reason: '' } });
render(<VoipErrorView session={session} />, { wrapper: appRoot.build() });

expect(screen.getByText('Unable_to_complete_call')).toBeInTheDocument();
expect(screen.getByText('Unable_to_complete_call__code')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'End_call' }));
expect(session.end).toHaveBeenCalled();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,16 @@ const VoipErrorView = ({ session, position }: VoipErrorViewProps) => {

const title = useMemo(() => {
Comment thread
pierre-lehnen-rc marked this conversation as resolved.
switch (status) {
case 488:
return t('Unable_to_negotiate_call_params');
case 487:
return t('Call_terminated');
case 486:
return t('Caller_is_busy');
case 480:
return t('Temporarily_unavailable');
default:
return t('Unable_to_complete_call');
return t('Unable_to_complete_call__code', { statusCode: status });
Comment thread
pierre-lehnen-rc marked this conversation as resolved.
}
}, [status, t]);

Expand Down
51 changes: 50 additions & 1 deletion packages/ui-voip/src/lib/VoipClient.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { SignalingSocketEvents, VoipEvents as CoreVoipEvents, VoIPUserConfiguration } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import type { InvitationAcceptOptions, Message, Referral, Session, SessionInviteOptions } from 'sip.js';
import type { InvitationAcceptOptions, Message, Referral, Session, SessionInviteOptions, Cancel as SipCancel } from 'sip.js';
import { Registerer, RequestPendingError, SessionState, UserAgent, Invitation, Inviter, RegistererState, UserAgentState } from 'sip.js';
import type { IncomingResponse, OutgoingByeRequest, URI } from 'sip.js/lib/core';
import type { SessionDescriptionHandlerOptions } from 'sip.js/lib/platform/web';
Expand All @@ -15,6 +15,7 @@ export type VoipEvents = Omit<CoreVoipEvents, 'ringing' | 'callestablished' | 'i
incomingcall: ContactInfo;
outgoingcall: ContactInfo;
dialer: { open: boolean };
incomingcallerror: string;
};

type SessionError = {
Expand Down Expand Up @@ -770,6 +771,7 @@ class VoipClient extends Emitter<VoipEvents> {
}

private setError(error: SessionError | null) {
console.error(error);
this.error = error;
this.emit('stateChanged');
}
Expand Down Expand Up @@ -843,12 +845,59 @@ class VoipClient extends Emitter<VoipEvents> {
this.emit('unregistrationerror', error);
};

private onInvitationCancel(invitation: Invitation, message: SipCancel): void {
Comment thread
pierre-lehnen-rc marked this conversation as resolved.
try {
const reasons = message?.request?.headers?.Reason;

const parsedReasons: string[] = reasons?.map(({ raw }) => raw.match(/text="(.+)"/)?.[1] || raw).filter((r) => r);
if (!parsedReasons?.length) {
throw new Error('error-missing-reason');
}

const naturalEndings = [
'ORIGINATOR_CANCEL',
'NO_ANSWER',
'NORMAL_CLEARING',
'USER_BUSY',
'NO_USER_RESPONSE',
'NORMAL_UNSPECIFIED',
] as const;

for (const ending of naturalEndings) {
if (parsedReasons.includes(ending)) {
// Do not emit any errors for normal endings
return;
}
}

if (parsedReasons.includes('USER_NOT_REGISTERED')) {
// This one means an error happened
this.emit('incomingcallerror', 'USER_NOT_REGISTERED');
return;
}

if (invitation.state === SessionState.Initial) {
// Call was canceled at the initial state and it was not due to one of the natural reasons, treat it as unexpected
this.emit('incomingcallerror', parsedReasons[0]);
Comment thread
kody-ai[bot] marked this conversation as resolved.
Outdated
return;
}

console.warn('The call was canceled for an unexpected reason', parsedReasons);
} catch {
console.error('Failed to determine the cause of an invitation cancel.');
}
Comment thread
kody-ai[bot] marked this conversation as resolved.
Outdated
}

private onIncomingCall = async (invitation: Invitation): Promise<void> => {
if (!this.isRegistered() || this.session) {
await invitation.reject();
return;
}

invitation.delegate = {
onCancel: (cancel: SipCancel) => this.onInvitationCancel(invitation, cancel),
};
Comment thread
kody-ai[bot] marked this conversation as resolved.

this.initSession(invitation);

this.emit('incomingcall', this.getContactInfo() as ContactInfo);
Expand Down
12 changes: 12 additions & 0 deletions packages/ui-voip/src/providers/VoipProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,16 @@ const VoipProvider = ({ children }: { children: ReactNode }) => {
dispatchToastMessage({ type: 'error', message: t('Voice_calling_registration_failed') });
};

const onIncomingCallError = (reason: string) => {
console.error('incoming call canceled', reason);
if (reason === 'USER_NOT_REGISTERED') {
dispatchToastMessage({ type: 'error', message: t('Incoming_voice_call_canceled_user_not_registered') });
return;
}

dispatchToastMessage({ type: 'error', message: t('Incoming_voice_call_canceled_suddenly') });
};

const onRegistered = () => {
setStorageRegistered(true);
};
Expand All @@ -104,6 +114,7 @@ const VoipProvider = ({ children }: { children: ReactNode }) => {
voipClient.on('registrationerror', onRegistrationError);
voipClient.on('registered', onRegistered);
voipClient.on('unregistered', onUnregister);
voipClient.on('incomingcallerror', onIncomingCallError);
voipClient.networkEmitter.on('disconnected', onNetworkDisconnected);
voipClient.networkEmitter.on('connectionerror', onNetworkDisconnected);
voipClient.networkEmitter.on('localnetworkoffline', onNetworkDisconnected);
Expand All @@ -116,6 +127,7 @@ const VoipProvider = ({ children }: { children: ReactNode }) => {
voipClient.off('registrationerror', onRegistrationError);
voipClient.off('registered', onRegistered);
voipClient.off('unregistered', onUnregister);
voipClient.off('incomingcallerror', onIncomingCallError);
Comment thread
pierre-lehnen-rc marked this conversation as resolved.
voipClient.networkEmitter.off('disconnected', onNetworkDisconnected);
voipClient.networkEmitter.off('connectionerror', onNetworkDisconnected);
voipClient.networkEmitter.off('localnetworkoffline', onNetworkDisconnected);
Expand Down