mirror of
https://github.com/jambonz/chrome-extension-dialer.git
synced 2026-05-06 08:47:01 +00:00
first draft
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
# Getting Started with Create React App
|
||||||
|
|
||||||
|
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
In the project directory, you can run:
|
||||||
|
|
||||||
|
### `npm start`
|
||||||
|
|
||||||
|
Runs the app in the development mode.\
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||||
|
|
||||||
|
The page will reload if you make edits.\
|
||||||
|
You will also see any lint errors in the console.
|
||||||
|
|
||||||
|
### `npm test`
|
||||||
|
|
||||||
|
Launches the test runner in the interactive watch mode.\
|
||||||
|
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||||
|
|
||||||
|
### `npm run build`
|
||||||
|
|
||||||
|
Builds the app for production to the `build` folder.\
|
||||||
|
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||||
|
|
||||||
|
The build is minified and the filenames include the hashes.\
|
||||||
|
Your app is ready to be deployed!
|
||||||
|
|
||||||
|
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||||
|
|
||||||
|
### `npm run eject`
|
||||||
|
|
||||||
|
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||||
|
|
||||||
|
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||||
|
|
||||||
|
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||||
|
|
||||||
|
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||||
|
|
||||||
|
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Webrtc Chrome Extension",
|
||||||
|
"description": "Webrtc Chrome Extension",
|
||||||
|
"action": {
|
||||||
|
"default_popup": "js/index.html",
|
||||||
|
"default_title": "Webrtc Chrome Extension"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["<all_urls>"],
|
||||||
|
"js": ["js/content.js"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"permissions": ["activeTab", "storage"]
|
||||||
|
}
|
||||||
Generated
+19922
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "webrtc-chrome-ext",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@testing-library/jest-dom": "^5.17.0",
|
||||||
|
"@testing-library/react": "^13.4.0",
|
||||||
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"typescript": "^4.9.5",
|
||||||
|
"web-vitals": "^2.1.4",
|
||||||
|
"@chakra-ui/react": "^2.8.0",
|
||||||
|
"jssip": "^3.2.17",
|
||||||
|
"react-feather": "^2.0.10",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"google-libphonenumber": "^3.2.33"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/google-libphonenumber": "^7.4.27",
|
||||||
|
"@types/jest": "^27.5.2",
|
||||||
|
"@types/node": "^16.18.53",
|
||||||
|
"@types/react": "^18.2.22",
|
||||||
|
"@types/react-dom": "^18.2.7",
|
||||||
|
"@types/chrome": "^0.0.242",
|
||||||
|
"webpack": "^5.88.2",
|
||||||
|
"webpack-cli": "^5.1.4",
|
||||||
|
"copy-webpack-plugin": "^11.0.0",
|
||||||
|
"sass": "^1.64.1",
|
||||||
|
"sass-loader": "^13.3.2",
|
||||||
|
"ts-loader": "^9.4.4",
|
||||||
|
"file-loader": "^6.2.0",
|
||||||
|
"style-loader": "^3.3.3",
|
||||||
|
"css-loader": "^6.8.1"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "webpack --config webpack.config.js",
|
||||||
|
"watch": "webpack -w --config webpack.config.js"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
import React from "react";
|
||||||
|
import "./styles.scss";
|
||||||
|
import { Button, Flex, Text } from "@chakra-ui/react";
|
||||||
|
import { openPhonePopup } from "./utils";
|
||||||
|
import { DEFAULT_COLOR_SCHEME } from "./common/constants";
|
||||||
|
export const App = () => {
|
||||||
|
const handleClick = () => {
|
||||||
|
openPhonePopup();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
justifyContent="center"
|
||||||
|
flexFlow="column"
|
||||||
|
height="100%"
|
||||||
|
padding="20px"
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<Text fontSize="xl" mb={5}>
|
||||||
|
Click 'Start' to activate the service.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
width="full"
|
||||||
|
onClick={handleClick}
|
||||||
|
colorScheme={DEFAULT_COLOR_SCHEME}
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const DEFAULT_COLOR_SCHEME = "pink";
|
||||||
|
export const DEFAULT_TOAST_DURATION = 5000;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
export interface LoginCredential {
|
||||||
|
name?: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Call {
|
||||||
|
number?: string;
|
||||||
|
state?: CallState;
|
||||||
|
action?: CallAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MessageEvent {
|
||||||
|
// Request
|
||||||
|
Login,
|
||||||
|
Call,
|
||||||
|
OpenPhoneWindow,
|
||||||
|
Ping,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmptyData {}
|
||||||
|
|
||||||
|
export interface Message<T> {
|
||||||
|
event: MessageEvent;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Contact {
|
||||||
|
name: string;
|
||||||
|
number: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallHistory {
|
||||||
|
id: string;
|
||||||
|
callerName?: string;
|
||||||
|
callerId: string;
|
||||||
|
direction: string;
|
||||||
|
time: string;
|
||||||
|
duration: number;
|
||||||
|
strDuration?: string;
|
||||||
|
startTime?: number;
|
||||||
|
endTime?: number;
|
||||||
|
label?: string;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageResponse<T> {
|
||||||
|
event: MessageEvent;
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CallState {
|
||||||
|
RINGING,
|
||||||
|
ANSWERED,
|
||||||
|
COMPLETED,
|
||||||
|
FAILED,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CallAction {
|
||||||
|
OUTBOUND,
|
||||||
|
ANSWER,
|
||||||
|
CANCEL,
|
||||||
|
BYE,
|
||||||
|
DTMF,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
sipDomain: string;
|
||||||
|
sipServerAddress: string;
|
||||||
|
sipUsername: string;
|
||||||
|
sipPassword: string;
|
||||||
|
sipDisplayName: string;
|
||||||
|
apiKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SipClientStatus = "online" | "offline";
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
|
import {
|
||||||
|
ChakraProvider,
|
||||||
|
Text,
|
||||||
|
IconButton,
|
||||||
|
Flex,
|
||||||
|
CSSReset,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { Phone } from "react-feather";
|
||||||
|
|
||||||
|
export const ContentApp = () => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [position, setPosition] = useState({ left: 0, top: 0 });
|
||||||
|
const [selectedText, setSelectedText] = useState("");
|
||||||
|
|
||||||
|
// Add event listener for text selection
|
||||||
|
useEffect(() => {
|
||||||
|
function handleMouseUp() {
|
||||||
|
const selectedText = window.getSelection()?.toString();
|
||||||
|
if (selectedText) {
|
||||||
|
setSelectedText(selectedText);
|
||||||
|
const range = window.getSelection()?.getRangeAt(0);
|
||||||
|
const rect = range?.getBoundingClientRect();
|
||||||
|
setIsOpen(true);
|
||||||
|
setPosition({ left: rect?.left || 0, top: rect?.bottom || 0 });
|
||||||
|
} else {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChakraProvider>
|
||||||
|
<CSSReset />
|
||||||
|
{isOpen && (
|
||||||
|
<Flex
|
||||||
|
position="fixed"
|
||||||
|
left={position.left}
|
||||||
|
top={position.top}
|
||||||
|
bg="white"
|
||||||
|
p={4}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor="gray.200"
|
||||||
|
boxShadow="md"
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<Text>{selectedText}</Text>
|
||||||
|
<IconButton
|
||||||
|
colorScheme="blue"
|
||||||
|
ml={2}
|
||||||
|
aria-label="text"
|
||||||
|
icon={<Phone />}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</ChakraProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a new div as an extension root
|
||||||
|
const extensionRoot = document.createElement("div");
|
||||||
|
document.body.appendChild(extensionRoot);
|
||||||
|
|
||||||
|
// Create a shadow root
|
||||||
|
const shadowRoot = extensionRoot.attachShadow({ mode: "open" });
|
||||||
|
|
||||||
|
// Now we create a mount point in the Shadow DOM
|
||||||
|
const reactRoot = document.createElement("div");
|
||||||
|
shadowRoot.appendChild(reactRoot);
|
||||||
|
|
||||||
|
// Render React App inside our shadow root within the mount point.
|
||||||
|
ReactDOM.render(<ContentApp />, reactRoot);
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
declare module "*.png";
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import { ChakraProvider } from "@chakra-ui/react";
|
||||||
|
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.className = "container";
|
||||||
|
document.body.appendChild(root);
|
||||||
|
const rootDiv = ReactDOM.createRoot(root);
|
||||||
|
rootDiv.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ChakraProvider>
|
||||||
|
<App />
|
||||||
|
</ChakraProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// @ts-ignore
|
||||||
|
|
||||||
|
const ringingURL: string = "https://vibe-public.s3.eu-west-1.amazonaws.com/Tone-Telephone-UK-Ring+Tone-Loop.mp3";
|
||||||
|
const failedURL: string = "https://vibe-public.s3.eu-west-1.amazonaws.com/windows-error-sound-effect-35894.mp3";
|
||||||
|
const answeredURL: string = "https://vibe-public.s3.eu-west-1.amazonaws.com/lazer-96499.mp3";
|
||||||
|
|
||||||
|
export default class SipAudioElements {
|
||||||
|
#ringing: HTMLAudioElement;
|
||||||
|
#failed: HTMLAudioElement;
|
||||||
|
#answer: HTMLAudioElement;
|
||||||
|
#remote: HTMLAudioElement;
|
||||||
|
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.#ringing = new Audio();
|
||||||
|
this.#ringing.loop = true;
|
||||||
|
this.#ringing.src = ringingURL;
|
||||||
|
this.#ringing.volume = 0.8;
|
||||||
|
this.#failed = new Audio();
|
||||||
|
this.#failed.src = failedURL;
|
||||||
|
this.#failed.volume = 0.3;
|
||||||
|
this.#answer = new Audio();
|
||||||
|
this.#answer.src = answeredURL;
|
||||||
|
this.#answer.volume = 0.3;
|
||||||
|
this.#remote = new Audio();
|
||||||
|
}
|
||||||
|
|
||||||
|
playRinging(volume: number | undefined): void {
|
||||||
|
if (volume) {
|
||||||
|
this.#ringing.volume = volume;
|
||||||
|
}
|
||||||
|
this.#ringing.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
pauseRinging(): void {
|
||||||
|
if (!this.#ringing.paused) {
|
||||||
|
this.#ringing.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playFailed(volume: number | undefined): void {
|
||||||
|
this.pauseRinging();
|
||||||
|
if (volume) {
|
||||||
|
this.#failed.volume = volume;
|
||||||
|
}
|
||||||
|
this.#failed.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
playAnswer(volume: number | undefined): void {
|
||||||
|
this.pauseRinging();
|
||||||
|
if (volume) {
|
||||||
|
this.#answer.volume = volume;
|
||||||
|
}
|
||||||
|
this.#answer.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
isRemoteAudioPaused(): boolean {
|
||||||
|
return this.#remote.paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
playRemote(stream: MediaStream) {
|
||||||
|
this.#remote.srcObject = stream;
|
||||||
|
this.#remote.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
isPLaying(audio: HTMLAudioElement) {
|
||||||
|
return audio.currentTime > 0 && !audio.paused && !audio.ended
|
||||||
|
&& audio.readyState > audio.HAVE_CURRENT_DATA;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#rtcSession.on("accepted", () => {
|
||||||
|
this.emit(SipConstants.SESSION_ANSWERED, {
|
||||||
|
status: SipConstants.SESSION_ANSWERED,
|
||||||
|
});
|
||||||
|
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.#rtcSession.on("ended", (data: EndEvent): void => {
|
||||||
|
const { originator, cause, message } = data;
|
||||||
|
let description;
|
||||||
|
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, "track");
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
mute(): void {
|
||||||
|
this.#rtcSession.mute({ audio: true, video: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
unmute(): void {
|
||||||
|
this.#rtcSession.unmute({ audio: true, video: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
hold(): void {
|
||||||
|
this.#rtcSession.hold();
|
||||||
|
}
|
||||||
|
|
||||||
|
unhold(): void {
|
||||||
|
this.#rtcSession.unhold();
|
||||||
|
}
|
||||||
|
|
||||||
|
sendDtmf(tone: number | string): void {
|
||||||
|
this.#rtcSession.sendDTMF(tone, { transportType: DTMF_TRANSPORT.RFC2833 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
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): void {
|
||||||
|
let normalizedNumber: string = normalizeNumber(number);
|
||||||
|
this.#ua.call(normalizedNumber, {
|
||||||
|
extraHeaders: [`X-Original-Number:${number}`],
|
||||||
|
mediaConstraints: { audio: true, video: false },
|
||||||
|
pcConfig: this.#rtcConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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";
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
function normalizeNumber(number: string): string {
|
||||||
|
if (/^(sips?|tel):/i.test(number)) {
|
||||||
|
return number;
|
||||||
|
} else if (/@/i.test(number)) {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { AppSettings } from "src/common/types";
|
||||||
|
import { Buffer } from "buffer";
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
const SETTINGS_KEY = "SettingsKey";
|
||||||
|
|
||||||
|
export const saveSettings = (settings: AppSettings) => {
|
||||||
|
const encoded = Buffer.from(JSON.stringify(settings), "utf-8").toString(
|
||||||
|
"base64"
|
||||||
|
);
|
||||||
|
|
||||||
|
localStorage.setItem(SETTINGS_KEY, encoded);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSettings = (): AppSettings => {
|
||||||
|
const str = localStorage.getItem(SETTINGS_KEY);
|
||||||
|
if (str) {
|
||||||
|
const planText = Buffer.from(str, "base64").toString("utf-8");
|
||||||
|
return JSON.parse(planText) as AppSettings;
|
||||||
|
}
|
||||||
|
return {} as AppSettings;
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.container {
|
||||||
|
width: 280px;
|
||||||
|
height: 480px;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { SipConstants } from "src/lib";
|
||||||
|
import { deleteWindowIdKey, getWindowIdKey, saveWindowIdKey } from "./storage";
|
||||||
|
import { PhoneNumberFormat, PhoneNumberUtil } from "google-libphonenumber";
|
||||||
|
|
||||||
|
export const formatPhoneNumber = (number: string) => {
|
||||||
|
try {
|
||||||
|
const phoneUtil = PhoneNumberUtil.getInstance();
|
||||||
|
|
||||||
|
const phoneNumber = phoneUtil.parse(number, "US");
|
||||||
|
return phoneUtil.format(phoneNumber, PhoneNumberFormat.NATIONAL);
|
||||||
|
} catch (error) {}
|
||||||
|
return number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openPhonePopup = () => {
|
||||||
|
const runningPhoneWindowId = getWindowIdKey();
|
||||||
|
if (runningPhoneWindowId) {
|
||||||
|
chrome.windows.update(runningPhoneWindowId, { focused: true }, () => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
deleteWindowIdKey();
|
||||||
|
initiateNewPhonePopup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
initiateNewPhonePopup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initiateNewPhonePopup = () => {
|
||||||
|
const cfg: chrome.windows.CreateData = {
|
||||||
|
url: chrome.runtime.getURL("window/index.html"),
|
||||||
|
width: 300,
|
||||||
|
height: 630,
|
||||||
|
focused: true,
|
||||||
|
type: "panel",
|
||||||
|
state: "normal",
|
||||||
|
};
|
||||||
|
chrome.windows.create(cfg, (w) => {
|
||||||
|
if (w && w.id) saveWindowIdKey(w.id);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isSipClientRinging = (callStatus: string) => {
|
||||||
|
return callStatus === SipConstants.SESSION_RINGING;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isSipClientAnswered = (callStatus: string) => {
|
||||||
|
return callStatus === SipConstants.SESSION_ANSWERED;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isSipClientIdle = (callStatus: string) => {
|
||||||
|
return (
|
||||||
|
callStatus === SipConstants.SESSION_ENDED ||
|
||||||
|
callStatus === SipConstants.SESSION_FAILED
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// window ID
|
||||||
|
const windowIdKey = "windowIdKey";
|
||||||
|
|
||||||
|
export const saveWindowIdKey = (id: number) => {
|
||||||
|
localStorage.setItem(windowIdKey, id.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWindowIdKey = (): number => {
|
||||||
|
const str = localStorage.getItem(windowIdKey);
|
||||||
|
if (str) {
|
||||||
|
return Number(str);
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteWindowIdKey = () => {
|
||||||
|
localStorage.removeItem(windowIdKey);
|
||||||
|
};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Box, Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react";
|
||||||
|
import Phone from "./phone";
|
||||||
|
import Settings from "./settings";
|
||||||
|
import { DEFAULT_COLOR_SCHEME } from "src/common/constants";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getSettings } from "src/storage";
|
||||||
|
|
||||||
|
export const WindowApp = () => {
|
||||||
|
const [sipDomain, setSipDomain] = useState("");
|
||||||
|
const [sipUsername, setSipUsername] = useState("");
|
||||||
|
const [sipServerAddress, setSipServerAddress] = useState("");
|
||||||
|
const [sipPassword, setSipPassword] = useState("");
|
||||||
|
const [sipDisplayName, setSipDisplayName] = useState("");
|
||||||
|
const tabsSettings = [
|
||||||
|
{
|
||||||
|
title: "Phone",
|
||||||
|
content: (
|
||||||
|
<Phone
|
||||||
|
sipUsername={sipUsername}
|
||||||
|
sipPassword={sipPassword}
|
||||||
|
sipDomain={sipDomain}
|
||||||
|
sipDisplayName={sipDisplayName}
|
||||||
|
sipServerAddress={sipServerAddress}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Settings",
|
||||||
|
content: <Settings />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTabsChange = () => {
|
||||||
|
loadSettings();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSettings = () => {
|
||||||
|
const settings = getSettings();
|
||||||
|
if (settings.sipDomain && settings.sipDomain !== sipDomain) {
|
||||||
|
setSipDomain(settings.sipDomain);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
settings.sipServerAddress &&
|
||||||
|
settings.sipServerAddress !== sipServerAddress
|
||||||
|
) {
|
||||||
|
setSipServerAddress(settings.sipServerAddress);
|
||||||
|
}
|
||||||
|
if (settings.sipUsername && settings.sipUsername !== sipUsername) {
|
||||||
|
setSipUsername(settings.sipUsername);
|
||||||
|
}
|
||||||
|
if (settings.sipPassword && settings.sipPassword !== sipPassword) {
|
||||||
|
setSipPassword(settings.sipPassword);
|
||||||
|
}
|
||||||
|
if (settings.sipDisplayName && settings.sipDisplayName !== sipDisplayName) {
|
||||||
|
setSipDisplayName(settings.sipDisplayName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Box p={4}>
|
||||||
|
<Tabs
|
||||||
|
isFitted
|
||||||
|
variant="enclosed"
|
||||||
|
colorScheme={DEFAULT_COLOR_SCHEME}
|
||||||
|
onChange={onTabsChange}
|
||||||
|
>
|
||||||
|
<TabList mb="1em">
|
||||||
|
{tabsSettings.map((s) => (
|
||||||
|
<Tab>{s.title}</Tab>
|
||||||
|
))}
|
||||||
|
</TabList>
|
||||||
|
|
||||||
|
<TabPanels>
|
||||||
|
{tabsSettings.map((s) => (
|
||||||
|
<TabPanel>{s.content}</TabPanel>
|
||||||
|
))}
|
||||||
|
</TabPanels>
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WindowApp;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Button, HStack, VStack } from "@chakra-ui/react";
|
||||||
|
|
||||||
|
type DialPadProbs = {
|
||||||
|
handleDigitPress: (digit: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DialPad = ({ handleDigitPress }: DialPadProbs) => {
|
||||||
|
const buttons = [
|
||||||
|
["1", "2", "3"],
|
||||||
|
["4", "5", "6"],
|
||||||
|
["7", "8", "9"],
|
||||||
|
["*", "0", "#"],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack w="full" bg="black" spacing={0.5}>
|
||||||
|
{buttons.map((row, rowIndex) => (
|
||||||
|
<HStack key={rowIndex} justifyContent="space-between" spacing={0.5}>
|
||||||
|
{row.map((num) => (
|
||||||
|
<Button
|
||||||
|
key={num}
|
||||||
|
onClick={() => handleDigitPress(num)}
|
||||||
|
size="lg"
|
||||||
|
p={0}
|
||||||
|
width="70px"
|
||||||
|
height="70px"
|
||||||
|
variant="unstyled"
|
||||||
|
bg="white"
|
||||||
|
borderRadius={0}
|
||||||
|
>
|
||||||
|
{num}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DialPad;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
VStack,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
IconButton,
|
||||||
|
Collapse,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Smartphone } from "react-feather";
|
||||||
|
import { DEFAULT_COLOR_SCHEME } from "src/common/constants";
|
||||||
|
import {
|
||||||
|
formatPhoneNumber,
|
||||||
|
isSipClientAnswered,
|
||||||
|
isSipClientRinging,
|
||||||
|
} from "src/utils";
|
||||||
|
import DialPad from "./dial-pad";
|
||||||
|
|
||||||
|
type IncommingCallProbs = {
|
||||||
|
number: string;
|
||||||
|
callStatus: string;
|
||||||
|
answer: () => void;
|
||||||
|
hangup: () => void;
|
||||||
|
decline: () => void;
|
||||||
|
handleDialPadClick: (s: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IncommingCall = ({
|
||||||
|
number,
|
||||||
|
callStatus,
|
||||||
|
answer,
|
||||||
|
hangup,
|
||||||
|
decline,
|
||||||
|
handleDialPadClick,
|
||||||
|
}: IncommingCallProbs) => {
|
||||||
|
const [showDialPad, setShowDialPad] = useState(false);
|
||||||
|
return (
|
||||||
|
<VStack alignItems="center" spacing={4} mt={5}>
|
||||||
|
{!showDialPad && (
|
||||||
|
<>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold">
|
||||||
|
Incoming call from
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold">
|
||||||
|
{formatPhoneNumber(number)}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSipClientAnswered(callStatus) && (
|
||||||
|
<Tooltip label="Dial pad">
|
||||||
|
<IconButton
|
||||||
|
colorScheme={DEFAULT_COLOR_SCHEME}
|
||||||
|
aria-label="Toggle Dialpad"
|
||||||
|
icon={<Smartphone />}
|
||||||
|
onClick={() => setShowDialPad((prev) => !prev)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Collapse in={showDialPad}>
|
||||||
|
<DialPad handleDigitPress={handleDialPadClick} />
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
w="full"
|
||||||
|
colorScheme={DEFAULT_COLOR_SCHEME}
|
||||||
|
onClick={isSipClientRinging(callStatus) ? answer : hangup}
|
||||||
|
>
|
||||||
|
{isSipClientRinging(callStatus) ? "Answer" : "Hang up"}
|
||||||
|
</Button>
|
||||||
|
{isSipClientRinging(callStatus) && (
|
||||||
|
<Button w="full" colorScheme={DEFAULT_COLOR_SCHEME} onClick={decline}>
|
||||||
|
Decline
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IncommingCall;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { ChakraProvider } from "@chakra-ui/react";
|
||||||
|
import WindownApp from "./app";
|
||||||
|
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.className = "container";
|
||||||
|
document.body.appendChild(root);
|
||||||
|
const rootDiv = ReactDOM.createRoot(root);
|
||||||
|
rootDiv.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ChakraProvider>
|
||||||
|
<WindownApp />
|
||||||
|
</ChakraProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
VStack,
|
||||||
|
Text,
|
||||||
|
IconButton,
|
||||||
|
Collapse,
|
||||||
|
Tooltip,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { DEFAULT_COLOR_SCHEME } from "src/common/constants";
|
||||||
|
import { formatPhoneNumber, isSipClientAnswered } from "src/utils";
|
||||||
|
import DialPad from "./dial-pad";
|
||||||
|
import { Smartphone } from "react-feather";
|
||||||
|
|
||||||
|
type IncommingCallProbs = {
|
||||||
|
number: string;
|
||||||
|
callStatus: string;
|
||||||
|
callHold: boolean;
|
||||||
|
hangup: () => void;
|
||||||
|
callOnHold: () => void;
|
||||||
|
handleDialPadClick: (s: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OutgoingCall = ({
|
||||||
|
number,
|
||||||
|
callStatus,
|
||||||
|
callHold,
|
||||||
|
hangup,
|
||||||
|
callOnHold,
|
||||||
|
handleDialPadClick,
|
||||||
|
}: IncommingCallProbs) => {
|
||||||
|
const [seconds, setSeconds] = useState(0);
|
||||||
|
const timerRef = useRef<NodeJS.Timer | null>(null);
|
||||||
|
const [showDialPad, setShowDialPad] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setSeconds((seconds) => seconds + 1);
|
||||||
|
}, 1000);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<VStack alignItems="center" spacing={4} mt={5}>
|
||||||
|
{!showDialPad && (
|
||||||
|
<>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold">
|
||||||
|
Talking to
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold">
|
||||||
|
{formatPhoneNumber(number)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text fontSize="xl" fontWeight="bold">
|
||||||
|
{new Date(seconds * 1000).toISOString().substr(11, 8)}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isSipClientAnswered(callStatus) && (
|
||||||
|
<Tooltip label="Dial pad">
|
||||||
|
<IconButton
|
||||||
|
colorScheme={DEFAULT_COLOR_SCHEME}
|
||||||
|
aria-label="Toggle Dialpad"
|
||||||
|
icon={<Smartphone />}
|
||||||
|
onClick={() => setShowDialPad((prev) => !prev)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Collapse in={showDialPad}>
|
||||||
|
<DialPad handleDigitPress={handleDialPadClick} />
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<VStack spacing={5}>
|
||||||
|
<Button w="full" colorScheme={DEFAULT_COLOR_SCHEME} onClick={hangup}>
|
||||||
|
Hang up
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
w="full"
|
||||||
|
colorScheme={DEFAULT_COLOR_SCHEME}
|
||||||
|
onClick={callOnHold}
|
||||||
|
>
|
||||||
|
{callHold ? "Unhold " : "Place call on hold"}
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OutgoingCall;
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Flex,
|
||||||
|
HStack,
|
||||||
|
Heading,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
InputGroup,
|
||||||
|
InputRightElement,
|
||||||
|
Spacer,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Delete } from "react-feather";
|
||||||
|
import { DEFAULT_COLOR_SCHEME } from "src/common/constants";
|
||||||
|
import { SipClientStatus } from "src/common/types";
|
||||||
|
import { SipConstants, SipUA } from "src/lib";
|
||||||
|
import IncommingCall from "./incomming-call";
|
||||||
|
import OutgoingCall from "./outgoing-call";
|
||||||
|
import DialPad from "./dial-pad";
|
||||||
|
import {
|
||||||
|
isSipClientAnswered,
|
||||||
|
isSipClientIdle,
|
||||||
|
isSipClientRinging,
|
||||||
|
} from "src/utils";
|
||||||
|
|
||||||
|
type PhoneProbs = {
|
||||||
|
sipDomain: string;
|
||||||
|
sipServerAddress: string;
|
||||||
|
sipUsername: string;
|
||||||
|
sipPassword: string;
|
||||||
|
sipDisplayName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Phone = ({
|
||||||
|
sipDomain,
|
||||||
|
sipServerAddress,
|
||||||
|
sipUsername,
|
||||||
|
sipPassword,
|
||||||
|
sipDisplayName,
|
||||||
|
}: PhoneProbs) => {
|
||||||
|
const [inputNumber, setInputNumber] = useState("");
|
||||||
|
const [status, setStatus] = useState<SipClientStatus>("offline");
|
||||||
|
const [goOffline, setGoOffline] = useState(false);
|
||||||
|
const [backtoOnline, setBackToOnline] = useState(false);
|
||||||
|
const [callStatus, setCallStatus] = useState(SipConstants.SESSION_ENDED);
|
||||||
|
const [sessionDirection, setSessionDirection] = useState("incoming");
|
||||||
|
const [callHold, setCallHold] = useState(false);
|
||||||
|
const sipUA = useRef<SipUA | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sipDomain && sipUsername && sipPassword) {
|
||||||
|
createSipClient();
|
||||||
|
}
|
||||||
|
}, [sipDomain, sipUsername, sipPassword, sipServerAddress, sipDisplayName]);
|
||||||
|
|
||||||
|
const createSipClient = (forceOfflineMode = false) => {
|
||||||
|
if (goOffline && !forceOfflineMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clientGoOffline();
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
username: `${sipUsername}@${sipDomain}`,
|
||||||
|
password: sipPassword,
|
||||||
|
name: sipDisplayName ?? sipUsername,
|
||||||
|
};
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
pcConfig: {
|
||||||
|
iceServers: [{ urls: ["stun:stun.l.google.com:19302"] }],
|
||||||
|
},
|
||||||
|
wsUri: sipServerAddress,
|
||||||
|
register: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sipClient = new SipUA(client, settings);
|
||||||
|
|
||||||
|
// UA Status
|
||||||
|
sipClient.on(SipConstants.UA_REGISTERED, (args) => {
|
||||||
|
setStatus("online");
|
||||||
|
setBackToOnline(false);
|
||||||
|
});
|
||||||
|
sipClient.on(SipConstants.UA_UNREGISTERED, (args) => {
|
||||||
|
setStatus("offline");
|
||||||
|
clientGoOffline();
|
||||||
|
setBackToOnline(false);
|
||||||
|
});
|
||||||
|
// Call Status
|
||||||
|
sipClient.on(SipConstants.SESSION_RINGING, (args) => {
|
||||||
|
setCallStatus(SipConstants.SESSION_RINGING);
|
||||||
|
setSessionDirection(args.session.direction);
|
||||||
|
setInputNumber(args.session.user);
|
||||||
|
});
|
||||||
|
sipClient.on(SipConstants.SESSION_ANSWERED, (args) => {
|
||||||
|
setCallStatus(SipConstants.SESSION_ANSWERED);
|
||||||
|
});
|
||||||
|
sipClient.on(SipConstants.SESSION_ENDED, (args) => {
|
||||||
|
setCallStatus(SipConstants.SESSION_ENDED);
|
||||||
|
setSessionDirection("");
|
||||||
|
});
|
||||||
|
sipClient.on(SipConstants.SESSION_FAILED, (args) => {
|
||||||
|
setCallStatus(SipConstants.SESSION_FAILED);
|
||||||
|
setSessionDirection("");
|
||||||
|
});
|
||||||
|
sipClient.on(SipConstants.SESSION_HOLD, (args) => {
|
||||||
|
setCallHold(true);
|
||||||
|
});
|
||||||
|
sipClient.on(SipConstants.SESSION_UNHOLD, (args) => {
|
||||||
|
setCallHold(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
sipClient.start();
|
||||||
|
sipUA.current = sipClient;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialPadClick = (value: string) => {
|
||||||
|
if (isSipClientIdle(callStatus)) {
|
||||||
|
setInputNumber((prev) => prev + value);
|
||||||
|
} else if (isSipClientAnswered(callStatus)) {
|
||||||
|
sipUA.current?.dtmf(value, undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCallButtion = () => {
|
||||||
|
if (sipUA.current) {
|
||||||
|
sipUA.current.call(inputNumber);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientGoOffline = () => {
|
||||||
|
if (sipUA.current) {
|
||||||
|
sipUA.current.stop();
|
||||||
|
sipUA.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoOffline = () => {
|
||||||
|
const newVal = !goOffline;
|
||||||
|
setGoOffline(newVal);
|
||||||
|
if (newVal) {
|
||||||
|
clientGoOffline();
|
||||||
|
} else {
|
||||||
|
createSipClient(true);
|
||||||
|
setBackToOnline(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHangup = () => {
|
||||||
|
if (isSipClientAnswered(callStatus) || isSipClientRinging(callStatus)) {
|
||||||
|
sipUA.current?.terminate(480, "Call Finished", undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCallOnHold = () => {
|
||||||
|
if (isSipClientAnswered(callStatus)) {
|
||||||
|
if (callHold) {
|
||||||
|
sipUA.current?.unhold(undefined);
|
||||||
|
} else {
|
||||||
|
sipUA.current?.hold(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnswer = () => {
|
||||||
|
if (isSipClientRinging(callStatus)) {
|
||||||
|
sipUA.current?.answer(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDecline = () => {
|
||||||
|
if (isSipClientRinging(callStatus)) {
|
||||||
|
sipUA.current?.terminate(486, "Busy here", undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center flexDirection="column">
|
||||||
|
{status === "online" || goOffline || backtoOnline ? (
|
||||||
|
<VStack mb={2}>
|
||||||
|
<Text fontWeight="bold" maxW="full">
|
||||||
|
{`${sipUsername}@${sipDomain}`}
|
||||||
|
</Text>
|
||||||
|
<Flex alignItems="center" justifyContent="space-between" w="full">
|
||||||
|
<Text>
|
||||||
|
<strong>Status:</strong> {status}
|
||||||
|
</Text>
|
||||||
|
<Spacer />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleGoOffline}
|
||||||
|
colorScheme={DEFAULT_COLOR_SCHEME}
|
||||||
|
ml={5}
|
||||||
|
>
|
||||||
|
{goOffline ? "Go online" : "Go offline"}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<Heading size="md" mb={2}>
|
||||||
|
Go to Settings to configure your account
|
||||||
|
</Heading>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isSipClientIdle(callStatus) ? (
|
||||||
|
sessionDirection === "outgoing" ? (
|
||||||
|
<OutgoingCall
|
||||||
|
callHold={callHold}
|
||||||
|
callStatus={callStatus}
|
||||||
|
number={inputNumber}
|
||||||
|
hangup={handleHangup}
|
||||||
|
callOnHold={handleCallOnHold}
|
||||||
|
handleDialPadClick={handleDialPadClick}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IncommingCall
|
||||||
|
number={inputNumber}
|
||||||
|
callStatus={callStatus}
|
||||||
|
answer={handleAnswer}
|
||||||
|
hangup={handleHangup}
|
||||||
|
decline={handleDecline}
|
||||||
|
handleDialPadClick={handleDialPadClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<VStack w="200px" spacing={4}>
|
||||||
|
<Center flexDirection="column">
|
||||||
|
<InputGroup>
|
||||||
|
<Input
|
||||||
|
value={inputNumber}
|
||||||
|
shadow="md"
|
||||||
|
fontWeight="bold"
|
||||||
|
onChange={(e) => setInputNumber(e.target.value)}
|
||||||
|
/>
|
||||||
|
<InputRightElement
|
||||||
|
children={
|
||||||
|
<IconButton
|
||||||
|
aria-label="Delete text"
|
||||||
|
icon={<Delete />}
|
||||||
|
variant="ghost"
|
||||||
|
colorScheme={DEFAULT_COLOR_SCHEME}
|
||||||
|
_hover={{ bg: "none" }}
|
||||||
|
_active={{ bg: "none" }}
|
||||||
|
onClick={() => setInputNumber((prev) => prev.slice(0, -1))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</Center>
|
||||||
|
|
||||||
|
<DialPad handleDigitPress={handleDialPadClick} />
|
||||||
|
|
||||||
|
<HStack alignItems="center" w="80%">
|
||||||
|
<Button
|
||||||
|
w="full"
|
||||||
|
onClick={handleCallButtion}
|
||||||
|
isDisabled={status === "offline"}
|
||||||
|
colorScheme={DEFAULT_COLOR_SCHEME}
|
||||||
|
>
|
||||||
|
Call
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Phone;
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
FormLabel,
|
||||||
|
Input,
|
||||||
|
VStack,
|
||||||
|
useToast,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { DEFAULT_TOAST_DURATION } from "src/common/constants";
|
||||||
|
import { AppSettings } from "src/common/types";
|
||||||
|
import { getSettings, saveSettings } from "src/storage";
|
||||||
|
|
||||||
|
export const Settings = () => {
|
||||||
|
const [sipDomain, setSipDomain] = useState("");
|
||||||
|
const [sipServerAddress, setSipServerAddress] = useState("");
|
||||||
|
const [sipUsername, setSipUsername] = useState("");
|
||||||
|
const [sipPassword, setSipPassword] = useState("");
|
||||||
|
const [sipDisplayName, setSipDisplayName] = useState("");
|
||||||
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const settings: AppSettings = {
|
||||||
|
sipDomain,
|
||||||
|
sipServerAddress: sipServerAddress,
|
||||||
|
sipUsername,
|
||||||
|
sipPassword,
|
||||||
|
sipDisplayName,
|
||||||
|
apiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
saveSettings(settings);
|
||||||
|
toast({
|
||||||
|
title: "Settings saved successfully",
|
||||||
|
status: "success",
|
||||||
|
duration: DEFAULT_TOAST_DURATION,
|
||||||
|
isClosable: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const settings = getSettings();
|
||||||
|
if (settings.sipDomain) {
|
||||||
|
setSipDomain(settings.sipDomain);
|
||||||
|
}
|
||||||
|
if (settings.sipServerAddress) {
|
||||||
|
setSipServerAddress(settings.sipServerAddress);
|
||||||
|
}
|
||||||
|
if (settings.sipUsername) {
|
||||||
|
setSipUsername(settings.sipUsername);
|
||||||
|
}
|
||||||
|
if (settings.sipPassword) {
|
||||||
|
setSipPassword(settings.sipPassword);
|
||||||
|
}
|
||||||
|
if (settings.sipDisplayName) {
|
||||||
|
setSipDisplayName(settings.sipDisplayName);
|
||||||
|
}
|
||||||
|
if (settings.apiKey) {
|
||||||
|
setApiKey(settings.apiKey);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<VStack spacing={4}>
|
||||||
|
<FormControl id="jambonz_sip_domain">
|
||||||
|
<FormLabel>Jambonz SIP Domain</FormLabel>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Domain"
|
||||||
|
isRequired
|
||||||
|
value={sipDomain}
|
||||||
|
onChange={(e) => setSipDomain(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl id="jambonz_server_address">
|
||||||
|
<FormLabel>Jambonz Server Address</FormLabel>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Server address"
|
||||||
|
isRequired
|
||||||
|
value={sipServerAddress}
|
||||||
|
onChange={(e) => setSipServerAddress(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl id="username">
|
||||||
|
<FormLabel>SIP Username</FormLabel>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
isRequired
|
||||||
|
value={sipUsername}
|
||||||
|
onChange={(e) => setSipUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl id="password">
|
||||||
|
<FormLabel>SIP Password</FormLabel>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
isRequired
|
||||||
|
value={sipPassword}
|
||||||
|
onChange={(e) => setSipPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl id="sip_display_name">
|
||||||
|
<FormLabel>SIP Display Name</FormLabel>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Display name"
|
||||||
|
isRequired
|
||||||
|
value={sipDisplayName}
|
||||||
|
onChange={(e) => setSipDisplayName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl id="api_key">
|
||||||
|
<FormLabel>API Key (optional)</FormLabel>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="API Key"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Button colorScheme="pink" type="submit" w="80%">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["@types/react", "@types/react-dom", "@types/chrome", "node"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": false,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"src": ["./src"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src", "src/**/*", "src/img"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
const path = require("path");
|
||||||
|
const HTMLPlugin = require("html-webpack-plugin");
|
||||||
|
const CopyPlugin = require("copy-webpack-plugin");
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
{
|
||||||
|
entry: "./src/content/index.tsx",
|
||||||
|
target: "web",
|
||||||
|
output: {
|
||||||
|
path: path.join(__dirname, "dist"),
|
||||||
|
filename: "js/content.js",
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.tsx?$/,
|
||||||
|
use: [
|
||||||
|
{
|
||||||
|
loader: "ts-loader",
|
||||||
|
options: {
|
||||||
|
compilerOptions: { noEmit: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exclude: /node_modules/,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
extensions: [".ts", ".js"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entry: {
|
||||||
|
index: "./src/index.tsx",
|
||||||
|
},
|
||||||
|
mode: "production",
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.tsx?$/,
|
||||||
|
use: [
|
||||||
|
{
|
||||||
|
loader: "ts-loader",
|
||||||
|
options: {
|
||||||
|
compilerOptions: { noEmit: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exclude: /node_modules/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exclude: /node_modules/,
|
||||||
|
test: /\.css$/i,
|
||||||
|
use: ["style-loader", "css-loader"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.s[ac]ss$/i,
|
||||||
|
use: [
|
||||||
|
// Creates `style` nodes from JS strings
|
||||||
|
"style-loader",
|
||||||
|
// Translates CSS into CommonJS
|
||||||
|
"css-loader",
|
||||||
|
// Compiles Sass to CSS
|
||||||
|
"sass-loader",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.(png|jpe?g|gif|svg)$/i,
|
||||||
|
use: [
|
||||||
|
{
|
||||||
|
loader: "file-loader",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new CopyPlugin({
|
||||||
|
patterns: [{ from: "manifest.json", to: "../manifest.json" }],
|
||||||
|
}),
|
||||||
|
...getHtmlPlugins(["index"]),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
src: path.resolve(__dirname, "src/"),
|
||||||
|
},
|
||||||
|
extensions: [".tsx", ".ts", ".js"],
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
path: path.join(__dirname, "dist/js"),
|
||||||
|
filename: "[name].js",
|
||||||
|
},
|
||||||
|
performance: {
|
||||||
|
maxEntrypointSize: 512000,
|
||||||
|
maxAssetSize: 512000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entry: {
|
||||||
|
index: "./src/window/index.tsx",
|
||||||
|
},
|
||||||
|
mode: "production",
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.tsx?$/,
|
||||||
|
use: [
|
||||||
|
{
|
||||||
|
loader: "ts-loader",
|
||||||
|
options: {
|
||||||
|
compilerOptions: { noEmit: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exclude: /node_modules/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exclude: /node_modules/,
|
||||||
|
test: /\.css$/i,
|
||||||
|
use: ["style-loader", "css-loader"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.s[ac]ss$/i,
|
||||||
|
use: [
|
||||||
|
// Creates `style` nodes from JS strings
|
||||||
|
"style-loader",
|
||||||
|
// Translates CSS into CommonJS
|
||||||
|
"css-loader",
|
||||||
|
// Compiles Sass to CSS
|
||||||
|
"sass-loader",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.(png|jpe?g|gif|svg)$/i,
|
||||||
|
use: [
|
||||||
|
{
|
||||||
|
loader: "file-loader",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new CopyPlugin({
|
||||||
|
patterns: [{ from: "manifest.json", to: "../manifest.json" }],
|
||||||
|
}),
|
||||||
|
...getHtmlPlugins(["index"]),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
src: path.resolve(__dirname, "src/"),
|
||||||
|
},
|
||||||
|
extensions: [".tsx", ".ts", ".js"],
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
path: path.join(__dirname, "dist/window"),
|
||||||
|
filename: "[name].js",
|
||||||
|
},
|
||||||
|
performance: {
|
||||||
|
maxEntrypointSize: 512000,
|
||||||
|
maxAssetSize: 512000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function getHtmlPlugins(chunks) {
|
||||||
|
return chunks.map(
|
||||||
|
(chunk) =>
|
||||||
|
new HTMLPlugin({
|
||||||
|
title: "Webrtc Chrome Extension",
|
||||||
|
filename: `${chunk}.html`,
|
||||||
|
chunks: [chunk],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user