Merge pull request #42 from jambonz/fix/noise_issolation

add noise isolation configuration and fix layout issue
This commit is contained in:
Dave Horton
2026-04-03 21:04:58 -04:00
committed by GitHub
23 changed files with 991 additions and 1706 deletions
+31 -24
View File
@@ -14,12 +14,12 @@
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@jambonz/client-sdk-web": "^0.1.4",
"buffer": "^6.0.3",
"dayjs": "^1.11.10",
"framer-motion": "^10.16.4",
"fuse.js": "^6.6.2",
"google-libphonenumber": "^3.2.33",
"jssip": "^3.10.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"uuid": "^9.0.1",
@@ -4038,6 +4038,32 @@
"node": ">=8"
}
},
"node_modules/@jambonz/client-sdk-core": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@jambonz/client-sdk-core/-/client-sdk-core-0.1.4.tgz",
"integrity": "sha512-D8IomTUJMboCGjqgFrun4OSZJffFP1Z4B6ggXe8HemcZTnownTJSaZpMyTzQyf9AL1C7vd+jb3LHXe1Sb4qtxw==",
"license": "MIT",
"dependencies": {
"jssip": "^3.10.1"
},
"peerDependencies": {
"react": ">=17.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
}
},
"node_modules/@jambonz/client-sdk-web": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@jambonz/client-sdk-web/-/client-sdk-web-0.1.4.tgz",
"integrity": "sha512-vKM/WcXr1VCNg/RFiF+idMgYAmmntzf7c7xoR/q3ANdhB2NQxZvitbmUBFbgevblX+hIPwUeWCXRr9Ncw6zwnA==",
"license": "MIT",
"dependencies": {
"@jambonz/client-sdk-core": "0.1.4"
}
},
"node_modules/@jest/console": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz",
@@ -5638,14 +5664,6 @@
"@types/node": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.8.tgz",
"integrity": "sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==",
"dependencies": {
"@types/ms": "*"
}
},
"node_modules/@types/eslint": {
"version": "8.44.2",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.2.tgz",
@@ -5672,11 +5690,6 @@
"integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==",
"dev": true
},
"node_modules/@types/events": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz",
"integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g=="
},
"node_modules/@types/express": {
"version": "4.17.17",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz",
@@ -5823,11 +5836,6 @@
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
"dev": true
},
"node_modules/@types/ms": {
"version": "0.7.31",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz",
"integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA=="
},
"node_modules/@types/node": {
"version": "16.18.53",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.53.tgz",
@@ -14938,12 +14946,11 @@
}
},
"node_modules/jssip": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/jssip/-/jssip-3.10.0.tgz",
"integrity": "sha512-iJj+bhnNl0S296sUDc2ZjIbAetnelzZ92aWARyW01oKZ0X8t+5aGrYfJdMFliLFm8hxMcnkep3vmSRGe/yRjsA==",
"version": "3.13.6",
"resolved": "https://registry.npmjs.org/jssip/-/jssip-3.13.6.tgz",
"integrity": "sha512-Bf1ndrSuqpO87/AG56WACR7kKcCvKOzaIQROu7JUMh0qFaGOV4NuR+wsnaXa7f3/d6xhwVczczFyt1ywJmTjPg==",
"license": "MIT",
"dependencies": {
"@types/debug": "^4.1.7",
"@types/events": "^3.0.0",
"debug": "^4.3.1",
"events": "^3.3.0",
"sdp-transform": "^2.14.1"
+1 -1
View File
@@ -9,12 +9,12 @@
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@jambonz/client-sdk-web": "^0.1.4",
"buffer": "^6.0.3",
"dayjs": "^1.11.10",
"framer-motion": "^10.16.4",
"fuse.js": "^6.6.2",
"google-libphonenumber": "^3.2.33",
"jssip": "^3.10.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"uuid": "^9.0.1",
Binary file not shown.
Binary file not shown.
+7 -9
View File
@@ -1,4 +1,5 @@
import { ConferenceModes } from "src/api/types";
import type { ClientState } from "@jambonz/client-sdk-web";
export interface LoginCredential {
name?: string;
@@ -61,6 +62,10 @@ export interface AppSettings {
accountSid?: string;
apiKey?: string;
apiServer?: string;
noiseIsolationVendor?: string;
noiseIsolationLevel?: number;
noiseIsolationModel?: string;
}
export interface IAppSettings {
@@ -97,12 +102,5 @@ export interface CallHistory {
isSaved?: boolean;
}
export type SipClientStatus =
| "start"
| "stop"
| "connecting"
| "connected"
| "disconnected"
| "registered"
| "unregistered";
export type SipCallDirection = "" | "outgoing" | "incoming";
export type SipClientStatus = ClientState;
export type SipCallDirection = "" | "outbound" | "inbound";
+8 -44
View File
@@ -2,8 +2,6 @@ export default class SipAudioElements {
#ringing: HTMLAudioElement;
#ringBack: HTMLAudioElement;
#failed: HTMLAudioElement;
#busy: HTMLAudioElement;
#remote: HTMLAudioElement;
#hungup: HTMLAudioElement;
#localHungup: HTMLAudioElement;
@@ -16,13 +14,10 @@ export default class SipAudioElements {
this.#ringBack.volume = 0.8;
this.#failed = this.getAudio("audios/call-failed.mp3");
this.#failed.volume = 0.3;
this.#busy = this.getAudio("audios/us-busy-signal.mp3");
this.#busy.volume = 0.3;
this.#hungup = this.getAudio("audios/remote-party-hungup-tone.mp3");
this.#hungup.volume = 0.3;
this.#localHungup = this.getAudio("audios/local-party-hungup-tone.mp3");
this.#localHungup.volume = 0.3;
this.#remote = new Audio();
}
private getAudio(path: string) {
@@ -43,19 +38,13 @@ export default class SipAudioElements {
return new Audio(audioURL);
}
playLocalHungup(volume: number | undefined) {
playLocalHungup() {
this.pauseRingback();
this.pauseRinging();
if (volume) {
this.#localHungup.volume = volume;
}
this.#localHungup.play();
}
playRinging(volume: number | undefined): void {
if (volume) {
this.#ringing.volume = volume;
}
playRinging(): void {
this.#ringing.play();
}
@@ -65,10 +54,7 @@ export default class SipAudioElements {
}
}
playRingback(volume: number | undefined): void {
if (volume) {
this.#ringBack.volume = volume;
}
playRingback(): void {
this.#ringBack.play();
}
@@ -78,47 +64,25 @@ export default class SipAudioElements {
}
}
playFailed(volume: number | undefined): void {
playFailed(): void {
this.pauseRinging();
this.pauseRingback();
if (volume) {
this.#failed.volume = volume;
}
this.#failed.play();
}
playRemotePartyHungup(volume: number | undefined): void {
if (volume) {
this.#hungup.volume = volume;
}
playRemotePartyHungup(): void {
this.#hungup.play();
}
playAnswer(volume: number | undefined): void {
playAnswer(): void {
this.pauseRinging();
this.pauseRingback();
}
isRemoteAudioPaused(): boolean {
return this.#remote.paused;
}
playRemote(stream: MediaStream) {
this.#remote.srcObject = stream;
this.#remote.play();
}
stopAll() {
this.pauseRinging();
this.pauseRingback();
}
isPLaying(audio: HTMLAudioElement) {
return (
audio.currentTime > 0 &&
!audio.paused &&
!audio.ended &&
audio.readyState > audio.HAVE_CURRENT_DATA
);
this.#ringing.currentTime = 0;
this.#ringBack.currentTime = 0;
}
}
-311
View File
@@ -1,311 +0,0 @@
import {
EndEvent,
HoldEvent,
IceCandidateEvent,
PeerConnectionEvent,
ReferEvent,
RTCPeerConnectionDeprecated,
RTCSession,
} from "jssip/lib/RTCSession";
import { SipConstants, SipAudioElements, randomId } from "./index";
import { DTMF_TRANSPORT } from "jssip/lib/Constants";
import { IncomingResponse } from "jssip/lib/SIPMessage";
import * as events from "events";
import { C, Grammar } from "jssip";
export default class SipSession extends events.EventEmitter {
#id: string;
#rtcOptions: any;
#audio: SipAudioElements;
#rtcSession: RTCSession;
#active: boolean;
constructor(
rtcSession: RTCSession,
rtcConfig: RTCConfiguration,
audio: SipAudioElements
) {
super();
this.setMaxListeners(Infinity);
this.#id = randomId("");
this.#rtcOptions = {
mediaConstraints: { audio: true, video: false },
pcConfig: rtcConfig,
};
this.#rtcSession = rtcSession;
this.#audio = audio;
this.#active = false;
this.addListeners();
}
addListeners(): void {
if (this.#rtcSession.connection) {
this.addPeerConnectionListener(this.#rtcSession.connection);
} else {
this.#rtcSession.on(
"peerconnection",
(data: PeerConnectionEvent): void => {
let pc: RTCPeerConnectionDeprecated = data.peerconnection;
this.addPeerConnectionListener(pc);
}
);
}
this.#rtcSession.on("progress", (): void => {
this.emit(SipConstants.SESSION_RINGING, {
status: SipConstants.SESSION_RINGING,
});
if (this.#audio.isRemoteAudioPaused() && !this.replaces) {
this.#audio.playRinging(undefined);
} else {
this.#audio.playRingback(undefined);
}
});
this.#rtcSession.on(
"accepted",
({ response }: { response: IncomingResponse }) => {
this.emit(SipConstants.SESSION_ANSWERED, {
status: SipConstants.SESSION_ANSWERED,
callSid: response?.hasHeader("X-Call-Sid")
? response.getHeader("X-Call-Sid")
: null,
});
this.#audio.playAnswer(undefined);
}
);
this.#rtcSession.on("failed", (data: EndEvent): void => {
let { originator, cause, message } = data;
let description;
if (
message &&
originator === "remote" &&
message instanceof IncomingResponse &&
message.status_code
) {
description = `${message.status_code}`.trim();
}
if (originator === "local" && cause === C.causes.CANCELED) {
description = "Cancelled by user";
}
if (originator === "local" && cause === C.causes.REJECTED) {
description = "Rejected by user";
}
this.emit(SipConstants.SESSION_FAILED, {
cause: cause,
status: SipConstants.SESSION_FAILED,
originator: originator,
description: description,
});
if (originator === "remote") {
this.#audio.playFailed(undefined);
} else {
this.#audio.pauseRinging();
this.#audio.pauseRingback();
}
});
this.#rtcSession.on("ended", (data: EndEvent): void => {
const { originator, cause, message } = data;
let description;
if (originator === "remote") {
this.#audio.playRemotePartyHungup(undefined);
} else {
this.#audio.playLocalHungup(undefined);
}
if (message && originator === "remote" && message.hasHeader("Reason")) {
const reason = Grammar.parse(message.getHeader("Reason"), "Reason");
if (reason) {
description = `${reason.cause}`.trim();
}
}
this.emit(SipConstants.SESSION_ENDED, {
cause: cause,
status: SipConstants.SESSION_ENDED,
originator: originator,
description: description,
});
});
this.#rtcSession.on("muted", (): void => {
this.emit(SipConstants.SESSION_MUTED, {
status: "muted",
});
});
this.#rtcSession.on("unmuted", (): void => {
this.emit(SipConstants.SESSION_MUTED, {
status: "unmuted",
});
});
this.#rtcSession.on("hold", (data: HoldEvent): void => {
this.emit(SipConstants.SESSION_HOLD, {
status: "hold",
originator: data.originator,
});
});
this.#rtcSession.on("unhold", (data: HoldEvent): void => {
this.emit(SipConstants.SESSION_HOLD, {
status: "unhold",
originator: data.originator,
});
});
this.#rtcSession.on("refer", (data: ReferEvent): void => {
let { accept } = data;
accept((rtcSession: RTCSession): void => {
rtcSession.data.replaces = true;
this.emit(SipConstants.SESSION_REFER, {
session: rtcSession,
type: "refer",
});
}, this.#rtcOptions);
});
this.#rtcSession.on("replaces", (data: ReferEvent): void => {
data.accept((rtcSession: RTCSession): void => {
rtcSession.data.replaces = true;
if (!rtcSession.isEstablished()) {
rtcSession.answer(this.#rtcOptions);
this.emit(SipConstants.SESSION_REPLACES, {
session: rtcSession,
type: "replaces",
});
}
});
});
this.#rtcSession.on("icecandidate", (evt: IceCandidateEvent): void => {
let type: string[] = evt.candidate.candidate.split(" ");
let candidate: string = type[7];
if (["srflx", "relay"].indexOf(candidate) > -1) {
evt.ready();
this.emit(SipConstants.SESSION_ICE_READY, {
candidate: candidate,
status: "ready",
});
}
});
}
addPeerConnectionListener(pc: RTCPeerConnection): void {
pc.addEventListener("addstream", (event: any): void => {
if (this.#rtcSession.direction === "outgoing") {
this.#audio.pauseRinging();
}
this.#audio.playRemote(event.stream);
this.emit(SipConstants.SESSION_ADD_STREAM, {
direction: this.#rtcSession.direction,
});
});
// pc.addEventListener(
// "track",
// (event: RTCPeerConnectionEventMap["track"]): void => {
// const stream: MediaStream = new MediaStream([event.track]);
// if (this.#rtcSession.direction === "outgoing") {
// this.#audio.pauseRinging();
// }
// this.#audio.playRemote(stream);
// this.emit(SipConstants.SESSION_TRACK, {
// direction: this.#rtcSession.direction,
// });
// }
// );
}
get rtcSession() {
return this.#rtcSession;
}
get direction() {
return this.#rtcSession.direction;
}
get id() {
return this.#id;
}
get user() {
return this.#rtcSession.remote_identity.uri.user;
}
get active() {
return this.#active;
}
get answerTime(): Date {
return this.#rtcSession.start_time;
}
get duration() {
if (!this.answerTime) {
return 0;
}
let now: number = new Date().getUTCMilliseconds();
return Math.floor((now - this.answerTime.getUTCMilliseconds()) / 1000);
}
get replaces() {
return Boolean(this.#rtcSession.data.replaces);
}
setActive(flag: boolean): void {
let wasActive: boolean = this.#active;
this.#active = flag;
if (this.#rtcSession.isEstablished()) {
if (this.replaces) {
return;
}
if (this.#active) {
this.unhold();
} else {
this.hold();
}
}
if (this.#active && !wasActive) {
this.emit(SipConstants.SESSION_ACTIVE);
}
}
answer() {
this.#rtcSession.answer(this.#rtcOptions);
}
terminate(sipCode: number, sipReason: string): void {
this.#rtcSession.terminate({
status_code: sipCode,
reason_phrase: sipReason,
});
}
isMuted(): boolean {
return this.#rtcSession.isMuted().audio?.valueOf() || false;
}
mute(): void {
this.#rtcSession.mute({ audio: true, video: true });
}
unmute(): void {
this.#rtcSession.unmute({ audio: true, video: true });
}
isHolded(): boolean {
return this.#rtcSession.isOnHold().local.valueOf() || false;
}
hold(): void {
this.#rtcSession.hold();
}
unhold(): void {
this.#rtcSession.unhold();
}
sendDtmf(tone: number | string): void {
this.#rtcSession.sendDTMF(tone, { transportType: DTMF_TRANSPORT.RFC2833 });
}
}
-100
View File
@@ -1,100 +0,0 @@
import { SipSession, SipModel, SipConstants } from "./index";
export default class SipSessionManager {
#sessions: Map<string, SipModel.SipSessionState>;
constructor() {
this.#sessions = new Map();
}
activate(session: SipSession) {
this.#sessions.forEach((v, k) => {
if (k !== session.id) {
v.active = false;
session.setActive(false);
} else {
v.active = true;
session.setActive(true);
}
});
}
updateSession(field: string, session: SipSession, args: any): void {
const state: SipModel.SipSessionState = this.getSessionState(session.id);
if (state) {
switch (field) {
case SipConstants.SESSION_RINGING:
state.status = args.status;
break;
case SipConstants.SESSION_ANSWERED:
state.status = args.status;
break;
case SipConstants.SESSION_FAILED:
case SipConstants.SESSION_ENDED:
state.status = args.status;
state.endState = {
cause: args.cause,
status: args.status,
originator: args.endState,
description: args.description,
};
this.#sessions.delete(session.id);
break;
case SipConstants.SESSION_MUTED:
state.muteStatus = args.status;
break;
case SipConstants.SESSION_HOLD:
state.holdState = {
originator: args.originator,
status: args.status,
};
break;
case SipConstants.SESSION_ICE_READY:
state.iceReady = true;
break;
case SipConstants.SESSION_ACTIVE:
state.active = true;
break;
}
}
}
getSessionState(id: string): SipModel.SipSessionState {
const state = this.#sessions.get(id);
if (!state) {
throw new Error("Session not found");
}
return state;
}
getSession(id: string): SipSession {
return this.getSessionState(id).sipSession;
}
newSession(session: SipSession): void {
this.#sessions.set(session.id, {
id: session.id,
sipSession: session,
startDateTime: new Date(),
active: true,
status: "init",
});
}
get activeSession(): SipSession {
if (this.#sessions.size === 0) {
throw new Error("No sessions");
}
const state = [...this.#sessions.values()].filter((s) => s.active);
if (state.length) {
return state[0].sipSession;
} else {
throw new Error("No Active sessions");
}
}
get count() {
return this.#sessions.size;
}
}
-219
View File
@@ -1,219 +0,0 @@
import * as events from "events";
import { UA, WebSocketInterface, debug } from "jssip";
import { DisconnectEvent } from "jssip/lib/WebSocketInterface";
import { RTCSession } from "jssip/lib/RTCSession";
import {
ConnectedEvent,
IncomingRTCSessionEvent,
RegisteredEvent,
UAConnectingEvent,
UnRegisteredEvent,
} from "jssip/lib/UA";
import {
SessionManager,
SipAudioElements,
SipSession,
SipConstants,
SipModel,
normalizeNumber,
} from "./index";
export default class SipUA extends events.EventEmitter {
#ua: UA;
#rtcConfig: RTCConfiguration;
#sessionManager: SessionManager;
constructor(client: SipModel.ClientAuth, settings: SipModel.ClientOptions) {
super();
debug.enable("JsSIP:*");
this.#sessionManager = new SessionManager();
this.#rtcConfig = settings.pcConfig;
this.#ua = new UA({
uri: `sip:${client.username}`,
password: client.password,
display_name: client.name,
sockets: [new WebSocketInterface(settings.wsUri)],
register: settings.register,
register_expires: 600,
});
this.#ua.on("connecting", (data: UAConnectingEvent) =>
this.emit(SipConstants.UA_CONNECTING, { ...data, client })
);
this.#ua.on("connected", (data: ConnectedEvent) =>
this.emit(SipConstants.UA_CONNECTED, { ...data, client })
);
this.#ua.on("disconnected", (data: DisconnectEvent) =>
this.emit(SipConstants.UA_DISCONNECTED, {
...data,
client,
})
);
this.#ua.on("registered", (data: RegisteredEvent) =>
this.emit(SipConstants.UA_REGISTERED, { ...data, client })
);
this.#ua.on("unregistered", (data: UnRegisteredEvent) =>
this.emit(SipConstants.UA_UNREGISTERED, {
...data,
client,
})
);
this.#ua.on("registrationFailed", (data: UnRegisteredEvent) =>
this.emit(SipConstants.UA_UNREGISTERED, {
...data,
client,
})
);
this.#ua.on("newRTCSession", (data: IncomingRTCSessionEvent) => {
const rtcSession: RTCSession = data.session;
const session: SipSession = new SipSession(
rtcSession,
this.#rtcConfig,
new SipAudioElements()
);
this.#sessionManager.newSession(session);
session.on(SipConstants.SESSION_RINGING, (args) =>
this.updateSession(SipConstants.SESSION_RINGING, session, args, client)
);
session.on(SipConstants.SESSION_ANSWERED, (args) =>
this.updateSession(SipConstants.SESSION_ANSWERED, session, args, client)
);
session.on(SipConstants.SESSION_FAILED, (args) =>
this.updateSession(SipConstants.SESSION_FAILED, session, args, client)
);
session.on(SipConstants.SESSION_ENDED, (args) =>
this.updateSession(SipConstants.SESSION_ENDED, session, args, client)
);
session.on(SipConstants.SESSION_MUTED, (args) =>
this.updateSession(SipConstants.SESSION_MUTED, session, args, client)
);
session.on(SipConstants.SESSION_HOLD, (args) =>
this.updateSession(SipConstants.SESSION_HOLD, session, args, client)
);
session.on(SipConstants.SESSION_UNHOLD, (args) =>
this.updateSession(SipConstants.SESSION_UNHOLD, session, args, client)
);
session.on(SipConstants.SESSION_ICE_READY, (args) =>
this.updateSession(
SipConstants.SESSION_ICE_READY,
session,
args,
client
)
);
session.on(SipConstants.SESSION_ACTIVE, (args) => {
this.updateSession(SipConstants.SESSION_ACTIVE, session, args, client);
});
session.setActive(true);
});
}
updateSession(
field: string,
session: SipSession,
args: any,
client: SipModel.ClientAuth
) {
this.emit(field, { ...args, client, session });
this.#sessionManager.updateSession(field, session, args);
}
start(): void {
this.#ua.start();
this.emit(SipConstants.UA_START);
}
stop(): void {
this.#ua.stop();
this.emit(SipConstants.UA_STOP);
}
call(number: string, customHeaders: string[] = []): void {
let normalizedNumber: string = normalizeNumber(number);
this.#ua.call(normalizedNumber, {
extraHeaders: [`X-Original-Number:${number}`].concat(customHeaders),
mediaConstraints: { audio: true, video: false },
pcConfig: this.#rtcConfig,
});
}
isMuted(id: string | undefined): boolean {
if (id) {
return this.#sessionManager.getSession(id).isMuted();
} else {
return this.#sessionManager.activeSession.isMuted();
}
}
mute(id: string | undefined): void {
if (id) {
this.#sessionManager.getSession(id).mute();
} else {
this.#sessionManager.activeSession.mute();
}
}
unmute(id: string | undefined): void {
if (id) {
this.#sessionManager.getSession(id).unmute();
} else {
this.#sessionManager.activeSession.unmute();
}
}
isHolded(id: string | undefined): boolean {
if (id) {
return this.#sessionManager.getSession(id).isHolded();
} else {
return this.#sessionManager.activeSession.isHolded();
}
}
hold(id: string | undefined): void {
if (id) {
this.#sessionManager.getSession(id).hold();
} else {
this.#sessionManager.activeSession.hold();
}
}
unhold(id: string | undefined): void {
if (id) {
this.#sessionManager.getSession(id).unhold();
} else {
this.#sessionManager.activeSession.unhold();
}
}
dtmf(tone: number | string, id: string | undefined): void {
if (id) {
this.#sessionManager.getSession(id).sendDtmf(tone);
} else {
this.#sessionManager.activeSession.sendDtmf(tone);
}
}
terminate(sipCode: number, reason: string, id: string | undefined): void {
if (id) {
this.#sessionManager.getSession(id).terminate(sipCode, reason);
} else {
this.#sessionManager.activeSession.terminate(sipCode, reason);
}
}
answer(id: string | undefined): void {
if (id) {
this.#sessionManager.getSession(id).answer();
} else {
this.#sessionManager.activeSession.answer();
}
}
activate(id: string) {
const session: SipSession = this.#sessionManager.getSession(id);
session.setActive(true);
}
isConnected() {
return this.#ua.isConnected();
}
}
+1 -16
View File
@@ -1,18 +1,3 @@
import SipSession from "./SipSession";
import SessionManager from "./SipSessionManager";
import SipAudioElements from "./SipAudioElements";
import SipUA from "./SipUA";
import {normalizeNumber, randomId} from "./sip-utils";
import * as SipModel from "./sip-models";
import * as SipConstants from "./sip-constants";
export {
SipUA,
SipSession,
SessionManager,
SipAudioElements,
randomId,
normalizeNumber,
SipConstants,
SipModel
}
export { SipAudioElements };
-20
View File
@@ -1,20 +0,0 @@
export const SESSION_RINGING: string = "ringing";
export const SESSION_ANSWERED: string = "answered";
export const SESSION_FAILED: string = "failed";
export const SESSION_ENDED: string = "ended";
export const SESSION_MUTED: string = "muted";
export const SESSION_HOLD: string = "hold";
export const SESSION_UNHOLD: string = "unhold";
export const SESSION_REFER: string = "refer";
export const SESSION_REPLACES: string = "replaces";
export const SESSION_ICE_READY: string = "ice-ready";
export const SESSION_ADD_STREAM: string = "add-stream";
export const SESSION_TRACK: string = "track";
export const SESSION_ACTIVE: string = "active";
export const UA_CONNECTING: string = "connecting";
export const UA_CONNECTED: string = "connected";
export const UA_DISCONNECTED: string = "disconnected";
export const UA_REGISTERED: string = "registered";
export const UA_UNREGISTERED: string = "unregistered";
export const UA_START: string = "start";
export const UA_STOP: string = "stop";
-45
View File
@@ -1,45 +0,0 @@
import SipSession from "./SipSession";
export interface SipSessionState {
id: string;
sipSession: SipSession;
startDateTime: Date;
active: boolean;
status: string;
muteStatus?: string;
iceReady?: boolean;
endState?: EndState;
holdState?: HoldState;
}
export interface EndState {
cause: string;
status: string;
originator: string;
description: string;
}
export interface HoldState {
status: string;
originator: string;
}
export interface ClientAuth {
username: string;
password: string;
name: string;
}
export interface ClientOptions {
pcConfig: PeerConnectionConfig;
wsUri: string;
register: boolean;
}
export interface PeerConnectionConfig {
iceServers: IceServer[];
}
export interface IceServer {
urls: string[];
}
-28
View File
@@ -1,28 +0,0 @@
function normalizeNumber(number: string): string {
if (/^(sips?|tel):/i.test(number)) {
return number;
} else if (/@/i.test(number)) {
return number;
} else if (
number.startsWith("app-") ||
number.startsWith("queue-") ||
number.startsWith("conference-")
) {
return number;
} else {
return number.replace(/[()\-. ]*/g, "");
}
}
function randomId(prefix: string): string {
const id: string = [...Array(16)]
.map(() => Math.floor(Math.random() * 16).toString(16))
.join("");
if (prefix) {
return `${prefix}-${id}`;
} else {
return id;
}
}
export { normalizeNumber, randomId };
+7 -10
View File
@@ -1,4 +1,4 @@
import { SipConstants } from "./../lib";
import { CallState } from "@jambonz/client-sdk-web";
import { deleteWindowIdKey, getWindowIdKey, saveWindowIdKey } from "./storage";
import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber";
@@ -44,19 +44,16 @@ const initiateNewPhonePopup = (callback: (v: unknown) => void) => {
});
};
export const isSipClientRinging = (callStatus: string) => {
return callStatus === SipConstants.SESSION_RINGING;
export const isSipClientRinging = (callState: CallState | null) => {
return callState === CallState.Ringing || callState === CallState.Connecting;
};
export const isSipClientAnswered = (callStatus: string) => {
return callStatus === SipConstants.SESSION_ANSWERED;
export const isSipClientAnswered = (callState: CallState | null) => {
return callState === CallState.Connected;
};
export const isSipClientIdle = (callStatus: string) => {
return (
callStatus === SipConstants.SESSION_ENDED ||
callStatus === SipConstants.SESSION_FAILED
);
export const isSipClientIdle = (callState: CallState | null) => {
return callState === null || callState === CallState.Idle || callState === CallState.Ended;
};
export const normalizeUrl = (input: string): string => {
+16 -19
View File
@@ -10,11 +10,12 @@ import {
import Phone from "./phone";
import Settings from "./settings";
import { DEFAULT_COLOR_SCHEME } from "src/common/constants";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { getActiveSettings, getCallHistories, getSettings } from "src/storage";
import CallHistories from "./history";
import { CallHistory, IAppSettings, SipClientStatus } from "src/common/types";
import { ClientState } from "@jambonz/client-sdk-web";
import Footer from "./footer/footer";
export const WindowApp = () => {
@@ -27,33 +28,25 @@ export const WindowApp = () => {
const [calledNumber, setCalledNumber] = useState("");
const [calledName, setCalledName] = useState("");
const [tabIndex, setTabIndex] = useState(0);
const [status, setStatus] = useState<SipClientStatus>("stop");
const [status, setStatus] = useState<SipClientStatus>(ClientState.Disconnected);
const [allSettings, setAllSettings] = useState<IAppSettings[]>([]);
const [advancedSettings, setAdvancedSettings] = useState<IAppSettings | null>(
null
);
const [isSwitchingUserStatus, setIsSwitchingUserStatus] = useState(false);
const [isOnline, setIsOnline] = useState(false);
const phoneSipAschildRef = useRef<{
updateGoOffline: (x: string) => void;
} | null>(null);
const [isUserOffline, setIsUserOffline] = useState(false);
const handleGoOffline = (s: SipClientStatus) => {
if (s === status) {
return;
}
if (phoneSipAschildRef.current) {
if (s === "unregistered") {
phoneSipAschildRef.current.updateGoOffline("stop");
if (s === ClientState.Unregistered) {
setIsUserOffline(true);
} else {
phoneSipAschildRef.current.updateGoOffline("start");
}
setIsUserOffline(false);
}
};
const loadSettings = () => {
const settings = getSettings();
const activeSettings = settings.find((el) => el.active);
setAllSettings(getSettings());
@@ -70,7 +63,6 @@ export const WindowApp = () => {
title: "Dialer",
content: (
<Phone
ref={phoneSipAschildRef}
sipUsername={sipUsername}
sipPassword={sipPassword}
sipDomain={sipDomain}
@@ -84,6 +76,7 @@ export const WindowApp = () => {
reload={loadSettings}
setIsSwitchingUserStatus={setIsSwitchingUserStatus}
setIsOnline={setIsOnline}
isUserOffline={isUserOffline}
/>
),
},
@@ -117,16 +110,20 @@ export const WindowApp = () => {
setCallHistories(getCallHistories(sipUsername));
};
return (
<Grid h="100vh" templateRows="1fr auto">
<Box p={2}>
<Grid h="100vh" templateRows="1fr auto" overflow="hidden">
<Box p={2} minH={0} display="flex" flexDirection="column" overflow="hidden">
<Tabs
isFitted
variant="enclosed"
colorScheme={DEFAULT_COLOR_SCHEME}
onChange={onTabsChange}
index={tabIndex}
display="flex"
flexDirection="column"
flex="1"
minH={0}
>
<TabList mb="1em" gap={1}>
<TabList mb="1em" gap={1} flexShrink={0}>
{tabsSettings.map((s, i) => (
<Tab
_selected={{ color: "white", bg: "jambonz.500" }}
@@ -138,7 +135,7 @@ export const WindowApp = () => {
))}
</TabList>
<TabPanels>
<TabPanels flex="1" minH={0} overflowY="auto">
{tabsSettings.map((s, i) => (
<TabPanel key={i}>{s.content}</TabPanel>
))}
+9 -3
View File
@@ -2,6 +2,7 @@ import { HStack, Image, Text } from "@chakra-ui/react";
import jambonz from "src/imgs/jambonz.svg";
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { SipClientStatus } from "src/common/types";
import { ClientState } from "@jambonz/client-sdk-web";
import JambonzSwitch from "src/components/switch";
import "./styles.scss";
@@ -35,9 +36,12 @@ function Footer({
const [isConfigured, setIsConfigured] = useState(false);
useEffect(() => {
if (status === "registered" || status === "disconnected") {
if (
status === ClientState.Registered ||
status === ClientState.Disconnected
) {
setIsSwitchingUserStatus(false);
setIsOnline(status === "registered");
setIsOnline(status === ClientState.Registered);
}
}, [status, setIsSwitchingUserStatus, setIsOnline]);
@@ -63,7 +67,9 @@ function Footer({
checked={[isOnline, setIsOnline]}
onChange={(v) => {
setIsSwitchingUserStatus(true);
onHandleGoOffline(v ? "registered" : "unregistered");
onHandleGoOffline(
v ? ClientState.Registered : ClientState.Unregistered
);
}}
/>
<Text>You are {isOnline ? "online" : "offline"}</Text>
+2 -2
View File
@@ -35,9 +35,9 @@ export const CallHistoryItem = ({
}: CallHistoryItemProbs) => {
const [callEnable, setCallEnable] = useState(false);
const getDirectionIcon = (direction: SipCallDirection) => {
if (direction === "outgoing") {
if (direction === "outbound") {
return faArrowRightFromBracket;
} else if (direction === "incoming") {
} else if (direction === "inbound") {
return faArrowRightToBracket;
} else {
return faPhone;
+14 -21
View File
@@ -1,7 +1,7 @@
import { Text, UnorderedList, VStack } from "@chakra-ui/react";
import CallHistoryItem from "./call-history-item";
import { CallHistory } from "src/common/types";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import Fuse from "fuse.js";
type RecentsProbs = {
@@ -19,35 +19,28 @@ export const Recents = ({
onDataChange,
onCallNumber,
}: RecentsProbs) => {
const [callHistories, setCallHistories] = useState<CallHistory[]>(calls);
const baseCalls = useMemo(
() => (isSaved ? calls.filter((c) => c.isSaved === true) : calls),
[calls, isSaved]
);
useEffect(() => {
const fuseInstance = useMemo(
() => new Fuse(baseCalls, { keys: ["number"] }),
[baseCalls]
);
const callHistories = useMemo(() => {
if (search) {
setCallHistories((prev) =>
new Fuse(prev, {
keys: ["number"],
})
.search(search)
.map(({ item }) => item)
);
} else {
setCallHistories(
isSaved ? calls.filter((c) => c.isSaved === true) : calls
);
return fuseInstance.search(search).map(({ item }) => item);
}
}, [search]);
useEffect(() => {
setCallHistories(isSaved ? calls.filter((c) => c.isSaved === true) : calls);
}, [calls]);
return baseCalls;
}, [search, fuseInstance, baseCalls]);
return (
<VStack spacing={2}>
{callHistories.length > 0 ? (
<UnorderedList
w="full"
maxH="calc(100vh - 21em)"
overflowY="auto"
spacing={2}
mt={2}
>
+12 -8
View File
@@ -2,8 +2,14 @@ export default class DialPadAudioElements {
private keySounds: { [key: string]: HTMLAudioElement | undefined } = {};
constructor() {
const arr = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"];
for (const i of arr) {
const keys = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"];
const fileMap: Record<string, string> = {
"*": "star",
"#": "hash",
};
for (const key of keys) {
const fileName = fileMap[key] || key;
let audioURL;
// Check if we're in a Chrome extension
@@ -12,16 +18,14 @@ export default class DialPadAudioElements {
chrome.runtime &&
chrome.runtime.getURL
) {
audioURL = chrome.runtime.getURL(
`audios/dtmf-${encodeURIComponent(i)}.mp3`
);
audioURL = chrome.runtime.getURL(`audios/dtmf-${fileName}.mp3`);
} else {
// We're in a web context, adjust this path as necessary
audioURL = `/audios/dtmf-${encodeURIComponent(i)}.mp3`;
audioURL = `/audios/dtmf-${fileName}.mp3`;
}
this.keySounds[i] = new Audio(audioURL);
const audio = this.keySounds[i];
this.keySounds[key] = new Audio(audioURL);
const audio = this.keySounds[key];
if (audio) {
audio.volume = 0.5;
}
+21 -24
View File
@@ -16,12 +16,12 @@ import { updateConferenceParticipantAction } from "src/api";
import { ConferenceModes } from "src/api/types";
import { DEFAULT_TOAST_DURATION } from "src/common/constants";
import OutlineBox from "src/components/outline-box";
import { SipConstants } from "src/lib";
import {
deleteConferenceSettings,
getConferenceSettings,
saveConferenceSettings,
} from "src/storage";
import { CallState } from "@jambonz/client-sdk-web";
type JoinConferenceProbs = {
conferenceId?: string;
@@ -60,9 +60,21 @@ export const JoinConference = ({
);
const [participantState, setParticipantState] = useState("Join as");
const updateSettings = (updates: Partial<{ mode: ConferenceModes; speakOnlyTo: string; tags: string }>) => {
const next = {
mode: updates.mode ?? mode,
speakOnlyTo: updates.speakOnlyTo ?? speakOnlyTo,
tags: updates.tags ?? tags,
};
if (updates.mode !== undefined) setMode(updates.mode);
if (updates.speakOnlyTo !== undefined) setSpeakOnlyTo(updates.speakOnlyTo);
if (updates.tags !== undefined) setTags(updates.tags);
saveConferenceSettings(next);
};
useEffect(() => {
switch (callStatus) {
case SipConstants.SESSION_ANSWERED:
case CallState.Connected:
setAppTitle("Conference");
setSubmitTitle("Update");
setCancelTitle("Hangup");
@@ -70,8 +82,8 @@ export const JoinConference = ({
setIsLoading(false);
configureConferenceSession();
break;
case SipConstants.SESSION_ENDED:
case SipConstants.SESSION_FAILED:
case CallState.Ended:
case CallState.Idle:
setIsLoading(false);
deleteConferenceSettings();
break;
@@ -80,7 +92,7 @@ export const JoinConference = ({
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (callStatus !== SipConstants.SESSION_ANSWERED) {
if (callStatus !== CallState.Connected) {
call(conferenceName);
if (!callSid) {
setIsLoading(true);
@@ -145,7 +157,7 @@ export const JoinConference = ({
</Text>
{callDuration > 0 && (
<Text fontSize="15px">
{new Date(callDuration * 1000).toISOString().substr(11, 8)}
{new Date(callDuration * 1000).toISOString().substring(11, 19)}
</Text>
)}
<FormControl id="conference_name">
@@ -163,12 +175,7 @@ export const JoinConference = ({
<OutlineBox title={participantState}>
<RadioGroup
onChange={(e) => {
setMode(e as ConferenceModes);
saveConferenceSettings({
mode: e as ConferenceModes,
speakOnlyTo,
tags,
});
updateSettings({ mode: e as ConferenceModes });
}}
value={mode}
colorScheme="jambonz"
@@ -189,12 +196,7 @@ export const JoinConference = ({
placeholder="tag"
value={speakOnlyTo}
onChange={(e) => {
setSpeakOnlyTo(e.target.value);
saveConferenceSettings({
mode,
speakOnlyTo: e.target.value,
tags,
});
updateSettings({ speakOnlyTo: e.target.value });
}}
disabled={mode !== "coach"}
required={mode === "coach"}
@@ -208,12 +210,7 @@ export const JoinConference = ({
placeholder="tag"
value={tags}
onChange={(e) => {
setTags(e.target.value);
saveConferenceSettings({
mode,
speakOnlyTo,
tags: e.target.value,
});
updateSettings({ tags: e.target.value });
}}
/>
</FormControl>
+15 -8
View File
@@ -1,6 +1,6 @@
import { Box, Button, HStack, VStack } from "@chakra-ui/react";
import DialPadAudioElements from "./DialPadSoundElement";
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
type DialPadProbs = {
handleDigitPress: (digit: string, fromKeyboard: boolean) => void;
@@ -8,9 +8,6 @@ type DialPadProbs = {
const keySounds = new DialPadAudioElements();
export const DialPad = ({ handleDigitPress }: DialPadProbs) => {
const selfRef = useRef<HTMLDivElement | null>(null);
const isVisibleRef = useRef(false);
const buttons = [
["1", "2", "3"],
["4", "5", "6"],
@@ -18,7 +15,17 @@ export const DialPad = ({ handleDigitPress }: DialPadProbs) => {
["*", "0", "#"],
];
const handleKeyDown = (e: KeyboardEvent) => {
export const DialPad = ({ handleDigitPress }: DialPadProbs) => {
const selfRef = useRef<HTMLDivElement | null>(null);
const isVisibleRef = useRef(false);
const handleDigitPressRef = useRef(handleDigitPress);
// Keep ref in sync so the keydown listener always uses the latest callback
useEffect(() => {
handleDigitPressRef.current = handleDigitPress;
}, [handleDigitPress]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (
["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"].includes(
e.key
@@ -26,10 +33,10 @@ export const DialPad = ({ handleDigitPress }: DialPadProbs) => {
) {
if (isVisibleRef.current) {
keySounds?.playKeyTone(e.key);
handleDigitPress(e.key, true);
handleDigitPressRef.current(e.key, true);
}
}
};
}, []);
useEffect(() => {
const observer = new IntersectionObserver(
@@ -50,7 +57,7 @@ export const DialPad = ({ handleDigitPress }: DialPadProbs) => {
observer.unobserve(selfRef.current);
}
};
}, []);
}, [handleKeyDown]);
return (
<Box p={2} w="full" h="280px" ref={selfRef}>
+306 -303
View File
@@ -15,27 +15,30 @@ import {
} from "@chakra-ui/react";
import {
Dispatch,
forwardRef,
SetStateAction,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import {
IAppSettings,
SipCallDirection,
SipClientStatus,
} from "src/common/types";
import { SipConstants, SipUA } from "src/lib";
import {
useJambonzClient,
useCall,
ClientState,
CallState as JambonzCallState,
} from "@jambonz/client-sdk-web";
import IncommingCall from "./incoming-call";
import DialPad from "./dial-pad";
import {
isSipClientAnswered,
isSipClientIdle,
isSipClientRinging,
} from "src/utils";
import { SipAudioElements } from "src/lib";
import Avatar from "src/imgs/icons/Avatar.svg";
import GreenAvatar from "src/imgs/icons/Avatar-Green.svg";
@@ -70,6 +73,8 @@ import {
faPhoneSlash,
faPlay,
faUserGroup,
faEarListen,
faEarDeaf,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import JoinConference from "./conference";
@@ -90,6 +95,7 @@ type PhoneProbs = {
reload: () => void;
setIsSwitchingUserStatus: React.Dispatch<React.SetStateAction<boolean>>;
setIsOnline: React.Dispatch<React.SetStateAction<boolean>>;
isUserOffline: boolean;
};
enum PAGE_VIEW {
@@ -99,15 +105,13 @@ enum PAGE_VIEW {
JOIN_CONFERENCE,
}
export const Phone = forwardRef(
(
{
export const Phone = ({
sipDomain,
sipServerAddress,
sipUsername,
sipPassword,
sipDisplayName,
stat: [status, setStatus],
stat: [, setStatus],
calledNumber: [calledANumber, setCalledANumber],
calledName: [calledAName, setCalledAName],
advancedSettings,
@@ -115,14 +119,10 @@ export const Phone = forwardRef(
reload,
setIsSwitchingUserStatus,
setIsOnline,
}: PhoneProbs,
ref: any
) => {
isUserOffline,
}: PhoneProbs) => {
const [inputNumber, setInputNumber] = useState("");
const [appName, setAppName] = useState("");
const [callStatus, setCallStatus] = useState(SipConstants.SESSION_ENDED);
const [sessionDirection, setSessionDirection] =
useState<SipCallDirection>("");
const [seconds, setSeconds] = useState(0);
const [isCallButtonLoading, setIsCallButtonLoading] = useState(false);
const [isAdvanceMode, setIsAdvancedMode] = useState(false);
@@ -135,240 +135,221 @@ export const Phone = forwardRef(
allow_direct_user_calling: false,
});
const [selectedConference, setSelectedConference] = useState("");
const [callSid, setCallSid] = useState("");
const [callSidState, setCallSidState] = useState("");
const [showConference, setShowConference] = useState(false);
const [showAccounts, setShowAccounts] = useState(false);
const [isNoiseIsolation, setIsNoiseIsolation] = useState(false);
const inputNumberRef = useRef(inputNumber);
const sessionDirectionRef = useRef(sessionDirection);
const sipUA = useRef<SipUA | null>(null);
const timerRef = useRef<NodeJS.Timer | null>(null);
const FetchUsertimerRef = useRef<NodeJS.Timer | null>(null);
const isRestartRef = useRef(false);
const sipDomainRef = useRef("");
const sipUsernameRef = useRef("");
const sipPasswordRef = useRef("");
const sipServerAddressRef = useRef("");
const sipDisplayNameRef = useRef("");
const unregisteredReasonRef = useRef("");
const isInputNumberFocusRef = useRef(false);
const secondsRef = useRef(seconds);
const accountsCardRef = useRef<HTMLDivElement | null>(null);
const audioRef = useRef<SipAudioElements>(new SipAudioElements());
const prevIsRegisteredRef = useRef(false);
const sipUsernameRef = useRef(sipUsername);
const toast = useToast();
useImperativeHandle(ref, () => ({
updateGoOffline(newState: string) {
if (newState === "stop") {
sipUA.current?.stop();
} else {
sipUA.current?.start();
}
},
}));
const addCallHistory = useCallback(() => {
const call = getCurrentCall();
if (call) {
saveCallHistory(sipUsername, {
number: call.number,
direction: call.direction,
duration: transform(Date.now(), call.timeStamp),
timeStamp: call.timeStamp,
callSid: call.callSid,
name: call.name,
});
}
deleteCurrentCall();
// Keep sipUsername ref up to date
useEffect(() => {
sipUsernameRef.current = sipUsername;
}, [sipUsername]);
const startCallDurationCounter = useCallback(() => {
stopCallDurationCounter();
timerRef.current = setInterval(() => {
setSeconds((seconds) => seconds + 1);
}, 1000);
}, []);
// Build stable options for useJambonzClient
const clientOptions = useMemo(() => {
if (!sipDomain || !sipUsername || !sipPassword || !sipServerAddress) {
return { server: "", username: "", password: "" };
}
return {
server: sipServerAddress,
username: sipUsername,
password: sipPassword,
displayName: sipDisplayName || sipUsername,
realm: sipDomain,
};
}, [sipDomain, sipServerAddress, sipUsername, sipPassword, sipDisplayName]);
const createSipClient = useCallback(() => {
const hasCredentials = !!(sipDomain && sipUsername && sipPassword && sipServerAddress);
const {
client,
state: clientState,
isRegistered,
connect,
disconnect,
error: clientError,
} = useJambonzClient(clientOptions);
const {
call: activeCall,
state: callState,
isMuted,
isHeld,
incomingCaller,
makeCall,
answerIncoming,
declineIncoming,
hangup,
toggleMute,
toggleHold,
sendDtmf,
} = useCall(client);
// Sync client state to parent
useEffect(() => {
setStatus(clientState);
}, [clientState, setStatus]);
// Auto-connect/disconnect based on credentials and user offline toggle
useEffect(() => {
if (hasCredentials && !isUserOffline) {
setIsSwitchingUserStatus(true);
const client = {
username: `${sipUsernameRef.current}@${sipDomainRef.current}`,
password: sipPasswordRef.current,
name: sipDisplayNameRef.current ?? sipUsernameRef.current,
};
const settings = {
pcConfig: {
iceServers: [{ urls: ["stun:stun.l.google.com:19302"] }],
},
wsUri: sipServerAddressRef.current,
register: true,
};
const sipClient = new SipUA(client, settings);
// UA Status
sipClient.on(SipConstants.UA_REGISTERED, (args) => {
setStatus("registered");
});
sipClient.on(SipConstants.UA_UNREGISTERED, (args) => {
setStatus("unregistered");
if (sipUA.current) {
sipUA.current.stop();
}
unregisteredReasonRef.current = `User is not registered${
args.cause ? `, ${args.cause}` : ""
}`;
});
sipClient.on(SipConstants.UA_DISCONNECTED, (args) => {
if (unregisteredReasonRef.current) {
toast({
title: unregisteredReasonRef.current,
status: "warning",
duration: DEFAULT_TOAST_DURATION,
isClosable: true,
});
unregisteredReasonRef.current = "";
}
setStatus("disconnected");
setIsOnline(false);
connect().catch((err) => {
setIsSwitchingUserStatus(false);
if (sipUA.current) {
sipUA.current.stop();
}
if (args.error) {
setIsOnline(false);
toast({
title: `Cannot connect to ${sipServerAddressRef.current}${
args.reason ? `, ${args.reason}` : ""
}`,
title: `Cannot connect to ${sipServerAddress}${err?.message ? `: ${err.message}` : ""}`,
status: "warning",
duration: DEFAULT_TOAST_DURATION,
isClosable: true,
});
} else if (isRestartRef.current) {
createSipClient();
isRestartRef.current = false;
}
});
// Call Status
sipClient.on(SipConstants.SESSION_RINGING, (args) => {
if (args.session.direction === "incoming") {
} else {
disconnect();
}
}, [hasCredentials, isUserOffline, sipDomain, sipUsername, sipPassword, sipServerAddress, sipDisplayName]);
// Cleanup on unmount
useEffect(() => {
return () => {
disconnect();
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
clearFetchUserTimer();
};
}, [disconnect]);
// Handle registration state changes
useEffect(() => {
if (isRegistered !== prevIsRegisteredRef.current) {
prevIsRegisteredRef.current = isRegistered;
setIsSwitchingUserStatus(false);
setIsOnline(isRegistered);
}
}, [isRegistered, setIsSwitchingUserStatus, setIsOnline]);
// Handle client errors
useEffect(() => {
if (clientError) {
toast({
title: clientError,
status: "warning",
duration: DEFAULT_TOAST_DURATION,
isClosable: true,
});
}
}, [clientError, toast]);
// Handle disconnected state
useEffect(() => {
if (clientState === ClientState.Disconnected || clientState === ClientState.Error) {
setIsSwitchingUserStatus(false);
setIsOnline(false);
}
}, [clientState, setIsSwitchingUserStatus, setIsOnline]);
// Audio feedback and page view based on call state
useEffect(() => {
if (isSipClientIdle(callState) && isCallButtonLoading) {
setIsCallButtonLoading(false);
}
if (callState === JambonzCallState.Ringing || callState === JambonzCallState.Connecting) {
if (incomingCaller) {
// Incoming call
setInputNumber(incomingCaller);
setPageView(PAGE_VIEW.INCOMING_CALL);
audioRef.current.playRinging();
saveCurrentCall({
number: args.session.user,
direction: args.session.direction,
number: incomingCaller,
direction: "inbound",
timeStamp: Date.now(),
duration: "0",
callSid: uuidv4(),
});
} else {
// Outgoing call ringing
setPageView(PAGE_VIEW.OUTGOING_CALL);
audioRef.current.playRingback();
}
setCallStatus(SipConstants.SESSION_RINGING);
setSessionDirection(args.session.direction);
setInputNumber(args.session.user);
});
sipClient.on(SipConstants.SESSION_ANSWERED, (args) => {
setCallSid(args.callSid);
} else if (callState === JambonzCallState.Connected) {
audioRef.current.playAnswer();
const currentCall = getCurrentCall();
if (currentCall) {
currentCall.timeStamp = Date.now();
saveCurrentCall(currentCall);
}
setCallStatus(SipConstants.SESSION_ANSWERED);
if (selectedConference) {
setPageView(PAGE_VIEW.JOIN_CONFERENCE);
} else {
setPageView(PAGE_VIEW.DIAL_PAD);
}
startCallDurationCounter();
});
sipClient.on(SipConstants.SESSION_ENDED, (args) => {
} else if (callState === JambonzCallState.Ended || callState === null) {
// Call ended or no call
if (timerRef.current) {
// There was an active call that just ended
audioRef.current.playLocalHungup();
addCallHistory();
setCallStatus(SipConstants.SESSION_ENDED);
setSessionDirection("");
stopCallDurationCounter();
});
sipClient.on(SipConstants.SESSION_FAILED, (args) => {
addCallHistory();
setCallStatus(SipConstants.SESSION_FAILED);
setSessionDirection("");
stopCallDurationCounter();
});
sipClient.start();
sipUA.current = sipClient;
}, [
addCallHistory,
setIsSwitchingUserStatus,
setStatus,
startCallDurationCounter,
toast,
setIsOnline,
]);
setSelectedConference("");
setIsNoiseIsolation(false);
setPageView(PAGE_VIEW.DIAL_PAD);
}
}
}, [callState, incomingCaller, selectedConference]);
// Handle call failed event from the call object
useEffect(() => {
sipDomainRef.current = sipDomain;
sipUsernameRef.current = sipUsername;
sipPasswordRef.current = sipPassword;
sipServerAddressRef.current = sipServerAddress;
sipDisplayNameRef.current = sipDisplayName;
if (sipDomain && sipUsername && sipPassword && sipServerAddress) {
if (sipUA.current) {
if (sipUA.current.isConnected()) {
clientGoOffline();
isRestartRef.current = true;
} else {
createSipClient();
}
} else {
createSipClient();
}
} else {
clientGoOffline();
}
}, [
sipDomain,
sipUsername,
sipPassword,
sipServerAddress,
sipDisplayName,
createSipClient,
]);
if (!activeCall) return;
const onFailed = () => {
audioRef.current.playFailed();
addCallHistory();
stopCallDurationCounter();
setSelectedConference("");
setPageView(PAGE_VIEW.DIAL_PAD);
};
activeCall.on("failed", onFailed);
return () => {
activeCall.off("failed", onFailed);
};
}, [activeCall]);
// Get callSid from call headers when accepted
useEffect(() => {
if (!activeCall) return;
const onAccepted = () => {
// The SDK doesn't expose response headers directly,
// use the call id as callSid
setCallSidState(activeCall.id);
};
activeCall.on("accepted", onAccepted);
return () => {
activeCall.off("accepted", onAccepted);
};
}, [activeCall]);
useEffect(() => {
setIsAdvancedMode(!!advancedSettings?.decoded?.accountSid);
fetchRegisterUser();
}, [advancedSettings]);
useEffect(() => {
inputNumberRef.current = inputNumber;
sessionDirectionRef.current = sessionDirection;
secondsRef.current = seconds;
}, [inputNumber, seconds, sessionDirection]);
useEffect(() => {
if (isSipClientIdle(callStatus) && isCallButtonLoading) {
setIsCallButtonLoading(false);
}
switch (callStatus) {
case SipConstants.SESSION_RINGING:
if (sessionDirection === "incoming") {
setPageView(PAGE_VIEW.INCOMING_CALL);
} else {
setPageView(PAGE_VIEW.OUTGOING_CALL);
}
break;
case SipConstants.SESSION_ANSWERED:
if (!!selectedConference) {
setPageView(PAGE_VIEW.JOIN_CONFERENCE);
} else {
setPageView(PAGE_VIEW.DIAL_PAD);
}
break;
case SipConstants.SESSION_ENDED:
case SipConstants.SESSION_FAILED:
setSelectedConference("");
setPageView(PAGE_VIEW.DIAL_PAD);
break;
}
}, [callStatus, isCallButtonLoading, selectedConference, sessionDirection]);
useEffect(() => {
if (calledANumber) {
if (
@@ -388,13 +369,6 @@ export const Phone = forwardRef(
}
}, [calledANumber, calledAName, setCalledAName, setCalledANumber]);
useEffect(() => {
if (status === "registered" || status === "disconnected") {
setIsSwitchingUserStatus(false);
setIsOnline(status === "registered");
}
}, [status, setIsOnline, setIsSwitchingUserStatus]);
const clearFetchUserTimer = () => {
if (FetchUsertimerRef.current) {
clearInterval(FetchUsertimerRef.current);
@@ -404,7 +378,6 @@ export const Phone = forwardRef(
useEffect(() => {
if (isAdvanceMode) {
// check conference aibility
getConferences()
.then(() => {
setShowConference(true);
@@ -412,6 +385,7 @@ export const Phone = forwardRef(
.catch(() => {
setShowConference(false);
});
clearFetchUserTimer();
FetchUsertimerRef.current = setInterval(() => {
fetchRegisterUser();
}, 10_000);
@@ -419,6 +393,7 @@ export const Phone = forwardRef(
clearFetchUserTimer();
setShowConference(false);
}
return () => clearFetchUserTimer();
}, [isAdvanceMode]);
useEffect(() => {
@@ -438,7 +413,7 @@ export const Phone = forwardRef(
.then(({ json }) => {
setRegisteredUser(json);
})
.catch((err) => {
.catch(() => {
setRegisteredUser({
allow_direct_app_calling: false,
allow_direct_queue_calling: false,
@@ -447,6 +422,28 @@ export const Phone = forwardRef(
});
};
const addCallHistory = useCallback(() => {
const call = getCurrentCall();
if (call) {
saveCallHistory(sipUsernameRef.current, {
number: call.number,
direction: call.direction,
duration: transform(Date.now(), call.timeStamp),
timeStamp: call.timeStamp,
callSid: call.callSid,
name: call.name,
});
}
deleteCurrentCall();
}, []);
const startCallDurationCounter = useCallback(() => {
stopCallDurationCounter();
timerRef.current = setInterval(() => {
setSeconds((seconds) => seconds + 1);
}, 1000);
}, []);
function stopCallDurationCounter() {
if (timerRef.current) {
clearInterval(timerRef.current);
@@ -456,105 +453,102 @@ export const Phone = forwardRef(
}
function transform(t1: number, t2: number) {
const diff = Math.abs(t1 - t2) / 1000; // Get the difference in seconds
const diff = Math.abs(t1 - t2) / 1000;
const hours = Math.floor(diff / 3600);
const minutes = Math.floor((diff % 3600) / 60);
const seconds = Math.floor(diff % 60);
// Pad the values with a leading zero if they are less than 10
const secs = Math.floor(diff % 60);
const hours1 = hours < 10 ? "0" + hours : hours;
const minutes1 = minutes < 10 ? "0" + minutes : minutes;
const seconds1 = seconds < 10 ? "0" + seconds : seconds;
const seconds1 = secs < 10 ? "0" + secs : secs;
return `${hours1}:${minutes1}:${seconds1}`;
}
const handleDialPadClick = (value: string, fromKeyboad: boolean) => {
if (!(isInputNumberFocusRef.current && fromKeyboad)) {
const handleDialPadClick = (value: string, fromKeyboard: boolean) => {
if (!(isInputNumberFocusRef.current && fromKeyboard)) {
setInputNumber((prev) => prev + value);
}
if (isSipClientAnswered(callStatus)) {
sipUA.current?.dtmf(value, undefined);
if (isSipClientAnswered(callState)) {
sendDtmf(value);
}
};
const handleCallButtion = () => {
const handleCallButton = () => {
makeOutboundCall(inputNumber);
};
const makeOutboundCall = (number: string, name: string = "") => {
if (sipUA.current && number) {
if (client && isRegistered && number) {
setIsCallButtonLoading(true);
setCallStatus(SipConstants.SESSION_RINGING);
setSessionDirection("outgoing");
saveCurrentCall({
number: number,
name,
direction: "outgoing",
direction: "outbound",
timeStamp: Date.now(),
duration: "0",
callSid: uuidv4(),
});
// Add custom header if this is special jambonz call
let customHeaders: string[] = [];
if (number.startsWith("app-")) {
customHeaders = [
`X-Application-Sid: ${number.substring(4, number.length)}`,
];
}
sipUA.current.call(number, customHeaders);
}
};
const clientGoOffline = () => {
if (sipUA.current) {
sipUA.current.stop();
sipUA.current = null;
// Use SDK-specific call methods based on the target type
if (number.startsWith("app-")) {
const appSid = number.substring(4);
makeCall(number, {
headers: { "X-Application-Sid": appSid },
});
} else if (number.startsWith("queue-")) {
makeCall(number);
} else if (number.startsWith("conference-")) {
makeCall(number);
} else {
makeCall(number);
}
}
};
const handleHangup = () => {
if (isSipClientAnswered(callStatus) || isSipClientRinging(callStatus)) {
sipUA.current?.terminate(480, "Call Finished", undefined);
}
audioRef.current.stopAll();
hangup();
};
const handleCallOnHold = () => {
if (isSipClientAnswered(callStatus)) {
if (sipUA.current?.isHolded(undefined)) {
sipUA.current?.unhold(undefined);
} else {
sipUA.current?.hold(undefined);
}
if (isSipClientAnswered(callState)) {
toggleHold();
}
};
const handleCallMute = () => {
if (isSipClientAnswered(callStatus)) {
if (sipUA.current?.isMuted(undefined)) {
sipUA.current?.unmute(undefined);
if (isSipClientAnswered(callState)) {
toggleMute();
}
};
const handleNoiseIsolation = () => {
if (isSipClientAnswered(callState) && activeCall) {
if (isNoiseIsolation) {
activeCall.disableNoiseIsolation();
setIsNoiseIsolation(false);
} else {
sipUA.current?.mute(undefined);
const settings = advancedSettings?.decoded;
activeCall.enableNoiseIsolation({
vendor: settings?.noiseIsolationVendor || "krisp",
level: settings?.noiseIsolationLevel ?? 0.3,
...(settings?.noiseIsolationModel
? { model: settings.noiseIsolationModel }
: {}),
});
setIsNoiseIsolation(true);
}
}
};
const handleAnswer = () => {
if (isSipClientRinging(callStatus)) {
sipUA.current?.answer(undefined);
}
audioRef.current.pauseRinging();
answerIncoming();
};
const handleDecline = () => {
if (isSipClientRinging(callStatus)) {
sipUA.current?.terminate(486, "Busy here", undefined);
}
};
const isStatusRegistered = () => {
return status === "registered";
audioRef.current.stopAll();
declineIncoming();
};
const handleSetActive = (id: number) => {
@@ -597,7 +591,7 @@ export const Phone = forwardRef(
{sipUsername && sipDomain ? (
<>
<Image
src={isStatusRegistered() ? GreenAvatar : Avatar}
src={isRegistered ? GreenAvatar : Avatar}
boxSize="35px"
/>
<VStack alignItems="start" w="full" spacing={0}>
@@ -607,7 +601,7 @@ export const Phone = forwardRef(
</Text>
<Circle
size="8px"
bg={isStatusRegistered() ? "green.500" : "gray.500"}
bg={isRegistered ? "green.500" : "gray.500"}
/>
</HStack>
<Text fontWeight="bold" w="full">
@@ -646,9 +640,9 @@ export const Phone = forwardRef(
spacing={2}
w="full"
mt={5}
className={isStatusRegistered() ? "" : "blurred"}
className={isRegistered ? "" : "blurred"}
>
{isAdvanceMode && isSipClientIdle(callStatus) && (
{isAdvanceMode && isSipClientIdle(callState) && (
<HStack spacing={2} align="start" w="full">
{registeredUser.allow_direct_user_calling && (
<IconButtonMenu
@@ -807,22 +801,22 @@ export const Phone = forwardRef(
isInputNumberFocusRef.current = false;
}}
textAlign="center"
isReadOnly={!isSipClientIdle(callStatus)}
isReadOnly={!isSipClientIdle(callState)}
/>
{!isSipClientIdle(callStatus) && seconds >= 0 && (
{!isSipClientIdle(callState) && seconds >= 0 && (
<Text fontSize="15px">
{new Date(seconds * 1000).toISOString().substr(11, 8)}
{new Date(seconds * 1000).toISOString().substring(11, 19)}
</Text>
)}
<DialPad handleDigitPress={handleDialPadClick} />
{isSipClientIdle(callStatus) ? (
{isSipClientIdle(callState) ? (
<Button
w="full"
onClick={handleCallButtion}
isDisabled={!isStatusRegistered()}
onClick={handleCallButton}
isDisabled={!isRegistered}
colorScheme="jambonz"
alignContent="center"
isLoading={isCallButtonLoading}
@@ -831,19 +825,12 @@ export const Phone = forwardRef(
</Button>
) : (
<HStack w="full">
<Tooltip
label={sipUA.current?.isHolded(undefined) ? "UnHold" : "Hold"}
>
<Tooltip label={isHeld ? "UnHold" : "Hold"}>
<IconButton
aria-label="Place call onhold"
icon={
<FontAwesomeIcon
icon={
sipUA.current?.isHolded(undefined) ? faPlay : faPause
<FontAwesomeIcon icon={isHeld ? faPlay : faPause} />
}
/>
}
w="33%"
variant="unstyled"
display="flex"
alignItems="center"
@@ -851,6 +838,30 @@ export const Phone = forwardRef(
onClick={handleCallOnHold}
/>
</Tooltip>
{isSipClientAnswered(callState) && (
<Tooltip
label={
isNoiseIsolation
? "Disable Noise Isolation"
: "Enable Noise Isolation"
}
>
<IconButton
aria-label="Toggle noise isolation"
icon={
<FontAwesomeIcon
icon={isNoiseIsolation ? faEarDeaf : faEarListen}
/>
}
variant="unstyled"
display="flex"
alignItems="center"
justifyContent="center"
color={isNoiseIsolation ? "jambonz.500" : undefined}
onClick={handleNoiseIsolation}
/>
</Tooltip>
)}
<Spacer />
<IconButton
@@ -863,21 +874,14 @@ export const Phone = forwardRef(
onClick={handleHangup}
/>
<Spacer />
<Tooltip
label={sipUA.current?.isMuted(undefined) ? "Unmute" : "Mute"}
>
<Tooltip label={isMuted ? "Unmute" : "Mute"}>
<IconButton
aria-label="Mute"
icon={
<FontAwesomeIcon
icon={
sipUA.current?.isMuted(undefined)
? faMicrophone
: faMicrophoneSlash
}
icon={isMuted ? faMicrophone : faMicrophoneSlash}
/>
}
w="33%"
variant="unstyled"
display="flex"
alignItems="center"
@@ -899,18 +903,18 @@ export const Phone = forwardRef(
{pageView === PAGE_VIEW.OUTGOING_CALL && (
<OutGoingCall
number={inputNumber || appName}
cancelCall={handleDecline}
cancelCall={handleHangup}
/>
)}
{pageView === PAGE_VIEW.JOIN_CONFERENCE && (
<JoinConference
conferenceId={selectedConference}
callSid={callSid}
callSid={callSidState}
callDuration={seconds}
callStatus={callStatus}
callStatus={callState ?? JambonzCallState.Idle}
handleCancel={() => {
if (isSipClientAnswered(callStatus)) {
sipUA.current?.terminate(480, "Call Finished", undefined);
if (isSipClientAnswered(callState)) {
hangup();
}
setPageView(PAGE_VIEW.DIAL_PAD);
}}
@@ -924,7 +928,6 @@ export const Phone = forwardRef(
)}
</Box>
);
}
);
};
export default Phone;
+50
View File
@@ -48,6 +48,9 @@ function AccountForm({
const [accountSid, setAccountSid] = useState<string | undefined>("");
const [isCredentialOk, setIsCredentialOk] = useState<boolean>(false);
const [isAdvancedMode, setIsAdvancedMode] = useState<boolean>(false);
const [noiseIsolationVendor, setNoiseIsolationVendor] = useState("krisp");
const [noiseIsolationLevel, setNoiseIsolationLevel] = useState("0.3");
const [noiseIsolationModel, setNoiseIsolationModel] = useState("");
const toast = useToast();
useEffect(
@@ -62,6 +65,13 @@ function AccountForm({
setAccountSid(formData.decoded.accountSid);
setApiKey(formData.decoded.apiKey || "");
setApiServer(formData.decoded.apiServer);
setNoiseIsolationVendor(formData.decoded.noiseIsolationVendor || "krisp");
setNoiseIsolationLevel(
formData.decoded.noiseIsolationLevel !== undefined
? String(formData.decoded.noiseIsolationLevel)
: "0.3"
);
setNoiseIsolationModel(formData.decoded.noiseIsolationModel || "");
if (
formData.decoded.accountSid ||
@@ -101,6 +111,9 @@ function AccountForm({
accountSid,
apiKey,
apiServer: apiServer ? normalizeUrl(apiServer) : "",
noiseIsolationVendor: noiseIsolationVendor || "krisp",
noiseIsolationLevel: parseFloat(noiseIsolationLevel) || 0.3,
noiseIsolationModel: noiseIsolationModel || undefined,
};
formData ? editSettings(settings, formData.id) : saveSettings(settings);
@@ -146,6 +159,9 @@ function AccountForm({
setApiServer("");
setAccountSid("");
setIsAdvancedMode(false);
setNoiseIsolationVendor("krisp");
setNoiseIsolationLevel("0.3");
setNoiseIsolationModel("");
if (formData) {
handleClose && handleClose();
@@ -282,6 +298,40 @@ function AccountForm({
)}
</HStack>
)}
<Text fontSize="13px" fontWeight="bold" mt={2}>
Noise Isolation
</Text>
<FormControl id={`noise_vendor${inputUniqueId}`}>
<FormLabel>Vendor</FormLabel>
<Input
type="text"
placeholder="krisp"
value={noiseIsolationVendor}
onChange={(e) => setNoiseIsolationVendor(e.target.value)}
/>
</FormControl>
<FormControl id={`noise_level${inputUniqueId}`}>
<FormLabel>Level</FormLabel>
<Input
type="number"
step="0.1"
min="0"
max="1"
placeholder="0.3"
value={noiseIsolationLevel}
onChange={(e) => setNoiseIsolationLevel(e.target.value)}
/>
</FormControl>
<FormControl id={`noise_model${inputUniqueId}`}>
<FormLabel>Model (Optional)</FormLabel>
<Input
type="text"
placeholder="Model name"
value={noiseIsolationModel}
onChange={(e) => setNoiseIsolationModel(e.target.value)}
/>
</FormControl>
</VStack>
</AnimateOnShow>
)}