mirror of
https://github.com/jambonz/chrome-extension-dialer.git
synced 2025-12-18 20:37:45 +00:00
first draft
This commit is contained in:
46
README.md
Normal file
46
README.md
Normal file
@@ -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/).
|
||||
17
manifest.json
Normal file
17
manifest.json
Normal file
@@ -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"]
|
||||
}
|
||||
19922
package-lock.json
generated
Normal file
19922
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
59
package.json
Normal file
59
package.json
Normal file
@@ -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
src/App.tsx
Normal file
34
src/App.tsx
Normal file
@@ -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;
|
||||
2
src/common/constants.ts
Normal file
2
src/common/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const DEFAULT_COLOR_SCHEME = "pink";
|
||||
export const DEFAULT_TOAST_DURATION = 5000;
|
||||
77
src/common/types.ts
Normal file
77
src/common/types.ts
Normal file
@@ -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";
|
||||
79
src/content/index.tsx
Normal file
79
src/content/index.tsx
Normal file
@@ -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);
|
||||
1
src/declarations.d.ts
vendored
Normal file
1
src/declarations.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "*.png";
|
||||
BIN
src/imgs/jambonz.png
Normal file
BIN
src/imgs/jambonz.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
16
src/index.tsx
Normal file
16
src/index.tsx
Normal file
@@ -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>
|
||||
);
|
||||
70
src/lib/SipAudioElements.ts
Normal file
70
src/lib/SipAudioElements.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
286
src/lib/SipSession.ts
Normal file
286
src/lib/SipSession.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
108
src/lib/SipSessionManager.ts
Normal file
108
src/lib/SipSessionManager.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
198
src/lib/SipUA.ts
Normal file
198
src/lib/SipUA.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
18
src/lib/index.ts
Normal file
18
src/lib/index.ts
Normal file
@@ -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
|
||||
}
|
||||
20
src/lib/sip-constants.ts
Normal file
20
src/lib/sip-constants.ts
Normal file
@@ -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";
|
||||
45
src/lib/sip-models.ts
Normal file
45
src/lib/sip-models.ts
Normal file
@@ -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[];
|
||||
}
|
||||
23
src/lib/sip-utils.ts
Normal file
23
src/lib/sip-utils.ts
Normal file
@@ -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
|
||||
}
|
||||
22
src/storage/index.ts
Normal file
22
src/storage/index.ts
Normal file
@@ -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;
|
||||
};
|
||||
7
src/styles.scss
Normal file
7
src/styles.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
.container {
|
||||
width: 280px;
|
||||
height: 480px;
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
56
src/utils/index.ts
Normal file
56
src/utils/index.ts
Normal file
@@ -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
|
||||
);
|
||||
};
|
||||
18
src/utils/storage.tsx
Normal file
18
src/utils/storage.tsx
Normal file
@@ -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);
|
||||
};
|
||||
86
src/window/app.tsx
Normal file
86
src/window/app.tsx
Normal file
@@ -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;
|
||||
40
src/window/dial-pad.tsx
Normal file
40
src/window/dial-pad.tsx
Normal file
@@ -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;
|
||||
81
src/window/incomming-call.tsx
Normal file
81
src/window/incomming-call.tsx
Normal file
@@ -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;
|
||||
16
src/window/index.tsx
Normal file
16
src/window/index.tsx
Normal file
@@ -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>
|
||||
);
|
||||
92
src/window/outgoing-call.tsx
Normal file
92
src/window/outgoing-call.tsx
Normal file
@@ -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;
|
||||
272
src/window/phone.tsx
Normal file
272
src/window/phone.tsx
Normal file
@@ -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;
|
||||
141
src/window/settings.tsx
Normal file
141
src/window/settings.tsx
Normal file
@@ -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;
|
||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
176
webpack.config.js
Normal file
176
webpack.config.js
Normal file
@@ -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