From 9fee6fb787980b4fe894cd262c7e97d509ba9c54 Mon Sep 17 00:00:00 2001 From: Hoan HL Date: Thu, 2 Apr 2026 15:23:58 +0700 Subject: [PATCH 1/2] use @jambonz/client-sdk-web and enable noiseIsolation feature --- package-lock.json | 55 +- package.json | 2 +- public/audios/dtmf-hash.mp3 | Bin 0 -> 1728 bytes public/audios/dtmf-star.mp3 | Bin 0 -> 1800 bytes src/common/types.ts | 12 +- src/lib/SipAudioElements.ts | 52 +- src/lib/SipSession.ts | 311 ----- src/lib/SipSessionManager.ts | 100 -- src/lib/SipUA.ts | 219 ---- src/lib/index.ts | 17 +- src/lib/sip-constants.ts | 20 - src/lib/sip-models.ts | 45 - src/lib/sip-utils.ts | 28 - src/utils/index.ts | 17 +- src/window/app.tsx | 25 +- src/window/footer/footer.tsx | 12 +- src/window/history/call-history-item.tsx | 4 +- src/window/history/recent.tsx | 33 +- src/window/phone/DialPadSoundElement.ts | 20 +- src/window/phone/conference.tsx | 45 +- src/window/phone/dial-pad.tsx | 29 +- src/window/phone/index.tsx | 1490 +++++++++++----------- 22 files changed, 881 insertions(+), 1655 deletions(-) create mode 100644 public/audios/dtmf-hash.mp3 create mode 100644 public/audios/dtmf-star.mp3 delete mode 100644 src/lib/SipSession.ts delete mode 100644 src/lib/SipSessionManager.ts delete mode 100644 src/lib/SipUA.ts delete mode 100644 src/lib/sip-constants.ts delete mode 100644 src/lib/sip-models.ts delete mode 100644 src/lib/sip-utils.ts diff --git a/package-lock.json b/package-lock.json index dd43260..ef62cba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 9b74ee7..e04dfa4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/audios/dtmf-hash.mp3 b/public/audios/dtmf-hash.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..7bbcc511517eb4b8a0a6e4931f8a7ecb673f36f0 GIT binary patch literal 1728 zcmchXYfw{16vy}GhMNH4rND$iBuG#sz+53zs8w#YbbtXt@DUL4iWTG`rbr1`Yi{rX zSbQLe!3Prr6;WFiv_PxeL{Jo8P*HrK!O#?_kEn5kMA$|GU^yc6QIsncZ{t zKL=_;TfyImQZrLbYa5f90f5nf_&4jup<*1g;{eCObXRo5=aC`)zB6!~>1o=2z=6$Y z&~5-xzki2r>ZHWK-2eLwHKJC)H@{y3=h2|FhFYQnP$KageW)Mp?d`RMh0je46ebXq zUE{+~o;77A1UsjvA99)J?{s}lKn=6uZmwnW((G8#(`Ox0O_q_nVykJ`>t8lL7=tY@ zjAQ-&1$`Qydk2&6$SUs?d-xSM4mzIh**{CtXCMgMLPWR0l{IFwNj(Bz{~={5-$K?) zZm{yLS}q-t)D>XjC2YWmJm=79SY%T!_(9$>8nbp{m&VGzG9BubhJg~7%X+_6@Kr^| zdiND2tD5g+dO$cv$JH`f90Y7`Q-^V!iNPOSq^BaX)hdj-eWIq<I%hSHdq`P)(57KDq9smpO&3mIxckC*VeOlfZ=1D0|73K@X{cLfxN$b%oyI-V)wlqBI&8N~()u9YHHRvs@b(BTXk zL6LSA@VDC}LCp&VVg_$AudLoZ#^6`Cqhg3Sj~{c&z98%Gu(lL$HID{U^5B@2nU9Vw&JDb7ajo~(&7R0* z)o?UBL#0|U3^jqR?Vd)}LMN&bY_3t+$U>*KIKeTD%W+OG(T6=_Z}15rDf!JHO)##-Etzc@KTTFn&-R+{PEP>gSN!g%x12`jLh6j9t%?=4;y zK-Q8qg(8uw6v6~Nb3{xRBEq(23z4l^9<`An7K0}WYc}PHPbKc-j>-T$UNr|_r#uBU zt6N{BS*p@1_Kz?ftpk;WLoMwLik5y`V5**6z%!PDFGw~3-oTk7bK1ZiN+=hrHd%+N zj$)y7B)XZ-W8gFkL^PlZHrGsH;{c}N2!SoffB#T__%6}SzUFjC*7x>uX_`A82&xFA zr?fQx)na~U^~UvGl~2EUuy-h-K3SVkKWR_Q{Ma)g!h%^|DK_rR({BzGU25{VR6js6 zz~s)DFI_Dt=I&%u51KwyPiFgxvK}1{uiW@+#dqB#&a>rU42l91LU{c<9>2o(TKD}n z#vaHhqWO=X{K8YS5rsE0*MLyEAd^Vs7_z;y3=1l0=k^QKD@5VQHa-Ij!IzVf2N5Gg#F11e{vG!S6f?r1Vamjwklk+6!Bm2?EWm(__s7Z3IHZFu>PNXxdf$4Ft!A635>l&(IGMG{JeP>hH@82Sw`cK ziC<36;Q_>Uvh?ot77kC$1AQNy4apn9-&eEi8;19RNS2-Abf*t&@5Zv==C zc+X0s54cqf^gb@=m}=mG@*7tgR{3oFpzJt0)`IxhpsAZ`{2qu4kD8IF+xef0vfWjz z9SV0BPnT19Zuisag4oAr?jIATJ2@0eeVw~8fx~aB17gfWv|LO@A_E^1?t3*SV0rQA z0{yN`nQBLAX^FK-pYD(oiU1)4>KQ0PZh-35A|8ag`GaUi6bOQcut?L6#H17(A1K>r zH@Xu0E)|ZN-jcV*@sgLpXRLM#UCpS2?dSa=G}r$d@T_LR?#*)OG^d`iA}o~9{SKH{dsv;)t+q? z)9U0^e(%QPW93jjNf<`@0 zM;=yWS0C4LG7S-}zig|H_xHaYozm0Z*|y;%zb(Gw%(1XnEmLLtf4(JCw_oWQt4>P& z^ZHbF_mijFPTm3FqXr16l3*l%!mE*G3EUD1$*@HN%kNQ+1b6h^LJ%o*xxIx{j2!kx zCmVDa0Ip_1%bsJI0S=LO-GA7A zmhD4G01ao+HGrA;&KK94)MBfwro7Viw?By<=M){49&L)W<`m|QbTVUaoEh#)DZ5r# zs1OZinc)?tL11(i8pGZ0bbd`Q6<}$qP8CWHgb!;kjt2*pl$E>LhfOA}) zB4$`J1GQY$J&!R(y1nEA$J~`C($egfg&X^)JwL1U>YBx|1^s){DT0T@8~!e9Xn z{IL5}=7jR}9Uu&*BT5pRgvFYKZAScggvqF>C<#F_M0WIG)wTS_fwo%*@2=1n=Wh^R z86b+1!q&ifKG`*tzc<1pOrm<0ZbLQ?m)lU~@)UgD?1AOvg1>$eZ&B>2{#J+9rwzRy zVc%DRhodPdPQp1hE{u5W5^csb)}7~QVa@8w^WDok%?;bn)1jm94NpAGUD#ZokUBU^ z%g}t`Xt4V}TP5TP z{sw%83fKacNr;?;qhxj?1G58-G#V3h@d?1WDACxWk`M+m_4=h@>(f_nTGH06YDq5r zHK({tXud$z0~o~*G?qM` zCcIL=mm&}zIX`DDs*wWob2@on4AWfp{d7C?nTX9ScOP z;aFUp@s_9*Rzd@n7Gji=MdMEHN}{a0gc=!*v3#90$+I_*`s{<$g3w2-N921(3-g_H zeKLki5Wze>V<)=@;foW2QBSNrBlx)x>eVN{8S#yc@Y?AXSakE<4UYd;pjM#j=Z<;u OI*U7yf35s~CI14R)7mxw literal 0 HcmV?d00001 diff --git a/src/common/types.ts b/src/common/types.ts index a0635e4..e2561d2 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -1,4 +1,5 @@ import { ConferenceModes } from "src/api/types"; +import type { ClientState } from "@jambonz/client-sdk-web"; export interface LoginCredential { name?: string; @@ -97,12 +98,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"; diff --git a/src/lib/SipAudioElements.ts b/src/lib/SipAudioElements.ts index e85a156..4b07e4b 100644 --- a/src/lib/SipAudioElements.ts +++ b/src/lib/SipAudioElements.ts @@ -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; } } diff --git a/src/lib/SipSession.ts b/src/lib/SipSession.ts deleted file mode 100644 index 333a18e..0000000 --- a/src/lib/SipSession.ts +++ /dev/null @@ -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 }); - } -} diff --git a/src/lib/SipSessionManager.ts b/src/lib/SipSessionManager.ts deleted file mode 100644 index 4e96afd..0000000 --- a/src/lib/SipSessionManager.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { SipSession, SipModel, SipConstants } from "./index"; - -export default class SipSessionManager { - #sessions: Map; - - 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; - } -} diff --git a/src/lib/SipUA.ts b/src/lib/SipUA.ts deleted file mode 100644 index cc05851..0000000 --- a/src/lib/SipUA.ts +++ /dev/null @@ -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(); - } -} diff --git a/src/lib/index.ts b/src/lib/index.ts index 31c1ef4..d470a4e 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -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 -} \ No newline at end of file +export { SipAudioElements }; diff --git a/src/lib/sip-constants.ts b/src/lib/sip-constants.ts deleted file mode 100644 index 9ef15ea..0000000 --- a/src/lib/sip-constants.ts +++ /dev/null @@ -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"; diff --git a/src/lib/sip-models.ts b/src/lib/sip-models.ts deleted file mode 100644 index df2b3d9..0000000 --- a/src/lib/sip-models.ts +++ /dev/null @@ -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[]; -} diff --git a/src/lib/sip-utils.ts b/src/lib/sip-utils.ts deleted file mode 100644 index f820d2e..0000000 --- a/src/lib/sip-utils.ts +++ /dev/null @@ -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 }; diff --git a/src/utils/index.ts b/src/utils/index.ts index ba4bed5..0d24641 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -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 => { diff --git a/src/window/app.tsx b/src/window/app.tsx index f2a9b48..78a7800 100644 --- a/src/window/app.tsx +++ b/src/window/app.tsx @@ -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("stop"); + const [status, setStatus] = useState(ClientState.Disconnected); const [allSettings, setAllSettings] = useState([]); const [advancedSettings, setAdvancedSettings] = useState( 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"); - } else { - phoneSipAschildRef.current.updateGoOffline("start"); - } + if (s === ClientState.Unregistered) { + setIsUserOffline(true); + } else { + 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: ( { reload={loadSettings} setIsSwitchingUserStatus={setIsSwitchingUserStatus} setIsOnline={setIsOnline} + isUserOffline={isUserOffline} /> ), }, diff --git a/src/window/footer/footer.tsx b/src/window/footer/footer.tsx index bf3ab34..9159792 100644 --- a/src/window/footer/footer.tsx +++ b/src/window/footer/footer.tsx @@ -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 + ); }} /> You are {isOnline ? "online" : "offline"} diff --git a/src/window/history/call-history-item.tsx b/src/window/history/call-history-item.tsx index 7317167..d844a0d 100644 --- a/src/window/history/call-history-item.tsx +++ b/src/window/history/call-history-item.tsx @@ -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; diff --git a/src/window/history/recent.tsx b/src/window/history/recent.tsx index 8d06034..36ecae8 100644 --- a/src/window/history/recent.tsx +++ b/src/window/history/recent.tsx @@ -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,27 +19,22 @@ export const Recents = ({ onDataChange, onCallNumber, }: RecentsProbs) => { - const [callHistories, setCallHistories] = useState(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 ( diff --git a/src/window/phone/DialPadSoundElement.ts b/src/window/phone/DialPadSoundElement.ts index 515b56d..a4eec78 100644 --- a/src/window/phone/DialPadSoundElement.ts +++ b/src/window/phone/DialPadSoundElement.ts @@ -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 = { + "*": "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; } diff --git a/src/window/phone/conference.tsx b/src/window/phone/conference.tsx index d26066c..1370f41 100644 --- a/src/window/phone/conference.tsx +++ b/src/window/phone/conference.tsx @@ -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 = ({ {callDuration > 0 && ( - {new Date(callDuration * 1000).toISOString().substr(11, 8)} + {new Date(callDuration * 1000).toISOString().substring(11, 19)} )} @@ -163,12 +175,7 @@ export const JoinConference = ({ { - 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 }); }} /> diff --git a/src/window/phone/dial-pad.tsx b/src/window/phone/dial-pad.tsx index b41eb59..ca16a13 100644 --- a/src/window/phone/dial-pad.tsx +++ b/src/window/phone/dial-pad.tsx @@ -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,17 +8,24 @@ type DialPadProbs = { const keySounds = new DialPadAudioElements(); +const buttons = [ + ["1", "2", "3"], + ["4", "5", "6"], + ["7", "8", "9"], + ["*", "0", "#"], +]; + export const DialPad = ({ handleDigitPress }: DialPadProbs) => { const selfRef = useRef(null); const isVisibleRef = useRef(false); - const buttons = [ - ["1", "2", "3"], - ["4", "5", "6"], - ["7", "8", "9"], - ["*", "0", "#"], - ]; + const handleDigitPressRef = useRef(handleDigitPress); - const handleKeyDown = (e: KeyboardEvent) => { + // 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 ( diff --git a/src/window/phone/index.tsx b/src/window/phone/index.tsx index c6b361a..f39ba12 100644 --- a/src/window/phone/index.tsx +++ b/src/window/phone/index.tsx @@ -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>; setIsOnline: React.Dispatch>; + isUserOffline: boolean; }; enum PAGE_VIEW { @@ -99,749 +105,725 @@ enum PAGE_VIEW { JOIN_CONFERENCE, } -export const Phone = forwardRef( - ( - { - sipDomain, - sipServerAddress, - sipUsername, - sipPassword, - sipDisplayName, - stat: [status, setStatus], - calledNumber: [calledANumber, setCalledANumber], - calledName: [calledAName, setCalledAName], - advancedSettings, - allSettings, - reload, - setIsSwitchingUserStatus, - setIsOnline, - }: PhoneProbs, - ref: any - ) => { - const [inputNumber, setInputNumber] = useState(""); - const [appName, setAppName] = useState(""); - const [callStatus, setCallStatus] = useState(SipConstants.SESSION_ENDED); - const [sessionDirection, setSessionDirection] = - useState(""); - const [seconds, setSeconds] = useState(0); - const [isCallButtonLoading, setIsCallButtonLoading] = useState(false); - const [isAdvanceMode, setIsAdvancedMode] = useState(false); - const [pageView, setPageView] = useState(PAGE_VIEW.DIAL_PAD); - const [registeredUser, setRegisteredUser] = useState< - Partial - >({ - allow_direct_app_calling: false, - allow_direct_queue_calling: false, - allow_direct_user_calling: false, - }); - const [selectedConference, setSelectedConference] = useState(""); - const [callSid, setCallSid] = useState(""); - const [showConference, setShowConference] = useState(false); +export const Phone = ({ + sipDomain, + sipServerAddress, + sipUsername, + sipPassword, + sipDisplayName, + stat: [, setStatus], + calledNumber: [calledANumber, setCalledANumber], + calledName: [calledAName, setCalledAName], + advancedSettings, + allSettings, + reload, + setIsSwitchingUserStatus, + setIsOnline, + isUserOffline, +}: PhoneProbs) => { + const [inputNumber, setInputNumber] = useState(""); + const [appName, setAppName] = useState(""); + const [seconds, setSeconds] = useState(0); + const [isCallButtonLoading, setIsCallButtonLoading] = useState(false); + const [isAdvanceMode, setIsAdvancedMode] = useState(false); + const [pageView, setPageView] = useState(PAGE_VIEW.DIAL_PAD); + const [registeredUser, setRegisteredUser] = useState< + Partial + >({ + allow_direct_app_calling: false, + allow_direct_queue_calling: false, + allow_direct_user_calling: false, + }); + const [selectedConference, setSelectedConference] = useState(""); + const [callSidState, setCallSidState] = useState(""); + const [showConference, setShowConference] = useState(false); + const [showAccounts, setShowAccounts] = useState(false); + const [isNoiseIsolation, setIsNoiseIsolation] = useState(false); - const [showAccounts, setShowAccounts] = useState(false); + const timerRef = useRef(null); + const FetchUsertimerRef = useRef(null); + const isInputNumberFocusRef = useRef(false); + const accountsCardRef = useRef(null); + const audioRef = useRef(new SipAudioElements()); + const prevIsRegisteredRef = useRef(false); + const sipUsernameRef = useRef(sipUsername); - const inputNumberRef = useRef(inputNumber); - const sessionDirectionRef = useRef(sessionDirection); - const sipUA = useRef(null); - const timerRef = useRef(null); - const FetchUsertimerRef = useRef(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(null); + const toast = useToast(); - const toast = useToast(); + // Keep sipUsername ref up to date + useEffect(() => { + sipUsernameRef.current = sipUsername; + }, [sipUsername]); - useImperativeHandle(ref, () => ({ - updateGoOffline(newState: string) { - if (newState === "stop") { - sipUA.current?.stop(); - } else { - sipUA.current?.start(); - } - }, - })); + // 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 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(); - }, [sipUsername]); + const hasCredentials = !!(sipDomain && sipUsername && sipPassword && sipServerAddress); - const startCallDurationCounter = useCallback(() => { - stopCallDurationCounter(); - timerRef.current = setInterval(() => { - setSeconds((seconds) => seconds + 1); - }, 1000); - }, []); + const { + client, + state: clientState, + isRegistered, + connect, + disconnect, + error: clientError, + } = useJambonzClient(clientOptions); - const createSipClient = useCallback(() => { + 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"); + connect().catch((err) => { + setIsSwitchingUserStatus(false); setIsOnline(false); - setIsSwitchingUserStatus(false); - if (sipUA.current) { - sipUA.current.stop(); - } - - if (args.error) { - toast({ - title: `Cannot connect to ${sipServerAddressRef.current}${ - args.reason ? `, ${args.reason}` : "" - }`, - 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") { - saveCurrentCall({ - number: args.session.user, - direction: args.session.direction, - timeStamp: Date.now(), - duration: "0", - callSid: uuidv4(), - }); - } - setCallStatus(SipConstants.SESSION_RINGING); - setSessionDirection(args.session.direction); - setInputNumber(args.session.user); - }); - sipClient.on(SipConstants.SESSION_ANSWERED, (args) => { - setCallSid(args.callSid); - const currentCall = getCurrentCall(); - if (currentCall) { - currentCall.timeStamp = Date.now(); - saveCurrentCall(currentCall); - } - setCallStatus(SipConstants.SESSION_ANSWERED); - startCallDurationCounter(); - }); - sipClient.on(SipConstants.SESSION_ENDED, (args) => { - 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, - ]); - - 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, - ]); - - 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 ( - !( - calledANumber.startsWith("app-") || - calledANumber.startsWith("queue-") || - calledANumber.startsWith("conference-") - ) - ) { - setInputNumber(calledANumber); - } - - setAppName(calledAName); - makeOutboundCall(calledANumber, calledAName); - setCalledANumber(""); - setCalledAName(""); - } - }, [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); - FetchUsertimerRef.current = null; - } - }; - - useEffect(() => { - if (isAdvanceMode) { - // check conference aibility - getConferences() - .then(() => { - setShowConference(true); - }) - .catch(() => { - setShowConference(false); - }); - FetchUsertimerRef.current = setInterval(() => { - fetchRegisterUser(); - }, 10_000); - } else { - clearFetchUserTimer(); - setShowConference(false); - } - }, [isAdvanceMode]); - - useEffect(() => { - if (showAccounts) { - document.addEventListener("mousedown", handleClickOutside); - } else { - document.removeEventListener("mousedown", handleClickOutside); - } - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [showAccounts]); - - const fetchRegisterUser = () => { - getSelfRegisteredUser(sipUsernameRef.current) - .then(({ json }) => { - setRegisteredUser(json); - }) - .catch((err) => { - setRegisteredUser({ - allow_direct_app_calling: false, - allow_direct_queue_calling: false, - allow_direct_user_calling: false, - }); + toast({ + title: `Cannot connect to ${sipServerAddress}${err?.message ? `: ${err.message}` : ""}`, + status: "warning", + duration: DEFAULT_TOAST_DURATION, + isClosable: true, }); - }; + }); + } else { + disconnect(); + } + }, [hasCredentials, isUserOffline, sipDomain, sipUsername, sipPassword, sipServerAddress, sipDisplayName]); - function stopCallDurationCounter() { + // Cleanup on unmount + useEffect(() => { + return () => { + disconnect(); if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; - setSeconds(0); } + 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); } - function transform(t1: number, t2: number) { - const diff = Math.abs(t1 - t2) / 1000; // Get the difference in seconds - - 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 hours1 = hours < 10 ? "0" + hours : hours; - const minutes1 = minutes < 10 ? "0" + minutes : minutes; - const seconds1 = seconds < 10 ? "0" + seconds : seconds; - - return `${hours1}:${minutes1}:${seconds1}`; - } - - const handleDialPadClick = (value: string, fromKeyboad: boolean) => { - if (!(isInputNumberFocusRef.current && fromKeyboad)) { - setInputNumber((prev) => prev + value); - } - - if (isSipClientAnswered(callStatus)) { - sipUA.current?.dtmf(value, undefined); - } - }; - - const handleCallButtion = () => { - makeOutboundCall(inputNumber); - }; - - const makeOutboundCall = (number: string, name: string = "") => { - if (sipUA.current && number) { - setIsCallButtonLoading(true); - setCallStatus(SipConstants.SESSION_RINGING); - setSessionDirection("outgoing"); + if (callState === JambonzCallState.Ringing || callState === JambonzCallState.Connecting) { + if (incomingCaller) { + // Incoming call + setInputNumber(incomingCaller); + setPageView(PAGE_VIEW.INCOMING_CALL); + audioRef.current.playRinging(); saveCurrentCall({ - number: number, - name, - direction: "outgoing", + number: incomingCaller, + direction: "inbound", 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); + } else { + // Outgoing call ringing + setPageView(PAGE_VIEW.OUTGOING_CALL); + audioRef.current.playRingback(); } - }; - - const clientGoOffline = () => { - if (sipUA.current) { - sipUA.current.stop(); - sipUA.current = null; + } else if (callState === JambonzCallState.Connected) { + audioRef.current.playAnswer(); + const currentCall = getCurrentCall(); + if (currentCall) { + currentCall.timeStamp = Date.now(); + saveCurrentCall(currentCall); } - }; - - const handleHangup = () => { - if (isSipClientAnswered(callStatus) || isSipClientRinging(callStatus)) { - sipUA.current?.terminate(480, "Call Finished", undefined); + if (selectedConference) { + setPageView(PAGE_VIEW.JOIN_CONFERENCE); + } else { + setPageView(PAGE_VIEW.DIAL_PAD); } - }; - - const handleCallOnHold = () => { - if (isSipClientAnswered(callStatus)) { - if (sipUA.current?.isHolded(undefined)) { - sipUA.current?.unhold(undefined); - } else { - sipUA.current?.hold(undefined); - } + startCallDurationCounter(); + } 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(); + stopCallDurationCounter(); + setSelectedConference(""); + setIsNoiseIsolation(false); + setPageView(PAGE_VIEW.DIAL_PAD); } + } + }, [callState, incomingCaller, selectedConference]); + + // Handle call failed event from the call object + useEffect(() => { + if (!activeCall) return; + + const onFailed = () => { + audioRef.current.playFailed(); + addCallHistory(); + stopCallDurationCounter(); + setSelectedConference(""); + setPageView(PAGE_VIEW.DIAL_PAD); }; - const handleCallMute = () => { - if (isSipClientAnswered(callStatus)) { - if (sipUA.current?.isMuted(undefined)) { - sipUA.current?.unmute(undefined); - } else { - sipUA.current?.mute(undefined); - } - } + 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); }; - const handleAnswer = () => { - if (isSipClientRinging(callStatus)) { - sipUA.current?.answer(undefined); - } + activeCall.on("accepted", onAccepted); + return () => { + activeCall.off("accepted", onAccepted); }; + }, [activeCall]); - const handleDecline = () => { - if (isSipClientRinging(callStatus)) { - sipUA.current?.terminate(486, "Busy here", undefined); - } - }; + useEffect(() => { + setIsAdvancedMode(!!advancedSettings?.decoded?.accountSid); + fetchRegisterUser(); + }, [advancedSettings]); - const isStatusRegistered = () => { - return status === "registered"; - }; - - const handleSetActive = (id: number) => { - setActiveSettings(id); - setShowAccounts(false); - reload(); - }; - - const handleClickOutside = (event: Event) => { - const target = event.target as Node; + useEffect(() => { + if (calledANumber) { if ( - accountsCardRef.current && - !accountsCardRef.current.contains(target) + !( + calledANumber.startsWith("app-") || + calledANumber.startsWith("queue-") || + calledANumber.startsWith("conference-") + ) ) { - setShowAccounts(false); + setInputNumber(calledANumber); } + + setAppName(calledAName); + makeOutboundCall(calledANumber, calledAName); + setCalledANumber(""); + setCalledAName(""); + } + }, [calledANumber, calledAName, setCalledAName, setCalledANumber]); + + const clearFetchUserTimer = () => { + if (FetchUsertimerRef.current) { + clearInterval(FetchUsertimerRef.current); + FetchUsertimerRef.current = null; + } + }; + + useEffect(() => { + if (isAdvanceMode) { + getConferences() + .then(() => { + setShowConference(true); + }) + .catch(() => { + setShowConference(false); + }); + clearFetchUserTimer(); + FetchUsertimerRef.current = setInterval(() => { + fetchRegisterUser(); + }, 10_000); + } else { + clearFetchUserTimer(); + setShowConference(false); + } + return () => clearFetchUserTimer(); + }, [isAdvanceMode]); + + useEffect(() => { + if (showAccounts) { + document.addEventListener("mousedown", handleClickOutside); + } else { + document.removeEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); }; + }, [showAccounts]); - return ( - - {allSettings.length >= 1 ? ( - <> - - Account - - - { - setShowAccounts(true)} - _hover={{ - cursor: "pointer", - }} - spacing={2} - boxShadow="md" - w="full" - borderRadius={5} - paddingY={2} - paddingX={3.5} - > - {sipUsername && sipDomain ? ( - <> - - - - - {sipDisplayName || sipUsername} - - - - - {`${sipUsername}@${sipDomain}`} + const fetchRegisterUser = () => { + getSelfRegisteredUser(sipUsernameRef.current) + .then(({ json }) => { + setRegisteredUser(json); + }) + .catch(() => { + setRegisteredUser({ + allow_direct_app_calling: false, + allow_direct_queue_calling: false, + allow_direct_user_calling: false, + }); + }); + }; + + 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); + timerRef.current = null; + setSeconds(0); + } + } + + function transform(t1: number, t2: number) { + const diff = Math.abs(t1 - t2) / 1000; + const hours = Math.floor(diff / 3600); + const minutes = Math.floor((diff % 3600) / 60); + const secs = Math.floor(diff % 60); + const hours1 = hours < 10 ? "0" + hours : hours; + const minutes1 = minutes < 10 ? "0" + minutes : minutes; + const seconds1 = secs < 10 ? "0" + secs : secs; + return `${hours1}:${minutes1}:${seconds1}`; + } + + const handleDialPadClick = (value: string, fromKeyboard: boolean) => { + if (!(isInputNumberFocusRef.current && fromKeyboard)) { + setInputNumber((prev) => prev + value); + } + + if (isSipClientAnswered(callState)) { + sendDtmf(value); + } + }; + + const handleCallButton = () => { + makeOutboundCall(inputNumber); + }; + + const makeOutboundCall = (number: string, name: string = "") => { + if (client && isRegistered && number) { + setIsCallButtonLoading(true); + saveCurrentCall({ + number: number, + name, + direction: "outbound", + timeStamp: Date.now(), + duration: "0", + callSid: uuidv4(), + }); + + // 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 = () => { + audioRef.current.stopAll(); + hangup(); + }; + + const handleCallOnHold = () => { + if (isSipClientAnswered(callState)) { + toggleHold(); + } + }; + + const handleCallMute = () => { + if (isSipClientAnswered(callState)) { + toggleMute(); + } + }; + + const handleNoiseIsolation = () => { + if (isSipClientAnswered(callState) && activeCall) { + if (isNoiseIsolation) { + activeCall.disableNoiseIsolation(); + setIsNoiseIsolation(false); + } else { + activeCall.enableNoiseIsolation(); + setIsNoiseIsolation(true); + } + } + }; + + const handleAnswer = () => { + audioRef.current.pauseRinging(); + answerIncoming(); + }; + + const handleDecline = () => { + audioRef.current.stopAll(); + declineIncoming(); + }; + + const handleSetActive = (id: number) => { + setActiveSettings(id); + setShowAccounts(false); + reload(); + }; + + const handleClickOutside = (event: Event) => { + const target = event.target as Node; + if ( + accountsCardRef.current && + !accountsCardRef.current.contains(target) + ) { + setShowAccounts(false); + } + }; + + return ( + + {allSettings.length >= 1 ? ( + <> + + Account + + + { + setShowAccounts(true)} + _hover={{ + cursor: "pointer", + }} + spacing={2} + boxShadow="md" + w="full" + borderRadius={5} + paddingY={2} + paddingX={3.5} + > + {sipUsername && sipDomain ? ( + <> + + + + + {sipDisplayName || sipUsername} - + + + + {`${sipUsername}@${sipDomain}`} + + - - - - - - ) : ( - Select Account - )} - - } - {showAccounts && ( - - - - )} - - - ) : ( - - Go to Settings to configure your account - - )} - {pageView === PAGE_VIEW.DIAL_PAD && ( - - {isAdvanceMode && isSipClientIdle(callStatus) && ( - - {registeredUser.allow_direct_user_calling && ( - } - tooltip="Call an online user" - noResultLabel="No one else is online" - onClick={(_, value) => { - setInputNumber(value); - makeOutboundCall(value); - }} - onOpen={() => { - return new Promise( - (resolve, reject) => { - getRegisteredUser() - .then(({ json }) => { - const sortedUsers = json.sort((a, b) => - a.localeCompare(b) - ); - resolve( - sortedUsers - .filter((u) => !u.includes(sipUsername)) - .map((u) => { - const uName = u.match(/(^.*)@.*/); - return { - name: uName ? uName[1] : u, - value: uName ? uName[1] : u, - }; - }) - ); - }) - .catch((err) => reject(err)); - } - ); - }} - /> - )} - - {registeredUser.allow_direct_queue_calling && ( - } - tooltip="Take a call from queue" - noResultLabel="No calls in queue" - onClick={(name, value) => { - setAppName(`Queue ${name}`); - const calledQueue = `queue-${value}`; - setInputNumber(""); - makeOutboundCall(calledQueue, `Queue ${name}`); - }} - onOpen={() => { - return new Promise( - (resolve, reject) => { - getQueues() - .then(({ json }) => { - const sortedQueues = json.sort((a, b) => - a.name.localeCompare(b.name) - ); - resolve( - sortedQueues.map((q) => ({ - name: `${q.name} (${q.length})`, - value: q.name, - })) - ); - }) - .catch((err) => reject(err)); - } - ); - }} - /> - )} - - {registeredUser.allow_direct_app_calling && ( - } - tooltip="Call an application" - noResultLabel="No applications" - onClick={(name, value) => { - setAppName(`App ${name}`); - const calledAppId = `app-${value}`; - setInputNumber(""); - makeOutboundCall(calledAppId, `App ${name}`); - }} - onOpen={() => { - return new Promise( - (resolve, reject) => { - getApplications() - .then(({ json }) => { - const sortedApps = json.sort((a, b) => - a.name.localeCompare(b.name) - ); - resolve( - sortedApps.map((a) => ({ - name: a.name, - value: a.application_sid, - })) - ); - }) - .catch((err) => reject(err)); - } - ); - }} - /> - )} - {registeredUser.allow_direct_app_calling && showConference && ( - } - tooltip="Join a conference" - noResultLabel="No conference" - onClick={(name, value) => { - setPageView(PAGE_VIEW.JOIN_CONFERENCE); - setSelectedConference( - value === PAGE_VIEW.JOIN_CONFERENCE.toString() - ? "" - : value - ); - }} - onOpen={() => { - return new Promise( - (resolve, reject) => { - getConferences() - .then(({ json }) => { - const sortedApps = json.sort((a, b) => - a.localeCompare(b) - ); - resolve([ - { - name: "Start new conference", - value: PAGE_VIEW.JOIN_CONFERENCE.toString(), - }, - ...sortedApps.map((a) => ({ - name: a, - value: a, - })), - ]); - }) - .catch((err) => reject(err)); - } - ); - }} - /> + + + + + + ) : ( + Select Account )} + } + {showAccounts && ( + + + )} + + + ) : ( + + Go to Settings to configure your account + + )} + {pageView === PAGE_VIEW.DIAL_PAD && ( + + {isAdvanceMode && isSipClientIdle(callState) && ( + + {registeredUser.allow_direct_user_calling && ( + } + tooltip="Call an online user" + noResultLabel="No one else is online" + onClick={(_, value) => { + setInputNumber(value); + makeOutboundCall(value); + }} + onOpen={() => { + return new Promise( + (resolve, reject) => { + getRegisteredUser() + .then(({ json }) => { + const sortedUsers = json.sort((a, b) => + a.localeCompare(b) + ); + resolve( + sortedUsers + .filter((u) => !u.includes(sipUsername)) + .map((u) => { + const uName = u.match(/(^.*)@.*/); + return { + name: uName ? uName[1] : u, + value: uName ? uName[1] : u, + }; + }) + ); + }) + .catch((err) => reject(err)); + } + ); + }} + /> + )} - { - setInputNumber(e.target.value); - }} - onFocus={() => { - isInputNumberFocusRef.current = true; - }} - onBlur={() => { - isInputNumberFocusRef.current = false; - }} - textAlign="center" - isReadOnly={!isSipClientIdle(callStatus)} - /> + {registeredUser.allow_direct_queue_calling && ( + } + tooltip="Take a call from queue" + noResultLabel="No calls in queue" + onClick={(name, value) => { + setAppName(`Queue ${name}`); + const calledQueue = `queue-${value}`; + setInputNumber(""); + makeOutboundCall(calledQueue, `Queue ${name}`); + }} + onOpen={() => { + return new Promise( + (resolve, reject) => { + getQueues() + .then(({ json }) => { + const sortedQueues = json.sort((a, b) => + a.name.localeCompare(b.name) + ); + resolve( + sortedQueues.map((q) => ({ + name: `${q.name} (${q.length})`, + value: q.name, + })) + ); + }) + .catch((err) => reject(err)); + } + ); + }} + /> + )} - {!isSipClientIdle(callStatus) && seconds >= 0 && ( - - {new Date(seconds * 1000).toISOString().substr(11, 8)} - - )} + {registeredUser.allow_direct_app_calling && ( + } + tooltip="Call an application" + noResultLabel="No applications" + onClick={(name, value) => { + setAppName(`App ${name}`); + const calledAppId = `app-${value}`; + setInputNumber(""); + makeOutboundCall(calledAppId, `App ${name}`); + }} + onOpen={() => { + return new Promise( + (resolve, reject) => { + getApplications() + .then(({ json }) => { + const sortedApps = json.sort((a, b) => + a.name.localeCompare(b.name) + ); + resolve( + sortedApps.map((a) => ({ + name: a.name, + value: a.application_sid, + })) + ); + }) + .catch((err) => reject(err)); + } + ); + }} + /> + )} + {registeredUser.allow_direct_app_calling && showConference && ( + } + tooltip="Join a conference" + noResultLabel="No conference" + onClick={(name, value) => { + setPageView(PAGE_VIEW.JOIN_CONFERENCE); + setSelectedConference( + value === PAGE_VIEW.JOIN_CONFERENCE.toString() + ? "" + : value + ); + }} + onOpen={() => { + return new Promise( + (resolve, reject) => { + getConferences() + .then(({ json }) => { + const sortedApps = json.sort((a, b) => + a.localeCompare(b) + ); + resolve([ + { + name: "Start new conference", + value: PAGE_VIEW.JOIN_CONFERENCE.toString(), + }, + ...sortedApps.map((a) => ({ + name: a, + value: a, + })), + ]); + }) + .catch((err) => reject(err)); + } + ); + }} + /> + )} + + )} - + { + setInputNumber(e.target.value); + }} + onFocus={() => { + isInputNumberFocusRef.current = true; + }} + onBlur={() => { + isInputNumberFocusRef.current = false; + }} + textAlign="center" + isReadOnly={!isSipClientIdle(callState)} + /> - {isSipClientIdle(callStatus) ? ( - - ) : ( + {!isSipClientIdle(callState) && seconds >= 0 && ( + + {new Date(seconds * 1000).toISOString().substring(11, 19)} + + )} + + + + {isSipClientIdle(callState) ? ( + + ) : ( + - + + } w="33%" variant="unstyled" @@ -863,18 +845,12 @@ export const Phone = forwardRef( onClick={handleHangup} /> - + } w="33%" @@ -886,45 +862,69 @@ export const Phone = forwardRef( /> - )} - - )} - {pageView === PAGE_VIEW.INCOMING_CALL && ( - - )} - {pageView === PAGE_VIEW.OUTGOING_CALL && ( - - )} - {pageView === PAGE_VIEW.JOIN_CONFERENCE && ( - { - if (isSipClientAnswered(callStatus)) { - sipUA.current?.terminate(480, "Call Finished", undefined); - } - setPageView(PAGE_VIEW.DIAL_PAD); - }} - call={(name) => { - const conference = `conference-${name}`; - setSelectedConference(name); - setInputNumber(conference); - makeOutboundCall(conference, `Conference ${name}`); - }} - /> - )} - - ); - } -); + {isSipClientAnswered(callState) && ( + + + } + variant="unstyled" + display="flex" + alignItems="center" + justifyContent="center" + color={isNoiseIsolation ? "jambonz.500" : undefined} + onClick={handleNoiseIsolation} + /> + + )} + + )} + + )} + {pageView === PAGE_VIEW.INCOMING_CALL && ( + + )} + {pageView === PAGE_VIEW.OUTGOING_CALL && ( + + )} + {pageView === PAGE_VIEW.JOIN_CONFERENCE && ( + { + if (isSipClientAnswered(callState)) { + hangup(); + } + setPageView(PAGE_VIEW.DIAL_PAD); + }} + call={(name) => { + const conference = `conference-${name}`; + setSelectedConference(name); + setInputNumber(conference); + makeOutboundCall(conference, `Conference ${name}`); + }} + /> + )} + + ); +}; export default Phone; From 0196d332d534f704f2d9fc45fe4aa6c403092368 Mon Sep 17 00:00:00 2001 From: Hoan HL Date: Fri, 3 Apr 2026 14:01:54 +0700 Subject: [PATCH 2/2] allow configuration for noise isolation and fix UI layout issue --- src/common/types.ts | 4 ++ src/window/app.tsx | 12 ++-- src/window/history/recent.tsx | 2 - src/window/phone/index.tsx | 93 +++++++++++++++-------------- src/window/settings/accountForm.tsx | 50 ++++++++++++++++ 5 files changed, 110 insertions(+), 51 deletions(-) diff --git a/src/common/types.ts b/src/common/types.ts index e2561d2..49c02a5 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -62,6 +62,10 @@ export interface AppSettings { accountSid?: string; apiKey?: string; apiServer?: string; + + noiseIsolationVendor?: string; + noiseIsolationLevel?: number; + noiseIsolationModel?: string; } export interface IAppSettings { diff --git a/src/window/app.tsx b/src/window/app.tsx index 78a7800..0aca0cf 100644 --- a/src/window/app.tsx +++ b/src/window/app.tsx @@ -110,16 +110,20 @@ export const WindowApp = () => { setCallHistories(getCallHistories(sipUsername)); }; return ( - - + + - + {tabsSettings.map((s, i) => ( { ))} - + {tabsSettings.map((s, i) => ( {s.content} ))} diff --git a/src/window/history/recent.tsx b/src/window/history/recent.tsx index 36ecae8..c067bc0 100644 --- a/src/window/history/recent.tsx +++ b/src/window/history/recent.tsx @@ -41,8 +41,6 @@ export const Recents = ({ {callHistories.length > 0 ? ( diff --git a/src/window/phone/index.tsx b/src/window/phone/index.tsx index f39ba12..259c7dd 100644 --- a/src/window/phone/index.tsx +++ b/src/window/phone/index.tsx @@ -528,7 +528,14 @@ export const Phone = ({ activeCall.disableNoiseIsolation(); setIsNoiseIsolation(false); } else { - activeCall.enableNoiseIsolation(); + const settings = advancedSettings?.decoded; + activeCall.enableNoiseIsolation({ + vendor: settings?.noiseIsolationVendor || "krisp", + level: settings?.noiseIsolationLevel ?? 0.3, + ...(settings?.noiseIsolationModel + ? { model: settings.noiseIsolationModel } + : {}), + }); setIsNoiseIsolation(true); } } @@ -817,51 +824,20 @@ export const Phone = ({ Call ) : ( - - - - - } - w="33%" - variant="unstyled" - display="flex" - alignItems="center" - justifyContent="center" - onClick={handleCallOnHold} - /> - - - + + } - w="70px" - h="70px" - borderRadius="100%" - colorScheme="jambonz" - onClick={handleHangup} + aria-label="Place call onhold" + icon={ + + } + variant="unstyled" + display="flex" + alignItems="center" + justifyContent="center" + onClick={handleCallOnHold} /> - - - - } - w="33%" - variant="unstyled" - display="flex" - alignItems="center" - justifyContent="center" - onClick={handleCallMute} - /> - - + {isSipClientAnswered(callState) && ( )} - + + + } + w="70px" + h="70px" + borderRadius="100%" + colorScheme="jambonz" + onClick={handleHangup} + /> + + + + } + variant="unstyled" + display="flex" + alignItems="center" + justifyContent="center" + onClick={handleCallMute} + /> + + )} )} diff --git a/src/window/settings/accountForm.tsx b/src/window/settings/accountForm.tsx index ff9ffb2..6f69671 100644 --- a/src/window/settings/accountForm.tsx +++ b/src/window/settings/accountForm.tsx @@ -48,6 +48,9 @@ function AccountForm({ const [accountSid, setAccountSid] = useState(""); const [isCredentialOk, setIsCredentialOk] = useState(false); const [isAdvancedMode, setIsAdvancedMode] = useState(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({ )} )} + + + Noise Isolation + + + Vendor + setNoiseIsolationVendor(e.target.value)} + /> + + + Level + setNoiseIsolationLevel(e.target.value)} + /> + + + Model (Optional) + setNoiseIsolationModel(e.target.value)} + /> + )}