Merge pull request #32 from jambonz/feat/conference

Support Conference
This commit is contained in:
Dave Horton
2024-05-08 09:26:09 -04:00
committed by GitHub
28 changed files with 3100 additions and 648 deletions

2579
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,14 @@
{
"name": "webrtc-chrome-ext",
"version": "1.0.5",
"version": "1.0.6",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.8.0",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"buffer": "^6.0.3",
"dayjs": "^1.11.10",
"framer-motion": "^10.16.4",
@@ -17,13 +17,13 @@
"jssip": "^3.10.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-feather": "^2.0.10",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"uuid": "^9.0.1",
"web-vitals": "^2.1.4"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/chrome": "^0.0.242",
"@types/google-libphonenumber": "^7.4.27",
"@types/jest": "^27.5.2",
@@ -34,8 +34,10 @@
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.8.1",
"file-loader": "^6.2.0",
"react-scripts": "5.0.1",
"sass": "^1.64.1",
"sass-loader": "^13.3.2",
"serve": "^14.2.1",
"style-loader": "^3.3.3",
"ts-loader": "^9.4.4",
"webpack": "^5.88.2",
@@ -43,7 +45,8 @@
},
"scripts": {
"build": "webpack --config webpack.config.js",
"watch": "webpack -w --config webpack.config.js"
"watch": "webpack -w --config webpack.config.js",
"start": "react-scripts start"
},
"eslintConfig": {
"extends": [

43
public/index.html Normal file
View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Jambonz Webphone</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@@ -1,5 +1,5 @@
{
"version": "1.0.5",
"version": "1.0.6",
"manifest_version": 3,
"name": "jambonz webrtc phone",
"description": "jambonz webrtc phone",

View File

@@ -1,33 +0,0 @@
import React from "react";
import "./styles.scss";
import { Button, Flex, Text } from "@chakra-ui/react";
import { openPhonePopup } from "./utils";
export const App = () => {
const handleClick = () => {
openPhonePopup();
};
return (
<Flex
justifyContent="center"
flexFlow="column"
height="100%"
padding="20px"
alignItems="center"
>
<Text fontSize="24px" mb={5}>
Click 'Start' to activate the service.
</Text>
<Button
size="lg"
width="full"
onClick={handleClick}
colorScheme="jambonz"
>
Start
</Button>
</Flex>
);
};
export default App;

View File

@@ -1,13 +1,17 @@
import {
Application,
ConferenceParticipantAction,
ConferenceParticipantActions,
FetchError,
FetchTransport,
Queue,
RegisteredUser,
StatusCodes,
UpdateCall,
} from "./types";
import { MSG_SOMETHING_WRONG } from "./constants";
import { getAdvancedSettings } from "src/storage";
import { EmptyData } from "src/common/types";
const fetchTransport = <Type>(
url: string,
@@ -152,3 +156,23 @@ export const getSelfRegisteredUser = (username: string) => {
`${advancedSettings.apiServer}/Accounts/${advancedSettings.accountSid}/RegisteredSipUsers/${username}`
);
};
export const getConferences = () => {
const advancedSettings = getAdvancedSettings();
return getFetch<string[]>(
`${advancedSettings.apiServer}/Accounts/${advancedSettings.accountSid}/Conferences`
);
};
export const updateConferenceParticipantAction = (
callSid: string,
payload: ConferenceParticipantAction
) => {
const advancedSettings = getAdvancedSettings();
return putFetch<EmptyData, UpdateCall>(
`${advancedSettings.apiServer}/Accounts/${advancedSettings.accountSid}/Calls/${callSid}`,
{
conferenceParticipantAction: payload,
}
);
};

View File

@@ -45,3 +45,23 @@ export interface RegisteredUser {
allow_direct_user_calling: boolean;
registered_status: string;
}
export type ConferenceParticipantActions =
| "tag"
| "untag"
| "coach"
| "uncoach"
| "mute"
| "unmute"
| "hold"
| "unhold";
export type ConferenceModes = "full_participant" | "muted" | "coach";
export interface ConferenceParticipantAction {
action: ConferenceParticipantActions;
tag: string;
}
export interface UpdateCall {
conferenceParticipantAction: ConferenceParticipantAction;
}

View File

@@ -20,7 +20,7 @@ const initiateNewPhonePopup = () => {
{
url: chrome.runtime.getURL("window/index.html"),
width: 440,
height: 720,
height: 750,
focused: true,
type: "panel",
state: "normal",

View File

@@ -0,0 +1,42 @@
import { VStack, Text } from "@chakra-ui/react";
import { ReactNode } from "react";
const OutlineBox = ({
title,
children,
h,
borderColor,
}: {
title: string;
children: ReactNode;
h?: string;
borderColor?: string;
}) => {
return (
<VStack align="stretch" h={h || ""} w="full">
<VStack
border="1px"
borderColor={borderColor || ""}
position="relative"
borderRadius={5}
p={4}
h={h || ""}
align="start"
>
<Text
position="absolute"
top={-3}
left={2}
bg="white"
color={borderColor || ""}
px={2}
>
{title}
</Text>
{children}
</VStack>
</VStack>
);
};
export default OutlineBox;

View File

@@ -1,12 +1,7 @@
import { useState } from "react";
import {
Input,
InputGroup,
InputRightElement,
Button,
Box,
} from "@chakra-ui/react";
import { Eye, EyeOff } from "react-feather";
import { Input, InputGroup, InputRightElement, Button } from "@chakra-ui/react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEye, faEyeSlash } from "@fortawesome/free-solid-svg-icons";
type PasswordInputProbs = {
password: [string, React.Dispatch<React.SetStateAction<string>>];
@@ -34,7 +29,7 @@ function PasswordInput({
/>
<InputRightElement width="4.5rem">
<Button h="1.75rem" size="sm" onClick={handleClick} variant="unstyled">
{showPassword ? <EyeOff /> : <Eye />}
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</Button>
</InputRightElement>
</InputGroup>

View File

@@ -1,9 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { ChakraProvider } from "@chakra-ui/react";
import mainTheme from "./theme";
import WindowApp from "./window/app";
// This file is being used only for dev.
const root = document.createElement("div");
root.className = "container";
document.body.appendChild(root);
@@ -11,7 +12,7 @@ const rootDiv = ReactDOM.createRoot(root);
rootDiv.render(
<React.StrictMode>
<ChakraProvider theme={mainTheme}>
<App />
<WindowApp />
</ChakraProvider>
</React.StrictMode>
);

View File

@@ -8,27 +8,41 @@ export default class SipAudioElements {
#localHungup: HTMLAudioElement;
constructor() {
this.#ringing = new Audio(chrome.runtime.getURL("audios/ringing.mp3"));
this.#ringing = this.getAudio("audios/ringing.mp3");
this.#ringing.loop = true;
this.#ringing.volume = 0.8;
this.#ringBack = new Audio(chrome.runtime.getURL("audios/us-ringback.mp3"));
this.#ringBack = this.getAudio("audios/us-ringback.mp3");
this.#ringBack.loop = true;
this.#ringBack.volume = 0.8;
this.#failed = new Audio(chrome.runtime.getURL("audios/call-failed.mp3"));
this.#failed = this.getAudio("audios/call-failed.mp3");
this.#failed.volume = 0.3;
this.#busy = new Audio(chrome.runtime.getURL("audios/us-busy-signal.mp3"));
this.#busy = this.getAudio("audios/us-busy-signal.mp3");
this.#busy.volume = 0.3;
this.#hungup = new Audio(
chrome.runtime.getURL("audios/remote-party-hungup-tone.mp3")
);
this.#hungup = this.getAudio("audios/remote-party-hungup-tone.mp3");
this.#hungup.volume = 0.3;
this.#localHungup = new Audio(
chrome.runtime.getURL("audios/local-party-hungup-tone.mp3")
);
this.#localHungup = this.getAudio("audios/local-party-hungup-tone.mp3");
this.#localHungup.volume = 0.3;
this.#remote = new Audio();
}
private getAudio(path: string) {
let audioURL;
// Check if we're in a Chrome extension
if (
typeof chrome !== "undefined" &&
chrome.runtime &&
chrome.runtime.getURL
) {
audioURL = chrome.runtime.getURL(path);
} else {
// We're in a web context, adjust this path as necessary
audioURL = `/${path}`;
}
return new Audio(audioURL);
}
playLocalHungup(volume: number | undefined) {
this.pauseRingback();
this.pauseRinging();

View File

@@ -62,12 +62,18 @@ export default class SipSession extends events.EventEmitter {
}
});
this.#rtcSession.on("accepted", () => {
this.#rtcSession.on(
"accepted",
({ response }: { response: IncomingResponse }) => {
this.emit(SipConstants.SESSION_ANSWERED, {
status: SipConstants.SESSION_ANSWERED,
callSid: response.hasHeader("X-Call-Sid")
? response.getHeader("X-Call-Sid")
: null,
});
this.#audio.playAnswer(undefined);
});
}
);
this.#rtcSession.on("failed", (data: EndEvent): void => {
let { originator, cause, message } = data;
@@ -195,16 +201,19 @@ export default class SipSession extends events.EventEmitter {
direction: this.#rtcSession.direction,
});
});
// pc.addEventListener('track', (event: RTCPeerConnectionEventMap["track"]): void => {
// const stream: MediaStream = new MediaStream([event.track])
// if (this.#rtcSession.direction === 'outgoing') {
// 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.#audio.playRemote(stream);
// this.emit(SipConstants.SESSION_TRACK, {
// direction: this.#rtcSession.direction
// });
// direction: this.#rtcSession.direction,
// });
// }
// );
}
get rtcSession() {

View File

@@ -1,10 +1,6 @@
import {
SipSession, SipModel, SipConstants
} from "./index";
import { SipSession, SipModel, SipConstants } from "./index";
export default class SipSessionManager {
#sessions: Map<string, SipModel.SipSessionState>;
constructor() {
@@ -24,7 +20,6 @@ export default class SipSessionManager {
}
updateSession(field: string, session: SipSession, args: any): void {
const state: SipModel.SipSessionState = this.getSessionState(session.id);
if (state) {
switch (field) {
@@ -41,8 +36,8 @@ export default class SipSessionManager {
cause: args.cause,
status: args.status,
originator: args.endState,
description: args.description
}
description: args.description,
};
this.#sessions.delete(session.id);
break;
case SipConstants.SESSION_MUTED:
@@ -51,8 +46,8 @@ export default class SipSessionManager {
case SipConstants.SESSION_HOLD:
state.holdState = {
originator: args.originator,
status: args.status
}
status: args.status,
};
break;
case SipConstants.SESSION_ICE_READY:
state.iceReady = true;
@@ -76,16 +71,13 @@ export default class SipSessionManager {
return this.getSessionState(id).sipSession;
}
newSession(session: SipSession): void {
this.#sessions.set(session.id,
{
this.#sessions.set(session.id, {
id: session.id,
sipSession: session,
startDateTime: new Date(),
active: true,
status: 'init',
status: "init",
});
}

View File

@@ -34,6 +34,7 @@ export default class SipUA extends events.EventEmitter {
display_name: client.name,
sockets: [new WebSocketInterface(settings.wsUri)],
register: settings.register,
register_expires: 600,
});
this.#ua.on("connecting", (data: UAConnectingEvent) =>
this.emit(SipConstants.UA_CONNECTING, { ...data, client })

View File

@@ -3,7 +3,11 @@ function normalizeNumber(number: string): string {
return number;
} else if (/@/i.test(number)) {
return number;
} else if (number.startsWith("app-") || number.startsWith("queue-")) {
} else if (
number.startsWith("app-") ||
number.startsWith("queue-") ||
number.startsWith("conference-")
) {
return number;
} else {
return number.replace(/[()\-. ]*/g, "");

View File

@@ -104,10 +104,11 @@ export const WindowApp = () => {
index={tabIndex}
>
<TabList mb="1em" gap={1}>
{tabsSettings.map((s) => (
{tabsSettings.map((s, i) => (
<Tab
_selected={{ color: "white", bg: "jambonz.500" }}
bg="grey.500"
key={i}
>
{s.title}
</Tab>
@@ -115,8 +116,8 @@ export const WindowApp = () => {
</TabList>
<TabPanels>
{tabsSettings.map((s) => (
<TabPanel>{s.content}</TabPanel>
{tabsSettings.map((s, i) => (
<TabPanel key={i}>{s.content}</TabPanel>
))}
</TabPanels>
</Tabs>

View File

@@ -1,21 +1,21 @@
import {
HStack,
Icon,
IconButton,
Spacer,
Text,
Tooltip,
VStack,
} from "@chakra-ui/react";
import {
faArrowRightFromBracket,
faArrowRightToBracket,
faPhone,
faSave,
faTrash,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import dayjs from "dayjs";
import { useState } from "react";
import {
Phone,
PhoneIncoming,
PhoneOutgoing,
Save,
Trash2,
} from "react-feather";
import { CallHistory, SipCallDirection } from "src/common/types";
import { getSettings, isSaveCallHistory } from "src/storage";
import { formatPhoneNumber } from "src/utils";
@@ -36,11 +36,11 @@ export const CallHistoryItem = ({
const [callEnable, setCallEnable] = useState(false);
const getDirectionIcon = (direction: SipCallDirection) => {
if (direction === "outgoing") {
return PhoneOutgoing;
return faArrowRightFromBracket;
} else if (direction === "incoming") {
return PhoneIncoming;
return faArrowRightToBracket;
} else {
return Phone;
return faPhone;
}
};
@@ -57,7 +57,7 @@ export const CallHistoryItem = ({
<Tooltip label="Call">
<IconButton
aria-label="call recents"
icon={<Phone />}
icon={<FontAwesomeIcon icon={faPhone} />}
onClick={() => {
if (onCallNumber) {
onCallNumber(call.number, call.name);
@@ -69,7 +69,11 @@ export const CallHistoryItem = ({
/>
</Tooltip>
) : (
<Icon as={getDirectionIcon(call.direction)} w="20px" h="20px" />
<FontAwesomeIcon
icon={getDirectionIcon(call.direction)}
width="20px"
height="20px"
/>
)}
<VStack align="start">
@@ -88,7 +92,11 @@ export const CallHistoryItem = ({
<Tooltip label={isSaved && call.isSaved ? "Delete" : "Save"}>
<IconButton
aria-label="save recents"
icon={isSaved && call.isSaved ? <Trash2 /> : <Save />}
icon={
<FontAwesomeIcon
icon={isSaved && call.isSaved ? faTrash : faSave}
/>
}
onClick={() => {
if (!isSaved && call.isSaved) {
return;

View File

@@ -13,11 +13,12 @@ import {
TabPanel,
} from "@chakra-ui/react";
import { useState } from "react";
import { Search, Sliders } from "react-feather";
import { CallHistory } from "src/common/types";
import { DEFAULT_COLOR_SCHEME } from "src/common/constants";
import Recents from "./recent";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSearch, faSliders } from "@fortawesome/free-solid-svg-icons";
type CallHistoriesProbs = {
calls: CallHistory[];
@@ -51,11 +52,11 @@ export const CallHistories = ({
fontWeight="normal"
/>
<InputLeftElement mr={2}>
<Icon as={Search} w="20px" h="20px" />
<FontAwesomeIcon icon={faSearch} width="20px" height="20px" />
</InputLeftElement>
</InputGroup>
<HStack spacing={2} bg="grey.100" p={2} borderRadius={7}>
<Icon as={Sliders} w="20px" h="20px" />
<FontAwesomeIcon icon={faSliders} width="20px" height="20px" />
<Text fontSize="12px" fontWeight="500">
Filter
</Text>

View File

@@ -51,8 +51,9 @@ export const Recents = ({
spacing={2}
mt={2}
>
{callHistories.map((c) => (
{callHistories.map((c, i) => (
<CallHistoryItem
key={i}
isSaved={isSaved}
call={c}
onCallNumber={onCallNumber}

View File

@@ -4,9 +4,23 @@ export default class DialPadAudioElements {
constructor() {
const arr = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"];
for (const i of arr) {
this.keySounds[i] = new Audio(
chrome.runtime.getURL(`audios/dtmf-${encodeURIComponent(i)}.mp3`)
let audioURL;
// Check if we're in a Chrome extension
if (
typeof chrome !== "undefined" &&
chrome.runtime &&
chrome.runtime.getURL
) {
audioURL = chrome.runtime.getURL(
`audios/dtmf-${encodeURIComponent(i)}.mp3`
);
} else {
// We're in a web context, adjust this path as necessary
audioURL = `/audios/dtmf-${encodeURIComponent(i)}.mp3`;
}
this.keySounds[i] = new Audio(audioURL);
const audio = this.keySounds[i];
if (audio) {
audio.volume = 0.5;

View File

@@ -0,0 +1,194 @@
import {
Box,
Button,
Checkbox,
FormControl,
FormLabel,
HStack,
Input,
Radio,
RadioGroup,
Text,
VStack,
} from "@chakra-ui/react";
import { FormEvent, useEffect, useState } from "react";
import { updateConferenceParticipantAction } from "src/api";
import { ConferenceModes } from "src/api/types";
import OutlineBox from "src/components/outline-box";
import { SipConstants } from "src/lib";
type JoinConferenceProbs = {
conferenceId?: string;
callSid: string;
callDuration: number;
callStatus: string;
handleCancel: () => void;
call: (conference: string) => void;
};
export const JoinConference = ({
conferenceId,
callSid,
callDuration,
callStatus,
handleCancel,
call,
}: JoinConferenceProbs) => {
const [conferenceName, setConferenceName] = useState(conferenceId || "");
const [appTitle, setAppTitle] = useState(
!!conferenceId ? "Joining Conference" : "Start Conference"
);
const [submitTitle, setSubmitTitle] = useState(
!!conferenceId ? "Joining Conference" : "Start Conference"
);
const [cancelTitle, setCancelTitle] = useState("Cancel");
const [isLoading, setIsLoading] = useState(false);
const [speakOnlyTo, setSpeakOnlyTo] = useState("");
const [tags, setTags] = useState("");
const [mode, setMode] = useState<ConferenceModes>("full_participant");
const [participantState, setParticipantState] = useState("Join as");
useEffect(() => {
switch (callStatus) {
case SipConstants.SESSION_ANSWERED:
setAppTitle("Conference");
setSubmitTitle("Update");
setCancelTitle("Hangup");
setParticipantState("Participant state");
setIsLoading(false);
configureConferenceSession();
break;
case SipConstants.SESSION_ENDED:
case SipConstants.SESSION_FAILED:
setIsLoading(false);
break;
}
}, [callStatus]);
useEffect(() => {
switch (mode) {
case "full_participant":
break;
case "muted":
break;
case "coach":
break;
}
}, [mode]);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (callStatus !== SipConstants.SESSION_ANSWERED) {
call(conferenceName);
if (!callSid) {
setIsLoading(true);
}
} else {
configureConferenceSession();
}
};
const configureConferenceSession = async () => {
if (callSid) {
await updateConferenceParticipantAction(callSid, {
action: mode === "muted" ? "mute" : "unmute",
tag: "",
});
await updateConferenceParticipantAction(callSid, {
action: tags ? "tag" : "untag",
tag: tags,
});
await updateConferenceParticipantAction(callSid, {
action: mode === "coach" ? "coach" : "uncoach",
tag: speakOnlyTo,
});
}
};
return (
<Box as="form" onSubmit={handleSubmit} w="full">
<VStack spacing={4} mt="20px" w="full">
<Text fontWeight="bold" fontSize="lg">
{appTitle}
</Text>
{callDuration > 0 && (
<Text fontSize="15px">
{new Date(callDuration * 1000).toISOString().substr(11, 8)}
</Text>
)}
<FormControl id="conference_name">
<FormLabel>Conference name</FormLabel>
<Input
type="text"
placeholder="Name"
isRequired
value={conferenceName}
onChange={(e) => setConferenceName(e.target.value)}
disabled={!!conferenceId}
/>
</FormControl>
<OutlineBox title={participantState}>
<RadioGroup
onChange={(e) => setMode(e as ConferenceModes)}
value={mode}
colorScheme="jambonz"
>
<VStack align="start">
<Radio value="full_participant" variant="">
Full participant
</Radio>
<Radio value="muted">Muted</Radio>
<Radio value="coach">Coach mode</Radio>
</VStack>
</RadioGroup>
<FormControl id="speak_only_to">
<FormLabel>Speak only to</FormLabel>
<Input
type="text"
placeholder="tag"
value={speakOnlyTo}
onChange={(e) => setSpeakOnlyTo(e.target.value)}
disabled={mode !== "coach"}
required={mode === "coach"}
/>
</FormControl>
<FormControl id="tag">
<FormLabel>Tag</FormLabel>
<Input
type="text"
placeholder="tag"
value={tags}
onChange={(e) => setTags(e.target.value)}
/>
</FormControl>
</OutlineBox>
<HStack w="full">
<Button
colorScheme="jambonz"
type="submit"
w="full"
isLoading={isLoading}
>
{submitTitle}
</Button>
<Button
colorScheme="grey"
type="button"
w="full"
textColor="black"
onClick={handleCancel}
>
{cancelTitle}
</Button>
</HStack>
</VStack>
</Box>
);
};
export default JoinConference;

View File

@@ -53,10 +53,16 @@ export const DialPad = ({ handleDigitPress }: DialPadProbs) => {
}, []);
return (
<Box p={2} w="full" ref={selfRef}>
<VStack w="full" bg="grey.500" spacing={0.5}>
<Box p={2} w="full" h="280px" ref={selfRef}>
<VStack w="full" h="full" bg="grey.500" spacing={0.5}>
{buttons.map((row, rowIndex) => (
<HStack key={rowIndex} justifyContent="space-between" spacing={0.5}>
<HStack
key={rowIndex}
justifyContent="space-between"
spacing={0.5}
w="full"
h="full"
>
{row.map((num) => (
<Button
key={num}
@@ -66,8 +72,8 @@ export const DialPad = ({ handleDigitPress }: DialPadProbs) => {
}}
size="lg"
p={0}
width="124px"
height="70px"
width="calc(100% / 3)"
height="100%"
variant="unstyled"
bg="white"
_hover={{

View File

@@ -1,5 +1,14 @@
import { Button, VStack, Text, Icon, HStack, Spacer } from "@chakra-ui/react";
import { PhoneCall } from "react-feather";
import {
Button,
VStack,
Text,
Icon,
HStack,
Spacer,
Box,
} from "@chakra-ui/react";
import { faPhone } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatPhoneNumber } from "src/utils";
type IncommingCallProbs = {
@@ -15,7 +24,13 @@ export const IncommingCall = ({
}: IncommingCallProbs) => {
return (
<VStack alignItems="center" spacing={4} mt="130px" w="full">
<Icon as={PhoneCall} color="jambonz.500" w="60px" h="60px" />
<Box
as={FontAwesomeIcon}
icon={faPhone}
color="jambonz.500"
width="30px"
height="30px"
/>
<Text fontSize="15px">Incoming call from</Text>
<Text fontSize="24px" fontWeight="bold">
{formatPhoneNumber(number)}

View File

@@ -7,7 +7,6 @@ import {
IconButton,
Image,
Input,
Select,
Spacer,
Text,
Tooltip,
@@ -15,16 +14,6 @@ import {
useToast,
} from "@chakra-ui/react";
import { useEffect, useRef, useState } from "react";
import {
GitMerge,
List,
Mic,
MicOff,
Pause,
PhoneOff,
Play,
Users,
} from "react-feather";
import {
AdvancedAppSettings,
SipCallDirection,
@@ -53,6 +42,7 @@ import { v4 as uuidv4 } from "uuid";
import IconButtonMenu, { IconButtonMenuItems } from "src/components/menu";
import {
getApplications,
getConferences,
getQueues,
getRegisteredUser,
getSelfRegisteredUser,
@@ -60,6 +50,19 @@ import {
import JambonzSwitch from "src/components/switch";
import { DEFAULT_TOAST_DURATION } from "src/common/constants";
import { RegisteredUser } from "src/api/types";
import {
faCodeMerge,
faList,
faMicrophone,
faMicrophoneSlash,
faPause,
faPeopleGroup,
faPhoneSlash,
faPlay,
faUserGroup,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import JoinConference from "./conference";
type PhoneProbs = {
sipDomain: string;
@@ -72,6 +75,13 @@ type PhoneProbs = {
advancedSettings: AdvancedAppSettings;
};
enum PAGE_VIEW {
DIAL_PAD,
INCOMING_CALL,
OUTGOING_CALL,
JOIN_CONFERENCE,
}
export const Phone = ({
sipDomain,
sipServerAddress,
@@ -84,29 +94,17 @@ export const Phone = ({
}: PhoneProbs) => {
const [inputNumber, setInputNumber] = useState("");
const [appName, setAppName] = useState("");
const inputNumberRef = useRef(inputNumber);
const [status, setStatus] = useState<SipClientStatus>("stop");
const [isConfigured, setIsConfigured] = useState(false);
const [callStatus, setCallStatus] = useState(SipConstants.SESSION_ENDED);
const [sessionDirection, setSessionDirection] =
useState<SipCallDirection>("");
const sessionDirectionRef = useRef(sessionDirection);
const sipUA = useRef<SipUA | null>(null);
const timerRef = useRef<NodeJS.Timer | null>(null);
const [seconds, setSeconds] = useState(0);
const secondsRef = useRef(seconds);
const [isCallButtonLoading, setIsCallButtonLoading] = useState(false);
const [isAdvanceMode, setIsAdvancedMode] = useState(false);
const isRestartRef = useRef(false);
const sipDomainRef = useRef("");
const sipUsernameRef = useRef("");
const sipPasswordRef = useRef("");
const sipServerAddressRef = useRef("");
const sipDisplayNameRef = useRef("");
const [isSwitchingUserStatus, setIsSwitchingUserStatus] = useState(true);
const [isOnline, setIsOnline] = useState(false);
const unregisteredReasonRef = useRef("");
const isInputNumberFocusRef = useRef(false);
const [pageView, setPageView] = useState<PAGE_VIEW>(PAGE_VIEW.DIAL_PAD);
const [registeredUser, setRegisteredUser] = useState<Partial<RegisteredUser>>(
{
allow_direct_app_calling: false,
@@ -114,6 +112,23 @@ export const Phone = ({
allow_direct_user_calling: false,
}
);
const [selectedConference, setSelectedConference] = useState("");
const [callSid, setCallSid] = useState("");
const inputNumberRef = useRef(inputNumber);
const sessionDirectionRef = useRef(sessionDirection);
const sipUA = useRef<SipUA | null>(null);
const timerRef = useRef<NodeJS.Timer | null>(null);
const isRestartRef = useRef(false);
const sipDomainRef = useRef("");
const sipUsernameRef = useRef("");
const sipPasswordRef = useRef("");
const sipServerAddressRef = useRef("");
const sipDisplayNameRef = useRef("");
const unregisteredReasonRef = useRef("");
const isInputNumberFocusRef = useRef(false);
const secondsRef = useRef(seconds);
const toast = useToast();
useEffect(() => {
@@ -156,13 +171,36 @@ export const Phone = ({
if (isSipClientIdle(callStatus) && isCallButtonLoading) {
setIsCallButtonLoading(false);
}
switch (callStatus) {
case SipConstants.SESSION_RINGING:
if (sessionDirection === "incoming") {
setPageView(PAGE_VIEW.INCOMING_CALL);
} else {
setPageView(PAGE_VIEW.OUTGOING_CALL);
}
break;
case SipConstants.SESSION_ANSWERED:
if (!!selectedConference) {
setPageView(PAGE_VIEW.JOIN_CONFERENCE);
} else {
setPageView(PAGE_VIEW.DIAL_PAD);
}
break;
case SipConstants.SESSION_ENDED:
case SipConstants.SESSION_FAILED:
setSelectedConference("");
setPageView(PAGE_VIEW.DIAL_PAD);
break;
}
}, [callStatus]);
useEffect(() => {
if (calledANumber) {
if (
!(
calledANumber.startsWith("app-") || calledANumber.startsWith("queue-")
calledANumber.startsWith("app-") ||
calledANumber.startsWith("queue-") ||
calledANumber.startsWith("conference-")
)
) {
setInputNumber(calledANumber);
@@ -183,34 +221,14 @@ export const Phone = ({
}, [status]);
useEffect(() => {
setInterval(() => {
const timer = setInterval(() => {
fetchRegisterUser();
}, 10_000);
}, 10000);
return () => {
clearInterval(timer);
};
}, []);
// useEffect(() => {
// chrome.runtime.onMessage.addListener(function (request) {
// const msg = request as Message<any>;
// switch (msg.event) {
// case MessageEvent.Call:
// handleCallEvent(msg.data as Call);
// break;
// default:
// break;
// }
// });
// }, []);
// const handleCallEvent = (call: Call) => {
// if (!call.number) return;
// if (isSipClientIdle(callStatus)) {
// setIsCallButtonLoading(true);
// setInputNumber(call.number);
// sipUA.current?.call(call.number);
// }
// };
const fetchRegisterUser = () => {
getSelfRegisteredUser(sipUsernameRef.current)
.then(({ json }) => {
@@ -313,6 +331,7 @@ export const Phone = ({
setInputNumber(args.session.user);
});
sipClient.on(SipConstants.SESSION_ANSWERED, (args) => {
setCallSid(args.callSid);
const currentCall = getCurrentCall();
if (currentCall) {
currentCall.timeStamp = Date.now();
@@ -514,21 +533,7 @@ export const Phone = ({
Go to Settings to configure your account
</Heading>
)}
{isSipClientRinging(callStatus) ? (
sessionDirection === "incoming" ? (
<IncommingCall
number={inputNumber}
answer={handleAnswer}
decline={handleDecline}
/>
) : (
<OutGoingCall
number={inputNumber || appName}
cancelCall={handleDecline}
/>
)
) : (
{pageView === PAGE_VIEW.DIAL_PAD && (
<VStack
spacing={2}
w="full"
@@ -539,7 +544,7 @@ export const Phone = ({
<HStack spacing={2} align="start" w="full">
{registeredUser.allow_direct_user_calling && (
<IconButtonMenu
icon={<Users />}
icon={<FontAwesomeIcon icon={faUserGroup} />}
tooltip="Call an online user"
noResultLabel="No one else is online"
onClick={(_, value) => {
@@ -551,7 +556,9 @@ export const Phone = ({
(resolve, reject) => {
getRegisteredUser()
.then(({ json }) => {
const sortedUsers = json.sort((a, b) => a.localeCompare(b));
const sortedUsers = json.sort((a, b) =>
a.localeCompare(b)
);
resolve(
sortedUsers
.filter((u) => !u.includes(sipUsername))
@@ -573,7 +580,7 @@ export const Phone = ({
{registeredUser.allow_direct_queue_calling && (
<IconButtonMenu
icon={<List />}
icon={<FontAwesomeIcon icon={faList} />}
tooltip="Take a call from queue"
noResultLabel="No calls in queue"
onClick={(name, value) => {
@@ -587,7 +594,9 @@ export const Phone = ({
(resolve, reject) => {
getQueues()
.then(({ json }) => {
const sortedQueues = json.sort((a, b) => a.name.localeCompare(b.name));
const sortedQueues = json.sort((a, b) =>
a.name.localeCompare(b.name)
);
resolve(
sortedQueues.map((q) => ({
name: `${q.name} (${q.length})`,
@@ -604,7 +613,7 @@ export const Phone = ({
{registeredUser.allow_direct_app_calling && (
<IconButtonMenu
icon={<GitMerge />}
icon={<FontAwesomeIcon icon={faCodeMerge} />}
tooltip="Call an application"
noResultLabel="No applications"
onClick={(name, value) => {
@@ -618,7 +627,9 @@ export const Phone = ({
(resolve, reject) => {
getApplications()
.then(({ json }) => {
const sortedApps = json.sort((a, b) => a.name.localeCompare(b.name));
const sortedApps = json.sort((a, b) =>
a.name.localeCompare(b.name)
);
resolve(
sortedApps.map((a) => ({
name: a.name,
@@ -632,6 +643,44 @@ export const Phone = ({
}}
/>
)}
{registeredUser.allow_direct_app_calling && (
<IconButtonMenu
icon={<FontAwesomeIcon icon={faPeopleGroup} />}
tooltip="Join a conference"
noResultLabel="No conference"
onClick={(name, value) => {
setPageView(PAGE_VIEW.JOIN_CONFERENCE);
setSelectedConference(
value === PAGE_VIEW.JOIN_CONFERENCE.toString()
? ""
: value
);
}}
onOpen={() => {
return new Promise<IconButtonMenuItems[]>(
(resolve, reject) => {
getConferences()
.then(({ json }) => {
const sortedApps = json.sort((a, b) =>
a.localeCompare(b)
);
resolve([
{
name: "Start new conference",
value: PAGE_VIEW.JOIN_CONFERENCE.toString(),
},
...sortedApps.map((a) => ({
name: a,
value: a,
})),
]);
})
.catch((err) => reject(err));
}
);
}}
/>
)}
</HStack>
)}
@@ -680,7 +729,11 @@ export const Phone = ({
<IconButton
aria-label="Place call onhold"
icon={
sipUA.current?.isHolded(undefined) ? <Play /> : <Pause />
<FontAwesomeIcon
icon={
sipUA.current?.isHolded(undefined) ? faPlay : faPause
}
/>
}
w="33%"
variant="unstyled"
@@ -694,7 +747,7 @@ export const Phone = ({
<Spacer />
<IconButton
aria-label="Hangup"
icon={<PhoneOff />}
icon={<FontAwesomeIcon icon={faPhoneSlash} />}
w="70px"
h="70px"
borderRadius="100%"
@@ -708,7 +761,13 @@ export const Phone = ({
<IconButton
aria-label="Mute"
icon={
sipUA.current?.isMuted(undefined) ? <Mic /> : <MicOff />
<FontAwesomeIcon
icon={
sipUA.current?.isMuted(undefined)
? faMicrophone
: faMicrophoneSlash
}
/>
}
w="33%"
variant="unstyled"
@@ -722,6 +781,39 @@ export const Phone = ({
)}
</VStack>
)}
{pageView === PAGE_VIEW.INCOMING_CALL && (
<IncommingCall
number={inputNumber}
answer={handleAnswer}
decline={handleDecline}
/>
)}
{pageView === PAGE_VIEW.OUTGOING_CALL && (
<OutGoingCall
number={inputNumber || appName}
cancelCall={handleDecline}
/>
)}
{pageView === PAGE_VIEW.JOIN_CONFERENCE && (
<JoinConference
conferenceId={selectedConference}
callSid={callSid}
callDuration={seconds}
callStatus={callStatus}
handleCancel={() => {
if (isSipClientAnswered(callStatus)) {
sipUA.current?.terminate(480, "Call Finished", undefined);
}
setPageView(PAGE_VIEW.DIAL_PAD);
}}
call={(name) => {
const conference = `conference-${name}`;
setSelectedConference(name);
setInputNumber(conference);
makeOutboundCall(conference, `Conference ${name}`);
}}
/>
)}
</Center>
);
};

View File

@@ -1,5 +1,6 @@
import { Button, Icon, Text, VStack } from "@chakra-ui/react";
import { PhoneCall } from "react-feather";
import { Box, Button, Icon, Text, VStack } from "@chakra-ui/react";
import { faPhone } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatPhoneNumber } from "src/utils";
type OutGoingCallProbs = {
@@ -10,7 +11,13 @@ type OutGoingCallProbs = {
export const OutGoingCall = ({ number, cancelCall }: OutGoingCallProbs) => {
return (
<VStack alignItems="center" spacing={4} mt="130px" w="full">
<Icon as={PhoneCall} color="jambonz.500" w="60px" h="60px" />
<Box
as={FontAwesomeIcon}
icon={faPhone}
color="jambonz.500"
width="60px"
height="60px"
/>
<Text fontSize="15px">Dialing</Text>
<Text fontSize="24px" fontWeight="bold">
{formatPhoneNumber(number)}

View File

@@ -1,4 +1,5 @@
import {
Box,
Button,
FormControl,
FormLabel,
@@ -6,19 +7,22 @@ import {
Icon,
Image,
Input,
Spacer,
Text,
VStack,
} from "@chakra-ui/react";
import {
faCheckCircle,
faCircleXmark,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useEffect, useState } from "react";
import { CheckCircle, XCircle } from "react-feather";
import { getApplications } from "src/api";
import { AdvancedAppSettings } from "src/common/types";
import PasswordInput from "src/components/password-input";
import InfoIcon from "src/imgs/icons/Info.svg";
import ResetIcon from "src/imgs/icons/Reset.svg";
import { getAdvancedSettings, saveAddvancedSettings } from "src/storage";
import { normalizeUrl } from "src/utils";
import { useToken } from "@chakra-ui/react";
export const AdvancedSettings = () => {
const [apiKey, setApiKey] = useState("");
@@ -109,10 +113,10 @@ export const AdvancedSettings = () => {
</VStack>
{isAdvancedMode && (
<HStack w="full" mt={2} mb={2}>
<Icon
as={isCredentialOk ? CheckCircle : XCircle}
<Box
as={FontAwesomeIcon}
icon={isCredentialOk ? faCheckCircle : faCircleXmark}
color={isCredentialOk ? "green.500" : "red.500"}
boxSize={6}
/>
<Text
fontSize="14px"

View File

@@ -3,34 +3,34 @@ const HTMLPlugin = require("html-webpack-plugin");
const CopyPlugin = require("copy-webpack-plugin");
module.exports = [
{
entry: "./src/content/index.tsx",
target: "web",
mode: "production",
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", ".tsx", ".js"],
},
},
// {
// entry: "./src/content/index.tsx",
// target: "web",
// mode: "production",
// 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", ".tsx", ".js"],
// },
// },
{
entry: "./src/background/index.ts",
target: "web",
@@ -59,68 +59,6 @@ module.exports = [
extensions: [".ts", ".tsx", ".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: /\.txt$/i,
type: "asset/source",
},
],
},
plugins: [
new CopyPlugin({
patterns: [{ from: "public", to: ".." }],
}),
...getHtmlPlugins(["index"]),
],
resolve: {
alias: {
src: path.resolve(__dirname, "src/"),
},
extensions: [".tsx", ".ts", ".js", ".txt"],
},
output: {
path: path.join(__dirname, "dist/js"),
filename: "[name].js",
},
performance: {
maxEntrypointSize: 512000,
maxAssetSize: 512000,
},
},
{
entry: {
index: "./src/window/index.tsx",
@@ -162,7 +100,12 @@ module.exports = [
},
],
},
plugins: [...getHtmlPlugins(["index"])],
plugins: [
new CopyPlugin({
patterns: [{ from: "public", to: ".." }],
}),
...getHtmlPlugins(["index"]),
],
resolve: {
alias: {
src: path.resolve(__dirname, "src/"),